rikiki-deck 0.6.0 → 0.7.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/.claude/skills/rikiki-debug/SKILL.md +17 -7
- package/.claude/skills/rikiki-deck/SKILL.md +362 -77
- package/.claude/skills/rikiki-theme/SKILL.md +1 -1
- package/README.md +69 -50
- package/bin/lib/assemble.mjs +154 -0
- package/bin/lib/box-geometry.mjs +66 -0
- package/bin/lib/browser.mjs +302 -0
- package/bin/lib/check-api.d.ts +28 -0
- package/bin/lib/check-api.mjs +6 -0
- package/bin/lib/check-plugins.mjs +228 -0
- package/bin/lib/check.mjs +1347 -0
- package/bin/lib/cli-error.mjs +26 -0
- package/bin/lib/component-deps.mjs +69 -0
- package/bin/lib/diff.mjs +275 -0
- package/bin/lib/export-pdf.mjs +65 -0
- package/bin/lib/graph-hit.mjs +86 -0
- package/bin/lib/inline.mjs +137 -39
- package/bin/lib/narrative.mjs +77 -0
- package/bin/lib/prune-icons.mjs +104 -0
- package/bin/lib/render.mjs +195 -0
- package/bin/lib/scan-external.mjs +126 -0
- package/bin/lib/starter.mjs +27 -14
- package/bin/lib/visual.mjs +120 -0
- package/bin/rikiki.mjs +420 -35
- package/dist/annotation-marks.d.ts +60 -0
- package/dist/annotation-marks.js +1 -0
- package/dist/bar-segments.d.ts +28 -0
- package/dist/bar-segments.js +1 -0
- package/dist/browser-location.d.ts +3 -0
- package/dist/browser-location.js +1 -0
- package/dist/cards-syntax.d.ts +31 -0
- package/dist/cards-syntax.js +6 -0
- package/dist/{plugins/click-stages.d.ts → click-stages.d.ts} +1 -1
- package/dist/deck-agenda.d.ts +25 -0
- package/dist/deck-agenda.js +6 -0
- package/dist/deck-annotate.d.ts +108 -0
- package/dist/deck-annotate.js +18 -0
- package/dist/deck-bar.d.ts +32 -0
- package/dist/deck-bar.js +19 -0
- package/dist/deck-bento.d.ts +38 -0
- package/dist/deck-bento.js +4 -0
- package/dist/{molecules/deck-callout.d.ts → deck-callout.d.ts} +2 -0
- package/dist/deck-callout.js +1 -1
- package/dist/deck-cell.d.ts +19 -0
- package/dist/deck-cell.js +1 -0
- package/dist/deck-checklist.d.ts +20 -0
- package/dist/deck-checklist.js +1 -0
- package/dist/{layouts/deck-cover.d.ts → deck-cover.d.ts} +8 -0
- package/dist/deck-cover.js +9 -6
- package/dist/deck-csv.d.ts +38 -0
- package/dist/deck-csv.js +15 -0
- package/dist/deck-feature-cards.js +2 -2
- package/dist/deck-feature.d.ts +18 -0
- package/dist/deck-feature.js +2 -2
- package/dist/deck-figure.d.ts +26 -0
- package/dist/deck-figure.js +8 -0
- package/dist/deck-fit.d.ts +14 -0
- package/dist/deck-fit.js +1 -0
- package/dist/deck-flow.d.ts +41 -0
- package/dist/deck-flow.js +7 -0
- package/dist/deck-graph.d.ts +92 -0
- package/dist/deck-graph.js +25 -0
- package/dist/deck-grid.js +1 -1
- package/dist/deck-icon.d.ts +20 -0
- package/dist/deck-icon.js +1 -0
- package/dist/{atoms/deck-kicker.d.ts → deck-kicker.d.ts} +5 -0
- package/dist/deck-kicker.js +1 -1
- package/dist/deck-kpi-grid.d.ts +26 -0
- package/dist/deck-kpi-grid.js +4 -0
- package/dist/deck-link.d.ts +21 -0
- package/dist/deck-link.js +1 -0
- package/dist/{molecules/deck-md.d.ts → deck-md.d.ts} +3 -0
- package/dist/deck-md.js +8 -3
- package/dist/deck-mermaid.js +15 -3
- package/dist/deck-outline.d.ts +50 -0
- package/dist/deck-outline.js +1 -0
- package/dist/deck-overview.js +53 -39
- package/dist/deck-persona.d.ts +31 -0
- package/dist/deck-persona.js +6 -0
- package/dist/deck-photo.js +1 -1
- package/dist/deck-point.d.ts +22 -0
- package/dist/deck-point.js +1 -0
- package/dist/deck-presenter.js +120 -48
- package/dist/deck-pull.d.ts +13 -0
- package/dist/deck-pull.js +1 -0
- package/dist/{atoms/deck-punch.d.ts → deck-punch.d.ts} +6 -0
- package/dist/deck-punch.js +1 -1
- package/dist/deck-quote.d.ts +28 -0
- package/dist/deck-quote.js +6 -0
- package/dist/{runtime/deck-root.d.ts → deck-root.d.ts} +88 -8
- package/dist/deck-root.js +17 -13
- package/dist/deck-section.js +2 -2
- package/dist/deck-source.d.ts +12 -0
- package/dist/deck-source.js +2 -0
- package/dist/deck-split.d.ts +30 -0
- package/dist/deck-split.js +5 -3
- package/dist/{molecules/deck-stat.d.ts → deck-stat.d.ts} +2 -0
- package/dist/deck-stat.js +2 -2
- package/dist/{molecules/deck-step-list.d.ts → deck-step-list.d.ts} +8 -0
- package/dist/deck-step-list.js +4 -2
- package/dist/deck-table.d.ts +26 -0
- package/dist/deck-table.js +1 -0
- package/dist/deck-takeaway.d.ts +18 -0
- package/dist/deck-takeaway.js +2 -2
- package/dist/deck-timeline.d.ts +37 -0
- package/dist/deck-timeline.js +5 -0
- package/dist/deck-transition.js +3 -3
- package/dist/deck-versus.d.ts +18 -0
- package/dist/deck-versus.js +9 -0
- package/dist/deep-link.d.ts +29 -0
- package/dist/deep-link.js +1 -0
- package/dist/escape-html.d.ts +3 -0
- package/dist/escape-html.js +1 -0
- package/dist/fit-controller.d.ts +27 -0
- package/dist/fit-controller.js +1 -0
- package/dist/graph-layout.d.ts +35 -0
- package/dist/graph-layout.js +1 -0
- package/dist/grid-tracks.d.ts +17 -0
- package/dist/grid-tracks.js +1 -0
- package/dist/icon-set.d.ts +6 -0
- package/dist/icon-set.js +1 -0
- package/dist/index.d.ts +37 -31
- package/dist/index.js +95 -49
- package/dist/keymap.d.ts +40 -0
- package/dist/keymap.js +1 -0
- package/dist/mouse-nav.d.ts +12 -0
- package/dist/mouse-nav.js +1 -0
- package/dist/navigation.d.ts +25 -0
- package/dist/navigation.js +1 -0
- package/dist/parse-csv.d.ts +9 -0
- package/dist/parse-csv.js +3 -0
- package/dist/shared-styles.js +1 -1
- package/dist/shiki.d.ts +8 -0
- package/dist/signature.d.ts +2 -0
- package/dist/signature.js +1 -0
- package/dist/slide-fill.d.ts +8 -0
- package/dist/slide-fill.js +1 -0
- package/dist/standalone.js +301 -169
- package/dist/vendor/THIRD-PARTY-NOTICES.txt +4347 -0
- package/dist/vendor/inventory.json +3029 -0
- package/dist/vendor/lit.js +62 -2
- package/dist/vendor/mermaid.min.js +95 -95
- package/dist/vendor/shiki.js +1 -57
- package/dist/viewport.d.ts +42 -0
- package/dist/viewport.js +1 -0
- package/docs/llms/rikiki-reference.md +955 -64
- package/docs/llms/rikiki-workflow.md +536 -0
- package/llms.txt +39 -12
- package/package.json +33 -12
- package/themes/rikiki.css +173 -47
- package/themes/siliceum.css +171 -51
- package/dist/layouts/deck-feature.d.ts +0 -11
- package/dist/layouts/deck-split.d.ts +0 -18
- package/dist/layouts/deck-takeaway.d.ts +0 -11
- package/dist/plugins/shiki.d.ts +0 -8
- /package/dist/{runtime/color.d.ts → color.d.ts} +0 -0
- /package/dist/{atoms/deck-badge.d.ts → deck-badge.d.ts} +0 -0
- /package/dist/{molecules/deck-card.d.ts → deck-card.d.ts} +0 -0
- /package/dist/{atoms/deck-code-highlighter.d.ts → deck-code-highlighter.d.ts} +0 -0
- /package/dist/{atoms/deck-code.d.ts → deck-code.d.ts} +0 -0
- /package/dist/{layouts/deck-feature-cards.d.ts → deck-feature-cards.d.ts} +0 -0
- /package/dist/{molecules/deck-grid.d.ts → deck-grid.d.ts} +0 -0
- /package/dist/{runtime/deck-help.d.ts → deck-help.d.ts} +0 -0
- /package/dist/{molecules/deck-mermaid.d.ts → deck-mermaid.d.ts} +0 -0
- /package/dist/{molecules/deck-metric.d.ts → deck-metric.d.ts} +0 -0
- /package/dist/{runtime/deck-notes.d.ts → deck-notes.d.ts} +0 -0
- /package/dist/{runtime/deck-overview.d.ts → deck-overview.d.ts} +0 -0
- /package/dist/{layouts/deck-photo.d.ts → deck-photo.d.ts} +0 -0
- /package/dist/{runtime/deck-presenter.d.ts → deck-presenter.d.ts} +0 -0
- /package/dist/{layouts/deck-section.d.ts → deck-section.d.ts} +0 -0
- /package/dist/{molecules/deck-shortcut.d.ts → deck-shortcut.d.ts} +0 -0
- /package/dist/{molecules/deck-stack.d.ts → deck-stack.d.ts} +0 -0
- /package/dist/{molecules/deck-tier-list.d.ts → deck-tier-list.d.ts} +0 -0
- /package/dist/{runtime/deck-transition.d.ts → deck-transition.d.ts} +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// ════════════════════════════════════════════════════════════════
|
|
2
|
+
// The difference between "you need to install something" and "rikiki broke".
|
|
3
|
+
// The first is a message the reader acts on; the second is a stack trace the
|
|
4
|
+
// maintainer acts on. Printing a stack for the first one buries the remedy
|
|
5
|
+
// under six lines of package internals.
|
|
6
|
+
// ════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
/** An error whose message is the whole story · printed without a stack.
|
|
9
|
+
*
|
|
10
|
+
* `exitCode` lets a command distinguish its own failure modes. `check` uses it
|
|
11
|
+
* to separate "the deck has defects" (1) from "I could not look at it" (2),
|
|
12
|
+
* which is the difference between a result and no result. */
|
|
13
|
+
export class ExpectedError extends Error {
|
|
14
|
+
name = 'ExpectedError';
|
|
15
|
+
|
|
16
|
+
constructor(message, { exitCode = 1, ...options } = {}) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.exitCode = exitCode;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the CLI prints when a command throws. */
|
|
23
|
+
export function formatCliError(e) {
|
|
24
|
+
if (e instanceof ExpectedError) return 'rikiki · ' + e.message;
|
|
25
|
+
return 'rikiki · error · ' + ((e && e.stack) || e);
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What a component needs loaded besides itself.
|
|
2
|
+
//
|
|
3
|
+
// A component may render another component's tag · deck-figure renders
|
|
4
|
+
// <deck-source> for its credit line, deck-annotate does the same, deck-graph
|
|
5
|
+
// renders <deck-icon>. The curated bundle is built from the tags written in
|
|
6
|
+
// the DECK (see scanComponents in inline.mjs), so those tags are invisible to
|
|
7
|
+
// it: the deck shipped without the module and the element stayed unregistered,
|
|
8
|
+
// rendering as bare text. decks/tests/figure.html is the live case.
|
|
9
|
+
//
|
|
10
|
+
// Read from dist/, not src/, for one reason: `rikiki bundle` runs from an
|
|
11
|
+
// installed package, where src/ does not exist. dist/ is also the only thing
|
|
12
|
+
// that can be wrong · a graph derived from what ships cannot disagree with
|
|
13
|
+
// what ships.
|
|
14
|
+
//
|
|
15
|
+
// The scan OVER-APPROXIMATES on purpose. `<deck-code` also appears in a
|
|
16
|
+
// warning string inside deck-code-highlighter and shiki, so both are reported
|
|
17
|
+
// as needing deck-code. That is the safe direction: a false positive adds a
|
|
18
|
+
// module the deck almost certainly wanted anyway (shiki highlights deck-code),
|
|
19
|
+
// while a false negative ships a deck with a missing element. Precision here
|
|
20
|
+
// would mean parsing minified JS to tell a Lit template from a string
|
|
21
|
+
// literal, which is a lot of machinery to save a few hundred bytes.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** Tags a module mentions in markup · `<deck-thing`. */
|
|
27
|
+
const RENDERED = /<(deck-[a-z0-9-]+)/g;
|
|
28
|
+
|
|
29
|
+
/** Every loadable module, mapped to the modules it needs beside it.
|
|
30
|
+
*
|
|
31
|
+
* dist/ is flat and a file IS its tag (build.mjs entryNames '[name]'), so the
|
|
32
|
+
* filename is the lookup key. A secondary element defined inside a sibling's
|
|
33
|
+
* file · deck-kbd in deck-shortcut.js, deck-tier in deck-tier-list.js · has
|
|
34
|
+
* no module of its own and travels with its parent, which is why anything
|
|
35
|
+
* without a dist/<tag>.js is dropped rather than requested. */
|
|
36
|
+
export function componentGraph(distDir) {
|
|
37
|
+
const modules = readdirSync(distDir)
|
|
38
|
+
.filter((f) => f.endsWith('.js') && f.startsWith('deck-'))
|
|
39
|
+
.map((f) => f.slice(0, -3));
|
|
40
|
+
const known = new Set(modules);
|
|
41
|
+
|
|
42
|
+
const graph = new Map();
|
|
43
|
+
for (const tag of modules) {
|
|
44
|
+
const source = readFileSync(join(distDir, `${tag}.js`), 'utf8');
|
|
45
|
+
const deps = new Set();
|
|
46
|
+
for (const [, found] of source.matchAll(RENDERED)) {
|
|
47
|
+
if (found !== tag && known.has(found)) deps.add(found);
|
|
48
|
+
}
|
|
49
|
+
graph.set(tag, deps);
|
|
50
|
+
}
|
|
51
|
+
return graph;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The tags to load so every one of `tags` renders completely.
|
|
55
|
+
*
|
|
56
|
+
* A closure over a visited set · a cycle between two components would be a
|
|
57
|
+
* defect to report, not a reason for this to recurse forever. */
|
|
58
|
+
export function expandDeps(tags, distDir, graph = componentGraph(distDir)) {
|
|
59
|
+
const out = new Set();
|
|
60
|
+
const queue = [...tags];
|
|
61
|
+
while (queue.length > 0) {
|
|
62
|
+
const tag = queue.pop();
|
|
63
|
+
if (out.has(tag)) continue;
|
|
64
|
+
out.add(tag);
|
|
65
|
+
for (const dep of graph.get(tag) ?? []) if (!out.has(dep)) queue.push(dep);
|
|
66
|
+
}
|
|
67
|
+
// Keep only what can actually be loaded · a caller may pass deck-kbd.
|
|
68
|
+
return [...out].filter((t) => existsSync(join(distDir, `${t}.js`)));
|
|
69
|
+
}
|
package/bin/lib/diff.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// ════════════════════════════════════════════════════════════════
|
|
2
|
+
// Did anything move since the last render?
|
|
3
|
+
//
|
|
4
|
+
// After a runtime update, 57 of 64 slides of a real deck changed pixels.
|
|
5
|
+
// Nearly all of it was anti-aliasing; seven were geometry that had actually
|
|
6
|
+
// moved. Finding those seven meant a `cmp` loop in a shell and opening images
|
|
7
|
+
// one by one. So the comparison has to rank: how much moved, and where.
|
|
8
|
+
//
|
|
9
|
+
// There is no image library here, and none is wanted. The comparison runs in
|
|
10
|
+
// the Chromium the CLI already launched: both PNGs go in as `data:` URLs, get
|
|
11
|
+
// drawn on a canvas, and `getImageData` gives the bytes. Everything the answer
|
|
12
|
+
// depends on · the ranking, the threshold, the box, the classification · is a
|
|
13
|
+
// pure function in this file, tested in Node, not a line hidden in a browser.
|
|
14
|
+
// ════════════════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const DIFF_SCHEMA = 'rikiki.render-diff/1';
|
|
20
|
+
|
|
21
|
+
/** How far one channel must move before a pixel counts as changed.
|
|
22
|
+
*
|
|
23
|
+
* Anti-aliasing on a re-render nudges an edge pixel by a handful of levels;
|
|
24
|
+
* a glyph that moved paints where there was background. 32 of 255 sits
|
|
25
|
+
* between the two, so a re-render of an unchanged deck reads as stable. */
|
|
26
|
+
export const CHANNEL_DELTA = 32;
|
|
27
|
+
|
|
28
|
+
/** Percent of a slide's pixels that has to change before it is worth looking
|
|
29
|
+
* at. `--threshold 0` reports every single pixel that moved. */
|
|
30
|
+
export const DEFAULT_THRESHOLD = 0.5;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The smallest rect containing every pixel in `pixels` (`{x, y}`), or null.
|
|
34
|
+
*
|
|
35
|
+
* The in-page pass keeps only the two extreme corners rather than a list of a
|
|
36
|
+
* million points · that is the same rect, which is why this takes a list.
|
|
37
|
+
*/
|
|
38
|
+
export function boundingBox(pixels) {
|
|
39
|
+
if (!pixels?.length) return null;
|
|
40
|
+
let left = Number.POSITIVE_INFINITY;
|
|
41
|
+
let top = Number.POSITIVE_INFINITY;
|
|
42
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
43
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
44
|
+
for (const { x, y } of pixels) {
|
|
45
|
+
if (x < left) left = x;
|
|
46
|
+
if (x > right) right = x;
|
|
47
|
+
if (y < top) top = y;
|
|
48
|
+
if (y > bottom) bottom = y;
|
|
49
|
+
}
|
|
50
|
+
return { left, top, width: right - left + 1, height: bottom - top + 1 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `changed` or `stable` for a measured slide.
|
|
54
|
+
*
|
|
55
|
+
* A slide exactly at the threshold is changed · the threshold is the point
|
|
56
|
+
* from which a difference counts, not the first value past it. A slide where
|
|
57
|
+
* nothing at all moved is stable whatever the threshold, so `--threshold 0`
|
|
58
|
+
* means "every pixel counts", not "every slide is suspect". */
|
|
59
|
+
export function statusForRatio(changedRatio, threshold) {
|
|
60
|
+
if (changedRatio <= 0) return 'stable';
|
|
61
|
+
return changedRatio * 100 >= threshold ? 'changed' : 'stable';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Which files both sides have, and which only one of them has. */
|
|
65
|
+
export function classifyFiles(currentFiles, baselineFiles) {
|
|
66
|
+
const baseline = new Set(baselineFiles);
|
|
67
|
+
const current = new Set(currentFiles);
|
|
68
|
+
return {
|
|
69
|
+
compared: currentFiles.filter((f) => baseline.has(f)),
|
|
70
|
+
added: currentFiles.filter((f) => !baseline.has(f)),
|
|
71
|
+
missing: baselineFiles.filter((f) => !current.has(f)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One report entry from one in-page measurement.
|
|
77
|
+
*
|
|
78
|
+
* Two canvases of different sizes have no pixels in common, so a `resized`
|
|
79
|
+
* slide carries both sizes and no count · a ratio computed across a resize
|
|
80
|
+
* would be a number that means nothing.
|
|
81
|
+
*/
|
|
82
|
+
export function slideEntry(file, measured, threshold, meta = {}) {
|
|
83
|
+
const { baselineSize, size } = measured;
|
|
84
|
+
if (baselineSize.width !== size.width || baselineSize.height !== size.height) {
|
|
85
|
+
return { file, ...meta, status: 'resized', baselineSize, size };
|
|
86
|
+
}
|
|
87
|
+
const changedRatio = measured.total ? measured.changed / measured.total : 0;
|
|
88
|
+
return {
|
|
89
|
+
file,
|
|
90
|
+
...meta,
|
|
91
|
+
status: statusForRatio(changedRatio, threshold),
|
|
92
|
+
changedRatio,
|
|
93
|
+
changedPixels: measured.changed,
|
|
94
|
+
totalPixels: measured.total,
|
|
95
|
+
box: boundingBox(measured.changedCorners),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The slides, loudest first · the ones nobody could measure keep to the end,
|
|
100
|
+
* in file order, because they are already named one by one in the report. */
|
|
101
|
+
export function rankSlides(slides) {
|
|
102
|
+
const loudness = (s) => (typeof s.changedRatio === 'number' ? s.changedRatio : -1);
|
|
103
|
+
return [...slides].sort((a, b) => loudness(b) - loudness(a) || a.file.localeCompare(b.file));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How many slides of each status. */
|
|
107
|
+
export function summarize(slides) {
|
|
108
|
+
const summary = { changed: 0, stable: 0, added: 0, missing: 0, resized: 0 };
|
|
109
|
+
for (const s of slides) summary[s.status] += 1;
|
|
110
|
+
return summary;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether the render disagrees with its baseline.
|
|
114
|
+
*
|
|
115
|
+
* A slide the baseline never had is what adding a slide looks like · it is
|
|
116
|
+
* news, not a regression, so it does not fail the run. */
|
|
117
|
+
export const diffFailed = (summary) => summary.changed + summary.missing + summary.resized > 0;
|
|
118
|
+
|
|
119
|
+
const percent = (ratio) => (ratio * 100).toFixed(2) + '%';
|
|
120
|
+
|
|
121
|
+
/** The report as a human reads it · the changed slides ranked, the ones that
|
|
122
|
+
* could not be compared named, then the counts. */
|
|
123
|
+
export function formatDiff(report) {
|
|
124
|
+
const lines = [];
|
|
125
|
+
const changed = report.slides.filter((s) => s.status === 'changed');
|
|
126
|
+
if (changed.length) lines.push(`rikiki · diff · ${changed.length} slide(s) changed · most changed first`);
|
|
127
|
+
for (const s of changed) {
|
|
128
|
+
const where = s.box ? ` · box ${s.box.left},${s.box.top} ${s.box.width}×${s.box.height}` : '';
|
|
129
|
+
lines.push(` · ${percent(s.changedRatio).padStart(7)} ${s.file}${s.title ? ' · ' + s.title : ''}${where}`);
|
|
130
|
+
}
|
|
131
|
+
for (const s of report.slides) {
|
|
132
|
+
if (s.status === 'missing') {
|
|
133
|
+
lines.push(` · missing ${s.file} · the baseline has it, this render does not`);
|
|
134
|
+
} else if (s.status === 'resized') {
|
|
135
|
+
const { baselineSize: was, size: now } = s;
|
|
136
|
+
lines.push(` · resized ${s.file} · ${was.width}×${was.height} → ${now.width}×${now.height}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const { changed: c, stable, added, missing, resized } = report.summary;
|
|
140
|
+
lines.push(
|
|
141
|
+
`rikiki · diff · ${c} changed · ${stable} stable · ${added} added · ` +
|
|
142
|
+
`${missing} missing · ${resized} resized · baseline ${report.baseline}`,
|
|
143
|
+
);
|
|
144
|
+
return lines.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compare two PNGs, in the page.
|
|
149
|
+
*
|
|
150
|
+
* Serialized into the browser by `diffRender`, so it is self-contained: no
|
|
151
|
+
* import, no closure over anything in this module. It returns both sizes
|
|
152
|
+
* always, the count of pixels whose worst channel moved by more than
|
|
153
|
+
* `channelDelta`, and the two extreme corners of those pixels · never the
|
|
154
|
+
* pixels themselves, which for a 1920×1080 slide would be two million points
|
|
155
|
+
* crossing the bridge for a rect.
|
|
156
|
+
*/
|
|
157
|
+
export const COMPARE_IMAGES = `async (baselineUrl, currentUrl, channelDelta) => {
|
|
158
|
+
const load = (src) => new Promise((ok, fail) => {
|
|
159
|
+
const img = new Image();
|
|
160
|
+
img.onload = () => ok(img);
|
|
161
|
+
img.onerror = () => fail(new Error('could not decode one of the two images'));
|
|
162
|
+
img.src = src;
|
|
163
|
+
});
|
|
164
|
+
const [before, after] = await Promise.all([load(baselineUrl), load(currentUrl)]);
|
|
165
|
+
const baselineSize = { width: before.naturalWidth, height: before.naturalHeight };
|
|
166
|
+
const size = { width: after.naturalWidth, height: after.naturalHeight };
|
|
167
|
+
if (baselineSize.width !== size.width || baselineSize.height !== size.height) {
|
|
168
|
+
return { baselineSize, size };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { width, height } = size;
|
|
172
|
+
const bytesOf = (img) => {
|
|
173
|
+
const canvas = document.createElement('canvas');
|
|
174
|
+
canvas.width = width;
|
|
175
|
+
canvas.height = height;
|
|
176
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
177
|
+
ctx.drawImage(img, 0, 0);
|
|
178
|
+
return ctx.getImageData(0, 0, width, height).data;
|
|
179
|
+
};
|
|
180
|
+
const a = bytesOf(before);
|
|
181
|
+
const b = bytesOf(after);
|
|
182
|
+
|
|
183
|
+
let changed = 0;
|
|
184
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
185
|
+
for (let i = 0, p = 0; i < a.length; i += 4, p += 1) {
|
|
186
|
+
const delta = Math.max(
|
|
187
|
+
Math.abs(a[i] - b[i]),
|
|
188
|
+
Math.abs(a[i + 1] - b[i + 1]),
|
|
189
|
+
Math.abs(a[i + 2] - b[i + 2]),
|
|
190
|
+
Math.abs(a[i + 3] - b[i + 3]),
|
|
191
|
+
);
|
|
192
|
+
if (delta <= channelDelta) continue;
|
|
193
|
+
changed += 1;
|
|
194
|
+
const x = p % width;
|
|
195
|
+
const y = (p / width) | 0;
|
|
196
|
+
if (x < minX) minX = x;
|
|
197
|
+
if (x > maxX) maxX = x;
|
|
198
|
+
if (y < minY) minY = y;
|
|
199
|
+
if (y > maxY) maxY = y;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
baselineSize,
|
|
203
|
+
size,
|
|
204
|
+
changed,
|
|
205
|
+
total: width * height,
|
|
206
|
+
changedCorners: changed ? [{ x: minX, y: minY }, { x: maxX, y: maxY }] : [],
|
|
207
|
+
};
|
|
208
|
+
}`;
|
|
209
|
+
|
|
210
|
+
const pngsIn = (dir) =>
|
|
211
|
+
readdirSync(dir)
|
|
212
|
+
.filter((f) => f.toLowerCase().endsWith('.png'))
|
|
213
|
+
.sort();
|
|
214
|
+
|
|
215
|
+
const dataUrl = (file) => 'data:image/png;base64,' + readFileSync(file).toString('base64');
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compare a fresh render against a directory of earlier captures.
|
|
219
|
+
*
|
|
220
|
+
* Runs on a blank page of the browser `renderDeck` already has open, writes
|
|
221
|
+
* `diff.json` next to the manifest, and returns the report.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} options
|
|
224
|
+
* @param {import('playwright').Browser} options.browser the open browser
|
|
225
|
+
* @param {string} options.outDir the render that just happened
|
|
226
|
+
* @param {string} options.baselineDir the captures to compare it against
|
|
227
|
+
* @param {number} [options.threshold] percent of pixels · at or above is `changed`
|
|
228
|
+
* @param {Array} [options.shots] the manifest's shots, to name the slides
|
|
229
|
+
*/
|
|
230
|
+
export async function diffRender({ browser, outDir, baselineDir, threshold = DEFAULT_THRESHOLD, shots = [] }) {
|
|
231
|
+
const { compared, added, missing } = classifyFiles(pngsIn(outDir), pngsIn(baselineDir));
|
|
232
|
+
const metaOf = new Map(
|
|
233
|
+
shots.map((s) => [s.file, { slide: s.index, id: s.id, title: s.title, step: s.step }]),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const slides = [];
|
|
237
|
+
// A page of its own: the deck's own page carries the deck, and a canvas the
|
|
238
|
+
// size of a slide has no business being appended to it.
|
|
239
|
+
const context = await browser.newContext();
|
|
240
|
+
const blank = await context.newPage();
|
|
241
|
+
try {
|
|
242
|
+
for (const file of compared) {
|
|
243
|
+
const measured = await blank.evaluate(
|
|
244
|
+
async ({ source, baseline, current, channelDelta }) => {
|
|
245
|
+
const compare = new Function('return ' + source)();
|
|
246
|
+
return compare(baseline, current, channelDelta);
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
source: COMPARE_IMAGES,
|
|
250
|
+
baseline: dataUrl(join(baselineDir, file)),
|
|
251
|
+
current: dataUrl(join(outDir, file)),
|
|
252
|
+
channelDelta: CHANNEL_DELTA,
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
slides.push(slideEntry(file, measured, threshold, metaOf.get(file)));
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
await context.close().catch(() => {});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const file of added) slides.push({ file, ...(metaOf.get(file) ?? {}), status: 'added' });
|
|
262
|
+
for (const file of missing) slides.push({ file, status: 'missing' });
|
|
263
|
+
|
|
264
|
+
const ranked = rankSlides(slides);
|
|
265
|
+
const report = {
|
|
266
|
+
schema: DIFF_SCHEMA,
|
|
267
|
+
baseline: baselineDir,
|
|
268
|
+
threshold,
|
|
269
|
+
slides: ranked,
|
|
270
|
+
summary: summarize(ranked),
|
|
271
|
+
};
|
|
272
|
+
const diffPath = join(outDir, 'diff.json');
|
|
273
|
+
writeFileSync(diffPath, JSON.stringify(report, null, 2) + '\n');
|
|
274
|
+
return { report, diffPath };
|
|
275
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ════════════════════════════════════════════════════════════════
|
|
2
|
+
// rikiki export · render a deck to PDF, one slide per page.
|
|
3
|
+
//
|
|
4
|
+
// The page geometry, the page breaks and the backgrounds all come from the
|
|
5
|
+
// deck's own print stylesheet (see slideShell in shared styles and the @page
|
|
6
|
+
// rule deck-root writes from its canvas). This module only asks for the PDF:
|
|
7
|
+
// the browser, the server and the settling live in browser.mjs, which render
|
|
8
|
+
// and check share with it.
|
|
9
|
+
// ════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
import { PAGE_LOAD_TIMEOUT_MS, waitForStillFrame, withDeck } from './browser.mjs';
|
|
12
|
+
|
|
13
|
+
export { rootDepthFor } from './browser.mjs';
|
|
14
|
+
|
|
15
|
+
/** Page objects in a PDF written by Chrome, which keeps them out of object
|
|
16
|
+
* streams · `/Type /Pages` (the tree) and `/Count` on the outline are not pages. */
|
|
17
|
+
export function pdfPageCount(buffer) {
|
|
18
|
+
return buffer.toString('latin1').match(/\/Type\s*\/Page(?![s\w])/g)?.length ?? 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lay the deck out as paper before the PDF is taken.
|
|
23
|
+
*
|
|
24
|
+
* Components that measure themselves (graph edges, annotation marks) only draw
|
|
25
|
+
* once the layout they measure exists, and a slide never shown on screen has
|
|
26
|
+
* none. page.pdf() switches to print media and snapshots in the same breath, so
|
|
27
|
+
* their ResizeObservers never ran: every diagram printed without its arrows.
|
|
28
|
+
* Switching first and letting frames pass gives them that layout.
|
|
29
|
+
*/
|
|
30
|
+
export async function preparePrint(page) {
|
|
31
|
+
await page.emulateMedia({ media: 'print' });
|
|
32
|
+
await waitForStillFrame(page);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Render `deckPath` to `outputPath`.
|
|
37
|
+
* @returns {Promise<{pages: number, slides: number, missing: string[]}>}
|
|
38
|
+
* `pages` is read from the PDF itself, so a slide lost on paper shows up as
|
|
39
|
+
* `pages < slides` instead of being reported as printed.
|
|
40
|
+
*/
|
|
41
|
+
export async function exportPdf(deckPath, outputPath, { timeoutMs = PAGE_LOAD_TIMEOUT_MS } = {}) {
|
|
42
|
+
return withDeck(
|
|
43
|
+
deckPath,
|
|
44
|
+
async ({ page, missing }) => {
|
|
45
|
+
await preparePrint(page);
|
|
46
|
+
const pdf = await page.pdf({
|
|
47
|
+
path: outputPath,
|
|
48
|
+
printBackground: true,
|
|
49
|
+
preferCSSPageSize: true,
|
|
50
|
+
// A bookmark per slide title · without an outline a reader has no way
|
|
51
|
+
// to jump around, and several viewers fall back to a continuous scroll
|
|
52
|
+
// with no page stops at all.
|
|
53
|
+
outline: true,
|
|
54
|
+
// Tagged output carries the reading order and the headings · it is what
|
|
55
|
+
// makes the outline above meaningful, and what a screen reader needs.
|
|
56
|
+
tagged: true,
|
|
57
|
+
});
|
|
58
|
+
const slides = await page.evaluate(
|
|
59
|
+
() => document.querySelectorAll('deck-root > *:not(script):not(style):not(template)').length,
|
|
60
|
+
);
|
|
61
|
+
return { pages: pdfPageCount(pdf), slides, missing };
|
|
62
|
+
},
|
|
63
|
+
{ timeoutMs },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ════════════════════════════════════════════════════════════════
|
|
2
|
+
// Does a painted graph edge cross a node it does not connect?
|
|
3
|
+
//
|
|
4
|
+
// Pure arithmetic, kept out of `check.mjs` because the answer is the whole
|
|
5
|
+
// diagnostic: an edge check that is a few pixels too forgiving reports nothing
|
|
6
|
+
// on a crossing anybody can see from the back of the room, and a check nobody
|
|
7
|
+
// trusts costs more than no check at all.
|
|
8
|
+
//
|
|
9
|
+
// The functions are also serialized into the browser by `check.mjs` (see
|
|
10
|
+
// GRAPH_GEOMETRY_READER), so they must stay self-contained: no imports, no
|
|
11
|
+
// closure over anything in this module.
|
|
12
|
+
// ════════════════════════════════════════════════════════════════
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read the `data-path` that `deck-graph` publishes on each `deck-edge`:
|
|
16
|
+
* `"x1,y1 x2,y2[ x3,y3 ...]"` in graph-relative CSS pixels.
|
|
17
|
+
*
|
|
18
|
+
* Returns `[]` for anything it cannot read whole · half a polyline would be
|
|
19
|
+
* tested as a shorter edge and quietly miss, which is the failure this module
|
|
20
|
+
* exists to end. The caller falls back to its own geometry instead.
|
|
21
|
+
*/
|
|
22
|
+
export function parseGraphPath(raw) {
|
|
23
|
+
if (typeof raw !== 'string' || !raw.trim()) return [];
|
|
24
|
+
const points = [];
|
|
25
|
+
for (const pair of raw.trim().split(/\s+/)) {
|
|
26
|
+
const [x, y] = pair.split(',').map(Number);
|
|
27
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return [];
|
|
28
|
+
points.push({ x, y });
|
|
29
|
+
}
|
|
30
|
+
return points.length >= 2 ? points : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Does a polyline put ink inside a rectangle?
|
|
35
|
+
*
|
|
36
|
+
* `margin` grows the rectangle before the test. It carries the half width of
|
|
37
|
+
* the stroke: an edge is a band, not a mathematical line, so a centre line
|
|
38
|
+
* that misses a node by one pixel still paints over it. Growing the rectangle
|
|
39
|
+
* rather than fattening the segment squares off the band's corners, which errs
|
|
40
|
+
* towards reporting · the right direction for a check whose only known defect
|
|
41
|
+
* was staying silent.
|
|
42
|
+
*/
|
|
43
|
+
export function polylineHitsRect(points, rect, margin = 0) {
|
|
44
|
+
const box = {
|
|
45
|
+
left: rect.left - margin,
|
|
46
|
+
right: rect.right + margin,
|
|
47
|
+
top: rect.top - margin,
|
|
48
|
+
bottom: rect.bottom + margin,
|
|
49
|
+
};
|
|
50
|
+
if (box.left >= box.right || box.top >= box.bottom) return false;
|
|
51
|
+
// Liang-Barsky: a segment hits when a slice of its parameter range survives
|
|
52
|
+
// all four half-planes of the box.
|
|
53
|
+
const segmentHits = (a, b) => {
|
|
54
|
+
let t0 = 0;
|
|
55
|
+
let t1 = 1;
|
|
56
|
+
const dx = b.x - a.x;
|
|
57
|
+
const dy = b.y - a.y;
|
|
58
|
+
for (const [p, q] of [
|
|
59
|
+
[-dx, a.x - box.left],
|
|
60
|
+
[dx, box.right - a.x],
|
|
61
|
+
[-dy, a.y - box.top],
|
|
62
|
+
[dy, box.bottom - a.y],
|
|
63
|
+
]) {
|
|
64
|
+
if (p === 0) {
|
|
65
|
+
if (q < 0) return false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const t = q / p;
|
|
69
|
+
if (p < 0) t0 = Math.max(t0, t);
|
|
70
|
+
else t1 = Math.min(t1, t);
|
|
71
|
+
if (t0 > t1) return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
76
|
+
if (segmentHits(points[i], points[i + 1])) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The same two functions, as source, for `page.evaluate` · the inspector runs
|
|
82
|
+
* in the browser and cannot import from here. */
|
|
83
|
+
export const GRAPH_GEOMETRY_READER = `({
|
|
84
|
+
parseGraphPath: ${parseGraphPath},
|
|
85
|
+
polylineHitsRect: ${polylineHitsRect},
|
|
86
|
+
})`;
|