@liustack/pptwise 0.22.0 → 0.24.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 (42) hide show
  1. package/README.md +72 -41
  2. package/README.zh-CN.md +72 -41
  3. package/dist/{chunk-M35M4QUC.js → chunk-2CA3JRFR.js} +460 -499
  4. package/dist/chunk-2CA3JRFR.js.map +1 -0
  5. package/dist/{chunk-WL5KWYKS.js → chunk-G7WQ7KSP.js} +34800 -30625
  6. package/dist/chunk-G7WQ7KSP.js.map +1 -0
  7. package/dist/{chunk-VUOLBHD7.js → chunk-RQUKZYSH.js} +1 -1
  8. package/dist/{chunk-VUOLBHD7.js.map → chunk-RQUKZYSH.js.map} +1 -1
  9. package/dist/{chunk-3ZUKISTY.js → chunk-VEZGVYJS.js} +2 -2
  10. package/dist/cli.js +911 -462
  11. package/dist/cli.js.map +1 -1
  12. package/dist/index.d.ts +5320 -1534
  13. package/dist/index.js +15 -13
  14. package/dist/node.d.ts +1 -1
  15. package/dist/node.js +2 -2
  16. package/dist/{pixel-audit-H5K6JK3X.js → pixel-audit-M5Q5WCST.js} +4 -4
  17. package/dist/pixel-audit-M5Q5WCST.js.map +1 -0
  18. package/dist/{registry-C0GJH7ZT.d.ts → registry-D8bkC8ff.d.ts} +1 -1
  19. package/dsh/preview-tool.js +20 -17
  20. package/package.json +2 -1
  21. package/skills/pptwise/SKILL.md +133 -70
  22. package/skills/pptwise/SKILL.zh-CN.md +136 -73
  23. package/skills/pptwise/references/branding.md +34 -8
  24. package/skills/pptwise/references/branding.zh-CN.md +36 -8
  25. package/skills/pptwise/references/components.md +63 -33
  26. package/skills/pptwise/references/components.zh-CN.md +63 -33
  27. package/skills/pptwise/references/density.md +23 -9
  28. package/skills/pptwise/references/density.zh-CN.md +23 -9
  29. package/skills/pptwise/references/images.md +26 -13
  30. package/skills/pptwise/references/images.zh-CN.md +26 -13
  31. package/skills/pptwise/references/layouts.md +30 -26
  32. package/skills/pptwise/references/layouts.zh-CN.md +30 -26
  33. package/skills/pptwise/references/spec.md +143 -69
  34. package/skills/pptwise/references/spec.zh-CN.md +143 -69
  35. package/skills/pptwise/references/validate.md +33 -45
  36. package/skills/pptwise/references/validate.zh-CN.md +33 -45
  37. package/skills/pptwise/scripts/run.ps1 +1 -1
  38. package/skills/pptwise/scripts/run.sh +1 -1
  39. package/dist/chunk-M35M4QUC.js.map +0 -1
  40. package/dist/chunk-WL5KWYKS.js.map +0 -1
  41. package/dist/pixel-audit-H5K6JK3X.js.map +0 -1
  42. /package/dist/{chunk-3ZUKISTY.js.map → chunk-VEZGVYJS.js.map} +0 -0
package/dist/index.js CHANGED
@@ -2,28 +2,31 @@ import {
2
2
  BrandThemeFileSchema,
3
3
  DeckSpecSchema,
4
4
  PageSpecSchema,
5
- PptxIRV3Schema,
6
5
  SPEC_PAGE_COUNT_RANGE,
6
+ THEME_PRESETS,
7
7
  VERSION,
8
8
  assembleDeck,
9
9
  buildAssetBrief,
10
+ copyThemePreset,
10
11
  disassembleDeck,
11
12
  extractBrandTheme,
13
+ forkTheme,
12
14
  formatSpecIssues,
13
- migrateDeckPlanToSpec,
14
- migrateIrV3ToV4,
15
+ getThemePreset,
16
+ isThemePresetId,
15
17
  parseBrandThemeFile,
16
18
  registerBrandThemeFile,
17
19
  resolveSpecThemeId,
18
20
  specJsonSchema,
19
21
  validateSpec
20
- } from "./chunk-M35M4QUC.js";
22
+ } from "./chunk-2CA3JRFR.js";
21
23
  import {
22
24
  AUDIENCE_VALUES,
23
25
  BUILTIN_THEME_IDS,
24
26
  BrandConfigSchema,
25
27
  COMPONENT_TYPES,
26
28
  DEFAULT_NARRATIVE,
29
+ KIND_VALUES,
27
30
  NARRATIVE_PRESETS,
28
31
  PACING_BUDGETS,
29
32
  PACING_VALUES,
@@ -31,7 +34,6 @@ import {
31
34
  PptxIRSchema,
32
35
  STRATEGY_DEFINITIONS,
33
36
  STRATEGY_VALUES,
34
- StyleOverrideSchema,
35
37
  ThemeSchema,
36
38
  auditDeck,
37
39
  formatIssues,
@@ -43,12 +45,11 @@ import {
43
45
  registerTheme,
44
46
  renderSlideSvg,
45
47
  resolveNarrative,
46
- styleJsonSchema,
47
48
  validateIr
48
- } from "./chunk-WL5KWYKS.js";
49
+ } from "./chunk-G7WQ7KSP.js";
49
50
  import {
50
51
  installPlatform
51
- } from "./chunk-VUOLBHD7.js";
52
+ } from "./chunk-RQUKZYSH.js";
52
53
  export {
53
54
  AUDIENCE_VALUES,
54
55
  BUILTIN_THEME_IDS,
@@ -57,34 +58,36 @@ export {
57
58
  COMPONENT_TYPES,
58
59
  DEFAULT_NARRATIVE,
59
60
  DeckSpecSchema,
61
+ KIND_VALUES,
60
62
  NARRATIVE_PRESETS,
61
63
  PACING_BUDGETS,
62
64
  PACING_VALUES,
63
65
  PageSpecSchema,
64
66
  PptwiseError,
65
67
  PptxIRSchema,
66
- PptxIRV3Schema,
67
68
  SPEC_PAGE_COUNT_RANGE,
68
69
  STRATEGY_DEFINITIONS,
69
70
  STRATEGY_VALUES,
70
- StyleOverrideSchema,
71
+ THEME_PRESETS,
71
72
  ThemeSchema,
72
73
  VERSION,
73
74
  assembleDeck,
74
75
  auditDeck,
75
76
  buildAssetBrief,
77
+ copyThemePreset,
76
78
  disassembleDeck,
77
79
  extractBrandTheme,
80
+ forkTheme,
78
81
  formatIssues,
79
82
  formatSpecIssues,
80
83
  generatePptx,
81
84
  getInstalledThemeIds,
82
85
  getThemeDefinition,
86
+ getThemePreset,
83
87
  installPlatform,
84
88
  irJsonSchema,
89
+ isThemePresetId,
85
90
  listThemes,
86
- migrateDeckPlanToSpec,
87
- migrateIrV3ToV4,
88
91
  parseBrandThemeFile,
89
92
  registerBrandThemeFile,
90
93
  registerTheme,
@@ -92,7 +95,6 @@ export {
92
95
  resolveNarrative,
93
96
  resolveSpecThemeId,
94
97
  specJsonSchema,
95
- styleJsonSchema,
96
98
  validateIr,
97
99
  validateSpec
98
100
  };
package/dist/node.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { P as PptwisePlatform, i as installPlatform } from './registry-C0GJH7ZT.js';
1
+ export { P as PptwisePlatform, i as installPlatform } from './registry-D8bkC8ff.js';
2
2
 
3
3
  /** Wire Node implementations (linkedom DOM, sharp image re-encode + SVG
4
4
  * rasterize) into the SDK. */
package/dist/node.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  installNodePlatform
3
- } from "./chunk-3ZUKISTY.js";
3
+ } from "./chunk-VEZGVYJS.js";
4
4
  import {
5
5
  installPlatform
6
- } from "./chunk-VUOLBHD7.js";
6
+ } from "./chunk-RQUKZYSH.js";
7
7
  export {
8
8
  installNodePlatform,
9
9
  installPlatform
@@ -5,11 +5,11 @@ import {
5
5
  blendOver,
6
6
  contrastRatio2 as contrastRatio,
7
7
  renderSlideSvg
8
- } from "./chunk-WL5KWYKS.js";
8
+ } from "./chunk-G7WQ7KSP.js";
9
9
  import {
10
10
  findRemoteAssetRef,
11
11
  getPlatform
12
- } from "./chunk-VUOLBHD7.js";
12
+ } from "./chunk-RQUKZYSH.js";
13
13
 
14
14
  // src/platform/browser.ts
15
15
  function isSecurityError(e) {
@@ -88,7 +88,7 @@ async function rasterizeSvgInBrowser(svgMarkup, width, height) {
88
88
  }
89
89
  }
90
90
 
91
- // src/svg/audit/pixel-audit.ts
91
+ // src/audit/pixel-audit.ts
92
92
  var PIXEL_CANVAS_W = CANVAS_W_PX;
93
93
  var PIXEL_CANVAS_H = CANVAS_H_PX;
94
94
  var PIXEL_HARD_FINDING_MAX_RATIO = 1.5;
@@ -215,4 +215,4 @@ export {
215
215
  runPixelContrastAudit,
216
216
  stripTextNodes
217
217
  };
218
- //# sourceMappingURL=pixel-audit-H5K6JK3X.js.map
218
+ //# sourceMappingURL=pixel-audit-M5Q5WCST.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/platform/browser.ts","../src/audit/pixel-audit.ts"],"sourcesContent":["import { findRemoteAssetRef, type RasterizedImage } from \"./registry\"\n\n/**\n * Browser default for `rasterizeSvg` (audit-v2 phase B, spec §4.3/§11.8) —\n * native `Image` + `OffscreenCanvas`/`<canvas>` only, zero new dependency.\n * Applied at the pixel-audit call site (`../audit/pixel-audit.ts`) the\n * same way `domParser`'s `?? globalThis.DOMParser` fallback already works\n * (`deck-audit.ts`'s `parseSvg`) — nothing calls `installPlatform()`\n * automatically in a browser, so this is a plain fallback function, not\n * something wired through `installPlatform()` itself. Lives outside\n * `src/platform/node.ts` (which imports `linkedom`/`sharp`) so it can sit\n * inside `src/index.ts`'s own browser-safe dependency closure without\n * pulling in either.\n *\n * Two explicit-failure paths, both load-bearing per the audit-v2 controller\n * ruling on browser remote assets (own tests: `browser.test.ts`):\n *\n * 1. `findRemoteAssetRef` — scanned *before* ever touching `Image`/canvas.\n * An `<img>`/`Image` load of an `http(s):` asset happens in a restricted\n * context: the resource is silently dropped rather than reliably\n * tainting the canvas, so without this guard a remote-image slide would\n * rasterize to a *blank* region and get sampled as if that blank were\n * the real background — exactly the \"checked nothing, reported clean\"\n * failure this wave rules out. (`node.ts`'s Sharp implementation shares\n * this same guard for a different reason — see `findRemoteAssetRef`'s own\n * doc comment.)\n * 2. `getImageData`'s own `SecurityError` — kept as a fallback for\n * whatever the markup scan didn't anticipate (a future asset kind, a\n * same-origin-but-still-tainting edge case): caught and re-thrown as an\n * explicit, readable error rather than left as a raw `DOMException` (or,\n * worse, silently producing zeroed pixel data some engines return\n * instead of throwing).\n */\n\ninterface Minimal2dContext {\n drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void\n getImageData(sx: number, sy: number, sw: number, sh: number): ImageData\n}\n\ninterface MinimalCanvas {\n getContext(id: \"2d\"): Minimal2dContext | null\n}\n\n/** `DOMException` is *not* an `Error` subclass per the WebIDL spec (real\n * browsers: `new DOMException(\"x\", \"SecurityError\") instanceof Error` is\n * `false`) — duck-type on `.name` rather than `instanceof Error`. */\nfunction isSecurityError(e: unknown): boolean {\n return typeof e === \"object\" && e !== null && (e as { name?: unknown }).name === \"SecurityError\"\n}\n\n/**\n * Maximum time (ms) to wait for `Image.onload`/`onerror` to fire before\n * treating the decode as stuck and rejecting explicitly, rather than\n * hanging the whole `rasterizeSvgInBrowser` call forever — unlike the\n * Node/Sharp path (`node.ts`'s `rasterizeSvg`, a synchronous, bounded\n * call), a browser `Image` decode has no timeout of its own, and a\n * genuinely stuck one (a browser/OS decoder bug, an unresponsive tab) would\n * otherwise leave the caller (e.g. a pixel-contrast audit) awaiting forever\n * with no way to know why. 30s: generous for even a large local decode —\n * this rasterizer only ever reaches `Image` for a local/data-URI SVG\n * (`findRemoteAssetRef` already rejected any remote asset reference above)\n * — but short enough that a stuck decode fails loud well within any\n * interactive caller's own patience.\n */\nexport const IMAGE_LOAD_TIMEOUT_MS = 30_000\n\nfunction loadImage(url: string): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => {\n reject(\n new Error(\n `rasterizeSvg: timed out after ${IMAGE_LOAD_TIMEOUT_MS}ms waiting for the browser to decode the rasterized SVG as an image (a stuck decode, never a silent hang)`,\n ),\n )\n }, IMAGE_LOAD_TIMEOUT_MS)\n const img = new Image()\n img.onload = () => {\n clearTimeout(timer)\n resolve(img)\n }\n img.onerror = () => {\n clearTimeout(timer)\n reject(new Error(\"rasterizeSvg: the browser could not decode the rasterized SVG as an image\"))\n }\n img.src = url\n })\n}\n\nfunction createCanvas(width: number, height: number): MinimalCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") {\n return new OffscreenCanvas(width, height) as unknown as MinimalCanvas\n }\n if (typeof document !== \"undefined\") {\n const canvas = document.createElement(\"canvas\")\n canvas.width = width\n canvas.height = height\n return canvas as unknown as MinimalCanvas\n }\n throw new Error(\n 'rasterizeSvg unavailable — in Node, call installNodePlatform() from \"@liustack/pptwise/node\" first (the pptwise CLI does this automatically); in a browser, OffscreenCanvas or a DOM canvas is required',\n )\n}\n\nexport async function rasterizeSvgInBrowser(svgMarkup: string, width: number, height: number): Promise<RasterizedImage> {\n const remoteRef = findRemoteAssetRef(svgMarkup)\n if (remoteRef) {\n throw new Error(\n `rasterizeSvg: refusing to rasterize an SVG that references a remote image (${remoteRef}) — only data-URI (or other local) assets are supported (a remote asset would silently drop when loaded this way, not rasterize)`,\n )\n }\n if (typeof Image === \"undefined\") {\n throw new Error(\n 'rasterizeSvg unavailable — in Node, call installNodePlatform() from \"@liustack/pptwise/node\" first (the pptwise CLI does this automatically); in a browser, the Image constructor is required',\n )\n }\n\n const blob = new Blob([svgMarkup], { type: \"image/svg+xml\" })\n const url = URL.createObjectURL(blob)\n try {\n const img = await loadImage(url)\n const canvas = createCanvas(width, height)\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) throw new Error(\"rasterizeSvg: could not obtain a 2d canvas context\")\n ctx.drawImage(img, 0, 0, width, height)\n let imageData: ImageData\n try {\n imageData = ctx.getImageData(0, 0, width, height)\n } catch (e) {\n if (isSecurityError(e)) {\n throw new Error(\n \"rasterizeSvg: the canvas was tainted while reading back pixel data (a cross-origin or otherwise untrusted asset) — only data-URI (or other local) assets are supported\",\n )\n }\n throw e\n }\n return { width: imageData.width, height: imageData.height, data: imageData.data }\n } finally {\n URL.revokeObjectURL(url)\n }\n}\n","import type { PptxIR } from \"@/ir\"\nimport { renderSlideSvg } from \"../api\"\nimport { CANVAS_H_PX, CANVAS_W_PX } from \"../constants\"\nimport { rasterizeSvgInBrowser } from \"../platform/browser\"\nimport { getPlatform, type RasterizedImage } from \"../platform/registry\"\nimport {\n __collectImageBackedTextRuns,\n blendOver,\n contrastRatio,\n type AuditFinding,\n type ImageBackedTextRun,\n} from \"./deck-audit\"\n\n/**\n * Optional pixel-level contrast audit (audit-v2 phase B, spec §4.3) — the\n * one pixel blind spot the deterministic SVG audit can't see: text painted\n * directly over a bare or too-faintly-scrimmed `<image>`, where\n * `deck-audit.ts`'s own SVG-color walk correctly gives up rather than guess\n * (`ImageBackedTextRun`, `__collectImageBackedTextRuns`). Never imported by\n * `deck-audit.ts` at the top level — `auditDeck`'s `pixels: true` branch\n * reaches this module through a lazy `import(\"./pixel-audit\")` instead\n * (that function's own doc comment explains why: this file statically\n * depends on `deck-audit.ts` for its shared primitives, so a *static*\n * import the other way would form a module cycle; nothing here needs to be\n * loaded at all for the far more common `pixels` left unset case).\n *\n * Flow per spec §4.3, steps 2-6:\n * 1. `__collectImageBackedTextRuns` — reuse the exact same background-\n * resolution walk the deterministic audit already ran, just reading its\n * \"could not resolve\" runs instead of discarding them. A page with none\n * skips rasterization entirely (no rasterizer call, no risk of that\n * page's own remote-asset guard ever firing) — real cost only where\n * there is real work to do.\n * 2. `stripTextNodes` — the \"去文字克隆\" clone.\n * 3. `rasterizeSvg(stripped, 1280, 720)` — platform primitive (Sharp in\n * Node, native canvas in a browser — see `resolveRasterizer`).\n * 4/5. `worstCaseSample` — grid-sample each run's estimated box against the\n * rasterized pixels, tracking the least-favorable (lowest-ratio) point.\n * 6. Only a sample below `PIXEL_HARD_FINDING_MAX_RATIO` becomes a finding —\n * spec §4.3's own anti-false-positive gate, see that constant's doc\n * comment.\n */\n\nconst PIXEL_CANVAS_W = CANVAS_W_PX\nconst PIXEL_CANVAS_H = CANVAS_H_PX\n\n/**\n * Below this, a pixel-sampled contrast finding is emitted regardless of the\n * real WCAG target (`ImageBackedTextRun.required`) — spec §4.3's own \"为了\n * 控制误报,首版只有低于 1.5:1 才进入 hard finding\" gate. Pixel sampling\n * carries antialiasing/rasterizer noise the SVG-only walk never has to deal\n * with (spec §11.10's determinism footnote: no cross-platform byte\n * guarantee for this layer) — `node-rasterize.test.ts`'s own transparency\n * probe found up to ~1/255-per-channel sequential-blend rounding drift,\n * nowhere near enough to move a real ratio across this floor. Deliberately\n * far below either real WCAG floor (3/4.5) so only a genuinely broken\n * pairing fires, not a borderline-but-fine one.\n */\nconst PIXEL_HARD_FINDING_MAX_RATIO = 1.5\n\n/**\n * Strip every `<text>...</text>` (and self-closing `<text/>`) element from\n * SVG markup — spec §4.3 step 2's \"去文字克隆\" (clone with text removed). A\n * plain string replace, not a DOM parse/remove/reserialize round-trip:\n * removing an element has zero effect on sibling/ancestor geometry in SVG\n * (no reflow, unlike HTML), so a round-trip would only add risk — a\n * serializer (linkedom in Node) producing markup that doesn't byte-match\n * what a real browser's `XMLSerializer` would, for zero benefit. Sound\n * under two preconditions this renderer's own architecture already\n * guarantees: `renderToStaticMarkup` (React) HTML/XML-escapes every text\n * *node*, so a literal `</text>` substring appearing in `markup` can only\n * ever be a real closing tag, never escaped slide content; and `<text>` is\n * never nested inside another `<text>` anywhere in `src/render` (verified\n * across every emitter while building this task).\n */\nconst TEXT_ELEMENT_RE = /<text\\b[^>]*\\/>|<text\\b[^>]*>[\\s\\S]*?<\\/text>/g\n\nexport function stripTextNodes(markup: string): string {\n return markup.replace(TEXT_ELEMENT_RE, \"\")\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n // Rounds first: callers now include averaged (non-integer) channel values\n // (`averageWindow`'s own doc comment) alongside the original raw-integer\n // ones — `Math.round` is a no-op for the latter, so this stays byte-for-\n // byte compatible with every pre-existing call shape.\n const toHex = (v: number) => Math.round(v).toString(16).padStart(2, \"0\")\n return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()\n}\n\n/**\n * Ascent/descent as a fraction of `fontSize`, defining the vertical band\n * `worstCaseSample` grids over — no real font metrics are available at\n * audit time (this renderer never embeds/queries a font file), so both are\n * the same kind of declared-size-relative heuristic `deck-audit.ts`'s own\n * `TEXT_DESCENT_RATIO` (0.25, used for v-overflow/derived-box-height) is —\n * `SAMPLE_DESCENT_RATIO` mirrors it exactly for consistency between the two\n * auditors' notion of \"how far below the baseline a glyph's ink extends\".\n * `SAMPLE_ASCENT_RATIO` (0.75) is this task's own addition — no existing\n * constant to mirror since nothing before this needed a text run's *top*\n * edge — a standard approximation for common UI sans-serif cap-height/\n * ascender proportion.\n */\nconst SAMPLE_ASCENT_RATIO = 0.75\nconst SAMPLE_DESCENT_RATIO = 0.25\n\n/**\n * Minimum contiguous low-contrast patch size (px, both axes, at the\n * 1280×720 rasterization scale) the sampling design below guarantees never\n * to miss — \"glyph scale\": at or below the narrowest highlight/shadow\n * sliver a single character can plausibly introduce (the deep-acceptance\n * review's own demonstrated miss used a 24px-wide patch, comfortably above\n * this floor with margin to spare). Anchors both `SAMPLE_STRIDE_PX`\n * (coverage) and the `SAMPLE_STRIDE_PX` vs `AGGREGATION_HALF_PX` margin\n * (noise robustness) — see `worstCaseSample`'s own doc comment for the\n * covering argument these three constants together satisfy.\n */\nconst MIN_GUARANTEED_PATCH_PX = 10\n\n/**\n * Sample-center spacing (px), both axes — half of `MIN_GUARANTEED_PATCH_PX`,\n * computed rather than duplicated as a literal so the two constants can\n * never drift apart. The standard \"sample at <= half the feature size\"\n * covering rule. Replaces\n * the old fixed 5×3-point grid (`SAMPLE_COLS`/`SAMPLE_ROWS`), whose own\n * justifying comment (\"15 points is already enough...\") the deep-acceptance\n * review falsified with a hand-verified repro: a real 1.03:1 contrast patch\n * sitting 35px from the nearest sample column (columns were 70px apart at\n * ImageCoverPage's real org-line scale) produced zero findings, yet the\n * identical patch was caught the moment it landed on a column — pure\n * alignment luck, not a real \"worst-case band\" guarantee. A dense,\n * position-independent stride closes that gap entirely (see the coverage\n * proof below) instead of shrinking it.\n */\nconst SAMPLE_STRIDE_PX = MIN_GUARANTEED_PATCH_PX / 2\n\n/**\n * Half-width (px) of the small square window averaged at each sample\n * center before the worst-case comparison — the noise-robustness half of\n * this design. A lone single-pixel outlier (rasterizer antialiasing noise\n * under a glyph edge, photo grain) is diluted to at most 1/9 of a 3×3\n * window's mean, never enough on its own to pull a genuinely-safe\n * surrounding patch's averaged ratio under the 1.5 hard-finding floor (this\n * file's own test: a single near-black (8) pixel inside an otherwise-232\n * 3×3 window averages to ~207.5, nowhere near dark enough to fail near-\n * black text). Small enough — window width 3, well under `SAMPLE_STRIDE_PX\n * × 2 = 10` — that it cannot itself straddle a genuine\n * `MIN_GUARANTEED_PATCH_PX`-wide bad patch and a safe neighbor closely\n * enough to dilute a real defect back above the floor (this file's own\n * test: a real 12px-wide dark patch at the same spot the noise test uses\n * still produces a finding).\n */\nconst AGGREGATION_HALF_PX = 1\n\ninterface WorstCaseSample {\n ratio: number\n background: string\n}\n\n/**\n * Dense sample-center positions from `lo` to `hi` inclusive, `SAMPLE_STRIDE_PX`\n * apart. Always includes both endpoints, even when the span isn't an exact\n * stride multiple (the last gap can be shorter than the stride, never\n * longer) — a run's own edges are never under-sampled relative to its\n * interior. `[lo]` when the span has collapsed to (or below) a point, same\n * degenerate-input guard the old fixed-fraction grid had.\n */\nfunction samplePositions(lo: number, hi: number): number[] {\n if (hi <= lo) return [lo]\n const positions: number[] = []\n for (let v = lo; v < hi; v += SAMPLE_STRIDE_PX) positions.push(v)\n positions.push(hi)\n return positions\n}\n\n/**\n * Mean RGB of the `(2*AGGREGATION_HALF_PX+1)²` pixel block centered at\n * `(cx, cy)` (rounded to the nearest pixel) — the noise-robustness\n * aggregation step, see `AGGREGATION_HALF_PX`'s own doc comment. Skips any\n * pixel that's off-canvas or below full alpha, same indeterminate rule the\n * original single-point design used; a window with at least one usable\n * pixel still contributes its partial average rather than being discarded\n * outright, maximizing real coverage near a run's own box edges. Returns\n * `null` only when the window contained zero usable pixels.\n */\nfunction averageWindow(image: RasterizedImage, cx: number, cy: number): { r: number; g: number; b: number } | null {\n const x0 = Math.round(cx)\n const y0 = Math.round(cy)\n let sumR = 0\n let sumG = 0\n let sumB = 0\n let count = 0\n for (let dy = -AGGREGATION_HALF_PX; dy <= AGGREGATION_HALF_PX; dy++) {\n const y = y0 + dy\n if (y < 0 || y >= image.height) continue\n for (let dx = -AGGREGATION_HALF_PX; dx <= AGGREGATION_HALF_PX; dx++) {\n const x = x0 + dx\n if (x < 0 || x >= image.width) continue\n const i = (y * image.width + x) * 4\n const alpha = image.data[i + 3]!\n if (alpha < 255) continue\n sumR += image.data[i]!\n sumG += image.data[i + 1]!\n sumB += image.data[i + 2]!\n count++\n }\n }\n if (count === 0) return null\n return { r: sumR / count, g: sumG / count, b: sumB / count }\n}\n\n/**\n * Grid-sample `run`'s estimated box against `image` at a dense,\n * deterministic stride (`SAMPLE_STRIDE_PX`) — each sample point itself a\n * small-window average (`averageWindow`/`AGGREGATION_HALF_PX`) rather than\n * one raw pixel — tracking the least-favorable (lowest) contrast ratio\n * found overall: spec §4.3 step 6's \"WCAG 最不利带\" (worst-case band).\n *\n * **Coverage guarantee:** any axis-aligned contiguous low-contrast patch at\n * least `MIN_GUARANTEED_PATCH_PX` (10px) wide *and* tall is always fully\n * covered by at least one sample's aggregation window, regardless of the\n * patch's position relative to the grid — no alignment/phase assumption,\n * unlike the fixed grid this replaces. Proof, one axis at a time: sample\n * centers sit at a fixed stride S=5px; a window centered at `c` is fully\n * inside a patch spanning `[a, a+10]` exactly when\n * `c ∈ [a+1, a+9]` (window half-width 1px each side) — an interval of\n * length 8 >= S, so by the covering property of a grid spaced S apart (any\n * interval of length >= S must contain a grid point, since consecutive\n * centers are only S apart), that interval always contains at least one\n * sample center. The same argument applies independently on the\n * perpendicular axis, so a 2D patch of that minimum size is always hit on\n * both axes at once. (The deep-acceptance review's demonstrated miss used a\n * 24px patch — comfortably inside this guarantee with margin to spare.)\n *\n * **Noise robustness:** see `AGGREGATION_HALF_PX`'s own doc comment — the\n * same small window that provides the guarantee above also means a single\n * noisy pixel can't flip a genuinely-safe patch into a false finding.\n *\n * Returns `null` when *no* sample window anywhere yielded any usable pixel\n * at all (every window skipped, or the run's box fell entirely outside the\n * rasterized canvas) — the caller treats that as \"nothing proven either\n * way\", not a finding.\n */\nfunction worstCaseSample(run: ImageBackedTextRun, image: RasterizedImage): WorstCaseSample | null {\n const top = run.baseline - run.fontSize * SAMPLE_ASCENT_RATIO\n const bottom = run.baseline + run.fontSize * SAMPLE_DESCENT_RATIO\n let worst: WorstCaseSample | null = null\n\n const ys = samplePositions(top, bottom)\n const xs = samplePositions(run.left, run.right)\n\n for (const y of ys) {\n for (const x of xs) {\n const avg = averageWindow(image, x, y)\n if (!avg) continue\n\n const bgHex = rgbToHex(avg.r, avg.g, avg.b)\n const effective = blendOver(run.fill, bgHex, run.alpha)\n const ratio = contrastRatio(effective, bgHex)\n if (!worst || ratio < worst.ratio) worst = { ratio, background: bgHex }\n }\n }\n return worst\n}\n\nexport interface PixelContrastIssue {\n text: string\n fill: string\n /** The small-window-averaged RGB (hex) at the worst-case sample center —\n * a rasterized-and-aggregated sample, never a resolved SVG paint (see\n * `averageWindow`). */\n background: string\n ratio: number\n required: number\n fontSize: number\n}\n\nfunction pixelContrastMessage(issue: PixelContrastIssue): string {\n return (\n `text \"${issue.text}\" has a pixel-sampled contrast ratio of ${issue.ratio.toFixed(2)}:1 against its image background ` +\n `(sampled ${issue.background}, target ${issue.required}:1) — this text sits directly on an image with no resolvable ` +\n `solid backing color; reposition it, add an opaque-enough scrim behind it, or recolor the text`\n )\n}\n\ntype Rasterizer = (svgMarkup: string, width: number, height: number) => Promise<RasterizedImage>\n\n/**\n * `getPlatform().rasterizeSvg` (Sharp once `installNodePlatform()` ran) or\n * the browser default — the exact same `?? fallback` shape `deck-audit.ts`'s\n * `parseSvg` already uses for `domParser`. Never throws itself: an\n * environment with neither capability surfaces through\n * `rasterizeSvgInBrowser`'s own curated \"rasterizeSvg unavailable\" error the\n * first time the returned function is actually called (its `Image`/canvas\n * capability guards — see `browser.ts`), which is the explicit-failure\n * contract spec §11.7's \"契约层\" asks for either way.\n */\nfunction resolveRasterizer(): Rasterizer {\n return getPlatform().rasterizeSvg ?? rasterizeSvgInBrowser\n}\n\nasync function pixelFindingsForPage(\n markup: string,\n page: number,\n slideId: string | undefined,\n rasterize: Rasterizer,\n): Promise<AuditFinding[]> {\n const runs = __collectImageBackedTextRuns(markup)\n if (runs.length === 0) return []\n\n const stripped = stripTextNodes(markup)\n const image = await rasterize(stripped, PIXEL_CANVAS_W, PIXEL_CANVAS_H)\n\n const findings: AuditFinding[] = []\n for (const run of runs) {\n const sampled = worstCaseSample(run, image)\n if (!sampled) continue\n if (sampled.ratio < PIXEL_HARD_FINDING_MAX_RATIO) {\n const issue: PixelContrastIssue = {\n text: run.text,\n fill: run.fill,\n background: sampled.background,\n ratio: sampled.ratio,\n required: run.required,\n fontSize: run.fontSize,\n }\n findings.push({\n page,\n ...(slideId !== undefined ? { slideId } : {}),\n code: \"low-contrast\",\n message: pixelContrastMessage(issue),\n // `source: \"pixels\"` distinguishes this from an SVG-color-resolved\n // low-contrast finding's own `detail` shape (`ContrastIssue`,\n // `deck-audit.ts`) — same `code`, since both are the same category\n // of defect (text fails contrast against its real background), just\n // measured through a different, more reliable source for that\n // background's color.\n detail: { ...issue, source: \"pixels\" },\n })\n }\n }\n return findings\n}\n\n/**\n * Test-only re-export (`__`-prefixed, same \"SDK-internal, not part of any\n * public barrel\" convention `deck-audit.ts`'s own `__collectBgRegions`/\n * `__pathBoundingBox` establish): lets `pixel-audit.test.ts` exercise the\n * sampling-grid + `PIXEL_HARD_FINDING_MAX_RATIO` threshold logic directly,\n * with a hand-crafted `rasterize` function returning exact, controlled\n * pixel data — real component geometry (`image-pages.tsx`'s `DarkScrim`, in\n * particular) turned out unable to organically produce a sub-1.5 ratio for\n * *any* photo brightness at the org-line's single-scrim-layer position\n * (confirmed empirically while building this task's own test suite: even a\n * pure-white source image only reaches ~1.83), which is the calibration\n * working as designed (spec §4.3's own \"control false positives\" gate) but\n * makes the threshold-crossing branch unreachable through real IR/layout\n * fixtures alone.\n */\nexport const __pixelFindingsForPage = pixelFindingsForPage\n\n/**\n * The pixel-audit pass over an already-valid deck — `auditDeck(ir, {pixels:\n * true})`'s own async branch (`deck-audit.ts`). Independently walks\n * `ir.slides` (skipping placeholders the same way `runDeterministicAudit`\n * does) rather than reusing that function's own loop, so this module has no\n * static dependency back on it beyond the few named imports at the top of\n * this file — `renderSlideSvg` is pure and synchronous, so re-rendering\n * each non-placeholder slide a second time costs a little CPU, never\n * correctness (spec's own \"no second renderer\" non-goal is about a\n * *different* rendering path, not calling the one true renderer twice).\n *\n * Sequential, not `Promise.all` — deliberately bounds rasterization\n * concurrency to 1 (each Sharp call is real CPU-bound work) and makes the\n * page-order-stable output an obvious property of the code rather than an\n * incidental one `Promise.all`'s array-order guarantee happens to provide.\n *\n * A rasterization failure on any one page (missing platform capability, a\n * remote asset reference, a tainted canvas) propagates out of this function\n * entirely, aborting the whole pixel pass rather than silently skipping\n * just that page — spec §11.7's \"契约层\": a requested-but-failed pixel audit\n * is an explicit failure, never a partial \"clean\".\n */\nexport async function runPixelContrastAudit(ir: PptxIR): Promise<AuditFinding[]> {\n const rasterize = resolveRasterizer()\n const findings: AuditFinding[] = []\n for (let i = 0; i < ir.slides.length; i++) {\n const slide = ir.slides[i]!\n if (slide.placeholder) continue\n const page = i + 1\n const slideId = slide.id\n const markup = renderSlideSvg(ir, i)\n findings.push(...(await pixelFindingsForPage(markup, page, slideId, rasterize)))\n }\n return findings\n}\n"],"mappings":"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","names":[]}
@@ -24,7 +24,7 @@ interface PptwisePlatform {
24
24
  * Rasterize SVG markup to a fixed-size pixel buffer (audit-v2 phase B,
25
25
  * spec §4.3/§11.7) — the one primitive the optional pixel-contrast audit
26
26
  * needs and the *only* one Sharp/canvas-shaped work is allowed to hide
27
- * behind (`src/svg/audit/pixel-audit.ts` never imports a rasterizer
27
+ * behind (`src/audit/pixel-audit.ts` never imports a rasterizer
28
28
  * itself). `installNodePlatform()` wires this to Sharp; a real browser
29
29
  * gets its own default (`./browser.ts`'s `rasterizeSvgInBrowser`) applied
30
30
  * at the call site the same way `domParser`'s `?? globalThis.DOMParser`
@@ -80,7 +80,7 @@
80
80
 
81
81
  import { randomUUID } from 'node:crypto'
82
82
  import { cpSync, existsSync, realpathSync, renameSync, rmSync } from 'node:fs'
83
- import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
83
+ import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
84
84
  import { homedir } from 'node:os'
85
85
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
86
86
  import { runChild } from './spawnHidden.js'
@@ -1021,8 +1021,8 @@ async function isFile(path) {
1021
1021
  }
1022
1022
 
1023
1023
  /**
1024
- * Where a deck project directory target actually lives, for the one thing
1025
- * `assemble` cannot carry into the snapshot: a deck-local `theme.json`.
1024
+ * Where a deck project directory target actually lives so its adjacent
1025
+ * `theme.json` can be copied into the snapshot directory.
1026
1026
  *
1027
1027
  * The CLI auto-loads that file for a deck *directory* only, so an assembled
1028
1028
  * IR naming a brand theme would fail to render from anywhere else. Path-form
@@ -1043,6 +1043,15 @@ async function locateDeckDir(target) {
1043
1043
  return undefined
1044
1044
  }
1045
1045
 
1046
+ /** Copy an adjacent theme file into the immutable snapshot directory. */
1047
+ async function snapshotThemeFile(sourceDir, outDir) {
1048
+ const source = join(sourceDir, THEME_FILE)
1049
+ if (!(await isFile(source))) return undefined
1050
+ const themeFile = join(outDir, THEME_FILE)
1051
+ await copyFile(source, themeFile)
1052
+ return themeFile
1053
+ }
1054
+
1046
1055
  /**
1047
1056
  * Copy a single-file IR target, rewriting every local asset src to an
1048
1057
  * absolute path.
@@ -1184,33 +1193,27 @@ async function inlineLocalImages(snapshotPath) {
1184
1193
  * snapshot on its own pins *which* deck rather than the bytes it is made of —
1185
1194
  * which left every later run free to re-read those files. `inlineLocalImages`
1186
1195
  * closes that for the formats it can (see its own note for the ones it
1187
- * cannot).
1196
+ * cannot). An adjacent `theme.json` is copied into the same directory for
1197
+ * both single-file IR and deck-directory targets.
1188
1198
  */
1189
1199
  async function captureSnapshot(cliPath, target, outDir, signal) {
1190
1200
  const snapshot = join(outDir, SNAPSHOT_FILE)
1191
1201
  if (await isFile(target)) {
1192
1202
  await snapshotIrFile(target, snapshot)
1193
1203
  await inlineLocalImages(snapshot)
1194
- return { snapshot, themeFile: undefined }
1204
+ const themeFile = await snapshotThemeFile(dirname(resolve(target)), outDir)
1205
+ return { snapshot, themeFile }
1195
1206
  }
1196
1207
  await runCli(cliPath, ['assemble', target, '-o', snapshot], signal)
1197
1208
  await inlineLocalImages(snapshot)
1198
1209
  const deckDir = await locateDeckDir(target)
1199
1210
  if (deckDir) {
1200
- const source = join(deckDir, THEME_FILE)
1201
- if (await isFile(source)) {
1202
- const themeFile = join(outDir, THEME_FILE)
1203
- await writeFile(themeFile, await readFile(source, 'utf8'))
1204
- return { snapshot, themeFile }
1205
- }
1211
+ const themeFile = await snapshotThemeFile(deckDir, outDir)
1212
+ if (themeFile) return { snapshot, themeFile }
1206
1213
  }
1207
1214
  return { snapshot, themeFile: undefined }
1208
1215
  }
1209
1216
 
1210
- function themeArgs(record) {
1211
- return record.themeFile ? ['--theme-file', record.themeFile] : []
1212
- }
1213
-
1214
1217
  /**
1215
1218
  * Read what the card's thumbnail strip needs: the manifest, plus the SVG of
1216
1219
  * every page the strip will actually draw, inlined so the card needs no
@@ -1726,7 +1729,7 @@ export function createPreviewService(cliPath) {
1726
1729
  // that everything the user later does with this preview refers back to.
1727
1730
  await runCli(
1728
1731
  cliPath,
1729
- ['preview', snapshot, '-o', outDir, '--html', ...themeArgs({ themeFile })],
1732
+ ['preview', snapshot, '-o', outDir, '--html'],
1730
1733
  exec?.signal,
1731
1734
  )
1732
1735
  const bundle = await readPreviewBundle(outDir)
@@ -1754,7 +1757,7 @@ export function createPreviewService(cliPath) {
1754
1757
  const draftArgs = bundle.draft ? ['--draft'] : []
1755
1758
  await runCli(
1756
1759
  cliPath,
1757
- ['render', snapshot, '-o', pptxPath, ...draftArgs, ...themeArgs({ themeFile })],
1760
+ ['render', snapshot, '-o', pptxPath, ...draftArgs],
1758
1761
  exec?.signal,
1759
1762
  )
1760
1763
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/pptwise",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Stable, editable PPTX generation for AI agents — semantic IR in, native DrawingML out",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -54,6 +54,7 @@
54
54
  "bench:score": "tsx tests/bench/score.mts",
55
55
  "docs:list": "node scripts/docs-list.js",
56
56
  "gallery": "tsx scripts/gallery.mts",
57
+ "gen:skill-refs": "tsx scripts/gen-skill-refs.mts",
57
58
  "evals:gallery": "tsx evals/gallery/run.mts",
58
59
  "fixtures:unassigned-bytes": "tsx scripts/write-unassigned-bytes.mts",
59
60
  "prepublishOnly": "pnpm check && pnpm e2e",
@@ -1,100 +1,163 @@
1
1
  ---
2
2
  name: pptwise
3
- description: Generate a native, editable PPTX deck from an outline, notes, or a document using the pptwise CLI (semantic IR → validate → render). Use when the user asks to create a PPT, deck, presentation, or slides (做PPT/生成PPT/制作演示文稿/幻灯片) and wants a stable, editable, brand-consistent result rather than freeform drawn slides.
3
+ description: Generate a native, editable PPTX deck from an outline, notes, or source material with the pptwise CLI. Use when the user asks to create a PPT, deck, presentation, or slides and wants a deterministic, editable, theme-consistent result.
4
4
  ---
5
5
 
6
- # pptwise deck generation playbook
6
+ # pptwise deck generation playbook
7
7
 
8
- pptwise turns a JSON IR (intermediate representation) into a native DrawingML `.pptx` every shape stays editable in PowerPoint. You own the content model. The tool owns layout, style, and motion. You never draw SVG or position anything: pick from a controlled vocabulary and let the validate gate catch what will not fit.
8
+ pptwise turns semantic JSON into native DrawingML `.pptx` files. Text and shapes stay editable in PowerPoint. Authors describe what each page is doing and which typed components it contains. The theme and engine own the page face, geometry, color, type, and decoration.
9
9
 
10
- ## Run it
10
+ Use this one-way chain. Do not skip backward or decide two layers at once.
11
11
 
12
- Everything in this playbook runs through the CLI: schema, spec/assemble, validate, render, audit, preview, serve, brand extract. Every one of those commands goes through the launcher bundled with this skill, which resolves a working runtime for you. Replace `<skill-dir>` with the directory this SKILL.md lives in:
12
+ ```text
13
+ intent -> narrative -> theme binding -> spec with kind -> fill -> render
14
+ ```
15
+
16
+ ## Run the CLI
17
+
18
+ Run every command through the launcher bundled beside this file. Replace `<skill-dir>` with this skill directory.
13
19
 
14
20
  ```bash
15
21
  bash <skill-dir>/scripts/run.sh <args> # macOS / Linux
16
22
  powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args> # Windows
17
23
  ```
18
24
 
19
- It tries a compatible `pptwise` on `PATH` first, then `npx`, then `bunx`, forwarding your arguments and its exit code unchanged. Nothing to install first, and the version it runs is pinned to this skill. Exit 78 means no runtime at all: relay the `nextSteps` from its stderr JSON instead of retrying.
25
+ The launcher tries a compatible `pptwise` on `PATH`, then `npx`, then `bunx`. It forwards arguments and exit codes unchanged. Exit 78 means no JavaScript runtime was found. Relay the `nextSteps` from stderr instead of retrying.
20
26
 
21
- Wherever this playbook writes `pptwise <args>`, run it through that launcher.
27
+ If scripts are unavailable, use the first available route:
22
28
 
23
- Right after an install, and any time a command misbehaves in a way the error message does not explain, run `pptwise doctor` before anything else. It reports the runtime, every installed skill copy and whether one is stale, the dsh plugin's version, which optional capabilities are present, and a self-test render. Relay what it says instead of guessing.
29
+ 1. `pptwise <args>` when the installed major version matches this skill and is at least the pinned version.
30
+ 2. `npx --yes --package @liustack/pptwise@0.24.0 pptwise <args>`.
31
+ 3. `bunx --bun @liustack/pptwise@0.24.0 <args>`.
32
+ 4. Otherwise ask the user to install Node 22.19+ or Bun.
24
33
 
25
- If your harness forbids running scripts, work down the same order by hand and use the first line that applies:
34
+ Run `pptwise doctor` after installation and whenever a failure is not explained by its own error. Relay the result instead of guessing.
26
35
 
27
- 1. A `pptwise` on `PATH` at the same major version as the pin below and no older: `pptwise <args>`.
28
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/pptwise@0.22.0 pptwise <args>`.
29
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/pptwise@0.22.0 <args>`.
30
- 4. Otherwise tell the user no JavaScript runtime was found, and that installing Node 22.19+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not report pptwise itself as broken.
36
+ ## Read live truth first
37
+
38
+ Never write IR or a spec from memory. Run these at the start of each deck task:
39
+
40
+ ```bash
41
+ pptwise schema
42
+ pptwise schema --spec
43
+ pptwise narratives --json
44
+ pptwise themes --json
45
+ ```
46
+
47
+ Scan the workspace before asking questions. An existing `deck.spec.json` already records the narrative, bound theme, page order, headings, and content kinds. A deck-local `theme.json`, a workspace `themes/` file, a named theme, or a supplied `.thmx`, `.potx`, or branded `.pptx` is a theme signal.
31
48
 
32
49
  ## Workflow
33
50
 
34
- Interview → spec → pages → validate → audit → render. Re-enter at the smallest step that captures a change. A very small deck (a handful of slides) may skip the spec file and write a single IR, still validating with `pptwise validate`. Never write IR or a spec from memory of a previous session or from this file. Run these fresh every session:
51
+ ### 1. Intent
52
+
53
+ Record four facts before choosing how to tell the story: audience, desired outcome, whether the deck will be presented or circulated, and available time. Derive facts already present in the request or workspace. If a user is present and material facts are still missing, ask all unresolved questions in one round.
54
+
55
+ ### 2. Narrative
56
+
57
+ Choose the narrative before the theme. The narrative decides argument strategy, pacing, and tone. Use a named preset when it matches, or write explicit `strategy`, `pacing`, and `audience` axes. Pacing controls editorial budgets and the body-text baseline. Narrative never chooses a page face.
58
+
59
+ Confirm the narrative package before continuing. See `references/spec.md` for the compact interview and spec example.
60
+
61
+ ### 3. Bind a theme
62
+
63
+ A theme is one complete, self-contained file containing style, a page menu, optional brand rules, and occasion metadata. Its content menu serves a subset of the 11 global `kind` words. Missing words are intentional.
64
+
65
+ Resolve theme names in this order:
66
+
67
+ 1. The deck directory.
68
+ 2. A workspace `themes/` directory while walking upward.
69
+ 3. The 24 factory presets.
70
+
71
+ Use request and workspace signals to shortlist themes by `occasions` and `identity`. Compare two to four candidates with the fixed fitting-room sample:
72
+
73
+ ```bash
74
+ pptwise theme try consulting,swiss,memo
75
+ ```
76
+
77
+ Create means copy. With no existing asset, copy the closest preset into the workspace. With an Office brand file, extract its colors and fonts while copying a suitable donor menu. For a color change, fork the current theme so the whole palette is rederived and the original stays untouched.
78
+
79
+ ```bash
80
+ pptwise theme new --from consulting --id acme-report
81
+ pptwise brand extract corp.pptx -o themes/acme.theme.json --from consulting
82
+ pptwise theme fork acme --primary '#0B5FFF' --id acme-blue
83
+ ```
84
+
85
+ Write the selected theme name into `deck.spec.json` before writing page content. Bound deck commands use that name. There is no render-time theme switch.
86
+
87
+ A same-menu color fork may replace the bound theme during the workflow. A different menu means a different theme. Return to this step, keep the intent, narrative, facts, data, images, and useful copy, then rewrite the spec and fill against the new menu.
88
+
89
+ ### 4. Write the spec with `kind`
90
+
91
+ The spec locks theme, narrative, branding posture, page order, page type, heading, and the `kind` of every content page. It contains no render selection state.
92
+
93
+ - `cover`, `chapter`, and `ending` are page types and do not use `kind`.
94
+ - Every `content` page requires exactly one explicit `kind`.
95
+ - Authors write only a semantic `kind`, never a page face or geometry choice.
96
+ - `focus` and `summary` are optional writing hints.
97
+
98
+ Run `pptwise spec validate deck.spec.json` until it prints `OK`. A content `kind` outside the bound theme menu is a hard error that lists what the menu offers. Do not relabel the page merely to silence the error. Change the page's intent only when that is semantically honest, or return to the theme step.
99
+
100
+ The 11 words and their boundaries are in `references/layouts.md`. Full spec guidance and theme creation are in `references/spec.md`.
101
+
102
+ ### 5. Fill pages
103
+
104
+ Write `pages/<id>.json` in batches of at most four. A page file may contain `components`, `background`, `image_side`, `footnote`, and `notes`. Never repeat `type`, `kind`, or `heading`, because the spec owns them.
105
+
106
+ Choose components that serve the page's `kind`. `quote` is a page kind. The quotation component is `blockquote`. Component ownership and lookalike choices are in `references/components.md`. Pacing and physical capacity are in `references/density.md`. Image workflows are in `references/images.md`.
107
+
108
+ After each batch:
35
109
 
36
110
  ```bash
37
- pptwise schema # IR JSON Schema: the single source of truth
38
- pptwise schema --spec # deck spec schema
39
- pptwise narratives --json # named narrative presets (strategy/pacing/audience axes + theme recommendations)
40
- pptwise themes --json # built-in themes (id + label)
111
+ pptwise assemble deck-dir/
112
+ pptwise validate deck-dir/
41
113
  ```
42
114
 
43
- Also scan the workspace before asking anyone anything. A confirmed `deck.spec.json` already locks narrative, theme, and branding: do not re-interview, revise that deck instead. A `theme.json`, pinned `pptwise.config.json` theme, user-named theme id, or supplied `.thmx` / `.potx` / branded `.pptx` is a brand signal: extract or honor it. Do not ask whether a template exists.
44
-
45
- **Boundary-page rule:** `chapter` and `ending` pages never render `components` or `footnote`. `cover` pages never render `footnote`. A `cover` may carry `components` only when its locked layout declares a slot for them. Today that is `verdict-index` (consulting), which reads the first `bullets` block as up to three numbered arguments. Every other cover layout still drops components. Put body content on a `content` page unless you are filling that consulting argument row. Wrong/right JSON and spec writing: `references/spec.md`.
46
-
47
- 1. **Interview** (at most one round) when a user is present and any of audience, how it is told, or pacing is still unknown. Relay unresolved questions in **one** message, then stop. Do not fill them in. Q1–Q4, ★ defaults, lookup, `NARRATIVE_INTERVIEW` gate: `references/spec.md`.
48
- 2. **Spec and confirm** before any page content. Write `deck.spec.json` (opens on `cover`, closes on `ending`, everything in between is `content` or `chapter`). Run `pptwise spec validate` until `OK`, then persist a `seed`. Do not re-spec a confirmed spec. How to write it: `references/spec.md`. Branding posture: `references/branding.md`.
49
- 3. **Pages** in batches of at most 4. Write `pages/<id>.json` (`components`, optional `layout`/`notes`). Never write `type`/`heading`. Pin-only and sparse climax layouts: `references/layouts.md`. Component forms: `references/components.md`. Density, beat, capacity: `references/density.md`. Images: `references/images.md`.
50
- 4. **Validate** after every batch: `pptwise assemble deck-dir/` then `pptwise validate deck-dir/` until both print `OK`. Restructure flagged content, never delete it. The assemble/validate/audit/preview/serve loop: `references/validate.md`.
51
- 5. **Audit** once every page is filled: `pptwise audit deck-dir/` until exit 0. Do not substitute a screenshot. Then hand the deck over (`pptwise_preview`, else `preview --html`, else `serve --no-open`): `references/validate.md`.
52
- 6. **Render:** `pptwise render deck-dir/`. Report the absolute path it prints. `--draft` and `--allow-dropped-content` only when the user says so.
53
-
54
- Follow-up: edit a page → steps 3–6 on that file only. A new deck → step 1. Unrelated → do not invoke pptwise.
55
-
56
- ## Component selection
57
-
58
- | Content shape | Use | Not |
59
- |---|---|---|
60
- | 2–5 headline metrics | `kpi_cards` | `chart` |
61
- | Series data (trend, comparison, share) | `chart` (`bar`/`line`/`pie`/`funnel`/`dumbbell`/`scatter`/`area`/`donut`/`gauge`) | numbers buried in `bullets` |
62
- | Exact figures the audience reads row-by-row (price list, spec sheet, metrics-by-period grid) | `data_table` | `chart` |
63
- | Linear process, no branches | `steps` | `flowchart` |
64
- | Branching process that reaches an endpoint | `flowchart` | `steps` |
65
- | Cyclical process with no endpoint — loops back to its own start (PDCA, a product lifecycle, a flywheel, a seasonal cycle) | `cycle` | `flowchart` |
66
- | Two-sided contrast | `comparison` | two bullet lists |
67
- | System/organizational layering (a stack of bands, e.g. tech-stack layers or a maturity ladder) | `architecture` | `bullets` |
68
- | Dated milestones | `timeline` | `bullets` with dates |
69
- | Phased plan with workstreams | `roadmap` | `timeline` |
70
- | Phased plan with dated bars on a shared axis | `gantt` | `roadmap` |
71
- | One verdict or takeaway sentence | `verdict_banner` or `callout` | `paragraph` |
72
- | 2×2 strategic assessment (strengths/weaknesses/opportunities/threats) | `swot` | `matrix` |
73
- | 9-block business model canvas | `bmc` | separate `bullets`/`row_cards` |
74
- | Cumulative bridge/variance breakdown | `waterfall` | `chart` |
75
- | 2×2 macro-environment scan (political/economic/social/technological) | `pest` | `swot` |
76
- | Competitive-structure analysis (rivalry + 4 surrounding forces) | `five_forces` | `matrix` |
77
- | Two-axis value grid with color-coded cells (e.g. region × quarter) | `heatmap` | `matrix` |
78
- | Proportional flow/quantity distribution across stages (e.g. budget allocation, energy mix) | `sankey` | `chart` (funnel) or `flowchart` |
79
- | A product/software screenshot that the slide needs to read as "this is real, running software" (an app dashboard, a live product UI) | `device_mockup` | `image` |
80
- | A roster of people (team, speaker lineup, judging panel, author list) needing an identity anchor with no photo available | `people_cards` | `row_cards`/`icon_cards` |
81
- | A set of short parallel labels (a tech stack, capabilities, keywords, certifications) — labels, not described items | `tag_row` | `bullets`/`row_cards` |
82
-
83
- Lookalike pairs, field notes, and full-body types: `references/components.md`.
115
+ Fix every error and rerun both commands. Restructure content instead of deleting what validation caught.
116
+
117
+ ### 6. Audit, review, and render
118
+
119
+ When all pages are filled, run:
120
+
121
+ ```bash
122
+ pptwise audit deck-dir/
123
+ pptwise preview deck-dir/ --html
124
+ pptwise render deck-dir/
125
+ ```
126
+
127
+ `audit` must exit 0 before delivery. Add `--pixels` when text sits over photo backgrounds. Use an available `pptwise_preview` tool first. Otherwise hand over the printed `preview.html` path, or run `pptwise serve deck-dir/ --no-open` for a live browser review. Report the absolute `.pptx` path printed by `render`.
128
+
129
+ Use `--draft` or `--allow-dropped-content` only when the user explicitly requests that compromise.
130
+
131
+ ## Fast component routing
132
+
133
+ | Page intent | `kind` | Typical component |
134
+ | --- | --- | --- |
135
+ | Ordered reasoning | `points` | `bullets`, `numbered_cards`, `paragraph` |
136
+ | Reorderable inventory | `list` | `row_cards`, `icon_cards`, `tag_row`, `people_cards` |
137
+ | Side-by-side differences | `comparison` | `comparison`, `image_compare`, `matrix`, `swot` |
138
+ | Directed steps or time | `process` | `steps`, `flowchart`, `timeline`, `roadmap`, `gantt`, `cycle` |
139
+ | Numeric structure | `data` | `chart`, `data_table`, `kpi_cards`, `heatmap`, `sankey` |
140
+ | Image as the message | `photo` | `image`, `image_grid`, `device_mockup` |
141
+ | Author's own proposition | `statement` | `verdict_banner`, `callout`, or no component |
142
+ | Another speaker's words | `quote` | `blockquote` |
143
+ | One number as the message | `fact` | one-item `kpi_cards` or no component |
144
+ | Claim plus one exhibit | `evidence` | `image`, `chart`, `data_table`, `code`, `device_mockup` |
145
+ | Containment or levels | `hierarchy` | `architecture`, `bmc`, `five_forces`, `rings` |
84
146
 
85
147
  ## Rules
86
148
 
87
- - Never edit or post-process the generated `.pptx`
88
- - Never bypass a `validate` error by deleting the content it flagged — restructure it (split the slide, tighten the heading, pick a denser component type)
89
- - Public deck text follows the user's language, IR structural fields are always the English enum values from the schema
90
- - Never tell a user that a `chart`'s or `data_table`'s numbers are editable inside PowerPoint: those components render as grouped shapes and text, fully restylable and retypable, but with no native chart part or `<a:tbl>` behind them. To change the numbers, edit the IR and re-render.
149
+ - Never edit or post-process the generated `.pptx`.
150
+ - Never add coordinates, SVG, page face names, or geometry controls to authored content.
151
+ - Keep public deck copy in the user's language. Keep schema keys and enum values in English.
152
+ - Never claim that `chart` or `data_table` values are native PowerPoint data objects. They export as editable grouped shapes and text. Change figures in the source and rerender.
153
+ - Preview is read-only. Revisions go back into `deck.spec.json`, `pages/*.json`, assets, or the bound theme file.
91
154
 
92
155
  ## Read when
93
156
 
94
- - `references/spec.md` writing `deck.spec.json`, choosing page types, or running the narrative interview
95
- - `references/layouts.md` pinning a layout, including climax, quote, and evidence sparse pages
96
- - `references/components.md` a lookalike pair or a component's fields and limits
97
- - `references/density.md` pacing budgets, `beat`, capacity warnings, or slide `decor`
98
- - `references/branding.md` extracting a company template, or whether to write `branding: "full"`
99
- - `references/images.md` declaring assets, searching stock, or generating art
100
- - `references/validate.md` assemble / validate / audit / preview / serve, or revising a page
157
+ - `references/spec.md`: intent, narrative, theme creation, binding, rebinding, spec, and menu errors.
158
+ - `references/layouts.md`: choosing among the 11 `kind` words.
159
+ - `references/components.md`: component ownership, fields, and lookalike choices.
160
+ - `references/density.md`: pacing, capacity, full-page components, and local decoration.
161
+ - `references/branding.md`: deck branding posture, frameless pages, logos, and brand extraction.
162
+ - `references/images.md`: image assets, stock search, generation, and `photo` versus `evidence`.
163
+ - `references/validate.md`: assemble, validate, audit, preview, serve, render, and revision loops.