collective

Browse / github.com/storytold/wordcraft / patch

fix(fields): quoted-argument backslash escapes; body PAGE in a cardinal-text section

fix(fields): quoted-argument backslash escapes; body PAGE in a cardinal-text section Two field-result fixes, both checked against real Word first. **What Word does** (Word COM, temporary documents, closed without saving): - `TITLE "x \* Upper"` shows `x * Upper`; `TITLE "x \\* Upper"` shows `x \* Upper`; `TITLE "x \"q\" z"` shows `x "q" z`. A backslash in a quoted argument makes the next character literal. - A body `PAGE` field in a section whose page number format is cardinal text shows `one`; `PAGE \* Upper` shows `ONE`. **Fixes** - `FieldCode::parse` only unescaped `\"` and `\\` inside quotes. It now unescapes any `\X`, so the quoted text reaches TITLE/SUBJECT/AUTHOR/KEYW

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #4579, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+43 −3 in 5 files
Review
pending
Shared
2026-10-28
Id
6fa368e6d7dc32944bb89595

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/references.rs crates/engine/src/cmd/references/tests.rs crates/layout/src/floats.rs crates/layout/src/flow.rs crates/layout/src/tests/pages.rs

diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs--- a/crates/engine/src/cmd/references.rs+++ b/crates/engine/src/cmd/references.rs@@ -100,8 +100,9 @@ pub fn specs() -> Vec<CommandSpec> {  /// A field instruction split into its name, positional arguments and switches (ECMA-376 /// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays-/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch-/// takes the word after it as its argument unless that word is another switch.+/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word:+/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its+/// argument unless that word is another switch. #[derive(Clone, Debug, Default, PartialEq)] pub struct FieldCode {     pub name: String,@@ -123,7 +124,7 @@ impl FieldCode {                 while let Some(c) = chars.next() {                     match c {                         '"' => break,-                        '\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),+                        '\\' if chars.peek().is_some() => w.extend(chars.next()),                         c => w.push(c),                     }                 }diff --git a/crates/engine/src/cmd/references/tests.rs b/crates/engine/src/cmd/references/tests.rs--- a/crates/engine/src/cmd/references/tests.rs+++ b/crates/engine/src/cmd/references/tests.rs@@ -511,3 +511,16 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {     let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();     assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}"); }++/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an+/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).+#[test]+fn quoted_field_argument_backslash_escapes() {+    for (instr, shown) in [(r#"TITLE "x \* Upper""#, "x * Upper"), (r#"TITLE "x \\* Upper""#, r"x \* Upper"), (r#"TITLE "a \"b\" c""#, r#"a "b" c"#)]+    {+        let value = new_property_value(instr).unwrap();+        assert_eq!(value, shown, "{instr}");+        assert_eq!(text_result(instr, value), shown, "{instr}");+    }+    assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");+}diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs--- a/crates/layout/src/floats.rs+++ b/crates/layout/src/floats.rs@@ -339,6 +339,7 @@ pub(crate) fn anchor_floats(     place: bool, ) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {     ctx.fields.page = pb.number;+    ctx.fields.page_format = pb.sect.page_num_format;     let (col_x, width) = (pb.col_x(), pb.col_w());     let mut rects = HashMap::new();     if !pb.web && place {diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs--- a/crates/layout/src/flow.rs+++ b/crates/layout/src/flow.rs@@ -194,6 +194,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:             if here != rel {                 wrap_relays += 1;                 ctx.fields.page = laid_page;+                ctx.fields.page_format = pb.sect.page_num_format;                 #[cfg(test)]                 WRAP_RELAYS.with(|c| c.set(c.get() + 1));                 let relaid =@@ -224,6 +225,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:             page_relays += 1;             page_map.push((resume, pb.number));             ctx.fields.page = laid_page;+            ctx.fields.page_format = pb.sect.page_num_format;             let relaid =                 with_page_map(ctx, &mut page_map, |ctx| again(ctx, eq_start, |ctx| ctx.para_labelled(p, laid_width, None, &rel, label.clone())));             match relaid.lines.iter().position(|l| l.start == resume) {@@ -239,6 +241,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:         }         // What is laid out for these lines from here (their footnotes) is on this page.         ctx.fields.page = pb.number;+        ctx.fields.page_format = pb.sect.page_num_format;         // How many lines fit?         let Some(first) = pl.lines.get(l0) else { break };         // A paragraph's first line may start below its top (pushed down by a floating object).@@ -548,6 +551,7 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr     let width = (pb.col_w() - indent - outline::SYMBOL_W).max(72.0);     let q = outline::outline_para(ctx.doc, p, o.view.plain);     ctx.fields.page = pb.number;+    ctx.fields.page_format = pb.sect.page_num_format;     let pl = ctx.para_labelled(&q, width, None, &[], label);     pb.y += pl.rp.space_before.max(0.0);     let n = pl.lines.len();diff --git a/crates/layout/src/tests/pages.rs b/crates/layout/src/tests/pages.rs--- a/crates/layout/src/tests/pages.rs+++ b/crates/layout/src/tests/pages.rs@@ -612,3 +612,24 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {         assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");     } }++/// A body PAGE field shows the page number in the section's number format, as Word does (a+/// cardinal-text section shows "one"), and `\*` overrides it.+#[test]+fn body_page_field_uses_the_section_number_format() {+    let mut d = Document::from_text("Page \nPage ");+    d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;+    for (i, instr) in ["PAGE", "PAGE \\* Upper"].into_iter().enumerate() {+        let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();+        let off = p.len();+        let field = InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };+        p.insert_object(off, field, &Default::default()).unwrap();+    }+    let l = lay(&d);+    let shown: Vec<String> = l.pages[0]+        .items+        .iter()+        .filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>()) } else { None })+        .collect();+    assert_eq!(shown, ["one", "ONE"], "{shown:?}");+}