@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.
Files changed (42) hide show
  1. package/AGENTS.md +53 -4
  2. package/ARCHITECTURE.md +57 -9
  3. package/README.md +47 -3
  4. package/core/types.js +35 -8
  5. package/core/word-xml.js +90 -0
  6. package/dist/docx-redline-js.esm.js +3073 -2594
  7. package/dist/docx-redline-js.esm.js.map +4 -4
  8. package/dist/docx-redline-js.esm.min.js +71 -67
  9. package/dist/docx-redline-js.esm.min.js.map +4 -4
  10. package/docs/VALIDATION.md +48 -0
  11. package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
  12. package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
  13. package/engine/format-application.js +13 -14
  14. package/engine/format-span-application.js +7 -6
  15. package/engine/formatting-removal.js +15 -12
  16. package/engine/oxml-engine.js +146 -55
  17. package/engine/reconstruction-mapper.js +35 -8
  18. package/engine/reconstruction-mode.js +14 -13
  19. package/engine/reconstruction-writer.js +97 -78
  20. package/engine/rpr-helpers.js +34 -32
  21. package/engine/run-builders.js +150 -39
  22. package/engine/surgical-diff-application.js +216 -0
  23. package/engine/surgical-mode.js +84 -519
  24. package/engine/surgical-run-splitting.js +96 -0
  25. package/engine/surgical-spans.js +169 -0
  26. package/engine/table-cell-context.js +15 -13
  27. package/engine/table-mode.js +39 -35
  28. package/index.d.ts +148 -0
  29. package/index.js +15 -13
  30. package/package.json +8 -1
  31. package/pipeline/ingestion-export.js +1 -0
  32. package/pipeline/ingestion-paragraph.js +37 -12
  33. package/pipeline/ingestion-table.js +11 -8
  34. package/scripts/build.mjs +35 -0
  35. package/scripts/check-types.mjs +28 -0
  36. package/scripts/export-validation-fixtures.mjs +68 -0
  37. package/scripts/run-tests.mjs +43 -0
  38. package/scripts/word-com-smoke.ps1 +48 -0
  39. package/services/comment-locator.js +10 -9
  40. package/services/revision-comment-management.js +115 -1
  41. package/services/standalone-operation-runner.js +119 -69
  42. 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.createElement('w:rPr');
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(), dateStr);
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.createElement('w:rPr');
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.createElementNS(NS_W, 'w:rPr');
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.createElementNS(NS_W, 'w:rPr');
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.createElementNS(NS_W, 'w:highlight');
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.createElementNS(NS_W, 'w:rPrChange');
190
-
191
- // Attributes
192
- rPrChange.setAttributeNS(NS_W, 'w:id', Math.floor(Math.random() * 9999999).toString());
193
- rPrChange.setAttributeNS(NS_W, 'w:author', author);
194
- rPrChange.setAttributeNS(NS_W, 'w:date', new Date().toISOString());
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.createElementNS(NS_W, 'w:t');
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.createElementNS(NS_W, 'w:t');
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.createElementNS(NS_W, 'w:t');
316
+ const newT = createWordElement(doc, 'w:t');
314
317
  newT.setAttribute('xml:space', 'preserve');
315
318
  newT.textContent = suffixText;
316
319
  suffixRun.appendChild(newT);
@@ -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
- getElementsByTag,
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
- * @returns {Promise<{ oxml: string, hasChanges: boolean }>}
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 noChanges = () => ({ oxml, hasChanges: false });
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 noChanges();
53
- }
54
-
55
- const parseError = getXmlParseError(xmlDoc);
56
- if (parseError) {
57
- error('[OxmlEngine] XML parse error:', parseError.textContent);
58
- return noChanges();
59
- }
60
-
61
- const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
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 = getElementsByTag(xmlDoc, 'w:tbl');
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