mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 04:55:50 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f2674580ec
commit
88dfcbdd18
@@ -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>, 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<Range<usize>> = 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;
|
||||
|
||||
@@ -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<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);
|
||||
// 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>, 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>, 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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user