dayassist 0.1.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/bin/dayassist +45 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/index-D1gfo14_.js +1 -0
- package/dist/assets/index-DBbObfqi.css +1 -0
- package/dist/assets/logo-DbmmI8Z0.png +0 -0
- package/dist/index.html +17 -0
- package/dist-electron/harness.min.js +2 -0
- package/dist-electron/main.js +1 -0
- package/dist-electron/preload.js +1 -0
- package/package.json +64 -0
- package/skills/docx/LICENSE.txt +30 -0
- package/skills/docx/SKILL.md +144 -0
- package/skills/docx/package-lock.json +207 -0
- package/skills/docx/package.json +10 -0
- package/skills/docx/scripts/node/docx.cjs +370 -0
- package/skills/docx/scripts/node/unpack.cjs +122 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* docx.cjs — deterministic Word document generator & editor.
|
|
4
|
+
*
|
|
5
|
+
* The model produces a JSON spec; this tested code turns it into a .docx with
|
|
6
|
+
* all the OOXML/docx-js correctness rules baked in (DXA table widths, real
|
|
7
|
+
* bullet numbering, ShadingType.CLEAR, US-Letter default, xml:space, …). The
|
|
8
|
+
* model never writes docx-js or touches raw XML.
|
|
9
|
+
*
|
|
10
|
+
* Usage (spec via --spec <file> or stdin):
|
|
11
|
+
* "$OPEN_CORE_NODE" docx.cjs create --out doc.docx < spec.json
|
|
12
|
+
* "$OPEN_CORE_NODE" docx.cjs edit --out doc.docx < editspec.json
|
|
13
|
+
*
|
|
14
|
+
* Deps (docx, jszip, xml-js) resolve from the skill's own node_modules.
|
|
15
|
+
*/
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const D = require("docx");
|
|
19
|
+
|
|
20
|
+
// ---- unit conversions (spec uses friendly units) ----------------------------
|
|
21
|
+
const pt = (p) => Math.round(p * 2); // points → half-points (run size)
|
|
22
|
+
const tw = (p) => Math.round(p * 20); // points → twips (spacing)
|
|
23
|
+
const inТwips = (i) => Math.round(i * 1440); // inches → twips (margins)
|
|
24
|
+
|
|
25
|
+
const PAGE = {
|
|
26
|
+
letter: { width: 12240, height: 15840 },
|
|
27
|
+
a4: { width: 11906, height: 16838 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// CREATE
|
|
32
|
+
// =============================================================================
|
|
33
|
+
function buildDocument(spec) {
|
|
34
|
+
const fontFamily = spec.font?.family || "Arial";
|
|
35
|
+
const fontSize = pt(spec.font?.size || 11);
|
|
36
|
+
|
|
37
|
+
const paper = PAGE[(spec.page?.size || "letter").toLowerCase()] || PAGE.letter;
|
|
38
|
+
const landscape = spec.page?.orientation === "landscape";
|
|
39
|
+
const margin = inТwips(spec.page?.margin ?? 1);
|
|
40
|
+
// Content width = page width minus L/R margins (long edge in landscape).
|
|
41
|
+
const contentWidth = (landscape ? paper.height : paper.width) - margin * 2;
|
|
42
|
+
|
|
43
|
+
const numbering = { config: [
|
|
44
|
+
{ reference: "bullets", levels: bulletLevels() },
|
|
45
|
+
{ reference: "numbers", levels: numberLevels() },
|
|
46
|
+
] };
|
|
47
|
+
|
|
48
|
+
const children = (spec.body || []).map((b) => block(b, contentWidth)).flat();
|
|
49
|
+
|
|
50
|
+
const section = {
|
|
51
|
+
properties: {
|
|
52
|
+
page: {
|
|
53
|
+
size: {
|
|
54
|
+
width: paper.width,
|
|
55
|
+
height: paper.height,
|
|
56
|
+
orientation: landscape ? D.PageOrientation.LANDSCAPE : D.PageOrientation.PORTRAIT,
|
|
57
|
+
},
|
|
58
|
+
margin: { top: margin, right: margin, bottom: margin, left: margin },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
children,
|
|
62
|
+
};
|
|
63
|
+
if (spec.header) section.headers = { default: makeHF(spec.header, false) };
|
|
64
|
+
if (spec.footer) section.footers = { default: makeHF(spec.footer, true) };
|
|
65
|
+
|
|
66
|
+
return new D.Document({
|
|
67
|
+
styles: {
|
|
68
|
+
default: { document: { run: { font: fontFamily, size: fontSize } } },
|
|
69
|
+
paragraphStyles: [
|
|
70
|
+
headingStyle("Heading1", 18, fontFamily, 0),
|
|
71
|
+
headingStyle("Heading2", 14, fontFamily, 1),
|
|
72
|
+
headingStyle("Heading3", 12, fontFamily, 2),
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
numbering,
|
|
76
|
+
sections: [section],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function headingStyle(id, sizePt, font, outline) {
|
|
81
|
+
return {
|
|
82
|
+
id, name: id.replace(/(\d)/, " $1"), basedOn: "Normal", next: "Normal", quickFormat: true,
|
|
83
|
+
run: { size: pt(sizePt), bold: true, font },
|
|
84
|
+
paragraph: { spacing: { before: tw(10), after: tw(8) }, outlineLevel: outline },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function bulletLevels() {
|
|
88
|
+
return [0, 1, 2].map((level) => ({
|
|
89
|
+
level, format: D.LevelFormat.BULLET, text: ["•", "◦", "▪"][level],
|
|
90
|
+
alignment: D.AlignmentType.LEFT,
|
|
91
|
+
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
function numberLevels() {
|
|
95
|
+
return [0, 1, 2].map((level) => ({
|
|
96
|
+
level, format: D.LevelFormat.DECIMAL, text: `%${level + 1}.`,
|
|
97
|
+
alignment: D.AlignmentType.LEFT,
|
|
98
|
+
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ALIGN = {
|
|
103
|
+
left: D.AlignmentType.LEFT, center: D.AlignmentType.CENTER,
|
|
104
|
+
right: D.AlignmentType.RIGHT, justify: D.AlignmentType.JUSTIFIED,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function runs(spec) {
|
|
108
|
+
const list = spec.runs ? spec.runs : [spec.text ?? ""];
|
|
109
|
+
return list.map((r) => {
|
|
110
|
+
if (typeof r === "string") r = { text: r };
|
|
111
|
+
const opts = {
|
|
112
|
+
text: r.text ?? "", bold: r.bold, italics: r.italic,
|
|
113
|
+
underline: r.underline ? {} : undefined,
|
|
114
|
+
color: r.color, size: r.size ? pt(r.size) : undefined, font: r.font,
|
|
115
|
+
};
|
|
116
|
+
if (r.link) {
|
|
117
|
+
return new D.ExternalHyperlink({
|
|
118
|
+
children: [new D.TextRun({ ...opts, style: "Hyperlink" })], link: r.link,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return new D.TextRun(opts);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function block(b, contentWidth) {
|
|
126
|
+
switch (b.type) {
|
|
127
|
+
case "heading": {
|
|
128
|
+
const lvl = Math.min(Math.max(b.level || 1, 1), 3);
|
|
129
|
+
return new D.Paragraph({
|
|
130
|
+
heading: [D.HeadingLevel.HEADING_1, D.HeadingLevel.HEADING_2, D.HeadingLevel.HEADING_3][lvl - 1],
|
|
131
|
+
children: runs(b),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
case "paragraph":
|
|
135
|
+
return new D.Paragraph({
|
|
136
|
+
children: runs(b),
|
|
137
|
+
alignment: ALIGN[b.align],
|
|
138
|
+
spacing: b.spacingAfter ? { after: tw(b.spacingAfter) } : undefined,
|
|
139
|
+
});
|
|
140
|
+
case "bullets":
|
|
141
|
+
case "numbered": {
|
|
142
|
+
const ref = b.type === "bullets" ? "bullets" : "numbers";
|
|
143
|
+
return (b.items || []).map((it) => {
|
|
144
|
+
if (typeof it === "string") it = { text: it };
|
|
145
|
+
return new D.Paragraph({ numbering: { reference: ref, level: it.level || 0 }, children: runs(it) });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
case "table":
|
|
149
|
+
return table(b, contentWidth);
|
|
150
|
+
case "image":
|
|
151
|
+
return new D.Paragraph({ children: [image(b)] });
|
|
152
|
+
case "pagebreak":
|
|
153
|
+
return new D.Paragraph({ children: [new D.PageBreak()] });
|
|
154
|
+
case "toc":
|
|
155
|
+
return new D.TableOfContents(b.title || "Table of Contents", { hyperlink: true, headingStyleRange: "1-3" });
|
|
156
|
+
default:
|
|
157
|
+
throw new Error(`unknown block type: ${JSON.stringify(b.type)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function table(b, contentWidth) {
|
|
162
|
+
const header = b.header || null;
|
|
163
|
+
const bodyRows = b.rows || [];
|
|
164
|
+
const nCols = header ? header.length : (bodyRows[0] || []).length;
|
|
165
|
+
if (!nCols) throw new Error("table has no columns");
|
|
166
|
+
// Dual widths: column widths sum to content width; each cell echoes its column width.
|
|
167
|
+
const widths = b.widths || Array(nCols).fill(Math.floor(contentWidth / nCols));
|
|
168
|
+
const border = { style: D.BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
|
|
169
|
+
const borders = { top: border, bottom: border, left: border, right: border };
|
|
170
|
+
|
|
171
|
+
const mkRow = (cells, isHeader) => new D.TableRow({
|
|
172
|
+
children: cells.map((c, i) => {
|
|
173
|
+
if (typeof c === "string") c = { text: c };
|
|
174
|
+
return new D.TableCell({
|
|
175
|
+
borders,
|
|
176
|
+
width: { size: widths[i], type: D.WidthType.DXA },
|
|
177
|
+
shading: c.fill ? { fill: c.fill, type: D.ShadingType.CLEAR }
|
|
178
|
+
: isHeader ? { fill: "D9EAF7", type: D.ShadingType.CLEAR } : undefined,
|
|
179
|
+
margins: { top: 80, bottom: 80, left: 120, right: 120 },
|
|
180
|
+
children: [new D.Paragraph({ alignment: ALIGN[c.align], children: runs({ ...c, bold: c.bold ?? isHeader }) })],
|
|
181
|
+
});
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const rows = [];
|
|
186
|
+
if (header) rows.push(mkRow(header, true));
|
|
187
|
+
for (const r of bodyRows) rows.push(mkRow(r, false));
|
|
188
|
+
return new D.Table({ width: { size: widths.reduce((a, b) => a + b, 0), type: D.WidthType.DXA }, columnWidths: widths, rows });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function image(b) {
|
|
192
|
+
const data = b.path ? fs.readFileSync(b.path) : Buffer.from(b.data, "base64");
|
|
193
|
+
const ext = (b.path ? path.extname(b.path).slice(1) : b.format || "png").toLowerCase();
|
|
194
|
+
return new D.ImageRun({
|
|
195
|
+
type: ext === "jpg" ? "jpeg" : ext,
|
|
196
|
+
data,
|
|
197
|
+
transformation: { width: b.width || 300, height: b.height || 200 },
|
|
198
|
+
altText: { title: b.alt || "image", description: b.alt || "image", name: b.alt || "image" },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function makeHF(spec, isFooter) {
|
|
203
|
+
const children = spec.text || spec.runs ? runs({ text: spec.text, runs: spec.runs }) : [];
|
|
204
|
+
if (isFooter && spec.pageNumber) {
|
|
205
|
+
if (spec.text) children.push(new D.TextRun(" "));
|
|
206
|
+
children.push(new D.TextRun({ children: ["Page ", D.PageNumber.CURRENT] }));
|
|
207
|
+
}
|
|
208
|
+
const P = new D.Paragraph({ alignment: ALIGN[spec.align] || (isFooter ? D.AlignmentType.CENTER : D.AlignmentType.RIGHT), children });
|
|
209
|
+
return isFooter ? new D.Footer({ children: [P] }) : new D.Header({ children: [P] });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function create(spec, out) {
|
|
213
|
+
const doc = buildDocument(spec);
|
|
214
|
+
const buf = await D.Packer.toBuffer(doc);
|
|
215
|
+
fs.writeFileSync(out, buf);
|
|
216
|
+
console.log(`Created ${out} (${buf.length} bytes)`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// EDIT — high-level ops over the document XML (model never sees XML)
|
|
221
|
+
// =============================================================================
|
|
222
|
+
const JSZip = require("jszip");
|
|
223
|
+
const xmljs = require("xml-js");
|
|
224
|
+
|
|
225
|
+
async function edit(spec, outOverride) {
|
|
226
|
+
const input = spec.file;
|
|
227
|
+
const out = outOverride || spec.out || input;
|
|
228
|
+
if (!input) throw new Error("edit spec needs a \"file\"");
|
|
229
|
+
|
|
230
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(input));
|
|
231
|
+
const docXmlName = "word/document.xml";
|
|
232
|
+
let tree = xmljs.xml2js(await zip.file(docXmlName).async("string"), { compact: false });
|
|
233
|
+
mergeRunsTree(tree); // so replaceText sees contiguous text
|
|
234
|
+
|
|
235
|
+
for (const op of spec.ops || []) applyOp(tree, op);
|
|
236
|
+
|
|
237
|
+
const xml = xmljs.js2xml(tree, {});
|
|
238
|
+
zip.file(docXmlName, repairSpace(xml));
|
|
239
|
+
const buf = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 6 } });
|
|
240
|
+
fs.writeFileSync(out, buf);
|
|
241
|
+
console.log(`Edited ${input} → ${out} (${(spec.ops || []).length} ops, ${buf.length} bytes)`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function applyOp(tree, op) {
|
|
245
|
+
switch (op.op) {
|
|
246
|
+
case "replaceText": {
|
|
247
|
+
let n = 0;
|
|
248
|
+
eachText(tree, (node) => {
|
|
249
|
+
if (op.find && node.text.includes(op.find)) {
|
|
250
|
+
node.text = op.all === false
|
|
251
|
+
? node.text.replace(op.find, op.replace ?? "")
|
|
252
|
+
: node.text.split(op.find).join(op.replace ?? "");
|
|
253
|
+
n++;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
if (op.find && n === 0) throw new Error(`replaceText: text not found: ${JSON.stringify(op.find)}`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
case "appendParagraph":
|
|
260
|
+
return appendToBody(tree, paragraphXml(op.text ?? "", null));
|
|
261
|
+
case "appendHeading":
|
|
262
|
+
return appendToBody(tree, paragraphXml(op.text ?? "", Math.min(Math.max(op.level || 1, 1), 3)));
|
|
263
|
+
default:
|
|
264
|
+
throw new Error(`unknown edit op: ${JSON.stringify(op.op)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build a <w:p> element node for plain text (optionally a heading level).
|
|
269
|
+
function paragraphXml(text, level) {
|
|
270
|
+
const pPr = level
|
|
271
|
+
? [{ type: "element", name: "w:pPr", elements: [
|
|
272
|
+
{ type: "element", name: "w:pStyle", attributes: { "w:val": `Heading${level}` } }] }]
|
|
273
|
+
: [];
|
|
274
|
+
const needsSpace = /^\s|\s$/.test(text);
|
|
275
|
+
return {
|
|
276
|
+
type: "element", name: "w:p",
|
|
277
|
+
elements: [
|
|
278
|
+
...pPr,
|
|
279
|
+
{ type: "element", name: "w:r", elements: [
|
|
280
|
+
{ type: "element", name: "w:t",
|
|
281
|
+
attributes: needsSpace ? { "xml:space": "preserve" } : undefined,
|
|
282
|
+
elements: [{ type: "text", text }] }] },
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function appendToBody(tree, pNode) {
|
|
288
|
+
const body = findFirst(tree, "w:body");
|
|
289
|
+
if (!body) throw new Error("no <w:body> in document");
|
|
290
|
+
const sectIdx = (body.elements || []).findIndex((e) => e.name === "w:sectPr");
|
|
291
|
+
if (sectIdx >= 0) body.elements.splice(sectIdx, 0, pNode); // before final sectPr
|
|
292
|
+
else body.elements.push(pNode);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- shared XML tree helpers (also used by run-merging) ---------------------
|
|
296
|
+
function eachText(node, fn) {
|
|
297
|
+
if (!node.elements) return;
|
|
298
|
+
for (const el of node.elements) {
|
|
299
|
+
if (el.name === "w:t") {
|
|
300
|
+
for (const t of el.elements || []) if (t.type === "text") fn(t);
|
|
301
|
+
}
|
|
302
|
+
eachText(el, fn);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function findFirst(node, name) {
|
|
306
|
+
if (!node.elements) return null;
|
|
307
|
+
for (const el of node.elements) {
|
|
308
|
+
if (el.name === name) return el;
|
|
309
|
+
const f = findFirst(el, name);
|
|
310
|
+
if (f) return f;
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
function mergeRunsTree(node) {
|
|
315
|
+
if (!node.elements) return;
|
|
316
|
+
const merged = [];
|
|
317
|
+
for (const child of node.elements) {
|
|
318
|
+
const prev = merged[merged.length - 1];
|
|
319
|
+
if (prev && simpleRun(prev) && simpleRun(child) && sameRpr(prev, child)) {
|
|
320
|
+
const pt2 = wtOf(prev), combined = txt(pt2) + txt(wtOf(child));
|
|
321
|
+
pt2.elements = [{ type: "text", text: combined }];
|
|
322
|
+
if (/^\s|\s$/.test(combined)) (pt2.attributes = pt2.attributes || {})["xml:space"] = "preserve";
|
|
323
|
+
} else merged.push(child);
|
|
324
|
+
}
|
|
325
|
+
node.elements = merged;
|
|
326
|
+
for (const c of merged) mergeRunsTree(c);
|
|
327
|
+
}
|
|
328
|
+
const simpleRun = (e) => e?.type === "element" && e.name === "w:r" && (e.elements || []).filter((x) => x.name !== "w:rPr").length === 1 && (e.elements || []).find((x) => x.name !== "w:rPr")?.name === "w:t";
|
|
329
|
+
const rprOf = (r) => (r.elements || []).find((e) => e.name === "w:rPr");
|
|
330
|
+
const wtOf = (r) => (r.elements || []).find((e) => e.name === "w:t");
|
|
331
|
+
const sameRpr = (a, b) => (rprOf(a) ? xmljs.js2xml({ elements: [rprOf(a)] }) : "") === (rprOf(b) ? xmljs.js2xml({ elements: [rprOf(b)] }) : "");
|
|
332
|
+
const txt = (wt) => (wt.elements || []).filter((e) => e.type === "text").map((e) => e.text).join("");
|
|
333
|
+
function repairSpace(xml) {
|
|
334
|
+
const tree = xmljs.xml2js(xml, { compact: false });
|
|
335
|
+
eachText(tree, () => {});
|
|
336
|
+
(function fix(node) {
|
|
337
|
+
if (!node.elements) return;
|
|
338
|
+
for (const el of node.elements) {
|
|
339
|
+
if (el.name === "w:t" && /^\s|\s$/.test(txt(el))) (el.attributes = el.attributes || {})["xml:space"] = "preserve";
|
|
340
|
+
fix(el);
|
|
341
|
+
}
|
|
342
|
+
})(tree);
|
|
343
|
+
return xmljs.js2xml(tree, { spaces: 2 });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// =============================================================================
|
|
347
|
+
// CLI
|
|
348
|
+
// =============================================================================
|
|
349
|
+
function readSpec(argv) {
|
|
350
|
+
const si = argv.indexOf("--spec");
|
|
351
|
+
const raw = si >= 0 ? fs.readFileSync(argv[si + 1], "utf8") : fs.readFileSync(0, "utf8");
|
|
352
|
+
try { return JSON.parse(raw); }
|
|
353
|
+
catch (e) { throw new Error(`invalid JSON spec: ${e.message}`); }
|
|
354
|
+
}
|
|
355
|
+
function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; }
|
|
356
|
+
|
|
357
|
+
async function main() {
|
|
358
|
+
const [, , cmd, ...rest] = process.argv;
|
|
359
|
+
if (cmd === "create") {
|
|
360
|
+
const out = flag(rest, "--out");
|
|
361
|
+
if (!out) throw new Error("create needs --out <file>");
|
|
362
|
+
await create(readSpec(rest), out);
|
|
363
|
+
} else if (cmd === "edit") {
|
|
364
|
+
await edit(readSpec(rest), flag(rest, "--out"));
|
|
365
|
+
} else {
|
|
366
|
+
console.error("usage: docx.cjs create --out <file> < spec.json\n docx.cjs edit [--out <file>] < editspec.json");
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
main().catch((e) => { console.error("docx.cjs:", e.message); process.exit(1); });
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* unpack.cjs — unpack a .docx for reading (no Python needed).
|
|
4
|
+
*
|
|
5
|
+
* A .docx is a ZIP of XML parts. This extracts it to a directory, pretty-prints
|
|
6
|
+
* the XML so it's editable with the Edit tool, and (by default) merges adjacent
|
|
7
|
+
* runs in the story parts so a single sentence isn't split across many <w:r>
|
|
8
|
+
* elements — which is what makes find-and-replace editing actually work.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* "$OPEN_CORE_NODE" unpack.cjs <input.docx> <outDir> [--no-merge-runs]
|
|
12
|
+
*
|
|
13
|
+
* Then edit files under <outDir>/word/, and repack with pack.cjs.
|
|
14
|
+
*
|
|
15
|
+
* Deps (jszip, xml-js) resolve from the skill's own node_modules.
|
|
16
|
+
*/
|
|
17
|
+
const JSZip = require("jszip");
|
|
18
|
+
const xmljs = require("xml-js");
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
|
|
22
|
+
const STORY_PART = /(?:^|\/)(document|header\d*|footer\d*|footnotes|endnotes)\.xml$/i;
|
|
23
|
+
const XML_PART = /\.(xml|rels)$/i;
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const [, , input, outDir, ...rest] = process.argv;
|
|
27
|
+
if (!input || !outDir) {
|
|
28
|
+
console.error("usage: unpack.cjs <input.docx> <outDir> [--no-merge-runs]");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const mergeRuns = !rest.includes("--no-merge-runs");
|
|
32
|
+
|
|
33
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(input));
|
|
34
|
+
const manifest = [];
|
|
35
|
+
|
|
36
|
+
for (const name of Object.keys(zip.files)) {
|
|
37
|
+
const entry = zip.files[name];
|
|
38
|
+
const dest = path.join(outDir, name);
|
|
39
|
+
if (entry.dir) {
|
|
40
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
44
|
+
|
|
45
|
+
if (XML_PART.test(name)) {
|
|
46
|
+
const raw = await entry.async("string");
|
|
47
|
+
const tree = xmljs.xml2js(raw, { compact: false });
|
|
48
|
+
if (mergeRuns && STORY_PART.test(name)) mergeRunsTree(tree);
|
|
49
|
+
// spaces:2 indents element structure; xml-js keeps text-only elements
|
|
50
|
+
// (like <w:t>) inline, so significant whitespace is preserved.
|
|
51
|
+
fs.writeFileSync(dest, xmljs.js2xml(tree, { spaces: 2 }), "utf8");
|
|
52
|
+
} else {
|
|
53
|
+
fs.writeFileSync(dest, await entry.async("nodebuffer"));
|
|
54
|
+
}
|
|
55
|
+
manifest.push(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Record original part order so pack.cjs can rebuild a faithful archive.
|
|
59
|
+
fs.writeFileSync(
|
|
60
|
+
path.join(outDir, ".docx-manifest.json"),
|
|
61
|
+
JSON.stringify(manifest),
|
|
62
|
+
);
|
|
63
|
+
console.log(
|
|
64
|
+
`Unpacked ${manifest.length} parts to ${outDir}${mergeRuns ? " (adjacent runs merged)" : ""}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Recursively merge adjacent, identically-formatted simple runs in every element. */
|
|
69
|
+
function mergeRunsTree(node) {
|
|
70
|
+
if (!node.elements) return;
|
|
71
|
+
const merged = [];
|
|
72
|
+
for (const child of node.elements) {
|
|
73
|
+
const prev = merged[merged.length - 1];
|
|
74
|
+
if (prev && isSimpleRun(prev) && isSimpleRun(child) && sameRpr(prev, child)) {
|
|
75
|
+
appendRunText(prev, child);
|
|
76
|
+
} else {
|
|
77
|
+
merged.push(child);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
node.elements = merged;
|
|
81
|
+
for (const child of merged) mergeRunsTree(child);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A run we can safely merge: <w:r> whose only non-rPr child is a single <w:t>. */
|
|
85
|
+
function isSimpleRun(el) {
|
|
86
|
+
if (!el || el.type !== "element" || el.name !== "w:r") return false;
|
|
87
|
+
const nonRpr = (el.elements || []).filter((e) => e.name !== "w:rPr");
|
|
88
|
+
return nonRpr.length === 1 && nonRpr[0].name === "w:t";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rprOf(run) {
|
|
92
|
+
return (run.elements || []).find((e) => e.name === "w:rPr");
|
|
93
|
+
}
|
|
94
|
+
function wtOf(run) {
|
|
95
|
+
return (run.elements || []).find((e) => e.name === "w:t");
|
|
96
|
+
}
|
|
97
|
+
function sameRpr(a, b) {
|
|
98
|
+
const sa = rprOf(a) ? xmljs.js2xml({ elements: [rprOf(a)] }) : "";
|
|
99
|
+
const sb = rprOf(b) ? xmljs.js2xml({ elements: [rprOf(b)] }) : "";
|
|
100
|
+
return sa === sb;
|
|
101
|
+
}
|
|
102
|
+
function textOf(wt) {
|
|
103
|
+
return (wt.elements || [])
|
|
104
|
+
.filter((e) => e.type === "text")
|
|
105
|
+
.map((e) => e.text)
|
|
106
|
+
.join("");
|
|
107
|
+
}
|
|
108
|
+
function appendRunText(prev, next) {
|
|
109
|
+
const pt = wtOf(prev);
|
|
110
|
+
const combined = textOf(pt) + textOf(wtOf(next));
|
|
111
|
+
pt.elements = [{ type: "text", text: combined }];
|
|
112
|
+
// Leading/trailing whitespace is only kept by Word with xml:space="preserve".
|
|
113
|
+
if (/^\s|\s$/.test(combined)) {
|
|
114
|
+
pt.attributes = pt.attributes || {};
|
|
115
|
+
pt.attributes["xml:space"] = "preserve";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
console.error("unpack failed:", err.message);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|