@hajimism/dek 0.3.1 → 0.3.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 +19 -0
- package/package.json +5 -5
- package/src/cli/commands.ts +2 -1
- package/src/core/pdf-text.ts +191 -0
- package/src/core/playwright-module.d.ts +6 -1
- package/src/core/playwright-visual.ts +40 -4
- package/src/core/playwright.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
Release notes for `@hajimism/dek`. From 0.1.0 on, release-please writes this file from the Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
+
## [0.3.2](https://github.com/hajimism/dek/compare/v0.3.1...v0.3.2) (2026-10-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **pdf:** print each slide as a picture with its text laid over it ([02f49a8](https://github.com/hajimism/dek/commit/02f49a855209e1c4430613fd9358699305bcbb9b))
|
|
11
|
+
* **pdf:** print each slide as a picture with its text laid over it ([b88259d](https://github.com/hajimism/dek/commit/b88259dbcc80ed0a1450cc6bf6422c71120c5802))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Performance
|
|
15
|
+
|
|
16
|
+
* **pdf:** keep a slide of photographs as a JPEG ([19a9092](https://github.com/hajimism/dek/commit/19a909214b256c8bbcf66a443f00c54c2b43766e))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* record the PDF as pictures in ADR 14 and the presenting guide ([c889781](https://github.com/hajimism/dek/commit/c88978175a9b3812f611d8af4de94b2de57556c1))
|
|
22
|
+
* say which slides the PDF keeps as JPEGs, in ADR 14 and the guide ([de733fb](https://github.com/hajimism/dek/commit/de733fb4589d5f6f45f6f9a83b60673d2feab706))
|
|
23
|
+
|
|
5
24
|
## [0.3.1](https://github.com/hajimism/dek/compare/v0.3.0...v0.3.1) (2026-10-08)
|
|
6
25
|
|
|
7
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hajimism/dek",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "A build system for talks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Hajime Nakagawa (https://github.com/hajimism)",
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"zod": "^4.6.5"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@biomejs/biome": "2.5.
|
|
65
|
-
"@happy-dom/global-registrator": "
|
|
64
|
+
"@biomejs/biome": "2.5.15",
|
|
65
|
+
"@happy-dom/global-registrator": "21.0.1",
|
|
66
66
|
"@types/bun": "^1.4.2",
|
|
67
|
-
"happy-dom": "
|
|
68
|
-
"knip": "6.
|
|
67
|
+
"happy-dom": "21.0.1",
|
|
68
|
+
"knip": "6.41.0",
|
|
69
69
|
"playwright": "^1.63.0",
|
|
70
70
|
"typescript": "^7.0.2",
|
|
71
71
|
"vitepress": "^1.6.4"
|
package/src/cli/commands.ts
CHANGED
|
@@ -774,7 +774,8 @@ export const COMMANDS = {
|
|
|
774
774
|
args: [],
|
|
775
775
|
flags: ["root-dist"],
|
|
776
776
|
usage: ["dekc pdf [deck] [--root-dist]"],
|
|
777
|
-
summary:
|
|
777
|
+
summary:
|
|
778
|
+
"Write dist/<deck>.pdf, one page per slide at its last beat: a picture of it with its text\nlaid over it, unseen, to select and search. Needs Playwright.",
|
|
778
779
|
group: "Output",
|
|
779
780
|
overview: [["dekc pdf [--root-dist]", "write a PDF"]],
|
|
780
781
|
agent: ["dekc pdf [--root-dist]"],
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// What a PDF prints of a slide: a picture of the slide as the browser drew it, with its text laid
|
|
3
|
+
// over it, unseen, so a reader can still select, search, and copy it. Chromium writes a blurred
|
|
4
|
+
// box-shadow and `background-clip: text` into a PDF as soft masks, which Apple's viewers (Preview,
|
|
5
|
+
// Quick Look, Safari, iOS) draw as flat boxes; a picture looks the same in every viewer.
|
|
6
|
+
|
|
7
|
+
/** The slides of the print document, one PDF page each. */
|
|
8
|
+
export const PDF_SLIDES = "#deck > .slide";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Leaves each slide's text where it was laid out and paints nothing else: no color, no
|
|
12
|
+
* background, no border, no shadow, no picture. Only paint changes, never layout, so the text lies
|
|
13
|
+
* where the picture under it shows it. In a layer, so that these `!important` declarations win
|
|
14
|
+
* over any the theme or a slide writes, whatever their selectors weigh.
|
|
15
|
+
*/
|
|
16
|
+
export const PDF_TEXT_CSS = `@layer dek-pdf-text {
|
|
17
|
+
#deck > .slide,
|
|
18
|
+
#deck > .slide::before,
|
|
19
|
+
#deck > .slide::after,
|
|
20
|
+
#deck > .slide *,
|
|
21
|
+
#deck > .slide *::before,
|
|
22
|
+
#deck > .slide *::after,
|
|
23
|
+
#deck > .slide *::marker,
|
|
24
|
+
#deck > .slide *::first-letter,
|
|
25
|
+
#deck > .slide *::first-line {
|
|
26
|
+
color: transparent !important;
|
|
27
|
+
-webkit-text-fill-color: transparent !important;
|
|
28
|
+
-webkit-text-stroke-color: transparent !important;
|
|
29
|
+
text-decoration-color: transparent !important;
|
|
30
|
+
text-emphasis-color: transparent !important;
|
|
31
|
+
text-shadow: none !important;
|
|
32
|
+
border-color: transparent !important;
|
|
33
|
+
border-image: none !important;
|
|
34
|
+
outline-color: transparent !important;
|
|
35
|
+
column-rule-color: transparent !important;
|
|
36
|
+
box-shadow: none !important;
|
|
37
|
+
transition: none !important;
|
|
38
|
+
}
|
|
39
|
+
#deck > .slide::before,
|
|
40
|
+
#deck > .slide::after,
|
|
41
|
+
#deck > .slide *,
|
|
42
|
+
#deck > .slide *::before,
|
|
43
|
+
#deck > .slide *::after,
|
|
44
|
+
#deck > .slide *::first-letter,
|
|
45
|
+
#deck > .slide *::first-line {
|
|
46
|
+
background: none !important;
|
|
47
|
+
}
|
|
48
|
+
#deck > .slide {
|
|
49
|
+
opacity: 1 !important;
|
|
50
|
+
mix-blend-mode: normal !important;
|
|
51
|
+
}
|
|
52
|
+
#deck > .slide :is(img, video, canvas, iframe, embed, object, input, select, textarea, button, progress, meter),
|
|
53
|
+
#deck > .slide svg image {
|
|
54
|
+
opacity: 0 !important;
|
|
55
|
+
}
|
|
56
|
+
#deck > .slide svg * {
|
|
57
|
+
fill: transparent !important;
|
|
58
|
+
stroke: transparent !important;
|
|
59
|
+
}
|
|
60
|
+
}`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where each slide lies on the whole page, for its picture to be shot.
|
|
64
|
+
*
|
|
65
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
66
|
+
*/
|
|
67
|
+
export function slideBoxesInPage(
|
|
68
|
+
selector: string,
|
|
69
|
+
): { x: number; y: number; width: number; height: number }[] {
|
|
70
|
+
return [...document.querySelectorAll(selector)].map((slide) => {
|
|
71
|
+
const box = slide.getBoundingClientRect();
|
|
72
|
+
return {
|
|
73
|
+
x: box.left + window.scrollX,
|
|
74
|
+
y: box.top + window.scrollY,
|
|
75
|
+
width: box.width,
|
|
76
|
+
height: box.height,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Takes every paint but the text away from the page's slides, once their pictures are shot. A
|
|
83
|
+
* filter or a backdrop filter would draw over the picture, or turn the text under it into pixels,
|
|
84
|
+
* so each goes; `will-change` keeps the box it made for its positioned descendants, so they stay
|
|
85
|
+
* where they were laid out.
|
|
86
|
+
*
|
|
87
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
88
|
+
*/
|
|
89
|
+
export function keepOnlyTextInPage({ selector, css }: { selector: string; css: string }): void {
|
|
90
|
+
for (const slide of document.querySelectorAll(selector)) {
|
|
91
|
+
for (const el of [slide, ...slide.querySelectorAll("*")]) {
|
|
92
|
+
if (!(el instanceof HTMLElement || el instanceof SVGElement)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const style = getComputedStyle(el);
|
|
96
|
+
const effects = (["filter", "backdrop-filter"] as const).filter(
|
|
97
|
+
(property) => style.getPropertyValue(property) !== "none",
|
|
98
|
+
);
|
|
99
|
+
if (effects.length === 0) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const hints = style.willChange === "auto" ? [] : [style.willChange];
|
|
103
|
+
for (const property of effects) {
|
|
104
|
+
el.style.setProperty(property, "none", "important");
|
|
105
|
+
}
|
|
106
|
+
el.style.setProperty("will-change", [...hints, ...effects].join(", "), "important");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const sheet = document.createElement("style");
|
|
110
|
+
sheet.textContent = css;
|
|
111
|
+
document.head.append(sheet);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** How good a JPEG a photograph's slide is kept as: near the PNG to the eye, at under half its size. */
|
|
115
|
+
export const PDF_JPEG_QUALITY = 90;
|
|
116
|
+
|
|
117
|
+
/** How much of a slide its photographs must cover for the slide to be kept as a JPEG. */
|
|
118
|
+
const PHOTO_SHARE = 0.1;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Which of a slide's two pictures the PDF keeps: the JPEG for a slide that shows photographs,
|
|
122
|
+
* where it is smaller, and the PNG for the rest. Type and flat shapes keep their edges in a PNG;
|
|
123
|
+
* a JPEG blurs them, so a slide of them is never kept as one, however much smaller it would be.
|
|
124
|
+
*/
|
|
125
|
+
export function pdfPictureType(
|
|
126
|
+
sizes: { png: number; jpeg: number },
|
|
127
|
+
photoShare: number,
|
|
128
|
+
): "png" | "jpeg" {
|
|
129
|
+
return photoShare >= PHOTO_SHARE && sizes.jpeg < sizes.png ? "jpeg" : "png";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How much of each slide raster pictures cover, 0 to 1: images, video, and canvases, and
|
|
134
|
+
* backgrounds drawn from a picture's URL. SVG is shapes, not a photograph, and does not count.
|
|
135
|
+
*
|
|
136
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
137
|
+
*/
|
|
138
|
+
export function photoSharesInPage(selector: string): number[] {
|
|
139
|
+
const vector = (url: string) => /^data:image\/svg|\.svg(?:[?#]|$)/i.test(url);
|
|
140
|
+
return [...document.querySelectorAll(selector)].map((slide) => {
|
|
141
|
+
const frame = slide.getBoundingClientRect();
|
|
142
|
+
let covered = 0;
|
|
143
|
+
for (const el of [slide, ...slide.querySelectorAll("*")]) {
|
|
144
|
+
const style = getComputedStyle(el);
|
|
145
|
+
if (style.visibility !== "visible" || style.display === "none") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const raster =
|
|
149
|
+
el instanceof HTMLImageElement
|
|
150
|
+
? !vector(el.currentSrc || el.src)
|
|
151
|
+
: el instanceof HTMLVideoElement ||
|
|
152
|
+
el instanceof HTMLCanvasElement ||
|
|
153
|
+
[...style.backgroundImage.matchAll(/url\("?([^")]*)"?\)/g)].some(
|
|
154
|
+
([, url]) => url !== undefined && !vector(url),
|
|
155
|
+
);
|
|
156
|
+
if (!raster) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const box = el.getBoundingClientRect();
|
|
160
|
+
const width = Math.min(box.right, frame.right) - Math.max(box.left, frame.left);
|
|
161
|
+
const height = Math.min(box.bottom, frame.bottom) - Math.max(box.top, frame.top);
|
|
162
|
+
covered += Math.max(0, width) * Math.max(0, height);
|
|
163
|
+
}
|
|
164
|
+
return Math.min(1, covered / (frame.width * frame.height));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sets a slide's picture, a `data:` URL, under its text: the `index`th of the page's slides.
|
|
170
|
+
* One slide a call, so no message to the page carries more than one picture.
|
|
171
|
+
*
|
|
172
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
173
|
+
*/
|
|
174
|
+
export function setSlidePictureInPage({
|
|
175
|
+
selector,
|
|
176
|
+
index,
|
|
177
|
+
picture,
|
|
178
|
+
}: {
|
|
179
|
+
selector: string;
|
|
180
|
+
index: number;
|
|
181
|
+
picture: string;
|
|
182
|
+
}): void {
|
|
183
|
+
const slide = document.querySelectorAll(selector)[index];
|
|
184
|
+
if (slide instanceof HTMLElement) {
|
|
185
|
+
slide.style.setProperty(
|
|
186
|
+
"background",
|
|
187
|
+
`url("${picture}") 0 0 / 100% 100% no-repeat border-box`,
|
|
188
|
+
"important",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -3,13 +3,18 @@ declare module "playwright" {
|
|
|
3
3
|
export type Page = {
|
|
4
4
|
goto(url: string): Promise<unknown>;
|
|
5
5
|
setContent(html: string, options?: { waitUntil?: string }): Promise<void>;
|
|
6
|
-
emulateMedia(options: {
|
|
6
|
+
emulateMedia(options: {
|
|
7
|
+
media?: "screen" | "print";
|
|
8
|
+
reducedMotion?: "reduce" | "no-preference";
|
|
9
|
+
}): Promise<void>;
|
|
7
10
|
setViewportSize(size: Viewport): Promise<void>;
|
|
8
11
|
evaluate<T, A = unknown>(fn: (arg: A) => T | Promise<T>, arg?: A): Promise<T>;
|
|
9
12
|
screenshot(options: {
|
|
10
13
|
path?: string;
|
|
11
14
|
fullPage?: boolean;
|
|
15
|
+
clip?: { x: number; y: number; width: number; height: number };
|
|
12
16
|
type?: "png" | "jpeg";
|
|
17
|
+
quality?: number;
|
|
13
18
|
}): Promise<Buffer>;
|
|
14
19
|
addScriptTag(options: { content: string }): Promise<unknown>;
|
|
15
20
|
pdf(options: {
|
|
@@ -11,6 +11,16 @@ import {
|
|
|
11
11
|
samePlace,
|
|
12
12
|
} from "./overflow.ts";
|
|
13
13
|
import { visitInPages } from "./page-pool.ts";
|
|
14
|
+
import {
|
|
15
|
+
keepOnlyTextInPage,
|
|
16
|
+
PDF_JPEG_QUALITY,
|
|
17
|
+
PDF_SLIDES,
|
|
18
|
+
PDF_TEXT_CSS,
|
|
19
|
+
pdfPictureType,
|
|
20
|
+
photoSharesInPage,
|
|
21
|
+
setSlidePictureInPage,
|
|
22
|
+
slideBoxesInPage,
|
|
23
|
+
} from "./pdf-text.ts";
|
|
14
24
|
import type {
|
|
15
25
|
ContrastFinding,
|
|
16
26
|
ContrastOrigin,
|
|
@@ -50,12 +60,13 @@ export async function runVisualRequest<R extends VisualRequest>(
|
|
|
50
60
|
browser: Browser,
|
|
51
61
|
request: R,
|
|
52
62
|
): Promise<ResponseTo<R>> {
|
|
53
|
-
// A PPTX's pictures are shown full screen, so they are drawn at twice the slide's
|
|
54
|
-
// self-contained (DEK020), so the page needs no network, and a ref's slide
|
|
63
|
+
// A PPTX's and a PDF's pictures are shown full screen, so they are drawn at twice the slide's
|
|
64
|
+
// size. A deck is self-contained (DEK020), so the page needs no network, and a ref's slide
|
|
65
|
+
// scripts get none.
|
|
55
66
|
const context = await browser.newContext({
|
|
56
67
|
viewport: request.viewport,
|
|
57
68
|
offline: true,
|
|
58
|
-
...(request.kind === "pptx" ? { deviceScaleFactor: 2 } : {}),
|
|
69
|
+
...(request.kind === "pptx" || request.kind === "pdf" ? { deviceScaleFactor: 2 } : {}),
|
|
59
70
|
});
|
|
60
71
|
try {
|
|
61
72
|
return (await answer(context, request)) as ResponseTo<R>;
|
|
@@ -83,10 +94,35 @@ function answer(context: BrowserContext, request: VisualRequest) {
|
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The whole print document as one PDF, a page per slide: each slide shot as print draws it, then
|
|
99
|
+
* printed as that picture with its text laid over it, unseen.
|
|
100
|
+
*/
|
|
87
101
|
async function printPdf(context: BrowserContext, request: PdfRequest): Promise<DoneResponse> {
|
|
88
102
|
const page = await context.newPage();
|
|
89
103
|
await page.setContent(request.html, { waitUntil: "load" });
|
|
104
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
|
105
|
+
await page.emulateMedia({ media: "print" });
|
|
106
|
+
// A slide that shows photographs is shot as a JPEG too, and kept as one where it is smaller.
|
|
107
|
+
const shares = await page.evaluate(photoSharesInPage, PDF_SLIDES);
|
|
108
|
+
const pictures: string[] = [];
|
|
109
|
+
for (const [index, clip] of (await page.evaluate(slideBoxesInPage, PDF_SLIDES)).entries()) {
|
|
110
|
+
const share = shares[index] ?? 0;
|
|
111
|
+
const png = await page.screenshot({ clip, fullPage: true });
|
|
112
|
+
const jpeg =
|
|
113
|
+
pdfPictureType({ png: png.length, jpeg: 0 }, share) === "jpeg"
|
|
114
|
+
? await page.screenshot({ clip, fullPage: true, type: "jpeg", quality: PDF_JPEG_QUALITY })
|
|
115
|
+
: undefined;
|
|
116
|
+
const kept =
|
|
117
|
+
jpeg && pdfPictureType({ png: png.length, jpeg: jpeg.length }, share) === "jpeg"
|
|
118
|
+
? { type: "jpeg", data: jpeg }
|
|
119
|
+
: { type: "png", data: png };
|
|
120
|
+
pictures.push(`data:image/${kept.type};base64,${kept.data.toString("base64")}`);
|
|
121
|
+
}
|
|
122
|
+
await page.evaluate(keepOnlyTextInPage, { selector: PDF_SLIDES, css: PDF_TEXT_CSS });
|
|
123
|
+
for (const [index, picture] of pictures.entries()) {
|
|
124
|
+
await page.evaluate(setSlidePictureInPage, { selector: PDF_SLIDES, index, picture });
|
|
125
|
+
}
|
|
90
126
|
await page.pdf({
|
|
91
127
|
path: request.pdfPath,
|
|
92
128
|
width: `${request.viewport.width}px`,
|
package/src/core/playwright.ts
CHANGED
|
@@ -55,7 +55,10 @@ export type PagesRequest = {
|
|
|
55
55
|
sheets?: SheetSpec[];
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* One page printed to `pdfPath`, a PDF page per slide: each slide a picture at twice its size,
|
|
60
|
+
* with its text laid over it, unseen, to select and search.
|
|
61
|
+
*/
|
|
59
62
|
export type PdfRequest = {
|
|
60
63
|
kind: "pdf";
|
|
61
64
|
viewport: Viewport;
|