@tycoworks/tycoslide 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/SKILL.md +2 -1
- package/dist/cli.js +8 -107
- package/dist/engine/dom.d.ts +5 -0
- package/dist/engine/dom.js +19 -3
- package/dist/engine/fillers/filler.d.ts +21 -13
- package/dist/engine/fillers/filler.js +21 -27
- package/dist/engine/fillers/image.d.ts +46 -7
- package/dist/engine/fillers/image.js +78 -36
- package/dist/engine/fillers/table.d.ts +4 -3
- package/dist/engine/fillers/table.js +58 -9
- package/dist/engine/generate.d.ts +34 -18
- package/dist/engine/generate.js +234 -65
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/notes.d.ts +76 -0
- package/dist/engine/notes.js +313 -0
- package/dist/engine/types.d.ts +57 -24
- package/dist/engine/types.js +11 -7
- package/dist/index.d.ts +19 -25
- package/dist/index.js +65 -92
- package/dist/manifest.js +19 -29
- package/dist/markdown/blocks/code.d.ts +15 -0
- package/dist/markdown/blocks/code.js +50 -0
- package/dist/markdown/blocks/image.d.ts +2 -0
- package/dist/markdown/blocks/image.js +9 -0
- package/dist/markdown/blocks/mermaid.d.ts +15 -0
- package/dist/markdown/blocks/mermaid.js +227 -0
- package/dist/markdown/{resolvers → blocks}/mermaidTheme.d.ts +1 -1
- package/dist/markdown/{resolvers → blocks}/mermaidTheme.js +1 -1
- package/dist/markdown/blocks/registry.d.ts +16 -0
- package/dist/markdown/blocks/registry.js +44 -0
- package/dist/markdown/blocks/table.d.ts +2 -0
- package/dist/markdown/blocks/table.js +23 -0
- package/dist/markdown/blocks/text.d.ts +12 -0
- package/dist/markdown/blocks/text.js +90 -0
- package/dist/markdown/deckCompiler.d.ts +18 -30
- package/dist/markdown/deckCompiler.js +167 -122
- package/dist/markdown/index.d.ts +11 -11
- package/dist/markdown/index.js +9 -8
- package/dist/markdown/inline.d.ts +26 -0
- package/dist/markdown/inline.js +136 -0
- package/dist/markdown/mdast.d.ts +25 -0
- package/dist/markdown/mdast.js +49 -0
- package/dist/markdown/schema/deckSchema.d.ts +30 -0
- package/dist/markdown/schema/deckSchema.js +51 -0
- package/dist/markdown/schema/strict.d.ts +9 -0
- package/dist/markdown/schema/strict.js +18 -0
- package/dist/markdown/schema/themeConfigSchema.d.ts +99 -0
- package/dist/markdown/schema/themeConfigSchema.js +145 -0
- package/dist/markdown/types.d.ts +184 -137
- package/dist/markdown/types.js +30 -19
- package/package.json +7 -3
- package/syntax.md +25 -5
- package/dist/markdown/parsers.d.ts +0 -32
- package/dist/markdown/parsers.js +0 -233
- package/dist/markdown/resolvers/code.d.ts +0 -17
- package/dist/markdown/resolvers/code.js +0 -44
- package/dist/markdown/resolvers/mermaid.d.ts +0 -14
- package/dist/markdown/resolvers/mermaid.js +0 -89
- package/dist/markdown/resolvers/resolver.d.ts +0 -42
- package/dist/markdown/resolvers/resolver.js +0 -52
|
@@ -1,10 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Table fill — clones specimen rows in
|
|
2
|
+
* Table fill — clones the template's specimen rows in `<a:tbl>` (row 0 header,
|
|
3
3
|
* row 1 data, optional row 2 zebra) and fills each cell's first paragraph with
|
|
4
|
-
* the corresponding StyledParagraph.
|
|
5
|
-
*
|
|
4
|
+
* the corresponding StyledParagraph. Cells and `<a:gridCol>` entries are cloned
|
|
5
|
+
* or trimmed to the header count, sharing the template's total width. Row, cell,
|
|
6
|
+
* and grid cloning stay engine-side because they need pptx-automizer DOM access.
|
|
6
7
|
*/
|
|
7
|
-
import { collectElements, isPlainObject, rebuildParagraphs, Tag } from "../dom.js";
|
|
8
|
+
import { Attr, collectElements, isPlainObject, rebuildParagraphs, Tag } from "../dom.js";
|
|
9
|
+
const EMPTY_CELL = { runs: [{ text: "" }] };
|
|
10
|
+
/**
|
|
11
|
+
* Grow or shrink a row's `<a:tc>` list to exactly `n` cells. Added columns clone
|
|
12
|
+
* the last existing cell (its styling/fill/margins carry over); removed columns
|
|
13
|
+
* drop from the right. The row is left with no stale specimen cells.
|
|
14
|
+
*/
|
|
15
|
+
function normalizeCellCount(row, n) {
|
|
16
|
+
const tcs = collectElements(row, Tag.TABLE_CELL);
|
|
17
|
+
if (tcs.length === n || tcs.length === 0)
|
|
18
|
+
return;
|
|
19
|
+
if (tcs.length > n) {
|
|
20
|
+
for (let i = n; i < tcs.length; i++)
|
|
21
|
+
row.removeChild(tcs[i]);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const specimen = tcs[tcs.length - 1];
|
|
25
|
+
for (let i = tcs.length; i < n; i++)
|
|
26
|
+
row.appendChild(specimen.cloneNode(true));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Rewrite the table's `<a:tblGrid>` to exactly `n` `<a:gridCol>` entries, sharing
|
|
30
|
+
* the template's total width evenly (the last column absorbs the rounding
|
|
31
|
+
* remainder so the total is conserved exactly). No-op when the count already
|
|
32
|
+
* matches. Leaves a grid-less table alone — malformed input we don't worsen.
|
|
33
|
+
*/
|
|
34
|
+
function reconcileGrid(tbl, n) {
|
|
35
|
+
const grid = tbl.getElementsByTagName(Tag.TABLE_GRID)[0];
|
|
36
|
+
if (!grid)
|
|
37
|
+
return;
|
|
38
|
+
const cols = collectElements(grid, Tag.GRID_COL);
|
|
39
|
+
if (cols.length === n || cols.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
const total = cols.reduce((sum, c) => sum + (Number(c.getAttribute(Attr.WIDTH)) || 0), 0);
|
|
42
|
+
const each = Math.round(total / n);
|
|
43
|
+
const specimen = cols[cols.length - 1];
|
|
44
|
+
for (const c of cols)
|
|
45
|
+
grid.removeChild(c);
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
const col = specimen.cloneNode(true);
|
|
48
|
+
col.setAttribute(Attr.WIDTH, String(i === n - 1 ? total - each * (n - 1) : each));
|
|
49
|
+
grid.appendChild(col);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
8
52
|
/**
|
|
9
53
|
* Fill a table shape by cloning specimen rows.
|
|
10
54
|
*
|
|
@@ -30,15 +74,16 @@ export function fillTable(shape, table, shapeName = "") {
|
|
|
30
74
|
// template rows are intentionally dropped — the specimen rows are re-cloned
|
|
31
75
|
// per data row below.
|
|
32
76
|
const dataTpls = rows.length > 2 ? [rows[1], rows[2]] : [rows[1]];
|
|
77
|
+
// Headers are the source of truth for column count; every row is normalized to
|
|
78
|
+
// it, and short/long data rows are padded/truncated to match.
|
|
79
|
+
const n = table.headers.length;
|
|
33
80
|
const fillRow = (tpl, cells) => {
|
|
34
81
|
const clone = tpl.cloneNode(true);
|
|
82
|
+
normalizeCellCount(clone, n);
|
|
35
83
|
const tcs = collectElements(clone, Tag.TABLE_CELL);
|
|
36
|
-
|
|
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++) {
|
|
84
|
+
for (let i = 0; i < n; i++) {
|
|
40
85
|
const txBody = tcs[i].getElementsByTagName(Tag.TX_BODY)[0] ?? tcs[i];
|
|
41
|
-
rebuildParagraphs(txBody, [cells[i]], 0, undefined, shapeName);
|
|
86
|
+
rebuildParagraphs(txBody, [cells[i] ?? EMPTY_CELL], 0, undefined, shapeName);
|
|
42
87
|
}
|
|
43
88
|
return clone;
|
|
44
89
|
};
|
|
@@ -47,6 +92,10 @@ export function fillTable(shape, table, shapeName = "") {
|
|
|
47
92
|
for (let r = 0; r < table.rows.length; r++) {
|
|
48
93
|
built.push(fillRow(dataTpls[r % dataTpls.length], table.rows[r]));
|
|
49
94
|
}
|
|
95
|
+
// Column invariant: grid `<a:gridCol>` count must equal every row's `<a:tc>`
|
|
96
|
+
// count (both == n). `normalizeCellCount` (in fillRow) holds the per-row half;
|
|
97
|
+
// `reconcileGrid` holds the grid half. Run the grid half once, table-global.
|
|
98
|
+
reconcileGrid(tbl, n);
|
|
50
99
|
for (const row of rows)
|
|
51
100
|
tbl.removeChild(row);
|
|
52
101
|
for (const row of built)
|
|
@@ -18,11 +18,25 @@
|
|
|
18
18
|
* file (registered by generate(), swapped by the ImageFiller) and adjusts
|
|
19
19
|
* geometry for the chosen fit. See fillers/image.ts.
|
|
20
20
|
*
|
|
21
|
-
* generate() loads the template, registers media, and for each DeckStep
|
|
22
|
-
* the
|
|
23
|
-
*
|
|
21
|
+
* generate() loads the template, registers media, and for each DeckStep clones
|
|
22
|
+
* the layout's base slide and calls `fillSlide`. Each value in `step.content`
|
|
23
|
+
* selects, by its own shape, the `Block` in the slot's `accepts` whose type it
|
|
24
|
+
* matches: a base-slide block fills in place; any other block is transplanted
|
|
25
|
+
* from its source slide onto the clone, then filled with the same callbacks.
|
|
26
|
+
*
|
|
27
|
+
* `generate()` is first below; its helpers follow (function declarations hoist).
|
|
24
28
|
*/
|
|
25
|
-
import type { Config, Deck,
|
|
29
|
+
import type { Config, Deck, DeckStep, Layout } from "./types.js";
|
|
30
|
+
/** Options for `generate` / `buildDeck`. */
|
|
31
|
+
export type GenerateOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* Suppress authored speaker notes. Default `false` (notes are written and any
|
|
34
|
+
* template notes cloned onto slides are stripped). When `true`, no notes are
|
|
35
|
+
* written but inherited template notes are still stripped — the deck is
|
|
36
|
+
* guaranteed notes-free.
|
|
37
|
+
*/
|
|
38
|
+
excludeNotes?: boolean;
|
|
39
|
+
};
|
|
26
40
|
/**
|
|
27
41
|
* Generate a PPTX file from a deck definition and a theme configuration.
|
|
28
42
|
*
|
|
@@ -30,22 +44,24 @@ import type { Config, Deck, Slot } from "./types.js";
|
|
|
30
44
|
* 1. Load the template; register it twice (as root and under an alias).
|
|
31
45
|
* 2. Pre-register every image's media buffer with pptx-automizer, walking
|
|
32
46
|
* the unified `step.content` for ImageFill values.
|
|
33
|
-
* 3. For each deck step, clone the layout's
|
|
34
|
-
* addSlide callback
|
|
35
|
-
*
|
|
47
|
+
* 3. For each deck step, clone the layout's base slide, then within the
|
|
48
|
+
* addSlide callback call `fillSlide` to dispatch each content value to the
|
|
49
|
+
* matching `Block` (fill in place, or transplant + fill).
|
|
36
50
|
* 4. Write the output PPTX.
|
|
37
51
|
*/
|
|
38
|
-
export declare function generate(deck: Deck, config: Config): Promise<void>;
|
|
52
|
+
export declare function generate(deck: Deck, config: Config, options?: GenerateOptions): Promise<void>;
|
|
39
53
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
54
|
+
* Fill one cloned slide. Per slot the step supplies a value for: resolve WHICH
|
|
55
|
+
* shape realizes it (`resolveBlock`), build WHAT to write (`FILLERS[…].callbacks`),
|
|
56
|
+
* and place it WHERE/HOW (`applyBlock`). Every ambiguity fails fast, naming layout
|
|
57
|
+
* + slot.
|
|
43
58
|
*
|
|
44
|
-
* Exported for tests;
|
|
59
|
+
* Exported for tests; `generate()` calls it inside the `addSlide` callback.
|
|
60
|
+
*/
|
|
61
|
+
export declare function fillSlide(slide: any, layout: Layout, step: DeckStep, sourceAlias: string): void;
|
|
62
|
+
/**
|
|
63
|
+
* Reject a slot whose `accepts` lists two blocks of the same type — the
|
|
64
|
+
* value→block lookup would silently pick the first. Called once per layout at
|
|
65
|
+
* build start.
|
|
45
66
|
*/
|
|
46
|
-
export declare function
|
|
47
|
-
layout: string;
|
|
48
|
-
content?: Record<string, unknown>;
|
|
49
|
-
}, tpl: {
|
|
50
|
-
slots: Slot[];
|
|
51
|
-
}): void;
|
|
67
|
+
export declare function assertSlotsWellFormed(layout: Layout): void;
|
package/dist/engine/generate.js
CHANGED
|
@@ -18,15 +18,21 @@
|
|
|
18
18
|
* file (registered by generate(), swapped by the ImageFiller) and adjusts
|
|
19
19
|
* geometry for the chosen fit. See fillers/image.ts.
|
|
20
20
|
*
|
|
21
|
-
* generate() loads the template, registers media, and for each DeckStep
|
|
22
|
-
* the
|
|
23
|
-
*
|
|
21
|
+
* generate() loads the template, registers media, and for each DeckStep clones
|
|
22
|
+
* the layout's base slide and calls `fillSlide`. Each value in `step.content`
|
|
23
|
+
* selects, by its own shape, the `Block` in the slot's `accepts` whose type it
|
|
24
|
+
* matches: a base-slide block fills in place; any other block is transplanted
|
|
25
|
+
* from its source slide onto the clone, then filled with the same callbacks.
|
|
26
|
+
*
|
|
27
|
+
* `generate()` is first below; its helpers follow (function declarations hoist).
|
|
24
28
|
*/
|
|
25
29
|
import { existsSync, rmSync } from "node:fs";
|
|
26
30
|
import { basename, dirname, resolve } from "node:path";
|
|
31
|
+
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
|
|
27
32
|
import { Automizer, modify } from "pptx-automizer";
|
|
28
33
|
import { FILLERS } from "./fillers/filler.js";
|
|
29
34
|
import { isImageFill } from "./fillers/image.js";
|
|
35
|
+
import { applyNotesToSlide, sweepOrphanNotes } from "./notes.js";
|
|
30
36
|
/**
|
|
31
37
|
* Generate a PPTX file from a deck definition and a theme configuration.
|
|
32
38
|
*
|
|
@@ -34,12 +40,12 @@ import { isImageFill } from "./fillers/image.js";
|
|
|
34
40
|
* 1. Load the template; register it twice (as root and under an alias).
|
|
35
41
|
* 2. Pre-register every image's media buffer with pptx-automizer, walking
|
|
36
42
|
* the unified `step.content` for ImageFill values.
|
|
37
|
-
* 3. For each deck step, clone the layout's
|
|
38
|
-
* addSlide callback
|
|
39
|
-
*
|
|
43
|
+
* 3. For each deck step, clone the layout's base slide, then within the
|
|
44
|
+
* addSlide callback call `fillSlide` to dispatch each content value to the
|
|
45
|
+
* matching `Block` (fill in place, or transplant + fill).
|
|
40
46
|
* 4. Write the output PPTX.
|
|
41
47
|
*/
|
|
42
|
-
export async function generate(deck, config) {
|
|
48
|
+
export async function generate(deck, config, options = {}) {
|
|
43
49
|
const { layouts, rootDir, template, outputDir } = config;
|
|
44
50
|
const outFile = deck.output;
|
|
45
51
|
const outDir = outputDir ?? process.cwd();
|
|
@@ -60,33 +66,17 @@ export async function generate(deck, config) {
|
|
|
60
66
|
throw new Error(`Unknown layout: ${name}`);
|
|
61
67
|
return match;
|
|
62
68
|
};
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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().
|
|
69
|
+
registerMedia(pres, deck);
|
|
70
|
+
assertLayoutsWellFormed(layouts);
|
|
71
|
+
// pptx-automizer runs fill callbacks during write() and SWALLOWS any error they
|
|
72
|
+
// throw (it logs a stack trace but keeps going, producing a broken slide). A
|
|
73
|
+
// fill primitive's fail-fast throw would therefore never fail the build. This
|
|
74
|
+
// wraps BOTH fill entry points — `modifyElement` (in-place) and `addElement`
|
|
75
|
+
// (transplant) — so a throw on a transplanted shape fails the build too.
|
|
86
76
|
const fillErrors = [];
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
77
|
+
const wrapCallbacks = (callbacks) => {
|
|
78
|
+
const arr = Array.isArray(callbacks) ? callbacks : callbacks === undefined ? [] : [callbacks];
|
|
79
|
+
return arr.map((cb) => typeof cb === "function"
|
|
90
80
|
? (...args) => {
|
|
91
81
|
try {
|
|
92
82
|
return cb(...args);
|
|
@@ -96,34 +86,45 @@ export async function generate(deck, config) {
|
|
|
96
86
|
throw err;
|
|
97
87
|
}
|
|
98
88
|
}
|
|
99
|
-
: cb)
|
|
89
|
+
: cb);
|
|
100
90
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
}
|
|
91
|
+
const captureFillErrors = (slide) => {
|
|
92
|
+
const modifyElement = slide.modifyElement.bind(slide);
|
|
93
|
+
slide.modifyElement = (shapeName, callbacks) => modifyElement(shapeName, wrapCallbacks(callbacks));
|
|
94
|
+
const addElement = slide.addElement.bind(slide);
|
|
95
|
+
slide.addElement = (presName, slideNumber, selector, callbacks) => addElement(presName, slideNumber, selector, wrapCallbacks(callbacks));
|
|
122
96
|
};
|
|
97
|
+
// Default: write authored notes and strip any template notes automizer clones
|
|
98
|
+
// onto slides. When true, no notes are written but inherited notes are still
|
|
99
|
+
// stripped — the deck is guaranteed notes-free.
|
|
100
|
+
const excludeNotes = options.excludeNotes ?? false;
|
|
123
101
|
for (const step of deck.steps) {
|
|
124
102
|
const layout = resolveLayout(step.layout);
|
|
125
|
-
pres.addSlide(sourceAlias, layout.
|
|
103
|
+
pres.addSlide(sourceAlias, layout.baseSlide, (slide) => {
|
|
104
|
+
captureFillErrors(slide);
|
|
105
|
+
fillSlide(slide, layout, step, sourceAlias);
|
|
106
|
+
// In-band notes pass: automizer runs this during write() and hands us the
|
|
107
|
+
// OUTPUT archive (parent.targetArchive) and the real output slide number
|
|
108
|
+
// (parent.targetNumber), so notes map 1:1 with no slide-number mapping.
|
|
109
|
+
// Registered for EVERY slide — a slide without authored notes must still
|
|
110
|
+
// have any auto-copied template notes stripped.
|
|
111
|
+
slide.modify(async (_document, parent) => {
|
|
112
|
+
await applyNotesToSlide(toNotesArchive(parent.targetArchive), parent.targetNumber, step.notes, excludeNotes);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
126
115
|
}
|
|
116
|
+
// Sweep the template's orphaned notesSlides. This MUST be a presentation-level
|
|
117
|
+
// modify callback, not part of the per-slide pass: per-slide callbacks run
|
|
118
|
+
// during writeSlides(), BEFORE automizer truncates the template's original
|
|
119
|
+
// slides and renumbers the deck — at which point every template notesSlide
|
|
120
|
+
// still looks referenced by a not-yet-removed original slide. automizer runs
|
|
121
|
+
// presentation modify callbacks after truncation, when a notesSlide's
|
|
122
|
+
// referenced status is final. Runs regardless of excludeNotes — the leak
|
|
123
|
+
// exists either way. Routed through toNotesArchive so the buffered
|
|
124
|
+
// [Content_Types].xml write survives automizer's final buffer flush.
|
|
125
|
+
pres.modify(async (_xml, _index, archive) => {
|
|
126
|
+
await sweepOrphanNotes(toNotesArchive(archive));
|
|
127
|
+
});
|
|
127
128
|
await pres.write(outFile);
|
|
128
129
|
if (fillErrors.length > 0) {
|
|
129
130
|
// The output would be a broken deck — remove it so a failed build never
|
|
@@ -134,6 +135,35 @@ export async function generate(deck, config) {
|
|
|
134
135
|
}
|
|
135
136
|
console.log(`tycoslide: built ${deck.steps.length} slide(s) → ${resolve(outDir, outFile)}`);
|
|
136
137
|
}
|
|
138
|
+
// ── generate() helpers ───────────────────────────────────────────────────────
|
|
139
|
+
/**
|
|
140
|
+
* Register the media for every image slot. Each file is validated (absolute
|
|
141
|
+
* paths are the caller's responsibility; a stray relative path trips the
|
|
142
|
+
* existsSync guard here or readFileSync in fillImage) and handed to
|
|
143
|
+
* pptx-automizer once per distinct filename.
|
|
144
|
+
*/
|
|
145
|
+
function registerMedia(pres, deck) {
|
|
146
|
+
const registeredMedia = new Set();
|
|
147
|
+
for (const step of deck.steps) {
|
|
148
|
+
for (const value of Object.values(step.content ?? {})) {
|
|
149
|
+
if (!isImageFill(value))
|
|
150
|
+
continue;
|
|
151
|
+
if (!existsSync(value.path)) {
|
|
152
|
+
throw new Error(`Layout "${step.layout}" image "${value.path}": file not found`);
|
|
153
|
+
}
|
|
154
|
+
const file = basename(value.path);
|
|
155
|
+
if (registeredMedia.has(file))
|
|
156
|
+
continue;
|
|
157
|
+
registeredMedia.add(file);
|
|
158
|
+
pres.loadMedia(file, dirname(value.path));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Validate every layout once, up front (see assertSlotsWellFormed).
|
|
163
|
+
function assertLayoutsWellFormed(layouts) {
|
|
164
|
+
for (const layout of layouts)
|
|
165
|
+
assertSlotsWellFormed(layout);
|
|
166
|
+
}
|
|
137
167
|
function describeValue(v) {
|
|
138
168
|
if (v === null)
|
|
139
169
|
return "null";
|
|
@@ -145,17 +175,156 @@ function describeValue(v) {
|
|
|
145
175
|
}
|
|
146
176
|
return typeof v;
|
|
147
177
|
}
|
|
148
|
-
// ──
|
|
178
|
+
// ── Slot fill dispatch (composition-aware) ───────────────────────────────────
|
|
149
179
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
180
|
+
* Fill one cloned slide. Per slot the step supplies a value for: resolve WHICH
|
|
181
|
+
* shape realizes it (`resolveBlock`), build WHAT to write (`FILLERS[…].callbacks`),
|
|
182
|
+
* and place it WHERE/HOW (`applyBlock`). Every ambiguity fails fast, naming layout
|
|
183
|
+
* + slot.
|
|
153
184
|
*
|
|
154
|
-
* Exported for tests;
|
|
185
|
+
* Exported for tests; `generate()` calls it inside the `addSlide` callback.
|
|
155
186
|
*/
|
|
156
|
-
export function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
187
|
+
export function fillSlide(slide, layout, step, sourceAlias) {
|
|
188
|
+
assertNoUnknownSlots(step, layout);
|
|
189
|
+
for (const slot of layout.slots) {
|
|
190
|
+
const value = step.content?.[slot.key];
|
|
191
|
+
if (value === undefined)
|
|
192
|
+
continue; // Empty slot: leave the base slide's shape untouched.
|
|
193
|
+
const block = resolveBlock(step, slot, value);
|
|
194
|
+
const callbacks = FILLERS[block.type].callbacks(value, targetOf(block));
|
|
195
|
+
applyBlock(slide, sourceAlias, layout.baseSlide, slot, block, callbacks);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** The SlotType a resolved `*Fill` value maps to, via the filler discriminators. */
|
|
199
|
+
function fillTypeOf(value) {
|
|
200
|
+
for (const type of Object.keys(FILLERS)) {
|
|
201
|
+
if (FILLERS[type].matches(value))
|
|
202
|
+
return type;
|
|
160
203
|
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Reject a slot whose `accepts` lists two blocks of the same type — the
|
|
208
|
+
* value→block lookup would silently pick the first. Called once per layout at
|
|
209
|
+
* build start.
|
|
210
|
+
*/
|
|
211
|
+
export function assertSlotsWellFormed(layout) {
|
|
212
|
+
for (const slot of layout.slots) {
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
for (const block of slot.accepts) {
|
|
215
|
+
if (seen.has(block.type)) {
|
|
216
|
+
throw new Error(`Layout "${layout.name}" slot "${slot.key}": accepts two ${block.type} blocks; each content type may appear once.`);
|
|
217
|
+
}
|
|
218
|
+
seen.add(block.type);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A deck supplying content for a slot the layout doesn't declare is an authoring
|
|
224
|
+
* mistake, not a silent no-op. Runs per step, over `step.content`'s keys.
|
|
225
|
+
*/
|
|
226
|
+
function assertNoUnknownSlots(step, layout) {
|
|
227
|
+
const keys = new Set(layout.slots.map((s) => s.key));
|
|
228
|
+
for (const key of Object.keys(step.content ?? {})) {
|
|
229
|
+
if (!keys.has(key)) {
|
|
230
|
+
const declared = layout.slots.map((s) => s.key).join(", ") || "none";
|
|
231
|
+
throw new Error(`Layout "${step.layout}" has no slot "${key}" (declared slots: ${declared}).`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* WHICH shape: pick the `Block` in `slot.accepts` whose type matches the value's
|
|
237
|
+
* shape. Fails fast — first on an unrecognized value, then on a value no block
|
|
238
|
+
* accepts (order matters; both messages are asserted).
|
|
239
|
+
*/
|
|
240
|
+
function resolveBlock(step, slot, value) {
|
|
241
|
+
const requestedType = fillTypeOf(value);
|
|
242
|
+
if (requestedType === undefined) {
|
|
243
|
+
throw new Error(`Layout "${step.layout}" slot "${slot.key}": unrecognized content value ${describeValue(value)}.`);
|
|
244
|
+
}
|
|
245
|
+
const block = slot.accepts.find((b) => b.type === requestedType);
|
|
246
|
+
if (!block) {
|
|
247
|
+
const available = slot.accepts.map((b) => b.type).join(", ") || "none";
|
|
248
|
+
throw new Error(`Layout "${step.layout}" slot "${slot.key}": no block accepts ${requestedType} content (this slot accepts: ${available}).`);
|
|
249
|
+
}
|
|
250
|
+
return block;
|
|
251
|
+
}
|
|
252
|
+
/** The shape a filler targets, plus `startAt` when the (text) block declares it. */
|
|
253
|
+
function targetOf(block) {
|
|
254
|
+
const target = { shapeName: block.shapeName };
|
|
255
|
+
if (block.startAt !== undefined)
|
|
256
|
+
target.startAt = block.startAt;
|
|
257
|
+
return target;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* WHERE/HOW: place the (already-built) fill callbacks. A base-slide block is
|
|
261
|
+
* already on the cloned slide → fill in place. Any other block is transplanted:
|
|
262
|
+
* pptx-automizer runs an appended shape's callbacks against the imported element
|
|
263
|
+
* itself, so the same callbacks refill it — position it to the slot's frame,
|
|
264
|
+
* then remove the base shape it supersedes (a base block, if this slot has one —
|
|
265
|
+
* a slot need not).
|
|
266
|
+
*/
|
|
267
|
+
function applyBlock(slide, sourceAlias, baseSlide, slot, block, callbacks) {
|
|
268
|
+
if (block.sourceSlide === baseSlide) {
|
|
269
|
+
slide.modifyElement(block.shapeName, callbacks);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
slide.addElement(sourceAlias, block.sourceSlide, block.shapeName, [modify.setPosition(slot.frame), ...callbacks]);
|
|
273
|
+
const baseBlock = slot.accepts.find((b) => b.sourceSlide === baseSlide);
|
|
274
|
+
if (baseBlock && baseBlock.shapeName !== block.shapeName)
|
|
275
|
+
slide.removeElement(baseBlock.shapeName);
|
|
276
|
+
}
|
|
277
|
+
// ── Notes archive adapter (automizer-buffer glue) ────────────────────────────
|
|
278
|
+
/** Installed pptx-automizer version this notes-buffer adapter is pinned to. */
|
|
279
|
+
const PPTX_AUTOMIZER_VERSION = "0.8.2";
|
|
280
|
+
/**
|
|
281
|
+
* Bridge pptx-automizer's OUTPUT archive (`parent.targetArchive`) to the minimal
|
|
282
|
+
* {@link NotesArchive} surface, cooperating with automizer's XML buffer.
|
|
283
|
+
*
|
|
284
|
+
* automizer caches every part it edits via `readXml`/`writeXml` — for a cloned
|
|
285
|
+
* slide's notes that means `slide{N}.xml.rels`, `notesSlide{N}.xml.rels`, and
|
|
286
|
+
* `[Content_Types].xml` — as a parsed document, then re-serializes the cache over
|
|
287
|
+
* the zip at write() time. A plain `write`/`remove` to those parts is therefore
|
|
288
|
+
* silently reverted at the final flush. This adapter reads the buffered
|
|
289
|
+
* (automizer-current) content and steers writes/removes back through the same
|
|
290
|
+
* buffer, so `applyNotesToSlide` stays pure over string+xmldom while its edits
|
|
291
|
+
* survive. The notes PART (`notesSlide{N}.xml`) is copied raw and never buffered,
|
|
292
|
+
* so it round-trips as a plain zip entry. The buffer holds docs parsed by the one
|
|
293
|
+
* installed `@xmldom/xmldom`, so our parser/serializer operate on them safely.
|
|
294
|
+
*/
|
|
295
|
+
function toNotesArchive(target) {
|
|
296
|
+
// Fail loud if automizer's internals no longer expose the buffer array this
|
|
297
|
+
// adapter steers writes through: without it, every buffered write would be
|
|
298
|
+
// silently reverted at the final flush, corrupting the deck with no error.
|
|
299
|
+
if (!Array.isArray(target.buffer)) {
|
|
300
|
+
throw new Error("Speaker notes: expected the pptx-automizer archive to expose a `buffer` array " +
|
|
301
|
+
`(pinned to pptx-automizer ${PPTX_AUTOMIZER_VERSION}); its internals may have changed. ` +
|
|
302
|
+
"Re-verify the notes buffer adapter before relying on it.");
|
|
303
|
+
}
|
|
304
|
+
const buffer = target.buffer;
|
|
305
|
+
const buffered = (file) => buffer.find((entry) => entry.relativePath === file);
|
|
306
|
+
return {
|
|
307
|
+
async read(file, type) {
|
|
308
|
+
const entry = buffered(file);
|
|
309
|
+
return entry ? new XMLSerializer().serializeToString(entry.content) : target.read(file, type);
|
|
310
|
+
},
|
|
311
|
+
async write(file, data) {
|
|
312
|
+
const entry = buffered(file);
|
|
313
|
+
if (entry)
|
|
314
|
+
entry.content = new DOMParser().parseFromString(data, "application/xml");
|
|
315
|
+
return target.write(file, data);
|
|
316
|
+
},
|
|
317
|
+
async remove(file) {
|
|
318
|
+
const index = buffer.findIndex((entry) => entry.relativePath === file);
|
|
319
|
+
if (index !== -1)
|
|
320
|
+
buffer.splice(index, 1);
|
|
321
|
+
await target.remove(file);
|
|
322
|
+
},
|
|
323
|
+
fileExists(file) {
|
|
324
|
+
return buffered(file) !== undefined || target.fileExists(file);
|
|
325
|
+
},
|
|
326
|
+
async folder(dir) {
|
|
327
|
+
return target.folder(dir);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
161
330
|
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { fillImage } from "./fillers/image.js";
|
|
|
3
3
|
export { fillTable, isTableFill } from "./fillers/table.js";
|
|
4
4
|
export { fillTemplate } from "./fillers/template.js";
|
|
5
5
|
export { fillText, isTextFill } from "./fillers/text.js";
|
|
6
|
+
export type { GenerateOptions } from "./generate.js";
|
|
6
7
|
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 {
|
|
8
|
+
export type { Block, Config, Deck, DeckStep, Frame, ImageFill, Layout, Slot, StyledParagraph, TableFill, TemplateFill, TemplateSegment, TextFill, TextRun, ThemeConfig, } from "./types.js";
|
|
9
|
+
export { ImageFit, SlotType } from "./types.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -4,4 +4,4 @@ export { fillTable, isTableFill } from "./fillers/table.js";
|
|
|
4
4
|
export { fillTemplate } from "./fillers/template.js";
|
|
5
5
|
export { fillText, isTextFill } from "./fillers/text.js";
|
|
6
6
|
export { generate } from "./generate.js";
|
|
7
|
-
export {
|
|
7
|
+
export { ImageFit, SlotType } from "./types.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker-notes injection — an in-band pass run per slide during `write()`.
|
|
3
|
+
*
|
|
4
|
+
* The render library (pptx-automizer) has no public notes writer, but it does
|
|
5
|
+
* expose a general seam: `slide.modify((document, parent) => …)` runs while the
|
|
6
|
+
* deck is being written and hands us `parent.targetArchive` (the OUTPUT archive)
|
|
7
|
+
* and `parent.targetNumber` (the real output slide number). automizer's clone
|
|
8
|
+
* auto-copies the source slide's notes as `notesSlide${targetNumber}.xml` and
|
|
9
|
+
* wires its rels + content-type BEFORE our callback runs, so notes map 1:1 to the
|
|
10
|
+
* output slide number — no `sldIdLst`→rels→slideK mapping is needed.
|
|
11
|
+
*
|
|
12
|
+
* For each slide we either overwrite/synthesize its `notesSlide${N}.xml` (when the
|
|
13
|
+
* step authored notes) or strip any auto-copied part (when it did not, or notes
|
|
14
|
+
* are excluded) — so the designer's template notes never leak into the output.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The minimal archive surface `applyNotesToSlide` needs. pptx-automizer's
|
|
18
|
+
* `IArchive` satisfies it structurally (so we never import its internal type), and
|
|
19
|
+
* the test fake implements it over a `Map`. String-based `read`/`write` only —
|
|
20
|
+
* XML is parsed/serialized with the engine's own `@xmldom/xmldom` instance, never
|
|
21
|
+
* automizer's `readXml`/`writeXml` (mixing xmldom instances risks subtle bugs).
|
|
22
|
+
*/
|
|
23
|
+
export type NotesArchive = {
|
|
24
|
+
read(file: string, type: "string"): Promise<string | Buffer>;
|
|
25
|
+
write(file: string, data: string): Promise<unknown>;
|
|
26
|
+
remove(file: string): Promise<void>;
|
|
27
|
+
fileExists(file: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Top-level entries of `dir` (nested `_rels` excluded). `name` is the FULL
|
|
30
|
+
* archive path (e.g. `ppt/notesSlides/notesSlide1.xml`). pptx-automizer's
|
|
31
|
+
* `IArchive.folder` satisfies this structurally.
|
|
32
|
+
*/
|
|
33
|
+
folder(dir: string): Promise<{
|
|
34
|
+
name: string;
|
|
35
|
+
relativePath: string;
|
|
36
|
+
}[]>;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Build a minimal `<p:notes>` part whose body placeholder carries one `<a:p>`
|
|
40
|
+
* per line of `notes`. Text goes through DOM text nodes, so `& < > "` escaping
|
|
41
|
+
* is handled by the serializer.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildNotesSlideXml(notes: string): string;
|
|
44
|
+
/** Next unused `rIdN` in a `.rels` XML string (max existing id + 1). */
|
|
45
|
+
export declare function nextFreeRId(relsXml: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Remove every notesSlide part that no live slide references. automizer drops the
|
|
48
|
+
* template's orphaned *slides* from the deck when building it but leaves all their
|
|
49
|
+
* `notesSlides` behind as unreferenced parts (plus their `[Content_Types].xml`
|
|
50
|
+
* Overrides), so the designer's private notes would otherwise leak into every
|
|
51
|
+
* output. Run once as a presentation-level pass, after the deck is assembled.
|
|
52
|
+
*
|
|
53
|
+
* A notesSlide is "referenced" iff a LIVE slide (reachable from `presentation.xml`,
|
|
54
|
+
* see {@link liveSlideParts}) carries a `/notesSlide` relationship pointing at it.
|
|
55
|
+
* Everything else is swept: the part, its rels, and its content-type Override (all
|
|
56
|
+
* Override removals batched into a single `[Content_Types].xml` read+write).
|
|
57
|
+
*/
|
|
58
|
+
export declare function sweepOrphanNotes(archive: NotesArchive): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Set (or strip) the speaker notes on one output slide, keyed on its real output
|
|
61
|
+
* `slideNumber` (`parent.targetNumber`). Runs inside `slide.modify` during
|
|
62
|
+
* `write()`, operating on the OUTPUT archive.
|
|
63
|
+
*
|
|
64
|
+
* - Authored notes (non-empty and `!excludeNotes`): if automizer auto-copied a
|
|
65
|
+
* notes part (`notesSlide${N}.xml` already exists), overwrite its text only —
|
|
66
|
+
* its rels and content-type are already correct. Otherwise synthesize the part,
|
|
67
|
+
* its rels (→slide + →master), a `notesSlide` rel on the slide (next free rId),
|
|
68
|
+
* and the `[Content_Types].xml` override.
|
|
69
|
+
* - No notes (or excluded): if a part was auto-copied, remove it, its rels, the
|
|
70
|
+
* slide's `notesSlide` rel, and the content-type override — so the designer's
|
|
71
|
+
* template notes never leak.
|
|
72
|
+
*
|
|
73
|
+
* Throws (no silent default) if a slide needs a NEW notes part but the template
|
|
74
|
+
* ships no notes master — synthesizing one is out of scope.
|
|
75
|
+
*/
|
|
76
|
+
export declare function applyNotesToSlide(archive: NotesArchive, slideNumber: number, notes: string | undefined, excludeNotes: boolean): Promise<void>;
|