mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
5d301305c9
commit
0344ed44a0
4 files changed
+65
-7
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user