erf(i18n): cache translations and skip unnecessary bidi analysis

tr() uses a OnceLock<HashMap>, 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.
This commit is contained in:
Shao Wenbin 2026-10-07 19:20:48 +08:00
parent 0f09d48a7c
commit ffcc2ef320
2 changed files with 80 additions and 9 deletions

View File

@ -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<usize> {
/// `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<HashMap<&'static str, [&'static str; 5]>> = OnceLock::new();
static ARABIC: OnceLock<HashMap<&'static str, &'static str>> = 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");

View File

@ -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>) -> 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>) -> WidgetText {
/// Accessibility still receives the original logical string through Galley::job.
pub fn label(ui: &mut egui::Ui, text: impl Into<WidgetText>) -> 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<usize>) -> Layo
}
fn layout(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> {
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| {