Browse / github.com/storytold/wordcraft / patch
Fields: format switches follow Word
Fields: format switches follow Word Field format switches now give the results real Word gives, for five rules the field-format code merged earlier got wrong. What changed (`crates/layout/src/fields.rs`, plus the callers in `references.rs` and `table_formula.rs`): - With several `\*` format switches the last one wins (`NUMPAGES \* ArabicDash \* roman` shows `i`). Switches inside quoted text are not switches. - `\* Caps` lowercases the other letters (`wEEKLY rEPORT` gives `Weekly Report`), starts a new word after any punctuation (`x-ray` gives `X-Ray`) but not after an apostrophe inside a word (`o'neil` gives `O'neil`). `\* Firs
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #4571, open)
- Base
d859c673c1e93bccaa6536fa3653b902ea877cea- Size
- +476 −183 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
531a5e42d3c63e2f3d308102
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/references.rs crates/engine/src/cmd/table_formula.rs crates/engine/src/tests.rs crates/layout/src/fields.rs docs/gaps.md docs/target-app-parity.md
@@ -228,6 +228,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-19 | A Thai run with Latin letters and digits in a missing Thai font is drawn in one face on Linux too (#1549): the fallback no longer stops at Noto Sans Thai, which has no Latin letters, but finds an installed font covering the whole run (IBM Plex Sans Thai, say) and loads its whole family, so regular text gets the Regular face. Concurrent lookups no longer cache a miss. | | 2026-10-19 | Word signature lines (Insert › Signature Line) survive a save: a .docx saved by WordCraft keeps each one as a signature line, still signable in Word, with its suggested signer, instead of turning it into a plain picture. | | 2026-10-25 | Custom document properties (#2982, split from #1798): File › Info › Properties › Advanced Properties… opens a Custom Document Properties dialog that lists the document's custom properties and adds, modifies and deletes them through the name/value form (text properties only; Word's number/date/yes-no types not offered). OK is one undoable `file.properties` step and marks the document changed; Cancel discards everything; names and values are validated against the engine's limits with the error shown in the dialog. Saved properties survive a DOCX round trip. |+| 2026-10-26 | Field formats match Word 16.0 (checked in a new document): the last `\*` format switch wins, `\* Caps` lowercases the rest, `\* DollarText` cuts off the whole part and rounds decimals in decimal, numbers outside a format's range show Word's "Error! Number cannot be represented in specified format." (CardText and OrdText 0–999,999, roman 1–32,767, alphabetic 1–780, Hex 0–65,535), and a quoted argument such as `TITLE "x \* Upper"` takes `\*` as text; the differences left are in `docs/gaps.md` | | 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) | | 2026-10-20 | Table sorting and formulas read and write decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), so `1.234` is a decimal instead of 1234; grouped-number and result-formatting regressions retain Spain's decimal comma. | | 2026-10-20 | Set Proofing Language › Set As Default persists for future blank documents and plain-text files, independently of the interface language; existing DOCX and templates keep their languages, and rejected commands retain the previous saved default (#2882). |@@ -1172,19 +1172,17 @@ fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: } /// The new value a summary-property field gives its property: the argument after the field's-/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`), at most 255-/// characters as Word allows.+/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`; a backslash+/// makes the next character plain, so `"x \* Upper"` is x * Upper), at most 255 characters as Word+/// allows. fn new_property_value(instr: &str) -> Option<String> {- FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect())+ wordcraft_layout::fields::text_argument(instr).map(|a| a.chars().take(255).collect()) } -/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one or-/// when the format does not cover the count (see [`super::table_formula::number_format_covers`]),+/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format (digits without one), /// with the field's capitalization switches. fn count_text(instr: &str, n: usize) -> String {- let fmt = wordcraft_layout::fields::format_switch(instr)- .filter(|f| super::table_formula::number_format_covers(*f, n as f64))- .unwrap_or(NumFormat::Decimal);+ let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(NumFormat::Decimal); wordcraft_layout::fields::field_number(instr, fmt, u32::try_from(n).unwrap_or(u32::MAX)) } @@ -1665,7 +1663,7 @@ mod tests { "seventh", "Seventh", "BUDGET",- "new \\* Upper"+ "new * Upper" ] ); }@@ -1686,16 +1684,17 @@ mod tests { assert_eq!(got, ["BOOKMARK TARGET TEXT".to_string(), "one".into(), "First".into(), name]); } - /// A count the field's number format does not cover keeps its digits.+ /// A count the field's number format cannot write shows Word's error text. #[test]- fn counts_past_the_number_format_keep_their_digits() {+ fn counts_past_the_number_format_show_the_error() { let mut doc = wordcraft_doc::Document::from_text("one two three"); doc.core.revision = 1_000_000; let codes = [r"REVNUM \* ROMAN", r"REVNUM \* CardText", r"REVNUM \* alphabetic", r"NUMWORDS \* ROMAN"]; let (mut s, hdr) = header_fields_session(doc, &codes); s.run("references.updateFields", &json!({})).unwrap(); let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();- assert_eq!(got, ["1000000", "1000000", "1000000", "III"]);+ let error = wordcraft_layout::fields::NUMBER_FORMAT_ERROR;+ assert_eq!(got, [error, error, error, "III"]); } /// #1706: `all: true` (printing) updates the whole document even with the caret in a header.@@ -107,41 +107,21 @@ pub fn evaluate_field_with( /// The text of a formula result under the field's `\*` switches, where `text` is `value` formatted /// with the `\#` picture, or in the cell's language when there is no picture. A number format such-/// as `\* CardText`, `\* OrdText` or `\* roman` writes a whole number the format covers (up to-/// 999,999 for CardText and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic), in lowercase-/// for CardText and OrdText. `\* Hex` writes a whole number from 0 to 4,294,967,295 in uppercase-/// hexadecimal digits. `\* DollarText` writes a result from 0 to 999,999.99 in lowercase cardinal-/// text with its first two decimals (rounded) over 100 (see-/// [`wordcraft_layout::fields::dollar_text`]). Any other result keeps its digits. The capitalization-/// switches (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`) then apply to the text in order. Other-/// `\*` switches leave the text as it is. Quoted text, such as a `\#` picture, is not a switch.+/// as `\* CardText`, `\* OrdText`, `\* roman` or `\* Hex` writes the rounded result as Word does (see+/// [`wordcraft_layout::fields::field_value`]), and shows Word's "Error! Number cannot be+/// represented in specified format." for one it can't write. `\* Arabic` writes the rounded result+/// only without a picture, which then writes the text. The capitalization switches (`\* Upper`,+/// `\* Lower`, `\* Caps`, `\* FirstCap`) then apply to the text in order. Other `\*` switches leave the+/// text as it is. Quoted text, such as a `\#` picture, is not a switch. fn format_switches(switches: &str, value: f64, text: String) -> String { use wordcraft_doc::section::NumFormat;- let unquoted = switches.split('"').step_by(2).collect::<Vec<_>>().join(" ");- let fmt = wordcraft_layout::fields::format_switch(&unquoted).filter(|f| *f != NumFormat::Decimal);- match fmt {- Some(NumFormat::DollarText) => {- let text = wordcraft_layout::fields::dollar_text(value).unwrap_or(text);- wordcraft_layout::fields::apply_case_switch(switches, text)- }- Some(fmt) if number_format_covers(fmt, value) => wordcraft_layout::fields::field_number(switches, fmt, value as u32),- _ => wordcraft_layout::fields::apply_case_switch(switches, text),+ match wordcraft_layout::fields::format_switch(switches) {+ Some(NumFormat::Decimal) if picture_of(switches).is_some() => wordcraft_layout::fields::apply_case_switch(switches, text),+ Some(fmt) => wordcraft_layout::fields::field_value(switches, fmt, value),+ None => wordcraft_layout::fields::apply_case_switch(switches, text), } } -/// Whether the `\*` number format `fmt` writes `value`: a whole number up to 999,999 for CardText-/// and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic, and 0 to `u32::MAX` otherwise.-pub(crate) fn number_format_covers(fmt: wordcraft_doc::section::NumFormat, value: f64) -> bool {- use wordcraft_doc::section::NumFormat;- let range = match fmt {- NumFormat::CardinalText | NumFormat::OrdinalText => 0.0..=999_999.0,- NumFormat::LowerRoman | NumFormat::UpperRoman => 1.0..=50_000.0,- NumFormat::LowerLetter | NumFormat::UpperLetter => 1.0..=780.0,- _ => 0.0..=f64::from(u32::MAX),- };- value.fract() == 0.0 && range.contains(&value)-}- /// Splits a field's switches (`\#` and `\*`) off its formula: `=SUM(ABOVE) \# "0.00"` gives the /// formula and the switch text (from the first switch on). pub fn split_switch(instr: &str) -> (&str, Option<&str>) {@@ -1010,13 +990,18 @@ mod tests { assert_eq!(shown("=A1 \\# \"#,##0.00\" \\* MERGEFORMAT"), "790.00"); assert_eq!(shown("=A1 \\* MERGEFORMAT \\# 0.0"), "790.0", "a picture after a format switch"); assert_eq!(shown("=A1 \\# \"0 \\* CardText items\""), "790 \\* CardText items", "a switch inside the picture is text");- assert_eq!(shown("=A2 \\* CardText"), "1.50", "a fraction keeps its digits");- assert_eq!(shown("=A1-800 \\* CardText"), "-10", "a negative number keeps its digits");- assert_eq!(shown("=A1*1266 \\* CardText"), "1000140", "a number past CardText keeps its digits");- assert_eq!(shown("=A1*1266 \\* OrdText \\* Upper"), "1000140");- assert_eq!(shown("=A1*64 \\* roman"), "50560", "a number past roman keeps its digits");- assert_eq!(shown("=A1-790 \\* roman"), "0", "roman has no zero");- assert_eq!(shown("=A1 \\* alphabetic"), "790", "a number past alphabetic keeps its digits");+ // Word 16.0 (build 16.0.20527), 2026-10-26: the result is rounded, and one the format+ // cannot write shows an error.+ let error = "Error! Number cannot be represented in specified format.";+ assert_eq!(shown("=A2 \\* CardText"), "two", "a fraction is rounded");+ assert_eq!(shown("=A1-800 \\* CardText"), error, "a negative number");+ assert_eq!(shown("=A1*1266 \\* CardText"), error, "a number past CardText");+ assert_eq!(shown("=A1*1266 \\* OrdText \\* Upper"), error.to_uppercase());+ assert_eq!(shown("=A1*64 \\* roman"), error, "a number past roman");+ assert_eq!(shown("=A1-790 \\* roman"), " ", "roman shows a space for 0");+ assert_eq!(shown("=A1 \\* alphabetic"), error, "a number past alphabetic");+ assert_eq!(shown("=A2 \\* Arabic"), "2", "Arabic rounds too");+ assert_eq!(shown("=A2/3 \\* CardText"), error, "from a half up to one is an error"); } #[test]@@ -1026,14 +1011,18 @@ mod tests { assert_eq!(shown("= 9.20 + 5.35 \\* DollarText \\* Upper"), "FOURTEEN AND 55/100"); assert_eq!(shown("=A1+B1 \\* DollarText"), "fourteen and 55/100"); assert_eq!(shown("=A2 \\* DollarText \\* FirstCap"), "Four hundred fifty-eight and 00/100");- assert_eq!(shown("=0.999 \\* DollarText"), "one and 00/100");+ assert_eq!(shown("=0.999 \\* DollarText"), "zero and 00/100");+ assert_eq!(shown("=1.995 \\* DollarText"), "one and 00/100");+ assert_eq!(shown("=12.345 \\* DollarText"), "twelve and 35/100"); assert_eq!(shown("=A2 \\* Hex"), "1CA"); assert_eq!(shown("=A2 \\* Hex \\* Lower"), "1ca"); assert_eq!(shown("=A2*0 \\* Hex"), "0");- assert_eq!(shown("=B2 \\* DollarText"), shown("=B2"), "a negative number keeps its digits");- assert_eq!(shown("=B2 \\* Hex"), shown("=B2"));- assert_eq!(shown("=A2/4 \\* Hex"), shown("=A2/4"), "a fraction keeps its digits");- assert_eq!(shown("=A2*2200 \\* DollarText"), shown("=A2*2200"), "a number past DollarText keeps its digits");+ let error = "Error! Number cannot be represented in specified format.";+ assert_eq!(shown("=B2 \\* DollarText"), error, "a negative number");+ assert_eq!(shown("=B2 \\* Hex"), error);+ assert_eq!(shown("=A2/4 \\* Hex"), "73", "a fraction is rounded");+ assert_eq!(shown("=A2*2200 \\* DollarText"), error, "a number past DollarText");+ assert_eq!(shown("=A2*200 \\* Hex"), error, "a number past 65,535"); assert_eq!(shown("=A2 \\* ArabicDash"), "- 458 -"); } @@ -9113,7 +9113,7 @@ fn update_fields_recalculates_a_table_formula() { fn table_formula_results_take_the_format_switches() { for (formula, inserted, updated) in [ (r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"),- (r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"),+ (r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-One"), (r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"), (r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"), ] {@@ -125,150 +125,244 @@ pub(crate) fn reference_link(instr: &str) -> Option<String> { None } -/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal,-/// ArabicDash, DollarText, Hex…). `{ PAGE \* ArabicDash }` shows "- 31 -".-pub fn format_switch(instr: &str) -> Option<NumFormat> {- let mut it = instr.split_whitespace();- while let Some(w) = it.next() {- if let Some(rest) = w.strip_prefix("\\*") {- let v = if rest.is_empty() { it.next()? } else { rest };- let v = v.split('\\').next().unwrap_or(v);- let fmt = match v {- "roman" => NumFormat::LowerRoman,- "ROMAN" | "Roman" => NumFormat::UpperRoman,- "alphabetic" => NumFormat::LowerLetter,- "ALPHABETIC" | "Alphabetic" => NumFormat::UpperLetter,- "CardText" | "cardtext" => NumFormat::CardinalText,- "OrdText" | "ordtext" => NumFormat::OrdinalText,- "Ordinal" | "ordinal" => NumFormat::Ordinal,- "Arabic" | "arabic" => NumFormat::Decimal,- "ArabicDash" | "arabicdash" | "ARABICDASH" => NumFormat::NumberInDash,- "DollarText" | "dollartext" | "DOLLARTEXT" => NumFormat::DollarText,- "Hex" | "hex" | "HEX" => NumFormat::Hex,- // Chinese numerals: counting (一百零一), legal (壹佰零壹), digit by digit (一〇一).- v if v.eq_ignore_ascii_case("ChineseNum1") => NumFormat::ChineseCountingThousand,- v if v.eq_ignore_ascii_case("ChineseNum2") => NumFormat::ChineseLegalSimplified,- v if v.eq_ignore_ascii_case("ChineseNum3") => NumFormat::IdeographDigital,- _ => continue,- };- return Some(fmt);- }- }- None-}--/// A field's capitalization switch (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`).-#[derive(Clone, Copy, Debug, PartialEq, Eq)]-enum CaseSwitch {- Upper,- Lower,- Caps,- FirstCap,-}--/// The capitalization switches in a field code, in the order they appear. Quoted text in the code-/// is not a switch.-fn case_switches(instr: &str) -> Vec<CaseSwitch> {+/// The words after each `\*` in a field code, in the order they appear. Quoted text in the code is+/// not a switch (a backslash inside it escapes the next character).+fn general_switches(instr: &str) -> Vec<String> { let mut chars = instr.chars().peekable(); let mut quoted = false;- let mut cases = Vec::new();+ let mut words = Vec::new(); while let Some(c) = chars.next() { match c { '\\' if quoted => { chars.next(); } '"' => quoted = !quoted,- '\\' if chars.peek() == Some(&'*') => {+ '\\' if !quoted && chars.peek() == Some(&'*') => { chars.next(); while chars.next_if(|c| c.is_whitespace()).is_some() {} let mut word = String::new(); while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '\\' && *c != '"') { word.push(c); }- let case = match word.to_ascii_lowercase().as_str() {- "upper" => CaseSwitch::Upper,- "lower" => CaseSwitch::Lower,- "caps" => CaseSwitch::Caps,- "firstcap" => CaseSwitch::FirstCap,- _ => continue,- };- cases.push(case);+ words.push(word); } _ => {} } }- cases+ words+}++/// The number format a `\*` switch word names (`roman` lowercase, `ROMAN` and `Roman` uppercase;+/// the other names ignore case).+fn number_format(word: &str) -> Option<NumFormat> {+ Some(match word {+ "roman" => NumFormat::LowerRoman,+ "ROMAN" | "Roman" => NumFormat::UpperRoman,+ "alphabetic" => NumFormat::LowerLetter,+ "ALPHABETIC" | "Alphabetic" => NumFormat::UpperLetter,+ w if w.eq_ignore_ascii_case("CardText") => NumFormat::CardinalText,+ w if w.eq_ignore_ascii_case("OrdText") => NumFormat::OrdinalText,+ w if w.eq_ignore_ascii_case("Ordinal") => NumFormat::Ordinal,+ w if w.eq_ignore_ascii_case("Arabic") => NumFormat::Decimal,+ w if w.eq_ignore_ascii_case("ArabicDash") => NumFormat::NumberInDash,+ w if w.eq_ignore_ascii_case("DollarText") => NumFormat::DollarText,+ w if w.eq_ignore_ascii_case("Hex") => NumFormat::Hex,+ // Chinese numerals: counting (一百零一), legal (壹佰零壹), digit by digit (一〇一).+ w if w.eq_ignore_ascii_case("ChineseNum1") => NumFormat::ChineseCountingThousand,+ w if w.eq_ignore_ascii_case("ChineseNum2") => NumFormat::ChineseLegalSimplified,+ w if w.eq_ignore_ascii_case("ChineseNum3") => NumFormat::IdeographDigital,+ _ => return None,+ })+}++/// The `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal,+/// ArabicDash, DollarText, Hex…); the last format switch wins, as in Word (`\* ArabicDash \* roman`+/// shows roman). `{ PAGE \* ArabicDash }` shows "- 31 -".+pub fn format_switch(instr: &str) -> Option<NumFormat> {+ general_switches(instr).iter().rev().find_map(|w| number_format(w))+}++/// A field's capitalization switch (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`).+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum CaseSwitch {+ Upper,+ Lower,+ Caps,+ FirstCap,+}++/// The capitalization switches in a field code, in the order they appear.+fn case_switches(instr: &str) -> Vec<CaseSwitch> {+ general_switches(instr)+ .iter()+ .filter_map(|word| match word.to_ascii_lowercase().as_str() {+ "upper" => Some(CaseSwitch::Upper),+ "lower" => Some(CaseSwitch::Lower),+ "caps" => Some(CaseSwitch::Caps),+ "firstcap" => Some(CaseSwitch::FirstCap),+ _ => None,+ })+ .collect() } /// `text` with the field's capitalization switches applied in the order they appear, so /// `\* Lower \* Caps` shows "WEEKLY REPORT" as "Weekly Report". `\* Upper` capitalizes every /// letter, `\* Lower` makes every letter lowercase, `\* Caps` capitalizes the first letter of each-/// word, and `\* FirstCap` capitalizes the first letter of the first word. A word's first letter is-/// its first letter or digit, after any leading quote, bracket or other punctuation. Without a-/// switch, the function returns `text` unchanged.+/// word and lowercases the others (`wEEKLY rEPORT` gives "Weekly Report"), and `\* FirstCap`+/// capitalizes the first letter of the first word and leaves the rest. A word is a run of letters+/// and digits, hyphens, slashes and other punctuation start a new one, and an apostrophe inside a+/// word does not (`o'neil` gives "O'neil"). A letter whose capital is more than one letter (ß)+/// stays as it is. Without a switch, the function returns `text` unchanged. pub fn apply_case_switch(instr: &str, text: String) -> String { case_switches(instr).into_iter().fold(text, apply_case) } +/// `c` in capitals when that is one letter (ß has no single-letter capital).+fn upper_char(c: char) -> char {+ let mut up = c.to_uppercase();+ match (up.next(), up.next()) {+ (Some(u), None) => u,+ _ => c,+ }+}++/// `c` in lowercase when that is one letter.+fn lower_char(c: char) -> char {+ let mut low = c.to_lowercase();+ match (low.next(), low.next()) {+ (Some(l), None) => l,+ _ => c,+ }+}++fn is_apostrophe(c: char) -> bool {+ matches!(c, '\'' | '\u{2019}')+}++/// Whether the character at `i` starts a word: nothing before it, or something other than a letter,+/// a digit or an apostrophe that follows one.+fn starts_word(chars: &[char], i: usize) -> bool {+ let before = |back: usize| i.checked_sub(back).and_then(|at| chars.get(at)).copied();+ match before(1) {+ None => true,+ Some(c) if c.is_alphanumeric() => false,+ Some(c) if is_apostrophe(c) => !before(2).is_some_and(char::is_alphanumeric),+ Some(_) => true,+ }+}+ /// `text` with one capitalization switch applied (see [`apply_case_switch`]). fn apply_case(text: String, case: CaseSwitch) -> String { match case {- CaseSwitch::Upper => text.to_uppercase(),- CaseSwitch::Lower => text.to_lowercase(),- CaseSwitch::Caps | CaseSwitch::FirstCap => {- let mut out = String::with_capacity(text.len());- let mut word_start = true;- let mut done = false;- for c in text.chars() {- if c.is_whitespace() {- word_start = true;- out.push(c);- } else if word_start && !done && c.is_alphanumeric() {- out.extend(c.to_uppercase());- word_start = false;- done = case == CaseSwitch::FirstCap;- } else {- if c.is_alphanumeric() {- word_start = false;- }- out.push(c);- }- }- out+ CaseSwitch::Upper => text.chars().map(upper_char).collect(),+ CaseSwitch::Lower => text.chars().map(lower_char).collect(),+ CaseSwitch::Caps => {+ let chars: Vec<char> = text.chars().collect();+ chars.iter().enumerate().map(|(i, &c)| if c.is_alphabetic() && starts_word(&chars, i) { upper_char(c) } else { lower_char(c) }).collect()+ }+ CaseSwitch::FirstCap => {+ // The first letter, digit or apostrophe decides: only a letter is raised.+ let mut raised = false;+ text.chars()+ .map(|c| {+ let first = !raised && (c.is_alphanumeric() || is_apostrophe(c));+ raised |= first;+ if first { upper_char(c) } else { c }+ })+ .collect() } } } -/// Formats `n` in `fmt` as a field shows it. Cardinal, ordinal and dollar text is lowercase-/// (`\* CardText` shows "twenty-one", `\* DollarText` shows "twenty-one and 00/100") and writes-/// numbers up to 999,999; a larger number keeps its digits. `\* Hex` writes uppercase hexadecimal-/// digits (`1CA`). The field's capitalization switches apply after that (`\* CardText \* Caps`-/// shows "Twenty-one").+/// What Word shows for a number a `\*` format can't write.+pub const NUMBER_FORMAT_ERROR: &str = "Error! Number cannot be represented in specified format.";++/// The largest number cardinal, ordinal and dollar text write in words.+const MAX_TEXT_NUMBER: u32 = 999_999;++/// Formats `n` in `fmt` as a field shows it (see [`field_value`]). pub fn field_number(instr: &str, fmt: NumFormat, n: u32) -> String {- let text = match fmt {- NumFormat::CardinalText | NumFormat::OrdinalText | NumFormat::DollarText if n > MAX_TEXT_NUMBER => n.to_string(),- NumFormat::CardinalText | NumFormat::OrdinalText => fmt.format(n).to_lowercase(),- NumFormat::DollarText => dollar_text(f64::from(n)).unwrap_or_else(|| n.to_string()),- NumFormat::Hex => format!("{n:X}"),- _ => fmt.format(n),- };+ field_value(instr, fmt, f64::from(n))+}++/// Formats `value` in `fmt` as a field shows it, then applies the field's capitalization switches+/// (`\* CardText \* Caps` shows "Twenty-One"). A format writes a whole number, so `value` is rounded+/// first (2.5 gives 3, -2.5 gives -3); one between 0.5 and 1 in size, or past the format's range,+/// shows [`NUMBER_FORMAT_ERROR`] instead. As Word writes them: cardinal and ordinal text for 0 to+/// 999,999 in lowercase (`\* CardText` shows "twenty-one"), roman numerals for 1 to 32,767 and+/// letters for 1 to 780 (0 shows a space), `\* Hex` uppercase hexadecimal digits for 0 to 65,535+/// (`1CA`), `\* DollarText` as [`dollar_text`] does, and `\* Arabic`, `\* ArabicDash` and+/// `\* Ordinal` any number.+pub fn field_value(instr: &str, fmt: NumFormat, value: f64) -> String {+ let text = value_text(fmt, value).unwrap_or_else(|| NUMBER_FORMAT_ERROR.to_string()); apply_case_switch(instr, text) } -/// The largest number cardinal, ordinal and dollar text write in words.-const MAX_TEXT_NUMBER: u32 = 999_999;+fn value_text(fmt: NumFormat, value: f64) -> Option<String> {+ if fmt == NumFormat::DollarText {+ return dollar_text(value);+ }+ // Word rounds half away from zero, except that a fraction between a half and one is an error.+ let size = value.abs();+ if !size.is_finite() || size > 1e15 || (0.5..1.0).contains(&size) {+ return None;+ }+ let n = value.round() as i64;+ let in_range = |max: u32| u32::try_from(n).ok().filter(|n| *n <= max);+ match fmt {+ NumFormat::CardinalText | NumFormat::OrdinalText => in_range(MAX_TEXT_NUMBER).map(|n| fmt.format(n).to_lowercase()),+ NumFormat::LowerRoman | NumFormat::UpperRoman => in_range(32_767).map(|n| if n == 0 { " ".into() } else { fmt.format(n) }),+ NumFormat::LowerLetter | NumFormat::UpperLetter => in_range(780).map(|n| if n == 0 { " ".into() } else { fmt.format(n) }),+ NumFormat::Hex => in_range(65_535).map(|n| format!("{n:X}")),+ NumFormat::Decimal => Some(n.to_string()),+ NumFormat::NumberInDash => Some(format!("- {n} -")),+ NumFormat::Ordinal => {+ let abs = u32::try_from(n.unsigned_abs()).ok()?;+ Some(format!("{}{}", if n < 0 { "-" } else { "" }, fmt.format(abs)))+ }+ _ => in_range(u32::MAX).map(|n| fmt.format(n)),+ }+} -/// `value` as `\* DollarText` writes it, before any capitalization switch: the whole part in-/// lowercase cardinal text, "and", and the first two decimals (rounded) over 100-/// (14.55 gives "fourteen and 55/100"). `None` for NaN, or for a value that rounds below 0 or past-/// 999,999.99.+/// `value` as `\* DollarText` writes it, before any capitalization switch: the whole part (cut off,+/// not rounded) in lowercase cardinal text, "and", and the first two decimals of its size, rounded+/// in decimal, over 100 (14.555 gives "fourteen and 56/100", -0.5 "zero and 50/100"). Decimals that+/// round up to 100 show 00 without adding to the whole part (1.995 gives "one and 00/100").+/// `None` for NaN, or for a whole part below 0 or past 999,999 (shown as [`NUMBER_FORMAT_ERROR`]). pub fn dollar_text(value: f64) -> Option<String> {- let cents = (value * 100.0).round();- if !(0.0..=f64::from(MAX_TEXT_NUMBER) * 100.0 + 99.0).contains(¢s) {+ let whole = value.trunc();+ if !value.is_finite() || !(0.0..=f64::from(MAX_TEXT_NUMBER)).contains(&whole) { return None; }- let cents = cents as u32;- Some(format!("{} and {:02}/100", NumFormat::CardinalText.format(cents / 100).to_lowercase(), cents % 100))+ let digits = value.abs().to_string();+ let mut decimals = digits.split_once('.').map_or("", |(_, d)| d).chars().map(|c| c.to_digit(10).unwrap_or(0));+ let first_two = decimals.next().unwrap_or(0) * 10 + decimals.next().unwrap_or(0);+ let cents = (first_two + u32::from(decimals.next().unwrap_or(0) >= 5)) % 100;+ // `whole` is within 0..=999,999 here.+ Some(format!("{} and {cents:02}/100", NumFormat::CardinalText.format(whole as u32).to_lowercase()))+}++/// The text argument of a field such as `TITLE "Quarterly report"`: the quoted text after the+/// field's name, where a backslash makes the next character plain (`"x \* Upper"` is x * Upper), or+/// one word without quotes. `None` when a switch (or nothing) follows the name.+pub fn text_argument(instr: &str) -> Option<String> {+ let rest = instr.trim_start().split_once(char::is_whitespace)?.1.trim_start();+ let mut chars = rest.chars();+ match chars.next()? {+ '"' => {+ let mut text = String::new();+ while let Some(c) = chars.next() {+ match c {+ '"' => break,+ '\\' => text.extend(chars.next()),+ c => text.push(c),+ }+ }+ Some(text)+ }+ '\\' => None,+ _ => Some(rest.split_whitespace().next().unwrap_or_default().to_string()),+ } } /// The light grey behind a field shown as its code.@@ -297,8 +391,8 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S Some((n, page_format)) => (field_number(instr, fmt.unwrap_or(page_format), n), true), None => (result.to_string(), false), },- "TITLE" if result.is_empty() => (apply_case_switch(instr, ctx.title.to_string()), false),- "AUTHOR" if result.is_empty() => (apply_case_switch(instr, ctx.author.to_string()), false),+ "TITLE" if result.is_empty() => (apply_case_switch(instr, text_argument(instr).unwrap_or_else(|| ctx.title.to_string())), false),+ "AUTHOR" if result.is_empty() => (apply_case_switch(instr, text_argument(instr).unwrap_or_else(|| ctx.author.to_string())), false), "FILENAME" if result.is_empty() => { let text = if has_switch(instr, 'p') && !ctx.filepath.is_empty() { ctx.filepath.to_string()@@ -402,7 +496,8 @@ mod tests { /// Cardinal and ordinal text is lowercase unless a capitalization switch follows; `\* Upper`, /// `\* Lower`, `\* Caps` and `\* FirstCap` apply to the page fields and the TITLE, AUTHOR and- /// FILENAME fallbacks. A switch inside quotes is text.+ /// FILENAME fallbacks. A switch inside quotes is text. Every expected value below is what Word+ /// 16.0 (build 16.0.20527) showed on 2026-10-26 for the same field code in a new document. #[test] fn capitalization_switches() { let ctx = FieldCtx {@@ -417,28 +512,58 @@ mod tests { assert_eq!(text(r"NUMPAGES \* CardText \* Caps"), "Seven Hundred Ninety"); assert_eq!(text(r"PAGE \* OrdText"), "twenty-first"); assert_eq!(text(r"PAGE \* OrdText \* FirstCap"), "Twenty-first");+ assert_eq!(text(r"PAGE \* OrdText \* Caps"), "Twenty-First"); assert_eq!(text(r"PAGE \* CardText \* Upper"), "TWENTY-ONE"); assert_eq!(text(r"PAGE \* roman \* upper"), "XXI");+ assert_eq!(text(r"PAGE \* roman \* Caps"), "Xxi");+ assert_eq!(text(r"PAGE \* hex \* Caps"), "15"); assert_eq!(text(r"TITLE \* FirstCap \* MERGEFORMAT"), "Weekly report on sales"); assert_eq!(text(r"TITLE \*Caps"), "Weekly Report On Sales"); assert_eq!(text(r"FILENAME \* Lower"), "weekly sales report.doc"); assert_eq!(text(r"AUTHOR \* Upper"), "");+ // `\* Caps` lowercases the other letters; `\* FirstCap` leaves them. for (title, caps, first_cap) in [- ("\"weekly report\"", "\"Weekly Report\"", "\"Weekly report\""),+ ("WEEKLY REPORT", "Weekly Report", "WEEKLY REPORT"),+ ("wEEKLY rEPORT", "Weekly Report", "WEEKLY rEPORT"), ("(weekly) report", "(Weekly) Report", "(Weekly) report"), (" q3 - final", " Q3 - Final", " Q3 - final"),+ ("don't stop-now o'neil x-ray 3rd", "Don't Stop-Now O'neil X-Ray 3rd", "Don't stop-now o'neil x-ray 3rd"),+ ("hello_world a.b.c a/b (x)y", "Hello_World A.B.C A/B (X)Y", "Hello_world a.b.c a/b (x)y"),+ ("ab 'cd' ef x'y 'z", "Ab 'Cd' Ef X'y 'Z", "Ab 'cd' ef x'y 'z"),+ ("don\u{2019}t \u{2019}x", "Don\u{2019}t \u{2019}X", "Don\u{2019}t \u{2019}x"),+ ("a1b2 c3d", "A1b2 C3d", "A1b2 c3d"),+ ("1a b", "1a B", "1a b"),+ ("ÀBC DÉF", "Àbc Déf", "ÀBC DÉF"),+ ("ΑΒΓ ΔΕΖ", "Αβγ Δεζ", "ΑΒΓ ΔΕΖ"),+ ("-x y", "-X Y", "-X y"),+ ("a--b", "A--B", "A--b"),+ // The first letter, digit or apostrophe decides FirstCap, and only a letter is raised.+ ("'quoted' text", "'Quoted' Text", "'quoted' text"),+ ("3 apples", "3 Apples", "3 apples"), ] { let ctx = FieldCtx { title: title.into(), ..Default::default() };- assert_eq!(field_text(r"TITLE \* Caps", "", &ctx).0, caps);- assert_eq!(field_text(r"TITLE \* FirstCap", "", &ctx).0, first_cap);+ assert_eq!(field_text(r"TITLE \* Caps", "", &ctx).0, caps, "Caps of {title}");+ assert_eq!(field_text(r"TITLE \* FirstCap", "", &ctx).0, first_cap, "FirstCap of {title}"); }- assert_eq!(field_text(r#"TITLE "x \* Upper""#, "", &ctx).0, "weekly report on sales");+ // Quoted text is an argument, not a switch, and it shows in place of the title.+ assert_eq!(text(r#"TITLE "WEEKLY REPORT" \* Caps"#), "Weekly Report");+ assert_eq!(text(r#"TITLE "wEEKLY rEPORT" \* FirstCap"#), "WEEKLY rEPORT");+ assert_eq!(text(r#"TITLE "x \* Upper""#), "x * Upper");+ assert_eq!(text(r#"AUTHOR "x \* Lower""#), "x * Lower");+ assert_eq!(text(r#"TITLE "a \"q\" b" \* Upper"#), "A \"Q\" B");+ assert_eq!(text(r#"TITLE "a \\ b""#), r"a \ b");+ assert_eq!(text(r#"TITLE "a \n b""#), "a n b", "a backslash only makes the next character plain");+ assert_eq!(text(r#"TITLE "a \* b \\ c" \* Caps"#), r"A * B \ C");+ assert_eq!(text(r#"TITLE "a \\* b""#), r"a \* b");+ // Word keeps ß, and any letter without a one-letter capital, as it is.+ assert_eq!(text(r#"TITLE "éé straße" \* Upper"#), "ÉÉ STRAßE");+ assert_eq!(text(r#"TITLE "ABC straße" \* Lower"#), "abc straße"); assert_eq!(field_text(r"REF mark \* Upper", "stored", &ctx).0, "stored", "a stored result shows as it is"); } - /// `\* DollarText` writes lowercase cardinal text with "and 00/100", `\* Hex` uppercase- /// hexadecimal digits, and `\* ArabicDash` the number between hyphens. Words- /// stop at 999,999; a larger number keeps its digits.+ /// `\* DollarText` writes lowercase cardinal text with the decimals over 100, `\* Hex` uppercase+ /// hexadecimal digits, and `\* ArabicDash` the number between hyphens. Expected values are+ /// Word 16.0 (build 16.0.20527) results on 2026-10-26. #[test] fn dollar_text_hex_and_arabic_dash_switches() { let ctx = FieldCtx { page: 31, pages: 458, section: 790, ..Default::default() };@@ -446,27 +571,204 @@ mod tests { assert_eq!(text(r"PAGE \* ArabicDash"), "- 31 -"); assert_eq!(text(r"PAGE \*arabicdash \* MERGEFORMAT"), "- 31 -"); assert_eq!(text(r"NUMPAGES \* ArabicDash"), "- 458 -", "any number field reads ArabicDash");- assert_eq!(text(r"NUMPAGES \* ArabicDash \* roman"), "- 458 -", "the first format switch counts"); assert_eq!(text(r"NUMPAGES \* Hex"), "1CA");+ for instr in [r"NUMPAGES \* hex", r"NUMPAGES \* HEX", r"NUMPAGES \* Hex \* Upper"] {+ assert_eq!(text(instr), "1CA", "{instr}");+ } assert_eq!(text(r"NUMPAGES \* hex \* Lower"), "1ca"); assert_eq!(text(r"PAGE \* Hex"), "1F"); assert_eq!(text(r"SECTION \* DollarText"), "seven hundred ninety and 00/100"); assert_eq!(text(r"SECTION \* DollarText \* Upper"), "SEVEN HUNDRED NINETY AND 00/100");+ assert_eq!(text(r"SECTION \* DollarText \* Caps"), "Seven Hundred Ninety And 00/100"); assert_eq!(text(r"PAGE \* DollarText \* FirstCap"), "Thirty-one and 00/100");- assert_eq!(dollar_text(9.20 + 5.35).as_deref(), Some("fourteen and 55/100"));- assert_eq!(dollar_text(0.999).as_deref(), Some("one and 00/100"), "the decimals round");- assert_eq!(dollar_text(999_999.99).as_deref(), Some("nine hundred ninety-nine thousand nine hundred ninety-nine and 99/100"));- assert_eq!(dollar_text(999_999.995), None);- assert_eq!(dollar_text(-1.0), None);- assert_eq!(dollar_text(-0.004).as_deref(), Some("zero and 00/100"), "a value that rounds to 0 is not below 0");- assert_eq!(dollar_text(f64::NAN), None);- for fmt in [NumFormat::CardinalText, NumFormat::OrdinalText, NumFormat::DollarText] {- assert_eq!(field_number(r"SEQ x \* Upper", fmt, 1_000_140), "1000140", "{fmt:?}");+ assert_eq!(text(r"PAGE \* Ordinal"), "31st");+ }++ /// With several format switches the last one counts: Word showed `i` for `NUMPAGES \*+ /// ArabicDash \* roman` and `- 1 -` with them the other way round, `one` for `PAGE \* Hex \*+ /// CardText` and `1` for `PAGE \* roman \* Arabic`. A switch inside quotes is not one.+ #[test]+ fn the_last_format_switch_wins() {+ let text = |instr: &str, n: u32| field_text(instr, "", &FieldCtx { page: n, pages: n, ..Default::default() }).0;+ assert_eq!(text(r"NUMPAGES \* ArabicDash \* roman", 1), "i");+ assert_eq!(text(r"NUMPAGES \* roman \* ArabicDash", 1), "- 1 -");+ assert_eq!(text(r"PAGE \* Hex \* CardText", 1), "one");+ assert_eq!(text(r"PAGE \* roman \* Arabic", 1), "1");+ assert_eq!(text(r"PAGE \* roman \* MERGEFORMAT", 4), "iv", "a switch that is no format doesn't count");+ assert_eq!(text(r"PAGE \* ROMAN \* roman", 5), "v");+ assert_eq!(text(r#"PAGE \* roman "\* CardText""#, 5), "v", "quoted text is not a switch");+ // Word reads the names without regard to case, except that roman and alphabetic are+ // lowercase only for lowercase.+ for (instr, shown) in [+ (r"PAGE \* CARDTEXT", "five"),+ (r"PAGE \* cardtext", "five"),+ (r"PAGE \* ordtext", "fifth"),+ (r"PAGE \* ORDTEXT", "fifth"),+ (r"PAGE \* Roman", "V"),+ (r"PAGE \* ROMAN", "V"),+ (r"PAGE \* Alphabetic", "E"),+ (r"PAGE \* alphabetic", "e"),+ (r"PAGE \* ordinal", "5th"),+ (r"PAGE \* ARABIC", "5"),+ ] {+ assert_eq!(text(instr, 5), shown, "{instr}");+ }+ }++ /// `\* DollarText` cuts the whole part off and rounds the first two decimals in decimal, half+ /// up; decimals that round to 100 show 00 and leave the whole part alone. Word 16.0 (build+ /// 16.0.20527), 2026-10-26.+ #[test]+ fn dollar_text_follows_words_rounding() {+ for (value, text) in [+ (0.999, "zero and 00/100"),+ (1.995, "one and 00/100"),+ (2.999, "two and 00/100"),+ (0.995, "zero and 00/100"),+ (999_999.995, "nine hundred ninety-nine thousand nine hundred ninety-nine and 00/100"),+ (999_999.999, "nine hundred ninety-nine thousand nine hundred ninety-nine and 00/100"),+ (999_999.99, "nine hundred ninety-nine thousand nine hundred ninety-nine and 99/100"),+ (12.345, "twelve and 35/100"),+ (14.555, "fourteen and 56/100"),+ (2.675, "two and 68/100"),+ (1.4, "one and 40/100"),+ (0.5, "zero and 50/100"),+ (0.0, "zero and 00/100"),+ (0.004, "zero and 00/100"),+ (0.005, "zero and 01/100"),+ (-0.004, "zero and 00/100"),+ (-0.005, "zero and 01/100"),+ (-0.01, "zero and 01/100"),+ (-0.5, "zero and 50/100"),+ (1234.5, "one thousand two hundred thirty-four and 50/100"),+ (9.20 + 5.35, "fourteen and 55/100"),+ ] {+ assert_eq!(dollar_text(value).as_deref(), Some(text), "{value}");+ }+ for value in [-1.0, -1.5, 1_000_000.0, 1_000_000.4, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300] {+ assert_eq!(dollar_text(value), None, "{value}"); }+ }++ /// A number a format can't write shows Word's error text, in capitals under `\* Upper`; the+ /// others are rounded first. Word 16.0 (build 16.0.20527), 2026-10-26, for `=` fields with the+ /// same results.+ #[test]+ fn numbers_outside_a_format_show_words_error() {+ let shown = |instr: &str, fmt: NumFormat, value: f64| field_value(instr, fmt, value);+ let error = NUMBER_FORMAT_ERROR;+ assert_eq!(error, "Error! Number cannot be represented in specified format.");+ for (fmt, value) in [+ (NumFormat::CardinalText, 1_000_140.0),+ (NumFormat::CardinalText, 1_000_000.0),+ (NumFormat::CardinalText, 999_999.5),+ (NumFormat::CardinalText, -1.0),+ (NumFormat::CardinalText, -0.6),+ (NumFormat::OrdinalText, 1_000_000.0),+ (NumFormat::OrdinalText, -5.0),+ (NumFormat::LowerRoman, -5.0),+ (NumFormat::LowerRoman, 32_768.0),+ (NumFormat::UpperRoman, 50_000.0),+ (NumFormat::LowerRoman, 32_767.5),+ (NumFormat::LowerRoman, 1_000_000.0),+ (NumFormat::LowerLetter, -5.0),+ (NumFormat::LowerLetter, 781.0),+ (NumFormat::UpperLetter, 999_999.0),+ (NumFormat::Hex, -5.0),+ (NumFormat::Hex, 65_536.0),+ (NumFormat::Hex, 999_999.0),+ (NumFormat::Hex, 65_535.5),+ (NumFormat::DollarText, -1.0),+ (NumFormat::DollarText, 1_000_000.0),+ // From a half up to one, in size, Word shows the error for every format.+ (NumFormat::CardinalText, 0.5),+ (NumFormat::OrdinalText, 0.7),+ (NumFormat::LowerRoman, 0.6),+ (NumFormat::Hex, 0.9),+ (NumFormat::LowerLetter, 0.5),+ (NumFormat::NumberInDash, 0.5),+ (NumFormat::Ordinal, 0.9),+ (NumFormat::Decimal, 0.99),+ (NumFormat::Decimal, -0.99),+ (NumFormat::LowerRoman, -0.5),+ (NumFormat::CardinalText, f64::NAN),+ ] {+ assert_eq!(shown("=0", fmt, value), error, "{fmt:?} {value}");+ }+ assert_eq!(shown(r"=1000140 \* CardText \* Upper", NumFormat::CardinalText, 1_000_140.0), error.to_uppercase());+ // At the ends of each range.+ assert_eq!(shown("", NumFormat::CardinalText, 999_999.0), "nine hundred ninety-nine thousand nine hundred ninety-nine");+ assert_eq!(shown("", NumFormat::CardinalText, 999_999.4), "nine hundred ninety-nine thousand nine hundred ninety-nine");+ assert_eq!(shown("", NumFormat::OrdinalText, 999_999.0), "nine hundred ninety-nine thousand nine hundred ninety-ninth");+ assert_eq!(shown("", NumFormat::OrdinalText, 0.0), "zeroth");+ assert_eq!(shown("", NumFormat::CardinalText, 0.0), "zero");+ assert_eq!(shown("", NumFormat::CardinalText, -0.4), "zero");+ assert_eq!(shown("", NumFormat::OrdinalText, 0.4), "zeroth");+ assert_eq!(shown("", NumFormat::LowerRoman, 32_767.0), format!("{}dcclxvii", "m".repeat(32)));+ assert_eq!(shown("", NumFormat::LowerRoman, 32_767.4), format!("{}dcclxvii", "m".repeat(32)));+ assert_eq!(shown("", NumFormat::LowerRoman, 5000.0), "mmmmm");+ assert_eq!(shown("", NumFormat::UpperRoman, 1000.0), "M");+ assert_eq!(shown("", NumFormat::LowerRoman, 3999.0), "mmmcmxcix");+ assert_eq!(shown("", NumFormat::LowerLetter, 780.0), "z".repeat(30));+ assert_eq!(shown("", NumFormat::LowerLetter, 27.0), "aa");+ assert_eq!(shown("", NumFormat::UpperLetter, 53.0), "AAA");+ assert_eq!(shown("", NumFormat::Hex, 65_535.0), "FFFF");+ assert_eq!(shown("", NumFormat::Hex, 0.0), "0");+ assert_eq!(shown("", NumFormat::Hex, -0.4), "0");+ // Roman numerals and letters show a space for 0.+ for fmt in [NumFormat::LowerRoman, NumFormat::UpperLetter] {+ for value in [0.0, 0.49, -0.49] {+ assert_eq!(shown("", fmt, value), " ", "{fmt:?} {value}");+ }+ }+ // Rounding is half away from zero for every format.+ assert_eq!(shown("", NumFormat::CardinalText, 2.5), "three");+ assert_eq!(shown("", NumFormat::CardinalText, 2.6), "three");+ assert_eq!(shown("", NumFormat::OrdinalText, 2.5), "third");+ assert_eq!(shown("", NumFormat::LowerRoman, 2.5), "iii");+ assert_eq!(shown("", NumFormat::LowerRoman, 1.4), "i");+ assert_eq!(shown("", NumFormat::LowerRoman, 1.5), "ii");+ assert_eq!(shown("", NumFormat::Hex, 2.5), "3");+ // Arabic, ArabicDash and Ordinal write any number.+ assert_eq!(shown("", NumFormat::Decimal, 1_234_567.891), "1234568");+ assert_eq!(shown("", NumFormat::Decimal, 2.5), "3");+ assert_eq!(shown("", NumFormat::Decimal, -2.5), "-3");+ assert_eq!(shown("", NumFormat::Decimal, 0.4), "0");+ assert_eq!(shown("", NumFormat::NumberInDash, -5.0), "- -5 -");+ assert_eq!(shown("", NumFormat::NumberInDash, -1.4), "- -1 -");+ assert_eq!(shown("", NumFormat::NumberInDash, 2.5), "- 3 -");+ assert_eq!(shown("", NumFormat::NumberInDash, 1_000_000.0), "- 1000000 -");+ assert_eq!(shown("", NumFormat::Ordinal, 2.5), "3rd");+ assert_eq!(shown("", NumFormat::Ordinal, -1.0), "-1st");+ assert_eq!(shown("", NumFormat::Ordinal, 1_000_000.0), "1000000th");+ // Page-number fields use the same rules.+ assert_eq!(field_number(r"SEQ x \* Upper", NumFormat::CardinalText, 1_000_140), error.to_uppercase()); assert_eq!(field_number("SEQ x", NumFormat::CardinalText, 999_999), "nine hundred ninety-nine thousand nine hundred ninety-nine"); } - /// Several capitalization switches apply in the order they appear.+ /// The text argument of a TITLE-like field: quoted text with a backslash making the next+ /// character plain, or one word; a switch or nothing after the name is no argument.+ #[test]+ fn text_arguments() {+ for (instr, text) in [+ (r#"TITLE "Quarterly report""#, Some("Quarterly report")),+ (r#" SUBJECT "a \"q\" b" \* Upper"#, Some(r#"a "q" b"#)),+ (r#"TITLE "x \* Upper""#, Some("x * Upper")),+ (r#"TITLE "a \\ b""#, Some(r"a \ b")),+ (r#"TITLE "end\""#, Some("end\"")),+ (r#"TITLE "open"#, Some("open")),+ (r#"TITLE "trailing\"#, Some("trailing")),+ (r#"TITLE """#, Some("")),+ ("SUBJECT Draft two", Some("Draft")),+ ("TITLE", None),+ (r"TITLE \* Caps", None),+ ("", None),+ ] {+ assert_eq!(text_argument(instr).as_deref(), text, "{instr}");+ }+ }++ /// Several capitalization switches apply in the order they appear (Word 16.0, 2026-10-26). #[test] fn capitalization_switches_apply_in_order() { let ctx = FieldCtx { title: "WEEKLY REPORT".into(), ..Default::default() };@@ -475,6 +777,7 @@ mod tests { assert_eq!(text(r"TITLE \* Lower \* FirstCap"), "Weekly report"); assert_eq!(text(r"TITLE \* Caps \* Lower"), "weekly report"); assert_eq!(text(r"TITLE \* Lower \* MERGEFORMAT \* Upper"), "WEEKLY REPORT");+ assert_eq!(text(r#"TITLE "ABC DEF" \* Lower \* FirstCap"#), "Abc def"); } /// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.@@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -135,8 +135,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): - Convert Table to Text has no "Convert nested tables" check box; nested tables always stay tables, which is Word's default (#4155).-- PAGE with a section page-number format of cardinal or ordinal text (`w:pgNumType`) shows the words in lowercase; only a `\* CardText` or `\* OrdText` switch should lowercase them (#4224).-- A number outside a text format's range (`\* CardText` or `\* OrdText` past 999,999, `\* roman` past 50,000) keeps its digits; Word shows "Error! Number cannot be represented in specified format." (#4224).+- Field format rules now follow real Word (16.0, 2026-10-26; the constants are in `layout::fields` tests): the last `\*` format switch wins, `\* Caps` lowercases the other letters, `\* DollarText` cuts off the whole part and rounds the decimals in decimal, a number a format can't write shows "Error! Number cannot be represented in specified format.", and a quoted text argument takes `\x` as x. A section page-number format of cardinal or ordinal text shows lowercase words in Word as well, so that follow-up was wrong and is gone (#4224).+- Differences from Word's field formats we don't match: `\* Caps` in a text with ß, a ligature or a digraph letter (Word then leaves some words unlowered in a pattern we could not work out, and raises dž to Dž); an unknown `\*` switch last in a formula (Word shows "Error! Unknown switch argument."); `\* Arabic` together with a `\#` picture (not checked in Word); a quoted argument's escapes in fields other than the document-property fields (only TITLE, AUTHOR and SUBJECT were checked). - AutoFormat's `*bold*` and `_italic_` don't convert when a closing bracket or quote follows the closing mark (`(*see note*)`): the trigger checks only space and `,.;:!?` (#4098). - The ribbon's Accept and Reject buttons only open their menus; Word's main half runs Accept (Reject) and Move to Next. The ribbon needs a big split button (Paste needs one too) (#4205). @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (Insert Table of Figures lists any caption label, #3013; previously trivial: Performance row: typing no longer starts render threads on every repaint, #3027; previously trivial: shapes inserted in a row are all kept, #2019; previously trivial: East Asian typography: `w:eastAsianLayout` kept on save, not laid out; previously trivial: Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** trivial (Field `\*` format rules checked against Word for Windows 16.0 (build 16.0.20527), not against the Word for Mac 16.113.4 target of this assessment; previously trivial: Insert Table of Figures lists any caption label, #3013; previously trivial: Performance row: typing no longer starts render threads on every repaint, #3027; previously trivial: shapes inserted in a row are all kept, #2019; previously trivial: East Asian typography: `w:eastAsianLayout` kept on save, not laid out; previously trivial: Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are@@ -64,7 +64,7 @@ full parity for the area. | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |-| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC, Table of Figures and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), table of figures (`\c \a \n \p`, any caption label, so a list of tables sits beside a list of figures; #3013), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402); Insert Caption… on the right-click menu of pictures, shapes, text boxes and charts (#1230). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources saved in DOCX (#790). Manage Sources imports Word's bibliography file (`b:Sources`, `Sources.xml`, #2640). |+| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC, Table of Figures and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), table of figures (`\c \a \n \p`, any caption label, so a list of tables sits beside a list of figures; #3013), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402); Field `\*` format switches follow Word for Windows 16.0 (build 16.0.20527) as checked in a new document on 2026-10-26 (not checked in Word for Mac 16.113.4): the last format switch wins, `\* Caps` lowercases the rest, `\* DollarText` rounds decimals in decimal, and a number a format can't write (CardText/OrdText past 999,999, roman past 32,767, alphabetic past 780, Hex past 65,535) shows "Error! Number cannot be represented in specified format."; unmatched cases are in `gaps.md`. Insert Caption… on the right-click menu of pictures, shapes, text boxes and charts (#1230). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources saved in DOCX (#790). Manage Sources imports Word's bibliography file (`b:Sources`, `Sources.xml`, #2640). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 44% | (in Review) | 20–40 | English, Polish, Portuguese, Italian and Czech (system Hunspell, #1048), chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10); Portuguese (pt-BR) against the installed VERO Hunspell dictionary — the affix engine reads its `FLAG UTF-8` flags, continuation classes and forbidden spellings — with accent-first suggestions, conservative grammar rules and TeX patterns (2026-10-12); Italian (it-IT) against the installed Hunspell dictionary through the same affix engine, with accent-first suggestions and conservative grammar rules. Other languages are left unmarked. The custom dictionary persists across sessions (#40). Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |@@ -313,6 +313,7 @@ can be used without committing Word output. | 2026-10-10 | minor | Merged main (12 PRs): Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info fields, Serbian. Catalog 378/428 → 384/430 (89.3%); typing 82→85%, styles 72→74%, tables 74→76%; feature depth 67→68%; mainstream 54→55%; full stays ~60%, essentials ~63% | | 2026-10-10 | minor | Readiness table: hours to ~95% per audience (full ~650–1,100 h, mainstream ~300–500 h, essentials ~90–140 h). Full number restored to the additive weighted sum, ~47% → ~60%: method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h | | 2026-10-11 | trivial | East Asian typography 15% → 25%: document grid (#391); kinsoku and Asian Typography options already landed |+| 2026-10-26 | trivial | References: field format switches (`\* Caps`, `\* DollarText`, last switch wins, out-of-range error, quoted escapes) checked against Word for Windows 16.0.20527; not checked in Word for Mac | | 2026-10-10 | minor | Added mainstream practitioner (~54%) and essentials user (~63%) numbers with written weights and discounts; full ready-for-real-work recomputed with the same multiplicative discounts: 60% → ~47% (method change, not new product evidence; stage stays alpha: above 40% and the gate passes); beta now ~450–700 h to the 75% bar; user-evidence counts from GitHub | | 2026-10-10 | minor | Stage checked against the core-workflow alpha gate: passes, stays alpha | | 2026-10-10 | major | Created from ROADMAP.md's "Parity by area" and "Estimate to 100%" sections; full re-measure against Word for Mac 16.113.4 (bundle listing, catalog diff from source, issue tracker); split into feature depth (67%) and ready for real work (60%); added proofing, East Asian, accessibility areas and the dimension table; hours re-calibrated from PR history |