@liustack/pptwise 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/README.zh-CN.md +136 -0
  4. package/cordis.patch.yml +5 -0
  5. package/dist/chunk-3ZUKISTY.js +114 -0
  6. package/dist/chunk-3ZUKISTY.js.map +1 -0
  7. package/dist/chunk-M35M4QUC.js +1167 -0
  8. package/dist/chunk-M35M4QUC.js.map +1 -0
  9. package/dist/chunk-VUOLBHD7.js +19 -0
  10. package/dist/chunk-VUOLBHD7.js.map +1 -0
  11. package/dist/chunk-WL5KWYKS.js +49762 -0
  12. package/dist/chunk-WL5KWYKS.js.map +1 -0
  13. package/dist/cli.js +4753 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/index.d.ts +4224 -0
  16. package/dist/index.js +99 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/node.d.ts +7 -0
  19. package/dist/node.js +11 -0
  20. package/dist/node.js.map +1 -0
  21. package/dist/pixel-audit-H5K6JK3X.js +218 -0
  22. package/dist/pixel-audit-H5K6JK3X.js.map +1 -0
  23. package/dist/registry-C0GJH7ZT.d.ts +46 -0
  24. package/dsh/client.js +1398 -0
  25. package/dsh/index.js +141 -0
  26. package/dsh/preview-tool.js +1931 -0
  27. package/dsh/spawnHidden.js +109 -0
  28. package/package.json +113 -0
  29. package/skills/pptwise/SKILL.md +100 -0
  30. package/skills/pptwise/SKILL.zh-CN.md +102 -0
  31. package/skills/pptwise/references/branding.md +18 -0
  32. package/skills/pptwise/references/branding.zh-CN.md +21 -0
  33. package/skills/pptwise/references/components.md +35 -0
  34. package/skills/pptwise/references/components.zh-CN.md +40 -0
  35. package/skills/pptwise/references/density.md +17 -0
  36. package/skills/pptwise/references/density.zh-CN.md +22 -0
  37. package/skills/pptwise/references/images.md +42 -0
  38. package/skills/pptwise/references/images.zh-CN.md +47 -0
  39. package/skills/pptwise/references/layouts.md +37 -0
  40. package/skills/pptwise/references/layouts.zh-CN.md +42 -0
  41. package/skills/pptwise/references/spec.md +107 -0
  42. package/skills/pptwise/references/spec.zh-CN.md +112 -0
  43. package/skills/pptwise/references/validate.md +82 -0
  44. package/skills/pptwise/references/validate.zh-CN.md +87 -0
  45. package/skills/pptwise/scripts/run.ps1 +192 -0
  46. package/skills/pptwise/scripts/run.sh +229 -0
@@ -0,0 +1,1167 @@
1
+ import {
2
+ AssetsSchema,
3
+ BEAT_VALUES,
4
+ BrandConfigSchema,
5
+ BrandSchema,
6
+ CANONICAL_THEME_IDS,
7
+ CAPACITY,
8
+ COMPONENT_TYPES,
9
+ DeckBrandingSchema,
10
+ LAYOUT_REGISTRY,
11
+ MetaSchema,
12
+ NarrativeProfileInputSchema,
13
+ PptwiseError,
14
+ PptxIRSchema,
15
+ STRATEGY_DEFINITIONS,
16
+ SlideSchema,
17
+ THEME_LABELS,
18
+ ThemeSchema,
19
+ contrastRatio,
20
+ getInstalledThemeIds,
21
+ getThemeDefinition,
22
+ mixHex,
23
+ normalizeDeckRootAliases,
24
+ normalizeNarrativeShape,
25
+ parseTransform,
26
+ registerTheme,
27
+ renderSlideSvg,
28
+ resolveEffectiveLayoutId,
29
+ resolveNarrative,
30
+ resolveStyle
31
+ } from "./chunk-WL5KWYKS.js";
32
+ import {
33
+ getPlatform
34
+ } from "./chunk-VUOLBHD7.js";
35
+
36
+ // src/version.ts
37
+ var VERSION = "0.22.0";
38
+
39
+ // src/ir/legacy-v3.ts
40
+ import { z } from "zod";
41
+ var PptxIRV3Schema = z.object({
42
+ version: z.literal("3").default("3"),
43
+ filename: z.string().default("presentation"),
44
+ // Pre-rename field name and axis vocabulary (mode/delivery/audience) —
45
+ // frozen as of the 0.3.0 release, spec §9.3. Same open-schema/closed-
46
+ // semantic split `NarrativeProfileInputSchema` documents: the actual
47
+ // mode/delivery/audience enum closure happened at `resolveScenario`
48
+ // runtime, not here, even before this rename.
49
+ scenario: z.union([z.string(), NarrativeProfileInputSchema]).optional(),
50
+ theme: ThemeSchema.default({ id: "consulting" }),
51
+ meta: MetaSchema.default({}),
52
+ assets: AssetsSchema.default({ images: {} }),
53
+ brand: BrandSchema.optional(),
54
+ // Optional so a v3 file that already carried the (then-undocumented)
55
+ // chrome key still parses. migrateIrV3ToV4 rewrites it to branding.
56
+ chrome: DeckBrandingSchema.optional(),
57
+ seed: z.number().int().optional(),
58
+ slides: z.array(SlideSchema)
59
+ }).strict();
60
+
61
+ // src/ir/migrate.ts
62
+ function migrateChromeToBranding(raw) {
63
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
64
+ const obj = raw;
65
+ const hasChrome = Object.hasOwn(obj, "chrome");
66
+ const hasBranding = Object.hasOwn(obj, "branding");
67
+ if (hasChrome && hasBranding) {
68
+ throw new PptwiseError('cannot migrate: both "chrome" and "branding" are present');
69
+ }
70
+ if (!hasChrome) return raw;
71
+ const next = { ...obj, branding: obj.chrome };
72
+ delete next.chrome;
73
+ return next;
74
+ }
75
+ function migrateBloomToClassroom(raw) {
76
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
77
+ const obj = raw;
78
+ const theme = obj.theme;
79
+ if (theme === "bloom") {
80
+ return { ...obj, theme: "classroom" };
81
+ }
82
+ if (typeof theme === "object" && theme !== null && !Array.isArray(theme)) {
83
+ const themeObj = theme;
84
+ if (themeObj.id === "bloom") {
85
+ return { ...obj, theme: { ...themeObj, id: "classroom" } };
86
+ }
87
+ }
88
+ return raw;
89
+ }
90
+ function isPlainRecord(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ }
93
+ function isLogoWallComponent(value) {
94
+ return isPlainRecord(value) && value.type === "logo_wall";
95
+ }
96
+ function arrayHasLogoWall(components) {
97
+ return Array.isArray(components) && components.some(isLogoWallComponent);
98
+ }
99
+ function rewriteLogoWallItem(item) {
100
+ if (!isPlainRecord(item)) return item;
101
+ const next = { asset_id: item.asset_id };
102
+ if (Object.hasOwn(item, "label") && item.label !== void 0) next.caption = item.label;
103
+ return next;
104
+ }
105
+ function rewriteLogoWallComponent(component) {
106
+ const items = component.items;
107
+ if (!Array.isArray(items)) {
108
+ return { type: "image_grid", items };
109
+ }
110
+ return { type: "image_grid", items: items.slice(0, 4).map(rewriteLogoWallItem) };
111
+ }
112
+ function rewriteComponentsArray(components) {
113
+ return components.map((component) => isLogoWallComponent(component) ? rewriteLogoWallComponent(component) : component);
114
+ }
115
+ function migrateLogoWallToImageGrid(raw) {
116
+ if (!isPlainRecord(raw)) return raw;
117
+ let next;
118
+ const take = () => {
119
+ if (!next) next = { ...raw };
120
+ return next;
121
+ };
122
+ if (arrayHasLogoWall(raw.components)) {
123
+ take().components = rewriteComponentsArray(raw.components);
124
+ }
125
+ if (Array.isArray(raw.slides)) {
126
+ let slidesChanged = false;
127
+ const slides = raw.slides.map((slide) => {
128
+ if (!isPlainRecord(slide) || !arrayHasLogoWall(slide.components)) return slide;
129
+ slidesChanged = true;
130
+ return { ...slide, components: rewriteComponentsArray(slide.components) };
131
+ });
132
+ if (slidesChanged) take().slides = slides;
133
+ }
134
+ return next ?? raw;
135
+ }
136
+ function rewriteBannerHeadingField(obj, key) {
137
+ if (obj[key] !== "banner-heading") return void 0;
138
+ return { ...obj, [key]: "two-column" };
139
+ }
140
+ function migrateBannerHeadingToTwoColumn(raw) {
141
+ if (!isPlainRecord(raw)) return raw;
142
+ let next;
143
+ const take = () => {
144
+ if (!next) next = { ...raw };
145
+ return next;
146
+ };
147
+ const topLayout = rewriteBannerHeadingField(raw, "layout");
148
+ const topFocus = rewriteBannerHeadingField(topLayout ?? raw, "focus");
149
+ if (topLayout || topFocus) {
150
+ const rewritten = topFocus ?? topLayout;
151
+ Object.assign(take(), rewritten);
152
+ }
153
+ if (Array.isArray(raw.slides)) {
154
+ let slidesChanged = false;
155
+ const slides = raw.slides.map((slide) => {
156
+ if (!isPlainRecord(slide)) return slide;
157
+ const rewritten = rewriteBannerHeadingField(slide, "layout");
158
+ if (!rewritten) return slide;
159
+ slidesChanged = true;
160
+ return rewritten;
161
+ });
162
+ if (slidesChanged) take().slides = slides;
163
+ }
164
+ if (Array.isArray(raw.pages)) {
165
+ let pagesChanged = false;
166
+ const pages = raw.pages.map((page) => {
167
+ if (!isPlainRecord(page)) return page;
168
+ const layoutHit = rewriteBannerHeadingField(page, "layout");
169
+ const focusHit = rewriteBannerHeadingField(layoutHit ?? page, "focus");
170
+ const rewritten = focusHit ?? layoutHit;
171
+ if (!rewritten) return page;
172
+ pagesChanged = true;
173
+ return rewritten;
174
+ });
175
+ if (pagesChanged) take().pages = pages;
176
+ }
177
+ return next ?? raw;
178
+ }
179
+ var STRATEGY_VALUE_MIGRATION = { narrative: "storytelling" };
180
+ var PACING_VALUE_MIGRATION = { text: "dense", presentation: "spacious" };
181
+ function migrateNarrativeInput(scenario) {
182
+ if (scenario === void 0 || typeof scenario === "string") return scenario;
183
+ const narrative = {};
184
+ for (const [key, value] of Object.entries(scenario)) {
185
+ if (key === "mode") {
186
+ narrative.strategy = typeof value === "string" ? STRATEGY_VALUE_MIGRATION[value] ?? value : value;
187
+ } else if (key === "delivery") {
188
+ narrative.pacing = typeof value === "string" ? PACING_VALUE_MIGRATION[value] ?? value : value;
189
+ } else if (key === "audience") {
190
+ narrative.audience = value;
191
+ } else {
192
+ narrative[key] = value;
193
+ }
194
+ }
195
+ return narrative;
196
+ }
197
+ function migrateIrV3ToV4(v3) {
198
+ const narrative = migrateNarrativeInput(v3.scenario);
199
+ const v4 = {
200
+ version: "4",
201
+ filename: v3.filename,
202
+ ...narrative !== void 0 ? { narrative } : {},
203
+ theme: v3.theme,
204
+ meta: v3.meta,
205
+ assets: v3.assets,
206
+ ...v3.brand !== void 0 ? { brand: v3.brand } : {},
207
+ ...v3.chrome !== void 0 ? { chrome: v3.chrome } : {},
208
+ ...v3.seed !== void 0 ? { seed: v3.seed } : {},
209
+ slides: v3.slides
210
+ };
211
+ return migrateBannerHeadingToTwoColumn(
212
+ migrateLogoWallToImageGrid(migrateBloomToClassroom(migrateChromeToBranding(v4)))
213
+ );
214
+ }
215
+
216
+ // src/themes/brand-extract.ts
217
+ import JSZip from "jszip";
218
+ var ACCENT_SLOTS = ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
219
+ var THEME_PART_RE = /theme\d*\.xml$/;
220
+ var THEME_VARIANTS_SEGMENT = "themeVariants";
221
+ var CLR_SCHEME_RE = /<a:clrScheme name="([^"]*)">([\s\S]*?)<\/a:clrScheme>/;
222
+ var CLR_SLOT_RE = /<a:(dk1|lt1|dk2|lt2|accent[1-6]|hlink|folHlink)>\s*<a:(?:srgbClr val="([0-9A-Fa-f]{6})"|sysClr[^>]*lastClr="([0-9A-Fa-f]{6})")/g;
223
+ var FONT_SCHEME_RE = /<a:fontScheme name="([^"]*)">[\s\S]*?<a:majorFont>\s*<a:latin typeface="([^"]*)"[\s\S]*?<a:minorFont>\s*<a:latin typeface="([^"]*)"/;
224
+ async function findThemePart(zip) {
225
+ const candidates = Object.keys(zip.files).filter((name) => !zip.files[name].dir && THEME_PART_RE.test(name) && !name.includes(THEME_VARIANTS_SEGMENT)).sort((a, b) => a.length - b.length);
226
+ const path = candidates[0];
227
+ if (path === void 0) return void 0;
228
+ const xml = await zip.files[path].async("string");
229
+ return { path, xml };
230
+ }
231
+ function parseColors(xml) {
232
+ const match = CLR_SCHEME_RE.exec(xml);
233
+ if (!match) return { schemeName: void 0, slots: {} };
234
+ const slots = {};
235
+ for (const m of match[2].matchAll(CLR_SLOT_RE)) {
236
+ const slot = m[1];
237
+ const hex = m[2] ?? m[3];
238
+ if (hex) slots[slot] = `#${hex.toUpperCase()}`;
239
+ }
240
+ return { schemeName: match[1]?.trim() || void 0, slots };
241
+ }
242
+ function parseFonts(xml) {
243
+ const m = FONT_SCHEME_RE.exec(xml);
244
+ if (!m) return { major: void 0, minor: void 0 };
245
+ return { major: m[2]?.trim() || void 0, minor: m[3]?.trim() || void 0 };
246
+ }
247
+ function isDark(hex) {
248
+ return contrastRatio(hex, "#FFFFFF") > contrastRatio(hex, "#000000");
249
+ }
250
+ function resolveBgText(dk1, lt1) {
251
+ return isDark(dk1) === isDark(lt1) ? { bg: lt1, text: dk1 } : isDark(dk1) ? { bg: lt1, text: dk1 } : { bg: dk1, text: lt1 };
252
+ }
253
+ var MUTED_STEPS = 20;
254
+ var MUTED_TARGET_RATIO = 4.5;
255
+ function deriveMuted(text, bg, surface) {
256
+ for (let step = MUTED_STEPS; step >= 0; step--) {
257
+ const t = step / MUTED_STEPS;
258
+ const candidate = mixHex(text, bg, t);
259
+ if (contrastRatio(candidate, bg) >= MUTED_TARGET_RATIO && contrastRatio(candidate, surface) >= MUTED_TARGET_RATIO) {
260
+ return candidate;
261
+ }
262
+ }
263
+ return text;
264
+ }
265
+ var SERIF_HINTS = [
266
+ "times",
267
+ "georgia",
268
+ "cambria",
269
+ "garamond",
270
+ "palatino",
271
+ "constantia",
272
+ "book antiqua",
273
+ "minion",
274
+ "serif",
275
+ "cochin",
276
+ "didot",
277
+ "baskerville",
278
+ "sitka",
279
+ "century",
280
+ "goudy",
281
+ "bodoni",
282
+ "caslon",
283
+ "perpetua",
284
+ "rockwell",
285
+ "playfair"
286
+ ];
287
+ function isSerifName(name) {
288
+ const n = name.toLowerCase();
289
+ return SERIF_HINTS.some((hint) => n.includes(hint));
290
+ }
291
+ function buildFontStack(extracted) {
292
+ if (extracted === void 0) return ["Calibri", "Microsoft YaHei", "sans-serif"];
293
+ return isSerifName(extracted) ? [extracted, "Georgia", "Source Han Serif SC", "serif"] : [extracted, "Calibri", "Microsoft YaHei", "sans-serif"];
294
+ }
295
+ function slugify(input, fallback = "brand") {
296
+ const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
297
+ return slug || fallback;
298
+ }
299
+ async function extractBrandTheme(bytes, opts = {}) {
300
+ const zip = await JSZip.loadAsync(bytes);
301
+ const part = await findThemePart(zip);
302
+ if (!part) {
303
+ throw new PptwiseError(
304
+ "no theme part found in this file \u2014 expected a .thmx/.potx/.pptx OOXML package with a ppt/theme/theme1.xml (or theme/theme/theme1.xml) part"
305
+ );
306
+ }
307
+ const { schemeName, slots } = parseColors(part.xml);
308
+ if (!slots.dk1 || !slots.lt1) {
309
+ throw new PptwiseError(`theme part ${part.path} is missing dk1/lt1 colors \u2014 cannot derive bg/text tokens`);
310
+ }
311
+ const chartPalette = ACCENT_SLOTS.map((slot) => slots[slot]).filter((c) => c !== void 0);
312
+ if (chartPalette.length === 0) {
313
+ throw new PptwiseError(`theme part ${part.path} has no accent colors \u2014 cannot derive a chart palette or primary color`);
314
+ }
315
+ const { bg, text } = resolveBgText(slots.dk1, slots.lt1);
316
+ const surface = slots.lt2 ?? bg;
317
+ const primary = slots.accent1 ?? slots.dk2 ?? chartPalette[0];
318
+ const accent = slots.accent2 ?? primary;
319
+ const muted = deriveMuted(text, bg, surface);
320
+ const fonts = parseFonts(part.xml);
321
+ const heading = buildFontStack(fonts.major);
322
+ const body = buildFontStack(fonts.minor ?? fonts.major);
323
+ const label = opts.label ?? schemeName ?? "brand";
324
+ const id = opts.id ?? slugify(opts.label ?? schemeName ?? "brand");
325
+ const style = {
326
+ id,
327
+ colors: { bg, surface, primary, accent, text, muted, chartPalette },
328
+ fonts: { heading, body },
329
+ defaultBackgrounds: {
330
+ cover: { kind: "color", value: bg },
331
+ chapter: { kind: "color", value: bg },
332
+ content: { kind: "color", value: bg },
333
+ ending: { kind: "color", value: bg }
334
+ }
335
+ };
336
+ return { id, label, style, brand: {}, tags: [] };
337
+ }
338
+
339
+ // src/themes/brand-theme-file.ts
340
+ import { z as z2 } from "zod";
341
+ var HexToken = z2.string().regex(/^#[0-9A-Fa-f]{3,8}$/, "expected a hex color like #RRGGBB");
342
+ var BackgroundSpecFileSchema = z2.discriminatedUnion("kind", [
343
+ z2.object({ kind: z2.literal("color"), value: HexToken }).strict(),
344
+ z2.object({
345
+ kind: z2.literal("gradient"),
346
+ from: HexToken,
347
+ to: HexToken,
348
+ direction: z2.enum(["tb", "lr", "diagonal"]).optional()
349
+ }).strict(),
350
+ z2.object({
351
+ kind: z2.literal("asset"),
352
+ asset_id: z2.string(),
353
+ overlay: z2.object({ color: HexToken, opacity: z2.number().min(0).max(1) }).strict().optional(),
354
+ fit: z2.enum(["cover", "contain"]).optional()
355
+ }).strict()
356
+ ]);
357
+ var StyleTokensFileSchema = z2.object({
358
+ id: z2.string().min(1),
359
+ allowCustomBackground: z2.boolean().optional(),
360
+ colors: z2.object({
361
+ bg: HexToken,
362
+ surface: HexToken,
363
+ panel: HexToken.optional(),
364
+ primary: HexToken,
365
+ accent: HexToken,
366
+ text: HexToken,
367
+ muted: HexToken,
368
+ border: HexToken.optional(),
369
+ chartPalette: z2.array(HexToken).min(1),
370
+ accentPool: z2.array(HexToken).min(1).optional(),
371
+ cardStroke: HexToken.optional()
372
+ }).strict(),
373
+ fonts: z2.object({
374
+ heading: z2.array(z2.string()).min(1),
375
+ body: z2.array(z2.string()).min(1),
376
+ mono: z2.array(z2.string()).min(1).optional()
377
+ }).strict(),
378
+ shape: z2.object({
379
+ radius: z2.number().min(0).max(32).optional(),
380
+ gapScale: z2.number().min(0.8).max(1.3).optional(),
381
+ typeScale: z2.number().min(0.5).max(2).optional()
382
+ }).strict().optional(),
383
+ defaultBackgrounds: z2.object({
384
+ cover: BackgroundSpecFileSchema,
385
+ chapter: BackgroundSpecFileSchema,
386
+ content: BackgroundSpecFileSchema,
387
+ ending: BackgroundSpecFileSchema
388
+ }).strict()
389
+ }).strict();
390
+ var BrandThemeFileSchema = z2.object({
391
+ id: z2.string().min(1),
392
+ label: z2.string().optional(),
393
+ style: StyleTokensFileSchema,
394
+ brand: BrandConfigSchema.optional(),
395
+ tags: z2.array(z2.string()).optional()
396
+ }).strict();
397
+ function parseBrandThemeFile(raw, source) {
398
+ const r = BrandThemeFileSchema.safeParse(raw);
399
+ if (!r.success) {
400
+ const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
401
+ throw new PptwiseError(`invalid theme file ${source}:
402
+ ${detail}`);
403
+ }
404
+ return r.data;
405
+ }
406
+ function registerBrandThemeFile(file) {
407
+ if (CANONICAL_THEME_IDS.includes(file.id)) {
408
+ throw new PptwiseError(
409
+ `theme file id "${file.id}" collides with a built-in pptwise theme \u2014 pick a different id (\`pptwise brand extract --id <id>\`, or edit the theme file's own "id" field)`
410
+ );
411
+ }
412
+ if (!getInstalledThemeIds().includes(file.id)) {
413
+ registerTheme({ id: file.id, style: file.style, brand: file.brand ?? {}, tags: file.tags ?? [] });
414
+ }
415
+ return file.id;
416
+ }
417
+
418
+ // src/spec/index.ts
419
+ import { z as z3 } from "zod";
420
+ var PAGE_TYPES = ["cover", "chapter", "content", "ending"];
421
+ var PageSpecSchema = z3.object({
422
+ id: z3.string(),
423
+ type: z3.enum(PAGE_TYPES),
424
+ heading: z3.string(),
425
+ /** One of the three beat values, or omitted entirely — an omitted
426
+ * beat is never a hard-gate violation on its own (see
427
+ * {@link checkBeatRotation}'s policy functions below). It gets
428
+ * auto-alternated at assemble time (W5 task 3, not this task — still
429
+ * unimplemented as of the P1 variety wave's task 1). Renamed
430
+ * from `rhythm` (vocabulary-v4 rename, spec §4.3/§6/§8.1) — same
431
+ * three values, same semantics, page-level term only, distinct from
432
+ * the deck-level `pacing` axis. A *declared* value here is no longer
433
+ * spec-only advisory material (P1 variety wave, task 1): `assembleDeck`
434
+ * (`./assemble.ts`) now carries it straight into the IR's own
435
+ * `Slide.beat` field, where it combines with a soft selection-weight
436
+ * onto layout picking (`Math.max`, not multiplication — see
437
+ * `SlideSchema.beat`'s own doc comment, `../ir/index.ts`, and
438
+ * `BEAT_TENDENCY_WEIGHT`'s in `../svg/layout-selection.ts` for why) —
439
+ * the checks below (rotation shape) and that downstream weighting
440
+ * (which layouts a given beat favors) are two independent consumers
441
+ * of the same declared value, not two views of one mechanism. */
442
+ beat: z3.enum(BEAT_VALUES).optional(),
443
+ /** Optional authoring hint pointing fill/select at a preferred
444
+ * component type or layout id — see {@link checkFocusVocabulary}. */
445
+ focus: z3.string().optional(),
446
+ /** Free-text content anchor read by the fill step and never validated or
447
+ * interpreted here. Assemble also surfaces it as `subheading` on
448
+ * boundary pages, which have no page-content field for that line. On a
449
+ * filled content page it remains a fill-only authoring hint. */
450
+ summary: z3.string().optional()
451
+ }).strict();
452
+ var DeckSpecSchema = z3.object({
453
+ version: z3.literal("1").default("1"),
454
+ // Same open-schema/closed-semantic split as PptxIRSchema's `narrative`
455
+ // field — see `NarrativeProfileInputSchema`'s doc comment in `ir/index.ts`
456
+ // for the full rationale (reused verbatim here, not redefined, so the
457
+ // two can't drift apart). Field renamed from `scenario` to `narrative`
458
+ // this task (spec §8.1's `DeckPlan`→`DeckSpec` rename, task 2) — its
459
+ // *value* was already in the new strategy/pacing vocabulary as of task 1
460
+ // (vocabulary-v4 rename) — `resolveNarrative` below is what actually
461
+ // enforces that.
462
+ narrative: z3.union([z3.string(), NarrativeProfileInputSchema]).optional(),
463
+ theme: z3.string().optional(),
464
+ filename: z3.string().optional(),
465
+ seed: z3.number().int().optional(),
466
+ meta: MetaSchema.default({}),
467
+ /** Deck logo placement — reused verbatim from the IR's own `brand` field
468
+ * (`BrandSchema`, `../ir`) so the deck spec and IR can't drift apart on
469
+ * shape, same pattern as `meta` just above. Unlike `meta`, no
470
+ * `.default({})`: IR's own `brand` field is a bare `.optional()` with no
471
+ * default either (`undefined` means "no brand", not "an empty brand
472
+ * object") — consumed by `Branding` (`src/svg/branding.tsx`) for
473
+ * the deck's logo image and corner position. */
474
+ brand: BrandSchema.optional(),
475
+ /**
476
+ * Where the brand footer and logo appear — reused verbatim from the IR's
477
+ * own `branding` field (`DeckBrandingSchema`, `../ir`) so the spec and IR
478
+ * cannot drift. Optional, no default: omitted stays unset and assemble
479
+ * does not write `"cover-only"` into the IR. The renderer treats that
480
+ * as `"cover-only"`. Omitted by default. Write `"full"` only when every
481
+ * content page needs the brand footer. `"full"` also paints confidentiality
482
+ * and date on cover and ending meta rows. Layout `branding: "none"` still
483
+ * wins at render.
484
+ */
485
+ branding: DeckBrandingSchema.optional(),
486
+ pages: z3.array(PageSpecSchema)
487
+ }).strict();
488
+ function specJsonSchema() {
489
+ return z3.toJSONSchema(DeckSpecSchema);
490
+ }
491
+ function formatSpecIssues(errors) {
492
+ return errors.map((e) => e.pageId ? `page "${e.pageId}" \u2014 ${e.path}: ${e.message}` : `${e.path}: ${e.message}`).join("\n");
493
+ }
494
+ function formatInvalidSpecError(errors) {
495
+ return `invalid spec (${errors.length} issue${errors.length === 1 ? "" : "s"}):
496
+ ${formatSpecIssues(errors)}`;
497
+ }
498
+ function resolveSpecThemeId(spec) {
499
+ return spec.theme ?? "consulting";
500
+ }
501
+ function checkPagesNonEmpty(spec) {
502
+ if (spec.pages.length > 0) return [];
503
+ return [{ path: "pages", message: "spec has no pages \u2014 a spec needs at least a cover page and an ending page" }];
504
+ }
505
+ function checkBoundaryTypes(spec) {
506
+ const { pages } = spec;
507
+ const errors = [];
508
+ const first = pages[0];
509
+ const last = pages[pages.length - 1];
510
+ if (first.type !== "cover") {
511
+ errors.push({
512
+ path: "pages.0.type",
513
+ pageId: first.id,
514
+ message: `first page must be type "cover" (got "${first.type}") \u2014 a spec must open with a cover page`
515
+ });
516
+ }
517
+ if (last.type !== "ending") {
518
+ errors.push({
519
+ path: `pages.${pages.length - 1}.type`,
520
+ pageId: last.id,
521
+ message: `last page must be type "ending" (got "${last.type}") \u2014 a spec must close with an ending page`
522
+ });
523
+ }
524
+ for (let i = 1; i < pages.length - 1; i++) {
525
+ const page = pages[i];
526
+ if (page.type === "cover" || page.type === "ending") {
527
+ errors.push({
528
+ path: `pages.${i}.type`,
529
+ pageId: page.id,
530
+ message: `page "${page.id}" is type "${page.type}", only allowed as the first (cover) or last (ending) page \u2014 use "content" or "chapter" for interior pages`
531
+ });
532
+ }
533
+ }
534
+ return errors;
535
+ }
536
+ function isUnsafePageId(id) {
537
+ return id.includes("/") || id.includes("\\") || id === "..";
538
+ }
539
+ function checkPageIds(spec) {
540
+ const errors = [];
541
+ const seen = /* @__PURE__ */ new Map();
542
+ spec.pages.forEach((page, i) => {
543
+ if (page.id.trim() === "") {
544
+ errors.push({ path: `pages.${i}.id`, message: `page ${i + 1} has an empty id \u2014 every page needs a non-empty, unique id` });
545
+ return;
546
+ }
547
+ if (isUnsafePageId(page.id)) {
548
+ errors.push({
549
+ path: `pages.${i}.id`,
550
+ pageId: page.id,
551
+ message: `page id "${page.id}" is not a safe file name \u2014 ids used as page/asset file names must not contain path separators or ".."`
552
+ });
553
+ return;
554
+ }
555
+ const indices = seen.get(page.id);
556
+ if (indices) indices.push(i);
557
+ else seen.set(page.id, [i]);
558
+ });
559
+ for (const [id, indices] of seen) {
560
+ if (indices.length < 2) continue;
561
+ errors.push({
562
+ path: "pages",
563
+ pageId: id,
564
+ message: `duplicate page id "${id}" used by ${indices.length} pages (positions ${indices.map((i) => i + 1).join(", ")}) \u2014 page ids must be unique within a spec`
565
+ });
566
+ }
567
+ return errors;
568
+ }
569
+ var HEADING_MAX_CHARS = CAPACITY.headingMaxChars;
570
+ function specHeadingLength(heading) {
571
+ return heading.length;
572
+ }
573
+ function checkHeadings(spec) {
574
+ const errors = [];
575
+ spec.pages.forEach((page, i) => {
576
+ if (page.heading.trim() === "") {
577
+ errors.push({ path: `pages.${i}.heading`, pageId: page.id, message: `page "${page.id}" is missing a required heading` });
578
+ return;
579
+ }
580
+ const length = specHeadingLength(page.heading);
581
+ if (length > HEADING_MAX_CHARS) {
582
+ errors.push({
583
+ path: `pages.${i}.heading`,
584
+ pageId: page.id,
585
+ message: `page "${page.id}" heading is ${length} characters, exceeds the ${HEADING_MAX_CHARS}-character limit \u2014 tighten it into a short, assertive phrase`
586
+ });
587
+ }
588
+ });
589
+ return errors;
590
+ }
591
+ function checkTheme(spec) {
592
+ const themeId = resolveSpecThemeId(spec);
593
+ const installed = getInstalledThemeIds();
594
+ if (installed.includes(themeId)) return [];
595
+ const message = themeId === "bloom" ? 'theme id "bloom" was removed \u2014 run `pptwise migrate <input> -o <output>` to rewrite it to "classroom"' : `unknown theme "${themeId}" \u2014 available: ${installed.join(", ")} (see \`pptwise themes\`)`;
596
+ return [{ path: "theme", message }];
597
+ }
598
+ var LAYOUT_IDS = Object.keys(LAYOUT_REGISTRY);
599
+ function checkFocusVocabulary(spec, strategy) {
600
+ const tendencies = STRATEGY_DEFINITIONS[strategy].tendencies;
601
+ const errors = [];
602
+ spec.pages.forEach((page, i) => {
603
+ if (page.focus === void 0) return;
604
+ if (page.focus === "logo_wall") {
605
+ errors.push({
606
+ path: `pages.${i}.focus`,
607
+ pageId: page.id,
608
+ message: 'component type "logo_wall" was removed \u2014 run `pptwise migrate <input> -o <output>` to rewrite it to "image_grid"'
609
+ });
610
+ return;
611
+ }
612
+ if (page.focus === "banner-heading") {
613
+ errors.push({
614
+ path: `pages.${i}.focus`,
615
+ pageId: page.id,
616
+ message: 'layout "banner-heading" was removed \u2014 run `pptwise migrate <input> -o <output>` to rewrite it to "two-column"'
617
+ });
618
+ return;
619
+ }
620
+ if (tendencies.includes(page.focus) || COMPONENT_TYPES.includes(page.focus) || LAYOUT_IDS.includes(page.focus)) {
621
+ return;
622
+ }
623
+ errors.push({
624
+ path: `pages.${i}.focus`,
625
+ pageId: page.id,
626
+ message: `unknown focus "${page.focus}" for strategy "${strategy}" \u2014 expected one of this strategy's tendencies (${tendencies.join(", ")}), a component type (${COMPONENT_TYPES.join(", ")}), or a layout id (${LAYOUT_IDS.join(", ")})`
627
+ });
628
+ });
629
+ return errors;
630
+ }
631
+ function declaredBeatContentPages(spec) {
632
+ const result = [];
633
+ spec.pages.forEach((page, index) => {
634
+ if (page.type === "content" && page.beat !== void 0) {
635
+ result.push({ index, id: page.id, beat: page.beat });
636
+ }
637
+ });
638
+ return result;
639
+ }
640
+ function checkAlternatePolicy(spec, strategy) {
641
+ const seq = declaredBeatContentPages(spec);
642
+ const errors = [];
643
+ let i = 0;
644
+ while (i < seq.length) {
645
+ let j = i + 1;
646
+ while (j < seq.length && seq[j].beat === seq[i].beat) j++;
647
+ const runLength = j - i;
648
+ if (runLength >= 3) {
649
+ const members = seq.slice(i, j);
650
+ errors.push({
651
+ path: "pages",
652
+ pageId: members[0].id,
653
+ message: `${runLength} consecutive content pages declare beat "${seq[i].beat}" (${members.map((m) => m.id).join(", ")}) \u2014 strategy "${strategy}" requires beat to alternate, vary at least one of them`
654
+ });
655
+ }
656
+ i = j;
657
+ }
658
+ return errors;
659
+ }
660
+ function checkAnchorOpenPolicy(spec, strategy) {
661
+ const firstContentIndex = spec.pages.findIndex((page) => page.type === "content");
662
+ if (firstContentIndex === -1) return [];
663
+ const firstContent = spec.pages[firstContentIndex];
664
+ if (firstContent.beat === void 0 || firstContent.beat === "anchor") return [];
665
+ return [
666
+ {
667
+ path: `pages.${firstContentIndex}.beat`,
668
+ pageId: firstContent.id,
669
+ message: `first content page declares beat "${firstContent.beat}" \u2014 strategy "${strategy}" requires the deck to open its first content page on "anchor" beat when a beat is declared`
670
+ }
671
+ ];
672
+ }
673
+ function checkAnchorSparsePolicy(spec, strategy) {
674
+ const declared = declaredBeatContentPages(spec);
675
+ if (declared.length === 0) return [];
676
+ const anchorPages = declared.filter((page) => page.beat === "anchor");
677
+ if (anchorPages.length / declared.length <= 0.5) return [];
678
+ const pct = Math.round(anchorPages.length / declared.length * 100);
679
+ return [
680
+ {
681
+ path: "pages",
682
+ // First offending anchor page, same "representative pageId" shape
683
+ // checkAlternatePolicy's own issue carries (members[0]!.id there) —
684
+ // this gate's violation is deck-wide (a ratio, not one page), but a
685
+ // representative id still gives a CLI/agent caller something to jump
686
+ // to rather than only a bare "pages" path.
687
+ pageId: anchorPages[0].id,
688
+ message: `${anchorPages.length} of ${declared.length} content pages with a declared beat are "anchor" (${pct}%: ${anchorPages.map((page) => page.id).join(", ")}) \u2014 strategy "${strategy}" requires "anchor" to stay a minority of declared beats, vary some to "dense" or "breathing"`
689
+ }
690
+ ];
691
+ }
692
+ function checkBeatRotation(spec, strategy) {
693
+ const policy = STRATEGY_DEFINITIONS[strategy].beatPolicy;
694
+ switch (policy) {
695
+ case "uniform-dense":
696
+ case "repetition-ok":
697
+ return [];
698
+ case "alternate":
699
+ return checkAlternatePolicy(spec, strategy);
700
+ case "anchor-open":
701
+ return checkAnchorOpenPolicy(spec, strategy);
702
+ case "anchor-sparse":
703
+ return checkAnchorSparsePolicy(spec, strategy);
704
+ default: {
705
+ const exhaustive = policy;
706
+ throw new Error(`unhandled beat policy: ${String(exhaustive)}`);
707
+ }
708
+ }
709
+ }
710
+ var SPEC_PAGE_COUNT_RANGE = {
711
+ dense: { min: 8, max: 30 },
712
+ balanced: { min: 6, max: 24 },
713
+ spacious: { min: 4, max: 16 }
714
+ };
715
+ function checkPageCount(spec, pacing) {
716
+ const { min, max } = SPEC_PAGE_COUNT_RANGE[pacing];
717
+ const n = spec.pages.length;
718
+ if (n >= min && n <= max) return [];
719
+ return [
720
+ {
721
+ path: "pages",
722
+ message: `spec has ${n} pages \u2014 "${pacing}" pacing expects ${min}-${max} pages, change pacing or add/remove pages`
723
+ }
724
+ ];
725
+ }
726
+ function pageIdFromRawInput(input, index) {
727
+ if (typeof input !== "object" || input === null) return void 0;
728
+ const pages = input.pages;
729
+ if (!Array.isArray(pages)) return void 0;
730
+ const page = pages[index];
731
+ if (typeof page !== "object" || page === null) return void 0;
732
+ const id = page.id;
733
+ return typeof id === "string" ? id : void 0;
734
+ }
735
+ function validateSpec(input) {
736
+ const rootAliasPass = normalizeDeckRootAliases(input);
737
+ const narrativeShapePass = normalizeNarrativeShape(rootAliasPass.value);
738
+ const normalizedInput = narrativeShapePass.value;
739
+ const normalized = [...rootAliasPass.normalized, ...narrativeShapePass.normalized];
740
+ const withNormalized = (result) => normalized.length > 0 ? { ...result, normalized } : result;
741
+ const r = DeckSpecSchema.safeParse(normalizedInput);
742
+ if (!r.success) {
743
+ const errors = r.error.issues.map((issue) => {
744
+ const path = issue.path.join(".");
745
+ const m = /^pages\.(\d+)/.exec(path);
746
+ return { path, message: issue.message, pageId: m ? pageIdFromRawInput(normalizedInput, Number(m[1])) : void 0 };
747
+ });
748
+ return withNormalized({ ok: false, errors });
749
+ }
750
+ const spec = r.data;
751
+ const emptyErrors = checkPagesNonEmpty(spec);
752
+ if (emptyErrors.length > 0) return withNormalized({ ok: false, errors: emptyErrors });
753
+ const boundaryErrors = checkBoundaryTypes(spec);
754
+ if (boundaryErrors.length > 0) return withNormalized({ ok: false, errors: boundaryErrors });
755
+ const idErrors = checkPageIds(spec);
756
+ if (idErrors.length > 0) return withNormalized({ ok: false, errors: idErrors });
757
+ const headingErrors = checkHeadings(spec);
758
+ if (headingErrors.length > 0) return withNormalized({ ok: false, errors: headingErrors });
759
+ const themeErrors = checkTheme(spec);
760
+ if (themeErrors.length > 0) return withNormalized({ ok: false, errors: themeErrors });
761
+ let resolvedAxes;
762
+ try {
763
+ resolvedAxes = resolveNarrative(spec.narrative);
764
+ } catch (err) {
765
+ if (!(err instanceof PptwiseError)) throw err;
766
+ return withNormalized({ ok: false, errors: [{ path: "narrative", message: err.message }] });
767
+ }
768
+ const beatErrors = checkBeatRotation(spec, resolvedAxes.strategy);
769
+ if (beatErrors.length > 0) return withNormalized({ ok: false, errors: beatErrors });
770
+ const focusErrors = checkFocusVocabulary(spec, resolvedAxes.strategy);
771
+ if (focusErrors.length > 0) return withNormalized({ ok: false, errors: focusErrors });
772
+ const pageCountErrors = checkPageCount(spec, resolvedAxes.pacing);
773
+ if (pageCountErrors.length > 0) return withNormalized({ ok: false, errors: pageCountErrors });
774
+ return withNormalized({ ok: true, spec, errors: [] });
775
+ }
776
+
777
+ // src/spec/assemble.ts
778
+ function stableHash(s) {
779
+ let h = 5381;
780
+ for (let i = 0; i < s.length; i++) h = (h << 5) + h + s.charCodeAt(i) | 0;
781
+ return Math.abs(h);
782
+ }
783
+ function generateSeed(filename, pageIds) {
784
+ return stableHash([filename ?? "", ...pageIds].join("\n"));
785
+ }
786
+ var LOCKED_KEYS = ["type", "heading"];
787
+ function assembleDeck(spec, pages) {
788
+ const validated = validateSpec(spec);
789
+ if (!validated.ok) {
790
+ throw new PptwiseError(formatInvalidSpecError(validated.errors));
791
+ }
792
+ const deckSpec = validated.spec;
793
+ for (const page of deckSpec.pages) {
794
+ const raw = pages[page.id];
795
+ if (raw === void 0) continue;
796
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
797
+ throw new PptwiseError(`page "${page.id}": page content must be an object`);
798
+ }
799
+ for (const key of LOCKED_KEYS) {
800
+ if (Object.hasOwn(raw, key)) {
801
+ throw new PptwiseError(`page "${page.id}": "${key}" is locked by the spec \u2014 remove it from the page file`);
802
+ }
803
+ }
804
+ }
805
+ const specIds = new Set(deckSpec.pages.map((page) => page.id));
806
+ const orphanIds = Object.keys(pages).filter((id) => !specIds.has(id));
807
+ if (orphanIds.length > 0) {
808
+ throw new PptwiseError(
809
+ `orphan page id${orphanIds.length === 1 ? "" : "s"} ${orphanIds.map((id) => `"${id}"`).join(", ")} \u2014 not in the spec, delete the page file or add the page to the spec`
810
+ );
811
+ }
812
+ const slides = deckSpec.pages.map((page) => buildSlide(page, pages[page.id]));
813
+ const generatedSeed = deckSpec.seed === void 0 ? generateSeed(deckSpec.filename, deckSpec.pages.map((page) => page.id)) : void 0;
814
+ const seed = deckSpec.seed ?? generatedSeed;
815
+ const rawIr = {
816
+ version: "4",
817
+ ...deckSpec.narrative !== void 0 ? { narrative: deckSpec.narrative } : {},
818
+ ...deckSpec.theme !== void 0 ? { theme: { id: deckSpec.theme } } : {},
819
+ ...deckSpec.filename !== void 0 ? { filename: deckSpec.filename } : {},
820
+ ...deckSpec.brand !== void 0 ? { brand: deckSpec.brand } : {},
821
+ ...deckSpec.branding !== void 0 ? { branding: deckSpec.branding } : {},
822
+ meta: deckSpec.meta,
823
+ seed,
824
+ slides
825
+ };
826
+ const parsed = PptxIRSchema.safeParse(rawIr);
827
+ if (!parsed.success) {
828
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
829
+ throw new PptwiseError(`assembled deck did not produce valid IR:
830
+ ${detail}`);
831
+ }
832
+ const { ir, materializedCount } = materializeEffectiveLayouts(parsed.data);
833
+ return {
834
+ ir,
835
+ ...generatedSeed !== void 0 ? { generatedSeed } : {},
836
+ ...materializedCount > 0 ? { materializedLayoutCount: materializedCount } : {}
837
+ };
838
+ }
839
+ function materializeEffectiveLayouts(ir) {
840
+ let materializedCount = 0;
841
+ const slides = ir.slides.map((slide, index) => {
842
+ if (slide.layout !== void 0) return slide;
843
+ const effectiveLayoutId = resolveEffectiveLayoutId(ir, slide, index);
844
+ if (effectiveLayoutId === null) return slide;
845
+ materializedCount++;
846
+ return { ...slide, layout: effectiveLayoutId };
847
+ });
848
+ return materializedCount === 0 ? { ir, materializedCount } : { ir: { ...ir, slides }, materializedCount };
849
+ }
850
+ function buildSlide(page, raw) {
851
+ if (raw === void 0) {
852
+ return {
853
+ id: page.id,
854
+ type: page.type,
855
+ heading: page.heading,
856
+ placeholder: true,
857
+ ...page.beat !== void 0 ? { beat: page.beat } : {},
858
+ ...page.summary !== void 0 ? { subheading: page.summary } : {}
859
+ };
860
+ }
861
+ return {
862
+ id: page.id,
863
+ type: page.type,
864
+ heading: page.heading,
865
+ ...page.beat !== void 0 ? { beat: page.beat } : {},
866
+ ...page.type !== "content" && page.summary !== void 0 ? { subheading: page.summary } : {},
867
+ ...raw.components !== void 0 ? { components: raw.components } : {},
868
+ ...raw.layout !== void 0 ? { layout: raw.layout } : {},
869
+ ...raw.arrangement !== void 0 ? { arrangement: raw.arrangement } : {},
870
+ ...raw.background !== void 0 ? { background: raw.background } : {},
871
+ ...raw.image_side !== void 0 ? { image_side: raw.image_side } : {},
872
+ ...raw.footnote !== void 0 ? { footnote: raw.footnote } : {},
873
+ ...raw.notes !== void 0 ? { notes: raw.notes } : {}
874
+ };
875
+ }
876
+ var UNTITLED_HEADING = "Untitled";
877
+ function disassembleDeck(ir) {
878
+ const pages = {};
879
+ const pageSpecs = ir.slides.map((slide, index) => {
880
+ const id = slide.id ?? `p-${index + 1}-${slide.type}`;
881
+ const heading = slide.heading !== void 0 && slide.heading.trim() !== "" ? slide.heading : UNTITLED_HEADING;
882
+ const pageSpec = {
883
+ id,
884
+ type: slide.type,
885
+ heading,
886
+ ...slide.beat !== void 0 ? { beat: slide.beat } : {},
887
+ ...(slide.placeholder === true || slide.type !== "content") && slide.subheading !== void 0 ? { summary: slide.subheading } : {}
888
+ };
889
+ if (slide.placeholder !== true) pages[id] = extractPageContent(slide);
890
+ return pageSpec;
891
+ });
892
+ const spec = {
893
+ version: "1",
894
+ // `ir.narrative` (v4 field, vocabulary-v4 rename) carries straight into
895
+ // the deck spec's own `narrative` field — its value is
896
+ // already in the new strategy/pacing vocabulary, no remapping needed.
897
+ ...ir.narrative !== void 0 ? { narrative: ir.narrative } : {},
898
+ theme: ir.theme.id,
899
+ filename: ir.filename,
900
+ ...ir.seed !== void 0 ? { seed: ir.seed } : {},
901
+ ...ir.brand !== void 0 ? { brand: ir.brand } : {},
902
+ ...ir.branding !== void 0 ? { branding: ir.branding } : {},
903
+ meta: ir.meta,
904
+ pages: pageSpecs
905
+ };
906
+ return { spec, pages };
907
+ }
908
+ function extractPageContent(slide) {
909
+ const content = {};
910
+ if (slide.components.length > 0) content.components = slide.components;
911
+ if (slide.layout !== void 0) content.layout = slide.layout;
912
+ if (slide.arrangement !== void 0) content.arrangement = slide.arrangement;
913
+ if (slide.background !== void 0) content.background = slide.background;
914
+ if (slide.image_side !== void 0) content.image_side = slide.image_side;
915
+ if (slide.footnote !== void 0) content.footnote = slide.footnote;
916
+ if (slide.notes !== void 0) content.notes = slide.notes;
917
+ return content;
918
+ }
919
+
920
+ // src/spec/migrate.ts
921
+ function migrateDeckPlanToSpec(raw) {
922
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
923
+ const { scenario, pages, ...rest } = raw;
924
+ const result = { ...rest };
925
+ if (scenario !== void 0) result.narrative = scenario;
926
+ if (Array.isArray(pages)) {
927
+ result.pages = pages.map(migratePageRhythmToBeat);
928
+ } else if (pages !== void 0) {
929
+ result.pages = pages;
930
+ }
931
+ return migrateBannerHeadingToTwoColumn(
932
+ migrateLogoWallToImageGrid(migrateBloomToClassroom(migrateChromeToBranding(result)))
933
+ );
934
+ }
935
+ function migratePageRhythmToBeat(page) {
936
+ if (typeof page !== "object" || page === null || Array.isArray(page)) return page;
937
+ const { rhythm, ...rest } = page;
938
+ const next = { ...rest };
939
+ if (rhythm !== void 0) next.beat = rhythm;
940
+ return next;
941
+ }
942
+
943
+ // src/svg/asset-brief.ts
944
+ var DUMMY_PNG_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
945
+ function extractImageFrames(markup) {
946
+ const Parser = getPlatform().domParser ?? globalThis.DOMParser;
947
+ if (!Parser) {
948
+ throw new Error(
949
+ 'DOMParser unavailable \u2014 in Node, call installNodePlatform() from "@liustack/pptwise/node" first (the pptwise CLI does this automatically)'
950
+ );
951
+ }
952
+ const doc = new Parser().parseFromString(markup, "image/svg+xml");
953
+ const byAssetId = /* @__PURE__ */ new Map();
954
+ const visit = (el, ox, oy, os) => {
955
+ const { dx, dy, scale } = parseTransform(el);
956
+ const ax = ox + os * dx;
957
+ const ay = oy + os * dy;
958
+ const as = os * scale;
959
+ if (el.tagName.toLowerCase() === "image") {
960
+ const href = el.getAttribute("href") ?? "";
961
+ const hashAt = href.indexOf("#");
962
+ if (hashAt !== -1) {
963
+ const assetId = href.slice(hashAt + 1);
964
+ const frame = {
965
+ x: ax + as * Number(el.getAttribute("x") ?? 0),
966
+ y: ay + as * Number(el.getAttribute("y") ?? 0),
967
+ w: as * Number(el.getAttribute("width") ?? 0),
968
+ h: as * Number(el.getAttribute("height") ?? 0),
969
+ preserveAspectRatio: el.getAttribute("preserveAspectRatio") ?? "xMidYMid meet"
970
+ };
971
+ const list = byAssetId.get(assetId);
972
+ if (list) list.push(frame);
973
+ else byAssetId.set(assetId, [frame]);
974
+ }
975
+ }
976
+ for (const child of Array.from(el.children)) visit(child, ax, ay, as);
977
+ };
978
+ visit(doc.documentElement, 0, 0, 1);
979
+ return byAssetId;
980
+ }
981
+ function gcd(a, b) {
982
+ let x = Math.abs(Math.round(a));
983
+ let y = Math.abs(Math.round(b));
984
+ while (y !== 0) [x, y] = [y, x % y];
985
+ return x || 1;
986
+ }
987
+ function formatAspect(w, h) {
988
+ if (h <= 0 || w <= 0) return `${w}:${h}`;
989
+ const ratio = w / h;
990
+ const TOL = 0.01;
991
+ for (let d = 1; d <= 12; d++) {
992
+ const n = Math.round(ratio * d);
993
+ if (n >= 1 && Math.abs(ratio - n / d) <= TOL) {
994
+ const g = gcd(n, d);
995
+ return `${n / g}:${d / g}`;
996
+ }
997
+ }
998
+ return `${ratio.toFixed(2)}:1`;
999
+ }
1000
+ function toFrame(raw) {
1001
+ const w = Math.round(raw.w);
1002
+ const h = Math.round(raw.h);
1003
+ return { x: Math.round(raw.x), y: Math.round(raw.y), w, h, aspect: formatAspect(w, h) };
1004
+ }
1005
+ function buildFit(mode, aspect) {
1006
+ if (mode === "cover") {
1007
+ return {
1008
+ mode,
1009
+ note: aspect ? `Cover crop (xMidYMid slice): generate close to ${aspect} to avoid any crop \u2014 otherwise the renderer center-crops to fill the frame, so keep essential subject matter within the center safe zone.` : `Cover crop (xMidYMid slice): the renderer center-crops to fill the frame \u2014 keep essential subject matter centered.`
1010
+ };
1011
+ }
1012
+ return {
1013
+ mode,
1014
+ note: aspect ? `Contain fit (xMidYMid meet): the renderer letterboxes to show the whole image inside the frame with no crop \u2014 match ${aspect} to avoid empty margins.` : `Contain fit (xMidYMid meet): the renderer letterboxes to show the whole image inside the frame with no crop.`
1015
+ };
1016
+ }
1017
+ function buildPalette(colors) {
1018
+ const candidates = [colors.bg, colors.surface, colors.panel, colors.primary, colors.accent, colors.text, colors.muted, colors.border];
1019
+ const hexes = [...new Set(candidates.filter((c) => Boolean(c)))];
1020
+ return { hexes, primary: colors.primary, accent: colors.accent };
1021
+ }
1022
+ function themeLabel(id) {
1023
+ return CANONICAL_THEME_IDS.includes(id) ? THEME_LABELS[id] : id;
1024
+ }
1025
+ function humanizeMotif(motif) {
1026
+ return motif.replace(/-motif$/, "").replace(/-/g, " ");
1027
+ }
1028
+ function buildMood(themeId, themeDef) {
1029
+ const label = themeLabel(themeId);
1030
+ const tagPhrase = themeDef.tags.length > 0 ? ` (${themeDef.tags.join(", ")})` : "";
1031
+ const motifPhrase = themeDef.motif ? ` with a ${humanizeMotif(themeDef.motif)} decorative motif` : "";
1032
+ return { tags: themeDef.tags, description: `${label} theme${tagPhrase}${motifPhrase}.` };
1033
+ }
1034
+ function buildPrompt(mood, palette, frame, fit) {
1035
+ const supporting = palette.hexes.filter((h) => h !== palette.primary && h !== palette.accent);
1036
+ const paletteText = `Color palette: primary ${palette.primary}, accent ${palette.accent}${supporting.length > 0 ? `, supporting tones ${supporting.join(", ")}` : ""}.`;
1037
+ const compositionText = frame ? `Compose for a ${frame.aspect} frame. ${fit.note}` : `Frame geometry unavailable \u2014 this image slot was not rendered under the deck's currently selected layout; generate at a versatile aspect ratio and verify placement once a layout renders it.`;
1038
+ return `${mood.description} ${paletteText} ${compositionText}`;
1039
+ }
1040
+ function buildAssetBrief(ir) {
1041
+ const themeDef = getThemeDefinition(ir.theme.id);
1042
+ const tokens = resolveStyle(ir.theme.id, ir.theme.style);
1043
+ const palette = buildPalette(tokens.colors);
1044
+ const mood = buildMood(ir.theme.id, themeDef);
1045
+ const occurrences = [];
1046
+ ir.slides.forEach((slide, slideIndex) => {
1047
+ for (const component of slide.components) {
1048
+ if (component.type === "image") occurrences.push({ slideIndex, component });
1049
+ }
1050
+ });
1051
+ const overrides = {};
1052
+ for (const { component } of occurrences) {
1053
+ overrides[component.asset_id] = { src: `${DUMMY_PNG_DATA_URI}#${component.asset_id}` };
1054
+ }
1055
+ const renderIr = { ...ir, assets: { images: { ...ir.assets.images, ...overrides } } };
1056
+ const framesBySlide = /* @__PURE__ */ new Map();
1057
+ for (const slideIndex of new Set(occurrences.map((o) => o.slideIndex))) {
1058
+ framesBySlide.set(slideIndex, extractImageFrames(renderSlideSvg(renderIr, slideIndex)));
1059
+ }
1060
+ const items = [];
1061
+ ir.slides.forEach((slide, slideIndex) => {
1062
+ const groups = /* @__PURE__ */ new Map();
1063
+ for (const component of slide.components) {
1064
+ if (component.type !== "image") continue;
1065
+ const list = groups.get(component.asset_id);
1066
+ if (list) list.push(component);
1067
+ else groups.set(component.asset_id, [component]);
1068
+ }
1069
+ if (groups.size === 0) return;
1070
+ const page = { index: slideIndex, id: slide.id, type: slide.type, heading: slide.heading };
1071
+ const frameMap = framesBySlide.get(slideIndex);
1072
+ const isMissing = (assetId) => !ir.assets.images[assetId]?.src;
1073
+ const altOf = (assetId) => ir.assets.images[assetId]?.alt;
1074
+ for (const [assetId, group] of groups) {
1075
+ const frames = frameMap?.get(assetId) ?? [];
1076
+ if (group.length === 1) {
1077
+ const raw = frames[0];
1078
+ const frame = raw ? toFrame(raw) : void 0;
1079
+ const fit = buildFit(group[0].fit, frame?.aspect);
1080
+ items.push({
1081
+ page,
1082
+ asset_id: assetId,
1083
+ alt: altOf(assetId),
1084
+ kind: "image",
1085
+ missing: isMissing(assetId),
1086
+ rendered: frame !== void 0,
1087
+ frame,
1088
+ suggested_pixels: frame ? { w: frame.w * 2, h: frame.h * 2 } : void 0,
1089
+ fit,
1090
+ palette,
1091
+ mood,
1092
+ suggested_prompt: buildPrompt(mood, palette, frame, fit)
1093
+ });
1094
+ continue;
1095
+ }
1096
+ const occurrenceCount = group.length;
1097
+ const sharedFit = group[0].fit;
1098
+ const missing = isMissing(assetId);
1099
+ for (const raw of frames) {
1100
+ const frame = toFrame(raw);
1101
+ const fit = buildFit(sharedFit, frame.aspect);
1102
+ items.push({
1103
+ page,
1104
+ asset_id: assetId,
1105
+ alt: altOf(assetId),
1106
+ kind: "image",
1107
+ missing,
1108
+ rendered: true,
1109
+ frame,
1110
+ suggested_pixels: { w: frame.w * 2, h: frame.h * 2 },
1111
+ fit,
1112
+ palette,
1113
+ mood,
1114
+ shared: true,
1115
+ occurrenceCount,
1116
+ suggested_prompt: buildPrompt(mood, palette, frame, fit)
1117
+ });
1118
+ }
1119
+ for (let i = frames.length; i < occurrenceCount; i++) {
1120
+ const fit = buildFit(sharedFit, void 0);
1121
+ items.push({
1122
+ page,
1123
+ asset_id: assetId,
1124
+ alt: altOf(assetId),
1125
+ kind: "image",
1126
+ missing,
1127
+ rendered: false,
1128
+ fit,
1129
+ palette,
1130
+ mood,
1131
+ shared: true,
1132
+ occurrenceCount,
1133
+ suggested_prompt: buildPrompt(mood, palette, void 0, fit)
1134
+ });
1135
+ }
1136
+ }
1137
+ });
1138
+ return { theme: ir.theme.id, items };
1139
+ }
1140
+
1141
+ export {
1142
+ VERSION,
1143
+ PptxIRV3Schema,
1144
+ migrateChromeToBranding,
1145
+ migrateBloomToClassroom,
1146
+ migrateLogoWallToImageGrid,
1147
+ migrateBannerHeadingToTwoColumn,
1148
+ migrateIrV3ToV4,
1149
+ slugify,
1150
+ extractBrandTheme,
1151
+ BrandThemeFileSchema,
1152
+ parseBrandThemeFile,
1153
+ registerBrandThemeFile,
1154
+ PageSpecSchema,
1155
+ DeckSpecSchema,
1156
+ specJsonSchema,
1157
+ formatSpecIssues,
1158
+ formatInvalidSpecError,
1159
+ resolveSpecThemeId,
1160
+ SPEC_PAGE_COUNT_RANGE,
1161
+ validateSpec,
1162
+ assembleDeck,
1163
+ disassembleDeck,
1164
+ migrateDeckPlanToSpec,
1165
+ buildAssetBrief
1166
+ };
1167
+ //# sourceMappingURL=chunk-M35M4QUC.js.map