@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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createWordElement, isWordElement } from '../core/word-xml.js';
|
|
2
|
+
import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
|
|
3
|
+
|
|
4
|
+
export function getRunContentPieces(runElement) {
|
|
5
|
+
const pieces = [];
|
|
6
|
+
let offset = 0;
|
|
7
|
+
|
|
8
|
+
for (const child of Array.from(runElement.childNodes || [])) {
|
|
9
|
+
if (isWordElement(child, 'rPr')) continue;
|
|
10
|
+
if (!isTextLikeRunChild(child)) continue;
|
|
11
|
+
|
|
12
|
+
const text = getRunChildText(child);
|
|
13
|
+
if (text.length === 0) continue;
|
|
14
|
+
|
|
15
|
+
pieces.push({
|
|
16
|
+
node: child,
|
|
17
|
+
start: offset,
|
|
18
|
+
end: offset + text.length,
|
|
19
|
+
text
|
|
20
|
+
});
|
|
21
|
+
offset += text.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return pieces;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getRunTextLength(pieces) {
|
|
28
|
+
if (pieces.length === 0) return 0;
|
|
29
|
+
return pieces[pieces.length - 1].end;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sliceRunPieces(xmlDoc, pieces, start, end, asDeletedText) {
|
|
33
|
+
const sliced = [];
|
|
34
|
+
if (end <= start) return sliced;
|
|
35
|
+
|
|
36
|
+
pieces.forEach(piece => {
|
|
37
|
+
const overlapStart = Math.max(start, piece.start);
|
|
38
|
+
const overlapEnd = Math.min(end, piece.end);
|
|
39
|
+
if (overlapEnd <= overlapStart) return;
|
|
40
|
+
|
|
41
|
+
const localStart = overlapStart - piece.start;
|
|
42
|
+
const localEnd = overlapEnd - piece.start;
|
|
43
|
+
const text = piece.text.slice(localStart, localEnd);
|
|
44
|
+
|
|
45
|
+
sliced.push(cloneRunPiece(xmlDoc, piece.node, text, asDeletedText));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return sliced;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createRunFromPieces(xmlDoc, pieces, rPr) {
|
|
52
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
53
|
+
if (rPr) run.appendChild(rPr.cloneNode(true));
|
|
54
|
+
pieces.forEach(piece => run.appendChild(piece));
|
|
55
|
+
return run;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
|
|
59
|
+
if (pieces.length === 0) return null;
|
|
60
|
+
const run = createRunFromPieces(xmlDoc, pieces, rPr);
|
|
61
|
+
parent.insertBefore(run, referenceNode);
|
|
62
|
+
return run;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
66
|
+
if (asDeletedText) {
|
|
67
|
+
const delText = createWordElement(xmlDoc, 'w:delText');
|
|
68
|
+
delText.setAttribute('xml:space', 'preserve');
|
|
69
|
+
delText.textContent = text;
|
|
70
|
+
return delText;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isWordElement(sourceNode, 't')) {
|
|
74
|
+
const textNode = sourceNode.cloneNode(false);
|
|
75
|
+
textNode.textContent = text;
|
|
76
|
+
if (/^\s|\s$/.test(text)) {
|
|
77
|
+
textNode.setAttribute('xml:space', 'preserve');
|
|
78
|
+
}
|
|
79
|
+
return textNode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (text === '\n' && (isWordElement(sourceNode, 'br') || isWordElement(sourceNode, 'cr'))) {
|
|
83
|
+
return sourceNode.cloneNode(true);
|
|
84
|
+
}
|
|
85
|
+
if (text === '\t' && isWordElement(sourceNode, 'tab')) {
|
|
86
|
+
return sourceNode.cloneNode(true);
|
|
87
|
+
}
|
|
88
|
+
if (text === '\u2011' && isWordElement(sourceNode, 'noBreakHyphen')) {
|
|
89
|
+
return sourceNode.cloneNode(true);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
93
|
+
textNode.setAttribute('xml:space', 'preserve');
|
|
94
|
+
textNode.textContent = text;
|
|
95
|
+
return textNode;
|
|
96
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { appendParagraphBoundary } from '../core/paragraph-offset-policy.js';
|
|
2
|
+
import { getFirstElementByTag } from '../core/xml-query.js';
|
|
3
|
+
import { isWordElement } from '../core/word-xml.js';
|
|
4
|
+
|
|
5
|
+
export function getRunChildText(child) {
|
|
6
|
+
if (isWordElement(child, 't')) return child.textContent || '';
|
|
7
|
+
if (isWordElement(child, 'br') || isWordElement(child, 'cr')) return '\n';
|
|
8
|
+
if (isWordElement(child, 'tab')) return '\t';
|
|
9
|
+
if (isWordElement(child, 'noBreakHyphen')) return '\u2011';
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isTextLikeRunChild(child) {
|
|
14
|
+
return isWordElement(child, 't')
|
|
15
|
+
|| isWordElement(child, 'br')
|
|
16
|
+
|| isWordElement(child, 'cr')
|
|
17
|
+
|| isWordElement(child, 'tab')
|
|
18
|
+
|| isWordElement(child, 'noBreakHyphen');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildSurgicalTextSpans(paragraphs) {
|
|
22
|
+
let fullText = '';
|
|
23
|
+
const textSpans = [];
|
|
24
|
+
|
|
25
|
+
paragraphs.forEach((paragraph, paragraphIndex) => {
|
|
26
|
+
const container = paragraph.parentNode;
|
|
27
|
+
|
|
28
|
+
for (let child = paragraph.firstChild; child; child = child.nextSibling) {
|
|
29
|
+
if (isWordElement(child, 'r')) {
|
|
30
|
+
fullText += processRunElement(child, paragraph, container, fullText.length, textSpans).text;
|
|
31
|
+
} else if (isWordElement(child, 'hyperlink')) {
|
|
32
|
+
for (let hc = child.firstChild; hc; hc = hc.nextSibling) {
|
|
33
|
+
if (isWordElement(hc, 'r')) {
|
|
34
|
+
fullText += processRunElement(hc, paragraph, container, fullText.length, textSpans).text;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fullText = appendParagraphBoundary(fullText, paragraphIndex, paragraphs.length);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return { fullText, textSpans };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function processRunElement(run, paragraph, container, currentOffset, textSpans) {
|
|
47
|
+
const rPr = getFirstElementByTag(run, 'w:rPr');
|
|
48
|
+
let localOffset = currentOffset;
|
|
49
|
+
const textParts = [];
|
|
50
|
+
|
|
51
|
+
for (let child = run.firstChild; child; child = child.nextSibling) {
|
|
52
|
+
if (isWordElement(child, 't')) {
|
|
53
|
+
const text = child.textContent || '';
|
|
54
|
+
if (text.length === 0) continue;
|
|
55
|
+
|
|
56
|
+
textSpans.push({
|
|
57
|
+
charStart: localOffset,
|
|
58
|
+
charEnd: localOffset + text.length,
|
|
59
|
+
textElement: child,
|
|
60
|
+
runElement: run,
|
|
61
|
+
paragraph,
|
|
62
|
+
container,
|
|
63
|
+
rPr
|
|
64
|
+
});
|
|
65
|
+
localOffset += text.length;
|
|
66
|
+
textParts.push(text);
|
|
67
|
+
} else if (isTextLikeRunChild(child)) {
|
|
68
|
+
const text = getRunChildText(child);
|
|
69
|
+
textSpans.push({
|
|
70
|
+
charStart: localOffset,
|
|
71
|
+
charEnd: localOffset + 1,
|
|
72
|
+
textElement: child,
|
|
73
|
+
runElement: run,
|
|
74
|
+
paragraph,
|
|
75
|
+
container,
|
|
76
|
+
rPr
|
|
77
|
+
});
|
|
78
|
+
localOffset += 1;
|
|
79
|
+
textParts.push(text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { text: textParts.join('') };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildSpanIndex(textSpans) {
|
|
87
|
+
const spans = textSpans
|
|
88
|
+
.slice()
|
|
89
|
+
.sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
|
|
90
|
+
|
|
91
|
+
const starts = spans.map(span => span.charStart);
|
|
92
|
+
const ends = spans.map(span => span.charEnd);
|
|
93
|
+
|
|
94
|
+
return { spans, starts, ends };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function forEachOverlappingSpan(spanIndex, startPos, endPos, callback) {
|
|
98
|
+
if (endPos <= startPos || spanIndex.spans.length === 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let index = upperBound(spanIndex.ends, startPos);
|
|
103
|
+
while (index < spanIndex.spans.length) {
|
|
104
|
+
const span = spanIndex.spans[index];
|
|
105
|
+
if (span.charStart >= endPos) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
callback(span);
|
|
109
|
+
index++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function findContainingSpan(spanIndex, pos) {
|
|
114
|
+
if (spanIndex.spans.length === 0) return null;
|
|
115
|
+
|
|
116
|
+
const index = upperBound(spanIndex.starts, pos) - 1;
|
|
117
|
+
if (index < 0) return null;
|
|
118
|
+
|
|
119
|
+
const span = spanIndex.spans[index];
|
|
120
|
+
return pos >= span.charStart && pos < span.charEnd ? span : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function findFirstSpanEndingAt(spanIndex, pos) {
|
|
124
|
+
const index = lowerBound(spanIndex.ends, pos);
|
|
125
|
+
if (index < spanIndex.spans.length && spanIndex.ends[index] === pos) {
|
|
126
|
+
return spanIndex.spans[index];
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function findLastSpanEndingBeforeOrAt(spanIndex, pos) {
|
|
132
|
+
const index = upperBound(spanIndex.ends, pos) - 1;
|
|
133
|
+
if (index >= 0) {
|
|
134
|
+
return spanIndex.spans[index];
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function upperBound(values, target) {
|
|
140
|
+
let left = 0;
|
|
141
|
+
let right = values.length;
|
|
142
|
+
|
|
143
|
+
while (left < right) {
|
|
144
|
+
const middle = (left + right) >> 1;
|
|
145
|
+
if (values[middle] <= target) {
|
|
146
|
+
left = middle + 1;
|
|
147
|
+
} else {
|
|
148
|
+
right = middle;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return left;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function lowerBound(values, target) {
|
|
156
|
+
let left = 0;
|
|
157
|
+
let right = values.length;
|
|
158
|
+
|
|
159
|
+
while (left < right) {
|
|
160
|
+
const middle = (left + right) >> 1;
|
|
161
|
+
if (values[middle] < target) {
|
|
162
|
+
left = middle + 1;
|
|
163
|
+
} else {
|
|
164
|
+
right = middle;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return left;
|
|
169
|
+
}
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getDocumentParagraphs } from './format-extraction.js';
|
|
9
|
-
import { log } from '../adapters/logger.js';
|
|
10
|
-
import { buildParagraphOnlyPackage } from '../services/package-builder.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
import { log } from '../adapters/logger.js';
|
|
10
|
+
import { buildParagraphOnlyPackage } from '../services/package-builder.js';
|
|
11
|
+
import { getElementsByTagNSOrTag } from '../core/xml-query.js';
|
|
12
|
+
import { NS_W } from '../core/types.js';
|
|
13
|
+
import { isWordElement } from '../core/word-xml.js';
|
|
14
|
+
|
|
15
|
+
const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Detects whether the current XML is table-wrapped and resolves target paragraph context.
|
|
@@ -30,19 +32,19 @@ const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
|
30
32
|
*/
|
|
31
33
|
export function detectTableCellContext(xmlDoc, originalText, options = {}) {
|
|
32
34
|
const { targetParagraphId = null } = options;
|
|
33
|
-
const tables =
|
|
35
|
+
const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
34
36
|
if (tables.length === 0) {
|
|
35
37
|
return { hasTableWrapper: false, isTableCellParagraph: false, paragraphs: [], paragraph: null, tableElement: null };
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const allParagraphs = getDocumentParagraphs(xmlDoc);
|
|
39
41
|
const paragraphsInCells = allParagraphs.filter(p => {
|
|
40
|
-
let parent = p.parentNode;
|
|
41
|
-
while (parent) {
|
|
42
|
-
if (parent
|
|
43
|
-
parent = parent.parentNode;
|
|
44
|
-
}
|
|
45
|
-
return false;
|
|
42
|
+
let parent = p.parentNode;
|
|
43
|
+
while (parent) {
|
|
44
|
+
if (isWordElement(parent, 'tc')) return true;
|
|
45
|
+
parent = parent.parentNode;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
log(`[OxmlEngine] Table wrapper detected: ${tables.length} tables, ${paragraphsInCells.length} paragraphs in cells`);
|
|
@@ -68,7 +70,7 @@ export function detectTableCellContext(xmlDoc, originalText, options = {}) {
|
|
|
68
70
|
const normalizedTarget = originalText.trim();
|
|
69
71
|
if (!targetParagraph) {
|
|
70
72
|
for (const p of paragraphsInCells) {
|
|
71
|
-
const textNodes =
|
|
73
|
+
const textNodes = getElementsByTagNSOrTag(p, NS_W, 't');
|
|
72
74
|
let paragraphText = '';
|
|
73
75
|
for (const t of textNodes) {
|
|
74
76
|
paragraphText += t.textContent || '';
|
package/engine/table-mode.js
CHANGED
|
@@ -2,23 +2,26 @@
|
|
|
2
2
|
* Table-specific reconciliation and transformation flows.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { NS_W,
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
getFirstElementByTag,
|
|
10
|
-
getFirstElementByTagNS,
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
import { NS_W, createRevisionMetadata } from '../core/types.js';
|
|
6
|
+
import {
|
|
7
|
+
getElementsByTagNS,
|
|
8
|
+
getElementsByTagNSOrTag,
|
|
9
|
+
getFirstElementByTag,
|
|
10
|
+
getFirstElementByTagNS,
|
|
11
|
+
getFirstElementByTagNSOrTag,
|
|
12
|
+
getXmlParseError
|
|
13
|
+
} from '../core/xml-query.js';
|
|
13
14
|
import { createParser } from '../adapters/xml-adapter.js';
|
|
14
|
-
import { log, error } from '../adapters/logger.js';
|
|
15
|
-
import { diffTablesWithVirtualGrid, serializeVirtualGridToOoxml, generateTableOoxml } from '../services/table-reconciliation.js';
|
|
16
|
-
import { parseTable } from '../pipeline/pipeline.js';
|
|
17
|
-
import { ingestTableToVirtualGrid } from '../pipeline/ingestion.js';
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
import { log, error } from '../adapters/logger.js';
|
|
16
|
+
import { diffTablesWithVirtualGrid, serializeVirtualGridToOoxml, generateTableOoxml } from '../services/table-reconciliation.js';
|
|
17
|
+
import { parseTable } from '../pipeline/pipeline.js';
|
|
18
|
+
import { ingestTableToVirtualGrid } from '../pipeline/ingestion.js';
|
|
19
|
+
import { createWordElement, withOoxmlSourceType } from '../core/word-xml.js';
|
|
20
|
+
import { markParagraphMarkDeleted } from './run-builders.js';
|
|
21
|
+
|
|
22
|
+
function noChanges(serializer, xmlDoc) {
|
|
23
|
+
return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
|
|
24
|
+
}
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Applies structural reconciliation to tables using Virtual Grid.
|
|
@@ -31,8 +34,8 @@ function noChanges(serializer, xmlDoc) {
|
|
|
31
34
|
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
32
35
|
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
33
36
|
*/
|
|
34
|
-
export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true) {
|
|
35
|
-
const tableNodes =
|
|
37
|
+
export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true) {
|
|
38
|
+
const tableNodes = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
36
39
|
const newTableData = parseTable(modifiedText);
|
|
37
40
|
const hasNewContent = newTableData.rows.length > 0 || newTableData.headers.length > 0;
|
|
38
41
|
|
|
@@ -60,7 +63,7 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
|
|
|
60
63
|
return noChanges(serializer, xmlDoc);
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
const newTableNode =
|
|
66
|
+
const newTableNode = getFirstElementByTagNSOrTag(reconciledDoc, NS_W, 'tbl');
|
|
64
67
|
if (!newTableNode) {
|
|
65
68
|
error('[OxmlEngine] No table found in reconciled OOXML');
|
|
66
69
|
return noChanges(serializer, xmlDoc);
|
|
@@ -69,7 +72,7 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
|
|
|
69
72
|
const importedTable = xmlDoc.importNode(newTableNode, true);
|
|
70
73
|
targetTable.parentNode.replaceChild(importedTable, targetTable);
|
|
71
74
|
|
|
72
|
-
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
|
|
75
|
+
return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: true });
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -135,25 +138,26 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
|
|
|
135
138
|
|
|
136
139
|
const importedTable = workingDoc.importNode(newTableElement, true);
|
|
137
140
|
|
|
138
|
-
if (generateRedlines) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const runs = getElementsByTagNS(p, NS_W, 'r');
|
|
142
|
-
runs.forEach(run => {
|
|
141
|
+
if (generateRedlines) {
|
|
142
|
+
paragraphs.forEach(p => {
|
|
143
|
+
markParagraphMarkDeleted(workingDoc, p, author);
|
|
144
|
+
const runs = getElementsByTagNS(p, NS_W, 'r');
|
|
145
|
+
runs.forEach(run => {
|
|
143
146
|
const textNodes = getElementsByTagNS(run, NS_W, 't');
|
|
144
147
|
textNodes.forEach(t => {
|
|
145
148
|
const text = t.textContent || '';
|
|
146
149
|
if (text.trim()) {
|
|
147
|
-
const delText = workingDoc
|
|
148
|
-
delText.textContent = text;
|
|
149
|
-
t.parentNode.replaceChild(delText, t);
|
|
150
|
+
const delText = createWordElement(workingDoc, 'w:delText');
|
|
151
|
+
delText.textContent = text;
|
|
152
|
+
t.parentNode.replaceChild(delText, t);
|
|
150
153
|
}
|
|
151
154
|
});
|
|
152
|
-
|
|
153
|
-
const del = workingDoc
|
|
154
|
-
|
|
155
|
-
del.setAttribute('w:
|
|
156
|
-
del.setAttribute('w:
|
|
155
|
+
|
|
156
|
+
const del = createWordElement(workingDoc, 'w:del');
|
|
157
|
+
const metadata = createRevisionMetadata(author);
|
|
158
|
+
del.setAttribute('w:id', String(metadata.id));
|
|
159
|
+
del.setAttribute('w:author', metadata.author);
|
|
160
|
+
del.setAttribute('w:date', metadata.date);
|
|
157
161
|
run.parentNode.insertBefore(del, run);
|
|
158
162
|
del.appendChild(run);
|
|
159
163
|
});
|
|
@@ -168,5 +172,5 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
log('[OxmlEngine] Text-to-table transformation complete');
|
|
171
|
-
return { oxml: serializer.serializeToString(workingDoc), hasChanges: true };
|
|
172
|
-
}
|
|
175
|
+
return withOoxmlSourceType({ oxml: serializer.serializeToString(workingDoc), hasChanges: true });
|
|
176
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export type OoxmlSourceType = 'package' | 'document' | 'fragment';
|
|
2
|
+
export type RedlineStatus = 'ok' | 'no-op' | 'error';
|
|
3
|
+
export type ExistingRevisionsPolicy = 'reject-input' | 'accept-all-first';
|
|
4
|
+
|
|
5
|
+
export interface RedlineError {
|
|
6
|
+
code: 'PARSE_ERROR' | 'TARGET_NOT_FOUND' | 'EXISTING_REVISIONS' | string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RedlineOptions {
|
|
11
|
+
generateRedlines?: boolean;
|
|
12
|
+
author?: string;
|
|
13
|
+
targetParagraphId?: string | null;
|
|
14
|
+
existingRevisions?: ExistingRevisionsPolicy;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RedlineResult {
|
|
19
|
+
oxml: string;
|
|
20
|
+
hasChanges: boolean;
|
|
21
|
+
sourceType?: OoxmlSourceType;
|
|
22
|
+
status?: RedlineStatus;
|
|
23
|
+
error?: RedlineError;
|
|
24
|
+
warnings?: string[];
|
|
25
|
+
numberingXml?: string;
|
|
26
|
+
useNativeApi?: boolean;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TableReconciliationResult extends RedlineResult {
|
|
31
|
+
isMarkdownTable: boolean;
|
|
32
|
+
tableData?: {
|
|
33
|
+
headers?: string[];
|
|
34
|
+
rows?: string[][];
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RevisionFilterOptions {
|
|
40
|
+
author?: string;
|
|
41
|
+
allAuthors?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AcceptTrackedChangesResult {
|
|
45
|
+
oxml: string;
|
|
46
|
+
hasChanges: boolean;
|
|
47
|
+
acceptedCount: number;
|
|
48
|
+
warnings: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RejectTrackedChangesResult {
|
|
52
|
+
oxml: string;
|
|
53
|
+
hasChanges: boolean;
|
|
54
|
+
rejectedCount: number;
|
|
55
|
+
warnings: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DeleteCommentsResult {
|
|
59
|
+
oxml: string;
|
|
60
|
+
hasChanges: boolean;
|
|
61
|
+
commentsRemoved: number;
|
|
62
|
+
referencesRemoved: number;
|
|
63
|
+
warnings: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface XmlProvider {
|
|
67
|
+
DOMParser: typeof DOMParser;
|
|
68
|
+
XMLSerializer: typeof XMLSerializer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface LoggerConfig {
|
|
72
|
+
log?: (...args: unknown[]) => void;
|
|
73
|
+
warn?: (...args: unknown[]) => void;
|
|
74
|
+
error?: (...args: unknown[]) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DocumentOperationResult {
|
|
78
|
+
documentXml: string;
|
|
79
|
+
hasChanges: boolean;
|
|
80
|
+
numberingXml?: string | null;
|
|
81
|
+
commentsXml?: string | null;
|
|
82
|
+
warnings?: string[];
|
|
83
|
+
status?: RedlineStatus;
|
|
84
|
+
error?: RedlineError;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function configureXmlProvider(provider: XmlProvider): void;
|
|
88
|
+
export function configureLogger(logger: LoggerConfig): void;
|
|
89
|
+
export function setDefaultAuthor(name: string): void;
|
|
90
|
+
export function getDefaultAuthor(): string;
|
|
91
|
+
export function setPlatform(label: string): void;
|
|
92
|
+
export function getPlatform(): string;
|
|
93
|
+
|
|
94
|
+
export function applyRedlineToOxml(
|
|
95
|
+
oxml: string,
|
|
96
|
+
originalText: string,
|
|
97
|
+
modifiedText: string,
|
|
98
|
+
options?: RedlineOptions
|
|
99
|
+
): Promise<RedlineResult>;
|
|
100
|
+
|
|
101
|
+
export function applyRedlineToOxmlWithListFallback(
|
|
102
|
+
oxml: string,
|
|
103
|
+
originalText: string,
|
|
104
|
+
modifiedText: string,
|
|
105
|
+
options?: RedlineOptions
|
|
106
|
+
): Promise<RedlineResult>;
|
|
107
|
+
|
|
108
|
+
export function reconcileMarkdownTableOoxml(
|
|
109
|
+
oxml: string,
|
|
110
|
+
originalText: string,
|
|
111
|
+
markdownTable: string,
|
|
112
|
+
options?: RedlineOptions
|
|
113
|
+
): Promise<TableReconciliationResult>;
|
|
114
|
+
|
|
115
|
+
export function ingestWordOoxmlToPlainText(oxml: string): string;
|
|
116
|
+
export function ingestWordOoxmlToMarkdown(oxml: string): string;
|
|
117
|
+
export function ingestOoxml(oxml: string): unknown;
|
|
118
|
+
export function preprocessMarkdown(text: string): { cleanText: string; formatHints: unknown[] };
|
|
119
|
+
export function serializeToOoxml(runModel: unknown[], pPrXml?: string | null, formatHints?: unknown[], options?: Record<string, unknown>): string;
|
|
120
|
+
export function wrapInDocumentFragment(rawOoxml: string, options?: Record<string, unknown>): string;
|
|
121
|
+
|
|
122
|
+
export function injectCommentsIntoOoxml(oxml: string, comments: unknown[], options?: Record<string, unknown>): unknown;
|
|
123
|
+
export function injectCommentsIntoPackage(packageXml: string, comments: unknown[], options?: Record<string, unknown>): unknown;
|
|
124
|
+
export function acceptTrackedChangesInOoxml(oxml: string, options?: RevisionFilterOptions): AcceptTrackedChangesResult;
|
|
125
|
+
export function rejectTrackedChangesInOoxml(oxml: string, options?: RevisionFilterOptions): RejectTrackedChangesResult;
|
|
126
|
+
export function deleteCommentsByAuthorInOoxml(oxml: string, options?: RevisionFilterOptions): DeleteCommentsResult;
|
|
127
|
+
export function containsTrackedChanges(xmlDoc: Document | Element): boolean;
|
|
128
|
+
|
|
129
|
+
export type RedlineValidationSeverity = 'error' | 'warning';
|
|
130
|
+
|
|
131
|
+
export interface RedlineValidationIssue {
|
|
132
|
+
code:
|
|
133
|
+
| 'PARSE_ERROR'
|
|
134
|
+
| 'NESTED_REVISION'
|
|
135
|
+
| 'DEL_CONTAINS_T'
|
|
136
|
+
| 'MISSING_REVISION_METADATA'
|
|
137
|
+
| 'DUPLICATE_REVISION_ID'
|
|
138
|
+
| 'MISSING_SPACE_PRESERVE'
|
|
139
|
+
| 'EMPTY_TEXT_ELEMENT'
|
|
140
|
+
| 'EMPTY_REVISION_WRAPPER'
|
|
141
|
+
| string;
|
|
142
|
+
severity: RedlineValidationSeverity;
|
|
143
|
+
message: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RedlineValidationResult {
|
|
147
|
+
valid: boolean;
|
|
148
|
+
issues: RedlineValidationIssue[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function validateRedlineOoxml(oxml: string): RedlineValidationResult;
|
|
152
|
+
|
|
153
|
+
export function applyHighlightToOoxml(oxml: string, targetText: string, color: string, options?: Record<string, unknown>): string;
|
|
154
|
+
export function generateTableOoxml(headersOrData: unknown, rowsOrOptions?: unknown, options?: Record<string, unknown>): string;
|
|
155
|
+
export function extractReplacementNodesFromOoxml(oxml: string): unknown;
|
|
156
|
+
export function validateDocxPackage(zip: unknown): Promise<unknown> | unknown;
|
|
157
|
+
export function ensureNumberingArtifactsInZip(zip: unknown, numberingXml: string): Promise<unknown> | unknown;
|
|
158
|
+
export function ensureCommentsArtifactsInZip(zip: unknown, commentsXml: string): Promise<unknown> | unknown;
|
|
159
|
+
export function createDynamicNumberingIdState(numberingXml?: string): unknown;
|
|
160
|
+
|
|
161
|
+
export function parseOoxml(ooxml: string): Document;
|
|
162
|
+
export function serializeOoxml(node: Node): string;
|
|
163
|
+
export function sanitizeAiResponse(text: string): string;
|
|
164
|
+
|
|
165
|
+
export class ReconciliationPipeline {
|
|
166
|
+
constructor(options?: Record<string, unknown>);
|
|
167
|
+
execute(oxml: string, modifiedText: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export class NumberingService {
|
|
171
|
+
constructor(...args: unknown[]);
|
|
172
|
+
}
|