@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/SKILL.md +249 -0
- package/bin/tycoslide.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +197 -0
- package/dist/engine/dom.d.ts +92 -0
- package/dist/engine/dom.js +354 -0
- package/dist/engine/fillers/filler.d.ts +22 -0
- package/dist/engine/fillers/filler.js +53 -0
- package/dist/engine/fillers/image.d.ts +19 -0
- package/dist/engine/fillers/image.js +105 -0
- package/dist/engine/fillers/table.d.ts +21 -0
- package/dist/engine/fillers/table.js +62 -0
- package/dist/engine/fillers/template.d.ts +27 -0
- package/dist/engine/fillers/template.js +221 -0
- package/dist/engine/fillers/text.d.ts +28 -0
- package/dist/engine/fillers/text.js +29 -0
- package/dist/engine/generate.d.ts +51 -0
- package/dist/engine/generate.js +161 -0
- package/dist/engine/index.d.ts +8 -0
- package/dist/engine/index.js +7 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +22 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +146 -0
- package/dist/manifest.d.ts +7 -0
- package/dist/manifest.js +88 -0
- package/dist/markdown/deckCompiler.d.ts +36 -0
- package/dist/markdown/deckCompiler.js +289 -0
- package/dist/markdown/index.d.ts +13 -0
- package/dist/markdown/index.js +13 -0
- package/dist/markdown/parsers.d.ts +32 -0
- package/dist/markdown/parsers.js +233 -0
- package/dist/markdown/resolvers/code.d.ts +17 -0
- package/dist/markdown/resolvers/code.js +44 -0
- package/dist/markdown/resolvers/mermaid.d.ts +14 -0
- package/dist/markdown/resolvers/mermaid.js +89 -0
- package/dist/markdown/resolvers/mermaidTheme.d.ts +27 -0
- package/dist/markdown/resolvers/mermaidTheme.js +113 -0
- package/dist/markdown/resolvers/resolver.d.ts +42 -0
- package/dist/markdown/resolvers/resolver.js +52 -0
- package/dist/markdown/slideParser.d.ts +14 -0
- package/dist/markdown/slideParser.js +196 -0
- package/dist/markdown/textTemplate.d.ts +15 -0
- package/dist/markdown/textTemplate.js +75 -0
- package/dist/markdown/types.d.ts +239 -0
- package/dist/markdown/types.js +40 -0
- package/package.json +41 -0
- 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
|
+
};
|