@gondola-lyrics/kit 0.1.0 → 0.3.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,13 +1,13 @@
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, removeOffset, resolveAnnotationItemTime, resolveAnnotationTokenTime, 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.
5
5
  */
6
- var readText = (line) => {
6
+ var readText$1 = (line) => {
7
7
  return (line.content?.text ?? getWordsText(line.content?.words ?? [])).trim();
8
8
  };
9
9
  var isReadable = (line) => {
10
- return line.time?.start !== void 0 && readText(line) !== "";
10
+ return line.time?.start !== void 0 && readText$1(line) !== "";
11
11
  };
12
12
  /**
13
13
  * Assigns each annotation line to a lyric line index.
@@ -79,11 +79,11 @@ var attachLineAnnotations = (lines, sources, options = {}) => {
79
79
  ...line,
80
80
  annotation: makeLineAnnotation({
81
81
  romans: [...line.annotation?.romans ?? [], ...roman === void 0 ? [] : [makeLineAnnotationRoman({
82
- text: readText(roman),
82
+ text: readText$1(roman),
83
83
  language: roman.languages[0]
84
84
  })]],
85
85
  translations: [...line.annotation?.translations ?? [], ...translation === void 0 ? [] : [makeLineAnnotationTranslation({
86
- text: readText(translation),
86
+ text: readText$1(translation),
87
87
  language: translation.languages[0]
88
88
  })]]
89
89
  })
@@ -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,647 @@ 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
+ * Creates an element from its full name, with attributes and children in the order given.
237
+ */
238
+ var createXmlElement = (qualifiedName, attributes = [], children = []) => {
239
+ const element = {
240
+ nodeType: XmlNodeType.element,
241
+ qualifiedName,
242
+ namespacePrefix: "",
243
+ localName: qualifiedName,
244
+ attributes: attributes.map(([name, value]) => {
245
+ return createXmlAttribute(name, value);
246
+ }),
247
+ children: [...children]
248
+ };
249
+ applyXmlNameParts(qualifiedName, element);
250
+ return element;
251
+ };
252
+ /**
253
+ * Creates a text node, whose value the writer escapes.
254
+ */
255
+ var createXmlText = (text) => {
256
+ return {
257
+ nodeType: XmlNodeType.text,
258
+ text
259
+ };
260
+ };
261
+ /**
262
+ * Returns the value of one attribute, matched by its local name so the prefix never has to be known.
263
+ */
264
+ var getXmlAttribute = (element, localName) => {
265
+ const attributes = element.attributes;
266
+ for (let index = 0; index < attributes.length; index += 1) {
267
+ const attribute = attributes[index];
268
+ if (attribute !== void 0 && attribute.localName === localName) return attribute.value;
269
+ }
270
+ };
271
+ /**
272
+ * Returns the value of one attribute, matched by its full name including the prefix.
273
+ */
274
+ var getXmlQualifiedAttribute = (element, qualifiedName) => {
275
+ const attributes = element.attributes;
276
+ for (let index = 0; index < attributes.length; index += 1) {
277
+ const attribute = attributes[index];
278
+ if (attribute !== void 0 && attribute.qualifiedName === qualifiedName) return attribute.value;
279
+ }
280
+ };
281
+ /**
282
+ * Returns the direct element children carrying one local name.
283
+ */
284
+ var getXmlChildElements = (element, localName) => {
285
+ const result = [];
286
+ const children = element.children;
287
+ for (let index = 0; index < children.length; index += 1) {
288
+ const child = children[index];
289
+ if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) result.push(child);
290
+ }
291
+ return result;
292
+ };
293
+ /**
294
+ * Reports whether the element has a direct child with one local name.
295
+ */
296
+ var hasXmlChildElement = (element, localName) => {
297
+ const children = element.children;
298
+ for (let index = 0; index < children.length; index += 1) {
299
+ const child = children[index];
300
+ if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) return true;
301
+ }
302
+ return false;
303
+ };
304
+ /**
305
+ * Returns the first element carrying one local name, the element itself included, in document order.
306
+ */
307
+ var findXmlElement = (element, localName) => {
308
+ const stack = [element];
309
+ while (stack.length > 0) {
310
+ const node = stack.pop();
311
+ if (node === void 0 || node.nodeType !== XmlNodeType.element) continue;
312
+ if (node.localName === localName) return node;
313
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
314
+ const child = node.children[index];
315
+ if (child !== void 0) stack.push(child);
316
+ }
317
+ }
318
+ };
319
+ /**
320
+ * Joins the text and CDATA content of a node and its descendants, in document order.
321
+ */
322
+ var getXmlTextContent = (node) => {
323
+ if (node.nodeType !== XmlNodeType.element) return node.text;
324
+ if (node.children.length === 0) return "";
325
+ const child = node.children[0];
326
+ if (node.children.length === 1 && child !== void 0 && child.nodeType !== XmlNodeType.element) return child.text;
327
+ const parts = [];
328
+ const stack = [node];
329
+ while (stack.length > 0) {
330
+ const current = stack.pop();
331
+ if (current === void 0) continue;
332
+ if (current.nodeType === XmlNodeType.element) {
333
+ for (let index = current.children.length - 1; index >= 0; index -= 1) {
334
+ const child = current.children[index];
335
+ if (child !== void 0) stack.push(child);
336
+ }
337
+ continue;
338
+ }
339
+ parts.push(current.text);
340
+ }
341
+ return parts.join("");
342
+ };
343
+ //#endregion
344
+ //#region src/utils/xml/reader.ts
345
+ var LESS_THAN$1 = 60;
346
+ var GREATER_THAN$1 = 62;
347
+ var SLASH = 47;
348
+ var EQUALS = 61;
349
+ var DOUBLE_QUOTE$1 = 34;
350
+ var SINGLE_QUOTE = 39;
351
+ var EXCLAMATION = 33;
352
+ var QUESTION = 63;
353
+ var AMPERSAND$1 = 38;
354
+ var HASH = 35;
355
+ var HYPHEN = 45;
356
+ var LOWER_X = 120;
357
+ var UPPER_X = 88;
358
+ var BYTE_ORDER_MARK = 65279;
359
+ var ROOT_NAME$1 = "#document";
360
+ var DECIMAL_DIGITS = /^\d+$/;
361
+ var HEX_DIGITS = /^[0-9a-fA-F]+$/;
362
+ /**
363
+ * Resolves one entity reference body, leaving anything unrecognized as written.
364
+ */
365
+ var decodeReference = (body) => {
366
+ switch (body) {
367
+ case "lt": return "<";
368
+ case "gt": return ">";
369
+ case "amp": return "&";
370
+ case "quot": return "\"";
371
+ case "apos": return "'";
372
+ }
373
+ if (body.charCodeAt(0) !== HASH) return `&${body};`;
374
+ const hexadecimal = body.charCodeAt(1) === LOWER_X || body.charCodeAt(1) === UPPER_X;
375
+ const digits = hexadecimal ? body.slice(2) : body.slice(1);
376
+ if (!(hexadecimal ? HEX_DIGITS : DECIMAL_DIGITS).test(digits)) return `&${body};`;
377
+ const code = Number.parseInt(digits, hexadecimal ? 16 : 10);
378
+ if (!Number.isFinite(code) || code > 1114111) return `&${body};`;
379
+ return String.fromCodePoint(code);
380
+ };
381
+ /**
382
+ * Replaces entity references in one run of text, leaving bare ampersands alone.
383
+ */
384
+ var decodeText = (text) => {
385
+ const parts = [];
386
+ const length = text.length;
387
+ let start = 0;
388
+ let cursor = 0;
389
+ while (cursor < length) {
390
+ if (text.charCodeAt(cursor) !== AMPERSAND$1) {
391
+ cursor += 1;
392
+ continue;
393
+ }
394
+ const end = text.indexOf(";", cursor + 1);
395
+ if (end === -1) {
396
+ cursor += 1;
397
+ continue;
398
+ }
399
+ parts.push(text.slice(start, cursor), decodeReference(text.slice(cursor + 1, end)));
400
+ cursor = end + 1;
401
+ start = cursor;
402
+ }
403
+ if (parts.length === 0) return text;
404
+ parts.push(text.slice(start));
405
+ return parts.join("");
406
+ };
407
+ /**
408
+ * Reads a document into a tree, or returns `null` when it is not well formed.
409
+ */
410
+ var parseXml = (source) => {
411
+ const length = source.length;
412
+ let position = length > 0 && source.charCodeAt(0) === BYTE_ORDER_MARK ? 1 : 0;
413
+ let valid = true;
414
+ const stack = [];
415
+ const root = {
416
+ nodeType: XmlNodeType.element,
417
+ qualifiedName: ROOT_NAME$1,
418
+ namespacePrefix: "",
419
+ localName: ROOT_NAME$1,
420
+ attributes: [],
421
+ children: []
422
+ };
423
+ stack.push(root);
424
+ /**
425
+ * Appends a text node to the element being read.
426
+ */
427
+ const readTextNode = (end) => {
428
+ const start = position;
429
+ position = end;
430
+ if (start === end) return;
431
+ const parent = stack[stack.length - 1];
432
+ if (parent === void 0) {
433
+ valid = false;
434
+ return;
435
+ }
436
+ const raw = source.slice(start, end);
437
+ parent.children.push({
438
+ nodeType: XmlNodeType.text,
439
+ text: raw.indexOf("&") === -1 ? raw : decodeText(raw)
440
+ });
441
+ };
442
+ /**
443
+ * Reads one quoted value, starting at `position`, and leaves `position` after the closing quote.
444
+ */
445
+ const readQuotedValue = () => {
446
+ const quote = source.charCodeAt(position);
447
+ if (quote !== DOUBLE_QUOTE$1 && quote !== SINGLE_QUOTE) {
448
+ valid = false;
449
+ return "";
450
+ }
451
+ position += 1;
452
+ const end = source.indexOf(quote === DOUBLE_QUOTE$1 ? "\"" : "'", position);
453
+ if (end === -1) {
454
+ valid = false;
455
+ return "";
456
+ }
457
+ const raw = source.slice(position, end);
458
+ position = end + 1;
459
+ return raw.indexOf("&") === -1 ? raw : decodeText(raw);
460
+ };
461
+ /**
462
+ * Reads a start tag with its attributes, and opens the element unless it closes itself.
463
+ */
464
+ const readStartTag = () => {
465
+ let cursor = position + 1;
466
+ const nameStart = cursor;
467
+ while (cursor < length) {
468
+ const code = source.charCodeAt(cursor);
469
+ if (code <= 32 || code === GREATER_THAN$1 || code === SLASH || code === EQUALS) break;
470
+ cursor += 1;
471
+ }
472
+ if (cursor === nameStart) {
473
+ valid = false;
474
+ return;
475
+ }
476
+ const qualifiedName = source.slice(nameStart, cursor);
477
+ const attributes = [];
478
+ const element = {
479
+ nodeType: XmlNodeType.element,
480
+ qualifiedName,
481
+ namespacePrefix: "",
482
+ localName: qualifiedName,
483
+ attributes,
484
+ children: []
485
+ };
486
+ applyXmlNameParts(qualifiedName, element);
487
+ let closed = false;
488
+ let selfClosing = false;
489
+ while (cursor < length) {
490
+ let code = source.charCodeAt(cursor);
491
+ while (code <= 32) {
492
+ cursor += 1;
493
+ if (cursor >= length) break;
494
+ code = source.charCodeAt(cursor);
495
+ }
496
+ if (cursor >= length) break;
497
+ if (code === GREATER_THAN$1) {
498
+ cursor += 1;
499
+ closed = true;
500
+ break;
501
+ }
502
+ if (code === SLASH) {
503
+ cursor += 1;
504
+ if (cursor < length && source.charCodeAt(cursor) === GREATER_THAN$1) {
505
+ cursor += 1;
506
+ closed = true;
507
+ selfClosing = true;
508
+ break;
509
+ }
510
+ valid = false;
511
+ return;
512
+ }
513
+ const attributeStart = cursor;
514
+ do {
515
+ cursor += 1;
516
+ if (cursor >= length) break;
517
+ code = source.charCodeAt(cursor);
518
+ } while (code > 32 && code !== GREATER_THAN$1 && code !== SLASH && code !== EQUALS);
519
+ if (cursor === attributeStart) {
520
+ valid = false;
521
+ return;
522
+ }
523
+ const attributeName = source.slice(attributeStart, cursor);
524
+ while (cursor < length && source.charCodeAt(cursor) <= 32) cursor += 1;
525
+ if (cursor >= length || source.charCodeAt(cursor) !== EQUALS) {
526
+ valid = false;
527
+ return;
528
+ }
529
+ cursor += 1;
530
+ while (cursor < length && source.charCodeAt(cursor) <= 32) cursor += 1;
531
+ position = cursor;
532
+ const value = readQuotedValue();
533
+ if (!valid) return;
534
+ cursor = position;
535
+ for (let index = 0; index < attributes.length; index += 1) if (attributes[index]?.qualifiedName === attributeName) {
536
+ valid = false;
537
+ return;
538
+ }
539
+ attributes.push(createXmlAttribute(attributeName, value));
540
+ }
541
+ if (!closed) {
542
+ valid = false;
543
+ return;
544
+ }
545
+ position = cursor;
546
+ const parent = stack[stack.length - 1];
547
+ if (parent === void 0) {
548
+ valid = false;
549
+ return;
550
+ }
551
+ parent.children.push(element);
552
+ if (!selfClosing) stack.push(element);
553
+ };
554
+ /**
555
+ * Closes the innermost element, which has to be the one the tag names.
556
+ */
557
+ const readEndTag = () => {
558
+ let cursor = position + 2;
559
+ const nameStart = cursor;
560
+ while (cursor < length) {
561
+ const code = source.charCodeAt(cursor);
562
+ if (code <= 32 || code === GREATER_THAN$1) break;
563
+ cursor += 1;
564
+ }
565
+ const name = source.slice(nameStart, cursor);
566
+ const end = source.indexOf(">", cursor);
567
+ if (end === -1) {
568
+ valid = false;
569
+ return;
570
+ }
571
+ position = end + 1;
572
+ if (stack.length <= 1) {
573
+ valid = false;
574
+ return;
575
+ }
576
+ const top = stack[stack.length - 1];
577
+ if (top === void 0 || top.qualifiedName !== name) {
578
+ valid = false;
579
+ return;
580
+ }
581
+ stack.pop();
582
+ };
583
+ /**
584
+ * Skips a comment, CDATA section, or any other declaration such as a DOCTYPE.
585
+ */
586
+ const readMarkupDeclaration = () => {
587
+ if (source.charCodeAt(position + 2) === HYPHEN && source.charCodeAt(position + 3) === HYPHEN) {
588
+ const end = source.indexOf("-->", position + 4);
589
+ if (end === -1) {
590
+ valid = false;
591
+ return;
592
+ }
593
+ position = end + 3;
594
+ return;
595
+ }
596
+ if (source.startsWith("[CDATA[", position + 2)) {
597
+ const start = position + 9;
598
+ const end = source.indexOf("]]>", start);
599
+ if (end === -1) {
600
+ valid = false;
601
+ return;
602
+ }
603
+ const parent = stack[stack.length - 1];
604
+ if (parent === void 0) {
605
+ valid = false;
606
+ return;
607
+ }
608
+ parent.children.push({
609
+ nodeType: XmlNodeType.cdata,
610
+ text: source.slice(start, end)
611
+ });
612
+ position = end + 3;
613
+ return;
614
+ }
615
+ let cursor = position + 2;
616
+ let depth = 1;
617
+ while (cursor < length && depth > 0) {
618
+ const code = source.charCodeAt(cursor);
619
+ if (code === LESS_THAN$1) depth += 1;
620
+ else if (code === GREATER_THAN$1) depth -= 1;
621
+ cursor += 1;
622
+ }
623
+ if (depth > 0) {
624
+ valid = false;
625
+ return;
626
+ }
627
+ position = cursor;
628
+ };
629
+ /**
630
+ * Skips a processing instruction, which carries nothing the kit reads.
631
+ */
632
+ const readProcessingInstruction = () => {
633
+ const end = source.indexOf("?>", position + 2);
634
+ if (end === -1) {
635
+ valid = false;
636
+ return;
637
+ }
638
+ position = end + 2;
639
+ };
640
+ /**
641
+ * Reads content until the end of input or the end tag of the innermost element.
642
+ */
643
+ const readNodes = () => {
644
+ while (position < length) {
645
+ const open = source.indexOf("<", position);
646
+ if (open === -1) {
647
+ readTextNode(length);
648
+ return;
649
+ }
650
+ if (open > position) {
651
+ readTextNode(open);
652
+ if (!valid) return;
653
+ }
654
+ position = open;
655
+ const next = source.charCodeAt(open + 1);
656
+ if (next === SLASH) readEndTag();
657
+ else if (next === EXCLAMATION) readMarkupDeclaration();
658
+ else if (next === QUESTION) readProcessingInstruction();
659
+ else readStartTag();
660
+ if (!valid) return;
661
+ }
662
+ };
663
+ readNodes();
664
+ if (!valid || stack.length !== 1) return null;
665
+ for (let index = 0; index < root.children.length; index += 1) if (root.children[index]?.nodeType === XmlNodeType.element) return root;
666
+ return null;
667
+ };
668
+ //#endregion
669
+ //#region src/utils/xml/writer.ts
670
+ var AMPERSAND = 38;
671
+ var LESS_THAN = 60;
672
+ var GREATER_THAN = 62;
673
+ var DOUBLE_QUOTE = 34;
674
+ var TAB = 9;
675
+ var LINE_FEED = 10;
676
+ var CARRIAGE_RETURN = 13;
677
+ var ROOT_NAME = "#document";
678
+ var REPLACEMENT_CHARACTER = "�";
679
+ var XML_DECLARATION = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
680
+ var DEFAULT_INDENT = " ";
681
+ /**
682
+ * Reports whether a code point may appear in an XML 1.0 document.
683
+ */
684
+ var isAllowedCodePoint = (code) => {
685
+ return code === TAB || code === LINE_FEED || code === CARRIAGE_RETURN || code >= 32 && code <= 55295 || code >= 57344 && code <= 65533 || code >= 65536 && code <= 1114111;
686
+ };
687
+ /**
688
+ * Escapes text content, replacing characters XML cannot carry so corruption stays visible.
689
+ */
690
+ var escapeXmlText = (text, chunks) => {
691
+ const length = text.length;
692
+ let start = 0;
693
+ for (let index = 0; index < length; index += 1) {
694
+ const code = text.charCodeAt(index);
695
+ let escaped = "";
696
+ if (code === AMPERSAND) escaped = "&amp;";
697
+ else if (code === LESS_THAN) escaped = "&lt;";
698
+ else if (code === GREATER_THAN) escaped = "&gt;";
699
+ else if (code === CARRIAGE_RETURN) escaped = "&#13;";
700
+ else if (code >= 55296 && code <= 56319) {
701
+ const low = index + 1 < length ? text.charCodeAt(index + 1) : 0;
702
+ if (low < 56320 || low > 57343) escaped = REPLACEMENT_CHARACTER;
703
+ else {
704
+ index += 1;
705
+ continue;
706
+ }
707
+ } else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
708
+ else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
709
+ if (escaped === "") continue;
710
+ if (start < index) chunks.push(text.slice(start, index));
711
+ chunks.push(escaped);
712
+ start = index + 1;
713
+ }
714
+ if (start === 0) {
715
+ chunks.push(text);
716
+ return;
717
+ }
718
+ if (start < length) chunks.push(text.slice(start));
719
+ };
720
+ /**
721
+ * Escapes an attribute value, where whitespace also has to survive normalization.
722
+ */
723
+ var escapeXmlAttribute = (value, chunks) => {
724
+ const length = value.length;
725
+ let start = 0;
726
+ for (let index = 0; index < length; index += 1) {
727
+ const code = value.charCodeAt(index);
728
+ let escaped = "";
729
+ if (code === AMPERSAND) escaped = "&amp;";
730
+ else if (code === LESS_THAN) escaped = "&lt;";
731
+ else if (code === GREATER_THAN) escaped = "&gt;";
732
+ else if (code === DOUBLE_QUOTE) escaped = "&quot;";
733
+ else if (code === TAB) escaped = "&#9;";
734
+ else if (code === LINE_FEED) escaped = "&#10;";
735
+ else if (code === CARRIAGE_RETURN) escaped = "&#13;";
736
+ else if (code >= 55296 && code <= 56319) {
737
+ const low = index + 1 < length ? value.charCodeAt(index + 1) : 0;
738
+ if (low < 56320 || low > 57343) escaped = REPLACEMENT_CHARACTER;
739
+ else {
740
+ index += 1;
741
+ continue;
742
+ }
743
+ } else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
744
+ else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
745
+ if (escaped === "") continue;
746
+ if (start < index) chunks.push(value.slice(start, index));
747
+ chunks.push(escaped);
748
+ start = index + 1;
749
+ }
750
+ if (start === 0) {
751
+ chunks.push(value);
752
+ return;
753
+ }
754
+ if (start < length) chunks.push(value.slice(start));
755
+ };
756
+ /**
757
+ * Reports whether every child is an element, which is the only case indentation can add whitespace to.
758
+ *
759
+ * An element holding any character data is mixed content, where the inserted whitespace would join the text.
760
+ */
761
+ var hasOnlyElements = (element) => {
762
+ const children = element.children;
763
+ for (let index = 0; index < children.length; index += 1) {
764
+ const child = children[index];
765
+ if (child !== void 0 && child.nodeType !== XmlNodeType.element) return false;
766
+ }
767
+ return true;
768
+ };
769
+ /**
770
+ * Serializes one node, appending its pieces to `chunks`.
771
+ */
772
+ var writeXmlNode = (node, chunks, depth, indent) => {
773
+ if (node.nodeType === XmlNodeType.text) {
774
+ escapeXmlText(node.text, chunks);
775
+ return;
776
+ }
777
+ if (node.nodeType === XmlNodeType.cdata) {
778
+ chunks.push("<![CDATA[", node.text.replaceAll("]]>", "]]]]><![CDATA[>"), "]]>");
779
+ return;
780
+ }
781
+ if (indent !== void 0 && depth > 0) chunks.push("\n", indent.repeat(depth));
782
+ chunks.push("<", node.qualifiedName);
783
+ for (let index = 0; index < node.attributes.length; index += 1) {
784
+ const attribute = node.attributes[index];
785
+ if (attribute === void 0) continue;
786
+ chunks.push(" ", attribute.qualifiedName, "=\"");
787
+ escapeXmlAttribute(attribute.value, chunks);
788
+ chunks.push("\"");
789
+ }
790
+ const children = node.children;
791
+ if (children.length === 0) {
792
+ chunks.push("/>");
793
+ return;
794
+ }
795
+ chunks.push(">");
796
+ const childIndent = indent !== void 0 && hasOnlyElements(node) ? indent : void 0;
797
+ for (let index = 0; index < children.length; index += 1) {
798
+ const child = children[index];
799
+ if (child !== void 0) writeXmlNode(child, chunks, depth + 1, childIndent);
800
+ }
801
+ if (childIndent !== void 0) chunks.push("\n", childIndent.repeat(depth));
802
+ chunks.push("</", node.qualifiedName, ">");
803
+ };
804
+ /**
805
+ * Writes a tree back to text.
806
+ *
807
+ * A `#document` root, as `parseXml` returns, writes its children; any other root writes itself.
808
+ */
809
+ var serializeXml = (root, options = {}) => {
810
+ const chunks = [];
811
+ if (options.declaration ?? true) chunks.push(XML_DECLARATION);
812
+ const indent = options.indent === true ? options.indentText ?? DEFAULT_INDENT : void 0;
813
+ if (indent !== void 0) chunks.push("\n");
814
+ if (root.qualifiedName === ROOT_NAME) for (let index = 0; index < root.children.length; index += 1) {
815
+ const child = root.children[index];
816
+ if (child !== void 0) writeXmlNode(child, chunks, 0, indent);
817
+ }
818
+ else writeXmlNode(root, chunks, 0, indent);
819
+ return chunks.join("");
820
+ };
821
+ //#endregion
142
822
  //#region src/formats/lrc/common.ts
143
823
  var readLrcContent = (input) => {
144
824
  if (typeof input === "string") return { original: input };
@@ -232,7 +912,7 @@ var detectLrc = (input) => {
232
912
  };
233
913
  //#endregion
234
914
  //#region src/formats/lrc/generator.ts
235
- var resolveOptions = (options = {}) => {
915
+ var resolveOptions$1 = (options = {}) => {
236
916
  return {
237
917
  wordTiming: options.wordTiming ?? "angle",
238
918
  timeFormat: options.timeFormat ?? "mm:ss.SSS",
@@ -315,7 +995,7 @@ var joinBlocks = (header, body) => {
315
995
  * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
316
996
  */
317
997
  var generateLrc = (lyric, options = {}) => {
318
- const resolved = resolveOptions(options);
998
+ const resolved = resolveOptions$1(options);
319
999
  const header = resolved.includeMeta && lyric.meta ? renderMetaRows(lyric.meta, resolved) : [];
320
1000
  const wordLevel = lyric.lines.some((line) => (line.content?.words.length ?? 0) > 0) && resolved.wordTiming !== "none";
321
1001
  const result = { original: joinBlocks(header, lyric.lines.map((line) => {
@@ -328,6 +1008,20 @@ var generateLrc = (lyric, options = {}) => {
328
1008
  return result;
329
1009
  };
330
1010
  //#endregion
1011
+ //#region src/formats/result.ts
1012
+ /**
1013
+ * Canonicalizes and validates one parser's output, so every format hands back the same shape.
1014
+ *
1015
+ * Canonicalization runs first because validation reports paths against the canonical tree.
1016
+ */
1017
+ var finalizeLyric = (input) => {
1018
+ const lyric = canonicalizeLyric(input);
1019
+ return {
1020
+ lyric,
1021
+ diagnostics: validateLyric(lyric)
1022
+ };
1023
+ };
1024
+ //#endregion
331
1025
  //#region src/formats/lrc/parser.ts
332
1026
  var isBareNumber = (value) => {
333
1027
  return /^\d+$/.test(value.trim());
@@ -440,7 +1134,7 @@ var readMetaTags = (tags) => {
440
1134
  * A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.
441
1135
  * Explicit word ends are kept as written, so duration tags survive unchanged.
442
1136
  */
443
- var readWords = (text, lineStart) => {
1137
+ var readWords$1 = (text, lineStart) => {
444
1138
  const drafts = [];
445
1139
  const open = /* @__PURE__ */ new Set();
446
1140
  const closeSegment = (boundary) => {
@@ -491,9 +1185,9 @@ var readWords = (text, lineStart) => {
491
1185
  * A line ends at the next line's start; without one, it ends at its final word's end.
492
1186
  * An open word is closed by the next timed word, or by the line end, so every sung word stays inside the line.
493
1187
  */
494
- var readLine = (text, start, nextStart) => {
1188
+ var readLine$1 = (text, start, nextStart) => {
495
1189
  const wordTimed = isWordTimed(text);
496
- const { drafts, closeSegment } = wordTimed ? readWords(text, start) : {
1190
+ const { drafts, closeSegment } = wordTimed ? readWords$1(text, start) : {
497
1191
  drafts: [],
498
1192
  closeSegment: () => {}
499
1193
  };
@@ -529,7 +1223,7 @@ var readLines = (timed) => {
529
1223
  const lines = [];
530
1224
  for (const [index, item] of timed.entries()) {
531
1225
  const next = timed[index + 1]?.start;
532
- lines.push(readLine(item.text, item.start, next));
1226
+ lines.push(readLine$1(item.text, item.start, next));
533
1227
  }
534
1228
  return lines;
535
1229
  };
@@ -557,23 +1251,1041 @@ var parseLrcText = (text) => {
557
1251
  *
558
1252
  * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
559
1253
  *
560
- * `INVALID` means no readable timed lines; `VALID` does not imply model validation has passed.
1254
+ * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
561
1255
  */
562
1256
  var parseLrc = (input, options = {}) => {
563
1257
  const source = readLrcContent(input) ?? { original: "" };
564
1258
  const lyric = parseLrcText(source.original);
565
- if (lyric.status === LyricStatus.INVALID) return lyric;
1259
+ if (lyric.status === LyricStatus.INVALID) return finalizeLyric(lyric);
566
1260
  const translations = source.translations === void 0 ? [] : readLines(readTimedLines(source.translations).timed);
567
1261
  const romans = source.romans === void 0 ? [] : readLines(readTimedLines(source.romans).timed);
568
- if (translations.length === 0 && romans.length === 0) return lyric;
1262
+ if (translations.length === 0 && romans.length === 0) return finalizeLyric(lyric);
569
1263
  const lines = attachLineAnnotations(lyric.lines, {
570
1264
  translations,
571
1265
  romans
572
1266
  }, { tolerance: options.annotationTolerance });
573
- return {
1267
+ return finalizeLyric({
574
1268
  ...lyric,
575
1269
  lines
1270
+ });
1271
+ };
1272
+ //#endregion
1273
+ //#region src/formats/ttml/common.ts
1274
+ var FORMAT = "ttml";
1275
+ var AMLL_HINT_PATTERN = /xmlns:amll|amll:meta/iu;
1276
+ var ITUNES_HINT_PATTERN = /xmlns:itunes|iTunesMetadata|itunes:timing=/iu;
1277
+ /**
1278
+ * Accepts the text input shared by TTML detection and parsing.
1279
+ */
1280
+ var readTtmlContent = (input) => {
1281
+ if (typeof input !== "string") return null;
1282
+ return input;
1283
+ };
1284
+ //#endregion
1285
+ //#region src/formats/ttml/detect.ts
1286
+ /**
1287
+ * Recognition needs a profile hint, since plain TTML says nothing about which profile wrote it.
1288
+ *
1289
+ * A bare `tt` root also counts, which is what a hand-written document has.
1290
+ * This is a format probe, not a validity check, so an input of another shape is simply not TTML.
1291
+ */
1292
+ var detectTtml = (input) => {
1293
+ const content = readTtmlContent(input);
1294
+ if (!content) return false;
1295
+ if (AMLL_HINT_PATTERN.test(content) || ITUNES_HINT_PATTERN.test(content)) return true;
1296
+ const root = parseXml(content);
1297
+ if (!root) return false;
1298
+ for (const child of root.children) if (child.nodeType === XmlNodeType.element && child.localName === "tt") return true;
1299
+ return false;
1300
+ };
1301
+ //#endregion
1302
+ //#region src/formats/ttml/utils.ts
1303
+ var ROLE_BACKGROUND = "x-bg";
1304
+ var ROLE_TRANSLATION = "x-translation";
1305
+ var ROLE_ROMAN = "x-roman";
1306
+ var RUBY_CONTAINER = "container";
1307
+ var RUBY_BASE = "base";
1308
+ var RUBY_TEXT_CONTAINER = "textContainer";
1309
+ var RUBY_TEXT = "text";
1310
+ var TIMING_WORD = "Word";
1311
+ var TIMING_LINE = "Line";
1312
+ //#endregion
1313
+ //#region src/formats/ttml/generator.ts
1314
+ var NAMESPACE_TTML = "http://www.w3.org/ns/ttml";
1315
+ var NAMESPACE_TTML_METADATA = "http://www.w3.org/ns/ttml#metadata";
1316
+ var NAMESPACE_TTML_STYLING = "http://www.w3.org/ns/ttml#styling";
1317
+ var NAMESPACE_ITUNES = "http://itunes.apple.com/lyric-ttml-extensions";
1318
+ var NAMESPACE_ITUNES_METADATA = "http://music.apple.com/lyric-ttml-internal";
1319
+ var NAMESPACE_AMLL = "http://www.example.com/ns/amll";
1320
+ var ANNOTATION_KINDS = ["translations", "romans"];
1321
+ var XML_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
1322
+ var resolveOptions = (lyric, options) => {
1323
+ const profile = options.profile ?? (lyric.format === "ttml" && lyric.extra.profile === "itunes" ? "itunes" : "amll");
1324
+ if (profile !== "amll" && profile !== "itunes") throw new Error("Unsupported TTML profile");
1325
+ return {
1326
+ profile,
1327
+ includeMeta: options.includeMeta ?? true,
1328
+ indent: options.indent ?? false
1329
+ };
1330
+ };
1331
+ /**
1332
+ * Reserves source ids before allocating keys, so unnamed entries cannot take later source ids.
1333
+ */
1334
+ var makeIdAllocator = (ids) => {
1335
+ const reserved = new Set(ids.filter((id) => {
1336
+ return XML_ID_PATTERN.test(id);
1337
+ }));
1338
+ const used = /* @__PURE__ */ new Set();
1339
+ const counters = /* @__PURE__ */ new Map();
1340
+ return (preferred, prefix) => {
1341
+ if (XML_ID_PATTERN.test(preferred) && !used.has(preferred)) {
1342
+ used.add(preferred);
1343
+ return preferred;
1344
+ }
1345
+ let count = counters.get(prefix) ?? 0;
1346
+ let id;
1347
+ do {
1348
+ count += 1;
1349
+ id = `${prefix}${count}`;
1350
+ } while (reserved.has(id) || used.has(id));
1351
+ counters.set(prefix, count);
1352
+ used.add(id);
1353
+ return id;
1354
+ };
1355
+ };
1356
+ /**
1357
+ * Shares one mapping between declarations, body lines, and annotation references.
1358
+ */
1359
+ var makeContext = (lyric, lines, profile) => {
1360
+ const ids = lyric.agents.map((agent) => {
1361
+ return agent.id;
1362
+ });
1363
+ for (const line of lines) {
1364
+ ids.push(line.id);
1365
+ for (const background of line.backgrounds) ids.push(background.id);
1366
+ }
1367
+ const allocate = makeIdAllocator(ids);
1368
+ const agents = /* @__PURE__ */ new Map();
1369
+ const keys = /* @__PURE__ */ new Map();
1370
+ for (const agent of lyric.agents) {
1371
+ if (agents.has(agent.id)) throw new Error("TTML agent declarations require unique ids");
1372
+ agents.set(agent.id, allocate(agent.id, "v"));
1373
+ }
1374
+ for (const line of lines) {
1375
+ keys.set(line, allocate(line.id, "L"));
1376
+ for (const background of line.backgrounds) keys.set(background, allocate(background.id, "B"));
1377
+ }
1378
+ return {
1379
+ profile,
1380
+ offset: lyric.meta?.offset ?? 0,
1381
+ keys,
1382
+ agents
1383
+ };
1384
+ };
1385
+ /**
1386
+ * Bakes the playback offset into TTML timestamps without clamping negative results.
1387
+ */
1388
+ var renderTime = (time, offset) => {
1389
+ const shifted = removeOffset(time, offset);
1390
+ if (!Number.isInteger(shifted) || shifted < 0) throw new RangeError("TTML timestamps must be non-negative integer milliseconds after applying the offset");
1391
+ const seconds = Math.floor(shifted / 1e3);
1392
+ const minutes = Math.floor(seconds / 60);
1393
+ const hours = String(Math.floor(minutes / 60)).padStart(2, "0");
1394
+ const milliseconds = String(shifted % 1e3).padStart(3, "0");
1395
+ return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${milliseconds}`;
1396
+ };
1397
+ /**
1398
+ * Writes known bounds without inventing an end.
1399
+ */
1400
+ var renderTimeAttributes = (time, offset) => {
1401
+ if (time === void 0) throw new Error("TTML sung lines, words, and ruby tokens require a time range");
1402
+ const attributes = [["begin", renderTime(time.start, offset)]];
1403
+ if (time.end !== void 0) attributes.push(["end", renderTime(time.end, offset)]);
1404
+ return attributes;
1405
+ };
1406
+ /**
1407
+ * Clears inherited language when the model leaves an item's language unspecified.
1408
+ */
1409
+ var renderLanguage = (language, inherited) => {
1410
+ if (language !== void 0) return [["xml:lang", language]];
1411
+ return inherited === void 0 ? [] : [["xml:lang", ""]];
1412
+ };
1413
+ var getLineLanguage = (line) => {
1414
+ return line.languages.length === 1 ? line.languages[0] : void 0;
1415
+ };
1416
+ /**
1417
+ * Writes separators as text and resolves inherited ruby timing through the model.
1418
+ */
1419
+ var renderWord = (word, context, language) => {
1420
+ if (word.type === WordType.SPACE) return createXmlText(word.text);
1421
+ const attributes = [...renderTimeAttributes(word.time, context.offset), ...renderLanguage(word.language, language)];
1422
+ const ruby = context.profile === "amll" ? word.annotation?.rubies[0] : void 0;
1423
+ if (ruby === void 0) return createXmlElement("span", attributes, [createXmlText(word.text)]);
1424
+ const tokens = ruby.tokens.map((token) => {
1425
+ return createXmlElement("span", [["tts:ruby", RUBY_TEXT], ...renderTimeAttributes(resolveAnnotationTokenTime(token, ruby, word), context.offset)], [createXmlText(token.text)]);
1426
+ });
1427
+ return createXmlElement("span", [["tts:ruby", RUBY_CONTAINER], ...attributes], [createXmlElement("span", [["tts:ruby", RUBY_BASE]], [createXmlText(word.text)]), createXmlElement("span", [
1428
+ ["tts:ruby", RUBY_TEXT_CONTAINER],
1429
+ ...renderTimeAttributes(resolveAnnotationItemTime(ruby, word), context.offset),
1430
+ ...renderLanguage(ruby.language, word.language)
1431
+ ], tokens)]);
1432
+ };
1433
+ /**
1434
+ * Retains the model's choice between whole text and timed words.
1435
+ */
1436
+ var renderContent = (line, context) => {
1437
+ const content = line.content;
1438
+ if (content === void 0) throw new Error("TTML sung lines require content");
1439
+ if (content.text !== void 0) return [createXmlText(content.text)];
1440
+ return content.words.map((word) => {
1441
+ return renderWord(word, context, getLineLanguage(line));
1442
+ });
1443
+ };
1444
+ /**
1445
+ * Keeps annotations outside the sung word stream.
1446
+ */
1447
+ var renderInlineAnnotations = (annotation, language) => {
1448
+ const nodes = [];
1449
+ for (const item of annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1450
+ for (const item of annotation?.romans ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_ROMAN], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1451
+ return nodes;
1452
+ };
1453
+ /**
1454
+ * Uses the same allocated keys and agent ids as the document's declarations.
1455
+ */
1456
+ var renderLineAttributes = (line, context) => {
1457
+ const attributes = [
1458
+ ...renderTimeAttributes(line.time, context.offset),
1459
+ ["itunes:key", context.keys.get(line)],
1460
+ ["xml:space", "preserve"],
1461
+ ["xml:lang", getLineLanguage(line) ?? ""]
1462
+ ];
1463
+ const lead = line.agents[0];
1464
+ if (lead !== void 0) {
1465
+ const id = context.agents.get(lead);
1466
+ if (id === void 0) throw new Error("TTML performing agents must have a declaration");
1467
+ attributes.push(["ttm:agent", id]);
1468
+ }
1469
+ return attributes;
1470
+ };
1471
+ /**
1472
+ * An empty text node keeps the XML writer's indentation outside lyric content.
1473
+ */
1474
+ var createLyricElement = (name, attributes, children) => {
1475
+ return createXmlElement(name, attributes, [createXmlText(""), ...children]);
1476
+ };
1477
+ var renderBackground = (line, context) => {
1478
+ const children = renderContent(line, context);
1479
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
1480
+ return createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ...renderLineAttributes(line, context)], children);
1481
+ };
1482
+ var renderLine = (line, context) => {
1483
+ const before = [];
1484
+ const after = [];
1485
+ for (const background of line.backgrounds) {
1486
+ const node = renderBackground(background, context);
1487
+ if (background.time !== void 0 && line.time !== void 0 && background.time.start < line.time.start) before.push(node);
1488
+ else after.push(node);
1489
+ }
1490
+ const children = [
1491
+ ...before,
1492
+ ...renderContent(line, context),
1493
+ ...after
1494
+ ];
1495
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
1496
+ return createLyricElement("p", renderLineAttributes(line, context), children);
1497
+ };
1498
+ var renderAgents = (lyric, context) => {
1499
+ return lyric.agents.map((agent) => {
1500
+ const type = agent.type === AgentType.PERSON ? "person" : agent.type === AgentType.GROUP ? "group" : agent.raw ?? "other";
1501
+ const names = agent.names.map((name) => {
1502
+ return createXmlElement("ttm:name", [["type", "full"], ...renderLanguage(name.language)], [createXmlText(name.text)]);
1503
+ });
1504
+ return createXmlElement("ttm:agent", [["type", type], ["xml:id", context.agents.get(agent.id)]], names);
1505
+ });
1506
+ };
1507
+ var renderAmllMeta = (meta) => {
1508
+ const nodes = [];
1509
+ const groups = [
1510
+ ["musicName", meta.titles],
1511
+ ["artists", meta.artists],
1512
+ ["album", meta.albums],
1513
+ ["ttmlAuthorGithubLogin", meta.authors]
1514
+ ];
1515
+ for (const [key, items] of groups) for (const item of items) nodes.push(createXmlElement("amll:meta", [
1516
+ ["key", key],
1517
+ ["value", item.text],
1518
+ ...renderLanguage(item.language)
1519
+ ]));
1520
+ for (const value of meta.isrcs) nodes.push(createXmlElement("amll:meta", [["key", "isrc"], ["value", value]]));
1521
+ for (const item of meta.unknowns) nodes.push(createXmlElement("amll:meta", [["key", item.key], ["value", item.value]]));
1522
+ return nodes;
1523
+ };
1524
+ var renderSongwriters = (meta) => {
1525
+ const names = [];
1526
+ for (const credit of meta.credits) {
1527
+ if (credit.role !== CreditRole.LYRICIST) continue;
1528
+ for (const name of credit.names) names.push(createXmlElement("songwriter", renderLanguage(name.language), [createXmlText(name.text)]));
1529
+ }
1530
+ return names.length === 0 ? [] : [createXmlElement("songwriters", [], names)];
1531
+ };
1532
+ var renderAnnotationTracks = (tracks, kind, context) => {
1533
+ if (tracks.size === 0) return [];
1534
+ const translated = kind === "translations";
1535
+ const blocks = Array.from(tracks, ([language, track]) => {
1536
+ const attributes = translated ? [["type", "subtitle"]] : [];
1537
+ attributes.push(...renderLanguage(language));
1538
+ const texts = Array.from(track, ([line, children]) => {
1539
+ return createLyricElement("text", [["for", context.keys.get(line)], ["xml:space", "preserve"]], children);
1540
+ });
1541
+ return createXmlElement(translated ? "translation" : "transliteration", attributes, texts);
1542
+ });
1543
+ return [createXmlElement(translated ? "translations" : "transliterations", [], blocks)];
1544
+ };
1545
+ /**
1546
+ * A background-only annotation still references its parent paragraph, with a second reference naming the background.
1547
+ */
1548
+ var renderHeadAnnotations = (lines, context) => {
1549
+ const tracks = {
1550
+ translations: /* @__PURE__ */ new Map(),
1551
+ romans: /* @__PURE__ */ new Map()
576
1552
  };
1553
+ const append = (annotation, line, background) => {
1554
+ if (annotation === void 0) return;
1555
+ for (const kind of ANNOTATION_KINDS) for (const item of annotation[kind]) {
1556
+ const language = item.language?.toLowerCase();
1557
+ let track = tracks[kind].get(language);
1558
+ if (track === void 0) {
1559
+ track = /* @__PURE__ */ new Map();
1560
+ tracks[kind].set(language, track);
1561
+ }
1562
+ let nodes = track.get(line);
1563
+ if (nodes === void 0) {
1564
+ nodes = [];
1565
+ track.set(line, nodes);
1566
+ }
1567
+ nodes.push(background === void 0 ? createXmlText(item.text) : createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ["for", context.keys.get(background)]], [createXmlText(item.text)]));
1568
+ }
1569
+ };
1570
+ for (const line of lines) {
1571
+ append(line.annotation, line);
1572
+ for (const background of line.backgrounds) append(background.annotation, line, background);
1573
+ }
1574
+ return ANNOTATION_KINDS.flatMap((kind) => {
1575
+ return renderAnnotationTracks(tracks[kind], kind, context);
1576
+ });
1577
+ };
1578
+ var renderMetadata = (lyric, lines, context, includeMeta) => {
1579
+ const metadata = renderAgents(lyric, context);
1580
+ const itunes = [];
1581
+ if (includeMeta && lyric.meta !== void 0) {
1582
+ if (context.profile === "amll") metadata.push(...renderAmllMeta(lyric.meta));
1583
+ itunes.push(...renderSongwriters(lyric.meta));
1584
+ }
1585
+ if (context.profile === "itunes") itunes.push(...renderHeadAnnotations(lines, context));
1586
+ if (itunes.length > 0) metadata.push(createXmlElement("iTunesMetadata", [["xmlns", NAMESPACE_ITUNES_METADATA]], itunes));
1587
+ return createXmlElement("metadata", renderLanguage(void 0, lyric.meta?.language), metadata);
1588
+ };
1589
+ var renderRootAttributes = (lyric, profile) => {
1590
+ const attributes = [
1591
+ ["xmlns", NAMESPACE_TTML],
1592
+ ["xmlns:ttm", NAMESPACE_TTML_METADATA],
1593
+ ["xmlns:tts", NAMESPACE_TTML_STYLING],
1594
+ ["xmlns:itunes", NAMESPACE_ITUNES]
1595
+ ];
1596
+ if (profile === "amll") attributes.push(["xmlns:amll", NAMESPACE_AMLL]);
1597
+ attributes.push(...renderLanguage(lyric.meta?.language), ["itunes:timing", lyric.timing === Timing.WORD ? TIMING_WORD : TIMING_LINE]);
1598
+ return attributes;
1599
+ };
1600
+ /**
1601
+ * Generates one TTML document, retaining the source profile unless explicitly overridden.
1602
+ *
1603
+ * Offsets are baked into timestamps; a negative result or a missing required time throws.
1604
+ * Profile mappings retain the lead agent and first AMLL ruby, omitting unsupported fields and instrumental nodes.
1605
+ */
1606
+ var generateTtml = (lyric, options = {}) => {
1607
+ if (lyric.timing !== Timing.LINE && lyric.timing !== Timing.WORD) throw new Error("TTML generation requires line or word timing");
1608
+ const resolved = resolveOptions(lyric, options);
1609
+ const lines = lyric.lines.filter((line) => {
1610
+ return line.type !== LineType.INSTRUMENTAL;
1611
+ });
1612
+ const context = makeContext(lyric, lines, resolved.profile);
1613
+ const metadata = renderMetadata(lyric, lines, context, resolved.includeMeta);
1614
+ const paragraphs = lines.map((line) => {
1615
+ return renderLine(line, context);
1616
+ });
1617
+ return serializeXml(createXmlElement("tt", renderRootAttributes(lyric, resolved.profile), [createXmlElement("head", [], [metadata]), createXmlElement("body", [], [createXmlElement("div", [], paragraphs)])]), { indent: resolved.indent });
1618
+ };
1619
+ //#endregion
1620
+ //#region src/formats/ttml/parser.ts
1621
+ var METADATA_NAMES = [
1622
+ "agent",
1623
+ "songwriter",
1624
+ "meta",
1625
+ "translation",
1626
+ "transliteration"
1627
+ ];
1628
+ var WORD_SEPARATOR_PATTERN = /\p{White_Space}/u;
1629
+ var readTtmlProfile = (source) => {
1630
+ return AMLL_HINT_PATTERN.test(source) ? "amll" : "itunes";
1631
+ };
1632
+ var readSpanTime = (element) => {
1633
+ const begin = getXmlAttribute(element, "begin");
1634
+ if (!begin) return;
1635
+ const start = parseTime(begin);
1636
+ if (start === void 0) return;
1637
+ const end = getXmlAttribute(element, "end");
1638
+ return {
1639
+ start,
1640
+ end: end ? parseTime(end) : void 0
1641
+ };
1642
+ };
1643
+ var readLineTime = (element) => {
1644
+ return readSpanTime(element);
1645
+ };
1646
+ var readAgentType = (raw) => {
1647
+ switch (raw) {
1648
+ case "person": return {
1649
+ type: AgentType.PERSON,
1650
+ raw: void 0
1651
+ };
1652
+ case "group": return {
1653
+ type: AgentType.GROUP,
1654
+ raw: void 0
1655
+ };
1656
+ default: return {
1657
+ type: AgentType.OTHER,
1658
+ raw: raw ?? "other"
1659
+ };
1660
+ }
1661
+ };
1662
+ var readDeclaredTiming = (root) => {
1663
+ switch (getXmlQualifiedAttribute(root, "itunes:timing")) {
1664
+ case TIMING_WORD: return Timing.WORD;
1665
+ case TIMING_LINE: return Timing.LINE;
1666
+ default: return;
1667
+ }
1668
+ };
1669
+ var readPreserveSpace = (element, inherited) => {
1670
+ switch (getXmlQualifiedAttribute(element, "xml:space")) {
1671
+ case "preserve": return true;
1672
+ case "default": return false;
1673
+ default: return inherited;
1674
+ }
1675
+ };
1676
+ var readLanguage = (element, inherited) => {
1677
+ const language = getXmlQualifiedAttribute(element, "xml:lang") ?? getXmlAttribute(element, "lang");
1678
+ return language === void 0 ? inherited : lowerTag(language);
1679
+ };
1680
+ var readText = (element, inherited) => {
1681
+ const text = getXmlTextContent(element);
1682
+ return readPreserveSpace(element, inherited) ? text : text.trim();
1683
+ };
1684
+ /**
1685
+ * Preserves the parser's whitespace segmentation when turning text into word drafts.
1686
+ */
1687
+ var splitWords = (text, language) => {
1688
+ if (text === "") return [];
1689
+ if (!WORD_SEPARATOR_PATTERN.test(text)) return [{
1690
+ text,
1691
+ space: false,
1692
+ language,
1693
+ rubies: []
1694
+ }];
1695
+ const words = [];
1696
+ for (const run of text.split(/(\p{White_Space}+)/u)) if (run !== "") {
1697
+ const space = /^\p{White_Space}+$/u.test(run);
1698
+ words.push({
1699
+ text: run,
1700
+ space,
1701
+ language: space ? void 0 : language,
1702
+ rubies: []
1703
+ });
1704
+ }
1705
+ return words;
1706
+ };
1707
+ /**
1708
+ * Distinguishes indentation runs from separators using the parser's existing rule.
1709
+ */
1710
+ var isFormattingWhitespace = (text) => {
1711
+ return text.trim() === "" && text.includes("\n");
1712
+ };
1713
+ /**
1714
+ * Builds words from source drafts without deriving missing timestamps.
1715
+ */
1716
+ var buildWords = (draft) => {
1717
+ const words = [];
1718
+ for (const word of draft.words) {
1719
+ if (word.space) {
1720
+ words.push(makeWordSpace({ text: word.text }));
1721
+ continue;
1722
+ }
1723
+ const time = word.start === void 0 ? void 0 : makeTimeRange({
1724
+ start: word.start,
1725
+ end: word.end
1726
+ });
1727
+ if (word.rubies.length === 0) {
1728
+ words.push(makeWordNormal({
1729
+ text: word.text,
1730
+ time,
1731
+ language: word.language
1732
+ }));
1733
+ continue;
1734
+ }
1735
+ const rubies = word.rubies.map((ruby) => {
1736
+ return makeWordAnnotationRuby({
1737
+ time: ruby.start === void 0 ? void 0 : makeTimeRange({
1738
+ start: ruby.start,
1739
+ end: ruby.end
1740
+ }),
1741
+ language: ruby.language,
1742
+ tokens: ruby.tokens.map((token) => {
1743
+ return makeWordAnnotationToken({
1744
+ text: token.text,
1745
+ time: token.start === void 0 ? void 0 : makeTimeRange({
1746
+ start: token.start,
1747
+ end: token.end
1748
+ })
1749
+ });
1750
+ })
1751
+ });
1752
+ });
1753
+ words.push(makeWordNormal({
1754
+ text: word.text,
1755
+ time,
1756
+ language: word.language,
1757
+ annotation: makeWordAnnotation({ rubies })
1758
+ }));
1759
+ }
1760
+ return words;
1761
+ };
1762
+ /**
1763
+ * Builds only annotations explicitly present in the source drafts.
1764
+ */
1765
+ var buildLineAnnotation = (draft) => {
1766
+ if (draft.translations.length === 0 && draft.romans.length === 0) return;
1767
+ return makeLineAnnotation({
1768
+ translations: draft.translations.map((item) => {
1769
+ return makeLineAnnotationTranslation({
1770
+ text: item.text,
1771
+ language: item.language
1772
+ });
1773
+ }),
1774
+ romans: draft.romans.map((item) => {
1775
+ return makeLineAnnotationRoman({
1776
+ text: item.text,
1777
+ language: item.language
1778
+ });
1779
+ })
1780
+ });
1781
+ };
1782
+ /**
1783
+ * Separates head annotation text from its background references.
1784
+ */
1785
+ var splitBackgroundText = (element, preserveSpace, language) => {
1786
+ preserveSpace = readPreserveSpace(element, preserveSpace);
1787
+ language = readLanguage(element, language);
1788
+ let main = "";
1789
+ const backgrounds = [];
1790
+ for (const child of element.children) {
1791
+ if (child.nodeType === XmlNodeType.element && child.localName === "span" && getXmlAttribute(child, "role") === "x-bg") {
1792
+ backgrounds.push({
1793
+ text: readText(child, preserveSpace),
1794
+ key: getXmlAttribute(child, "for"),
1795
+ language: readLanguage(child, language)
1796
+ });
1797
+ continue;
1798
+ }
1799
+ main += getXmlTextContent(child);
1800
+ }
1801
+ return {
1802
+ main: preserveSpace ? main : main.trim(),
1803
+ backgrounds
1804
+ };
1805
+ };
1806
+ /**
1807
+ * Carries language and whitespace declarations through wrappers to the elements they govern.
1808
+ */
1809
+ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language) => {
1810
+ const wanted = new Set(names);
1811
+ const result = /* @__PURE__ */ new Map();
1812
+ const stack = [{
1813
+ element: root,
1814
+ preserveSpace,
1815
+ language
1816
+ }];
1817
+ while (stack.length > 0) {
1818
+ const entry = stack.pop();
1819
+ if (entry === void 0) continue;
1820
+ const element = entry.element;
1821
+ const currentSpace = readPreserveSpace(element, entry.preserveSpace);
1822
+ const currentLanguage = readLanguage(element, entry.language);
1823
+ if (wanted.has(element.localName)) {
1824
+ const group = result.get(element.localName);
1825
+ const context = {
1826
+ element,
1827
+ preserveSpace: currentSpace,
1828
+ language: currentLanguage
1829
+ };
1830
+ if (group === void 0) result.set(element.localName, [context]);
1831
+ else group.push(context);
1832
+ if (stopAtMatch) continue;
1833
+ }
1834
+ const children = element.children;
1835
+ for (let index = children.length - 1; index >= 0; index -= 1) {
1836
+ const child = children[index];
1837
+ if (child && child.nodeType === XmlNodeType.element) stack.push({
1838
+ element: child,
1839
+ preserveSpace: currentSpace,
1840
+ language: currentLanguage
1841
+ });
1842
+ }
1843
+ }
1844
+ return result;
1845
+ };
1846
+ var findElements = (root, name, preserveSpace, stopAtMatch = false, language) => {
1847
+ return groupElements(root, [name], preserveSpace, stopAtMatch, language).get(name) ?? [];
1848
+ };
1849
+ /**
1850
+ * Reads an AMLL ruby container into one word plus its per-syllable annotation.
1851
+ */
1852
+ var readRuby = (container, preserveSpace, language) => {
1853
+ preserveSpace = readPreserveSpace(container, preserveSpace);
1854
+ language = readLanguage(container, language);
1855
+ let base = "";
1856
+ let baseLanguage = language;
1857
+ let rubyTime;
1858
+ let rubyLanguage = language;
1859
+ const tokens = [];
1860
+ for (const child of getXmlChildElements(container, "span")) {
1861
+ const kind = getXmlAttribute(child, "ruby");
1862
+ if (kind === "base") {
1863
+ base = readText(child, preserveSpace);
1864
+ baseLanguage = readLanguage(child, language);
1865
+ continue;
1866
+ }
1867
+ if (kind !== "textContainer") continue;
1868
+ rubyTime ??= readSpanTime(child);
1869
+ rubyLanguage = readLanguage(child, language);
1870
+ const rubySpace = readPreserveSpace(child, preserveSpace);
1871
+ for (const text of getXmlChildElements(child, "span")) {
1872
+ if (getXmlAttribute(text, "ruby") !== "text") continue;
1873
+ const time = readSpanTime(text);
1874
+ const content = readText(text, rubySpace);
1875
+ if (!content) continue;
1876
+ tokens.push({
1877
+ start: time?.start,
1878
+ end: time?.end,
1879
+ text: content
1880
+ });
1881
+ }
1882
+ }
1883
+ if (!base) return;
1884
+ const first = tokens.find((token) => {
1885
+ return token.start !== void 0;
1886
+ });
1887
+ const last = tokens.findLast((token) => {
1888
+ return token.start !== void 0;
1889
+ });
1890
+ const time = readSpanTime(container) ?? rubyTime ?? (first === void 0 ? void 0 : {
1891
+ start: first.start,
1892
+ end: last?.end
1893
+ });
1894
+ return {
1895
+ text: base,
1896
+ space: false,
1897
+ start: time?.start,
1898
+ end: time?.end,
1899
+ language: baseLanguage,
1900
+ rubies: tokens.length === 0 ? [] : [{
1901
+ start: rubyTime?.start,
1902
+ end: rubyTime?.end,
1903
+ language: rubyLanguage,
1904
+ tokens
1905
+ }]
1906
+ };
1907
+ };
1908
+ /**
1909
+ * Collects the words of one span-bearing element.
1910
+ *
1911
+ * Text between spans carries the separators, and a span without a readable begin contributes its text untimed.
1912
+ * A span carrying a role is annotation rather than a word, so it is skipped here.
1913
+ * Whitespace inside a span remains a separator; indentation is dropped only without a preserve declaration.
1914
+ */
1915
+ var readWords = (element, preserveSpace, language) => {
1916
+ preserveSpace = readPreserveSpace(element, preserveSpace);
1917
+ language = readLanguage(element, language);
1918
+ const words = [];
1919
+ let wordLevel = false;
1920
+ for (const child of element.children) {
1921
+ if (child.nodeType !== XmlNodeType.element) {
1922
+ if (preserveSpace || !isFormattingWhitespace(child.text)) words.push(...splitWords(child.text, language));
1923
+ continue;
1924
+ }
1925
+ if (child.localName !== "span") continue;
1926
+ if (getXmlAttribute(child, "role") !== void 0) continue;
1927
+ if (getXmlAttribute(child, "ruby") === "container") {
1928
+ const ruby = readRuby(child, preserveSpace, language);
1929
+ if (ruby !== void 0) {
1930
+ for (const part of splitWords(ruby.text)) words.push(part.space ? part : {
1931
+ ...ruby,
1932
+ text: part.text
1933
+ });
1934
+ wordLevel ||= ruby.start !== void 0;
1935
+ }
1936
+ continue;
1937
+ }
1938
+ const text = getXmlTextContent(child);
1939
+ const childSpace = readPreserveSpace(child, preserveSpace);
1940
+ if (!text || !childSpace && isFormattingWhitespace(text)) continue;
1941
+ const time = readSpanTime(child);
1942
+ for (const part of splitWords(text, readLanguage(child, language))) {
1943
+ if (!part.space && time !== void 0) {
1944
+ part.start = time.start;
1945
+ part.end = time.end;
1946
+ wordLevel = true;
1947
+ }
1948
+ words.push(part);
1949
+ }
1950
+ }
1951
+ return {
1952
+ words,
1953
+ wordLevel
1954
+ };
1955
+ };
1956
+ var readInlineAnnotations = (element, preserveSpace, language) => {
1957
+ const translations = [];
1958
+ const romans = [];
1959
+ for (const span of getXmlChildElements(element, "span")) {
1960
+ const role = getXmlAttribute(span, "role");
1961
+ if (role !== "x-translation" && role !== "x-roman") continue;
1962
+ const text = readText(span, preserveSpace);
1963
+ if (!text) continue;
1964
+ const item = {
1965
+ language: readLanguage(span, language),
1966
+ text
1967
+ };
1968
+ if (role === "x-translation") translations.push(item);
1969
+ else romans.push(item);
1970
+ }
1971
+ return {
1972
+ translations,
1973
+ romans
1974
+ };
1975
+ };
1976
+ /**
1977
+ * Reads one paragraph, pulling its inline annotations and background lines out of the word stream.
1978
+ */
1979
+ var readLine = (element, preserveSpace, language) => {
1980
+ preserveSpace = readPreserveSpace(element, preserveSpace);
1981
+ language = readLanguage(element, language);
1982
+ const time = readLineTime(element);
1983
+ if (time === void 0) return;
1984
+ const draft = {
1985
+ start: time.start,
1986
+ end: time.end,
1987
+ agentId: getXmlAttribute(element, "agent"),
1988
+ key: getXmlAttribute(element, "key"),
1989
+ words: [],
1990
+ translations: [],
1991
+ romans: [],
1992
+ backgrounds: [],
1993
+ wordLevel: false,
1994
+ preserveSpace,
1995
+ language
1996
+ };
1997
+ if (!hasXmlChildElement(element, "span")) {
1998
+ const plain = readText(element, preserveSpace);
1999
+ if (!plain) return;
2000
+ draft.words = splitWords(plain, language);
2001
+ return draft;
2002
+ }
2003
+ const read = readWords(element, preserveSpace, language);
2004
+ draft.words = read.words;
2005
+ draft.wordLevel = read.wordLevel;
2006
+ const annotations = readInlineAnnotations(element, preserveSpace, language);
2007
+ draft.translations = annotations.translations;
2008
+ draft.romans = annotations.romans;
2009
+ for (const span of getXmlChildElements(element, "span")) if (getXmlAttribute(span, "role") === "x-bg") {
2010
+ const background = readBackground(span, preserveSpace, language);
2011
+ if (background) draft.backgrounds.push(background);
2012
+ }
2013
+ return draft;
2014
+ };
2015
+ /**
2016
+ * Reads a background line, whose end may stay open because its parent's end covers it.
2017
+ *
2018
+ * A background line states no time of its own in AMLL files, so its range comes from its words.
2019
+ */
2020
+ var readBackground = (element, preserveSpace, language) => {
2021
+ preserveSpace = readPreserveSpace(element, preserveSpace);
2022
+ language = readLanguage(element, language);
2023
+ const read = readWords(element, preserveSpace, language);
2024
+ if (read.words.length === 0) return;
2025
+ const begin = getXmlAttribute(element, "begin");
2026
+ const end = getXmlAttribute(element, "end");
2027
+ const first = read.words.find((word) => {
2028
+ return !word.space && word.start !== void 0;
2029
+ });
2030
+ const last = read.words.findLast((word) => {
2031
+ return !word.space && word.start !== void 0;
2032
+ });
2033
+ const start = begin === void 0 ? first?.start ?? 0 : parseTime(begin);
2034
+ if (start === void 0) return;
2035
+ return {
2036
+ start,
2037
+ end: end === void 0 ? last?.end : parseTime(end),
2038
+ agentId: getXmlAttribute(element, "agent"),
2039
+ key: getXmlAttribute(element, "key"),
2040
+ words: read.words,
2041
+ ...readInlineAnnotations(element, preserveSpace, language),
2042
+ backgrounds: [],
2043
+ wordLevel: read.wordLevel,
2044
+ preserveSpace,
2045
+ language
2046
+ };
2047
+ };
2048
+ var buildContent = (draft) => {
2049
+ if (draft.wordLevel) return makeLineContentWords(buildWords(draft));
2050
+ const text = draft.words.map((word) => {
2051
+ return word.text;
2052
+ }).join("");
2053
+ return makeLineContentText(draft.preserveSpace ? text : text.trim());
2054
+ };
2055
+ /**
2056
+ * Builds one background line.
2057
+ */
2058
+ var buildBackground = (draft) => {
2059
+ return makeLineBackground({
2060
+ id: draft.key,
2061
+ time: makeTimeRange({
2062
+ start: draft.start,
2063
+ end: draft.end
2064
+ }),
2065
+ agents: draft.agentId === void 0 ? [] : [draft.agentId],
2066
+ languages: draft.language === void 0 ? [] : [draft.language],
2067
+ content: buildContent(draft),
2068
+ annotation: buildLineAnnotation(draft)
2069
+ });
2070
+ };
2071
+ /**
2072
+ * Builds one normal line, keeping plain text whole unless the source timed its words.
2073
+ */
2074
+ var buildLine = (draft) => {
2075
+ return makeLineNormal({
2076
+ id: draft.key,
2077
+ time: makeTimeRange({
2078
+ start: draft.start,
2079
+ end: draft.end
2080
+ }),
2081
+ agents: draft.agentId === void 0 ? [] : [draft.agentId],
2082
+ languages: draft.language === void 0 ? [] : [draft.language],
2083
+ content: buildContent(draft),
2084
+ annotation: buildLineAnnotation(draft),
2085
+ backgrounds: draft.backgrounds.map(buildBackground)
2086
+ });
2087
+ };
2088
+ /**
2089
+ * Reads the head's agents, which lines reference by id.
2090
+ */
2091
+ var readAgents = (elements) => {
2092
+ const agents = [];
2093
+ for (const { element, preserveSpace, language } of elements) {
2094
+ const id = getXmlQualifiedAttribute(element, "xml:id") ?? getXmlAttribute(element, "id");
2095
+ if (!id) continue;
2096
+ const names = getXmlChildElements(element, "name").map((name) => {
2097
+ return makeText({
2098
+ text: readText(name, preserveSpace),
2099
+ language: readLanguage(name, language)
2100
+ });
2101
+ }).filter((name) => {
2102
+ return name.text !== "";
2103
+ });
2104
+ const kind = readAgentType(getXmlAttribute(element, "type"));
2105
+ agents.push(makeAgent({
2106
+ id,
2107
+ type: kind.type,
2108
+ raw: kind.raw,
2109
+ names
2110
+ }));
2111
+ }
2112
+ return agents;
2113
+ };
2114
+ /**
2115
+ * Reads the head's songwriters as one lyricist credit, since TTML does not split the role further.
2116
+ *
2117
+ * AMLL wraps these in `iTunesMetadata`, so the whole head is searched rather than its direct children.
2118
+ */
2119
+ var readCredits = (elements, meta) => {
2120
+ const names = elements.map(({ element, preserveSpace, language }) => {
2121
+ return makeText({
2122
+ text: readText(element, preserveSpace),
2123
+ language
2124
+ });
2125
+ }).filter((name) => {
2126
+ return name.text !== "";
2127
+ });
2128
+ if (names.length === 0) return;
2129
+ meta.credits.push(makeMetaCredit({
2130
+ role: CreditRole.LYRICIST,
2131
+ names
2132
+ }));
2133
+ };
2134
+ /**
2135
+ * Reads one `amll:meta` entry, keeping an unknown key as written so a round trip loses nothing.
2136
+ */
2137
+ var applyAmllMeta = (meta, element, language) => {
2138
+ const key = getXmlAttribute(element, "key");
2139
+ const value = getXmlAttribute(element, "value")?.trim();
2140
+ if (!key || !value) return;
2141
+ language = readLanguage(element, language);
2142
+ switch (key) {
2143
+ case "musicName":
2144
+ meta.titles.push(makeText({
2145
+ text: value,
2146
+ language
2147
+ }));
2148
+ return;
2149
+ case "artists":
2150
+ meta.artists.push(makeText({
2151
+ text: value,
2152
+ language
2153
+ }));
2154
+ return;
2155
+ case "album":
2156
+ meta.albums.push(makeText({
2157
+ text: value,
2158
+ language
2159
+ }));
2160
+ return;
2161
+ case "isrc":
2162
+ meta.isrcs.push(value);
2163
+ return;
2164
+ case "ttmlAuthorGithubLogin":
2165
+ meta.authors.push(makeText({
2166
+ text: value,
2167
+ language
2168
+ }));
2169
+ return;
2170
+ default: meta.unknowns.push(makeUnknownEntry({
2171
+ key,
2172
+ value
2173
+ }));
2174
+ }
2175
+ };
2176
+ /**
2177
+ * Attaches head translations and transliterations to the lines they name by `itunes:key`.
2178
+ *
2179
+ * A replacement translation rewrites the line's text, which is how a source swaps one script for another.
2180
+ */
2181
+ var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
2182
+ if (lineMap.size === 0) return;
2183
+ const attach = (blocks, kind) => {
2184
+ for (const { element: block, preserveSpace, language } of blocks) {
2185
+ const type = getXmlQualifiedAttribute(block, "type") ?? getXmlAttribute(block, "type");
2186
+ for (const text of getXmlChildElements(block, "text")) {
2187
+ const key = getXmlAttribute(text, "for");
2188
+ const line = key ? lineMap.get(key) : void 0;
2189
+ if (!line) continue;
2190
+ const textLanguage = readLanguage(text, language);
2191
+ const { main, backgrounds } = splitBackgroundText(text, preserveSpace, textLanguage);
2192
+ if (kind === "translation" && type === "replacement") {
2193
+ if (main) {
2194
+ line.words = splitWords(main, textLanguage);
2195
+ line.wordLevel = false;
2196
+ line.preserveSpace = readPreserveSpace(text, preserveSpace);
2197
+ line.language = textLanguage;
2198
+ }
2199
+ continue;
2200
+ }
2201
+ if (main) {
2202
+ const item = {
2203
+ language: textLanguage,
2204
+ text: main
2205
+ };
2206
+ if (kind === "translation") line.translations.push(item);
2207
+ else line.romans.push(item);
2208
+ }
2209
+ for (const background of backgrounds) {
2210
+ if (!background.text) continue;
2211
+ const target = background.key ? backgroundMap.get(background.key) : void 0;
2212
+ if (!target) continue;
2213
+ const item = {
2214
+ language: background.language,
2215
+ text: background.text
2216
+ };
2217
+ if (kind === "translation") target.translations.push(item);
2218
+ else target.romans.push(item);
2219
+ }
2220
+ }
2221
+ }
2222
+ };
2223
+ attach(metadata.get("translation") ?? [], "translation");
2224
+ attach(metadata.get("transliteration") ?? [], "roman");
2225
+ };
2226
+ /**
2227
+ * Reads a TTML document into the model.
2228
+ *
2229
+ * The profile only changes where metadata comes from, so one reader serves both.
2230
+ * A document that parses but holds no timed paragraph is `INVALID` rather than an error.
2231
+ */
2232
+ var parseTtml = (input, options = {}) => {
2233
+ const content = readTtmlContent(input);
2234
+ if (!content) return finalizeLyric(makeLyric({
2235
+ format: FORMAT,
2236
+ status: LyricStatus.INVALID,
2237
+ meta: makeMeta()
2238
+ }));
2239
+ const extra = { profile: readTtmlProfile(content) };
2240
+ const document = parseXml(content);
2241
+ const root = document ? findXmlElement(document, "tt") : void 0;
2242
+ if (!root) return finalizeLyric(makeLyric({
2243
+ format: FORMAT,
2244
+ status: LyricStatus.INVALID,
2245
+ meta: makeMeta(),
2246
+ extra
2247
+ }));
2248
+ const language = readLanguage(root, lowerTag(options.language));
2249
+ const body = findElements(root, "body", false, true, language)[0];
2250
+ const metadata = findElements(root, "metadata", false, true, language)[0];
2251
+ const metadataElements = metadata === void 0 ? /* @__PURE__ */ new Map() : groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language);
2252
+ const meta = makeMeta();
2253
+ if (language !== void 0) meta.language = language;
2254
+ readCredits(metadataElements.get("songwriter") ?? [], meta);
2255
+ for (const { element, language: itemLanguage } of metadataElements.get("meta") ?? []) applyAmllMeta(meta, element, itemLanguage);
2256
+ const drafts = [];
2257
+ const lineMap = /* @__PURE__ */ new Map();
2258
+ const backgroundMap = /* @__PURE__ */ new Map();
2259
+ for (const { element, preserveSpace, language: lineLanguage } of body ? findElements(body.element, "p", body.preserveSpace, true, body.language) : []) {
2260
+ const draft = readLine(element, preserveSpace, lineLanguage);
2261
+ if (!draft) continue;
2262
+ drafts.push(draft);
2263
+ if (draft.key) lineMap.set(draft.key, draft);
2264
+ for (const background of draft.backgrounds) if (background.key) backgroundMap.set(background.key, background);
2265
+ }
2266
+ attachHeadAnnotations(metadataElements, lineMap, backgroundMap);
2267
+ if (drafts.length === 0) return finalizeLyric(makeLyric({
2268
+ format: FORMAT,
2269
+ status: LyricStatus.INVALID,
2270
+ meta,
2271
+ extra
2272
+ }));
2273
+ const lines = drafts.map(buildLine);
2274
+ const wordLevel = drafts.some((draft) => {
2275
+ return draft.wordLevel || draft.backgrounds.some((background) => {
2276
+ return background.wordLevel;
2277
+ });
2278
+ });
2279
+ return finalizeLyric(makeLyric({
2280
+ format: FORMAT,
2281
+ status: LyricStatus.VALID,
2282
+ timing: readDeclaredTiming(root) ?? (wordLevel ? Timing.WORD : Timing.LINE),
2283
+ meta,
2284
+ extra,
2285
+ agents: readAgents(metadataElements.get("agent") ?? []),
2286
+ languages: deriveLanguageUsages(lines),
2287
+ lines
2288
+ }));
577
2289
  };
578
2290
  //#endregion
579
2291
  //#region src/formats/index.ts
@@ -584,9 +2296,10 @@ var parseLrc = (input, options = {}) => {
584
2296
  */
585
2297
  var detectFormat = (input) => {
586
2298
  if (detectLrc(input)) return "lrc";
2299
+ if (detectTtml(input)) return "ttml";
587
2300
  };
588
2301
  /**
589
- * Unrecognized text returns `null`; a recognized format returns a lyric with its parser-assigned status.
2302
+ * Unrecognized text returns `null`; a recognized format returns its canonical lyric and diagnostics.
590
2303
  * Annotation tracks travel with the source and are attached by the format's parser.
591
2304
  */
592
2305
  var parse = (input, options = {}) => {
@@ -594,17 +2307,24 @@ var parse = (input, options = {}) => {
594
2307
  if (!format) return null;
595
2308
  switch (format) {
596
2309
  case "lrc": return parseLrc(input, options);
2310
+ case "ttml": return parseTtml(input, options);
597
2311
  }
598
2312
  };
2313
+ var GENERATORS = {
2314
+ lrc: generateLrc,
2315
+ ttml: generateTtml
2316
+ };
599
2317
  /**
600
2318
  * Generates text in the requested format, which need not match `lyric.format`.
2319
+ *
2320
+ * The writer map pairs each format's options with its result and requires a writer for every supported format.
2321
+ * An unknown target returns `null`.
601
2322
  */
602
2323
  var generate = (lyric, target, options = {}) => {
603
- switch (target) {
604
- case "lrc": return generateLrc(lyric, options);
605
- }
2324
+ if (!Object.hasOwn(GENERATORS, target)) return null;
2325
+ return GENERATORS[target](lyric, options);
606
2326
  };
607
2327
  //#endregion
608
- export { detectFormat, detectLrc, generate, generateLrc, parse, parseLrc };
2328
+ export { detectFormat, detectLrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseTtml };
609
2329
 
610
2330
  //# sourceMappingURL=formats.js.map