@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,395 @@
1
+ /**
2
+ * OOXML Reconciliation Pipeline - Serialization
3
+ *
4
+ * Converts patched run model back to OOXML with track changes.
5
+ */
6
+
7
+ import { RunKind, escapeXml, createRevisionMetadata } from '../core/types.js';
8
+ import { getApplicableFormatHints } from './markdown-processor.js';
9
+ import { serializeXml } from '../adapters/xml-adapter.js';
10
+ import { warn } from '../adapters/logger.js';
11
+ import { buildDocumentFragmentPackage } from '../services/package-builder.js';
12
+ import { getDefaultAuthor } from '../adapters/config.js';
13
+
14
+ const XMLNS_ATTR_REGEX = /\s+xmlns:[^=]+="[^"]*"/g;
15
+
16
+ /**
17
+ * Serializes a patched run model to OOXML.
18
+ *
19
+ * @param {import('../core/types.js').RunEntry[]} patchedModel - The patched run model
20
+ * @param {Element|null} pPr - Paragraph properties element
21
+ * @param {import('../core/types.js').FormatHint[]} [formatHints=[]] - Format hints
22
+ * @param {import('../core/types.js').SerializationOptions} [options={}] - Serialization options
23
+ * @returns {string} OOXML paragraph string (WITHOUT namespace - added by wrapper)
24
+ */
25
+ export function serializeToOoxml(patchedModel, pPr, formatHints = [], options = {}) {
26
+ const serializationOptions = normalizeSerializationOptions(options);
27
+ const { author, generateRedlines } = serializationOptions;
28
+ const paragraphs = [];
29
+ let currentPPrXml = '';
30
+ let currentPPrElement = null;
31
+ let currentRuns = [];
32
+
33
+ // Helper to flush accumulated runs into a paragraph
34
+ function flushParagraph() {
35
+ if (currentRuns.length > 0 || paragraphs.length === 0) {
36
+ // Build paragraph properties - handle both string and DOM element
37
+ let pPrContent = '';
38
+ if (currentPPrXml) {
39
+ pPrContent = stripNamespaceDeclarations(currentPPrXml);
40
+ } else if (currentPPrElement) {
41
+ currentPPrXml = stripNamespaceDeclarations(serializeXml(currentPPrElement));
42
+ pPrContent = currentPPrXml;
43
+ } else if (pPr) {
44
+ // Fallback to legacy pPr if no PARAGRAPH_START was seen
45
+ if (typeof pPr === 'string') {
46
+ pPrContent = pPr;
47
+ } else {
48
+ pPrContent = serializeXml(pPr);
49
+ }
50
+ pPrContent = stripNamespaceDeclarations(pPrContent);
51
+ }
52
+ paragraphs.push(`<w:p>${pPrContent}${currentRuns.join('')}</w:p>`);
53
+ currentRuns = [];
54
+ }
55
+ }
56
+
57
+ for (const item of patchedModel) {
58
+ switch (item.kind) {
59
+ case RunKind.PARAGRAPH_START:
60
+ // Flush previous paragraph before starting a new one
61
+ if (currentRuns.length > 0 || paragraphs.length > 0) {
62
+ flushParagraph();
63
+ }
64
+ currentPPrXml = item.pPrXml || '';
65
+ currentPPrElement = item.pPrElement || null;
66
+ break;
67
+
68
+ case RunKind.TEXT:
69
+ currentRuns.push(buildRunXmlWithHints(item, formatHints, serializationOptions));
70
+ break;
71
+
72
+ case RunKind.DELETION:
73
+ if (generateRedlines) {
74
+ currentRuns.push(buildDeletionXml(item, serializationOptions));
75
+ }
76
+ // If redlines are disabled, we simply omit the deleted content
77
+ break;
78
+
79
+ case RunKind.INSERTION:
80
+ if (generateRedlines) {
81
+ currentRuns.push(buildInsertionXml(item, formatHints, serializationOptions));
82
+ } else {
83
+ // Treat insertion as a normal run when redlines are disabled
84
+ currentRuns.push(buildRunXmlWithHints(item, formatHints, serializationOptions));
85
+ }
86
+ break;
87
+
88
+ case RunKind.BOOKMARK:
89
+ case RunKind.HYPERLINK:
90
+ // Pass through original XML - but strip any namespace declarations
91
+ if (item.nodeXml) {
92
+ currentRuns.push(stripNamespaceDeclarations(item.nodeXml));
93
+ }
94
+ break;
95
+
96
+ case RunKind.CONTAINER_START:
97
+ if (item.containerKind === 'sdt') {
98
+ currentRuns.push(`<w:sdt>${item.propertiesXml}<w:sdtContent>`);
99
+ } else if (item.containerKind === 'smartTag') {
100
+ currentRuns.push(`<w:smartTag ${item.propertiesXml}>`);
101
+ } else if (item.containerKind === 'hyperlink') {
102
+ const props = JSON.parse(item.propertiesXml);
103
+ const rIdAttr = props.rId ? ` r:id="${props.rId}"` : '';
104
+ const anchorAttr = props.anchor ? ` w:anchor="${props.anchor}"` : '';
105
+ currentRuns.push(`<w:hyperlink${rIdAttr}${anchorAttr}>`);
106
+ }
107
+ break;
108
+
109
+ case RunKind.CONTAINER_END:
110
+ if (item.containerKind === 'sdt') {
111
+ currentRuns.push(`</w:sdtContent></w:sdt>`);
112
+ } else if (item.containerKind === 'smartTag') {
113
+ currentRuns.push(`</w:smartTag>`);
114
+ } else if (item.containerKind === 'hyperlink') {
115
+ currentRuns.push(`</w:hyperlink>`);
116
+ }
117
+ break;
118
+
119
+ default:
120
+ warn('Unknown run kind:', item.kind);
121
+ }
122
+ }
123
+
124
+ // Flush final paragraph
125
+ flushParagraph();
126
+
127
+ // Return all paragraphs WITHOUT namespace - wrapper will add it
128
+ return paragraphs.join('');
129
+ }
130
+
131
+ /**
132
+ * Normalizes serialization options to a single object contract.
133
+ *
134
+ * @param {import('../core/types.js').SerializationOptions|string|undefined|null} options - Raw options
135
+ * @returns {import('../core/types.js').SerializationOptions}
136
+ */
137
+ function normalizeSerializationOptions(options) {
138
+ // Backward compatibility: accept legacy `font` string signature.
139
+ if (typeof options === 'string') {
140
+ return {
141
+ author: getDefaultAuthor(),
142
+ generateRedlines: true,
143
+ font: options
144
+ };
145
+ }
146
+
147
+ const normalized = options && typeof options === 'object' ? options : {};
148
+ const resolvedAuthor = typeof normalized.author === 'string' && normalized.author.trim()
149
+ ? normalized.author.trim()
150
+ : getDefaultAuthor();
151
+
152
+ return {
153
+ author: resolvedAuthor,
154
+ generateRedlines: normalized.generateRedlines ?? true,
155
+ font: normalized.font ?? null
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Builds a run XML element, applying format hints if applicable.
161
+ *
162
+ * @param {import('../core/types.js').RunEntry} item - Run entry
163
+ * @param {import('../core/types.js').FormatHint[]} formatHints - Format hints
164
+ * @param {import('../core/types.js').SerializationOptions} options - Serialization options
165
+ * @returns {string}
166
+ */
167
+ function buildRunXmlWithHints(item, formatHints, options = {}) {
168
+ const applicableHints = getApplicableFormatHints(formatHints, item.startOffset, item.endOffset);
169
+ const font = options?.font ?? null;
170
+ let cleanRPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
171
+
172
+ if (font) {
173
+ cleanRPr = applyFont(cleanRPr, font);
174
+ }
175
+
176
+ if (applicableHints.length === 0) {
177
+ // No formatting changes - use original rPr (strip namespace)
178
+ return buildSimpleRun(item.text, cleanRPr);
179
+ }
180
+
181
+ // Split the run text at format boundaries and apply hints
182
+ const runs = [];
183
+ let pos = 0;
184
+ const text = item.text;
185
+ const baseOffset = item.startOffset;
186
+
187
+ for (const hint of applicableHints) {
188
+ const localStart = Math.max(0, hint.start - baseOffset);
189
+ const localEnd = Math.min(text.length, hint.end - baseOffset);
190
+
191
+ // Text before the hint
192
+ if (localStart > pos) {
193
+ runs.push(buildSimpleRun(text.slice(pos, localStart), cleanRPr));
194
+ }
195
+
196
+ // Formatted text
197
+ const formattedRPr = injectFormatting(cleanRPr, hint.format);
198
+ runs.push(buildSimpleRun(text.slice(localStart, localEnd), formattedRPr));
199
+ pos = localEnd;
200
+ }
201
+
202
+ // Remaining text after last hint
203
+ if (pos < text.length) {
204
+ runs.push(buildSimpleRun(text.slice(pos), cleanRPr));
205
+ }
206
+
207
+ return runs.join('');
208
+ }
209
+
210
+ /**
211
+ * Builds a simple w:r element.
212
+ *
213
+ * @param {string} text - Text content
214
+ * @param {string} rPrXml - Run properties XML
215
+ * @returns {string}
216
+ */
217
+ function buildSimpleRun(text, rPrXml) {
218
+ if (!text) return '';
219
+ const rPr = rPrXml || '';
220
+ return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
221
+ }
222
+
223
+ /**
224
+ * Builds a deletion (w:del) element.
225
+ *
226
+ * @param {import('../core/types.js').RunEntry} item - Deletion entry
227
+ * @param {import('../core/types.js').SerializationOptions} options - Serialization options
228
+ * @returns {string}
229
+ */
230
+ function buildDeletionXml(item, options = {}) {
231
+ const metadata = createRevisionMetadata(options.author ?? getDefaultAuthor());
232
+ const font = options.font ?? null;
233
+ let rPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
234
+
235
+ if (font) {
236
+ rPr = applyFont(rPr, font);
237
+ }
238
+
239
+ return `<w:del w:id="${metadata.id}" w:author="${escapeXml(metadata.author)}" w:date="${metadata.date}">` +
240
+ `<w:r>${rPr}<w:delText xml:space="preserve">${escapeXml(item.text)}</w:delText></w:r>` +
241
+ `</w:del>`;
242
+ }
243
+
244
+ /**
245
+ * Builds an insertion (w:ins) element.
246
+ *
247
+ * @param {import('../core/types.js').RunEntry} item - Insertion entry
248
+ * @param {import('../core/types.js').FormatHint[]} formatHints - Format hints
249
+ * @param {import('../core/types.js').SerializationOptions} options - Serialization options
250
+ * @returns {string}
251
+ */
252
+ function buildInsertionXml(item, formatHints, options = {}) {
253
+ const metadata = createRevisionMetadata(options.author ?? getDefaultAuthor());
254
+ const font = options.font ?? null;
255
+
256
+ // Build the inner run content with format hints
257
+ const applicableHints = getApplicableFormatHints(formatHints, item.startOffset, item.endOffset);
258
+ let innerContent = '';
259
+ let cleanRPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
260
+
261
+ if (font) {
262
+ cleanRPr = applyFont(cleanRPr, font);
263
+ }
264
+
265
+ if (applicableHints.length === 0) {
266
+ innerContent = buildSimpleRun(item.text, cleanRPr);
267
+ } else {
268
+ // Apply format hints
269
+ let pos = 0;
270
+ const text = item.text;
271
+ const baseOffset = item.startOffset;
272
+
273
+ for (const hint of applicableHints) {
274
+ const localStart = Math.max(0, hint.start - baseOffset);
275
+ const localEnd = Math.min(text.length, hint.end - baseOffset);
276
+
277
+ if (localStart > pos) {
278
+ innerContent += buildSimpleRun(text.slice(pos, localStart), cleanRPr);
279
+ }
280
+
281
+ const formattedRPr = injectFormatting(cleanRPr, hint.format);
282
+ innerContent += buildSimpleRun(text.slice(localStart, localEnd), formattedRPr);
283
+ pos = localEnd;
284
+ }
285
+
286
+ if (pos < text.length) {
287
+ innerContent += buildSimpleRun(text.slice(pos), cleanRPr);
288
+ }
289
+ }
290
+
291
+ return `<w:ins w:id="${metadata.id}" w:author="${escapeXml(metadata.author)}" w:date="${metadata.date}">` +
292
+ innerContent +
293
+ `</w:ins>`;
294
+ }
295
+
296
+ /**
297
+ * Applies a font to run properties XML.
298
+ *
299
+ * @param {string} baseRPrXml - Base run properties
300
+ * @param {string} font - Font name
301
+ * @returns {string}
302
+ */
303
+ function applyFont(baseRPrXml, font) {
304
+ if (!font) return baseRPrXml;
305
+
306
+ // Extract existing content from rPr
307
+ let content = '';
308
+ if (baseRPrXml) {
309
+ content = baseRPrXml.replace(/<\/?w:rPr[^>]*>/g, '');
310
+ }
311
+
312
+ // Replace or add rFonts
313
+ if (content.includes('<w:rFonts')) {
314
+ content = content.replace(/<w:rFonts[^>]*\/>/, `<w:rFonts w:ascii="${font}" w:hAnsi="${font}"/>`);
315
+ } else {
316
+ content = `<w:rFonts w:ascii="${font}" w:hAnsi="${font}"/>` + content;
317
+ }
318
+
319
+ return `<w:rPr>${content}</w:rPr>`;
320
+ }
321
+
322
+ /**
323
+ * Injects formatting into run properties XML.
324
+ *
325
+ * @param {string} baseRPrXml - Base run properties
326
+ * @param {Object} format - Format flags (bold, italic, underline, strikethrough)
327
+ * @returns {string}
328
+ */
329
+ function injectFormatting(baseRPrXml, format) {
330
+ if (!format || Object.keys(format).length === 0) {
331
+ return baseRPrXml;
332
+ }
333
+
334
+ // Extract existing content from rPr
335
+ let content = '';
336
+ if (baseRPrXml) {
337
+ content = baseRPrXml.replace(/<\/?w:rPr[^>]*>/g, '');
338
+ }
339
+
340
+ // Add new formatting elements
341
+ if (format.bold && !content.includes('<w:b')) {
342
+ content = '<w:b/>' + content;
343
+ }
344
+ if (format.italic && !content.includes('<w:i')) {
345
+ content = '<w:i/>' + content;
346
+ }
347
+ if (format.underline && !content.includes('<w:u')) {
348
+ content = '<w:u w:val="single"/>' + content;
349
+ }
350
+ if (format.strikethrough && !content.includes('<w:strike')) {
351
+ content = '<w:strike/>' + content;
352
+ }
353
+
354
+ return `<w:rPr>${content}</w:rPr>`;
355
+ }
356
+
357
+ /**
358
+ * Wraps OOXML paragraph content for Word's insertOoxml API.
359
+ * Must include both the document part AND the relationships part.
360
+ *
361
+ * @param {string} paragraphXml - The paragraph XML (without namespace declarations)
362
+ * @param {import('../core/types.js').DocumentFragmentOptions|boolean} [options={}] - Fragment options
363
+ * @returns {string} Complete OOXML package for insertOoxml
364
+ */
365
+ export function wrapInDocumentFragment(paragraphXml, options = {}) {
366
+ const normalizedOptions = normalizeFragmentOptions(options);
367
+ return buildDocumentFragmentPackage(paragraphXml, normalizedOptions);
368
+ }
369
+
370
+ /**
371
+ * Normalizes wrapper options to object form.
372
+ *
373
+ * @param {import('../core/types.js').DocumentFragmentOptions|boolean|undefined|null} options - Raw options
374
+ * @returns {import('../core/types.js').DocumentFragmentOptions}
375
+ */
376
+ function normalizeFragmentOptions(options) {
377
+ if (typeof options === 'boolean') {
378
+ return { includeNumbering: options };
379
+ }
380
+
381
+ if (!options || typeof options !== 'object') {
382
+ return {};
383
+ }
384
+
385
+ return {
386
+ includeNumbering: options.includeNumbering ?? false,
387
+ numberingXml: options.numberingXml ?? null,
388
+ appendTrailingParagraph: options.appendTrailingParagraph ?? true
389
+ };
390
+ }
391
+
392
+ function stripNamespaceDeclarations(xml) {
393
+ return xml ? xml.replace(XMLNS_ATTR_REGEX, '') : '';
394
+ }
395
+