Output counts locked SEQ fields, and not tracked-deleted ones, as Word does (#5594)

* Count locked SEQ fields and not tracked-deleted ones in output

Exporting, Word counts a locked SEQ, its \r too, though the field keeps its result, and a SEQ in a tracked deletion keeps its result and doesn't count, with markup shown or hidden (measured in Word for Windows). Output counted it the other way round: a locked SEQ was skipped and a deleted one counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: locked and tracked-deleted SEQ fields in layout parity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* count_seq walks the deleted ranges with one cursor

The runs come in order and the objects by offset, so a forward cursor
replaces the per-field scan of every deleted range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
This commit is contained in:
pakiat
2026-11-08 04:38:32 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 Martin Ball
parent 7152143e45
commit b9936609dc
3 changed files with 54 additions and 5 deletions
+13 -4
View File
@@ -102,7 +102,9 @@ struct SeqCount {
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut count = SeqCount::default();
for path in doc.para_paths(StoryRef::Body) {
@@ -120,7 +122,11 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
for (k, o) in p.objects.iter().enumerate() {
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
let mut deleted = p.deleted_ranges().into_iter().peekable();
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
while deleted.next_if(|r| r.end <= off).is_some() {}
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
for id in o.text_boxes() {
if count.boxes.insert(id) {
let part = StoryRef::Part(id);
@@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
}
}
}
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let InlineObject::Field { instr, locked, .. } = o else { continue };
let f = FieldCode::parse(instr);
if f.name != "SEQ" {
if f.name != "SEQ" || in_deletion {
continue;
}
let Some(id) = f.args.first() else { continue };
@@ -149,6 +155,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
if *locked {
continue;
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
count.out.push((story, path.clone(), k, r));
+39
View File
@@ -793,6 +793,45 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
assert_eq!(results, expected);
}
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
#[test]
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
for instr in fields_in {
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let top = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
for (k, o) in para.objects.iter_mut().enumerate() {
if let InlineObject::Field { result, locked, .. } = o {
*locked = matches!(k, 4 | 6);
*result = if *locked { "7" } else { "9" }.into();
}
}
para.touch();
// The second Del field is a tracked deletion.
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
let out = output_fields(&s.doc).expect("the stale results change");
let shown: Vec<String> = out
.para(StoryRef::Body, &top)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
+2 -1
View File
@@ -95,7 +95,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — |
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 |
| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — |
| Fields filled in for output: SEQ, STYLEREF, REF in the body | ✅ | PDF export, print jobs and page images lay out a copy whose unlocked SEQ, STYLEREF and REF fields show their current results, as Word does when printing or exporting with Update fields before printing off (measured in Word for Windows with stale stored results); NUMWORDS, FILENAME, the document properties and formulas keep their stored results, as in Word; REF fields with `\p`, `\r`, `\n` or `\w` (above or below, and paragraph numbers) keep theirs until those are computed, and so does a REF to a bookmark that ends in a later paragraph (Word shows its paragraphs as paragraphs of the result); SEQ numbers are filled in before the STYLEREF and REF fields that read them, so a STYLEREF to a heading holding a SEQ field shows its new number (after F9 too); SEQ fields in text boxes are numbered where their boxes are anchored, after F9 too, and a REF to a bookmark in a text box, header, footer, footnote, endnote or comment shows that text as stored, as in Word; a REF to a bookmark the document lacks prints "Error! Reference source not found."; the document is unchanged (`cmd::references::tests::output_shows_fresh_seq_styleref_and_ref_results`, `cmd::references::tests::output_numbers_text_box_captions_where_their_boxes_are_anchored`, `cmd::references::tests::output_shows_references_to_bookmarks_outside_the_body`, `cmd::references::tests::output_shows_words_error_for_a_reference_to_a_missing_bookmark`) | — |
| Fields filled in for output: SEQ, STYLEREF, REF in the body | ✅ | PDF export, print jobs and page images lay out a copy whose unlocked SEQ, STYLEREF and REF fields show their current results, as Word does when printing or exporting with Update fields before printing off (measured in Word for Windows with stale stored results); a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ in a tracked deletion keeps its result and doesn't count, with markup shown or hidden (measured in Word for Windows); NUMWORDS, FILENAME, the document properties and formulas keep their stored results, as in Word; REF fields with `\p`, `\r`, `\n` or `\w` (above or below, and paragraph numbers) keep theirs until those are computed, and so does a REF to a bookmark that ends in a later paragraph (Word shows its paragraphs as paragraphs of the result); SEQ numbers are filled in before the STYLEREF and REF fields that read them, so a STYLEREF to a heading holding a SEQ field shows its new number (after F9 too); SEQ fields in text boxes are numbered where their boxes are anchored, after F9 too, and a REF to a bookmark in a text box, header, footer, footnote, endnote or comment shows that text as stored, as in Word; a REF to a bookmark the document lacks prints "Error! Reference source not found."; the document is unchanged (`cmd::references::tests::output_shows_fresh_seq_styleref_and_ref_results`, `cmd::references::tests::output_numbers_text_box_captions_where_their_boxes_are_anchored`, `cmd::references::tests::output_shows_references_to_bookmarks_outside_the_body`, `cmd::references::tests::output_shows_words_error_for_a_reference_to_a_missing_bookmark`, `cmd::references::tests::output_counts_locked_seq_fields_and_not_deleted_ones`) | — |
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages; a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — |
| Fields: `\* DollarText`, `\* Hex` and `\* ArabicDash` | 🟡 | `\* DollarText` writes lowercase cardinal text, "and", and the first two decimals over 100 (`{ = 9.20 + 5.35 \* DollarText }` shows "fourteen and 55/100"), `\* Hex` uppercase hexadecimal digits (458 shows "1CA"), and `\* ArabicDash` the number between hyphens (`{ PAGE \* ArabicDash }` shows "- 31 -"), in table formulas and in the page-number, SEQ, PAGEREF, NUMWORDS, NUMCHARS and REVNUM fields; a capitalization switch applies after them (#4226). A result past 999,999 in cardinal or ordinal text, or past 999,999.99 in dollar text, keeps its digits, and so does a negative result or a fractional Hex or ArabicDash result. `layout::fields::tests::dollar_text_hex_and_arabic_dash_switches`, `engine::cmd::table_formula::tests::dollar_text_and_hex_switches_write_the_result`, `engine::tests::table_formula_results_take_dollar_text_and_hex`; not compared with Word | — |
| Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 |
@@ -314,6 +314,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
| 2026-10-31 | trivial | A floating object anchored right after a page break inside a paragraph, before text, is drawn on the new page, as in Word; only a cover page's anchors, with nothing taking room after them, stay on the page before (#4262) |
| 2026-10-31 | trivial | On a later page of a long paragraph, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page; one whose own wrap pushes its line on stays with the text wrapping around it (#4250, `layout::tests::a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page` and the two after it) |
| 2026-10-31 | trivial | A floating object anchored in a line of a long paragraph on a later page is drawn on that page, positioned from where the paragraph continues, as in Word; a shape dropped on such a page stays there (#4250) |
| 2026-11-04 | trivial | Output counts a locked SEQ and not one in a tracked deletion, as Word does; readiness estimates unchanged |
| 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box |
| 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged |
| 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged |