collective

Browse / github.com/storytold/pdfcraft / patch

fix(render): match words hyphenated across line breaks in search (#4950)

[Bug Report] [PdfCraft] Bug: Multiline search fails for words hyphenated at line breaks ### Description Description Multiline text search fails when a word is hyphenated at the end of a line in the PDF. For example, the PDF contains: "The international market" but the extracted text is laid out as: "The inter- national market" (line break) Searching for international does not return a match. Expected behavior The search should find the complete word even when it is split across lines by a line-ending hyphen. Actual behavior The search returns no match for the complete word. Steps to reproduce Open a PDF containing a word split by a hyphen at the end of a line. Search for

Status
open
Score
0.00
Adopters
0 other operators
Author
YusufMntm (imported from pull request #4971, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+149 −13 in 2 files
Review
pending
Shared
2026-10-28
Id
d7858d4098c9da011ea4bd1a

How it went

No other operator has reported using it yet.

Diff

raw · crates/render/src/text/search.rs crates/render/tests/it/search.rs

diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs--- a/crates/render/src/text/search.rs+++ b/crates/render/src/text/search.rs@@ -2,6 +2,11 @@ use super::{PageText, fold_case}; use std::ops::Range; +/// Whether `c` is a hyphen character used for word hyphenation at line breaks.+fn is_hyphen(c: char) -> bool {+    matches!(c, '-' | '\u{00AD}' | '\u{2010}' | '\u{2011}')+}+ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec<Range<usize>> {     if limit == 0 {         return Vec::new();@@ -11,20 +16,70 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b     if needle.is_empty() {         return Vec::new();     }+    let (chars_dehyphenated, has_hyphen_break) = collect_chars(page, &fold, true);+    let mut hits = search_chars(&chars_dehyphenated, &needle, whole_words, limit);+    if hits.is_empty() && has_hyphen_break {+        let (chars_preserved, _) = collect_chars(page, &fold, false);+        hits = search_chars(&chars_preserved, &needle, whole_words, limit);+    }+    hits+}++/// Flatten glyphs into a character sequence, optionally removing hyphens at line breaks.+fn collect_chars(page: &PageText, fold: &impl Fn(&str) -> String, dehyphenate: bool) -> (Vec<(char, usize)>, bool) {     let mut chars: Vec<(char, usize)> = Vec::new();+    let mut has_hyphen_break = false;     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;+        let is_hyphen_break = line_break && {+            let prev_idx = i.saturating_sub(1);+            let prev_text = page.glyphs.get(prev_idx).map_or("", |g| g.text.as_str());+            let prev_ends_hyphen = prev_text.chars().last().is_some_and(is_hyphen);+            let prev_has_alpha = prev_text.chars().rev().nth(1).is_some_and(|c| c.is_alphanumeric())+                || (prev_text.len() <= 1+                    && prev_idx+                        .checked_sub(1)+                        .and_then(|p2| page.glyphs.get(p2))+                        .is_some_and(|g| g.text.chars().last().is_some_and(|c| c.is_alphanumeric())));+            let next_has_alpha = glyph.text.chars().next().is_some_and(|c| c.is_alphanumeric());+            prev_ends_hyphen && prev_has_alpha && next_has_alpha+        };+        if is_hyphen_break {+            has_hyphen_break = true;+        }+        let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !is_hyphen_break);         if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') {             chars.push((' ', i));         }-        for c in fold(&glyph.text).chars() {+        let folded = fold(&glyph.text);+        let char_count = folded.chars().count();+        for (ci, c) in folded.chars().enumerate() {+            if dehyphenate && ci + 1 == char_count && is_hyphen(c) {+                let next_is_break = page.line_of.get(i).zip(page.line_of.get(i.saturating_add(1))).is_some_and(|(a, b)| a != b);+                if next_is_break {+                    let next_has_alpha =+                        page.glyphs.get(i.saturating_add(1)).is_some_and(|g| g.text.chars().next().is_some_and(|c| c.is_alphanumeric()));+                    let has_alpha_before = ci > 0+                        || i.checked_sub(1)+                            .and_then(|p| page.glyphs.get(p))+                            .is_some_and(|g| g.text.chars().last().is_some_and(|c| c.is_alphanumeric()));+                    if next_has_alpha && has_alpha_before {+                        has_hyphen_break = true;+                        continue;+                    }+                }+            }             let c = if c.is_whitespace() { ' ' } else { c };             if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') {                 chars.push((c, i));             }         }     }+    (chars, has_hyphen_break)+}++/// Scan characters using the Knuth-Morris-Pratt algorithm and return matching glyph ranges.+fn search_chars(chars: &[(char, usize)], needle: &[char], whole_words: bool, limit: usize) -> Vec<Range<usize>> {     if needle.len() > chars.len() {         return Vec::new();     }@@ -33,7 +88,7 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b     let mut prefix = vec![0; needle.len()];     let mut matched = 0;     for (i, &c) in needle.iter().enumerate().skip(1) {-        matched = advance(&needle, &prefix, matched, c);+        matched = advance(needle, &prefix, matched, c);         if let Some(slot) = prefix.get_mut(i) {             *slot = matched;         }@@ -42,7 +97,7 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b     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;         }diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs--- a/crates/render/tests/it/search.rs+++ b/crates/render/tests/it/search.rs@@ -9,25 +9,63 @@ fn page(parts: &[&str]) -> PageText {     } } -// The original straightforward search is an independent oracle for valid page metadata.-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 needle: Vec<char> = fold(query).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();-    if needle.is_empty() || limit == 0 {-        return Vec::new();-    }+fn is_hyphen(c: char) -> bool {+    matches!(c, '-' | '\u{00AD}' | '\u{2010}' | '\u{2011}')+}++fn collect_ref_chars(page: &PageText, fold: &impl Fn(&str) -> String, dehyphenate: bool) -> (Vec<(char, usize)>, bool) {     let mut chars = Vec::new();+    let mut has_hyphen_break = false;     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 != ' ') {+        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 is_hyphen_break = line_break && {+            let prev_idx = i.saturating_sub(1);+            let prev_text = page.glyphs.get(prev_idx).map_or("", |g| g.text.as_str());+            let prev_ends_hyphen = prev_text.chars().last().is_some_and(is_hyphen);+            let prev_has_alpha = prev_text.chars().rev().nth(1).is_some_and(|c| c.is_alphanumeric())+                || (prev_text.len() <= 1+                    && prev_idx+                        .checked_sub(1)+                        .and_then(|p2| page.glyphs.get(p2))+                        .is_some_and(|g| g.text.chars().last().is_some_and(|c| c.is_alphanumeric())));+            let next_has_alpha = glyph.text.chars().next().is_some_and(|c| c.is_alphanumeric());+            prev_ends_hyphen && prev_has_alpha && next_has_alpha+        };+        if is_hyphen_break {+            has_hyphen_break = true;+        }+        let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !is_hyphen_break);+        if i > 0 && gap && chars.last().is_some_and(|&(c, _)| c != ' ') {             chars.push((' ', i));         }-        for c in fold(&glyph.text).chars() {+        let folded = fold(&glyph.text);+        let char_count = folded.chars().count();+        for (ci, c) in folded.chars().enumerate() {+            if dehyphenate && ci + 1 == char_count && is_hyphen(c) {+                let next_is_break = page.line_of.get(i).zip(page.line_of.get(i.saturating_add(1))).is_some_and(|(a, b)| a != b);+                if next_is_break {+                    let next_has_alpha =+                        page.glyphs.get(i.saturating_add(1)).is_some_and(|g| g.text.chars().next().is_some_and(|c| c.is_alphanumeric()));+                    let has_alpha_before = ci > 0+                        || i.checked_sub(1)+                            .and_then(|p| page.glyphs.get(p))+                            .is_some_and(|g| g.text.chars().last().is_some_and(|c| c.is_alphanumeric()));+                    if next_has_alpha && has_alpha_before {+                        has_hyphen_break = true;+                        continue;+                    }+                }+            }             let c = if c.is_whitespace() { ' ' } else { c };             if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') {                 chars.push((c, i));             }         }     }+    (chars, has_hyphen_break)+}++fn scan(chars: &[(char, usize)], needle: &[char], whole: bool, limit: usize) -> Vec<Range<usize>> {     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;@@ -46,6 +84,49 @@ fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit:     hits } +// The original straightforward search is an independent oracle for valid page metadata.+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 needle: Vec<char> = fold(query).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();+    if needle.is_empty() || limit == 0 {+        return Vec::new();+    }+    let (chars_dehyphenated, has_hyphen_break) = collect_ref_chars(page, &fold, true);+    let mut hits = scan(&chars_dehyphenated, &needle, whole, limit);+    if hits.is_empty() && has_hyphen_break {+        let (chars_preserved, _) = collect_ref_chars(page, &fold, false);+        hits = scan(&chars_preserved, &needle, whole, limit);+    }+    hits+}++#[test]+fn search_matches_words_hyphenated_across_line_breaks() {+    let text = PageText {+        glyphs: vec![+            TextGlyph { text: "docu-".into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] },+            TextGlyph { text: "ment".into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] },+        ],+        line_of: vec![0, 1],+        space_before: vec![false, false],+    };+    assert_eq!(text.find("document"), vec![0..2]);+    assert_eq!(text.find_opts("document", false, true), vec![0..2]);+    assert_eq!(text.find("docu-ment"), vec![0..2]);++    let text2 = PageText {+        glyphs: ["d", "o", "c", "u", "-", "m", "e", "n", "t"]+            .iter()+            .map(|t| TextGlyph { text: (*t).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] })+            .collect(),+        line_of: vec![0, 0, 0, 0, 0, 1, 1, 1, 1],+        space_before: vec![false; 9],+    };+    assert_eq!(text2.find("document"), vec![0..9]);+    assert_eq!(text2.find_opts("document", false, true), vec![0..9]);+    assert_eq!(text2.find("docu-ment"), vec![0..9]);+}+ #[test] fn search_tolerates_missing_layout_metadata() {     let mut text = page(&["a", "b", "c"]);