@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
package/src/core/html.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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";
|
|
@@ -233,8 +234,11 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
|
|
|
233
234
|
});
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
/**
|
|
237
|
-
|
|
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 };
|
|
238
242
|
|
|
239
243
|
/**
|
|
240
244
|
* Where each start tag of `html` is written in the file it was taken from, in document order,
|
|
@@ -243,12 +247,30 @@ type TagSpot = LineColumn & { classes?: string };
|
|
|
243
247
|
*/
|
|
244
248
|
function startTagSpots(html: string, source: SlideSource): TagSpot[] {
|
|
245
249
|
const mark = uniqueMark(html);
|
|
246
|
-
const
|
|
250
|
+
const tags: Array<{ tag: string; classes: string | null; text: string }> = [];
|
|
251
|
+
const open: number[] = [];
|
|
247
252
|
const marked = rewriteHtml(html, (rewriter) => {
|
|
248
253
|
rewriter.on("*", {
|
|
249
254
|
element(el) {
|
|
250
255
|
el.before(mark, { html: true });
|
|
251
|
-
|
|
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
|
+
}
|
|
252
274
|
},
|
|
253
275
|
});
|
|
254
276
|
});
|
|
@@ -259,13 +281,36 @@ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
|
|
|
259
281
|
.slice(0, -1)
|
|
260
282
|
.map((before, index) => {
|
|
261
283
|
offset += before.length;
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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(" ") };
|
|
266
289
|
});
|
|
267
290
|
}
|
|
268
291
|
|
|
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
|
+
|
|
269
314
|
/**
|
|
270
315
|
* Every slide of the deck in script order, as the player pages hold them. `sources` says where
|
|
271
316
|
* each tag is written, for the dev server's pages.
|
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
@@ -33,6 +33,12 @@ export const PAGE_ID = {
|
|
|
33
33
|
announce: "dek-announce",
|
|
34
34
|
} as const;
|
|
35
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
|
+
|
|
36
42
|
export type PageMode = "player" | "presenter" | "video";
|
|
37
43
|
|
|
38
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?: {
|
|
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
|
-
|
|
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();
|
package/src/core/playwright.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { DekError } from "./error.ts";
|
|
|
6
6
|
import type { Fill } from "./fill.ts";
|
|
7
7
|
import { type Collision, EDGES, type Overflow } from "./overflow.ts";
|
|
8
8
|
import { moduleFilePath } from "./path.ts";
|
|
9
|
+
import type { PptxTextBox } from "./pptx-package.ts";
|
|
9
10
|
import type { MotionBeat, SheetSpec } from "./sheet.ts";
|
|
10
11
|
import { exitWorker, readWorkerRequest, runJsonWorker, workerCommand } from "./spawn.ts";
|
|
11
12
|
import type { Position } from "./step.ts";
|
|
@@ -79,8 +80,23 @@ export type MotionRequest = {
|
|
|
79
80
|
motion: MotionSpec;
|
|
80
81
|
};
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Each slide's still page drawn for a PPTX, at twice its size: its text measured line by line,
|
|
85
|
+
* then the page shot without it, to `screenshotPath`.
|
|
86
|
+
*/
|
|
87
|
+
export type PptxRequest = {
|
|
88
|
+
kind: "pptx";
|
|
89
|
+
viewport: Viewport;
|
|
90
|
+
pages: Array<{ html: string; slug: string; screenshotPath: string }>;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Each page's text, in the order of the request's pages, with the font each run was drawn in. */
|
|
94
|
+
export type PptxResponse = {
|
|
95
|
+
slides: Array<{ slug: string; description: string; boxes: PptxTextBox[] }>;
|
|
96
|
+
};
|
|
97
|
+
|
|
82
98
|
/** What the Playwright worker is asked; the kind says which payload comes with it. */
|
|
83
|
-
export type VisualRequest = PagesRequest | PdfRequest | MorphRequest | MotionRequest;
|
|
99
|
+
export type VisualRequest = PagesRequest | PdfRequest | MorphRequest | MotionRequest | PptxRequest;
|
|
84
100
|
|
|
85
101
|
type OverflowFinding = Overflow & { slug: string; step: string };
|
|
86
102
|
|
|
@@ -151,6 +167,7 @@ type VisualResponses = {
|
|
|
151
167
|
pdf: DoneResponse;
|
|
152
168
|
morph: DoneResponse;
|
|
153
169
|
motion: MotionResponse;
|
|
170
|
+
pptx: PptxResponse;
|
|
154
171
|
};
|
|
155
172
|
|
|
156
173
|
/** The answer to a request of `R`'s kind. */
|
|
@@ -297,6 +314,7 @@ const RESPONSE_PARSERS: {
|
|
|
297
314
|
morph: () => ({}),
|
|
298
315
|
motion: ({ motion, sheets }) =>
|
|
299
316
|
isArrayOf(motion, isMotionBeat) && isArrayOf(sheets, isString) ? { motion, sheets } : null,
|
|
317
|
+
pptx: ({ slides }) => (isArrayOf(slides, isPptxSlide) ? { slides } : null),
|
|
300
318
|
};
|
|
301
319
|
|
|
302
320
|
/** The worker's JSON as the answer to a `kind` request, or null when it is not one. */
|
|
@@ -415,6 +433,38 @@ function isContrastFinding(value: unknown): value is ContrastFinding {
|
|
|
415
433
|
);
|
|
416
434
|
}
|
|
417
435
|
|
|
436
|
+
function isPptxSlide(value: unknown): value is PptxResponse["slides"][number] {
|
|
437
|
+
if (!value || typeof value !== "object") {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
const { slug, description, boxes } = value as Record<string, unknown>;
|
|
441
|
+
return isString(slug) && isString(description) && isArrayOf(boxes, isPptxBox);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isPptxBox(value: unknown): value is PptxTextBox {
|
|
445
|
+
if (!value || typeof value !== "object") {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
const box = value as Record<string, unknown>;
|
|
449
|
+
return (
|
|
450
|
+
["x", "y", "width", "height", "lineHeight"].every((key) => isNumber(box[key])) &&
|
|
451
|
+
isArrayOf(box.runs, (run: unknown): run is PptxTextBox["runs"][number] => {
|
|
452
|
+
if (!run || typeof run !== "object") {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
const { text, size, alpha, letterSpacing, color, font } = run as Record<string, unknown>;
|
|
456
|
+
return (
|
|
457
|
+
isString(text) &&
|
|
458
|
+
isNumber(size) &&
|
|
459
|
+
isNumber(alpha) &&
|
|
460
|
+
isNumber(letterSpacing) &&
|
|
461
|
+
isArrayOf(color, isNumber) &&
|
|
462
|
+
(font === undefined || isString(font))
|
|
463
|
+
);
|
|
464
|
+
})
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
418
468
|
function isMotionBeat(value: unknown): value is MotionBeat {
|
|
419
469
|
if (!value || typeof value !== "object") {
|
|
420
470
|
return false;
|