@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,389 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **The `scroll` fence**: text in a script written in vertical columns, read
|
|
16
|
+
* from the right, as a handscroll unrolls.
|
|
17
|
+
*
|
|
18
|
+
* ````markdown
|
|
19
|
+
* ```scroll {rows=25 cols=12 lang=tsu map=tengchou}
|
|
20
|
+
* chen teng chou hing
|
|
21
|
+
* tan tsou dou rou
|
|
22
|
+
*
|
|
23
|
+
* gen chei chou tsuen
|
|
24
|
+
* ```
|
|
25
|
+
* ````
|
|
26
|
+
*
|
|
27
|
+
* - **The body is raw.** It is a code fence, so Markdown never reformats it.
|
|
28
|
+
* - **A paragraph is a run of lines**, joined and reflowed as one text; a
|
|
29
|
+
* blank line starts the next paragraph, flush at the top of a fresh column.
|
|
30
|
+
* - **`rows=`** is the characters in a column and **`cols=`** the columns in a
|
|
31
|
+
* panel, each a whole number above zero. Either may be left out.
|
|
32
|
+
* - **`lang=`, `font=` and `map=`** are read by
|
|
33
|
+
* {@link module:engine/content-scripts.scanRuns}, which maps the text and
|
|
34
|
+
* refuses a fence whose script its note does not state is written in
|
|
35
|
+
* columns.
|
|
36
|
+
*
|
|
37
|
+
* {@link renderScroll} writes the HTML the site, the preview and Foundry show:
|
|
38
|
+
* a `.scroll` block whose stylesheet sets `writing-mode: vertical-rl`. The book
|
|
39
|
+
* has no vertical writing and draws the columns as panel images; see
|
|
40
|
+
* {@link scrollPanels} and {@link module:engine/font-outlines}.
|
|
41
|
+
*
|
|
42
|
+
* @module
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import MarkdownIt from "markdown-it";
|
|
46
|
+
|
|
47
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The keys a scroll fence's attribute block takes. `lang`, `font` and `map`
|
|
51
|
+
* are read by `scanRuns`; `data-pagefind-ignore` is written on the site by its
|
|
52
|
+
* rewrite of a fence set in a face, and reaches the element as written.
|
|
53
|
+
*/
|
|
54
|
+
export const SCROLL_KEYS = Object.freeze([
|
|
55
|
+
"rows",
|
|
56
|
+
"cols",
|
|
57
|
+
"lang",
|
|
58
|
+
"font",
|
|
59
|
+
"map",
|
|
60
|
+
"data-pagefind-ignore",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** The keys that size a scroll, each a whole number above zero. */
|
|
64
|
+
const SIZE_KEYS = Object.freeze({ rows: "characters", cols: "columns" });
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The distance from one column to the next, in ems: the character's one em
|
|
68
|
+
* and a gap of half an em.
|
|
69
|
+
*/
|
|
70
|
+
export const SCROLL_PITCH = 1.5;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The longest column the book sets when a fence states no `rows=`: each
|
|
74
|
+
* paragraph is one column as long as the longest, up to this many characters.
|
|
75
|
+
*/
|
|
76
|
+
export const BOOK_SCROLL_ROWS = 40;
|
|
77
|
+
|
|
78
|
+
/** The class every scroll carries. */
|
|
79
|
+
export const SCROLL_CLASS = "scroll";
|
|
80
|
+
|
|
81
|
+
const structureParser = new MarkdownIt({ html: true });
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One `scroll` fence.
|
|
85
|
+
*
|
|
86
|
+
* @typedef {object} Scroll
|
|
87
|
+
* @property {number} start - The 0-based line of its opening.
|
|
88
|
+
* @property {number} end - The 0-based line of its closing.
|
|
89
|
+
* @property {string} fence - Its fence marker.
|
|
90
|
+
* @property {string} indent - The opening line's indentation.
|
|
91
|
+
* @property {Record<string, string>} attributes - Its `key=value` attributes.
|
|
92
|
+
* @property {string[]} classes - Its classes.
|
|
93
|
+
* @property {number|undefined} rows - Its `rows=`, where it is valid.
|
|
94
|
+
* @property {number|undefined} cols - Its `cols=`, where it is valid.
|
|
95
|
+
* @property {boolean} invalid - Whether its opening has a finding.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The 1-based columns of `key=` and of its value in an opening line.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} line - The opening line.
|
|
102
|
+
* @param {string} key - The key.
|
|
103
|
+
* @returns {{key: number, value: number}} Its columns, or the brace's when it
|
|
104
|
+
* is not found.
|
|
105
|
+
*/
|
|
106
|
+
function keyColumns(line, key) {
|
|
107
|
+
const from = Math.max(0, line.indexOf("{"));
|
|
108
|
+
const match = new RegExp(`[\\s{]${key.replace(/[-]/g, "\\$&")}=`).exec(line.slice(from));
|
|
109
|
+
if (!match) return { key: from + 1, value: from + 1 };
|
|
110
|
+
const at = from + match.index + 1;
|
|
111
|
+
const value = at + key.length + 1;
|
|
112
|
+
return { key: at + 1, value: value + (line[value] === '"' ? 1 : 0) + 1 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read every `scroll` fence in a source, and every fault in its opening.
|
|
117
|
+
*
|
|
118
|
+
* A fence inside a block quote or an HTML block is not read, and neither is a
|
|
119
|
+
* fence quoted inside another fence.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} source - Markdown.
|
|
122
|
+
* @returns {{blocks: Scroll[], errors: Array<{line: number, column: number, message: string}>}}
|
|
123
|
+
* The fences, and each finding at its 1-based position.
|
|
124
|
+
*/
|
|
125
|
+
export function scanScrolls(source) {
|
|
126
|
+
const text = String(source ?? "");
|
|
127
|
+
const blocks = [];
|
|
128
|
+
const errors = [];
|
|
129
|
+
if (!/^ *(?:`{3,}|~{3,}) *scroll(?:\s|$)/m.test(text)) return { blocks, errors };
|
|
130
|
+
const lines = text.split("\n");
|
|
131
|
+
const literalLines = new Set();
|
|
132
|
+
for (const token of structureParser.parse(text, {})) {
|
|
133
|
+
if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
|
|
134
|
+
for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
|
|
135
|
+
}
|
|
136
|
+
let opening = null;
|
|
137
|
+
for (let i = 0; i < lines.length; i++) {
|
|
138
|
+
if (literalLines.has(i)) continue;
|
|
139
|
+
const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
|
|
140
|
+
if (opening) {
|
|
141
|
+
if (
|
|
142
|
+
match &&
|
|
143
|
+
match[1][0] === opening.fence[0] &&
|
|
144
|
+
match[1].length >= opening.fence.length &&
|
|
145
|
+
!match[2].trim()
|
|
146
|
+
) {
|
|
147
|
+
if (opening.scroll) {
|
|
148
|
+
const block = { ...opening.scroll, end: i };
|
|
149
|
+
if (!lines.slice(block.start + 1, i).some((line) => line.trim()))
|
|
150
|
+
errors.push({
|
|
151
|
+
line: block.start + 1,
|
|
152
|
+
column: block.indent.length + 1,
|
|
153
|
+
message: "scroll fence is empty",
|
|
154
|
+
});
|
|
155
|
+
blocks.push(block);
|
|
156
|
+
}
|
|
157
|
+
opening = null;
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!match) continue;
|
|
162
|
+
const info = match[2].trim();
|
|
163
|
+
opening = { fence: match[1], scroll: undefined };
|
|
164
|
+
if (info.split(/\s/)[0] !== "scroll") continue;
|
|
165
|
+
const indent = /^ */.exec(lines[i])[0];
|
|
166
|
+
const scroll = {
|
|
167
|
+
start: i,
|
|
168
|
+
end: i,
|
|
169
|
+
fence: match[1],
|
|
170
|
+
indent,
|
|
171
|
+
attributes: {},
|
|
172
|
+
classes: [],
|
|
173
|
+
rows: undefined,
|
|
174
|
+
cols: undefined,
|
|
175
|
+
invalid: false,
|
|
176
|
+
};
|
|
177
|
+
opening.scroll = scroll;
|
|
178
|
+
const raw = info.slice("scroll".length).trim();
|
|
179
|
+
const at = { line: i + 1, column: indent.length + 1 };
|
|
180
|
+
if (raw && !/^\{[^}\n]*\}$/.test(raw)) {
|
|
181
|
+
errors.push({ ...at, message: "scroll attributes need {…} braces" });
|
|
182
|
+
scroll.invalid = true;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const parsed = parseExtensionAttributes(raw ? raw.slice(1, -1) : "");
|
|
186
|
+
scroll.attributes = parsed.values;
|
|
187
|
+
scroll.classes = parsed.classes;
|
|
188
|
+
const problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
|
|
189
|
+
if (parsed.id) problems.push("a scroll fence takes no `#id`");
|
|
190
|
+
for (const message of problems) errors.push({ ...at, message });
|
|
191
|
+
if (problems.length) scroll.invalid = true;
|
|
192
|
+
for (const [key, value] of Object.entries(parsed.values)) {
|
|
193
|
+
const columns = keyColumns(lines[i], key);
|
|
194
|
+
if (!SCROLL_KEYS.includes(key)) {
|
|
195
|
+
errors.push({
|
|
196
|
+
line: i + 1,
|
|
197
|
+
column: columns.key,
|
|
198
|
+
message: `\`${key}=\` is not a scroll attribute; a scroll takes \`rows=\`, \`cols=\`, \`lang=\`, \`font=\` and \`map=\``,
|
|
199
|
+
});
|
|
200
|
+
scroll.invalid = true;
|
|
201
|
+
} else if (Object.hasOwn(SIZE_KEYS, key)) {
|
|
202
|
+
if (/^[1-9]\d*$/.test(value)) scroll[key] = Number(value);
|
|
203
|
+
else {
|
|
204
|
+
errors.push({
|
|
205
|
+
line: i + 1,
|
|
206
|
+
column: columns.value,
|
|
207
|
+
message: `\`${key}=${value}\` is not a number of ${SIZE_KEYS[key]}: \`${key}=\` is a whole number above zero`,
|
|
208
|
+
});
|
|
209
|
+
scroll.invalid = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (opening?.scroll)
|
|
215
|
+
errors.push({
|
|
216
|
+
line: opening.scroll.start + 1,
|
|
217
|
+
column: opening.scroll.indent.length + 1,
|
|
218
|
+
message: "scroll fence needs a closing code fence",
|
|
219
|
+
});
|
|
220
|
+
errors.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
221
|
+
return { blocks, errors };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A scroll's paragraphs: each run of nonblank lines, its lines trimmed and
|
|
226
|
+
* joined by a space.
|
|
227
|
+
*
|
|
228
|
+
* @param {string[]} lines - The body's lines.
|
|
229
|
+
* @returns {string[]} The paragraphs, in order.
|
|
230
|
+
*/
|
|
231
|
+
export function scrollParagraphs(lines) {
|
|
232
|
+
const paragraphs = [];
|
|
233
|
+
let current = [];
|
|
234
|
+
for (const line of [...lines, ""]) {
|
|
235
|
+
if (line.trim()) {
|
|
236
|
+
current.push(line.trim());
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (current.length) paragraphs.push(current.join(" "));
|
|
240
|
+
current = [];
|
|
241
|
+
}
|
|
242
|
+
return paragraphs;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The emphasis tags the rewrite of marked text writes, which a scroll keeps. */
|
|
246
|
+
const EMPHASIS_TAG = /(<(?:em|strong)(?: class="emph-(?:rubric|overline)")?>|<\/(?:em|strong)>)/;
|
|
247
|
+
|
|
248
|
+
/** HTML-escape text. */
|
|
249
|
+
const escapeHtml = (value) =>
|
|
250
|
+
String(value)
|
|
251
|
+
.replace(/&/g, "&")
|
|
252
|
+
.replace(/"/g, """)
|
|
253
|
+
.replace(/</g, "<")
|
|
254
|
+
.replace(/>/g, ">");
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A paragraph's text as HTML: escaped, with the emphasis tags marked text is
|
|
258
|
+
* rewritten with kept as tags.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} text - The paragraph.
|
|
261
|
+
* @returns {string} Its HTML.
|
|
262
|
+
*/
|
|
263
|
+
function paragraphHtml(text) {
|
|
264
|
+
return text
|
|
265
|
+
.split(EMPHASIS_TAG)
|
|
266
|
+
.map((part, at) => (at % 2 ? part : escapeHtml(part)))
|
|
267
|
+
.join("");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Replace each well-formed `scroll` fence with the HTML block the site, the
|
|
272
|
+
* preview and Foundry show: a `<div class="scroll">` holding a `<p>` for each
|
|
273
|
+
* paragraph, its size carried as `--scroll-rows` and `--scroll-cols`. A fence
|
|
274
|
+
* with a finding in its opening is left as a code fence.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} source - Markdown, its marked text already rewritten.
|
|
277
|
+
* @returns {{markdown: string}} The Markdown.
|
|
278
|
+
*/
|
|
279
|
+
export function renderScroll(source) {
|
|
280
|
+
const text = String(source ?? "");
|
|
281
|
+
const { blocks } = scanScrolls(text);
|
|
282
|
+
if (!blocks.length) return { markdown: text };
|
|
283
|
+
const lines = text.split("\n");
|
|
284
|
+
const output = [];
|
|
285
|
+
let cursor = 0;
|
|
286
|
+
for (const block of blocks) {
|
|
287
|
+
if (block.invalid) continue;
|
|
288
|
+
output.push(...lines.slice(cursor, block.start));
|
|
289
|
+
const attrs = [`class="${escapeHtml([SCROLL_CLASS, ...block.classes].join(" "))}"`];
|
|
290
|
+
const entries = Object.entries(block.attributes);
|
|
291
|
+
const ordered = [
|
|
292
|
+
...entries.filter(([key]) => key === "lang"),
|
|
293
|
+
...entries.filter(([key]) => key !== "lang"),
|
|
294
|
+
];
|
|
295
|
+
for (const [key, value] of ordered)
|
|
296
|
+
attrs.push(
|
|
297
|
+
`${["lang", "data-pagefind-ignore"].includes(key) ? key : `data-${key}`}="${escapeHtml(value)}"`,
|
|
298
|
+
);
|
|
299
|
+
const style = [
|
|
300
|
+
...(block.rows === undefined ? [] : [`--scroll-rows: ${block.rows}`]),
|
|
301
|
+
...(block.cols === undefined ? [] : [`--scroll-cols: ${block.cols}`]),
|
|
302
|
+
];
|
|
303
|
+
if (style.length) attrs.push(`style="${style.join("; ")}"`);
|
|
304
|
+
let indent = block.indent;
|
|
305
|
+
if (
|
|
306
|
+
indent.length >= 4 &&
|
|
307
|
+
!lines.slice(0, block.start).some((line) => /^ *(?:[-+*]|\d+[.)]) +/.test(line))
|
|
308
|
+
)
|
|
309
|
+
indent = "";
|
|
310
|
+
output.push(
|
|
311
|
+
"",
|
|
312
|
+
`${indent}<div ${attrs.join(" ")}>`,
|
|
313
|
+
...scrollParagraphs(lines.slice(block.start + 1, block.end)).map(
|
|
314
|
+
(paragraph) => `${indent}<p>${paragraphHtml(paragraph)}</p>`,
|
|
315
|
+
),
|
|
316
|
+
`${indent}</div>`,
|
|
317
|
+
"",
|
|
318
|
+
);
|
|
319
|
+
cursor = block.end + 1;
|
|
320
|
+
}
|
|
321
|
+
output.push(...lines.slice(cursor));
|
|
322
|
+
return { markdown: output.join("\n") };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* One character of a scroll as the book draws it.
|
|
327
|
+
*
|
|
328
|
+
* @typedef {object} ScrollCell
|
|
329
|
+
* @property {string} text - The character, one code point.
|
|
330
|
+
* @property {string} roman - The source text it was written from: the text of
|
|
331
|
+
* the piece of the map it begins, with the text of any piece before it that
|
|
332
|
+
* wrote nothing; `""` for a later character of the same piece.
|
|
333
|
+
* @property {"rubric"|"overline"} [emphasis] - The convention it is set by.
|
|
334
|
+
*/
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A scroll's text, as the book lays it out.
|
|
338
|
+
*
|
|
339
|
+
* @typedef {object} ScrollLayout
|
|
340
|
+
* @property {ScrollCell[][]} paragraphs - Each paragraph's characters.
|
|
341
|
+
* @property {import("./script-fonts.mjs").Face|undefined} face - The face it
|
|
342
|
+
* is drawn in.
|
|
343
|
+
*/
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Lay a scroll's paragraphs into columns and the columns into panels.
|
|
347
|
+
*
|
|
348
|
+
* Each paragraph starts a fresh column, filled from the top, `rows` characters
|
|
349
|
+
* to a column; the columns are read from the right and grouped `cols` to a
|
|
350
|
+
* panel. Without `rows`, a column is as long as the longest paragraph, up to
|
|
351
|
+
* {@link BOOK_SCROLL_ROWS}; without `cols`, each column is a panel of its own,
|
|
352
|
+
* so the book sets as many beside one another as fit its measure.
|
|
353
|
+
*
|
|
354
|
+
* @param {ScrollCell[][]} paragraphs - The characters of each paragraph.
|
|
355
|
+
* @param {{rows?: number, cols?: number}} size - The fence's size.
|
|
356
|
+
* @returns {{rows: number, panels: ScrollCell[][][]}} The column length, and
|
|
357
|
+
* each panel's columns, the first column the rightmost.
|
|
358
|
+
*/
|
|
359
|
+
export function scrollPanels(paragraphs, { rows, cols } = {}) {
|
|
360
|
+
const longest = Math.max(1, ...paragraphs.map((cells) => cells.length));
|
|
361
|
+
const length = rows ?? Math.min(longest, BOOK_SCROLL_ROWS);
|
|
362
|
+
const columns = [];
|
|
363
|
+
for (const cells of paragraphs)
|
|
364
|
+
for (let at = 0; at < cells.length; at += length)
|
|
365
|
+
columns.push(cells.slice(at, at + length));
|
|
366
|
+
const width = cols ?? 1;
|
|
367
|
+
const panels = [];
|
|
368
|
+
for (let at = 0; at < columns.length; at += width) panels.push(columns.slice(at, at + width));
|
|
369
|
+
return { rows: length, panels };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The roman text a panel was written from, as its alt text: each paragraph's
|
|
374
|
+
* source text, a space between paragraphs, its spacing collapsed.
|
|
375
|
+
*
|
|
376
|
+
* @param {ScrollCell[][]} panel - The panel's columns.
|
|
377
|
+
* @param {ScrollCell[][]} paragraphs - The scroll's paragraphs, so a column
|
|
378
|
+
* that begins a paragraph is told from one that continues it.
|
|
379
|
+
* @returns {string} The text.
|
|
380
|
+
*/
|
|
381
|
+
export function panelRoman(panel, paragraphs) {
|
|
382
|
+
const firsts = new Set(paragraphs.map((cells) => cells[0]));
|
|
383
|
+
let out = "";
|
|
384
|
+
for (const column of panel) {
|
|
385
|
+
if (out && firsts.has(column[0])) out += " ";
|
|
386
|
+
for (const cell of column) out += cell.roman;
|
|
387
|
+
}
|
|
388
|
+
return out.replace(/\s+/g, " ").trim();
|
|
389
|
+
}
|