@ansonlai/docx-redline-js 0.9.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Added
6
+
7
+ - **`existingRevisions: 'auto'`:** per target, `merge-same-author` when the target has no tracked changes or only the operation
8
+ author's, otherwise `slice-cross-author`. Unattributed changes count as another author's, and a `targetEnd` range resolves
9
+ over every paragraph in it. Accepted per operation, per batch, by `applyRedlineToOxml` and as `--existing-revisions auto`;
10
+ preflight reports the resolved policy. `resolveExistingRevisionsPolicy` is exported and accepts a node or an array of
11
+ paragraphs. CLI capability `existing-revisions-auto-v1`; the contract version stays 9.
12
+ - **`compareDocumentInspections(expected, actual)`** (and `inspectionReviewShape`): compare two inspections by text, table position,
13
+ list label, review segments and comments, ignoring ids, dates and run boundaries. For verifying what a host committed.
14
+
15
+ ### Fixed
16
+
17
+ - **Literal underscore runs are no longer Markdown emphasis:** `__x__` and `_x_` follow CommonMark flanking rules (not intraword,
18
+ not inside a longer underscore run, not around whitespace). Signature lines such as `_________________________` survive a
19
+ table reconciliation and a paragraph rewrite; `snake_case` stays literal.
20
+ - **`structuredContent: false` is plain text:** a caller's plain-text `redline`/`replace` keeps inline Markdown markers literally
21
+ instead of turning them into formatting. Internal plain-structure calls are unchanged.
22
+ - **Comment reply paraIds no longer collide with body paragraphs:** `applyCommentReplyToParts` (and the `comment_reply`
23
+ operation) now collects body `w14:paraId` values before allocating a reply's id. The scan's regex contained a literal
24
+ backspace instead of `\b`, so body ids were never seen.
25
+ - **Internal:** the Reject All verifier's numbering check uses its memoized definition parser (it was never wired in), and the
26
+ reject-roundtrip matrix's revision-identity cross-check and `RECEIPT_RECONCILIATION_FAILED` gate now actually run (the
27
+ same backspace-for-`\b` defect had made them no-ops).
28
+ - **Same-author checks count unattributed tracked changes as another author's:** a target holding the operation author's
29
+ revisions plus a change with no or a blank `w:author` passed as all same-author. `merge-same-author` then refused with
30
+ `UNSAFE_REVISION_NESTING` (`manual_resolution`) instead of `EXISTING_REVISIONS`, and `slice-cross-author` refused instead of
31
+ slicing; no revision was lost. Merge now refuses with `EXISTING_REVISIONS` (`revisionAuthors` lists named authors only; the
32
+ message adds `unattributed`), and slicing applies while Reject All still restores the unattributed change.
33
+
3
34
  ## 0.9.0
4
35
 
5
36
  ### Changed
@@ -247,7 +278,7 @@
247
278
  ### Known limitations
248
279
 
249
280
  - 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`.
281
+ `docs/plans/completed/2026-09-30-canonical-list-operations.md`.
251
282
  - `1. Header` to `1. Header` on a bare `document.xml` (not a packaged `.docx`) can fail with `RECEIPT_RECONCILIATION_FAILED`.
252
283
 
253
284
  ## 0.8.2
package/README.md CHANGED
@@ -21,7 +21,13 @@ 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.10.0, 0.9.0 and 0.8.x
25
+
26
+ 0.10.0 adds `existingRevisions: 'auto'` (capability `existing-revisions-auto-v1`, contract version stays 9), which
27
+ merges a target's own-author history and slices around another reviewer's, per target. It also adds
28
+ `compareDocumentInspections` for checking a committed document against a prepared one, and fixes underscore runs such as
29
+ signature lines (`_____`) collapsing to one `_`. A caller's `structuredContent: false` now keeps inline Markdown markers
30
+ literal. See the [0.10.0 release notes](./docs/releases/0.10.0.md).
25
31
 
26
32
  0.9.0 moves the CLI contract to version 9 and adds the `list-format` operation (`convert-plain`, `change-format`, `join`),
27
33
  `paragraph-format` numbering binding, list-conversion diagnostics, a single numbering allocator and merge, and a Reject All
@@ -292,7 +298,7 @@ docx-redline preflight contract.docx --operations operations.json --author "Edit
292
298
  All commands emit JSON on stdout. `apply` defaults:
293
299
  - **Author**: Defaults to `'AI Redliner'` (or `DOCX_REDLINE_AUTHOR` environment variable).
294
300
  - **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.
301
+ - **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
302
  - **Transactionality**: Defaults to `atomic: false` (applies valid operations and reports any failures). Pass `--atomic` for all-or-nothing rollback on any operation error.
297
303
  - **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
304
  - **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 +335,8 @@ capabilities `revision-fidelity-v1`, `list-conversion-diagnostics-v1`, `paragrap
329
335
  `part` targeting, the `comment_resolve` operation and `delete-comments --comment-id` are advertised as
330
336
  `header-footer-parts-v1`, `comment-resolve-operation-v1` and `delete-comments-by-id-v1`. The remaining advertised
331
337
  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`.
338
+ `cross-author-revision-slicing`, `document-scoped-list-revision-ids` and `format-text-occurrence-v1`. 0.10.0 adds
339
+ `existing-revisions-auto-v1` (`existingRevisions: 'auto'`) without a contract bump.
333
340
  Wrappers should negotiate only the
334
341
  capabilities they use. Run `docx-redline <command> --help` for that command's
335
342
  machine-readable options, behavior, exit codes, canonical GitHub documentation links, and compact examples.
@@ -375,10 +382,10 @@ See the [compact agent fast start](./docs/AGENT_FAST_START.md), the
375
382
  | `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
383
  | `author` | `string` | `'AI Redliner'` | Reviewer/author name stamped on generated tracked changes and comments. Overridable via `DOCX_REDLINE_AUTHOR` env variable. |
377
384
  | `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. |
385
+ | `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. With `true`, underscores follow CommonMark flanking rules, so signature lines (`_____`) and `snake_case` stay literal. |
379
386
  | `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
387
  | `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. |
388
+ | `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
389
  | `removeFormatting` | `boolean` | `false` | When `true` and the text is unchanged with no Markdown hints, strips existing bold/italic/underline/strikethrough formatting. |
383
390
  | `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
391
  | `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 +429,19 @@ The engine applies Microsoft Word Desktop-native tracked change structures:
422
429
  - **Straddle deletions**: Deletions spanning between baseline text and pending insertions cleanly partition across their respective container contexts without invalid coalescing.
423
430
  - **Lifecycle parity**: Accepting or rejecting either reviewer independently produces identical results to Microsoft Word Desktop's native review pane.
424
431
 
432
+ ### Comparing inspections
433
+
434
+ `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:
435
+
436
+ ```js
437
+ import { inspectDocumentParts, compareDocumentInspections } from '@ansonlai/docx-redline-js';
438
+
439
+ const check = compareDocumentInspections(inspectDocumentParts(preparedParts), inspectDocumentParts(committedParts));
440
+ if (!check.matches) console.warn(check.differences); // at most the first 10 differences
441
+ ```
442
+
443
+ `inspectionReviewShape(inspection)` returns the normalized shape the comparison uses.
444
+
425
445
  ### Replacing a heading with a tracked list
426
446
 
427
447
  The list route treats a one-paragraph heading expanded into multiple markdown
@@ -560,6 +580,7 @@ an earlier block has already replaced.
560
580
  | `analyzeStructuredContent(markdown)` | Decompose mixed Markdown into typed blocks and report malformed table syntax without creating an operation. |
561
581
  | `planStructuredReplacement(target, markdown, options)` | Validate mixed content and return one atomic `structuredContent` replacement operation, or `operation: null` with issues. |
562
582
  | `containsTrackedChanges(xmlDoc)` | Detect `w:ins`, `w:del`, move revisions, property changes, and paragraph-mark revision markup in a parsed OOXML document/fragment. |
583
+ | `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
584
  | `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
585
 
565
586
  ### Services
@@ -567,7 +588,9 @@ an earlier block has already replaced.
567
588
  | Function | Purpose |
568
589
  |----------|---------|
569
590
  | `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. |
591
+ | `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. |
592
+ | `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). |
593
+ | `inspectionReviewShape(inspection)` | The normalized, host-independent shape `compareDocumentInspections` compares. |
571
594
  | `acceptTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Accept `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
572
595
  | `rejectTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Reject `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
573
596
  | `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`. |
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
+ }