@heroiclands/package-build 24.0.1 → 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.
- package/CHANGELOG.md +14 -0
- package/assets/preview/gitea.ini +1 -1
- package/assets/styles/content-base.css +33 -0
- package/bin/content-commands.mjs +9 -7
- package/docs/api.md +6 -5
- package/docs/authoring/links-and-markup.md +164 -16
- package/docs/commands.md +16 -14
- package/docs/configuration.md +16 -0
- package/docs/content-format.md +4 -2
- package/docs/guides/writing-in-invented-scripts.md +68 -6
- package/docs/reference/format-details.md +143 -49
- package/docs/reference/note-types.md +47 -28
- package/docs/source-preview.md +2 -2
- package/engine/content-blocks.mjs +6 -2
- package/engine/content-format.yaml +2 -0
- package/engine/content-index.mjs +5 -1
- package/engine/content-render.mjs +2 -1
- package/engine/content-scripts.mjs +268 -21
- package/engine/content-scroll.mjs +389 -0
- package/engine/font-outlines.mjs +394 -0
- package/engine/language-data.mjs +154 -63
- package/engine/language-rows.mjs +32 -6
- package/engine/language-terms.mjs +32 -8
- package/engine/map-lexicon.mjs +31 -6
- package/engine/note-vocabulary.mjs +58 -6
- package/engine/pdf-fonts.mjs +1 -1
- package/engine/pdf-render.mjs +52 -11
- package/engine/run-rows.mjs +45 -3
- package/engine/script-direction.mjs +67 -0
- package/engine/script-emphasis-terms.mjs +11 -3
- package/engine/script-font-delivery.mjs +42 -9
- package/engine/script-fonts.mjs +7 -0
- package/engine/verse-metre.mjs +1 -0
- package/engine/written-in.mjs +208 -0
- package/package.json +1 -1
- package/site-theme/static/css/style.css +7 -0
- package/types/engine/content-scripts.d.mts +31 -6
- package/types/engine/content-scroll.d.mts +179 -0
- package/types/engine/font-outlines.d.mts +67 -0
- package/types/engine/language-data.d.mts +13 -0
- package/types/engine/language-rows.d.mts +4 -3
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/map-lexicon.d.mts +4 -3
- package/types/engine/pdf-fonts.d.mts +10 -0
- package/types/engine/run-rows.d.mts +28 -6
- package/types/engine/script-direction.d.mts +10 -0
- package/types/engine/script-emphasis-terms.d.mts +10 -3
- package/types/engine/script-font-delivery.d.mts +5 -3
- package/types/engine/script-fonts.d.mts +9 -0
- package/types/engine/written-in.d.mts +76 -0
|
@@ -0,0 +1,394 @@
|
|
|
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
|
+
* **A face's outlines, drawn as SVG.** The book sets a `scroll` fence as panel
|
|
16
|
+
* images because Typst has no vertical writing, and draws each character from
|
|
17
|
+
* the face's own TrueType outlines, so a panel needs no renderer and no font
|
|
18
|
+
* on the machine that compiles the book.
|
|
19
|
+
*
|
|
20
|
+
* - **`cmap`** (formats 4 and 12) gives each code point its glyph.
|
|
21
|
+
* - **`loca` and `glyf`** give each glyph's quadratic contours, a composite
|
|
22
|
+
* glyph's components moved and scaled into place.
|
|
23
|
+
* - **`hmtx`** gives a glyph's width, which centres it in its column.
|
|
24
|
+
* - **`vmtx`**, where the face has it, gives the space above a glyph in a
|
|
25
|
+
* column one em tall; a face without it is centred on its ascender and
|
|
26
|
+
* descender.
|
|
27
|
+
*
|
|
28
|
+
* A panel is one SVG: each glyph drawn once as a path and placed by `<use>`,
|
|
29
|
+
* the columns read from the right, `rows` ems tall and
|
|
30
|
+
* {@link module:engine/content-scroll.SCROLL_PITCH} ems from column to column.
|
|
31
|
+
* A character the face does not draw, and whitespace, leave their cell empty.
|
|
32
|
+
*
|
|
33
|
+
* @module
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
|
|
38
|
+
import { SCROLL_PITCH } from "./content-scroll.mjs";
|
|
39
|
+
import { tableDirectory } from "./pdf-fonts.mjs";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One glyph, in font units, its y axis pointing down from the baseline.
|
|
43
|
+
*
|
|
44
|
+
* @typedef {object} Glyph
|
|
45
|
+
* @property {number} index - Its glyph index.
|
|
46
|
+
* @property {string} d - Its outline as SVG path data; `""` for none.
|
|
47
|
+
* @property {number} advance - Its horizontal advance.
|
|
48
|
+
* @property {number} top - From the top of its cell to its baseline.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A face's outlines.
|
|
53
|
+
*
|
|
54
|
+
* @typedef {object} Outlines
|
|
55
|
+
* @property {number} unitsPerEm - Its units per em.
|
|
56
|
+
* @property {(point: number) => Glyph|undefined} glyph - The glyph a code
|
|
57
|
+
* point is drawn with, or `undefined` when the face does not draw it.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** @type {Map<string, Outlines>} */
|
|
61
|
+
const cache = new Map();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Code point → glyph index, from a `cmap` of format 4 or 12.
|
|
65
|
+
*
|
|
66
|
+
* @param {Buffer} buf - The font file.
|
|
67
|
+
* @param {{offset: number}} table - The `cmap` record.
|
|
68
|
+
* @returns {Map<number, number>} Glyph indices by code point.
|
|
69
|
+
*/
|
|
70
|
+
function codepointGlyphs(buf, table) {
|
|
71
|
+
const out = new Map();
|
|
72
|
+
const at = table.offset;
|
|
73
|
+
const numTables = buf.readUInt16BE(at + 2);
|
|
74
|
+
for (let i = 0; i < numTables; i += 1) {
|
|
75
|
+
const sub = at + buf.readUInt32BE(at + 4 + i * 8 + 4);
|
|
76
|
+
const format = buf.readUInt16BE(sub);
|
|
77
|
+
if (format === 4) {
|
|
78
|
+
const segX2 = buf.readUInt16BE(sub + 6);
|
|
79
|
+
const endAt = sub + 14;
|
|
80
|
+
const startAt = endAt + segX2 + 2;
|
|
81
|
+
const deltaAt = startAt + segX2;
|
|
82
|
+
const rangeAt = deltaAt + segX2;
|
|
83
|
+
for (let s = 0; s < segX2 / 2; s += 1) {
|
|
84
|
+
const end = buf.readUInt16BE(endAt + s * 2);
|
|
85
|
+
const start = buf.readUInt16BE(startAt + s * 2);
|
|
86
|
+
const delta = buf.readInt16BE(deltaAt + s * 2);
|
|
87
|
+
const rangeOffset = buf.readUInt16BE(rangeAt + s * 2);
|
|
88
|
+
if (start === 0xffff) continue;
|
|
89
|
+
for (let cp = start; cp <= end; cp += 1) {
|
|
90
|
+
let glyph;
|
|
91
|
+
if (rangeOffset === 0) glyph = (cp + delta) & 0xffff;
|
|
92
|
+
else {
|
|
93
|
+
glyph = buf.readUInt16BE(rangeAt + s * 2 + rangeOffset + (cp - start) * 2);
|
|
94
|
+
if (glyph) glyph = (glyph + delta) & 0xffff;
|
|
95
|
+
}
|
|
96
|
+
if (glyph && !out.has(cp)) out.set(cp, glyph);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} else if (format === 12) {
|
|
100
|
+
const groups = buf.readUInt32BE(sub + 12);
|
|
101
|
+
for (let g = 0; g < groups; g += 1) {
|
|
102
|
+
const row = sub + 16 + g * 12;
|
|
103
|
+
const start = buf.readUInt32BE(row);
|
|
104
|
+
const end = Math.min(buf.readUInt32BE(row + 4), start + 0x1_0000);
|
|
105
|
+
const first = buf.readUInt32BE(row + 8);
|
|
106
|
+
for (let cp = start; cp <= end; cp += 1)
|
|
107
|
+
if (!out.has(cp)) out.set(cp, first + (cp - start));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A point of a contour.
|
|
116
|
+
*
|
|
117
|
+
* @typedef {{x: number, y: number, on: boolean}} Point
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A glyph's contours, its components resolved.
|
|
122
|
+
*
|
|
123
|
+
* @param {Buffer} buf - The font file.
|
|
124
|
+
* @param {number} glyf - The `glyf` table's offset.
|
|
125
|
+
* @param {(index: number) => [number, number]} span - A glyph's offset in
|
|
126
|
+
* `glyf` and the offset past it.
|
|
127
|
+
* @param {number} index - The glyph.
|
|
128
|
+
* @param {number} depth - How deep in a composite this glyph sits.
|
|
129
|
+
* @returns {Point[][]} Its contours.
|
|
130
|
+
*/
|
|
131
|
+
function contours(buf, glyf, span, index, depth = 0) {
|
|
132
|
+
const [from, to] = span(index);
|
|
133
|
+
if (to <= from || depth > 8) return [];
|
|
134
|
+
const at = glyf + from;
|
|
135
|
+
const count = buf.readInt16BE(at);
|
|
136
|
+
if (count >= 0) {
|
|
137
|
+
const ends = [];
|
|
138
|
+
for (let c = 0; c < count; c += 1) ends.push(buf.readUInt16BE(at + 10 + c * 2));
|
|
139
|
+
const points = count ? ends[count - 1] + 1 : 0;
|
|
140
|
+
let p = at + 10 + count * 2;
|
|
141
|
+
p += 2 + buf.readUInt16BE(p);
|
|
142
|
+
const flags = [];
|
|
143
|
+
while (flags.length < points) {
|
|
144
|
+
const flag = buf.readUInt8(p++);
|
|
145
|
+
flags.push(flag);
|
|
146
|
+
if (flag & 8) {
|
|
147
|
+
const repeat = buf.readUInt8(p++);
|
|
148
|
+
for (let r = 0; r < repeat; r += 1) flags.push(flag);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const read = (short, same) => {
|
|
152
|
+
const values = [];
|
|
153
|
+
let value = 0;
|
|
154
|
+
for (const flag of flags) {
|
|
155
|
+
if (flag & short) {
|
|
156
|
+
const delta = buf.readUInt8(p++);
|
|
157
|
+
value += flag & same ? delta : -delta;
|
|
158
|
+
} else if (!(flag & same)) {
|
|
159
|
+
value += buf.readInt16BE(p);
|
|
160
|
+
p += 2;
|
|
161
|
+
}
|
|
162
|
+
values.push(value);
|
|
163
|
+
}
|
|
164
|
+
return values;
|
|
165
|
+
};
|
|
166
|
+
const xs = read(2, 16);
|
|
167
|
+
const ys = read(4, 32);
|
|
168
|
+
const out = [];
|
|
169
|
+
let start = 0;
|
|
170
|
+
for (const end of ends) {
|
|
171
|
+
const contour = [];
|
|
172
|
+
for (let k = start; k <= end; k += 1)
|
|
173
|
+
contour.push({ x: xs[k], y: ys[k], on: Boolean(flags[k] & 1) });
|
|
174
|
+
out.push(contour);
|
|
175
|
+
start = end + 1;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
const out = [];
|
|
180
|
+
let p = at + 10;
|
|
181
|
+
for (;;) {
|
|
182
|
+
const flags = buf.readUInt16BE(p);
|
|
183
|
+
const component = buf.readUInt16BE(p + 2);
|
|
184
|
+
p += 4;
|
|
185
|
+
let dx;
|
|
186
|
+
let dy;
|
|
187
|
+
if (flags & 1) {
|
|
188
|
+
dx = buf.readInt16BE(p);
|
|
189
|
+
dy = buf.readInt16BE(p + 2);
|
|
190
|
+
p += 4;
|
|
191
|
+
} else {
|
|
192
|
+
dx = buf.readInt8(p);
|
|
193
|
+
dy = buf.readInt8(p + 1);
|
|
194
|
+
p += 2;
|
|
195
|
+
}
|
|
196
|
+
if (!(flags & 2)) {
|
|
197
|
+
dx = 0;
|
|
198
|
+
dy = 0;
|
|
199
|
+
}
|
|
200
|
+
let [a, b, c, d] = [1, 0, 0, 1];
|
|
201
|
+
const f2dot14 = (offset) => buf.readInt16BE(offset) / 16384;
|
|
202
|
+
if (flags & 8) {
|
|
203
|
+
a = d = f2dot14(p);
|
|
204
|
+
p += 2;
|
|
205
|
+
} else if (flags & 0x40) {
|
|
206
|
+
a = f2dot14(p);
|
|
207
|
+
d = f2dot14(p + 2);
|
|
208
|
+
p += 4;
|
|
209
|
+
} else if (flags & 0x80) {
|
|
210
|
+
a = f2dot14(p);
|
|
211
|
+
b = f2dot14(p + 2);
|
|
212
|
+
c = f2dot14(p + 4);
|
|
213
|
+
d = f2dot14(p + 6);
|
|
214
|
+
p += 8;
|
|
215
|
+
}
|
|
216
|
+
for (const contour of contours(buf, glyf, span, component, depth + 1))
|
|
217
|
+
out.push(
|
|
218
|
+
contour.map(({ x, y, on }) => ({
|
|
219
|
+
x: a * x + c * y + dx,
|
|
220
|
+
y: b * x + d * y + dy,
|
|
221
|
+
on,
|
|
222
|
+
})),
|
|
223
|
+
);
|
|
224
|
+
if (!(flags & 0x20)) break;
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A number as path data writes it. */
|
|
230
|
+
const num = (value) => String(Math.round(value * 100) / 100);
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A contour of quadratic segments as SVG path data, its y axis flipped so it
|
|
234
|
+
* points down from the baseline.
|
|
235
|
+
*
|
|
236
|
+
* @param {Point[]} contour - The contour.
|
|
237
|
+
* @returns {string} The path data.
|
|
238
|
+
*/
|
|
239
|
+
function contourPath(contour) {
|
|
240
|
+
if (!contour.length) return "";
|
|
241
|
+
const points = contour.map(({ x, y, on }) => ({ x, y: -y, on }));
|
|
242
|
+
const mid = (p, q) => ({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2, on: true });
|
|
243
|
+
let first = points.findIndex((point) => point.on);
|
|
244
|
+
let start;
|
|
245
|
+
let ordered;
|
|
246
|
+
if (first < 0) {
|
|
247
|
+
start = mid(points[points.length - 1], points[0]);
|
|
248
|
+
ordered = points;
|
|
249
|
+
} else {
|
|
250
|
+
start = points[first];
|
|
251
|
+
ordered = [...points.slice(first + 1), ...points.slice(0, first)];
|
|
252
|
+
}
|
|
253
|
+
let d = `M${num(start.x)} ${num(start.y)}`;
|
|
254
|
+
let control;
|
|
255
|
+
for (const point of [...ordered, start]) {
|
|
256
|
+
if (point.on) {
|
|
257
|
+
d +=
|
|
258
|
+
control ?
|
|
259
|
+
`Q${num(control.x)} ${num(control.y)} ${num(point.x)} ${num(point.y)}`
|
|
260
|
+
: `L${num(point.x)} ${num(point.y)}`;
|
|
261
|
+
control = undefined;
|
|
262
|
+
} else if (control) {
|
|
263
|
+
const between = mid(control, point);
|
|
264
|
+
d += `Q${num(control.x)} ${num(control.y)} ${num(between.x)} ${num(between.y)}`;
|
|
265
|
+
control = point;
|
|
266
|
+
} else control = point;
|
|
267
|
+
}
|
|
268
|
+
return `${d}Z`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The outlines of a TrueType face, read once per file.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} file - The `.ttf`.
|
|
275
|
+
* @returns {Outlines} Its outlines.
|
|
276
|
+
* @throws {Error} When the file is not a TrueType face with outlines.
|
|
277
|
+
*/
|
|
278
|
+
export function fontOutlines(file) {
|
|
279
|
+
const known = cache.get(file);
|
|
280
|
+
if (known) return known;
|
|
281
|
+
const buf = fs.readFileSync(file);
|
|
282
|
+
const tables = tableDirectory(buf);
|
|
283
|
+
for (const tag of ["head", "maxp", "hhea", "hmtx", "loca", "glyf", "cmap"])
|
|
284
|
+
if (!tables.has(tag))
|
|
285
|
+
throw new Error(
|
|
286
|
+
`${file}: error: the face has no \`${tag}\` table to draw a scroll from`,
|
|
287
|
+
);
|
|
288
|
+
const head = /** @type {{offset: number}} */ (tables.get("head")).offset;
|
|
289
|
+
const unitsPerEm = buf.readUInt16BE(head + 18);
|
|
290
|
+
const longOffsets = buf.readInt16BE(head + 50) === 1;
|
|
291
|
+
const numGlyphs = buf.readUInt16BE(
|
|
292
|
+
/** @type {{offset: number}} */ (tables.get("maxp")).offset + 4,
|
|
293
|
+
);
|
|
294
|
+
const hhea = /** @type {{offset: number}} */ (tables.get("hhea")).offset;
|
|
295
|
+
const ascender = buf.readInt16BE(hhea + 4);
|
|
296
|
+
const descender = buf.readInt16BE(hhea + 6);
|
|
297
|
+
const hMetrics = buf.readUInt16BE(hhea + 34);
|
|
298
|
+
const hmtx = /** @type {{offset: number}} */ (tables.get("hmtx")).offset;
|
|
299
|
+
const loca = /** @type {{offset: number}} */ (tables.get("loca")).offset;
|
|
300
|
+
const glyf = /** @type {{offset: number}} */ (tables.get("glyf")).offset;
|
|
301
|
+
const vhea = tables.get("vhea");
|
|
302
|
+
const vmtx = tables.get("vmtx");
|
|
303
|
+
const vMetrics = vhea && vmtx ? buf.readUInt16BE(vhea.offset + 34) : 0;
|
|
304
|
+
const span = (index) =>
|
|
305
|
+
index >= numGlyphs ? [0, 0]
|
|
306
|
+
: longOffsets ? [buf.readUInt32BE(loca + index * 4), buf.readUInt32BE(loca + index * 4 + 4)]
|
|
307
|
+
: [buf.readUInt16BE(loca + index * 2) * 2, buf.readUInt16BE(loca + index * 2 + 2) * 2];
|
|
308
|
+
const glyphs = codepointGlyphs(buf, /** @type {{offset: number}} */ (tables.get("cmap")));
|
|
309
|
+
/** @type {Map<number, Glyph>} */
|
|
310
|
+
const drawn = new Map();
|
|
311
|
+
const glyphOf = (index) => {
|
|
312
|
+
const had = drawn.get(index);
|
|
313
|
+
if (had) return had;
|
|
314
|
+
const advance = buf.readUInt16BE(hmtx + Math.min(index, hMetrics - 1) * 4);
|
|
315
|
+
const [from, to] = span(index);
|
|
316
|
+
const yMax = to > from ? buf.readInt16BE(glyf + from + 8) : 0;
|
|
317
|
+
let top;
|
|
318
|
+
if (vmtx && vMetrics) {
|
|
319
|
+
const tsb =
|
|
320
|
+
index < vMetrics ?
|
|
321
|
+
buf.readInt16BE(vmtx.offset + index * 4 + 2)
|
|
322
|
+
: buf.readInt16BE(vmtx.offset + vMetrics * 4 + (index - vMetrics) * 2);
|
|
323
|
+
top = yMax + tsb;
|
|
324
|
+
} else top = ascender + (unitsPerEm - (ascender - descender)) / 2;
|
|
325
|
+
const glyph = {
|
|
326
|
+
index,
|
|
327
|
+
d: contours(buf, glyf, span, index).map(contourPath).join(""),
|
|
328
|
+
advance,
|
|
329
|
+
top,
|
|
330
|
+
};
|
|
331
|
+
drawn.set(index, glyph);
|
|
332
|
+
return glyph;
|
|
333
|
+
};
|
|
334
|
+
const outlines = {
|
|
335
|
+
unitsPerEm,
|
|
336
|
+
glyph: (point) => {
|
|
337
|
+
const index = glyphs.get(point);
|
|
338
|
+
return index === undefined ? undefined : glyphOf(index);
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
cache.set(file, outlines);
|
|
342
|
+
return outlines;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The book's rubric red, which its preamble names `book-rubric`. */
|
|
346
|
+
export const BOOK_RUBRIC = "#9c281b";
|
|
347
|
+
|
|
348
|
+
/** The book's ink, which its preamble names `book-ink`. */
|
|
349
|
+
export const BOOK_INK = "#241f1a";
|
|
350
|
+
|
|
351
|
+
/** The colour of plain text and of text set in the rubric. */
|
|
352
|
+
const INK = Object.freeze({ plain: BOOK_INK, rubric: BOOK_RUBRIC });
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* One panel of a scroll as SVG: its columns read from the right, `rows` ems
|
|
356
|
+
* tall, each character centred in its column, in red where it is set in the
|
|
357
|
+
* rubric and with a line beside it, on the right, where it is overlined.
|
|
358
|
+
*
|
|
359
|
+
* @param {import("./content-scroll.mjs").ScrollCell[][]} panel - Its columns,
|
|
360
|
+
* the first the rightmost.
|
|
361
|
+
* @param {number} rows - The characters in a column.
|
|
362
|
+
* @param {Outlines} outlines - The face.
|
|
363
|
+
* @returns {string} The SVG.
|
|
364
|
+
*/
|
|
365
|
+
export function scrollPanelSvg(panel, rows, outlines) {
|
|
366
|
+
const em = outlines.unitsPerEm;
|
|
367
|
+
const pitch = SCROLL_PITCH * em;
|
|
368
|
+
const width = panel.length * pitch;
|
|
369
|
+
const height = rows * em;
|
|
370
|
+
const defs = new Map();
|
|
371
|
+
const marks = [];
|
|
372
|
+
panel.forEach((column, at) => {
|
|
373
|
+
const centre = width - (at + 0.5) * pitch;
|
|
374
|
+
column.forEach((cell, row) => {
|
|
375
|
+
const cellTop = row * em;
|
|
376
|
+
if (cell.emphasis === "overline")
|
|
377
|
+
marks.push(
|
|
378
|
+
`<rect x="${num(centre + 0.55 * em)}" y="${num(cellTop)}" width="${num(0.05 * em)}" height="${num(em)}" fill="${INK.plain}"/>`,
|
|
379
|
+
);
|
|
380
|
+
const point = cell.text.codePointAt(0);
|
|
381
|
+
if (point === undefined || /\s/u.test(cell.text)) return;
|
|
382
|
+
const glyph = outlines.glyph(point);
|
|
383
|
+
if (!glyph?.d) return;
|
|
384
|
+
defs.set(glyph.index, `<path id="g${glyph.index}" d="${glyph.d}"/>`);
|
|
385
|
+
marks.push(
|
|
386
|
+
`<use href="#g${glyph.index}" x="${num(centre - glyph.advance / 2)}" y="${num(cellTop + glyph.top)}" fill="${cell.emphasis === "rubric" ? INK.rubric : INK.plain}"/>`,
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
return (
|
|
391
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${num(width)} ${num(height)}" width="${num(width)}" height="${num(height)}">` +
|
|
392
|
+
`<defs>${[...defs.values()].join("")}</defs>${marks.join("")}</svg>`
|
|
393
|
+
);
|
|
394
|
+
}
|
package/engine/language-data.mjs
CHANGED
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
* - no skill of another subType states these keys;
|
|
22
22
|
* - a tongue tag is lowercase letters and digits, optionally closing on
|
|
23
23
|
* `:<tongue>`, and stands once; a tongue's `language` names a language note;
|
|
24
|
-
* - every tag a register row
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
24
|
+
* - every tag a register row names is declared in `data.tongues`,
|
|
25
|
+
* `translation` apart: `exonym:vylari` is declared by `exonym:<tongue>`;
|
|
26
|
+
* - every tongue a letter row names is such a tag or the Address of a
|
|
27
|
+
* language note, and a spelling guide's, on a `doc` note, an Address (see
|
|
28
|
+
* {@link checkGuideSounds});
|
|
27
29
|
* - an edge stands once in `data.sounds.edges`;
|
|
28
30
|
* - a sound stands once in each mutation of `data.sounds.mutations`;
|
|
29
31
|
* - a cluster stands in one group of `data.sounds.clusters.groups`, and a
|
|
@@ -432,6 +434,153 @@ function checkParticiples(participles, affixes, finding) {
|
|
|
432
434
|
return out;
|
|
433
435
|
}
|
|
434
436
|
|
|
437
|
+
/**
|
|
438
|
+
* The Address checks a note's data reads through, each finding located by
|
|
439
|
+
* `finding`.
|
|
440
|
+
*
|
|
441
|
+
* @param {object|undefined} index - The index; without `addressHit`, nothing
|
|
442
|
+
* resolves and nothing is reported.
|
|
443
|
+
* @param {(path: Array<string|number>, severity: string, message: string, key?: boolean) => object} finding -
|
|
444
|
+
* A finding at a path of the note's frontmatter.
|
|
445
|
+
* @returns {{resolve: Function, language: Function}} The checks.
|
|
446
|
+
*/
|
|
447
|
+
function resolvers(index, finding) {
|
|
448
|
+
/**
|
|
449
|
+
* The note an Address names, or a finding where it names none.
|
|
450
|
+
*
|
|
451
|
+
* @param {unknown} value - The Address as the note holds it.
|
|
452
|
+
* @param {Array<string|number>} path - Where it sits.
|
|
453
|
+
* @param {string|undefined} type - The type a bare shortcode defaults to.
|
|
454
|
+
* @param {boolean} [key] - Whether the Address is a map key.
|
|
455
|
+
* @returns {{target?: object, key?: string, finding?: object}}
|
|
456
|
+
*/
|
|
457
|
+
function resolve(value, path, type, key = false) {
|
|
458
|
+
if (!index?.addressHit || value === undefined || value === null) return {};
|
|
459
|
+
const canonical = addressKeyOf(value, index, type);
|
|
460
|
+
if (!canonical)
|
|
461
|
+
return {
|
|
462
|
+
finding: finding(
|
|
463
|
+
path,
|
|
464
|
+
"error",
|
|
465
|
+
`\`${label(path)}\` is not an Address: ${JSON.stringify(
|
|
466
|
+
isAddressTuple(value) ? renderAddress(value) : value,
|
|
467
|
+
)}`,
|
|
468
|
+
key,
|
|
469
|
+
),
|
|
470
|
+
};
|
|
471
|
+
const target = index.addressHit(canonical);
|
|
472
|
+
if (!target)
|
|
473
|
+
return {
|
|
474
|
+
finding: finding(
|
|
475
|
+
path,
|
|
476
|
+
"error",
|
|
477
|
+
`\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
|
|
478
|
+
"package or its declared dependencies",
|
|
479
|
+
key,
|
|
480
|
+
),
|
|
481
|
+
};
|
|
482
|
+
return { target, key: canonical };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** As {@link resolve}, and the target must be a language note. */
|
|
486
|
+
function language(value, path, key = false) {
|
|
487
|
+
const result = resolve(value, path, "skill", key);
|
|
488
|
+
if (result.finding) return [result.finding];
|
|
489
|
+
if (!result.target) return [];
|
|
490
|
+
const target = result.target.fm ?? result.target;
|
|
491
|
+
if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
|
|
492
|
+
return [
|
|
493
|
+
finding(
|
|
494
|
+
path,
|
|
495
|
+
"error",
|
|
496
|
+
`\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
|
|
497
|
+
"language",
|
|
498
|
+
key,
|
|
499
|
+
),
|
|
500
|
+
];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { resolve, language };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The findings on each `tongue` of a note's `data.sounds.letters` rows. A value
|
|
508
|
+
* is a tag the note's `data.tongues` declares, or else the Address of a
|
|
509
|
+
* language note; a `doc` note declares no tags, so its rows name language
|
|
510
|
+
* notes only. Without an index no Address resolves: a language note's value
|
|
511
|
+
* that is not a declared tag is then a finding, and a guide's is not checked.
|
|
512
|
+
*
|
|
513
|
+
* @param {unknown} letters - The rows.
|
|
514
|
+
* @param {object} context
|
|
515
|
+
* @param {Set<string>|undefined} context.declared - The tags `data.tongues`
|
|
516
|
+
* declares; `undefined` on a note that declares none.
|
|
517
|
+
* @param {object|undefined} context.index - The index Addresses resolve in.
|
|
518
|
+
* @param {(path: Array<string|number>, severity: string, message: string, key?: boolean) => object} context.finding -
|
|
519
|
+
* A finding at a path of the note's frontmatter.
|
|
520
|
+
* @returns {object[]} The findings, each at its value.
|
|
521
|
+
*/
|
|
522
|
+
function letterTongueFindings(letters, { declared, index, finding }) {
|
|
523
|
+
const { resolve, language } = resolvers(index, finding);
|
|
524
|
+
const out = [];
|
|
525
|
+
listOf(letters).forEach((row, i) => {
|
|
526
|
+
if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
|
|
527
|
+
const at = ["data", "sounds", "letters", i, "tongue"];
|
|
528
|
+
const values = Array.isArray(row.tongue) ? row.tongue : [row.tongue];
|
|
529
|
+
values.forEach((value, j) => {
|
|
530
|
+
if (typeof value !== "string") return;
|
|
531
|
+
if (declared && tongueDeclared(value, declared)) return;
|
|
532
|
+
const path = Array.isArray(row.tongue) ? [...at, j] : at;
|
|
533
|
+
const resolved = resolve(value, path, "skill");
|
|
534
|
+
if (resolved.target) {
|
|
535
|
+
out.push(...language(value, path));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (declared)
|
|
539
|
+
out.push(
|
|
540
|
+
finding(
|
|
541
|
+
path,
|
|
542
|
+
"error",
|
|
543
|
+
`the tongue \`${value}\` is not declared in \`data.tongues\`` +
|
|
544
|
+
(index?.addressHit ? " and names no language note" : ""),
|
|
545
|
+
),
|
|
546
|
+
);
|
|
547
|
+
else if (resolved.finding)
|
|
548
|
+
out.push(
|
|
549
|
+
finding(
|
|
550
|
+
path,
|
|
551
|
+
"error",
|
|
552
|
+
`\`${label(path)}\` names \`${value}\`, which is no language note's ` +
|
|
553
|
+
"Address; a spelling guide names each tongue by its language note",
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Check a `doc` note's `data.sounds`, a spelling guide's letters and marks:
|
|
563
|
+
* each letter row's `tongue` names language notes. The data-key check owns
|
|
564
|
+
* the keys, so a sound rule a guide does not state is refused there.
|
|
565
|
+
*
|
|
566
|
+
* @param {object} note - A `doc` note: `file`, `raw` and parsed `fm`.
|
|
567
|
+
* @param {object} [context]
|
|
568
|
+
* @param {object} [context.index] - The index, for whether an Address resolves.
|
|
569
|
+
* @returns {object[]} The findings.
|
|
570
|
+
*/
|
|
571
|
+
export function checkGuideSounds(note, { index } = {}) {
|
|
572
|
+
const sounds = note?.fm?.data?.sounds;
|
|
573
|
+
if (!isMap(sounds)) return [];
|
|
574
|
+
const raw = note.raw ?? "";
|
|
575
|
+
const finding = (path, severity, message, key = false) => ({
|
|
576
|
+
file: note.file,
|
|
577
|
+
...positionOfFrontmatterPath(raw, path, { key }),
|
|
578
|
+
severity,
|
|
579
|
+
message,
|
|
580
|
+
});
|
|
581
|
+
return letterTongueFindings(sounds.letters, { declared: undefined, index, finding });
|
|
582
|
+
}
|
|
583
|
+
|
|
435
584
|
/**
|
|
436
585
|
* Check a `skill` note's language data against what it means.
|
|
437
586
|
*
|
|
@@ -542,60 +691,7 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
542
691
|
...checkAffixClasses(data.affixes, finding),
|
|
543
692
|
);
|
|
544
693
|
|
|
545
|
-
|
|
546
|
-
* The note an Address names, or a finding where it names none.
|
|
547
|
-
*
|
|
548
|
-
* @param {unknown} value - The Address as the note holds it.
|
|
549
|
-
* @param {Array<string|number>} path - Where it sits.
|
|
550
|
-
* @param {string|undefined} type - The type a bare shortcode defaults to.
|
|
551
|
-
* @param {boolean} [key] - Whether the Address is a map key.
|
|
552
|
-
* @returns {{target?: object, key?: string, finding?: object}}
|
|
553
|
-
*/
|
|
554
|
-
function resolve(value, path, type, key = false) {
|
|
555
|
-
if (!index?.addressHit || value === undefined || value === null) return {};
|
|
556
|
-
const canonical = addressKeyOf(value, index, type);
|
|
557
|
-
if (!canonical)
|
|
558
|
-
return {
|
|
559
|
-
finding: finding(
|
|
560
|
-
path,
|
|
561
|
-
"error",
|
|
562
|
-
`\`${label(path)}\` is not an Address: ${JSON.stringify(
|
|
563
|
-
isAddressTuple(value) ? renderAddress(value) : value,
|
|
564
|
-
)}`,
|
|
565
|
-
key,
|
|
566
|
-
),
|
|
567
|
-
};
|
|
568
|
-
const target = index.addressHit(canonical);
|
|
569
|
-
if (!target)
|
|
570
|
-
return {
|
|
571
|
-
finding: finding(
|
|
572
|
-
path,
|
|
573
|
-
"error",
|
|
574
|
-
`\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
|
|
575
|
-
"package or its declared dependencies",
|
|
576
|
-
key,
|
|
577
|
-
),
|
|
578
|
-
};
|
|
579
|
-
return { target, key: canonical };
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** As {@link resolve}, and the target must be a language note. */
|
|
583
|
-
function language(value, path, key = false) {
|
|
584
|
-
const result = resolve(value, path, "skill", key);
|
|
585
|
-
if (result.finding) return [result.finding];
|
|
586
|
-
if (!result.target) return [];
|
|
587
|
-
const target = result.target.fm ?? result.target;
|
|
588
|
-
if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
|
|
589
|
-
return [
|
|
590
|
-
finding(
|
|
591
|
-
path,
|
|
592
|
-
"error",
|
|
593
|
-
`\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
|
|
594
|
-
"language",
|
|
595
|
-
key,
|
|
596
|
-
),
|
|
597
|
-
];
|
|
598
|
-
}
|
|
694
|
+
const { resolve, language } = resolvers(index, finding);
|
|
599
695
|
|
|
600
696
|
// ── Tongues ──────────────────────────────────────────────────────────────
|
|
601
697
|
const declared = new Set();
|
|
@@ -668,12 +764,7 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
668
764
|
|
|
669
765
|
// ── Sounds ───────────────────────────────────────────────────────────────
|
|
670
766
|
const sounds = isMap(data.sounds) ? data.sounds : {};
|
|
671
|
-
|
|
672
|
-
if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
|
|
673
|
-
const path = ["data", "sounds", "letters", i, "tongue"];
|
|
674
|
-
if (Array.isArray(row.tongue)) undeclared(row.tongue, (j) => [...path, j]);
|
|
675
|
-
else undeclared([row.tongue], () => path);
|
|
676
|
-
});
|
|
767
|
+
findings.push(...letterTongueFindings(sounds.letters, { declared, index, finding }));
|
|
677
768
|
const edgeAt = new Map();
|
|
678
769
|
listOf(sounds.edges).forEach((row, i) => {
|
|
679
770
|
if (!isMap(row) || typeof row.edge !== "string") return;
|