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 |
