@gondola-lyrics/kit 0.4.0 → 0.6.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,593 @@
1
- import { AgentType, CreditRole, LineType, LyricStatus, Timing, WordType, canonicalizeLyric, deriveLanguageUsages, getWordsText, makeAgent, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineBackground, makeLineContentText, makeLineContentWords, makeLineInstrumental, makeLineNormal, makeLyric, makeMeta, makeMetaCredit, makeText, makeTimeRange, makeUnknownEntry, makeWordAnnotation, makeWordAnnotationRuby, makeWordAnnotationToken, makeWordNormal, makeWordSpace, removeOffset, resolveAnnotationItemTime, resolveAnnotationTokenTime, validateLyric } from "@gondola-lyrics/model/runtime";
2
- //#region src/utils/annotation.ts
1
+ import { a as lowerBound, i as lowerTag, o as mapShared, r as trimRuleWhitespace } from "./rules.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/formats/krc/common.ts
4
+ var LINE_PATTERN$3 = /^\[(\d+),(\d+)\]/;
5
+ var WORD_PATTERN$3 = /<(\d+,\d+,\d+)>/g;
3
6
  /**
4
- * An annotation line carries its text in either field, depending on whether the track used word tags.
7
+ * Reads decrypted plaintext without unwrapping XML or accepting auxiliary tracks.
8
+ *
9
+ * @returns Original content, or null when the input cannot be read as plaintext KRC.
5
10
  */
6
- var readText$1 = (line) => {
7
- return (line.content?.text ?? getWordsText(line.content?.words ?? [])).trim();
11
+ var readKrcContent = (input) => {
12
+ const source = typeof input === "string" ? { original: input } : input;
13
+ if (!source || typeof source !== "object" || Array.isArray(source)) return null;
14
+ const original = source.original;
15
+ if (typeof original !== "string" || /^\s*</.test(original)) return null;
16
+ return { original };
8
17
  };
9
- var isReadable = (line) => {
10
- return line.time?.start !== void 0 && readText$1(line) !== "";
18
+ //#endregion
19
+ //#region src/formats/krc/detect.ts
20
+ var LANGUAGE_PATTERN = /\[language:\s*ey[A-Za-z0-9+/=]*\]/;
21
+ /**
22
+ * Recognizes native KRC from a duration line carrying a complete three-field word tag, or from the Kugou language container.
23
+ *
24
+ * Both signals live in the original plaintext, so an auxiliary track still cannot decide the format.
25
+ *
26
+ * @returns Whether the original plaintext contains a strong KRC syntax signal.
27
+ */
28
+ var detectKrc = (input) => {
29
+ const source = readKrcContent(input);
30
+ if (!source) return false;
31
+ if (LANGUAGE_PATTERN.test(source.original)) return true;
32
+ return source.original.split(/\r\n?|\n/).some((raw) => {
33
+ const line = raw.trimStart();
34
+ const header = LINE_PATTERN$3.exec(line);
35
+ return !!header && /<\d+,\d+,\d+>/.test(line.slice(header[0].length));
36
+ });
11
37
  };
38
+ //#endregion
39
+ //#region src/formats/result.ts
12
40
  /**
13
- * Assigns each annotation line to a lyric line index.
41
+ * Canonicalizes and validates one parser's output, so every format hands back the same shape.
42
+ *
43
+ * Canonicalization runs first because validation reports paths against the canonical tree.
44
+ */
45
+ var finalizeLyric = (input) => {
46
+ const lyric = canonicalizeLyric(input);
47
+ return {
48
+ lyric,
49
+ diagnostics: validateLyric(lyric)
50
+ };
51
+ };
52
+ //#endregion
53
+ //#region src/utils/kana.ts
54
+ var KANA_COMBINATIONS = /* @__PURE__ */ new Map([
55
+ [12353, "うくぐつふゔ"],
56
+ [12355, "うくぐすずつてでふゔ"],
57
+ [12357, "とど"],
58
+ [12359, "いうくぐしじちぢつにひびぴみりふゔ"],
59
+ [12361, "うくぐつふゔ"],
60
+ [12419, "きぎしじちぢにひびぴみり"],
61
+ [12421, "きぎしじちぢにひびぴみりてでふゔ"],
62
+ [12423, "きぎしじちぢにひびぴみり"],
63
+ [12430, "くぐ"]
64
+ ]);
65
+ var readKana = (text) => {
66
+ const kana = text.normalize("NFKC");
67
+ if (kana.length !== 1) return "";
68
+ const code = kana.charCodeAt(0);
69
+ return code >= 12449 && code <= 12534 ? String.fromCharCode(code - 96) : kana;
70
+ };
71
+ /**
72
+ * Reports whether the second kana joins the first into one mora, so a single roman fragment may cover both.
14
73
  *
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.
74
+ * Katakana and compatibility forms normalize to hiragana first.
75
+ * Doubled consonants, long-vowel marks and three-kana runs are not combinations.
17
76
  */
18
- var matchEntries = (lines, annotations, tolerance) => {
77
+ var combinesKana = (first, second) => {
78
+ const base = readKana(first);
79
+ const small = readKana(second);
80
+ return !!base && !!small && !!KANA_COMBINATIONS.get(small.charCodeAt(0))?.includes(base);
81
+ };
82
+ //#endregion
83
+ //#region src/utils/annotation.ts
84
+ var readText$3 = (line) => {
85
+ return getContentText(line.content).trim();
86
+ };
87
+ /**
88
+ * Combines explicit roman tracks with display-only word aggregations, preferring explicit entries for the same language.
89
+ *
90
+ * Derived entries must not be stored back on the line.
91
+ */
92
+ var getLineRomans = (line) => {
93
+ const explicit = line.annotation?.romans ?? [];
94
+ const languages = new Set(explicit.map((item) => lowerTag(item.language)));
95
+ const derived = deriveLineRomans(line.content).filter((item) => !languages.has(lowerTag(item.language)));
96
+ return derived.length ? [...explicit, ...derived] : explicit;
97
+ };
98
+ var readWordRange = (word) => {
99
+ const time = word.time;
100
+ if (!time || !Number.isSafeInteger(time.start) || time.start < 0 || time.end !== void 0 && (!Number.isSafeInteger(time.end) || time.end < time.start)) return;
101
+ return time;
102
+ };
103
+ var containsRomanTime = (source, target, tolerance) => {
104
+ const end = target.end ?? Number.POSITIVE_INFINITY;
105
+ if (source.start < target.start - tolerance || source.end !== void 0 && source.end > end + tolerance) return false;
106
+ if (source.end === void 0 || source.start === source.end) return target.start === target.end ? source.start <= end + tolerance : source.start < end + tolerance;
107
+ return true;
108
+ };
109
+ var matchRomanTarget = (time, targets, tolerance) => {
110
+ let matched;
111
+ let priority = -1;
112
+ for (const target of targets) {
113
+ if (!containsRomanTime(time, target.time, tolerance)) continue;
114
+ const exact = time.end !== void 0 && time.start === target.time.start && time.end === target.time.end;
115
+ const point = time.start === time.end && target.time.start === target.time.end;
116
+ const rank = exact ? 3 : point ? 2 : containsRomanTime(time, target.time, 0) ? 1 : 0;
117
+ if (rank > priority) {
118
+ matched = target;
119
+ priority = rank;
120
+ } else if (rank === priority) matched = void 0;
121
+ }
122
+ return matched ?? (priority === -1 ? void 0 : null);
123
+ };
124
+ var overlapLength = (time, target) => {
125
+ const finish = time.end ?? time.start;
126
+ return Math.min(finish, target.time.end ?? Number.POSITIVE_INFINITY) - Math.max(time.start, target.time.start);
127
+ };
128
+ var sharesLanguage = (targets) => {
129
+ return new Set(targets.flatMap((target) => target ? [lowerTag(target.word.language)] : []).filter(Boolean)).size <= 1;
130
+ };
131
+ var isOrderedByTime = (targets) => {
132
+ return targets.every((target, index) => {
133
+ return target.time.start >= (targets[index - 1]?.time.start ?? 0);
134
+ });
135
+ };
136
+ var canExtendToFollowing = (target, following) => {
137
+ const first = lowerTag(target.word.language);
138
+ const second = lowerTag(following.word.language);
139
+ return (!first || !second || first === second) && combinesKana(target.word.text, following.word.text);
140
+ };
141
+ var recoverRomanTargets = (sources, targets, tolerance, rounding, language) => {
142
+ const slack = Math.max(tolerance, rounding);
143
+ const used = new Set(sources.flatMap((source) => source.target ? [source.target] : []));
144
+ const reserved = /* @__PURE__ */ new Set();
145
+ const conflict = (target) => {
146
+ return target.word.annotation?.romans.some((item) => lowerTag(item.language) === language);
147
+ };
148
+ for (const [index, source] of sources.entries()) {
149
+ const time = source.time;
150
+ if (source.target || !time || time.end === void 0) continue;
151
+ const exact = targets.filter((target) => target.time.start === time.start);
152
+ const candidates = exact.length ? exact : targets.filter((target) => Math.abs(target.time.start - time.start) <= rounding);
153
+ if (candidates.length !== 1) continue;
154
+ const target = candidates[0];
155
+ const following = targets[targets.indexOf(target) + 1];
156
+ if (!following || following.index !== target.index + 1 || !canExtendToFollowing(target, following)) continue;
157
+ if (used.has(target) || used.has(following) || reserved.has(target) || reserved.has(following)) continue;
158
+ if (conflict(target) || following.word.annotation?.romans.length) continue;
159
+ const previous = sources.slice(0, index).findLast((item) => item.target)?.target;
160
+ const next = sources.slice(index + 1).find((item) => item.target)?.target;
161
+ if (target.index <= (previous?.index ?? -1) || following.index >= (next?.index ?? Number.POSITIVE_INFINITY)) continue;
162
+ if (target.time.start < (previous?.time.start ?? 0) || following.time.start > (next?.time.start ?? Number.POSITIVE_INFINITY)) continue;
163
+ if (!sharesLanguage([
164
+ previous,
165
+ target,
166
+ following,
167
+ next
168
+ ])) continue;
169
+ if (target.time.end === void 0 || following.time.end === void 0 || Math.abs(following.time.start - target.time.end) > rounding) continue;
170
+ if (time.end <= target.time.end || time.end > following.time.end + slack) continue;
171
+ if (targets.some((candidate) => candidate !== target && candidate !== following && overlapLength(time, candidate) > slack)) continue;
172
+ source.target = target;
173
+ used.add(target);
174
+ reserved.add(following);
175
+ }
176
+ for (let start = 0; start < sources.length; start += 1) {
177
+ if (sources[start].target) continue;
178
+ let end = start + 1;
179
+ while (end < sources.length && !sources[end].target) end += 1;
180
+ const previous = sources[start - 1]?.target;
181
+ const next = sources[end]?.target;
182
+ const pending = targets.filter((target) => {
183
+ return target.index > (previous?.index ?? -1) && target.index < (next?.index ?? Number.POSITIVE_INFINITY) && !reserved.has(target);
184
+ });
185
+ if (pending.length !== end - start) return false;
186
+ if (sources.slice(start, end).some((source) => !source.time) && pending.length > 1 && (!previous || !next)) return false;
187
+ const boundaries = [
188
+ previous,
189
+ ...pending,
190
+ next
191
+ ].filter((target) => target !== void 0);
192
+ if (!sharesLanguage(boundaries) || !isOrderedByTime(boundaries)) return false;
193
+ for (const [offset, target] of pending.entries()) {
194
+ const source = sources[start + offset];
195
+ if (used.has(target) || conflict(target)) return false;
196
+ const time = source.time;
197
+ if (time && (overlapLength(time, target) <= 0 || targets.some((candidate) => candidate !== target && overlapLength(time, candidate) > slack))) return false;
198
+ source.target = target;
199
+ used.add(target);
200
+ }
201
+ start = end - 1;
202
+ }
203
+ return true;
204
+ };
205
+ var readRomanLanguage = (source) => {
206
+ const languages = new Set(source.languages.map(lowerTag).filter(Boolean));
207
+ for (const word of source.content?.words ?? []) {
208
+ const language = word.type === WordType.NORMAL ? lowerTag(word.language) : void 0;
209
+ if (language) languages.add(language);
210
+ }
211
+ return {
212
+ language: languages.size === 1 ? languages.values().next().value : lowerTag(source.languages[0]),
213
+ consistent: languages.size <= 1
214
+ };
215
+ };
216
+ var attachWordRomans = (line, source, tolerance, language, fallback) => {
217
+ const content = line.content;
218
+ const sourceContent = source.line.content;
219
+ if (!content?.words.length || content.text !== void 0 || !sourceContent?.words.length || sourceContent.text !== void 0) return;
220
+ const targets = [];
221
+ let spaces = 0;
222
+ for (const [index, word] of content.words.entries()) {
223
+ if (word.type === WordType.SPACE) {
224
+ spaces += 1;
225
+ continue;
226
+ }
227
+ const time = readWordRange(word);
228
+ if (word.type !== WordType.NORMAL || !time) return;
229
+ targets.push({
230
+ word,
231
+ index,
232
+ time,
233
+ spaces,
234
+ tokens: []
235
+ });
236
+ }
237
+ const sources = [];
238
+ let separator = "";
239
+ for (const word of sourceContent.words) {
240
+ if (word.type === WordType.SPACE) {
241
+ separator += word.text;
242
+ continue;
243
+ }
244
+ const time = readWordRange(word);
245
+ if (word.type !== WordType.NORMAL || !word.text.trim() || word.time && !time || !time && !fallback) return;
246
+ const target = time ? matchRomanTarget(time, targets, tolerance) : void 0;
247
+ if (target === null || !target && !fallback) return;
248
+ sources.push({
249
+ word,
250
+ time,
251
+ separator,
252
+ target
253
+ });
254
+ separator = "";
255
+ }
256
+ const anchors = sources.flatMap((source) => source.target ? [source.target] : []);
257
+ if (anchors.some((target, index) => target.index < (anchors[index - 1]?.index ?? -1))) return;
258
+ const recovered = fallback && sources.some((item) => !item.target);
259
+ if (recovered && !recoverRomanTargets(sources, targets, tolerance, source.rounding, language)) return;
260
+ let previous;
261
+ let previousStart = 0;
262
+ for (const { word, time, separator, target } of sources) {
263
+ if (!target || target.index < (previous?.index ?? -1) || target.word.annotation?.romans.some((item) => lowerTag(item.language) === language)) return;
264
+ const bound = target.time.end ?? Number.POSITIVE_INFINITY;
265
+ const start = time ? Math.min(bound, Math.max(target.time.start, time.start)) : target.time.start;
266
+ const end = time?.end === void 0 ? target.time.end : Math.min(bound, Math.max(target.time.start, time.end));
267
+ if (recovered && start < previousStart) return;
268
+ previousStart = start;
269
+ const token = makeWordAnnotationToken({
270
+ text: previous ? word.text : word.text.trimStart(),
271
+ time: {
272
+ start,
273
+ end
274
+ }
275
+ });
276
+ if (previous) {
277
+ const last = previous.tokens.at(-1);
278
+ if (target.spaces > previous.spaces) {
279
+ const space = separator.indexOf(" ");
280
+ if (space === -1) return;
281
+ last.text += separator.slice(0, space);
282
+ token.text = separator.slice(space + 1) + token.text;
283
+ } else last.text += separator;
284
+ }
285
+ target.tokens.push(token);
286
+ previous = target;
287
+ }
288
+ if (!previous) return;
289
+ const last = previous.tokens.at(-1);
290
+ last.text = last.text.trimEnd();
291
+ const words = content.words.slice();
292
+ for (const { word, index, tokens } of targets) {
293
+ if (!tokens.length) continue;
294
+ const roman = makeWordAnnotationRoman({
295
+ language,
296
+ tokens
297
+ });
298
+ words[index] = {
299
+ ...word,
300
+ annotation: makeWordAnnotation({
301
+ ...word.annotation,
302
+ romans: [...word.annotation?.romans ?? [], roman]
303
+ })
304
+ };
305
+ }
306
+ const result = {
307
+ ...line,
308
+ content: {
309
+ ...content,
310
+ words
311
+ }
312
+ };
313
+ return deriveLineRomans(result.content).find((item) => lowerTag(item.language) === language)?.text === source.text ? result : void 0;
314
+ };
315
+ var readSources = (lines, options) => {
316
+ const result = [];
317
+ const preserve = options.preservePlaceholders;
318
+ for (const [index, line] of lines.entries()) {
319
+ const start = line.time?.start;
320
+ const text = readText$3(line);
321
+ const timing = options.getTiming?.(line);
322
+ const precision = timing?.precision;
323
+ if (start !== void 0 && (preserve || text) || options.matchByOrder && text) result.push({
324
+ line,
325
+ index,
326
+ text,
327
+ start,
328
+ precision: precision && Number.isFinite(precision) && precision > 0 ? precision : 1,
329
+ rounding: timing?.rounding === 1 ? 1 : 0
330
+ });
331
+ }
332
+ return result;
333
+ };
334
+ var createTimeIndex = (lines, options) => {
335
+ const starts = [];
336
+ const lyricRows = [];
337
+ const targets = /* @__PURE__ */ new Map();
338
+ for (const [index, line] of lines.entries()) {
339
+ const start = line.time?.start;
340
+ const valid = start !== void 0 && (!options.preservePlaceholders || Number.isFinite(start));
341
+ starts.push(valid ? start : void 0);
342
+ if (options.matchByOrder && line.type === LineType.NORMAL && readText$3(line)) lyricRows.push(index);
343
+ if (!valid) continue;
344
+ const queue = targets.get(start) ?? [];
345
+ queue.push(index);
346
+ targets.set(start, queue);
347
+ }
348
+ return {
349
+ starts,
350
+ lyricRows,
351
+ targets,
352
+ buckets: /* @__PURE__ */ new Map()
353
+ };
354
+ };
355
+ var getTimeBuckets = (index, precision) => {
356
+ const cached = index.buckets.get(precision);
357
+ if (cached) return cached;
358
+ const buckets = /* @__PURE__ */ new Map();
359
+ for (const [target, start] of index.starts.entries()) {
360
+ if (start === void 0 || !Number.isFinite(start)) continue;
361
+ const key = Math.floor(start / precision);
362
+ let bucket = buckets.get(key);
363
+ if (!bucket) {
364
+ bucket = {
365
+ start,
366
+ indices: [],
367
+ mixed: false
368
+ };
369
+ buckets.set(key, bucket);
370
+ }
371
+ bucket.indices.push(target);
372
+ bucket.mixed ||= bucket.start !== start;
373
+ }
374
+ index.buckets.set(precision, buckets);
375
+ return buckets;
376
+ };
377
+ var matchDuplicates = (start, count, index, groups) => {
378
+ const result = [];
379
+ const collect = (precision, key, roundingOnly) => {
380
+ const queue = groups.get(precision)?.get(key);
381
+ if (!queue) return true;
382
+ const bucket = getTimeBuckets(index, precision).get(key);
383
+ if (bucket && (bucket.mixed || bucket.start !== start)) return roundingOnly && queue.every((source) => source.rounding !== 1);
384
+ for (const source of queue) {
385
+ const sourceStart = source.start;
386
+ const rounded = source.rounding === 1;
387
+ if (roundingOnly && !rounded) continue;
388
+ if (rounded && (sourceStart - 1 !== start && index.targets.has(sourceStart - 1) || sourceStart + 1 !== start && index.targets.has(sourceStart + 1))) return false;
389
+ result.push(source);
390
+ if (result.length > count) return false;
391
+ }
392
+ return true;
393
+ };
394
+ for (const precision of groups.keys()) if (!collect(precision, Math.floor(start / precision), false)) return;
395
+ for (const neighbor of [start - 1, start + 1]) if (neighbor !== start && !collect(1, Math.floor(neighbor), true)) return;
396
+ return result;
397
+ };
398
+ var matchAnnotations = (index, sources, options) => {
19
399
  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]);
400
+ if (!sources.length) return matched;
401
+ const preserve = options.preservePlaceholders;
402
+ const byStart = /* @__PURE__ */ new Map();
403
+ for (const source of sources) {
404
+ const start = source.start;
405
+ if (start === void 0 || preserve && !Number.isFinite(start)) continue;
406
+ const queue = byStart.get(start) ?? [];
407
+ queue.push(source);
408
+ byStart.set(start, queue);
28
409
  }
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);
410
+ const usedSources = /* @__PURE__ */ new Set();
411
+ const ambiguous = /* @__PURE__ */ new Set();
412
+ const assign = (target, source) => {
413
+ matched.set(target, source);
414
+ usedSources.add(source);
415
+ };
416
+ for (const [start, queue] of index.targets) {
417
+ const candidates = byStart.get(start);
418
+ if (!candidates) continue;
419
+ if (queue.length > 1) {
420
+ if (!preserve || candidates.length === queue.length) {
421
+ const count = Math.min(queue.length, candidates.length);
422
+ for (let position = 0; position < count; position += 1) assign(queue[position], candidates[position]);
423
+ }
37
424
  continue;
38
425
  }
39
- matched.set(index, annotation);
40
- used.add(index);
426
+ let source = candidates[0];
427
+ for (const candidate of candidates) if (candidate.precision < source.precision) source = candidate;
428
+ assign(queue[0], source);
41
429
  }
42
- if (tolerance <= 0) return matched;
43
- for (const annotation of pending) {
44
- const start = annotation.time?.start;
45
- if (start === void 0) continue;
430
+ if (usedSources.size === sources.length) return matched;
431
+ const readable = options.matchByOrder ? sources.filter((source) => source.text) : [];
432
+ if (readable.length && readable.length === index.lyricRows.length) {
433
+ if (readable.every((source, position) => {
434
+ const target = index.lyricRows[position];
435
+ return !matched.has(target) && !usedSources.has(source) || matched.get(target) === source;
436
+ })) {
437
+ for (const [position, source] of readable.entries()) if (!usedSources.has(source)) assign(index.lyricRows[position], source);
438
+ return matched;
439
+ }
440
+ }
441
+ if (preserve || options.getTiming) {
442
+ const groups = /* @__PURE__ */ new Map();
443
+ for (const source of sources) {
444
+ const start = source.start;
445
+ if (usedSources.has(source) || start === void 0 || !Number.isFinite(start)) continue;
446
+ const precision = source.precision;
447
+ let group = groups.get(precision);
448
+ if (!group) {
449
+ group = /* @__PURE__ */ new Map();
450
+ groups.set(precision, group);
451
+ }
452
+ const key = Math.floor(start / precision);
453
+ const queue = group.get(key) ?? [];
454
+ queue.push(source);
455
+ group.set(key, queue);
456
+ }
457
+ for (const [start, queue] of index.targets) {
458
+ if (queue.length < 2 || matched.has(queue[0])) continue;
459
+ const candidates = matchDuplicates(start, queue.length, index, groups);
460
+ if (!candidates || candidates.length !== queue.length) continue;
461
+ candidates.sort((first, second) => first.index - second.index);
462
+ for (const [position, source] of candidates.entries()) assign(queue[position], source);
463
+ }
464
+ for (const precision of [...groups.keys()].sort((first, second) => first - second)) {
465
+ const candidates = getTimeBuckets(index, precision);
466
+ for (const [key, entries] of groups.get(precision)) {
467
+ const bucket = candidates.get(key);
468
+ if (!bucket) continue;
469
+ const sourceQueue = entries.filter((source) => !usedSources.has(source));
470
+ const targetQueue = bucket.indices.filter((target) => !matched.has(target));
471
+ if (!sourceQueue.length || !targetQueue.length) continue;
472
+ if ((precision > 1 || targetQueue.length > 1) && sourceQueue.length !== targetQueue.length) {
473
+ for (const source of sourceQueue) ambiguous.add(source);
474
+ continue;
475
+ }
476
+ const count = Math.min(sourceQueue.length, targetQueue.length);
477
+ for (let position = 0; position < count; position += 1) assign(targetQueue[position], sourceQueue[position]);
478
+ }
479
+ }
480
+ const rounding = /* @__PURE__ */ new Map();
481
+ const claims = /* @__PURE__ */ new Map();
482
+ for (const source of sources) {
483
+ const start = source.start;
484
+ if (source.rounding !== 1 || usedSources.has(source) || ambiguous.has(source) || start === void 0 || !Number.isFinite(start)) continue;
485
+ let target = -1;
486
+ let count = 0;
487
+ for (const neighbor of [start - 1, start + 1]) for (const candidate of index.targets.get(neighbor) ?? []) {
488
+ if (matched.has(candidate)) continue;
489
+ target = candidate;
490
+ count += 1;
491
+ claims.set(candidate, (claims.get(candidate) ?? 0) + 1);
492
+ }
493
+ if (count === 1) rounding.set(source, target);
494
+ else if (count > 1) ambiguous.add(source);
495
+ }
496
+ for (const [source, target] of rounding) if (claims.get(target) === 1) assign(target, source);
497
+ else ambiguous.add(source);
498
+ }
499
+ const tolerance = options.tolerance ?? 0;
500
+ if (!Number.isFinite(tolerance) || tolerance <= 0) return matched;
501
+ const ordered = index.ordered ??= [...index.targets.keys()].filter(Number.isFinite).sort((first, second) => first - second);
502
+ const cursors = /* @__PURE__ */ new Map();
503
+ for (const source of sources) {
504
+ const start = source.start;
505
+ if (usedSources.has(source) || ambiguous.has(source) || start === void 0 || !Number.isFinite(start)) continue;
46
506
  let target = -1;
47
507
  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;
508
+ const end = start + tolerance;
509
+ for (let position = lowerBound(ordered, start - tolerance); position < ordered.length && ordered[position] <= end; position += 1) {
510
+ const targetStart = ordered[position];
511
+ const current = Math.abs(targetStart - start);
512
+ if (current > tolerance || current > distance) continue;
513
+ const queue = index.targets.get(targetStart);
514
+ let cursor = cursors.get(targetStart) ?? 0;
515
+ while (cursor < queue.length && matched.has(queue[cursor])) cursor += 1;
516
+ cursors.set(targetStart, cursor);
517
+ const candidate = queue[cursor];
518
+ if (candidate !== void 0 && (current < distance || current === distance && candidate < target)) {
519
+ target = candidate;
53
520
  distance = current;
54
521
  }
55
522
  }
56
- if (target !== -1) {
57
- matched.set(target, annotation);
58
- used.add(target);
59
- }
523
+ if (target !== -1) assign(target, source);
60
524
  }
61
525
  return matched;
62
526
  };
527
+ var attachTracks = (lines, sources, options, wordTolerance) => {
528
+ if (!lines.length || !sources.translations?.length && !sources.romans?.length) return lines;
529
+ const index = createTimeIndex(lines, options);
530
+ let result = lines;
531
+ for (const kind of ["translations", "romans"]) {
532
+ const matches = matchAnnotations(index, readSources(sources[kind] ?? [], options), options);
533
+ for (const [target, source] of matches) {
534
+ const line = result[target];
535
+ if (line.type === LineType.INSTRUMENTAL || !source.text) continue;
536
+ const romanLanguage = kind === "romans" && wordTolerance !== void 0 ? readRomanLanguage(source.line) : void 0;
537
+ let annotated = romanLanguage?.consistent && wordTolerance !== void 0 ? attachWordRomans(line, source, wordTolerance, romanLanguage.language, options.wordFallback === true) : void 0;
538
+ if (!annotated) {
539
+ const language = romanLanguage ? romanLanguage.language : source.line.languages[0];
540
+ const create = kind === "romans" ? makeLineAnnotationRoman : makeLineAnnotationTranslation;
541
+ const annotation = makeLineAnnotation({
542
+ ...line.annotation,
543
+ [kind]: [...line.annotation?.[kind] ?? [], create({
544
+ text: source.text,
545
+ language
546
+ })]
547
+ });
548
+ annotated = {
549
+ ...line,
550
+ annotation
551
+ };
552
+ }
553
+ if (result === lines) result = lines.slice();
554
+ result[target] = annotated;
555
+ }
556
+ }
557
+ return result;
558
+ };
63
559
  /**
64
- * Attaches annotation tracks to parsed lines by start time.
560
+ * Attaches complete roman tracks to words using timing and optional local recovery, retaining the source line when matching is incomplete.
561
+ * Translations remain line-level, and successful word tracks are not duplicated on the line.
65
562
  *
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
- });
563
+ * Match before final normalization; original text and word timing are preserved.
564
+ */
565
+ var attachAnnotations = (lines, sources, options = {}) => {
566
+ return attachTracks(lines, sources, options, Number.isFinite(options.wordTolerance) ? Math.max(0, options.wordTolerance ?? 0) : 0);
92
567
  };
93
568
  //#endregion
94
- //#region src/utils/language.ts
569
+ //#region src/utils/encoding.ts
95
570
  /**
96
- * Returns a language tag in the canonical form the model stores, which is lower case.
571
+ * Decodes canonical standard base64 into bytes, identically in browsers and Node.
572
+ * atob decides what is decodable; the round-trip re-encode keeps the result canonical, so slacker forms stay undecoded.
97
573
  *
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.
100
- */
101
- var lowerTag = (tag) => {
102
- return tag ? tag.toLowerCase() : void 0;
574
+ * @param value Base64 text; whitespace, URL-safe alphabets, and misplaced padding are rejected.
575
+ *
576
+ * @returns The decoded bytes, or null when the input is not canonical base64.
577
+ */
578
+ var decodeBase64 = (value) => {
579
+ if (!value) return null;
580
+ try {
581
+ const binary = atob(value);
582
+ if (btoa(binary).replace(/=+$/, "") !== value.replace(/=+$/, "")) return null;
583
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
584
+ } catch {
585
+ return null;
586
+ }
103
587
  };
104
588
  //#endregion
105
589
  //#region src/utils/time.ts
106
- var CLOCK_PATTERN = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
590
+ var CLOCK_PATTERN$1 = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
107
591
  var OFFSET_PATTERN = /^(\d+(?:\.\d+)?)(ms|h|m|s)$/iu;
108
592
  /**
109
593
  * Reads a clock value, where the fraction is padded or truncated to millisecond precision.
@@ -112,7 +596,7 @@ var OFFSET_PATTERN = /^(\d+(?:\.\d+)?)(ms|h|m|s)$/iu;
112
596
  */
113
597
  var parseClock = (value) => {
114
598
  if (!value.includes(":") && !value.includes(".")) return;
115
- const match = CLOCK_PATTERN.exec(value);
599
+ const match = CLOCK_PATTERN$1.exec(value);
116
600
  if (!match) return;
117
601
  const hour = Number(match[1] ?? 0);
118
602
  const minute = Number(match[2] ?? 0);
@@ -194,7 +678,7 @@ var prefixedNames = /* @__PURE__ */ new Map();
194
678
  */
195
679
  var splitXmlName = (name) => {
196
680
  const cached = prefixedNames.get(name);
197
- if (cached !== void 0) return cached;
681
+ if (cached) return cached;
198
682
  const colon = name.indexOf(":");
199
683
  const split = colon === -1 ? {
200
684
  namespacePrefix: "",
@@ -203,7 +687,7 @@ var splitXmlName = (name) => {
203
687
  namespacePrefix: name.slice(0, colon),
204
688
  localName: name.slice(colon + 1)
205
689
  };
206
- if (split.namespacePrefix !== "" && prefixedNames.size < 1024) prefixedNames.set(name, split);
690
+ if (split.namespacePrefix && prefixedNames.size < 1024) prefixedNames.set(name, split);
207
691
  return split;
208
692
  };
209
693
  /**
@@ -265,7 +749,7 @@ var getXmlAttribute = (element, localName) => {
265
749
  const attributes = element.attributes;
266
750
  for (let index = 0; index < attributes.length; index += 1) {
267
751
  const attribute = attributes[index];
268
- if (attribute !== void 0 && attribute.localName === localName) return attribute.value;
752
+ if (attribute && attribute.localName === localName) return attribute.value;
269
753
  }
270
754
  };
271
755
  /**
@@ -275,7 +759,7 @@ var getXmlQualifiedAttribute = (element, qualifiedName) => {
275
759
  const attributes = element.attributes;
276
760
  for (let index = 0; index < attributes.length; index += 1) {
277
761
  const attribute = attributes[index];
278
- if (attribute !== void 0 && attribute.qualifiedName === qualifiedName) return attribute.value;
762
+ if (attribute && attribute.qualifiedName === qualifiedName) return attribute.value;
279
763
  }
280
764
  };
281
765
  /**
@@ -286,7 +770,7 @@ var getXmlChildElements = (element, localName) => {
286
770
  const children = element.children;
287
771
  for (let index = 0; index < children.length; index += 1) {
288
772
  const child = children[index];
289
- if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) result.push(child);
773
+ if (child && child.nodeType === XmlNodeType.element && child.localName === localName) result.push(child);
290
774
  }
291
775
  return result;
292
776
  };
@@ -297,7 +781,7 @@ var hasXmlChildElement = (element, localName) => {
297
781
  const children = element.children;
298
782
  for (let index = 0; index < children.length; index += 1) {
299
783
  const child = children[index];
300
- if (child !== void 0 && child.nodeType === XmlNodeType.element && child.localName === localName) return true;
784
+ if (child && child.nodeType === XmlNodeType.element && child.localName === localName) return true;
301
785
  }
302
786
  return false;
303
787
  };
@@ -306,13 +790,13 @@ var hasXmlChildElement = (element, localName) => {
306
790
  */
307
791
  var findXmlElement = (element, localName) => {
308
792
  const stack = [element];
309
- while (stack.length > 0) {
793
+ while (stack.length) {
310
794
  const node = stack.pop();
311
- if (node === void 0 || node.nodeType !== XmlNodeType.element) continue;
795
+ if (!node || node.nodeType !== XmlNodeType.element) continue;
312
796
  if (node.localName === localName) return node;
313
797
  for (let index = node.children.length - 1; index >= 0; index -= 1) {
314
798
  const child = node.children[index];
315
- if (child !== void 0) stack.push(child);
799
+ if (child) stack.push(child);
316
800
  }
317
801
  }
318
802
  };
@@ -321,18 +805,18 @@ var findXmlElement = (element, localName) => {
321
805
  */
322
806
  var getXmlTextContent = (node) => {
323
807
  if (node.nodeType !== XmlNodeType.element) return node.text;
324
- if (node.children.length === 0) return "";
808
+ if (!node.children.length) return "";
325
809
  const child = node.children[0];
326
- if (node.children.length === 1 && child !== void 0 && child.nodeType !== XmlNodeType.element) return child.text;
810
+ if (node.children.length === 1 && child && child.nodeType !== XmlNodeType.element) return child.text;
327
811
  const parts = [];
328
812
  const stack = [node];
329
- while (stack.length > 0) {
813
+ while (stack.length) {
330
814
  const current = stack.pop();
331
- if (current === void 0) continue;
815
+ if (!current) continue;
332
816
  if (current.nodeType === XmlNodeType.element) {
333
817
  for (let index = current.children.length - 1; index >= 0; index -= 1) {
334
818
  const child = current.children[index];
335
- if (child !== void 0) stack.push(child);
819
+ if (child) stack.push(child);
336
820
  }
337
821
  continue;
338
822
  }
@@ -400,7 +884,7 @@ var decodeText = (text) => {
400
884
  cursor = end + 1;
401
885
  start = cursor;
402
886
  }
403
- if (parts.length === 0) return text;
887
+ if (!parts.length) return text;
404
888
  parts.push(text.slice(start));
405
889
  return parts.join("");
406
890
  };
@@ -409,7 +893,7 @@ var decodeText = (text) => {
409
893
  */
410
894
  var parseXml = (source) => {
411
895
  const length = source.length;
412
- let position = length > 0 && source.charCodeAt(0) === BYTE_ORDER_MARK ? 1 : 0;
896
+ let position = length && source.charCodeAt(0) === BYTE_ORDER_MARK ? 1 : 0;
413
897
  let valid = true;
414
898
  const stack = [];
415
899
  const root = {
@@ -429,7 +913,7 @@ var parseXml = (source) => {
429
913
  position = end;
430
914
  if (start === end) return;
431
915
  const parent = stack[stack.length - 1];
432
- if (parent === void 0) {
916
+ if (!parent) {
433
917
  valid = false;
434
918
  return;
435
919
  }
@@ -544,7 +1028,7 @@ var parseXml = (source) => {
544
1028
  }
545
1029
  position = cursor;
546
1030
  const parent = stack[stack.length - 1];
547
- if (parent === void 0) {
1031
+ if (!parent) {
548
1032
  valid = false;
549
1033
  return;
550
1034
  }
@@ -574,7 +1058,7 @@ var parseXml = (source) => {
574
1058
  return;
575
1059
  }
576
1060
  const top = stack[stack.length - 1];
577
- if (top === void 0 || top.qualifiedName !== name) {
1061
+ if (!top || top.qualifiedName !== name) {
578
1062
  valid = false;
579
1063
  return;
580
1064
  }
@@ -601,7 +1085,7 @@ var parseXml = (source) => {
601
1085
  return;
602
1086
  }
603
1087
  const parent = stack[stack.length - 1];
604
- if (parent === void 0) {
1088
+ if (!parent) {
605
1089
  valid = false;
606
1090
  return;
607
1091
  }
@@ -706,7 +1190,7 @@ var escapeXmlText = (text, chunks) => {
706
1190
  }
707
1191
  } else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
708
1192
  else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
709
- if (escaped === "") continue;
1193
+ if (!escaped) continue;
710
1194
  if (start < index) chunks.push(text.slice(start, index));
711
1195
  chunks.push(escaped);
712
1196
  start = index + 1;
@@ -742,7 +1226,7 @@ var escapeXmlAttribute = (value, chunks) => {
742
1226
  }
743
1227
  } else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
744
1228
  else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
745
- if (escaped === "") continue;
1229
+ if (!escaped) continue;
746
1230
  if (start < index) chunks.push(value.slice(start, index));
747
1231
  chunks.push(escaped);
748
1232
  start = index + 1;
@@ -762,7 +1246,7 @@ var hasOnlyElements = (element) => {
762
1246
  const children = element.children;
763
1247
  for (let index = 0; index < children.length; index += 1) {
764
1248
  const child = children[index];
765
- if (child !== void 0 && child.nodeType !== XmlNodeType.element) return false;
1249
+ if (child && child.nodeType !== XmlNodeType.element) return false;
766
1250
  }
767
1251
  return true;
768
1252
  };
@@ -782,13 +1266,13 @@ var writeXmlNode = (node, chunks, depth, indent) => {
782
1266
  chunks.push("<", node.qualifiedName);
783
1267
  for (let index = 0; index < node.attributes.length; index += 1) {
784
1268
  const attribute = node.attributes[index];
785
- if (attribute === void 0) continue;
1269
+ if (!attribute) continue;
786
1270
  chunks.push(" ", attribute.qualifiedName, "=\"");
787
1271
  escapeXmlAttribute(attribute.value, chunks);
788
1272
  chunks.push("\"");
789
1273
  }
790
1274
  const children = node.children;
791
- if (children.length === 0) {
1275
+ if (!children.length) {
792
1276
  chunks.push("/>");
793
1277
  return;
794
1278
  }
@@ -796,7 +1280,7 @@ var writeXmlNode = (node, chunks, depth, indent) => {
796
1280
  const childIndent = indent !== void 0 && hasOnlyElements(node) ? indent : void 0;
797
1281
  for (let index = 0; index < children.length; index += 1) {
798
1282
  const child = children[index];
799
- if (child !== void 0) writeXmlNode(child, chunks, depth + 1, childIndent);
1283
+ if (child) writeXmlNode(child, chunks, depth + 1, childIndent);
800
1284
  }
801
1285
  if (childIndent !== void 0) chunks.push("\n", childIndent.repeat(depth));
802
1286
  chunks.push("</", node.qualifiedName, ">");
@@ -809,20 +1293,94 @@ var writeXmlNode = (node, chunks, depth, indent) => {
809
1293
  var serializeXml = (root, options = {}) => {
810
1294
  const chunks = [];
811
1295
  if (options.declaration ?? true) chunks.push(XML_DECLARATION);
812
- const indent = options.indent === true ? options.indentText ?? DEFAULT_INDENT : void 0;
1296
+ const indent = options.indent ? options.indentText ?? DEFAULT_INDENT : void 0;
813
1297
  if (indent !== void 0) chunks.push("\n");
814
1298
  if (root.qualifiedName === ROOT_NAME) for (let index = 0; index < root.children.length; index += 1) {
815
1299
  const child = root.children[index];
816
- if (child !== void 0) writeXmlNode(child, chunks, 0, indent);
1300
+ if (child) writeXmlNode(child, chunks, 0, indent);
817
1301
  }
818
1302
  else writeXmlNode(root, chunks, 0, indent);
819
1303
  return chunks.join("");
820
1304
  };
821
1305
  //#endregion
1306
+ //#region src/formats/qrc/common.ts
1307
+ var LINE_PATTERN$2 = /^\[(\d+),(\d+)\]/;
1308
+ var XML_ROOT_NAMES = [
1309
+ "QrcInfos",
1310
+ "LyricInfo",
1311
+ "Lyric_1"
1312
+ ];
1313
+ var XML_PATTERN = /^\s*</;
1314
+ var TIMED_LINE_PATTERN = /(?:^|[\r\n])[^\S\r\n]*\[\d+,\d+\]/;
1315
+ /**
1316
+ * Unwraps supported QRC XML while keeping plaintext literal.
1317
+ *
1318
+ * XML entities are decoded once by the XML reader.
1319
+ *
1320
+ * @returns The payload, or null when XML is malformed or has no supported lyric content.
1321
+ */
1322
+ var readText$2 = (source) => {
1323
+ if (!XML_PATTERN.test(source)) return source;
1324
+ if (!XML_ROOT_NAMES.some((name) => source.includes(name))) return null;
1325
+ const document = parseXml(source);
1326
+ if (!document) return null;
1327
+ const root = document.children.find((node) => node.nodeType === XmlNodeType.element);
1328
+ if (!root || !XML_ROOT_NAMES.includes(root.localName)) return null;
1329
+ const pending = [root];
1330
+ while (pending.length) {
1331
+ const element = pending.pop();
1332
+ const content = getXmlAttribute(element, "LyricContent");
1333
+ if (content !== void 0) return content;
1334
+ for (let index = element.children.length - 1; index >= 0; index -= 1) {
1335
+ const child = element.children[index];
1336
+ if (child.nodeType === XmlNodeType.element) pending.push(child);
1337
+ }
1338
+ }
1339
+ return null;
1340
+ };
1341
+ /**
1342
+ * Validates the original payload and keeps auxiliary tracks separate so each can use its own format.
1343
+ *
1344
+ * @returns Normalized input, or null when the original content cannot be read.
1345
+ */
1346
+ var readContent = (input) => {
1347
+ const value = typeof input === "string" ? { original: input } : input;
1348
+ if (!value || typeof value !== "object") return null;
1349
+ const source = value;
1350
+ if (typeof source.original !== "string") return null;
1351
+ const original = readText$2(source.original);
1352
+ if (original === null) return null;
1353
+ return {
1354
+ original,
1355
+ translations: typeof source.translations === "string" ? source.translations : void 0,
1356
+ romans: typeof source.romans === "string" ? source.romans : void 0
1357
+ };
1358
+ };
1359
+ /**
1360
+ * Requires a timed QRC row so metadata alone cannot select the format.
1361
+ */
1362
+ var hasTimedLines = (text) => {
1363
+ return TIMED_LINE_PATTERN.test(text);
1364
+ };
1365
+ //#endregion
1366
+ //#region src/formats/qrc/detect.ts
1367
+ /**
1368
+ * Detects QRC from the original track so annotations cannot change format selection.
1369
+ *
1370
+ * @returns Whether plaintext or a supported XML payload contains a timed QRC row.
1371
+ */
1372
+ var detectQrc = (input) => {
1373
+ const content = readContent(input);
1374
+ return !!content && hasTimedLines(content.original);
1375
+ };
1376
+ //#endregion
822
1377
  //#region src/formats/lrc/common.ts
1378
+ /**
1379
+ * Accepts plain lyrics or separate tracks while rejecting an unreadable original payload.
1380
+ */
823
1381
  var readLrcContent = (input) => {
824
1382
  if (typeof input === "string") return { original: input };
825
- if (typeof input !== "object" || input === null) return null;
1383
+ if (!input || typeof input !== "object") return null;
826
1384
  const source = input;
827
1385
  if (typeof source.original !== "string") return null;
828
1386
  return {
@@ -871,34 +1429,42 @@ var parseWordTime = (body, lineStart) => {
871
1429
  }
872
1430
  };
873
1431
  /**
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.
1432
+ * Splits recognized tags from their following text while preserving literal content and empty tagged segments.
1433
+ *
1434
+ * @param pattern A global pattern whose first capture is the tag body.
1435
+ * @param accepts Optional format-specific tag validation.
1436
+ *
1437
+ * @returns Source pieces in their original order, including an untagged prefix when present.
876
1438
  */
877
- var splitWordTags = (content) => {
1439
+ var scanWordTags = (content, pattern, accepts) => {
878
1440
  const pieces = [];
879
1441
  let tag;
880
1442
  let cursor = 0;
881
- for (const match of content.matchAll(WORD_TAG_PATTERN)) {
882
- const at = match.index;
883
- const before = content.slice(cursor, at);
884
- if (before) pieces.push({
1443
+ for (const match of content.matchAll(pattern)) {
1444
+ const nextTag = match[1] ?? "";
1445
+ if (accepts && !accepts(nextTag)) continue;
1446
+ const before = content.slice(cursor, match.index);
1447
+ if (before || tag !== void 0) pieces.push({
885
1448
  text: before,
886
1449
  tag
887
1450
  });
888
- tag = match[1] ?? "";
889
- cursor = at + match[0].length;
1451
+ tag = nextTag;
1452
+ cursor = match.index + match[0].length;
890
1453
  }
891
1454
  const tail = content.slice(cursor);
892
- if (tail) pieces.push({
1455
+ if (tail || tag !== void 0) pieces.push({
893
1456
  text: tail,
894
1457
  tag
895
1458
  });
896
- else if (tag !== void 0) pieces.push({
897
- text: "",
898
- tag
899
- });
900
1459
  return pieces;
901
1460
  };
1461
+ /**
1462
+ * Untagged leading text is preserved; empty timed pieces retain explicit segment boundaries.
1463
+ * Only recognized time tags split content; other angle-bracket text remains literal.
1464
+ */
1465
+ var splitWordTags = (content) => {
1466
+ return scanWordTags(content, WORD_TAG_PATTERN, (tag) => !!parseWordTime(tag, 0));
1467
+ };
902
1468
  //#endregion
903
1469
  //#region src/formats/lrc/detect.ts
904
1470
  /**
@@ -932,25 +1498,48 @@ var renderWordTag = (start, end, options) => {
932
1498
  return `${open}${formatTime(start, options.timeFormat)}${duration}${close}`;
933
1499
  };
934
1500
  /**
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
1501
  * Word-level lines interleave tags with their text; untimed words stay untagged.
945
1502
  */
946
1503
  var renderSyllableText = (line, options) => {
947
1504
  const content = line.content;
948
- if (!content?.words.length) return renderPlainText(line);
1505
+ if (!content?.words.length) return getContentText(content);
949
1506
  return content.words.map((word) => {
950
1507
  if (word.type === WordType.SPACE) return word.text;
951
1508
  const start = word.time?.start;
952
1509
  if (start === void 0) return word.text;
953
- return `${renderWordTag(start, word.time?.end, options)}${word.text}`;
1510
+ const end = word.time?.end;
1511
+ const closing = options.wordTiming === "angle" && options.includeWordEnd && end === start ? renderWordTag(end, void 0, options) : "";
1512
+ return `${renderWordTag(start, end, options)}${word.text}${closing}`;
1513
+ }).join("");
1514
+ };
1515
+ var renderWordRomans = (line, language, options) => {
1516
+ const pieces = [];
1517
+ let spaced = false;
1518
+ for (const word of line.content?.words ?? []) {
1519
+ if (word.type === WordType.SPACE) {
1520
+ spaced = true;
1521
+ continue;
1522
+ }
1523
+ const roman = word.annotation?.romans.find((item) => lowerTag(item.language) === language);
1524
+ if (!roman || !getAnnotationItemText(roman)) continue;
1525
+ let prefix = pieces.length && spaced ? " " : "";
1526
+ spaced = false;
1527
+ for (const token of roman.tokens) {
1528
+ if (!token.text) continue;
1529
+ pieces.push({
1530
+ text: prefix + token.text,
1531
+ time: resolveAnnotationTokenTime(token, roman, word)
1532
+ });
1533
+ prefix = "";
1534
+ }
1535
+ }
1536
+ return pieces.map((piece, index) => {
1537
+ const start = piece.time?.start;
1538
+ if (start === void 0) return piece.text;
1539
+ const end = piece.time?.end;
1540
+ const nextStart = pieces[index + 1]?.time?.start;
1541
+ const closing = end !== void 0 && end !== nextStart && (!options.includeWordEnd || end === start) ? renderWordTag(end, void 0, options) : "";
1542
+ return `${renderWordTag(start, end, options)}${piece.text}${closing}`;
954
1543
  }).join("");
955
1544
  };
956
1545
  var renderMetaRows = (meta, options) => {
@@ -972,7 +1561,7 @@ var renderLineRow = (line, options, wordLevel) => {
972
1561
  if (line.type === LineType.INSTRUMENTAL && !options.includeInstrumental) return;
973
1562
  const time = renderTimeTag(line.time?.start ?? 0, options);
974
1563
  if (line.type === LineType.INSTRUMENTAL) return time;
975
- return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) : renderPlainText(line)}`;
1564
+ return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) : getContentText(line.content)}`;
976
1565
  };
977
1566
  var renderAnnotationRows = (lines, options, pick) => {
978
1567
  const rows = [];
@@ -983,6 +1572,20 @@ var renderAnnotationRows = (lines, options, pick) => {
983
1572
  }
984
1573
  return rows;
985
1574
  };
1575
+ var renderRomanRows = (lines, options) => {
1576
+ const rows = [];
1577
+ for (const line of lines) {
1578
+ const start = line.time?.start;
1579
+ if (start === void 0) continue;
1580
+ const explicit = new Set((line.annotation?.romans ?? []).map((item) => lowerTag(item.language)));
1581
+ for (const item of getLineRomans(line)) {
1582
+ const language = lowerTag(item.language);
1583
+ const text = options.wordTiming === "none" || explicit.has(language) ? item.text : renderWordRomans(line, language, options);
1584
+ rows.push(`${renderTimeTag(start, options)}${text}`);
1585
+ }
1586
+ }
1587
+ return rows;
1588
+ };
986
1589
  var joinBlocks = (header, body) => {
987
1590
  const blocks = [];
988
1591
  if (header.length) blocks.push(header.join("\n"));
@@ -993,47 +1596,29 @@ var joinBlocks = (header, body) => {
993
1596
  * Generates the LRC text plus the annotations a single file cannot hold.
994
1597
  *
995
1598
  * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
1599
+ * Word romanizations retain token boundaries; explicit line romanizations take precedence for the same language.
996
1600
  */
997
1601
  var generateLrc = (lyric, options = {}) => {
998
1602
  const resolved = resolveOptions$1(options);
999
1603
  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";
1604
+ const wordLevel = lyric.lines.some((line) => !!line.content?.words.length) && resolved.wordTiming !== "none";
1001
1605
  const result = { original: joinBlocks(header, lyric.lines.map((line) => {
1002
1606
  return renderLineRow(line, resolved, wordLevel);
1003
1607
  }).filter((row) => row !== void 0)) };
1004
1608
  const translations = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.translations ?? []);
1005
1609
  if (translations.length) result.translations = joinBlocks(header, translations);
1006
- const romans = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.romans ?? []);
1610
+ const romans = renderRomanRows(lyric.lines, resolved);
1007
1611
  if (romans.length) result.romans = joinBlocks(header, romans);
1008
1612
  return result;
1009
1613
  };
1010
1614
  //#endregion
1011
- //#region src/formats/result.ts
1012
- /**
1013
- * Canonicalizes and validates one parser's output, so every format hands back the same shape.
1014
- *
1015
- * Canonicalization runs first because validation reports paths against the canonical tree.
1016
- */
1017
- var finalizeLyric = (input) => {
1018
- const lyric = canonicalizeLyric(input);
1019
- return {
1020
- lyric,
1021
- diagnostics: validateLyric(lyric)
1022
- };
1023
- };
1024
- //#endregion
1025
1615
  //#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);
1616
+ var CLOCK_PATTERN = /^(?:\d+:){0,2}\d+(?:\.(\d+))?$/;
1617
+ var readLineTiming = (tag) => {
1618
+ const clock = CLOCK_PATTERN.exec(tag.trim());
1619
+ return { precision: clock ? 10 ** Math.max(0, 3 - (clock[1]?.length ?? 0)) : 1 };
1035
1620
  };
1036
- var readMeta = (body) => {
1621
+ var readMeta$1 = (body) => {
1037
1622
  const match = META_PATTERN.exec(body);
1038
1623
  if (!match) return;
1039
1624
  return {
@@ -1041,28 +1626,33 @@ var readMeta = (body) => {
1041
1626
  value: (match[2] ?? "").trim()
1042
1627
  };
1043
1628
  };
1044
- var readGroup = (tags, text, timed, meta) => {
1629
+ var readGroup = (tags, text, row, timed, meta, options) => {
1045
1630
  for (const tag of tags) {
1046
1631
  const start = isTime(tag) ? parseTime(tag) : void 0;
1047
1632
  if (start !== void 0) {
1048
1633
  timed.push({
1049
1634
  text,
1050
- start
1635
+ start,
1636
+ tag,
1637
+ row
1051
1638
  });
1052
1639
  continue;
1053
1640
  }
1054
- const pair = readMeta(tag);
1055
- if (pair) meta.push(pair);
1641
+ const pair = readMeta$1(tag);
1642
+ if (pair) {
1643
+ meta.push(pair);
1644
+ options.onMeta?.(pair, row);
1645
+ }
1056
1646
  }
1057
1647
  };
1058
1648
  /**
1059
1649
  * Stacked timestamps share the following text, as in `[00:10][00:20]twice`.
1060
1650
  * Text before the first tag is discarded because it has no timing association.
1061
1651
  */
1062
- var readTimedLines = (text) => {
1652
+ var readTimedLines = (text, options) => {
1063
1653
  const timed = [];
1064
1654
  const meta = [];
1065
- for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
1655
+ for (const [row, raw] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
1066
1656
  const line = raw.trim();
1067
1657
  if (!line) continue;
1068
1658
  const matches = [...line.matchAll(TAG_PATTERN)];
@@ -1073,10 +1663,10 @@ var readTimedLines = (text) => {
1073
1663
  const shared = line.slice(at, next);
1074
1664
  group.push(match[1] ?? "");
1075
1665
  if (!shared.trim()) continue;
1076
- readGroup(group, shared, timed, meta);
1666
+ readGroup(group, shared, row, timed, meta, options);
1077
1667
  group = [];
1078
1668
  }
1079
- readGroup(group, "", timed, meta);
1669
+ readGroup(group, "", row, timed, meta, options);
1080
1670
  }
1081
1671
  return {
1082
1672
  timed,
@@ -1084,57 +1674,11 @@ var readTimedLines = (text) => {
1084
1674
  };
1085
1675
  };
1086
1676
  /**
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
1677
  * Whitespace becomes a space word preserving the source run, as in `lo World`.
1134
1678
  * A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.
1135
1679
  * Explicit word ends are kept as written, so duration tags survive unchanged.
1136
1680
  */
1137
- var readWords$1 = (text, lineStart) => {
1681
+ var readWords$2 = (pieces, lineStart) => {
1138
1682
  const drafts = [];
1139
1683
  const open = /* @__PURE__ */ new Set();
1140
1684
  const closeSegment = (boundary) => {
@@ -1144,9 +1688,9 @@ var readWords$1 = (text, lineStart) => {
1144
1688
  };
1145
1689
  let segmentStart = lineStart;
1146
1690
  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) {
1691
+ for (const piece of pieces) {
1692
+ const time = piece.tag ? parseWordTime(piece.tag, lineStart) : void 0;
1693
+ if (time) {
1150
1694
  closeSegment(time.start);
1151
1695
  segmentStart = time.start;
1152
1696
  cursor = time.start;
@@ -1162,7 +1706,7 @@ var readWords$1 = (text, lineStart) => {
1162
1706
  });
1163
1707
  continue;
1164
1708
  }
1165
- const tagged = !applied && time?.start !== void 0;
1709
+ const tagged = !applied && !!time;
1166
1710
  applied = true;
1167
1711
  const start = tagged ? segmentStart : cursor;
1168
1712
  const end = tagged ? time?.end : void 0;
@@ -1182,23 +1726,26 @@ var readWords$1 = (text, lineStart) => {
1182
1726
  };
1183
1727
  };
1184
1728
  /**
1185
- * A line ends at the next line's start; without one, it ends at its final word's end.
1186
- * An open word is closed by the next timed word, or by the line end, so every sung word stays inside the line.
1187
- * Empty timed rows represent instrumental time rather than sung content.
1729
+ * Explicit word ranges can extend past the next line's start, so the inferred end covers both.
1730
+ * A trailing open segment uses that end when it is known.
1188
1731
  */
1189
- var readLine$1 = (text, start, nextStart) => {
1732
+ var readLine$4 = (text, start, nextStart) => {
1190
1733
  const trimmed = text.trim();
1191
1734
  let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
1192
- if (trimmed === "") return makeLineInstrumental({ time: makeTimeRange({
1735
+ if (!trimmed) return makeLineInstrumental({ time: makeTimeRange({
1193
1736
  start,
1194
1737
  end
1195
1738
  }) });
1196
- const wordTimed = isWordTimed(text);
1197
- const { drafts, closeSegment } = wordTimed ? readWords$1(text, start) : {
1739
+ const pieces = splitWordTags(text);
1740
+ const wordTimed = pieces.some((piece) => !!piece.tag);
1741
+ const { drafts, closeSegment } = wordTimed ? readWords$2(pieces, start) : {
1198
1742
  drafts: [],
1199
1743
  closeSegment: () => {}
1200
1744
  };
1201
1745
  if (end === void 0) end = [...drafts].reverse().find((draft) => !draft.space && draft.start !== void 0)?.end;
1746
+ if (end !== void 0) {
1747
+ for (const draft of drafts) if (draft.end !== void 0) end = Math.max(end, draft.end);
1748
+ }
1202
1749
  closeSegment(end);
1203
1750
  const words = drafts.map((draft) => {
1204
1751
  if (draft.space) return makeWordSpace({ text: draft.text });
@@ -1225,29 +1772,97 @@ var readLine$1 = (text, start, nextStart) => {
1225
1772
  * Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.
1226
1773
  * Empty timed rows become instrumental lines bounded by the next timestamp when one is usable.
1227
1774
  */
1228
- var readLines = (timed) => {
1775
+ var readLines$1 = (timed, options) => {
1229
1776
  const lines = [];
1230
1777
  for (const [index, item] of timed.entries()) {
1231
1778
  const next = timed[index + 1]?.start;
1232
- lines.push(readLine$1(item.text, item.start, next));
1779
+ const line = readLine$4(item.text, item.start, next);
1780
+ lines.push(line);
1781
+ options.onLine?.(line, readLineTiming(item.tag), item.row);
1233
1782
  }
1234
1783
  return lines;
1235
1784
  };
1236
- var parseLrcText = (text) => {
1237
- const { timed, meta } = readTimedLines(text);
1238
- const metaTags = readMetaTags(meta);
1239
- if (!timed.length) return makeLyric({
1240
- format: "lrc",
1241
- status: LyricStatus.INVALID,
1242
- meta: metaTags
1243
- });
1244
- const lines = readLines(timed);
1785
+ /**
1786
+ * Reads one LRC track in source order and performs no other operation: no metadata mapping, annotation attachment, sorting, or normalization.
1787
+ *
1788
+ * @param text Original lyrics or one auxiliary track in LRC syntax.
1789
+ * @param options Optional observer for source timestamp information.
1790
+ *
1791
+ * @returns Model lines in input order and metadata tags with lowercase keys and trimmed values.
1792
+ */
1793
+ var readLrc = (text, options = {}) => {
1794
+ const { timed, meta } = readTimedLines(text, options);
1795
+ return {
1796
+ lines: readLines$1(timed, options),
1797
+ metaTags: meta
1798
+ };
1799
+ };
1800
+ var isBareNumber = (value) => {
1801
+ return /^\d+$/.test(value.trim());
1802
+ };
1803
+ /**
1804
+ * Maps standard lyric metadata while preserving unmapped tags and unparseable durations in unknowns.
1805
+ *
1806
+ * @param tags Reader tags with lowercase keys and trimmed values.
1807
+ *
1808
+ * @returns Model metadata without interpreting platform-specific embedded annotations.
1809
+ */
1810
+ var readLrcMetaTags = (tags) => {
1811
+ const meta = makeMeta();
1812
+ for (const tag of tags) switch (tag.key) {
1813
+ case "length":
1814
+ case "duration": {
1815
+ const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : NaN);
1816
+ if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
1817
+ else meta.unknowns.push(makeUnknownEntry({
1818
+ key: tag.key,
1819
+ value: tag.value
1820
+ }));
1821
+ break;
1822
+ }
1823
+ case "offset":
1824
+ meta.offset = Math.trunc(Number(tag.value)) || 0;
1825
+ break;
1826
+ case "ti":
1827
+ case "title":
1828
+ meta.titles.push(makeText({ text: tag.value }));
1829
+ break;
1830
+ case "ar":
1831
+ case "artist":
1832
+ meta.artists.push(makeText({ text: tag.value }));
1833
+ break;
1834
+ case "al":
1835
+ case "album":
1836
+ meta.albums.push(makeText({ text: tag.value }));
1837
+ break;
1838
+ case "by":
1839
+ case "author":
1840
+ meta.authors.push(makeText({ text: tag.value }));
1841
+ break;
1842
+ case "isrc":
1843
+ meta.isrcs.push(tag.value);
1844
+ break;
1845
+ default: meta.unknowns.push(makeUnknownEntry({
1846
+ key: tag.key,
1847
+ value: tag.value
1848
+ }));
1849
+ }
1850
+ return meta;
1851
+ };
1852
+ var parseLrcText = (text) => {
1853
+ const { lines, metaTags } = readLrc(text);
1854
+ const meta = readLrcMetaTags(metaTags);
1855
+ if (!lines.length) return makeLyric({
1856
+ format: "lrc",
1857
+ status: LyricStatus.INVALID,
1858
+ meta
1859
+ });
1245
1860
  const wordTimed = lines.some((line) => (line.content?.words.length ?? 0) > 0);
1246
1861
  return makeLyric({
1247
1862
  format: "lrc",
1248
1863
  status: LyricStatus.VALID,
1249
1864
  timing: wordTimed ? Timing.WORD : Timing.LINE,
1250
- meta: metaTags,
1865
+ meta,
1251
1866
  languages: deriveLanguageUsages(lines),
1252
1867
  lines
1253
1868
  });
@@ -1256,6 +1871,7 @@ var parseLrcText = (text) => {
1256
1871
  * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
1257
1872
  *
1258
1873
  * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
1874
+ * Complete roman tracks attach to words by timing and optional local recovery; other tracks retain their source line text.
1259
1875
  *
1260
1876
  * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
1261
1877
  */
@@ -1263,19 +1879,836 @@ var parseLrc = (input, options = {}) => {
1263
1879
  const source = readLrcContent(input) ?? { original: "" };
1264
1880
  const lyric = parseLrcText(source.original);
1265
1881
  if (lyric.status === LyricStatus.INVALID) return finalizeLyric(lyric);
1266
- const translations = source.translations === void 0 ? [] : readLines(readTimedLines(source.translations).timed);
1267
- const romans = source.romans === void 0 ? [] : readLines(readTimedLines(source.romans).timed);
1268
- if (translations.length === 0 && romans.length === 0) return finalizeLyric(lyric);
1269
- const lines = attachLineAnnotations(lyric.lines, {
1882
+ const translations = source.translations ? readLrc(source.translations).lines : [];
1883
+ const romans = source.romans ? readLrc(source.romans).lines : [];
1884
+ if (!translations.length && !romans.length) return finalizeLyric(lyric);
1885
+ const lines = attachAnnotations(lyric.lines, {
1270
1886
  translations,
1271
1887
  romans
1272
- }, { tolerance: options.annotationTolerance });
1888
+ }, {
1889
+ tolerance: options.annotationTolerance,
1890
+ wordTolerance: options.wordAnnotationTolerance,
1891
+ wordFallback: options.wordAnnotationFallback
1892
+ });
1273
1893
  return finalizeLyric({
1274
1894
  ...lyric,
1275
1895
  lines
1276
1896
  });
1277
1897
  };
1278
1898
  //#endregion
1899
+ //#region src/formats/qrc/parser.ts
1900
+ var WORD_PATTERN$2 = /\((\d+),(\d+)\)/g;
1901
+ var META_PATTERN$1 = /^([A-Za-z0-9_-]+)\s*:\s*([\s\S]*)$/;
1902
+ var META_TAG_PATTERN = /\[([^\]]*)\]/g;
1903
+ var RUBY_PATTERN = /\((\d+),(\d+)\)|(\d+)|([^0-9()]+)/gy;
1904
+ var RUBY_WORD_PATTERN = /[\p{Script=Han}\p{Number}]/u;
1905
+ var SEPARATORS$3 = /^\p{White_Space}+$/u;
1906
+ var NATIVE_ANNOTATION_TIMING = {
1907
+ precision: 1,
1908
+ rounding: 1
1909
+ };
1910
+ /**
1911
+ * Reads coverage markers and optional absolute token times without interpreting timing digits as coverage.
1912
+ *
1913
+ * @returns Complete ruby sources, or undefined when any source cannot be read.
1914
+ */
1915
+ var readRubyEntries = (value) => {
1916
+ const entries = [];
1917
+ let length = 0;
1918
+ let tokens = [];
1919
+ let text = "";
1920
+ let cursor = 0;
1921
+ const finish = () => {
1922
+ if (text.trim()) tokens.push(makeWordAnnotationToken({ text }));
1923
+ if (!length || !tokens.length) return false;
1924
+ entries.push({
1925
+ length,
1926
+ ruby: makeWordAnnotationRuby({ tokens })
1927
+ });
1928
+ tokens = [];
1929
+ text = "";
1930
+ return true;
1931
+ };
1932
+ for (const match of value.matchAll(RUBY_PATTERN)) {
1933
+ cursor = match.index + match[0].length;
1934
+ if (match[3]) {
1935
+ if (length && !finish()) return;
1936
+ length = Number(match[3]);
1937
+ if (!Number.isSafeInteger(length) || length <= 0) return;
1938
+ } else if (match[1]) {
1939
+ const start = Number(match[1]);
1940
+ const end = start + Number(match[2]);
1941
+ if (!length || !text.trim() || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return;
1942
+ tokens.push(makeWordAnnotationToken({
1943
+ text,
1944
+ time: {
1945
+ start,
1946
+ end
1947
+ }
1948
+ }));
1949
+ text = "";
1950
+ } else {
1951
+ if (!length) return;
1952
+ text += match[4];
1953
+ }
1954
+ }
1955
+ return cursor === value.length && finish() ? entries : void 0;
1956
+ };
1957
+ var readRubies = (meta) => {
1958
+ let entries;
1959
+ const unknowns = [];
1960
+ for (const entry of meta.unknowns) if (entry.key === "kana") entries ??= readRubyEntries(entry.value);
1961
+ else unknowns.push(entry);
1962
+ meta.unknowns = unknowns;
1963
+ return entries;
1964
+ };
1965
+ var matchRubies = (words, sources) => {
1966
+ const starts = /* @__PURE__ */ new Map();
1967
+ for (const [index, candidate] of words.entries()) {
1968
+ const start = candidate?.word.time?.start;
1969
+ if (start !== void 0 && Number.isSafeInteger(start) && start >= 0) {
1970
+ const queue = starts.get(start) ?? [];
1971
+ queue.push(index);
1972
+ starts.set(start, queue);
1973
+ }
1974
+ }
1975
+ const timed = /* @__PURE__ */ new Set();
1976
+ const claims = /* @__PURE__ */ new Map();
1977
+ for (const [index, source] of sources.entries()) {
1978
+ const times = source.ruby.tokens.flatMap((token) => token.time ? [token.time] : []);
1979
+ if (!times.length) continue;
1980
+ timed.add(index);
1981
+ const start = times[0].start;
1982
+ const end = times.at(-1).end;
1983
+ if (start === void 0) continue;
1984
+ let candidates = (starts.get(start) ?? []).filter((target) => {
1985
+ const candidate = words[target];
1986
+ const finish = candidate.word.time?.end;
1987
+ return candidate.length === source.length && finish !== void 0 && Number.isSafeInteger(finish) && times.every((time) => {
1988
+ return time.start !== void 0 && time.end !== void 0 && time.start >= start && time.end >= time.start && time.end <= finish;
1989
+ });
1990
+ });
1991
+ if (times.length === source.ruby.tokens.length) {
1992
+ const exact = candidates.filter((target) => words[target].word.time.end === end);
1993
+ if (exact.length) candidates = exact;
1994
+ }
1995
+ if (candidates.length !== 1) continue;
1996
+ const target = candidates[0];
1997
+ const queue = claims.get(target) ?? [];
1998
+ queue.push(index);
1999
+ claims.set(target, queue);
2000
+ }
2001
+ const matched = /* @__PURE__ */ new Map();
2002
+ for (const [target, queue] of claims) if (queue.length === 1) matched.set(queue[0], target);
2003
+ const anchors = [
2004
+ [-1, -1],
2005
+ ...[...matched].sort((first, second) => first[0] - second[0]),
2006
+ [sources.length, words.length]
2007
+ ];
2008
+ for (let index = 1; index < anchors.length; index += 1) if (anchors[index][1] <= anchors[index - 1][1]) return matched;
2009
+ for (let index = 1; index < anchors.length; index += 1) {
2010
+ const [firstSource, firstWord] = anchors[index - 1];
2011
+ const [lastSource, lastWord] = anchors[index];
2012
+ const pending = [];
2013
+ let compatible = true;
2014
+ for (let source = firstSource + 1; source < lastSource; source += 1) {
2015
+ if (timed.has(source)) {
2016
+ compatible = false;
2017
+ break;
2018
+ }
2019
+ pending.push(source);
2020
+ }
2021
+ if (!compatible || !pending.length) continue;
2022
+ const candidates = [];
2023
+ for (let target = firstWord + 1; target < lastWord; target += 1) {
2024
+ const candidate = words[target];
2025
+ if (!candidate) {
2026
+ compatible = false;
2027
+ break;
2028
+ }
2029
+ if (candidate.byOrder) candidates.push(target);
2030
+ }
2031
+ if (!compatible || candidates.length !== pending.length || pending.some((source, position) => sources[source].length !== words[candidates[position]].length)) continue;
2032
+ for (const [position, source] of pending.entries()) matched.set(source, candidates[position]);
2033
+ }
2034
+ return matched;
2035
+ };
2036
+ /**
2037
+ * Attaches whole-word ruby without changing the source text, segmentation, or timing.
2038
+ *
2039
+ * @param lines Original timed rows before filtering or sorting.
2040
+ * @param sources Readings with source coverage lengths and optional token times.
2041
+ *
2042
+ * @returns Annotated rows and matched source indices, preserving unchanged nodes.
2043
+ */
2044
+ var attachRubies = (lines, sources) => {
2045
+ if (!sources.length) return {
2046
+ lines,
2047
+ matched: /* @__PURE__ */ new Set()
2048
+ };
2049
+ const words = [];
2050
+ for (const line of lines) {
2051
+ if (line.type !== LineType.NORMAL) continue;
2052
+ if (line.content?.text && RUBY_WORD_PATTERN.test(line.content.text)) {
2053
+ words.push(void 0);
2054
+ continue;
2055
+ }
2056
+ for (const word of line.content?.words ?? []) if (word.type === WordType.NORMAL) words.push({
2057
+ word,
2058
+ length: [...word.text].length,
2059
+ byOrder: RUBY_WORD_PATTERN.test(word.text)
2060
+ });
2061
+ }
2062
+ const matched = matchRubies(words, sources);
2063
+ if (!matched.size) return {
2064
+ lines,
2065
+ matched: /* @__PURE__ */ new Set()
2066
+ };
2067
+ const annotations = new Map([...matched].map(([source, target]) => [words[target].word, sources[source].ruby]));
2068
+ return {
2069
+ lines: mapShared(lines, (line) => {
2070
+ const content = line.content;
2071
+ if (!content) return line;
2072
+ const words = mapShared(content.words, (word) => {
2073
+ const ruby = annotations.get(word);
2074
+ if (!ruby) return word;
2075
+ return {
2076
+ ...word,
2077
+ annotation: makeWordAnnotation({
2078
+ ...word.annotation,
2079
+ rubies: [...word.annotation?.rubies ?? [], ruby]
2080
+ })
2081
+ };
2082
+ });
2083
+ return words === content.words ? line : {
2084
+ ...line,
2085
+ content: {
2086
+ ...content,
2087
+ words
2088
+ }
2089
+ };
2090
+ }),
2091
+ matched: new Set(matched.keys())
2092
+ };
2093
+ };
2094
+ var readMeta = (body, meta) => {
2095
+ const match = META_PATTERN$1.exec(body);
2096
+ if (!match) return;
2097
+ const key = match[1].toLowerCase();
2098
+ const value = match[2].trim();
2099
+ switch (key) {
2100
+ case "length":
2101
+ case "duration": {
2102
+ const duration = parseTime(value) ?? (/^\d+$/.test(value) ? Number(value) : NaN);
2103
+ if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
2104
+ else meta.unknowns.push(makeUnknownEntry({
2105
+ key,
2106
+ value
2107
+ }));
2108
+ break;
2109
+ }
2110
+ case "offset":
2111
+ meta.offset = Math.trunc(Number(value)) || 0;
2112
+ break;
2113
+ case "ti":
2114
+ case "title":
2115
+ meta.titles.push(makeText({ text: value }));
2116
+ break;
2117
+ case "ar":
2118
+ case "artist":
2119
+ meta.artists.push(makeText({ text: value }));
2120
+ break;
2121
+ case "al":
2122
+ case "album":
2123
+ meta.albums.push(makeText({ text: value }));
2124
+ break;
2125
+ case "by":
2126
+ case "author":
2127
+ meta.authors.push(makeText({ text: value }));
2128
+ break;
2129
+ case "isrc":
2130
+ meta.isrcs.push(value);
2131
+ break;
2132
+ default: meta.unknowns.push(makeUnknownEntry({
2133
+ key,
2134
+ value
2135
+ }));
2136
+ }
2137
+ };
2138
+ var readTimeRange = (startText, durationText) => {
2139
+ const start = Number(startText);
2140
+ return makeTimeRange({
2141
+ start,
2142
+ end: start + Number(durationText)
2143
+ });
2144
+ };
2145
+ var appendWords$2 = (words, text, time) => {
2146
+ for (const fragment of text.split(/(\p{White_Space}+)/u)) {
2147
+ if (!fragment) continue;
2148
+ if (SEPARATORS$3.test(fragment)) words.push(makeWordSpace({ text: fragment }));
2149
+ else words.push(makeWordNormal({
2150
+ text: fragment,
2151
+ time
2152
+ }));
2153
+ }
2154
+ };
2155
+ var readWords$1 = (text) => {
2156
+ const words = [];
2157
+ let cursor = 0;
2158
+ let tagged = false;
2159
+ for (const match of text.matchAll(WORD_PATTERN$2)) {
2160
+ tagged = true;
2161
+ appendWords$2(words, text.slice(cursor, match.index), readTimeRange(match[1], match[2]));
2162
+ cursor = match.index + match[0].length;
2163
+ }
2164
+ if (!tagged) return;
2165
+ appendWords$2(words, text.slice(cursor));
2166
+ return words;
2167
+ };
2168
+ /**
2169
+ * Kugou writes `//` where it holds no translation, in QRC source rows and in its language payload alike.
2170
+ *
2171
+ * @returns Whether the trimmed text is that sentinel instead of real content.
2172
+ */
2173
+ var isPlaceholderText = (text) => {
2174
+ return text.trim() === "//";
2175
+ };
2176
+ var readLines = (text) => {
2177
+ const lines = [];
2178
+ const meta = makeMeta();
2179
+ const placeholders = /* @__PURE__ */ new Set();
2180
+ for (const raw of text.split(/\r\n?|\n/)) {
2181
+ const line = raw.trimStart();
2182
+ const match = LINE_PATTERN$2.exec(line);
2183
+ if (match) {
2184
+ const time = readTimeRange(match[1], match[2]);
2185
+ const text = line.slice(match[0].length);
2186
+ const words = readWords$1(text);
2187
+ const contentText = (words ? getWordsText(words) : text).trim();
2188
+ const placeholder = isPlaceholderText(contentText);
2189
+ if (placeholder) placeholders.add(lines.length);
2190
+ if (!contentText || placeholder) lines.push(makeLineInstrumental({ time }));
2191
+ else {
2192
+ const content = words ? makeLineContentWords(words) : makeLineContentText(text);
2193
+ lines.push(makeLineNormal({
2194
+ time,
2195
+ content
2196
+ }));
2197
+ }
2198
+ continue;
2199
+ }
2200
+ if (!line.startsWith("[")) continue;
2201
+ for (const tag of line.matchAll(META_TAG_PATTERN)) {
2202
+ const body = tag[1];
2203
+ if (isTime(body)) continue;
2204
+ readMeta(body, meta);
2205
+ }
2206
+ }
2207
+ return {
2208
+ lines,
2209
+ meta,
2210
+ placeholders
2211
+ };
2212
+ };
2213
+ var readAnnotations$1 = (text, timings) => {
2214
+ if (!text) return [];
2215
+ const { lines } = readLines(text);
2216
+ if (lines.length) {
2217
+ for (const line of lines) timings.set(line, NATIVE_ANNOTATION_TIMING);
2218
+ return lines;
2219
+ }
2220
+ readLrc(text, { onLine: (line, timing) => {
2221
+ const annotation = isPlaceholderText(getContentText(line.content)) ? makeLineInstrumental({ time: line.time }) : line;
2222
+ timings.set(annotation, timing);
2223
+ lines.push(annotation);
2224
+ } });
2225
+ return lines;
2226
+ };
2227
+ /**
2228
+ * Parses plaintext or XML QRC, retaining explicit timing, word romanization, and ruby from kana metadata.
2229
+ * Original-track kana supplies ruby and is removed from the returned metadata.
2230
+ *
2231
+ * @param input Original lyrics with optional translation and romanization tracks.
2232
+ * @param options Independent line and word tolerances, with optional local roman recovery.
2233
+ *
2234
+ * @returns Canonical lyrics and source diagnostics; an empty result has INVALID status.
2235
+ */
2236
+ var parseQrc = (input, options = {}) => {
2237
+ const source = readContent(input);
2238
+ if (!source) return finalizeLyric(makeLyric({
2239
+ format: "qrc",
2240
+ status: LyricStatus.INVALID
2241
+ }));
2242
+ const parsed = readLines(source.original);
2243
+ const meta = parsed.meta;
2244
+ const rubies = readRubies(meta);
2245
+ if (!parsed.lines.length) return finalizeLyric(makeLyric({
2246
+ format: "qrc",
2247
+ status: LyricStatus.INVALID,
2248
+ meta
2249
+ }));
2250
+ const timings = /* @__PURE__ */ new WeakMap();
2251
+ const translationLines = readAnnotations$1(source.translations && readText$2(source.translations), timings);
2252
+ const romanLines = readAnnotations$1(source.romans && readText$2(source.romans), timings);
2253
+ const rounding = romanLines.some((line) => timings.get(line)?.rounding === 1) ? 1 : 0;
2254
+ const wordTolerance = Number.isFinite(options.wordAnnotationTolerance) ? options.wordAnnotationTolerance ?? 0 : 0;
2255
+ const annotated = attachAnnotations(!rubies ? parsed.lines : attachRubies(parsed.lines, rubies).lines, {
2256
+ translations: translationLines,
2257
+ romans: romanLines
2258
+ }, {
2259
+ matchByOrder: true,
2260
+ preservePlaceholders: true,
2261
+ tolerance: options.annotationTolerance,
2262
+ wordTolerance: Math.max(wordTolerance, rounding),
2263
+ wordFallback: options.wordAnnotationFallback,
2264
+ getTiming: (line) => timings.get(line)
2265
+ });
2266
+ const lines = parsed.placeholders.size ? annotated.filter((_, index) => !parsed.placeholders.has(index)) : annotated;
2267
+ return finalizeLyric(makeLyric({
2268
+ format: "qrc",
2269
+ status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
2270
+ timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
2271
+ meta,
2272
+ lines,
2273
+ languages: deriveLanguageUsages(lines)
2274
+ }));
2275
+ };
2276
+ //#endregion
2277
+ //#region src/formats/krc/parser.ts
2278
+ var SEPARATORS$2 = /^\p{White_Space}+$/u;
2279
+ var appendWords$1 = (words, text, time) => {
2280
+ for (const fragment of text.split(/(\p{White_Space}+)/u)) {
2281
+ if (!fragment) continue;
2282
+ words.push(SEPARATORS$2.test(fragment) ? makeWordSpace({ text: fragment }) : makeWordNormal({
2283
+ text: fragment,
2284
+ time
2285
+ }));
2286
+ }
2287
+ };
2288
+ var readLine$3 = (body, start, duration) => {
2289
+ const time = makeTimeRange({
2290
+ start,
2291
+ end: start + duration
2292
+ });
2293
+ const pieces = scanWordTags(body, WORD_PATTERN$3);
2294
+ if (!pieces.some((piece) => piece.tag !== void 0)) return body.trim() ? makeLineNormal({
2295
+ time,
2296
+ content: makeLineContentText(body)
2297
+ }) : makeLineInstrumental({ time });
2298
+ const words = [];
2299
+ for (const piece of pieces) {
2300
+ if (piece.tag === void 0) {
2301
+ appendWords$1(words, piece.text);
2302
+ continue;
2303
+ }
2304
+ const [offset, wordDuration] = piece.tag.split(",").map(Number);
2305
+ const wordStart = start + offset;
2306
+ appendWords$1(words, piece.text, makeTimeRange({
2307
+ start: wordStart,
2308
+ end: wordStart + wordDuration
2309
+ }));
2310
+ }
2311
+ return getWordsText(words).trim() ? makeLineNormal({
2312
+ time,
2313
+ content: makeLineContentWords(words)
2314
+ }) : makeLineInstrumental({ time });
2315
+ };
2316
+ /**
2317
+ * Reads native KRC rows in source order and performs no other operation: no metadata mapping, annotation attachment, sorting, or normalization.
2318
+ *
2319
+ * Explicit line and word durations survive unchanged; whitespace splitting never reallocates segment time.
2320
+ *
2321
+ * @returns Original-order model lines and metadata tags awaiting interpretation.
2322
+ */
2323
+ var readKrc = (text) => {
2324
+ const lines = [];
2325
+ const metaTags = [];
2326
+ for (const raw of text.replace(/^\uFEFF/, "").split(/\r\n?|\n/)) {
2327
+ const line = raw.trimStart();
2328
+ const header = LINE_PATTERN$3.exec(line);
2329
+ if (header) {
2330
+ lines.push(readLine$3(line.slice(header[0].length), Number(header[1]), Number(header[2])));
2331
+ continue;
2332
+ }
2333
+ if (!line.startsWith("[")) continue;
2334
+ for (const tag of line.matchAll(TAG_PATTERN)) {
2335
+ const meta = META_PATTERN.exec(tag[1]);
2336
+ if (meta) metaTags.push({
2337
+ key: meta[1].toLowerCase(),
2338
+ value: meta[2].trim()
2339
+ });
2340
+ }
2341
+ }
2342
+ return {
2343
+ lines,
2344
+ metaTags
2345
+ };
2346
+ };
2347
+ var attachKana = (lines, meta) => {
2348
+ for (const entry of meta.unknowns) {
2349
+ if (entry.key !== "kana") continue;
2350
+ const sources = readRubyEntries(entry.value);
2351
+ if (!sources) continue;
2352
+ const attached = attachRubies(lines, sources);
2353
+ if (attached.matched.size === sources.length) meta.unknowns = meta.unknowns.filter((item) => item !== entry);
2354
+ return attached.lines;
2355
+ }
2356
+ return lines;
2357
+ };
2358
+ var isObject$1 = (value) => {
2359
+ return !!value && typeof value === "object" && !Array.isArray(value);
2360
+ };
2361
+ var readPayload = (value) => {
2362
+ const bytes = decodeBase64(value);
2363
+ if (!bytes) return;
2364
+ try {
2365
+ const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
2366
+ return isObject$1(payload) && payload.version === 1 && Array.isArray(payload.content) ? payload : void 0;
2367
+ } catch {
2368
+ return;
2369
+ }
2370
+ };
2371
+ var readTrack$1 = (value) => {
2372
+ if (!isObject$1(value) || value.type !== 0 && value.type !== 1 || typeof value.language !== "number" || !Number.isSafeInteger(value.language) || value.language < 0 || !Array.isArray(value.lyricContent) || !value.lyricContent.every((row) => Array.isArray(row) && row.every((cell) => typeof cell === "string"))) return;
2373
+ return value;
2374
+ };
2375
+ var attachTrack = (lines, track) => {
2376
+ let result = lines;
2377
+ let complete = true;
2378
+ for (const [index, cells] of track.lyricContent.entries()) {
2379
+ const text = cells.join("");
2380
+ if (cells.length > 1) complete = false;
2381
+ if (!text.trim() || isPlaceholderText(text)) continue;
2382
+ const line = result[index];
2383
+ if (line.type === LineType.INSTRUMENTAL) {
2384
+ complete = false;
2385
+ continue;
2386
+ }
2387
+ const kind = track.type === 1 ? "translations" : "romans";
2388
+ const existing = line.annotation?.[kind] ?? [];
2389
+ const occupied = existing.find((item) => !item.language);
2390
+ if (occupied) {
2391
+ complete = complete && occupied.text === text;
2392
+ continue;
2393
+ }
2394
+ const create = track.type === 1 ? makeLineAnnotationTranslation : makeLineAnnotationRoman;
2395
+ const annotated = {
2396
+ ...line,
2397
+ annotation: makeLineAnnotation({
2398
+ ...line.annotation,
2399
+ [kind]: [...existing, create({ text })]
2400
+ })
2401
+ };
2402
+ if (result === lines) result = lines.slice();
2403
+ result[index] = annotated;
2404
+ }
2405
+ return {
2406
+ lines: result,
2407
+ complete
2408
+ };
2409
+ };
2410
+ /**
2411
+ * Attaches strictly recognized Kugou language tracks by original timed slot before sorting or filtering.
2412
+ *
2413
+ * Existing annotations take precedence; incomplete or ambiguous payloads remain in metadata.
2414
+ * Numeric language codes other than the verified default are retained without inventing BCP 47 tags.
2415
+ *
2416
+ * @returns Annotated lines, reusing the original array when no visible content changes.
2417
+ */
2418
+ var attachKugouLanguage = (lines, meta) => {
2419
+ if (!lines.length) return lines;
2420
+ let result = lines;
2421
+ const unknowns = [];
2422
+ const selected = /* @__PURE__ */ new Map();
2423
+ for (const entry of meta.unknowns) {
2424
+ const payload = entry.key === "language" ? readPayload(entry.value) : void 0;
2425
+ if (!payload) {
2426
+ unknowns.push(entry);
2427
+ continue;
2428
+ }
2429
+ let complete = Object.keys(payload).every((key) => key === "version" || key === "content" || key === "contentV2");
2430
+ if ("contentV2" in payload && (!Array.isArray(payload.contentV2) || payload.contentV2.length !== 0)) complete = false;
2431
+ for (const value of payload.content) {
2432
+ const track = readTrack$1(value);
2433
+ if (!track || track.language !== 0 || track.lyricContent.length !== lines.length) {
2434
+ complete = false;
2435
+ continue;
2436
+ }
2437
+ if (Object.keys(value).some((key) => key !== "type" && key !== "language" && key !== "lyricContent")) complete = false;
2438
+ const previous = selected.get(track.type);
2439
+ if (previous) {
2440
+ complete = complete && JSON.stringify(previous) === JSON.stringify(track.lyricContent);
2441
+ continue;
2442
+ }
2443
+ selected.set(track.type, track.lyricContent);
2444
+ const attached = attachTrack(result, track);
2445
+ result = attached.lines;
2446
+ complete = complete && attached.complete;
2447
+ }
2448
+ if (!complete) unknowns.push(entry);
2449
+ }
2450
+ meta.unknowns = unknowns;
2451
+ return result;
2452
+ };
2453
+ /**
2454
+ * Parses decrypted Kugou lyrics, retaining explicit durations and attaching embedded annotations by source slot.
2455
+ *
2456
+ * A source carrying no KRC row is read as LRC, so a platform fallback keeps its container annotations.
2457
+ * Placeholder slots carry no annotation, so they never keep a credit line in the stream.
2458
+ * Kana supplies whole-word ruby; type 0 language tracks retain complete line romanization.
2459
+ * Incomplete metadata remains available in unknowns, and source timing violations remain visible in diagnostics.
2460
+ *
2461
+ * @param input Decrypted plaintext or an object containing its original text.
2462
+ *
2463
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
2464
+ */
2465
+ var parseKrc = (input) => {
2466
+ const source = readKrcContent(input);
2467
+ if (!source) return finalizeLyric(makeLyric({
2468
+ format: "krc",
2469
+ status: LyricStatus.INVALID
2470
+ }));
2471
+ const native = readKrc(source.original);
2472
+ const parsed = native.lines.length ? native : readLrc(source.original);
2473
+ const meta = readLrcMetaTags(parsed.metaTags);
2474
+ const lines = attachKugouLanguage(attachKana(parsed.lines, meta), meta);
2475
+ return finalizeLyric(makeLyric({
2476
+ format: "krc",
2477
+ status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
2478
+ timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
2479
+ meta,
2480
+ lines,
2481
+ languages: deriveLanguageUsages(lines)
2482
+ }));
2483
+ };
2484
+ //#endregion
2485
+ //#region src/formats/kwrc/common.ts
2486
+ var FORMAT$1 = "kwrc";
2487
+ var LINE_PATTERN$1 = /^\[([^\]]*)\]/;
2488
+ var WORD_PATTERN$1 = /<(-?\d+,-?\d+(?:,-?\d+)?)>/g;
2489
+ var VISIBLE_TEXT = /[^\p{White_Space}]/u;
2490
+ /**
2491
+ * Removes only complete encoded tags so literal angle-bracket text survives.
2492
+ */
2493
+ var readText$1 = (text) => {
2494
+ return text.replace(WORD_PATTERN$1, "");
2495
+ };
2496
+ /**
2497
+ * Accepts decoded plaintext without unwrapping XML or auxiliary tracks.
2498
+ */
2499
+ var readKwrcContent = (input) => {
2500
+ const source = typeof input === "string" ? { original: input } : input;
2501
+ if (!source || typeof source !== "object" || Array.isArray(source)) return null;
2502
+ const original = source.original;
2503
+ if (typeof original !== "string" || /^\s*</.test(original)) return null;
2504
+ return { original };
2505
+ };
2506
+ /**
2507
+ * Retains source slots and literal bodies before word timing or bilingual boundaries are interpreted.
2508
+ */
2509
+ var readKwrcSource = (text) => {
2510
+ const lines = [];
2511
+ const metaTags = [];
2512
+ const rows = text.replace(/^\uFEFF/, "").split(/\r\n?|\n/);
2513
+ for (const [row, raw] of rows.entries()) {
2514
+ const times = [];
2515
+ let body = raw.trimStart();
2516
+ let header;
2517
+ while (header = LINE_PATTERN$1.exec(body)) {
2518
+ const tag = header[1].trim();
2519
+ const start = parseTime(tag);
2520
+ if (start !== void 0) times.push({
2521
+ start,
2522
+ clock: /^[\d:.]+$/.test(tag)
2523
+ });
2524
+ else {
2525
+ const meta = META_PATTERN.exec(tag);
2526
+ if (!meta || times.length) break;
2527
+ metaTags.push({
2528
+ key: meta[1].toLowerCase(),
2529
+ value: meta[2].trim()
2530
+ });
2531
+ }
2532
+ body = body.slice(header[0].length);
2533
+ if (!times.length) body = body.trimStart();
2534
+ }
2535
+ for (const time of times) lines.push({
2536
+ row,
2537
+ ...time,
2538
+ text: body
2539
+ });
2540
+ }
2541
+ return {
2542
+ lines,
2543
+ metaTags
2544
+ };
2545
+ };
2546
+ /**
2547
+ * Requires complete octal values and agreement across all parameter tags, without selecting a default scale.
2548
+ */
2549
+ var readKwrcScales = (tags) => {
2550
+ let value;
2551
+ for (const tag of tags) {
2552
+ if (tag.key !== "kuwo") continue;
2553
+ if (!/^[0-7]+$/.test(tag.value)) return;
2554
+ const next = Number.parseInt(tag.value, 8);
2555
+ if (!Number.isSafeInteger(next) || value !== void 0 && value !== next) return;
2556
+ value = next;
2557
+ }
2558
+ if (value === void 0) return;
2559
+ const start = Math.trunc(value / 10);
2560
+ const duration = value % 10;
2561
+ return start > 0 && duration > 0 ? {
2562
+ start,
2563
+ duration
2564
+ } : void 0;
2565
+ };
2566
+ /**
2567
+ * A repeated timestamp opens the previous original's translation slot before the next original.
2568
+ * The entire sequence must agree so one missing slot cannot shift all later translations.
2569
+ */
2570
+ var hasKwrcTranslations = ({ lines, metaTags }) => {
2571
+ const markers = metaTags.filter((tag) => tag.key === "ml");
2572
+ if (!markers.length || markers.some((tag) => tag.value !== "1.0") || lines.length < 3 || lines.length % 2 !== 1) return false;
2573
+ if (!lines.every((line, index) => {
2574
+ return line.clock && Number.isSafeInteger(line.start) && line.row !== lines[index - 1]?.row;
2575
+ })) return false;
2576
+ let previous = lines[0];
2577
+ for (let index = 1; index < lines.length; index += 2) {
2578
+ const translation = lines[index];
2579
+ const original = lines[index + 1];
2580
+ if (translation.start <= previous.start || translation.start !== original.start) return false;
2581
+ if (!VISIBLE_TEXT.test(readText$1(previous.text)) && VISIBLE_TEXT.test(readText$1(translation.text))) return false;
2582
+ previous = original;
2583
+ }
2584
+ return true;
2585
+ };
2586
+ //#endregion
2587
+ //#region src/formats/kwrc/detect.ts
2588
+ /**
2589
+ * Recognizes octal parameters with encoded clock rows, or a complete marked bilingual sequence.
2590
+ */
2591
+ var detectKwrc = (input) => {
2592
+ const source = readKwrcContent(input);
2593
+ if (!source) return false;
2594
+ const track = readKwrcSource(source.original);
2595
+ if (readKwrcScales(track.metaTags)) {
2596
+ for (const line of track.lines) if (line.clock && Number.isSafeInteger(line.start) && line.text.search(WORD_PATTERN$1) !== -1) return true;
2597
+ }
2598
+ return hasKwrcTranslations(track);
2599
+ };
2600
+ //#endregion
2601
+ //#region src/formats/kwrc/parser.ts
2602
+ var SEPARATORS$1 = /^\p{White_Space}+$/u;
2603
+ var readWordTime = (tag, lineStart, scales) => {
2604
+ const values = tag.split(",").map(Number);
2605
+ if (!Number.isSafeInteger(lineStart) || !values.every(Number.isSafeInteger)) return;
2606
+ const [x, y] = values;
2607
+ const sum = x + y;
2608
+ const difference = x - y;
2609
+ if (!Number.isSafeInteger(sum) || !Number.isSafeInteger(difference) || sum % (2 * scales.start) !== 0 || difference % (2 * scales.duration) !== 0) return;
2610
+ const relativeStart = Math.abs(sum / (2 * scales.start));
2611
+ const duration = Math.abs(difference / (2 * scales.duration));
2612
+ const start = lineStart + relativeStart;
2613
+ const end = start + duration;
2614
+ if (![
2615
+ relativeStart,
2616
+ duration,
2617
+ start,
2618
+ end
2619
+ ].every(Number.isSafeInteger)) return;
2620
+ return makeTimeRange({
2621
+ start,
2622
+ end
2623
+ });
2624
+ };
2625
+ var readLine$2 = ({ text, start }, nextStart, scales) => {
2626
+ const boundary = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
2627
+ let end = boundary;
2628
+ const pieces = scanWordTags(text, WORD_PATTERN$1);
2629
+ if (!pieces.some((piece) => piece.tag !== void 0)) {
2630
+ if (Number.isSafeInteger(start) && splitWordTags(text).some((piece) => piece.tag !== void 0)) return readLrc(`[${start}ms]${text}${end === void 0 ? "" : `\n[${end}ms]`}`).lines[0];
2631
+ const time = makeTimeRange({
2632
+ start,
2633
+ end
2634
+ });
2635
+ return VISIBLE_TEXT.test(text) ? makeLineNormal({
2636
+ time,
2637
+ content: makeLineContentText(text)
2638
+ }) : makeLineInstrumental({ time });
2639
+ }
2640
+ const words = [];
2641
+ for (const piece of pieces) {
2642
+ const time = piece.tag !== void 0 && scales ? readWordTime(piece.tag, start, scales) : void 0;
2643
+ if (piece.tag !== void 0 && !time) return makeLineNormal({
2644
+ time: makeTimeRange({
2645
+ start,
2646
+ end: boundary
2647
+ }),
2648
+ content: makeLineContentText(text)
2649
+ });
2650
+ if (time?.end !== void 0) end = Math.max(end ?? start, time.end);
2651
+ for (const fragment of piece.text.split(/(\p{White_Space}+)/u)) if (fragment) words.push(SEPARATORS$1.test(fragment) ? makeWordSpace({ text: fragment }) : makeWordNormal({
2652
+ text: fragment,
2653
+ time
2654
+ }));
2655
+ }
2656
+ const time = makeTimeRange({
2657
+ start,
2658
+ end
2659
+ });
2660
+ return VISIBLE_TEXT.test(readText$1(text)) ? makeLineNormal({
2661
+ time,
2662
+ content: makeLineContentWords(words)
2663
+ }) : makeLineInstrumental({ time });
2664
+ };
2665
+ /**
2666
+ * Parses decoded KWRC with explicit word durations and structurally verified embedded translations.
2667
+ *
2668
+ * Missing or conflicting parameters and unsafe word calculations retain the complete row as line-level text.
2669
+ * Plain LRC remains readable; offset is stored as metadata without changing source timestamps.
2670
+ *
2671
+ * @param input Decoded plaintext or an object containing its original text.
2672
+ *
2673
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
2674
+ */
2675
+ var parseKwrc = (input) => {
2676
+ const source = readKwrcContent(input);
2677
+ if (!source) return finalizeLyric(makeLyric({
2678
+ format: FORMAT$1,
2679
+ status: LyricStatus.INVALID
2680
+ }));
2681
+ const track = readKwrcSource(source.original);
2682
+ const bilingual = hasKwrcTranslations(track);
2683
+ const encoded = track.lines.some((line) => !!line.text.match(WORD_PATTERN$1));
2684
+ let lines;
2685
+ let tags = track.metaTags;
2686
+ if (!encoded && !bilingual) {
2687
+ const fallback = readLrc(source.original);
2688
+ lines = fallback.lines;
2689
+ tags = fallback.metaTags;
2690
+ } else {
2691
+ const scales = readKwrcScales(tags);
2692
+ const originals = bilingual ? track.lines.filter((_, index) => index % 2 === 0) : track.lines;
2693
+ lines = originals.map((original, index) => {
2694
+ const line = readLine$2(original, originals[index + 1]?.start, scales);
2695
+ const translation = bilingual ? readText$1(track.lines[index * 2 + 1]?.text ?? "") : "";
2696
+ if (VISIBLE_TEXT.test(translation)) line.annotation = makeLineAnnotation({ translations: [makeLineAnnotationTranslation({ text: translation })] });
2697
+ return line;
2698
+ });
2699
+ }
2700
+ const last = lines.at(-1);
2701
+ if (last?.type === LineType.INSTRUMENTAL && (last.time?.end === void 0 || last.time.end === last.time.start)) lines.pop();
2702
+ return finalizeLyric(makeLyric({
2703
+ format: FORMAT$1,
2704
+ status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
2705
+ timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
2706
+ meta: readLrcMetaTags(tags),
2707
+ lines,
2708
+ languages: deriveLanguageUsages(lines)
2709
+ }));
2710
+ };
2711
+ //#endregion
1279
2712
  //#region src/formats/ttml/common.ts
1280
2713
  var FORMAT = "ttml";
1281
2714
  var AMLL_HINT_PATTERN = /xmlns:amll|amll:meta/iu;
@@ -1404,7 +2837,7 @@ var renderTime = (time, offset) => {
1404
2837
  * Writes known bounds without inventing an end.
1405
2838
  */
1406
2839
  var renderTimeAttributes = (time, offset) => {
1407
- if (time === void 0) throw new Error("TTML sung lines, words, and ruby tokens require a time range");
2840
+ if (!time) throw new Error("TTML sung lines, words, and ruby tokens require a time range");
1408
2841
  const attributes = [["begin", renderTime(time.start, offset)]];
1409
2842
  if (time.end !== void 0) attributes.push(["end", renderTime(time.end, offset)]);
1410
2843
  return attributes;
@@ -1426,7 +2859,7 @@ var renderWord = (word, context, language) => {
1426
2859
  if (word.type === WordType.SPACE) return createXmlText(word.text);
1427
2860
  const attributes = [...renderTimeAttributes(word.time, context.offset), ...renderLanguage(word.language, language)];
1428
2861
  const ruby = context.profile === "amll" ? word.annotation?.rubies[0] : void 0;
1429
- if (ruby === void 0) return createXmlElement("span", attributes, [createXmlText(word.text)]);
2862
+ if (!ruby) return createXmlElement("span", attributes, [createXmlText(word.text)]);
1430
2863
  const tokens = ruby.tokens.map((token) => {
1431
2864
  return createXmlElement("span", [["tts:ruby", RUBY_TEXT], ...renderTimeAttributes(resolveAnnotationTokenTime(token, ruby, word), context.offset)], [createXmlText(token.text)]);
1432
2865
  });
@@ -1441,7 +2874,7 @@ var renderWord = (word, context, language) => {
1441
2874
  */
1442
2875
  var renderContent = (line, context) => {
1443
2876
  const content = line.content;
1444
- if (content === void 0) throw new Error("TTML sung lines require content");
2877
+ if (!content) throw new Error("TTML sung lines require content");
1445
2878
  if (content.text !== void 0) return [createXmlText(content.text)];
1446
2879
  return content.words.map((word) => {
1447
2880
  return renderWord(word, context, getLineLanguage(line));
@@ -1450,10 +2883,10 @@ var renderContent = (line, context) => {
1450
2883
  /**
1451
2884
  * Keeps annotations outside the sung word stream.
1452
2885
  */
1453
- var renderInlineAnnotations = (annotation, language) => {
2886
+ var renderInlineAnnotations = (line, language) => {
1454
2887
  const nodes = [];
1455
- for (const item of annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1456
- for (const item of annotation?.romans ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_ROMAN], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
2888
+ for (const item of line.annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
2889
+ for (const item of getLineRomans(line)) nodes.push(createXmlElement("span", [["ttm:role", ROLE_ROMAN], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
1457
2890
  return nodes;
1458
2891
  };
1459
2892
  /**
@@ -1469,7 +2902,7 @@ var renderLineAttributes = (line, context) => {
1469
2902
  const lead = line.agents[0];
1470
2903
  if (lead !== void 0) {
1471
2904
  const id = context.agents.get(lead);
1472
- if (id === void 0) throw new Error("TTML performing agents must have a declaration");
2905
+ if (!id) throw new Error("TTML performing agents must have a declaration");
1473
2906
  attributes.push(["ttm:agent", id]);
1474
2907
  }
1475
2908
  return attributes;
@@ -1482,7 +2915,7 @@ var createLyricElement = (name, attributes, children) => {
1482
2915
  };
1483
2916
  var renderBackground = (line, context) => {
1484
2917
  const children = renderContent(line, context);
1485
- if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
2918
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
1486
2919
  return createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ...renderLineAttributes(line, context)], children);
1487
2920
  };
1488
2921
  var renderLine = (line, context) => {
@@ -1490,7 +2923,7 @@ var renderLine = (line, context) => {
1490
2923
  const after = [];
1491
2924
  for (const background of line.backgrounds) {
1492
2925
  const node = renderBackground(background, context);
1493
- if (background.time !== void 0 && line.time !== void 0 && background.time.start < line.time.start) before.push(node);
2926
+ if (background.time && line.time && background.time.start < line.time.start) before.push(node);
1494
2927
  else after.push(node);
1495
2928
  }
1496
2929
  const children = [
@@ -1498,7 +2931,7 @@ var renderLine = (line, context) => {
1498
2931
  ...renderContent(line, context),
1499
2932
  ...after
1500
2933
  ];
1501
- if (context.profile === "amll") children.push(...renderInlineAnnotations(line.annotation, getLineLanguage(line)));
2934
+ if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
1502
2935
  return createLyricElement("p", renderLineAttributes(line, context), children);
1503
2936
  };
1504
2937
  var renderAgents = (lyric, context) => {
@@ -1533,10 +2966,10 @@ var renderSongwriters = (meta) => {
1533
2966
  if (credit.role !== CreditRole.LYRICIST) continue;
1534
2967
  for (const name of credit.names) names.push(createXmlElement("songwriter", renderLanguage(name.language), [createXmlText(name.text)]));
1535
2968
  }
1536
- return names.length === 0 ? [] : [createXmlElement("songwriters", [], names)];
2969
+ return names.length ? [createXmlElement("songwriters", [], names)] : [];
1537
2970
  };
1538
2971
  var renderAnnotationTracks = (tracks, kind, context) => {
1539
- if (tracks.size === 0) return [];
2972
+ if (!tracks.size) return [];
1540
2973
  const translated = kind === "translations";
1541
2974
  const blocks = Array.from(tracks, ([language, track]) => {
1542
2975
  const attributes = translated ? [["type", "subtitle"]] : [];
@@ -1556,26 +2989,29 @@ var renderHeadAnnotations = (lines, context) => {
1556
2989
  translations: /* @__PURE__ */ new Map(),
1557
2990
  romans: /* @__PURE__ */ new Map()
1558
2991
  };
1559
- const append = (annotation, line, background) => {
1560
- if (annotation === void 0) return;
2992
+ const append = (source, line, background) => {
2993
+ const annotation = {
2994
+ translations: source.annotation?.translations ?? [],
2995
+ romans: getLineRomans(source)
2996
+ };
1561
2997
  for (const kind of ANNOTATION_KINDS) for (const item of annotation[kind]) {
1562
2998
  const language = item.language?.toLowerCase();
1563
2999
  let track = tracks[kind].get(language);
1564
- if (track === void 0) {
3000
+ if (!track) {
1565
3001
  track = /* @__PURE__ */ new Map();
1566
3002
  tracks[kind].set(language, track);
1567
3003
  }
1568
3004
  let nodes = track.get(line);
1569
- if (nodes === void 0) {
3005
+ if (!nodes) {
1570
3006
  nodes = [];
1571
3007
  track.set(line, nodes);
1572
3008
  }
1573
- nodes.push(background === void 0 ? createXmlText(item.text) : createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ["for", context.keys.get(background)]], [createXmlText(item.text)]));
3009
+ nodes.push(!background ? createXmlText(item.text) : createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ["for", context.keys.get(background)]], [createXmlText(item.text)]));
1574
3010
  }
1575
3011
  };
1576
3012
  for (const line of lines) {
1577
- append(line.annotation, line);
1578
- for (const background of line.backgrounds) append(background.annotation, line, background);
3013
+ append(line, line);
3014
+ for (const background of line.backgrounds) append(background, line, background);
1579
3015
  }
1580
3016
  return ANNOTATION_KINDS.flatMap((kind) => {
1581
3017
  return renderAnnotationTracks(tracks[kind], kind, context);
@@ -1584,12 +3020,12 @@ var renderHeadAnnotations = (lines, context) => {
1584
3020
  var renderMetadata = (lyric, lines, context, includeMeta) => {
1585
3021
  const metadata = renderAgents(lyric, context);
1586
3022
  const itunes = [];
1587
- if (includeMeta && lyric.meta !== void 0) {
3023
+ if (includeMeta && lyric.meta) {
1588
3024
  if (context.profile === "amll") metadata.push(...renderAmllMeta(lyric.meta));
1589
3025
  itunes.push(...renderSongwriters(lyric.meta));
1590
3026
  }
1591
3027
  if (context.profile === "itunes") itunes.push(...renderHeadAnnotations(lines, context));
1592
- if (itunes.length > 0) metadata.push(createXmlElement("iTunesMetadata", [["xmlns", NAMESPACE_ITUNES_METADATA]], itunes));
3028
+ if (itunes.length) metadata.push(createXmlElement("iTunesMetadata", [["xmlns", NAMESPACE_ITUNES_METADATA]], itunes));
1593
3029
  return createXmlElement("metadata", renderLanguage(void 0, lyric.meta?.language), metadata);
1594
3030
  };
1595
3031
  var renderRootAttributes = (lyric, profile) => {
@@ -1608,6 +3044,7 @@ var renderRootAttributes = (lyric, profile) => {
1608
3044
  *
1609
3045
  * Offsets are baked into timestamps; a negative result or a missing required time throws.
1610
3046
  * Profile mappings retain the lead agent and first AMLL ruby, omitting unsupported fields and instrumental nodes.
3047
+ * Word romanizations are emitted as line text without their token timing.
1611
3048
  */
1612
3049
  var generateTtml = (lyric, options = {}) => {
1613
3050
  if (lyric.timing !== Timing.LINE && lyric.timing !== Timing.WORD) throw new Error("TTML generation requires line or word timing");
@@ -1691,7 +3128,7 @@ var readText = (element, inherited) => {
1691
3128
  * Preserves the parser's whitespace segmentation when turning text into word drafts.
1692
3129
  */
1693
3130
  var splitWords = (text, language) => {
1694
- if (text === "") return [];
3131
+ if (!text) return [];
1695
3132
  if (!WORD_SEPARATOR_PATTERN.test(text)) return [{
1696
3133
  text,
1697
3134
  space: false,
@@ -1699,7 +3136,7 @@ var splitWords = (text, language) => {
1699
3136
  rubies: []
1700
3137
  }];
1701
3138
  const words = [];
1702
- for (const run of text.split(/(\p{White_Space}+)/u)) if (run !== "") {
3139
+ for (const run of text.split(/(\p{White_Space}+)/u)) if (run) {
1703
3140
  const space = /^\p{White_Space}+$/u.test(run);
1704
3141
  words.push({
1705
3142
  text: run,
@@ -1714,7 +3151,7 @@ var splitWords = (text, language) => {
1714
3151
  * Distinguishes indentation runs from separators using the parser's existing rule.
1715
3152
  */
1716
3153
  var isFormattingWhitespace = (text) => {
1717
- return text.trim() === "" && text.includes("\n");
3154
+ return !text.trim() && text.includes("\n");
1718
3155
  };
1719
3156
  /**
1720
3157
  * Builds words from source drafts without deriving missing timestamps.
@@ -1730,7 +3167,7 @@ var buildWords = (draft) => {
1730
3167
  start: word.start,
1731
3168
  end: word.end
1732
3169
  });
1733
- if (word.rubies.length === 0) {
3170
+ if (!word.rubies.length) {
1734
3171
  words.push(makeWordNormal({
1735
3172
  text: word.text,
1736
3173
  time,
@@ -1769,7 +3206,7 @@ var buildWords = (draft) => {
1769
3206
  * Builds only annotations explicitly present in the source drafts.
1770
3207
  */
1771
3208
  var buildLineAnnotation = (draft) => {
1772
- if (draft.translations.length === 0 && draft.romans.length === 0) return;
3209
+ if (!draft.translations.length && !draft.romans.length) return;
1773
3210
  return makeLineAnnotation({
1774
3211
  translations: draft.translations.map((item) => {
1775
3212
  return makeLineAnnotationTranslation({
@@ -1820,9 +3257,9 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
1820
3257
  preserveSpace,
1821
3258
  language
1822
3259
  }];
1823
- while (stack.length > 0) {
3260
+ while (stack.length) {
1824
3261
  const entry = stack.pop();
1825
- if (entry === void 0) continue;
3262
+ if (!entry) continue;
1826
3263
  const element = entry.element;
1827
3264
  const currentSpace = readPreserveSpace(element, entry.preserveSpace);
1828
3265
  const currentLanguage = readLanguage(element, entry.language);
@@ -1833,7 +3270,7 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
1833
3270
  preserveSpace: currentSpace,
1834
3271
  language: currentLanguage
1835
3272
  };
1836
- if (group === void 0) result.set(element.localName, [context]);
3273
+ if (!group) result.set(element.localName, [context]);
1837
3274
  else group.push(context);
1838
3275
  if (stopAtMatch) continue;
1839
3276
  }
@@ -1893,22 +3330,22 @@ var readRuby = (container, preserveSpace, language) => {
1893
3330
  const last = tokens.findLast((token) => {
1894
3331
  return token.start !== void 0;
1895
3332
  });
1896
- const time = readSpanTime(container) ?? rubyTime ?? (first === void 0 ? void 0 : {
3333
+ const time = readSpanTime(container) ?? rubyTime ?? (first ? {
1897
3334
  start: first.start,
1898
3335
  end: last?.end
1899
- });
3336
+ } : void 0);
1900
3337
  return {
1901
3338
  text: base,
1902
3339
  space: false,
1903
3340
  start: time?.start,
1904
3341
  end: time?.end,
1905
3342
  language: baseLanguage,
1906
- rubies: tokens.length === 0 ? [] : [{
3343
+ rubies: tokens.length ? [{
1907
3344
  start: rubyTime?.start,
1908
3345
  end: rubyTime?.end,
1909
3346
  language: rubyLanguage,
1910
3347
  tokens
1911
- }]
3348
+ }] : []
1912
3349
  };
1913
3350
  };
1914
3351
  /**
@@ -1932,7 +3369,7 @@ var readWords = (element, preserveSpace, language) => {
1932
3369
  if (getXmlAttribute(child, "role") !== void 0) continue;
1933
3370
  if (getXmlAttribute(child, "ruby") === "container") {
1934
3371
  const ruby = readRuby(child, preserveSpace, language);
1935
- if (ruby !== void 0) {
3372
+ if (ruby) {
1936
3373
  for (const part of splitWords(ruby.text)) words.push(part.space ? part : {
1937
3374
  ...ruby,
1938
3375
  text: part.text
@@ -1946,7 +3383,7 @@ var readWords = (element, preserveSpace, language) => {
1946
3383
  if (!text || !childSpace && isFormattingWhitespace(text)) continue;
1947
3384
  const time = readSpanTime(child);
1948
3385
  for (const part of splitWords(text, readLanguage(child, language))) {
1949
- if (!part.space && time !== void 0) {
3386
+ if (!part.space && time) {
1950
3387
  part.start = time.start;
1951
3388
  part.end = time.end;
1952
3389
  wordLevel = true;
@@ -1982,11 +3419,11 @@ var readInlineAnnotations = (element, preserveSpace, language) => {
1982
3419
  /**
1983
3420
  * Reads one paragraph, pulling its inline annotations and background lines out of the word stream.
1984
3421
  */
1985
- var readLine = (element, preserveSpace, language) => {
3422
+ var readLine$1 = (element, preserveSpace, language) => {
1986
3423
  preserveSpace = readPreserveSpace(element, preserveSpace);
1987
3424
  language = readLanguage(element, language);
1988
3425
  const time = readLineTime(element);
1989
- if (time === void 0) return;
3426
+ if (!time) return;
1990
3427
  const draft = {
1991
3428
  start: time.start,
1992
3429
  end: time.end,
@@ -2027,7 +3464,7 @@ var readBackground = (element, preserveSpace, language) => {
2027
3464
  preserveSpace = readPreserveSpace(element, preserveSpace);
2028
3465
  language = readLanguage(element, language);
2029
3466
  const read = readWords(element, preserveSpace, language);
2030
- if (read.words.length === 0) return;
3467
+ if (!read.words.length) return;
2031
3468
  const begin = getXmlAttribute(element, "begin");
2032
3469
  const end = getXmlAttribute(element, "end");
2033
3470
  const first = read.words.find((word) => {
@@ -2069,7 +3506,7 @@ var buildBackground = (draft) => {
2069
3506
  end: draft.end
2070
3507
  }),
2071
3508
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
2072
- languages: draft.language === void 0 ? [] : [draft.language],
3509
+ languages: draft.language ? [draft.language] : [],
2073
3510
  content: buildContent(draft),
2074
3511
  annotation: buildLineAnnotation(draft)
2075
3512
  });
@@ -2085,7 +3522,7 @@ var buildLine = (draft) => {
2085
3522
  end: draft.end
2086
3523
  }),
2087
3524
  agents: draft.agentId === void 0 ? [] : [draft.agentId],
2088
- languages: draft.language === void 0 ? [] : [draft.language],
3525
+ languages: draft.language ? [draft.language] : [],
2089
3526
  content: buildContent(draft),
2090
3527
  annotation: buildLineAnnotation(draft),
2091
3528
  backgrounds: draft.backgrounds.map(buildBackground)
@@ -2105,7 +3542,7 @@ var readAgents = (elements) => {
2105
3542
  language: readLanguage(name, language)
2106
3543
  });
2107
3544
  }).filter((name) => {
2108
- return name.text !== "";
3545
+ return !!name.text;
2109
3546
  });
2110
3547
  const kind = readAgentType(getXmlAttribute(element, "type"));
2111
3548
  agents.push(makeAgent({
@@ -2129,9 +3566,9 @@ var readCredits = (elements, meta) => {
2129
3566
  language
2130
3567
  });
2131
3568
  }).filter((name) => {
2132
- return name.text !== "";
3569
+ return !!name.text;
2133
3570
  });
2134
- if (names.length === 0) return;
3571
+ if (!names.length) return;
2135
3572
  meta.credits.push(makeMetaCredit({
2136
3573
  role: CreditRole.LYRICIST,
2137
3574
  names
@@ -2185,7 +3622,7 @@ var applyAmllMeta = (meta, element, language) => {
2185
3622
  * A replacement translation rewrites the line's text, which is how a source swaps one script for another.
2186
3623
  */
2187
3624
  var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
2188
- if (lineMap.size === 0) return;
3625
+ if (!lineMap.size) return;
2189
3626
  const attach = (blocks, kind) => {
2190
3627
  for (const { element: block, preserveSpace, language } of blocks) {
2191
3628
  const type = getXmlQualifiedAttribute(block, "type") ?? getXmlAttribute(block, "type");
@@ -2254,23 +3691,23 @@ var parseTtml = (input, options = {}) => {
2254
3691
  const language = readLanguage(root, lowerTag(options.language));
2255
3692
  const body = findElements(root, "body", false, true, language)[0];
2256
3693
  const metadata = findElements(root, "metadata", false, true, language)[0];
2257
- const metadataElements = metadata === void 0 ? /* @__PURE__ */ new Map() : groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language);
3694
+ const metadataElements = metadata ? groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language) : /* @__PURE__ */ new Map();
2258
3695
  const meta = makeMeta();
2259
- if (language !== void 0) meta.language = language;
3696
+ if (language) meta.language = language;
2260
3697
  readCredits(metadataElements.get("songwriter") ?? [], meta);
2261
3698
  for (const { element, language: itemLanguage } of metadataElements.get("meta") ?? []) applyAmllMeta(meta, element, itemLanguage);
2262
3699
  const drafts = [];
2263
3700
  const lineMap = /* @__PURE__ */ new Map();
2264
3701
  const backgroundMap = /* @__PURE__ */ new Map();
2265
3702
  for (const { element, preserveSpace, language: lineLanguage } of body ? findElements(body.element, "p", body.preserveSpace, true, body.language) : []) {
2266
- const draft = readLine(element, preserveSpace, lineLanguage);
3703
+ const draft = readLine$1(element, preserveSpace, lineLanguage);
2267
3704
  if (!draft) continue;
2268
3705
  drafts.push(draft);
2269
3706
  if (draft.key) lineMap.set(draft.key, draft);
2270
3707
  for (const background of draft.backgrounds) if (background.key) backgroundMap.set(background.key, background);
2271
3708
  }
2272
3709
  attachHeadAnnotations(metadataElements, lineMap, backgroundMap);
2273
- if (drafts.length === 0) return finalizeLyric(makeLyric({
3710
+ if (!drafts.length) return finalizeLyric(makeLyric({
2274
3711
  format: FORMAT,
2275
3712
  status: LyricStatus.INVALID,
2276
3713
  meta,
@@ -2294,6 +3731,260 @@ var parseTtml = (input, options = {}) => {
2294
3731
  }));
2295
3732
  };
2296
3733
  //#endregion
3734
+ //#region src/formats/yrc/common.ts
3735
+ var LINE_PATTERN = /^\[(\d+),(\d+)\]/;
3736
+ var WORD_PATTERN = /\((\d+,\d+,\d+)\)/g;
3737
+ /**
3738
+ * Accepts plaintext tracks on one timeline without interpreting an API response or an XML container.
3739
+ *
3740
+ * @returns Original and optional auxiliary text, or null when the original payload is unreadable.
3741
+ */
3742
+ var readYrcContent = (input) => {
3743
+ const value = typeof input === "string" ? { original: input } : input;
3744
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
3745
+ const source = value;
3746
+ if (typeof source.original !== "string" || /^\s*</.test(source.original)) return null;
3747
+ return {
3748
+ original: source.original,
3749
+ translations: typeof source.translations === "string" ? source.translations : void 0,
3750
+ romans: typeof source.romans === "string" ? source.romans : void 0
3751
+ };
3752
+ };
3753
+ //#endregion
3754
+ //#region src/formats/yrc/parser.ts
3755
+ var SEPARATORS = /^\p{White_Space}+$/u;
3756
+ var CREDIT_LABEL_PATTERN = /[::]$/u;
3757
+ var isObject = (value) => {
3758
+ return !!value && typeof value === "object" && !Array.isArray(value);
3759
+ };
3760
+ var readCredit = (fragments) => {
3761
+ const label = trimRuleWhitespace(fragments[0] ?? "");
3762
+ const raw = trimRuleWhitespace(label.replace(CREDIT_LABEL_PATTERN, ""));
3763
+ if (!raw) return;
3764
+ const names = fragments.slice(1).join("").split("/").map(trimRuleWhitespace).filter(Boolean);
3765
+ return names.length ? {
3766
+ raw,
3767
+ names
3768
+ } : void 0;
3769
+ };
3770
+ /**
3771
+ * Reads JSON display text and splits the joined credit content at slash separators.
3772
+ *
3773
+ * @returns A readable display line and whether all source fields are represented, or null for other JSON.
3774
+ */
3775
+ var readJsonLine = (text) => {
3776
+ try {
3777
+ const value = JSON.parse(text);
3778
+ if (!isObject(value) || typeof value.t !== "number" || !Number.isSafeInteger(value.t) || value.t < 0 || !Array.isArray(value.c)) return null;
3779
+ const fragments = [];
3780
+ let complete = Object.keys(value).every((key) => key === "t" || key === "c");
3781
+ for (const fragment of value.c) {
3782
+ if (!isObject(fragment) || typeof fragment.tx !== "string") return null;
3783
+ fragments.push(fragment.tx);
3784
+ complete = complete && Object.keys(fragment).every((key) => key === "tx");
3785
+ }
3786
+ const content = fragments.join("");
3787
+ return /[^\p{White_Space}]/u.test(content) ? {
3788
+ start: value.t,
3789
+ text: content,
3790
+ complete,
3791
+ credit: readCredit(fragments)
3792
+ } : null;
3793
+ } catch {
3794
+ return null;
3795
+ }
3796
+ };
3797
+ var appendWords = (words, text, time) => {
3798
+ for (const fragment of text.split(/(\p{White_Space}+)/u)) if (fragment) words.push(SEPARATORS.test(fragment) ? makeWordSpace({ text: fragment }) : makeWordNormal({
3799
+ text: fragment,
3800
+ time
3801
+ }));
3802
+ };
3803
+ var readLine = (body, start, duration) => {
3804
+ const time = makeTimeRange({
3805
+ start,
3806
+ end: start + duration
3807
+ });
3808
+ const pieces = scanWordTags(body, WORD_PATTERN);
3809
+ if (!pieces.some((piece) => piece.tag !== void 0)) return /[^\p{White_Space}]/u.test(body) ? makeLineNormal({
3810
+ time,
3811
+ content: makeLineContentText(body)
3812
+ }) : makeLineInstrumental({ time });
3813
+ const words = [];
3814
+ for (const piece of pieces) {
3815
+ if (piece.tag === void 0) {
3816
+ appendWords(words, piece.text);
3817
+ continue;
3818
+ }
3819
+ const [wordStart, wordDuration] = piece.tag.split(",").map(Number);
3820
+ appendWords(words, piece.text, makeTimeRange({
3821
+ start: wordStart,
3822
+ end: wordStart + wordDuration
3823
+ }));
3824
+ }
3825
+ return words.some((word) => word.type === WordType.NORMAL) ? makeLineNormal({
3826
+ time,
3827
+ content: makeLineContentWords(words)
3828
+ }) : makeLineInstrumental({ time });
3829
+ };
3830
+ var readTrack = (text, timings) => {
3831
+ const tags = [];
3832
+ const native = [];
3833
+ const fallback = [];
3834
+ const displays = [];
3835
+ const rows = /^\s*</.test(text) ? [] : text.replace(/^\uFEFF/, "").split(/\r\n?|\n/);
3836
+ for (const [row, raw] of rows.entries()) {
3837
+ const line = raw.trimStart();
3838
+ const header = LINE_PATTERN.exec(line);
3839
+ if (header) {
3840
+ native.push({
3841
+ row,
3842
+ line: readLine(line.slice(header[0].length), Number(header[1]), Number(header[2]))
3843
+ });
3844
+ fallback.push("");
3845
+ } else if (line.startsWith("{")) {
3846
+ const display = readJsonLine(line);
3847
+ if (display) {
3848
+ displays.push({
3849
+ row,
3850
+ display: true,
3851
+ credit: !!display.credit,
3852
+ line: makeLineNormal({
3853
+ time: makeTimeRange({ start: display.start }),
3854
+ content: makeLineContentText(display.text)
3855
+ })
3856
+ });
3857
+ if (display.credit) {
3858
+ const { raw, names } = display.credit;
3859
+ for (const value of names) tags.push({
3860
+ row,
3861
+ unknown: true,
3862
+ tag: {
3863
+ key: raw,
3864
+ value
3865
+ }
3866
+ });
3867
+ }
3868
+ }
3869
+ if (!display?.complete) tags.push({
3870
+ row,
3871
+ unknown: true,
3872
+ tag: {
3873
+ key: "yrc:json",
3874
+ value: raw
3875
+ }
3876
+ });
3877
+ fallback.push(display ? `[${display.start}ms]` : "");
3878
+ } else fallback.push(raw);
3879
+ }
3880
+ const clock = [];
3881
+ const displayRows = new Set(displays.map((entry) => entry.row));
3882
+ readLrc(fallback.join("\n"), {
3883
+ onLine: (line, timing, row) => {
3884
+ if (displayRows.has(row)) return;
3885
+ clock.push({
3886
+ row,
3887
+ line
3888
+ });
3889
+ timings?.set(line, timing);
3890
+ },
3891
+ onMeta: (tag, row) => {
3892
+ tags.push({
3893
+ row,
3894
+ tag
3895
+ });
3896
+ }
3897
+ });
3898
+ const entries = [...displays, ...native.length ? native : clock].sort((first, second) => first.row - second.row);
3899
+ for (const [index, entry] of entries.entries()) {
3900
+ if (!entry.display) continue;
3901
+ const next = entries[index + 1]?.line.time?.start;
3902
+ if (next !== void 0 && next >= entry.line.time.start) entry.line.time.end = next;
3903
+ }
3904
+ const metaTags = tags.sort((first, second) => first.row - second.row);
3905
+ return {
3906
+ entries: entries.filter((entry) => !entry.credit),
3907
+ metaTags
3908
+ };
3909
+ };
3910
+ var readAnnotations = (text, timings) => {
3911
+ return text ? readTrack(text, timings).entries.filter((entry) => !entry.display).map((entry) => entry.line) : [];
3912
+ };
3913
+ /**
3914
+ * Parses native YRC or LRC fallback, keeping JSON display rows separate from annotation matching.
3915
+ * Explicit line and word durations remain unchanged, including zero durations and source timing violations.
3916
+ * JSON credit content becomes separate plain-text unknown entries sharing the source label and is omitted from lyric lines.
3917
+ *
3918
+ * @param input Plaintext tracks on the same timeline: yrc with ytlrc/yromalrc, or lrc with tlyric/romalrc.
3919
+ * @param options Independent line and word tolerances, with optional local roman recovery.
3920
+ *
3921
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
3922
+ */
3923
+ var parseYrc = (input, options = {}) => {
3924
+ const source = readYrcContent(input);
3925
+ if (!source) return finalizeLyric(makeLyric({
3926
+ format: "yrc",
3927
+ status: LyricStatus.INVALID
3928
+ }));
3929
+ const parsed = readTrack(source.original);
3930
+ const meta = readLrcMetaTags(parsed.metaTags.filter((entry) => !entry.unknown).map((entry) => entry.tag));
3931
+ let unknownIndex = 0;
3932
+ meta.unknowns = parsed.metaTags.flatMap(({ tag, unknown }) => {
3933
+ if (unknown) return [makeUnknownEntry(tag)];
3934
+ const entry = meta.unknowns[unknownIndex];
3935
+ if (entry?.key === tag.key && entry.value === tag.value) {
3936
+ unknownIndex += 1;
3937
+ return [entry];
3938
+ }
3939
+ return [];
3940
+ });
3941
+ const body = parsed.entries.filter((entry) => !entry.display).map((entry) => entry.line);
3942
+ const timings = /* @__PURE__ */ new WeakMap();
3943
+ const annotated = attachAnnotations(body, {
3944
+ translations: readAnnotations(source.translations, timings),
3945
+ romans: readAnnotations(source.romans, timings)
3946
+ }, {
3947
+ preservePlaceholders: true,
3948
+ tolerance: options.annotationTolerance,
3949
+ wordTolerance: options.wordAnnotationTolerance,
3950
+ wordFallback: options.wordAnnotationFallback,
3951
+ getTiming: (line) => timings.get(line)
3952
+ });
3953
+ let index = 0;
3954
+ const lines = parsed.entries.map((entry) => {
3955
+ return entry.display ? entry.line : annotated[index++];
3956
+ });
3957
+ return finalizeLyric(makeLyric({
3958
+ format: "yrc",
3959
+ status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
3960
+ timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
3961
+ meta,
3962
+ lines,
3963
+ languages: deriveLanguageUsages(lines)
3964
+ }));
3965
+ };
3966
+ //#endregion
3967
+ //#region src/formats/yrc/detect.ts
3968
+ /**
3969
+ * Recognizes native word tags or a JSON display line accompanied by LRC content in the original track.
3970
+ *
3971
+ * @returns Whether the original plaintext carries a YRC signal stronger than its shared QRC line header.
3972
+ */
3973
+ var detectYrc = (input) => {
3974
+ const source = readYrcContent(input);
3975
+ if (!source) return false;
3976
+ let display = false;
3977
+ const fallback = [];
3978
+ for (const raw of source.original.split(/\r\n?|\n/)) {
3979
+ const line = raw.trimStart();
3980
+ const header = LINE_PATTERN.exec(line);
3981
+ if (header && line.slice(header[0].length).match(WORD_PATTERN)) return true;
3982
+ if (line.startsWith("{")) display = display || !!readJsonLine(line);
3983
+ else fallback.push(raw);
3984
+ }
3985
+ return display && detectLrc(fallback.join("\n"));
3986
+ };
3987
+ //#endregion
2297
3988
  //#region src/formats/index.ts
2298
3989
  /**
2299
3990
  * Recognition reads only the original text; the annotation tracks never decide the format.
@@ -2301,6 +3992,10 @@ var parseTtml = (input, options = {}) => {
2301
3992
  * Unrecognized text returns `undefined`.
2302
3993
  */
2303
3994
  var detectFormat = (input) => {
3995
+ if (detectKrc(input)) return "krc";
3996
+ if (detectYrc(input)) return "yrc";
3997
+ if (detectQrc(input)) return "qrc";
3998
+ if (detectKwrc(input)) return "kwrc";
2304
3999
  if (detectLrc(input)) return "lrc";
2305
4000
  if (detectTtml(input)) return "ttml";
2306
4001
  };
@@ -2312,8 +4007,12 @@ var parse = (input, options = {}) => {
2312
4007
  const format = detectFormat(input);
2313
4008
  if (!format) return null;
2314
4009
  switch (format) {
4010
+ case "krc": return parseKrc(input);
4011
+ case "kwrc": return parseKwrc(input);
2315
4012
  case "lrc": return parseLrc(input, options);
4013
+ case "qrc": return parseQrc(input, options);
2316
4014
  case "ttml": return parseTtml(input, options);
4015
+ case "yrc": return parseYrc(input, options);
2317
4016
  }
2318
4017
  };
2319
4018
  var GENERATORS = {
@@ -2323,7 +4022,7 @@ var GENERATORS = {
2323
4022
  /**
2324
4023
  * Generates text in the requested format, which need not match `lyric.format`.
2325
4024
  *
2326
- * The writer map pairs each format's options with its result and requires a writer for every supported format.
4025
+ * The writer map pairs options with results only for formats that have an implemented writer.
2327
4026
  * An unknown target returns `null`.
2328
4027
  */
2329
4028
  var generate = (lyric, target, options = {}) => {
@@ -2331,6 +4030,6 @@ var generate = (lyric, target, options = {}) => {
2331
4030
  return GENERATORS[target](lyric, options);
2332
4031
  };
2333
4032
  //#endregion
2334
- export { detectFormat, detectLrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseTtml };
4033
+ export { META_PATTERN, TAG_PATTERN, attachRubies, detectFormat, detectKrc, detectKwrc, detectLrc, detectQrc, detectTtml, detectYrc, generate, generateLrc, generateTtml, isPlaceholderText, parse, parseKrc, parseKwrc, parseLrc, parseQrc, parseTtml, parseWordTime, parseYrc, readJsonLine, readKrc, readLrc, readLrcMetaTags, readRubyEntries, scanWordTags, splitWordTags };
2335
4034
 
2336
4035
  //# sourceMappingURL=formats.js.map