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
@@ -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), } }@@ -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");+}@@ -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 {@@ -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();@@ -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:?}");+}