AutoFit: estimate Arabic text width from measured glyph widths (#1933)

* M12.AR12: estimate Arabic text width from measured glyph widths in AutoFit

* M12.AR12: treat U+0897–089F as marks and U+08E2 as visible; size the honorific ligatures (review)

* M12.AR12: size the remaining honorific ligatures like their siblings (review)

U+FD4C-FD4F (Unicode 14) and U+FDC8-FDCF (FDCF Unicode 14, FDC8-FDCE
Unicode 17) are honorific word ligatures, not letters, but fell into
the 0.48 em letter arm. Give them the 1.6 em of the measured
U+FD40-FD4B honorifics and assert each range's endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR12: size Arabic-Indic digits like ASCII digits and the remaining honorific ligatures (review)

Arabic-Indic (U+0660-0669) and Extended Arabic-Indic (U+06F0-06F9)
digits measure 0.53-0.57 em in Noto Sans Arabic, Arial, Tahoma and
Times New Roman, the same as each font's ASCII digits, so they get
the ASCII digit estimate (0.56) instead of the letter estimate.
Arabic punctuation measures 0.17-0.50 em, so it keeps 0.48 rather
than falling back to 1 em.

U+FBC3-FBD2 and U+FD90-FD91 are honorific word ligatures like
U+FD40-FD4F and get the same 1.6 em.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-09 15:52:55 +01:00
1 parent 517a4b9256
commit 004523c286
3 files changed
+92 -2

No files matched your search

+46
View File
@@ -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);
+44 -1
View File
@@ -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<f32> {
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;
+2 -1
View File
@@ -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 `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` 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`) |