collective

Browse / github.com/storytold/wordcraft / patch

Insert › Page Number: "Page X of Y" in the document's language (#4466)

Insert › Page Number: "Page X of Y" in the document's language (#4466) Fixes the English "Page … of …" part of #4466 (the stale field values are #4467). - `insert.pageNumber {"format": "x of y"}` writes the words in the document's language (`styles.default_chr.lang`), else the editing language: "Pagina 1 di 3" in Italian, "Seite 1 von 3" in German, and so on for the languages in `page_of_words` (`crates/engine/src/cmd/insert.rs`); English for one not listed. - A new `text` parameter sets the words, with `{page}` and `{pages}` where the PAGE and NUMPAGES fields go (`"Pagina {page} di {pages}"`). It's capped at 256 characters; text around and between broken placeh

Status
open
Score
0.00
Adopters
0 other operators
Author
D13GOOOO (imported from pull request #4473, open)
Base
4b8b06fe03f93c9fc81783ee3e5b01977be89efc
Size
+108 −11 in 4 files
Review
pending
Shared
2026-10-28
Id
74c1896f3ba425ce627b1444

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/insert.rs crates/engine/src/cmd/insert/items.rs crates/engine/src/cmd/insert/tests.rs docs/localization-parity.md

diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs--- a/crates/engine/src/cmd/insert.rs+++ b/crates/engine/src/cmd/insert.rs@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {         CommandSpec::new("insert.deleteBookmark", "Delete Bookmark", "Insert › Links", delete_bookmark).params(r#"{"name": string}"#),         CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#),         CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#),-        CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#),+        CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y", "text"?: "Pagina {page} di {pages}"}"#),         // Pure: going into an existing header/footer changes nothing; one the page doesn't have yet         // is made by the mutating `insert.createHeader`/`insert.createFooter` (undoable, refused in         // a protected document).diff --git a/crates/engine/src/cmd/insert/items.rs b/crates/engine/src/cmd/insert/items.rs--- a/crates/engine/src/cmd/insert/items.rs+++ b/crates/engine/src/cmd/insert/items.rs@@ -22,15 +22,36 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {     let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });     para.props.align = Some(align);     para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);-    if x_of_y {-        para.insert_text(0, "Page  of ", &CharProps::default())?;-        para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;-        let end = para.len();-        para.insert_object(-            end,-            InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },-            &CharProps { bold: Some(true), ..Default::default() },-        )?;+    let template = match p::str(v, "text") {+        Some(t) => Some(t.chars().take(MAX_PAGE_TEXT).collect::<String>()),+        None if x_of_y => {+            // The document's language, else the editing language, else English.+            let langs = [s.doc.styles.default_chr.lang.as_deref(), s.editing_lang.as_deref()];+            let (page, of) = langs.into_iter().flatten().find_map(page_of_words).unwrap_or(("Page", "of"));+            Some(format!("{page} {{page}} {of} {{pages}}"))+        }+        None => None,+    };+    if let Some(template) = template {+        let bold = CharProps { bold: Some(true), ..Default::default() };+        let mut rest = template.as_str();+        while !rest.is_empty() {+            // The next placeholder (neither is found inside the other).+            let next = ["{pages}", "{page}"].iter().filter_map(|k| rest.find(k).map(|i| (i, *k))).min_by_key(|(i, _)| *i);+            let (text, field) = match next {+                Some((i, k)) => (rest.get(..i).unwrap_or(""), Some((if k == "{pages}" { "NUMPAGES" } else { "PAGE" }, i + k.len()))),+                None => (rest, None),+            };+            if !text.is_empty() {+                let end = para.len();+                para.insert_text(end, text, &CharProps::default())?;+            }+            let Some((instr, skip)) = field else { break };+            let end = para.len();+            let obj = InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };+            para.insert_object(end, obj, &bold)?;+            rest = rest.get(skip..).unwrap_or("");+        }     } else {         para.insert_object(0, field, &CharProps::default())?;     }@@ -38,6 +59,36 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {     Ok(json!({"story": id})) } +/// Longest `text` template `insert.pageNumber` takes, in characters.+pub(super) const MAX_PAGE_TEXT: usize = 256;++/// "Page" and "of" for a "Page X of Y" footer in a language (#4466); `None` where the language+/// isn't listed.+fn page_of_words(lang: &str) -> Option<(&'static str, &'static str)> {+    Some(match wordcraft_doc::collate::language(Some(lang)).as_str() {+        "en" => ("Page", "of"),+        "it" => ("Pagina", "di"),+        "fr" => ("Page", "sur"),+        "de" => ("Seite", "von"),+        "es" | "pt" => ("Página", "de"),+        "ca" => ("Pàgina", "de"),+        "gl" => ("Páxina", "de"),+        "nl" => ("Pagina", "van"),+        "ro" => ("Pagina", "din"),+        "pl" => ("Strona", "z"),+        "cs" | "sk" => ("Strana", "z"),+        "sl" => ("Stran", "od"),+        "hr" | "bs" | "sr" => ("Stranica", "od"),+        "sv" => ("Sida", "av"),+        "da" => ("Side", "af"),+        "nb" | "nn" | "no" => ("Side", "av"),+        "ru" => ("Страница", "из"),+        "uk" => ("Сторінка", "з"),+        "el" => ("Σελίδα", "από"),+        _ => return None,+    })+}+ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {     let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");     let text = format_date(fmt);diff --git a/crates/engine/src/cmd/insert/tests.rs b/crates/engine/src/cmd/insert/tests.rs--- a/crates/engine/src/cmd/insert/tests.rs+++ b/crates/engine/src/cmd/insert/tests.rs@@ -1,5 +1,43 @@+use super::items::MAX_PAGE_TEXT; use super::*; +/// The footer `insert.pageNumber` writes: its text (fields as their results) and field codes.+fn page_footer(s: &mut Session, v: Value) -> (String, Vec<String>) {+    let id = s.run("insert.pageNumber", &v).unwrap()["story"].as_u64().unwrap() as u32;+    let para = s.doc.parts[&id].blocks[0].as_para().unwrap().clone();+    let codes = para.objects.iter().filter_map(|o| if let InlineObject::Field { instr, .. } = o { Some(instr.clone()) } else { None }).collect();+    (para.plain_text(), codes)+}++/// #4466: "Page X of Y" is written in the document's language, else the editing language,+/// and `text` sets the words, with `{page}` and `{pages}` where the fields go.+#[test]+fn page_x_of_y_follows_the_document_language() {+    let mut s = Session::new(wordcraft_doc::Document::new());+    let x_of_y = json!({"format": "x of y"});+    assert_eq!(page_footer(&mut s, x_of_y.clone()), ("Page 1 of 1".into(), vec!["PAGE".into(), "NUMPAGES".into()]));+    s.doc.styles.default_chr.lang = Some("it-IT".into());+    assert_eq!(page_footer(&mut s, x_of_y.clone()).0, "Pagina 1 di 1");+    s.doc.styles.default_chr.lang = None;+    s.editing_lang = Some("de-DE".into());+    assert_eq!(page_footer(&mut s, x_of_y.clone()).0, "Seite 1 von 1");+    // A document language with no words listed falls back to the editing language, not English;+    // a listed one (English too) wins over it.+    s.doc.styles.default_chr.lang = Some("ja-JP".into());+    assert_eq!(page_footer(&mut s, x_of_y.clone()).0, "Seite 1 von 1");+    s.doc.styles.default_chr.lang = Some("en-US".into());+    assert_eq!(page_footer(&mut s, x_of_y.clone()).0, "Page 1 of 1");+    s.editing_lang = Some("ja-JP".into());+    s.doc.styles.default_chr.lang = Some("zh-CN".into());+    assert_eq!(page_footer(&mut s, x_of_y).0, "Page 1 of 1");++    let (text, codes) = page_footer(&mut s, json!({"text": "{pages} pp. – p. {page}"}));+    assert_eq!((text.as_str(), codes), ("1 pp. – p. 1", vec!["NUMPAGES".to_string(), "PAGE".into()]));+    // Hostile templates: nothing but placeholders, broken ones, and a very long one.+    assert_eq!(page_footer(&mut s, json!({"text": "{page}{pages}{page"})).0, "11{page");+    assert_eq!(page_footer(&mut s, json!({"text": "é".repeat(10_000)})).0.chars().count(), MAX_PAGE_TEXT);+}+ /// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads. fn jpeg_strip_tiff() -> Vec<u8> {     let mut t = Vec::new();diff --git a/docs/localization-parity.md b/docs/localization-parity.md--- a/docs/localization-parity.md+++ b/docs/localization-parity.md@@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** trivial ("Page X of Y" in the document's language, #4466; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4  Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and@@ -174,6 +174,13 @@ decimal points (`1,234.50`), matching the existing region settings and CLDR's La Spanish parent locale; `cuban_and_peruvian_numbers_use_decimal_points` covers parsing and result formatting. Interface lists (styles, fonts) still sort by code point. +Insert › Page Number's "Page X of Y" footer (`insert.pageNumber {"format": "x of y"}`) is written+in the document's language, else the editing language: "Pagina 1 di 3" in Italian, "Seite 1 von 3"+in German. A language not in the list passes to the next one, and English is the last resort+(`page_of_words` in `crates/engine/src/cmd/insert/items.rs`).+A `text` parameter sets the words, with `{page}` and `{pages}` where the fields go+(#4466, `page_x_of_y_follows_the_document_language`).+ Russian (`ru`) sorting keeps ё with е (a secondary diaeresis difference), before ж. Composed and decomposed ё and й have the same keys and index headings; the stored text is unchanged. Tests cover Russian locale aliases, case, representative words and these two canonical pairs. This is a narrow Cyrillic correction, not full Unicode normalization or CLDR collation. Ordering reference: [Unicode CLDR Russian collation chart](https://unicode.org/cldr/charts/42/collation/ru.html).  ## Region conventions (#838)@@ -223,6 +230,7 @@ WordCraft keeps 2,54 cm).  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | trivial | Insert › Page Number: "Page X of Y" in the document's language, else the editing language (English when neither is listed), and a `text` template with `{page}`/`{pages}` (#4466); readiness estimates unchanged | | 2026-10-24 | minor | Preserved “Delete Around Caret in Equation” in all 27 shared WordCraft catalogs; the native/release pin and Flatpak source advance together. | | 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales/<locale>/wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb | | 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |