fix(ui): bookmark, comment and command filters match Turkish İ/ı in any case (#937)

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.
This commit is contained in:
snipeTR authored and GitHub committed 2026-10-11 13:06:22 -04:00
1 parent 5d301305c9
commit 0344ed44a0
4 files changed
+65 -7

No files matched your search

+1 -1
View File
@@ -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 {
+21 -2
View File
@@ -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<Item = &'a str>, 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"));
}
}
+18 -2
View File
@@ -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<usize> {
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);
}
}
+25 -2
View File
@@ -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<Vec<usize>> {
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 {
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());
}
}