@gondola-lyrics/kit 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1748 @@
1
+ import { AgentType, CreditRole, LineType, LyricStatus, Timing, WordType, canonicalizeLyric, deriveLanguageUsages, getWordsText, makeAgent, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineBackground, makeLineContentText, makeLineContentWords, makeLineNormal, makeLyric, makeMeta, makeMetaCredit, makeText, makeTimeRange, makeUnknownEntry, makeWordAnnotation, makeWordAnnotationRuby, makeWordAnnotationToken, makeWordNormal, makeWordSpace, validateLyric } from "@gondola-lyrics/model/runtime";
2
+ //#region src/utils/annotation.ts
3
+ /**
4
+ * An annotation line carries its text in either field, depending on whether the track used word tags.
5
+ */
6
+ var readText = (line) => {
7
+ return (line.content?.text ?? getWordsText(line.content?.words ?? [])).trim();
8
+ };
9
+ var isReadable = (line) => {
10
+ return line.time?.start !== void 0 && readText(line) !== "";
11
+ };
12
+ /**
13
+ * Assigns each annotation line to a lyric line index.
14
+ *
15
+ * Exact matches claim their lines first, so a fuzzy match never takes a line an exact one needed.
16
+ * The remaining annotations take the nearest unclaimed line within the tolerance, and ties keep the earlier line.
17
+ */
18
+ var matchEntries = (lines, annotations, tolerance) => {
19
+ const matched = /* @__PURE__ */ new Map();
20
+ if (annotations.length === 0) return matched;
21
+ const starts = lines.map((line) => line.time?.start);
22
+ const queues = /* @__PURE__ */ new Map();
23
+ for (const [index, start] of starts.entries()) {
24
+ if (start === void 0) continue;
25
+ const queue = queues.get(start);
26
+ if (queue) queue.push(index);
27
+ else queues.set(start, [index]);
28
+ }
29
+ const used = /* @__PURE__ */ new Set();
30
+ const pending = [];
31
+ for (const annotation of annotations) {
32
+ const start = annotation.time?.start;
33
+ if (start === void 0) continue;
34
+ const index = queues.get(start)?.shift();
35
+ if (index === void 0) {
36
+ pending.push(annotation);
37
+ continue;
38
+ }
39
+ matched.set(index, annotation);
40
+ used.add(index);
41
+ }
42
+ if (tolerance <= 0) return matched;
43
+ for (const annotation of pending) {
44
+ const start = annotation.time?.start;
45
+ if (start === void 0) continue;
46
+ let target = -1;
47
+ let distance = Number.POSITIVE_INFINITY;
48
+ for (const [index, lineStart] of starts.entries()) {
49
+ if (lineStart === void 0 || used.has(index)) continue;
50
+ const current = Math.abs(lineStart - start);
51
+ if (current <= tolerance && current < distance) {
52
+ target = index;
53
+ distance = current;
54
+ }
55
+ }
56
+ if (target !== -1) {
57
+ matched.set(target, annotation);
58
+ used.add(target);
59
+ }
60
+ }
61
+ return matched;
62
+ };
63
+ /**
64
+ * Attaches annotation tracks to parsed lines by start time.
65
+ *
66
+ * Annotations that match nothing are dropped, and an instrumental line consumes its annotation without carrying one.
67
+ * Any annotation already on a line is kept, with the matched track appended to its layer.
68
+ */
69
+ var attachLineAnnotations = (lines, sources, options = {}) => {
70
+ const tolerance = Number.isFinite(options.tolerance) ? Math.max(0, options.tolerance ?? 0) : 0;
71
+ const translations = matchEntries(lines, (sources.translations ?? []).filter(isReadable), tolerance);
72
+ const romans = matchEntries(lines, (sources.romans ?? []).filter(isReadable), tolerance);
73
+ if (translations.size === 0 && romans.size === 0) return lines;
74
+ return lines.map((line, index) => {
75
+ const translation = translations.get(index);
76
+ const roman = romans.get(index);
77
+ if (line.type === LineType.INSTRUMENTAL || translation === void 0 && roman === void 0) return line;
78
+ return {
79
+ ...line,
80
+ annotation: makeLineAnnotation({
81
+ romans: [...line.annotation?.romans ?? [], ...roman === void 0 ? [] : [makeLineAnnotationRoman({
82
+ text: readText(roman),
83
+ language: roman.languages[0]
84
+ })]],
85
+ translations: [...line.annotation?.translations ?? [], ...translation === void 0 ? [] : [makeLineAnnotationTranslation({
86
+ text: readText(translation),
87
+ language: translation.languages[0]
88
+ })]]
89
+ })
90
+ };
91
+ });
92
+ };
93
+ //#endregion
94
+ //#region src/utils/language.ts
95
+ /**
96
+ * Returns a language tag in the canonical form the model stores, which is lower case.
97
+ *
98
+ * This mirrors the model's own helper, which it does not export.
99
+ * Comparing tags stays with the consumer, since BCP 47 matches by subtag prefix rather than by string equality.
100
+ */
101
+ var lowerTag = (tag) => {
102
+ return tag ? tag.toLowerCase() : void 0;
103
+ };
104
+ //#endregion
105
+ //#region src/utils/time.ts
106
+ var CLOCK_PATTERN = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
107
+ var OFFSET_PATTERN = /^(\d+(?:\.\d+)?)(ms|h|m|s)$/iu;
108
+ /**
109
+ * Reads a clock value, where the fraction is padded or truncated to millisecond precision.
110
+ *
111
+ * `14.514` is fourteen seconds, not fourteen minutes: a colon, not a dot, introduces the larger units.
112
+ */
113
+ var parseClock = (value) => {
114
+ if (!value.includes(":") && !value.includes(".")) return;
115
+ const match = CLOCK_PATTERN.exec(value);
116
+ if (!match) return;
117
+ const hour = Number(match[1] ?? 0);
118
+ const minute = Number(match[2] ?? 0);
119
+ const second = Number(match[3] ?? 0);
120
+ const millisecond = Number((match[4] ?? "").padEnd(3, "0").slice(0, 3));
121
+ return ((hour * 60 + minute) * 60 + second) * 1e3 + millisecond;
122
+ };
123
+ /**
124
+ * Reads an offset such as `1.5s`, `2m`, `500ms`, or `1h`.
125
+ */
126
+ var parseOffset = (value) => {
127
+ const match = OFFSET_PATTERN.exec(value);
128
+ if (!match) return;
129
+ const amount = Number(match[1]);
130
+ if (!Number.isFinite(amount)) return;
131
+ switch ((match[2] ?? "").toLowerCase()) {
132
+ case "h": return Math.round(amount * 36e5);
133
+ case "m": return Math.round(amount * 6e4);
134
+ case "ms": return Math.round(amount);
135
+ default: return Math.round(amount * 1e3);
136
+ }
137
+ };
138
+ /**
139
+ * Reports whether a value reads as a time.
140
+ */
141
+ var isTime = (body) => {
142
+ return parseTime(body) !== void 0;
143
+ };
144
+ /**
145
+ * Reads a time value into milliseconds.
146
+ *
147
+ * A clock reading accepts `ss.fraction`, `mm:ss.fraction`, and `hh:mm:ss.fraction`.
148
+ * An offset accepts a number with an `h`, `m`, `s`, or `ms` suffix.
149
+ * A bare number returns `undefined`, and so does anything else.
150
+ */
151
+ var parseTime = (body) => {
152
+ const trimmed = body.trim();
153
+ if (trimmed === "") return;
154
+ return parseOffset(trimmed) ?? parseClock(trimmed);
155
+ };
156
+ /**
157
+ * Tokens `h`, `m`, `s`, and `S` select hours, minutes, seconds, and fractional seconds.
158
+ * Repeated unit tokens set minimum width; `S` tokens select up to three fractional digits.
159
+ *
160
+ * Omitting hours carries them into minutes; negative or non-finite input writes zero fields.
161
+ */
162
+ var formatTime = (time, format = "mm:ss.SSS") => {
163
+ if (!Number.isFinite(time) || time < 0) return format.replace(/h+|m+|s+|S+/g, (run) => "0".repeat(run.length));
164
+ const total = Math.floor(time);
165
+ const totalSeconds = Math.floor(total / 1e3);
166
+ const totalMinutes = Math.floor(totalSeconds / 60);
167
+ const millisecond = String(total % 1e3).padStart(3, "0");
168
+ const fields = {
169
+ h: String(Math.floor(totalMinutes / 60)),
170
+ m: String(format.includes("h") ? totalMinutes % 60 : totalMinutes),
171
+ s: String(totalSeconds % 60),
172
+ S: millisecond
173
+ };
174
+ return format.replace(/h+|m+|s+|S+/g, (run) => {
175
+ const field = fields[run[0]];
176
+ if (run[0] === "S") return field.slice(0, run.length);
177
+ return field.padStart(run.length, "0");
178
+ });
179
+ };
180
+ //#endregion
181
+ //#region src/utils/xml/utils.ts
182
+ var XmlNodeType = {
183
+ element: 1,
184
+ text: 2,
185
+ cdata: 3
186
+ };
187
+ /**
188
+ * Only prefixed names need their split cached, since an unprefixed one keeps its name as the local part.
189
+ * The cache is bounded, which keeps an adversarial document from growing it without limit.
190
+ */
191
+ var prefixedNames = /* @__PURE__ */ new Map();
192
+ /**
193
+ * Splits a qualified name into its namespace prefix and local name.
194
+ */
195
+ var splitXmlName = (name) => {
196
+ const cached = prefixedNames.get(name);
197
+ if (cached !== void 0) return cached;
198
+ const colon = name.indexOf(":");
199
+ const split = colon === -1 ? {
200
+ namespacePrefix: "",
201
+ localName: name
202
+ } : {
203
+ namespacePrefix: name.slice(0, colon),
204
+ localName: name.slice(colon + 1)
205
+ };
206
+ if (split.namespacePrefix !== "" && prefixedNames.size < 1024) prefixedNames.set(name, split);
207
+ return split;
208
+ };
209
+ /**
210
+ * Writes the prefix and local part onto a node that already carries the full name.
211
+ */
212
+ var applyXmlNameParts = (name, target) => {
213
+ if (name.indexOf(":") === -1) {
214
+ target.namespacePrefix = "";
215
+ target.localName = name;
216
+ return;
217
+ }
218
+ const split = splitXmlName(name);
219
+ target.namespacePrefix = split.namespacePrefix;
220
+ target.localName = split.localName;
221
+ };
222
+ /**
223
+ * Creates an attribute from its full name and value.
224
+ */
225
+ var createXmlAttribute = (qualifiedName, value) => {
226
+ const attribute = {
227
+ qualifiedName,
228
+ value,
229
+ namespacePrefix: "",
230
+ localName: qualifiedName
231
+ };
232
+ applyXmlNameParts(qualifiedName, attribute);
233
+ return attribute;
234
+ };
235
+ /**
236
+ * Returns the value of one attribute, matched by its local name so the prefix never has to be known.
237
+ */
238
+ var getXmlAttribute = (element, localName) => {
239
+ const attributes = element.attributes;
240
+ for (let index = 0; index < attributes.length; index += 1) {
241
+ const attribute = attributes[index];
242
+ if (attribute !== void 0 && attribute.localName === localName) return attribute.value;
243
+ }
244
+ };
245
+ /**
246
+ * Returns the value of one attribute, matched by its full name including the prefix.
247
+ */
248
+ var getXmlQualifiedAttribute = (element, qualifiedName) => {
249
+ const attributes = element.attributes;
250
+ for (let index = 0; index < attributes.length; index += 1) {
251
+ const attribute = attributes[index];
252
+ if (attribute !== void 0 && attribute.qualifiedName === qualifiedName) return attribute.value;
253
+ }
254
+ };
255
+ /**
256
+ * Returns the direct element children carrying one local name.
257
+ */
258
+ var getXmlChildElements = (element, localName) => {
259
+ const result = [];
260
+ const children = element.children;
261
+ for (let index = 0; index < children.length; index += 1) {
262
+ const child = children[index];
263
+ if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) result.push(child);
264
+ }
265
+ return result;
266
+ };
267
+ /**
268
+ * Reports whether the element has a direct child with one local name.
269
+ */
270
+ var hasXmlChildElement = (element, localName) => {
271
+ const children = element.children;
272
+ for (let index = 0; index < children.length; index += 1) {
273
+ const child = children[index];
274
+ if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) return true;
275
+ }
276
+ return false;
277
+ };
278
+ /**
279
+ * Returns the first element carrying one local name, the element itself included, in document order.
280
+ */
281
+ var findXmlElement = (element, localName) => {
282
+ const stack = [element];
283
+ while (stack.length > 0) {
284
+ const node = stack.pop();
285
+ if (node === void 0 || node.nodeType !== XmlNodeType.element) continue;
286
+ if (node.localName === localName) return node;
287
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
288
+ const child = node.children[index];
289
+ if (child !== void 0) stack.push(child);
290
+ }
291
+ }
292
+ };
293
+ /**
294
+ * Returns every element carrying one local name, the element itself included, in document order.
295
+ */
296
+ var findXmlElements = (element, localName) => {
297
+ const result = [];
298
+ const stack = [element];
299
+ while (stack.length > 0) {
300
+ const node = stack.pop();
301
+ if (node === void 0 || node.nodeType !== XmlNodeType.element) continue;
302
+ if (node.localName === localName) result.push(node);
303
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
304
+ const child = node.children[index];
305
+ if (child !== void 0) stack.push(child);
306
+ }
307
+ }
308
+ return result;
309
+ };
310
+ /**
311
+ * Joins the text and CDATA content of a node and its descendants, in document order.
312
+ */
313
+ var getXmlTextContent = (node) => {
314
+ if (node.nodeType !== XmlNodeType.element) return node.text;
315
+ const parts = [];
316
+ const stack = [node];
317
+ while (stack.length > 0) {
318
+ const current = stack.pop();
319
+ if (current === void 0) continue;
320
+ if (current.nodeType === XmlNodeType.element) {
321
+ for (let index = current.children.length - 1; index >= 0; index -= 1) {
322
+ const child = current.children[index];
323
+ if (child !== void 0) stack.push(child);
324
+ }
325
+ continue;
326
+ }
327
+ parts.push(current.text);
328
+ }
329
+ return parts.join("");
330
+ };
331
+ //#endregion
332
+ //#region src/utils/xml/reader.ts
333
+ var LESS_THAN = 60;
334
+ var GREATER_THAN = 62;
335
+ var SLASH = 47;
336
+ var EQUALS = 61;
337
+ var DOUBLE_QUOTE = 34;
338
+ var SINGLE_QUOTE = 39;
339
+ var EXCLAMATION = 33;
340
+ var QUESTION = 63;
341
+ var AMPERSAND = 38;
342
+ var HASH = 35;
343
+ var HYPHEN = 45;
344
+ var LOWER_X = 120;
345
+ var UPPER_X = 88;
346
+ var BYTE_ORDER_MARK = 65279;
347
+ var ROOT_NAME = "#document";
348
+ var DECIMAL_DIGITS = /^\d+$/;
349
+ var HEX_DIGITS = /^[0-9a-fA-F]+$/;
350
+ /**
351
+ * Resolves one entity reference body, leaving anything unrecognized as written.
352
+ */
353
+ var decodeReference = (body) => {
354
+ switch (body) {
355
+ case "lt": return "<";
356
+ case "gt": return ">";
357
+ case "amp": return "&";
358
+ case "quot": return "\"";
359
+ case "apos": return "'";
360
+ }
361
+ if (body.charCodeAt(0) !== HASH) return `&${body};`;
362
+ const hexadecimal = body.charCodeAt(1) === LOWER_X || body.charCodeAt(1) === UPPER_X;
363
+ const digits = hexadecimal ? body.slice(2) : body.slice(1);
364
+ if (!(hexadecimal ? HEX_DIGITS : DECIMAL_DIGITS).test(digits)) return `&${body};`;
365
+ const code = Number.parseInt(digits, hexadecimal ? 16 : 10);
366
+ if (!Number.isFinite(code) || code > 1114111) return `&${body};`;
367
+ return String.fromCodePoint(code);
368
+ };
369
+ /**
370
+ * Replaces entity references in one run of text, leaving bare ampersands alone.
371
+ */
372
+ var decodeText = (text) => {
373
+ const parts = [];
374
+ const length = text.length;
375
+ let start = 0;
376
+ let cursor = 0;
377
+ while (cursor < length) {
378
+ if (text.charCodeAt(cursor) !== AMPERSAND) {
379
+ cursor += 1;
380
+ continue;
381
+ }
382
+ const end = text.indexOf(";", cursor + 1);
383
+ if (end === -1) {
384
+ cursor += 1;
385
+ continue;
386
+ }
387
+ parts.push(text.slice(start, cursor), decodeReference(text.slice(cursor + 1, end)));
388
+ cursor = end + 1;
389
+ start = cursor;
390
+ }
391
+ if (parts.length === 0) return text;
392
+ parts.push(text.slice(start));
393
+ return parts.join("");
394
+ };
395
+ /**
396
+ * Reads a document into a tree, or returns `null` when it is not well formed.
397
+ */
398
+ var parseXml = (source) => {
399
+ const length = source.length;
400
+ let position = length > 0 && source.charCodeAt(0) === BYTE_ORDER_MARK ? 1 : 0;
401
+ let valid = true;
402
+ const stack = [];
403
+ const root = {
404
+ nodeType: XmlNodeType.element,
405
+ qualifiedName: ROOT_NAME,
406
+ namespacePrefix: "",
407
+ localName: ROOT_NAME,
408
+ attributes: [],
409
+ children: []
410
+ };
411
+ stack.push(root);
412
+ /**
413
+ * Appends a text node to the element being read.
414
+ */
415
+ const readTextNode = (end) => {
416
+ const start = position;
417
+ position = end;
418
+ if (start === end) return;
419
+ const parent = stack[stack.length - 1];
420
+ if (parent === void 0) {
421
+ valid = false;
422
+ return;
423
+ }
424
+ const raw = source.slice(start, end);
425
+ parent.children.push({
426
+ nodeType: XmlNodeType.text,
427
+ text: raw.indexOf("&") === -1 ? raw : decodeText(raw)
428
+ });
429
+ };
430
+ /**
431
+ * Reads one quoted value, starting at `position`, and leaves `position` after the closing quote.
432
+ */
433
+ const readQuotedValue = () => {
434
+ const quote = source.charCodeAt(position);
435
+ if (quote !== DOUBLE_QUOTE && quote !== SINGLE_QUOTE) {
436
+ valid = false;
437
+ return "";
438
+ }
439
+ position += 1;
440
+ const end = source.indexOf(quote === DOUBLE_QUOTE ? "\"" : "'", position);
441
+ if (end === -1) {
442
+ valid = false;
443
+ return "";
444
+ }
445
+ const raw = source.slice(position, end);
446
+ position = end + 1;
447
+ return raw.indexOf("&") === -1 ? raw : decodeText(raw);
448
+ };
449
+ /**
450
+ * Reads a start tag with its attributes, and opens the element unless it closes itself.
451
+ */
452
+ const readStartTag = () => {
453
+ let cursor = position + 1;
454
+ const nameStart = cursor;
455
+ while (cursor < length) {
456
+ const code = source.charCodeAt(cursor);
457
+ if (code <= 32 || code === GREATER_THAN || code === SLASH || code === EQUALS) break;
458
+ cursor += 1;
459
+ }
460
+ if (cursor === nameStart) {
461
+ valid = false;
462
+ return;
463
+ }
464
+ const qualifiedName = source.slice(nameStart, cursor);
465
+ const attributes = [];
466
+ const element = {
467
+ nodeType: XmlNodeType.element,
468
+ qualifiedName,
469
+ namespacePrefix: "",
470
+ localName: qualifiedName,
471
+ attributes,
472
+ children: []
473
+ };
474
+ applyXmlNameParts(qualifiedName, element);
475
+ let closed = false;
476
+ let selfClosing = false;
477
+ while (cursor < length) {
478
+ let code = source.charCodeAt(cursor);
479
+ while (code <= 32) {
480
+ cursor += 1;
481
+ if (cursor >= length) break;
482
+ code = source.charCodeAt(cursor);
483
+ }
484
+ if (cursor >= length) break;
485
+ if (code === GREATER_THAN) {
486
+ cursor += 1;
487
+ closed = true;
488
+ break;
489
+ }
490
+ if (code === SLASH) {
491
+ cursor += 1;
492
+ if (cursor < length && source.charCodeAt(cursor) === GREATER_THAN) {
493
+ cursor += 1;
494
+ closed = true;
495
+ selfClosing = true;
496
+ break;
497
+ }
498
+ valid = false;
499
+ return;
500
+ }
501
+ const attributeStart = cursor;
502
+ do {
503
+ cursor += 1;
504
+ if (cursor >= length) break;
505
+ code = source.charCodeAt(cursor);
506
+ } while (code > 32 && code !== GREATER_THAN && code !== SLASH && code !== EQUALS);
507
+ if (cursor === attributeStart) {
508
+ valid = false;
509
+ return;
510
+ }
511
+ const attributeName = source.slice(attributeStart, cursor);
512
+ while (cursor < length && source.charCodeAt(cursor) <= 32) cursor += 1;
513
+ if (cursor >= length || source.charCodeAt(cursor) !== EQUALS) {
514
+ valid = false;
515
+ return;
516
+ }
517
+ cursor += 1;
518
+ while (cursor < length && source.charCodeAt(cursor) <= 32) cursor += 1;
519
+ position = cursor;
520
+ const value = readQuotedValue();
521
+ if (!valid) return;
522
+ cursor = position;
523
+ for (let index = 0; index < attributes.length; index += 1) if (attributes[index]?.qualifiedName === attributeName) {
524
+ valid = false;
525
+ return;
526
+ }
527
+ attributes.push(createXmlAttribute(attributeName, value));
528
+ }
529
+ if (!closed) {
530
+ valid = false;
531
+ return;
532
+ }
533
+ position = cursor;
534
+ const parent = stack[stack.length - 1];
535
+ if (parent === void 0) {
536
+ valid = false;
537
+ return;
538
+ }
539
+ parent.children.push(element);
540
+ if (!selfClosing) stack.push(element);
541
+ };
542
+ /**
543
+ * Closes the innermost element, which has to be the one the tag names.
544
+ */
545
+ const readEndTag = () => {
546
+ let cursor = position + 2;
547
+ const nameStart = cursor;
548
+ while (cursor < length) {
549
+ const code = source.charCodeAt(cursor);
550
+ if (code <= 32 || code === GREATER_THAN) break;
551
+ cursor += 1;
552
+ }
553
+ const name = source.slice(nameStart, cursor);
554
+ const end = source.indexOf(">", cursor);
555
+ if (end === -1) {
556
+ valid = false;
557
+ return;
558
+ }
559
+ position = end + 1;
560
+ if (stack.length <= 1) {
561
+ valid = false;
562
+ return;
563
+ }
564
+ const top = stack[stack.length - 1];
565
+ if (top === void 0 || top.qualifiedName !== name) {
566
+ valid = false;
567
+ return;
568
+ }
569
+ stack.pop();
570
+ };
571
+ /**
572
+ * Skips a comment, CDATA section, or any other declaration such as a DOCTYPE.
573
+ */
574
+ const readMarkupDeclaration = () => {
575
+ if (source.charCodeAt(position + 2) === HYPHEN && source.charCodeAt(position + 3) === HYPHEN) {
576
+ const end = source.indexOf("-->", position + 4);
577
+ if (end === -1) {
578
+ valid = false;
579
+ return;
580
+ }
581
+ position = end + 3;
582
+ return;
583
+ }
584
+ if (source.startsWith("[CDATA[", position + 2)) {
585
+ const start = position + 9;
586
+ const end = source.indexOf("]]>", start);
587
+ if (end === -1) {
588
+ valid = false;
589
+ return;
590
+ }
591
+ const parent = stack[stack.length - 1];
592
+ if (parent === void 0) {
593
+ valid = false;
594
+ return;
595
+ }
596
+ parent.children.push({
597
+ nodeType: XmlNodeType.cdata,
598
+ text: source.slice(start, end)
599
+ });
600
+ position = end + 3;
601
+ return;
602
+ }
603
+ let cursor = position + 2;
604
+ let depth = 1;
605
+ while (cursor < length && depth > 0) {
606
+ const code = source.charCodeAt(cursor);
607
+ if (code === LESS_THAN) depth += 1;
608
+ else if (code === GREATER_THAN) depth -= 1;
609
+ cursor += 1;
610
+ }
611
+ if (depth > 0) {
612
+ valid = false;
613
+ return;
614
+ }
615
+ position = cursor;
616
+ };
617
+ /**
618
+ * Skips a processing instruction, which carries nothing the kit reads.
619
+ */
620
+ const readProcessingInstruction = () => {
621
+ const end = source.indexOf("?>", position + 2);
622
+ if (end === -1) {
623
+ valid = false;
624
+ return;
625
+ }
626
+ position = end + 2;
627
+ };
628
+ /**
629
+ * Reads content until the end of input or the end tag of the innermost element.
630
+ */
631
+ const readNodes = () => {
632
+ while (position < length) {
633
+ const open = source.indexOf("<", position);
634
+ if (open === -1) {
635
+ readTextNode(length);
636
+ return;
637
+ }
638
+ if (open > position) {
639
+ readTextNode(open);
640
+ if (!valid) return;
641
+ }
642
+ position = open;
643
+ const next = source.charCodeAt(open + 1);
644
+ if (next === SLASH) readEndTag();
645
+ else if (next === EXCLAMATION) readMarkupDeclaration();
646
+ else if (next === QUESTION) readProcessingInstruction();
647
+ else readStartTag();
648
+ if (!valid) return;
649
+ }
650
+ };
651
+ readNodes();
652
+ if (!valid || stack.length !== 1) return null;
653
+ for (let index = 0; index < root.children.length; index += 1) if (root.children[index]?.nodeType === XmlNodeType.element) return root;
654
+ return null;
655
+ };
656
+ //#endregion
657
+ //#region src/formats/lrc/common.ts
658
+ var readLrcContent = (input) => {
659
+ if (typeof input === "string") return { original: input };
660
+ if (typeof input !== "object" || input === null) return null;
661
+ const source = input;
662
+ if (typeof source.original !== "string") return null;
663
+ return {
664
+ original: source.original,
665
+ translations: typeof source.translations === "string" ? source.translations : void 0,
666
+ romans: typeof source.romans === "string" ? source.romans : void 0
667
+ };
668
+ };
669
+ //#endregion
670
+ //#region src/formats/lrc/utils.ts
671
+ var WORD_OFFSET_PATTERN = /^(\d+)(?:,(\d+))?$/;
672
+ var WORD_CLOCK_DURATION_PATTERN = /^([^,]+),(\d+)$/;
673
+ var WORD_TAG_PATTERN = /<([^>]*)>/g;
674
+ var TAG_PATTERN = /\[([^\]]*)\]/g;
675
+ var META_PATTERN = /^([A-Za-z0-9_-]+)\s*:\s*([\s\S]*)$/;
676
+ /**
677
+ * Integer tags use `offset[,duration]` in milliseconds relative to the line start.
678
+ * Clock tags are absolute and may append a duration in milliseconds, as in `<00:01.50,500>`.
679
+ * A missing or zero duration leaves the word end unspecified.
680
+ */
681
+ var parseWordTime = (body, lineStart) => {
682
+ if (isTime(body)) {
683
+ const start = parseTime(body);
684
+ return start === void 0 ? void 0 : {
685
+ start,
686
+ end: void 0
687
+ };
688
+ }
689
+ const match = WORD_OFFSET_PATTERN.exec(body.trim());
690
+ if (match) {
691
+ const start = lineStart + Number(match[1]);
692
+ const duration = Number(match[2] ?? 0);
693
+ return {
694
+ start,
695
+ end: duration > 0 ? start + duration : void 0
696
+ };
697
+ }
698
+ const clock = WORD_CLOCK_DURATION_PATTERN.exec(body.trim());
699
+ if (clock && isTime(clock[1] ?? "")) {
700
+ const start = parseTime(clock[1] ?? "");
701
+ const duration = Number(clock[2] ?? 0);
702
+ return start === void 0 ? void 0 : {
703
+ start,
704
+ end: duration > 0 ? start + duration : void 0
705
+ };
706
+ }
707
+ };
708
+ /**
709
+ * Untagged leading text is preserved; tags without following text produce no piece.
710
+ * A trailing tag still yields an empty piece so it can close the preceding word.
711
+ */
712
+ var splitWordTags = (content) => {
713
+ const pieces = [];
714
+ let tag;
715
+ let cursor = 0;
716
+ for (const match of content.matchAll(WORD_TAG_PATTERN)) {
717
+ const at = match.index;
718
+ const before = content.slice(cursor, at);
719
+ if (before) pieces.push({
720
+ text: before,
721
+ tag
722
+ });
723
+ tag = match[1] ?? "";
724
+ cursor = at + match[0].length;
725
+ }
726
+ const tail = content.slice(cursor);
727
+ if (tail) pieces.push({
728
+ text: tail,
729
+ tag
730
+ });
731
+ else if (tag !== void 0) pieces.push({
732
+ text: "",
733
+ tag
734
+ });
735
+ return pieces;
736
+ };
737
+ //#endregion
738
+ //#region src/formats/lrc/detect.ts
739
+ /**
740
+ * A single readable time tag is enough for recognition; metadata alone is not.
741
+ * This is a format probe, not a validity check, so an input of another shape is simply not LRC.
742
+ */
743
+ var detectLrc = (input) => {
744
+ const content = readLrcContent(input);
745
+ if (!content) return false;
746
+ return content.original.replace(/\r\n?/g, "\n").split("\n").some((line) => [...line.matchAll(TAG_PATTERN)].some((match) => isTime(match[1] ?? "")));
747
+ };
748
+ //#endregion
749
+ //#region src/formats/lrc/generator.ts
750
+ var resolveOptions = (options = {}) => {
751
+ return {
752
+ wordTiming: options.wordTiming ?? "angle",
753
+ timeFormat: options.timeFormat ?? "mm:ss.SSS",
754
+ includeMeta: options.includeMeta ?? true,
755
+ includeWordEnd: options.includeWordEnd ?? false,
756
+ includeInstrumental: options.includeInstrumental ?? true
757
+ };
758
+ };
759
+ var renderTimeTag = (time, options) => {
760
+ return `[${formatTime(time, options.timeFormat)}]`;
761
+ };
762
+ var renderWordTag = (start, end, options) => {
763
+ if (options.wordTiming === "none") return "";
764
+ const open = options.wordTiming === "bracket" ? "[" : "<";
765
+ const close = options.wordTiming === "bracket" ? "]" : ">";
766
+ const duration = options.includeWordEnd && end !== void 0 ? `,${Math.max(0, end - start)}` : "";
767
+ return `${open}${formatTime(start, options.timeFormat)}${duration}${close}`;
768
+ };
769
+ /**
770
+ * Plain text joins words as written, including the separators stored as space words.
771
+ */
772
+ var renderPlainText = (line) => {
773
+ const content = line.content;
774
+ if (!content) return "";
775
+ if (content.text !== void 0) return content.text;
776
+ return content.words.map((word) => word.text).join("");
777
+ };
778
+ /**
779
+ * Word-level lines interleave tags with their text; untimed words stay untagged.
780
+ */
781
+ var renderSyllableText = (line, options) => {
782
+ const content = line.content;
783
+ if (!content?.words.length) return renderPlainText(line);
784
+ return content.words.map((word) => {
785
+ if (word.type === WordType.SPACE) return word.text;
786
+ const start = word.time?.start;
787
+ if (start === void 0) return word.text;
788
+ return `${renderWordTag(start, word.time?.end, options)}${word.text}`;
789
+ }).join("");
790
+ };
791
+ var renderMetaRows = (meta, options) => {
792
+ const rows = [];
793
+ for (const title of meta.titles) rows.push(`[ti:${title.text}]`);
794
+ for (const artist of meta.artists) rows.push(`[ar:${artist.text}]`);
795
+ for (const album of meta.albums) rows.push(`[al:${album.text}]`);
796
+ for (const author of meta.authors) rows.push(`[by:${author.text}]`);
797
+ if (meta.offset) rows.push(`[offset:${meta.offset}]`);
798
+ if (meta.duration !== void 0) rows.push(`[length:${formatTime(meta.duration, options.timeFormat)}]`);
799
+ for (const isrc of meta.isrcs) rows.push(`[isrc:${isrc}]`);
800
+ for (const unknown of meta.unknowns) rows.push(`[${unknown.key}:${unknown.value}]`);
801
+ return rows;
802
+ };
803
+ /**
804
+ * Returns `undefined` for a line the options leave out, so callers can drop it rather than emit a blank row.
805
+ */
806
+ var renderLineRow = (line, options, wordLevel) => {
807
+ if (line.type === LineType.INSTRUMENTAL && !options.includeInstrumental) return;
808
+ const time = renderTimeTag(line.time?.start ?? 0, options);
809
+ if (line.type === LineType.INSTRUMENTAL) return time;
810
+ return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) : renderPlainText(line)}`;
811
+ };
812
+ var renderAnnotationRows = (lines, options, pick) => {
813
+ const rows = [];
814
+ for (const line of lines) {
815
+ const start = line.time?.start;
816
+ if (start === void 0) continue;
817
+ for (const item of pick(line)) rows.push(`${renderTimeTag(start, options)}${item.text}`);
818
+ }
819
+ return rows;
820
+ };
821
+ var joinBlocks = (header, body) => {
822
+ const blocks = [];
823
+ if (header.length) blocks.push(header.join("\n"));
824
+ if (body.length) blocks.push(body.join("\n"));
825
+ return blocks.join("\n\n");
826
+ };
827
+ /**
828
+ * Generates the LRC text plus the annotations a single file cannot hold.
829
+ *
830
+ * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
831
+ */
832
+ var generateLrc = (lyric, options = {}) => {
833
+ const resolved = resolveOptions(options);
834
+ const header = resolved.includeMeta && lyric.meta ? renderMetaRows(lyric.meta, resolved) : [];
835
+ const wordLevel = lyric.lines.some((line) => (line.content?.words.length ?? 0) > 0) && resolved.wordTiming !== "none";
836
+ const result = { original: joinBlocks(header, lyric.lines.map((line) => {
837
+ return renderLineRow(line, resolved, wordLevel);
838
+ }).filter((row) => row !== void 0)) };
839
+ const translations = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.translations ?? []);
840
+ if (translations.length) result.translations = joinBlocks(header, translations);
841
+ const romans = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.romans ?? []);
842
+ if (romans.length) result.romans = joinBlocks(header, romans);
843
+ return result;
844
+ };
845
+ //#endregion
846
+ //#region src/formats/result.ts
847
+ /**
848
+ * Canonicalizes and validates one parser's output, so every format hands back the same shape.
849
+ *
850
+ * Canonicalization runs first because validation reports paths against the canonical tree.
851
+ */
852
+ var finalizeLyric = (input) => {
853
+ const lyric = canonicalizeLyric(input);
854
+ return {
855
+ lyric,
856
+ diagnostics: validateLyric(lyric)
857
+ };
858
+ };
859
+ //#endregion
860
+ //#region src/formats/lrc/parser.ts
861
+ var isBareNumber = (value) => {
862
+ return /^\d+$/.test(value.trim());
863
+ };
864
+ /**
865
+ * A trailing tag alone still makes the line word-level, so it can close the last word.
866
+ * Unreadable angle-bracket text stays plain lyric content.
867
+ */
868
+ var isWordTimed = (text) => {
869
+ return splitWordTags(text).some((piece) => piece.tag !== void 0 && parseWordTime(piece.tag, 0) !== void 0);
870
+ };
871
+ var readMeta = (body) => {
872
+ const match = META_PATTERN.exec(body);
873
+ if (!match) return;
874
+ return {
875
+ key: (match[1] ?? "").toLowerCase(),
876
+ value: (match[2] ?? "").trim()
877
+ };
878
+ };
879
+ var readGroup = (tags, text, timed, meta) => {
880
+ for (const tag of tags) {
881
+ const start = isTime(tag) ? parseTime(tag) : void 0;
882
+ if (start !== void 0) {
883
+ timed.push({
884
+ text,
885
+ start
886
+ });
887
+ continue;
888
+ }
889
+ const pair = readMeta(tag);
890
+ if (pair) meta.push(pair);
891
+ }
892
+ };
893
+ /**
894
+ * Stacked timestamps share the following text, as in `[00:10][00:20]twice`.
895
+ * Text before the first tag is discarded because it has no timing association.
896
+ */
897
+ var readTimedLines = (text) => {
898
+ const timed = [];
899
+ const meta = [];
900
+ for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
901
+ const line = raw.trim();
902
+ if (!line) continue;
903
+ const matches = [...line.matchAll(TAG_PATTERN)];
904
+ let group = [];
905
+ for (const [index, match] of matches.entries()) {
906
+ const at = match.index + match[0].length;
907
+ const next = matches[index + 1]?.index ?? line.length;
908
+ const shared = line.slice(at, next);
909
+ group.push(match[1] ?? "");
910
+ if (!shared.trim()) continue;
911
+ readGroup(group, shared, timed, meta);
912
+ group = [];
913
+ }
914
+ readGroup(group, "", timed, meta);
915
+ }
916
+ return {
917
+ timed,
918
+ meta
919
+ };
920
+ };
921
+ /**
922
+ * Unmapped tags and unparseable durations remain in `unknowns` to avoid discarding metadata.
923
+ * Keys are lowercased and values trimmed, so original tag spelling is not preserved.
924
+ */
925
+ var readMetaTags = (tags) => {
926
+ const meta = makeMeta();
927
+ for (const tag of tags) switch (tag.key) {
928
+ case "length":
929
+ case "duration": {
930
+ const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : NaN);
931
+ if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
932
+ else meta.unknowns.push(makeUnknownEntry({
933
+ key: tag.key,
934
+ value: tag.value
935
+ }));
936
+ break;
937
+ }
938
+ case "offset":
939
+ meta.offset = Math.trunc(Number(tag.value)) || 0;
940
+ break;
941
+ case "ti":
942
+ case "title":
943
+ meta.titles.push(makeText({ text: tag.value }));
944
+ break;
945
+ case "ar":
946
+ case "artist":
947
+ meta.artists.push(makeText({ text: tag.value }));
948
+ break;
949
+ case "al":
950
+ case "album":
951
+ meta.albums.push(makeText({ text: tag.value }));
952
+ break;
953
+ case "by":
954
+ case "author":
955
+ meta.authors.push(makeText({ text: tag.value }));
956
+ break;
957
+ case "isrc":
958
+ meta.isrcs.push(tag.value);
959
+ break;
960
+ default: meta.unknowns.push(makeUnknownEntry({
961
+ key: tag.key,
962
+ value: tag.value
963
+ }));
964
+ }
965
+ return meta;
966
+ };
967
+ /**
968
+ * Whitespace becomes a space word preserving the source run, as in `lo World`.
969
+ * A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.
970
+ * Explicit word ends are kept as written, so duration tags survive unchanged.
971
+ */
972
+ var readWords$1 = (text, lineStart) => {
973
+ const drafts = [];
974
+ const open = /* @__PURE__ */ new Set();
975
+ const closeSegment = (boundary) => {
976
+ if (boundary === void 0) return;
977
+ for (const draft of open) if (draft.start !== void 0 && draft.end === void 0 && boundary >= draft.start) draft.end = boundary;
978
+ open.clear();
979
+ };
980
+ let segmentStart = lineStart;
981
+ let cursor = lineStart;
982
+ for (const piece of splitWordTags(text)) {
983
+ const time = piece.tag === void 0 ? void 0 : parseWordTime(piece.tag, lineStart);
984
+ if (time?.start !== void 0) {
985
+ closeSegment(time.start);
986
+ segmentStart = time.start;
987
+ cursor = time.start;
988
+ }
989
+ if (!piece.text) continue;
990
+ let applied = false;
991
+ for (const run of piece.text.split(/(\s+)/)) {
992
+ if (!run) continue;
993
+ if (!run.trim()) {
994
+ if (drafts.length) drafts.push({
995
+ text: run,
996
+ space: true
997
+ });
998
+ continue;
999
+ }
1000
+ const tagged = !applied && time?.start !== void 0;
1001
+ applied = true;
1002
+ const start = tagged ? segmentStart : cursor;
1003
+ const end = tagged ? time?.end : void 0;
1004
+ const draft = {
1005
+ text: run,
1006
+ start,
1007
+ end
1008
+ };
1009
+ drafts.push(draft);
1010
+ if (end !== void 0) cursor = end;
1011
+ else open.add(draft);
1012
+ }
1013
+ }
1014
+ return {
1015
+ drafts,
1016
+ closeSegment
1017
+ };
1018
+ };
1019
+ /**
1020
+ * A line ends at the next line's start; without one, it ends at its final word's end.
1021
+ * An open word is closed by the next timed word, or by the line end, so every sung word stays inside the line.
1022
+ */
1023
+ var readLine$1 = (text, start, nextStart) => {
1024
+ const wordTimed = isWordTimed(text);
1025
+ const { drafts, closeSegment } = wordTimed ? readWords$1(text, start) : {
1026
+ drafts: [],
1027
+ closeSegment: () => {}
1028
+ };
1029
+ let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
1030
+ if (end === void 0) end = [...drafts].reverse().find((draft) => !draft.space && draft.start !== void 0)?.end;
1031
+ closeSegment(end);
1032
+ const words = drafts.map((draft) => {
1033
+ if (draft.space) return makeWordSpace({ text: draft.text });
1034
+ const time = draft.start === void 0 ? void 0 : makeTimeRange({
1035
+ start: draft.start,
1036
+ end: draft.end
1037
+ });
1038
+ return makeWordNormal({
1039
+ text: draft.text,
1040
+ time
1041
+ });
1042
+ });
1043
+ const time = makeTimeRange({
1044
+ start,
1045
+ end
1046
+ });
1047
+ const content = wordTimed ? makeLineContentWords(words) : makeLineContentText(text.trim());
1048
+ return makeLineNormal({
1049
+ time,
1050
+ content
1051
+ });
1052
+ };
1053
+ /**
1054
+ * Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.
1055
+ * An empty timed row still yields a line, since it marks a gap rather than a parse failure.
1056
+ */
1057
+ var readLines = (timed) => {
1058
+ const lines = [];
1059
+ for (const [index, item] of timed.entries()) {
1060
+ const next = timed[index + 1]?.start;
1061
+ lines.push(readLine$1(item.text, item.start, next));
1062
+ }
1063
+ return lines;
1064
+ };
1065
+ var parseLrcText = (text) => {
1066
+ const { timed, meta } = readTimedLines(text);
1067
+ const metaTags = readMetaTags(meta);
1068
+ if (!timed.length) return makeLyric({
1069
+ format: "lrc",
1070
+ status: LyricStatus.INVALID,
1071
+ meta: metaTags
1072
+ });
1073
+ const lines = readLines(timed);
1074
+ const wordTimed = lines.some((line) => (line.content?.words.length ?? 0) > 0);
1075
+ return makeLyric({
1076
+ format: "lrc",
1077
+ status: LyricStatus.VALID,
1078
+ timing: wordTimed ? Timing.WORD : Timing.LINE,
1079
+ meta: metaTags,
1080
+ languages: deriveLanguageUsages(lines),
1081
+ lines
1082
+ });
1083
+ };
1084
+ /**
1085
+ * Source order, empty timed lines, and missing word times are preserved for model validation.
1086
+ *
1087
+ * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
1088
+ *
1089
+ * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
1090
+ */
1091
+ var parseLrc = (input, options = {}) => {
1092
+ const source = readLrcContent(input) ?? { original: "" };
1093
+ const lyric = parseLrcText(source.original);
1094
+ if (lyric.status === LyricStatus.INVALID) return finalizeLyric(lyric);
1095
+ const translations = source.translations === void 0 ? [] : readLines(readTimedLines(source.translations).timed);
1096
+ const romans = source.romans === void 0 ? [] : readLines(readTimedLines(source.romans).timed);
1097
+ if (translations.length === 0 && romans.length === 0) return finalizeLyric(lyric);
1098
+ const lines = attachLineAnnotations(lyric.lines, {
1099
+ translations,
1100
+ romans
1101
+ }, { tolerance: options.annotationTolerance });
1102
+ return finalizeLyric({
1103
+ ...lyric,
1104
+ lines
1105
+ });
1106
+ };
1107
+ //#endregion
1108
+ //#region src/formats/ttml/common.ts
1109
+ var FORMAT = "ttml";
1110
+ var AMLL_HINT_PATTERN = /xmlns:amll|amll:meta/iu;
1111
+ var ITUNES_HINT_PATTERN = /xmlns:itunes|iTunesMetadata|itunes:timing=/iu;
1112
+ var readTtmlContent = (input) => {
1113
+ if (typeof input === "string") return { original: input };
1114
+ if (!input || typeof input !== "object") return null;
1115
+ const source = input;
1116
+ if (typeof source.original !== "string") return null;
1117
+ return { original: source.original };
1118
+ };
1119
+ var readTtmlProfile = (source) => {
1120
+ return AMLL_HINT_PATTERN.test(source) ? "amll" : "itunes";
1121
+ };
1122
+ //#endregion
1123
+ //#region src/formats/ttml/detect.ts
1124
+ /**
1125
+ * Recognition needs a profile hint, since plain TTML says nothing about which profile wrote it.
1126
+ *
1127
+ * A bare `tt` root also counts, which is what a hand-written document has.
1128
+ * This is a format probe, not a validity check, so an input of another shape is simply not TTML.
1129
+ */
1130
+ var detectTtml = (input) => {
1131
+ const content = readTtmlContent(input);
1132
+ if (!content) return false;
1133
+ if (AMLL_HINT_PATTERN.test(content.original) || ITUNES_HINT_PATTERN.test(content.original)) return true;
1134
+ const root = parseXml(content.original);
1135
+ if (!root) return false;
1136
+ for (const child of root.children) if (child.nodeType === XmlNodeType.element && child.localName === "tt") return true;
1137
+ return false;
1138
+ };
1139
+ var TIMING_WORD = "Word";
1140
+ var TIMING_LINE = "Line";
1141
+ //#endregion
1142
+ //#region src/formats/ttml/parser.ts
1143
+ /**
1144
+ * Reads the span time range, which a word needs both ends of to be sung.
1145
+ */
1146
+ var readSpanTime = (element) => {
1147
+ const begin = getXmlAttribute(element, "begin");
1148
+ const end = getXmlAttribute(element, "end");
1149
+ if (!begin || !end) return;
1150
+ const start = parseTime(begin);
1151
+ const finish = parseTime(end);
1152
+ if (start === void 0 || finish === void 0) return;
1153
+ return {
1154
+ start,
1155
+ end: finish
1156
+ };
1157
+ };
1158
+ /**
1159
+ * Reads the line time range, which may leave its end open for the next line to supply.
1160
+ */
1161
+ var readLineTime = (element) => {
1162
+ const begin = getXmlAttribute(element, "begin");
1163
+ if (!begin) return;
1164
+ const start = parseTime(begin);
1165
+ if (start === void 0) return;
1166
+ const end = getXmlAttribute(element, "end");
1167
+ return {
1168
+ start,
1169
+ end: end ? parseTime(end) : void 0
1170
+ };
1171
+ };
1172
+ /**
1173
+ * Turns a run of text into drafts, keeping the separators as space words.
1174
+ *
1175
+ * A span's own trailing space is part of the word it follows, so leading and trailing runs both survive.
1176
+ */
1177
+ var splitWords = (text) => {
1178
+ const words = [];
1179
+ for (const run of text.split(/(\s+)/)) if (run !== "") words.push({
1180
+ text: run,
1181
+ space: !run.trim(),
1182
+ rubies: []
1183
+ });
1184
+ return words;
1185
+ };
1186
+ /**
1187
+ * Reads an AMLL ruby container into one word plus its per-syllable annotation.
1188
+ */
1189
+ var readRuby = (container) => {
1190
+ let base = "";
1191
+ const tokens = [];
1192
+ for (const child of getXmlChildElements(container, "span")) {
1193
+ const kind = getXmlAttribute(child, "ruby");
1194
+ if (kind === "base") {
1195
+ base = getXmlTextContent(child).trim();
1196
+ continue;
1197
+ }
1198
+ if (kind !== "textContainer") continue;
1199
+ for (const text of getXmlChildElements(child, "span")) {
1200
+ if (getXmlAttribute(text, "ruby") !== "text") continue;
1201
+ const time = readSpanTime(text);
1202
+ const content = getXmlTextContent(text).trim();
1203
+ if (time === void 0 || !content) continue;
1204
+ tokens.push({
1205
+ start: time.start,
1206
+ end: time.end,
1207
+ text: content
1208
+ });
1209
+ }
1210
+ }
1211
+ const first = tokens[0];
1212
+ const last = tokens[tokens.length - 1];
1213
+ if (!base || first === void 0 || last === void 0) return;
1214
+ return {
1215
+ text: base,
1216
+ space: false,
1217
+ start: first.start,
1218
+ end: last.end,
1219
+ rubies: [{
1220
+ start: first.start,
1221
+ end: last.end,
1222
+ tokens
1223
+ }]
1224
+ };
1225
+ };
1226
+ /**
1227
+ * Reports whether a text run holds nothing but formatting whitespace, which only a pretty-printed source produces.
1228
+ */
1229
+ var isFormattingWhitespace = (text) => {
1230
+ return text.trim() === "" && text.includes("\n");
1231
+ };
1232
+ /**
1233
+ * Collects the words of one span-bearing element.
1234
+ *
1235
+ * Text between spans carries the separators, and a span without both ends contributes its text untimed.
1236
+ * A span carrying a role is annotation rather than a word, so it is skipped here.
1237
+ * Indentation between tags is dropped, since the words themselves keep their own trailing spaces.
1238
+ */
1239
+ var readWords = (element) => {
1240
+ const words = [];
1241
+ let wordLevel = false;
1242
+ for (const child of element.children) {
1243
+ if (child.nodeType === XmlNodeType.text) {
1244
+ if (!isFormattingWhitespace(child.text)) words.push(...splitWords(child.text));
1245
+ continue;
1246
+ }
1247
+ if (child.nodeType !== XmlNodeType.element || child.localName !== "span") continue;
1248
+ if (getXmlAttribute(child, "role") !== void 0) continue;
1249
+ if (getXmlAttribute(child, "ruby") === "container") {
1250
+ const ruby = readRuby(child);
1251
+ if (ruby !== void 0) {
1252
+ words.push(ruby);
1253
+ wordLevel = true;
1254
+ }
1255
+ continue;
1256
+ }
1257
+ const text = getXmlTextContent(child).trim();
1258
+ if (!text) continue;
1259
+ const time = readSpanTime(child);
1260
+ if (time === void 0) {
1261
+ words.push({
1262
+ text,
1263
+ space: false,
1264
+ rubies: []
1265
+ });
1266
+ continue;
1267
+ }
1268
+ wordLevel = true;
1269
+ words.push({
1270
+ text,
1271
+ space: false,
1272
+ start: time.start,
1273
+ end: time.end,
1274
+ rubies: []
1275
+ });
1276
+ }
1277
+ return {
1278
+ words,
1279
+ wordLevel
1280
+ };
1281
+ };
1282
+ /**
1283
+ * Reads one paragraph, pulling its inline annotations and background lines out of the word stream.
1284
+ */
1285
+ var readLine = (element) => {
1286
+ const time = readLineTime(element);
1287
+ if (time === void 0) return;
1288
+ const draft = {
1289
+ start: time.start,
1290
+ end: time.end,
1291
+ agentId: getXmlAttribute(element, "agent"),
1292
+ key: getXmlAttribute(element, "key"),
1293
+ words: [],
1294
+ translations: [],
1295
+ romans: [],
1296
+ backgrounds: [],
1297
+ wordLevel: false
1298
+ };
1299
+ if (!hasXmlChildElement(element, "span")) {
1300
+ const plain = getXmlTextContent(element).trim();
1301
+ if (!plain) return;
1302
+ draft.words = splitWords(plain);
1303
+ return draft;
1304
+ }
1305
+ const read = readWords(element);
1306
+ draft.words = read.words;
1307
+ draft.wordLevel = read.wordLevel;
1308
+ for (const span of getXmlChildElements(element, "span")) {
1309
+ const role = getXmlAttribute(span, "role");
1310
+ if (!role) continue;
1311
+ if (role === "x-bg") {
1312
+ const background = readBackground(span);
1313
+ if (background) draft.backgrounds.push(background);
1314
+ continue;
1315
+ }
1316
+ const text = getXmlTextContent(span).trim();
1317
+ if (!text) continue;
1318
+ const item = {
1319
+ language: lowerTag(getXmlAttribute(span, "lang")),
1320
+ text
1321
+ };
1322
+ if (role === "x-translation") draft.translations.push(item);
1323
+ else if (role === "x-roman") draft.romans.push(item);
1324
+ }
1325
+ return draft;
1326
+ };
1327
+ /**
1328
+ * Reads a background line, whose end may stay open because its parent's end covers it.
1329
+ *
1330
+ * A background line states no time of its own in AMLL files, so its range comes from its words.
1331
+ */
1332
+ var readBackground = (element) => {
1333
+ const read = readWords(element);
1334
+ if (read.words.length === 0) return;
1335
+ const begin = getXmlAttribute(element, "begin");
1336
+ const end = getXmlAttribute(element, "end");
1337
+ const first = read.words.find((word) => {
1338
+ return !word.space && word.start !== void 0;
1339
+ });
1340
+ const last = read.words.findLast((word) => {
1341
+ return !word.space && word.start !== void 0;
1342
+ });
1343
+ const start = begin === void 0 ? first?.start ?? 0 : parseTime(begin);
1344
+ if (start === void 0) return;
1345
+ return {
1346
+ start,
1347
+ end: end === void 0 ? last?.end : parseTime(end),
1348
+ agentId: getXmlAttribute(element, "agent"),
1349
+ key: getXmlAttribute(element, "key"),
1350
+ words: read.words,
1351
+ translations: [],
1352
+ romans: [],
1353
+ backgrounds: [],
1354
+ wordLevel: read.wordLevel
1355
+ };
1356
+ };
1357
+ /**
1358
+ * Builds the model words of one draft line.
1359
+ */
1360
+ var buildWords = (draft) => {
1361
+ const words = [];
1362
+ for (const word of draft.words) {
1363
+ if (word.space) {
1364
+ words.push(makeWordSpace({ text: word.text }));
1365
+ continue;
1366
+ }
1367
+ const time = word.start === void 0 ? void 0 : makeTimeRange({
1368
+ start: word.start,
1369
+ end: word.end
1370
+ });
1371
+ if (word.rubies.length === 0) {
1372
+ words.push(makeWordNormal({
1373
+ text: word.text,
1374
+ time
1375
+ }));
1376
+ continue;
1377
+ }
1378
+ const rubies = word.rubies.map((ruby) => {
1379
+ return makeWordAnnotationRuby({
1380
+ time: makeTimeRange({
1381
+ start: ruby.start,
1382
+ end: ruby.end
1383
+ }),
1384
+ tokens: ruby.tokens.map((token) => {
1385
+ return makeWordAnnotationToken({
1386
+ text: token.text,
1387
+ time: makeTimeRange({
1388
+ start: token.start,
1389
+ end: token.end
1390
+ })
1391
+ });
1392
+ })
1393
+ });
1394
+ });
1395
+ words.push(makeWordNormal({
1396
+ text: word.text,
1397
+ time,
1398
+ annotation: makeWordAnnotation({ rubies })
1399
+ }));
1400
+ }
1401
+ return words;
1402
+ };
1403
+ /**
1404
+ * Builds a line's annotation container, which the model only keeps when it holds something.
1405
+ */
1406
+ var buildLineAnnotation = (draft) => {
1407
+ if (draft.translations.length === 0 && draft.romans.length === 0) return;
1408
+ return makeLineAnnotation({
1409
+ translations: draft.translations.map((item) => {
1410
+ return makeLineAnnotationTranslation({
1411
+ text: item.text,
1412
+ language: item.language
1413
+ });
1414
+ }),
1415
+ romans: draft.romans.map((item) => {
1416
+ return makeLineAnnotationRoman({
1417
+ text: item.text,
1418
+ language: item.language
1419
+ });
1420
+ })
1421
+ });
1422
+ };
1423
+ /**
1424
+ * Builds one background line.
1425
+ */
1426
+ var buildBackground = (draft) => {
1427
+ return makeLineBackground({
1428
+ id: draft.key,
1429
+ time: makeTimeRange({
1430
+ start: draft.start,
1431
+ end: draft.end
1432
+ }),
1433
+ agents: draft.agentId === void 0 ? [] : [draft.agentId],
1434
+ content: makeLineContentWords(buildWords(draft)),
1435
+ annotation: buildLineAnnotation(draft)
1436
+ });
1437
+ };
1438
+ /**
1439
+ * Builds one normal line, keeping plain text whole unless the source timed its words.
1440
+ */
1441
+ var buildLine = (draft) => {
1442
+ const content = draft.wordLevel ? makeLineContentWords(buildWords(draft)) : makeLineContentText(draft.words.map((word) => {
1443
+ return word.text;
1444
+ }).join("").trim());
1445
+ return makeLineNormal({
1446
+ id: draft.key,
1447
+ time: makeTimeRange({
1448
+ start: draft.start,
1449
+ end: draft.end
1450
+ }),
1451
+ agents: draft.agentId === void 0 ? [] : [draft.agentId],
1452
+ content,
1453
+ annotation: buildLineAnnotation(draft),
1454
+ backgrounds: draft.backgrounds.map(buildBackground)
1455
+ });
1456
+ };
1457
+ /**
1458
+ * Maps one iTunes agent type onto the model's.
1459
+ *
1460
+ * The model requires `raw` on `OTHER`, since it cannot tell whether the source stated that word or the reader gave up.
1461
+ */
1462
+ var readAgentType = (raw) => {
1463
+ switch (raw) {
1464
+ case "person": return {
1465
+ type: AgentType.PERSON,
1466
+ raw: void 0
1467
+ };
1468
+ case "group": return {
1469
+ type: AgentType.GROUP,
1470
+ raw: void 0
1471
+ };
1472
+ default: return {
1473
+ type: AgentType.OTHER,
1474
+ raw: raw ?? "other"
1475
+ };
1476
+ }
1477
+ };
1478
+ /**
1479
+ * Reads the head's agents, which lines reference by id.
1480
+ */
1481
+ var readAgents = (metadata) => {
1482
+ if (!metadata) return [];
1483
+ const agents = [];
1484
+ for (const element of findXmlElements(metadata, "agent")) {
1485
+ const id = getXmlQualifiedAttribute(element, "xml:id") ?? getXmlAttribute(element, "id");
1486
+ if (!id) continue;
1487
+ const names = getXmlChildElements(element, "name").map((name) => {
1488
+ return getXmlTextContent(name).trim();
1489
+ }).filter((name) => {
1490
+ return name !== "";
1491
+ });
1492
+ const kind = readAgentType(getXmlAttribute(element, "type"));
1493
+ agents.push(makeAgent({
1494
+ id,
1495
+ type: kind.type,
1496
+ raw: kind.raw,
1497
+ names: names.map((name) => {
1498
+ return makeText({ text: name });
1499
+ })
1500
+ }));
1501
+ }
1502
+ return agents;
1503
+ };
1504
+ /**
1505
+ * Reads the head's songwriters as one lyricist credit, since TTML does not split the role further.
1506
+ *
1507
+ * AMLL wraps these in `iTunesMetadata`, so the whole head is searched rather than its direct children.
1508
+ */
1509
+ var readCredits = (metadata, meta) => {
1510
+ if (!metadata) return;
1511
+ const names = findXmlElements(metadata, "songwriter").map((element) => {
1512
+ return getXmlTextContent(element).trim();
1513
+ }).filter((name) => {
1514
+ return name !== "";
1515
+ });
1516
+ if (names.length === 0) return;
1517
+ meta.credits.push(makeMetaCredit({
1518
+ role: CreditRole.LYRICIST,
1519
+ names: names.map((name) => {
1520
+ return makeText({ text: name });
1521
+ })
1522
+ }));
1523
+ };
1524
+ /**
1525
+ * Reads one `amll:meta` entry, keeping an unknown key as written so a round trip loses nothing.
1526
+ */
1527
+ var applyAmllMeta = (meta, element) => {
1528
+ const key = getXmlAttribute(element, "key");
1529
+ const value = getXmlAttribute(element, "value")?.trim();
1530
+ if (!key || !value) return;
1531
+ switch (key) {
1532
+ case "musicName":
1533
+ meta.titles.push(makeText({ text: value }));
1534
+ return;
1535
+ case "artists":
1536
+ meta.artists.push(makeText({ text: value }));
1537
+ return;
1538
+ case "album":
1539
+ meta.albums.push(makeText({ text: value }));
1540
+ return;
1541
+ case "isrc":
1542
+ meta.isrcs.push(value);
1543
+ return;
1544
+ case "ttmlAuthorGithubLogin":
1545
+ meta.authors.push(makeText({ text: value }));
1546
+ return;
1547
+ default: meta.unknowns.push(makeUnknownEntry({
1548
+ key,
1549
+ value
1550
+ }));
1551
+ }
1552
+ };
1553
+ /**
1554
+ * Splits one annotation text into its main part and any background pieces it embeds.
1555
+ */
1556
+ var splitBackgroundText = (element) => {
1557
+ let main = "";
1558
+ const backgrounds = [];
1559
+ for (const child of element.children) {
1560
+ if (child.nodeType === XmlNodeType.element && child.localName === "span" && getXmlAttribute(child, "role") === "x-bg") {
1561
+ backgrounds.push({
1562
+ text: getXmlTextContent(child).trim(),
1563
+ key: getXmlAttribute(child, "for")
1564
+ });
1565
+ continue;
1566
+ }
1567
+ main += getXmlTextContent(child);
1568
+ }
1569
+ return {
1570
+ main: main.trim(),
1571
+ backgrounds
1572
+ };
1573
+ };
1574
+ /**
1575
+ * Attaches head translations and transliterations to the lines they name by `itunes:key`.
1576
+ *
1577
+ * A replacement translation rewrites the line's text, which is how a source swaps one script for another.
1578
+ */
1579
+ var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
1580
+ if (!metadata || lineMap.size === 0) return;
1581
+ const attach = (blocks, kind) => {
1582
+ for (const block of blocks) {
1583
+ const language = lowerTag(getXmlAttribute(block, "lang"));
1584
+ const type = getXmlQualifiedAttribute(block, "type") ?? getXmlAttribute(block, "type");
1585
+ for (const text of getXmlChildElements(block, "text")) {
1586
+ const key = getXmlAttribute(text, "for");
1587
+ const line = key ? lineMap.get(key) : void 0;
1588
+ if (!line) continue;
1589
+ const { main, backgrounds } = splitBackgroundText(text);
1590
+ if (kind === "translation" && type === "replacement") {
1591
+ if (main) {
1592
+ line.words = splitWords(main);
1593
+ line.wordLevel = false;
1594
+ }
1595
+ continue;
1596
+ }
1597
+ if (main) {
1598
+ const item = {
1599
+ language,
1600
+ text: main
1601
+ };
1602
+ if (kind === "translation") line.translations.push(item);
1603
+ else line.romans.push(item);
1604
+ }
1605
+ for (const background of backgrounds) {
1606
+ if (!background.text) continue;
1607
+ const target = background.key ? backgroundMap.get(background.key) : void 0;
1608
+ if (!target) continue;
1609
+ const item = {
1610
+ language,
1611
+ text: background.text
1612
+ };
1613
+ if (kind === "translation") target.translations.push(item);
1614
+ else target.romans.push(item);
1615
+ }
1616
+ }
1617
+ }
1618
+ };
1619
+ attach(findXmlElements(metadata, "translation"), "translation");
1620
+ attach(findXmlElements(metadata, "transliteration"), "roman");
1621
+ };
1622
+ /**
1623
+ * Returns the paragraphs of a body, including any nested inside a div.
1624
+ */
1625
+ var findParagraphs = (body) => {
1626
+ const result = [];
1627
+ const stack = [body];
1628
+ while (stack.length > 0) {
1629
+ const element = stack.pop();
1630
+ if (!element) continue;
1631
+ if (element.localName === "p") {
1632
+ result.push(element);
1633
+ continue;
1634
+ }
1635
+ const children = element.children;
1636
+ for (let index = children.length - 1; index >= 0; index -= 1) {
1637
+ const child = children[index];
1638
+ if (child && child.nodeType === XmlNodeType.element) stack.push(child);
1639
+ }
1640
+ }
1641
+ return result;
1642
+ };
1643
+ var readDeclaredTiming = (root) => {
1644
+ switch (getXmlQualifiedAttribute(root, "itunes:timing")) {
1645
+ case TIMING_WORD: return Timing.WORD;
1646
+ case TIMING_LINE: return Timing.LINE;
1647
+ default: return;
1648
+ }
1649
+ };
1650
+ /**
1651
+ * Reads a TTML document into the model.
1652
+ *
1653
+ * The profile only changes where metadata comes from, so one reader serves both.
1654
+ * A document that parses but holds no timed paragraph is `INVALID` rather than an error.
1655
+ */
1656
+ var parseTtml = (input, options = {}) => {
1657
+ const content = readTtmlContent(input);
1658
+ if (!content) return finalizeLyric(makeLyric({
1659
+ format: FORMAT,
1660
+ status: LyricStatus.INVALID,
1661
+ meta: makeMeta()
1662
+ }));
1663
+ const extra = { profile: readTtmlProfile(content.original) };
1664
+ const document = parseXml(content.original);
1665
+ const root = document ? findXmlElement(document, "tt") : void 0;
1666
+ if (!root) return finalizeLyric(makeLyric({
1667
+ format: FORMAT,
1668
+ status: LyricStatus.INVALID,
1669
+ meta: makeMeta(),
1670
+ extra
1671
+ }));
1672
+ const body = findXmlElement(root, "body");
1673
+ const metadata = findXmlElement(root, "metadata");
1674
+ const meta = makeMeta();
1675
+ const language = lowerTag(getXmlQualifiedAttribute(root, "xml:lang")) ?? lowerTag(options.language);
1676
+ if (language !== void 0) meta.language = language;
1677
+ readCredits(metadata, meta);
1678
+ for (const element of metadata ? findXmlElements(metadata, "meta") : []) applyAmllMeta(meta, element);
1679
+ const drafts = [];
1680
+ const lineMap = /* @__PURE__ */ new Map();
1681
+ const backgroundMap = /* @__PURE__ */ new Map();
1682
+ for (const element of body ? findParagraphs(body) : []) {
1683
+ const draft = readLine(element);
1684
+ if (!draft) continue;
1685
+ drafts.push(draft);
1686
+ if (draft.key) lineMap.set(draft.key, draft);
1687
+ for (const background of draft.backgrounds) if (background.key) backgroundMap.set(background.key, background);
1688
+ }
1689
+ attachHeadAnnotations(metadata, lineMap, backgroundMap);
1690
+ if (drafts.length === 0) return finalizeLyric(makeLyric({
1691
+ format: FORMAT,
1692
+ status: LyricStatus.INVALID,
1693
+ meta,
1694
+ extra
1695
+ }));
1696
+ const lines = drafts.map(buildLine);
1697
+ const wordLevel = drafts.some((draft) => {
1698
+ return draft.wordLevel;
1699
+ });
1700
+ return finalizeLyric(makeLyric({
1701
+ format: FORMAT,
1702
+ status: LyricStatus.VALID,
1703
+ timing: readDeclaredTiming(root) ?? (wordLevel ? Timing.WORD : Timing.LINE),
1704
+ meta,
1705
+ extra,
1706
+ agents: readAgents(metadata),
1707
+ languages: deriveLanguageUsages(lines),
1708
+ lines
1709
+ }));
1710
+ };
1711
+ //#endregion
1712
+ //#region src/formats/index.ts
1713
+ /**
1714
+ * Recognition reads only the original text; the annotation tracks never decide the format.
1715
+ *
1716
+ * Unrecognized text returns `undefined`.
1717
+ */
1718
+ var detectFormat = (input) => {
1719
+ if (detectLrc(input)) return "lrc";
1720
+ if (detectTtml(input)) return "ttml";
1721
+ };
1722
+ /**
1723
+ * Unrecognized text returns `null`; a recognized format returns its canonical lyric and diagnostics.
1724
+ * Annotation tracks travel with the source and are attached by the format's parser.
1725
+ */
1726
+ var parse = (input, options = {}) => {
1727
+ const format = detectFormat(input);
1728
+ if (!format) return null;
1729
+ switch (format) {
1730
+ case "lrc": return parseLrc(input, options);
1731
+ case "ttml": return parseTtml(input, options);
1732
+ }
1733
+ };
1734
+ /**
1735
+ * Generates text in the requested format, which need not match `lyric.format`.
1736
+ *
1737
+ * A format without a writer has no branch here, so adding one to `LyricFormat` makes this fail to compile.
1738
+ */
1739
+ var generate = (lyric, target, options = {}) => {
1740
+ switch (target) {
1741
+ case "lrc": return generateLrc(lyric, options);
1742
+ case "ttml": throw new Error("ttml generation is not implemented yet");
1743
+ }
1744
+ };
1745
+ //#endregion
1746
+ export { AMLL_HINT_PATTERN, FORMAT, ITUNES_HINT_PATTERN, detectFormat, detectLrc, detectTtml, generate, generateLrc, parse, parseLrc, parseTtml, readTtmlContent, readTtmlProfile };
1747
+
1748
+ //# sourceMappingURL=formats.js.map