@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,354 @@
1
+ /**
2
+ * Shared shape / DOM helpers used by every fill primitive.
3
+ *
4
+ * Two layers live here. First the low-level slide-XML substrate: `Tag` names the
5
+ * DrawingML / PresentationML elements, plus node utilities (element collection,
6
+ * run text access, run and paragraph builders) and hyperlink relationship
7
+ * management. Second, the higher-level StyledParagraph-rebuild machinery shared
8
+ * by fillText and fillTable: harvest specimen (pPr, rPr) buckets grouped by
9
+ * (bullet kind, level), detach the specimens, then build fresh paragraphs
10
+ * cloning the appropriate bucket. No fill strategy lives here — this is the
11
+ * substrate the fill modules build on.
12
+ */
13
+ // ── XML Tag Constants ──────────────────────────────────────────────────────────
14
+ /** DrawingML and PresentationML tag names used when manipulating slide XML. */
15
+ export const Tag = {
16
+ RUN: "a:r",
17
+ TEXT: "a:t",
18
+ PARAGRAPH: "a:p",
19
+ RUN_PROPS: "a:rPr",
20
+ PARA_PROPS: "a:pPr",
21
+ BULLET_CHAR: "a:buChar",
22
+ BULLET_AUTO: "a:buAutoNum",
23
+ BULLET_NONE: "a:buNone",
24
+ LINE_BREAK: "a:br",
25
+ END_PARA_RUN_PROPS: "a:endParaRPr",
26
+ OFFSET: "a:off",
27
+ EXTENT: "a:ext",
28
+ BLIP: "a:blip",
29
+ SRC_RECT: "a:srcRect",
30
+ BLIP_FILL: "p:blipFill",
31
+ SPACE_BEFORE: "a:spcBef",
32
+ HLINK_CLICK: "a:hlinkClick",
33
+ RELATIONSHIP: "Relationship",
34
+ TABLE: "a:tbl",
35
+ TABLE_ROW: "a:tr",
36
+ TABLE_CELL: "a:tc",
37
+ TX_BODY: "a:txBody",
38
+ SOLID_FILL: "a:solidFill",
39
+ SRGB_CLR: "a:srgbClr",
40
+ };
41
+ /**
42
+ * OOXML attribute names and fixed attribute values. Element names live in `Tag`;
43
+ * this is the same discipline for the attributes and enumerated values written
44
+ * onto them, so no bare string literal leaks into the fill code.
45
+ */
46
+ export const Attr = {
47
+ // rPr marks
48
+ BOLD: "b",
49
+ ITALIC: "i",
50
+ STRIKE: "strike",
51
+ UNDERLINE: "u",
52
+ ON: "1",
53
+ STRIKE_SINGLE: "sngStrike",
54
+ UNDERLINE_SINGLE: "sng",
55
+ // color
56
+ VALUE: "val",
57
+ // relationships
58
+ ID: "Id",
59
+ TYPE: "Type",
60
+ TARGET: "Target",
61
+ TARGET_MODE: "TargetMode",
62
+ EXTERNAL: "External",
63
+ HLINK_ID: "r:id",
64
+ // paragraph
65
+ LEVEL: "lvl",
66
+ // geometry (offset / extent)
67
+ X: "x",
68
+ Y: "y",
69
+ CX: "cx",
70
+ CY: "cy",
71
+ // srcRect edges
72
+ LEFT: "l",
73
+ TOP: "t",
74
+ RIGHT: "r",
75
+ BOTTOM: "b",
76
+ // whitespace preservation
77
+ XML_SPACE: "xml:space",
78
+ PRESERVE: "preserve",
79
+ };
80
+ /** True for a non-null, non-array object — the shared prefix of every `isXFill`. */
81
+ export function isPlainObject(v) {
82
+ return typeof v === "object" && v !== null && !Array.isArray(v);
83
+ }
84
+ // ── DOM Helpers ────────────────────────────────────────────────────────────────
85
+ export function collectElements(parent, tagName) {
86
+ const out = [];
87
+ const live = parent.getElementsByTagName(tagName);
88
+ for (let i = 0; i < live.length; i++)
89
+ out.push(live[i]);
90
+ return out;
91
+ }
92
+ export function childrenByTag(parent, tagName) {
93
+ const out = [];
94
+ for (let i = 0; i < parent.childNodes.length; i++) {
95
+ const child = parent.childNodes[i];
96
+ if (child && child.nodeType === 1 && child.tagName === tagName)
97
+ out.push(child);
98
+ }
99
+ return out;
100
+ }
101
+ export function detach(node) {
102
+ if (node?.parentNode)
103
+ node.parentNode.removeChild(node);
104
+ }
105
+ export function runText(run) {
106
+ const t = run.getElementsByTagName(Tag.TEXT)[0];
107
+ return t ? (t.textContent ?? "") : "";
108
+ }
109
+ export function setRunTextPreservingStyle(run, text) {
110
+ let t = run.getElementsByTagName(Tag.TEXT)[0];
111
+ if (!t) {
112
+ t = run.ownerDocument.createElement(Tag.TEXT);
113
+ run.appendChild(t);
114
+ }
115
+ if (text !== text.trim())
116
+ t.setAttribute(Attr.XML_SPACE, Attr.PRESERVE);
117
+ else
118
+ t.removeAttribute(Attr.XML_SPACE);
119
+ t.textContent = text;
120
+ }
121
+ export function leadingDecorativePrefix(text) {
122
+ const m = /^[\s\p{P}\p{S}]+/u.exec(text);
123
+ return m ? m[0] : "";
124
+ }
125
+ // ── Run/Paragraph Builders ────────────────────────────────────────────────────
126
+ export function buildRun(doc, cloneRPr, text) {
127
+ const r = doc.createElement(Tag.RUN);
128
+ if (cloneRPr)
129
+ r.appendChild(cloneRPr.cloneNode(true));
130
+ const t = doc.createElement(Tag.TEXT);
131
+ if (text !== text.trim())
132
+ t.setAttribute(Attr.XML_SPACE, Attr.PRESERVE);
133
+ t.textContent = text;
134
+ r.appendChild(t);
135
+ return r;
136
+ }
137
+ export function buildParagraph(doc, clonePPr, run) {
138
+ const p = doc.createElement(Tag.PARAGRAPH);
139
+ if (clonePPr)
140
+ p.appendChild(clonePPr.cloneNode(true));
141
+ p.appendChild(run);
142
+ return p;
143
+ }
144
+ // ── Relationship Management ──────────────────────────────────────────────────
145
+ export const HYPERLINK_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
146
+ export function addRelationship(relation, url) {
147
+ const existing = collectElements(relation, Tag.RELATIONSHIP);
148
+ let maxId = 0;
149
+ for (const rel of existing) {
150
+ const id = rel.getAttribute(Attr.ID);
151
+ const num = id ? parseInt(id.replace("rId", ""), 10) : 0;
152
+ if (num > maxId)
153
+ maxId = num;
154
+ }
155
+ const rId = `rId${maxId + 1}`;
156
+ const rel = relation.ownerDocument.createElement(Tag.RELATIONSHIP);
157
+ rel.setAttribute(Attr.ID, rId);
158
+ rel.setAttribute(Attr.TYPE, HYPERLINK_REL_TYPE);
159
+ rel.setAttribute(Attr.TARGET, url);
160
+ rel.setAttribute(Attr.TARGET_MODE, Attr.EXTERNAL);
161
+ relation.appendChild(rel);
162
+ return rId;
163
+ }
164
+ /**
165
+ * Replace all runs in a paragraph with a sequence of styled runs, cloning the
166
+ * first existing run's rPr as the template and layering per-run marks on top.
167
+ *
168
+ * Exported for tests; not part of the public engine surface (index.ts).
169
+ */
170
+ export function setRichRuns(para, runs, relation) {
171
+ const existing = collectElements(para, Tag.RUN);
172
+ if (existing.length === 0)
173
+ return;
174
+ const tpl = existing[0];
175
+ const newNodes = [];
176
+ for (const run of runs) {
177
+ const clone = tpl.cloneNode(true);
178
+ const t = clone.getElementsByTagName(Tag.TEXT)[0];
179
+ if (t) {
180
+ t.textContent = run.text;
181
+ // The cloned template run may already carry xml:space — set or clear it to
182
+ // match the new text, mirroring setRunTextPreservingStyle.
183
+ if (run.text !== run.text.trim())
184
+ t.setAttribute(Attr.XML_SPACE, Attr.PRESERVE);
185
+ else
186
+ t.removeAttribute(Attr.XML_SPACE);
187
+ }
188
+ if (run.bold || run.italic || run.strikethrough || run.underline) {
189
+ let rPr = clone.getElementsByTagName(Tag.RUN_PROPS)[0];
190
+ if (!rPr) {
191
+ rPr = para.ownerDocument.createElement(Tag.RUN_PROPS);
192
+ clone.insertBefore(rPr, clone.firstChild);
193
+ }
194
+ if (run.bold)
195
+ rPr.setAttribute(Attr.BOLD, Attr.ON);
196
+ if (run.italic)
197
+ rPr.setAttribute(Attr.ITALIC, Attr.ON);
198
+ if (run.strikethrough)
199
+ rPr.setAttribute(Attr.STRIKE, Attr.STRIKE_SINGLE);
200
+ if (run.underline)
201
+ rPr.setAttribute(Attr.UNDERLINE, Attr.UNDERLINE_SINGLE);
202
+ }
203
+ if (run.color) {
204
+ let rPr = clone.getElementsByTagName(Tag.RUN_PROPS)[0];
205
+ if (!rPr) {
206
+ rPr = para.ownerDocument.createElement(Tag.RUN_PROPS);
207
+ clone.insertBefore(rPr, clone.firstChild);
208
+ }
209
+ const existingSolidFill = rPr.getElementsByTagName(Tag.SOLID_FILL)[0];
210
+ if (existingSolidFill)
211
+ rPr.removeChild(existingSolidFill);
212
+ const solidFill = para.ownerDocument.createElement(Tag.SOLID_FILL);
213
+ const srgbClr = para.ownerDocument.createElement(Tag.SRGB_CLR);
214
+ srgbClr.setAttribute(Attr.VALUE, run.color);
215
+ solidFill.appendChild(srgbClr);
216
+ rPr.appendChild(solidFill);
217
+ }
218
+ if (run.link && relation) {
219
+ let rPr = clone.getElementsByTagName(Tag.RUN_PROPS)[0];
220
+ if (!rPr) {
221
+ rPr = para.ownerDocument.createElement(Tag.RUN_PROPS);
222
+ clone.insertBefore(rPr, clone.firstChild);
223
+ }
224
+ const rId = addRelationship(relation, run.link);
225
+ const hlink = para.ownerDocument.createElement(Tag.HLINK_CLICK);
226
+ hlink.setAttribute(Attr.HLINK_ID, rId);
227
+ rPr.appendChild(hlink);
228
+ }
229
+ newNodes.push(clone);
230
+ }
231
+ for (const old of existing)
232
+ detach(old);
233
+ const endParaRPr = para.getElementsByTagName(Tag.END_PARA_RUN_PROPS)[0];
234
+ for (const nr of newNodes) {
235
+ if (endParaRPr)
236
+ para.insertBefore(nr, endParaRPr);
237
+ else
238
+ para.appendChild(nr);
239
+ }
240
+ }
241
+ function paragraphBulletKind(paragraph) {
242
+ const pPr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
243
+ if (!pPr)
244
+ return "inherit";
245
+ if (childrenByTag(pPr, Tag.BULLET_NONE).length > 0)
246
+ return "none";
247
+ if (childrenByTag(pPr, Tag.BULLET_CHAR).length > 0 || childrenByTag(pPr, Tag.BULLET_AUTO).length > 0) {
248
+ return "bullet";
249
+ }
250
+ return "inherit";
251
+ }
252
+ function paragraphLevel(paragraph) {
253
+ const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
254
+ const lvl = ppr?.getAttribute(Attr.LEVEL);
255
+ return lvl ? Number(lvl) || 0 : 0;
256
+ }
257
+ function harvestStyles(specimen) {
258
+ const bullets = new Map();
259
+ let paras = null;
260
+ for (const p of specimen) {
261
+ const kind = paragraphBulletKind(p);
262
+ const lvl = paragraphLevel(p);
263
+ const firstRun = collectElements(p, Tag.RUN)[0];
264
+ const bucket = {
265
+ pPr: p.getElementsByTagName(Tag.PARA_PROPS)[0] ?? null,
266
+ rPr: firstRun ? (firstRun.getElementsByTagName(Tag.RUN_PROPS)[0] ?? null) : null,
267
+ };
268
+ if (kind === "bullet") {
269
+ if (!bullets.has(lvl))
270
+ bullets.set(lvl, bucket);
271
+ }
272
+ else {
273
+ if (paras == null)
274
+ paras = bucket;
275
+ }
276
+ }
277
+ return { bullets, paras };
278
+ }
279
+ function specimenBulletPara(specimen, level) {
280
+ for (const p of specimen) {
281
+ if (paragraphBulletKind(p) === "bullet" && paragraphLevel(p) === level)
282
+ return p;
283
+ }
284
+ return null;
285
+ }
286
+ function maybeOverrideLevel(paragraph, level) {
287
+ if (level == null)
288
+ return;
289
+ const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
290
+ if (!ppr)
291
+ return;
292
+ if (level === 0)
293
+ ppr.removeAttribute(Attr.LEVEL);
294
+ else
295
+ ppr.setAttribute(Attr.LEVEL, String(level));
296
+ }
297
+ // ── Rebuild strategy (shared by fillText and fillTable) ──────────────────────
298
+ export function rebuildParagraphs(shape, paragraphs, startIndex, relation, shapeName = "") {
299
+ const allParas = collectElements(shape, Tag.PARAGRAPH);
300
+ if (allParas.length === 0) {
301
+ throw new Error(`Shape "${shapeName}": has no paragraphs to rebuild from (need at least one specimen <a:p>).`);
302
+ }
303
+ const txBody = allParas[0].parentNode;
304
+ const specimen = allParas.slice(startIndex);
305
+ if (specimen.length === 0) {
306
+ throw new Error(`Shape "${shapeName}": startAt ${startIndex} is past the last paragraph (shape has ${allParas.length}).`);
307
+ }
308
+ const { bullets, paras } = harvestStyles(specimen);
309
+ const maxBulletLevel = bullets.size > 0 ? Math.max(...bullets.keys()) : -1;
310
+ const pickBullet = (lvl) => {
311
+ if (bullets.has(lvl))
312
+ return { bucket: bullets.get(lvl), effectiveLvl: lvl };
313
+ if (maxBulletLevel >= 0)
314
+ return { bucket: bullets.get(maxBulletLevel), effectiveLvl: maxBulletLevel };
315
+ return { bucket: null, effectiveLvl: lvl };
316
+ };
317
+ const plainBucket = paras ?? bullets.get(0) ?? null;
318
+ const firstBulletKey = bullets.has(0) ? 0 : maxBulletLevel;
319
+ const firstBullet = firstBulletKey >= 0 ? specimenBulletPara(specimen, firstBulletKey) : null;
320
+ const transitionSpcBef = firstBullet?.getElementsByTagName(Tag.PARA_PROPS)[0]?.getElementsByTagName(Tag.SPACE_BEFORE)[0] ?? null;
321
+ const applyTransitionSpacing = (paragraph) => {
322
+ if (!transitionSpcBef)
323
+ return;
324
+ const ppr = paragraph.getElementsByTagName(Tag.PARA_PROPS)[0];
325
+ if (!ppr)
326
+ return;
327
+ const old = ppr.getElementsByTagName(Tag.SPACE_BEFORE)[0];
328
+ if (old)
329
+ ppr.removeChild(old);
330
+ ppr.appendChild(transitionSpcBef.cloneNode(true));
331
+ };
332
+ for (const p of specimen)
333
+ detach(p);
334
+ const doc = shape.ownerDocument;
335
+ let prevWasBullet = false;
336
+ for (const para of paragraphs) {
337
+ if (!para.runs || para.runs.length === 0)
338
+ continue;
339
+ const isBullet = para.bullet !== undefined;
340
+ const inLevel = para.bullet?.level ?? 0;
341
+ const { bucket, effectiveLvl } = isBullet ? pickBullet(inLevel) : { bucket: plainBucket, effectiveLvl: 0 };
342
+ // Skip fully-empty text (matches previous fillText behavior).
343
+ if (para.runs.length === 1 && !para.runs[0].text)
344
+ continue;
345
+ const seedRun = buildRun(doc, bucket?.rPr ?? null, "");
346
+ const newPara = buildParagraph(doc, bucket?.pPr ?? null, seedRun);
347
+ maybeOverrideLevel(newPara, isBullet ? effectiveLvl : null);
348
+ if (!isBullet && prevWasBullet)
349
+ applyTransitionSpacing(newPara);
350
+ setRichRuns(newPara, para.runs, relation);
351
+ txBody.appendChild(newPara);
352
+ prevWasBullet = isBullet;
353
+ }
354
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The `Filler` strategy registry — one plain-object strategy per SlotType, each
3
+ * pairing a value discriminator with a slide-level fill. The record key IS the
4
+ * slot type, so a strategy carries no redundant `type` field. `generate()`
5
+ * consults `FILLERS[slot.type]` once per (slot, value): `matches` validates the
6
+ * value shape, then `fill` applies it to the slide.
7
+ *
8
+ * Element-level geometry lives in the `fillX` primitives; slide-level concerns
9
+ * (media pre-swap for images, relation access for body hyperlinks, column
10
+ * validation for tables) live in the strategy wrappers here.
11
+ */
12
+ import { type Slot, SlotType } from "../types.js";
13
+ export type FillContext = {
14
+ layoutName: string;
15
+ };
16
+ export interface Filler<T> {
17
+ matches(v: unknown): v is T;
18
+ /** Human name for the mismatch error — no magic string at the throw site. */
19
+ label: string;
20
+ fill(slide: any, slot: Slot, value: T, ctx: FillContext): void;
21
+ }
22
+ export declare const FILLERS: Record<SlotType, Filler<any>>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The `Filler` strategy registry — one plain-object strategy per SlotType, each
3
+ * pairing a value discriminator with a slide-level fill. The record key IS the
4
+ * slot type, so a strategy carries no redundant `type` field. `generate()`
5
+ * consults `FILLERS[slot.type]` once per (slot, value): `matches` validates the
6
+ * value shape, then `fill` applies it to the slide.
7
+ *
8
+ * Element-level geometry lives in the `fillX` primitives; slide-level concerns
9
+ * (media pre-swap for images, relation access for body hyperlinks, column
10
+ * validation for tables) live in the strategy wrappers here.
11
+ */
12
+ import { basename } from "node:path";
13
+ import { ModifyImageHelper } from "pptx-automizer";
14
+ import { SlotType } from "../types.js";
15
+ import { fillImage, isImageFill } from "./image.js";
16
+ import { fillTable, isTableFill } from "./table.js";
17
+ import { fillTemplate, isTemplateFill } from "./template.js";
18
+ import { fillText, isTextFill } from "./text.js";
19
+ export const FILLERS = {
20
+ [SlotType.Template]: {
21
+ matches: isTemplateFill,
22
+ label: "TemplateFill",
23
+ fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [(el) => fillTemplate(el, v, slot.shapeName)]),
24
+ },
25
+ [SlotType.Text]: {
26
+ matches: isTextFill,
27
+ label: "TextFill",
28
+ fill: (slide, slot, v) => {
29
+ const startAt = slot.startAt ?? 0;
30
+ slide.modifyElement(slot.shapeName, [
31
+ (el, relation) => fillText(el, v, { startAt, relation, shapeName: slot.shapeName }),
32
+ ]);
33
+ },
34
+ },
35
+ [SlotType.Table]: {
36
+ matches: isTableFill,
37
+ label: "TableFill",
38
+ fill: (slide, slot, v, { layoutName }) => {
39
+ if (slot.columns !== undefined && v.headers.length !== slot.columns) {
40
+ throw new Error(`Layout "${layoutName}" slot "${slot.key}": table has ${v.headers.length} columns, template expects ${slot.columns}`);
41
+ }
42
+ slide.modifyElement(slot.shapeName, [(el) => fillTable(el, v, slot.shapeName)]);
43
+ },
44
+ },
45
+ [SlotType.Image]: {
46
+ matches: isImageFill,
47
+ label: "ImageFill",
48
+ fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [
49
+ ModifyImageHelper.setRelationTarget(basename(v.path)),
50
+ (el) => fillImage(el, v, slot.shapeName),
51
+ ]),
52
+ },
53
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Image fill — element-level picture geometry only. The media swap (pointing the
3
+ * blip relationship at the new file) is a slide-level modifier in the ImageFiller
4
+ * (`fillers/filler.ts`); this module just adjusts the frame for the fit mode.
5
+ */
6
+ import { type ImageFill } from "../types.js";
7
+ /**
8
+ * Adjust a picture shape's geometry for the chosen fit mode:
9
+ * - cover: writes `<a:srcRect>` insets (units: 1/100,000%) so the image fills
10
+ * the frame with the overflowing axis center-cropped.
11
+ * - contain: shrinks the picture's frame to the image's aspect-ratio dimensions
12
+ * and re-centers within the original frame bounds.
13
+ *
14
+ * `image.path` is assumed absolute — the compiler / caller resolves it before the
15
+ * ImageFill reaches the engine.
16
+ */
17
+ export declare function fillImage(shape: any, image: ImageFill, shapeName?: string): void;
18
+ /** Discriminator for ImageFill values. */
19
+ export declare function isImageFill(v: unknown): v is ImageFill;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Image fill — element-level picture geometry only. The media swap (pointing the
3
+ * blip relationship at the new file) is a slide-level modifier in the ImageFiller
4
+ * (`fillers/filler.ts`); this module just adjusts the frame for the fit mode.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { imageSize } from "image-size";
8
+ import { Attr, isPlainObject, Tag } from "../dom.js";
9
+ import { FitMode, SlotType } from "../types.js";
10
+ /**
11
+ * Adjust a picture shape's geometry for the chosen fit mode:
12
+ * - cover: writes `<a:srcRect>` insets (units: 1/100,000%) so the image fills
13
+ * the frame with the overflowing axis center-cropped.
14
+ * - contain: shrinks the picture's frame to the image's aspect-ratio dimensions
15
+ * and re-centers within the original frame bounds.
16
+ *
17
+ * `image.path` is assumed absolute — the compiler / caller resolves it before the
18
+ * ImageFill reaches the engine.
19
+ */
20
+ export function fillImage(shape, image, shapeName = "") {
21
+ const dims = imageSize(new Uint8Array(readFileSync(image.path)));
22
+ if (!dims.width || !dims.height) {
23
+ throw new Error(`Image shape "${shapeName}": could not read image dimensions from "${image.path}".`);
24
+ }
25
+ const off = shape.getElementsByTagName(Tag.OFFSET)[0];
26
+ const ext = shape.getElementsByTagName(Tag.EXTENT)[0];
27
+ const blipFill = shape.getElementsByTagName(Tag.BLIP_FILL)[0];
28
+ if (!off || !ext || !blipFill) {
29
+ throw new Error(`Image shape "${shapeName}": is not a picture (missing <a:off>, <a:ext>, or <p:blipFill>).`);
30
+ }
31
+ const frame = {
32
+ x: Number(off.getAttribute(Attr.X)),
33
+ y: Number(off.getAttribute(Attr.Y)),
34
+ w: Number(ext.getAttribute(Attr.CX)),
35
+ h: Number(ext.getAttribute(Attr.CY)),
36
+ };
37
+ const geom = computeFit(frame, dims.width, dims.height, image.fit);
38
+ if (geom.kind === "crop") {
39
+ // cover: symmetric crop on the overflowing axis, written as srcRect insets.
40
+ applySrcRect(shape, blipFill, geom.left, geom.top, geom.left, geom.top);
41
+ return;
42
+ }
43
+ // contain: drop any inherited crop, then resize + re-center the frame itself.
44
+ applySrcRect(shape, blipFill, 0, 0, 0, 0);
45
+ ext.setAttribute(Attr.CX, String(geom.cx));
46
+ ext.setAttribute(Attr.CY, String(geom.cy));
47
+ off.setAttribute(Attr.X, String(geom.x));
48
+ off.setAttribute(Attr.Y, String(geom.y));
49
+ }
50
+ /**
51
+ * Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
52
+ * Given the picture frame (EMU) and the source image's pixel size: cover scales
53
+ * up until both axes are covered and crops the overflow; contain scales down
54
+ * until the whole image fits, then re-centres the shrunken frame.
55
+ */
56
+ function computeFit(frame, imgW, imgH, fit) {
57
+ const fitX = frame.w / imgW;
58
+ const fitY = frame.h / imgH;
59
+ const inset = (fraction) => Math.round(fraction * 100000); // <a:srcRect> unit: 1/100,000%
60
+ if (fit === FitMode.Cover) {
61
+ const scale = Math.max(fitX, fitY);
62
+ const shownW = imgW * scale;
63
+ const shownH = imgH * scale;
64
+ return {
65
+ kind: "crop",
66
+ left: shownW > frame.w ? inset((1 - frame.w / shownW) / 2) : 0,
67
+ top: shownH > frame.h ? inset((1 - frame.h / shownH) / 2) : 0,
68
+ };
69
+ }
70
+ const scale = Math.min(fitX, fitY);
71
+ const cx = Math.round(imgW * scale);
72
+ const cy = Math.round(imgH * scale);
73
+ return {
74
+ kind: "frame",
75
+ x: Math.round(frame.x + (frame.w - cx) / 2),
76
+ y: Math.round(frame.y + (frame.h - cy) / 2),
77
+ cx,
78
+ cy,
79
+ };
80
+ }
81
+ /**
82
+ * Write `<a:srcRect>` crop insets (left/top/right/bottom, in 1/100,000%),
83
+ * creating the element right after `<a:blip>` when the picture has none yet.
84
+ */
85
+ function applySrcRect(shape, blipFill, l, t, r, b) {
86
+ let srcRect = blipFill.getElementsByTagName(Tag.SRC_RECT)[0];
87
+ if (!srcRect) {
88
+ srcRect = shape.ownerDocument.createElement(Tag.SRC_RECT);
89
+ const blip = blipFill.getElementsByTagName(Tag.BLIP)[0];
90
+ if (blip?.nextSibling)
91
+ blipFill.insertBefore(srcRect, blip.nextSibling);
92
+ else
93
+ blipFill.appendChild(srcRect);
94
+ }
95
+ srcRect.setAttribute(Attr.LEFT, String(l));
96
+ srcRect.setAttribute(Attr.TOP, String(t));
97
+ srcRect.setAttribute(Attr.RIGHT, String(r));
98
+ srcRect.setAttribute(Attr.BOTTOM, String(b));
99
+ }
100
+ /** Discriminator for ImageFill values. */
101
+ export function isImageFill(v) {
102
+ return (isPlainObject(v) &&
103
+ v.type === SlotType.Image &&
104
+ typeof v.path === "string");
105
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Table fill — clones specimen rows in the template's `<a:tbl>` (row 0 header,
3
+ * row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
4
+ * the corresponding StyledParagraph. Row cloning must stay engine-side because
5
+ * it needs pptx-automizer DOM access.
6
+ */
7
+ import type { TableFill } from "../types.js";
8
+ /**
9
+ * Fill a table shape by cloning specimen rows.
10
+ *
11
+ * Row layout in the template's `<a:tbl>`:
12
+ * - Row 0: header specimen
13
+ * - Row 1: data specimen
14
+ * - Row 2 (optional): alternating-data specimen (zebra striping)
15
+ *
16
+ * Each cell's first paragraph is rebuilt from the corresponding
17
+ * StyledParagraph, so tables inherit rich-run/bullet support for free.
18
+ */
19
+ export declare function fillTable(shape: any, table: TableFill, shapeName?: string): void;
20
+ /** Discriminator for TableFill values. */
21
+ export declare function isTableFill(v: unknown): v is TableFill;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Table fill — clones specimen rows in the template's `<a:tbl>` (row 0 header,
3
+ * row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
4
+ * the corresponding StyledParagraph. Row cloning must stay engine-side because
5
+ * it needs pptx-automizer DOM access.
6
+ */
7
+ import { collectElements, isPlainObject, rebuildParagraphs, Tag } from "../dom.js";
8
+ /**
9
+ * Fill a table shape by cloning specimen rows.
10
+ *
11
+ * Row layout in the template's `<a:tbl>`:
12
+ * - Row 0: header specimen
13
+ * - Row 1: data specimen
14
+ * - Row 2 (optional): alternating-data specimen (zebra striping)
15
+ *
16
+ * Each cell's first paragraph is rebuilt from the corresponding
17
+ * StyledParagraph, so tables inherit rich-run/bullet support for free.
18
+ */
19
+ export function fillTable(shape, table, shapeName = "") {
20
+ const tbl = shape.getElementsByTagName(Tag.TABLE)[0];
21
+ if (!tbl) {
22
+ throw new Error(`Table shape "${shapeName}": has no <a:tbl> element (is it actually a table?).`);
23
+ }
24
+ const rows = collectElements(tbl, Tag.TABLE_ROW);
25
+ if (rows.length < 2) {
26
+ throw new Error(`fillTable: template table has ${rows.length} row(s), need at least 2 (header + data specimen)`);
27
+ }
28
+ const headerTpl = rows[0];
29
+ // Only rows 0-2 are specimens (header, data, optional zebra); any further
30
+ // template rows are intentionally dropped — the specimen rows are re-cloned
31
+ // per data row below.
32
+ const dataTpls = rows.length > 2 ? [rows[1], rows[2]] : [rows[1]];
33
+ const fillRow = (tpl, cells) => {
34
+ const clone = tpl.cloneNode(true);
35
+ const tcs = collectElements(clone, Tag.TABLE_CELL);
36
+ // Fill min(cells, template cells): extra data columns beyond the template's
37
+ // cell count are intentionally ignored (column count is opt-in via
38
+ // slot.columns, enforced in the Table filler).
39
+ for (let i = 0; i < cells.length && i < tcs.length; i++) {
40
+ const txBody = tcs[i].getElementsByTagName(Tag.TX_BODY)[0] ?? tcs[i];
41
+ rebuildParagraphs(txBody, [cells[i]], 0, undefined, shapeName);
42
+ }
43
+ return clone;
44
+ };
45
+ const built = [];
46
+ built.push(fillRow(headerTpl, table.headers));
47
+ for (let r = 0; r < table.rows.length; r++) {
48
+ built.push(fillRow(dataTpls[r % dataTpls.length], table.rows[r]));
49
+ }
50
+ for (const row of rows)
51
+ tbl.removeChild(row);
52
+ for (const row of built)
53
+ tbl.appendChild(row);
54
+ }
55
+ /** Discriminator for TableFill values. */
56
+ export function isTableFill(v) {
57
+ return (isPlainObject(v) &&
58
+ "headers" in v &&
59
+ "rows" in v &&
60
+ Array.isArray(v.headers) &&
61
+ Array.isArray(v.rows));
62
+ }
@@ -0,0 +1,27 @@
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 type { TemplateFill } from "../types.js";
10
+ /**
11
+ * Fill a text shape from its template segments — styles are never read or
12
+ * changed. Template line i fills the shape's visual line i, 1:1, via
13
+ * `fillLineFromSegments`: coalesce same-style runs, then a uniform line takes the
14
+ * substituted text directly, while a multi-style line is matched against the
15
+ * sample to place each variable's value in the run carrying its style. A template
16
+ * with more lines than the shape has visual lines is an authoring error (throw);
17
+ * fewer lines fill the covered lines and leave the rest untouched.
18
+ */
19
+ export declare function fillTemplate(shape: any, fill: TemplateFill, shapeName?: string): void;
20
+ /** Discriminator for TemplateFill values. */
21
+ export declare function isTemplateFill(v: unknown): v is TemplateFill;
22
+ /**
23
+ * Merge adjacent same-style runs across a whole paragraph. Thin wrapper over
24
+ * `coalesceRunList` operating on the paragraph's direct `<a:r>` children.
25
+ * Exported for tests.
26
+ */
27
+ export declare function coalesceSameStyleRuns(para: any): void;