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)); +}