@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.
- package/ARCHITECTURE.md +691 -669
- package/CHANGELOG.md +67 -1
- package/README.md +39 -8
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +528 -312
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +94 -94
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +528 -312
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +528 -312
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
- package/docs/SKILL_AUTHORING.md +5 -1
- package/docs/TESTING.md +10 -1
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +748 -736
- package/engine/table-mode.js +7 -4
- package/index.d.ts +29 -2
- package/index.js +3 -0
- package/node/cli.js +993 -992
- package/package.json +1 -1
- package/pipeline/content-analysis.js +2 -1
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/structured-content.js +2 -1
- package/services/comment-parts-merge.js +124 -0
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +1 -0
- package/services/document-operation-contract.js +1 -0
- package/services/document-operation-mutations.js +3362 -3346
- package/services/inspection-comparison.js +82 -0
- package/services/operation-heuristics.js +1 -1
- package/services/operation-preflight.js +42 -33
- package/services/revision-fidelity-verifier.js +5 -19
- package/services/standalone-docx-plumbing.js +38 -22
- 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
|
+
}
|