@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
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`) files.
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, loadThemeConfig, 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";
@@ -96,16 +16,6 @@ const BUILD_COMMAND = "npx tycoslide build";
96
16
  const sdkDir = dirname(fileURLToPath(import.meta.url));
97
17
  const skillMdPath = resolve(sdkDir, "..", SKILL_FILE);
98
18
  const syntaxMdPath = resolve(sdkDir, "..", SYNTAX_FILE);
99
- function loadConfig(absPath) {
100
- let raw;
101
- try {
102
- raw = JSON.parse(readFileSync(absPath, "utf-8"));
103
- }
104
- catch {
105
- throw new Error(`Config file not found or invalid JSON: ${absPath}`);
106
- }
107
- return { ...raw, rootDir: dirname(absPath) };
108
- }
109
19
  const pkg = JSON.parse(readFileSync(resolve(sdkDir, "..", "package.json"), "utf-8"));
110
20
  const program = new Command().name("tycoslide").description("PPTX template engine CLI").version(pkg.version);
111
21
  program
@@ -113,6 +23,7 @@ program
113
23
  .description("Build a PPTX deck from a Markdown spec")
114
24
  .argument("<deck>", "path to deck markdown file")
115
25
  .option(`-c, --config <path>`, "override theme config path (default: read from frontmatter)")
26
+ .option("--no-notes", "omit speaker notes from the output (also strips any inherited template notes)")
116
27
  .action(async (deckPath, opts) => {
117
28
  const absDeckPath = resolve(process.cwd(), deckPath);
118
29
  let source;
@@ -132,11 +43,11 @@ program
132
43
  if (!absConfigPath) {
133
44
  throw new Error(`${basename(deckPath)}: missing required "${RESERVED_KEY.THEME}" in global frontmatter`);
134
45
  }
135
- const config = loadConfig(absConfigPath);
136
- const deck = compileDeck(doc, config.layouts, config.rootDir);
46
+ const config = loadThemeConfig(absConfigPath);
47
+ const deck = await compileDeck(doc, config);
137
48
  if (!deck.output)
138
49
  deck.output = basename(deckPath).replace(/\.md$/, ".pptx");
139
- await buildDeck(deck, config);
50
+ await buildDeck(deck, config, { excludeNotes: !opts.notes });
140
51
  });
141
52
  program
142
53
  .command("manifest")
@@ -144,7 +55,7 @@ program
144
55
  .option(`-c, --config <path>`, "path to theme config file", DEFAULT_CONFIG)
145
56
  .option(`-o, --out <file>`, "write to file instead of stdout")
146
57
  .action(async (opts) => {
147
- const config = loadConfig(resolve(process.cwd(), opts.config));
58
+ const config = loadThemeConfig(resolve(process.cwd(), opts.config));
148
59
  const json = generateManifest(config, { build: { command: BUILD_COMMAND } });
149
60
  if (opts.out) {
150
61
  writeFileSync(resolve(process.cwd(), opts.out), `${json}\n`);
@@ -154,22 +65,12 @@ program
154
65
  process.stdout.write(`${json}\n`);
155
66
  }
156
67
  });
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
68
  program
168
69
  .command("plugin")
169
70
  .description("Generate plugin package (plugin.json, manifest.json, SKILL.md, syntax.md) for AI agents")
170
71
  .option(`-c, --config <path>`, "path to theme config file", DEFAULT_CONFIG)
171
72
  .action(async (opts) => {
172
- const config = loadConfig(resolve(process.cwd(), opts.config));
73
+ const config = loadThemeConfig(resolve(process.cwd(), opts.config));
173
74
  const cwd = process.cwd();
174
75
  const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf-8"));
175
76
  const pluginMeta = {
@@ -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);
@@ -1,22 +1,30 @@
1
1
  /**
2
2
  * The `Filler` strategy registry — one plain-object strategy per SlotType, each
3
- * pairing a value discriminator with a slide-level fill. The record key IS the
4
- * slot type, so a strategy carries no redundant `type` field. `generate()`
5
- * consults `FILLERS[slot.type]` once per (slot, value): `matches` validates the
6
- * value shape, then `fill` applies it to the slide.
3
+ * pairing a value discriminator with the element-level modify callbacks that
4
+ * apply it. The record key IS the slot type, so a strategy carries no redundant
5
+ * `type` field.
7
6
  *
8
- * Element-level geometry lives in the `fillX` primitives; slide-level concerns
9
- * (media pre-swap for images, relation access for body hyperlinks, column
10
- * validation for tables) live in the strategy wrappers here.
7
+ * `callbacks(value, target)` returns the `(element, relation)` callbacks that
8
+ * fill one shape. They are deliberately shape-name-agnostic beyond the `target`
9
+ * so they can be applied two ways: `slide.modifyElement(name, callbacks)` for a
10
+ * shape already on the cloned base slide, or `slide.addElement(alias, n, name,
11
+ * callbacks)` for a shape transplanted from another slide — pptx-automizer runs
12
+ * an appended shape's callbacks against the imported element itself, so the same
13
+ * callbacks refill a transplant.
14
+ *
15
+ * Element-level geometry lives in the `fillX` primitives; cross-shape concerns
16
+ * (media pre-swap for images) live in the callbacks here.
11
17
  */
12
- import { type Slot, SlotType } from "../types.js";
13
- export type FillContext = {
14
- layoutName: string;
18
+ import { SlotType } from "../types.js";
19
+ /** The shape a filler targets, plus its slot-level options (startAt for text). */
20
+ export type FillTarget = {
21
+ shapeName: string;
22
+ startAt?: number;
15
23
  };
24
+ /** A pptx-automizer element-modify callback: `(element, relation) => void`. */
25
+ export type ShapeCallback = (element: any, relation: any) => unknown;
16
26
  export interface Filler<T> {
17
27
  matches(v: unknown): v is T;
18
- /** Human name for the mismatch error — no magic string at the throw site. */
19
- label: string;
20
- fill(slide: any, slot: Slot, value: T, ctx: FillContext): void;
28
+ callbacks(value: T, target: FillTarget): ShapeCallback[];
21
29
  }
22
30
  export declare const FILLERS: Record<SlotType, Filler<any>>;
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * The `Filler` strategy registry — one plain-object strategy per SlotType, each
3
- * pairing a value discriminator with a slide-level fill. The record key IS the
4
- * slot type, so a strategy carries no redundant `type` field. `generate()`
5
- * consults `FILLERS[slot.type]` once per (slot, value): `matches` validates the
6
- * value shape, then `fill` applies it to the slide.
3
+ * pairing a value discriminator with the element-level modify callbacks that
4
+ * apply it. The record key IS the slot type, so a strategy carries no redundant
5
+ * `type` field.
7
6
  *
8
- * Element-level geometry lives in the `fillX` primitives; slide-level concerns
9
- * (media pre-swap for images, relation access for body hyperlinks, column
10
- * validation for tables) live in the strategy wrappers here.
7
+ * `callbacks(value, target)` returns the `(element, relation)` callbacks that
8
+ * fill one shape. They are deliberately shape-name-agnostic beyond the `target`
9
+ * so they can be applied two ways: `slide.modifyElement(name, callbacks)` for a
10
+ * shape already on the cloned base slide, or `slide.addElement(alias, n, name,
11
+ * callbacks)` for a shape transplanted from another slide — pptx-automizer runs
12
+ * an appended shape's callbacks against the imported element itself, so the same
13
+ * callbacks refill a transplant.
14
+ *
15
+ * Element-level geometry lives in the `fillX` primitives; cross-shape concerns
16
+ * (media pre-swap for images) live in the callbacks here.
11
17
  */
12
18
  import { basename } from "node:path";
13
19
  import { ModifyImageHelper } from "pptx-automizer";
@@ -19,35 +25,23 @@ import { fillText, isTextFill } from "./text.js";
19
25
  export const FILLERS = {
20
26
  [SlotType.Template]: {
21
27
  matches: isTemplateFill,
22
- label: "TemplateFill",
23
- fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [(el) => fillTemplate(el, v, slot.shapeName)]),
28
+ callbacks: (v, t) => [(el) => fillTemplate(el, v, t.shapeName)],
24
29
  },
25
30
  [SlotType.Text]: {
26
31
  matches: isTextFill,
27
- label: "TextFill",
28
- fill: (slide, slot, v) => {
29
- const startAt = slot.startAt ?? 0;
30
- slide.modifyElement(slot.shapeName, [
31
- (el, relation) => fillText(el, v, { startAt, relation, shapeName: slot.shapeName }),
32
- ]);
33
- },
32
+ callbacks: (v, t) => [
33
+ (el, relation) => fillText(el, v, { startAt: t.startAt ?? 0, relation, shapeName: t.shapeName }),
34
+ ],
34
35
  },
35
36
  [SlotType.Table]: {
36
37
  matches: isTableFill,
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
- }
42
- slide.modifyElement(slot.shapeName, [(el) => fillTable(el, v, slot.shapeName)]);
43
- },
38
+ callbacks: (v, t) => [(el) => fillTable(el, v, t.shapeName)],
44
39
  },
45
40
  [SlotType.Image]: {
46
41
  matches: isImageFill,
47
- label: "ImageFill",
48
- fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [
42
+ callbacks: (v, t) => [
49
43
  ModifyImageHelper.setRelationTarget(basename(v.path)),
50
- (el) => fillImage(el, v, slot.shapeName),
51
- ]),
44
+ (el) => fillImage(el, v, t.shapeName),
45
+ ],
52
46
  },
53
47
  };
@@ -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
  /**