mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 14:27:16 -06:00
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:
co-authored by
Claude Opus 5.5
parent
8cdc172171
commit
ffa127c615
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user