@tycoworks/tycoslide 0.7.0 → 0.8.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 +5 -94
- package/dist/engine/dom.d.ts +5 -0
- package/dist/engine/dom.js +19 -3
- package/dist/engine/fillers/filler.js +1 -4
- 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 +11 -1
- package/dist/engine/generate.js +84 -2
- package/dist/engine/index.d.ts +2 -1
- 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 +18 -15
- package/dist/engine/types.js +11 -7
- package/dist/index.d.ts +4 -4
- package/dist/index.js +7 -9
- package/dist/manifest.js +3 -5
- package/dist/markdown/deckCompiler.d.ts +5 -14
- package/dist/markdown/deckCompiler.js +43 -20
- package/dist/markdown/index.d.ts +3 -3
- package/dist/markdown/index.js +3 -3
- package/dist/markdown/resolvers/mermaid.js +19 -27
- package/dist/markdown/types.d.ts +20 -6
- package/dist/markdown/types.js +12 -1
- package/package.json +3 -2
- package/syntax.md +25 -5
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# tycoslide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Let AI agents build slides from existing PowerPoint (`.pptx`) templates.
|
|
4
4
|
|
|
5
5
|
> **Early release** — tycoslide is under active development.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## How it works
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
1. **tycoslide wraps your PowerPoint file as an agent skill.**
|
|
10
|
+
2. **Your agents use the skill to write slides in markdown.**
|
|
11
|
+
3. **tycoslide builds a finished PowerPoint file from the markdown.**
|
|
12
12
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
@@ -49,7 +49,7 @@ tycoslide build deck.md # → deck.pptx
|
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
tycoslide build deck.md # markdown → PPTX (theme resolved from deck frontmatter)
|
|
52
|
-
tycoslide
|
|
52
|
+
tycoslide build deck.md --no-notes # omit speaker notes from the output
|
|
53
53
|
tycoslide plugin # generate AI agent plugin package
|
|
54
54
|
tycoslide manifest # print layout + asset catalog to stdout
|
|
55
55
|
```
|
package/SKILL.md
CHANGED
|
@@ -39,7 +39,7 @@ For brand voice and naming guidelines, read `brand.md` if it exists alongside th
|
|
|
39
39
|
|
|
40
40
|
Before writing anything, read `manifest.json`. It contains:
|
|
41
41
|
|
|
42
|
-
- **layouts** -- for each: `name`, `description`, `parameters` (frontmatter inputs) and `slots` (body regions), each with `type` and optionally `required`, `
|
|
42
|
+
- **layouts** -- for each: `name`, `description`, `parameters` (frontmatter inputs) and `slots` (body regions), each with `type` and optionally `required`, `limit`, `codeTheme`, `mermaidVariant` (plus, for assets, a `type` of `icon`/`image`/`background`), plus documentation (`whenToUse`, `whenNotToUse`)
|
|
43
43
|
- **assets** -- brand logos, client logos, illustrations, and icons (`description`, `whenToUse`)
|
|
44
44
|
|
|
45
45
|
A layout's inputs split two ways (see [syntax.md](syntax.md) for details):
|
|
@@ -101,6 +101,7 @@ subtitle: This Quarter
|
|
|
101
101
|
- `layout` is required and consumed by the compiler (not forwarded as content).
|
|
102
102
|
- All other frontmatter keys fill parameters: `title` fills the `title` template parameter, `subtitle` fills the `subtitle` parameter, `hero` fills the `hero` image parameter, etc. A multi-line text shape surfaces as several keys (e.g. `name` + `jobTitle`); fill each as its own scalar line.
|
|
103
103
|
- Slots (`text`, `table`, `code`, `mermaid`) are filled by body regions, not frontmatter -- see below.
|
|
104
|
+
- A slide may also carry a `notes:` block in frontmatter -- plain-text speaker notes for the slide's notes page (see [syntax.md](syntax.md#speaker-notes)). It is slide-level metadata, not a parameter or slot.
|
|
104
105
|
|
|
105
106
|
### Body content, slots, and formatting
|
|
106
107
|
|
package/dist/cli.js
CHANGED
|
@@ -2,90 +2,10 @@ import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import {
|
|
6
|
-
import { buildDeck, toEngineConfig } from "./index.js";
|
|
5
|
+
import { buildDeck } from "./index.js";
|
|
7
6
|
import { generateManifest } from "./manifest.js";
|
|
8
|
-
import {
|
|
9
|
-
import { CompilerSlotType, compileDeck, ParameterType, parseSlideDocument, RESERVED_KEY, } from "./markdown/index.js";
|
|
10
|
-
import { templateKeys, templateToSegments } from "./markdown/textTemplate.js";
|
|
11
|
-
// ── Smoke content fixtures (used by the `smoke` subcommand) ──────────────────
|
|
12
|
-
const line = (text) => ({ runs: [{ text }] });
|
|
13
|
-
const bulletLine = (text, level = 0) => ({ runs: [{ text }], bullet: { level } });
|
|
14
|
-
const SMOKE_TABLE = {
|
|
15
|
-
headers: ["Feature", "Starter", "Pro", "Business", "Enterprise"].map(line),
|
|
16
|
-
rows: [
|
|
17
|
-
["Users", "5", "25", "100", "Unlimited"].map(line),
|
|
18
|
-
["Storage", "1 GB", "10 GB", "100 GB", "1 TB"].map(line),
|
|
19
|
-
["Support", "Email", "Priority", "24/7", "Dedicated"].map(line),
|
|
20
|
-
],
|
|
21
|
-
};
|
|
22
|
-
const SMOKE_CODE = [
|
|
23
|
-
{ runs: [{ text: "SELECT name, total", color: "FF7B72" }] },
|
|
24
|
-
{ runs: [{ text: "FROM orders", color: "FF7B72" }] },
|
|
25
|
-
{ runs: [{ text: "WHERE created_at > now();", color: "FF7B72" }] },
|
|
26
|
-
];
|
|
27
|
-
const SMOKE_PROSE = [
|
|
28
|
-
line("Sample intro line for this block."),
|
|
29
|
-
bulletLine("First point"),
|
|
30
|
-
bulletLine("Second point"),
|
|
31
|
-
];
|
|
32
|
-
function pickFirstAsset(config) {
|
|
33
|
-
for (const group of Object.values(config.assets)) {
|
|
34
|
-
for (const entry of Object.values(group))
|
|
35
|
-
return entry.path;
|
|
36
|
-
}
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
|
-
function smokeSteps(config) {
|
|
40
|
-
const firstAsset = pickFirstAsset(config);
|
|
41
|
-
const absAsset = firstAsset ? resolve(config.rootDir, firstAsset) : undefined;
|
|
42
|
-
return config.layouts.map((layout) => {
|
|
43
|
-
const content = {};
|
|
44
|
-
// Parameters (frontmatter): text → a placeholder-filled template, image → the first asset.
|
|
45
|
-
for (const p of layout.parameters) {
|
|
46
|
-
switch (p.type) {
|
|
47
|
-
case ParameterType.Image: {
|
|
48
|
-
if (!absAsset)
|
|
49
|
-
continue;
|
|
50
|
-
content[p.key] = toImageFill(p, absAsset);
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
case ParameterType.Template: {
|
|
54
|
-
// Text shapes carry no key — the engine slot is keyed by shapeName.
|
|
55
|
-
const values = new Map(templateKeys(p.template).map((k) => [k, "Sample"]));
|
|
56
|
-
content[p.shapeName] = { lines: templateToSegments(p.template, values, p.shapeName) };
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
// Slots (body regions): text → prose, table → table, code → code, mermaid → image.
|
|
62
|
-
for (const s of layout.slots) {
|
|
63
|
-
switch (s.type) {
|
|
64
|
-
case CompilerSlotType.Table:
|
|
65
|
-
content[s.key] = SMOKE_TABLE;
|
|
66
|
-
break;
|
|
67
|
-
case CompilerSlotType.Code:
|
|
68
|
-
content[s.key] = { paragraphs: SMOKE_CODE };
|
|
69
|
-
break;
|
|
70
|
-
case CompilerSlotType.Mermaid: {
|
|
71
|
-
// Mermaid slots don't declare fit; smoke fills them with a fixed
|
|
72
|
-
// contained ImageFill so the projected engine slot (Image, contain)
|
|
73
|
-
// stays consistent with the real renderer's output.
|
|
74
|
-
if (!absAsset)
|
|
75
|
-
continue;
|
|
76
|
-
content[s.key] = { type: SlotType.Image, path: absAsset, fit: FitMode.Contain };
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
case CompilerSlotType.Text:
|
|
80
|
-
content[s.key] = { paragraphs: SMOKE_PROSE };
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return { layout: layout.name, content };
|
|
85
|
-
});
|
|
86
|
-
}
|
|
7
|
+
import { compileDeck, parseSlideDocument, RESERVED_KEY, } from "./markdown/index.js";
|
|
87
8
|
const DEFAULT_CONFIG = "theme.json";
|
|
88
|
-
const DEFAULT_SMOKE_OUTPUT = "smoke-all.pptx";
|
|
89
9
|
const SKILL_DIR = "skills/slides";
|
|
90
10
|
const PLUGIN_DIR = ".claude-plugin";
|
|
91
11
|
const PLUGIN_FILE = "plugin.json";
|
|
@@ -113,6 +33,7 @@ program
|
|
|
113
33
|
.description("Build a PPTX deck from a Markdown spec")
|
|
114
34
|
.argument("<deck>", "path to deck markdown file")
|
|
115
35
|
.option(`-c, --config <path>`, "override theme config path (default: read from frontmatter)")
|
|
36
|
+
.option("--no-notes", "omit speaker notes from the output (also strips any inherited template notes)")
|
|
116
37
|
.action(async (deckPath, opts) => {
|
|
117
38
|
const absDeckPath = resolve(process.cwd(), deckPath);
|
|
118
39
|
let source;
|
|
@@ -133,10 +54,10 @@ program
|
|
|
133
54
|
throw new Error(`${basename(deckPath)}: missing required "${RESERVED_KEY.THEME}" in global frontmatter`);
|
|
134
55
|
}
|
|
135
56
|
const config = loadConfig(absConfigPath);
|
|
136
|
-
const deck = compileDeck(doc, config.layouts, config.rootDir);
|
|
57
|
+
const deck = compileDeck(doc, config.layouts, config.rootDir, config.assets);
|
|
137
58
|
if (!deck.output)
|
|
138
59
|
deck.output = basename(deckPath).replace(/\.md$/, ".pptx");
|
|
139
|
-
await buildDeck(deck, config);
|
|
60
|
+
await buildDeck(deck, config, { excludeNotes: !opts.notes });
|
|
140
61
|
});
|
|
141
62
|
program
|
|
142
63
|
.command("manifest")
|
|
@@ -154,16 +75,6 @@ program
|
|
|
154
75
|
process.stdout.write(`${json}\n`);
|
|
155
76
|
}
|
|
156
77
|
});
|
|
157
|
-
program
|
|
158
|
-
.command("smoke")
|
|
159
|
-
.description("Generate one smoke-test slide per layout")
|
|
160
|
-
.option(`-c, --config <path>`, "path to theme config file", DEFAULT_CONFIG)
|
|
161
|
-
.option(`-o, --out <file>`, "output PPTX filename", DEFAULT_SMOKE_OUTPUT)
|
|
162
|
-
.action(async (opts) => {
|
|
163
|
-
const config = loadConfig(resolve(process.cwd(), opts.config));
|
|
164
|
-
const steps = smokeSteps(config);
|
|
165
|
-
await generate({ theme: opts.config, output: opts.out, steps }, toEngineConfig(config));
|
|
166
|
-
});
|
|
167
78
|
program
|
|
168
79
|
.command("plugin")
|
|
169
80
|
.description("Generate plugin package (plugin.json, manifest.json, SKILL.md, syntax.md) for AI agents")
|
package/dist/engine/dom.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export declare const Tag: {
|
|
|
32
32
|
readonly HLINK_CLICK: "a:hlinkClick";
|
|
33
33
|
readonly RELATIONSHIP: "Relationship";
|
|
34
34
|
readonly TABLE: "a:tbl";
|
|
35
|
+
readonly TABLE_GRID: "a:tblGrid";
|
|
36
|
+
readonly GRID_COL: "a:gridCol";
|
|
35
37
|
readonly TABLE_ROW: "a:tr";
|
|
36
38
|
readonly TABLE_CELL: "a:tc";
|
|
37
39
|
readonly TX_BODY: "a:txBody";
|
|
@@ -63,6 +65,7 @@ export declare const Attr: {
|
|
|
63
65
|
readonly Y: "y";
|
|
64
66
|
readonly CX: "cx";
|
|
65
67
|
readonly CY: "cy";
|
|
68
|
+
readonly WIDTH: "w";
|
|
66
69
|
readonly LEFT: "l";
|
|
67
70
|
readonly TOP: "t";
|
|
68
71
|
readonly RIGHT: "r";
|
|
@@ -81,6 +84,8 @@ export declare function leadingDecorativePrefix(text: string): string;
|
|
|
81
84
|
export declare function buildRun(doc: any, cloneRPr: any | null, text: string): any;
|
|
82
85
|
export declare function buildParagraph(doc: any, clonePPr: any | null, run: any): any;
|
|
83
86
|
export declare const HYPERLINK_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
|
87
|
+
/** Next unused `rIdN` (max existing id + 1) among a rels container's `<Relationship>` children. */
|
|
88
|
+
export declare function nextFreeRId(relation: any): string;
|
|
84
89
|
export declare function addRelationship(relation: any, url: string): string;
|
|
85
90
|
/**
|
|
86
91
|
* Replace all runs in a paragraph with a sequence of styled runs, cloning the
|
package/dist/engine/dom.js
CHANGED
|
@@ -32,6 +32,8 @@ export const Tag = {
|
|
|
32
32
|
HLINK_CLICK: "a:hlinkClick",
|
|
33
33
|
RELATIONSHIP: "Relationship",
|
|
34
34
|
TABLE: "a:tbl",
|
|
35
|
+
TABLE_GRID: "a:tblGrid",
|
|
36
|
+
GRID_COL: "a:gridCol",
|
|
35
37
|
TABLE_ROW: "a:tr",
|
|
36
38
|
TABLE_CELL: "a:tc",
|
|
37
39
|
TX_BODY: "a:txBody",
|
|
@@ -68,6 +70,8 @@ export const Attr = {
|
|
|
68
70
|
Y: "y",
|
|
69
71
|
CX: "cx",
|
|
70
72
|
CY: "cy",
|
|
73
|
+
// table grid column width
|
|
74
|
+
WIDTH: "w",
|
|
71
75
|
// srcRect edges
|
|
72
76
|
LEFT: "l",
|
|
73
77
|
TOP: "t",
|
|
@@ -143,7 +147,8 @@ export function buildParagraph(doc, clonePPr, run) {
|
|
|
143
147
|
}
|
|
144
148
|
// ── Relationship Management ──────────────────────────────────────────────────
|
|
145
149
|
export const HYPERLINK_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
|
146
|
-
|
|
150
|
+
/** Next unused `rIdN` (max existing id + 1) among a rels container's `<Relationship>` children. */
|
|
151
|
+
export function nextFreeRId(relation) {
|
|
147
152
|
const existing = collectElements(relation, Tag.RELATIONSHIP);
|
|
148
153
|
let maxId = 0;
|
|
149
154
|
for (const rel of existing) {
|
|
@@ -152,7 +157,10 @@ export function addRelationship(relation, url) {
|
|
|
152
157
|
if (num > maxId)
|
|
153
158
|
maxId = num;
|
|
154
159
|
}
|
|
155
|
-
|
|
160
|
+
return `rId${maxId + 1}`;
|
|
161
|
+
}
|
|
162
|
+
export function addRelationship(relation, url) {
|
|
163
|
+
const rId = nextFreeRId(relation);
|
|
156
164
|
const rel = relation.ownerDocument.createElement(Tag.RELATIONSHIP);
|
|
157
165
|
rel.setAttribute(Attr.ID, rId);
|
|
158
166
|
rel.setAttribute(Attr.TYPE, HYPERLINK_REL_TYPE);
|
|
@@ -338,10 +346,18 @@ export function rebuildParagraphs(shape, paragraphs, startIndex, relation, shape
|
|
|
338
346
|
continue;
|
|
339
347
|
const isBullet = para.bullet !== undefined;
|
|
340
348
|
const inLevel = para.bullet?.level ?? 0;
|
|
341
|
-
const { bucket, effectiveLvl } = isBullet ? pickBullet(inLevel) : { bucket: plainBucket, effectiveLvl: 0 };
|
|
342
349
|
// Skip fully-empty text (matches previous fillText behavior).
|
|
343
350
|
if (para.runs.length === 1 && !para.runs[0].text)
|
|
344
351
|
continue;
|
|
352
|
+
// Bulleted content needs a bulleted specimen to model on. A plain-only shape
|
|
353
|
+
// (e.g. a designer "description" line, buNone) accepts plain text only, so
|
|
354
|
+
// bulleting it is an authoring error — fail fast rather than emit a run with
|
|
355
|
+
// no modelled style (which renders in the app default colour, e.g. black on
|
|
356
|
+
// a dark slide whose real text colour lives only in the specimen's rPr).
|
|
357
|
+
if (isBullet && bullets.size === 0) {
|
|
358
|
+
throw new Error(`Shape "${shapeName}": received bulleted content, but its specimen has no bulleted paragraph to model — this slot accepts plain text only.`);
|
|
359
|
+
}
|
|
360
|
+
const { bucket, effectiveLvl } = isBullet ? pickBullet(inLevel) : { bucket: plainBucket, effectiveLvl: 0 };
|
|
345
361
|
const seedRun = buildRun(doc, bucket?.rPr ?? null, "");
|
|
346
362
|
const newPara = buildParagraph(doc, bucket?.pPr ?? null, seedRun);
|
|
347
363
|
maybeOverrideLevel(newPara, isBullet ? effectiveLvl : null);
|
|
@@ -35,10 +35,7 @@ export const FILLERS = {
|
|
|
35
35
|
[SlotType.Table]: {
|
|
36
36
|
matches: isTableFill,
|
|
37
37
|
label: "TableFill",
|
|
38
|
-
fill: (slide, slot, v
|
|
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
|
-
}
|
|
38
|
+
fill: (slide, slot, v) => {
|
|
42
39
|
slide.modifyElement(slot.shapeName, [(el) => fillTable(el, v, slot.shapeName)]);
|
|
43
40
|
},
|
|
44
41
|
},
|
|
@@ -1,19 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Image fill — element-level picture geometry only. The media swap (pointing the
|
|
3
3
|
* blip relationship at the new file) is a slide-level modifier in the ImageFiller
|
|
4
|
-
* (`fillers/filler.ts`); this module
|
|
4
|
+
* (`fillers/filler.ts`); this module resolves the frame geometry from the image's
|
|
5
|
+
* `ImageFit` (contain/cover/scale-down) and the source's true pixel size.
|
|
5
6
|
*/
|
|
6
|
-
import { type ImageFill } from "../types.js";
|
|
7
|
+
import { type ImageFill, ImageFit } from "../types.js";
|
|
8
|
+
/** A picture frame's placement + size, in EMU. */
|
|
9
|
+
interface Frame {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
}
|
|
15
|
+
/** The two ways a picture is placed: crop the source (`<a:srcRect>`) or resize the frame. */
|
|
16
|
+
declare const Placement: {
|
|
17
|
+
readonly Crop: "crop";
|
|
18
|
+
readonly Fit: "fit";
|
|
19
|
+
};
|
|
20
|
+
/** Either a symmetric `<a:srcRect>` crop (fill/cover) or a resized+re-centred frame (fit/contain). */
|
|
21
|
+
type FitGeometry = {
|
|
22
|
+
placement: typeof Placement.Crop;
|
|
23
|
+
left: number;
|
|
24
|
+
top: number;
|
|
25
|
+
} | {
|
|
26
|
+
placement: typeof Placement.Fit;
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
cx: number;
|
|
30
|
+
cy: number;
|
|
31
|
+
};
|
|
32
|
+
export type GeometryResult = {
|
|
33
|
+
geometry: FitGeometry;
|
|
34
|
+
warnings: string[];
|
|
35
|
+
};
|
|
7
36
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* and re-centers within the original frame bounds.
|
|
37
|
+
* Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
|
|
38
|
+
* `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
|
|
39
|
+
* aspect ratio and re-centre (fit/letterbox). Advisory warnings from the
|
|
40
|
+
* geometry pass go to `console.warn`.
|
|
13
41
|
*
|
|
14
42
|
* `image.path` is assumed absolute — the compiler / caller resolves it before the
|
|
15
43
|
* ImageFill reaches the engine.
|
|
16
44
|
*/
|
|
17
45
|
export declare function fillImage(shape: any, image: ImageFill, shapeName?: string): void;
|
|
46
|
+
/**
|
|
47
|
+
* Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
|
|
48
|
+
* `fit` picks the strategy: `cover` scales to the larger axis ratio and
|
|
49
|
+
* center-crops the overflow; `contain` scales to the smaller ratio and
|
|
50
|
+
* letterboxes; `scale-down` is `contain` capped at native size (never enlarge →
|
|
51
|
+
* the image sits at native size, centred). Emits advisory warnings for scaling
|
|
52
|
+
* far from native and for a severe aspect mismatch; the caller decides how to
|
|
53
|
+
* surface them.
|
|
54
|
+
*/
|
|
55
|
+
export declare function computeGeometry(frame: Frame, imgW: number, imgH: number, fit: ImageFit): GeometryResult;
|
|
18
56
|
/** Discriminator for ImageFill values. */
|
|
19
57
|
export declare function isImageFill(v: unknown): v is ImageFill;
|
|
58
|
+
export {};
|
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Image fill — element-level picture geometry only. The media swap (pointing the
|
|
3
3
|
* blip relationship at the new file) is a slide-level modifier in the ImageFiller
|
|
4
|
-
* (`fillers/filler.ts`); this module
|
|
4
|
+
* (`fillers/filler.ts`); this module resolves the frame geometry from the image's
|
|
5
|
+
* `ImageFit` (contain/cover/scale-down) and the source's true pixel size.
|
|
5
6
|
*/
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
7
8
|
import { imageSize } from "image-size";
|
|
8
9
|
import { Attr, isPlainObject, Tag } from "../dom.js";
|
|
9
|
-
import {
|
|
10
|
+
import { ImageFit, SlotType } from "../types.js";
|
|
11
|
+
/** The two ways a picture is placed: crop the source (`<a:srcRect>`) or resize the frame. */
|
|
12
|
+
const Placement = { Crop: "crop", Fit: "fit" };
|
|
13
|
+
const EMU_PER_INCH = 914400;
|
|
14
|
+
/** PPTX's reference pixel density: one source px maps to one output px at this DPI. */
|
|
15
|
+
const PX_PER_INCH = 96;
|
|
16
|
+
/** EMU that one source pixel occupies at native (1:1) size — 9525. */
|
|
17
|
+
const NATIVE_EMU_PER_PX = EMU_PER_INCH / PX_PER_INCH;
|
|
18
|
+
/** `<a:srcRect>` insets are fixed-point fractions of the picture where this = 100%. */
|
|
19
|
+
const SRC_RECT_FULL = 100000;
|
|
20
|
+
/** Warn once the cropped-away or empty area exceeds this fraction of the frame. */
|
|
21
|
+
const SEVERE_MISMATCH_FRACTION = 0.5;
|
|
22
|
+
/** Warn once the image renders below this fraction of its native pixel size. */
|
|
23
|
+
const MIN_SCALE = 0.2;
|
|
10
24
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and re-centers within the original frame bounds.
|
|
25
|
+
* Size a picture shape from its `ImageFit` (via `computeGeometry`): either write
|
|
26
|
+
* `<a:srcRect>` insets to fill-and-crop, or shrink the frame to the image's
|
|
27
|
+
* aspect ratio and re-centre (fit/letterbox). Advisory warnings from the
|
|
28
|
+
* geometry pass go to `console.warn`.
|
|
16
29
|
*
|
|
17
30
|
* `image.path` is assumed absolute — the compiler / caller resolves it before the
|
|
18
31
|
* ImageFill reaches the engine.
|
|
@@ -34,48 +47,77 @@ export function fillImage(shape, image, shapeName = "") {
|
|
|
34
47
|
w: Number(ext.getAttribute(Attr.CX)),
|
|
35
48
|
h: Number(ext.getAttribute(Attr.CY)),
|
|
36
49
|
};
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
const { geometry, warnings } = computeGeometry(frame, dims.width, dims.height, image.fit);
|
|
51
|
+
for (const w of warnings)
|
|
52
|
+
console.warn(`Image "${shapeName}": ${w}`);
|
|
53
|
+
if (geometry.placement === Placement.Crop) {
|
|
54
|
+
// fill: symmetric crop on the overflowing axis, written as srcRect insets.
|
|
55
|
+
applySrcRect(shape, blipFill, geometry.left, geometry.top, geometry.left, geometry.top);
|
|
41
56
|
return;
|
|
42
57
|
}
|
|
43
|
-
//
|
|
58
|
+
// fit: drop any inherited crop, then resize + re-center the frame itself.
|
|
44
59
|
applySrcRect(shape, blipFill, 0, 0, 0, 0);
|
|
45
|
-
ext.setAttribute(Attr.CX, String(
|
|
46
|
-
ext.setAttribute(Attr.CY, String(
|
|
47
|
-
off.setAttribute(Attr.X, String(
|
|
48
|
-
off.setAttribute(Attr.Y, String(
|
|
60
|
+
ext.setAttribute(Attr.CX, String(geometry.cx));
|
|
61
|
+
ext.setAttribute(Attr.CY, String(geometry.cy));
|
|
62
|
+
off.setAttribute(Attr.X, String(geometry.x));
|
|
63
|
+
off.setAttribute(Attr.Y, String(geometry.y));
|
|
49
64
|
}
|
|
50
65
|
/**
|
|
51
66
|
* Pure fit geometry — no DOM, so it is trivially unit-testable in isolation.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
67
|
+
* `fit` picks the strategy: `cover` scales to the larger axis ratio and
|
|
68
|
+
* center-crops the overflow; `contain` scales to the smaller ratio and
|
|
69
|
+
* letterboxes; `scale-down` is `contain` capped at native size (never enlarge →
|
|
70
|
+
* the image sits at native size, centred). Emits advisory warnings for scaling
|
|
71
|
+
* far from native and for a severe aspect mismatch; the caller decides how to
|
|
72
|
+
* surface them.
|
|
55
73
|
*/
|
|
56
|
-
function
|
|
74
|
+
export function computeGeometry(frame, imgW, imgH, fit) {
|
|
57
75
|
const fitX = frame.w / imgW;
|
|
58
76
|
const fitY = frame.h / imgH;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
const allowCrop = fit === ImageFit.Cover;
|
|
78
|
+
let scale = allowCrop ? Math.max(fitX, fitY) : Math.min(fitX, fitY);
|
|
79
|
+
if (fit === ImageFit.ScaleDown)
|
|
80
|
+
scale = Math.min(scale, NATIVE_EMU_PER_PX); // never enlarge past native
|
|
81
|
+
const warnings = [];
|
|
82
|
+
const scaleRatio = scale / NATIVE_EMU_PER_PX; // rendered size vs the image's native pixels
|
|
83
|
+
if (scaleRatio > 1) {
|
|
84
|
+
warnings.push(`enlarged to ${Math.round(scaleRatio * 100)}% of native — will look soft; supply a larger image`);
|
|
85
|
+
}
|
|
86
|
+
else if (scaleRatio < MIN_SCALE) {
|
|
87
|
+
warnings.push(`shrunk to ${Math.round(scaleRatio * 100)}% of native — the slot is far smaller than the image`);
|
|
88
|
+
}
|
|
89
|
+
const shownW = imgW * scale;
|
|
90
|
+
const shownH = imgH * scale;
|
|
91
|
+
const inset = (fraction) => Math.round(fraction * SRC_RECT_FULL);
|
|
92
|
+
if (allowCrop && (shownW > frame.w || shownH > frame.h)) {
|
|
93
|
+
const cropped = 1 - (frame.w * frame.h) / (shownW * shownH);
|
|
94
|
+
if (cropped > SEVERE_MISMATCH_FRACTION) {
|
|
95
|
+
warnings.push(`fills the frame by cropping ${Math.round(cropped * 100)}% of the image (aspect mismatch)`);
|
|
96
|
+
}
|
|
64
97
|
return {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
98
|
+
geometry: {
|
|
99
|
+
placement: Placement.Crop,
|
|
100
|
+
left: shownW > frame.w ? inset((1 - frame.w / shownW) / 2) : 0,
|
|
101
|
+
top: shownH > frame.h ? inset((1 - frame.h / shownH) / 2) : 0,
|
|
102
|
+
},
|
|
103
|
+
warnings,
|
|
68
104
|
};
|
|
69
105
|
}
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
106
|
+
const empty = 1 - (shownW * shownH) / (frame.w * frame.h);
|
|
107
|
+
if (empty > SEVERE_MISMATCH_FRACTION) {
|
|
108
|
+
warnings.push(`leaves ${Math.round(empty * 100)}% of the frame empty (aspect mismatch or small image)`);
|
|
109
|
+
}
|
|
110
|
+
const cx = Math.round(shownW);
|
|
111
|
+
const cy = Math.round(shownH);
|
|
73
112
|
return {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
113
|
+
geometry: {
|
|
114
|
+
placement: Placement.Fit,
|
|
115
|
+
x: Math.round(frame.x + (frame.w - cx) / 2),
|
|
116
|
+
y: Math.round(frame.y + (frame.h - cy) / 2),
|
|
117
|
+
cx,
|
|
118
|
+
cy,
|
|
119
|
+
},
|
|
120
|
+
warnings,
|
|
79
121
|
};
|
|
80
122
|
}
|
|
81
123
|
/**
|
|
@@ -1,8 +1,9 @@
|
|
|
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
8
|
import type { TableFill } from "../types.js";
|
|
8
9
|
/**
|
|
@@ -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)
|
|
@@ -23,6 +23,16 @@
|
|
|
23
23
|
* slot's type via `FILLERS[slot.type]` (required, no default).
|
|
24
24
|
*/
|
|
25
25
|
import type { Config, Deck, Slot } from "./types.js";
|
|
26
|
+
/** Options for `generate` / `buildDeck`. */
|
|
27
|
+
export type GenerateOptions = {
|
|
28
|
+
/**
|
|
29
|
+
* Suppress authored speaker notes. Default `false` (notes are written and any
|
|
30
|
+
* template notes cloned onto slides are stripped). When `true`, no notes are
|
|
31
|
+
* written but inherited template notes are still stripped — the deck is
|
|
32
|
+
* guaranteed notes-free.
|
|
33
|
+
*/
|
|
34
|
+
excludeNotes?: boolean;
|
|
35
|
+
};
|
|
26
36
|
/**
|
|
27
37
|
* Generate a PPTX file from a deck definition and a theme configuration.
|
|
28
38
|
*
|
|
@@ -35,7 +45,7 @@ import type { Config, Deck, Slot } from "./types.js";
|
|
|
35
45
|
* via `FILLERS[slot.type]`.
|
|
36
46
|
* 4. Write the output PPTX.
|
|
37
47
|
*/
|
|
38
|
-
export declare function generate(deck: Deck, config: Config): Promise<void>;
|
|
48
|
+
export declare function generate(deck: Deck, config: Config, options?: GenerateOptions): Promise<void>;
|
|
39
49
|
/**
|
|
40
50
|
* Validate that every required slot on a layout is supplied. Type/shape
|
|
41
51
|
* validation happens in the compiler now — this is a thin required-only
|