From 0344ed44a0a2d5930db13c08f830ba65cfe9333f Mon Sep 17 00:00:00 2001 From: snipeTR <31975916+snipeTR@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:06:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(ui):=20bookmark,=20comment=20and=20command?= =?UTF-8?q?=20filters=20match=20Turkish=20=C4=B0/=C4=B1=20in=20any=20case?= =?UTF-8?q?=20(#937)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bookmark filter, the Comments panel filter and the command palette lowercased with str::to_lowercase, which follows no locale: "İSTANBUL" became "i̇stanbul" (a combining dot added) and "ı" has no case pair, so "istanbul" never found the bookmark "İSTANBUL ŞUBESİ" and "imzalayın" never found a comment "Lütfen İMZALAYIN". They now fold with pdfcraft_render::text::fold_case, the folding Find and Search & Redact use (made public here): I, i, İ and ı count as one letter and every other character folds to its own lowercase. --- crates/render/src/text.rs | 2 +- crates/ui-egui/src/comments_panel.rs | 23 +++++++++++++++++++++-- crates/ui-egui/src/palette.rs | 20 ++++++++++++++++++-- crates/ui-egui/src/panels.rs | 27 +++++++++++++++++++++++++-- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 7f64c3f5..fa129494 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -37,7 +37,7 @@ pub struct TextGlyph { /// combining dot (two characters) and leaves `ı` alone, so neither `İSTANBUL` nor `IRMAK` could /// be found by typing `istanbul` or `ırmak`. Search can't know the document's language, so the /// four are treated as one letter; this only adds matches where an `ı` or `İ` is involved. -fn fold_case(s: &str) -> String { +pub fn fold_case(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index d4958288..4dc4dfb7 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -90,8 +90,13 @@ pub fn count(info: &DocInfo) -> usize { } fn matches(a: &Annotation, q: &str) -> bool { - let q = q.to_lowercase(); - [a.author.as_deref(), a.contents.as_deref(), Some(kind_label(a))].into_iter().flatten().any(|s| s.to_lowercase().contains(&q)) + any_contains([a.author.as_deref(), a.contents.as_deref(), Some(kind_label(a))].into_iter().flatten(), q) +} + +/// Whether one of `fields` contains `q` in any case, Turkish dotted and dotless i included. +fn any_contains<'a>(fields: impl IntoIterator, q: &str) -> bool { + let q = pdfcraft_render::text::fold_case(q); + fields.into_iter().any(|s| pdfcraft_render::text::fold_case(s).contains(&q)) } /// Draw the panel body. Returns an edit to apply (posting, replying, deleting…). @@ -588,3 +593,17 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV }); command } + +#[cfg(test)] +mod filter { + use super::any_contains; + + #[test] + fn comments_match_in_any_case_with_turkish_i() { + assert!(any_contains(["Ayşe Yılmaz", "Lütfen İMZALAYIN"], "imzalayın")); + assert!(any_contains(["Ayşe Yılmaz"], "YILMAZ")); + assert!(any_contains(["IRMAK"], "ırmak")); + assert!(any_contains(["Note"], "NOTE")); + assert!(!any_contains(["Ayşe Yılmaz"], "mehmet")); + } +} diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index f11e1c15..c0908494 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -17,11 +17,12 @@ struct Hit { ready: bool, } +/// Where `needle` (folded with [`pdfcraft_render::text::fold_case`]) matches `hay`, for ranking. fn score(hay: &str, needle: &str) -> Option { if needle.is_empty() { return Some(0); } - let h = hay.to_lowercase(); + let h = pdfcraft_render::text::fold_case(hay); if let Some(p) = h.find(needle) { return Some(p); } @@ -48,7 +49,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { app.palette_open = false; return; } - let q = app.palette_query.trim().to_lowercase(); + let q = pdfcraft_render::text::fold_case(app.palette_query.trim()); let mut hits: Vec<(usize, Hit)> = Vec::new(); let active = app.active_ids().map(|(_, id)| id); for spec in pdfcraft_engine::commands::COMMANDS { @@ -192,3 +193,18 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod search { + use super::score; + use pdfcraft_render::text::fold_case; + + #[test] + fn commands_match_in_any_case_with_turkish_i() { + assert_eq!(score("İçe aktar", &fold_case("içe")), Some(0)); + assert_eq!(score("Yazdır", &fold_case("YAZDIR")), Some(0)); + assert_eq!(score("Insert Pages", &fold_case("ınsert")), Some(0)); + assert_eq!(score("Print", &fold_case("print")), Some(0)); + assert_eq!(score("Print", &fold_case("zz")), None); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index abdd38ee..72156d83 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -514,7 +514,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(search_id, bookmark_query.clone())); ui.add_space(6.0); } - let bookmark_query = bookmark_query.trim().to_lowercase(); + let bookmark_query = pdfcraft_render::text::fold_case(bookmark_query.trim()); let mut bookmark_matches = if panel == RightPanel::Bookmarks { outline_matches(&info.outline, &bookmark_query) } else { std::collections::HashSet::new() }; // A new bookmark must stay nameable even when its initial title doesn't match. @@ -730,6 +730,7 @@ pub enum BmAction { } /// Matching titles plus their ancestors, keeping document paths rather than filtered indexes. +/// `query` is folded with [`pdfcraft_render::text::fold_case`], as titles are here. fn outline_matches(items: &[OutlineItem], query: &str) -> std::collections::HashSet> { let mut matches = std::collections::HashSet::new(); if query.is_empty() { @@ -737,7 +738,7 @@ fn outline_matches(items: &[OutlineItem], query: &str) -> std::collections::Hash } let mut pending: Vec<_> = items.iter().enumerate().map(|(i, item)| (vec![i], item)).collect(); while let Some((path, item)) = pending.pop() { - if item.title.to_lowercase().contains(query) { + if pdfcraft_render::text::fold_case(&item.title).contains(query) { let mut ancestor = path.clone(); while !ancestor.is_empty() { matches.insert(ancestor.clone()); @@ -1313,3 +1314,25 @@ mod label_column { assert!(shown.ends_with('\u{2026}') && label.starts_with(shown.trim_end_matches('\u{2026}')), "{shown:?}"); } } + +#[cfg(test)] +mod bookmark_filter { + use super::outline_matches; + use pdfcraft_render::OutlineItem; + + fn item(title: &str, children: Vec) -> OutlineItem { + OutlineItem { title: title.into(), page: Some(0), view: pdfcraft_render::DestView::Top, children, open: true } + } + + /// Turkish dotted and dotless i match in any case, as in Find (#892). + #[test] + fn titles_match_in_any_case_with_turkish_i() { + let outline = [item("İSTANBUL ŞUBESİ", vec![item("Kış dönemi", vec![])]), item("Ankara", vec![])]; + let fold = pdfcraft_render::text::fold_case; + assert_eq!(outline_matches(&outline, &fold("istanbul")).len(), 1); + assert_eq!(outline_matches(&outline, &fold("şubesi")).len(), 1); + // A child match keeps its parent: [0] and [0, 0]. + assert_eq!(outline_matches(&outline, &fold("KIŞ")).len(), 2); + assert!(outline_matches(&outline, &fold("izmir")).is_empty()); + } +}