@tycoworks/tycoslide 0.15.1 → 0.16.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 +10 -6
- package/dist/agents/catalog.d.ts +32 -0
- package/dist/agents/catalog.js +37 -0
- package/dist/agents/commands.d.ts +9 -0
- package/dist/agents/commands.js +57 -0
- package/dist/agents/extract.d.ts +22 -0
- package/dist/agents/extract.js +54 -0
- package/dist/{files.d.ts → agents/files.d.ts} +13 -3
- package/dist/{files.js → agents/files.js} +16 -3
- package/dist/agents/inventory.d.ts +83 -0
- package/dist/agents/inventory.js +568 -0
- package/dist/agents/manifest.d.ts +3 -0
- package/dist/{manifest.js → agents/manifest.js} +3 -14
- package/dist/agents/media.d.ts +38 -0
- package/dist/agents/media.js +83 -0
- package/dist/agents/pptx.d.ts +48 -0
- package/dist/agents/pptx.js +123 -0
- package/dist/agents/skill.d.ts +43 -0
- package/dist/agents/skill.js +134 -0
- package/dist/cli.js +8 -44
- package/dist/engine/dom.d.ts +3 -0
- package/dist/engine/dom.js +4 -0
- package/dist/engine/fillers/image.d.ts +6 -5
- package/dist/engine/fillers/image.js +24 -7
- package/dist/engine/generate.d.ts +2 -2
- package/dist/engine/generate.js +2 -2
- package/dist/engine/index.d.ts +3 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/notes.js +20 -62
- package/dist/engine/ooxml.d.ts +36 -0
- package/dist/engine/ooxml.js +56 -0
- package/dist/engine/types.d.ts +4 -0
- package/dist/index.d.ts +4 -11
- package/dist/index.js +2 -15
- package/dist/markdown/blocks/code.js +1 -2
- package/dist/markdown/blocks/image.d.ts +23 -0
- package/dist/markdown/blocks/image.js +65 -3
- package/dist/markdown/blocks/mermaid.d.ts +1 -1
- package/dist/markdown/blocks/mermaid.js +6 -4
- package/dist/markdown/blocks/table.js +7 -6
- package/dist/markdown/blocks/text.js +9 -8
- package/dist/markdown/deckCompiler.d.ts +2 -13
- package/dist/markdown/deckCompiler.js +7 -78
- package/dist/markdown/index.d.ts +4 -2
- package/dist/markdown/index.js +3 -1
- package/dist/markdown/inline.d.ts +2 -0
- package/dist/markdown/inline.js +4 -3
- package/dist/markdown/schema/themeConfigSchema.d.ts +2 -11
- package/dist/markdown/schema/themeConfigSchema.js +6 -16
- package/dist/markdown/slideParser.d.ts +2 -0
- package/dist/markdown/slideParser.js +6 -3
- package/dist/markdown/types.d.ts +4 -29
- package/dist/markdown/types.js +0 -11
- package/{theme-package/syntax.md → docs/markdown.md} +60 -28
- package/docs/theme.md +144 -0
- package/package.json +3 -2
- package/theme-package/SKILL.md +24 -90
- package/dist/engine/rels.d.ts +0 -18
- package/dist/engine/rels.js +0 -55
- package/dist/manifest.d.ts +0 -5
- package/dist/skillZip.d.ts +0 -49
- package/dist/skillZip.js +0 -177
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inventory: what a template holds, in the terms `theme.json` needs. The slide
|
|
3
|
+
* size, the colour and font schemes, the embedded typefaces; each slide's
|
|
4
|
+
* position, layout, background tone and shapes (with frames in EMU, as
|
|
5
|
+
* `theme.json` takes them); and which slides share the same geometry.
|
|
6
|
+
*/
|
|
7
|
+
import { AcceptType, collectElements, PRESENTATION_PART } from "../index.js";
|
|
8
|
+
import { attributes, elementChildren, find, Part, partNumber, RelType } from "./pptx.js";
|
|
9
|
+
/** The element and attribute names the inventory reads. */
|
|
10
|
+
const Tag = {
|
|
11
|
+
SLIDE_SIZE: "p:sldSz",
|
|
12
|
+
SLIDE_ID_LIST: "p:sldIdLst",
|
|
13
|
+
SLIDE_ID: "p:sldId",
|
|
14
|
+
MASTER_ID_LIST: "p:sldMasterIdLst",
|
|
15
|
+
MASTER_ID: "p:sldMasterId",
|
|
16
|
+
EMBEDDED_FONT_LIST: "p:embeddedFontLst",
|
|
17
|
+
EMBEDDED_FONT: "p:embeddedFont",
|
|
18
|
+
FONT: "p:font",
|
|
19
|
+
COMMON_SLIDE_DATA: "p:cSld",
|
|
20
|
+
BACKGROUND: "p:bg",
|
|
21
|
+
BACKGROUND_PROPS: "p:bgPr",
|
|
22
|
+
BACKGROUND_REF: "p:bgRef",
|
|
23
|
+
COLOR_MAP: "p:clrMap",
|
|
24
|
+
COLOR_MAP_OVERRIDE: "p:clrMapOvr",
|
|
25
|
+
OVERRIDE_COLOR_MAPPING: "a:overrideClrMapping",
|
|
26
|
+
THEME_ELEMENTS: "a:themeElements",
|
|
27
|
+
COLOR_SCHEME: "a:clrScheme",
|
|
28
|
+
FONT_SCHEME: "a:fontScheme",
|
|
29
|
+
MAJOR_FONT: "a:majorFont",
|
|
30
|
+
MINOR_FONT: "a:minorFont",
|
|
31
|
+
LATIN: "a:latin",
|
|
32
|
+
FORMAT_SCHEME: "a:fmtScheme",
|
|
33
|
+
FILL_STYLES: "a:fillStyleLst",
|
|
34
|
+
BACKGROUND_FILL_STYLES: "a:bgFillStyleLst",
|
|
35
|
+
SOLID_FILL: "a:solidFill",
|
|
36
|
+
GRADIENT_FILL: "a:gradFill",
|
|
37
|
+
PICTURE_FILL: "a:blipFill",
|
|
38
|
+
GRADIENT_STOPS: "a:gsLst",
|
|
39
|
+
GRADIENT_STOP: "a:gs",
|
|
40
|
+
RGB_COLOR: "a:srgbClr",
|
|
41
|
+
SCHEME_COLOR: "a:schemeClr",
|
|
42
|
+
SYSTEM_COLOR: "a:sysClr",
|
|
43
|
+
PRESET_COLOR: "a:prstClr",
|
|
44
|
+
LUMINANCE_MOD: "a:lumMod",
|
|
45
|
+
LUMINANCE_OFF: "a:lumOff",
|
|
46
|
+
TINT: "a:tint",
|
|
47
|
+
SHADE: "a:shade",
|
|
48
|
+
SHAPE_TREE: "p:spTree",
|
|
49
|
+
SHAPE: "p:sp",
|
|
50
|
+
PICTURE: "p:pic",
|
|
51
|
+
GRAPHIC_FRAME: "p:graphicFrame",
|
|
52
|
+
GROUP: "p:grpSp",
|
|
53
|
+
CONNECTOR: "p:cxnSp",
|
|
54
|
+
SHAPE_PROPS: "p:spPr",
|
|
55
|
+
GROUP_PROPS: "p:grpSpPr",
|
|
56
|
+
TRANSFORM: "a:xfrm",
|
|
57
|
+
GRAPHIC_FRAME_TRANSFORM: "p:xfrm",
|
|
58
|
+
OFFSET: "a:off",
|
|
59
|
+
EXTENT: "a:ext",
|
|
60
|
+
TABLE: "a:tbl",
|
|
61
|
+
TABLE_ROW: "a:tr",
|
|
62
|
+
TABLE_GRID: "a:tblGrid",
|
|
63
|
+
GRID_COLUMN: "a:gridCol",
|
|
64
|
+
NON_VISUAL_PROPS: "p:cNvPr",
|
|
65
|
+
NON_VISUAL_SHAPE: "p:nvSpPr",
|
|
66
|
+
NON_VISUAL_SHAPE_PROPS: "p:cNvSpPr",
|
|
67
|
+
APP_PROPS: "p:nvPr",
|
|
68
|
+
PLACEHOLDER: "p:ph",
|
|
69
|
+
PARAGRAPH: "a:p",
|
|
70
|
+
LINE_BREAK: "a:br",
|
|
71
|
+
TEXT: "a:t",
|
|
72
|
+
};
|
|
73
|
+
/** Every shape's non-visual properties element is named `p:nv…Pr`, whatever the shape. */
|
|
74
|
+
const NON_VISUAL_PREFIX = "p:nv";
|
|
75
|
+
/** A colour-scheme slot's element, e.g. `a:accent1`. */
|
|
76
|
+
const schemeSlotElement = (slot) => `a:${slot}`;
|
|
77
|
+
const Attr = {
|
|
78
|
+
REL_ID: "r:id",
|
|
79
|
+
CX: "cx",
|
|
80
|
+
CY: "cy",
|
|
81
|
+
NAME: "name",
|
|
82
|
+
TYPEFACE: "typeface",
|
|
83
|
+
VALUE: "val",
|
|
84
|
+
LAST_COLOR: "lastClr",
|
|
85
|
+
INDEX: "idx",
|
|
86
|
+
TYPE: "type",
|
|
87
|
+
X: "x",
|
|
88
|
+
Y: "y",
|
|
89
|
+
TEXT_BOX: "txBox",
|
|
90
|
+
};
|
|
91
|
+
/** The twelve colour-scheme slots, in the order PowerPoint lists them. */
|
|
92
|
+
export const SCHEME_SLOTS = [
|
|
93
|
+
"dk1",
|
|
94
|
+
"lt1",
|
|
95
|
+
"dk2",
|
|
96
|
+
"lt2",
|
|
97
|
+
"accent1",
|
|
98
|
+
"accent2",
|
|
99
|
+
"accent3",
|
|
100
|
+
"accent4",
|
|
101
|
+
"accent5",
|
|
102
|
+
"accent6",
|
|
103
|
+
"hlink",
|
|
104
|
+
"folHlink",
|
|
105
|
+
];
|
|
106
|
+
/** A background's tone, as a layout's `variant` would describe it; `image` for a picture fill. */
|
|
107
|
+
export const Background = { Light: "light", Dark: "dark", Image: "image", Unknown: "unknown" };
|
|
108
|
+
/** A shape's kind. A fillable shape's kind is the `theme.json` block type that fills it. */
|
|
109
|
+
export const ShapeKind = {
|
|
110
|
+
Text: AcceptType.Text,
|
|
111
|
+
Image: AcceptType.Image,
|
|
112
|
+
Table: AcceptType.Table,
|
|
113
|
+
Group: "group",
|
|
114
|
+
Other: "other",
|
|
115
|
+
};
|
|
116
|
+
/** Where a placeholder that doesn't position itself takes its frame from. */
|
|
117
|
+
export const FrameSource = { Layout: "layout", Master: "master" };
|
|
118
|
+
/** A scheme colour's placeholder value, `phClr`: the colour its reference supplies. */
|
|
119
|
+
const PLACEHOLDER_COLOR = "phClr";
|
|
120
|
+
/** `sysClr` without a `lastClr` renders mid grey. */
|
|
121
|
+
const SYSTEM_COLOR_FALLBACK = "808080";
|
|
122
|
+
const PRESET_COLORS = { white: [255, 255, 255], black: [0, 0, 0] };
|
|
123
|
+
/** OOXML percentages are in thousandths of a percent. */
|
|
124
|
+
const PERCENT = 100000;
|
|
125
|
+
/** `bgRef` indexes 1001 and up point into the background fill styles, 1–999 into the ordinary ones. */
|
|
126
|
+
const BACKGROUND_FILL_INDEX = 1001;
|
|
127
|
+
const FILL_INDEX = 1;
|
|
128
|
+
/** Above this relative luminance a background reads as light. */
|
|
129
|
+
const LIGHT_LUMINANCE = 0.5;
|
|
130
|
+
/** An 8-bit colour channel's maximum. */
|
|
131
|
+
const CHANNEL_MAX = 255;
|
|
132
|
+
/** `#RRGGBB`: the prefix, then two hex digits per channel. */
|
|
133
|
+
const HEX_PREFIX = "#";
|
|
134
|
+
const HEX_RADIX = 16;
|
|
135
|
+
const HEX_DIGITS = 2;
|
|
136
|
+
/** WCAG 2 relative luminance: sRGB linearisation, then per-channel weights. */
|
|
137
|
+
const WCAG = {
|
|
138
|
+
LINEAR_BELOW: 0.03928,
|
|
139
|
+
LINEAR_DIVISOR: 12.92,
|
|
140
|
+
OFFSET: 0.055,
|
|
141
|
+
SCALE: 1.055,
|
|
142
|
+
GAMMA: 2.4,
|
|
143
|
+
WEIGHTS: [0.2126, 0.7152, 0.0722],
|
|
144
|
+
};
|
|
145
|
+
/** Read a template's inventory. */
|
|
146
|
+
export async function readInventory(presentation) {
|
|
147
|
+
const root = await presentation.xml(PRESENTATION_PART);
|
|
148
|
+
const targets = new Map((await presentation.relationships(PRESENTATION_PART)).map((rel) => [rel.id, rel.target]));
|
|
149
|
+
const master = firstMaster(presentation, root, targets);
|
|
150
|
+
const theme = master && (await presentation.related(master, RelType.Theme))[0];
|
|
151
|
+
if (!master || !theme)
|
|
152
|
+
throw new Error("Could not find a theme part for the first slide master.");
|
|
153
|
+
const themeRoot = await presentation.xml(theme);
|
|
154
|
+
const colorScheme = readColorScheme(themeRoot);
|
|
155
|
+
const order = presentationOrder(root, targets);
|
|
156
|
+
const slides = [];
|
|
157
|
+
for (const part of presentation.numbered(Part.Slide)) {
|
|
158
|
+
const layout = (await presentation.related(part, RelType.SlideLayout))[0];
|
|
159
|
+
const position = order.get(part);
|
|
160
|
+
const layoutName = layout && find(await presentation.xml(layout), Tag.COMMON_SLIDE_DATA)?.getAttribute(Attr.NAME);
|
|
161
|
+
const parts = { slide: part, layout, master };
|
|
162
|
+
slides.push({
|
|
163
|
+
slide: partNumber(part, Part.Slide),
|
|
164
|
+
...(position !== undefined && { position }),
|
|
165
|
+
...(layoutName && { layout: layoutName }),
|
|
166
|
+
background: await classifyBackground(presentation, parts, themeRoot, colorScheme),
|
|
167
|
+
shapes: await readShapes(presentation, parts),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const size = find(root, Tag.SLIDE_SIZE);
|
|
171
|
+
return {
|
|
172
|
+
slideSize: { cx: Number(size.getAttribute(Attr.CX)), cy: Number(size.getAttribute(Attr.CY)) },
|
|
173
|
+
colorScheme,
|
|
174
|
+
fontScheme: readFontScheme(themeRoot),
|
|
175
|
+
embeddedFonts: elementChildren(find(root, Tag.EMBEDDED_FONT_LIST), Tag.EMBEDDED_FONT).map((font) => find(font, Tag.FONT).getAttribute(Attr.TYPEFACE)),
|
|
176
|
+
slides,
|
|
177
|
+
duplicates: findDuplicates(slides),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** The presentation's first slide master, by its master list or else the lowest-numbered part. */
|
|
181
|
+
function firstMaster(presentation, root, targets) {
|
|
182
|
+
for (const id of elementChildren(find(root, Tag.MASTER_ID_LIST), Tag.MASTER_ID)) {
|
|
183
|
+
const target = targets.get(id.getAttribute(Attr.REL_ID));
|
|
184
|
+
if (target)
|
|
185
|
+
return target;
|
|
186
|
+
}
|
|
187
|
+
return presentation.numbered(Part.Master)[0];
|
|
188
|
+
}
|
|
189
|
+
/** Slide part → 1-based position in the presentation's slide list. */
|
|
190
|
+
function presentationOrder(root, targets) {
|
|
191
|
+
const order = new Map();
|
|
192
|
+
elementChildren(find(root, Tag.SLIDE_ID_LIST), Tag.SLIDE_ID).forEach((id, index) => {
|
|
193
|
+
const target = targets.get(id.getAttribute(Attr.REL_ID));
|
|
194
|
+
if (target)
|
|
195
|
+
order.set(target, index + 1);
|
|
196
|
+
});
|
|
197
|
+
return order;
|
|
198
|
+
}
|
|
199
|
+
function readColorScheme(themeRoot) {
|
|
200
|
+
const scheme = find(themeRoot, Tag.THEME_ELEMENTS, Tag.COLOR_SCHEME);
|
|
201
|
+
const colors = { name: scheme.getAttribute(Attr.NAME) };
|
|
202
|
+
for (const slot of SCHEME_SLOTS) {
|
|
203
|
+
const color = elementChildren(find(scheme, schemeSlotElement(slot)))[0];
|
|
204
|
+
const rgb = color && resolveColor(color, {}, {});
|
|
205
|
+
if (rgb)
|
|
206
|
+
colors[slot] = hex(rgb);
|
|
207
|
+
}
|
|
208
|
+
return colors;
|
|
209
|
+
}
|
|
210
|
+
function readFontScheme(themeRoot) {
|
|
211
|
+
const scheme = find(themeRoot, Tag.THEME_ELEMENTS, Tag.FONT_SCHEME);
|
|
212
|
+
const fonts = { name: scheme.getAttribute(Attr.NAME) };
|
|
213
|
+
const major = find(scheme, Tag.MAJOR_FONT, Tag.LATIN)?.getAttribute(Attr.TYPEFACE);
|
|
214
|
+
const minor = find(scheme, Tag.MINOR_FONT, Tag.LATIN)?.getAttribute(Attr.TYPEFACE);
|
|
215
|
+
if (major)
|
|
216
|
+
fonts.major = major;
|
|
217
|
+
if (minor)
|
|
218
|
+
fonts.minor = minor;
|
|
219
|
+
return fonts;
|
|
220
|
+
}
|
|
221
|
+
/** Light, dark or image, from the first background the slide, its layout or its master declares. */
|
|
222
|
+
async function classifyBackground(presentation, parts, themeRoot, scheme) {
|
|
223
|
+
let background;
|
|
224
|
+
for (const part of [parts.slide, parts.layout, parts.master]) {
|
|
225
|
+
background = part && find(await presentation.xml(part), Tag.COMMON_SLIDE_DATA, Tag.BACKGROUND);
|
|
226
|
+
if (background)
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
if (!background)
|
|
230
|
+
return Background.Unknown;
|
|
231
|
+
const map = await colorMap(presentation, parts);
|
|
232
|
+
const { fill, placeholder } = backgroundFill(background, themeRoot, scheme, map);
|
|
233
|
+
if (!fill)
|
|
234
|
+
return Background.Unknown;
|
|
235
|
+
if (fill.tagName === Tag.PICTURE_FILL)
|
|
236
|
+
return Background.Image;
|
|
237
|
+
const rgb = fillColor(fill, scheme, map, placeholder);
|
|
238
|
+
if (!rgb)
|
|
239
|
+
return Background.Unknown;
|
|
240
|
+
return relativeLuminance(rgb) > LIGHT_LUMINANCE ? Background.Light : Background.Dark;
|
|
241
|
+
}
|
|
242
|
+
/** The master's colour map, with the layout's then the slide's overrides applied. */
|
|
243
|
+
async function colorMap(presentation, parts) {
|
|
244
|
+
const map = attributes(find(await presentation.xml(parts.master), Tag.COLOR_MAP));
|
|
245
|
+
for (const part of [parts.layout, parts.slide]) {
|
|
246
|
+
if (!part)
|
|
247
|
+
continue;
|
|
248
|
+
const override = find(await presentation.xml(part), Tag.COLOR_MAP_OVERRIDE, Tag.OVERRIDE_COLOR_MAPPING);
|
|
249
|
+
if (override)
|
|
250
|
+
Object.assign(map, attributes(override));
|
|
251
|
+
}
|
|
252
|
+
return map;
|
|
253
|
+
}
|
|
254
|
+
/** The background's fill element, and the colour it gives `phClr` when it comes from a theme style. */
|
|
255
|
+
function backgroundFill(background, themeRoot, scheme, map) {
|
|
256
|
+
const props = find(background, Tag.BACKGROUND_PROPS);
|
|
257
|
+
if (props)
|
|
258
|
+
return { fill: elementChildren(props)[0] };
|
|
259
|
+
const ref = find(background, Tag.BACKGROUND_REF);
|
|
260
|
+
if (!ref)
|
|
261
|
+
return {};
|
|
262
|
+
const index = Number(ref.getAttribute(Attr.INDEX) ?? 0);
|
|
263
|
+
const [list, first] = index >= BACKGROUND_FILL_INDEX
|
|
264
|
+
? [Tag.BACKGROUND_FILL_STYLES, BACKGROUND_FILL_INDEX]
|
|
265
|
+
: [Tag.FILL_STYLES, FILL_INDEX];
|
|
266
|
+
const styles = elementChildren(find(themeRoot, Tag.THEME_ELEMENTS, Tag.FORMAT_SCHEME, list));
|
|
267
|
+
const fill = styles[index - first];
|
|
268
|
+
if (!fill)
|
|
269
|
+
return {};
|
|
270
|
+
const color = elementChildren(ref)[0];
|
|
271
|
+
return { fill, placeholder: color && resolveColor(color, scheme, map) };
|
|
272
|
+
}
|
|
273
|
+
/** A solid fill's colour, or a gradient's stops averaged. */
|
|
274
|
+
function fillColor(fill, scheme, map, placeholder) {
|
|
275
|
+
if (fill.tagName === Tag.SOLID_FILL) {
|
|
276
|
+
const color = elementChildren(fill)[0];
|
|
277
|
+
return color && resolveColor(color, scheme, map, placeholder);
|
|
278
|
+
}
|
|
279
|
+
if (fill.tagName === Tag.GRADIENT_FILL) {
|
|
280
|
+
const stops = elementChildren(find(fill, Tag.GRADIENT_STOPS), Tag.GRADIENT_STOP)
|
|
281
|
+
.map((stop) => elementChildren(stop)[0])
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.map((color) => resolveColor(color, scheme, map, placeholder))
|
|
284
|
+
.filter((rgb) => rgb !== undefined);
|
|
285
|
+
if (stops.length)
|
|
286
|
+
return [0, 1, 2].map((i) => stops.reduce((sum, rgb) => sum + rgb[i], 0) / stops.length);
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
// ── Shapes ────────────────────────────────────────────────────────────────────
|
|
291
|
+
const SHAPE_ELEMENTS = new Set([
|
|
292
|
+
Tag.SHAPE,
|
|
293
|
+
Tag.PICTURE,
|
|
294
|
+
Tag.GRAPHIC_FRAME,
|
|
295
|
+
Tag.GROUP,
|
|
296
|
+
Tag.CONNECTOR,
|
|
297
|
+
]);
|
|
298
|
+
const GROUP_SEPARATOR = "/";
|
|
299
|
+
const PARAGRAPH_MARK = "¶";
|
|
300
|
+
const LINE_BREAK_MARK = "↵";
|
|
301
|
+
const TEXT_PREVIEW_CHARS = 80;
|
|
302
|
+
const TEXT_BOX_ON = "1";
|
|
303
|
+
/** A placeholder with no `type` is a body placeholder. */
|
|
304
|
+
const DEFAULT_PLACEHOLDER_TYPE = "body";
|
|
305
|
+
/** Placeholder types that stand in for one another when a layout or master is searched for a frame. */
|
|
306
|
+
const EQUIVALENT_PLACEHOLDER = { ctrTitle: "title", subTitle: "body", obj: "body" };
|
|
307
|
+
/** The slide's shapes in document order, groups' members after the group, placeholder frames inherited. */
|
|
308
|
+
async function readShapes(presentation, parts) {
|
|
309
|
+
const tree = find(await presentation.xml(parts.slide), Tag.COMMON_SLIDE_DATA, Tag.SHAPE_TREE);
|
|
310
|
+
const found = walkShapes(tree, "");
|
|
311
|
+
const sources = [
|
|
312
|
+
[FrameSource.Layout, parts.layout ? placeholderFrames(await presentation.xml(parts.layout)) : []],
|
|
313
|
+
[FrameSource.Master, placeholderFrames(await presentation.xml(parts.master))],
|
|
314
|
+
];
|
|
315
|
+
for (const { shape, el } of found) {
|
|
316
|
+
const placeholder = el.tagName === Tag.SHAPE && placeholderOf(el);
|
|
317
|
+
if (shape.frame || !placeholder)
|
|
318
|
+
continue;
|
|
319
|
+
for (const [source, frames] of sources) {
|
|
320
|
+
const frame = matchingFrame(placeholder, frames);
|
|
321
|
+
if (frame) {
|
|
322
|
+
shape.frame = frame;
|
|
323
|
+
shape.frameFrom = source;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Set fields only, in a fixed order, and the text cut to a preview.
|
|
329
|
+
return found.map(({ shape: { name, kind, frame, frameFrom, rows, cols, text } }) => ({
|
|
330
|
+
name,
|
|
331
|
+
kind,
|
|
332
|
+
...(frame && { frame }),
|
|
333
|
+
...(frameFrom && { frameFrom }),
|
|
334
|
+
...(rows !== undefined && { rows, cols }),
|
|
335
|
+
text: Array.from(text).slice(0, TEXT_PREVIEW_CHARS).join(""),
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
function walkShapes(container, prefix) {
|
|
339
|
+
return elementChildren(container)
|
|
340
|
+
.filter((el) => SHAPE_ELEMENTS.has(el.tagName))
|
|
341
|
+
.flatMap((el) => {
|
|
342
|
+
const shape = describeShape(el, prefix);
|
|
343
|
+
const members = el.tagName === Tag.GROUP ? walkShapes(el, shape.name + GROUP_SEPARATOR) : [];
|
|
344
|
+
return [{ shape, el }, ...members];
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function describeShape(el, prefix) {
|
|
348
|
+
const table = collectElements(el, Tag.TABLE)[0];
|
|
349
|
+
return {
|
|
350
|
+
name: prefix + (find(nonVisualProps(el), Tag.NON_VISUAL_PROPS)?.getAttribute(Attr.NAME) ?? ""),
|
|
351
|
+
kind: shapeKind(el, table),
|
|
352
|
+
frame: shapeFrame(el),
|
|
353
|
+
rows: table && elementChildren(table, Tag.TABLE_ROW).length,
|
|
354
|
+
cols: table && elementChildren(find(table, Tag.TABLE_GRID), Tag.GRID_COLUMN).length,
|
|
355
|
+
text: el.tagName === Tag.GROUP ? "" : shapeText(el),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function shapeKind(el, table) {
|
|
359
|
+
switch (el.tagName) {
|
|
360
|
+
case Tag.PICTURE:
|
|
361
|
+
return ShapeKind.Image;
|
|
362
|
+
case Tag.GROUP:
|
|
363
|
+
return ShapeKind.Group;
|
|
364
|
+
case Tag.GRAPHIC_FRAME:
|
|
365
|
+
return table ? ShapeKind.Table : ShapeKind.Other;
|
|
366
|
+
case Tag.SHAPE: {
|
|
367
|
+
const isTextBox = find(el, Tag.NON_VISUAL_SHAPE, Tag.NON_VISUAL_SHAPE_PROPS)?.getAttribute(Attr.TEXT_BOX) === TEXT_BOX_ON;
|
|
368
|
+
return placeholderOf(el) || isTextBox || shapeText(el) ? ShapeKind.Text : ShapeKind.Other;
|
|
369
|
+
}
|
|
370
|
+
default:
|
|
371
|
+
return ShapeKind.Other;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** The shape's text, `¶` between paragraphs and `↵` for line breaks, whitespace collapsed. */
|
|
375
|
+
function shapeText(el) {
|
|
376
|
+
const paragraphs = collectElements(el, Tag.PARAGRAPH).map((paragraph) => elementChildren(paragraph)
|
|
377
|
+
.flatMap((child) => child.tagName === Tag.LINE_BREAK
|
|
378
|
+
? [LINE_BREAK_MARK]
|
|
379
|
+
: collectElements(child, Tag.TEXT).map((text) => text.textContent ?? ""))
|
|
380
|
+
.join(" ")
|
|
381
|
+
.split(/\s+/)
|
|
382
|
+
.filter(Boolean)
|
|
383
|
+
.join(" "));
|
|
384
|
+
return paragraphs.filter(Boolean).join(` ${PARAGRAPH_MARK} `);
|
|
385
|
+
}
|
|
386
|
+
/** The shape's own frame, if it declares one. */
|
|
387
|
+
function shapeFrame(el) {
|
|
388
|
+
const transform = (el.tagName === Tag.GRAPHIC_FRAME
|
|
389
|
+
? find(el, Tag.GRAPHIC_FRAME_TRANSFORM)
|
|
390
|
+
: find(el, Tag.SHAPE_PROPS, Tag.TRANSFORM)) ?? find(el, Tag.GROUP_PROPS, Tag.TRANSFORM);
|
|
391
|
+
const offset = find(transform, Tag.OFFSET);
|
|
392
|
+
const extent = find(transform, Tag.EXTENT);
|
|
393
|
+
if (!offset || !extent)
|
|
394
|
+
return undefined;
|
|
395
|
+
return {
|
|
396
|
+
x: Number(offset.getAttribute(Attr.X)),
|
|
397
|
+
y: Number(offset.getAttribute(Attr.Y)),
|
|
398
|
+
cx: Number(extent.getAttribute(Attr.CX)),
|
|
399
|
+
cy: Number(extent.getAttribute(Attr.CY)),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function nonVisualProps(el) {
|
|
403
|
+
return elementChildren(el).find((child) => child.tagName.startsWith(NON_VISUAL_PREFIX));
|
|
404
|
+
}
|
|
405
|
+
function placeholderOf(el) {
|
|
406
|
+
return find(nonVisualProps(el), Tag.APP_PROPS, Tag.PLACEHOLDER);
|
|
407
|
+
}
|
|
408
|
+
/** Every placeholder in a layout or master, with its type, index and frame. */
|
|
409
|
+
function placeholderFrames(root) {
|
|
410
|
+
return collectElements(root, Tag.SHAPE).flatMap((el) => {
|
|
411
|
+
const placeholder = placeholderOf(el);
|
|
412
|
+
if (!placeholder)
|
|
413
|
+
return [];
|
|
414
|
+
const frame = shapeFrame(el);
|
|
415
|
+
return [{ type: placeholderType(placeholder), idx: placeholder.getAttribute(Attr.INDEX) ?? undefined, frame }];
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/** The frame of the placeholder with the same index, or else of an equivalent type. */
|
|
419
|
+
function matchingFrame(placeholder, frames) {
|
|
420
|
+
const idx = placeholder.getAttribute(Attr.INDEX);
|
|
421
|
+
const type = equivalentType(placeholderType(placeholder));
|
|
422
|
+
return ((idx ? frames.find((candidate) => candidate.idx === idx && candidate.frame)?.frame : undefined) ??
|
|
423
|
+
frames.find((candidate) => equivalentType(candidate.type) === type && candidate.frame)?.frame);
|
|
424
|
+
}
|
|
425
|
+
function placeholderType(placeholder) {
|
|
426
|
+
return placeholder.getAttribute(Attr.TYPE) || DEFAULT_PLACEHOLDER_TYPE;
|
|
427
|
+
}
|
|
428
|
+
function equivalentType(type) {
|
|
429
|
+
return EQUIVALENT_PLACEHOLDER[type] ?? type;
|
|
430
|
+
}
|
|
431
|
+
// ── Duplicates ────────────────────────────────────────────────────────────────
|
|
432
|
+
/** Frames closer than this (one point, in EMU) count as equal. */
|
|
433
|
+
const GEOMETRY_TOLERANCE = 12700;
|
|
434
|
+
/** Groups of slide numbers whose shapes have the same kinds and frames. */
|
|
435
|
+
function findDuplicates(slides) {
|
|
436
|
+
const groups = [];
|
|
437
|
+
for (const slide of slides) {
|
|
438
|
+
if (!slide.shapes.length)
|
|
439
|
+
continue;
|
|
440
|
+
const geometry = slide.shapes.flatMap(({ kind, frame }) => (frame ? [{ kind, frame }] : []));
|
|
441
|
+
const group = groups.find((candidate) => sameGeometry(geometry, candidate.geometry));
|
|
442
|
+
if (group)
|
|
443
|
+
group.slides.push(slide.slide);
|
|
444
|
+
else
|
|
445
|
+
groups.push({ geometry, slides: [slide.slide] });
|
|
446
|
+
}
|
|
447
|
+
return groups.filter((group) => group.slides.length > 1).map((group) => group.slides);
|
|
448
|
+
}
|
|
449
|
+
/** True when the two sets of (kind, frame) match one for one, within the tolerance. */
|
|
450
|
+
function sameGeometry(a, b) {
|
|
451
|
+
if (a.length !== b.length)
|
|
452
|
+
return false;
|
|
453
|
+
const remaining = [...b];
|
|
454
|
+
for (const { kind, frame } of a) {
|
|
455
|
+
const match = remaining.findIndex((other) => other.kind === kind &&
|
|
456
|
+
Object.keys(frame).every((key) => Math.abs(frame[key] - other.frame[key]) <= GEOMETRY_TOLERANCE));
|
|
457
|
+
if (match === -1)
|
|
458
|
+
return false;
|
|
459
|
+
remaining.splice(match, 1);
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
// ── Colours ───────────────────────────────────────────────────────────────────
|
|
464
|
+
/**
|
|
465
|
+
* An `a:srgbClr` / `a:schemeClr` / `a:sysClr` / `a:prstClr` as RGB, modifiers applied.
|
|
466
|
+
* Scheme slots are looked up as named (a scheme can be deliberately inverted); only
|
|
467
|
+
* `bg1`/`tx1`/`bg2`/`tx2`, which aren't slots, go through the colour map.
|
|
468
|
+
*/
|
|
469
|
+
function resolveColor(el, scheme, map, placeholder) {
|
|
470
|
+
let rgb;
|
|
471
|
+
switch (el.tagName) {
|
|
472
|
+
case Tag.RGB_COLOR:
|
|
473
|
+
rgb = parseHex(el.getAttribute(Attr.VALUE));
|
|
474
|
+
break;
|
|
475
|
+
case Tag.SYSTEM_COLOR:
|
|
476
|
+
rgb = parseHex(el.getAttribute(Attr.LAST_COLOR) || SYSTEM_COLOR_FALLBACK);
|
|
477
|
+
break;
|
|
478
|
+
case Tag.PRESET_COLOR:
|
|
479
|
+
rgb = PRESET_COLORS[el.getAttribute(Attr.VALUE)];
|
|
480
|
+
break;
|
|
481
|
+
case Tag.SCHEME_COLOR: {
|
|
482
|
+
const value = el.getAttribute(Attr.VALUE);
|
|
483
|
+
if (value === PLACEHOLDER_COLOR)
|
|
484
|
+
return placeholder;
|
|
485
|
+
const slotHex = scheme[(map[value] ?? value)];
|
|
486
|
+
rgb = slotHex ? parseHex(slotHex.slice(HEX_PREFIX.length)) : undefined;
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
default:
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
return rgb && applyModifiers(rgb, el);
|
|
493
|
+
}
|
|
494
|
+
/** Approximate `lumMod` / `lumOff` / `tint` / `shade`, so "accent1 at 20%" reads correctly. */
|
|
495
|
+
function applyModifiers(rgb, el) {
|
|
496
|
+
let [r, g, b] = rgb.map((c) => c / CHANNEL_MAX);
|
|
497
|
+
for (const mod of elementChildren(el)) {
|
|
498
|
+
const value = Number(mod.getAttribute(Attr.VALUE) || 0) / PERCENT;
|
|
499
|
+
if (mod.tagName === Tag.LUMINANCE_MOD || mod.tagName === Tag.LUMINANCE_OFF) {
|
|
500
|
+
const [h, l, s] = rgbToHls(r, g, b);
|
|
501
|
+
const lightness = mod.tagName === Tag.LUMINANCE_MOD ? l * value : l + value;
|
|
502
|
+
[r, g, b] = hlsToRgb(h, Math.min(Math.max(lightness, 0), 1), s);
|
|
503
|
+
}
|
|
504
|
+
else if (mod.tagName === Tag.TINT) {
|
|
505
|
+
[r, g, b] = [r, g, b].map((c) => 1 - (1 - c) * value);
|
|
506
|
+
}
|
|
507
|
+
else if (mod.tagName === Tag.SHADE) {
|
|
508
|
+
[r, g, b] = [r, g, b].map((c) => c * value);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return [r, g, b].map((c) => c * CHANNEL_MAX);
|
|
512
|
+
}
|
|
513
|
+
function relativeLuminance(rgb) {
|
|
514
|
+
return rgb.reduce((sum, channel, i) => {
|
|
515
|
+
const v = channel / CHANNEL_MAX;
|
|
516
|
+
const linear = v <= WCAG.LINEAR_BELOW ? v / WCAG.LINEAR_DIVISOR : ((v + WCAG.OFFSET) / WCAG.SCALE) ** WCAG.GAMMA;
|
|
517
|
+
return sum + WCAG.WEIGHTS[i] * linear;
|
|
518
|
+
}, 0);
|
|
519
|
+
}
|
|
520
|
+
function rgbToHls(r, g, b) {
|
|
521
|
+
const max = Math.max(r, g, b);
|
|
522
|
+
const min = Math.min(r, g, b);
|
|
523
|
+
const l = (max + min) / 2;
|
|
524
|
+
if (max === min)
|
|
525
|
+
return [0, l, 0];
|
|
526
|
+
const range = max - min;
|
|
527
|
+
const s = l <= 0.5 ? range / (max + min) : range / (2 - max - min);
|
|
528
|
+
const [rc, gc, bc] = [(max - r) / range, (max - g) / range, (max - b) / range];
|
|
529
|
+
const h = r === max ? bc - gc : g === max ? 2 + rc - bc : 4 + gc - rc;
|
|
530
|
+
return [mod1(h / 6), l, s];
|
|
531
|
+
}
|
|
532
|
+
function hlsToRgb(h, l, s) {
|
|
533
|
+
if (s === 0)
|
|
534
|
+
return [l, l, l];
|
|
535
|
+
const m2 = l <= 0.5 ? l * (1 + s) : l + s - l * s;
|
|
536
|
+
const m1 = 2 * l - m2;
|
|
537
|
+
const channel = (hue) => {
|
|
538
|
+
const t = mod1(hue);
|
|
539
|
+
if (t < 1 / 6)
|
|
540
|
+
return m1 + (m2 - m1) * t * 6;
|
|
541
|
+
if (t < 0.5)
|
|
542
|
+
return m2;
|
|
543
|
+
if (t < 2 / 3)
|
|
544
|
+
return m1 + (m2 - m1) * (2 / 3 - t) * 6;
|
|
545
|
+
return m1;
|
|
546
|
+
};
|
|
547
|
+
return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];
|
|
548
|
+
}
|
|
549
|
+
/** `x` wrapped into [0, 1). */
|
|
550
|
+
function mod1(x) {
|
|
551
|
+
return ((x % 1) + 1) % 1;
|
|
552
|
+
}
|
|
553
|
+
/** `RRGGBB` → RGB. */
|
|
554
|
+
function parseHex(hexValue) {
|
|
555
|
+
return [0, 1, 2].map((i) => Number.parseInt(hexValue.slice(i * HEX_DIGITS, (i + 1) * HEX_DIGITS), HEX_RADIX));
|
|
556
|
+
}
|
|
557
|
+
/** `#RRGGBB`, each channel rounded half to even. */
|
|
558
|
+
function hex(rgb) {
|
|
559
|
+
const digits = rgb.map((c) => roundHalfEven(c).toString(HEX_RADIX).toUpperCase().padStart(HEX_DIGITS, "0"));
|
|
560
|
+
return HEX_PREFIX + digits.join("");
|
|
561
|
+
}
|
|
562
|
+
function roundHalfEven(x) {
|
|
563
|
+
const floor = Math.floor(x);
|
|
564
|
+
const diff = x - floor;
|
|
565
|
+
if (diff !== 0.5)
|
|
566
|
+
return Math.round(x);
|
|
567
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
568
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { templateKeys, } from "../index.js";
|
|
2
|
+
import { ASSETS_FILE, jsonFile } from "./files.js";
|
|
3
3
|
/**
|
|
4
4
|
* Flatten a compiler parameter to the manifest entries advertised to AI authors.
|
|
5
5
|
* A parameter has no top-level key — its template's placeholders are the keys, so
|
|
@@ -33,16 +33,5 @@ export function generateManifest(config) {
|
|
|
33
33
|
return ml;
|
|
34
34
|
});
|
|
35
35
|
const manifest = { layouts, assets: ASSETS_FILE };
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
/** The catalog document: searched by name, never read whole. */
|
|
39
|
-
export function generateAssetCatalog(config) {
|
|
40
|
-
const assets = {};
|
|
41
|
-
for (const [category, entries] of Object.entries(config.assets)) {
|
|
42
|
-
assets[category] = {};
|
|
43
|
-
for (const [name, entry] of Object.entries(entries)) {
|
|
44
|
-
assets[category][name] = { path: entry.path, type: entry.type, description: entry.description };
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return JSON.stringify(assets, null, 2);
|
|
36
|
+
return jsonFile(manifest);
|
|
48
37
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media: copy the images a template uses, on its masters, layouts and slides, out
|
|
3
|
+
* of the .pptx under their original media filenames, as raw material for a theme's
|
|
4
|
+
* image catalog.
|
|
5
|
+
*/
|
|
6
|
+
import { type Presentation } from "./pptx.js";
|
|
7
|
+
/** What happened to one referenced image. */
|
|
8
|
+
export declare const MediaOutcome: {
|
|
9
|
+
readonly Copied: "copied";
|
|
10
|
+
/** A file of that name was already in the output folder, and was left untouched. */
|
|
11
|
+
readonly Present: "present";
|
|
12
|
+
/** Byte-identical to an image already handled under another name, so not copied twice. */
|
|
13
|
+
readonly Duplicate: "duplicate";
|
|
14
|
+
/** Referenced by a relationship but absent from the package. */
|
|
15
|
+
readonly Missing: "missing";
|
|
16
|
+
};
|
|
17
|
+
export type MediaOutcome = (typeof MediaOutcome)[keyof typeof MediaOutcome];
|
|
18
|
+
export type MediaImage = {
|
|
19
|
+
/** The media filename, e.g. `image3.png`. */
|
|
20
|
+
file: string;
|
|
21
|
+
/** The masters, layouts and slides that use it, e.g. `slideMaster1`, `slideLayout4`, `slide3`. */
|
|
22
|
+
usedBy: string[];
|
|
23
|
+
outcome: MediaOutcome;
|
|
24
|
+
/** Pixel size, when the format is readable. */
|
|
25
|
+
size?: {
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
};
|
|
29
|
+
/** For a duplicate: the file it is identical to. */
|
|
30
|
+
duplicateOf?: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Copy every image the presentation's masters, layouts and slides use into
|
|
34
|
+
* `outDir` (created if missing), and describe each in first-use order: masters
|
|
35
|
+
* first, then layouts, then slides, each in number order. Never overwrites: an
|
|
36
|
+
* image whose filename is already there is left as it is.
|
|
37
|
+
*/
|
|
38
|
+
export declare function copyImages(presentation: Presentation, outDir: string): Promise<MediaImage[]>;
|