@gondola-lyrics/kit 0.4.0 → 0.5.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,109 +1,523 @@
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
- //#region src/utils/annotation.ts
3
- /**
4
- * An annotation line carries its text in either field, depending on whether the track used word tags.
1
+ import { i as mapShared, n as lowerTag, r as lowerBound } from "./language.js";
2
+ import { AgentType, CreditRole, LineType, LyricStatus, Timing, WordType, canonicalizeLyric, deriveLanguageUsages, deriveLineRomans, getAnnotationItemText, getContentText, getWordsText, isWordLevelLine, makeAgent, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineBackground, makeLineContentText, makeLineContentWords, makeLineInstrumental, makeLineNormal, makeLyric, makeMeta, makeMetaCredit, makeText, makeTimeRange, makeUnknownEntry, makeWordAnnotation, makeWordAnnotationRoman, makeWordAnnotationRuby, makeWordAnnotationToken, makeWordNormal, makeWordSpace, removeOffset, resolveAnnotationItemTime, resolveAnnotationTokenTime, validateLyric } from "@gondola-lyrics/model/runtime";
3
+ //#region src/utils/kana.ts
4
+ var KANA_COMBINATIONS = /* @__PURE__ */ new Map([
5
+ [12353, "うくぐつふゔ"],
6
+ [12355, "うくぐすずつてでふゔ"],
7
+ [12357, "とど"],
8
+ [12359, "いうくぐしじちぢつにひびぴみりふゔ"],
9
+ [12361, "うくぐつふゔ"],
10
+ [12419, "きぎしじちぢにひびぴみり"],
11
+ [12421, "きぎしじちぢにひびぴみりてでふゔ"],
12
+ [12423, "きぎしじちぢにひびぴみり"],
13
+ [12430, "くぐ"]
14
+ ]);
15
+ var readKana = (text) => {
16
+ const kana = text.normalize("NFKC");
17
+ if (kana.length !== 1) return "";
18
+ const code = kana.charCodeAt(0);
19
+ return code >= 12449 && code <= 12534 ? String.fromCharCode(code - 96) : kana;
20
+ };
21
+ /**
22
+ * Reports whether the second kana joins the first into one mora, so a single roman fragment may cover both.
23
+ *
24
+ * Katakana and compatibility forms normalize to hiragana first.
25
+ * Doubled consonants, long-vowel marks and three-kana runs are not combinations.
5
26
  */
6
- var readText$1 = (line) => {
7
- return (line.content?.text ?? getWordsText(line.content?.words ?? [])).trim();
27
+ var combinesKana = (first, second) => {
28
+ const base = readKana(first);
29
+ const small = readKana(second);
30
+ return !!base && !!small && !!KANA_COMBINATIONS.get(small.charCodeAt(0))?.includes(base);
8
31
  };
9
- var isReadable = (line) => {
10
- return line.time?.start !== void 0 && readText$1(line) !== "";
32
+ //#endregion
33
+ //#region src/utils/annotation.ts
34
+ var readText$2 = (line) => {
35
+ return getContentText(line.content).trim();
11
36
  };
12
37
  /**
13
- * Assigns each annotation line to a lyric line index.
38
+ * Combines explicit roman tracks with display-only word aggregations, preferring explicit entries for the same language.
14
39
  *
15
- * Exact matches claim their lines first, so a fuzzy match never takes a line an exact one needed.
16
- * The remaining annotations take the nearest unclaimed line within the tolerance, and ties keep the earlier line.
17
- */
18
- var matchEntries = (lines, annotations, tolerance) => {
40
+ * Derived entries must not be stored back on the line.
41
+ */
42
+ var getLineRomans = (line) => {
43
+ const explicit = line.annotation?.romans ?? [];
44
+ const languages = new Set(explicit.map((item) => lowerTag(item.language)));
45
+ const derived = deriveLineRomans(line.content).filter((item) => !languages.has(lowerTag(item.language)));
46
+ return derived.length ? [...explicit, ...derived] : explicit;
47
+ };
48
+ var readWordRange = (word) => {
49
+ const time = word.time;
50
+ if (!time || !Number.isSafeInteger(time.start) || time.start < 0 || time.end !== void 0 && (!Number.isSafeInteger(time.end) || time.end < time.start)) return;
51
+ return time;
52
+ };
53
+ var containsRomanTime = (source, target, tolerance) => {
54
+ const end = target.end ?? Number.POSITIVE_INFINITY;
55
+ if (source.start < target.start - tolerance || source.end !== void 0 && source.end > end + tolerance) return false;
56
+ if (source.end === void 0 || source.start === source.end) return target.start === target.end ? source.start <= end + tolerance : source.start < end + tolerance;
57
+ return true;
58
+ };
59
+ var matchRomanTarget = (time, targets, tolerance) => {
60
+ let matched;
61
+ let priority = -1;
62
+ for (const target of targets) {
63
+ if (!containsRomanTime(time, target.time, tolerance)) continue;
64
+ const exact = time.end !== void 0 && time.start === target.time.start && time.end === target.time.end;
65
+ const point = time.start === time.end && target.time.start === target.time.end;
66
+ const rank = exact ? 3 : point ? 2 : containsRomanTime(time, target.time, 0) ? 1 : 0;
67
+ if (rank > priority) {
68
+ matched = target;
69
+ priority = rank;
70
+ } else if (rank === priority) matched = void 0;
71
+ }
72
+ return matched ?? (priority === -1 ? void 0 : null);
73
+ };
74
+ var overlapLength = (time, target) => {
75
+ const finish = time.end ?? time.start;
76
+ return Math.min(finish, target.time.end ?? Number.POSITIVE_INFINITY) - Math.max(time.start, target.time.start);
77
+ };
78
+ var sharesLanguage = (targets) => {
79
+ return new Set(targets.flatMap((target) => target ? [lowerTag(target.word.language)] : []).filter(Boolean)).size <= 1;
80
+ };
81
+ var isOrderedByTime = (targets) => {
82
+ return targets.every((target, index) => {
83
+ return target.time.start >= (targets[index - 1]?.time.start ?? 0);
84
+ });
85
+ };
86
+ var canExtendToFollowing = (target, following) => {
87
+ const first = lowerTag(target.word.language);
88
+ const second = lowerTag(following.word.language);
89
+ return (!first || !second || first === second) && combinesKana(target.word.text, following.word.text);
90
+ };
91
+ var recoverRomanTargets = (sources, targets, tolerance, rounding, language) => {
92
+ const slack = Math.max(tolerance, rounding);
93
+ const used = new Set(sources.flatMap((source) => source.target ? [source.target] : []));
94
+ const reserved = /* @__PURE__ */ new Set();
95
+ const conflict = (target) => {
96
+ return target.word.annotation?.romans.some((item) => lowerTag(item.language) === language);
97
+ };
98
+ for (const [index, source] of sources.entries()) {
99
+ const time = source.time;
100
+ if (source.target || !time || time.end === void 0) continue;
101
+ const exact = targets.filter((target) => target.time.start === time.start);
102
+ const candidates = exact.length ? exact : targets.filter((target) => Math.abs(target.time.start - time.start) <= rounding);
103
+ if (candidates.length !== 1) continue;
104
+ const target = candidates[0];
105
+ const following = targets[targets.indexOf(target) + 1];
106
+ if (!following || following.index !== target.index + 1 || !canExtendToFollowing(target, following)) continue;
107
+ if (used.has(target) || used.has(following) || reserved.has(target) || reserved.has(following)) continue;
108
+ if (conflict(target) || following.word.annotation?.romans.length) continue;
109
+ const previous = sources.slice(0, index).findLast((item) => item.target)?.target;
110
+ const next = sources.slice(index + 1).find((item) => item.target)?.target;
111
+ if (target.index <= (previous?.index ?? -1) || following.index >= (next?.index ?? Number.POSITIVE_INFINITY)) continue;
112
+ if (target.time.start < (previous?.time.start ?? 0) || following.time.start > (next?.time.start ?? Number.POSITIVE_INFINITY)) continue;
113
+ if (!sharesLanguage([
114
+ previous,
115
+ target,
116
+ following,
117
+ next
118
+ ])) continue;
119
+ if (target.time.end === void 0 || following.time.end === void 0 || Math.abs(following.time.start - target.time.end) > rounding) continue;
120
+ if (time.end <= target.time.end || time.end > following.time.end + slack) continue;
121
+ if (targets.some((candidate) => candidate !== target && candidate !== following && overlapLength(time, candidate) > slack)) continue;
122
+ source.target = target;
123
+ used.add(target);
124
+ reserved.add(following);
125
+ }
126
+ for (let start = 0; start < sources.length; start += 1) {
127
+ if (sources[start].target) continue;
128
+ let end = start + 1;
129
+ while (end < sources.length && !sources[end].target) end += 1;
130
+ const previous = sources[start - 1]?.target;
131
+ const next = sources[end]?.target;
132
+ const pending = targets.filter((target) => {
133
+ return target.index > (previous?.index ?? -1) && target.index < (next?.index ?? Number.POSITIVE_INFINITY) && !reserved.has(target);
134
+ });
135
+ if (pending.length !== end - start) return false;
136
+ if (sources.slice(start, end).some((source) => !source.time) && pending.length > 1 && (!previous || !next)) return false;
137
+ const boundaries = [
138
+ previous,
139
+ ...pending,
140
+ next
141
+ ].filter((target) => target !== void 0);
142
+ if (!sharesLanguage(boundaries) || !isOrderedByTime(boundaries)) return false;
143
+ for (const [offset, target] of pending.entries()) {
144
+ const source = sources[start + offset];
145
+ if (used.has(target) || conflict(target)) return false;
146
+ const time = source.time;
147
+ if (time && (overlapLength(time, target) <= 0 || targets.some((candidate) => candidate !== target && overlapLength(time, candidate) > slack))) return false;
148
+ source.target = target;
149
+ used.add(target);
150
+ }
151
+ start = end - 1;
152
+ }
153
+ return true;
154
+ };
155
+ var readRomanLanguage = (source) => {
156
+ const languages = new Set(source.languages.map(lowerTag).filter(Boolean));
157
+ for (const word of source.content?.words ?? []) {
158
+ const language = word.type === WordType.NORMAL ? lowerTag(word.language) : void 0;
159
+ if (language) languages.add(language);
160
+ }
161
+ return {
162
+ language: languages.size === 1 ? languages.values().next().value : lowerTag(source.languages[0]),
163
+ consistent: languages.size <= 1
164
+ };
165
+ };
166
+ var attachWordRomans = (line, source, tolerance, language, fallback) => {
167
+ const content = line.content;
168
+ const sourceContent = source.line.content;
169
+ if (!content?.words.length || content.text !== void 0 || !sourceContent?.words.length || sourceContent.text !== void 0) return;
170
+ const targets = [];
171
+ let spaces = 0;
172
+ for (const [index, word] of content.words.entries()) {
173
+ if (word.type === WordType.SPACE) {
174
+ spaces += 1;
175
+ continue;
176
+ }
177
+ const time = readWordRange(word);
178
+ if (word.type !== WordType.NORMAL || !time) return;
179
+ targets.push({
180
+ word,
181
+ index,
182
+ time,
183
+ spaces,
184
+ tokens: []
185
+ });
186
+ }
187
+ const sources = [];
188
+ let separator = "";
189
+ for (const word of sourceContent.words) {
190
+ if (word.type === WordType.SPACE) {
191
+ separator += word.text;
192
+ continue;
193
+ }
194
+ const time = readWordRange(word);
195
+ if (word.type !== WordType.NORMAL || !word.text.trim() || word.time && !time || !time && !fallback) return;
196
+ const target = time ? matchRomanTarget(time, targets, tolerance) : void 0;
197
+ if (target === null || !target && !fallback) return;
198
+ sources.push({
199
+ word,
200
+ time,
201
+ separator,
202
+ target
203
+ });
204
+ separator = "";
205
+ }
206
+ const anchors = sources.flatMap((source) => source.target ? [source.target] : []);
207
+ if (anchors.some((target, index) => target.index < (anchors[index - 1]?.index ?? -1))) return;
208
+ const recovered = fallback && sources.some((item) => !item.target);
209
+ if (recovered && !recoverRomanTargets(sources, targets, tolerance, source.rounding, language)) return;
210
+ let previous;
211
+ let previousStart = 0;
212
+ for (const { word, time, separator, target } of sources) {
213
+ if (!target || target.index < (previous?.index ?? -1) || target.word.annotation?.romans.some((item) => lowerTag(item.language) === language)) return;
214
+ const bound = target.time.end ?? Number.POSITIVE_INFINITY;
215
+ const start = time ? Math.min(bound, Math.max(target.time.start, time.start)) : target.time.start;
216
+ const end = time?.end === void 0 ? target.time.end : Math.min(bound, Math.max(target.time.start, time.end));
217
+ if (recovered && start < previousStart) return;
218
+ previousStart = start;
219
+ const token = makeWordAnnotationToken({
220
+ text: previous ? word.text : word.text.trimStart(),
221
+ time: {
222
+ start,
223
+ end
224
+ }
225
+ });
226
+ if (previous) {
227
+ const last = previous.tokens.at(-1);
228
+ if (target.spaces > previous.spaces) {
229
+ const space = separator.indexOf(" ");
230
+ if (space === -1) return;
231
+ last.text += separator.slice(0, space);
232
+ token.text = separator.slice(space + 1) + token.text;
233
+ } else last.text += separator;
234
+ }
235
+ target.tokens.push(token);
236
+ previous = target;
237
+ }
238
+ if (!previous) return;
239
+ const last = previous.tokens.at(-1);
240
+ last.text = last.text.trimEnd();
241
+ const words = content.words.slice();
242
+ for (const { word, index, tokens } of targets) {
243
+ if (!tokens.length) continue;
244
+ const roman = makeWordAnnotationRoman({
245
+ language,
246
+ tokens
247
+ });
248
+ words[index] = {
249
+ ...word,
250
+ annotation: makeWordAnnotation({
251
+ ...word.annotation,
252
+ romans: [...word.annotation?.romans ?? [], roman]
253
+ })
254
+ };
255
+ }
256
+ const result = {
257
+ ...line,
258
+ content: {
259
+ ...content,
260
+ words
261
+ }
262
+ };
263
+ return deriveLineRomans(result.content).find((item) => lowerTag(item.language) === language)?.text === source.text ? result : void 0;
264
+ };
265
+ var readSources = (lines, options) => {
266
+ const result = [];
267
+ const preserve = options.preservePlaceholders;
268
+ for (const [index, line] of lines.entries()) {
269
+ const start = line.time?.start;
270
+ const text = readText$2(line);
271
+ const timing = options.getTiming?.(line);
272
+ const precision = timing?.precision;
273
+ if (start !== void 0 && (preserve || text) || options.matchByOrder && text) result.push({
274
+ line,
275
+ index,
276
+ text,
277
+ start,
278
+ precision: precision && Number.isFinite(precision) && precision > 0 ? precision : 1,
279
+ rounding: timing?.rounding === 1 ? 1 : 0
280
+ });
281
+ }
282
+ return result;
283
+ };
284
+ var createTimeIndex = (lines, options) => {
285
+ const starts = [];
286
+ const lyricRows = [];
287
+ const targets = /* @__PURE__ */ new Map();
288
+ for (const [index, line] of lines.entries()) {
289
+ const start = line.time?.start;
290
+ const valid = start !== void 0 && (!options.preservePlaceholders || Number.isFinite(start));
291
+ starts.push(valid ? start : void 0);
292
+ if (options.matchByOrder && line.type === LineType.NORMAL && readText$2(line)) lyricRows.push(index);
293
+ if (!valid) continue;
294
+ const queue = targets.get(start) ?? [];
295
+ queue.push(index);
296
+ targets.set(start, queue);
297
+ }
298
+ return {
299
+ starts,
300
+ lyricRows,
301
+ targets,
302
+ buckets: /* @__PURE__ */ new Map()
303
+ };
304
+ };
305
+ var getTimeBuckets = (index, precision) => {
306
+ const cached = index.buckets.get(precision);
307
+ if (cached) return cached;
308
+ const buckets = /* @__PURE__ */ new Map();
309
+ for (const [target, start] of index.starts.entries()) {
310
+ if (start === void 0 || !Number.isFinite(start)) continue;
311
+ const key = Math.floor(start / precision);
312
+ let bucket = buckets.get(key);
313
+ if (!bucket) {
314
+ bucket = {
315
+ start,
316
+ indices: [],
317
+ mixed: false
318
+ };
319
+ buckets.set(key, bucket);
320
+ }
321
+ bucket.indices.push(target);
322
+ bucket.mixed ||= bucket.start !== start;
323
+ }
324
+ index.buckets.set(precision, buckets);
325
+ return buckets;
326
+ };
327
+ var matchDuplicates = (start, count, index, groups) => {
328
+ const result = [];
329
+ const collect = (precision, key, roundingOnly) => {
330
+ const queue = groups.get(precision)?.get(key);
331
+ if (!queue) return true;
332
+ const bucket = getTimeBuckets(index, precision).get(key);
333
+ if (bucket && (bucket.mixed || bucket.start !== start)) return roundingOnly && queue.every((source) => source.rounding !== 1);
334
+ for (const source of queue) {
335
+ const sourceStart = source.start;
336
+ const rounded = source.rounding === 1;
337
+ if (roundingOnly && !rounded) continue;
338
+ if (rounded && (sourceStart - 1 !== start && index.targets.has(sourceStart - 1) || sourceStart + 1 !== start && index.targets.has(sourceStart + 1))) return false;
339
+ result.push(source);
340
+ if (result.length > count) return false;
341
+ }
342
+ return true;
343
+ };
344
+ for (const precision of groups.keys()) if (!collect(precision, Math.floor(start / precision), false)) return;
345
+ for (const neighbor of [start - 1, start + 1]) if (neighbor !== start && !collect(1, Math.floor(neighbor), true)) return;
346
+ return result;
347
+ };
348
+ var matchAnnotations = (index, sources, options) => {
19
349
  const matched = /* @__PURE__ */ new Map();
20
- if (annotations.length === 0) return matched;
21
- const starts = lines.map((line) => line.time?.start);
22
- const queues = /* @__PURE__ */ new Map();
23
- for (const [index, start] of starts.entries()) {
24
- if (start === void 0) continue;
25
- const queue = queues.get(start);
26
- if (queue) queue.push(index);
27
- else queues.set(start, [index]);
350
+ if (!sources.length) return matched;
351
+ const preserve = options.preservePlaceholders;
352
+ const byStart = /* @__PURE__ */ new Map();
353
+ for (const source of sources) {
354
+ const start = source.start;
355
+ if (start === void 0 || preserve && !Number.isFinite(start)) continue;
356
+ const queue = byStart.get(start) ?? [];
357
+ queue.push(source);
358
+ byStart.set(start, queue);
28
359
  }
29
- const used = /* @__PURE__ */ new Set();
30
- const pending = [];
31
- for (const annotation of annotations) {
32
- const start = annotation.time?.start;
33
- if (start === void 0) continue;
34
- const index = queues.get(start)?.shift();
35
- if (index === void 0) {
36
- pending.push(annotation);
360
+ const usedSources = /* @__PURE__ */ new Set();
361
+ const ambiguous = /* @__PURE__ */ new Set();
362
+ const assign = (target, source) => {
363
+ matched.set(target, source);
364
+ usedSources.add(source);
365
+ };
366
+ for (const [start, queue] of index.targets) {
367
+ const candidates = byStart.get(start);
368
+ if (!candidates) continue;
369
+ if (queue.length > 1) {
370
+ if (!preserve || candidates.length === queue.length) {
371
+ const count = Math.min(queue.length, candidates.length);
372
+ for (let position = 0; position < count; position += 1) assign(queue[position], candidates[position]);
373
+ }
37
374
  continue;
38
375
  }
39
- matched.set(index, annotation);
40
- used.add(index);
376
+ let source = candidates[0];
377
+ for (const candidate of candidates) if (candidate.precision < source.precision) source = candidate;
378
+ assign(queue[0], source);
41
379
  }
42
- if (tolerance <= 0) return matched;
43
- for (const annotation of pending) {
44
- const start = annotation.time?.start;
45
- if (start === void 0) continue;
380
+ if (usedSources.size === sources.length) return matched;
381
+ const readable = options.matchByOrder ? sources.filter((source) => source.text) : [];
382
+ if (readable.length && readable.length === index.lyricRows.length) {
383
+ if (readable.every((source, position) => {
384
+ const target = index.lyricRows[position];
385
+ return !matched.has(target) && !usedSources.has(source) || matched.get(target) === source;
386
+ })) {
387
+ for (const [position, source] of readable.entries()) if (!usedSources.has(source)) assign(index.lyricRows[position], source);
388
+ return matched;
389
+ }
390
+ }
391
+ if (preserve || options.getTiming) {
392
+ const groups = /* @__PURE__ */ new Map();
393
+ for (const source of sources) {
394
+ const start = source.start;
395
+ if (usedSources.has(source) || start === void 0 || !Number.isFinite(start)) continue;
396
+ const precision = source.precision;
397
+ let group = groups.get(precision);
398
+ if (!group) {
399
+ group = /* @__PURE__ */ new Map();
400
+ groups.set(precision, group);
401
+ }
402
+ const key = Math.floor(start / precision);
403
+ const queue = group.get(key) ?? [];
404
+ queue.push(source);
405
+ group.set(key, queue);
406
+ }
407
+ for (const [start, queue] of index.targets) {
408
+ if (queue.length < 2 || matched.has(queue[0])) continue;
409
+ const candidates = matchDuplicates(start, queue.length, index, groups);
410
+ if (!candidates || candidates.length !== queue.length) continue;
411
+ candidates.sort((first, second) => first.index - second.index);
412
+ for (const [position, source] of candidates.entries()) assign(queue[position], source);
413
+ }
414
+ for (const precision of [...groups.keys()].sort((first, second) => first - second)) {
415
+ const candidates = getTimeBuckets(index, precision);
416
+ for (const [key, entries] of groups.get(precision)) {
417
+ const bucket = candidates.get(key);
418
+ if (!bucket) continue;
419
+ const sourceQueue = entries.filter((source) => !usedSources.has(source));
420
+ const targetQueue = bucket.indices.filter((target) => !matched.has(target));
421
+ if (!sourceQueue.length || !targetQueue.length) continue;
422
+ if ((precision > 1 || targetQueue.length > 1) && sourceQueue.length !== targetQueue.length) {
423
+ for (const source of sourceQueue) ambiguous.add(source);
424
+ continue;
425
+ }
426
+ const count = Math.min(sourceQueue.length, targetQueue.length);
427
+ for (let position = 0; position < count; position += 1) assign(targetQueue[position], sourceQueue[position]);
428
+ }
429
+ }
430
+ const rounding = /* @__PURE__ */ new Map();
431
+ const claims = /* @__PURE__ */ new Map();
432
+ for (const source of sources) {
433
+ const start = source.start;
434
+ if (source.rounding !== 1 || usedSources.has(source) || ambiguous.has(source) || start === void 0 || !Number.isFinite(start)) continue;
435
+ let target = -1;
436
+ let count = 0;
437
+ for (const neighbor of [start - 1, start + 1]) for (const candidate of index.targets.get(neighbor) ?? []) {
438
+ if (matched.has(candidate)) continue;
439
+ target = candidate;
440
+ count += 1;
441
+ claims.set(candidate, (claims.get(candidate) ?? 0) + 1);
442
+ }
443
+ if (count === 1) rounding.set(source, target);
444
+ else if (count > 1) ambiguous.add(source);
445
+ }
446
+ for (const [source, target] of rounding) if (claims.get(target) === 1) assign(target, source);
447
+ else ambiguous.add(source);
448
+ }
449
+ const tolerance = options.tolerance ?? 0;
450
+ if (!Number.isFinite(tolerance) || tolerance <= 0) return matched;
451
+ const ordered = index.ordered ??= [...index.targets.keys()].filter(Number.isFinite).sort((first, second) => first - second);
452
+ const cursors = /* @__PURE__ */ new Map();
453
+ for (const source of sources) {
454
+ const start = source.start;
455
+ if (usedSources.has(source) || ambiguous.has(source) || start === void 0 || !Number.isFinite(start)) continue;
46
456
  let target = -1;
47
457
  let distance = Number.POSITIVE_INFINITY;
48
- for (const [index, lineStart] of starts.entries()) {
49
- if (lineStart === void 0 || used.has(index)) continue;
50
- const current = Math.abs(lineStart - start);
51
- if (current <= tolerance && current < distance) {
52
- target = index;
458
+ const end = start + tolerance;
459
+ for (let position = lowerBound(ordered, start - tolerance); position < ordered.length && ordered[position] <= end; position += 1) {
460
+ const targetStart = ordered[position];
461
+ const current = Math.abs(targetStart - start);
462
+ if (current > tolerance || current > distance) continue;
463
+ const queue = index.targets.get(targetStart);
464
+ let cursor = cursors.get(targetStart) ?? 0;
465
+ while (cursor < queue.length && matched.has(queue[cursor])) cursor += 1;
466
+ cursors.set(targetStart, cursor);
467
+ const candidate = queue[cursor];
468
+ if (candidate !== void 0 && (current < distance || current === distance && candidate < target)) {
469
+ target = candidate;
53
470
  distance = current;
54
471
  }
55
472
  }
56
- if (target !== -1) {
57
- matched.set(target, annotation);
58
- used.add(target);
59
- }
473
+ if (target !== -1) assign(target, source);
60
474
  }
61
475
  return matched;
62
476
  };
63
- /**
64
- * Attaches annotation tracks to parsed lines by start time.
65
- *
66
- * Annotations that match nothing are dropped, and an instrumental line consumes its annotation without carrying one.
67
- * Any annotation already on a line is kept, with the matched track appended to its layer.
68
- */
69
- var attachLineAnnotations = (lines, sources, options = {}) => {
70
- const tolerance = Number.isFinite(options.tolerance) ? Math.max(0, options.tolerance ?? 0) : 0;
71
- const translations = matchEntries(lines, (sources.translations ?? []).filter(isReadable), tolerance);
72
- const romans = matchEntries(lines, (sources.romans ?? []).filter(isReadable), tolerance);
73
- if (translations.size === 0 && romans.size === 0) return lines;
74
- return lines.map((line, index) => {
75
- const translation = translations.get(index);
76
- const roman = romans.get(index);
77
- if (line.type === LineType.INSTRUMENTAL || translation === void 0 && roman === void 0) return line;
78
- return {
79
- ...line,
80
- annotation: makeLineAnnotation({
81
- romans: [...line.annotation?.romans ?? [], ...roman === void 0 ? [] : [makeLineAnnotationRoman({
82
- text: readText$1(roman),
83
- language: roman.languages[0]
84
- })]],
85
- translations: [...line.annotation?.translations ?? [], ...translation === void 0 ? [] : [makeLineAnnotationTranslation({
86
- text: readText$1(translation),
87
- language: translation.languages[0]
88
- })]]
89
- })
90
- };
91
- });
477
+ var attachTracks = (lines, sources, options, wordTolerance) => {
478
+ if (!lines.length || !sources.translations?.length && !sources.romans?.length) return lines;
479
+ const index = createTimeIndex(lines, options);
480
+ let result = lines;
481
+ for (const kind of ["translations", "romans"]) {
482
+ const matches = matchAnnotations(index, readSources(sources[kind] ?? [], options), options);
483
+ for (const [target, source] of matches) {
484
+ const line = result[target];
485
+ if (line.type === LineType.INSTRUMENTAL || !source.text) continue;
486
+ const romanLanguage = kind === "romans" && wordTolerance !== void 0 ? readRomanLanguage(source.line) : void 0;
487
+ let annotated = romanLanguage?.consistent && wordTolerance !== void 0 ? attachWordRomans(line, source, wordTolerance, romanLanguage.language, options.wordFallback === true) : void 0;
488
+ if (!annotated) {
489
+ const language = romanLanguage ? romanLanguage.language : source.line.languages[0];
490
+ const create = kind === "romans" ? makeLineAnnotationRoman : makeLineAnnotationTranslation;
491
+ const annotation = makeLineAnnotation({
492
+ ...line.annotation,
493
+ [kind]: [...line.annotation?.[kind] ?? [], create({
494
+ text: source.text,
495
+ language
496
+ })]
497
+ });
498
+ annotated = {
499
+ ...line,
500
+ annotation
501
+ };
502
+ }
503
+ if (result === lines) result = lines.slice();
504
+ result[target] = annotated;
505
+ }
506
+ }
507
+ return result;
92
508
  };
93
- //#endregion
94
- //#region src/utils/language.ts
95
509
  /**
96
- * Returns a language tag in the canonical form the model stores, which is lower case.
510
+ * Attaches complete roman tracks to words using timing and optional local recovery, retaining the source line when matching is incomplete.
511
+ * Translations remain line-level, and successful word tracks are not duplicated on the line.
97
512
  *
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.
513
+ * Match before final normalization; original text and word timing are preserved.
100
514
  */
101
- var lowerTag = (tag) => {
102
- return tag ? tag.toLowerCase() : void 0;
515
+ var attachAnnotations = (lines, sources, options = {}) => {
516
+ return attachTracks(lines, sources, options, Number.isFinite(options.wordTolerance) ? Math.max(0, options.wordTolerance ?? 0) : 0);
103
517
  };
104
518
  //#endregion
105
519
  //#region src/utils/time.ts
106
- var CLOCK_PATTERN = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
520
+ var CLOCK_PATTERN$1 = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
107
521
  var OFFSET_PATTERN = /^(\d+(?:\.\d+)?)(ms|h|m|s)$/iu;
108
522
  /**
109
523
  * Reads a clock value, where the fraction is padded or truncated to millisecond precision.
@@ -112,7 +526,7 @@ var OFFSET_PATTERN = /^(\d+(?:\.\d+)?)(ms|h|m|s)$/iu;
112
526
  */
113
527
  var parseClock = (value) => {
114
528
  if (!value.includes(":") && !value.includes(".")) return;
115
- const match = CLOCK_PATTERN.exec(value);
529
+ const match = CLOCK_PATTERN$1.exec(value);
116
530
  if (!match) return;
117
531
  const hour = Number(match[1] ?? 0);
118
532
  const minute = Number(match[2] ?? 0);
@@ -194,7 +608,7 @@ var prefixedNames = /* @__PURE__ */ new Map();
194
608
  */
195
609
  var splitXmlName = (name) => {
196
610
  const cached = prefixedNames.get(name);
197
- if (cached !== void 0) return cached;
611
+ if (cached) return cached;
198
612
  const colon = name.indexOf(":");
199
613
  const split = colon === -1 ? {
200
614
  namespacePrefix: "",
@@ -203,7 +617,7 @@ var splitXmlName = (name) => {
203
617
  namespacePrefix: name.slice(0, colon),
204
618
  localName: name.slice(colon + 1)
205
619
  };
206
- if (split.namespacePrefix !== "" && prefixedNames.size < 1024) prefixedNames.set(name, split);
620
+ if (split.namespacePrefix && prefixedNames.size < 1024) prefixedNames.set(name, split);
207
621
  return split;
208
622
  };
209
623
  /**
@@ -265,7 +679,7 @@ var getXmlAttribute = (element, localName) => {
265
679
  const attributes = element.attributes;
266
680
  for (let index = 0; index < attributes.length; index += 1) {
267
681
  const attribute = attributes[index];
268
- if (attribute !== void 0 && attribute.localName === localName) return attribute.value;
682
+ if (attribute && attribute.localName === localName) return attribute.value;
269
683
  }
270
684
  };
271
685
  /**
@@ -275,7 +689,7 @@ var getXmlQualifiedAttribute = (element, qualifiedName) => {
275
689
  const attributes = element.attributes;
276
690
  for (let index = 0; index < attributes.length; index += 1) {
277
691
  const attribute = attributes[index];
278
- if (attribute !== void 0 && attribute.qualifiedName === qualifiedName) return attribute.value;
692
+ if (attribute && attribute.qualifiedName === qualifiedName) return attribute.value;
279
693
  }
280
694
  };
281
695
  /**
@@ -286,7 +700,7 @@ var getXmlChildElements = (element, localName) => {
286
700
  const children = element.children;
287
701
  for (let index = 0; index < children.length; index += 1) {
288
702
  const child = children[index];
289
- if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) result.push(child);
703
+ if (child && child.nodeType === XmlNodeType.element && child.localName === localName) result.push(child);
290
704
  }
291
705
  return result;
292
706
  };
@@ -297,7 +711,7 @@ var hasXmlChildElement = (element, localName) => {
297
711
  const children = element.children;
298
712
  for (let index = 0; index < children.length; index += 1) {
299
713
  const child = children[index];
300
- if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) return true;
714
+ if (child && child.nodeType === XmlNodeType.element && child.localName === localName) return true;
301
715
  }
302
716
  return false;
303
717
  };
@@ -306,13 +720,13 @@ var hasXmlChildElement = (element, localName) => {
306
720
  */
307
721
  var findXmlElement = (element, localName) => {
308
722
  const stack = [element];
309
- while (stack.length > 0) {
723
+ while (stack.length) {
310
724
  const node = stack.pop();
311
- if (node === void 0 || node.nodeType !== XmlNodeType.element) continue;
725
+ if (!node || node.nodeType !== XmlNodeType.element) continue;
312
726
  if (node.localName === localName) return node;
313
727
  for (let index = node.children.length - 1; index >= 0; index -= 1) {
314
728
  const child = node.children[index];
315
- if (child !== void 0) stack.push(child);
729
+ if (child) stack.push(child);
316
730
  }
317
731
  }
318
732
  };
@@ -321,18 +735,18 @@ var findXmlElement = (element, localName) => {
321
735
  */
322
736
  var getXmlTextContent = (node) => {
323
737
  if (node.nodeType !== XmlNodeType.element) return node.text;
324
- if (node.children.length === 0) return "";
738
+ if (!node.children.length) return "";
325
739
  const child = node.children[0];
326
- if (node.children.length === 1 && child !== void 0 && child.nodeType !== XmlNodeType.element) return child.text;
740
+ if (node.children.length === 1 && child && child.nodeType !== XmlNodeType.element) return child.text;
327
741
  const parts = [];
328
742
  const stack = [node];
329
- while (stack.length > 0) {
743
+ while (stack.length) {
330
744
  const current = stack.pop();
331
- if (current === void 0) continue;
745
+ if (!current) continue;
332
746
  if (current.nodeType === XmlNodeType.element) {
333
747
  for (let index = current.children.length - 1; index >= 0; index -= 1) {
334
748
  const child = current.children[index];
335
- if (child !== void 0) stack.push(child);
749
+ if (child) stack.push(child);
336
750
  }
337
751
  continue;
338
752
  }
@@ -400,7 +814,7 @@ var decodeText = (text) => {
400
814
  cursor = end + 1;
401
815
  start = cursor;
402
816
  }
403
- if (parts.length === 0) return text;
817
+ if (!parts.length) return text;
404
818
  parts.push(text.slice(start));
405
819
  return parts.join("");
406
820
  };
@@ -409,7 +823,7 @@ var decodeText = (text) => {
409
823
  */
410
824
  var parseXml = (source) => {
411
825
  const length = source.length;
412
- let position = length > 0 && source.charCodeAt(0) === BYTE_ORDER_MARK ? 1 : 0;
826
+ let position = length && source.charCodeAt(0) === BYTE_ORDER_MARK ? 1 : 0;
413
827
  let valid = true;
414
828
  const stack = [];
415
829
  const root = {
@@ -429,7 +843,7 @@ var parseXml = (source) => {
429
843
  position = end;
430
844
  if (start === end) return;
431
845
  const parent = stack[stack.length - 1];
432
- if (parent === void 0) {
846
+ if (!parent) {
433
847
  valid = false;
434
848
  return;
435
849
  }
@@ -544,7 +958,7 @@ var parseXml = (source) => {
544
958
  }
545
959
  position = cursor;
546
960
  const parent = stack[stack.length - 1];
547
- if (parent === void 0) {
961
+ if (!parent) {
548
962
  valid = false;
549
963
  return;
550
964
  }
@@ -574,7 +988,7 @@ var parseXml = (source) => {
574
988
  return;
575
989
  }
576
990
  const top = stack[stack.length - 1];
577
- if (top === void 0 || top.qualifiedName !== name) {
991
+ if (!top || top.qualifiedName !== name) {
578
992
  valid = false;
579
993
  return;
580
994
  }
@@ -601,7 +1015,7 @@ var parseXml = (source) => {
601
1015
  return;
602
1016
  }
603
1017
  const parent = stack[stack.length - 1];
604
- if (parent === void 0) {
1018
+ if (!parent) {
605
1019
  valid = false;
606
1020
  return;
607
1021
  }
@@ -706,7 +1120,7 @@ var escapeXmlText = (text, chunks) => {
706
1120
  }
707
1121
  } else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
708
1122
  else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
709
- if (escaped === "") continue;
1123
+ if (!escaped) continue;
710
1124
  if (start < index) chunks.push(text.slice(start, index));
711
1125
  chunks.push(escaped);
712
1126
  start = index + 1;
@@ -742,7 +1156,7 @@ var escapeXmlAttribute = (value, chunks) => {
742
1156
  }
743
1157
  } else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
744
1158
  else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
745
- if (escaped === "") continue;
1159
+ if (!escaped) continue;
746
1160
  if (start < index) chunks.push(value.slice(start, index));
747
1161
  chunks.push(escaped);
748
1162
  start = index + 1;
@@ -762,7 +1176,7 @@ var hasOnlyElements = (element) => {
762
1176
  const children = element.children;
763
1177
  for (let index = 0; index < children.length; index += 1) {
764
1178
  const child = children[index];
765
- if (child !== void 0 && child.nodeType !== XmlNodeType.element) return false;
1179
+ if (child && child.nodeType !== XmlNodeType.element) return false;
766
1180
  }
767
1181
  return true;
768
1182
  };
@@ -782,13 +1196,13 @@ var writeXmlNode = (node, chunks, depth, indent) => {
782
1196
  chunks.push("<", node.qualifiedName);
783
1197
  for (let index = 0; index < node.attributes.length; index += 1) {
784
1198
  const attribute = node.attributes[index];
785
- if (attribute === void 0) continue;
1199
+ if (!attribute) continue;
786
1200
  chunks.push(" ", attribute.qualifiedName, "=\"");
787
1201
  escapeXmlAttribute(attribute.value, chunks);
788
1202
  chunks.push("\"");
789
1203
  }
790
1204
  const children = node.children;
791
- if (children.length === 0) {
1205
+ if (!children.length) {
792
1206
  chunks.push("/>");
793
1207
  return;
794
1208
  }
@@ -796,7 +1210,7 @@ var writeXmlNode = (node, chunks, depth, indent) => {
796
1210
  const childIndent = indent !== void 0 && hasOnlyElements(node) ? indent : void 0;
797
1211
  for (let index = 0; index < children.length; index += 1) {
798
1212
  const child = children[index];
799
- if (child !== void 0) writeXmlNode(child, chunks, depth + 1, childIndent);
1213
+ if (child) writeXmlNode(child, chunks, depth + 1, childIndent);
800
1214
  }
801
1215
  if (childIndent !== void 0) chunks.push("\n", childIndent.repeat(depth));
802
1216
  chunks.push("</", node.qualifiedName, ">");
@@ -809,20 +1223,23 @@ var writeXmlNode = (node, chunks, depth, indent) => {
809
1223
  var serializeXml = (root, options = {}) => {
810
1224
  const chunks = [];
811
1225
  if (options.declaration ?? true) chunks.push(XML_DECLARATION);
812
- const indent = options.indent === true ? options.indentText ?? DEFAULT_INDENT : void 0;
1226
+ const indent = options.indent ? options.indentText ?? DEFAULT_INDENT : void 0;
813
1227
  if (indent !== void 0) chunks.push("\n");
814
1228
  if (root.qualifiedName === ROOT_NAME) for (let index = 0; index < root.children.length; index += 1) {
815
1229
  const child = root.children[index];
816
- if (child !== void 0) writeXmlNode(child, chunks, 0, indent);
1230
+ if (child) writeXmlNode(child, chunks, 0, indent);
817
1231
  }
818
1232
  else writeXmlNode(root, chunks, 0, indent);
819
1233
  return chunks.join("");
820
1234
  };
821
1235
  //#endregion
822
1236
  //#region src/formats/lrc/common.ts
1237
+ /**
1238
+ * Accepts plain lyrics or separate tracks while rejecting an unreadable original payload.
1239
+ */
823
1240
  var readLrcContent = (input) => {
824
1241
  if (typeof input === "string") return { original: input };
825
- if (typeof input !== "object" || input === null) return null;
1242
+ if (!input || typeof input !== "object") return null;
826
1243
  const source = input;
827
1244
  if (typeof source.original !== "string") return null;
828
1245
  return {
@@ -837,7 +1254,7 @@ var WORD_OFFSET_PATTERN = /^(\d+)(?:,(\d+))?$/;
837
1254
  var WORD_CLOCK_DURATION_PATTERN = /^([^,]+),(\d+)$/;
838
1255
  var WORD_TAG_PATTERN = /<([^>]*)>/g;
839
1256
  var TAG_PATTERN = /\[([^\]]*)\]/g;
840
- var META_PATTERN = /^([A-Za-z0-9_-]+)\s*:\s*([\s\S]*)$/;
1257
+ var META_PATTERN$1 = /^([A-Za-z0-9_-]+)\s*:\s*([\s\S]*)$/;
841
1258
  /**
842
1259
  * Integer tags use `offset[,duration]` in milliseconds relative to the line start.
843
1260
  * Clock tags are absolute and may append a duration in milliseconds, as in `<00:01.50,500>`.
@@ -871,21 +1288,23 @@ var parseWordTime = (body, lineStart) => {
871
1288
  }
872
1289
  };
873
1290
  /**
874
- * Untagged leading text is preserved; tags without following text produce no piece.
875
- * A trailing tag still yields an empty piece so it can close the preceding word.
1291
+ * Untagged leading text is preserved; empty timed pieces retain explicit segment boundaries.
1292
+ * Only recognized time tags split content; other angle-bracket text remains literal.
876
1293
  */
877
1294
  var splitWordTags = (content) => {
878
1295
  const pieces = [];
879
1296
  let tag;
880
1297
  let cursor = 0;
881
1298
  for (const match of content.matchAll(WORD_TAG_PATTERN)) {
1299
+ const nextTag = match[1] ?? "";
1300
+ if (!parseWordTime(nextTag, 0)) continue;
882
1301
  const at = match.index;
883
1302
  const before = content.slice(cursor, at);
884
- if (before) pieces.push({
1303
+ if (before || tag) pieces.push({
885
1304
  text: before,
886
1305
  tag
887
1306
  });
888
- tag = match[1] ?? "";
1307
+ tag = nextTag;
889
1308
  cursor = at + match[0].length;
890
1309
  }
891
1310
  const tail = content.slice(cursor);
@@ -893,7 +1312,7 @@ var splitWordTags = (content) => {
893
1312
  text: tail,
894
1313
  tag
895
1314
  });
896
- else if (tag !== void 0) pieces.push({
1315
+ else if (tag) pieces.push({
897
1316
  text: "",
898
1317
  tag
899
1318
  });
@@ -932,25 +1351,48 @@ var renderWordTag = (start, end, options) => {
932
1351
  return `${open}${formatTime(start, options.timeFormat)}${duration}${close}`;
933
1352
  };
934
1353
  /**
935
- * Plain text joins words as written, including the separators stored as space words.
936
- */
937
- var renderPlainText = (line) => {
938
- const content = line.content;
939
- if (!content) return "";
940
- if (content.text !== void 0) return content.text;
941
- return content.words.map((word) => word.text).join("");
942
- };
943
- /**
944
1354
  * Word-level lines interleave tags with their text; untimed words stay untagged.
945
1355
  */
946
1356
  var renderSyllableText = (line, options) => {
947
1357
  const content = line.content;
948
- if (!content?.words.length) return renderPlainText(line);
1358
+ if (!content?.words.length) return getContentText(content);
949
1359
  return content.words.map((word) => {
950
1360
  if (word.type === WordType.SPACE) return word.text;
951
1361
  const start = word.time?.start;
952
1362
  if (start === void 0) return word.text;
953
- return `${renderWordTag(start, word.time?.end, options)}${word.text}`;
1363
+ const end = word.time?.end;
1364
+ const closing = options.wordTiming === "angle" && options.includeWordEnd && end === start ? renderWordTag(end, void 0, options) : "";
1365
+ return `${renderWordTag(start, end, options)}${word.text}${closing}`;
1366
+ }).join("");
1367
+ };
1368
+ var renderWordRomans = (line, language, options) => {
1369
+ const pieces = [];
1370
+ let spaced = false;
1371
+ for (const word of line.content?.words ?? []) {
1372
+ if (word.type === WordType.SPACE) {
1373
+ spaced = true;
1374
+ continue;
1375
+ }
1376
+ const roman = word.annotation?.romans.find((item) => lowerTag(item.language) === language);
1377
+ if (!roman || !getAnnotationItemText(roman)) continue;
1378
+ let prefix = pieces.length && spaced ? " " : "";
1379
+ spaced = false;
1380
+ for (const token of roman.tokens) {
1381
+ if (!token.text) continue;
1382
+ pieces.push({
1383
+ text: prefix + token.text,
1384
+ time: resolveAnnotationTokenTime(token, roman, word)
1385
+ });
1386
+ prefix = "";
1387
+ }
1388
+ }
1389
+ return pieces.map((piece, index) => {
1390
+ const start = piece.time?.start;
1391
+ if (start === void 0) return piece.text;
1392
+ const end = piece.time?.end;
1393
+ const nextStart = pieces[index + 1]?.time?.start;
1394
+ const closing = end !== void 0 && end !== nextStart && (!options.includeWordEnd || end === start) ? renderWordTag(end, void 0, options) : "";
1395
+ return `${renderWordTag(start, end, options)}${piece.text}${closing}`;
954
1396
  }).join("");
955
1397
  };
956
1398
  var renderMetaRows = (meta, options) => {
@@ -972,7 +1414,7 @@ var renderLineRow = (line, options, wordLevel) => {
972
1414
  if (line.type === LineType.INSTRUMENTAL && !options.includeInstrumental) return;
973
1415
  const time = renderTimeTag(line.time?.start ?? 0, options);
974
1416
  if (line.type === LineType.INSTRUMENTAL) return time;
975
- return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) : renderPlainText(line)}`;
1417
+ return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) : getContentText(line.content)}`;
976
1418
  };
977
1419
  var renderAnnotationRows = (lines, options, pick) => {
978
1420
  const rows = [];
@@ -983,6 +1425,20 @@ var renderAnnotationRows = (lines, options, pick) => {
983
1425
  }
984
1426
  return rows;
985
1427
  };
1428
+ var renderRomanRows = (lines, options) => {
1429
+ const rows = [];
1430
+ for (const line of lines) {
1431
+ const start = line.time?.start;
1432
+ if (start === void 0) continue;
1433
+ const explicit = new Set((line.annotation?.romans ?? []).map((item) => lowerTag(item.language)));
1434
+ for (const item of getLineRomans(line)) {
1435
+ const language = lowerTag(item.language);
1436
+ const text = options.wordTiming === "none" || explicit.has(language) ? item.text : renderWordRomans(line, language, options);
1437
+ rows.push(`${renderTimeTag(start, options)}${text}`);
1438
+ }
1439
+ }
1440
+ return rows;
1441
+ };
986
1442
  var joinBlocks = (header, body) => {
987
1443
  const blocks = [];
988
1444
  if (header.length) blocks.push(header.join("\n"));
@@ -993,17 +1449,18 @@ var joinBlocks = (header, body) => {
993
1449
  * Generates the LRC text plus the annotations a single file cannot hold.
994
1450
  *
995
1451
  * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
1452
+ * Word romanizations retain token boundaries; explicit line romanizations take precedence for the same language.
996
1453
  */
997
1454
  var generateLrc = (lyric, options = {}) => {
998
1455
  const resolved = resolveOptions$1(options);
999
1456
  const header = resolved.includeMeta && lyric.meta ? renderMetaRows(lyric.meta, resolved) : [];
1000
- const wordLevel = lyric.lines.some((line) => (line.content?.words.length ?? 0) > 0) && resolved.wordTiming !== "none";
1457
+ const wordLevel = lyric.lines.some((line) => !!line.content?.words.length) && resolved.wordTiming !== "none";
1001
1458
  const result = { original: joinBlocks(header, lyric.lines.map((line) => {
1002
1459
  return renderLineRow(line, resolved, wordLevel);
1003
1460
  }).filter((row) => row !== void 0)) };
1004
1461
  const translations = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.translations ?? []);
1005
1462
  if (translations.length) result.translations = joinBlocks(header, translations);
1006
- const romans = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.romans ?? []);
1463
+ const romans = renderRomanRows(lyric.lines, resolved);
1007
1464
  if (romans.length) result.romans = joinBlocks(header, romans);
1008
1465
  return result;
1009
1466
  };
@@ -1022,19 +1479,14 @@ var finalizeLyric = (input) => {
1022
1479
  };
1023
1480
  };
1024
1481
  //#endregion
1025
- //#region src/formats/lrc/parser.ts
1026
- var isBareNumber = (value) => {
1027
- return /^\d+$/.test(value.trim());
1028
- };
1029
- /**
1030
- * A trailing tag alone still makes the line word-level, so it can close the last word.
1031
- * Unreadable angle-bracket text stays plain lyric content.
1032
- */
1033
- var isWordTimed = (text) => {
1034
- return splitWordTags(text).some((piece) => piece.tag !== void 0 && parseWordTime(piece.tag, 0) !== void 0);
1035
- };
1036
- var readMeta = (body) => {
1037
- const match = META_PATTERN.exec(body);
1482
+ //#region src/formats/lrc/reader.ts
1483
+ var CLOCK_PATTERN = /^(?:\d+:){0,2}\d+(?:\.(\d+))?$/;
1484
+ var readLineTiming = (tag) => {
1485
+ const clock = CLOCK_PATTERN.exec(tag.trim());
1486
+ return { precision: clock ? 10 ** Math.max(0, 3 - (clock[1]?.length ?? 0)) : 1 };
1487
+ };
1488
+ var readMeta$1 = (body) => {
1489
+ const match = META_PATTERN$1.exec(body);
1038
1490
  if (!match) return;
1039
1491
  return {
1040
1492
  key: (match[1] ?? "").toLowerCase(),
@@ -1047,11 +1499,12 @@ var readGroup = (tags, text, timed, meta) => {
1047
1499
  if (start !== void 0) {
1048
1500
  timed.push({
1049
1501
  text,
1050
- start
1502
+ start,
1503
+ tag
1051
1504
  });
1052
1505
  continue;
1053
1506
  }
1054
- const pair = readMeta(tag);
1507
+ const pair = readMeta$1(tag);
1055
1508
  if (pair) meta.push(pair);
1056
1509
  }
1057
1510
  };
@@ -1084,57 +1537,11 @@ var readTimedLines = (text) => {
1084
1537
  };
1085
1538
  };
1086
1539
  /**
1087
- * Unmapped tags and unparseable durations remain in `unknowns` to avoid discarding metadata.
1088
- * Keys are lowercased and values trimmed, so original tag spelling is not preserved.
1089
- */
1090
- var readMetaTags = (tags) => {
1091
- const meta = makeMeta();
1092
- for (const tag of tags) switch (tag.key) {
1093
- case "length":
1094
- case "duration": {
1095
- const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : NaN);
1096
- if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
1097
- else meta.unknowns.push(makeUnknownEntry({
1098
- key: tag.key,
1099
- value: tag.value
1100
- }));
1101
- break;
1102
- }
1103
- case "offset":
1104
- meta.offset = Math.trunc(Number(tag.value)) || 0;
1105
- break;
1106
- case "ti":
1107
- case "title":
1108
- meta.titles.push(makeText({ text: tag.value }));
1109
- break;
1110
- case "ar":
1111
- case "artist":
1112
- meta.artists.push(makeText({ text: tag.value }));
1113
- break;
1114
- case "al":
1115
- case "album":
1116
- meta.albums.push(makeText({ text: tag.value }));
1117
- break;
1118
- case "by":
1119
- case "author":
1120
- meta.authors.push(makeText({ text: tag.value }));
1121
- break;
1122
- case "isrc":
1123
- meta.isrcs.push(tag.value);
1124
- break;
1125
- default: meta.unknowns.push(makeUnknownEntry({
1126
- key: tag.key,
1127
- value: tag.value
1128
- }));
1129
- }
1130
- return meta;
1131
- };
1132
- /**
1133
1540
  * Whitespace becomes a space word preserving the source run, as in `lo World`.
1134
1541
  * A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.
1135
1542
  * Explicit word ends are kept as written, so duration tags survive unchanged.
1136
1543
  */
1137
- var readWords$1 = (text, lineStart) => {
1544
+ var readWords$2 = (pieces, lineStart) => {
1138
1545
  const drafts = [];
1139
1546
  const open = /* @__PURE__ */ new Set();
1140
1547
  const closeSegment = (boundary) => {
@@ -1144,9 +1551,9 @@ var readWords$1 = (text, lineStart) => {
1144
1551
  };
1145
1552
  let segmentStart = lineStart;
1146
1553
  let cursor = lineStart;
1147
- for (const piece of splitWordTags(text)) {
1148
- const time = piece.tag === void 0 ? void 0 : parseWordTime(piece.tag, lineStart);
1149
- if (time?.start !== void 0) {
1554
+ for (const piece of pieces) {
1555
+ const time = piece.tag ? parseWordTime(piece.tag, lineStart) : void 0;
1556
+ if (time) {
1150
1557
  closeSegment(time.start);
1151
1558
  segmentStart = time.start;
1152
1559
  cursor = time.start;
@@ -1162,7 +1569,7 @@ var readWords$1 = (text, lineStart) => {
1162
1569
  });
1163
1570
  continue;
1164
1571
  }
1165
- const tagged = !applied && time?.start !== void 0;
1572
+ const tagged = !applied && !!time;
1166
1573
  applied = true;
1167
1574
  const start = tagged ? segmentStart : cursor;
1168
1575
  const end = tagged ? time?.end : void 0;
@@ -1182,23 +1589,26 @@ var readWords$1 = (text, lineStart) => {
1182
1589
  };
1183
1590
  };
1184
1591
  /**
1185
- * A line ends at the next line's start; without one, it ends at its final word's end.
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.
1592
+ * Explicit word ranges can extend past the next line's start, so the inferred end covers both.
1593
+ * A trailing open segment uses that end when it is known.
1188
1594
  */
1189
1595
  var readLine$1 = (text, start, nextStart) => {
1190
1596
  const trimmed = text.trim();
1191
1597
  let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
1192
- if (trimmed === "") return makeLineInstrumental({ time: makeTimeRange({
1598
+ if (!trimmed) return makeLineInstrumental({ time: makeTimeRange({
1193
1599
  start,
1194
1600
  end
1195
1601
  }) });
1196
- const wordTimed = isWordTimed(text);
1197
- const { drafts, closeSegment } = wordTimed ? readWords$1(text, start) : {
1602
+ const pieces = splitWordTags(text);
1603
+ const wordTimed = pieces.some((piece) => !!piece.tag);
1604
+ const { drafts, closeSegment } = wordTimed ? readWords$2(pieces, start) : {
1198
1605
  drafts: [],
1199
1606
  closeSegment: () => {}
1200
1607
  };
1201
1608
  if (end === void 0) end = [...drafts].reverse().find((draft) => !draft.space && draft.start !== void 0)?.end;
1609
+ if (end !== void 0) {
1610
+ for (const draft of drafts) if (draft.end !== void 0) end = Math.max(end, draft.end);
1611
+ }
1202
1612
  closeSegment(end);
1203
1613
  const words = drafts.map((draft) => {
1204
1614
  if (draft.space) return makeWordSpace({ text: draft.text });
@@ -1225,29 +1635,97 @@ var readLine$1 = (text, start, nextStart) => {
1225
1635
  * Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.
1226
1636
  * Empty timed rows become instrumental lines bounded by the next timestamp when one is usable.
1227
1637
  */
1228
- var readLines = (timed) => {
1638
+ var readLines$1 = (timed, options) => {
1229
1639
  const lines = [];
1230
1640
  for (const [index, item] of timed.entries()) {
1231
1641
  const next = timed[index + 1]?.start;
1232
- lines.push(readLine$1(item.text, item.start, next));
1642
+ const line = readLine$1(item.text, item.start, next);
1643
+ lines.push(line);
1644
+ options.onLine?.(line, readLineTiming(item.tag));
1233
1645
  }
1234
1646
  return lines;
1235
1647
  };
1236
- var parseLrcText = (text) => {
1648
+ /**
1649
+ * Reads one LRC track before normalization so consumers retain source order and timing slots.
1650
+ * The caller owns metadata mapping, annotation attachment, and final normalization.
1651
+ *
1652
+ * @param text Original lyrics or one auxiliary track in LRC syntax.
1653
+ * @param options Optional observer for source timestamp information.
1654
+ *
1655
+ * @returns Model lines in input order and metadata tags with lowercase keys and trimmed values.
1656
+ */
1657
+ var readLrc = (text, options = {}) => {
1237
1658
  const { timed, meta } = readTimedLines(text);
1238
- const metaTags = readMetaTags(meta);
1239
- if (!timed.length) return makeLyric({
1659
+ return {
1660
+ lines: readLines$1(timed, options),
1661
+ metaTags: meta
1662
+ };
1663
+ };
1664
+ //#endregion
1665
+ //#region src/formats/lrc/parser.ts
1666
+ var isBareNumber = (value) => {
1667
+ return /^\d+$/.test(value.trim());
1668
+ };
1669
+ /**
1670
+ * Unmapped tags and unparseable durations remain in `unknowns` to avoid discarding metadata.
1671
+ * Keys are lowercased and values trimmed, so original tag spelling is not preserved.
1672
+ */
1673
+ var readMetaTags = (tags) => {
1674
+ const meta = makeMeta();
1675
+ for (const tag of tags) switch (tag.key) {
1676
+ case "length":
1677
+ case "duration": {
1678
+ const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : NaN);
1679
+ if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
1680
+ else meta.unknowns.push(makeUnknownEntry({
1681
+ key: tag.key,
1682
+ value: tag.value
1683
+ }));
1684
+ break;
1685
+ }
1686
+ case "offset":
1687
+ meta.offset = Math.trunc(Number(tag.value)) || 0;
1688
+ break;
1689
+ case "ti":
1690
+ case "title":
1691
+ meta.titles.push(makeText({ text: tag.value }));
1692
+ break;
1693
+ case "ar":
1694
+ case "artist":
1695
+ meta.artists.push(makeText({ text: tag.value }));
1696
+ break;
1697
+ case "al":
1698
+ case "album":
1699
+ meta.albums.push(makeText({ text: tag.value }));
1700
+ break;
1701
+ case "by":
1702
+ case "author":
1703
+ meta.authors.push(makeText({ text: tag.value }));
1704
+ break;
1705
+ case "isrc":
1706
+ meta.isrcs.push(tag.value);
1707
+ break;
1708
+ default: meta.unknowns.push(makeUnknownEntry({
1709
+ key: tag.key,
1710
+ value: tag.value
1711
+ }));
1712
+ }
1713
+ return meta;
1714
+ };
1715
+ var parseLrcText = (text) => {
1716
+ const { lines, metaTags } = readLrc(text);
1717
+ const meta = readMetaTags(metaTags);
1718
+ if (!lines.length) return makeLyric({
1240
1719
  format: "lrc",
1241
1720
  status: LyricStatus.INVALID,
1242
- meta: metaTags
1721
+ meta
1243
1722
  });
1244
- const lines = readLines(timed);
1245
1723
  const wordTimed = lines.some((line) => (line.content?.words.length ?? 0) > 0);
1246
1724
  return makeLyric({
1247
1725
  format: "lrc",
1248
1726
  status: LyricStatus.VALID,
1249
1727
  timing: wordTimed ? Timing.WORD : Timing.LINE,
1250
- meta: metaTags,
1728
+ meta,
1251
1729
  languages: deriveLanguageUsages(lines),
1252
1730
  lines
1253
1731
  });
@@ -1256,6 +1734,7 @@ var parseLrcText = (text) => {
1256
1734
  * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
1257
1735
  *
1258
1736
  * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
1737
+ * Complete roman tracks attach to words by timing and optional local recovery; other tracks retain their source line text.
1259
1738
  *
1260
1739
  * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
1261
1740
  */
@@ -1263,19 +1742,450 @@ var parseLrc = (input, options = {}) => {
1263
1742
  const source = readLrcContent(input) ?? { original: "" };
1264
1743
  const lyric = parseLrcText(source.original);
1265
1744
  if (lyric.status === LyricStatus.INVALID) return finalizeLyric(lyric);
1266
- const translations = source.translations === void 0 ? [] : readLines(readTimedLines(source.translations).timed);
1267
- const romans = source.romans === void 0 ? [] : readLines(readTimedLines(source.romans).timed);
1268
- if (translations.length === 0 && romans.length === 0) return finalizeLyric(lyric);
1269
- const lines = attachLineAnnotations(lyric.lines, {
1745
+ const translations = source.translations ? readLrc(source.translations).lines : [];
1746
+ const romans = source.romans ? readLrc(source.romans).lines : [];
1747
+ if (!translations.length && !romans.length) return finalizeLyric(lyric);
1748
+ const lines = attachAnnotations(lyric.lines, {
1270
1749
  translations,
1271
1750
  romans
1272
- }, { tolerance: options.annotationTolerance });
1751
+ }, {
1752
+ tolerance: options.annotationTolerance,
1753
+ wordTolerance: options.wordAnnotationTolerance,
1754
+ wordFallback: options.wordAnnotationFallback
1755
+ });
1273
1756
  return finalizeLyric({
1274
1757
  ...lyric,
1275
1758
  lines
1276
1759
  });
1277
1760
  };
1278
1761
  //#endregion
1762
+ //#region src/formats/qrc/common.ts
1763
+ var LINE_PATTERN = /^\[(\d+),(\d+)\]/;
1764
+ var XML_ROOT_NAMES = [
1765
+ "QrcInfos",
1766
+ "LyricInfo",
1767
+ "Lyric_1"
1768
+ ];
1769
+ var XML_PATTERN = /^\s*</;
1770
+ var TIMED_LINE_PATTERN = /(?:^|[\r\n])[^\S\r\n]*\[\d+,\d+\]/;
1771
+ /**
1772
+ * Unwraps supported QRC XML while keeping plaintext literal.
1773
+ *
1774
+ * XML entities are decoded once by the XML reader.
1775
+ *
1776
+ * @returns The payload, or null when XML is malformed or has no supported lyric content.
1777
+ */
1778
+ var readText$1 = (source) => {
1779
+ if (!XML_PATTERN.test(source)) return source;
1780
+ if (!XML_ROOT_NAMES.some((name) => source.includes(name))) return null;
1781
+ const document = parseXml(source);
1782
+ if (!document) return null;
1783
+ const root = document.children.find((node) => node.nodeType === XmlNodeType.element);
1784
+ if (!root || !XML_ROOT_NAMES.includes(root.localName)) return null;
1785
+ const pending = [root];
1786
+ while (pending.length) {
1787
+ const element = pending.pop();
1788
+ const content = getXmlAttribute(element, "LyricContent");
1789
+ if (content !== void 0) return content;
1790
+ for (let index = element.children.length - 1; index >= 0; index -= 1) {
1791
+ const child = element.children[index];
1792
+ if (child.nodeType === XmlNodeType.element) pending.push(child);
1793
+ }
1794
+ }
1795
+ return null;
1796
+ };
1797
+ /**
1798
+ * Validates the original payload and keeps auxiliary tracks separate so each can use its own format.
1799
+ *
1800
+ * @returns Normalized input, or null when the original content cannot be read.
1801
+ */
1802
+ var readContent = (input) => {
1803
+ const value = typeof input === "string" ? { original: input } : input;
1804
+ if (!value || typeof value !== "object") return null;
1805
+ const source = value;
1806
+ if (typeof source.original !== "string") return null;
1807
+ const original = readText$1(source.original);
1808
+ if (original === null) return null;
1809
+ return {
1810
+ original,
1811
+ translations: typeof source.translations === "string" ? source.translations : void 0,
1812
+ romans: typeof source.romans === "string" ? source.romans : void 0
1813
+ };
1814
+ };
1815
+ /**
1816
+ * Requires a timed QRC row so metadata alone cannot select the format.
1817
+ */
1818
+ var hasTimedLines = (text) => {
1819
+ return TIMED_LINE_PATTERN.test(text);
1820
+ };
1821
+ //#endregion
1822
+ //#region src/formats/qrc/detect.ts
1823
+ /**
1824
+ * Detects QRC from the original track so annotations cannot change format selection.
1825
+ *
1826
+ * @returns Whether plaintext or a supported XML payload contains a timed QRC row.
1827
+ */
1828
+ var detectQrc = (input) => {
1829
+ const content = readContent(input);
1830
+ return !!content && hasTimedLines(content.original);
1831
+ };
1832
+ //#endregion
1833
+ //#region src/formats/qrc/parser.ts
1834
+ var WORD_PATTERN = /\((\d+),(\d+)\)/g;
1835
+ var META_PATTERN = /^([A-Za-z0-9_-]+)\s*:\s*([\s\S]*)$/;
1836
+ var META_TAG_PATTERN = /\[([^\]]*)\]/g;
1837
+ var RUBY_PATTERN = /\((\d+),(\d+)\)|(\d+)|([^0-9()]+)/gy;
1838
+ var RUBY_WORD_PATTERN = /[\p{Script=Han}\p{Number}]/u;
1839
+ var SEPARATORS = /^\p{White_Space}+$/u;
1840
+ var NATIVE_ANNOTATION_TIMING = {
1841
+ precision: 1,
1842
+ rounding: 1
1843
+ };
1844
+ var readRubyEntries = (value) => {
1845
+ const entries = [];
1846
+ let length = 0;
1847
+ let tokens = [];
1848
+ let text = "";
1849
+ let cursor = 0;
1850
+ const finish = () => {
1851
+ if (text.trim()) tokens.push(makeWordAnnotationToken({ text }));
1852
+ if (!length || !tokens.length) return false;
1853
+ entries.push({
1854
+ length,
1855
+ ruby: makeWordAnnotationRuby({ tokens })
1856
+ });
1857
+ tokens = [];
1858
+ text = "";
1859
+ return true;
1860
+ };
1861
+ for (const match of value.matchAll(RUBY_PATTERN)) {
1862
+ cursor = match.index + match[0].length;
1863
+ if (match[3]) {
1864
+ if (length && !finish()) return;
1865
+ length = Number(match[3]);
1866
+ if (!Number.isSafeInteger(length) || length <= 0) return;
1867
+ } else if (match[1]) {
1868
+ const start = Number(match[1]);
1869
+ const end = start + Number(match[2]);
1870
+ if (!length || !text.trim() || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return;
1871
+ tokens.push(makeWordAnnotationToken({
1872
+ text,
1873
+ time: {
1874
+ start,
1875
+ end
1876
+ }
1877
+ }));
1878
+ text = "";
1879
+ } else {
1880
+ if (!length) return;
1881
+ text += match[4];
1882
+ }
1883
+ }
1884
+ return cursor === value.length && finish() ? entries : void 0;
1885
+ };
1886
+ var readRubies = (meta) => {
1887
+ let entries;
1888
+ const unknowns = [];
1889
+ for (const entry of meta.unknowns) if (entry.key === "kana") entries ??= readRubyEntries(entry.value);
1890
+ else unknowns.push(entry);
1891
+ meta.unknowns = unknowns;
1892
+ return entries;
1893
+ };
1894
+ var matchRubies = (words, sources) => {
1895
+ const starts = /* @__PURE__ */ new Map();
1896
+ for (const [index, candidate] of words.entries()) {
1897
+ const start = candidate?.word.time?.start;
1898
+ if (start !== void 0 && Number.isSafeInteger(start) && start >= 0) {
1899
+ const queue = starts.get(start) ?? [];
1900
+ queue.push(index);
1901
+ starts.set(start, queue);
1902
+ }
1903
+ }
1904
+ const timed = /* @__PURE__ */ new Set();
1905
+ const claims = /* @__PURE__ */ new Map();
1906
+ for (const [index, source] of sources.entries()) {
1907
+ const times = source.ruby.tokens.flatMap((token) => token.time ? [token.time] : []);
1908
+ if (!times.length) continue;
1909
+ timed.add(index);
1910
+ const start = times[0].start;
1911
+ const end = times.at(-1).end;
1912
+ if (start === void 0) continue;
1913
+ let candidates = (starts.get(start) ?? []).filter((target) => {
1914
+ const candidate = words[target];
1915
+ const finish = candidate.word.time?.end;
1916
+ return candidate.length === source.length && finish !== void 0 && Number.isSafeInteger(finish) && times.every((time) => {
1917
+ return time.start !== void 0 && time.end !== void 0 && time.start >= start && time.end >= time.start && time.end <= finish;
1918
+ });
1919
+ });
1920
+ if (times.length === source.ruby.tokens.length) {
1921
+ const exact = candidates.filter((target) => words[target].word.time.end === end);
1922
+ if (exact.length) candidates = exact;
1923
+ }
1924
+ if (candidates.length !== 1) continue;
1925
+ const target = candidates[0];
1926
+ const queue = claims.get(target) ?? [];
1927
+ queue.push(index);
1928
+ claims.set(target, queue);
1929
+ }
1930
+ const matched = /* @__PURE__ */ new Map();
1931
+ for (const [target, queue] of claims) if (queue.length === 1) matched.set(queue[0], target);
1932
+ const anchors = [
1933
+ [-1, -1],
1934
+ ...[...matched].sort((first, second) => first[0] - second[0]),
1935
+ [sources.length, words.length]
1936
+ ];
1937
+ for (let index = 1; index < anchors.length; index += 1) if (anchors[index][1] <= anchors[index - 1][1]) return matched;
1938
+ for (let index = 1; index < anchors.length; index += 1) {
1939
+ const [firstSource, firstWord] = anchors[index - 1];
1940
+ const [lastSource, lastWord] = anchors[index];
1941
+ const pending = [];
1942
+ let compatible = true;
1943
+ for (let source = firstSource + 1; source < lastSource; source += 1) {
1944
+ if (timed.has(source)) {
1945
+ compatible = false;
1946
+ break;
1947
+ }
1948
+ pending.push(source);
1949
+ }
1950
+ if (!compatible || !pending.length) continue;
1951
+ const candidates = [];
1952
+ for (let target = firstWord + 1; target < lastWord; target += 1) {
1953
+ const candidate = words[target];
1954
+ if (!candidate) {
1955
+ compatible = false;
1956
+ break;
1957
+ }
1958
+ if (candidate.byOrder) candidates.push(target);
1959
+ }
1960
+ if (!compatible || candidates.length !== pending.length || pending.some((source, position) => sources[source].length !== words[candidates[position]].length)) continue;
1961
+ for (const [position, source] of pending.entries()) matched.set(source, candidates[position]);
1962
+ }
1963
+ return matched;
1964
+ };
1965
+ /**
1966
+ * Attaches whole-word ruby without changing the source text, segmentation, or timing.
1967
+ *
1968
+ * @param lines Original QRC rows before filtering or sorting.
1969
+ * @param sources Readings with source coverage lengths and optional token times.
1970
+ *
1971
+ * @returns Annotated rows, reusing arrays and nodes when they are unchanged.
1972
+ */
1973
+ var attachRubies = (lines, sources) => {
1974
+ if (!sources.length) return lines;
1975
+ const words = [];
1976
+ for (const line of lines) {
1977
+ if (line.type !== LineType.NORMAL) continue;
1978
+ if (line.content?.text && RUBY_WORD_PATTERN.test(line.content.text)) {
1979
+ words.push(void 0);
1980
+ continue;
1981
+ }
1982
+ for (const word of line.content?.words ?? []) if (word.type === WordType.NORMAL) words.push({
1983
+ word,
1984
+ length: [...word.text].length,
1985
+ byOrder: RUBY_WORD_PATTERN.test(word.text)
1986
+ });
1987
+ }
1988
+ const matched = matchRubies(words, sources);
1989
+ if (!matched.size) return lines;
1990
+ const annotations = new Map([...matched].map(([source, target]) => [words[target].word, sources[source].ruby]));
1991
+ return mapShared(lines, (line) => {
1992
+ const content = line.content;
1993
+ if (!content) return line;
1994
+ const words = mapShared(content.words, (word) => {
1995
+ const ruby = annotations.get(word);
1996
+ if (!ruby) return word;
1997
+ return {
1998
+ ...word,
1999
+ annotation: makeWordAnnotation({
2000
+ ...word.annotation,
2001
+ rubies: [...word.annotation?.rubies ?? [], ruby]
2002
+ })
2003
+ };
2004
+ });
2005
+ return words === content.words ? line : {
2006
+ ...line,
2007
+ content: {
2008
+ ...content,
2009
+ words
2010
+ }
2011
+ };
2012
+ });
2013
+ };
2014
+ var readMeta = (body, meta) => {
2015
+ const match = META_PATTERN.exec(body);
2016
+ if (!match) return;
2017
+ const key = match[1].toLowerCase();
2018
+ const value = match[2].trim();
2019
+ switch (key) {
2020
+ case "length":
2021
+ case "duration": {
2022
+ const duration = parseTime(value) ?? (/^\d+$/.test(value) ? Number(value) : NaN);
2023
+ if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
2024
+ else meta.unknowns.push(makeUnknownEntry({
2025
+ key,
2026
+ value
2027
+ }));
2028
+ break;
2029
+ }
2030
+ case "offset":
2031
+ meta.offset = Math.trunc(Number(value)) || 0;
2032
+ break;
2033
+ case "ti":
2034
+ case "title":
2035
+ meta.titles.push(makeText({ text: value }));
2036
+ break;
2037
+ case "ar":
2038
+ case "artist":
2039
+ meta.artists.push(makeText({ text: value }));
2040
+ break;
2041
+ case "al":
2042
+ case "album":
2043
+ meta.albums.push(makeText({ text: value }));
2044
+ break;
2045
+ case "by":
2046
+ case "author":
2047
+ meta.authors.push(makeText({ text: value }));
2048
+ break;
2049
+ case "isrc":
2050
+ meta.isrcs.push(value);
2051
+ break;
2052
+ default: meta.unknowns.push(makeUnknownEntry({
2053
+ key,
2054
+ value
2055
+ }));
2056
+ }
2057
+ };
2058
+ var readTimeRange = (startText, durationText) => {
2059
+ const start = Number(startText);
2060
+ return makeTimeRange({
2061
+ start,
2062
+ end: start + Number(durationText)
2063
+ });
2064
+ };
2065
+ var appendWords = (words, text, time) => {
2066
+ for (const fragment of text.split(/(\p{White_Space}+)/u)) {
2067
+ if (!fragment) continue;
2068
+ if (SEPARATORS.test(fragment)) words.push(makeWordSpace({ text: fragment }));
2069
+ else words.push(makeWordNormal({
2070
+ text: fragment,
2071
+ time
2072
+ }));
2073
+ }
2074
+ };
2075
+ var readWords$1 = (text) => {
2076
+ const words = [];
2077
+ let cursor = 0;
2078
+ let tagged = false;
2079
+ for (const match of text.matchAll(WORD_PATTERN)) {
2080
+ tagged = true;
2081
+ appendWords(words, text.slice(cursor, match.index), readTimeRange(match[1], match[2]));
2082
+ cursor = match.index + match[0].length;
2083
+ }
2084
+ if (!tagged) return;
2085
+ appendWords(words, text.slice(cursor));
2086
+ return words;
2087
+ };
2088
+ var readLines = (text) => {
2089
+ const lines = [];
2090
+ const meta = makeMeta();
2091
+ const placeholders = /* @__PURE__ */ new Set();
2092
+ for (const raw of text.split(/\r\n?|\n/)) {
2093
+ const line = raw.trimStart();
2094
+ const match = LINE_PATTERN.exec(line);
2095
+ if (match) {
2096
+ const time = readTimeRange(match[1], match[2]);
2097
+ const text = line.slice(match[0].length);
2098
+ const words = readWords$1(text);
2099
+ const contentText = (words ? getWordsText(words) : text).trim();
2100
+ const placeholder = contentText === "//";
2101
+ if (placeholder) placeholders.add(lines.length);
2102
+ if (!contentText || placeholder) lines.push(makeLineInstrumental({ time }));
2103
+ else {
2104
+ const content = words ? makeLineContentWords(words) : makeLineContentText(text);
2105
+ lines.push(makeLineNormal({
2106
+ time,
2107
+ content
2108
+ }));
2109
+ }
2110
+ continue;
2111
+ }
2112
+ if (!line.startsWith("[")) continue;
2113
+ for (const tag of line.matchAll(META_TAG_PATTERN)) {
2114
+ const body = tag[1];
2115
+ if (isTime(body)) continue;
2116
+ readMeta(body, meta);
2117
+ }
2118
+ }
2119
+ return {
2120
+ lines,
2121
+ meta,
2122
+ placeholders
2123
+ };
2124
+ };
2125
+ var readAnnotations = (text, timings) => {
2126
+ if (!text) return [];
2127
+ const { lines } = readLines(text);
2128
+ if (lines.length) {
2129
+ for (const line of lines) timings.set(line, NATIVE_ANNOTATION_TIMING);
2130
+ return lines;
2131
+ }
2132
+ readLrc(text, { onLine: (line, timing) => {
2133
+ const annotation = getContentText(line.content).trim() === "//" ? makeLineInstrumental({ time: line.time }) : line;
2134
+ timings.set(annotation, timing);
2135
+ lines.push(annotation);
2136
+ } });
2137
+ return lines;
2138
+ };
2139
+ /**
2140
+ * Parses plaintext or XML QRC, retaining explicit timing, word romanization, and ruby from kana metadata.
2141
+ * Original-track kana supplies ruby and is removed from the returned metadata.
2142
+ *
2143
+ * @param input Original lyrics with optional translation and romanization tracks.
2144
+ * @param options Independent line and word tolerances, with optional local roman recovery.
2145
+ *
2146
+ * @returns Canonical lyrics and source diagnostics; an empty result has INVALID status.
2147
+ */
2148
+ var parseQrc = (input, options = {}) => {
2149
+ const source = readContent(input);
2150
+ if (!source) return finalizeLyric(makeLyric({
2151
+ format: "qrc",
2152
+ status: LyricStatus.INVALID
2153
+ }));
2154
+ const parsed = readLines(source.original);
2155
+ const meta = parsed.meta;
2156
+ const rubies = readRubies(meta);
2157
+ if (!parsed.lines.length) return finalizeLyric(makeLyric({
2158
+ format: "qrc",
2159
+ status: LyricStatus.INVALID,
2160
+ meta
2161
+ }));
2162
+ const timings = /* @__PURE__ */ new WeakMap();
2163
+ const translationLines = readAnnotations(source.translations && readText$1(source.translations), timings);
2164
+ const romanLines = readAnnotations(source.romans && readText$1(source.romans), timings);
2165
+ const rounding = romanLines.some((line) => timings.get(line)?.rounding === 1) ? 1 : 0;
2166
+ const wordTolerance = Number.isFinite(options.wordAnnotationTolerance) ? options.wordAnnotationTolerance ?? 0 : 0;
2167
+ const annotated = attachAnnotations(!rubies ? parsed.lines : attachRubies(parsed.lines, rubies), {
2168
+ translations: translationLines,
2169
+ romans: romanLines
2170
+ }, {
2171
+ matchByOrder: true,
2172
+ preservePlaceholders: true,
2173
+ tolerance: options.annotationTolerance,
2174
+ wordTolerance: Math.max(wordTolerance, rounding),
2175
+ wordFallback: options.wordAnnotationFallback,
2176
+ getTiming: (line) => timings.get(line)
2177
+ });
2178
+ const lines = parsed.placeholders.size ? annotated.filter((_, index) => !parsed.placeholders.has(index)) : annotated;
2179
+ return finalizeLyric(makeLyric({
2180
+ format: "qrc",
2181
+ status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
2182
+ timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
2183
+ meta,
2184
+ lines,
2185
+ languages: deriveLanguageUsages(lines)
2186
+ }));
2187
+ };
2188
+ //#endregion
1279
2189
  //#region src/formats/ttml/common.ts
1280
2190
  var FORMAT = "ttml";
1281
2191
  var AMLL_HINT_PATTERN = /xmlns:amll|amll:meta/iu;
@@ -1404,7 +2314,7 @@ var renderTime = (time, offset) => {
1404
2314
  * Writes known bounds without inventing an end.
1405
2315
  */
1406
2316
  var renderTimeAttributes = (time, offset) => {
1407
- if (time === void 0) throw new Error("TTML sung lines, words, and ruby tokens require a time range");
2317
+ if (!time) throw new Error("TTML sung lines, words, and ruby tokens require a time range");
1408
2318
  const attributes = [["begin", renderTime(time.start, offset)]];
1409
2319
  if (time.end !== void 0) attributes.push(["end", renderTime(time.end, offset)]);
1410
2320
  return attributes;
@@ -1426,7 +2336,7 @@ var renderWord = (word, context, language) => {
1426
2336
  if (word.type === WordType.SPACE) return createXmlText(word.text);
1427
2337
  const attributes = [...renderTimeAttributes(word.time, context.offset), ...renderLanguage(word.language, language)];
1428
2338
  const ruby = context.profile === "amll" ? word.annotation?.rubies[0] : void 0;
1429
- if (ruby === void 0) return createXmlElement("span", attributes, [createXmlText(word.text)]);
2339
+ if (!ruby) return createXmlElement("span", attributes, [createXmlText(word.text)]);
1430
2340
  const tokens = ruby.tokens.map((token) => {
1431
2341
  return createXmlElement("span", [["tts:ruby", RUBY_TEXT], ...renderTimeAttributes(resolveAnnotationTokenTime(token, ruby, word), context.offset)], [createXmlText(token.text)]);
1432
2342
  });
@@ -1441,7 +2351,7 @@ var renderWord = (word, context, language) => {
1441
2351
  */
1442
2352
  var renderContent = (line, context) => {
1443
2353
  const content = line.content;
1444
- if (content === void 0) throw new Error("TTML sung lines require content");
2354
+ if (!content) throw new Error("TTML sung lines require content");
1445
2355
  if (content.text !== void 0) return [createXmlText(content.text)];
1446
2356
  return content.words.map((word) => {
1447
2357
  return renderWord(word, context, getLineLanguage(line));
@@ -1450,10 +2360,10 @@ var renderContent = (line, context) => {
1450
2360
  /**
1451
2361
  * Keeps annotations outside the sung word stream.
1452
2362
  */
1453
- var renderInlineAnnotations = (annotation, language) => {
2363
+ var renderInlineAnnotations = (line, language) => {
1454
2364
  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)]));
2365
+ for (const item of line.annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
2366
+ for (const item of getLineRomans(line)) nodes.push(createXmlElement("span", [["ttm:role", ROLE_ROMAN], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1457
2367
  return nodes;
1458
2368
  };
1459
2369
  /**
@@ -1469,7 +2379,7 @@ var renderLineAttributes = (line, context) => {
1469
2379
  const lead = line.agents[0];
1470
2380
  if (lead !== void 0) {
1471
2381
  const id = context.agents.get(lead);
1472
- if (id === void 0) throw new Error("TTML performing agents must have a declaration");
2382
+ if (!id) throw new Error("TTML performing agents must have a declaration");
1473
2383
  attributes.push(["ttm:agent", id]);
1474
2384
  }
1475
2385
  return attributes;
@@ -1482,7 +2392,7 @@ var createLyricElement = (name, attributes, children) => {
1482
2392
  };
1483
2393
  var renderBackground = (line, context) => {
1484
2394
  const children = renderContent(line, context);
1485
- if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
2395
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
1486
2396
  return createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ...renderLineAttributes(line, context)], children);
1487
2397
  };
1488
2398
  var renderLine = (line, context) => {
@@ -1490,7 +2400,7 @@ var renderLine = (line, context) => {
1490
2400
  const after = [];
1491
2401
  for (const background of line.backgrounds) {
1492
2402
  const node = renderBackground(background, context);
1493
- if (background.time !== void 0 && line.time !== void 0 && background.time.start < line.time.start) before.push(node);
2403
+ if (background.time && line.time && background.time.start < line.time.start) before.push(node);
1494
2404
  else after.push(node);
1495
2405
  }
1496
2406
  const children = [
@@ -1498,7 +2408,7 @@ var renderLine = (line, context) => {
1498
2408
  ...renderContent(line, context),
1499
2409
  ...after
1500
2410
  ];
1501
- if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
2411
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
1502
2412
  return createLyricElement("p", renderLineAttributes(line, context), children);
1503
2413
  };
1504
2414
  var renderAgents = (lyric, context) => {
@@ -1533,10 +2443,10 @@ var renderSongwriters = (meta) => {
1533
2443
  if (credit.role !== CreditRole.LYRICIST) continue;
1534
2444
  for (const name of credit.names) names.push(createXmlElement("songwriter", renderLanguage(name.language), [createXmlText(name.text)]));
1535
2445
  }
1536
- return names.length === 0 ? [] : [createXmlElement("songwriters", [], names)];
2446
+ return names.length ? [createXmlElement("songwriters", [], names)] : [];
1537
2447
  };
1538
2448
  var renderAnnotationTracks = (tracks, kind, context) => {
1539
- if (tracks.size === 0) return [];
2449
+ if (!tracks.size) return [];
1540
2450
  const translated = kind === "translations";
1541
2451
  const blocks = Array.from(tracks, ([language, track]) => {
1542
2452
  const attributes = translated ? [["type", "subtitle"]] : [];
@@ -1556,26 +2466,29 @@ var renderHeadAnnotations = (lines, context) => {
1556
2466
  translations: /* @__PURE__ */ new Map(),
1557
2467
  romans: /* @__PURE__ */ new Map()
1558
2468
  };
1559
- const append = (annotation, line, background) => {
1560
- if (annotation === void 0) return;
2469
+ const append = (source, line, background) => {
2470
+ const annotation = {
2471
+ translations: source.annotation?.translations ?? [],
2472
+ romans: getLineRomans(source)
2473
+ };
1561
2474
  for (const kind of ANNOTATION_KINDS) for (const item of annotation[kind]) {
1562
2475
  const language = item.language?.toLowerCase();
1563
2476
  let track = tracks[kind].get(language);
1564
- if (track === void 0) {
2477
+ if (!track) {
1565
2478
  track = /* @__PURE__ */ new Map();
1566
2479
  tracks[kind].set(language, track);
1567
2480
  }
1568
2481
  let nodes = track.get(line);
1569
- if (nodes === void 0) {
2482
+ if (!nodes) {
1570
2483
  nodes = [];
1571
2484
  track.set(line, nodes);
1572
2485
  }
1573
- nodes.push(background === void 0 ? createXmlText(item.text) : createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ["for", context.keys.get(background)]], [createXmlText(item.text)]));
2486
+ nodes.push(!background ? createXmlText(item.text) : createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ["for", context.keys.get(background)]], [createXmlText(item.text)]));
1574
2487
  }
1575
2488
  };
1576
2489
  for (const line of lines) {
1577
- append(line.annotation, line);
1578
- for (const background of line.backgrounds) append(background.annotation, line, background);
2490
+ append(line, line);
2491
+ for (const background of line.backgrounds) append(background, line, background);
1579
2492
  }
1580
2493
  return ANNOTATION_KINDS.flatMap((kind) => {
1581
2494
  return renderAnnotationTracks(tracks[kind], kind, context);
@@ -1584,12 +2497,12 @@ var renderHeadAnnotations = (lines, context) => {
1584
2497
  var renderMetadata = (lyric, lines, context, includeMeta) => {
1585
2498
  const metadata = renderAgents(lyric, context);
1586
2499
  const itunes = [];
1587
- if (includeMeta && lyric.meta !== void 0) {
2500
+ if (includeMeta && lyric.meta) {
1588
2501
  if (context.profile === "amll") metadata.push(...renderAmllMeta(lyric.meta));
1589
2502
  itunes.push(...renderSongwriters(lyric.meta));
1590
2503
  }
1591
2504
  if (context.profile === "itunes") itunes.push(...renderHeadAnnotations(lines, context));
1592
- if (itunes.length > 0) metadata.push(createXmlElement("iTunesMetadata", [["xmlns", NAMESPACE_ITUNES_METADATA]], itunes));
2505
+ if (itunes.length) metadata.push(createXmlElement("iTunesMetadata", [["xmlns", NAMESPACE_ITUNES_METADATA]], itunes));
1593
2506
  return createXmlElement("metadata", renderLanguage(void 0, lyric.meta?.language), metadata);
1594
2507
  };
1595
2508
  var renderRootAttributes = (lyric, profile) => {
@@ -1608,6 +2521,7 @@ var renderRootAttributes = (lyric, profile) => {
1608
2521
  *
1609
2522
  * Offsets are baked into timestamps; a negative result or a missing required time throws.
1610
2523
  * Profile mappings retain the lead agent and first AMLL ruby, omitting unsupported fields and instrumental nodes.
2524
+ * Word romanizations are emitted as line text without their token timing.
1611
2525
  */
1612
2526
  var generateTtml = (lyric, options = {}) => {
1613
2527
  if (lyric.timing !== Timing.LINE && lyric.timing !== Timing.WORD) throw new Error("TTML generation requires line or word timing");
@@ -1691,7 +2605,7 @@ var readText = (element, inherited) => {
1691
2605
  * Preserves the parser's whitespace segmentation when turning text into word drafts.
1692
2606
  */
1693
2607
  var splitWords = (text, language) => {
1694
- if (text === "") return [];
2608
+ if (!text) return [];
1695
2609
  if (!WORD_SEPARATOR_PATTERN.test(text)) return [{
1696
2610
  text,
1697
2611
  space: false,
@@ -1699,7 +2613,7 @@ var splitWords = (text, language) => {
1699
2613
  rubies: []
1700
2614
  }];
1701
2615
  const words = [];
1702
- for (const run of text.split(/(\p{White_Space}+)/u)) if (run !== "") {
2616
+ for (const run of text.split(/(\p{White_Space}+)/u)) if (run) {
1703
2617
  const space = /^\p{White_Space}+$/u.test(run);
1704
2618
  words.push({
1705
2619
  text: run,
@@ -1714,7 +2628,7 @@ var splitWords = (text, language) => {
1714
2628
  * Distinguishes indentation runs from separators using the parser's existing rule.
1715
2629
  */
1716
2630
  var isFormattingWhitespace = (text) => {
1717
- return text.trim() === "" && text.includes("\n");
2631
+ return !text.trim() && text.includes("\n");
1718
2632
  };
1719
2633
  /**
1720
2634
  * Builds words from source drafts without deriving missing timestamps.
@@ -1730,7 +2644,7 @@ var buildWords = (draft) => {
1730
2644
  start: word.start,
1731
2645
  end: word.end
1732
2646
  });
1733
- if (word.rubies.length === 0) {
2647
+ if (!word.rubies.length) {
1734
2648
  words.push(makeWordNormal({
1735
2649
  text: word.text,
1736
2650
  time,
@@ -1769,7 +2683,7 @@ var buildWords = (draft) => {
1769
2683
  * Builds only annotations explicitly present in the source drafts.
1770
2684
  */
1771
2685
  var buildLineAnnotation = (draft) => {
1772
- if (draft.translations.length === 0 && draft.romans.length === 0) return;
2686
+ if (!draft.translations.length && !draft.romans.length) return;
1773
2687
  return makeLineAnnotation({
1774
2688
  translations: draft.translations.map((item) => {
1775
2689
  return makeLineAnnotationTranslation({
@@ -1820,9 +2734,9 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
1820
2734
  preserveSpace,
1821
2735
  language
1822
2736
  }];
1823
- while (stack.length > 0) {
2737
+ while (stack.length) {
1824
2738
  const entry = stack.pop();
1825
- if (entry === void 0) continue;
2739
+ if (!entry) continue;
1826
2740
  const element = entry.element;
1827
2741
  const currentSpace = readPreserveSpace(element, entry.preserveSpace);
1828
2742
  const currentLanguage = readLanguage(element, entry.language);
@@ -1833,7 +2747,7 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
1833
2747
  preserveSpace: currentSpace,
1834
2748
  language: currentLanguage
1835
2749
  };
1836
- if (group === void 0) result.set(element.localName, [context]);
2750
+ if (!group) result.set(element.localName, [context]);
1837
2751
  else group.push(context);
1838
2752
  if (stopAtMatch) continue;
1839
2753
  }
@@ -1893,22 +2807,22 @@ var readRuby = (container, preserveSpace, language) => {
1893
2807
  const last = tokens.findLast((token) => {
1894
2808
  return token.start !== void 0;
1895
2809
  });
1896
- const time = readSpanTime(container) ?? rubyTime ?? (first === void 0 ? void 0 : {
2810
+ const time = readSpanTime(container) ?? rubyTime ?? (first ? {
1897
2811
  start: first.start,
1898
2812
  end: last?.end
1899
- });
2813
+ } : void 0);
1900
2814
  return {
1901
2815
  text: base,
1902
2816
  space: false,
1903
2817
  start: time?.start,
1904
2818
  end: time?.end,
1905
2819
  language: baseLanguage,
1906
- rubies: tokens.length === 0 ? [] : [{
2820
+ rubies: tokens.length ? [{
1907
2821
  start: rubyTime?.start,
1908
2822
  end: rubyTime?.end,
1909
2823
  language: rubyLanguage,
1910
2824
  tokens
1911
- }]
2825
+ }] : []
1912
2826
  };
1913
2827
  };
1914
2828
  /**
@@ -1932,7 +2846,7 @@ var readWords = (element, preserveSpace, language) => {
1932
2846
  if (getXmlAttribute(child, "role") !== void 0) continue;
1933
2847
  if (getXmlAttribute(child, "ruby") === "container") {
1934
2848
  const ruby = readRuby(child, preserveSpace, language);
1935
- if (ruby !== void 0) {
2849
+ if (ruby) {
1936
2850
  for (const part of splitWords(ruby.text)) words.push(part.space ? part : {
1937
2851
  ...ruby,
1938
2852
  text: part.text
@@ -1946,7 +2860,7 @@ var readWords = (element, preserveSpace, language) => {
1946
2860
  if (!text || !childSpace && isFormattingWhitespace(text)) continue;
1947
2861
  const time = readSpanTime(child);
1948
2862
  for (const part of splitWords(text, readLanguage(child, language))) {
1949
- if (!part.space && time !== void 0) {
2863
+ if (!part.space && time) {
1950
2864
  part.start = time.start;
1951
2865
  part.end = time.end;
1952
2866
  wordLevel = true;
@@ -1986,7 +2900,7 @@ var readLine = (element, preserveSpace, language) => {
1986
2900
  preserveSpace = readPreserveSpace(element, preserveSpace);
1987
2901
  language = readLanguage(element, language);
1988
2902
  const time = readLineTime(element);
1989
- if (time === void 0) return;
2903
+ if (!time) return;
1990
2904
  const draft = {
1991
2905
  start: time.start,
1992
2906
  end: time.end,
@@ -2027,7 +2941,7 @@ var readBackground = (element, preserveSpace, language) => {
2027
2941
  preserveSpace = readPreserveSpace(element, preserveSpace);
2028
2942
  language = readLanguage(element, language);
2029
2943
  const read = readWords(element, preserveSpace, language);
2030
- if (read.words.length === 0) return;
2944
+ if (!read.words.length) return;
2031
2945
  const begin = getXmlAttribute(element, "begin");
2032
2946
  const end = getXmlAttribute(element, "end");
2033
2947
  const first = read.words.find((word) => {
@@ -2069,7 +2983,7 @@ var buildBackground = (draft) => {
2069
2983
  end: draft.end
2070
2984
  }),
2071
2985
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
2072
- languages: draft.language === void 0 ? [] : [draft.language],
2986
+ languages: draft.language ? [draft.language] : [],
2073
2987
  content: buildContent(draft),
2074
2988
  annotation: buildLineAnnotation(draft)
2075
2989
  });
@@ -2085,7 +2999,7 @@ var buildLine = (draft) => {
2085
2999
  end: draft.end
2086
3000
  }),
2087
3001
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
2088
- languages: draft.language === void 0 ? [] : [draft.language],
3002
+ languages: draft.language ? [draft.language] : [],
2089
3003
  content: buildContent(draft),
2090
3004
  annotation: buildLineAnnotation(draft),
2091
3005
  backgrounds: draft.backgrounds.map(buildBackground)
@@ -2105,7 +3019,7 @@ var readAgents = (elements) => {
2105
3019
  language: readLanguage(name, language)
2106
3020
  });
2107
3021
  }).filter((name) => {
2108
- return name.text !== "";
3022
+ return !!name.text;
2109
3023
  });
2110
3024
  const kind = readAgentType(getXmlAttribute(element, "type"));
2111
3025
  agents.push(makeAgent({
@@ -2129,9 +3043,9 @@ var readCredits = (elements, meta) => {
2129
3043
  language
2130
3044
  });
2131
3045
  }).filter((name) => {
2132
- return name.text !== "";
3046
+ return !!name.text;
2133
3047
  });
2134
- if (names.length === 0) return;
3048
+ if (!names.length) return;
2135
3049
  meta.credits.push(makeMetaCredit({
2136
3050
  role: CreditRole.LYRICIST,
2137
3051
  names
@@ -2185,7 +3099,7 @@ var applyAmllMeta = (meta, element, language) => {
2185
3099
  * A replacement translation rewrites the line's text, which is how a source swaps one script for another.
2186
3100
  */
2187
3101
  var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
2188
- if (lineMap.size === 0) return;
3102
+ if (!lineMap.size) return;
2189
3103
  const attach = (blocks, kind) => {
2190
3104
  for (const { element: block, preserveSpace, language } of blocks) {
2191
3105
  const type = getXmlQualifiedAttribute(block, "type") ?? getXmlAttribute(block, "type");
@@ -2254,9 +3168,9 @@ var parseTtml = (input, options = {}) => {
2254
3168
  const language = readLanguage(root, lowerTag(options.language));
2255
3169
  const body = findElements(root, "body", false, true, language)[0];
2256
3170
  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);
3171
+ const metadataElements = metadata ? groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language) : /* @__PURE__ */ new Map();
2258
3172
  const meta = makeMeta();
2259
- if (language !== void 0) meta.language = language;
3173
+ if (language) meta.language = language;
2260
3174
  readCredits(metadataElements.get("songwriter") ?? [], meta);
2261
3175
  for (const { element, language: itemLanguage } of metadataElements.get("meta") ?? []) applyAmllMeta(meta, element, itemLanguage);
2262
3176
  const drafts = [];
@@ -2270,7 +3184,7 @@ var parseTtml = (input, options = {}) => {
2270
3184
  for (const background of draft.backgrounds) if (background.key) backgroundMap.set(background.key, background);
2271
3185
  }
2272
3186
  attachHeadAnnotations(metadataElements, lineMap, backgroundMap);
2273
- if (drafts.length === 0) return finalizeLyric(makeLyric({
3187
+ if (!drafts.length) return finalizeLyric(makeLyric({
2274
3188
  format: FORMAT,
2275
3189
  status: LyricStatus.INVALID,
2276
3190
  meta,
@@ -2301,6 +3215,7 @@ var parseTtml = (input, options = {}) => {
2301
3215
  * Unrecognized text returns `undefined`.
2302
3216
  */
2303
3217
  var detectFormat = (input) => {
3218
+ if (detectQrc(input)) return "qrc";
2304
3219
  if (detectLrc(input)) return "lrc";
2305
3220
  if (detectTtml(input)) return "ttml";
2306
3221
  };
@@ -2313,6 +3228,7 @@ var parse = (input, options = {}) => {
2313
3228
  if (!format) return null;
2314
3229
  switch (format) {
2315
3230
  case "lrc": return parseLrc(input, options);
3231
+ case "qrc": return parseQrc(input, options);
2316
3232
  case "ttml": return parseTtml(input, options);
2317
3233
  }
2318
3234
  };
@@ -2323,7 +3239,7 @@ var GENERATORS = {
2323
3239
  /**
2324
3240
  * Generates text in the requested format, which need not match `lyric.format`.
2325
3241
  *
2326
- * The writer map pairs each format's options with its result and requires a writer for every supported format.
3242
+ * The writer map pairs options with results only for formats that have an implemented writer.
2327
3243
  * An unknown target returns `null`.
2328
3244
  */
2329
3245
  var generate = (lyric, target, options = {}) => {
@@ -2331,6 +3247,6 @@ var generate = (lyric, target, options = {}) => {
2331
3247
  return GENERATORS[target](lyric, options);
2332
3248
  };
2333
3249
  //#endregion
2334
- export { detectFormat, detectLrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseTtml };
3250
+ export { detectFormat, detectLrc, detectQrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseQrc, parseTtml, readLrc };
2335
3251
 
2336
3252
  //# sourceMappingURL=formats.js.map