diff --git a/crates/automation/tests/it/find_text.rs b/crates/automation/tests/it/find_text.rs new file mode 100644 index 0000000000..4ba2441305 --- /dev/null +++ b/crates/automation/tests/it/find_text.rs @@ -0,0 +1,84 @@ +//! `text_find` on a ligature glyph named in the encoding and on a word divided by a hyphen at a +//! line end. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// A fresh directory for one test. +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-find-text-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// One page: `ofce` in a Helvetica whose code 128 is named `/fi`, and `exam-` at the end of +/// one line with `ple` starting the next. +fn find_pdf() -> Vec { + let body = "BT /F2 14 Tf 90 700 Td (of\\200ce) Tj ET\nBT /F1 14 Tf 90 650 Td (exam-) Tj 0 -16 Td (ple) Tj ET\n"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>" + .to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// "office" finds the ligature, and "example" both halves of the divided word, highlighted line by +/// line; the hyphenated form is still found as written. +#[test] +fn text_find_matches_ligatures_and_words_divided_at_a_line_end() { + let dir = tmp("forms"); + std::fs::write(dir.join("find.pdf"), find_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "find.pdf" }))["doc"].as_u64().unwrap(); + let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q })); + + let office = find(&mut a, "office"); + assert_eq!(office["count"], 1, "{office}"); + let rects = office["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 1); + assert!((rects[0][0].as_f64().unwrap() - 90.0).abs() < 0.5, "from the left edge of the o: {rects:?}"); + + let example = find(&mut a, "example"); + assert_eq!(example["count"], 1, "{example}"); + assert_eq!(example["matches"][0]["text"], "exam-\nple"); + let rects = example["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 2, "one highlight on each line: {rects:?}"); + let top = |k: usize| rects[k][1].as_f64().unwrap(); + assert!(top(1) > top(0) + 10.0, "the second on the next line down: {rects:?}"); + + for q in ["exam-ple", "Exam- ple", "exam-"] { + assert_eq!(find(&mut a, q)["count"], 1, "{q}"); + } + assert_eq!(find(&mut a, "examples")["count"], 0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index b48b23bf0a..80205de56d 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -5,6 +5,7 @@ mod automation; mod automation_roots; mod batch; mod button_caption; +mod find_text; mod form_bulk; mod hostile_args; mod optimize_content_boundaries; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 054262ee35..34aca3eff0 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -128,7 +128,8 @@ impl PageText { } /// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks, - /// which count as a single space). + /// which count as a single space; a ligature matches its letters, and a word divided by a + /// hyphen at a line end matches whole as well as with the hyphen). pub fn find(&self, needle: &str) -> Vec> { self.find_opts(needle, false, false) } diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index c68660dbe9..d42dc218de 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -1,33 +1,74 @@ //! Literal search in normalized page text, retaining the original glyph owners. +//! +//! The page text is read as characters, each owned by the glyph it came from: words and lines are +//! separated by one space, and a Latin compatibility ligature (U+FB00–U+FB06) reads as its letters +//! in the page and in the query, so "office" finds "o\u{FB03}ce". A word divided by a hyphen at +//! the end of a line is also found whole: where a line ends in a hyphen between two letters, the +//! text is searched a second time without the hyphen and the line break ("example" finds +//! "exam-" + "ple") and a third time with the hyphen but without the break ("exam-ple"). A hit in +//! any reading counts; overlapping hits keep the one that starts first (the longer on a tie). use super::{PageText, fold_case}; use std::ops::Range; +/// How a line ending in a hyphen between two letters is read. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Hyphen { + /// As written: the hyphen, then a line break (a space). + Written, + /// One word: neither the hyphen nor the break. + Joined, + /// A hyphenated compound: the hyphen, but no break. + Kept, +} + +/// The letters of a Latin compatibility ligature (U+FB00–U+FB06), their compatibility decomposition. +fn ligature(c: char) -> Option<&'static str> { + match c { + '\u{FB00}' => Some("ff"), + '\u{FB01}' => Some("fi"), + '\u{FB02}' => Some("fl"), + '\u{FB03}' => Some("ffi"), + '\u{FB04}' => Some("ffl"), + '\u{FB05}' | '\u{FB06}' => Some("st"), + _ => None, + } +} + +/// `s` with each Latin compatibility ligature written as its letters. Nothing else is normalised. +fn letters(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match ligature(c) { + Some(l) => out.push_str(l), + None => out.push(c), + } + } + out +} + +/// A hyphen that can divide a word at a line end: HYPHEN-MINUS, SOFT HYPHEN (ISO 32000-2 +/// §14.8.2.3) and HYPHEN. +fn is_hyphen(c: char) -> bool { + matches!(c, '-' | '\u{AD}' | '\u{2010}') +} + +/// Whether a line break separates glyph `i` from the glyph before it. +fn breaks_before(page: &PageText, i: usize) -> bool { + i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b) +} + pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec> { if limit == 0 { return Vec::new(); } - let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; + let fold = |s: &str| if sensitive { letters(s) } else { fold_case(&letters(s)) }; let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() { return Vec::new(); } - let mut chars: Vec<(char, usize)> = Vec::new(); - for (i, glyph) in page.glyphs.iter().enumerate() { - let line_break = i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b); - let gap = page.space_before.get(i).copied().unwrap_or(false) || line_break; - if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') { - chars.push((' ', i)); - } - for c in fold(&glyph.text).chars() { - let c = if c.is_whitespace() { ' ' } else { c }; - if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { - chars.push((c, i)); - } - } - } - if needle.len() > chars.len() { - return Vec::new(); - } + let texts: Vec = page.glyphs.iter().map(|g| fold(&g.text)).collect(); + let divided: Vec = (0..texts.len()).map(|i| divides(page, &texts, i)).collect(); + let readings: &[Hyphen] = if divided.contains(&true) { &[Hyphen::Written, Hyphen::Joined, Hyphen::Kept] } else { &[Hyphen::Written] }; // KMP reuses a matched prefix instead of comparing a long query again at every position. // The table is bounded by the page's already materialized character count. let mut prefix = vec![0; needle.len()]; @@ -38,11 +79,81 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b *slot = matched; } } - matched = 0; + let mut hits = Vec::new(); + for &reading in readings { + hits.extend(search(&characters(page, &texts, ÷d, reading), &needle, &prefix, whole_words, limit)); + } + if readings.len() == 1 { + return hits; + } + // Each reading's hits are in order and apart; across readings, the earliest start wins. + hits.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + let mut kept: Vec> = Vec::new(); + for hit in hits { + if kept.last().is_none_or(|last| hit.start >= last.end) { + kept.push(hit); + if kept.len() == limit { + break; + } + } + } + kept +} + +/// Whether glyph `i` ends its line with a hyphen that follows a letter of its word, and the next +/// line starts with a letter. `texts` are the glyphs' folded texts. +fn divides(page: &PageText, texts: &[String], i: usize) -> bool { + let Some(text) = texts.get(i) else { return false }; + let mut back = text.chars().rev(); + if !back.next().is_some_and(is_hyphen) { + return false; + } + let before = match back.next() { + Some(c) => Some(c), + // A hyphen glyph of its own: the letter is the end of the glyph before, on its line with no word gap. + None if !page.space_before.get(i).copied().unwrap_or(false) && !breaks_before(page, i) => { + i.checked_sub(1).and_then(|p| texts.get(p)).and_then(|t| t.chars().next_back()) + } + None => None, + }; + let Some(next) = i.checked_add(1) else { return false }; + before.is_some_and(char::is_alphabetic) + && breaks_before(page, next) + && texts.get(next).and_then(|t| t.chars().next()).is_some_and(char::is_alphabetic) +} + +/// The page's characters in one `reading`, each with the glyph it came from. +fn characters(page: &PageText, texts: &[String], divided: &[bool], reading: Hyphen) -> Vec<(char, usize)> { + let mut chars: Vec<(char, usize)> = Vec::new(); + for (i, text) in texts.iter().enumerate() { + let after_divide = reading != Hyphen::Written && i.checked_sub(1).and_then(|p| divided.get(p)).copied().unwrap_or(false); + let gap = page.space_before.get(i).copied().unwrap_or(false) || (breaks_before(page, i) && !after_divide); + if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') { + chars.push((' ', i)); + } + // The divided glyph's last character is its hyphen. + let last = + if reading == Hyphen::Joined && divided.get(i).copied().unwrap_or(false) { text.chars().count().saturating_sub(1) } else { usize::MAX }; + for c in text.chars().take(last) { + let c = if c.is_whitespace() { ' ' } else { c }; + if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { + chars.push((c, i)); + } + } + } + chars +} + +/// Non-overlapping hits of `needle` (with its KMP `prefix` table) in `chars`, as glyph ranges. +fn search(chars: &[(char, usize)], needle: &[char], prefix: &[usize], whole_words: bool, limit: usize) -> Vec> { + if needle.len() > chars.len() { + return Vec::new(); + } + let mut matched = 0; let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric()); let mut hits = Vec::new(); for (i, &(c, owner)) in chars.iter().enumerate() { - matched = advance(&needle, &prefix, matched, c); + matched = advance(needle, prefix, matched, c); if matched != needle.len() { continue; } @@ -75,3 +186,14 @@ fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> us } if needle.get(matched) == Some(&c) { matched.saturating_add(1) } else { 0 } } + +#[cfg(test)] +mod tests { + use super::letters; + + #[test] + fn ligatures_read_as_their_letters() { + assert_eq!(letters("o\u{FB03}ce \u{FB00}\u{FB01}\u{FB02}\u{FB04}\u{FB05}\u{FB06}"), "office fffiflfflstst"); + assert_eq!(letters("\u{FB13}é\u{FF21}\u{2460}"), "\u{FB13}é\u{FF21}\u{2460}", "nothing else is normalised"); + } +} diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 44fbe49350..fc2d61136b 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -9,41 +9,138 @@ fn page(parts: &[&str]) -> PageText { } } -// The original straightforward search is an independent oracle for valid page metadata. +// The original straightforward search is an independent oracle for valid page metadata, extended +// with ligature letters and the three readings of a line-end hyphen between letters: as written, +// joined (no hyphen, no break) and kept (hyphen, no break). fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { - let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; + let letters = |s: &str| { + s.replace('\u{FB00}', "ff") + .replace('\u{FB01}', "fi") + .replace('\u{FB02}', "fl") + .replace('\u{FB03}', "ffi") + .replace('\u{FB04}', "ffl") + .replace(['\u{FB05}', '\u{FB06}'], "st") + }; + let fold = |s: &str| if sensitive { letters(s) } else { fold_case(&letters(s)) }; let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() || limit == 0 { return Vec::new(); } - let mut chars = Vec::new(); - for (i, glyph) in page.glyphs.iter().enumerate() { - if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') { - chars.push((' ', i)); - } - for c in fold(&glyph.text).chars() { - let c = if c.is_whitespace() { ' ' } else { c }; - if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') { - chars.push((c, i)); + let texts: Vec = page.glyphs.iter().map(|g| fold(&g.text)).collect(); + let n = texts.len(); + let new_line = |i: usize| i > 0 && page.line_of[i] != page.line_of[i - 1]; + let hyphen = |c: char| matches!(c, '-' | '\u{AD}' | '\u{2010}'); + let divided: Vec = (0..n) + .map(|i| { + let chars: Vec = texts[i].chars().collect(); + let Some(&last) = chars.last() else { return false }; + let before = if chars.len() >= 2 { + Some(chars[chars.len() - 2]) + } else if i > 0 && !page.space_before[i] && !new_line(i) { + texts[i - 1].chars().last() + } else { + None + }; + hyphen(last) + && before.is_some_and(char::is_alphabetic) + && i + 1 < n + && new_line(i + 1) + && texts[i + 1].chars().next().is_some_and(char::is_alphabetic) + }) + .collect(); + let mut all = Vec::new(); + let readings = if divided.contains(&true) { 3 } else { 1 }; + for reading in 0..readings { + let mut chars = Vec::new(); + for i in 0..n { + let joined = reading > 0 && i > 0 && divided[i - 1]; + if i > 0 && (page.space_before[i] || (new_line(i) && !joined)) && chars.last().is_some_and(|&(c, _)| c != ' ') { + chars.push((' ', i)); + } + let mut glyph: Vec = texts[i].chars().collect(); + if reading == 1 && divided[i] { + glyph.pop(); + } + for c in glyph { + let c = if c.is_whitespace() { ' ' } else { c }; + if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') { + chars.push((c, i)); + } } } - } - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric()); - let mut hits = Vec::new(); - let mut start = 0; - while start + needle.len() <= chars.len() { - let end = start + needle.len(); - if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) { - hits.push(chars[start].1..chars[end - 1].1 + 1); - if hits.len() == limit { - break; + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric()); + let mut hits = Vec::new(); + let mut start = 0; + while start + needle.len() <= chars.len() { + let end = start + needle.len(); + if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) { + hits.push(chars[start].1..chars[end - 1].1 + 1); + if hits.len() == limit { + break; + } + start = end; + } else { + start += 1; } - start = end; - } else { - start += 1; + } + all.push(hits); + } + if readings == 1 { + return all.remove(0); + } + let mut hits: Vec> = all.concat(); + hits.sort_by_key(|r| (r.start, std::cmp::Reverse(r.end))); + let mut out: Vec> = Vec::new(); + for h in hits { + if out.last().is_none_or(|l| h.start >= l.end) && out.len() < limit { + out.push(h); } } - hits + out +} + +/// One glyph per entry of `text`, each on the line given in `lines`. +fn lined(text: &[&str], lines: &[u32]) -> PageText { + let mut p = page(text); + p.line_of = lines.to_vec(); + p +} + +#[test] +fn a_ligature_matches_its_letters() { + let text = page(&["o", "\u{FB03}", "c", "e", " ", "o", "f", "\u{FB01}", "c", "e"]); + assert_eq!(text.find("office"), vec![0..4, 5..10]); + assert_eq!(text.find("OFFICE"), vec![0..4, 5..10]); + assert_eq!(text.find_opts("office", true, true), vec![0..4, 5..10], "case-sensitive and whole words too"); + assert_eq!(text.find("o\u{FB03}ce"), vec![0..4, 5..10], "a ligature in the query reads as its letters too"); + assert_eq!(text.find("fi"), vec![1..2, 7..8], "a match inside a ligature takes its glyph"); + assert!(text.find("o\u{FB13}").is_empty(), "no other compatibility folding"); +} + +#[test] +fn a_word_divided_by_a_line_end_hyphen_matches_whole_and_as_written() { + // "exam-" ends line 0, "ple" starts line 1; the hyphen is its own glyph. + let text = lined(&["e", "x", "a", "m", "-", "p", "l", "e"], &[0, 0, 0, 0, 0, 1, 1, 1]); + assert_eq!(text.find("example"), vec![0..8], "both parts, and the hyphen between them"); + assert_eq!(text.find_opts("example", false, true), vec![0..8], "a whole word"); + assert_eq!(text.find("exam-ple"), vec![0..8], "a query with the hyphen"); + assert_eq!(text.find("exam- ple"), vec![0..8], "and with the line break as a space"); + assert_eq!(text.find("exam"), vec![0..4]); + // The hyphen in the glyph of the last letter, a soft hyphen and U+2010. + for hyphen in ["m-", "m\u{AD}", "m\u{2010}"] { + let text = lined(&["e", "x", "a", hyphen, "p", "l", "e"], &[0, 0, 0, 0, 1, 1, 1]); + assert_eq!(text.find("example"), vec![0..7], "{hyphen:?}"); + } + // Not divided: a hyphen inside a line, a dash after a word gap, a hyphen between digits. + assert!(page(&["e", "x", "a", "m", "-", "p", "l", "e"]).find("example").is_empty(), "inside a line"); + let mut spaced = lined(&["e", "x", "a", "m", "-", "p", "l", "e"], &[0, 0, 0, 0, 0, 1, 1, 1]); + spaced.space_before[4] = true; + assert!(spaced.find("example").is_empty(), "a dash after a word gap"); + assert!(spaced.find("exam ple").is_empty(), "a dash after a word gap is never dropped"); + assert!(lined(&["1", "0", "-", "2", "0"], &[0, 0, 0, 1, 1]).find("1020").is_empty(), "between digits"); + assert!(lined(&["a", "-", "2", "0"], &[0, 0, 1, 1]).find("a20").is_empty(), "before a number"); + assert!(lined(&["1", "0", "-", "a", "b"], &[0, 0, 0, 1, 1]).find("10ab").is_empty(), "after a number"); + assert!(lined(&["a", "-"], &[0, 0]).find("a-").len() == 1, "at the end of the page"); } #[test] @@ -69,7 +166,7 @@ fn whole_word_rejection_keeps_overlapping_candidates() { #[test] fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { - let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"]; + let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{FB01}", "b\u{AD}", "1"]; let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..b5d718ff23 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1406,8 +1406,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable"] -notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic." +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable", "crates/render/tests/it/search.rs::a_ligature_matches_its_letters", "crates/render/tests/it/search.rs::a_word_divided_by_a_line_end_hyphen_matches_whole_and_as_written", "crates/automation/tests/it/find_text.rs::text_find_matches_ligatures_and_words_divided_at_a_line_end"] +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. A Latin ligature (U+FB00–FB06) matches its letters, and a word divided by a hyphen (U+002D, U+00AD or U+2010) between letters at a line end matches whole, highlighted on both lines, as well as with the hyphen. Missing: accent-insensitive matching (an unaccented query finding accented text; an open pull request adds it)." [[feature]] id = "view.find-next-previous"