M3: Find matches ligatures and words split by a line-end hyphen

Find, text_find and Search & Redact's word search read the Latin
compatibility ligatures U+FB00-U+FB06 as their letters, in the page and in
the query, so "office" finds a page drawn with an fi ligature glyph
(ISO 32000-2 9.10.3 maps ligature glyphs to their letters). Each letter
stays owned by its glyph, so the highlight covers the ligature.

Where a line ends in a hyphen (U+002D, U+00AD, U+2010) between two
letters, the page is also searched with the hyphen and the break dropped
("example" finds "exam-" + "ple") and with the hyphen kept but the break
dropped ("exam-ple"). The hits are merged in page order; a match covers
both lines and is highlighted on each. Pages without such a hyphen are
searched once, as before.

Accent-insensitive search is left to the open pull request for #3460.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
mtlcl410
2026-10-30 13:25:40 +08:00
co-authored by Claude Opus 5.5
parent f76dbb9e8c
commit f2674580ec
6 changed files with 354 additions and 49 deletions
+84
View File
@@ -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: `of<fi>ce` 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<u8> {
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);
}
+1
View File
@@ -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;
+2 -1
View File
@@ -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<std::ops::Range<usize>> {
self.find_opts(needle, false, false)
}
+142 -20
View File
@@ -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<Range<usize>> {
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<char> = fold(query).split_whitespace().collect::<Vec<_>>().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<String> = page.glyphs.iter().map(|g| fold(&g.text)).collect();
let divided: Vec<bool> = (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, &divided, 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<Range<usize>> = 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<Range<usize>> {
if needle.len() > chars.len() {
return Vec::new();
}
let mut matched = 0;
let word = |i: Option<usize>| 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");
}
}
+123 -26
View File
@@ -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<Range<usize>> {
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<char> = fold(query).split_whitespace().collect::<Vec<_>>().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<String> = 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<bool> = (0..n)
.map(|i| {
let chars: Vec<char> = 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<char> = 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<usize>| 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<usize>| 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<Range<usize>> = all.concat();
hits.sort_by_key(|r| (r.start, std::cmp::Reverse(r.end)));
let mut out: Vec<Range<usize>> = 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);
+2 -2
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"]
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"