@tycoworks/tycoslide 0.7.0 → 0.8.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
@@ -1,14 +1,14 @@
1
1
  # tycoslide
2
2
 
3
- Generate slides from markdown using your real PowerPoint templates.
3
+ Let AI agents build slides from existing PowerPoint (`.pptx`) templates.
4
4
 
5
5
  > **Early release** — tycoslide is under active development.
6
6
 
7
- ## Why tycoslide?
7
+ ## How it works
8
8
 
9
- AI can write great slide content, but it can never get things on-brand. No matter what you try, fonts, logos, and colors end up slightly wrong, and you spend hours fixing it by hand.
10
-
11
- tycoslide helps AI agents build presentations using your real slide templates, so they're always on-brand. You define a theme with your .pptx files, layouts and design assets, from which agents can quickly build new presentations using markdown.
9
+ 1. **tycoslide wraps your PowerPoint file as an agent skill.**
10
+ 2. **Your agents use the skill to write slides in markdown.**
11
+ 3. **tycoslide builds a finished PowerPoint file from the markdown.**
12
12
 
13
13
  ## Quick Start
14
14
 
@@ -49,7 +49,7 @@ tycoslide build deck.md # → deck.pptx
49
49
 
50
50
  ```bash
51
51
  tycoslide build deck.md # markdown → PPTX (theme resolved from deck frontmatter)
52
- tycoslide smoke # one slide per layout smoke-all.pptx
52
+ tycoslide build deck.md --no-notes # omit speaker notes from the output
53
53
  tycoslide plugin # generate AI agent plugin package
54
54
  tycoslide manifest # print layout + asset catalog to stdout
55
55
  ```
package/SKILL.md CHANGED
@@ -39,7 +39,7 @@ For brand voice and naming guidelines, read `brand.md` if it exists alongside th
39
39
 
40
40
  Before writing anything, read `manifest.json`. It contains:
41
41
 
42
- - **layouts** -- for each: `name`, `description`, `parameters` (frontmatter inputs) and `slots` (body regions), each with `type` and optionally `required`, `fit`, `limit`, `codeTheme`, `mermaidVariant`, plus documentation (`whenToUse`, `whenNotToUse`)
42
+ - **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`)
43
43
  - **assets** -- brand logos, client logos, illustrations, and icons (`description`, `whenToUse`)
44
44
 
45
45
  A layout's inputs split two ways (see [syntax.md](syntax.md) for details):
@@ -101,6 +101,7 @@ subtitle: This Quarter
101
101
  - `layout` is required and consumed by the compiler (not forwarded as content).
102
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.
103
103
  - Slots (`text`, `table`, `code`, `mermaid`) are filled by body regions, not frontmatter -- see below.
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.
104
105
 
105
106
  ### Body content, slots, and formatting
106
107
 
package/dist/cli.js CHANGED
@@ -2,90 +2,10 @@ import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { Command } from "commander";
5
- import { FitMode, generate, SlotType } from "./engine/index.js";
6
- import { buildDeck, toEngineConfig } from "./index.js";
5
+ import { buildDeck } from "./index.js";
7
6
  import { generateManifest } from "./manifest.js";
8
- import { toImageFill } from "./markdown/deckCompiler.js";
9
- import { CompilerSlotType, compileDeck, ParameterType, parseSlideDocument, RESERVED_KEY, } from "./markdown/index.js";
10
- import { templateKeys, templateToSegments } from "./markdown/textTemplate.js";
11
- // ── Smoke content fixtures (used by the `smoke` subcommand) ──────────────────
12
- const line = (text) => ({ runs: [{ text }] });
13
- const bulletLine = (text, level = 0) => ({ runs: [{ text }], bullet: { level } });
14
- const SMOKE_TABLE = {
15
- headers: ["Feature", "Starter", "Pro", "Business", "Enterprise"].map(line),
16
- rows: [
17
- ["Users", "5", "25", "100", "Unlimited"].map(line),
18
- ["Storage", "1 GB", "10 GB", "100 GB", "1 TB"].map(line),
19
- ["Support", "Email", "Priority", "24/7", "Dedicated"].map(line),
20
- ],
21
- };
22
- const SMOKE_CODE = [
23
- { runs: [{ text: "SELECT name, total", color: "FF7B72" }] },
24
- { runs: [{ text: "FROM orders", color: "FF7B72" }] },
25
- { runs: [{ text: "WHERE created_at > now();", color: "FF7B72" }] },
26
- ];
27
- const SMOKE_PROSE = [
28
- line("Sample intro line for this block."),
29
- bulletLine("First point"),
30
- bulletLine("Second point"),
31
- ];
32
- function pickFirstAsset(config) {
33
- for (const group of Object.values(config.assets)) {
34
- for (const entry of Object.values(group))
35
- return entry.path;
36
- }
37
- return undefined;
38
- }
39
- function smokeSteps(config) {
40
- const firstAsset = pickFirstAsset(config);
41
- const absAsset = firstAsset ? resolve(config.rootDir, firstAsset) : undefined;
42
- return config.layouts.map((layout) => {
43
- const content = {};
44
- // Parameters (frontmatter): text → a placeholder-filled template, image → the first asset.
45
- for (const p of layout.parameters) {
46
- switch (p.type) {
47
- case ParameterType.Image: {
48
- if (!absAsset)
49
- continue;
50
- content[p.key] = toImageFill(p, absAsset);
51
- break;
52
- }
53
- case ParameterType.Template: {
54
- // Text shapes carry no key — the engine slot is keyed by shapeName.
55
- const values = new Map(templateKeys(p.template).map((k) => [k, "Sample"]));
56
- content[p.shapeName] = { lines: templateToSegments(p.template, values, p.shapeName) };
57
- break;
58
- }
59
- }
60
- }
61
- // Slots (body regions): text → prose, table → table, code → code, mermaid → image.
62
- for (const s of layout.slots) {
63
- switch (s.type) {
64
- case CompilerSlotType.Table:
65
- content[s.key] = SMOKE_TABLE;
66
- break;
67
- case CompilerSlotType.Code:
68
- content[s.key] = { paragraphs: SMOKE_CODE };
69
- break;
70
- case CompilerSlotType.Mermaid: {
71
- // Mermaid slots don't declare fit; smoke fills them with a fixed
72
- // contained ImageFill so the projected engine slot (Image, contain)
73
- // stays consistent with the real renderer's output.
74
- if (!absAsset)
75
- continue;
76
- content[s.key] = { type: SlotType.Image, path: absAsset, fit: FitMode.Contain };
77
- break;
78
- }
79
- case CompilerSlotType.Text:
80
- content[s.key] = { paragraphs: SMOKE_PROSE };
81
- break;
82
- }
83
- }
84
- return { layout: layout.name, content };
85
- });
86
- }
7
+ import { compileDeck, parseSlideDocument, RESERVED_KEY, } from "./markdown/index.js";
87
8
  const DEFAULT_CONFIG = "theme.json";
88
- const DEFAULT_SMOKE_OUTPUT = "smoke-all.pptx";
89
9
  const SKILL_DIR = "skills/slides";
90
10
  const PLUGIN_DIR = ".claude-plugin";
91
11
  const PLUGIN_FILE = "plugin.json";
@@ -113,6 +33,7 @@ program
113
33
  .description("Build a PPTX deck from a Markdown spec")
114
34
  .argument("<deck>", "path to deck markdown file")
115
35
  .option(`-c, --config <path>`, "override theme config path (default: read from frontmatter)")
36
+ .option("--no-notes", "omit speaker notes from the output (also strips any inherited template notes)")
116
37
  .action(async (deckPath, opts) => {
117
38
  const absDeckPath = resolve(process.cwd(), deckPath);
118
39
  let source;
@@ -133,10 +54,10 @@ program
133
54
  throw new Error(`${basename(deckPath)}: missing required "${RESERVED_KEY.THEME}" in global frontmatter`);
134
55
  }
135
56
  const config = loadConfig(absConfigPath);
136
- const deck = compileDeck(doc, config.layouts, config.rootDir);
57
+ const deck = compileDeck(doc, config.layouts, config.rootDir, config.assets);
137
58
  if (!deck.output)
138
59
  deck.output = basename(deckPath).replace(/\.md$/, ".pptx");
139
- await buildDeck(deck, config);
60
+ await buildDeck(deck, config, { excludeNotes: !opts.notes });
140
61
  });
141
62
  program
142
63
  .command("manifest")
@@ -154,16 +75,6 @@ program
154
75
  process.stdout.write(`${json}\n`);
155
76
  }
156
77
  });
157
- program
158
- .command("smoke")
159
- .description("Generate one smoke-test slide per layout")
160
- .option(`-c, --config <path>`, "path to theme config file", DEFAULT_CONFIG)
161
- .option(`-o, --out <file>`, "output PPTX filename", DEFAULT_SMOKE_OUTPUT)
162
- .action(async (opts) => {
163
- const config = loadConfig(resolve(process.cwd(), opts.config));
164
- const steps = smokeSteps(config);
165
- await generate({ theme: opts.config, output: opts.out, steps }, toEngineConfig(config));
166
- });
167
78
  program
168
79
  .command("plugin")
169
80
  .description("Generate plugin package (plugin.json, manifest.json, SKILL.md, syntax.md) for AI agents")
@@ -32,6 +32,8 @@ export declare const Tag: {
32
32
  readonly HLINK_CLICK: "a:hlinkClick";
33
33
  readonly RELATIONSHIP: "Relationship";
34
34
  readonly TABLE: "a:tbl";
35
+ readonly TABLE_GRID: "a:tblGrid";
36
+ readonly GRID_COL: "a:gridCol";
35
37
  readonly TABLE_ROW: "a:tr";
36
38
  readonly TABLE_CELL: "a:tc";
37
39
  readonly TX_BODY: "a:txBody";
@@ -63,6 +65,7 @@ export declare const Attr: {
63
65
  readonly Y: "y";
64
66
  readonly CX: "cx";
65
67
  readonly CY: "cy";
68
+ readonly WIDTH: "w";
66
69
  readonly LEFT: "l";
67
70
  readonly TOP: "t";
68
71
  readonly RIGHT: "r";
@@ -81,6 +84,8 @@ export declare function leadingDecorativePrefix(text: string): string;
81
84
  export declare function buildRun(doc: any, cloneRPr: any | null, text: string): any;
82
85
  export declare function buildParagraph(doc: any, clonePPr: any | null, run: any): any;
83
86
  export declare const HYPERLINK_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
87
+ /** Next unused `rIdN` (max existing id + 1) among a rels container's `<Relationship>` children. */
88
+ export declare function nextFreeRId(relation: any): string;
84
89
  export declare function addRelationship(relation: any, url: string): string;
85
90
  /**
86
91
  * Replace all runs in a paragraph with a sequence of styled runs, cloning the
@@ -32,6 +32,8 @@ export const Tag = {
32
32
  HLINK_CLICK: "a:hlinkClick",
33
33
  RELATIONSHIP: "Relationship",
34
34
  TABLE: "a:tbl",
35
+ TABLE_GRID: "a:tblGrid",
36
+ GRID_COL: "a:gridCol",
35
37
  TABLE_ROW: "a:tr",
36
38
  TABLE_CELL: "a:tc",
37
39
  TX_BODY: "a:txBody",
@@ -68,6 +70,8 @@ export const Attr = {
68
70
  Y: "y",
69
71
  CX: "cx",
70
72
  CY: "cy",
73
+ // table grid column width
74
+ WIDTH: "w",
71
75
  // srcRect edges
72
76
  LEFT: "l",
73
77
  TOP: "t",
@@ -143,7 +147,8 @@ export function buildParagraph(doc, clonePPr, run) {
143
147
  }
144
148
  // ── Relationship Management ──────────────────────────────────────────────────
145
149
  export const HYPERLINK_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
146
- export function addRelationship(relation, url) {
150
+ /** Next unused `rIdN` (max existing id + 1) among a rels container's `<Relationship>` children. */
151
+ export function nextFreeRId(relation) {
147
152
  const existing = collectElements(relation, Tag.RELATIONSHIP);
148
153
  let maxId = 0;
149
154
  for (const rel of existing) {
@@ -152,7 +157,10 @@ export function addRelationship(relation, url) {
152
157
  if (num > maxId)
153
158
  maxId = num;
154
159
  }
155
- const rId = `rId${maxId + 1}`;
160
+ return `rId${maxId + 1}`;
161
+ }
162
+ export function addRelationship(relation, url) {
163
+ const rId = nextFreeRId(relation);
156
164
  const rel = relation.ownerDocument.createElement(Tag.RELATIONSHIP);
157
165
  rel.setAttribute(Attr.ID, rId);
158
166
  rel.setAttribute(Attr.TYPE, HYPERLINK_REL_TYPE);
@@ -338,10 +346,18 @@ export function rebuildParagraphs(shape, paragraphs, startIndex, relation, shape
338
346
  continue;
339
347
  const isBullet = para.bullet !== undefined;
340
348
  const inLevel = para.bullet?.level ?? 0;
341
- const { bucket, effectiveLvl } = isBullet ? pickBullet(inLevel) : { bucket: plainBucket, effectiveLvl: 0 };
342
349
  // Skip fully-empty text (matches previous fillText behavior).
343
350
  if (para.runs.length === 1 && !para.runs[0].text)
344
351
  continue;
352
+ // Bulleted content needs a bulleted specimen to model on. A plain-only shape
353
+ // (e.g. a designer "description" line, buNone) accepts plain text only, so
354
+ // bulleting it is an authoring error — fail fast rather than emit a run with
355
+ // no modelled style (which renders in the app default colour, e.g. black on
356
+ // a dark slide whose real text colour lives only in the specimen's rPr).
357
+ if (isBullet && bullets.size === 0) {
358
+ throw new Error(`Shape "${shapeName}": received bulleted content, but its specimen has no bulleted paragraph to model — this slot accepts plain text only.`);
359
+ }
360
+ const { bucket, effectiveLvl } = isBullet ? pickBullet(inLevel) : { bucket: plainBucket, effectiveLvl: 0 };
345
361
  const seedRun = buildRun(doc, bucket?.rPr ?? null, "");
346
362
  const newPara = buildParagraph(doc, bucket?.pPr ?? null, seedRun);
347
363
  maybeOverrideLevel(newPara, isBullet ? effectiveLvl : null);
@@ -35,10 +35,7 @@ export const FILLERS = {
35
35
  [SlotType.Table]: {
36
36
  matches: isTableFill,
37
37
  label: "TableFill",
38
- fill: (slide, slot, v, { layoutName }) => {
39
- if (slot.columns !== undefined && v.headers.length !== slot.columns) {
40
- throw new Error(`Layout "${layoutName}" slot "${slot.key}": table has ${v.headers.length} columns, template expects ${slot.columns}`);
41
- }
38
+ fill: (slide, slot, v) => {
42
39
  slide.modifyElement(slot.shapeName, [(el) => fillTable(el, v, slot.shapeName)]);
43
40
  },
44
41
  },
@@ -1,19 +1,58 @@
1
1
  /**
2
2
  * Image fill — element-level picture geometry only. The media swap (pointing the
3
3
  * blip relationship at the new file) is a slide-level modifier in the ImageFiller
4
- * (`fillers/filler.ts`); this module just adjusts the frame for the fit mode.
4
+ * (`fillers/filler.ts`); this module resolves the frame geometry from the image's
5
+ * `ImageFit` (contain/cover/scale-down) and the source's true pixel size.
5
6
  */
6
- import { type ImageFill } from "../types.js";
7
+ import { type ImageFill, ImageFit } from "../types.js";
8
+ /** A picture frame's placement + size, in EMU. */
9
+ interface Frame {
10
+ x: number;
11
+ y: number;
12
+ w: number;
13
+ h: number;
14
+ }
15
+ /** The two ways a picture is placed: crop the source (`<a:srcRect>`) or resize the frame. */
16
+ declare const Placement: {
17
+ readonly Crop: "crop";
18
+ readonly Fit: "fit";
19
+ };
20
+ /** Either a symmetric `<a:srcRect>` crop (fill/cover) or a resized+re-centred frame (fit/contain). */
21
+ type FitGeometry = {
22
+ placement: typeof Placement.Crop;
23
+ left: number;
24
+ top: number;
25
+ } | {
26
+ placement: typeof Placement.Fit;
27
+ x: number;
28
+ y: number;
29
+ cx: number;
30
+ cy: number;
31
+ };
32
+ export type GeometryResult = {
33
+ geometry: FitGeometry;
34
+ warnings: string[];
35
+ };
7
36
  /**
8
- * Adjust a picture shape's geometry for the chosen fit mode:
9
- * - cover: writes `<a:srcRect>` insets (units: 1/100,000%) so the image fills
10
- * the frame with the overflowing axis center-cropped.
11
- * - contain: shrinks the picture's frame to the image's aspect-ratio dimensions
12
- * and re-centers within the original frame bounds.
37
+ * Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
38
+ * `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
39
+ * aspect ratio and re-centre (fit/letterbox). Advisory warnings from the
40
+ * geometry pass go to `console.warn`.
13
41
  *
14
42
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
15
43
  * ImageFill reaches the engine.
16
44
  */
17
45
  export declare function fillImage(shape: any, image: ImageFill, shapeName?: string): void;
46
+ /**
47
+ * Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
48
+ * `fit` picks the strategy: `cover` scales to the larger axis ratio and
49
+ * center-crops the overflow; `contain` scales to the smaller ratio and
50
+ * letterboxes; `scale-down` is `contain` capped at native size (never enlarge →
51
+ * the image sits at native size, centred). Emits advisory warnings for scaling
52
+ * far from native and for a severe aspect mismatch; the caller decides how to
53
+ * surface them.
54
+ */
55
+ export declare function computeGeometry(frame: Frame, imgW: number, imgH: number, fit: ImageFit): GeometryResult;
18
56
  /** Discriminator for ImageFill values. */
19
57
  export declare function isImageFill(v: unknown): v is ImageFill;
58
+ export {};
@@ -1,18 +1,31 @@
1
1
  /**
2
2
  * Image fill — element-level picture geometry only. The media swap (pointing the
3
3
  * blip relationship at the new file) is a slide-level modifier in the ImageFiller
4
- * (`fillers/filler.ts`); this module just adjusts the frame for the fit mode.
4
+ * (`fillers/filler.ts`); this module resolves the frame geometry from the image's
5
+ * `ImageFit` (contain/cover/scale-down) and the source's true pixel size.
5
6
  */
6
7
  import { readFileSync } from "node:fs";
7
8
  import { imageSize } from "image-size";
8
9
  import { Attr, isPlainObject, Tag } from "../dom.js";
9
- import { FitMode, SlotType } from "../types.js";
10
+ import { ImageFit, SlotType } from "../types.js";
11
+ /** The two ways a picture is placed: crop the source (`<a:srcRect>`) or resize the frame. */
12
+ const Placement = { Crop: "crop", Fit: "fit" };
13
+ const EMU_PER_INCH = 914400;
14
+ /** PPTX's reference pixel density: one source px maps to one output px at this DPI. */
15
+ const PX_PER_INCH = 96;
16
+ /** EMU that one source pixel occupies at native (1:1) size — 9525. */
17
+ const NATIVE_EMU_PER_PX = EMU_PER_INCH / PX_PER_INCH;
18
+ /** `<a:srcRect>` insets are fixed-point fractions of the picture where this = 100%. */
19
+ const SRC_RECT_FULL = 100000;
20
+ /** Warn once the cropped-away or empty area exceeds this fraction of the frame. */
21
+ const SEVERE_MISMATCH_FRACTION = 0.5;
22
+ /** Warn once the image renders below this fraction of its native pixel size. */
23
+ const MIN_SCALE = 0.2;
10
24
  /**
11
- * Adjust a picture shape's geometry for the chosen fit mode:
12
- * - cover: writes `<a:srcRect>` insets (units: 1/100,000%) so the image fills
13
- * the frame with the overflowing axis center-cropped.
14
- * - contain: shrinks the picture's frame to the image's aspect-ratio dimensions
15
- * and re-centers within the original frame bounds.
25
+ * Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
26
+ * `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
27
+ * aspect ratio and re-centre (fit/letterbox). Advisory warnings from the
28
+ * geometry pass go to `console.warn`.
16
29
  *
17
30
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
18
31
  * ImageFill reaches the engine.
@@ -34,48 +47,77 @@ export function fillImage(shape, image, shapeName = "") {
34
47
  w: Number(ext.getAttribute(Attr.CX)),
35
48
  h: Number(ext.getAttribute(Attr.CY)),
36
49
  };
37
- const geom = computeFit(frame, dims.width, dims.height, image.fit);
38
- if (geom.kind === "crop") {
39
- // cover: symmetric crop on the overflowing axis, written as srcRect insets.
40
- applySrcRect(shape, blipFill, geom.left, geom.top, geom.left, geom.top);
50
+ const { geometry, warnings } = computeGeometry(frame, dims.width, dims.height, image.fit);
51
+ for (const w of warnings)
52
+ console.warn(`Image "${shapeName}": ${w}`);
53
+ if (geometry.placement === Placement.Crop) {
54
+ // fill: symmetric crop on the overflowing axis, written as srcRect insets.
55
+ applySrcRect(shape, blipFill, geometry.left, geometry.top, geometry.left, geometry.top);
41
56
  return;
42
57
  }
43
- // contain: drop any inherited crop, then resize + re-center the frame itself.
58
+ // fit: drop any inherited crop, then resize + re-center the frame itself.
44
59
  applySrcRect(shape, blipFill, 0, 0, 0, 0);
45
- ext.setAttribute(Attr.CX, String(geom.cx));
46
- ext.setAttribute(Attr.CY, String(geom.cy));
47
- off.setAttribute(Attr.X, String(geom.x));
48
- off.setAttribute(Attr.Y, String(geom.y));
60
+ ext.setAttribute(Attr.CX, String(geometry.cx));
61
+ ext.setAttribute(Attr.CY, String(geometry.cy));
62
+ off.setAttribute(Attr.X, String(geometry.x));
63
+ off.setAttribute(Attr.Y, String(geometry.y));
49
64
  }
50
65
  /**
51
66
  * Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
52
- * Given the picture frame (EMU) and the source image's pixel size: cover scales
53
- * up until both axes are covered and crops the overflow; contain scales down
54
- * until the whole image fits, then re-centres the shrunken frame.
67
+ * `fit` picks the strategy: `cover` scales to the larger axis ratio and
68
+ * center-crops the overflow; `contain` scales to the smaller ratio and
69
+ * letterboxes; `scale-down` is `contain` capped at native size (never enlarge →
70
+ * the image sits at native size, centred). Emits advisory warnings for scaling
71
+ * far from native and for a severe aspect mismatch; the caller decides how to
72
+ * surface them.
55
73
  */
56
- function computeFit(frame, imgW, imgH, fit) {
74
+ export function computeGeometry(frame, imgW, imgH, fit) {
57
75
  const fitX = frame.w / imgW;
58
76
  const fitY = frame.h / imgH;
59
- const inset = (fraction) => Math.round(fraction * 100000); // <a:srcRect> unit: 1/100,000%
60
- if (fit === FitMode.Cover) {
61
- const scale = Math.max(fitX, fitY);
62
- const shownW = imgW * scale;
63
- const shownH = imgH * scale;
77
+ const allowCrop = fit === ImageFit.Cover;
78
+ let scale = allowCrop ? Math.max(fitX, fitY) : Math.min(fitX, fitY);
79
+ if (fit === ImageFit.ScaleDown)
80
+ scale = Math.min(scale, NATIVE_EMU_PER_PX); // never enlarge past native
81
+ const warnings = [];
82
+ const scaleRatio = scale / NATIVE_EMU_PER_PX; // rendered size vs the image's native pixels
83
+ if (scaleRatio > 1) {
84
+ warnings.push(`enlarged to ${Math.round(scaleRatio * 100)}% of native — will look soft; supply a larger image`);
85
+ }
86
+ else if (scaleRatio < MIN_SCALE) {
87
+ warnings.push(`shrunk to ${Math.round(scaleRatio * 100)}% of native — the slot is far smaller than the image`);
88
+ }
89
+ const shownW = imgW * scale;
90
+ const shownH = imgH * scale;
91
+ const inset = (fraction) => Math.round(fraction * SRC_RECT_FULL);
92
+ if (allowCrop && (shownW > frame.w || shownH > frame.h)) {
93
+ const cropped = 1 - (frame.w * frame.h) / (shownW * shownH);
94
+ if (cropped > SEVERE_MISMATCH_FRACTION) {
95
+ warnings.push(`fills the frame by cropping ${Math.round(cropped * 100)}% of the image (aspect mismatch)`);
96
+ }
64
97
  return {
65
- kind: "crop",
66
- left: shownW > frame.w ? inset((1 - frame.w / shownW) / 2) : 0,
67
- top: shownH > frame.h ? inset((1 - frame.h / shownH) / 2) : 0,
98
+ geometry: {
99
+ placement: Placement.Crop,
100
+ left: shownW > frame.w ? inset((1 - frame.w / shownW) / 2) : 0,
101
+ top: shownH > frame.h ? inset((1 - frame.h / shownH) / 2) : 0,
102
+ },
103
+ warnings,
68
104
  };
69
105
  }
70
- const scale = Math.min(fitX, fitY);
71
- const cx = Math.round(imgW * scale);
72
- const cy = Math.round(imgH * scale);
106
+ const empty = 1 - (shownW * shownH) / (frame.w * frame.h);
107
+ if (empty > SEVERE_MISMATCH_FRACTION) {
108
+ warnings.push(`leaves ${Math.round(empty * 100)}% of the frame empty (aspect mismatch or small image)`);
109
+ }
110
+ const cx = Math.round(shownW);
111
+ const cy = Math.round(shownH);
73
112
  return {
74
- kind: "frame",
75
- x: Math.round(frame.x + (frame.w - cx) / 2),
76
- y: Math.round(frame.y + (frame.h - cy) / 2),
77
- cx,
78
- cy,
113
+ geometry: {
114
+ placement: Placement.Fit,
115
+ x: Math.round(frame.x + (frame.w - cx) / 2),
116
+ y: Math.round(frame.y + (frame.h - cy) / 2),
117
+ cx,
118
+ cy,
119
+ },
120
+ warnings,
79
121
  };
80
122
  }
81
123
  /**
@@ -1,8 +1,9 @@
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
8
  import type { TableFill } from "../types.js";
8
9
  /**
@@ -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)
@@ -23,6 +23,16 @@
23
23
  * slot's type via `FILLERS[slot.type]` (required, no default).
24
24
  */
25
25
  import type { Config, Deck, Slot } from "./types.js";
26
+ /** Options for `generate` / `buildDeck`. */
27
+ export type GenerateOptions = {
28
+ /**
29
+ * Suppress authored speaker notes. Default `false` (notes are written and any
30
+ * template notes cloned onto slides are stripped). When `true`, no notes are
31
+ * written but inherited template notes are still stripped — the deck is
32
+ * guaranteed notes-free.
33
+ */
34
+ excludeNotes?: boolean;
35
+ };
26
36
  /**
27
37
  * Generate a PPTX file from a deck definition and a theme configuration.
28
38
  *
@@ -35,7 +45,7 @@ import type { Config, Deck, Slot } from "./types.js";
35
45
  * via `FILLERS[slot.type]`.
36
46
  * 4. Write the output PPTX.
37
47
  */
38
- export declare function generate(deck: Deck, config: Config): Promise<void>;
48
+ export declare function generate(deck: Deck, config: Config, options?: GenerateOptions): Promise<void>;
39
49
  /**
40
50
  * Validate that every required slot on a layout is supplied. Type/shape
41
51
  * validation happens in the compiler now — this is a thin required-only