ppt-template-reuse 0.4.0-rc.2
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/LICENSE +21 -0
- package/NOTICE.md +8 -0
- package/README.md +233 -0
- package/adapters/README.md +13 -0
- package/adapters/claude/.claude-plugin/plugin.json +16 -0
- package/adapters/claude/.mcp.json +8 -0
- package/adapters/claude/skills/learn-ppt-template/SKILL.md +105 -0
- package/adapters/kimi/kimi.plugin.json +12 -0
- package/adapters/kimi/skills/learn-ppt-template/SKILL.md +105 -0
- package/adapters/kimi-work/mcp.json +8 -0
- package/adapters/kimi-work/skills/learn-ppt-template/SKILL.md +105 -0
- package/adapters/workbuddy/mcp.json +8 -0
- package/adapters/workbuddy/skills/learn-ppt-template/SKILL.md +105 -0
- package/package.json +53 -0
- package/plugins/ppt-template-reuse/.codex-plugin/plugin.json +38 -0
- package/plugins/ppt-template-reuse/.mcp.json +9 -0
- package/plugins/ppt-template-reuse/skills/learn-ppt-template/SKILL.md +105 -0
- package/plugins/ppt-template-reuse/skills/learn-ppt-template/agents/openai.yaml +6 -0
- package/skills/ppt-template-reuse/SKILL.md +105 -0
- package/skills/ppt-template-reuse/agents/openai.yaml +6 -0
- package/src/cli/ppt-reuse.mjs +203 -0
- package/src/core/adaptive-planner.mjs +1192 -0
- package/src/core/content-audit.mjs +573 -0
- package/src/core/frame-map.mjs +91 -0
- package/src/core/index.mjs +38 -0
- package/src/core/io.mjs +81 -0
- package/src/core/legacy-capsule-import.mjs +382 -0
- package/src/core/ooxml-editor.mjs +753 -0
- package/src/core/paths.mjs +60 -0
- package/src/core/planner-lib.mjs +59 -0
- package/src/core/pptx-package.mjs +227 -0
- package/src/core/renderer.mjs +402 -0
- package/src/core/source-extractor.mjs +412 -0
- package/src/core/template-inspector.mjs +489 -0
- package/src/core/template-intelligence.mjs +675 -0
- package/src/core/universal-engine.mjs +1075 -0
- package/src/index.mjs +2 -0
- package/src/install/installer.mjs +293 -0
- package/src/mcp/server.mjs +377 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
import pixelmatch from "pixelmatch";
|
|
9
|
+
import { PNG } from "pngjs";
|
|
10
|
+
|
|
11
|
+
import { exists } from "./io.mjs";
|
|
12
|
+
import { extractSource } from "./source-extractor.mjs";
|
|
13
|
+
|
|
14
|
+
function executableCandidates() {
|
|
15
|
+
const candidates = [
|
|
16
|
+
"soffice",
|
|
17
|
+
"libreoffice",
|
|
18
|
+
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
|
19
|
+
];
|
|
20
|
+
for (const root of [
|
|
21
|
+
process.env.ProgramFiles,
|
|
22
|
+
process.env["ProgramFiles(x86)"],
|
|
23
|
+
].filter(Boolean)) {
|
|
24
|
+
candidates.push(path.join(root, "LibreOffice", "program", "soffice.exe"));
|
|
25
|
+
}
|
|
26
|
+
return candidates;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canRun(command) {
|
|
30
|
+
const result = spawnSync(command, ["--version"], {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
timeout: 10000,
|
|
33
|
+
});
|
|
34
|
+
return result.status === 0
|
|
35
|
+
? String(result.stdout || result.stderr || "").trim()
|
|
36
|
+
: null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function locateLibreOffice(explicit) {
|
|
40
|
+
for (const candidate of [explicit, ...executableCandidates()].filter(Boolean)) {
|
|
41
|
+
if (path.isAbsolute(candidate) && !(await exists(candidate))) continue;
|
|
42
|
+
const version = canRun(candidate);
|
|
43
|
+
if (version) return { command: candidate, version };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadCanvasRuntime() {
|
|
49
|
+
const canvas = await import("@napi-rs/canvas").catch(() => null);
|
|
50
|
+
if (!canvas) {
|
|
51
|
+
const error = new Error(
|
|
52
|
+
"The optional @napi-rs/canvas runtime is required for PDF page rendering",
|
|
53
|
+
);
|
|
54
|
+
error.code = "CANVAS_RUNTIME_REQUIRED";
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
for (const key of ["DOMMatrix", "ImageData", "Path2D"]) {
|
|
58
|
+
if (canvas[key] && !globalThis[key]) globalThis[key] = canvas[key];
|
|
59
|
+
}
|
|
60
|
+
return canvas;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sha256(bytes) {
|
|
64
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function renderPdfPages({
|
|
68
|
+
pdfPath,
|
|
69
|
+
outputDir,
|
|
70
|
+
dpi = 144,
|
|
71
|
+
prefix = "slide",
|
|
72
|
+
}) {
|
|
73
|
+
const canvasRuntime = await loadCanvasRuntime();
|
|
74
|
+
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
75
|
+
const data = new Uint8Array(await fs.readFile(pdfPath));
|
|
76
|
+
const document = await pdfjs.getDocument({
|
|
77
|
+
data,
|
|
78
|
+
disableFontFace: false,
|
|
79
|
+
isEvalSupported: false,
|
|
80
|
+
useSystemFonts: true,
|
|
81
|
+
verbosity: 0,
|
|
82
|
+
}).promise;
|
|
83
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
84
|
+
const pages = [];
|
|
85
|
+
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
|
|
86
|
+
const page = await document.getPage(pageNumber);
|
|
87
|
+
const viewport = page.getViewport({ scale: dpi / 72 });
|
|
88
|
+
const canvas = canvasRuntime.createCanvas(
|
|
89
|
+
Math.ceil(viewport.width),
|
|
90
|
+
Math.ceil(viewport.height),
|
|
91
|
+
);
|
|
92
|
+
const context = canvas.getContext("2d");
|
|
93
|
+
await page.render({ canvasContext: context, viewport }).promise;
|
|
94
|
+
const bytes = canvas.toBuffer("image/png");
|
|
95
|
+
const output = path.join(
|
|
96
|
+
outputDir,
|
|
97
|
+
`${prefix}-${String(pageNumber).padStart(2, "0")}.png`,
|
|
98
|
+
);
|
|
99
|
+
await fs.writeFile(output, bytes);
|
|
100
|
+
pages.push({
|
|
101
|
+
page: pageNumber,
|
|
102
|
+
path: output,
|
|
103
|
+
width: canvas.width,
|
|
104
|
+
height: canvas.height,
|
|
105
|
+
sha256: sha256(bytes),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return pages;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function createMontage({ pages, outputPath, columns = 4 }) {
|
|
112
|
+
if (!pages.length) throw new Error("Cannot create a montage without pages");
|
|
113
|
+
const canvasRuntime = await loadCanvasRuntime();
|
|
114
|
+
const images = await Promise.all(
|
|
115
|
+
pages.map((page) => canvasRuntime.loadImage(page.path)),
|
|
116
|
+
);
|
|
117
|
+
const thumbWidth = 360;
|
|
118
|
+
const ratio = images[0].height / images[0].width;
|
|
119
|
+
const thumbHeight = Math.round(thumbWidth * ratio);
|
|
120
|
+
const gap = 16;
|
|
121
|
+
const rows = Math.ceil(images.length / columns);
|
|
122
|
+
const canvas = canvasRuntime.createCanvas(
|
|
123
|
+
columns * thumbWidth + (columns + 1) * gap,
|
|
124
|
+
rows * thumbHeight + (rows + 1) * gap,
|
|
125
|
+
);
|
|
126
|
+
const context = canvas.getContext("2d");
|
|
127
|
+
context.fillStyle = "#e5e7eb";
|
|
128
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
129
|
+
images.forEach((image, index) => {
|
|
130
|
+
const column = index % columns;
|
|
131
|
+
const row = Math.floor(index / columns);
|
|
132
|
+
const x = gap + column * (thumbWidth + gap);
|
|
133
|
+
const y = gap + row * (thumbHeight + gap);
|
|
134
|
+
context.drawImage(image, x, y, thumbWidth, thumbHeight);
|
|
135
|
+
});
|
|
136
|
+
const bytes = canvas.toBuffer("image/png");
|
|
137
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
138
|
+
await fs.writeFile(outputPath, bytes);
|
|
139
|
+
return { path: outputPath, sha256: sha256(bytes) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function renderPptx({
|
|
143
|
+
pptxPath,
|
|
144
|
+
workspace,
|
|
145
|
+
libreOffice,
|
|
146
|
+
dpi = 144,
|
|
147
|
+
}) {
|
|
148
|
+
const located = await locateLibreOffice(libreOffice);
|
|
149
|
+
if (!located) {
|
|
150
|
+
const error = new Error(
|
|
151
|
+
"LibreOffice is required before a final PPTX can be visually validated",
|
|
152
|
+
);
|
|
153
|
+
error.code = "RENDERER_REQUIRED";
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
const root = path.resolve(workspace);
|
|
157
|
+
const pdfDir = path.join(root, "pdf");
|
|
158
|
+
const pageDir = path.join(root, "pages");
|
|
159
|
+
const profileDir = path.join(root, "libreoffice-profile");
|
|
160
|
+
await fs.mkdir(pdfDir, { recursive: true });
|
|
161
|
+
await fs.mkdir(profileDir, { recursive: true });
|
|
162
|
+
const result = spawnSync(
|
|
163
|
+
located.command,
|
|
164
|
+
[
|
|
165
|
+
"--headless",
|
|
166
|
+
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
|
167
|
+
"--convert-to",
|
|
168
|
+
"pdf",
|
|
169
|
+
"--outdir",
|
|
170
|
+
pdfDir,
|
|
171
|
+
path.resolve(pptxPath),
|
|
172
|
+
],
|
|
173
|
+
{
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
176
|
+
timeout: 180000,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
if (result.status !== 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
String(result.stderr || result.stdout || "LibreOffice conversion failed"),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const pdfPath = path.join(
|
|
185
|
+
pdfDir,
|
|
186
|
+
`${path.basename(pptxPath, path.extname(pptxPath))}.pdf`,
|
|
187
|
+
);
|
|
188
|
+
if (!(await exists(pdfPath))) {
|
|
189
|
+
throw new Error(`LibreOffice did not create ${pdfPath}`);
|
|
190
|
+
}
|
|
191
|
+
const pages = await renderPdfPages({ pdfPath, outputDir: pageDir, dpi });
|
|
192
|
+
const montage = await createMontage({
|
|
193
|
+
pages,
|
|
194
|
+
outputPath: path.join(root, "contact-sheet.png"),
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
renderer: located,
|
|
198
|
+
pdfPath,
|
|
199
|
+
pages,
|
|
200
|
+
montage,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function comparePng({
|
|
205
|
+
expectedPath,
|
|
206
|
+
actualPath,
|
|
207
|
+
diffPath,
|
|
208
|
+
threshold = 0.1,
|
|
209
|
+
}) {
|
|
210
|
+
const expected = PNG.sync.read(await fs.readFile(expectedPath));
|
|
211
|
+
const actual = PNG.sync.read(await fs.readFile(actualPath));
|
|
212
|
+
if (expected.width !== actual.width || expected.height !== actual.height) {
|
|
213
|
+
return {
|
|
214
|
+
passed: false,
|
|
215
|
+
reason: "dimension-mismatch",
|
|
216
|
+
expected: { width: expected.width, height: expected.height },
|
|
217
|
+
actual: { width: actual.width, height: actual.height },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const diff = new PNG({ width: expected.width, height: expected.height });
|
|
221
|
+
const changedPixels = pixelmatch(
|
|
222
|
+
expected.data,
|
|
223
|
+
actual.data,
|
|
224
|
+
diff.data,
|
|
225
|
+
expected.width,
|
|
226
|
+
expected.height,
|
|
227
|
+
{ threshold },
|
|
228
|
+
);
|
|
229
|
+
if (diffPath) await fs.writeFile(diffPath, PNG.sync.write(diff));
|
|
230
|
+
const ratio = changedPixels / (expected.width * expected.height);
|
|
231
|
+
return {
|
|
232
|
+
passed: ratio <= 0.002,
|
|
233
|
+
changedPixels,
|
|
234
|
+
changedPixelRatio: ratio,
|
|
235
|
+
diffPath,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function compareProtectedPng({
|
|
240
|
+
expectedPath,
|
|
241
|
+
actualPath,
|
|
242
|
+
editableBoxes = [],
|
|
243
|
+
canvas,
|
|
244
|
+
diffPath,
|
|
245
|
+
threshold = 0.1,
|
|
246
|
+
maxChangedPixelRatio = 0.002,
|
|
247
|
+
}) {
|
|
248
|
+
const expected = PNG.sync.read(await fs.readFile(expectedPath));
|
|
249
|
+
const actual = PNG.sync.read(await fs.readFile(actualPath));
|
|
250
|
+
if (expected.width !== actual.width || expected.height !== actual.height) {
|
|
251
|
+
return {
|
|
252
|
+
passed: false,
|
|
253
|
+
reason: "dimension-mismatch",
|
|
254
|
+
expected: { width: expected.width, height: expected.height },
|
|
255
|
+
actual: { width: actual.width, height: actual.height },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const compared = PNG.sync.read(PNG.sync.write(actual));
|
|
259
|
+
const editable = new Uint8Array(expected.width * expected.height);
|
|
260
|
+
const scaleX = expected.width / Number(canvas?.width || 960);
|
|
261
|
+
const scaleY = expected.height / Number(canvas?.height || 540);
|
|
262
|
+
for (const box of editableBoxes) {
|
|
263
|
+
const padding = 3;
|
|
264
|
+
const x0 = Math.max(0, Math.floor(Number(box.x || 0) * scaleX) - padding);
|
|
265
|
+
const y0 = Math.max(0, Math.floor(Number(box.y || 0) * scaleY) - padding);
|
|
266
|
+
const x1 = Math.min(
|
|
267
|
+
expected.width,
|
|
268
|
+
Math.ceil((Number(box.x || 0) + Number(box.width || 0)) * scaleX) +
|
|
269
|
+
padding,
|
|
270
|
+
);
|
|
271
|
+
const y1 = Math.min(
|
|
272
|
+
expected.height,
|
|
273
|
+
Math.ceil((Number(box.y || 0) + Number(box.height || 0)) * scaleY) +
|
|
274
|
+
padding,
|
|
275
|
+
);
|
|
276
|
+
for (let y = y0; y < y1; y += 1) {
|
|
277
|
+
for (let x = x0; x < x1; x += 1) {
|
|
278
|
+
const pixel = y * expected.width + x;
|
|
279
|
+
editable[pixel] = 1;
|
|
280
|
+
const offset = pixel * 4;
|
|
281
|
+
compared.data[offset] = expected.data[offset];
|
|
282
|
+
compared.data[offset + 1] = expected.data[offset + 1];
|
|
283
|
+
compared.data[offset + 2] = expected.data[offset + 2];
|
|
284
|
+
compared.data[offset + 3] = expected.data[offset + 3];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const diff = new PNG({ width: expected.width, height: expected.height });
|
|
289
|
+
const rawChanged = pixelmatch(
|
|
290
|
+
expected.data,
|
|
291
|
+
compared.data,
|
|
292
|
+
diff.data,
|
|
293
|
+
expected.width,
|
|
294
|
+
expected.height,
|
|
295
|
+
{ threshold },
|
|
296
|
+
);
|
|
297
|
+
const protectedPixels =
|
|
298
|
+
expected.width * expected.height -
|
|
299
|
+
editable.reduce((sum, value) => sum + value, 0);
|
|
300
|
+
const ratio = rawChanged / Math.max(1, protectedPixels);
|
|
301
|
+
if (diffPath) {
|
|
302
|
+
await fs.mkdir(path.dirname(diffPath), { recursive: true });
|
|
303
|
+
await fs.writeFile(diffPath, PNG.sync.write(diff));
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
passed: ratio <= maxChangedPixelRatio,
|
|
307
|
+
changedPixels: rawChanged,
|
|
308
|
+
protectedPixels,
|
|
309
|
+
changedPixelRatio: ratio,
|
|
310
|
+
editableBoxCount: editableBoxes.length,
|
|
311
|
+
diffPath,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function comparableText(value) {
|
|
316
|
+
return String(value || "")
|
|
317
|
+
.normalize("NFKC")
|
|
318
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ngrams(value, size = 2) {
|
|
322
|
+
const characters = [...comparableText(value)];
|
|
323
|
+
const result = new Set();
|
|
324
|
+
for (let index = 0; index <= characters.length - size; index += 1) {
|
|
325
|
+
result.add(characters.slice(index, index + size).join(""));
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function unitsByPage(extraction) {
|
|
331
|
+
const result = new Map();
|
|
332
|
+
for (const unit of extraction.units || []) {
|
|
333
|
+
const page = Number(
|
|
334
|
+
unit.sourcePage || unit.page || unit.metadata?.page || 0,
|
|
335
|
+
);
|
|
336
|
+
if (!page) continue;
|
|
337
|
+
result.set(
|
|
338
|
+
page,
|
|
339
|
+
[result.get(page), unit.text].filter(Boolean).join("\n"),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function compareRenderedTextFidelity({
|
|
346
|
+
pptxPath,
|
|
347
|
+
pdfPath,
|
|
348
|
+
minimumBigramRecall = 0.72,
|
|
349
|
+
}) {
|
|
350
|
+
const [pptx, pdf] = await Promise.all([
|
|
351
|
+
extractSource(pptxPath),
|
|
352
|
+
extractSource(pdfPath),
|
|
353
|
+
]);
|
|
354
|
+
const expectedByPage = unitsByPage(pptx);
|
|
355
|
+
const actualByPage = unitsByPage(pdf);
|
|
356
|
+
const pages = [];
|
|
357
|
+
for (const [page, expectedText] of expectedByPage) {
|
|
358
|
+
const expected = comparableText(expectedText);
|
|
359
|
+
if ([...expected].length < 10) continue;
|
|
360
|
+
const actual = comparableText(actualByPage.get(page) || "");
|
|
361
|
+
const expectedNgrams = ngrams(expected);
|
|
362
|
+
const actualNgrams = ngrams(actual);
|
|
363
|
+
const matched = [...expectedNgrams].filter((item) =>
|
|
364
|
+
actualNgrams.has(item),
|
|
365
|
+
).length;
|
|
366
|
+
const bigramRecall =
|
|
367
|
+
expectedNgrams.size > 0 ? matched / expectedNgrams.size : 1;
|
|
368
|
+
pages.push({
|
|
369
|
+
page,
|
|
370
|
+
expectedCharacters: [...expected].length,
|
|
371
|
+
renderedCharacters: [...actual].length,
|
|
372
|
+
expectedBigramCount: expectedNgrams.size,
|
|
373
|
+
matchedBigramCount: matched,
|
|
374
|
+
bigramRecall,
|
|
375
|
+
passed: bigramRecall + Number.EPSILON >= minimumBigramRecall,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const failures = pages.filter((page) => !page.passed);
|
|
379
|
+
return {
|
|
380
|
+
passed: failures.length === 0,
|
|
381
|
+
minimumBigramRecall,
|
|
382
|
+
pages,
|
|
383
|
+
failures,
|
|
384
|
+
requiresManualOfficeReview: failures.length > 0,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function rendererDoctor(options = {}) {
|
|
389
|
+
const libreOffice = await locateLibreOffice(options.libreOffice);
|
|
390
|
+
const canvas = await import("@napi-rs/canvas")
|
|
391
|
+
.then(() => true)
|
|
392
|
+
.catch(() => false);
|
|
393
|
+
return {
|
|
394
|
+
platform: process.platform,
|
|
395
|
+
architecture: process.arch,
|
|
396
|
+
node: process.version,
|
|
397
|
+
libreOffice,
|
|
398
|
+
canvas,
|
|
399
|
+
finalRenderingAvailable: Boolean(libreOffice && canvas),
|
|
400
|
+
temporaryDirectory: os.tmpdir(),
|
|
401
|
+
};
|
|
402
|
+
}
|