Merge #6597: M3: copied and found text keeps combining marks after their letter and right-to-left word breaks between words (view.copy-text, view.find)

# Conflicts:
#	parity/acrobat-features.toml
This commit is contained in:
bflatastic committed 2026-11-09 08:20:39 +07:00
commit 45b95e3bd1
6 files changed
+943 -27

No files matched your search

Generated
+1
View File
@@ -4702,6 +4702,7 @@ dependencies = [
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
"unicode-bidi",
]
[[package]]
+3
View File
@@ -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]
+77 -9
View File
@@ -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<usize>, Vec<u32>, Vec<bool>) {
// 1. Segments in content order. Every run is a range of consecutive glyphs.
let mut segs: Vec<Seg> = Vec::new();
// `anchor[i]`: the glyph a combining mark rides on (the first glyph of its cluster), or `i`.
let mut anchor: Vec<usize> = (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<usize>, Vec<u32>, Vec<bool>) {
&& !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<usize>, Vec<u32>, Vec<bool>) {
let mut line_of: Vec<u32> = Vec::with_capacity(n);
let mut space_before: Vec<bool> = 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<bool> = Vec::new();
@@ -400,12 +460,16 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
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<usize>, Vec<u32>, Vec<bool>) {
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<usize>, Vec<u32>, Vec<bool>) {
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<usize>, Vec<u32>, Vec<bool>) {
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);
+250 -14
View File
@@ -152,6 +152,56 @@ pub(super) struct Scratch {
pub(super) logical: Vec<usize>,
/// `placed` with the marks merged in, built before it is swapped in.
pub(super) merged: Vec<usize>,
/// 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<usize>,
/// 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<Option<f32>>,
}
/// 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<usize>, step: fn(usize) -> Option<usize>| {
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<Item = usize>) -> 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<usize>, 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<usize>, 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) {
+608
View File
@@ -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<TextGlyph>, 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> <FFFF> 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<Object> = 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<char> = 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<Object> = 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> <FF> 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<char> = 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<Object> = 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> <FF> 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<TextGlyph> =
"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<TextGlyph> =
["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::<String>();
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<TextGlyph> = 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");
}
+4 -4
View File
@@ -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"