@liustack/pptfast 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/README.zh-CN.md +23 -1
- package/dist/browser.js +319 -0
- package/dist/{chunk-7V73LHUQ.js → chunk-7B3Y4FZZ.js} +15525 -14254
- package/dist/chunk-7B3Y4FZZ.js.map +1 -0
- package/dist/{chunk-2ZOMS6G3.js → chunk-PGSVXCDR.js} +19 -6
- package/dist/chunk-PGSVXCDR.js.map +1 -0
- package/dist/cli.js +24 -4
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +107 -1411
- package/dist/index.js +2 -2
- package/dist/{pixel-audit-ZRFTV4V7.js → pixel-audit-YHV3VMUW.js} +2 -2
- package/dist/validate.d.ts +1423 -0
- package/dist/validate.js +68 -0
- package/package.json +10 -2
- package/dist/chunk-2ZOMS6G3.js.map +0 -1
- package/dist/chunk-7V73LHUQ.js.map +0 -1
- /package/dist/{pixel-audit-ZRFTV4V7.js.map → pixel-audit-YHV3VMUW.js.map} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AssetsSchema,
|
|
3
|
+
BEAT_VALUES,
|
|
3
4
|
BrandSchema,
|
|
4
5
|
CAPACITY,
|
|
5
6
|
COMPONENT_TYPES,
|
|
@@ -14,10 +15,10 @@ import {
|
|
|
14
15
|
getInstalledThemeIds,
|
|
15
16
|
resolveEffectiveLayoutId,
|
|
16
17
|
resolveNarrative
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-7B3Y4FZZ.js";
|
|
18
19
|
|
|
19
20
|
// src/version.ts
|
|
20
|
-
var VERSION = "0.
|
|
21
|
+
var VERSION = "0.7.0";
|
|
21
22
|
|
|
22
23
|
// src/ir/legacy-v3.ts
|
|
23
24
|
import { z } from "zod";
|
|
@@ -75,7 +76,6 @@ function migrateIrV3ToV4(v3) {
|
|
|
75
76
|
// src/plan/index.ts
|
|
76
77
|
import { z as z2 } from "zod";
|
|
77
78
|
var PAGE_TYPES = ["cover", "chapter", "content", "ending"];
|
|
78
|
-
var BEAT_VALUES = ["anchor", "dense", "breathing"];
|
|
79
79
|
var PageSpecSchema = z2.object({
|
|
80
80
|
id: z2.string(),
|
|
81
81
|
type: z2.enum(PAGE_TYPES),
|
|
@@ -83,10 +83,20 @@ var PageSpecSchema = z2.object({
|
|
|
83
83
|
/** One of the three beat values, or omitted entirely — an omitted
|
|
84
84
|
* beat is never a hard-gate violation on its own (see
|
|
85
85
|
* {@link checkBeatRotation}'s policy functions below). It gets
|
|
86
|
-
* auto-alternated at assemble time (W5 task 3, not this task
|
|
86
|
+
* auto-alternated at assemble time (W5 task 3, not this task — still
|
|
87
|
+
* unimplemented as of the P1 variety wave's task 1). Renamed
|
|
87
88
|
* from `rhythm` (vocabulary-v4 rename, spec §4.3/§6/§8.1) — same
|
|
88
89
|
* three values, same semantics, page-level term only, distinct from
|
|
89
|
-
* the deck-level `pacing` axis.
|
|
90
|
+
* the deck-level `pacing` axis. A *declared* value here is no longer
|
|
91
|
+
* spec-only advisory material (P1 variety wave, task 1): `assembleDeck`
|
|
92
|
+
* (`./assemble.ts`) now carries it straight into the IR's own
|
|
93
|
+
* `Slide.beat` field, where it combines with a soft selection-weight
|
|
94
|
+
* onto layout picking (`Math.max`, not multiplication — see
|
|
95
|
+
* `SlideSchema.beat`'s own doc comment, `../ir/index.ts`, and
|
|
96
|
+
* `BEAT_TENDENCY_WEIGHT`'s in `../svg/effective-layout.ts` for why) —
|
|
97
|
+
* the checks below (rotation shape) and that downstream weighting
|
|
98
|
+
* (which archetypes a given beat favors) are two independent consumers
|
|
99
|
+
* of the same declared value, not two views of one mechanism. */
|
|
90
100
|
beat: z2.enum(BEAT_VALUES).optional(),
|
|
91
101
|
/** Optional authoring hint pointing fill/select at a preferred
|
|
92
102
|
* component type or layout id — see {@link checkFocusVocabulary}. */
|
|
@@ -466,6 +476,7 @@ function buildSlide(page, raw) {
|
|
|
466
476
|
type: page.type,
|
|
467
477
|
heading: page.heading,
|
|
468
478
|
placeholder: true,
|
|
479
|
+
...page.beat !== void 0 ? { beat: page.beat } : {},
|
|
469
480
|
...page.summary !== void 0 ? { subheading: page.summary } : {}
|
|
470
481
|
};
|
|
471
482
|
}
|
|
@@ -473,6 +484,7 @@ function buildSlide(page, raw) {
|
|
|
473
484
|
id: page.id,
|
|
474
485
|
type: page.type,
|
|
475
486
|
heading: page.heading,
|
|
487
|
+
...page.beat !== void 0 ? { beat: page.beat } : {},
|
|
476
488
|
...raw.components !== void 0 ? { components: raw.components } : {},
|
|
477
489
|
...raw.layout !== void 0 ? { layout: raw.layout } : {},
|
|
478
490
|
...raw.arrangement !== void 0 ? { arrangement: raw.arrangement } : {},
|
|
@@ -492,6 +504,7 @@ function disassembleDeck(ir) {
|
|
|
492
504
|
id,
|
|
493
505
|
type: slide.type,
|
|
494
506
|
heading,
|
|
507
|
+
...slide.beat !== void 0 ? { beat: slide.beat } : {},
|
|
495
508
|
...slide.placeholder === true && slide.subheading !== void 0 ? { summary: slide.subheading } : {}
|
|
496
509
|
};
|
|
497
510
|
if (slide.placeholder !== true) pages[id] = extractPageContent(slide);
|
|
@@ -561,4 +574,4 @@ export {
|
|
|
561
574
|
disassembleDeck,
|
|
562
575
|
migrateDeckPlanToSpec
|
|
563
576
|
};
|
|
564
|
-
//# sourceMappingURL=chunk-
|
|
577
|
+
//# sourceMappingURL=chunk-PGSVXCDR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/version.ts","../src/ir/legacy-v3.ts","../src/ir/migrate.ts","../src/plan/index.ts","../src/plan/assemble.ts","../src/plan/migrate.ts"],"sourcesContent":["export const VERSION = \"0.7.0\"\n","import { z } from \"zod\"\nimport {\n AssetsSchema,\n BrandSchema,\n MetaSchema,\n NarrativeProfileInputSchema,\n SlideSchema,\n ThemeSchema,\n} from \"./index\"\n\n/**\n * The frozen IR v3 top-level shape (vocabulary-v4 rename, task 1 — spec\n * §9.3: \"v3 已冻结... 顶层字段和枚举改名必须进入新的 IR 版本,不能在 v3\n * 内静默改变含义\"). `./index.ts`'s `PptxIRSchema` is v4 now — this module\n * exists only so a genuinely v3-shaped document still has somewhere to parse\n * against: `migrateIrV3ToV4`'s input type (`./migrate.ts`), and the\n * v3-hard-reject path's own tests (constructing a *valid* v3 IR to prove the\n * reject fires on version alone, not on some other schema defect).\n *\n * `validateIr` (`src/api.ts`) never calls this schema itself — an incoming\n * v3 document (`version === \"3\"`) is hard-rejected before any schema parse\n * runs at all (spec §9.3), full stop. This schema is a migration-tooling and\n * test fixture, not a second accepted input shape.\n *\n * Every field but `version` and `scenario` is byte-identical to `./index.ts`'s\n * v4 `PptxIRSchema` (spec §9.1: \"其余 IR 字段保持不变\") — reuses the exact\n * same `ThemeSchema`/`MetaSchema`/`AssetsSchema`/`BrandSchema`/`SlideSchema`\n * instances rather than redefining them, so there is no way for this frozen\n * shape to silently drift from the fields it shares with v4.\n */\nexport const PptxIRV3Schema = z\n .object({\n version: z.literal(\"3\").default(\"3\"),\n filename: z.string().default(\"presentation\"),\n // Pre-rename field name and axis vocabulary (mode/delivery/audience) —\n // frozen as of the 0.3.0 release, spec §9.3. Same open-schema/closed-\n // semantic split `NarrativeProfileInputSchema` documents: the actual\n // mode/delivery/audience enum closure happened at `resolveScenario`\n // runtime, not here, even before this rename.\n scenario: z.union([z.string(), NarrativeProfileInputSchema]).optional(),\n theme: ThemeSchema.default({ id: \"consulting\" }),\n meta: MetaSchema.default({}),\n assets: AssetsSchema.default({ images: {} }),\n brand: BrandSchema.optional(),\n seed: z.number().int().optional(),\n slides: z.array(SlideSchema),\n })\n .strict()\n\nexport type PptxIRV3 = z.infer<typeof PptxIRV3Schema>\n","import type { PptxIR } from \"./index\"\nimport type { PptxIRV3 } from \"./legacy-v3\"\n\n/**\n * `scenario.mode` → `narrative.strategy` value map (spec §9.1): only the\n * `\"narrative\"` mode value renames (the abstraction/instance collision spec\n * §1 flags), every other mode value (`pyramid`/`instructional`/`showcase`/\n * `briefing`) carries straight across unchanged.\n */\nconst STRATEGY_VALUE_MIGRATION: Readonly<Record<string, string>> = { narrative: \"storytelling\" }\n\n/**\n * `scenario.delivery` → `narrative.pacing` value map (spec §9.1): `text` →\n * `dense`, `presentation` → `spacious`. `balanced` is not listed because it\n * maps to itself — the fallback branch below handles it (and any other\n * value this map doesn't know about) as an identity mapping.\n */\nconst PACING_VALUE_MIGRATION: Readonly<Record<string, string>> = { text: \"dense\", presentation: \"spacious\" }\n\n/**\n * Map one v3 `scenario` input (`PptxIRV3Schema`'s open `string |\n * Record<string, unknown>` shape) to its v4 `narrative` equivalent, per spec\n * §9.1's field/value table:\n *\n * ```text\n * scenario → narrative\n * scenario.mode → narrative.strategy\n * scenario.mode: \"narrative\" → narrative.strategy: \"storytelling\"\n * scenario.delivery → narrative.pacing\n * scenario.delivery: \"text\" → narrative.pacing: \"dense\"\n * scenario.delivery: \"balanced\" → narrative.pacing: \"balanced\"\n * scenario.delivery: \"presentation\" → narrative.pacing: \"spacious\"\n * scenario.audience → narrative.audience\n * ```\n *\n * A preset-id string (e.g. `\"annual-review\"`) carries straight across\n * unchanged — spec §5: preset ids are not renamed, only the axes and values\n * a preset resolves to internally. An `undefined` input stays `undefined` —\n * both `resolveScenario` and `resolveNarrative` fall back to the exact same\n * `general` preset for an omitted axis input, so omitting is itself already\n * the equivalence-preserving choice (no need to materialize the default).\n *\n * Deliberately mechanical, not validating: an unrecognized key (already\n * invalid under v3 too) or an unrecognized `mode`/`delivery` value passes\n * through unchanged rather than throwing — `migrateIrV3ToV4` is a pure\n * structural mapping (spec §9.3: \"只做已声明的结构映射,不运行模型,不重写\n * 内容\"), not a second copy of `resolveScenario`'s own runtime validation.\n * Any input that was already invalid under v3's own semantics stays exactly\n * as invalid under v4's — `resolveNarrative` reports it as such the same way\n * `resolveScenario` would have.\n */\nfunction migrateNarrativeInput(\n scenario: string | Record<string, unknown> | undefined,\n): string | Record<string, unknown> | undefined {\n if (scenario === undefined || typeof scenario === \"string\") return scenario\n\n const narrative: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(scenario)) {\n if (key === \"mode\") {\n narrative.strategy = typeof value === \"string\" ? (STRATEGY_VALUE_MIGRATION[value] ?? value) : value\n } else if (key === \"delivery\") {\n narrative.pacing = typeof value === \"string\" ? (PACING_VALUE_MIGRATION[value] ?? value) : value\n } else if (key === \"audience\") {\n narrative.audience = value\n } else {\n // Unknown key on an already-open record — not one of v3's own\n // documented axis keys. Carried across as-is (see this function's own\n // docstring on why this stays mechanical, not validating).\n narrative[key] = value\n }\n }\n return narrative\n}\n\n/**\n * Deterministic, pure IR v3 → v4 migration (spec §9.1). Field-for-field,\n * value-for-value per the mapping in {@link migrateNarrativeInput}'s\n * docstring — every field this function doesn't touch (`filename`, `theme`,\n * `meta`, `assets`, `brand`, `seed`, `slides`) carries across by the exact\n * same reference it came in with, unchanged (spec §9.1: \"其余 IR 字段保持不\n * 变\"; spec §10: no weight/budget/selection/render change is in scope for\n * this migration, ever).\n *\n * Exported from the SDK surface (`src/index.ts`) as the deterministic\n * migration primitive the `pptfast migrate` CLI command (task 2) wraps —\n * this function itself does no I/O and never runs a model, per spec §9.3's\n * \"只做已声明的结构映射,不运行模型,不重写内容,不重新选择 layout\".\n *\n * Takes an already-parsed `PptxIRV3` (i.e. `PptxIRV3Schema.parse(...)`'s\n * output, defaults already applied) rather than raw `unknown` JSON — schema\n * validation of the v3 input is the caller's job (the CLI parses-then-\n * migrates; `validateIr`'s own v3 path hard-rejects before ever reaching\n * this function, spec §9.3, so `validateIr` itself never calls this).\n */\nexport function migrateIrV3ToV4(v3: PptxIRV3): PptxIR {\n const narrative = migrateNarrativeInput(v3.scenario as string | Record<string, unknown> | undefined)\n return {\n version: \"4\",\n filename: v3.filename,\n ...(narrative !== undefined ? { narrative } : {}),\n theme: v3.theme,\n meta: v3.meta,\n assets: v3.assets,\n ...(v3.brand !== undefined ? { brand: v3.brand } : {}),\n ...(v3.seed !== undefined ? { seed: v3.seed } : {}),\n slides: v3.slides,\n }\n}\n","/**\n * Deck spec schema + validation (spec §5 \"plan artifact and hard gates\", W5\n * task 2 — renamed to \"Deck Spec\" per the vocabulary-v4 rename, spec §6/§8.1;\n * \"spec §N\" citations throughout this file that predate this rename still\n * point at that original W5 design doc, not this rename's own spec — left\n * as historical citations, not renumbered).\n *\n * A deck spec is a workflow artifact, not a render prerequisite (spec §5's\n * \"escape hatch\": a bare IR v3 renders directly, `spec validate` is a\n * separate, optional gate for the spec-authoring stage of the six-phase\n * workflow). This module stays pure and Node-free — no `fs`, no CLI concerns\n * — so it can sit in `src/index.ts`'s dependency closure exactly like\n * `src/ir` and `src/narrative` already do (`AGENTS.md`'s layout rule).\n * `src/cli/commands.ts` owns the one Node-touching wrapper\n * (`runSpecValidate`: read file, call {@link validateSpec}, format the\n * result).\n *\n * Design mirrors `api.ts`'s `validateIr`/`ValidateResult` throughout\n * (structural zod pass first, then a sequential chain of hard-gate\n * categories, each short-circuiting the chain on its own failure — see\n * {@link validateSpec}'s own comment for why): same overall shape, adapted\n * for spec pages being keyed by an author-assigned `id` instead of IR's\n * positional slide index.\n */\nimport { z } from \"zod\"\nimport { PptfastError } from \"../errors\"\nimport { BEAT_VALUES, BrandSchema, COMPONENT_TYPES, MetaSchema, NarrativeProfileInputSchema } from \"../ir\"\nimport { STRATEGY_DEFINITIONS, resolveNarrative, type Pacing, type Strategy, type NarrativeProfile } from \"../narrative\"\nimport { CAPACITY } from \"../svg/audit/capacity\"\nimport { LAYOUT_REGISTRY, type SlideType } from \"../svg/layouts/registry\"\nimport { getInstalledThemeIds } from \"../themes/definitions\"\n\n// ── schema ───────────────────────────────────────────────────────────────\n\n/**\n * Mirrors `SlideSchema.type` / `SlideType` (`ir/index.ts`,\n * `svg/layouts/registry.ts`) exactly. Kept as an independent literal tuple\n * here (not imported from either) — a page spec's `type` is a 4-value enum\n * on its own schema, not a re-export of IR's — but the `satisfies` clause\n * makes any future drift between the two a compile error instead of a\n * silent mismatch.\n */\nconst PAGE_TYPES = [\"cover\", \"chapter\", \"content\", \"ending\"] as const satisfies readonly SlideType[]\n\nexport type PageSpecType = (typeof PAGE_TYPES)[number]\nexport type PageBeat = (typeof BEAT_VALUES)[number]\n\n/**\n * A single page spec (spec §5, §6). `type`/`heading` are required — unlike\n * IR's own `SlideSchema` (where both default/omit for weak-model\n * friendliness), a deck spec is the authoring artifact those fields get\n * *locked* from at assemble time (W5 task 3), so leaving either implicit\n * here would defeat the point. `beat`/`focus`/`summary` stay optional per\n * spec §5's defaults chain (\"beat omitted → auto-rotates by page position —\n * focus/summary/layout/slot can all be omitted\").\n */\nexport const PageSpecSchema = z\n .object({\n id: z.string(),\n type: z.enum(PAGE_TYPES),\n heading: z.string(),\n /** One of the three beat values, or omitted entirely — an omitted\n * beat is never a hard-gate violation on its own (see\n * {@link checkBeatRotation}'s policy functions below). It gets\n * auto-alternated at assemble time (W5 task 3, not this task — still\n * unimplemented as of the P1 variety wave's task 1). Renamed\n * from `rhythm` (vocabulary-v4 rename, spec §4.3/§6/§8.1) — same\n * three values, same semantics, page-level term only, distinct from\n * the deck-level `pacing` axis. A *declared* value here is no longer\n * spec-only advisory material (P1 variety wave, task 1): `assembleDeck`\n * (`./assemble.ts`) now carries it straight into the IR's own\n * `Slide.beat` field, where it combines with a soft selection-weight\n * onto layout picking (`Math.max`, not multiplication — see\n * `SlideSchema.beat`'s own doc comment, `../ir/index.ts`, and\n * `BEAT_TENDENCY_WEIGHT`'s in `../svg/effective-layout.ts` for why) —\n * the checks below (rotation shape) and that downstream weighting\n * (which archetypes a given beat favors) are two independent consumers\n * of the same declared value, not two views of one mechanism. */\n beat: z.enum(BEAT_VALUES).optional(),\n /** Optional authoring hint pointing fill/select at a preferred\n * component type or layout id — see {@link checkFocusVocabulary}. */\n focus: z.string().optional(),\n /** Free-text content anchor, \"for the fill step's own reading only\" (spec §5) — read by a later\n * fill step, never validated or interpreted here. */\n summary: z.string().optional(),\n })\n .strict()\n\nexport type PageSpec = z.infer<typeof PageSpecSchema>\n\n/**\n * Top-level deck spec shape (spec §5, §6). `narrative`/`theme` deliberately\n * have no schema-level `.default(...)` — same reasoning as `PptxIRSchema`'s\n * own `narrative` field (`ir/index.ts`): the resolved value is never baked\n * back into the parsed shape, {@link validateSpec} (here) and, later,\n * assemble (W5 task 3) each resolve it themselves. `seed` is accepted but\n * entirely unexamined by this module — \"not validateSpec's concern\" (spec's\n * own wording): assemble generates and suggests writing one back on first\n * materialization.\n *\n * `version` stays the literal `\"1\"` (unchanged value) but now carries an\n * independent Deck Spec versioning scheme (spec §6: \"`deck.spec.json` 使用\n * 独立的 spec 版本 1。它是新工件,不继承 `deck.plan.json` 的版本号语义\") —\n * this \"1\" is the Deck Spec artifact's own first version, not a continuation\n * of the old deck-plan artifact's version counter, even though the digit is\n * the same.\n */\nexport const DeckSpecSchema = z\n .object({\n version: z.literal(\"1\").default(\"1\"),\n // Same open-schema/closed-semantic split as PptxIRSchema's `narrative`\n // field — see `NarrativeProfileInputSchema`'s doc comment in `ir/index.ts`\n // for the full rationale (reused verbatim here, not redefined, so the\n // two can't drift apart). Field renamed from `scenario` to `narrative`\n // this task (spec §8.1's `DeckPlan`→`DeckSpec` rename, task 2) — its\n // *value* was already in the new strategy/pacing vocabulary as of task 1\n // (vocabulary-v4 rename) — `resolveNarrative` below is what actually\n // enforces that.\n narrative: z.union([z.string(), NarrativeProfileInputSchema]).optional(),\n theme: z.string().optional(),\n filename: z.string().optional(),\n seed: z.number().int().optional(),\n meta: MetaSchema.default({}),\n /** Deck logo placement — reused verbatim from the IR's own `brand` field\n * (`BrandSchema`, `../ir`) so the deck spec and IR can't drift apart on\n * shape, same pattern as `meta` just above. Unlike `meta`, no\n * `.default({})`: IR's own `brand` field is a bare `.optional()` with no\n * default either (`undefined` means \"no brand\", not \"an empty brand\n * object\") — consumed by `BrandChrome` (`src/svg/BrandChrome.tsx`) for\n * the deck's logo image and corner position. */\n brand: BrandSchema.optional(),\n pages: z.array(PageSpecSchema),\n })\n .strict()\n\nexport type DeckSpec = z.infer<typeof DeckSpecSchema>\n\n/** JSON Schema for the deck spec — feed this to a model before it writes one (see `pptfast schema --spec`). */\nexport function specJsonSchema(): Record<string, unknown> {\n return z.toJSONSchema(DeckSpecSchema) as Record<string, unknown>\n}\n\n// ── result / issue types ────────────────────────────────────────────────\n\nexport interface SpecValidationIssue {\n path: string\n message: string\n /** The offending page's `id`, when the issue is scoped to one specific\n * page and that page's `id` could be determined — absent for deck-level\n * issues (e.g. \"pages non-empty\") and for structural issues on a page\n * whose own `id` itself failed to parse. */\n pageId?: string\n}\n\nexport interface SpecValidateResult {\n ok: boolean\n spec?: DeckSpec\n errors: SpecValidationIssue[]\n}\n\nexport function formatSpecIssues(errors: SpecValidationIssue[]): string {\n return errors.map((e) => (e.pageId ? `page \"${e.pageId}\" — ${e.path}: ${e.message}` : `${e.path}: ${e.message}`)).join(\"\\n\")\n}\n\n/**\n * `\"invalid spec (N issue[s]):\\n<formatted issues>\"` — the exact\n * {@link PptfastError} message both `runSpecValidate` (`src/cli/commands.ts`)\n * and {@link assembleDeck}'s (`./assemble.ts`) step 1 throw on a failed\n * {@link validateSpec} call. Extracted here instead of duplicated verbatim at\n * each call site so the two can't drift on wording — reuses\n * {@link formatSpecIssues} for the per-issue body.\n */\nexport function formatInvalidSpecError(errors: SpecValidationIssue[]): string {\n return `invalid spec (${errors.length} issue${errors.length === 1 ? \"\" : \"s\"}):\\n${formatSpecIssues(errors)}`\n}\n\n/**\n * Deck-spec-level theme default (spec §5's defaults chain: \"theme omitted →\n * consulting\") — the same default IR's own `theme.id` field carries\n * (`ThemeSchema` in `ir/index.ts`). Exported so a caller already holding a\n * validated {@link DeckSpec} (the CLI's OK-summary line) doesn't re-derive\n * the fallback itself.\n */\nexport function resolveSpecThemeId(spec: DeckSpec): string {\n return spec.theme ?? \"consulting\"\n}\n\n// ── hard gate: pages non-empty ──────────────────────────────────────────\n\nfunction checkPagesNonEmpty(spec: DeckSpec): SpecValidationIssue[] {\n if (spec.pages.length > 0) return []\n return [{ path: \"pages\", message: \"spec has no pages — a spec needs at least a cover page and an ending page\" }]\n}\n\n// ── hard gate: boundary types ───────────────────────────────────────────\n\n/**\n * Structural boundary gate (spec §5): the deck must open on a cover page and\n * close on an ending page, and no interior page may claim either type —\n * cover/ending are reserved for the two boundary positions. `content` and\n * `chapter` are both legal interior types (chapter divider pages are not\n * boundary types, and are excluded from the beat-rotation streak checks\n * below for that same reason). Called only when `spec.pages` is non-empty\n * (see {@link validateSpec}) — on a single-page spec `first`/`last` are the\n * same page and both checks run against it independently, so a lone page\n * that is neither cover nor ending reports both violations.\n */\nfunction checkBoundaryTypes(spec: DeckSpec): SpecValidationIssue[] {\n const { pages } = spec\n const errors: SpecValidationIssue[] = []\n const first = pages[0]!\n const last = pages[pages.length - 1]!\n if (first.type !== \"cover\") {\n errors.push({\n path: \"pages.0.type\",\n pageId: first.id,\n message: `first page must be type \"cover\" (got \"${first.type}\") — a spec must open with a cover page`,\n })\n }\n if (last.type !== \"ending\") {\n errors.push({\n path: `pages.${pages.length - 1}.type`,\n pageId: last.id,\n message: `last page must be type \"ending\" (got \"${last.type}\") — a spec must close with an ending page`,\n })\n }\n for (let i = 1; i < pages.length - 1; i++) {\n const page = pages[i]!\n if (page.type === \"cover\" || page.type === \"ending\") {\n errors.push({\n path: `pages.${i}.type`,\n pageId: page.id,\n message: `page \"${page.id}\" is type \"${page.type}\", only allowed as the first (cover) or last (ending) page — use \"content\" or \"chapter\" for interior pages`,\n })\n }\n }\n return errors\n}\n\n// ── hard gate: page id required + unique ────────────────────────────────\n\n/**\n * Path-traversal-safety check (CWE-22 defense-in-depth, W5 whole-branch\n * review finding 1) on a spec-authored page id — this id becomes `slide.id`\n * at assemble time ({@link buildSlide} in `./assemble.ts`, step 5) and, from\n * there, a `pages/<id>.json` / `assets/<id><ext>` file name if the resulting\n * IR is ever disassembled again (`runDisassemble`'s page write and\n * `writeOneAsset`, both via `assertSafeFileSegment` in `../cli/deck-dir.ts`\n * — the actual write-time gates, and the only checks that matter for\n * *every* id regardless of provenance, since a hand-authored bare IR skips\n * this module entirely). Rejecting an unsafe id here too, at spec-validation\n * time, is pure defense-in-depth: it means a spec-authored id is already\n * safe by the time it could ever reach either sink.\n *\n * This module stays Node-free (`AGENTS.md`'s layout rule, this file's own\n * top comment), so the check is duplicated as plain string logic instead of\n * importing `assertSafeFileSegment` itself (which needs `node:path`'s\n * `resolve`/`relative`) — same \"duplicate a few lines rather than pull a\n * Node-touching module into this closure\" call {@link specHeadingLength}'s\n * own doc comment makes just above. A single trailing path segment can only\n * ever escape whatever directory it is joined under if it is itself\n * absolute, contains a `/`/`\\` separator, or is exactly `\"..\"` —\n * `assertSafeFileSegment`'s own doc comment walks through why those lexical\n * checks alone are already sufficient (it additionally cross-checks via\n * `resolve`/`relative` as belt-and-suspenders, not because the lexical\n * checks alone fall short). Keep in sync with `assertSafeFileSegment` if\n * either check's rules ever change.\n */\nfunction isUnsafePageId(id: string): boolean {\n return id.includes(\"/\") || id.includes(\"\\\\\") || id === \"..\"\n}\n\n/**\n * `id` is required at the schema level (`PageSpecSchema.id: z.string()`, no\n * `.optional()`) so a missing key is already a structural error by the time\n * this runs — what remains to check here is (a) an empty/whitespace-only\n * string, which the schema's plain `z.string()` lets through, (b) a value\n * unsafe to use as a page/asset file name ({@link isUnsafePageId}, W5\n * whole-branch review finding 1), and (c) cross-page uniqueness, which no\n * per-page schema can express. Kebab-case is suggested by spec §5 but never\n * enforced (\"kebab-case suggested, not required\") — neither (a) nor (b)\n * narrows that: spaces, underscores, and uppercase all stay legal, only path\n * separators and a bare `\"..\"` are not.\n */\nfunction checkPageIds(spec: DeckSpec): SpecValidationIssue[] {\n const errors: SpecValidationIssue[] = []\n const seen = new Map<string, number[]>()\n spec.pages.forEach((page, i) => {\n if (page.id.trim() === \"\") {\n errors.push({ path: `pages.${i}.id`, message: `page ${i + 1} has an empty id — every page needs a non-empty, unique id` })\n return\n }\n if (isUnsafePageId(page.id)) {\n errors.push({\n path: `pages.${i}.id`,\n pageId: page.id,\n message: `page id \"${page.id}\" is not a safe file name — ids used as page/asset file names must not contain path separators or \"..\"`,\n })\n return\n }\n const indices = seen.get(page.id)\n if (indices) indices.push(i)\n else seen.set(page.id, [i])\n })\n for (const [id, indices] of seen) {\n if (indices.length < 2) continue\n errors.push({\n path: \"pages\",\n pageId: id,\n message: `duplicate page id \"${id}\" used by ${indices.length} pages (positions ${indices.map((i) => i + 1).join(\", \")}) — page ids must be unique within a spec`,\n })\n }\n return errors\n}\n\n// ── hard gate: heading required + length ────────────────────────────────\n\n/**\n * `CAPACITY.headingMaxChars` (`svg/audit/capacity.ts`) — the exact same 48\n * numeric source `ir-quality.ts`'s long-heading warning reads (see that\n * constant's own derivation comment there). Not re-derived here.\n */\nconst HEADING_MAX_CHARS = CAPACITY.headingMaxChars\n\n/**\n * Character count for the heading-length gate — deliberately plain\n * `.length` (CJK characters count as 1 each), matching `ir-quality.ts`'s own\n * `charLen` helper's semantics exactly (that function's doc comment: \"Count\n * characters. CJK characters count as 1 each (same as .length)\") — not\n * `measureTextUnits`'s visual-width weighting, a different unit system used\n * elsewhere in this codebase for a different purpose (bullets budgets).\n * Duplicated rather than imported: `charLen` is a one-line function and\n * importing it would pull `ir-quality.ts`'s whole module graph\n * (`effective-layout.ts`, `svg-text-layout.ts`, ...) into this Node-free\n * package for a single line of logic. Keep in sync with `ir-quality.ts`'s\n * `charLen` if that one ever changes.\n */\nfunction specHeadingLength(heading: string): number {\n return heading.length\n}\n\nfunction checkHeadings(spec: DeckSpec): SpecValidationIssue[] {\n const errors: SpecValidationIssue[] = []\n spec.pages.forEach((page, i) => {\n if (page.heading.trim() === \"\") {\n errors.push({ path: `pages.${i}.heading`, pageId: page.id, message: `page \"${page.id}\" is missing a required heading` })\n return\n }\n const length = specHeadingLength(page.heading)\n if (length > HEADING_MAX_CHARS) {\n errors.push({\n path: `pages.${i}.heading`,\n pageId: page.id,\n message: `page \"${page.id}\" heading is ${length} characters, exceeds the ${HEADING_MAX_CHARS}-character limit — tighten it into a short, assertive phrase`,\n })\n }\n })\n return errors\n}\n\n// ── hard gate: theme resolution ─────────────────────────────────────────\n\n/**\n * Installed-theme check, same shape as `validateIr`'s own (`api.ts`) —\n * `theme` stays an open string at the schema layer (like IR's `theme.id`),\n * this hard gate is where an unknown id is actually rejected.\n */\nfunction checkTheme(spec: DeckSpec): SpecValidationIssue[] {\n const themeId = resolveSpecThemeId(spec)\n const installed = getInstalledThemeIds()\n if (installed.includes(themeId)) return []\n return [{ path: \"theme\", message: `unknown theme \"${themeId}\" — available: ${installed.join(\", \")} (see \\`pptfast themes\\`)` }]\n}\n\n// ── hard gate: focus vocabulary ─────────────────────────────────────────\n\nconst LAYOUT_IDS: readonly string[] = Object.keys(LAYOUT_REGISTRY)\n\n/**\n * Focus vocabulary gate (spec §5): `focus` is optional authoring guidance\n * pointing a later fill/select step at a preferred component or layout —\n * when present it must resolve against one of three vocabularies: the\n * resolved strategy's own tendency set (`STRATEGY_DEFINITIONS[strategy].tendencies`, W3\n * data), the full component-type vocabulary ({@link COMPONENT_TYPES}, 28\n * names), or the full layout-id vocabulary ({@link LAYOUT_IDS},\n * `LAYOUT_REGISTRY`'s keys).\n *\n * The strategy tendency set is currently always a subset of the other two\n * (every entry in every `StrategyDefinition.tendencies` array already resolves\n * against either component types or layout ids — see that field's own doc\n * comment in `narrative/index.ts`) — checked explicitly anyway, both because\n * the brief's wording keeps it a first-class term of the union (a future\n * tendency value from some other vocabulary would still resolve correctly\n * without touching this function) and because the strategy-specific list is the\n * one most useful to show first in the error message, ahead of the two much\n * longer global lists.\n */\nfunction checkFocusVocabulary(spec: DeckSpec, strategy: Strategy): SpecValidationIssue[] {\n const tendencies = STRATEGY_DEFINITIONS[strategy].tendencies\n const errors: SpecValidationIssue[] = []\n spec.pages.forEach((page, i) => {\n if (page.focus === undefined) return\n if (tendencies.includes(page.focus) || COMPONENT_TYPES.includes(page.focus) || LAYOUT_IDS.includes(page.focus)) {\n return\n }\n errors.push({\n path: `pages.${i}.focus`,\n pageId: page.id,\n message:\n `unknown focus \"${page.focus}\" for strategy \"${strategy}\" — expected one of this strategy's tendencies ` +\n `(${tendencies.join(\", \")}), a component type (${COMPONENT_TYPES.join(\", \")}), ` +\n `or a layout id (${LAYOUT_IDS.join(\", \")})`,\n })\n })\n return errors\n}\n\n// ── hard gate: beat rotation (parameterized by strategy's beatPolicy) ──\n\ntype DeclaredBeatPage = { index: number; id: string; beat: PageBeat }\n\n/**\n * Content-type pages (cover/chapter/ending excluded, per the brief's streak\n * rule) that declared an explicit `beat` — the exact population every\n * beat-policy check below reasons over. A content page that leaves\n * `beat` unset is filtered out here too, not treated as a streak-breaker —\n * see {@link checkAlternatePolicy}'s doc comment for why that matters.\n */\nfunction declaredBeatContentPages(spec: DeckSpec): DeclaredBeatPage[] {\n const result: DeclaredBeatPage[] = []\n spec.pages.forEach((page, index) => {\n if (page.type === \"content\" && page.beat !== undefined) {\n result.push({ index, id: page.id, beat: page.beat })\n }\n })\n return result\n}\n\n/**\n * `alternate` policy (storytelling strategy): no run of 3 or more consecutive\n * content pages may declare the *same* beat. \"Consecutive\" is evaluated\n * on the declared-beat content-page subsequence\n * ({@link declaredBeatContentPages}), not on raw array adjacency —\n * cover/chapter/ending pages are excluded per the brief, and a content page\n * that leaves `beat` unset is *also* transparent to this scan (filtered\n * out, neither breaking nor extending a run) rather than treated as a\n * guaranteed streak-breaker: nothing at validate time knows what an unset\n * beat will resolve to (assemble's later auto-alternation step decides\n * that), so a run of declared \"anchor\" pages either side of one undeclared\n * page is still a real 3-in-a-row risk once that gap gets filled, and\n * treating it as already-safe would let the loudest form of the violation\n * (every visible declaration identical) through silently. A maximal run\n * reports exactly one error naming every member, not one error per\n * overlapping triple within it.\n */\nfunction checkAlternatePolicy(spec: DeckSpec, strategy: Strategy): SpecValidationIssue[] {\n const seq = declaredBeatContentPages(spec)\n const errors: SpecValidationIssue[] = []\n let i = 0\n while (i < seq.length) {\n let j = i + 1\n while (j < seq.length && seq[j]!.beat === seq[i]!.beat) j++\n const runLength = j - i\n if (runLength >= 3) {\n const members = seq.slice(i, j)\n errors.push({\n path: \"pages\",\n pageId: members[0]!.id,\n message:\n `${runLength} consecutive content pages declare beat \"${seq[i]!.beat}\" ` +\n `(${members.map((m) => m.id).join(\", \")}) — strategy \"${strategy}\" requires beat to alternate, ` +\n `vary at least one of them`,\n })\n }\n i = j\n }\n return errors\n}\n\n/**\n * `anchor-open` policy (pyramid strategy): only the deck's *first* content page\n * is checked — it must declare beat \"anchor\" if it declares a beat at\n * all. An unset beat on that first content page is not a violation (spec:\n * omission always defers to the later auto-fill step). Every other content\n * page's beat is left alone by this policy, by design (spec's own words:\n * \"only checks the opening\"). Vacuously fine when the spec has no content\n * pages at all (e.g. cover → chapter → ending).\n */\nfunction checkAnchorOpenPolicy(spec: DeckSpec, strategy: Strategy): SpecValidationIssue[] {\n const firstContentIndex = spec.pages.findIndex((page) => page.type === \"content\")\n if (firstContentIndex === -1) return []\n const firstContent = spec.pages[firstContentIndex]!\n if (firstContent.beat === undefined || firstContent.beat === \"anchor\") return []\n return [\n {\n path: `pages.${firstContentIndex}.beat`,\n pageId: firstContent.id,\n message: `first content page declares beat \"${firstContent.beat}\" — strategy \"${strategy}\" requires the deck to open its first content page on \"anchor\" beat when a beat is declared`,\n },\n ]\n}\n\n/**\n * `anchor-sparse` policy (showcase strategy): among content pages that declare a\n * beat, \"anchor\" must stay a minority (at most half). Showcase's own\n * beat *default* leans anchor-heavy (spec §5's beat-default column:\n * \"anchor-dominant\" — applied by the later auto-alternation step when beat is\n * omitted), but this gate only ever looks at pages the author explicitly\n * marked — its job is guarding against an agent mechanically stamping\n * \"anchor\" on every page it writes. An anchor page is meant to read as a\n * deliberate, occasional high-impact beat. If every page claims that beat,\n * none of them keep it. Zero declared-beat content pages is not a\n * violation — there is nothing to compute a ratio over (same \"absence never\n * violates\" posture as every other policy here).\n */\nfunction checkAnchorSparsePolicy(spec: DeckSpec, strategy: Strategy): SpecValidationIssue[] {\n const declared = declaredBeatContentPages(spec)\n if (declared.length === 0) return []\n const anchorPages = declared.filter((page) => page.beat === \"anchor\")\n if (anchorPages.length / declared.length <= 0.5) return []\n const pct = Math.round((anchorPages.length / declared.length) * 100)\n return [\n {\n path: \"pages\",\n // First offending anchor page, same \"representative pageId\" shape\n // checkAlternatePolicy's own issue carries (members[0]!.id there) —\n // this gate's violation is deck-wide (a ratio, not one page), but a\n // representative id still gives a CLI/agent caller something to jump\n // to rather than only a bare \"pages\" path.\n pageId: anchorPages[0]!.id,\n message:\n `${anchorPages.length} of ${declared.length} content pages with a declared beat are \"anchor\" ` +\n `(${pct}%: ${anchorPages.map((page) => page.id).join(\", \")}) — strategy \"${strategy}\" requires \"anchor\" to ` +\n `stay a minority of declared beats, vary some to \"dense\" or \"breathing\"`,\n },\n ]\n}\n\n/**\n * Dispatches to the resolved strategy's beat-rotation rule (spec §5's spec\n * hard-gate section, \"beat-rotation rule parameterized by strategy\" — a single universal \"no 3\n * same-beat pages in a row\" rule would reject e.g. briefing's own correct\n * default, the exact self-contradiction the spec's codex-review pass\n * flagged, hence a per-`beatPolicy` rule set instead of one rule for\n * everyone). See `StrategyDefinition.beatPolicy`'s own doc comment\n * (`narrative/index.ts`) for which of the five strategies maps to which policy.\n */\nfunction checkBeatRotation(spec: DeckSpec, strategy: Strategy): SpecValidationIssue[] {\n const policy = STRATEGY_DEFINITIONS[strategy].beatPolicy\n switch (policy) {\n case \"uniform-dense\":\n case \"repetition-ok\":\n // Exempt entirely — uniform/repeated beat across content pages is\n // these strategies' own correct default (briefing's \"uniform dense\",\n // instructional's \"dense tolerated, structure repeats across pages\"), not a violation of\n // anything a generic streak rule would otherwise flag.\n return []\n case \"alternate\":\n return checkAlternatePolicy(spec, strategy)\n case \"anchor-open\":\n return checkAnchorOpenPolicy(spec, strategy)\n case \"anchor-sparse\":\n return checkAnchorSparsePolicy(spec, strategy)\n default: {\n const exhaustive: never = policy\n throw new Error(`unhandled beat policy: ${String(exhaustive)}`)\n }\n }\n}\n\n// ── hard gate: page count vs pacing ─────────────────────────────────────\n\n/**\n * Deck-level page-count range per pacing (spec §5's pacing table,\n * initial values — \"dense 8-30 / balanced 6-24 / spacious 4-16\", not yet\n * tuned against real usage). Independent of `PACING_BUDGETS`\n * (`narrative/index.ts`, per-slide component-count/bullets editorial\n * budget) — this is a separate, deck-wide page-count concern the spec calls\n * out as its own hard gate (\"page count vs. pacing recommended range\").\n * Message wording renamed from \"delivery\" to \"pacing\" (vocabulary-v4\n * residual, routed from the task 1 review) — the axis itself was already\n * `Pacing` at the type level, this closes the last stale word in the\n * error text.\n */\nexport const SPEC_PAGE_COUNT_RANGE: Record<Pacing, { min: number; max: number }> = {\n dense: { min: 8, max: 30 },\n balanced: { min: 6, max: 24 },\n spacious: { min: 4, max: 16 },\n}\n\nfunction checkPageCount(spec: DeckSpec, pacing: Pacing): SpecValidationIssue[] {\n const { min, max } = SPEC_PAGE_COUNT_RANGE[pacing]\n const n = spec.pages.length\n if (n >= min && n <= max) return []\n return [\n {\n path: \"pages\",\n message: `spec has ${n} pages — \"${pacing}\" pacing expects ${min}-${max} pages, change pacing or add/remove pages`,\n },\n ]\n}\n\n// ── entry point ──────────────────────────────────────────────────────────\n\n/** Best-effort page id lookup straight off the *raw* (pre-parse) input, used\n * only for structural (zod) issues — the page that failed to parse may\n * still have a readable `id` sitting right next to whatever field failed. */\nfunction pageIdFromRawInput(input: unknown, index: number): string | undefined {\n if (typeof input !== \"object\" || input === null) return undefined\n const pages = (input as Record<string, unknown>).pages\n if (!Array.isArray(pages)) return undefined\n const page = pages[index] as unknown\n if (typeof page !== \"object\" || page === null) return undefined\n const id = (page as Record<string, unknown>).id\n return typeof id === \"string\" ? id : undefined\n}\n\n/**\n * Validate raw JSON against the spec schema, then — once it parses — run the\n * spec §5 hard-gate chain. Mirrors `validateIr`'s (`api.ts`) overall shape: a\n * structural zod pass first, then a sequence of isolated hard-gate\n * categories, each short-circuiting the whole chain on its own failure\n * (rather than accumulating errors across categories) so a later category\n * never has to guess at what an earlier, already-broken one would have\n * meant — e.g. beat-rotation and page-count both need a resolved\n * narrative, so nothing past the narrative/theme stage runs until that\n * resolves cleanly. Every spec-gate philosophy here is \"hard block, no soft\n * warning\" (spec §5's \"escape hatch\" section — a spec that doesn't fit this shape\n * should be authored as bare IR instead, not warned-and-shipped).\n */\nexport function validateSpec(input: unknown): SpecValidateResult {\n const r = DeckSpecSchema.safeParse(input)\n if (!r.success) {\n const errors = r.error.issues.map((issue) => {\n const path = issue.path.join(\".\")\n const m = /^pages\\.(\\d+)/.exec(path)\n return { path, message: issue.message, pageId: m ? pageIdFromRawInput(input, Number(m[1])) : undefined }\n })\n return { ok: false, errors }\n }\n const spec = r.data\n\n const emptyErrors = checkPagesNonEmpty(spec)\n if (emptyErrors.length > 0) return { ok: false, errors: emptyErrors }\n\n const boundaryErrors = checkBoundaryTypes(spec)\n if (boundaryErrors.length > 0) return { ok: false, errors: boundaryErrors }\n\n const idErrors = checkPageIds(spec)\n if (idErrors.length > 0) return { ok: false, errors: idErrors }\n\n const headingErrors = checkHeadings(spec)\n if (headingErrors.length > 0) return { ok: false, errors: headingErrors }\n\n const themeErrors = checkTheme(spec)\n if (themeErrors.length > 0) return { ok: false, errors: themeErrors }\n\n // Narrative resolution (spec §5's defaults chain), same open-schema/\n // closed-semantic split as validateIr's own (api.ts) — see that\n // function's comment for the full rationale. `spec.narrative`'s inferred\n // type is wider than `resolveNarrative`'s parameter — safe to narrow here\n // because `resolveNarrative` validates every key/value itself at runtime.\n let resolvedAxes: NarrativeProfile\n try {\n resolvedAxes = resolveNarrative(spec.narrative as string | Partial<NarrativeProfile> | undefined)\n } catch (err) {\n if (!(err instanceof PptfastError)) throw err\n return { ok: false, errors: [{ path: \"narrative\", message: err.message }] }\n }\n\n const beatErrors = checkBeatRotation(spec, resolvedAxes.strategy)\n if (beatErrors.length > 0) return { ok: false, errors: beatErrors }\n\n const focusErrors = checkFocusVocabulary(spec, resolvedAxes.strategy)\n if (focusErrors.length > 0) return { ok: false, errors: focusErrors }\n\n const pageCountErrors = checkPageCount(spec, resolvedAxes.pacing)\n if (pageCountErrors.length > 0) return { ok: false, errors: pageCountErrors }\n\n return { ok: true, spec, errors: [] }\n}\n","/**\n * assembleDeck / disassembleDeck — deck spec + per-page content → IR, and\n * back (spec §5's \"assemble is a pure SDK function\", W5 task 3; the spec\n * artifact itself renamed from \"plan\" to \"spec\" per the vocabulary-v4\n * rename, spec §6/§8.1 — old \"spec §N\" citations throughout this file\n * predate that rename and still cite the original W5 design doc, left as\n * historical citations, not renumbered).\n *\n * `assembleDeck` is the pure-function half of the deck-project directory\n * concept (spec §7): a locked {@link DeckSpec} (§5's workflow artifact,\n * validated by `validateSpec` in `./index.ts`) plus a `pages` record keyed\n * by page id, materialized into a renderable {@link PptxIR}. The CLI's\n * directory wrapper (W5 task 5, not this file) is the only Node-touching\n * piece — it reads `deck.spec.json` + `pages/<id>.json` off disk and calls\n * straight through to {@link assembleDeck}, exactly like `src/cli/commands.ts`\n * already wraps `validateIr`/`validateSpec`. This module itself stays zero-fs\n * (no `node:*` imports, nothing from `src/cli*` or `src/platform/node.ts`) so\n * it can sit in `src/index.ts`'s dependency closure (`AGENTS.md`'s layout\n * rule) — the exact same posture `./index.ts` (this folder's schema/validate\n * module) already holds, documented in that file's own top comment.\n *\n * `assembleDeck` also materializes each page's effective layout id into its\n * own `layout` field when the page file omitted one (W4 design decision 10):\n * once the IR above is built and schema-validated, every slide whose\n * `layout` is still unset gets exactly what `resolveEffectiveLayoutId`\n * (`../svg/effective-layout.ts`) resolves for it — the same function the\n * render chain itself calls, so `deck.json` never carries a second,\n * independently-derived guess at what a page will render as (see this\n * module's own {@link materializeEffectiveLayouts} for the mechanics). A page\n * whose file already set `layout` is left untouched. `../svg/effective-layout.ts`\n * is a pure function with no Node-only import anywhere in its own closure and\n * nothing in that closure imports back from `src/plan`, so depending on it\n * from here adds a new edge, not a cycle, and does not pull anything\n * Node-only into `src/index.ts`'s dependency closure.\n *\n * `disassembleDeck` is the documented-lossy inverse (spec §7: \"disassemble\n * — the inverse of assemble, an optional tail item for W5\"): it reconstructs a spec + pages record from\n * an existing IR, well enough that re-`assembleDeck`-ing the result\n * reproduces the same slide content, but spec-only fields that never made it\n * into the IR in the first place (`focus`, and `summary` on anything but a\n * placeholder page) cannot be recovered — see that function's own doc\n * comment for the full accounting. `beat` *did* have this same \"never made\n * it into the IR\" status until the P1 variety wave's task 1 gave it a real\n * `Slide.beat` field (`../ir/index.ts`) — it is now a plain passthrough on\n * both sides, same as `layout`/`heading`, no longer in this lossy list.\n */\nimport { PptfastError } from \"../errors\"\nimport { PptxIRSchema, type BackgroundSpec, type Component, type PptxIR, type Slide } from \"../ir\"\nimport { resolveEffectiveLayoutId } from \"../svg/effective-layout\"\nimport { formatInvalidSpecError, validateSpec, type DeckSpec, type PageSpec } from \"./index\"\n\n// ── PageContent (per-page authoring record, spec §7's `pages/<id>.json`) ──\n\n/**\n * One page's fillable content — everything a page spec's `id` does *not*\n * already lock in. Deliberately excludes `type`/`heading` (spec-owned, see\n * {@link assembleDeck}'s locked-field gate) and `subheading`/`decor`\n * (legitimate `Slide` fields, but outside this record's shape by spec §7's\n * own layout — \"pages/<id>.json contains only components\" — a spec/pages deck can't\n * author either one — a hand-authored bare IR still can). Every field here is\n * a same-name, same-shape subset of `Slide`'s own optional fields\n * (`../ir`'s `SlideSchema`) — reused, not redeclared, so the two can't drift.\n */\nexport interface PageContent {\n components?: Component[]\n layout?: string\n arrangement?: NonNullable<Slide[\"arrangement\"]>\n background?: BackgroundSpec\n image_side?: \"left\" | \"right\"\n footnote?: string\n notes?: string\n}\n\nexport interface AssembleResult {\n ir: PptxIR\n /**\n * Set only when `spec.seed` was absent and {@link assembleDeck} generated\n * one deterministically (see the seed section of this function's doc\n * comment) — `undefined` when `spec.seed` was already present and simply\n * passed through. The CLI shell (W5 task 5) is the one that acts on this:\n * suggest writing the value back into the spec file, never rewrite it\n * itself (assemble stays a pure function, no fs side effects here).\n */\n generatedSeed?: number\n /**\n * Count of pages whose `layout` field this call filled in via\n * {@link materializeEffectiveLayouts} (W4 design decision 10) —\n * `undefined` when zero, same \"absent means nothing to report\" posture as\n * {@link generatedSeed} just above (a page with an explicit pin already in\n * its page file, or every omitted page landing on the image-cover\n * takeover's `null` bypass, both leave this unset). Purely informational:\n * the CLI shell (`runAssemble`, `../cli/commands.ts`) surfaces it as a\n * one-line note, nothing here or downstream acts on the number itself.\n */\n materializedLayoutCount?: number\n}\n\n// ── deterministic seed generation ───────────────────────────────────────\n\n/**\n * djb2 string hash. The exact same five-line algorithm as\n * `svg/variety.ts`'s `stableHash` (and `svg/components/chart-svg.tsx`'s own\n * private copy of it) — reimplemented locally here for the same reason\n * those two already give each other in `variety.ts`'s doc comment: it is a\n * five-line primitive, and importing it from `svg/variety.ts` would pull a\n * `plan → svg` dependency this module has no business taking (`src/plan` is\n * this module's own package name, unchanged this task — only the artifact\n * concepts it exports renamed, see this file's own top comment). `src/plan`\n * sits beside `src/ir` (an IR-adjacent, pre-render authoring concern).\n * `src/svg` is a *consumer* of IR (the render chain), not a neighbor of\n * `plan` — reaching \"up\" into it here would point the dependency arrow the\n * wrong way for what is conceptually a lower-level module. The two hashes\n * are also semantically independent on purpose (see {@link generateSeed}):\n * this one intentionally excludes heading text that `deckSeed` intentionally\n * includes, so sharing an implementation would invite sharing behavior that\n * must not be shared.\n */\nfunction stableHash(s: string): number {\n let h = 5381\n for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0\n return Math.abs(h)\n}\n\n/**\n * Deterministic seed for a spec that omits `seed` (spec §5's \"seed mechanism\n * revision\": modification stability requires an *explicit*, persisted seed —\n * this is the one-time generation `assembleDeck` performs on first\n * materialization, spec's own words: \"generated once at creation time,\n * stable thereafter\"). Hashes `filename + the spec's own\n * ordered page-id sequence` — deliberately *not* heading text or any\n * per-page content, unlike `svg/variety.ts`'s `deckSeed` (pre-v0.3 content\n * hash, still used when a bare IR omits `seed` entirely): editing an\n * existing page's heading or components must not reshuffle every other\n * page's auto-selected layout (the exact regression spec §6 calls out this\n * seed field to fix), so this hash is a function of deck *shape* — which\n * pages exist, in what order — not deck *content*. Reordering, adding, or\n * removing pages does change it (page identity plus position both feed the\n * hash: two decks with the same id set in a different order still hash\n * differently — join with a separator no id can itself contain, `\"\\n\"`, so\n * `[\"ab\", \"c\"]` and `[\"a\", \"bc\"]` can never collide).\n */\nfunction generateSeed(filename: string | undefined, pageIds: readonly string[]): number {\n return stableHash([filename ?? \"\", ...pageIds].join(\"\\n\"))\n}\n\n// ── assembleDeck ────────────────────────────────────────────────────────\n\nconst LOCKED_KEYS = [\"type\", \"heading\"] as const\n\n/**\n * Assemble a validated deck spec plus a per-page content record into a\n * renderable IR. See this module's own top comment for the overall shape.\n * Step numbers below match the W5 task-3 brief's own numbered \"inject\n * semantics\" list verbatim — kept in that exact order because two of them\n * (locked-field vs. orphan) both throw and their relative order is\n * otherwise unobservable from either doc comment alone.\n *\n * 1. `spec` is `unknown` (same boundary `validateSpec` itself has — a spec\n * is almost always freshly `JSON.parse`d off disk by the caller) —\n * invalid shape or a failed hard gate throws {@link PptfastError} with\n * `validateSpec`'s own formatted issue list, not a re-derived message.\n * 2. Shape guard + locked-field protection: a `pages[id]` entry must first be\n * a plain object — not `null`, an array, or a primitive — else throws.\n * `pages` is `unknown`-shaped off disk same as `spec` itself (step 1), so\n * a JSON `null`/string/array content value is a real possibility, not\n * just a type-system hole, and `Object.hasOwn` throws its own\n * uninformative native `TypeError` on `null` (and silently no-ops on a\n * string/array/number) rather than this gate's own readable message.\n * Once that holds, a `pages[id]` entry that carries a `type` or `heading`\n * *key* — even set to `undefined` — throws. `Object.hasOwn`, not a\n * `!== undefined` read, is what makes that \"even `undefined`\" case\n * catchable: `PageContent` itself never declares either field, but a\n * page file freshly parsed off disk is `unknown` before it reaches this\n * function's declared `PageContent` parameter type, so a stray\n * `\"heading\": null`-turned-`undefined` or a copy-pasted empty key is\n * exactly the drift this gate exists to catch instead of silently\n * ignoring.\n * 3. Orphan keys: a `pages` entry whose id isn't any spec page's id. Listed\n * together with a fix suggestion, checked only after every present page\n * has cleared the locked-field gate (step 2) — an orphan file that\n * *also* happens to redeclare `heading` reports as locked-field first.\n * 4. Missing pages (a spec id with no `pages` entry) become a placeholder\n * slide — never an error. Spec §7's own words: \"assemble's precise\n * semantics — a missing page always succeeds (placeholder), a structural\n * contradiction (orphan file / bad spec / id conflict) errors\". A\n * declared `summary` becomes the placeholder's `subheading` (the one spot\n * `summary` — otherwise a spec-only anchor, see step 5 — does reach the\n * IR) so a `--draft` preview of an unfilled deck still reads as more than\n * a bare \"Untitled\". A declared `beat` carries straight into the\n * placeholder's own `Slide.beat` too (P1 variety wave, task 1 — see step\n * 5's note below). A placeholder page still participates in this\n * function's own layout materialization below, so its beat still needs\n * to reach the IR for that weighting to see it once the page is filled\n * in later without re-materializing.\n * 5. Present pages become a full slide: `id`/`type`/`heading` from the page\n * spec (never the content record — see step 2), plus whichever of\n * {@link PageContent}'s seven fields the content record actually set.\n * `beat` now carries straight into the IR's own `Slide.beat` field too —\n * reached from `PageSpec`, not `PageContent` (same source as `id`/`type`/\n * `heading`, since `beat` is spec-owned, not per-page content) — as of\n * the P1 variety wave's task 1. Previously dropped here as a spec-only\n * authoring anchor, `SlideSchema.beat`'s own doc comment\n * (`../ir/index.ts`) has the full accounting of what it now does\n * downstream. `focus`/`summary` still never reach the IR for a present\n * page — they remain spec-only authoring anchors (focus steers a future\n * fill/select step, summary is \"for the fill step's own reading only\"),\n * not slide content.\n * 6. Top-level: `version` is always the literal `\"4\"` (IR's own version,\n * unrelated to the deck spec's own `version: \"1\"`) — the deck spec's own\n * `narrative` field (renamed this task from `scenario`, spec §8.1's\n * `DeckPlan`→`DeckSpec` rename, task 2) carries across into the v4 IR's\n * own `narrative` field, its value already in the new strategy/pacing\n * vocabulary (vocabulary-v4 rename, task 1 — `plan/index.ts`'s own\n * `resolveNarrative` call already validates it against that vocabulary\n * before this function ever runs).\n * `theme`/`filename`/`brand`/`meta`/`seed` (step 7) carry over from the spec when\n * present. When absent, this function omits the field from the raw\n * object it hands to {@link PptxIRSchema} rather than re-deriving IR's\n * own default value a second time — one default source of truth, the\n * schema itself (e.g. `theme` omitted here becomes `{ id: \"consulting\" }`,\n * exactly like a bare hand-authored IR that never mentions theme at all —\n * not a value this function needs to know).\n * 7. Seed: `spec.seed` present → passed through, `generatedSeed` stays\n * `undefined` on the result. Absent → {@link generateSeed} derives one\n * from `filename` + the spec's own ordered page-id list (never page\n * *content* — see that function's own doc comment for why), written to\n * `ir.seed` *and* returned as `generatedSeed` so a CLI shell can suggest\n * writing it back into the spec file (this function never touches disk\n * itself).\n * 8. Idempotence: every step above is a pure function of its inputs (no\n * randomness, no wall-clock, no reliance on unordered iteration) — two\n * calls with structurally-equal `spec`/`pages` produce deep-equal\n * results, `generatedSeed` included. Exercised directly by this module's\n * test suite rather than asserted here.\n */\nexport function assembleDeck(spec: unknown, pages: Record<string, PageContent>): AssembleResult {\n // Step 1\n const validated = validateSpec(spec)\n if (!validated.ok) {\n throw new PptfastError(formatInvalidSpecError(validated.errors))\n }\n const deckSpec = validated.spec!\n\n // Step 2 — shape guard + locked-field protection, scanned before orphan\n // detection (see this function's own doc comment for why the order is\n // observable).\n for (const page of deckSpec.pages) {\n const raw = pages[page.id]\n if (raw === undefined) continue\n if (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) {\n throw new PptfastError(`page \"${page.id}\": page content must be an object`)\n }\n for (const key of LOCKED_KEYS) {\n if (Object.hasOwn(raw, key)) {\n throw new PptfastError(`page \"${page.id}\": \"${key}\" is locked by the spec — remove it from the page file`)\n }\n }\n }\n\n // Step 3 — orphan pages keys\n const specIds = new Set(deckSpec.pages.map((page) => page.id))\n const orphanIds = Object.keys(pages).filter((id) => !specIds.has(id))\n if (orphanIds.length > 0) {\n throw new PptfastError(\n `orphan page id${orphanIds.length === 1 ? \"\" : \"s\"} ${orphanIds.map((id) => `\"${id}\"`).join(\", \")} — not in the spec, delete the page file or add the page to the spec`,\n )\n }\n\n // Steps 4 + 5 — build each slide\n const slides = deckSpec.pages.map((page) => buildSlide(page, pages[page.id]))\n\n // Step 7 — seed (computed before step 6's raw object so it can be spliced\n // straight in — spec-only, never reads `pages`, see generateSeed's own doc).\n const generatedSeed =\n deckSpec.seed === undefined ? generateSeed(deckSpec.filename, deckSpec.pages.map((page) => page.id)) : undefined\n const seed = deckSpec.seed ?? generatedSeed!\n\n // Step 6 — top-level IR fields\n const rawIr = {\n version: \"4\" as const,\n ...(deckSpec.narrative !== undefined ? { narrative: deckSpec.narrative } : {}),\n ...(deckSpec.theme !== undefined ? { theme: { id: deckSpec.theme } } : {}),\n ...(deckSpec.filename !== undefined ? { filename: deckSpec.filename } : {}),\n ...(deckSpec.brand !== undefined ? { brand: deckSpec.brand } : {}),\n meta: deckSpec.meta,\n seed,\n slides,\n }\n\n const parsed = PptxIRSchema.safeParse(rawIr)\n if (!parsed.success) {\n const detail = parsed.error.issues.map((issue) => `${issue.path.join(\".\") || \"(root)\"}: ${issue.message}`).join(\"\\n\")\n throw new PptfastError(`assembled deck did not produce valid IR:\\n${detail}`)\n }\n\n // Materialization (W4 design decision 10) — must run after the schema\n // parse above, not on `rawIr`: `resolveEffectiveLayoutId` needs the fully\n // *defaulted* IR (a spec that omits `theme`, for instance, needs\n // `parsed.data.theme.id === \"consulting\"` already filled in by the schema,\n // not the bare `rawIr` that simply omits the key).\n const { ir, materializedCount } = materializeEffectiveLayouts(parsed.data)\n\n return {\n ir,\n ...(generatedSeed !== undefined ? { generatedSeed } : {}),\n ...(materializedCount > 0 ? { materializedLayoutCount: materializedCount } : {}),\n }\n}\n\n/**\n * Writes each page's auto-selected layout id into its own `layout` field\n * (W4 design decision 10: once `assembleDeck` has built the IR, run the same\n * `resolveEffectiveLayoutId` selection over it and write the auto-picked\n * result into each page's own `layout` field) — so `deck.json`, the artifact\n * {@link assembleDeck}'s caller actually writes to disk, always names an\n * explicit layout per page instead of leaving a downstream reader (or a\n * future re-`assembleDeck` call with a different seed) to re-derive one.\n * Runs exactly once per {@link assembleDeck} call, after {@link PptxIRSchema}\n * has already produced `ir` — every input `resolveEffectiveLayoutId` needs\n * (resolved theme id, resolved narrative strategy, the final `seed`) is only\n * available on that fully-defaulted object, never on the raw pre-parse shape.\n *\n * Two cases leave a slide untouched:\n *\n * - `slide.layout` already set (the page file wrote one explicitly, `step 5`\n * of {@link assembleDeck}'s own doc comment) — skipped without even calling\n * the resolver. `resolveEffectiveLayoutId` would just echo a valid\n * archetype pin straight back anyway (its own explicit-pin short-circuit),\n * but skipping the call keeps \"an explicit page file value survives\n * untouched\" true by construction, not by coincidence of what the resolver\n * happens to do with it.\n * - The resolver returns `null` — the image-cover takeover\n * (`ImageCoverPage`'s bespoke chrome, no `LAYOUT_REGISTRY` id to name, see\n * `resolveEffectiveLayoutId`'s own doc comment). `null` has no home in\n * `layout`'s `string | undefined` shape, and this function's job is to\n * materialize *exactly* what the resolver already means by its return\n * value, never invent a representation the resolver itself doesn't have.\n *\n * Every other omitted-`layout` slide gets `resolveEffectiveLayoutId`'s\n * return value spliced in — always a real `LAYOUT_REGISTRY` id for the 13\n * built-in themes (their curated pools are never empty for any slide type).\n *\n * Reads every slide off the *same* `ir` object across the whole pass, and\n * builds a fresh `slides` array instead of mutating one in place — required\n * by `resolveEffectiveLayoutId`'s own adjacent-anti-repetition fold\n * (`../svg/effective-layout.ts`'s `resolveDeckEffectiveLayoutIds`), which\n * walks the deck once against `ir.slides` exactly as given and caches by\n * `ir` object identity: feeding it a slide whose `layout` this function's\n * own earlier iteration had already overwritten would corrupt \"slide i-1's\n * final effective id\" into \"slide i-1's materialized id\" for every later\n * slide — the same value for an auto-picked neighbor, but silently wrong the\n * moment a spec pins one page's layout explicitly next to an auto-picked one\n * (the pin would then read back as slide i-1's own \"effective id\" a second\n * time, double-counting it as a repetition risk it was never actually a\n * candidate for).\n *\n * Round-trip note: `disassembleDeck` (below) reads whatever this function\n * wrote the exact same way it reads a hand-authored pin — see that\n * function's own doc comment for why that is an accepted consequence, not a\n * bug.\n */\nfunction materializeEffectiveLayouts(ir: PptxIR): { ir: PptxIR; materializedCount: number } {\n let materializedCount = 0\n const slides = ir.slides.map((slide, index) => {\n if (slide.layout !== undefined) return slide\n const effectiveLayoutId = resolveEffectiveLayoutId(ir, slide, index)\n if (effectiveLayoutId === null) return slide\n materializedCount++\n return { ...slide, layout: effectiveLayoutId }\n })\n return materializedCount === 0 ? { ir, materializedCount } : { ir: { ...ir, slides }, materializedCount }\n}\n\n/** Step 4 (no content record → placeholder) / step 5 (content record → full slide). */\nfunction buildSlide(page: PageSpec, raw: PageContent | undefined): Record<string, unknown> {\n if (raw === undefined) {\n return {\n id: page.id,\n type: page.type,\n heading: page.heading,\n placeholder: true,\n ...(page.beat !== undefined ? { beat: page.beat } : {}),\n ...(page.summary !== undefined ? { subheading: page.summary } : {}),\n }\n }\n return {\n id: page.id,\n type: page.type,\n heading: page.heading,\n ...(page.beat !== undefined ? { beat: page.beat } : {}),\n ...(raw.components !== undefined ? { components: raw.components } : {}),\n ...(raw.layout !== undefined ? { layout: raw.layout } : {}),\n ...(raw.arrangement !== undefined ? { arrangement: raw.arrangement } : {}),\n ...(raw.background !== undefined ? { background: raw.background } : {}),\n ...(raw.image_side !== undefined ? { image_side: raw.image_side } : {}),\n ...(raw.footnote !== undefined ? { footnote: raw.footnote } : {}),\n ...(raw.notes !== undefined ? { notes: raw.notes } : {}),\n }\n}\n\n// ── disassembleDeck ─────────────────────────────────────────────────────\n\n/** Heading synthesized for a bare IR slide whose own `heading` is missing or\n * blank (`SlideSchema.heading` is optional with no default — a hand-authored\n * IR is free to omit it entirely — but `PageSpecSchema.heading` is\n * required, non-empty, spec §5: a page spec without a heading has no\n * reasonable default). Adjudication (W5 task-3 brief flags this as an open\n * call): a fixed, deterministic placeholder was picked over synthesizing\n * from the slide's first text-bearing component, because there frequently\n * isn't one to find — a `kpi_cards`-only or `chart`-only content slide has\n * no paragraph/bullets/quote text at all, so a \"first text content\"\n * heuristic would need its own silent fallback for exactly the slides most\n * likely to hit this path, plus ad-hoc truncation to clear the 48-char spec\n * heading gate (`CAPACITY.headingMaxChars`, `./index.ts`). A single fixed\n * string is total, deterministic, always legal against that gate, and — as\n * a visibly-fake title — a much louder signal to fill in a real heading\n * than a truncated content fragment that might accidentally read as\n * intentional. */\nconst UNTITLED_HEADING = \"Untitled\"\n\n/**\n * Inverse of {@link assembleDeck}: reconstructs `{ spec, pages }` from an\n * existing IR well enough that `assembleDeck(...disassembleDeck(ir))`\n * reproduces every slide's content — but the map is not lossless in both\n * directions, only in the direction the round trip actually exercises\n * (IR → spec/pages → IR). Fields with no IR-side home never survive being\n * written *to* the IR in the first place, so there is nothing here to read\n * back:\n *\n * - `focus` never appears on any produced {@link PageSpec} — it is a\n * spec-only authoring anchor with no corresponding `Slide` field at all\n * (see {@link assembleDeck} step 5's doc comment). Nothing here could\n * recover a value that was never written anywhere. `beat` used to share\n * this fate but no longer does (P1 variety wave, task 1): it is now a\n * plain passthrough on both sides, exactly like `layout`/`heading` below —\n * `assembleDeck` step 5/6 reads `pageSpec.beat` into `slide.beat`, this\n * function reads `slide.beat` straight back below.\n * - `summary` is recovered *only* for a placeholder slide, by reversing\n * step 4's `summary` → `subheading` injection (`slide.subheading` back to\n * `pageSpec.summary`). A non-placeholder slide's own `subheading` — a\n * legitimate, independent `Slide` field a hand-authored bare IR is free to\n * set — has no {@link PageContent} field to land in (spec §7's pages/\n * record is deliberately narrower than `Slide` itself, see that\n * interface's own doc comment) and no `summary` semantics either (`summary`\n * only ever flows to a *placeholder*'s `subheading`, never the reverse for\n * a filled page) — so it is dropped. Same for `decor`: a real `Slide`\n * field, absent from `PageContent`'s shape entirely.\n * - `theme.style` / `theme.brand` overrides collapse to a bare theme-id\n * string (`DeckSpecSchema.theme` has no shape for either) — only `theme.id`\n * survives. That `theme.brand` is `ThemeSchema.brand` (`BrandConfigSchema`\n * — `suppressFooterOnCardContent`/`suppressFooterRule`, footer-chrome\n * flags owned by the *theme*) — not to be confused with the deck-level\n * `brand` field below, a different, unrelated schema despite the shared\n * name.\n * - `ir.assets.images` is not part of this function's return value at all —\n * `{ spec, pages }` has no `assets` field, and this module stays zero-fs\n * by design (this file's own top comment), so it has no way to write an\n * `assets/` directory itself. Any `asset_id` reference inside a copied\n * `components`/`background` survives untouched ({@link extractPageContent}\n * copies both as-is), but the underlying image bytes are deliberately left\n * for the caller: the CLI shell closes that gap. `runDisassemble`\n * (`../cli/commands.ts`) walks the *input* IR's `ir.assets.images` itself\n * and materializes every entry into `<outDir>/assets/<id>.<ext>`\n * (`writeDeckAssets`, `../cli/deck-dir.ts`) so a later `readDeckDir`'s\n * `scanAssets` re-registers them exactly like any other deck-directory\n * asset. Skipping that shell-side step is not a doc-comment nuance — it\n * reproduces as a real bug: an image deck disassembles with every\n * `asset_id` left dangling, then re-assembles and renders with the image\n * silently missing.\n *\n * Round-trip-safe despite the above, worth calling out because of that name\n * collision: the top-level `brand` field (`BrandSchema` — `logo_asset_id` /\n * `position`, the deck logo/position `BrandChrome` reads,\n * `src/svg/BrandChrome.tsx`) is a plain passthrough on both sides\n * ({@link assembleDeck} step 6 reads `spec.brand` into `ir.brand` — this\n * function reads `ir.brand` back into `spec.brand` below) — carried through\n * unmodified, same as `narrative`/`filename`/`seed`, never\n * synthesized or dropped.\n *\n * `layout` deserves a different kind of callout: it round-trips as plain\n * content like any other field ({@link extractPageContent} copies\n * `slide.layout` into `PageContent.layout` whenever the slide has one, no\n * special case) — but a `deck.json` produced by {@link assembleDeck} now\n * carries a `layout` on nearly every slide (W4 design decision 10's\n * materialization, see that function's own doc comment), even on pages whose\n * *original page file* never set one. Nothing on `Slide` marks which is\n * which — `layout` is just a string either way — so disassembling an\n * already-assembled `deck.json` writes every materialized pick into the\n * regenerated page file as if it had been an explicit pin all along, and a\n * later re-`assembleDeck` call skips materialization for that page from then\n * on, same as any hand-authored pin. Accepted, not a bug: `disassembleDeck`\n * is a one-time bare-IR importer into an editable project directory, not a\n * round-trip channel for `deck.json` itself. A deck project's own\n * `pages/<id>.json` files are the durable, edit-in-place artifacts —\n * `deck.json` is downstream output, and feeding it back through\n * `disassembleDeck` (instead of editing the project directory that produced\n * it) is what actually costs those pages their revision-stability\n * re-selection eligibility going forward. A real, user-visible narrowing,\n * worth knowing about here, not worth adding code to guard against for a\n * usage pattern nothing in this codebase actually exercises.\n *\n * Two structural fields are synthesized rather than copied when the source\n * slide omits them, each documented where it is generated:\n * {@link UNTITLED_HEADING} for a missing/blank `heading`, and a positional\n * `p-<1-based-ordinal>-<type>` scheme for a missing `id` (stable across\n * repeated calls on the same IR — it is a pure function of slide position\n * and type — but, unlike a spec-assigned id, *not* stable across inserting\n * or reordering slides — out of scope here, since a bare IR with no `id` at\n * all has no stabler identity to fall back to in the first place).\n *\n * That generated `p-<ordinal>-<type>` id is safe by construction as a\n * page/asset file-name segment (W5 whole-branch review finding 1, verified\n * — not just asserted — rather than also routing it through\n * `assertSafeFileSegment`, `../cli/deck-dir.ts`): `<ordinal>` is\n * `index + 1`, always a plain non-negative integer, and `<type>` is a\n * `Slide[\"type\"]`, a closed schema enum (`\"cover\" | \"chapter\" | \"content\" |\n * \"ending\"`, `SlideSchema.type` in `../ir/index.ts`) — neither half can ever\n * contain a `/`, a `\\`, or resolve to `\"..\"`, so the joined id can't either.\n * A carried-over `slide.id` (the `??` branch's other side) has no such\n * guarantee — that is the one this function passes straight through\n * unchecked, same as every other field {@link extractPageContent} copies —\n * which is exactly why the write-time gate in `../cli/deck-dir.ts` (not this\n * function) is what actually closes the vulnerability.\n */\nexport function disassembleDeck(ir: PptxIR): { spec: DeckSpec; pages: Record<string, PageContent> } {\n const pages: Record<string, PageContent> = {}\n const pageSpecs: PageSpec[] = ir.slides.map((slide, index) => {\n const id = slide.id ?? `p-${index + 1}-${slide.type}`\n const heading = slide.heading !== undefined && slide.heading.trim() !== \"\" ? slide.heading : UNTITLED_HEADING\n const pageSpec: PageSpec = {\n id,\n type: slide.type,\n heading,\n ...(slide.beat !== undefined ? { beat: slide.beat } : {}),\n ...(slide.placeholder === true && slide.subheading !== undefined ? { summary: slide.subheading } : {}),\n }\n if (slide.placeholder !== true) pages[id] = extractPageContent(slide)\n return pageSpec\n })\n\n const spec: DeckSpec = {\n version: \"1\",\n // `ir.narrative` (v4 field, vocabulary-v4 rename) carries straight into\n // the deck spec's own `narrative` field — its value is\n // already in the new strategy/pacing vocabulary, no remapping needed.\n ...(ir.narrative !== undefined ? { narrative: ir.narrative } : {}),\n theme: ir.theme.id,\n filename: ir.filename,\n ...(ir.seed !== undefined ? { seed: ir.seed } : {}),\n ...(ir.brand !== undefined ? { brand: ir.brand } : {}),\n meta: ir.meta,\n pages: pageSpecs,\n }\n\n return { spec, pages }\n}\n\n/** Non-placeholder-slide half of {@link disassembleDeck} — the same seven\n * fields {@link buildSlide} injects, read back off the slide. `components`\n * is included only when non-empty: `Slide.components` always defaults to\n * `[]` (never `undefined`, `SlideSchema` in `../ir`), but that default and\n * an author explicitly wanting an empty list are indistinguishable, so an\n * empty array is treated the same as \"omitted\" — round-trips to the exact\n * same `[]` either way once re-defaulted by `assembleDeck`. */\nfunction extractPageContent(slide: Slide): PageContent {\n const content: PageContent = {}\n if (slide.components.length > 0) content.components = slide.components\n if (slide.layout !== undefined) content.layout = slide.layout\n if (slide.arrangement !== undefined) content.arrangement = slide.arrangement\n if (slide.background !== undefined) content.background = slide.background\n if (slide.image_side !== undefined) content.image_side = slide.image_side\n if (slide.footnote !== undefined) content.footnote = slide.footnote\n if (slide.notes !== undefined) content.notes = slide.notes\n return content\n}\n","/**\n * Deterministic, pure `deck.plan.json` → `deck.spec.json` migration (spec\n * §9.2, vocabulary-v4 rename, task 2). Mirrors `../ir/migrate.ts`'s\n * `migrateIrV3ToV4` in spirit — mechanical field rename only, no schema\n * validation, no model call, no content rewrite (same posture spec §9.3\n * states for the IR v3→v4 primitive: \"只做已声明的结构映射,不运行模型,\n * 不重写内容,不重新选择 layout\" — this function is that same contract\n * applied to the other artifact this rename touches). A caller should run\n * `validateSpec` (`./index.ts`) against the result to confirm it lands as a\n * legal deck spec, exactly as it would for a `deck.spec.json` authored by\n * hand — this function itself never parses against `DeckSpecSchema`.\n *\n * Field-for-field, value-for-value per spec §9.2's table:\n *\n * ```text\n * deck.plan.json → deck.spec.json\n * scenario → narrative\n * pages[].rhythm → pages[].beat\n * 其余字段 → 原样保留\n * ```\n *\n * Takes the raw, `JSON.parse`d `deck.plan.json` contents (`unknown`) rather\n * than an already-validated shape — unlike `migrateIrV3ToV4` (which takes an\n * already-`PptxIRV3Schema.parse`d object), there is no schema left in this\n * codebase a pre-rename plan file could validate against: `DeckSpecSchema`\n * (`./index.ts`) already requires the *post*-rename field names (`narrative`,\n * `beat`), so parsing a plan-shaped document against it would just fail on\n * the very keys this function exists to rename. Non-object input, or a\n * `pages` value that isn't an array, passes through completely unchanged —\n * reporting a malformed source file is `runMigrate`'s job\n * (`../cli/commands.ts`), not this mechanical rename step's.\n */\nexport function migrateDeckPlanToSpec(raw: unknown): unknown {\n if (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) return raw\n const { scenario, pages, ...rest } = raw as Record<string, unknown>\n const result: Record<string, unknown> = { ...rest }\n if (scenario !== undefined) result.narrative = scenario\n if (Array.isArray(pages)) {\n result.pages = pages.map(migratePageRhythmToBeat)\n } else if (pages !== undefined) {\n // Not an array — structurally invalid either way, left untouched for\n // `validateSpec` to report on its own terms (same \"mechanical, not\n // validating\" posture as the rest of this function).\n result.pages = pages\n }\n return result\n}\n\n/** `pages[].rhythm` → `pages[].beat` (spec §9.2) — applied per page so a\n * mix of already-migrated and not-yet-migrated page objects (unlikely in\n * practice, but not this function's job to rule out) still converts\n * correctly: a page with no `rhythm` key passes through unchanged. */\nfunction migratePageRhythmToBeat(page: unknown): unknown {\n if (typeof page !== \"object\" || page === null || Array.isArray(page)) return page\n const { rhythm, ...rest } = page as Record<string, unknown>\n const next: Record<string, unknown> = { ...rest }\n if (rhythm !== undefined) next.beat = rhythm\n return next\n}\n"],"mappings":"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","names":["z","z"]}
|
package/dist/cli.js
CHANGED
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
resolveSpecThemeId,
|
|
11
11
|
specJsonSchema,
|
|
12
12
|
validateSpec
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-PGSVXCDR.js";
|
|
14
14
|
import {
|
|
15
15
|
installNodePlatform
|
|
16
16
|
} from "./chunk-HFRNKYZ6.js";
|
|
17
17
|
import {
|
|
18
18
|
AUDIENCE_VALUES,
|
|
19
|
+
FORMAT_BY_MIME,
|
|
20
|
+
MIME_BY_SNIFFED_FORMAT,
|
|
19
21
|
NARRATIVE_PRESETS,
|
|
20
22
|
PACING_BUDGETS,
|
|
21
23
|
PptfastError,
|
|
@@ -30,9 +32,10 @@ import {
|
|
|
30
32
|
listThemes,
|
|
31
33
|
renderSlideSvg,
|
|
32
34
|
resolveNarrative,
|
|
35
|
+
sniffImageFormat,
|
|
33
36
|
styleJsonSchema,
|
|
34
37
|
validateIr
|
|
35
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-7B3Y4FZZ.js";
|
|
36
39
|
import {
|
|
37
40
|
getPlatform
|
|
38
41
|
} from "./chunk-L524YK63.js";
|
|
@@ -152,8 +155,24 @@ async function resolveLocalAssets(ir, baseDir) {
|
|
|
152
155
|
} catch {
|
|
153
156
|
throw new PptfastError(`asset "${name}": cannot read image file ${abs} (from src "${src}")`);
|
|
154
157
|
}
|
|
155
|
-
|
|
158
|
+
if (bytes.length === 0) {
|
|
159
|
+
throw new PptfastError(`asset "${name}": image file ${abs} is zero bytes \u2014 re-export or re-select the file`);
|
|
160
|
+
}
|
|
161
|
+
const ext = extname(abs).toLowerCase();
|
|
162
|
+
const mime = MIME_BY_EXT[ext];
|
|
156
163
|
if (mime) {
|
|
164
|
+
const sniffed = sniffImageFormat(bytes);
|
|
165
|
+
if (sniffed === null) {
|
|
166
|
+
throw new PptfastError(
|
|
167
|
+
`asset "${name}": image file ${abs} has a corrupt or unrecognized header (extension claims ${mime}) \u2014 re-export or re-select the file`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const expected = FORMAT_BY_MIME[mime];
|
|
171
|
+
if (expected && sniffed !== expected) {
|
|
172
|
+
throw new PptfastError(
|
|
173
|
+
`asset "${name}": image file ${abs} is named "${ext}" but its bytes are actually ${MIME_BY_SNIFFED_FORMAT[sniffed]} \u2014 rename the file to match its real format, or re-export/re-save it as a genuine ${mime}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
157
176
|
asset.src = `data:${mime};base64,${bytes.toString("base64")}`;
|
|
158
177
|
continue;
|
|
159
178
|
}
|
|
@@ -716,7 +735,7 @@ function placeholderNote(ir) {
|
|
|
716
735
|
}
|
|
717
736
|
async function runValidate(irPath, cwd = process.cwd()) {
|
|
718
737
|
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
719
|
-
const { raw, isDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
|
|
738
|
+
const { raw, baseDir, isDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
|
|
720
739
|
await applyDeckConfig(raw, { cwd, projectHit, userHit });
|
|
721
740
|
const v = validateIr(raw);
|
|
722
741
|
if (!v.ok)
|
|
@@ -724,6 +743,7 @@ async function runValidate(irPath, cwd = process.cwd()) {
|
|
|
724
743
|
`invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
|
|
725
744
|
${formatIssues(v.errors)}`
|
|
726
745
|
);
|
|
746
|
+
await resolveLocalAssets(v.ir, baseDir);
|
|
727
747
|
const ok = `OK \u2014 ${v.ir.slides.length} slides, theme "${v.ir.theme.id}"`;
|
|
728
748
|
const notes = [];
|
|
729
749
|
const warnNote = warningsNote(v.warnings);
|