@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.
Files changed (47) hide show
  1. package/AGENTS.md +53 -4
  2. package/ARCHITECTURE.md +75 -11
  3. package/README.md +62 -3
  4. package/core/redline-validation.js +156 -0
  5. package/core/types.js +35 -8
  6. package/core/word-xml.js +90 -0
  7. package/dist/docx-redline-js.esm.js +3195 -2592
  8. package/dist/docx-redline-js.esm.js.map +4 -4
  9. package/dist/docx-redline-js.esm.min.js +71 -67
  10. package/dist/docx-redline-js.esm.min.js.map +4 -4
  11. package/docs/VALIDATION.md +104 -0
  12. package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
  13. package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
  14. package/docs/plans/2026-05-31-architectural changes.md +591 -0
  15. package/engine/format-application.js +13 -14
  16. package/engine/format-span-application.js +7 -6
  17. package/engine/formatting-removal.js +15 -12
  18. package/engine/oxml-engine.js +146 -55
  19. package/engine/reconstruction-mapper.js +35 -8
  20. package/engine/reconstruction-mode.js +14 -13
  21. package/engine/reconstruction-writer.js +97 -78
  22. package/engine/rpr-helpers.js +34 -32
  23. package/engine/run-builders.js +150 -39
  24. package/engine/surgical-diff-application.js +216 -0
  25. package/engine/surgical-mode.js +84 -519
  26. package/engine/surgical-run-splitting.js +96 -0
  27. package/engine/surgical-spans.js +169 -0
  28. package/engine/table-cell-context.js +15 -13
  29. package/engine/table-mode.js +39 -35
  30. package/index.d.ts +172 -0
  31. package/index.js +50 -47
  32. package/package.json +10 -2
  33. package/pipeline/ingestion-export.js +1 -0
  34. package/pipeline/ingestion-paragraph.js +37 -12
  35. package/pipeline/ingestion-table.js +11 -8
  36. package/scripts/build.mjs +40 -0
  37. package/scripts/check-types.mjs +29 -0
  38. package/scripts/export-validation-fixtures.mjs +125 -0
  39. package/scripts/lib/minimal-zip.mjs +155 -0
  40. package/scripts/run-tests.mjs +43 -0
  41. package/scripts/validate-fixtures-xsd.sh +37 -0
  42. package/scripts/word-com-differential.ps1 +133 -0
  43. package/scripts/word-com-smoke.ps1 +48 -0
  44. package/services/comment-locator.js +10 -9
  45. package/services/revision-comment-management.js +115 -1
  46. package/services/standalone-operation-runner.js +119 -69
  47. package/services/table-reconciliation.js +7 -8
@@ -0,0 +1,216 @@
1
+ import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
2
+ import { isWordElement } from '../core/word-xml.js';
3
+ import {
4
+ createTrackChange,
5
+ createTextRun,
6
+ createFormattedRuns,
7
+ createTextRunWithRPrElement,
8
+ injectFormattingToRPr
9
+ } from './run-builders.js';
10
+ import {
11
+ createRunFromPieces,
12
+ getRunContentPieces,
13
+ getRunTextLength,
14
+ insertRunPiecesBefore,
15
+ sliceRunPieces
16
+ } from './surgical-run-splitting.js';
17
+ import {
18
+ findContainingSpan,
19
+ findFirstSpanEndingAt,
20
+ findLastSpanEndingBeforeOrAt,
21
+ forEachOverlappingSpan
22
+ } from './surgical-spans.js';
23
+
24
+ export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
25
+ const desiredFormat = {};
26
+ if (applicableHints.length > 0) {
27
+ applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
28
+ }
29
+
30
+ const rPr = span.rPr;
31
+ const hasElement = (localName) => {
32
+ if (!rPr) return false;
33
+ for (let node = rPr.firstChild; node; node = node.nextSibling) {
34
+ if (isWordElement(node, localName)) {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ };
40
+
41
+ const existingFormat = {
42
+ bold: hasElement('b'),
43
+ italic: hasElement('i'),
44
+ underline: hasElement('u'),
45
+ strikethrough: hasElement('strike')
46
+ };
47
+
48
+ const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
49
+ const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
50
+
51
+ if (!changesNeeded) return false;
52
+
53
+ const parent = span.runElement.parentNode;
54
+ if (!parent) return false;
55
+
56
+ const fullText = span.textElement.textContent || '';
57
+ const runStart = span.charStart;
58
+
59
+ const localStart = start - runStart;
60
+ const localEnd = end - runStart;
61
+
62
+ const beforeText = fullText.substring(0, localStart);
63
+ const affectedText = fullText.substring(localStart, localEnd);
64
+ const afterText = fullText.substring(localEnd);
65
+
66
+ if (beforeText.length > 0) {
67
+ const beforeRun = createTextRun(xmlDoc, beforeText, rPr, false);
68
+ parent.insertBefore(beforeRun, span.runElement);
69
+ }
70
+
71
+ const newRPr = injectFormattingToRPr(xmlDoc, rPr, desiredFormat, author, generateRedlines);
72
+ const newRun = createTextRunWithRPrElement(xmlDoc, affectedText, newRPr, false);
73
+ parent.insertBefore(newRun, span.runElement);
74
+
75
+ if (afterText.length > 0) {
76
+ const afterRun = createTextRun(xmlDoc, afterText, rPr, false);
77
+ parent.insertBefore(afterRun, span.runElement);
78
+ }
79
+
80
+ parent.removeChild(span.runElement);
81
+ return true;
82
+ }
83
+
84
+ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines) {
85
+ const spans = [];
86
+ forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
87
+ spans.push(span);
88
+ });
89
+
90
+ if (spans.length === 0) return false;
91
+
92
+ const spansByRun = new Map();
93
+ spans.forEach(span => {
94
+ if (!span.runElement?.parentNode) return;
95
+ if (!spansByRun.has(span.runElement)) spansByRun.set(span.runElement, []);
96
+ spansByRun.get(span.runElement).push(span);
97
+ });
98
+
99
+ let changed = false;
100
+ spansByRun.forEach((runSpans, runElement) => {
101
+ const parent = runElement.parentNode;
102
+ if (!parent) return;
103
+
104
+ const pieces = getRunContentPieces(runElement);
105
+ if (pieces.length === 0) return;
106
+
107
+ let deleteStart = Infinity;
108
+ let deleteEnd = -Infinity;
109
+ runSpans.forEach(span => {
110
+ const piece = pieces.find(candidate => candidate.node === span.textElement);
111
+ if (!piece) return;
112
+
113
+ const spanDeleteStart = Math.max(0, startPos - span.charStart);
114
+ const spanDeleteEnd = Math.min(span.charEnd - span.charStart, endPos - span.charStart);
115
+ if (spanDeleteEnd <= spanDeleteStart) return;
116
+
117
+ deleteStart = Math.min(deleteStart, piece.start + spanDeleteStart);
118
+ deleteEnd = Math.max(deleteEnd, piece.start + spanDeleteEnd);
119
+ });
120
+
121
+ if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
122
+
123
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false);
124
+ const deletedPieces = sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true);
125
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false);
126
+
127
+ insertRunPiecesBefore(xmlDoc, parent, runElement, beforePieces, runSpans[0].rPr);
128
+
129
+ if (generateRedlines && deletedPieces.length > 0) {
130
+ const delRun = createRunFromPieces(xmlDoc, deletedPieces, runSpans[0].rPr);
131
+ const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
132
+ parent.insertBefore(delWrapper, runElement);
133
+ }
134
+
135
+ insertRunPiecesBefore(xmlDoc, parent, runElement, afterPieces, runSpans[0].rPr);
136
+ parent.removeChild(runElement);
137
+ changed = true;
138
+ });
139
+
140
+ return changed;
141
+ }
142
+
143
+ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null) {
144
+ let targetSpan = findContainingSpan(spanIndex, pos);
145
+
146
+ if (!targetSpan && pos > 0) {
147
+ targetSpan = findFirstSpanEndingAt(spanIndex, pos);
148
+ }
149
+
150
+ if (!targetSpan && pos > 0) {
151
+ targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
152
+ }
153
+
154
+ if (!targetSpan && spanIndex.spans.length > 0) {
155
+ targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
156
+ }
157
+
158
+ if (!targetSpan) {
159
+ if (!fallbackParagraph) return false;
160
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines);
161
+ return true;
162
+ }
163
+
164
+ const parent = targetSpan.runElement.parentNode;
165
+ if (!parent) {
166
+ if (!fallbackParagraph) return false;
167
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
168
+ return true;
169
+ }
170
+
171
+ const pieces = getRunContentPieces(targetSpan.runElement);
172
+ const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
173
+ const localInsertPos = targetPiece
174
+ ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
175
+ : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
176
+
177
+ if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
178
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
179
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
180
+
181
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
182
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
183
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
184
+ parent.removeChild(targetSpan.runElement);
185
+ return true;
186
+ }
187
+
188
+ const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
189
+ insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
190
+ return true;
191
+ }
192
+
193
+ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines) {
194
+ const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
195
+
196
+ if (applicableHints.length === 0) {
197
+ const insRun = createTextRun(xmlDoc, text, baseRPr, false);
198
+ if (generateRedlines) {
199
+ const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
200
+ parent.insertBefore(insWrapper, referenceNode);
201
+ } else {
202
+ parent.insertBefore(insRun, referenceNode);
203
+ }
204
+ return;
205
+ }
206
+
207
+ const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
208
+
209
+ if (generateRedlines) {
210
+ const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
211
+ runs.forEach(run => insWrapper.appendChild(run));
212
+ parent.insertBefore(insWrapper, referenceNode);
213
+ } else {
214
+ runs.forEach(run => parent.insertBefore(run, referenceNode));
215
+ }
216
+ }