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
@@ -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).@@ -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);@@ -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();@@ -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 |