@ansonlai/docx-redline-js 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/ARCHITECTURE.md +691 -669
  2. package/CHANGELOG.md +67 -1
  3. package/README.md +39 -8
  4. package/core/word-xml.js +160 -118
  5. package/dist/docx-redline-js.esm.js +528 -312
  6. package/dist/docx-redline-js.esm.js.map +4 -4
  7. package/dist/docx-redline-js.esm.min.js +94 -94
  8. package/dist/docx-redline-js.esm.min.js.map +4 -4
  9. package/dist/docx-redline.bundle.cjs +528 -312
  10. package/dist/docx-redline.bundle.cjs.map +4 -4
  11. package/dist/docx-redline.bundle.js +528 -312
  12. package/dist/docx-redline.bundle.js.map +4 -4
  13. package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
  14. package/docs/SKILL_AUTHORING.md +5 -1
  15. package/docs/TESTING.md +10 -1
  16. package/docs/schemas/document-operations.schema.json +1 -1
  17. package/engine/oxml-engine.js +748 -736
  18. package/engine/table-mode.js +7 -4
  19. package/index.d.ts +29 -2
  20. package/index.js +3 -0
  21. package/node/cli.js +993 -992
  22. package/package.json +1 -1
  23. package/pipeline/content-analysis.js +2 -1
  24. package/pipeline/markdown-processor.js +5 -2
  25. package/pipeline/structured-content.js +2 -1
  26. package/services/comment-parts-merge.js +124 -0
  27. package/services/comment-replies.js +1 -1
  28. package/services/document-operation-applier.js +1 -0
  29. package/services/document-operation-contract.js +1 -0
  30. package/services/document-operation-mutations.js +3362 -3346
  31. package/services/inspection-comparison.js +82 -0
  32. package/services/operation-heuristics.js +1 -1
  33. package/services/operation-preflight.js +42 -33
  34. package/services/revision-fidelity-verifier.js +5 -19
  35. package/services/standalone-docx-plumbing.js +38 -22
  36. package/services/table-reconciliation.js +11 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Added
6
+
7
+ - **`mergeCommentParts(parts, output)`:** merges a batch result's comment output (`commentsXml` / `commentsXmlMode`,
8
+ `commentsExtendedXml` / `commentsExtendedXmlMode`) into comment parts held as XML strings, for hosts that keep package
9
+ parts outside a ZIP (a Flat OPC package, a browser parts map). Appends or replaces comments.xml, brings existing
10
+ commentsIds.xml / commentsExtensible.xml in line, and replaces commentsExtended.xml. Returns
11
+ `{ status, hasChanges, parts, changedParts, addedParts }`. It never throws for document content: `PARSE_ERROR`,
12
+ `INVALID_COMMENT_PARTS` (a non-string input or a part with the wrong root element), `DUPLICATE_COMMENT_ID` (an id
13
+ already used, or repeated within the incoming part, in every mode) and `UNSUPPORTED_EXISTING_COMMENT_THREAD_METADATA` (a `'merge'` thread-metadata delta for
14
+ parts that already have it) come back as `error`, with `error.part` naming the part, and the input parts unchanged.
15
+ Existing commentsIds/commentsExtensible parts are validated before they are reconciled, and an incoming comments part
16
+ with no comments changes nothing. Relationships and content types stay with the
17
+ host; `addedParts` lists parts new to the input (presence only, so hosts should also repair a missing relationship for
18
+ any part in `changedParts`). Same merge as `openDocx` (shared helper).
19
+
20
+ ### Fixed
21
+
22
+ - **`structuredContent: false` now also covers paragraphs a redline adds.** A plain-text redline whose `modified` adds
23
+ paragraphs before or after the target (`'Intro.
24
+ Use __literal__ text'`) kept the target literal but stripped inline
25
+ Markdown, and parsed `#` headings, in the new paragraphs. They now stay literal too, including list siblings, ranges,
26
+ `replace` and untracked edits. Inserting paragraphs this way still inherits the list of a list-item target.
27
+ - **One-column tables:** a one-column Markdown separator (`| --- |`) is now recognized. Before, a multi-line redline of a
28
+ cell in a one-column table (`'Cell text.
29
+ Second line'`) inserted a stray `---` row (`structuredContent: false`) or was
30
+ refused `TABLE_SEPARATOR_REQUIRED` (`true`), and a caller-written one-column table was refused the same way. A bare
31
+ `---` line is still not a table separator.
32
+ - **`structuredContent: false` on the table route:** text added to or rewritten in table cells by a plain-text redline
33
+ (including the multi-line cell edit that inserts rows) keeps inline Markdown literal.
34
+ - The `applyCommentReplyToParts` declaration now includes `documentXml`.
35
+ - `ensureCommentsArtifactsInZip` (and so `openDocx`), when merging into an existing comments.xml, also refuses an id
36
+ repeated within the incoming part, and leaves comments.xml untouched when the incoming part has no comments.
37
+
38
+ ## 0.10.0
39
+
40
+ ### Added
41
+
42
+ - **`existingRevisions: 'auto'`:** per target, `merge-same-author` when the target has no tracked changes or only the operation
43
+ author's, otherwise `slice-cross-author`. Unattributed changes count as another author's, and a `targetEnd` range resolves
44
+ over every paragraph in it. Accepted per operation, per batch, by `applyRedlineToOxml` and as `--existing-revisions auto`;
45
+ preflight reports the resolved policy. `resolveExistingRevisionsPolicy` is exported and accepts a node or an array of
46
+ paragraphs. CLI capability `existing-revisions-auto-v1`; the contract version stays 9.
47
+ - **`compareDocumentInspections(expected, actual)`** (and `inspectionReviewShape`): compare two inspections by text, table position,
48
+ list label, review segments and comments, ignoring ids, dates and run boundaries. For verifying what a host committed.
49
+
50
+ ### Fixed
51
+
52
+ - **Literal underscore runs are no longer Markdown emphasis:** `__x__` and `_x_` follow CommonMark flanking rules (not intraword,
53
+ not inside a longer underscore run, not around whitespace). Signature lines such as `_________________________` survive a
54
+ table reconciliation and a paragraph rewrite; `snake_case` stays literal.
55
+ - **`structuredContent: false` is plain text:** a caller's plain-text `redline`/`replace` keeps inline Markdown markers literally
56
+ instead of turning them into formatting. Internal plain-structure calls are unchanged.
57
+ - **Comment reply paraIds no longer collide with body paragraphs:** `applyCommentReplyToParts` (and the `comment_reply`
58
+ operation) now collects body `w14:paraId` values before allocating a reply's id. The scan's regex contained a literal
59
+ backspace instead of `\b`, so body ids were never seen.
60
+ - **Internal:** the Reject All verifier's numbering check uses its memoized definition parser (it was never wired in), and the
61
+ reject-roundtrip matrix's revision-identity cross-check and `RECEIPT_RECONCILIATION_FAILED` gate now actually run (the
62
+ same backspace-for-`\b` defect had made them no-ops).
63
+ - **Same-author checks count unattributed tracked changes as another author's:** a target holding the operation author's
64
+ revisions plus a change with no or a blank `w:author` passed as all same-author. `merge-same-author` then refused with
65
+ `UNSAFE_REVISION_NESTING` (`manual_resolution`) instead of `EXISTING_REVISIONS`, and `slice-cross-author` refused instead of
66
+ slicing; no revision was lost. Merge now refuses with `EXISTING_REVISIONS` (`revisionAuthors` lists named authors only; the
67
+ message adds `unattributed`), and slicing applies while Reject All still restores the unattributed change.
68
+
3
69
  ## 0.9.0
4
70
 
5
71
  ### Changed
@@ -247,7 +313,7 @@
247
313
  ### Known limitations
248
314
 
249
315
  - Plain paragraph to list conversion and list format changes are not yet supported canonically; see
250
- `docs/plans/2026-09-30-canonical-list-operations.md`.
316
+ `docs/plans/completed/2026-09-30-canonical-list-operations.md`.
251
317
  - `1. Header` to `1. Header` on a bare `document.xml` (not a packaged `.docx`) can fail with `RECEIPT_RECONCILIATION_FAILED`.
252
318
 
253
319
  ## 0.8.2
package/README.md CHANGED
@@ -21,7 +21,20 @@ Converts AI-generated or programmatic text/markdown edits into valid Office Open
21
21
  - Package plumbing helpers for numbering.xml, comments.xml, content types, and relationships
22
22
  - Zero host dependencies: works in Node.js, browsers, Deno, and similar JS runtimes with DOM parsing support
23
23
 
24
- ## What's New in 0.9.0 and 0.8.x
24
+ ## What's New in 0.11.0, 0.10.0, 0.9.0 and 0.8.x
25
+
26
+ 0.11.0 adds `mergeCommentParts(parts, output)`, which merges a batch result's comment output into comment parts held as
27
+ XML strings, for hosts that have no ZIP. It also fixes `structuredContent: false` so inline Markdown stays literal in
28
+ paragraphs a redline adds and in table cells, recognizes one-column Markdown table separators (`| --- |`), and makes
29
+ `ensureCommentsArtifactsInZip` refuse an incoming part that repeats a comment id while merging into an existing
30
+ `comments.xml`. See the
31
+ [0.11.0 release notes](./docs/releases/0.11.0.md).
32
+
33
+ 0.10.0 adds `existingRevisions: 'auto'` (capability `existing-revisions-auto-v1`, contract version stays 9), which
34
+ merges a target's own-author history and slices around another reviewer's, per target. It also adds
35
+ `compareDocumentInspections` for checking a committed document against a prepared one, and fixes underscore runs such as
36
+ signature lines (`_____`) collapsing to one `_`. A caller's `structuredContent: false` now keeps inline Markdown markers
37
+ literal. See the [0.10.0 release notes](./docs/releases/0.10.0.md).
25
38
 
26
39
  0.9.0 moves the CLI contract to version 9 and adds the `list-format` operation (`convert-plain`, `change-format`, `join`),
27
40
  `paragraph-format` numbering binding, list-conversion diagnostics, a single numbering allocator and merge, and a Reject All
@@ -292,7 +305,7 @@ docx-redline preflight contract.docx --operations operations.json --author "Edit
292
305
  All commands emit JSON on stdout. `apply` defaults:
293
306
  - **Author**: Defaults to `'AI Redliner'` (or `DOCX_REDLINE_AUTHOR` environment variable).
294
307
  - **Output overwrite**: Destination files provided via `--output` overwrite by default. Pass `--no-overwrite` or `--no-clobber` to safeguard existing destination files. The source input is never overwritten unless `--in-place` is specified.
295
- - **Existing revisions**: Defaults to `'merge-same-author'`. Pass `--existing-revisions slice-cross-author` to edit inside another reviewer's pending insertions with native carrier slicing.
308
+ - **Existing revisions**: Defaults to `'merge-same-author'`. Pass `--existing-revisions slice-cross-author` to edit inside another reviewer's pending insertions with native carrier slicing, or `--existing-revisions auto` to choose per target (slicing only where another reviewer's changes are present).
296
309
  - **Transactionality**: Defaults to `atomic: false` (applies valid operations and reports any failures). Pass `--atomic` for all-or-nothing rollback on any operation error.
297
310
  - **Complete-success exit**: Pass `--require-complete` when a progressive `partial` result must exit with code `3`; errors exit with code `2`. Without the flag, partial results retain the legacy zero exit code, so always inspect `completion`.
298
311
  - **Agent profile**: `--profile agent` enables complete-success exit behavior while retaining the ordinary progressive transaction and existing-revision defaults. Compose it with `--atomic` or an explicit `--existing-revisions` policy when intended, and verify the resolved `effectiveOptions`.
@@ -329,7 +342,8 @@ capabilities `revision-fidelity-v1`, `list-conversion-diagnostics-v1`, `paragrap
329
342
  `part` targeting, the `comment_resolve` operation and `delete-comments --comment-id` are advertised as
330
343
  `header-footer-parts-v1`, `comment-resolve-operation-v1` and `delete-comments-by-id-v1`. The remaining advertised
331
344
  capabilities are `atomic-batch-results-on-package-failure`, `baseline-aware-validation`, `compact-mutation-results`,
332
- `cross-author-revision-slicing`, `document-scoped-list-revision-ids` and `format-text-occurrence-v1`.
345
+ `cross-author-revision-slicing`, `document-scoped-list-revision-ids` and `format-text-occurrence-v1`. 0.10.0 adds
346
+ `existing-revisions-auto-v1` (`existingRevisions: 'auto'`) without a contract bump.
333
347
  Wrappers should negotiate only the
334
348
  capabilities they use. Run `docx-redline <command> --help` for that command's
335
349
  machine-readable options, behavior, exit codes, canonical GitHub documentation links, and compact examples.
@@ -375,10 +389,10 @@ See the [compact agent fast start](./docs/AGENT_FAST_START.md), the
375
389
  | `generateRedlines` | `boolean` | `true` | When `true`, emit Word-native tracked changes (`w:ins`/`w:del`). When `false`, apply clean direct edits without revision markup. **Note: Redlines are not always the preferred method** — pass `generateRedlines: false` (or `--no-redlines` via CLI) when producing clean execution drafts, restructuring documents, or when revision clutter is unwanted. |
376
390
  | `author` | `string` | `'AI Redliner'` | Reviewer/author name stamped on generated tracked changes and comments. Overridable via `DOCX_REDLINE_AUTHOR` env variable. |
377
391
  | `atomic` | `boolean` | `false` | Batch transaction mode. By default (`false`), valid edits are applied and failing operations report errors. When `true`, any operation failure rolls back the entire batch to the original document state (`rolledBack: true`, `hasChanges: false`). Feature prominently in high-assurance workflows. |
378
- | `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text. |
392
+ | `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text: inline Markdown markers (`**`, `__`, `_`, `~~`, `++`) are kept literally, including in paragraphs a redline adds and in table cells. With `true`, underscores follow CommonMark flanking rules, so signature lines (`_____`) and `snake_case` stay literal. |
379
393
  | `pairReplacements` | `boolean` | `true` | Links adjacent `<w:del>` and `<w:ins>` revisions with matching timestamps so Word groups them as a single replacement in the Reviewing Pane. |
380
394
  | `strictTargets` | `boolean` | `true` (CLI/facade) | Requires exact target descriptors (`exactText`, `paragraphId`, `index`, `occurrence`, `fingerprint`) and forbids ambiguous matching. Defaults to `false` in low-level runner for backwards compatibility. |
381
- | `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges revisions from the same author and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` keeps that same-author merge behavior while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. |
395
+ | `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges revisions from the same author and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` keeps that same-author merge behavior while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. `'auto'` chooses per target: `'merge-same-author'` when the target has no tracked changes or only the operation author's, otherwise `'slice-cross-author'` (preflight reports the resolved policy). |
382
396
  | `removeFormatting` | `boolean` | `false` | When `true` and the text is unchanged with no Markdown hints, strips existing bold/italic/underline/strikethrough formatting. |
383
397
  | `verifyRevisionFidelity` | `'enforce' \| 'warn' \| 'off'` | `'enforce'` | Post-apply Reject All check described under [Reject All verification](#reject-all-verification). CLI: `--revision-fidelity`; the agent profile rejects `off`. |
384
398
  | `listMarkers` | `'literal'` | unset | Per operation. Accepts list-marker text as literal text and suppresses `LIST_MARKER_NOT_BOUND`, `UNSUPPORTED_LIST_CONVERSION`, `LIST_FORMAT_NOT_APPLIED` and `UNSUPPORTED_LIST_FORMAT_CHANGE` for that operation. |
@@ -422,6 +436,19 @@ The engine applies Microsoft Word Desktop-native tracked change structures:
422
436
  - **Straddle deletions**: Deletions spanning between baseline text and pending insertions cleanly partition across their respective container contexts without invalid coalescing.
423
437
  - **Lifecycle parity**: Accepting or rejecting either reviewer independently produces identical results to Microsoft Word Desktop's native review pane.
424
438
 
439
+ ### Comparing inspections
440
+
441
+ `compareDocumentInspections(expected, actual)` checks that two inspections describe the same reviewable document. It compares paragraph text, table position, list labels, review segments (adjacent segments of one kind and author are merged) and comments, and ignores ids, dates, rsids, paragraph ids and run boundaries. A host that writes a prepared package through another API can inspect what it prepared and what it committed, then compare them:
442
+
443
+ ```js
444
+ import { inspectDocumentParts, compareDocumentInspections } from '@ansonlai/docx-redline-js';
445
+
446
+ const check = compareDocumentInspections(inspectDocumentParts(preparedParts), inspectDocumentParts(committedParts));
447
+ if (!check.matches) console.warn(check.differences); // at most the first 10 differences
448
+ ```
449
+
450
+ `inspectionReviewShape(inspection)` returns the normalized shape the comparison uses.
451
+
425
452
  ### Replacing a heading with a tracked list
426
453
 
427
454
  The list route treats a one-paragraph heading expanded into multiple markdown
@@ -538,7 +565,7 @@ const result = await document.applyOperations([plan.operation], {
538
565
  });
539
566
  ```
540
567
 
541
- Every Markdown table must include a separator row immediately below its header.
568
+ Every Markdown table must include a separator row immediately below its header (a one-column table uses `| --- |`).
542
569
  Missing separators, missing data rows, and inconsistent column counts are
543
570
  reported as structured errors instead of being inserted as visible pipe text.
544
571
  Use `#` through `#########` to request Word heading paragraphs; blank lines
@@ -560,6 +587,7 @@ an earlier block has already replaced.
560
587
  | `analyzeStructuredContent(markdown)` | Decompose mixed Markdown into typed blocks and report malformed table syntax without creating an operation. |
561
588
  | `planStructuredReplacement(target, markdown, options)` | Validate mixed content and return one atomic `structuredContent` replacement operation, or `operation: null` with issues. |
562
589
  | `containsTrackedChanges(xmlDoc)` | Detect `w:ins`, `w:del`, move revisions, property changes, and paragraph-mark revision markup in a parsed OOXML document/fragment. |
590
+ | `resolveExistingRevisionsPolicy(policy, node, author)` | Resolve `existingRevisions: 'auto'` for a parsed paragraph, scope element, or an array of range paragraphs: `'slice-cross-author'` when any tracked change is by another or an unattributed author, otherwise `'merge-same-author'`. Other policies are returned unchanged. |
563
591
  | `validateRedlineOoxml(oxml)` | Validate generated redline OOXML against the package's structural invariants (no nested revisions, `w:delText` inside `w:del`, complete metadata, unique revision ids, preserved boundary whitespace). Returns `{ valid, issues }`; run it before writing output into a package. |
564
592
 
565
593
  ### Services
@@ -567,14 +595,17 @@ an earlier block has already replaced.
567
595
  | Function | Purpose |
568
596
  |----------|---------|
569
597
  | `injectCommentsIntoOoxml(oxml, comments, options)` | Add comments anchored to text ranges. |
570
- | `applyCommentReplyToParts(options)` | Build a threaded reply in `comments.xml` and `commentsExtended.xml` without adding a document-body anchor. |
598
+ | `applyCommentReplyToParts(options)` | Build a threaded reply in `comments.xml` and `commentsExtended.xml` without adding a document-body anchor. Pass `documentXml` so the reply's `w14:paraId` also avoids body paragraph ids. |
599
+ | `mergeCommentParts(parts, output)` | Merge a batch result's comment output into comment parts held as XML strings (no ZIP): appends or replaces `comments.xml`, reconciles existing `commentsIds.xml` / `commentsExtensible.xml`, replaces `commentsExtended.xml`. Returns `{ status, hasChanges, parts, changedParts, addedParts, error? }`; never throws for document content. Error codes: `PARSE_ERROR`, `INVALID_COMMENT_PARTS`, `DUPLICATE_COMMENT_ID`, `UNSUPPORTED_EXISTING_COMMENT_THREAD_METADATA`, each with `error.part`. The host adds relationships and content types for `addedParts`; that list reflects part presence only, so also repair a missing relationship for any part in `changedParts`. |
600
+ | `compareDocumentInspections(expected, actual)` | Compare two `inspectDocumentParts` / `inspect()` results by text, table position, list label, review segments and comments, ignoring ids, dates and run boundaries. Returns `{ matches, differences }` (at most 10). See [Comparing inspections](#comparing-inspections). |
601
+ | `inspectionReviewShape(inspection)` | The normalized, host-independent shape `compareDocumentInspections` compares. |
571
602
  | `acceptTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Accept `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
572
603
  | `rejectTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Reject `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
573
604
  | `deleteCommentsByAuthorInOoxml(oxml, { author?, allAuthors? })` | Delete matching comment definitions and anchors present in the supplied OOXML payload. Real `.docx` packages require updating both `word/comments.xml` and `word/document.xml`. |
574
605
  | `generateTableOoxml(headers, rows, options)` | Generate a `w:tbl` from tabular data. |
575
606
  | `createDynamicNumberingIdState(numberingXml)` | Allocate numbering IDs without collisions. |
576
607
  | `ensureNumberingArtifactsInZip(zip, numberingXml, options)` | Add numbering artifacts to a `.docx` package. Always merges into an existing `word/numbering.xml` through the package's single numbering merge (`mergeNumberingParts`); an incoming id that already exists with different content fails with `NUMBERING_ID_CONFLICT`. The former `mergeNumberingXml` option is ignored. |
577
- | `ensureCommentsArtifactsInZip(zip, commentsXml)` | Merge comments artifacts into a `.docx` package. |
608
+ | `ensureCommentsArtifactsInZip(zip, commentsXml)` | Merge comments artifacts into a `.docx` package. When merging into an existing `comments.xml`, refuses an id already in use, including one repeated within the incoming part (`DUPLICATE_COMMENT_ID`), and leaves `comments.xml` untouched when the incoming part has no comments. `replaceExisting` (or no existing part) writes the incoming part as is. |
578
609
  | `ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml)` | Add or replace modern Word comment-thread metadata in a `.docx` package. |
579
610
  | `validateDocxPackage(zip)` | Validate `.docx` structural consistency. |
580
611
 
package/core/word-xml.js CHANGED
@@ -1,118 +1,160 @@
1
- import { NS_W } from './types.js';
2
-
3
- /**
4
- * Returns true when a node is a WordprocessingML element with the given local name.
5
- *
6
- * @param {Node|null|undefined} node - Candidate node
7
- * @param {string} localName - Word local name, for example `r` or `tbl`
8
- * @returns {boolean}
9
- */
10
- export function isWordElement(node, localName) {
11
- if (!node || node.nodeType !== 1) return false;
12
- if (node.namespaceURI === NS_W && node.localName === localName) return true;
13
- const nodeName = String(node.nodeName || '');
14
- return nodeName === `w:${localName}` || nodeName === localName;
15
- }
16
-
17
- /**
18
- * Creates a WordprocessingML element using namespace-aware DOM APIs when available.
19
- *
20
- * @param {Document} xmlDoc - Target document
21
- * @param {string} qualifiedName - Qualified name, for example `w:r`
22
- * @returns {Element}
23
- */
24
- export function createWordElement(xmlDoc, qualifiedName) {
25
- return typeof xmlDoc.createElementNS === 'function'
26
- ? xmlDoc.createElementNS(NS_W, qualifiedName)
27
- : xmlDoc.createElement(qualifiedName);
28
- }
29
-
30
- function wordElementsByLocalName(xmlDoc, localName) {
31
- const namespaced = Array.from(xmlDoc?.getElementsByTagNameNS?.(NS_W, localName) || []);
32
- if (namespaced.length > 0) return namespaced;
33
- return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
34
- }
35
-
36
- // Keep revision discovery and author discovery on the same taxonomy. Cell
37
- // markers are included for fail-closed detection even though selective
38
- // acceptance/rejection of those structural revisions is not yet supported.
39
- const TRACKED_CHANGE_NAMES = [
40
- 'ins',
41
- 'del',
42
- 'moveFrom',
43
- 'moveTo',
44
- 'moveFromRangeStart',
45
- 'moveFromRangeEnd',
46
- 'moveToRangeStart',
47
- 'moveToRangeEnd',
48
- 'rPrChange',
49
- 'pPrChange',
50
- 'tblPrChange',
51
- 'trPrChange',
52
- 'tcPrChange',
53
- 'cellIns',
54
- 'cellDel'
55
- ];
56
-
57
- /**
58
- * Returns true if a document or fragment contains Word tracked-change markup.
59
- *
60
- * @param {Document|Element} xmlDoc - Parsed OOXML document or element
61
- * @returns {boolean}
62
- */
63
- export function containsTrackedChanges(xmlDoc) {
64
- return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
65
- }
66
-
67
- /**
68
- * Collects all distinct author names from tracked changes in the given XML document or element.
69
- *
70
- * @param {Document|Element|null|undefined} xmlDocOrElement
71
- * @returns {string[]} Sorted unique list of author names.
72
- */
73
- export function getTrackedChangeAuthors(xmlDocOrElement) {
74
- if (!xmlDocOrElement) return [];
75
- const authors = new Set();
76
- for (const localName of TRACKED_CHANGE_NAMES) {
77
- for (const node of wordElementsByLocalName(xmlDocOrElement, localName)) {
78
- const author = node.getAttribute?.('w:author')
79
- || node.getAttribute?.('author')
80
- || (typeof node.getAttributeNS === 'function' ? node.getAttributeNS(NS_W, 'author') : null);
81
- if (author && typeof author === 'string' && author.trim()) {
82
- authors.add(author.trim());
83
- }
84
- }
85
- }
86
- return [...authors].sort();
87
- }
88
-
89
- /**
90
- * Classifies the shape of an OOXML payload.
91
- *
92
- * @param {string} oxml - OOXML payload
93
- * @returns {'package'|'document'|'fragment'}
94
- */
95
- export function classifyOoxmlSourceType(oxml) {
96
- const trimmed = String(oxml || '').trim();
97
- if (/^<\?xml\b[^>]*>\s*<pkg:package\b/i.test(trimmed) || /^<pkg:package\b/i.test(trimmed)) {
98
- return 'package';
99
- }
100
- if (/^<\?xml\b[^>]*>\s*<(?:w:)?document\b/i.test(trimmed) || /^<(?:w:)?document\b/i.test(trimmed)) {
101
- return 'document';
102
- }
103
- return 'fragment';
104
- }
105
-
106
- /**
107
- * Adds `sourceType` metadata to OOXML result objects without changing payloads.
108
- *
109
- * @template T
110
- * @param {T & { oxml?: string, sourceType?: 'package'|'document'|'fragment' }} result - Result object
111
- * @returns {T & { sourceType?: 'package'|'document'|'fragment' }}
112
- */
113
- export function withOoxmlSourceType(result) {
114
- if (!result || typeof result !== 'object' || result.sourceType || typeof result.oxml !== 'string') {
115
- return result;
116
- }
117
- return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
118
- }
1
+ import { NS_W } from './types.js';
2
+
3
+ /**
4
+ * Returns true when a node is a WordprocessingML element with the given local name.
5
+ *
6
+ * @param {Node|null|undefined} node - Candidate node
7
+ * @param {string} localName - Word local name, for example `r` or `tbl`
8
+ * @returns {boolean}
9
+ */
10
+ export function isWordElement(node, localName) {
11
+ if (!node || node.nodeType !== 1) return false;
12
+ if (node.namespaceURI === NS_W && node.localName === localName) return true;
13
+ const nodeName = String(node.nodeName || '');
14
+ return nodeName === `w:${localName}` || nodeName === localName;
15
+ }
16
+
17
+ /**
18
+ * Creates a WordprocessingML element using namespace-aware DOM APIs when available.
19
+ *
20
+ * @param {Document} xmlDoc - Target document
21
+ * @param {string} qualifiedName - Qualified name, for example `w:r`
22
+ * @returns {Element}
23
+ */
24
+ export function createWordElement(xmlDoc, qualifiedName) {
25
+ return typeof xmlDoc.createElementNS === 'function'
26
+ ? xmlDoc.createElementNS(NS_W, qualifiedName)
27
+ : xmlDoc.createElement(qualifiedName);
28
+ }
29
+
30
+ function wordElementsByLocalName(xmlDoc, localName) {
31
+ const namespaced = Array.from(xmlDoc?.getElementsByTagNameNS?.(NS_W, localName) || []);
32
+ if (namespaced.length > 0) return namespaced;
33
+ return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
34
+ }
35
+
36
+ // Keep revision discovery and author discovery on the same taxonomy. Cell
37
+ // markers are included for fail-closed detection even though selective
38
+ // acceptance/rejection of those structural revisions is not yet supported.
39
+ const TRACKED_CHANGE_NAMES = [
40
+ 'ins',
41
+ 'del',
42
+ 'moveFrom',
43
+ 'moveTo',
44
+ 'moveFromRangeStart',
45
+ 'moveFromRangeEnd',
46
+ 'moveToRangeStart',
47
+ 'moveToRangeEnd',
48
+ 'rPrChange',
49
+ 'pPrChange',
50
+ 'tblPrChange',
51
+ 'trPrChange',
52
+ 'tcPrChange',
53
+ 'cellIns',
54
+ 'cellDel'
55
+ ];
56
+
57
+ /**
58
+ * Returns true if a document or fragment contains Word tracked-change markup.
59
+ *
60
+ * @param {Document|Element} xmlDoc - Parsed OOXML document or element
61
+ * @returns {boolean}
62
+ */
63
+ export function containsTrackedChanges(xmlDoc) {
64
+ return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
65
+ }
66
+
67
+ /**
68
+ * Collects all distinct author names from tracked changes in the given XML document or element.
69
+ *
70
+ * @param {Document|Element|null|undefined} xmlDocOrElement
71
+ * @returns {string[]} Sorted unique list of author names.
72
+ */
73
+ export function getTrackedChangeAuthors(xmlDocOrElement) {
74
+ if (!xmlDocOrElement) return [];
75
+ const authors = new Set();
76
+ for (const localName of TRACKED_CHANGE_NAMES) {
77
+ for (const node of wordElementsByLocalName(xmlDocOrElement, localName)) {
78
+ const author = node.getAttribute?.('w:author')
79
+ || node.getAttribute?.('author')
80
+ || (typeof node.getAttributeNS === 'function' ? node.getAttributeNS(NS_W, 'author') : null);
81
+ if (author && typeof author === 'string' && author.trim()) {
82
+ authors.add(author.trim());
83
+ }
84
+ }
85
+ }
86
+ return [...authors].sort();
87
+ }
88
+
89
+ /**
90
+ * Classifies the shape of an OOXML payload.
91
+ *
92
+ * @param {string} oxml - OOXML payload
93
+ * @returns {'package'|'document'|'fragment'}
94
+ */
95
+ export function classifyOoxmlSourceType(oxml) {
96
+ const trimmed = String(oxml || '').trim();
97
+ if (/^<\?xml\b[^>]*>\s*<pkg:package\b/i.test(trimmed) || /^<pkg:package\b/i.test(trimmed)) {
98
+ return 'package';
99
+ }
100
+ if (/^<\?xml\b[^>]*>\s*<(?:w:)?document\b/i.test(trimmed) || /^<(?:w:)?document\b/i.test(trimmed)) {
101
+ return 'document';
102
+ }
103
+ return 'fragment';
104
+ }
105
+
106
+ /**
107
+ * Adds `sourceType` metadata to OOXML result objects without changing payloads.
108
+ *
109
+ * @template T
110
+ * @param {T & { oxml?: string, sourceType?: 'package'|'document'|'fragment' }} result - Result object
111
+ * @returns {T & { sourceType?: 'package'|'document'|'fragment' }}
112
+ */
113
+ export function withOoxmlSourceType(result) {
114
+ if (!result || typeof result !== 'object' || result.sourceType || typeof result.oxml !== 'string') {
115
+ return result;
116
+ }
117
+ return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
118
+ }
119
+
120
+ /**
121
+ * Returns true if any tracked change in the node lacks a non-blank author.
122
+ * Same-author checks treat such a revision as another reviewer's, because
123
+ * `getTrackedChangeAuthors` omits it.
124
+ *
125
+ * @param {Document|Element|null|undefined} node
126
+ * @returns {boolean}
127
+ */
128
+ export function hasUnattributedTrackedChange(node) {
129
+ if (!node) return false;
130
+ for (const localName of TRACKED_CHANGE_NAMES) {
131
+ for (const change of wordElementsByLocalName(node, localName)) {
132
+ const author = change.getAttribute?.('w:author')
133
+ || change.getAttribute?.('author')
134
+ || (typeof change.getAttributeNS === 'function' ? change.getAttributeNS(NS_W, 'author') : null);
135
+ if (!author || !String(author).trim()) return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Resolves `existingRevisions: 'auto'` for one target. A target without tracked
143
+ * changes, or whose changes are all by `author`, merges same-author history;
144
+ * any other or unattributed reviewer's change selects 'slice-cross-author',
145
+ * which preserves that history. Other policies are returned unchanged.
146
+ * @param {string|null|undefined} policy
147
+ * @param {Node|Array<Node>} node - target paragraph, scope element or document, or every paragraph of a range
148
+ * @param {string} author
149
+ * @returns {string|null|undefined}
150
+ */
151
+ export function resolveExistingRevisionsPolicy(policy, node, author) {
152
+ if (policy !== 'auto') return policy;
153
+ const nodes = (Array.isArray(node) ? node : [node]).filter(Boolean);
154
+ const own = String(author || '').trim().toLowerCase();
155
+ const foreign = nodes.some(item => containsTrackedChanges(item) && (
156
+ hasUnattributedTrackedChange(item)
157
+ || getTrackedChangeAuthors(item).some(name => name.trim().toLowerCase() !== own)
158
+ ));
159
+ return foreign ? 'slice-cross-author' : 'merge-same-author';
160
+ }