Skip to content

feat(docx): cleanDocx round-trip-safe for tracking tokens β€” extract-stash-reapply at importer (variant C)Β #349

Description

@arthrod

@coderabbitai plan

πŸ₯• multi-assumptions: variant C of 3 parallel framings of the same goal.
Sibling variant(s): #347 (variant A) and #348 (variant B). Please compare and tell us which assumption is load-bearing. Original framing in #346.

My goal

Make cleanDocx round-trip-safe for the DOCX tracking-token grammar ([[DOCX_INS_*]], [[DOCX_DEL_*]], [[DOCX_CMT_*]]) used by @platejs/docx-io, so the cleanup pipeline strips Office-HTML cruft without mangling JSON payloads, dropping end-only paragraphs, or splitting tokens across normalized text nodes.

To do so, do this (variant C β€” tokens never enter cleanDocx; extract-stash-reapply at the importer)

  1. Leave cleanDocx (and its 13 cleaners) entirely untouched. It stays token-blind and that is fine.
  2. In packages/docx-io/src/lib/importDocx.ts (the tracking import path), insert an extraction step before cleanDocx is called:
    • Walk the raw HTML string with the token regex.
    • For each match, record { tokenText, anchorBefore, anchorAfter, kind, payload } where anchorBefore/anchorAfter are the 32-character text fingerprints (HTML-decoded plain text) immediately preceding and following the token, ignoring whitespace.
    • Strip the token from the HTML.
  3. Run cleanDocx(strippedHtml, rtf) exactly as the non-tracking path does today. The cleaner sees only Office-HTML cruft, no tokens.
  4. Deserialize the cleaned HTML to Plate nodes via editor.api.html.deserialize.
  5. Reapply tokens onto the deserialized tree:
    • For each stashed token, run a cross-node text search (the existing searchRange utility) to locate the position whose surrounding context matches anchorBefore + anchorAfter with edit distance ≀ 2 (cleanup may have collapsed whitespace).
    • At that position, materialize the suggestion mark / comment mark directly on the affected text nodes, using payload. No token text is reinserted into editor.children.
  6. Errors (anchor not found) are aggregated into result.errors and surfaced to the caller; the import does not throw.
    Acceptance: cleanDocx stays untouched; the @platejs/docx-io test suite covers (a) round-trip: import β†’ export produces identical tracking metadata, and (b) anchor-resilience: a fixture where cleanDocx removes a wrapping <span> and merges adjacent text nodes still resolves the token's position.

Because the code is

  • packages/docx/src/lib/docx-cleaner/cleanDocx.ts β€” already a stable, well-tested pipeline. Touching it is the riskiest change in this family.
  • packages/docx-io/src/lib/importDocx.ts (issue feat(docx-io): tracked-changes round-trip via embedded tracking tokensΒ #342 introduces the tracking branch) β€” the single place that knows about tokens. Keeping token-awareness here keeps the boundary clean.
  • The searchRange cross-node text search (issue feat(docx-io): tracked-changes round-trip via embedded tracking tokensΒ #342) is already required to anchor tokens AFTER deserialization, since deserialization reorganizes Mammoth's run-level spans into Slate text nodes. A pre-clean extraction reuses the same anchoring infrastructure with one extra fingerprint.
  • jubarte-Mammoth output preserves enough surrounding text context that 32-character HTML-decoded fingerprints are unique within typical paragraphs (validated empirically against notes/4shapes.docx in potion_deploy).

Assumptions deliberately shuffled vs variants A and B

Axis Variant C (extract-stash-reapply at importer) Variant A (DOM placeholder swap) Variant B (per-cleaner skip predicate)
Layer importer (caller of cleanDocx) cleaner pipeline wrapper (DOM) each cleaner's internals
Trigger pre-clean extraction + post-deserialize reapply pre-pipeline + post-pipeline during each cleaner's walk
Granularity per-token, materialized as marks early per-token per-cleaner
Failure mode partial β€” anchor miss drops one token's metadata, surfaced via errors[] silent if placeholder dropped loud (typecheck enforces predicate)
Reversibility feature flag at the importer feature flag at the wrapper boundary hard cutover across many files
Round-trip fidelity re-derived from sidecar; lossy if anchor matching fails preserved byte-for-byte preserved byte-for-byte
Affects cleanDocx public API no (caller-side change) no no
Couples @platejs/docx and @platejs/docx-io no β€” full decoupling yes β€” shared constants module yes β€” shared predicate module
Survives a future cleaner change yes β€” cleaner can be replaced freely maybe β€” new cleaner unaware of placeholders yes if registry enforced

Validate this approach β€” especially but not limited to

  • Static analysis of packages/docx-io/src/lib/importDocx.ts, packages/docx-io/src/lib/searchRange.ts, packages/docx-io/src/lib/importTrackChanges.ts, packages/docx-io/src/lib/importComments.ts.
  • Review of our own code for the anchor-fingerprint approach β€” is 32 characters with edit-distance ≀ 2 enough? What happens with two identical token-bearing ranges in one paragraph?
  • Comparative analysis vs siblings feat(docx): cleanDocx round-trip-safe for tracking tokens β€” DOM placeholder swap (variant A)Β #347 and feat(docx): cleanDocx round-trip-safe for tracking tokens β€” per-cleaner skip predicates (variant B)Β #348 β€” name the load-bearing assumption as a single sentence in the form "If matters more than , variant <A|B|C> wins; otherwise the other." Do not pick a winner yet β€” name the axis.
  • Adversarial input fixture: a paragraph with two identical insertions by the same author, one immediately after the other, separated by a single space β€” does the fingerprint disambiguate them? Should it use a per-token monotonic counter as a secondary anchor?

Full plan

  1. New module packages/docx-io/src/lib/extractTokensForCleaning.ts exporting extractTokens(html: string) β†’ { stripped: string; tokens: StashedToken[] }.
  2. New module packages/docx-io/src/lib/reapplyTokens.ts exporting reapplyTokens(editor, nodes, tokens) using the existing searchRange utility from feat(docx-io): tracked-changes round-trip via embedded tracking tokensΒ #342.
  3. Modify packages/docx-io/src/lib/importDocx.ts (tracking branch) to extract β†’ cleanDocx β†’ deserialize β†’ reapply.
  4. Add errors[] propagation through ImportDocxWithTrackingResult (the type already exists; just ensure anchor misses are routed there).
  5. Fixture matrix:
    • duplicate-tokens-same-author paragraph
    • token whose anchor text contains mso-* styled spans that cleanDocx will unwrap
    • 1000-token document for performance regression
  6. No changes to @platejs/docx. The package version stays exactly where it is.

References

πŸ₯• The cleanest carrot is the one nobody touched. Pluck the tokens before the kitchen, season after.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions