@tycoworks/tycoslide 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,8 @@ layout: Body
35
35
  title: Highlights
36
36
  ---
37
37
 
38
+ ::body::
39
+
38
40
  - Revenue up 12% quarter-over-quarter
39
41
  - Three major product launches completed
40
42
  ```
@@ -50,7 +52,7 @@ npx tycoslide build deck.md # → deck.pptx
50
52
  ```bash
51
53
  npx tycoslide build deck.md # markdown → PPTX (theme resolved from deck frontmatter)
52
54
  npx tycoslide build deck.md --no-notes # omit speaker notes from the output
53
- npx tycoslide package # generate the Agent Skill (skill.md, syntax.md, manifest.json at the theme root) + an uploadable <package-name>.zip
55
+ npx tycoslide package # regenerate skill.md/syntax.md/manifest.json + zip the whole theme into a self-contained <package-name>.zip
54
56
  ```
55
57
 
56
58
  ## Theme Structure
package/SKILL.md CHANGED
@@ -21,8 +21,6 @@ This installs the tycoslide engine and its dependencies. You only need to do thi
21
21
 
22
22
  This skill builds on-brand decks from a markdown deck file. The theme provides slide layouts that control design. Your job: pick the right layouts, fill them with content, and build. You never restyle the layout; the engine clones the real slides, so brand, layout, fonts, and chrome come for free.
23
23
 
24
- For brand voice and naming guidelines, read `brand.md` if it exists alongside this skill.
25
-
26
24
  ## Quick Reference
27
25
 
28
26
  | Task | Guide |
@@ -37,21 +35,25 @@ For brand voice and naming guidelines, read `brand.md` if it exists alongside th
37
35
 
38
36
  Before writing anything, read `manifest.json`. It contains:
39
37
 
40
- - **layouts** -- for each: `name`, `description`, `parameters` (frontmatter inputs) and `slots` (body regions), each with `type` and optionally `required`, `limit`, `codeTheme`, `mermaidVariant` (plus, for assets, a `type` of `icon`/`image`/`background`), plus documentation (`whenToUse`, `whenNotToUse`)
41
- - **assets** -- brand logos, client logos, illustrations, and icons (`description`, `whenToUse`)
38
+ - **layouts** -- for each: `name`, `description`, `parameters` (frontmatter inputs) and `slots` (body regions), each with `accepts` and optionally `required` (plus, for assets, a `type` of `icon`/`image`/`background`)
39
+ - **assets** -- brand logos, client logos, illustrations, and icons (`description`)
42
40
 
43
41
  A layout's inputs split two ways (see [syntax.md](syntax.md) for details):
44
42
  - **parameters** -- one value on a frontmatter line. Types: `template`, `image`. Fill by putting a value under the parameter's key in the slide frontmatter.
45
- - **slots** -- a multi-line region in the body. Types: `text`, `table`, `code`, `mermaid`. Fill as the default body region (after the closing `---`) or a `::name::` region.
43
+ - **slots** -- a multi-line region in the body. Accept types: `text`, `table`, `image`. Fill as a `::name::` region; a fenced code block routes to a `text` slot, a fenced mermaid block to an `image` slot.
44
+
45
+ A slot may accept more than one content type -- the manifest lists each slot's `accepts`, and the shape of the content you write selects which one.
46
46
 
47
- A single physical slide may back multiple layouts. When two manifest entries share the same `slideNumber`, they render into the same underlying PPTX shapes but declare their fill differently -- e.g. one layout exposing the fill as an `image` parameter and a sibling exposing it as a `mermaid` slot. Pick between them by naming the layout you want in frontmatter (`layout: Full bleed diagram` vs `layout: Full bleed image`); the declaration is unambiguous per layout, so the compiler always knows how to interpret the content you provide.
47
+ Parameters and slots you leave unfilled are dropped from the slide, so a layout with numbered slots (several sections, stats, columns) renders only the ones you fill -- fill as many as you have.
48
48
 
49
- Study each layout's `slots` and `limit`s before writing any slides.
49
+ Study each layout's `slots` before writing any slides.
50
50
 
51
51
  ---
52
52
 
53
53
  ## Creating Slides
54
54
 
55
+ > **Every layout, parameter, and slot name in the examples below is a placeholder.** Your theme's real names live in `manifest.json` — read it first, and never assume a name shown in an example exists in your theme.
56
+
55
57
  Write a deck file in markdown. The file starts with a global frontmatter block declaring the theme, followed by slides separated by `---`.
56
58
 
57
59
  ```markdown
@@ -60,8 +62,8 @@ theme: ./theme.json
60
62
  ---
61
63
 
62
64
  ---
63
- layout: Title
64
- title: Q2 Business Review
65
+ layout: Title # ← a layout from your manifest.json
66
+ title: Q2 Business Review # ← a parameter that layout declares
65
67
  subtitle: Engineering Division
66
68
  ---
67
69
 
@@ -80,7 +82,7 @@ A text shape that holds several lines (e.g. an attribution with a name over a ti
80
82
 
81
83
  A deck file has three parts:
82
84
 
83
- 1. **Global frontmatter** (required) -- the first `---`-delimited block. Must contain `theme:` pointing to the theme config file. May also contain `output:` to set the filename (defaults to `deck.md` → `deck.pptx`).
85
+ 1. **Global frontmatter** (required) -- the first `---`-delimited block. Must contain `theme:` pointing to the theme config file. The output `.pptx` is written next to the deck, named after it (`deck.md` → `deck.pptx`).
84
86
  2. **Slides** -- each begins with a `---` separator. A slide's frontmatter sits between `---` delimiters. Body content follows the closing `---`.
85
87
  3. **Slide separators** -- a `---` on its own line separates slides.
86
88
 
@@ -98,53 +100,49 @@ subtitle: This Quarter
98
100
 
99
101
  - `layout` is required and consumed by the compiler (not forwarded as content).
100
102
  - All other frontmatter keys fill parameters: `title` fills the `title` template parameter, `subtitle` fills the `subtitle` parameter, `hero` fills the `hero` image parameter, etc. A multi-line text shape surfaces as several keys (e.g. `name` + `jobTitle`); fill each as its own scalar line.
101
- - Slots (`text`, `table`, `code`, `mermaid`) are filled by body regions, not frontmatter -- see below.
103
+ - Slots (accepting `text`, `table`, `image`) are filled by body regions, not frontmatter -- see below.
102
104
  - A slide may also carry a `notes:` block in frontmatter -- plain-text speaker notes for the slide's notes page (see [syntax.md](syntax.md#speaker-notes)). It is slide-level metadata, not a parameter or slot.
103
105
 
104
106
  ### Body content, slots, and formatting
105
107
 
106
- See [syntax.md](syntax.md) for the full syntax reference: body content (paragraphs, bullets, nesting), inline formatting (bold, italic, strikethrough, underline, hyperlinks), named slots (`::name::` markers), the parameter/slot split (parameters: template, image; slots: text, table, code, mermaid), and image parameters.
108
+ See [syntax.md](syntax.md) for the full syntax reference: body content (paragraphs, bullets, nesting), inline formatting (bold, italic, strikethrough, underline, hyperlinks), named slots (`::name::` markers), the parameter/slot split (parameters: template, image; slots accept: text, table, image), and image parameters.
107
109
 
108
110
  ### Build
109
111
 
110
- Run the command from `manifest.json`'s `build.command`:
112
+ Build the deck (replace `<deck.md>` with your deck file):
111
113
 
112
114
  ```bash
113
- # e.g.: npx tycoslide build deck.md
115
+ npx tycoslide build <deck.md>
114
116
  ```
115
117
 
116
- The deck is written to your current working directory (not inside the skill).
118
+ The `.pptx` is written next to your deck file (same directory as `<deck.md>`).
117
119
 
118
120
  ---
119
121
 
120
122
  ## Layout Selection
121
123
 
122
- **Don't create boring decks.** Repeating the same layout on every slide makes a forgettable presentation. Use variety and match content shape to layout purpose.
124
+ **Don't create boring decks.** Repeating the same layout on every slide makes a forgettable presentation. Use variety and match content shape to the layout's slots.
123
125
 
124
126
  ### Before Starting
125
127
 
126
- 1. **Read the manifest thoroughly.** Each layout has `whenToUse`, `whenNotToUse`, and `limit`s. Respect all three.
127
- 2. **Match content shape to layout purpose.** A comparison belongs in a two/three-column layout, quantified proof belongs in stat blocks, a customer voice belongs in a quote or testimonial layout. Don't force content into the wrong layout.
128
- 3. **Plan narrative arc first.** Decide the sequence of ideas before picking layouts. Then assign each idea to its best-fit layout from the manifest. Keep one variant (all dark or all light) across the deck.
128
+ 1. **Read the manifest thoroughly.** Each layout declares its `slots` (and the `accepts` types each one takes) -- the shape of what it can hold. Respect them.
129
+ 2. **Match content shape to the layout's slots.** A comparison fits a layout with two or three text columns; quantified proof fits one with stat slots; a customer voice fits a quote slot. The slots tell you what fits -- don't force content into the wrong shape.
130
+ 3. **Assign each idea to its best-fit layout** from the manifest. Some layouts come in more than one variant (e.g. a dark and a light version, shown in the name) consider which suits the deck.
129
131
 
130
132
  ### For Each Slide
131
133
 
132
- **Every slide communicates one idea.** If you're writing more than 5 bullets or 3 paragraphs, split into two slides. Put the takeaway in the headline, then support it.
134
+ **Every slide communicates one idea.** Put the takeaway in the headline, then support it.
133
135
 
134
- Check each layout's `limit` in the manifest for content density constraints. When content overflows, split across slides.
136
+ Keep each slot's content to what its region comfortably holds. When content overflows, split it across slides.
135
137
 
136
138
  ### Avoid (Common Mistakes)
137
139
 
138
140
  - **Don't repeat the same layout** -- vary layouts for visual rhythm
139
- - **Don't dump all content on one slide** -- two clear slides beat one crowded slide
140
- - **Don't ignore layout limits** -- if a slot says max 4 stats, use 4 or fewer
141
- - **Don't open with a body/content layout** -- use the Title layout for impact
142
- - **Don't skip section dividers** -- for decks over 5 slides, use Section title layouts to group sections
141
+ - **Don't overstuff a slot** -- keep content to what its region comfortably holds; split across slides when there's too much
143
142
  - **Don't restyle the layout** -- the theme owns all design; you only fill slots
144
143
  - **Don't use an image that's wrong for the slot** -- a small slot wants a simple icon, not a dense illustration; if the build warns an image shrank to a small %, swap it for a simpler one
145
144
  - **Don't invent layout or asset names** -- only use what exists in the manifest
146
145
  - **Don't leave required parameters or slots empty** -- and don't leave a placeholder logo or dummy text in an image parameter you care about
147
- - **Don't mix dark and light** -- keep one variant across the deck
148
146
 
149
147
  ---
150
148
 
@@ -154,15 +152,7 @@ Check each layout's `limit` in the manifest for content density constraints. Whe
154
152
 
155
153
  Your first draft almost never comes out clean. Approach QA as a debugging session, not a confirmation step. If you haven't run at least one build-fix cycle, you're not done.
156
154
 
157
- ### Build
158
-
159
- Run the command from `manifest.json`'s `build.command`:
160
-
161
- ```bash
162
- # e.g.: npx tycoslide build deck.md
163
- ```
164
-
165
- Read output carefully. Common errors and fixes:
155
+ Build the deck again ([Build](#build)) and read the output carefully. Common errors and fixes:
166
156
 
167
157
  | Error | Fix |
168
158
  |-------|-----|
@@ -172,7 +162,7 @@ Read output carefully. Common errors and fixes:
172
162
  | YAML parse error | Fix the YAML syntax in the slide's frontmatter |
173
163
  | `Skipped setting relation target` | The asset image couldn't be placed; check the path and file |
174
164
  | `forbidden style directive` | Remove `style`, `classDef`, `linkStyle`, or `%%{init}` from your mermaid block -- use `class` for grouping instead |
175
- | `mermaid-cli is required` | Install mermaid-cli: `npm i -D @mermaid-js/mermaid-cli` |
165
+ | `Mermaid render failed` | The engine renders diagrams with Playwright's Chromium; install it once with `npx playwright install chromium` |
176
166
  | `no "mermaid" block` | The theme has no mermaid color config -- add a `mermaid` section to theme.json |
177
167
 
178
168
  ### Verification Loop
@@ -213,12 +203,9 @@ After a successful build, spawn a subagent:
213
203
  Review this deck. Assume there are issues -- find them.
214
204
 
215
205
  Check for:
216
- - Slides that are too dense (>7 bullets, >5 paragraphs, too many stats/rows)
206
+ - Slides where content overflows its slot (more bullets/stats/rows than the region holds)
217
207
  - Same layout repeated multiple times with no variety
218
- - Content that doesn't match layout purpose (check whenToUse in manifest.json)
219
- - Narrative that doesn't flow logically
220
- - Missing opening (Title) or closing (Thank you) slide
221
- - Slides that are too sparse (a single bullet doesn't need its own slide)
208
+ - Content that doesn't match the layout's slots (check `accepts` in manifest.json)
222
209
  - Leftover placeholder logos or dummy text in the rendered images
223
210
 
224
211
  For each issue, suggest a specific fix.
@@ -234,16 +221,3 @@ If the subagent finds issues, fix them and rebuild.
234
221
  ## Full Example
235
222
 
236
223
  See [syntax.md](syntax.md#full-example) for a complete multi-slide deck example.
237
-
238
- ---
239
-
240
- ## Core Commands
241
-
242
- ```bash
243
- # Build a deck (use command from manifest.json's build.command)
244
- # e.g.: npx tycoslide build deck.md
245
-
246
- # Render to images for visual QA
247
- soffice --headless --convert-to pdf --outdir . <deck>.pptx
248
- pdftoppm -png -r 96 <deck>.pdf <name>
249
- ```
package/dist/cli.js CHANGED
@@ -5,14 +5,13 @@ import { Command } from "commander";
5
5
  import { buildDeck } from "./index.js";
6
6
  import { generateManifest } from "./manifest.js";
7
7
  import { compileDeck, loadThemeConfig, parseSlideDocument, RESERVED_KEY } from "./markdown/index.js";
8
- import { renameSkill, zipSkill } from "./skillZip.js";
8
+ import { renameSkill, zipDir } from "./skillZip.js";
9
9
  const DEFAULT_CONFIG = "theme.json";
10
10
  const MANIFEST_FILE = "manifest.json";
11
11
  // The theme skill is written as lowercase skill.md (copied from tycoslide's own
12
12
  // SKILL.md), so the skill folder can live at the theme repo root.
13
13
  const SKILL_FILE = "skill.md";
14
14
  const SYNTAX_FILE = "syntax.md";
15
- const BUILD_COMMAND = "npx tycoslide build";
16
15
  const sdkDir = dirname(fileURLToPath(import.meta.url));
17
16
  const skillMdPath = resolve(sdkDir, "..", "SKILL.md");
18
17
  const syntaxMdPath = resolve(sdkDir, "..", SYNTAX_FILE);
@@ -45,8 +44,9 @@ program
45
44
  }
46
45
  const config = loadThemeConfig(absConfigPath);
47
46
  const deck = await compileDeck(doc, config);
48
- if (!deck.output)
49
- deck.output = basename(deckPath).replace(/\.md$/, ".pptx");
47
+ // Always write the .pptx next to the input deck, named after it.
48
+ const outName = basename(deckPath).replace(/\.md$/, ".pptx");
49
+ deck.output = resolve(dirname(absDeckPath), outName);
50
50
  await buildDeck(deck, config, { excludeNotes: !opts.notes });
51
51
  });
52
52
  program
@@ -61,7 +61,7 @@ program
61
61
  }
62
62
  // basename drops any npm scope, e.g. "@acme/mz-slides" -> "mz-slides".
63
63
  const skillName = basename(themePkg.name);
64
- const manifestJson = `${generateManifest(config, { build: { command: BUILD_COMMAND } })}\n`;
64
+ const manifestJson = `${generateManifest(config)}\n`;
65
65
  writeFileSync(resolve(process.cwd(), MANIFEST_FILE), manifestJson);
66
66
  console.log(`WROTE ${MANIFEST_FILE}`);
67
67
  let skillMd;
@@ -76,13 +76,10 @@ program
76
76
  const syntaxMd = readFileSync(syntaxMdPath, "utf-8");
77
77
  writeFileSync(resolve(process.cwd(), SYNTAX_FILE), syntaxMd);
78
78
  console.log(`WROTE ${SYNTAX_FILE}`);
79
+ // Bundle the WHOLE theme so the skill is self-contained: unzip ->
80
+ // `npm install` (pulls the engine + its deps) -> `npx tycoslide build`.
79
81
  const zipFile = `${skillName}.zip`;
80
- const zipBuf = await zipSkill(skillName, [
81
- { name: SKILL_FILE, content: skillMd },
82
- { name: SYNTAX_FILE, content: syntaxMd },
83
- { name: MANIFEST_FILE, content: manifestJson },
84
- ]);
85
- writeFileSync(resolve(process.cwd(), zipFile), zipBuf);
82
+ writeFileSync(resolve(process.cwd(), zipFile), await zipDir(process.cwd(), skillName));
86
83
  console.log(`WROTE ${zipFile}`);
87
84
  });
88
85
  await program.parseAsync(process.argv);
@@ -15,16 +15,42 @@
15
15
  * Element-level geometry lives in the `fillX` primitives; cross-shape concerns
16
16
  * (media pre-swap for images) live in the callbacks here.
17
17
  */
18
- import { SlotType } from "../types.js";
19
- /** The shape a filler targets, plus its slot-level options (startAt for text). */
20
- export type FillTarget = {
18
+ import { type BodyRows, SlotType } from "../types.js";
19
+ /**
20
+ * The shape a filler targets — a union discriminated by `type` (mirroring the
21
+ * `Block` variants), so each filler's callback sees only its own specimen
22
+ * options. Every target carries a `shapeName` and a human `label` for
23
+ * diagnostics (the author-facing "slide N, layout …, slot …" a filler prints in
24
+ * advisory warnings instead of the raw PPTX shape id); a text target may carry
25
+ * `startAt`, and a table target carries its required `bodyRows` range.
26
+ */
27
+ export type TemplateFillTarget = {
28
+ type: typeof SlotType.Template;
29
+ shapeName: string;
30
+ label: string;
31
+ };
32
+ export type TextFillTarget = {
33
+ type: typeof SlotType.Text;
21
34
  shapeName: string;
35
+ label: string;
22
36
  startAt?: number;
23
37
  };
38
+ export type TableFillTarget = {
39
+ type: typeof SlotType.Table;
40
+ shapeName: string;
41
+ label: string;
42
+ bodyRows: BodyRows;
43
+ };
44
+ export type ImageFillTarget = {
45
+ type: typeof SlotType.Image;
46
+ shapeName: string;
47
+ label: string;
48
+ };
49
+ export type FillTarget = TemplateFillTarget | TextFillTarget | TableFillTarget | ImageFillTarget;
24
50
  /** A pptx-automizer element-modify callback: `(element, relation) => void`. */
25
51
  export type ShapeCallback = (element: any, relation: any) => unknown;
26
- export interface Filler<T> {
52
+ export interface Filler<T, Tgt extends FillTarget = FillTarget> {
27
53
  matches(v: unknown): v is T;
28
- callbacks(value: T, target: FillTarget): ShapeCallback[];
54
+ callbacks(value: T, target: Tgt): ShapeCallback[];
29
55
  }
30
56
  export declare const FILLERS: Record<SlotType, Filler<any>>;
@@ -22,26 +22,34 @@ import { fillImage, isImageFill } from "./image.js";
22
22
  import { fillTable, isTableFill } from "./table.js";
23
23
  import { fillTemplate, isTemplateFill } from "./template.js";
24
24
  import { fillText, isTextFill } from "./text.js";
25
+ const templateFiller = {
26
+ matches: isTemplateFill,
27
+ callbacks: (v, t) => [(el) => fillTemplate(el, v, t.shapeName)],
28
+ };
29
+ const textFiller = {
30
+ matches: isTextFill,
31
+ callbacks: (v, t) => [
32
+ (el, relation) => fillText(el, v, { startAt: t.startAt ?? 0, relation, shapeName: t.shapeName }),
33
+ ],
34
+ };
35
+ // `t` is a TableFillTarget, so `t.bodyRows` is a guaranteed `BodyRows` tuple.
36
+ // `targetOf` builds this target from a `TableBlock`, whose `bodyRows` is required,
37
+ // and the Zod theme schema rejects a table block without a `bodyRows` range; a
38
+ // table filler therefore cannot be reached without one.
39
+ const tableFiller = {
40
+ matches: isTableFill,
41
+ callbacks: (v, t) => [(el) => fillTable(el, v, t.shapeName, t.bodyRows)],
42
+ };
43
+ const imageFiller = {
44
+ matches: isImageFill,
45
+ callbacks: (v, t) => [
46
+ ModifyImageHelper.setRelationTarget(basename(v.path)),
47
+ (el) => fillImage(el, v, t.shapeName, t.label),
48
+ ],
49
+ };
25
50
  export const FILLERS = {
26
- [SlotType.Template]: {
27
- matches: isTemplateFill,
28
- callbacks: (v, t) => [(el) => fillTemplate(el, v, t.shapeName)],
29
- },
30
- [SlotType.Text]: {
31
- matches: isTextFill,
32
- callbacks: (v, t) => [
33
- (el, relation) => fillText(el, v, { startAt: t.startAt ?? 0, relation, shapeName: t.shapeName }),
34
- ],
35
- },
36
- [SlotType.Table]: {
37
- matches: isTableFill,
38
- callbacks: (v, t) => [(el) => fillTable(el, v, t.shapeName)],
39
- },
40
- [SlotType.Image]: {
41
- matches: isImageFill,
42
- callbacks: (v, t) => [
43
- ModifyImageHelper.setRelationTarget(basename(v.path)),
44
- (el) => fillImage(el, v, t.shapeName),
45
- ],
46
- },
51
+ [SlotType.Template]: templateFiller,
52
+ [SlotType.Text]: textFiller,
53
+ [SlotType.Table]: tableFiller,
54
+ [SlotType.Image]: imageFiller,
47
55
  };
@@ -42,7 +42,7 @@ export type GeometryResult = {
42
42
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
43
43
  * ImageFill reaches the engine.
44
44
  */
45
- export declare function fillImage(shape: any, image: ImageFill, shapeName?: string): void;
45
+ export declare function fillImage(shape: any, image: ImageFill, shapeName: string, label: string): void;
46
46
  /**
47
47
  * Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
48
48
  * `fit` picks the strategy: `cover` scales to the larger axis ratio and
@@ -30,7 +30,7 @@ const MIN_SCALE = 0.2;
30
30
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
31
31
  * ImageFill reaches the engine.
32
32
  */
33
- export function fillImage(shape, image, shapeName = "") {
33
+ export function fillImage(shape, image, shapeName, label) {
34
34
  const dims = imageSize(new Uint8Array(readFileSync(image.path)));
35
35
  if (!dims.width || !dims.height) {
36
36
  throw new Error(`Image shape "${shapeName}": could not read image dimensions from "${image.path}".`);
@@ -49,7 +49,7 @@ export function fillImage(shape, image, shapeName = "") {
49
49
  };
50
50
  const { geometry, warnings } = computeGeometry(frame, dims.width, dims.height, image.fit);
51
51
  for (const w of warnings)
52
- console.warn(`Image "${shapeName}": ${w}`);
52
+ console.warn(`Image ${label}: ${w}`);
53
53
  if (geometry.placement === Placement.Crop) {
54
54
  // fill: symmetric crop on the overflowing axis, written as srcRect insets.
55
55
  applySrcRect(shape, blipFill, geometry.left, geometry.top, geometry.left, geometry.top);
@@ -1,22 +1,39 @@
1
1
  /**
2
- * Table fill — clones the template's specimen rows in `<a:tbl>` (row 0 header,
3
- * row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
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.
2
+ * Table fill — composes a variable number of output rows from a fixed table
3
+ * specimen in `<a:tbl>` by cloning specimen rows around one contiguous `bodyRows`
4
+ * range, then fills each cell's first paragraph with the corresponding
5
+ * StyledParagraph. Row 0 styles the header; the rows before the range are top
6
+ * fixed rows (rendered once — the under-header row lives here, never looped, so
7
+ * its divider survives); the `[start, end]` range is the repeatable body, cycled
8
+ * to fill the deck's data; the rows after it are bottom fixed rows (rendered once
9
+ * — a decorated total row lives here). `<a:tcPr>` (fill, borders, margins) is
10
+ * preserved per row — only the cell text is rebuilt. Cells and `<a:gridCol>`
11
+ * entries are cloned or trimmed to the header count, sharing the template's total
12
+ * width. Row, cell, and grid cloning stay engine-side because they need
13
+ * pptx-automizer DOM access.
7
14
  */
8
- import type { TableFill } from "../types.js";
15
+ import type { BodyRows, TableFill } from "../types.js";
9
16
  /**
10
- * Fill a table shape by cloning specimen rows.
11
- *
12
- * Row layout in the template's `<a:tbl>`:
13
- * - Row 0: header specimen
14
- * - Row 1: data specimen
15
- * - Row 2 (optional): alternating-data specimen (zebra striping)
16
- *
17
- * Each cell's first paragraph is rebuilt from the corresponding
18
- * StyledParagraph, so tables inherit rich-run/bullet support for free.
17
+ * Validate a `bodyRows = [start, end]` range against a specimen's actual row
18
+ * count `R`, then compose the `K` specimen-row picks that back the deck's `K` data
19
+ * rows (row 0 always styles the header, so it is not a pick). Everything outside
20
+ * the range is a fixed row rendered once: rows `[1, start-1]` (top — the
21
+ * under-header row, never looped, so its divider survives) back the first data
22
+ * rows; the `[start, end]` range cycles to fill the middle; rows `[end+1, R-1]`
23
+ * (bottom — a decorated total row) back the last data rows. Throws (naming the
24
+ * shape) when the range is out of bounds or the deck supplies too few rows to fill
25
+ * the fixed rows.
19
26
  */
20
- export declare function fillTable(shape: any, table: TableFill, shapeName?: string): void;
27
+ export declare function resolveRowPlan(bodyRows: BodyRows, R: number, K: number, shapeName: string): {
28
+ headerIdx: number;
29
+ picks: number[];
30
+ };
31
+ /**
32
+ * Fill a table shape by composing its specimen rows per the body-range plan (see
33
+ * `resolveRowPlan`). Each output cell's first paragraph is rebuilt from the
34
+ * corresponding StyledParagraph, so tables inherit rich-run/bullet support for
35
+ * free; the specimen row's `<a:tcPr>` is carried over untouched.
36
+ */
37
+ export declare function fillTable(shape: any, table: TableFill, shapeName: string, bodyRows: BodyRows): void;
21
38
  /** Discriminator for TableFill values. */
22
39
  export declare function isTableFill(v: unknown): v is TableFill;
@@ -1,9 +1,16 @@
1
1
  /**
2
- * Table fill — clones the template's specimen rows in `<a:tbl>` (row 0 header,
3
- * row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
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.
2
+ * Table fill — composes a variable number of output rows from a fixed table
3
+ * specimen in `<a:tbl>` by cloning specimen rows around one contiguous `bodyRows`
4
+ * range, then fills each cell's first paragraph with the corresponding
5
+ * StyledParagraph. Row 0 styles the header; the rows before the range are top
6
+ * fixed rows (rendered once — the under-header row lives here, never looped, so
7
+ * its divider survives); the `[start, end]` range is the repeatable body, cycled
8
+ * to fill the deck's data; the rows after it are bottom fixed rows (rendered once
9
+ * — a decorated total row lives here). `<a:tcPr>` (fill, borders, margins) is
10
+ * preserved per row — only the cell text is rebuilt. Cells and `<a:gridCol>`
11
+ * entries are cloned or trimmed to the header count, sharing the template's total
12
+ * width. Row, cell, and grid cloning stay engine-side because they need
13
+ * pptx-automizer DOM access.
7
14
  */
8
15
  import { Attr, collectElements, isPlainObject, rebuildParagraphs, Tag } from "../dom.js";
9
16
  const EMPTY_CELL = { runs: [{ text: "" }] };
@@ -50,30 +57,58 @@ function reconcileGrid(tbl, n) {
50
57
  }
51
58
  }
52
59
  /**
53
- * Fill a table shape by cloning specimen rows.
54
- *
55
- * Row layout in the template's `<a:tbl>`:
56
- * - Row 0: header specimen
57
- * - Row 1: data specimen
58
- * - Row 2 (optional): alternating-data specimen (zebra striping)
59
- *
60
- * Each cell's first paragraph is rebuilt from the corresponding
61
- * StyledParagraph, so tables inherit rich-run/bullet support for free.
60
+ * Validate a `bodyRows = [start, end]` range against a specimen's actual row
61
+ * count `R`, then compose the `K` specimen-row picks that back the deck's `K` data
62
+ * rows (row 0 always styles the header, so it is not a pick). Everything outside
63
+ * the range is a fixed row rendered once: rows `[1, start-1]` (top — the
64
+ * under-header row, never looped, so its divider survives) back the first data
65
+ * rows; the `[start, end]` range cycles to fill the middle; rows `[end+1, R-1]`
66
+ * (bottom — a decorated total row) back the last data rows. Throws (naming the
67
+ * shape) when the range is out of bounds or the deck supplies too few rows to fill
68
+ * the fixed rows.
62
69
  */
63
- export function fillTable(shape, table, shapeName = "") {
70
+ export function resolveRowPlan(bodyRows, R, K, shapeName) {
71
+ const [start, end] = bodyRows;
72
+ // Row 0 is the header, so the body must start at 1 or later; the range must be
73
+ // non-empty and stay within the specimen's rows.
74
+ if (!(start >= 1 && start <= end && end <= R - 1)) {
75
+ throw new Error(`Table shape "${shapeName}": bodyRows [${start}, ${end}] is out of range; require 1 <= start <= end <= ${R - 1} (row 0 is the header).`);
76
+ }
77
+ const topFixed = start - 1; // rows [1, start-1]
78
+ const bottomFixed = R - 1 - end; // rows [end+1, R-1]
79
+ const bodyCount = K - topFixed - bottomFixed;
80
+ const span = end - start + 1;
81
+ // The deck must supply enough data rows to back every fixed row (top + bottom);
82
+ // below that there is nothing to cycle the body over and the total row goes unfilled.
83
+ if (bodyCount < 0) {
84
+ throw new Error(`Table shape "${shapeName}": deck supplies ${K} data row(s) but the specimen's fixed rows need at least ${topFixed + bottomFixed}.`);
85
+ }
86
+ const picks = [];
87
+ for (let r = 1; r <= start - 1; r++)
88
+ picks.push(r); // top fixed
89
+ for (let i = 0; i < bodyCount; i++)
90
+ picks.push(start + (i % span)); // body, cycled
91
+ for (let r = end + 1; r <= R - 1; r++)
92
+ picks.push(r); // bottom fixed
93
+ return { headerIdx: 0, picks };
94
+ }
95
+ /**
96
+ * Fill a table shape by composing its specimen rows per the body-range plan (see
97
+ * `resolveRowPlan`). Each output cell's first paragraph is rebuilt from the
98
+ * corresponding StyledParagraph, so tables inherit rich-run/bullet support for
99
+ * free; the specimen row's `<a:tcPr>` is carried over untouched.
100
+ */
101
+ export function fillTable(shape, table, shapeName, bodyRows) {
64
102
  const tbl = shape.getElementsByTagName(Tag.TABLE)[0];
65
103
  if (!tbl) {
66
104
  throw new Error(`Table shape "${shapeName}": has no <a:tbl> element (is it actually a table?).`);
67
105
  }
68
- const rows = collectElements(tbl, Tag.TABLE_ROW);
69
- if (rows.length < 2) {
70
- throw new Error(`fillTable: template table has ${rows.length} row(s), need at least 2 (header + data specimen)`);
106
+ const specimenRows = collectElements(tbl, Tag.TABLE_ROW);
107
+ const R = specimenRows.length;
108
+ if (R < 2) {
109
+ throw new Error(`fillTable: template table has ${R} row(s), need at least 2 (header + data specimen)`);
71
110
  }
72
- const headerTpl = rows[0];
73
- // Only rows 0-2 are specimens (header, data, optional zebra); any further
74
- // template rows are intentionally dropped — the specimen rows are re-cloned
75
- // per data row below.
76
- const dataTpls = rows.length > 2 ? [rows[1], rows[2]] : [rows[1]];
111
+ const { headerIdx, picks } = resolveRowPlan(bodyRows, R, table.rows.length, shapeName);
77
112
  // Headers are the source of truth for column count; every row is normalized to
78
113
  // it, and short/long data rows are padded/truncated to match.
79
114
  const n = table.headers.length;
@@ -88,15 +123,15 @@ export function fillTable(shape, table, shapeName = "") {
88
123
  return clone;
89
124
  };
90
125
  const built = [];
91
- built.push(fillRow(headerTpl, table.headers));
92
- for (let r = 0; r < table.rows.length; r++) {
93
- built.push(fillRow(dataTpls[r % dataTpls.length], table.rows[r]));
126
+ built.push(fillRow(specimenRows[headerIdx], table.headers));
127
+ for (let r = 0; r < picks.length; r++) {
128
+ built.push(fillRow(specimenRows[picks[r]], table.rows[r]));
94
129
  }
95
130
  // Column invariant: grid `<a:gridCol>` count must equal every row's `<a:tc>`
96
131
  // count (both == n). `normalizeCellCount` (in fillRow) holds the per-row half;
97
132
  // `reconcileGrid` holds the grid half. Run the grid half once, table-global.
98
133
  reconcileGrid(tbl, n);
99
- for (const row of rows)
134
+ for (const row of specimenRows)
100
135
  tbl.removeChild(row);
101
136
  for (const row of built)
102
137
  tbl.appendChild(row);
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * `generate()` is first below; its helpers follow (function declarations hoist).
28
28
  */
29
- import type { Config, Deck, DeckStep, Layout } from "./types.js";
29
+ import { type Config, type Deck, type DeckStep, type Layout } from "./types.js";
30
30
  /** Options for `generate` / `buildDeck`. */
31
31
  export type GenerateOptions = {
32
32
  /**
@@ -53,12 +53,13 @@ export declare function generate(deck: Deck, config: Config, options?: GenerateO
53
53
  /**
54
54
  * Fill one cloned slide. Per slot the step supplies a value for: resolve WHICH
55
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.
56
+ * and place it WHERE/HOW (`applyBlock`). A slot the step leaves unfilled has its
57
+ * base-slide shape removed, so a layout with numbered slots renders exactly the
58
+ * ones supplied. Every ambiguity fails fast, naming layout + slot.
58
59
  *
59
60
  * Exported for tests; `generate()` calls it inside the `addSlide` callback.
60
61
  */
61
- export declare function fillSlide(slide: any, layout: Layout, step: DeckStep, sourceAlias: string): void;
62
+ export declare function fillSlide(slide: any, layout: Layout, step: DeckStep, sourceAlias: string, slideNumber: number): void;
62
63
  /**
63
64
  * Reject a slot whose `accepts` lists two blocks of the same type — the
64
65
  * value→block lookup would silently pick the first. Called once per layout at