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 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-27 13:39:09 +01:00
co-authored by Claude Opus 5.5
parent 8cdc172171
commit ffa127c615
10 changed files with 86 additions and 9 deletions
+21
View File
@@ -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<usize> {
// 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<usize> = Vec::new();
let mut prev: Option<char> = 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<usize> { 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 រ.
+12 -1
View File
@@ -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();
+12 -4
View File
@@ -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<std::ops::Range<usize>> = 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<std::ops::Range<usize>> = 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
+16
View File
@@ -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();
+4
View File
@@ -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<bool> {
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),
+2 -1
View File
@@ -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).
+2 -2
View File
@@ -110,8 +110,6 @@ impl Exporter<'_> {
let placed: Vec<(u32, f32, f32, std::ops::Range<usize>)> =
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<KrillaGlyph> = 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)));
+4
View File
@@ -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<f32> {
let &(_, _, first) = glyphs.first()?;
if glyphs.iter().all(|g| g.2.to_bits() == first.to_bits()) {
return Some(first);
}
let mut counts: HashMap<u32, usize> = HashMap::new();
for (_, _, y) in glyphs {
*counts.entry(y.to_bits()).or_insert(0) += 1;
+1 -1
View File
@@ -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};
+12
View File
@@ -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));
}