@gondola-lyrics/kit 0.1.0 → 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.
package/dist/formats.js CHANGED
@@ -1,4 +1,4 @@
1
- import { LineType, LyricStatus, Timing, WordType, deriveLanguageUsages, getWordsText, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineContentText, makeLineContentWords, makeLineNormal, makeLyric, makeMeta, makeText, makeTimeRange, makeUnknownEntry, makeWordNormal, makeWordSpace } from "@gondola-lyrics/model/runtime";
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
2
  //#region src/utils/annotation.ts
3
3
  /**
4
4
  * An annotation line carries its text in either field, depending on whether the track used word tags.
@@ -91,30 +91,69 @@ var attachLineAnnotations = (lines, sources, options = {}) => {
91
91
  });
92
92
  };
93
93
  //#endregion
94
- //#region src/utils/index.ts
95
- var TIME_PATTERN = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
94
+ //#region src/utils/language.ts
96
95
  /**
97
- * Bare integers are excluded because formats may use them as millisecond counts or offsets.
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.
98
100
  */
99
- var isTime = (body) => {
100
- const trimmed = body.trim();
101
- return (trimmed.includes(":") || trimmed.includes(".")) && TIME_PATTERN.test(trimmed);
101
+ var lowerTag = (tag) => {
102
+ return tag ? tag.toLowerCase() : void 0;
102
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;
103
108
  /**
104
- * Fractional seconds are padded or truncated to millisecond precision.
109
+ * Reads a clock value, where the fraction is padded or truncated to millisecond precision.
105
110
  *
106
- * Invalid clock syntax returns `undefined`; time bounds are left to model validation.
111
+ * `14.514` is fourteen seconds, not fourteen minutes: a colon, not a dot, introduces the larger units.
107
112
  */
108
- var parseTime = (body) => {
109
- if (!isTime(body)) return;
110
- const match = TIME_PATTERN.exec(body.trim());
113
+ var parseClock = (value) => {
114
+ if (!value.includes(":") && !value.includes(".")) return;
115
+ const match = CLOCK_PATTERN.exec(value);
116
+ if (!match) return;
111
117
  const hour = Number(match[1] ?? 0);
112
118
  const minute = Number(match[2] ?? 0);
113
- const second = Number(match[3]);
119
+ const second = Number(match[3] ?? 0);
114
120
  const millisecond = Number((match[4] ?? "").padEnd(3, "0").slice(0, 3));
115
121
  return ((hour * 60 + minute) * 60 + second) * 1e3 + millisecond;
116
122
  };
117
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
+ /**
118
157
  * Tokens `h`, `m`, `s`, and `S` select hours, minutes, seconds, and fractional seconds.
119
158
  * Repeated unit tokens set minimum width; `S` tokens select up to three fractional digits.
120
159
  *
@@ -139,6 +178,482 @@ var formatTime = (time, format = "mm:ss.SSS") => {
139
178
  });
140
179
  };
141
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
142
657
  //#region src/formats/lrc/common.ts
143
658
  var readLrcContent = (input) => {
144
659
  if (typeof input === "string") return { original: input };
@@ -328,6 +843,20 @@ var generateLrc = (lyric, options = {}) => {
328
843
  return result;
329
844
  };
330
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
331
860
  //#region src/formats/lrc/parser.ts
332
861
  var isBareNumber = (value) => {
333
862
  return /^\d+$/.test(value.trim());
@@ -440,7 +969,7 @@ var readMetaTags = (tags) => {
440
969
  * A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.
441
970
  * Explicit word ends are kept as written, so duration tags survive unchanged.
442
971
  */
443
- var readWords = (text, lineStart) => {
972
+ var readWords$1 = (text, lineStart) => {
444
973
  const drafts = [];
445
974
  const open = /* @__PURE__ */ new Set();
446
975
  const closeSegment = (boundary) => {
@@ -491,9 +1020,9 @@ var readWords = (text, lineStart) => {
491
1020
  * A line ends at the next line's start; without one, it ends at its final word's end.
492
1021
  * An open word is closed by the next timed word, or by the line end, so every sung word stays inside the line.
493
1022
  */
494
- var readLine = (text, start, nextStart) => {
1023
+ var readLine$1 = (text, start, nextStart) => {
495
1024
  const wordTimed = isWordTimed(text);
496
- const { drafts, closeSegment } = wordTimed ? readWords(text, start) : {
1025
+ const { drafts, closeSegment } = wordTimed ? readWords$1(text, start) : {
497
1026
  drafts: [],
498
1027
  closeSegment: () => {}
499
1028
  };
@@ -529,7 +1058,7 @@ var readLines = (timed) => {
529
1058
  const lines = [];
530
1059
  for (const [index, item] of timed.entries()) {
531
1060
  const next = timed[index + 1]?.start;
532
- lines.push(readLine(item.text, item.start, next));
1061
+ lines.push(readLine$1(item.text, item.start, next));
533
1062
  }
534
1063
  return lines;
535
1064
  };
@@ -557,23 +1086,627 @@ var parseLrcText = (text) => {
557
1086
  *
558
1087
  * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
559
1088
  *
560
- * `INVALID` means no readable timed lines; `VALID` does not imply model validation has passed.
1089
+ * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
561
1090
  */
562
1091
  var parseLrc = (input, options = {}) => {
563
1092
  const source = readLrcContent(input) ?? { original: "" };
564
1093
  const lyric = parseLrcText(source.original);
565
- if (lyric.status === LyricStatus.INVALID) return lyric;
1094
+ if (lyric.status === LyricStatus.INVALID) return finalizeLyric(lyric);
566
1095
  const translations = source.translations === void 0 ? [] : readLines(readTimedLines(source.translations).timed);
567
1096
  const romans = source.romans === void 0 ? [] : readLines(readTimedLines(source.romans).timed);
568
- if (translations.length === 0 && romans.length === 0) return lyric;
1097
+ if (translations.length === 0 && romans.length === 0) return finalizeLyric(lyric);
569
1098
  const lines = attachLineAnnotations(lyric.lines, {
570
1099
  translations,
571
1100
  romans
572
1101
  }, { tolerance: options.annotationTolerance });
573
- return {
1102
+ return finalizeLyric({
574
1103
  ...lyric,
575
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
+ }
576
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
+ }));
577
1710
  };
578
1711
  //#endregion
579
1712
  //#region src/formats/index.ts
@@ -584,9 +1717,10 @@ var parseLrc = (input, options = {}) => {
584
1717
  */
585
1718
  var detectFormat = (input) => {
586
1719
  if (detectLrc(input)) return "lrc";
1720
+ if (detectTtml(input)) return "ttml";
587
1721
  };
588
1722
  /**
589
- * Unrecognized text returns `null`; a recognized format returns a lyric with its parser-assigned status.
1723
+ * Unrecognized text returns `null`; a recognized format returns its canonical lyric and diagnostics.
590
1724
  * Annotation tracks travel with the source and are attached by the format's parser.
591
1725
  */
592
1726
  var parse = (input, options = {}) => {
@@ -594,17 +1728,21 @@ var parse = (input, options = {}) => {
594
1728
  if (!format) return null;
595
1729
  switch (format) {
596
1730
  case "lrc": return parseLrc(input, options);
1731
+ case "ttml": return parseTtml(input, options);
597
1732
  }
598
1733
  };
599
1734
  /**
600
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.
601
1738
  */
602
1739
  var generate = (lyric, target, options = {}) => {
603
1740
  switch (target) {
604
1741
  case "lrc": return generateLrc(lyric, options);
1742
+ case "ttml": throw new Error("ttml generation is not implemented yet");
605
1743
  }
606
1744
  };
607
1745
  //#endregion
608
- export { detectFormat, detectLrc, generate, generateLrc, parse, parseLrc };
1746
+ export { AMLL_HINT_PATTERN, FORMAT, ITUNES_HINT_PATTERN, detectFormat, detectLrc, detectTtml, generate, generateLrc, parse, parseLrc, parseTtml, readTtmlContent, readTtmlProfile };
609
1747
 
610
1748
  //# sourceMappingURL=formats.js.map