@hajimism/dek 0.2.0 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.ja.md +3 -1
  3. package/README.md +3 -1
  4. package/package.json +3 -1
  5. package/skills/dek/SKILL.md +44 -0
  6. package/src/cli/annotations.ts +26 -0
  7. package/src/cli/commands.ts +66 -0
  8. package/src/cli/contract.ts +75 -4
  9. package/src/cli/files.ts +3 -3
  10. package/src/cli/flags.ts +1 -1
  11. package/src/cli/format.ts +3 -0
  12. package/src/cli/pptx.ts +20 -0
  13. package/src/cli/serve.ts +1 -1
  14. package/src/cli/show.ts +17 -2
  15. package/src/cli/text.ts +43 -0
  16. package/src/cli/theme.ts +7 -1
  17. package/src/core/annotation-rows.ts +176 -0
  18. package/src/core/annotations.ts +362 -0
  19. package/src/core/chrome.ts +3 -1
  20. package/src/core/diagnostic.ts +1 -0
  21. package/src/core/document.ts +17 -2
  22. package/src/core/html-scan.ts +54 -0
  23. package/src/core/html.ts +144 -14
  24. package/src/core/lint/slide-html.ts +92 -2
  25. package/src/core/lint.ts +8 -1
  26. package/src/core/live-protocol.ts +4 -1
  27. package/src/core/page.ts +7 -0
  28. package/src/core/playwright-module.d.ts +4 -1
  29. package/src/core/playwright-visual.ts +74 -1
  30. package/src/core/playwright.ts +51 -1
  31. package/src/core/pptx-package.ts +312 -0
  32. package/src/core/pptx-text.ts +363 -0
  33. package/src/core/pptx.ts +75 -0
  34. package/src/core/slide-measure.ts +1 -0
  35. package/src/core/sync.ts +13 -5
  36. package/src/core/theme-excerpt.ts +61 -9
  37. package/src/core/theme-facts.ts +12 -2
  38. package/src/core/zip.ts +77 -0
  39. package/src/runtime/annotate-pick.ts +144 -0
  40. package/src/runtime/annotate-place.ts +46 -0
  41. package/src/runtime/annotate-view.ts +752 -0
  42. package/src/runtime/annotate.ts +19 -0
  43. package/src/runtime/browser.ts +5 -1
  44. package/src/runtime/live.ts +1 -0
  45. package/src/runtime/player.ts +15 -7
  46. package/src/runtime/routes.ts +3 -0
  47. package/src/server/auth.ts +3 -1
  48. package/src/server/dev.ts +2 -1
  49. package/src/server/pages.ts +2 -1
  50. package/src/server/routes.ts +77 -4
@@ -39,8 +39,17 @@ export type HtmlScan = {
39
39
  * collapsed. An SVG's `<title>` and `<desc>` describe it and are never drawn, so they hold none.
40
40
  */
41
41
  hiddenTexts: Array<{ element: HtmlElement; text: string }>;
42
+ /**
43
+ * Each outermost picture a screen reader is not told to skip: an `<img>`, an `<svg>`, or an
44
+ * element with `role="img"`, none of them under `aria-hidden="true"`. `text` says whether it
45
+ * holds any text to read, and `titled` whether a `<title>` inside it names it.
46
+ */
47
+ pictures: Picture[];
42
48
  };
43
49
 
50
+ /** A picture in the markup, with what it holds that a screen reader could say. */
51
+ export type Picture = { element: HtmlElement; text: boolean; titled: boolean };
52
+
44
53
  const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
45
54
  /** Elements that give a heading something to show without any text. */
46
55
  const CONTENT_TAGS = new Set(["img", "svg", "picture", "video", "canvas", "object", "math"]);
@@ -62,6 +71,10 @@ export function scanSlideHtml(html: string): HtmlScan {
62
71
  // The outermost aria-hidden element still open, and how many undrawn elements are open in it.
63
72
  let hiding: { index: number; text: string } | undefined;
64
73
  let undrawn = 0;
74
+ // The outermost picture still open, and whether a <title> inside it is open.
75
+ const pictures: Array<{ index: number; text: boolean; titled: boolean }> = [];
76
+ let picture: { index: number; text: boolean; titled: boolean } | undefined;
77
+ let titles = 0;
65
78
 
66
79
  const transformed = new HTMLRewriter()
67
80
  .on("*", {
@@ -94,6 +107,27 @@ export function scanSlideHtml(html: string): HtmlScan {
94
107
  hiding = undefined;
95
108
  });
96
109
  }
110
+ if (picture) {
111
+ if (tag === "title" && el.canHaveContent) {
112
+ titles++;
113
+ el.onEndTag(() => {
114
+ titles--;
115
+ });
116
+ }
117
+ } else if (
118
+ !hiding &&
119
+ el.getAttribute("aria-hidden")?.trim() !== "true" &&
120
+ isPicture(tag, el.getAttribute("role"))
121
+ ) {
122
+ const entry = { index: parsed.length, text: false, titled: false };
123
+ pictures.push(entry);
124
+ if (el.canHaveContent) {
125
+ picture = entry;
126
+ el.onEndTag(() => {
127
+ picture = undefined;
128
+ });
129
+ }
130
+ }
97
131
  if (HEADING_TAGS.has(tag) && el.canHaveContent) {
98
132
  const heading = {
99
133
  index: parsed.length,
@@ -119,6 +153,10 @@ export function scanSlideHtml(html: string): HtmlScan {
119
153
  if (hiding && undrawn === 0) {
120
154
  hiding.text += chunk.text;
121
155
  }
156
+ if (picture && !hiding && chunk.text.trim() !== "") {
157
+ picture.text = true;
158
+ picture.titled ||= titles > 0;
159
+ }
122
160
  if (open.length > 0 && chunk.text.trim() !== "") {
123
161
  for (const heading of open) {
124
162
  heading.content = true;
@@ -187,9 +225,25 @@ export function scanSlideHtml(html: string): HtmlScan {
187
225
  const squashed = text.replace(/\s+/g, " ").trim();
188
226
  return element && squashed !== "" ? [{ element, text: squashed }] : [];
189
227
  }),
228
+ pictures: pictures.flatMap(({ index, text, titled }) => {
229
+ const element = elements[index];
230
+ return element ? [{ element, text, titled }] : [];
231
+ }),
190
232
  };
191
233
  }
192
234
 
235
+ /**
236
+ * Whether an element draws a picture a screen reader must be told about. `role="presentation"`
237
+ * or `"none"` says it is decoration, as `aria-hidden` does.
238
+ */
239
+ function isPicture(tag: string, role: string | null): boolean {
240
+ const roles = (role ?? "").trim().toLowerCase().split(/\s+/);
241
+ if (roles[0] === "presentation" || roles[0] === "none") {
242
+ return false;
243
+ }
244
+ return tag === "img" || tag === "svg" || roles[0] === "img";
245
+ }
246
+
193
247
  /** The value of `name` on `element`, as the parser read it. */
194
248
  function attributeValue(element: HtmlElement, name: string): string | undefined {
195
249
  return element.attributes.find((attribute) => attribute.name === name)?.value;
package/src/core/html.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { cutText } from "./annotation-rows.ts";
1
2
  import { assetInliner } from "./assets.ts";
2
3
  import { deckPaths } from "./deck-paths.ts";
3
4
  import { escapeAttr, escapeHtml } from "./escape.ts";
5
+ import { type LineColumn, lineLocator } from "./lines.ts";
4
6
  import { isInside } from "./path.ts";
5
7
  import { type ProjectDeck, readDeckFile } from "./resolve.ts";
6
8
  import { FALLBACK_LANG, type Section } from "./schema.ts";
@@ -41,6 +43,11 @@ export function hasSlideClass(className: string | null | undefined): boolean {
41
43
  * written. A slide that never closes runs to `</body>`, or to the end.
42
44
  */
43
45
  export function extractSlideSection(html: string): string | undefined {
46
+ return locateSlideSection(html)?.section;
47
+ }
48
+
49
+ /** The first slide section, as `extractSlideSection` reads it, and the offset it starts at. */
50
+ function locateSlideSection(html: string): { section: string; start: number } | undefined {
44
51
  const mark = uniqueMark(html);
45
52
  let found = false;
46
53
  let closed = false;
@@ -68,10 +75,13 @@ export function extractSlideSection(html: string): string | undefined {
68
75
  : undefined;
69
76
  // The parser closes an unclosed slide at the end of its parent, past `</body>`.
70
77
  if (slide !== undefined && /<\/section\s*>$/i.test(slide)) {
71
- return slide;
78
+ return { section: slide, start };
72
79
  }
73
80
  const bodyClose = html.slice(start).search(/<\/body>/i);
74
- return bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start);
81
+ return {
82
+ section: bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start),
83
+ start,
84
+ };
75
85
  }
76
86
 
77
87
  /** A deck's slide markup, each file read at most once. */
@@ -86,32 +96,43 @@ export type DeckSlides = {
86
96
  * stops on them, because at the venue a deck that shows beats one that does not.
87
97
  */
88
98
  section(slug: string): string;
99
+ /** Where the written section is in `slides/<slug>.html`; none for a skeleton. */
100
+ source(slug: string): SlideSource | undefined;
89
101
  };
90
102
 
103
+ /** A slide file and the offset its section starts at: the section is `file` from `start`. */
104
+ type SlideSource = { file: string; start: number };
105
+
91
106
  export function deckSlides(deck: ProjectDeck): DeckSlides {
92
107
  const paths = deckPaths(deck.dir);
93
- const read = new Map<string, string | undefined>();
108
+ const read = new Map<string, (SlideSource & { section: string }) | undefined>();
94
109
  let layouts: SkeletonLayouts | undefined;
95
110
  const skeleton = (slug: string): string | undefined => {
96
111
  layouts ??= deckLayouts(deck.dir);
97
112
  return skeletonHtml(deck.deck, slug, layouts);
98
113
  };
99
- const written = (slug: string): string | undefined => {
114
+ const located = (slug: string): (SlideSource & { section: string }) | undefined => {
100
115
  if (!read.has(slug)) {
101
116
  const path = paths.slide(slug, ".html");
102
117
  // A slug from a URL may try to climb out of slides/.
103
- const source =
118
+ const file =
104
119
  isSafeSlideSlug(slug) && isInside(path, paths.slides)
105
120
  ? readDeckFile(deck.dir, path)
106
121
  : undefined;
107
- read.set(slug, source === undefined ? undefined : extractSlideSection(source));
122
+ const found = file === undefined ? undefined : locateSlideSection(file);
123
+ read.set(slug, file !== undefined && found ? { file, ...found } : undefined);
108
124
  }
109
125
  return read.get(slug);
110
126
  };
127
+ const written = (slug: string): string | undefined => located(slug)?.section;
111
128
  return {
112
129
  has: (slug) => written(slug) !== undefined,
113
130
  written,
114
131
  section: (slug) => written(slug) ?? extractSlideSection(skeleton(slug) ?? "") ?? "",
132
+ source(slug) {
133
+ const found = located(slug);
134
+ return found && { file: found.file, start: found.start };
135
+ },
115
136
  };
116
137
  }
117
138
 
@@ -140,15 +161,24 @@ export type SlideStamp = {
140
161
  beat?: { index: number; step: string };
141
162
  /** Inline the deck's files as data: URIs, for a page that stands alone. */
142
163
  inline?: { deckDir: string };
164
+ /**
165
+ * Where the section is written, for the dev server's pages: every start tag then says where
166
+ * in the file it is, as `data-dek-source="<line>:<column>"`, and the classes it is written
167
+ * with, as `data-dek-class`, so annotate mode can name it as the file does whatever a script
168
+ * adds to it.
169
+ */
170
+ source?: SlideSource;
143
171
  };
144
172
 
145
173
  /**
146
174
  * One slide section with what the page needs on it, in one parser pass: its `data-slug` unless
147
- * it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, and for a still
148
- * page the classes and beat the player would have set.
175
+ * it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, for a still page
176
+ * the classes and beat the player would have set, and for the dev server where each tag is
177
+ * written.
149
178
  */
150
179
  export function stampSlide(html: string, stamp: SlideStamp): string {
151
180
  let done = false;
181
+ const spots = stamp.source && startTagSpots(html, stamp.source);
152
182
  return rewriteHtml(html, (rewriter) => {
153
183
  rewriter.on("section", {
154
184
  element(el) {
@@ -186,21 +216,121 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
186
216
  if (stamp.inline) {
187
217
  rewriter.on("*", assetInliner(stamp.inline.deckDir));
188
218
  }
219
+ if (spots) {
220
+ // The same parser over the same markup meets the start tags in the same order.
221
+ let index = 0;
222
+ rewriter.on("*", {
223
+ element(el) {
224
+ const spot = spots[index++];
225
+ if (spot) {
226
+ el.setAttribute("data-dek-source", `${spot.line}:${spot.column}`);
227
+ if (spot.classes !== undefined) {
228
+ el.setAttribute("data-dek-class", spot.classes);
229
+ }
230
+ }
231
+ },
232
+ });
233
+ }
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Where a start tag is written, its tag, its classes as written (none when it has no `class`),
239
+ * and the text inside it as written, whitespace collapsed.
240
+ */
241
+ type TagSpot = LineColumn & { tag: string; classes?: string; text: string };
242
+
243
+ /**
244
+ * Where each start tag of `html` is written in the file it was taken from, in document order,
245
+ * read before anything else touches the markup. A marker goes in front of each tag; where the
246
+ * markers land, less their own length, is where the tags were.
247
+ */
248
+ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
249
+ const mark = uniqueMark(html);
250
+ const tags: Array<{ tag: string; classes: string | null; text: string }> = [];
251
+ const open: number[] = [];
252
+ const marked = rewriteHtml(html, (rewriter) => {
253
+ rewriter.on("*", {
254
+ element(el) {
255
+ el.before(mark, { html: true });
256
+ const index = tags.length;
257
+ tags.push({ tag: el.tagName.toLowerCase(), classes: el.getAttribute("class"), text: "" });
258
+ if (el.canHaveContent) {
259
+ open.push(index);
260
+ el.onEndTag(() => {
261
+ open.splice(open.lastIndexOf(index), 1);
262
+ });
263
+ }
264
+ },
265
+ });
266
+ rewriter.onDocument({
267
+ text(chunk) {
268
+ for (const index of open) {
269
+ const tag = tags[index];
270
+ if (tag) {
271
+ tag.text += chunk.text;
272
+ }
273
+ }
274
+ },
275
+ });
189
276
  });
277
+ const spot = lineLocator(source.file);
278
+ let offset = source.start;
279
+ return marked
280
+ .split(mark)
281
+ .slice(0, -1)
282
+ .map((before, index) => {
283
+ offset += before.length;
284
+ const { tag = "", classes = null, text = "" } = tags[index] ?? {};
285
+ const found = { ...spot(offset), tag, text: text.replace(/\s+/g, " ").trim() };
286
+ return classes === null
287
+ ? found
288
+ : { ...found, classes: classes.split(/\s+/).filter(Boolean).join(" ") };
289
+ });
190
290
  }
191
291
 
192
- /** Every slide of the deck in script order, as the player pages hold them. */
193
- export function collectSlidesHtml(deck: ProjectDeck, options: { inline: boolean }): string {
292
+ /**
293
+ * An element of a written slide as annotate mode names it, whatever a script adds to it: where
294
+ * its start tag is written, `<line>:<column>`, its tag and classes as written, as `div.chevron`,
295
+ * and its text as written, cut short. The slide itself has no text: all of the slide names
296
+ * nothing.
297
+ */
298
+ export type WrittenElement = { source: string; name: string; text: string };
299
+
300
+ /** Every element of the slide `slides/<slug>.html` writes, in document order; none for a skeleton. */
301
+ export function writtenElements(slides: DeckSlides, slug: string): WrittenElement[] | undefined {
302
+ const html = slides.written(slug);
303
+ const source = slides.source(slug);
304
+ if (html === undefined || source === undefined) {
305
+ return undefined;
306
+ }
307
+ return startTagSpots(html, source).map((spot, index) => ({
308
+ source: `${spot.line}:${spot.column}`,
309
+ name: [spot.tag, ...(spot.classes ?? "").split(" ").filter(Boolean)].join("."),
310
+ text: index === 0 ? "" : cutText(spot.text),
311
+ }));
312
+ }
313
+
314
+ /**
315
+ * Every slide of the deck in script order, as the player pages hold them. `sources` says where
316
+ * each tag is written, for the dev server's pages.
317
+ */
318
+ export function collectSlidesHtml(
319
+ deck: ProjectDeck,
320
+ options: { inline: boolean; sources?: boolean },
321
+ ): string {
194
322
  const slides = deckSlides(deck);
195
323
  const { sections } = deck.deck;
196
324
  return sections
197
- .map((section, index) =>
198
- stampSlide(slides.section(section.slug), {
325
+ .map((section, index) => {
326
+ const source = options.sources ? slides.source(section.slug) : undefined;
327
+ return stampSlide(slides.section(section.slug), {
199
328
  slug: section.slug,
200
329
  place: slidePlace(sections, index),
201
330
  ...(options.inline ? { inline: { deckDir: deck.dir } } : {}),
202
- }),
203
- )
331
+ ...(source ? { source } : {}),
332
+ });
333
+ })
204
334
  .join("");
205
335
  }
206
336
 
@@ -1,8 +1,9 @@
1
1
  import { type Diagnostic, diag } from "../diagnostic.ts";
2
- import type { HtmlAttribute, HtmlScan, SourceSpot } from "../html-scan.ts";
2
+ import type { HtmlAttribute, HtmlScan, Picture, SourceSpot } from "../html-scan.ts";
3
3
  import type { Section } from "../schema.ts";
4
4
  import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
5
5
  import { suggest } from "../suggest.ts";
6
+ import { RUNTIME_CLASSES } from "../theme-facts.ts";
6
7
  import { isUrlAttribute } from "../url-attributes.ts";
7
8
  import { assetRefDiagnostics } from "./asset-refs.ts";
8
9
  import { RESERVED_MORPHS } from "./morph.ts";
@@ -52,6 +53,7 @@ export function lintSlideHtml(
52
53
  ...presentationDiagnostics(slide),
53
54
  ...emptyHeadingDiagnostics(slide),
54
55
  ...hiddenTextDiagnostics(slide),
56
+ ...pictureDiagnostics(slide),
55
57
  ...(options.classes
56
58
  ? unknownClassDiagnostics(slide, options.classes, options.hasScript === true)
57
59
  : []),
@@ -442,7 +444,85 @@ function hiddenTextDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[]
442
444
  });
443
445
  }
444
446
 
445
- /** DEK010: a class neither theme.css nor the slide's own stylesheet defines. */
447
+ /**
448
+ * DEK034: a picture with nothing for a screen reader to say: an `<img>` without `alt`, an `<svg>`
449
+ * with no text and no name, or an element whose `role="img"` has no name. Each is either content,
450
+ * which wants words, or decoration, which says so: `alt=""` or `aria-hidden="true"`. An SVG with
451
+ * text of its own is read as it is, so the hint never sends a labeled figure to `aria-hidden`,
452
+ * where DEK029 would warn of it.
453
+ */
454
+ function pictureDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
455
+ return scan.pictures.flatMap((picture) => {
456
+ const found = unnamedPicture(picture);
457
+ return found
458
+ ? [
459
+ diag("DEK034", {
460
+ message: found.message,
461
+ path,
462
+ ...spotOf(picture.element),
463
+ slug: section.slug,
464
+ hint: found.hint,
465
+ data: found.data,
466
+ }),
467
+ ]
468
+ : [];
469
+ });
470
+ }
471
+
472
+ function unnamedPicture({
473
+ element,
474
+ text,
475
+ titled,
476
+ }: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
477
+ const value = (name: string): string | undefined =>
478
+ element.attributes.find((attribute) => attribute.name === name)?.value;
479
+ const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(
480
+ (name) => name !== undefined && name.trim() !== "",
481
+ );
482
+ const { tag } = element;
483
+ if (named || (tag === "img" && value("alt") !== undefined)) {
484
+ return undefined;
485
+ }
486
+ if ((value("role") ?? "").trim().toLowerCase().split(/\s+/)[0] === "img") {
487
+ if (titled) {
488
+ return undefined;
489
+ }
490
+ const data = { tag, role: "img" };
491
+ return text
492
+ ? {
493
+ message: `<${tag} role="img"> has no name, and role="img" keeps a screen reader from reading its text`,
494
+ hint: 'give it aria-label="…" saying what it shows, or remove role="img" so its text is read',
495
+ data,
496
+ }
497
+ : {
498
+ message: `<${tag} role="img"> has no name, so a screen reader cannot say what it shows`,
499
+ hint: 'give it aria-label="…" saying what it shows, or replace role="img" with aria-hidden="true" if it is decoration',
500
+ data,
501
+ };
502
+ }
503
+ if (tag === "img") {
504
+ const src = value("src");
505
+ return {
506
+ message: `<img${src === undefined ? "" : ` src="${src}"`}> has no alt, so a screen reader cannot say what it shows`,
507
+ hint: 'add alt="…" saying what the picture shows, or alt="" if it is decoration',
508
+ data: src === undefined ? { tag } : { tag, src },
509
+ };
510
+ }
511
+ if (text) {
512
+ return undefined;
513
+ }
514
+ return {
515
+ message: `<${tag}> has nothing to read and no name, so a screen reader skips what it shows`,
516
+ hint: 'give it role="img" and aria-label="…" saying what it shows, or aria-hidden="true" if it is decoration',
517
+ data: { tag },
518
+ };
519
+ }
520
+
521
+ /**
522
+ * DEK010: a class neither theme.css nor the slide's own stylesheet defines, or a state class the
523
+ * player sets, which markup that writes it would claim before the player does. `classes` holds
524
+ * neither.
525
+ */
446
526
  function unknownClassDiagnostics(
447
527
  { section, path, scan }: SlideHtml,
448
528
  classes: Set<string>,
@@ -468,6 +548,16 @@ function unknownClassDiagnostics(
468
548
  }
469
549
  }
470
550
  return [...unknown].map(([name, attribute]) => {
551
+ if (RUNTIME_CLASSES.has(name)) {
552
+ return diag("DEK010", {
553
+ message: `class "${name}" is a state the player sets at runtime, not a class for markup`,
554
+ path,
555
+ ...spotOf(attribute),
556
+ slug: section.slug,
557
+ hint: `remove "${name}" from the class attribute: the player puts is-current on the slide on screen and is-shown on each data-step element once its beat plays. To style that state, select it in theme.css or slides/${section.slug}.css, as .slide.is-current [data-step].is-shown`,
558
+ data: { class: name, state: true },
559
+ });
560
+ }
471
561
  const suggestion = suggest(name, known);
472
562
  return diag("DEK010", {
473
563
  message: `class "${name}" is not defined in theme.css`,
package/src/core/lint.ts CHANGED
@@ -19,6 +19,7 @@ import { voiceDiagnostics } from "./lint/voice.ts";
19
19
  import { asResolvedDeck, type Project, type ProjectDeck } from "./resolve.ts";
20
20
  import type { Section } from "./schema.ts";
21
21
  import { evaluateSlideScripts, slideScriptsProblems } from "./slide-script-eval.ts";
22
+ import { RUNTIME_CLASSES } from "./theme-facts.ts";
22
23
 
23
24
  export { unreadableScriptDiagnostics } from "./lint/script.ts";
24
25
  export { silentCueDiagnostics } from "./lint/voice.ts";
@@ -122,7 +123,13 @@ function slideDiagnostics(ctx: LintContext, section: Section): Diagnostic[] {
122
123
  deckDir: ctx.deck.dir,
123
124
  skeleton: source.skeleton,
124
125
  hasScript: script !== undefined,
125
- classes: ctx.theme && new Set([...ctx.theme.classes, ...(sheet ? cssClassNames(sheet) : [])]),
126
+ classes:
127
+ ctx.theme &&
128
+ new Set(
129
+ [...ctx.theme.classes, ...(sheet ? cssClassNames(sheet) : [])].filter(
130
+ (name) => !RUNTIME_CLASSES.has(name),
131
+ ),
132
+ ),
126
133
  // A theme with no layouts at all has nothing to check a data-layout against.
127
134
  ...(ctx.theme && ctx.theme.layouts.length > 0
128
135
  ? {
@@ -12,7 +12,9 @@ export type LiveEvent =
12
12
  | { type: "reload-theme" }
13
13
  | { type: "reload-script"; slugs: string[] }
14
14
  | { type: "diagnostics"; diagnostics: Diagnostic[] }
15
- | { type: "timeline" };
15
+ | { type: "timeline" }
16
+ /** The deck's annotations changed; a page that shows them asks for them again. */
17
+ | { type: "annotations" };
16
18
 
17
19
  export type LiveEventType = LiveEvent["type"];
18
20
 
@@ -32,6 +34,7 @@ const FIELDS_OK: Record<LiveEventType, (event: Record<string, unknown>) => boole
32
34
  "reload-script": (event) => isStrings(event.slugs),
33
35
  diagnostics: (event) => Array.isArray(event.diagnostics),
34
36
  timeline: () => true,
37
+ annotations: () => true,
35
38
  };
36
39
 
37
40
  /** Whether a message read off the stream is one this build of the page understands. */
package/src/core/page.ts CHANGED
@@ -28,10 +28,17 @@ export const PAGE_ID = {
28
28
  laser: "dek-laser",
29
29
  laserToggle: "dek-laser-toggle",
30
30
  markToggle: "dek-mark-toggle",
31
+ annotateToggle: "dek-annotate-toggle",
31
32
  hint: "dek-hint",
32
33
  announce: "dek-announce",
33
34
  } as const;
34
35
 
36
+ /**
37
+ * The event the player fires on `document` when the dev server says the deck's annotations
38
+ * changed, for annotate mode, which is compiled apart from the player, to ask for them again.
39
+ */
40
+ export const ANNOTATIONS_CHANGED = "dek-annotations";
41
+
35
42
  export type PageMode = "player" | "presenter" | "video";
36
43
 
37
44
  /** The page's settings, written on <body> as `data-*` and read back once by the runtime. */
@@ -34,7 +34,10 @@ declare module "playwright" {
34
34
  };
35
35
  export type Browser = {
36
36
  newPage(options?: { viewport?: Viewport }): Promise<Page>;
37
- newContext(options?: { viewport?: Viewport }): Promise<BrowserContext>;
37
+ newContext(options?: {
38
+ viewport?: Viewport;
39
+ deviceScaleFactor?: number;
40
+ }): Promise<BrowserContext>;
38
41
  close(): Promise<void>;
39
42
  };
40
43
  export const chromium: {
@@ -21,12 +21,15 @@ import type {
21
21
  PagesRequest,
22
22
  PagesResponse,
23
23
  PdfRequest,
24
+ PptxRequest,
25
+ PptxResponse,
24
26
  ResponseTo,
25
27
  VisualPage,
26
28
  VisualRequest,
27
29
  } from "./playwright.ts";
28
30
  import { captureMotion } from "./playwright-motion.ts";
29
31
  import { renderSheets } from "./playwright-sheet.ts";
32
+ import { measurePptxTextsInPage, PPTX_OWNER_ATTR } from "./pptx-text.ts";
30
33
  import { pseudoTextBoxes } from "./pseudo-text.ts";
31
34
  import { motionSheets } from "./sheet.ts";
32
35
  import {
@@ -47,7 +50,11 @@ export async function runVisualRequest<R extends VisualRequest>(
47
50
  browser: Browser,
48
51
  request: R,
49
52
  ): Promise<ResponseTo<R>> {
50
- const context = await browser.newContext({ viewport: request.viewport });
53
+ // A PPTX's pictures are shown full screen, so they are drawn at twice the slide's size.
54
+ const context = await browser.newContext({
55
+ viewport: request.viewport,
56
+ ...(request.kind === "pptx" ? { deviceScaleFactor: 2 } : {}),
57
+ });
51
58
  try {
52
59
  return (await answer(context, request)) as ResponseTo<R>;
53
60
  } finally {
@@ -65,6 +72,8 @@ function answer(context: BrowserContext, request: VisualRequest) {
65
72
  return shootMorph(context, request);
66
73
  case "motion":
67
74
  return shootMotion(context, request);
75
+ case "pptx":
76
+ return shootPptx(context, request);
68
77
  default: {
69
78
  const unknown: never = request;
70
79
  throw new Error(`unknown visual request ${JSON.stringify(unknown)}`);
@@ -86,6 +95,70 @@ async function printPdf(context: BrowserContext, request: PdfRequest): Promise<D
86
95
  return {};
87
96
  }
88
97
 
98
+ /** Each slide's text measured and taken out, and the slide shot without it, page by page. */
99
+ async function shootPptx(context: BrowserContext, request: PptxRequest): Promise<PptxResponse> {
100
+ const slides = await visitInPages(
101
+ request.pages,
102
+ () => context.newPage(),
103
+ async (page, pageReq) => {
104
+ await page.setContent(pageReq.html, { waitUntil: "load" });
105
+ await page.evaluate(() => document.fonts.ready.then(() => undefined));
106
+ const measured = await page.evaluate(measurePptxTextsInPage);
107
+ const fonts = await platformFonts(page);
108
+ await page.screenshot({ path: pageReq.screenshotPath, fullPage: false });
109
+ return {
110
+ slug: pageReq.slug,
111
+ description: measured.description,
112
+ boxes: measured.boxes.map((box) => ({
113
+ ...box,
114
+ runs: box.runs.map(({ owner, ...run }) => {
115
+ const font = fonts.get(owner);
116
+ return font === undefined ? run : { ...run, font };
117
+ }),
118
+ })),
119
+ };
120
+ },
121
+ );
122
+ return { slides };
123
+ }
124
+
125
+ /**
126
+ * The font Chromium drew each marked element's text with, by the element's number: the one that
127
+ * drew most of its glyphs, as the DevTools protocol reports it. A theme names a stack, or only
128
+ * `sans-serif`; this is the face that was found.
129
+ */
130
+ async function platformFonts(page: Page): Promise<Map<number, string>> {
131
+ const fonts = new Map<number, string>();
132
+ const cdp = await page.context().newCDPSession(page);
133
+ try {
134
+ await cdp.send("DOM.enable");
135
+ await cdp.send("CSS.enable");
136
+ const { root } = (await cdp.send("DOM.getDocument", { depth: -1 })) as {
137
+ root: { nodeId: number };
138
+ };
139
+ const { nodeIds } = (await cdp.send("DOM.querySelectorAll", {
140
+ nodeId: root.nodeId,
141
+ selector: `[${PPTX_OWNER_ATTR}]`,
142
+ })) as { nodeIds: number[] };
143
+ for (const nodeId of nodeIds) {
144
+ const { attributes } = (await cdp.send("DOM.getAttributes", { nodeId })) as {
145
+ attributes: string[];
146
+ };
147
+ const owner = Number(attributes[attributes.indexOf(PPTX_OWNER_ATTR) + 1]);
148
+ const { fonts: used } = (await cdp.send("CSS.getPlatformFontsForNode", { nodeId })) as {
149
+ fonts: Array<{ familyName: string; glyphCount: number }>;
150
+ };
151
+ const main = [...used].sort((a, b) => b.glyphCount - a.glyphCount)[0];
152
+ if (main && Number.isInteger(owner)) {
153
+ fonts.set(owner, main.familyName);
154
+ }
155
+ }
156
+ } finally {
157
+ await cdp.detach();
158
+ }
159
+ return fonts;
160
+ }
161
+
89
162
  /** The move between two slides, frozen at a moment and shot. */
90
163
  async function shootMorph(context: BrowserContext, request: MorphRequest): Promise<DoneResponse> {
91
164
  const page = await context.newPage();