@gondola-lyrics/kit 0.3.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, 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,17 +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.
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.
1187
1594
  */
1188
1595
  var readLine$1 = (text, start, nextStart) => {
1189
- const wordTimed = isWordTimed(text);
1190
- const { drafts, closeSegment } = wordTimed ? readWords$1(text, start) : {
1596
+ const trimmed = text.trim();
1597
+ let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
1598
+ if (!trimmed) return makeLineInstrumental({ time: makeTimeRange({
1599
+ start,
1600
+ end
1601
+ }) });
1602
+ const pieces = splitWordTags(text);
1603
+ const wordTimed = pieces.some((piece) => !!piece.tag);
1604
+ const { drafts, closeSegment } = wordTimed ? readWords$2(pieces, start) : {
1191
1605
  drafts: [],
1192
1606
  closeSegment: () => {}
1193
1607
  };
1194
- let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
1195
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
+ }
1196
1612
  closeSegment(end);
1197
1613
  const words = drafts.map((draft) => {
1198
1614
  if (draft.space) return makeWordSpace({ text: draft.text });
@@ -1209,7 +1625,7 @@ var readLine$1 = (text, start, nextStart) => {
1209
1625
  start,
1210
1626
  end
1211
1627
  });
1212
- const content = wordTimed ? makeLineContentWords(words) : makeLineContentText(text.trim());
1628
+ const content = wordTimed ? makeLineContentWords(words) : makeLineContentText(trimmed);
1213
1629
  return makeLineNormal({
1214
1630
  time,
1215
1631
  content
@@ -1217,39 +1633,108 @@ var readLine$1 = (text, start, nextStart) => {
1217
1633
  };
1218
1634
  /**
1219
1635
  * Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.
1220
- * An empty timed row still yields a line, since it marks a gap rather than a parse failure.
1636
+ * Empty timed rows become instrumental lines bounded by the next timestamp when one is usable.
1221
1637
  */
1222
- var readLines = (timed) => {
1638
+ var readLines$1 = (timed, options) => {
1223
1639
  const lines = [];
1224
1640
  for (const [index, item] of timed.entries()) {
1225
1641
  const next = timed[index + 1]?.start;
1226
- 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));
1227
1645
  }
1228
1646
  return lines;
1229
1647
  };
1230
- 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 = {}) => {
1231
1658
  const { timed, meta } = readTimedLines(text);
1232
- const metaTags = readMetaTags(meta);
1233
- 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({
1234
1719
  format: "lrc",
1235
1720
  status: LyricStatus.INVALID,
1236
- meta: metaTags
1721
+ meta
1237
1722
  });
1238
- const lines = readLines(timed);
1239
1723
  const wordTimed = lines.some((line) => (line.content?.words.length ?? 0) > 0);
1240
1724
  return makeLyric({
1241
1725
  format: "lrc",
1242
1726
  status: LyricStatus.VALID,
1243
1727
  timing: wordTimed ? Timing.WORD : Timing.LINE,
1244
- meta: metaTags,
1728
+ meta,
1245
1729
  languages: deriveLanguageUsages(lines),
1246
1730
  lines
1247
1731
  });
1248
1732
  };
1249
1733
  /**
1250
- * Source order, empty timed lines, and missing word times are preserved for model validation.
1734
+ * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
1251
1735
  *
1252
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.
1253
1738
  *
1254
1739
  * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
1255
1740
  */
@@ -1257,19 +1742,450 @@ var parseLrc = (input, options = {}) => {
1257
1742
  const source = readLrcContent(input) ?? { original: "" };
1258
1743
  const lyric = parseLrcText(source.original);
1259
1744
  if (lyric.status === LyricStatus.INVALID) return finalizeLyric(lyric);
1260
- const translations = source.translations === void 0 ? [] : readLines(readTimedLines(source.translations).timed);
1261
- const romans = source.romans === void 0 ? [] : readLines(readTimedLines(source.romans).timed);
1262
- if (translations.length === 0 && romans.length === 0) return finalizeLyric(lyric);
1263
- 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, {
1264
1749
  translations,
1265
1750
  romans
1266
- }, { tolerance: options.annotationTolerance });
1751
+ }, {
1752
+ tolerance: options.annotationTolerance,
1753
+ wordTolerance: options.wordAnnotationTolerance,
1754
+ wordFallback: options.wordAnnotationFallback
1755
+ });
1267
1756
  return finalizeLyric({
1268
1757
  ...lyric,
1269
1758
  lines
1270
1759
  });
1271
1760
  };
1272
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
1273
2189
  //#region src/formats/ttml/common.ts
1274
2190
  var FORMAT = "ttml";
1275
2191
  var AMLL_HINT_PATTERN = /xmlns:amll|amll:meta/iu;
@@ -1398,7 +2314,7 @@ var renderTime = (time, offset) => {
1398
2314
  * Writes known bounds without inventing an end.
1399
2315
  */
1400
2316
  var renderTimeAttributes = (time, offset) => {
1401
- 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");
1402
2318
  const attributes = [["begin", renderTime(time.start, offset)]];
1403
2319
  if (time.end !== void 0) attributes.push(["end", renderTime(time.end, offset)]);
1404
2320
  return attributes;
@@ -1420,7 +2336,7 @@ var renderWord = (word, context, language) => {
1420
2336
  if (word.type === WordType.SPACE) return createXmlText(word.text);
1421
2337
  const attributes = [...renderTimeAttributes(word.time, context.offset), ...renderLanguage(word.language, language)];
1422
2338
  const ruby = context.profile === "amll" ? word.annotation?.rubies[0] : void 0;
1423
- if (ruby === void 0) return createXmlElement("span", attributes, [createXmlText(word.text)]);
2339
+ if (!ruby) return createXmlElement("span", attributes, [createXmlText(word.text)]);
1424
2340
  const tokens = ruby.tokens.map((token) => {
1425
2341
  return createXmlElement("span", [["tts:ruby", RUBY_TEXT], ...renderTimeAttributes(resolveAnnotationTokenTime(token, ruby, word), context.offset)], [createXmlText(token.text)]);
1426
2342
  });
@@ -1435,7 +2351,7 @@ var renderWord = (word, context, language) => {
1435
2351
  */
1436
2352
  var renderContent = (line, context) => {
1437
2353
  const content = line.content;
1438
- if (content === void 0) throw new Error("TTML sung lines require content");
2354
+ if (!content) throw new Error("TTML sung lines require content");
1439
2355
  if (content.text !== void 0) return [createXmlText(content.text)];
1440
2356
  return content.words.map((word) => {
1441
2357
  return renderWord(word, context, getLineLanguage(line));
@@ -1444,10 +2360,10 @@ var renderContent = (line, context) => {
1444
2360
  /**
1445
2361
  * Keeps annotations outside the sung word stream.
1446
2362
  */
1447
- var renderInlineAnnotations = (annotation, language) => {
2363
+ var renderInlineAnnotations = (line, language) => {
1448
2364
  const nodes = [];
1449
- for (const item of annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1450
- for (const item of annotation?.romans ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_ROMAN], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
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)]));
1451
2367
  return nodes;
1452
2368
  };
1453
2369
  /**
@@ -1463,7 +2379,7 @@ var renderLineAttributes = (line, context) => {
1463
2379
  const lead = line.agents[0];
1464
2380
  if (lead !== void 0) {
1465
2381
  const id = context.agents.get(lead);
1466
- 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");
1467
2383
  attributes.push(["ttm:agent", id]);
1468
2384
  }
1469
2385
  return attributes;
@@ -1476,7 +2392,7 @@ var createLyricElement = (name, attributes, children) => {
1476
2392
  };
1477
2393
  var renderBackground = (line, context) => {
1478
2394
  const children = renderContent(line, context);
1479
- if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
2395
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
1480
2396
  return createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ...renderLineAttributes(line, context)], children);
1481
2397
  };
1482
2398
  var renderLine = (line, context) => {
@@ -1484,7 +2400,7 @@ var renderLine = (line, context) => {
1484
2400
  const after = [];
1485
2401
  for (const background of line.backgrounds) {
1486
2402
  const node = renderBackground(background, context);
1487
- 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);
1488
2404
  else after.push(node);
1489
2405
  }
1490
2406
  const children = [
@@ -1492,7 +2408,7 @@ var renderLine = (line, context) => {
1492
2408
  ...renderContent(line, context),
1493
2409
  ...after
1494
2410
  ];
1495
- if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
2411
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
1496
2412
  return createLyricElement("p", renderLineAttributes(line, context), children);
1497
2413
  };
1498
2414
  var renderAgents = (lyric, context) => {
@@ -1527,10 +2443,10 @@ var renderSongwriters = (meta) => {
1527
2443
  if (credit.role !== CreditRole.LYRICIST) continue;
1528
2444
  for (const name of credit.names) names.push(createXmlElement("songwriter", renderLanguage(name.language), [createXmlText(name.text)]));
1529
2445
  }
1530
- return names.length === 0 ? [] : [createXmlElement("songwriters", [], names)];
2446
+ return names.length ? [createXmlElement("songwriters", [], names)] : [];
1531
2447
  };
1532
2448
  var renderAnnotationTracks = (tracks, kind, context) => {
1533
- if (tracks.size === 0) return [];
2449
+ if (!tracks.size) return [];
1534
2450
  const translated = kind === "translations";
1535
2451
  const blocks = Array.from(tracks, ([language, track]) => {
1536
2452
  const attributes = translated ? [["type", "subtitle"]] : [];
@@ -1550,26 +2466,29 @@ var renderHeadAnnotations = (lines, context) => {
1550
2466
  translations: /* @__PURE__ */ new Map(),
1551
2467
  romans: /* @__PURE__ */ new Map()
1552
2468
  };
1553
- const append = (annotation, line, background) => {
1554
- if (annotation === void 0) return;
2469
+ const append = (source, line, background) => {
2470
+ const annotation = {
2471
+ translations: source.annotation?.translations ?? [],
2472
+ romans: getLineRomans(source)
2473
+ };
1555
2474
  for (const kind of ANNOTATION_KINDS) for (const item of annotation[kind]) {
1556
2475
  const language = item.language?.toLowerCase();
1557
2476
  let track = tracks[kind].get(language);
1558
- if (track === void 0) {
2477
+ if (!track) {
1559
2478
  track = /* @__PURE__ */ new Map();
1560
2479
  tracks[kind].set(language, track);
1561
2480
  }
1562
2481
  let nodes = track.get(line);
1563
- if (nodes === void 0) {
2482
+ if (!nodes) {
1564
2483
  nodes = [];
1565
2484
  track.set(line, nodes);
1566
2485
  }
1567
- 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)]));
1568
2487
  }
1569
2488
  };
1570
2489
  for (const line of lines) {
1571
- append(line.annotation, line);
1572
- 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);
1573
2492
  }
1574
2493
  return ANNOTATION_KINDS.flatMap((kind) => {
1575
2494
  return renderAnnotationTracks(tracks[kind], kind, context);
@@ -1578,12 +2497,12 @@ var renderHeadAnnotations = (lines, context) => {
1578
2497
  var renderMetadata = (lyric, lines, context, includeMeta) => {
1579
2498
  const metadata = renderAgents(lyric, context);
1580
2499
  const itunes = [];
1581
- if (includeMeta && lyric.meta !== void 0) {
2500
+ if (includeMeta && lyric.meta) {
1582
2501
  if (context.profile === "amll") metadata.push(...renderAmllMeta(lyric.meta));
1583
2502
  itunes.push(...renderSongwriters(lyric.meta));
1584
2503
  }
1585
2504
  if (context.profile === "itunes") itunes.push(...renderHeadAnnotations(lines, context));
1586
- 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));
1587
2506
  return createXmlElement("metadata", renderLanguage(void 0, lyric.meta?.language), metadata);
1588
2507
  };
1589
2508
  var renderRootAttributes = (lyric, profile) => {
@@ -1602,6 +2521,7 @@ var renderRootAttributes = (lyric, profile) => {
1602
2521
  *
1603
2522
  * Offsets are baked into timestamps; a negative result or a missing required time throws.
1604
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.
1605
2525
  */
1606
2526
  var generateTtml = (lyric, options = {}) => {
1607
2527
  if (lyric.timing !== Timing.LINE && lyric.timing !== Timing.WORD) throw new Error("TTML generation requires line or word timing");
@@ -1685,7 +2605,7 @@ var readText = (element, inherited) => {
1685
2605
  * Preserves the parser's whitespace segmentation when turning text into word drafts.
1686
2606
  */
1687
2607
  var splitWords = (text, language) => {
1688
- if (text === "") return [];
2608
+ if (!text) return [];
1689
2609
  if (!WORD_SEPARATOR_PATTERN.test(text)) return [{
1690
2610
  text,
1691
2611
  space: false,
@@ -1693,7 +2613,7 @@ var splitWords = (text, language) => {
1693
2613
  rubies: []
1694
2614
  }];
1695
2615
  const words = [];
1696
- 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) {
1697
2617
  const space = /^\p{White_Space}+$/u.test(run);
1698
2618
  words.push({
1699
2619
  text: run,
@@ -1708,7 +2628,7 @@ var splitWords = (text, language) => {
1708
2628
  * Distinguishes indentation runs from separators using the parser's existing rule.
1709
2629
  */
1710
2630
  var isFormattingWhitespace = (text) => {
1711
- return text.trim() === "" && text.includes("\n");
2631
+ return !text.trim() && text.includes("\n");
1712
2632
  };
1713
2633
  /**
1714
2634
  * Builds words from source drafts without deriving missing timestamps.
@@ -1724,7 +2644,7 @@ var buildWords = (draft) => {
1724
2644
  start: word.start,
1725
2645
  end: word.end
1726
2646
  });
1727
- if (word.rubies.length === 0) {
2647
+ if (!word.rubies.length) {
1728
2648
  words.push(makeWordNormal({
1729
2649
  text: word.text,
1730
2650
  time,
@@ -1763,7 +2683,7 @@ var buildWords = (draft) => {
1763
2683
  * Builds only annotations explicitly present in the source drafts.
1764
2684
  */
1765
2685
  var buildLineAnnotation = (draft) => {
1766
- if (draft.translations.length === 0 && draft.romans.length === 0) return;
2686
+ if (!draft.translations.length && !draft.romans.length) return;
1767
2687
  return makeLineAnnotation({
1768
2688
  translations: draft.translations.map((item) => {
1769
2689
  return makeLineAnnotationTranslation({
@@ -1814,9 +2734,9 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
1814
2734
  preserveSpace,
1815
2735
  language
1816
2736
  }];
1817
- while (stack.length > 0) {
2737
+ while (stack.length) {
1818
2738
  const entry = stack.pop();
1819
- if (entry === void 0) continue;
2739
+ if (!entry) continue;
1820
2740
  const element = entry.element;
1821
2741
  const currentSpace = readPreserveSpace(element, entry.preserveSpace);
1822
2742
  const currentLanguage = readLanguage(element, entry.language);
@@ -1827,7 +2747,7 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
1827
2747
  preserveSpace: currentSpace,
1828
2748
  language: currentLanguage
1829
2749
  };
1830
- if (group === void 0) result.set(element.localName, [context]);
2750
+ if (!group) result.set(element.localName, [context]);
1831
2751
  else group.push(context);
1832
2752
  if (stopAtMatch) continue;
1833
2753
  }
@@ -1887,22 +2807,22 @@ var readRuby = (container, preserveSpace, language) => {
1887
2807
  const last = tokens.findLast((token) => {
1888
2808
  return token.start !== void 0;
1889
2809
  });
1890
- const time = readSpanTime(container) ?? rubyTime ?? (first === void 0 ? void 0 : {
2810
+ const time = readSpanTime(container) ?? rubyTime ?? (first ? {
1891
2811
  start: first.start,
1892
2812
  end: last?.end
1893
- });
2813
+ } : void 0);
1894
2814
  return {
1895
2815
  text: base,
1896
2816
  space: false,
1897
2817
  start: time?.start,
1898
2818
  end: time?.end,
1899
2819
  language: baseLanguage,
1900
- rubies: tokens.length === 0 ? [] : [{
2820
+ rubies: tokens.length ? [{
1901
2821
  start: rubyTime?.start,
1902
2822
  end: rubyTime?.end,
1903
2823
  language: rubyLanguage,
1904
2824
  tokens
1905
- }]
2825
+ }] : []
1906
2826
  };
1907
2827
  };
1908
2828
  /**
@@ -1926,7 +2846,7 @@ var readWords = (element, preserveSpace, language) => {
1926
2846
  if (getXmlAttribute(child, "role") !== void 0) continue;
1927
2847
  if (getXmlAttribute(child, "ruby") === "container") {
1928
2848
  const ruby = readRuby(child, preserveSpace, language);
1929
- if (ruby !== void 0) {
2849
+ if (ruby) {
1930
2850
  for (const part of splitWords(ruby.text)) words.push(part.space ? part : {
1931
2851
  ...ruby,
1932
2852
  text: part.text
@@ -1940,7 +2860,7 @@ var readWords = (element, preserveSpace, language) => {
1940
2860
  if (!text || !childSpace && isFormattingWhitespace(text)) continue;
1941
2861
  const time = readSpanTime(child);
1942
2862
  for (const part of splitWords(text, readLanguage(child, language))) {
1943
- if (!part.space && time !== void 0) {
2863
+ if (!part.space && time) {
1944
2864
  part.start = time.start;
1945
2865
  part.end = time.end;
1946
2866
  wordLevel = true;
@@ -1980,7 +2900,7 @@ var readLine = (element, preserveSpace, language) => {
1980
2900
  preserveSpace = readPreserveSpace(element, preserveSpace);
1981
2901
  language = readLanguage(element, language);
1982
2902
  const time = readLineTime(element);
1983
- if (time === void 0) return;
2903
+ if (!time) return;
1984
2904
  const draft = {
1985
2905
  start: time.start,
1986
2906
  end: time.end,
@@ -2021,7 +2941,7 @@ var readBackground = (element, preserveSpace, language) => {
2021
2941
  preserveSpace = readPreserveSpace(element, preserveSpace);
2022
2942
  language = readLanguage(element, language);
2023
2943
  const read = readWords(element, preserveSpace, language);
2024
- if (read.words.length === 0) return;
2944
+ if (!read.words.length) return;
2025
2945
  const begin = getXmlAttribute(element, "begin");
2026
2946
  const end = getXmlAttribute(element, "end");
2027
2947
  const first = read.words.find((word) => {
@@ -2063,7 +2983,7 @@ var buildBackground = (draft) => {
2063
2983
  end: draft.end
2064
2984
  }),
2065
2985
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
2066
- languages: draft.language === void 0 ? [] : [draft.language],
2986
+ languages: draft.language ? [draft.language] : [],
2067
2987
  content: buildContent(draft),
2068
2988
  annotation: buildLineAnnotation(draft)
2069
2989
  });
@@ -2079,7 +2999,7 @@ var buildLine = (draft) => {
2079
2999
  end: draft.end
2080
3000
  }),
2081
3001
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
2082
- languages: draft.language === void 0 ? [] : [draft.language],
3002
+ languages: draft.language ? [draft.language] : [],
2083
3003
  content: buildContent(draft),
2084
3004
  annotation: buildLineAnnotation(draft),
2085
3005
  backgrounds: draft.backgrounds.map(buildBackground)
@@ -2099,7 +3019,7 @@ var readAgents = (elements) => {
2099
3019
  language: readLanguage(name, language)
2100
3020
  });
2101
3021
  }).filter((name) => {
2102
- return name.text !== "";
3022
+ return !!name.text;
2103
3023
  });
2104
3024
  const kind = readAgentType(getXmlAttribute(element, "type"));
2105
3025
  agents.push(makeAgent({
@@ -2123,9 +3043,9 @@ var readCredits = (elements, meta) => {
2123
3043
  language
2124
3044
  });
2125
3045
  }).filter((name) => {
2126
- return name.text !== "";
3046
+ return !!name.text;
2127
3047
  });
2128
- if (names.length === 0) return;
3048
+ if (!names.length) return;
2129
3049
  meta.credits.push(makeMetaCredit({
2130
3050
  role: CreditRole.LYRICIST,
2131
3051
  names
@@ -2179,7 +3099,7 @@ var applyAmllMeta = (meta, element, language) => {
2179
3099
  * A replacement translation rewrites the line's text, which is how a source swaps one script for another.
2180
3100
  */
2181
3101
  var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
2182
- if (lineMap.size === 0) return;
3102
+ if (!lineMap.size) return;
2183
3103
  const attach = (blocks, kind) => {
2184
3104
  for (const { element: block, preserveSpace, language } of blocks) {
2185
3105
  const type = getXmlQualifiedAttribute(block, "type") ?? getXmlAttribute(block, "type");
@@ -2248,9 +3168,9 @@ var parseTtml = (input, options = {}) => {
2248
3168
  const language = readLanguage(root, lowerTag(options.language));
2249
3169
  const body = findElements(root, "body", false, true, language)[0];
2250
3170
  const metadata = findElements(root, "metadata", false, true, language)[0];
2251
- const metadataElements = metadata === void 0 ? /* @__PURE__ */ new Map() : groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language);
3171
+ const metadataElements = metadata ? groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language) : /* @__PURE__ */ new Map();
2252
3172
  const meta = makeMeta();
2253
- if (language !== void 0) meta.language = language;
3173
+ if (language) meta.language = language;
2254
3174
  readCredits(metadataElements.get("songwriter") ?? [], meta);
2255
3175
  for (const { element, language: itemLanguage } of metadataElements.get("meta") ?? []) applyAmllMeta(meta, element, itemLanguage);
2256
3176
  const drafts = [];
@@ -2264,7 +3184,7 @@ var parseTtml = (input, options = {}) => {
2264
3184
  for (const background of draft.backgrounds) if (background.key) backgroundMap.set(background.key, background);
2265
3185
  }
2266
3186
  attachHeadAnnotations(metadataElements, lineMap, backgroundMap);
2267
- if (drafts.length === 0) return finalizeLyric(makeLyric({
3187
+ if (!drafts.length) return finalizeLyric(makeLyric({
2268
3188
  format: FORMAT,
2269
3189
  status: LyricStatus.INVALID,
2270
3190
  meta,
@@ -2295,6 +3215,7 @@ var parseTtml = (input, options = {}) => {
2295
3215
  * Unrecognized text returns `undefined`.
2296
3216
  */
2297
3217
  var detectFormat = (input) => {
3218
+ if (detectQrc(input)) return "qrc";
2298
3219
  if (detectLrc(input)) return "lrc";
2299
3220
  if (detectTtml(input)) return "ttml";
2300
3221
  };
@@ -2307,6 +3228,7 @@ var parse = (input, options = {}) => {
2307
3228
  if (!format) return null;
2308
3229
  switch (format) {
2309
3230
  case "lrc": return parseLrc(input, options);
3231
+ case "qrc": return parseQrc(input, options);
2310
3232
  case "ttml": return parseTtml(input, options);
2311
3233
  }
2312
3234
  };
@@ -2317,7 +3239,7 @@ var GENERATORS = {
2317
3239
  /**
2318
3240
  * Generates text in the requested format, which need not match `lyric.format`.
2319
3241
  *
2320
- * 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.
2321
3243
  * An unknown target returns `null`.
2322
3244
  */
2323
3245
  var generate = (lyric, target, options = {}) => {
@@ -2325,6 +3247,6 @@ var generate = (lyric, target, options = {}) => {
2325
3247
  return GENERATORS[target](lyric, options);
2326
3248
  };
2327
3249
  //#endregion
2328
- export { detectFormat, detectLrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseTtml };
3250
+ export { detectFormat, detectLrc, detectQrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseQrc, parseTtml, readLrc };
2329
3251
 
2330
3252
  //# sourceMappingURL=formats.js.map