@heroiclands/package-build 24.0.0 → 24.0.2

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 (61) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +9 -7
  5. package/docs/api.md +6 -5
  6. package/docs/authoring/links-and-markup.md +164 -16
  7. package/docs/commands.md +23 -19
  8. package/docs/configuration.md +16 -0
  9. package/docs/content-format.md +4 -2
  10. package/docs/guides/site.md +43 -0
  11. package/docs/guides/writing-in-invented-scripts.md +68 -6
  12. package/docs/reference/format-details.md +143 -49
  13. package/docs/reference/note-types.md +47 -28
  14. package/docs/source-preview.md +2 -2
  15. package/engine/content-blocks.mjs +6 -2
  16. package/engine/content-format.yaml +2 -0
  17. package/engine/content-index.mjs +5 -1
  18. package/engine/content-render.mjs +2 -1
  19. package/engine/content-scripts.mjs +268 -21
  20. package/engine/content-scroll.mjs +389 -0
  21. package/engine/font-outlines.mjs +394 -0
  22. package/engine/language-data.mjs +154 -63
  23. package/engine/language-rows.mjs +32 -6
  24. package/engine/language-terms.mjs +32 -8
  25. package/engine/map-lexicon.mjs +31 -6
  26. package/engine/note-vocabulary.mjs +58 -6
  27. package/engine/pdf-fonts.mjs +1 -1
  28. package/engine/pdf-render.mjs +52 -11
  29. package/engine/run-rows.mjs +45 -3
  30. package/engine/script-direction.mjs +67 -0
  31. package/engine/script-emphasis-terms.mjs +11 -3
  32. package/engine/script-font-delivery.mjs +42 -9
  33. package/engine/script-fonts.mjs +7 -0
  34. package/engine/site-build.mjs +8 -1
  35. package/engine/site-reach.mjs +213 -0
  36. package/engine/site-render.mjs +120 -45
  37. package/engine/site-root.mjs +68 -26
  38. package/engine/site-serve.mjs +2 -1
  39. package/engine/site-theme.mjs +19 -6
  40. package/engine/sql-tables.mjs +9 -6
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +1 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/engine/content-scripts.d.mts +31 -6
  46. package/types/engine/content-scroll.d.mts +179 -0
  47. package/types/engine/font-outlines.d.mts +67 -0
  48. package/types/engine/language-data.d.mts +13 -0
  49. package/types/engine/language-rows.d.mts +4 -3
  50. package/types/engine/language-terms.d.mts +7 -0
  51. package/types/engine/map-lexicon.d.mts +4 -3
  52. package/types/engine/pdf-fonts.d.mts +10 -0
  53. package/types/engine/run-rows.d.mts +28 -6
  54. package/types/engine/script-direction.d.mts +10 -0
  55. package/types/engine/script-emphasis-terms.d.mts +10 -3
  56. package/types/engine/script-font-delivery.d.mts +5 -3
  57. package/types/engine/script-fonts.d.mts +9 -0
  58. package/types/engine/site-reach.d.mts +76 -0
  59. package/types/engine/site-render.d.mts +4 -2
  60. package/types/engine/site-root.d.mts +2 -0
  61. package/types/engine/written-in.d.mts +76 -0
@@ -309,11 +309,14 @@ function languagesOf(records) {
309
309
  * @param {string} [options.prefix] - The prefix of a key sharing an identity
310
310
  * column's name.
311
311
  * @param {readonly string[]} [options.lists] - The keys always set as lists.
312
+ * @param {(records: Iterable<Record<string, any>>) => Array<[string, Record<string, any>]>} [options.sources] -
313
+ * The records stating the list, with their Addresses; the language records
314
+ * where unstated.
312
315
  * @returns {object[]} One row per entry, in record then entry order.
313
316
  */
314
- function listRows(records, read, keys, { prefix = "", lists = [] } = {}) {
317
+ function listRows(records, read, keys, { prefix = "", lists = [], sources = languagesOf } = {}) {
315
318
  const rows = [];
316
- for (const [language, record] of languagesOf(records)) {
319
+ for (const [language, record] of sources(records)) {
317
320
  const entries = read(isMap(record.data) ? record.data : {});
318
321
  if (!Array.isArray(entries)) continue;
319
322
  entries.forEach((entry, position) => {
@@ -328,6 +331,24 @@ function listRows(records, read, keys, { prefix = "", lists = [] } = {}) {
328
331
  return rows;
329
332
  }
330
333
 
334
+ /**
335
+ * Each record stating letters and marks, with its Address: every language
336
+ * record, and every `doc` record whose `data.sounds` is a spelling guide's.
337
+ *
338
+ * @param {Iterable<Record<string, any>>} records - Index records.
339
+ * @returns {Array<[string, Record<string, any>]>}
340
+ */
341
+ function soundSourcesOf(records) {
342
+ const list = [...records];
343
+ const out = languagesOf(list);
344
+ for (const record of list) {
345
+ if (record?.type !== "doc" || record.documents || !isMap(record.data?.sounds)) continue;
346
+ const address = noteAddressOf(record);
347
+ if (address) out.push([address, record]);
348
+ }
349
+ return out;
350
+ }
351
+
331
352
  /** A language's `data.sounds`, or an empty map. */
332
353
  const soundsOf = (data) => (isMap(data.sounds) ? data.sounds : {});
333
354
 
@@ -494,8 +515,8 @@ export function scopeRows(records) {
494
515
  }
495
516
 
496
517
  /**
497
- * The `letters` rows: one per `data.sounds.letters` entry, `letters` and
498
- * `tongue` lists.
518
+ * The `letters` rows: one per `data.sounds.letters` entry of a language note
519
+ * or a spelling guide, `letters` and `tongue` lists.
499
520
  *
500
521
  * @param {Iterable<Record<string, any>>} records - Index records.
501
522
  * @returns {object[]}
@@ -503,6 +524,7 @@ export function scopeRows(records) {
503
524
  export function letterRows(records) {
504
525
  return listRows(records, (data) => soundsOf(data).letters, LETTER_KEYS, {
505
526
  lists: ["letters", "tongue"],
527
+ sources: soundSourcesOf,
506
528
  });
507
529
  }
508
530
 
@@ -875,13 +897,17 @@ export function joinRows(records) {
875
897
  }
876
898
 
877
899
  /**
878
- * The `marks` rows: one per `data.sounds.marks` entry.
900
+ * The `marks` rows: one per `data.sounds.marks` entry of a language note or a
901
+ * spelling guide.
879
902
  *
880
903
  * @param {Iterable<Record<string, any>>} records - Index records.
881
904
  * @returns {object[]}
882
905
  */
883
906
  export function markRows(records) {
884
- return listRows(records, (data) => soundsOf(data).marks, MARK_KEYS, { lists: ["letters"] });
907
+ return listRows(records, (data) => soundsOf(data).marks, MARK_KEYS, {
908
+ lists: ["letters"],
909
+ sources: soundSourcesOf,
910
+ });
885
911
  }
886
912
 
887
913
  /**
@@ -130,7 +130,7 @@ export const CLUSTER_USES = Object.freeze(["common", "sparing"]);
130
130
  export const STRESS_TWO = Object.freeze(["first", "last"]);
131
131
 
132
132
  /** The syllable a word of three or more stresses, counted from its end. */
133
- export const STRESS_LONGER = Object.freeze(["antepenultimate", "penultimate", "final"]);
133
+ export const STRESS_LONGER = Object.freeze(["first", "antepenultimate", "penultimate", "final"]);
134
134
 
135
135
  /**
136
136
  * The diacritics that mark a stress the rule would not choose, with the
@@ -377,13 +377,20 @@ export const LETTER_FIELDS = Object.freeze([
377
377
  shape: "a string, such as `lips` or `tongue-tip`",
378
378
  describe: "Where in the mouth they are made.",
379
379
  }),
380
+ Object.freeze({
381
+ name: "ipa",
382
+ kind: "string",
383
+ shape: "a string in the International Phonetic Alphabet, such as `ʃ`",
384
+ describe: "The sounds they write, in the International Phonetic Alphabet.",
385
+ }),
380
386
  Object.freeze({
381
387
  name: "tongue",
382
388
  kind: "string-or-list",
383
- shape: "a tag, or a list of them",
389
+ shape: "a tag or a language note's Address, or a list of them",
384
390
  entries: STRINGS,
385
391
  describe:
386
- "The tongues that write them, as tags `data.tongues` declares; every one where absent.",
392
+ "The tongues that write them, each a tag `data.tongues` declares or the Address of a " +
393
+ "language note; every one where absent. A `doc` note's rows name language notes only.",
387
394
  }),
388
395
  ]);
389
396
 
@@ -529,6 +536,12 @@ export const FINAL_FIELDS = Object.freeze([
529
536
  shape: "a string",
530
537
  describe: "What it sounds like.",
531
538
  }),
539
+ Object.freeze({
540
+ name: "ipa",
541
+ kind: "string",
542
+ shape: "a string in the International Phonetic Alphabet, such as `ʃ`",
543
+ describe: "Its sound, in the International Phonetic Alphabet.",
544
+ }),
532
545
  ]);
533
546
 
534
547
  /**
@@ -718,7 +731,8 @@ export const STRESS_FIELDS = Object.freeze([
718
731
  required: true,
719
732
  allowed: STRESS_LONGER,
720
733
  shape: `one of ${STRESS_LONGER.join(", ")}`,
721
- describe: "The stressed syllable of a word of three or more, counted from its end.",
734
+ describe:
735
+ "The stressed syllable of a word of three or more: `first`, or counted from its end.",
722
736
  }),
723
737
  Object.freeze({
724
738
  name: "mark",
@@ -914,10 +928,10 @@ export const SOUND_FIELDS = Object.freeze([
914
928
  Object.freeze({
915
929
  name: "letters",
916
930
  kind: "list",
917
- shape: "list of `{ letters, kind, like, tongue? }`",
931
+ shape: "list of `{ letters, kind, like?, place?, ipa?, tongue? }`",
918
932
  entries: Object.freeze({
919
933
  kind: "map",
920
- shape: "a map — `{ letters, kind, like, tongue? }`",
934
+ shape: "a map — `{ letters, kind, like?, place?, ipa?, tongue? }`",
921
935
  fields: LETTER_FIELDS,
922
936
  }),
923
937
  describe: "The letters the language writes, and the sounds they stand for.",
@@ -950,10 +964,10 @@ export const SOUND_FIELDS = Object.freeze([
950
964
  Object.freeze({
951
965
  name: "finals",
952
966
  kind: "list",
953
- shape: "list of `{ final, like }`",
967
+ shape: "list of `{ final, like, ipa? }`",
954
968
  entries: Object.freeze({
955
969
  kind: "map",
956
- shape: "a map — `{ final, like }`",
970
+ shape: "a map — `{ final, like, ipa? }`",
957
971
  fields: FINAL_FIELDS,
958
972
  }),
959
973
  describe: "The syllable finals, for a language whose syllables are parsed by them.",
@@ -1047,6 +1061,16 @@ export const SOUND_FIELDS = Object.freeze([
1047
1061
  }),
1048
1062
  ]);
1049
1063
 
1064
+ /**
1065
+ * The keys of a spelling guide's `data.sounds`: a `doc` note stating the
1066
+ * letters and marks every tongue of a setting is written by.
1067
+ *
1068
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
1069
+ */
1070
+ export const GUIDE_SOUND_FIELDS = Object.freeze(
1071
+ SOUND_FIELDS.filter((field) => field.name === "letters" || field.name === "marks"),
1072
+ );
1073
+
1050
1074
  /**
1051
1075
  * The keys of one `data.soundChanges` entry.
1052
1076
  *
@@ -55,6 +55,7 @@ import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs
55
55
  import { isNoteRecord } from "./index-records.mjs";
56
56
  import { nearest } from "./near-miss.mjs";
57
57
  import { WRITTEN_ENDINGS } from "./language-terms.mjs";
58
+ import { fromWrittenIn } from "./written-in.mjs";
58
59
  import { writingsOf } from "./logographic.mjs";
59
60
  import { FONTS_PACKAGE } from "./script-fonts.mjs";
60
61
  import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
@@ -386,14 +387,37 @@ export function fontProblem(map, font) {
386
387
  }
387
388
 
388
389
  /**
389
- * The mapping a command uses, or what is wrong with what it was given. A map
390
- * listing one face defaults to it; one listing more needs `font`.
390
+ * The mapping a command uses, or what is wrong with what it was given. The
391
+ * language's `data.writtenIn` fills the map and the face it leaves out, as it
392
+ * fills a run's; after that, a map listing one face defaults to it, and one
393
+ * listing more needs `font`.
391
394
  *
392
395
  * @param {object} ctx - The run context.
393
- * @param {{map: string, font?: string, lang?: string}} keys - What was asked for.
396
+ * @param {{map?: string, font?: string, lang?: string}} keys - What was asked for.
394
397
  * @returns {{mapping: Mapping}|{problem: string, about: "map"|"lang"|"font"}} The mapping or the problem.
395
398
  */
396
- function commandMapping(ctx, { map, font, lang }) {
399
+ function commandMapping(ctx, keys) {
400
+ const { lang } = keys;
401
+ const filled = fromWrittenIn(keys, ctx?.writtenIn, ctx?.maps);
402
+ const map = keys.map ?? filled.map;
403
+ const font = keys.font ?? filled.font;
404
+ if (map === undefined) {
405
+ if (lang !== undefined && !Object.hasOwn(ctx?.languages ?? {}, lang)) {
406
+ const near = nearest(lang, Object.keys(ctx?.languages ?? {}));
407
+ return {
408
+ about: "lang",
409
+ problem:
410
+ `\`${lang}\` is not a declared language (\`languages\` in ${CONFIG})` +
411
+ (near ? `; did you mean \`${near}\`?` : ""),
412
+ };
413
+ }
414
+ return {
415
+ about: "map",
416
+ problem:
417
+ `\`${lang}\` names no script: its note states no \`data.writtenIn\`, so name a ` +
418
+ "map with `--map`",
419
+ };
420
+ }
397
421
  const textMap = ctx?.maps?.get(map);
398
422
  if (!textMap) {
399
423
  const names = [...(ctx?.maps?.keys() ?? [])];
@@ -543,8 +567,9 @@ export function transliterate(ctx, keys, text) {
543
567
  * writing, the signs a run writes directly for that sense.
544
568
  *
545
569
  * @param {object} ctx - The run context.
546
- * @param {{lang: string, map: string, font?: string, derived?: boolean}} keys -
547
- * The language, the map, the face, and whether to list derived forms.
570
+ * @param {{lang: string, map?: string, font?: string, derived?: boolean}} keys -
571
+ * The language, the map, the face, and whether to list derived forms. A map
572
+ * or a face left out is the one the language's `data.writtenIn` names.
548
573
  * @returns {{entries: Array<{form: string, native: string, exception?: boolean, source?: "own"|"parts"|"none", ambiguous?: true, class: string|null, gloss: string|null, from?: string}>}|{problem: string, about: string}}
549
574
  * The words in `data.words` order, then any derived forms, or the problem.
550
575
  */
@@ -139,8 +139,8 @@ import {
139
139
  checkLanguageWords,
140
140
  } from "./language-words.mjs";
141
141
  import { checkGlossary } from "./glossary-entries.mjs";
142
- import { LANGUAGE_DATA_FIELDS } from "./language-terms.mjs";
143
- import { checkLanguageData } from "./language-data.mjs";
142
+ import { GUIDE_SOUND_FIELDS, LANGUAGE_DATA_FIELDS } from "./language-terms.mjs";
143
+ import { checkGuideSounds, checkLanguageData } from "./language-data.mjs";
144
144
  import { checkAffiliationReach } from "./culture-reach.mjs";
145
145
  import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
146
146
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
@@ -163,7 +163,12 @@ import { checkMapField } from "./text-maps.mjs";
163
163
  import { checkConceptList, checkCoreFlag } from "./core-vocabulary.mjs";
164
164
  import { METRE_KINDS, checkMetresField } from "./metre-declaration.mjs";
165
165
  import { checkLanguageEmphasis, checkScriptField } from "./script-emphasis.mjs";
166
- import { EMPHASIS_CONVENTIONS, LANGUAGE_EMPHASES } from "./script-emphasis-terms.mjs";
166
+ import { WRITTEN_IN_KEY, checkWrittenIn } from "./written-in.mjs";
167
+ import {
168
+ EMPHASIS_CONVENTIONS,
169
+ LANGUAGE_EMPHASES,
170
+ SCRIPT_DIRECTIONS,
171
+ } from "./script-emphasis-terms.mjs";
167
172
 
168
173
  /**
169
174
  * One `data:` key a note type may carry.
@@ -2182,10 +2187,36 @@ export const NOTE_VOCABULARY = Object.freeze({
2182
2187
  "and `words` marks it with a word, so either is a finding. Only a skill of " +
2183
2188
  "`subType: language` states it.",
2184
2189
  },
2190
+ {
2191
+ name: WRITTEN_IN_KEY,
2192
+ kind: "map",
2193
+ shape: "a map — `{ map, font }`",
2194
+ check: checkWrittenIn,
2195
+ fields: Object.freeze([
2196
+ {
2197
+ name: "map",
2198
+ kind: "string",
2199
+ shape: "a mapping note's `data.map.name`",
2200
+ required: true,
2201
+ describe: "The map the language's text is written by.",
2202
+ },
2203
+ {
2204
+ name: "font",
2205
+ kind: "string",
2206
+ shape: "an installed face's slug",
2207
+ required: true,
2208
+ describe: "The face the text is set in, one the map is drawn in.",
2209
+ },
2210
+ ]),
2211
+ describe:
2212
+ "The script the language is written in, as the map and the face a run marked " +
2213
+ "with its name alone takes; a run's own `map=` or `font=` overrides it. Only a " +
2214
+ "skill of `subType: language` states it.",
2215
+ },
2185
2216
  {
2186
2217
  name: "script",
2187
2218
  kind: "map",
2188
- shape: "a map — `{ fonts, emphasis, strongEmphasis? }`",
2219
+ shape: "a map — `{ fonts, emphasis, strongEmphasis?, direction? }`",
2189
2220
  check: checkScriptField,
2190
2221
  fields: Object.freeze([
2191
2222
  {
@@ -2220,10 +2251,20 @@ export const NOTE_VOCABULARY = Object.freeze({
2220
2251
  allowed: EMPHASIS_CONVENTIONS,
2221
2252
  describe: "The convention `**…**` takes instead of `emphasis`'s.",
2222
2253
  },
2254
+ {
2255
+ name: "direction",
2256
+ kind: "string",
2257
+ shape: "`vertical-rl`",
2258
+ allowed: SCRIPT_DIRECTIONS,
2259
+ describe:
2260
+ "How the script is written: `vertical-rl` is columns, top to bottom, " +
2261
+ "read from the right, which a `scroll` fence sets. A script stating " +
2262
+ "none is written in lines.",
2263
+ },
2223
2264
  ]),
2224
2265
  describe:
2225
- "A script note's faces and how it marks emphasis. Only a skill of " +
2226
- "`subType: script` states it.",
2266
+ "A script note's faces, how it marks emphasis and the direction it is " +
2267
+ "written in. Only a skill of `subType: script` states it.",
2227
2268
  },
2228
2269
  ]),
2229
2270
  }),
@@ -2323,6 +2364,17 @@ export const NOTE_VOCABULARY = Object.freeze({
2323
2364
  "The culture a setting guide introduces, as a culture lore note. " +
2324
2365
  "Required on `settingguide`; no other subType states it.",
2325
2366
  },
2367
+ {
2368
+ name: "sounds",
2369
+ kind: "map",
2370
+ shape: "a map — `{ letters?, marks? }`",
2371
+ fields: GUIDE_SOUND_FIELDS,
2372
+ check: checkGuideSounds,
2373
+ describe:
2374
+ "A spelling guide's letters and the marks written on them, for every " +
2375
+ "tongue of the setting: each letter row's `tongue` names the language notes " +
2376
+ "that write it.",
2377
+ },
2326
2378
  {
2327
2379
  name: "map",
2328
2380
  kind: "map",
@@ -55,7 +55,7 @@ import { familyOf } from "./content-icons.mjs";
55
55
  * @param {Buffer} buf - The font file.
56
56
  * @returns {Map<string, {offset: number, length: number}>} Its table directory.
57
57
  */
58
- function tableDirectory(buf) {
58
+ export function tableDirectory(buf) {
59
59
  const tables = new Map();
60
60
  if (buf.length < 12) return tables;
61
61
  // A TrueType collection points at its first font; a bare font starts at 0.
@@ -14,10 +14,11 @@
14
14
  /**
15
15
  * A note's markdown, and a document plan, rendered as Typst source.
16
16
  *
17
- * **This module emits text and reads nothing.** It takes markdown and a plan and
18
- * returns a `.typ` document; the filesystem, the note bodies and the compiler
19
- * that turns the result into a PDF all live in
20
- * {@link module:engine/pdf-build}. That split is what lets the outline, the
17
+ * **This module emits text and reads nothing but a face's outlines.** It takes
18
+ * markdown and a plan and returns a `.typ` document; the filesystem, the note
19
+ * bodies and the compiler that turns the result into a PDF all live in
20
+ * {@link module:engine/pdf-build}. The one file it reads is the TrueType face a
21
+ * `scroll` fence is drawn from, through {@link module:engine/font-outlines}. That split is what lets the outline, the
21
22
  * table of contents, every anchor and every link destination be asserted in a
22
23
  * unit test with no renderer installed — which is most of what a book has to
23
24
  * get right, and all of what a test can check without eyes.
@@ -95,6 +96,8 @@ import {
95
96
  verseStanzas,
96
97
  } from "./content-verse.mjs";
97
98
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
99
+ import { panelRoman, scanScrolls, scrollPanels, scrollParagraphs } from "./content-scroll.mjs";
100
+ import { BOOK_INK, BOOK_RUBRIC, fontOutlines, scrollPanelSvg } from "./font-outlines.mjs";
98
101
  import { HTML_TAG, htmlMessage } from "./content-html.mjs";
99
102
  import { EXPRESSION } from "./markdown-expressions.mjs";
100
103
  import { inlineText } from "./name-markup.mjs";
@@ -498,11 +501,12 @@ export function markdownToTypst(markdown, opts = {}) {
498
501
  // is an offset into *this* string, and the renderer below walks slices of it.
499
502
  if (!opts.nested) reportUnrenderable(authored, definitions, opts, given);
500
503
  // Text marked with a language, a font or a map is rewritten once, ahead of
501
- // every other pass, line for line; the nested calls below read the result.
502
- const source =
503
- opts.nested ? authored : (
504
- renderRuns(authored, opts.runs, undefined, { target: "book" }).markdown
505
- );
504
+ // every other pass, line for line; the nested calls below read the result,
505
+ // and the scroll fences' layouts the rewrite returns.
506
+ const marked =
507
+ opts.nested ? undefined : renderRuns(authored, opts.runs, undefined, { target: "book" });
508
+ const source = marked ? marked.markdown : authored;
509
+ if (marked) sharedOptions.scrolls = marked.scrolls;
506
510
  // One map for the whole body, not one per block: a heading inside a
507
511
  // blockquote or a list item shares the entry's anchor namespace with every
508
512
  // other heading in the same body, because `sectionLabel` scopes by entry
@@ -522,6 +526,7 @@ export function markdownToTypst(markdown, opts = {}) {
522
526
  seen: new Map(),
523
527
  footnoteState,
524
528
  footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
529
+ scrolls: marked?.scrolls ?? opts.scrolls ?? new Map(),
525
530
  };
526
531
  // A withheld section is set in the box a `:::secret` is set in, and its
527
532
  // heading is set inside it: a printed page honours no reader permission, so
@@ -752,6 +757,10 @@ function renderBlock(tokens, i, out, ctx) {
752
757
  }
753
758
  case "fence":
754
759
  case "code_block": {
760
+ if (token.type === "fence" && /^scroll(?:\s|$)/.test(token.info?.trim() || "")) {
761
+ out.push(scrollBlock(token, ctx));
762
+ return 1;
763
+ }
755
764
  if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
756
765
  const opening = `${token.markup}${token.info}`;
757
766
  const poem = scanVerse(`${opening}\n${token.content}${token.markup}`).blocks[0];
@@ -1199,6 +1208,38 @@ function padCells(cells, columns) {
1199
1208
  return padded.map((c) => `[${c}]`).join(", ");
1200
1209
  }
1201
1210
 
1211
+ /**
1212
+ * A `scroll` fence as the book sets it: Typst has no vertical writing, so each
1213
+ * panel of at most `rows` × `cols` characters is an image drawn from the
1214
+ * face's outlines, its roman text as alt text. The panels sit in a paragraph
1215
+ * set right to left, so they are read from the right and wrap to the next
1216
+ * line where the measure ends; without `cols`, each column is a panel, and as
1217
+ * many columns sit side by side as fit. A fence the rewrite of marked text did
1218
+ * not set, or whose face has no TrueType file, is set as code.
1219
+ *
1220
+ * @param {object} token - The fence token.
1221
+ * @param {object} ctx - Render context, holding the scroll layouts.
1222
+ * @returns {string} Typst markup.
1223
+ */
1224
+ function scrollBlock(token, ctx) {
1225
+ const scroll = scanScrolls(`${token.markup}${token.info}\n${token.content}${token.markup}`)
1226
+ .blocks[0];
1227
+ const layout = ctx.scrolls?.get(scrollParagraphs(token.content.split("\n")).join("\n\n"));
1228
+ if (!scroll || scroll.invalid || !layout?.face?.file)
1229
+ return rawBlock(token.content, token.info?.trim() || "");
1230
+ const outlines = fontOutlines(layout.face.file);
1231
+ const { rows, panels } = scrollPanels(layout.paragraphs, scroll);
1232
+ const boxes = panels.map(
1233
+ (panel) =>
1234
+ `#box(image(bytes("${escapeTypstString(scrollPanelSvg(panel, rows, outlines))}"), ` +
1235
+ `format: "svg", height: ${rows}em, alt: "${escapeTypstString(panelRoman(panel, layout.paragraphs))}"))`,
1236
+ );
1237
+ // A zero-width space lets a line break between two columns and adds no
1238
+ // width; panels of several columns are kept a column gap apart.
1239
+ const between = scroll.cols === undefined ? "\u200B" : "#h(1em, weak: true) ";
1240
+ return `\n#block(width: 100%)[#set text(dir: rtl)\n${boxes.join(between)}\n]\n\n`;
1241
+ }
1242
+
1202
1243
  /**
1203
1244
  * A fenced block as Typst raw text.
1204
1245
  *
@@ -1944,10 +1985,10 @@ export function bookTypstPreamble(runs = {}, families = {}) {
1944
1985
  // The title sets short of the measure, so a plate's last line does not
1945
1986
  // run to the trimmed edge of the paper.
1946
1987
  "#let book-title-measure = book-text-width - 2.5cm",
1947
- '#let book-ink = rgb("#241f1a")',
1988
+ `#let book-ink = rgb("${BOOK_INK}")`,
1948
1989
  // A rubric script's emphasis: a vermilion that holds 4.5:1 on the paper
1949
1990
  // and stays apart from the ink in lightness.
1950
- '#let book-rubric = rgb("#9c281b")',
1991
+ `#let book-rubric = rgb("${BOOK_RUBRIC}")`,
1951
1992
  '#let book-paper = rgb("#f4efe4")',
1952
1993
  '#let book-accent = rgb("#7c3b1e")',
1953
1994
  '#let book-head = rgb("#5e2b14")',
@@ -17,6 +17,10 @@
17
17
  * column value and none is a key, so two notes writing one tag in different
18
18
  * case are two rows. Documentation records and assets contribute no rows.
19
19
  *
20
+ * A run's `font` and `map` are the ones it is set with: its own, else those of
21
+ * the div or fence around it, else those its language's note names in
22
+ * `data.writtenIn`, filled by {@link fillWrittenIn} once every record exists.
23
+ *
20
24
  * @module
21
25
  */
22
26
 
@@ -24,6 +28,9 @@ import { canonicalKey, readCanonicalKey } from "./address.mjs";
24
28
  import { encodeAddresses } from "./address-values.mjs";
25
29
  import { isNoteRecord } from "./index-records.mjs";
26
30
  import { scanRuns } from "./content-scripts.mjs";
31
+ import { languageRecordsByName } from "./map-lexicon.mjs";
32
+ import { mapsFromRecords } from "./text-maps.mjs";
33
+ import { fromWrittenIn, writtenInOf } from "./written-in.mjs";
27
34
 
28
35
  /** Every column of the `runs` relation, in order. */
29
36
  export const RUN_COLUMNS = Object.freeze([
@@ -41,12 +48,14 @@ export const RUN_COLUMNS = Object.freeze([
41
48
  * One run as an index record carries it.
42
49
  *
43
50
  * @typedef {object} RecordRun
44
- * @property {"span"|"block"|"verse"} kind - The construct.
51
+ * @property {"span"|"block"|"verse"|"scroll"} kind - The construct.
45
52
  * @property {string|null} lang - The language it is set in, as written: its
46
53
  * own `lang=`, else the one the div or fence around it states.
47
54
  * @property {string|null} tag - The BCP 47 tag it resolves to, or null.
48
- * @property {string|null} font - Its font, as written and inherited the same way.
49
- * @property {string|null} map - Its map, as written and inherited the same way.
55
+ * @property {string|null} font - Its font, as written and inherited the same
56
+ * way, else its language's script's.
57
+ * @property {string|null} map - Its map, as written and inherited the same way,
58
+ * else its language's script's.
50
59
  * @property {number} line - 1-based file line of its opening.
51
60
  * @property {number} column - 1-based UTF-16 file column of its opening.
52
61
  */
@@ -80,6 +89,39 @@ export function recordRuns(body, { context, bodyLine = 1, bodyColumn = 1, syntax
80
89
  }));
81
90
  }
82
91
 
92
+ /**
93
+ * Give every recorded run the map and face its language's note names in
94
+ * `data.writtenIn`, where the run leaves them unstated, as a run is set. Read
95
+ * once every record exists, since a run's language note may be recorded after
96
+ * the note that marks it.
97
+ *
98
+ * @param {Record<string, any>[]} records - The package's index records; each
99
+ * note record's `runs` is updated in place.
100
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
101
+ * The configured languages, whose `note` names each language note.
102
+ */
103
+ export function fillWrittenIn(records, config) {
104
+ const maps = mapsFromRecords(records);
105
+ const scripts = writtenInOf(languageRecordsByName(records, config), maps);
106
+ if (!scripts.size) return;
107
+ for (const record of records) {
108
+ if (!record || !isNoteRecord(record) || !Array.isArray(record.runs)) continue;
109
+ for (const run of record.runs) {
110
+ const filled = fromWrittenIn(
111
+ {
112
+ lang: run.lang ?? undefined,
113
+ map: run.map ?? undefined,
114
+ font: run.font ?? undefined,
115
+ },
116
+ scripts,
117
+ maps,
118
+ );
119
+ if (filled.map !== undefined) run.map = filled.map;
120
+ if (filled.font !== undefined) run.font = filled.font;
121
+ }
122
+ }
123
+ }
124
+
83
125
  /**
84
126
  * The containing page's NOTE identity.
85
127
  *
@@ -0,0 +1,67 @@
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
+ * **The direction a script is written in.** A script note states it beside its
16
+ * faces:
17
+ *
18
+ * ```yaml
19
+ * data:
20
+ * script:
21
+ * fonts: [teng-chou]
22
+ * emphasis: rubric
23
+ * direction: vertical-rl
24
+ * ```
25
+ *
26
+ * `vertical-rl` is a script written in columns, top to bottom, the columns
27
+ * read from the right. A script that states no direction is written in lines.
28
+ * Markup never states a direction: a `scroll` fence takes its script's, and is
29
+ * refused in a script written in lines; see
30
+ * {@link module:engine/content-scroll}.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ import { isNoteRecord } from "./index-records.mjs";
36
+ import { SCRIPT_DIRECTIONS, SCRIPT_SUBTYPE } from "./script-emphasis-terms.mjs";
37
+
38
+ export { SCRIPT_DIRECTIONS } from "./script-emphasis-terms.mjs";
39
+
40
+ /**
41
+ * Each face's direction, from the script notes among the index records. The
42
+ * first note to list a face keeps it; a face whose note states no direction,
43
+ * or one the build does not know, is absent.
44
+ *
45
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
46
+ * @returns {Map<string, "vertical-rl">} The directions, by face slug.
47
+ */
48
+ export function directionsFromRecords(records) {
49
+ const out = new Map();
50
+ const listed = new Set();
51
+ for (const record of records ?? []) {
52
+ if (!record || !isNoteRecord(record)) continue;
53
+ if (
54
+ record.type !== "skill" ||
55
+ String(record.subType ?? "").toLowerCase() !== SCRIPT_SUBTYPE
56
+ )
57
+ continue;
58
+ const script = record.data?.script;
59
+ if (!script || typeof script !== "object" || !Array.isArray(script.fonts)) continue;
60
+ for (const slug of script.fonts) {
61
+ if (typeof slug !== "string" || listed.has(slug)) continue;
62
+ listed.add(slug);
63
+ if (SCRIPT_DIRECTIONS.includes(script.direction)) out.set(slug, script.direction);
64
+ }
65
+ }
66
+ return out;
67
+ }
@@ -12,9 +12,10 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **The words a script note and a language note state emphasis by.** Read by
16
- * the vocabulary as it is declared, so this module imports nothing; the
17
- * checks and the conventions' use are in {@link module:engine/script-emphasis}.
15
+ * **The words a script note and a language note state emphasis and direction
16
+ * by.** Read by the vocabulary as it is declared, so this module imports
17
+ * nothing; the checks and the conventions' use are in
18
+ * {@link module:engine/script-emphasis} and {@link module:engine/script-direction}.
18
19
  *
19
20
  * @module
20
21
  */
@@ -34,5 +35,12 @@ export const EMPHASIS_CLASSES = Object.freeze({
34
35
  */
35
36
  export const LANGUAGE_EMPHASES = Object.freeze(["words", ...EMPHASIS_CONVENTIONS]);
36
37
 
38
+ /**
39
+ * The directions a script states it is written in. `vertical-rl` is columns,
40
+ * top to bottom, read from the right; a script stating none is written in
41
+ * lines.
42
+ */
43
+ export const SCRIPT_DIRECTIONS = Object.freeze(["vertical-rl"]);
44
+
37
45
  /** The `subType` of a script note. */
38
46
  export const SCRIPT_SUBTYPE = "script";