Files
wordcraft/crates/layout/src/tests_numeral.rs
T
2f58d441b6 Numeral display modes: Arabic, Hindi, Context and System digits (view.numeral) (#501)
* Numeral display modes: Arabic, Hindi, Context and System digits

Display-only digit substitution (Word File > Options > Advanced >
Numeral): ASCII and Arabic-Indic digits are substituted before shaping
while clusters keep indexing the original text, so find, spelling,
copy/paste and saved files see the typed digits.

- layout: NumeralMode (arabic|hindi|context|system) with System resolved
  from LC_ALL/LANG, threaded through LayoutOptions/ParaEnv with cache
  invalidation; Context follows the surrounding text
- engine: view.numeral command + catalog row
- tests: layout tests_numeral, engine command test

* Options page: Numerals picker in File > Options > Display

ComboBox for the view.numeral mode (Arabic/Hindi/Context/System),
mirroring Word's File > Options > Advanced > Numeral. Display-only,
session-scoped like the other Display options.

* fmt: collapse numeral direction branch

* #501: translate the Numerals option label in every catalog

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

---------

Co-authored-by: abdo4312 <abdo4312@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:04:43 -04:00

179 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Numeral display modes (Word › File › Options › Advanced › Numeral): Western digits,
//! Hindi digits, or context-following digits — display only, the text is untouched.
use super::*;
use wordcraft_doc::props::ParaProps;
use wordcraft_doc::{Pos, StoryRef};
fn lay_opts(text: &str, rtl_para: bool, numeral: NumeralMode) -> DocLayout {
let mut d = Document::from_text(text);
if rtl_para {
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
}
let mut c = LayoutCache::new();
layout(&d, &mut c, &LayoutOptions { numeral, ..LayoutOptions::default() })
}
/// Glyph ids of the first line's clusters covering `text`, in cluster order.
fn line_gids(l: &DocLayout, text: &str) -> Vec<Vec<u32>> {
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Body, para, .. } = it {
let line = &para.lines[0];
let mut out = Vec::new();
for k in line.c0..line.c1 {
let cl = &para.clusters[k];
assert!(cl.start <= cl.end && cl.end <= text.len(), "cluster indexes the original text");
let gids: Vec<u32> = para.glyphs[cl.g0 as usize..cl.g1 as usize].iter().map(|g| g.gid).collect();
out.push(gids);
}
return out;
}
}
}
panic!("body paragraph not laid out");
}
#[test]
fn parse_accepts_the_four_modes_any_case() {
assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic));
assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi));
assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context));
assert_eq!(NumeralMode::parse("system"), Some(NumeralMode::System));
assert_eq!(NumeralMode::parse("Hindi"), Some(NumeralMode::Hindi));
assert_eq!(NumeralMode::parse("CONTEXT"), Some(NumeralMode::Context));
assert_eq!(NumeralMode::parse("western"), None);
assert_eq!(NumeralMode::parse(""), None);
assert_eq!(NumeralMode::parse("hindi "), None);
}
#[test]
fn map_substitutes_digits_both_ways() {
for d in '0'..='9' {
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
assert_eq!(NumeralMode::Hindi.map(d, true), hindi, "Hindi mode ignores direction");
assert_eq!(NumeralMode::Arabic.map(hindi, false), d);
assert_eq!(NumeralMode::Arabic.map(hindi, true), d, "Arabic mode converts back both ways");
assert_eq!(NumeralMode::Arabic.map(d, false), d);
assert_eq!(NumeralMode::Context.map(d, true), hindi);
assert_eq!(NumeralMode::Context.map(d, false), d);
assert_eq!(NumeralMode::Context.map(hindi, true), hindi);
assert_eq!(NumeralMode::Context.map(hindi, false), d);
}
// Letters, punctuation and Persian digits are never touched.
for c in ['A', 'ب', '.', ' ', '(', '۰', '۹'] {
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
assert_eq!(m.map(c, false), c, "{m:?} leaves {c:?} alone (ltr)");
assert_eq!(m.map(c, true), c, "{m:?} leaves {c:?} alone (rtl)");
}
}
}
#[test]
fn resolve_uses_the_given_system_locale() {
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved");
}
for tag in ["ar", "ar-EG", "fa_IR.UTF-8", "ur-PK", "ckb-IQ", "AR-sa"] {
assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Hindi, "{tag}");
}
for tag in ["en-US", "C", "", "tr_TR", "ary"] {
assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Arabic, "{tag}");
}
assert_eq!(NumeralMode::System.resolve(None), NumeralMode::Arabic, "unknown locale: Western digits");
}
#[test]
fn context_mode_on_a_very_long_digit_run_stays_linear() {
// Every digit used to scan back over all the digits and spaces before it (O(n²)).
let text = format!("سنة {}", "1 ".repeat(100_000));
let start = std::time::Instant::now();
let l = lay_opts(&text, false, NumeralMode::Context);
assert!(!l.pages.is_empty());
assert!(start.elapsed() < std::time::Duration::from_secs(60), "took {:?}", start.elapsed());
// The digits follow the Arabic word before them.
let short = "سنة 1 2 3";
assert_eq!(line_gids(&lay_opts(short, false, NumeralMode::Context), short), line_gids(&lay_opts(short, false, NumeralMode::Hindi), short));
}
#[test]
fn hindi_mode_reshapes_digits_but_keeps_offsets() {
let text = "Year 2024!";
let latin = lay_opts(text, false, NumeralMode::Arabic);
let hindi = lay_opts(text, false, NumeralMode::Hindi);
let a = line_gids(&latin, text);
let h = line_gids(&hindi, text);
assert_eq!(a.len(), h.len(), "same clusters in both modes");
assert!(!a.is_empty());
// Letter clusters draw identical glyphs; digit clusters draw different ones.
for (n, (ga, gh)) in a.iter().zip(h.iter()).enumerate() {
let ch = text.chars().nth(n).unwrap();
if ch.is_ascii_digit() {
assert_ne!(ga, gh, "digit {ch:?} reshaped in Hindi mode");
assert!(!gh.is_empty(), "Hindi digit draws something");
} else {
assert_eq!(ga, gh, "{ch:?} untouched by Hindi mode");
}
}
}
#[test]
fn arabic_mode_converts_hindi_digits_back() {
let text = "عام ٢٠٢٤";
let hindi = lay_opts(text, true, NumeralMode::Hindi);
let arabic = lay_opts(text, true, NumeralMode::Arabic);
let h = line_gids(&hindi, text);
let a = line_gids(&arabic, text);
assert_eq!(h.len(), a.len());
let mut changed = 0;
for (n, (gh, ga)) in h.iter().zip(a.iter()).enumerate() {
if text.chars().nth(n).unwrap() == '٢' {
assert_ne!(gh, ga);
changed += 1;
}
}
assert!(changed > 0, "typed Hindi digits display Western in Arabic mode");
}
#[test]
fn context_mode_follows_run_direction() {
// Right-to-left paragraph: digits behave like Hindi mode.
let rtl = "سنة 2024";
let ctx = line_gids(&lay_opts(rtl, true, NumeralMode::Context), rtl);
let hin = line_gids(&lay_opts(rtl, true, NumeralMode::Hindi), rtl);
assert_eq!(ctx, hin, "digits in RTL text show Hindi in Context mode");
// Left-to-right paragraph: digits behave like Arabic mode.
let ltr = "Year 2024";
let ctx = line_gids(&lay_opts(ltr, false, NumeralMode::Context), ltr);
let ara = line_gids(&lay_opts(ltr, false, NumeralMode::Arabic), ltr);
assert_eq!(ctx, ara, "digits in LTR text show Western in Context mode");
// Mixed paragraph: digits next to Arabic show Hindi even in a left-to-right paragraph.
let mixed = "Year سنة 2024";
let ctx = line_gids(&lay_opts(mixed, false, NumeralMode::Context), mixed);
let hin = line_gids(&lay_opts(mixed, false, NumeralMode::Hindi), mixed);
assert_eq!(ctx, hin, "digits by Arabic text show Hindi in Context mode");
}
#[test]
fn remap_range_maps_display_bytes_to_original() {
use crate::para::remap_range;
// "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it.
let orig = "a5ب";
let disp = "a٥ب";
let map_starts: Vec<usize> = disp.char_indices().map(|(i, _)| i).collect();
let orig_of: Vec<usize> = orig.char_indices().map(|(i, _)| i).collect();
let mut orig_of = orig_of;
orig_of.push(orig.len());
// Display "a" [0,1) → original [0,1); "٥" [1,3) → original [1,2); "ب" [3,5) → [2,4).
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 1), (0, 1));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 1, 3), (1, 2));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 5), (2, 4));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 5), (0, 4));
// Hostile inputs never panic and stay in range.
assert_eq!(remap_range(&[], &[], 0, 0, 0), (0, 0));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 99, 200), (4, 4));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 1), (1, 2));
}