@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
@@ -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,215 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
3
+
4
+ $fixturesDir = "C:\Users\Phara\Desktop\Projects\Docx Redline JS\tests\fixtures\paragraph-boundaries"
5
+ if (-not (Test-Path $fixturesDir)) {
6
+ New-Item -ItemType Directory -Path $fixturesDir -Force | Out-Null
7
+ }
8
+
9
+ function Extract-DocumentXml($docxPath, $xmlPath) {
10
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($docxPath)
11
+ try {
12
+ $entry = $zip.GetEntry("word/document.xml")
13
+ if ($entry) {
14
+ $reader = New-Object System.IO.StreamReader($entry.Open(), [System.Text.Encoding]::UTF8)
15
+ try {
16
+ $xml = $reader.ReadToEnd()
17
+ [System.IO.File]::WriteAllText($xmlPath, $xml, [System.Text.Encoding]::UTF8)
18
+ } finally {
19
+ $reader.Dispose()
20
+ }
21
+ }
22
+ } finally {
23
+ $zip.Dispose()
24
+ }
25
+ }
26
+
27
+ function Save-Triple($doc, [string]$baseName) {
28
+ $pendingDocx = [string](Join-Path $fixturesDir "$baseName-pending.docx")
29
+ $acceptedDocx = [string](Join-Path $fixturesDir "$baseName-accepted.docx")
30
+ $rejectedDocx = [string](Join-Path $fixturesDir "$baseName-rejected.docx")
31
+
32
+ [object]$pRef = $pendingDocx
33
+ [object]$aRef = $acceptedDocx
34
+ [object]$rRef = $rejectedDocx
35
+ [object]$fmt = 16
36
+
37
+ # Save pending
38
+ $doc.SaveAs2([ref]$pRef, [ref]$fmt)
39
+
40
+ # Accept All
41
+ $doc.Revisions.AcceptAll()
42
+ $doc.SaveAs2([ref]$aRef, [ref]$fmt)
43
+ $doc.Close($false)
44
+
45
+ # Reopen pending and Reject All
46
+ $reopened = $global:word.Documents.Open([ref]$pRef)
47
+ $reopened.Revisions.RejectAll()
48
+ $reopened.SaveAs2([ref]$rRef, [ref]$fmt)
49
+ $reopened.Close($false)
50
+
51
+ # Extract XML
52
+ Extract-DocumentXml $pendingDocx (Join-Path $fixturesDir "$baseName-pending.xml")
53
+ Extract-DocumentXml $acceptedDocx (Join-Path $fixturesDir "$baseName-accepted.xml")
54
+ Extract-DocumentXml $rejectedDocx (Join-Path $fixturesDir "$baseName-rejected.xml")
55
+ Write-Host "Saved: $baseName"
56
+ }
57
+
58
+ $global:word = New-Object -ComObject Word.Application
59
+ $global:word.Visible = $false
60
+ $global:word.DisplayAlerts = 0
61
+
62
+ try {
63
+ # 1. split-middle
64
+ Write-Host "1. split-middle"
65
+ $doc = $global:word.Documents.Add()
66
+ $doc.TrackRevisions = $false
67
+ $doc.Range(0, 0).Text = "Sentence one. Sentence two."
68
+ $doc.TrackRevisions = $true
69
+ $splitPoint = $doc.Range(14, 14)
70
+ $splitPoint.InsertParagraph()
71
+ Save-Triple $doc "split-middle"
72
+
73
+ # 2. delete-boundary
74
+ Write-Host "2. delete-boundary"
75
+ $doc = $global:word.Documents.Add()
76
+ $doc.TrackRevisions = $false
77
+ $r = $doc.Range(0, 0)
78
+ $r.Text = "Paragraph one."
79
+ $r.InsertParagraphAfter()
80
+ $doc.Paragraphs.Item(2).Range.Text = "Paragraph two."
81
+ $doc.TrackRevisions = $true
82
+ $p1 = $doc.Paragraphs.Item(1).Range
83
+ $delPoint = $doc.Range($p1.End - 1, $p1.End)
84
+ $delPoint.Delete() | Out-Null
85
+ Save-Triple $doc "delete-boundary"
86
+
87
+ # 3. delete-middle-paragraph
88
+ Write-Host "3. delete-middle-paragraph"
89
+ $doc = $global:word.Documents.Add()
90
+ $doc.TrackRevisions = $false
91
+ $r = $doc.Range(0, 0)
92
+ $r.Text = "Paragraph one."
93
+ $r.InsertParagraphAfter()
94
+ $doc.Paragraphs.Item(2).Range.Text = "Paragraph two."
95
+ $doc.Paragraphs.Item(2).Range.InsertParagraphAfter()
96
+ $doc.Paragraphs.Item(3).Range.Text = "Paragraph three."
97
+ $doc.TrackRevisions = $true
98
+ $p2 = $doc.Paragraphs.Item(2).Range
99
+ $p2.Delete() | Out-Null
100
+ Save-Triple $doc "delete-middle-paragraph"
101
+
102
+ # 4. insert-blank-paragraph
103
+ Write-Host "4. insert-blank-paragraph"
104
+ $doc = $global:word.Documents.Add()
105
+ $doc.TrackRevisions = $false
106
+ $r = $doc.Range(0, 0)
107
+ $r.Text = "Paragraph one."
108
+ $r.InsertParagraphAfter()
109
+ $doc.Paragraphs.Item(2).Range.Text = "Paragraph two."
110
+ $doc.TrackRevisions = $true
111
+ $p1 = $doc.Paragraphs.Item(1).Range
112
+ $insPoint = $doc.Range($p1.End - 1, $p1.End - 1)
113
+ $insPoint.InsertParagraph()
114
+ Save-Triple $doc "insert-blank-paragraph"
115
+
116
+ # 5. different-styles-boundary
117
+ Write-Host "5. different-styles-boundary"
118
+ $doc = $global:word.Documents.Add()
119
+ $doc.TrackRevisions = $false
120
+ $p1 = $doc.Paragraphs.Item(1).Range
121
+ $p1.Text = "Heading title"
122
+ $p1.Style = "Heading 1"
123
+ $p1.InsertParagraphAfter()
124
+ $p2 = $doc.Paragraphs.Item(2).Range
125
+ $p2.Text = "Normal body text."
126
+ $p2.Style = "Normal"
127
+ $doc.TrackRevisions = $true
128
+ $p1End = $doc.Paragraphs.Item(1).Range.End
129
+ $delPoint = $doc.Range($p1End - 1, $p1End)
130
+ $delPoint.Delete() | Out-Null
131
+ Save-Triple $doc "different-styles-boundary"
132
+
133
+ # 6. different-list-levels-boundary
134
+ Write-Host "6. different-list-levels-boundary"
135
+ $doc = $global:word.Documents.Add()
136
+ $doc.TrackRevisions = $false
137
+ $p1 = $doc.Paragraphs.Item(1).Range
138
+ $p1.Text = "List item 1"
139
+ $p1.InsertParagraphAfter()
140
+ $p2 = $doc.Paragraphs.Item(2).Range
141
+ $p2.Text = "List item 2"
142
+ $doc.Paragraphs.Item(1).Range.ListFormat.ApplyBulletDefault()
143
+ $doc.Paragraphs.Item(2).Range.ListFormat.ApplyBulletDefault()
144
+ $doc.Paragraphs.Item(2).Range.ListFormat.ListIndent()
145
+ $doc.TrackRevisions = $true
146
+ $p1End = $doc.Paragraphs.Item(1).Range.End
147
+ $delPoint = $doc.Range($p1End - 1, $p1End)
148
+ $delPoint.Delete() | Out-Null
149
+ Save-Triple $doc "different-list-levels-boundary"
150
+
151
+ # 7. section-break-boundary
152
+ Write-Host "7. section-break-boundary"
153
+ $doc = $global:word.Documents.Add()
154
+ $doc.TrackRevisions = $false
155
+ $p1 = $doc.Paragraphs.Item(1).Range
156
+ $p1.Text = "Section one text."
157
+ $p1.InsertBreak(2) # wdSectionBreakNextPage
158
+ $p2 = $doc.Paragraphs.Item(2).Range
159
+ $p2.Text = "Section two text."
160
+ $doc.TrackRevisions = $true
161
+ $p1End = $doc.Paragraphs.Item(1).Range.End
162
+ $delPoint = $doc.Range($p1End - 1, $p1End)
163
+ $delPoint.Delete() | Out-Null
164
+ Save-Triple $doc "section-break-boundary"
165
+
166
+ # 8. table-cell-boundary
167
+ Write-Host "8. table-cell-boundary"
168
+ $doc = $global:word.Documents.Add()
169
+ $doc.TrackRevisions = $false
170
+ $table = $doc.Tables.Add($doc.Range(0, 0), 1, 1)
171
+ $cell = $table.Cell(1, 1)
172
+ $cell.Range.Text = "Cell paragraph 1"
173
+ $cell.Range.InsertParagraphAfter()
174
+ $p2 = $doc.Paragraphs.Item(2).Range
175
+ $p2.Text = "Cell paragraph 2"
176
+ $doc.TrackRevisions = $true
177
+ $p1End = $doc.Paragraphs.Item(1).Range.End
178
+ $delPoint = $doc.Range($p1End - 1, $p1End)
179
+ $delPoint.Delete() | Out-Null
180
+ Save-Triple $doc "table-cell-boundary"
181
+
182
+ # 9. adjacent-bookmark-comment
183
+ Write-Host "9. adjacent-bookmark-comment"
184
+ $doc = $global:word.Documents.Add()
185
+ $doc.TrackRevisions = $false
186
+ $p1 = $doc.Paragraphs.Item(1).Range
187
+ $p1.Text = "Paragraph with bookmark."
188
+ $p1.InsertParagraphAfter()
189
+ $p2 = $doc.Paragraphs.Item(2).Range
190
+ $p2.Text = "Second paragraph."
191
+ $bmRange = $doc.Range(0, 9)
192
+ $doc.Bookmarks.Add("TestBookmark", $bmRange) | Out-Null
193
+ $doc.Comments.Add($bmRange, "Comment on first paragraph") | Out-Null
194
+ $doc.TrackRevisions = $true
195
+ $p1End = $doc.Paragraphs.Item(1).Range.End
196
+ $delPoint = $doc.Range($p1End - 1, $p1End)
197
+ $delPoint.Delete() | Out-Null
198
+ Save-Triple $doc "adjacent-bookmark-comment"
199
+
200
+ # 10. multi-author-boundary
201
+ Write-Host "10. multi-author-boundary"
202
+ $doc = $global:word.Documents.Add()
203
+ $doc.TrackRevisions = $false
204
+ $doc.Range(0, 0).Text = "Author one text. Author two text."
205
+ $doc.TrackRevisions = $true
206
+ $global:word.UserName = "AuthorOne"
207
+ $doc.Range(16, 16).InsertParagraph()
208
+ $global:word.UserName = "AuthorTwo"
209
+ $doc.Paragraphs.Item(2).Range.InsertBefore("Updated ")
210
+ Save-Triple $doc "multi-author-boundary"
211
+
212
+ Write-Host "All 10 Word paragraph boundary golden fixtures generated successfully!"
213
+ } finally {
214
+ $global:word.Quit()
215
+ }