From ffa127c615ffe8477187f36f0c2bdbc7de7f6e80 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:39:09 +0100 Subject: [PATCH] Skip per-character table lookups for plain text in layout and PDF export Shortcuts that give the same answers as the lookups they skip: - is_rtl: nothing before U+0590 is right to left, so Latin, Greek and Cyrillic text skips the bidi class search. - strong_rtl and is_mark: ASCII letters are the only strong ASCII characters, and no ASCII character is a mark. - cluster_starts: in ASCII text every character starts a grapheme except LF after CR, and no ASCII character joins a cluster. - display lines: a glyph run's vectors and text are sized up front, and the symbol-font check is made once per run instead of once per cluster. - PDF glyph runs: the baseline (the y most glyphs share) is only worked out for italic runs, the only ones that use it, and a run on one baseline returns it without counting. Tests check each shortcut against the full lookup (every code point for the bidi classes, every ASCII pair for the clusters). PDF export of the export_bench document, interleaved with main: median 293 ms -> 241 ms (-18 % with the previous commit). The PDF bytes are identical. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/cluster.rs | 21 +++++++++++++++++++++ crates/fonts/src/lib.rs | 13 ++++++++++++- crates/layout/src/display/lines.rs | 16 ++++++++++++---- crates/layout/src/para.rs | 16 ++++++++++++++++ crates/layout/src/para/bidi.rs | 4 ++++ crates/layout/src/para/chars.rs | 3 ++- crates/pdf/src/exporter/text.rs | 4 ++-- crates/pdf/src/glyphs.rs | 4 ++++ crates/pdf/src/lib.rs | 2 +- crates/pdf/src/tests.rs | 12 ++++++++++++ 10 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/cluster.rs b/crates/doc/src/cluster.rs index b41a8fcc4..186b79f9d 100644 --- a/crates/doc/src/cluster.rs +++ b/crates/doc/src/cluster.rs @@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool { /// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the /// first is 0 for non-empty text. pub fn cluster_starts(text: &str) -> Vec { + // ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29). + if text.is_ascii() { + let b = text.as_bytes(); + return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect(); + } let mut out: Vec = Vec::new(); let mut prev: Option = None; for (i, g) in text.grapheme_indices(true) { @@ -188,6 +193,22 @@ mod tests { assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]); } + /// The ASCII shortcut gives the grapheme rules' answer. + #[test] + fn ascii_cluster_starts_match_the_graphemes() { + let graphemes = |t: &str| -> Vec { t.grapheme_indices(true).map(|(i, _)| i).collect() }; + let all: String = (0u8..128).map(char::from).collect(); + for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] { + assert_eq!(cluster_starts(t), graphemes(t), "{t:?}"); + } + for a in 0u8..128 { + for b in 0u8..128 { + let t = format!("{}{}", char::from(a), char::from(b)); + assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}"); + } + } + } + #[test] fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() { // ខ្មែរ: ខ + coeng + ម + ែ, then រ. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index d8e6acf7d..59d6295aa 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -133,7 +133,9 @@ pub fn feature_value(tag: &[u8; 4], value: u32) -> Feature { /// A strong right-to-left character (Hebrew, Arabic, …)? pub fn is_rtl(c: char) -> bool { use unicode_bidi::BidiClass::{AL, R}; - matches!(unicode_bidi::bidi_class(c), R | AL) + // Nothing before the Hebrew block is right to left: Latin, Greek and Cyrillic text skips the + // table search. + c >= '\u{590}' && matches!(unicode_bidi::bidi_class(c), R | AL) } /// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left). @@ -254,6 +256,15 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { mod tests { use super::*; + /// The shortcut before the Hebrew block gives the bidi class's answer for every character. + #[test] + fn is_rtl_matches_the_bidi_class() { + use unicode_bidi::BidiClass::{AL, R}; + for c in (0..=0x10ffff).filter_map(char::from_u32) { + assert_eq!(is_rtl(c), matches!(unicode_bidi::bidi_class(c), R | AL), "{:04x}", c as u32); + } + } + #[test] fn last_resort_face_parses() { let f = fontdb::last_resort_face(); diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index b64899985..0d253f920 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -156,9 +156,17 @@ pub(super) fn lines( k += 1; continue; }; - let mut glyphs = Vec::new(); - let mut text = String::new(); - let mut ranges: Vec> = Vec::new(); + // Sized for the run's text clusters up front: no growing per glyph. + let run = pl.clusters.get(k..line.c1.min(pl.clusters.len())).unwrap_or_default(); + let (n_glyphs, n_bytes) = run + .iter() + .take_while(|c| c.style == si) + .filter(|c| matches!(c.kind, ClKind::Text | ClKind::Space)) + .fold((0usize, 0usize), |(g, b), c| (g + c.g1.saturating_sub(c.g0) as usize, b + c.end.saturating_sub(c.start))); + let mut glyphs = Vec::with_capacity(n_glyphs); + let mut text = String::with_capacity(n_bytes); + let mut ranges: Vec> = Vec::with_capacity(n_glyphs); + let symbol_font = wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font); let run_start = k; while k < line.c1 { let Some(c) = pl.clusters.get(k) else { break }; @@ -184,7 +192,7 @@ pub(super) fn lines( } else if let Some(p) = para { let t = p.text.get(c.start..c.end).unwrap_or(""); // Symbol-font characters (Wingdings…) read as the Unicode symbols drawn. - if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) { + if symbol_font { text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t)); } else if st.rc.caps || st.rc.small_caps { // All Caps and Small Caps read as the capitals drawn (#1473), mapped diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 24db10314..25dd940a6 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -423,6 +423,22 @@ mod label_spacing_tests { mod tests { use super::*; + /// The ASCII shortcuts give the bidi class's answer for every character. + #[test] + fn strong_and_mark_shortcuts_match_the_bidi_class() { + use unicode_bidi::BidiClass::{AL, BN, L, NSM, R}; + for c in (0..=0x10ffff).filter_map(char::from_u32) { + let class = unicode_bidi::bidi_class(c); + let strong = match class { + R | AL => Some(true), + L => Some(false), + _ => None, + }; + assert_eq!(bidi::strong_rtl(c), strong, "{:04x}", c as u32); + assert_eq!(chars::is_mark(c), matches!(class, NSM | BN) && !c.is_control(), "{:04x}", c as u32); + } + } + #[test] fn symbols_ignore_roman_hint() { let mut d = Document::new(); diff --git a/crates/layout/src/para/bidi.rs b/crates/layout/src/para/bidi.rs index d62af4e7f..62bee16d3 100644 --- a/crates/layout/src/para/bidi.rs +++ b/crates/layout/src/para/bidi.rs @@ -15,6 +15,10 @@ pub(super) enum Dir<'l> { /// The direction of `c` if it is a strong letter (UAX #9 P2): `Some(true)` right to left. pub(super) fn strong_rtl(c: char) -> Option { use unicode_bidi::BidiClass::{AL, L, R}; + // In ASCII only the letters are strong (L). + if c.is_ascii() { + return c.is_ascii_alphabetic().then_some(false); + } match unicode_bidi::bidi_class(c) { R | AL => Some(true), L => Some(false), diff --git a/crates/layout/src/para/chars.rs b/crates/layout/src/para/chars.rs index 5302f44cc..ba039850a 100644 --- a/crates/layout/src/para/chars.rs +++ b/crates/layout/src/para/chars.rs @@ -58,7 +58,8 @@ pub(super) fn caps_expands(c: char) -> bool { /// character before them: same font, same shaping run, so Arabic joining isn't broken. pub(super) fn is_mark(c: char) -> bool { use unicode_bidi::BidiClass::{BN, NSM}; - matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control() + // No ASCII character is a mark (its BN characters are controls). + !c.is_ascii() && matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control() } /// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669). diff --git a/crates/pdf/src/exporter/text.rs b/crates/pdf/src/exporter/text.rs index d92d4891c..fff476667 100644 --- a/crates/pdf/src/exporter/text.rs +++ b/crates/pdf/src/exporter/text.rs @@ -110,8 +110,6 @@ impl Exporter<'_> { let placed: Vec<(u32, f32, f32, std::ops::Range)> = order.iter().filter_map(|&i| Some((glyphs.get(i).copied()?, ranges.get(i).cloned()?))).map(|((g, x, y), r)| (g, x, y, r)).collect(); let Some(&(_, x0, y0, _)) = placed.first() else { return }; - // The run's baseline: the y most of its glyphs share (marks sit higher or lower). - let base = baseline(glyphs).unwrap_or(y0); let kg: Vec = placed .iter() .enumerate() @@ -125,6 +123,8 @@ impl Exporter<'_> { .collect(); let pushed = italic; if italic { + // The run's baseline: the y most of its glyphs share (marks sit higher or lower). + let base = baseline(glyphs).unwrap_or(y0); s.push_transform(&Transform::from_row(1.0, 0.0, -0.21, 1.0, 0.21 * base, 0.0)); } s.set_fill(Some(fill(color, alpha))); diff --git a/crates/pdf/src/glyphs.rs b/crates/pdf/src/glyphs.rs index ea6264156..bad9a1b21 100644 --- a/crates/pdf/src/glyphs.rs +++ b/crates/pdf/src/glyphs.rs @@ -116,6 +116,10 @@ pub(crate) fn visual_order( /// The y most of a run's glyphs share: its baseline, which raised or lowered marks leave. pub(crate) fn baseline(glyphs: &[(u32, f32, f32)]) -> Option { + let &(_, _, first) = glyphs.first()?; + if glyphs.iter().all(|g| g.2.to_bits() == first.to_bits()) { + return Some(first); + } let mut counts: HashMap = HashMap::new(); for (_, _, y) in glyphs { *counts.entry(y.to_bits()).or_insert(0) += 1; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 0363fc139..78a27d35e 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -65,7 +65,7 @@ pub use export::{export, export_layout}; #[cfg(test)] use exporter::Exporter; #[cfg(test)] -use glyphs::{cluster_ends, left_to_right, split_clusters, visual_order}; +use glyphs::{baseline, cluster_ends, left_to_right, split_clusters, visual_order}; #[cfg(test)] use metadata::{civil, parse_iso}; pub use options::{PdfError, PdfOptions}; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f6548d9c7..c8f1bec54 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2450,3 +2450,15 @@ fn cluster_ends_match_the_next_larger_cluster_in_linear_time() { assert!(ends[..=n].iter().all(|e| *e == 2 * n + 2)); assert_eq!(ends.last(), Some(&(2 * n + 4))); } + +/// A run's baseline is the y most of its glyphs share, ties to the lower bits; a run on one +/// baseline (the common case) gives its y without counting. +#[test] +fn baseline_is_the_most_shared_y() { + assert_eq!(baseline(&[]), None); + assert_eq!(baseline(&[(1, 0.0, 7.5)]), Some(7.5)); + assert_eq!(baseline(&[(1, 0.0, 7.5), (2, 3.0, 7.5), (3, 6.0, 7.5)]), Some(7.5)); + assert_eq!(baseline(&[(1, 0.0, 7.5), (2, 3.0, 4.0), (3, 6.0, 7.5)]), Some(7.5)); + assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0)); + assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0)); +}