@ansonlai/docx-redline-js 0.1.4 → 0.1.6
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 +53 -4
- package/ARCHITECTURE.md +57 -9
- package/README.md +47 -3
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +3073 -2594
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +71 -67
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +48 -0
- package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
- package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
- package/engine/format-application.js +13 -14
- package/engine/format-span-application.js +7 -6
- package/engine/formatting-removal.js +15 -12
- package/engine/oxml-engine.js +146 -55
- package/engine/reconstruction-mapper.js +35 -8
- package/engine/reconstruction-mode.js +14 -13
- package/engine/reconstruction-writer.js +97 -78
- package/engine/rpr-helpers.js +34 -32
- package/engine/run-builders.js +150 -39
- package/engine/surgical-diff-application.js +216 -0
- package/engine/surgical-mode.js +84 -519
- package/engine/surgical-run-splitting.js +96 -0
- package/engine/surgical-spans.js +169 -0
- package/engine/table-cell-context.js +15 -13
- package/engine/table-mode.js +39 -35
- package/index.d.ts +148 -0
- package/index.js +15 -13
- package/package.json +8 -1
- package/pipeline/ingestion-export.js +1 -0
- package/pipeline/ingestion-paragraph.js +37 -12
- package/pipeline/ingestion-table.js +11 -8
- package/scripts/build.mjs +35 -0
- package/scripts/check-types.mjs +28 -0
- package/scripts/export-validation-fixtures.mjs +68 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/word-com-smoke.ps1 +48 -0
- package/services/comment-locator.js +10 -9
- package/services/revision-comment-management.js +115 -1
- package/services/standalone-operation-runner.js +119 -69
- package/services/table-reconciliation.js +7 -8
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
This package works on OOXML strings and intentionally leaves `.docx` zip
|
|
4
|
+
packaging to consumers. Release validation should therefore check both XML
|
|
5
|
+
invariants and at least one real OOXML consumer.
|
|
6
|
+
|
|
7
|
+
## Automated Checks
|
|
8
|
+
|
|
9
|
+
Run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm test
|
|
13
|
+
npm run test:isolation
|
|
14
|
+
npm run check:types
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Export Fixture Parts
|
|
18
|
+
|
|
19
|
+
Run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node scripts/export-validation-fixtures.mjs
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The script writes generated `word/document.xml` payloads to
|
|
26
|
+
`tmp/validation-docx/`. If a case emits numbering XML, it writes that alongside
|
|
27
|
+
the document XML. The script does not create full `.docx` files because the
|
|
28
|
+
library intentionally avoids a zip dependency.
|
|
29
|
+
|
|
30
|
+
## Manual Consumer Checks
|
|
31
|
+
|
|
32
|
+
To inspect in Microsoft Word or LibreOffice:
|
|
33
|
+
|
|
34
|
+
1. Start from a minimal valid `.docx` package.
|
|
35
|
+
2. Replace `word/document.xml` with one generated `*.document.xml` fixture.
|
|
36
|
+
3. Add matching `word/numbering.xml` when present.
|
|
37
|
+
4. Open the document and confirm the file opens without repair prompts and the
|
|
38
|
+
expected tracked changes are visible.
|
|
39
|
+
|
|
40
|
+
On Windows with desktop Word installed, the optional COM smoke script described
|
|
41
|
+
in the improvement plan can be used to open a completed `.docx` and count
|
|
42
|
+
revisions.
|
|
43
|
+
|
|
44
|
+
With LibreOffice installed, a quick parser check is:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
soffice --headless --convert-to pdf path/to/fixtures/*.docx
|
|
48
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# 0.1.4 Release Prep Design
|
|
2
|
+
|
|
3
|
+
**Objective:** Prepare the repository for a `0.1.4` patch release without publishing from this session.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
- Bump the package version from `0.1.3` to `0.1.4` in release metadata.
|
|
8
|
+
- Update local release-note examples that are pinned to the prior version.
|
|
9
|
+
- Run release preflight verification locally after the version bump.
|
|
10
|
+
- Hand off the exact npm commands for the manual publish step.
|
|
11
|
+
|
|
12
|
+
## Approach
|
|
13
|
+
|
|
14
|
+
Use minimal, explicit edits so the release prep is easy to review. Keep the release workflow local and manual, consistent with the existing policy in `.anson/release-ci-cd-notes.md`.
|
|
15
|
+
|
|
16
|
+
## Verification
|
|
17
|
+
|
|
18
|
+
Run the same preflight commands documented in the local release notes:
|
|
19
|
+
|
|
20
|
+
- `npm test:isolation`
|
|
21
|
+
- `npm test`
|
|
22
|
+
- `npm run build`
|
|
23
|
+
- `npm pack --dry-run`
|
|
24
|
+
|
|
25
|
+
## Manual Handoff
|
|
26
|
+
|
|
27
|
+
After verification, provide the exact commands for:
|
|
28
|
+
|
|
29
|
+
- `npm whoami`
|
|
30
|
+
- `npm publish --access public`
|
|
31
|
+
- `npm view @ansonlai/docx-redline-js version`
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# 0.1.4 Release Prep Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Prepare the repository for a local/manual `0.1.4` release and verify it is ready to publish.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Keep the release prep narrowly scoped to version metadata, local release instructions, and preflight verification. Do not publish from the implementation step; instead, leave the workspace in a verified, reviewable state and hand off the final npm commands.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js, npm, PowerShell, package metadata files
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Update Release Metadata
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `docs/plans/2026-03-01-release-0.1.4-design.md`
|
|
17
|
+
- Create: `docs/plans/2026-03-01-release-0.1.4.md`
|
|
18
|
+
- Modify: `package.json`
|
|
19
|
+
- Modify: `package-lock.json`
|
|
20
|
+
- Modify: `.anson/release-ci-cd-notes.md`
|
|
21
|
+
|
|
22
|
+
**Step 1: Write the failing test**
|
|
23
|
+
|
|
24
|
+
Inspect the current version references and confirm they still show `0.1.3`.
|
|
25
|
+
|
|
26
|
+
**Step 2: Run test to verify it fails**
|
|
27
|
+
|
|
28
|
+
Run: `rg -uu -n "0\\.1\\.3|0\\.1\\.4" .`
|
|
29
|
+
Expected: package metadata and local release-note examples still point to `0.1.3`.
|
|
30
|
+
|
|
31
|
+
**Step 3: Write minimal implementation**
|
|
32
|
+
|
|
33
|
+
- Change package version fields to `0.1.4`.
|
|
34
|
+
- Update local release-note example commands and CDN URLs to `0.1.4`.
|
|
35
|
+
- Save the release design and implementation plan docs.
|
|
36
|
+
|
|
37
|
+
**Step 4: Run test to verify it passes**
|
|
38
|
+
|
|
39
|
+
Run: `rg -uu -n "0\\.1\\.3|0\\.1\\.4" package.json package-lock.json .anson/release-ci-cd-notes.md`
|
|
40
|
+
Expected: only `0.1.4` appears in those release-facing files.
|
|
41
|
+
|
|
42
|
+
**Step 5: Commit**
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git add docs/plans/2026-03-01-release-0.1.4-design.md docs/plans/2026-03-01-release-0.1.4.md package.json package-lock.json .anson/release-ci-cd-notes.md
|
|
46
|
+
git commit -m "release: prep v0.1.4"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Task 2: Run Release Preflight
|
|
50
|
+
|
|
51
|
+
**Files:**
|
|
52
|
+
- Modify: `dist/docx-redline-js.esm.js`
|
|
53
|
+
|
|
54
|
+
**Step 1: Write the failing test**
|
|
55
|
+
|
|
56
|
+
Assume the built artifact banner still reflects the previous version until the build runs.
|
|
57
|
+
|
|
58
|
+
**Step 2: Run test to verify it fails**
|
|
59
|
+
|
|
60
|
+
Run: `rg -n "v0\\.1\\.3|v0\\.1\\.4" dist/docx-redline-js.esm.js`
|
|
61
|
+
Expected: the banner still shows `v0.1.3` before rebuilding.
|
|
62
|
+
|
|
63
|
+
**Step 3: Write minimal implementation**
|
|
64
|
+
|
|
65
|
+
Run the documented preflight:
|
|
66
|
+
|
|
67
|
+
- `npm test:isolation`
|
|
68
|
+
- `npm test`
|
|
69
|
+
- `npm run build`
|
|
70
|
+
- `npm pack --dry-run`
|
|
71
|
+
|
|
72
|
+
**Step 4: Run test to verify it passes**
|
|
73
|
+
|
|
74
|
+
Run: `rg -n "v0\\.1\\.3|v0\\.1\\.4" dist/docx-redline-js.esm.js`
|
|
75
|
+
Expected: the banner shows `v0.1.4`, and the preflight commands complete successfully.
|
|
76
|
+
|
|
77
|
+
**Step 5: Commit**
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
git add dist/docx-redline-js.esm.js
|
|
81
|
+
git commit -m "build: refresh dist for v0.1.4"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Task 3: Publish Handoff
|
|
85
|
+
|
|
86
|
+
**Files:**
|
|
87
|
+
- No file changes
|
|
88
|
+
|
|
89
|
+
**Step 1: Write the failing test**
|
|
90
|
+
|
|
91
|
+
Confirm the package is not yet published from this session.
|
|
92
|
+
|
|
93
|
+
**Step 2: Run test to verify it fails**
|
|
94
|
+
|
|
95
|
+
Run: `npm view @ansonlai/docx-redline-js version`
|
|
96
|
+
Expected: the registry version may still report the prior published release until you publish manually.
|
|
97
|
+
|
|
98
|
+
**Step 3: Write minimal implementation**
|
|
99
|
+
|
|
100
|
+
Provide the exact local commands for npm auth check, publish, optional tag push, and post-publish verification.
|
|
101
|
+
|
|
102
|
+
**Step 4: Run test to verify it passes**
|
|
103
|
+
|
|
104
|
+
User runs the publish commands locally and confirms `npm view @ansonlai/docx-redline-js version` returns `0.1.4`.
|
|
105
|
+
|
|
106
|
+
**Step 5: Commit**
|
|
107
|
+
|
|
108
|
+
No additional commit required.
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
import { mergeFormats } from '../pipeline/markdown-processor.js';
|
|
9
9
|
import { applyFormatOverridesToRPr, extractFormatFromRPr } from './rpr-helpers.js';
|
|
10
10
|
import { snapshotAndAttachRPrChange, injectFormattingToRPr } from './run-builders.js';
|
|
11
|
-
import { getDocumentParagraphs, buildTextSpansFromParagraphs } from './format-extraction.js';
|
|
12
|
-
import { buildParagraphInfos, findTargetParagraphInfo } from './format-paragraph-targeting.js';
|
|
13
|
-
import { splitSpansAtBoundaries, applyFormatHintsToSpansRobust } from './format-span-application.js';
|
|
14
|
-
import { getRevisionTimestamp } from '../core/types.js';
|
|
11
|
+
import { getDocumentParagraphs, buildTextSpansFromParagraphs } from './format-extraction.js';
|
|
12
|
+
import { buildParagraphInfos, findTargetParagraphInfo } from './format-paragraph-targeting.js';
|
|
13
|
+
import { splitSpansAtBoundaries, applyFormatHintsToSpansRobust } from './format-span-application.js';
|
|
15
14
|
import { warn, log } from '../adapters/logger.js';
|
|
16
15
|
import { getFirstElementByTag } from '../core/xml-query.js';
|
|
17
16
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
17
|
+
import { createWordElement } from '../core/word-xml.js';
|
|
18
18
|
|
|
19
19
|
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
20
20
|
|
|
@@ -62,9 +62,8 @@ function normalizePrecomputedFormatContext(precomputedContext) {
|
|
|
62
62
|
*/
|
|
63
63
|
export function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, existingFormatHints, serializer, author, generateRedlines = true) {
|
|
64
64
|
void textSpans;
|
|
65
|
-
let hasAnyChanges = false;
|
|
66
|
-
const processedRuns = new Set();
|
|
67
|
-
const dateStr = getRevisionTimestamp();
|
|
65
|
+
let hasAnyChanges = false;
|
|
66
|
+
const processedRuns = new Set();
|
|
68
67
|
|
|
69
68
|
log(`[OxmlEngine] Surgical format removal: ${existingFormatHints.length} hints to process (using w:rPrChange)`);
|
|
70
69
|
|
|
@@ -76,14 +75,14 @@ export function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, exist
|
|
|
76
75
|
|
|
77
76
|
log('[OxmlEngine] Processing run for surgical format removal, format:', hint.format);
|
|
78
77
|
|
|
79
|
-
let rPr = getFirstElementByTag(run, 'w:rPr');
|
|
80
|
-
if (!rPr) {
|
|
81
|
-
rPr = xmlDoc
|
|
82
|
-
run.insertBefore(rPr, run.firstChild);
|
|
83
|
-
}
|
|
84
|
-
|
|
78
|
+
let rPr = getFirstElementByTag(run, 'w:rPr');
|
|
79
|
+
if (!rPr) {
|
|
80
|
+
rPr = createWordElement(xmlDoc, 'w:rPr');
|
|
81
|
+
run.insertBefore(rPr, run.firstChild);
|
|
82
|
+
}
|
|
83
|
+
|
|
85
84
|
if (generateRedlines) {
|
|
86
|
-
snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor()
|
|
85
|
+
snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor());
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
applyFormatOverridesToRPr(xmlDoc, rPr, hint.format);
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* text spans without owning paragraph targeting concerns.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { mergeFormats } from '../pipeline/markdown-processor.js';
|
|
9
|
-
import { injectFormattingToRPr, createTextRun } from './run-builders.js';
|
|
8
|
+
import { mergeFormats } from '../pipeline/markdown-processor.js';
|
|
9
|
+
import { injectFormattingToRPr, createTextRun } from './run-builders.js';
|
|
10
|
+
import { createWordElement } from '../core/word-xml.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Splits spans at all supplied absolute boundaries.
|
|
@@ -166,10 +167,10 @@ function addFormattingToRun(xmlDoc, run, format, author, generateRedlines) {
|
|
|
166
167
|
let rPr = run.getElementsByTagName('w:rPr')[0];
|
|
167
168
|
const baseRPr = rPr ? rPr.cloneNode(true) : null;
|
|
168
169
|
|
|
169
|
-
if (!rPr) {
|
|
170
|
-
rPr = xmlDoc
|
|
171
|
-
run.insertBefore(rPr, run.firstChild);
|
|
172
|
-
}
|
|
170
|
+
if (!rPr) {
|
|
171
|
+
rPr = createWordElement(xmlDoc, 'w:rPr');
|
|
172
|
+
run.insertBefore(rPr, run.firstChild);
|
|
173
|
+
}
|
|
173
174
|
|
|
174
175
|
const newRPr = injectFormattingToRPr(xmlDoc, baseRPr, format, author, generateRedlines);
|
|
175
176
|
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { parseOoxml, serializeOoxml } from './oxml-engine.js';
|
|
9
9
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
10
|
+
import { createRevisionMetadata } from '../core/types.js';
|
|
11
|
+
import { createWordElement } from '../core/word-xml.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Removes specific formatting properties from a run properties (w:rPr) element.
|
|
@@ -154,7 +156,7 @@ export function injectHighlightIntoRPr(doc, rPr, color = 'yellow', options = {})
|
|
|
154
156
|
let rPrElement = rPr;
|
|
155
157
|
if (!rPrElement) {
|
|
156
158
|
// Create new rPr element
|
|
157
|
-
rPrElement = doc
|
|
159
|
+
rPrElement = createWordElement(doc, 'w:rPr');
|
|
158
160
|
} else {
|
|
159
161
|
rPrElement = rPr.cloneNode(true);
|
|
160
162
|
}
|
|
@@ -163,7 +165,7 @@ export function injectHighlightIntoRPr(doc, rPr, color = 'yellow', options = {})
|
|
|
163
165
|
// Clone the *original* rPr children before we touch them
|
|
164
166
|
let previousRPrState = null;
|
|
165
167
|
if (generateRedlines) {
|
|
166
|
-
previousRPrState = doc
|
|
168
|
+
previousRPrState = createWordElement(doc, 'w:rPr');
|
|
167
169
|
Array.from(rPrElement.childNodes).forEach(child => {
|
|
168
170
|
// Don't include existing rPrChange in the "previous" state wrapper usually,
|
|
169
171
|
// but for simplicity we clone children. Word generally handles nested track changes poorly,
|
|
@@ -180,18 +182,19 @@ export function injectHighlightIntoRPr(doc, rPr, color = 'yellow', options = {})
|
|
|
180
182
|
Array.from(existingHighlight).forEach(el => el.remove());
|
|
181
183
|
|
|
182
184
|
// Create and add new highlight element
|
|
183
|
-
const highlightEl = doc
|
|
185
|
+
const highlightEl = createWordElement(doc, 'w:highlight');
|
|
184
186
|
highlightEl.setAttributeNS(NS_W, 'w:val', ooxmlColor);
|
|
185
187
|
rPrElement.appendChild(highlightEl);
|
|
186
188
|
|
|
187
189
|
// --- WRAP IN REDLINES IF ENABLED ---
|
|
188
190
|
if (generateRedlines && previousRPrState) {
|
|
189
|
-
const rPrChange = doc
|
|
190
|
-
|
|
191
|
-
// Attributes
|
|
192
|
-
|
|
193
|
-
rPrChange.
|
|
194
|
-
rPrChange.
|
|
191
|
+
const rPrChange = createWordElement(doc, 'w:rPrChange');
|
|
192
|
+
|
|
193
|
+
// Attributes
|
|
194
|
+
const metadata = createRevisionMetadata(author);
|
|
195
|
+
rPrChange.setAttribute('w:id', String(metadata.id));
|
|
196
|
+
rPrChange.setAttribute('w:author', metadata.author);
|
|
197
|
+
rPrChange.setAttribute('w:date', metadata.date);
|
|
195
198
|
|
|
196
199
|
// Format: <w:rPrChange ...> <w:rPr>...previous...</w:rPr> </w:rPrChange>
|
|
197
200
|
rPrChange.appendChild(previousRPrState);
|
|
@@ -272,7 +275,7 @@ export function applyHighlightToOoxml(ooxmlString, targetText, color = 'yellow',
|
|
|
272
275
|
const tNodes = prefixRun.getElementsByTagNameNS(NS_W, 't');
|
|
273
276
|
// Simply remove all t nodes and add one with new text to avoid complexity of multiple t nodes
|
|
274
277
|
Array.from(tNodes).forEach(t => t.remove());
|
|
275
|
-
const newT = doc
|
|
278
|
+
const newT = createWordElement(doc, 'w:t');
|
|
276
279
|
// Preserve xml:space="preserve" if it existed, or just add it usually
|
|
277
280
|
newT.setAttribute('xml:space', 'preserve');
|
|
278
281
|
newT.textContent = prefixText;
|
|
@@ -286,7 +289,7 @@ export function applyHighlightToOoxml(ooxmlString, targetText, color = 'yellow',
|
|
|
286
289
|
// Update text content
|
|
287
290
|
const tNodes = matchRun.getElementsByTagNameNS(NS_W, 't');
|
|
288
291
|
Array.from(tNodes).forEach(t => t.remove());
|
|
289
|
-
const newT = doc
|
|
292
|
+
const newT = createWordElement(doc, 'w:t');
|
|
290
293
|
newT.setAttribute('xml:space', 'preserve');
|
|
291
294
|
newT.textContent = matchText;
|
|
292
295
|
matchRun.appendChild(newT);
|
|
@@ -310,7 +313,7 @@ export function applyHighlightToOoxml(ooxmlString, targetText, color = 'yellow',
|
|
|
310
313
|
// Update text content
|
|
311
314
|
const tNodes = suffixRun.getElementsByTagNameNS(NS_W, 't');
|
|
312
315
|
Array.from(tNodes).forEach(t => t.remove());
|
|
313
|
-
const newT = doc
|
|
316
|
+
const newT = createWordElement(doc, 'w:t');
|
|
314
317
|
newT.setAttribute('xml:space', 'preserve');
|
|
315
318
|
newT.textContent = suffixText;
|
|
316
319
|
suffixRun.appendChild(newT);
|
package/engine/oxml-engine.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
8
8
|
import { isListTargetLoose } from '../pipeline/list-markers.js';
|
|
9
9
|
import { ReconciliationPipeline } from '../pipeline/pipeline.js';
|
|
10
|
-
import { wrapInDocumentFragment } from '../pipeline/serialization.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
getXmlParseError
|
|
14
|
-
} from '../core/xml-query.js';
|
|
10
|
+
import { wrapInDocumentFragment } from '../pipeline/serialization.js';
|
|
11
|
+
import {
|
|
12
|
+
getElementsByTagNSOrTag,
|
|
13
|
+
getXmlParseError
|
|
14
|
+
} from '../core/xml-query.js';
|
|
15
15
|
import { createParser, createSerializer, parseXml, serializeXml } from '../adapters/xml-adapter.js';
|
|
16
16
|
import { log, error } from '../adapters/logger.js';
|
|
17
17
|
import { extractFormattingFromOoxml } from './format-extraction.js';
|
|
@@ -25,6 +25,9 @@ import { applySurgicalMode } from './surgical-mode.js';
|
|
|
25
25
|
import { applyReconstructionMode } from './reconstruction-mode.js';
|
|
26
26
|
import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
|
|
27
27
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
28
|
+
import { containsTrackedChanges, withOoxmlSourceType } from '../core/word-xml.js';
|
|
29
|
+
import { NS_W, seedRevisionIdsFromDocument } from '../core/types.js';
|
|
30
|
+
import { acceptTrackedChangesInOoxml } from '../services/revision-comment-management.js';
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Applies redline track changes to OOXML by modifying the DOM in-place.
|
|
@@ -32,33 +35,87 @@ import { getDefaultAuthor } from '../adapters/config.js';
|
|
|
32
35
|
* @param {string} oxml - Original OOXML string
|
|
33
36
|
* @param {string} originalText - Original plain text
|
|
34
37
|
* @param {string} modifiedText - New text (may contain markdown)
|
|
35
|
-
* @param {Object} [options={}] - Options
|
|
36
|
-
* @param {string} [options.author='AI'] - Author for track changes
|
|
37
|
-
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
38
|
-
* @
|
|
39
|
-
|
|
38
|
+
* @param {Object} [options={}] - Options
|
|
39
|
+
* @param {string} [options.author='AI'] - Author for track changes
|
|
40
|
+
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
41
|
+
* @param {'reject-input'|'accept-all-first'} [options.existingRevisions='reject-input'] - Policy for source OOXML with tracked changes
|
|
42
|
+
* @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
|
|
43
|
+
*/
|
|
40
44
|
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
41
45
|
const generateRedlines = options.generateRedlines ?? true;
|
|
42
46
|
const author = options.author || getDefaultAuthor();
|
|
43
47
|
const parser = createParser();
|
|
44
48
|
const serializer = createSerializer();
|
|
45
|
-
const
|
|
49
|
+
const finalize = result => {
|
|
50
|
+
const withStatus = { ...result };
|
|
51
|
+
if (!withStatus.status) {
|
|
52
|
+
withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
|
|
53
|
+
}
|
|
54
|
+
return withOoxmlSourceType(withStatus);
|
|
55
|
+
};
|
|
56
|
+
const noChanges = () => finalize({ oxml, hasChanges: false });
|
|
46
57
|
|
|
47
58
|
let xmlDoc;
|
|
48
59
|
try {
|
|
49
60
|
xmlDoc = parser.parseFromString(oxml, 'text/xml');
|
|
50
|
-
} catch (e) {
|
|
51
|
-
error('[OxmlEngine] Failed to parse OXML:', e);
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
} catch (e) {
|
|
62
|
+
error('[OxmlEngine] Failed to parse OXML:', e);
|
|
63
|
+
return finalize({
|
|
64
|
+
oxml,
|
|
65
|
+
hasChanges: false,
|
|
66
|
+
status: 'error',
|
|
67
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse OOXML input.' }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parseError = getXmlParseError(xmlDoc);
|
|
72
|
+
if (parseError) {
|
|
73
|
+
error('[OxmlEngine] XML parse error:', parseError.textContent);
|
|
74
|
+
return finalize({
|
|
75
|
+
oxml,
|
|
76
|
+
hasChanges: false,
|
|
77
|
+
status: 'error',
|
|
78
|
+
error: { code: 'PARSE_ERROR', message: parseError.textContent || 'Could not parse OOXML input.' }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
seedRevisionIdsFromDocument(xmlDoc);
|
|
82
|
+
|
|
83
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
84
|
+
const existingRevisionsPolicy = options.existingRevisions || 'reject-input';
|
|
85
|
+
if (existingRevisionsPolicy === 'accept-all-first') {
|
|
86
|
+
log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
|
|
87
|
+
const accepted = acceptTrackedChangesInOoxml(oxml, { allAuthors: true });
|
|
88
|
+
oxml = accepted.oxml;
|
|
89
|
+
xmlDoc = parser.parseFromString(oxml, 'text/xml');
|
|
90
|
+
const acceptedParseError = getXmlParseError(xmlDoc);
|
|
91
|
+
if (acceptedParseError) {
|
|
92
|
+
error('[OxmlEngine] XML parse error after accepting existing revisions:', acceptedParseError.textContent);
|
|
93
|
+
return finalize({
|
|
94
|
+
oxml,
|
|
95
|
+
hasChanges: false,
|
|
96
|
+
status: 'error',
|
|
97
|
+
error: {
|
|
98
|
+
code: 'PARSE_ERROR',
|
|
99
|
+
message: 'Could not parse OOXML after accepting existing revisions.'
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
seedRevisionIdsFromDocument(xmlDoc);
|
|
104
|
+
} else {
|
|
105
|
+
log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
|
|
106
|
+
return finalize({
|
|
107
|
+
oxml,
|
|
108
|
+
hasChanges: false,
|
|
109
|
+
status: 'error',
|
|
110
|
+
error: {
|
|
111
|
+
code: 'EXISTING_REVISIONS',
|
|
112
|
+
message: 'Input OOXML contains existing tracked changes. Pass existingRevisions: "accept-all-first" to normalize before redlining.'
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
|
|
62
119
|
if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
|
|
63
120
|
log('[OxmlEngine] Isolating table-cell paragraph before diff');
|
|
64
121
|
const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
|
|
@@ -74,8 +131,29 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
74
131
|
const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
|
|
75
132
|
const hasFormatHints = formatHints.length > 0;
|
|
76
133
|
|
|
77
|
-
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
134
|
+
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
78
135
|
const hasExistingFormatting = existingFormatHints.length > 0;
|
|
136
|
+
const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
|
|
137
|
+
if (
|
|
138
|
+
hasTextChanges
|
|
139
|
+
&& typeof originalText === 'string'
|
|
140
|
+
&& originalText.trim()
|
|
141
|
+
&& !originalText.includes('\n')
|
|
142
|
+
&& !visibleText.includes(originalText.trim())
|
|
143
|
+
&& !visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
|
|
144
|
+
&& !normalizeTargetText(visibleText).includes(normalizeTargetText(originalText))
|
|
145
|
+
) {
|
|
146
|
+
log('[OxmlEngine] Target text not found in OOXML');
|
|
147
|
+
return finalize({
|
|
148
|
+
oxml,
|
|
149
|
+
hasChanges: false,
|
|
150
|
+
status: 'error',
|
|
151
|
+
error: {
|
|
152
|
+
code: 'TARGET_NOT_FOUND',
|
|
153
|
+
message: 'Original text was not found in the supplied OOXML.'
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
79
157
|
let paragraphInfos = null;
|
|
80
158
|
const getParagraphInfos = () => {
|
|
81
159
|
if (!paragraphInfos) {
|
|
@@ -149,13 +227,13 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
149
227
|
);
|
|
150
228
|
|
|
151
229
|
if (tableCellCtx.hasTableWrapper && targetParagraph) {
|
|
152
|
-
return {
|
|
153
|
-
oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
|
|
154
|
-
hasChanges: removalResult.hasChanges
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return removalResult;
|
|
230
|
+
return finalize({
|
|
231
|
+
oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
|
|
232
|
+
hasChanges: removalResult.hasChanges
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return finalize(removalResult);
|
|
159
237
|
}
|
|
160
238
|
|
|
161
239
|
if (!hasTextChanges && hasFormatHints) {
|
|
@@ -173,16 +251,16 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
173
251
|
const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
|
|
174
252
|
|
|
175
253
|
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
|
|
176
|
-
return {
|
|
254
|
+
return finalize({
|
|
177
255
|
oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
|
|
178
256
|
hasChanges: formatResult.hasChanges
|
|
179
|
-
};
|
|
257
|
+
});
|
|
180
258
|
}
|
|
181
259
|
|
|
182
|
-
return applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
|
|
260
|
+
return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
|
|
183
261
|
}
|
|
184
262
|
|
|
185
|
-
const tables =
|
|
263
|
+
const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
186
264
|
const hasTables = tables.length > 0;
|
|
187
265
|
const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
|
|
188
266
|
const isTargetList = isListTargetLoose(cleanModifiedText);
|
|
@@ -192,12 +270,12 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
192
270
|
|
|
193
271
|
if (isMarkdownTable && !hasTables) {
|
|
194
272
|
log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
|
|
195
|
-
return applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (hasTables && isMarkdownTable) {
|
|
199
|
-
return applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines);
|
|
200
|
-
}
|
|
273
|
+
return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (hasTables && isMarkdownTable) {
|
|
277
|
+
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines));
|
|
278
|
+
}
|
|
201
279
|
if (hasTables) {
|
|
202
280
|
const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
|
|
203
281
|
? tableCellContext.targetParagraph
|
|
@@ -217,11 +295,11 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
217
295
|
surgicalTarget
|
|
218
296
|
);
|
|
219
297
|
|
|
220
|
-
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
221
|
-
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
222
|
-
return { oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true };
|
|
223
|
-
}
|
|
224
|
-
return result;
|
|
298
|
+
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
299
|
+
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
300
|
+
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
301
|
+
}
|
|
302
|
+
return finalize(result);
|
|
225
303
|
}
|
|
226
304
|
if (isTargetList) {
|
|
227
305
|
log('[OxmlEngine] 🎯 Using reconciliation pipeline for list generation');
|
|
@@ -235,15 +313,28 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
235
313
|
numberingXml: result.numberingXml
|
|
236
314
|
});
|
|
237
315
|
log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
|
|
238
|
-
return { oxml: wrapped, hasChanges: true };
|
|
239
|
-
}
|
|
240
|
-
return noChanges();
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return applyReconstructionMode(xmlDoc, originalText, cleanModifiedText, serializer, author, formatHints, generateRedlines);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
316
|
+
return finalize({ oxml: wrapped, hasChanges: true });
|
|
317
|
+
}
|
|
318
|
+
return noChanges();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return finalize(applyReconstructionMode(xmlDoc, originalText, cleanModifiedText, serializer, author, formatHints, generateRedlines));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function normalizeTargetText(text) {
|
|
325
|
+
return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function textSpanVisibleText(span) {
|
|
329
|
+
const node = span?.textElement;
|
|
330
|
+
const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
|
|
331
|
+
if (localName === 'tab') return '\t';
|
|
332
|
+
if (localName === 'br' || localName === 'cr') return '\n';
|
|
333
|
+
if (localName === 'noBreakHyphen') return '\u2011';
|
|
334
|
+
return node?.textContent || '';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
247
338
|
* Sanitizes AI response text by removing common prefixes.
|
|
248
339
|
*
|
|
249
340
|
* @param {string} text - AI response text
|