Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions LaTeX/songbook/bes-songbook-config.tex
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,24 @@
% Translations
\newversetype{prechorus}

\DeclareTranslation{English}{leadsheets/major}{Major}
\DeclareTranslation{English}{leadsheets/minor}{Minor}
\DeclareTranslation{English}{leadsheets/composer}{Compozitor}
\DeclareTranslation{English}{leadsheets/lyrics}{Versuri}
\DeclareTranslation{English}{leadsheets/key}{Gama}
\DeclareTranslation{English}{leadsheets/capo}{Capo}
\DeclareTranslation{English}{leadsheets/fret}{Fret}
\DeclareTranslation{English}{leadsheets/interpret}
\DeclareTranslation{Romanian}{leadsheets/major}{Major}
\DeclareTranslation{Romanian}{leadsheets/minor}{Minor}
\DeclareTranslation{Romanian}{leadsheets/composer}{Compozitor}
\DeclareTranslation{Romanian}{leadsheets/lyrics}{Versuri}
\DeclareTranslation{Romanian}{leadsheets/key}{Gama}
\DeclareTranslation{Romanian}{leadsheets/capo}{Capo}
\DeclareTranslation{Romanian}{leadsheets/fret}{Fret}
\DeclareTranslation{Romanian}{leadsheets/interpret}
{interpretat de \printsongpropertylist{interpret}{ \& }{, }{ \& }}
\DeclareTranslation{English}{leadsheets/intro}{Intro}
\DeclareTranslation{English}{leadsheets/interlude}{Interludiu}
\DeclareTranslation{English}{leadsheets/bridge}{Bridge}
\DeclareTranslation{English}{leadsheets/chorus}{Refren}
\DeclareTranslation{Romanian}{leadsheets/intro}{Intro}
\DeclareTranslation{Romanian}{leadsheets/interlude}{Interludiu}
\DeclareTranslation{Romanian}{leadsheets/bridge}{Bridge}
\DeclareTranslation{Romanian}{leadsheets/chorus}{Refren}
% Hack - We use it for the ending
\DeclareTranslation{English}{leadsheets/outro}{End}
\DeclareTranslation{English}{leadsheets/prechorus}{Prerefren}
\DeclareTranslation{English}{leadsheets/solo}{Solo}
\DeclareTranslation{English}{leadsheets/verse}{Vers}
\DeclareTranslation{Romanian}{leadsheets/outro}{End}
\DeclareTranslation{Romanian}{leadsheets/prechorus}{Prerefren}
\DeclareTranslation{Romanian}{leadsheets/solo}{Solo}
\DeclareTranslation{Romanian}{leadsheets/verse}{Vers}

\defineversetypetemplate{framed}
{%
Expand Down
38 changes: 20 additions & 18 deletions LaTeX/songbook/bes-songbook.template.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

\documentclass[headheight=29pt,footheight=29pt]{scrartcl}

\usepackage{datetime} % For custom date and time formatting
\usepackage[full]{leadsheets}

\usepackage{array}
Expand All @@ -28,6 +27,21 @@

% https://tex.stackexchange.com/questions/352804/setmainfont-vs-fontspec
\usepackage{fontspec}

% Romanian hyphenation, and a Romanian `\today` in the footer and title page.
\usepackage{polyglossia}
\setdefaultlanguage{romanian}

% Lyrics and chords use MonoLisa where it is installed; it is a licensed font,
% so the CI build, which lacks it, falls back to Latin Modern and a bold sans
% serif. XeTeX only reaches the Regular instance of MonoLisa's variable font,
% so bold is synthesized.
\IfFontExistsTF{MonoLisaText}
{\setmainfont{MonoLisaText}[AutoFakeBold=1.5]}
{}
\IfFontExistsTF{MonoLisaCode}
{\newfontfamily\chordfont{MonoLisaCode}[AutoFakeBold=1.5]}
{\newcommand\chordfont{\sffamily}}
\usepackage[12pt]{extsizes}
\KOMAoptions{headheight=29pt,footheight=29pt}
\recalctypearea
Expand Down Expand Up @@ -64,6 +78,8 @@
info/format = \itshape,
% chorus/format = \itshape,
bar-shortcuts = true,
align-chords = l,
chords/format = \chordfont\bfseries,
obey-lines = false,
smash-chords = false,
% after-song = AS,
Expand All @@ -79,7 +95,7 @@
% Define headers with underline
\ihead{\textit{Caiet de cântece BES.}\par\noindent\rule{\textwidth}{0.4pt}} % Inner (left) header with underline
\ohead{\textbf{Pagina \thepage}\par\noindent\rule{\textwidth}{0.4pt}} % Outer (right) header with underline
\cfoot{\textit{\textcopyright\ 2025 BES. Toate drepturile rezervate. \\ \scriptsize{Versiunea \today.}}} % Centered footer
\cfoot{\textit{\textcopyright\ \the\year\ BES. Toate drepturile rezervate. \\ \scriptsize{Versiunea \today.}}} % Centered footer

\input{bes-songbook-config.tex}

Expand All @@ -93,7 +109,7 @@
% Title
{\Huge \textbf{Caiet de Cântece}} \\
\vspace{0.5cm}
{\LARGE \textbf{BES - 2025}} \\
{\LARGE \textbf{BES - \the\year}} \\

% Add a decorative horizontal line
\vspace{1cm}
Expand All @@ -119,21 +135,7 @@
\end{titlepage}

% Table of content
% Set a custom title for the Table of Contents in another language
\renewcommand{\contentsname}{\textbf{Cuprins}}

% Set a custom title for the Table of Contents
\renewcommand{\contentsname}{Conținut} % Suppress the default "Contents" title

% Add the title to the Table of Contents without numbering
\addcontentsline{toc}{section}{\contentsname}

% Display the Table of Contents
\begin{center}
\vspace*{1cm}
{\Huge \textbf{Cuprins}} % Center the title
\vspace*{0.5cm}
\end{center}
\renewcommand{\contentsname}{Cuprins}

\tableofcontents

Expand Down
22 changes: 5 additions & 17 deletions LaTeX/songbook/convertToSongbookTex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ import path from 'path';
import { fileURLToPath } from 'url';
import '../../bin/env.js';
import {
EMPTY_STRING,
LEADSHEETS_DIR,
logFileWithLinkInConsole,
logProcessingFile,
NEW_LINE,
padForTex,
parse,
readSongFiles,
SongAST,
TEX_EXTENSION,
TEX_MUSICAL_NOTATIONS,
TXT_EXTENSION,
} from '../../src/index.js';
import { convertSongToLeadsheet } from '../../src/songToLeadsheetConverter.js';
import {
compareSongbookOrder,
convertSongToLeadsheet,
} from '../../src/songToLeadsheetConverter.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand All @@ -32,24 +33,11 @@ const readFiles = async (dir: string) =>
songAST: parse(songFile.content, { rejoinSubsections: true }),
}));

const getSortKey = ({
title,
alternative,
composer,
arranger,
band,
genre,
version,
}: SongAST) =>
[title, alternative, composer, arranger, band, genre, version].join(
EMPTY_STRING,
);

const runForDirs = async (songsDirs: string[]) => {
const songs = (await Promise.all(songsDirs.map(readFiles)))
.flat()
.sort(({ songAST: songA }, { songAST: songB }) =>
getSortKey(songA).localeCompare(getSortKey(songB)),
compareSongbookOrder(songA, songB),
);

// Absolute paths, because `songbook:compile` runs from the repository root
Expand Down
22 changes: 13 additions & 9 deletions docs/leadsheets-and-songbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ Spaces inside the braces are rejected.
The converter adapts the markup for the LaTeX
[`leadsheets`](https://ctan.org/pkg/leadsheets) package:

- `/` becomes `-` (`^{D/F#}` → `^{D-F#}`);
- when one word carries several chords, all but the last become `^*{…}` and the
word is split, so the chords do not collide: `^{G4}th^{G}is` →
`^*{G4}th ^{G}is`.
`^*{G4}th ^{G}is`;
- the standalone repeat marks `/:` and `:/` become the repeat bars `|:` and
`:|`.

AI agents that author or audit lead sheets follow
[`skills/bes-song-leadsheets`](../skills/bes-song-leadsheets/SKILL.md), which
Expand All @@ -53,13 +54,16 @@ also maps the complete `leadsheets` package.
`npm run songbook:convert` reads `leadsheets/trupe_lauda_si_inchinare/`, the
worship-band lead sheets, and writes one `.tex` file per song into
`LaTeX/songbook/target-tex/`. It then fills `bes-songbook.template.txt` to
produce `bes-songbook.tex`. Songs are sorted by title and numbered, and each
starts on a new page. Verses, choruses, pre-choruses, bridges, endings and
recitals become `leadsheets` environments, and every section other than a verse
is framed. Each song's header shows its interpreter, composer, lyricist, genre,
tempo and key when they are set. The book opens with a title page and a table of
contents, and its labels are in Romanian (`Refren`, `Gama`, `Versuri`,
`Cuprins`).
produce `bes-songbook.tex`. Songs are sorted by title in Romanian alphabetical
order and numbered, and each starts on a new page. Verses, choruses,
pre-choruses, bridges, endings and recitals become `leadsheets` environments,
and every section other than a verse is framed. Each song's header shows its
interpreter, composer, lyricist, genre, tempo and key when they are set. The
book opens with a title page and a table of contents, and it is typeset in
Romanian: hyphenation, dates and labels (`Refren`, `Gama`, `Versuri`,
`Cuprins`). Lyrics and chords are set in MonoLisa where that licensed font is
installed; elsewhere, including CI, lyrics use Latin Modern and chords a bold
sans serif.

`npm run songbook:compile` builds the PDF with `latexmk` and XeLaTeX, and it
fails on LaTeX errors. `npm run songbook:dist` runs both steps. Locally this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Direct-TeX rules:
1. `song` takes optional song options in `[...]` and mandatory properties in `{...}`.
2. `^{G}` is the in-song shortcut for `\chord{G}`.
3. The chord command consumes following text plus a mandatory trailing space; line endings need care.
4. Use direct slash-chord notation such as `^{D/F#}`. The BES converter's dash normalization is repository-specific and should not be copied into handwritten TeX.
4. Use direct slash-chord notation such as `^{D/F#}`, as the BES converter also emits.
5. Use `\writechord{G}` when printing a chord outside lyric placement, such as in a title template.
6. The `leadsheet` class supplies `prechorus`, `\instruction`, and `\choir` in addition to package song functionality.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,16 @@ Validation/repair sequence:
2. Add missing caret before `{...}` when absent.
3. If multiple chord notations appear in one word, split them and convert all but the final chord marker to starred form (`^*{...}`).
4. Throw on a chord that `verify:leadsheets` would reject, and TeX-escape the lyrics around the chords.
5. Replace slash in chord-bass notation with dash (`^{D/F#}` -> `^{D-F#}`).
5. Rewrite the standalone repeat marks `/:` and `:/` as the Leadsheets repeat bars `|:` and `:|`.

Slash chords stay as written (`^{D/F#}`), and a dash still chains chords in one marker (`^{A4-A}`).

Examples:

1. `^{G4}th^{G}is` -> `^*{G4}th ^{G}is`
2. `{G4}th{G}is` -> `^*{G4}th ^{G}is`
3. `^{Db/Ab}invi^{Ab}at` -> `^*{Db-Ab}invi ^{Ab}at`
3. `^{Db/Ab}invi^{Ab}at` -> `^*{Db/Ab}invi ^{Ab}at`
4. `/: Da, eu ^{G}cred :/` -> `|: Da, eu ^{G}cred :|`

## Line Layout and Escaping

Expand All @@ -92,7 +95,7 @@ Line handling:
1. Each newline in section content is rewritten to TeX line break form (` \\`).
2. Converter pads lines with spaces for readability but padding is cosmetic.

Escaping: lyrics outside chord markup and every song property except a chord `key` are printed, never interpreted. `\ % # $ _ & ~ ^ { }` become `\textbackslash{}`, `\%`, `\#`, `\$`, `\_`, `\&`, `\textasciitilde{}`, `\textasciicircum{}`, `\{` and `\}`.
Escaping: lyrics outside chord markup and every song property except a chord `key` are printed, never interpreted. `\ % # $ _ & ~ ^ { } |` become `\textbackslash{}`, `\%`, `\#`, `\$`, `\_`, `\&`, `\textasciitilde{}`, `\textasciicircum{}`, `\{`, `\}` and `\textbar{}`, so a lyric `|` never becomes a bar.

TeX document shape:

Expand All @@ -117,7 +120,6 @@ If explicit subsection rendering is required in TeX:
## Known Limitations

1. Canonical lyric edits must be applied to paired lead sheets; `npm run verify:leadsheets` detects but does not repair drift.
2. `/:` and `:/` repeat syntax is not transformed into left/right repeat commands by current converter.
3. `title` metadata parsing is simple brace splitting; malformed nested braces can break extraction.
4. Sequence parsing assumes comma-separated tokens without extra wrappers.
5. Converter ignores some available `leadsheets` properties even if present in BES metadata.
2. `title` metadata parsing is simple brace splitting; malformed nested braces can break extraction.
3. Sequence parsing assumes comma-separated tokens without extra wrappers.
4. Converter ignores some available `leadsheets` properties even if present in BES metadata.
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ With `bar-shortcuts=true` inside `song`:
- `||` → double bar
- `|||` → final/stop bar

The BES converter currently preserves its source repeat markers unless explicitly changed.
The BES converter rewrites the standalone source repeat marks `/:` and `:/` as `|:` and `:|`, and escapes any other `|` in lyrics.

## Templates — Pages 33–44

Expand All @@ -168,7 +168,7 @@ Templates can implement arbitrary layout such as flush alignment or framed secti

Use `\DeclareTranslation{language}{leadsheets/key}{translation}`.

Predefined concepts include major/minor, verse types, composer, lyrics, key, capo, fret, and interpretation. BES overrides relevant English translation entries with Romanian labels.
Predefined concepts include major/minor, verse types, composer, lyrics, key, capo, fret, and interpretation. The BES songbook sets Romanian as its `polyglossia` language and declares its labels as `Romanian` translations.

## Standalone Class and External Inclusion — Pages 46–51

Expand Down
70 changes: 64 additions & 6 deletions src/songToLeadsheetConverter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { parse } from './songParser.js';
import {
compareSongbookOrder,
convertSongToLeadsheet,
getNormalizedContent,
} from './songToLeadsheetConverter.js';
import { SongAST } from './types.js';

describe('songToLeadsheetConverter', () => {
it('should correctly convert to a leadsheet song', () => {
Expand Down Expand Up @@ -207,17 +209,26 @@ Ori zece mii de ani și-n veșnicii.",
);
});

it('should normalise words with notations having bass (from / to -)', () => {
expect(getNormalizedContent('^{Ab/C}th')).toEqual('^{Ab-C}th');
it('keeps slash chords as written', () => {
expect(getNormalizedContent('^{Ab/C}th')).toEqual('^{Ab/C}th');
expect(getNormalizedContent('^{D/F#}lumi^{G}nat')).toEqual(
'^*{D-F#}lumi ^{G}nat',
'^*{D/F#}lumi ^{G}nat',
);
expect(getNormalizedContent('/: th :/')).toEqual('/: th :/');
});

it('prints standalone repeat marks as repeat bars', () => {
expect(getNormalizedContent('/: Da, eu ^{G}cred :/ (x2)')).toEqual(
'|: Da, eu ^{G}cred :| (x2)',
);
});

it('keeps repeat marks inside a word as text', () => {
expect(getNormalizedContent('a/: b:/')).toEqual('a/: b:/');
});

it('should normalise words with multiple complex chords within a single word', () => {
expect(getNormalizedContent('^{Db/Ab}invi^{Ab}at.')).toEqual(
'^*{Db-Ab}invi ^{Ab}at.',
'^*{Db/Ab}invi ^{Ab}at.',
);
});

Expand Down Expand Up @@ -245,6 +256,7 @@ Ori zece mii de ani și-n veșnicii.",
['~', '\\textasciitilde{}'],
['^', '\\textasciicircum{}'],
['}', '\\}'],
['|', '\\textbar{}'],
])('escapes "%s" in lyrics as "%s"', (texActiveChar, escapedChar) => {
expect(getNormalizedContent(`a${texActiveChar}b`)).toEqual(
`a${escapedChar}b`,
Expand All @@ -253,7 +265,7 @@ Ori zece mii de ani și-n veșnicii.",

it('escapes lyrics around chords without touching the chords', () => {
expect(getNormalizedContent('^{D/F#}lumi_na^{G}t 100%')).toEqual(
'^*{D-F#}lumi\\_na ^{G}t 100\\%',
'^*{D/F#}lumi\\_na ^{G}t 100\\%',
);
});

Expand Down Expand Up @@ -325,4 +337,50 @@ Text
expect(tex).toContain('key={Re\\_major}');
});
});

describe('compareSongbookOrder', () => {
const toSong = (song: Partial<SongAST> & Pick<SongAST, 'title'>) =>
({
contentHash: 'abc',
id: 'abc',
sectionOrder: [],
sectionsMap: {},
sequence: [],
...song,
}) satisfies SongAST;

const sortTitles = (songs: SongAST[]) =>
songs.sort(compareSongbookOrder).map(({ title }) => title);

it('sorts a title before a longer title it starts', () => {
expect(
sortTitles([
toSong({ title: 'Biruitor in pustiu', composer: 'Grupul Eldad' }),
toSong({ title: 'Biruitor', composer: 'Dani Ardelean' }),
]),
).toEqual(['Biruitor', 'Biruitor in pustiu']);
});

it('sorts titles in Romanian alphabetical order', () => {
expect(
sortTitles([
toSong({ title: 'Cântă, suflet' }),
toSong({ title: 'Aș vrea' }),
toSong({ title: 'Cântarea mea' }),
toSong({ title: 'Astăzi' }),
]),
).toEqual(['Astăzi', 'Aș vrea', 'Cântarea mea', 'Cântă, suflet']);
});

it('sorts songs sharing a title by composer', () => {
const songs = [
toSong({ title: 'Ce mare ești', composer: 'Bethanias' }),
toSong({ title: 'Ce mare ești', composer: 'Anonim' }),
];

expect(
songs.sort(compareSongbookOrder).map(({ composer }) => composer),
).toEqual(['Anonim', 'Bethanias']);
});
});
});
Loading