diff --git a/Cargo.lock b/Cargo.lock index 5bcb1d80dc..341bbdcdf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4702,6 +4702,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 65c94852e6..b411a72f5a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -25,6 +25,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..e147cda84d 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,60 @@ 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 +} + +/// 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 }; + 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; + } +} + fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x3134F | 0x1100..=0x11FF) } @@ -173,13 +224,21 @@ 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 { 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]; @@ -196,6 +255,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 +452,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 +460,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 +482,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 +491,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(); @@ -441,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 efcabb5f1a..c0ff03a706 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -152,6 +152,56 @@ 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>, +} + +/// 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. 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; + 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 { + 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]; + } + } +} + +/// 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. @@ -243,7 +293,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); @@ -261,18 +312,19 @@ 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); } - // 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(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; } @@ -281,6 +333,123 @@ 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 +/// (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. +/// +/// 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 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 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 +/// 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 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. Nonspacing marks give none, alphabetic ones (U+0345) included: a + // mark has the direction of the letter it sits on. + let strong = |g: &TextGlyph| { + 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 spacing().any(char::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; + } + // 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| { + let mut p = from; + for _ in 0..TAKEN_BACK_SCAN { + let Some(g) = p.and_then(|p| glyph(placed, p)) else { return line_rtl }; + if let Some(rtl) = strong(g) { + return rtl; + } + p = p.and_then(step); + } + false + }; + for p in 1..placed.len() { + 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 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 mut r = p; + 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)), + }; + 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(); + } + } + } } /// `place_glyphs` for a segment of two or more text runs. `h` is the segment's line height. Returns whether the segment @@ -296,17 +465,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 +540,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,8 +551,10 @@ 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))); - let left = sc.group[start..].iter().map(|&i| x0(i)).fold(f32::INFINITY, f32::min); + sc.group[start..].sort_unstable_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b)); + // 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() }); @@ -412,6 +597,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 { @@ -419,11 +609,56 @@ 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. 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_alphabetic() { + 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 { + !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| 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), + _ => continue, + }; + if let Some(it) = items.get_mut(k) + && let Some(mark) = it.0 + && nsm(mark) + && anchor_of(anchor, mark) != mark + && anchor_of(anchor, mark) == anchor_of(anchor, prev) + { + 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 +/// more right-to-left than left-to-right letters ([`is_rtl_line`]); 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() { @@ -434,8 +669,9 @@ 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(""); - 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 }; + // 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. 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 f0bd9d864b..12c7ddc38e 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1386,6 +1386,257 @@ 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"); +} + +/// 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 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 +/// 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}"); + // 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 +/// 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; + } + // 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 +/// 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() { @@ -2167,6 +2418,190 @@ fn text_outside_the_crop_box_is_clipped() { } } +/// 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"); +} + +/// 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"); +} + +/// 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() { @@ -2181,3 +2616,176 @@ 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:?}"); +} + +/// 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"); +} + +/// 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::(); + 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); +} + +/// 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. +#[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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cf6a503e3b..3213783a2d 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", "crates/render/tests/it/search.rs::find_skips_soft_hyphens_and_zero_width_characters", "crates/render/tests/it/search.rs::find_joins_a_word_divided_at_a_line_end_soft_hyphen", "crates/render/tests/it/search.rs::a_zero_width_space_is_a_word_boundary_for_whole_words", "crates/render/tests/it/search.rs::a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines", "crates/render/tests/it/search.rs::a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text", "crates/render/tests/it/search.rs::a_word_divided_between_vertical_columns_is_found_in_both", "crates/render/tests/it/search.rs::each_unspaced_range_joins_and_spaced_scripts_do_not", "crates/render/tests/it/search.rs::a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine"] -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. Soft hyphens, zero width spaces and joiners, word joiners and U+FEFF inside a word are skipped, in the page and in the query; a skipped zero width space still ends a word for Whole words only. A line break reads as no word break when the line ends in a soft hyphen, or when it runs on between two Chinese or Japanese characters (East Asian Wide, Fullwidth or Halfwidth, not Hangul and not fullwidth Latin letters or digits), so a divided word is found, in horizontal lines and in vertical columns that continue to the left; the break stays a space beside Latin or Korean text and between text set side by side on one line or in staggered cells." +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/tests/it/search.rs::find_skips_soft_hyphens_and_zero_width_characters", "crates/render/tests/it/search.rs::find_joins_a_word_divided_at_a_line_end_soft_hyphen", "crates/render/tests/it/search.rs::a_zero_width_space_is_a_word_boundary_for_whole_words", "crates/render/tests/it/search.rs::a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines", "crates/render/tests/it/search.rs::a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text", "crates/render/tests/it/search.rs::a_word_divided_between_vertical_columns_is_found_in_both", "crates/render/tests/it/search.rs::each_unspaced_range_joins_and_spaced_scripts_do_not", "crates/render/tests/it/search.rs::a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine", "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. Soft hyphens, zero width spaces and joiners, word joiners and U+FEFF inside a word are skipped, in the page and in the query; a skipped zero width space still ends a word for Whole words only. A line break reads as no word break when the line ends in a soft hyphen, or when it runs on between two Chinese or Japanese characters (East Asian Wide, Fullwidth or Halfwidth, not Hangul and not fullwidth Latin letters or digits), so a divided word is found, in horizontal lines and in vertical columns that continue to the left; the break stays a space beside Latin or Korean text and between text set side by side on one line or in staggered cells." [[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::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"