diff --git a/ROADMAP.md b/ROADMAP.md index 884007ad5..06ca479ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -456,6 +456,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout | | 2026-10-24 | Document information fields: SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS update with the other fields and are listed in the Field dialog; a quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces that document property (at most 255 characters), and NUMWORDS, NUMCHARS and REVNUM take a `\*` number format | | 2026-10-24 | Field capitalization switches: `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the document information, DOCPROPERTY, FILENAME, date, REF, PAGEREF, SEQ, STYLEREF and table formula results that Update Field computes and to the page-number fields, and `\* CardText` and `\* OrdText` results are lowercase unless one of them follows (`\* CardText \* Caps` shows "Seven Hundred Ninety"); a table formula takes a `\*` number format (`=SUM(A1:B2) \* CardText`), and other `\*` switches such as `\* MERGEFORMAT` no longer make it a syntax error | +| 2026-10-24 | Field `\* DollarText` and `\* Hex` switches: `{ = 9.20 + 5.35 \* DollarText \* Upper }` shows "FOURTEEN AND 55/100" and `\* Hex` writes uppercase hexadecimal digits (458 shows "1CA"), in table formulas and in the page-number, SEQ, PAGEREF and count fields; `{ PAGE \* ArabicDash }` shows "- 31 -"; cardinal, ordinal and dollar text past 999,999 keep their digits | | 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP | | 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off | | 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing | diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs index 60d4c02cb..0ee50057d 100644 --- a/crates/engine/src/cmd/table_formula.rs +++ b/crates/engine/src/cmd/table_formula.rs @@ -109,9 +109,12 @@ pub fn evaluate_field_with( /// 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. Any other result keeps its digits. A capitalization switch -/// (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`) then applies to the text. Other `\*` switches -/// leave the text as it is. Quoted text, such as a `\#` picture, is not a switch. +/// 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. A capitalization +/// switch (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`) then applies to the text. 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::>().join(" "); @@ -123,6 +126,10 @@ fn format_switches(switches: &str, value: f64, text: String) -> String { _ => 0.0..=f64::from(u32::MAX), }; 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 value.fract() == 0.0 && covers(fmt).contains(&value) => wordcraft_layout::fields::field_number(switches, fmt, value as u32), _ => wordcraft_layout::fields::apply_case_switch(switches, text), } @@ -1005,6 +1012,24 @@ mod tests { assert_eq!(shown("=A1 \\* alphabetic"), "790", "a number past alphabetic keeps its digits"); } + #[test] + fn dollar_text_and_hex_switches_write_the_result() { + let t = table(&[&["9.20", "5.35"], &["458", "-1"], &["", ""]]); + let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}")); + 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("=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"); + assert_eq!(shown("=A2 \\* ArabicDash"), "458", "ArabicDash is for page numbers only"); + } + #[test] fn a_huge_range_over_a_small_table_reads_only_the_table() { let t = table(&[&["1", "2", "3"], &["4", "5", "6"], &["", "", ""]]); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 12f8bec80..3049d56c9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8798,6 +8798,27 @@ fn table_formula_results_take_the_format_switches() { } } +#[test] +fn table_formula_results_take_dollar_text_and_hex() { + for (formula, inserted, updated) in [ + (r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"), + (r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"), + (r"=SUM(ABOVE) \* Hex", "C", "15"), + ] { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": formula})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); + run( + &mut s, + "select.range", + json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}), + ); + run(&mut s, "text.insert", json!({"text": "10"})); + run(&mut s, "references.updateFields", json!({})); + assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}"); + } +} + /// A one-column table (no header row) holding `cells`, in a document whose default language /// is `lang`; the caret ends in the last cell. fn column_table(lang: &str, cells: &[&str]) -> Session { diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index 8a0590f2d..88a6f03f0 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -125,8 +125,20 @@ pub(crate) fn reference_link(instr: &str) -> Option { None } -/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal…). +/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal, +/// DollarText, Hex…). pub fn format_switch(instr: &str) -> Option { + format_switch_of(instr, false) +} + +/// A PAGE field's `\* FORMAT` switch value: the values of [`format_switch`] and `\* ArabicDash` +/// (`{ PAGE \* ArabicDash }` shows "- 31 -"). Only PAGE reads `\* ArabicDash`; other fields, +/// PAGEREF included, skip it and read the next format switch. +pub fn page_format_switch(instr: &str) -> Option { + format_switch_of(instr, true) +} + +fn format_switch_of(instr: &str, page: bool) -> Option { let mut it = instr.split_whitespace(); while let Some(w) = it.next() { if let Some(rest) = w.strip_prefix("\\*") { @@ -141,6 +153,9 @@ pub fn format_switch(instr: &str) -> Option { "OrdText" | "ordtext" => NumFormat::OrdinalText, "Ordinal" | "ordinal" => NumFormat::Ordinal, "Arabic" | "arabic" => NumFormat::Decimal, + "ArabicDash" | "arabicdash" | "ARABICDASH" if page => 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, @@ -228,17 +243,38 @@ pub fn apply_case_switch(instr: &str, text: String) -> String { } } -/// Formats `n` in `fmt` as a field shows it. Cardinal and ordinal text is lowercase -/// (`\* CardText` shows "twenty-one"), and the field's capitalization switch applies after that -/// (`\* CardText \* Caps` shows "Twenty-one"). +/// 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 switch applies after that (`\* CardText \* Caps` +/// shows "Twenty-one"). 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), }; apply_case_switch(instr, text) } +/// The largest number cardinal, ordinal and dollar text write in words. +const MAX_TEXT_NUMBER: u32 = 999_999; + +/// `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. +pub fn dollar_text(value: f64) -> Option { + let cents = (value * 100.0).round(); + if !(0.0..=f64::from(MAX_TEXT_NUMBER) * 100.0 + 99.0).contains(¢s) { + return None; + } + let cents = cents as u32; + Some(format!("{} and {:02}/100", NumFormat::CardinalText.format(cents / 100).to_lowercase(), cents % 100)) +} + /// The light grey behind a field shown as its code. pub const FIELD_SHADING: wordcraft_doc::props::Rgb = wordcraft_doc::props::Rgb(0xD9, 0xD9, 0xD9); @@ -257,7 +293,7 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S let name = field_name(instr); let fmt = format_switch(instr); match name.as_str() { - "PAGE" => (field_number(instr, fmt.unwrap_or(ctx.page_format), page), true), + "PAGE" => (field_number(instr, page_format_switch(instr).unwrap_or(ctx.page_format), page), true), "NUMPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.pages), true), "SECTIONPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section_pages), true), "SECTION" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section), true), @@ -404,6 +440,36 @@ mod tests { 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` a page number between hyphens, on PAGE only. Words + /// stop at 999,999; a larger number keeps its digits. + #[test] + fn dollar_text_hex_and_arabic_dash_switches() { + let ctx = FieldCtx { page: 31, pages: 458, section: 790, ..Default::default() }; + let text = |instr: &str| field_text(instr, "", &ctx).0; + assert_eq!(text(r"PAGE \* ArabicDash"), "- 31 -"); + assert_eq!(text(r"PAGE \*arabicdash \* MERGEFORMAT"), "- 31 -"); + assert_eq!(text(r"NUMPAGES \* ArabicDash"), "458", "ArabicDash is for page numbers only"); + assert_eq!(text(r"NUMPAGES \* ArabicDash \* roman"), "cdlviii", "the next format switch counts"); + assert_eq!(text(r"NUMPAGES \* Hex"), "1CA"); + 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"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!(field_number("SEQ x", NumFormat::CardinalText, 999_999), "nine hundred ninety-nine thousand nine hundred ninety-nine"); + } + /// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored. #[test] fn ask_and_set_show_nothing() {