@hajimism/dek 0.2.1 → 0.2.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.
- package/CHANGELOG.md +20 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +3 -1
- package/skills/dek/SKILL.md +44 -0
- package/src/cli/annotations.ts +26 -0
- package/src/cli/commands.ts +66 -0
- package/src/cli/contract.ts +75 -4
- package/src/cli/files.ts +3 -3
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +3 -0
- package/src/cli/pptx.ts +20 -0
- package/src/cli/show.ts +17 -2
- package/src/cli/text.ts +43 -0
- package/src/cli/theme.ts +7 -1
- package/src/core/annotation-rows.ts +176 -0
- package/src/core/annotations.ts +362 -0
- package/src/core/diagnostic.ts +1 -0
- package/src/core/html-scan.ts +54 -0
- package/src/core/html.ts +53 -8
- package/src/core/lint/slide-html.ts +92 -2
- package/src/core/lint.ts +8 -1
- package/src/core/live-protocol.ts +4 -1
- package/src/core/page.ts +6 -0
- package/src/core/playwright-module.d.ts +4 -1
- package/src/core/playwright-visual.ts +74 -1
- package/src/core/playwright.ts +51 -1
- package/src/core/pptx-package.ts +312 -0
- package/src/core/pptx-text.ts +363 -0
- package/src/core/pptx.ts +75 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/sync.ts +13 -5
- package/src/core/theme-excerpt.ts +61 -9
- package/src/core/theme-facts.ts +12 -2
- package/src/core/zip.ts +77 -0
- package/src/runtime/annotate-pick.ts +2 -6
- package/src/runtime/annotate-view.ts +115 -101
- package/src/runtime/annotate.ts +5 -1
- package/src/runtime/browser.ts +5 -1
- package/src/runtime/live.ts +1 -0
- package/src/runtime/routes.ts +3 -0
- package/src/server/auth.ts +3 -1
- package/src/server/routes.ts +68 -1
- package/src/runtime/annotate-notes.ts +0 -207
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// What a PPTX takes from a slide as the browser drew it: each line of the text in its HTML flow,
|
|
3
|
+
// where it was laid out and how it was set, so PowerPoint can set it there again; and the slide
|
|
4
|
+
// drawn with that text taken out, as the picture under it.
|
|
5
|
+
|
|
6
|
+
import type { PptxRun, PptxTextBox } from "./pptx-package.ts";
|
|
7
|
+
|
|
8
|
+
/** A run as the page measures it: the element that owns its text, for its font to be looked up. */
|
|
9
|
+
type MeasuredRun = Omit<PptxRun, "font"> & { owner: number };
|
|
10
|
+
|
|
11
|
+
type MeasuredBox = Omit<PptxTextBox, "runs"> & { runs: MeasuredRun[] };
|
|
12
|
+
|
|
13
|
+
export type PptxPageTexts = {
|
|
14
|
+
boxes: MeasuredBox[];
|
|
15
|
+
/** How many elements own text, marked `data-dek-pptx-font="<n>"` for their fonts. */
|
|
16
|
+
owners: number;
|
|
17
|
+
/** The alternative texts the slide's pictures carry, for the slide's picture as a whole. */
|
|
18
|
+
description: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The attribute that marks each element owning measured text, by its number. */
|
|
22
|
+
export const PPTX_OWNER_ATTR = "data-dek-pptx-font";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Measures every text in the slide's HTML flow, line by line, then hides it from the drawing with
|
|
26
|
+
* a highlight, which changes no layout, so a shot taken next is the picture under the text. What
|
|
27
|
+
* is not measured stays drawn: SVG, decoration under `aria-hidden`, what a pseudo-element draws,
|
|
28
|
+
* text a transform rotates or skews, text with no fill of its own, and every text's shadow.
|
|
29
|
+
*
|
|
30
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
31
|
+
*/
|
|
32
|
+
export function measurePptxTextsInPage(): PptxPageTexts {
|
|
33
|
+
const slide = document.querySelector("#deck > .slide");
|
|
34
|
+
if (!(slide instanceof HTMLElement)) {
|
|
35
|
+
return { boxes: [], owners: 0, description: "" };
|
|
36
|
+
}
|
|
37
|
+
const origin = slide.getBoundingClientRect();
|
|
38
|
+
|
|
39
|
+
const canvas = document.createElement("canvas");
|
|
40
|
+
canvas.width = 1;
|
|
41
|
+
canvas.height = 1;
|
|
42
|
+
const paint = canvas.getContext("2d", { willReadFrequently: true });
|
|
43
|
+
const colors = new Map<string, { rgb: [number, number, number]; alpha: number }>();
|
|
44
|
+
/** A computed color as sRGB, whatever space it is written in, by drawing it. */
|
|
45
|
+
const rgba = (value: string): { rgb: [number, number, number]; alpha: number } => {
|
|
46
|
+
const known = colors.get(value);
|
|
47
|
+
if (known) {
|
|
48
|
+
return known;
|
|
49
|
+
}
|
|
50
|
+
let found = { rgb: [0, 0, 0] as [number, number, number], alpha: 0 };
|
|
51
|
+
if (paint) {
|
|
52
|
+
paint.clearRect(0, 0, 1, 1);
|
|
53
|
+
paint.fillStyle = value;
|
|
54
|
+
paint.fillRect(0, 0, 1, 1);
|
|
55
|
+
const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
|
|
56
|
+
found = { rgb: [r, g, b], alpha: a / 255 };
|
|
57
|
+
}
|
|
58
|
+
colors.set(value, found);
|
|
59
|
+
return found;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How much the element and what it is in, up to the slide, scale it; none when one of them
|
|
64
|
+
* rotates or skews it, or scales it unevenly, which a text box cannot follow.
|
|
65
|
+
*/
|
|
66
|
+
const scaleOf = (el: Element): number | undefined => {
|
|
67
|
+
let scale = 1;
|
|
68
|
+
for (
|
|
69
|
+
let node: Element | null = el;
|
|
70
|
+
node && node !== slide.parentElement;
|
|
71
|
+
node = node.parentElement
|
|
72
|
+
) {
|
|
73
|
+
const transform = getComputedStyle(node).transform;
|
|
74
|
+
if (transform === "none" || transform === "") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const matrix = new DOMMatrixReadOnly(transform);
|
|
78
|
+
if (!matrix.is2D || Math.abs(matrix.b) > 1e-6 || Math.abs(matrix.c) > 1e-6) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
if (Math.abs(matrix.a - matrix.d) > 1e-6 || matrix.a <= 0) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
scale *= matrix.a;
|
|
85
|
+
}
|
|
86
|
+
return scale;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const opacityOf = (el: Element): number => {
|
|
90
|
+
let opacity = 1;
|
|
91
|
+
for (
|
|
92
|
+
let node: Element | null = el;
|
|
93
|
+
node && node !== slide.parentElement;
|
|
94
|
+
node = node.parentElement
|
|
95
|
+
) {
|
|
96
|
+
opacity *= Number(getComputedStyle(node).opacity);
|
|
97
|
+
}
|
|
98
|
+
return opacity;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The nearest element around `el` laid out as a block of its own, `el` included. */
|
|
102
|
+
const blockOf = (el: Element): Element => {
|
|
103
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
104
|
+
const display = getComputedStyle(node).display;
|
|
105
|
+
if (node === slide || (display !== "inline" && display !== "contents")) {
|
|
106
|
+
return node;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return slide;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The decorations an element's text is drawn with: its own, and its inline ancestors' up to its block. */
|
|
113
|
+
const decorationsOf = (el: Element, block: Element): { underline: boolean; strike: boolean } => {
|
|
114
|
+
let underline = false;
|
|
115
|
+
let strike = false;
|
|
116
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
117
|
+
const line = getComputedStyle(node).textDecorationLine;
|
|
118
|
+
underline ||= line.includes("underline");
|
|
119
|
+
strike ||= line.includes("line-through");
|
|
120
|
+
if (node === block) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { underline, strike };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const transformText = (text: string, transform: string, lang: string): string => {
|
|
128
|
+
if (transform === "uppercase") {
|
|
129
|
+
return text.toLocaleUpperCase(lang || undefined);
|
|
130
|
+
}
|
|
131
|
+
if (transform === "lowercase") {
|
|
132
|
+
return text.toLocaleLowerCase(lang || undefined);
|
|
133
|
+
}
|
|
134
|
+
if (transform === "capitalize") {
|
|
135
|
+
return text.replace(
|
|
136
|
+
/(^|\s)(\p{L})/gu,
|
|
137
|
+
(_, space: string, letter: string) =>
|
|
138
|
+
`${space}${letter.toLocaleUpperCase(lang || undefined)}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return text;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
type Fragment = {
|
|
145
|
+
text: string;
|
|
146
|
+
/** Where its first glyph that is not a collapsible space starts. */
|
|
147
|
+
ink: number;
|
|
148
|
+
left: number;
|
|
149
|
+
top: number;
|
|
150
|
+
right: number;
|
|
151
|
+
bottom: number;
|
|
152
|
+
block: Element;
|
|
153
|
+
run: MeasuredRun;
|
|
154
|
+
};
|
|
155
|
+
const fragments: Fragment[] = [];
|
|
156
|
+
const ranges: Range[] = [];
|
|
157
|
+
const owners = new Map<Element, number>();
|
|
158
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
159
|
+
|
|
160
|
+
const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT);
|
|
161
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
162
|
+
const text = node as Text;
|
|
163
|
+
const parent = text.parentElement;
|
|
164
|
+
if (!parent || !/[^ \t\n\r\f]/.test(text.data)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (parent.closest('svg, script, style, template, [aria-hidden="true"]')) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const style = getComputedStyle(parent);
|
|
171
|
+
if (style.visibility !== "visible") {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const fill = rgba(style.webkitTextFillColor || style.color);
|
|
175
|
+
const opacity = opacityOf(parent);
|
|
176
|
+
const scale = scaleOf(parent);
|
|
177
|
+
if (fill.alpha === 0 || opacity === 0 || scale === undefined) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const block = blockOf(parent);
|
|
181
|
+
const owner = owners.get(parent) ?? owners.size;
|
|
182
|
+
owners.set(parent, owner);
|
|
183
|
+
const weight = Number(style.fontWeight);
|
|
184
|
+
const letterSpacing =
|
|
185
|
+
style.letterSpacing === "normal" ? 0 : Number.parseFloat(style.letterSpacing);
|
|
186
|
+
const run: MeasuredRun = {
|
|
187
|
+
text: "",
|
|
188
|
+
size: Number.parseFloat(style.fontSize) * scale,
|
|
189
|
+
bold: weight >= 600,
|
|
190
|
+
italic: style.fontStyle !== "normal",
|
|
191
|
+
...decorationsOf(parent, block),
|
|
192
|
+
color: fill.rgb,
|
|
193
|
+
alpha: Math.round(fill.alpha * opacity * 1000) / 1000,
|
|
194
|
+
letterSpacing: (Number.isFinite(letterSpacing) ? letterSpacing : 0) * scale,
|
|
195
|
+
owner,
|
|
196
|
+
};
|
|
197
|
+
const collapse = !/^(pre|pre-wrap|break-spaces)$/.test(style.whiteSpace);
|
|
198
|
+
const lang = parent.closest("[lang]")?.getAttribute("lang") ?? "";
|
|
199
|
+
|
|
200
|
+
// The lines the text is drawn on, each the graphemes on it and the box they cover.
|
|
201
|
+
const range = document.createRange();
|
|
202
|
+
const lines: Array<{
|
|
203
|
+
text: string;
|
|
204
|
+
ink: number;
|
|
205
|
+
left: number;
|
|
206
|
+
top: number;
|
|
207
|
+
right: number;
|
|
208
|
+
bottom: number;
|
|
209
|
+
}> = [];
|
|
210
|
+
for (const { segment, index } of segmenter.segment(text.data)) {
|
|
211
|
+
range.setStart(text, index);
|
|
212
|
+
range.setEnd(text, index + segment.length);
|
|
213
|
+
const rect = [...range.getClientRects()].find((box) => box.width > 0 || box.height > 0);
|
|
214
|
+
// Only the white space CSS collapses; an ideographic space is a character like any other.
|
|
215
|
+
const space = /^[ \t\n\r\f]+$/.test(segment);
|
|
216
|
+
if (!rect || (space && rect.width === 0)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const glyph = space && collapse ? " " : segment;
|
|
220
|
+
const line = lines.at(-1);
|
|
221
|
+
const sameLine =
|
|
222
|
+
line !== undefined &&
|
|
223
|
+
rect.top < line.bottom - (line.bottom - line.top) / 2 &&
|
|
224
|
+
rect.left >= line.right - Math.max(2, run.size / 2);
|
|
225
|
+
if (sameLine) {
|
|
226
|
+
if (!(collapse && glyph === " " && line.text.endsWith(" "))) {
|
|
227
|
+
line.text += glyph;
|
|
228
|
+
}
|
|
229
|
+
if (!space && line.ink === Number.POSITIVE_INFINITY) {
|
|
230
|
+
line.ink = rect.left;
|
|
231
|
+
}
|
|
232
|
+
line.left = Math.min(line.left, rect.left);
|
|
233
|
+
line.top = Math.min(line.top, rect.top);
|
|
234
|
+
line.right = Math.max(line.right, rect.right);
|
|
235
|
+
line.bottom = Math.max(line.bottom, rect.bottom);
|
|
236
|
+
} else {
|
|
237
|
+
lines.push({
|
|
238
|
+
text: glyph,
|
|
239
|
+
ink: space ? Number.POSITIVE_INFINITY : rect.left,
|
|
240
|
+
left: rect.left,
|
|
241
|
+
top: rect.top,
|
|
242
|
+
right: rect.right,
|
|
243
|
+
bottom: rect.bottom,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (lines.length === 0) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const all = document.createRange();
|
|
251
|
+
all.selectNodeContents(text);
|
|
252
|
+
ranges.push(all);
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
fragments.push({
|
|
255
|
+
...line,
|
|
256
|
+
text: transformText(line.text, style.textTransform, lang),
|
|
257
|
+
block,
|
|
258
|
+
run,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Pieces of one line in one block that sit together are one box, a run each; a gap wider than
|
|
264
|
+
// a space, such as the padding of a chip, starts another.
|
|
265
|
+
const boxes: MeasuredBox[] = [];
|
|
266
|
+
let open: { box: MeasuredBox; block: Element; right: number } | undefined;
|
|
267
|
+
for (const fragment of fragments) {
|
|
268
|
+
const height = fragment.bottom - fragment.top;
|
|
269
|
+
const gap = open ? fragment.left - open.right : Number.POSITIVE_INFINITY;
|
|
270
|
+
const joins =
|
|
271
|
+
open !== undefined &&
|
|
272
|
+
open.block === fragment.block &&
|
|
273
|
+
Math.abs(fragment.top - open.box.y - origin.top) < height / 2 &&
|
|
274
|
+
gap > -fragment.run.size / 2 &&
|
|
275
|
+
gap <= fragment.run.size * 0.3;
|
|
276
|
+
if (open && joins) {
|
|
277
|
+
const { box } = open;
|
|
278
|
+
const left = Math.min(box.x + origin.left, fragment.left);
|
|
279
|
+
const top = Math.min(box.y + origin.top, fragment.top);
|
|
280
|
+
const right = Math.max(box.x + origin.left + box.width, fragment.right);
|
|
281
|
+
const bottom = Math.max(box.y + origin.top + box.height, fragment.bottom);
|
|
282
|
+
Object.assign(box, {
|
|
283
|
+
x: left - origin.left,
|
|
284
|
+
y: top - origin.top,
|
|
285
|
+
width: right - left,
|
|
286
|
+
height: bottom - top,
|
|
287
|
+
lineHeight: Math.max(box.lineHeight, height),
|
|
288
|
+
});
|
|
289
|
+
const last = box.runs.at(-1);
|
|
290
|
+
if (last && sameStyle(last, fragment.run)) {
|
|
291
|
+
last.text += fragment.text;
|
|
292
|
+
} else {
|
|
293
|
+
box.runs.push({ ...fragment.run, text: fragment.text });
|
|
294
|
+
}
|
|
295
|
+
open.right = fragment.right;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// A box starts at its first glyph: a space it would start with is not set.
|
|
299
|
+
const start = Number.isFinite(fragment.ink) ? fragment.ink : fragment.left;
|
|
300
|
+
const box: MeasuredBox = {
|
|
301
|
+
x: start - origin.left,
|
|
302
|
+
y: fragment.top - origin.top,
|
|
303
|
+
width: Math.max(0, fragment.right - start),
|
|
304
|
+
height,
|
|
305
|
+
lineHeight: height,
|
|
306
|
+
runs: [{ ...fragment.run, text: fragment.text }],
|
|
307
|
+
};
|
|
308
|
+
boxes.push(box);
|
|
309
|
+
open = { box, block: fragment.block, right: fragment.right };
|
|
310
|
+
}
|
|
311
|
+
for (const box of boxes) {
|
|
312
|
+
const first = box.runs[0];
|
|
313
|
+
const last = box.runs.at(-1);
|
|
314
|
+
if (first) {
|
|
315
|
+
first.text = first.text.replace(/^[ \t\n\r\f]+/, "");
|
|
316
|
+
}
|
|
317
|
+
if (last) {
|
|
318
|
+
last.text = last.text.replace(/[ \t\n\r\f]+$/, "");
|
|
319
|
+
}
|
|
320
|
+
box.runs = box.runs.filter((run) => run.text !== "");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const [el, owner] of owners) {
|
|
324
|
+
el.setAttribute("data-dek-pptx-font", String(owner));
|
|
325
|
+
}
|
|
326
|
+
const style = document.createElement("style");
|
|
327
|
+
style.textContent =
|
|
328
|
+
"::highlight(dek-pptx) { color: transparent; -webkit-text-fill-color: transparent; text-decoration-color: transparent; }";
|
|
329
|
+
document.head.append(style);
|
|
330
|
+
CSS.highlights.set("dek-pptx", new Highlight(...ranges));
|
|
331
|
+
|
|
332
|
+
const described = [...slide.querySelectorAll("img[alt], [aria-label], svg > title")].flatMap(
|
|
333
|
+
(el) => {
|
|
334
|
+
if (el.closest('[aria-hidden="true"]')) {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
const said =
|
|
338
|
+
(el.tagName.toLowerCase() === "title"
|
|
339
|
+
? el.textContent
|
|
340
|
+
: (el.getAttribute("alt") ?? el.getAttribute("aria-label"))) ?? "";
|
|
341
|
+
return said.trim() === "" ? [] : [said.trim()];
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
return {
|
|
345
|
+
boxes: boxes.filter((box) => box.runs.length > 0),
|
|
346
|
+
owners: owners.size,
|
|
347
|
+
description: described.join(" / "),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
function sameStyle(a: MeasuredRun, b: MeasuredRun): boolean {
|
|
351
|
+
return (
|
|
352
|
+
a.owner === b.owner &&
|
|
353
|
+
a.size === b.size &&
|
|
354
|
+
a.bold === b.bold &&
|
|
355
|
+
a.italic === b.italic &&
|
|
356
|
+
a.underline === b.underline &&
|
|
357
|
+
a.strike === b.strike &&
|
|
358
|
+
a.alpha === b.alpha &&
|
|
359
|
+
a.letterSpacing === b.letterSpacing &&
|
|
360
|
+
a.color.join() === b.color.join()
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
package/src/core/pptx.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type DistOptions, distFile } from "./path.ts";
|
|
5
|
+
import { type PlaywrightRunner, requirePlaywright } from "./playwright.ts";
|
|
6
|
+
import { pptxPackage } from "./pptx-package.ts";
|
|
7
|
+
import { presenterSlides } from "./presenter.ts";
|
|
8
|
+
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
9
|
+
import { writeInside } from "./safe-fs.ts";
|
|
10
|
+
import { logicalSize } from "./size.ts";
|
|
11
|
+
import { lastStop } from "./step.ts";
|
|
12
|
+
import { loadSlideSources, renderSlideHtml } from "./still-page.ts";
|
|
13
|
+
|
|
14
|
+
export type PptxResult = { outPath: string };
|
|
15
|
+
|
|
16
|
+
export type PptxOptions = DistOptions & {
|
|
17
|
+
/** For anyone who has the file: the notes leave out the stage directions and comments. */
|
|
18
|
+
public?: boolean;
|
|
19
|
+
runner?: PlaywrightRunner;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, drawn as a
|
|
24
|
+
* picture with its text laid over it in text boxes a recipient can edit, and its script in the
|
|
25
|
+
* notes. A slide script that cannot run stops it, as it stops the PDF.
|
|
26
|
+
*/
|
|
27
|
+
export async function pptxDeck(dir: string, options?: PptxOptions): Promise<PptxResult>;
|
|
28
|
+
export async function pptxDeck(source: ResolvedDeck, options?: PptxOptions): Promise<PptxResult>;
|
|
29
|
+
export async function pptxDeck(
|
|
30
|
+
input: string | ResolvedDeck,
|
|
31
|
+
options: PptxOptions = {},
|
|
32
|
+
): Promise<PptxResult> {
|
|
33
|
+
const { project, deck } = asResolvedDeck(input);
|
|
34
|
+
const outPath = distFile(project, deck, "pptx", options);
|
|
35
|
+
const size = logicalSize(deck.deck.ratio);
|
|
36
|
+
const sources = loadSlideSources(deck, { strict: true });
|
|
37
|
+
const { sections } = deck.deck;
|
|
38
|
+
// Rendered before Chromium starts, so a slide that cannot be drawn stops it with nothing written.
|
|
39
|
+
const pages = sections.map((section, index) => ({
|
|
40
|
+
html: renderSlideHtml(sources, section.slug, lastStop(section.beats)),
|
|
41
|
+
slug: section.slug,
|
|
42
|
+
index,
|
|
43
|
+
}));
|
|
44
|
+
const shots = mkdtempSync(join(tmpdir(), "dek-pptx-"));
|
|
45
|
+
try {
|
|
46
|
+
const answer = await requirePlaywright(
|
|
47
|
+
{
|
|
48
|
+
kind: "pptx",
|
|
49
|
+
viewport: size,
|
|
50
|
+
pages: pages.map(({ html, slug, index }) => ({
|
|
51
|
+
html,
|
|
52
|
+
slug,
|
|
53
|
+
screenshotPath: join(shots, `${index + 1}.png`),
|
|
54
|
+
})),
|
|
55
|
+
},
|
|
56
|
+
options.runner,
|
|
57
|
+
);
|
|
58
|
+
const scripts = presenterSlides(deck, options.public ? "audience" : "speaker");
|
|
59
|
+
const bytes = pptxPackage({
|
|
60
|
+
title: deck.deck.title,
|
|
61
|
+
lang: deck.deck.lang,
|
|
62
|
+
size,
|
|
63
|
+
slides: answer.slides.map((slide, index) => ({
|
|
64
|
+
picture: readFileSync(join(shots, `${index + 1}.png`)),
|
|
65
|
+
description: slide.description || (sections[index]?.title ?? slide.slug),
|
|
66
|
+
texts: slide.boxes,
|
|
67
|
+
notes: scripts[index]?.script ?? "",
|
|
68
|
+
})),
|
|
69
|
+
});
|
|
70
|
+
writeInside(outPath, bytes, project.root);
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(shots, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
return { outPath };
|
|
75
|
+
}
|
|
@@ -90,6 +90,7 @@ export function measureSlideInPage(): SlideMeasure {
|
|
|
90
90
|
if (!slide) {
|
|
91
91
|
return { slideBox: undefined, elements: [], pseudoTexts: [] };
|
|
92
92
|
}
|
|
93
|
+
// RUNTIME_CLASSES from theme-facts.ts, written out: this function cannot import.
|
|
93
94
|
const runtimeClasses = new Set(["is-current", "is-shown"]);
|
|
94
95
|
const pictures = new Set(["img", "svg", "video", "canvas", "iframe", "object", "embed"]);
|
|
95
96
|
// Computed colors come back as rgba() or, from a newer syntax, with a "/ alpha" at the end.
|
package/src/core/sync.ts
CHANGED
|
@@ -250,13 +250,16 @@ A build system for talks. Write what you will say; dek builds, measures, and shi
|
|
|
250
250
|
## Conventions
|
|
251
251
|
|
|
252
252
|
- One \`##\` heading is one slide. HTML lives in \`slides/<id>.html\`.
|
|
253
|
-
- Each deck owns its \`theme.css
|
|
254
|
-
-
|
|
253
|
+
- Each deck owns its \`theme.css\`, \`decks/<deck>/theme.css\`: lint, the build, and \`dekc theme\` read that one, and it is the \`theme.css\` this file and every hint mean. The \`theme.css\` at the project root is only the template \`dekc new\` copies into a new deck; editing it changes no deck that exists.
|
|
254
|
+
- Before writing a slide, run \`dekc theme\` in the deck for the classes, tokens, and layouts it defines, and \`dekc theme <layout>\` for a layout's markup.
|
|
255
|
+
- Shared look lives in the deck's \`theme.css\`. Decoration only one slide uses lives in \`slides/<id>.css\`, which is scoped to that slide.
|
|
255
256
|
- Use only classes defined in \`theme.css\` or in that slide's own \`slides/<id>.css\`. The theme holds at most \`max_classes\` in the frontmatter of \`script.md\`, 40 by default (\`DEK013\`); classes in \`slides/<id>.css\` do not count, so keep a class only one slide uses there.
|
|
257
|
+
- \`is-current\` and \`is-shown\` are states the player sets: \`is-current\` on the slide on screen, \`is-shown\` on each \`data-step\` element once its beat plays. \`dekc theme\` lists them apart, as state classes. Select them in CSS, never write them in markup (\`DEK010\`).
|
|
256
258
|
- A rule in \`slides/<id>.css\` weighs as if it were written at the end of \`theme.css\`: it beats the theme's \`.slide .x\`, but not a more specific rule such as a layout's \`.slide[data-layout="split"] .x\` or the beat state \`.slide.is-current [data-step]\`. To override one of those, write the same selector.
|
|
257
|
-
-
|
|
259
|
+
- In either stylesheet, colors, font families, easings, and lengths and times in an absolute, viewport, container, or root unit (\`px\`, \`rem\`, \`vw\`, \`ms\`, and the like) come from a token's \`var()\` (\`DEK014\`). Keywords such as \`bold\` or \`thin\`, unitless numbers, \`%\`, and units of the element's own font (\`em\`, \`ch\`, \`lh\`) are not raw values and pass. A value only one slide uses can be a token of its own on that slide's \`.slide\` rule in \`slides/<id>.css\`.
|
|
258
260
|
- Do not add \`<style>\`, \`style=\`, \`<script>\`, event handler attributes (\`onclick=\` and the like), or \`javascript:\` URLs inside slide HTML.
|
|
259
261
|
- Motion CSS cannot express lives in \`slides/<id>.ts\`: \`export default { motion: { <step>: ms }, draw(slide, { index, step, t }) {} } satisfies DekSlide\`. \`DekSlide\` is global, from \`.dek/slide.d.ts\`; do not import it. Key the slide's arrival, before its first beat, as \`"0"\`: every \`data-step\` element is hidden there, so draw what the slide shows before anything happens. Draw from \`t\` alone and set everything you touch on every call, with no timers and no imports, so video and screenshots can seek it. In \`draw\`, find elements by data-* attributes from the slide it is given, never by class or through \`document\`: the built deck holds every slide.
|
|
262
|
+
- Every picture says what it shows, or that it is decoration (\`DEK034\`). An \`<img>\` takes \`alt\`, \`alt=""\` for decoration. An \`<svg>\` with no text of its own takes \`role="img"\` and an \`aria-label\`, or \`aria-hidden="true"\` when it is decoration; one with \`<text>\` is read as it is. An element with \`role="img"\` takes an \`aria-label\`.
|
|
260
263
|
- Keep the deck self-contained: no remote URLs and no paths outside the deck.
|
|
261
264
|
- Every slide carries its place in \`script.md\` as \`--dek-slide-number\` and \`--dek-slide-count\`. Print a folio from them in \`theme.css\`, never by hand: \`.slide { counter-reset: folio var(--dek-slide-number) }\`, then \`content: counter(folio)\`. It follows the script as slides move.
|
|
262
265
|
|
|
@@ -267,14 +270,19 @@ A build system for talks. Write what you will say; dek builds, measures, and shi
|
|
|
267
270
|
- A shot's path names what it drew, and an edit to the slide or the theme replaces the file. Never reuse a shot's path from before an edit: run \`dekc shot <slug>\` again, which shoots only what changed, and read the path it prints.
|
|
268
271
|
- \`dekc shot --sheet\` tiles every slide on one image: read it to judge the deck's balance in one look, then open a slide's own shot for detail.
|
|
269
272
|
- Mark decoration \`aria-hidden="true"\`: a glow that bleeds off the slide, or a sample of text the talk shows as unreadable. Lint measures neither overflow nor contrast on it, and screen readers skip it, so never mark text the audience should read: \`DEK029\` warns of text under it.
|
|
270
|
-
- When a hint sends a fix to \`theme.css\`, make it there, not in \`slides/<id>.css\`: the theme alone draws it that way, so other slides share the problem, and one change fixes them all.
|
|
271
|
-
- One shot shows no motion. \`dekc shot <slug> --motion\` lays the slide's beats out as rows, each held at moments through everything it moves and ending as the shot does. \`dekc shot <a> --to <b> --at 0.5\` freezes the view transition between any two slides.
|
|
273
|
+
- When a hint sends a fix to the deck's \`theme.css\`, make it there, not in \`slides/<id>.css\`: the theme alone draws it that way, so other slides share the problem, and one change fixes them all.
|
|
274
|
+
- One shot shows no motion. \`dekc shot <slug> --motion\` lays the slide's beats out as rows, each held at moments through everything it moves and ending as the shot does. Its \`--json\` puts the sheets under \`sheets\` and each beat's frames under \`motion\`; \`shots\` is empty. \`dekc shot <a> --to <b> --at 0.5\` freezes the view transition between any two slides.
|
|
272
275
|
|
|
273
276
|
## After a rehearsal
|
|
274
277
|
|
|
275
278
|
- \`dekc marks\` lists the beats the speaker marked while rehearsing aloud (\`m\` in the presenter view): the words they stumbled over are \`was\`, at \`line\` in \`script.md\`. Rewrite those beats to be easier to say, and keep each beat's heading so its mark follows it; \`status\` turns \`edited\` once the words changed.
|
|
276
279
|
- Leave \`dekc marks clear\` to the speaker: only saying the new words aloud tells whether they work.
|
|
277
280
|
|
|
281
|
+
## When the human points at an element
|
|
282
|
+
|
|
283
|
+
- \`dekc annotations\` lists the notes the human wrote on elements of the slides (\`a\` on the dev server's page): each element at \`path\`, \`line\`, and \`column\` in the file as it is now, with its \`name\`, the note's \`text\`, and the \`shot\` that shows the slide at the beat the human saw. Read them yourself when the human says to deal with their notes. Fix each, then list them again: \`status\` turns \`edited\` once the slide's own files changed and \`gone\` once none of the note's elements is left. \`edited\` does not mean a note is dealt with: compare the \`shot\` with the note.
|
|
284
|
+
- Leave \`dekc annotations clear\` to the human: they wrote the notes and judge the result.
|
|
285
|
+
|
|
278
286
|
## Before you report a deck as done
|
|
279
287
|
|
|
280
288
|
- \`dekc lint --visual\` passes.
|
|
@@ -6,10 +6,9 @@ import {
|
|
|
6
6
|
selectorLayouts,
|
|
7
7
|
splitSelectorList,
|
|
8
8
|
} from "./css.ts";
|
|
9
|
+
import { blankStringsAndComments } from "./css-scan.ts";
|
|
9
10
|
import { escapeRegExp } from "./escape.ts";
|
|
10
|
-
|
|
11
|
-
/** Classes the player adds at runtime, so a slide's markup never names them. */
|
|
12
|
-
const RUNTIME_CLASSES = ["is-current", "is-shown"];
|
|
11
|
+
import { RUNTIME_CLASSES } from "./theme-facts.ts";
|
|
13
12
|
|
|
14
13
|
export type SlideUsage = {
|
|
15
14
|
/** Classes the slide's markup uses. */
|
|
@@ -18,8 +17,17 @@ export type SlideUsage = {
|
|
|
18
17
|
layout?: string;
|
|
19
18
|
/** The slide's own stylesheet, whose `var()` and animations reach into the theme too. */
|
|
20
19
|
css?: Stylesheet;
|
|
20
|
+
/**
|
|
21
|
+
* The element tags and attribute names the slide's markup has. Given, a rule that needs an
|
|
22
|
+
* element or an attribute the slide lacks, as `.slide ul` or `[data-step]`, is left out; left
|
|
23
|
+
* out, every element and attribute rule under `.slide` is kept.
|
|
24
|
+
*/
|
|
25
|
+
markup?: { tags: Iterable<string>; attributes: Iterable<string> };
|
|
21
26
|
};
|
|
22
27
|
|
|
28
|
+
/** The markup as a selector meets it: lowercased, with what the build stamps on every slide. */
|
|
29
|
+
type Markup = { tags: Set<string>; attributes: Set<string> };
|
|
30
|
+
|
|
23
31
|
type ExcerptEntry = {
|
|
24
32
|
atPath: string[];
|
|
25
33
|
selector: string;
|
|
@@ -29,9 +37,9 @@ type ExcerptEntry = {
|
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
39
|
* The part of a theme one slide depends on, as CSS that reads on its own: the
|
|
32
|
-
* rules its classes and layout select, element and state rules under `.slide
|
|
33
|
-
* the
|
|
34
|
-
* through `var()`. It errs toward keeping a rule, since a rule left out misleads
|
|
40
|
+
* rules its classes and layout select, element and state rules under `.slide`
|
|
41
|
+
* for the elements and attributes it has, the keyframes those rules animate
|
|
42
|
+
* with, and only the tokens they reach through `var()`. It errs toward keeping a rule, since a rule left out misleads
|
|
35
43
|
* a reader silently while an extra one only costs a line.
|
|
36
44
|
*/
|
|
37
45
|
export function themeExcerpt(sheet: Stylesheet, usage: SlideUsage): string {
|
|
@@ -66,6 +74,10 @@ export function themeExcerpt(sheet: Stylesheet, usage: SlideUsage): string {
|
|
|
66
74
|
*/
|
|
67
75
|
function excerptEntries(sheet: Stylesheet, usage: SlideUsage): ExcerptEntry[] {
|
|
68
76
|
const used = new Set([...usage.classes, "slide", ...RUNTIME_CLASSES]);
|
|
77
|
+
const markup = usage.markup && {
|
|
78
|
+
tags: new Set([...usage.markup.tags].map((tag) => tag.toLowerCase())),
|
|
79
|
+
attributes: new Set([...usage.markup.attributes].map((name) => name.toLowerCase())),
|
|
80
|
+
};
|
|
69
81
|
const entries: ExcerptEntry[] = [];
|
|
70
82
|
for (const rule of sheet.rules) {
|
|
71
83
|
// An at-rule's block reads here only where it styles its parent's elements.
|
|
@@ -82,7 +94,7 @@ function excerptEntries(sheet: Stylesheet, usage: SlideUsage): ExcerptEntry[] {
|
|
|
82
94
|
}
|
|
83
95
|
const parts = splitSelectorList(selector)
|
|
84
96
|
.map((part) => part.trim())
|
|
85
|
-
.filter((part) => part && selectorPartApplies(part, used, usage.layout));
|
|
97
|
+
.filter((part) => part && selectorPartApplies(part, used, usage.layout, markup));
|
|
86
98
|
if (parts.length > 0) {
|
|
87
99
|
entries.push({ atPath, selector: parts.join(", "), decls: rule.decls });
|
|
88
100
|
}
|
|
@@ -104,13 +116,53 @@ function animatedKeyframes(styled: CssDeclaration[], entries: ExcerptEntry[]): S
|
|
|
104
116
|
);
|
|
105
117
|
}
|
|
106
118
|
|
|
107
|
-
function selectorPartApplies(
|
|
119
|
+
function selectorPartApplies(
|
|
120
|
+
part: string,
|
|
121
|
+
used: Set<string>,
|
|
122
|
+
layout: string | undefined,
|
|
123
|
+
markup: Markup | undefined,
|
|
124
|
+
): boolean {
|
|
108
125
|
if (part.startsWith("::view-transition")) {
|
|
109
126
|
return false;
|
|
110
127
|
}
|
|
111
128
|
return (
|
|
112
129
|
selectorClasses(part).every((name) => used.has(name)) &&
|
|
113
|
-
selectorLayouts(part).every((name) => name === layout)
|
|
130
|
+
selectorLayouts(part).every((name) => name === layout) &&
|
|
131
|
+
(markup === undefined || selectorFitsMarkup(part, markup))
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Attributes the build sets on every slide, or that the layout check already decides. */
|
|
136
|
+
const STAMPED_ATTRIBUTES = new Set(["data-layout", "data-slug", "style"]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the markup has every element and attribute a selector needs. Only what the selector
|
|
140
|
+
* requires outright counts: a name inside `:not()`, `:is()`, `:has()`, or any other parenthesis
|
|
141
|
+
* may be absent, so it keeps the rule.
|
|
142
|
+
*/
|
|
143
|
+
function selectorFitsMarkup(part: string, markup: Markup): boolean {
|
|
144
|
+
let outside = "";
|
|
145
|
+
let depth = 0;
|
|
146
|
+
for (const ch of blankStringsAndComments(part)) {
|
|
147
|
+
if (ch === "(") {
|
|
148
|
+
depth++;
|
|
149
|
+
} else if (ch === ")") {
|
|
150
|
+
depth = Math.max(0, depth - 1);
|
|
151
|
+
} else if (depth === 0) {
|
|
152
|
+
outside += ch;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const attributes = [...outside.matchAll(/\[\s*([-\w]+)/g)].map((m) => (m[1] ?? "").toLowerCase());
|
|
156
|
+
const bare = outside.replace(/\[[^\]]*\]/g, "").replace(/::?[-\w]+/g, "");
|
|
157
|
+
const tags = [...bare.matchAll(/(?:^|[\s>+~])([A-Za-z][-\w]*)/g)].map((m) =>
|
|
158
|
+
(m[1] ?? "").toLowerCase(),
|
|
159
|
+
);
|
|
160
|
+
return (
|
|
161
|
+
tags.every((tag) => markup.tags.has(tag)) &&
|
|
162
|
+
attributes.every(
|
|
163
|
+
(name) =>
|
|
164
|
+
markup.attributes.has(name) || STAMPED_ATTRIBUTES.has(name) || name.startsWith("data-dek-"),
|
|
165
|
+
)
|
|
114
166
|
);
|
|
115
167
|
}
|
|
116
168
|
|
package/src/core/theme-facts.ts
CHANGED
|
@@ -8,13 +8,21 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export type ThemeLayout = { name: string; example?: string };
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The state classes the player sets at runtime: `is-current` on the slide on screen, `is-shown`
|
|
13
|
+
* on each beat element that has appeared. A theme selects them; a slide's markup never writes them.
|
|
14
|
+
*/
|
|
15
|
+
export const RUNTIME_CLASSES: ReadonlySet<string> = new Set(["is-current", "is-shown"]);
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* What a theme offers a slide, read once: AGENTS.md, `dekc theme`, and lint all say the same
|
|
13
19
|
* thing about it because they all read it here.
|
|
14
20
|
*/
|
|
15
21
|
export type ThemeFacts = {
|
|
16
|
-
/** Every class the theme's selectors name. */
|
|
22
|
+
/** Every class the theme's selectors name for markup to use: its vocabulary. */
|
|
17
23
|
classes: Set<string>;
|
|
24
|
+
/** The runtime state classes its selectors name, which the player sets and markup never does. */
|
|
25
|
+
stateClasses: Set<string>;
|
|
18
26
|
/** The layouts it styles, by name, each with its example markup when it has one. */
|
|
19
27
|
layouts: ThemeLayout[];
|
|
20
28
|
/** The tokens every slide can `var()`, with their values, in source order. */
|
|
@@ -22,8 +30,10 @@ export type ThemeFacts = {
|
|
|
22
30
|
};
|
|
23
31
|
|
|
24
32
|
export function themeFacts(sheet: Stylesheet): ThemeFacts {
|
|
33
|
+
const named = [...cssClassNames(sheet)];
|
|
25
34
|
return {
|
|
26
|
-
classes:
|
|
35
|
+
classes: new Set(named.filter((name) => !RUNTIME_CLASSES.has(name))),
|
|
36
|
+
stateClasses: new Set(named.filter((name) => RUNTIME_CLASSES.has(name))),
|
|
27
37
|
layouts: themeLayouts(sheet),
|
|
28
38
|
tokens: publishedTokens(sheet),
|
|
29
39
|
};
|