@ansonlai/docx-redline-js 0.1.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 +176 -0
- package/ARCHITECTURE.md +121 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/adapters/config.js +43 -0
- package/adapters/logger.js +89 -0
- package/adapters/xml-adapter.js +74 -0
- package/core/list-targeting.js +398 -0
- package/core/ooxml-identifiers.js +15 -0
- package/core/paragraph-offset-policy.js +50 -0
- package/core/paragraph-targeting.js +501 -0
- package/core/table-targeting.js +233 -0
- package/core/types.js +204 -0
- package/core/xml-query.js +99 -0
- package/dist/docx-redline-js.esm.js +8801 -0
- package/dist/docx-redline-js.esm.js.map +7 -0
- package/dist/docx-redline-js.esm.min.js +195 -0
- package/dist/docx-redline-js.esm.min.js.map +7 -0
- package/engine/format-application.js +358 -0
- package/engine/format-extraction.js +232 -0
- package/engine/format-paragraph-targeting.js +208 -0
- package/engine/format-span-application.js +178 -0
- package/engine/formatting-removal.js +330 -0
- package/engine/oxml-engine.js +279 -0
- package/engine/reconstruction-mapper.js +270 -0
- package/engine/reconstruction-mode.js +38 -0
- package/engine/reconstruction-writer.js +276 -0
- package/engine/rpr-helpers.js +194 -0
- package/engine/run-builders.js +235 -0
- package/engine/surgical-mode.js +520 -0
- package/engine/table-cell-context.js +151 -0
- package/engine/table-mode.js +172 -0
- package/index.js +308 -0
- package/orchestration/list-markdown.js +141 -0
- package/orchestration/list-parsing.js +73 -0
- package/orchestration/list-structural-fallback.js +530 -0
- package/orchestration/redline-operation-converter.js +141 -0
- package/orchestration/route-plan.js +160 -0
- package/package.json +76 -0
- package/pipeline/content-analysis.js +107 -0
- package/pipeline/diff-engine.js +204 -0
- package/pipeline/ingestion-export.js +255 -0
- package/pipeline/ingestion-paragraph.js +351 -0
- package/pipeline/ingestion-table.js +169 -0
- package/pipeline/ingestion-xml.js +39 -0
- package/pipeline/ingestion.js +8 -0
- package/pipeline/list-generation.js +280 -0
- package/pipeline/list-markers.js +77 -0
- package/pipeline/markdown-processor.js +160 -0
- package/pipeline/patching.js +408 -0
- package/pipeline/pipeline.js +326 -0
- package/pipeline/serialization.js +395 -0
- package/services/browser-demo-prompt-context.js +345 -0
- package/services/comment-builders.js +60 -0
- package/services/comment-engine.js +248 -0
- package/services/comment-locator.js +197 -0
- package/services/comment-package.js +113 -0
- package/services/numbering-helpers.js +416 -0
- package/services/numbering-service.js +290 -0
- package/services/package-builder.js +147 -0
- package/services/standalone-docx-plumbing.js +443 -0
- package/services/standalone-operation-runner.js +1169 -0
- package/services/table-reconciliation.js +344 -0
- package/standalone.js +5 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { advanceOffsetForParagraphBoundary } from '../core/paragraph-offset-policy.js';
|
|
2
|
+
|
|
3
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
4
|
+
|
|
5
|
+
function isWordElement(node, localName) {
|
|
6
|
+
if (!node || node.nodeType !== 1) return false;
|
|
7
|
+
if (node.namespaceURI === NS_W && node.localName === localName) return true;
|
|
8
|
+
const nodeName = String(node.nodeName || '');
|
|
9
|
+
return nodeName === `w:${localName}` || nodeName === localName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Paragraph targeting helpers for format-only operations.
|
|
14
|
+
*
|
|
15
|
+
* Encapsulates paragraph text reconstruction and matching logic used to map
|
|
16
|
+
* AI-provided text ranges to the correct paragraph/spans in OOXML.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds paragraph metadata (text, spans, offsets) used for format-only changes.
|
|
21
|
+
*
|
|
22
|
+
* @param {Document} xmlDoc - XML document (unused, kept for signature compatibility)
|
|
23
|
+
* @param {Element[]} paragraphs - Paragraph elements
|
|
24
|
+
* @param {Array} textSpans - Extracted text spans
|
|
25
|
+
* @returns {Array}
|
|
26
|
+
*/
|
|
27
|
+
export function buildParagraphInfos(xmlDoc, paragraphs, textSpans) {
|
|
28
|
+
void xmlDoc;
|
|
29
|
+
const spansByParagraph = new Map();
|
|
30
|
+
for (const span of textSpans) {
|
|
31
|
+
if (!span || !span.paragraph) continue;
|
|
32
|
+
if (!spansByParagraph.has(span.paragraph)) {
|
|
33
|
+
spansByParagraph.set(span.paragraph, []);
|
|
34
|
+
}
|
|
35
|
+
spansByParagraph.get(span.paragraph).push(span);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const infos = [];
|
|
39
|
+
let runningOffset = 0;
|
|
40
|
+
|
|
41
|
+
paragraphs.forEach((p, index) => {
|
|
42
|
+
const spans = (spansByParagraph.get(p) || []).slice().sort((a, b) => a.charStart - b.charStart);
|
|
43
|
+
const text = buildParagraphTextFromSpans(spans);
|
|
44
|
+
const normalizedText = normalizeParagraphComparisonText(text);
|
|
45
|
+
const normalizedTrim = normalizedText.trim();
|
|
46
|
+
|
|
47
|
+
infos.push({
|
|
48
|
+
paragraph: p,
|
|
49
|
+
spans,
|
|
50
|
+
text,
|
|
51
|
+
normalizedText,
|
|
52
|
+
normalizedTrim,
|
|
53
|
+
startOffset: runningOffset
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
runningOffset += normalizedText.length;
|
|
57
|
+
runningOffset = advanceOffsetForParagraphBoundary(runningOffset, index, paragraphs.length);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return infos;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Finds a paragraph info entry that matches the provided text.
|
|
65
|
+
*
|
|
66
|
+
* @param {Array} paragraphInfos - Paragraph metadata
|
|
67
|
+
* @param {string} originalText - Original text
|
|
68
|
+
* @returns {Object|null}
|
|
69
|
+
*/
|
|
70
|
+
export function findMatchingParagraphInfo(paragraphInfos, originalText) {
|
|
71
|
+
if (!originalText) return null;
|
|
72
|
+
|
|
73
|
+
const normalizedOriginal = normalizeParagraphComparisonText(originalText);
|
|
74
|
+
const normalizedTrim = normalizedOriginal.trim();
|
|
75
|
+
if (!normalizedTrim) return null;
|
|
76
|
+
|
|
77
|
+
for (const info of paragraphInfos) {
|
|
78
|
+
if (info.normalizedText === normalizedOriginal) {
|
|
79
|
+
return info;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const info of paragraphInfos) {
|
|
84
|
+
if (info.normalizedTrim === normalizedTrim) {
|
|
85
|
+
return info;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Finds the best target paragraph and offset for format-only operations.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array} paragraphInfos - Paragraph metadata
|
|
96
|
+
* @param {string} originalText - Original text used for matching
|
|
97
|
+
* @returns {{ targetInfo: Object|null, matchOffset: number }}
|
|
98
|
+
*/
|
|
99
|
+
export function findTargetParagraphInfo(paragraphInfos, originalText) {
|
|
100
|
+
const normalizedOriginalFull = normalizeParagraphComparisonText(originalText);
|
|
101
|
+
const normalizedOriginalTrim = normalizedOriginalFull.trim();
|
|
102
|
+
|
|
103
|
+
let targetInfo = null;
|
|
104
|
+
let matchOffset = 0;
|
|
105
|
+
|
|
106
|
+
for (const info of paragraphInfos) {
|
|
107
|
+
if (info.normalizedText === normalizedOriginalFull) {
|
|
108
|
+
targetInfo = info;
|
|
109
|
+
return { targetInfo, matchOffset };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (normalizedOriginalTrim.length > 0) {
|
|
114
|
+
for (const info of paragraphInfos) {
|
|
115
|
+
if (info.normalizedTrim === normalizedOriginalTrim) {
|
|
116
|
+
targetInfo = info;
|
|
117
|
+
return { targetInfo, matchOffset };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (normalizedOriginalTrim.length > 0) {
|
|
123
|
+
const docPlain = paragraphInfos.map(info => info.normalizedText).join('\n');
|
|
124
|
+
const idx = docPlain.indexOf(normalizedOriginalTrim);
|
|
125
|
+
if (idx !== -1) {
|
|
126
|
+
for (const info of paragraphInfos) {
|
|
127
|
+
const start = info.startOffset;
|
|
128
|
+
const length = info.normalizedText.length;
|
|
129
|
+
if (idx >= start && idx <= start + length) {
|
|
130
|
+
targetInfo = info;
|
|
131
|
+
matchOffset = idx - start;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Fallback: single-paragraph scope where extracted span text is only a subset
|
|
139
|
+
// of original paragraph text (common when prior tracked wrappers contain text
|
|
140
|
+
// that is not represented in direct run-span extraction).
|
|
141
|
+
if (!targetInfo && paragraphInfos.length === 1 && normalizedOriginalFull.length > 0) {
|
|
142
|
+
const onlyInfo = paragraphInfos[0];
|
|
143
|
+
const paragraphTrim = onlyInfo.normalizedTrim || '';
|
|
144
|
+
if (paragraphTrim.length > 0) {
|
|
145
|
+
const subsetIndex = normalizedOriginalFull.indexOf(paragraphTrim);
|
|
146
|
+
if (subsetIndex >= 0) {
|
|
147
|
+
targetInfo = onlyInfo;
|
|
148
|
+
matchOffset = -subsetIndex;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { targetInfo, matchOffset };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Walks up the DOM to find the containing paragraph for a run.
|
|
158
|
+
*
|
|
159
|
+
* @param {Node} node - Starting node
|
|
160
|
+
* @returns {Element|null}
|
|
161
|
+
*/
|
|
162
|
+
export function getContainingParagraph(node) {
|
|
163
|
+
let current = node;
|
|
164
|
+
while (current) {
|
|
165
|
+
if (isWordElement(current, 'p')) return current;
|
|
166
|
+
current = current.parentNode;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Reconstructs human-readable text from spans to align with Word paragraph text.
|
|
173
|
+
*
|
|
174
|
+
* @param {Array} spans - Span collection
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function buildParagraphTextFromSpans(spans) {
|
|
178
|
+
if (!spans || spans.length === 0) return '';
|
|
179
|
+
|
|
180
|
+
let text = '';
|
|
181
|
+
for (const span of spans) {
|
|
182
|
+
if (!span || !span.textElement) continue;
|
|
183
|
+
const textElement = span.textElement;
|
|
184
|
+
if (isWordElement(textElement, 't')) {
|
|
185
|
+
text += span.textElement.textContent || '';
|
|
186
|
+
} else if (isWordElement(textElement, 'tab')) {
|
|
187
|
+
text += '\t';
|
|
188
|
+
} else if (isWordElement(textElement, 'br') || isWordElement(textElement, 'cr')) {
|
|
189
|
+
text += '\n';
|
|
190
|
+
} else if (isWordElement(textElement, 'noBreakHyphen')) {
|
|
191
|
+
text += '\u2011';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalizes paragraph text for comparisons (handles carriage returns and NBSP).
|
|
199
|
+
*
|
|
200
|
+
* @param {string} text - Text input
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
function normalizeParagraphComparisonText(text) {
|
|
204
|
+
if (!text) return '';
|
|
205
|
+
return text
|
|
206
|
+
.replace(/\r/g, '\n')
|
|
207
|
+
.replace(/\u00a0/g, ' ');
|
|
208
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span-level formatting helpers.
|
|
3
|
+
*
|
|
4
|
+
* Handles boundary splitting and robust format application on already-extracted
|
|
5
|
+
* text spans without owning paragraph targeting concerns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mergeFormats } from '../pipeline/markdown-processor.js';
|
|
9
|
+
import { injectFormattingToRPr, createTextRun } from './run-builders.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Splits spans at all supplied absolute boundaries.
|
|
13
|
+
*
|
|
14
|
+
* @param {Document} xmlDoc - XML document
|
|
15
|
+
* @param {Array} textSpans - Input spans
|
|
16
|
+
* @param {number[]} boundaries - Absolute boundaries
|
|
17
|
+
* @returns {Array}
|
|
18
|
+
*/
|
|
19
|
+
export function splitSpansAtBoundaries(xmlDoc, textSpans, boundaries) {
|
|
20
|
+
const sortedBoundaries = Array.from(new Set(boundaries)).sort((a, b) => a - b);
|
|
21
|
+
if (sortedBoundaries.length === 0 || textSpans.length === 0) {
|
|
22
|
+
return [...textSpans];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const orderedSpans = [...textSpans].sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
|
|
26
|
+
const splitSpans = [];
|
|
27
|
+
let boundaryIndex = 0;
|
|
28
|
+
|
|
29
|
+
for (const span of orderedSpans) {
|
|
30
|
+
while (boundaryIndex < sortedBoundaries.length && sortedBoundaries[boundaryIndex] <= span.charStart) {
|
|
31
|
+
boundaryIndex++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let currentSpan = span;
|
|
35
|
+
while (boundaryIndex < sortedBoundaries.length && sortedBoundaries[boundaryIndex] < currentSpan.charEnd) {
|
|
36
|
+
const boundary = sortedBoundaries[boundaryIndex];
|
|
37
|
+
const splitResult = splitSpanAtOffset(xmlDoc, currentSpan, boundary);
|
|
38
|
+
|
|
39
|
+
if (!splitResult) {
|
|
40
|
+
boundaryIndex++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
splitSpans.push(splitResult[0]);
|
|
45
|
+
currentSpan = splitResult[1];
|
|
46
|
+
boundaryIndex++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
splitSpans.push(currentSpan);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return splitSpans;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Robust formatting application.
|
|
57
|
+
* Identifies all boundaries, splits all runs first, then applies merged formats.
|
|
58
|
+
*
|
|
59
|
+
* @param {Document} xmlDoc - XML document
|
|
60
|
+
* @param {Array} textSpans - Text spans
|
|
61
|
+
* @param {Array} formatHints - Format hints
|
|
62
|
+
* @param {string} author - Change author
|
|
63
|
+
* @param {boolean} generateRedlines - Track change toggle
|
|
64
|
+
* @returns {void}
|
|
65
|
+
*/
|
|
66
|
+
export function applyFormatHintsToSpansRobust(xmlDoc, textSpans, formatHints, author, generateRedlines) {
|
|
67
|
+
if (textSpans.length === 0) return;
|
|
68
|
+
|
|
69
|
+
const boundaries = [];
|
|
70
|
+
for (const hint of formatHints) {
|
|
71
|
+
boundaries.push(hint.start, hint.end);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const currentSpans = splitSpansAtBoundaries(xmlDoc, textSpans, boundaries)
|
|
75
|
+
.sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
|
|
76
|
+
const getOverlappingHints = createFormatHintOverlapLookup(formatHints);
|
|
77
|
+
|
|
78
|
+
for (const span of currentSpans) {
|
|
79
|
+
const applicableHints = getOverlappingHints(span.charStart, span.charEnd);
|
|
80
|
+
if (applicableHints.length > 0) {
|
|
81
|
+
const targetFormat = mergeFormats(...applicableHints.map(h => h.format));
|
|
82
|
+
addFormattingToRun(xmlDoc, span.runElement, targetFormat, author, generateRedlines);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createFormatHintOverlapLookup(formatHints) {
|
|
88
|
+
const sortedHints = (formatHints || [])
|
|
89
|
+
.slice()
|
|
90
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
91
|
+
|
|
92
|
+
const activeHints = [];
|
|
93
|
+
let nextHintIndex = 0;
|
|
94
|
+
|
|
95
|
+
return (start, end) => {
|
|
96
|
+
while (nextHintIndex < sortedHints.length && sortedHints[nextHintIndex].start < end) {
|
|
97
|
+
activeHints.push(sortedHints[nextHintIndex]);
|
|
98
|
+
nextHintIndex++;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (let i = activeHints.length - 1; i >= 0; i--) {
|
|
102
|
+
if (activeHints[i].end <= start) {
|
|
103
|
+
activeHints.splice(i, 1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const overlaps = [];
|
|
108
|
+
for (const hint of activeHints) {
|
|
109
|
+
if (hint.start < end && hint.end > start) {
|
|
110
|
+
overlaps.push(hint);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return overlaps;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Splits a text span at a specific absolute character offset.
|
|
119
|
+
* Modifies the DOM and returns the two new span objects.
|
|
120
|
+
*
|
|
121
|
+
* @param {Document} xmlDoc - XML document
|
|
122
|
+
* @param {Object} span - Text span
|
|
123
|
+
* @param {number} absoluteOffset - Absolute split offset
|
|
124
|
+
* @returns {Array|null}
|
|
125
|
+
*/
|
|
126
|
+
export function splitSpanAtOffset(xmlDoc, span, absoluteOffset) {
|
|
127
|
+
const run = span.runElement;
|
|
128
|
+
const parent = run.parentNode;
|
|
129
|
+
if (!parent) return null;
|
|
130
|
+
|
|
131
|
+
const fullText = span.textElement.textContent || '';
|
|
132
|
+
const localSplitPoint = absoluteOffset - span.charStart;
|
|
133
|
+
|
|
134
|
+
const textBefore = fullText.substring(0, localSplitPoint);
|
|
135
|
+
const textAfter = fullText.substring(localSplitPoint);
|
|
136
|
+
|
|
137
|
+
if (textBefore.length === 0 || textAfter.length === 0) return null;
|
|
138
|
+
|
|
139
|
+
const runBefore = createTextRun(xmlDoc, textBefore, span.rPr, false);
|
|
140
|
+
const runAfter = createTextRun(xmlDoc, textAfter, span.rPr, false);
|
|
141
|
+
|
|
142
|
+
parent.insertBefore(runBefore, run);
|
|
143
|
+
parent.insertBefore(runAfter, run);
|
|
144
|
+
parent.removeChild(run);
|
|
145
|
+
|
|
146
|
+
const tBefore = runBefore.getElementsByTagName('w:t')[0];
|
|
147
|
+
const tAfter = runAfter.getElementsByTagName('w:t')[0];
|
|
148
|
+
|
|
149
|
+
return [
|
|
150
|
+
{ ...span, charEnd: absoluteOffset, textElement: tBefore, runElement: runBefore },
|
|
151
|
+
{ ...span, charStart: absoluteOffset, textElement: tAfter, runElement: runAfter }
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Adds formatting elements to a run's rPr, with track change support.
|
|
157
|
+
*
|
|
158
|
+
* @param {Document} xmlDoc - XML document
|
|
159
|
+
* @param {Element} run - Run element
|
|
160
|
+
* @param {Object} format - Format flags
|
|
161
|
+
* @param {string} author - Change author
|
|
162
|
+
* @param {boolean} generateRedlines - Track change toggle
|
|
163
|
+
* @returns {void}
|
|
164
|
+
*/
|
|
165
|
+
function addFormattingToRun(xmlDoc, run, format, author, generateRedlines) {
|
|
166
|
+
let rPr = run.getElementsByTagName('w:rPr')[0];
|
|
167
|
+
const baseRPr = rPr ? rPr.cloneNode(true) : null;
|
|
168
|
+
|
|
169
|
+
if (!rPr) {
|
|
170
|
+
rPr = xmlDoc.createElement('w:rPr');
|
|
171
|
+
run.insertBefore(rPr, run.firstChild);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const newRPr = injectFormattingToRPr(xmlDoc, baseRPr, format, author, generateRedlines);
|
|
175
|
+
|
|
176
|
+
while (rPr.firstChild) rPr.removeChild(rPr.firstChild);
|
|
177
|
+
Array.from(newRPr.childNodes).forEach(child => rPr.appendChild(child));
|
|
178
|
+
}
|