@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.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. 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
+ }