Skip to content

fix(super-converter): keep the document when a style record is unreadable (#3861) - #3865

Closed
caio-pizzol wants to merge 2 commits into
fix/empty-ooxml-style-propertiesfrom
fix/empty-ooxml-style-catalogue
Closed

fix(super-converter): keep the document when a style record is unreadable (#3861)#3865
caio-pizzol wants to merge 2 commits into
fix/empty-ooxml-style-propertiesfrom
fix/empty-ooxml-style-catalogue

Conversation

@caio-pizzol

@caio-pizzol caio-pizzol commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Stops one malformed w:style from discarding an entire imported document. Same root cause as #3861, much worse outcome, so it is split out for separate review.

The style catalogue is built outside the importer per-node recovery boundary. A throw there aborts createDocumentJson, getSchema returns null, and createDocument falls back to an empty document, losing the whole body instead of one node. The exception event does fire and hosts can subscribe via onException, so it is not strictly silent, but the default is a blank editable page with no error state.

  • Scope: the fragment in Empty <w:rPr /> (and similar) in styles.xml table styles aborts DOCX import #3861 does not reach this path. It needs an empty w:outlineLvl, an empty w:tab, a repeated w:styleId whose second record is empty, or a w:style with no attributes. Whether the reporting customer hit this is unknown without their file.
  • Guarding the crash was not enough. An outline level with a missing or non-numeric w:val still parsed to NaN, and a tab stop missing w:val or w:pos was still emitted half-formed. Both attributes are required in ECMA-376, so both records are now dropped, and the tests assert parsed values rather than only that import survived.
  • Style records with no w:styleId are skipped, since nothing can reference them, and the latent-style loop that collected into an unread list is gone.

Not included: the getSchema fallback of returning null and mounting an empty document. Turning a parse failure into a blank page is arguably wrong, but it changes an initialization contract and deserves its own decision.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The ecma-spec MCP tools are all blocked behind a permission prompt that isn't being granted in this environment (every call returns "haven't granted it yet"), so I couldn't run live schema lookups. I've verified the changed elements/attributes against ECMA-376 (WordprocessingML, §17.3 / §17.7) from knowledge instead. Flagging that so you can re-run with the MCP enabled if you want machine-checked confirmation — but the surface here is small and all core WordprocessingML.

Status: PASS

This PR is a refactor of the styles.xml reader — it swaps ad-hoc .elements.find(...) walks for shared xml-node-access helpers and hardens against malformed style records. It doesn't introduce any new OOXML element/attribute references, and everything it touches checks out against the spec:

  • w:tab (get-default-style-definition.js:65-80) — the new comment claims w:val and w:pos are required. That's correct: CT_TabStop declares both w:val (ST_TabJc) and w:pos (ST_SignedTwipsMeasure) as required, with w:leader optional. All three attributes read here exist. The left→start / right→end remap is internal normalization (both spellings are valid ST_TabJc members), not a spec concern. See https://ooxml.dev/spec?q=tab
  • w:style filter (docxImporter.js) — dropping w:style elements with no w:styleId is spec-consistent. w:styleId is optional in the CT_Style schema, but a style with no id is unreferenceable, so treating it as unusable doesn't violate anything. https://ooxml.dev/spec?q=style
  • w:pageBreakBefore / w:pageBreakAfter defaults (:88-99) — treating a present element with no w:val as true/1 is the correct ST_OnOff semantics (CT_OnOff defaults to on when the attribute is absent). https://ooxml.dev/spec?q=pageBreakBefore
  • Remaining readsw:spacing (w:before/w:after/w:line), w:ind (w:left/w:right/w:firstLine), w:jc/w:val, w:outlineLvl/w:val, w:link/w:val, w:basedOn/w:val, w:name/w:val, and the presence-only w:qFormat/w:keepNext/w:keepLines all reference valid attributes on their respective complex types.

One non-spec note (not a violation, so it doesn't change the verdict): the tab .filter((tab) => tab.attributes) at :67 only checks that some attributes exist, not that both w:val and w:pos are present — a w:tab with only w:pos still produces a tab stop with val: undefined. That's a robustness edge, not an OOXML compliance issue.

No non-existent attributes/elements, no missing-required handling, no wrong defaults.

@caio-pizzol
caio-pizzol force-pushed the fix/empty-ooxml-style-catalogue branch from b743102 to 5ed2957 Compare August 4, 2026 18:04
@caio-pizzol
caio-pizzol marked this pull request as ready for review August 4, 2026 18:09
@caio-pizzol
caio-pizzol requested a review from a team as a code owner August 4, 2026 18:09
@caio-pizzol

Copy link
Copy Markdown
Contributor Author

The tab filter note is already fixed. That review read b743102f; the branch was force-pushed to 5ed2957a at 17:53Z, and the filter now requires both attributes:

.filter((tab) => attrValue(tab, "w:val") != null && attrValue(tab, "w:pos") != null)

It was a real defect, not just a robustness edge: a w:tab with only w:pos produced { pos: 48 } with no val, and one with only w:val produced { val: "start" } with no position. Same commit also fixed an outline level with a missing or non-numeric w:val parsing to NaN. Both are covered by tests that assert the parsed values rather than only that import survived, which is what let them through the first time.

Ran the schema lookups the review could not, and both of its claims hold:

  • CT_TabStop: val required, pos required, leader optional.
  • CT_Style: styleId optional. So dropping styles without one is a usability judgement, not a spec rule, exactly as stated. They are unreferenceable, and they were previously entered into the catalogue under an undefined id.
  • CT_DecimalNumber (w:outlineLvl): val required, which is why a missing one now yields null instead of NaN.

@qodo-code-review

qodo-code-review Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🔴 Action Required

1. Join preserves deleted formatting 🐞
Description
Accepting a paragraph-mark deletion joins the successor into the deleted left-hand paragraph, so the
resulting paragraph retains the deleted block's style, numbering, indentation, and block identity
instead of the successor's. This corrupts the surviving block whenever adjacent paragraphs have
different attributes.
Code

packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js[1667]

+      tr.join(joinPos);
Evidence
The new collapse clears the left node's tracking attribute and calls tr.join at that node's end
without copying the right node's attributes. The accompanying test uses paragraphs with
identical/null attributes and therefore verifies only text and block count, while the
implementation's own contract states that the successor should retain its paragraph properties.

packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js[1649-1667]
packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js[46-64]
packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js[75-94]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Accepting a paragraph-mark deletion currently performs a forward ProseMirror join, which keeps the deleted left-hand node's attributes and applies them to the successor's surviving content.

## Issue Context
The implementation comment says the successor must keep its own paragraph properties. Capture and restore the successor's node attributes/type as part of the collapse, or replace the empty deleted node while retaining the successor node; add coverage with distinct style, numbering, and block-ID attributes.

## Fix Focus Areas
- packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js[1650-1667]
- packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js[75-94]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Last deleted block survives 🐞
Description
Accepting a whole-block deletion only removes the paragraph by joining it into a successor; when the
target is last in its parent, the guard clears the revision and deliberately leaves an empty block.
The deletion then disappears from review and cannot be retried, leaving the final list item or
paragraph behind.
Code

packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js[R1661-1664]

+      if (joinPos <= 0 || joinPos >= tr.doc.content.size || !canJoin(tr.doc, joinPos)) {
+        // Last block in its parent, or an unjoinable successor. The mark is
+        // resolved and the content is gone; leaving the empty paragraph beats
+        // failing the whole decision.
Evidence
The changed branch clears markTrackChange before testing joinability and explicitly continues with
the empty paragraph when the target is last or unjoinable. Existing acceptance coverage deletes only
a middle paragraph with a successor, while the block-deletion adapter states that acceptance should
remove the paragraph.

packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js[1650-1667]
packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js[46-64]
packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts[517-522]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A paragraph-mark deletion at the end of its parent has no successor to join, so acceptance clears its revision but leaves the empty block in place.

## Issue Context
When the parent permits removal, delete the emptied target node directly. Retain a placeholder only where the schema requires one, such as a table cell, and add separate tests for a final body paragraph, final list item, and required cell paragraph.

## Fix Focus Areas
- packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js[1650-1667]
- packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js[75-94]
- packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js[167-213]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Context used
Review mode: 🧠 Deep: This incremental change has 70 independent edit sites across importer, XML/style parsing, export, tracked-changes logic, document adapters, and SDK paths, creating many distinct opportunities for subtle regressions that benefit from redundant review.
ⓘ  2 issues published inline · 2 in summary — for nitpicking, see all findings

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Previous review results

Review updated until commit b7f9d2d 🧠 Deep

Results up to commit 5ed2957 ⚖️ Balanced


No changes from previous review

Results up to commit 4272276 ⚖️ Balanced


No changes from previous review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

…able

The style catalogue is built outside the importer's per-node recovery boundary,
so one unreadable `w:style` aborted createDocumentJson. getSchema then returned
null and createDocument fell back to an empty document, discarding the whole
body instead of a single node.

Guarding the crash was not enough on its own: an outline level with a missing
or non-numeric w:val still parsed to NaN, and a tab stop missing w:val or w:pos
was still emitted half-formed. Both attributes are required in ECMA-376, so
neither record carries anything to apply and both are now dropped.

Also skips style records with no w:styleId, since nothing in the document can
reference them, and removes the latent-style match loop that collected into a
list nothing ever read.
parseInt reads a numeric prefix, so an outline level of "2abc" or "3.9"
imported as level 2 or 3 and silently reshaped headings and the TOC. w:val is
ST_DecimalNumber, so the whole value must be an integer.

An empty w:pos converted to a plausible-looking position 0 rather than an
obvious NaN. w:pos is ST_SignedTwipsMeasure, a union of xsd:integer and
ST_UniversalMeasure, so the stop is judged by whether it converts to a finite
position instead of by matching the raw string, which would reject legal
values such as "1.5in".
@caio-pizzol
caio-pizzol force-pushed the fix/empty-ooxml-style-catalogue branch from 4272276 to b7f9d2d Compare August 4, 2026 23:34
@caio-pizzol
caio-pizzol deleted the branch fix/empty-ooxml-style-properties August 4, 2026 23:34
@caio-pizzol caio-pizzol closed this Aug 4, 2026
@caio-pizzol caio-pizzol reopened this Aug 4, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

if (joinPos <= 0 || joinPos >= tr.doc.content.size || !canJoin(tr.doc, joinPos)) {
// Last block in its parent, or an unjoinable successor. The mark is
// resolved and the content is gone; leaving the empty paragraph beats
// failing the whole decision.
continue;

P2 Badge Delete accepted terminal block instead of clearing only its mark

When the accepted whole-block deletion is the last top-level paragraph/list item, joinPos is at doc.content.size, so this branch runs after the text content has been removed and after markTrackChange was cleared, leaving an empty numbered paragraph behind. That means tracked delete_blocks still fails for common “delete the last item” cases; the no-successor case should remove the emptied block where the schema permits it, rather than silently keeping it.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@caio-pizzol

Copy link
Copy Markdown
Contributor Author

Out of scope for this PR, but it may be a real finding against a different change.

decision-engine.js is not in this diff. This PR touches four files, all under super-converter/:

docx-helpers/get-default-style-definition.empty-style-properties.test.js
docx-helpers/get-default-style-definition.js
v2/importer/docxImporter.empty-style-properties.test.js
v2/importer/docxImporter.js

Neither commit on this branch modifies track-changes/review-model/decision-engine.js. The linked blob resolves at this PR's head only because the branch was rebased onto current main a few minutes ago, so the head SHA contains everything already on main.

Those lines came from 10e0adb4c, "feat(v1): let agents delete a whole list item as a tracked suggestion (#1305)", which landed on main independently of this work.

Not dismissing the substance. If the terminal-block case is genuinely leaving an emptied numbered paragraph behind, that is worth pursuing, just against the change that introduced it rather than here. Happy to open a separate issue referencing this comment if that is useful.

@superdoc-bot

superdoc-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This pull request landed on main as 2a9df40.

@superdoc-bot superdoc-bot Bot closed this in 2a9df40 Aug 4, 2026
@caio-pizzol
caio-pizzol deleted the fix/empty-ooxml-style-catalogue branch August 4, 2026 23:44
@superdoc-orbit

superdoc-orbit Bot commented Aug 5, 2026

Copy link
Copy Markdown

🎉 This PR is included in superdoc-cli v0.23.1

The release is available on GitHub release

@superdoc-orbit

superdoc-orbit Bot commented Aug 5, 2026

Copy link
Copy Markdown

🎉 This PR is included in superdoc-sdk v1.22.1

@superdoc-orbit

superdoc-orbit Bot commented Aug 5, 2026

Copy link
Copy Markdown

🎉 This PR is included in @superdoc-dev/mcp v0.18.1

The release is available on GitHub release

@superdoc-orbit

superdoc-orbit Bot commented Aug 5, 2026

Copy link
Copy Markdown

🎉 This PR is included in superdoc v1.46.1

The release is available on GitHub release

@superdoc-orbit

superdoc-orbit Bot commented Aug 5, 2026

Copy link
Copy Markdown

🎉 This PR is included in @superdoc-dev/react v1.17.1

The release is available on GitHub release

@superdoc-orbit

superdoc-orbit Bot commented Aug 5, 2026

Copy link
Copy Markdown

🎉 This PR is included in vscode-ext v2.18.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant