@seriousgaming/deck 0.0.0-stage → 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +1 -0
  3. package/README.md +20 -2
  4. package/lib/Deck.d.ts +49 -0
  5. package/lib/Deck.js +148 -0
  6. package/lib/Slide.d.ts +27 -0
  7. package/lib/Slide.js +20 -0
  8. package/lib/Step.d.ts +25 -0
  9. package/lib/Step.js +24 -0
  10. package/lib/brand.d.ts +39 -0
  11. package/lib/brand.js +75 -0
  12. package/lib/context.d.ts +54 -0
  13. package/lib/context.js +32 -0
  14. package/lib/controls.d.ts +18 -0
  15. package/lib/controls.js +15 -0
  16. package/lib/export/collect.d.ts +32 -0
  17. package/lib/export/collect.js +312 -0
  18. package/lib/export/index.d.ts +28 -0
  19. package/lib/export/index.js +92 -0
  20. package/lib/export/ir.d.ts +85 -0
  21. package/lib/export/ir.js +1 -0
  22. package/lib/export/pptx.d.ts +12 -0
  23. package/lib/export/pptx.js +125 -0
  24. package/lib/export/use-deck-export.d.ts +19 -0
  25. package/lib/export/use-deck-export.js +97 -0
  26. package/lib/index.d.ts +17 -0
  27. package/lib/index.js +15 -0
  28. package/lib/keyboard.d.ts +18 -0
  29. package/lib/keyboard.js +59 -0
  30. package/lib/memory.d.ts +31 -0
  31. package/lib/memory.js +60 -0
  32. package/lib/navigation.d.ts +26 -0
  33. package/lib/navigation.js +93 -0
  34. package/lib/overview.d.ts +11 -0
  35. package/lib/overview.js +22 -0
  36. package/lib/primitives/chart.d.ts +40 -0
  37. package/lib/primitives/chart.js +116 -0
  38. package/lib/primitives/media.d.ts +14 -0
  39. package/lib/primitives/media.js +5 -0
  40. package/lib/primitives/table.d.ts +11 -0
  41. package/lib/primitives/table.js +24 -0
  42. package/lib/primitives/text.d.ts +33 -0
  43. package/lib/primitives/text.js +30 -0
  44. package/lib/styles.d.ts +10 -0
  45. package/lib/styles.js +192 -0
  46. package/package.json +54 -3
@@ -0,0 +1,54 @@
1
+ import { type Brand } from './brand.js';
2
+ import type { Transition } from './Slide.js';
3
+ /** Every slide is laid out on this canvas and scaled to fit wherever it is shown. */
4
+ export declare const SLIDE_WIDTH = 1920;
5
+ export declare const SLIDE_HEIGHT = 1080;
6
+ /**
7
+ * How a slide is being rendered.
8
+ *
9
+ * `live` is the presenter's view: steps reveal one at a time and things move.
10
+ * `static` is every other render (print, PDF, PowerPoint and PNG export, the
11
+ * overview grid), and shows each slide as it ends up, fully built and still.
12
+ */
13
+ export type RenderMode = 'live' | 'static';
14
+ /** A step value meaning 'everything on the slide is shown'. */
15
+ export declare const ALL_STEPS: number;
16
+ export interface DeckState {
17
+ slideIndex: number;
18
+ slideCount: number;
19
+ /** Steps revealed on the current slide; 0 before the first. */
20
+ step: number;
21
+ next: () => void;
22
+ prev: () => void;
23
+ goTo: (slideIndex: number, step?: number) => void;
24
+ }
25
+ export declare const DeckContext: import("react").Context<DeckState | null>;
26
+ export interface SlideState {
27
+ index: number;
28
+ mode: RenderMode;
29
+ /** Steps revealed on this slide. {@link ALL_STEPS} in a static render. */
30
+ step: number;
31
+ isActive: boolean;
32
+ /** Text direction of the deck, which the exporter carries into PowerPoint. */
33
+ dir: 'ltr' | 'rtl';
34
+ /** Called by each `Step` so the deck knows how many clicks a slide takes. */
35
+ registerStep: (at: number) => () => void;
36
+ /** Called by `Slide` so the deck can carry its notes into an export. */
37
+ registerNotes: (notes: string | undefined) => void;
38
+ /** Transition a slide uses when it names none. */
39
+ defaultTransition: Transition;
40
+ /** Which way the presenter moved to reach this slide. */
41
+ direction: 'forward' | 'backward';
42
+ }
43
+ export declare const SlideContext: import("react").Context<SlideState | null>;
44
+ /** Where the deck is, and how to move it. Only inside a `Deck`. */
45
+ export declare function useDeck(): DeckState;
46
+ /**
47
+ * The slide this component sits on: how far it is built, and whether it is a
48
+ * live or static render. Custom animations read `step` and `mode` from here, and
49
+ * should show their final state whenever `mode` is `static`.
50
+ */
51
+ export declare function useSlide(): SlideState;
52
+ export declare const BrandContext: import("react").Context<Brand>;
53
+ /** The deck's resolved brand, for components that draw in its colours. */
54
+ export declare function useBrand(): Brand;
package/lib/context.js ADDED
@@ -0,0 +1,32 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { defaultBrand } from './brand.js';
3
+ /** Every slide is laid out on this canvas and scaled to fit wherever it is shown. */
4
+ export const SLIDE_WIDTH = 1920;
5
+ export const SLIDE_HEIGHT = 1080;
6
+ /** A step value meaning 'everything on the slide is shown'. */
7
+ export const ALL_STEPS = Number.POSITIVE_INFINITY;
8
+ export const DeckContext = createContext(null);
9
+ export const SlideContext = createContext(null);
10
+ /** Where the deck is, and how to move it. Only inside a `Deck`. */
11
+ export function useDeck() {
12
+ const deck = useContext(DeckContext);
13
+ if (!deck)
14
+ throw new Error('useDeck must be used inside <Deck>');
15
+ return deck;
16
+ }
17
+ /**
18
+ * The slide this component sits on: how far it is built, and whether it is a
19
+ * live or static render. Custom animations read `step` and `mode` from here, and
20
+ * should show their final state whenever `mode` is `static`.
21
+ */
22
+ export function useSlide() {
23
+ const slide = useContext(SlideContext);
24
+ if (!slide)
25
+ throw new Error('useSlide must be used inside <Slide>');
26
+ return slide;
27
+ }
28
+ export const BrandContext = createContext(defaultBrand);
29
+ /** The deck's resolved brand, for components that draw in its colours. */
30
+ export function useBrand() {
31
+ return useContext(BrandContext);
32
+ }
@@ -0,0 +1,18 @@
1
+ import type { ExportFormat } from './export/use-deck-export.js';
2
+ interface ControlsProps {
3
+ dir: 'ltr' | 'rtl';
4
+ slideIndex: number;
5
+ slideCount: number;
6
+ atStart: boolean;
7
+ next: () => void;
8
+ prev: () => void;
9
+ toggleOverview: () => void;
10
+ toggleFullscreen: () => void;
11
+ exportFormats: ExportFormat[];
12
+ menuOpen: boolean;
13
+ setMenuOpen: (update: (open: boolean) => boolean) => void;
14
+ runExport: (format: ExportFormat) => Promise<void>;
15
+ }
16
+ /** The presenter's bar: previous, position, next, overview, fullscreen and export. */
17
+ export declare function Controls({ dir, slideIndex, slideCount, atStart, next, prev, toggleOverview, toggleFullscreen, exportFormats, menuOpen, setMenuOpen, runExport, }: ControlsProps): import("react").JSX.Element;
18
+ export {};
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const EXPORT_LABELS = {
3
+ pdf: 'PDF (print, save as PDF)',
4
+ pptx: 'PowerPoint (editable)',
5
+ png: 'This slide as PNG',
6
+ };
7
+ /** The presenter's bar: previous, position, next, overview, fullscreen and export. */
8
+ export function Controls({ dir, slideIndex, slideCount, atStart, next, prev, toggleOverview, toggleFullscreen, exportFormats, menuOpen, setMenuOpen, runExport, }) {
9
+ return (_jsxs("div", { className: "deck-controls", onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", onClick: prev, "aria-label": "Previous", disabled: atStart, children: dir === 'rtl' ? '→' : '←' }), _jsxs("span", { className: "deck-position", "aria-live": "polite", children: [slideIndex + 1, " / ", slideCount] }), _jsx("button", { type: "button", onClick: next, "aria-label": "Next", children: dir === 'rtl' ? '←' : '→' }), _jsx("button", { type: "button", onClick: toggleOverview, "aria-label": "Overview", children: "\u25A6" }), _jsx("button", { type: "button", onClick: toggleFullscreen, "aria-label": "Fullscreen", children: "\u26F6" }), exportFormats.length > 0 && (_jsxs("span", { style: { position: 'relative' }, children: [_jsx("button", { type: "button", onClick: () => setMenuOpen((open) => !open), "aria-expanded": menuOpen, children: "Export" }), menuOpen && (_jsx("span", { className: "deck-menu", role: "menu", children: ['pdf', 'pptx', 'png']
10
+ .filter((format) => exportFormats.includes(format))
11
+ .map((format) => (_jsx("button", { type: "button", role: "menuitem", onClick: () => {
12
+ setMenuOpen(() => false);
13
+ void runExport(format);
14
+ }, children: EXPORT_LABELS[format] }, format))) }))] }))] }));
15
+ }
@@ -0,0 +1,32 @@
1
+ import type { Box, ExportElement, Paragraph } from './ir.js';
2
+ /** The attribute a primitive carries to be exported as a native object. */
3
+ export declare const NATIVE_ATTRIBUTE = "data-deck-native";
4
+ /** Set on a native element the exporter could not describe, so it stays in the picture. */
5
+ export declare const NATIVE_FAILED_ATTRIBUTE = "data-deck-native-failed";
6
+ /**
7
+ * Describe every native element on one slide, in document order.
8
+ *
9
+ * A native element nested inside another is left to its ancestor, so a
10
+ * heading inside a native card is not exported twice. An element that cannot
11
+ * be described is marked {@link NATIVE_FAILED_ATTRIBUTE} and left out, which
12
+ * keeps it in the slide's background picture rather than losing it.
13
+ */
14
+ export declare function collectNativeElements(slide: HTMLElement): Promise<ExportElement[]>;
15
+ /**
16
+ * Walk an element's text into paragraphs of styled runs.
17
+ *
18
+ * A block-level element starts a paragraph and a list item makes it a bullet,
19
+ * which is the whole of what PowerPoint can hold. Inline formatting comes from
20
+ * each text node's computed style, so a `<strong>` or a styled span carries
21
+ * through without the primitives knowing about it.
22
+ */
23
+ export declare function extractParagraphs(root: HTMLElement): Paragraph[];
24
+ /**
25
+ * The first family in a CSS font stack, as an office suite would name it.
26
+ *
27
+ * Self-hosted variable fonts register as 'X Variable', which no installed font
28
+ * is called; the static family of the same name is what a viewer has, if any.
29
+ */
30
+ export declare function fontName(stack: string): string;
31
+ /** Where an `object-fit: contain` picture actually lands inside its box. */
32
+ export declare function fitContain(box: Box, naturalWidth: number, naturalHeight: number): Box;
@@ -0,0 +1,312 @@
1
+ import { toHex } from '../brand.js';
2
+ /** The attribute a primitive carries to be exported as a native object. */
3
+ export const NATIVE_ATTRIBUTE = 'data-deck-native';
4
+ /** Set on a native element the exporter could not describe, so it stays in the picture. */
5
+ export const NATIVE_FAILED_ATTRIBUTE = 'data-deck-native-failed';
6
+ const BLOCK_DISPLAYS = new Set([
7
+ 'block',
8
+ 'flex',
9
+ 'grid',
10
+ 'list-item',
11
+ 'table',
12
+ 'flow-root',
13
+ 'table-row',
14
+ 'table-cell',
15
+ ]);
16
+ /**
17
+ * Describe every native element on one slide, in document order.
18
+ *
19
+ * A native element nested inside another is left to its ancestor, so a
20
+ * heading inside a native card is not exported twice. An element that cannot
21
+ * be described is marked {@link NATIVE_FAILED_ATTRIBUTE} and left out, which
22
+ * keeps it in the slide's background picture rather than losing it.
23
+ */
24
+ export async function collectNativeElements(slide) {
25
+ const origin = slide.getBoundingClientRect();
26
+ const natives = [...slide.querySelectorAll(`[${NATIVE_ATTRIBUTE}]`)].filter((element) => !element.parentElement?.closest(`[${NATIVE_ATTRIBUTE}]`));
27
+ const elements = [];
28
+ for (const element of natives) {
29
+ element.removeAttribute(NATIVE_FAILED_ATTRIBUTE);
30
+ try {
31
+ const described = await describe(element, origin);
32
+ if (described)
33
+ elements.push(described);
34
+ else
35
+ element.setAttribute(NATIVE_FAILED_ATTRIBUTE, '');
36
+ }
37
+ catch {
38
+ element.setAttribute(NATIVE_FAILED_ATTRIBUTE, '');
39
+ }
40
+ }
41
+ return elements;
42
+ }
43
+ async function describe(element, origin) {
44
+ if (getComputedStyle(element).display === 'none')
45
+ return null;
46
+ switch (element.getAttribute(NATIVE_ATTRIBUTE)) {
47
+ case 'text':
48
+ return describeText(element, origin);
49
+ case 'table':
50
+ return describeTable(element, origin);
51
+ case 'image':
52
+ return describeImage(element, origin);
53
+ default:
54
+ return null;
55
+ }
56
+ }
57
+ function contentBox(element, origin) {
58
+ const rect = element.getBoundingClientRect();
59
+ const style = getComputedStyle(element);
60
+ const left = px(style.paddingLeft) + px(style.borderLeftWidth);
61
+ const right = px(style.paddingRight) + px(style.borderRightWidth);
62
+ const top = px(style.paddingTop) + px(style.borderTopWidth);
63
+ const bottom = px(style.paddingBottom) + px(style.borderBottomWidth);
64
+ return {
65
+ x: rect.left - origin.left + left,
66
+ y: rect.top - origin.top + top,
67
+ w: Math.max(1, rect.width - left - right),
68
+ h: Math.max(1, rect.height - top - bottom),
69
+ };
70
+ }
71
+ function px(value) {
72
+ const n = parseFloat(value);
73
+ return Number.isFinite(n) ? n : 0;
74
+ }
75
+ function describeText(element, origin) {
76
+ const paragraphs = extractParagraphs(element);
77
+ if (paragraphs.length === 0)
78
+ return null;
79
+ return { kind: 'text', box: contentBox(element, origin), paragraphs };
80
+ }
81
+ /**
82
+ * Walk an element's text into paragraphs of styled runs.
83
+ *
84
+ * A block-level element starts a paragraph and a list item makes it a bullet,
85
+ * which is the whole of what PowerPoint can hold. Inline formatting comes from
86
+ * each text node's computed style, so a `<strong>` or a styled span carries
87
+ * through without the primitives knowing about it.
88
+ */
89
+ export function extractParagraphs(root) {
90
+ const paragraphs = [];
91
+ let current = null;
92
+ const open = (block) => {
93
+ close();
94
+ const style = getComputedStyle(block);
95
+ const isItem = style.display === 'list-item';
96
+ const list = isItem ? block.closest('ol, ul') : null;
97
+ const paragraph = {
98
+ align: alignOf(style),
99
+ bullet: isItem ? (list?.tagName === 'OL' ? 'number' : 'disc') : null,
100
+ level: isItem ? listDepth(block, root) : 0,
101
+ lineHeight: style.lineHeight.endsWith('px') ? px(style.lineHeight) : undefined,
102
+ spaceAfter: px(style.marginBottom) || undefined,
103
+ runs: [],
104
+ };
105
+ current = paragraph;
106
+ return paragraph;
107
+ };
108
+ const close = () => {
109
+ if (current) {
110
+ trimRuns(current.runs);
111
+ if (current.runs.length > 0)
112
+ paragraphs.push(current);
113
+ }
114
+ current = null;
115
+ };
116
+ const walk = (node, block) => {
117
+ if (node.nodeType === Node.TEXT_NODE) {
118
+ const text = (node.textContent ?? '').replace(/\s+/g, ' ');
119
+ if (!text)
120
+ return;
121
+ const paragraph = current ?? open(block);
122
+ const parent = node.parentElement ?? block;
123
+ paragraph.runs.push(runOf(text, getComputedStyle(parent)));
124
+ return;
125
+ }
126
+ if (!(node instanceof HTMLElement))
127
+ return;
128
+ const style = getComputedStyle(node);
129
+ if (style.display === 'none' || style.visibility === 'hidden')
130
+ return;
131
+ if (node.tagName === 'BR') {
132
+ if (current)
133
+ current.runs.push({ ...lastRun(current), text: '\n' });
134
+ return;
135
+ }
136
+ const isBlock = node !== root && BLOCK_DISPLAYS.has(style.display);
137
+ if (isBlock)
138
+ open(node);
139
+ for (const child of node.childNodes)
140
+ walk(child, isBlock ? node : block);
141
+ if (isBlock)
142
+ close();
143
+ };
144
+ open(root);
145
+ for (const child of root.childNodes)
146
+ walk(child, root);
147
+ close();
148
+ return paragraphs;
149
+ }
150
+ function lastRun(paragraph) {
151
+ return (paragraph.runs[paragraph.runs.length - 1] ?? {
152
+ text: '',
153
+ font: '',
154
+ size: 16,
155
+ color: '000000',
156
+ bold: false,
157
+ italic: false,
158
+ underline: false,
159
+ });
160
+ }
161
+ function trimRuns(runs) {
162
+ while (runs.length > 0 && runs[0].text.trim() === '' && runs[0].text !== '\n')
163
+ runs.shift();
164
+ if (runs[0])
165
+ runs[0].text = runs[0].text.replace(/^ +/, '');
166
+ while (runs.length > 0 && runs[runs.length - 1].text.trim() === '')
167
+ runs.pop();
168
+ if (runs.length > 0) {
169
+ const last = runs[runs.length - 1];
170
+ last.text = last.text.replace(/ +$/, '');
171
+ }
172
+ }
173
+ function runOf(text, style) {
174
+ const transformed = style.textTransform === 'uppercase'
175
+ ? text.toUpperCase()
176
+ : style.textTransform === 'lowercase'
177
+ ? text.toLowerCase()
178
+ : text;
179
+ return {
180
+ text: transformed,
181
+ font: fontName(style.fontFamily),
182
+ size: px(style.fontSize),
183
+ color: toHex(style.color) ?? '000000',
184
+ bold: Number(style.fontWeight) >= 600 || style.fontWeight === 'bold',
185
+ italic: style.fontStyle === 'italic',
186
+ underline: style.textDecorationLine.includes('underline'),
187
+ };
188
+ }
189
+ /**
190
+ * The first family in a CSS font stack, as an office suite would name it.
191
+ *
192
+ * Self-hosted variable fonts register as 'X Variable', which no installed font
193
+ * is called; the static family of the same name is what a viewer has, if any.
194
+ */
195
+ export function fontName(stack) {
196
+ const first = stack
197
+ .split(',')[0]
198
+ ?.trim()
199
+ .replace(/^["']|["']$/g, '') ?? '';
200
+ if (/^(system-ui|-apple-system|sans-serif|serif|monospace)$/i.test(first))
201
+ return 'Arial';
202
+ return first.replace(/ Variable$/, '');
203
+ }
204
+ function alignOf(style) {
205
+ const rtl = style.direction === 'rtl';
206
+ switch (style.textAlign) {
207
+ case 'center':
208
+ return 'center';
209
+ case 'right':
210
+ return 'right';
211
+ case 'left':
212
+ return 'left';
213
+ case 'justify':
214
+ return 'justify';
215
+ case 'end':
216
+ return rtl ? 'left' : 'right';
217
+ default:
218
+ return rtl ? 'right' : 'left';
219
+ }
220
+ }
221
+ function listDepth(item, root) {
222
+ let depth = -1;
223
+ for (let node = item; node && node !== root; node = node.parentElement) {
224
+ if (node.tagName === 'UL' || node.tagName === 'OL')
225
+ depth += 1;
226
+ }
227
+ if (root.tagName === 'UL' || root.tagName === 'OL')
228
+ depth += 1;
229
+ return Math.max(0, depth);
230
+ }
231
+ function fillOf(...elements) {
232
+ for (const element of elements) {
233
+ if (!element)
234
+ continue;
235
+ const background = getComputedStyle(element).backgroundColor;
236
+ const alpha = /rgba\([^)]*,\s*([\d.]+)\)/.exec(background);
237
+ if (background === 'transparent' || (alpha && Number(alpha[1]) === 0))
238
+ continue;
239
+ const hex = toHex(background);
240
+ if (hex)
241
+ return hex;
242
+ }
243
+ return null;
244
+ }
245
+ function describeTable(element, origin) {
246
+ const table = element.tagName === 'TABLE' ? element : element.querySelector('table');
247
+ if (!table)
248
+ return null;
249
+ const rows = [...table.querySelectorAll('tr')];
250
+ if (rows.length === 0)
251
+ return null;
252
+ const columnWidths = [...rows[0].cells].map((cell) => cell.getBoundingClientRect().width);
253
+ const tableRect = table.getBoundingClientRect();
254
+ return {
255
+ kind: 'table',
256
+ box: {
257
+ x: tableRect.left - origin.left,
258
+ y: tableRect.top - origin.top,
259
+ w: tableRect.width,
260
+ h: tableRect.height,
261
+ },
262
+ columnWidths,
263
+ rows: rows.map((row) => ({
264
+ height: row.getBoundingClientRect().height,
265
+ cells: [...row.cells].map((cell) => {
266
+ const style = getComputedStyle(cell);
267
+ const borderWidth = px(style.borderBottomWidth);
268
+ return {
269
+ paragraphs: extractParagraphs(cell),
270
+ fill: fillOf(cell, row, row.parentElement, table),
271
+ border: borderWidth > 0 && style.borderBottomStyle !== 'none'
272
+ ? {
273
+ color: toHex(style.borderBottomColor) ?? '000000',
274
+ width: borderWidth,
275
+ }
276
+ : null,
277
+ };
278
+ }),
279
+ })),
280
+ };
281
+ }
282
+ async function describeImage(element, origin) {
283
+ const image = element instanceof HTMLImageElement ? element : element.querySelector('img');
284
+ if (!image)
285
+ return null;
286
+ if (!image.complete)
287
+ await image.decode();
288
+ const box = contentBox(image, origin);
289
+ const fitted = fitContain(box, image.naturalWidth, image.naturalHeight);
290
+ const canvas = document.createElement('canvas');
291
+ // Twice the slide's own resolution, so a picture still looks sharp when
292
+ // someone enlarges it in PowerPoint.
293
+ canvas.width = Math.round(fitted.w * 2);
294
+ canvas.height = Math.round(fitted.h * 2);
295
+ const context = canvas.getContext('2d');
296
+ if (!context)
297
+ return null;
298
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
299
+ // Throws for a cross-origin picture served without CORS; the caller keeps
300
+ // it in the background picture instead.
301
+ const src = canvas.toDataURL('image/png');
302
+ return { kind: 'image', box: fitted, src, alt: image.alt };
303
+ }
304
+ /** Where an `object-fit: contain` picture actually lands inside its box. */
305
+ export function fitContain(box, naturalWidth, naturalHeight) {
306
+ if (!naturalWidth || !naturalHeight)
307
+ return box;
308
+ const scale = Math.min(box.w / naturalWidth, box.h / naturalHeight);
309
+ const w = naturalWidth * scale;
310
+ const h = naturalHeight * scale;
311
+ return { x: box.x + (box.w - w) / 2, y: box.y + (box.h - h) / 2, w, h };
312
+ }
@@ -0,0 +1,28 @@
1
+ import type { DeckExport } from './ir.js';
2
+ export interface RenderedSlide {
3
+ element: HTMLElement;
4
+ notes?: string;
5
+ }
6
+ /**
7
+ * A slide as an image data URL: PNG by default, JPEG on request.
8
+ *
9
+ * With `withoutNative`, every native element is hidden first, which leaves
10
+ * what PowerPoint cannot hold as objects: backgrounds, decoration and any
11
+ * custom component. The exporter lays the natives back on top as real objects.
12
+ */
13
+ export declare function rasteriseSlide(slide: HTMLElement, { withoutNative, scale, format, }?: {
14
+ withoutNative?: boolean;
15
+ scale?: number;
16
+ format?: 'png' | 'jpeg';
17
+ }): Promise<string>;
18
+ /** Describe rendered slides as data, ready for any exporter. */
19
+ export declare function describeDeck(slides: RenderedSlide[], { title, dir }: {
20
+ title: string;
21
+ dir: 'ltr' | 'rtl';
22
+ }, onProgress?: (done: number, total: number) => void): Promise<DeckExport>;
23
+ /** Hand a file to the browser to save. */
24
+ export declare function download(file: Blob | string, fileName: string): void;
25
+ /** A file name from a deck title, safe on every desktop. */
26
+ export declare function fileNameFor(title: string, extension: string, suffix?: string): string;
27
+ export { writePptx } from './pptx.js';
28
+ export type { DeckExport } from './ir.js';
@@ -0,0 +1,92 @@
1
+ import { SLIDE_HEIGHT, SLIDE_WIDTH } from '../context.js';
2
+ import { collectNativeElements } from './collect.js';
3
+ /**
4
+ * A slide as an image data URL: PNG by default, JPEG on request.
5
+ *
6
+ * With `withoutNative`, every native element is hidden first, which leaves
7
+ * what PowerPoint cannot hold as objects: backgrounds, decoration and any
8
+ * custom component. The exporter lays the natives back on top as real objects.
9
+ */
10
+ export async function rasteriseSlide(slide, { withoutNative = false, scale = 1, format = 'png', } = {}) {
11
+ const { createContext, destroyContext, domToDataUrl } = await import('modern-screenshot');
12
+ const host = slide.closest('.deck-static-stack') ?? slide;
13
+ if (withoutNative)
14
+ host.classList.add('deck-exporting');
15
+ // The type goes on the context: domToJpeg and domToPng set it only on a
16
+ // context they create, so handed this one they would both write PNG.
17
+ const context = await createContext(slide, {
18
+ width: SLIDE_WIDTH,
19
+ height: SLIDE_HEIGHT,
20
+ scale,
21
+ type: format === 'jpeg' ? 'image/jpeg' : 'image/png',
22
+ quality: 0.92,
23
+ });
24
+ try {
25
+ context.sandbox = await loadedSandbox(slide.ownerDocument);
26
+ return await domToDataUrl(context);
27
+ }
28
+ finally {
29
+ destroyContext(context);
30
+ if (withoutNative)
31
+ host.classList.remove('deck-exporting');
32
+ }
33
+ }
34
+ /**
35
+ * The frame modern-screenshot reads browser defaults from, loaded as a
36
+ * standards-mode document. It copies only the styles that differ from those
37
+ * defaults, and the frame it makes for itself is read while still the initial
38
+ * quirks-mode `about:blank`. Firefox applies the quirk that drops a heading's
39
+ * top margin there, so the deck's zeroed margins matched the 'default' and were
40
+ * left out of the picture, where the browser's own margins came back and pushed
41
+ * the slide's content below the editable text laid over it. The `srcdoc` is set
42
+ * before the frame is inserted so the first `load` is the document's own.
43
+ */
44
+ async function loadedSandbox(document) {
45
+ const sandbox = document.createElement('iframe');
46
+ sandbox.width = '0';
47
+ sandbox.height = '0';
48
+ sandbox.style.visibility = 'hidden';
49
+ sandbox.style.position = 'fixed';
50
+ sandbox.srcdoc = '<!DOCTYPE html><meta charset="UTF-8"><title></title><body>';
51
+ const loaded = new Promise((resolve) => sandbox.addEventListener('load', resolve, { once: true }));
52
+ document.body.appendChild(sandbox);
53
+ await loaded;
54
+ return sandbox;
55
+ }
56
+ /** Describe rendered slides as data, ready for any exporter. */
57
+ export async function describeDeck(slides, { title, dir }, onProgress) {
58
+ const described = [];
59
+ for (const [index, slide] of slides.entries()) {
60
+ // Collect first: an element that cannot be described is marked, and so
61
+ // stays in the background picture taken next.
62
+ const elements = await collectNativeElements(slide.element);
63
+ // JPEG: a slide is opaque, and a gradient or photograph as PNG runs to
64
+ // megabytes a slide.
65
+ const background = await rasteriseSlide(slide.element, {
66
+ withoutNative: true,
67
+ format: 'jpeg',
68
+ });
69
+ described.push({ background, notes: slide.notes, elements });
70
+ onProgress?.(index + 1, slides.length);
71
+ }
72
+ return { title, dir, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, slides: described };
73
+ }
74
+ /** Hand a file to the browser to save. */
75
+ export function download(file, fileName) {
76
+ const url = typeof file === 'string' ? file : URL.createObjectURL(file);
77
+ const link = document.createElement('a');
78
+ link.href = url;
79
+ link.download = fileName;
80
+ link.rel = 'noopener';
81
+ document.body.appendChild(link);
82
+ link.click();
83
+ link.remove();
84
+ if (typeof file !== 'string')
85
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
86
+ }
87
+ /** A file name from a deck title, safe on every desktop. */
88
+ export function fileNameFor(title, extension, suffix = '') {
89
+ const base = title.replace(/[\\/:*?"<>|]+/g, ' ').trim() || 'Presentation';
90
+ return `${base}${suffix}.${extension}`;
91
+ }
92
+ export { writePptx } from './pptx.js';
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A deck as plain data, between the rendered slides and any file format.
3
+ *
4
+ * Private to this library and deliberately so. Primitives describe themselves
5
+ * in these terms, and each exporter reads only these terms, so the PowerPoint
6
+ * writer (or whatever replaces it) can change without a game noticing. Nothing
7
+ * outside the library, the SDK and the host included, reads this shape, which
8
+ * is what keeps it free to change.
9
+ *
10
+ * Geometry is in CSS pixels on the 1920 by 1080 slide canvas. Converting to a
11
+ * format's own units is the exporter's job.
12
+ */
13
+ export interface DeckExport {
14
+ title: string;
15
+ dir: 'ltr' | 'rtl';
16
+ width: number;
17
+ height: number;
18
+ slides: SlideExport[];
19
+ }
20
+ export interface SlideExport {
21
+ /** Everything on the slide that is not a native element, as an image data URL. */
22
+ background: string;
23
+ notes?: string;
24
+ elements: ExportElement[];
25
+ }
26
+ export interface Box {
27
+ x: number;
28
+ y: number;
29
+ w: number;
30
+ h: number;
31
+ }
32
+ export type ExportElement = TextElement | TableElement | ImageElement;
33
+ export interface TextRun {
34
+ text: string;
35
+ font: string;
36
+ /** CSS pixels. */
37
+ size: number;
38
+ /** Six hex digits, no hash. */
39
+ color: string;
40
+ bold: boolean;
41
+ italic: boolean;
42
+ underline: boolean;
43
+ }
44
+ export interface Paragraph {
45
+ align: 'left' | 'center' | 'right' | 'justify';
46
+ /** Bullet style, or none for an ordinary paragraph. */
47
+ bullet: 'disc' | 'number' | null;
48
+ /** Nesting depth of a bulleted paragraph, from 0. */
49
+ level: number;
50
+ /** CSS pixels, when the line height is set explicitly. */
51
+ lineHeight?: number;
52
+ /** CSS pixels of space after the paragraph. */
53
+ spaceAfter?: number;
54
+ runs: TextRun[];
55
+ }
56
+ export interface TextElement {
57
+ kind: 'text';
58
+ box: Box;
59
+ paragraphs: Paragraph[];
60
+ }
61
+ export interface TableCell {
62
+ paragraphs: Paragraph[];
63
+ /** Six hex digits, or null for none. */
64
+ fill: string | null;
65
+ border: {
66
+ color: string;
67
+ width: number;
68
+ } | null;
69
+ }
70
+ export interface TableElement {
71
+ kind: 'table';
72
+ box: Box;
73
+ columnWidths: number[];
74
+ rows: {
75
+ height: number;
76
+ cells: TableCell[];
77
+ }[];
78
+ }
79
+ export interface ImageElement {
80
+ kind: 'image';
81
+ box: Box;
82
+ /** PNG data URL. */
83
+ src: string;
84
+ alt: string;
85
+ }
@@ -0,0 +1 @@
1
+ export {};