@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
|
@@ -62,19 +62,37 @@ function authorMatchesNode(node, filter) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function parseXmlWithWarnings(oxml, parseFailurePrefix) {
|
|
65
|
-
|
|
65
|
+
let rawOxml = typeof oxml === 'string' ? oxml.replace(/^\uFEFF/, '').trim() : '';
|
|
66
|
+
let isFragmentWrapped = false;
|
|
67
|
+
let parsed = parseOoxmlSafe(rawOxml, 'application/xml');
|
|
68
|
+
if (parsed.error && (parsed.error.message.includes('HierarchyRequestError') || parsed.error.message.includes('Only one element'))) {
|
|
69
|
+
const wrapped = `<w:body xmlns:w="${NS_W}">${rawOxml}</w:body>`;
|
|
70
|
+
const wrappedParsed = parseOoxmlSafe(wrapped, 'application/xml');
|
|
71
|
+
if (!wrappedParsed.error) {
|
|
72
|
+
parsed = wrappedParsed;
|
|
73
|
+
isFragmentWrapped = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
66
76
|
const parseError = parsed.doc ? getXmlParseError(parsed.doc) : null;
|
|
67
77
|
if (parsed.error || parseError) {
|
|
68
78
|
const message = parsed.error?.message || parseError?.textContent || 'parse error';
|
|
69
79
|
return {
|
|
70
80
|
xmlDoc: null,
|
|
71
81
|
serializer: null,
|
|
82
|
+
isFragmentWrapped: false,
|
|
72
83
|
warning: `${parseFailurePrefix}: ${message}`,
|
|
73
84
|
warnings: parsed.warnings,
|
|
74
85
|
error: { code: 'PARSE_ERROR', message }
|
|
75
86
|
};
|
|
76
87
|
}
|
|
77
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
xmlDoc: parsed.doc,
|
|
90
|
+
serializer: createSerializer(),
|
|
91
|
+
isFragmentWrapped,
|
|
92
|
+
warning: null,
|
|
93
|
+
warnings: parsed.warnings,
|
|
94
|
+
error: null
|
|
95
|
+
};
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
function removeNode(node) {
|
|
@@ -124,11 +142,17 @@ function mergeParagraphIntoNextAndRemove(paragraph) {
|
|
|
124
142
|
if (!paragraph?.parentNode) return false;
|
|
125
143
|
const nextParagraph = getNextWordParagraph(paragraph);
|
|
126
144
|
if (!nextParagraph) {
|
|
145
|
+
if (isWordElement(paragraph.parentNode, 'tc') && getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
127
148
|
return removeNode(paragraph);
|
|
128
149
|
}
|
|
129
150
|
|
|
130
151
|
const childrenToMove = Array.from(paragraph.childNodes || []).filter(child => !isWordElement(child, 'pPr'));
|
|
131
|
-
|
|
152
|
+
let insertionPoint = nextParagraph.firstChild || null;
|
|
153
|
+
if (isWordElement(insertionPoint, 'pPr')) {
|
|
154
|
+
insertionPoint = insertionPoint.nextSibling || null;
|
|
155
|
+
}
|
|
132
156
|
for (const child of childrenToMove) {
|
|
133
157
|
nextParagraph.insertBefore(child, insertionPoint);
|
|
134
158
|
}
|
|
@@ -214,8 +238,12 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
214
238
|
}
|
|
215
239
|
}
|
|
216
240
|
|
|
241
|
+
const serializedOxml = parseResult.isFragmentWrapped
|
|
242
|
+
? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
|
|
243
|
+
: serializer.serializeToString(xmlDoc);
|
|
244
|
+
|
|
217
245
|
return {
|
|
218
|
-
oxml:
|
|
246
|
+
oxml: serializedOxml,
|
|
219
247
|
hasChanges: acceptedCount > 0,
|
|
220
248
|
acceptedCount,
|
|
221
249
|
warnings
|
|
@@ -395,8 +423,12 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
395
423
|
}
|
|
396
424
|
}
|
|
397
425
|
|
|
426
|
+
const serializedOxml = parseResult.isFragmentWrapped
|
|
427
|
+
? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
|
|
428
|
+
: serializer.serializeToString(xmlDoc);
|
|
429
|
+
|
|
398
430
|
return {
|
|
399
|
-
oxml:
|
|
431
|
+
oxml: serializedOxml,
|
|
400
432
|
hasChanges: rejectedCount > 0,
|
|
401
433
|
rejectedCount,
|
|
402
434
|
warnings
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision token framing and calculation for docx-redline-js.
|
|
3
|
+
*
|
|
4
|
+
* Implements unambiguous big-endian binary framing to generate cryptographic
|
|
5
|
+
* revision tokens over package entries or document parts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const textEncoder = new TextEncoder();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes an OPC entry or part name to canonical forward-slash form.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} name - Raw entry name
|
|
14
|
+
* @returns {string} Normalized entry name
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeOpcEntryName(name) {
|
|
17
|
+
if (typeof name !== 'string') {
|
|
18
|
+
throw new TypeError(`Entry name must be a string, got ${typeof name}`);
|
|
19
|
+
}
|
|
20
|
+
let normalized = name.replace(/\\/g, '/');
|
|
21
|
+
normalized = normalized.replace(/^\/+/, '');
|
|
22
|
+
while (normalized.startsWith('./')) {
|
|
23
|
+
normalized = normalized.slice(2);
|
|
24
|
+
}
|
|
25
|
+
normalized = normalized.replace(/\/+/g, '/');
|
|
26
|
+
if (!normalized) {
|
|
27
|
+
throw new Error(`Invalid empty entry name: "${name}"`);
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds the binary framing buffer for a revision token according to the specification:
|
|
34
|
+
*
|
|
35
|
+
* magic = "docx-redline-revision-token\0"
|
|
36
|
+
* version = uint32be(1)
|
|
37
|
+
* scopeLength + scope
|
|
38
|
+
* entryCount
|
|
39
|
+
* for each entry sorted by normalized name:
|
|
40
|
+
* nameLength + name
|
|
41
|
+
* payloadLength + payload
|
|
42
|
+
*
|
|
43
|
+
* @param {{ scope: string, entries: Array<{ name: string, payload: Uint8Array|string|Buffer }|Array> }} options
|
|
44
|
+
* @returns {{ framing: Uint8Array, scope: string, version: number, coveredParts: string[] }}
|
|
45
|
+
*/
|
|
46
|
+
export function buildRevisionTokenFraming({ scope, entries = [] }) {
|
|
47
|
+
if (typeof scope !== 'string' || !scope) {
|
|
48
|
+
throw new TypeError('Revision token scope must be a non-empty string.');
|
|
49
|
+
}
|
|
50
|
+
const magicBytes = textEncoder.encode('docx-redline-revision-token\0');
|
|
51
|
+
const version = 1;
|
|
52
|
+
const scopeBytes = textEncoder.encode(scope);
|
|
53
|
+
|
|
54
|
+
const normalizedEntries = [];
|
|
55
|
+
const seenNames = new Set();
|
|
56
|
+
|
|
57
|
+
const rawList = Array.isArray(entries)
|
|
58
|
+
? entries
|
|
59
|
+
: (entries instanceof Map ? Array.from(entries.entries()) : Object.entries(entries || {}));
|
|
60
|
+
|
|
61
|
+
for (const item of rawList) {
|
|
62
|
+
if (!item) continue;
|
|
63
|
+
const rawName = Array.isArray(item) ? item[0] : item.name;
|
|
64
|
+
const rawPayload = Array.isArray(item) ? item[1] : (item.payload ?? item.bytes);
|
|
65
|
+
|
|
66
|
+
if (rawName == null) continue;
|
|
67
|
+
const normName = normalizeOpcEntryName(String(rawName));
|
|
68
|
+
if (seenNames.has(normName)) {
|
|
69
|
+
throw new Error(`Duplicate normalized entry path detected: "${normName}"`);
|
|
70
|
+
}
|
|
71
|
+
seenNames.add(normName);
|
|
72
|
+
|
|
73
|
+
let payloadBytes;
|
|
74
|
+
if (typeof rawPayload === 'string') {
|
|
75
|
+
payloadBytes = textEncoder.encode(rawPayload);
|
|
76
|
+
} else if (rawPayload instanceof Uint8Array) {
|
|
77
|
+
payloadBytes = rawPayload;
|
|
78
|
+
} else if (rawPayload && typeof rawPayload.length === 'number') {
|
|
79
|
+
payloadBytes = new Uint8Array(rawPayload);
|
|
80
|
+
} else {
|
|
81
|
+
payloadBytes = new Uint8Array(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
normalizedEntries.push({
|
|
85
|
+
name: normName,
|
|
86
|
+
nameBytes: textEncoder.encode(normName),
|
|
87
|
+
payloadBytes
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Sort entries by normalized name in binary codepoint order
|
|
92
|
+
normalizedEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
93
|
+
|
|
94
|
+
let totalSize = magicBytes.length + 4 + 4 + scopeBytes.length + 4;
|
|
95
|
+
for (const e of normalizedEntries) {
|
|
96
|
+
totalSize += 4 + e.nameBytes.length + 4 + e.payloadBytes.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const buffer = new Uint8Array(totalSize);
|
|
100
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
101
|
+
let offset = 0;
|
|
102
|
+
|
|
103
|
+
// magic
|
|
104
|
+
buffer.set(magicBytes, offset);
|
|
105
|
+
offset += magicBytes.length;
|
|
106
|
+
|
|
107
|
+
// version uint32be
|
|
108
|
+
view.setUint32(offset, version, false);
|
|
109
|
+
offset += 4;
|
|
110
|
+
|
|
111
|
+
// scopeLength uint32be
|
|
112
|
+
view.setUint32(offset, scopeBytes.length, false);
|
|
113
|
+
offset += 4;
|
|
114
|
+
|
|
115
|
+
// scope
|
|
116
|
+
buffer.set(scopeBytes, offset);
|
|
117
|
+
offset += scopeBytes.length;
|
|
118
|
+
|
|
119
|
+
// entryCount uint32be
|
|
120
|
+
view.setUint32(offset, normalizedEntries.length, false);
|
|
121
|
+
offset += 4;
|
|
122
|
+
|
|
123
|
+
for (const e of normalizedEntries) {
|
|
124
|
+
// nameLength uint32be
|
|
125
|
+
view.setUint32(offset, e.nameBytes.length, false);
|
|
126
|
+
offset += 4;
|
|
127
|
+
buffer.set(e.nameBytes, offset);
|
|
128
|
+
offset += e.nameBytes.length;
|
|
129
|
+
|
|
130
|
+
// payloadLength uint32be
|
|
131
|
+
view.setUint32(offset, e.payloadBytes.length, false);
|
|
132
|
+
offset += 4;
|
|
133
|
+
buffer.set(e.payloadBytes, offset);
|
|
134
|
+
offset += e.payloadBytes.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
framing: buffer,
|
|
139
|
+
scope,
|
|
140
|
+
version,
|
|
141
|
+
coveredParts: normalizedEntries.map(e => e.name)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Calculates a SHA-256 revision token asynchronously using Web Crypto or a custom digest function.
|
|
147
|
+
*
|
|
148
|
+
* @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => Promise<string> }} options
|
|
149
|
+
* @returns {Promise<{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }>}
|
|
150
|
+
*/
|
|
151
|
+
export async function computeRevisionToken({ scope, entries = [], digestFn = null }) {
|
|
152
|
+
const { framing, coveredParts, version } = buildRevisionTokenFraming({ scope, entries });
|
|
153
|
+
|
|
154
|
+
let hashHex = '';
|
|
155
|
+
if (typeof digestFn === 'function') {
|
|
156
|
+
hashHex = await digestFn(framing);
|
|
157
|
+
} else if (typeof globalThis.crypto?.subtle?.digest === 'function') {
|
|
158
|
+
const hashBuf = await globalThis.crypto.subtle.digest('SHA-256', framing);
|
|
159
|
+
const hashBytes = new Uint8Array(hashBuf);
|
|
160
|
+
hashHex = Array.from(hashBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
161
|
+
} else {
|
|
162
|
+
throw new Error('No crypto provider available for SHA-256 revision token computation.');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
algorithm: 'sha256',
|
|
167
|
+
version,
|
|
168
|
+
scope,
|
|
169
|
+
value: hashHex,
|
|
170
|
+
coveredParts
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Calculates a SHA-256 revision token synchronously using a provided digest function.
|
|
176
|
+
*
|
|
177
|
+
* @param {{ scope: string, entries: Array<any>, digestFn: (bytes: Uint8Array) => string }} options
|
|
178
|
+
* @returns {{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }}
|
|
179
|
+
*/
|
|
180
|
+
export function computeRevisionTokenSync({ scope, entries = [], digestFn }) {
|
|
181
|
+
if (typeof digestFn !== 'function') {
|
|
182
|
+
throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn.');
|
|
183
|
+
}
|
|
184
|
+
const { framing, coveredParts, version } = buildRevisionTokenFraming({ scope, entries });
|
|
185
|
+
const hashHex = digestFn(framing);
|
|
186
|
+
return {
|
|
187
|
+
algorithm: 'sha256',
|
|
188
|
+
version,
|
|
189
|
+
scope,
|
|
190
|
+
value: hashHex,
|
|
191
|
+
coveredParts
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Extracts entry records from document parts descriptor.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} parts - Document parts object
|
|
199
|
+
* @returns {Array<{ name: string, payload: any }>}
|
|
200
|
+
*/
|
|
201
|
+
export function extractDocumentPartsEntries(parts) {
|
|
202
|
+
const entries = [];
|
|
203
|
+
if (parts?.documentXml) {
|
|
204
|
+
entries.push({ name: 'word/document.xml', payload: parts.documentXml });
|
|
205
|
+
}
|
|
206
|
+
if (parts?.commentsXml) {
|
|
207
|
+
entries.push({ name: 'word/comments.xml', payload: parts.commentsXml });
|
|
208
|
+
}
|
|
209
|
+
if (parts?.commentsExtendedXml) {
|
|
210
|
+
entries.push({ name: 'word/commentsExtended.xml', payload: parts.commentsExtendedXml });
|
|
211
|
+
}
|
|
212
|
+
if (parts?.numberingXml) {
|
|
213
|
+
entries.push({ name: 'word/numbering.xml', payload: parts.numberingXml });
|
|
214
|
+
}
|
|
215
|
+
if (parts?.stylesXml) {
|
|
216
|
+
entries.push({ name: 'word/styles.xml', payload: parts.stylesXml });
|
|
217
|
+
}
|
|
218
|
+
if (parts?.parts instanceof Map) {
|
|
219
|
+
for (const [name, payload] of parts.parts.entries()) {
|
|
220
|
+
entries.push({ name, payload });
|
|
221
|
+
}
|
|
222
|
+
} else if (parts?.additionalParts && typeof parts.additionalParts === 'object') {
|
|
223
|
+
for (const [name, payload] of Object.entries(parts.additionalParts)) {
|
|
224
|
+
entries.push({ name, payload });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return entries;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Computes a revision token for document parts asynchronously.
|
|
232
|
+
*
|
|
233
|
+
* @param {object} parts - Document parts
|
|
234
|
+
* @param {object} [options={}] - Options
|
|
235
|
+
* @returns {Promise<{ algorithm: 'sha256', version: number, scope: 'document-parts', value: string, coveredParts: string[] }>}
|
|
236
|
+
*/
|
|
237
|
+
export async function computeDocumentPartsRevisionToken(parts, options = {}) {
|
|
238
|
+
const entries = extractDocumentPartsEntries(parts);
|
|
239
|
+
return computeRevisionToken({
|
|
240
|
+
scope: 'document-parts',
|
|
241
|
+
entries,
|
|
242
|
+
digestFn: options.digestFn
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Validates the structure and syntax of an incoming revision token object.
|
|
248
|
+
*
|
|
249
|
+
* @param {any} token
|
|
250
|
+
* @returns {{ valid: boolean, error?: { code: string, message: string } }}
|
|
251
|
+
*/
|
|
252
|
+
export function validateRevisionToken(token) {
|
|
253
|
+
if (!token || typeof token !== 'object') {
|
|
254
|
+
return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: 'Revision token must be an object.' } };
|
|
255
|
+
}
|
|
256
|
+
if (token.algorithm !== 'sha256') {
|
|
257
|
+
return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: `Unsupported revision token algorithm: "${token.algorithm}". Expected "sha256".` } };
|
|
258
|
+
}
|
|
259
|
+
if (token.version !== 1) {
|
|
260
|
+
return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: `Unsupported revision token version: "${token.version}". Expected 1.` } };
|
|
261
|
+
}
|
|
262
|
+
if (token.scope !== 'document-parts' && token.scope !== 'package') {
|
|
263
|
+
return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: `Unsupported revision token scope: "${token.scope}". Expected "document-parts" or "package".` } };
|
|
264
|
+
}
|
|
265
|
+
if (typeof token.value !== 'string' || !/^[0-9a-f]{64}$/i.test(token.value.trim())) {
|
|
266
|
+
return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: 'Revision token value must be a 64-character hex string.' } };
|
|
267
|
+
}
|
|
268
|
+
return { valid: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Compares two revision token values using timing-safe byte comparison.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} a
|
|
275
|
+
* @param {string} b
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
export function areRevisionTokensEqual(a, b) {
|
|
279
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
280
|
+
const aNorm = a.trim().toLowerCase();
|
|
281
|
+
const bNorm = b.trim().toLowerCase();
|
|
282
|
+
if (aNorm.length !== bNorm.length) return false;
|
|
283
|
+
const aBuf = textEncoder.encode(aNorm);
|
|
284
|
+
const bBuf = textEncoder.encode(bNorm);
|
|
285
|
+
let diff = 0;
|
|
286
|
+
for (let i = 0; i < aBuf.length; i++) {
|
|
287
|
+
diff |= aBuf[i] ^ bBuf[i];
|
|
288
|
+
}
|
|
289
|
+
return diff === 0;
|
|
290
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
|
+
import { warn as logWarning } from '../adapters/logger.js';
|
|
6
7
|
|
|
7
8
|
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
8
9
|
const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
@@ -11,10 +12,13 @@ const NUMBERING_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/200
|
|
|
11
12
|
const NUMBERING_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml';
|
|
12
13
|
const COMMENTS_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments';
|
|
13
14
|
const COMMENTS_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml';
|
|
15
|
+
const COMMENTS_EXTENDED_REL_TYPE = 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended';
|
|
16
|
+
const COMMENTS_EXTENDED_CONTENT_TYPE = 'application/vnd.ms-word.commentsExtended+xml';
|
|
14
17
|
|
|
15
18
|
const DOCUMENT_PATH = 'word/document.xml';
|
|
16
19
|
const NUMBERING_PATH = 'word/numbering.xml';
|
|
17
20
|
const COMMENTS_PATH = 'word/comments.xml';
|
|
21
|
+
const COMMENTS_EXTENDED_PATH = 'word/commentsExtended.xml';
|
|
18
22
|
const CONTENT_TYPES_PATH = '[Content_Types].xml';
|
|
19
23
|
const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
|
|
20
24
|
|
|
@@ -193,10 +197,14 @@ export function extractReplacementNodesFromOoxml(outputOxml) {
|
|
|
193
197
|
|
|
194
198
|
function upsertContentTypeOverride(ctDoc, partName, contentType) {
|
|
195
199
|
const overrides = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'));
|
|
196
|
-
const
|
|
200
|
+
const existingOverride = overrides.find(
|
|
197
201
|
override => (override.getAttribute('PartName') || '').toLowerCase() === String(partName).toLowerCase()
|
|
198
202
|
);
|
|
199
|
-
if (
|
|
203
|
+
if (existingOverride) {
|
|
204
|
+
if ((existingOverride.getAttribute('ContentType') || '') === contentType) return false;
|
|
205
|
+
existingOverride.setAttribute('ContentType', contentType);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
200
208
|
|
|
201
209
|
const override = ctDoc.createElementNS(NS_CT, 'Override');
|
|
202
210
|
override.setAttribute('PartName', partName);
|
|
@@ -205,7 +213,7 @@ function upsertContentTypeOverride(ctDoc, partName, contentType) {
|
|
|
205
213
|
return true;
|
|
206
214
|
}
|
|
207
215
|
|
|
208
|
-
function upsertDocumentRelationship(relsDoc, relType, target) {
|
|
216
|
+
function upsertDocumentRelationship(relsDoc, relType, target, options = {}) {
|
|
209
217
|
const relsRoot = relsDoc.getElementsByTagNameNS('*', 'Relationships')[0] || relsDoc.documentElement;
|
|
210
218
|
const rels = Array.from(relsRoot.getElementsByTagNameNS('*', 'Relationship'));
|
|
211
219
|
const hasRel = rels.some(rel => (rel.getAttribute('Type') || '') === relType);
|
|
@@ -220,11 +228,17 @@ function upsertDocumentRelationship(relsDoc, relType, target) {
|
|
|
220
228
|
}
|
|
221
229
|
}
|
|
222
230
|
|
|
231
|
+
const newId = `rId${maxId + 1}`;
|
|
223
232
|
const rel = relsDoc.createElementNS(NS_RELS, 'Relationship');
|
|
224
|
-
rel.setAttribute('Id',
|
|
233
|
+
rel.setAttribute('Id', newId);
|
|
225
234
|
rel.setAttribute('Type', relType);
|
|
226
235
|
rel.setAttribute('Target', target);
|
|
227
236
|
relsRoot.appendChild(rel);
|
|
237
|
+
if (options?._receiptCollector) {
|
|
238
|
+
options._receiptCollector.recordRelationship(newId);
|
|
239
|
+
} else if (options?._documentOperationSession?.receiptCollector) {
|
|
240
|
+
options._documentOperationSession.receiptCollector.recordRelationship(newId);
|
|
241
|
+
}
|
|
228
242
|
return true;
|
|
229
243
|
}
|
|
230
244
|
|
|
@@ -249,10 +263,14 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
|
|
|
249
263
|
const mergeNumberingXml = typeof options?.mergeNumberingXml === 'function'
|
|
250
264
|
? options.mergeNumberingXml
|
|
251
265
|
: null;
|
|
266
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : logWarning;
|
|
252
267
|
const incomingPayloads = (Array.isArray(numberingXmlList) ? numberingXmlList : [numberingXmlList]).filter(Boolean);
|
|
253
268
|
if (incomingPayloads.length === 0) return;
|
|
254
269
|
|
|
255
270
|
const existing = await readZipText(zip, NUMBERING_PATH);
|
|
271
|
+
if (existing && !mergeNumberingXml) {
|
|
272
|
+
onWarn('[Deprecation] Replacing an existing numbering.xml without mergeNumberingXml is deprecated and will throw in the next major version.');
|
|
273
|
+
}
|
|
256
274
|
let mergedNumberingXml = existing || null;
|
|
257
275
|
for (const incomingNumberingXml of incomingPayloads) {
|
|
258
276
|
if (!mergedNumberingXml) {
|
|
@@ -284,7 +302,7 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
|
|
|
284
302
|
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
285
303
|
if (relsText) {
|
|
286
304
|
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
287
|
-
if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml')) {
|
|
305
|
+
if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml', options)) {
|
|
288
306
|
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
289
307
|
}
|
|
290
308
|
}
|
|
@@ -303,7 +321,7 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
|
|
|
303
321
|
|
|
304
322
|
const serializer = createSerializer();
|
|
305
323
|
const existingText = await readZipText(zip, COMMENTS_PATH);
|
|
306
|
-
if (!existingText) {
|
|
324
|
+
if (!existingText || options.replaceExisting === true) {
|
|
307
325
|
onInfo('[Demo] Adding comments.xml');
|
|
308
326
|
zip.file(COMMENTS_PATH, commentsXml);
|
|
309
327
|
} else {
|
|
@@ -336,7 +354,29 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
|
|
|
336
354
|
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
337
355
|
if (relsText) {
|
|
338
356
|
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
339
|
-
if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml')) {
|
|
357
|
+
if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml', options)) {
|
|
358
|
+
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Ensures the modern Word comment-threading part and package metadata exist. */
|
|
364
|
+
export async function ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml, options = {}) {
|
|
365
|
+
if (!commentsExtendedXml) return;
|
|
366
|
+
parseXmlStrictStandalone(commentsExtendedXml, 'word/commentsExtended.xml');
|
|
367
|
+
zip.file(COMMENTS_EXTENDED_PATH, commentsExtendedXml);
|
|
368
|
+
const serializer = createSerializer();
|
|
369
|
+
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
370
|
+
if (ctText) {
|
|
371
|
+
const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
|
|
372
|
+
if (upsertContentTypeOverride(ctDoc, '/word/commentsExtended.xml', COMMENTS_EXTENDED_CONTENT_TYPE)) {
|
|
373
|
+
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
377
|
+
if (relsText) {
|
|
378
|
+
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
379
|
+
if (upsertDocumentRelationship(relsDoc, COMMENTS_EXTENDED_REL_TYPE, 'commentsExtended.xml', options)) {
|
|
340
380
|
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
341
381
|
}
|
|
342
382
|
}
|
|
@@ -395,6 +435,10 @@ export async function validateDocxPackage(zip) {
|
|
|
395
435
|
|
|
396
436
|
const numberingXml = await readZipText(zip, NUMBERING_PATH);
|
|
397
437
|
const commentsXml = await readZipText(zip, COMMENTS_PATH);
|
|
438
|
+
const commentsExtendedXml = await readZipText(zip, COMMENTS_EXTENDED_PATH);
|
|
439
|
+
if (commentsExtendedXml && !commentsXml) {
|
|
440
|
+
throw new Error('Validation failed: commentsExtended part exists but comments part is missing');
|
|
441
|
+
}
|
|
398
442
|
|
|
399
443
|
if (numberingXml) {
|
|
400
444
|
parseXmlStrictStandalone(numberingXml, NUMBERING_PATH);
|
|
@@ -403,7 +447,66 @@ export async function validateDocxPackage(zip) {
|
|
|
403
447
|
}
|
|
404
448
|
|
|
405
449
|
if (commentsXml) {
|
|
406
|
-
parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
|
|
450
|
+
const commentsDoc = parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
|
|
451
|
+
const idsFor = (doc, localName) => Array.from(doc.getElementsByTagNameNS(NS_W, localName))
|
|
452
|
+
.map(node => node.getAttribute('w:id') || node.getAttribute('id'))
|
|
453
|
+
.filter(id => id !== '');
|
|
454
|
+
const starts = new Set(idsFor(documentDoc, 'commentRangeStart'));
|
|
455
|
+
const ends = new Set(idsFor(documentDoc, 'commentRangeEnd'));
|
|
456
|
+
const references = new Set(idsFor(documentDoc, 'commentReference'));
|
|
457
|
+
const definitionIds = idsFor(commentsDoc, 'comment');
|
|
458
|
+
const definitions = new Set(definitionIds);
|
|
459
|
+
const sorted = ids => Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
460
|
+
const difference = (left, right) => sorted(new Set(Array.from(left).filter(id => !right.has(id))));
|
|
461
|
+
|
|
462
|
+
const duplicateDefinitions = sorted(new Set(definitionIds.filter((id, index) => definitionIds.indexOf(id) !== index)));
|
|
463
|
+
if (duplicateDefinitions.length > 0) {
|
|
464
|
+
throw new Error(`Validation failed: duplicate comment definitions for id(s): ${duplicateDefinitions.join(', ')}`);
|
|
465
|
+
}
|
|
466
|
+
const startsWithoutEnds = difference(starts, ends);
|
|
467
|
+
const endsWithoutStarts = difference(ends, starts);
|
|
468
|
+
if (startsWithoutEnds.length > 0 || endsWithoutStarts.length > 0) {
|
|
469
|
+
const parts = [];
|
|
470
|
+
if (startsWithoutEnds.length > 0) parts.push(`start without end: ${startsWithoutEnds.join(', ')}`);
|
|
471
|
+
if (endsWithoutStarts.length > 0) parts.push(`end without start: ${endsWithoutStarts.join(', ')}`);
|
|
472
|
+
throw new Error(`Validation failed: unbalanced comment range marker(s) (${parts.join('; ')})`);
|
|
473
|
+
}
|
|
474
|
+
const rangesWithoutReferences = difference(new Set([...starts, ...ends]), references);
|
|
475
|
+
if (rangesWithoutReferences.length > 0) {
|
|
476
|
+
throw new Error(`Validation failed: comment range has no reference for id(s): ${rangesWithoutReferences.join(', ')}`);
|
|
477
|
+
}
|
|
478
|
+
const usagesWithoutDefinitions = difference(new Set([...starts, ...ends, ...references]), definitions);
|
|
479
|
+
if (usagesWithoutDefinitions.length > 0) {
|
|
480
|
+
throw new Error(`Validation failed: comment usage has no definition for id(s): ${usagesWithoutDefinitions.join(', ')}`);
|
|
481
|
+
}
|
|
482
|
+
const replyCommentIds = new Set();
|
|
483
|
+
if (commentsExtendedXml) {
|
|
484
|
+
const extendedDoc = parseXmlStrictStandalone(commentsExtendedXml, COMMENTS_EXTENDED_PATH);
|
|
485
|
+
const paraIdToCommentId = new Map();
|
|
486
|
+
for (const comment of Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment'))) {
|
|
487
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
488
|
+
const paragraph = Array.from(comment.getElementsByTagNameNS('*', 'p'))[0];
|
|
489
|
+
const paraId = paragraph?.getAttribute('w14:paraId') || paragraph?.getAttribute('paraId');
|
|
490
|
+
if (paraId) paraIdToCommentId.set(paraId.toUpperCase(), id);
|
|
491
|
+
}
|
|
492
|
+
const knownParaIds = new Set(paraIdToCommentId.keys());
|
|
493
|
+
const extendedParaIds = new Set();
|
|
494
|
+
for (const entry of Array.from(extendedDoc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
495
|
+
const paraId = entry.getAttribute('w15:paraId') || entry.getAttribute('paraId');
|
|
496
|
+
const parentParaId = entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent');
|
|
497
|
+
if (!paraId || !knownParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: commentsExtended entry has no matching comment paragraph: ${paraId || '(missing)'}`);
|
|
498
|
+
if (extendedParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: duplicate commentsExtended entry for paraId: ${paraId}`);
|
|
499
|
+
extendedParaIds.add(paraId.toUpperCase());
|
|
500
|
+
if (parentParaId) {
|
|
501
|
+
if (!knownParaIds.has(parentParaId.toUpperCase())) throw new Error(`Validation failed: commentsExtended parent paragraph was not found: ${parentParaId}`);
|
|
502
|
+
replyCommentIds.add(paraIdToCommentId.get(paraId.toUpperCase()));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const definitionsWithoutReferences = difference(new Set([...definitions].filter(id => !replyCommentIds.has(id))), references);
|
|
507
|
+
if (definitionsWithoutReferences.length > 0) {
|
|
508
|
+
throw new Error(`Validation failed: comment definition has no document reference for id(s): ${definitionsWithoutReferences.join(', ')}`);
|
|
509
|
+
}
|
|
407
510
|
} else if (hasCommentUsage) {
|
|
408
511
|
throw new Error('Validation failed: comments used but part missing');
|
|
409
512
|
}
|
|
@@ -451,4 +554,16 @@ export async function validateDocxPackage(zip) {
|
|
|
451
554
|
throw new Error('Validation failed: comments rel missing');
|
|
452
555
|
}
|
|
453
556
|
}
|
|
557
|
+
|
|
558
|
+
if (commentsExtendedXml) {
|
|
559
|
+
const hasContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
|
|
560
|
+
(override.getAttribute('PartName') || '').toLowerCase() === '/word/commentsextended.xml'
|
|
561
|
+
&& (override.getAttribute('ContentType') || '') === COMMENTS_EXTENDED_CONTENT_TYPE
|
|
562
|
+
);
|
|
563
|
+
const hasRelationship = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
|
|
564
|
+
(rel.getAttribute('Type') || '') === COMMENTS_EXTENDED_REL_TYPE
|
|
565
|
+
);
|
|
566
|
+
if (!hasContentType) throw new Error('Validation failed: commentsExtended CT override missing');
|
|
567
|
+
if (!hasRelationship) throw new Error('Validation failed: commentsExtended rel missing');
|
|
568
|
+
}
|
|
454
569
|
}
|