@dataimago/css 0.1.0-alpha.20 → 0.1.0-alpha.22

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 (114) hide show
  1. package/README.md +171 -0
  2. package/dist/bin/dataimago-figures.mjs +703 -0
  3. package/dist/bin/dataimago-sync.mjs +281 -19
  4. package/dist/dataimago.css +9 -3
  5. package/dist/dataimago.min.css +1 -1
  6. package/dist/dataviz/dataimago-dataviz.css +1 -1
  7. package/dist/dataviz/dataimago-dataviz.json +2 -2
  8. package/dist/dataviz/dataimago-dataviz.tex +1 -1
  9. package/dist/dataviz/dataimago_dataviz.R +1 -1
  10. package/dist/figures/NotoSans-Bold.otf +0 -0
  11. package/dist/figures/NotoSans-BoldItalic.otf +0 -0
  12. package/dist/figures/NotoSans-Italic.otf +0 -0
  13. package/dist/figures/NotoSans-Regular.otf +0 -0
  14. package/dist/figures/NotoSansMath-Regular.ttf +0 -0
  15. package/dist/figures/OFL-NotoSans.txt +93 -0
  16. package/dist/figures/OFL-NotoSansMath.txt +93 -0
  17. package/dist/figures/dataimago-figure.sty +105 -0
  18. package/dist/figures/dataimago-tokens.sty +134 -0
  19. package/dist/fonts/cyrillic/OFL-NotoSans.txt +93 -0
  20. package/dist/fonts/cyrillic/OFL-NotoSansMono.txt +93 -0
  21. package/dist/fonts/cyrillic/fonts.css +27 -0
  22. package/dist/fonts/cyrillic/noto-sans-italic.woff2 +0 -0
  23. package/dist/fonts/cyrillic/noto-sans-mono.woff2 +0 -0
  24. package/dist/fonts/cyrillic/noto-sans.woff2 +0 -0
  25. package/dist/fonts/cyrillic-ext/OFL-NotoSans.txt +93 -0
  26. package/dist/fonts/cyrillic-ext/OFL-NotoSansMono.txt +93 -0
  27. package/dist/fonts/cyrillic-ext/fonts.css +27 -0
  28. package/dist/fonts/cyrillic-ext/noto-sans-italic.woff2 +0 -0
  29. package/dist/fonts/cyrillic-ext/noto-sans-mono.woff2 +0 -0
  30. package/dist/fonts/cyrillic-ext/noto-sans.woff2 +0 -0
  31. package/dist/fonts/greek/OFL-NotoSans.txt +93 -0
  32. package/dist/fonts/greek/OFL-NotoSansMono.txt +93 -0
  33. package/dist/fonts/greek/fonts.css +27 -0
  34. package/dist/fonts/greek/noto-sans-italic.woff2 +0 -0
  35. package/dist/fonts/greek/noto-sans-mono.woff2 +0 -0
  36. package/dist/fonts/greek/noto-sans.woff2 +0 -0
  37. package/dist/fonts/greek-ext/OFL-NotoSans.txt +93 -0
  38. package/dist/fonts/greek-ext/OFL-NotoSansMono.txt +93 -0
  39. package/dist/fonts/greek-ext/fonts.css +27 -0
  40. package/dist/fonts/greek-ext/noto-sans-italic.woff2 +0 -0
  41. package/dist/fonts/greek-ext/noto-sans-mono.woff2 +0 -0
  42. package/dist/fonts/greek-ext/noto-sans.woff2 +0 -0
  43. package/dist/fonts/josefin/OFL-JosefinSans.txt +93 -0
  44. package/dist/fonts/josefin/fonts.css +8 -0
  45. package/dist/fonts/josefin/josefin-sans.woff2 +0 -0
  46. package/dist/fonts/lang.css +181 -0
  47. package/dist/fonts/latin/OFL-NotoSans.txt +93 -0
  48. package/dist/fonts/latin/OFL-NotoSansMono.txt +93 -0
  49. package/dist/fonts/latin/fonts.css +27 -0
  50. package/dist/fonts/latin/noto-sans-italic.woff2 +0 -0
  51. package/dist/fonts/latin/noto-sans-mono.woff2 +0 -0
  52. package/dist/fonts/latin/noto-sans.woff2 +0 -0
  53. package/dist/fonts/latin-ext/OFL-NotoSans.txt +93 -0
  54. package/dist/fonts/latin-ext/OFL-NotoSansMono.txt +93 -0
  55. package/dist/fonts/latin-ext/fonts.css +27 -0
  56. package/dist/fonts/latin-ext/noto-sans-italic.woff2 +0 -0
  57. package/dist/fonts/latin-ext/noto-sans-mono.woff2 +0 -0
  58. package/dist/fonts/latin-ext/noto-sans.woff2 +0 -0
  59. package/dist/fonts/math/OFL-NotoSansMath.txt +93 -0
  60. package/dist/fonts/math/fonts.css +8 -0
  61. package/dist/fonts/math/noto-sans-math.woff2 +0 -0
  62. package/dist/fonts/pkg/GFL.txt +29 -0
  63. package/dist/fonts/pkg/README-lm.txt +27 -0
  64. package/dist/fonts/pkg/fonts.css +8 -0
  65. package/dist/fonts/pkg/lmsans10-regular.woff2 +0 -0
  66. package/dist/fonts/vietnamese/OFL-NotoSans.txt +93 -0
  67. package/dist/fonts/vietnamese/OFL-NotoSansMono.txt +93 -0
  68. package/dist/fonts/vietnamese/fonts.css +27 -0
  69. package/dist/fonts/vietnamese/noto-sans-italic.woff2 +0 -0
  70. package/dist/fonts/vietnamese/noto-sans-mono.woff2 +0 -0
  71. package/dist/fonts/vietnamese/noto-sans.woff2 +0 -0
  72. package/dist/latex/dataimago-tokens.sty +14 -2
  73. package/dist/latex/dataimago.sty +6 -1
  74. package/dist/outputs.json +105 -21
  75. package/dist/quarto/_extensions/dataimago/monograph/_extension.yml +35 -0
  76. package/dist/quarto/_extensions/dataimago/monograph/dataimago-base.sty +275 -0
  77. package/dist/quarto/_extensions/dataimago/monograph/dataimago-monograph.sty +270 -0
  78. package/dist/quarto/_extensions/dataimago/monograph/dataimago-tokens.sty +134 -0
  79. package/dist/quarto/_extensions/dataimago/monograph/filters/figure-plate.lua +52 -0
  80. package/dist/quarto/_extensions/dataimago/monograph/filters/matter.lua +170 -0
  81. package/dist/quarto/_extensions/dataimago/monograph/filters/pkg-name.lua +17 -0
  82. package/dist/quarto/_extensions/dataimago/monograph/fonts/GFL.txt +29 -0
  83. package/dist/quarto/_extensions/dataimago/monograph/fonts/JosefinSans-VF.ttf +0 -0
  84. package/dist/quarto/_extensions/dataimago/monograph/fonts/NotoSans-Italic-VF.ttf +0 -0
  85. package/dist/quarto/_extensions/dataimago/monograph/fonts/NotoSans-VF.ttf +0 -0
  86. package/dist/quarto/_extensions/dataimago/monograph/fonts/NotoSansMath-Regular.ttf +0 -0
  87. package/dist/quarto/_extensions/dataimago/monograph/fonts/NotoSansMono-VF.ttf +0 -0
  88. package/dist/quarto/_extensions/dataimago/monograph/fonts/OFL-JosefinSans.txt +93 -0
  89. package/dist/quarto/_extensions/dataimago/monograph/fonts/OFL-NotoSans.txt +93 -0
  90. package/dist/quarto/_extensions/dataimago/monograph/fonts/OFL-NotoSansMath.txt +93 -0
  91. package/dist/quarto/_extensions/dataimago/monograph/fonts/OFL-NotoSansMono.txt +93 -0
  92. package/dist/quarto/_extensions/dataimago/monograph/fonts/README-lm.txt +27 -0
  93. package/dist/quarto/_extensions/dataimago/monograph/fonts/lmsans10-regular.otf +0 -0
  94. package/dist/quarto/_extensions/dataimago/monograph/partials/before-body.tex +16 -0
  95. package/dist/quarto/_extensions/dataimago/monograph/partials/title.tex +27 -0
  96. package/dist/quarto/components.scss +14 -1
  97. package/dist/quarto/dataimago-dark.scss +23 -6
  98. package/dist/quarto/dataimago-light.scss +17 -6
  99. package/dist/tailwind-preset.js +4 -4
  100. package/dist/templates/KNOWLEDGE-explorer.md +1 -1
  101. package/dist/templates/KNOWLEDGE-framework.md +1 -1
  102. package/dist/templates/KNOWLEDGE-research.md +1 -1
  103. package/dist/templates/quarto_website/README.md +1 -1
  104. package/dist/templates/quarto_website/_quarto.yml.template +4 -2
  105. package/dist/templates/quarto_website/about.qmd.template +1 -1
  106. package/dist/templates/quarto_website/assets/css/lang.css +181 -0
  107. package/dist/templates/quarto_website/assets/js/custom-anchors.js +1 -1
  108. package/dist/templates/quarto_website/assets/js/logo-switch.js +1 -1
  109. package/dist/templates/quarto_website/assets/js/scroll.js +1 -1
  110. package/dist/templates/quarto_website/ethics.qmd.template +1 -1
  111. package/dist/templates/quarto_website/index.qmd.template +1 -1
  112. package/dist/tokens.css +3 -3
  113. package/dist/tokens.scss +3 -3
  114. package/package.json +6 -3
@@ -0,0 +1,703 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dataimago-figures — the one figure build for PSTricks sources (spec: "The figure toolkit and build").
4
+ *
5
+ * dataimago-figures build [<dir|file>...] [--toolkit <dir>] [--force]
6
+ * dataimago-figures build --check [...] exit 1 if any figure is out of date (needs no TeX)
7
+ * dataimago-figures --help
8
+ * node tools/formats/dataimago-figures.mjs --self-test (repo only; needs TeX, else skipped)
9
+ *
10
+ * Each standalone source compiles ONCE under XeLaTeX into <name>.pdf (the PDF), <name>.svg (the HTML,
11
+ * fonts as paths) and <name>.png (300 dpi, slides and previews):
12
+ *
13
+ * xelatex -no-pdf -> .xdv xdvipdfmx -> .pdf
14
+ * dvisvgm --no-fonts --exact-bbox (on the .xdv) -> .svg pdftocairo -png -transp -r 300 (on the .pdf) -> .png (RGBA)
15
+ *
16
+ * Composites (a source that \includegraphics other figures) are the one exception for the SVG: dvisvgm
17
+ * silently drops the PDF images an .xdv includes ("6 PDF specials ignored") and cannot read PDFs here
18
+ * (Ghostscript >= 10.01 is unsupported, mutool absent), so a composite's SVG is `pdftocairo -svg` of its
19
+ * PDF instead, which also draws glyphs as paths.
20
+ *
21
+ * The toolkit directory (default: where the work's .dataimago/design.json puts `figures/`) holds
22
+ * dataimago-figure.sty, dataimago-tokens.sty and the static Noto fonts; it is put on TEXINPUTS,
23
+ * OPENTYPEFONTS and TTFONTS for the run. A figure that embeds any font outside the house faces
24
+ * (see ALLOWED_FONT) fails the build, naming the source and the font.
25
+ *
26
+ * A `figures.json` beside the sources is optional:
27
+ * { "<source>.tex": { "out": "<dir>", "name": "<stable>", "inputs": ["<dir>"],
28
+ * "variants": { "<id>": { "define": "\\panelletterstrue", "out": "<dir>", "name": "<n>", "inputs": [...] } } } }
29
+ * Every field is optional and every path is relative to the source's directory. Without it, outputs land
30
+ * beside the sources under the source's own name. `define` is TeX run at \begin{document}; `inputs` are
31
+ * extra directories searched first for \includegraphics (a composite's lettered variant points at the
32
+ * lettered panels).
33
+ *
34
+ * Panels build before the composites that \includegraphics them (by output name). A content hash of the
35
+ * source, its local \input files, the toolkit and its dependencies is kept in `.dataimago-figures.json`
36
+ * in each output directory; unchanged figures are skipped.
37
+ *
38
+ * Exit codes: 0 ok, 1 build or check failure, 2 usage or configuration error (including missing tools).
39
+ * Node built-ins only: this file ships in @dataimago/css dist/bin/ like dataimago-sync.
40
+ */
41
+
42
+ import { spawnSync } from 'node:child_process';
43
+ import { createHash } from 'node:crypto';
44
+ import fs from 'node:fs';
45
+ import os from 'node:os';
46
+ import path from 'node:path';
47
+ import zlib from 'node:zlib';
48
+ import { fileURLToPath } from 'node:url';
49
+
50
+ const MANIFEST = '.dataimago-figures.json';
51
+ const TOOLS = ['xelatex', 'xdvipdfmx', 'dvisvgm', 'pdffonts', 'pdftocairo'];
52
+ /** Bump when the pipeline's output changes, so existing figures rebuild without --force. 2: RGBA PNGs. */
53
+ const PIPELINE_VERSION = 2;
54
+ const INSTALL_HINT = 'Install TeX Live or MacTeX (xelatex, xdvipdfmx, dvisvgm) and poppler (pdffonts, pdftocairo), e.g. `brew install texlive poppler`.';
55
+ const TOOLKIT_FILES = ['dataimago-figure.sty', 'dataimago-tokens.sty', 'NotoSans-Regular.otf', 'NotoSans-Italic.otf', 'NotoSans-Bold.otf', 'NotoSans-BoldItalic.otf', 'NotoSansMath-Regular.ttf'];
56
+
57
+ /**
58
+ * The font allowlist, matched against pdffonts names with the subset tag ("ABCDEF+") and the
59
+ * "-Identity-H" CID suffix removed. House faces only: Noto Sans and Noto Sans Math.
60
+ * PSTricksDotFont is the one exception, explicitly: PSTricks' \psdots (dotstyle *, o, square, ...) draws
61
+ * every dot as a glyph of that generated Type 1 font. It has no text, no letterforms and no licence
62
+ * question, and it cannot be avoided without giving up \psdots, so it is allowed by name.
63
+ */
64
+ /** The exceptions named above, in one place: the allowlist is built from them, and a checker that must allow one by name (tools/pdf-fixture-check.mjs) imports them. */
65
+ export const FIGURE_FONT_EXCEPTIONS = ['PSTricksDotFont'];
66
+ const ALLOWED_FONT = new RegExp(`^(NotoSans-(Regular|Italic|Bold|BoldItalic)|NotoSansMath-Regular|${FIGURE_FONT_EXCEPTIONS.join('|')})$`);
67
+
68
+ class ConfigError extends Error {}
69
+
70
+ const USAGE = `dataimago-figures — build PSTricks figure sources into .pdf, .svg and .png
71
+
72
+ dataimago-figures build [<dir|file>...] compile each standalone .tex source (default: the current directory)
73
+ dataimago-figures build --check [...] exit 1 if any figure is out of date (no TeX needed)
74
+ dataimago-figures build --force [...] rebuild even when the content hash matches
75
+ dataimago-figures build --toolkit <dir> use <dir> as the toolkit (default: the \`figures/\` destination
76
+ in .dataimago/design.json, searched upward from here)
77
+ dataimago-figures --help this text
78
+
79
+ A source is a .tex file containing \\documentclass. Outputs land beside it, or where an optional
80
+ figures.json beside the sources says. Needs xelatex, xdvipdfmx, dvisvgm, pdffonts and pdftocairo.
81
+ ${INSTALL_HINT}`;
82
+
83
+ // ---------------------------------------------------------------- helpers
84
+
85
+ const sha = (...parts) => {
86
+ const h = createHash('sha256');
87
+ for (const p of parts) { h.update(p); h.update('\0'); }
88
+ return h.digest('hex');
89
+ };
90
+
91
+ function readJson(file, what) {
92
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
93
+ catch (e) { throw new ConfigError(`cannot read ${what} at ${file}: ${e.message}`); }
94
+ }
95
+
96
+ /** Find an executable on PATH (our own scan, so tests can narrow PATH). */
97
+ function which(cmd, env = process.env) {
98
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
99
+ if (!dir) continue;
100
+ const p = path.join(dir, cmd);
101
+ try { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return p; } catch { /* next */ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function parseArgs(argv) {
107
+ const o = { cmd: null, paths: [], check: false, force: false, toolkit: null, help: false, selfTest: false };
108
+ for (let i = 0; i < argv.length; i++) {
109
+ const a = argv[i];
110
+ if (a === '--help' || a === '-h') o.help = true;
111
+ else if (a === '--self-test') o.selfTest = true;
112
+ else if (a === '--check') o.check = true;
113
+ else if (a === '--force') o.force = true;
114
+ else if (a === '--toolkit') {
115
+ if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new ConfigError('--toolkit needs a directory');
116
+ o.toolkit = path.resolve(argv[++i]);
117
+ } else if (a.startsWith('--')) throw new ConfigError(`unknown argument ${a} (see --help)`);
118
+ else if (o.cmd === null) o.cmd = a;
119
+ else o.paths.push(a);
120
+ }
121
+ return o;
122
+ }
123
+
124
+ /** The toolkit: --toolkit, else the `figures/` output of the nearest .dataimago/design.json. */
125
+ function resolveToolkit(explicit, cwd) {
126
+ let dir = explicit;
127
+ if (!dir) {
128
+ for (let d = cwd; ; d = path.dirname(d)) {
129
+ const cfg = path.join(d, '.dataimago', 'design.json');
130
+ if (fs.existsSync(cfg)) {
131
+ const outputs = readJson(cfg, '.dataimago/design.json').outputs ?? {};
132
+ const key = Object.keys(outputs).find((k) => k.replace(/\/+$/, '') === 'figures');
133
+ if (!key) throw new ConfigError(`${cfg} lists no \`figures/\` output; run dataimago-sync, or pass --toolkit <dir>`);
134
+ dir = path.resolve(d, outputs[key]);
135
+ break;
136
+ }
137
+ if (path.dirname(d) === d) throw new ConfigError('no .dataimago/design.json found; pass --toolkit <dir>');
138
+ }
139
+ }
140
+ const missing = TOOLKIT_FILES.filter((f) => !fs.existsSync(path.join(dir, f)));
141
+ if (missing.length) throw new ConfigError(`toolkit ${dir} is missing ${missing.join(', ')} (run dataimago-sync, or pass --toolkit <dir>)`);
142
+ return dir;
143
+ }
144
+
145
+ function toolkitHash(dir) {
146
+ // TEXINPUTS searches toolkit//, so every .sty/.otf/.ttf at any depth counts.
147
+ const files = [];
148
+ const walk = (d) => {
149
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
150
+ const p = path.join(d, e.name);
151
+ if (e.isDirectory()) walk(p);
152
+ else if (/\.(sty|otf|ttf|cls|def|cfg|tex)$/i.test(e.name)) files.push(p);
153
+ }
154
+ };
155
+ walk(dir);
156
+ files.sort();
157
+ return sha(...files.flatMap((f) => [path.relative(dir, f), fs.readFileSync(f)]));
158
+ }
159
+
160
+ // ---------------------------------------------------------------- discovery
161
+
162
+ function findSources(inputs, cwd) {
163
+ const out = new Set();
164
+ const walk = (dir) => {
165
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
166
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
167
+ const p = path.join(dir, e.name);
168
+ if (e.isDirectory()) walk(p);
169
+ else if (e.name.endsWith('.tex') && /\\documentclass/.test(fs.readFileSync(p, 'utf8'))) out.add(p);
170
+ }
171
+ };
172
+ for (const raw of inputs.length ? inputs : ['.']) {
173
+ const p = path.resolve(cwd, raw);
174
+ if (!fs.existsSync(p)) throw new ConfigError(`no such file or directory: ${raw}`);
175
+ if (fs.statSync(p).isDirectory()) walk(p);
176
+ else if (p.endsWith('.tex')) out.add(p);
177
+ else throw new ConfigError(`not a .tex source: ${raw}`);
178
+ }
179
+ return [...out].sort();
180
+ }
181
+
182
+ const strList = (v, what) => {
183
+ if (v === undefined) return [];
184
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) throw new ConfigError(`${what} must be an array of paths`);
185
+ return v;
186
+ };
187
+
188
+ /** One job per (source, variant): { src, srcDir, label, name, outDir, define, inputs }. */
189
+ function planJobs(sources, cwd) {
190
+ const configs = new Map();
191
+ const cfgFor = (dir) => {
192
+ if (!configs.has(dir)) {
193
+ const f = path.join(dir, 'figures.json');
194
+ const c = fs.existsSync(f) ? readJson(f, 'figures.json') : {};
195
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) throw new ConfigError(`${f} must be an object keyed by source file`);
196
+ configs.set(dir, c);
197
+ }
198
+ return configs.get(dir);
199
+ };
200
+ const jobs = [];
201
+ for (const src of sources) {
202
+ const srcDir = path.dirname(src);
203
+ const base = path.basename(src);
204
+ const stem = base.replace(/\.tex$/, '');
205
+ const entry = cfgFor(srcDir)[base] ?? {};
206
+ const where = `${path.join(srcDir, 'figures.json')} entry ${base}`;
207
+ for (const k of ['out', 'name', 'define']) if (entry[k] !== undefined && typeof entry[k] !== 'string') throw new ConfigError(`${where}: ${k} must be a string`);
208
+ const entryName = entry.name ?? stem;
209
+ const entryInputs = strList(entry.inputs, `${where}: inputs`);
210
+ const mk = (label, v) => ({
211
+ src, srcDir, label,
212
+ name: v.name ?? entryName,
213
+ outDir: path.resolve(srcDir, v.out ?? entry.out ?? '.'),
214
+ define: v.define ?? '',
215
+ inputs: [...strList(v.inputs, `${where}: ${label} inputs`), ...entryInputs].map((d) => path.resolve(srcDir, d)),
216
+ });
217
+ jobs.push(mk('', { define: entry.define }));
218
+ const variants = entry.variants ?? {};
219
+ if (variants === null || typeof variants !== 'object' || Array.isArray(variants)) throw new ConfigError(`${where}: variants must be an object`);
220
+ for (const [id, v] of Object.entries(variants)) {
221
+ for (const k of ['out', 'name', 'define']) if (v[k] !== undefined && typeof v[k] !== 'string') throw new ConfigError(`${where}: variant ${id}: ${k} must be a string`);
222
+ jobs.push(mk(id, v));
223
+ }
224
+ }
225
+ const seen = new Map();
226
+ for (const j of jobs) {
227
+ const key = path.join(j.outDir, j.name);
228
+ if (seen.has(key)) throw new ConfigError(`two figures write ${key}.* (${path.basename(seen.get(key).src)} ${seen.get(key).label || 'base'} and ${path.basename(j.src)} ${j.label || 'base'})`);
229
+ seen.set(key, j);
230
+ j.id = `${path.relative(cwd, j.src).startsWith('..') ? j.src : path.relative(cwd, j.src)}${j.label ? ` [${j.label}]` : ''}`;
231
+ }
232
+ return jobs;
233
+ }
234
+
235
+ /** Local files the source pulls in with \input / \InputIfFileExists (recursive), as absolute paths. */
236
+ function localInputs(src) {
237
+ const found = new Set();
238
+ const visit = (file) => {
239
+ const text = fs.readFileSync(file, 'utf8');
240
+ for (const m of text.matchAll(/\\(?:input|InputIfFileExists)\s*\{([^}]+)\}/g)) {
241
+ for (const cand of [m[1], `${m[1]}.tex`]) {
242
+ const p = path.resolve(path.dirname(src), cand);
243
+ if (fs.existsSync(p) && fs.statSync(p).isFile() && !found.has(p)) { found.add(p); visit(p); break; }
244
+ }
245
+ }
246
+ };
247
+ visit(src);
248
+ return [...found].sort();
249
+ }
250
+
251
+ /** Order jobs so every figure comes after the figures it \includegraphics. Returns { order, deps }. */
252
+ function orderJobs(jobs) {
253
+ const byName = new Map();
254
+ for (const j of jobs) byName.set(j.name, [...(byName.get(j.name) ?? []), j]);
255
+ const deps = new Map();
256
+ for (const j of jobs) {
257
+ const text = fs.readFileSync(j.src, 'utf8').replace(/(^|[^\\])%.*$/gm, '$1');
258
+ const wanted = new Map();
259
+ for (const m of text.matchAll(/\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/g)) {
260
+ wanted.set(path.basename(m[1]).replace(/\.(pdf|png|svg)$/i, ''), m[1].trim());
261
+ }
262
+ j.composite = wanted.size > 0;
263
+ j.externals = [];
264
+ const mine = [];
265
+ for (const [n, written] of wanted) {
266
+ let cands = (byName.get(n) ?? []).filter((d) => d !== j);
267
+ if (cands.length > 1) {
268
+ // Resolve as TEXINPUTS does: the figure's `inputs` directories, in order, first.
269
+ for (const dir of j.inputs) {
270
+ const hit = cands.filter((d) => d.outDir === dir);
271
+ if (hit.length) { cands = hit; break; }
272
+ }
273
+ }
274
+ if (cands.length > 1) {
275
+ throw new ConfigError(`${j.id}: \\includegraphics{${written}} matches ${cands.length} built figures (${cands.map((d) => path.join(d.outDir, d.name)).join(', ')}); ` +
276
+ 'set `inputs` for it in figures.json to say which directory to use.');
277
+ }
278
+ if (cands.length === 1) mine.push(cands[0]);
279
+ else {
280
+ // Not built by any job: a user's own image. Its bytes belong in the hash.
281
+ const dirs = [...j.inputs, j.srcDir];
282
+ const hit = dirs.flatMap((d) => ['', '.pdf', '.png', '.jpg', '.jpeg', '.svg'].map((x) => path.resolve(d, written + x)))
283
+ .find((f) => fs.existsSync(f) && fs.statSync(f).isFile());
284
+ if (hit) j.externals.push(hit);
285
+ }
286
+ }
287
+ j.resolved = mine;
288
+ deps.set(j, mine);
289
+ }
290
+ const order = [];
291
+ const state = new Map();
292
+ const visit = (j, trail) => {
293
+ if (state.get(j) === 2) return;
294
+ if (state.get(j) === 1) throw new ConfigError(`figures include each other: ${[...trail, j].map((x) => x.id).join(' -> ')}`);
295
+ state.set(j, 1);
296
+ for (const d of deps.get(j)) visit(d, [...trail, j]);
297
+ state.set(j, 2);
298
+ order.push(j);
299
+ };
300
+ for (const j of jobs) visit(j, []);
301
+ return { order, deps };
302
+ }
303
+
304
+ // ---------------------------------------------------------------- manifest
305
+
306
+ const readManifest = (dir) => {
307
+ try { return JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8')).figures ?? {}; } catch { return {}; }
308
+ };
309
+ function writeManifest(dir, name, entry) {
310
+ const figures = readManifest(dir);
311
+ figures[name] = entry;
312
+ const sorted = Object.fromEntries(Object.keys(figures).sort().map((k) => [k, figures[k]]));
313
+ fs.writeFileSync(path.join(dir, MANIFEST), `${JSON.stringify({ version: 1, figures: sorted }, null, 2)}\n`);
314
+ }
315
+ const outputsOf = (j) => ['pdf', 'svg', 'png'].map((e) => path.join(j.outDir, `${j.name}.${e}`));
316
+ const isFresh = (j) => outputsOf(j).every((f) => fs.existsSync(f)) && readManifest(j.outDir)[j.name]?.hash === j.hash;
317
+
318
+ // ---------------------------------------------------------------- building
319
+
320
+ function texEnv(job, toolkit, allJobs) {
321
+ const dirs = [`${toolkit}//`, ...job.inputs, job.srcDir, ...new Set(allJobs.map((j) => j.outDir))];
322
+ const fonts = `${toolkit}//${path.delimiter}`;
323
+ return {
324
+ ...process.env,
325
+ TEXINPUTS: dirs.join(path.delimiter) + path.delimiter,
326
+ OPENTYPEFONTS: fonts,
327
+ TTFONTS: fonts,
328
+ SOURCE_DATE_EPOCH: '1767225600',
329
+ FORCE_SOURCE_DATE: '1',
330
+ };
331
+ }
332
+
333
+ /** A path for messages: relative when it stays inside cwd, else absolute. */
334
+ const show = (p, cwd) => { const r = path.relative(cwd, p); return r.startsWith('..') ? p : r || '.'; };
335
+
336
+ const tail = (text, n) => text.trim().split('\n').slice(-n).join('\n');
337
+
338
+ /** The first TeX error (file:line: message, or "! message") from a log, with its context lines. */
339
+ function texError(log) {
340
+ const lines = log.split('\n');
341
+ const i = lines.findIndex((l) => /^\S+:\d+: /.test(l) || l.startsWith('! '));
342
+ return i === -1 ? tail(log, 8) : lines.slice(i, i + 4).join('\n');
343
+ }
344
+
345
+ /** Fonts embedded in a PDF, normalised, with pdffonts' raw name for messages. */
346
+ function embeddedFonts(pdf, pdffonts) {
347
+ const r = spawnSync(pdffonts, [pdf], { encoding: 'utf8' });
348
+ if (r.status !== 0) throw new Error(`pdffonts failed on ${pdf}: ${tail(r.stderr || r.stdout, 3)}`);
349
+ return r.stdout.split('\n').slice(2).filter((l) => l.trim()).map((l) => {
350
+ const cols = l.trim().split(/\s+/);
351
+ const raw = cols[0];
352
+ const name = raw.replace(/^[A-Z]{6}\+/, '').replace(/-Identity-[HV]$/, '');
353
+ return { raw, name, embedded: cols.includes('yes') && cols.indexOf('yes') === cols.findIndex((c) => c === 'yes' || c === 'no') };
354
+ });
355
+ }
356
+
357
+ function buildJob(job, toolkit, allJobs, tools) {
358
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'di-fig-build-'));
359
+ try {
360
+ const env = texEnv(job, toolkit, allJobs);
361
+ const define = job.define ? `\\AtBeginDocument{${job.define}}` : '';
362
+ const tex = spawnSync(tools.xelatex, [
363
+ '-no-pdf', '-interaction=nonstopmode', '-halt-on-error', '-file-line-error',
364
+ `-jobname=${job.name}`, `-output-directory=${work}`, `${define}\\input{${path.basename(job.src)}}`,
365
+ ], { cwd: job.srcDir, env, encoding: 'utf8' });
366
+ const xdv = path.join(work, `${job.name}.xdv`);
367
+ if (tex.status !== 0 || !fs.existsSync(xdv)) {
368
+ let log = tex.stdout;
369
+ try { log = fs.readFileSync(path.join(work, `${job.name}.log`), 'utf8'); } catch { /* use stdout */ }
370
+ return `${job.id}: TeX error (xelatex, log ${job.name}.log)\n${texError(log)}`;
371
+ }
372
+ const pdf = path.join(work, `${job.name}.pdf`);
373
+ const svgOut = path.join(work, `${job.name}.svg`);
374
+ const run = (label, bin, args) => {
375
+ const r = spawnSync(bin, args, { cwd: job.srcDir, env, encoding: 'utf8' });
376
+ return r.status === 0 ? null : `${job.id}: ${label} failed\n${tail(r.stderr || r.stdout, 6)}`;
377
+ };
378
+ const bad0 = run('xdvipdfmx', tools.xdvipdfmx, ['-q', '-o', pdf, xdv])
379
+ ?? (job.composite
380
+ ? run('pdftocairo', tools.pdftocairo, ['-svg', pdf, svgOut])
381
+ : run('dvisvgm', tools.dvisvgm, ['--no-fonts', '--exact-bbox', '-o', svgOut, xdv]));
382
+ if (bad0) return bad0;
383
+ // The font allowlist: the point of the build. Checked before anything is published.
384
+ const bad = embeddedFonts(pdf, tools.pdffonts).filter((f) => !ALLOWED_FONT.test(f.name) || !f.embedded);
385
+ if (bad.length) {
386
+ return `${job.id}: embeds a font outside the house faces: ${[...new Set(bad.map((f) => f.raw))].join(', ')} ` +
387
+ '(allowed: NotoSans-{Regular,Italic,Bold,BoldItalic}, NotoSansMath-Regular, PSTricksDotFont). ' +
388
+ 'Remove the \\usepackage or \\fontfamily that selects it.';
389
+ }
390
+ const svg = fs.readFileSync(path.join(work, `${job.name}.svg`), 'utf8');
391
+ if (/<text[\s>]/.test(svg)) return `${job.id}: the SVG contains <text> elements; fonts must be paths`;
392
+ const png = spawnSync(tools.pdftocairo, ['-png', '-transp', '-r', '300', '-singlefile', pdf, path.join(work, job.name)], { encoding: 'utf8' });
393
+ if (png.status !== 0) return `${job.id}: pdftocairo (png) failed\n${tail(png.stderr, 4)}`;
394
+ fs.mkdirSync(job.outDir, { recursive: true });
395
+ for (const ext of ['pdf', 'svg', 'png']) fs.copyFileSync(path.join(work, `${job.name}.${ext}`), path.join(job.outDir, `${job.name}.${ext}`));
396
+ writeManifest(job.outDir, job.name, { source: path.relative(job.outDir, job.src), hash: job.hash });
397
+ return null;
398
+ } finally {
399
+ fs.rmSync(work, { recursive: true, force: true });
400
+ }
401
+ }
402
+
403
+ /** Fingerprint each job in dependency order: source, local inputs, define, toolkit, dependency hashes. */
404
+ function fingerprint(order, deps, tkHash) {
405
+ for (const j of order) {
406
+ const files = [j.src, ...localInputs(j.src)];
407
+ const config = JSON.stringify({
408
+ define: j.define, inputs: j.inputs.map((d) => path.relative(j.srcDir, d)), name: j.name,
409
+ out: path.relative(j.srcDir, j.outDir), variant: j.label, composite: j.composite,
410
+ });
411
+ const externals = j.externals.flatMap((f) => [path.relative(j.srcDir, f), fs.readFileSync(f)]);
412
+ j.hash = sha(`pipeline:${PIPELINE_VERSION}`, config, ...externals, tkHash, j.define, ...files.flatMap((f) => [path.basename(f), fs.readFileSync(f)]), ...deps.get(j).map((d) => d.hash));
413
+ }
414
+ }
415
+
416
+ function main(argv, cwd = process.cwd(), env = process.env) {
417
+ let opts;
418
+ try { opts = parseArgs(argv); } catch (e) { console.error(`dataimago-figures: ${e.message}`); return 2; }
419
+ if (opts.help || !opts.cmd) { console.log(USAGE); return opts.help ? 0 : 2; }
420
+ try {
421
+ if (opts.cmd !== 'build') throw new ConfigError(`unknown command ${opts.cmd} (see --help)`);
422
+ const toolkit = resolveToolkit(opts.toolkit, cwd);
423
+ const jobs = planJobs(findSources(opts.paths, cwd), cwd);
424
+ const { order, deps } = orderJobs(jobs);
425
+
426
+ let tools = null;
427
+ if (!opts.check) {
428
+ tools = {};
429
+ const missing = [];
430
+ for (const t of TOOLS) { tools[t] = which(t, env); if (!tools[t]) missing.push(t); }
431
+ if (missing.length) throw new ConfigError(`missing required tool${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}. ${INSTALL_HINT}`);
432
+ }
433
+ fingerprint(order, deps, toolkitHash(toolkit));
434
+
435
+ if (opts.check) {
436
+ const stale = order.filter((j) => !isFresh(j));
437
+ for (const j of stale) console.error(`dataimago-figures: out of date: ${j.id} -> ${show(path.join(j.outDir, j.name), cwd)}.*`);
438
+ console.log(`figures: ${order.length - stale.length} up to date, ${stale.length} out of date`);
439
+ return stale.length ? 1 : 0;
440
+ }
441
+
442
+ let built = 0; let upToDate = 0; const failed = []; const failedJobs = new Set();
443
+ for (const j of order) {
444
+ const blocker = deps.get(j).find((d) => failedJobs.has(d));
445
+ if (blocker) { failed.push(`${j.id}: skipped, ${blocker.id} failed`); failedJobs.add(j); continue; }
446
+ if (!opts.force && isFresh(j)) { upToDate += 1; continue; }
447
+ const err = buildJob(j, toolkit, order, tools);
448
+ if (err) { failed.push(err); failedJobs.add(j); continue; }
449
+ built += 1;
450
+ console.log(`built ${show(path.join(j.outDir, j.name), cwd)}.{pdf,svg,png}`);
451
+ }
452
+ for (const f of failed) console.error(`dataimago-figures: ${f}`);
453
+ console.log(`figures: ${built} built, ${upToDate} up to date, ${failed.length} failed`);
454
+ return failed.length ? 1 : 0;
455
+ } catch (e) {
456
+ if (!(e instanceof ConfigError)) throw e;
457
+ console.error(`dataimago-figures: ${e.message}`);
458
+ return 2;
459
+ }
460
+ }
461
+
462
+ // ---------------------------------------------------------------- self-test
463
+
464
+ async function selfTest() {
465
+ const here = path.dirname(fileURLToPath(import.meta.url));
466
+ const repo = path.resolve(here, '..', '..');
467
+ const fixtures = path.join(repo, 'tools', '__fixtures__', 'figures');
468
+ if (!fs.existsSync(fixtures)) {
469
+ console.error('dataimago-figures --self-test runs from the dataimago-design repo (tools/formats/).');
470
+ process.exit(2);
471
+ }
472
+ const missing = TOOLS.filter((t) => !which(t));
473
+ if (!which('xelatex') || (missing.length && process.env.DATAIMAGO_REQUIRE_TEX === '1')) {
474
+ const msg = `dataimago-figures self-test: missing ${missing.join(', ')}`;
475
+ if (process.env.DATAIMAGO_REQUIRE_TEX === '1') {
476
+ console.error(`${msg} and DATAIMAGO_REQUIRE_TEX=1`);
477
+ process.exit(1);
478
+ }
479
+ console.log('dataimago-figures self-test: SKIPPED (xelatex not found). It runs in the PDF job.');
480
+ return;
481
+ }
482
+ if (missing.length) {
483
+ console.error(`dataimago-figures self-test: cannot run, missing ${missing.join(', ')}. ${INSTALL_HINT}`);
484
+ process.exit(2);
485
+ }
486
+ const assert = (await import('node:assert/strict')).default;
487
+ const pdffonts = which('pdffonts');
488
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'di-fig-'));
489
+ const run = (args, { cwd = tmp, env = process.env } = {}) =>
490
+ spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...args], { cwd, encoding: 'utf8', env });
491
+ const out = (r) => `${r.stdout}\n${r.stderr}`;
492
+
493
+ // The toolkit as Task 6 ships it: figures/ = the two .sty, the static OTFs and Math, the OFL texts.
494
+ const toolkit = path.join(tmp, 'toolkit');
495
+ fs.mkdirSync(toolkit);
496
+ const fontDir = path.join(repo, 'packages', 'css', 'fonts');
497
+ for (const f of ['dataimago-figure.sty', 'dataimago-tokens.sty']) fs.copyFileSync(path.join(repo, 'src', 'latex', f), path.join(toolkit, f));
498
+ for (const f of TOOLKIT_FILES.filter((x) => /\.(otf|ttf)$/.test(x))) fs.copyFileSync(path.join(fontDir, 'latex', f), path.join(toolkit, f));
499
+ for (const f of ['OFL-NotoSans.txt', 'OFL-NotoSansMath.txt']) fs.copyFileSync(path.join(fontDir, f), path.join(toolkit, f));
500
+
501
+ const work = path.join(tmp, 'work');
502
+ fs.cpSync(fixtures, work, { recursive: true });
503
+ const badDir = path.join(tmp, 'bad');
504
+ fs.mkdirSync(badDir);
505
+ fs.renameSync(path.join(work, 'bad-font.tex'), path.join(badDir, 'bad-font.tex'));
506
+ const tk = ['--toolkit', toolkit];
507
+ const summary = (r) => { const m = /figures: (\d+) built, (\d+) up to date, (\d+) failed/.exec(r.stdout); return m ? m.slice(1).map(Number) : null; };
508
+ const fontsOf = (rel) => embeddedFonts(path.join(work, rel), pdffonts);
509
+
510
+ // 1. The panels and the composite build into .pdf, .svg, .png, and figures.json places them.
511
+ let r = run(['build', work, ...tk]);
512
+ assert.equal(r.status, 0, out(r));
513
+ assert.deepEqual(summary(r), [5, 0, 0], out(r));
514
+ const expected = ['build/kernel_slices', 'build/lettered/kernel_slices', 'panel-b', 'build/composite', 'build/lettered/composite'];
515
+ for (const e of expected) for (const ext of ['pdf', 'svg', 'png']) assert.ok(fs.existsSync(path.join(work, `${e}.${ext}`)), `${e}.${ext} exists`);
516
+ assert.ok(!fs.existsSync(path.join(work, 'panel-a.pdf')), 'a stable name replaces the source name');
517
+ assert.equal(fs.readFileSync(path.join(work, 'build/kernel_slices.png')).subarray(1, 4).toString(), 'PNG', 'a real PNG');
518
+ assert.equal(fs.readFileSync(path.join(work, 'build/kernel_slices.pdf')).subarray(0, 4).toString(), '%PDF', 'a real PDF');
519
+
520
+ // 2. Fonts as paths in the SVG; house faces only in the PDF (pst-plot, pst-node, pst-3dplot, \psdots all covered).
521
+ for (const e of expected) {
522
+ const svg = fs.readFileSync(path.join(work, `${e}.svg`), 'utf8');
523
+ assert.ok(svg.includes('<svg') && !/<text[\s>]/.test(svg), `${e}.svg has no <text>`);
524
+ assert.ok(/<path/.test(svg), `${e}.svg draws paths`);
525
+ for (const f of fontsOf(`${e}.pdf`)) assert.match(f.name, ALLOWED_FONT, `${e}.pdf embeds only house faces (saw ${f.raw})`);
526
+ }
527
+ assert.ok(fontsOf('panel-b.pdf').some((f) => f.name === 'PSTricksDotFont'), '\\psdots does emit PSTricksDotFont (the explicit allowance is load-bearing)');
528
+ assert.ok(fontsOf('build/kernel_slices.pdf').some((f) => /^NotoSans-/.test(f.name)), 'Noto Sans is embedded');
529
+ assert.ok(fontsOf('build/kernel_slices.pdf').some((f) => f.name === 'NotoSansMath-Regular'), 'Noto Sans Math is embedded');
530
+
531
+ assert.ok((fs.readFileSync(path.join(work, 'build/composite.svg'), 'utf8').match(/<path/g) ?? []).length > 40, 'the composite SVG carries its panels (dvisvgm would drop included PDFs)');
532
+
533
+ // 3. The lettered variant differs from the unlettered one (\panelletterstrue ran), and the composite's
534
+ // lettered variant took the lettered panels (figures.json `inputs`).
535
+ assert.notEqual(fs.readFileSync(path.join(work, 'build/kernel_slices.svg'), 'utf8'), fs.readFileSync(path.join(work, 'build/lettered/kernel_slices.svg'), 'utf8'), 'the lettered panel carries its letter');
536
+ assert.notEqual(fs.readFileSync(path.join(work, 'build/composite.svg'), 'utf8'), fs.readFileSync(path.join(work, 'build/lettered/composite.svg'), 'utf8'), "the composite's lettered variant includes the lettered panels");
537
+
538
+ // 3b. PNGs are RGBA with a transparent page: colour type 6, and the top-left pixel's alpha is 0.
539
+ const png = fs.readFileSync(path.join(work, 'build/kernel_slices.png'));
540
+ assert.equal(png[25], 6, 'PNG colour type 6 (RGBA)');
541
+ assert.equal(png[24], 8, '8-bit PNG');
542
+ const idat = Buffer.concat((() => {
543
+ const parts = []; let o = 8;
544
+ while (o < png.length) { const len = png.readUInt32BE(o); if (png.subarray(o + 4, o + 8).toString() === 'IDAT') parts.push(png.subarray(o + 8, o + 8 + len)); o += 12 + len; }
545
+ return parts;
546
+ })());
547
+ assert.equal(zlib.inflateSync(idat)[4], 0, 'the corner pixel is fully transparent');
548
+
549
+ // 4. Dependency order: from a clean slate with the composite listed first, it still builds last.
550
+ const w2 = path.join(tmp, 'order');
551
+ fs.mkdirSync(w2);
552
+ for (const f of ['panel-b.tex', 'composite.tex']) fs.copyFileSync(path.join(fixtures, f), path.join(w2, f));
553
+ fs.copyFileSync(path.join(fixtures, 'panel-a.tex'), path.join(w2, 'kernel_slices.tex'));
554
+ r = run(['build', path.join(w2, 'composite.tex'), path.join(w2, 'panel-b.tex'), path.join(w2, 'kernel_slices.tex'), ...tk]);
555
+ assert.equal(r.status, 0, out(r));
556
+ assert.ok(r.stdout.split('\n').filter((l) => l.startsWith('built ')).at(-1).includes('composite'), `the composite builds last:\n${r.stdout}`);
557
+
558
+ // 5. A second run rebuilds nothing; --force rebuilds.
559
+ r = run(['build', work, ...tk]);
560
+ assert.equal(r.status, 0, out(r));
561
+ assert.deepEqual(summary(r), [0, 5, 0], `second run is a no-op:\n${out(r)}`);
562
+ assert.deepEqual(summary(run(['build', work, '--force', ...tk])), [5, 0, 0], '--force rebuilds');
563
+
564
+ // 6. --check passes when fresh and fails after a source edit; the edit reaches the composites that include it.
565
+ r = run(['build', '--check', work, ...tk]);
566
+ assert.equal(r.status, 0, out(r));
567
+ fs.appendFileSync(path.join(work, 'panel-b.tex'), '\n% edited\n');
568
+ r = run(['build', '--check', work, ...tk]);
569
+ assert.equal(r.status, 1, out(r));
570
+ assert.match(r.stderr, /panel-b\.tex/);
571
+ assert.match(r.stderr, /composite\.tex/, 'the composite is stale too: it includes the edited panel');
572
+ r = run(['build', work, ...tk]);
573
+ assert.equal(r.status, 0, out(r));
574
+ assert.equal(summary(r)[0], 3, `the edited panel and its two composites rebuilt:\n${out(r)}`);
575
+ fs.appendFileSync(path.join(toolkit, 'dataimago-figure.sty'), '\n% toolkit edited\n');
576
+ assert.equal(run(['build', '--check', work, ...tk]).status, 1, 'a toolkit change makes every figure stale');
577
+ fs.copyFileSync(path.join(repo, 'src', 'latex', 'dataimago-figure.sty'), path.join(toolkit, 'dataimago-figure.sty'));
578
+ assert.equal(run(['build', '--check', work, ...tk]).status, 0, 'restoring the toolkit makes the figures fresh again');
579
+ fs.mkdirSync(path.join(toolkit, 'sub'));
580
+ fs.writeFileSync(path.join(toolkit, 'sub', 'extra.sty'), '% a toolkit subdirectory file\n');
581
+ assert.equal(run(['build', '--check', work, ...tk]).status, 1, 'a file in a toolkit subdirectory (on TEXINPUTS toolkit//) counts');
582
+ fs.rmSync(path.join(toolkit, 'sub'), { recursive: true });
583
+
584
+ // 6b. Changing a composite variant's `inputs` makes it stale. NOTE: the dependency hash (it now includes a different panel) is what
585
+ // catches this case; the config hash is proven on its own in 6b2.
586
+ const cfgFile = path.join(work, 'figures.json');
587
+ const cfgText = fs.readFileSync(cfgFile, 'utf8');
588
+ assert.equal(run(['build', '--check', work, ...tk]).status, 0);
589
+ fs.writeFileSync(cfgFile, cfgText.replace('"inputs": ["build/lettered"]', '"inputs": []'));
590
+ r = run(['build', '--check', work, ...tk]);
591
+ assert.equal(r.status, 1, out(r));
592
+ assert.match(r.stderr, /composite\.tex \[lettered\]/);
593
+ fs.writeFileSync(cfgFile, cfgText);
594
+ assert.equal(run(['build', '--check', work, ...tk]).status, 0);
595
+
596
+ // 6b2. The config hash alone: a panel with no composites gets an `inputs` entry in figures.json (an empty dir). Source, toolkit,
597
+ // dependencies and outputs are untouched, so only the hashed config can flag it.
598
+ const w8 = path.join(tmp, 'cfgonly');
599
+ fs.mkdirSync(path.join(w8, 'extra'), { recursive: true });
600
+ fs.copyFileSync(path.join(fixtures, 'panel-b.tex'), path.join(w8, 'solo.tex'));
601
+ assert.equal(run(['build', w8, ...tk]).status, 0);
602
+ assert.equal(run(['build', '--check', w8, ...tk]).status, 0);
603
+ fs.writeFileSync(path.join(w8, 'figures.json'), JSON.stringify({ 'solo.tex': { inputs: ['extra'] } }));
604
+ r = run(['build', '--check', w8, ...tk]);
605
+ assert.equal(r.status, 1, `a config-only change (inputs) makes the figure stale:\n${out(r)}`);
606
+ fs.rmSync(path.join(w8, 'figures.json'));
607
+ assert.equal(run(['build', '--check', w8, ...tk]).status, 0, 'restoring the config makes it fresh again');
608
+
609
+ // 6c. An external image a source includes (not built by any figure) is hashed: editing it makes --check fail.
610
+ const w6 = path.join(tmp, 'external');
611
+ fs.mkdirSync(w6);
612
+ fs.copyFileSync(path.join(work, 'build/kernel_slices.png'), path.join(w6, 'pic.png'));
613
+ fs.writeFileSync(path.join(w6, 'uses-pic.tex'), '\\documentclass[border=2pt]{standalone}\n\\usepackage{dataimago-figure}\n\\usepackage{graphicx}\n\\begin{document}\n\\includegraphics[width=1in]{pic}\n\\end{document}\n');
614
+ r = run(['build', w6, ...tk]);
615
+ assert.equal(r.status, 0, out(r));
616
+ assert.equal(run(['build', '--check', w6, ...tk]).status, 0);
617
+ fs.appendFileSync(path.join(w6, 'pic.png'), Buffer.from([0]));
618
+ assert.equal(run(['build', '--check', w6, ...tk]).status, 1, 'editing an external included image makes the figure stale');
619
+
620
+ // 6d. Ambiguity is an error, not traversal luck: a composite with no `inputs` that names a panel built in two places.
621
+ const w7 = path.join(tmp, 'ambiguous');
622
+ fs.mkdirSync(w7);
623
+ for (const f of ['panel-a.tex', 'composite.tex', 'figures.json']) fs.copyFileSync(path.join(fixtures, f), path.join(w7, f));
624
+ fs.writeFileSync(path.join(w7, 'figures.json'), JSON.stringify({ 'panel-a.tex': { name: 'kernel_slices', out: 'build', variants: { lettered: { define: '\\panelletterstrue', out: 'build/lettered' } } } }));
625
+ r = run(['build', w7, ...tk]);
626
+ assert.equal(r.status, 2, out(r));
627
+ assert.match(r.stderr, /kernel_slices/);
628
+ assert.match(r.stderr, /build[\\/]lettered[\\/]kernel_slices/, 'names both candidates');
629
+ assert.match(r.stderr, /inputs/, 'tells the user to set inputs');
630
+
631
+ // 7. Review Focus 3: a figure that reaches for Computer Modern fails, naming the file and the font; nothing is published.
632
+ r = run(['build', badDir, ...tk]);
633
+ assert.equal(r.status, 1, out(r));
634
+ assert.match(r.stderr, /bad-font\.tex/, 'names the figure');
635
+ assert.match(r.stderr, /CMSS/, 'names the font');
636
+ assert.ok(!fs.existsSync(path.join(badDir, 'bad-font.pdf')), 'a rejected figure writes no outputs');
637
+
638
+ // 8. A TeX error names the source and the error, and does not stop independent figures.
639
+ const w3 = path.join(tmp, 'texerr');
640
+ fs.mkdirSync(w3);
641
+ fs.writeFileSync(path.join(w3, 'broken.tex'), '\\documentclass[border=2pt]{standalone}\n\\usepackage{dataimago-figure}\n\\begin{document}\n\\undefinedmacro\n\\end{document}\n');
642
+ fs.copyFileSync(path.join(fixtures, 'panel-b.tex'), path.join(w3, 'fine.tex'));
643
+ r = run(['build', w3, ...tk]);
644
+ assert.equal(r.status, 1, out(r));
645
+ assert.match(r.stderr, /broken\.tex/);
646
+ assert.match(r.stderr, /Undefined control sequence/);
647
+ assert.ok(fs.existsSync(path.join(w3, 'fine.pdf')), 'an independent figure still builds');
648
+
649
+ // 9. Configuration errors exit 2: no toolkit, a bad toolkit, an unknown flag, a bad figures.json.
650
+ assert.equal(run(['build', work]).status, 2, 'no toolkit and no design.json');
651
+ assert.equal(run(['build', work, '--toolkit', tmp]).status, 2, 'a directory without the toolkit files');
652
+ assert.equal(run(['build', work, '--nope']).status, 2);
653
+ const w4 = path.join(tmp, 'badcfg');
654
+ fs.mkdirSync(w4);
655
+ fs.copyFileSync(path.join(fixtures, 'panel-b.tex'), path.join(w4, 'p.tex'));
656
+ fs.writeFileSync(path.join(w4, 'figures.json'), '{ nope');
657
+ assert.equal(run(['build', w4, ...tk]).status, 2, 'unparseable figures.json');
658
+
659
+ // 10. The default toolkit is the `figures/` destination of .dataimago/design.json.
660
+ const w5 = path.join(tmp, 'viaconfig');
661
+ fs.mkdirSync(path.join(w5, '.dataimago'), { recursive: true });
662
+ fs.copyFileSync(path.join(fixtures, 'panel-b.tex'), path.join(w5, 'p.tex'));
663
+ fs.writeFileSync(path.join(w5, '.dataimago', 'design.json'), JSON.stringify({ package: '@dataimago/css', version: '0.0.0', outputs: { 'figures/': `${path.relative(w5, toolkit)}/` } }));
664
+ r = run(['build', '.'], { cwd: w5 });
665
+ assert.equal(r.status, 0, out(r));
666
+ assert.ok(fs.existsSync(path.join(w5, 'p.svg')));
667
+
668
+ // 11. A missing dvisvgm exits 2 naming it (PATH narrowed to links of every other tool; nothing is run).
669
+ const bin = path.join(tmp, 'bin');
670
+ fs.mkdirSync(bin);
671
+ for (const t of TOOLS.filter((x) => x !== 'dvisvgm')) fs.symlinkSync(which(t), path.join(bin, t));
672
+ fs.writeFileSync(path.join(w5, 'p.tex'), `${fs.readFileSync(path.join(w5, 'p.tex'), 'utf8')}\n% stale\n`);
673
+ r = run(['build', w5, ...tk], { env: { ...process.env, PATH: bin } });
674
+ assert.equal(r.status, 2, out(r));
675
+ assert.match(r.stderr, /dvisvgm/);
676
+ assert.match(r.stderr, /missing required tool: dvisvgm\./, 'only the missing tool is named');
677
+
678
+ // 12. The wrong-engine error halts the run (as dataimago-base.sty does): one error, nothing after it.
679
+ if (which('lualatex')) {
680
+ const w9 = path.join(tmp, 'engine');
681
+ fs.mkdirSync(w9);
682
+ fs.writeFileSync(path.join(w9, 'x.tex'), '\\documentclass{article}\n\\usepackage{dataimago-figure}\n\\begin{document}x\\end{document}\n');
683
+ const lr = spawnSync('lualatex', ['-interaction=nonstopmode', 'x.tex'], { cwd: w9, encoding: 'utf8', env: { ...process.env, TEXINPUTS: `${toolkit}//:` } });
684
+ const lout = `${lr.stdout}\n${lr.stderr}`;
685
+ assert.match(lout, /This toolkit needs XeLaTeX/, lout.slice(-800));
686
+ assert.equal((lout.match(/^! /gm) ?? []).length, 1, `exactly one error, then a halt:\n${lout.slice(-1200)}`);
687
+ }
688
+
689
+ assert.match(run(['--help']).stdout, /dataimago-figures build/);
690
+ // The font allowlist is built from FIGURE_FONT_EXCEPTIONS: every exception passes it, a house face passes, Computer Modern does not.
691
+ for (const name of [...FIGURE_FONT_EXCEPTIONS, 'NotoSans-Bold', 'NotoSansMath-Regular']) assert.match(name, ALLOWED_FONT, `${name} is allowed`);
692
+ for (const name of ['CMR10', 'LMRoman10-Regular', 'NotoSans-ExtraBold', 'NotoSansMono-Regular']) assert.doesNotMatch(name, ALLOWED_FONT, `${name} is refused`);
693
+ assert.equal(run([]).status, 2, 'no command is a usage error');
694
+ fs.rmSync(tmp, { recursive: true, force: true });
695
+ console.log('dataimago-figures self-test: all checks passed');
696
+ }
697
+
698
+ // Run only as a program: tools/pdf-fixture-check.mjs imports FIGURE_FONT_EXCEPTIONS. realpath, because npx runs the bin through a symlink.
699
+ const asProgram = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
700
+ if (asProgram) {
701
+ if (process.argv.includes('--self-test')) await selfTest();
702
+ else process.exitCode = main(process.argv.slice(2));
703
+ }