mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 20:37:10 -06:00
* 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>
179 lines
8.0 KiB
Rust
179 lines
8.0 KiB
Rust
//! 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 = ¶.lines[0];
|
||
let mut out = Vec::new();
|
||
for k in line.c0..line.c1 {
|
||
let cl = ¶.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));
|
||
}
|