From ffcc2ef320de7c337533618749e8f8d56e732576 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Wed, 7 Oct 2026 19:20:48 +0800 Subject: [PATCH] erf(i18n): cache translations and skip unnecessary bidi analysis tr() uses a OnceLock, initialized once, changing lookups from a linear scan to average O(1). Labels without RTL text, such as Chinese, Japanese, and Western-language labels, skip BidiInfo. Arabic, Hebrew, and directional controls retain their existing handling. --- crates/ui-egui/src/i18n.rs | 28 +++++++++++++---- crates/ui-egui/src/rtl.rs | 61 +++++++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 27e20f5..7047f8e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -3,6 +3,8 @@ //! Chinese and Arabic feature terminology is checked against public Adobe help where documented; //! project-specific labels are original. Sources and scope: `docs/localization-terminology.json`. +use std::{collections::HashMap, sync::OnceLock}; + mod ar; /// Supported interface languages: (code, name in that language). @@ -2635,14 +2637,13 @@ fn column(lang: &str) -> Option { /// `s` in `lang` (English, or the string itself, when there's no translation). pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { + static TRANSLATIONS: OnceLock> = OnceLock::new(); + static ARABIC: OnceLock> = OnceLock::new(); if lang == "ar" { - return ar::TABLE.iter().find(|(en, _)| *en == s).map_or(s, |(_, t)| *t); + return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } let Some(c) = column(lang) else { return s }; - match TABLE.iter().find(|(en, _)| *en == s) { - Some((_, t)) => t[c], - None => s, - } + TRANSLATIONS.get_or_init(|| TABLE.iter().copied().collect()).get(s).and_then(|row| row.get(c)).copied().unwrap_or(s) } /// Localize reserved built-in style names only; user-defined names are document data. @@ -2665,6 +2666,23 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { mod tests { use super::*; + #[test] + fn cached_lookup_preserves_every_translation_across_language_switches() { + for (key, translations) in TABLE { + for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) { + assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + } + assert_eq!(tr("", key), *key); + } + for (key, expected) in ar::TABLE { + assert_eq!(tr("ar", key), *expected, "ar: {key}"); + } + let unknown = String::from("A user-defined untranslated label"); + for lang in ["ar", "zh", "ja", "de", "fr", "es", "", "unknown"] { + assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str())); + } + } + #[test] fn translates_known_strings_and_keeps_the_rest() { assert_eq!(tr("de", "File"), "Datei"); diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 18ad6ab..bc042c7 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -3,13 +3,40 @@ use egui::text::{LayoutJob, TextWrapping}; use egui::{Align, Align2, Color32, FontFamily, FontId, Galley, Pos2, Rect, WidgetText}; use std::{ops::Range, sync::Arc}; -use unicode_bidi::BidiInfo; +use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; use unicode_segmentation::UnicodeSegmentation; +/// Allocation-free preflight: ordinary Latin, CJK and emoji labels need no bidi analysis. +/// Include directional controls so explicit embeddings/overrides keep their existing behavior. +fn may_have_rtl(text: &str) -> bool { + !text.is_ascii() + && text.chars().any(|c| { + matches!( + bidi_class(c), + BidiClass::R + | BidiClass::AL + | BidiClass::AN + | BidiClass::LRE + | BidiClass::RLE + | BidiClass::LRO + | BidiClass::RLO + | BidiClass::PDF + | BidiClass::LRI + | BidiClass::RLI + | BidiClass::FSI + | BidiClass::PDI + ) + }) +} + +fn has_rtl(text: &str) -> bool { + may_have_rtl(text) && BidiInfo::new(text, None).has_rtl() +} + /// Resolve translated labels through bidi, without changing IDs, commands or stored text. pub fn widget(ui: &egui::Ui, text: impl Into) -> WidgetText { let text = text.into(); - if text.text().is_ascii() || !BidiInfo::new(text.text(), None).has_rtl() { + if !has_rtl(text.text()) { return text; } let mut job = (*text.into_layout_job(ui.style(), egui::FontSelection::Default, Align::Center)).clone(); @@ -21,7 +48,7 @@ pub fn widget(ui: &egui::Ui, text: impl Into) -> WidgetText { /// Accessibility still receives the original logical string through Galley::job. pub fn label(ui: &mut egui::Ui, text: impl Into) -> egui::Response { let text = text.into(); - let rtl = !text.text().is_ascii() && BidiInfo::new(text.text(), None).has_rtl(); + let rtl = has_rtl(text.text()); if !rtl { return ui.label(text); } @@ -112,7 +139,7 @@ fn visual_line(job: &LayoutJob, bidi: &BidiInfo<'_>, line: Range) -> Layo } fn layout(ctx: &egui::Context, mut job: LayoutJob) -> Arc { - if job.text.is_ascii() || !BidiInfo::new(&job.text, None).has_rtl() { + if !has_rtl(&job.text) { return ctx.fonts_mut(|fonts| fonts.layout_job(job)); } let original = Arc::new(job.clone()); @@ -219,6 +246,32 @@ mod tests { galley.rows.iter().flat_map(|row| row.glyphs.iter().filter(|g| g.advance_width > 0.0).map(|g| g.chr)).collect() } + #[test] + fn ordinary_multilingual_labels_skip_bidi_analysis() { + for text in ["", "File (123)", "Édition", "中文排版", "スウォッチ", "e\u{301}", "🎨 🙂"] { + assert!(!may_have_rtl(text), "{text:?}"); + assert!(!BidiInfo::new(text, None).has_rtl(), "{text:?}"); + } + for text in ["ملف", "עברית", "PDF ملف 123", "\u{1e900}", "\u{200f}", "\u{202e}abc\u{202c}", "\u{2067}abc\u{2069}", "١٢٣"] { + assert!(may_have_rtl(text), "{text:?}"); + assert_eq!(has_rtl(text), BidiInfo::new(text, None).has_rtl(), "{text:?}"); + } + } + + #[test] + fn non_rtl_widgets_keep_native_text_and_formatting() { + with_fonts(|ctx| { + egui::Area::new(egui::Id::new("rtl-fast-path-test")).show(ctx, |ui| { + for text in ["中文", "日本語", "Édition", "🎨"] { + let rendered = widget(ui, egui::RichText::new(text).strong()); + assert!(matches!(rendered, WidgetText::RichText(_))); + assert_eq!(rendered.text(), text); + assert!(label(ui, text).rect.is_finite()); + } + }); + }); + } + #[test] fn mixed_arabic_latin_digits_and_brackets_preserve_logical_source() { with_fonts(|ctx| {