@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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment text location and marker injection helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NS_W } from '../core/types.js';
|
|
6
|
+
import { getElementsByTag, getFirstElementByTag } from '../core/xml-query.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds a paragraph text index in a single pass for repeated lookups.
|
|
10
|
+
*
|
|
11
|
+
* @param {Element} paragraph - w:p element
|
|
12
|
+
* @returns {{ fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }}
|
|
13
|
+
*/
|
|
14
|
+
export function createParagraphTextIndex(paragraph) {
|
|
15
|
+
const runs = getElementsByTag(paragraph, 'w:r');
|
|
16
|
+
const runOffsets = [];
|
|
17
|
+
let fullText = '';
|
|
18
|
+
|
|
19
|
+
for (const run of runs) {
|
|
20
|
+
const start = fullText.length;
|
|
21
|
+
const textNodes = getElementsByTag(run, 'w:t');
|
|
22
|
+
for (const textNode of textNodes) {
|
|
23
|
+
fullText += textNode.textContent || '';
|
|
24
|
+
}
|
|
25
|
+
runOffsets.push({ run, start, end: fullText.length });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { fullText, runOffsets };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Finds text within a prebuilt paragraph text index.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }} paragraphIndex - Prebuilt index
|
|
35
|
+
* @param {string} searchText - Text to locate
|
|
36
|
+
* @returns {{ found: boolean, startRun?: Element, startOffset?: number, endRun?: Element, endOffset?: number }}
|
|
37
|
+
*/
|
|
38
|
+
export function findTextInParagraphIndex(paragraphIndex, searchText) {
|
|
39
|
+
const searchIndex = paragraphIndex.fullText.indexOf(searchText);
|
|
40
|
+
if (searchIndex === -1) {
|
|
41
|
+
return { found: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const searchEnd = searchIndex + searchText.length;
|
|
45
|
+
let startRun = null;
|
|
46
|
+
let endRun = null;
|
|
47
|
+
let startOffset = 0;
|
|
48
|
+
let endOffset = 0;
|
|
49
|
+
|
|
50
|
+
for (const { run, start, end } of paragraphIndex.runOffsets) {
|
|
51
|
+
if (searchIndex >= start && searchIndex < end) {
|
|
52
|
+
startRun = run;
|
|
53
|
+
startOffset = searchIndex - start;
|
|
54
|
+
}
|
|
55
|
+
if (searchEnd > start && searchEnd <= end) {
|
|
56
|
+
endRun = run;
|
|
57
|
+
endOffset = searchEnd - start;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
found: true,
|
|
63
|
+
startRun,
|
|
64
|
+
startOffset,
|
|
65
|
+
endRun,
|
|
66
|
+
endOffset
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cloneRunWithText(xmlDoc, rPr, newText) {
|
|
71
|
+
const newRun = xmlDoc.createElementNS(NS_W, 'w:r');
|
|
72
|
+
if (rPr) {
|
|
73
|
+
newRun.appendChild(rPr.cloneNode(true));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const newTextNode = xmlDoc.createElementNS(NS_W, 'w:t');
|
|
77
|
+
newTextNode.setAttribute('xml:space', 'preserve');
|
|
78
|
+
newTextNode.textContent = newText;
|
|
79
|
+
newRun.appendChild(newTextNode);
|
|
80
|
+
return newRun;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Injects comment markers around text in a paragraph.
|
|
85
|
+
*
|
|
86
|
+
* @param {Document} xmlDoc - XML document
|
|
87
|
+
* @param {Element} paragraph - w:p element
|
|
88
|
+
* @param {string} textToFind - Target text
|
|
89
|
+
* @param {number} commentId - Comment id
|
|
90
|
+
* @param {{ fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }|null} [paragraphIndex=null] - Optional prebuilt index
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commentId, paragraphIndex = null) {
|
|
94
|
+
const activeIndex = paragraphIndex || createParagraphTextIndex(paragraph);
|
|
95
|
+
const location = findTextInParagraphIndex(activeIndex, textToFind);
|
|
96
|
+
if (!location.found || !location.startRun) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const startMarker = xmlDoc.createElementNS(NS_W, 'w:commentRangeStart');
|
|
101
|
+
startMarker.setAttribute('w:id', String(commentId));
|
|
102
|
+
|
|
103
|
+
const endMarker = xmlDoc.createElementNS(NS_W, 'w:commentRangeEnd');
|
|
104
|
+
endMarker.setAttribute('w:id', String(commentId));
|
|
105
|
+
|
|
106
|
+
const referenceRun = xmlDoc.createElementNS(NS_W, 'w:r');
|
|
107
|
+
const reference = xmlDoc.createElementNS(NS_W, 'w:commentReference');
|
|
108
|
+
reference.setAttribute('w:id', String(commentId));
|
|
109
|
+
referenceRun.appendChild(reference);
|
|
110
|
+
|
|
111
|
+
if (location.startRun === location.endRun) {
|
|
112
|
+
const run = location.startRun;
|
|
113
|
+
const textNode = getFirstElementByTag(run, 'w:t');
|
|
114
|
+
if (!textNode) {
|
|
115
|
+
run.parentNode.insertBefore(startMarker, run);
|
|
116
|
+
if (run.nextSibling) {
|
|
117
|
+
run.parentNode.insertBefore(endMarker, run.nextSibling);
|
|
118
|
+
endMarker.parentNode.insertBefore(referenceRun, endMarker.nextSibling);
|
|
119
|
+
} else {
|
|
120
|
+
run.parentNode.appendChild(endMarker);
|
|
121
|
+
run.parentNode.appendChild(referenceRun);
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fullText = textNode.textContent || '';
|
|
127
|
+
const beforeText = fullText.substring(0, location.startOffset);
|
|
128
|
+
const highlightedText = fullText.substring(location.startOffset, location.endOffset);
|
|
129
|
+
const afterText = fullText.substring(location.endOffset);
|
|
130
|
+
const rPr = getFirstElementByTag(run, 'w:rPr');
|
|
131
|
+
const parent = run.parentNode;
|
|
132
|
+
|
|
133
|
+
if (beforeText) {
|
|
134
|
+
parent.insertBefore(cloneRunWithText(xmlDoc, rPr, beforeText), run);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
parent.insertBefore(startMarker, run);
|
|
138
|
+
textNode.textContent = highlightedText;
|
|
139
|
+
|
|
140
|
+
if (run.nextSibling) {
|
|
141
|
+
parent.insertBefore(endMarker, run.nextSibling);
|
|
142
|
+
} else {
|
|
143
|
+
parent.appendChild(endMarker);
|
|
144
|
+
}
|
|
145
|
+
parent.insertBefore(referenceRun, endMarker.nextSibling || null);
|
|
146
|
+
|
|
147
|
+
if (afterText) {
|
|
148
|
+
parent.insertBefore(cloneRunWithText(xmlDoc, rPr, afterText), referenceRun.nextSibling || null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const startTextNode = getFirstElementByTag(location.startRun, 'w:t');
|
|
155
|
+
if (startTextNode && location.startOffset > 0) {
|
|
156
|
+
const fullText = startTextNode.textContent || '';
|
|
157
|
+
const beforeText = fullText.substring(0, location.startOffset);
|
|
158
|
+
const highlightedStart = fullText.substring(location.startOffset);
|
|
159
|
+
|
|
160
|
+
if (beforeText) {
|
|
161
|
+
const rPr = getFirstElementByTag(location.startRun, 'w:rPr');
|
|
162
|
+
location.startRun.parentNode.insertBefore(cloneRunWithText(xmlDoc, rPr, beforeText), location.startRun);
|
|
163
|
+
}
|
|
164
|
+
startTextNode.textContent = highlightedStart;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
location.startRun.parentNode.insertBefore(startMarker, location.startRun);
|
|
168
|
+
|
|
169
|
+
const endRun = location.endRun || location.startRun;
|
|
170
|
+
const endTextNode = getFirstElementByTag(endRun, 'w:t');
|
|
171
|
+
if (endTextNode && location.endOffset < (endTextNode.textContent || '').length) {
|
|
172
|
+
const fullText = endTextNode.textContent || '';
|
|
173
|
+
const highlightedEnd = fullText.substring(0, location.endOffset);
|
|
174
|
+
const afterText = fullText.substring(location.endOffset);
|
|
175
|
+
|
|
176
|
+
endTextNode.textContent = highlightedEnd;
|
|
177
|
+
|
|
178
|
+
if (afterText) {
|
|
179
|
+
const rPr = getFirstElementByTag(endRun, 'w:rPr');
|
|
180
|
+
if (endRun.nextSibling) {
|
|
181
|
+
endRun.parentNode.insertBefore(cloneRunWithText(xmlDoc, rPr, afterText), endRun.nextSibling);
|
|
182
|
+
} else {
|
|
183
|
+
endRun.parentNode.appendChild(cloneRunWithText(xmlDoc, rPr, afterText));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (endRun.nextSibling) {
|
|
189
|
+
endRun.parentNode.insertBefore(endMarker, endRun.nextSibling);
|
|
190
|
+
endMarker.parentNode.insertBefore(referenceRun, endMarker.nextSibling);
|
|
191
|
+
} else {
|
|
192
|
+
endRun.parentNode.appendChild(endMarker);
|
|
193
|
+
endRun.parentNode.appendChild(referenceRun);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment package builders and pkg:part wiring.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createParser, createSerializer } from '../adapters/xml-adapter.js';
|
|
6
|
+
import { error as logError } from '../adapters/logger.js';
|
|
7
|
+
import { buildDocumentCommentsPackage, buildParagraphCommentsPackage } from './package-builder.js';
|
|
8
|
+
import { getElementsByTagNS, getXmlParseError } from '../core/xml-query.js';
|
|
9
|
+
|
|
10
|
+
const PKG_NS = 'http://schemas.microsoft.com/office/2006/xmlPackage';
|
|
11
|
+
const RELS_NS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wraps paragraph XML with minimal package structure including comments part.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} paragraphXml - Paragraph XML content
|
|
17
|
+
* @param {string} commentsXml - comments.xml payload
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function wrapParagraphWithComments(paragraphXml, commentsXml) {
|
|
21
|
+
return buildParagraphCommentsPackage(paragraphXml, commentsXml);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Injects comments.xml and relationship entry into an existing pkg:package.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} packageOxml - Existing package XML
|
|
28
|
+
* @param {string} commentsXml - comments.xml payload
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function injectCommentsIntoPackage(packageOxml, commentsXml) {
|
|
32
|
+
const parser = createParser();
|
|
33
|
+
const serializer = createSerializer();
|
|
34
|
+
const pkgDoc = parser.parseFromString(packageOxml, 'text/xml');
|
|
35
|
+
|
|
36
|
+
const parseError = getXmlParseError(pkgDoc);
|
|
37
|
+
if (parseError) {
|
|
38
|
+
logError('[CommentEngine] Failed to parse package:', parseError.textContent);
|
|
39
|
+
return packageOxml;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pkgPackage = pkgDoc.documentElement;
|
|
43
|
+
|
|
44
|
+
const commentsPart = pkgDoc.createElementNS(PKG_NS, 'pkg:part');
|
|
45
|
+
commentsPart.setAttribute('pkg:name', '/word/comments.xml');
|
|
46
|
+
commentsPart.setAttribute('pkg:contentType', 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml');
|
|
47
|
+
|
|
48
|
+
const commentsXmlData = pkgDoc.createElementNS(PKG_NS, 'pkg:xmlData');
|
|
49
|
+
const commentsDoc = parser.parseFromString(commentsXml, 'text/xml');
|
|
50
|
+
commentsXmlData.appendChild(pkgDoc.importNode(commentsDoc.documentElement, true));
|
|
51
|
+
commentsPart.appendChild(commentsXmlData);
|
|
52
|
+
pkgPackage.appendChild(commentsPart);
|
|
53
|
+
|
|
54
|
+
const parts = getElementsByTagNS(pkgPackage, PKG_NS, 'part');
|
|
55
|
+
const docRelsPart = parts.find(part => part.getAttribute('pkg:name') === '/word/_rels/document.xml.rels');
|
|
56
|
+
|
|
57
|
+
if (docRelsPart) {
|
|
58
|
+
const xmlDataNodes = getElementsByTagNS(docRelsPart, PKG_NS, 'xmlData');
|
|
59
|
+
if (xmlDataNodes.length > 0) {
|
|
60
|
+
const relsNodes = getElementsByTagNS(xmlDataNodes[0], RELS_NS, 'Relationships');
|
|
61
|
+
if (relsNodes.length > 0) {
|
|
62
|
+
const relationships = relsNodes[0];
|
|
63
|
+
const existingRels = getElementsByTagNS(relationships, RELS_NS, 'Relationship');
|
|
64
|
+
const hasCommentsRel = existingRels.some(rel =>
|
|
65
|
+
rel.getAttribute('Type')?.includes('comments')
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (!hasCommentsRel) {
|
|
69
|
+
let maxId = 0;
|
|
70
|
+
existingRels.forEach(rel => {
|
|
71
|
+
const id = rel.getAttribute('Id');
|
|
72
|
+
const idNumber = parseInt(id?.replace('rId', '') || '0', 10);
|
|
73
|
+
if (idNumber > maxId) maxId = idNumber;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const newRel = pkgDoc.createElementNS(RELS_NS, 'Relationship');
|
|
77
|
+
newRel.setAttribute('Id', `rId${maxId + 1}`);
|
|
78
|
+
newRel.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments');
|
|
79
|
+
newRel.setAttribute('Target', 'comments.xml');
|
|
80
|
+
relationships.appendChild(newRel);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
const newDocRelsPart = pkgDoc.createElementNS(PKG_NS, 'pkg:part');
|
|
86
|
+
newDocRelsPart.setAttribute('pkg:name', '/word/_rels/document.xml.rels');
|
|
87
|
+
newDocRelsPart.setAttribute('pkg:contentType', 'application/vnd.openxmlformats-package.relationships+xml');
|
|
88
|
+
|
|
89
|
+
const relsXmlData = pkgDoc.createElementNS(PKG_NS, 'pkg:xmlData');
|
|
90
|
+
const relationships = pkgDoc.createElementNS(RELS_NS, 'Relationships');
|
|
91
|
+
const commentsRel = pkgDoc.createElementNS(RELS_NS, 'Relationship');
|
|
92
|
+
commentsRel.setAttribute('Id', 'rId1');
|
|
93
|
+
commentsRel.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments');
|
|
94
|
+
commentsRel.setAttribute('Target', 'comments.xml');
|
|
95
|
+
relationships.appendChild(commentsRel);
|
|
96
|
+
relsXmlData.appendChild(relationships);
|
|
97
|
+
newDocRelsPart.appendChild(relsXmlData);
|
|
98
|
+
pkgPackage.appendChild(newDocRelsPart);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return serializer.serializeToString(pkgDoc);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @deprecated Use injectCommentsIntoPackage instead.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} documentXml - Document OOXML
|
|
108
|
+
* @param {string} commentsXml - comments.xml payload
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function wrapWithCommentsPart(documentXml, commentsXml) {
|
|
112
|
+
return buildDocumentCommentsPackage(documentXml, commentsXml);
|
|
113
|
+
}
|