diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 1066f48e2..d5eac5b5d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -870,6 +870,52 @@ mod tests { assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00"); } + #[test] + fn arabic_vowel_and_direction_marks_add_no_width() { + let plain = approx_text_width("محمد يدرس في المدرسة", 11.0, false); + assert_eq!(approx_text_width("مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", 11.0, false), plain); + assert_eq!(approx_text_width("\u{200F}محمد يدرس في المدرسة\u{200C}", 11.0, false), plain); + assert_eq!(approx_text_width("ب\u{0897}\u{0898}\u{089F}", 11.0, false), approx_text_width("ب", 11.0, false)); + assert!(approx_text_width("\u{08E2}", 11.0, false) > 0.0, "the disputed end of ayah is a visible sign"); + } + + #[test] + fn arabic_text_fits_a_column_about_as_wide_as_latin_text_of_the_same_length() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", "Hello there in school"]]})).unwrap(); + s.execute("home.autofitColumnWidth", json!({"cols": "A:B"})).unwrap(); + let doc = s.doc().unwrap(); + let width = |c: u32| doc.wb.active().and_then(|sh| sh.cols.get(&c)).and_then(|e| e.size).unwrap(); + let ratio = width(0) / width(1); + assert!((0.75..=1.15).contains(&ratio), "Arabic {} vs Latin {}", width(0), width(1)); + } + + #[test] + fn arabic_word_ligatures_are_wider_than_letters() { + let em = |t: &str| approx_text_width(t, 72.0, false) / 96.0; + assert!(em("\u{FDFD}") > 8.0); + assert!(em("\u{FDFA}") > 1.0); + let honorifics = ['\u{FD40}', '\u{FD4B}', '\u{FD4C}', '\u{FD4F}', '\u{FDC8}', '\u{FDCF}', '\u{FDFE}', '\u{FDFF}']; + for honorific in honorifics.into_iter().chain(['\u{FBC3}', '\u{FBD2}', '\u{FD90}', '\u{FD91}']) { + assert!(em(&honorific.to_string()) > 1.0, "U+{:04X}", u32::from(honorific)); + } + assert!(em("ب") < 0.6); + } + + #[test] + fn arabic_digits_are_as_wide_as_ascii_digits_and_punctuation_as_a_letter() { + let width = |t: &str| approx_text_width(t, 11.0, false); + assert_eq!(width("١٢٣"), width("123")); + assert_eq!(width("۴۵۶"), width("456")); + assert_eq!(width("،؟"), width("بب")); + } + + #[test] + fn cjk_text_keeps_a_full_em_per_character() { + assert_eq!(approx_text_width("中文", 12.0, false), 2.0 * 12.0 * 96.0 / 72.0); + } + #[test] fn width_units() { assert_eq!(chars_to_points(8.43), 64.0); diff --git a/crates/engine/src/cmd/format/sizes.rs b/crates/engine/src/cmd/format/sizes.rs index c79327678..8b304c889 100644 --- a/crates/engine/src/cmd/format/sizes.rs +++ b/crates/engine/src/cmd/format/sizes.rs @@ -136,12 +136,55 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 { c if c.is_ascii_uppercase() => 0.65, c if c.is_ascii_digit() => 0.56, c if c.is_ascii() => 0.52, - _ => 1.0, + c if is_zero_width(c) => 0.0, + c => arabic_em(c).unwrap_or(1.0), }; } w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0 } +/// Arabic vowel and Quranic marks, which sit on the previous letter, and invisible format +/// characters such as direction marks and zero-width joiners. +fn is_zero_width(c: char) -> bool { + matches!( + c, + '\u{0610}'..='\u{061A}' + | '\u{061C}' + | '\u{064B}'..='\u{065F}' + | '\u{0670}' + | '\u{06D6}'..='\u{06DC}' + | '\u{06DF}'..='\u{06E4}' + | '\u{06E7}'..='\u{06E8}' + | '\u{06EA}'..='\u{06ED}' + | '\u{0897}'..='\u{089F}' + | '\u{08CA}'..='\u{08E1}' + | '\u{08E3}'..='\u{08FF}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{2069}' + | '\u{FE20}'..='\u{FE2F}' + | '\u{FEFF}' + ) +} + +/// Width in ems of an Arabic-script character, measured from shaped Noto Sans Arabic, Noto Naskh +/// Arabic and DejaVu Sans: joined letters average about half an em, word ligatures such as ﷺ and the +/// rial sign about 1.2 em, the honorific ligatures (U+FD40–FD4B) about 1.6 em, and the bismillah +/// ligature about 11 em. The later honorific ligatures (U+FBC3–FBD2, U+FD4C–FD4F, U+FD90–FD91, +/// U+FDC8–FDCF) are not measured and get the 1.6 em of their siblings rather than the letter width. +/// Arabic-Indic digits are as wide as ASCII digits (0.53–0.57 em in Noto Sans Arabic, Arial, Tahoma +/// and Times New Roman); Arabic punctuation (0.2–0.5 em in the same fonts) keeps the letter width. +fn arabic_em(c: char) -> Option { + match c { + '\u{FDFD}' => Some(11.0), + '\u{FBC3}'..='\u{FBD2}' | '\u{FD40}'..='\u{FD4F}' | '\u{FD90}'..='\u{FD91}' | '\u{FDC8}'..='\u{FDCF}' => Some(1.6), + '\u{FDF0}'..='\u{FDFC}' | '\u{FDFE}'..='\u{FDFF}' => Some(1.2), + '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}' => Some(0.56), + '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{0870}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}' => Some(0.48), + _ => None, + } +} + /// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a /// quick measure so a huge column doesn't lay out every value. const EXACT_TEXT_WIDTHS: usize = 2_000; diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7b5dda2c0..3f1dacb96 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -110,6 +110,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | AutoFit column width, wrapped-row height and PivotTable column fit estimate Arabic letters at ~0.48 em (measured 0.36–0.53 em shaped, Noto Sans/Naskh Arabic and DejaVu Sans) instead of 1 em, Arabic-Indic digits as wide as ASCII digits, and give vowel marks and direction marks no width; vowelled Arabic no longer fits ~3.5× too wide | | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |