@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.
- package/AGENTS.md +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1148 -572
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +95 -104
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +17 -13
- package/engine/surgical-diff-application.js +7 -21
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +23 -5
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- 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 {
|
|
115
|
-
|
|
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
|
|
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`,
|
|
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;
|
|
246
|
-
9.
|
|
247
|
-
10.
|
|
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` |
|
|
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 `
|
|
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 {
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
package/adapters/xml-adapter.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
|
62
|
-
|
|
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.
|
package/core/list-targeting.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
|
317
|
-
if (parsedRef) throw
|
|
318
|
-
throw
|
|
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
|
-
|
|
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, ''');
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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}
|