@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Builds the Word revision-semantics fixture matrix (WP-2 COM backstop).
3
+ *
4
+ * node scripts/generate-word-revision-semantics-matrix.mjs
5
+ * powershell -File scripts/word-revision-semantics-matrix.ps1
6
+ *
7
+ * Writes tests/fixtures/word-revision-semantics/cases.json and one minimal .docx
8
+ * per case; the PowerShell script drives Word and writes expectations.json.
9
+ */
10
+ import { mkdirSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { buildZip } from './lib/minimal-zip.mjs';
14
+
15
+ const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'tests', 'fixtures', 'word-revision-semantics');
16
+ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
17
+ const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
18
+ const PKG = 'http://schemas.openxmlformats.org/package/2006/relationships';
19
+ const DATE = '2026-01-01T00:00:00Z';
20
+
21
+ let id = 100;
22
+ const rev = (tag, author = 'A') => `<w:${tag} w:id="${id++}" w:author="${author}" w:date="${DATE}"/>`;
23
+ const run = t => `<w:r><w:t xml:space="preserve">${t}</w:t></w:r>`;
24
+ const insRun = (t, a = 'A') => `<w:ins w:id="${id++}" w:author="${a}" w:date="${DATE}"><w:r><w:t xml:space="preserve">${t}</w:t></w:r></w:ins>`;
25
+ const delRun = (t, a = 'A') => `<w:del w:id="${id++}" w:author="${a}" w:date="${DATE}"><w:r><w:delText xml:space="preserve">${t}</w:delText></w:r></w:del>`;
26
+ const NUM = '<w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr>';
27
+ const LIST = '<w:pStyle w:val="ListParagraph"/>' + NUM;
28
+ const mark = (kind, a = 'A') => `<w:rPr>${rev(kind, a)}</w:rPr>`;
29
+ // pPr child order: style/numPr..., rPr (mark), sectPr, pPrChange
30
+ const para = (content, { ppr = '', mark: m = '', extra = '' } = {}) =>
31
+ `<w:p>${ppr || m || extra ? `<w:pPr>${ppr}${m}${extra}</w:pPr>` : ''}${content}</w:p>`;
32
+ const cell = inner => `<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>${inner}</w:tc>`;
33
+ const table = rows => `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>${rows}</w:tbl>`;
34
+ const row = (inner, trPr = '') => `<w:tr>${trPr ? `<w:trPr>${trPr}</w:trPr>` : ''}${inner}</w:tr>`;
35
+ const SECT = '<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="720" w:footer="720" w:gutter="0"/></w:sectPr>';
36
+
37
+ const defs = [];
38
+ function add(name, description, build) { id = 100; defs.push({ name, description, bodyXml: build() + SECT }); }
39
+ const P = t => para(run(t));
40
+ const pprChange = old => `<w:pPrChange w:id="${id++}" w:author="A" w:date="${DATE}"><w:pPr>${old}</w:pPr></w:pPrChange>`;
41
+ const BOLD_MARK = '<w:rPr><w:b/></w:rPr>';
42
+
43
+ add('ins-mark-mid-plain', 'Mid-body paragraph with inserted content and inserted mark',
44
+ () => P('Alpha.') + para(insRun('Inserted.'), { mark: mark('ins') }) + P('Omega.'));
45
+ add('ins-mark-mid-untracked-content', 'Mid-body paragraph, inserted mark, untracked content',
46
+ () => P('Alpha.') + para(run('Untracked.'), { mark: mark('ins') }) + P('Omega.'));
47
+ add('ins-mark-list-then-plain', 'List paragraph with inserted mark followed by plain paragraph',
48
+ () => P('Alpha.') + para(insRun('Item.'), { ppr: LIST, mark: mark('ins') }) + P('Plain after.'));
49
+ add('ins-mark-last-prev-deleted', 'Last body paragraph inserted mark; preceding paragraph mark deleted (replacement shape)',
50
+ () => P('First.') + para(delRun('Second paragraph.'), { mark: mark('del') })
51
+ + para(insRun('Item one.'), { ppr: LIST, mark: mark('ins') })
52
+ + para(insRun('Item two.'), { ppr: LIST, mark: mark('ins') }));
53
+ add('ins-mark-last-prev-plain', 'Last body paragraph inserted mark; preceding paragraph plain',
54
+ () => P('First.') + P('Second paragraph.') + para(insRun('Added last.'), { ppr: LIST, mark: mark('ins') }));
55
+ add('ins-mark-last-plain-style', 'Last body paragraph inserted mark, no list props',
56
+ () => P('First.') + para(insRun('Added last.'), { mark: mark('ins') }));
57
+ add('ins-mark-cell-sole', 'Sole paragraph in a table cell has inserted mark',
58
+ () => P('Intro.') + table(row(cell(para(insRun('Cell ins.'), { mark: mark('ins') })))) + P('After table.'));
59
+ add('ins-mark-cell-last-of-two', 'Last of two paragraphs in a cell has inserted mark',
60
+ () => P('Intro.') + table(row(cell(P('Cell one.') + para(insRun('Cell two.'), { mark: mark('ins') })))) + P('After table.'));
61
+ add('ins-mark-before-table', 'Paragraph with inserted mark directly before a table',
62
+ () => P('Intro.') + para(insRun('Inserted.'), { mark: mark('ins') }) + table(row(cell(P('Cell.')))) + P('After table.'));
63
+ add('ins-mark-bookmark-between', 'Inserted mark paragraph with bookmark between paragraphs',
64
+ () => P('Alpha.') + para('<w:bookmarkStart w:id="900" w:name="bm1"/>' + insRun('Inserted.') + '<w:bookmarkEnd w:id="900"/>', { mark: mark('ins') }) + P('Omega.'));
65
+
66
+ add('del-mark-mid-plain', 'Mid-body paragraph with deleted content and deleted mark',
67
+ () => P('Alpha.') + para(delRun('Deleted.'), { mark: mark('del') }) + P('Omega.'));
68
+ add('del-mark-mid-untracked-content', 'Mid-body paragraph, deleted mark, untracked content',
69
+ () => P('Alpha.') + para(run('Untracked.'), { mark: mark('del') }) + P('Omega.'));
70
+ add('del-mark-list-then-plain', 'List paragraph with deleted mark followed by plain paragraph',
71
+ () => P('Alpha.') + para(delRun('Item.'), { ppr: LIST, mark: mark('del') }) + P('Plain after.'));
72
+ add('del-mark-last', 'Last body paragraph with deleted mark',
73
+ () => P('First.') + para(delRun('Gone last.'), { mark: mark('del') }));
74
+ add('del-mark-last-list-prev-plain', 'Last body list paragraph with deleted mark; preceding plain',
75
+ () => P('First.') + P('Second.') + para(delRun('Gone last.'), { ppr: LIST, mark: mark('del') }));
76
+ add('del-mark-cell-sole', 'Sole cell paragraph with deleted mark',
77
+ () => P('Intro.') + table(row(cell(para(delRun('Cell del.'), { mark: mark('del') })))) + P('After table.'));
78
+ add('del-mark-cell-last-of-two', 'Last of two cell paragraphs with deleted mark',
79
+ () => P('Intro.') + table(row(cell(P('Cell one.') + para(delRun('Cell two.'), { mark: mark('del') })))) + P('After table.'));
80
+ add('del-mark-before-table', 'Paragraph with deleted mark directly before a table',
81
+ () => P('Intro.') + para(delRun('Deleted.'), { mark: mark('del') }) + table(row(cell(P('Cell.')))) + P('After table.'));
82
+ add('del-mark-bookmark-between', 'Deleted mark paragraph with bookmark between paragraphs',
83
+ () => P('Alpha.') + para('<w:bookmarkStart w:id="900" w:name="bm1"/>' + delRun('Deleted.') + '<w:bookmarkEnd w:id="900"/>', { mark: mark('del') }) + P('Omega.'));
84
+
85
+ add('pprchange-restores-list', 'pPrChange: current Heading1, previous list item; mark rPr has bold',
86
+ () => P('Alpha.') + para(run('Was a list item.'), { ppr: '<w:pStyle w:val="Heading1"/>', mark: BOLD_MARK, extra: pprChange(LIST) }) + P('Omega.'));
87
+ add('pprchange-removes-list', 'pPrChange: current list item, previous plain; mark rPr has bold',
88
+ () => P('Alpha.') + para(run('Became a list item.'), { ppr: LIST, mark: BOLD_MARK, extra: pprChange('') }) + P('Omega.'));
89
+ add('del-mark-with-sectpr', 'Deleted-mark paragraph carrying a section break',
90
+ () => para(delRun('Section one end.'), { mark: mark('del'), extra: '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>' }) + P('Section two.'));
91
+ add('adjacent-ins-then-del-marks', 'Inserted-mark paragraph followed by deleted-mark paragraph',
92
+ () => P('Alpha.') + para(insRun('New.'), { mark: mark('ins') }) + para(delRun('Old.'), { mark: mark('del') }) + P('Keep.'));
93
+ add('adjacent-del-then-ins-marks', 'Deleted-mark paragraph followed by inserted-mark paragraph',
94
+ () => P('Alpha.') + para(delRun('Old.'), { mark: mark('del') }) + para(insRun('New.'), { mark: mark('ins') }) + P('Keep.'));
95
+ add('mixed-authors-marks', 'Inserted marks by two authors plus a deleted mark by a third',
96
+ () => P('Alpha.') + para(insRun('By A.', 'A'), { mark: mark('ins', 'A') })
97
+ + para(insRun('By B.', 'B'), { mark: mark('ins', 'B') })
98
+ + para(delRun('By C.', 'C'), { mark: mark('del', 'C') }) + P('Omega.'));
99
+ add('ins-row', 'Table with an inserted row',
100
+ () => P('Intro.') + table(
101
+ row(cell(P('Existing row.')))
102
+ + row(cell(para(insRun('Inserted row.'), { mark: mark('ins') })), rev('ins'))) + P('After table.'));
103
+ add('del-row', 'Table with a deleted row',
104
+ () => P('Intro.') + table(
105
+ row(cell(P('Existing row.')))
106
+ + row(cell(para(delRun('Deleted row.'), { mark: mark('del') })), rev('del'))) + P('After table.'));
107
+
108
+ // R5: end-of-document table append. insRow = row-level ins with inserted cell marks (library shape).
109
+ const insRow = (text, cols = 2) => row(Array.from({ length: cols }, (_, i) => cell(para(insRun(`${text}${i + 1}`), { mark: mark('ins') }))).join(''), rev('ins'));
110
+ add('table-append-end-trailing-ins-mark', 'Table appended after the final paragraph: inserted rows, then an empty trailing paragraph with an inserted mark (library shape)',
111
+ () => P('Alpha.') + P('Final paragraph.') + table(insRow('R1C') + insRow('R2C')) + para('', { mark: mark('ins') }));
112
+ add('table-append-end-word-native', 'Table appended after the final paragraph as Word writes it: final paragraph mark inserted, inserted rows, untracked empty trailing paragraph',
113
+ () => P('Alpha.') + para(run('Final paragraph.'), { mark: mark('ins') }) + table(insRow('R1C') + insRow('R2C')) + P(''));
114
+ add('table-append-end-untracked-trailing', 'Table appended after the final paragraph: inserted rows followed by an untracked empty trailing paragraph',
115
+ () => P('Alpha.') + P('Final paragraph.') + table(insRow('R1C') + insRow('R2C')) + P(''));
116
+ add('table-append-end-no-trailing-paragraph', 'Table appended at the very end with no paragraph after it: Word supplies an untracked empty paragraph (the pretty-printed-body defect shape)',
117
+ () => P('Alpha.') + P('Final paragraph.') + table(insRow('R1C') + insRow('R2C')));
118
+
119
+ const wrap = body => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${W}" xmlns:r="${R}"><w:body>${body}</w:body></w:document>`;
120
+ const styles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="${W}"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/></w:pPr></w:style></w:styles>`;
121
+ const numbering = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering xmlns:w="${W}"><w:abstractNum w:abstractNumId="0"><w:multiLevelType w:val="hybridMultilevel"/><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:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>`;
122
+ const entries = body => [
123
+ { name: '[Content_Types].xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/></Types>` },
124
+ { name: '_rels/.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${PKG}"><Relationship Id="rId1" Type="${R}/officeDocument" Target="word/document.xml"/></Relationships>` },
125
+ { name: 'word/document.xml', data: wrap(body) },
126
+ { name: 'word/_rels/document.xml.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${PKG}"><Relationship Id="rId1" Type="${R}/styles" Target="styles.xml"/><Relationship Id="rId2" Type="${R}/numbering" Target="numbering.xml"/></Relationships>` },
127
+ { name: 'word/styles.xml', data: styles },
128
+ { name: 'word/numbering.xml', data: numbering }
129
+ ];
130
+
131
+ mkdirSync(OUT, { recursive: true });
132
+ for (const def of defs) writeFileSync(join(OUT, `${def.name}.docx`), buildZip(entries(def.bodyXml)));
133
+ writeFileSync(join(OUT, 'cases.json'), JSON.stringify(defs, null, 2) + '\n');
134
+ console.log(`Wrote ${defs.length} cases to ${OUT}`);
@@ -50,6 +50,7 @@ function assertSyntheticClaim(testCase, structure) {
50
50
  || testCase.original.includes('\n') || testCase.modified.includes('\n'),
51
51
  'formatted-runs': () => /<(?:w:)?(?:b|i|u)(?:\s|\/|>)/.test(xml) || /(?:\*\*|\*|\+\+)/.test(testCase.modified),
52
52
  list: () => elementCount(xml, 'numPr') > 0
53
+ || (testCase.batchOperations || []).some(operation => operation?.type === 'list-format')
53
54
  || (testCase.operationOptions?.structuredContent === true
54
55
  && /^\s*(?:[-+*]|\d+[.)])\s+/m.test(testCase.modified)),
55
56
  table: () => elementCount(xml, 'tbl') > 0
@@ -74,8 +75,12 @@ function assertSyntheticClaim(testCase, structure) {
74
75
  }
75
76
 
76
77
  function syntheticTask(testCase) {
77
- if (testCase.operation?.type === 'list-change') return 'list-change';
78
- if (testCase.operation?.type === 'table-reconciliation') return 'table-reconciliation';
78
+ const operationTypes = [testCase.operation, ...(testCase.batchOperations || [])]
79
+ .map(operation => operation?.type)
80
+ .filter(Boolean);
81
+ if (operationTypes.includes('list-change') || operationTypes.includes('list-format')) return 'list-change';
82
+ if (operationTypes.includes('table-reconciliation')) return 'table-reconciliation';
83
+ if (operationTypes.includes('paragraph-format') || operationTypes.includes('character-format')) return 'format';
79
84
  if (testCase.expectAtomicRollback) return 'mixed-batch';
80
85
  if (testCase.expectNoOp) return 'accept-reject';
81
86
  if (testCase.task.startsWith('apply-')) return 'format';
@@ -16,8 +16,8 @@ try {
16
16
  $pdfPath = Join-Path $resolvedOut "multilevel-bullet--$view.pdf"
17
17
  $doc = $word.Documents.OpenNoRepairDialog($resolvedDocx)
18
18
  try {
19
- if ($view -eq 'acceptAll') { $doc.AcceptAllRevisions(); $exportItem = 0 }
20
- elseif ($view -eq 'rejectAll') { $doc.RejectAllRevisions(); $exportItem = 0 }
19
+ if ($view -eq 'acceptAll') { $doc.Revisions.AcceptAll(); $exportItem = 0 }
20
+ elseif ($view -eq 'rejectAll') { $doc.Revisions.RejectAll(); $exportItem = 0 }
21
21
  else { $doc.ShowRevisions = $true; $doc.PrintRevisions = $true; $exportItem = 7 }
22
22
  $pages = $doc.ComputeStatistics(2)
23
23
  $doc.ExportAsFixedFormat($pdfPath, 17, $false, 0, 0, 1, 1, $exportItem, $true, $true, 0, $true, $true, $false)
@@ -60,8 +60,8 @@ function Export-View($word, [string]$docxPath, [string]$pdfPath, [string]$viewNa
60
60
  $document = $null
61
61
  try {
62
62
  $document = $word.Documents.OpenNoRepairDialog($docxPath)
63
- if ($viewName -eq 'acceptAll') { $document.AcceptAllRevisions(); $exportItem = 0 }
64
- elseif ($viewName -eq 'rejectAll') { $document.RejectAllRevisions(); $exportItem = 0 }
63
+ if ($viewName -eq 'acceptAll') { $document.Revisions.AcceptAll(); $exportItem = 0 }
64
+ elseif ($viewName -eq 'rejectAll') { $document.Revisions.RejectAll(); $exportItem = 0 }
65
65
  else { $document.ShowRevisions = $true; $document.PrintRevisions = $true; $exportItem = 7 }
66
66
  $pages = $document.ComputeStatistics(2)
67
67
  $document.ExportAsFixedFormat($pdfPath, 17, $false, 0, 0, 1, 1, $exportItem, $true, $true, 0, $true, $true, $false)