explico 0.12.0 → 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.
- package/CHANGELOG.md +1122 -1
- package/README.md +91 -13
- package/dist/compiler/astro.d.ts +4 -3
- package/dist/compiler/astro.js +45 -15
- package/dist/compiler/cli.d.ts +26 -12
- package/dist/compiler/cli.js +96 -25
- package/dist/compiler/collections.d.ts +9 -1
- package/dist/compiler/collections.js +55 -16
- package/dist/compiler/content-schema.d.ts +31 -0
- package/dist/compiler/content-schema.js +82 -0
- package/dist/compiler/content-schemas.d.ts +422 -0
- package/dist/compiler/content-schemas.js +229 -0
- package/dist/compiler/course-config.d.ts +1 -1
- package/dist/compiler/course-config.js +30 -2
- package/dist/compiler/exports/combined-html.d.ts +97 -0
- package/dist/compiler/exports/combined-html.js +492 -0
- package/dist/compiler/exports/download-index.d.ts +9 -0
- package/dist/compiler/exports/download-index.js +33 -0
- package/dist/compiler/exports/export-chapters.d.ts +98 -0
- package/dist/compiler/exports/export-chapters.js +164 -0
- package/dist/compiler/exports/export-pages.d.ts +48 -0
- package/dist/compiler/exports/export-pages.js +22 -0
- package/dist/compiler/exports/html-tree.d.ts +22 -0
- package/dist/compiler/exports/html-tree.js +47 -0
- package/dist/compiler/exports/latex/capture.d.ts +106 -0
- package/dist/compiler/exports/latex/capture.js +435 -0
- package/dist/compiler/exports/latex/compile.d.ts +73 -0
- package/dist/compiler/exports/latex/compile.js +115 -0
- package/dist/compiler/exports/latex/document.d.ts +102 -0
- package/dist/compiler/exports/latex/document.js +349 -0
- package/dist/compiler/exports/latex/explico.cls +415 -0
- package/dist/compiler/exports/latex/numbering.d.ts +18 -0
- package/dist/compiler/exports/latex/numbering.js +57 -0
- package/dist/compiler/exports/latex/serialize.d.ts +112 -0
- package/dist/compiler/exports/latex/serialize.js +606 -0
- package/dist/compiler/exports/latex-export.d.ts +44 -0
- package/dist/compiler/exports/latex-export.js +169 -0
- package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
- package/dist/compiler/exports/markdown/serialize.js +527 -0
- package/dist/compiler/exports/markdown-export.d.ts +39 -0
- package/dist/compiler/exports/markdown-export.js +240 -0
- package/dist/compiler/exports/standalone-html.d.ts +14 -5
- package/dist/compiler/exports/standalone-html.js +23 -51
- package/dist/compiler/exports/static-tree.d.ts +253 -0
- package/dist/compiler/exports/static-tree.js +625 -0
- package/dist/compiler/exports/zip.d.ts +16 -0
- package/dist/compiler/exports/zip.js +72 -0
- package/dist/compiler/lesson-derivation.d.ts +8 -2
- package/dist/compiler/lesson-derivation.js +6 -7
- package/dist/compiler/manifest.d.ts +87 -6
- package/dist/compiler/manifest.js +174 -31
- package/dist/compiler/render-diagnostics.d.ts +65 -0
- package/dist/compiler/render-diagnostics.js +56 -0
- package/dist/compiler/resolution-report.d.ts +2 -1
- package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
- package/dist/curriculum/validation.d.ts +9 -3
- package/dist/curriculum/validation.js +35 -17
- package/dist/reference/base-abbreviations.d.mts +2 -2
- package/dist/reference/base-abbreviations.mjs +2 -2
- package/dist/reference/base-operators.d.mts +2 -2
- package/dist/reference/base-operators.mjs +2 -2
- package/dist/reference/base-roman-labels.d.mts +4 -3
- package/dist/reference/base-roman-labels.mjs +4 -3
- package/dist/reference/citation-format.d.mts +35 -0
- package/dist/reference/citation-format.mjs +74 -0
- package/dist/reference/consistency.d.ts +5 -9
- package/dist/reference/consistency.js +34 -38
- package/dist/reference/curriculum-alignment.js +3 -24
- package/dist/reference/derived-entries.d.ts +45 -0
- package/dist/reference/derived-entries.js +50 -0
- package/dist/reference/derived-notation.d.mts +125 -0
- package/dist/reference/derived-notation.mjs +121 -0
- package/dist/reference/diagram-fit.d.ts +45 -0
- package/dist/reference/diagram-fit.js +54 -0
- package/dist/reference/diagram-layout.d.ts +59 -10
- package/dist/reference/diagram-layout.js +85 -24
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +1 -1
- package/dist/reference/equations-validate.js +26 -1
- package/dist/reference/equations.d.mts +27 -0
- package/dist/reference/equations.mjs +120 -22
- package/dist/reference/examples-validate.d.ts +1 -1
- package/dist/reference/figure-fit.d.ts +70 -0
- package/dist/reference/figure-fit.js +60 -0
- package/dist/reference/figure-pictures.d.ts +27 -0
- package/dist/reference/figure-pictures.js +49 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.d.ts +1 -1
- package/dist/reference/gate-abbreviations.js +1 -1
- package/dist/reference/gate-course-rules.d.ts +4 -3
- package/dist/reference/gate-course-rules.js +9 -4
- package/dist/reference/gate-heading-length.d.ts +2 -0
- package/dist/reference/gate-heading-length.js +7 -5
- package/dist/reference/gate-heading-structure.js +26 -0
- package/dist/reference/gate-indices.d.ts +35 -0
- package/dist/reference/gate-indices.js +299 -0
- package/dist/reference/gate-math.d.ts +32 -2
- package/dist/reference/gate-math.js +68 -12
- package/dist/reference/gate-mdx-expression.d.ts +17 -0
- package/dist/reference/gate-mdx-expression.js +29 -0
- package/dist/reference/gate-part-pages.d.ts +1 -1
- package/dist/reference/gate-prop-text.d.ts +28 -0
- package/dist/reference/gate-prop-text.js +106 -0
- package/dist/reference/gate-prose-notation.d.ts +3 -1
- package/dist/reference/gate-prose-notation.js +4 -2
- package/dist/reference/gate-retired-keys.d.ts +35 -0
- package/dist/reference/gate-retired-keys.js +58 -0
- package/dist/reference/gloss.d.ts +2 -0
- package/dist/reference/katex-measure.d.ts +17 -0
- package/dist/reference/katex-measure.js +223 -0
- package/dist/reference/keyed-float-validate.js +12 -0
- package/dist/reference/keyed-jsx-floats.d.mts +2 -2
- package/dist/reference/keyed-jsx-floats.mjs +11 -14
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-scope.js +13 -2
- package/dist/reference/math-glyphs.d.mts +94 -2
- package/dist/reference/math-glyphs.mjs +851 -46
- package/dist/reference/mixed-content.d.ts +8 -3
- package/dist/reference/mixed-content.js +4 -2
- package/dist/reference/page-notation.d.ts +34 -4
- package/dist/reference/page-notation.js +54 -2
- package/dist/reference/references.d.ts +11 -1
- package/dist/reference/references.js +6 -0
- package/dist/reference/registry.js +104 -10
- package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
- package/dist/reference/rehype-heading-anchors.mjs +11 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +84 -20
- package/dist/reference/render-lab-math.d.ts +2 -0
- package/dist/reference/render-lab-math.js +17 -5
- package/dist/reference/sections-validate.d.ts +28 -3
- package/dist/reference/sections-validate.js +31 -4
- package/dist/reference/sections.d.mts +24 -6
- package/dist/reference/sections.mjs +92 -17
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/tables-validate.js +24 -1
- package/dist/reference/text-measure.d.ts +10 -0
- package/dist/reference/text-measure.js +41 -0
- package/dist/reference/text-widths.d.ts +10 -0
- package/dist/reference/text-widths.js +1238 -0
- package/dist/reference/types.d.ts +64 -8
- package/dist/rules/catalog.js +191 -30
- package/dist/rules/in-effect.d.ts +1 -1
- package/dist/rules/rules.schema.json +79 -24
- package/dist/rules/settings.d.ts +1 -1
- package/dist/rules/settings.js +7 -0
- package/dist/session/PreferenceStore.d.ts +18 -1
- package/dist/session/PreferenceStore.js +29 -1
- package/docs/authoring/README.md +32 -21
- package/docs/authoring/agents.md +38 -7
- package/docs/authoring/components.md +111 -6
- package/docs/authoring/migrating.md +663 -0
- package/docs/authoring/notation.md +281 -29
- package/docs/authoring/retired.md +1 -0
- package/docs/authoring/structure-rules.md +10 -41
- package/docs/authoring/structure.md +53 -2
- package/docs/authoring/visualizations.md +144 -18
- package/package.json +12 -7
- package/src/compiler/astro.ts +57 -17
- package/src/compiler/cli.ts +143 -35
- package/src/compiler/collections.ts +92 -19
- package/src/compiler/content-schema.ts +108 -0
- package/src/compiler/content-schemas.ts +391 -0
- package/src/compiler/course-config.ts +86 -15
- package/src/compiler/exports/combined-html.ts +669 -0
- package/src/compiler/exports/download-index.ts +36 -0
- package/src/compiler/exports/export-chapters.ts +233 -0
- package/src/compiler/exports/export-pages.ts +44 -0
- package/src/compiler/exports/html-tree.ts +62 -0
- package/src/compiler/exports/latex/capture.ts +651 -0
- package/src/compiler/exports/latex/compile.ts +168 -0
- package/src/compiler/exports/latex/document.ts +479 -0
- package/src/compiler/exports/latex/explico.cls +415 -0
- package/src/compiler/exports/latex/numbering.ts +74 -0
- package/src/compiler/exports/latex/serialize.ts +787 -0
- package/src/compiler/exports/latex/texlive-packages.txt +76 -0
- package/src/compiler/exports/latex-export.ts +255 -0
- package/src/compiler/exports/markdown/serialize.ts +632 -0
- package/src/compiler/exports/markdown-export.ts +329 -0
- package/src/compiler/exports/standalone-html.ts +39 -70
- package/src/compiler/exports/static-tree.ts +944 -0
- package/src/compiler/exports/zip.ts +88 -0
- package/src/compiler/lesson-derivation.ts +13 -8
- package/src/compiler/manifest.ts +299 -28
- package/src/compiler/render-diagnostics.ts +111 -0
- package/src/components/HoverPanel.astro +204 -6
- package/src/components/PartOverview.astro +12 -9
- package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
- package/src/components/assessments/AssessmentRunner.tsx +7 -1
- package/src/components/assessments/AssessmentSet.astro +14 -1
- package/src/components/block-heading.ts +38 -0
- package/src/components/card-arrow.ts +18 -4
- package/src/components/card-carry.ts +406 -0
- package/src/components/card-drag.ts +1 -30
- package/src/components/card-fetch.ts +87 -0
- package/src/components/card-manager.ts +1222 -38
- package/src/components/card-memory.ts +5 -1
- package/src/components/card-placement.ts +211 -13
- package/src/components/card-side.ts +281 -31
- package/src/components/citation/CitationLayer.astro +45 -40
- package/src/components/diagram/Diagram.astro +123 -44
- package/src/components/figure/Figure.astro +11 -0
- package/src/components/figure/FloatCaption.astro +4 -17
- package/src/components/float-toolbar.ts +274 -0
- package/src/components/memo/Memo.astro +21 -10
- package/src/components/memo/ShortNote.astro +4 -0
- package/src/components/notation/NotationGlossary.astro +22 -17
- package/src/components/notation/NotationLayer.astro +300 -79
- package/src/components/notation/NotationMath.astro +8 -1
- package/src/components/notation/render-notation-math.ts +51 -5
- package/src/components/readable-text.ts +25 -0
- package/src/components/reference/FloatRefLayer.astro +162 -89
- package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
- package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
- package/src/components/starlight/LayoutPageSidebar.astro +17 -3
- package/src/components/starlight/LayoutSocialIcons.astro +79 -29
- package/src/components/starlight/LessonExportActions.astro +384 -310
- package/src/components/starlight/LessonFooter.astro +47 -9
- package/src/components/starlight/PrintController.astro +28 -0
- package/src/components/starlight/TextSizeControl.astro +137 -0
- package/src/components/starlight/print-controller.ts +380 -0
- package/src/components/starlight/section-summary.ts +13 -5
- package/src/components/table/Table.astro +13 -21
- package/src/components/table/table-truncation.ts +15 -0
- package/src/content-config.ts +20 -312
- package/src/curriculum/validation.ts +72 -24
- package/src/reference/base-abbreviations.mjs +2 -2
- package/src/reference/base-operators.mjs +2 -2
- package/src/reference/base-roman-labels.mjs +4 -3
- package/src/reference/citation-format.mjs +74 -0
- package/src/reference/consistency.ts +47 -61
- package/src/reference/curriculum-alignment.ts +4 -41
- package/src/reference/derived-entries.ts +68 -0
- package/src/reference/derived-notation.mjs +121 -0
- package/src/reference/diagram-fit.ts +104 -0
- package/src/reference/diagram-layout.ts +182 -51
- package/src/reference/diagrams-validate.ts +2 -1
- package/src/reference/equations-validate.ts +41 -2
- package/src/reference/equations.mjs +120 -22
- package/src/reference/examples-validate.ts +1 -0
- package/src/reference/figure-fit.ts +139 -0
- package/src/reference/figure-pictures.ts +96 -0
- package/src/reference/figures-validate.ts +2 -1
- package/src/reference/gate-abbreviations.ts +1 -1
- package/src/reference/gate-course-rules.ts +22 -7
- package/src/reference/gate-heading-length.ts +25 -7
- package/src/reference/gate-heading-structure.ts +40 -1
- package/src/reference/gate-indices.ts +470 -0
- package/src/reference/gate-math.ts +102 -13
- package/src/reference/gate-mdx-expression.ts +45 -0
- package/src/reference/gate-prop-text.ts +145 -0
- package/src/reference/gate-prose-notation.ts +19 -5
- package/src/reference/gate-retired-keys.ts +101 -0
- package/src/reference/gloss.ts +2 -0
- package/src/reference/katex-measure.ts +384 -0
- package/src/reference/keyed-float-validate.ts +22 -0
- package/src/reference/keyed-jsx-floats.mjs +11 -14
- package/src/reference/lab-math-scope.ts +15 -1
- package/src/reference/math-glyphs.mjs +851 -46
- package/src/reference/mixed-content.ts +11 -2
- package/src/reference/page-notation.ts +100 -3
- package/src/reference/references.ts +14 -0
- package/src/reference/registry.ts +154 -8
- package/src/reference/rehype-fail-katex-errors.mjs +19 -0
- package/src/reference/rehype-heading-anchors.mjs +11 -1
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +84 -20
- package/src/reference/render-lab-math.ts +29 -5
- package/src/reference/sections-validate.ts +67 -3
- package/src/reference/sections.mjs +92 -17
- package/src/reference/tables-validate.ts +40 -2
- package/src/reference/text-measure.ts +63 -0
- package/src/reference/text-widths.ts +133 -0
- package/src/reference/types.ts +75 -8
- package/src/rules/catalog.ts +205 -29
- package/src/rules/settings.ts +10 -0
- package/src/session/PreferenceStore.ts +53 -0
- package/src/styles/global.css +291 -75
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { captionTitle } from '../../../components/float-name';
|
|
2
|
+
import type { ContentSegment } from '../../../reference/mixed-content';
|
|
3
|
+
import type {
|
|
4
|
+
Block,
|
|
5
|
+
CourseTarget,
|
|
6
|
+
DiagramGeometry,
|
|
7
|
+
FigurePicture,
|
|
8
|
+
Inline,
|
|
9
|
+
NotationEntry,
|
|
10
|
+
StaticPage,
|
|
11
|
+
TableCell,
|
|
12
|
+
} from '../static-tree';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Serialize explico's static reading tree to the LaTeX of one chapter, written
|
|
16
|
+
* against `explico.cls`. The output is source a person can read and edit:
|
|
17
|
+
* explico's blocks are named environments, cross-references are `\ref`s to
|
|
18
|
+
* labels, and numbers are LaTeX's own (checked against the website after
|
|
19
|
+
* compiling, see `numbering.ts`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What the serializer needs to know beyond the page itself. */
|
|
23
|
+
export interface ChapterContext {
|
|
24
|
+
readonly page: StaticPage;
|
|
25
|
+
/** Routes of every page in this document; a reference to one becomes a `\ref`. */
|
|
26
|
+
readonly documentRoutes: ReadonlySet<string>;
|
|
27
|
+
/** Published course root, for links that leave the document. */
|
|
28
|
+
readonly canonicalBaseUrl?: string;
|
|
29
|
+
/** Picture files written for this chapter, by float anchor (`fig-…`), relative to the document. */
|
|
30
|
+
readonly pictures: ReadonlyMap<string, string>;
|
|
31
|
+
/** Answers of this chapter's checks, by assessment id, in authored order. */
|
|
32
|
+
readonly checkItems: ReadonlyMap<string, readonly CheckItem[]>;
|
|
33
|
+
/** Course title, version and the chapter's live URL for the front line. */
|
|
34
|
+
readonly source?: { readonly course: string; readonly version?: string; readonly url?: string };
|
|
35
|
+
/**
|
|
36
|
+
* A chapter of a part's or the course's book: its citation keys gain the
|
|
37
|
+
* chapter (`projectiles/projectile-range/openstax`), since each chapter
|
|
38
|
+
* keeps its own reference list numbered as on its page; its notation is in
|
|
39
|
+
* the book's appendix, not after the chapter; and under its title is only
|
|
40
|
+
* the link to its live version, the title page naming the course.
|
|
41
|
+
*/
|
|
42
|
+
readonly inBook?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Keys with an entry in the document's notation appendix, so a formula's
|
|
45
|
+
* `\explain{key}{…}` can link to it. Absent for a chapter's own document,
|
|
46
|
+
* which has no appendix: its markers print their TeX, as the class does.
|
|
47
|
+
*/
|
|
48
|
+
readonly notationTargets?: ReadonlySet<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CheckItem {
|
|
52
|
+
readonly itemId: string;
|
|
53
|
+
readonly type: 'single-choice' | 'numeric';
|
|
54
|
+
readonly promptSegments: readonly ContentSegment[];
|
|
55
|
+
readonly explanationSegments: readonly ContentSegment[];
|
|
56
|
+
readonly options?: readonly {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
readonly label: string;
|
|
59
|
+
/** The label as text and math (manifest v30); without it, the label is text. */
|
|
60
|
+
readonly labelSegments?: readonly ContentSegment[];
|
|
61
|
+
}[];
|
|
62
|
+
readonly correctOptionId?: string;
|
|
63
|
+
readonly answer?: { readonly value: number; readonly tolerance: number };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A LaTeX label for an anchor on a page: unique across a whole book. */
|
|
67
|
+
export function labelOf(route: string, anchor: string): string {
|
|
68
|
+
return `${route}/${anchor}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TEXT_ESCAPES: Readonly<Record<string, string>> = {
|
|
72
|
+
'\\': '\\textbackslash{}',
|
|
73
|
+
'{': '\\{',
|
|
74
|
+
'}': '\\}',
|
|
75
|
+
$: '\\$',
|
|
76
|
+
'&': '\\&',
|
|
77
|
+
'#': '\\#',
|
|
78
|
+
'^': '\\textasciicircum{}',
|
|
79
|
+
_: '\\_',
|
|
80
|
+
'%': '\\%',
|
|
81
|
+
'~': '\\textasciitilde{}',
|
|
82
|
+
'<': '\\textless{}',
|
|
83
|
+
'>': '\\textgreater{}',
|
|
84
|
+
'|': '\\textbar{}',
|
|
85
|
+
' ': '~',
|
|
86
|
+
'': '',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Two or more Greek letters in a row: a word, set as text, not as symbols. */
|
|
90
|
+
const GREEK_WORD = /[\u0370-\u03FF\u1F00-\u1FFF]{2,}/gu;
|
|
91
|
+
|
|
92
|
+
export function escapeText(text: string): string {
|
|
93
|
+
return text
|
|
94
|
+
.replace(/[\\{}$&#^_%~<>| ]/gu, (char) => TEXT_ESCAPES[char] ?? char)
|
|
95
|
+
.replace(GREEK_WORD, (word) => `\\ExplicoGreekWord{${word}}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function escapeUrl(url: string): string {
|
|
99
|
+
return url.replace(/[\\{}]/gu, '').replace(/[%#]/gu, (char) => `\\${char}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Roughly how many glyphs a formula prints: commands count as one, markers' keys as none. */
|
|
103
|
+
function texVisibleLength(tex: string): number {
|
|
104
|
+
return tex
|
|
105
|
+
.replace(/\\(?:explain|slot)\{[^{}]*\}/gu, '')
|
|
106
|
+
.replace(/\\(?:left|right|[bB]igg?[lr]?|quad|qquad)(?![a-zA-Z])/gu, '')
|
|
107
|
+
.replace(/\\[,;:!]/gu, '')
|
|
108
|
+
.replace(/\\[a-zA-Z]+/gu, 'x')
|
|
109
|
+
.replace(/[{}^_\s]/gu, '').length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Roughly how many glyphs some inline content prints. */
|
|
113
|
+
function visibleLength(inlines: readonly Inline[]): number {
|
|
114
|
+
let length = 0;
|
|
115
|
+
for (const inline of inlines) {
|
|
116
|
+
if (inline.kind === 'text' || inline.kind === 'code') length += inline.value.length;
|
|
117
|
+
else if (inline.kind === 'math') length += texVisibleLength(inline.tex);
|
|
118
|
+
else if (inline.kind === 'cite') length += inline.number.length + 2;
|
|
119
|
+
else if ('children' in inline) length += visibleLength(inline.children);
|
|
120
|
+
}
|
|
121
|
+
return length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isMathOnly(inlines: readonly Inline[]): boolean {
|
|
125
|
+
const content = inlines.filter((inline) => !(inline.kind === 'text' && !inline.value.trim()));
|
|
126
|
+
return content.length > 0 && content.every((inline) => inline.kind === 'math');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function plainText(inlines: readonly Inline[]): string {
|
|
130
|
+
return inlines
|
|
131
|
+
.map((inline) => {
|
|
132
|
+
switch (inline.kind) {
|
|
133
|
+
case 'text':
|
|
134
|
+
case 'code':
|
|
135
|
+
return inline.value;
|
|
136
|
+
case 'math':
|
|
137
|
+
return inline.tex;
|
|
138
|
+
case 'break':
|
|
139
|
+
return ' ';
|
|
140
|
+
case 'cite':
|
|
141
|
+
return `[${inline.number}]`;
|
|
142
|
+
case 'memoMark':
|
|
143
|
+
return inline.label;
|
|
144
|
+
default:
|
|
145
|
+
return 'children' in inline ? plainText(inline.children) : '';
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
.join('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every citation's source id, by the anchor of its reference-list item. */
|
|
152
|
+
function citationKeys(blocks: readonly Block[]): Map<string, string> {
|
|
153
|
+
const keys = new Map<string, string>();
|
|
154
|
+
const visitInlines = (inlines: readonly Inline[]) => {
|
|
155
|
+
for (const inline of inlines) {
|
|
156
|
+
if (inline.kind === 'cite' && inline.sourceId) keys.set(inline.anchor, inline.sourceId);
|
|
157
|
+
if ('children' in inline) visitInlines(inline.children);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const visitBlocks = (list: readonly Block[]) => {
|
|
161
|
+
for (const block of list) {
|
|
162
|
+
if ('children' in block && Array.isArray(block.children)) {
|
|
163
|
+
if (block.kind === 'paragraph' || block.kind === 'heading') visitInlines(block.children);
|
|
164
|
+
else visitBlocks(block.children as readonly Block[]);
|
|
165
|
+
}
|
|
166
|
+
if (block.kind === 'list') block.items.forEach(visitBlocks);
|
|
167
|
+
if (block.kind === 'examples') block.items.forEach((item) => visitBlocks(item.children));
|
|
168
|
+
if (block.kind === 'table') [...block.head, ...block.body].flat().forEach((cell) => visitInlines(cell.children));
|
|
169
|
+
if ('caption' in block) visitInlines(block.caption);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
visitBlocks(blocks);
|
|
173
|
+
return keys;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Each reference-list entry's `\bibitem` key, by its anchor. The page lists a
|
|
178
|
+
* source once per locator it is cited with (`[@s; ch. 3]` and `[@s; ch. 4]`
|
|
179
|
+
* are two entries, two numbers), so a source cited with one locator keeps its
|
|
180
|
+
* id as the key and one cited with several gets one key per entry
|
|
181
|
+
* (`s/cite-4`): a repeated key would be multiply defined, and every `\cite`
|
|
182
|
+
* of it would print the last entry's number.
|
|
183
|
+
*/
|
|
184
|
+
function entryKeys(sourceByAnchor: ReadonlyMap<string, string>): Map<string, string> {
|
|
185
|
+
const anchorsBySource = new Map<string, string[]>();
|
|
186
|
+
for (const [anchor, sourceId] of sourceByAnchor) {
|
|
187
|
+
const anchors = anchorsBySource.get(sourceId) ?? [];
|
|
188
|
+
anchors.push(anchor);
|
|
189
|
+
anchorsBySource.set(sourceId, anchors);
|
|
190
|
+
}
|
|
191
|
+
const keys = new Map<string, string>();
|
|
192
|
+
for (const [sourceId, anchors] of anchorsBySource) {
|
|
193
|
+
for (const anchor of anchors) {
|
|
194
|
+
keys.set(anchor, anchors.length === 1 ? sourceId : `${sourceId}/${anchor}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return keys;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Rewrites each `\explain{key}{tex}` whose key `linkTo` names as
|
|
202
|
+
* `\ExplicoNotation{target}{tex}`, a link to the notation entry; any other
|
|
203
|
+
* marker stays, and the class prints its TeX. Brace-aware, so a body with
|
|
204
|
+
* nested groups is kept whole. A link cannot hold another, so the outermost
|
|
205
|
+
* linked marker wins: `\dot{x}` links to the time derivative, and the markers
|
|
206
|
+
* inside it print their TeX.
|
|
207
|
+
*/
|
|
208
|
+
export function linkExplains(tex: string, linkTo: (key: string) => string | undefined): string {
|
|
209
|
+
const marker = '\\explain{';
|
|
210
|
+
let out = '';
|
|
211
|
+
let index = 0;
|
|
212
|
+
for (;;) {
|
|
213
|
+
const start = tex.indexOf(marker, index);
|
|
214
|
+
if (start < 0) return out + tex.slice(index);
|
|
215
|
+
const keyEnd = tex.indexOf('}', start + marker.length);
|
|
216
|
+
if (keyEnd < 0 || tex[keyEnd + 1] !== '{') {
|
|
217
|
+
out += tex.slice(index, start + marker.length);
|
|
218
|
+
index = start + marker.length;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
let depth = 0;
|
|
222
|
+
let close = keyEnd + 1;
|
|
223
|
+
for (; close < tex.length; close++) {
|
|
224
|
+
const char = tex[close];
|
|
225
|
+
if (char === '\\') close++;
|
|
226
|
+
else if (char === '{') depth++;
|
|
227
|
+
else if (char === '}' && --depth === 0) break;
|
|
228
|
+
}
|
|
229
|
+
if (close >= tex.length) return out + tex.slice(index);
|
|
230
|
+
const key = tex.slice(start + marker.length, keyEnd);
|
|
231
|
+
const target = linkTo(key);
|
|
232
|
+
const inner = tex.slice(keyEnd + 2, close);
|
|
233
|
+
const body = target ? inner : linkExplains(inner, linkTo);
|
|
234
|
+
out += tex.slice(index, start);
|
|
235
|
+
out += target ? `\\ExplicoNotation{${target}}{${body}}` : `\\explain{${key}}{${body}}`;
|
|
236
|
+
index = close + 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
class ChapterWriter {
|
|
241
|
+
private readonly route: string;
|
|
242
|
+
private readonly citationKeys: Map<string, string>;
|
|
243
|
+
/** Above zero inside a moving argument (a heading, a caption, a title) or a link: no link goes there. */
|
|
244
|
+
private unlinked = 0;
|
|
245
|
+
|
|
246
|
+
constructor(private readonly context: ChapterContext) {
|
|
247
|
+
this.route = context.page.route;
|
|
248
|
+
this.citationKeys = entryKeys(citationKeys(context.page.blocks));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A reference-list entry's citation key in this document, by its anchor. */
|
|
252
|
+
private citeKey(anchor: string): string {
|
|
253
|
+
const key = this.citationKeys.get(anchor) ?? anchor;
|
|
254
|
+
return this.context.inBook ? `${this.route}/${key}` : key;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
label(anchor: string): string {
|
|
258
|
+
return labelOf(this.route, anchor);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Renders `write` with links to notation off: a link cannot nest, or sit in a moving argument. */
|
|
262
|
+
private unlinkedWhile<T>(write: () => T): T {
|
|
263
|
+
this.unlinked++;
|
|
264
|
+
try {
|
|
265
|
+
return write();
|
|
266
|
+
} finally {
|
|
267
|
+
this.unlinked--;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Inline content with no notation links inside it. */
|
|
272
|
+
private plain(inlines: readonly Inline[]): string {
|
|
273
|
+
return this.unlinkedWhile(() => this.inlines(inlines));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A formula's TeX, with its notation markers linked to the appendix where the document has the entry. */
|
|
277
|
+
private math(tex: string): string {
|
|
278
|
+
const targets = this.context.notationTargets;
|
|
279
|
+
if (this.unlinked > 0 || !targets || targets.size === 0) return tex;
|
|
280
|
+
return linkExplains(tex, (key) => (targets.has(key) ? labelOf('notation', key) : undefined));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private inDocument(target: CourseTarget): boolean {
|
|
284
|
+
return this.context.documentRoutes.has(target.route);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private canonicalUrl(target: CourseTarget): string | undefined {
|
|
288
|
+
const base = this.context.canonicalBaseUrl;
|
|
289
|
+
if (!base) return undefined;
|
|
290
|
+
const path = target.route ? `${target.route}/` : '';
|
|
291
|
+
return `${base}${path}${target.anchor ? `#${target.anchor}` : ''}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** A cross-reference: LaTeX's number inside the document, the website's outside it. */
|
|
295
|
+
private reference(refKind: string, target: CourseTarget, children: readonly Inline[]): string {
|
|
296
|
+
const text = plainText(children);
|
|
297
|
+
if (this.inDocument(target) && target.anchor) {
|
|
298
|
+
const label = labelOf(target.route, target.anchor);
|
|
299
|
+
if (refKind === 'equation' && /^\(\s*[\d.]+\s*\)$/u.test(text.trim())) {
|
|
300
|
+
return `\\ExplicoEqRef{${label}}`;
|
|
301
|
+
}
|
|
302
|
+
const number = /\d+(?:\.\d+)*/u.exec(text);
|
|
303
|
+
if (number && ['equation', 'figure', 'table', 'diagram', 'section', 'example', 'check'].includes(refKind)) {
|
|
304
|
+
const before = text.slice(0, number.index);
|
|
305
|
+
const after = text.slice(number.index + number[0].length);
|
|
306
|
+
return `\\ExplicoRef{${label}}{${escapeText(before)}}{${escapeText(after)}}`;
|
|
307
|
+
}
|
|
308
|
+
return `\\hyperref[${label}]{${this.plain(children)}}`;
|
|
309
|
+
}
|
|
310
|
+
const url = this.canonicalUrl(target);
|
|
311
|
+
return url ? `\\href{${escapeUrl(url)}}{${this.plain(children)}}` : this.inlines(children);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
inline(inline: Inline): string {
|
|
315
|
+
switch (inline.kind) {
|
|
316
|
+
case 'text':
|
|
317
|
+
return escapeText(inline.value);
|
|
318
|
+
case 'emphasis':
|
|
319
|
+
return `\\emph{${this.inlines(inline.children)}}`;
|
|
320
|
+
case 'strong':
|
|
321
|
+
return `\\textbf{${this.inlines(inline.children)}}`;
|
|
322
|
+
case 'superscript':
|
|
323
|
+
return `\\textsuperscript{${this.inlines(inline.children)}}`;
|
|
324
|
+
case 'subscript':
|
|
325
|
+
return `\\textsubscript{${this.inlines(inline.children)}}`;
|
|
326
|
+
case 'code':
|
|
327
|
+
return `\\texttt{${escapeText(inline.value)}}`;
|
|
328
|
+
case 'math':
|
|
329
|
+
return `$${this.math(inline.tex)}$`;
|
|
330
|
+
case 'break':
|
|
331
|
+
return '\\newline ';
|
|
332
|
+
case 'link':
|
|
333
|
+
return `\\href{${escapeUrl(inline.href)}}{${this.plain(inline.children)}}`;
|
|
334
|
+
case 'ref':
|
|
335
|
+
return this.reference(inline.refKind, inline.target, inline.children);
|
|
336
|
+
case 'term':
|
|
337
|
+
return `\\ExplicoTerm{${inline.key}}{${this.inlines(inline.children)}}`;
|
|
338
|
+
case 'cite':
|
|
339
|
+
// The locator is part of the entry it points at, as on the page,
|
|
340
|
+
// so the marker is the entry's number alone.
|
|
341
|
+
return inline.sourceId
|
|
342
|
+
? `\\cite{${this.citeKey(inline.anchor)}}`
|
|
343
|
+
: `[${escapeText(inline.number)}]`;
|
|
344
|
+
case 'memoMark':
|
|
345
|
+
return `\\ExplicoMemoMark{${this.label(inline.anchor)}}`;
|
|
346
|
+
case 'noteMark':
|
|
347
|
+
return `\\ExplicoNoteMark{${this.label(inline.anchor)}}{${this.plain(inline.children)}}`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
inlines(inlines: readonly Inline[]): string {
|
|
352
|
+
return inlines.map((inline) => this.inline(inline)).join('');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private segments(segments: readonly ContentSegment[]): string {
|
|
356
|
+
return segments
|
|
357
|
+
.map((segment) => (segment.type === 'math' ? `$${this.math(segment.latex)}$` : escapeText(segment.value)))
|
|
358
|
+
.join('');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A float's caption: its short caption for the lists, bold before the long one. */
|
|
362
|
+
private caption(block: { short?: string; caption: readonly Inline[]; anchor: string }, extra: readonly Inline[] = []): string {
|
|
363
|
+
const short = block.short?.trim();
|
|
364
|
+
const long = [this.plain(block.caption), this.plain(extra)].filter(Boolean).join(' ');
|
|
365
|
+
const title = short ? `\\ExplicoCaptionTitle{${escapeText(captionTitle(short))}} ` : '';
|
|
366
|
+
const list = short ? `[${escapeText(short)}]` : '';
|
|
367
|
+
return `\\caption${list}{${title}${long}}\\label{${this.label(block.anchor)}}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* A float. Inside a box (a memo, a worked example) a float cannot float, so
|
|
372
|
+
* it is set where it stands (`ExplicoHere`, explico.cls) and keeps its number.
|
|
373
|
+
*/
|
|
374
|
+
private float(kind: string, inPlace: boolean, content: string, caption: string, captionFirst = false): string {
|
|
375
|
+
const parts = captionFirst ? [caption, content] : [content, caption];
|
|
376
|
+
if (inPlace) return [`\\begin{ExplicoHere}{${kind}}`, ...parts, '\\end{ExplicoHere}'].join('\n');
|
|
377
|
+
return [`\\begin{${kind}}[htbp]`, '\\centering', ...parts, `\\end{${kind}}`].join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private picture(anchor: string, picture: FigurePicture, alt: string): string {
|
|
381
|
+
if (picture.kind === 'diagram') return diagramTikz(picture.geometry, this);
|
|
382
|
+
const file = this.context.pictures.get(anchor);
|
|
383
|
+
if (file) {
|
|
384
|
+
// The tagged PDF reads `alt` to a screen reader in place of the picture.
|
|
385
|
+
// Its braces keep a comma, an equals sign or a bracket from ending the
|
|
386
|
+
// key or the option list, so the text keeps its punctuation.
|
|
387
|
+
const text = escapeText(alt || 'Figure');
|
|
388
|
+
return `\\includegraphics[alt={${text}},max width=\\linewidth,max height=0.6\\textheight]{${file}}`;
|
|
389
|
+
}
|
|
390
|
+
return `\\fbox{\\parbox{0.9\\linewidth}{\\centering\\small\\itshape ${escapeText(
|
|
391
|
+
alt || 'Interactive figure',
|
|
392
|
+
)}: see the interactive version on the course website.}}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private table(block: Extract<Block, { kind: 'table' }>, inBox = false): string {
|
|
396
|
+
const rows = [...block.head, ...block.body];
|
|
397
|
+
const columns = Math.max(0, ...rows.map((row) => row.reduce((sum, cell) => sum + cell.colspan, 0)));
|
|
398
|
+
if (columns === 0) return '';
|
|
399
|
+
const cellAt = (row: readonly TableCell[], column: number): TableCell | undefined => {
|
|
400
|
+
let position = 0;
|
|
401
|
+
for (const cell of row) {
|
|
402
|
+
if (position === column && cell.colspan === 1) return cell;
|
|
403
|
+
position += cell.colspan;
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
};
|
|
407
|
+
const widths = Array.from({ length: columns }, (_, column) =>
|
|
408
|
+
Math.max(0, ...rows.map((row) => visibleLength(cellAt(row, column)?.children ?? []))),
|
|
409
|
+
);
|
|
410
|
+
// A column that is mostly prose may wrap; math and numbers never break.
|
|
411
|
+
const prose = Array.from({ length: columns }, (_, column) =>
|
|
412
|
+
block.body.some((row) => {
|
|
413
|
+
const cell = cellAt(row, column);
|
|
414
|
+
return !!cell && !isMathOnly(cell.children) && /[a-z]{3}/iu.test(plainText(cell.children));
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
const total = widths.reduce((sum, width) => sum + width + 2, 0);
|
|
418
|
+
const wraps = total > 75 && prose.some((isProse, column) => isProse && widths[column]! > 18);
|
|
419
|
+
const alignOf = (column: number): string => {
|
|
420
|
+
if (wraps && prose[column] && widths[column]! > 18) return '>{\\raggedright\\arraybackslash}X';
|
|
421
|
+
const align = (block.body[0] && cellAt(block.body[0], column)?.align) ?? cellAt(block.head[0] ?? [], column)?.align;
|
|
422
|
+
return align === 'right' ? 'r' : align === 'center' ? 'c' : 'l';
|
|
423
|
+
};
|
|
424
|
+
const spec = Array.from({ length: columns }, (_, column) => alignOf(column)).join('');
|
|
425
|
+
const dense = columns >= 8;
|
|
426
|
+
const row = (cells: readonly TableCell[]) =>
|
|
427
|
+
`${cells
|
|
428
|
+
.map((cell) => {
|
|
429
|
+
const content = this.inlines(cell.children);
|
|
430
|
+
return cell.colspan > 1 ? `\\multicolumn{${cell.colspan}}{l}{${content}}` : content;
|
|
431
|
+
})
|
|
432
|
+
.join(' & ')} \\\\`;
|
|
433
|
+
const body = [
|
|
434
|
+
'\\toprule',
|
|
435
|
+
...block.head.map(row),
|
|
436
|
+
...(block.head.length > 0 ? ['\\midrule'] : []),
|
|
437
|
+
...block.body.map(row),
|
|
438
|
+
'\\bottomrule',
|
|
439
|
+
];
|
|
440
|
+
// A table of data (many columns, nothing to wrap) shrinks to the measure
|
|
441
|
+
// only when it would overflow it; a table of prose wraps its long columns.
|
|
442
|
+
const tabular = wraps
|
|
443
|
+
? ['\\small', `\\begin{tabularx}{\\linewidth}{@{}${spec}@{}}`, ...body, '\\end{tabularx}'].join('\n')
|
|
444
|
+
: [
|
|
445
|
+
dense ? '\\footnotesize\\setlength{\\tabcolsep}{4pt}' : '\\small',
|
|
446
|
+
'\\begin{adjustbox}{max width=\\linewidth}',
|
|
447
|
+
`\\begin{tabular}{@{}${spec}@{}}`,
|
|
448
|
+
...body,
|
|
449
|
+
'\\end{tabular}',
|
|
450
|
+
'\\end{adjustbox}',
|
|
451
|
+
].join('\n');
|
|
452
|
+
if (!block.number) return `\\begin{center}\n${tabular}\n\\end{center}`;
|
|
453
|
+
return this.float('table', inBox, tabular, this.caption(block), true);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** A choice's label, its math typeset like the prompt's. */
|
|
457
|
+
private optionLabel(option: NonNullable<CheckItem['options']>[number]): string {
|
|
458
|
+
return option.labelSegments ? this.segments(option.labelSegments) : escapeText(option.label);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private check(block: Extract<Block, { kind: 'check' }>): string {
|
|
462
|
+
const items = this.context.checkItems.get(block.assessmentId) ?? [];
|
|
463
|
+
const questions = items.map((item) => {
|
|
464
|
+
const options =
|
|
465
|
+
item.type === 'single-choice' && item.options && item.options.length > 0
|
|
466
|
+
? [
|
|
467
|
+
'\\begin{enumerate}[label=(\\alph*)]',
|
|
468
|
+
...item.options.map((option) => `\\item ${this.optionLabel(option)}`),
|
|
469
|
+
'\\end{enumerate}',
|
|
470
|
+
].join('\n')
|
|
471
|
+
: '';
|
|
472
|
+
return `\\item ${this.segments(item.promptSegments)}${options ? `\n${options}` : ''}`;
|
|
473
|
+
});
|
|
474
|
+
return [
|
|
475
|
+
`\\begin{ExplicoCheck}{${this.plain(block.title)}}\\label{${this.label(block.anchor)}}`,
|
|
476
|
+
...(questions.length > 0 ? ['\\begin{enumerate}', ...questions, '\\end{enumerate}'] : []),
|
|
477
|
+
`\\ExplicoCheckAnswerNote{${this.label('answers')}}`,
|
|
478
|
+
'\\end{ExplicoCheck}',
|
|
479
|
+
].join('\n');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The chapter's answer key: every check's answers and explanations, in order. */
|
|
483
|
+
answers(checks: readonly Extract<Block, { kind: 'check' }>[]): string {
|
|
484
|
+
if (checks.length === 0) return '';
|
|
485
|
+
const parts = [`\\section*{Answers to the knowledge checks}\\hypertarget{${this.label('answers')}}{}`];
|
|
486
|
+
for (const check of checks) {
|
|
487
|
+
const items = this.context.checkItems.get(check.assessmentId) ?? [];
|
|
488
|
+
parts.push(
|
|
489
|
+
`\\paragraph*{\\ExplicoRef{${this.label(check.anchor)}}{Knowledge check~}{}: ${this.plain(check.title)}}`,
|
|
490
|
+
);
|
|
491
|
+
parts.push('\\begin{enumerate}');
|
|
492
|
+
for (const item of items) {
|
|
493
|
+
let answer = '';
|
|
494
|
+
if (item.type === 'single-choice' && item.options) {
|
|
495
|
+
const index = item.options.findIndex((option) => option.id === item.correctOptionId);
|
|
496
|
+
if (index !== -1) {
|
|
497
|
+
answer = `\\textbf{(${String.fromCharCode(97 + index)})} ${this.optionLabel(item.options[index]!)}. `;
|
|
498
|
+
}
|
|
499
|
+
} else if (item.type === 'numeric' && item.answer) {
|
|
500
|
+
answer = `\\textbf{$${item.answer.value}$} (accepted within $\\pm${item.answer.tolerance}$). `;
|
|
501
|
+
}
|
|
502
|
+
parts.push(`\\item ${answer}${this.segments(item.explanationSegments)}`);
|
|
503
|
+
}
|
|
504
|
+
parts.push('\\end{enumerate}');
|
|
505
|
+
}
|
|
506
|
+
return parts.join('\n');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
block(block: Block, inBox = false): string {
|
|
510
|
+
switch (block.kind) {
|
|
511
|
+
case 'heading': {
|
|
512
|
+
const text = this.plain(block.children);
|
|
513
|
+
const label = block.anchor ? `\\label{${this.label(block.anchor)}}` : '';
|
|
514
|
+
if (inBox) return `\\par\\smallskip\\noindent\\textbf{${text}}${label}\\par`;
|
|
515
|
+
const command =
|
|
516
|
+
['section', 'subsection', 'subsubsection', 'paragraph', 'subparagraph'][
|
|
517
|
+
Math.min(Math.max(block.level - 2, 0), 4)
|
|
518
|
+
] ?? 'paragraph';
|
|
519
|
+
const star = block.number ? '' : '*';
|
|
520
|
+
return `\\${command}${star}{${text}}${label}`;
|
|
521
|
+
}
|
|
522
|
+
case 'paragraph':
|
|
523
|
+
return this.inlines(block.children);
|
|
524
|
+
case 'list': {
|
|
525
|
+
const environment = block.ordered ? 'enumerate' : 'itemize';
|
|
526
|
+
const start =
|
|
527
|
+
block.ordered && block.start && block.start !== 1
|
|
528
|
+
? `\\setcounter{enumi}{${block.start - 1}}\n`
|
|
529
|
+
: '';
|
|
530
|
+
const items = block.items.map((item) => `\\item ${this.blocks(item, inBox)}`);
|
|
531
|
+
return `\\begin{${environment}}\n${start}${items.join('\n')}\n\\end{${environment}}`;
|
|
532
|
+
}
|
|
533
|
+
case 'equation':
|
|
534
|
+
return `\\begin{equation}\\label{${this.label(block.anchor)}}\n${this.math(block.tex)}\n\\end{equation}`;
|
|
535
|
+
case 'displayMath':
|
|
536
|
+
return `\\[\n${this.math(block.tex)}\n\\]`;
|
|
537
|
+
case 'figure':
|
|
538
|
+
return this.float(
|
|
539
|
+
'figure',
|
|
540
|
+
inBox,
|
|
541
|
+
this.picture(
|
|
542
|
+
block.anchor,
|
|
543
|
+
block.picture,
|
|
544
|
+
block.alt ?? [block.short && captionTitle(block.short), plainText(block.caption)].filter(Boolean).join(' '),
|
|
545
|
+
),
|
|
546
|
+
this.caption(block, block.live),
|
|
547
|
+
);
|
|
548
|
+
case 'diagram':
|
|
549
|
+
return this.float(
|
|
550
|
+
'diagram',
|
|
551
|
+
inBox,
|
|
552
|
+
this.unlinkedWhile(() => diagramTikz(block.geometry, this)),
|
|
553
|
+
this.caption(block),
|
|
554
|
+
);
|
|
555
|
+
case 'table':
|
|
556
|
+
return this.table(block, inBox);
|
|
557
|
+
case 'memo':
|
|
558
|
+
return [
|
|
559
|
+
`\\begin{ExplicoMemo}{${this.plain(block.title)}}\\label{${this.label(block.anchor)}}`,
|
|
560
|
+
this.blocks(block.children, true),
|
|
561
|
+
'\\end{ExplicoMemo}',
|
|
562
|
+
].join('\n');
|
|
563
|
+
case 'note':
|
|
564
|
+
return [
|
|
565
|
+
`\\begin{ExplicoNote}{${this.label(block.anchor)}}{${escapeText(block.title)}}`,
|
|
566
|
+
this.blocks(block.children, true),
|
|
567
|
+
'\\end{ExplicoNote}',
|
|
568
|
+
].join('\n');
|
|
569
|
+
case 'aside':
|
|
570
|
+
return [
|
|
571
|
+
`\\begin{ExplicoAside}{${block.variant}}{${this.plain(block.title)}}`,
|
|
572
|
+
this.blocks(block.children, true),
|
|
573
|
+
'\\end{ExplicoAside}',
|
|
574
|
+
].join('\n');
|
|
575
|
+
case 'fold':
|
|
576
|
+
return [
|
|
577
|
+
...(block.title ? [`\\paragraph*{${this.plain(block.title)}}`] : []),
|
|
578
|
+
this.blocks(block.children, inBox),
|
|
579
|
+
].join('\n');
|
|
580
|
+
case 'examples':
|
|
581
|
+
return [
|
|
582
|
+
`\\begin{ExplicoExamples}{${this.plain(block.title)}}\\label{${this.label(block.anchor)}}`,
|
|
583
|
+
...block.items.flatMap((item) => [
|
|
584
|
+
`\\ExplicoExample{${this.plain(item.title)}}`,
|
|
585
|
+
this.blocks(item.children, true),
|
|
586
|
+
]),
|
|
587
|
+
'\\end{ExplicoExamples}',
|
|
588
|
+
].join('\n');
|
|
589
|
+
case 'check':
|
|
590
|
+
return this.check(block);
|
|
591
|
+
case 'quote':
|
|
592
|
+
return `\\begin{quote}\n${this.blocks(block.children, inBox)}\n\\end{quote}`;
|
|
593
|
+
case 'image': {
|
|
594
|
+
const file = this.context.pictures.get(block.src);
|
|
595
|
+
return file
|
|
596
|
+
? `\\begin{center}\\includegraphics[max width=\\linewidth]{${file}}\\end{center}`
|
|
597
|
+
: '';
|
|
598
|
+
}
|
|
599
|
+
case 'code':
|
|
600
|
+
return `\\begin{verbatim}\n${block.value}\n\\end{verbatim}`;
|
|
601
|
+
case 'rule':
|
|
602
|
+
return '\\par\\noindent\\rule{\\linewidth}{0.4pt}\\par';
|
|
603
|
+
case 'references':
|
|
604
|
+
return [
|
|
605
|
+
`\\begin{ExplicoReferences}{${block.items.length}}`,
|
|
606
|
+
...block.items.map(
|
|
607
|
+
(item) =>
|
|
608
|
+
`\\bibitem{${this.citeKey(item.anchor)}} ${this.plain(item.children)}`,
|
|
609
|
+
),
|
|
610
|
+
'\\end{ExplicoReferences}',
|
|
611
|
+
].join('\n');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
blocks(blocks: readonly Block[], inBox = false): string {
|
|
616
|
+
return blocks
|
|
617
|
+
.map((block) => this.block(block, inBox))
|
|
618
|
+
.filter((text) => text.trim().length > 0)
|
|
619
|
+
.join('\n\n');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
notation(entries: readonly NotationEntry[], remarks: readonly (readonly Inline[])[]): string {
|
|
623
|
+
if (entries.length === 0) return '';
|
|
624
|
+
const items = entries.map((entry) => {
|
|
625
|
+
const symbol = entry.symbolTex ? `$${entry.symbolTex}$` : '';
|
|
626
|
+
const parts = [
|
|
627
|
+
`\\textbf{${this.inlines(entry.label)}}.`,
|
|
628
|
+
this.inlines(entry.summary),
|
|
629
|
+
...(entry.units ? [`\\emph{Units:} ${this.inlines(entry.units)}.`] : []),
|
|
630
|
+
...(entry.formulaTex ? [`\\[${entry.formulaTex}\\]`] : []),
|
|
631
|
+
];
|
|
632
|
+
return `\\item[${symbol}]\\hypertarget{${this.label(entry.anchor)}}{}${parts.join(' ')}`;
|
|
633
|
+
});
|
|
634
|
+
return [
|
|
635
|
+
'\\section*{Notation}',
|
|
636
|
+
...remarks.map((remark) => `\\noindent\\emph{${this.inlines(remark)}}\\par\\medskip`),
|
|
637
|
+
'\\begin{description}',
|
|
638
|
+
...items,
|
|
639
|
+
'\\end{description}',
|
|
640
|
+
].join('\n');
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* explico's `<Diagram>` in TikZ, from the layout Dagre gave it at build time:
|
|
646
|
+
* the same boxes and edges as the website, with LaTeX setting the text and the
|
|
647
|
+
* math. Coordinates are scaled to the text width; type keeps its size.
|
|
648
|
+
*/
|
|
649
|
+
export function diagramTikz(geometry: DiagramGeometry, writer: { inlines(inlines: readonly Inline[]): string }): string {
|
|
650
|
+
if (geometry.nodes.length === 0) return '';
|
|
651
|
+
// CSS pixels to points, shrunk to fit a 450 pt measure (the text width of
|
|
652
|
+
// both letter and A4 pages with one-inch margins is wider).
|
|
653
|
+
const scale = 0.75 * Math.min(1, 450 / Math.max(1, geometry.width * 0.75));
|
|
654
|
+
const pt = (value: number) => `${(value * scale).toFixed(1)}pt`;
|
|
655
|
+
const point = (x: number, y: number) => `(${pt(x)},${pt(-y)})`;
|
|
656
|
+
const nodes = geometry.nodes.map((node) => {
|
|
657
|
+
const text = node.text ? writer.inlines([{ kind: 'text', value: node.text }]) : '';
|
|
658
|
+
const math = node.tex ? `$${node.tex}$` : '';
|
|
659
|
+
const content = [text, math].filter(Boolean).join('\\\\ ');
|
|
660
|
+
const textWidth = Math.max(10, node.width * scale - 8).toFixed(1);
|
|
661
|
+
return `\\node[explico diagram node,minimum width=${pt(node.width)},minimum height=${pt(node.height)},text width=${textWidth}pt] at ${point(node.x + node.width / 2, node.y + node.height / 2)} {${content}};`;
|
|
662
|
+
});
|
|
663
|
+
const edges = geometry.edges.map(
|
|
664
|
+
(edge) => `\\draw[explico diagram edge] ${edge.map(([x, y]) => point(x, y)).join(' -- ')};`,
|
|
665
|
+
);
|
|
666
|
+
return ['\\begin{tikzpicture}', ...edges, ...nodes, '\\end{tikzpicture}'].join('\n');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The LaTeX of one chapter: `\chapter`, its body, references, notation and answers. */
|
|
670
|
+
export function serializeChapter(context: ChapterContext): string {
|
|
671
|
+
const writer = new ChapterWriter(context);
|
|
672
|
+
const { page } = context;
|
|
673
|
+
const checks: Extract<Block, { kind: 'check' }>[] = [];
|
|
674
|
+
const collect = (blocks: readonly Block[]) => {
|
|
675
|
+
for (const block of blocks) {
|
|
676
|
+
if (block.kind === 'check') checks.push(block);
|
|
677
|
+
if ('children' in block && Array.isArray(block.children) && block.kind !== 'paragraph' && block.kind !== 'heading') {
|
|
678
|
+
collect(block.children as readonly Block[]);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
collect(page.blocks);
|
|
683
|
+
const source = context.source;
|
|
684
|
+
const sourceLine = !source
|
|
685
|
+
? []
|
|
686
|
+
: context.inBook
|
|
687
|
+
? source.url
|
|
688
|
+
? [`\\ExplicoInteractive{${escapeUrl(source.url)}}`]
|
|
689
|
+
: []
|
|
690
|
+
: [
|
|
691
|
+
`\\ExplicoSource{${escapeText(source.course)}}{${escapeText(source.version ?? '')}}{${
|
|
692
|
+
source.url ? escapeUrl(source.url) : ''
|
|
693
|
+
}}`,
|
|
694
|
+
];
|
|
695
|
+
return [
|
|
696
|
+
`\\chapter{${escapeText(page.title)}}\\label{${labelOf(page.route, '_top')}}`,
|
|
697
|
+
'\\ExplicoApplyOffsets',
|
|
698
|
+
...sourceLine,
|
|
699
|
+
writer.blocks(page.blocks),
|
|
700
|
+
...(context.inBook ? [] : [writer.notation(page.notation, page.notationRemarks)]),
|
|
701
|
+
writer.answers(checks),
|
|
702
|
+
'',
|
|
703
|
+
]
|
|
704
|
+
.filter((part) => part !== undefined)
|
|
705
|
+
.join('\n\n');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* A part's opener in a book: `\ExplicoPart`, numbered as on the website, then
|
|
710
|
+
* the text of the part's own page (its introduction, scope and objectives).
|
|
711
|
+
*/
|
|
712
|
+
export function serializePartOpener(context: ChapterContext, part: { readonly ordinal: number; readonly label: string }): string {
|
|
713
|
+
const writer = new ChapterWriter({ ...context, inBook: true });
|
|
714
|
+
return [
|
|
715
|
+
`\\ExplicoPart{${part.ordinal}}{${escapeText(part.label)}}\\label{${labelOf(context.page.route, '_top')}}`,
|
|
716
|
+
writer.blocks(context.page.blocks),
|
|
717
|
+
'',
|
|
718
|
+
].join('\n\n');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** A symbol in a book's notation appendix: its entry, the page that defines it, and the chapter it is local to. */
|
|
722
|
+
export interface AppendixNotation {
|
|
723
|
+
readonly entry: NotationEntry;
|
|
724
|
+
readonly page: StaticPage;
|
|
725
|
+
/** `2.2` for a symbol only one chapter defines; none for a shared one. */
|
|
726
|
+
readonly chapter?: string;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The book's notation appendix: every symbol its chapters use, in the order
|
|
731
|
+
* they first appear, each with its meaning, units and defining formula.
|
|
732
|
+
*/
|
|
733
|
+
export function serializeNotationAppendix(
|
|
734
|
+
entries: readonly AppendixNotation[],
|
|
735
|
+
context: Omit<ChapterContext, 'page'>,
|
|
736
|
+
): string {
|
|
737
|
+
if (entries.length === 0) return '';
|
|
738
|
+
const writers = new Map<string, ChapterWriter>();
|
|
739
|
+
const writerFor = (page: StaticPage) => {
|
|
740
|
+
let writer = writers.get(page.route);
|
|
741
|
+
if (!writer) {
|
|
742
|
+
writer = new ChapterWriter({ ...context, page, inBook: true });
|
|
743
|
+
writers.set(page.route, writer);
|
|
744
|
+
}
|
|
745
|
+
return writer;
|
|
746
|
+
};
|
|
747
|
+
const targets = context.notationTargets;
|
|
748
|
+
/** A defining formula, each other entry it names linked; its own symbol is the entry the reader is on. */
|
|
749
|
+
const formula = (key: string, tex: string) =>
|
|
750
|
+
targets ? linkExplains(tex, (named) => (named !== key && targets.has(named) ? labelOf('notation', named) : undefined)) : tex;
|
|
751
|
+
const items = entries.map(({ entry, page, chapter }) => {
|
|
752
|
+
const writer = writerFor(page);
|
|
753
|
+
const parts = [
|
|
754
|
+
`\\textbf{${writer.inlines(entry.label)}}.`,
|
|
755
|
+
writer.inlines(entry.summary),
|
|
756
|
+
...(entry.units ? [`\\emph{Units:} ${writer.inlines(entry.units)}.`] : []),
|
|
757
|
+
...(chapter ? [`\\emph{In Chapter~${chapter} only.}`] : []),
|
|
758
|
+
...(entry.formulaTex ? [`\\[${formula(entry.key, entry.formulaTex)}\\]`] : []),
|
|
759
|
+
];
|
|
760
|
+
const symbol = entry.symbolTex ? `$${entry.symbolTex}$` : '';
|
|
761
|
+
return `\\item[{${symbol}}]\\hypertarget{${labelOf('notation', entry.key)}}{}${parts.join(' ')}`;
|
|
762
|
+
});
|
|
763
|
+
return [
|
|
764
|
+
'\\chapter{Notation}\\label{appendix/notation}',
|
|
765
|
+
'The symbols of this book, in the order they first appear.',
|
|
766
|
+
'\\begin{description}',
|
|
767
|
+
...items,
|
|
768
|
+
'\\end{description}',
|
|
769
|
+
'',
|
|
770
|
+
].join('\n');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The book's abbreviation appendix: each abbreviation and what it stands for. */
|
|
774
|
+
export function serializeAbbreviationAppendix(
|
|
775
|
+
abbreviations: readonly { readonly abbr: string; readonly expansion: string }[],
|
|
776
|
+
): string {
|
|
777
|
+
if (abbreviations.length === 0) return '';
|
|
778
|
+
return [
|
|
779
|
+
'\\chapter{Abbreviations}\\label{appendix/abbreviations}',
|
|
780
|
+
'\\begin{description}',
|
|
781
|
+
...[...abbreviations]
|
|
782
|
+
.sort((a, b) => a.abbr.localeCompare(b.abbr))
|
|
783
|
+
.map((entry) => `\\item[{${escapeText(entry.abbr)}}] ${escapeText(entry.expansion)}`),
|
|
784
|
+
'\\end{description}',
|
|
785
|
+
'',
|
|
786
|
+
].join('\n');
|
|
787
|
+
}
|