From 2f58d441b6abe05aa60f3c8ce7f33080e0bc565a Mon Sep 17 00:00:00 2001 From: abdo4312 <149546333+abdo4312@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:04:43 +0300 Subject: [PATCH] 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 --------- Co-authored-by: abdo4312 Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/view.rs | 18 ++- crates/engine/src/lib.rs | 2 +- crates/engine/src/session.rs | 27 ++++- crates/engine/src/tests.rs | 24 ++++ crates/layout/src/lib.rs | 91 ++++++++++++++ crates/layout/src/para.rs | 118 ++++++++++++++++-- crates/layout/src/table.rs | 2 + crates/layout/src/tests_numeral.rs | 178 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 7 +- crates/ui-egui/src/i18n/et.tsv | 7 +- crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 7 +- crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 7 +- crates/ui-egui/src/i18n/ru.tsv | 7 +- crates/ui-egui/src/i18n/sr-latn.tsv | 7 +- crates/ui-egui/src/i18n/sr.tsv | 7 +- crates/ui-egui/src/i18n/tr.tsv | 7 +- crates/ui-egui/src/i18n/uk.tsv | 7 +- crates/ui-egui/src/i18n/zh-hans.tsv | 7 +- crates/ui-egui/src/i18n/zh-hant.tsv | 7 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/options.rs | 19 +++ 26 files changed, 550 insertions(+), 31 deletions(-) create mode 100644 crates/layout/src/tests_numeral.rs diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 92d2cdf..47cbfc5 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -20,6 +20,7 @@ File|Backstage|Properties|file.properties File|Backstage|Export PDF|file.exportPdf File|Backstage|Export Image|file.exportPng File|Backstage|Options|file.options +File|Options|Numerals|view.numeral File|Backstage|Share|file.share File|Backstage|Protect Document|file.protect File|Backstage|Inspect Document|file.inspect diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 1e2e0cf..b1b6fec 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,7 +1,7 @@ //! View tab and status bar: views, zoom, show/hide, panes. use serde_json::{Value, json}; -use wordcraft_layout::ViewMode; +use wordcraft_layout::{NumeralMode, ViewMode}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -83,6 +83,22 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))) .pure(), + // Word › File › Options › Advanced › Numeral: how digits look on screen (display + // only; the document text keeps the typed codepoints). + CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") { + None => Ok(json!({"value": s.view.numeral})), + Some(Value::String(name)) => { + let Some(mode) = NumeralMode::parse(name) else { + return Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())); + }; + s.view.numeral = mode; + s.relayout(); + Ok(json!({"value": s.view.numeral})) + } + Some(_) => Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())), + }) + .params(r#"{"value"?: "arabic|hindi|context|system"}"#) + .pure(), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ec7893..927d426 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,7 +25,7 @@ use serde_json::Value; pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; -pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState}; +pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState, set_system_locale}; pub use wordcraft_doc as doc; pub use wordcraft_layout as layout; pub use wordcraft_proof as proof; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 85f07b6..3c01c43 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,13 +1,22 @@ //! The editing session: document, selection, undo/redo, layout, view state. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; + +/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. +static SYSTEM_LOCALE: OnceLock = OnceLock::new(); + +/// Tell the engine the system locale (the shell calls this once at startup; layout never reads +/// the environment). Headless sessions without it show Western digits for Numeral › System. +pub fn set_system_locale(tag: &str) { + let _ = SYSTEM_LOCALE.set(tag.to_string()); +} use serde::{Deserialize, Serialize}; use serde_json::Value; use wordcraft_doc::edit::Fragment; use wordcraft_doc::props::CharProps; use wordcraft_doc::{Document, Path, Pos, StoryRef}; -use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode}; +use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode}; use crate::{CmdError, Registry}; @@ -86,6 +95,9 @@ pub struct ViewState { pub track_changes_pane: bool, /// Check spelling and grammar as you type. pub proofing: bool, + /// How digits look on screen (Word › File › Options › Advanced › Numeral). + #[serde(default)] + pub numeral: NumeralMode, /// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document). #[serde(default)] pub hide_ink: bool, @@ -138,6 +150,7 @@ impl Default for ViewState { show_markup: true, track_changes_pane: false, proofing: true, + numeral: NumeralMode::default(), hide_ink: false, draw: Default::default(), field_codes: false, @@ -269,8 +282,8 @@ pub struct Session { document_id: u64, cache: LayoutCache, /// (revision, web width, view, layout, proofing, outline, (deletions left out, body - /// deletions in balloons)). - layout: Option<(u64, f32, ViewMode, Arc, bool, Option, (bool, bool))>, + /// deletions in balloons), numeral display). + layout: Option<(u64, f32, ViewMode, Arc, bool, Option, (bool, bool), NumeralMode)>, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, /// Last mutating command (Repeat). @@ -572,11 +585,12 @@ impl Session { let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions(); let deleted = (hide_deleted, self.revisions_in_balloons()); let outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view()); - if let Some((r, w, m, l, pf, o, hd)) = &self.layout + if let Some((r, w, m, l, pf, o, hd, n)) = &self.layout && *r == self.rev && (*w == ww || self.view.mode == ViewMode::Print) && *m == self.view.mode && *pf == self.view.proofing + && *n == self.view.numeral && *hd == deleted && *o == outline { @@ -589,13 +603,14 @@ impl Session { hide_deleted, deletions_in_balloons: deleted.1, proofing: self.view.proofing, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), comment_balloons: false, field_codes: self.view.field_codes, outline: outline.clone(), proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted)); + self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted, self.view.numeral)); l } /// A layout for output (PDF, images, print): no proofing marks, print view. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5961310..e292a8f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2615,6 +2615,30 @@ fn page_and_table_gridlines_toggle_independently() { assert_eq!(state["tableGridlines"], false); } +/// File › Options › Advanced › Numeral: the display mode for digits (display only). +#[test] +fn numeral_mode_sets_gets_and_rejects_junk() { + use wordcraft_layout::NumeralMode; + let mut s = s(); + assert_eq!(s.view.numeral, NumeralMode::Arabic, "Western digits by default"); + let r = run(&mut s, "view.numeral", json!({"value": "hindi"})); + assert_eq!(r["value"], "hindi"); + assert_eq!(s.view.numeral, NumeralMode::Hindi); + let r = run(&mut s, "view.numeral", json!({})); + assert_eq!(r["value"], "hindi", "no params reads the current mode"); + run(&mut s, "view.numeral", json!({"value": "Context"})); + assert_eq!(s.view.numeral, NumeralMode::Context, "mode names ignore case"); + assert!(s.run("view.numeral", &json!({"value": "western"})).is_err()); + assert!(s.run("view.numeral", &json!({"value": 3})).is_err()); + assert!(s.run("view.numeral", &json!({"value": ["hindi"]})).is_err()); + assert_eq!(s.view.numeral, NumeralMode::Context, "rejected values keep the mode"); + // Switching modes relayouts without disturbing the text. + s.doc = wordcraft_doc::Document::from_text("Year 2024"); + run(&mut s, "view.numeral", json!({"value": "hindi"})); + let _l = s.layout(); + assert_eq!(text(&s), "Year 2024"); +} + /// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom /// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck /// short of the 10%–500% limits. diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3b13445..9e3f0ad 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -52,6 +52,88 @@ pub enum ViewMode { Outline, } +/// How digits look on screen (Word › File › Options › Advanced › Numeral). +/// +/// Display only: the document text keeps the typed codepoints, so find, spelling, copying and +/// the saved file all see the original digits. Extended Arabic-Indic digits (U+06F0–U+06F9, +/// Persian/Urdu ۰۱۲۳…) always show as typed; only ASCII and Arabic-Indic (U+0660–U+0669) digits +/// are substituted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NumeralMode { + /// Western digits everywhere (`0123456789`); the default, today's behaviour. + #[default] + Arabic, + /// Eastern Arabic-Indic digits everywhere (`٠١٢٣٤٥٦٧٨٩`). + Hindi, + /// Hindi digits in and around right-to-left text, Western digits elsewhere (numbers take + /// an even bidi level, so the surrounding text decides). + Context, + /// Hindi digits when the system locale is an Arabic-script language, else Western digits. + /// Call [`NumeralMode::resolve`] before laying out. + System, +} + +impl NumeralMode { + /// Short UI label (Word's option names). + pub fn label(self) -> &'static str { + match self { + NumeralMode::Arabic => "Arabic", + NumeralMode::Hindi => "Hindi", + NumeralMode::Context => "Context", + NumeralMode::System => "System", + } + } + + /// Parse a command value (`"arabic" | "hindi" | "context" | "system"`, any case). + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "arabic" => Some(NumeralMode::Arabic), + "hindi" => Some(NumeralMode::Hindi), + "context" => Some(NumeralMode::Context), + "system" => Some(NumeralMode::System), + _ => None, + } + } + + /// Resolve `System` against the system locale (a BCP 47 or POSIX tag such as `ar-EG` or + /// `fa_IR.UTF-8`, found by the shell: layout never reads the environment). Hindi digits for + /// Arabic-script languages, else Western; unknown locale = Western. Other modes pass through. + pub fn resolve(self, system_locale: Option<&str>) -> NumeralMode { + if self != NumeralMode::System { + return self; + } + if system_locale.is_some_and(hindi_locale) { NumeralMode::Hindi } else { NumeralMode::Arabic } + } + + /// The displayed form of `c` in a run shaped with direction `rtl`. + /// + /// One character maps to exactly one character, so shaped text stays codepoint-aligned + /// with the document text (only byte offsets shift). A `System` mode that was not + /// resolved falls back to Western digits. + pub fn map(self, c: char, rtl: bool) -> char { + let hindi = match self { + NumeralMode::Arabic => false, + NumeralMode::Hindi => true, + NumeralMode::Context => rtl, + NumeralMode::System => false, + }; + if hindi { + if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c } + } else if ('\u{660}'..='\u{669}').contains(&c) { + (b'0' + (c as u32 - 0x660) as u8) as char + } else { + c + } + } +} + +/// Does locale `tag` use Hindi digits (Arabic, Persian, Urdu, Pashto, Sindhi, Kurdish)? +fn hindi_locale(tag: &str) -> bool { + let lang = tag.split(['_', '-', '.', '@']).next().unwrap_or_default().to_ascii_lowercase(); + matches!(lang.as_str(), "ar" | "fa" | "ur" | "ps" | "sd" | "ku" | "ckb") +} + #[derive(Clone, Debug, Default)] pub struct LayoutOptions { pub view: ViewMode, @@ -66,6 +148,9 @@ pub struct LayoutOptions { pub deletions_in_balloons: bool, /// Check spelling as you type (squiggles). pub proofing: bool, + /// How digits look on screen (display only; the text is untouched). Callers resolve + /// `System` ([`NumeralMode::resolve`]); an unresolved `System` shows Western digits. + pub numeral: NumeralMode, /// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`] /// and show the comments there in balloons joined to their anchors, as Word prints them. /// The editor draws its own interactive balloons and leaves this off. @@ -409,6 +494,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { opts.hide_deleted, opts.deletions_in_balloons, opts.proofing, + opts.numeral, opts.field_codes, opts.proof, wordcraft_proof::dictionary_generation(), @@ -549,6 +635,7 @@ impl Ctx<'_> { hide_deleted: false, table: None, proofing: None, + numeral: self.opts.numeral, exclusions: &[], eq_number: 0, grid: para::Grid::default(), @@ -618,6 +705,7 @@ impl Ctx<'_> { hide_deleted: self.hide_deleted, table, proofing: self.opts.proofing.then_some(self.opts.proof), + numeral: self.opts.numeral, exclusions, eq_number, grid: self.grid, @@ -2715,6 +2803,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { hide_deleted: ctx.opts.hides_deleted(StoryRef::Body), table: None, proofing: None, + numeral: ctx.opts.numeral, exclusions: &[], eq_number: ctx.eq_count, grid: ctx.grid, @@ -3038,6 +3127,8 @@ mod tests_complex; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_numeral; +#[cfg(test)] mod tests_pages; #[cfg(test)] mod tests_textbox; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 85e8e0c..e896016 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -12,6 +12,7 @@ use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara}; use wordcraft_doc::{Document, Paragraph}; use wordcraft_fonts::FaceRef; +use crate::NumeralMode; use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text}; /// A face at a size with the formatting needed to draw it. @@ -334,6 +335,8 @@ pub struct ParaEnv<'a> { pub table: Option<&'a CellText>, /// Check spelling (and grammar, as the options say) for squiggles; `None` = no squiggles. pub proofing: Option, + /// How digits look on screen (an unresolved `System` shows Western digits). + pub numeral: NumeralMode, /// Areas text must flow around (floating objects), relative to the paragraph: x from the /// column's left edge, y from the top of the first line. pub exclusions: &'a [Exclusion], @@ -467,6 +470,64 @@ fn is_mark(c: char) -> bool { matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control() } +/// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669). +fn is_numeral_digit(c: char) -> bool { + c.is_ascii_digit() || ('\u{660}'..='\u{669}').contains(&c) +} + +/// Characters skipped when looking for the text around a number: digits, spaces and the +/// Arabic decimal/thousands separators (٫ ٬). +fn is_number_inside(c: char) -> bool { + is_numeral_digit(c) || ('\u{6f0}'..='\u{6f9}').contains(&c) || c == ' ' || c == '\t' || c == '\u{66b}' || c == '\u{66c}' +} + +/// Which digits of run `text` sit in right-to-left surroundings (Word › Numeral › Context), by +/// byte offset. European numbers take an even bidi level, so a digit's own level says little; +/// the nearest neighbour on either side that isn't part of the number (digits, spaces, +/// separators) decides. One pass each way, so long runs of digits and spaces stay linear. +/// Offsets past `levels` count as left to right. +fn context_digit_dirs(text: &str, levels: &[u8], base: usize) -> Vec { + let odd = |o: usize| levels.get(base.saturating_add(o)).is_some_and(|l| l % 2 == 1); + let mut out = vec![false; text.len()]; + let mut left_rtl = false; + for (o, c) in text.char_indices() { + if !is_number_inside(c) { + left_rtl = odd(o); + } else if is_numeral_digit(c) + && let Some(v) = out.get_mut(o) + { + *v = odd(o) || left_rtl; + } + } + let mut right_rtl = false; + for (o, c) in text.char_indices().rev() { + if !is_number_inside(c) { + right_rtl = odd(o); + } else if right_rtl + && is_numeral_digit(c) + && let Some(v) = out.get_mut(o) + { + *v = true; + } + } + out +} + +/// Map a byte range of numeral-substituted display text back to the original text. +/// +/// `map_starts[i]` is the display byte offset of the `i`th char and `orig_of[i]` its original +/// byte offset (`orig_of` ends with the original length as a sentinel). Substitution is +/// 1 char → 1 char, so both ends land on char boundaries; anything else falls back to the +/// end of the original text instead of panicking or slicing mid-character. +pub(crate) fn remap_range(map_starts: &[usize], orig_of: &[usize], orig_len: usize, ms: usize, me: usize) -> (usize, usize) { + let n = map_starts.len(); + let i = map_starts.partition_point(|&b| b < ms).min(n); + let j = map_starts.partition_point(|&b| b < me).min(n); + let s = orig_of.get(i).copied().unwrap_or(orig_len); + let e = orig_of.get(j).copied().unwrap_or(orig_len); + (s.min(e), s.max(e)) +} + /// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the /// paragraph is left to right and has no right-to-left characters. pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec { @@ -517,6 +578,15 @@ fn east_asian(c: char) -> bool { } impl<'a> Builder<'a> { + /// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one + /// char out. `rtl` is the shaping direction; in Context mode `context` (from + /// [`context_digit_dirs`], empty otherwise) gives each digit's surroundings, since European + /// numbers take an even bidi level. + fn numeral_char(&self, context: &[bool], at: usize, c: char, rtl: bool) -> char { + let dir = if !context.is_empty() && is_numeral_digit(c) { context.get(at).copied().unwrap_or(false) } else { rtl }; + self.env.numeral.map(c, dir) + } + /// Style index for a resolved char in `face` (caps-scaled `size` override for small caps). fn style(&mut self, rc: &Arc, face_override: Option, small: bool) -> u16 { let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); @@ -595,6 +665,7 @@ impl<'a> Builder<'a> { .filter(|r| !r.substituted) .map(|r| r.face); let use_levels = para_text && !self.levels.is_empty(); + let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() }; // One fallback face for the run's East Asian characters: the first face in fallback order // covering every character the primary font lacks, so the run draws in a single CJK face // instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered @@ -616,27 +687,31 @@ impl<'a> Builder<'a> { let mut cur: Option = None; let mut segs: Vec<(usize, usize, SegKey)> = Vec::new(); for (i, c) in text.char_indices() { + let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1); + // Displayed form (Word › Numeral): one char in, one char out. Coverage and the + // fallback face are picked for the displayed char, so Hindi digits land in an + // Arabic-capable face and Western digits in a Latin one. + let dc = self.numeral_char(&context, i, c, rtl); let mark = is_mark(c); let complex = match cur { Some(k) if mark => k.complex, - _ => rc_cs.is_some() && rc.uses_complex(c), + _ => rc_cs.is_some() && rc.uses_complex(dc), }; let prim = if complex { primary_cs.unwrap_or(primary) } else { primary }; let face = match cur { Some(k) if mark => k.face, _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea, - _ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, + _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, // Stay in the fallback face the word started in while it covers the letters. - Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f), + Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f), // The run's unified East Asian face, when one face covers the whole run. _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face, _ => { let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) }; - wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f)) + wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f)) } }; let small = rc.small_caps && !rc.caps && c.is_lowercase(); - let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1); let k = SegKey { face, small, complex, rtl }; match cur { Some(p) if p.same(&k) => {} @@ -662,21 +737,38 @@ impl<'a> Builder<'a> { let si = self.style(rc, key.face, small); let Some(st) = self.styles.get(si as usize).cloned() else { continue }; let upper = rc.caps || small; + // Displayed text: numeral substitution is 1 char → 1 char, so codepoints stay + // aligned with the document and only byte offsets shift; `orig_of` maps each + // displayed char back to its original byte offset (+ a `sub.len()` sentinel). + let substituted = sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c); + let mut mapped = String::new(); + let mut map_starts: Vec = Vec::new(); + let mut orig_of: Vec = Vec::new(); + if substituted { + mapped = String::with_capacity(sub.len()); + for (i, c) in sub.char_indices() { + map_starts.push(mapped.len()); + orig_of.push(i); + mapped.push(self.numeral_char(&context, a + i, c, key.rtl)); + } + orig_of.push(sub.len()); + } + let shape_text = if substituted { mapped.as_str() } else { sub }; let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c }; let shaped = if use_levels { - wordcraft_fonts::shape_run(&st.face, sub, &[], map, key.rtl) + wordcraft_fonts::shape_run(&st.face, shape_text, &[], map, key.rtl) } else { - wordcraft_fonts::shape(&st.face, sub, &[], map) + wordcraft_fonts::shape(&st.face, shape_text, &[], map) }; let k = st.size / st.face.upem.max(1.0) as f32; let hscale = rc.scale / 100.0; // Group glyphs by cluster byte offset; graphemes may span several shaper clusters. // Complex-script syllables the graphemes split (Myanmar vowel signs, stacked // consonants) stay one cluster: the caret never stops inside them. - let bounds: Vec = wordcraft_doc::cluster::cluster_starts(sub); + let bounds: Vec = wordcraft_doc::cluster::cluster_starts(shape_text); let mut gi = 0usize; for (bi, &gs) in bounds.iter().enumerate() { - let ge = bounds.get(bi + 1).copied().unwrap_or(sub.len()); + let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len()); let g0 = self.glyphs.len() as u32; let mut adv = 0.0f32; while let Some(g) = shaped.get(gi) { @@ -688,7 +780,7 @@ impl<'a> Builder<'a> { gi += 1; } let g1 = self.glyphs.len() as u32; - let s = sub.get(gs..ge).unwrap_or(""); + let s = shape_text.get(gs..ge).unwrap_or(""); let ch = s.chars().next().unwrap_or(' '); let kind = kind_override.unwrap_or(if ch == ' ' || ch == '\u{3000}' { ClKind::Space @@ -698,9 +790,11 @@ impl<'a> Builder<'a> { ClKind::Text }); let adv = if kind == ClKind::Marker { 0.0 } else { adv + rc.spacing }; + // Clusters index the document text, never the displayed text. + let (cs, ce) = if substituted { remap_range(&map_starts, &orig_of, sub.len(), gs, ge) } else { (gs, ge) }; self.clusters.push(Cluster { - start: base + a + gs, - end: base + a + ge, + start: base + a + cs, + end: base + a + ce, adv, kind, style: si, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index c305674..c0e80fa 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -168,6 +168,8 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f hide_deleted: false, table: text.as_ref(), proofing: None, + // Measurement uses a fixed environment (like show_hidden/proofing above). + numeral: crate::NumeralMode::default(), exclusions: &[], eq_number: 0, grid: crate::para::Grid::default(), diff --git a/crates/layout/src/tests_numeral.rs b/crates/layout/src/tests_numeral.rs new file mode 100644 index 0000000..9de3e8e --- /dev/null +++ b/crates/layout/src/tests_numeral.rs @@ -0,0 +1,178 @@ +//! 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> { + 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 = 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 = disp.char_indices().map(|(i, _)| i).collect(); + let orig_of: Vec = 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)); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7864626..3569ce2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2048,3 +2048,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otevře Nastavení › Výchozí aplikace, kde zvolíte WordCraft pro .docx a další typy dokumentů. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Chcete-li dokumenty Word otevírat ve WordCraftu jako výchozí: vyberte jeden ve Finderu, zvolte Soubor › Informace, v části „Otevřít v aplikaci“ vyberte WordCraft a klikněte na „Změnit vše“. WordCraft is now the default app for Word documents. WordCraft je nyní výchozí aplikací pro dokumenty Word. + Numerals: Číslice: + Numerals Číslice + Hindi Hindské + Context Podle kontextu + System Systémové diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 354d07f..e2b62dc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2042,3 +2042,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Öffnet Einstellungen › Standard-Apps, wo Sie WordCraft für .docx und die anderen Dokumenttypen wählen. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Um Word-Dokumente standardmäßig mit WordCraft zu öffnen: Wählen Sie eines im Finder aus, wählen Sie Ablage › Informationen, wählen Sie WordCraft unter „Öffnen mit“ und klicken Sie auf „Alle ändern“. WordCraft is now the default app for Word documents. WordCraft ist jetzt die Standard-App für Word-Dokumente. + Numerals: Ziffern: + Numerals Ziffern + Hindi Hindi + Context Kontext + System System diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index eb39d48..e2bff07 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1648,6 +1648,12 @@ Enlarge symbol Agrandar símbolo Enclosure Contorno Circle Círculo + Numerals: Números: + Numerals Números + Arabic Árabe + Hindi Hindi + Context Contexto + System Sistema Templates Plantillas Templates and Add-ins Plantillas y complementos Attach Template Adjuntar plantilla @@ -1993,7 +1999,6 @@ Add line numbering Agregar numeración de líneas Add to Document Agregar al documento Address labels Etiquetas de dirección - Arabic Árabe Auto Auto Automatically hyphenate document Separar palabras automáticamente en el documento Chinese (Simplified) Chino (simplificado) diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index cbafdc3..5d0da6a 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1579,6 +1579,12 @@ Right (vertical) Paremal (vertikaalne) Ruby text: Ruby-tekst: Size (pt): Suurus (pt): + Numerals: Numbrid: + Numerals Numbrid + Arabic Araabia + Hindi Hindi + Context Kontekst + System Süsteem Couldn't make WordCraft the default: {error} WordCrafti ei õnnestunud vaikerakenduseks määrata: {error} In Default apps, choose WordCraft for the document types you want it to open. Vali jaotises „Vaikerakendused“ WordCraft nende dokumenditüüpide jaoks, mida see peaks avama. Make WordCraft the default for Word documents… Määra WordCraft Wordi dokumentide vaikerakenduseks… @@ -1987,7 +1993,6 @@ Add line numbering Lisa reanummerdus Add to Document Lisa dokumenti Address labels Aadressisildid - Arabic Araabia Auto Automaatne Automatically hyphenate document Poolita dokumenti automaatselt Chinese (Simplified) Hiina (lihtsustatud) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c11bba4..661f649 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2427,3 +2427,8 @@ preview Title Esempio Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Apre Impostazioni › App predefinite, dove scegli WordCraft per .docx e gli altri tipi di documento. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Per aprire i documenti Word con WordCraft per impostazione predefinita: selezionane uno nel Finder, scegli Archivio › Ottieni informazioni, scegli WordCraft in «Apri con», quindi fai clic su «Modifica tutto». WordCraft is now the default app for Word documents. WordCraft è ora l'app predefinita per i documenti Word. + Numerals: Cifre: + Numerals Cifre + Hindi Hindi + Context Contesto + System Sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5637bcb..bc0f918 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1632,6 +1632,12 @@ Enlarge symbol 記号を拡大 Enclosure 囲い Circle 円 + Numerals: 数字: + Numerals 数字 + Arabic アラビア + Hindi ヒンディー + Context コンテキスト + System システム Templates テンプレート Templates and Add-ins テンプレートとアドイン Attach Template テンプレートの添付 @@ -1977,7 +1983,6 @@ Add line numbering 行番号を追加する Add to Document 文書に追加 Address labels 宛名ラベル - Arabic アラビア語 Auto 自動 Automatically hyphenate document 文書を自動的にハイフネーションする Chinese (Simplified) 中国語(簡体字) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index aca3d2e..41fd289 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2060,3 +2060,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otwiera Ustawienia › Aplikacje domyślne, gdzie wybierasz WordCraft dla .docx i innych typów dokumentów. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Aby otwierać dokumenty Word domyślnie w WordCraft: zaznacz jeden w Finderze, wybierz Plik › Pokaż info, wybierz WordCraft w „Otwórz za pomocą”, a następnie kliknij „Zmień wszystkie”. WordCraft is now the default app for Word documents. WordCraft jest teraz domyślną aplikacją dla dokumentów Word. + Numerals: Cyfry: + Numerals Cyfry + Hindi Hindi + Context Kontekstowe + System Systemowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6d36b89..fdf4dc3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1644,6 +1644,12 @@ Enlarge symbol Ampliar símbolo Enclosure Contorno Circle Círculo + Numerals: Numerais: + Numerals Numerais + Arabic Arábico + Hindi Hindi + Context Contexto + System Sistema Templates Modelos Templates and Add-ins Modelos e suplementos Attach Template Anexar modelo @@ -1989,7 +1995,6 @@ Add line numbering Adicionar numeração de linhas Add to Document Adicionar ao documento Address labels Etiquetas de endereço - Arabic Árabe Auto Auto Automatically hyphenate document Hifenizar o documento automaticamente Chinese (Simplified) Chinês (simplificado) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f0a8799..cdf8392 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1998,6 +1998,12 @@ preview Title Пример Search icons Поиск значков No icons match your search. Нет значков, соответствующих поиску. Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок + Numerals: Цифры: + Numerals Цифры + Arabic Арабские + Hindi Хинди + Context Контекст + System Системные Couldn't make WordCraft the default: {error} Не удалось сделать WordCraft приложением по умолчанию: {error} In Default apps, choose WordCraft for the document types you want it to open. В разделе «Приложения по умолчанию» выберите WordCraft для нужных типов документов. Make WordCraft the default for Word documents… Сделать WordCraft приложением по умолчанию для документов Word… @@ -2323,7 +2329,6 @@ preview Title Пример Add line numbering Добавить нумерацию строк Add to Document Добавить в документ Address labels Адресные наклейки - Arabic Арабский Auto Авто Automatically hyphenate document Автоматически расставлять переносы в документе Chinese (Simplified) Китайский (упрощённый) diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index ff2ddf2..386a00f 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1642,6 +1642,12 @@ Enlarge symbol Uvećaj simbol Enclosure Okvir Circle Krug + Numerals: Brojevi: + Numerals Brojevi + Arabic Arapski + Hindi Hindi + Context Kontekst + System Sistem Templates Šabloni Templates and Add-ins Šabloni i dodaci Attach Template Priloži šablon @@ -1987,7 +1993,6 @@ Add line numbering Dodaj brojeve redova Add to Document Dodaj u dokument Address labels Adresne nalepnice - Arabic Arapski Auto Automatski Automatically hyphenate document Automatski deli reči u dokumentu Chinese (Simplified) Kineski (pojednostavljeni) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f79d553..458f398 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1646,6 +1646,12 @@ Enlarge symbol Увећај симбол Enclosure Оквир Circle Круг + Numerals: Бројеви: + Numerals Бројеви + Arabic Арапски + Hindi Хинди + Context Контекст + System Систем Templates Шаблони Templates and Add-ins Шаблони и додаци Attach Template Приложи шаблон @@ -1991,7 +1997,6 @@ Add line numbering Додај бројеве редова Add to Document Додај у документ Address labels Адресне налепнице - Arabic Арапски Auto Аутоматски Automatically hyphenate document Аутоматски дели речи у документу Chinese (Simplified) Кинески (поједностављени) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bb878d5..1dab129 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1623,6 +1623,12 @@ Search icons Simgelerde ara No icons match your search. Aramanızla eşleşen simge yok. Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir + Numerals: Rakamlar: + Numerals Rakamlar + Arabic Arapça + Hindi Hintçe + Context Bağlam + System Sistem Couldn't make WordCraft the default: {error} WordCraft varsayılan yapılamadı: {error} In Default apps, choose WordCraft for the document types you want it to open. Varsayılan uygulamalar bölümünde, WordCraft'ın açmasını istediğiniz belge türlerini seçin. Make WordCraft the default for Word documents… WordCraft'ı Word belgeleri için varsayılan yap… @@ -1945,7 +1951,6 @@ Add line numbering Satır numaralandırma ekle Add to Document Belgeye Ekle Address labels Adres etiketleri - Arabic Arapça Auto Otomatik Automatically hyphenate document Belgede otomatik heceleme yap Chinese (Simplified) Çince (Basitleştirilmiş) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f314bda..ddd6b55 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1644,6 +1644,12 @@ Enlarge symbol Збільшити символ Enclosure Обведення Circle Коло + Numerals: Цифри: + Numerals Цифри + Arabic Арабські + Hindi Гінді + Context Контекст + System Системні Templates Шаблони Templates and Add-ins Шаблони й надбудови Attach Template Приєднати шаблон @@ -1989,7 +1995,6 @@ Add line numbering Додати нумерацію рядків Add to Document Додати до документа Address labels Адресні етикетки - Arabic Арабська Auto Авто Automatically hyphenate document Автоматично переносити слова в документі Chinese (Simplified) Китайська (спрощена) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 132eaa5..e52228b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1644,6 +1644,12 @@ Enlarge symbol 增大圈号 Enclosure 圈号 Circle 圆形 + Numerals: 数字: + Numerals 数字 + Arabic 阿拉伯 + Hindi 印地 + Context 上下文 + System 系统 Templates 模板 Templates and Add-ins 模板和加载项 Attach Template 附加模板 @@ -1989,7 +1995,6 @@ Add line numbering 添加行号 Add to Document 添加到文档 Address labels 地址标签 - Arabic 阿拉伯语 Auto 自动 Automatically hyphenate document 自动断字 Chinese (Simplified) 中文(简体) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c584d5a..89bbe5a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1633,6 +1633,12 @@ Enlarge symbol 放大符號 Enclosure 圍繞符號 Circle 圓形 + Numerals: 數字: + Numerals 數字 + Arabic 阿拉伯 + Hindi 印地 + Context 上下文 + System 系統 Templates 範本 Templates and Add-ins 範本與增益集 Attach Template 附加範本 @@ -1978,7 +1984,6 @@ Add line numbering 新增行號 Add to Document 新增至文件 Address labels 地址標籤 - Arabic 阿拉伯文 Auto 自動 Automatically hyphenate document 自動斷字 Chinese (Simplified) 中文(簡體) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f043923..90e228c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,6 +404,10 @@ impl SaveChoice { impl WordApp { pub fn new(session: Session, services: Services) -> Self { + // Numeral › System follows the system locale, found here rather than in layout. + if let Some(tag) = sys_locale::get_locale() { + wordcraft_engine::set_system_locale(&tag); + } WordApp { session, ui: UiState::default(), diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 9f35504..ab27e63 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -191,6 +191,7 @@ fn display(app: &mut WordApp, ui: &mut Ui) { if ui.checkbox(&mut ruler, tl!("Show rulers")).changed() { let _ = app.run("view.ruler", json!({"value": ruler})); } + numeral_picker(app, ui); ui.add_space(8.0); section(ui, "Printing"); ui.checkbox(&mut app.ui.print_backgrounds, tl!("Print the page color")); @@ -504,6 +505,24 @@ fn default_app_button(app: &mut WordApp, ui: &mut Ui) { } } +/// File ▸ Options ▸ Numerals: how digits look on screen (display only, like Word's +/// File › Options › Advanced › Numeral). +fn numeral_picker(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_layout::NumeralMode; + ui.horizontal(|ui| { + ui.label(tl!("Numerals:")); + let mut sel = app.session.view.numeral; + egui::ComboBox::from_id_salt("numerals").selected_text(tl!(sel.label())).width(220.0).show_ui(ui, |ui| { + for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context, NumeralMode::System] { + ui.selectable_value(&mut sel, m, tl!(m.label())); + } + }); + if sel != app.session.view.numeral { + let _ = app.run("view.numeral", json!({ "value": sel })); + } + }); +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable;