@heroiclands/package-build 24.1.1 → 25.1.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 (107) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +30 -96
  4. package/bin/encoding-commands.mjs +146 -0
  5. package/bin/package-build.mjs +35 -24
  6. package/config.mjs +25 -0
  7. package/convert.mjs +182 -0
  8. package/docs/api.md +154 -11
  9. package/docs/authoring/links-and-markup.md +260 -473
  10. package/docs/commands.md +118 -119
  11. package/docs/configuration.md +31 -13
  12. package/docs/content-format.md +11 -4
  13. package/docs/diagnostics.md +43 -16
  14. package/docs/encoding-core.md +135 -0
  15. package/docs/guides/book.md +2 -2
  16. package/docs/guides/module-project.md +23 -1
  17. package/docs/guides/site.md +2 -2
  18. package/docs/guides/writing-in-invented-scripts.md +453 -569
  19. package/docs/reference/format-details.md +290 -380
  20. package/docs/reference/note-types.md +80 -62
  21. package/docs/source-preview.md +8 -0
  22. package/e2e.mjs +1 -1
  23. package/engine/base-compiler.mjs +27 -9
  24. package/engine/content-charset.mjs +21 -1
  25. package/engine/content-format.yaml +7 -2
  26. package/engine/content-index.mjs +6 -2
  27. package/engine/content-links.mjs +1 -0
  28. package/engine/content-scripts.mjs +658 -423
  29. package/engine/content-scroll.mjs +4 -4
  30. package/engine/content-spans.mjs +40 -9
  31. package/engine/content-verse.mjs +1 -1
  32. package/engine/converter-data.mjs +70 -0
  33. package/engine/dictionary-rows.mjs +2 -34
  34. package/engine/encoding-integrity.mjs +94 -0
  35. package/engine/encoding-language.mjs +506 -0
  36. package/engine/encoding-lexicon.mjs +699 -0
  37. package/engine/encoding-notes.mjs +186 -0
  38. package/engine/encoding-standard.mjs +42 -0
  39. package/engine/encoding-template.mjs +313 -0
  40. package/engine/encoding-terms.mjs +141 -0
  41. package/engine/encoding.mjs +946 -0
  42. package/engine/extension-attributes.mjs +2 -0
  43. package/engine/helpers.mjs +1 -1
  44. package/engine/index.mjs +10 -0
  45. package/engine/language-data.mjs +4 -1
  46. package/engine/language-terms.mjs +23 -12
  47. package/engine/{logographic.mjs → language-writings.mjs} +29 -38
  48. package/engine/markup-validation.mjs +1 -1
  49. package/engine/note-vocabulary.mjs +4 -158
  50. package/engine/pdf-render.mjs +31 -3
  51. package/engine/roman-characters.mjs +232 -0
  52. package/engine/roman-run.mjs +149 -0
  53. package/engine/roman-verse.mjs +95 -0
  54. package/engine/run-rows.mjs +29 -31
  55. package/engine/run-words.mjs +2 -2
  56. package/engine/script-emphasis-terms.mjs +3 -0
  57. package/engine/script-emphasis.mjs +3 -10
  58. package/engine/script-font-delivery.mjs +9 -12
  59. package/engine/script-fonts.mjs +2 -1
  60. package/engine/site-build.mjs +14 -5
  61. package/engine/site-preview.mjs +12 -2
  62. package/engine/sql-tables.mjs +36 -31
  63. package/engine/verse-metre.mjs +1 -1
  64. package/engine/word-forms.mjs +1 -1
  65. package/engine/word-inflections.mjs +48 -0
  66. package/engine/word-rows.mjs +1 -1
  67. package/engine/word-terms.mjs +16 -17
  68. package/package.json +11 -5
  69. package/types/config.d.mts +5 -0
  70. package/types/convert.d.mts +44 -0
  71. package/types/e2e.d.mts +1 -1
  72. package/types/engine/base-compiler.d.mts +4 -2
  73. package/types/engine/content-charset.d.mts +8 -0
  74. package/types/engine/content-index.d.mts +4 -1
  75. package/types/engine/content-scripts.d.mts +108 -42
  76. package/types/engine/converter-data.d.mts +11 -0
  77. package/types/engine/dictionary-rows.d.mts +1 -25
  78. package/types/engine/encoding-integrity.d.mts +14 -0
  79. package/types/engine/encoding-language.d.mts +36 -0
  80. package/types/engine/encoding-lexicon.d.mts +259 -0
  81. package/types/engine/encoding-notes.d.mts +39 -0
  82. package/types/engine/encoding-standard.d.mts +11 -0
  83. package/types/engine/encoding-template.d.mts +43 -0
  84. package/types/engine/encoding-terms.d.mts +105 -0
  85. package/types/engine/encoding.d.mts +155 -0
  86. package/types/engine/helpers.d.mts +1 -1
  87. package/types/engine/index.d.mts +5 -0
  88. package/types/engine/language-terms.d.mts +1 -1
  89. package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
  90. package/types/engine/markup-validation.d.mts +1 -1
  91. package/types/engine/pdf-render.d.mts +1 -1
  92. package/types/engine/roman-characters.d.mts +45 -0
  93. package/types/engine/roman-run.d.mts +101 -0
  94. package/types/engine/roman-verse.d.mts +23 -0
  95. package/types/engine/run-rows.d.mts +13 -20
  96. package/types/engine/script-emphasis-terms.d.mts +2 -0
  97. package/types/engine/script-emphasis.d.mts +1 -1
  98. package/types/engine/script-font-delivery.d.mts +2 -3
  99. package/types/engine/script-fonts.d.mts +5 -0
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/word-inflections.d.mts +25 -0
  102. package/engine/map-lexicon.mjs +0 -1034
  103. package/engine/text-maps.mjs +0 -654
  104. package/engine/written-in.mjs +0 -204
  105. package/types/engine/map-lexicon.d.mts +0 -330
  106. package/types/engine/text-maps.d.mts +0 -189
  107. package/types/engine/written-in.d.mts +0 -76
@@ -12,87 +12,38 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **Text marked with a language, a font and a mapping.** Three keys on the
16
- * attribute grammar spans, divs, verse fences and scroll fences already share,
17
- * each optional and independent of the others:
15
+ * Text marked with a language, font or encoding document on shared attributes.
16
+ * `enc=` resolves a document Address and infers its configured language and
17
+ * font; explicit selections must agree. `enc=default`, including an omitted
18
+ * selection, retains Roman text. A language alias or BCP 47 tag identifies
19
+ * text independently of conversion, and an installed font or generic family
20
+ * selects its face independently.
18
21
  *
19
- * ```markdown
20
- * [Daimela, helthos.]{lang=vyl}
21
- * [Zaron tholmeza]{lang=vyl font=semaron-kuriale map=semaron}
22
+ * Configured language notes supply vocabulary, Roman additions and emphasis.
23
+ * Encoded words read lexical native writings, complete parts and encoding
24
+ * mappings in that order. Source validation sees the complete logical run,
25
+ * including class-only emphasis and name children, while physical rendering
26
+ * preserves their wrappers. Children stating run keys make a separate choice.
27
+ * A native name's braces write the shared name controls.
22
28
  *
23
- * ::: {lang=vyl font=semaron-kuriale map=semaron}
24
- * Zaron tholmeza.
25
- * :::
26
- *
27
- * ```verse {form=epic lang=vyl font=semaron-kuriale map=semaron}
28
- * ```
29
- *
30
- * - **`lang=`** gives the language tag. It is a name declared in the
31
- * package's `languages`, which gives its tag, or otherwise a well-formed BCP
32
- * 47 tag, checked by form alone. A declared language whose note names its
33
- * script in `data.writtenIn` gives a run the map and face it leaves
34
- * unstated; see {@link module:engine/written-in}.
35
- * - **`font=`** is one of the installed faces, by its class without `hl-`
36
- * (`semaron-kuriale`), or `mono`, `serif` or `sans`.
37
- * - **`map=`** names a mapping note's `data.map`, which rewrites the text
38
- * before it is set; see {@link module:engine/text-maps}. Without it the text
39
- * is set as typed. A run's `font=` is one of the faces the map lists in its
40
- * `fonts`, whose own rows come before the shared ones.
41
- * - **`lang=` with `map=` reads the language's lexicon first**: a word whose
42
- * entry states a native form for the map is written by it, and the rows
43
- * spell every other word; see {@link module:engine/map-lexicon}.
44
- * - **A logographic map writes every word from the lexicon**: by the word's
45
- * own writing or its parts', and a word with neither, or whose form stands
46
- * for entries written differently, is a finding at the word, once per run;
47
- * see {@link module:engine/logographic}.
48
- * - **Every word of a run with `lang=` and `map=` is a word of its language**
49
- * where the language's `note` names its language note: a lexicon form, a
50
- * word of a name its own register lists, or a regular form of a lexicon
51
- * form. Any other
52
- * word is a finding at its first character, once per run; see
53
- * {@link module:engine/run-words}.
54
- * - **A run inside a span, div or verse fence inherits its keys**, and its
55
- * own keys override them. A span switching to a different language selects
56
- * that language's script defaults and its own explicit font and map. A span
57
- * in the same language keeps the surrounding script choices.
58
- * - **Text with a font, a map or a declared non-English language is plain.** A span or a
59
- * verse holds no emphasis, image, footnote, wikilink, code or HTML, and no
60
- * span but a marked one. A standalone resolved reference sets only its
61
- * label; links embedded in longer native prose remain forbidden. A div
62
- * holds paragraphs and line breaks only.
63
- * - **A non-English span holds no bold or italic markup**, including in a
64
- * script face. English tags and configured names resolving to English
65
- * retain their emphasis under the font's rules. Escaped delimiters are text.
66
- * - **Other text set in a face takes `*…*` and `**…**`**, set by the convention its
67
- * language's note states, else its face's script note, else plainly; see
68
- * {@link module:engine/script-emphasis}. A language that marks emphasis
69
- * with a word makes either a finding at the emphasis.
70
- * The text is read as written, so `[`, `]`, `"`, `<<`, `>>`, `!` and
71
- * `@#$%^&` are characters, and need no escaping. A real language's tag alone
72
- * marks text and leaves its Markdown alone, subject to the non-English span
73
- * rule. Verse indentation periods remain layout rather than mapped text.
74
- * - **A face draws only what it lists.** After the map, a character that is
75
- * neither whitespace nor in the face's `codepoints` is a finding, at the
76
- * source character it came from. `mono`, `serif` and `sans` are not checked.
77
- *
78
- * - **A scroll fence is set in a face whose script note states
79
- * `direction: vertical-rl`.** One that names no `font=` takes its
80
- * language's script, else the face of a map drawn in one; one that names no
81
- * face, or whose script is written in lines, is a finding. See {@link module:engine/content-scroll}.
82
- *
83
- * {@link scanRuns} reads the markup through {@link module:engine/content-spans.scanSpans},
84
- * {@link module:engine/content-blocks.scanBlocks},
85
- * {@link module:engine/content-verse.scanVerse} and
86
- * {@link module:engine/content-scroll.scanScrolls}, so what counts as a span, a
87
- * div or a fence is theirs to say.
29
+ * Plain configured-language runs restrict Markdown consistently across site,
30
+ * Foundry and book targets. Non-English spans admit no bold or italic markup;
31
+ * explicit emphasis classes follow the language's or script's convention.
32
+ * English text with the default encoding retains ordinary Markdown grammar.
33
+ * A selected font must draw every visible encoded output character.
88
34
  *
35
+ * Span, div, verse and scroll scanners retain original UTF-16 source offsets.
36
+ * Public diagnostics count Unicode code-point columns. A scroll's face must
37
+ * belong to a script written vertically, and parallel verse selects encoding
38
+ * on each reading rather than the fence. Every native content line retains
39
+ * its line boundary before a stanza or canto.
89
40
  * @module
90
41
  */
91
42
 
92
43
  import MarkdownIt from "markdown-it";
93
44
 
94
45
  import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
95
- import { scanVerse, hasColumns, verseLine, verseGroups } from "./content-verse.mjs";
46
+ import { scanVerse, hasColumns, verseLine, verseGroups, STANZA_BREAK } from "./content-verse.mjs";
96
47
  import { scanSpans } from "./content-spans.mjs";
97
48
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
98
49
  import { directionsFromRecords } from "./script-direction.mjs";
@@ -105,23 +56,25 @@ import {
105
56
  fontProblem,
106
57
  languageRecordsByName,
107
58
  lexiconsFromRecords,
108
- mappingFor,
109
- uncapitalisedMessage,
110
- unwrittenMessage,
111
- } from "./map-lexicon.mjs";
59
+ encodingFor,
60
+ } from "./encoding-lexicon.mjs";
112
61
  import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-words.mjs";
113
62
  import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
114
- import { applyMap, mapsFromRecords } from "./text-maps.mjs";
115
- import { writtenInOf } from "./written-in.mjs";
116
- import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
63
+ import { encodeWithLanguage } from "./encoding-language.mjs";
64
+ import { encodingsFromRecords, encodingAddress } from "./encoding-notes.mjs";
65
+ import { romanInventoriesOf, romanTextProblems } from "./roman-characters.mjs";
66
+ import { plainRomanRun } from "./roman-run.mjs";
67
+ import { plainRomanVerse } from "./roman-verse.mjs";
68
+ import { encodeText } from "./encoding.mjs";
69
+ import { WIKILINK } from "./wikilink-syntax.mjs";
117
70
  import { matchAllOutsideCode } from "./code-fences.mjs";
118
71
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
119
72
 
120
73
  /** The keys this module reads, which no construct passes through as written. */
121
- export const RUN_KEYS = Object.freeze(["lang", "font", "map"]);
74
+ export const RUN_KEYS = Object.freeze(["lang", "font", "enc"]);
122
75
 
123
76
  /** Explicit opt-in to native-script presentation on this construct. */
124
- export const NATIVEFONT_CLASS = "nativefont";
77
+ const NATIVEFONT_CLASS = "nativefont";
125
78
 
126
79
  /** The generic families `font=` may name, each with the class that sets it. */
127
80
  export const GENERIC_FONTS = Object.freeze({
@@ -137,28 +90,30 @@ const CONFIG = "package-build.config.yaml";
137
90
  * What a run is checked against.
138
91
  *
139
92
  * @typedef {object} RunContext
93
+ * @property {string} [contentPackage] - The citing content package.
140
94
  * @property {Readonly<Record<string, {tag: string}>>} [languages] - The
141
95
  * configured languages.
142
96
  * @property {ReadonlyMap<string, import("./script-fonts.mjs").Face>} [faces] -
143
97
  * The installed faces by slug, from
144
98
  * {@link module:engine/script-fonts.resolveScriptFonts}.
145
- * @property {ReadonlyMap<string, import("./text-maps.mjs").TextMap>} [maps] -
146
- * The mapping notes' maps by name, from
147
- * {@link module:engine/text-maps.mapsFromRecords}.
148
- * @property {ReadonlyMap<string, import("./written-in.mjs").WrittenIn>} [writtenIn] -
149
- * The script each configured language's note names, by language name, from
150
- * {@link module:engine/written-in.writtenInOf}.
151
- * @property {ReadonlyMap<string, import("./map-lexicon.mjs").Lexicon>} [lexicons] -
99
+ * @property {ReadonlyMap<string, ReturnType<import("./encoding.mjs").compileEncoding>>} [encodings] -
100
+ * The compiled encoding documents, keyed by canonical and local Addresses.
101
+ * @property {ReadonlyArray<{file?: string, line?: number, column?: number, message: string}>} [encodingFindings] - Encoding-document or font-code findings.
102
+ * @property {ReadonlyMap<string, {data: object, codes: Record<string,string>}>} [encodingDefinitions] - Serialized encoding documents and their selected font codes.
103
+ * @property {ReadonlyMap<string, Record<string, any>>} [languageRecords] - The configured language notes.
104
+ * @property {ReadonlyMap<string, import("./encoding-lexicon.mjs").Lexicon>} [lexicons] -
152
105
  * Each configured language's lexicon by name, from
153
- * {@link module:engine/map-lexicon.lexiconsFromRecords}.
106
+ * {@link module:engine/encoding-lexicon.lexiconsFromRecords}.
154
107
  * @property {ReadonlyMap<string, import("./run-words.mjs").WordBook>} [wordBooks] -
155
- * What each configured language's mapped runs are checked against, from
108
+ * What each configured language's encoded runs are checked against, from
156
109
  * {@link module:engine/run-words.wordBooksFromRecords}.
157
110
  * @property {ReadonlyMap<string, import("./metre-declaration.mjs").Metre>} [metres] -
158
111
  * The metres the `doc` notes declare, by name.
159
112
  * @property {ReadonlyMap<string, import("./verse-metre.mjs").MetreBook>} [metreBooks] -
160
113
  * What each configured language's verse is counted by, from
161
114
  * {@link module:engine/verse-metre.metreBooksFromRecords}.
115
+ * @property {ReadonlyMap<string, import("./roman-characters.mjs").RomanInventory>} [romanInventories] -
116
+ * Exact Roman source inventories by configured alias and BCP 47 tag.
162
117
  * @property {ReadonlyMap<string, import("./script-emphasis.mjs").Emphasis>} [emphases] -
163
118
  * Each face's emphasis conventions, from the script notes, by
164
119
  * {@link module:engine/script-emphasis.emphasesFromRecords}.
@@ -171,11 +126,9 @@ const CONFIG = "package-build.config.yaml";
171
126
  */
172
127
 
173
128
  /**
174
- * One span, div, verse fence or scroll fence carrying `lang=`, `font=` or
175
- * `map=`. Its `lang`, `font` and `map` are the ones it is set with: its own,
176
- * else inherited language or an ordinary prose family. Native map/face
177
- * defaults are selected only by this construct's explicit `.nativefont`
178
- * opt-in; marked children make their own presentation choice.
129
+ * One span, div, verse fence or scroll fence carrying run attributes.
130
+ * Values combine explicit choices, class-only inheritance, encoding-document
131
+ * language/font defaults and the Roman default encoding.
179
132
  *
180
133
  * @typedef {object} Run
181
134
  * @property {"span"|"block"|"verse"|"scroll"} kind - Which construct.
@@ -183,32 +136,30 @@ const CONFIG = "package-build.config.yaml";
183
136
  * @property {string|undefined} tag - The BCP 47 tag it resolves to; `undefined`
184
137
  * when it resolves to none.
185
138
  * @property {string|undefined} font - The font as written.
186
- * @property {string|undefined} map - The map as written.
139
+ * @property {string|undefined} enc - The resolved document Address, or default for Roman text.
187
140
  * @property {number} line - 1-based line of the construct's opening.
188
141
  * @property {number} column - 1-based UTF-16 column of its opening.
189
142
  * @property {number} start - A span's offset of `[`; a div's or fence's
190
143
  * 0-based opening line.
191
144
  * @property {number} end - A span's offset past its attributes; a div's or
192
145
  * fence's 0-based closing line.
193
- * @property {boolean} [nativefont] - True only for an explicit native opt-in
194
- * with its own language; retained by index records for deferred defaults.
195
146
  */
196
147
 
197
148
  /** @type {WeakMap<object, RunContext>} */
198
149
  const contexts = new WeakMap();
199
150
 
200
151
  /** @type {WeakMap<object, WeakMap<object, RunContext>>} */
201
- const withMaps = new WeakMap();
152
+ const withEncodings = new WeakMap();
202
153
 
203
154
  /**
204
- * The context a resolved configuration gives, and the maps an index holds.
155
+ * The context a resolved configuration gives, and the encodings an index holds.
205
156
  *
206
- * @param {{languages?: object, rootDir?: string}|undefined} config - The
157
+ * @param {{languages?: object, rootDir?: string, contentPackage?:string, paths?:{content?:string}}|undefined} config - The
207
158
  * resolved configuration.
208
159
  * @param {Iterable<Record<string, any>>} [records] - The content index's
209
- * records, whose mapping notes give the maps. Without them no map is known.
160
+ * records, whose language notes supply lexical writings and script choices.
210
161
  * @returns {RunContext} Its languages, the faces installed where it sits, and
211
- * the maps. A fonts package that cannot be read gives no faces; a run naming
162
+ * the encodings. A fonts package that cannot be read gives no faces; a run naming
212
163
  * one is reported.
213
164
  */
214
165
  export function runContext(config, records) {
@@ -216,14 +167,19 @@ export function runContext(config, records) {
216
167
  let base = contexts.get(config);
217
168
  if (!base) {
218
169
  let faces;
170
+ let fonts;
219
171
  if (config.rootDir) {
220
172
  try {
221
- faces = resolveScriptFonts(config.rootDir)?.faces;
173
+ fonts = resolveScriptFonts(config.rootDir);
174
+ faces = fonts?.faces;
222
175
  } catch {
223
176
  faces = undefined;
224
177
  }
225
178
  }
226
179
  base = Object.freeze({
180
+ fonts,
181
+ encodings: new Map(),
182
+ encodingFindings: [],
227
183
  languages: config.languages ?? {},
228
184
  faces: faces ?? new Map(),
229
185
  contentPackage: config.contentPackage,
@@ -231,16 +187,20 @@ export function runContext(config, records) {
231
187
  contexts.set(config, base);
232
188
  }
233
189
  if (!records || typeof records !== "object") return base;
234
- let byRecords = withMaps.get(config);
235
- if (!byRecords) withMaps.set(config, (byRecords = new WeakMap()));
190
+ let byRecords = withEncodings.get(config);
191
+ if (!byRecords) withEncodings.set(config, (byRecords = new WeakMap()));
236
192
  const known = byRecords.get(records);
237
193
  if (known) return known;
238
- const maps = mapsFromRecords(records);
194
+ const loaded = encodingsFromRecords(records, config, base.fonts);
195
+ const encodings = loaded.encodings;
239
196
  const languageRecords = languageRecordsByName(records, config);
240
197
  const context = Object.freeze({
241
198
  ...base,
242
- maps,
243
- writtenIn: writtenInOf(languageRecords, maps),
199
+ encodings,
200
+ encodingDefinitions: loaded.definitions,
201
+ encodingFindings: loaded.findings,
202
+ languageRecords,
203
+ romanInventories: romanInventoriesOf(languageRecords, config.languages),
244
204
  lexicons: lexiconsFromRecords(records, config),
245
205
  wordBooks: wordBooksFromRecords(records, config),
246
206
  metres: metresFromRecords(records),
@@ -590,28 +550,6 @@ function emphasisDelimiters(text, holes = new Map()) {
590
550
  return delimiters;
591
551
  }
592
552
 
593
- /**
594
- * The parts of ranges of text that no delimiter covers.
595
- *
596
- * @param {Array<[number, number]>} parts - The ranges, as offsets.
597
- * @param {readonly Delimiter[]} delimiters - Delimiters, by absolute offset.
598
- * @returns {Array<[number, number]>} The ranges, split around each delimiter.
599
- */
600
- function outsideDelimiters(parts, delimiters) {
601
- if (!delimiters.length) return parts;
602
- const out = [];
603
- for (const [from, to] of parts) {
604
- let at = from;
605
- for (const delimiter of delimiters) {
606
- if (delimiter.offset < at || delimiter.offset >= to) continue;
607
- if (delimiter.offset > at) out.push([at, delimiter.offset]);
608
- at = delimiter.offset + delimiter.length;
609
- }
610
- if (at < to) out.push([at, to]);
611
- }
612
- return out;
613
- }
614
-
615
553
  /** A line split into its indentation, its text, a trailing break and its trailing space. */
616
554
  const LINE_PARTS = /^(\s*)(.*?)(\\?)(\s*)$/;
617
555
 
@@ -625,7 +563,7 @@ const LINE_PARTS = /^(\s*)(.*?)(\\?)(\s*)$/;
625
563
  * @returns {string} The text, safe in a paragraph.
626
564
  */
627
565
  function escapeRoman(line, lineStart = true) {
628
- const escaped = line.replace(/[\\`*_[\]<>!&#~|{}]/g, "\\$&");
566
+ const escaped = line.replace(/[\\`*_[\]<>"'!&#~|{}-]/g, "\\$&");
629
567
  return lineStart ?
630
568
  escaped.replace(/^([-+=:])/, "\\$1").replace(/^(\d+)([.)])/, "$1\\$2")
631
569
  : escaped;
@@ -655,19 +593,47 @@ function unescapeRoman(core) {
655
593
  return { text, offsets };
656
594
  }
657
595
 
596
+ /** Apply a selected encoding, retaining source text only for recoverable rendering. */
597
+ function applyEncoding(encoding, text, options = {}) {
598
+ if (!encoding)
599
+ return {
600
+ text,
601
+ pieces: [...text].map((char, index) => ({
602
+ from: char,
603
+ to: char,
604
+ at: index,
605
+ row: false,
606
+ })),
607
+ };
608
+ const result = encodeWithLanguage(encoding, text, options);
609
+ return {
610
+ text: result.findings.length ? text : result.text,
611
+ pieces: [
612
+ ...result.pieces.map((one) => ({ ...one, at: one.start, row: Boolean(one.row) })),
613
+ ...result.findings.map((one) => ({
614
+ from: text.slice(one.offset, one.offset + 1),
615
+ to: "",
616
+ at: one.offset,
617
+ row: false,
618
+ problem: one.message,
619
+ })),
620
+ ],
621
+ };
622
+ }
623
+
658
624
  /**
659
- * Set a piece of a run's text: an author's escapes removed, the map applied,
625
+ * Set a piece of a run's text: an author's escapes removed, the enc applied,
660
626
  * and the characters Markdown would read escaped again. Every line is set on
661
627
  * its own, so the line count holds.
662
628
  *
663
629
  * @param {string} raw - The text as written.
664
- * @param {import("./map-lexicon.mjs").Mapping|undefined} mapping - The run's
665
- * map, face and lexicon.
630
+ * @param {ReturnType<import("./encoding-lexicon.mjs").encodingFor>|undefined} selection - The run's
631
+ * enc, face and lexicon.
666
632
  * @param {boolean} lineStart - Whether `raw` starts its line.
667
- * @returns {{text: string, pieces: Array<{offset: number, from: string, to: string, row: boolean, native?: boolean, uncapitalised?: string, unwritten?: object}>}}
633
+ * @returns {{text: string, pieces: Array<{offset: number, from: string, to: string, row: boolean, native?: boolean, problem?: string}>}}
668
634
  * The set text, and each piece of it with the offset in `raw` it came from.
669
635
  */
670
- function setText(raw, mapping, lineStart) {
636
+ function setText(raw, selection, lineStart) {
671
637
  const out = [];
672
638
  const pieces = [];
673
639
  let base = 0;
@@ -678,26 +644,22 @@ function setText(raw, mapping, lineStart) {
678
644
  if (core === "") out.push(line);
679
645
  else {
680
646
  const plain = unescapeRoman(core);
681
- const mapped = applyMap(mapping?.map, plain.text, {
682
- font: mapping?.font,
683
- native: mapping?.native,
684
- writing: mapping?.writing,
647
+ const encoded = applyEncoding(selection?.encoding, plain.text, {
648
+ font: selection?.font,
649
+ lexicon: selection?.lexicon,
685
650
  });
686
- for (const piece of mapped.pieces)
651
+ for (const piece of encoded.pieces)
687
652
  pieces.push({
688
653
  offset: base + lead.length + (plain.offsets[piece.at] ?? 0),
689
654
  from: piece.from,
690
655
  to: piece.to,
691
656
  row: piece.row,
692
657
  ...(piece.native ? { native: true } : {}),
693
- ...(piece.uncapitalised === undefined ?
694
- {}
695
- : { uncapitalised: piece.uncapitalised }),
696
- ...(piece.unwritten === undefined ? {} : { unwritten: piece.unwritten }),
658
+ ...(piece.problem === undefined ? {} : { problem: piece.problem }),
697
659
  });
698
660
  out.push(
699
661
  lead +
700
- escapeRoman(mapped.text, index > 0 || lineStart || lead !== "") +
662
+ escapeRoman(encoded.text, index > 0 || lineStart || lead !== "") +
701
663
  breakMark +
702
664
  trail,
703
665
  );
@@ -709,21 +671,20 @@ function setText(raw, mapping, lineStart) {
709
671
 
710
672
  /**
711
673
  * Set a stretch of a scroll fence's text: the author's escapes removed and the
712
- * map applied to all of it, its spaces included, and nothing escaped, since
674
+ * enc applied to all of it, its spaces included, and nothing escaped, since
713
675
  * the fence's body is raw.
714
676
  *
715
677
  * @param {string} raw - The text, on one line.
716
- * @param {import("./map-lexicon.mjs").Mapping|undefined} mapping - The run's
717
- * map, face and lexicon.
678
+ * @param {ReturnType<import("./encoding-lexicon.mjs").encodingFor>|undefined} selection - The run's
679
+ * enc, face and lexicon.
718
680
  * @returns {{text: string, pieces: Array<{from: string, to: string}>}} The set
719
681
  * text, and each piece of it.
720
682
  */
721
- function setScrollText(raw, mapping) {
683
+ function setScrollText(raw, selection) {
722
684
  const plain = unescapeRoman(raw);
723
- return applyMap(mapping?.map, plain.text, {
724
- font: mapping?.font,
725
- native: mapping?.native,
726
- writing: mapping?.writing,
685
+ return applyEncoding(selection?.encoding, plain.text, {
686
+ font: selection?.font,
687
+ lexicon: selection?.lexicon,
727
688
  });
728
689
  }
729
690
 
@@ -802,8 +763,8 @@ const BLOCK_WHAT = Object.freeze({
802
763
  * @property {boolean} plain - Whether its text is set.
803
764
  * @property {import("./script-fonts.mjs").Face|undefined} face - Its face.
804
765
  * @property {string|undefined} generic - Its generic family's class.
805
- * @property {import("./text-maps.mjs").TextMap|undefined} textMap - Its map.
806
- * @property {import("./map-lexicon.mjs").Mapping|undefined} mapping - Its map,
766
+ * @property {ReturnType<import("./encoding.mjs").compileEncoding>|undefined} encoding - Its enc.
767
+ * @property {ReturnType<import("./encoding-lexicon.mjs").encodingFor>|undefined} selection - Its enc,
807
768
  * face and lexicon, which its text is set by.
808
769
  * @property {Map<number, number>} holes - The marked spans inside it.
809
770
  * @property {[number, number]|undefined} body - A div's or fence's body, as
@@ -817,6 +778,10 @@ const BLOCK_WHAT = Object.freeze({
817
778
  * What its emphasis is set by: its language's convention, else its face's
818
779
  * script's; `undefined` sets it plain.
819
780
  * @property {Delimiter[]} emphasis - The emphasis it holds, by absolute offset.
781
+ * @property {boolean} [checkRoman] - Its inline source structure permits Roman
782
+ * inventory validation, independently of encoding and face resolution.
783
+ * @property {boolean} [checkText] - Its selection and source structure permit
784
+ * native encoding and output glyph validation.
820
785
  * @property {boolean} [unrolled] - A scroll fence whose opening has a finding,
821
786
  * which is left as written.
822
787
  */
@@ -842,10 +807,10 @@ function wordsMessage(lang) {
842
807
  * @returns {import("./script-emphasis.mjs").Emphasis|undefined} The conventions.
843
808
  */
844
809
  function conventionsOf({ face, languageEmphasis }, ctx) {
845
- if (!face || languageEmphasis === "words") return undefined;
810
+ if (languageEmphasis === "words") return undefined;
846
811
  if (languageEmphasis === "rubric" || languageEmphasis === "overline")
847
812
  return { em: languageEmphasis, strong: languageEmphasis };
848
- return ctx.emphases?.get(face.slug);
813
+ return face ? ctx.emphases?.get(face.slug) : undefined;
849
814
  }
850
815
 
851
816
  /**
@@ -855,7 +820,7 @@ function conventionsOf({ face, languageEmphasis }, ctx) {
855
820
  * @param {RunContext} ctx - The names.
856
821
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
857
822
  * scanner projections.
858
- * @returns {{runs: Run[], details: RunDetail[], errors: Array<{line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>, spans: object[], divs: Map<number, object>, poems: Map<number, object>}}
823
+ * @returns {{runs: Run[], details: RunDetail[], errors: Array<{file?: string, line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>, spans: object[], divs: Map<number, object>, poems: Map<number, object>}}
859
824
  * What {@link scanRuns} returns, and what {@link renderRuns} rewrites by.
860
825
  */
861
826
  function analyseRuns(source, ctx, syntax) {
@@ -864,7 +829,12 @@ function analyseRuns(source, ctx, syntax) {
864
829
  const starts = lineStarts(text);
865
830
  const languages = ctx.languages ?? {};
866
831
  const faces = ctx.faces ?? new Map();
867
- const maps = ctx.maps ?? new Map();
832
+ const encodings = ctx.encodings ?? new Map();
833
+ const encodingUnavailable = ctx.encodingFindings?.[0];
834
+ const encodingNamed = (value) =>
835
+ value === "default" ? undefined : (
836
+ (encodings.get(value) ?? encodings.get(encodingAddress(value, ctx.contentPackage)))
837
+ );
868
838
  const errors = [];
869
839
  const warnings = [];
870
840
  const footnotes = new Set(
@@ -939,7 +909,7 @@ function analyseRuns(source, ctx, syntax) {
939
909
  line0: first,
940
910
  where: { line: first + 1, column: 1 },
941
911
  }),
942
- `\`.nativefont\` is read on a span, a div, a verse fence or a scroll fence, not on a ${block.name} block`,
912
+ "`.nativefont` is not an accepted class; select an encoding with `enc=`",
943
913
  );
944
914
  for (const key of Object.keys(keys))
945
915
  report(
@@ -1061,6 +1031,7 @@ function analyseRuns(source, ctx, syntax) {
1061
1031
  (construct) =>
1062
1032
  construct.kind === "scroll" ||
1063
1033
  construct.own.classes.includes(NATIVEFONT_CLASS) ||
1034
+ construct.own.classes.some((name) => name === "emph" || name === "strong") ||
1064
1035
  Object.keys(construct.keys).length ||
1065
1036
  around(construct).some((container) => Object.keys(container.keys).length),
1066
1037
  );
@@ -1072,43 +1043,25 @@ function analyseRuns(source, ctx, syntax) {
1072
1043
  : isLanguageTag(lang) ? lang
1073
1044
  : undefined;
1074
1045
 
1075
- const native = (construct) => construct.own.classes.includes(NATIVEFONT_CLASS);
1046
+ const native = (construct) =>
1047
+ construct.keys.enc !== undefined && construct.keys.enc !== "default";
1076
1048
 
1077
1049
  const faceNames = () => [...faces.keys(), ...Object.keys(GENERIC_FONTS)];
1078
1050
 
1079
1051
  /** Check the keys a construct states itself. */
1080
1052
  const checkOwn = (construct) => {
1081
- const { lang, font, map } = construct.keys;
1053
+ const { lang, font, enc } = construct.keys;
1082
1054
  const { at } = construct;
1083
- if (native(construct) && lang === undefined)
1055
+ if (construct.own.classes.includes(NATIVEFONT_CLASS))
1084
1056
  report(
1085
1057
  nativeAt(construct),
1086
- "`.nativefont` requires an explicit `lang=` on the same construct",
1058
+ "`.nativefont` is not an accepted class; select an encoding with `enc=`",
1087
1059
  );
1088
1060
  if (native(construct) && construct.kind === "verse" && hasColumns(construct.node))
1089
1061
  report(
1090
- nativeAt(construct),
1091
- "a verse with cols>1 opts individual runs into `.nativefont`, not the fence",
1062
+ construct.at("enc", "value"),
1063
+ "a verse with cols>1 selects encoding on individual runs, not the fence",
1092
1064
  );
1093
- if (!native(construct)) {
1094
- if (construct.kind === "scroll")
1095
- report(
1096
- construct.where,
1097
- "a scroll requires `.nativefont` and an explicit `lang=` on the same construct",
1098
- );
1099
- else {
1100
- if (font !== undefined && faces.has(font))
1101
- report(
1102
- at("font", "value"),
1103
- `native \`font=${font}\` requires \`.nativefont\` and an explicit \`lang=\` on the same construct`,
1104
- );
1105
- if (map !== undefined && maps.has(map))
1106
- report(
1107
- at("map", "value"),
1108
- `\`map=${map}\` requires \`.nativefont\` and an explicit \`lang=\` on the same construct`,
1109
- );
1110
- }
1111
- }
1112
1065
 
1113
1066
  if (lang !== undefined && !Object.hasOwn(languages, lang)) {
1114
1067
  if (isLanguageTag(lang)) {
@@ -1141,47 +1094,72 @@ function analyseRuns(source, ctx, syntax) {
1141
1094
  (near ? `; did you mean \`${near}\`?` : ""),
1142
1095
  );
1143
1096
  }
1144
- if (map !== undefined && !maps.has(map)) {
1145
- const near = nearest(map, [...maps.keys()]);
1097
+ if (enc !== undefined && enc !== "default" && !encodingNamed(enc) && !encodingUnavailable) {
1098
+ const near = nearest(enc, [...encodings.keys()]);
1146
1099
  report(
1147
- at("map", "value"),
1148
- `\`${map}\` is not a map: \`map=\` names the \`data.map.name\` of a mapping note` +
1100
+ at("enc", "value"),
1101
+ `\`${enc}\` does not resolve to an encoding document` +
1149
1102
  (near ? `; did you mean \`${near}\`?` : ""),
1150
1103
  );
1151
1104
  }
1152
1105
  };
1153
1106
 
1154
1107
  const nativeDefaults = (construct, keys) => {
1155
- const defaults = ctx.writtenIn?.get(keys.lang);
1156
- for (const key of ["map", "font"])
1157
- if (keys[key] === undefined && defaults?.[key] !== undefined) keys[key] = defaults[key];
1158
- if (keys.font === undefined)
1159
- report(
1160
- nativeAt(construct),
1161
- `\`.nativefont\` has no default \`font=\` for \`${keys.lang}\`; state \`font=\` explicitly`,
1162
- );
1163
- if (keys.map !== undefined && !maps.has(keys.map))
1164
- report(
1165
- nativeAt(construct),
1166
- `native default \`map=${keys.map}\` is unavailable; state \`map=\` explicitly`,
1167
- );
1108
+ if (encodingUnavailable) return;
1109
+ const encoding = encodings.get(keys.enc);
1110
+ if (!encoding) return;
1168
1111
  if (
1169
- keys.font !== undefined &&
1170
- !faces.has(keys.font) &&
1171
- !Object.hasOwn(GENERIC_FONTS, keys.font)
1112
+ encoding.language !== undefined &&
1113
+ keys.lang !== undefined &&
1114
+ keys.lang !== encoding.language &&
1115
+ tagOf(keys.lang)?.toLowerCase() !== tagOf(encoding.language)?.toLowerCase()
1172
1116
  )
1173
1117
  report(
1174
- nativeAt(construct),
1175
- `native default \`font=${keys.font}\` is unavailable; state \`font=\` explicitly`,
1118
+ construct.at(construct.keys.lang !== undefined ? "lang" : "enc", "value"),
1119
+ `Encoding \`${encoding.name}\` writes language \`${encoding.language}\`, not \`${keys.lang}\``,
1176
1120
  );
1121
+ if (!keys.font || (!faces.has(keys.font) && !Object.hasOwn(GENERIC_FONTS, keys.font)))
1122
+ report(construct.at("enc", "value"), `Encoding font \`${keys.font}\` is unavailable`);
1177
1123
  };
1178
1124
 
1125
+ // A class-only child marks the parent's text. An explicit run key makes
1126
+ // a separate presentation choice, including an explicit language switch.
1127
+ const presentations = new Map();
1128
+ const presentationOf = (construct) => {
1129
+ if (presentations.has(construct)) return presentations.get(construct);
1130
+ const ancestors = around(construct);
1131
+ const inherits = construct.kind === "span" && !Object.keys(construct.keys).length;
1132
+ const parent = inherits ? ancestors.at(-1) : undefined;
1133
+ const inherited = parent ? presentationOf(parent) : undefined;
1134
+ const keys = inherited ? { ...inherited.keys } : {};
1135
+ if (!inherited)
1136
+ for (const ancestor of ancestors) {
1137
+ const inheritedKeys = presentationOf(ancestor).keys;
1138
+ if (inheritedKeys.lang !== undefined) keys.lang = inheritedKeys.lang;
1139
+ if (!native(ancestor) && ancestor.keys.font !== undefined)
1140
+ keys.font = ancestor.keys.font;
1141
+ }
1142
+ if (native(construct)) delete keys.font;
1143
+ Object.assign(keys, construct.keys);
1144
+ keys.enc ??= "default";
1145
+ const selected = encodingNamed(keys.enc);
1146
+ if (selected) {
1147
+ keys.enc = selected.name;
1148
+ keys.lang ??= selected.language;
1149
+ keys.font ??= selected.font ?? selected.fonts[0];
1150
+ } else if (!inherits && keys.font === undefined && ancestors.some(native)) {
1151
+ keys.font = "serif";
1152
+ }
1153
+ const result = { keys };
1154
+ presentations.set(construct, result);
1155
+ return result;
1156
+ };
1157
+
1158
+ const rawReferences = [];
1179
1159
  // Raw wikilinks belong to the resolver, not the span rewriter. Validate
1180
1160
  // their own control attributes with the same grammar before resolution,
1181
- // without mapping a destination or consuming the trailing attributes.
1161
+ // without selection a destination or consuming the trailing attributes.
1182
1162
  for (const match of matchAllOutsideCode(text, new RegExp(WIKILINK.source, "g"))) {
1183
- const parsed = parseWikilink(match[1], match[0]);
1184
- if (!parsed.attributes) continue;
1185
1163
  const close = match[0].indexOf("]]") + 1;
1186
1164
  const from = match.index + close + 2;
1187
1165
  const attrs = parseExtensionAttributes(match[0].slice(close + 2, -1), { positions: true });
@@ -1200,25 +1178,47 @@ function analyseRuns(source, ctx, syntax) {
1200
1178
  };
1201
1179
  const unchecked = errors.length;
1202
1180
  checkOwn(construct);
1203
- if (errors.length !== unchecked || !native(construct)) continue;
1204
- const keys = { ...construct.keys };
1181
+ const keys = presentationOf(construct).keys;
1182
+ const bar = match[1].indexOf("|");
1183
+ if (bar >= 0 && errors.length === unchecked) {
1184
+ rawReferences.push({
1185
+ lang: keys.lang,
1186
+ font: keys.font,
1187
+ from: match.index + 2 + bar + 1,
1188
+ to: match.index + close - 1,
1189
+ where: construct.where,
1190
+ construct,
1191
+ });
1192
+ }
1193
+ if (errors.length !== unchecked || keys.enc === "default" || !encodingNamed(keys.enc))
1194
+ continue;
1205
1195
  nativeDefaults(construct, keys);
1206
- if (errors.length !== unchecked || keys.map === undefined || keys.font === undefined)
1196
+ if (errors.length !== unchecked || keys.enc === undefined || keys.font === undefined)
1207
1197
  continue;
1208
- const problem = fontProblem(maps.get(keys.map), keys.font);
1198
+ const problem = fontProblem(encodings.get(keys.enc), keys.font);
1209
1199
  if (problem)
1210
1200
  report(
1211
- construct.at(construct.keys.map !== undefined ? "map" : "font", "value"),
1201
+ construct.at(construct.keys.font !== undefined ? "font" : "enc", "value"),
1212
1202
  problem,
1213
1203
  );
1214
1204
  }
1215
1205
 
1216
1206
  const verseMarkers = new Map();
1207
+ const verseContexts = new Map();
1208
+ const verseReadings = new Map();
1217
1209
  for (const poem of scanVerse(text).blocks) {
1210
+ for (let line = poem.start + 1; line < poem.end; line++) verseContexts.set(line, poem);
1211
+ for (let line = poem.start + 1; line < poem.end; line++)
1212
+ if (
1213
+ STANZA_BREAK.test(lines[line].trim()) ||
1214
+ /^---\s+\{\.canto\}$/.test(lines[line].trim())
1215
+ )
1216
+ verseMarkers.set(starts[line], starts[line] + lines[line].length);
1218
1217
  if (hasColumns(poem)) {
1219
1218
  for (const group of verseGroups(poem, lines)) {
1220
1219
  if (group.stanzaBreak) continue;
1221
1220
  for (const part of group.parts) {
1221
+ verseReadings.set(part.line, part);
1222
1222
  const start = starts[part.line];
1223
1223
  if (part.prefixLength)
1224
1224
  verseMarkers.set(
@@ -1297,141 +1297,34 @@ function analyseRuns(source, ctx, syntax) {
1297
1297
  }
1298
1298
  };
1299
1299
 
1300
- /**
1301
- * Report each character a face does not draw, once per run, at the source
1302
- * character it came from.
1303
- *
1304
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1305
- * @param {RunDetail} detail - The run.
1306
- */
1307
- const covered = (parts, detail) => {
1308
- const { face, textMap, mapping } = detail;
1309
- if (!face) return;
1310
- const said = new Set();
1311
- for (const [from, to] of parts) {
1312
- const lineStart = from === 0 || text[from - 1] === "\n";
1313
- for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces) {
1314
- // A word with no writing is its own finding, not a face's.
1315
- if (piece.unwritten) continue;
1316
- for (const char of piece.to) {
1317
- const point = /** @type {number} */ (char.codePointAt(0));
1318
- if (/\s/u.test(char) || face.draws.has(point)) continue;
1319
- const key = piece.row ? `${piece.from}\u0000${point}` : char;
1320
- if (said.has(key)) continue;
1321
- said.add(key);
1322
- const where = positionOf(starts, from + piece.offset);
1323
- if (piece.native)
1324
- report(
1325
- where,
1326
- `\`${piece.from}\` is written ${codeName(point)} by the lexicon of ` +
1327
- `\`${detail.run.lang}\` for the map \`${textMap?.name}\`, which the font ` +
1328
- `\`${face.slug}\` does not draw`,
1329
- );
1330
- else if (piece.row)
1331
- report(
1332
- where,
1333
- `\`${piece.from}\` maps to ${codeName(point)} in the map \`${textMap?.name}\`, ` +
1334
- `which the font \`${face.slug}\` does not draw`,
1335
- );
1336
- else
1337
- report(
1338
- where,
1339
- `\`${char}\` (${codeName(point)}) is not drawn by the font \`${face.slug}\`` +
1340
- (textMap ?
1341
- `, and the map \`${textMap.name}\` has no row for it`
1342
- : ""),
1343
- );
1344
- }
1345
- }
1346
- }
1347
- };
1348
-
1349
- /**
1350
- * Report each capitalised word whose native form the rows cannot
1351
- * capitalise, at the word.
1352
- *
1353
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1354
- * @param {RunDetail} detail - The run.
1355
- */
1356
- const capitals = (parts, detail) => {
1357
- const { mapping } = detail;
1358
- if (!mapping?.native) return;
1359
- for (const [from, to] of parts) {
1360
- const lineStart = from === 0 || text[from - 1] === "\n";
1361
- for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces)
1362
- if (piece.uncapitalised !== undefined)
1363
- report(
1364
- positionOf(starts, from + piece.offset),
1365
- uncapitalisedMessage(
1366
- piece.from,
1367
- piece.uncapitalised,
1368
- detail.run.lang,
1369
- mapping.map.name,
1370
- ),
1371
- );
1372
- }
1373
- };
1300
+ /** The configured source inventory for either an alias or its BCP 47 tag. */
1301
+ const inventoryOf = (lang) =>
1302
+ lang === undefined ? undefined : (
1303
+ (ctx.romanInventories?.get(lang) ?? ctx.romanInventories?.get(lang.toLowerCase()))
1304
+ );
1374
1305
 
1375
- /**
1376
- * Report each word a logographic map gives no writing, once per run, at
1377
- * the word.
1378
- *
1379
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1380
- * @param {RunDetail} detail - The run.
1381
- */
1382
- const writings = (parts, detail) => {
1383
- const { mapping } = detail;
1384
- if (!mapping?.map.logographic) return;
1385
- const said = new Set();
1386
- for (const [from, to] of parts) {
1387
- const lineStart = from === 0 || text[from - 1] === "\n";
1388
- for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces) {
1389
- if (piece.unwritten === undefined || said.has(piece.from)) continue;
1390
- said.add(piece.from);
1391
- report(
1392
- positionOf(starts, from + piece.offset),
1393
- unwrittenMessage(
1394
- piece.from,
1395
- piece.unwritten,
1396
- detail.run.lang,
1397
- mapping.map.name,
1398
- ),
1399
- );
1400
- }
1306
+ /** Check visible source before selection or lexicon-native replacement. */
1307
+ const roman = (parts, detail) => {
1308
+ const inventory = inventoryOf(detail.run.lang);
1309
+ if (!inventory) return;
1310
+ if (!inventory.characters) {
1311
+ report(
1312
+ { line: detail.run.line, column: detail.run.column },
1313
+ `the language note for \`${inventory.language}\` must declare \`data.roman\``,
1314
+ );
1315
+ return;
1401
1316
  }
1402
- };
1403
-
1404
- /**
1405
- * Report each word of a mapped run that is not a word of its language, once
1406
- * per run, at its first character.
1407
- *
1408
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1409
- * @param {RunDetail} detail - The run.
1410
- */
1411
- const lexical = (parts, detail) => {
1412
- const { lang } = detail.run;
1413
- if (lang === undefined) return;
1414
- const book = ctx.wordBooks?.get(lang);
1415
- if (!book) return;
1416
- const said = new Set();
1417
1317
  for (const [from, to] of parts) {
1418
- let base = from;
1419
- for (const line of text.slice(from, to).split("\n")) {
1420
- const plain = unescapeRoman(line);
1421
- for (const { offset, word } of unknownWords(plain.text, book, said))
1422
- report(
1423
- positionOf(starts, base + (plain.offsets[offset] ?? 0)),
1424
- unknownWordMessage(word, lang),
1425
- );
1426
- base += line.length + 1;
1427
- }
1318
+ const plain = plainRomanRun(text, { from, to });
1319
+ for (const problem of romanTextProblems(plain.text, inventory))
1320
+ report(positionOf(starts, plain.offsets[problem.offset] ?? from), problem.message);
1428
1321
  }
1429
1322
  };
1430
1323
 
1431
1324
  /**
1432
1325
  * Give a scroll fence the face it is set in, and report a fence that names
1433
1326
  * none or whose script is written in lines. A fence that names no face
1434
- * takes its map's, where the map is drawn in one.
1327
+ * takes its enc's, where the enc is drawn in one.
1435
1328
  *
1436
1329
  * @param {object} construct - The scroll fence.
1437
1330
  * @param {Record<string, string>} keys - Its run keys, given the face.
@@ -1442,7 +1335,7 @@ function analyseRuns(source, ctx, syntax) {
1442
1335
  if (ctx.directions.get(keys.font) === "vertical-rl") return;
1443
1336
  report(
1444
1337
  construct.keys.font !== undefined ? construct.at("font", "value")
1445
- : construct.keys.map !== undefined ? construct.at("map", "value")
1338
+ : construct.keys.enc !== undefined ? construct.at("enc", "value")
1446
1339
  : construct.keys.lang !== undefined ? construct.at("lang", "value")
1447
1340
  : construct.where,
1448
1341
  `a \`scroll\` fence sets a script written in columns, and the script of \`${keys.font}\` ` +
@@ -1455,68 +1348,52 @@ function analyseRuns(source, ctx, syntax) {
1455
1348
  for (const construct of active) {
1456
1349
  const unchecked = errors.length;
1457
1350
  checkOwn(construct);
1458
- // A key that names nothing is the finding; the text it would have set
1459
- // is not then checked against a face or a language it cannot reach.
1460
1351
  let named = errors.length === unchecked;
1461
- // Language and ordinary prose-family choices inherit. Native map/face
1462
- // presentation belongs to this construct alone, including same-language
1463
- // children, so a parallel Roman run never takes a native neighbour's face.
1464
- const ancestors = around(construct);
1465
- const keys = {};
1466
- for (const ancestor of ancestors) {
1467
- if (ancestor.keys.lang !== undefined) keys.lang = ancestor.keys.lang;
1468
- if (!native(ancestor) && Object.hasOwn(GENERIC_FONTS, ancestor.keys.font ?? ""))
1469
- keys.font = ancestor.keys.font;
1470
- }
1471
- if (native(construct)) delete keys.font;
1472
- Object.assign(keys, construct.keys);
1473
- const columnFence = construct.kind === "verse" && hasColumns(construct.node);
1474
- if (named && native(construct) && !columnFence) {
1352
+ const { keys } = presentationOf(construct);
1353
+ if (named && native(construct)) {
1475
1354
  nativeDefaults(construct, keys);
1476
1355
  named = errors.length === unchecked;
1477
- } else if (!native(construct) && keys.font === undefined && ancestors.some(native)) {
1478
- // Reset CSS/Typst inheritance from a surrounding native face.
1479
- keys.font = "serif";
1480
1356
  }
1481
- if (construct.kind === "scroll" && named) {
1482
- scrollFace(construct, keys);
1483
- named = errors.length === unchecked;
1484
- }
1485
- const { lang, font, map } = keys;
1486
- // A face the run's map is not for, once the face and the map are each
1357
+ const { lang, font, enc } = keys;
1358
+ // A face the run's enc is not for, once the face and the enc are each
1487
1359
  // known, at the construct that states either.
1488
1360
  const stated =
1489
1361
  native(construct) ||
1490
- construct.keys.map !== undefined ||
1362
+ construct.keys.enc !== undefined ||
1491
1363
  construct.keys.font !== undefined;
1492
1364
  if (
1493
1365
  named &&
1494
1366
  stated &&
1495
- map !== undefined &&
1496
- maps.has(map) &&
1367
+ enc !== undefined &&
1368
+ encodings.has(enc) &&
1497
1369
  font !== undefined &&
1498
1370
  (faces.has(font) || Object.hasOwn(GENERIC_FONTS, font))
1499
1371
  ) {
1500
- const problem = fontProblem(maps.get(map), font);
1372
+ const problem = fontProblem(encodings.get(enc), font);
1501
1373
  if (problem)
1502
1374
  report(
1503
- construct.at(construct.keys.map !== undefined ? "map" : "font", "value"),
1375
+ construct.at(construct.keys.font !== undefined ? "font" : "enc", "value"),
1504
1376
  problem,
1505
1377
  );
1506
1378
  }
1379
+ named = errors.length === unchecked;
1380
+ if (construct.kind === "scroll" && named) {
1381
+ scrollFace(construct, keys);
1382
+ named = errors.length === unchecked;
1383
+ }
1507
1384
  const run = {
1508
1385
  kind: construct.kind,
1509
1386
  lang,
1510
1387
  tag: tagOf(lang),
1511
1388
  font,
1512
- map,
1513
- ...(native(construct) && construct.keys.lang !== undefined ? { nativefont: true } : {}),
1389
+ enc,
1514
1390
  ...construct.where,
1515
1391
  };
1516
1392
  runs.push(run);
1517
1393
  const plain =
1394
+ inventoryOf(lang) !== undefined ||
1518
1395
  font !== undefined ||
1519
- map !== undefined ||
1396
+ (enc !== undefined && enc !== "default") ||
1520
1397
  (lang !== undefined &&
1521
1398
  Object.hasOwn(languages, lang) &&
1522
1399
  !/^en(?:-|$)/i.test(run.tag ?? ""));
@@ -1527,8 +1404,8 @@ function analyseRuns(source, ctx, syntax) {
1527
1404
  plain,
1528
1405
  face: font === undefined ? undefined : faces.get(font),
1529
1406
  generic: font === undefined ? undefined : GENERIC_FONTS[font],
1530
- textMap: map === undefined ? undefined : maps.get(map),
1531
- mapping: map === undefined ? undefined : mappingFor(ctx, { map, font, lang }),
1407
+ encoding: enc === undefined ? undefined : encodings.get(enc),
1408
+ selection: enc === undefined ? undefined : encodingFor(ctx, { enc, font, lang }),
1532
1409
  holes: new Map(),
1533
1410
  body: undefined,
1534
1411
  contentEnd: construct.kind === "span" ? construct.node.contentEnd : undefined,
@@ -1542,11 +1419,23 @@ function analyseRuns(source, ctx, syntax) {
1542
1419
  details.push(detail);
1543
1420
  const nonEnglishSpan =
1544
1421
  construct.kind === "span" && run.tag !== undefined && !/^en(?:-|$)/i.test(run.tag);
1422
+ const emphasis = construct.own.classes.filter(
1423
+ (name) => name === "emph" || name === "strong",
1424
+ );
1425
+ if (emphasis.length > 1)
1426
+ report(construct.where, "a span takes `.emph` or `.strong`, not both");
1427
+ else if (emphasis[0] === "strong" && nonEnglishSpan)
1428
+ report(
1429
+ construct.where,
1430
+ `a span in non-English language \`${lang}\` holds no \`.strong\` emphasis`,
1431
+ );
1432
+ else if (emphasis[0] === "emph" && detail.languageEmphasis === "words")
1433
+ report(construct.where, wordsMessage(lang));
1545
1434
  if (!plain && !nonEnglishSpan) continue;
1546
1435
  const marking = [
1547
1436
  ...(lang === undefined ? [] : [`lang=${lang}`]),
1548
1437
  ...(font === undefined ? [] : [`font=${font}`]),
1549
- ...(map === undefined ? [] : [`map=${map}`]),
1438
+ ...(enc === undefined ? [] : [`enc=${enc}`]),
1550
1439
  ].join(" ");
1551
1440
  const before = errors.length;
1552
1441
 
@@ -1577,16 +1466,8 @@ function analyseRuns(source, ctx, syntax) {
1577
1466
  }
1578
1467
  if (plain && errors.length === before)
1579
1468
  plainInline(inline, from, marking, detail, detail.holes);
1580
- if (named && errors.length === before) {
1581
- const parts = outsideDelimiters(
1582
- outsideHoles(from, to, detail.holes),
1583
- detail.emphasis,
1584
- );
1585
- lexical(parts, detail);
1586
- capitals(parts, detail);
1587
- writings(parts, detail);
1588
- covered(parts, detail);
1589
- }
1469
+ detail.checkRoman = errors.length === before;
1470
+ if (named && detail.checkRoman) detail.checkText = true;
1590
1471
  continue;
1591
1472
  }
1592
1473
 
@@ -1647,13 +1528,261 @@ function analyseRuns(source, ctx, syntax) {
1647
1528
  for (let line = first; line < last; line++)
1648
1529
  plainInline(lines[line], starts[line], marking, detail, detail.holes);
1649
1530
  }
1650
- if (named && errors.length === before) {
1651
- const parts = outsideDelimiters(outsideHoles(from, to, detail.holes), detail.emphasis);
1652
- lexical(parts, detail);
1653
- capitals(parts, detail);
1654
- writings(parts, detail);
1655
- covered(parts, detail);
1531
+ detail.checkRoman = errors.length === before;
1532
+ if (named && detail.checkRoman) detail.checkText = true;
1533
+ }
1534
+
1535
+ const boundsOf = (detail) =>
1536
+ detail.reference ??
1537
+ (detail.run.kind === "span" ? [detail.run.start + 1, detail.contentEnd] : detail.body);
1538
+ const parentOf = (detail) => {
1539
+ if (detail.run.kind !== "span" || Object.keys(detail.keys).length) return undefined;
1540
+ return details
1541
+ .filter(
1542
+ (parent) =>
1543
+ parent !== detail &&
1544
+ boundsOf(parent) &&
1545
+ boundsOf(parent)[0] <= detail.run.start &&
1546
+ boundsOf(parent)[1] >= detail.run.end,
1547
+ )
1548
+ .sort((a, b) => boundsOf(a)[1] - boundsOf(a)[0] - (boundsOf(b)[1] - boundsOf(b)[0]))[0];
1549
+ };
1550
+ const ownerOf = (detail) => {
1551
+ const parent = parentOf(detail);
1552
+ return parent ? ownerOf(parent) : detail;
1553
+ };
1554
+ const wholeVerseReading = (detail) => {
1555
+ if (detail.run.kind === "verse") return true;
1556
+ if (detail.run.kind !== "span") return false;
1557
+ const line = detail.run.line - 1;
1558
+ const poem = verseContexts.get(line);
1559
+ if (!poem) return false;
1560
+ const end = starts[line] + lines[line].length;
1561
+ if (detail.run.end > end) return false;
1562
+ const reading = verseReadings.get(line);
1563
+ const from =
1564
+ hasColumns(poem) ? starts[line] + (reading?.textColumn ?? 1) - 1 : starts[line];
1565
+ return (
1566
+ from <= detail.run.start &&
1567
+ /^[ \t.]*$/.test(text.slice(from, detail.run.start)) &&
1568
+ /^[ \t\r]*$/.test(text.slice(detail.run.end, end))
1569
+ );
1570
+ };
1571
+ const spanDetails = new Map(
1572
+ details
1573
+ .filter((detail) => detail.run.kind === "span")
1574
+ .map((detail) => [detail.run.start, detail]),
1575
+ );
1576
+ for (const owner of details.filter((detail) => ownerOf(detail) === detail)) {
1577
+ const bounds = boundsOf(owner);
1578
+ if (!bounds || !owner.checkRoman) continue;
1579
+ const owned = details.filter((detail) => ownerOf(detail) === owner);
1580
+ for (const detail of owned) detail.owner = owner;
1581
+ const excluded = [
1582
+ ...[...verseMarkers].filter(([start]) => start >= bounds[0] && start < bounds[1]),
1583
+ ...owned.flatMap((detail) =>
1584
+ detail.emphasis.map((delimiter) => [
1585
+ delimiter.offset,
1586
+ delimiter.offset + delimiter.length,
1587
+ ]),
1588
+ ),
1589
+ ];
1590
+ const projectionOptions = {
1591
+ from: bounds[0],
1592
+ to: bounds[1],
1593
+ spans,
1594
+ exclude: excluded,
1595
+ includeSpan: (span) =>
1596
+ spanDetails.has(span.start) && ownerOf(spanDetails.get(span.start)) === owner,
1597
+ };
1598
+ const verse = wholeVerseReading(owner);
1599
+ const projection =
1600
+ verse ?
1601
+ plainRomanVerse(text, {
1602
+ ...projectionOptions,
1603
+ isCantoLine: (line) =>
1604
+ STANZA_BREAK.test(line.trim()) || /^---\s+\{\.canto\}$/.test(line.trim()),
1605
+ })
1606
+ : plainRomanRun(text, projectionOptions);
1607
+ const before = errors.length;
1608
+ const inventory = inventoryOf(owner.run.lang);
1609
+ if (inventory && !inventory.characters)
1610
+ report(
1611
+ { line: owner.run.line, column: owner.run.column },
1612
+ `the language note for \`${inventory.language}\` must declare \`data.roman\``,
1613
+ );
1614
+ else if (inventory)
1615
+ for (const problem of romanTextProblems(projection.text, inventory))
1616
+ report(
1617
+ positionOf(starts, projection.offsets[problem.offset] ?? bounds[0]),
1618
+ problem.message,
1619
+ );
1620
+ for (const problem of projection.errors ?? [])
1621
+ report(positionOf(starts, problem.offset), problem.message);
1622
+ if (!verse && verseContexts.has(owner.run.line - 1))
1623
+ for (const match of projection.text.matchAll(/\//g)) {
1624
+ if (
1625
+ projection.text.lastIndexOf("⟦", match.index) >
1626
+ projection.text.lastIndexOf("⟧", match.index)
1627
+ )
1628
+ continue;
1629
+ report(
1630
+ positionOf(starts, projection.offsets[match.index]),
1631
+ "a verse fence uses authored line endings for verse boundaries; write no literal `/` inside it",
1632
+ );
1633
+ }
1634
+ const book = ctx.wordBooks?.get(owner.run.lang);
1635
+ if (book) {
1636
+ const mask = projection.text.split("");
1637
+ for (const match of projection.text.matchAll(/⟦[^]*?(?:⟧|$)/g))
1638
+ mask.fill(" ", match.index, match.index + match[0].length);
1639
+ for (const span of projection.spans.filter((span) => !span.included))
1640
+ mask.fill(" ", span.from, span.to);
1641
+ for (const problem of unknownWords(mask.join(""), book))
1642
+ report(
1643
+ positionOf(starts, projection.offsets[problem.offset] ?? bounds[0]),
1644
+ unknownWordMessage(problem.word, owner.run.lang),
1645
+ );
1646
+ }
1647
+ // Roman spelling and structure belong to the authored source even
1648
+ // when its selected encoding or face cannot be resolved. Only the
1649
+ // native conversion and glyph checks depend on that selection.
1650
+ if (!owner.checkText) continue;
1651
+ if (!owner.selection) {
1652
+ const marker = projection.text.indexOf("⟦");
1653
+ if (marker >= 0 && !encodingUnavailable)
1654
+ report(
1655
+ positionOf(starts, projection.offsets[marker]),
1656
+ "literal codage names no encoding; state `enc=`",
1657
+ );
1658
+ continue;
1659
+ }
1660
+ if (errors.length !== before) continue;
1661
+ const result = encodeWithLanguage(owner.selection.encoding, projection.text, {
1662
+ font: owner.selection.font,
1663
+ lexicon: owner.selection.lexicon,
1664
+ });
1665
+ const opaque = projection.spans.filter((span) => !span.included);
1666
+ const braces = new Map(
1667
+ projection.spans
1668
+ .filter((span) => span.included && span.name)
1669
+ .flatMap((span) => [
1670
+ [span.from, { anchor: span.source.start + 1 }],
1671
+ [span.to - 1, { anchor: span.source.contentEnd, closing: true }],
1672
+ ]),
1673
+ );
1674
+ const pieces = result.pieces
1675
+ .filter(
1676
+ (piece) => !opaque.some((span) => piece.start >= span.from && piece.end <= span.to),
1677
+ )
1678
+ .filter((piece) => !verse || !/[\r\n]/.test(piece.from) || piece.from.startsWith("⟦"))
1679
+ .map((piece) => {
1680
+ const brace = braces.get(piece.start);
1681
+ return {
1682
+ ...piece,
1683
+ offset: projection.offsets[piece.start] ?? bounds[0],
1684
+ anchor: brace?.anchor ?? projection.offsets[piece.start] ?? bounds[0],
1685
+ closing: brace?.closing ?? false,
1686
+ };
1687
+ });
1688
+ for (const finding of result.findings) {
1689
+ if (!opaque.some((span) => finding.offset >= span.from && finding.offset < span.to))
1690
+ report(
1691
+ positionOf(starts, projection.offsets[finding.offset] ?? bounds[0]),
1692
+ finding.message,
1693
+ );
1694
+ }
1695
+ if (verse) {
1696
+ for (const boundary of projection.boundaries) {
1697
+ const target =
1698
+ boundary.kind === "line" ?
1699
+ encodeText(owner.selection.encoding, "/", { font: owner.selection.font })
1700
+ : owner.selection.encoding.controls[
1701
+ boundary.kind === "stanza" ? "verseStanza" : "verseCanto"
1702
+ ];
1703
+ if (target === undefined) continue;
1704
+ pieces.push({
1705
+ from: "",
1706
+ to: target,
1707
+ start: boundary.at,
1708
+ end: boundary.at,
1709
+ offset: boundary.sourceOffset,
1710
+ anchor: boundary.sourceOffset,
1711
+ boundary: boundary.kind,
1712
+ closing: true,
1713
+ });
1714
+ }
1656
1715
  }
1716
+ pieces.sort((a, b) => a.anchor - b.anchor || a.start - b.start);
1717
+ const said = new Set();
1718
+ if (owner.face)
1719
+ for (const piece of pieces)
1720
+ for (const char of piece.to) {
1721
+ const point = char.codePointAt(0);
1722
+ if (
1723
+ /\s/u.test(char) ||
1724
+ /\p{Default_Ignorable_Code_Point}/u.test(char) ||
1725
+ owner.face.draws.has(point) ||
1726
+ said.has(point)
1727
+ )
1728
+ continue;
1729
+ said.add(point);
1730
+ report(
1731
+ positionOf(starts, piece.offset),
1732
+ `\`${piece.from}\` encodes to ${codeName(point)} in \`${owner.selection.encoding.name}\`, which the font \`${owner.face.slug}\` does not draw`,
1733
+ );
1734
+ }
1735
+ const plan = { owner, bounds, projection, pieces, verse, emitted: new Set() };
1736
+ for (const detail of owned) detail.plan = plan;
1737
+ }
1738
+
1739
+ for (const reference of rawReferences) {
1740
+ const font = reference.font;
1741
+ roman([[reference.from, reference.to]], {
1742
+ run: {
1743
+ lang: reference.lang,
1744
+
1745
+ ...reference.where,
1746
+ },
1747
+ face: font === undefined ? undefined : faces.get(font),
1748
+ });
1749
+ }
1750
+
1751
+ const firstEncodingRun =
1752
+ active.find(native) ??
1753
+ rawReferences.find((reference) => native(reference.construct))?.construct;
1754
+ if (encodingUnavailable && firstEncodingRun) {
1755
+ if (encodingUnavailable.file)
1756
+ errors.push({
1757
+ file: encodingUnavailable.file,
1758
+ line: encodingUnavailable.line ?? 1,
1759
+ column: encodingUnavailable.column ?? 1,
1760
+ message: encodingUnavailable.message,
1761
+ });
1762
+ else report(nativeAt(firstEncodingRun), encodingUnavailable.message);
1763
+ }
1764
+ for (const match of matchAllOutsideCode(text, /⟦/g)) {
1765
+ if (encodingUnavailable && firstEncodingRun) continue;
1766
+ if (
1767
+ details.some(
1768
+ (detail) =>
1769
+ detail.selection &&
1770
+ boundsOf(detail) &&
1771
+ boundsOf(detail)[0] <= match.index &&
1772
+ match.index < boundsOf(detail)[1],
1773
+ )
1774
+ )
1775
+ continue;
1776
+ const where = positionOf(starts, match.index);
1777
+ if (
1778
+ !errors.some(
1779
+ (error) =>
1780
+ error.line === where.line &&
1781
+ error.column === where.column &&
1782
+ error.message.includes("literal codage"),
1783
+ )
1784
+ )
1785
+ report(where, "literal codage names no encoding; state `enc=`");
1657
1786
  }
1658
1787
 
1659
1788
  const order = (a, b) => a.line - b.line || a.column - b.column;
@@ -1664,21 +1793,44 @@ function analyseRuns(source, ctx, syntax) {
1664
1793
  return { runs, details, errors, warnings, spans, divs, poems };
1665
1794
  }
1666
1795
 
1796
+ /**
1797
+ * Diagnostic columns count Unicode code points. Parsing, edits and run records
1798
+ * keep their UTF-16 source coordinates; foreign-file loader positions already
1799
+ * use the encoding file's own code-point columns.
1800
+ * @param {string} source - Original authored text.
1801
+ * @param {Array<{file?: string, line: number, column: number, message: string}>} findings - Internal findings.
1802
+ * @returns {Array<{file?: string, line: number, column: number, message: string}>} Public diagnostic positions.
1803
+ */
1804
+ function diagnosticColumns(source, findings) {
1805
+ const lines = source.split("\n");
1806
+ return findings.map((finding) =>
1807
+ finding.file ? finding : (
1808
+ {
1809
+ ...finding,
1810
+ column:
1811
+ [...(lines[finding.line - 1] ?? "").slice(0, Math.max(0, finding.column - 1))]
1812
+ .length + 1,
1813
+ }
1814
+ ),
1815
+ );
1816
+ }
1817
+
1667
1818
  /**
1668
1819
  * Find the spans, divs and verse fences marked with `lang=`, `font=` or
1669
- * `map=`, and every fault in their markup and their text.
1820
+ * `enc=`, and every fault in their markup and their text.
1670
1821
  *
1671
1822
  * @param {string} source - Authored Markdown: a note body, or a whole file
1672
1823
  * whose frontmatter is blanked.
1673
1824
  * @param {RunContext} [ctx] - The configured languages, the installed faces
1674
- * and the maps. Without them every `lang=` must be a tag, every face and
1675
- * map is unknown, and only `mono`, `serif` and `sans` are fonts.
1825
+ * and the encodings. Without them every `lang=` must be a tag, every face and
1826
+ * enc is unknown, and only `mono`, `serif` and `sans` are fonts.
1676
1827
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
1677
1828
  * scanner projections, so the source's spans and blocks are scanned once.
1678
- * @returns {{runs: Run[], errors: Array<{line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>}}
1829
+ * @returns {{runs: Run[], errors: Array<{file?: string, line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>}}
1679
1830
  * The runs in source order, and each finding at its 1-based position. A
1680
1831
  * warning is a `lang=` read as a tag that sits within one edit of a
1681
- * declared name.
1832
+ * declared name. Diagnostic columns count Unicode code points; each run's
1833
+ * source offsets and column remain UTF-16 units.
1682
1834
  */
1683
1835
  export function scanRuns(source, ctx = {}, syntax) {
1684
1836
  const text = String(source ?? "");
@@ -1693,9 +1845,9 @@ export function scanRuns(source, ctx = {}, syntax) {
1693
1845
  const merged = (found, extra) =>
1694
1846
  [...found, ...extra].sort((a, b) => a.line - b.line || a.column - b.column);
1695
1847
  return {
1696
- ...authored,
1697
- errors: merged(authored.errors, metre.errors),
1698
- warnings: merged(authored.warnings, metre.warnings),
1848
+ runs: authored.runs,
1849
+ errors: diagnosticColumns(text, merged(authored.errors, metre.errors)),
1850
+ warnings: diagnosticColumns(text, merged(authored.warnings, metre.warnings)),
1699
1851
  };
1700
1852
  }
1701
1853
 
@@ -1745,15 +1897,29 @@ const SCRIPT_BLOCK_CLASS = "script-block";
1745
1897
  * @returns {string|undefined} The text between the braces.
1746
1898
  */
1747
1899
  function rewrittenAttributes(detail, search) {
1748
- const { run, own, keys, face, generic, textMap } = detail;
1900
+ const { run, own, keys, face, generic, encoding } = detail;
1901
+ const emphasis = own.classes.find((name) => name === "emph" || name === "strong");
1902
+ if (emphasis && !Object.keys(keys).length) {
1903
+ const convention = detail.conventions?.[emphasis === "emph" ? "em" : "strong"];
1904
+ return attributeText({
1905
+ lang: undefined,
1906
+ ...own,
1907
+ classes: [...own.classes, ...(convention ? [EMPHASIS_CLASSES[convention]] : [])],
1908
+ });
1909
+ }
1749
1910
  const changes =
1750
1911
  own.classes.includes(NATIVEFONT_CLASS) ||
1751
1912
  run.font !== undefined ||
1752
- run.map !== undefined ||
1913
+ run.enc !== undefined ||
1753
1914
  (run.tag !== undefined &&
1754
1915
  (keys.lang !== run.tag || run.kind === "verse" || run.kind === "scroll"));
1755
1916
  if (!changes) return undefined;
1756
- const classes = own.classes.filter((name) => name !== NATIVEFONT_CLASS);
1917
+ const convention =
1918
+ emphasis ? detail.conventions?.[emphasis === "emph" ? "em" : "strong"] : undefined;
1919
+ const classes = [
1920
+ ...own.classes.filter((name) => name !== NATIVEFONT_CLASS),
1921
+ ...(convention ? [EMPHASIS_CLASSES[convention]] : []),
1922
+ ];
1757
1923
  const values = [...own.values];
1758
1924
  if (face) {
1759
1925
  classes.push(SCRIPT_CLASS);
@@ -1762,7 +1928,7 @@ function rewrittenAttributes(detail, search) {
1762
1928
  if (run.kind !== "span" && face.direction === "rtl") values.push(["dir", "rtl"]);
1763
1929
  if (search) values.push(["data-pagefind-ignore", "true"]);
1764
1930
  } else if (generic) classes.push(generic);
1765
- if (run.map !== undefined && !textMap) return undefined;
1931
+ if (run.enc !== undefined && run.enc !== "default" && !encoding) return undefined;
1766
1932
  return attributeText({ lang: run.tag, id: own.id, classes, values });
1767
1933
  }
1768
1934
 
@@ -1800,6 +1966,37 @@ function emphasisTag(delimiter, conventions, book) {
1800
1966
  * @returns {string} The set text.
1801
1967
  */
1802
1968
  function setRange(text, from, to, detail, lineStart, book) {
1969
+ if (detail.plan) {
1970
+ const events = detail.plan.pieces.filter(
1971
+ (piece) =>
1972
+ piece.boundary !== "canto" &&
1973
+ ((piece.anchor >= from && piece.anchor < to) ||
1974
+ (piece.anchor === to && piece.closing)),
1975
+ );
1976
+ for (const piece of events) detail.plan.emitted.add(piece);
1977
+ const breaks = [...text.slice(from, to).matchAll(/\r\n|\r|\n/g)].map((match) => ({
1978
+ anchor: from + match.index,
1979
+ text: match[0],
1980
+ newline: true,
1981
+ }));
1982
+ const emphasis = detail.emphasis
1983
+ .filter((delimiter) => delimiter.offset >= from && delimiter.offset < to)
1984
+ .map((delimiter) => ({
1985
+ anchor: delimiter.offset,
1986
+ text: emphasisTag(delimiter, detail.conventions, book),
1987
+ markup: true,
1988
+ }));
1989
+ const output = [...events, ...breaks, ...emphasis].sort(
1990
+ (a, b) => a.anchor - b.anchor || Number(a.newline) - Number(b.newline),
1991
+ );
1992
+ return output
1993
+ .map((event) =>
1994
+ event.newline || event.markup ?
1995
+ event.text
1996
+ : escapeRoman(event.to, lineStart && event.anchor === from),
1997
+ )
1998
+ .join("");
1999
+ }
1803
2000
  let out = "";
1804
2001
  let at = from;
1805
2002
  for (const delimiter of detail.emphasis) {
@@ -1807,24 +2004,25 @@ function setRange(text, from, to, detail, lineStart, book) {
1807
2004
  if (delimiter.offset > at)
1808
2005
  out += setText(
1809
2006
  text.slice(at, delimiter.offset),
1810
- detail.mapping,
2007
+ detail.selection,
1811
2008
  at === from && lineStart,
1812
2009
  ).text;
1813
2010
  out += emphasisTag(delimiter, detail.conventions, book);
1814
2011
  at = delimiter.offset + delimiter.length;
1815
2012
  }
1816
- if (at < to) out += setText(text.slice(at, to), detail.mapping, at === from && lineStart).text;
2013
+ if (at < to)
2014
+ out += setText(text.slice(at, to), detail.selection, at === from && lineStart).text;
1817
2015
  return out;
1818
2016
  }
1819
2017
 
1820
2018
  /**
1821
- * Rewrite text marked with `lang=`, `font=` and `map=` into the plain span,
2019
+ * Rewrite text marked with `lang=`, `font=` and `enc=` into the plain span,
1822
2020
  * div and verse grammar the rest of each chain renders, one line for one line.
1823
2021
  *
1824
2022
  * - **Numbered verse prefixes and indentation markers** stay outside run text.
1825
2023
  * Each authored run chooses its own language and presentation; physical
1826
2024
  * lines and source positions remain unchanged.
1827
- * - **The map, if any, is applied first.** Characters Markdown would read are
2025
+ * - **The enc, if any, is applied first.** Characters Markdown would read are
1828
2026
  * then backslash-escaped, so they reach every later pass as characters.
1829
2027
  * - **Emphasis in a face** becomes an `<em>` or a `<strong>` carrying its
1830
2028
  * convention's class, or no class where none is stated. The book reads
@@ -1838,19 +2036,19 @@ function setRange(text, from, to, detail, lineStart, book) {
1838
2036
  * mark, `<bdo>` or `<bdi>` is ever written: the code points carry their own
1839
2037
  * direction.
1840
2038
  * - **A scroll fence's body** is rewritten to one line per paragraph, its
1841
- * lines joined by what the map writes for a space, and left unescaped,
2039
+ * lines joined by what the enc writes for a space, and left unescaped,
1842
2040
  * since the body is raw. Its layout, each character with the source text it
1843
2041
  * was written from, is returned for the book, which draws it.
1844
2042
  *
1845
2043
  * A run with a finding is left as written; {@link scanRuns} reports it.
1846
2044
  *
1847
2045
  * @param {string} source - Authored Markdown: a note body.
1848
- * @param {RunContext} [ctx] - The configured languages, faces and maps.
2046
+ * @param {RunContext} [ctx] - The configured languages, faces and encodings.
1849
2047
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
1850
2048
  * scanner projections.
1851
2049
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
1852
2050
  * Markdown is rendered for; only `site` writes the search marker.
1853
- * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
2051
+ * @returns {{markdown: string, errors: Array<{file?: string, line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
1854
2052
  * The rewritten Markdown, the findings of {@link scanRuns}, and each
1855
2053
  * rewritten scroll fence's layout, by its rewritten body.
1856
2054
  */
@@ -1860,19 +2058,22 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1860
2058
  const text = String(source ?? "");
1861
2059
  /** @type {Map<string, import("./content-scroll.mjs").ScrollLayout>} */
1862
2060
  const scrolls = new Map();
1863
- if (!/\b(?:lang|font|map)=|\bnativefont\b/.test(text))
2061
+ if (!/\b(?:lang|font|enc)=|\bnativefont\b|\.(?:emph|strong)\b|⟦/.test(text))
1864
2062
  return { markdown: text, errors: [], scrolls };
1865
2063
  const { details, errors } = analyseRuns(text, ctx, syntax);
1866
- if (!details.length) return { markdown: text, errors, scrolls };
2064
+ if (!details.length)
2065
+ return { markdown: text, errors: diagnosticColumns(text, errors), scrolls };
1867
2066
  const lines = text.split("\n");
1868
2067
  const starts = lineStarts(text);
1869
2068
 
1870
2069
  /** Whether a finding sits inside a run. */
1871
2070
  const faulted = (run) => {
2071
+ if (run.enc !== "default" && errors.some((error) => error.file)) return true;
1872
2072
  const from = (run.line - 1) * 1e6 + run.column;
1873
2073
  const end = run.kind === "span" ? positionOf(starts, run.end) : undefined;
1874
2074
  const to = end ? (end.line - 1) * 1e6 + end.column : (run.end + 1) * 1e6;
1875
2075
  return errors.some((error) => {
2076
+ if (error.file) return false;
1876
2077
  const at = (error.line - 1) * 1e6 + error.column;
1877
2078
  return at >= from && at < to;
1878
2079
  });
@@ -1889,7 +2090,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1889
2090
  * The body and the layout.
1890
2091
  */
1891
2092
  const setScroll = (detail) => {
1892
- const join = setScrollText(" ", detail.mapping);
2093
+ const join = setScrollText(" ", detail.selection);
1893
2094
  const texts = [];
1894
2095
  const paragraphs = [];
1895
2096
  for (const [top, past] of paragraphRanges(lines, detail.run.start + 1, detail.run.end)) {
@@ -1900,7 +2101,9 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1900
2101
  let pending = "";
1901
2102
  const emit = (pieces) => {
1902
2103
  for (const piece of pieces) {
1903
- const chars = [...piece.to];
2104
+ const chars = [...piece.to].filter(
2105
+ (char) => !/\p{Default_Ignorable_Code_Point}/u.test(char),
2106
+ );
1904
2107
  if (!chars.length) {
1905
2108
  pending += piece.from;
1906
2109
  continue;
@@ -1917,7 +2120,13 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1917
2120
  }
1918
2121
  };
1919
2122
  const set = (from, to) => {
1920
- const result = setScrollText(text.slice(from, to), detail.mapping);
2123
+ const pieces = detail.plan?.pieces.filter(
2124
+ (piece) => piece.anchor >= from && piece.anchor < to,
2125
+ );
2126
+ const result =
2127
+ pieces ?
2128
+ { text: pieces.map((piece) => piece.to).join(""), pieces }
2129
+ : setScrollText(text.slice(from, to), detail.selection);
1921
2130
  out += result.text;
1922
2131
  emit(result.pieces);
1923
2132
  };
@@ -1949,7 +2158,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1949
2158
 
1950
2159
  for (const detail of details) {
1951
2160
  const { run } = detail;
1952
- if (faulted(run)) continue;
2161
+ if (faulted(run) || (detail.owner && faulted(detail.owner.run))) continue;
1953
2162
  const attributes = rewrittenAttributes(detail, search);
1954
2163
  if (run.kind === "span") {
1955
2164
  const contentEnd = /** @type {number} */ (detail.contentEnd);
@@ -1977,7 +2186,13 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1977
2186
  });
1978
2187
  continue;
1979
2188
  }
1980
- if (attributes !== undefined)
2189
+ const emphasis = detail.own.classes.some(
2190
+ (name) => name === "emph" || name === "strong",
2191
+ );
2192
+ if (emphasis && book && detail.face && !detail.conventions) {
2193
+ edits.push({ from: run.start, to: run.start + 1, text: "" });
2194
+ edits.push({ from: contentEnd, to: run.end, text: "" });
2195
+ } else if (attributes !== undefined)
1981
2196
  edits.push({ from: contentEnd + 2, to: run.end - 1, text: attributes });
1982
2197
  if (detail.plain)
1983
2198
  for (const [from, to] of detail.reference ?
@@ -2021,8 +2236,28 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2021
2236
  }
2022
2237
  }
2023
2238
 
2239
+ for (const detail of details) {
2240
+ if (!detail.plan || detail.plan.owner !== detail || faulted(detail.run)) continue;
2241
+ for (const piece of detail.plan.pieces.filter(
2242
+ (piece) => piece.boundary && !detail.plan.emitted.has(piece),
2243
+ )) {
2244
+ if (piece.boundary === "canto") {
2245
+ const end = text.indexOf("\n", piece.anchor);
2246
+ edits.push({ from: piece.anchor, to: end < 0 ? text.length : end, text: piece.to });
2247
+ } else {
2248
+ // A final independent child may occupy the rest of the line.
2249
+ // Its boundary belongs after that child, in the enclosing face.
2250
+ edits.push({
2251
+ from: piece.anchor,
2252
+ to: piece.anchor,
2253
+ text: escapeRoman(piece.to, false),
2254
+ });
2255
+ }
2256
+ }
2257
+ }
2258
+
2024
2259
  let markdown = text;
2025
2260
  for (const edit of edits.sort((a, b) => b.from - a.from))
2026
2261
  markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2027
- return { markdown, errors, scrolls };
2262
+ return { markdown, errors: diagnosticColumns(text, errors), scrolls };
2028
2263
  }