M9: cache final RTL label layouts

Reuse wrapped and shaped RTL galleys across frames using a bounded,
context-local cache keyed by the complete LayoutJob. Invalidate cached
results when egui replaces its font-cache token, including font, DPI and
atlas changes. Preserve existing wrapping and elision behavior.

Add regression coverage for cross-frame reuse, layout parameter changes,
font/scale/atlas invalidation and cache eviction, plus an opt-in benchmark.

Validation: cargo xtask ci passed fmt, clippy, 590 tests, assets and layers.
WASM checks are blocked by the missing wasm32-unknown-unknown standard
library in the local Homebrew Rust toolchain (E0463).
Arabic menu screenshots remained identical after ten additional frames.
Manual benchmark: 200 wrapped labels took 31.46 ms uncached vs 0.082 ms
with final-layout cache hits on this machine.
This commit is contained in:
Shao Wenbin committed 2026-10-07 19:45:38 +08:00
1 parent 7f0d94b24f
commit f52c591b87
2 files changed
+185 -3

No files matched your search

+1 -1
View File
@@ -206,7 +206,7 @@ same references), and from the app's JSON control channel. See [docs/agents.md](
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) |
| M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes, cross-references, index) |
| M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) |
| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, culling, parallel compose 6.7× faster, perf harness) |
| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, bounded RTL label layout cache, culling, parallel compose 6.7× faster, perf harness) |
| M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) |
| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge, step & repeat, snippets) |
| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) |
+184 -2
View File
@@ -2,7 +2,7 @@
//! Source strings stay logical. Never use these galleys for editable text/caret mapping.
use egui::text::{LayoutJob, TextWrapping};
use egui::{Align, Align2, Color32, FontFamily, FontId, Galley, Pos2, Rect, WidgetText};
use std::{ops::Range, sync::Arc};
use std::{collections::HashMap, ops::Range, sync::Arc};
use unicode_bidi::{BidiClass, BidiInfo, bidi_class};
use unicode_segmentation::UnicodeSegmentation;
@@ -138,7 +138,72 @@ fn visual_line(job: &LayoutJob, bidi: &BidiInfo<'_>, line: Range<usize>) -> Layo
visual
}
fn layout(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> {
const MAX_CACHED_LABELS: usize = 256;
const MAX_CACHED_TEXT_BYTES: usize = 4096;
#[derive(Clone)]
struct CachedLayout {
galley: Arc<Galley>,
last_used: u64,
}
#[derive(Clone, Default)]
struct LayoutCache {
// A tiny galley owned by egui's font cache acts as a lifetime token. Font changes,
// DPI changes, atlas rebuilds and eviction replace it. Holding the old Arc prevents
// address reuse. Unlike a font-definition hash, this also detects atlas resets.
font_token: Option<Arc<Galley>>,
entries: HashMap<u64, CachedLayout>,
clock: u64,
}
impl LayoutCache {
fn lookup(&mut self, token: Arc<Galley>, key: u64, job: &LayoutJob) -> Option<Arc<Galley>> {
if self.font_token.as_ref().is_none_or(|old| !Arc::ptr_eq(old, &token)) {
self.entries.clear();
self.font_token = Some(token);
}
self.clock = self.clock.saturating_add(1);
let entry = self.entries.get_mut(&key)?;
// Check equality as well as the hash: collisions must never display another label.
if *entry.galley.job != *job {
return None;
}
entry.last_used = self.clock;
Some(Arc::clone(&entry.galley))
}
fn insert(&mut self, key: u64, galley: Arc<Galley>) {
if self.entries.len() >= MAX_CACHED_LABELS
&& let Some(oldest) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used).map(|(key, _)| *key)
{
self.entries.remove(&oldest);
}
self.entries.insert(key, CachedLayout { galley, last_used: self.clock });
}
}
fn layout(ctx: &egui::Context, job: LayoutJob) -> Arc<Galley> {
if !may_have_rtl(&job.text) {
return ctx.fonts_mut(|fonts| fonts.layout_job(job));
}
// Keep unusually large/dynamic content from filling a UI-label cache.
if job.text.len() > MAX_CACHED_TEXT_BYTES || job.sections.len() > 256 {
return layout_uncached(ctx, job);
}
let token = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(String::new(), FontId::default(), Color32::TRANSPARENT));
let id = egui::Id::new("designcraft.rtl.layout-cache");
let key = egui::util::hash(&job);
let cached = ctx.data_mut(|data| data.get_temp_mut_or_default::<LayoutCache>(id).lookup(token, key, &job));
if let Some(galley) = cached {
return galley;
}
let galley = layout_uncached(ctx, job);
ctx.data_mut(|data| data.get_temp_mut_or_default::<LayoutCache>(id).insert(key, Arc::clone(&galley)));
galley
}
fn layout_uncached(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> {
if !has_rtl(&job.text) {
return ctx.fonts_mut(|fonts| fonts.layout_job(job));
}
@@ -246,6 +311,123 @@ mod tests {
galley.rows.iter().flat_map(|row| row.glyphs.iter().filter(|g| g.advance_width > 0.0).map(|g| g.chr)).collect()
}
fn cache_job() -> LayoutJob {
LayoutJob::simple("ملف (PDF 123) جديد للنشر والطباعة".repeat(4), FontId::proportional(15.0), Color32::WHITE, 150.0)
}
#[test]
fn cache_reuses_final_layout_across_frames_and_matches_uncached_output() {
with_fonts(|ctx| {
let job = cache_job();
let first = layout(ctx, job.clone());
let reference = layout_uncached(ctx, job.clone());
assert_eq!(displayed(&first), displayed(&reference));
assert_eq!(first.rect, reference.rect);
assert_eq!(first.rows.len(), reference.rows.len());
assert!(Arc::ptr_eq(&first, &layout(ctx, job.clone())));
ctx.end_pass().textures_delta.clear();
ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() });
assert!(Arc::ptr_eq(&first, &layout(ctx, job)));
});
}
#[test]
fn cache_respects_text_formatting_wrapping_and_elision() {
with_fonts(|ctx| {
let job = cache_job();
let first = layout(ctx, job.clone());
let mut variants = Vec::new();
let mut changed = job.clone();
changed.wrap.max_width = 80.0;
variants.push(changed);
let mut changed = job.clone();
changed.wrap.max_rows = 1;
variants.push(changed);
let mut changed = job.clone();
changed.sections[0].format.font_id.size = 24.0;
variants.push(changed);
let mut changed = job.clone();
changed.sections[0].format.color = Color32::RED;
variants.push(changed);
variants.push(LayoutJob::simple_singleline("עברית 123".into(), FontId::default(), Color32::WHITE));
for changed in variants {
let cached = layout(ctx, changed.clone());
let reference = layout_uncached(ctx, changed);
assert!(!Arc::ptr_eq(&first, &cached));
assert_eq!(cached.job, reference.job);
assert_eq!(cached.rect, reference.rect);
assert_eq!(cached.elided, reference.elided);
assert_eq!(displayed(&cached), displayed(&reference));
}
});
}
#[test]
fn cache_invalidates_when_fonts_or_scale_change() {
with_fonts(|ctx| {
let job = cache_job();
let first = layout(ctx, job.clone());
ctx.end_pass().textures_delta.clear();
ctx.set_pixels_per_point(2.0);
ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() });
let scaled = layout(ctx, job.clone());
assert!(!Arc::ptr_eq(&first, &scaled));
ctx.end_pass().textures_delta.clear();
let mut fonts = ctx.fonts(|fonts| fonts.definitions().clone());
fonts.families.insert(FontFamily::Name("cache-test".into()), Vec::new());
ctx.set_fonts(fonts);
ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() });
let new_fonts = layout(ctx, job.clone());
assert!(!Arc::ptr_eq(&scaled, &new_fonts));
ctx.end_pass().textures_delta.clear();
// Changing atlas options recreates the atlas without changing font definitions.
ctx.begin_pass(egui::RawInput { max_texture_side: Some(4096), ..Default::default() });
let new_atlas = layout(ctx, job.clone());
assert!(!Arc::ptr_eq(&new_fonts, &new_atlas));
assert_eq!(displayed(&new_atlas), displayed(&layout_uncached(ctx, job)));
});
}
#[test]
fn cache_is_bounded_and_font_token_replacement_invalidates_entries() {
with_fonts(|ctx| {
let job = cache_job();
let galley = layout_uncached(ctx, job.clone());
let token = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(String::new(), FontId::default(), Color32::TRANSPARENT));
let mut cache = LayoutCache::default();
cache.lookup(token, 0, &job);
for key in 0..=MAX_CACHED_LABELS as u64 {
cache.clock += 1;
cache.insert(key, Arc::clone(&galley));
}
assert_eq!(cache.entries.len(), MAX_CACHED_LABELS);
assert!(!cache.entries.contains_key(&0));
// Replacing the font-cache token models atlas recreation, even with identical fonts.
let replacement = Arc::new((*galley).clone());
assert!(cache.lookup(replacement, 1, &job).is_none());
assert!(cache.entries.is_empty());
});
}
#[test]
#[ignore = "manual timing comparison, no machine-dependent pass threshold"]
fn benchmark_cached_wrapping() {
with_fonts(|ctx| {
let job = cache_job();
let _ = layout(ctx, job.clone());
let start = std::time::Instant::now();
for _ in 0..200 {
std::hint::black_box(layout_uncached(ctx, job.clone()));
}
let uncached = start.elapsed();
let start = std::time::Instant::now();
for _ in 0..200 {
std::hint::black_box(layout(ctx, job.clone()));
}
eprintln!("200 wrapped labels: uncached={uncached:?}, cached={:?}", start.elapsed());
});
}
#[test]
fn ordinary_multilingual_labels_skip_bidi_analysis() {
for text in ["", "File (123)", "Édition", "中文排版", "スウォッチ", "e\u{301}", "🎨 🙂"] {