@@ -17,6 +17,52 @@ const isChordLine = (line: string): boolean => {
1717 return line . slice ( - 2 ) === ' ' ;
1818} ;
1919
20+ // escape a value for use inside a single-quoted XML attribute
21+ const escapeXmlAttr = ( value : string ) : string =>
22+ value . replace ( / & / g, '&' ) . replace ( / < / g, '<' ) . replace ( / > / g, '>' ) . replace ( / ' / g, ''' ) ;
23+
24+ // splice a chord line's tokens into its paired lyric line as OpenLyrics <chord> tags at their column position.
25+ // Important: lyricLine must stay unescaped until after splicing — column offsets are computed against the raw
26+ // (unescaped) text, so escaping first would shift indices and misalign chords with text.
27+ const embedChords = ( chordLine : string , lyricLine : string ) : string => {
28+ const tokens : { name : string ; column : number } [ ] = [ ] ;
29+ const re = / \S + / g;
30+ let match : RegExpExecArray | null ;
31+ while ( ( match = re . exec ( chordLine ) ) !== null ) {
32+ tokens . push ( { name : match [ 0 ] , column : match . index } ) ;
33+ }
34+ let result = '' , cursor = 0 ;
35+ for ( const { name, column } of tokens ) {
36+ result += lyricLine . slice ( cursor , column ) ; // slice clamps automatically if column > lyricLine.length
37+ result += `<chord name='${ escapeXmlAttr ( name ) } '/>` ;
38+ cursor = column ;
39+ }
40+ return result + lyricLine . slice ( cursor ) ;
41+ } ;
42+
43+ // walk a song part's raw lines (lyrics interleaved with chord lines), pairing each chord line with the
44+ // non-blank lyric line directly beneath it and join everything with the file's existing '<br />' convention
45+ const chordTaggedLines = ( content : string ) : string => {
46+ const lines = content . split ( '\n' ) ;
47+ const result : string [ ] = [ ] ;
48+ for ( let i = 0 ; i < lines . length ; i ++ ) {
49+ if ( isChordLine ( lines [ i ] ) ) {
50+ const next = lines [ i + 1 ] ;
51+ if ( next !== undefined && next . trim ( ) !== '' && ! isChordLine ( next ) ) {
52+ result . push ( embedChords ( lines [ i ] , next ) ) ;
53+ i ++ ;
54+ } else {
55+ // standalone/instrumental chord line (back-to-back chord lines, last line of a part, or
56+ // followed by a blank separator line): emit bare chord tags, don't swallow a blank line
57+ result . push ( embedChords ( lines [ i ] , '' ) ) ;
58+ }
59+ } else {
60+ result . push ( lines [ i ] ) ;
61+ }
62+ }
63+ return result . join ( '<br />' ) ;
64+ } ;
65+
2066// parse song content syntax
2167function parsedContent ( content : string , keyOffset : number , showChords : boolean , twoColumns : false ) : SongPart [ ] ;
2268function parsedContent ( content : string , keyOffset : number , showChords : boolean , twoColumns : true ) : [ SongPart [ ] , SongPart [ ] ] ;
@@ -367,13 +413,13 @@ const openLyricsXML = (song: SongEntity, version: string, translatedSong: SongEn
367413 ? `<format><tags application='OpenLP'><tag name='it'><open><![CDATA[<em>]]></open><close><![CDATA[</em>]]></close><hidden><![CDATA[False]]></hidden></tag><tag name='gr'><open><![CDATA[<span style='-webkit-text-fill-color:grey'>]]></open><close><![CDATA[</span>]]></close><hidden><![CDATA[True]]></hidden></tag><tag name='fd'><open><![CDATA[<small>]]></open><close><![CDATA[</small>]]></close><hidden><![CDATA[True]]></hidden></tag></tags></format>`
368414 : '' ;
369415 const tParts = translatedSong ? parsedContent ( translatedSong . content , 0 , false , false ) : [ ] ;
370- const lyrics = parsedContent ( song . content , 0 , false , false ) . map ( ( p , i ) => {
416+ const lyrics = parsedContent ( song . content , 0 , true , false ) . map ( ( p , i ) => {
371417 const type = p . type ? p . type . toUpperCase ( ) : 'V' ;
372418 const num = Number ( p . number ) > 0 ? p . number : '1' ;
373419 const tContent = ( i in tParts )
374420 ? `<br/><br/><tag name='it'><tag name='gr'><tag name='fd'>${ tParts [ i ] . content . replace ( / \n / g, "<br />" ) } </tag></tag></tag>`
375421 : '' ;
376- return `<verse name='${ type } ${ num } '><lines>${ p . content . replace ( / \n / g , "<br />" ) } ${ tContent } </lines></verse>` ;
422+ return `<verse name='${ type } ${ num } '><lines>${ chordTaggedLines ( p . content ) } ${ tContent } </lines></verse>` ;
377423 } ) . join ( '' ) ;
378424
379425 return `<?xml version='1.0' encoding='UTF-8'?><song xmlns='http://openlyrics.info/namespace/2009/song' version='0.9' createdIn='SongDrive ${ version } ' modifiedIn='SongDrive ${ version } ' modifiedDate='${ timestamp } '><properties><titles>${ title } ${ subtitle } </titles>${ copyright } ${ year } ${ ccli } ${ authors } ${ tags } </properties>${ format } <lyrics>${ lyrics } </lyrics></song>` ;
0 commit comments