@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -6,8 +6,11 @@ import { preprocessMarkdown } from './markdown-processor.js';
6
6
  import { matchListMarker, stripListMarker } from './list-markers.js';
7
7
  import { serializeToOoxml } from './serialization.js';
8
8
  import { generateTableOoxml } from '../services/table-reconciliation.js';
9
- import { parseTable } from './content-analysis.js';
10
- import { log } from '../adapters/logger.js';
9
+ import { parseTable } from './content-analysis.js';
10
+ import { log } from '../adapters/logger.js';
11
+ import { createRevisionMetadata, NS_W, RunKind } from '../core/types.js';
12
+ import { parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
13
+ import { createWordElement } from '../core/word-xml.js';
11
14
 
12
15
  /**
13
16
  * Executes list generation when paragraph content expands into list/table blocks.
@@ -39,10 +42,17 @@ export async function executeListGeneration(options) {
39
42
  const normalizedListText = normalizeCompositeListMarkers(cleanText);
40
43
  const lineMetadata = buildLineMetadata(normalizedListText);
41
44
  const rawLines = lineMetadata.map(line => line.raw);
42
- const results = [];
45
+ const results = [];
46
+ const sourcePPr = getSourceParagraphProperties(originalRunModel);
47
+ const inheritedTypographyRPrXml = extractInheritedTypographyRPrXml(originalRunModel, sourcePPr);
48
+ const inheritedHeadingRPrXml = extractInheritedTypographyRPrXml(
49
+ originalRunModel,
50
+ sourcePPr,
51
+ ['rFonts', 'kern', 'position', 'rtl', 'cs', 'lang']
52
+ );
43
53
 
44
54
  let deletionRuns = [];
45
- if (generateRedlines) {
55
+ if (generateRedlines) {
46
56
  if (originalRunModel && originalRunModel.length > 0) {
47
57
  deletionRuns = originalRunModel
48
58
  .filter(run => run.kind === 'text' || run.kind === 'run')
@@ -56,8 +66,18 @@ export async function executeListGeneration(options) {
56
66
  startOffset: 0,
57
67
  endOffset: trimmed.length
58
68
  }];
59
- }
60
- }
69
+ }
70
+ }
71
+
72
+ if (generateRedlines && deletionRuns.length > 0) {
73
+ const deletedPPr = addParagraphMarkRevision(sourcePPr, 'del', author, revisionIdAllocator);
74
+ const deletedParagraph = serializeToOoxml(deletionRuns, deletedPPr, [], {
75
+ author,
76
+ generateRedlines,
77
+ revisionIdAllocator
78
+ });
79
+ results.push(deletedParagraph);
80
+ }
61
81
 
62
82
  const indentStep = detectIndentationStep(rawLines);
63
83
  log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
@@ -71,19 +91,11 @@ export async function executeListGeneration(options) {
71
91
  if (tableBlock) {
72
92
  const tableData = parseTable(tableBlock.tableText);
73
93
  if (tableData.headers.length > 0 || tableData.rows.length > 0) {
74
- if (generateRedlines && results.length === 0 && deletionRuns.length > 0) {
75
- results.push(serializeToOoxml(deletionRuns, null, [], {
76
- author,
77
- generateRedlines,
78
- font,
79
- revisionIdAllocator
80
- }));
81
- }
82
-
83
94
  results.push(generateTableOoxml(tableData, {
84
95
  generateRedlines,
85
96
  author,
86
- revisionIdAllocator
97
+ revisionIdAllocator,
98
+ trackAsBlock: true
87
99
  }));
88
100
  i = tableBlock.endIndex;
89
101
  continue;
@@ -91,31 +103,29 @@ export async function executeListGeneration(options) {
91
103
  }
92
104
 
93
105
  const line = lineMetadata[i];
94
- const entry = buildListEntry(
95
- line,
96
- i,
97
- indentStep,
106
+ const entry = buildListEntry(
107
+ line,
108
+ indentStep,
98
109
  numberingContext,
99
110
  numberingService,
100
111
  generateRedlines,
101
112
  author,
102
113
  font,
103
114
  revisionIdAllocator,
104
- deletionRuns
105
- );
115
+ inheritedTypographyRPrXml,
116
+ inheritedHeadingRPrXml
117
+ );
106
118
  results.push(entry.ooxml);
107
119
  }
108
120
 
109
121
  const numberingXml = numberingService.generateNumberingXml();
110
122
  const finalOoxml = results.join('');
111
- const blankParagraph = '<w:p><w:pPr></w:pPr></w:p>';
112
- const oxmlWithSpacing = finalOoxml + blankParagraph;
113
-
114
- log(`[ListGen] ✅ Generated OOXML for ${results.length} list items, total length: ${oxmlWithSpacing.length}`);
115
- log(`[ListGen] First 200 chars: ${oxmlWithSpacing.substring(0, 200)}...`);
123
+
124
+ log(`[ListGen] Generated OOXML for ${results.length} paragraphs, total length: ${finalOoxml.length}`);
125
+ log(`[ListGen] First 200 chars: ${finalOoxml.substring(0, 200)}...`);
116
126
 
117
127
  return {
118
- ooxml: oxmlWithSpacing,
128
+ ooxml: finalOoxml,
119
129
  isValid: true,
120
130
  warnings: ['Paragraph expanded to list fragment'],
121
131
  type: 'fragment',
@@ -226,28 +236,33 @@ function collectMarkdownTableBlock(lineMetadata, index) {
226
236
  };
227
237
  }
228
238
 
229
- function buildListEntry(
230
- line,
231
- lineIndex,
232
- indentStep,
239
+ function buildListEntry(
240
+ line,
241
+ indentStep,
233
242
  numberingContext,
234
243
  numberingService,
235
244
  generateRedlines,
236
245
  author,
237
246
  font,
238
247
  revisionIdAllocator,
239
- deletionRuns
240
- ) {
241
- let pPrXml = '';
242
- let segmentText = '';
248
+ inheritedTypographyRPrXml,
249
+ inheritedHeadingRPrXml
250
+ ) {
251
+ let pPrXml = '';
252
+ let segmentText = '';
253
+ let insertedRPrXml = inheritedTypographyRPrXml;
243
254
 
244
255
  if (line.headerMatch) {
245
256
  const level = Math.min(line.headerMatch[1].length, 9);
246
257
  const outlineLevel = Math.min(level - 1, 8);
247
258
  const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
248
- const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
249
- segmentText = line.headerMatch[2].trim();
250
- pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
259
+ const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
260
+ segmentText = line.headerMatch[2].trim();
261
+ pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
262
+ insertedRPrXml = mergeRunProperties(
263
+ inheritedHeadingRPrXml,
264
+ `<w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr>`
265
+ );
251
266
  } else if (line.marker) {
252
267
  const lineFormat = numberingService.detectNumberingFormat(line.marker);
253
268
  const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
@@ -259,20 +274,26 @@ function buildListEntry(
259
274
  segmentText = stripListMarker(line.raw);
260
275
  const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
261
276
  pPrXml = numberingService.buildListPPr(numId, ilvl);
262
- } else {
263
- segmentText = line.raw;
264
- }
265
-
266
- const { cleanText, formatHints } = preprocessMarkdown(segmentText);
267
- const runModel = [];
268
-
269
- if (lineIndex === 0 && deletionRuns.length > 0) {
270
- runModel.push(...deletionRuns);
271
- }
272
-
273
- runModel.push({
274
- kind: generateRedlines ? 'insertion' : 'run',
275
- text: cleanText,
277
+ } else {
278
+ segmentText = line.raw;
279
+ }
280
+
281
+ if (generateRedlines) {
282
+ pPrXml = addParagraphMarkRevision(
283
+ pPrXml || '<w:pPr/>',
284
+ 'ins',
285
+ author,
286
+ revisionIdAllocator
287
+ );
288
+ }
289
+
290
+ const { cleanText, formatHints } = preprocessMarkdown(segmentText);
291
+ const runModel = [];
292
+
293
+ runModel.push({
294
+ kind: generateRedlines ? 'insertion' : 'run',
295
+ text: cleanText,
296
+ rPrXml: insertedRPrXml,
276
297
  author,
277
298
  startOffset: 0,
278
299
  endOffset: cleanText.length
@@ -285,5 +306,79 @@ function buildListEntry(
285
306
  font,
286
307
  revisionIdAllocator
287
308
  })
288
- };
289
- }
309
+ };
310
+ }
311
+
312
+ function getSourceParagraphProperties(originalRunModel) {
313
+ const paragraphStart = (originalRunModel || []).find(run => run.kind === RunKind.PARAGRAPH_START);
314
+ return paragraphStart?.pPrElement || paragraphStart?.pPrXml || null;
315
+ }
316
+
317
+ function extractInheritedTypographyRPrXml(
318
+ originalRunModel,
319
+ sourcePPr,
320
+ tagOrder = ['rFonts', 'kern', 'position', 'sz', 'szCs', 'rtl', 'cs', 'lang']
321
+ ) {
322
+ const sources = (originalRunModel || [])
323
+ .filter(run => (run.kind === RunKind.TEXT || run.kind === 'text') && run.rPrXml)
324
+ .map(run => run.rPrXml);
325
+
326
+ if (sourcePPr) {
327
+ sources.push(typeof sourcePPr === 'string' ? sourcePPr : serializeXml(sourcePPr));
328
+ }
329
+
330
+ const inherited = [];
331
+ for (const tagName of tagOrder) {
332
+ let match = null;
333
+ for (const source of sources) {
334
+ match = String(source || '').match(new RegExp(`<w:${tagName}\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/w:${tagName}>)`));
335
+ if (match) break;
336
+ }
337
+ if (match) inherited.push(match[0]);
338
+ }
339
+
340
+ return inherited.length > 0 ? `<w:rPr>${inherited.join('')}</w:rPr>` : '';
341
+ }
342
+
343
+ function mergeRunProperties(...sources) {
344
+ const properties = sources
345
+ .map(source => String(source || '').replace(/^\s*<w:rPr[^>]*>|<\/w:rPr>\s*$/g, ''))
346
+ .filter(Boolean)
347
+ .join('');
348
+ return properties ? `<w:rPr>${properties}</w:rPr>` : '';
349
+ }
350
+
351
+ function addParagraphMarkRevision(xml, type, author, revisionIdAllocator) {
352
+ const metadata = createRevisionMetadata(author, revisionIdAllocator);
353
+ let pPr;
354
+ let ownerDoc;
355
+
356
+ if (xml?.nodeType === 1) {
357
+ pPr = xml.cloneNode(true);
358
+ ownerDoc = pPr.ownerDocument;
359
+ } else {
360
+ const pPrXml = typeof xml === 'string' && xml.trim() ? xml : '<w:pPr/>';
361
+ const parsed = parseOoxmlSafe(`<w:root xmlns:w="${NS_W}">${pPrXml}</w:root>`);
362
+ if (!parsed.doc) {
363
+ throw new Error(parsed.error?.message || 'Could not parse paragraph properties for list revision');
364
+ }
365
+ ownerDoc = parsed.doc;
366
+ pPr = Array.from(ownerDoc.documentElement.childNodes || []).find(node => (
367
+ node.nodeType === 1 && node.localName === 'pPr'
368
+ ));
369
+ }
370
+
371
+ if (!pPr || !ownerDoc) throw new Error('List paragraph properties are unavailable');
372
+ let rPr = Array.from(pPr.childNodes || []).find(node => node.nodeType === 1 && node.localName === 'rPr');
373
+ if (!rPr) {
374
+ rPr = createWordElement(ownerDoc, 'w:rPr');
375
+ pPr.appendChild(rPr);
376
+ }
377
+
378
+ const marker = createWordElement(ownerDoc, type === 'del' ? 'w:del' : 'w:ins');
379
+ marker.setAttribute('w:id', String(metadata.id));
380
+ marker.setAttribute('w:author', metadata.author);
381
+ marker.setAttribute('w:date', metadata.date);
382
+ rPr.appendChild(marker);
383
+ return serializeXml(pPr);
384
+ }
@@ -4,7 +4,7 @@
4
4
  * Keeps marker parsing consistent across router, pipeline, and patching flows.
5
5
  */
6
6
 
7
- const LIST_MARKER_CORE = String.raw`(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|\d+\.|[ivxlcIVXLC]+\.|[-*\u2022])`;
7
+ const LIST_MARKER_CORE = String.raw`(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|\d+\.|[ivxlcIVXLC]+\.|[-*+\u2022])`;
8
8
 
9
9
  const LINE_REGEX_STRICT = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s+)`);
10
10
  const LINE_REGEX_LOOSE = new RegExp(`^(\\s*)((?:${LIST_MARKER_CORE})\\s*)`);
@@ -70,8 +70,75 @@ export function extractListMarker(line, options = {}) {
70
70
  * @param {boolean} [options.allowZeroSpaceAfterMarker=false] - Allow zero spaces after marker
71
71
  * @returns {string}
72
72
  */
73
- export function stripListMarker(line, options = {}) {
73
+ export function stripListMarker(line, options = {}) {
74
74
  const { allowZeroSpaceAfterMarker = false } = options;
75
75
  const regex = allowZeroSpaceAfterMarker ? LINE_REGEX_LOOSE : LINE_REGEX_STRICT;
76
76
  return line.replace(regex, '');
77
- }
77
+ }
78
+
79
+ /**
80
+ * Classifies a parsed marker using the shared list vocabulary.
81
+ *
82
+ * @param {string} marker - Marker without trailing whitespace
83
+ * @returns {'bullet'|'numbered'}
84
+ */
85
+ export function classifyListMarker(marker) {
86
+ return /^[-*+\u2022]$/.test(String(marker || '').trim()) ? 'bullet' : 'numbered';
87
+ }
88
+
89
+ /**
90
+ * Infers the Word numbering style represented by a numbered marker.
91
+ *
92
+ * @param {string} marker - Marker text
93
+ * @returns {'bullet'|'decimal'|'lowerAlpha'|'upperAlpha'|'lowerRoman'|'upperRoman'}
94
+ */
95
+ export function inferNumberingStyleFromMarker(marker) {
96
+ const value = String(marker || '').trim();
97
+ if (classifyListMarker(value) === 'bullet') return 'bullet';
98
+ if (/^\d+(?:\.\d+)*\.?$/.test(value) || /^\(\d+\)$/.test(value)) return 'decimal';
99
+ if (/^[ivxlcdm]+\.$/.test(value)) return 'lowerRoman';
100
+ if (/^[IVXLCDM]{2,}\.$/.test(value)) return 'upperRoman';
101
+ if (/^[a-z]\.$/.test(value)) return 'lowerAlpha';
102
+ if (/^[A-Z]\.$/.test(value)) return 'upperAlpha';
103
+ return 'decimal';
104
+ }
105
+
106
+ /**
107
+ * Returns an outline level encoded directly in a composite decimal marker.
108
+ * Indentation-derived levels remain the caller's responsibility.
109
+ *
110
+ * @param {string} marker - Marker text
111
+ * @returns {number|null}
112
+ */
113
+ export function parseOutlineLevelFromMarker(marker) {
114
+ const value = String(marker || '').trim();
115
+ if (!/^\d+(?:\.\d+)+\.?$/.test(value)) return null;
116
+ return Math.max(0, value.replace(/\.$/, '').split('.').length - 1);
117
+ }
118
+
119
+ /**
120
+ * Parses one line into the common list-item representation.
121
+ *
122
+ * @param {string} line - Input line
123
+ * @param {{allowZeroSpaceAfterMarker?: boolean, indentSpaces?: number}} [options]
124
+ * @returns {{line:string,text:string,marker:string,indent:number,level:number,markerType:'bullet'|'numbered',listType:'bullet'|'numbered',numberingStyle:string,outlineLevel:number|null}|null}
125
+ */
126
+ export function parseListItem(line, options = {}) {
127
+ const match = matchListMarker(String(line || ''), options);
128
+ if (!match) return null;
129
+ const marker = match[2].trim();
130
+ const indent = (match[1] || '').length;
131
+ const indentSpaces = Math.max(1, Number(options.indentSpaces) || 2);
132
+ const markerType = classifyListMarker(marker);
133
+ return {
134
+ line: String(line || ''),
135
+ text: stripListMarker(String(line || ''), options),
136
+ marker,
137
+ indent,
138
+ level: Math.min(8, Math.floor(indent / indentSpaces)),
139
+ markerType,
140
+ listType: markerType,
141
+ numberingStyle: inferNumberingStyleFromMarker(marker),
142
+ outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null
143
+ };
144
+ }
@@ -232,7 +232,8 @@ function buildSimpleRun(text, rPrXml) {
232
232
  function buildDeletionXml(item, options = {}) {
233
233
  const metadata = createRevisionMetadata(
234
234
  options.author ?? getDefaultAuthor(),
235
- options.revisionIdAllocator
235
+ options.revisionIdAllocator,
236
+ 'del'
236
237
  );
237
238
  const font = options.font ?? null;
238
239
  let rPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
@@ -257,7 +258,8 @@ function buildDeletionXml(item, options = {}) {
257
258
  function buildInsertionXml(item, formatHints, options = {}) {
258
259
  const metadata = createRevisionMetadata(
259
260
  options.author ?? getDefaultAuthor(),
260
- options.revisionIdAllocator
261
+ options.revisionIdAllocator,
262
+ 'ins'
261
263
  );
262
264
  const font = options.font ?? null;
263
265
 
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Strict, dependency-light planning for mixed Markdown document insertions.
3
+ *
4
+ * Agents should run this before replacing one Word paragraph with content that
5
+ * contains headings, paragraphs, lists, or tables. The planner never guesses a
6
+ * malformed table: a Markdown separator row is required so literal pipe text
7
+ * cannot silently reach the document.
8
+ */
9
+
10
+ import { matchListMarker } from './list-markers.js';
11
+
12
+ const TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
13
+
14
+ function isBlank(line) {
15
+ return !line || line.trim().length === 0;
16
+ }
17
+
18
+ function isTableLine(line) {
19
+ const trimmed = String(line || '').trim();
20
+ return trimmed.startsWith('|') && trimmed.endsWith('|');
21
+ }
22
+
23
+ function tableCells(line) {
24
+ return String(line || '').trim().split('|').slice(1, -1).map(cell => cell.trim());
25
+ }
26
+
27
+ function classifyLine(line) {
28
+ if (isBlank(line)) return 'blank';
29
+ if (/^\s*#{1,9}\s+\S/.test(line)) return 'heading';
30
+ if (isTableLine(line)) return 'table';
31
+ if (matchListMarker(line)) return 'list';
32
+ return 'paragraph';
33
+ }
34
+
35
+ /**
36
+ * Decomposes mixed Markdown into explicit document blocks and validates the
37
+ * structural syntax required by the OOXML generator.
38
+ *
39
+ * @param {string} markdown
40
+ * @returns {{ valid: boolean, normalizedMarkdown: string, blocks: Array<object>, issues: Array<object>, counts: Record<string, number>, requiresStructuredContent: boolean }}
41
+ */
42
+ export function analyzeStructuredContent(markdown) {
43
+ const source = typeof markdown === 'string' ? markdown.replace(/\r\n?/g, '\n') : String(markdown ?? '');
44
+ const lines = source.split('\n');
45
+ const blocks = [];
46
+ const issues = [];
47
+ let index = 0;
48
+
49
+ while (index < lines.length) {
50
+ if (isBlank(lines[index])) {
51
+ index++;
52
+ continue;
53
+ }
54
+
55
+ const kind = classifyLine(lines[index]);
56
+ const startLine = index + 1;
57
+ if (kind === 'heading') {
58
+ const match = lines[index].match(/^\s*(#{1,9})\s+(.+?)\s*$/);
59
+ blocks.push({ type: 'heading', level: match[1].length, text: match[2], markdown: lines[index].trim() });
60
+ index++;
61
+ continue;
62
+ }
63
+
64
+ if (kind === 'table') {
65
+ const tableLines = [];
66
+ while (index < lines.length && isTableLine(lines[index])) {
67
+ tableLines.push(lines[index].trim());
68
+ index++;
69
+ }
70
+ const separatorPresent = tableLines.length > 1 && TABLE_SEPARATOR.test(tableLines[1]);
71
+ const widths = tableLines.filter(line => !TABLE_SEPARATOR.test(line)).map(line => tableCells(line).length);
72
+ if (!separatorPresent) {
73
+ issues.push({
74
+ severity: 'error',
75
+ code: 'TABLE_SEPARATOR_REQUIRED',
76
+ line: startLine,
77
+ message: 'Markdown tables require a separator row immediately after the header (for example | --- | --- |).'
78
+ });
79
+ }
80
+ if (widths.length < 2) {
81
+ issues.push({
82
+ severity: 'error',
83
+ code: 'TABLE_DATA_ROW_REQUIRED',
84
+ line: startLine,
85
+ message: 'Markdown tables require a header and at least one data row.'
86
+ });
87
+ } else if (new Set(widths).size > 1) {
88
+ issues.push({
89
+ severity: 'error',
90
+ code: 'TABLE_COLUMN_COUNT_MISMATCH',
91
+ line: startLine,
92
+ message: `Markdown table rows have inconsistent column counts: ${widths.join(', ')}.`
93
+ });
94
+ }
95
+ blocks.push({
96
+ type: 'table',
97
+ columns: widths[0] || 0,
98
+ rows: Math.max(0, widths.length - 1),
99
+ hasHeader: separatorPresent,
100
+ markdown: tableLines.join('\n')
101
+ });
102
+ continue;
103
+ }
104
+
105
+ if (kind === 'list') {
106
+ const listLines = [];
107
+ while (index < lines.length && classifyLine(lines[index]) === 'list') {
108
+ listLines.push(lines[index].trimEnd());
109
+ index++;
110
+ }
111
+ blocks.push({ type: 'list', items: listLines.length, markdown: listLines.join('\n') });
112
+ continue;
113
+ }
114
+
115
+ const paragraphLines = [];
116
+ while (index < lines.length && classifyLine(lines[index]) === 'paragraph') {
117
+ paragraphLines.push(lines[index].trim());
118
+ index++;
119
+ }
120
+ const text = paragraphLines.join(' ').trim();
121
+ blocks.push({ type: 'paragraph', text, markdown: text });
122
+ }
123
+
124
+ const counts = { heading: 0, paragraph: 0, list: 0, table: 0 };
125
+ for (const block of blocks) counts[block.type] = (counts[block.type] || 0) + 1;
126
+ const normalizedMarkdown = blocks.map(block => block.markdown).join('\n\n');
127
+ return {
128
+ valid: issues.every(issue => issue.severity !== 'error'),
129
+ normalizedMarkdown,
130
+ blocks,
131
+ issues,
132
+ counts,
133
+ requiresStructuredContent: blocks.length > 1 || blocks.some(block => block.type === 'heading' || block.type === 'table' || (block.type === 'list' && block.items > 1))
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Builds one atomic full-document replacement operation from validated mixed
139
+ * Markdown. The operation remains a single target mutation; blocks are not
140
+ * emitted as a fragile sequence of operations that would invalidate the anchor.
141
+ *
142
+ * @param {string|object} target
143
+ * @param {string} markdown
144
+ * @param {{ author?: string, generateRedlines?: boolean, existingRevisions?: string }} [options]
145
+ */
146
+ export function planStructuredReplacement(target, markdown, options = {}) {
147
+ const analysis = analyzeStructuredContent(markdown);
148
+ return {
149
+ ...analysis,
150
+ operation: analysis.valid ? {
151
+ type: 'replace',
152
+ target,
153
+ modified: analysis.normalizedMarkdown,
154
+ structuredContent: true,
155
+ ...(options.author ? { author: options.author } : {}),
156
+ ...(typeof options.generateRedlines === 'boolean' ? { generateRedlines: options.generateRedlines } : {}),
157
+ ...(options.existingRevisions ? { existingRevisions: options.existingRevisions } : {})
158
+ } : null
159
+ };
160
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Compatibility entrypoint for legacy docx-redline skill invocations.
4
+ // All targeting, transaction, validation, and output behavior is delegated to
5
+ // the supported CLI instead of being reimplemented here.
6
+ import { runCli } from '../node/cli.js';
7
+
8
+ const legacyArgs = process.argv.slice(2);
9
+ const input = legacyArgs.shift();
10
+ const operations = legacyArgs.shift();
11
+ const output = legacyArgs[0] && !legacyArgs[0].startsWith('--')
12
+ ? legacyArgs.shift()
13
+ : null;
14
+
15
+ const delegatedArgs = ['apply'];
16
+ if (input) delegatedArgs.push(input);
17
+ if (operations) delegatedArgs.push('--operations', operations);
18
+ if (output) delegatedArgs.push('--output', output);
19
+ delegatedArgs.push(...legacyArgs);
20
+ if (!legacyArgs.some(argument => argument === '--author' || argument.startsWith('--author='))) {
21
+ delegatedArgs.push('--author', process.env.DOCX_REDLINE_AUTHOR || 'Agent');
22
+ }
23
+ if (!legacyArgs.some(argument => argument === '--atomic' || argument === '--no-atomic')) {
24
+ delegatedArgs.push('--atomic');
25
+ }
26
+
27
+ process.exitCode = await runCli(delegatedArgs);