@tycoworks/tycoslide 0.7.0 → 0.9.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 (62) hide show
  1. package/README.md +6 -6
  2. package/SKILL.md +2 -1
  3. package/dist/cli.js +8 -107
  4. package/dist/engine/dom.d.ts +5 -0
  5. package/dist/engine/dom.js +19 -3
  6. package/dist/engine/fillers/filler.d.ts +21 -13
  7. package/dist/engine/fillers/filler.js +21 -27
  8. package/dist/engine/fillers/image.d.ts +46 -7
  9. package/dist/engine/fillers/image.js +78 -36
  10. package/dist/engine/fillers/table.d.ts +4 -3
  11. package/dist/engine/fillers/table.js +58 -9
  12. package/dist/engine/generate.d.ts +34 -18
  13. package/dist/engine/generate.js +234 -65
  14. package/dist/engine/index.d.ts +3 -2
  15. package/dist/engine/index.js +1 -1
  16. package/dist/engine/notes.d.ts +76 -0
  17. package/dist/engine/notes.js +313 -0
  18. package/dist/engine/types.d.ts +57 -24
  19. package/dist/engine/types.js +11 -7
  20. package/dist/index.d.ts +19 -25
  21. package/dist/index.js +65 -92
  22. package/dist/manifest.js +19 -29
  23. package/dist/markdown/blocks/code.d.ts +15 -0
  24. package/dist/markdown/blocks/code.js +50 -0
  25. package/dist/markdown/blocks/image.d.ts +2 -0
  26. package/dist/markdown/blocks/image.js +9 -0
  27. package/dist/markdown/blocks/mermaid.d.ts +15 -0
  28. package/dist/markdown/blocks/mermaid.js +227 -0
  29. package/dist/markdown/{resolvers → blocks}/mermaidTheme.d.ts +1 -1
  30. package/dist/markdown/{resolvers → blocks}/mermaidTheme.js +1 -1
  31. package/dist/markdown/blocks/registry.d.ts +16 -0
  32. package/dist/markdown/blocks/registry.js +44 -0
  33. package/dist/markdown/blocks/table.d.ts +2 -0
  34. package/dist/markdown/blocks/table.js +23 -0
  35. package/dist/markdown/blocks/text.d.ts +12 -0
  36. package/dist/markdown/blocks/text.js +90 -0
  37. package/dist/markdown/deckCompiler.d.ts +18 -30
  38. package/dist/markdown/deckCompiler.js +167 -122
  39. package/dist/markdown/index.d.ts +11 -11
  40. package/dist/markdown/index.js +9 -8
  41. package/dist/markdown/inline.d.ts +26 -0
  42. package/dist/markdown/inline.js +136 -0
  43. package/dist/markdown/mdast.d.ts +25 -0
  44. package/dist/markdown/mdast.js +49 -0
  45. package/dist/markdown/schema/deckSchema.d.ts +30 -0
  46. package/dist/markdown/schema/deckSchema.js +51 -0
  47. package/dist/markdown/schema/strict.d.ts +9 -0
  48. package/dist/markdown/schema/strict.js +18 -0
  49. package/dist/markdown/schema/themeConfigSchema.d.ts +99 -0
  50. package/dist/markdown/schema/themeConfigSchema.js +145 -0
  51. package/dist/markdown/types.d.ts +184 -137
  52. package/dist/markdown/types.js +30 -19
  53. package/package.json +7 -3
  54. package/syntax.md +25 -5
  55. package/dist/markdown/parsers.d.ts +0 -32
  56. package/dist/markdown/parsers.js +0 -233
  57. package/dist/markdown/resolvers/code.d.ts +0 -17
  58. package/dist/markdown/resolvers/code.js +0 -44
  59. package/dist/markdown/resolvers/mermaid.d.ts +0 -14
  60. package/dist/markdown/resolvers/mermaid.js +0 -89
  61. package/dist/markdown/resolvers/resolver.d.ts +0 -42
  62. package/dist/markdown/resolvers/resolver.js +0 -52
@@ -1,10 +1,54 @@
1
1
  /**
2
- * Table fill — clones specimen rows in the template's `<a:tbl>` (row 0 header,
2
+ * Table fill — clones the template's specimen rows in `<a:tbl>` (row 0 header,
3
3
  * row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
4
- * the corresponding StyledParagraph. Row cloning must stay engine-side because
5
- * it needs pptx-automizer DOM access.
4
+ * the corresponding StyledParagraph. Cells and `<a:gridCol>` entries are cloned
5
+ * or trimmed to the header count, sharing the template's total width. Row, cell,
6
+ * and grid cloning stay engine-side because they need pptx-automizer DOM access.
6
7
  */
7
- import { collectElements, isPlainObject, rebuildParagraphs, Tag } from "../dom.js";
8
+ import { Attr, collectElements, isPlainObject, rebuildParagraphs, Tag } from "../dom.js";
9
+ const EMPTY_CELL = { runs: [{ text: "" }] };
10
+ /**
11
+ * Grow or shrink a row's `<a:tc>` list to exactly `n` cells. Added columns clone
12
+ * the last existing cell (its styling/fill/margins carry over); removed columns
13
+ * drop from the right. The row is left with no stale specimen cells.
14
+ */
15
+ function normalizeCellCount(row, n) {
16
+ const tcs = collectElements(row, Tag.TABLE_CELL);
17
+ if (tcs.length === n || tcs.length === 0)
18
+ return;
19
+ if (tcs.length > n) {
20
+ for (let i = n; i < tcs.length; i++)
21
+ row.removeChild(tcs[i]);
22
+ return;
23
+ }
24
+ const specimen = tcs[tcs.length - 1];
25
+ for (let i = tcs.length; i < n; i++)
26
+ row.appendChild(specimen.cloneNode(true));
27
+ }
28
+ /**
29
+ * Rewrite the table's `<a:tblGrid>` to exactly `n` `<a:gridCol>` entries, sharing
30
+ * the template's total width evenly (the last column absorbs the rounding
31
+ * remainder so the total is conserved exactly). No-op when the count already
32
+ * matches. Leaves a grid-less table alone — malformed input we don't worsen.
33
+ */
34
+ function reconcileGrid(tbl, n) {
35
+ const grid = tbl.getElementsByTagName(Tag.TABLE_GRID)[0];
36
+ if (!grid)
37
+ return;
38
+ const cols = collectElements(grid, Tag.GRID_COL);
39
+ if (cols.length === n || cols.length === 0)
40
+ return;
41
+ const total = cols.reduce((sum, c) => sum + (Number(c.getAttribute(Attr.WIDTH)) || 0), 0);
42
+ const each = Math.round(total / n);
43
+ const specimen = cols[cols.length - 1];
44
+ for (const c of cols)
45
+ grid.removeChild(c);
46
+ for (let i = 0; i < n; i++) {
47
+ const col = specimen.cloneNode(true);
48
+ col.setAttribute(Attr.WIDTH, String(i === n - 1 ? total - each * (n - 1) : each));
49
+ grid.appendChild(col);
50
+ }
51
+ }
8
52
  /**
9
53
  * Fill a table shape by cloning specimen rows.
10
54
  *
@@ -30,15 +74,16 @@ export function fillTable(shape, table, shapeName = "") {
30
74
  // template rows are intentionally dropped — the specimen rows are re-cloned
31
75
  // per data row below.
32
76
  const dataTpls = rows.length > 2 ? [rows[1], rows[2]] : [rows[1]];
77
+ // Headers are the source of truth for column count; every row is normalized to
78
+ // it, and short/long data rows are padded/truncated to match.
79
+ const n = table.headers.length;
33
80
  const fillRow = (tpl, cells) => {
34
81
  const clone = tpl.cloneNode(true);
82
+ normalizeCellCount(clone, n);
35
83
  const tcs = collectElements(clone, Tag.TABLE_CELL);
36
- // Fill min(cells, template cells): extra data columns beyond the template's
37
- // cell count are intentionally ignored (column count is opt-in via
38
- // slot.columns, enforced in the Table filler).
39
- for (let i = 0; i < cells.length && i < tcs.length; i++) {
84
+ for (let i = 0; i < n; i++) {
40
85
  const txBody = tcs[i].getElementsByTagName(Tag.TX_BODY)[0] ?? tcs[i];
41
- rebuildParagraphs(txBody, [cells[i]], 0, undefined, shapeName);
86
+ rebuildParagraphs(txBody, [cells[i] ?? EMPTY_CELL], 0, undefined, shapeName);
42
87
  }
43
88
  return clone;
44
89
  };
@@ -47,6 +92,10 @@ export function fillTable(shape, table, shapeName = "") {
47
92
  for (let r = 0; r < table.rows.length; r++) {
48
93
  built.push(fillRow(dataTpls[r % dataTpls.length], table.rows[r]));
49
94
  }
95
+ // Column invariant: grid `<a:gridCol>` count must equal every row's `<a:tc>`
96
+ // count (both == n). `normalizeCellCount` (in fillRow) holds the per-row half;
97
+ // `reconcileGrid` holds the grid half. Run the grid half once, table-global.
98
+ reconcileGrid(tbl, n);
50
99
  for (const row of rows)
51
100
  tbl.removeChild(row);
52
101
  for (const row of built)
@@ -18,11 +18,25 @@
18
18
  * file (registered by generate(), swapped by the ImageFiller) and adjusts
19
19
  * geometry for the chosen fit. See fillers/image.ts.
20
20
  *
21
- * generate() loads the template, registers media, and for each DeckStep walks
22
- * the unified `layout.slots`. Each value in `step.content` is dispatched by the
23
- * slot's type via `FILLERS[slot.type]` (required, no default).
21
+ * generate() loads the template, registers media, and for each DeckStep clones
22
+ * the layout's base slide and calls `fillSlide`. Each value in `step.content`
23
+ * selects, by its own shape, the `Block` in the slot's `accepts` whose type it
24
+ * matches: a base-slide block fills in place; any other block is transplanted
25
+ * from its source slide onto the clone, then filled with the same callbacks.
26
+ *
27
+ * `generate()` is first below; its helpers follow (function declarations hoist).
24
28
  */
25
- import type { Config, Deck, Slot } from "./types.js";
29
+ import type { Config, Deck, DeckStep, Layout } from "./types.js";
30
+ /** Options for `generate` / `buildDeck`. */
31
+ export type GenerateOptions = {
32
+ /**
33
+ * Suppress authored speaker notes. Default `false` (notes are written and any
34
+ * template notes cloned onto slides are stripped). When `true`, no notes are
35
+ * written but inherited template notes are still stripped — the deck is
36
+ * guaranteed notes-free.
37
+ */
38
+ excludeNotes?: boolean;
39
+ };
26
40
  /**
27
41
  * Generate a PPTX file from a deck definition and a theme configuration.
28
42
  *
@@ -30,22 +44,24 @@ import type { Config, Deck, Slot } from "./types.js";
30
44
  * 1. Load the template; register it twice (as root and under an alias).
31
45
  * 2. Pre-register every image's media buffer with pptx-automizer, walking
32
46
  * the unified `step.content` for ImageFill values.
33
- * 3. For each deck step, clone the layout's source slide, then within the
34
- * addSlide callback walk `layout.slots` once and dispatch by slot.type
35
- * via `FILLERS[slot.type]`.
47
+ * 3. For each deck step, clone the layout's base slide, then within the
48
+ * addSlide callback call `fillSlide` to dispatch each content value to the
49
+ * matching `Block` (fill in place, or transplant + fill).
36
50
  * 4. Write the output PPTX.
37
51
  */
38
- export declare function generate(deck: Deck, config: Config): Promise<void>;
52
+ export declare function generate(deck: Deck, config: Config, options?: GenerateOptions): Promise<void>;
39
53
  /**
40
- * Validate that every required slot on a layout is supplied. Type/shape
41
- * validation happens in the compiler now this is a thin required-only
42
- * checker kept for tests and defensive callers.
54
+ * Fill one cloned slide. Per slot the step supplies a value for: resolve WHICH
55
+ * shape realizes it (`resolveBlock`), build WHAT to write (`FILLERS[…].callbacks`),
56
+ * and place it WHERE/HOW (`applyBlock`). Every ambiguity fails fast, naming layout
57
+ * + slot.
43
58
  *
44
- * Exported for tests; not part of the public engine surface (index.ts).
59
+ * Exported for tests; `generate()` calls it inside the `addSlide` callback.
60
+ */
61
+ export declare function fillSlide(slide: any, layout: Layout, step: DeckStep, sourceAlias: string): void;
62
+ /**
63
+ * Reject a slot whose `accepts` lists two blocks of the same type — the
64
+ * value→block lookup would silently pick the first. Called once per layout at
65
+ * build start.
45
66
  */
46
- export declare function validateContentSlots(step: {
47
- layout: string;
48
- content?: Record<string, unknown>;
49
- }, tpl: {
50
- slots: Slot[];
51
- }): void;
67
+ export declare function assertSlotsWellFormed(layout: Layout): void;
@@ -18,15 +18,21 @@
18
18
  * file (registered by generate(), swapped by the ImageFiller) and adjusts
19
19
  * geometry for the chosen fit. See fillers/image.ts.
20
20
  *
21
- * generate() loads the template, registers media, and for each DeckStep walks
22
- * the unified `layout.slots`. Each value in `step.content` is dispatched by the
23
- * slot's type via `FILLERS[slot.type]` (required, no default).
21
+ * generate() loads the template, registers media, and for each DeckStep clones
22
+ * the layout's base slide and calls `fillSlide`. Each value in `step.content`
23
+ * selects, by its own shape, the `Block` in the slot's `accepts` whose type it
24
+ * matches: a base-slide block fills in place; any other block is transplanted
25
+ * from its source slide onto the clone, then filled with the same callbacks.
26
+ *
27
+ * `generate()` is first below; its helpers follow (function declarations hoist).
24
28
  */
25
29
  import { existsSync, rmSync } from "node:fs";
26
30
  import { basename, dirname, resolve } from "node:path";
31
+ import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
27
32
  import { Automizer, modify } from "pptx-automizer";
28
33
  import { FILLERS } from "./fillers/filler.js";
29
34
  import { isImageFill } from "./fillers/image.js";
35
+ import { applyNotesToSlide, sweepOrphanNotes } from "./notes.js";
30
36
  /**
31
37
  * Generate a PPTX file from a deck definition and a theme configuration.
32
38
  *
@@ -34,12 +40,12 @@ import { isImageFill } from "./fillers/image.js";
34
40
  * 1. Load the template; register it twice (as root and under an alias).
35
41
  * 2. Pre-register every image's media buffer with pptx-automizer, walking
36
42
  * the unified `step.content` for ImageFill values.
37
- * 3. For each deck step, clone the layout's source slide, then within the
38
- * addSlide callback walk `layout.slots` once and dispatch by slot.type
39
- * via `FILLERS[slot.type]`.
43
+ * 3. For each deck step, clone the layout's base slide, then within the
44
+ * addSlide callback call `fillSlide` to dispatch each content value to the
45
+ * matching `Block` (fill in place, or transplant + fill).
40
46
  * 4. Write the output PPTX.
41
47
  */
42
- export async function generate(deck, config) {
48
+ export async function generate(deck, config, options = {}) {
43
49
  const { layouts, rootDir, template, outputDir } = config;
44
50
  const outFile = deck.output;
45
51
  const outDir = outputDir ?? process.cwd();
@@ -60,33 +66,17 @@ export async function generate(deck, config) {
60
66
  throw new Error(`Unknown layout: ${name}`);
61
67
  return match;
62
68
  };
63
- // Register the media for every image slot. Each file is validated (absolute
64
- // paths are the caller's responsibility; a stray relative path trips the
65
- // existsSync guard here or readFileSync in fillImage) and handed to
66
- // pptx-automizer once per distinct filename.
67
- const registeredMedia = new Set();
68
- for (const step of deck.steps) {
69
- for (const value of Object.values(step.content ?? {})) {
70
- if (!isImageFill(value))
71
- continue;
72
- if (!existsSync(value.path)) {
73
- throw new Error(`Layout "${step.layout}" image "${value.path}": file not found`);
74
- }
75
- const file = basename(value.path);
76
- if (registeredMedia.has(file))
77
- continue;
78
- registeredMedia.add(file);
79
- pres.loadMedia(file, dirname(value.path));
80
- }
81
- }
82
- // pptx-automizer runs modifyElement callbacks during write() and SWALLOWS any
83
- // error they throw (it logs a stack trace but keeps going, producing a broken
84
- // slide). A fill primitive's fail-fast throw would therefore never fail the
85
- // build. Collect those errors and re-surface them after write().
69
+ registerMedia(pres, deck);
70
+ assertLayoutsWellFormed(layouts);
71
+ // pptx-automizer runs fill callbacks during write() and SWALLOWS any error they
72
+ // throw (it logs a stack trace but keeps going, producing a broken slide). A
73
+ // fill primitive's fail-fast throw would therefore never fail the build. This
74
+ // wraps BOTH fill entry points — `modifyElement` (in-place) and `addElement`
75
+ // (transplant) so a throw on a transplanted shape fails the build too.
86
76
  const fillErrors = [];
87
- const captureFillErrors = (slide) => {
88
- const modifyElement = slide.modifyElement.bind(slide);
89
- slide.modifyElement = (shapeName, callbacks) => modifyElement(shapeName, callbacks.map((cb) => typeof cb === "function"
77
+ const wrapCallbacks = (callbacks) => {
78
+ const arr = Array.isArray(callbacks) ? callbacks : callbacks === undefined ? [] : [callbacks];
79
+ return arr.map((cb) => typeof cb === "function"
90
80
  ? (...args) => {
91
81
  try {
92
82
  return cb(...args);
@@ -96,34 +86,45 @@ export async function generate(deck, config) {
96
86
  throw err;
97
87
  }
98
88
  }
99
- : cb));
89
+ : cb);
100
90
  };
101
- // Populate one cloned slide: for each declared slot that the step supplies a
102
- // value for, hand the value to the filler registered for the slot's type.
103
- const fillSlide = (slide, layout, step) => {
104
- captureFillErrors(slide);
105
- for (const slot of layout.slots) {
106
- const value = step.content?.[slot.key];
107
- if (value === undefined)
108
- continue;
109
- if (typeof value === "string") {
110
- // Fallback: bare string. Compiler normally normalizes to
111
- // StyledParagraph[] / ImageFill; this branch only fires when
112
- // callers construct decks by hand.
113
- slide.modifyElement(slot.shapeName, [modify.setText(value)]);
114
- continue;
115
- }
116
- const filler = FILLERS[slot.type];
117
- if (!filler.matches(value)) {
118
- throw new Error(`Layout "${step.layout}" slot "${slot.key}" (type "${slot.type}"): expected ${filler.label}, got ${describeValue(value)}`);
119
- }
120
- filler.fill(slide, slot, value, { layoutName: step.layout });
121
- }
91
+ const captureFillErrors = (slide) => {
92
+ const modifyElement = slide.modifyElement.bind(slide);
93
+ slide.modifyElement = (shapeName, callbacks) => modifyElement(shapeName, wrapCallbacks(callbacks));
94
+ const addElement = slide.addElement.bind(slide);
95
+ slide.addElement = (presName, slideNumber, selector, callbacks) => addElement(presName, slideNumber, selector, wrapCallbacks(callbacks));
122
96
  };
97
+ // Default: write authored notes and strip any template notes automizer clones
98
+ // onto slides. When true, no notes are written but inherited notes are still
99
+ // stripped — the deck is guaranteed notes-free.
100
+ const excludeNotes = options.excludeNotes ?? false;
123
101
  for (const step of deck.steps) {
124
102
  const layout = resolveLayout(step.layout);
125
- pres.addSlide(sourceAlias, layout.slideNumber, (slide) => fillSlide(slide, layout, step));
103
+ pres.addSlide(sourceAlias, layout.baseSlide, (slide) => {
104
+ captureFillErrors(slide);
105
+ fillSlide(slide, layout, step, sourceAlias);
106
+ // In-band notes pass: automizer runs this during write() and hands us the
107
+ // OUTPUT archive (parent.targetArchive) and the real output slide number
108
+ // (parent.targetNumber), so notes map 1:1 with no slide-number mapping.
109
+ // Registered for EVERY slide — a slide without authored notes must still
110
+ // have any auto-copied template notes stripped.
111
+ slide.modify(async (_document, parent) => {
112
+ await applyNotesToSlide(toNotesArchive(parent.targetArchive), parent.targetNumber, step.notes, excludeNotes);
113
+ });
114
+ });
126
115
  }
116
+ // Sweep the template's orphaned notesSlides. This MUST be a presentation-level
117
+ // modify callback, not part of the per-slide pass: per-slide callbacks run
118
+ // during writeSlides(), BEFORE automizer truncates the template's original
119
+ // slides and renumbers the deck — at which point every template notesSlide
120
+ // still looks referenced by a not-yet-removed original slide. automizer runs
121
+ // presentation modify callbacks after truncation, when a notesSlide's
122
+ // referenced status is final. Runs regardless of excludeNotes — the leak
123
+ // exists either way. Routed through toNotesArchive so the buffered
124
+ // [Content_Types].xml write survives automizer's final buffer flush.
125
+ pres.modify(async (_xml, _index, archive) => {
126
+ await sweepOrphanNotes(toNotesArchive(archive));
127
+ });
127
128
  await pres.write(outFile);
128
129
  if (fillErrors.length > 0) {
129
130
  // The output would be a broken deck — remove it so a failed build never
@@ -134,6 +135,35 @@ export async function generate(deck, config) {
134
135
  }
135
136
  console.log(`tycoslide: built ${deck.steps.length} slide(s) → ${resolve(outDir, outFile)}`);
136
137
  }
138
+ // ── generate() helpers ───────────────────────────────────────────────────────
139
+ /**
140
+ * Register the media for every image slot. Each file is validated (absolute
141
+ * paths are the caller's responsibility; a stray relative path trips the
142
+ * existsSync guard here or readFileSync in fillImage) and handed to
143
+ * pptx-automizer once per distinct filename.
144
+ */
145
+ function registerMedia(pres, deck) {
146
+ const registeredMedia = new Set();
147
+ for (const step of deck.steps) {
148
+ for (const value of Object.values(step.content ?? {})) {
149
+ if (!isImageFill(value))
150
+ continue;
151
+ if (!existsSync(value.path)) {
152
+ throw new Error(`Layout "${step.layout}" image "${value.path}": file not found`);
153
+ }
154
+ const file = basename(value.path);
155
+ if (registeredMedia.has(file))
156
+ continue;
157
+ registeredMedia.add(file);
158
+ pres.loadMedia(file, dirname(value.path));
159
+ }
160
+ }
161
+ }
162
+ // Validate every layout once, up front (see assertSlotsWellFormed).
163
+ function assertLayoutsWellFormed(layouts) {
164
+ for (const layout of layouts)
165
+ assertSlotsWellFormed(layout);
166
+ }
137
167
  function describeValue(v) {
138
168
  if (v === null)
139
169
  return "null";
@@ -145,17 +175,156 @@ function describeValue(v) {
145
175
  }
146
176
  return typeof v;
147
177
  }
148
- // ── Content-slot validation (test-visible helper) ────────────────────────────
178
+ // ── Slot fill dispatch (composition-aware) ───────────────────────────────────
149
179
  /**
150
- * Validate that every required slot on a layout is supplied. Type/shape
151
- * validation happens in the compiler now this is a thin required-only
152
- * checker kept for tests and defensive callers.
180
+ * Fill one cloned slide. Per slot the step supplies a value for: resolve WHICH
181
+ * shape realizes it (`resolveBlock`), build WHAT to write (`FILLERS[…].callbacks`),
182
+ * and place it WHERE/HOW (`applyBlock`). Every ambiguity fails fast, naming layout
183
+ * + slot.
153
184
  *
154
- * Exported for tests; not part of the public engine surface (index.ts).
185
+ * Exported for tests; `generate()` calls it inside the `addSlide` callback.
155
186
  */
156
- export function validateContentSlots(step, tpl) {
157
- const missing = tpl.slots.filter((s) => step.content?.[s.key] === undefined).map((s) => s.key);
158
- if (missing.length > 0) {
159
- throw new Error(`Layout "${step.layout}": missing content for slot(s): ${missing.join(", ")}`);
187
+ export function fillSlide(slide, layout, step, sourceAlias) {
188
+ assertNoUnknownSlots(step, layout);
189
+ for (const slot of layout.slots) {
190
+ const value = step.content?.[slot.key];
191
+ if (value === undefined)
192
+ continue; // Empty slot: leave the base slide's shape untouched.
193
+ const block = resolveBlock(step, slot, value);
194
+ const callbacks = FILLERS[block.type].callbacks(value, targetOf(block));
195
+ applyBlock(slide, sourceAlias, layout.baseSlide, slot, block, callbacks);
196
+ }
197
+ }
198
+ /** The SlotType a resolved `*Fill` value maps to, via the filler discriminators. */
199
+ function fillTypeOf(value) {
200
+ for (const type of Object.keys(FILLERS)) {
201
+ if (FILLERS[type].matches(value))
202
+ return type;
160
203
  }
204
+ return undefined;
205
+ }
206
+ /**
207
+ * Reject a slot whose `accepts` lists two blocks of the same type — the
208
+ * value→block lookup would silently pick the first. Called once per layout at
209
+ * build start.
210
+ */
211
+ export function assertSlotsWellFormed(layout) {
212
+ for (const slot of layout.slots) {
213
+ const seen = new Set();
214
+ for (const block of slot.accepts) {
215
+ if (seen.has(block.type)) {
216
+ throw new Error(`Layout "${layout.name}" slot "${slot.key}": accepts two ${block.type} blocks; each content type may appear once.`);
217
+ }
218
+ seen.add(block.type);
219
+ }
220
+ }
221
+ }
222
+ /**
223
+ * A deck supplying content for a slot the layout doesn't declare is an authoring
224
+ * mistake, not a silent no-op. Runs per step, over `step.content`'s keys.
225
+ */
226
+ function assertNoUnknownSlots(step, layout) {
227
+ const keys = new Set(layout.slots.map((s) => s.key));
228
+ for (const key of Object.keys(step.content ?? {})) {
229
+ if (!keys.has(key)) {
230
+ const declared = layout.slots.map((s) => s.key).join(", ") || "none";
231
+ throw new Error(`Layout "${step.layout}" has no slot "${key}" (declared slots: ${declared}).`);
232
+ }
233
+ }
234
+ }
235
+ /**
236
+ * WHICH shape: pick the `Block` in `slot.accepts` whose type matches the value's
237
+ * shape. Fails fast — first on an unrecognized value, then on a value no block
238
+ * accepts (order matters; both messages are asserted).
239
+ */
240
+ function resolveBlock(step, slot, value) {
241
+ const requestedType = fillTypeOf(value);
242
+ if (requestedType === undefined) {
243
+ throw new Error(`Layout "${step.layout}" slot "${slot.key}": unrecognized content value ${describeValue(value)}.`);
244
+ }
245
+ const block = slot.accepts.find((b) => b.type === requestedType);
246
+ if (!block) {
247
+ const available = slot.accepts.map((b) => b.type).join(", ") || "none";
248
+ throw new Error(`Layout "${step.layout}" slot "${slot.key}": no block accepts ${requestedType} content (this slot accepts: ${available}).`);
249
+ }
250
+ return block;
251
+ }
252
+ /** The shape a filler targets, plus `startAt` when the (text) block declares it. */
253
+ function targetOf(block) {
254
+ const target = { shapeName: block.shapeName };
255
+ if (block.startAt !== undefined)
256
+ target.startAt = block.startAt;
257
+ return target;
258
+ }
259
+ /**
260
+ * WHERE/HOW: place the (already-built) fill callbacks. A base-slide block is
261
+ * already on the cloned slide → fill in place. Any other block is transplanted:
262
+ * pptx-automizer runs an appended shape's callbacks against the imported element
263
+ * itself, so the same callbacks refill it — position it to the slot's frame,
264
+ * then remove the base shape it supersedes (a base block, if this slot has one —
265
+ * a slot need not).
266
+ */
267
+ function applyBlock(slide, sourceAlias, baseSlide, slot, block, callbacks) {
268
+ if (block.sourceSlide === baseSlide) {
269
+ slide.modifyElement(block.shapeName, callbacks);
270
+ return;
271
+ }
272
+ slide.addElement(sourceAlias, block.sourceSlide, block.shapeName, [modify.setPosition(slot.frame), ...callbacks]);
273
+ const baseBlock = slot.accepts.find((b) => b.sourceSlide === baseSlide);
274
+ if (baseBlock && baseBlock.shapeName !== block.shapeName)
275
+ slide.removeElement(baseBlock.shapeName);
276
+ }
277
+ // ── Notes archive adapter (automizer-buffer glue) ────────────────────────────
278
+ /** Installed pptx-automizer version this notes-buffer adapter is pinned to. */
279
+ const PPTX_AUTOMIZER_VERSION = "0.8.2";
280
+ /**
281
+ * Bridge pptx-automizer's OUTPUT archive (`parent.targetArchive`) to the minimal
282
+ * {@link NotesArchive} surface, cooperating with automizer's XML buffer.
283
+ *
284
+ * automizer caches every part it edits via `readXml`/`writeXml` — for a cloned
285
+ * slide's notes that means `slide{N}.xml.rels`, `notesSlide{N}.xml.rels`, and
286
+ * `[Content_Types].xml` — as a parsed document, then re-serializes the cache over
287
+ * the zip at write() time. A plain `write`/`remove` to those parts is therefore
288
+ * silently reverted at the final flush. This adapter reads the buffered
289
+ * (automizer-current) content and steers writes/removes back through the same
290
+ * buffer, so `applyNotesToSlide` stays pure over string+xmldom while its edits
291
+ * survive. The notes PART (`notesSlide{N}.xml`) is copied raw and never buffered,
292
+ * so it round-trips as a plain zip entry. The buffer holds docs parsed by the one
293
+ * installed `@xmldom/xmldom`, so our parser/serializer operate on them safely.
294
+ */
295
+ function toNotesArchive(target) {
296
+ // Fail loud if automizer's internals no longer expose the buffer array this
297
+ // adapter steers writes through: without it, every buffered write would be
298
+ // silently reverted at the final flush, corrupting the deck with no error.
299
+ if (!Array.isArray(target.buffer)) {
300
+ throw new Error("Speaker notes: expected the pptx-automizer archive to expose a `buffer` array " +
301
+ `(pinned to pptx-automizer ${PPTX_AUTOMIZER_VERSION}); its internals may have changed. ` +
302
+ "Re-verify the notes buffer adapter before relying on it.");
303
+ }
304
+ const buffer = target.buffer;
305
+ const buffered = (file) => buffer.find((entry) => entry.relativePath === file);
306
+ return {
307
+ async read(file, type) {
308
+ const entry = buffered(file);
309
+ return entry ? new XMLSerializer().serializeToString(entry.content) : target.read(file, type);
310
+ },
311
+ async write(file, data) {
312
+ const entry = buffered(file);
313
+ if (entry)
314
+ entry.content = new DOMParser().parseFromString(data, "application/xml");
315
+ return target.write(file, data);
316
+ },
317
+ async remove(file) {
318
+ const index = buffer.findIndex((entry) => entry.relativePath === file);
319
+ if (index !== -1)
320
+ buffer.splice(index, 1);
321
+ await target.remove(file);
322
+ },
323
+ fileExists(file) {
324
+ return buffered(file) !== undefined || target.fileExists(file);
325
+ },
326
+ async folder(dir) {
327
+ return target.folder(dir);
328
+ },
329
+ };
161
330
  }
@@ -3,6 +3,7 @@ export { fillImage } from "./fillers/image.js";
3
3
  export { fillTable, isTableFill } from "./fillers/table.js";
4
4
  export { fillTemplate } from "./fillers/template.js";
5
5
  export { fillText, isTextFill } from "./fillers/text.js";
6
+ export type { GenerateOptions } from "./generate.js";
6
7
  export { generate } from "./generate.js";
7
- export type { Config, Deck, DeckStep, ImageFill, Layout, Slot, StyledParagraph, TableFill, TemplateFill, TemplateSegment, TextFill, TextRun, ThemeConfig, } from "./types.js";
8
- export { FitMode, SlotType } from "./types.js";
8
+ export type { Block, Config, Deck, DeckStep, Frame, ImageFill, Layout, Slot, StyledParagraph, TableFill, TemplateFill, TemplateSegment, TextFill, TextRun, ThemeConfig, } from "./types.js";
9
+ export { ImageFit, SlotType } from "./types.js";
@@ -4,4 +4,4 @@ export { fillTable, isTableFill } from "./fillers/table.js";
4
4
  export { fillTemplate } from "./fillers/template.js";
5
5
  export { fillText, isTextFill } from "./fillers/text.js";
6
6
  export { generate } from "./generate.js";
7
- export { FitMode, SlotType } from "./types.js";
7
+ export { ImageFit, SlotType } from "./types.js";
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Speaker-notes injection — an in-band pass run per slide during `write()`.
3
+ *
4
+ * The render library (pptx-automizer) has no public notes writer, but it does
5
+ * expose a general seam: `slide.modify((document, parent) => …)` runs while the
6
+ * deck is being written and hands us `parent.targetArchive` (the OUTPUT archive)
7
+ * and `parent.targetNumber` (the real output slide number). automizer's clone
8
+ * auto-copies the source slide's notes as `notesSlide${targetNumber}.xml` and
9
+ * wires its rels + content-type BEFORE our callback runs, so notes map 1:1 to the
10
+ * output slide number — no `sldIdLst`→rels→slideK mapping is needed.
11
+ *
12
+ * For each slide we either overwrite/synthesize its `notesSlide${N}.xml` (when the
13
+ * step authored notes) or strip any auto-copied part (when it did not, or notes
14
+ * are excluded) — so the designer's template notes never leak into the output.
15
+ */
16
+ /**
17
+ * The minimal archive surface `applyNotesToSlide` needs. pptx-automizer's
18
+ * `IArchive` satisfies it structurally (so we never import its internal type), and
19
+ * the test fake implements it over a `Map`. String-based `read`/`write` only —
20
+ * XML is parsed/serialized with the engine's own `@xmldom/xmldom` instance, never
21
+ * automizer's `readXml`/`writeXml` (mixing xmldom instances risks subtle bugs).
22
+ */
23
+ export type NotesArchive = {
24
+ read(file: string, type: "string"): Promise<string | Buffer>;
25
+ write(file: string, data: string): Promise<unknown>;
26
+ remove(file: string): Promise<void>;
27
+ fileExists(file: string): boolean;
28
+ /**
29
+ * Top-level entries of `dir` (nested `_rels` excluded). `name` is the FULL
30
+ * archive path (e.g. `ppt/notesSlides/notesSlide1.xml`). pptx-automizer's
31
+ * `IArchive.folder` satisfies this structurally.
32
+ */
33
+ folder(dir: string): Promise<{
34
+ name: string;
35
+ relativePath: string;
36
+ }[]>;
37
+ };
38
+ /**
39
+ * Build a minimal `<p:notes>` part whose body placeholder carries one `<a:p>`
40
+ * per line of `notes`. Text goes through DOM text nodes, so `& < > "` escaping
41
+ * is handled by the serializer.
42
+ */
43
+ export declare function buildNotesSlideXml(notes: string): string;
44
+ /** Next unused `rIdN` in a `.rels` XML string (max existing id + 1). */
45
+ export declare function nextFreeRId(relsXml: string): string;
46
+ /**
47
+ * Remove every notesSlide part that no live slide references. automizer drops the
48
+ * template's orphaned *slides* from the deck when building it but leaves all their
49
+ * `notesSlides` behind as unreferenced parts (plus their `[Content_Types].xml`
50
+ * Overrides), so the designer's private notes would otherwise leak into every
51
+ * output. Run once as a presentation-level pass, after the deck is assembled.
52
+ *
53
+ * A notesSlide is "referenced" iff a LIVE slide (reachable from `presentation.xml`,
54
+ * see {@link liveSlideParts}) carries a `/notesSlide` relationship pointing at it.
55
+ * Everything else is swept: the part, its rels, and its content-type Override (all
56
+ * Override removals batched into a single `[Content_Types].xml` read+write).
57
+ */
58
+ export declare function sweepOrphanNotes(archive: NotesArchive): Promise<void>;
59
+ /**
60
+ * Set (or strip) the speaker notes on one output slide, keyed on its real output
61
+ * `slideNumber` (`parent.targetNumber`). Runs inside `slide.modify` during
62
+ * `write()`, operating on the OUTPUT archive.
63
+ *
64
+ * - Authored notes (non-empty and `!excludeNotes`): if automizer auto-copied a
65
+ * notes part (`notesSlide${N}.xml` already exists), overwrite its text only —
66
+ * its rels and content-type are already correct. Otherwise synthesize the part,
67
+ * its rels (→slide + →master), a `notesSlide` rel on the slide (next free rId),
68
+ * and the `[Content_Types].xml` override.
69
+ * - No notes (or excluded): if a part was auto-copied, remove it, its rels, the
70
+ * slide's `notesSlide` rel, and the content-type override — so the designer's
71
+ * template notes never leak.
72
+ *
73
+ * Throws (no silent default) if a slide needs a NEW notes part but the template
74
+ * ships no notes master — synthesizing one is out of scope.
75
+ */
76
+ export declare function applyNotesToSlide(archive: NotesArchive, slideNumber: number, notes: string | undefined, excludeNotes: boolean): Promise<void>;