@heroiclands/package-build 25.0.0 → 25.1.1
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 +26 -0
- package/README.md +1 -0
- package/bin/content-commands.mjs +30 -96
- package/bin/encoding-commands.mjs +146 -0
- package/bin/package-build.mjs +35 -24
- package/config.mjs +25 -0
- package/convert.mjs +182 -0
- package/docs/api.md +144 -6
- package/docs/authoring/links-and-markup.md +260 -481
- package/docs/commands.md +110 -119
- package/docs/configuration.md +31 -13
- package/docs/content-format.md +11 -4
- package/docs/diagnostics.md +43 -16
- package/docs/encoding-core.md +193 -0
- package/docs/guides/book.md +2 -2
- package/docs/guides/module-project.md +23 -1
- package/docs/guides/site.md +2 -2
- package/docs/guides/writing-in-invented-scripts.md +458 -575
- package/docs/reference/format-details.md +292 -404
- package/docs/reference/note-types.md +83 -63
- package/docs/source-preview.md +8 -0
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +27 -9
- package/engine/content-charset.mjs +13 -1
- package/engine/content-format.yaml +6 -2
- package/engine/content-index.mjs +6 -2
- package/engine/content-links.mjs +1 -0
- package/engine/content-scripts.mjs +614 -459
- package/engine/content-scroll.mjs +4 -4
- package/engine/content-spans.mjs +40 -9
- package/engine/content-verse.mjs +1 -1
- package/engine/converter-data.mjs +70 -0
- package/engine/dictionary-rows.mjs +2 -34
- package/engine/encoding-integrity.mjs +94 -0
- package/engine/encoding-language.mjs +506 -0
- package/engine/encoding-lexicon.mjs +701 -0
- package/engine/encoding-notes.mjs +186 -0
- package/engine/encoding-standard.mjs +42 -0
- package/engine/encoding-template.mjs +370 -0
- package/engine/encoding-terms.mjs +178 -0
- package/engine/encoding.mjs +952 -0
- package/engine/extension-attributes.mjs +2 -0
- package/engine/helpers.mjs +1 -1
- package/engine/index.mjs +7 -0
- package/engine/language-terms.mjs +17 -16
- package/engine/{logographic.mjs → language-writings.mjs} +29 -38
- package/engine/markup-validation.mjs +1 -1
- package/engine/note-vocabulary.mjs +4 -158
- package/engine/pdf-render.mjs +31 -3
- package/engine/roman-characters.mjs +135 -16
- package/engine/roman-run.mjs +149 -0
- package/engine/roman-verse.mjs +95 -0
- package/engine/run-rows.mjs +29 -31
- package/engine/run-words.mjs +2 -2
- package/engine/script-emphasis-terms.mjs +3 -0
- package/engine/script-emphasis.mjs +3 -10
- package/engine/script-font-delivery.mjs +9 -12
- package/engine/script-fonts.mjs +2 -1
- package/engine/site-build.mjs +14 -5
- package/engine/site-preview.mjs +12 -2
- package/engine/sql-tables.mjs +36 -31
- package/engine/verse-metre.mjs +1 -1
- package/engine/word-forms.mjs +1 -1
- package/engine/word-inflections.mjs +48 -0
- package/engine/word-rows.mjs +1 -1
- package/engine/word-terms.mjs +16 -17
- package/package.json +11 -5
- package/types/config.d.mts +5 -0
- package/types/convert.d.mts +44 -0
- package/types/e2e.d.mts +1 -1
- package/types/engine/base-compiler.d.mts +4 -2
- package/types/engine/content-index.d.mts +4 -1
- package/types/engine/content-scripts.d.mts +103 -42
- package/types/engine/converter-data.d.mts +11 -0
- package/types/engine/dictionary-rows.d.mts +1 -25
- package/types/engine/encoding-integrity.d.mts +14 -0
- package/types/engine/encoding-language.d.mts +36 -0
- package/types/engine/encoding-lexicon.d.mts +259 -0
- package/types/engine/encoding-notes.d.mts +39 -0
- package/types/engine/encoding-standard.d.mts +11 -0
- package/types/engine/encoding-template.d.mts +44 -0
- package/types/engine/encoding-terms.d.mts +143 -0
- package/types/engine/encoding.d.mts +155 -0
- package/types/engine/helpers.d.mts +1 -1
- package/types/engine/index.d.mts +4 -0
- package/types/engine/language-terms.d.mts +1 -1
- package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
- package/types/engine/markup-validation.d.mts +1 -1
- package/types/engine/pdf-render.d.mts +1 -1
- package/types/engine/roman-characters.d.mts +2 -2
- package/types/engine/roman-run.d.mts +101 -0
- package/types/engine/roman-verse.d.mts +23 -0
- package/types/engine/run-rows.d.mts +13 -20
- package/types/engine/script-emphasis-terms.d.mts +2 -0
- package/types/engine/script-emphasis.d.mts +1 -1
- package/types/engine/script-font-delivery.d.mts +2 -3
- package/types/engine/script-fonts.d.mts +5 -0
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/word-inflections.d.mts +25 -0
- package/engine/map-lexicon.mjs +0 -1045
- package/engine/text-maps.mjs +0 -654
- package/engine/written-in.mjs +0 -204
- package/types/engine/map-lexicon.d.mts +0 -330
- package/types/engine/text-maps.d.mts +0 -189
- package/types/engine/written-in.d.mts +0 -76
|
@@ -0,0 +1,506 @@
|
|
|
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
|
+
/** Language-aware conversion over plain JSON lexicons, usable in browsers and Node. */
|
|
15
|
+
import {
|
|
16
|
+
encodePieces,
|
|
17
|
+
encodeText,
|
|
18
|
+
decodeText,
|
|
19
|
+
EncodingError,
|
|
20
|
+
validateSource,
|
|
21
|
+
} from "./encoding.mjs";
|
|
22
|
+
import { writingsOf } from "./language-writings.mjs";
|
|
23
|
+
import { derivedForms, foldWord } from "./word-forms.mjs";
|
|
24
|
+
import { WORD } from "./word-pattern.mjs";
|
|
25
|
+
|
|
26
|
+
const OPEN = "\u{E0112}",
|
|
27
|
+
CLOSE = "\u{E0113}",
|
|
28
|
+
ESCAPE = "\u{E0115}";
|
|
29
|
+
const MARKS = /[\u{E0100}-\u{E01EF}]/u;
|
|
30
|
+
const stripMarks = (text) => text.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
|
|
31
|
+
const list = (value) => (Array.isArray(value) ? value : []);
|
|
32
|
+
const letter = /\p{L}/u;
|
|
33
|
+
const cp = (text, at) => String.fromCodePoint(text.codePointAt(at));
|
|
34
|
+
const lexical = (text) => /^[\p{L}\p{M}\p{N}’'–-]+$/u.test(text);
|
|
35
|
+
function finding(text, offset, message) {
|
|
36
|
+
return {
|
|
37
|
+
severity: "error",
|
|
38
|
+
offset,
|
|
39
|
+
line: text.slice(0, offset).split("\n").length,
|
|
40
|
+
column: [...text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset)].length + 1,
|
|
41
|
+
message,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function optionsFor(enc, options) {
|
|
45
|
+
return { ...options, font: options.font ?? enc.fonts[0] };
|
|
46
|
+
}
|
|
47
|
+
function initialCapital(word) {
|
|
48
|
+
const first = cp(word, 0);
|
|
49
|
+
return first.toUpperCase() + word.slice(first.length);
|
|
50
|
+
}
|
|
51
|
+
function capitalWriting(enc, word, native, font) {
|
|
52
|
+
const first = (source) => {
|
|
53
|
+
try {
|
|
54
|
+
return encodePieces(enc, source, { font })[0];
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!(error instanceof EncodingError) || !(error.offset > 0)) return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return encodePieces(enc, source, { font, end: error.offset })[0];
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const lower = first(word.toLowerCase()),
|
|
65
|
+
upper = first(word);
|
|
66
|
+
return (
|
|
67
|
+
lower && upper && lower.from.length === upper.from.length && native.startsWith(lower.to)
|
|
68
|
+
) ?
|
|
69
|
+
upper.to + native.slice(lower.to.length)
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
function entryForms(lexicon) {
|
|
73
|
+
const data = lexicon?.data ?? {};
|
|
74
|
+
const derived = new Map();
|
|
75
|
+
for (const entry of derivedForms(data)) {
|
|
76
|
+
if (!derived.has(entry.word)) derived.set(entry.word, []);
|
|
77
|
+
derived.get(entry.word).push(entry);
|
|
78
|
+
}
|
|
79
|
+
// Authored word order decides recovery ties. Each citation precedes its
|
|
80
|
+
// own variants and derived forms, before the next word's citation.
|
|
81
|
+
const words = list(data.words).filter((word) => typeof word?.form === "string");
|
|
82
|
+
return [
|
|
83
|
+
...words.flatMap((word) => {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const seenComponents = new Set();
|
|
86
|
+
const forms = [];
|
|
87
|
+
for (const form of [
|
|
88
|
+
word.form,
|
|
89
|
+
...list(word.variants),
|
|
90
|
+
...(derived.get(word) ?? []).map((entry) => entry.form),
|
|
91
|
+
]) {
|
|
92
|
+
if (typeof form !== "string" || !form || seen.has(form)) continue;
|
|
93
|
+
seen.add(form);
|
|
94
|
+
forms.push({ form, word });
|
|
95
|
+
const parts = [...form.matchAll(WORD)].map(([part]) => part);
|
|
96
|
+
if (parts.length > 1)
|
|
97
|
+
for (const part of parts) {
|
|
98
|
+
if (seenComponents.has(part)) continue;
|
|
99
|
+
seenComponents.add(part);
|
|
100
|
+
// Components establish recovery and known-word membership,
|
|
101
|
+
// not ownership of their expression's native spelling.
|
|
102
|
+
forms.push({ form: part, component: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return forms;
|
|
106
|
+
}),
|
|
107
|
+
...list(data.register)
|
|
108
|
+
.filter((row) => typeof row?.name === "string" && row.tongue !== "translation")
|
|
109
|
+
.flatMap((row) => {
|
|
110
|
+
const canonical = foldWord(row.name);
|
|
111
|
+
const forms = [...new Set([row.name, canonical])].map((form) => ({
|
|
112
|
+
form,
|
|
113
|
+
name: true,
|
|
114
|
+
canonical,
|
|
115
|
+
}));
|
|
116
|
+
if ([...row.name.matchAll(WORD)].length > 1)
|
|
117
|
+
for (const [part] of row.name.matchAll(WORD))
|
|
118
|
+
for (const form of new Set([part, foldWord(part)]))
|
|
119
|
+
forms.push({ form, name: false, namePart: true });
|
|
120
|
+
return forms;
|
|
121
|
+
}),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function literalEnd(text, at) {
|
|
126
|
+
let cursor = at + OPEN.length;
|
|
127
|
+
while (cursor < text.length) {
|
|
128
|
+
if (text.startsWith(ESCAPE, cursor)) {
|
|
129
|
+
cursor +=
|
|
130
|
+
ESCAPE.length +
|
|
131
|
+
(cursor + ESCAPE.length < text.length ?
|
|
132
|
+
cp(text, cursor + ESCAPE.length).length
|
|
133
|
+
: 0);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (text.startsWith(CLOSE, cursor)) return cursor + CLOSE.length;
|
|
137
|
+
cursor += cp(text, cursor).length;
|
|
138
|
+
}
|
|
139
|
+
return text.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Encode using the lexicon's own and composed writings before the encoding rows.
|
|
144
|
+
* @param {ReturnType<import("./encoding.mjs").compileEncoding>} enc
|
|
145
|
+
* @param {string} text
|
|
146
|
+
* @param {{font?:string,verse?:boolean,lexicon?:{language:string,data:Record<string,any>}}} [options]
|
|
147
|
+
* @returns {{text:string,pieces:object[],findings:object[]}}
|
|
148
|
+
*/
|
|
149
|
+
export function encodeWithLanguage(enc, text, options = {}) {
|
|
150
|
+
options = optionsFor(enc, options);
|
|
151
|
+
const findings = [],
|
|
152
|
+
pieces = [];
|
|
153
|
+
if (enc.template) {
|
|
154
|
+
try {
|
|
155
|
+
validateSource(enc, text, { font: options.font });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!(error instanceof EncodingError)) throw error;
|
|
158
|
+
return {
|
|
159
|
+
text: "",
|
|
160
|
+
pieces,
|
|
161
|
+
findings: [finding(text, error.offset ?? 0, error.message)],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const writing =
|
|
166
|
+
options.lexicon ? writingsOf(options.lexicon.data, enc.name, options.font) : undefined;
|
|
167
|
+
const phrases =
|
|
168
|
+
options.lexicon ?
|
|
169
|
+
entryForms(options.lexicon)
|
|
170
|
+
.map((entry) => entry.form)
|
|
171
|
+
.filter((form) => [...form.matchAll(WORD)].length > 1)
|
|
172
|
+
.map((form) => ({
|
|
173
|
+
form,
|
|
174
|
+
spaces: form.match(/[\p{Zs}\u0009-\u000D]+/gu) ?? [],
|
|
175
|
+
pattern: new RegExp(
|
|
176
|
+
form
|
|
177
|
+
.split(/[\p{Zs}\u0009-\u000D]+/u)
|
|
178
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
179
|
+
.join("[\\p{Zs}\\u0009-\\u000D]+"),
|
|
180
|
+
"iuy",
|
|
181
|
+
),
|
|
182
|
+
}))
|
|
183
|
+
: [];
|
|
184
|
+
let at = 0;
|
|
185
|
+
const appendRows = (from, to) => {
|
|
186
|
+
let cursor = from;
|
|
187
|
+
while (cursor < to) {
|
|
188
|
+
try {
|
|
189
|
+
pieces.push(
|
|
190
|
+
...encodePieces(enc, text, {
|
|
191
|
+
font: options.font,
|
|
192
|
+
verse: options.verse,
|
|
193
|
+
start: cursor,
|
|
194
|
+
end: to,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!(error instanceof EncodingError)) throw error;
|
|
200
|
+
const offset = error.offset ?? cursor;
|
|
201
|
+
findings.push(finding(text, offset, error.message));
|
|
202
|
+
if (offset > cursor) {
|
|
203
|
+
try {
|
|
204
|
+
pieces.push(
|
|
205
|
+
...encodePieces(enc, text, {
|
|
206
|
+
font: options.font,
|
|
207
|
+
verse: options.verse,
|
|
208
|
+
start: cursor,
|
|
209
|
+
end: offset,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
} catch {
|
|
213
|
+
/* An invalid literal is refused as one span. */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const open = text.lastIndexOf("⟦", offset),
|
|
217
|
+
close = text.lastIndexOf("⟧", offset);
|
|
218
|
+
if (open > close) {
|
|
219
|
+
const end = text.indexOf("⟧", offset);
|
|
220
|
+
cursor = end < 0 ? to : end + 1;
|
|
221
|
+
} else cursor = offset + (offset < text.length ? cp(text, offset).length : 1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
for (const match of text.matchAll(WORD)) {
|
|
226
|
+
const start = match.index;
|
|
227
|
+
if (start < at) continue;
|
|
228
|
+
// Literal contents belong to the encoding's codes, not to a language lexicon.
|
|
229
|
+
const open = text.lastIndexOf("⟦", start),
|
|
230
|
+
close = text.lastIndexOf("⟧", start);
|
|
231
|
+
if (open > close) continue;
|
|
232
|
+
// The authored longest phrase wins before individual word overrides.
|
|
233
|
+
// Match only complete words, and leave literal payloads to the codes.
|
|
234
|
+
let phrase;
|
|
235
|
+
for (const candidate of phrases) {
|
|
236
|
+
candidate.pattern.lastIndex = start;
|
|
237
|
+
const found = candidate.pattern.exec(text)?.[0];
|
|
238
|
+
if (
|
|
239
|
+
!found ||
|
|
240
|
+
found.includes("⟦") ||
|
|
241
|
+
found.includes("⟧") ||
|
|
242
|
+
/[\p{L}\p{M}\p{N}’'–-]/u.test(text[start + found.length] ?? "")
|
|
243
|
+
)
|
|
244
|
+
continue;
|
|
245
|
+
let space = 0;
|
|
246
|
+
const lookup = found.replace(
|
|
247
|
+
/[\p{Zs}\u0009-\u000D]+/gu,
|
|
248
|
+
() => candidate.spaces[space++],
|
|
249
|
+
);
|
|
250
|
+
const own = writing.word(lookup);
|
|
251
|
+
if (!("text" in own) && !("ambiguous" in own)) continue;
|
|
252
|
+
if (!phrase || found.length > phrase.text.length) phrase = { text: found, lookup, own };
|
|
253
|
+
}
|
|
254
|
+
if (phrase) match[0] = phrase.text;
|
|
255
|
+
const lookup = phrase?.lookup ?? match[0];
|
|
256
|
+
const own = phrase?.own ?? writing?.word(lookup);
|
|
257
|
+
if (own && "ambiguous" in own) {
|
|
258
|
+
appendRows(at, start);
|
|
259
|
+
findings.push(
|
|
260
|
+
finding(
|
|
261
|
+
text,
|
|
262
|
+
start,
|
|
263
|
+
`\`${match[0]}\` has ambiguous writings in the \`${options.lexicon.language}\` lexicon: ${own.ambiguous.glosses.join("; ")}`,
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
at = start + match[0].length;
|
|
267
|
+
} else if (own && "text" in own) {
|
|
268
|
+
appendRows(at, start);
|
|
269
|
+
let native = own.text;
|
|
270
|
+
const exact = list(options.lexicon.data.words).some(
|
|
271
|
+
(entry) => entry.form === lookup || list(entry.variants).includes(lookup),
|
|
272
|
+
);
|
|
273
|
+
if (!exact && match[0] !== match[0].toLowerCase()) {
|
|
274
|
+
const capital =
|
|
275
|
+
match[0] === initialCapital(match[0].toLowerCase()) ?
|
|
276
|
+
capitalWriting(enc, match[0], native, options.font)
|
|
277
|
+
: undefined;
|
|
278
|
+
if (capital === undefined) {
|
|
279
|
+
findings.push(
|
|
280
|
+
finding(
|
|
281
|
+
text,
|
|
282
|
+
start,
|
|
283
|
+
`The encoding cannot capitalize the native writing of \`${match[0].toLowerCase()}\`; author \`${match[0]}\` with its own native entry`,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
at = start + match[0].length;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
native = capital;
|
|
290
|
+
}
|
|
291
|
+
pieces.push({
|
|
292
|
+
from: match[0],
|
|
293
|
+
to: native,
|
|
294
|
+
start,
|
|
295
|
+
end: start + match[0].length,
|
|
296
|
+
row: null,
|
|
297
|
+
native: true,
|
|
298
|
+
source: own.source,
|
|
299
|
+
});
|
|
300
|
+
at = start + match[0].length;
|
|
301
|
+
} else if (letter.test(match[0]) && ![...enc.accepted].some((char) => letter.test(char))) {
|
|
302
|
+
appendRows(at, start);
|
|
303
|
+
findings.push(
|
|
304
|
+
finding(
|
|
305
|
+
text,
|
|
306
|
+
start,
|
|
307
|
+
`\`${match[0]}\` has no writing in the \`${options.lexicon?.language ?? "unspecified language"}\` lexicon for \`${enc.name}\`; write it from the lexicon or as ⟦codage⟧`,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
at = start + match[0].length;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
appendRows(at, text.length);
|
|
314
|
+
return { text: findings.length ? "" : pieces.map((p) => p.to).join(""), pieces, findings };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Decode with explicit writings first and recover unmarked words from the lexicon.
|
|
319
|
+
* @param {ReturnType<import("./encoding.mjs").compileEncoding>} enc
|
|
320
|
+
* @param {string} text
|
|
321
|
+
* @param {{font?:string,lexicon?:{language:string,data:Record<string,any>}}} [options]
|
|
322
|
+
* @returns {{text:string,findings:object[]}}
|
|
323
|
+
*/
|
|
324
|
+
export function decodeWithLanguage(enc, text, options = {}) {
|
|
325
|
+
options = optionsFor(enc, options);
|
|
326
|
+
if (!options.lexicon) {
|
|
327
|
+
try {
|
|
328
|
+
return { text: decodeText(enc, text, options), findings: [] };
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (!(error instanceof EncodingError)) throw error;
|
|
331
|
+
return { text: "", findings: [finding(text, error.offset ?? 0, error.message)] };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const findings = [],
|
|
335
|
+
forms = entryForms(options.lexicon),
|
|
336
|
+
writing = writingsOf(options.lexicon.data, enc.name, options.font);
|
|
337
|
+
const own = [];
|
|
338
|
+
const skeletons = new Map();
|
|
339
|
+
const knownForms = new Set(
|
|
340
|
+
forms.filter((entry) => !entry.name).map((entry) => foldWord(entry.form)),
|
|
341
|
+
);
|
|
342
|
+
for (const entry of forms) {
|
|
343
|
+
const native = entry.name || entry.component ? undefined : writing.word(entry.form);
|
|
344
|
+
// Keep authored senses in order; entry-specific native texts can share a spelling.
|
|
345
|
+
// Derived forms instead use their composed stem and ending writing above.
|
|
346
|
+
const authored =
|
|
347
|
+
entry.form === entry.word?.form || list(entry.word?.variants).includes(entry.form);
|
|
348
|
+
const explicit =
|
|
349
|
+
authored ?
|
|
350
|
+
list(entry.word?.native).filter(
|
|
351
|
+
(n) => n.enc === enc.name && (!n.font || n.font === options.font),
|
|
352
|
+
)
|
|
353
|
+
: [];
|
|
354
|
+
const chosen =
|
|
355
|
+
explicit.find((n) => n.font === options.font) ?? explicit.find((n) => !n.font);
|
|
356
|
+
if (chosen || (native && "text" in native)) {
|
|
357
|
+
const target = chosen?.text ?? native.text;
|
|
358
|
+
own.push({ text: target, form: entry.form, name: entry.name });
|
|
359
|
+
if (entry.form === entry.form.toLowerCase()) {
|
|
360
|
+
const title = initialCapital(entry.form),
|
|
361
|
+
capital = capitalWriting(enc, title, target, options.font);
|
|
362
|
+
if (capital !== undefined && capital !== target)
|
|
363
|
+
own.push({ text: capital, form: title });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (entry.name) {
|
|
367
|
+
const encoded = encodeWithLanguage(enc, entry.form, options);
|
|
368
|
+
if (!encoded.findings.length) {
|
|
369
|
+
const target = stripMarks(encoded.text);
|
|
370
|
+
if (target)
|
|
371
|
+
own.push({ text: target, form: entry.canonical, name: true, recovery: true });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const skeleton = decodeText(
|
|
376
|
+
enc,
|
|
377
|
+
stripMarks(encodeText(enc, entry.form, options)),
|
|
378
|
+
options,
|
|
379
|
+
);
|
|
380
|
+
if (skeleton && !skeletons.has(skeleton)) skeletons.set(skeleton, entry);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (!(error instanceof EncodingError)) throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
own.sort((a, b) => [...b.text].length - [...a.text].length);
|
|
386
|
+
const nativeReadings = new Map();
|
|
387
|
+
for (const entry of own) {
|
|
388
|
+
if (!entry.text) continue;
|
|
389
|
+
const first = cp(entry.text, 0);
|
|
390
|
+
if (!nativeReadings.has(first)) nativeReadings.set(first, []);
|
|
391
|
+
nativeReadings.get(first).push(entry);
|
|
392
|
+
}
|
|
393
|
+
const reader = enc.faces.get(options.font)?.readings ?? enc.shared.readings;
|
|
394
|
+
const noLetters = ![...enc.accepted].some((char) => letter.test(char));
|
|
395
|
+
let out = "",
|
|
396
|
+
at = 0,
|
|
397
|
+
named = false;
|
|
398
|
+
function plain(raw, offset) {
|
|
399
|
+
try {
|
|
400
|
+
return decodeText(enc, raw, options);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (!(error instanceof EncodingError)) throw error;
|
|
403
|
+
findings.push(finding(text, offset + (error.offset ?? 0), error.message));
|
|
404
|
+
return "";
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const rowAt = (offset) =>
|
|
408
|
+
(reader.get(cp(text, offset)) ?? []).find((row) => text.startsWith(row.to, offset));
|
|
409
|
+
const wordEnd = (from) => {
|
|
410
|
+
let cursor = from;
|
|
411
|
+
while (cursor < text.length && !text.startsWith(OPEN, cursor)) {
|
|
412
|
+
const row = rowAt(cursor);
|
|
413
|
+
if (row && !lexical(row.from)) break;
|
|
414
|
+
cursor += row?.to.length ?? cp(text, cursor).length;
|
|
415
|
+
}
|
|
416
|
+
return cursor;
|
|
417
|
+
};
|
|
418
|
+
const nativeAt = (offset) =>
|
|
419
|
+
(nativeReadings.get(cp(text, offset)) ?? []).find((entry) => {
|
|
420
|
+
if (!entry.text || !text.startsWith(entry.text, offset)) return false;
|
|
421
|
+
if (noLetters) return true;
|
|
422
|
+
// A shorter native spelling must not consume a prefix of a complete
|
|
423
|
+
// alphabetic word. A native writing may itself cross spaces or marks.
|
|
424
|
+
const end = offset + entry.text.length;
|
|
425
|
+
return end === wordEnd(end);
|
|
426
|
+
});
|
|
427
|
+
let previousWord = false;
|
|
428
|
+
const appendWord = (roman) => {
|
|
429
|
+
if (noLetters && previousWord) out += " ";
|
|
430
|
+
out += roman;
|
|
431
|
+
previousWord = true;
|
|
432
|
+
};
|
|
433
|
+
while (at < text.length) {
|
|
434
|
+
if (text.startsWith(OPEN, at)) {
|
|
435
|
+
const end = literalEnd(text, at);
|
|
436
|
+
out += plain(text.slice(at, end), at);
|
|
437
|
+
at = end;
|
|
438
|
+
previousWord = false;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const native = nativeAt(at);
|
|
442
|
+
if (native) {
|
|
443
|
+
appendWord(native.name && !named ? `{${native.form}}` : native.form);
|
|
444
|
+
at += native.text.length;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const row = rowAt(at);
|
|
448
|
+
if (row && !lexical(row.from)) {
|
|
449
|
+
out += row.from;
|
|
450
|
+
named =
|
|
451
|
+
row.from === "{" ? true
|
|
452
|
+
: row.from === "}" ? false
|
|
453
|
+
: named;
|
|
454
|
+
at += row.to.length;
|
|
455
|
+
previousWord = false;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const start = at;
|
|
459
|
+
if (noLetters) {
|
|
460
|
+
// An unknown stretch ends before the next known writing, so a
|
|
461
|
+
// missing word cannot swallow the rest of a sign passage.
|
|
462
|
+
at += row?.to.length ?? cp(text, at).length;
|
|
463
|
+
while (at < text.length && !text.startsWith(OPEN, at) && !nativeAt(at)) {
|
|
464
|
+
const following = rowAt(at);
|
|
465
|
+
if (following && !lexical(following.from)) break;
|
|
466
|
+
at += following?.to.length ?? cp(text, at).length;
|
|
467
|
+
}
|
|
468
|
+
const raw = text.slice(start, at);
|
|
469
|
+
const roman = `⟦${[...raw].map((char) => `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(" ")}⟧`;
|
|
470
|
+
appendWord(decodeText(enc, encodeText(enc, roman, options), options));
|
|
471
|
+
findings.push(
|
|
472
|
+
finding(
|
|
473
|
+
text,
|
|
474
|
+
start,
|
|
475
|
+
`These signs are not a word of the \`${options.lexicon.language}\` lexicon`,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
at = wordEnd(at);
|
|
481
|
+
const raw = text.slice(start, at);
|
|
482
|
+
const decoded = plain(raw, start);
|
|
483
|
+
if (MARKS.test(raw)) {
|
|
484
|
+
out += decoded;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const recovered = skeletons.get(decoded);
|
|
488
|
+
if (recovered)
|
|
489
|
+
out +=
|
|
490
|
+
recovered.name && !named ? `{${recovered.canonical ?? recovered.form}}`
|
|
491
|
+
: recovered.name ? (recovered.canonical ?? recovered.form)
|
|
492
|
+
: recovered.form;
|
|
493
|
+
else if (knownForms.has(foldWord(decoded))) out += decoded;
|
|
494
|
+
else {
|
|
495
|
+
out += decoded;
|
|
496
|
+
findings.push(
|
|
497
|
+
finding(
|
|
498
|
+
text,
|
|
499
|
+
start,
|
|
500
|
+
`\`${decoded}\` is not in the \`${options.lexicon.language}\` lexicon`,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { text: out, findings };
|
|
506
|
+
}
|