From 88dfcbdd18f757e82d4987bb2d00b7862c1ca6f3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:35:52 +0800 Subject: [PATCH] M3: Find keeps same-glyph hits on pages with a divided word On a page with a word divided by a line-end hyphen, Find searches three readings and merged their hits by dropping any hit that overlapped the one kept before it. That also dropped hits of one reading that share a glyph, such as "f" twice in an "ffi" ligature glyph, so the match count depended on unrelated text elsewhere on the page. A hit is now left out only where it overlaps one kept from another reading. The test oracle follows the same rule, and a fixed case covers it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/render/src/text/search.rs | 29 ++++++++++++++++++++--------- crates/render/tests/it/search.rs | 21 ++++++++++++++------- parity/acrobat-features.toml | 2 +- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index d42dc218de..d3c80af218 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -6,7 +6,8 @@ //! 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). +//! any reading counts; where hits from different readings overlap, the one that starts first is kept +//! (the longer on a tie). use super::{PageText, fold_case}; use std::ops::Range; @@ -79,18 +80,28 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b *slot = matched; } } - let mut hits = Vec::new(); - for &reading in readings { - hits.extend(search(&characters(page, &texts, ÷d, reading), &needle, &prefix, whole_words, limit)); + let mut hits: Vec<(Range, usize)> = Vec::new(); + for (r, &reading) in readings.iter().enumerate() { + let found = search(&characters(page, &texts, ÷d, reading), &needle, &prefix, whole_words, limit); + hits.extend(found.into_iter().map(|hit| (hit, r))); } if readings.len() == 1 { - return hits; + return hits.into_iter().map(|(hit, _)| hit).collect(); } - // 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))); + // Each reading's hits are in order and apart in its characters, though two can share a glyph + // ("f" twice in an "ffi" glyph); they are all kept. Across readings, the earliest start wins + // (the longer on a tie): a hit is left out where it overlaps one kept from another reading. + // The sort is stable, so the earlier reading wins an exact tie. + hits.sort_by(|a, b| a.0.start.cmp(&b.0.start).then(b.0.end.cmp(&a.0.end))); + // The furthest end of the hits kept from each reading. Hits come in order of start, so one + // overlaps an earlier kept hit exactly when it starts before that hit's end. + let mut ends = [0usize; 3]; let mut kept: Vec> = Vec::new(); - for hit in hits { - if kept.last().is_none_or(|last| hit.start >= last.end) { + for (hit, r) in hits { + if ends.iter().enumerate().all(|(other, &end)| other == r || hit.start >= end) { + if let Some(end) = ends.get_mut(r) { + *end = (*end).max(hit.end); + } kept.push(hit); if kept.len() == limit { break; diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index fc2d61136b..cfaf706b29 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -88,15 +88,18 @@ fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: 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); + // A hit is left out where it overlaps one kept from another reading; one reading's hits are + // all kept, though two can share a glyph. + let mut hits: Vec<(Range, usize)> = all.into_iter().enumerate().flat_map(|(r, hits)| hits.into_iter().map(move |h| (h, r))).collect(); + hits.sort_by_key(|(h, _)| (h.start, std::cmp::Reverse(h.end))); + let mut out: Vec<(Range, usize)> = Vec::new(); + for (h, r) in hits { + let overlaps = out.iter().any(|(k, kr)| *kr != r && h.start < k.end && k.start < h.end); + if !overlaps && out.len() < limit { + out.push((h, r)); } } - out + out.into_iter().map(|(h, _)| h).collect() } /// One glyph per entry of `text`, each on the line given in `lines`. @@ -162,6 +165,10 @@ fn whole_word_rejection_keeps_overlapping_candidates() { // Accepted matches still do not overlap, even within one multi-character glyph. assert_eq!(page(&["aaaaa"]).find("aaa"), vec![0..1]); assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]); + // So does a page with a word divided at a line end, which is searched in three readings. + let divided = lined(&["\u{FB03}", " ", "exam", "-", "ple"], &[0, 0, 0, 0, 1]); + assert_eq!(divided.find("f"), vec![0..1, 0..1]); + assert_eq!(divided.find("example"), vec![2..5]); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b5d718ff23..7c4769f2e8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1407,7 +1407,7 @@ 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::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)." +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"