From 5307510f665ae03ae3eea60ee6b98114566f9f91 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 17:08:09 +0800 Subject: [PATCH 01/13] M3: copied and found text keeps each combining mark after its letter (view.copy-text, view.find) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text drawn one glyph per show operation, with zero-advance Thai tone marks starting a hair past their base's advance, came out with the mark after the next letter ("ห้อง" read "หอ้ง") because a line's glyphs were sorted by x alone. A combining mark (Mn/Me) of a left-to-right script drawn right after the glyph it sits over now rides on that glyph: placement sorts clusters, so the mark keeps its content-stream place. Word gaps are measured between clusters, and the tight-cluster rule now guards only loose combining signs, so a Thai or Indic word gap with no space glyph reads as a space. Fixes #6517 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- Cargo.lock | 1 + crates/render/Cargo.toml | 3 + crates/render/src/text/layout.rs | 65 ++++++++++++++++++--- crates/render/src/text/order.rs | 63 ++++++++++++++++++-- crates/render/src/text/tests.rs | 99 ++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 8 +-- 6 files changed, 223 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8617c45522..f2b0918811 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4652,6 +4652,7 @@ dependencies = [ "serde", "skrifa 0.44.0", "thiserror 2.0.21", + "unicode-bidi", ] [[package]] diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 71467dfeb2..3f929176ba 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -23,6 +23,9 @@ log = { workspace = true } serde = { workspace = true } # Reads rendered pixel words as bytes; already in the tree through hayro. bytemuck = { version = "1" } +# Tells combining marks (bidi class NSM: general categories Mn and Me) in extracted text; the +# version pdfcraft-fonts already uses. +unicode-bidi = "0.3" # lopdf (bootstrap) draws random bytes for encryption; on the web they come from the browser. [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 99afc0f8a3..8d9aa9f12b 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,5 +1,7 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; -use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, place_glyphs, redraws, restarts_run, reverse_rtl}; +use super::order::{ + NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run, reverse_rtl, +}; use super::{HashMap, PageText, TextGlyph}; /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. @@ -97,11 +99,49 @@ pub(super) fn is_rtl(c: char) -> bool { } /// Scripts written with explicit spaces whose combining signs can sit apart from their base -/// (Indic, Thai, Lao, Myanmar, Khmer): never infer a space inside a tight cluster. +/// (Indic, Thai, Lao, Myanmar, Khmer): never infer a space next to such a sign in a tight cluster. fn is_complex(c: char) -> bool { matches!(c as u32, 0x0300..=0x036F | 0x0900..=0x0DFF | 0x0E00..=0x0EFF | 0x1000..=0x109F | 0x1780..=0x17FF) } +/// Whether `text` is only combining marks (Unicode Mn and Me, which take bidi class NSM) of a +/// left-to-right script: Thai tone marks and above or below vowels, Indic signs such as virama, +/// Latin combining accents. Right-to-left marks are ordered with their script by [`reverse_rtl`]. +fn is_mark(text: &str) -> bool { + !text.is_empty() && text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM && !is_rtl(c)) +} + +/// How far from the glyph before it, as a fraction of its height, a combining mark may start and +/// still ride on it. A zero-advance mark starts at the pen after its base, give or take a small +/// mark-positioning offset, and its ink is drawn back over the base. +const MARK_REACH: f32 = 0.3; + +/// Whether glyph `m`, drawn right after `p` in one run, is a combining mark on `p`: ISO 32000-2 +/// §9.10 leaves extraction to the processor, and Unicode stores a mark after its base, as the +/// content stream draws it here. The mark then stays after `p` in reading order however far its +/// own box sits along the line; sorting by x alone moved a Thai tone mark that starts a hair past +/// its base's advance after the next letter (#6517). +fn rides_on(p: &TextGlyph, m: &TextGlyph) -> bool { + let reach = MARK_REACH * (m.rect[3] - m.rect[1]).max(0.1); + is_mark(&m.text) + && p.text.chars().any(|c| !c.is_whitespace()) + && !p.text.chars().any(is_rtl) + && m.rect[0] >= p.rect[0] - reach + && m.rect[0] <= p.rect[2] + reach +} + +/// Glyph `i` continues the run of glyph `i - 1`: when it is a combining mark on that glyph, it joins its cluster. +fn attach_mark(glyphs: &[&TextGlyph], anchor: &mut [usize], i: usize) { + let Some(p) = i.checked_sub(1) else { return }; + if let (Some(pg), Some(mg)) = (glyphs.get(p), glyphs.get(i)) + && rides_on(pg, mg) + && let Some(&a) = anchor.get(p) + && let Some(slot) = anchor.get_mut(i) + { + *slot = a; + } +} + fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x3134F | 0x1100..=0x11FF) } @@ -173,6 +213,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { // 1. Segments in content order. Every run is a range of consecutive glyphs. let mut segs: Vec = Vec::new(); + // `anchor[i]`: the glyph a combining mark rides on (the first glyph of its cluster), or `i`. + let mut anchor: Vec = (0..n).collect(); let mut run_start_x = 0.0; let mut furthest_progress = 0.0; for i in 0..n { @@ -196,6 +238,7 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { && !reset && let Some(s) = segs.last_mut() { + attach_mark(&glyphs, &mut anchor, i); s.first.end = i + 1; s.extend_box(&g, hi); furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs()); @@ -392,7 +435,7 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let mut line_of: Vec = Vec::with_capacity(n); let mut space_before: Vec = Vec::with_capacity(n); let mut line = 0u32; - let mut sc = Scratch::default(); + let mut sc = Scratch { anchor, ..Default::default() }; // `spaces[w]`: a word gap left of the w-th glyph of the segment, by position on the line, not by drawing order // (#1062). Reused by every segment. let mut spaces: Vec = Vec::new(); @@ -400,12 +443,16 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { for &si in &blocks[b].0 { place_glyphs(&glyphs, &height, &segs[si], &mut sc); let h = segs[si].h; + measure_cluster_gaps(&glyphs, &mut sc); + // The text of the cluster that glyph `i` belongs to: its base's. + let cluster_text = |i: usize| sc.anchor.get(i).and_then(|&a| glyphs.get(a)).map_or("", |g| g.text.as_str()); // Word gaps relative to this line's typical letter gap (handles tracking). // Long runs of repeated punctuation belong to leaders, not letter tracking: a TOC leader // must not raise the threshold above the word gaps in its title (#3086). sc.gaps.clear(); sc.gaps.extend(sc.placed.windows(2).enumerate().filter_map(|(w, pair)| { let &[p, c] = pair else { return None }; + let gap = (*sc.cluster_gap.get(w + 1)?)?; let (p, c) = (glyphs.get(p)?, glyphs.get(c)?); if p.text == c.text && p.text.chars().all(|ch| !ch.is_alphanumeric() && !ch.is_whitespace()) { // Keep short tracked punctuation, including an ellipsis with a final period. @@ -418,7 +465,7 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { return None; } } - Some(c.rect[0] - p.rect[2]) + Some(gap) })); sc.gaps.sort_by(f32::total_cmp); let typical = sc.gaps.get(sc.gaps.len() / 3).copied().unwrap_or(0.0).max(0.0); @@ -427,10 +474,14 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { spaces.resize(sc.placed.len(), false); for (w, pair) in sc.placed.windows(2).enumerate() { let &[p, c] = pair else { continue }; - let gap = glyphs[c].rect[0] - glyphs[p].rect[2]; - let (pt, ct) = (&glyphs[p].text, &glyphs[c].text); + let Some(&Some(gap)) = sc.cluster_gap.get(w + 1) else { continue }; + let (pt, ct) = (cluster_text(p), cluster_text(c)); let cjk = pt.chars().any(is_cjk) && ct.chars().any(is_cjk) && gap < h * 0.5; - let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < h * 0.6; + // A combining sign that rides on no glyph before it (drawn apart from its base, or in a run of its own) + // may sit anywhere over its base, so it never takes a space next to it. Two base letters of these + // scripts are measured like any others: a Thai or Indic word gap is narrower than half an em. + let loose_sign = |t: &str| is_mark(t) && t.chars().any(is_complex); + let tight_cluster = (loose_sign(pt) || loose_sign(ct)) && gap < h * 0.6; let run_start = sc.breaks.binary_search(&c).is_ok(); if let Some(space) = spaces.get_mut(w + 1) { *space = (gap > threshold || run_start) && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty(); diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index efcabb5f1a..0a9c91de24 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -152,6 +152,45 @@ pub(super) struct Scratch { pub(super) logical: Vec, /// `placed` with the marks merged in, built before it is swapped in. pub(super) merged: Vec, + /// For each glyph of the page, the glyph a combining mark drawn right after it rides on (the start of its cluster), + /// or the glyph itself. Placement sorts clusters, so a mark keeps its content-stream place after its base (#6517). + /// A glyph past its end is its own anchor. + pub(super) anchor: Vec, + /// The gap left of each glyph of `placed`, between clusters; `None` for the first glyph and for a riding mark. + pub(super) cluster_gap: Vec>, +} + +/// Fills `sc.cluster_gap` with the gap left of each glyph of `sc.placed`, between clusters: a combining mark riding on a +/// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge or the furthest mark start. +/// A zero-advance mark's box is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em). +pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) { + sc.cluster_gap.clear(); + let mut right = f32::NEG_INFINITY; + for (w, &c) in sc.placed.iter().enumerate() { + let Some(g) = glyphs.get(c) else { + sc.cluster_gap.push(None); + continue; + }; + if anchor_of(&sc.anchor, c) != c { + right = right.max(g.rect[0]); + sc.cluster_gap.push(None); + } else { + sc.cluster_gap.push((w > 0).then_some(g.rect[0] - right)); + right = g.rect[2]; + } + } +} + +/// The glyph that glyph `i`'s cluster starts at ([`Scratch::anchor`]). +fn anchor_of(anchor: &[usize], i: usize) -> usize { + anchor.get(i).copied().unwrap_or(i) +} + +/// Orders glyphs by the x of their cluster's first glyph, keeping each cluster together and in content order. +fn by_cluster(glyphs: &[&TextGlyph], anchor: &[usize], a: usize, b: usize) -> std::cmp::Ordering { + let (ka, kb) = (anchor_of(anchor, a), anchor_of(anchor, b)); + let x = |i: usize| glyphs.get(i).map_or(0.0, |g| g.rect[0]); + x(ka).total_cmp(&x(kb)).then(ka.cmp(&kb)).then(a.cmp(&b)) } /// One run of a colliding component. @@ -261,7 +300,7 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, if sc.text.len() <= 1 { // A single run cannot collide with itself: its glyphs only need sorting by x. sc.placed.extend(sc.text.iter().flat_map(|run| run.range())); - sc.placed.sort_unstable_by(|a, b| x0(*a).total_cmp(&x0(*b)).then(a.cmp(b))); + sc.placed.sort_unstable_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b)); } else { collide(glyphs, height, s.h, sc); } @@ -272,7 +311,7 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, sc.merged.clear(); let mut next = 0; for &m in &sc.marks { - while next < sc.placed.len() && x0(sc.placed[next]) <= x0(m) { + while next < sc.placed.len() && x0(anchor_of(&sc.anchor, sc.placed[next])) <= x0(m) { sc.merged.push(sc.placed[next]); next += 1; } @@ -296,17 +335,31 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f for (r, run) in sc.text.iter().enumerate() { sc.owner.extend(run.range().map(|i| (i, r))); } - sc.owner.sort_unstable_by(|a, b| x0(a.0).total_cmp(&x0(b.0)).then(a.0.cmp(&b.0))); + sc.owner.sort_unstable_by(|a, b| by_cluster(glyphs, &sc.anchor, a.0, b.0)); sc.parent.clear(); sc.parent.extend(0..runs); let mut budget = COLLIDE_PAIRS_PER_GLYPH.saturating_mul(sc.owner.len()); let mut collided = false; let mut dense = false; + // A mark riding on a glyph goes with it, so only cluster starts are swept. They are in x order in `owner`. + let rides = |i: usize| anchor_of(&sc.anchor, i) != i; 'sweep: for a in 0..sc.owner.len() { let (i, ri) = sc.owner[a]; + if rides(i) { + continue; + } let (x1, hi) = (glyphs[i].rect[2], height(i)); for b in a + 1..sc.owner.len() { let (j, rj) = sc.owner[b]; + if rides(j) { + // Skipping counts against the budget, so a long string of marks cannot make the sweep quadratic. + if budget == 0 { + dense = true; + break 'sweep; + } + budget -= 1; + continue; + } if x0(j) >= x1 { break; } @@ -357,7 +410,7 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f for i in sc.text[head].range() { let at = sc.flat.len(); sc.flat.push(i); - sc.units.push(Unit { left: x0(i), first: i, glyphs: at..at + 1 }); + sc.units.push(Unit { left: x0(anchor_of(&sc.anchor, i)), first: i, glyphs: at..at + 1 }); } continue; } @@ -368,7 +421,7 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f let run = sc.text[m]; let start = sc.group.len(); sc.group.extend(run.range()); - sc.group[start..].sort_unstable_by(|a, b| x0(*a).total_cmp(&x0(*b)).then(a.cmp(b))); + sc.group[start..].sort_unstable_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b)); let left = sc.group[start..].iter().map(|&i| x0(i)).fold(f32::INFINITY, f32::min); let right = sc.group[start..].iter().map(|&i| glyphs[i].rect[2]).fold(f32::NEG_INFINITY, f32::max); let lead = sc.group[start]; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a1af42a8d0..b5dd72bb9f 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1965,3 +1965,102 @@ fn text_outside_the_crop_box_is_clipped() { assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text()); } } + +/// One glyph per text object, as a CAD or word-processor PDF writer draws shaped text: `text` in Helvetica +/// remapped by `/ToUnicode`, base letters 600 units wide, combining marks 0 wide and placed at the pen after their +/// base shifted by `mark_dx` points (a mark-positioning offset), words 260 units apart, with a space glyph or +/// without one. +fn one_glyph_per_show(text: &str, mark_dx: f32, space_glyphs: bool) -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let is_mark = |c: char| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM; + let mut chars: Vec = Vec::new(); + for c in text.chars() { + if !chars.contains(&c) { + chars.push(c); + } + } + let code = |c: char| 0x21 + chars.iter().position(|&k| k == c).unwrap(); + let (size, base, space) = (20.0_f32, 600.0_f32, 260.0_f32); + let mut ops = String::new(); + let mut x = 50.0_f32; + for c in text.chars() { + let at = if is_mark(c) { x + mark_dx } else { x }; + if c != ' ' || space_glyphs { + ops.push_str(&format!("BT /F1 {size} Tf 1 0 0 1 {at:.3} 300 Tm <{:02X}> Tj ET\n", code(c))); + } + if c == ' ' { + x += space * size / 1000.0; + } else if !is_mark(c) { + x += base * size / 1000.0; + } + } + let width = |c: char| { + if is_mark(c) { + 0 + } else if c == ' ' { + 260 + } else { + 600 + } + }; + let widths: Vec = chars.iter().map(|&c| Object::Integer(width(c))).collect(); + let pairs: String = chars.iter().map(|&c| format!("<{:02X}> <{:04X}>\n", code(c), u32::from(c))).collect(); + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /T def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange {} beginbfchar\n{pairs}endbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + chars.len() + ); + let mut doc = Document::with_version("1.7"); + let unicode = doc.add_object(Stream::new(dictionary! {}, cmap.into_bytes())); + let mut differences = vec![Object::Integer(0x21)]; + differences.extend(chars.iter().map(|_| Object::Name(b"a".to_vec()))); + let font = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "FirstChar" => 0x21, "LastChar" => 0x20 + chars.len() as i64, + "Widths" => widths, "Encoding" => dictionary! { "Type" => "Encoding", "Differences" => differences }, "ToUnicode" => unicode + }); + let content = doc.add_object(Stream::new(dictionary! {}, ops.into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(600), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() +} + +/// #6517: Thai drawn one glyph per show operation, each zero-advance tone mark or above vowel starting a hair past +/// its base's advance (or exactly at it, or back over the base), reads in content order: no mark moves after the +/// next letter, and the word gaps read as spaces whether a space glyph is drawn or not. Copy and Find use this order. +#[test] +fn thai_marks_drawn_one_glyph_at_a_time_keep_their_place_and_words() { + let thai = "ห้อง ก๋วยเตี๋ยว น้ำ ที่นี่"; + for mark_dx in [0.4, 0.0, -6.0] { + for space_glyphs in [false, true] { + let text = extract_page(&one_glyph_per_show(thai, mark_dx, space_glyphs), 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), thai, "marks {mark_dx} pt past the pen, space glyphs {space_glyphs}"); + for word in ["ห้อง", "ก๋วยเตี๋ยว", "น้ำ", "ที่นี่"] { + assert_eq!(text.find_opts(word, true, true).len(), 1, "{word:?} found as a whole word in {:?}", text.plain_text()); + } + } + } +} + +/// Combining marks of other left-to-right scripts ride on their base the same way: Devanagari signs (virama, +/// anusvara, a below vowel; the spacing vowel sign ि is a letter of its own), and Latin combining accents. +#[test] +fn combining_marks_of_other_scripts_keep_their_place() { + for text in ["कुछ हिंदी स्कूल", "e\u{301}te\u{301} na\u{303}o"] { + let page = extract_page(&one_glyph_per_show(text, 0.4, false), 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(page.plain_text(), text); + } +} + +/// A line drawn right to left glyph by glyph still reads by x, and a mark drawn right after its letter moves with +/// it. A mark that starts far from the glyph before it is not taken for a mark on that glyph: it goes by its own x. +#[test] +fn a_mark_moves_with_its_letter_in_a_line_drawn_backwards() { + let v = vec![g("c", 24.0, 10.0, 30.0), g("b", 18.0, 10.0, 24.0), g("\u{301}", 24.3, 10.0, 29.3), g("a", 12.0, 10.0, 18.0)]; + assert_eq!(layout(v).plain_text(), "ab\u{301}c"); + let v = vec![g("a", 12.0, 10.0, 18.0), g("b", 18.0, 10.0, 24.0), g("c", 24.0, 10.0, 30.0), g("\u{301}", 13.0, 10.0, 18.0)]; + assert_eq!(layout(v).plain_text(), "a\u{301}bc"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 10b040e09f..b34fe89f48 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1406,8 +1406,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable"] -notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic." +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable", "crates/render/src/text/tests.rs::thai_marks_drawn_one_glyph_at_a_time_keep_their_place_and_words"] +notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Combining marks (Mn/Me) of left-to-right scripts drawn right after their letter stay with it in content order, and Thai or Indic word gaps without a space glyph are found as spaces (#6517). Complex interleaved flows still use a geometric reading-order heuristic." [[feature]] id = "view.find-next-previous" @@ -1550,8 +1550,8 @@ milestone = "M3" status = "shipped" commands = ["edit.copy"] tools = ["text_extract"] -evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"] -notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract." +evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership", "crates/render/src/text/tests.rs::combining_marks_of_other_scripts_keep_their_place"] +notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract. Copied Thai, Devanagari and Latin text keeps each combining mark after its letter when every glyph is drawn on its own (#6517)." [[feature]] id = "view.select-all" From f1bd39e57895d207d9746e37ee592154dbcd2161 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 18:32:56 +0800 Subject: [PATCH 02/13] M3: a stacked, raised or squeezed combining mark stays with its letter (view.copy-text, view.find) A second mark stacked on a letter now rides on the letter as well as on the mark before it; a mark drawn up to an em above or below its letter stays on the letter's line, and the glyph after it continues from the letter; marks in a zero-width run are ordered by their letter; and a word gap after a mark is measured from the letter's advance, not the mark's offset start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/layout.rs | 27 ++++++++++++++++++++++----- crates/render/src/text/order.rs | 15 ++++++++------- crates/render/src/text/tests.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 8d9aa9f12b..b5e2107e75 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -130,12 +130,23 @@ fn rides_on(p: &TextGlyph, m: &TextGlyph) -> bool { && m.rect[0] <= p.rect[2] + reach } -/// Glyph `i` continues the run of glyph `i - 1`: when it is a combining mark on that glyph, it joins its cluster. +/// How far, as a fraction of its letter's height, a combining mark's em box may sit above or below the letter's and +/// still ride on it. A tone mark lifted over an upper vowel can be drawn more than half an em higher, past where a +/// glyph of its own would start a new line. +const MARK_RISE: f32 = 1.0; + +/// Whether glyph `m`, drawn right after glyph `p` (a letter, or a mark riding on letter `a`), rides on that cluster: +/// on `p` itself, or, for a mark stacked on another one, on its letter `a`. +fn rides_on_cluster(glyphs: &[&TextGlyph], p: usize, a: usize, m: &TextGlyph) -> bool { + glyphs.get(p).is_some_and(|pg| rides_on(pg, m)) || (a != p && glyphs.get(a).is_some_and(|ag| rides_on(ag, m))) +} + +/// Glyph `i` continues the run of glyph `i - 1`: when it is a combining mark on that glyph's cluster, it joins it. fn attach_mark(glyphs: &[&TextGlyph], anchor: &mut [usize], i: usize) { let Some(p) = i.checked_sub(1) else { return }; - if let (Some(pg), Some(mg)) = (glyphs.get(p), glyphs.get(i)) - && rides_on(pg, mg) - && let Some(&a) = anchor.get(p) + let Some(&a) = anchor.get(p) else { return }; + if let Some(mg) = glyphs.get(i) + && rides_on_cluster(glyphs, p, a, mg) && let Some(slot) = anchor.get_mut(i) { *slot = a; @@ -221,7 +232,13 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let g = glyphs[i].rect; let hi = height(i); let cont = segs.last().is_some_and(|s| { - let Some(p) = s.first.end.checked_sub(1) else { return false }; + let Some(last) = s.first.end.checked_sub(1) else { return false }; + // A glyph after a combining mark continues from the mark's letter, and a mark on that letter's cluster + // stays on its line even when drawn raised or lowered by up to an em (`MARK_RISE`). + let p = anchor.get(last).copied().unwrap_or(last); + if rides_on_cluster(&glyphs, last, p, glyphs[i]) && (cy(i) - cy(p)).abs() < height(p) * MARK_RISE { + return true; + } let ph = height(p).min(hi); let sh = s.h.min(hi); let gap = g[0] - glyphs[p].rect[2]; diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 0a9c91de24..c8bf5a5d68 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -161,8 +161,9 @@ pub(super) struct Scratch { } /// Fills `sc.cluster_gap` with the gap left of each glyph of `sc.placed`, between clusters: a combining mark riding on a -/// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge or the furthest mark start. -/// A zero-advance mark's box is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em). +/// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge. A zero-advance mark's box +/// is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em), and its start is only +/// the base's advance moved by a mark-positioning offset. pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) { sc.cluster_gap.clear(); let mut right = f32::NEG_INFINITY; @@ -172,7 +173,6 @@ pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) { continue; }; if anchor_of(&sc.anchor, c) != c { - right = right.max(g.rect[0]); sc.cluster_gap.push(None); } else { sc.cluster_gap.push((w > 0).then_some(g.rect[0] - right)); @@ -304,14 +304,15 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, } else { collide(glyphs, height, s.h, sc); } - // Each mark goes before the first placed glyph whose x is greater than its own. Marks are visited in x order, and - // that position never moves back, so one pass over `placed` puts all of them in. - sc.marks.sort_by(|a, b| x0(*a).total_cmp(&x0(*b))); + // Each mark goes before the first placed glyph whose x is greater than its own, or its letter's when it is a + // combining mark riding on one. Marks are visited in that order, and that position never moves back, so one pass + // over `placed` puts all of them in. + sc.marks.sort_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b)); if !sc.marks.is_empty() { sc.merged.clear(); let mut next = 0; for &m in &sc.marks { - while next < sc.placed.len() && x0(anchor_of(&sc.anchor, sc.placed[next])) <= x0(m) { + while next < sc.placed.len() && x0(anchor_of(&sc.anchor, sc.placed[next])) <= x0(anchor_of(&sc.anchor, m)) { sc.merged.push(sc.placed[next]); next += 1; } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index b5dd72bb9f..0014f79870 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2064,3 +2064,33 @@ fn a_mark_moves_with_its_letter_in_a_line_drawn_backwards() { let v = vec![g("a", 12.0, 10.0, 18.0), g("b", 18.0, 10.0, 24.0), g("c", 24.0, 10.0, 30.0), g("\u{301}", 13.0, 10.0, 18.0)]; assert_eq!(layout(v).plain_text(), "a\u{301}bc"); } + +/// A second mark stacked on a letter rides on the letter, not only on the mark before it: a mark drawn back over its +/// base, then a tone mark a hair past the base's advance, both stay before the next letter. +#[test] +fn a_stacked_mark_rides_on_its_base_letter() { + let v = vec![g("ก", 12.0, 10.0, 18.0), g("\u{e34}", 10.0, 10.0, 15.0), g("\u{e48}", 19.5, 10.0, 24.5), g("ข", 18.0, 10.0, 24.0)]; + assert_eq!(layout(v).plain_text(), "ก\u{e34}\u{e48}ข"); +} + +/// A mark drawn raised by more than half an em over its letter (a tone mark lifted above an upper vowel) stays on the +/// letter's line and before the next letter. +#[test] +fn a_raised_mark_stays_with_its_letter() { + let v = vec![g("ก", 12.0, 10.0, 18.0), g("\u{e48}", 18.4, 16.0, 23.4), g("ข", 18.0, 10.0, 24.0), g("ค", 24.0, 10.0, 30.0)]; + assert_eq!(layout(v).plain_text(), "ก\u{e48}ขค"); +} + +/// A word gap after a mark is measured from its letter's advance: a mark drawn a hair past the pen does not narrow it. +#[test] +fn a_word_gap_after_a_mark_is_measured_from_its_letter() { + let v = vec![g("a", 0.0, 10.0, 6.0), g("b", 6.0, 10.0, 12.0), g("\u{301}", 12.4, 10.0, 17.4), g("c", 13.8, 10.0, 19.8), g("d", 19.8, 10.0, 25.8)]; + assert_eq!(layout(v).plain_text(), "ab\u{301} cd"); +} + +/// In a run squeezed to no width, a mark still follows its letter, even when it is drawn past the next letter. +#[test] +fn a_mark_in_a_zero_width_run_follows_its_letter() { + let v = vec![g("a", 10.0, 10.0, 10.0), g("\u{301}", 10.6, 10.0, 10.6), g("b", 10.3, 10.0, 10.3)]; + assert_eq!(layout(v).plain_text(), "a\u{301}b"); +} From 152d696fb0fee8510b65b6976a85e04096916750 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 00:07:25 +0800 Subject: [PATCH 03/13] M3: a space taken back under the next letter stays a word break, so right-to-left words are not split (view.copy-text) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A right-to-left show string in reverse order starts with the space that ends its word (ISO 32000-2 §14.8.2.5.3), and a word processor pulls the next letter back over it with a TJ adjustment a little larger than the space's width. The letter then starts a hair left of the space, so sorting a line by x put the space inside the word: copied and found text read "המדינ ה" for "המדינה" and "כולם ?" for "כולם?" (pdf.js #11656), and Find missed both. A blank glyph has no ink, so its box cannot place it against a glyph that covers it. On a line with right-to-left text, when the glyph drawn right after a blank is a right-to-left letter or a neutral, starts at or left of the blank's start and covers at least half of it, the blank keeps its drawing place before that glyph (§14.8.2.6.2: the space is a word break in the character stream). Left-to-right text, a glyph that only touches the blank, and a blank inside a letter-and-mark cluster keep their x order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 36 ++++++++++++++++++- crates/render/src/text/tests.rs | 63 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index efcabb5f1a..8d75d9e81b 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -243,7 +243,8 @@ pub(super) fn restarts_run(glyphs: &[&TextGlyph], start: usize, next: usize, pro /// Glyphs are placed by x. Runs that collide, meaning that glyphs of different runs overprint each other, stay whole /// and are placed by their left edge. Runs that only interleave along the line, such as symbols between letters, keep /// their glyph-by-glyph order, so the common case is one sort. A run of zero-width glyphs (a combining mark) has no -/// extent of its own, so it goes next to the glyph it starts at. +/// extent of its own, so it goes next to the glyph it starts at. A space whose advance the next glyph takes back stays +/// before that glyph ([`keep_taken_back_blanks`]). pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, s: &Seg, sc: &mut Scratch) { let x0 = |i: usize| glyphs[i].rect[0]; let zero_width = |i: usize| glyphs[i].rect[2] - glyphs[i].rect[0] <= 0.05 * height(i); @@ -265,6 +266,7 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, } else { collide(glyphs, height, s.h, sc); } + keep_taken_back_blanks(glyphs, &mut sc.placed); // Each mark goes before the first placed glyph whose x is greater than its own. Marks are visited in x order, and // that position never moves back, so one pass over `placed` puts all of them in. sc.marks.sort_by(|a, b| x0(*a).total_cmp(&x0(*b))); @@ -283,6 +285,38 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, } } +/// Puts a blank glyph (a space) back before the glyph drawn right after it on a line with right-to-left text, when that +/// glyph's adjustment took the blank's advance back: it starts at or left of the blank's own start and covers at least +/// half of it. Right-to-left text drawn in reverse order starts a show string with the space that ends its word +/// (ISO 32000-2 §14.8.2.5.3, whose NOTE 2 has such a space realign the typography without visible effect), and a word +/// processor pulls the next letter back over it. A blank has no ink, so its box says nothing about which side of that +/// letter it belongs on, and sorting by x split "המדינה" as "המדינ ה". The space is a word break in the character +/// stream (§14.8.2.6.2), and drawing order is the only evidence of its place. +/// +/// Only a right-to-left letter or a neutral (punctuation, a symbol) after the blank counts: left-to-right text has no +/// reverse-order show strings, and in scanned pages' invisible text layers its spaces overlap letters at random. A +/// glyph that only touches the blank's start was drawn beside it, as text set letter by letter leftwards is, and x +/// order already reads it right. A blank that a glyph placed before them also reaches over sits inside a cluster (a +/// letter and the marks drawn on it), where its place is no clearer, so it stays where x order put it. +fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { + let blank = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(char::is_whitespace); + let left_to_right = |g: &TextGlyph| g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c)); + if !placed.iter().any(|&i| glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl))) { + return; + } + for p in 1..placed.len() { + let (Some(&ink), Some(&space)) = (placed.get(p - 1), placed.get(p)) else { continue }; + let (Some(s), Some(g)) = (glyphs.get(space), glyphs.get(ink)) else { continue }; + let width = s.rect[2] - s.rect[0]; + let covered = g.rect[2].min(s.rect[2]) - s.rect[0]; + let taken_back = g.rect[0] <= s.rect[0] && width > 0.0 && covered >= 0.5 * width; + let alone = p.checked_sub(2).and_then(|q| placed.get(q)).and_then(|&q| glyphs.get(q)).is_none_or(|q| q.rect[2] <= s.rect[0]); + if space.checked_add(1) == Some(ink) && blank(s) && !blank(g) && !left_to_right(g) && taken_back && alone { + placed.swap(p - 1, p); + } + } +} + /// `place_glyphs` for a segment of two or more text runs. `h` is the segment's line height. Returns whether the segment /// was too dense to sweep ([`COLLIDE_PAIRS_PER_GLYPH`]), in which case all its text runs form one component. /// diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a1af42a8d0..093fe8ceb6 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1185,6 +1185,69 @@ fn word_gaps_follow_position_not_drawing_order() { assert_eq!(layout(v).plain_text(), "שלום עולם"); } +/// A right-to-left show string in reverse order starts with the space that ends its word, and the next letter is pulled +/// back over it (ISO 32000-2 §14.8.2.5.3): the space stays a word break after the word, not inside it (pdf.js #11656). +#[test] +fn a_space_taken_back_under_the_next_letter_stays_before_it() { + let rtl_line = |first: &str| { + // Drawn left to right: " הנידמה" with the space's advance taken back, then "רקבמ" after a word gap. + let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box(first, 9.9, 0.0, 17.5, 11.0)]; + let mut x = 17.5; + for c in ["נ", "י", "ד", "מ", "ה"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + x += 3.5; + for c in ["ר", "ק", "ב", "מ"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + layout(v) + }; + let text = rtl_line("ה"); + assert_eq!(text.plain_text().trim_end(), "מבקר המדינה"); + assert_eq!(text.find("המדינה").len(), 1); + // The same with punctuation: the question mark ends the line, with no space before it. + assert_eq!(rtl_line("?").plain_text().trim_end(), "מבקר המדינ?"); + // Right-to-left text drawn letter by letter leftwards: the letter after the space ends where the space starts, a + // rounding hair over it, and stays on its left. + let mut v = Vec::new(); + let mut x = 60.0; + for c in ["ש", "ל", "ו", "ם"] { + v.push(em_box(c, x - 6.0, 0.0, x, 11.0)); + x -= 6.0; + } + v.push(em_box(" ", x - 3.5, 0.0, x, 11.0)); + x -= 3.5; + v.push(em_box("ע", x - 6.0, 0.0, x + 0.005, 11.0)); + x -= 6.0; + for c in ["ו", "ל", "ם"] { + v.push(em_box(c, x - 6.0, 0.0, x, 11.0)); + x -= 6.0; + } + assert_eq!(layout(v).plain_text(), "שלום עולם"); + // A space inside a cluster, under a letter and the mark drawn on it, stays where x order puts it: before the mark. + let v = vec![ + em_box("ש", 9.995, 0.0, 25.0, 11.0), + em_box(" ", 10.006, 0.0, 13.5, 11.0), + em_box("\u{5B4}", 10.003, 0.0, 12.8, 11.0), + em_box("ל", 25.0, 0.0, 31.0, 11.0), + ]; + assert_eq!(layout(v).plain_text(), "ל \u{5B4}ש"); + // Left-to-right text has no reverse-order show strings: a space taken back there is still placed by position. + let v = vec![ + em_box("a", 0.0, 0.0, 6.0, 11.0), + em_box("b", 6.0, 0.0, 12.0, 11.0), + em_box(" ", 12.0, 0.0, 15.5, 11.0), + em_box("c", 11.9, 0.0, 17.9, 11.0), + ]; + assert_eq!(layout(v).plain_text().trim_end(), "abc"); + // A space whose advance stands is placed by position, as before. + let mut v = vec![em_box("a", 0.0, 0.0, 6.0, 11.0), em_box(" ", 6.0, 0.0, 9.5, 11.0), em_box("b", 9.5, 0.0, 15.5, 11.0)]; + v.push(em_box("c", 15.5, 0.0, 21.5, 11.0)); + assert_eq!(layout(v).plain_text(), "a bc"); +} + /// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words. #[test] fn rtl_line_with_number_and_punctuation_reads_in_logical_order() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7582b3f6bc..07e626a708 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1550,8 +1550,8 @@ milestone = "M3" status = "shipped" commands = ["edit.copy"] tools = ["text_extract"] -evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"] -notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract." +evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership", "crates/render/src/text/tests.rs::a_space_taken_back_under_the_next_letter_stays_before_it"] +notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract. A space that starts a right-to-left show string and is taken back under the next letter (ISO 32000-2 §14.8.2.5.3) stays a word break after its word instead of splitting it." [[feature]] id = "view.select-all" From fc8a4d6974fa411f634499eb183c2ae121f9d747 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 08:26:02 +0800 Subject: [PATCH 04/13] M3: a taken-back space moves only inside right-to-left text, past marks drawn before its letter (view.copy-text) A neutral taken back over a space keeps the space before it only when the nearest strong glyphs on both sides are right-to-left (or the line edge), so a parenthesis opening Latin text is placed by x as on a line without Hebrew. Combining marks drawn between the space and its letter no longer stop the move, and the space, the marks and the letter keep drawing order. The pass now runs after zero-width marks are merged, since that merge relies on x order. Adds tests for the pdf.js #11656 content stream and for two taken-back spaces on one line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 97 ++++++++++++++++--- crates/render/src/text/tests.rs | 164 ++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 12 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 4353e78f89..a29dec647b 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -305,7 +305,6 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, } else { collide(glyphs, height, s.h, sc); } - keep_taken_back_blanks(glyphs, &mut sc.placed); // Each mark goes before the first placed glyph whose x is greater than its own, or its letter's when it is a // combining mark riding on one. Marks are visited in that order, and that position never moves back, so one pass // over `placed` puts all of them in. @@ -323,8 +322,15 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, sc.merged.extend_from_slice(&sc.placed[next..]); std::mem::swap(&mut sc.placed, &mut sc.merged); } + // After the marks: their merge relies on `placed` being in x order, which this breaks on purpose. + keep_taken_back_blanks(glyphs, &mut sc.placed); } +/// How many glyphs [`keep_taken_back_blanks`] looks at past a blank: marks between it and its letter, in drawing order +/// and in x order, and glyphs without a strong direction on each side of a taken-back neutral. Each scan stops there, +/// so the pass stays linear in the line's glyphs. +const TAKEN_BACK_SCAN: usize = 8; + /// Puts a blank glyph (a space) back before the glyph drawn right after it on a line with right-to-left text, when that /// glyph's adjustment took the blank's advance back: it starts at or left of the blank's own start and covers at least /// half of it. Right-to-left text drawn in reverse order starts a show string with the space that ends its word @@ -333,26 +339,93 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, /// letter it belongs on, and sorting by x split "המדינה" as "המדינ ה". The space is a word break in the character /// stream (§14.8.2.6.2), and drawing order is the only evidence of its place. /// -/// Only a right-to-left letter or a neutral (punctuation, a symbol) after the blank counts: left-to-right text has no -/// reverse-order show strings, and in scanned pages' invisible text layers its spaces overlap letters at random. A -/// glyph that only touches the blank's start was drawn beside it, as text set letter by letter leftwards is, and x -/// order already reads it right. A blank that a glyph placed before them also reaches over sits inside a cluster (a -/// letter and the marks drawn on it), where its place is no clearer, so it stays where x order put it. +/// Combining marks (bidi class NSM) drawn between the blank and its letter are passed over, and the blank, those marks +/// and the letter keep drawing order, which puts each mark after its letter once the line is reversed, as Unicode +/// stores it. Only a right-to-left letter or a neutral (punctuation, a symbol) after the blank counts: left-to-right +/// text has no reverse-order show strings, and in scanned pages' invisible text layers its spaces overlap letters at +/// random. A neutral counts only inside right-to-left text: the nearest glyph with a strong direction before it, and +/// the nearest after the blank, is right-to-left or the line's edge. A parenthesis that opens Latin text then stays +/// where x order puts it, as on a line without right-to-left text. A glyph that only touches the blank's start was +/// drawn beside it, as text set letter by letter leftwards is, and x order already reads it right. A blank that a +/// glyph placed before them also reaches over sits inside a cluster (a letter and the marks drawn on it), where its +/// place is no clearer, so it stays where x order put it. fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { + let glyph = |placed: &[usize], p: usize| placed.get(p).and_then(|&i| glyphs.get(i)).copied(); let blank = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(char::is_whitespace); - let left_to_right = |g: &TextGlyph| g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c)); + let mark = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM); + // Some(true) for right-to-left text, Some(false) for left-to-right text (Latin letters, European digits), None for + // a glyph without a strong direction. + let strong = |g: &TextGlyph| { + if g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c)) { + Some(false) + } else if g.text.chars().any(|c| c.is_alphanumeric()) { + Some(true) + } else { + None + } + }; if !placed.iter().any(|&i| glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl))) { return; } + // Whether the nearest glyph with a strong direction from position `from` on, stepping by `step`, is right-to-left, + // or there is none before the line's edge. + let rtl_side = |placed: &[usize], from: Option, step: fn(usize) -> Option| { + let mut p = from; + for _ in 0..TAKEN_BACK_SCAN { + let Some(g) = p.and_then(|p| glyph(placed, p)) else { return true }; + if let Some(rtl) = strong(g) { + return rtl; + } + p = p.and_then(step); + } + false + }; for p in 1..placed.len() { - let (Some(&ink), Some(&space)) = (placed.get(p - 1), placed.get(p)) else { continue }; - let (Some(s), Some(g)) = (glyphs.get(space), glyphs.get(ink)) else { continue }; + let Some(&space) = placed.get(p) else { continue }; + let Some(s) = glyphs.get(space).copied() else { continue }; + if !blank(s) { + continue; + } + // The glyph drawn after the blank, past any marks drawn between them. + let mut next = space.checked_add(1); + for _ in 0..TAKEN_BACK_SCAN { + match next.and_then(|i| glyphs.get(i)) { + Some(g) if mark(g) => next = next.and_then(|i| i.checked_add(1)), + _ => break, + } + } + let Some(ink) = next.filter(|&i| glyphs.get(i).is_some_and(|g| !mark(g))) else { continue }; + // The marks drawn between them, wherever x order put them next to the blank, go with them. + let between = |i: usize| i > space && i < ink; + let mut q = p.checked_sub(1); + for _ in 0..TAKEN_BACK_SCAN { + match q.and_then(|q| placed.get(q)) { + Some(&i) if between(i) => q = q.and_then(|q| q.checked_sub(1)), + _ => break, + } + } + let Some(q) = q.filter(|&q| placed.get(q) == Some(&ink)) else { continue }; + let mut r = p; + for _ in 0..TAKEN_BACK_SCAN { + match r.checked_add(1).and_then(|r| placed.get(r)) { + Some(&i) if between(i) => r += 1, + _ => break, + } + } + let Some(g) = glyphs.get(ink).copied() else { continue }; let width = s.rect[2] - s.rect[0]; let covered = g.rect[2].min(s.rect[2]) - s.rect[0]; let taken_back = g.rect[0] <= s.rect[0] && width > 0.0 && covered >= 0.5 * width; - let alone = p.checked_sub(2).and_then(|q| placed.get(q)).and_then(|&q| glyphs.get(q)).is_none_or(|q| q.rect[2] <= s.rect[0]); - if space.checked_add(1) == Some(ink) && blank(s) && !blank(g) && !left_to_right(g) && taken_back && alone { - placed.swap(p - 1, p); + let alone = q.checked_sub(1).and_then(|b| glyph(placed, b)).is_none_or(|before| before.rect[2] <= s.rect[0]); + let direction = match strong(g) { + Some(rtl) => rtl, + None => rtl_side(placed, q.checked_sub(1), |p| p.checked_sub(1)) && rtl_side(placed, r.checked_add(1), |p| p.checked_add(1)), + }; + if !blank(g) && direction && taken_back && alone { + // The blank, its marks and its letter in drawing order. + if let Some(group) = placed.get_mut(q..=r) { + group.sort_unstable(); + } } } } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index ccbc994f27..48dad7591b 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1248,6 +1248,170 @@ fn a_space_taken_back_under_the_next_letter_stays_before_it() { assert_eq!(layout(v).plain_text(), "a bc"); } +/// A neutral taken back over a space keeps the space before it only inside right-to-left text. On a right-to-left line +/// drawn in logical order, a parenthesis that opens Latin text and is kerned back into the space before it stays +/// where its position puts it, as it would on a line without the Hebrew word. +#[test] +fn a_taken_back_neutral_beside_latin_text_keeps_its_place() { + // Drawn right to left: "שלום", the space, "(" pulled back into the space, then "AB" to its left. + let mut v = Vec::new(); + let mut x = 60.0; + for c in ["ש", "ל", "ו", "ם"] { + v.push(em_box(c, x - 6.5, 0.0, x, 11.0)); + x -= 6.5; + } + v.push(em_box(" ", x - 3.5, 0.0, x, 11.0)); + x -= 3.5; + v.push(em_box("(", x - 2.0, 0.0, x + 2.0, 11.0)); + x -= 2.0; + v.push(em_box("A", x - 13.0, 0.0, x - 6.5, 11.0)); + v.push(em_box("B", x - 6.5, 0.0, x, 11.0)); + assert_eq!(layout(v).plain_text(), "שלום (AB"); +} + +/// A mark drawn between a space and the letter taken back over it does not keep them apart: the space still ends the +/// word before, and the mark follows its letter, whether its box starts right or left of the space's start. +#[test] +fn a_mark_between_a_space_and_its_taken_back_letter_keeps_the_space_before_both() { + let line = |mark_x0: f32| { + let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("\u{5B8}", mark_x0, 0.0, 17.5, 11.0), em_box("ה", 9.9, 0.0, 17.5, 11.0)]; + let mut x = 17.5; + for c in ["נ", "י", "ד", "מ", "ה"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + x += 3.5; + for c in ["ר", "ק", "ב", "מ"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + layout(v).plain_text() + }; + assert_eq!(line(12.0).trim_end(), "מבקר המדינה\u{5B8}"); + assert_eq!(line(9.95).trim_end(), "מבקר המדינה\u{5B8}"); +} + +/// Two taken-back spaces on one line each stay before their letter, and a second space drawn before a taken-back one +/// stays with it. +#[test] +fn two_taken_back_spaces_on_a_line_each_stay_before_their_letter() { + // Drawn left to right: " הנידמה" and " רקבמ", each space's advance taken back by its next letter, then " לע". + let word = |v: &mut Vec, x: &mut f32, first: &str, rest: &[&str]| { + v.push(em_box(" ", *x, 0.0, *x + 3.5, 11.0)); + v.push(em_box(first, *x - 0.1, 0.0, *x + 6.4, 11.0)); + *x += 6.4; + for c in rest { + v.push(em_box(c, *x, 0.0, *x + 6.5, 11.0)); + *x += 6.5; + } + }; + let mut v = Vec::new(); + let mut x = 10.0; + word(&mut v, &mut x, "ה", &["נ", "י", "ד", "מ", "ה"]); + word(&mut v, &mut x, "ר", &["ק", "ב", "מ"]); + // The last word's space keeps its advance. + v.push(em_box(" ", x, 0.0, x + 3.5, 11.0)); + x += 3.5; + for c in ["ל", "ע"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + assert_eq!(layout(v).plain_text().trim(), "על מבקר המדינה"); + // Two spaces in a row, the second taken back: both stay before the letter. + let mut v = vec![em_box(" ", 6.5, 0.0, 10.0, 11.0), em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("ה", 9.9, 0.0, 17.5, 11.0)]; + let mut x = 17.5; + for c in ["נ", "י", "ד", "מ", "ה"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + assert_eq!(layout(v).plain_text().trim(), "המדינה"); +} + +/// pdf.js issue 11656 (https://github.com/mozilla/pdf.js/issues/11656): its content stream rebuilt with the same +/// codes, widths, adjustments and `/ToUnicode` on an unembedded font. Each right-to-left show string starts with a +/// space taken back under the next letter or punctuation; copied text keeps whole words and punctuation after its +/// word, and the parentheses of "15(ב)(4)" read as before. +#[test] +fn issue_11656_layout_reads_whole_words() { + use lopdf::{Document, Object, Stream, dictionary}; + let shows: [(f32, f32, &str); 10] = [ + ( + 386.83, + 708.96, + "[<0003>324<0022>-3<02AD>11<02AC02A5>4<02AB0003>10<02AD02A502AC>4<02B90003>8<02A402AE000302AD>9<02AC02A5>4<02B2>-3<000302AD02A502AC02B9>] TJ", + ), + (380.71, 708.96, "<0003> Tj"), + ( + 329.69, + 686.62, + "[<0003>324<001102A902BA>3<02A902AE02A0000302A302AC>4<02A9000302B8>4<02A102AB>11<000302A902B0>10<02A00003>-2<000F02AD02A902A8>4<02A502A7>13<02A4000302AF02AE000302A302B8>6<02A502A9>5<000302A902B002A0>] TJ", + ), + (323.57, 686.62, "<0003> Tj"), + (446.35, 664.3, "[<0011>10<02AD02A902A3>6<02AC02A9>4<02A4000302AC02AB000302A502AE02AB>] TJ"), + ( + 378.19, + 641.98, + "[<000302B3>-2<02A902B202B1>11<000302A902B402AC0003>10<02A402AC02A0000302BA02A5>3<02AC02A5>4<02B2>7<02B4000302A1>-3<02A502A9>5<02A702AC>] TJ", + ), + (366.19, 641.98, "-0.0278 Tc [<00140018>] TJ 0 Tc"), + (346.51, 641.98, "[<000C000B>5<02A1000C>] TJ"), + (340.49, 641.98, "[<0017>] TJ"), + (236.33, 641.98, "[<0003>324<02A402B002A902A3>4<02AE02A4000302B802B7>16<02A102AE0003>-2<02B7>3<02A502A7>13<02AC0003000B>] TJ"), + ]; + let ops: String = shows.iter().map(|(x, y, show)| format!("BT /F1 11.04 Tf 1 0 0 1 {x} {y} Tm {show} ET\n")).collect(); + let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Adobe-Identity-UCS def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 1 beginbfchar <0003> <0020> endbfchar 1 beginbfrange <000B> <000C> [<0029> <0028>] endbfrange 3 beginbfchar <000F> <002C> <0011> <002E> <0014> <0031> endbfchar 1 beginbfrange <0017> <0018> <0034> endbfrange 1 beginbfchar <0022> <003F> endbfchar 5 beginbfrange <02A0> <02A1> <05D0> <02A3> <02A5> <05D3> <02A7> <02A9> <05D7> <02AB> <02B4> <05DB> <02B7> <02BA> <05E7> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + let ranges: [(i64, &[i64]); 13] = [ + (0, &[1000]), + (3, &[313]), + (11, &[383, 383]), + (15, &[303]), + (17, &[303]), + (20, &[546]), + (23, &[546, 546]), + (34, &[474]), + (672, &[673, 640]), + (675, &[601, 685, 274]), + (679, &[685, 699, 274]), + (683, &[621, 621, 685, 685, 362, 401, 730, 648, 662, 652]), + (695, &[677, 569, 815, 709]), + ]; + let w: Vec = ranges + .iter() + .flat_map(|(first, widths)| [Object::Integer(*first), Object::Array(widths.iter().map(|&n| Object::Integer(n)).collect())]) + .collect(); + let mut doc = Document::with_version("1.7"); + let unicode = doc.add_object(Stream::new(dictionary! {}, cmap.as_bytes().to_vec())); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "TestHebrew", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "DW" => 1000, "W" => w, "CIDToGIDMap" => "Identity" + }); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestHebrew", "Encoding" => "Identity-H", "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode }); + let content = doc.add_object(Stream::new(dictionary! {}, ops.into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(612), Object::Integer(792)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let text = extract_page(&Pdf::new(std::sync::Arc::new(bytes)).unwrap(), 0, &InterpreterSettings::default()).unwrap(); + let plain = text.plain_text(); + let lines: Vec<&str> = plain.lines().map(str::trim).collect(); + assert_eq!( + lines, + [ + "שלום עולם מה שלום כולם?", + "אני יורד מן החוטים, אני כבר ילד אמיתי.", + "כמו כל הילדים.", + "לחיוב פעולות אלה לפי סעיף 15(ב)(4) לחוק מבקר המדינה" + ] + ); + for word in ["המדינה", "כולם?", "אמיתי.", "15(ב)(4)", "(ב)"] { + assert_eq!(text.find(word).len(), 1, "{word:?} in {lines:?}"); + } +} + /// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words. #[test] fn rtl_line_with_number_and_punctuation_reads_in_logical_order() { From b5068a2df7ca3fdd18d94a9461f686af38bc4087 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 08:30:56 +0800 Subject: [PATCH 05/13] M3: view.copy-text lists the issue 11656 layout test and the narrowed taken-back rule Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7859d87e3c..88c9744e73 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1550,8 +1550,8 @@ milestone = "M3" status = "shipped" commands = ["edit.copy"] tools = ["text_extract"] -evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership", "crates/render/src/text/tests.rs::a_space_taken_back_under_the_next_letter_stays_before_it", "crates/render/src/text/tests.rs::combining_marks_of_other_scripts_keep_their_place"] -notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract. Copied Thai, Devanagari and Latin text keeps each combining mark after its letter when every glyph is drawn on its own (#6517). A space that starts a right-to-left show string and is taken back under the next letter (ISO 32000-2 §14.8.2.5.3) stays a word break after its word instead of splitting it." +evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership", "crates/render/src/text/tests.rs::a_space_taken_back_under_the_next_letter_stays_before_it", "crates/render/src/text/tests.rs::issue_11656_layout_reads_whole_words", "crates/render/src/text/tests.rs::combining_marks_of_other_scripts_keep_their_place"] +notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract. Copied Thai, Devanagari and Latin text keeps each combining mark after its letter when every glyph is drawn on its own (#6517). A space that starts a right-to-left show string and is taken back under the next letter (ISO 32000-2 §14.8.2.5.3) stays a word break after its word instead of splitting it, also when a mark is drawn between them; punctuation taken back that way moves only inside right-to-left text." [[feature]] id = "view.select-all" From 94203a2b781ec20f170928ba601fe2e9cc4add2a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 08:46:53 +0800 Subject: [PATCH 06/13] M3: a taken-back space carries the marks on either side of it and reads numbers as right-to-left context (view.copy-text) The letter after a blank and the marks drawn between them are gathered from both sides of the blank in x order, so a tanwin pulled back over the space with its alef starting at the space's own start still moves the space before the word (PDFBOX-5487). Numbers beside a taken-back neutral count as right-to-left, as in the bidi pass (UAX #9 N1), so '15)' followed by a full stop keeps the stop after the number. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 38 +++++++++++++++++++-------------- crates/render/src/text/tests.rs | 23 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index a29dec647b..8151b59cd6 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -343,8 +343,8 @@ const TAKEN_BACK_SCAN: usize = 8; /// and the letter keep drawing order, which puts each mark after its letter once the line is reversed, as Unicode /// stores it. Only a right-to-left letter or a neutral (punctuation, a symbol) after the blank counts: left-to-right /// text has no reverse-order show strings, and in scanned pages' invisible text layers its spaces overlap letters at -/// random. A neutral counts only inside right-to-left text: the nearest glyph with a strong direction before it, and -/// the nearest after the blank, is right-to-left or the line's edge. A parenthesis that opens Latin text then stays +/// random. A neutral counts only inside right-to-left text: the nearest letter or number before it, and the nearest +/// after the blank, is right-to-left (numbers count as such beside neutrals) or the line's edge. A parenthesis that opens Latin text then stays /// where x order puts it, as on a line without right-to-left text. A glyph that only touches the blank's start was /// drawn beside it, as text set letter by letter leftwards is, and x order already reads it right. A blank that a /// glyph placed before them also reaches over sits inside a cluster (a letter and the marks drawn on it), where its @@ -353,12 +353,13 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { let glyph = |placed: &[usize], p: usize| placed.get(p).and_then(|&i| glyphs.get(i)).copied(); let blank = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(char::is_whitespace); let mark = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM); - // Some(true) for right-to-left text, Some(false) for left-to-right text (Latin letters, European digits), None for - // a glyph without a strong direction. + // The direction a glyph gives the text around it: Some(false) for left-to-right letters, Some(true) for + // right-to-left letters and for numbers, which read as right-to-left beside neutrals (UAX #9 N1, as in + // `reverse_rtl`), None for a glyph without one. let strong = |g: &TextGlyph| { - if g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c)) { + if g.text.chars().any(|c| c.is_alphabetic() && !is_rtl(c)) { Some(false) - } else if g.text.chars().any(|c| c.is_alphanumeric()) { + } else if g.text.chars().any(char::is_alphanumeric) { Some(true) } else { None @@ -395,29 +396,34 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { } } let Some(ink) = next.filter(|&i| glyphs.get(i).is_some_and(|g| !mark(g))) else { continue }; - // The marks drawn between them, wherever x order put them next to the blank, go with them. - let between = |i: usize| i > space && i < ink; - let mut q = p.checked_sub(1); - for _ in 0..TAKEN_BACK_SCAN { - match q.and_then(|q| placed.get(q)) { - Some(&i) if between(i) => q = q.and_then(|q| q.checked_sub(1)), + // The glyphs x order put next to the blank that were drawn after it, up to and including the letter: the + // letter and the marks drawn between them, on either side of the blank. + let drawn_with = |i: usize| i > space && i <= ink; + let mut q = p; + for _ in 0..=TAKEN_BACK_SCAN { + match q.checked_sub(1).and_then(|k| placed.get(k)) { + Some(&i) if drawn_with(i) => q -= 1, _ => break, } } - let Some(q) = q.filter(|&q| placed.get(q) == Some(&ink)) else { continue }; let mut r = p; - for _ in 0..TAKEN_BACK_SCAN { - match r.checked_add(1).and_then(|r| placed.get(r)) { - Some(&i) if between(i) => r += 1, + for _ in 0..=TAKEN_BACK_SCAN { + match r.checked_add(1).and_then(|k| placed.get(k)) { + Some(&i) if drawn_with(i) => r += 1, _ => break, } } + if q == p || !placed.get(q..=r).is_some_and(|group| group.contains(&ink)) { + continue; + } let Some(g) = glyphs.get(ink).copied() else { continue }; let width = s.rect[2] - s.rect[0]; let covered = g.rect[2].min(s.rect[2]) - s.rect[0]; let taken_back = g.rect[0] <= s.rect[0] && width > 0.0 && covered >= 0.5 * width; let alone = q.checked_sub(1).and_then(|b| glyph(placed, b)).is_none_or(|before| before.rect[2] <= s.rect[0]); + // A left-to-right letter or a European digit after the blank never moves; a neutral moves inside right-to-left text. let direction = match strong(g) { + _ if g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c)) => false, Some(rtl) => rtl, None => rtl_side(placed, q.checked_sub(1), |p| p.checked_sub(1)) && rtl_side(placed, r.checked_add(1), |p| p.checked_add(1)), }; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 48dad7591b..db6016aa95 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1267,6 +1267,18 @@ fn a_taken_back_neutral_beside_latin_text_keeps_its_place() { v.push(em_box("A", x - 13.0, 0.0, x - 6.5, 11.0)); v.push(em_box("B", x - 6.5, 0.0, x, 11.0)); assert_eq!(layout(v).plain_text(), "שלום (AB"); + // A number reads as right-to-left beside a neutral: a question mark taken back over the space after "15", drawn in + // reverse order, still ends the line with no space before it. + let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("?", 9.9, 0.0, 15.0, 11.0)]; + v.push(em_box("1", 15.0, 0.0, 20.5, 11.0)); + v.push(em_box("5", 20.5, 0.0, 26.0, 11.0)); + v.push(em_box(" ", 26.0, 0.0, 29.5, 11.0)); + let mut x = 29.5; + for c in ["ף", "י", "ע", "ס"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + assert_eq!(layout(v).plain_text().trim_end(), "סעיף 15?"); } /// A mark drawn between a space and the letter taken back over it does not keep them apart: the space still ends the @@ -1289,6 +1301,17 @@ fn a_mark_between_a_space_and_its_taken_back_letter_keeps_the_space_before_both( }; assert_eq!(line(12.0).trim_end(), "מבקר המדינה\u{5B8}"); assert_eq!(line(9.95).trim_end(), "מבקר המדינה\u{5B8}"); + // PDFBOX-5487's Arabic: a tanwin pulled back over the space, and the alef it sits on starting at the space's own + // start, so x order puts the alef after the space. + let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("\u{64B}", 9.5, 0.0, 16.3, 11.0), em_box("ا", 10.0, 0.0, 12.8, 11.0)]; + let mut x = 12.8; + for c in ["ر", "ي", "ر", "ق", "ت"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + v.push(em_box("ي", 3.0, 0.0, 6.0, 11.0)); + v.push(em_box("ف", 6.0, 0.0, 9.5, 11.0)); + assert_eq!(layout(v).plain_text().trim_end(), "تقريراً في"); } /// Two taken-back spaces on one line each stay before their letter, and a second space drawn before a taken-back one From c4cc9e0c37cde720b9bfe8ef30f66c407eb22849 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 11:56:38 +0800 Subject: [PATCH 07/13] M3: a combining mark takes its letter's direction, a line's edge has the line's direction, and overprinting runs are placed by their clusters (view.copy-text) - reverse_rtl applies UAX #9 W1: a nonspacing mark takes the class of the letter it rides on, so a Latin accent at a word end on a right-to-left line stays after its letter. - keep_taken_back_blanks reads a line's edge as the line's direction (UAX #9 X10) instead of right-to-left, so a neutral at the edge of a mostly left-to-right line keeps its place. - Rows of overprinting runs are ordered by where their clusters start, so a riding mark that reaches left of its letter cannot put its run first. - The two-taken-back-spaces test checks the untrimmed text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 35 ++++++++++++++++--- crates/render/src/text/tests.rs | 59 ++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 8151b59cd6..ed50f0064e 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -344,7 +344,8 @@ const TAKEN_BACK_SCAN: usize = 8; /// stores it. Only a right-to-left letter or a neutral (punctuation, a symbol) after the blank counts: left-to-right /// text has no reverse-order show strings, and in scanned pages' invisible text layers its spaces overlap letters at /// random. A neutral counts only inside right-to-left text: the nearest letter or number before it, and the nearest -/// after the blank, is right-to-left (numbers count as such beside neutrals) or the line's edge. A parenthesis that opens Latin text then stays +/// after the blank, is right-to-left (numbers count as such beside neutrals), or the line's edge on a right-to-left +/// line. A parenthesis that opens Latin text then stays /// where x order puts it, as on a line without right-to-left text. A glyph that only touches the blank's start was /// drawn beside it, as text set letter by letter leftwards is, and x order already reads it right. A blank that a /// glyph placed before them also reaches over sits inside a cluster (a letter and the marks drawn on it), where its @@ -368,12 +369,16 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { if !placed.iter().any(|&i| glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl))) { return; } + // The line's direction, as `reverse_rtl` takes it: right-to-left when it has more right-to-left letters than + // left-to-right ones. A line's edge has that direction (UAX #9 X10: sos and eos). + let count = |b: Bidi| placed.iter().filter(|&&i| glyphs.get(i).is_some_and(|g| bidi_class(&g.text) == b)).count(); + let line_rtl = count(Bidi::R) > count(Bidi::L); // Whether the nearest glyph with a strong direction from position `from` on, stepping by `step`, is right-to-left, - // or there is none before the line's edge. + // or the line is when there is none before its edge. let rtl_side = |placed: &[usize], from: Option, step: fn(usize) -> Option| { let mut p = from; for _ in 0..TAKEN_BACK_SCAN { - let Some(g) = p.and_then(|p| glyph(placed, p)) else { return true }; + let Some(g) = p.and_then(|p| glyph(placed, p)) else { return line_rtl }; if let Some(rtl) = strong(g) { return rtl; } @@ -536,7 +541,9 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f let start = sc.group.len(); sc.group.extend(run.range()); sc.group[start..].sort_unstable_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b)); - let left = sc.group[start..].iter().map(|&i| x0(i)).fold(f32::INFINITY, f32::min); + // Ordered by where its clusters start: a mark riding on a glyph may reach left of it, but it has no place of + // its own, so it cannot put its run before one whose first letter stands further left. + let left = sc.group[start..].iter().map(|&i| x0(anchor_of(&sc.anchor, i))).fold(f32::INFINITY, f32::min); let right = sc.group[start..].iter().map(|&i| glyphs[i].rect[2]).fold(f32::NEG_INFINITY, f32::max); let lead = sc.group[start]; sc.rows.push(Row { left, right, first: run.start, lead, glyphs: start..sc.group.len() }); @@ -586,6 +593,25 @@ fn bidi_class(text: &str) -> Bidi { } } +/// UAX #9 W1: a nonspacing mark takes the class of the glyph before it, the letter it rides on, which placement keeps +/// right in front of it. A mark after a word gap (`None`) rides on nothing and keeps its own class. +fn resolve_marks(glyphs: &[&TextGlyph], items: &mut [(Option, Bidi)]) { + let nsm = |i: usize| { + glyphs.get(i).is_some_and(|g| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM)) + }; + for k in 1..items.len() { + let base = match items.get(k - 1) { + Some(&(Some(_), base)) => base, + _ => continue, + }; + if let Some(it) = items.get_mut(k) + && it.0.is_some_and(nsm) + { + it.1 = base; + } + } +} + /// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the /// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has /// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals @@ -601,6 +627,7 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut } let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); let text = |k: usize| texts.get(k).copied().unwrap_or(""); + resolve_marks(glyphs, &mut items); let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; let n = items.len(); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index db6016aa95..989dbbd65a 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1347,7 +1347,8 @@ fn two_taken_back_spaces_on_a_line_each_stay_before_their_letter() { v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); x += 6.5; } - assert_eq!(layout(v).plain_text().trim(), "המדינה"); + // Untrimmed: both spaces follow the word, in drawing order. + assert_eq!(layout(v).plain_text(), "המדינה "); } /// pdf.js issue 11656 (https://github.com/mozilla/pdf.js/issues/11656): its content stream rebuilt with the same @@ -2359,3 +2360,59 @@ fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { assert_eq!(text.glyph_at(25.0, 5.0), None); assert_eq!(PageText::default().glyph_at(0.0, 0.0), None); } + +/// A combining mark takes the direction of the letter it rides on (UAX #9 W1, nonspacing marks): a Latin accent at the +/// end of a Latin word on a right-to-left line stays after its letter, not on the far side of it. +#[test] +fn a_latin_mark_at_a_word_end_on_a_right_to_left_line_stays_after_its_letter() { + // Drawn left to right: "abé" (e and a combining acute riding on it), a space, then "שלום" right to left. + let mut v = vec![em_box("a", 0.0, 0.0, 6.0, 11.0), em_box("b", 6.0, 0.0, 12.0, 11.0), em_box("e", 12.0, 0.0, 18.0, 11.0)]; + v.push(em_box("\u{301}", 14.0, 0.0, 14.0, 11.0)); + v.push(em_box(" ", 18.0, 0.0, 21.5, 11.0)); + let mut x = 21.5; + for c in ["ם", "ו", "ל", "ש", "ם", "ו", "ל", "ש"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + assert_eq!(layout(v).plain_text().trim_end(), "שלוםשלום abe\u{301}"); +} + +/// A line's edge has the line's direction (UAX #9 X10): on a line of mostly left-to-right text, a neutral taken back +/// over a space at the line's left edge is not inside right-to-left text, so it stays where its position puts it, +/// however close a Hebrew word is on its other side. +#[test] +fn a_taken_back_neutral_at_the_edge_of_a_left_to_right_line_keeps_its_place() { + let line = |words: &[&str]| { + let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("(", 9.9, 0.0, 15.0, 11.0)]; + let mut x = 15.0; + for word in words { + for c in word.chars() { + v.push(em_box(&c.to_string(), x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + x += 3.5; + } + layout(v).plain_text() + }; + // Mostly Latin: x order, "(" first. + assert_eq!(line(&["םולש", "Hello", "world", "again"]).trim(), "( שלום Hello world again"); + // Mostly Hebrew: the line's edge is right-to-left, so "(" is taken back with the space, as "?" is in + // `a_space_taken_back_under_the_next_letter_stays_before_it`. + assert_eq!(line(&["םולש", "םלוע", "AB"]).trim_end(), "AB עולם שלום("); +} + +/// Runs that overprint each other are placed by where their clusters start: a combining mark riding on the second +/// run's first letter and reaching left of the first run's start does not put the second run first. +#[test] +fn a_mark_reaching_left_of_its_letter_does_not_reorder_overprinting_runs() { + // A first run "ABCDEF", then a second run drawn back over it, its "c" starting 1 pt right of "A" and the acute on + // that "c" reaching 1.5 pt left of "A". + let mut v: Vec = + "ABCDEF".chars().zip(0u8..).map(|(c, k)| em_box(&c.to_string(), 100.0 + 6.0 * f32::from(k), 0.0, 106.0 + 6.0 * f32::from(k), 11.0)).collect(); + v.push(em_box("c", 101.0, 0.0, 107.0, 11.0)); + v.push(em_box("\u{301}", 98.5, 0.0, 102.5, 11.0)); + v.push(em_box("d", 107.0, 0.0, 113.0, 11.0)); + let text = layout(v).plain_text(); + assert!(text.starts_with("ABCDEF"), "{text:?}"); + assert!(text.contains("c\u{301}d"), "{text:?}"); +} From ee4b04429312783fef60778853b2a29127f59120 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 12:31:11 +0800 Subject: [PATCH 08/13] M3: a mark takes the class of the letter it rides on, and the paragraph direction counts letters before marks (view.copy-text) - resolve_marks applies UAX #9 W1 only to a mark riding on the glyph before it (its anchor), so a Hebrew mark placed after a Latin letter keeps its own class and stays with its word. - reverse_rtl takes the paragraph direction before marks take their letter's class, as keep_taken_back_blanks counts the line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/layout.rs | 2 +- crates/render/src/text/order.rs | 22 ++++++++++++++-------- crates/render/src/text/tests.rs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index b5e2107e75..e147cda84d 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -509,7 +509,7 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { if has_rtl { sc.logical.clear(); sc.logical.extend_from_slice(&sc.placed); - reverse_rtl(&glyphs, &mut sc.logical, &mut spaces); + reverse_rtl(&glyphs, &sc.anchor, &mut sc.logical, &mut spaces); } let idx: &[usize] = if has_rtl { &sc.logical } else { &sc.placed }; order.extend_from_slice(idx); diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index ed50f0064e..8389641b4e 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -593,19 +593,23 @@ fn bidi_class(text: &str) -> Bidi { } } -/// UAX #9 W1: a nonspacing mark takes the class of the glyph before it, the letter it rides on, which placement keeps -/// right in front of it. A mark after a word gap (`None`) rides on nothing and keeps its own class. -fn resolve_marks(glyphs: &[&TextGlyph], items: &mut [(Option, Bidi)]) { +/// UAX #9 W1: a nonspacing mark takes the class of the letter it rides on ([`Scratch::anchor`]), which placement keeps +/// right in front of it. A mark that rides on nothing (one after a word gap, or a right-to-left mark, which placement +/// puts by its own position) keeps its own class. +fn resolve_marks(glyphs: &[&TextGlyph], anchor: &[usize], items: &mut [(Option, Bidi)]) { let nsm = |i: usize| { glyphs.get(i).is_some_and(|g| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM)) }; for k in 1..items.len() { - let base = match items.get(k - 1) { - Some(&(Some(_), base)) => base, + let (prev, base) = match items.get(k - 1) { + Some(&(Some(prev), base)) => (prev, base), _ => continue, }; if let Some(it) = items.get_mut(k) - && it.0.is_some_and(nsm) + && let Some(mark) = it.0 + && nsm(mark) + && anchor_of(anchor, mark) != mark + && anchor_of(anchor, mark) == anchor_of(anchor, prev) { it.1 = base; } @@ -616,7 +620,7 @@ fn resolve_marks(glyphs: &[&TextGlyph], items: &mut [(Option, Bidi)]) { /// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has /// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals /// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones. -pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) { +pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], anchor: &[usize], idx: &mut [usize], spaces: &mut [bool]) { // Glyphs and word gaps (None) in visual order. let mut items: Vec<(Option, Bidi)> = Vec::with_capacity(idx.len() * 2); for (w, &i) in idx.iter().enumerate() { @@ -627,9 +631,11 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut } let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); let text = |k: usize| texts.get(k).copied().unwrap_or(""); - resolve_marks(glyphs, &mut items); + // The paragraph's direction counts letters as they are, before marks take their letter's class, as + // `keep_taken_back_blanks` counts them. let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; + resolve_marks(glyphs, anchor, &mut items); let n = items.len(); // W4: a single separator between two digits belongs to the number. for k in 1..n.saturating_sub(1) { diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 989dbbd65a..9310632963 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2416,3 +2416,19 @@ fn a_mark_reaching_left_of_its_letter_does_not_reorder_overprinting_runs() { assert!(text.starts_with("ABCDEF"), "{text:?}"); assert!(text.contains("c\u{301}d"), "{text:?}"); } + +/// A right-to-left mark rides on no glyph, so it keeps its own class: one placed after a Latin +/// letter, left of the Hebrew letter it belongs to, still reads with the Hebrew word, after its letter. +#[test] +fn a_hebrew_mark_after_a_latin_letter_stays_with_the_hebrew_word() { + let mut v = vec![em_box("a", 0.0, 0.0, 6.0, 11.0), em_box("b", 6.0, 0.0, 12.0, 11.0)]; + v.push(em_box("\u{5B8}", 11.9, 0.0, 11.9, 11.0)); + let mut x = 12.0; + for c in ["ם", "ו", "ל", "ש"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + let text = layout(v).plain_text(); + // The mark follows its letter, the last of "שלום", before the Latin text. + assert_eq!(text, "שלום\u{5B8}ab"); +} From 37d0f37110ecc527a1ba32f688a612c8ca054d94 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 13:09:56 +0800 Subject: [PATCH 09/13] M3: nonspacing marks count toward neither direction of a line (view.copy-text) reverse_rtl's paragraph direction and keep_taken_back_blanks' line direction both leave glyphs made only of nonspacing marks out of their counts (is_nonspacing_mark), so a few Hebrew letters carrying many points don't turn a mostly Latin line right to left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 15 ++++++++++----- crates/render/src/text/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 8389641b4e..b442771820 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -371,7 +371,7 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { } // The line's direction, as `reverse_rtl` takes it: right-to-left when it has more right-to-left letters than // left-to-right ones. A line's edge has that direction (UAX #9 X10: sos and eos). - let count = |b: Bidi| placed.iter().filter(|&&i| glyphs.get(i).is_some_and(|g| bidi_class(&g.text) == b)).count(); + let count = |b: Bidi| placed.iter().filter(|&&i| glyphs.get(i).is_some_and(|g| !is_nonspacing_mark(&g.text) && bidi_class(&g.text) == b)).count(); let line_rtl = count(Bidi::R) > count(Bidi::L); // Whether the nearest glyph with a strong direction from position `from` on, stepping by `step`, is right-to-left, // or the line is when there is none before its edge. @@ -593,13 +593,17 @@ fn bidi_class(text: &str) -> Bidi { } } +/// Whether `text` is only nonspacing marks (bidi class NSM). Such a glyph counts toward neither direction of a line: +/// it has the direction of the letter it sits on. +fn is_nonspacing_mark(text: &str) -> bool { + !text.is_empty() && text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM) +} + /// UAX #9 W1: a nonspacing mark takes the class of the letter it rides on ([`Scratch::anchor`]), which placement keeps /// right in front of it. A mark that rides on nothing (one after a word gap, or a right-to-left mark, which placement /// puts by its own position) keeps its own class. fn resolve_marks(glyphs: &[&TextGlyph], anchor: &[usize], items: &mut [(Option, Bidi)]) { - let nsm = |i: usize| { - glyphs.get(i).is_some_and(|g| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM)) - }; + let nsm = |i: usize| glyphs.get(i).is_some_and(|g| is_nonspacing_mark(&g.text)); for k in 1..items.len() { let (prev, base) = match items.get(k - 1) { Some(&(Some(prev), base)) => (prev, base), @@ -633,7 +637,8 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], anchor: &[usize], idx: &mut [us let text = |k: usize| texts.get(k).copied().unwrap_or(""); // The paragraph's direction counts letters as they are, before marks take their letter's class, as // `keep_taken_back_blanks` counts them. - let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); + let count = + |b: Bidi| items.iter().filter(|it| it.1 == b && !it.0.and_then(|i| glyphs.get(i)).is_some_and(|g| is_nonspacing_mark(&g.text))).count(); let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; resolve_marks(glyphs, anchor, &mut items); let n = items.len(); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 9310632963..1a23aaab4b 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2432,3 +2432,28 @@ fn a_hebrew_mark_after_a_latin_letter_stays_with_the_hebrew_word() { // The mark follows its letter, the last of "שלום", before the Latin text. assert_eq!(text, "שלום\u{5B8}ab"); } + +/// Nonspacing marks count toward neither direction of a line: a line of four Latin letters and a +/// three-letter Hebrew word whose letters each carry two points reads in the same order as without +/// the points. +#[test] +fn marks_do_not_decide_a_lines_direction() { + let line = |marks: bool| { + let mut v: Vec = + ["a", "b", "c", "d"].iter().zip(0u8..).map(|(c, k)| em_box(c, 6.0 * f32::from(k), 0.0, 6.0 * f32::from(k) + 6.0, 11.0)).collect(); + let mut x = 27.5; + for c in ["ו", "ל", "ש"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + if marks { + v.push(em_box("\u{5B8}", x + 1.0, 0.0, x + 1.0, 11.0)); + v.push(em_box("\u{5B4}", x + 2.0, 0.0, x + 2.0, 11.0)); + } + x += 6.5; + } + layout(v).plain_text() + }; + // Compared on the letters alone: the points' own word gaps are not what this is about. + let letters = |s: String| s.chars().filter(|&c| c != '\u{5B8}' && c != '\u{5B4}' && c != ' ').collect::(); + assert_eq!(letters(line(true)), letters(line(false))); + assert!(letters(line(true)).starts_with("abcd"), "a mostly Latin line reads Latin first"); +} From 8c1977f0519e881e6e660250d29ef5eef311858a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 13:55:51 +0800 Subject: [PATCH 10/13] M3: a ligature counts each of its letters toward a line's direction, and an unattached mark has no direction of its own (view.copy-text) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 37 +++++++++++++++++++-------- crates/render/src/text/tests.rs | 44 ++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index b442771820..98d2f9454d 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -369,10 +369,8 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { if !placed.iter().any(|&i| glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl))) { return; } - // The line's direction, as `reverse_rtl` takes it: right-to-left when it has more right-to-left letters than - // left-to-right ones. A line's edge has that direction (UAX #9 X10: sos and eos). - let count = |b: Bidi| placed.iter().filter(|&&i| glyphs.get(i).is_some_and(|g| !is_nonspacing_mark(&g.text) && bidi_class(&g.text) == b)).count(); - let line_rtl = count(Bidi::R) > count(Bidi::L); + // The line's direction, as `reverse_rtl` takes it. A line's edge has that direction (UAX #9 X10: sos and eos). + let line_rtl = is_rtl_line(glyphs, placed.iter().copied()); // Whether the nearest glyph with a strong direction from position `from` on, stepping by `step`, is right-to-left, // or the line is when there is none before its edge. let rtl_side = |placed: &[usize], from: Option, step: fn(usize) -> Option| { @@ -586,6 +584,11 @@ fn bidi_class(text: &str) -> Bidi { Bidi::R } else if text.chars().any(char::is_numeric) { Bidi::Num + } else if is_nonspacing_mark(text) { + // A mark of a left-to-right script that rides on no letter (`resolve_marks`) has no direction of its own: NSM + // is a weak class (UAX #9 Table 4), so it reads with the glyphs around its drawn position, as a neutral does, + // even when it is alphabetic (U+0345, Other_Alphabetic). + Bidi::N } else if text.chars().any(char::is_alphabetic) { Bidi::L } else { @@ -593,6 +596,23 @@ fn bidi_class(text: &str) -> Bidi { } } +/// Whether a line of the glyphs `line` is right-to-left: it has more right-to-left letters than left-to-right ones. +/// Letters are counted one by one, so a ligature glyph ("ffi") counts each of its letters, and nonspacing marks count +/// toward neither direction: a mark has the direction of the letter it sits on. +fn is_rtl_line(glyphs: &[&TextGlyph], line: impl Iterator) -> bool { + let (mut rtl, mut ltr) = (0usize, 0usize); + for g in line.filter_map(|i| glyphs.get(i)) { + for c in g.text.chars().filter(|&c| unicode_bidi::bidi_class(c) != unicode_bidi::BidiClass::NSM) { + if is_rtl(c) && !c.is_numeric() { + rtl = rtl.saturating_add(1); + } else if c.is_alphabetic() && !is_rtl(c) { + ltr = ltr.saturating_add(1); + } + } + } + rtl > ltr +} + /// Whether `text` is only nonspacing marks (bidi class NSM). Such a glyph counts toward neither direction of a line: /// it has the direction of the letter it sits on. fn is_nonspacing_mark(text: &str) -> bool { @@ -622,7 +642,7 @@ fn resolve_marks(glyphs: &[&TextGlyph], anchor: &[usize], items: &mut [(Option = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); let text = |k: usize| texts.get(k).copied().unwrap_or(""); - // The paragraph's direction counts letters as they are, before marks take their letter's class, as - // `keep_taken_back_blanks` counts them. - let count = - |b: Bidi| items.iter().filter(|it| it.1 == b && !it.0.and_then(|i| glyphs.get(i)).is_some_and(|g| is_nonspacing_mark(&g.text))).count(); - let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; + // The paragraph's direction, as `keep_taken_back_blanks` takes it. + let para = if is_rtl_line(glyphs, idx.iter().copied()) { Bidi::R } else { Bidi::L }; resolve_marks(glyphs, anchor, &mut items); let n = items.len(); // W4: a single separator between two digits belongs to the number. diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 1a23aaab4b..755a042c17 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2454,6 +2454,48 @@ fn marks_do_not_decide_a_lines_direction() { }; // Compared on the letters alone: the points' own word gaps are not what this is about. let letters = |s: String| s.chars().filter(|&c| c != '\u{5B8}' && c != '\u{5B4}' && c != ' ').collect::(); - assert_eq!(letters(line(true)), letters(line(false))); + let marked = line(true); + for point in ['\u{5B8}', '\u{5B4}'] { + assert_eq!(marked.chars().filter(|&c| c == point).count(), 3, "every point is kept: {marked:?}"); + } + assert_eq!(letters(marked), letters(line(false))); assert!(letters(line(true)).starts_with("abcd"), "a mostly Latin line reads Latin first"); } + +/// A ligature glyph counts each of its letters toward a line's direction: a line of an "ffi" ligature, a word gap and +/// a two-letter Hebrew word reads as the same line with "f", "f" and "i" drawn one by one, Latin first. +#[test] +fn a_ligature_counts_each_of_its_letters_toward_a_lines_direction() { + let line = |latin: &[&str]| { + let mut v: Vec = Vec::new(); + let step = 12.0 / latin.len() as f32; + for (k, t) in latin.iter().enumerate() { + let x = step * k as f32; + v.push(em_box(t, x, 0.0, x + step, 11.0)); + } + v.push(em_box("ל", 16.0, 0.0, 22.5, 11.0)); + v.push(em_box("ש", 22.5, 0.0, 29.0, 11.0)); + layout(v).plain_text() + }; + let split = line(&["f", "f", "i"]); + assert_eq!(split, "ffi של"); + assert_eq!(line(&["ffi"]), split); +} + +/// A mark of a left-to-right script that rides on no letter has no direction of its own: U+0345 (alphabetic, bidi +/// class NSM) drawn on the last letter of a Hebrew word, left of it and right after a Latin word, reads with the +/// Hebrew word, after that letter, as a Hebrew point drawn there does. +#[test] +fn an_unattached_alphabetic_mark_reads_with_the_glyphs_at_its_place() { + let mut v = vec![em_box("a", 0.0, 0.0, 6.0, 11.0), em_box("b", 6.0, 0.0, 12.0, 11.0)]; + let mut x = 12.0; + for c in ["ם", "ו", "ל", "ש"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + if c == "ם" { + v.push(em_box("\u{345}", 11.9, 0.0, 11.9, 11.0)); + } + x += 6.5; + } + let text = layout(v).plain_text(); + assert_eq!(text, "שלום\u{345}ab"); +} From aab3682629d08737bba2dfb902e6847e79012d0e Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 14:19:30 +0800 Subject: [PATCH 11/13] M3: right-to-left punctuation doesn't decide a line's direction, and an alphabetic mark gives a taken-back neutral no direction (view.copy-text) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 15 +++++++++------ crates/render/src/text/tests.rs | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 98d2f9454d..7850b603c7 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -356,11 +356,13 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { let mark = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM); // The direction a glyph gives the text around it: Some(false) for left-to-right letters, Some(true) for // right-to-left letters and for numbers, which read as right-to-left beside neutrals (UAX #9 N1, as in - // `reverse_rtl`), None for a glyph without one. + // `reverse_rtl`), None for a glyph without one. Nonspacing marks give none, alphabetic ones (U+0345) included: a + // mark has the direction of the letter it sits on. let strong = |g: &TextGlyph| { - if g.text.chars().any(|c| c.is_alphabetic() && !is_rtl(c)) { + let spacing = || g.text.chars().filter(|&c| unicode_bidi::bidi_class(c) != unicode_bidi::BidiClass::NSM); + if spacing().any(|c| c.is_alphabetic() && !is_rtl(c)) { Some(false) - } else if g.text.chars().any(char::is_alphanumeric) { + } else if spacing().any(char::is_alphanumeric) { Some(true) } else { None @@ -597,13 +599,14 @@ fn bidi_class(text: &str) -> Bidi { } /// Whether a line of the glyphs `line` is right-to-left: it has more right-to-left letters than left-to-right ones. -/// Letters are counted one by one, so a ligature glyph ("ffi") counts each of its letters, and nonspacing marks count -/// toward neither direction: a mark has the direction of the letter it sits on. +/// Letters are counted one by one, so a ligature glyph ("ffi") counts each of its letters. Punctuation of a +/// right-to-left script (a maqaf, an Arabic comma) is not a letter and counts toward neither direction, nor do +/// nonspacing marks: a mark has the direction of the letter it sits on. fn is_rtl_line(glyphs: &[&TextGlyph], line: impl Iterator) -> bool { let (mut rtl, mut ltr) = (0usize, 0usize); for g in line.filter_map(|i| glyphs.get(i)) { for c in g.text.chars().filter(|&c| unicode_bidi::bidi_class(c) != unicode_bidi::BidiClass::NSM) { - if is_rtl(c) && !c.is_numeric() { + if is_rtl(c) && c.is_alphabetic() { rtl = rtl.saturating_add(1); } else if c.is_alphabetic() && !is_rtl(c) { ltr = ltr.saturating_add(1); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 755a042c17..5dde3d8ea8 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2482,6 +2482,40 @@ fn a_ligature_counts_each_of_its_letters_toward_a_lines_direction() { assert_eq!(line(&["ffi"]), split); } +/// Punctuation of a right-to-left script is not a letter and doesn't decide a line's direction: two Latin letters, +/// a Hebrew letter and a maqaf and a sof pasuq after it read Latin first. +#[test] +fn right_to_left_punctuation_does_not_decide_a_lines_direction() { + let mut v = vec![em_box("a", 0.0, 0.0, 6.0, 11.0), em_box("b", 6.0, 0.0, 12.0, 11.0)]; + let mut x = 16.0; + for c in ["\u{5C3}", "\u{5BE}", "ש"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + let text = layout(v).plain_text(); + assert!(text.starts_with("ab"), "{text:?}"); +} + +/// An alphabetic nonspacing mark (U+0345) gives the text around a taken-back neutral no direction: a question mark +/// taken back over the space that starts a reverse-order show string reads as on the same line without the mark. +#[test] +fn an_alphabetic_mark_beside_a_taken_back_neutral_gives_it_no_direction() { + let line = |mark: bool| { + let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("?", 9.9, 0.0, 15.0, 11.0)]; + let mut x = 15.0; + for c in ["ף", "י", "ע", "ס"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + if mark { + v.push(em_box("\u{345}", 14.0, 0.0, 14.0, 11.0)); + } + layout(v).plain_text() + }; + let without = line(false); + assert_eq!(line(true).replace('\u{345}', ""), without); +} + /// A mark of a left-to-right script that rides on no letter has no direction of its own: U+0345 (alphabetic, bidi /// class NSM) drawn on the last letter of a Hebrew word, left of it and right after a Latin word, reads with the /// Hebrew word, after that letter, as a Hebrew point drawn there does. From 1ea4051d048100fa043ff6edc2ef2119e0084422 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 14:50:38 +0800 Subject: [PATCH 12/13] M3: rerun CI (a FreeBSD ui-egui preferences test failed on a text-ordering change) (view.copy-text) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From 7ba96e24d733e0ee60313badfe227d0a1e942b8a Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Mon, 9 Nov 2026 06:08:25 +0800 Subject: [PATCH 13/13] M3: a Thai mark with its own advance doesn't open a word gap (view.copy-text) A combining mark whose font gives it a real advance (a synthetic or broken /Widths entry, as in the grouped Thai body edit test) moves the pen, so the next letter starts at the mark's right edge. The cluster ended at the base's right edge, so that advance read as a word gap and a space opened after every cluster with a mark. The cluster now ends at the mark when the glyph drawn after it starts at the mark's box edge; a zero-advance mark, whose box draw_glyph widens and the next letter overlaps, still leaves the gap measured from its base. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 13 +++++++- crates/render/src/text/tests.rs | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 7850b603c7..c0ff03a706 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -160,10 +160,16 @@ pub(super) struct Scratch { pub(super) cluster_gap: Vec>, } +/// How close, as a fraction of its height, the glyph drawn after a combining mark must start to the mark's right edge +/// for the mark to have moved the pen by its own width ([`measure_cluster_gaps`]). +const MARK_ADVANCE_TOLERANCE: f32 = 0.05; + /// Fills `sc.cluster_gap` with the gap left of each glyph of `sc.placed`, between clusters: a combining mark riding on a /// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge. A zero-advance mark's box /// is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em), and its start is only -/// the base's advance moved by a mark-positioning offset. +/// the base's advance moved by a mark-positioning offset. A mark whose font gives it an advance of its own does move +/// the pen, and the glyph drawn after it starts right at its box's right edge: the cluster then ends there, or every +/// letter after such a mark would start a word. pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) { sc.cluster_gap.clear(); let mut right = f32::NEG_INFINITY; @@ -174,6 +180,11 @@ pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) { }; if anchor_of(&sc.anchor, c) != c { sc.cluster_gap.push(None); + let tolerance = MARK_ADVANCE_TOLERANCE * (g.rect[3] - g.rect[1]).max(0.1); + let pen_moved = c.checked_add(1).and_then(|n| glyphs.get(n)).is_some_and(|n| (n.rect[0] - g.rect[2]).abs() <= tolerance); + if pen_moved && g.rect[2] > right { + right = g.rect[2]; + } } else { sc.cluster_gap.push((w > 0).then_some(g.rect[0] - right)); right = g.rect[2]; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index b075d7e994..12c7ddc38e 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2547,6 +2547,61 @@ fn a_mark_in_a_zero_width_run_follows_its_letter() { assert_eq!(layout(v).plain_text(), "a\u{301}b"); } +/// One show string of `text` in Helvetica remapped by `/ToUnicode`, every glyph `width` units wide, combining marks +/// and spaces included, as a font whose `/Widths` give a mark an advance of its own draws it: each mark moves the pen, +/// and the next letter starts right at the mark's box. +fn one_show_string(text: &str, width: i64) -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let mut chars: Vec = Vec::new(); + for c in text.chars() { + if !chars.contains(&c) { + chars.push(c); + } + } + let code = |c: char| 0x21 + chars.iter().position(|&k| k == c).unwrap(); + let codes: String = text.chars().map(|c| format!("{:02X}", code(c))).collect(); + let ops = format!("BT /F1 14 Tf 20 220 Td <{codes}> Tj ET"); + let widths: Vec = chars.iter().map(|_| Object::Integer(width)).collect(); + let pairs: String = chars.iter().map(|&c| format!("<{:02X}> <{:04X}>\n", code(c), u32::from(c))).collect(); + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /T def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange {} beginbfchar\n{pairs}endbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + chars.len() + ); + let mut doc = Document::with_version("1.7"); + let unicode = doc.add_object(Stream::new(dictionary! {}, cmap.into_bytes())); + let mut differences = vec![Object::Integer(0x21)]; + differences.extend(chars.iter().map(|_| Object::Name(b"a".to_vec()))); + let font = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "FirstChar" => 0x21, "LastChar" => 0x20 + chars.len() as i64, + "Widths" => widths, "Encoding" => dictionary! { "Type" => "Encoding", "Differences" => differences }, "ToUnicode" => unicode + }); + let content = doc.add_object(Stream::new(dictionary! {}, ops.into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(600), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() +} + +/// A Thai tone mark or above or below vowel whose font gives it an advance of its own (here 300 units, as every +/// glyph) moves the pen, so the next letter of the word starts a mark's width past its base. The cluster ends at the +/// mark: no space opens inside "ข้อความ", "เดิม" or "ผู้ใช้", and the space glyphs between the words still read as +/// spaces. +#[test] +fn a_thai_mark_with_its_own_advance_does_not_open_a_word_gap() { + let thai = "ข้อความเดิม น้ำ ผู้ใช้"; + for width in [300, 600] { + let text = extract_page(&one_show_string(thai, width), 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), thai, "every glyph {width} units wide"); + for word in ["ข้อความเดิม", "น้ำ", "ผู้ใช้"] { + assert_eq!(text.find_opts(word, true, true).len(), 1, "{word:?} found as a whole word in {:?}", text.plain_text()); + } + } +} + /// The glyph under a point is the first whose box holds it, edges included; between glyphs, none. #[test] fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() {