@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.
Files changed (53) hide show
  1. package/AGENTS.md +15 -10
  2. package/ARCHITECTURE.md +30 -10
  3. package/CHANGELOG.md +539 -431
  4. package/README.md +331 -309
  5. package/adapters/xml-adapter.js +84 -56
  6. package/core/sha256.js +120 -0
  7. package/dist/docx-redline-js.esm.js +8982 -693
  8. package/dist/docx-redline-js.esm.js.map +4 -4
  9. package/dist/docx-redline-js.esm.min.js +102 -89
  10. package/dist/docx-redline-js.esm.min.js.map +4 -4
  11. package/dist/docx-redline.bundle.cjs +28305 -0
  12. package/dist/docx-redline.bundle.cjs.map +7 -0
  13. package/dist/docx-redline.bundle.js +28296 -0
  14. package/dist/docx-redline.bundle.js.map +7 -0
  15. package/docs/AGENT_FAST_START.md +2 -1
  16. package/docs/AGENT_KNOWLEDGE_BASE.md +480 -393
  17. package/docs/SKILL_AUTHORING.md +5 -2
  18. package/docs/TESTING.md +2 -1
  19. package/docs/schemas/document-operations.schema.json +29 -0
  20. package/document/docx-document.d.ts +39 -0
  21. package/document/docx-document.js +1010 -0
  22. package/document/zip-archive.js +258 -0
  23. package/engine/reconstruction-writer.js +18 -0
  24. package/index.d.ts +598 -531
  25. package/index.js +349 -340
  26. package/node/cli-help.js +5 -5
  27. package/node/cli.js +760 -754
  28. package/node/docx-document.js +11 -355
  29. package/node/index.d.ts +1 -1
  30. package/node/zip-archive.js +28 -38
  31. package/package.json +14 -11
  32. package/scripts/build.mjs +40 -5
  33. package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
  34. package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
  35. package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
  36. package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
  37. package/scripts/generate-word-package-integrity-cases.mjs +141 -0
  38. package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
  39. package/scripts/word-com-package-integrity.ps1 +98 -0
  40. package/services/batch-operation-orchestrator.js +262 -259
  41. package/services/comment-replies.js +96 -95
  42. package/services/comment-thread-parts.js +280 -0
  43. package/services/document-inspection.js +366 -365
  44. package/services/document-operation-applier.js +230 -202
  45. package/services/document-operation-contract.js +17 -2
  46. package/services/error-recovery.js +6 -0
  47. package/services/headers-footers.js +206 -0
  48. package/services/operation-batch-compiler.js +1 -0
  49. package/services/operation-preflight.js +231 -218
  50. package/services/package-parts.js +80 -0
  51. package/services/revision-token.js +11 -7
  52. package/services/standalone-docx-plumbing.js +631 -569
  53. 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
  ) {