@heroiclands/package-build 20.4.0 → 20.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -0,0 +1,420 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Which glyph an icon name resolves to, read from the font that carries it.
16
+ *
17
+ * {@link module:engine/content-icons} states an icon's family and its name and
18
+ * **deliberately holds no codepoints**: writing them out by hand would be a
19
+ * second copy of a table the font already owns, wrong the first time the icon
20
+ * set renumbers anything, and wrong silently. So the renderer resolves a name
21
+ * against the file it is going to embed, which is this module.
22
+ *
23
+ * ## Two tables, joined
24
+ *
25
+ * A TrueType font answers "what is this glyph called" and "what codepoint
26
+ * reaches it" in different places, and neither alone is enough:
27
+ *
28
+ * - **`post`**, format 2.0, maps every glyph index to a name — `star`,
29
+ * `pen-to-square`. That is the half that knows what an author meant.
30
+ * - **`cmap`** maps codepoints to glyph indices. That is the half Typst needs,
31
+ * because a document selects a glyph by writing a character.
32
+ *
33
+ * Reading `post` for the index and inverting `cmap` to get back to a codepoint
34
+ * is the whole algorithm. Formats 4 and 12 are both read: an icon font that
35
+ * outgrew the Basic Multilingual Plane uses 12, and the older sets use 4.
36
+ *
37
+ * ## Nothing here fails a build
38
+ *
39
+ * A font that cannot be read, or a name it does not carry, is a **finding** and
40
+ * the icon falls back to its literal `:icon-…:` text. That is the failure mode
41
+ * {@link module:engine/content-icons} was designed around — visible on the
42
+ * page, where an author is looking — and it is strictly better than a tofu box
43
+ * or a silently missing glyph.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import fs from "node:fs";
49
+
50
+ import { familyOf } from "./content-icons.mjs";
51
+
52
+ /**
53
+ * The tables an sfnt file holds, by tag.
54
+ *
55
+ * @param {Buffer} buf - The font file.
56
+ * @returns {Map<string, {offset: number, length: number}>} Its table directory.
57
+ */
58
+ function tableDirectory(buf) {
59
+ const tables = new Map();
60
+ if (buf.length < 12) return tables;
61
+ // A TrueType collection points at its first font; a bare font starts at 0.
62
+ let base = 0;
63
+ if (buf.toString("ascii", 0, 4) === "ttcf") {
64
+ if (buf.length < 16) return tables;
65
+ base = buf.readUInt32BE(12);
66
+ }
67
+ if (buf.length < base + 12) return tables;
68
+ const numTables = buf.readUInt16BE(base + 4);
69
+ for (let i = 0; i < numTables; i += 1) {
70
+ const rec = base + 12 + i * 16;
71
+ if (rec + 16 > buf.length) break;
72
+ tables.set(buf.toString("ascii", rec, rec + 4), {
73
+ offset: buf.readUInt32BE(rec + 8),
74
+ length: buf.readUInt32BE(rec + 12),
75
+ });
76
+ }
77
+ return tables;
78
+ }
79
+
80
+ /**
81
+ * The 258 names a `post` table may refer to by index without spelling them out.
82
+ *
83
+ * This is the standard Macintosh glyph ordering, fixed by the OpenType `post`
84
+ * specification: an index below 258 names a glyph from this list rather than
85
+ * from the font's own string pool. It is data, not a guess, and it is here in
86
+ * full rather than truncated to the entries an icon set happens to use — a
87
+ * lookup that silently returned nothing for `A` would be the quiet kind of
88
+ * wrong this toolchain exists to refuse.
89
+ *
90
+ * @type {readonly string[]}
91
+ */
92
+ const MAC_GLYPH_NAMES = Object.freeze(
93
+ (
94
+ ".notdef .null nonmarkingreturn space exclam quotedbl numbersign dollar percent " +
95
+ "ampersand quotesingle parenleft parenright asterisk plus comma hyphen period slash " +
96
+ "zero one two three four five six seven eight nine colon semicolon less equal greater " +
97
+ "question at A B C D E F G H I J K L M N O P Q R S T U V W X Y Z bracketleft backslash " +
98
+ "bracketright asciicircum underscore grave a b c d e f g h i j k l m n o p q r s t u v " +
99
+ "w x y z braceleft bar braceright asciitilde Adieresis Aring Ccedilla Eacute Ntilde " +
100
+ "Odieresis Udieresis aacute agrave acircumflex adieresis atilde aring ccedilla eacute " +
101
+ "egrave ecircumflex edieresis iacute igrave icircumflex idieresis ntilde oacute ograve " +
102
+ "ocircumflex odieresis otilde uacute ugrave ucircumflex udieresis dagger degree cent " +
103
+ "sterling section bullet paragraph germandbls registered copyright trademark acute " +
104
+ "dieresis notequal AE Oslash infinity plusminus lessequal greaterequal yen mu " +
105
+ "partialdiff summation product pi integral ordfeminine ordmasculine Omega ae oslash " +
106
+ "questiondown exclamdown logicalnot radical florin approxequal Delta guillemotleft " +
107
+ "guillemotright ellipsis nonbreakingspace Agrave Atilde Otilde OE oe endash emdash " +
108
+ "quotedblleft quotedblright quoteleft quoteright divide lozenge ydieresis Ydieresis " +
109
+ "fraction currency guilsinglleft guilsinglright fi fl daggerdbl periodcentered " +
110
+ "quotesinglbase quotedblbase perthousand Acircumflex Ecircumflex Aacute Edieresis " +
111
+ "Egrave Iacute Icircumflex Idieresis Igrave Oacute Ocircumflex apple Ograve Uacute " +
112
+ "Ucircumflex Ugrave dotlessi circumflex tilde macron breve dotaccent ring cedilla " +
113
+ "hungarumlaut ogonek caron Lslash lslash Scaron scaron Zcaron zcaron brokenbar Eth eth " +
114
+ "Yacute yacute Thorn thorn minus multiply onesuperior twosuperior threesuperior onehalf " +
115
+ "onequarter threequarters franc Gbreve gbreve Idotaccent Scedilla scedilla Cacute " +
116
+ "cacute Ccaron ccaron dcroat"
117
+ ).split(" "),
118
+ );
119
+
120
+ /**
121
+ * Glyph index → name, from a `post` table of format 2.0.
122
+ *
123
+ * @param {Buffer} buf - The font file.
124
+ * @param {{offset: number, length: number}} table - The `post` record.
125
+ * @returns {Map<number, string>} Names by glyph index.
126
+ */
127
+ function glyphNames(buf, table) {
128
+ const names = new Map();
129
+ const at = table.offset;
130
+ if (at + 34 > buf.length) return names;
131
+ if (buf.readUInt32BE(at) !== 0x0002_0000) return names;
132
+ const numGlyphs = buf.readUInt16BE(at + 32);
133
+ const indexAt = at + 34;
134
+ if (indexAt + numGlyphs * 2 > buf.length) return names;
135
+
136
+ // The pascal strings follow the index array, in order, and are addressed by
137
+ // ordinal rather than by offset — so they have to be read in one pass.
138
+ const custom = [];
139
+ let p = indexAt + numGlyphs * 2;
140
+ while (p < at + table.length && p < buf.length) {
141
+ const len = buf.readUInt8(p);
142
+ custom.push(buf.toString("latin1", p + 1, p + 1 + len));
143
+ p += 1 + len;
144
+ }
145
+
146
+ for (let g = 0; g < numGlyphs; g += 1) {
147
+ const idx = buf.readUInt16BE(indexAt + g * 2);
148
+ if (idx < 258) {
149
+ if (idx < MAC_GLYPH_NAMES.length) names.set(g, MAC_GLYPH_NAMES[idx]);
150
+ continue;
151
+ }
152
+ const name = custom[idx - 258];
153
+ if (name) names.set(g, name);
154
+ }
155
+ return names;
156
+ }
157
+
158
+ /**
159
+ * Whether a codepoint is in a Private Use Area.
160
+ *
161
+ * All three: the BMP block and the two supplementary planes, because an icon
162
+ * set large enough to have outgrown `U+E000`–`U+F8FF` moves to plane 15.
163
+ *
164
+ * @param {number} cp - A codepoint.
165
+ * @returns {boolean} Whether it is private use.
166
+ */
167
+ function isPrivateUse(cp) {
168
+ return (
169
+ (cp >= 0xe000 && cp <= 0xf8ff) ||
170
+ (cp >= 0xf_0000 && cp <= 0xf_fffd) ||
171
+ (cp >= 0x10_0000 && cp <= 0x10_fffd)
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Glyph index → the codepoint a document should write to reach it.
177
+ *
178
+ * **The Private Use Area address wins when the font offers one.** An icon set
179
+ * routinely maps one glyph twice: at a PUA codepoint that is its own, and at a
180
+ * real Unicode character that merely looks like it. Font Awesome's `star` is at
181
+ * both `U+F005` and `U+2B50`, its `xmark` at both `U+F00D` and `U+00D7`.
182
+ *
183
+ * Writing the real character would *look* right in a test and fail in a book:
184
+ * `U+2B50` is the emoji star, so the moment font fallback engages — a weight the
185
+ * icon font does not ship, a viewer substituting a face — the page gets a colour
186
+ * emoji or a multiplication sign instead of the icon. A PUA codepoint is
187
+ * unassigned by definition, so nothing but the font named alongside it can
188
+ * satisfy it, and a substitution becomes a visible missing glyph rather than a
189
+ * plausible wrong one.
190
+ *
191
+ * @param {Buffer} buf - The font file.
192
+ * @param {{offset: number}} table - The `cmap` record.
193
+ * @returns {Map<number, number>} Codepoints by glyph index.
194
+ */
195
+ function glyphCodepoints(buf, table) {
196
+ const out = new Map();
197
+ const at = table.offset;
198
+ if (at + 4 > buf.length) return out;
199
+ const numTables = buf.readUInt16BE(at + 2);
200
+
201
+ /**
202
+ * Record a mapping, keeping the lowest codepoint per glyph.
203
+ *
204
+ * @param {number} glyph - The glyph index.
205
+ * @param {number} cp - A codepoint reaching it.
206
+ */
207
+ const record = (glyph, cp) => {
208
+ if (!glyph) return;
209
+ const seen = out.get(glyph);
210
+ if (seen === undefined) {
211
+ out.set(glyph, cp);
212
+ return;
213
+ }
214
+ // A PUA address displaces a non-PUA one; between two of a kind the
215
+ // lower wins, so the result does not depend on table order.
216
+ const had = isPrivateUse(seen);
217
+ const has = isPrivateUse(cp);
218
+ if (has && !had) out.set(glyph, cp);
219
+ else if (has === had && cp < seen) out.set(glyph, cp);
220
+ };
221
+
222
+ for (let i = 0; i < numTables; i += 1) {
223
+ const rec = at + 4 + i * 8;
224
+ if (rec + 8 > buf.length) break;
225
+ const sub = at + buf.readUInt32BE(rec + 4);
226
+ if (sub + 4 > buf.length) continue;
227
+ const format = buf.readUInt16BE(sub);
228
+
229
+ if (format === 4) {
230
+ const segX2 = buf.readUInt16BE(sub + 6);
231
+ const segs = segX2 / 2;
232
+ const endAt = sub + 14;
233
+ const startAt = endAt + segX2 + 2;
234
+ const deltaAt = startAt + segX2;
235
+ const rangeAt = deltaAt + segX2;
236
+ if (rangeAt + segX2 > buf.length) continue;
237
+ for (let s = 0; s < segs; s += 1) {
238
+ const end = buf.readUInt16BE(endAt + s * 2);
239
+ const start = buf.readUInt16BE(startAt + s * 2);
240
+ const delta = buf.readInt16BE(deltaAt + s * 2);
241
+ const rangeOffset = buf.readUInt16BE(rangeAt + s * 2);
242
+ if (start === 0xffff) continue;
243
+ for (let cp = start; cp <= end && cp !== 0x1_0000; cp += 1) {
244
+ let glyph;
245
+ if (rangeOffset === 0) {
246
+ glyph = (cp + delta) & 0xffff;
247
+ } else {
248
+ const gAt = rangeAt + s * 2 + rangeOffset + (cp - start) * 2;
249
+ if (gAt + 2 > buf.length) continue;
250
+ glyph = buf.readUInt16BE(gAt);
251
+ if (glyph) glyph = (glyph + delta) & 0xffff;
252
+ }
253
+ record(glyph, cp);
254
+ }
255
+ }
256
+ } else if (format === 12) {
257
+ const groups = buf.readUInt32BE(sub + 12);
258
+ for (let g = 0; g < groups; g += 1) {
259
+ const rowAt = sub + 16 + g * 12;
260
+ if (rowAt + 12 > buf.length) break;
261
+ const start = buf.readUInt32BE(rowAt);
262
+ const end = buf.readUInt32BE(rowAt + 4);
263
+ const startGlyph = buf.readUInt32BE(rowAt + 8);
264
+ // A single group may legitimately span a plane; the cap keeps a
265
+ // corrupt table from turning into an unbounded loop.
266
+ const last = Math.min(end, start + 0x1_0000);
267
+ for (let cp = start; cp <= last; cp += 1) record(startGlyph + (cp - start), cp);
268
+ }
269
+ }
270
+ }
271
+ return out;
272
+ }
273
+
274
+ /**
275
+ * Every glyph name a font carries, with the codepoint that reaches it.
276
+ *
277
+ * @param {string} file - Path to a `.ttf`/`.otf`.
278
+ * @returns {Map<string, number>} Name → codepoint.
279
+ */
280
+ export function glyphTable(file) {
281
+ const buf = fs.readFileSync(file);
282
+ const tables = tableDirectory(buf);
283
+ const post = tables.get("post");
284
+ const cmap = tables.get("cmap");
285
+ if (!post || !cmap) return new Map();
286
+ const names = glyphNames(buf, post);
287
+ const points = glyphCodepoints(buf, cmap);
288
+ const out = new Map();
289
+ for (const [glyph, name] of names) {
290
+ const cp = points.get(glyph);
291
+ if (cp !== undefined && !out.has(name)) out.set(name, cp);
292
+ }
293
+ return out;
294
+ }
295
+
296
+ /**
297
+ * The family name a font file announces, for Typst's `text(font: …)`.
298
+ *
299
+ * Read from the `name` table rather than from the file name, because the file
300
+ * is what a consumer happened to call it and the family is what the font stack
301
+ * will match on.
302
+ *
303
+ * @param {string} file - Path to a `.ttf`/`.otf`.
304
+ * @returns {string} The family name, or "" when the table cannot be read.
305
+ */
306
+ export function familyName(file) {
307
+ let buf;
308
+ try {
309
+ buf = fs.readFileSync(file);
310
+ } catch {
311
+ return "";
312
+ }
313
+ const table = tableDirectory(buf).get("name");
314
+ if (!table) return "";
315
+ const at = table.offset;
316
+ if (at + 6 > buf.length) return "";
317
+ const count = buf.readUInt16BE(at + 2);
318
+ const storage = at + buf.readUInt16BE(at + 4);
319
+ let best = "";
320
+ for (let i = 0; i < count; i += 1) {
321
+ const rec = at + 6 + i * 12;
322
+ if (rec + 12 > buf.length) break;
323
+ const platform = buf.readUInt16BE(rec);
324
+ const nameId = buf.readUInt16BE(rec + 6);
325
+ if (nameId !== 1) continue;
326
+ const length = buf.readUInt16BE(rec + 8);
327
+ const offset = storage + buf.readUInt16BE(rec + 10);
328
+ if (offset + length > buf.length) continue;
329
+ const value =
330
+ platform === 3 ?
331
+ buf.toString("utf16le", offset, offset + length).replace(/\0/g, "")
332
+ : buf.toString("latin1", offset, offset + length);
333
+ // Windows records (platform 3) are the ones a font stack reads, so they
334
+ // win when a font ships both.
335
+ if (platform === 3) return swapUtf16(buf, offset, length);
336
+ if (!best) best = value;
337
+ }
338
+ return best;
339
+ }
340
+
341
+ /**
342
+ * A big-endian UTF-16 name record, as a string.
343
+ *
344
+ * @param {Buffer} buf - The font file.
345
+ * @param {number} offset - Where the record starts.
346
+ * @param {number} length - Its byte length.
347
+ * @returns {string} The decoded name.
348
+ */
349
+ function swapUtf16(buf, offset, length) {
350
+ const slice = Buffer.from(buf.subarray(offset, offset + length));
351
+ slice.swap16();
352
+ return slice.toString("utf16le").replace(/\0/g, "");
353
+ }
354
+
355
+ /**
356
+ * Resolve every icon in a registry against the fonts a consumer named.
357
+ *
358
+ * @param {object} registry - The resolved `icons:` registry.
359
+ * @param {Record<string, string>} iconFonts - Family name → font file.
360
+ * @param {object[]} [findings] - Collected here rather than thrown.
361
+ * @returns {Map<string, {font: string, codepoint: number}>} Icon name → glyph.
362
+ */
363
+ export function resolveIconGlyphs(registry, iconFonts = {}, findings = []) {
364
+ const out = new Map();
365
+ const entries = Object.entries(registry?.icons ?? {});
366
+ if (!entries.length) return out;
367
+
368
+ const loaded = new Map();
369
+ for (const [family, file] of Object.entries(iconFonts ?? {})) {
370
+ try {
371
+ loaded.set(family, {
372
+ family: familyName(file) || family,
373
+ table: glyphTable(file),
374
+ file,
375
+ });
376
+ } catch (err) {
377
+ findings.push({
378
+ file,
379
+ severity: "warning",
380
+ message:
381
+ `the font named by \`pdf.iconFonts.${family}\` cannot be read ` +
382
+ `(${err.message}), so its icons print as their names`,
383
+ });
384
+ }
385
+ }
386
+ if (!loaded.size) return out;
387
+
388
+ for (const [name, entry] of entries) {
389
+ const family = familyOf(entry, registry);
390
+ const font = loaded.get(family);
391
+ if (!font) continue;
392
+ // The registry's `icon` is the glyph's own name in its set; the entry's
393
+ // key is what an author writes, and the two differ whenever a set
394
+ // renames something.
395
+ const glyphName = entry?.icon ?? name;
396
+ const codepoint = font.table.get(glyphName);
397
+ if (codepoint === undefined) {
398
+ findings.push({
399
+ file: font.file,
400
+ severity: "warning",
401
+ message:
402
+ `\`:icon-${name}:\` asks for glyph \`${glyphName}\`, which ` +
403
+ `${path0(font.file)} does not carry — it prints as its name`,
404
+ });
405
+ continue;
406
+ }
407
+ out.set(name, { font: font.family, codepoint });
408
+ }
409
+ return out;
410
+ }
411
+
412
+ /**
413
+ * A font file's base name, for a diagnostic that should not carry a full path.
414
+ *
415
+ * @param {string} file - The path.
416
+ * @returns {string} Its last segment.
417
+ */
418
+ function path0(file) {
419
+ return String(file).split("/").pop() ?? file;
420
+ }