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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +8 -0
  3. package/README.md +233 -0
  4. package/adapters/README.md +13 -0
  5. package/adapters/claude/.claude-plugin/plugin.json +16 -0
  6. package/adapters/claude/.mcp.json +8 -0
  7. package/adapters/claude/skills/learn-ppt-template/SKILL.md +105 -0
  8. package/adapters/kimi/kimi.plugin.json +12 -0
  9. package/adapters/kimi/skills/learn-ppt-template/SKILL.md +105 -0
  10. package/adapters/kimi-work/mcp.json +8 -0
  11. package/adapters/kimi-work/skills/learn-ppt-template/SKILL.md +105 -0
  12. package/adapters/workbuddy/mcp.json +8 -0
  13. package/adapters/workbuddy/skills/learn-ppt-template/SKILL.md +105 -0
  14. package/package.json +53 -0
  15. package/plugins/ppt-template-reuse/.codex-plugin/plugin.json +38 -0
  16. package/plugins/ppt-template-reuse/.mcp.json +9 -0
  17. package/plugins/ppt-template-reuse/skills/learn-ppt-template/SKILL.md +105 -0
  18. package/plugins/ppt-template-reuse/skills/learn-ppt-template/agents/openai.yaml +6 -0
  19. package/skills/ppt-template-reuse/SKILL.md +105 -0
  20. package/skills/ppt-template-reuse/agents/openai.yaml +6 -0
  21. package/src/cli/ppt-reuse.mjs +203 -0
  22. package/src/core/adaptive-planner.mjs +1192 -0
  23. package/src/core/content-audit.mjs +573 -0
  24. package/src/core/frame-map.mjs +91 -0
  25. package/src/core/index.mjs +38 -0
  26. package/src/core/io.mjs +81 -0
  27. package/src/core/legacy-capsule-import.mjs +382 -0
  28. package/src/core/ooxml-editor.mjs +753 -0
  29. package/src/core/paths.mjs +60 -0
  30. package/src/core/planner-lib.mjs +59 -0
  31. package/src/core/pptx-package.mjs +227 -0
  32. package/src/core/renderer.mjs +402 -0
  33. package/src/core/source-extractor.mjs +412 -0
  34. package/src/core/template-inspector.mjs +489 -0
  35. package/src/core/template-intelligence.mjs +675 -0
  36. package/src/core/universal-engine.mjs +1075 -0
  37. package/src/index.mjs +2 -0
  38. package/src/install/installer.mjs +293 -0
  39. package/src/mcp/server.mjs +377 -0
@@ -0,0 +1,1075 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+
6
+ import { buildAdaptiveContentPlan } from "./adaptive-planner.mjs";
7
+ import { auditContentPlan } from "./content-audit.mjs";
8
+ import { buildFrameMap } from "./frame-map.mjs";
9
+ import {
10
+ assertAllowedPath,
11
+ exists,
12
+ fingerprint,
13
+ readJson,
14
+ sha256File,
15
+ writeJsonAtomic,
16
+ } from "./io.mjs";
17
+ import { editPptxNative, inspectPptxPackage } from "./ooxml-editor.mjs";
18
+ import {
19
+ resolveEngineHome,
20
+ resolveLibraryDir,
21
+ resolveSessionsDir,
22
+ } from "./paths.mjs";
23
+ import {
24
+ compareProtectedPng,
25
+ compareRenderedTextFidelity,
26
+ renderPdfPages,
27
+ renderPptx,
28
+ rendererDoctor,
29
+ } from "./renderer.mjs";
30
+ import { extractSource } from "./source-extractor.mjs";
31
+ import { inspectTemplate } from "./template-inspector.mjs";
32
+ import { loadPptx, parseXml, slideRecords, textOf, xmlFrom } from "./pptx-package.mjs";
33
+
34
+ export const engineVersion = "0.4.0-rc.2";
35
+
36
+ const jobs = new Map();
37
+ const PLACEHOLDER =
38
+ /(插入图片|相关图片|点击添加|请在此|占位|type here|add title|add text|replace image|sample text)/i;
39
+ const VALID_KINDS = new Set([
40
+ "background",
41
+ "fact",
42
+ "timeline",
43
+ "issue",
44
+ "rule",
45
+ "theory",
46
+ "method",
47
+ "evidence",
48
+ "data",
49
+ "application",
50
+ "analysis",
51
+ "counterargument",
52
+ "rebuttal",
53
+ "conclusion",
54
+ "recommendation",
55
+ ]);
56
+
57
+ function sessionId() {
58
+ return `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${crypto
59
+ .randomBytes(6)
60
+ .toString("hex")}`;
61
+ }
62
+
63
+ function normalizeRoots(roots = []) {
64
+ return [...new Set([process.cwd(), os.tmpdir(), ...roots].map((item) => path.resolve(item)))];
65
+ }
66
+
67
+ function sessionDir(id, home) {
68
+ if (!/^[a-zA-Z0-9._-]+$/.test(String(id))) throw new Error("Invalid session id");
69
+ return path.join(resolveSessionsDir(home), String(id));
70
+ }
71
+
72
+ function statusPath(id, home) {
73
+ return path.join(sessionDir(id, home), "session.json");
74
+ }
75
+
76
+ async function loadSession(id, home) {
77
+ const candidate = statusPath(id, home);
78
+ if (!(await exists(candidate))) throw new Error(`Unknown session: ${id}`);
79
+ return readJson(candidate);
80
+ }
81
+
82
+ async function saveSession(session, home) {
83
+ session.updatedAt = new Date().toISOString();
84
+ session.sessionFingerprint = fingerprint({
85
+ id: session.id,
86
+ state: session.state,
87
+ input: session.input,
88
+ artifacts: session.artifacts,
89
+ warnings: session.warnings,
90
+ });
91
+ await writeJsonAtomic(statusPath(session.id, home), session);
92
+ return session;
93
+ }
94
+
95
+ async function failSession(session, error, home) {
96
+ session.state = "failed";
97
+ session.error = {
98
+ code: error.code || "ENGINE_ERROR",
99
+ message: error.message || String(error),
100
+ };
101
+ await saveSession(session, home);
102
+ }
103
+
104
+ function action(type, instructions, extra = {}) {
105
+ return { type, instructions, ...extra };
106
+ }
107
+
108
+ function durationSeconds(requirements = {}) {
109
+ if (requirements.durationSeconds) return requirements.durationSeconds;
110
+ const value = String(requirements.duration || "").trim();
111
+ const match = value.match(/^(\d+)(?:\s*[-–]\s*(\d+))?$/);
112
+ if (!match) return {};
113
+ return {
114
+ min: Number(match[1]) * 60,
115
+ max: Number(match[2] || match[1]) * 60,
116
+ };
117
+ }
118
+
119
+ function semanticKind(value) {
120
+ const normalized = String(value || "analysis").trim().toLowerCase();
121
+ const aliases = {
122
+ chronology: "timeline",
123
+ sequence: "timeline",
124
+ argument: "analysis",
125
+ claim: "analysis",
126
+ recommendation: "recommendation",
127
+ };
128
+ return VALID_KINDS.has(normalized)
129
+ ? normalized
130
+ : aliases[normalized] || "analysis";
131
+ }
132
+
133
+ function validateSourceSemantics(extraction, semantics, requirements) {
134
+ const supplied = Array.isArray(semantics?.sourceUnits)
135
+ ? semantics.sourceUnits
136
+ : Array.isArray(semantics)
137
+ ? semantics
138
+ : [];
139
+ const byId = new Map(supplied.map((unit) => [String(unit.id), unit]));
140
+ const expected = new Set(extraction.units.map((unit) => unit.id));
141
+ const extras = [...byId.keys()].filter((id) => !expected.has(id));
142
+ const missing = [...expected].filter((id) => !byId.has(id));
143
+ if (extras.length || missing.length) {
144
+ const error = new Error(
145
+ `Source semantics must preserve every extracted unit exactly; missing=[${missing.join(
146
+ ", ",
147
+ )}], extra=[${extras.join(", ")}]`,
148
+ );
149
+ error.code = "SOURCE_ANCHOR_MISMATCH";
150
+ throw error;
151
+ }
152
+ const sourceUnits = extraction.units.map((raw) => {
153
+ const next = byId.get(raw.id);
154
+ if (next.anchor && next.anchor !== raw.anchor) {
155
+ const error = new Error(`Anchor changed for ${raw.id}`);
156
+ error.code = "SOURCE_ANCHOR_MISMATCH";
157
+ throw error;
158
+ }
159
+ const priority = String(next.priority || "supporting").toLowerCase();
160
+ const presentationRelevance = String(
161
+ next.presentationRelevance || (priority === "optional" ? "appendix" : "core"),
162
+ ).toLowerCase();
163
+ if (!["critical", "supporting", "optional"].includes(priority)) {
164
+ throw new Error(`Invalid priority for ${raw.id}: ${priority}`);
165
+ }
166
+ if (!["core", "appendix", "exclude"].includes(presentationRelevance)) {
167
+ throw new Error(
168
+ `Invalid presentationRelevance for ${raw.id}: ${presentationRelevance}`,
169
+ );
170
+ }
171
+ if (presentationRelevance === "exclude" && priority === "critical") {
172
+ throw new Error(`Critical source unit ${raw.id} cannot be excluded`);
173
+ }
174
+ if (
175
+ presentationRelevance === "exclude" &&
176
+ !String(next.exclusionReason || "").trim()
177
+ ) {
178
+ throw new Error(
179
+ `Excluded source unit ${raw.id} requires an explicit exclusionReason`,
180
+ );
181
+ }
182
+ const summary = String(next.summary || "").trim();
183
+ const detail = String(next.detail || raw.detail || raw.text).trim();
184
+ const claim = String(next.claim || "").trim();
185
+ const evidence = Array.isArray(next.evidence)
186
+ ? next.evidence.map(String).map((value) => value.trim()).filter(Boolean)
187
+ : next.evidence
188
+ ? [String(next.evidence).trim()]
189
+ : [];
190
+ const whyItMatters = String(next.whyItMatters || "").trim();
191
+ const displayFragments = Array.isArray(next.displayFragments)
192
+ ? next.displayFragments
193
+ .map((fragment) =>
194
+ typeof fragment === "string"
195
+ ? { role: "label", text: fragment.trim() }
196
+ : {
197
+ role: String(fragment?.role || "label")
198
+ .trim()
199
+ .toLowerCase(),
200
+ text: String(fragment?.text || "").trim(),
201
+ },
202
+ )
203
+ .filter((fragment) => fragment.text)
204
+ : [];
205
+ const requiredFields = [
206
+ ["summary", summary],
207
+ ["claim", claim],
208
+ ["evidence", evidence.join("")],
209
+ ["whyItMatters", whyItMatters],
210
+ ].filter(([, value]) => !value);
211
+ if (requiredFields.length) {
212
+ const error = new Error(
213
+ `Source unit ${raw.id} is missing required semantic fields: ${requiredFields
214
+ .map(([field]) => field)
215
+ .join(", ")}`,
216
+ );
217
+ error.code = "SOURCE_SEMANTICS_INCOMPLETE";
218
+ throw error;
219
+ }
220
+ const limits = [
221
+ ["summary", summary, 100],
222
+ ["claim", claim, 90],
223
+ ["whyItMatters", whyItMatters, 120],
224
+ ...evidence.map((value, index) => [
225
+ `evidence[${index}]`,
226
+ value,
227
+ 140,
228
+ ]),
229
+ ...displayFragments.map((fragment, index) => [
230
+ `displayFragments[${index}].text`,
231
+ fragment.text,
232
+ 80,
233
+ ]),
234
+ ];
235
+ const overlong = limits.filter(([, value, maximum]) => {
236
+ return [...String(value).replace(/\s+/gu, "")].length > maximum;
237
+ });
238
+ if (overlong.length) {
239
+ const error = new Error(
240
+ `Source unit ${raw.id} has semantic fields that exceed safe visible capacity: ${overlong
241
+ .map(([field, , maximum]) => `${field}>${maximum}`)
242
+ .join(", ")}`,
243
+ );
244
+ error.code = "SOURCE_SEMANTIC_CAPACITY_INVALID";
245
+ throw error;
246
+ }
247
+ return {
248
+ ...raw,
249
+ ...next,
250
+ id: raw.id,
251
+ anchor: raw.anchor,
252
+ parentAnchor: raw.parentAnchor,
253
+ segmentIndex: raw.segmentIndex,
254
+ segmentCount: raw.segmentCount,
255
+ text: raw.text,
256
+ kind: semanticKind(next.kind),
257
+ priority,
258
+ presentationRelevance,
259
+ summary,
260
+ detail,
261
+ claim,
262
+ evidence,
263
+ whyItMatters,
264
+ displayFragments,
265
+ };
266
+ });
267
+ return {
268
+ schemaVersion: 2,
269
+ presentationBrief: {
270
+ title: requirements.title || semantics.presentationBrief?.title || "演示文稿",
271
+ subtitle: requirements.subtitle || semantics.presentationBrief?.subtitle || "",
272
+ audience: requirements.audience || semantics.presentationBrief?.audience || "",
273
+ communicationJob:
274
+ requirements.communicationJob ||
275
+ semantics.presentationBrief?.communicationJob ||
276
+ "完整解释材料中的事实、依据、分析和结论",
277
+ useCase:
278
+ requirements.useCase ||
279
+ semantics.presentationBrief?.useCase ||
280
+ "general-report",
281
+ density: requirements.density || semantics.presentationBrief?.density || "rich",
282
+ scope:
283
+ requirements.scope ||
284
+ semantics.presentationBrief?.scope ||
285
+ "core-and-appendix",
286
+ durationSeconds: durationSeconds(requirements),
287
+ author: requirements.author || semantics.presentationBrief?.author || "",
288
+ organization:
289
+ requirements.organization || semantics.presentationBrief?.organization || "",
290
+ date: requirements.date || semantics.presentationBrief?.date || "",
291
+ },
292
+ sourceUnits,
293
+ };
294
+ }
295
+
296
+ function validateTemplateSemantics(inspection, submitted) {
297
+ if (!submitted) return inspection;
298
+ if (
299
+ submitted.source?.sha256 &&
300
+ submitted.source.sha256 !== inspection.source.sha256
301
+ ) {
302
+ throw new Error("Template semantics were produced for a different file");
303
+ }
304
+ const expectedSlides = new Set(
305
+ inspection.slides.map((slide) => Number(slide.sourceSlide)),
306
+ );
307
+ const slides = submitted.slides || inspection.slides;
308
+ const extras = slides.filter(
309
+ (slide) => !expectedSlides.has(Number(slide.sourceSlide)),
310
+ );
311
+ if (extras.length) throw new Error("Template semantics contain unknown slides");
312
+ const bySlide = new Map(
313
+ slides.map((slide) => [Number(slide.sourceSlide), slide]),
314
+ );
315
+ const merged = {
316
+ ...inspection,
317
+ ...submitted,
318
+ source: inspection.source,
319
+ slides: inspection.slides.map((slide) => ({
320
+ ...slide,
321
+ ...(bySlide.get(Number(slide.sourceSlide)) || {}),
322
+ sourceSlide: slide.sourceSlide,
323
+ sourcePart: slide.sourcePart,
324
+ slotSchema:
325
+ bySlide.get(Number(slide.sourceSlide))?.slotSchema || slide.slotSchema,
326
+ })),
327
+ };
328
+ const unsafe = merged.slides.filter(
329
+ (slide) =>
330
+ slide.safetyStatus === "automatic" &&
331
+ !(slide.slotSchema || []).some((slot) => slot.purpose === "title"),
332
+ );
333
+ if (unsafe.length) {
334
+ throw new Error(
335
+ `Automatic slides require an explicit title slot: ${unsafe
336
+ .map((slide) => slide.sourceSlide)
337
+ .join(", ")}`,
338
+ );
339
+ }
340
+ merged.reviewSummary = {
341
+ automaticSlides: merged.slides
342
+ .filter((slide) => slide.safetyStatus === "automatic")
343
+ .map((slide) => slide.sourceSlide),
344
+ reviewSlides: merged.slides
345
+ .filter((slide) => slide.safetyStatus === "review")
346
+ .map((slide) => slide.sourceSlide),
347
+ blockedSlides: merged.slides
348
+ .filter((slide) => slide.safetyStatus === "blocked")
349
+ .map((slide) => slide.sourceSlide),
350
+ };
351
+ const unresolvedVariants = Object.fromEntries(
352
+ Object.entries(inspection.variantSelections || {}).filter(
353
+ ([, sourceSlides]) =>
354
+ merged.slides.filter(
355
+ (slide) =>
356
+ sourceSlides.map(Number).includes(Number(slide.sourceSlide)) &&
357
+ slide.safetyStatus === "automatic",
358
+ ).length !== 1,
359
+ ),
360
+ );
361
+ merged.profileFingerprint = fingerprint({
362
+ source: merged.source,
363
+ slides: merged.slides,
364
+ purposeProfile: merged.purposeProfile,
365
+ });
366
+ merged.actionRequired =
367
+ merged.reviewSummary.reviewSlides.length ||
368
+ Object.keys(unresolvedVariants).length
369
+ ? action(
370
+ "template-semantics",
371
+ [
372
+ "Review or block every unresolved template slide before planning.",
373
+ "For every opening or closing variant group, approve exactly one source slide and block the alternatives.",
374
+ ],
375
+ {
376
+ slides: merged.reviewSummary.reviewSlides,
377
+ variantSelections: unresolvedVariants,
378
+ },
379
+ )
380
+ : null;
381
+ return merged;
382
+ }
383
+
384
+ function requiredAssets(contentPlan) {
385
+ const missing = [];
386
+ for (const slide of contentPlan.slides || []) {
387
+ for (const [slotId, value] of Object.entries(slide.fields || {})) {
388
+ if (
389
+ value &&
390
+ typeof value === "object" &&
391
+ ["image", "icon"].includes(String(value.type || "").toLowerCase()) &&
392
+ !value.path
393
+ ) {
394
+ missing.push({ outputSlide: slide.outputSlide, slotId, type: value.type });
395
+ }
396
+ }
397
+ }
398
+ return missing;
399
+ }
400
+
401
+ async function allSlideText(pptxPath) {
402
+ const zip = await loadPptx(pptxPath);
403
+ const order = await slideRecords(zip);
404
+ const slides = [];
405
+ for (const slide of order.slides) {
406
+ const document = parseXml(await xmlFrom(zip, slide.partName));
407
+ slides.push({ slide: slide.slideNumber, text: textOf(document) });
408
+ }
409
+ return slides;
410
+ }
411
+
412
+ export async function createSession(input, options = {}) {
413
+ const home = resolveEngineHome(options.home);
414
+ const roots = normalizeRoots(input.roots || options.roots || []);
415
+ const template = assertAllowedPath(input.template, roots);
416
+ const source = assertAllowedPath(input.source, roots);
417
+ const output = assertAllowedPath(input.output, roots);
418
+ if (!(await exists(template))) throw new Error(`Template not found: ${template}`);
419
+ if (!(await exists(source))) throw new Error(`Source not found: ${source}`);
420
+ const id = sessionId();
421
+ const directory = sessionDir(id, home);
422
+ await fs.mkdir(directory, { recursive: true });
423
+ const session = {
424
+ schemaVersion: 1,
425
+ engineVersion,
426
+ id,
427
+ createdAt: new Date().toISOString(),
428
+ updatedAt: new Date().toISOString(),
429
+ state: "created",
430
+ input: {
431
+ template,
432
+ source,
433
+ output,
434
+ roots,
435
+ requirements: input.requirements || {},
436
+ templateSha256: await sha256File(template),
437
+ sourceSha256: await sha256File(source),
438
+ },
439
+ artifacts: {},
440
+ warnings: [],
441
+ events: [],
442
+ };
443
+ await saveSession(session, home);
444
+ return session;
445
+ }
446
+
447
+ export async function inspectSession(id, options = {}) {
448
+ const home = resolveEngineHome(options.home);
449
+ const session = await loadSession(id, home);
450
+ try {
451
+ const capsuleDirectory = path.join(
452
+ resolveLibraryDir(home),
453
+ session.input.templateSha256,
454
+ );
455
+ const capsuleProfilePath = path.join(
456
+ capsuleDirectory,
457
+ "template-profile.lock.json",
458
+ );
459
+ const cachedProfile = (await exists(capsuleProfilePath))
460
+ ? await readJson(capsuleProfilePath)
461
+ : null;
462
+ const capsuleReused =
463
+ cachedProfile?.source?.sha256 === session.input.templateSha256;
464
+ const templateProfile = capsuleReused
465
+ ? cachedProfile
466
+ : await inspectTemplate(session.input.template);
467
+ const sourceExtraction = await extractSource(session.input.source);
468
+ const directory = sessionDir(id, home);
469
+ let templateRendering;
470
+ try {
471
+ templateRendering = await renderPptx({
472
+ pptxPath: session.input.template,
473
+ workspace: path.join(directory, "template-preview"),
474
+ libreOffice: options.libreOffice,
475
+ });
476
+ session.artifacts.templatePreviews = templateRendering.pages.map(
477
+ (page) => page.path,
478
+ );
479
+ session.artifacts.templateMontage = templateRendering.montage.path;
480
+ } catch (error) {
481
+ session.warnings.push(
482
+ `Template preview rendering is unavailable: ${error.message}`,
483
+ );
484
+ }
485
+ if (sourceExtraction.scannedPages?.length) {
486
+ try {
487
+ const rendered = await renderPdfPages({
488
+ pdfPath: session.input.source,
489
+ outputDir: path.join(directory, "scanned-source-pages"),
490
+ prefix: "source-page",
491
+ });
492
+ sourceExtraction.scannedPageImages = rendered.filter((page) =>
493
+ sourceExtraction.scannedPages.includes(page.page),
494
+ );
495
+ sourceExtraction.actionRequired.scannedPageImages =
496
+ sourceExtraction.scannedPageImages;
497
+ session.artifacts.scannedSourcePages =
498
+ sourceExtraction.scannedPageImages.map((page) => page.path);
499
+ } catch (error) {
500
+ session.warnings.push(
501
+ `Scanned PDF pages require host visual review, but page rendering failed: ${error.message}`,
502
+ );
503
+ }
504
+ }
505
+ const templatePath = path.join(directory, "template-profile.auto.json");
506
+ const sourcePath = path.join(directory, "source-extraction.json");
507
+ await writeJsonAtomic(templatePath, templateProfile);
508
+ await writeJsonAtomic(sourcePath, sourceExtraction);
509
+ session.artifacts.templateInspection = templatePath;
510
+ session.artifacts.sourceExtraction = sourcePath;
511
+ const actions = [];
512
+ if (templateProfile.actionRequired) {
513
+ actions.push({
514
+ ...templateProfile.actionRequired,
515
+ previewResources: session.artifacts.templatePreviews || [],
516
+ montageResource: session.artifacts.templateMontage,
517
+ });
518
+ }
519
+ actions.push(sourceExtraction.actionRequired);
520
+ session.actionRequired = actions;
521
+ session.templateCapsule = {
522
+ sha256: session.input.templateSha256,
523
+ path: capsuleDirectory,
524
+ reused: capsuleReused,
525
+ };
526
+ session.state = actions.some((item) => item.type === "template-semantics")
527
+ ? "needs-template-and-source-semantics"
528
+ : "needs-source-semantics";
529
+ await saveSession(session, home);
530
+ return {
531
+ session,
532
+ templateInspection: templateProfile,
533
+ sourceExtraction,
534
+ templateRendering,
535
+ actionRequired: actions,
536
+ capsuleReused,
537
+ };
538
+ } catch (error) {
539
+ await failSession(session, error, home);
540
+ throw error;
541
+ }
542
+ }
543
+
544
+ export async function submitSemantics(id, payload, options = {}) {
545
+ const home = resolveEngineHome(options.home);
546
+ const session = await loadSession(id, home);
547
+ const directory = sessionDir(id, home);
548
+ const inspection = await readJson(session.artifacts.templateInspection);
549
+ const extraction = await readJson(session.artifacts.sourceExtraction);
550
+ const templateProfile = validateTemplateSemantics(
551
+ inspection,
552
+ payload.templateProfile,
553
+ );
554
+ const sourceMap = validateSourceSemantics(
555
+ extraction,
556
+ payload.sourceSemantics,
557
+ session.input.requirements,
558
+ );
559
+ if (templateProfile.actionRequired) {
560
+ const error = new Error("Template review is still unresolved");
561
+ error.code = "TEMPLATE_REVIEW_REQUIRED";
562
+ throw error;
563
+ }
564
+ const templatePath = path.join(directory, "template-profile.lock.json");
565
+ const sourcePath = path.join(directory, "source-map.lock.json");
566
+ await writeJsonAtomic(templatePath, templateProfile);
567
+ await writeJsonAtomic(sourcePath, sourceMap);
568
+ const capsuleDirectory = path.join(
569
+ resolveLibraryDir(home),
570
+ session.input.templateSha256,
571
+ );
572
+ await fs.mkdir(capsuleDirectory, { recursive: true });
573
+ await writeJsonAtomic(
574
+ path.join(capsuleDirectory, "template-profile.lock.json"),
575
+ templateProfile,
576
+ );
577
+ await writeJsonAtomic(path.join(capsuleDirectory, "manifest.json"), {
578
+ schemaVersion: 1,
579
+ engineVersion,
580
+ sourceSha256: session.input.templateSha256,
581
+ profileFingerprint: templateProfile.profileFingerprint,
582
+ updatedAt: new Date().toISOString(),
583
+ });
584
+ session.artifacts.templateProfile = templatePath;
585
+ session.artifacts.sourceMap = sourcePath;
586
+ session.state = "semantics-ready";
587
+ session.actionRequired = [];
588
+ await saveSession(session, home);
589
+ return { session, templateProfile, sourceMap };
590
+ }
591
+
592
+ export async function planSession(id, planningOptions = {}, options = {}) {
593
+ const home = resolveEngineHome(options.home);
594
+ const session = await loadSession(id, home);
595
+ const sourceMap = await readJson(session.artifacts.sourceMap);
596
+ const templateProfile = await readJson(session.artifacts.templateProfile);
597
+ let contentPlan;
598
+ try {
599
+ contentPlan = buildAdaptiveContentPlan(sourceMap, templateProfile, {
600
+ density: planningOptions.density,
601
+ scope: planningOptions.scope,
602
+ templateMode: planningOptions.templateMode,
603
+ allowLowFit: planningOptions.allowLowFit === true,
604
+ durationSeconds:
605
+ planningOptions.durationSeconds ||
606
+ sourceMap.presentationBrief?.durationSeconds,
607
+ });
608
+ } catch (error) {
609
+ if (error.code === "TEMPLATE_SELECTION_AMBIGUOUS") {
610
+ session.state = "needs-template-selection";
611
+ session.actionRequired = [
612
+ action(
613
+ "template-selection",
614
+ [
615
+ "Inspect the full-size candidate previews and approve one source slide for this content role.",
616
+ "Do not choose automatically while the leading candidates remain near-tied.",
617
+ ],
618
+ error.details || {},
619
+ ),
620
+ ];
621
+ await saveSession(session, home);
622
+ return { session, actionRequired: session.actionRequired };
623
+ }
624
+ if (/^Template fit is /.test(error.message)) {
625
+ session.state = "needs-template-fit-decision";
626
+ session.actionRequired = [
627
+ action(
628
+ "template-fit",
629
+ [
630
+ "The template lacks one or more safe capabilities required by this presentation.",
631
+ "Choose another template or explicitly approve style-only mode; do not silently force a poor fit.",
632
+ ],
633
+ { planningError: error.message },
634
+ ),
635
+ ];
636
+ await saveSession(session, home);
637
+ return { session, actionRequired: session.actionRequired };
638
+ }
639
+ if (error.code === "DURATION_CONFLICT") {
640
+ session.state = "needs-duration-decision";
641
+ session.actionRequired = [
642
+ action(
643
+ "duration",
644
+ [
645
+ "The complete traced content cannot fit the requested speaking time.",
646
+ "Ask the user to extend the duration, narrow scope explicitly, or split the presentation. Never delete critical reasons.",
647
+ ],
648
+ error.details || {},
649
+ ),
650
+ ];
651
+ await saveSession(session, home);
652
+ return { session, actionRequired: session.actionRequired };
653
+ }
654
+ const assetTypes = [
655
+ ...new Set(
656
+ templateProfile.slides
657
+ .flatMap((slide) => slide.assetRequirements || [])
658
+ .filter((item) => item.required)
659
+ .map((item) => item.type),
660
+ ),
661
+ ];
662
+ if (
663
+ assetTypes.length &&
664
+ /No approved template page can safely express|supply required assets/i.test(
665
+ error.message,
666
+ )
667
+ ) {
668
+ session.state = "needs-assets";
669
+ session.actionRequired = [
670
+ action(
671
+ "assets",
672
+ [
673
+ "The available approved layouts require visual assets for one or more source units.",
674
+ "Use the host image search/generation capability when authorized, or ask the user once for all missing assets.",
675
+ "Register assets against sourceUnitId, then call ppt_plan again.",
676
+ ],
677
+ { requiredTypes: assetTypes, planningError: error.message },
678
+ ),
679
+ ];
680
+ await saveSession(session, home);
681
+ return { session, actionRequired: session.actionRequired };
682
+ }
683
+ throw error;
684
+ }
685
+ const audit = auditContentPlan(contentPlan);
686
+ if (!audit.passed) {
687
+ const error = new Error(`Content audit failed:\n${audit.errors.join("\n")}`);
688
+ error.code = "CONTENT_AUDIT_FAILED";
689
+ error.details = audit;
690
+ throw error;
691
+ }
692
+ const frameMap = buildFrameMap(contentPlan, templateProfile);
693
+ const directory = sessionDir(id, home);
694
+ const contentPath = path.join(directory, "content-plan.lock.json");
695
+ const framePath = path.join(directory, "frame-map.lock.json");
696
+ const auditPath = path.join(directory, "content-audit.json");
697
+ await writeJsonAtomic(contentPath, contentPlan);
698
+ await writeJsonAtomic(framePath, frameMap);
699
+ await writeJsonAtomic(auditPath, audit);
700
+ session.artifacts.contentPlan = contentPath;
701
+ session.artifacts.frameMap = framePath;
702
+ session.artifacts.contentAudit = auditPath;
703
+ const missingAssets = requiredAssets(contentPlan);
704
+ session.state = missingAssets.length ? "needs-assets" : "ready";
705
+ session.actionRequired = missingAssets.length
706
+ ? [
707
+ action(
708
+ "assets",
709
+ [
710
+ "Supply every required image, chart, table, or icon before generation.",
711
+ "The engine will not emit gray placeholders or temporary stock graphics.",
712
+ ],
713
+ { missing: missingAssets },
714
+ ),
715
+ ]
716
+ : [];
717
+ await saveSession(session, home);
718
+ return { session, contentPlan, frameMap, audit };
719
+ }
720
+
721
+ export async function supplyAssets(id, assets, options = {}) {
722
+ const home = resolveEngineHome(options.home);
723
+ const session = await loadSession(id, home);
724
+ const hasPlan = Boolean(session.artifacts.contentPlan);
725
+ const document = await readJson(
726
+ hasPlan ? session.artifacts.contentPlan : session.artifacts.sourceMap,
727
+ );
728
+ const roots = session.input.roots;
729
+ const units = new Map(document.sourceUnits.map((unit) => [unit.id, unit]));
730
+ for (const asset of assets || []) {
731
+ const unit = units.get(String(asset.sourceUnitId));
732
+ if (!unit) throw new Error(`Unknown sourceUnitId: ${asset.sourceUnitId}`);
733
+ const pathValue = asset.path
734
+ ? assertAllowedPath(asset.path, roots)
735
+ : undefined;
736
+ if (pathValue && !(await exists(pathValue))) {
737
+ throw new Error(`Asset not found: ${pathValue}`);
738
+ }
739
+ unit.assets ||= [];
740
+ unit.assets.push({
741
+ type: asset.type || "image",
742
+ value: pathValue || asset.value,
743
+ path: pathValue,
744
+ alt: asset.alt || "",
745
+ });
746
+ }
747
+ if (!hasPlan) {
748
+ await writeJsonAtomic(session.artifacts.sourceMap, document);
749
+ session.state = "semantics-ready";
750
+ session.actionRequired = [];
751
+ await saveSession(session, home);
752
+ return { session, sourceMap: document, missingAssets: [] };
753
+ }
754
+ const templateProfile = await readJson(session.artifacts.templateProfile);
755
+ const replanned = buildAdaptiveContentPlan(document, templateProfile, {
756
+ durationSeconds: document.presentationBrief?.durationSeconds,
757
+ allowLowFit: true,
758
+ });
759
+ const audit = auditContentPlan(replanned);
760
+ if (!audit.passed) throw new Error(`Content audit failed:\n${audit.errors.join("\n")}`);
761
+ const frameMap = buildFrameMap(replanned, templateProfile);
762
+ await writeJsonAtomic(session.artifacts.contentPlan, replanned);
763
+ await writeJsonAtomic(session.artifacts.frameMap, frameMap);
764
+ await writeJsonAtomic(session.artifacts.contentAudit, audit);
765
+ const missingAssets = requiredAssets(replanned);
766
+ session.state = missingAssets.length ? "needs-assets" : "ready";
767
+ session.actionRequired = missingAssets.length
768
+ ? [action("assets", ["Additional required assets are still missing."], { missing: missingAssets })]
769
+ : [];
770
+ await saveSession(session, home);
771
+ return { session, missingAssets, contentPlan: replanned, frameMap };
772
+ }
773
+
774
+ async function appendProgress(session, event, home) {
775
+ const latest = await loadSession(session.id, home);
776
+ if (latest.state === "cancelled") {
777
+ const error = new Error("Generation was cancelled");
778
+ error.code = "JOB_CANCELLED";
779
+ throw error;
780
+ }
781
+ session.events.push({ ...event, at: new Date().toISOString() });
782
+ if (session.events.length > 500) session.events = session.events.slice(-500);
783
+ await saveSession(session, home);
784
+ }
785
+
786
+ export async function generateSession(id, options = {}) {
787
+ const home = resolveEngineHome(options.home);
788
+ const session = await loadSession(id, home);
789
+ if (!["ready", "generated", "validation-failed"].includes(session.state)) {
790
+ throw new Error(`Session ${id} is not ready; current state is ${session.state}`);
791
+ }
792
+ session.state = "generating";
793
+ session.actionRequired = [];
794
+ const jobId =
795
+ options.jobId || `job-${crypto.randomBytes(6).toString("hex")}`;
796
+ session.jobId = jobId;
797
+ await saveSession(session, home);
798
+ try {
799
+ const frameMap = await readJson(session.artifacts.frameMap);
800
+ const directory = sessionDir(id, home);
801
+ const livePreview = options.livePreview === true;
802
+ const snapshotPath = livePreview
803
+ ? path.join(directory, "progress", "working.pptx")
804
+ : undefined;
805
+ const authoring = await editPptxNative({
806
+ templatePath: session.input.template,
807
+ frameMap,
808
+ outputPath: session.input.output,
809
+ progressSnapshotPath: snapshotPath,
810
+ progress: async (event) => {
811
+ await appendProgress(session, event, home);
812
+ if (!livePreview || !event.snapshotPptx) return;
813
+ const liveRendering = await renderPptx({
814
+ pptxPath: event.snapshotPptx,
815
+ workspace: path.join(
816
+ directory,
817
+ "progress",
818
+ `render-${String(event.outputSlide).padStart(2, "0")}`,
819
+ ),
820
+ libreOffice: options.libreOffice,
821
+ });
822
+ const page = liveRendering.pages.at(-1);
823
+ const previewPath = path.join(
824
+ directory,
825
+ "progress",
826
+ "previews",
827
+ `slide-${String(event.outputSlide).padStart(2, "0")}.png`,
828
+ );
829
+ await fs.mkdir(path.dirname(previewPath), { recursive: true });
830
+ await fs.copyFile(page.path, previewPath);
831
+ session.artifacts.livePreviews ||= [];
832
+ session.artifacts.livePreviews[event.outputSlide - 1] = previewPath;
833
+ await appendProgress(
834
+ session,
835
+ {
836
+ event: "preview-ready",
837
+ outputSlide: event.outputSlide,
838
+ slideCount: event.slideCount,
839
+ preview: previewPath,
840
+ renderSha256: await sha256File(previewPath),
841
+ },
842
+ home,
843
+ );
844
+ },
845
+ });
846
+ session.artifacts.output = authoring.output;
847
+ session.artifacts.authoring = path.join(sessionDir(id, home), "authoring.json");
848
+ await writeJsonAtomic(session.artifacts.authoring, authoring);
849
+ session.state = "generated";
850
+ await saveSession(session, home);
851
+ if (options.validate !== false) return validateSession(id, options);
852
+ return { session, authoring };
853
+ } catch (error) {
854
+ if (error.code === "JOB_CANCELLED") {
855
+ session.state = "cancelled";
856
+ session.error = undefined;
857
+ await saveSession(session, home);
858
+ return { session, cancelled: true };
859
+ }
860
+ await failSession(session, error, home);
861
+ throw error;
862
+ }
863
+ }
864
+
865
+ export function startGeneration(id, options = {}) {
866
+ if (jobs.has(id)) return jobs.get(id).descriptor;
867
+ const descriptor = {
868
+ sessionId: id,
869
+ jobId: `job-${crypto.randomBytes(6).toString("hex")}`,
870
+ startedAt: new Date().toISOString(),
871
+ };
872
+ const promise = generateSession(id, {
873
+ ...options,
874
+ jobId: descriptor.jobId,
875
+ })
876
+ .catch((error) => ({
877
+ error: {
878
+ code: error.code || "PPT_REUSE_GENERATION_FAILED",
879
+ message: error.message || String(error),
880
+ },
881
+ }))
882
+ .finally(() => jobs.delete(id));
883
+ jobs.set(id, { descriptor, promise });
884
+ return descriptor;
885
+ }
886
+
887
+ export async function validateSession(id, options = {}) {
888
+ const home = resolveEngineHome(options.home);
889
+ const session = await loadSession(id, home);
890
+ if (!session.artifacts.output || !(await exists(session.artifacts.output))) {
891
+ throw new Error("No generated PPTX is available for validation");
892
+ }
893
+ session.state = "validating";
894
+ await saveSession(session, home);
895
+ const packageReport = await inspectPptxPackage(session.artifacts.output);
896
+ const contentAudit = await readJson(session.artifacts.contentAudit);
897
+ const slideText = await allSlideText(session.artifacts.output);
898
+ const residualPlaceholders = slideText.filter((slide) =>
899
+ PLACEHOLDER.test(slide.text),
900
+ );
901
+ const chartWorkbookWarnings = session.artifacts.authoring
902
+ ? (await readJson(session.artifacts.authoring)).editLog.filter(
903
+ (entry) => entry.type === "chart" && entry.workbookSynced === false,
904
+ )
905
+ : [];
906
+ let rendering;
907
+ let protectedRegions = [];
908
+ let renderedTextFidelity;
909
+ let rendererError;
910
+ try {
911
+ rendering = await renderPptx({
912
+ pptxPath: session.artifacts.output,
913
+ workspace: path.join(sessionDir(id, home), "render"),
914
+ libreOffice: options.libreOffice,
915
+ });
916
+ renderedTextFidelity = await compareRenderedTextFidelity({
917
+ pptxPath: session.artifacts.output,
918
+ pdfPath: rendering.pdfPath,
919
+ });
920
+ const frameMap = await readJson(session.artifacts.frameMap);
921
+ const templateProfile = await readJson(session.artifacts.templateProfile);
922
+ const templateRendering = await renderPptx({
923
+ pptxPath: session.input.template,
924
+ workspace: path.join(sessionDir(id, home), "render-template"),
925
+ libreOffice: options.libreOffice,
926
+ });
927
+ for (const outputSlide of frameMap.outputSlides) {
928
+ const expected = templateRendering.pages[Number(outputSlide.sourceSlide) - 1];
929
+ const actual = rendering.pages[Number(outputSlide.outputSlide) - 1];
930
+ if (!expected || !actual) {
931
+ protectedRegions.push({
932
+ outputSlide: outputSlide.outputSlide,
933
+ passed: false,
934
+ reason: "missing-rendered-page",
935
+ });
936
+ continue;
937
+ }
938
+ protectedRegions.push({
939
+ outputSlide: outputSlide.outputSlide,
940
+ sourceSlide: outputSlide.sourceSlide,
941
+ ...(await compareProtectedPng({
942
+ expectedPath: expected.path,
943
+ actualPath: actual.path,
944
+ editableBoxes: outputSlide.editTargets
945
+ .map((target) => target.sourceBbox)
946
+ .filter(Boolean),
947
+ canvas: templateProfile.canvas,
948
+ diffPath: path.join(
949
+ sessionDir(id, home),
950
+ "render",
951
+ "protected-diffs",
952
+ `slide-${String(outputSlide.outputSlide).padStart(2, "0")}.png`,
953
+ ),
954
+ })),
955
+ });
956
+ }
957
+ } catch (error) {
958
+ rendererError = { code: error.code || "RENDER_FAILED", message: error.message };
959
+ }
960
+ const errors = [];
961
+ if (!contentAudit.passed) errors.push(...contentAudit.errors);
962
+ if (packageReport.slideCount !== contentAudit.metrics.slideCount) {
963
+ errors.push("PPTX slide count does not match the frozen content plan");
964
+ }
965
+ if (packageReport.notesSlideCount < contentAudit.metrics.slideCount) {
966
+ errors.push("One or more slides are missing native speaker notes");
967
+ }
968
+ if (residualPlaceholders.length) {
969
+ errors.push(
970
+ `Residual placeholder text on slides: ${residualPlaceholders
971
+ .map((item) => item.slide)
972
+ .join(", ")}`,
973
+ );
974
+ }
975
+ if (chartWorkbookWarnings.length) {
976
+ errors.push("Chart cache changed without synchronizing the embedded workbook");
977
+ }
978
+ if (rendererError) {
979
+ errors.push(`Final rendering unavailable: ${rendererError.message}`);
980
+ }
981
+ if (
982
+ rendering &&
983
+ rendering.pages.length !== packageReport.slideCount
984
+ ) {
985
+ errors.push("Rendered page count does not match PPTX slide count");
986
+ }
987
+ if (renderedTextFidelity && !renderedTextFidelity.passed) {
988
+ errors.push(
989
+ "Rendered text fidelity could not be verified; complete a manual WPS or PowerPoint review before claiming visual acceptance",
990
+ );
991
+ }
992
+ const protectedFailures = protectedRegions.filter((item) => !item.passed);
993
+ if (protectedFailures.length) {
994
+ errors.push(
995
+ `Protected-region pixel checks failed on slides: ${protectedFailures
996
+ .map((item) => item.outputSlide)
997
+ .join(", ")}`,
998
+ );
999
+ }
1000
+ const qa = {
1001
+ schemaVersion: 1,
1002
+ passed: errors.length === 0,
1003
+ errors,
1004
+ warnings: session.warnings,
1005
+ package: packageReport,
1006
+ content: contentAudit,
1007
+ rendering,
1008
+ renderedTextFidelity,
1009
+ protectedRegions,
1010
+ rendererError,
1011
+ residualPlaceholders,
1012
+ };
1013
+ const qaPath = path.join(sessionDir(id, home), "qa-report.json");
1014
+ await writeJsonAtomic(qaPath, qa);
1015
+ session.artifacts.qa = qaPath;
1016
+ if (rendering) {
1017
+ session.artifacts.previews = rendering.pages.map((page) => page.path);
1018
+ session.artifacts.montage = rendering.montage.path;
1019
+ for (const page of rendering.pages) {
1020
+ await appendProgress(
1021
+ session,
1022
+ {
1023
+ event: "preview-ready",
1024
+ outputSlide: page.page,
1025
+ slideCount: rendering.pages.length,
1026
+ preview: page.path,
1027
+ renderSha256: page.sha256,
1028
+ },
1029
+ home,
1030
+ );
1031
+ }
1032
+ }
1033
+ session.state = qa.passed ? "completed" : "validation-failed";
1034
+ session.actionRequired = qa.passed
1035
+ ? []
1036
+ : [
1037
+ action("validation", [
1038
+ "Resolve every validation error before calling the deck complete.",
1039
+ ], { errors }),
1040
+ ];
1041
+ await saveSession(session, home);
1042
+ return { session, qa };
1043
+ }
1044
+
1045
+ export async function cancelSession(id, options = {}) {
1046
+ const home = resolveEngineHome(options.home);
1047
+ const session = await loadSession(id, home);
1048
+ session.state = "cancelled";
1049
+ session.cancelledAt = new Date().toISOString();
1050
+ session.actionRequired = [];
1051
+ await saveSession(session, home);
1052
+ return session;
1053
+ }
1054
+
1055
+ export async function getSessionStatus(id, options = {}) {
1056
+ const home = resolveEngineHome(options.home);
1057
+ const session = await loadSession(id, home);
1058
+ const after = Number(options.afterEvent || 0);
1059
+ return {
1060
+ ...session,
1061
+ events: session.events.slice(Math.max(0, after)),
1062
+ nextEvent: session.events.length,
1063
+ runningInCurrentProcess: jobs.has(id),
1064
+ };
1065
+ }
1066
+
1067
+ export async function engineDoctor(options = {}) {
1068
+ return {
1069
+ engineVersion,
1070
+ home: resolveEngineHome(options.home),
1071
+ renderer: await rendererDoctor(options),
1072
+ supportedInputs: [".pptx", ".potx", ".docx", ".pdf", ".md", ".txt"],
1073
+ macroPolicy: "pptm-rejected-no-macro-execution",
1074
+ };
1075
+ }