@ansonlai/docx-redline-js 0.2.0 → 0.4.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 (87) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1148 -572
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +95 -104
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +17 -13
  36. package/engine/surgical-diff-application.js +7 -21
  37. package/engine/surgical-mode.js +3 -2
  38. package/engine/table-mode.js +27 -16
  39. package/index.d.ts +95 -3
  40. package/index.js +14 -13
  41. package/orchestration/list-structural-fallback.js +16 -39
  42. package/package.json +23 -5
  43. package/pipeline/diff-engine.js +174 -55
  44. package/pipeline/ingestion-export.js +39 -24
  45. package/pipeline/ingestion-paragraph.js +7 -5
  46. package/pipeline/list-generation.js +27 -18
  47. package/pipeline/patching.js +2 -3
  48. package/pipeline/pipeline.js +65 -36
  49. package/pipeline/serialization.js +13 -5
  50. package/scripts/build-test-dashboard.mjs +43 -0
  51. package/scripts/check-types.mjs +16 -24
  52. package/scripts/export-validation-fixtures.mjs +191 -45
  53. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  54. package/scripts/generate-test-dashboard.mjs +199 -0
  55. package/scripts/inspect-visual-evidence.mjs +271 -0
  56. package/scripts/lib/minimal-zip.mjs +199 -18
  57. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  58. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  59. package/scripts/lib/zip-reader.mjs +64 -0
  60. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  61. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  62. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  63. package/scripts/prepare-word-review.mjs +77 -0
  64. package/scripts/prepare-word-visual-review.mjs +90 -0
  65. package/scripts/render-agenda-multilevel.mjs +70 -0
  66. package/scripts/render-case22.mjs +73 -0
  67. package/scripts/render-case40.ps1 +35 -0
  68. package/scripts/render-multilevel-bullet-images.py +58 -0
  69. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  70. package/scripts/render-multilevel-cases.mjs +80 -0
  71. package/scripts/report-coverage-gaps.mjs +103 -0
  72. package/scripts/report-word-coverage.mjs +71 -0
  73. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  74. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  75. package/scripts/word-com-corpus-suite.ps1 +43 -0
  76. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  77. package/scripts/word-com-differential.ps1 +158 -16
  78. package/scripts/word-com-suite.ps1 +19 -0
  79. package/scripts/word-com-visual-suite.ps1 +132 -0
  80. package/services/comment-engine.js +51 -46
  81. package/services/comment-locator.js +0 -1
  82. package/services/comment-package.js +11 -10
  83. package/services/numbering-service.js +1 -1
  84. package/services/revision-comment-management.js +31 -10
  85. package/services/standalone-docx-plumbing.js +45 -34
  86. package/services/standalone-operation-runner.js +315 -75
  87. package/services/table-reconciliation.js +23 -11
package/AGENTS.md CHANGED
@@ -54,7 +54,9 @@ const result = await applyRedlineToOxml(oxml, originalText, modifiedText, {
54
54
 
55
55
  `existingRevisions` defaults to `'reject-input'`. Use `'accept-all-first'` only
56
56
  when the caller intentionally wants to accept prior tracked changes before
57
- applying a new edit.
57
+ applying a new edit. A no-op still returns the untouched input; use
58
+ `'accept-all-first-keep-normalized'` only when accepted revisions should be
59
+ returned as a real change even without a new redline.
58
60
 
59
61
  ### Apply a text edit without tracked changes
60
62
 
@@ -111,11 +113,24 @@ const removedAll = deleteCommentsByAuthorInOoxml(packageOrDocumentOoxml, { allAu
111
113
  ### Apply multiple operations to full document XML
112
114
 
113
115
  ```js
114
- import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/services/standalone-operation-runner.js';
115
- const result = await applyOperationToDocumentXml(documentXml, operation, options);
116
+ import {
117
+ applyOperationToDocumentXml,
118
+ applyOperationsToDocumentXml
119
+ } from '@ansonlai/docx-redline-js/services/standalone-operation-runner.js';
120
+
121
+ const result = await applyOperationsToDocumentXml(documentXml, operations, 'Agent', runtimeContext, options);
116
122
  ```
117
123
 
118
- Use `result.documentXml` from this API when replacing full `word/document.xml`.
124
+ Use `result.documentXml` from these APIs when replacing full `word/document.xml`.
125
+ For mixed batches, prefer `applyOperationsToDocumentXml(...)`; it applies comments
126
+ before replacements so earlier edits cannot invalidate their anchors.
127
+
128
+ Batches are atomic by default. If any operation fails, the batch returns the
129
+ original `documentXml`, `hasChanges: false`, no comment/numbering artifacts, and
130
+ `rolledBack: true`; `results` still describes every attempted operation because
131
+ `continueOnError` defaults to `true`. Pass `{ atomic: false }` only when a
132
+ partially applied document is intentional. Pass `{ continueOnError: false }` to
133
+ stop attempting operations after the first error.
119
134
 
120
135
  ### Detect existing tracked changes
121
136
 
@@ -195,7 +210,9 @@ orchestration/
195
210
  {
196
211
  generateRedlines: true,
197
212
  author: 'Name',
198
- existingRevisions: 'reject-input'
213
+ existingRevisions: 'reject-input',
214
+ removeFormatting: false,
215
+ sanitizeInput: false
199
216
  }
200
217
  ```
201
218
 
@@ -213,8 +230,13 @@ orchestration/
213
230
  }
214
231
  ```
215
232
 
216
- Known error codes include `PARSE_ERROR`, `TARGET_NOT_FOUND`, and
217
- `EXISTING_REVISIONS`.
233
+ Known error codes include `PARSE_ERROR`, `TARGET_NOT_FOUND`, `PARTIAL_TARGET`,
234
+ `EXISTING_REVISIONS`, `DIFF_TOKEN_LIMIT`, and `BATCH_OPERATION_FAILED`.
235
+
236
+ For ingestion that must distinguish an empty document from malformed OOXML,
237
+ use `ingestWordOoxmlToPlainTextResult` or
238
+ `ingestWordOoxmlToMarkdownResult`. The legacy ingestion helpers intentionally
239
+ retain their string-only return type and return `''` for parse failures.
218
240
 
219
241
  ### OOXML wrapping for Word insertOoxml scenarios
220
242
 
@@ -242,9 +264,13 @@ directly into `word/document.xml`.
242
264
  5. `useNativeApi: true` means standalone mode cannot fully handle that operation path.
243
265
  6. `deleteCommentsByAuthorInOoxml` removes matching `comments.xml` entries and linked comment anchors/references in the document.
244
266
  7. If output begins with `<pkg:package`, treat it as package-level OOXML and normalize it before writing anything back to `word/document.xml`.
245
- 8. Existing revisions are rejected by default; pass `existingRevisions: 'accept-all-first'` only when that is desired.
246
- 9. Hyperlinks, bookmarks, comment markers, tabs/breaks, and footnote/endnote references are structural OOXML and should survive adjacent redline edits.
247
- 10. Internally, create Word elements through `createWordElement` and tracked-change metadata through `createRevisionMetadata`.
267
+ 8. Existing revisions are rejected by default; `accept-all-first` preserves the original OOXML on no-op, while `accept-all-first-keep-normalized` explicitly returns normalization as a change.
268
+ 9. Caller content is not sanitized by default. Pass `sanitizeInput: true` only for raw assistant output; literal dollar delimiters and `\\n` sequences are never rewritten.
269
+ 10. Hyperlinks, bookmarks, comment markers, tabs/breaks, and footnote/endnote references are structural OOXML and should survive adjacent redline edits.
270
+ 11. Internally, create Word elements through `createWordElement` and tracked-change metadata through `createRevisionMetadata`.
271
+ 12. Revision IDs are document-scoped in public operation paths. Thread the
272
+ internal allocator through new string-serialization paths; generated
273
+ `w:id` values are not stable across documents.
248
274
 
249
275
  ## Validation Commands
250
276
 
package/README.md CHANGED
@@ -120,14 +120,21 @@ Common `applyRedlineToOxml` options:
120
120
  |--------|---------|
121
121
  | `generateRedlines` | When `true`, emit Word-native tracked changes; when `false`, apply clean text changes. |
122
122
  | `author` | Track-change author used for generated revisions. |
123
- | `existingRevisions` | Policy for source OOXML that already contains tracked changes: `'reject-input'` (default) returns `status: 'error'` with code `EXISTING_REVISIONS`; `'accept-all-first'` accepts existing revisions before applying the new edit. |
123
+ | `existingRevisions` | Existing-revision policy. `'reject-input'` is the default. `'accept-all-first'` normalizes before a real edit but returns the untouched input on no-op. `'accept-all-first-keep-normalized'` explicitly returns accepted revisions as a change even on no-op. |
124
+ | `removeFormatting` | When `true` and the text is unchanged with no Markdown hints, explicitly remove existing bold/italic/underline/strikethrough formatting. Defaults to `false`. |
125
+ | `sanitizeInput` | Opt-in removal of a standalone leading assistant-preface line. Defaults to `false`; dollar-delimited text and literal `\\n` sequences are always preserved. |
124
126
 
125
127
  Common result fields:
126
128
 
127
129
  | Field | Purpose |
128
130
  |-------|---------|
129
131
  | `status` | Optional non-breaking status: `'ok'`, `'no-op'`, or `'error'`. |
130
- | `error` | Present when `status === 'error'`; includes a stable `code` such as `PARSE_ERROR`, `TARGET_NOT_FOUND`, or `EXISTING_REVISIONS`. |
132
+ | `error` | Present when `status === 'error'`; includes a stable `code` such as `PARSE_ERROR`, `TARGET_NOT_FOUND`, `PARTIAL_TARGET`, `EXISTING_REVISIONS`, `DIFF_TOKEN_LIMIT`, or `BATCH_OPERATION_FAILED`. |
133
+
134
+ Word diffs are deterministic by default (no wall-clock timeout). Inputs above
135
+ the safe ceiling of 262,144 unique diff tokens return `DIFF_TOKEN_LIMIT` with
136
+ the original OOXML unchanged so callers can split the operation without risking
137
+ silent text loss.
131
138
 
132
139
  ### Pipeline (lower-level access)
133
140
 
@@ -136,6 +143,8 @@ Common result fields:
136
143
  | `ReconciliationPipeline` | Direct pipeline access (ingest, diff, patch, serialize). |
137
144
  | `ingestWordOoxmlToPlainText(oxml)` | Extract plain text from OOXML. |
138
145
  | `ingestWordOoxmlToMarkdown(oxml)` | Convert OOXML to markdown. |
146
+ | `ingestWordOoxmlToPlainTextResult(oxml)` | Extract text as `{ text, status, error?, warnings? }`, distinguishing malformed input from an empty document. |
147
+ | `ingestWordOoxmlToMarkdownResult(oxml)` | Markdown counterpart to the result-returning plain-text helper. |
139
148
  | `ingestOoxml(oxml)` | Flatten OOXML into an internal run model with offsets. |
140
149
  | `preprocessMarkdown(text)` | Normalize markdown and extract format hints. |
141
150
  | `containsTrackedChanges(xmlDoc)` | Detect `w:ins`, `w:del`, move revisions, property changes, and paragraph-mark revision markup in a parsed OOXML document/fragment. |
@@ -155,15 +164,34 @@ Common result fields:
155
164
  | `ensureCommentsArtifactsInZip(zip, commentsXml)` | Merge comments artifacts into a `.docx` package. |
156
165
  | `validateDocxPackage(zip)` | Validate `.docx` structural consistency. |
157
166
 
167
+ Malformed OOXML never escapes these public transform APIs as a raw parser
168
+ exception. Transforms return `status: 'error'` with `error.code === 'PARSE_ERROR'`;
169
+ validators return a `PARSE_ERROR` issue. Recoverable XML parser
170
+ diagnostics are forwarded through the configured logger and included in
171
+ `warnings` where the result shape supports them.
172
+
158
173
  ### Deep Imports
159
174
 
160
175
  For advanced usage, import specific submodules:
161
176
 
162
177
  ```js
163
- import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/services/standalone-operation-runner.js';
178
+ import {
179
+ applyOperationToDocumentXml,
180
+ applyOperationsToDocumentXml,
181
+ orderOperationsForStableTargets
182
+ } from '@ansonlai/docx-redline-js/services/standalone-operation-runner.js';
164
183
  import { getParagraphText } from '@ansonlai/docx-redline-js/core/paragraph-targeting.js';
165
184
  ```
166
185
 
186
+ Use `applyOperationsToDocumentXml(...)` for mixed batches. It stably runs comments before text-changing operations so replacements cannot invalidate their original anchors. Other operation types retain their relative order. Batch results retain each operation's original 1-based index and expose the actual `executionOrder`.
187
+
188
+ Batches are atomic by default: any operation error returns the original
189
+ `documentXml`, `hasChanges: false`, empty package artifacts, and
190
+ `rolledBack: true`. The default `continueOnError: true` still attempts the full
191
+ batch so `results` describes what would have applied. Callers that intentionally
192
+ consume partial results must pass `{ atomic: false }`; use
193
+ `{ continueOnError: false }` to stop after the first error.
194
+
167
195
  ### Output Shape Matrix
168
196
 
169
197
  Different APIs return different OOXML shapes. Use this as a packaging safety check.
@@ -174,12 +202,14 @@ Different APIs return different OOXML shapes. Use this as a packaging safety che
174
202
  | `applyRedlineToOxmlWithListFallback(...)` | Paragraph or range-scope OOXML | `result.oxml` | Fragment, `<w:document>`, or package payload (`<pkg:package>`) | No. Inspect first. |
175
203
  | `reconcileMarkdownTableOoxml(...)` | Table or paragraph-scope OOXML | `result.oxml` | Same shapes as `applyRedlineToOxml(...)` for the supplied scope | No. Inspect first. |
176
204
  | `applyOperationToDocumentXml(...)` | Full `word/document.xml` string | `result.documentXml` | `<w:document>` | Yes. This is the document-safe helper. |
205
+ | `applyOperationsToDocumentXml(...)` | Full `word/document.xml` plus an operation batch | `result.documentXml` | `<w:document>` | Yes. Atomic by default; comments are applied before text-changing operations. |
177
206
  | `extractReplacementNodesFromOoxml(...)` | Any OOXML payload | `{ replacementNodes, numberingXml, sourceType }` | Normalized to `fragment`, `document`, or `package` | Yes. Use this when consuming `result.oxml`. |
178
207
 
179
208
  ### Do / Don't for Packaging
180
209
 
181
210
  - Do use `applyOperationToDocumentXml(...).documentXml` when your intent is to replace `word/document.xml`.
182
- - Redline application now strips non-visible field scaffolding (`w:fldChar`, `w:instrText`) and proofing markers (`w:proofErr`) from the matched target paragraph before diffing, while preserving the visible field result text. This avoids a class of Word-open failures caused by tracked changes spanning hidden field instruction runs.
211
+ - Do use `applyOperationsToDocumentXml(...)` rather than an unsorted loop for batches containing comments and replacements that target the same original paragraph.
212
+ - Redline application strips proofing markers (`w:proofErr`) from the matched target paragraph before diffing, while preserving complex-field scaffolding (`w:fldChar`, `w:instrText`) and its cached visible result as inert structure. Adjacent edits do not revise or move an unchanged field result.
183
213
  - Hyperlinks, bookmarks, comment range markers, tabs/breaks, and footnote/endnote references are treated as structural OOXML that should survive adjacent redline edits instead of being orphaned or wrapped in deletions.
184
214
  - Do use `extractReplacementNodesFromOoxml(...)` when you are consuming `result.oxml` from paragraph/range/table APIs.
185
215
  - Do merge numbering/comments artifacts with `ensureNumberingArtifactsInZip(...)` and `ensureCommentsArtifactsInZip(...)` when those parts are present.
@@ -240,12 +270,17 @@ const output = await zip.generateAsync({ type: 'nodebuffer' });
240
270
 
241
271
  ## Validating Output
242
272
 
273
+ For the test-lane design and instructions for adding regression, synthetic
274
+ Word, and real-corpus cases, see [docs/TESTING.md](./docs/TESTING.md).
275
+
243
276
  Run the automated package checks:
244
277
 
245
278
  ```bash
246
279
  npm test
247
280
  npm run test:isolation
248
281
  npm run check:types
282
+ npm run lint
283
+ npm run test:coverage
249
284
  ```
250
285
 
251
286
  For release-time fixture export:
@@ -265,10 +300,29 @@ rejects the generated revisions and the resulting text is compared to the
265
300
  expected outcomes):
266
301
 
267
302
  ```bash
268
- node scripts/export-validation-fixtures.mjs
269
- npm run smoke:word:diff
303
+ npm run test:word
270
304
  ```
271
305
 
306
+ This Windows-only test command generates an English legal/administrative task
307
+ suite under `tmp/word-validation/` and drives installed desktop Microsoft Word
308
+ through COM. Its 33 cases include targeted reliability checks for literal
309
+ content, multi-paragraph replacement, prior-revision no-op, atomic rollback,
310
+ hostile revision IDs, bookmarks, internal hyperlinks, mixed formatted runs,
311
+ content controls, table cells, structural tabs, locked complex fields,
312
+ comments, footnotes/endnotes, headers/footers, and external hyperlinks.
313
+ Structure-focused cases also assert required
314
+ OOXML elements before Word independently checks Accept All and Reject All. The
315
+ published library remains clean, host-independent JavaScript; Word automation
316
+ exists only in development scripts.
317
+
318
+ Use `npm run report:word:coverage` to print the validated task-by-structure
319
+ matrix across all 33 synthetic and 31 SuperDoc scenarios. Before a release,
320
+ `npm run review:word:prepare -- --cycle=0` creates a pending human-review
321
+ manifest with changed cases, a rotating 20% synthetic sample, and legal plus
322
+ administrative corpus representatives. See [docs/TESTING.md](./docs/TESTING.md)
323
+ and [docs/WORD-MANUAL-REVIEW.md](./docs/WORD-MANUAL-REVIEW.md); preparation and
324
+ AI preflight never count as human sign-off.
325
+
272
326
  A nightly GitHub Actions workflow additionally validates generated fixtures
273
327
  against the ECMA-376 transitional schemas (`xmllint`), opens them with
274
328
  LibreOffice, and runs an extended fuzz sweep of the accept/reject round-trip
@@ -281,3 +335,26 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for module layout, data flow, and contr
281
335
  See [AGENTS.md](./AGENTS.md) for a concise reference for AI coding agents.
282
336
 
283
337
  See [docs/VALIDATION.md](./docs/VALIDATION.md) for release-time validation steps.
338
+
339
+ See [docs/TESTING.md](./docs/TESTING.md) for how the test lanes work and how to
340
+ add new cases.
341
+
342
+ ## Test Corpus Attribution
343
+
344
+ Real-document reliability testing uses selected references from
345
+ [docx-corpus](https://docxcorp.us/), built by
346
+ [SuperDoc](https://superdoc.dev/). The dataset is licensed under the
347
+ [Open Data Commons Attribution License (ODC-By) 1.0](https://opendatacommons.org/licenses/by/1-0/).
348
+
349
+ Only explicitly pinned English legal and administrative documents are eligible
350
+ for the initial corpus lane. References and provenance live in
351
+ `tests/corpus/superdoc-english-legal-administrative.json`; downloaded documents
352
+ are hash-verified and kept in ignored `tmp/` storage rather than committed. On
353
+ Windows with desktop Word installed, run the reviewed 31-scenario/23-document lane with:
354
+
355
+ ```bash
356
+ npm run test:corpus:word
357
+ ```
358
+
359
+ ODC-By applies to the database; individual documents may carry additional
360
+ rights, so each selected document must be reviewed before becoming a test case.
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * Default behavior uses browser-provided DOMParser/XMLSerializer.
5
5
  * Consumers can override these constructors for non-browser runtimes.
6
- */
7
-
8
- let _DOMParser = globalThis.DOMParser;
9
- let _XMLSerializer = globalThis.XMLSerializer;
6
+ */
7
+
8
+ import { warn as logWarning, error as logError } from './logger.js';
9
+
10
+ let _DOMParser = globalThis.DOMParser;
11
+ let _XMLSerializer = globalThis.XMLSerializer;
10
12
 
11
13
  /**
12
14
  * Configures XML provider constructors.
@@ -25,14 +27,14 @@ export function configureXmlProvider(options = {}) {
25
27
  *
26
28
  * @returns {DOMParser}
27
29
  */
28
- export function createParser() {
30
+ export function createParser(options = {}) {
29
31
  if (!_DOMParser && globalThis.DOMParser) {
30
32
  _DOMParser = globalThis.DOMParser;
31
33
  }
32
34
  if (!_DOMParser) {
33
35
  throw new Error('DOMParser is not configured. Call configureXmlProvider({ DOMParser, XMLSerializer }) first.');
34
36
  }
35
- return new _DOMParser();
37
+ return new _DOMParser(options);
36
38
  }
37
39
 
38
40
  /**
@@ -57,10 +59,71 @@ export function createSerializer() {
57
59
  * @param {string} [contentType='text/xml'] - MIME type
58
60
  * @returns {Document}
59
61
  */
60
- export function parseXml(xmlString, contentType = 'text/xml') {
61
- const parser = createParser();
62
- return parser.parseFromString(xmlString, contentType);
63
- }
62
+ export function parseXml(xmlString, contentType = 'text/xml') {
63
+ const result = parseOoxmlSafe(xmlString, contentType);
64
+ if (result.error) {
65
+ const parseError = new Error(result.error.message);
66
+ parseError.code = result.error.code;
67
+ throw parseError;
68
+ }
69
+ return result.doc;
70
+ }
71
+
72
+ function browserParseError(doc) {
73
+ if (!doc?.documentElement) return null;
74
+ if (String(doc.documentElement.localName || doc.documentElement.nodeName).toLowerCase() === 'parsererror') {
75
+ return doc.documentElement;
76
+ }
77
+ return doc.getElementsByTagName?.('parsererror')?.[0] || null;
78
+ }
79
+
80
+ /**
81
+ * Parses OOXML without allowing parser/provider exceptions to escape.
82
+ *
83
+ * `@xmldom/xmldom` reports recoverable diagnostics through `onError` and
84
+ * throws for fatal errors. Browser DOMParser implementations instead return a
85
+ * `<parsererror>` document. This helper normalizes both behaviors.
86
+ *
87
+ * @param {unknown} xmlString
88
+ * @param {string} [contentType='application/xml']
89
+ * @returns {{ doc: Document|null, error: {code:'PARSE_ERROR', message:string}|null, warnings: string[] }}
90
+ */
91
+ export function parseOoxmlSafe(xmlString, contentType = 'application/xml') {
92
+ const warnings = [];
93
+ if (typeof xmlString !== 'string' || xmlString.trim() === '') {
94
+ return {
95
+ doc: null,
96
+ error: { code: 'PARSE_ERROR', message: 'Input is not a non-empty XML string.' },
97
+ warnings
98
+ };
99
+ }
100
+
101
+ const onError = (level, message) => {
102
+ const diagnostic = String(message || 'XML parser diagnostic.');
103
+ if (level === 'fatalError') {
104
+ logError('[XmlAdapter] XML fatal parse error:', diagnostic);
105
+ } else {
106
+ warnings.push(diagnostic);
107
+ logWarning(`[XmlAdapter] XML ${level || 'warning'}:`, diagnostic);
108
+ }
109
+ };
110
+
111
+ try {
112
+ const parser = createParser({ onError });
113
+ const doc = parser.parseFromString(xmlString, contentType);
114
+ const parseError = browserParseError(doc);
115
+ if (!doc?.documentElement || parseError) {
116
+ const message = parseError?.textContent || 'Could not parse XML input.';
117
+ logError('[XmlAdapter] XML parse error:', message);
118
+ return { doc: null, error: { code: 'PARSE_ERROR', message }, warnings };
119
+ }
120
+ return { doc, error: null, warnings };
121
+ } catch (caught) {
122
+ const message = caught?.message || String(caught || 'Could not parse XML input.');
123
+ logError('[XmlAdapter] XML parse error:', message);
124
+ return { doc: null, error: { code: 'PARSE_ERROR', message }, warnings };
125
+ }
126
+ }
64
127
 
65
128
  /**
66
129
  * Serializes a node to XML text.
@@ -121,6 +121,9 @@ function shouldPromoteBulletInsertionsToChildDepth(parsedItems, normalizedTarget
121
121
  }
122
122
 
123
123
  if (firstItem?.kind === 'list' && firstItem.markerType === 'numbered') {
124
+ const firstLevel = firstItem.level || 0;
125
+ const alreadyIndented = trailingListItems.some(item => (item.level || 0) > firstLevel);
126
+ if (alreadyIndented) return false;
124
127
  return isNormalizedTextEqual(firstItem.text, normalizedTargetText);
125
128
  }
126
129
 
@@ -9,6 +9,12 @@ function toArray(nodeList) {
9
9
  return Array.from(nodeList || []);
10
10
  }
11
11
 
12
+ function createTargetNotFoundError(message) {
13
+ const error = new Error(message);
14
+ error.code = 'TARGET_NOT_FOUND';
15
+ return error;
16
+ }
17
+
12
18
  export const WORD_MAIN_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
13
19
 
14
20
  function getElementsByLocalName(node, localName) {
@@ -28,12 +34,29 @@ function getElementsByLocalName(node, localName) {
28
34
  }
29
35
 
30
36
  function toParagraphText(paragraph) {
31
- const textNodes = getElementsByLocalName(paragraph, 't');
32
- return textNodes.map(node => node.textContent || '').join('');
37
+ let text = '';
38
+
39
+ const visit = node => {
40
+ for (const child of toArray(node?.childNodes)) {
41
+ if (child?.nodeType !== 1) continue;
42
+ const localName = String(child.localName || child.nodeName || '').replace(/^.*:/, '');
43
+ if (localName === 't') {
44
+ text += child.textContent || '';
45
+ } else if (localName === 'tab') {
46
+ text += '\t';
47
+ } else {
48
+ visit(child);
49
+ }
50
+ }
51
+ };
52
+
53
+ visit(paragraph);
54
+ return text;
33
55
  }
34
56
 
35
57
  /**
36
- * Reads visible text from a paragraph by concatenating `w:t` nodes.
58
+ * Reads visible text from a paragraph by concatenating `w:t` nodes and mapping
59
+ * structural `w:tab` elements to a literal tab character.
37
60
  *
38
61
  * @param {Element|null|undefined} paragraph - OOXML paragraph node
39
62
  * @returns {string}
@@ -313,9 +336,9 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
313
336
  if (fuzzyMatch) return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text' };
314
337
  }
315
338
 
316
- if (cleanTargetText) throw new Error(`Target paragraph not found: "${cleanTargetText}"`);
317
- if (parsedRef) throw new Error(`Target paragraph reference not found: [P${parsedRef}]`);
318
- throw new Error('Operation target missing: provide "target" text or "targetRef" ([P#]).');
339
+ if (cleanTargetText) throw createTargetNotFoundError(`Target paragraph not found: "${cleanTargetText}"`);
340
+ if (parsedRef) throw createTargetNotFoundError(`Target paragraph reference not found: [P${parsedRef}]`);
341
+ throw createTargetNotFoundError('Operation target missing: provide "target" text or "targetRef" ([P#]).');
319
342
  }
320
343
 
321
344
  function isParagraphInTable(paragraph) {
@@ -444,7 +467,10 @@ export function resolveTargetParagraphWithSnapshot(xmlDoc, options = {}) {
444
467
  return { paragraph: bestCandidate.paragraph, resolvedBy: 'strict_text_after_ref_drift' };
445
468
  }
446
469
 
447
- return resolved;
470
+ throw createTargetNotFoundError(
471
+ `Target paragraph [P${parsedRef}] no longer matches its batch-start anchor.`
472
+ );
473
+
448
474
  }
449
475
 
450
476
  /**
@@ -85,6 +85,28 @@ export function validateRedlineOoxml(oxml) {
85
85
  const delElements = elementsByLocalName(doc, 'del');
86
86
  const revisions = insElements.concat(delElements);
87
87
 
88
+ // Paragraphs cannot contain other paragraphs (CT_P has no w:p child).
89
+ for (const paragraph of elementsByLocalName(doc, 'p')) {
90
+ const nested = Array.from(paragraph.getElementsByTagName('*'))
91
+ .find(el => el !== paragraph && localNameOf(el) === 'p');
92
+ if (nested) {
93
+ addIssue('NESTED_PARAGRAPH', 'error', `<${paragraph.nodeName}> contains nested <${nested.nodeName}>.`);
94
+ }
95
+ }
96
+
97
+ // Body-level section properties must be the final body element.
98
+ for (const body of elementsByLocalName(doc, 'body')) {
99
+ const children = Array.from(body.childNodes || []).filter(child => child.nodeType === 1);
100
+ const sectPrIndexes = children
101
+ .map((child, index) => localNameOf(child) === 'sectPr' ? index : -1)
102
+ .filter(index => index >= 0);
103
+ if (sectPrIndexes.length > 1) {
104
+ addIssue('MULTIPLE_BODY_SECTPR', 'error', '<w:body> contains multiple direct <w:sectPr> elements.');
105
+ } else if (sectPrIndexes.length === 1 && sectPrIndexes[0] !== children.length - 1) {
106
+ addIssue('SECTPR_NOT_LAST', 'error', '<w:sectPr> is not the last element child of <w:body>.');
107
+ }
108
+ }
109
+
88
110
  // No w:ins/w:del nested inside another w:ins/w:del.
89
111
  for (const revision of revisions) {
90
112
  const nested = Array.from(revision.getElementsByTagName('*'))
package/core/types.js CHANGED
@@ -157,15 +157,116 @@ export function escapeXml(str) {
157
157
  .replace(/'/g, '&apos;');
158
158
  }
159
159
 
160
- // Global revision ID counter for track changes
161
- let revisionIdCounter = 1000;
160
+ const DEFAULT_REVISION_ID_START = 1000;
161
+ const MAX_PRACTICAL_REVISION_ID = 2147483647;
162
+ const REVISION_ID_SAFETY_MARGIN = 10000;
163
+ const REVISION_ELEMENT_NAMES = new Set([
164
+ 'ins',
165
+ 'del',
166
+ 'moveFrom',
167
+ 'moveTo',
168
+ 'rPrChange',
169
+ 'pPrChange',
170
+ 'cellIns',
171
+ 'cellDel',
172
+ 'comment'
173
+ ]);
174
+ const revisionAllocatorByDocument = new WeakMap();
175
+
176
+ function isRevisionIdElement(element) {
177
+ if (!element || element.nodeType !== 1) return false;
178
+ const localName = String(element.localName || element.nodeName || '').replace(/^.*:/, '');
179
+ if (!REVISION_ELEMENT_NAMES.has(localName)) return false;
180
+ return !element.namespaceURI || element.namespaceURI === NS_W || String(element.nodeName || '').startsWith('w:');
181
+ }
182
+
183
+ function readWordId(element) {
184
+ const raw = element?.getAttributeNS?.(NS_W, 'id')
185
+ || element?.getAttribute?.('w:id')
186
+ || element?.getAttribute?.('id');
187
+ const parsed = Number.parseInt(String(raw ?? ''), 10);
188
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
189
+ }
190
+
191
+ /**
192
+ * Document-scoped allocator for Word revision IDs.
193
+ */
194
+ export class RevisionIdAllocator {
195
+ constructor(startValue = DEFAULT_REVISION_ID_START) {
196
+ this.startValue = Number.isInteger(startValue) && startValue >= 0
197
+ ? startValue
198
+ : DEFAULT_REVISION_ID_START;
199
+ this.nextId = this.startValue;
200
+ this.occupiedIds = new Set();
201
+ }
202
+
203
+ seed(xmlDoc) {
204
+ let maxFound = -1;
205
+ const elements = Array.from(xmlDoc?.getElementsByTagName?.('*') || []);
206
+ if (xmlDoc?.nodeType === 1) elements.unshift(xmlDoc);
207
+
208
+ for (const element of elements) {
209
+ if (!isRevisionIdElement(element)) continue;
210
+ const id = readWordId(element);
211
+ if (id == null) continue;
212
+ this.occupiedIds.add(id);
213
+ maxFound = Math.max(maxFound, id);
214
+ }
215
+
216
+ const highRiskBoundary = MAX_PRACTICAL_REVISION_ID - REVISION_ID_SAFETY_MARGIN;
217
+ this.nextId = maxFound >= highRiskBoundary
218
+ ? this.startValue
219
+ : Math.max(this.nextId, maxFound + 1);
220
+ this.advanceToAvailableId();
221
+ return this.nextId;
222
+ }
223
+
224
+ advanceToAvailableId() {
225
+ const highRiskBoundary = MAX_PRACTICAL_REVISION_ID - REVISION_ID_SAFETY_MARGIN;
226
+ if (this.nextId >= highRiskBoundary) this.nextId = this.startValue;
227
+ while (this.occupiedIds.has(this.nextId)) {
228
+ this.nextId += 1;
229
+ if (this.nextId >= highRiskBoundary) this.nextId = this.startValue;
230
+ }
231
+ }
232
+
233
+ next() {
234
+ this.advanceToAvailableId();
235
+ const id = this.nextId;
236
+ this.occupiedIds.add(id);
237
+ this.nextId += 1;
238
+ return id;
239
+ }
240
+ }
241
+
242
+ let defaultRevisionIdAllocator = new RevisionIdAllocator();
243
+
244
+ export function setRevisionIdAllocatorForDocument(xmlNode, allocator) {
245
+ const xmlDoc = xmlNode?.nodeType === 9 ? xmlNode : xmlNode?.ownerDocument;
246
+ if (xmlDoc && allocator instanceof RevisionIdAllocator) {
247
+ revisionAllocatorByDocument.set(xmlDoc, allocator);
248
+ }
249
+ return allocator;
250
+ }
251
+
252
+ export function getRevisionIdAllocatorForDocument(xmlNode) {
253
+ const xmlDoc = xmlNode?.nodeType === 9 ? xmlNode : xmlNode?.ownerDocument;
254
+ return xmlDoc ? (revisionAllocatorByDocument.get(xmlDoc) || null) : null;
255
+ }
256
+
257
+ export function createRevisionIdAllocator(xmlDoc, startValue = DEFAULT_REVISION_ID_START) {
258
+ const allocator = new RevisionIdAllocator(startValue);
259
+ allocator.seed(xmlDoc);
260
+ setRevisionIdAllocatorForDocument(xmlDoc, allocator);
261
+ return allocator;
262
+ }
162
263
 
163
264
  /**
164
265
  * Gets the next unique revision ID for track changes
165
266
  * @returns {number} Unique revision ID
166
267
  */
167
- export function getNextRevisionId() {
168
- return revisionIdCounter++;
268
+ export function getNextRevisionId() {
269
+ return defaultRevisionIdAllocator.next();
169
270
  }
170
271
 
171
272
  /**
@@ -182,15 +283,19 @@ export function getRevisionTimestamp(date = new Date()) {
182
283
  * Creates shared revision metadata for OOXML track-change tags.
183
284
  *
184
285
  * @param {string} [author] - Track-change author (defaults to configured default author)
286
+ * @param {RevisionIdAllocator|Document|Element|null} [allocatorOrNode] - Scoped allocator or registered OOXML node
185
287
  * @returns {{ id: number, author: string, date: string }}
186
288
  */
187
- export function createRevisionMetadata(author) {
289
+ export function createRevisionMetadata(author, allocatorOrNode = null) {
188
290
  const resolvedAuthor = typeof author === 'string' && author.trim()
189
291
  ? author.trim()
190
292
  : getDefaultAuthor();
293
+ const allocator = allocatorOrNode instanceof RevisionIdAllocator
294
+ ? allocatorOrNode
295
+ : (getRevisionIdAllocatorForDocument(allocatorOrNode) || defaultRevisionIdAllocator);
191
296
 
192
297
  return {
193
- id: getNextRevisionId(),
298
+ id: allocator.next(),
194
299
  author: resolvedAuthor,
195
300
  date: getRevisionTimestamp()
196
301
  };
@@ -200,32 +305,22 @@ export function createRevisionMetadata(author) {
200
305
  * Seeds the revision ID counter above any existing Word revision/comment id values.
201
306
  *
202
307
  * @param {Document|Element} xmlDoc - Parsed OOXML document or element
308
+ * @param {RevisionIdAllocator} [allocator=defaultRevisionIdAllocator] - Allocator to seed
203
309
  * @returns {number} Next revision id after seeding
204
310
  */
205
- export function seedRevisionIdsFromDocument(xmlDoc) {
206
- let maxFound = -1;
207
- const elements = Array.from(xmlDoc?.getElementsByTagName?.('*') || []);
208
-
209
- for (const element of elements) {
210
- for (const attr of Array.from(element.attributes || [])) {
211
- if ((attr.localName || '').toLowerCase() !== 'id') continue;
212
- const parsed = Number.parseInt(attr.value, 10);
213
- if (Number.isFinite(parsed)) {
214
- maxFound = Math.max(maxFound, parsed);
215
- }
216
- }
217
- }
218
-
219
- if (maxFound >= revisionIdCounter) {
220
- revisionIdCounter = maxFound + 1;
221
- }
222
- return revisionIdCounter;
311
+ export function seedRevisionIdsFromDocument(xmlDoc, allocator = defaultRevisionIdAllocator) {
312
+ const resolvedAllocator = allocator instanceof RevisionIdAllocator
313
+ ? allocator
314
+ : defaultRevisionIdAllocator;
315
+ const nextId = resolvedAllocator.seed(xmlDoc);
316
+ setRevisionIdAllocatorForDocument(xmlDoc, resolvedAllocator);
317
+ return nextId;
223
318
  }
224
319
 
225
320
  /**
226
321
  * Resets the revision ID counter (for testing)
227
322
  * @param {number} [startValue=1000] - Value to reset to
228
323
  */
229
- export function resetRevisionIdCounter(startValue = 1000) {
230
- revisionIdCounter = startValue;
231
- }
324
+ export function resetRevisionIdCounter(startValue = 1000) {
325
+ defaultRevisionIdAllocator = new RevisionIdAllocator(startValue);
326
+ }
package/core/xml-query.js CHANGED
@@ -89,7 +89,9 @@ export function getFirstElementByTagNSOrTag(node, namespaceUri, localName, fallb
89
89
  }
90
90
 
91
91
  /**
92
- * Returns XML parser error element if present.
92
+ * Returns XML parser error element if present. This is a browser DOMParser
93
+ * compatibility probe; xmldom reports diagnostics through `parseOoxmlSafe`
94
+ * and does not normally create `<parsererror>` elements.
93
95
  *
94
96
  * @param {Document|Element|null|undefined} xmlDoc - Parsed XML document
95
97
  * @returns {Element|null}