@ansonlai/docx-redline-js 0.1.4 → 0.2.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 +53 -4
- package/ARCHITECTURE.md +75 -11
- package/README.md +62 -3
- package/core/redline-validation.js +156 -0
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +3195 -2592
- 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 +104 -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/docs/plans/2026-05-31-architectural changes.md +591 -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 +172 -0
- package/index.js +50 -47
- package/package.json +10 -2
- 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 +40 -0
- package/scripts/check-types.mjs +29 -0
- package/scripts/export-validation-fixtures.mjs +125 -0
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -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
|
@@ -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
|
|
@@ -109,11 +109,12 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
let processedModifiedText = modifiedText;
|
|
113
|
-
tokenToCharMap.forEach((char, tokenString) => {
|
|
114
|
-
const escapedToken = tokenString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
|
115
|
-
processedModifiedText = processedModifiedText.replace(new RegExp(escapedToken, 'g'), char);
|
|
116
|
-
});
|
|
112
|
+
let processedModifiedText = modifiedText;
|
|
113
|
+
tokenToCharMap.forEach((char, tokenString) => {
|
|
114
|
+
const escapedToken = tokenString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
|
115
|
+
processedModifiedText = processedModifiedText.replace(new RegExp(escapedToken, 'g'), char);
|
|
116
|
+
});
|
|
117
|
+
processedModifiedText = preserveReferencePlaceholders(originalFullText, processedModifiedText, referenceMap);
|
|
117
118
|
|
|
118
119
|
const containerFragments = new Map();
|
|
119
120
|
uniqueContainers.forEach(container => {
|
|
@@ -169,9 +170,35 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
|
|
|
169
170
|
getPropertySpanLength,
|
|
170
171
|
isParagraphStart: index => paragraphStarts.has(index)
|
|
171
172
|
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function preserveReferencePlaceholders(originalFullText, modifiedText, referenceMap) {
|
|
176
|
+
let result = modifiedText;
|
|
177
|
+
|
|
178
|
+
for (const referenceChar of referenceMap.keys()) {
|
|
179
|
+
if (result.includes(referenceChar)) continue;
|
|
180
|
+
|
|
181
|
+
const originalIndex = originalFullText.indexOf(referenceChar);
|
|
182
|
+
if (originalIndex < 0) continue;
|
|
183
|
+
|
|
184
|
+
const prefix = originalFullText.slice(0, originalIndex);
|
|
185
|
+
const suffix = originalFullText.slice(originalIndex + referenceChar.length);
|
|
186
|
+
|
|
187
|
+
if (prefix && result.startsWith(prefix)) {
|
|
188
|
+
result = `${result.slice(0, prefix.length)}${referenceChar}${result.slice(prefix.length)}`;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (suffix && result.endsWith(suffix)) {
|
|
193
|
+
const insertAt = result.length - suffix.length;
|
|
194
|
+
result = `${result.slice(0, insertAt)}${referenceChar}${result.slice(insertAt)}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function processChildNode(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode) {
|
|
175
202
|
if (child.nodeName === 'w:r') {
|
|
176
203
|
return processRunForReconstruction(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode);
|
|
177
204
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
6
6
|
import { buildReconstructionMapping } from './reconstruction-mapper.js';
|
|
7
7
|
import { applyReconstructionDiffs } from './reconstruction-writer.js';
|
|
8
|
+
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Applies reconstruction mode reconciliation.
|
|
@@ -19,20 +20,20 @@ import { applyReconstructionDiffs } from './reconstruction-writer.js';
|
|
|
19
20
|
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
20
21
|
*/
|
|
21
22
|
export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true) {
|
|
22
|
-
const mapping = buildReconstructionMapping(xmlDoc, modifiedText);
|
|
23
|
-
if (mapping.paragraphs.length === 0) {
|
|
24
|
-
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: false };
|
|
25
|
-
}
|
|
23
|
+
const mapping = buildReconstructionMapping(xmlDoc, modifiedText);
|
|
24
|
+
if (mapping.paragraphs.length === 0) {
|
|
25
|
+
return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
|
|
26
|
+
}
|
|
26
27
|
|
|
27
28
|
const diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText);
|
|
28
29
|
|
|
29
|
-
return applyReconstructionDiffs(
|
|
30
|
+
return withOoxmlSourceType(applyReconstructionDiffs(
|
|
30
31
|
xmlDoc,
|
|
31
|
-
diffs,
|
|
32
|
-
mapping,
|
|
33
|
-
serializer,
|
|
34
|
-
author,
|
|
35
|
-
formatHints,
|
|
36
|
-
generateRedlines
|
|
37
|
-
);
|
|
38
|
-
}
|
|
32
|
+
diffs,
|
|
33
|
+
mapping,
|
|
34
|
+
serializer,
|
|
35
|
+
author,
|
|
36
|
+
formatHints,
|
|
37
|
+
generateRedlines
|
|
38
|
+
));
|
|
39
|
+
}
|