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
@@ -45,19 +45,68 @@ interface DiagramLayout {
45
45
  * would otherwise create a phantom zero-size node).
46
46
  */
47
47
  declare function layoutDiagram(nodes: readonly DiagramNodeInput[], edges: readonly DiagramEdgeInput[], options?: DiagramLayoutOptions): DiagramLayout;
48
- interface BoxSizeEstimate {
48
+ /** A node's text font size, in px. */
49
+ declare const NODE_FONT_PX = 13.6;
50
+ /**
51
+ * A node's formula font size, in px: KaTeX's own `.katex { font-size: 1.21em }`
52
+ * of the node's text. `katex.css` is unlayered, so no rule in the component's
53
+ * `@layer` can shrink it, and at that size KaTeX_Main's x-height matches the
54
+ * label's.
55
+ */
56
+ declare const FORMULA_FONT_PX: number;
57
+ /** What a node holds, measured: its label, and its formula's box in px. */
58
+ interface NodeBoxContent {
59
+ readonly text?: string;
60
+ /** `measureFormula(latex, FORMULA_FONT_PX)`. */
61
+ readonly formula?: {
62
+ readonly width: number;
63
+ readonly height: number;
64
+ readonly depth: number;
65
+ };
66
+ }
67
+ interface NodeBoxOptions {
68
+ /** The author's width: the label wraps inside it and the height follows. */
69
+ readonly width?: number;
70
+ /** The author's height. */
71
+ readonly height?: number;
72
+ /** The widest box a label is wrapped to fit before it takes a second line. */
73
+ readonly maxWidth?: number;
74
+ }
75
+ interface NodeBox {
49
76
  readonly width: number;
50
77
  readonly height: number;
78
+ /** The label as it is wrapped in DejaVu Sans. */
79
+ readonly lines: readonly string[];
51
80
  }
52
81
  /**
53
- * Estimate a diagram node's box from its label text (or raw, pre-resolution
54
- * LaTeX for a formula node). Deliberately generous: it is meant to avoid
55
- * clipping, not to match a real render pixel-for-pixel. Pass an explicit
56
- * `width`/`height` on the node to override for a label this estimate gets
57
- * badly wrong.
82
+ * Size a node's box from its measured content: as wide as its widest line of
83
+ * text or its formula, whichever is wider, plus the inset, and as tall as its
84
+ * lines of text, the gap and its formula's line. A label wider than `maxWidth`
85
+ * wraps; a formula never does, so a wide formula widens the box instead and the
86
+ * label wraps to it. An author's `width` or `height` makes the box larger,
87
+ * never too small for its formula or its lines.
58
88
  */
59
- declare function estimateBoxSize(content: string, kind: 'text' | 'formula', options?: {
60
- readonly maxWidth?: number;
61
- }): BoxSizeEstimate;
89
+ declare function nodeBoxSize(content: NodeBoxContent, options?: NodeBoxOptions): NodeBox;
90
+ /** One node to fit: what it holds, and the author's size if any. */
91
+ interface FitNodeInput {
92
+ readonly id: string;
93
+ readonly content: NodeBoxContent;
94
+ readonly width?: number;
95
+ readonly height?: number;
96
+ }
97
+ interface FitOptions extends DiagramLayoutOptions {
98
+ /** The widest the diagram should lay out (rule V8's column); omit to skip fitting. */
99
+ readonly fitWidth?: number;
100
+ }
101
+ /**
102
+ * Size every node from its content and lay the diagram out. When `fitWidth`
103
+ * is set and the diagram comes out wider, wrap the labels of the nodes without
104
+ * an author's `width` narrower, step by step, until it fits; a diagram that
105
+ * still does not fit keeps its narrowest layout, and the caller warns.
106
+ */
107
+ declare function fitDiagramLayout(nodes: readonly FitNodeInput[], edges: readonly DiagramEdgeInput[], options?: FitOptions): {
108
+ readonly layout: DiagramLayout;
109
+ readonly boxes: ReadonlyMap<string, NodeBox>;
110
+ };
62
111
 
63
- export { type BoxSizeEstimate, type DiagramEdgeInput, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodeInput, type LaidOutEdge, type LaidOutNode, type Point, estimateBoxSize, layoutDiagram };
112
+ export { type DiagramEdgeInput, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodeInput, FORMULA_FONT_PX, type FitNodeInput, type FitOptions, type LaidOutEdge, type LaidOutNode, NODE_FONT_PX, type NodeBox, type NodeBoxContent, type NodeBoxOptions, type Point, fitDiagramLayout, layoutDiagram, nodeBoxSize };
@@ -1,4 +1,5 @@
1
1
  import dagre from "@dagrejs/dagre";
2
+ import { textWidth, wrapText } from "./text-measure.js";
2
3
  function layoutDiagram(nodes, edges, options = {}) {
3
4
  const ids = /* @__PURE__ */ new Set();
4
5
  for (const node of nodes) {
@@ -19,6 +20,11 @@ function layoutDiagram(nodes, edges, options = {}) {
19
20
  if (!ids.has(edge.to)) {
20
21
  throw new Error(`Diagram edge references unknown node "${edge.to}".`);
21
22
  }
23
+ if (edge.from === edge.to) {
24
+ throw new Error(
25
+ `Diagram edge from "${edge.from}" to itself: a diagram draws no loop on one node. Name the repetition in the label ("Repeat until it converges"), or draw the step it returns to as its own node.`
26
+ );
27
+ }
22
28
  }
23
29
  const graph = new dagre.graphlib.Graph();
24
30
  graph.setGraph({
@@ -91,32 +97,87 @@ function layoutDiagram(nodes, edges, options = {}) {
91
97
  height: maxY - minY + padding * 2
92
98
  };
93
99
  }
94
- const DEFAULT_MAX_WIDTH = 220;
100
+ const NODE_FONT_PX = 13.6;
101
+ const FORMULA_FONT_PX = NODE_FONT_PX * 1.21;
102
+ const LINE_PX = 17;
103
+ const CSS_PADDING_X = 8;
104
+ const INSET_X = 16;
105
+ const INSET_Y = 14;
106
+ const GAP_PX = 2.4;
107
+ const FORMULA_ASCENT_PX = 14;
108
+ const FORMULA_DESCENT_PX = 5.5;
95
109
  const MIN_WIDTH = 90;
96
- const PADDING_X = 20;
97
- const PADDING_Y = 16;
98
- const LINE_HEIGHT = 22;
99
- const CHAR_WIDTH = {
100
- text: 7.2,
101
- formula: 11
102
- };
103
- function formulaVisibleLength(latex) {
104
- const collapsed = latex.replace(/\\[a-zA-Z]+/g, "x").replace(/[{}$]/g, "").trim();
105
- return Math.max(1, collapsed.length);
110
+ const DEFAULT_MAX_WIDTH = 220;
111
+ function balancedLines(text, maxWidth) {
112
+ const greedy = wrapText(text, maxWidth, NODE_FONT_PX);
113
+ if (greedy.length <= 1) return greedy;
114
+ let low = 1;
115
+ let high = Math.ceil(maxWidth);
116
+ while (low < high) {
117
+ const middle = Math.floor((low + high) / 2);
118
+ if (wrapText(text, middle, NODE_FONT_PX).length <= greedy.length) high = middle;
119
+ else low = middle + 1;
120
+ }
121
+ return wrapText(text, high, NODE_FONT_PX);
122
+ }
123
+ function nodeBoxSize(content, options = {}) {
124
+ const text = content.text?.trim() ?? "";
125
+ const formula = content.formula;
126
+ const formulaWidth = formula?.width ?? 0;
127
+ let width;
128
+ let lines;
129
+ if (options.width !== void 0) {
130
+ width = Math.max(options.width, Math.ceil(formulaWidth + 2 * CSS_PADDING_X));
131
+ lines = text ? wrapText(text, width - 2 * CSS_PADDING_X, NODE_FONT_PX) : [];
132
+ } else {
133
+ const maxContent = Math.max(
134
+ (options.maxWidth ?? DEFAULT_MAX_WIDTH) - 2 * INSET_X,
135
+ formulaWidth
136
+ );
137
+ lines = text ? balancedLines(text, maxContent) : [];
138
+ const contentWidth = Math.max(
139
+ formulaWidth,
140
+ ...lines.map((line) => textWidth(line, NODE_FONT_PX))
141
+ );
142
+ width = Math.max(MIN_WIDTH, Math.ceil(contentWidth + 2 * INSET_X));
143
+ }
144
+ const formulaHeight = formula ? Math.max(FORMULA_ASCENT_PX, formula.height) + Math.max(FORMULA_DESCENT_PX, formula.depth) : 0;
145
+ const gap = lines.length > 0 && formula ? GAP_PX : 0;
146
+ const contentHeight = Math.ceil(lines.length * LINE_PX + gap + formulaHeight + 2 * INSET_Y);
147
+ const height = Math.max(options.height ?? 0, contentHeight);
148
+ return { width, height, lines };
149
+ }
150
+ const FIT_MAX_WIDTHS = [DEFAULT_MAX_WIDTH, 200, 180, 160, 140, 120];
151
+ function fitDiagramLayout(nodes, edges, options = {}) {
152
+ let result;
153
+ for (const maxWidth of FIT_MAX_WIDTHS) {
154
+ const boxes = new Map(
155
+ nodes.map((node) => [
156
+ node.id,
157
+ nodeBoxSize(node.content, {
158
+ maxWidth,
159
+ ...node.width !== void 0 ? { width: node.width } : {},
160
+ ...node.height !== void 0 ? { height: node.height } : {}
161
+ })
162
+ ])
163
+ );
164
+ const layout = layoutDiagram(
165
+ nodes.map((node) => ({ id: node.id, ...sizeOf(boxes.get(node.id)) })),
166
+ edges,
167
+ options
168
+ );
169
+ result = { layout, boxes };
170
+ if (options.fitWidth === void 0 || layout.width <= options.fitWidth) break;
171
+ }
172
+ return result;
106
173
  }
107
- function estimateBoxSize(content, kind, options = {}) {
108
- const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
109
- const visibleLength = kind === "formula" ? formulaVisibleLength(content) : content.trim().length || 1;
110
- const charWidth = CHAR_WIDTH[kind];
111
- const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
112
- const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
113
- const usableWidth = width - PADDING_X * 2;
114
- const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
115
- const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
116
- const height = lines * LINE_HEIGHT + PADDING_Y * 2;
117
- return { width: Math.round(width), height: Math.round(height) };
174
+ function sizeOf(box) {
175
+ return { width: box.width, height: box.height };
118
176
  }
119
177
  export {
120
- estimateBoxSize,
121
- layoutDiagram
178
+ FORMULA_FONT_PX,
179
+ NODE_FONT_PX,
180
+ fitDiagramLayout,
181
+ layoutDiagram,
182
+ nodeBoxSize
122
183
  };
@@ -2,7 +2,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
2
2
 
3
3
  /** `validateKeyedFloats` for `<Diagram>` — see `keyed-float-validate.ts`. */
4
4
 
5
- type DiagramDiagnosticCode = 'dia-invalid-key' | 'dia-duplicate-key' | 'dia-ref-resolves' | 'dia-key-unused' | 'dia-removed-prop';
5
+ type DiagramDiagnosticCode = 'dia-invalid-key' | 'dia-duplicate-key' | 'dia-ref-resolves' | 'dia-key-unused' | 'dia-removed-prop' | 'dia-unread-prop';
6
6
  interface DiagramDiagnostic {
7
7
  readonly code: DiagramDiagnosticCode;
8
8
  readonly severity: 'error' | 'warning' | 'info';
@@ -25,7 +25,7 @@ interface EquationRefRecord {
25
25
  /** Target lesson slug for a cross-page reference; `undefined` = same page. */
26
26
  readonly targetSlug?: string;
27
27
  }
28
- type EquationDiagnosticCode = 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused';
28
+ type EquationDiagnosticCode = 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused' | 'eq-label-repeated' | 'eq-latex-ref';
29
29
  interface EquationDiagnostic {
30
30
  readonly code: EquationDiagnosticCode;
31
31
  readonly severity: 'error' | 'warning' | 'info';
@@ -1,4 +1,5 @@
1
- import { parseEquationRef, scanEquationLabels } from "./equations.mjs";
1
+ import { parseEquationRef, repeatedEquationLabels, scanEquationLabels } from "./equations.mjs";
2
+ import { proseOnly } from "./gate-prose-notation.js";
2
3
  const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
3
4
  function stripCode(text) {
4
5
  return text.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
@@ -16,6 +17,7 @@ function bodyEquationRefs(lesson) {
16
17
  }
17
18
  return refs;
18
19
  }
20
+ const LATEX_REFERENCE = /\\(eqref|ref|autoref|cref|Cref)\s*\{([^{}\n]*)\}/g;
19
21
  function validateEquations(lessons) {
20
22
  const labels = [];
21
23
  const diagnostics = [];
@@ -50,6 +52,29 @@ function validateEquations(lessons) {
50
52
  });
51
53
  }
52
54
  numbersBySlug[lesson.slug] = table;
55
+ for (const match of proseOnly(lesson.body).matchAll(LATEX_REFERENCE)) {
56
+ const [, command, target = ""] = match;
57
+ const [prefix, key] = target.includes(":") ? target.split(":", 2) : ["eq", target];
58
+ diagnostics.push({
59
+ code: "eq-latex-ref",
60
+ severity: "error",
61
+ message: `${lesson.lessonId} writes \\${command}{${target}}, LaTeX's reference: MDX reads its {\u2026} as JavaScript, and the page fails to build. Write [[${prefix}-${key}]].`,
62
+ file: lesson.file,
63
+ lessonId: lesson.lessonId,
64
+ key: key ?? target
65
+ });
66
+ }
67
+ for (const keys of repeatedEquationLabels(lesson.body)) {
68
+ const [first, ...rest] = keys;
69
+ diagnostics.push({
70
+ code: "eq-label-repeated",
71
+ severity: "error",
72
+ message: `${lesson.lessonId} puts ${keys.map((key) => `\\label{eq:${key}}`).join(" and ")} in one display block, which is one numbered equation: eq:${first} names it, and ${rest.map((key) => `[[eq-${key}]]`).join(", ")} would point at nothing. Split the block into one $$\u2026$$ block per equation you refer to.`,
73
+ file: lesson.file,
74
+ lessonId: lesson.lessonId,
75
+ key: rest[0]
76
+ });
77
+ }
53
78
  }
54
79
  const referenced = /* @__PURE__ */ new Set();
55
80
  for (const lesson of lessons) {
@@ -74,6 +74,27 @@ export function stripFencedCode(markdown: any): string;
74
74
  * a transparent body do not change the surrounding page's section counter.
75
75
  */
76
76
  export function stripJsxComponents(markdown: any, keep: any, transparent?: any[]): string;
77
+ /**
78
+ * Blank the body of every `:::name … :::` container directive — a Starlight
79
+ * aside (`:::note`, `:::tip`, …) — keeping newlines, the text-scan twin of a
80
+ * tree walk that never enters a `containerDirective`. An aside is a callout,
81
+ * like `<Aside>`: what it holds is illustrative and numbers nothing. A fence
82
+ * of more colons closes only on as many; one left open runs to the end.
83
+ */
84
+ export function stripContainerDirectives(markdown: any): string;
85
+ /**
86
+ * Every `$$` display block in a Markdown text the way remark-math reads one:
87
+ * an opening fence of two or more `$` that starts a line — inside a list item
88
+ * or a blockquote too — with nothing but a `$`-free meta after it, up to a
89
+ * closing fence of at least as many `$` on a line of its own (or the end of
90
+ * the text). `$$x$$` inside a paragraph, or on one line, is inline math and
91
+ * is not a display block. Each block is `{ index, body }`: the offset of its
92
+ * opening line and the text between the fences.
93
+ */
94
+ export function displayMathBlocks(markdown: any): {
95
+ index: number;
96
+ body: string;
97
+ }[];
77
98
  /**
78
99
  * Every display equation in a Markdown body, keyed or not, in appearance order
79
100
  * with its assigned number and its `\caption{}` short caption (`short`, or
@@ -94,6 +115,12 @@ export function scanDisplayEquations(markdown: any, numbering: any): {
94
115
  section: number;
95
116
  indexInSection: number;
96
117
  }[];
118
+ /**
119
+ * The keys of each display block that carries more than one `\label{eq:…}`,
120
+ * in order. A block is one numbered equation: its first label names it, and
121
+ * a later one would name nothing.
122
+ */
123
+ export function repeatedEquationLabels(markdown: any): string[][];
97
124
  /** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
98
125
  export function scanEquationLabels(markdown: any, numbering: any): {
99
126
  key: string | null | undefined;
@@ -204,6 +204,87 @@ export function stripJsxComponents(markdown, keep, transparent = []) {
204
204
  */
205
205
  export const TRANSPARENT_COMPONENTS = Object.freeze(['Memo', 'ShortNote', 'Fold']);
206
206
 
207
+ /**
208
+ * Blank the body of every `:::name … :::` container directive — a Starlight
209
+ * aside (`:::note`, `:::tip`, …) — keeping newlines, the text-scan twin of a
210
+ * tree walk that never enters a `containerDirective`. An aside is a callout,
211
+ * like `<Aside>`: what it holds is illustrative and numbers nothing. A fence
212
+ * of more colons closes only on as many; one left open runs to the end.
213
+ */
214
+ export function stripContainerDirectives(markdown) {
215
+ const lines = String(markdown).split('\n');
216
+ let fence = 0;
217
+ for (let index = 0; index < lines.length; index += 1) {
218
+ const line = lines[index] ?? '';
219
+ if (fence === 0) {
220
+ const open = /^[ \t]{0,3}(:{3,})[A-Za-z]/.exec(line);
221
+ if (!open) continue;
222
+ fence = open[1].length;
223
+ lines[index] = line.replace(/[^\n]/g, ' ');
224
+ continue;
225
+ }
226
+ const close = /^[ \t]{0,3}(:{3,})[ \t]*$/.exec(line);
227
+ if (close && close[1].length >= fence) fence = 0;
228
+ lines[index] = line.replace(/[^\n]/g, ' ');
229
+ }
230
+ return lines.join('\n');
231
+ }
232
+
233
+ /** A line with its blockquote markers, list marker and indentation removed. */
234
+ function containerContent(line) {
235
+ let rest = String(line);
236
+ for (;;) {
237
+ const next = rest
238
+ .replace(/^[ \t]*>[ \t]?/, '')
239
+ .replace(/^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+/, '');
240
+ if (next === rest) return rest.replace(/^[ \t]+/, '');
241
+ rest = next;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Every `$$` display block in a Markdown text the way remark-math reads one:
247
+ * an opening fence of two or more `$` that starts a line — inside a list item
248
+ * or a blockquote too — with nothing but a `$`-free meta after it, up to a
249
+ * closing fence of at least as many `$` on a line of its own (or the end of
250
+ * the text). `$$x$$` inside a paragraph, or on one line, is inline math and
251
+ * is not a display block. Each block is `{ index, body }`: the offset of its
252
+ * opening line and the text between the fences.
253
+ */
254
+ export function displayMathBlocks(markdown) {
255
+ const text = String(markdown);
256
+ const lines = text.split('\n');
257
+ const starts = [];
258
+ let offset = 0;
259
+ for (const line of lines) {
260
+ starts.push(offset);
261
+ offset += line.length + 1;
262
+ }
263
+ const blocks = [];
264
+ for (let index = 0; index < lines.length; index += 1) {
265
+ const open = /^(\${2,})[ \t]*[^$]*$/.exec(containerContent(lines[index]));
266
+ if (!open) continue;
267
+ const size = open[1].length;
268
+ let end = lines.length;
269
+ for (let next = index + 1; next < lines.length; next += 1) {
270
+ const close = /^(\${2,})[ \t]*$/.exec(containerContent(lines[next]));
271
+ if (close && close[1].length >= size) {
272
+ end = next;
273
+ break;
274
+ }
275
+ }
276
+ blocks.push({
277
+ index: starts[index],
278
+ body: lines
279
+ .slice(index + 1, end)
280
+ .map((line) => containerContent(line))
281
+ .join('\n'),
282
+ });
283
+ index = end;
284
+ }
285
+ return blocks;
286
+ }
287
+
207
288
  /**
208
289
  * Every display equation in a Markdown body, keyed or not, in appearance order
209
290
  * with its assigned number and its `\caption{}` short caption (`short`, or
@@ -219,25 +300,20 @@ export const TRANSPARENT_COMPONENTS = Object.freeze(['Memo', 'ShortNote', 'Fold'
219
300
  */
220
301
  export function scanDisplayEquations(markdown, numbering) {
221
302
  const { prefix, offset } = resolveNumbering(numbering);
222
- const text = stripJsxComponents(
223
- stripFencedCode(markdown),
224
- undefined,
225
- TRANSPARENT_COMPONENTS,
226
- );
303
+ const text = equationText(markdown);
227
304
  const tokens = [];
228
305
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
229
- const display = /\$\$([\s\S]+?)\$\$/g;
230
306
  for (const match of text.matchAll(heading)) {
231
307
  tokens.push({ kind: 'section', index: match.index });
232
308
  }
233
- for (const match of text.matchAll(display)) {
309
+ for (const block of displayMathBlocks(text)) {
234
310
  EQ_LABEL_PATTERN.lastIndex = 0;
235
- const label = EQ_LABEL_PATTERN.exec(match[1]);
311
+ const label = EQ_LABEL_PATTERN.exec(block.body);
236
312
  tokens.push({
237
313
  kind: 'equation',
238
- index: match.index,
314
+ index: block.index,
239
315
  key: label ? label[1] : null,
240
- short: equationCaption(match[1]),
316
+ short: equationCaption(block.body),
241
317
  });
242
318
  }
243
319
  tokens.sort((a, b) => a.index - b.index);
@@ -271,6 +347,31 @@ export function scanDisplayEquations(markdown, numbering) {
271
347
  return labels;
272
348
  }
273
349
 
350
+ /**
351
+ * A lesson body as the equation scans read it: no fenced code, no component
352
+ * but the transparent ones, no `:::` directive body.
353
+ */
354
+ function equationText(markdown) {
355
+ return stripContainerDirectives(
356
+ stripJsxComponents(stripFencedCode(markdown), undefined, TRANSPARENT_COMPONENTS),
357
+ );
358
+ }
359
+
360
+ /**
361
+ * The keys of each display block that carries more than one `\label{eq:…}`,
362
+ * in order. A block is one numbered equation: its first label names it, and
363
+ * a later one would name nothing.
364
+ */
365
+ export function repeatedEquationLabels(markdown) {
366
+ return displayMathBlocks(equationText(markdown))
367
+ // A copy: `matchAll` starts at the shared pattern's `lastIndex`, which
368
+ // `scanDisplayEquations`' `exec` leaves past the label it found.
369
+ .map((block) =>
370
+ [...block.body.matchAll(new RegExp(EQ_LABEL_PATTERN.source, 'g'))].map((match) => match[1]),
371
+ )
372
+ .filter((keys) => keys.length > 1);
373
+ }
374
+
274
375
  /** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
275
376
  export function scanEquationLabels(markdown, numbering) {
276
377
  return scanDisplayEquations(markdown, numbering).filter(
@@ -301,13 +402,16 @@ export function collectDisplayEquations(tree, numbering) {
301
402
  let indexInSection = 0;
302
403
  let indexInLesson = 0;
303
404
 
304
- // Top-level blocks plus the bodies of the `TRANSPARENT_COMPONENTS`: other
305
- // MDX components (a `<CompactExample>` worked example, an `<Aside>`) remain
306
- // illustrative and outside the lesson's official equation sequence. The
307
- // transparent ones occupy an exact point in the reading order, even while
308
- // visually collapsed.
405
+ // The lesson's flow — top-level blocks, list items, blockquotes — plus the
406
+ // bodies of the `TRANSPARENT_COMPONENTS`: other MDX components (a
407
+ // `<CompactExample>` worked example, an `<Aside>`) and `:::note`-style
408
+ // asides remain illustrative and outside the lesson's official equation
409
+ // sequence. The transparent ones occupy an exact point in the reading
410
+ // order, even while visually collapsed. `scanDisplayEquations` reads the
411
+ // same set from the text.
309
412
  const collectFromDetail = (node) => {
310
413
  if (!node || typeof node !== 'object') return;
414
+ if (node.type === 'containerDirective') return;
311
415
  if (node.type === 'math') {
312
416
  indexInSection += 1;
313
417
  indexInLesson += 1;
@@ -347,13 +451,7 @@ export function collectDisplayEquations(tree, numbering) {
347
451
  indexInSection = 0;
348
452
  continue;
349
453
  }
350
- if (node.type === 'math') collectFromDetail(node);
351
- else if (
352
- node.type === 'mdxJsxFlowElement' &&
353
- TRANSPARENT_COMPONENTS.includes(node.name)
354
- ) {
355
- collectFromDetail(node);
356
- }
454
+ collectFromDetail(node);
357
455
  }
358
456
  return equations;
359
457
  }
@@ -12,7 +12,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
12
12
  * no default title to fall back on.
13
13
  */
14
14
 
15
- type ExampleDiagnosticCode = 'ex-invalid-key' | 'ex-duplicate-key' | 'ex-ref-resolves' | 'ex-key-unused' | 'ex-removed-prop' | 'ex-missing-prop';
15
+ type ExampleDiagnosticCode = 'ex-invalid-key' | 'ex-duplicate-key' | 'ex-ref-resolves' | 'ex-key-unused' | 'ex-removed-prop' | 'ex-unread-prop' | 'ex-missing-prop';
16
16
  interface ExampleDiagnostic {
17
17
  readonly code: ExampleDiagnosticCode;
18
18
  readonly severity: 'error' | 'warning' | 'info';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Rule V7 — a figure's picture fits on one screen.
3
+ *
4
+ * A picture's height is only known once the browser has laid the page out and
5
+ * hydrated its islands, so this check runs in a course's Playwright suite, not
6
+ * in the build: open each lesson that has a figure, call
7
+ * {@link checkFigureFit}, and expect no findings. The pure half,
8
+ * {@link figureFitFindings}, turns measured heights into findings and is what
9
+ * the unit tests pin.
10
+ */
11
+ /** The share of the screen height a picture may take unless the course sets `rules.V7.maxPercent`. */
12
+ declare const FIGURE_FIT_MAX_PERCENT = 80;
13
+ /** One `<Figure>` as measured on a rendered page. */
14
+ interface FigureMeasurement {
15
+ /** The figure's anchor, `fig-<key>`. */
16
+ readonly anchor: string;
17
+ /** Its number, `2.1.3`. */
18
+ readonly number: string;
19
+ /** Height of the picture — the figure's default slot — in CSS pixels. */
20
+ readonly height: number;
21
+ }
22
+ interface FigureFitFinding {
23
+ readonly code: 'figure-too-tall';
24
+ readonly severity: 'warning';
25
+ readonly rule: 'V7';
26
+ readonly anchor: string;
27
+ readonly number: string;
28
+ readonly height: number;
29
+ /** The tallest picture the budget allows at this screen height, in CSS pixels. */
30
+ readonly limit: number;
31
+ readonly message: string;
32
+ }
33
+ /**
34
+ * The pictures taller than `maxPercent` of a `screenHeight`-pixel screen.
35
+ * A picture of height zero is skipped: nothing was drawn to judge.
36
+ */
37
+ declare function figureFitFindings(measured: readonly FigureMeasurement[], screenHeight: number, maxPercent?: number): FigureFitFinding[];
38
+ /** The part of a Playwright `Page` the check uses. */
39
+ interface FigureFitPage {
40
+ evaluate<R>(pageFunction: () => R | Promise<R>): Promise<R>;
41
+ viewportSize(): {
42
+ readonly width: number;
43
+ readonly height: number;
44
+ } | null;
45
+ }
46
+ /**
47
+ * Measure every `<Figure>` on the open page and report the pictures that break
48
+ * rule V7 at the page's current viewport. A figure inside a closed `<Fold>` or
49
+ * `<details>` is opened first, since a reader can open it; each figure is
50
+ * scrolled into view and waits for its islands to hydrate and its height to
51
+ * settle before it is measured.
52
+ */
53
+ declare function checkFigureFit(page: FigureFitPage, options?: {
54
+ readonly maxPercent?: number;
55
+ /** Figures exempt from the budget, by anchor (`fig-<key>`), each with the reason it may be taller. */
56
+ readonly allow?: Readonly<Record<string, string>>;
57
+ }): Promise<FigureFitFinding[]>;
58
+ /**
59
+ * The lesson paths whose manifest entry has at least one figure — the pages a
60
+ * course's V7 test opens. Pass the manifest the build wrote
61
+ * (`build/manifest.json`).
62
+ */
63
+ declare function lessonsWithFigures(manifest: {
64
+ readonly lessons: readonly {
65
+ readonly slug: string;
66
+ readonly figures: readonly unknown[];
67
+ }[];
68
+ }): string[];
69
+
70
+ export { FIGURE_FIT_MAX_PERCENT, type FigureFitFinding, type FigureFitPage, type FigureMeasurement, checkFigureFit, figureFitFindings, lessonsWithFigures };
@@ -0,0 +1,60 @@
1
+ const FIGURE_FIT_MAX_PERCENT = 80;
2
+ function figureFitFindings(measured, screenHeight, maxPercent = FIGURE_FIT_MAX_PERCENT) {
3
+ const limit = Math.floor(screenHeight * maxPercent / 100);
4
+ return measured.filter((figure) => figure.height > limit).map((figure) => ({
5
+ code: "figure-too-tall",
6
+ severity: "warning",
7
+ rule: "V7",
8
+ anchor: figure.anchor,
9
+ number: figure.number,
10
+ height: figure.height,
11
+ limit,
12
+ message: `Figure ${figure.number} (#${figure.anchor}): its picture is ${figure.height}px tall, ${Math.round(figure.height / screenHeight * 100)}% of a ${screenHeight}px screen; rule V7 allows ${maxPercent}% (${limit}px). Give a square or tall plot a fixed width instead of the full column, or split stacked panels.`
13
+ }));
14
+ }
15
+ async function checkFigureFit(page, options = {}) {
16
+ const viewport = page.viewportSize();
17
+ if (!viewport) throw new Error("checkFigureFit needs a page with a fixed viewport size.");
18
+ const measured = await page.evaluate(measureFigures);
19
+ const allow = options.allow ?? {};
20
+ for (const [anchor, reason] of Object.entries(allow)) {
21
+ if (!reason.trim()) throw new Error(`checkFigureFit: the exemption for #${anchor} needs a reason.`);
22
+ }
23
+ return figureFitFindings(
24
+ measured.filter((figure) => !(figure.anchor in allow)),
25
+ viewport.height,
26
+ options.maxPercent
27
+ );
28
+ }
29
+ function lessonsWithFigures(manifest) {
30
+ return manifest.lessons.filter((lesson) => lesson.figures.length > 0).map((lesson) => `/${lesson.slug}/`);
31
+ }
32
+ async function measureFigures() {
33
+ const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
34
+ const out = [];
35
+ for (const figure of document.querySelectorAll("figure.keyed-figure")) {
36
+ for (let fold = figure.parentElement?.closest("details"); fold; fold = fold.parentElement?.closest("details")) {
37
+ fold.open = true;
38
+ }
39
+ const body = figure.querySelector(":scope > .keyed-figure__body");
40
+ if (!body) continue;
41
+ figure.scrollIntoView({ block: "start" });
42
+ const deadline = performance.now() + 5e3;
43
+ while (body.querySelector("astro-island[ssr]") && performance.now() < deadline) await nextFrame();
44
+ let height = -1;
45
+ for (let still = 0, frames = 0; still < 5 && frames < 120; frames++) {
46
+ await nextFrame();
47
+ const now = body.getBoundingClientRect().height;
48
+ still = Math.abs(now - height) < 0.5 ? still + 1 : 0;
49
+ height = now;
50
+ }
51
+ out.push({ anchor: figure.id, number: figure.dataset.figNumber ?? "", height: Math.round(height) });
52
+ }
53
+ return out;
54
+ }
55
+ export {
56
+ FIGURE_FIT_MAX_PERCENT,
57
+ checkFigureFit,
58
+ figureFitFindings,
59
+ lessonsWithFigures
60
+ };