@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  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/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,317 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { resolve, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
6
+ import { configureXmlProvider } from '../adapters/xml-adapter.js';
7
+ import { configureLogger } from '../adapters/logger.js';
8
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
9
+ import { RevisionIdAllocator } from '../core/types.js';
10
+ import { applyRedlineToOxml, reconcileMarkdownTableOoxml } from '../index.js';
11
+ import { ingestWordOoxmlToPlainText } from '../pipeline/ingestion-export.js';
12
+ import { buildMinimalDocx } from './lib/minimal-zip.mjs';
13
+
14
+ configureXmlProvider({ DOMParser, XMLSerializer });
15
+ configureLogger(console, { level: 'silent' });
16
+
17
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
18
+ const serializer = new XMLSerializer();
19
+
20
+ const escapeXml = value => String(value)
21
+ .replace(/&/g, '&')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;');
24
+
25
+ const run = (text, properties = '') => `<w:r>${properties ? `<w:rPr>${properties}</w:rPr>` : ''}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
26
+
27
+ function paragraph(text, { heading = false, pageBreakBefore = false, numId = null } = {}) {
28
+ const pPr = [
29
+ pageBreakBefore ? '<w:pageBreakBefore/>' : '',
30
+ numId == null ? '' : `<w:numPr><w:ilvl w:val="0"/><w:numId w:val="${numId}"/></w:numPr>`,
31
+ `<w:spacing w:after="${heading ? 120 : 100}" w:line="${heading ? 280 : 300}" w:lineRule="auto"/>`
32
+ ].join('');
33
+ const rPr = heading
34
+ ? '<w:b/><w:color w:val="2E74B5"/><w:sz w:val="28"/><w:szCs w:val="28"/>'
35
+ : '<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="22"/><w:szCs w:val="22"/>';
36
+ return `<w:p xmlns:w="${NS_W}"><w:pPr>${pPr}</w:pPr>${run(text, rPr)}</w:p>`;
37
+ }
38
+
39
+ function titleParagraph(title, subtitle) {
40
+ return [
41
+ `<w:p><w:pPr><w:spacing w:after="80"/></w:pPr>${run(title, '<w:b/><w:color w:val="1F4D78"/><w:sz w:val="36"/><w:szCs w:val="36"/>')}</w:p>`,
42
+ `<w:p><w:pPr><w:spacing w:after="240"/></w:pPr>${run(subtitle, '<w:i/><w:color w:val="666666"/><w:sz w:val="20"/><w:szCs w:val="20"/>')}</w:p>`
43
+ ].join('');
44
+ }
45
+
46
+ function markdownTable(headers, rows) {
47
+ return [
48
+ `| ${headers.join(' | ')} |`,
49
+ `| ${headers.map(() => '---').join(' | ')} |`,
50
+ ...rows.map(row => `| ${row.join(' | ')} |`)
51
+ ].join('\n');
52
+ }
53
+
54
+ function tableXml(headers, rows) {
55
+ const widths = [3000, 3000, 3360];
56
+ const rowXml = (values, isHeader) => `<w:tr>${values.map((value, index) => `
57
+ <w:tc>
58
+ <w:tcPr><w:tcW w:w="${widths[index]}" w:type="dxa"/><w:vAlign w:val="center"/>${isHeader ? '<w:shd w:fill="E8EEF5"/>' : ''}</w:tcPr>
59
+ <w:p><w:pPr><w:spacing w:after="40"/></w:pPr>${run(value, `${isHeader ? '<w:b/>' : ''}<w:sz w:val="20"/><w:szCs w:val="20"/>`)}</w:p>
60
+ </w:tc>`).join('')}</w:tr>`;
61
+ return `<w:tbl xmlns:w="${NS_W}">
62
+ <w:tblPr>
63
+ <w:tblW w:w="9360" w:type="dxa"/><w:tblInd w:w="120" w:type="dxa"/><w:tblLayout w:type="fixed"/>
64
+ <w:tblBorders><w:top w:val="single" w:sz="4" w:color="AAB7C4"/><w:left w:val="single" w:sz="4" w:color="AAB7C4"/><w:bottom w:val="single" w:sz="4" w:color="AAB7C4"/><w:right w:val="single" w:sz="4" w:color="AAB7C4"/><w:insideH w:val="single" w:sz="4" w:color="D5DCE3"/><w:insideV w:val="single" w:sz="4" w:color="D5DCE3"/></w:tblBorders>
65
+ <w:tblCellMar><w:top w:w="80" w:type="dxa"/><w:start w:w="120" w:type="dxa"/><w:bottom w:w="80" w:type="dxa"/><w:end w:w="120" w:type="dxa"/></w:tblCellMar>
66
+ </w:tblPr>
67
+ <w:tblGrid>${widths.map(width => `<w:gridCol w:w="${width}"/>`).join('')}</w:tblGrid>
68
+ ${rowXml(headers, true)}${rows.map(row => rowXml(row, false)).join('')}
69
+ </w:tbl>`;
70
+ }
71
+
72
+ const NUMBERING_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
73
+ <w:numbering xmlns:w="${NS_W}">
74
+ <w:abstractNum w:abstractNumId="10"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:tabs><w:tab w:val="num" w:pos="540"/></w:tabs><w:ind w:left="540" w:hanging="270"/></w:pPr></w:lvl></w:abstractNum>
75
+ <w:abstractNum w:abstractNumId="11"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:lvlJc w:val="left"/><w:pPr><w:tabs><w:tab w:val="num" w:pos="540"/></w:tabs><w:ind w:left="540" w:hanging="270"/></w:pPr></w:lvl></w:abstractNum>
76
+ <w:num w:numId="10"><w:abstractNumId w:val="10"/></w:num>
77
+ <w:num w:numId="11"><w:abstractNumId w:val="11"/></w:num>
78
+ </w:numbering>`;
79
+
80
+ const SCENARIOS = [
81
+ {
82
+ name: 'mixed-policy-review',
83
+ title: 'Mixed Policy Review Stress Fixture',
84
+ paragraphs: [
85
+ ['The review board meets every month to assess open actions.', 'The review board meets every quarter to assess open actions.', 'The oversight committee meets every quarter to assess unresolved actions.'],
86
+ ['All submissions require a manager signature.', 'All submissions require a manager signature and a dated approval note.', 'All submissions require a director signature and a dated approval note.'],
87
+ ['The legacy exception remains available during the pilot period.', 'The legacy exception remains available.', 'The temporary exception remains available through year-end.'],
88
+ ['Critical controls must be documented before launch.', 'Critical **controls** must be documented before launch.', 'Critical **safeguards** must be documented before production launch.'],
89
+ ['The coordinator records decisions in the central register.', 'The coordinator records final decisions in the central register within two business days.', 'The governance lead records final decisions in the central register within one business day.']
90
+ ],
91
+ lists: [
92
+ ['Confirm the meeting agenda.', 'Confirm the revised meeting agenda.', 'Confirm the final meeting agenda.'],
93
+ ['Collect stakeholder comments.', 'Collect written stakeholder comments.', 'Collect written stakeholder approvals.'],
94
+ ['Escalate unresolved risks.', 'Escalate unresolved operational risks.', 'Escalate unresolved operational and legal risks.'],
95
+ ['Archive the superseded draft.', 'Archive the superseded working draft.', 'Archive the superseded approved draft.'],
96
+ ['Record the decision owner.', 'Record the **decision owner**.', 'Record the **accountable decision owner**.'],
97
+ ['Publish the implementation notice.', 'Publish the implementation notice within five days.', 'Publish the implementation notice within three days.']
98
+ ],
99
+ tables: [
100
+ {
101
+ headers: ['Workstream', 'Owner', 'Status'],
102
+ source: [['Budget', 'Finance', 'Pending'], ['Policy', 'Legal', 'Draft'], ['Training', 'People', 'Planned']],
103
+ round1: [['Budget', 'Treasury', 'Approved'], ['Policy', 'Legal', 'In review'], ['Training', 'People', 'Planned']],
104
+ round2: [['Budget', 'Treasury', 'Complete'], ['Policy', 'Compliance', 'Approved'], ['Training', 'People', 'Scheduled']]
105
+ },
106
+ {
107
+ headers: ['Risk', 'Rating', 'Response'],
108
+ source: [['Access', 'Medium', 'Monitor'], ['Continuity', 'Low', 'Review']],
109
+ round1: [['Access', 'High', 'Mitigate'], ['Privacy', 'Medium', 'Assess'], ['Continuity', 'Low', 'Review']],
110
+ round2: [['Access', 'Medium', 'Mitigate'], ['Privacy', 'Low', 'Monitor'], ['Continuity', 'Low', 'Close']]
111
+ },
112
+ {
113
+ headers: ['Milestone', 'Date', 'Lead'],
114
+ source: [['Design', 'April 10', 'Avery'], ['Pilot', 'May 15', 'Blair'], ['Launch', 'June 20', 'Casey']],
115
+ round1: [['Design', 'April 17', 'Avery'], ['Launch', 'June 27', 'Casey']],
116
+ round2: [['Design', 'April 17', 'Avery'], ['Readiness', 'June 10', 'Blair'], ['Launch', 'July 4', 'Casey']]
117
+ }
118
+ ]
119
+ },
120
+ {
121
+ name: 'mixed-contract-operations',
122
+ title: 'Mixed Contract Operations Stress Fixture',
123
+ paragraphs: [
124
+ ['The supplier will retain audit records for three years.', 'The supplier will retain audit records for five years.', 'The contractor will retain complete audit records for seven years.'],
125
+ ['Notices may be delivered by ordinary mail.', 'Notices may be delivered by registered mail or secure email.', 'Notices must be delivered by secure email with receipt confirmation.'],
126
+ ['The annual renewal is automatic unless cancelled.', 'The annual renewal requires written confirmation.', 'Each renewal requires written confirmation from both parties.'],
127
+ ['Service credits are calculated monthly.', 'Service **credits** are calculated monthly.', 'Service **adjustments** are calculated quarterly.'],
128
+ ['The customer may request one compliance report.', 'The customer may request two compliance reports each year.', 'The customer may request quarterly compliance reports.']
129
+ ],
130
+ lists: [
131
+ ['Verify insurance certificates.', 'Verify current insurance certificates.', 'Verify current insurance certificates and endorsements.'],
132
+ ['Review subcontractor access.', 'Review approved subcontractor access.', 'Review and recertify approved subcontractor access.'],
133
+ ['Log all security incidents.', 'Log all material security incidents.', 'Log all material security incidents within four hours.'],
134
+ ['Preserve delivery receipts.', 'Preserve signed delivery receipts.', 'Preserve signed electronic delivery receipts.'],
135
+ ['Confirm the remediation owner.', 'Confirm the **remediation owner**.', 'Confirm the **executive remediation owner**.'],
136
+ ['Close completed obligations.', 'Close completed obligations after evidence review.', 'Close completed obligations after independent evidence review.']
137
+ ],
138
+ tables: [
139
+ {
140
+ headers: ['Clause', 'Position', 'Owner'],
141
+ source: [['Liability', 'Open', 'Legal'], ['Security', 'Draft', 'Risk'], ['Privacy', 'Open', 'Privacy']],
142
+ round1: [['Liability', 'Capped', 'Legal'], ['Security', 'Approved', 'Risk'], ['Privacy', 'Open', 'Privacy']],
143
+ round2: [['Liability', 'Revised cap', 'Legal'], ['Security', 'Approved', 'Security'], ['Privacy', 'Approved', 'Privacy']]
144
+ },
145
+ {
146
+ headers: ['Deliverable', 'Due', 'State'],
147
+ source: [['Report', 'Day 10', 'Planned'], ['Certificate', 'Day 20', 'Planned']],
148
+ round1: [['Report', 'Day 7', 'In progress'], ['Evidence pack', 'Day 14', 'Planned'], ['Certificate', 'Day 20', 'Planned']],
149
+ round2: [['Report', 'Day 5', 'Complete'], ['Evidence pack', 'Day 12', 'In progress'], ['Certificate', 'Day 18', 'Planned']]
150
+ },
151
+ {
152
+ headers: ['Region', 'Threshold', 'Reviewer'],
153
+ source: [['Canada', '$50,000', 'Morgan'], ['United States', '$75,000', 'Riley'], ['Europe', 'EUR 60,000', 'Taylor']],
154
+ round1: [['Canada', '$60,000', 'Morgan'], ['Europe', 'EUR 70,000', 'Taylor']],
155
+ round2: [['Canada', '$65,000', 'Morgan'], ['United Kingdom', 'GBP 55,000', 'Jordan'], ['Europe', 'EUR 70,000', 'Taylor']]
156
+ }
157
+ ]
158
+ }
159
+ ];
160
+
161
+ function firstElement(xml, localName) {
162
+ const document = new DOMParser().parseFromString(xml, 'application/xml');
163
+ const element = document.getElementsByTagNameNS(NS_W, localName)[0];
164
+ if (!element) throw new Error(`Expected w:${localName} in generated OOXML`);
165
+ return serializer.serializeToString(element);
166
+ }
167
+
168
+ async function redlineParagraph(sourceXml, original, modified, author, allocator, existingRevisions = 'reject-input') {
169
+ const result = await applyRedlineToOxml(sourceXml, original, modified, {
170
+ generateRedlines: true,
171
+ author,
172
+ existingRevisions,
173
+ _revisionIdAllocator: allocator
174
+ });
175
+ if (!result.hasChanges || result.status === 'error') {
176
+ throw new Error(`Paragraph redline failed: ${result.error?.message || result.status || 'no change'}`);
177
+ }
178
+ return firstElement(result.oxml, 'p');
179
+ }
180
+
181
+ async function redlineTable(sourceXml, sourceRows, targetRows, headers, author, allocator, existingRevisions = 'reject-input') {
182
+ const modifiedMarkdown = markdownTable(headers, targetRows);
183
+ const result = await reconcileMarkdownTableOoxml(sourceXml, sourceRows[0][0], modifiedMarkdown, {
184
+ generateRedlines: true,
185
+ author,
186
+ existingRevisions,
187
+ _revisionIdAllocator: allocator
188
+ });
189
+ if (!result.hasChanges || result.status === 'error') {
190
+ throw new Error(`Table redline failed: ${result.error?.message || result.status || 'no change'}`);
191
+ }
192
+ return firstElement(result.oxml, 'tbl');
193
+ }
194
+
195
+ function documentXml(scenario, stage, paragraphBlocks, listBlocks, tableBlocks) {
196
+ const stageLabel = stage === 'source'
197
+ ? 'Clean source document'
198
+ : stage === 'round1'
199
+ ? 'First review round - several paragraph, list, formatting, and table changes'
200
+ : 'Second review round - prior revisions accepted per block, then re-redlined';
201
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
202
+ <w:document xmlns:w="${NS_W}"><w:body>
203
+ ${titleParagraph(scenario.title, stageLabel)}
204
+ ${paragraph('Paragraph revisions', { heading: true })}
205
+ ${paragraphBlocks.join('\n ')}
206
+ ${paragraph('Bullet and numbered-list revisions', { heading: true, pageBreakBefore: true })}
207
+ ${listBlocks.join('\n ')}
208
+ ${paragraph('Table revisions', { heading: true, pageBreakBefore: true })}
209
+ ${tableBlocks.join(`\n ${paragraph('', {})}\n `)}
210
+ <w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr>
211
+ </w:body></w:document>`;
212
+ }
213
+
214
+ function revisionCounts(xml) {
215
+ const doc = new DOMParser().parseFromString(xml, 'application/xml');
216
+ return Object.fromEntries(['ins', 'del', 'rPrChange', 'pPrChange'].map(name => [
217
+ name,
218
+ doc.getElementsByTagNameNS(NS_W, name).length
219
+ ]));
220
+ }
221
+
222
+ async function buildScenario(scenario) {
223
+ const round1Allocator = new RevisionIdAllocator(1000);
224
+ const round2Allocator = new RevisionIdAllocator(5000);
225
+ const sourceParagraphs = scenario.paragraphs.map(([source]) => paragraph(source));
226
+ const sourceLists = scenario.lists.map(([source], index) => paragraph(source, { numId: index < 4 ? 10 : 11 }));
227
+ const sourceTables = scenario.tables.map(table => tableXml(table.headers, table.source));
228
+
229
+ const round1Paragraphs = [];
230
+ const round2Paragraphs = [];
231
+ for (let index = 0; index < scenario.paragraphs.length; index += 1) {
232
+ const [source, first, second] = scenario.paragraphs[index];
233
+ const firstXml = await redlineParagraph(sourceParagraphs[index], source, first, 'Round One Reviewer', round1Allocator);
234
+ round1Paragraphs.push(firstXml);
235
+ round2Paragraphs.push(await redlineParagraph(firstXml, first.replace(/[*+]/g, ''), second, 'Round Two Reviewer', round2Allocator, 'accept-all-first'));
236
+ }
237
+
238
+ const round1Lists = [];
239
+ const round2Lists = [];
240
+ for (let index = 0; index < scenario.lists.length; index += 1) {
241
+ const [source, first, second] = scenario.lists[index];
242
+ const firstXml = await redlineParagraph(sourceLists[index], source, first, 'Round One Reviewer', round1Allocator);
243
+ round1Lists.push(firstXml);
244
+ round2Lists.push(await redlineParagraph(firstXml, first.replace(/[*+]/g, ''), second, 'Round Two Reviewer', round2Allocator, 'accept-all-first'));
245
+ }
246
+
247
+ const round1Tables = [];
248
+ const round2Tables = [];
249
+ for (let index = 0; index < scenario.tables.length; index += 1) {
250
+ const table = scenario.tables[index];
251
+ const firstXml = await redlineTable(sourceTables[index], table.source, table.round1, table.headers, 'Round One Reviewer', round1Allocator);
252
+ round1Tables.push(firstXml);
253
+ round2Tables.push(await redlineTable(firstXml, table.round1, table.round2, table.headers, 'Round Two Reviewer', round2Allocator, 'accept-all-first'));
254
+ }
255
+
256
+ return {
257
+ source: documentXml(scenario, 'source', sourceParagraphs, sourceLists, sourceTables),
258
+ round1: documentXml(scenario, 'round1', round1Paragraphs, round1Lists, round1Tables),
259
+ rerelined: documentXml(scenario, 'rerelined', round2Paragraphs, round2Lists, round2Tables)
260
+ };
261
+ }
262
+
263
+ export async function generateReredlineStressFixtures(outputDir) {
264
+ const resolvedOutputDir = resolve(outputDir);
265
+ mkdirSync(resolvedOutputDir, { recursive: true });
266
+ const manifest = { generatedBy: 'scripts/export-reredline-stress-fixtures.mjs', scenarios: [] };
267
+
268
+ for (const scenario of SCENARIOS) {
269
+ const stages = await buildScenario(scenario);
270
+ const entry = { name: scenario.name, stages: {} };
271
+ for (const [stage, xml] of Object.entries(stages)) {
272
+ const validation = validateRedlineOoxml(xml);
273
+ if (!validation.valid) {
274
+ throw new Error(`${scenario.name}/${stage} failed validation: ${JSON.stringify(validation.issues)}`);
275
+ }
276
+ const baseName = `${scenario.name}-${stage}`;
277
+ writeFileSync(join(resolvedOutputDir, `${baseName}.docx`), buildMinimalDocx(xml, { numberingXml: NUMBERING_XML }));
278
+ writeFileSync(join(resolvedOutputDir, `${baseName}.document.xml`), xml, 'utf8');
279
+ entry.stages[stage] = {
280
+ docx: `${baseName}.docx`,
281
+ documentXml: `${baseName}.document.xml`,
282
+ revisions: revisionCounts(xml),
283
+ visibleText: ingestWordOoxmlToPlainText(xml)
284
+ };
285
+ }
286
+ manifest.scenarios.push(entry);
287
+ }
288
+
289
+ writeFileSync(join(resolvedOutputDir, 'README.md'), `# Re-redlining stress fixtures
290
+
291
+ Generated by \`node scripts/export-reredline-stress-fixtures.mjs\`.
292
+
293
+ Each scenario contains five independently redlined prose paragraphs, six real
294
+ Word list paragraphs (four bullet and two numbered), and three tables covering
295
+ multi-cell updates, row insertion, and row deletion. The stages are:
296
+
297
+ - \`source\`: clean input with no tracked changes.
298
+ - \`round1\`: a heavy first review by \`Round One Reviewer\`.
299
+ - \`rerelined\`: each revised block is normalized with
300
+ \`existingRevisions: "accept-all-first"\`, then changed again by
301
+ \`Round Two Reviewer\` and assembled into one heavily redlined document.
302
+
303
+ The adjacent \`.document.xml\` files support direct validator/XSD inspection;
304
+ \`manifest.json\` records visible text and revision counts for every stage.
305
+ `, 'utf8');
306
+ writeFileSync(join(resolvedOutputDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
307
+ return manifest;
308
+ }
309
+
310
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
311
+ if (isMain) {
312
+ const outputIndex = process.argv.indexOf('--output-dir');
313
+ const outputDir = outputIndex >= 0 ? process.argv[outputIndex + 1] : join(process.cwd(), 'tests', 'fixtures', 'reredline-stress');
314
+ if (!outputDir) throw new Error('--output-dir requires a path');
315
+ const manifest = await generateReredlineStressFixtures(outputDir);
316
+ console.log(`Wrote ${manifest.scenarios.length * 3} DOCX fixtures to ${resolve(outputDir)}`);
317
+ }
@@ -260,7 +260,7 @@ Validation entry points:
260
260
  - Word (differential accept/reject): \`npm run smoke:word:diff\`
261
261
  - LibreOffice parse check: \`soffice --headless --convert-to pdf *.docx\`
262
262
  - Schema check: \`xmllint --noout --schema wml.xsd *.document.xml\`
263
- (transitional schemas from ECMA-376 Part 4; see docs/VALIDATION.md)
263
+ (transitional schemas from ECMA-376 Part 4; see docs/TESTING.md)
264
264
  `, 'utf8');
265
265
 
266
266
  if (failures > 0) {
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Compatibility entrypoint for legacy docx-redline skill invocations.
4
+ // The supported CLI owns selector parsing and emits 1-based paragraph indexes.
5
+ import { runCli } from '../node/cli.js';
6
+
7
+ process.exitCode = await runCli(['extract', ...process.argv.slice(2)]);
@@ -0,0 +1,256 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
3
+
4
+ $repoRoot = Split-Path -Parent $PSScriptRoot
5
+ $fixturesDir = Join-Path $repoRoot "tests\fixtures\cross-author-slicing"
6
+ if (-not (Test-Path $fixturesDir)) {
7
+ New-Item -ItemType Directory -Path $fixturesDir -Force | Out-Null
8
+ }
9
+
10
+ function Extract-DocumentXml($docxPath, $xmlPath) {
11
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($docxPath)
12
+ try {
13
+ $entry = $zip.GetEntry("word/document.xml")
14
+ if ($entry) {
15
+ $reader = New-Object System.IO.StreamReader($entry.Open(), [System.Text.Encoding]::UTF8)
16
+ try {
17
+ $xml = $reader.ReadToEnd()
18
+ [System.IO.File]::WriteAllText($xmlPath, $xml, [System.Text.Encoding]::UTF8)
19
+ } finally {
20
+ $reader.Dispose()
21
+ }
22
+ }
23
+ } finally {
24
+ $zip.Dispose()
25
+ }
26
+ }
27
+
28
+ function Save-Triple($doc, [string]$baseName) {
29
+ $pendingDocx = [string](Join-Path $fixturesDir "$baseName-pending.docx")
30
+ $acceptedDocx = [string](Join-Path $fixturesDir "$baseName-accepted.docx")
31
+ $rejectedDocx = [string](Join-Path $fixturesDir "$baseName-rejected.docx")
32
+
33
+ [object]$pRef = $pendingDocx
34
+ [object]$aRef = $acceptedDocx
35
+ [object]$rRef = $rejectedDocx
36
+ [object]$fmt = 16
37
+
38
+ # Save pending
39
+ $doc.SaveAs2([ref]$pRef, [ref]$fmt)
40
+
41
+ # Accept All
42
+ $doc.Revisions.AcceptAll()
43
+ $doc.SaveAs2([ref]$aRef, [ref]$fmt)
44
+ $doc.Close($false)
45
+
46
+ # Reopen pending and Reject All
47
+ $reopened = $global:word.Documents.Open([ref]$pRef)
48
+ $reopened.Revisions.RejectAll()
49
+ $reopened.SaveAs2([ref]$rRef, [ref]$fmt)
50
+ $reopened.Close($false)
51
+
52
+ # Extract XML
53
+ Extract-DocumentXml $pendingDocx (Join-Path $fixturesDir "$baseName-pending.xml")
54
+ Extract-DocumentXml $acceptedDocx (Join-Path $fixturesDir "$baseName-accepted.xml")
55
+ Extract-DocumentXml $rejectedDocx (Join-Path $fixturesDir "$baseName-rejected.xml")
56
+ Write-Host "Saved: $baseName"
57
+ }
58
+
59
+ function Find-RequiredText($doc, [string]$text, [string]$scenario) {
60
+ $foundPos = $doc.Content.Text.IndexOf($text)
61
+ if ($foundPos -lt 0) {
62
+ throw "Scenario '$scenario' could not find required text '$text'."
63
+ }
64
+ return $foundPos
65
+ }
66
+
67
+ $origUserName = $null
68
+ $origUserInitials = $null
69
+
70
+ try {
71
+ $global:word = New-Object -ComObject Word.Application
72
+ $global:word.Visible = $false
73
+ $global:word.DisplayAlerts = 0
74
+ $origUserName = $global:word.UserName
75
+ $origUserInitials = $global:word.UserInitials
76
+
77
+ # -------------------------------------------------------------------------
78
+ # Scenario 1: insert-interior
79
+ # Author A (Barry) inserts: "amended by this Agreement."
80
+ # Author B (Anson) inserts "MASTER " before "Agreement"
81
+ # -------------------------------------------------------------------------
82
+ Write-Host "1. insert-interior"
83
+ $doc = $global:word.Documents.Add()
84
+ $doc.TrackRevisions = $false
85
+ $doc.Range(0, 0).Text = "Contract terms "
86
+
87
+ # Author A insertion
88
+ $global:word.UserName = "Barry Plasteras"
89
+ $global:word.UserInitials = "BP"
90
+ $doc.TrackRevisions = $true
91
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
92
+ $endR.Text = "amended by this Agreement."
93
+
94
+ # Author B insertion inside Author A's insertion
95
+ $global:word.UserName = "Anson Lai"
96
+ $global:word.UserInitials = "AL"
97
+ # Find "Agreement" and insert "MASTER " before it
98
+ $targetText = "Agreement."
99
+ $foundPos = Find-RequiredText $doc $targetText "insert-interior"
100
+ $insRange = $doc.Range($foundPos, $foundPos)
101
+ $insRange.Text = "MASTER "
102
+ Save-Triple $doc "insert-interior"
103
+
104
+ # -------------------------------------------------------------------------
105
+ # Scenario 2: delete-interior
106
+ # Author A (Barry) inserts: "The Services will process the Input to generate outputs for Customer."
107
+ # Author B (Anson) deletes: "generate "
108
+ # -------------------------------------------------------------------------
109
+ Write-Host "2. delete-interior"
110
+ $doc = $global:word.Documents.Add()
111
+ $doc.TrackRevisions = $false
112
+ $doc.Range(0, 0).Text = "Background. "
113
+
114
+ # Author A insertion
115
+ $global:word.UserName = "Barry Plasteras"
116
+ $global:word.UserInitials = "BP"
117
+ $doc.TrackRevisions = $true
118
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
119
+ $endR.Text = "The Services will process the Input to generate outputs for Customer."
120
+
121
+ # Author B deletion inside Author A's insertion
122
+ $global:word.UserName = "Anson Lai"
123
+ $global:word.UserInitials = "AL"
124
+ $delWord = "generate "
125
+ $foundPos = Find-RequiredText $doc $delWord "delete-interior"
126
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
127
+ $delRange.Delete() | Out-Null
128
+ Save-Triple $doc "delete-interior"
129
+
130
+ # -------------------------------------------------------------------------
131
+ # Scenario 3: delete-boundary-start
132
+ # Author A (Barry) inserts: "Notwithstanding the foregoing, the NDA remains in effect."
133
+ # Author B (Anson) deletes: "Notwithstanding the foregoing, "
134
+ # -------------------------------------------------------------------------
135
+ Write-Host "3. delete-boundary-start"
136
+ $doc = $global:word.Documents.Add()
137
+ $doc.TrackRevisions = $false
138
+ $doc.Range(0, 0).Text = "Section 1. "
139
+
140
+ # Author A insertion
141
+ $global:word.UserName = "Barry Plasteras"
142
+ $global:word.UserInitials = "BP"
143
+ $doc.TrackRevisions = $true
144
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
145
+ $endR.Text = "Notwithstanding the foregoing, the NDA remains in effect."
146
+
147
+ # Author B deletion at start of insertion
148
+ $global:word.UserName = "Anson Lai"
149
+ $global:word.UserInitials = "AL"
150
+ $delWord = "Notwithstanding the foregoing, "
151
+ $foundPos = Find-RequiredText $doc $delWord "delete-boundary-start"
152
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
153
+ $delRange.Delete() | Out-Null
154
+ Save-Triple $doc "delete-boundary-start"
155
+
156
+ # -------------------------------------------------------------------------
157
+ # Scenario 4: delete-boundary-end
158
+ # Author A (Barry) inserts: "subject to Section 2.8 and applicable law."
159
+ # Author B (Anson) deletes: " and applicable law."
160
+ # -------------------------------------------------------------------------
161
+ Write-Host "4. delete-boundary-end"
162
+ $doc = $global:word.Documents.Add()
163
+ $doc.TrackRevisions = $false
164
+ $doc.Range(0, 0).Text = "Compliance: "
165
+
166
+ # Author A insertion
167
+ $global:word.UserName = "Barry Plasteras"
168
+ $global:word.UserInitials = "BP"
169
+ $doc.TrackRevisions = $true
170
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
171
+ $endR.Text = "subject to Section 2.8 and applicable law."
172
+
173
+ # Author B deletion at end of insertion
174
+ $global:word.UserName = "Anson Lai"
175
+ $global:word.UserInitials = "AL"
176
+ $delWord = " and applicable law."
177
+ $foundPos = Find-RequiredText $doc $delWord "delete-boundary-end"
178
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
179
+ $delRange.Delete() | Out-Null
180
+ Save-Triple $doc "delete-boundary-end"
181
+
182
+ # -------------------------------------------------------------------------
183
+ # Scenario 5: delete-straddle-baseline-insertion
184
+ # Baseline: "Baseline start "
185
+ # Author A (Barry) inserts: "inserted finish."
186
+ # Author B (Anson) deletes: "start inserted" (straddling baseline and insertion)
187
+ # -------------------------------------------------------------------------
188
+ Write-Host "5. delete-straddle-baseline-insertion"
189
+ $doc = $global:word.Documents.Add()
190
+ $doc.TrackRevisions = $false
191
+ $doc.Range(0, 0).Text = "Baseline start "
192
+
193
+ # Author A insertion
194
+ $global:word.UserName = "Barry Plasteras"
195
+ $global:word.UserInitials = "BP"
196
+ $doc.TrackRevisions = $true
197
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
198
+ $endR.Text = "inserted finish."
199
+
200
+ # Author B deletion straddling baseline and insertion
201
+ $global:word.UserName = "Anson Lai"
202
+ $global:word.UserInitials = "AL"
203
+ $delWord = "start inserted"
204
+ $foundPos = Find-RequiredText $doc $delWord "delete-straddle-baseline-insertion"
205
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
206
+ $delRange.Delete() | Out-Null
207
+ Save-Triple $doc "delete-straddle-baseline-insertion"
208
+
209
+ # -------------------------------------------------------------------------
210
+ # Scenario 6: multi-author-stacked
211
+ # Baseline: "Provision "
212
+ # Author A (Barry) inserts: "first draft of the proposal with initial metrics."
213
+ # Author B (Anson) deletes: "of the proposal "
214
+ # Author C (Chris) deletes: "initial " from the remaining text
215
+ # -------------------------------------------------------------------------
216
+ Write-Host "6. multi-author-stacked"
217
+ $doc = $global:word.Documents.Add()
218
+ $doc.TrackRevisions = $false
219
+ $doc.Range(0, 0).Text = "Provision "
220
+
221
+ # Author A insertion
222
+ $global:word.UserName = "Barry Plasteras"
223
+ $global:word.UserInitials = "BP"
224
+ $doc.TrackRevisions = $true
225
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
226
+ $endR.Text = "first draft of the proposal with initial metrics."
227
+
228
+ # Author B deletion inside Author A's insertion
229
+ $global:word.UserName = "Anson Lai"
230
+ $global:word.UserInitials = "AL"
231
+ $delWordB = "of the proposal "
232
+ $foundPosB = Find-RequiredText $doc $delWordB "multi-author-stacked (Author B)"
233
+ $delRangeB = $doc.Range($foundPosB, $foundPosB + $delWordB.Length)
234
+ $delRangeB.Delete() | Out-Null
235
+
236
+ # Author C deletion inside Author A's remaining insertion
237
+ $global:word.UserName = "Chris Davis"
238
+ $global:word.UserInitials = "CD"
239
+ $delWordC = "initial "
240
+ $foundPosC = Find-RequiredText $doc $delWordC "multi-author-stacked (Author C)"
241
+ $delRangeC = $doc.Range($foundPosC, $foundPosC + $delWordC.Length)
242
+ $delRangeC.Delete() | Out-Null
243
+ Save-Triple $doc "multi-author-stacked"
244
+
245
+ Write-Host "All 6 fixture triples successfully generated!"
246
+ }
247
+ finally {
248
+ if ($null -ne $origUserName -and $null -ne $global:word) {
249
+ $global:word.UserName = $origUserName
250
+ $global:word.UserInitials = $origUserInitials
251
+ }
252
+ if ($null -ne $global:word) {
253
+ $global:word.Quit()
254
+ [System.Runtime.InteropServices.Marshal]::ReleaseComObject($global:word) | Out-Null
255
+ }
256
+ }