@heroiclands/package-build 22.22.0 → 22.23.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 (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/content-commands.mjs +51 -14
  3. package/ci/content-format-reference.mjs +1 -1
  4. package/content-config.mjs +80 -2
  5. package/docs/api.md +85 -72
  6. package/docs/authoring/assets.md +14 -0
  7. package/docs/authoring/dates-and-calendars.md +37 -1
  8. package/docs/authoring/links-and-markup.md +112 -31
  9. package/docs/commands.md +12 -10
  10. package/docs/configuration.md +90 -13
  11. package/docs/content-format.md +1 -1
  12. package/docs/guides/book.md +5 -4
  13. package/docs/reference/format-details.md +323 -60
  14. package/docs/reference/note-types.md +9 -9
  15. package/engine/anchors.mjs +16 -10
  16. package/engine/art-fields.mjs +118 -0
  17. package/engine/art-slots.mjs +7 -1
  18. package/engine/asset-index.mjs +160 -10
  19. package/engine/asset-replacement.mjs +93 -0
  20. package/engine/asset-types.mjs +240 -0
  21. package/engine/base-compiler.mjs +54 -2
  22. package/engine/bundles.mjs +13 -1
  23. package/engine/content-blocks.mjs +4 -4
  24. package/engine/content-embeds.mjs +19 -0
  25. package/engine/content-figures.mjs +468 -0
  26. package/engine/content-footnotes.mjs +10 -2
  27. package/engine/content-format.yaml +1 -1
  28. package/engine/content-images.mjs +126 -25
  29. package/engine/content-index.mjs +36 -10
  30. package/engine/field-reference.mjs +31 -10
  31. package/engine/field-spec.mjs +10 -8
  32. package/engine/foreign-catalog.mjs +143 -18
  33. package/engine/frontmatter-lint.mjs +73 -1
  34. package/engine/heading-attributes.mjs +31 -18
  35. package/engine/helpers.mjs +97 -30
  36. package/engine/infobox-registry.mjs +4 -1
  37. package/engine/infobox.mjs +47 -8
  38. package/engine/itinerary-scenes.mjs +4 -10
  39. package/engine/journals.mjs +264 -39
  40. package/engine/lore-events.mjs +197 -0
  41. package/engine/markdown-expressions.mjs +257 -7
  42. package/engine/note-dates.mjs +305 -12
  43. package/engine/note-vocabulary.mjs +11 -5
  44. package/engine/pathnames.mjs +36 -0
  45. package/engine/pdf-build.mjs +357 -34
  46. package/engine/pdf-images.mjs +64 -0
  47. package/engine/pdf-render.mjs +160 -36
  48. package/engine/scenes.mjs +16 -4
  49. package/engine/site-build.mjs +127 -13
  50. package/engine/site-index.mjs +72 -1
  51. package/engine/site-preview.mjs +2 -0
  52. package/engine/system-block.mjs +44 -16
  53. package/hm3/actors.mjs +2 -2
  54. package/manifest.mjs +30 -24
  55. package/package.json +1 -1
  56. package/packagebuild-metadata.jsonl +13 -13
  57. package/sohl/item-fields.mjs +4 -3
  58. package/types/content-config.d.mts +48 -1
  59. package/types/engine/anchors.d.mts +9 -4
  60. package/types/engine/art-fields.d.mts +64 -0
  61. package/types/engine/asset-index.d.mts +43 -0
  62. package/types/engine/asset-replacement.d.mts +22 -0
  63. package/types/engine/asset-types.d.mts +18 -0
  64. package/types/engine/base-compiler.d.mts +0 -17
  65. package/types/engine/content-embeds.d.mts +16 -0
  66. package/types/engine/content-figures.d.mts +101 -0
  67. package/types/engine/content-footnotes.d.mts +7 -0
  68. package/types/engine/content-images.d.mts +78 -12
  69. package/types/engine/content-index.d.mts +8 -2
  70. package/types/engine/field-reference.d.mts +21 -0
  71. package/types/engine/field-spec.d.mts +20 -16
  72. package/types/engine/foreign-catalog.d.mts +39 -0
  73. package/types/engine/heading-attributes.d.mts +13 -8
  74. package/types/engine/helpers.d.mts +23 -7
  75. package/types/engine/infobox-registry.d.mts +4 -1
  76. package/types/engine/infobox.d.mts +6 -0
  77. package/types/engine/journals.d.mts +31 -9
  78. package/types/engine/lore-events.d.mts +14 -0
  79. package/types/engine/markdown-expressions.d.mts +23 -2
  80. package/types/engine/note-dates.d.mts +64 -1
  81. package/types/engine/pathnames.d.mts +26 -0
  82. package/types/engine/pdf-build.d.mts +28 -6
  83. package/types/engine/pdf-images.d.mts +52 -0
  84. package/types/engine/pdf-render.d.mts +12 -0
  85. package/types/engine/site-build.d.mts +14 -3
  86. package/types/engine/site-index.d.mts +54 -0
  87. package/types/engine/system-block.d.mts +26 -2
  88. package/types/hm3/actors.d.mts +18 -0
  89. package/types/manifest.d.mts +19 -23
  90. package/engine/content-captions.mjs +0 -230
  91. package/types/engine/content-captions.d.mts +0 -58
@@ -94,18 +94,25 @@ import {
94
94
  gatesFailed,
95
95
  resolveSitePass,
96
96
  } from "./site-build.mjs";
97
- import { resolveInfoboxRef, wikiContext } from "./site-index.mjs";
97
+ import {
98
+ figureIndexKeys,
99
+ resolveCrossNoteFigures,
100
+ resolveInfoboxRef,
101
+ wikiContext,
102
+ } from "./site-index.mjs";
98
103
  import { resolveWebWikilinks } from "./web-wikilinks.mjs";
99
104
  import { linkFindingMessage } from "./wikilink-syntax.mjs";
100
105
  import { positionOfLiteral } from "./diagnostics.mjs";
101
- import { artPathname, assetAddressIndex } from "./art-fields.mjs";
106
+ import { artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs";
107
+ import { embedRole } from "./content-embeds.mjs";
102
108
  import { expandContentTables } from "./content-tables.mjs";
103
109
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
104
- import { numberCaptions, scanCaptions } from "./content-captions.mjs";
110
+ import { numberFigures, scanFigures } from "./content-figures.mjs";
105
111
  import { collectAnchors } from "./anchors.mjs";
106
112
  import { protectCode } from "./code-fences.mjs";
107
113
  import { imagesIn, parseImageDirective } from "./content-images.mjs";
108
114
  import {
115
+ bookImageWidthIn,
109
116
  PDF_IMAGE_INCHES,
110
117
  PDF_PAGE,
111
118
  pdfColumnWidth,
@@ -113,7 +120,15 @@ import {
113
120
  pdfTextWidth,
114
121
  resamplePdfImages,
115
122
  } from "./pdf-images.mjs";
116
- import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
123
+ import {
124
+ ASSETS_SEGMENT,
125
+ assetPathnameKey,
126
+ pathnameProblem,
127
+ resolvePathname,
128
+ } from "./pathnames.mjs";
129
+ import { ASSET_SYSTEM, assetTypeOfRoot, isAssetShortcode } from "./asset-types.mjs";
130
+ import { canonicalKey } from "./address.mjs";
131
+ import { isComplete as isForeignCacheComplete, newestVersionDir } from "./metadata-index.mjs";
117
132
  import {
118
133
  createParser,
119
134
  labelFor,
@@ -128,6 +143,7 @@ import { noteInfoboxes } from "./infobox-registry.mjs";
128
143
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
129
144
  import { buildMaps, relatedPlaces } from "./map-build.mjs";
130
145
  import { mapWorld } from "./map-places.mjs";
146
+ import { resolveAssetReplacement } from "./asset-replacement.mjs";
131
147
 
132
148
  /**
133
149
  * The file on disk an authored image pathname names, or `null`.
@@ -144,21 +160,165 @@ import { mapWorld } from "./map-places.mjs";
144
160
  * makes the source a consumer can compile by hand with no flags, and what keeps
145
161
  * a build from touching a path outside its own output.
146
162
  *
147
- * Only a file **this** package ships can be staged. A pathname naming another
148
- * package's file, or a URL, names something no build here can open — a build
149
- * reaches no network — and the caller reports it as a picture the book will not
150
- * carry.
163
+ * A file **this** package ships stages directly. A pathname naming another
164
+ * package's file stages instead from a declared replacement's cached archive,
165
+ * when `opts.resolveReplacement` — the pure function
166
+ * {@link module:engine/asset-replacement.resolveAssetReplacement} —
167
+ * answers it; `opts.foreignIndex` is the merged index that answer is read
168
+ * against. A pathname naming a package no replacement carries it for, or a
169
+ * URL, names something no build here can open — a build reaches no network —
170
+ * and the caller reports it as a picture the book will not carry.
151
171
  *
152
172
  * @param {string} src - The pathname, as authored.
153
173
  * @param {object} config - The resolved configuration.
174
+ * @param {object} [opts]
175
+ * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
176
+ * dependency's own records, keyed by canonical address — the index a
177
+ * replacement's record is read out of.
178
+ * @param {Function} [opts.resolveReplacement] - {@link
179
+ * module:engine/asset-replacement.resolveAssetReplacement}, or an
180
+ * equivalent a caller injects for a test. Omitted, a foreign pathname this
181
+ * package does not own stays declined exactly as it always has.
154
182
  * @returns {{from: string, to: string}|null} The file, and where under the
155
183
  * output directory it is staged.
184
+ * @throws {Error} When a declared replacement answers the address but its
185
+ * archive was never fetched.
156
186
  */
157
- export function stagedImagePath(src, config) {
187
+ export function stagedImagePath(src, config, opts = {}) {
158
188
  const forms = resolvePathname(src, config);
159
- if (!forms || forms.state !== "package" || !forms.own) return null;
189
+ if (!forms || forms.state !== "package") return null;
190
+ // A declared replacement answers before the package's own file does, which
191
+ // is the whole of what declaring one buys: the address a note writes is
192
+ // this package's own, and the picture staged for it is the replacement's.
193
+ // A miss falls through, so a package declaring none stages exactly what it
194
+ // ships.
195
+ const replaced = opts.resolveReplacement ? stagedReplacementPath(forms, config, opts) : null;
196
+ if (replaced) return replaced;
197
+ if (forms.own) {
198
+ return {
199
+ from: path.resolve(config.rootDir, forms.local),
200
+ to: forms.pdf,
201
+ };
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Every declared relationship that opted a dependency in as this package's
208
+ * asset replacement, in declaration order.
209
+ *
210
+ * The same shape {@link module:engine/foreign-catalog.itemCatalogRelationships}
211
+ * returns, read directly rather than through that module: this file stays a
212
+ * leaf over the configuration it is handed, and the flag is as small to read
213
+ * back as it was to validate.
214
+ *
215
+ * @param {object} config - The resolved build configuration.
216
+ * @returns {Array<{id: string, package: string}>} The relationships, each
217
+ * with the package segment its own content index publishes under.
218
+ */
219
+ function assetReplacementRelationships(config) {
220
+ const out = [];
221
+ for (const entries of Object.values(config.relationships ?? {})) {
222
+ for (const rel of entries ?? []) {
223
+ if (rel.assetReplacement) {
224
+ out.push({ id: rel.id, package: rel.contentPackage ?? rel.id });
225
+ }
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * The address a pathname this package does not own is read back as, for
233
+ * asking whether a declared replacement answers it instead of the package
234
+ * the pathname names.
235
+ *
236
+ * {@link module:engine/asset-replacement.resolveAssetReplacement} only ever
237
+ * matches an address whose package segment is the compiling package's own,
238
+ * so the pathname's own package is set aside and its suffix — the one piece
239
+ * every surface derives from, and the piece a replacement publishes under
240
+ * its own package instead — is read back as this package's type and
241
+ * shortcode.
242
+ *
243
+ * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
244
+ * resolved.
245
+ * @param {object} config - The resolved build configuration.
246
+ * @returns {string|null} The canonical address, or `null` when the suffix is
247
+ * not shaped like an asset's.
248
+ */
249
+ function localAssetAddress(forms, config) {
250
+ const segments = String(forms.suffix ?? "").split("/");
251
+ const assetType = assetTypeOfRoot(segments[0]);
252
+ if (!assetType) return null;
253
+ const base = segments[segments.length - 1];
254
+ const ext = path.extname(base);
255
+ const shortcode = (ext ? base.slice(0, -ext.length) : base).toLowerCase();
256
+ if (!isAssetShortcode(shortcode)) return null;
257
+ return canonicalKey(config.contentPackage, ASSET_SYSTEM, assetType.type, shortcode);
258
+ }
259
+
260
+ /**
261
+ * The cache directory one relationship's archive was fetched into, found the
262
+ * way {@link module:engine/foreign-catalog.foreignItemCatalogDirs} finds an
263
+ * item catalogue's: the newest `.complete`-stamped `<id>@<version>`
264
+ * directory under `config.paths.foreignCache`.
265
+ *
266
+ * @param {object} config - The resolved build configuration.
267
+ * @param {string} id - The relationship's dependency id.
268
+ * @returns {string|null} The directory, or `null` when nothing is cached.
269
+ */
270
+ function cachedReplacementDir(config, id) {
271
+ const root = config.paths.foreignCache;
272
+ if (!fs.existsSync(root)) return null;
273
+ const cached = fs
274
+ .readdirSync(root)
275
+ .filter((name) => name.startsWith(`${id}@`))
276
+ .map((name) => path.join(root, name))
277
+ .filter(isForeignCacheComplete);
278
+ return cached.length ? newestVersionDir(cached) : null;
279
+ }
280
+
281
+ /**
282
+ * The file a declared replacement stages in place of a pathname this package
283
+ * does not own, or `null` when no replacement answers it.
284
+ *
285
+ * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
286
+ * resolved, with `forms.own` already `false`.
287
+ * @param {object} config - The resolved build configuration.
288
+ * @param {object} opts
289
+ * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
290
+ * dependency's own records, keyed by canonical address.
291
+ * @param {Function} opts.resolveReplacement - {@link
292
+ * module:engine/asset-replacement.resolveAssetReplacement}.
293
+ * @returns {{from: string, to: string}|null} The file, staged at the same
294
+ * `pdf`-relative path this address would have taken had this package
295
+ * shipped it.
296
+ * @throws {Error} When the replacement that answers the address has not
297
+ * been fetched.
298
+ */
299
+ function stagedReplacementPath(forms, config, { foreignIndex = new Map(), resolveReplacement }) {
300
+ const address = localAssetAddress(forms, config);
301
+ if (!address) return null;
302
+ const relationships = assetReplacementRelationships(config);
303
+ if (!relationships.length) return null;
304
+ const hit = resolveReplacement(address, {
305
+ localPackage: config.contentPackage,
306
+ replacements: relationships.map((rel) => rel.package),
307
+ index: foreignIndex,
308
+ });
309
+ if (!hit) return null;
310
+ const assetPath = hit.record?.asset?.path;
311
+ if (!assetPath) return null;
312
+ const rel = relationships.find((entry) => entry.package === hit.package);
313
+ const dir = cachedReplacementDir(config, rel?.id ?? hit.package);
314
+ if (!dir) {
315
+ throw new Error(
316
+ `\`${hit.package}\` declares \`assetReplacement: true\` and carries \`${address}\`, ` +
317
+ "but its archive has not been fetched. Run `package-build deps fetch` first.",
318
+ );
319
+ }
160
320
  return {
161
- from: path.resolve(config.rootDir, forms.local),
321
+ from: path.join(dir, ASSETS_SEGMENT, assetPath),
162
322
  to: forms.pdf,
163
323
  };
164
324
  }
@@ -301,9 +461,19 @@ function readDocumentTree(file) {
301
461
  * @param {string} [opts.version] - Stamped on the title page and the file name.
302
462
  * @param {boolean} [opts.compile] - Whether to run Typst. False leaves the
303
463
  * `.typ` source, which is what the unit tests read.
464
+ * @param {Function} [opts.resolveReplacement] - {@link
465
+ * module:engine/asset-replacement.resolveAssetReplacement}, for tests.
466
+ * Loaded from that module otherwise, and only when a relationship declares
467
+ * `assetReplacement: true` — a build with none never imports it.
304
468
  * @returns {Promise<object>} `{ built, reason, findings, typ, pdf, stats }`.
305
469
  */
306
- export async function buildPdf({ config, out, version = "", compile = true } = {}) {
470
+ export async function buildPdf({
471
+ config,
472
+ out,
473
+ version = "",
474
+ compile = true,
475
+ resolveReplacement,
476
+ } = {}) {
307
477
  const resolved = config ?? loadPackConfig();
308
478
  const findings = [];
309
479
 
@@ -441,6 +611,17 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
441
611
  const md = createParser(resolved.icons);
442
612
  const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
443
613
 
614
+ // Asked only where a relationship declares `assetReplacement: true`: with
615
+ // none declared the resolver is never consulted, and staging gives the
616
+ // answer it gives a package that declares no replacement. A caller may
617
+ // supply its own, which is how the staging tests drive it.
618
+ const stagingOpts = {
619
+ foreignIndex: gates.foreign.index,
620
+ resolveReplacement:
621
+ resolveReplacement ??
622
+ (assetReplacementRelationships(resolved).length ? resolveAssetReplacement : null),
623
+ };
624
+
444
625
  const outDir = path.resolve(
445
626
  resolved.rootDir,
446
627
  out || resolved.pdf.out || path.join("build", "dist"),
@@ -449,6 +630,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
449
630
 
450
631
  /** @type {Map<string, string>} Authored address → the staged file's path. */
451
632
  const images = new Map();
633
+ /** @type {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
634
+ * Authored address, exactly as the note wrote it → its asset's role and
635
+ * pixel size. Keyed the same way {@link images} is rather than by the
636
+ * asset index's own canonical pathname, because a body image is free to
637
+ * write the bare, own-package form — see
638
+ * {@link module:engine/pathnames.assetPathnameKey}. */
639
+ const imageAssets = new Map();
452
640
  /** @type {Map<string, {from: string, to: string, relative: string, file: string, uses: Array<{width: number, height: number}>}>} */
453
641
  const imageCandidates = new Map();
454
642
  /** @type {Set<string>} Addresses already looked for, staged or not. */
@@ -473,8 +661,18 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
473
661
  const wide =
474
662
  directive.size === "full-width" || directive.classes.includes("full-width");
475
663
  const availableWidth = wide ? pdfTextWidth : pdfColumnWidth(columns);
664
+ const assetKey = assetPathnameKey(src, resolved);
665
+ const asset = assetKey ? assets.byPath.get(assetKey) : undefined;
666
+ if (asset) imageAssets.set(src, asset);
667
+ // `auto` is the one size a role or an icon's nominal width can
668
+ // narrow — a named `size=` is the author's own statement, honoured
669
+ // as it always was.
670
+ const roleWidth = directive.size === "auto" ? bookImageWidthIn(asset) : undefined;
476
671
  const use = {
477
- width: Math.min(PDF_IMAGE_INCHES[directive.size] ?? availableWidth, availableWidth),
672
+ width: Math.min(
673
+ roleWidth ?? PDF_IMAGE_INCHES[directive.size] ?? availableWidth,
674
+ availableWidth,
675
+ ),
478
676
  height: pdfTextHeight * 0.8,
479
677
  };
480
678
  if (seenImages.has(src)) {
@@ -491,7 +689,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
491
689
  findings.push({ file, severity: "error", message: problem });
492
690
  continue;
493
691
  }
494
- const staged = stagedImagePath(src, resolved);
692
+ let staged;
693
+ try {
694
+ staged = stagedImagePath(src, resolved, stagingOpts);
695
+ } catch (err) {
696
+ findings.push({ file, severity: "error", message: err.message });
697
+ continue;
698
+ }
495
699
  if (!staged) {
496
700
  findings.push({
497
701
  file,
@@ -540,16 +744,24 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
540
744
  * @param {number} columns - The entry's page columns.
541
745
  * @returns {string} Typst markup.
542
746
  */
543
- const captionCounts = { code: 0, table: 0, figure: 0, prose: 0 };
544
- const frontCaptions = new Map();
747
+ const figureCounts = { code: 0, table: 0, figure: 0, map: 0, prose: 0 };
748
+ // The role lookup a `:::figure` fence's `map` counter reaches through —
749
+ // see `engine/content-figures.mjs`'s `resolveRole`. Addressed form, for a
750
+ // page's own scan below, which runs before its embeds are rewritten into
751
+ // ordinary images; `pathRoles` is the same lookup keyed by the pathname
752
+ // that rewrite leaves behind, for the renderer's own rescan of the
753
+ // resolved body.
754
+ const resolveRole = (address) => embedRole(assets, address);
755
+ const pathRoles = pathnameRoles(assets);
756
+ const frontFigures = new Map();
545
757
  for (const file of resolved.pdf.front) {
546
758
  try {
547
- const scan = scanCaptions(fs.readFileSync(file, "utf8"));
759
+ const scan = scanFigures(fs.readFileSync(file, "utf8"), { resolveRole });
548
760
  // Numbered here, because a number runs across the whole book and only
549
- // this loop knows the order; what is *wrong* with a caption is
761
+ // this loop knows the order; what is *wrong* with a figure is
550
762
  // reported by the renderer, which reads every one of the three kinds
551
763
  // of markdown a book is made of.
552
- frontCaptions.set(file, numberCaptions(scan.captions, captionCounts));
764
+ frontFigures.set(file, numberFigures(scan.figures, figureCounts));
553
765
  } catch {
554
766
  // The rendering pass reports an unreadable front-matter file.
555
767
  }
@@ -560,17 +772,83 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
560
772
  config: resolved,
561
773
  book: true,
562
774
  });
775
+
776
+ /**
777
+ * Every entry's own figures, numbered across the whole book in reading
778
+ * order, before any entry's expressions render — so a cross-note `ref`
779
+ * finds the number its target carries in the finished book rather than a
780
+ * page-local recount. Walked in the same order {@link bodies} fills
781
+ * below, so `figureCounts` lands on the same final tallies either way;
782
+ * the note's table expansion is cached here too, so the render loop does
783
+ * not redo it.
784
+ *
785
+ * @type {Map<string, {markdown?: string, errors?: object[],
786
+ * lineMap?: object[], numberedFigures: object[]}>}
787
+ */
788
+ const prepared = new Map();
789
+ // Every note's figures, by the address a `ref` crossing into it would
790
+ // write — see `engine/site-index.mjs`'s `figureIndexKeys`.
791
+ const figuresByAddress = new Map();
792
+ for (const entry of plan.entries) {
793
+ if (entry.kind === "note") {
794
+ const file = noteFile(contentBase, entry.record);
795
+ const page = byFile.get(file);
796
+ if (!page) continue; // reported as `missing` by the render loop below
797
+ const src = page.relPath ?? page.base;
798
+ const { markdown, errors, lineMap } = expandContentTables(page.body, {
799
+ docs: universe.get(page.pkg) ?? [],
800
+ linkable: (d) => Boolean(d.fm.shortcode),
801
+ source: src,
802
+ sqlTables: sqlTables?.get(page.file),
803
+ pageLists: sqlTables?.pageLists?.get(page.file),
804
+ self: { fm: page.fm, path: page.relPath },
805
+ });
806
+ const numberedFigures = numberFigures(
807
+ scanFigures(markdown, { resolveRole }).figures,
808
+ figureCounts,
809
+ );
810
+ prepared.set(entry.anchor, { markdown, errors, lineMap, numberedFigures });
811
+ const shortcode = page.fm.shortcode;
812
+ if (typeof shortcode === "string" && shortcode) {
813
+ const byId = new Map(
814
+ numberedFigures
815
+ .filter((figure) => figure.id)
816
+ .map((figure) => [
817
+ figure.id,
818
+ {
819
+ label: figure.label,
820
+ caption: figure.caption,
821
+ hasCaption: figure.hasCaption,
822
+ },
823
+ ]),
824
+ );
825
+ const type = String(page.fm.type ?? "").toLowerCase();
826
+ for (const key of figureIndexKeys(page.pkg, type, shortcode))
827
+ figuresByAddress.set(key, byId);
828
+ }
829
+ continue;
830
+ }
831
+ if (entry.kind === "prose") {
832
+ const file = path.resolve(resolved.rootDir, entry.file);
833
+ let text;
834
+ try {
835
+ text = fs.readFileSync(file, "utf8");
836
+ } catch {
837
+ continue; // reported by the render loop below
838
+ }
839
+ prepared.set(entry.anchor, {
840
+ numberedFigures: numberFigures(
841
+ scanFigures(text, { resolveRole }).figures,
842
+ figureCounts,
843
+ ),
844
+ });
845
+ }
846
+ }
847
+
563
848
  const renderPage = (page, headingOffset, anchorPrefix, columns) => {
564
849
  const src = page.relPath ?? page.base;
565
850
  const wikiErrors = [];
566
- const { markdown, errors, lineMap } = expandContentTables(page.body, {
567
- docs: universe.get(page.pkg) ?? [],
568
- linkable: (d) => Boolean(d.fm.shortcode),
569
- source: src,
570
- sqlTables: sqlTables?.get(page.file),
571
- pageLists: sqlTables?.pageLists?.get(page.file),
572
- self: { fm: page.fm, path: page.relPath },
573
- });
851
+ const { markdown, errors, lineMap, numberedFigures } = prepared.get(anchorPrefix);
574
852
  // A content-table finding states its `reason`, its 0-based line within
575
853
  // the body and its column, exactly as the site build reports them.
576
854
  // Reading it as `message` yields `[object Object]` and discards the
@@ -593,16 +871,31 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
593
871
  foreignIndex: gates.foreign.index,
594
872
  assets,
595
873
  });
596
- const captionScan = scanCaptions(markdown);
597
- const numberedCaptions = numberCaptions(captionScan.captions, captionCounts);
598
874
  linkCtx.captionLabels = new Map(
599
- numberedCaptions.map((caption) => [caption.id, caption.label]),
875
+ numberedFigures
876
+ .filter((figure) => figure.id)
877
+ .map((figure) => [figure.id, figure.label]),
600
878
  );
601
879
  // This page's own anchors, so a `[[#slug]]` self-link is checked
602
880
  // against what the page actually declares, exactly as the site build
603
881
  // checks it.
604
882
  linkCtx.anchors = new Set(collectAnchors(markdown).map((anchor) => anchor.slug));
605
883
  linkCtx.output = "book";
884
+ // What the `ref` expression helper needs beyond the label — the
885
+ // caption text and whether one was authored — keyed as
886
+ // `engine/site-build.mjs` keys its own, but built from this note's
887
+ // *book-wide* numbering rather than its page-local count: the book
888
+ // numbers each kind across the whole book in reading order, so a
889
+ // reference has to read the number the book gives the figure, not the
890
+ // number the note would give it alone.
891
+ const figuresById = new Map(
892
+ numberedFigures
893
+ .filter((figure) => figure.id)
894
+ .map((figure) => [
895
+ figure.id,
896
+ { label: figure.label, caption: figure.caption, hasCaption: figure.hasCaption },
897
+ ]),
898
+ );
606
899
  // Code fences are protected for the same reason every other pass
607
900
  // protects them: a wikilink shown as an example is prose about a
608
901
  // wikilink, and resolving it would make the example impossible to write.
@@ -612,6 +905,25 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
612
905
  sqlResults: sqlTables?.inline?.get(page.file),
613
906
  file: page.file,
614
907
  bodyLine: page.bodyLine,
908
+ figures: {
909
+ get: (id) => figuresById.get(id),
910
+ // A cross-note `ref` resolves the written address exactly as
911
+ // a wikilink does, against every note's figures numbered
912
+ // before any entry's expressions render — see `prepared`
913
+ // above. The default link renders the target's own web
914
+ // address, which `renderLink` in `engine/pdf-render.mjs`
915
+ // resolves to the entry the book gave it, exactly as an
916
+ // ordinary cross-note wikilink does.
917
+ note: (target) =>
918
+ resolveCrossNoteFigures(target, {
919
+ contentTypes: gates.index.contentTypes,
920
+ packages: gates.index.packages,
921
+ noIndexPackages: gates.index.noIndexPackages,
922
+ contentPackage: gates.index.contentPackage,
923
+ siteIndexMap: gates.index.index,
924
+ figuresByAddress,
925
+ }),
926
+ },
615
927
  });
616
928
  findings.push(...expressions.findings);
617
929
  const resolvedBody = protectCode(expressions.markdown, (text) =>
@@ -657,21 +969,24 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
657
969
  links: plan.links,
658
970
  glyphs,
659
971
  images,
972
+ assets: imageAssets,
660
973
  headingOffset,
661
974
  anchorPrefix,
662
- captions: numberedCaptions,
975
+ captions: numberedFigures,
663
976
  url: site,
664
977
  findings,
665
978
  file: page.file,
666
979
  bodyLine: page.bodyLine,
667
980
  lineMap,
668
981
  prepared: true,
982
+ resolveRole: (pathname) => pathRoles.get(pathname),
669
983
  });
670
984
  // Infoboxes follow the authored body within this note's entry.
671
985
  const boxes = noteInfoboxes(page.fm, {
672
986
  resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
673
987
  router: routerFor(resolved),
674
988
  dates: gates.index.dateContext,
989
+ contentPackage: gates.index.contentPackage,
675
990
  });
676
991
  const panel = infoboxesToTypst(boxes, {
677
992
  link: (value) => linkToTypst(value, plan.links, labelFor, site),
@@ -724,9 +1039,10 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
724
1039
  links: plan.links,
725
1040
  glyphs,
726
1041
  images,
1042
+ assets: imageAssets,
727
1043
  headingOffset: entry.depth,
728
1044
  anchorPrefix: entry.anchor,
729
- captions: numberCaptions(scanCaptions(text).captions, captionCounts),
1045
+ captions: prepared.get(entry.anchor).numberedFigures,
730
1046
  url: site,
731
1047
  findings,
732
1048
  file,
@@ -753,8 +1069,9 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
753
1069
  links: plan.links,
754
1070
  glyphs,
755
1071
  images,
1072
+ assets: imageAssets,
756
1073
  footnotePrefix: `footnote-front-${index + 1}`,
757
- captions: frontCaptions.get(file),
1074
+ captions: frontFigures.get(file),
758
1075
  url: site,
759
1076
  findings,
760
1077
  file,
@@ -790,7 +1107,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
790
1107
  const stageMapBackground = (entry, value, title) => {
791
1108
  const file = noteFile(contentBase, entry.record);
792
1109
  const found = artPathname(assets, value, "image", ["image", "icon"]);
793
- const staged = stagedImagePath(found.pathname ?? value, resolved);
1110
+ let staged;
1111
+ try {
1112
+ staged = stagedImagePath(found.pathname ?? value, resolved, stagingOpts);
1113
+ } catch (err) {
1114
+ findings.push({ file, severity: "error", message: err.message });
1115
+ return;
1116
+ }
794
1117
  if (!staged || !fs.existsSync(staged.from)) {
795
1118
  findings.push({
796
1119
  file,
@@ -40,6 +40,70 @@ export const pdfTextHeight = PDF_PAGE.height - 2 * PDF_PAGE.margin;
40
40
  export const pdfColumnWidth = (columns = 2) =>
41
41
  (pdfTextWidth * (1 - 0.04 * (columns - 1))) / columns;
42
42
 
43
+ /**
44
+ * The fraction of the page's one-column measure a picture's role may fill at
45
+ * most — one table, read by {@link bookImageWidthIn}, so a policy change here
46
+ * is the one line it costs rather than an edit to every note drawing a role.
47
+ *
48
+ * A `portrait` or an `emblem` names a subject or a mark, which sits beside
49
+ * prose rather than replacing a page of it; a `banner`, a `plate` and a `map`
50
+ * are each meant to be looked at in full. The measure is the page's own text
51
+ * width, {@link pdfTextWidth}, so a role's slot does not shrink when a page
52
+ * happens to be set in two columns — it is what the picture is *for* that
53
+ * sets the slot, not the layout it lands in.
54
+ *
55
+ * @type {Readonly<Record<string, number>>}
56
+ */
57
+ export const BOOK_ROLE_SLOTS = Object.freeze({
58
+ portrait: 0.5,
59
+ emblem: 0.5,
60
+ banner: 1,
61
+ plate: 1,
62
+ map: 1,
63
+ });
64
+
65
+ /**
66
+ * The book's nominal width for an icon-type picture, in inches, whatever its
67
+ * file holds — almost every icon is an SVG with no pixel count to measure.
68
+ *
69
+ * @type {number}
70
+ */
71
+ export const BOOK_ICON_WIDTH_IN = 1;
72
+
73
+ /**
74
+ * The width the book draws one picture at, in inches, when nothing authored
75
+ * narrows it to a named `size=`.
76
+ *
77
+ * > drawn width = min(the role's slot, natural pixels ÷ the print floor)
78
+ *
79
+ * **Every size here is a maximum, never a target**: a picture is never drawn
80
+ * larger than it was made. The print floor is {@link PDF_PAGE.dpi}, the same
81
+ * constant the page geometry above is set against, so a role's slot and the
82
+ * page it is drawn on cannot disagree about what a pixel is worth.
83
+ *
84
+ * An icon-type address takes the nominal icon width whatever role it might
85
+ * otherwise carry — `role` never reaches one, since the asset record refuses
86
+ * it there. A vector carries no pixel count, so nothing narrows its role's
87
+ * slot. A picture declaring no role answers `undefined`: the medium's
88
+ * ordinary size, decided elsewhere by a named `size=` or by its own natural
89
+ * pixels within whatever it is set in.
90
+ *
91
+ * @param {object} [asset] - What {@link module:engine/art-fields.assetImageInfoByPathname}
92
+ * records for the picture's address.
93
+ * @param {string} [asset.type] - The asset's type — `icon` or `image`.
94
+ * @param {string} [asset.role] - The picture's declared role.
95
+ * @param {number|""} [asset.width] - The file's own pixel width; blank for a vector.
96
+ * @returns {number|undefined} The drawn width, in inches.
97
+ */
98
+ export function bookImageWidthIn({ type, role, width } = {}) {
99
+ if (type === "icon") return BOOK_ICON_WIDTH_IN;
100
+ const fraction = role ? BOOK_ROLE_SLOTS[role] : undefined;
101
+ if (!fraction) return undefined;
102
+ const slot = fraction * pdfTextWidth;
103
+ if (typeof width !== "number" || !Number.isFinite(width) || width <= 0) return slot;
104
+ return Math.min(slot, width / PDF_PAGE.dpi);
105
+ }
106
+
43
107
  /**
44
108
  * Optimize staged raster images without changing their authored files.
45
109
  *