@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
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# tycoslide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Let AI agents build slides from existing PowerPoint (`.pptx`) files.
|
|
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, loadThemeConfig, 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";
|
|
@@ -96,16 +16,6 @@ const BUILD_COMMAND = "npx tycoslide build";
|
|
|
96
16
|
const sdkDir = dirname(fileURLToPath(import.meta.url));
|
|
97
17
|
const skillMdPath = resolve(sdkDir, "..", SKILL_FILE);
|
|
98
18
|
const syntaxMdPath = resolve(sdkDir, "..", SYNTAX_FILE);
|
|
99
|
-
function loadConfig(absPath) {
|
|
100
|
-
let raw;
|
|
101
|
-
try {
|
|
102
|
-
raw = JSON.parse(readFileSync(absPath, "utf-8"));
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
throw new Error(`Config file not found or invalid JSON: ${absPath}`);
|
|
106
|
-
}
|
|
107
|
-
return { ...raw, rootDir: dirname(absPath) };
|
|
108
|
-
}
|
|
109
19
|
const pkg = JSON.parse(readFileSync(resolve(sdkDir, "..", "package.json"), "utf-8"));
|
|
110
20
|
const program = new Command().name("tycoslide").description("PPTX template engine CLI").version(pkg.version);
|
|
111
21
|
program
|
|
@@ -113,6 +23,7 @@ program
|
|
|
113
23
|
.description("Build a PPTX deck from a Markdown spec")
|
|
114
24
|
.argument("<deck>", "path to deck markdown file")
|
|
115
25
|
.option(`-c, --config <path>`, "override theme config path (default: read from frontmatter)")
|
|
26
|
+
.option("--no-notes", "omit speaker notes from the output (also strips any inherited template notes)")
|
|
116
27
|
.action(async (deckPath, opts) => {
|
|
117
28
|
const absDeckPath = resolve(process.cwd(), deckPath);
|
|
118
29
|
let source;
|
|
@@ -132,11 +43,11 @@ program
|
|
|
132
43
|
if (!absConfigPath) {
|
|
133
44
|
throw new Error(`${basename(deckPath)}: missing required "${RESERVED_KEY.THEME}" in global frontmatter`);
|
|
134
45
|
}
|
|
135
|
-
const config =
|
|
136
|
-
const deck = compileDeck(doc, config
|
|
46
|
+
const config = loadThemeConfig(absConfigPath);
|
|
47
|
+
const deck = await compileDeck(doc, config);
|
|
137
48
|
if (!deck.output)
|
|
138
49
|
deck.output = basename(deckPath).replace(/\.md$/, ".pptx");
|
|
139
|
-
await buildDeck(deck, config);
|
|
50
|
+
await buildDeck(deck, config, { excludeNotes: !opts.notes });
|
|
140
51
|
});
|
|
141
52
|
program
|
|
142
53
|
.command("manifest")
|
|
@@ -144,7 +55,7 @@ program
|
|
|
144
55
|
.option(`-c, --config <path>`, "path to theme config file", DEFAULT_CONFIG)
|
|
145
56
|
.option(`-o, --out <file>`, "write to file instead of stdout")
|
|
146
57
|
.action(async (opts) => {
|
|
147
|
-
const config =
|
|
58
|
+
const config = loadThemeConfig(resolve(process.cwd(), opts.config));
|
|
148
59
|
const json = generateManifest(config, { build: { command: BUILD_COMMAND } });
|
|
149
60
|
if (opts.out) {
|
|
150
61
|
writeFileSync(resolve(process.cwd(), opts.out), `${json}\n`);
|
|
@@ -154,22 +65,12 @@ program
|
|
|
154
65
|
process.stdout.write(`${json}\n`);
|
|
155
66
|
}
|
|
156
67
|
});
|
|
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
68
|
program
|
|
168
69
|
.command("plugin")
|
|
169
70
|
.description("Generate plugin package (plugin.json, manifest.json, SKILL.md, syntax.md) for AI agents")
|
|
170
71
|
.option(`-c, --config <path>`, "path to theme config file", DEFAULT_CONFIG)
|
|
171
72
|
.action(async (opts) => {
|
|
172
|
-
const config =
|
|
73
|
+
const config = loadThemeConfig(resolve(process.cwd(), opts.config));
|
|
173
74
|
const cwd = process.cwd();
|
|
174
75
|
const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf-8"));
|
|
175
76
|
const pluginMeta = {
|
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);
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `Filler` strategy registry — one plain-object strategy per SlotType, each
|
|
3
|
-
* pairing a value discriminator with
|
|
4
|
-
* slot type, so a strategy carries no redundant
|
|
5
|
-
*
|
|
6
|
-
* value shape, then `fill` applies it to the slide.
|
|
3
|
+
* pairing a value discriminator with the element-level modify callbacks that
|
|
4
|
+
* apply it. The record key IS the slot type, so a strategy carries no redundant
|
|
5
|
+
* `type` field.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `callbacks(value, target)` returns the `(element, relation)` callbacks that
|
|
8
|
+
* fill one shape. They are deliberately shape-name-agnostic beyond the `target`
|
|
9
|
+
* so they can be applied two ways: `slide.modifyElement(name, callbacks)` for a
|
|
10
|
+
* shape already on the cloned base slide, or `slide.addElement(alias, n, name,
|
|
11
|
+
* callbacks)` for a shape transplanted from another slide — pptx-automizer runs
|
|
12
|
+
* an appended shape's callbacks against the imported element itself, so the same
|
|
13
|
+
* callbacks refill a transplant.
|
|
14
|
+
*
|
|
15
|
+
* Element-level geometry lives in the `fillX` primitives; cross-shape concerns
|
|
16
|
+
* (media pre-swap for images) live in the callbacks here.
|
|
11
17
|
*/
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
import { SlotType } from "../types.js";
|
|
19
|
+
/** The shape a filler targets, plus its slot-level options (startAt for text). */
|
|
20
|
+
export type FillTarget = {
|
|
21
|
+
shapeName: string;
|
|
22
|
+
startAt?: number;
|
|
15
23
|
};
|
|
24
|
+
/** A pptx-automizer element-modify callback: `(element, relation) => void`. */
|
|
25
|
+
export type ShapeCallback = (element: any, relation: any) => unknown;
|
|
16
26
|
export interface Filler<T> {
|
|
17
27
|
matches(v: unknown): v is T;
|
|
18
|
-
|
|
19
|
-
label: string;
|
|
20
|
-
fill(slide: any, slot: Slot, value: T, ctx: FillContext): void;
|
|
28
|
+
callbacks(value: T, target: FillTarget): ShapeCallback[];
|
|
21
29
|
}
|
|
22
30
|
export declare const FILLERS: Record<SlotType, Filler<any>>;
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `Filler` strategy registry — one plain-object strategy per SlotType, each
|
|
3
|
-
* pairing a value discriminator with
|
|
4
|
-
* slot type, so a strategy carries no redundant
|
|
5
|
-
*
|
|
6
|
-
* value shape, then `fill` applies it to the slide.
|
|
3
|
+
* pairing a value discriminator with the element-level modify callbacks that
|
|
4
|
+
* apply it. The record key IS the slot type, so a strategy carries no redundant
|
|
5
|
+
* `type` field.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `callbacks(value, target)` returns the `(element, relation)` callbacks that
|
|
8
|
+
* fill one shape. They are deliberately shape-name-agnostic beyond the `target`
|
|
9
|
+
* so they can be applied two ways: `slide.modifyElement(name, callbacks)` for a
|
|
10
|
+
* shape already on the cloned base slide, or `slide.addElement(alias, n, name,
|
|
11
|
+
* callbacks)` for a shape transplanted from another slide — pptx-automizer runs
|
|
12
|
+
* an appended shape's callbacks against the imported element itself, so the same
|
|
13
|
+
* callbacks refill a transplant.
|
|
14
|
+
*
|
|
15
|
+
* Element-level geometry lives in the `fillX` primitives; cross-shape concerns
|
|
16
|
+
* (media pre-swap for images) live in the callbacks here.
|
|
11
17
|
*/
|
|
12
18
|
import { basename } from "node:path";
|
|
13
19
|
import { ModifyImageHelper } from "pptx-automizer";
|
|
@@ -19,35 +25,23 @@ import { fillText, isTextFill } from "./text.js";
|
|
|
19
25
|
export const FILLERS = {
|
|
20
26
|
[SlotType.Template]: {
|
|
21
27
|
matches: isTemplateFill,
|
|
22
|
-
|
|
23
|
-
fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [(el) => fillTemplate(el, v, slot.shapeName)]),
|
|
28
|
+
callbacks: (v, t) => [(el) => fillTemplate(el, v, t.shapeName)],
|
|
24
29
|
},
|
|
25
30
|
[SlotType.Text]: {
|
|
26
31
|
matches: isTextFill,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
slide.modifyElement(slot.shapeName, [
|
|
31
|
-
(el, relation) => fillText(el, v, { startAt, relation, shapeName: slot.shapeName }),
|
|
32
|
-
]);
|
|
33
|
-
},
|
|
32
|
+
callbacks: (v, t) => [
|
|
33
|
+
(el, relation) => fillText(el, v, { startAt: t.startAt ?? 0, relation, shapeName: t.shapeName }),
|
|
34
|
+
],
|
|
34
35
|
},
|
|
35
36
|
[SlotType.Table]: {
|
|
36
37
|
matches: isTableFill,
|
|
37
|
-
|
|
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
|
-
},
|
|
38
|
+
callbacks: (v, t) => [(el) => fillTable(el, v, t.shapeName)],
|
|
44
39
|
},
|
|
45
40
|
[SlotType.Image]: {
|
|
46
41
|
matches: isImageFill,
|
|
47
|
-
|
|
48
|
-
fill: (slide, slot, v) => slide.modifyElement(slot.shapeName, [
|
|
42
|
+
callbacks: (v, t) => [
|
|
49
43
|
ModifyImageHelper.setRelationTarget(basename(v.path)),
|
|
50
|
-
(el) => fillImage(el, v,
|
|
51
|
-
]
|
|
44
|
+
(el) => fillImage(el, v, t.shapeName),
|
|
45
|
+
],
|
|
52
46
|
},
|
|
53
47
|
};
|
|
@@ -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
|
/**
|