@tycoworks/tycoslide 0.16.0 → 0.17.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
@@ -4,7 +4,7 @@ Create editable PowerPoint slides from markdown, using your existing .pptx templ
4
4
 
5
5
  ## Getting started
6
6
 
7
- 1. **Create a tycoslide theme.** Install the [create-theme](skills/create-theme) skill (`npx skills add tycoworks/tycoslide --skill create-theme`) and give an agent such as Claude Code or Codex your `.pptx`. You'll get back an npm package of mapped layouts, colors, and visual assets, like the [tycoworks-theme](https://github.com/tycoworks/tycoworks-theme).
7
+ 1. **Create a tycoslide theme.** Install the [create-theme](skills/create-theme) skill (`npx skills add tycoworks/tycoslide`) and give an agent such as Claude Code or Codex your `.pptx`. You'll get back an npm package of mapped layouts, colors, and visual assets, like the [tycoworks-theme](https://github.com/tycoworks/tycoworks-theme).
8
8
  2. **Write slides in markdown.** GitHub-flavored markdown is supported; see [Markdown support](#markdown-support) below. tycoslide themes also include an agent skill, so an agent can write the slides for you.
9
9
  3. **Build.** `npx tycoslide build deck.md` compiles the markdown into an editable PowerPoint file.
10
10
 
@@ -35,4 +35,4 @@ Node 23.6 or later. Mermaid diagrams need Chrome on the machine; tycoslide finds
35
35
 
36
36
  ## Thanks
37
37
 
38
- tycoslide was inspired by [pptx-automizer](https://github.com/singerla/pptx-automizer), which it uses under the hood, and by [Slidev](https://sli.dev) and its markdown slides.
38
+ tycoslide was inspired by [pptx-automizer](https://github.com/singerla/pptx-automizer), which it uses under the hood, and by [Slidev](https://sli.dev)'s way of writing slides in markdown.
@@ -1,18 +1,17 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, posix, resolve } from "node:path";
3
3
  import { loadThemeConfig } from "../index.js";
4
- import { loadAssetCatalog } from "./catalog.js";
5
4
  import { extractTemplate, summarizeExtraction } from "./extract.js";
6
- import { ASSETS_DIR, ASSETS_FILE, MANIFEST_FILE, PACKAGE_JSON, SKILL_FILE, SKILL_ZIP_EXT, TEMPLATE_FILE, THEME_CONFIG, THEME_PACKAGE_DIR, } from "./files.js";
5
+ import { ASSETS_DIR, ASSETS_FILE, MANIFEST_FILE, PACKAGE_JSON, SKILL_FILE, SKILL_TEMPLATE_FILE, SKILL_ZIP_EXT, TEMPLATE_FILE, THEME_CONFIG, THEME_PACKAGE_DIR, } from "./files.js";
7
6
  import { generateManifest } from "./manifest.js";
8
- import { renameSkill, skillPackageJson, zipDir } from "./skill.js";
7
+ import { loadAssetCatalog, renameSkill, skillPackageJson, zipDir } from "./skill.js";
9
8
  /** The CLI's line for something written, with an optional note on what it holds. */
10
9
  function wrote(target, detail) {
11
10
  console.log(detail ? `WROTE ${target} ${detail}` : `WROTE ${target}`);
12
11
  }
13
12
  /** Register the agent layer's commands on the CLI program. */
14
13
  export function registerAgentCommands(program, tool) {
15
- const skillMdPath = resolve(tool.root, THEME_PACKAGE_DIR, SKILL_FILE);
14
+ const skillMdPath = resolve(tool.root, THEME_PACKAGE_DIR, SKILL_TEMPLATE_FILE);
16
15
  program
17
16
  .command("package")
18
17
  .description("Zip the theme into an Agent Skill for AI agents")
@@ -18,6 +18,7 @@ export declare const ASSETS_FILE = "assets.json";
18
18
  export declare const ASSETS_ARCHIVE = "assets.dat";
19
19
  export declare const SKILL_FILE = "SKILL.md";
20
20
  export declare const THEME_PACKAGE_DIR = "theme-package";
21
+ export declare const SKILL_TEMPLATE_FILE = "SKILL.template.md";
21
22
  /**
22
23
  * npm's package manifest: the theme's, which names the skill, and the packaged
23
24
  * skill's own, which it installs from (authored rather than copied).
@@ -20,9 +20,11 @@ export const ASSETS_ARCHIVE = "assets.dat";
20
20
  // the root of a skill folder, and a case-sensitive filesystem will not find any
21
21
  // other.
22
22
  export const SKILL_FILE = "SKILL.md";
23
- // The folder holding the SKILL.md template copied into each packaged theme, kept
24
- // out of the repo root so skill installers do not mistake it for this repo's own skill.
23
+ // The folder holding the deck skill that `package` puts in each packaged theme.
25
24
  export const THEME_PACKAGE_DIR = "theme-package";
25
+ // The deck skill as tycoslide ships it, named so skill installers scanning this
26
+ // repo don't offer it as a skill of its own; `package` writes it as SKILL_FILE.
27
+ export const SKILL_TEMPLATE_FILE = "SKILL.template.md";
26
28
  /**
27
29
  * npm's package manifest: the theme's, which names the skill, and the packaged
28
30
  * skill's own, which it installs from (authored rather than copied).
@@ -87,6 +87,8 @@ const Attr = {
87
87
  X: "x",
88
88
  Y: "y",
89
89
  TEXT_BOX: "txBox",
90
+ WIDTH: "w",
91
+ HEIGHT: "h",
90
92
  };
91
93
  /** The twelve colour-scheme slots, in the order PowerPoint lists them. */
92
94
  export const SCHEME_SLOTS = [
@@ -349,7 +351,7 @@ function describeShape(el, prefix) {
349
351
  return {
350
352
  name: prefix + (find(nonVisualProps(el), Tag.NON_VISUAL_PROPS)?.getAttribute(Attr.NAME) ?? ""),
351
353
  kind: shapeKind(el, table),
352
- frame: shapeFrame(el),
354
+ frame: table ? tableFrame(el, table) : shapeFrame(el),
353
355
  rows: table && elementChildren(table, Tag.TABLE_ROW).length,
354
356
  cols: table && elementChildren(find(table, Tag.TABLE_GRID), Tag.GRID_COLUMN).length,
355
357
  text: el.tagName === Tag.GROUP ? "" : shapeText(el),
@@ -399,6 +401,23 @@ function shapeFrame(el) {
399
401
  cy: Number(extent.getAttribute(Attr.CY)),
400
402
  };
401
403
  }
404
+ /**
405
+ * A table's frame: where its graphic frame puts it, at the size its column widths
406
+ * and row heights give it. PowerPoint draws a table at that size, rows growing
407
+ * taller when their text needs it, and some tools (Google Slides among them)
408
+ * write a placeholder size on the frame.
409
+ */
410
+ function tableFrame(el, table) {
411
+ const frame = shapeFrame(el);
412
+ if (!frame)
413
+ return undefined;
414
+ const total = (els, attr) => els.reduce((sum, e) => sum + Number(e.getAttribute(attr) || 0), 0);
415
+ return {
416
+ ...frame,
417
+ cx: total(elementChildren(find(table, Tag.TABLE_GRID), Tag.GRID_COLUMN), Attr.WIDTH) || frame.cx,
418
+ cy: total(elementChildren(table, Tag.TABLE_ROW), Attr.HEIGHT) || frame.cy,
419
+ };
420
+ }
402
421
  function nonVisualProps(el) {
403
422
  return elementChildren(el).find((child) => child.tagName.startsWith(NON_VISUAL_PREFIX));
404
423
  }
@@ -1,4 +1,4 @@
1
- import { templateKeys, } from "../index.js";
1
+ import { AcceptType, templateKeys, } from "../index.js";
2
2
  import { ASSETS_FILE, jsonFile } from "./files.js";
3
3
  /**
4
4
  * Flatten a compiler parameter to the manifest entries advertised to AI authors.
@@ -15,6 +15,9 @@ function stripParameter(param) {
15
15
  }
16
16
  function stripSlot(slot) {
17
17
  const result = { key: slot.key, accepts: slot.accepts.map((b) => b.type) };
18
+ for (const block of slot.accepts)
19
+ if (block.type === AcceptType.Image)
20
+ result.fit = block.fit;
18
21
  if (slot.required)
19
22
  result.required = true;
20
23
  return result;
@@ -1,5 +1,16 @@
1
+ import * as z from "zod";
1
2
  import { type CompilerThemeConfig } from "../index.js";
2
- import type { AssetCatalog } from "./catalog.js";
3
+ declare const AssetCatalogSchema: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
4
+ path: z.ZodString;
5
+ description: z.ZodString;
6
+ }, z.core.$strict>>>;
7
+ export type AssetCatalog = z.infer<typeof AssetCatalogSchema>;
8
+ /**
9
+ * Read and validate a theme's image catalog, `assets.json`: what a deck-writing
10
+ * agent searches and copies images from, and what a packaged skill archives. It
11
+ * is authored with the theme and required; a theme with no images has `{}`.
12
+ */
13
+ export declare function loadAssetCatalog(themeDir: string): AssetCatalog;
3
14
  /**
4
15
  * Pack `paths` (theme-relative, POSIX) into one archive, reading each through
5
16
  * `read`. Entries keep their declared paths, so expanding reproduces the layout
@@ -41,3 +52,4 @@ export declare function skillPackageJson(theme: Record<string, unknown>, engine:
41
52
  * catalog declares but that is missing is an error.
42
53
  */
43
54
  export declare function zipDir(rootDir: string, folderName: string, config: CompilerThemeConfig, catalog: AssetCatalog, themeFiles: string[], generated: Record<string, string>): Promise<Buffer>;
55
+ export {};
@@ -1,8 +1,37 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import JSZip from "jszip";
4
- import { TEMPLATE_DIR } from "../index.js";
5
- import { ASSETS_ARCHIVE, jsonFile } from "./files.js";
4
+ import * as z from "zod";
5
+ import { strict, TEMPLATE_DIR } from "../index.js";
6
+ import { ASSETS_ARCHIVE, ASSETS_FILE, jsonFile } from "./files.js";
7
+ /** One image a theme offers deck authors: where it is, and what it shows. */
8
+ const AssetEntrySchema = strict({
9
+ path: z.string(),
10
+ description: z.string(),
11
+ });
12
+ // `{ category: { name: entry } }`. Both record levels are open (the names are the
13
+ // theme's own); only the entry is strict.
14
+ const AssetCatalogSchema = z.record(z.string(), z.record(z.string(), AssetEntrySchema));
15
+ /**
16
+ * Read and validate a theme's image catalog, `assets.json`: what a deck-writing
17
+ * agent searches and copies images from, and what a packaged skill archives. It
18
+ * is authored with the theme and required; a theme with no images has `{}`.
19
+ */
20
+ export function loadAssetCatalog(themeDir) {
21
+ const path = join(themeDir, ASSETS_FILE);
22
+ let raw;
23
+ try {
24
+ raw = JSON.parse(readFileSync(path, "utf-8"));
25
+ }
26
+ catch {
27
+ throw new Error(`Image catalog not found or invalid JSON: ${path}`);
28
+ }
29
+ const result = AssetCatalogSchema.safeParse(raw);
30
+ if (!result.success) {
31
+ throw new Error(`${ASSETS_FILE}: invalid image catalog\n${z.prettifyError(result.error)}`);
32
+ }
33
+ return result.data;
34
+ }
6
35
  /** Entries are stored, not deflated: assets are already-compressed images. */
7
36
  const NO_COMPRESSION = { type: "nodebuffer", compression: "STORE" };
8
37
  /**
@@ -30,6 +30,8 @@ export declare const Tag: {
30
30
  readonly BLIP_FILL: "p:blipFill";
31
31
  readonly NON_VISUAL_PROPS: "p:cNvPr";
32
32
  readonly SPACE_BEFORE: "a:spcBef";
33
+ readonly LINE_SPACING: "a:lnSpc";
34
+ readonly OUTLINE: "a:ln";
33
35
  readonly HLINK_CLICK: "a:hlinkClick";
34
36
  readonly RELATIONSHIP: "Relationship";
35
37
  readonly TABLE: "a:tbl";
@@ -30,6 +30,8 @@ export const Tag = {
30
30
  BLIP_FILL: "p:blipFill",
31
31
  NON_VISUAL_PROPS: "p:cNvPr",
32
32
  SPACE_BEFORE: "a:spcBef",
33
+ LINE_SPACING: "a:lnSpc",
34
+ OUTLINE: "a:ln",
33
35
  HLINK_CLICK: "a:hlinkClick",
34
36
  RELATIONSHIP: "Relationship",
35
37
  TABLE: "a:tbl",
@@ -106,6 +108,16 @@ export function childrenByTag(parent, tagName) {
106
108
  }
107
109
  return out;
108
110
  }
111
+ /**
112
+ * Insert `child` as `parent`'s first child, or right after a leading `leadingTag`
113
+ * child when there is one. The schema puts a run's fill after only its outline, and
114
+ * a paragraph's space before after only its line spacing; PowerPoint reports a file
115
+ * with either out of place as needing repair.
116
+ */
117
+ function insertAfterLeading(parent, child, leadingTag) {
118
+ const leading = childrenByTag(parent, leadingTag)[0];
119
+ parent.insertBefore(child, leading ? leading.nextSibling : parent.firstChild);
120
+ }
109
121
  export function detach(node) {
110
122
  if (node?.parentNode)
111
123
  node.parentNode.removeChild(node);
@@ -225,7 +237,7 @@ export function setRichRuns(para, runs, relation) {
225
237
  const srgbClr = para.ownerDocument.createElement(Tag.SRGB_CLR);
226
238
  srgbClr.setAttribute(Attr.VALUE, run.color);
227
239
  solidFill.appendChild(srgbClr);
228
- rPr.appendChild(solidFill);
240
+ insertAfterLeading(rPr, solidFill, Tag.OUTLINE);
229
241
  }
230
242
  if (run.link && relation) {
231
243
  let rPr = clone.getElementsByTagName(Tag.RUN_PROPS)[0];
@@ -266,9 +278,27 @@ function paragraphLevel(paragraph) {
266
278
  const lvl = ppr?.getAttribute(Attr.LEVEL);
267
279
  return lvl ? Number(lvl) || 0 : 0;
268
280
  }
281
+ /** A paragraph's `<a:spcBef>`, or null when it has none. */
282
+ function spaceBefore(paragraph) {
283
+ return paragraph.getElementsByTagName(Tag.PARA_PROPS)[0]?.getElementsByTagName(Tag.SPACE_BEFORE)[0] ?? null;
284
+ }
285
+ /** Give the paragraph a copy of `spacing` as its space before, or none when null. */
286
+ function setSpaceBefore(paragraph, spacing) {
287
+ const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
288
+ if (!ppr)
289
+ return;
290
+ for (const old of childrenByTag(ppr, Tag.SPACE_BEFORE))
291
+ ppr.removeChild(old);
292
+ if (spacing)
293
+ insertAfterLeading(ppr, spacing.cloneNode(true), Tag.LINE_SPACING);
294
+ }
269
295
  function harvestStyles(specimen) {
270
296
  const bullets = new Map();
271
297
  let paras = null;
298
+ // Level → the space before a bullet that follows another, taken from the
299
+ // specimen's second bullet at that level. The first often carries extra space
300
+ // to set the list off from the text above it.
301
+ const followOn = new Map();
272
302
  for (const p of specimen) {
273
303
  const kind = paragraphBulletKind(p);
274
304
  const lvl = paragraphLevel(p);
@@ -280,13 +310,15 @@ function harvestStyles(specimen) {
280
310
  if (kind === "bullet") {
281
311
  if (!bullets.has(lvl))
282
312
  bullets.set(lvl, bucket);
313
+ else if (!followOn.has(lvl))
314
+ followOn.set(lvl, spaceBefore(p));
283
315
  }
284
316
  else {
285
317
  if (paras == null)
286
318
  paras = bucket;
287
319
  }
288
320
  }
289
- return { bullets, paras };
321
+ return { bullets, paras, followOn };
290
322
  }
291
323
  function specimenBulletPara(specimen, level) {
292
324
  for (const p of specimen) {
@@ -317,7 +349,7 @@ export function rebuildParagraphs(shape, paragraphs, startIndex, relation, shape
317
349
  if (specimen.length === 0) {
318
350
  throw new Error(`Shape "${shapeName}": startAt ${startIndex} is past the last paragraph (shape has ${allParas.length}).`);
319
351
  }
320
- const { bullets, paras } = harvestStyles(specimen);
352
+ const { bullets, paras, followOn } = harvestStyles(specimen);
321
353
  const maxBulletLevel = bullets.size > 0 ? Math.max(...bullets.keys()) : -1;
322
354
  const pickBullet = (lvl) => {
323
355
  if (bullets.has(lvl))
@@ -329,18 +361,7 @@ export function rebuildParagraphs(shape, paragraphs, startIndex, relation, shape
329
361
  const plainBucket = paras ?? bullets.get(0) ?? null;
330
362
  const firstBulletKey = bullets.has(0) ? 0 : maxBulletLevel;
331
363
  const firstBullet = firstBulletKey >= 0 ? specimenBulletPara(specimen, firstBulletKey) : null;
332
- const transitionSpcBef = firstBullet?.getElementsByTagName(Tag.PARA_PROPS)[0]?.getElementsByTagName(Tag.SPACE_BEFORE)[0] ?? null;
333
- const applyTransitionSpacing = (paragraph) => {
334
- if (!transitionSpcBef)
335
- return;
336
- const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
337
- if (!ppr)
338
- return;
339
- const old = ppr.getElementsByTagName(Tag.SPACE_BEFORE)[0];
340
- if (old)
341
- ppr.removeChild(old);
342
- ppr.appendChild(transitionSpcBef.cloneNode(true));
343
- };
364
+ const transitionSpcBef = firstBullet ? spaceBefore(firstBullet) : null;
344
365
  for (const p of specimen)
345
366
  detach(p);
346
367
  const doc = shape.ownerDocument;
@@ -362,8 +383,10 @@ export function rebuildParagraphs(shape, paragraphs, startIndex, relation, shape
362
383
  const seedRun = buildRun(doc, bucket?.rPr ?? null, "");
363
384
  const newPara = buildParagraph(doc, bucket?.pPr ?? null, seedRun);
364
385
  maybeOverrideLevel(newPara, isBullet ? effectiveLvl : null);
365
- if (!isBullet && prevWasBullet)
366
- applyTransitionSpacing(newPara);
386
+ if (!isBullet && prevWasBullet && transitionSpcBef)
387
+ setSpaceBefore(newPara, transitionSpcBef);
388
+ if (isBullet && prevWasBullet && followOn.has(effectiveLvl))
389
+ setSpaceBefore(newPara, followOn.get(effectiveLvl));
367
390
  setRichRuns(newPara, para.runs, relation);
368
391
  txBody.appendChild(newPara);
369
392
  prevWasBullet = isBullet;
@@ -15,14 +15,15 @@
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 { type BodyRows, SlotType } from "../types.js";
18
+ import { type BodyRows, type ImageFit, SlotType } from "../types.js";
19
19
  /**
20
20
  * The shape a filler targets — a union discriminated by `type` (mirroring the
21
21
  * `Block` variants), so each filler's callback sees only its own specimen
22
22
  * options. Every target carries a `shapeName` and a human `label` for
23
23
  * diagnostics (the author-facing "slide N, layout …, slot …" a filler prints in
24
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.
25
+ * `startAt`, a table target carries its required `bodyRows` range, and an image
26
+ * target its required `fit`.
26
27
  */
27
28
  export type TemplateFillTarget = {
28
29
  type: typeof SlotType.Template;
@@ -45,6 +46,7 @@ export type ImageFillTarget = {
45
46
  type: typeof SlotType.Image;
46
47
  shapeName: string;
47
48
  label: string;
49
+ fit: ImageFit;
48
50
  };
49
51
  export type FillTarget = TemplateFillTarget | TextFillTarget | TableFillTarget | ImageFillTarget;
50
52
  /** A pptx-automizer element-modify callback: `(element, relation) => void`. */
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { basename } from "node:path";
19
19
  import { ModifyImageHelper } from "pptx-automizer";
20
- import { SlotType } from "../types.js";
20
+ import { SlotType, } from "../types.js";
21
21
  import { fillImage, isImageFill } from "./image.js";
22
22
  import { fillTable, isTableFill } from "./table.js";
23
23
  import { fillTemplate, isTemplateFill } from "./template.js";
@@ -44,7 +44,7 @@ const imageFiller = {
44
44
  matches: isImageFill,
45
45
  callbacks: (v, t) => [
46
46
  ModifyImageHelper.setRelationTarget(basename(v.path)),
47
- (el) => fillImage(el, v, t.shapeName, t.label),
47
+ (el) => fillImage(el, v, t.shapeName, t.label, t.fit),
48
48
  ],
49
49
  };
50
50
  export const FILLERS = {
@@ -2,7 +2,7 @@
2
2
  * Image fill — element-level picture geometry and alt text. The media swap
3
3
  * (pointing the blip relationship at the new file) is a slide-level modifier in
4
4
  * the ImageFiller (`fillers/filler.ts`); this module resolves the frame geometry
5
- * from the image's `ImageFit` (contain/cover/scale-down) and the source's true
5
+ * from the slot's `ImageFit` (contain/cover/scale-down) and the source's true
6
6
  * pixel size, and rewrites the picture's alt text for the new image.
7
7
  */
8
8
  import { type ImageFill, ImageFit } from "../types.js";
@@ -35,7 +35,7 @@ export type GeometryResult = {
35
35
  warnings: string[];
36
36
  };
37
37
  /**
38
- * Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
38
+ * Size a picture shape by its slot's `fit` (via `computeGeometry`): either write
39
39
  * `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
40
40
  * aspect ratio and re-center (fit/letterbox). Advisory warnings from the
41
41
  * geometry pass go to `console.warn`. Also sets its alt text (`setAltText`).
@@ -43,7 +43,7 @@ export type GeometryResult = {
43
43
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
44
44
  * ImageFill reaches the engine.
45
45
  */
46
- export declare function fillImage(shape: any, image: ImageFill, shapeName: string, label: string): void;
46
+ export declare function fillImage(shape: any, image: ImageFill, shapeName: string, label: string, fit: ImageFit): void;
47
47
  /**
48
48
  * Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
49
49
  * `fit` picks the strategy: `cover` scales to the larger axis ratio and
@@ -2,7 +2,7 @@
2
2
  * Image fill — element-level picture geometry and alt text. The media swap
3
3
  * (pointing the blip relationship at the new file) is a slide-level modifier in
4
4
  * the ImageFiller (`fillers/filler.ts`); this module resolves the frame geometry
5
- * from the image's `ImageFit` (contain/cover/scale-down) and the source's true
5
+ * from the slot's `ImageFit` (contain/cover/scale-down) and the source's true
6
6
  * pixel size, and rewrites the picture's alt text for the new image.
7
7
  */
8
8
  import { readFileSync } from "node:fs";
@@ -23,7 +23,7 @@ const SEVERE_MISMATCH_FRACTION = 0.5;
23
23
  /** Warn once the image renders below this fraction of its native pixel size. */
24
24
  const MIN_SCALE = 0.2;
25
25
  /**
26
- * Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
26
+ * Size a picture shape by its slot's `fit` (via `computeGeometry`): either write
27
27
  * `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
28
28
  * aspect ratio and re-center (fit/letterbox). Advisory warnings from the
29
29
  * geometry pass go to `console.warn`. Also sets its alt text (`setAltText`).
@@ -31,7 +31,7 @@ const MIN_SCALE = 0.2;
31
31
  * `image.path` is assumed absolute — the compiler / caller resolves it before the
32
32
  * ImageFill reaches the engine.
33
33
  */
34
- export function fillImage(shape, image, shapeName, label) {
34
+ export function fillImage(shape, image, shapeName, label, fit) {
35
35
  const dims = imageSize(new Uint8Array(readFileSync(image.path)));
36
36
  if (!dims.width || !dims.height) {
37
37
  throw new Error(`Image shape "${shapeName}": could not read image dimensions from "${image.path}".`);
@@ -50,7 +50,7 @@ export function fillImage(shape, image, shapeName, label) {
50
50
  w: Number(ext.getAttribute(Attr.CX)),
51
51
  h: Number(ext.getAttribute(Attr.CY)),
52
52
  };
53
- const { geometry, warnings } = computeGeometry(frame, dims.width, dims.height, image.fit);
53
+ const { geometry, warnings } = computeGeometry(frame, dims.width, dims.height, fit);
54
54
  for (const w of warnings)
55
55
  console.warn(`Image ${label}: ${w}`);
56
56
  if (geometry.placement === Placement.Crop) {
@@ -272,7 +272,8 @@ function resolveBlock(step, slot, value) {
272
272
  }
273
273
  /**
274
274
  * The shape a filler targets, plus its diagnostic `label` and the intra-specimen
275
- * options a block declares: `startAt` (text) and the required `bodyRows` (table).
275
+ * options a block declares: `startAt` (text), the required `bodyRows` (table) and
276
+ * the required `fit` (image).
276
277
  * The block's `type` selects the matching target variant, so each filler's
277
278
  * callback sees only its own options.
278
279
  */
@@ -287,7 +288,7 @@ function targetOf(block, label) {
287
288
  case SlotType.Table:
288
289
  return { type: SlotType.Table, shapeName: block.shapeName, label, bodyRows: block.bodyRows };
289
290
  case SlotType.Image:
290
- return { type: SlotType.Image, shapeName: block.shapeName, label };
291
+ return { type: SlotType.Image, shapeName: block.shapeName, label, fit: block.fit };
291
292
  }
292
293
  }
293
294
  /**
@@ -99,17 +99,16 @@ export declare const ImageFit: {
99
99
  };
100
100
  export type ImageFit = (typeof ImageFit)[keyof typeof ImageFit];
101
101
  /**
102
- * Input to fillImage — a resolved image path plus its object-fit directive.
103
- * `path` must be absolute; the compiler resolves it (and maps the asset's
104
- * semantic type to a `fit`) before the ImageFill reaches the engine.
105
- * `alt` is the picture's accessibility description (`descr` on its
102
+ * Input to fillImage — a resolved image path and its alt text. How the image
103
+ * fills its shape is the slot's, from its image block's `fit`.
104
+ * `path` must be absolute; the compiler resolves it before the ImageFill reaches
105
+ * the engine. `alt` is the picture's accessibility description (`descr` on its
106
106
  * `<p:cNvPr>`); empty means none, and clears any description the placeholder
107
107
  * carried.
108
108
  */
109
109
  export type ImageFill = {
110
110
  type: typeof SlotType.Image;
111
111
  path: string;
112
- fit: ImageFit;
113
112
  alt: string;
114
113
  };
115
114
  /** A shape's absolute position and size, in EMU — the slot's frame. */
@@ -128,7 +127,7 @@ export type Frame = {
128
127
  * otherwise it is transplanted from `sourceSlide` into the slot's frame. A text
129
128
  * block may pin `startAt` (leave the first N specimen paragraphs untouched); a
130
129
  * table block MUST declare its `bodyRows` range (the repeatable specimen rows);
131
- * a template or image block carries neither. `Template` reaches the engine only
130
+ * an image block MUST declare its `fit`; a template block carries none of them. `Template` reaches the engine only
132
131
  * via a frontmatter parameter (`paramToEngineSlot`), never as an author body block.
133
132
  *
134
133
  * Named `Block` — a kind of content the way an author thinks of it. Distinct
@@ -156,6 +155,7 @@ export type ImageBlock = {
156
155
  type: typeof SlotType.Image;
157
156
  sourceSlide: number;
158
157
  shapeName: string;
158
+ fit: ImageFit;
159
159
  };
160
160
  export type Block = TemplateBlock | TextBlock | TableBlock | ImageBlock;
161
161
  /**
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ function compilerBlockToEngineBlock(b) {
34
34
  case AcceptType.Table:
35
35
  return { type: SlotType.Table, sourceSlide: b.sourceSlide, shapeName: b.shapeName, bodyRows: b.bodyRows };
36
36
  case AcceptType.Image:
37
- return { type: SlotType.Image, sourceSlide: b.sourceSlide, shapeName: b.shapeName };
37
+ return { type: SlotType.Image, sourceSlide: b.sourceSlide, shapeName: b.shapeName, fit: b.fit };
38
38
  }
39
39
  }
40
40
  function slotToEngineSlot(slot) {
@@ -1,25 +1,2 @@
1
- import * as z from "zod";
2
1
  import { type BlockHandler } from "../types.js";
3
2
  export declare const IMAGE: BlockHandler;
4
- /**
5
- * Image options, carried in a markdown image's title: `![alt](src "fit: cover")`.
6
- * The title is a YAML mapping validated by this strict schema, so an unknown key
7
- * throws naming the valid ones, and adding an option is one line here.
8
- */
9
- declare const imageOptionsSchema: z.ZodObject<{
10
- fit: z.ZodOptional<z.ZodEnum<{
11
- readonly Contain: "contain";
12
- readonly Cover: "cover";
13
- readonly ScaleDown: "scale-down";
14
- }>>;
15
- }, z.core.$strict>;
16
- export type ImageOptions = z.infer<typeof imageOptionsSchema>;
17
- /**
18
- * Parse an image title into its options. A missing or blank title means no
19
- * options. Anything else must be a YAML mapping of known options, or the build
20
- * fails with `where` (the slide, layout and slot) and a hint for the common
21
- * mistakes: a caption written in the title, a missing space after the colon, and
22
- * several options without flow braces.
23
- */
24
- export declare function parseImageTitle(title: string | null | undefined, where: string): ImageOptions;
25
- export {};
@@ -1,71 +1,19 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { parse as parseYaml } from "yaml";
4
- import * as z from "zod";
5
- import { ImageFit, SlotType } from "../../engine/index.js";
3
+ import { SlotType } from "../../engine/index.js";
6
4
  import { MdastType } from "../mdast.js";
7
- import { strict } from "../schema/strict.js";
8
- import { isYamlMapping } from "../slideParser.js";
9
5
  import { AcceptType } from "../types.js";
10
- /** The fit when the title gives none: the whole picture, never cropped. */
11
- const DEFAULT_FIT = ImageFit.Contain;
12
6
  export const IMAGE = {
13
7
  match: (node) => node.type === MdastType.Image,
14
8
  acceptType: AcceptType.Image,
15
9
  // `url` is a path relative to the deck. `alt` is the picture's alt text; the
16
10
  // parser flattens it to plain text, and an image written without any is "".
17
- // `title` carries the image's options.
18
11
  compile: async (node, ctx) => {
19
- const { url, alt, title } = node;
20
- const options = parseImageTitle(title, ctx.region);
12
+ const { url, alt } = node;
21
13
  const path = resolve(ctx.config.deckDir, url);
22
14
  if (!existsSync(path)) {
23
15
  throw new Error(`${ctx.region}: image "${url}" not found at ${path}`);
24
16
  }
25
- return { type: SlotType.Image, path, fit: options.fit ?? DEFAULT_FIT, alt: alt ?? "" };
17
+ return { type: SlotType.Image, path, alt: alt ?? "" };
26
18
  },
27
19
  };
28
- /**
29
- * Image options, carried in a markdown image's title: `![alt](src "fit: cover")`.
30
- * The title is a YAML mapping validated by this strict schema, so an unknown key
31
- * throws naming the valid ones, and adding an option is one line here.
32
- */
33
- const imageOptionsSchema = strict({
34
- fit: z.enum(ImageFit).optional(),
35
- });
36
- /** The one-option title every hint quotes, so an author sees the expected form. */
37
- const EXAMPLE_TITLE = `"fit: ${ImageFit.Contain}"`;
38
- /** A colon run into the next character, as in `fit:contain`: YAML reads that as one string. */
39
- const COLON_WITHOUT_SPACE_RE = /:\S/;
40
- /**
41
- * Parse an image title into its options. A missing or blank title means no
42
- * options. Anything else must be a YAML mapping of known options, or the build
43
- * fails with `where` (the slide, layout and slot) and a hint for the common
44
- * mistakes: a caption written in the title, a missing space after the colon, and
45
- * several options without flow braces.
46
- */
47
- export function parseImageTitle(title, where) {
48
- if (!title?.trim())
49
- return {};
50
- const prefix = `${where}: image title "${title}"`;
51
- let parsed;
52
- try {
53
- parsed = parseYaml(title);
54
- }
55
- catch (err) {
56
- // The yaml message's first line names the problem; the rest is a code frame.
57
- const reason = err.message.split("\n")[0].replace(/:$/, "");
58
- throw new Error(`${prefix} is not valid YAML (${reason}). Several options go in braces: "{key: value, key: value}".`);
59
- }
60
- if (!isYamlMapping(parsed)) {
61
- const hint = typeof parsed === "string" && COLON_WITHOUT_SPACE_RE.test(parsed)
62
- ? `put a space after the colon, as in ${EXAMPLE_TITLE}`
63
- : `the title holds options like ${EXAMPLE_TITLE}; put a description in the alt text instead: ![${title}](…)`;
64
- throw new Error(`${prefix} is not a set of options: ${hint}.`);
65
- }
66
- const result = imageOptionsSchema.safeParse(parsed);
67
- if (!result.success) {
68
- throw new Error(`${prefix}: ${z.prettifyError(result.error)}`);
69
- }
70
- return result.data;
71
- }
@@ -7,8 +7,8 @@ export declare const MERMAID_LANG = "mermaid";
7
7
  * Recognize a ```mermaid fenced block at a region's top level, folding it to an
8
8
  * Image fill, and compile it by rendering the definition to a PNG (cached under
9
9
  * `<rootDir>/.tycoslide-cache/mermaid/<hash>.png`, in the theme directory) and wrapping it as an
10
- * ImageFill. Fit is always `contain` — mermaid diagrams are shown in their
11
- * entirety — and a fence carries no alt text. Resolution is strict: the theme MUST carry a `mermaid` block, MUST
10
+ * ImageFill, which fills the slot by the slot's fit like any image. A fence
11
+ * carries no alt text. Resolution is strict: the theme MUST carry a `mermaid` block, MUST
12
12
  * declare a `mermaidVariant`, and that variant MUST exist — each missing piece
13
13
  * throws by name.
14
14
  */
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
3
3
  import { createRequire } from "node:module";
4
4
  import { extname, join, resolve, sep } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
- import { ImageFit, SlotType } from "../../engine/index.js";
6
+ import { SlotType } from "../../engine/index.js";
7
7
  import { MdastType } from "../mdast.js";
8
8
  import { AcceptType } from "../types.js";
9
9
  import { buildMermaidRenderConfig, injectClassDefs, validateMermaidDefinition, } from "./mermaidTheme.js";
@@ -15,8 +15,8 @@ export const MERMAID_LANG = "mermaid";
15
15
  * Recognize a ```mermaid fenced block at a region's top level, folding it to an
16
16
  * Image fill, and compile it by rendering the definition to a PNG (cached under
17
17
  * `<rootDir>/.tycoslide-cache/mermaid/<hash>.png`, in the theme directory) and wrapping it as an
18
- * ImageFill. Fit is always `contain` — mermaid diagrams are shown in their
19
- * entirety — and a fence carries no alt text. Resolution is strict: the theme MUST carry a `mermaid` block, MUST
18
+ * ImageFill, which fills the slot by the slot's fit like any image. A fence
19
+ * carries no alt text. Resolution is strict: the theme MUST carry a `mermaid` block, MUST
20
20
  * declare a `mermaidVariant`, and that variant MUST exist — each missing piece
21
21
  * throws by name.
22
22
  */
@@ -42,7 +42,7 @@ export const MERMAID = {
42
42
  }
43
43
  const cacheDir = ensureCacheDir(config);
44
44
  const pngPath = await renderOne(definition, variantName, variant, cacheDir, config);
45
- return { type: SlotType.Image, path: pngPath, fit: ImageFit.Contain, alt: "" };
45
+ return { type: SlotType.Image, path: pngPath, alt: "" };
46
46
  },
47
47
  };
48
48
  const FONT_FORMATS = {
@@ -187,12 +187,12 @@ export async function launchChromium(chromium, browserPath) {
187
187
  }
188
188
  /**
189
189
  * Render a mermaid definition to a transparent PNG via a headless Chromium
190
- * (Playwright — the proven old driver, stronger headless font fidelity). The
190
+ * (Playwright, for its headless font fidelity). The
191
191
  * theme fonts are injected as `@font-face` and every registered face is awaited
192
192
  * (`document.fonts.load()`) BEFORE `mermaid.render()` measures text — the only
193
193
  * cross-platform, zero-install way to make Chromium lay out labels in the brand
194
- * font instead of a substitute. mmdc's `--cssFile` injects too late (after layout)
195
- * to affect metrics, which is why this replaces its programmatic API.
194
+ * font instead of a substitute. A stylesheet injected after layout comes too
195
+ * late to affect the metrics.
196
196
  */
197
197
  async function renderMermaidToPng(processed, renderConfig, fonts, outputPath, browserPath) {
198
198
  const bundle = getMermaidBundle();
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Owner of MermaidVariant / MermaidConfig — these types are compiler-facing
5
5
  * (the engine has no idea mermaid exists). `blocks/mermaid.ts` consumes
6
- * them to build --configFile input for `mmdc`.
6
+ * them to build the render config.
7
7
  */
8
8
  export type MermaidVariant = {
9
9
  primary: string;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Owner of MermaidVariant / MermaidConfig — these types are compiler-facing
5
5
  * (the engine has no idea mermaid exists). `blocks/mermaid.ts` consumes
6
- * them to build --configFile input for `mmdc`.
6
+ * them to build the render config.
7
7
  */
8
8
  const FORBIDDEN_PATTERNS = [/^\s*style\s+\S+\s+/, /^\s*linkStyle\s+/, /^\s*classDef\s+/, /^\s*%%\{init/];
9
9
  export function validateMermaidDefinition(definition) {
@@ -88,7 +88,7 @@ export function buildMermaidRenderConfig(variant) {
88
88
  tertiaryColor: variant.surface,
89
89
  textColor: variant.text,
90
90
  titleColor: variant.text,
91
- nodeTextColor: variant.text,
91
+ nodeTextColor: variant.primaryContrast,
92
92
  clusterBkg: variant.surface,
93
93
  clusterBorder: variant.surfaceBorder,
94
94
  edgeLabelBackground: variant.surface,
@@ -30,6 +30,11 @@ export declare const ThemeConfigSchema: z.ZodObject<{
30
30
  type: z.ZodLiteral<"image">;
31
31
  sourceSlide: z.ZodNumber;
32
32
  shapeName: z.ZodString;
33
+ fit: z.ZodEnum<{
34
+ readonly Contain: "contain";
35
+ readonly Cover: "cover";
36
+ readonly ScaleDown: "scale-down";
37
+ }>;
33
38
  }, z.core.$strict>], "type">>;
34
39
  frame: z.ZodOptional<z.ZodObject<{
35
40
  x: z.ZodNumber;
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { basename, dirname } from "node:path";
3
3
  import * as z from "zod";
4
- import { AcceptType, Variant } from "../types.js";
4
+ import { AcceptType, ImageFit, Variant } from "../types.js";
5
5
  import { strict } from "./strict.js";
6
6
  /**
7
7
  * Fail-fast runtime validation for a `theme.json`. The hand-written types in
@@ -60,9 +60,10 @@ const ParameterSchema = strict({
60
60
  template: z.string(),
61
61
  });
62
62
  // One arm per accept type, discriminated by `type`, mirroring `CompilerBlock`:
63
- // `startAt` lives only on the text arm, and `bodyRows` only on the table arm (a
64
- // table block MUST declare its repeatable-row range; text/image arms have no
65
- // `bodyRows` field, so a stray one is an unknown key the `strict` arm rejects).
63
+ // `startAt` lives only on the text arm, `bodyRows` only on the table arm and `fit`
64
+ // only on the image arm (a table block MUST declare its repeatable-row range and
65
+ // an image block its fit; an option on another arm is an unknown key the `strict`
66
+ // arm rejects).
66
67
  const TextBlockSchema = strict({
67
68
  type: z.literal(AcceptType.Text),
68
69
  sourceSlide: z.number(),
@@ -79,6 +80,7 @@ const ImageBlockSchema = strict({
79
80
  type: z.literal(AcceptType.Image),
80
81
  sourceSlide: z.number(),
81
82
  shapeName: z.string(),
83
+ fit: z.enum(ImageFit),
82
84
  });
83
85
  const BlockSchema = z.discriminatedUnion("type", [TextBlockSchema, TableBlockSchema, ImageBlockSchema]);
84
86
  const SlotSchema = strict({
@@ -8,8 +8,6 @@ export interface ParsedDocument {
8
8
  slides: RawSlide[];
9
9
  }
10
10
  export declare function parseSlideDocument(source: string): ParsedDocument;
11
- /** True for a parsed YAML document that is a mapping (`key: value` pairs). */
12
- export declare function isYamlMapping(parsed: unknown): parsed is Record<string, unknown>;
13
11
  export declare class FrontmatterParseError extends Error {
14
12
  constructor(slideIndex: number, yamlSource: string, cause: unknown);
15
13
  }
@@ -16,7 +16,7 @@ export function parseSlideDocument(source) {
16
16
  // ============================================
17
17
  const GLOBAL_FM_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
18
18
  /** True for a parsed YAML document that is a mapping (`key: value` pairs). */
19
- export function isYamlMapping(parsed) {
19
+ function isYamlMapping(parsed) {
20
20
  return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
21
21
  }
22
22
  function extractGlobalFrontmatter(source) {
@@ -1,7 +1,8 @@
1
1
  import type { RootContent } from "mdast";
2
- import { type BodyRows, type Frame, type ImageFill, type TableFill, type TemplateFill, type TextFill } from "../engine/index.js";
2
+ import { type BodyRows, type Frame, type ImageFill, ImageFit, type TableFill, type TemplateFill, type TextFill } from "../engine/index.js";
3
3
  import type { MermaidConfig } from "./blocks/mermaidTheme.js";
4
4
  export type { BodyRows };
5
+ export { ImageFit };
5
6
  /**
6
7
  * The engine content types a slot may `accept`. A subset of the engine's
7
8
  * `SlotType`: `Template` is parameter-only, never a body block. There is no
@@ -146,9 +147,10 @@ export declare const RESERVED_KEY: {
146
147
  * place), otherwise it is transplanted into the slot's `frame`. A text block may
147
148
  * pin `startAt` (leave the first N specimen paragraphs untouched); a table block
148
149
  * MUST declare its `bodyRows` range (the repeatable `<a:tbl>` specimen rows); an
149
- * image block carries neither. There is no `Template` variant — `AcceptType` excludes
150
- * it (a parameter, not a body block). Named `CompilerBlock` to stay distinct from
151
- * the engine's `Block` and the compiler's `BlockHandler`.
150
+ * image block MUST declare its `fit`, which sets how every image placed in it
151
+ * fills the shape. There is no `Template` variant — `AcceptType` excludes it (a
152
+ * parameter, not a body block). Named `CompilerBlock` to stay distinct from the
153
+ * engine's `Block` and the compiler's `BlockHandler`.
152
154
  */
153
155
  export type CompilerTextBlock = {
154
156
  type: typeof AcceptType.Text;
@@ -166,6 +168,7 @@ export type CompilerImageBlock = {
166
168
  type: typeof AcceptType.Image;
167
169
  sourceSlide: number;
168
170
  shapeName: string;
171
+ fit: ImageFit;
169
172
  };
170
173
  export type CompilerBlock = CompilerTextBlock | CompilerTableBlock | CompilerImageBlock;
171
174
  /**
@@ -1,4 +1,5 @@
1
- import { SlotType, } from "../engine/index.js";
1
+ import { ImageFit, SlotType, } from "../engine/index.js";
2
+ export { ImageFit };
2
3
  // ── AcceptType discriminator (what a slot accepts) ────────────────────────────
3
4
  /**
4
5
  * The engine content types a slot may `accept`. A subset of the engine's
package/docs/markdown.md CHANGED
@@ -144,23 +144,23 @@ Fill a slot by writing a `::key::` region in the body; the marker maps to the sl
144
144
  ````
145
145
  The language tag (e.g. `sql`, `python`, `typescript`) is required -- it drives syntax highlighting, using the theme's `codeTheme` (set once in `theme.json`, not per slot). Colors are applied as native text runs in the output, not images.
146
146
  - **table** (slots that accept `table`) -- a GFM table. Write it in the slot region between `|`-delimited headers and rows; cells inherit inline formatting (bold, italic, links).
147
- - **image** (slots that accept `image`) -- an image, written as `![alt](path "options")` with the path relative to the deck:
147
+ - **image** (slots that accept `image`) -- an image, written as `![alt](path)` with the path relative to the deck:
148
148
  ```markdown
149
149
  ::logo::
150
150
 
151
- ![Acme Corp logo](assets/acme-logo.png "fit: contain")
151
+ ![Acme Corp logo](assets/acme-logo.png)
152
152
  ```
153
- The alt text becomes the image's alt text in PowerPoint; leave it empty for an image that is only decoration. The title is optional and holds image options as YAML. The only option is `fit`, one of `contain` (the default, which shows the whole image and never crops), `cover` (fills the frame and center-crops) or `scale-down` (shows the whole image and never enlarges it). Anything else in the title fails the build. A fenced `mermaid` block also fills an image slot, rendering to a themed PNG (see below).
153
+ The alt text becomes the image's alt text in PowerPoint; leave it empty for an image that is only decoration. The slot's `fit`, set in the theme, decides how the image fills its shape. A fenced `mermaid` block also fills an image slot, rendering to a themed PNG (see below).
154
154
 
155
155
  ---
156
156
 
157
157
  ## Mermaid diagrams
158
158
 
159
- Write a fenced code block with the `mermaid` language tag in a named slot that accepts `image`. It renders to a themed PNG and behaves like any other image in the slot -- always shown in its entirety. The color variant comes from the theme's `mermaidVariant` (set once in `theme.json`, not per slot).
159
+ Write a fenced code block with the `mermaid` language tag in a named slot that accepts `image`. It renders to a themed PNG and fills the slot like any other image, by the slot's `fit`. The color variant comes from the theme's `mermaidVariant` (set once in `theme.json`, not per slot).
160
160
 
161
161
  ````markdown
162
162
  ---
163
- layout: Full bleed image with title dark
163
+ layout: Diagram
164
164
  title: System Architecture
165
165
  ---
166
166
 
@@ -215,7 +215,7 @@ The theme owns all styling. These directives are rejected at build time:
215
215
 
216
216
  Each parameter or slot in the layout definition may declare:
217
217
  - **`accepts`** (slots, required) -- an array of `text`, `table`, `image`.
218
- - **`required: true`** -- the slide has no usable default and the build fails if the parameter/slot has no value (e.g. team-member photos, icon-grid icons, the quote logo).
218
+ - **`required: true`** -- the slide has no usable default and the build fails if the parameter/slot has no value.
219
219
  - **optional (the default)** -- a parameter or slot you leave unfilled is dropped from the slide (its shape is removed), so a layout with numbered slots (e.g. up to six sections, up to four stats) renders only the ones you fill.
220
220
 
221
221
  Each layout also declares a `slideNumber` pointing at the physical slide in the theme's template -- unique per layout (one layout maps to one physical slide).
@@ -272,7 +272,6 @@ Common errors and fixes:
272
272
  | A parameter or slot didn't fill | Use the key names the layout declares -- parameters in frontmatter, slots as body regions |
273
273
  | YAML parse error | Fix the YAML syntax in the slide's frontmatter |
274
274
  | `image "…" not found at …` | Check the path; it is relative to the deck file |
275
- | `is not a set of options` | The image title holds options like `"fit: contain"`; put a description in the alt text |
276
275
  | `Skipped setting relation target` | The asset image couldn't be placed; check the path and file |
277
276
  | `forbidden style directive` | Remove `style`, `classDef`, `linkStyle`, or `%%{init}` from your mermaid block -- use `class` for grouping instead |
278
277
  | `no "mermaid" block` | The theme has no mermaid color config -- add a `mermaid` section to theme.json |
package/docs/theme.md CHANGED
@@ -48,7 +48,7 @@ A slot is a body region. The author's markdown shape picks which accepted block
48
48
 
49
49
  ### Blocks inside `accepts`
50
50
 
51
- Each block is discriminated by `type` and carries only its own option. A `bodyRows` on a text block or a `startAt` on a table block is an unknown key and fails the load.
51
+ Each block is discriminated by `type` and carries only its own option. A `bodyRows` on a text block, a `startAt` on a table block or a `fit` on either is an unknown key and fails the load.
52
52
 
53
53
  | Field | Meaning | Error when wrong |
54
54
  |---|---|---|
@@ -57,8 +57,9 @@ Each block is discriminated by `type` and carries only its own option. A `bodyRo
57
57
  | `shapeName` | Exact name of the specimen shape on `sourceSlide`. Required. | `Can't find element on slide N in source:` and the placeholder remains. |
58
58
  | `startAt` | Text only. Leave the first N specimen paragraphs untouched and rebuild from paragraph N. Optional, default 0. | `startAt 3 is past the last paragraph (shape has 2)`. |
59
59
  | `bodyRows` | Table only. `[start, end]`, 0-based inclusive: the rows that repeat for data. Required. Row 0 is always the header. Rows above `start` and below `end` render once, as fixed rows. | `bodyRows [s, e] is out of range; require 1 <= start <= end <= R-1 (row 0 is the header)`; `has no <a:tbl> element (is it actually a table?)`. |
60
+ | `fit` | Image only. Sets how every image placed in the shape fills it. `"contain"` shows the whole image and never crops, `"cover"` fills the shape and center-crops, and `"scale-down"` shows the whole image and never enlarges it. Required. | Schema error listing the valid values. |
60
61
 
61
- For an image block the shape must be a real picture: `is not a picture (missing <a:off>, <a:ext>, <p:blipFill>, or <p:cNvPr>)`.
62
+ For an image block the shape must be an image shape: `is not a picture (missing <a:off>, <a:ext>, <p:blipFill>, or <p:cNvPr>)`.
62
63
 
63
64
  Transplants: a block whose `sourceSlide` differs from the layout's `slideNumber` is copied from that other slide onto the cloned slide, positioned into the slot's `frame`. The shape the slot replaces on the base slide is removed. This is how one slot accepts both text and a table when the styled table lives on a different slide, as the worked example's `Content` layout does with a table from slide 15. The frame is the observed `<a:xfrm>` of the shape being replaced, so a text block in the same slot fills in place and the table lands exactly where the text was. The transplanted shape is positioned and sized to the frame, so its own size on the source slide does not matter.
64
65
 
@@ -69,7 +70,7 @@ Optional array of `@font-face` entries used only when rendering mermaid diagrams
69
70
  | Field | Meaning | Error when wrong |
70
71
  |---|---|---|
71
72
  | `family` | CSS family name; must equal the mermaid variant's `fontFamily`. Required. | Warning `fontFamily "X" matches none of the declared theme fonts`, then a substitute font. |
72
- | `path` | A package specifier such as `@fontsource/inter/files/inter-latin-400-normal.woff2`, or a `./` or `/` file path. Required. | `could not resolve "path" from <rootDir>`; `file not found at ...`; `unsupported format ".otf"`. Supported: woff2, woff, ttf. |
73
+ | `path` | A package specifier such as `@fontsource/inter/files/inter-latin-400-normal.woff2`, or a `./` or `/` file path. Required. | `could not resolve "path" from <rootDir>`; `file not found at ...`; `unsupported format ".eot"`. Supported: woff2, woff, ttf, otf. |
73
74
  | `weight` | OpenType weight. Optional, default 400. | None. |
74
75
 
75
76
  ## `codeTheme`
@@ -84,7 +85,7 @@ Optional record of named color variants. The record keys are free; every variant
84
85
  |---|---|
85
86
  | `primary` | Node fill color. |
86
87
  | `primaryContrast` | Text color on `primary` nodes. |
87
- | `text` | Default text, title and node label color. |
88
+ | `text` | Default text and title color, including labels outside nodes. |
88
89
  | `line` | Edge and arrow color. |
89
90
  | `surface` | Subgraph, secondary node and edge-label background. |
90
91
  | `surfaceBorder` | Border color for nodes and subgraphs. |
@@ -120,7 +121,7 @@ Optional string naming a key of `mermaid`. Required as soon as any deck has a me
120
121
  "accepts": [
121
122
  { "type": "text", "sourceSlide": 1, "shapeName": "Text 1" },
122
123
  { "type": "table", "sourceSlide": 2, "shapeName": "Table 0", "bodyRows": [1, 1] },
123
- { "type": "image", "sourceSlide": 3, "shapeName": "Image 0" }
124
+ { "type": "image", "sourceSlide": 3, "shapeName": "Image 0", "fit": "contain" }
124
125
  ]
125
126
  }
126
127
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tycoworks/tycoslide",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Create editable PowerPoint slides from markdown, using your existing .pptx templates.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,9 +26,9 @@
26
26
  "format": "biome format --write ."
27
27
  },
28
28
  "dependencies": {
29
- "@xmldom/xmldom": "^0.9.10",
29
+ "@xmldom/xmldom": "^0.9.12",
30
30
  "commander": "^15.0.0",
31
- "image-size": "^2.0.2",
31
+ "image-size": "^2.0.4",
32
32
  "jszip": "^3.10.1",
33
33
  "mermaid": "^11.16.1",
34
34
  "playwright-core": "^1.62.1",
@@ -3,6 +3,7 @@ name: slides
3
3
  description: >
4
4
  Build branded slides, presentations, pitch decks, or sales collateral as a .pptx.
5
5
  Trigger on "deck," "slides," "presentation," "pitch," ".pptx," or "build me a deck."
6
+ compatibility: Needs Node 23.6+ and network access to npm on first use. LibreOffice (soffice) and poppler (pdftoppm) to preview slides. Chrome only if decks contain mermaid diagrams.
6
7
  ---
7
8
 
8
9
  # slides
@@ -16,7 +17,7 @@ npm install
16
17
  unzip -nq assets.dat
17
18
  ```
18
19
 
19
- This installs the tycoslide engine and its dependencies, and unpacks the theme's images so you can copy them into decks. You only need to do this once. If there is no `assets.dat`, skip the unzip.
20
+ This installs the tycoslide engine and its dependencies, and unpacks the theme's images so you can copy them into decks. You only need to do this once. If there is no `assets.dat`, skip the unzip. Run every `npx tycoslide` command from this folder too, since that is where the engine is installed and npx finds it nowhere else.
20
21
 
21
22
 
22
23
  ## Overview
@@ -36,7 +37,7 @@ This skill builds decks from a markdown deck file. The theme provides slide layo
36
37
 
37
38
  ## Layout Discovery
38
39
 
39
- Before writing anything, read `manifest.json`. It lists the theme's **layouts** -- for each: `name`, `description`, `parameters` (frontmatter inputs) and `slots` (body regions). A layout is identified by its `name`; every parameter and slot by its `key`. Slots carry `accepts`, and either may be `required`.
40
+ Before writing anything, read `manifest.json`. It lists the theme's **layouts** -- for each: `name`, `description`, `parameters` (frontmatter inputs) and `slots` (body regions). A layout is identified by its `name`; every parameter and slot by its `key`. Slots carry `accepts`, and either may be `required`. A slot that accepts an image also shows its `fit`. A `cover` slot crops the image to fill the slot, a `contain` slot shows the whole image, and a `scale-down` slot shows the whole image without ever enlarging it.
40
41
 
41
42
  A layout's inputs split two ways, parameters and slots; see [markdown.md](node_modules/@tycoworks/tycoslide/docs/markdown.md#parameters-and-slots) for how to fill each. Unfilled ones are dropped, so fill as many of a layout's numbered slots (sections, stats, columns) as you have content for.
42
43
 
@@ -44,19 +45,19 @@ Study each layout's `slots` before writing any slides.
44
45
 
45
46
  ### Images
46
47
 
47
- Images live in `assets.json`: every logo, illustration and icon the theme offers, keyed by category and name, each with a `path`, a `fit` and a `description`. **Search it, do not read it whole** -- an icon set alone can run to thousands of entries. **Search for what the icon depicts, not what you mean by it**: a catalog is indexed by image, so "freshness" finds nothing while `grep -i "clock" assets.json` and `grep -i "bolt"` find the icon you wanted.
48
+ Images live in `assets.json`: every logo, illustration and icon the theme offers, keyed by category and name, each with a `path` and a `description`. **Search it, do not read it whole** -- an icon set alone can run to thousands of entries. **Search for what the icon depicts, not what you mean by it**: a catalog is indexed by image, so "freshness" finds nothing while `grep -i -B1 "clock" assets.json` and `grep -i -B1 "bolt" assets.json` find the icon you wanted, with its path on the line above.
48
49
 
49
- To use an image, copy it into your deck's folder at the same relative path, then write its `path` and `fit` in the image:
50
+ To use an image, copy it into your deck's folder at the same relative path, then write its `path` in the image:
50
51
 
51
52
  ```bash
52
53
  mkdir -p <deck dir>/assets && cp <theme dir>/assets/hub.png <deck dir>/assets/
53
54
  ```
54
55
 
55
56
  ```markdown
56
- ![Central hub connecting three services](assets/hub.png "fit: scale-down")
57
+ ![Central hub connecting three services](assets/hub.png)
57
58
  ```
58
59
 
59
- If your deck is in the theme folder, the file is already there, so skip the copy. An image the user gives you also goes in the deck's folder; choose its fit by looking at it (`contain` unless it is full-bleed art that may crop). Write alt text for this slide, describing what the image shows here rather than repeating the catalog description.
60
+ If your deck is in the theme folder, the file is already there, so skip the copy. An image the user gives you also goes in the deck's folder. Write alt text for this slide, describing what the image shows here rather than repeating the catalog description.
60
61
 
61
62
  ---
62
63
 
@@ -89,7 +90,7 @@ Keep each slot's content to what its region comfortably holds. When content over
89
90
  - **Don't restyle the layout** -- the theme owns all design; you only fill slots
90
91
  - **Don't use an image that's wrong for the slot** -- a small slot wants a simple icon, not a dense illustration. If you get a `shrunk to X%` warning, look at the rendered slide: if the image is now too small to make out, use a simpler one. A `leaves X% of the frame empty` warning is the opposite: the image is a different shape from the slot. Neither fails the build -- judge both from the rendered slide.
91
92
  - **Don't skip alt text** -- describe what a meaningful image shows and why it's there, not "image of". Leave it empty only for decoration, such as backgrounds and icons beside a heading.
92
- - **Don't crop what can't be cropped** -- copy the `fit` from `assets.json`. For your own images, never `fit: cover` a diagram, chart, screenshot or logo.
93
+ - **Don't crop what can't be cropped** -- a `cover` slot crops, so never put a diagram (mermaid included), chart, screenshot or logo in one; choose a layout with a `contain` image slot instead.
93
94
  - **Don't invent layout names or image paths** -- layouts come from `manifest.json`, and theme images from `assets.json`, copied next to the deck
94
95
  - **Don't leave required parameters or slots empty** -- and don't leave a placeholder logo or dummy text in an image slot you care about. If you don't have a suitable image, ask the user for one.
95
96
 
@@ -105,7 +106,7 @@ Build the deck again ([Build](node_modules/@tycoworks/tycoslide/docs/markdown.md
105
106
 
106
107
  | Error | Fix |
107
108
  |-------|-----|
108
- | An image didn't swap / placeholder remains | Write a `::key::` region using the image slot's key, containing `![alt](path "fit: …")` with the path relative to the deck |
109
+ | An image didn't swap / placeholder remains | Write a `::key::` region using the image slot's key, containing `![alt](path)` with the path relative to the deck |
109
110
  | `image "…" not found at …` | For a theme image, copy it into the deck's folder at the path you wrote (search `assets.json`; did you unzip `assets.dat`?) |
110
111
 
111
112
  ### Verification Loop
@@ -127,7 +128,7 @@ soffice --headless --convert-to pdf --outdir . <deck>.pptx
127
128
  pdftoppm -png -r 96 <deck>.pdf <name>
128
129
  ```
129
130
 
130
- Read each slide image and check for:
131
+ LibreOffice substitutes any font it can't find, so line breaks in the images can differ slightly from PowerPoint's. Read each slide image and check for:
131
132
 
132
133
  - **Word wrapping** -- text that breaks mid-word or overflows its container
133
134
  - **Cramped text** -- content too dense for the slide area
@@ -1,32 +0,0 @@
1
- import * as z from "zod";
2
- /**
3
- * One image a theme offers deck authors. `fit` is the value an author copies
4
- * into the image's title, so it is already a title value.
5
- */
6
- declare const AssetEntrySchema: z.ZodObject<{
7
- path: z.ZodString;
8
- fit: z.ZodEnum<{
9
- readonly Contain: "contain";
10
- readonly Cover: "cover";
11
- readonly ScaleDown: "scale-down";
12
- }>;
13
- description: z.ZodString;
14
- }, z.core.$strict>;
15
- declare const AssetCatalogSchema: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
16
- path: z.ZodString;
17
- fit: z.ZodEnum<{
18
- readonly Contain: "contain";
19
- readonly Cover: "cover";
20
- readonly ScaleDown: "scale-down";
21
- }>;
22
- description: z.ZodString;
23
- }, z.core.$strict>>>;
24
- export type AssetEntry = z.infer<typeof AssetEntrySchema>;
25
- export type AssetCatalog = z.infer<typeof AssetCatalogSchema>;
26
- /**
27
- * Read and validate a theme's image catalog, `assets.json`: what a deck-writing
28
- * agent searches, copies images from, and takes each image's fit from. It is
29
- * authored with the theme and required; a theme with no images has `{}`.
30
- */
31
- export declare function loadAssetCatalog(themeDir: string): AssetCatalog;
32
- export {};
@@ -1,37 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import * as z from "zod";
4
- import { ImageFit, strict } from "../index.js";
5
- import { ASSETS_FILE } from "./files.js";
6
- /**
7
- * One image a theme offers deck authors. `fit` is the value an author copies
8
- * into the image's title, so it is already a title value.
9
- */
10
- const AssetEntrySchema = strict({
11
- path: z.string(),
12
- fit: z.enum(ImageFit),
13
- description: z.string(),
14
- });
15
- // `{ category: { name: entry } }`. Both record levels are open (the names are the
16
- // theme's own); only the entry is strict.
17
- const AssetCatalogSchema = z.record(z.string(), z.record(z.string(), AssetEntrySchema));
18
- /**
19
- * Read and validate a theme's image catalog, `assets.json`: what a deck-writing
20
- * agent searches, copies images from, and takes each image's fit from. It is
21
- * authored with the theme and required; a theme with no images has `{}`.
22
- */
23
- export function loadAssetCatalog(themeDir) {
24
- const path = join(themeDir, ASSETS_FILE);
25
- let raw;
26
- try {
27
- raw = JSON.parse(readFileSync(path, "utf-8"));
28
- }
29
- catch {
30
- throw new Error(`Image catalog not found or invalid JSON: ${path}`);
31
- }
32
- const result = AssetCatalogSchema.safeParse(raw);
33
- if (!result.success) {
34
- throw new Error(`${ASSETS_FILE}: invalid image catalog\n${z.prettifyError(result.error)}`);
35
- }
36
- return result.data;
37
- }