@gondola-lyrics/kit 0.2.0 → 0.4.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 { 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";
1
+ import { AgentType, CreditRole, LineType, LyricStatus, Timing, WordType, canonicalizeLyric, deriveLanguageUsages, getWordsText, makeAgent, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineBackground, makeLineContentText, makeLineContentWords, makeLineInstrumental, 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
  })
@@ -233,6 +233,32 @@ var createXmlAttribute = (qualifiedName, value) => {
233
233
  return attribute;
234
234
  };
235
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
+ /**
236
262
  * Returns the value of one attribute, matched by its local name so the prefix never has to be known.
237
263
  */
238
264
  var getXmlAttribute = (element, localName) => {
@@ -291,27 +317,13 @@ var findXmlElement = (element, localName) => {
291
317
  }
292
318
  };
293
319
  /**
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
320
  * Joins the text and CDATA content of a node and its descendants, in document order.
312
321
  */
313
322
  var getXmlTextContent = (node) => {
314
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;
315
327
  const parts = [];
316
328
  const stack = [node];
317
329
  while (stack.length > 0) {
@@ -330,21 +342,21 @@ var getXmlTextContent = (node) => {
330
342
  };
331
343
  //#endregion
332
344
  //#region src/utils/xml/reader.ts
333
- var LESS_THAN = 60;
334
- var GREATER_THAN = 62;
345
+ var LESS_THAN$1 = 60;
346
+ var GREATER_THAN$1 = 62;
335
347
  var SLASH = 47;
336
348
  var EQUALS = 61;
337
- var DOUBLE_QUOTE = 34;
349
+ var DOUBLE_QUOTE$1 = 34;
338
350
  var SINGLE_QUOTE = 39;
339
351
  var EXCLAMATION = 33;
340
352
  var QUESTION = 63;
341
- var AMPERSAND = 38;
353
+ var AMPERSAND$1 = 38;
342
354
  var HASH = 35;
343
355
  var HYPHEN = 45;
344
356
  var LOWER_X = 120;
345
357
  var UPPER_X = 88;
346
358
  var BYTE_ORDER_MARK = 65279;
347
- var ROOT_NAME = "#document";
359
+ var ROOT_NAME$1 = "#document";
348
360
  var DECIMAL_DIGITS = /^\d+$/;
349
361
  var HEX_DIGITS = /^[0-9a-fA-F]+$/;
350
362
  /**
@@ -375,7 +387,7 @@ var decodeText = (text) => {
375
387
  let start = 0;
376
388
  let cursor = 0;
377
389
  while (cursor < length) {
378
- if (text.charCodeAt(cursor) !== AMPERSAND) {
390
+ if (text.charCodeAt(cursor) !== AMPERSAND$1) {
379
391
  cursor += 1;
380
392
  continue;
381
393
  }
@@ -402,9 +414,9 @@ var parseXml = (source) => {
402
414
  const stack = [];
403
415
  const root = {
404
416
  nodeType: XmlNodeType.element,
405
- qualifiedName: ROOT_NAME,
417
+ qualifiedName: ROOT_NAME$1,
406
418
  namespacePrefix: "",
407
- localName: ROOT_NAME,
419
+ localName: ROOT_NAME$1,
408
420
  attributes: [],
409
421
  children: []
410
422
  };
@@ -432,12 +444,12 @@ var parseXml = (source) => {
432
444
  */
433
445
  const readQuotedValue = () => {
434
446
  const quote = source.charCodeAt(position);
435
- if (quote !== DOUBLE_QUOTE && quote !== SINGLE_QUOTE) {
447
+ if (quote !== DOUBLE_QUOTE$1 && quote !== SINGLE_QUOTE) {
436
448
  valid = false;
437
449
  return "";
438
450
  }
439
451
  position += 1;
440
- const end = source.indexOf(quote === DOUBLE_QUOTE ? "\"" : "'", position);
452
+ const end = source.indexOf(quote === DOUBLE_QUOTE$1 ? "\"" : "'", position);
441
453
  if (end === -1) {
442
454
  valid = false;
443
455
  return "";
@@ -454,7 +466,7 @@ var parseXml = (source) => {
454
466
  const nameStart = cursor;
455
467
  while (cursor < length) {
456
468
  const code = source.charCodeAt(cursor);
457
- if (code <= 32 || code === GREATER_THAN || code === SLASH || code === EQUALS) break;
469
+ if (code <= 32 || code === GREATER_THAN$1 || code === SLASH || code === EQUALS) break;
458
470
  cursor += 1;
459
471
  }
460
472
  if (cursor === nameStart) {
@@ -482,14 +494,14 @@ var parseXml = (source) => {
482
494
  code = source.charCodeAt(cursor);
483
495
  }
484
496
  if (cursor >= length) break;
485
- if (code === GREATER_THAN) {
497
+ if (code === GREATER_THAN$1) {
486
498
  cursor += 1;
487
499
  closed = true;
488
500
  break;
489
501
  }
490
502
  if (code === SLASH) {
491
503
  cursor += 1;
492
- if (cursor < length && source.charCodeAt(cursor) === GREATER_THAN) {
504
+ if (cursor < length && source.charCodeAt(cursor) === GREATER_THAN$1) {
493
505
  cursor += 1;
494
506
  closed = true;
495
507
  selfClosing = true;
@@ -503,7 +515,7 @@ var parseXml = (source) => {
503
515
  cursor += 1;
504
516
  if (cursor >= length) break;
505
517
  code = source.charCodeAt(cursor);
506
- } while (code > 32 && code !== GREATER_THAN && code !== SLASH && code !== EQUALS);
518
+ } while (code > 32 && code !== GREATER_THAN$1 && code !== SLASH && code !== EQUALS);
507
519
  if (cursor === attributeStart) {
508
520
  valid = false;
509
521
  return;
@@ -547,7 +559,7 @@ var parseXml = (source) => {
547
559
  const nameStart = cursor;
548
560
  while (cursor < length) {
549
561
  const code = source.charCodeAt(cursor);
550
- if (code <= 32 || code === GREATER_THAN) break;
562
+ if (code <= 32 || code === GREATER_THAN$1) break;
551
563
  cursor += 1;
552
564
  }
553
565
  const name = source.slice(nameStart, cursor);
@@ -604,8 +616,8 @@ var parseXml = (source) => {
604
616
  let depth = 1;
605
617
  while (cursor < length && depth > 0) {
606
618
  const code = source.charCodeAt(cursor);
607
- if (code === LESS_THAN) depth += 1;
608
- else if (code === GREATER_THAN) depth -= 1;
619
+ if (code === LESS_THAN$1) depth += 1;
620
+ else if (code === GREATER_THAN$1) depth -= 1;
609
621
  cursor += 1;
610
622
  }
611
623
  if (depth > 0) {
@@ -654,6 +666,159 @@ var parseXml = (source) => {
654
666
  return null;
655
667
  };
656
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
657
822
  //#region src/formats/lrc/common.ts
658
823
  var readLrcContent = (input) => {
659
824
  if (typeof input === "string") return { original: input };
@@ -747,7 +912,7 @@ var detectLrc = (input) => {
747
912
  };
748
913
  //#endregion
749
914
  //#region src/formats/lrc/generator.ts
750
- var resolveOptions = (options = {}) => {
915
+ var resolveOptions$1 = (options = {}) => {
751
916
  return {
752
917
  wordTiming: options.wordTiming ?? "angle",
753
918
  timeFormat: options.timeFormat ?? "mm:ss.SSS",
@@ -830,7 +995,7 @@ var joinBlocks = (header, body) => {
830
995
  * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
831
996
  */
832
997
  var generateLrc = (lyric, options = {}) => {
833
- const resolved = resolveOptions(options);
998
+ const resolved = resolveOptions$1(options);
834
999
  const header = resolved.includeMeta && lyric.meta ? renderMetaRows(lyric.meta, resolved) : [];
835
1000
  const wordLevel = lyric.lines.some((line) => (line.content?.words.length ?? 0) > 0) && resolved.wordTiming !== "none";
836
1001
  const result = { original: joinBlocks(header, lyric.lines.map((line) => {
@@ -1019,14 +1184,20 @@ var readWords$1 = (text, lineStart) => {
1019
1184
  /**
1020
1185
  * A line ends at the next line's start; without one, it ends at its final word's end.
1021
1186
  * An open word is closed by the next timed word, or by the line end, so every sung word stays inside the line.
1187
+ * Empty timed rows represent instrumental time rather than sung content.
1022
1188
  */
1023
1189
  var readLine$1 = (text, start, nextStart) => {
1190
+ const trimmed = text.trim();
1191
+ let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
1192
+ if (trimmed === "") return makeLineInstrumental({ time: makeTimeRange({
1193
+ start,
1194
+ end
1195
+ }) });
1024
1196
  const wordTimed = isWordTimed(text);
1025
1197
  const { drafts, closeSegment } = wordTimed ? readWords$1(text, start) : {
1026
1198
  drafts: [],
1027
1199
  closeSegment: () => {}
1028
1200
  };
1029
- let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
1030
1201
  if (end === void 0) end = [...drafts].reverse().find((draft) => !draft.space && draft.start !== void 0)?.end;
1031
1202
  closeSegment(end);
1032
1203
  const words = drafts.map((draft) => {
@@ -1044,7 +1215,7 @@ var readLine$1 = (text, start, nextStart) => {
1044
1215
  start,
1045
1216
  end
1046
1217
  });
1047
- const content = wordTimed ? makeLineContentWords(words) : makeLineContentText(text.trim());
1218
+ const content = wordTimed ? makeLineContentWords(words) : makeLineContentText(trimmed);
1048
1219
  return makeLineNormal({
1049
1220
  time,
1050
1221
  content
@@ -1052,7 +1223,7 @@ var readLine$1 = (text, start, nextStart) => {
1052
1223
  };
1053
1224
  /**
1054
1225
  * Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.
1055
- * An empty timed row still yields a line, since it marks a gap rather than a parse failure.
1226
+ * Empty timed rows become instrumental lines bounded by the next timestamp when one is usable.
1056
1227
  */
1057
1228
  var readLines = (timed) => {
1058
1229
  const lines = [];
@@ -1082,7 +1253,7 @@ var parseLrcText = (text) => {
1082
1253
  });
1083
1254
  };
1084
1255
  /**
1085
- * Source order, empty timed lines, and missing word times are preserved for model validation.
1256
+ * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
1086
1257
  *
1087
1258
  * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
1088
1259
  *
@@ -1109,15 +1280,12 @@ var parseLrc = (input, options = {}) => {
1109
1280
  var FORMAT = "ttml";
1110
1281
  var AMLL_HINT_PATTERN = /xmlns:amll|amll:meta/iu;
1111
1282
  var ITUNES_HINT_PATTERN = /xmlns:itunes|iTunesMetadata|itunes:timing=/iu;
1283
+ /**
1284
+ * Accepts the text input shared by TTML detection and parsing.
1285
+ */
1112
1286
  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";
1287
+ if (typeof input !== "string") return null;
1288
+ return input;
1121
1289
  };
1122
1290
  //#endregion
1123
1291
  //#region src/formats/ttml/detect.ts
@@ -1130,35 +1298,344 @@ var readTtmlProfile = (source) => {
1130
1298
  var detectTtml = (input) => {
1131
1299
  const content = readTtmlContent(input);
1132
1300
  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);
1301
+ if (AMLL_HINT_PATTERN.test(content) || ITUNES_HINT_PATTERN.test(content)) return true;
1302
+ const root = parseXml(content);
1135
1303
  if (!root) return false;
1136
1304
  for (const child of root.children) if (child.nodeType === XmlNodeType.element && child.localName === "tt") return true;
1137
1305
  return false;
1138
1306
  };
1307
+ //#endregion
1308
+ //#region src/formats/ttml/utils.ts
1309
+ var ROLE_BACKGROUND = "x-bg";
1310
+ var ROLE_TRANSLATION = "x-translation";
1311
+ var ROLE_ROMAN = "x-roman";
1312
+ var RUBY_CONTAINER = "container";
1313
+ var RUBY_BASE = "base";
1314
+ var RUBY_TEXT_CONTAINER = "textContainer";
1315
+ var RUBY_TEXT = "text";
1139
1316
  var TIMING_WORD = "Word";
1140
1317
  var TIMING_LINE = "Line";
1141
1318
  //#endregion
1142
- //#region src/formats/ttml/parser.ts
1319
+ //#region src/formats/ttml/generator.ts
1320
+ var NAMESPACE_TTML = "http://www.w3.org/ns/ttml";
1321
+ var NAMESPACE_TTML_METADATA = "http://www.w3.org/ns/ttml#metadata";
1322
+ var NAMESPACE_TTML_STYLING = "http://www.w3.org/ns/ttml#styling";
1323
+ var NAMESPACE_ITUNES = "http://itunes.apple.com/lyric-ttml-extensions";
1324
+ var NAMESPACE_ITUNES_METADATA = "http://music.apple.com/lyric-ttml-internal";
1325
+ var NAMESPACE_AMLL = "http://www.example.com/ns/amll";
1326
+ var ANNOTATION_KINDS = ["translations", "romans"];
1327
+ var XML_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
1328
+ var resolveOptions = (lyric, options) => {
1329
+ const profile = options.profile ?? (lyric.format === "ttml" && lyric.extra.profile === "itunes" ? "itunes" : "amll");
1330
+ if (profile !== "amll" && profile !== "itunes") throw new Error("Unsupported TTML profile");
1331
+ return {
1332
+ profile,
1333
+ includeMeta: options.includeMeta ?? true,
1334
+ indent: options.indent ?? false
1335
+ };
1336
+ };
1143
1337
  /**
1144
- * Reads the span time range, which a word needs both ends of to be sung.
1338
+ * Reserves source ids before allocating keys, so unnamed entries cannot take later source ids.
1145
1339
  */
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;
1340
+ var makeIdAllocator = (ids) => {
1341
+ const reserved = new Set(ids.filter((id) => {
1342
+ return XML_ID_PATTERN.test(id);
1343
+ }));
1344
+ const used = /* @__PURE__ */ new Set();
1345
+ const counters = /* @__PURE__ */ new Map();
1346
+ return (preferred, prefix) => {
1347
+ if (XML_ID_PATTERN.test(preferred) && !used.has(preferred)) {
1348
+ used.add(preferred);
1349
+ return preferred;
1350
+ }
1351
+ let count = counters.get(prefix) ?? 0;
1352
+ let id;
1353
+ do {
1354
+ count += 1;
1355
+ id = `${prefix}${count}`;
1356
+ } while (reserved.has(id) || used.has(id));
1357
+ counters.set(prefix, count);
1358
+ used.add(id);
1359
+ return id;
1360
+ };
1361
+ };
1362
+ /**
1363
+ * Shares one mapping between declarations, body lines, and annotation references.
1364
+ */
1365
+ var makeContext = (lyric, lines, profile) => {
1366
+ const ids = lyric.agents.map((agent) => {
1367
+ return agent.id;
1368
+ });
1369
+ for (const line of lines) {
1370
+ ids.push(line.id);
1371
+ for (const background of line.backgrounds) ids.push(background.id);
1372
+ }
1373
+ const allocate = makeIdAllocator(ids);
1374
+ const agents = /* @__PURE__ */ new Map();
1375
+ const keys = /* @__PURE__ */ new Map();
1376
+ for (const agent of lyric.agents) {
1377
+ if (agents.has(agent.id)) throw new Error("TTML agent declarations require unique ids");
1378
+ agents.set(agent.id, allocate(agent.id, "v"));
1379
+ }
1380
+ for (const line of lines) {
1381
+ keys.set(line, allocate(line.id, "L"));
1382
+ for (const background of line.backgrounds) keys.set(background, allocate(background.id, "B"));
1383
+ }
1153
1384
  return {
1154
- start,
1155
- end: finish
1385
+ profile,
1386
+ offset: lyric.meta?.offset ?? 0,
1387
+ keys,
1388
+ agents
1156
1389
  };
1157
1390
  };
1158
1391
  /**
1159
- * Reads the line time range, which may leave its end open for the next line to supply.
1392
+ * Bakes the playback offset into TTML timestamps without clamping negative results.
1160
1393
  */
1161
- var readLineTime = (element) => {
1394
+ var renderTime = (time, offset) => {
1395
+ const shifted = removeOffset(time, offset);
1396
+ if (!Number.isInteger(shifted) || shifted < 0) throw new RangeError("TTML timestamps must be non-negative integer milliseconds after applying the offset");
1397
+ const seconds = Math.floor(shifted / 1e3);
1398
+ const minutes = Math.floor(seconds / 60);
1399
+ const hours = String(Math.floor(minutes / 60)).padStart(2, "0");
1400
+ const milliseconds = String(shifted % 1e3).padStart(3, "0");
1401
+ return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${milliseconds}`;
1402
+ };
1403
+ /**
1404
+ * Writes known bounds without inventing an end.
1405
+ */
1406
+ var renderTimeAttributes = (time, offset) => {
1407
+ if (time === void 0) throw new Error("TTML sung lines, words, and ruby tokens require a time range");
1408
+ const attributes = [["begin", renderTime(time.start, offset)]];
1409
+ if (time.end !== void 0) attributes.push(["end", renderTime(time.end, offset)]);
1410
+ return attributes;
1411
+ };
1412
+ /**
1413
+ * Clears inherited language when the model leaves an item's language unspecified.
1414
+ */
1415
+ var renderLanguage = (language, inherited) => {
1416
+ if (language !== void 0) return [["xml:lang", language]];
1417
+ return inherited === void 0 ? [] : [["xml:lang", ""]];
1418
+ };
1419
+ var getLineLanguage = (line) => {
1420
+ return line.languages.length === 1 ? line.languages[0] : void 0;
1421
+ };
1422
+ /**
1423
+ * Writes separators as text and resolves inherited ruby timing through the model.
1424
+ */
1425
+ var renderWord = (word, context, language) => {
1426
+ if (word.type === WordType.SPACE) return createXmlText(word.text);
1427
+ const attributes = [...renderTimeAttributes(word.time, context.offset), ...renderLanguage(word.language, language)];
1428
+ const ruby = context.profile === "amll" ? word.annotation?.rubies[0] : void 0;
1429
+ if (ruby === void 0) return createXmlElement("span", attributes, [createXmlText(word.text)]);
1430
+ const tokens = ruby.tokens.map((token) => {
1431
+ return createXmlElement("span", [["tts:ruby", RUBY_TEXT], ...renderTimeAttributes(resolveAnnotationTokenTime(token, ruby, word), context.offset)], [createXmlText(token.text)]);
1432
+ });
1433
+ return createXmlElement("span", [["tts:ruby", RUBY_CONTAINER], ...attributes], [createXmlElement("span", [["tts:ruby", RUBY_BASE]], [createXmlText(word.text)]), createXmlElement("span", [
1434
+ ["tts:ruby", RUBY_TEXT_CONTAINER],
1435
+ ...renderTimeAttributes(resolveAnnotationItemTime(ruby, word), context.offset),
1436
+ ...renderLanguage(ruby.language, word.language)
1437
+ ], tokens)]);
1438
+ };
1439
+ /**
1440
+ * Retains the model's choice between whole text and timed words.
1441
+ */
1442
+ var renderContent = (line, context) => {
1443
+ const content = line.content;
1444
+ if (content === void 0) throw new Error("TTML sung lines require content");
1445
+ if (content.text !== void 0) return [createXmlText(content.text)];
1446
+ return content.words.map((word) => {
1447
+ return renderWord(word, context, getLineLanguage(line));
1448
+ });
1449
+ };
1450
+ /**
1451
+ * Keeps annotations outside the sung word stream.
1452
+ */
1453
+ var renderInlineAnnotations = (annotation, language) => {
1454
+ const nodes = [];
1455
+ for (const item of annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1456
+ for (const item of annotation?.romans ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_ROMAN], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1457
+ return nodes;
1458
+ };
1459
+ /**
1460
+ * Uses the same allocated keys and agent ids as the document's declarations.
1461
+ */
1462
+ var renderLineAttributes = (line, context) => {
1463
+ const attributes = [
1464
+ ...renderTimeAttributes(line.time, context.offset),
1465
+ ["itunes:key", context.keys.get(line)],
1466
+ ["xml:space", "preserve"],
1467
+ ["xml:lang", getLineLanguage(line) ?? ""]
1468
+ ];
1469
+ const lead = line.agents[0];
1470
+ if (lead !== void 0) {
1471
+ const id = context.agents.get(lead);
1472
+ if (id === void 0) throw new Error("TTML performing agents must have a declaration");
1473
+ attributes.push(["ttm:agent", id]);
1474
+ }
1475
+ return attributes;
1476
+ };
1477
+ /**
1478
+ * An empty text node keeps the XML writer's indentation outside lyric content.
1479
+ */
1480
+ var createLyricElement = (name, attributes, children) => {
1481
+ return createXmlElement(name, attributes, [createXmlText(""), ...children]);
1482
+ };
1483
+ var renderBackground = (line, context) => {
1484
+ const children = renderContent(line, context);
1485
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
1486
+ return createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ...renderLineAttributes(line, context)], children);
1487
+ };
1488
+ var renderLine = (line, context) => {
1489
+ const before = [];
1490
+ const after = [];
1491
+ for (const background of line.backgrounds) {
1492
+ const node = renderBackground(background, context);
1493
+ if (background.time !== void 0 && line.time !== void 0 && background.time.start < line.time.start) before.push(node);
1494
+ else after.push(node);
1495
+ }
1496
+ const children = [
1497
+ ...before,
1498
+ ...renderContent(line, context),
1499
+ ...after
1500
+ ];
1501
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
1502
+ return createLyricElement("p", renderLineAttributes(line, context), children);
1503
+ };
1504
+ var renderAgents = (lyric, context) => {
1505
+ return lyric.agents.map((agent) => {
1506
+ const type = agent.type === AgentType.PERSON ? "person" : agent.type === AgentType.GROUP ? "group" : agent.raw ?? "other";
1507
+ const names = agent.names.map((name) => {
1508
+ return createXmlElement("ttm:name", [["type", "full"], ...renderLanguage(name.language)], [createXmlText(name.text)]);
1509
+ });
1510
+ return createXmlElement("ttm:agent", [["type", type], ["xml:id", context.agents.get(agent.id)]], names);
1511
+ });
1512
+ };
1513
+ var renderAmllMeta = (meta) => {
1514
+ const nodes = [];
1515
+ const groups = [
1516
+ ["musicName", meta.titles],
1517
+ ["artists", meta.artists],
1518
+ ["album", meta.albums],
1519
+ ["ttmlAuthorGithubLogin", meta.authors]
1520
+ ];
1521
+ for (const [key, items] of groups) for (const item of items) nodes.push(createXmlElement("amll:meta", [
1522
+ ["key", key],
1523
+ ["value", item.text],
1524
+ ...renderLanguage(item.language)
1525
+ ]));
1526
+ for (const value of meta.isrcs) nodes.push(createXmlElement("amll:meta", [["key", "isrc"], ["value", value]]));
1527
+ for (const item of meta.unknowns) nodes.push(createXmlElement("amll:meta", [["key", item.key], ["value", item.value]]));
1528
+ return nodes;
1529
+ };
1530
+ var renderSongwriters = (meta) => {
1531
+ const names = [];
1532
+ for (const credit of meta.credits) {
1533
+ if (credit.role !== CreditRole.LYRICIST) continue;
1534
+ for (const name of credit.names) names.push(createXmlElement("songwriter", renderLanguage(name.language), [createXmlText(name.text)]));
1535
+ }
1536
+ return names.length === 0 ? [] : [createXmlElement("songwriters", [], names)];
1537
+ };
1538
+ var renderAnnotationTracks = (tracks, kind, context) => {
1539
+ if (tracks.size === 0) return [];
1540
+ const translated = kind === "translations";
1541
+ const blocks = Array.from(tracks, ([language, track]) => {
1542
+ const attributes = translated ? [["type", "subtitle"]] : [];
1543
+ attributes.push(...renderLanguage(language));
1544
+ const texts = Array.from(track, ([line, children]) => {
1545
+ return createLyricElement("text", [["for", context.keys.get(line)], ["xml:space", "preserve"]], children);
1546
+ });
1547
+ return createXmlElement(translated ? "translation" : "transliteration", attributes, texts);
1548
+ });
1549
+ return [createXmlElement(translated ? "translations" : "transliterations", [], blocks)];
1550
+ };
1551
+ /**
1552
+ * A background-only annotation still references its parent paragraph, with a second reference naming the background.
1553
+ */
1554
+ var renderHeadAnnotations = (lines, context) => {
1555
+ const tracks = {
1556
+ translations: /* @__PURE__ */ new Map(),
1557
+ romans: /* @__PURE__ */ new Map()
1558
+ };
1559
+ const append = (annotation, line, background) => {
1560
+ if (annotation === void 0) return;
1561
+ for (const kind of ANNOTATION_KINDS) for (const item of annotation[kind]) {
1562
+ const language = item.language?.toLowerCase();
1563
+ let track = tracks[kind].get(language);
1564
+ if (track === void 0) {
1565
+ track = /* @__PURE__ */ new Map();
1566
+ tracks[kind].set(language, track);
1567
+ }
1568
+ let nodes = track.get(line);
1569
+ if (nodes === void 0) {
1570
+ nodes = [];
1571
+ track.set(line, nodes);
1572
+ }
1573
+ nodes.push(background === void 0 ? createXmlText(item.text) : createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ["for", context.keys.get(background)]], [createXmlText(item.text)]));
1574
+ }
1575
+ };
1576
+ for (const line of lines) {
1577
+ append(line.annotation, line);
1578
+ for (const background of line.backgrounds) append(background.annotation, line, background);
1579
+ }
1580
+ return ANNOTATION_KINDS.flatMap((kind) => {
1581
+ return renderAnnotationTracks(tracks[kind], kind, context);
1582
+ });
1583
+ };
1584
+ var renderMetadata = (lyric, lines, context, includeMeta) => {
1585
+ const metadata = renderAgents(lyric, context);
1586
+ const itunes = [];
1587
+ if (includeMeta && lyric.meta !== void 0) {
1588
+ if (context.profile === "amll") metadata.push(...renderAmllMeta(lyric.meta));
1589
+ itunes.push(...renderSongwriters(lyric.meta));
1590
+ }
1591
+ if (context.profile === "itunes") itunes.push(...renderHeadAnnotations(lines, context));
1592
+ if (itunes.length > 0) metadata.push(createXmlElement("iTunesMetadata", [["xmlns", NAMESPACE_ITUNES_METADATA]], itunes));
1593
+ return createXmlElement("metadata", renderLanguage(void 0, lyric.meta?.language), metadata);
1594
+ };
1595
+ var renderRootAttributes = (lyric, profile) => {
1596
+ const attributes = [
1597
+ ["xmlns", NAMESPACE_TTML],
1598
+ ["xmlns:ttm", NAMESPACE_TTML_METADATA],
1599
+ ["xmlns:tts", NAMESPACE_TTML_STYLING],
1600
+ ["xmlns:itunes", NAMESPACE_ITUNES]
1601
+ ];
1602
+ if (profile === "amll") attributes.push(["xmlns:amll", NAMESPACE_AMLL]);
1603
+ attributes.push(...renderLanguage(lyric.meta?.language), ["itunes:timing", lyric.timing === Timing.WORD ? TIMING_WORD : TIMING_LINE]);
1604
+ return attributes;
1605
+ };
1606
+ /**
1607
+ * Generates one TTML document, retaining the source profile unless explicitly overridden.
1608
+ *
1609
+ * Offsets are baked into timestamps; a negative result or a missing required time throws.
1610
+ * Profile mappings retain the lead agent and first AMLL ruby, omitting unsupported fields and instrumental nodes.
1611
+ */
1612
+ var generateTtml = (lyric, options = {}) => {
1613
+ if (lyric.timing !== Timing.LINE && lyric.timing !== Timing.WORD) throw new Error("TTML generation requires line or word timing");
1614
+ const resolved = resolveOptions(lyric, options);
1615
+ const lines = lyric.lines.filter((line) => {
1616
+ return line.type !== LineType.INSTRUMENTAL;
1617
+ });
1618
+ const context = makeContext(lyric, lines, resolved.profile);
1619
+ const metadata = renderMetadata(lyric, lines, context, resolved.includeMeta);
1620
+ const paragraphs = lines.map((line) => {
1621
+ return renderLine(line, context);
1622
+ });
1623
+ return serializeXml(createXmlElement("tt", renderRootAttributes(lyric, resolved.profile), [createXmlElement("head", [], [metadata]), createXmlElement("body", [], [createXmlElement("div", [], paragraphs)])]), { indent: resolved.indent });
1624
+ };
1625
+ //#endregion
1626
+ //#region src/formats/ttml/parser.ts
1627
+ var METADATA_NAMES = [
1628
+ "agent",
1629
+ "songwriter",
1630
+ "meta",
1631
+ "translation",
1632
+ "transliteration"
1633
+ ];
1634
+ var WORD_SEPARATOR_PATTERN = /\p{White_Space}/u;
1635
+ var readTtmlProfile = (source) => {
1636
+ return AMLL_HINT_PATTERN.test(source) ? "amll" : "itunes";
1637
+ };
1638
+ var readSpanTime = (element) => {
1162
1639
  const begin = getXmlAttribute(element, "begin");
1163
1640
  if (!begin) return;
1164
1641
  const start = parseTime(begin);
@@ -1169,120 +1646,345 @@ var readLineTime = (element) => {
1169
1646
  end: end ? parseTime(end) : void 0
1170
1647
  };
1171
1648
  };
1649
+ var readLineTime = (element) => {
1650
+ return readSpanTime(element);
1651
+ };
1652
+ var readAgentType = (raw) => {
1653
+ switch (raw) {
1654
+ case "person": return {
1655
+ type: AgentType.PERSON,
1656
+ raw: void 0
1657
+ };
1658
+ case "group": return {
1659
+ type: AgentType.GROUP,
1660
+ raw: void 0
1661
+ };
1662
+ default: return {
1663
+ type: AgentType.OTHER,
1664
+ raw: raw ?? "other"
1665
+ };
1666
+ }
1667
+ };
1668
+ var readDeclaredTiming = (root) => {
1669
+ switch (getXmlQualifiedAttribute(root, "itunes:timing")) {
1670
+ case TIMING_WORD: return Timing.WORD;
1671
+ case TIMING_LINE: return Timing.LINE;
1672
+ default: return;
1673
+ }
1674
+ };
1675
+ var readPreserveSpace = (element, inherited) => {
1676
+ switch (getXmlQualifiedAttribute(element, "xml:space")) {
1677
+ case "preserve": return true;
1678
+ case "default": return false;
1679
+ default: return inherited;
1680
+ }
1681
+ };
1682
+ var readLanguage = (element, inherited) => {
1683
+ const language = getXmlQualifiedAttribute(element, "xml:lang") ?? getXmlAttribute(element, "lang");
1684
+ return language === void 0 ? inherited : lowerTag(language);
1685
+ };
1686
+ var readText = (element, inherited) => {
1687
+ const text = getXmlTextContent(element);
1688
+ return readPreserveSpace(element, inherited) ? text : text.trim();
1689
+ };
1172
1690
  /**
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.
1691
+ * Preserves the parser's whitespace segmentation when turning text into word drafts.
1176
1692
  */
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(),
1693
+ var splitWords = (text, language) => {
1694
+ if (text === "") return [];
1695
+ if (!WORD_SEPARATOR_PATTERN.test(text)) return [{
1696
+ text,
1697
+ space: false,
1698
+ language,
1182
1699
  rubies: []
1183
- });
1700
+ }];
1701
+ const words = [];
1702
+ for (const run of text.split(/(\p{White_Space}+)/u)) if (run !== "") {
1703
+ const space = /^\p{White_Space}+$/u.test(run);
1704
+ words.push({
1705
+ text: run,
1706
+ space,
1707
+ language: space ? void 0 : language,
1708
+ rubies: []
1709
+ });
1710
+ }
1184
1711
  return words;
1185
1712
  };
1186
1713
  /**
1714
+ * Distinguishes indentation runs from separators using the parser's existing rule.
1715
+ */
1716
+ var isFormattingWhitespace = (text) => {
1717
+ return text.trim() === "" && text.includes("\n");
1718
+ };
1719
+ /**
1720
+ * Builds words from source drafts without deriving missing timestamps.
1721
+ */
1722
+ var buildWords = (draft) => {
1723
+ const words = [];
1724
+ for (const word of draft.words) {
1725
+ if (word.space) {
1726
+ words.push(makeWordSpace({ text: word.text }));
1727
+ continue;
1728
+ }
1729
+ const time = word.start === void 0 ? void 0 : makeTimeRange({
1730
+ start: word.start,
1731
+ end: word.end
1732
+ });
1733
+ if (word.rubies.length === 0) {
1734
+ words.push(makeWordNormal({
1735
+ text: word.text,
1736
+ time,
1737
+ language: word.language
1738
+ }));
1739
+ continue;
1740
+ }
1741
+ const rubies = word.rubies.map((ruby) => {
1742
+ return makeWordAnnotationRuby({
1743
+ time: ruby.start === void 0 ? void 0 : makeTimeRange({
1744
+ start: ruby.start,
1745
+ end: ruby.end
1746
+ }),
1747
+ language: ruby.language,
1748
+ tokens: ruby.tokens.map((token) => {
1749
+ return makeWordAnnotationToken({
1750
+ text: token.text,
1751
+ time: token.start === void 0 ? void 0 : makeTimeRange({
1752
+ start: token.start,
1753
+ end: token.end
1754
+ })
1755
+ });
1756
+ })
1757
+ });
1758
+ });
1759
+ words.push(makeWordNormal({
1760
+ text: word.text,
1761
+ time,
1762
+ language: word.language,
1763
+ annotation: makeWordAnnotation({ rubies })
1764
+ }));
1765
+ }
1766
+ return words;
1767
+ };
1768
+ /**
1769
+ * Builds only annotations explicitly present in the source drafts.
1770
+ */
1771
+ var buildLineAnnotation = (draft) => {
1772
+ if (draft.translations.length === 0 && draft.romans.length === 0) return;
1773
+ return makeLineAnnotation({
1774
+ translations: draft.translations.map((item) => {
1775
+ return makeLineAnnotationTranslation({
1776
+ text: item.text,
1777
+ language: item.language
1778
+ });
1779
+ }),
1780
+ romans: draft.romans.map((item) => {
1781
+ return makeLineAnnotationRoman({
1782
+ text: item.text,
1783
+ language: item.language
1784
+ });
1785
+ })
1786
+ });
1787
+ };
1788
+ /**
1789
+ * Separates head annotation text from its background references.
1790
+ */
1791
+ var splitBackgroundText = (element, preserveSpace, language) => {
1792
+ preserveSpace = readPreserveSpace(element, preserveSpace);
1793
+ language = readLanguage(element, language);
1794
+ let main = "";
1795
+ const backgrounds = [];
1796
+ for (const child of element.children) {
1797
+ if (child.nodeType === XmlNodeType.element && child.localName === "span" && getXmlAttribute(child, "role") === "x-bg") {
1798
+ backgrounds.push({
1799
+ text: readText(child, preserveSpace),
1800
+ key: getXmlAttribute(child, "for"),
1801
+ language: readLanguage(child, language)
1802
+ });
1803
+ continue;
1804
+ }
1805
+ main += getXmlTextContent(child);
1806
+ }
1807
+ return {
1808
+ main: preserveSpace ? main : main.trim(),
1809
+ backgrounds
1810
+ };
1811
+ };
1812
+ /**
1813
+ * Carries language and whitespace declarations through wrappers to the elements they govern.
1814
+ */
1815
+ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language) => {
1816
+ const wanted = new Set(names);
1817
+ const result = /* @__PURE__ */ new Map();
1818
+ const stack = [{
1819
+ element: root,
1820
+ preserveSpace,
1821
+ language
1822
+ }];
1823
+ while (stack.length > 0) {
1824
+ const entry = stack.pop();
1825
+ if (entry === void 0) continue;
1826
+ const element = entry.element;
1827
+ const currentSpace = readPreserveSpace(element, entry.preserveSpace);
1828
+ const currentLanguage = readLanguage(element, entry.language);
1829
+ if (wanted.has(element.localName)) {
1830
+ const group = result.get(element.localName);
1831
+ const context = {
1832
+ element,
1833
+ preserveSpace: currentSpace,
1834
+ language: currentLanguage
1835
+ };
1836
+ if (group === void 0) result.set(element.localName, [context]);
1837
+ else group.push(context);
1838
+ if (stopAtMatch) continue;
1839
+ }
1840
+ const children = element.children;
1841
+ for (let index = children.length - 1; index >= 0; index -= 1) {
1842
+ const child = children[index];
1843
+ if (child && child.nodeType === XmlNodeType.element) stack.push({
1844
+ element: child,
1845
+ preserveSpace: currentSpace,
1846
+ language: currentLanguage
1847
+ });
1848
+ }
1849
+ }
1850
+ return result;
1851
+ };
1852
+ var findElements = (root, name, preserveSpace, stopAtMatch = false, language) => {
1853
+ return groupElements(root, [name], preserveSpace, stopAtMatch, language).get(name) ?? [];
1854
+ };
1855
+ /**
1187
1856
  * Reads an AMLL ruby container into one word plus its per-syllable annotation.
1188
1857
  */
1189
- var readRuby = (container) => {
1858
+ var readRuby = (container, preserveSpace, language) => {
1859
+ preserveSpace = readPreserveSpace(container, preserveSpace);
1860
+ language = readLanguage(container, language);
1190
1861
  let base = "";
1862
+ let baseLanguage = language;
1863
+ let rubyTime;
1864
+ let rubyLanguage = language;
1191
1865
  const tokens = [];
1192
1866
  for (const child of getXmlChildElements(container, "span")) {
1193
1867
  const kind = getXmlAttribute(child, "ruby");
1194
1868
  if (kind === "base") {
1195
- base = getXmlTextContent(child).trim();
1869
+ base = readText(child, preserveSpace);
1870
+ baseLanguage = readLanguage(child, language);
1196
1871
  continue;
1197
1872
  }
1198
1873
  if (kind !== "textContainer") continue;
1874
+ rubyTime ??= readSpanTime(child);
1875
+ rubyLanguage = readLanguage(child, language);
1876
+ const rubySpace = readPreserveSpace(child, preserveSpace);
1199
1877
  for (const text of getXmlChildElements(child, "span")) {
1200
1878
  if (getXmlAttribute(text, "ruby") !== "text") continue;
1201
1879
  const time = readSpanTime(text);
1202
- const content = getXmlTextContent(text).trim();
1203
- if (time === void 0 || !content) continue;
1880
+ const content = readText(text, rubySpace);
1881
+ if (!content) continue;
1204
1882
  tokens.push({
1205
- start: time.start,
1206
- end: time.end,
1883
+ start: time?.start,
1884
+ end: time?.end,
1207
1885
  text: content
1208
1886
  });
1209
1887
  }
1210
1888
  }
1211
- const first = tokens[0];
1212
- const last = tokens[tokens.length - 1];
1213
- if (!base || first === void 0 || last === void 0) return;
1889
+ if (!base) return;
1890
+ const first = tokens.find((token) => {
1891
+ return token.start !== void 0;
1892
+ });
1893
+ const last = tokens.findLast((token) => {
1894
+ return token.start !== void 0;
1895
+ });
1896
+ const time = readSpanTime(container) ?? rubyTime ?? (first === void 0 ? void 0 : {
1897
+ start: first.start,
1898
+ end: last?.end
1899
+ });
1214
1900
  return {
1215
1901
  text: base,
1216
1902
  space: false,
1217
- start: first.start,
1218
- end: last.end,
1219
- rubies: [{
1220
- start: first.start,
1221
- end: last.end,
1903
+ start: time?.start,
1904
+ end: time?.end,
1905
+ language: baseLanguage,
1906
+ rubies: tokens.length === 0 ? [] : [{
1907
+ start: rubyTime?.start,
1908
+ end: rubyTime?.end,
1909
+ language: rubyLanguage,
1222
1910
  tokens
1223
1911
  }]
1224
1912
  };
1225
1913
  };
1226
1914
  /**
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
1915
  * Collects the words of one span-bearing element.
1234
1916
  *
1235
- * Text between spans carries the separators, and a span without both ends contributes its text untimed.
1917
+ * Text between spans carries the separators, and a span without a readable begin contributes its text untimed.
1236
1918
  * 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.
1919
+ * Whitespace inside a span remains a separator; indentation is dropped only without a preserve declaration.
1238
1920
  */
1239
- var readWords = (element) => {
1921
+ var readWords = (element, preserveSpace, language) => {
1922
+ preserveSpace = readPreserveSpace(element, preserveSpace);
1923
+ language = readLanguage(element, language);
1240
1924
  const words = [];
1241
1925
  let wordLevel = false;
1242
1926
  for (const child of element.children) {
1243
- if (child.nodeType === XmlNodeType.text) {
1244
- if (!isFormattingWhitespace(child.text)) words.push(...splitWords(child.text));
1927
+ if (child.nodeType !== XmlNodeType.element) {
1928
+ if (preserveSpace || !isFormattingWhitespace(child.text)) words.push(...splitWords(child.text, language));
1245
1929
  continue;
1246
1930
  }
1247
- if (child.nodeType !== XmlNodeType.element || child.localName !== "span") continue;
1931
+ if (child.localName !== "span") continue;
1248
1932
  if (getXmlAttribute(child, "role") !== void 0) continue;
1249
1933
  if (getXmlAttribute(child, "ruby") === "container") {
1250
- const ruby = readRuby(child);
1934
+ const ruby = readRuby(child, preserveSpace, language);
1251
1935
  if (ruby !== void 0) {
1252
- words.push(ruby);
1253
- wordLevel = true;
1936
+ for (const part of splitWords(ruby.text)) words.push(part.space ? part : {
1937
+ ...ruby,
1938
+ text: part.text
1939
+ });
1940
+ wordLevel ||= ruby.start !== void 0;
1254
1941
  }
1255
1942
  continue;
1256
1943
  }
1257
- const text = getXmlTextContent(child).trim();
1258
- if (!text) continue;
1944
+ const text = getXmlTextContent(child);
1945
+ const childSpace = readPreserveSpace(child, preserveSpace);
1946
+ if (!text || !childSpace && isFormattingWhitespace(text)) continue;
1259
1947
  const time = readSpanTime(child);
1260
- if (time === void 0) {
1261
- words.push({
1262
- text,
1263
- space: false,
1264
- rubies: []
1265
- });
1266
- continue;
1948
+ for (const part of splitWords(text, readLanguage(child, language))) {
1949
+ if (!part.space && time !== void 0) {
1950
+ part.start = time.start;
1951
+ part.end = time.end;
1952
+ wordLevel = true;
1953
+ }
1954
+ words.push(part);
1267
1955
  }
1268
- wordLevel = true;
1269
- words.push({
1270
- text,
1271
- space: false,
1272
- start: time.start,
1273
- end: time.end,
1274
- rubies: []
1275
- });
1276
1956
  }
1277
1957
  return {
1278
1958
  words,
1279
1959
  wordLevel
1280
1960
  };
1281
1961
  };
1962
+ var readInlineAnnotations = (element, preserveSpace, language) => {
1963
+ const translations = [];
1964
+ const romans = [];
1965
+ for (const span of getXmlChildElements(element, "span")) {
1966
+ const role = getXmlAttribute(span, "role");
1967
+ if (role !== "x-translation" && role !== "x-roman") continue;
1968
+ const text = readText(span, preserveSpace);
1969
+ if (!text) continue;
1970
+ const item = {
1971
+ language: readLanguage(span, language),
1972
+ text
1973
+ };
1974
+ if (role === "x-translation") translations.push(item);
1975
+ else romans.push(item);
1976
+ }
1977
+ return {
1978
+ translations,
1979
+ romans
1980
+ };
1981
+ };
1282
1982
  /**
1283
1983
  * Reads one paragraph, pulling its inline annotations and background lines out of the word stream.
1284
1984
  */
1285
- var readLine = (element) => {
1985
+ var readLine = (element, preserveSpace, language) => {
1986
+ preserveSpace = readPreserveSpace(element, preserveSpace);
1987
+ language = readLanguage(element, language);
1286
1988
  const time = readLineTime(element);
1287
1989
  if (time === void 0) return;
1288
1990
  const draft = {
@@ -1294,33 +1996,25 @@ var readLine = (element) => {
1294
1996
  translations: [],
1295
1997
  romans: [],
1296
1998
  backgrounds: [],
1297
- wordLevel: false
1999
+ wordLevel: false,
2000
+ preserveSpace,
2001
+ language
1298
2002
  };
1299
2003
  if (!hasXmlChildElement(element, "span")) {
1300
- const plain = getXmlTextContent(element).trim();
2004
+ const plain = readText(element, preserveSpace);
1301
2005
  if (!plain) return;
1302
- draft.words = splitWords(plain);
2006
+ draft.words = splitWords(plain, language);
1303
2007
  return draft;
1304
2008
  }
1305
- const read = readWords(element);
2009
+ const read = readWords(element, preserveSpace, language);
1306
2010
  draft.words = read.words;
1307
2011
  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);
2012
+ const annotations = readInlineAnnotations(element, preserveSpace, language);
2013
+ draft.translations = annotations.translations;
2014
+ draft.romans = annotations.romans;
2015
+ for (const span of getXmlChildElements(element, "span")) if (getXmlAttribute(span, "role") === "x-bg") {
2016
+ const background = readBackground(span, preserveSpace, language);
2017
+ if (background) draft.backgrounds.push(background);
1324
2018
  }
1325
2019
  return draft;
1326
2020
  };
@@ -1329,8 +2023,10 @@ var readLine = (element) => {
1329
2023
  *
1330
2024
  * A background line states no time of its own in AMLL files, so its range comes from its words.
1331
2025
  */
1332
- var readBackground = (element) => {
1333
- const read = readWords(element);
2026
+ var readBackground = (element, preserveSpace, language) => {
2027
+ preserveSpace = readPreserveSpace(element, preserveSpace);
2028
+ language = readLanguage(element, language);
2029
+ const read = readWords(element, preserveSpace, language);
1334
2030
  if (read.words.length === 0) return;
1335
2031
  const begin = getXmlAttribute(element, "begin");
1336
2032
  const end = getXmlAttribute(element, "end");
@@ -1348,77 +2044,19 @@ var readBackground = (element) => {
1348
2044
  agentId: getXmlAttribute(element, "agent"),
1349
2045
  key: getXmlAttribute(element, "key"),
1350
2046
  words: read.words,
1351
- translations: [],
1352
- romans: [],
2047
+ ...readInlineAnnotations(element, preserveSpace, language),
1353
2048
  backgrounds: [],
1354
- wordLevel: read.wordLevel
2049
+ wordLevel: read.wordLevel,
2050
+ preserveSpace,
2051
+ language
1355
2052
  };
1356
2053
  };
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
- });
2054
+ var buildContent = (draft) => {
2055
+ if (draft.wordLevel) return makeLineContentWords(buildWords(draft));
2056
+ const text = draft.words.map((word) => {
2057
+ return word.text;
2058
+ }).join("");
2059
+ return makeLineContentText(draft.preserveSpace ? text : text.trim());
1422
2060
  };
1423
2061
  /**
1424
2062
  * Builds one background line.
@@ -1431,7 +2069,8 @@ var buildBackground = (draft) => {
1431
2069
  end: draft.end
1432
2070
  }),
1433
2071
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
1434
- content: makeLineContentWords(buildWords(draft)),
2072
+ languages: draft.language === void 0 ? [] : [draft.language],
2073
+ content: buildContent(draft),
1435
2074
  annotation: buildLineAnnotation(draft)
1436
2075
  });
1437
2076
  };
@@ -1439,9 +2078,6 @@ var buildBackground = (draft) => {
1439
2078
  * Builds one normal line, keeping plain text whole unless the source timed its words.
1440
2079
  */
1441
2080
  var buildLine = (draft) => {
1442
- const content = draft.wordLevel ? makeLineContentWords(buildWords(draft)) : makeLineContentText(draft.words.map((word) => {
1443
- return word.text;
1444
- }).join("").trim());
1445
2081
  return makeLineNormal({
1446
2082
  id: draft.key,
1447
2083
  time: makeTimeRange({
@@ -1449,54 +2085,34 @@ var buildLine = (draft) => {
1449
2085
  end: draft.end
1450
2086
  }),
1451
2087
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
1452
- content,
2088
+ languages: draft.language === void 0 ? [] : [draft.language],
2089
+ content: buildContent(draft),
1453
2090
  annotation: buildLineAnnotation(draft),
1454
2091
  backgrounds: draft.backgrounds.map(buildBackground)
1455
2092
  });
1456
2093
  };
1457
2094
  /**
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
2095
  * Reads the head's agents, which lines reference by id.
1480
2096
  */
1481
- var readAgents = (metadata) => {
1482
- if (!metadata) return [];
2097
+ var readAgents = (elements) => {
1483
2098
  const agents = [];
1484
- for (const element of findXmlElements(metadata, "agent")) {
2099
+ for (const { element, preserveSpace, language } of elements) {
1485
2100
  const id = getXmlQualifiedAttribute(element, "xml:id") ?? getXmlAttribute(element, "id");
1486
2101
  if (!id) continue;
1487
2102
  const names = getXmlChildElements(element, "name").map((name) => {
1488
- return getXmlTextContent(name).trim();
2103
+ return makeText({
2104
+ text: readText(name, preserveSpace),
2105
+ language: readLanguage(name, language)
2106
+ });
1489
2107
  }).filter((name) => {
1490
- return name !== "";
2108
+ return name.text !== "";
1491
2109
  });
1492
2110
  const kind = readAgentType(getXmlAttribute(element, "type"));
1493
2111
  agents.push(makeAgent({
1494
2112
  id,
1495
2113
  type: kind.type,
1496
2114
  raw: kind.raw,
1497
- names: names.map((name) => {
1498
- return makeText({ text: name });
1499
- })
2115
+ names
1500
2116
  }));
1501
2117
  }
1502
2118
  return agents;
@@ -1506,43 +2122,56 @@ var readAgents = (metadata) => {
1506
2122
  *
1507
2123
  * AMLL wraps these in `iTunesMetadata`, so the whole head is searched rather than its direct children.
1508
2124
  */
1509
- var readCredits = (metadata, meta) => {
1510
- if (!metadata) return;
1511
- const names = findXmlElements(metadata, "songwriter").map((element) => {
1512
- return getXmlTextContent(element).trim();
2125
+ var readCredits = (elements, meta) => {
2126
+ const names = elements.map(({ element, preserveSpace, language }) => {
2127
+ return makeText({
2128
+ text: readText(element, preserveSpace),
2129
+ language
2130
+ });
1513
2131
  }).filter((name) => {
1514
- return name !== "";
2132
+ return name.text !== "";
1515
2133
  });
1516
2134
  if (names.length === 0) return;
1517
2135
  meta.credits.push(makeMetaCredit({
1518
2136
  role: CreditRole.LYRICIST,
1519
- names: names.map((name) => {
1520
- return makeText({ text: name });
1521
- })
2137
+ names
1522
2138
  }));
1523
2139
  };
1524
2140
  /**
1525
2141
  * Reads one `amll:meta` entry, keeping an unknown key as written so a round trip loses nothing.
1526
2142
  */
1527
- var applyAmllMeta = (meta, element) => {
2143
+ var applyAmllMeta = (meta, element, language) => {
1528
2144
  const key = getXmlAttribute(element, "key");
1529
2145
  const value = getXmlAttribute(element, "value")?.trim();
1530
2146
  if (!key || !value) return;
2147
+ language = readLanguage(element, language);
1531
2148
  switch (key) {
1532
2149
  case "musicName":
1533
- meta.titles.push(makeText({ text: value }));
2150
+ meta.titles.push(makeText({
2151
+ text: value,
2152
+ language
2153
+ }));
1534
2154
  return;
1535
2155
  case "artists":
1536
- meta.artists.push(makeText({ text: value }));
2156
+ meta.artists.push(makeText({
2157
+ text: value,
2158
+ language
2159
+ }));
1537
2160
  return;
1538
2161
  case "album":
1539
- meta.albums.push(makeText({ text: value }));
2162
+ meta.albums.push(makeText({
2163
+ text: value,
2164
+ language
2165
+ }));
1540
2166
  return;
1541
2167
  case "isrc":
1542
2168
  meta.isrcs.push(value);
1543
2169
  return;
1544
2170
  case "ttmlAuthorGithubLogin":
1545
- meta.authors.push(makeText({ text: value }));
2171
+ meta.authors.push(makeText({
2172
+ text: value,
2173
+ language
2174
+ }));
1546
2175
  return;
1547
2176
  default: meta.unknowns.push(makeUnknownEntry({
1548
2177
  key,
@@ -1551,52 +2180,33 @@ var applyAmllMeta = (meta, element) => {
1551
2180
  }
1552
2181
  };
1553
2182
  /**
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
2183
  * Attaches head translations and transliterations to the lines they name by `itunes:key`.
1576
2184
  *
1577
2185
  * A replacement translation rewrites the line's text, which is how a source swaps one script for another.
1578
2186
  */
1579
2187
  var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
1580
- if (!metadata || lineMap.size === 0) return;
2188
+ if (lineMap.size === 0) return;
1581
2189
  const attach = (blocks, kind) => {
1582
- for (const block of blocks) {
1583
- const language = lowerTag(getXmlAttribute(block, "lang"));
2190
+ for (const { element: block, preserveSpace, language } of blocks) {
1584
2191
  const type = getXmlQualifiedAttribute(block, "type") ?? getXmlAttribute(block, "type");
1585
2192
  for (const text of getXmlChildElements(block, "text")) {
1586
2193
  const key = getXmlAttribute(text, "for");
1587
2194
  const line = key ? lineMap.get(key) : void 0;
1588
2195
  if (!line) continue;
1589
- const { main, backgrounds } = splitBackgroundText(text);
2196
+ const textLanguage = readLanguage(text, language);
2197
+ const { main, backgrounds } = splitBackgroundText(text, preserveSpace, textLanguage);
1590
2198
  if (kind === "translation" && type === "replacement") {
1591
2199
  if (main) {
1592
- line.words = splitWords(main);
2200
+ line.words = splitWords(main, textLanguage);
1593
2201
  line.wordLevel = false;
2202
+ line.preserveSpace = readPreserveSpace(text, preserveSpace);
2203
+ line.language = textLanguage;
1594
2204
  }
1595
2205
  continue;
1596
2206
  }
1597
2207
  if (main) {
1598
2208
  const item = {
1599
- language,
2209
+ language: textLanguage,
1600
2210
  text: main
1601
2211
  };
1602
2212
  if (kind === "translation") line.translations.push(item);
@@ -1607,7 +2217,7 @@ var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
1607
2217
  const target = background.key ? backgroundMap.get(background.key) : void 0;
1608
2218
  if (!target) continue;
1609
2219
  const item = {
1610
- language,
2220
+ language: background.language,
1611
2221
  text: background.text
1612
2222
  };
1613
2223
  if (kind === "translation") target.translations.push(item);
@@ -1616,36 +2226,8 @@ var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
1616
2226
  }
1617
2227
  }
1618
2228
  };
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
- }
2229
+ attach(metadata.get("translation") ?? [], "translation");
2230
+ attach(metadata.get("transliteration") ?? [], "roman");
1649
2231
  };
1650
2232
  /**
1651
2233
  * Reads a TTML document into the model.
@@ -1660,8 +2242,8 @@ var parseTtml = (input, options = {}) => {
1660
2242
  status: LyricStatus.INVALID,
1661
2243
  meta: makeMeta()
1662
2244
  }));
1663
- const extra = { profile: readTtmlProfile(content.original) };
1664
- const document = parseXml(content.original);
2245
+ const extra = { profile: readTtmlProfile(content) };
2246
+ const document = parseXml(content);
1665
2247
  const root = document ? findXmlElement(document, "tt") : void 0;
1666
2248
  if (!root) return finalizeLyric(makeLyric({
1667
2249
  format: FORMAT,
@@ -1669,24 +2251,25 @@ var parseTtml = (input, options = {}) => {
1669
2251
  meta: makeMeta(),
1670
2252
  extra
1671
2253
  }));
1672
- const body = findXmlElement(root, "body");
1673
- const metadata = findXmlElement(root, "metadata");
2254
+ const language = readLanguage(root, lowerTag(options.language));
2255
+ const body = findElements(root, "body", false, true, language)[0];
2256
+ const metadata = findElements(root, "metadata", false, true, language)[0];
2257
+ const metadataElements = metadata === void 0 ? /* @__PURE__ */ new Map() : groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language);
1674
2258
  const meta = makeMeta();
1675
- const language = lowerTag(getXmlQualifiedAttribute(root, "xml:lang")) ?? lowerTag(options.language);
1676
2259
  if (language !== void 0) meta.language = language;
1677
- readCredits(metadata, meta);
1678
- for (const element of metadata ? findXmlElements(metadata, "meta") : []) applyAmllMeta(meta, element);
2260
+ readCredits(metadataElements.get("songwriter") ?? [], meta);
2261
+ for (const { element, language: itemLanguage } of metadataElements.get("meta") ?? []) applyAmllMeta(meta, element, itemLanguage);
1679
2262
  const drafts = [];
1680
2263
  const lineMap = /* @__PURE__ */ new Map();
1681
2264
  const backgroundMap = /* @__PURE__ */ new Map();
1682
- for (const element of body ? findParagraphs(body) : []) {
1683
- const draft = readLine(element);
2265
+ for (const { element, preserveSpace, language: lineLanguage } of body ? findElements(body.element, "p", body.preserveSpace, true, body.language) : []) {
2266
+ const draft = readLine(element, preserveSpace, lineLanguage);
1684
2267
  if (!draft) continue;
1685
2268
  drafts.push(draft);
1686
2269
  if (draft.key) lineMap.set(draft.key, draft);
1687
2270
  for (const background of draft.backgrounds) if (background.key) backgroundMap.set(background.key, background);
1688
2271
  }
1689
- attachHeadAnnotations(metadata, lineMap, backgroundMap);
2272
+ attachHeadAnnotations(metadataElements, lineMap, backgroundMap);
1690
2273
  if (drafts.length === 0) return finalizeLyric(makeLyric({
1691
2274
  format: FORMAT,
1692
2275
  status: LyricStatus.INVALID,
@@ -1695,7 +2278,9 @@ var parseTtml = (input, options = {}) => {
1695
2278
  }));
1696
2279
  const lines = drafts.map(buildLine);
1697
2280
  const wordLevel = drafts.some((draft) => {
1698
- return draft.wordLevel;
2281
+ return draft.wordLevel || draft.backgrounds.some((background) => {
2282
+ return background.wordLevel;
2283
+ });
1699
2284
  });
1700
2285
  return finalizeLyric(makeLyric({
1701
2286
  format: FORMAT,
@@ -1703,7 +2288,7 @@ var parseTtml = (input, options = {}) => {
1703
2288
  timing: readDeclaredTiming(root) ?? (wordLevel ? Timing.WORD : Timing.LINE),
1704
2289
  meta,
1705
2290
  extra,
1706
- agents: readAgents(metadata),
2291
+ agents: readAgents(metadataElements.get("agent") ?? []),
1707
2292
  languages: deriveLanguageUsages(lines),
1708
2293
  lines
1709
2294
  }));
@@ -1731,18 +2316,21 @@ var parse = (input, options = {}) => {
1731
2316
  case "ttml": return parseTtml(input, options);
1732
2317
  }
1733
2318
  };
2319
+ var GENERATORS = {
2320
+ lrc: generateLrc,
2321
+ ttml: generateTtml
2322
+ };
1734
2323
  /**
1735
2324
  * Generates text in the requested format, which need not match `lyric.format`.
1736
2325
  *
1737
- * A format without a writer has no branch here, so adding one to `LyricFormat` makes this fail to compile.
2326
+ * The writer map pairs each format's options with its result and requires a writer for every supported format.
2327
+ * An unknown target returns `null`.
1738
2328
  */
1739
2329
  var generate = (lyric, target, options = {}) => {
1740
- switch (target) {
1741
- case "lrc": return generateLrc(lyric, options);
1742
- case "ttml": throw new Error("ttml generation is not implemented yet");
1743
- }
2330
+ if (!Object.hasOwn(GENERATORS, target)) return null;
2331
+ return GENERATORS[target](lyric, options);
1744
2332
  };
1745
2333
  //#endregion
1746
- export { AMLL_HINT_PATTERN, FORMAT, ITUNES_HINT_PATTERN, detectFormat, detectLrc, detectTtml, generate, generateLrc, parse, parseLrc, parseTtml, readTtmlContent, readTtmlProfile };
2334
+ export { detectFormat, detectLrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseTtml };
1747
2335
 
1748
2336
  //# sourceMappingURL=formats.js.map