@tycoworks/tycoslide 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/SKILL.md +249 -0
  4. package/bin/tycoslide.js +2 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +197 -0
  7. package/dist/engine/dom.d.ts +92 -0
  8. package/dist/engine/dom.js +354 -0
  9. package/dist/engine/fillers/filler.d.ts +22 -0
  10. package/dist/engine/fillers/filler.js +53 -0
  11. package/dist/engine/fillers/image.d.ts +19 -0
  12. package/dist/engine/fillers/image.js +105 -0
  13. package/dist/engine/fillers/table.d.ts +21 -0
  14. package/dist/engine/fillers/table.js +62 -0
  15. package/dist/engine/fillers/template.d.ts +27 -0
  16. package/dist/engine/fillers/template.js +221 -0
  17. package/dist/engine/fillers/text.d.ts +28 -0
  18. package/dist/engine/fillers/text.js +29 -0
  19. package/dist/engine/generate.d.ts +51 -0
  20. package/dist/engine/generate.js +161 -0
  21. package/dist/engine/index.d.ts +8 -0
  22. package/dist/engine/index.js +7 -0
  23. package/dist/engine/types.d.ts +128 -0
  24. package/dist/engine/types.js +22 -0
  25. package/dist/index.d.ts +46 -0
  26. package/dist/index.js +146 -0
  27. package/dist/manifest.d.ts +7 -0
  28. package/dist/manifest.js +88 -0
  29. package/dist/markdown/deckCompiler.d.ts +36 -0
  30. package/dist/markdown/deckCompiler.js +289 -0
  31. package/dist/markdown/index.d.ts +13 -0
  32. package/dist/markdown/index.js +13 -0
  33. package/dist/markdown/parsers.d.ts +32 -0
  34. package/dist/markdown/parsers.js +233 -0
  35. package/dist/markdown/resolvers/code.d.ts +17 -0
  36. package/dist/markdown/resolvers/code.js +44 -0
  37. package/dist/markdown/resolvers/mermaid.d.ts +14 -0
  38. package/dist/markdown/resolvers/mermaid.js +89 -0
  39. package/dist/markdown/resolvers/mermaidTheme.d.ts +27 -0
  40. package/dist/markdown/resolvers/mermaidTheme.js +113 -0
  41. package/dist/markdown/resolvers/resolver.d.ts +42 -0
  42. package/dist/markdown/resolvers/resolver.js +52 -0
  43. package/dist/markdown/slideParser.d.ts +14 -0
  44. package/dist/markdown/slideParser.js +196 -0
  45. package/dist/markdown/textTemplate.d.ts +15 -0
  46. package/dist/markdown/textTemplate.js +75 -0
  47. package/dist/markdown/types.d.ts +239 -0
  48. package/dist/markdown/types.js +40 -0
  49. package/package.json +41 -0
  50. package/syntax.md +291 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Text-template fill — fills each visual line in place from its template
3
+ * segments. A visual line starts at either a paragraph boundary (`<a:p>`) or a
4
+ * soft line break (`<a:br/>`); template line i fills visual line i, 1:1. Styles
5
+ * are never read or changed: coalesce same-style runs, then a uniform line takes
6
+ * the substituted text directly, while a multi-style line is matched against the
7
+ * sample to place each variable's value in the run carrying its style.
8
+ */
9
+ import { childrenByTag, collectElements, detach, isPlainObject, runText, setRunTextPreservingStyle, Tag, } from "../dom.js";
10
+ /**
11
+ * Split a shape's text into visual lines. Walk each `<a:p>` in document order;
12
+ * within a paragraph, iterate child elements in order and cut a new visual line
13
+ * at every `<a:br/>` (`Tag.LINE_BREAK`), collecting the `<a:r>` runs into the
14
+ * current line. A paragraph yields (break count + 1) visual lines, so an empty
15
+ * line (a break with no runs) is still a line positionally.
16
+ */
17
+ function visualLines(shape) {
18
+ const lines = [];
19
+ for (const para of collectElements(shape, Tag.PARAGRAPH)) {
20
+ let current = [];
21
+ for (let i = 0; i < para.childNodes.length; i++) {
22
+ const child = para.childNodes[i];
23
+ if (child?.nodeType !== 1)
24
+ continue;
25
+ if (child.tagName === Tag.LINE_BREAK) {
26
+ lines.push({ runs: current });
27
+ current = [];
28
+ }
29
+ else if (child.tagName === Tag.RUN) {
30
+ current.push(child);
31
+ }
32
+ }
33
+ lines.push({ runs: current });
34
+ }
35
+ return lines;
36
+ }
37
+ /**
38
+ * Fill a text shape from its template segments — styles are never read or
39
+ * changed. Template line i fills the shape's visual line i, 1:1, via
40
+ * `fillLineFromSegments`: coalesce same-style runs, then a uniform line takes the
41
+ * substituted text directly, while a multi-style line is matched against the
42
+ * sample to place each variable's value in the run carrying its style. A template
43
+ * with more lines than the shape has visual lines is an authoring error (throw);
44
+ * fewer lines fill the covered lines and leave the rest untouched.
45
+ */
46
+ export function fillTemplate(shape, fill, shapeName = "") {
47
+ const lines = visualLines(shape);
48
+ if (fill.lines.length > lines.length) {
49
+ throw new Error(`Text shape "${shapeName}": template has ${fill.lines.length} lines but the shape has only ${lines.length} visual line(s); ` +
50
+ "the template claims a line the shape doesn't have.");
51
+ }
52
+ for (let i = 0; i < fill.lines.length; i++) {
53
+ fillLineFromSegments(lines[i].runs, fill.lines[i], shapeName);
54
+ }
55
+ }
56
+ /** Discriminator for TemplateFill values. */
57
+ export function isTemplateFill(v) {
58
+ return isPlainObject(v) && Array.isArray(v.lines);
59
+ }
60
+ function segmentText(s) {
61
+ return s.kind === "literal" ? s.text : s.value;
62
+ }
63
+ function escapeRegExp(s) {
64
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
65
+ }
66
+ /**
67
+ * Fill one visual line from its template segments. Coalesce same-style runs
68
+ * within the line first; a resulting single run (uniform line) just takes the
69
+ * substituted text. A multi-run line has real style boundaries, so match the
70
+ * template's literals against the shape's sample text to place each variable's
71
+ * value in the run carrying its style. A variable whose matched span crosses run
72
+ * boundaries is not an error: it collapses to its first run's style (the spanned
73
+ * runs are emptied and detached). Position ambiguity still fails fast — adjacent
74
+ * variables with no separator, or a sample that doesn't fit the template.
75
+ */
76
+ function fillLineFromSegments(lineRuns, segments, shapeName) {
77
+ const runs = coalesceRunList(lineRuns);
78
+ if (runs.length === 0)
79
+ return;
80
+ if (runs.length === 1) {
81
+ setRunTextPreservingStyle(runs[0], segments.map(segmentText).join(""));
82
+ return;
83
+ }
84
+ // Multi-run: real style boundaries.
85
+ for (let j = 1; j < segments.length; j++) {
86
+ const prev = segments[j - 1];
87
+ const cur = segments[j];
88
+ if (prev.kind === "variable" && cur.kind === "variable") {
89
+ throw new Error(`Text shape "${shapeName}": variables "{${prev.key}}" and "{${cur.key}}" are adjacent with no separator; ` +
90
+ "a multi-style shape needs a literal or style boundary between variables.");
91
+ }
92
+ }
93
+ const runTexts = runs.map(runText);
94
+ const bounds = [];
95
+ let acc = 0;
96
+ for (const t of runTexts) {
97
+ bounds.push([acc, acc + t.length]);
98
+ acc += t.length;
99
+ }
100
+ const sample = runTexts.join("");
101
+ const source = `^${segments.map((s) => (s.kind === "literal" ? escapeRegExp(s.text) : "([\\s\\S]*?)")).join("")}$`;
102
+ const match = new RegExp(source, "d").exec(sample);
103
+ if (!match?.indices) {
104
+ throw new Error(`Text shape "${shapeName}": its styled sample text ${JSON.stringify(sample)} does not fit the template; ` +
105
+ "make the shape a single style, or give it sample text matching the template's structure.");
106
+ }
107
+ const indices = match.indices;
108
+ const runOf = (pos) => {
109
+ for (let k = 0; k < bounds.length; k++)
110
+ if (pos >= bounds[k][0] && pos < bounds[k][1])
111
+ return k;
112
+ return bounds.length - 1;
113
+ };
114
+ const out = runs.map(() => "");
115
+ let cursor = 0;
116
+ let varIdx = 0;
117
+ for (const s of segments) {
118
+ if (s.kind === "literal") {
119
+ // A literal may straddle run boundaries — split it, each piece stays in its run.
120
+ let p = 0;
121
+ while (p < s.text.length) {
122
+ const k = runOf(cursor + p);
123
+ const take = Math.min(s.text.length, p + (bounds[k][1] - (cursor + p)));
124
+ out[k] += s.text.slice(p, take);
125
+ p = take;
126
+ }
127
+ cursor += s.text.length;
128
+ }
129
+ else {
130
+ varIdx++;
131
+ const span = indices[varIdx];
132
+ if (!span)
133
+ throw new Error(`Text shape "${shapeName}": failed to locate variable "{${s.key}}" in the sample text.`);
134
+ const [vs, ve] = span;
135
+ // A variable whose span crosses run boundaries collapses to its FIRST run:
136
+ // the value goes into `out[kStart]`, the spanned runs get no text (they end
137
+ // up ""), and we detach those empties below so the span becomes one run
138
+ // carrying the first run's style.
139
+ const kStart = runOf(vs);
140
+ out[kStart] += s.value;
141
+ cursor = ve;
142
+ }
143
+ }
144
+ for (let k = 0; k < runs.length; k++)
145
+ setRunTextPreservingStyle(runs[k], out[k]);
146
+ // Detach runs left empty (e.g. runs a collapsed variable's span passed over) so
147
+ // the visual line loses its now-textless runs. Never detach every run: if all
148
+ // ended up empty, keep the first as the surviving run.
149
+ const anyNonEmpty = out.some((t) => t !== "");
150
+ for (let k = 0; k < runs.length; k++) {
151
+ if (out[k] === "" && !(!anyNonEmpty && k === 0))
152
+ detach(runs[k]);
153
+ }
154
+ }
155
+ // ── Run coalescing (text-template fill) ───────────────────────────────────────
156
+ //
157
+ // PowerPoint scatters one logical line across several runs for no semantic
158
+ // reason. Before a text-template fill we merge adjacent runs that carry an
159
+ // identical style, so a uniform line collapses to one run. We only ever merge
160
+ // runs we can PROVE are stylistically identical; anything uncertain is left
161
+ // untouched — we never guess a run away.
162
+ /**
163
+ * A run's `<a:rPr>` is "blank" (the default style) when it is missing entirely
164
+ * or carries no attributes and no child elements. A missing and an empty `<a:rPr>`
165
+ * are therefore the same style.
166
+ */
167
+ function isBlankRPr(rPr) {
168
+ if (!rPr)
169
+ return true;
170
+ if (rPr.attributes.length > 0)
171
+ return false;
172
+ for (let i = 0; i < rPr.childNodes.length; i++)
173
+ if (rPr.childNodes[i]?.nodeType === 1)
174
+ return false;
175
+ return true;
176
+ }
177
+ /**
178
+ * Two runs share a style iff their `<a:rPr>` are equal. We compare the whole
179
+ * node via DOM `isEqualNode` (attribute-order-insensitive, recursive), so every
180
+ * property — bold, size, color, font, hyperlink, … — is covered without
181
+ * enumerating them; blank rPrs (missing or empty) compare equal to each other.
182
+ */
183
+ function sameStyle(a, b) {
184
+ const ra = childrenByTag(a, Tag.RUN_PROPS)[0] ?? null;
185
+ const rb = childrenByTag(b, Tag.RUN_PROPS)[0] ?? null;
186
+ if (isBlankRPr(ra) && isBlankRPr(rb))
187
+ return true;
188
+ if (!ra || !rb)
189
+ return false;
190
+ return ra.isEqualNode(rb);
191
+ }
192
+ /**
193
+ * Merge adjacent runs in a run list that carry an identical style: keep the
194
+ * first, append the others' text into it, and detach the redundant runs.
195
+ * Provably-same-style only; distinct styles are preserved as separate runs.
196
+ * Returns the surviving runs (in order). Scoped to the given list — a visual
197
+ * line's runs, not necessarily a whole paragraph.
198
+ */
199
+ function coalesceRunList(runs) {
200
+ const survivors = [];
201
+ let keep = null;
202
+ for (const run of runs) {
203
+ if (keep && sameStyle(keep, run)) {
204
+ setRunTextPreservingStyle(keep, runText(keep) + runText(run));
205
+ detach(run);
206
+ }
207
+ else {
208
+ keep = run;
209
+ survivors.push(run);
210
+ }
211
+ }
212
+ return survivors;
213
+ }
214
+ /**
215
+ * Merge adjacent same-style runs across a whole paragraph. Thin wrapper over
216
+ * `coalesceRunList` operating on the paragraph's direct `<a:r>` children.
217
+ * Exported for tests.
218
+ */
219
+ export function coalesceSameStyleRuns(para) {
220
+ coalesceRunList(childrenByTag(para, Tag.RUN));
221
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Text fill — rebuilds a shape's paragraphs from a TextFill by harvesting
3
+ * specimen (pPr, rPr) buckets grouped by (bullet kind, level), detaching
4
+ * specimens from startAt onward, then building fresh paragraphs cloning the
5
+ * appropriate bucket. This is the strategy for body blocks, bullet lists, rich
6
+ * runs, and syntax-highlighted code.
7
+ */
8
+ import type { TextFill } from "../types.js";
9
+ /**
10
+ * Fill a text shape by rebuilding paragraphs from harvested specimen styles.
11
+ *
12
+ * Harvests specimen (pPr,rPr) buckets grouped by (bullet kind, level),
13
+ * detaches specimens from startAt onward, then builds fresh paragraphs
14
+ * cloning the appropriate bucket. Bullet input paragraphs pick a bullet
15
+ * bucket by level (clamped); non-bullet input picks the plain bucket. If a
16
+ * non-bullet paragraph follows any bullet paragraph, the bullet template's
17
+ * spcBef is grafted onto it (transition spacing).
18
+ *
19
+ * Rich per-paragraph runs (bold/italic/color/link) come through unchanged
20
+ * via setRichRuns.
21
+ */
22
+ export declare function fillText(shape: any, fill: TextFill, opts?: {
23
+ startAt?: number;
24
+ relation?: any;
25
+ shapeName?: string;
26
+ }): void;
27
+ /** Discriminator for TextFill values. */
28
+ export declare function isTextFill(v: unknown): v is TextFill;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Text fill — rebuilds a shape's paragraphs from a TextFill by harvesting
3
+ * specimen (pPr, rPr) buckets grouped by (bullet kind, level), detaching
4
+ * specimens from startAt onward, then building fresh paragraphs cloning the
5
+ * appropriate bucket. This is the strategy for body blocks, bullet lists, rich
6
+ * runs, and syntax-highlighted code.
7
+ */
8
+ import { isPlainObject, rebuildParagraphs } from "../dom.js";
9
+ /**
10
+ * Fill a text shape by rebuilding paragraphs from harvested specimen styles.
11
+ *
12
+ * Harvests specimen (pPr,rPr) buckets grouped by (bullet kind, level),
13
+ * detaches specimens from startAt onward, then builds fresh paragraphs
14
+ * cloning the appropriate bucket. Bullet input paragraphs pick a bullet
15
+ * bucket by level (clamped); non-bullet input picks the plain bucket. If a
16
+ * non-bullet paragraph follows any bullet paragraph, the bullet template's
17
+ * spcBef is grafted onto it (transition spacing).
18
+ *
19
+ * Rich per-paragraph runs (bold/italic/color/link) come through unchanged
20
+ * via setRichRuns.
21
+ */
22
+ export function fillText(shape, fill, opts = {}) {
23
+ const { startAt = 0, relation, shapeName = "" } = opts;
24
+ rebuildParagraphs(shape, fill.paragraphs, startAt, relation, shapeName);
25
+ }
26
+ /** Discriminator for TextFill values. */
27
+ export function isTextFill(v) {
28
+ return isPlainObject(v) && Array.isArray(v.paragraphs);
29
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * PPTX Generation Engine — orchestration.
3
+ *
4
+ * `generate()` walks a Deck and dispatches each content value to one of four
5
+ * `Filler` strategies (in `fillers/filler.ts`), each taking its matching `XFill`:
6
+ *
7
+ * TemplateFill → fillTemplate Fills each paragraph in place from its template
8
+ * segments — styles are never read or changed. See fillers/template.ts.
9
+ *
10
+ * TextFill → fillText Rebuilds a shape's paragraphs from harvested
11
+ * specimen styles (bullets, rich runs, syntax-highlighted code). See
12
+ * fillers/text.ts / dom.ts.
13
+ *
14
+ * TableFill → fillTable Clones specimen rows in the template's `<a:tbl>` and
15
+ * fills each cell's first paragraph. See fillers/table.ts.
16
+ *
17
+ * ImageFill → fillImage Points the picture's blip relationship at a new media
18
+ * file (registered by generate(), swapped by the ImageFiller) and adjusts
19
+ * geometry for the chosen fit. See fillers/image.ts.
20
+ *
21
+ * generate() loads the template, registers media, and for each DeckStep walks
22
+ * the unified `layout.slots`. Each value in `step.content` is dispatched by the
23
+ * slot's type via `FILLERS[slot.type]` (required, no default).
24
+ */
25
+ import type { Config, Deck, Slot } from "./types.js";
26
+ /**
27
+ * Generate a PPTX file from a deck definition and a theme configuration.
28
+ *
29
+ * Orchestration:
30
+ * 1. Load the template; register it twice (as root and under an alias).
31
+ * 2. Pre-register every image's media buffer with pptx-automizer, walking
32
+ * the unified `step.content` for ImageFill values.
33
+ * 3. For each deck step, clone the layout's source slide, then within the
34
+ * addSlide callback walk `layout.slots` once and dispatch by slot.type
35
+ * via `FILLERS[slot.type]`.
36
+ * 4. Write the output PPTX.
37
+ */
38
+ export declare function generate(deck: Deck, config: Config): Promise<void>;
39
+ /**
40
+ * Validate that every required slot on a layout is supplied. Type/shape
41
+ * validation happens in the compiler now — this is a thin required-only
42
+ * checker kept for tests and defensive callers.
43
+ *
44
+ * Exported for tests; not part of the public engine surface (index.ts).
45
+ */
46
+ export declare function validateContentSlots(step: {
47
+ layout: string;
48
+ content?: Record<string, unknown>;
49
+ }, tpl: {
50
+ slots: Slot[];
51
+ }): void;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * PPTX Generation Engine — orchestration.
3
+ *
4
+ * `generate()` walks a Deck and dispatches each content value to one of four
5
+ * `Filler` strategies (in `fillers/filler.ts`), each taking its matching `XFill`:
6
+ *
7
+ * TemplateFill → fillTemplate Fills each paragraph in place from its template
8
+ * segments — styles are never read or changed. See fillers/template.ts.
9
+ *
10
+ * TextFill → fillText Rebuilds a shape's paragraphs from harvested
11
+ * specimen styles (bullets, rich runs, syntax-highlighted code). See
12
+ * fillers/text.ts / dom.ts.
13
+ *
14
+ * TableFill → fillTable Clones specimen rows in the template's `<a:tbl>` and
15
+ * fills each cell's first paragraph. See fillers/table.ts.
16
+ *
17
+ * ImageFill → fillImage Points the picture's blip relationship at a new media
18
+ * file (registered by generate(), swapped by the ImageFiller) and adjusts
19
+ * geometry for the chosen fit. See fillers/image.ts.
20
+ *
21
+ * generate() loads the template, registers media, and for each DeckStep walks
22
+ * the unified `layout.slots`. Each value in `step.content` is dispatched by the
23
+ * slot's type via `FILLERS[slot.type]` (required, no default).
24
+ */
25
+ import { existsSync, rmSync } from "node:fs";
26
+ import { basename, dirname, resolve } from "node:path";
27
+ import { Automizer, modify } from "pptx-automizer";
28
+ import { FILLERS } from "./fillers/filler.js";
29
+ import { isImageFill } from "./fillers/image.js";
30
+ /**
31
+ * Generate a PPTX file from a deck definition and a theme configuration.
32
+ *
33
+ * Orchestration:
34
+ * 1. Load the template; register it twice (as root and under an alias).
35
+ * 2. Pre-register every image's media buffer with pptx-automizer, walking
36
+ * the unified `step.content` for ImageFill values.
37
+ * 3. For each deck step, clone the layout's source slide, then within the
38
+ * addSlide callback walk `layout.slots` once and dispatch by slot.type
39
+ * via `FILLERS[slot.type]`.
40
+ * 4. Write the output PPTX.
41
+ */
42
+ export async function generate(deck, config) {
43
+ const { layouts, rootDir, template, outputDir } = config;
44
+ const outFile = deck.output;
45
+ const outDir = outputDir ?? process.cwd();
46
+ const automizer = new Automizer({
47
+ templateDir: resolve(rootDir, "template"),
48
+ outputDir: outDir,
49
+ removeExistingSlides: true,
50
+ autoImportSlideMasters: true,
51
+ useCreationIds: false,
52
+ cleanup: true,
53
+ });
54
+ const sourceAlias = "source";
55
+ const pres = automizer.loadRoot(template).load(template, sourceAlias);
56
+ // Look up the layout a step targets; an unknown name is a hard error.
57
+ const resolveLayout = (name) => {
58
+ const match = layouts.find((candidate) => candidate.name === name);
59
+ if (!match)
60
+ throw new Error(`Unknown layout: ${name}`);
61
+ return match;
62
+ };
63
+ // Register the media for every image slot. Each file is validated (absolute
64
+ // paths are the caller's responsibility; a stray relative path trips the
65
+ // existsSync guard here or readFileSync in fillImage) and handed to
66
+ // pptx-automizer once per distinct filename.
67
+ const registeredMedia = new Set();
68
+ for (const step of deck.steps) {
69
+ for (const value of Object.values(step.content ?? {})) {
70
+ if (!isImageFill(value))
71
+ continue;
72
+ if (!existsSync(value.path)) {
73
+ throw new Error(`Layout "${step.layout}" image "${value.path}": file not found`);
74
+ }
75
+ const file = basename(value.path);
76
+ if (registeredMedia.has(file))
77
+ continue;
78
+ registeredMedia.add(file);
79
+ pres.loadMedia(file, dirname(value.path));
80
+ }
81
+ }
82
+ // pptx-automizer runs modifyElement callbacks during write() and SWALLOWS any
83
+ // error they throw (it logs a stack trace but keeps going, producing a broken
84
+ // slide). A fill primitive's fail-fast throw would therefore never fail the
85
+ // build. Collect those errors and re-surface them after write().
86
+ const fillErrors = [];
87
+ const captureFillErrors = (slide) => {
88
+ const modifyElement = slide.modifyElement.bind(slide);
89
+ slide.modifyElement = (shapeName, callbacks) => modifyElement(shapeName, callbacks.map((cb) => typeof cb === "function"
90
+ ? (...args) => {
91
+ try {
92
+ return cb(...args);
93
+ }
94
+ catch (err) {
95
+ fillErrors.push(err instanceof Error ? err : new Error(String(err)));
96
+ throw err;
97
+ }
98
+ }
99
+ : cb));
100
+ };
101
+ // Populate one cloned slide: for each declared slot that the step supplies a
102
+ // value for, hand the value to the filler registered for the slot's type.
103
+ const fillSlide = (slide, layout, step) => {
104
+ captureFillErrors(slide);
105
+ for (const slot of layout.slots) {
106
+ const value = step.content?.[slot.key];
107
+ if (value === undefined)
108
+ continue;
109
+ if (typeof value === "string") {
110
+ // Fallback: bare string. Compiler normally normalizes to
111
+ // StyledParagraph[] / ImageFill; this branch only fires when
112
+ // callers construct decks by hand.
113
+ slide.modifyElement(slot.shapeName, [modify.setText(value)]);
114
+ continue;
115
+ }
116
+ const filler = FILLERS[slot.type];
117
+ if (!filler.matches(value)) {
118
+ throw new Error(`Layout "${step.layout}" slot "${slot.key}" (type "${slot.type}"): expected ${filler.label}, got ${describeValue(value)}`);
119
+ }
120
+ filler.fill(slide, slot, value, { layoutName: step.layout });
121
+ }
122
+ };
123
+ for (const step of deck.steps) {
124
+ const layout = resolveLayout(step.layout);
125
+ pres.addSlide(sourceAlias, layout.slideNumber, (slide) => fillSlide(slide, layout, step));
126
+ }
127
+ await pres.write(outFile);
128
+ if (fillErrors.length > 0) {
129
+ // The output would be a broken deck — remove it so a failed build never
130
+ // leaves a misleading artifact behind.
131
+ rmSync(resolve(outDir, outFile), { force: true });
132
+ const detail = fillErrors.map((e) => ` - ${e.message}`).join("\n");
133
+ throw new Error(`generate: ${fillErrors.length} shape fill(s) failed while writing "${outFile}":\n${detail}`);
134
+ }
135
+ console.log(`tycoslide: built ${deck.steps.length} slide(s) → ${resolve(outDir, outFile)}`);
136
+ }
137
+ function describeValue(v) {
138
+ if (v === null)
139
+ return "null";
140
+ if (Array.isArray(v))
141
+ return "array";
142
+ if (typeof v === "object") {
143
+ const t = v.type;
144
+ return typeof t === "string" ? `object (type="${t}")` : "object";
145
+ }
146
+ return typeof v;
147
+ }
148
+ // ── Content-slot validation (test-visible helper) ────────────────────────────
149
+ /**
150
+ * Validate that every required slot on a layout is supplied. Type/shape
151
+ * validation happens in the compiler now — this is a thin required-only
152
+ * checker kept for tests and defensive callers.
153
+ *
154
+ * Exported for tests; not part of the public engine surface (index.ts).
155
+ */
156
+ export function validateContentSlots(step, tpl) {
157
+ const missing = tpl.slots.filter((s) => step.content?.[s.key] === undefined).map((s) => s.key);
158
+ if (missing.length > 0) {
159
+ throw new Error(`Layout "${step.layout}": missing content for slot(s): ${missing.join(", ")}`);
160
+ }
161
+ }
@@ -0,0 +1,8 @@
1
+ export { FILLERS } from "./fillers/filler.js";
2
+ export { fillImage } from "./fillers/image.js";
3
+ export { fillTable, isTableFill } from "./fillers/table.js";
4
+ export { fillTemplate } from "./fillers/template.js";
5
+ export { fillText, isTextFill } from "./fillers/text.js";
6
+ export { generate } from "./generate.js";
7
+ export type { Config, Deck, DeckStep, ImageFill, Layout, Slot, StyledParagraph, TableFill, TemplateFill, TemplateSegment, TextFill, TextRun, ThemeConfig, } from "./types.js";
8
+ export { FitMode, SlotType } from "./types.js";
@@ -0,0 +1,7 @@
1
+ export { FILLERS } from "./fillers/filler.js";
2
+ export { fillImage } from "./fillers/image.js";
3
+ export { fillTable, isTableFill } from "./fillers/table.js";
4
+ export { fillTemplate } from "./fillers/template.js";
5
+ export { fillText, isTextFill } from "./fillers/text.js";
6
+ export { generate } from "./generate.js";
7
+ export { FitMode, SlotType } from "./types.js";
@@ -0,0 +1,128 @@
1
+ /** How the engine should scale an image inside its picture frame. */
2
+ export declare const FitMode: {
3
+ /** Shrink the picture frame to the image's aspect ratio, centered. */
4
+ readonly Contain: "contain";
5
+ /** Fill the frame; center-crop overflow via srcRect. */
6
+ readonly Cover: "cover";
7
+ };
8
+ export type FitMode = (typeof FitMode)[keyof typeof FitMode];
9
+ /**
10
+ * Fill-strategy discriminator carried on every Slot. Required — there is no
11
+ * silent default. The engine dispatches to fillTemplate / fillText / fillTable /
12
+ * fillImage based on this value.
13
+ */
14
+ export declare const SlotType: {
15
+ /** fillTemplate: walk existing runs, replace each in place. */
16
+ readonly Template: "template";
17
+ /** fillText: rebuild paragraphs from harvested specimen styles. */
18
+ readonly Text: "text";
19
+ /** fillTable: clone specimen rows in an `<a:tbl>`. */
20
+ readonly Table: "table";
21
+ /** fillImage: swap the picture's blip and adjust frame geometry. */
22
+ readonly Image: "image";
23
+ };
24
+ export type SlotType = (typeof SlotType)[keyof typeof SlotType];
25
+ /** A single styled span of text within a paragraph. */
26
+ export type TextRun = {
27
+ text: string;
28
+ bold?: boolean;
29
+ italic?: boolean;
30
+ strikethrough?: boolean;
31
+ underline?: boolean;
32
+ link?: string;
33
+ color?: string;
34
+ };
35
+ /**
36
+ * The engine's one normalized text shape. `bullet` presence encodes both
37
+ * bullet-ness and level: absent = non-bullet, present = bullet at that level.
38
+ */
39
+ export type StyledParagraph = {
40
+ runs: TextRun[];
41
+ bullet?: {
42
+ level: number;
43
+ };
44
+ };
45
+ /**
46
+ * One piece of a template line handed to fillTemplate: a fixed `literal`, or a
47
+ * `variable` carrying its already-substituted `value` (the `key` is kept only
48
+ * for error messages). The compiler parses each template line into an ordered
49
+ * list of these; the engine matches the literals against the shape's sample
50
+ * text to place each variable's value in the run that carries its style. The
51
+ * engine never parses `{}` syntax.
52
+ */
53
+ export type TemplateSegment = {
54
+ kind: "literal";
55
+ text: string;
56
+ } | {
57
+ kind: "variable";
58
+ key: string;
59
+ value: string;
60
+ };
61
+ /**
62
+ * Input to fillTemplate — one segment list per line (mapping to the shape's `<a:p>`
63
+ * in order). Distinguished from the other fills by its `lines` array.
64
+ */
65
+ export type TemplateFill = {
66
+ lines: TemplateSegment[][];
67
+ };
68
+ /**
69
+ * Input to fillText — a shape's paragraphs rebuilt from harvested specimen
70
+ * styles. Distinguished from the other fills by its `paragraphs` array. Like
71
+ * the other three fills, `fillText` takes this whole object; the four
72
+ * `DeckStep.content` value shapes read as one family.
73
+ */
74
+ export type TextFill = {
75
+ paragraphs: StyledParagraph[];
76
+ };
77
+ /** Input to fillTable — headers and body rows as styled cells. */
78
+ export type TableFill = {
79
+ headers: StyledParagraph[];
80
+ rows: StyledParagraph[][];
81
+ };
82
+ /**
83
+ * Input to fillImage — a resolved image path plus its fit mode. `path` must
84
+ * be an absolute filesystem path; the compiler is responsible for resolution
85
+ * before an ImageFill reaches the engine. Relative paths fail loudly at
86
+ * `readFileSync` / `imageSize` in the engine.
87
+ */
88
+ export type ImageFill = {
89
+ type: typeof SlotType.Image;
90
+ path: string;
91
+ fit: FitMode;
92
+ };
93
+ export type Slot = {
94
+ key: string;
95
+ shapeName: string;
96
+ /** Fill strategy discriminator — required, no silent default. */
97
+ type: SlotType;
98
+ /** Leave the first N specimen paragraphs untouched (fillText only). */
99
+ startAt?: number;
100
+ /** Enforced column count for table slots. */
101
+ columns?: number;
102
+ };
103
+ export type Layout = {
104
+ name: string;
105
+ slideNumber: number;
106
+ description: string;
107
+ whenToUse: string;
108
+ whenNotToUse: string;
109
+ slots: Slot[];
110
+ };
111
+ export type DeckStep = {
112
+ layout: string;
113
+ content?: Record<string, TextFill | TableFill | ImageFill | TemplateFill>;
114
+ };
115
+ export type Deck = {
116
+ theme: string;
117
+ /** Output filename. Required — no silent default. */
118
+ output: string;
119
+ steps: DeckStep[];
120
+ };
121
+ export type ThemeConfig = {
122
+ layouts: Layout[];
123
+ template: string;
124
+ outputDir?: string;
125
+ };
126
+ export type Config = ThemeConfig & {
127
+ rootDir: string;
128
+ };
@@ -0,0 +1,22 @@
1
+ /** How the engine should scale an image inside its picture frame. */
2
+ export const FitMode = {
3
+ /** Shrink the picture frame to the image's aspect ratio, centered. */
4
+ Contain: "contain",
5
+ /** Fill the frame; center-crop overflow via srcRect. */
6
+ Cover: "cover",
7
+ };
8
+ /**
9
+ * Fill-strategy discriminator carried on every Slot. Required — there is no
10
+ * silent default. The engine dispatches to fillTemplate / fillText / fillTable /
11
+ * fillImage based on this value.
12
+ */
13
+ export const SlotType = {
14
+ /** fillTemplate: walk existing runs, replace each in place. */
15
+ Template: "template",
16
+ /** fillText: rebuild paragraphs from harvested specimen styles. */
17
+ Text: "text",
18
+ /** fillTable: clone specimen rows in an `<a:tbl>`. */
19
+ Table: "table",
20
+ /** fillImage: swap the picture's blip and adjust frame geometry. */
21
+ Image: "image",
22
+ };