@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.
- package/AGENTS.md +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /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, '<')
|
|
23
|
+
.replace(/>/g, '>');
|
|
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/
|
|
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
|
+
}
|