@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,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
+ }