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,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
|
+
}
|