explico 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/CHANGELOG.md +1292 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +11 -4
  4. package/dist/compiler/astro.js +48 -15
  5. package/dist/compiler/cli.d.ts +27 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +33 -2
  8. package/dist/compiler/collections.js +91 -19
  9. package/dist/compiler/content-schema.d.ts +31 -0
  10. package/dist/compiler/content-schema.js +82 -0
  11. package/dist/compiler/content-schemas.d.ts +422 -0
  12. package/dist/compiler/content-schemas.js +229 -0
  13. package/dist/compiler/course-config.d.ts +1 -1
  14. package/dist/compiler/course-config.js +30 -2
  15. package/dist/compiler/exports/combined-html.d.ts +97 -0
  16. package/dist/compiler/exports/combined-html.js +492 -0
  17. package/dist/compiler/exports/download-index.d.ts +9 -0
  18. package/dist/compiler/exports/download-index.js +33 -0
  19. package/dist/compiler/exports/export-chapters.d.ts +98 -0
  20. package/dist/compiler/exports/export-chapters.js +164 -0
  21. package/dist/compiler/exports/export-pages.d.ts +48 -0
  22. package/dist/compiler/exports/export-pages.js +22 -0
  23. package/dist/compiler/exports/html-tree.d.ts +22 -0
  24. package/dist/compiler/exports/html-tree.js +47 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +106 -0
  26. package/dist/compiler/exports/latex/capture.js +435 -0
  27. package/dist/compiler/exports/latex/compile.d.ts +73 -0
  28. package/dist/compiler/exports/latex/compile.js +115 -0
  29. package/dist/compiler/exports/latex/document.d.ts +102 -0
  30. package/dist/compiler/exports/latex/document.js +349 -0
  31. package/dist/compiler/exports/latex/explico.cls +415 -0
  32. package/dist/compiler/exports/latex/numbering.d.ts +18 -0
  33. package/dist/compiler/exports/latex/numbering.js +57 -0
  34. package/dist/compiler/exports/latex/serialize.d.ts +112 -0
  35. package/dist/compiler/exports/latex/serialize.js +606 -0
  36. package/dist/compiler/exports/latex-export.d.ts +44 -0
  37. package/dist/compiler/exports/latex-export.js +169 -0
  38. package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
  39. package/dist/compiler/exports/markdown/serialize.js +527 -0
  40. package/dist/compiler/exports/markdown-export.d.ts +39 -0
  41. package/dist/compiler/exports/markdown-export.js +240 -0
  42. package/dist/compiler/exports/standalone-html.d.ts +15 -5
  43. package/dist/compiler/exports/standalone-html.js +23 -51
  44. package/dist/compiler/exports/static-tree.d.ts +253 -0
  45. package/dist/compiler/exports/static-tree.js +625 -0
  46. package/dist/compiler/exports/zip.d.ts +16 -0
  47. package/dist/compiler/exports/zip.js +72 -0
  48. package/dist/compiler/lesson-derivation.d.ts +8 -2
  49. package/dist/compiler/lesson-derivation.js +6 -7
  50. package/dist/compiler/manifest.d.ts +115 -8
  51. package/dist/compiler/manifest.js +221 -39
  52. package/dist/compiler/render-diagnostics.d.ts +65 -0
  53. package/dist/compiler/render-diagnostics.js +56 -0
  54. package/dist/compiler/resolution-report.d.ts +3 -1
  55. package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
  56. package/dist/curriculum/validation.d.ts +9 -3
  57. package/dist/curriculum/validation.js +35 -17
  58. package/dist/reference/base-abbreviations.d.mts +2 -2
  59. package/dist/reference/base-abbreviations.mjs +2 -2
  60. package/dist/reference/base-operators.d.mts +8 -2
  61. package/dist/reference/base-operators.mjs +8 -10
  62. package/dist/reference/base-roman-labels.d.mts +4 -3
  63. package/dist/reference/base-roman-labels.mjs +4 -3
  64. package/dist/reference/citation-format.d.mts +35 -0
  65. package/dist/reference/citation-format.mjs +74 -0
  66. package/dist/reference/consistency.d.ts +5 -9
  67. package/dist/reference/consistency.js +34 -38
  68. package/dist/reference/curriculum-alignment.js +3 -24
  69. package/dist/reference/derived-entries.d.ts +45 -0
  70. package/dist/reference/derived-entries.js +50 -0
  71. package/dist/reference/derived-notation.d.mts +125 -0
  72. package/dist/reference/derived-notation.mjs +121 -0
  73. package/dist/reference/diagram-fit.d.ts +45 -0
  74. package/dist/reference/diagram-fit.js +54 -0
  75. package/dist/reference/diagram-layout.d.ts +59 -10
  76. package/dist/reference/diagram-layout.js +85 -24
  77. package/dist/reference/diagrams-validate.d.ts +1 -1
  78. package/dist/reference/equations-validate.d.ts +1 -1
  79. package/dist/reference/equations-validate.js +26 -1
  80. package/dist/reference/equations.d.mts +27 -0
  81. package/dist/reference/equations.mjs +120 -22
  82. package/dist/reference/examples-validate.d.ts +1 -1
  83. package/dist/reference/figure-fit.d.ts +70 -0
  84. package/dist/reference/figure-fit.js +60 -0
  85. package/dist/reference/figure-pictures.d.ts +27 -0
  86. package/dist/reference/figure-pictures.js +49 -0
  87. package/dist/reference/figure-symbols.d.ts +32 -0
  88. package/dist/reference/figure-symbols.js +55 -0
  89. package/dist/reference/figures-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-abbreviations.js +1 -1
  92. package/dist/reference/gate-course-rules.d.ts +4 -3
  93. package/dist/reference/gate-course-rules.js +9 -4
  94. package/dist/reference/gate-heading-length.d.ts +2 -0
  95. package/dist/reference/gate-heading-length.js +7 -5
  96. package/dist/reference/gate-heading-structure.js +26 -0
  97. package/dist/reference/gate-indices.d.ts +35 -0
  98. package/dist/reference/gate-indices.js +299 -0
  99. package/dist/reference/gate-math.d.ts +32 -2
  100. package/dist/reference/gate-math.js +68 -12
  101. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  102. package/dist/reference/gate-mdx-expression.js +29 -0
  103. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  104. package/dist/reference/gate-non-chapter-pages.js +156 -0
  105. package/dist/reference/gate-part-pages.d.ts +4 -2
  106. package/dist/reference/gate-part-pages.js +2 -1
  107. package/dist/reference/gate-prop-text.d.ts +28 -0
  108. package/dist/reference/gate-prop-text.js +106 -0
  109. package/dist/reference/gate-prose-notation.d.ts +3 -1
  110. package/dist/reference/gate-prose-notation.js +4 -2
  111. package/dist/reference/gate-retired-keys.d.ts +35 -0
  112. package/dist/reference/gate-retired-keys.js +58 -0
  113. package/dist/reference/gloss.d.ts +2 -0
  114. package/dist/reference/katex-measure.d.ts +17 -0
  115. package/dist/reference/katex-measure.js +223 -0
  116. package/dist/reference/keyed-float-validate.js +12 -0
  117. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  118. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  119. package/dist/reference/lab-katex-validate.d.ts +43 -0
  120. package/dist/reference/lab-katex-validate.js +96 -0
  121. package/dist/reference/lab-math-scope.d.ts +1 -0
  122. package/dist/reference/lab-math-scope.js +13 -2
  123. package/dist/reference/lint-ignore.js +1 -1
  124. package/dist/reference/math-glyphs.d.mts +94 -2
  125. package/dist/reference/math-glyphs.mjs +851 -46
  126. package/dist/reference/mixed-content.d.ts +8 -3
  127. package/dist/reference/mixed-content.js +4 -2
  128. package/dist/reference/page-notation.d.ts +39 -7
  129. package/dist/reference/page-notation.js +62 -10
  130. package/dist/reference/raw-floats.mjs +37 -13
  131. package/dist/reference/references.d.ts +11 -1
  132. package/dist/reference/references.js +6 -0
  133. package/dist/reference/registry.js +115 -11
  134. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  135. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  136. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  137. package/dist/reference/remark-citation.mjs +0 -0
  138. package/dist/reference/remark-notation.mjs +275 -23
  139. package/dist/reference/render-lab-math.d.ts +13 -2
  140. package/dist/reference/render-lab-math.js +18 -6
  141. package/dist/reference/sections-validate.d.ts +28 -3
  142. package/dist/reference/sections-validate.js +31 -4
  143. package/dist/reference/sections.d.mts +24 -6
  144. package/dist/reference/sections.mjs +92 -17
  145. package/dist/reference/tables-validate.d.ts +1 -1
  146. package/dist/reference/tables-validate.js +24 -1
  147. package/dist/reference/text-measure.d.ts +10 -0
  148. package/dist/reference/text-measure.js +41 -0
  149. package/dist/reference/text-widths.d.ts +10 -0
  150. package/dist/reference/text-widths.js +1238 -0
  151. package/dist/reference/types.d.ts +64 -8
  152. package/dist/rules/catalog.js +223 -32
  153. package/dist/rules/in-effect.d.ts +1 -1
  154. package/dist/rules/rules.schema.json +100 -27
  155. package/dist/rules/settings.d.ts +1 -1
  156. package/dist/rules/settings.js +7 -0
  157. package/dist/session/PreferenceStore.d.ts +18 -1
  158. package/dist/session/PreferenceStore.js +29 -1
  159. package/docs/authoring/README.md +33 -21
  160. package/docs/authoring/agents.md +38 -7
  161. package/docs/authoring/components.md +115 -10
  162. package/docs/authoring/migrating.md +878 -0
  163. package/docs/authoring/notation.md +285 -30
  164. package/docs/authoring/retired.md +1 -0
  165. package/docs/authoring/structure-rules.md +84 -45
  166. package/docs/authoring/structure.md +67 -2
  167. package/docs/authoring/visualizations.md +167 -25
  168. package/package.json +12 -7
  169. package/src/compiler/astro.ts +72 -21
  170. package/src/compiler/cli.ts +143 -35
  171. package/src/compiler/collections.ts +158 -27
  172. package/src/compiler/content-schema.ts +108 -0
  173. package/src/compiler/content-schemas.ts +391 -0
  174. package/src/compiler/course-config.ts +86 -15
  175. package/src/compiler/exports/combined-html.ts +669 -0
  176. package/src/compiler/exports/download-index.ts +36 -0
  177. package/src/compiler/exports/export-chapters.ts +233 -0
  178. package/src/compiler/exports/export-pages.ts +44 -0
  179. package/src/compiler/exports/html-tree.ts +62 -0
  180. package/src/compiler/exports/latex/capture.ts +651 -0
  181. package/src/compiler/exports/latex/compile.ts +168 -0
  182. package/src/compiler/exports/latex/document.ts +479 -0
  183. package/src/compiler/exports/latex/explico.cls +415 -0
  184. package/src/compiler/exports/latex/numbering.ts +74 -0
  185. package/src/compiler/exports/latex/serialize.ts +787 -0
  186. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  187. package/src/compiler/exports/latex-export.ts +255 -0
  188. package/src/compiler/exports/markdown/serialize.ts +632 -0
  189. package/src/compiler/exports/markdown-export.ts +329 -0
  190. package/src/compiler/exports/standalone-html.ts +39 -70
  191. package/src/compiler/exports/static-tree.ts +944 -0
  192. package/src/compiler/exports/zip.ts +88 -0
  193. package/src/compiler/lesson-derivation.ts +13 -8
  194. package/src/compiler/manifest.ts +387 -40
  195. package/src/compiler/render-diagnostics.ts +111 -0
  196. package/src/components/CourseContents.astro +6 -0
  197. package/src/components/HoverPanel.astro +204 -6
  198. package/src/components/PartOverview.astro +18 -9
  199. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  200. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  201. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  202. package/src/components/assessments/AssessmentSet.astro +14 -1
  203. package/src/components/block-heading.ts +38 -0
  204. package/src/components/card-arrow.ts +18 -4
  205. package/src/components/card-carry.ts +406 -0
  206. package/src/components/card-drag.ts +1 -30
  207. package/src/components/card-fetch.ts +87 -0
  208. package/src/components/card-manager.ts +1222 -38
  209. package/src/components/card-memory.ts +5 -1
  210. package/src/components/card-placement.ts +211 -13
  211. package/src/components/card-side.ts +281 -31
  212. package/src/components/citation/CitationLayer.astro +45 -40
  213. package/src/components/diagram/Diagram.astro +123 -44
  214. package/src/components/disclosure/print-as-shown.ts +42 -6
  215. package/src/components/figure/Figure.astro +17 -1
  216. package/src/components/figure/FloatCaption.astro +4 -17
  217. package/src/components/figure/figure-state.ts +26 -17
  218. package/src/components/float-toolbar.ts +274 -0
  219. package/src/components/memo/Memo.astro +21 -10
  220. package/src/components/memo/ShortNote.astro +4 -0
  221. package/src/components/notation/NotationGlossary.astro +22 -17
  222. package/src/components/notation/NotationLayer.astro +300 -79
  223. package/src/components/notation/NotationMath.astro +8 -1
  224. package/src/components/notation/render-notation-math.ts +51 -5
  225. package/src/components/readable-text.ts +25 -0
  226. package/src/components/reference/FloatRefLayer.astro +162 -89
  227. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  228. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  229. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  230. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  231. package/src/components/starlight/LessonExportActions.astro +384 -310
  232. package/src/components/starlight/LessonFooter.astro +66 -8
  233. package/src/components/starlight/PrintController.astro +28 -0
  234. package/src/components/starlight/TextSizeControl.astro +137 -0
  235. package/src/components/starlight/print-controller.ts +380 -0
  236. package/src/components/starlight/section-summary.ts +13 -5
  237. package/src/components/table/Table.astro +14 -22
  238. package/src/components/table/table-truncation.ts +15 -0
  239. package/src/content-config.ts +20 -312
  240. package/src/curriculum/validation.ts +72 -24
  241. package/src/reference/base-abbreviations.mjs +2 -2
  242. package/src/reference/base-operators.mjs +8 -10
  243. package/src/reference/base-roman-labels.mjs +4 -3
  244. package/src/reference/citation-format.mjs +74 -0
  245. package/src/reference/consistency.ts +47 -61
  246. package/src/reference/curriculum-alignment.ts +4 -41
  247. package/src/reference/derived-entries.ts +68 -0
  248. package/src/reference/derived-notation.mjs +121 -0
  249. package/src/reference/diagram-fit.ts +104 -0
  250. package/src/reference/diagram-layout.ts +182 -51
  251. package/src/reference/diagrams-validate.ts +2 -1
  252. package/src/reference/equations-validate.ts +41 -2
  253. package/src/reference/equations.mjs +120 -22
  254. package/src/reference/examples-validate.ts +1 -0
  255. package/src/reference/figure-fit.ts +139 -0
  256. package/src/reference/figure-pictures.ts +96 -0
  257. package/src/reference/figure-symbols.ts +117 -0
  258. package/src/reference/figures-validate.ts +2 -1
  259. package/src/reference/gate-abbreviations.ts +1 -1
  260. package/src/reference/gate-course-rules.ts +22 -7
  261. package/src/reference/gate-heading-length.ts +25 -7
  262. package/src/reference/gate-heading-structure.ts +40 -1
  263. package/src/reference/gate-indices.ts +470 -0
  264. package/src/reference/gate-math.ts +102 -13
  265. package/src/reference/gate-mdx-expression.ts +45 -0
  266. package/src/reference/gate-non-chapter-pages.ts +241 -0
  267. package/src/reference/gate-part-pages.ts +1 -1
  268. package/src/reference/gate-prop-text.ts +145 -0
  269. package/src/reference/gate-prose-notation.ts +19 -5
  270. package/src/reference/gate-retired-keys.ts +101 -0
  271. package/src/reference/gloss.ts +2 -0
  272. package/src/reference/katex-measure.ts +384 -0
  273. package/src/reference/keyed-float-validate.ts +22 -0
  274. package/src/reference/keyed-jsx-floats.mjs +11 -14
  275. package/src/reference/lab-katex-validate.ts +156 -0
  276. package/src/reference/lab-math-scope.ts +15 -1
  277. package/src/reference/lint-ignore.ts +4 -1
  278. package/src/reference/math-glyphs.mjs +851 -46
  279. package/src/reference/mixed-content.ts +11 -2
  280. package/src/reference/page-notation.ts +114 -14
  281. package/src/reference/raw-floats.mjs +37 -13
  282. package/src/reference/references.ts +14 -0
  283. package/src/reference/registry.ts +170 -9
  284. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  285. package/src/reference/rehype-heading-anchors.mjs +11 -1
  286. package/src/reference/remark-citation.mjs +0 -0
  287. package/src/reference/remark-notation.mjs +275 -23
  288. package/src/reference/render-lab-math.ts +40 -5
  289. package/src/reference/sections-validate.ts +67 -3
  290. package/src/reference/sections.mjs +92 -17
  291. package/src/reference/tables-validate.ts +40 -2
  292. package/src/reference/text-measure.ts +63 -0
  293. package/src/reference/text-widths.ts +133 -0
  294. package/src/reference/types.ts +75 -8
  295. package/src/rules/catalog.ts +241 -31
  296. package/src/rules/settings.ts +10 -0
  297. package/src/session/PreferenceStore.ts +53 -0
  298. package/src/styles/global.css +322 -83
@@ -0,0 +1,651 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createServer, type Server } from 'node:http';
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { extname, join, resolve, sep } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ /**
9
+ * The capture pass: draw each figure a browser has to draw (a hydrated lab, a
10
+ * course component's SVG) and print it as a vector PDF for the LaTeX source.
11
+ *
12
+ * Recipe, from the spike that tested it:
13
+ * 1. Serve the finished `dist/` on loopback, at the site's base path.
14
+ * 2. Open the page in a fresh light-mode context and fire
15
+ * `explico:prepare-static-export` in `markdown` mode, which opens every
16
+ * disclosure so a figure in a collapsed section gets a layout box.
17
+ * 3. Scroll each figure into view so its `client:visible` lab hydrates, and
18
+ * wait until it has drawn and stopped changing.
19
+ * 4. Clone the hydrated figure into a clean page *on the same origin* that
20
+ * links the site's stylesheets — hiding the rest of the live page around it
21
+ * loses Plot's tick labels and blanks a diagram, and a page on another
22
+ * origin cannot load the site's fonts (fonts are CORS-restricted).
23
+ * 5. Print that page at the figure's size: every path and label stays vector,
24
+ * with its fonts embedded.
25
+ *
26
+ * Playwright is resolved from the course itself, so the browser it drives is
27
+ * the one the course installed for its own tests.
28
+ */
29
+
30
+ export interface CaptureRequest {
31
+ /** Base-relative route of the page, e.g. `projectiles/optimal-launch-angle`. */
32
+ readonly route: string;
33
+ /** Anchor ids of the figures and diagrams to capture (`fig-…`, `dia-…`). */
34
+ readonly anchors: readonly string[];
35
+ }
36
+
37
+ export interface CapturedFigure {
38
+ /** The figure's picture as a one-page vector PDF. */
39
+ readonly pdf: Uint8Array;
40
+ /** The same picture as PNG at two device pixels per CSS pixel, when asked for. */
41
+ readonly png?: Uint8Array;
42
+ /** The caption's live clause after the lab wrote it, as HTML. */
43
+ readonly liveHtml: string;
44
+ /** Fonts some of the figure's text came from that the machine supplied, not the site. */
45
+ readonly machineFonts: readonly string[];
46
+ /** The size, in points, the figure's most common SVG label prints at in the book. */
47
+ readonly labelPt?: number;
48
+ }
49
+
50
+ /** By route, then by figure anchor. */
51
+ export type CaptureResults = Map<string, Map<string, CapturedFigure>>;
52
+
53
+ interface ChromiumLike {
54
+ launch(options?: Record<string, unknown>): Promise<BrowserLike>;
55
+ }
56
+ interface BrowserLike {
57
+ newContext(options?: Record<string, unknown>): Promise<ContextLike>;
58
+ close(): Promise<void>;
59
+ }
60
+ interface ContextLike {
61
+ newPage(): Promise<PageLike>;
62
+ /** Chromium only: the DevTools protocol, which names the fonts text was drawn with. */
63
+ newCDPSession?(page: PageLike): Promise<SessionLike>;
64
+ close(): Promise<void>;
65
+ }
66
+ interface SessionLike {
67
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
68
+ detach(): Promise<void>;
69
+ }
70
+ interface PageLike {
71
+ goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
72
+ setContent(html: string, options?: Record<string, unknown>): Promise<void>;
73
+ evaluate<R, A>(fn: (arg: A) => R | Promise<R>, arg?: A): Promise<R>;
74
+ emulateMedia(options: Record<string, unknown>): Promise<void>;
75
+ pdf(options: Record<string, unknown>): Promise<Uint8Array>;
76
+ screenshot(options: Record<string, unknown>): Promise<Uint8Array>;
77
+ close(): Promise<void>;
78
+ }
79
+
80
+ /** Playwright's Chromium from the course's own dependencies, or `undefined`. */
81
+ export async function courseChromium(projectRoot: string): Promise<ChromiumLike | undefined> {
82
+ const require = createRequire(join(projectRoot, 'package.json'));
83
+ type PlaywrightModule = { chromium?: ChromiumLike; default?: { chromium?: ChromiumLike } };
84
+ for (const name of ['playwright-core', 'playwright', '@playwright/test']) {
85
+ let path: string;
86
+ try {
87
+ path = require.resolve(name);
88
+ } catch {
89
+ continue; // Not installed under this name.
90
+ }
91
+ // Playwright is CommonJS; `require` also works where the exporter runs
92
+ // inside Vite's module runner (a workspace course loading explico's source).
93
+ try {
94
+ const module = require(path) as PlaywrightModule;
95
+ const chromium = module.chromium ?? module.default?.chromium;
96
+ if (chromium) return chromium;
97
+ } catch {
98
+ const module = (await import(/* @vite-ignore */ pathToFileURL(path).href)) as PlaywrightModule;
99
+ const chromium = module.chromium ?? module.default?.chromium;
100
+ if (chromium) return chromium;
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ const MIME_TYPES: Readonly<Record<string, string>> = {
107
+ '.css': 'text/css; charset=utf-8',
108
+ '.html': 'text/html; charset=utf-8',
109
+ '.js': 'text/javascript; charset=utf-8',
110
+ '.mjs': 'text/javascript; charset=utf-8',
111
+ '.json': 'application/json',
112
+ '.svg': 'image/svg+xml',
113
+ '.png': 'image/png',
114
+ '.jpg': 'image/jpeg',
115
+ '.jpeg': 'image/jpeg',
116
+ '.gif': 'image/gif',
117
+ '.webp': 'image/webp',
118
+ '.avif': 'image/avif',
119
+ '.ico': 'image/x-icon',
120
+ '.woff': 'font/woff',
121
+ '.woff2': 'font/woff2',
122
+ '.ttf': 'font/ttf',
123
+ '.otf': 'font/otf',
124
+ '.txt': 'text/plain; charset=utf-8',
125
+ '.xml': 'application/xml',
126
+ '.wasm': 'application/wasm',
127
+ };
128
+
129
+ const CAPTURE_PATH = '__explico_capture__';
130
+
131
+ async function existingFile(path: string): Promise<string | undefined> {
132
+ try {
133
+ const info = await stat(path);
134
+ if (info.isFile()) return path;
135
+ if (info.isDirectory()) return existingFile(join(path, 'index.html'));
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ /** Serve `root` on loopback at `base`; resolves with the origin once listening. */
143
+ async function serve(root: string, base: string): Promise<{ origin: string; server: Server }> {
144
+ const rootPath = resolve(root);
145
+ const server = createServer((request, response) => {
146
+ void (async () => {
147
+ let pathname = '/';
148
+ try {
149
+ pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname);
150
+ } catch {
151
+ response.writeHead(400).end();
152
+ return;
153
+ }
154
+ if (pathname.startsWith(base)) pathname = `/${pathname.slice(base.length)}`;
155
+ if (pathname === `/${CAPTURE_PATH}`) {
156
+ response.writeHead(200, { 'Content-Type': MIME_TYPES['.html']! });
157
+ response.end('<!doctype html><html><head></head><body></body></html>');
158
+ return;
159
+ }
160
+ const candidate = resolve(rootPath, `.${pathname}`);
161
+ if (candidate !== rootPath && !candidate.startsWith(`${rootPath}${sep}`)) {
162
+ response.writeHead(403).end();
163
+ return;
164
+ }
165
+ const file = (await existingFile(candidate)) ?? (await existingFile(`${candidate}.html`));
166
+ if (!file) {
167
+ response.writeHead(404).end();
168
+ return;
169
+ }
170
+ response.writeHead(200, {
171
+ 'Content-Type': MIME_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream',
172
+ });
173
+ response.end(await readFile(file));
174
+ })().catch(() => {
175
+ if (!response.headersSent) response.writeHead(500);
176
+ response.end();
177
+ });
178
+ });
179
+ await new Promise<void>((resolveListen, rejectListen) => {
180
+ server.once('error', rejectListen);
181
+ server.listen(0, '127.0.0.1', () => resolveListen());
182
+ });
183
+ const address = server.address();
184
+ if (!address || typeof address === 'string') throw new Error('Capture server has no port.');
185
+ return { origin: `http://127.0.0.1:${address.port}`, server };
186
+ }
187
+
188
+ interface FigureSnapshot {
189
+ readonly html: string;
190
+ readonly width: number;
191
+ readonly height: number;
192
+ readonly links: readonly string[];
193
+ readonly styles: string;
194
+ readonly live: string;
195
+ }
196
+
197
+ /** Wait until a figure's lab has hydrated and its drawing has stopped changing. */
198
+ async function settleFigure(page: PageLike, anchor: string, timeoutMs: number): Promise<void> {
199
+ const settled = await page.evaluate(
200
+ async ({ anchor: id, timeout }: { anchor: string; timeout: number }) => {
201
+ const figure = document.getElementById(id);
202
+ if (!figure) return `no element #${id}`;
203
+ const body = figure.querySelector('.keyed-figure__body, .diagram__body') ?? figure;
204
+ body.scrollIntoView({ block: 'center' });
205
+ const frame = () => new Promise<void>((done) => requestAnimationFrame(() => done()));
206
+ const started = performance.now();
207
+ let previous = '';
208
+ let stableSince = performance.now();
209
+ while (performance.now() - started < timeout) {
210
+ await frame();
211
+ const pending = body.querySelector('astro-island[ssr]');
212
+ const current = body.innerHTML;
213
+ if (current !== previous) {
214
+ previous = current;
215
+ stableSince = performance.now();
216
+ }
217
+ if (!pending && performance.now() - stableSince > 400) {
218
+ await document.fonts.ready;
219
+ return '';
220
+ }
221
+ await new Promise((done) => setTimeout(done, 50));
222
+ }
223
+ return body.querySelector('astro-island[ssr]')
224
+ ? `#${id} never hydrated`
225
+ : `#${id} kept changing`;
226
+ },
227
+ { anchor, timeout: timeoutMs },
228
+ );
229
+ if (settled) throw new Error(`Figure capture: ${settled} within ${timeoutMs} ms.`);
230
+ }
231
+
232
+ async function snapshotFigure(page: PageLike, anchor: string): Promise<FigureSnapshot> {
233
+ return page.evaluate((id: string) => {
234
+ const figure = document.getElementById(id)!;
235
+ const body = (figure.querySelector('.keyed-figure__body, .diagram__body') as HTMLElement | null) ?? figure;
236
+ const box = body.getBoundingClientRect();
237
+ // A diagram scrolls its canvas inside its body: the picture is the canvas.
238
+ const scrolled = [...body.querySelectorAll<HTMLElement>('*')].map((element) => element.scrollWidth);
239
+ const clone = figure.cloneNode(true) as HTMLElement;
240
+ clone.querySelectorAll('script').forEach((node) => node.remove());
241
+ const live = figure.querySelector('.float-caption__state');
242
+ return {
243
+ html: clone.outerHTML,
244
+ width: Math.ceil(Math.max(box.width, body.scrollWidth, ...scrolled)),
245
+ height: Math.ceil(Math.max(box.height, body.scrollHeight)),
246
+ links: [...document.styleSheets].flatMap((sheet) => (sheet.href ? [sheet.href] : [])),
247
+ styles: [...document.querySelectorAll('style')].map((node) => node.textContent ?? '').join('\n'),
248
+ live: live?.innerHTML ?? '',
249
+ };
250
+ }, anchor);
251
+ }
252
+
253
+ /**
254
+ * A figure's labels on paper are set in the book's typeface: KaTeX's
255
+ * Computer Modern, whose fonts explico's stylesheet loads on every page, so
256
+ * CI and a laptop draw the same figure. Upright Latin comes from KaTeX_Main;
257
+ * KaTeX_Math adds the italic Greek a label may name. A character in neither
258
+ * falls back to the machine's serif, which `machineFonts` reports.
259
+ */
260
+ const LABEL_FONT = 'KaTeX_Main, KaTeX_Math, serif';
261
+
262
+ /** The size a figure's most common label prints at, as `\footnotesize` in the book's 11 pt. */
263
+ export const LABEL_PT = 9;
264
+
265
+ /** PostScript points per CSS pixel: Chromium prints 96 px to the inch. */
266
+ const PT_PER_PX = 0.75;
267
+
268
+ const CAPTURE_STYLE = `
269
+ @page { margin: 0 }
270
+ html, body { margin: 0 !important; padding: 0 !important; background: #fff !important; }
271
+ main, .sl-markdown-content { margin: 0 !important; padding: 0 !important; max-width: none !important; }
272
+ figure { margin: 0 !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; }
273
+ figure > :not(.keyed-figure__body, .diagram__body) { display: none !important; }
274
+ :is(.keyed-figure__body, .diagram__body) { font-family: ${LABEL_FONT}; }
275
+ :is(.keyed-figure__body, .diagram__body) :is(svg text, svg tspan) { font-family: ${LABEL_FONT} !important; }
276
+ :is(.keyed-figure__body, .diagram__body), :is(.keyed-figure__body, .diagram__body) :is(svg, .diagram-scroll) { overflow: visible !important; }
277
+ `;
278
+
279
+ /** The page a figure's PDF is printed on: its laid-out box, in CSS pixels. */
280
+ interface PrintBox {
281
+ readonly width: number;
282
+ readonly height: number;
283
+ }
284
+
285
+ /**
286
+ * How many points one CSS pixel of the figure prints at in the book, once
287
+ * `\includegraphics[max width=\linewidth,max height=0.6\textheight]` has
288
+ * scaled it into the measure.
289
+ */
290
+ function printedPtPerPx(box: PrintBox, measure: FigureMeasure): number {
291
+ const fit = Math.min(1, measure.width / (box.width * PT_PER_PX), measure.maxHeight / (box.height * PT_PER_PX));
292
+ return PT_PER_PX * fit;
293
+ }
294
+
295
+ /** The box to print, and the size the figure's most common label prints at. */
296
+ interface SizedLabels extends PrintBox {
297
+ readonly labelPt?: number;
298
+ }
299
+
300
+ /**
301
+ * Size the figure's SVG labels so the most common one prints at `labelPt`
302
+ * after LaTeX scales the figure, every other label keeping its proportion
303
+ * to it. An SVG's labels are drawn in its own units, so a plot drawn wide
304
+ * and shown narrow prints them small; text outside an SVG keeps its size.
305
+ *
306
+ * A layout made for smaller labels has no room for larger ones. An axis
307
+ * title of Observable Plot moves clear of its grown tick labels, as Plot
308
+ * would have placed it; beyond that, labels grow only as far as they can
309
+ * without two of them touching that did not before. Resolves with the box
310
+ * to print, grown to any label that now overflows it.
311
+ */
312
+ async function sizeLabels(page: PageLike, box: PrintBox, measure: FigureMeasure): Promise<SizedLabels> {
313
+ return page.evaluate(
314
+ async ({ labelPt, ptPerPx, width, height }: { labelPt: number; ptPerPx: number; width: number; height: number }) => {
315
+ const body = document.querySelector<HTMLElement>('.keyed-figure__body, .diagram__body');
316
+ if (!body) return { width, height };
317
+ const scaleOf = (element: SVGGraphicsElement) => {
318
+ const matrix = element.getScreenCTM();
319
+ return matrix ? Math.hypot(matrix.a, matrix.b) : 1;
320
+ };
321
+ const elements = [...body.querySelectorAll<SVGTextContentElement>('svg text, svg tspan')];
322
+ const own = new Map(elements.map((element) => [element, Number.parseFloat(getComputedStyle(element).fontSize)]));
323
+ const weights = new Map<number, number>();
324
+ for (const element of elements) {
325
+ const characters = [...element.childNodes]
326
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
327
+ .reduce((count, node) => count + (node.textContent ?? '').trim().length, 0);
328
+ const printed = Math.round((own.get(element) ?? 0) * scaleOf(element) * ptPerPx * 10) / 10;
329
+ if (characters > 0 && printed > 0) weights.set(printed, (weights.get(printed) ?? 0) + characters);
330
+ }
331
+ const common = [...weights].sort((a, b) => b[1] - a[1] || b[0] - a[0])[0]?.[0];
332
+ if (!common) return { width, height };
333
+
334
+ // Where a label's glyphs are, on screen: its advance box across, and
335
+ // from cap height above the baseline to descender depth below it.
336
+ const texts = [...body.querySelectorAll<SVGTextElement>('svg text')];
337
+ const ink = (text: SVGTextElement) => {
338
+ const rect = text.getBoundingClientRect();
339
+ const size = Number.parseFloat(getComputedStyle(text).fontSize) * scaleOf(text);
340
+ const matrix = text.getScreenCTM();
341
+ if (!matrix || text.getNumberOfChars() === 0 || rect.height > 1.6 * size) {
342
+ return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
343
+ }
344
+ const start = text.getStartPositionOfChar(0);
345
+ const baseline = matrix.b * start.x + matrix.d * start.y + matrix.f;
346
+ return { left: rect.left, right: rect.right, top: baseline - 0.72 * size, bottom: baseline + 0.22 * size };
347
+ };
348
+ type Box = ReturnType<typeof ink>;
349
+ const touching = (a: Box, b: Box) =>
350
+ a.left < b.right - 0.5 && b.left < a.right - 0.5 && a.top < b.bottom - 0.5 && b.top < a.bottom - 0.5;
351
+ const contacts = () => {
352
+ const boxes = texts.map(ink);
353
+ const found = new Set<string>();
354
+ boxes.forEach((a, i) => boxes.slice(i + 1).forEach((b, j) => touching(a, b) && found.add(`${i}:${i + 1 + j}`)));
355
+ return found;
356
+ };
357
+ const before = contacts();
358
+
359
+ // Plot's axis titles and the tick labels they sit beside, with the gap between them.
360
+ const transforms = new Map(texts.map((text) => [text, text.getAttribute('transform')]));
361
+ const titles = ['x', 'y', 'fx', 'fy'].flatMap((axis) => {
362
+ const ticks = [...body.querySelectorAll<SVGTextElement>(`g[aria-label="${axis}-axis tick label"] text`)];
363
+ return [...body.querySelectorAll<SVGTextElement>(`g[aria-label="${axis}-axis label"] text`)].flatMap((title) => {
364
+ const box = ink(title);
365
+ const near = ticks.filter((tick) => {
366
+ const other = ink(tick);
367
+ return other.left < box.right && box.left < other.right;
368
+ });
369
+ if (near.length === 0) return [];
370
+ const boxes = near.map(ink);
371
+ const below = (box.top + box.bottom) / 2 > Math.max(...boxes.map((other) => (other.top + other.bottom) / 2));
372
+ const gap = below
373
+ ? box.top - Math.max(...boxes.map((other) => other.bottom))
374
+ : Math.min(...boxes.map((other) => other.top)) - box.bottom;
375
+ return [{ title, near, below, gap: Math.max(1, gap) }];
376
+ });
377
+ });
378
+
379
+ const apply = (ratio: number) => {
380
+ for (const element of elements) {
381
+ element.style.setProperty('font-size', `${(own.get(element) ?? 0) * ratio}px`, 'important');
382
+ }
383
+ for (const [text, transform] of transforms) {
384
+ if (transform === null) text.removeAttribute('transform');
385
+ else text.setAttribute('transform', transform);
386
+ }
387
+ for (const { title, near, below, gap } of titles) {
388
+ const box = ink(title);
389
+ const boxes = near.map(ink);
390
+ const shift = below
391
+ ? Math.max(0, Math.max(...boxes.map((other) => other.bottom)) + gap - box.top)
392
+ : Math.min(0, Math.min(...boxes.map((other) => other.top)) - gap - box.bottom);
393
+ if (shift === 0) continue;
394
+ const transform = transforms.get(title);
395
+ title.setAttribute('transform', `${transform ?? ''} translate(0 ${shift / scaleOf(title)})`.trim());
396
+ }
397
+ };
398
+ const collides = () => [...contacts()].some((pair) => !before.has(pair));
399
+
400
+ const goal = Math.min(2.5, Math.max(0.4, labelPt / common));
401
+ let ratio = goal;
402
+ apply(ratio);
403
+ if (goal > 1 && collides()) {
404
+ let [fits, fails] = [1, goal];
405
+ for (let step = 0; step < 8; step++) {
406
+ const middle = (fits + fails) / 2;
407
+ apply(middle);
408
+ if (collides()) fails = middle;
409
+ else fits = middle;
410
+ }
411
+ ratio = fits;
412
+ apply(ratio);
413
+ }
414
+ await document.fonts.ready;
415
+
416
+ // A label grown past its SVG's edge would be cut off by the page: grow
417
+ // the page to it, moving the figure right or down when it is left or
418
+ // above, and let every box between the SVG and the figure show it.
419
+ for (const svg of body.querySelectorAll('svg')) {
420
+ for (let node = svg.parentElement; node && node !== body; node = node.parentElement) {
421
+ node.style.setProperty('overflow', 'visible', 'important');
422
+ }
423
+ }
424
+ const frame = body.getBoundingClientRect();
425
+ let [left, top, right, bottom] = [frame.left, frame.top, frame.left + width, frame.top + height];
426
+ for (const element of elements) {
427
+ const rect = element.getBoundingClientRect();
428
+ if (rect.width === 0 && rect.height === 0) continue;
429
+ [left, top] = [Math.min(left, rect.left), Math.min(top, rect.top)];
430
+ [right, bottom] = [Math.max(right, rect.right), Math.max(bottom, rect.bottom)];
431
+ }
432
+ const [shiftX, shiftY] = [Math.ceil(frame.left - left), Math.ceil(frame.top - top)];
433
+ if (shiftX > 0 || shiftY > 0) {
434
+ document.body.style.setProperty('padding', `${shiftY}px 0 0 ${shiftX}px`, 'important');
435
+ }
436
+ return {
437
+ width: Math.ceil(right - left),
438
+ height: Math.ceil(bottom - top),
439
+ labelPt: Math.round(common * ratio * 10) / 10,
440
+ };
441
+ },
442
+ { labelPt: LABEL_PT, ptPerPx: printedPtPerPx(box, measure), width: box.width, height: box.height },
443
+ );
444
+ }
445
+
446
+ /**
447
+ * The machine's own fonts any text in the figure was drawn with: a glyph
448
+ * the site's fonts lack, or a lab that sets its own family. Such a figure
449
+ * may print differently on another machine.
450
+ */
451
+ async function machineFonts(context: ContextLike, page: PageLike): Promise<string[]> {
452
+ if (!context.newCDPSession) return [];
453
+ await page.evaluate(() => {
454
+ for (const element of document.querySelectorAll(
455
+ ':is(.keyed-figure__body, .diagram__body), :is(.keyed-figure__body, .diagram__body) *',
456
+ )) {
457
+ // KaTeX's MathML copy is for screen readers, clipped out of sight.
458
+ if (element.closest('.katex-mathml')) continue;
459
+ const text = [...element.childNodes].some(
460
+ (node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim() !== '',
461
+ );
462
+ if (text) element.setAttribute('data-explico-capture-text', '');
463
+ }
464
+ });
465
+ const session = await context.newCDPSession(page);
466
+ try {
467
+ await session.send('DOM.enable');
468
+ await session.send('CSS.enable');
469
+ const { root } = (await session.send('DOM.getDocument', { depth: 0 })) as { root: { nodeId: number } };
470
+ const { nodeIds } = (await session.send('DOM.querySelectorAll', {
471
+ nodeId: root.nodeId,
472
+ selector: '[data-explico-capture-text]',
473
+ })) as { nodeIds: number[] };
474
+ // Each machine font, with the text of the first element it drew.
475
+ const found = new Map<string, string>();
476
+ for (const nodeId of nodeIds) {
477
+ const { fonts } = (await session.send('CSS.getPlatformFontsForNode', { nodeId })) as {
478
+ fonts: { familyName: string; isCustomFont: boolean }[];
479
+ };
480
+ for (const font of fonts) {
481
+ if (font.isCustomFont || found.has(font.familyName)) continue;
482
+ const { outerHTML } = (await session.send('DOM.getOuterHTML', { nodeId })) as { outerHTML: string };
483
+ const sample = outerHTML.replace(/<[^>]*>/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 40);
484
+ found.set(font.familyName, sample);
485
+ }
486
+ }
487
+ return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([family, sample]) => `${family} ("${sample}")`);
488
+ } finally {
489
+ await session.detach();
490
+ }
491
+ }
492
+
493
+ async function printFigure(
494
+ context: ContextLike,
495
+ origin: string,
496
+ snapshot: FigureSnapshot,
497
+ measure: FigureMeasure,
498
+ withPng: boolean,
499
+ ): Promise<Omit<CapturedFigure, 'liveHtml'>> {
500
+ const page = await context.newPage();
501
+ try {
502
+ await page.goto(`${origin}/${CAPTURE_PATH}`);
503
+ const links = snapshot.links
504
+ .map((href) => `<link rel="stylesheet" href="${href.replace(/"/gu, '&quot;')}">`)
505
+ .join('');
506
+ await page.setContent(
507
+ `<!doctype html><html data-theme="light"><head><meta charset="utf-8">${links}` +
508
+ `<style>${snapshot.styles.replace(/<\/style/giu, '<\\/style')}</style>` +
509
+ `<style>${CAPTURE_STYLE}</style></head><body>` +
510
+ `<main><div class="sl-markdown-content" style="width:${snapshot.width}px">${snapshot.html}</div></main>` +
511
+ `</body></html>`,
512
+ { waitUntil: 'load' },
513
+ );
514
+ await page.evaluate(async () => {
515
+ await document.fonts.ready;
516
+ });
517
+ await page.emulateMedia({ media: 'screen' });
518
+ const box = await sizeLabels(page, { width: snapshot.width, height: snapshot.height }, measure);
519
+ const fonts = await machineFonts(context, page);
520
+ const png = withPng
521
+ ? await page.screenshot({
522
+ type: 'png',
523
+ clip: { x: 0, y: 0, width: box.width, height: box.height },
524
+ animations: 'disabled',
525
+ caret: 'hide',
526
+ })
527
+ : undefined;
528
+ const pdf = await page.pdf({
529
+ width: `${box.width}px`,
530
+ height: `${box.height}px`,
531
+ printBackground: true,
532
+ pageRanges: '1',
533
+ });
534
+ return {
535
+ pdf,
536
+ ...(png ? { png } : {}),
537
+ machineFonts: fonts,
538
+ ...(box.labelPt ? { labelPt: box.labelPt } : {}),
539
+ };
540
+ } finally {
541
+ await page.close();
542
+ }
543
+ }
544
+
545
+ /** The book's measure, in PostScript points: the most a figure is set at. */
546
+ export interface FigureMeasure {
547
+ /** `\linewidth` of the page. */
548
+ readonly width: number;
549
+ /** `0.6\textheight`, the tallest a figure is included. */
550
+ readonly maxHeight: number;
551
+ }
552
+
553
+ /** The measure of explico.cls on letter or A4 paper with one-inch margins. */
554
+ export function figureMeasure(paper: 'letter' | 'a4'): FigureMeasure {
555
+ const inch = 72;
556
+ const millimetre = 72 / 25.4;
557
+ return paper === 'a4'
558
+ ? { width: (210 - 50.8) * millimetre, maxHeight: 0.6 * (297 - 50.8) * millimetre }
559
+ : { width: 6.5 * inch, maxHeight: 0.6 * 9 * inch };
560
+ }
561
+
562
+ /**
563
+ * Chromium stamps a PDF with the moment it prints it (`/CreationDate`,
564
+ * `/ModDate`), and LaTeX copies an included PDF's dates into its own, so
565
+ * no two builds would agree. Each date becomes `epoch`'s, written in the
566
+ * same number of bytes so the cross-reference table stays right.
567
+ */
568
+ export function stampPdfDates(pdf: Uint8Array, epoch: number): Uint8Array {
569
+ const date = new Date(epoch * 1000).toISOString().replace(/\D/gu, '').slice(0, 14);
570
+ const text = Buffer.from(pdf).toString('latin1');
571
+ return Buffer.from(
572
+ text.replace(/(\/(?:CreationDate|ModDate)\s*\(D:)\d{14}/gu, (_match, key: string) => `${key}${date}`),
573
+ 'latin1',
574
+ );
575
+ }
576
+
577
+ export interface CaptureOptions {
578
+ /** The finished build output. */
579
+ readonly outputRoot: string;
580
+ /** The site's base path, `/` or `/course/`. */
581
+ readonly base: string;
582
+ readonly chromium: ChromiumLike;
583
+ /** The book's measure, which sizes figure labels (letter paper's by default). */
584
+ readonly measure?: FigureMeasure;
585
+ /** Seconds since 1970 to date every figure PDF with, so a build is reproducible. */
586
+ readonly epoch?: number;
587
+ /** Also take each figure as PNG, from the same page as its PDF (Markdown). */
588
+ readonly png?: boolean;
589
+ /** Per-figure limit for hydrating and settling. */
590
+ readonly timeoutMs?: number;
591
+ }
592
+
593
+ /** Capture every requested figure, one browser for the whole build. */
594
+ export async function captureFigures(
595
+ requests: readonly CaptureRequest[],
596
+ options: CaptureOptions,
597
+ ): Promise<CaptureResults> {
598
+ const results: CaptureResults = new Map();
599
+ if (requests.length === 0) return results;
600
+ const { origin, server } = await serve(options.outputRoot, options.base);
601
+ const browser = await options.chromium.launch();
602
+ try {
603
+ for (const request of requests) {
604
+ const context = await browser.newContext({
605
+ viewport: { width: 1200, height: 900 },
606
+ // Two device pixels per CSS pixel: the PNG a Markdown export shows.
607
+ deviceScaleFactor: 2,
608
+ colorScheme: 'light',
609
+ reducedMotion: 'reduce',
610
+ locale: 'en-US',
611
+ });
612
+ try {
613
+ const page = await context.newPage();
614
+ const path = `${options.base}${request.route ? `${request.route}/` : ''}`;
615
+ await page.goto(`${origin}${path}`, { waitUntil: 'load' });
616
+ await page.evaluate(() => {
617
+ window.dispatchEvent(
618
+ new CustomEvent('explico:prepare-static-export', { detail: { mode: 'markdown' } }),
619
+ );
620
+ });
621
+ const figures = new Map<string, CapturedFigure>();
622
+ for (const anchor of request.anchors) {
623
+ await settleFigure(page, anchor, options.timeoutMs ?? 30_000);
624
+ const snapshot = await snapshotFigure(page, anchor);
625
+ const printed = await printFigure(
626
+ context,
627
+ origin,
628
+ snapshot,
629
+ options.measure ?? figureMeasure('letter'),
630
+ options.png ?? false,
631
+ );
632
+ const pdf = options.epoch === undefined ? printed.pdf : stampPdfDates(printed.pdf, options.epoch);
633
+ figures.set(anchor, { ...printed, pdf, liveHtml: snapshot.live });
634
+ }
635
+ results.set(request.route, figures);
636
+ } catch (error) {
637
+ throw new Error(
638
+ `Could not capture the figures of ${request.route || 'the landing page'}: ${
639
+ error instanceof Error ? error.message : String(error)
640
+ }`,
641
+ );
642
+ } finally {
643
+ await context.close();
644
+ }
645
+ }
646
+ } finally {
647
+ await browser.close();
648
+ await new Promise<void>((done) => server.close(() => done()));
649
+ }
650
+ return results;
651
+ }