@gondola-lyrics/kit 0.5.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.
@@ -67,7 +67,7 @@ export declare type LrcGenerateResult = LrcContent;
67
67
  /**
68
68
  * Values match the lowercase format names stored in `Lyric.format`.
69
69
  */
70
- export declare type LyricFormat = 'lrc' | 'qrc' | 'ttml';
70
+ export declare type LyricFormat = 'lrc' | 'qrc' | 'krc' | 'kwrc' | 'yrc' | 'ttml';
71
71
 
72
72
  export declare interface TtmlGenerateOptions {
73
73
  /**
package/dist/index.d.ts CHANGED
@@ -92,6 +92,7 @@ export declare interface ExtractBackgroundLinesOptions {
92
92
  /**
93
93
  * Preserves source labels and name spelling while matching normalized credit labels in normal lead lines.
94
94
  * Merges names by role and original label without inferring their language.
95
+ * Unknown metadata is processed only when matchUnknowns is enabled; consumed entries leave the unknown list.
95
96
  *
96
97
  * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
97
98
  */
@@ -99,8 +100,15 @@ export declare const extractCredits: (lyric: runtime.Lyric, options?: ExtractCre
99
100
 
100
101
  export declare interface ExtractCreditsOptions {
101
102
  /**
102
- * Removes extracted source lines unless they carry background vocals, line annotations, or word translations or romanizations.
103
- * Ruby annotations alone do not prevent removal.
103
+ * Matches existing unknown metadata keys as credit labels and consumes fully readable matches.
104
+ * Values use nameSeparators; unrecognized keys and values with no readable names remain unchanged.
105
+ *
106
+ * @default false
107
+ */
108
+ matchUnknowns?: boolean;
109
+ /**
110
+ * Removes successfully extracted source lines together with their backgrounds and annotations.
111
+ * Extracted roles and names remain in metadata; attached content is not moved to other lines.
104
112
  *
105
113
  * Refreshes language usages and falls back to line timing when no word-level content remains.
106
114
  *
@@ -255,6 +263,16 @@ export declare const InsertSpacesRules: {
255
263
  readonly TRIM_INSIDE_SYMBOLS: "trimInsideSymbols";
256
264
  };
257
265
 
266
+ declare interface KrcContent {
267
+ original: string;
268
+ }
269
+
270
+ export declare type KrcParseInput = KrcContent;
271
+
272
+ export declare interface KwrcParseInput {
273
+ original: string;
274
+ }
275
+
258
276
  declare interface LrcContent {
259
277
  original: string;
260
278
  translations?: string;
@@ -310,7 +328,7 @@ export declare interface LrcParseOptions {
310
328
  /**
311
329
  * Values match the lowercase format names stored in `Lyric.format`.
312
330
  */
313
- export declare type LyricFormat = 'lrc' | 'qrc' | 'ttml';
331
+ export declare type LyricFormat = 'lrc' | 'qrc' | 'krc' | 'kwrc' | 'yrc' | 'ttml';
314
332
 
315
333
  /**
316
334
  * Categories the bundled filter rules identify, in rule order.
@@ -321,7 +339,33 @@ export declare const NON_LYRIC_CATEGORIES: readonly string[];
321
339
  * Unrecognized text returns `null`; a recognized format returns its canonical lyric and diagnostics.
322
340
  * Annotation tracks travel with the source and are attached by the format's parser.
323
341
  */
324
- export declare const parse: (input: unknown, options?: LrcParseOptions & QrcParseOptions & TtmlParseOptions) => ParseResult | null;
342
+ export declare const parse: (input: unknown, options?: LrcParseOptions & QrcParseOptions & TtmlParseOptions & YrcParseOptions) => ParseResult | null;
343
+
344
+ /**
345
+ * Parses decrypted Kugou lyrics, retaining explicit durations and attaching embedded annotations by source slot.
346
+ *
347
+ * A source carrying no KRC row is read as LRC, so a platform fallback keeps its container annotations.
348
+ * Placeholder slots carry no annotation, so they never keep a credit line in the stream.
349
+ * Kana supplies whole-word ruby; type 0 language tracks retain complete line romanization.
350
+ * Incomplete metadata remains available in unknowns, and source timing violations remain visible in diagnostics.
351
+ *
352
+ * @param input Decrypted plaintext or an object containing its original text.
353
+ *
354
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
355
+ */
356
+ export declare const parseKrc: (input: string | KrcParseInput) => ParseResult;
357
+
358
+ /**
359
+ * Parses decoded KWRC with explicit word durations and structurally verified embedded translations.
360
+ *
361
+ * Missing or conflicting parameters and unsafe word calculations retain the complete row as line-level text.
362
+ * Plain LRC remains readable; offset is stored as metadata without changing source timestamps.
363
+ *
364
+ * @param input Decoded plaintext or an object containing its original text.
365
+ *
366
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
367
+ */
368
+ export declare const parseKwrc: (input: string | KwrcParseInput) => ParseResult;
325
369
 
326
370
  /**
327
371
  * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
@@ -360,6 +404,18 @@ export declare interface ParseResult {
360
404
  */
361
405
  export declare const parseTtml: (input: TtmlParseInput, options?: TtmlParseOptions) => ParseResult;
362
406
 
407
+ /**
408
+ * Parses native YRC or LRC fallback, keeping JSON display rows separate from annotation matching.
409
+ * Explicit line and word durations remain unchanged, including zero durations and source timing violations.
410
+ * JSON credit content becomes separate plain-text unknown entries sharing the source label and is omitted from lyric lines.
411
+ *
412
+ * @param input Plaintext tracks on the same timeline: yrc with ytlrc/yromalrc, or lrc with tlyric/romalrc.
413
+ * @param options Independent line and word tolerances, with optional local roman recovery.
414
+ *
415
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
416
+ */
417
+ export declare const parseYrc: (input: string | YrcParseInput, options?: YrcParseOptions) => ParseResult;
418
+
363
419
  declare interface QrcContent {
364
420
  original: string;
365
421
  translations?: string;
@@ -457,4 +513,36 @@ export declare interface TtmlParseOptions {
457
513
 
458
514
  export declare type TtmlProfile = 'amll' | 'itunes';
459
515
 
516
+ declare interface YrcContent {
517
+ original: string;
518
+ translations?: string;
519
+ romans?: string;
520
+ }
521
+
522
+ export declare type YrcParseInput = YrcContent;
523
+
524
+ export declare interface YrcParseOptions {
525
+ /**
526
+ * Extra timestamp drift allowed after exact and source-precision matching, in milliseconds.
527
+ * Tracks must share the original timeline; equal row counts never enable order matching.
528
+ *
529
+ * @default 0
530
+ */
531
+ annotationTolerance?: number;
532
+ /**
533
+ * Maximum roman token drift outside a matched word, in milliseconds; matched boundaries are adjusted to that word.
534
+ * Original word timing is preserved, and incomplete matches retain the whole line romanization.
535
+ *
536
+ * @default 0
537
+ */
538
+ wordAnnotationTolerance?: number;
539
+ /**
540
+ * Recovers roman fragments from unique two-kana combination starts or equal-sized gaps between timing anchors.
541
+ * Recovered token times are clipped to their original word; untimed fragments inherit its range.
542
+ *
543
+ * @default false
544
+ */
545
+ wordAnnotationFallback?: boolean;
546
+ }
547
+
460
548
  export { }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { detectFormat, generate, generateLrc, generateTtml, parse, parseLrc, parseQrc, parseTtml } from "./formats.js";
1
+ import { detectFormat, generate, generateLrc, generateTtml, parse, parseKrc, parseKwrc, parseLrc, parseQrc, parseTtml, parseYrc } from "./formats.js";
2
2
  import { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, extractAgents, extractBackgroundLines, extractCredits, insertInstrumentalLines, insertSpacesInLines, removeNonLyricLines, stripBackgroundBrackets } from "./transforms.js";
3
3
  import "./parser.js";
4
4
  import "./generator.js";
5
- export { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, detectFormat, extractAgents, extractBackgroundLines, extractCredits, generate, generateLrc, generateTtml, insertInstrumentalLines, insertSpacesInLines, parse, parseLrc, parseQrc, parseTtml, removeNonLyricLines, stripBackgroundBrackets };
5
+ export { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, detectFormat, extractAgents, extractBackgroundLines, extractCredits, generate, generateLrc, generateTtml, insertInstrumentalLines, insertSpacesInLines, parse, parseKrc, parseKwrc, parseLrc, parseQrc, parseTtml, parseYrc, removeNonLyricLines, stripBackgroundBrackets };
package/dist/parser.d.ts CHANGED
@@ -92,6 +92,7 @@ export declare interface ExtractBackgroundLinesOptions {
92
92
  /**
93
93
  * Preserves source labels and name spelling while matching normalized credit labels in normal lead lines.
94
94
  * Merges names by role and original label without inferring their language.
95
+ * Unknown metadata is processed only when matchUnknowns is enabled; consumed entries leave the unknown list.
95
96
  *
96
97
  * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
97
98
  */
@@ -99,8 +100,15 @@ export declare const extractCredits: (lyric: runtime.Lyric, options?: ExtractCre
99
100
 
100
101
  export declare interface ExtractCreditsOptions {
101
102
  /**
102
- * Removes extracted source lines unless they carry background vocals, line annotations, or word translations or romanizations.
103
- * Ruby annotations alone do not prevent removal.
103
+ * Matches existing unknown metadata keys as credit labels and consumes fully readable matches.
104
+ * Values use nameSeparators; unrecognized keys and values with no readable names remain unchanged.
105
+ *
106
+ * @default false
107
+ */
108
+ matchUnknowns?: boolean;
109
+ /**
110
+ * Removes successfully extracted source lines together with their backgrounds and annotations.
111
+ * Extracted roles and names remain in metadata; attached content is not moved to other lines.
104
112
  *
105
113
  * Refreshes language usages and falls back to line timing when no word-level content remains.
106
114
  *
@@ -218,6 +226,16 @@ export declare const InsertSpacesRules: {
218
226
  readonly TRIM_INSIDE_SYMBOLS: "trimInsideSymbols";
219
227
  };
220
228
 
229
+ declare interface KrcContent {
230
+ original: string;
231
+ }
232
+
233
+ export declare type KrcParseInput = KrcContent;
234
+
235
+ export declare interface KwrcParseInput {
236
+ original: string;
237
+ }
238
+
221
239
  declare interface LrcContent {
222
240
  original: string;
223
241
  translations?: string;
@@ -252,7 +270,7 @@ export declare interface LrcParseOptions {
252
270
  /**
253
271
  * Values match the lowercase format names stored in `Lyric.format`.
254
272
  */
255
- export declare type LyricFormat = 'lrc' | 'qrc' | 'ttml';
273
+ export declare type LyricFormat = 'lrc' | 'qrc' | 'krc' | 'kwrc' | 'yrc' | 'ttml';
256
274
 
257
275
  /**
258
276
  * Categories the bundled filter rules identify, in rule order.
@@ -263,7 +281,33 @@ export declare const NON_LYRIC_CATEGORIES: readonly string[];
263
281
  * Unrecognized text returns `null`; a recognized format returns its canonical lyric and diagnostics.
264
282
  * Annotation tracks travel with the source and are attached by the format's parser.
265
283
  */
266
- export declare const parse: (input: unknown, options?: LrcParseOptions & QrcParseOptions & TtmlParseOptions) => ParseResult | null;
284
+ export declare const parse: (input: unknown, options?: LrcParseOptions & QrcParseOptions & TtmlParseOptions & YrcParseOptions) => ParseResult | null;
285
+
286
+ /**
287
+ * Parses decrypted Kugou lyrics, retaining explicit durations and attaching embedded annotations by source slot.
288
+ *
289
+ * A source carrying no KRC row is read as LRC, so a platform fallback keeps its container annotations.
290
+ * Placeholder slots carry no annotation, so they never keep a credit line in the stream.
291
+ * Kana supplies whole-word ruby; type 0 language tracks retain complete line romanization.
292
+ * Incomplete metadata remains available in unknowns, and source timing violations remain visible in diagnostics.
293
+ *
294
+ * @param input Decrypted plaintext or an object containing its original text.
295
+ *
296
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
297
+ */
298
+ export declare const parseKrc: (input: string | KrcParseInput) => ParseResult;
299
+
300
+ /**
301
+ * Parses decoded KWRC with explicit word durations and structurally verified embedded translations.
302
+ *
303
+ * Missing or conflicting parameters and unsafe word calculations retain the complete row as line-level text.
304
+ * Plain LRC remains readable; offset is stored as metadata without changing source timestamps.
305
+ *
306
+ * @param input Decoded plaintext or an object containing its original text.
307
+ *
308
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
309
+ */
310
+ export declare const parseKwrc: (input: string | KwrcParseInput) => ParseResult;
267
311
 
268
312
  /**
269
313
  * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
@@ -302,6 +346,18 @@ export declare interface ParseResult {
302
346
  */
303
347
  export declare const parseTtml: (input: TtmlParseInput, options?: TtmlParseOptions) => ParseResult;
304
348
 
349
+ /**
350
+ * Parses native YRC or LRC fallback, keeping JSON display rows separate from annotation matching.
351
+ * Explicit line and word durations remain unchanged, including zero durations and source timing violations.
352
+ * JSON credit content becomes separate plain-text unknown entries sharing the source label and is omitted from lyric lines.
353
+ *
354
+ * @param input Plaintext tracks on the same timeline: yrc with ytlrc/yromalrc, or lrc with tlyric/romalrc.
355
+ * @param options Independent line and word tolerances, with optional local roman recovery.
356
+ *
357
+ * @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
358
+ */
359
+ export declare const parseYrc: (input: string | YrcParseInput, options?: YrcParseOptions) => ParseResult;
360
+
305
361
  declare interface QrcContent {
306
362
  original: string;
307
363
  translations?: string;
@@ -380,4 +436,36 @@ export declare interface TtmlParseOptions {
380
436
 
381
437
  export declare type TtmlProfile = 'amll' | 'itunes';
382
438
 
439
+ declare interface YrcContent {
440
+ original: string;
441
+ translations?: string;
442
+ romans?: string;
443
+ }
444
+
445
+ export declare type YrcParseInput = YrcContent;
446
+
447
+ export declare interface YrcParseOptions {
448
+ /**
449
+ * Extra timestamp drift allowed after exact and source-precision matching, in milliseconds.
450
+ * Tracks must share the original timeline; equal row counts never enable order matching.
451
+ *
452
+ * @default 0
453
+ */
454
+ annotationTolerance?: number;
455
+ /**
456
+ * Maximum roman token drift outside a matched word, in milliseconds; matched boundaries are adjusted to that word.
457
+ * Original word timing is preserved, and incomplete matches retain the whole line romanization.
458
+ *
459
+ * @default 0
460
+ */
461
+ wordAnnotationTolerance?: number;
462
+ /**
463
+ * Recovers roman fragments from unique two-kana combination starts or equal-sized gaps between timing anchors.
464
+ * Recovered token times are clipped to their original word; untimed fragments inherit its range.
465
+ *
466
+ * @default false
467
+ */
468
+ wordAnnotationFallback?: boolean;
469
+ }
470
+
383
471
  export { }
package/dist/parser.js CHANGED
@@ -1,3 +1,3 @@
1
- import { detectFormat, parse, parseLrc, parseQrc, parseTtml } from "./formats.js";
1
+ import { detectFormat, parse, parseKrc, parseKwrc, parseLrc, parseQrc, parseTtml, parseYrc } from "./formats.js";
2
2
  import { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, extractAgents, extractBackgroundLines, extractCredits, insertInstrumentalLines, insertSpacesInLines, removeNonLyricLines, stripBackgroundBrackets } from "./transforms.js";
3
- export { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, detectFormat, extractAgents, extractBackgroundLines, extractCredits, insertInstrumentalLines, insertSpacesInLines, parse, parseLrc, parseQrc, parseTtml, removeNonLyricLines, stripBackgroundBrackets };
3
+ export { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, detectFormat, extractAgents, extractBackgroundLines, extractCredits, insertInstrumentalLines, insertSpacesInLines, parse, parseKrc, parseKwrc, parseLrc, parseQrc, parseTtml, parseYrc, removeNonLyricLines, stripBackgroundBrackets };
@@ -112,6 +112,55 @@ var lowerTag = (tag) => {
112
112
  return tag ? tag.toLowerCase() : void 0;
113
113
  };
114
114
  //#endregion
115
- export { mapShared as i, lowerTag as n, lowerBound as r, findScriptRunEnd as t };
115
+ //#region src/utils/rules.ts
116
+ var RULE_WHITESPACE = /[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/g;
117
+ var RULE_EDGE_WHITESPACE = /^[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+|[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+$/g;
118
+ /**
119
+ * Uses the shared whitespace set and ASCII case folding to keep matching consistent across language kits.
120
+ */
121
+ var toRuleMatchKey = (text) => {
122
+ return text.replace(RULE_WHITESPACE, "").replace(/[A-Z]+/g, (run) => run.toLowerCase());
123
+ };
124
+ var RULE_WHITESPACE_CHARACTER = new RegExp(RULE_WHITESPACE.source);
125
+ /**
126
+ * Finds the offsets in toRuleMatchKey(text) where the source allows a cut.
127
+ * A cut sits before the first kept character of a script run and after configured whitespace, so neutral punctuation never splits aliases such as A&R or Co-Producer.
128
+ *
129
+ * @param text A source label with its original spelling.
130
+ *
131
+ * @returns Ascending offsets from 0 to the key length, or undefined when the source has no inner cut.
132
+ */
133
+ var findRuleMatchCuts = (text) => {
134
+ if (!RULE_WHITESPACE_CHARACTER.test(text) && findScriptRunEnd(text) === text.length) return;
135
+ const cuts = [0];
136
+ let length = 0;
137
+ let pendingCut = false;
138
+ let runStart = 0;
139
+ while (runStart < text.length) {
140
+ const runEnd = findScriptRunEnd(text, runStart);
141
+ if (runStart > 0) pendingCut = true;
142
+ for (const character of text.slice(runStart, runEnd)) {
143
+ if (RULE_WHITESPACE_CHARACTER.test(character)) {
144
+ pendingCut = true;
145
+ continue;
146
+ }
147
+ if (pendingCut) {
148
+ if (length > 0) cuts.push(length);
149
+ pendingCut = false;
150
+ }
151
+ length += character.length;
152
+ }
153
+ runStart = runEnd;
154
+ }
155
+ if (cuts.length === 1) return;
156
+ cuts.push(length);
157
+ return cuts;
158
+ };
159
+ /**
160
+ * Preserves internal whitespace so source labels and credited names retain their spelling.
161
+ */
162
+ var trimRuleWhitespace = (text) => text.replace(RULE_EDGE_WHITESPACE, "");
163
+ //#endregion
164
+ export { lowerBound as a, lowerTag as i, toRuleMatchKey as n, mapShared as o, trimRuleWhitespace as r, findRuleMatchCuts as t };
116
165
 
117
- //# sourceMappingURL=language.js.map
166
+ //# sourceMappingURL=rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules.js","names":[],"sources":["../src/utils/array.ts","../src/utils/language.ts","../src/utils/rules.ts"],"sourcesContent":["/**\n * Preserves the input array when every element keeps its identity, so unchanged parent nodes can also be reused.\n *\n * The callback must leave inputs untouched and return the original element when unchanged.\n */\nexport const mapShared = <T>(items: T[], transform: (item: T) => T): T[] => {\n let result = items\n for (const [index, item] of items.entries()) {\n const transformed = transform(item)\n if (transformed !== item) {\n if (result === items) {\n result = [...items]\n }\n result[index] = transformed\n }\n }\n return result\n}\n\n/**\n * Finds the first value meeting a lower bound so range queries can skip earlier candidates.\n *\n * @param values Values sorted in ascending order.\n * @param target Inclusive lower bound.\n *\n * @returns The first matching index, or the array length when every value is smaller.\n */\nexport const lowerBound = (values: readonly number[], target: number): number => {\n let left = 0\n let right = values.length\n while (left < right) {\n const middle = Math.floor((left + right) / 2)\n if (values[middle]! < target) {\n left = middle + 1\n } else {\n right = middle\n }\n }\n return left\n}\n","const enum ScriptGroup {\n NEUTRAL,\n EAST_ASIAN,\n LATIN,\n HANGUL,\n CYRILLIC,\n GREEK,\n ARABIC,\n HEBREW,\n DEVANAGARI,\n THAI,\n}\n\nconst SCRIPT_PATTERNS: readonly (readonly [RegExp, ScriptGroup])[] = [\n [/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}]/u, ScriptGroup.EAST_ASIAN],\n [/\\p{Script=Latin}/u, ScriptGroup.LATIN],\n [/\\p{Script=Hangul}/u, ScriptGroup.HANGUL],\n [/\\p{Script=Cyrillic}/u, ScriptGroup.CYRILLIC],\n [/\\p{Script=Greek}/u, ScriptGroup.GREEK],\n [/\\p{Script=Arabic}/u, ScriptGroup.ARABIC],\n [/\\p{Script=Hebrew}/u, ScriptGroup.HEBREW],\n [/\\p{Script=Devanagari}/u, ScriptGroup.DEVANAGARI],\n [/\\p{Script=Thai}/u, ScriptGroup.THAI],\n]\n\nconst getOtherScript = (text: string, start: number, width: number): ScriptGroup => {\n const unit = text.charCodeAt(start)\n if (unit === 0x30fb || unit === 0x30fc) {\n return ScriptGroup.NEUTRAL\n }\n if ((unit >= 0x0400 && unit <= 0x0481) || (unit >= 0x048a && unit <= 0x052f)) {\n return ScriptGroup.CYRILLIC\n }\n if (unit >= 0x03a3 && unit <= 0x03e1) {\n return ScriptGroup.GREEK\n }\n if ((unit >= 0x0620 && unit <= 0x063f) || (unit >= 0x0641 && unit <= 0x064a)) {\n return ScriptGroup.ARABIC\n }\n if (unit >= 0x05d0 && unit <= 0x05ea) {\n return ScriptGroup.HEBREW\n }\n if ((unit >= 0x0900 && unit <= 0x0950) || (unit >= 0x0955 && unit <= 0x0963) || (unit >= 0x0966 && unit <= 0x097f)) {\n return ScriptGroup.DEVANAGARI\n }\n if ((unit >= 0x0e01 && unit <= 0x0e3a) || (unit >= 0x0e40 && unit <= 0x0e5b)) {\n return ScriptGroup.THAI\n }\n\n const character = text.slice(start, start + width)\n for (const [pattern, script] of SCRIPT_PATTERNS) {\n if (pattern.test(character)) {\n return script\n }\n }\n return ScriptGroup.NEUTRAL\n}\n\n/**\n * Finds a boundary between supported scripts without allocating a segment list.\n * Han and kana share a group so Japanese labels stay intact; Hangul, Latin, Cyrillic, Greek, Arabic, Hebrew, Devanagari and Thai remain separate.\n * Punctuation, digits, inherited marks and unsupported scripts stay in the surrounding run.\n *\n * @param start A UTF-16 offset at a code-point boundary, usually the end returned by the previous call.\n *\n * @returns The exclusive UTF-16 end of the run, or text.length when no later boundary exists.\n */\nexport const findScriptRunEnd = (text: string, start = 0): number => {\n let script = ScriptGroup.NEUTRAL\n let index = start\n\n while (index < text.length) {\n const unit = text.charCodeAt(index)\n let nextScript = ScriptGroup.NEUTRAL\n let width = 1\n\n if (unit <= 0x7f) {\n const letter = unit | 0x20\n if (letter >= 0x61 && letter <= 0x7a) {\n if (script !== ScriptGroup.NEUTRAL && script !== ScriptGroup.LATIN) {\n return index\n }\n script = ScriptGroup.LATIN\n do {\n index += 1\n const nextLetter = text.charCodeAt(index) | 0x20\n if (nextLetter < 0x61 || nextLetter > 0x7a) {\n break\n }\n } while (index < text.length)\n continue\n }\n } else if (unit >= 0x4e00 && unit <= 0x9fff) {\n nextScript = ScriptGroup.EAST_ASIAN\n } else if ((unit >= 0x3041 && unit <= 0x3096) || (unit >= 0x30a1 && unit <= 0x30fa)) {\n nextScript = ScriptGroup.EAST_ASIAN\n } else if (unit >= 0xac00 && unit <= 0xd7a3) {\n nextScript = ScriptGroup.HANGUL\n } else {\n width = unit >= 0xd800 && unit <= 0xdbff && text.codePointAt(index)! > 0xffff ? 2 : 1\n nextScript = getOtherScript(text, index, width)\n }\n\n if (nextScript !== ScriptGroup.NEUTRAL) {\n if (script !== ScriptGroup.NEUTRAL && script !== nextScript) {\n return index\n }\n script = nextScript\n }\n index += width\n }\n return text.length\n}\n\n/**\n * Returns a language tag in the canonical form the model stores, which is lower case.\n *\n * This mirrors the model's own helper, which it does not export.\n * Comparing tags stays with the consumer, since BCP 47 matches by subtag prefix rather than by string equality.\n */\nexport const lowerTag = (tag: string | undefined): string | undefined => {\n return tag ? tag.toLowerCase() : undefined\n}\n","import { findScriptRunEnd } from './language'\n\nconst RULE_WHITESPACE = /[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]/g\nconst RULE_EDGE_WHITESPACE =\n /^[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+|[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+$/g\n\n/**\n * Uses the shared whitespace set and ASCII case folding to keep matching consistent across language kits.\n */\nexport const toRuleMatchKey = (text: string): string => {\n return text.replace(RULE_WHITESPACE, '').replace(/[A-Z]+/g, (run) => run.toLowerCase())\n}\n\nconst RULE_WHITESPACE_CHARACTER = new RegExp(RULE_WHITESPACE.source)\n\n/**\n * Finds the offsets in toRuleMatchKey(text) where the source allows a cut.\n * A cut sits before the first kept character of a script run and after configured whitespace, so neutral punctuation never splits aliases such as A&R or Co-Producer.\n *\n * @param text A source label with its original spelling.\n *\n * @returns Ascending offsets from 0 to the key length, or undefined when the source has no inner cut.\n */\nexport const findRuleMatchCuts = (text: string): readonly number[] | undefined => {\n // Most labels are a single script run without inner whitespace, so they skip the character walk.\n if (!RULE_WHITESPACE_CHARACTER.test(text) && findScriptRunEnd(text) === text.length) {\n return undefined\n }\n\n const cuts: number[] = [0]\n let length = 0\n let pendingCut = false\n let runStart = 0\n while (runStart < text.length) {\n const runEnd = findScriptRunEnd(text, runStart)\n if (runStart > 0) {\n pendingCut = true\n }\n for (const character of text.slice(runStart, runEnd)) {\n if (RULE_WHITESPACE_CHARACTER.test(character)) {\n pendingCut = true\n continue\n }\n if (pendingCut) {\n if (length > 0) {\n cuts.push(length)\n }\n pendingCut = false\n }\n length += character.length\n }\n runStart = runEnd\n }\n if (cuts.length === 1) {\n return undefined\n }\n cuts.push(length)\n return cuts\n}\n\n/**\n * Preserves internal whitespace so source labels and credited names retain their spelling.\n */\nexport const trimRuleWhitespace = (text: string): string => text.replace(RULE_EDGE_WHITESPACE, '')\n"],"mappings":";;;;;;AAKA,IAAa,aAAgB,OAAY,cAAmC;CAC1E,IAAI,SAAS;CACb,KAAK,MAAM,CAAC,OAAO,SAAS,MAAM,QAAQ,GAAG;EAC3C,MAAM,cAAc,UAAU,IAAI;EAClC,IAAI,gBAAgB,MAAM;GACxB,IAAI,WAAW,OACb,SAAS,CAAC,GAAG,KAAK;GAEpB,OAAO,SAAS;EAClB;CACF;CACA,OAAO;AACT;;;;;;;;;AAUA,IAAa,cAAc,QAA2B,WAA2B;CAC/E,IAAI,OAAO;CACX,IAAI,QAAQ,OAAO;CACnB,OAAO,OAAO,OAAO;EACnB,MAAM,SAAS,KAAK,OAAO,OAAO,SAAS,CAAC;EAC5C,IAAI,OAAO,UAAW,QACpB,OAAO,SAAS;OAEhB,QAAQ;CAEZ;CACA,OAAO;AACT;;;AC1BA,IAAM,kBAA+D;CACnE,CAAC,2DAAA,CAAiF;CAClF,CAAC,qBAAA,CAAsC;CACvC,CAAC,sBAAA,CAAwC;CACzC,CAAC,wBAAA,CAA4C;CAC7C,CAAC,qBAAA,CAAsC;CACvC,CAAC,sBAAA,CAAwC;CACzC,CAAC,sBAAA,CAAwC;CACzC,CAAC,0BAAA,CAAgD;CACjD,CAAC,oBAAA,CAAoC;AACvC;AAEA,IAAM,kBAAkB,MAAc,OAAe,UAA+B;CAClF,MAAM,OAAO,KAAK,WAAW,KAAK;CAClC,IAAI,SAAS,SAAU,SAAS,OAC9B,OAAA;CAEF,IAAK,QAAQ,QAAU,QAAQ,QAAY,QAAQ,QAAU,QAAQ,MACnE,OAAA;CAEF,IAAI,QAAQ,OAAU,QAAQ,KAC5B,OAAA;CAEF,IAAK,QAAQ,QAAU,QAAQ,QAAY,QAAQ,QAAU,QAAQ,MACnE,OAAA;CAEF,IAAI,QAAQ,QAAU,QAAQ,MAC5B,OAAA;CAEF,IAAK,QAAQ,QAAU,QAAQ,QAAY,QAAQ,QAAU,QAAQ,QAAY,QAAQ,QAAU,QAAQ,MACzG,OAAA;CAEF,IAAK,QAAQ,QAAU,QAAQ,QAAY,QAAQ,QAAU,QAAQ,MACnE,OAAA;CAGF,MAAM,YAAY,KAAK,MAAM,OAAO,QAAQ,KAAK;CACjD,KAAK,MAAM,CAAC,SAAS,WAAW,iBAC9B,IAAI,QAAQ,KAAK,SAAS,GACxB,OAAO;CAGX,OAAA;AACF;;;;;;;;;;AAWA,IAAa,oBAAoB,MAAc,QAAQ,MAAc;CACnE,IAAI,SAAA;CACJ,IAAI,QAAQ;CAEZ,OAAO,QAAQ,KAAK,QAAQ;EAC1B,MAAM,OAAO,KAAK,WAAW,KAAK;EAClC,IAAI,aAAA;EACJ,IAAI,QAAQ;EAEZ,IAAI,QAAQ,KAAM;GAChB,MAAM,SAAS,OAAO;GACtB,IAAI,UAAU,MAAQ,UAAU,KAAM;IACpC,IAAI,WAAA,KAAkC,WAAA,GACpC,OAAO;IAET,SAAA;IACA,GAAG;KACD,SAAS;KACT,MAAM,aAAa,KAAK,WAAW,KAAK,IAAI;KAC5C,IAAI,aAAa,MAAQ,aAAa,KACpC;IAEJ,SAAS,QAAQ,KAAK;IACtB;GACF;EACF,OAAO,IAAI,QAAQ,SAAU,QAAQ,OACnC,aAAA;OACK,IAAK,QAAQ,SAAU,QAAQ,SAAY,QAAQ,SAAU,QAAQ,OAC1E,aAAA;OACK,IAAI,QAAQ,SAAU,QAAQ,OACnC,aAAA;OACK;GACL,QAAQ,QAAQ,SAAU,QAAQ,SAAU,KAAK,YAAY,KAAK,IAAK,QAAS,IAAI;GACpF,aAAa,eAAe,MAAM,OAAO,KAAK;EAChD;EAEA,IAAI,eAAA,GAAoC;GACtC,IAAI,WAAA,KAAkC,WAAW,YAC/C,OAAO;GAET,SAAS;EACX;EACA,SAAS;CACX;CACA,OAAO,KAAK;AACd;;;;;;;AAQA,IAAa,YAAY,QAAgD;CACvE,OAAO,MAAM,IAAI,YAAY,IAAI,KAAA;AACnC;;;ACxHA,IAAM,kBAAkB;AACxB,IAAM,uBACJ;;;;AAKF,IAAa,kBAAkB,SAAyB;CACtD,OAAO,KAAK,QAAQ,iBAAiB,EAAE,CAAC,CAAC,QAAQ,YAAY,QAAQ,IAAI,YAAY,CAAC;AACxF;AAEA,IAAM,4BAA4B,IAAI,OAAO,gBAAgB,MAAM;;;;;;;;;AAUnE,IAAa,qBAAqB,SAAgD;CAEhF,IAAI,CAAC,0BAA0B,KAAK,IAAI,KAAK,iBAAiB,IAAI,MAAM,KAAK,QAC3E;CAGF,MAAM,OAAiB,CAAC,CAAC;CACzB,IAAI,SAAS;CACb,IAAI,aAAa;CACjB,IAAI,WAAW;CACf,OAAO,WAAW,KAAK,QAAQ;EAC7B,MAAM,SAAS,iBAAiB,MAAM,QAAQ;EAC9C,IAAI,WAAW,GACb,aAAa;EAEf,KAAK,MAAM,aAAa,KAAK,MAAM,UAAU,MAAM,GAAG;GACpD,IAAI,0BAA0B,KAAK,SAAS,GAAG;IAC7C,aAAa;IACb;GACF;GACA,IAAI,YAAY;IACd,IAAI,SAAS,GACX,KAAK,KAAK,MAAM;IAElB,aAAa;GACf;GACA,UAAU,UAAU;EACtB;EACA,WAAW;CACb;CACA,IAAI,KAAK,WAAW,GAClB;CAEF,KAAK,KAAK,MAAM;CAChB,OAAO;AACT;;;;AAKA,IAAa,sBAAsB,SAAyB,KAAK,QAAQ,sBAAsB,EAAE"}
@@ -84,6 +84,7 @@ export declare interface ExtractBackgroundLinesOptions {
84
84
  /**
85
85
  * Preserves source labels and name spelling while matching normalized credit labels in normal lead lines.
86
86
  * Merges names by role and original label without inferring their language.
87
+ * Unknown metadata is processed only when matchUnknowns is enabled; consumed entries leave the unknown list.
87
88
  *
88
89
  * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
89
90
  */
@@ -91,8 +92,15 @@ export declare const extractCredits: (lyric: runtime.Lyric, options?: ExtractCre
91
92
 
92
93
  export declare interface ExtractCreditsOptions {
93
94
  /**
94
- * Removes extracted source lines unless they carry background vocals, line annotations, or word translations or romanizations.
95
- * Ruby annotations alone do not prevent removal.
95
+ * Matches existing unknown metadata keys as credit labels and consumes fully readable matches.
96
+ * Values use nameSeparators; unrecognized keys and values with no readable names remain unchanged.
97
+ *
98
+ * @default false
99
+ */
100
+ matchUnknowns?: boolean;
101
+ /**
102
+ * Removes successfully extracted source lines together with their backgrounds and annotations.
103
+ * Extracted roles and names remain in metadata; attached content is not moved to other lines.
96
104
  *
97
105
  * Refreshes language usages and falls back to line timing when no word-level content remains.
98
106
  *
@@ -1,54 +1,5 @@
1
- import { i as mapShared, t as findScriptRunEnd } from "./language.js";
1
+ import { n as toRuleMatchKey, o as mapShared, r as trimRuleWhitespace, t as findRuleMatchCuts } from "./rules.js";
2
2
  import { AgentType, CreditRole, LineType, Timing, WordType, deriveLanguageUsages, getContentText, isWordLevelLine, makeAgent, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineBackground, makeLineContentText, makeLineContentWords, makeLineInstrumental, makeMeta, makeMetaCredit, makeText, makeTimeRange, makeWordEmphasis, makeWordSpace, validateTimeRange } from "@gondola-lyrics/model/runtime";
3
- //#region src/utils/rules.ts
4
- var RULE_WHITESPACE = /[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/g;
5
- var RULE_EDGE_WHITESPACE = /^[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+|[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+$/g;
6
- /**
7
- * Uses the shared whitespace set and ASCII case folding to keep matching consistent across language kits.
8
- */
9
- var toRuleMatchKey = (text) => {
10
- return text.replace(RULE_WHITESPACE, "").replace(/[A-Z]+/g, (run) => run.toLowerCase());
11
- };
12
- var RULE_WHITESPACE_CHARACTER = new RegExp(RULE_WHITESPACE.source);
13
- /**
14
- * Finds the offsets in toRuleMatchKey(text) where the source allows a cut.
15
- * A cut sits before the first kept character of a script run and after configured whitespace, so neutral punctuation never splits aliases such as A&R or Co-Producer.
16
- *
17
- * @param text A source label with its original spelling.
18
- *
19
- * @returns Ascending offsets from 0 to the key length, or undefined when the source has no inner cut.
20
- */
21
- var findRuleMatchCuts = (text) => {
22
- if (!RULE_WHITESPACE_CHARACTER.test(text) && findScriptRunEnd(text) === text.length) return;
23
- const cuts = [0];
24
- let length = 0;
25
- let pendingCut = false;
26
- let runStart = 0;
27
- while (runStart < text.length) {
28
- const runEnd = findScriptRunEnd(text, runStart);
29
- if (runStart > 0) pendingCut = true;
30
- for (const character of text.slice(runStart, runEnd)) {
31
- if (RULE_WHITESPACE_CHARACTER.test(character)) {
32
- pendingCut = true;
33
- continue;
34
- }
35
- if (pendingCut) {
36
- if (length > 0) cuts.push(length);
37
- pendingCut = false;
38
- }
39
- length += character.length;
40
- }
41
- runStart = runEnd;
42
- }
43
- if (cuts.length === 1) return;
44
- cuts.push(length);
45
- return cuts;
46
- };
47
- /**
48
- * Preserves internal whitespace so source labels and credited names retain their spelling.
49
- */
50
- var trimRuleWhitespace = (text) => text.replace(RULE_EDGE_WHITESPACE, "");
51
- //#endregion
52
3
  //#region src/transforms/agent/extract.ts
53
4
  var hashName = (name) => {
54
5
  let hash = 0;
@@ -2460,19 +2411,13 @@ var readCreditNames = (text, separators) => {
2460
2411
  }
2461
2412
  return [...unique];
2462
2413
  };
2463
- var canRemoveSourceLine = (line) => {
2464
- if (line.backgrounds.length || line.annotation) return false;
2465
- return !line.content?.words.some(({ annotation }) => {
2466
- if (!annotation) return false;
2467
- return !!(annotation.translations.length || annotation.romans.length);
2468
- });
2469
- };
2470
2414
  var hasCreditColon = (word) => {
2471
2415
  return word.text.includes(":") || word.text.includes(":");
2472
2416
  };
2473
2417
  /**
2474
2418
  * Preserves source labels and name spelling while matching normalized credit labels in normal lead lines.
2475
2419
  * Merges names by role and original label without inferring their language.
2420
+ * Unknown metadata is processed only when matchUnknowns is enabled; consumed entries leave the unknown list.
2476
2421
  *
2477
2422
  * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
2478
2423
  */
@@ -2481,32 +2426,9 @@ var extractCredits = (lyric, options = {}) => {
2481
2426
  const originalCredits = lyric.meta?.credits ?? [];
2482
2427
  let credits = originalCredits;
2483
2428
  let creditIndex;
2484
- const originalLines = lyric.lines;
2485
- let remainingLines;
2486
- for (let index = 0; index < originalLines.length; index += 1) {
2487
- const line = originalLines[index];
2488
- if (line.type !== LineType.NORMAL) {
2489
- remainingLines?.push(line);
2490
- continue;
2491
- }
2492
- const content = line.content;
2493
- if (!(content?.text !== void 0 || (content?.words.some(hasCreditColon) ?? false))) {
2494
- remainingLines?.push(line);
2495
- continue;
2496
- }
2497
- const text = getContentText(content);
2498
- const colon = text.search(/[::]/);
2499
- if (colon === -1) {
2500
- remainingLines?.push(line);
2501
- continue;
2502
- }
2503
- const raw = trimRuleWhitespace(text.slice(0, colon));
2429
+ const appendCredit = (raw, names) => {
2504
2430
  const roles = matchCreditLabel(raw);
2505
- const names = roles ? readCreditNames(text.slice(colon + 1), separators) : [];
2506
- if (!roles || !names.length) {
2507
- remainingLines?.push(line);
2508
- continue;
2509
- }
2431
+ if (!roles || !names.length) return false;
2510
2432
  creditIndex ??= createCreditIndex(originalCredits);
2511
2433
  let entries = creditIndex.get(raw);
2512
2434
  if (!entries) {
@@ -2539,10 +2461,44 @@ var extractCredits = (lyric, options = {}) => {
2539
2461
  }
2540
2462
  for (const name of additions) entry.names.add(name);
2541
2463
  }
2542
- if (options.removeSourceLines && canRemoveSourceLine(line)) remainingLines ??= originalLines.slice(0, index);
2464
+ return true;
2465
+ };
2466
+ const originalUnknowns = lyric.meta?.unknowns ?? [];
2467
+ let unknowns = originalUnknowns;
2468
+ if (options.matchUnknowns) for (const [index, entry] of originalUnknowns.entries()) {
2469
+ const raw = trimRuleWhitespace(entry.key);
2470
+ if (appendCredit(raw, matchCreditLabel(raw) ? readCreditNames(entry.value, separators) : [])) {
2471
+ if (unknowns === originalUnknowns) unknowns = originalUnknowns.slice(0, index);
2472
+ } else if (unknowns !== originalUnknowns) unknowns.push(entry);
2473
+ }
2474
+ const originalLines = lyric.lines;
2475
+ let remainingLines;
2476
+ for (let index = 0; index < originalLines.length; index += 1) {
2477
+ const line = originalLines[index];
2478
+ if (line.type !== LineType.NORMAL) {
2479
+ remainingLines?.push(line);
2480
+ continue;
2481
+ }
2482
+ const content = line.content;
2483
+ if (!(content?.text !== void 0 || (content?.words.some(hasCreditColon) ?? false))) {
2484
+ remainingLines?.push(line);
2485
+ continue;
2486
+ }
2487
+ const text = getContentText(content);
2488
+ const colon = text.search(/[::]/);
2489
+ if (colon === -1) {
2490
+ remainingLines?.push(line);
2491
+ continue;
2492
+ }
2493
+ const raw = trimRuleWhitespace(text.slice(0, colon));
2494
+ if (!appendCredit(raw, matchCreditLabel(raw) ? readCreditNames(text.slice(colon + 1), separators) : [])) {
2495
+ remainingLines?.push(line);
2496
+ continue;
2497
+ }
2498
+ if (options.removeSourceLines) remainingLines ??= originalLines.slice(0, index);
2543
2499
  else remainingLines?.push(line);
2544
2500
  }
2545
- if (credits === originalCredits && !remainingLines) return lyric;
2501
+ if (credits === originalCredits && unknowns === originalUnknowns && !remainingLines) return lyric;
2546
2502
  const removed = !!remainingLines;
2547
2503
  const lines = remainingLines ?? originalLines;
2548
2504
  let timing = lyric.timing;
@@ -2551,10 +2507,14 @@ var extractCredits = (lyric, options = {}) => {
2551
2507
  return isWordLevelLine(line) || line.backgrounds.some(isWordLevelLine);
2552
2508
  })) timing = Timing.LINE;
2553
2509
  }
2554
- const meta = credits === originalCredits ? lyric.meta : lyric.meta ? {
2510
+ const meta = credits === originalCredits && unknowns === originalUnknowns ? lyric.meta : lyric.meta ? {
2555
2511
  ...lyric.meta,
2556
- credits
2557
- } : makeMeta({ credits });
2512
+ credits,
2513
+ unknowns
2514
+ } : makeMeta({
2515
+ credits,
2516
+ unknowns
2517
+ });
2558
2518
  return {
2559
2519
  ...lyric,
2560
2520
  meta,