@hajimism/dek 0.3.1 → 0.3.3
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 +46 -0
- package/package.json +5 -5
- package/src/cli/commands.ts +45 -8
- package/src/cli/contract.ts +9 -0
- package/src/cli/files.ts +8 -1
- package/src/cli/new.ts +32 -8
- package/src/cli/pack.ts +21 -0
- package/src/cli/ref.ts +156 -4
- package/src/cli/scope.ts +14 -5
- package/src/cli/text.ts +18 -2
- package/src/core/config-keys.ts +6 -2
- package/src/core/config.ts +32 -5
- package/src/core/licenses/CC-BY-4.0.txt +396 -0
- package/src/core/licenses/CC0-1.0.txt +121 -0
- package/src/core/licenses.ts +20 -0
- package/src/core/pack.ts +137 -0
- 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/src/core/ref-name.ts +56 -12
- package/src/core/ref.ts +148 -20
- package/src/core/sync.ts +1 -1
- package/src/runtime/annotate-view.ts +4 -2
package/src/core/pack.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.ts";
|
|
4
|
+
import { DekError } from "./error.ts";
|
|
5
|
+
import { licenseText } from "./licenses.ts";
|
|
6
|
+
import { type DistOptions, distFile } from "./path.ts";
|
|
7
|
+
import { keepsInDeck, LICENSE_NAMES, MAX_SNAPSHOT_BYTES } from "./ref.ts";
|
|
8
|
+
import { isRefDeckName } from "./ref-name.ts";
|
|
9
|
+
import { followInside, requireInside, writeInside } from "./safe-fs.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A deck handed over as a file, to be read as a ref by someone who cannot reach it on GitHub:
|
|
13
|
+
* a gzipped tar of a project that holds only that deck, under a folder named for it. The deck
|
|
14
|
+
* keeps what `dekc ref` keeps of a repository; the project's dek.toml goes empty, so the
|
|
15
|
+
* sender's refs and voice settings stay home. Its LICENSE is the one `packLicense` picks.
|
|
16
|
+
*/
|
|
17
|
+
export async function packDeck(
|
|
18
|
+
project: { root: string },
|
|
19
|
+
deck: { name: string; dir: string },
|
|
20
|
+
options: { maxBytes?: number } = {},
|
|
21
|
+
): Promise<Uint8Array> {
|
|
22
|
+
if (!isRefDeckName(deck.name)) {
|
|
23
|
+
throw new DekError(`deck "${deck.name}" cannot be read as a ref by that name`, {
|
|
24
|
+
path: deck.dir,
|
|
25
|
+
hint: "a ref names its deck with no space or @; rename the deck's folder, e.g. to 2026-10-talk, and pack it again",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// Each file by its path in the pack and where it really is, measured before any is read.
|
|
29
|
+
const sources: Array<readonly [string, string]> = deckFiles(deck.dir, project.root).map(
|
|
30
|
+
(path) =>
|
|
31
|
+
[`decks/${deck.name}/${path}`, requireInside(join(deck.dir, path), project.root)] as const,
|
|
32
|
+
);
|
|
33
|
+
const license = packLicense(project.root);
|
|
34
|
+
if (license?.path) {
|
|
35
|
+
sources.push([license.name, license.path]);
|
|
36
|
+
}
|
|
37
|
+
const maxBytes = options.maxBytes ?? MAX_SNAPSHOT_BYTES;
|
|
38
|
+
const size = sources.reduce((sum, [, real]) => sum + statSync(real).size, 0);
|
|
39
|
+
if (size > maxBytes) {
|
|
40
|
+
throw new DekError(
|
|
41
|
+
`deck "${deck.name}" is larger than a ref keeps, ${maxBytes / 1024 / 1024} MB`,
|
|
42
|
+
{
|
|
43
|
+
path: deck.dir,
|
|
44
|
+
hint: "a deck to read as a model should be far smaller; check its assets",
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const files = new Map<string, Uint8Array>([["dek.toml", new Uint8Array()]]);
|
|
49
|
+
for (const [path, real] of sources) {
|
|
50
|
+
files.set(path, readFileSync(real));
|
|
51
|
+
}
|
|
52
|
+
if (license?.text !== undefined) {
|
|
53
|
+
files.set(license.name, new TextEncoder().encode(license.text));
|
|
54
|
+
}
|
|
55
|
+
const entries: Record<string, Uint8Array> = {};
|
|
56
|
+
for (const [path, bytes] of files) {
|
|
57
|
+
entries[`${deck.name}/${path}`] = bytes;
|
|
58
|
+
}
|
|
59
|
+
return new Bun.Archive(entries, { compress: "gzip" }).bytes();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The LICENSE a pack carries: the legal code `[pack] license` names, which says what the reader
|
|
64
|
+
* may do with a deck handed over on purpose; else the project's own LICENSE file; else none.
|
|
65
|
+
*/
|
|
66
|
+
function packLicense(
|
|
67
|
+
root: string,
|
|
68
|
+
):
|
|
69
|
+
| { name: string; text: string; path?: undefined }
|
|
70
|
+
| { name: string; path: string; text?: undefined }
|
|
71
|
+
| undefined {
|
|
72
|
+
const id = loadConfig(join(root, "dek.toml")).pack?.license;
|
|
73
|
+
if (id) {
|
|
74
|
+
return { name: "LICENSE", text: licenseText(id) };
|
|
75
|
+
}
|
|
76
|
+
const name = LICENSE_NAMES.find((file) => isFile(join(root, file), root));
|
|
77
|
+
return name ? { name, path: requireInside(join(root, name), root) } : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Said to the sender, who can fix it, when a pack would go out with no license. */
|
|
81
|
+
const NO_PACK_LICENSE =
|
|
82
|
+
'the pack carries no license, so its reader is told to ask you before reusing it; add `[pack] license = "CC0-1.0"` to dek.toml to let them reuse it freely, or put a LICENSE at the project root';
|
|
83
|
+
|
|
84
|
+
/** `dekc pack`: write the deck's pack as dist/<deck>.tgz. */
|
|
85
|
+
export async function writePack(
|
|
86
|
+
project: { root: string },
|
|
87
|
+
deck: { name: string; dir: string },
|
|
88
|
+
options: DistOptions = {},
|
|
89
|
+
): Promise<{ outPath: string; warnings: string[] }> {
|
|
90
|
+
const bytes = await packDeck(project, deck);
|
|
91
|
+
const outPath = distFile(project, deck, "tgz", options);
|
|
92
|
+
writeInside(outPath, bytes, project.root);
|
|
93
|
+
return { outPath, warnings: packLicense(project.root) ? [] : [NO_PACK_LICENSE] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What `deckFiles` looks at in the deck's own folder; dist/, .cache/, and voice/ are not read. */
|
|
97
|
+
const DECK_TOP = new Set(["script.md", "theme.css", "slides", "assets"]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The deck's files a ref keeps, relative to the deck, in a stable order. A file or folder whose
|
|
101
|
+
* name starts with a dot stays behind; a link dek would not follow anywhere else is an error.
|
|
102
|
+
*/
|
|
103
|
+
function deckFiles(deckDir: string, root: string): string[] {
|
|
104
|
+
const found: string[] = [];
|
|
105
|
+
const walk = (rel: string) => {
|
|
106
|
+
const dir = requireInside(join(deckDir, rel), root);
|
|
107
|
+
for (const name of readdirSync(dir).sort()) {
|
|
108
|
+
if (name.startsWith(".") || (rel === "" && !DECK_TOP.has(name))) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const path = rel === "" ? name : `${rel}/${name}`;
|
|
112
|
+
const real = requireInside(join(deckDir, path), root);
|
|
113
|
+
if (!existsSync(real)) {
|
|
114
|
+
throw new DekError(`${relative(root, join(deckDir, path))} is missing`, {
|
|
115
|
+
path: join(deckDir, path),
|
|
116
|
+
hint: "it is a symlink to nothing; remove it, or copy the file it meant in its place",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (statSync(real).isDirectory()) {
|
|
120
|
+
walk(path);
|
|
121
|
+
} else if (keepsInDeck(path)) {
|
|
122
|
+
found.push(path);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk("");
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isFile(path: string, root: string): boolean {
|
|
131
|
+
const real = followInside(path, root);
|
|
132
|
+
try {
|
|
133
|
+
return real !== undefined && statSync(real).isFile();
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -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;
|
package/src/core/ref-name.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { DekError } from "./error.ts";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A ref is someone else's deck, kept as a read-only snapshot to read as a
|
|
5
|
-
* model. dek.toml `[refs]` pins each one to a commit
|
|
6
|
-
* `refs
|
|
5
|
+
* model. dek.toml `[refs]` pins each one: a deck on GitHub to a commit, a pack
|
|
6
|
+
* to a digest of its files. The snapshot under `refs/` is only a copy of what
|
|
7
|
+
* the pin names, fetched or unpacked again whenever it is missing.
|
|
7
8
|
* This module only names refs; `ref.ts` fetches and stores them.
|
|
8
9
|
*/
|
|
9
|
-
export type
|
|
10
|
+
export type GithubRefSource = {
|
|
11
|
+
kind: "github";
|
|
10
12
|
/** `owner/repo/deck`, the key in dek.toml `[refs]`. */
|
|
11
13
|
name: string;
|
|
12
14
|
owner: string;
|
|
@@ -18,13 +20,32 @@ export type RefSource = {
|
|
|
18
20
|
path?: string;
|
|
19
21
|
};
|
|
20
22
|
|
|
23
|
+
/** A deck handed over as a file by `dekc pack`, committed as `packs/<deck>.tgz`. */
|
|
24
|
+
export type FileRefSource = {
|
|
25
|
+
kind: "file";
|
|
26
|
+
/** `file/<deck>`, the key in dek.toml `[refs]`. */
|
|
27
|
+
name: string;
|
|
28
|
+
deck: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type RefSource = GithubRefSource | FileRefSource;
|
|
32
|
+
|
|
33
|
+
/** The first segment of a pack's ref name; no GitHub owner may take it, so refs/ never overlaps. */
|
|
34
|
+
const PACK_OWNER = "file";
|
|
35
|
+
|
|
21
36
|
const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
22
37
|
const REPO_RE = /^[A-Za-z0-9._-]+$/;
|
|
23
38
|
const DECK_RE = /^[^/\\\s@]+$/;
|
|
24
39
|
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
40
|
+
const DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
25
41
|
|
|
26
42
|
const REF_HINT =
|
|
27
|
-
"name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1";
|
|
43
|
+
"name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1, or a pack's as file/<deck>";
|
|
44
|
+
|
|
45
|
+
/** Where a pack's ref keeps its pack, relative to the project root: committed, never ignored. */
|
|
46
|
+
export function packFile(deck: string): string {
|
|
47
|
+
return `packs/${deck}.tgz`;
|
|
48
|
+
}
|
|
28
49
|
|
|
29
50
|
/** True for `owner/repo/deck[@rev]` or a GitHub link, which a deck name never is. */
|
|
30
51
|
export function isRefName(arg: string): boolean {
|
|
@@ -34,14 +55,15 @@ export function isRefName(arg: string): boolean {
|
|
|
34
55
|
if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) {
|
|
35
56
|
return false;
|
|
36
57
|
}
|
|
37
|
-
|
|
58
|
+
const segments = arg.split("@")[0]?.split("/") ?? [];
|
|
59
|
+
return segments.length === 3 || (segments.length === 2 && segments[0] === PACK_OWNER);
|
|
38
60
|
}
|
|
39
61
|
|
|
40
|
-
/** A valid `owner/repo/deck` with no rev
|
|
62
|
+
/** A valid `owner/repo/deck` with no rev, or a pack's `file/<deck>`: the form dek.toml `[refs]` keys take. */
|
|
41
63
|
export function isPlainRefName(name: string): boolean {
|
|
42
64
|
try {
|
|
43
65
|
const source = parseRefSource(name);
|
|
44
|
-
return source.rev === undefined && source.name === name;
|
|
66
|
+
return (source.kind === "file" || source.rev === undefined) && source.name === name;
|
|
45
67
|
} catch {
|
|
46
68
|
return false;
|
|
47
69
|
}
|
|
@@ -51,6 +73,16 @@ export function isPinnedRev(rev: string): boolean {
|
|
|
51
73
|
return SHA_RE.test(rev);
|
|
52
74
|
}
|
|
53
75
|
|
|
76
|
+
/** True when `pin` has the form a ref of this kind is pinned with: a commit, or a pack's digest. */
|
|
77
|
+
export function isPinFor(source: RefSource, pin: string): boolean {
|
|
78
|
+
return source.kind === "file" ? DIGEST_RE.test(pin) : SHA_RE.test(pin);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True for a deck name a ref can carry: no slash, space, or @, and not . or .. */
|
|
82
|
+
export function isRefDeckName(deck: string | undefined): deck is string {
|
|
83
|
+
return deck !== undefined && DECK_RE.test(deck) && deck !== "." && deck !== "..";
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
export function parseRefSource(input: string): RefSource {
|
|
55
87
|
const trimmed = input.trim();
|
|
56
88
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
@@ -60,6 +92,17 @@ export function parseRefSource(input: string): RefSource {
|
|
|
60
92
|
const path = at === -1 ? trimmed : trimmed.slice(0, at);
|
|
61
93
|
const rev = at === -1 ? undefined : trimmed.slice(at + 1);
|
|
62
94
|
const [owner, repo, deck, ...extra] = path.split("/");
|
|
95
|
+
if (owner === PACK_OWNER && deck === undefined) {
|
|
96
|
+
if (rev !== undefined || !isRefDeckName(repo)) {
|
|
97
|
+
throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
|
|
98
|
+
}
|
|
99
|
+
return { kind: "file", name: `${PACK_OWNER}/${repo}`, deck: repo };
|
|
100
|
+
}
|
|
101
|
+
if (owner?.toLowerCase() === PACK_OWNER && deck !== undefined) {
|
|
102
|
+
throw new DekError(`"${input}" is not a ref name`, {
|
|
103
|
+
hint: "file is kept for packs, so no GitHub owner of that name can be pinned; name a pack's ref as file/<deck>",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
63
106
|
const valid =
|
|
64
107
|
extra.length === 0 &&
|
|
65
108
|
owner !== undefined &&
|
|
@@ -68,15 +111,13 @@ export function parseRefSource(input: string): RefSource {
|
|
|
68
111
|
REPO_RE.test(repo) &&
|
|
69
112
|
repo !== "." &&
|
|
70
113
|
repo !== ".." &&
|
|
71
|
-
deck
|
|
72
|
-
DECK_RE.test(deck) &&
|
|
73
|
-
deck !== "." &&
|
|
74
|
-
deck !== ".." &&
|
|
114
|
+
isRefDeckName(deck) &&
|
|
75
115
|
(rev === undefined || /^[^\s@]+$/.test(rev));
|
|
76
116
|
if (!valid) {
|
|
77
117
|
throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
|
|
78
118
|
}
|
|
79
119
|
return {
|
|
120
|
+
kind: "github",
|
|
80
121
|
name: `${owner}/${repo}/${deck}`,
|
|
81
122
|
owner,
|
|
82
123
|
repo,
|
|
@@ -90,7 +131,7 @@ export function parseRefSource(input: string): RefSource {
|
|
|
90
131
|
* link people copy from a browser. A branch name with a slash cannot be told
|
|
91
132
|
* apart from the path, so such a branch goes after `@` instead.
|
|
92
133
|
*/
|
|
93
|
-
function parseGithubUrl(input: string):
|
|
134
|
+
function parseGithubUrl(input: string): GithubRefSource {
|
|
94
135
|
const url = new URL(input);
|
|
95
136
|
const [owner = "", repo = "", kind, rev, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
96
137
|
const decks = rest.indexOf("decks");
|
|
@@ -106,5 +147,8 @@ function parseGithubUrl(input: string): RefSource {
|
|
|
106
147
|
});
|
|
107
148
|
}
|
|
108
149
|
const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
|
|
150
|
+
if (source.kind !== "github") {
|
|
151
|
+
throw new DekError(`"${input}" is not a link to a deck on GitHub`, { hint: REF_HINT });
|
|
152
|
+
}
|
|
109
153
|
return { ...source, path: rest.slice(0, decks + 2).join("/") };
|
|
110
154
|
}
|