Skip to content

feat(docx): cleanDocx round-trip-safe for tracking tokens β€” DOM placeholder swap (variant A)Β #347

Description

@arthrod

@coderabbitai plan

πŸ₯• multi-assumptions: variant A of 3 parallel framings of the same goal.
Sibling variant(s): will be cross-linked once filed. Please compare and tell us which assumption is load-bearing. The carrot family also includes the original framing in #346, which this variant supersedes.

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 A β€” DOM placeholder swap around the existing pipeline)

  1. Add protectTrackingTokens(body) that walks body once: for every text node containing [[DOCX_(INS|DEL|CMT)_(START|END|REF):…]], split the text node, replace each token slice with <span data-docx-tracking-token="<base64-of-original>" data-docx-tracking-kind="ins-start|...">​</span> (zero-width-space inside so no cleaner classifies it as empty).
  2. Run the existing cleanDocx pipeline unchanged: cleanDocxFootnotes β†’ cleanDocxImageElements β†’ cleanHtmlEmptyElements β†’ cleanDocxEmptyParagraphs β†’ cleanDocxQuotes β†’ cleanDocxSpans β†’ cleanHtmlTextNodes β†’ cleanDocxBrComments β†’ cleanHtmlBrElements β†’ cleanHtmlLinkElements β†’ cleanHtmlFontElements β†’ cleanDocxListElements β†’ copyBlockMarksToSpanChild.
  3. Add restoreTrackingTokens(body) that swaps every surviving <span data-docx-tracking-token> back to a Text node with the base64-decoded original token bytes.
  4. Add a string-level pre-pass for tokens that exist between tags (rare Mammoth output) before DOMParser is called.
    Acceptance: the existing cleanDocx test snapshot is unchanged for token-free HTML, and a fixture matrix (token in mso-styled paragraph, end-only paragraph, adjacent tokens, JSON with quotes/spaces) round-trips byte-for-byte.

Because the code is

  • packages/docx/src/lib/docx-cleaner/cleanDocx.ts:23-49 β€” the linear pipeline that runs cleanHtmlTextNodes, cleanDocxEmptyParagraphs, cleanDocxSpans, copyBlockMarksToSpanChild over body. None of these inspect attributes; they classify by tag/text content.
  • cleanDocxEmptyParagraphs drops paragraphs with no visible text. A paragraph whose only content is [[DOCX_INS_END:abc]] reads as visible text β€” survives. A paragraph whose only content is a <span class="track-end"> that gets unwrapped first by cleanDocxSpans and then leaves an empty <p> is dropped.
  • cleanHtmlTextNodes normalizes whitespace and merges adjacent text nodes β€” JSON payloads with author names containing spaces ({"author":"John Doe"}) get the spaces normalized.
  • jubarte-Mammoth output keeps tokens as plain text inside <span> wrappers; the DOM serializer round-trips attributes losslessly.

Assumptions deliberately shuffled vs variants B and C

Axis Variant A (DOM placeholder swap) Variant B (per-cleaner skip predicate) Variant C (extract-stash-reapply at importer)
Layer cleaner pipeline wrapper (DOM) each cleaner's internals importer (caller of cleanDocx)
Trigger pre-pipeline + post-pipeline during each cleaner's walk pre-clean extraction + post-deserialize reapply
Granularity per-token per-cleaner per-token, materialized as marks early
Failure mode silent if placeholder dropped (mitigated by ID pairing) loud (typecheck enforces predicate) partial (reapply may fail to anchor β†’ metadata drop)
Reversibility feature flag at the wrapper boundary hard cutover across many files feature flag at the importer
Round-trip fidelity preserved byte-for-byte preserved byte-for-byte re-derived from sidecar after deserialize
Affects cleanDocx public API no no (internal predicate added per cleaner) no (caller-side change)

Validate this approach β€” especially but not limited to

  • Static analysis of packages/docx/src/lib/docx-cleaner/cleanDocx.ts, packages/docx/src/lib/docx-cleaner/utils/*, and packages/docx-io/src/lib/importDocx.ts.
  • Review of our own code for placeholder-element interactions: do cleanDocxSpans or copyBlockMarksToSpanChild mutate elements with data-* attributes? If yes, the swap leaks.
  • Comparative analysis vs sibling variants B and C β€” 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.
  • Worst-case input fixture: a paragraph with mso-* styles, an <o:p> empty wrapper, three adjacent tokens (END, START, REF), and a JSON payload with \"-escaped quotes inside body.

Full plan

  1. New module packages/docx/src/lib/docx-cleaner/protectTrackingTokens.ts with protectTrackingTokens(body) and restoreTrackingTokens(body).
  2. New shared module packages/utils/docx-tracking-tokens.ts exporting prefix/suffix constants and TRACKING_TOKEN_REGEX so @platejs/docx and @platejs/docx-io import from one place (no cycle).
  3. Modify cleanDocx to wrap the pipeline: protectTrackingTokens(body); …existing pipeline…; restoreTrackingTokens(body);.
  4. Add string-level pre-pass on raw HTML for tokens between tags.
  5. Add fixture matrix in packages/docx/src/lib/docx-cleaner/__tests__/cleanDocx-tracking.spec.ts.
  6. Backport the constant import in @platejs/docx-io to consume the new shared module.

References

πŸ₯• The carrot patch grows three rows side by side β€” pick whichever sprouts the cleanest token through the dirt.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestkilo-duplicateAuto-generated label by Kilokilo-triagedAuto-generated label by Kilo

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions