@heroiclands/package-build 22.22.0 → 22.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +1 -0
- package/bin/content-commands.mjs +51 -14
- package/ci/content-format-reference.mjs +1 -1
- package/content-config.mjs +80 -2
- package/docs/api.md +85 -72
- package/docs/authoring/assets.md +29 -0
- package/docs/authoring/dates-and-calendars.md +37 -1
- package/docs/authoring/links-and-markup.md +112 -31
- package/docs/commands.md +30 -29
- package/docs/configuration.md +92 -19
- package/docs/content-format.md +1 -1
- package/docs/guides/book.md +5 -4
- package/docs/guides/module-project.md +2 -2
- package/docs/guides/site.md +41 -0
- package/docs/project-setup.md +4 -4
- package/docs/reference/format-details.md +323 -60
- package/docs/reference/note-types.md +9 -9
- package/docs/reference/theme-pages.md +496 -0
- package/engine/anchors.mjs +16 -10
- package/engine/art-fields.mjs +118 -0
- package/engine/art-slots.mjs +9 -2
- package/engine/asset-index.mjs +160 -10
- package/engine/asset-replacement.mjs +93 -0
- package/engine/asset-types.mjs +240 -0
- package/engine/base-compiler.mjs +54 -2
- package/engine/bundles.mjs +13 -1
- package/engine/content-blocks.mjs +4 -4
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +10 -2
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +36 -10
- package/engine/field-reference.mjs +31 -10
- package/engine/field-spec.mjs +10 -8
- package/engine/foreign-catalog.mjs +145 -19
- package/engine/frontmatter-lint.mjs +73 -1
- package/engine/heading-attributes.mjs +31 -18
- package/engine/helpers.mjs +102 -33
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox.mjs +47 -8
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +264 -39
- package/engine/lore-events.mjs +197 -0
- package/engine/markdown-expressions.mjs +257 -7
- package/engine/note-dates.mjs +305 -12
- package/engine/note-vocabulary.mjs +11 -5
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +357 -34
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +160 -36
- package/engine/scenes.mjs +16 -4
- package/engine/site-build.mjs +127 -13
- package/engine/site-config.mjs +19 -24
- package/engine/site-index.mjs +72 -1
- package/engine/site-preview.mjs +2 -0
- package/engine/system-block.mjs +44 -16
- package/hm3/actors.mjs +2 -2
- package/hugo-theme/data/banners.yaml +69 -0
- package/hugo-theme/layouts/404.html +85 -0
- package/hugo-theme/layouts/_default/baseof.html +64 -0
- package/hugo-theme/layouts/_default/list.html +139 -0
- package/hugo-theme/layouts/_default/single.html +4 -0
- package/hugo-theme/layouts/blog/list.html +54 -0
- package/hugo-theme/layouts/blog-post/single.html +36 -0
- package/hugo-theme/layouts/index.html +75 -0
- package/hugo-theme/layouts/partials/banner-url.html +117 -0
- package/hugo-theme/layouts/partials/cdn-url.html +36 -0
- package/hugo-theme/layouts/partials/draft-notice.html +41 -0
- package/hugo-theme/layouts/partials/draft-state.html +26 -0
- package/hugo-theme/layouts/partials/footer.html +29 -0
- package/hugo-theme/layouts/partials/header.html +35 -0
- package/hugo-theme/layouts/partials/hero-banner.html +86 -0
- package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
- package/hugo-theme/layouts/partials/holdings.html +61 -0
- package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
- package/hugo-theme/layouts/partials/infobox/items.html +26 -0
- package/hugo-theme/layouts/partials/infobox/section.html +56 -0
- package/hugo-theme/layouts/partials/infobox/value.html +43 -0
- package/hugo-theme/layouts/partials/infobox.html +57 -0
- package/hugo-theme/layouts/partials/page-body.html +154 -0
- package/hugo-theme/layouts/partials/place-map.html +9 -0
- package/hugo-theme/layouts/partials/related.html +121 -0
- package/hugo-theme/layouts/partials/search.html +84 -0
- package/hugo-theme/layouts/partials/site-url.html +32 -0
- package/hugo-theme/layouts/partials/tags.html +30 -0
- package/hugo-theme/layouts/partials/toc.html +28 -0
- package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
- package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
- package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
- package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
- package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
- package/hugo-theme/static/css/sohl-icons.css +57 -0
- package/hugo-theme/static/css/style.css +2953 -0
- package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
- package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
- package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
- package/hugo-theme/theme.toml +11 -0
- package/manifest.mjs +30 -24
- package/package.json +2 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/item-fields.mjs +4 -3
- package/types/content-config.d.mts +48 -1
- package/types/engine/anchors.d.mts +9 -4
- package/types/engine/art-fields.d.mts +64 -0
- package/types/engine/art-slots.d.mts +2 -1
- package/types/engine/asset-index.d.mts +43 -0
- package/types/engine/asset-replacement.d.mts +22 -0
- package/types/engine/asset-types.d.mts +18 -0
- package/types/engine/base-compiler.d.mts +0 -17
- package/types/engine/content-embeds.d.mts +16 -0
- package/types/engine/content-figures.d.mts +101 -0
- package/types/engine/content-footnotes.d.mts +7 -0
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- package/types/engine/field-reference.d.mts +21 -0
- package/types/engine/field-spec.d.mts +20 -16
- package/types/engine/foreign-catalog.d.mts +39 -0
- package/types/engine/heading-attributes.d.mts +13 -8
- package/types/engine/helpers.d.mts +28 -10
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +31 -9
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +23 -2
- package/types/engine/note-dates.d.mts +64 -1
- package/types/engine/pathnames.d.mts +26 -0
- package/types/engine/pdf-build.d.mts +28 -6
- package/types/engine/pdf-images.d.mts +52 -0
- package/types/engine/pdf-render.d.mts +12 -0
- package/types/engine/site-build.d.mts +14 -3
- package/types/engine/site-config.d.mts +9 -9
- package/types/engine/site-index.d.mts +54 -0
- package/types/engine/system-block.d.mts +26 -2
- package/types/hm3/actors.d.mts +18 -0
- package/types/manifest.d.mts +19 -23
- package/engine/content-captions.mjs +0 -230
- package/types/engine/content-captions.d.mts +0 -58
package/engine/pdf-build.mjs
CHANGED
|
@@ -94,18 +94,25 @@ import {
|
|
|
94
94
|
gatesFailed,
|
|
95
95
|
resolveSitePass,
|
|
96
96
|
} from "./site-build.mjs";
|
|
97
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
148
|
-
* package's file
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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"
|
|
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.
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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
|
|
544
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 } =
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
package/engine/pdf-images.mjs
CHANGED
|
@@ -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
|
*
|