import {Injectable, inject} from '@angular/core'; import {TransposeService} from './transpose.service'; import {TransposeMode} from './transpose-mode'; import {SectionType} from './section-type'; import {Section} from './section'; import {LineType} from './line-type'; import {Chord, ChordAddDescriptor, ChordValidationIssue} from './chord'; import {Line} from './line'; interface ParsedValidationToken { root: string; suffix: string; slashChord: string | null; rootWasAlias: boolean; slashWasAlias: boolean; } interface ChordLineValidationResult { issues: ChordValidationIssue[]; isChordLike: boolean; } @Injectable({ providedIn: 'root', }) export class TextRenderingService { private transposeService = inject(TransposeService); private readonly regexSection = /^\s*(Strophe|Refrain|Bridge)\b/i; private readonly chordRoots = [ 'C#', 'Db', 'D#', 'Eb', 'F#', 'Gb', 'G#', 'Ab', 'A#', 'C', 'D', 'E', 'F', 'G', 'A', 'B', 'H', 'c#', 'db', 'd#', 'eb', 'f#', 'gb', 'g#', 'ab', 'a#', 'c', 'd', 'e', 'f', 'g', 'a', 'b', 'h', ] as const; private readonly suffixKeywords = ['moll', 'verm', 'maj', 'min', 'dur', 'dim', 'aug', 'sus', 'add', 'm'] as const; private readonly suffixChars = new Set(['#', 'b', '+', '-', '(', ')']); private readonly alternativeChordRoots: Record = { Ais: 'A#', ais: 'a#', As: 'Ab', as: 'ab', Cis: 'C#', cis: 'c#', Des: 'Db', des: 'db', Dis: 'D#', dis: 'd#', Es: 'Eb', es: 'eb', Fis: 'F#', fis: 'f#', Ges: 'Gb', ges: 'gb', Gis: 'G#', gis: 'g#', Hb: 'B', hb: 'b', }; public parse(text: string, transpose: TransposeMode | null, withComments = true): Section[] { if (!text) { return []; } const indices = { [SectionType.Bridge]: 0, [SectionType.Chorus]: 0, [SectionType.Verse]: 0, }; const sections: Section[] = []; for (const [lineIndex, line] of text.split(/\r?\n/).entries()) { if (!line || this.isCommentLine(line, withComments)) { continue; } const type = this.getSectionTypeOfLine(line); if (type !== null) { sections.push({ type, number: indices[type]++, lines: [], }); continue; } if (sections.length === 0) { continue; } const renderedLine = this.getLineOfLineText(line, transpose, lineIndex + 1); if (renderedLine) { sections[sections.length - 1].lines.push(renderedLine); } } return sections; } public validateChordNotation(text: string, withComments = true): ChordValidationIssue[] { if (!text) { return []; } const issues: ChordValidationIssue[] = []; const lines = text.split(/\r?\n/); lines.forEach((line, lineIndex) => { if (!line || this.isCommentLine(line, withComments) || this.getSectionTypeOfLine(line) !== null) { return; } const validationResult = this.getChordLineValidationResult(line, lineIndex + 1); issues.push(...validationResult.issues); }); return issues; } private getLineOfLineText(text: string, transpose: TransposeMode | null, lineNumber?: number): Line | null { if (!text) return null; const chords = this.readChords(text); const validationResult = lineNumber ? this.getChordLineValidationResult(text, lineNumber) : {issues: [], isChordLike: false}; const validationIssues = validationResult.issues; const hasMatches = chords.length > 0; const isChordLikeLine = hasMatches || validationResult.isChordLike; const type = isChordLikeLine ? LineType.chord : LineType.text; const line: Line = {type, text, chords: hasMatches ? chords : null, lineNumber}; if (validationIssues.length > 0 || (!hasMatches && isChordLikeLine)) { return line; } return transpose !== null && transpose !== undefined ? this.transposeService.transpose(line, transpose.baseKey, transpose.targetKey) : this.transposeService.renderChords(line); } private getSectionTypeOfLine(line: string): SectionType | null { if (!line) { return null; } const match = this.regexSection.exec(line); if (!match || match.length < 2) { return null; } const typeString = match[1].toLowerCase(); switch (typeString) { case 'strophe': return SectionType.Verse; case 'refrain': return SectionType.Chorus; case 'bridge': return SectionType.Bridge; } return null; } private readChords(chordLine: string): Chord[] { const chords: Chord[] = []; const tokens = chordLine.match(/\S+/g) ?? []; for (const token of tokens) { const position = chordLine.indexOf(token, chords.length > 0 ? chords[chords.length - 1].position + chords[chords.length - 1].length : 0); const chord = this.parseChordToken(token, position); if (chord) { chords.push(chord); } } const chordCount = chords.reduce((acc: number, cur: Chord) => acc + cur.length, 0); const lineCount = chordLine.replace(/\s/g, '').length; const isChordLine = chordCount * 1.2 > lineCount; return isChordLine ? chords : []; } private getChordLineValidationResult(line: string, lineNumber: number): ChordLineValidationResult { const tokens = line.match(/\S+/g) ?? []; const parsedTokens = tokens .map(token => ({ token, parsed: this.parseValidationToken(token), })); const recognizedTokens = parsedTokens.filter((entry): entry is {token: string; parsed: ParsedValidationToken} => entry.parsed !== null); const parsedLength = recognizedTokens.reduce((sum, entry) => sum + entry.token.length, 0); const compactLineLength = line.replace(/\s/g, '').length; const strictlyChordLike = compactLineLength > 0 && parsedLength * 1.2 > compactLineLength; const heuristicallyChordLike = tokens.length >= 2 && recognizedTokens.length >= Math.ceil(tokens.length * 0.6); const isChordLike = strictlyChordLike || heuristicallyChordLike; if (!isChordLike) { return {issues: [], isChordLike: false}; } return { issues: parsedTokens .map(entry => { if (!entry.parsed) { return this.createUnknownTokenIssue(line, lineNumber, entry.token); } return this.createValidationIssue(line, lineNumber, entry.token, entry.parsed); }) .filter((issue): issue is ChordValidationIssue => issue !== null), isChordLike: true, }; } private isCommentLine(line: string, withComments: boolean): boolean { return !withComments && line.trimStart().startsWith('#'); } private parseChordToken(token: string, position: number): Chord | null { const root = this.readChordRoot(token, 0); if (!root) { return null; } let cursor = root.length; const suffix = this.readChordSuffix(token, cursor); cursor += suffix.length; let slashChord: string | null = null; if (token[cursor] === '/') { const slash = this.readChordRoot(token, cursor + 1); if (!slash) { return null; } slashChord = slash; cursor += 1 + slash.length; } if (cursor !== token.length) { return null; } return { chord: root, length: token.length, position, slashChord, add: suffix || null, addDescriptor: this.parseChordAddDescriptor(suffix || null), }; } private readChordRoot(token: string, start: number): string | null { return this.chordRoots.find(root => token.startsWith(root, start)) ?? null; } private readValidationChordRoot(token: string, start: number): {root: string; length: number; wasAlias: boolean} | null { const directMatch = this.readChordRoot(token, start); if (directMatch) { return {root: directMatch, length: directMatch.length, wasAlias: false}; } const aliasMatch = Object.entries(this.alternativeChordRoots).find(([alias]) => token.startsWith(alias, start)); if (!aliasMatch) { return null; } return {root: aliasMatch[1], length: aliasMatch[0].length, wasAlias: true}; } private readChordSuffix(token: string, start: number): string { let cursor = start; let suffix = ''; while (cursor < token.length) { const keyword = this.suffixKeywords.find(entry => token.startsWith(entry, cursor)); if (keyword) { suffix += keyword; cursor += keyword.length; continue; } const char = token[cursor]; if (this.isDigit(char) || this.suffixChars.has(char)) { suffix += char; cursor += 1; continue; } break; } return suffix; } private readValidationChordSuffix(token: string, start: number): string { let cursor = start; let suffix = ''; while (cursor < token.length) { const keyword = this.suffixKeywords.find(entry => token.slice(cursor, cursor + entry.length).toLowerCase() === entry); if (keyword) { suffix += token.slice(cursor, cursor + keyword.length); cursor += keyword.length; continue; } const char = token[cursor]; if (this.isDigit(char) || this.suffixChars.has(char)) { suffix += char; cursor += 1; continue; } break; } return suffix; } private isDigit(char: string | undefined): boolean { return !!char && char >= '0' && char <= '9'; } private parseValidationToken(token: string): ParsedValidationToken | null { const root = this.readValidationChordRoot(token, 0); if (!root) { return null; } let cursor = root.length; const suffix = this.readValidationChordSuffix(token, cursor); cursor += suffix.length; let slashChord: string | null = null; let slashWasAlias = false; if (token[cursor] === '/') { const slash = this.readValidationChordRoot(token, cursor + 1); if (!slash) { return null; } slashChord = slash.root; slashWasAlias = slash.wasAlias; cursor += 1 + slash.length; } if (cursor !== token.length) { return null; } return { root: root.root, suffix, slashChord, rootWasAlias: root.wasAlias, slashWasAlias, }; } private createValidationIssue(line: string, lineNumber: number, token: string, parsed: ParsedValidationToken): ChordValidationIssue | null { const suggestion = this.getCanonicalChordToken(parsed); if (suggestion === token) { return null; } if (parsed.rootWasAlias || parsed.slashWasAlias) { return { lineNumber, lineText: line, token, suggestion, reason: 'alias', message: `Bitte #/b statt is/es verwenden: ${suggestion}`, }; } const descriptor = this.parseChordAddDescriptor(parsed.suffix); if (descriptor?.quality === 'minor' && this.isMajorRoot(parsed.root)) { return { lineNumber, lineText: line, token, suggestion, reason: 'minor_format', message: `Mollakkorde bitte mit kleinem Grundton schreiben: ${suggestion}`, }; } if (this.isMinorRoot(parsed.root) && descriptor?.quality === 'major') { return { lineNumber, lineText: line, token, suggestion, reason: 'major_format', message: `Durakkorde bitte mit grossem Grundton schreiben: ${suggestion}`, }; } if (parsed.suffix !== this.normalizeSuffix(parsed.suffix)) { return { lineNumber, lineText: line, token, suggestion, reason: 'invalid_suffix', message: `Bitte die vorgegebene Akkordschreibweise verwenden: ${suggestion}`, }; } return null; } private createUnknownTokenIssue(line: string, lineNumber: number, token: string): ChordValidationIssue { return { lineNumber, lineText: line, token, suggestion: null, reason: 'unknown_token', message: 'Unbekannter Akkord oder falsche Schreibweise', }; } private getCanonicalChordToken(parsed: ParsedValidationToken): string { const descriptor = this.parseChordAddDescriptor(parsed.suffix); const normalizedSuffix = this.normalizeSuffix(parsed.suffix); let root = parsed.root; let suffix = normalizedSuffix; if (descriptor?.quality === 'minor') { root = this.toMinorRoot(root); suffix = this.stripLeadingMinorMarker(normalizedSuffix); } else if (descriptor?.quality === 'major') { root = this.toMajorRoot(root); suffix = this.stripLeadingDurMarker(normalizedSuffix); } const slashChord = parsed.slashChord ? (this.isMinorRoot(root) ? this.toMinorRoot(parsed.slashChord) : this.toMajorRoot(parsed.slashChord)) : null; return root + suffix + (slashChord ? `/${slashChord}` : ''); } private normalizeSuffix(suffix: string): string { if (!suffix) { return suffix; } let rest = suffix; let normalized = ''; const prefixMap: Array<[string, string]> = [ ['moll', 'moll'], ['min', 'min'], ['maj', 'maj'], ['dur', 'dur'], ['dim', 'dim'], ['verm', 'verm'], ['aug', 'aug'], ['sus', 'sus'], ['add', 'add'], ['m', 'm'], ]; while (rest.length > 0) { const keyword = prefixMap.find(([key]) => rest.slice(0, key.length).toLowerCase() === key); if (keyword) { normalized += keyword[1]; rest = rest.slice(keyword[0].length); continue; } normalized += rest[0]; rest = rest.slice(1); } return normalized; } private stripLeadingMinorMarker(suffix: string): string { return suffix.replace(/^(moll|min|m)/, ''); } private stripLeadingDurMarker(suffix: string): string { return suffix.replace(/^dur/, ''); } private isMajorRoot(root: string): boolean { return root[0] === root[0].toUpperCase(); } private isMinorRoot(root: string): boolean { return root[0] === root[0].toLowerCase(); } private toMinorRoot(root: string): string { return root[0].toLowerCase() + root.slice(1); } private toMajorRoot(root: string): string { return root[0].toUpperCase() + root.slice(1); } private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null { if (!suffix) { return null; } let rest = suffix; let quality: ChordAddDescriptor['quality'] = null; const qualityMatchers: Array<[string, NonNullable]> = [ ['moll', 'minor'], ['min', 'minor'], ['maj', 'major'], ['dur', 'major'], ['dim', 'diminished'], ['verm', 'diminished'], ['aug', 'augmented'], ['m', 'minor'], ]; for (const [prefix, normalized] of qualityMatchers) { if (rest.startsWith(prefix)) { quality = normalized; rest = rest.slice(prefix.length); break; } } const descriptor: ChordAddDescriptor = { raw: suffix, quality, extensions: [], additions: [], suspensions: [], alterations: [], modifiers: [], }; while (rest.length > 0) { const additionMatch = rest.match(/^add([#b+\-]?\d+)/); if (additionMatch) { descriptor.additions.push(additionMatch[1]); rest = rest.slice(additionMatch[0].length); continue; } const suspensionMatch = rest.match(/^sus(\d*)/); if (suspensionMatch) { descriptor.suspensions.push(suspensionMatch[1] || 'sus'); rest = rest.slice(suspensionMatch[0].length); continue; } const extensionMatch = rest.match(/^\d+/); if (extensionMatch) { descriptor.extensions.push(extensionMatch[0]); rest = rest.slice(extensionMatch[0].length); continue; } const alterationMatch = rest.match(/^[#b+\-]\d+/); if (alterationMatch) { descriptor.alterations.push(alterationMatch[0]); rest = rest.slice(alterationMatch[0].length); continue; } const modifierMatch = rest.match(/^\([^)]*\)/); if (modifierMatch) { descriptor.modifiers.push(modifierMatch[0]); rest = rest.slice(modifierMatch[0].length); continue; } descriptor.modifiers.push(rest); break; } return descriptor; } }