@ansonlai/docx-redline-js 0.8.0 → 0.8.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.
- package/CHANGELOG.md +539 -486
- package/README.md +331 -309
- package/dist/docx-redline-js.esm.js +1083 -163
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +107 -107
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +1083 -163
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +1083 -163
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.js +416 -22
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -575
- package/index.js +349 -348
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/index.d.ts +1 -1
- package/package.json +2 -1
- package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
- package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
- package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
- package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
- package/scripts/generate-word-package-integrity-cases.mjs +141 -0
- package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
- package/scripts/word-com-package-integrity.ps1 +98 -0
- package/services/batch-operation-orchestrator.js +262 -259
- package/services/comment-replies.js +96 -95
- package/services/comment-thread-parts.js +280 -0
- package/services/document-inspection.js +366 -365
- package/services/document-operation-applier.js +230 -202
- package/services/document-operation-contract.js +17 -2
- package/services/error-recovery.js +6 -0
- package/services/headers-footers.js +206 -0
- package/services/operation-batch-compiler.js +1 -0
- package/services/operation-preflight.js +231 -218
- package/services/package-parts.js +80 -0
- package/services/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
package/node/index.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export class DocxDocument {
|
|
|
22
22
|
preflight(operations: DocumentOperation[], author?: string, options?: StandaloneRunnerOptions): OperationPreflightResult;
|
|
23
23
|
applyOperations(operations: DocumentOperation[], options?: DocxApplyOptions): Promise<DocxApplyResult>;
|
|
24
24
|
resolveRevisions(action: 'accept' | 'reject', options: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
|
|
25
|
-
deleteComments(options: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
|
|
25
|
+
deleteComments(options: { author?: string; allAuthors?: boolean; ids?: Array<string | number>; validate?: boolean }): Promise<DocxApplyResult>;
|
|
26
26
|
toBuffer(): Uint8Array;
|
|
27
27
|
}
|
|
28
28
|
export function computePackageRevisionToken(input: unknown): RevisionToken;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansonlai/docx-redline-js",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"smoke:word": "powershell -File scripts/word-com-smoke.ps1",
|
|
109
109
|
"smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
|
|
110
110
|
"test:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-suite.ps1",
|
|
111
|
+
"test:word:package": "node scripts/generate-word-package-integrity-cases.mjs && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-package-integrity.ps1",
|
|
111
112
|
"test:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-visual-suite.ps1",
|
|
112
113
|
"test:corpus:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-suite.ps1",
|
|
113
114
|
"test:corpus:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-visual-suite.ps1",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Generates tests/fixtures/word-authored/comment-edge-cases.docx using real Word: comments on middle words,
|
|
2
|
+
# across two paragraphs, inside a table cell, on text with another author's tracked insertion, and on a hyperlink.
|
|
3
|
+
$ErrorActionPreference = 'Stop'
|
|
4
|
+
$outDir = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\tests\fixtures\word-authored'))
|
|
5
|
+
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
|
6
|
+
$path = [string](Join-Path $outDir 'comment-edge-cases.docx')
|
|
7
|
+
if (Test-Path $path) { Remove-Item $path -Force }
|
|
8
|
+
$word = New-Object -ComObject Word.Application
|
|
9
|
+
$word.Visible = $false
|
|
10
|
+
$word.DisplayAlerts = 0
|
|
11
|
+
try {
|
|
12
|
+
$doc = $word.Documents.Add()
|
|
13
|
+
$doc.TrackRevisions = $false
|
|
14
|
+
$doc.Range(0, 0).Text = "Alpha beta gamma delta epsilon.`rThe counterparty proposed tenure.`rSpanning first paragraph.`rSpanning second paragraph.`rLink here: example.com now.`rTable follows."
|
|
15
|
+
|
|
16
|
+
# 1: comment on the middle words only
|
|
17
|
+
$r = $doc.Paragraphs.Item(1).Range
|
|
18
|
+
$mid = $doc.Range($r.Start + 6, $r.Start + 16) # "beta gamma"
|
|
19
|
+
$c = $doc.Comments.Add($mid, "Middle words comment"); $c.Author = "Reviewer"
|
|
20
|
+
|
|
21
|
+
# 2: another author's tracked insertion inside a commented paragraph
|
|
22
|
+
$word.UserName = "Other Author"
|
|
23
|
+
$doc.TrackRevisions = $true
|
|
24
|
+
$p2 = $doc.Paragraphs.Item(2).Range
|
|
25
|
+
$ins = $doc.Range($p2.End - 8, $p2.End - 8) # before "tenure."
|
|
26
|
+
$ins.InsertAfter("long-term ")
|
|
27
|
+
$doc.TrackRevisions = $false
|
|
28
|
+
$word.UserName = "Reviewer"
|
|
29
|
+
$c = $doc.Comments.Add($doc.Paragraphs.Item(2).Range, "Comment over a tracked insertion"); $c.Author = "Reviewer"
|
|
30
|
+
|
|
31
|
+
# 3: one comment spanning two paragraphs
|
|
32
|
+
$span = $doc.Range($doc.Paragraphs.Item(3).Range.Start, $doc.Paragraphs.Item(4).Range.End - 1)
|
|
33
|
+
$c = $doc.Comments.Add($span, "Spans two paragraphs"); $c.Author = "Reviewer"
|
|
34
|
+
|
|
35
|
+
# 4: comment on a hyperlink
|
|
36
|
+
$p5 = $doc.Paragraphs.Item(5).Range
|
|
37
|
+
$linkRange = $doc.Range($p5.Start + 11, $p5.Start + 22) # "example.com"
|
|
38
|
+
$doc.Hyperlinks.Add($linkRange, "https://example.com") | Out-Null
|
|
39
|
+
$p5 = $doc.Paragraphs.Item(5).Range
|
|
40
|
+
$c = $doc.Comments.Add($p5, "On a paragraph with a hyperlink"); $c.Author = "Reviewer"
|
|
41
|
+
|
|
42
|
+
# 5: a table with a commented cell
|
|
43
|
+
$end = $doc.Content; $end.InsertParagraphAfter()
|
|
44
|
+
$tableRange = $doc.Paragraphs.Item($doc.Paragraphs.Count).Range
|
|
45
|
+
$table = $doc.Tables.Add($tableRange, 2, 2)
|
|
46
|
+
$table.Cell(1, 1).Range.Text = "Cell one"
|
|
47
|
+
$table.Cell(1, 2).Range.Text = "Cell two"
|
|
48
|
+
$table.Cell(2, 1).Range.Text = "Cell three"
|
|
49
|
+
$table.Cell(2, 2).Range.Text = "Cell four"
|
|
50
|
+
$cellText = $table.Cell(1, 2).Range
|
|
51
|
+
$cellRange = $doc.Range($cellText.Start, $cellText.End - 1)
|
|
52
|
+
$c = $doc.Comments.Add($cellRange, "Comment in a table cell"); $c.Author = "Reviewer"
|
|
53
|
+
|
|
54
|
+
[object]$p = $path; [object]$f = 16
|
|
55
|
+
$doc.SaveAs2([ref]$p, [ref]$f)
|
|
56
|
+
$doc.Close($false)
|
|
57
|
+
Write-Host "Saved $path"
|
|
58
|
+
} finally { $word.Quit() }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Generates tests/fixtures/word-authored/threaded-comments.docx using real Word.
|
|
2
|
+
# The fixture is the oracle for package content types: tests compare our output against
|
|
3
|
+
# what Word itself wrote, so the expected values are not derived from our own constants.
|
|
4
|
+
$ErrorActionPreference = 'Stop'
|
|
5
|
+
$outDir = Join-Path $PSScriptRoot '..\tests\fixtures\word-authored'
|
|
6
|
+
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
|
7
|
+
$outPath = [System.IO.Path]::GetFullPath((Join-Path $outDir 'threaded-comments.docx'))
|
|
8
|
+
if (Test-Path $outPath) { Remove-Item $outPath -Force }
|
|
9
|
+
|
|
10
|
+
$word = New-Object -ComObject Word.Application
|
|
11
|
+
$word.Visible = $false
|
|
12
|
+
$word.DisplayAlerts = 0
|
|
13
|
+
try {
|
|
14
|
+
$doc = $word.Documents.Add()
|
|
15
|
+
$doc.TrackRevisions = $false
|
|
16
|
+
$doc.Range(0, 0).Text = "The Supplier shall deliver the goods within thirty days.`rPayment is due upon receipt of invoice.`rThis agreement is governed by local law."
|
|
17
|
+
|
|
18
|
+
# Thread 1: comment with a reply (open)
|
|
19
|
+
$c1 = $doc.Comments.Add($doc.Paragraphs.Item(1).Range, "Can we shorten the delivery window?")
|
|
20
|
+
$c1.Author = "Counterparty"
|
|
21
|
+
$r1 = $c1.Replies.Add($c1.Scope, "We can offer twenty days.")
|
|
22
|
+
$r1.Author = "Internal"
|
|
23
|
+
|
|
24
|
+
# Thread 2: resolved comment
|
|
25
|
+
$c2 = $doc.Comments.Add($doc.Paragraphs.Item(2).Range, "Confirm payment terms.")
|
|
26
|
+
$c2.Author = "Counterparty"
|
|
27
|
+
$c2.Done = $true
|
|
28
|
+
|
|
29
|
+
[object]$path = $outPath
|
|
30
|
+
[object]$fmt = 16
|
|
31
|
+
$doc.SaveAs2([ref]$path, [ref]$fmt)
|
|
32
|
+
$doc.Close($false)
|
|
33
|
+
Write-Host "Saved $outPath"
|
|
34
|
+
} finally {
|
|
35
|
+
$word.Quit()
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Generates tests/fixtures/word-authored/header-footer.docx using real Word: default + first-page
|
|
2
|
+
# header/footer with a PAGE field, and a second section. Ground truth for header/footer part
|
|
3
|
+
# content types, relationship types and sectPr references.
|
|
4
|
+
$ErrorActionPreference = 'Stop'
|
|
5
|
+
$outDir = Join-Path $PSScriptRoot '..\tests\fixtures\word-authored'
|
|
6
|
+
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
|
7
|
+
$outPath = [System.IO.Path]::GetFullPath((Join-Path $outDir 'header-footer.docx'))
|
|
8
|
+
if (Test-Path $outPath) { Remove-Item $outPath -Force }
|
|
9
|
+
|
|
10
|
+
$word = New-Object -ComObject Word.Application
|
|
11
|
+
$word.Visible = $false
|
|
12
|
+
$word.DisplayAlerts = 0
|
|
13
|
+
try {
|
|
14
|
+
$doc = $word.Documents.Add()
|
|
15
|
+
$doc.TrackRevisions = $false
|
|
16
|
+
$doc.Range(0, 0).Text = "Body paragraph one.`rBody paragraph two."
|
|
17
|
+
$sec = $doc.Sections.Item(1)
|
|
18
|
+
$sec.PageSetup.DifferentFirstPageHeaderFooter = -1
|
|
19
|
+
$sec.Headers.Item(1).Range.Text = "CONFIDENTIAL - Acme Master Agreement" # wdHeaderFooterPrimary
|
|
20
|
+
$sec.Headers.Item(2).Range.Text = "First page header" # wdHeaderFooterFirstPage
|
|
21
|
+
$sec.Footers.Item(1).Range.Text = "Page "
|
|
22
|
+
$footerRange = $sec.Footers.Item(1).Range
|
|
23
|
+
$footerRange.Collapse(0)
|
|
24
|
+
$doc.Fields.Add($footerRange, 33) | Out-Null # wdFieldPage
|
|
25
|
+
$sec.Footers.Item(2).Range.Text = "First page footer"
|
|
26
|
+
|
|
27
|
+
[object]$path = $outPath
|
|
28
|
+
[object]$fmt = 16
|
|
29
|
+
$doc.SaveAs2([ref]$path, [ref]$fmt)
|
|
30
|
+
$doc.Close($false)
|
|
31
|
+
Write-Host "Saved $outPath"
|
|
32
|
+
} finally { $word.Quit() }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Generates tests/fixtures/word-authored/multi-section-headers.docx using real Word: two sections where the second
|
|
2
|
+
# section has its own header (not linked to previous) but shares the first section's footer.
|
|
3
|
+
$ErrorActionPreference = 'Stop'
|
|
4
|
+
$outDir = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\tests\fixtures\word-authored'))
|
|
5
|
+
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
|
6
|
+
$path = [string](Join-Path $outDir 'multi-section-headers.docx')
|
|
7
|
+
if (Test-Path $path) { Remove-Item $path -Force }
|
|
8
|
+
$word = New-Object -ComObject Word.Application
|
|
9
|
+
$word.Visible = $false
|
|
10
|
+
$word.DisplayAlerts = 0
|
|
11
|
+
try {
|
|
12
|
+
$doc = $word.Documents.Add()
|
|
13
|
+
$doc.Range(0, 0).Text = "Section one body."
|
|
14
|
+
$doc.Sections.Item(1).Headers.Item(1).Range.Text = "Section one header"
|
|
15
|
+
$doc.Sections.Item(1).Footers.Item(1).Range.Text = "Shared footer"
|
|
16
|
+
$end = $doc.Content
|
|
17
|
+
$end.InsertParagraphAfter()
|
|
18
|
+
$doc.Paragraphs.Item($doc.Paragraphs.Count).Range.InsertBreak(2) # wdSectionBreakNextPage
|
|
19
|
+
$doc.Paragraphs.Item($doc.Paragraphs.Count).Range.InsertBefore("Section two body.")
|
|
20
|
+
$sec2 = $doc.Sections.Item(2)
|
|
21
|
+
$sec2.Headers.Item(1).LinkToPrevious = $false
|
|
22
|
+
$sec2.Headers.Item(1).Range.Text = "Section two header"
|
|
23
|
+
[object]$p = $path; [object]$f = 16
|
|
24
|
+
$doc.SaveAs2([ref]$p, [ref]$f)
|
|
25
|
+
$doc.Close($false)
|
|
26
|
+
Write-Host "Saved $path"
|
|
27
|
+
} finally { $word.Quit() }
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds .docx outputs for the Word open-check (scripts/word-com-package-integrity.ps1).
|
|
3
|
+
*
|
|
4
|
+
* Starting from a document Word itself saved (tests/fixtures/word-authored/threaded-comments.docx),
|
|
5
|
+
* runs each write path of the package and writes the result to tmp/word-package-integrity/.
|
|
6
|
+
* `expect-open` cases must open in Word with repair disabled. `expect-fail-*` cases are controls
|
|
7
|
+
* that reproduce the original bug and prove the Word check can actually detect it.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { openDocx } from '../node/index.js';
|
|
13
|
+
import { unzipDocx, zipDocx } from '../document/zip-archive.js';
|
|
14
|
+
|
|
15
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const outDir = join(root, 'tmp', 'word-package-integrity');
|
|
17
|
+
const fixture = readFileSync(join(root, 'tests', 'fixtures', 'word-authored', 'threaded-comments.docx'));
|
|
18
|
+
const WORD_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml';
|
|
19
|
+
const BAD_TYPE = 'application/vnd.ms-word.commentsExtended+xml';
|
|
20
|
+
|
|
21
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
22
|
+
mkdirSync(outDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
const write = (name, bytes) => writeFileSync(join(outDir, name), Buffer.from(bytes));
|
|
25
|
+
|
|
26
|
+
function withContentType(bytes, from, to) {
|
|
27
|
+
const entries = unzipDocx(bytes);
|
|
28
|
+
const xml = new TextDecoder().decode(entries.get('[Content_Types].xml'));
|
|
29
|
+
if (!xml.includes(from)) throw new Error(`fixture does not contain ${from}`);
|
|
30
|
+
entries.set('[Content_Types].xml', new TextEncoder().encode(xml.replace(from, to)));
|
|
31
|
+
return zipDocx(entries);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `expectDone`: the resolved state Word must report for each comment (document order). Checked through Word's
|
|
35
|
+
// own object model, so it proves Word reads our threading parts the way we wrote them.
|
|
36
|
+
// `expect` is either an array of Done states or { commentsDone?, ancestors?, texts? } where `ancestors[i]` is the
|
|
37
|
+
// 1-based index of comment i's thread parent (0 = top level), read from Word's Comment.Ancestor.
|
|
38
|
+
async function run(name, source, fn, expect) {
|
|
39
|
+
const doc = openDocx(source);
|
|
40
|
+
const result = await fn(doc);
|
|
41
|
+
if (result.status !== 'ok' || !result.written) throw new Error(`${name}: ${result.error?.message || 'not written'}`);
|
|
42
|
+
write(`expect-open-${name}.docx`, result.toBuffer());
|
|
43
|
+
if (expect) writeFileSync(join(outDir, `expect-open-${name}.expect.json`), JSON.stringify(Array.isArray(expect) ? { commentsDone: expect } : expect));
|
|
44
|
+
}
|
|
45
|
+
const load = name => readFileSync(join(root, 'tests', 'fixtures', 'word-authored', name));
|
|
46
|
+
|
|
47
|
+
const edit = doc => doc.applyOperations([{ type: 'replace', target: { exactText: 'This agreement is governed by local law.' }, modified: 'This agreement is governed by New York law.' }], { author: 'Agent', atomic: true });
|
|
48
|
+
const reply = doc => doc.applyOperations([{ type: 'comment_reply', parentCommentId: 0, commentContent: 'Agreed, twenty days works.', author: 'Agent' }], { author: 'Agent', atomic: true });
|
|
49
|
+
|
|
50
|
+
write('expect-open-00-word-original.docx', fixture);
|
|
51
|
+
await run('01-edit-unrelated-to-comments', fixture, edit);
|
|
52
|
+
await run('02-reply', fixture, reply, [false, false, false, true]);
|
|
53
|
+
await run('03-add-comment', fixture, doc => doc.applyOperations([{ type: 'comment', target: { exactText: 'This agreement is governed by local law.' }, commentContent: 'Check governing law.', author: 'Agent' }], { author: 'Agent', atomic: true }));
|
|
54
|
+
await run('04-edit-then-accept', fixture, async doc => { await edit(doc); return doc.resolveRevisions('accept', { allAuthors: true }); });
|
|
55
|
+
await run('05-delete-comments', fixture, doc => doc.deleteComments({ author: 'Counterparty' }));
|
|
56
|
+
|
|
57
|
+
await run('07-resolve-thread-via-reply', fixture, doc => doc.resolveComment(1), [true, true, true]);
|
|
58
|
+
await run('08-reopen-word-resolved-thread', load('resolved-threads.docx'), doc => doc.resolveComment(3, { resolved: false }), [true, true, true, false, false]);
|
|
59
|
+
await run('09-reply-to-multi-paragraph-comment', load('multi-paragraph-thread.docx'), doc => doc.applyOperations([{ type: 'comment_reply', parentCommentId: 0, commentContent: 'Another reply', author: 'Agent' }], { author: 'Agent', atomic: true }), [false, false, false]);
|
|
60
|
+
await run('10-resolve-multi-paragraph-thread', load('multi-paragraph-thread.docx'), doc => doc.resolveComment(0), [true, true]);
|
|
61
|
+
|
|
62
|
+
// --- additional Word probes: scenarios likelier to expose bugs than the happy paths above ---
|
|
63
|
+
const stripThreadingParts = bytes => {
|
|
64
|
+
const entries = unzipDocx(bytes);
|
|
65
|
+
for (const name of ['word/commentsExtended.xml', 'word/commentsIds.xml', 'word/commentsExtensible.xml']) entries.delete(name);
|
|
66
|
+
const dec = new TextDecoder(); const enc = new TextEncoder();
|
|
67
|
+
entries.set('[Content_Types].xml', enc.encode(dec.decode(entries.get('[Content_Types].xml')).replace(/<Override PartName="\/word\/comments(Extended|Ids|Extensible)\.xml"[^>]*\/>/g, '')));
|
|
68
|
+
entries.set('word/_rels/document.xml.rels', enc.encode(dec.decode(entries.get('word/_rels/document.xml.rels')).replace(/<Relationship [^>]*Target="comments(Extended|Ids|Extensible)\.xml"[^>]*\/>/g, '')));
|
|
69
|
+
return zipDocx(entries);
|
|
70
|
+
};
|
|
71
|
+
const replyTo = (parent, text) => ({ type: 'comment_reply', parentCommentId: parent, commentContent: text, author: 'Agent' });
|
|
72
|
+
const apply = (doc, ops) => doc.applyOperations(ops, { author: 'Agent', atomic: true });
|
|
73
|
+
|
|
74
|
+
await run('11-reply-to-a-reply', fixture, doc => apply(doc, [replyTo(1, 'Reply to the reply')]), {ancestors: [0, 1, 1, 0], commentsDone: [false, false, false, true]});
|
|
75
|
+
await run('12-two-replies-same-root', fixture, doc => apply(doc, [replyTo(0, 'First new'), replyTo(0, 'Second new')]), {ancestors: [0, 1, 1, 1, 0], commentsDone: [false, false, false, false, true]});
|
|
76
|
+
await run('13-comment-then-reply-to-own-comment', fixture, async doc => {
|
|
77
|
+
const added = await apply(doc, [{ type: 'comment', target: { exactText: 'This agreement is governed by local law.' }, commentContent: 'Governing law?', author: 'Agent' }]);
|
|
78
|
+
if (added.status !== 'ok') return added;
|
|
79
|
+
return apply(doc, [replyTo(3, 'Reply to my own comment')]);
|
|
80
|
+
}, {ancestors: [0, 1, 0, 0, 4], commentsDone: [false, false, true, false, false]});
|
|
81
|
+
await run('14-edit-inside-commented-paragraph', fixture, doc => apply(doc, [{ type: 'replace', target: { exactText: 'The Supplier shall deliver the goods within thirty days.' }, modified: 'The Supplier shall deliver the goods within twenty days.' }]), {ancestors: [0, 1, 0], commentsDone: [false, false, true]});
|
|
82
|
+
await run('15-edit-and-reply-in-one-batch', fixture, doc => apply(doc, [replyTo(0, 'Batch reply'), { type: 'replace', target: { exactText: 'Payment is due upon receipt of invoice.' }, modified: 'Payment is due within thirty days of invoice.' }]), {ancestors: [0, 1, 1, 0], commentsDone: [false, false, false, true]});
|
|
83
|
+
await run('16-reply-accept-resolve', fixture, async doc => {
|
|
84
|
+
await apply(doc, [replyTo(0, 'Then accept')]);
|
|
85
|
+
await doc.resolveRevisions('accept', { allAuthors: true });
|
|
86
|
+
return doc.resolveComment(0);
|
|
87
|
+
}, {ancestors: [0, 1, 1, 0], commentsDone: [true, true, true, true]});
|
|
88
|
+
await run('17-legacy-no-threading-parts-reply', stripThreadingParts(fixture), doc => apply(doc, [replyTo(0, 'Reply on legacy file')]), {ancestors: [0, 1, 0, 0], commentsDone: [false, false, false, false]});
|
|
89
|
+
await run('18-legacy-no-threading-parts-resolve', stripThreadingParts(fixture), doc => doc.resolveComment(0), {ancestors: [0, 0, 0], commentsDone: [true, false, false]});
|
|
90
|
+
await run('19-edit-commented-text-multi-paragraph', load('multi-paragraph-thread.docx'), doc => apply(doc, [{ type: 'replace', target: { exactText: 'Alpha paragraph.' }, modified: 'Alpha paragraph, revised.' }]), {ancestors: [0, 1], commentsDone: [false, false]});
|
|
91
|
+
await run('20-delete-reply-only', fixture, doc => doc.deleteComments({ author: 'Internal' }), {ancestors: [0, 0], commentsDone: [false, true]});
|
|
92
|
+
|
|
93
|
+
// Batch comment_resolve and delete-by-id (fixture: 0 root + 1 reply open, 2 resolved).
|
|
94
|
+
const resolveOp = (commentId, resolved = true) => ({ type: 'comment_resolve', commentId, resolved });
|
|
95
|
+
await run('40-batch-resolve-thread', fixture, doc => apply(doc, [resolveOp(0)]), { ancestors: [0, 1, 0], commentsDone: [true, true, true] });
|
|
96
|
+
await run('41-batch-reply-then-resolve', fixture, doc => apply(doc, [replyTo(0, 'Closing this out'), resolveOp(0)]), { ancestors: [0, 1, 1, 0], commentsDone: [true, true, true, true] });
|
|
97
|
+
await run('42-reply-to-resolved-thread', fixture, doc => apply(doc, [replyTo(2, 'Late reply')]), { ancestors: [0, 1, 0, 3], commentsDone: [false, false, true, true] });
|
|
98
|
+
await run('43-batch-reopen', fixture, doc => apply(doc, [resolveOp(2, false)]), { ancestors: [0, 1, 0], commentsDone: [false, false, false] });
|
|
99
|
+
await run('44-delete-root-by-id', fixture, doc => doc.deleteComments({ ids: [0] }), { ancestors: [0], commentsDone: [true] });
|
|
100
|
+
await run('45-delete-reply-by-id', fixture, doc => doc.deleteComments({ ids: [1] }), { ancestors: [0, 0], commentsDone: [false, true] });
|
|
101
|
+
|
|
102
|
+
// Comment shapes Word wrote (comment-edge-cases.docx): every edit must leave Word seeing the same comments.
|
|
103
|
+
const edgeCases = load('comment-edge-cases.docx');
|
|
104
|
+
const edgeText = doc => doc.inspect().paragraphs.map(p => p.text);
|
|
105
|
+
const edgeReplace = (index, to) => doc => apply(doc, [{ type: 'replace', target: { exactText: edgeText(doc)[index - 1] }, modified: to }]);
|
|
106
|
+
const five = { ancestors: [0, 0, 0, 0, 0], commentsDone: [false, false, false, false, false] };
|
|
107
|
+
await run('21-edge-edit-middle-words', edgeCases, edgeReplace(1, 'Alpha BETA GAMMA delta epsilon.'), five);
|
|
108
|
+
await run('22-edge-edit-over-tracked-insertion', edgeCases, doc => doc.applyOperations([{ type: 'replace', target: { exactText: edgeText(doc)[1] }, modified: 'The counterparty proposed short-term tenure.' }], { author: 'Agent', atomic: true, existingRevisions: 'slice-cross-author' }), five);
|
|
109
|
+
await run('23-edge-edit-spanning-comment-paragraph', edgeCases, edgeReplace(4, 'Spanning SECOND paragraph.'), five);
|
|
110
|
+
await run('24-edge-edit-hyperlink-paragraph', edgeCases, edgeReplace(5, 'Link here: example.org now.'), five);
|
|
111
|
+
await run('25-edge-edit-table-cell', edgeCases, edgeReplace(8, 'Cell TWO'), five);
|
|
112
|
+
await run('26-edge-reply-to-spanning-comment', edgeCases, doc => apply(doc, [replyTo(3, 'Reply on a two-paragraph range')]), { ancestors: [0, 0, 0, 3, 0, 0], commentsDone: [false, false, false, false, false, false] });
|
|
113
|
+
await run('27-edge-reply-in-table-cell', edgeCases, doc => apply(doc, [replyTo(5, 'Reply in a cell')]), { ancestors: [0, 0, 0, 0, 0, 5], commentsDone: [false, false, false, false, false, false] });
|
|
114
|
+
|
|
115
|
+
// --- Headers and footers (header-footer.docx was saved by Word; see scripts/generate-word-header-footer-fixture.ps1) ---
|
|
116
|
+
const headerFooter = load('header-footer.docx');
|
|
117
|
+
const HDR = { kind: 'header', type: 'default' };
|
|
118
|
+
const HDR_TEXT = 'CONFIDENTIAL - Acme Master Agreement';
|
|
119
|
+
const hdrEdit = { type: 'replace', part: HDR, target: { exactText: HDR_TEXT }, modified: 'STRICTLY CONFIDENTIAL - Acme Master Agreement' };
|
|
120
|
+
await run('30-header-edit', headerFooter, doc => apply(doc, [hdrEdit]), { headersFooters: [{ section: 1, kind: 'header', slot: 'primary', revisions: 1 }], bodyRevisions: 0 });
|
|
121
|
+
await run('31-footer-edit-around-page-field', headerFooter, doc => apply(doc, [{ type: 'replace', part: { kind: 'footer' }, target: { exactText: 'Page 1' }, modified: 'Sheet 1' }]), { headersFooters: [{ section: 1, kind: 'footer', slot: 'primary', revisions: 2, fields: 1 }] });
|
|
122
|
+
await run('32-body-header-and-first-page-in-one-batch', headerFooter, doc => apply(doc, [
|
|
123
|
+
{ type: 'replace', target: { exactText: 'Body paragraph one.' }, modified: 'Body paragraph ONE.' },
|
|
124
|
+
hdrEdit,
|
|
125
|
+
{ type: 'replace', part: 'word/header3.xml', target: { exactText: 'First page header' }, modified: 'First page header (rev)' }
|
|
126
|
+
]), { headersFooters: [{ section: 1, kind: 'header', slot: 'primary', revisions: 1 }, { section: 1, kind: 'header', slot: 'first', revisions: 1 }], bodyRevisions: 2 });
|
|
127
|
+
await run('33-header-edit-then-accept', headerFooter, async doc => { await apply(doc, [hdrEdit]); return doc.resolveRevisions('accept', { allAuthors: true }); }, { headersFooters: [{ section: 1, kind: 'header', slot: 'primary', revisions: 0, text: 'STRICTLY CONFIDENTIAL - Acme Master Agreement' }, { section: 1, kind: 'footer', slot: 'primary', fields: 1 }] });
|
|
128
|
+
await run('34-header-edit-then-reject', headerFooter, async doc => { await apply(doc, [hdrEdit]); return doc.resolveRevisions('reject', { allAuthors: true }); }, { headersFooters: [{ section: 1, kind: 'header', slot: 'primary', revisions: 0, text: HDR_TEXT }] });
|
|
129
|
+
|
|
130
|
+
const multiSection = load('multi-section-headers.docx');
|
|
131
|
+
await run('35-multi-section-second-header', multiSection, doc => apply(doc, [{ type: 'replace', part: { kind: 'header', section: 1 }, target: { exactText: 'Section two header' }, modified: 'Section two header (rev)' }]), { headersFooters: [{ section: 1, kind: 'header', slot: 'primary', revisions: 0 }, { section: 2, kind: 'header', slot: 'primary', revisions: 1 }] });
|
|
132
|
+
await run('36-multi-section-inherited-footer', multiSection, doc => apply(doc, [{ type: 'replace', part: { kind: 'footer', section: 1 }, target: { exactText: 'Shared footer' }, modified: 'Shared footer (rev)' }]), { headersFooters: [{ section: 1, kind: 'footer', slot: 'primary', revisions: 1 }, { section: 2, kind: 'footer', slot: 'primary', revisions: 1 }] });
|
|
133
|
+
|
|
134
|
+
// Files damaged by earlier versions of this package: the next save must repair them.
|
|
135
|
+
const damaged = withContentType(fixture, WORD_TYPE, BAD_TYPE);
|
|
136
|
+
await run('06-repair-damaged-on-edit', damaged, edit);
|
|
137
|
+
|
|
138
|
+
// Controls: reproduce the original bug. Word must refuse these, or this check proves nothing.
|
|
139
|
+
write('expect-fail-control-bad-extended-type.docx', damaged);
|
|
140
|
+
|
|
141
|
+
console.log(`Wrote cases to ${outDir}`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Generates real-Word fixtures for comment thread behavior (tests/fixtures/word-authored/):
|
|
2
|
+
# multi-paragraph-thread.docx a three-paragraph comment with one reply. Word keys commentsExtended /
|
|
3
|
+
# commentsIds on the LAST paragraph of the comment.
|
|
4
|
+
# resolved-threads.docx thread 1: root + two replies, resolved by setting Done on the ROOT.
|
|
5
|
+
# thread 2: root + reply, resolved by setting Done on the REPLY.
|
|
6
|
+
# Word marks the WHOLE thread done in both cases.
|
|
7
|
+
# reply-to-resolved.docx a reply added to an already-resolved thread. Word writes the reply done too.
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
$outDir = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\tests\fixtures\word-authored'))
|
|
10
|
+
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
|
11
|
+
$word = New-Object -ComObject Word.Application
|
|
12
|
+
$word.Visible = $false
|
|
13
|
+
$word.DisplayAlerts = 0
|
|
14
|
+
|
|
15
|
+
function Save-Doc($doc, $name) {
|
|
16
|
+
$path = [string](Join-Path $outDir $name)
|
|
17
|
+
if (Test-Path $path) { Remove-Item $path -Force }
|
|
18
|
+
[object]$p = $path; [object]$f = 16
|
|
19
|
+
$doc.SaveAs2([ref]$p, [ref]$f)
|
|
20
|
+
$doc.Close($false)
|
|
21
|
+
Write-Host "Saved $path"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
$doc = $word.Documents.Add()
|
|
26
|
+
$doc.Range(0, 0).Text = "Alpha paragraph."
|
|
27
|
+
$c = $doc.Comments.Add($doc.Paragraphs.Item(1).Range, "first para of comment`rsecond para of comment`rthird para")
|
|
28
|
+
$c.Author = "Counterparty"
|
|
29
|
+
$r = $c.Replies.Add($c.Scope, "a reply")
|
|
30
|
+
$r.Author = "Internal"
|
|
31
|
+
Save-Doc $doc 'multi-paragraph-thread.docx'
|
|
32
|
+
|
|
33
|
+
$doc = $word.Documents.Add()
|
|
34
|
+
$doc.Range(0, 0).Text = "Alpha paragraph.`rBeta paragraph."
|
|
35
|
+
$c1 = $doc.Comments.Add($doc.Paragraphs.Item(1).Range, "root one"); $c1.Author = "A"
|
|
36
|
+
$r1 = $c1.Replies.Add($c1.Scope, "reply one"); $r1.Author = "B"
|
|
37
|
+
$r2 = $c1.Replies.Add($c1.Scope, "reply two"); $r2.Author = "C"
|
|
38
|
+
$c1.Done = $true
|
|
39
|
+
$c2 = $doc.Comments.Add($doc.Paragraphs.Item(2).Range, "root two"); $c2.Author = "A"
|
|
40
|
+
$rr = $c2.Replies.Add($c2.Scope, "reply to two"); $rr.Author = "B"
|
|
41
|
+
$rr.Done = $true
|
|
42
|
+
Save-Doc $doc 'resolved-threads.docx'
|
|
43
|
+
|
|
44
|
+
$doc = $word.Documents.Add()
|
|
45
|
+
$doc.Range(0, 0).Text = "Alpha paragraph."
|
|
46
|
+
$c = $doc.Comments.Add($doc.Paragraphs.Item(1).Range, "root"); $c.Author = "A"
|
|
47
|
+
$c.Done = $true
|
|
48
|
+
$r = $c.Replies.Add($c.Scope, "late reply"); $r.Author = "B"
|
|
49
|
+
Save-Doc $doc 'reply-to-resolved.docx'
|
|
50
|
+
} finally { $word.Quit() }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<#
|
|
2
|
+
Opens every .docx in tmp/word-package-integrity (built by generate-word-package-integrity-cases.mjs)
|
|
3
|
+
in real Word with OpenAndRepair disabled. Word throws on a package it considers unreadable, which is
|
|
4
|
+
exactly what the "unreadable content" repair prompt reports.
|
|
5
|
+
|
|
6
|
+
expect-open-* must open cleanly.
|
|
7
|
+
expect-fail-* are controls that reproduce a known-bad package and must be REFUSED.
|
|
8
|
+
If Word opens a control, this check cannot detect the bug and fails.
|
|
9
|
+
#>
|
|
10
|
+
param([string]$Dir = (Join-Path $PSScriptRoot '..\tmp\word-package-integrity'))
|
|
11
|
+
$ErrorActionPreference = 'Stop'
|
|
12
|
+
$Dir = [System.IO.Path]::GetFullPath($Dir)
|
|
13
|
+
$files = Get-ChildItem -LiteralPath $Dir -Filter *.docx | Sort-Object Name
|
|
14
|
+
if (-not $files) { Write-Error "No cases in $Dir. Run: node scripts/generate-word-package-integrity-cases.mjs"; exit 2 }
|
|
15
|
+
|
|
16
|
+
$word = New-Object -ComObject Word.Application
|
|
17
|
+
$word.Visible = $false
|
|
18
|
+
$word.DisplayAlerts = 0
|
|
19
|
+
$missing = [Type]::Missing
|
|
20
|
+
$failures = 0
|
|
21
|
+
try {
|
|
22
|
+
foreach ($file in $files) {
|
|
23
|
+
$opened = $false; $detail = ''
|
|
24
|
+
$doc = $null
|
|
25
|
+
try {
|
|
26
|
+
# FileName, ConfirmConversions, ReadOnly, AddToRecentFiles, PasswordDocument, PasswordTemplate, Revert,
|
|
27
|
+
# WritePasswordDocument, WritePasswordTemplate, Format, Encoding, Visible, OpenAndRepair
|
|
28
|
+
$doc = $word.Documents.Open([string]$file.FullName, $false, $true, $false, $missing, $missing, $false, $missing, $missing, $missing, $missing, $false, $false)
|
|
29
|
+
$opened = $true
|
|
30
|
+
$detail = "comments=$($doc.Comments.Count)"
|
|
31
|
+
$expectFile = [System.IO.Path]::ChangeExtension($file.FullName, '.expect.json')
|
|
32
|
+
if (Test-Path -LiteralPath $expectFile) {
|
|
33
|
+
$expect = Get-Content -LiteralPath $expectFile -Raw | ConvertFrom-Json
|
|
34
|
+
$n = $doc.Comments.Count
|
|
35
|
+
$checks = @{}
|
|
36
|
+
if ($null -ne $expect.commentsDone) {
|
|
37
|
+
$actual = @(); for ($i = 1; $i -le $n; $i++) { $actual += [bool]$doc.Comments.Item($i).Done }
|
|
38
|
+
$checks['done'] = @(($actual -join ','), (@($expect.commentsDone) -join ','))
|
|
39
|
+
}
|
|
40
|
+
if ($null -ne $expect.ancestors) {
|
|
41
|
+
$actual = @()
|
|
42
|
+
for ($i = 1; $i -le $n; $i++) {
|
|
43
|
+
$anc = $doc.Comments.Item($i).Ancestor
|
|
44
|
+
$idx = 0
|
|
45
|
+
if ($anc) { for ($j = 1; $j -le $n; $j++) { $c = $doc.Comments.Item($j); if ($c.Author -eq $anc.Author -and $c.Range.Text -eq $anc.Range.Text) { $idx = $j; break } } }
|
|
46
|
+
$actual += $idx
|
|
47
|
+
}
|
|
48
|
+
$checks['ancestors'] = @(($actual -join ','), (@($expect.ancestors) -join ','))
|
|
49
|
+
}
|
|
50
|
+
if ($null -ne $expect.texts) {
|
|
51
|
+
$actual = @(); for ($i = 1; $i -le $n; $i++) { $actual += $doc.Comments.Item($i).Range.Text }
|
|
52
|
+
$checks['texts'] = @(($actual -join '|'), (@($expect.texts) -join '|'))
|
|
53
|
+
}
|
|
54
|
+
if ($expect.probe) {
|
|
55
|
+
$rows = @()
|
|
56
|
+
for ($i = 1; $i -le $n; $i++) {
|
|
57
|
+
$c = $doc.Comments.Item($i); $anc = $c.Ancestor; $idx = 0
|
|
58
|
+
if ($anc) { for ($j = 1; $j -le $n; $j++) { $o = $doc.Comments.Item($j); if ($o.Author -eq $anc.Author -and $o.Range.Text -eq $anc.Range.Text) { $idx = $j; break } } }
|
|
59
|
+
$rows += "#$i[$($c.Author): $($c.Range.Text) | anc=$idx done=$([bool]$c.Done) scope='$($c.Scope.Text)']"
|
|
60
|
+
}
|
|
61
|
+
$detail += " PROBE " + ($rows -join ' ; ')
|
|
62
|
+
}
|
|
63
|
+
# headersFooters: [{ section, kind: header|footer, slot: primary|first|even, revisions?, fields?, text? }]
|
|
64
|
+
# read from Word's own Headers/Footers objects; text is compared only when given.
|
|
65
|
+
if ($null -ne $expect.headersFooters) {
|
|
66
|
+
$slots = @{ primary = 1; first = 2; even = 3 }
|
|
67
|
+
$actualParts = @(); $expectedParts = @()
|
|
68
|
+
foreach ($want in @($expect.headersFooters)) {
|
|
69
|
+
$section = $doc.Sections.Item([int]$want.section)
|
|
70
|
+
$hf = if ($want.kind -eq 'header') { $section.Headers.Item($slots[[string]$want.slot]) } else { $section.Footers.Item($slots[[string]$want.slot]) }
|
|
71
|
+
$range = $hf.Range
|
|
72
|
+
$label = "$($want.kind)/$($want.slot)"
|
|
73
|
+
$got = @(); $exp = @()
|
|
74
|
+
if ($null -ne $want.revisions) { $got += "rev=$($range.Revisions.Count)"; $exp += "rev=$($want.revisions)" }
|
|
75
|
+
if ($null -ne $want.fields) { $got += "fields=$($range.Fields.Count)"; $exp += "fields=$($want.fields)" }
|
|
76
|
+
if ($null -ne $want.text) { $got += "text=$(($range.Text -replace '[\r\a]+$', ''))"; $exp += "text=$($want.text)" }
|
|
77
|
+
$actualParts += "$label{$($got -join ',')}"; $expectedParts += "$label{$($exp -join ',')}"
|
|
78
|
+
}
|
|
79
|
+
$checks['headersFooters'] = @(($actualParts -join ' '), ($expectedParts -join ' '))
|
|
80
|
+
}
|
|
81
|
+
if ($null -ne $expect.bodyRevisions) {
|
|
82
|
+
$checks['bodyRevisions'] = @([string]$doc.Revisions.Count, [string]$expect.bodyRevisions)
|
|
83
|
+
}
|
|
84
|
+
foreach ($key in $checks.Keys) {
|
|
85
|
+
$detail += " $key=[$($checks[$key][0])]"
|
|
86
|
+
if ($checks[$key][0] -ne $checks[$key][1]) { $opened = $false; $detail += " EXPECTED $key=[$($checks[$key][1])]" }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { $detail = $_.Exception.Message.Trim() }
|
|
90
|
+
finally { if ($doc) { $doc.Close($false) | Out-Null } }
|
|
91
|
+
|
|
92
|
+
$expectOpen = $file.Name.StartsWith('expect-open-')
|
|
93
|
+
if ($opened -eq $expectOpen) { Write-Host "PASS $($file.Name) ($(if ($opened) { 'opened' } else { 'refused as expected' }); $detail)" }
|
|
94
|
+
else { Write-Host "FAIL $($file.Name) ($(if ($opened) { 'opened but should be refused' } else { 'refused but should open' }); $detail)"; $failures++ }
|
|
95
|
+
}
|
|
96
|
+
} finally { $word.Quit() | Out-Null }
|
|
97
|
+
if ($failures) { Write-Host "$failures case(s) failed"; exit 1 }
|
|
98
|
+
Write-Host "All Word package-integrity cases passed"
|