@tycoworks/tycoslide 0.16.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,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,11 +1,10 @@
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
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, personalizeSkill, 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}`);
@@ -28,7 +27,7 @@ export function registerAgentCommands(program, tool) {
28
27
  const skillName = basename(themePkg.name);
29
28
  let skillMd;
30
29
  try {
31
- skillMd = renameSkill(readFileSync(skillMdPath, "utf-8"), skillName);
30
+ skillMd = personalizeSkill(readFileSync(skillMdPath, "utf-8"), skillName, themePkg.description);
32
31
  }
33
32
  catch (err) {
34
33
  throw new Error(`${skillMdPath}: ${err.message}`);
@@ -52,8 +52,8 @@ export type InventoryShape = {
52
52
  rows?: number;
53
53
  cols?: number;
54
54
  /**
55
- * The start of the shape's text, with `¶` between paragraphs and `↵` for line
56
- * breaks: where a parameter template puts `\n`.
55
+ * The shape's text, with `¶` between paragraphs and `↵` for line breaks,
56
+ * either of which a parameter template writes as `\n`.
57
57
  */
58
58
  text: string;
59
59
  };
@@ -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 = [
@@ -298,7 +300,6 @@ const SHAPE_ELEMENTS = new Set([
298
300
  const GROUP_SEPARATOR = "/";
299
301
  const PARAGRAPH_MARK = "¶";
300
302
  const LINE_BREAK_MARK = "↵";
301
- const TEXT_PREVIEW_CHARS = 80;
302
303
  const TEXT_BOX_ON = "1";
303
304
  /** A placeholder with no `type` is a body placeholder. */
304
305
  const DEFAULT_PLACEHOLDER_TYPE = "body";
@@ -325,14 +326,14 @@ async function readShapes(presentation, parts) {
325
326
  }
326
327
  }
327
328
  }
328
- // Set fields only, in a fixed order, and the text cut to a preview.
329
+ // Set fields only, in a fixed order.
329
330
  return found.map(({ shape: { name, kind, frame, frameFrom, rows, cols, text } }) => ({
330
331
  name,
331
332
  kind,
332
333
  ...(frame && { frame }),
333
334
  ...(frameFrom && { frameFrom }),
334
335
  ...(rows !== undefined && { rows, cols }),
335
- text: Array.from(text).slice(0, TEXT_PREVIEW_CHARS).join(""),
336
+ text,
336
337
  }));
337
338
  }
338
339
  function walkShapes(container, prefix) {
@@ -349,7 +350,7 @@ function describeShape(el, prefix) {
349
350
  return {
350
351
  name: prefix + (find(nonVisualProps(el), Tag.NON_VISUAL_PROPS)?.getAttribute(Attr.NAME) ?? ""),
351
352
  kind: shapeKind(el, table),
352
- frame: shapeFrame(el),
353
+ frame: table ? tableFrame(el, table) : shapeFrame(el),
353
354
  rows: table && elementChildren(table, Tag.TABLE_ROW).length,
354
355
  cols: table && elementChildren(find(table, Tag.TABLE_GRID), Tag.GRID_COLUMN).length,
355
356
  text: el.tagName === Tag.GROUP ? "" : shapeText(el),
@@ -371,13 +372,17 @@ function shapeKind(el, table) {
371
372
  return ShapeKind.Other;
372
373
  }
373
374
  }
374
- /** The shape's text, `¶` between paragraphs and `↵` for line breaks, whitespace collapsed. */
375
+ /**
376
+ * The shape's text, `¶` between paragraphs and `↵` for line breaks, whitespace
377
+ * collapsed. Runs join as PowerPoint shows them, with no space added between: a
378
+ * run is a stretch of one style, not a word.
379
+ */
375
380
  function shapeText(el) {
376
381
  const paragraphs = collectElements(el, Tag.PARAGRAPH).map((paragraph) => elementChildren(paragraph)
377
382
  .flatMap((child) => child.tagName === Tag.LINE_BREAK
378
- ? [LINE_BREAK_MARK]
383
+ ? [` ${LINE_BREAK_MARK} `]
379
384
  : collectElements(child, Tag.TEXT).map((text) => text.textContent ?? ""))
380
- .join(" ")
385
+ .join("")
381
386
  .split(/\s+/)
382
387
  .filter(Boolean)
383
388
  .join(" "));
@@ -399,6 +404,23 @@ function shapeFrame(el) {
399
404
  cy: Number(extent.getAttribute(Attr.CY)),
400
405
  };
401
406
  }
407
+ /**
408
+ * A table's frame: where its graphic frame puts it, at the size its column widths
409
+ * and row heights give it. PowerPoint draws a table at that size, rows growing
410
+ * taller when their text needs it, and some tools (Google Slides among them)
411
+ * write a placeholder size on the frame.
412
+ */
413
+ function tableFrame(el, table) {
414
+ const frame = shapeFrame(el);
415
+ if (!frame)
416
+ return undefined;
417
+ const total = (els, attr) => els.reduce((sum, e) => sum + Number(e.getAttribute(attr) || 0), 0);
418
+ return {
419
+ ...frame,
420
+ cx: total(elementChildren(find(table, Tag.TABLE_GRID), Tag.GRID_COLUMN), Attr.WIDTH) || frame.cx,
421
+ cy: total(elementChildren(table, Tag.TABLE_ROW), Attr.HEIGHT) || frame.cy,
422
+ };
423
+ }
402
424
  function nonVisualProps(el) {
403
425
  return elementChildren(el).find((child) => child.tagName.startsWith(NON_VISUAL_PREFIX));
404
426
  }
@@ -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
@@ -7,12 +18,14 @@ import type { AssetCatalog } from "./catalog.js";
7
18
  */
8
19
  export declare function packAssets(paths: string[], read: (rel: string) => Buffer): Promise<Buffer>;
9
20
  /**
10
- * Rewrite the `name:` value in a SKILL.md's leading YAML frontmatter so the
11
- * packaged skill is named after the consuming theme, not the source template.
12
- * Only the leading `---`…`---` block is touched; the body is left byte-for-byte.
13
- * Throws if there is no frontmatter or no `name:` line — the caller names the file.
21
+ * Make the skill template the theme's own: its frontmatter `name:` and its title
22
+ * heading become the theme's name, and the theme's `description`, when it has
23
+ * one, leads the skill's description, so an agent with several theme skills can
24
+ * tell them apart. The rest of the body is left byte-for-byte. Throws if there is
25
+ * no frontmatter, no `name:` line, or no folded description to lead — the caller
26
+ * names the file.
14
27
  */
15
- export declare function renameSkill(md: string, name: string): string;
28
+ export declare function personalizeSkill(md: string, name: string, description?: string): string;
16
29
  /**
17
30
  * The `package.json` a packaged skill installs from — deliberately NOT the theme's
18
31
  * own. A theme repo's manifest is a development document: it may carry scripts,
@@ -41,3 +54,4 @@ export declare function skillPackageJson(theme: Record<string, unknown>, engine:
41
54
  * catalog declares but that is missing is an error.
42
55
  */
43
56
  export declare function zipDir(rootDir: string, folderName: string, config: CompilerThemeConfig, catalog: AssetCatalog, themeFiles: string[], generated: Record<string, string>): Promise<Buffer>;
57
+ 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
  /**
@@ -18,19 +47,32 @@ export async function packAssets(paths, read) {
18
47
  }
19
48
  const FRONTMATTER = /^---\n([\s\S]*?)\n---/;
20
49
  const NAME_LINE = /^name:[ \t]*.*$/m;
50
+ /** The folded `description: >` opener; the description's lines follow it, indented. */
51
+ const FOLDED_DESCRIPTION_LINE = /^description:[ \t]*>[ \t]*$/m;
52
+ const DESCRIPTION_INDENT = " ";
53
+ const TITLE_HEADING = /^# .*$/m;
21
54
  /**
22
- * Rewrite the `name:` value in a SKILL.md's leading YAML frontmatter so the
23
- * packaged skill is named after the consuming theme, not the source template.
24
- * Only the leading `---`…`---` block is touched; the body is left byte-for-byte.
25
- * Throws if there is no frontmatter or no `name:` line — the caller names the file.
55
+ * Make the skill template the theme's own: its frontmatter `name:` and its title
56
+ * heading become the theme's name, and the theme's `description`, when it has
57
+ * one, leads the skill's description, so an agent with several theme skills can
58
+ * tell them apart. The rest of the body is left byte-for-byte. Throws if there is
59
+ * no frontmatter, no `name:` line, or no folded description to lead — the caller
60
+ * names the file.
26
61
  */
27
- export function renameSkill(md, name) {
62
+ export function personalizeSkill(md, name, description) {
28
63
  const block = md.match(FRONTMATTER);
29
64
  if (!block)
30
65
  throw new Error("SKILL.md has no YAML frontmatter block");
31
66
  if (!NAME_LINE.test(block[1]))
32
67
  throw new Error('SKILL.md frontmatter has no "name:" line');
33
- return md.replace(block[0], block[0].replace(NAME_LINE, `name: ${name}`));
68
+ let frontmatter = block[0].replace(NAME_LINE, `name: ${name}`);
69
+ if (description) {
70
+ if (!FOLDED_DESCRIPTION_LINE.test(frontmatter)) {
71
+ throw new Error('SKILL.md frontmatter has no folded "description: >" to lead');
72
+ }
73
+ frontmatter = frontmatter.replace(FOLDED_DESCRIPTION_LINE, (line) => `${line}\n${DESCRIPTION_INDENT}${description}`);
74
+ }
75
+ return frontmatter + md.slice(block[0].length).replace(TITLE_HEADING, `# ${name}`);
34
76
  }
35
77
  /**
36
78
  * Files a packaged skill needs beyond the theme's own declarations. Only the
@@ -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) {
@@ -136,7 +136,7 @@ function buildSlide(index, fmString, rawContent) {
136
136
  export class FrontmatterParseError extends Error {
137
137
  constructor(slideIndex, yamlSource, cause) {
138
138
  const preview = yamlSource.length > 80 ? `${yamlSource.slice(0, 80)}...` : yamlSource;
139
- super(`Invalid YAML in slide ${slideIndex} frontmatter:\n${preview}`);
139
+ super(`Slide ${slideIndex + 1}: invalid YAML in frontmatter:\n${preview}`);
140
140
  this.name = "FrontmatterParseError";
141
141
  this.cause = cause;
142
142
  }
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  This document covers the detailed syntax for writing slide content in tycoslide deck files.
4
4
 
5
- > **Every layout, parameter, and slot name in the examples below is a placeholder.** Your theme's real names live in `theme.json` — read it first, and never assume a name shown here (`Body`, `TwoColumn`, `hero`, `::left::`, etc.) exists in your theme.
5
+ > **Every layout, parameter, and slot name in the examples below is a placeholder.** Your theme's real names live in its `theme.json`, and in `manifest.json` when the theme is packaged as a skill — read it first, and never assume a name shown here (`Body`, `TwoColumn`, `hero`, `::left::`, etc.) exists in your theme.
6
6
 
7
7
  ---
8
8
 
@@ -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
 
@@ -216,7 +216,7 @@ The theme owns all styling. These directives are rejected at build time:
216
216
  Each parameter or slot in the layout definition may declare:
217
217
  - **`accepts`** (slots, required) -- an array of `text`, `table`, `image`.
218
218
  - **`required: true`** -- the slide has no usable default and the build fails if the parameter/slot has no value.
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.
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. A shape the theme doesn't map, such as a card drawn behind a stat, stays, so an unfilled one can leave an empty card.
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).
222
222
 
@@ -268,11 +268,10 @@ Common errors and fixes:
268
268
 
269
269
  | Error | Fix |
270
270
  |-------|-----|
271
- | `unknown layout "xyz"` | Check layout names in `theme.json` |
271
+ | `unknown layout "xyz"` | Check layout names in `theme.json` (or `manifest.json`) |
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.1",
3
+ "version": "0.17.1",
4
4
  "description": "Create editable PowerPoint slides from markdown, using your existing .pptx templates.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,33 +37,33 @@ This skill builds decks from a markdown deck file. The theme provides slide layo
37
37
 
38
38
  ## Layout Discovery
39
39
 
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`.
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.
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
+ 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. A card or panel drawn behind an unfilled one stays, so choose a layout with no more of them than you can fill.
43
43
 
44
44
  Study each layout's `slots` before writing any slides.
45
45
 
46
46
  ### Images
47
47
 
48
- 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 -B2 "clock" assets.json` and `grep -i -B2 "bolt" assets.json` find the icon you wanted, with its path and fit on the lines above.
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.
49
49
 
50
- 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:
51
51
 
52
52
  ```bash
53
53
  mkdir -p <deck dir>/assets && cp <theme dir>/assets/hub.png <deck dir>/assets/
54
54
  ```
55
55
 
56
56
  ```markdown
57
- ![Central hub connecting three services](assets/hub.png "fit: scale-down")
57
+ ![Central hub connecting three services](assets/hub.png)
58
58
  ```
59
59
 
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; 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.
61
61
 
62
62
  ---
63
63
 
64
64
  ## Creating Slides
65
65
 
66
- Write a deck file in markdown, as [markdown.md](node_modules/@tycoworks/tycoslide/docs/markdown.md) describes (see its [full example](node_modules/@tycoworks/tycoslide/docs/markdown.md#full-example)), and build it as its [Build](node_modules/@tycoworks/tycoslide/docs/markdown.md#build) section shows.
66
+ Write a deck file in markdown, as [markdown.md](node_modules/@tycoworks/tycoslide/docs/markdown.md) describes (see its [full example](node_modules/@tycoworks/tycoslide/docs/markdown.md#full-example)), and build it as its [Build](node_modules/@tycoworks/tycoslide/docs/markdown.md#build) section shows. The deck's `theme:` is the path from the deck to this folder's `theme.json`: `./theme.json` when the deck sits in this folder, and the full path to it when the deck is anywhere else.
67
67
 
68
68
  ---
69
69
 
@@ -90,7 +90,7 @@ Keep each slot's content to what its region comfortably holds. When content over
90
90
  - **Don't restyle the layout** -- the theme owns all design; you only fill slots
91
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.
92
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.
93
- - **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.
94
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
95
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.
96
96
 
@@ -106,7 +106,7 @@ Build the deck again ([Build](node_modules/@tycoworks/tycoslide/docs/markdown.md
106
106
 
107
107
  | Error | Fix |
108
108
  |-------|-----|
109
- | 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 |
110
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`?) |
111
111
 
112
112
  ### Verification Loop
@@ -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
- }