@ansonlai/docx-redline-js 0.7.2 → 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/AGENTS.md +15 -10
- package/ARCHITECTURE.md +30 -10
- package/CHANGELOG.md +539 -431
- package/README.md +331 -309
- package/adapters/xml-adapter.js +84 -56
- package/core/sha256.js +120 -0
- package/dist/docx-redline-js.esm.js +8982 -693
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +102 -89
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +28305 -0
- package/dist/docx-redline.bundle.cjs.map +7 -0
- package/dist/docx-redline.bundle.js +28296 -0
- package/dist/docx-redline.bundle.js.map +7 -0
- package/docs/AGENT_FAST_START.md +2 -1
- package/docs/AGENT_KNOWLEDGE_BASE.md +480 -393
- package/docs/SKILL_AUTHORING.md +5 -2
- package/docs/TESTING.md +2 -1
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.d.ts +39 -0
- package/document/docx-document.js +1010 -0
- package/document/zip-archive.js +258 -0
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -531
- package/index.js +349 -340
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/docx-document.js +11 -355
- package/node/index.d.ts +1 -1
- package/node/zip-archive.js +28 -38
- package/package.json +14 -11
- package/scripts/build.mjs +40 -5
- 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/revision-token.js +11 -7
- package/services/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header and footer parts: discovery, targeting and safety checks.
|
|
3
|
+
*
|
|
4
|
+
* Word stores each header/footer as its own part (word/header1.xml ...), referenced from a section's
|
|
5
|
+
* w:sectPr through w:headerReference / w:footerReference (type default | first | even) and the document
|
|
6
|
+
* relationships. Behavior here is grounded in tests/fixtures/word-authored/header-footer.docx, saved by Word.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
10
|
+
import { findPartSpecByPartName } from './package-parts.js';
|
|
11
|
+
|
|
12
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
13
|
+
const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
14
|
+
|
|
15
|
+
const REVISION_ELEMENTS = new Set(['ins', 'del', 'moveFrom', 'moveTo', 'rPrChange', 'pPrChange', 'sectPrChange', 'tblPrChange', 'tblGridChange', 'tcPrChange', 'trPrChange', 'numberingChange']);
|
|
16
|
+
const REVISION_CONTENT_ELEMENTS = new Set(['ins', 'del', 'moveFrom', 'moveTo']);
|
|
17
|
+
|
|
18
|
+
const attrOf = (node, qualified, local) => node?.getAttribute?.(qualified) || node?.getAttribute?.(local) || '';
|
|
19
|
+
|
|
20
|
+
/** Resolves a relationship Target (relative to word/) to a package entry name such as word/header1.xml. */
|
|
21
|
+
function resolveTarget(target) {
|
|
22
|
+
const clean = String(target || '');
|
|
23
|
+
if (clean.startsWith('/')) return clean.replace(/^\/+/, '');
|
|
24
|
+
const parts = ['word'];
|
|
25
|
+
for (const segment of clean.split('/')) {
|
|
26
|
+
if (segment === '..') parts.pop();
|
|
27
|
+
else if (segment && segment !== '.') parts.push(segment);
|
|
28
|
+
}
|
|
29
|
+
return parts.join('/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lists the header/footer parts a document uses.
|
|
34
|
+
*
|
|
35
|
+
* @param {{ documentXml: string, relsXml?: string|null, settingsXml?: string|null }} parts
|
|
36
|
+
* `sections` lists sections that reference the part explicitly. A section with no reference of some type inherits
|
|
37
|
+
* the previous section's part (Word omits the reference to mean "linked to previous"), so `appliesToSections` also
|
|
38
|
+
* includes inherited sections.
|
|
39
|
+
*
|
|
40
|
+
* @returns {Array<{ path: string, kind: 'header'|'footer', type: 'default'|'first'|'even', sections: number[],
|
|
41
|
+
* appliesToSections: number[], sharedBySections: boolean, active: boolean }>}
|
|
42
|
+
*/
|
|
43
|
+
export function discoverHeaderFooterParts({ documentXml, relsXml = null, settingsXml = null }) {
|
|
44
|
+
if (!documentXml || !relsXml) return [];
|
|
45
|
+
const documentParsed = parseOoxmlSafe(documentXml, 'application/xml');
|
|
46
|
+
const relsParsed = parseOoxmlSafe(relsXml, 'application/xml');
|
|
47
|
+
if (!documentParsed.doc || documentParsed.error || !relsParsed.doc || relsParsed.error) return [];
|
|
48
|
+
|
|
49
|
+
const targetByRelId = new Map();
|
|
50
|
+
for (const rel of Array.from(relsParsed.doc.getElementsByTagNameNS('*', 'Relationship'))) {
|
|
51
|
+
targetByRelId.set(rel.getAttribute('Id') || '', rel.getAttribute('Target') || '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const evenAndOdd = !!settingsXml && /<w:evenAndOddHeaders\b(?![^>]*w:val="(?:0|false|off)")/.test(settingsXml);
|
|
55
|
+
const byPath = new Map();
|
|
56
|
+
const inForce = new Map(); // `${kind}:${type}` -> path currently in force
|
|
57
|
+
const sectPrs = Array.from(documentParsed.doc.getElementsByTagNameNS(NS_W, 'sectPr'));
|
|
58
|
+
sectPrs.forEach((sectPr, sectionIndex) => {
|
|
59
|
+
const titlePg = Array.from(sectPr.childNodes || []).some(child => child.localName === 'titlePg' && !/^(0|false|off)$/i.test(attrOf(child, 'w:val', 'val')));
|
|
60
|
+
for (const ref of Array.from(sectPr.childNodes || [])) {
|
|
61
|
+
if (ref.nodeType !== 1 || (ref.localName !== 'headerReference' && ref.localName !== 'footerReference')) continue;
|
|
62
|
+
const relId = ref.getAttributeNS?.(NS_R, 'id') || ref.getAttribute('r:id') || '';
|
|
63
|
+
const target = targetByRelId.get(relId);
|
|
64
|
+
if (!target) continue;
|
|
65
|
+
const path = resolveTarget(target);
|
|
66
|
+
const kind = ref.localName === 'headerReference' ? 'header' : 'footer';
|
|
67
|
+
const type = attrOf(ref, 'w:type', 'type') || 'default';
|
|
68
|
+
const entry = byPath.get(path) || { path, kind, type, sections: [], applies: [], titlePgSections: 0 };
|
|
69
|
+
if (!entry.sections.includes(sectionIndex)) entry.sections.push(sectionIndex);
|
|
70
|
+
if (titlePg) entry.titlePgSections += 1;
|
|
71
|
+
byPath.set(path, entry);
|
|
72
|
+
inForce.set(`${kind}:${type}`, path);
|
|
73
|
+
}
|
|
74
|
+
for (const path of inForce.values()) {
|
|
75
|
+
const entry = byPath.get(path);
|
|
76
|
+
if (entry && !entry.applies.includes(sectionIndex)) entry.applies.push(sectionIndex);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return Array.from(byPath.values()).map(entry => ({
|
|
81
|
+
path: entry.path,
|
|
82
|
+
kind: entry.kind,
|
|
83
|
+
type: entry.type,
|
|
84
|
+
sections: entry.sections,
|
|
85
|
+
appliesToSections: entry.applies,
|
|
86
|
+
sharedBySections: entry.applies.length > 1,
|
|
87
|
+
// first-page parts only show with w:titlePg; even parts only with w:evenAndOddHeaders in settings.xml.
|
|
88
|
+
active: entry.type === 'first' ? entry.titlePgSections > 0 : entry.type === 'even' ? evenAndOdd : true
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolves an operation's `part` selector to one discovered part.
|
|
94
|
+
*
|
|
95
|
+
* @param {ReturnType<typeof discoverHeaderFooterParts>} parts
|
|
96
|
+
* @param {string | { kind: 'header'|'footer', type?: string, section?: number }} selector
|
|
97
|
+
* @returns {{ part: object } | { error: { code: string, message: string, candidates?: object[] } }}
|
|
98
|
+
*/
|
|
99
|
+
export function resolvePartSelector(parts, selector) {
|
|
100
|
+
const describe = part => ({ path: part.path, kind: part.kind, type: part.type, sections: part.sections, appliesToSections: part.appliesToSections });
|
|
101
|
+
if (typeof selector === 'string') {
|
|
102
|
+
const wanted = selector.replace(/^\/+/, '').toLowerCase();
|
|
103
|
+
const match = parts.find(part => part.path.toLowerCase() === wanted || part.path.toLowerCase() === `word/${wanted}`);
|
|
104
|
+
if (match) return { part: match };
|
|
105
|
+
return { error: { code: 'PART_NOT_FOUND', message: `No header/footer part '${selector}' is referenced by the document.`, candidates: parts.map(describe) } };
|
|
106
|
+
}
|
|
107
|
+
if (!selector || typeof selector !== 'object' || (selector.kind !== 'header' && selector.kind !== 'footer')) {
|
|
108
|
+
return { error: { code: 'INVALID_OPERATION', message: 'part must be a part path or { kind: "header"|"footer", type?: "default"|"first"|"even", section?: number }.' } };
|
|
109
|
+
}
|
|
110
|
+
const type = selector.type || 'default';
|
|
111
|
+
if (!['default', 'first', 'even'].includes(type)) {
|
|
112
|
+
return { error: { code: 'INVALID_OPERATION', message: `part.type must be default, first or even (got '${type}').` } };
|
|
113
|
+
}
|
|
114
|
+
let matches = parts.filter(part => part.kind === selector.kind && part.type === type);
|
|
115
|
+
if (Number.isInteger(selector.section)) matches = matches.filter(part => part.appliesToSections.includes(selector.section));
|
|
116
|
+
if (matches.length === 1) return { part: matches[0] };
|
|
117
|
+
if (matches.length === 0) {
|
|
118
|
+
return { error: { code: 'PART_NOT_FOUND', message: `The document has no ${type} ${selector.kind}${Number.isInteger(selector.section) ? ` for section ${selector.section}` : ''}.`, candidates: parts.map(describe) } };
|
|
119
|
+
}
|
|
120
|
+
return { error: { code: 'PART_AMBIGUOUS', message: `Several ${type} ${selector.kind} parts exist; add "section" or use the part path.`, candidates: matches.map(describe) } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether a package entry name is a header or footer part. */
|
|
124
|
+
export function isHeaderFooterPath(path) {
|
|
125
|
+
const spec = findPartSpecByPartName(path);
|
|
126
|
+
return spec?.id === 'header' || spec?.id === 'footer';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Counts tracked-change elements that sit inside a field's instruction or cached result
|
|
131
|
+
* (between w:fldChar begin and end, or inside w:fldSimple). A field is one atomic unit: PAGE, NUMPAGES, DATE and
|
|
132
|
+
* friends are recomputed by Word, so a redline inside one is meaningless and can leave stray text after accept.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} xml part XML
|
|
135
|
+
* @returns {number}
|
|
136
|
+
*/
|
|
137
|
+
export function countRevisionsInsideFields(xml) {
|
|
138
|
+
const parsed = parseOoxmlSafe(xml, 'application/xml');
|
|
139
|
+
if (!parsed.doc || parsed.error) return 0;
|
|
140
|
+
let depth = 0;
|
|
141
|
+
let count = 0;
|
|
142
|
+
const visit = node => {
|
|
143
|
+
for (const child of Array.from(node.childNodes || [])) {
|
|
144
|
+
if (child.nodeType !== 1) continue;
|
|
145
|
+
const name = child.localName;
|
|
146
|
+
if (name === 'fldChar') {
|
|
147
|
+
const type = attrOf(child, 'w:fldCharType', 'fldCharType');
|
|
148
|
+
if (type === 'begin') depth += 1;
|
|
149
|
+
else if (type === 'end') depth = Math.max(0, depth - 1);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (REVISION_CONTENT_ELEMENTS.has(name) && depth > 0) count += 1;
|
|
153
|
+
if (name === 'fldSimple') {
|
|
154
|
+
depth += 1;
|
|
155
|
+
visit(child);
|
|
156
|
+
depth -= 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
visit(child);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
visit(parsed.doc.documentElement);
|
|
163
|
+
return count;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** All w:id values used by revision elements in an XML string. */
|
|
167
|
+
export function collectRevisionIds(xml) {
|
|
168
|
+
const ids = new Set();
|
|
169
|
+
for (const match of String(xml || '').matchAll(/<w:(?:ins|del|moveFrom|moveTo|rPrChange|pPrChange|sectPrChange|tblPrChange|tblGridChange|tcPrChange|trPrChange|numberingChange)\b[^>]*?\bw:id="(\d+)"/g)) ids.add(match[1]);
|
|
170
|
+
return ids;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Renumbers revision elements in `xml` whose w:id collides with an id in `taken`, so ids stay unique across
|
|
175
|
+
* document.xml and header/footer parts. Ids that do not collide are left alone.
|
|
176
|
+
*
|
|
177
|
+
* @returns {{ xml: string, renumbered: number, idMap: Map<string, string> }} idMap maps each old id to its new id
|
|
178
|
+
*/
|
|
179
|
+
export function renumberCollidingRevisionIds(xml, taken) {
|
|
180
|
+
const parsed = parseOoxmlSafe(xml, 'application/xml');
|
|
181
|
+
if (!parsed.doc || parsed.error) return { xml, renumbered: 0, idMap: new Map() };
|
|
182
|
+
const idMap = new Map();
|
|
183
|
+
let next = 1 + Math.max(0, ...Array.from(taken, id => Number(id)).filter(Number.isFinite));
|
|
184
|
+
const used = new Set(taken);
|
|
185
|
+
let renumbered = 0;
|
|
186
|
+
const visit = node => {
|
|
187
|
+
for (const child of Array.from(node.childNodes || [])) {
|
|
188
|
+
if (child.nodeType !== 1) continue;
|
|
189
|
+
if (REVISION_ELEMENTS.has(child.localName)) {
|
|
190
|
+
const id = attrOf(child, 'w:id', 'id');
|
|
191
|
+
if (id && used.has(id)) {
|
|
192
|
+
while (used.has(String(next))) next += 1;
|
|
193
|
+
child.setAttributeNS(NS_W, 'w:id', String(next));
|
|
194
|
+
idMap.set(id, String(next));
|
|
195
|
+
used.add(String(next));
|
|
196
|
+
renumbered += 1;
|
|
197
|
+
} else if (id) {
|
|
198
|
+
used.add(id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
visit(child);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
visit(parsed.doc.documentElement);
|
|
205
|
+
return renumbered > 0 ? { xml: createSerializer().serializeToString(parsed.doc), renumbered, idMap } : { xml, renumbered: 0, idMap };
|
|
206
|
+
}
|
|
@@ -251,6 +251,7 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
|
251
251
|
if (
|
|
252
252
|
validation.valid
|
|
253
253
|
&& operation.operationKind !== 'comment_reply'
|
|
254
|
+
&& operation.operationKind !== 'comment_resolve'
|
|
254
255
|
&& !operation.targetDescriptor?.captureRef
|
|
255
256
|
&& !binding.dynamic
|
|
256
257
|
) {
|