diff --git a/README.md b/README.md index 53c691f..954333c 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,30 @@ -# chordpro-php +# The chordpro-php library A simple tool to parse, transpose & format [ChordPro](https://www.chordpro.org) songs with lyrics & chords. Forked from by [Nicolaz Wurtz](https://github.com/nicolaswurtz), on LGPL-3 license. -It currently supports the following output formats : -- HTML (verses contain blocks with embricated `span` for alignement of chords with lyrics) -- JSON (verses are array of arrays of chords and lyrics for alignement purpose) -- Plain text (chords are aligned with monospace text thanks to whitespaces) +The following output formats are currently supported -And provides some extra functionnalities : -- Tranpose chords (can be very clever if original key is known) -- Display french chords -- Guess tonality key of a song +- HTML (verses contain blocks with embedded `span` for aligning chords with lyrics) +- JSON (verses are arrays of chords and lyrics for alignment purposes) +- Plain text (chords are aligned with monospace text thanks to whitespace) -_I'm french, so there's probably a lot of mistakes, my english is not always accurate — je fais ce que je peux hein :P_ +And provides some extra functionality: + +- Tranpose chords by semitones or to the target key. +- Parse and display various chord notations: + - French (`Do`, `Ré`, `Mi`) + - German (`Fis`, `a`) + - With UTF characters (`A♭`, `F♯`) +- Guess the key of a song. ## Install -Via composer : +Via composer: ``` bash -$ composer require intelektron/chordpro-php +composer require intelektron/chordpro-php ``` ## Usage @@ -38,8 +41,8 @@ $txt = "{t:ChordpropPHP Song} {c:GPL3 2019 Nicolas Wurtz} {key:C} [C]This is the [Dm]beautiful [Em]song -I [Dm]wroted in [F/G]Chordpro for[C]mat [Dm/F] -Let's singing a[C/E]long +I [Dm]wrote in [F/G]Chordpro for[C]mat [Dm/F] +Let's sing it a[C/E]long [Bb] It's ea[Dm]sy to do [F]that [C] {soc} @@ -52,81 +55,172 @@ Let's singing a[C/E]long $parser = new ChordPro\Parser(); -// Choose one (or all !) of those formatters following your needs -$html_formatter = new ChordPro\HtmlFormatter(); -$monospace_formatter = new ChordPro\MonospaceFormatter(); -$json_formatter = new ChordPro\JSONFormatter(); +// Choose one of these formatters according to your needs. +$htmlFormatter = new ChordPro\Formatter\HtmlFormatter(); +$monospaceFormatter = new ChordPro\Formatter\MonospaceFormatter(); +$jsonFormatter = new ChordPro\Formatter\JSONFormatter(); -// Create song object after parsing txt +// Create song object after parsing $txt. $song = $parser->parse($txt); -// You can tranpose your song, put how many semitones you want to transpose in second argument OR desired key (only if metadata "key" is defined) +// You can transpose your song. $transposer = new ChordPro\Transposer(); -$transposer->transpose($song,-5); // Simple transpose, but could produce some musical errors (sharp instead of flat) -//$transposer->transpose($song,'Abm'); -// Some options are mandatory, you could use en empty array if none -$options = array( - 'french' => true, - 'no_chords' => true -); +// Define how many semitones you want to transpose by. +$transposer->transpose($song,-5); + +// If the song key is known, you can also transpose from key to key. +// $transposer->transpose($song,'Abm'); + +// The formatter has some options +$options = [ + 'ignore_metadata' => 'title', +]; // Render ! -$html = $html_formatter->format($song,$options); -$plaintext = $monospace_formatter->format($song,$options); -$json = $json_formatter->format($song,$options); +$html = $htmlFormatter->format($song, $options); +$monospaced = $monospaceFormatter->format($song, $options); +$json = $jsonFormatter->format($song, $options); ``` ## Formatting options + Simply give an array with values at true or false for each key/option. + ``` php -array( - 'french' => true, // to display french chords (Do, Ré, Mi, Fa, Sol, La, Si, Do), including Song Key. - 'no_chords' => true // to only get text (it removes "block" system for chords alignements) -); +[ + 'ignore_metadata' => ['title', 'subtitle'], // Do not render these types of metadata. + 'no_chords' => true // Render text without chords. + 'notation' => new ChordPro\Notation\GermanChordNotation(), // Choose output chord notation. +]; ``` -## Specific methods +## The song key + +The key can be set/changed in the following ways: -### Song -- `$song->getKey([])` to obtain key of song, **with transposition**, you can alter langage english by default, or French ```$song->getKey(['french' => true]);```, options array is mandatory, you could use en empty array if none -- `$song->getMetadataKey()` to obtain key of song, as defined in metadata's field "key" +- Manually, by calling `$song->setKey('A')`. +- By parsing a song with metadata, e.g. `{key:A}` +- By transposing the song to another key. + +You can get the key by calling: + +- `$song->getKey()` - get the key if it is defined by `setKey()`, otherwise, use the key from the metadata. +- `$song->getMetadataKey()` - get the key from the metadata. + +If the song has no key defined, there is a possibility to guess it. This feature is experimental and not reliable (20% error rate, tested with ~1000 songs), but can be very useful. -### Guess key of a song -This fonctionnality is experimental and not reliable (20% of mistakes, tested with ~1000 songs), but can be very useful. -Usage is very simple (you have to parse a song before as described before): ``` php $guess = new ChordPro\GuessKey(); $key = $guess->guessKey($song); ``` -## CSS Classes you can use with _HTML_ Formatter +## Chord notations -### Verses -_Verses_ are one line composed by blocks of text + chords, chord with class `chordpro-chord` and text with class `chordpro-text`. +The library supports several chord notations. You can also create your own (by implementing `ChordNotationInterface`). Notations are used for both parsing and formatting. So you can parse a song in German notation and display it as French: -A typical `div` will be like this : -``` html -
- +```php +$txt = 'A typical [fis]German [a]verse'; +$parser = new ChordPro\Parser(); +$notation = new ChordPro\Notation\GermanChordNotation(); +$song = $parser->parse($song, [$notation]) +``` + +At this point, `fis` is recognized and saved as `F#m`, and `a` is saved as `Am`. Note that you can pass multiple notations to the parser, in order of precedence. This can be useful if you have mixed up chord notations in one song. + +Now, to show this song in French: + +```php +$monospaceFormatter = new ChordPro\Formatter\MonospaceFormatter(); +$html = $monospaceFormatter->format($song, [ + 'notation' => $frenchNotation +]); + +// Fa♯m Lam +// A typical German verse +``` + +## Styling the HTML code + +### Song lines + +Lines are made up of blocks. Each block consists of a text and a chord. The chord has the class `chordpro-chord' and the text has the class `chordpro-text'. + +A typical line of the song looks like this: + +```html +
+ C This is the - + Dm beautiful song
``` -### Chorus -The _chorus_ (`soc`/`start_of_chorus`) will be contained inside ```
```. +### Song sections + +The ChordPro format allows to organize your songs into sections. The following song fragment: + +```chordpro +{start_of_verse Verse 1} +... +{end_of_verse} + +{start_of_foobar} +... +{end_of_foobar} +``` + +Will be converted to: + +```html +
Verse 1
+
+ ... +
+ +
+ ... +
+``` + +You can use anything in place of `foobar`. However, the following shortcuts are supported: + +- `{soc}` → `{start_of_chorus}` +- `{eoc}` → `{end_of_chorus}` +- `{sov}` → `{start_of_verse}` +- `{eov}` → `{end_of_verse}` +- `{sob}` → `{start_of_bridge}` +- `{eob}` → `{end_of_bridge}` +- `{sot}` → `{start_of_tab}` +- `{eot}` → `{end_of_tab}` +- `{sog}` → `{start_of_grid}` +- `{eog}` → `{end_of_grid}` ### Metadata -By default, all _metadatas_ are placed inside ```
```. -For example, the _title_ will be + +The library reads ChordPro metadata and renders it as HTML in the following way: + +```chordpro +{title: Let's Sing!} +{c: Very loud} +``` + +Becomes: + ``` html -
It's a great title !
+
Let's Sing!
+
Very loud
``` -_ChordproPHP doesn't care about the metadata names, it just puts it after `chordpro-` :)_ -> Metatada's names are always converted to their long form (`c` will be recorded as `comment`) when using short names from [official directives](https://www.chordpro.org/chordpro/ChordPro-Directives.html) + +The names of metadata are not restricted in any way, however, there are some standard ones described by ChordPro format. The following shortcuts are supported: + +- `{t}` → `{title}` +- `{st}` → `{subtitle}` +- `{c}` → `{comment}` +- `{ci}` → `{comment_italic}` +- `{cb}` → `{comment_box}` diff --git a/composer.json b/composer.json index 9ba7612..6ec8491 100644 --- a/composer.json +++ b/composer.json @@ -3,18 +3,18 @@ "description": "Parse, transpose and format (html, json, plaintext) ChordPro format for songs lyrics with chords.", "type": "library", "require": { - "php": ">=8.1", - "phpcompatibility/php-compatibility": "^9.3" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-deprecation-rules": "^1.1", "ekino/phpstan-banned-code": "^1.0", "friendsofphp/php-cs-fixer": "^3.46", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-deprecation-rules": "^1.1", "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "^10.0", "thecodingmachine/phpstan-strict-rules": "^1.0", - "phpstan/extension-installer": "^1.3", "voku/phpstan-rules": "^3.2" }, "autoload": { diff --git a/src/Chord.php b/src/Chord.php index 081cd60..da63e4c 100644 --- a/src/Chord.php +++ b/src/Chord.php @@ -5,6 +5,7 @@ namespace ChordPro; use ChordPro\Notation\ChordNotationInterface; +use PHPUnit\Event\Runtime\PHP; /** * A class for chord manipulations. @@ -29,17 +30,16 @@ class Chord private bool $isKnown = false; /** - * Static cache of different notation roots. - * - * @var string[][] + * @param string $originalName The original name of the chord. + * @param ChordNotationInterface[] $sourceNotations The notations to use, ordered by precedence. */ - private static array $notationRoots = []; - - public function __construct(private string $originalName, ?ChordNotationInterface $sourceNotation = null) + public function __construct(private string $originalName, array $sourceNotations = []) { - $rootChordTable = $this->getRootChordTable($sourceNotation); + foreach ($sourceNotations as $sourceNotation) { + $originalName = $sourceNotation->convertChordRootFromNotation($originalName); + } - foreach ($rootChordTable as $rootChord) { + foreach (self::ROOT_CHORDS as $rootChord) { if (str_starts_with($originalName, $rootChord)) { $this->rootChord = $rootChord; $this->ext = substr($originalName, strlen($rootChord)); @@ -55,9 +55,12 @@ public function __construct(private string $originalName, ?ChordNotationInterfac /** * Create multiple chords from slices like [C/E]. * + * @param string $text The text to parse. + * @param ChordNotationInterface[] $notations The notations to use, ordered by precedence. + * * @return Chord[] */ - public static function fromSlice(string $text, ?ChordNotationInterface $notation = null): array + public static function fromSlice(string $text, array $notations = []): array { if ($text === '') { return []; @@ -65,7 +68,7 @@ public static function fromSlice(string $text, ?ChordNotationInterface $notation $chords = explode('/', $text); $result = []; foreach ($chords as $chord) { - $result[] = new Chord($chord, $notation); + $result[] = new Chord($chord, $notations); } return $result; } @@ -80,25 +83,6 @@ public function isMinor(): bool return substr($this->rootChord, -1) === 'm'; } - /** - * @return string[] The root chords in the notation. - */ - private function getRootChordTable(?ChordNotationInterface $notation): array - { - if (is_null($notation)) { - return self::ROOT_CHORDS; - } elseif (isset($this->notationRoots[$notation::class])) { - return $this->notationRoots[$notation::class]; - } else { - $rootChordTable = []; - foreach (self::ROOT_CHORDS as $rootChord) { - $rootChordTable[] = $notation->convertChordRootFromNotation($rootChord); - } - $this->notationRoots[$notation::class] = $rootChordTable; - return $rootChordTable; - } - } - public function getRootChord(?ChordNotationInterface $targetNotation = null): string { if (!is_null($targetNotation)) { diff --git a/src/Formatter/Formatter.php b/src/Formatter/Formatter.php index 99eeae3..fdaca8a 100644 --- a/src/Formatter/Formatter.php +++ b/src/Formatter/Formatter.php @@ -8,7 +8,7 @@ abstract class Formatter { - protected ?ChordNotationInterface $notation; + protected ?ChordNotationInterface $notation = null; protected bool $noChords = false; /** @@ -21,6 +21,10 @@ abstract class Formatter */ public function setOptions(array $options): void { + $this->notation = null; + $this->noChords = false; + $this->ignoreMetadata = []; + if (isset($options['notation']) && $options['notation'] instanceof ChordNotationInterface) { $this->notation = $options['notation']; } diff --git a/src/Formatter/FormatterInterface.php b/src/Formatter/FormatterInterface.php index 08f316e..b4a5bf6 100644 --- a/src/Formatter/FormatterInterface.php +++ b/src/Formatter/FormatterInterface.php @@ -11,5 +11,5 @@ interface FormatterInterface /** * @param mixed[] $options */ - public function format(Song $song, array $options): string; + public function format(Song $song, array $options = []): string; } diff --git a/src/Formatter/HtmlFormatter.php b/src/Formatter/HtmlFormatter.php index 1c4ab71..4987caa 100644 --- a/src/Formatter/HtmlFormatter.php +++ b/src/Formatter/HtmlFormatter.php @@ -12,7 +12,7 @@ class HtmlFormatter extends Formatter implements FormatterInterface { - public function format(Song $song, array $options): string + public function format(Song $song, array $options = []): string { $this->setOptions($options); @@ -28,7 +28,7 @@ private function getLineHtml(Line $line): string if ($line instanceof Metadata) { return $this->getMetadataHtml($line); } elseif ($line instanceof EmptyLine) { - return '
'; + return "
\n"; } elseif ($line instanceof Lyrics) { return (true === $this->noChords) ? $this->getLyricsOnlyHtml($line) : $this->getLyricsHtml($line); } else { @@ -53,24 +53,24 @@ private function getMetadataHtml(Metadata $metadata): string } $match = []; - if (preg_match('/^start_of_(.*)/', $metadata->getName(), $match) !== false) { + if (preg_match('/^start_of_(.*)/', $metadata->getName(), $match) === 1) { $type = preg_replace('/[\W_\-]/', '', $match[1]); $content = ''; if (null !== $metadata->getValue()) { - $content = '
'.$metadata->getValue().'
'; + $content = '
'.$metadata->getValue()."
\n"; } - return $content.'
'; - } elseif (preg_match('/^end_of_(.*)/', $metadata->getName()) !== false) { - return '
'; + return $content.'
'."\n"; + } elseif (preg_match('/^end_of_(.*)/', $metadata->getName()) === 1) { + return "
\n"; } else { $name = preg_replace('/[\W_\-]/', '', mb_strtolower($metadata->getName())); - return '
'.$metadata->getValue().'
'; + return '
'.$metadata->getValue()."
\n"; } } private function getLyricsHtml(Lyrics $lyrics): string { - $verse = '
'; + $line = '
'."\n"; foreach ($lyrics->getBlocks() as $block) { $chords = []; @@ -78,7 +78,11 @@ private function getLyricsHtml(Lyrics $lyrics): string $slicedChords = $block->getChords(); foreach ($slicedChords as $slicedChord) { if ($slicedChord->isKnown()) { - $chords[] = $slicedChord->getRootChord($this->notation).''.$slicedChord->getExt($this->notation).''; + $ext = $slicedChord->getExt(); + if ($ext !== '') { + $ext = ''.$ext.''; + } + $chords[] = $slicedChord->getRootChord($this->notation).$ext; } else { $chords[] = $slicedChord->getOriginalName(); } @@ -87,22 +91,22 @@ private function getLyricsHtml(Lyrics $lyrics): string $chord = implode('/', $chords); $text = $this->blankChars($block->getText()); - $verse .= ' - '.$chord.' - '.$text.' - '; + $line .= '' . + ''.$chord.'' . + ''.$text.'' . + ''; } - $verse .= '
'; - return $verse; + $line .= "\n
\n"; + return $line; } private function getLyricsOnlyHtml(Lyrics $lyrics): string { - $verse = '
'; + $line = '
'."\n"; foreach ($lyrics->getBlocks() as $block) { - $verse .= ltrim($block->getText()); + $line .= ltrim($block->getText()); } - $verse .= '
'; - return $verse; + $line .= "\n
\n"; + return $line; } } diff --git a/src/Formatter/JSONFormatter.php b/src/Formatter/JSONFormatter.php index fb3ca1d..2b3e5f6 100644 --- a/src/Formatter/JSONFormatter.php +++ b/src/Formatter/JSONFormatter.php @@ -4,56 +4,61 @@ namespace ChordPro\Formatter; +use ChordPro\Line\Comment; +use ChordPro\Line\EmptyLine; use ChordPro\Line\Line; use ChordPro\Line\Lyrics; use ChordPro\Line\Metadata; use ChordPro\Song; +use PHP_CodeSniffer\Util\Common; class JSONFormatter extends Formatter implements FormatterInterface { - public function format(Song $song, array $options): string + public function format(Song $song, array $options = []): string { $this->setOptions($options); $json = []; foreach ($song->getLines() as $line) { - $json[] = $this->getLineJSON($line); + $jsonLine = $this->getLineJSON($line); + if (count($jsonLine) > 0) { + $json[] = $jsonLine; + } } return (string) json_encode($json, JSON_PRETTY_PRINT); } /** - * @return mixed + * @return mixed[] */ - private function getLineJSON(Line $line): mixed + private function getLineJSON(Line $line): array { if ($line instanceof Metadata) { return $this->getMetadataJSON($line); } elseif ($line instanceof Lyrics) { return (true === $this->noChords) ? $this->getLyricsOnlyJSON($line) : $this->getLyricsJSON($line); - } else { - return null; + } elseif ($line instanceof EmptyLine) { + return ['type' => 'empty_line',]; + } elseif ($line instanceof Comment) { + return ['type' => 'comment', 'content' => $line->getContent()]; } + return []; } /** * @return mixed[] */ - private function getMetadataJSON(Metadata $metadata): ?array + private function getMetadataJSON(Metadata $metadata): array { // Ignore some metadata. if (in_array($metadata->getName(), $this->ignoreMetadata, true)) { - return null; - } - - if (!is_null($metadata->getValue())) { - return [$metadata->getName()]; - } else { - switch($metadata->getName()) { - default: - return [$metadata->getName() => $metadata->getValue()]; - } + return []; } + return [ + 'type' => 'metadata', + 'name' => $metadata->getName(), + 'value' => $metadata->getValue(), + ]; } /** @@ -72,20 +77,29 @@ private function getLyricsJSON(Lyrics $lyrics): array $chords[] = $slicedChord->getOriginalName(); } } - $chord = implode('/', array_map("implode", $chords)).' '; + $chord = implode('/', $chords).' '; $text = $block->getText(); $return[] = ['chord' => trim($chord), 'text' => $text]; } - return $return; + return [ + 'type' => 'line', + 'blocks' => $return, + ]; } - private function getLyricsOnlyJSON(Lyrics $lyrics): string + /** + * @return string[] + */ + private function getLyricsOnlyJSON(Lyrics $lyrics): array { $return = ''; foreach ($lyrics->getBlocks() as $block) { $return .= ltrim($block->getText()); } - return $return; + return [ + 'type' => 'line', + 'text' => $return, + ]; } } diff --git a/src/Formatter/MonospaceFormatter.php b/src/Formatter/MonospaceFormatter.php index 74aa335..4cc2a51 100644 --- a/src/Formatter/MonospaceFormatter.php +++ b/src/Formatter/MonospaceFormatter.php @@ -12,7 +12,7 @@ class MonospaceFormatter extends Formatter implements FormatterInterface { - public function format(Song $song, array $options): string + public function format(Song $song, array $options = []): string { $this->setOptions($options); @@ -44,11 +44,11 @@ private function getMetadataMonospace(Metadata $metadata): string } $match = []; - if (preg_match('/^start_of_(.*)/', $metadata->getName(), $match) !== false) { + if (preg_match('/^start_of_(.*)/', $metadata->getName(), $match) === 1) { $content = (null !== $metadata->getValue()) ? $metadata->getValue()."\n" : mb_strtoupper($match[1]) . "\n"; return $content; - } elseif (preg_match('/^end_of_(.*)/', $metadata->getName()) !== false) { - return '\n'; + } elseif (preg_match('/^end_of_(.*)/', $metadata->getName()) === 1) { + return "\n"; } else { return $metadata->getValue()."\n"; } diff --git a/src/Line/Metadata.php b/src/Line/Metadata.php index 8a34636..fb71ccd 100644 --- a/src/Line/Metadata.php +++ b/src/Line/Metadata.php @@ -49,8 +49,8 @@ private function convertToFullName(string $name): string 'eob' => 'end_of_bridge', 'sot' => 'start_of_tab', 'eot' => 'end_of_tab', - 'sot' => 'start_of_grid', - 'eot' => 'end_of_grid', + 'sog' => 'start_of_grid', + 'eog' => 'end_of_grid', default => $name, }; } diff --git a/src/Notation/FrenchChordNotation.php b/src/Notation/FrenchChordNotation.php index a7e43ca..948e4c0 100644 --- a/src/Notation/FrenchChordNotation.php +++ b/src/Notation/FrenchChordNotation.php @@ -7,6 +7,35 @@ class FrenchChordNotation extends ChordNotation { public const ENGLISH_TO_FRENCH = [ + 'Abm' => 'La♭m', + 'Bbm' => 'Si♭m', + 'Dbm' => 'Ré♭m', + 'Ebm' => 'Mi♭m', + 'A#m' => 'La♯m', + 'C#m' => 'Do♯m', + 'D#m' => 'Ré♯m', + 'E#m' => 'Mi♯m', + 'F#m' => 'Fa♯m', + 'G#m' => 'Sol♯m', + 'Ab' => 'La♭', + 'Bb' => 'Si♭', + 'Cb' => 'Do♭', + 'Db' => 'Ré♭', + 'Eb' => 'Mi♭', + 'Fb' => 'Fa♭', + 'Gb' => 'Sol♭', + 'A#' => 'La♯', + 'C#' => 'Do♯', + 'D#' => 'Ré♯', + 'F#' => 'Fa♯', + 'G#' => 'Sol♯', + 'Am' => 'Lam', + 'Bm' => 'Sim', + 'Cm' => 'Dom', + 'Dm' => 'Rém', + 'Em' => 'Mim', + 'Fm' => 'Fam', + 'Gm' => 'Solm', 'A' => 'La', 'B' => 'Si', 'C' => 'Do', @@ -17,6 +46,57 @@ class FrenchChordNotation extends ChordNotation ]; public const FRENCH_TO_ENGLISH = [ + 'Sol♯m' => 'G#m', + 'Sol#m' => 'G#m', + 'La♭m' => 'Abm', + 'Si♭m' => 'Bbm', + 'Ré♭m' => 'Dbm', + 'Mi♭m' => 'Ebm', + 'Labm' => 'Abm', + 'Sibm' => 'Bbm', + 'Rébm' => 'Dbm', + 'Mibm' => 'Ebm', + 'La♯m' => 'A#m', + 'Do♯m' => 'C#m', + 'Ré♯m' => 'D#m', + 'Mi♯m' => 'E#m', + 'Fa♯m' => 'F#m', + 'La#m' => 'A#m', + 'Do#m' => 'C#m', + 'Ré#m' => 'D#m', + 'Mi#m' => 'E#m', + 'Fa#m' => 'F#m', + 'Sol♭' => 'Gb', + 'Solb' => 'Gb', + 'Sol♯' => 'G#', + 'Sol#' => 'G#', + 'Solm' => 'Gm', + 'La♭' => 'Ab', + 'Si♭' => 'Bb', + 'Do♭' => 'Cb', + 'Ré♭' => 'Db', + 'Mi♭' => 'Eb', + 'Fa♭' => 'Fb', + 'Lab' => 'Ab', + 'Sib' => 'Bb', + 'Dob' => 'Cb', + 'Réb' => 'Db', + 'Mib' => 'Eb', + 'Fab' => 'Fb', + 'La♯' => 'A#', + 'Do♯' => 'C#', + 'Ré♯' => 'D#', + 'Fa♯' => 'F#', + 'La#' => 'A#', + 'Do#' => 'C#', + 'Ré#' => 'D#', + 'Fa#' => 'F#', + 'Lam' => 'Am', + 'Sim' => 'Bm', + 'Dom' => 'Cm', + 'Rém' => 'Dm', + 'Mim' => 'Em', + 'Fam' => 'Fm', 'Sol' => 'G', 'La' => 'A', 'Si' => 'B', diff --git a/src/Notation/UtfChordNotation.php b/src/Notation/UtfChordNotation.php new file mode 100644 index 0000000..5163018 --- /dev/null +++ b/src/Notation/UtfChordNotation.php @@ -0,0 +1,69 @@ + 'A♭m', + 'Bbm' => 'B♭m', + 'Dbm' => 'D♭m', + 'Ebm' => 'E♭m', + 'A#m' => 'A♯m', + 'C#m' => 'C♯m', + 'D#m' => 'D♯m', + 'E#m' => 'E♯m', + 'F#m' => 'F♯m', + 'G#m' => 'G♯m', + 'Ab' => 'A♭', + 'Bb' => 'B♭', + 'Cb' => 'C♭', + 'Db' => 'D♭', + 'Eb' => 'E♭', + 'Fb' => 'F♭', + 'Gb' => 'G♭', + 'A#' => 'A♯', + 'C#' => 'C♯', + 'D#' => 'D♯', + 'F#' => 'F♯', + 'G#' => 'G♯', + ]; + + public const UTF_TO_ASCII = [ + 'A♭m' => 'Abm', + 'B♭m' => 'Bbm', + 'D♭m' => 'Dbm', + 'E♭m' => 'Ebm', + 'A♯m' => 'A#m', + 'C♯m' => 'C#m', + 'D♯m' => 'D#m', + 'E♯m' => 'E#m', + 'F♯m' => 'F#m', + 'G♯m' => 'G#m', + 'A♭' => 'Ab', + 'B♭' => 'Bb', + 'C♭' => 'Cb', + 'D♭' => 'Db', + 'E♭' => 'Eb', + 'F♭' => 'Fb', + 'G♭' => 'Gb', + 'A♯' => 'A#', + 'C♯' => 'C#', + 'D♯' => 'D#', + 'F♯' => 'F#', + 'G♯' => 'G#', + ]; + + protected function getToEnglishTable(): array + { + return self::UTF_TO_ASCII; + } + + protected function getFromEnglishTable(): array + { + return self::ASCII_TO_UTF; + } + +} diff --git a/src/Parser.php b/src/Parser.php index a6a3f91..7a1f9ed 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -12,7 +12,15 @@ class Parser { - public function parse(string $text, ?ChordNotationInterface $sourceNotation = null): Song + /** + * Parse the song text. + * + * @param string $text The song text to parse. + * @param ChordNotationInterface[] $sourceNotations The notations to use, ordered by precedence. + * + * @return Song + */ + public function parse(string $text, array $sourceNotations = []): Song { $lines = []; $split = preg_split('~\R~', $text); @@ -30,7 +38,7 @@ public function parse(string $text, ?ChordNotationInterface $sourceNotation = nu $lines[] = new EmptyLine(); break; default: - $lines[] = $this->parseLyrics($line, $sourceNotation); + $lines[] = $this->parseLyrics($line, $sourceNotations); } } } @@ -67,9 +75,11 @@ private function parseMetadata(string $line): Metadata * Parse a song line, assuming it contains lyrics. * * @param string $line A line of the song. + * @param ChordNotationInterface[] $sourceNotations The notations to use, ordered by precedence. + * * @return \ChordPro\Line\Lyrics The structured lyrics */ - private function parseLyrics(string $line, ?ChordNotationInterface $sourceNotation = null): Lyrics + private function parseLyrics(string $line, array $sourceNotations = []): Lyrics { $blocks = []; $explodedLine = explode('[', $line); @@ -80,7 +90,7 @@ private function parseLyrics(string $line, ?ChordNotationInterface $sourceNotati // If the fragment consists of only a chord without text. if (isset($chordWithText[1]) && $chordWithText[1] == '') { $blocks[] = new Block( - chords: Chord::fromSlice($chordWithText[0], $sourceNotation), + chords: Chord::fromSlice($chordWithText[0], $sourceNotations), text: '' ); } @@ -93,7 +103,7 @@ private function parseLyrics(string $line, ?ChordNotationInterface $sourceNotati // If there is a space after "]", threat it as separate blocks. } elseif (substr($chordWithText[1], 0, 1) == " ") { $blocks[] = new Block( - chords: Chord::fromSlice($chordWithText[0], $sourceNotation), + chords: Chord::fromSlice($chordWithText[0], $sourceNotations), text: '' ); $blocks[] = new Block( @@ -103,7 +113,7 @@ private function parseLyrics(string $line, ?ChordNotationInterface $sourceNotati // If there is no space after "]", threat it as chord with text. } else { $blocks[] = new Block( - chords: Chord::fromSlice($chordWithText[0], $sourceNotation), + chords: Chord::fromSlice($chordWithText[0], $sourceNotations), text: $chordWithText[1] ); } diff --git a/tests/Formatter/HtmlFormatterTest.php b/tests/Formatter/HtmlFormatterTest.php new file mode 100644 index 0000000..f01adeb --- /dev/null +++ b/tests/Formatter/HtmlFormatterTest.php @@ -0,0 +1,70 @@ +parse($text); + $formatter = new HtmlFormatter(); + $html = $formatter->format($song); + + $expected = '
Test
' . "\n" . + '
' . "\n" . + '
' . "\n" . + '
' . "\n" . + 'C7Test DTest2' . "\n" . + '
' . "\n" . + '
' . "\n"; + + $this->assertSame($expected, $html, 'HTML output is not as expected'); + } + + public function testWithoutChords(): void + { + $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new HtmlFormatter(); + $html = $formatter->format($song, [ + 'no_chords' => true + ]); + + $expected = '
Test
' . "\n" . + '
' . "\n" . + '
' . "\n" . + '
' . "\n" . + 'Test Test2' . "\n" . + '
' . "\n" . + '
' . "\n"; + + $this->assertSame($expected, $html, 'HTML output is not as expected'); + } + + public function testWithoutMetadata(): void + { + $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new HtmlFormatter(); + $html = $formatter->format($song, [ + 'ignore_metadata' => ['title'] + ]); + + $expected = '
' . "\n" . + '
' . "\n" . + '
' . "\n" . + 'C7Test DTest2' . "\n" . + '
' . "\n" . + '
' . "\n"; + + $this->assertSame($expected, $html, 'HTML output is not as expected'); + } +} diff --git a/tests/Formatter/JSONFormatterTest.php b/tests/Formatter/JSONFormatterTest.php new file mode 100644 index 0000000..94ba9b3 --- /dev/null +++ b/tests/Formatter/JSONFormatterTest.php @@ -0,0 +1,64 @@ +parse($text); + $formatter = new JSONFormatter(); + $json = $formatter->format($song); + + $result = json_decode($json, true); + $this->assertSame('metadata', $result[0]['type']); + $this->assertSame('title', $result[0]['name']); + $this->assertSame('Test', $result[0]['value']); + $this->assertSame('empty_line', $result[1]['type']); + $this->assertSame('metadata', $result[2]['type']); + $this->assertSame('start_of_verse', $result[2]['name']); + $this->assertNull($result[2]['value']); + $this->assertSame('line', $result[3]['type']); + $this->assertSame(2, count($result[3]['blocks'])); + $this->assertSame('metadata', $result[4]['type']); + $this->assertSame('end_of_verse', $result[4]['name']); + $this->assertNull($result[4]['value']); + $this->assertSame('comment', $result[5]['type']); + $this->assertSame('Comment', $result[5]['content']); + } + + public function testWithoutChords(): void + { + $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new JSONFormatter(); + $json = $formatter->format($song, [ + 'no_chords' => true + ]); + + $result = json_decode($json, true); + $this->assertSame('line', $result[3]['type']); + $this->assertSame('Test Test2', $result[3]['text']); + } + + public function textWithoutMetadata(): void + { + $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new JSONFormatter(); + $json = $formatter->format($song, [ + 'ignore_metadata' => ['title'] + ]); + + $result = json_decode($json, true); + $this->assertSame('empty_line', $result[0]['type']); + } +} diff --git a/tests/Formatter/MonospaceFormatterTest.php b/tests/Formatter/MonospaceFormatterTest.php new file mode 100644 index 0000000..df86cfe --- /dev/null +++ b/tests/Formatter/MonospaceFormatterTest.php @@ -0,0 +1,49 @@ +parse($text); + $formatter = new MonospaceFormatter(); + $monospace = $formatter->format($song); + $expected = "Test\n\nVERSE\nC7 D \nTest Test2\n\n"; + $this->assertSame($expected, $monospace, 'Monospace output is not as expected'); + } + + public function testWithoutChords(): void + { + $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new MonospaceFormatter(); + $monospace = $formatter->format($song, [ + 'no_chords' => true + ]); + $expected = "Test\n\nVERSE\nTest Test2\n\n"; + + $this->assertSame($expected, $monospace, 'Monospace output is not as expected'); + } + + public function testWithoutMetadata(): void + { + $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new MonospaceFormatter(); + $monospace = $formatter->format($song, [ + 'ignore_metadata' => ['title'] + ]); + $expected = "\nVERSE\nC7 D \nTest Test2\n\n"; + + $this->assertSame($expected, $monospace, 'Monospace output is not as expected'); + } +} diff --git a/tests/Line/CommentTest.php b/tests/Line/CommentTest.php new file mode 100644 index 0000000..a91b368 --- /dev/null +++ b/tests/Line/CommentTest.php @@ -0,0 +1,15 @@ +assertEquals('Test Comment', $comment->getContent()); + } +} diff --git a/tests/Line/LyricsTest.php b/tests/Line/LyricsTest.php new file mode 100644 index 0000000..ee3c76b --- /dev/null +++ b/tests/Line/LyricsTest.php @@ -0,0 +1,21 @@ +assertSame([$block], $lyrics->getBlocks(), 'Blocks are not returned'); + } +} diff --git a/tests/Line/MetadataTest.php b/tests/Line/MetadataTest.php new file mode 100644 index 0000000..b5bd1a4 --- /dev/null +++ b/tests/Line/MetadataTest.php @@ -0,0 +1,50 @@ +assertEquals('Test Name', $metadata->getName()); + $this->assertEquals('Test Value', $metadata->getValue()); + + $metadata2 = new Metadata('Test Name', null); + $this->assertEquals('Test Name', $metadata2->getName()); + $this->assertNull($metadata2->getValue()); + } + + public static function shortcutProvider(): array + { + return [ + ['t', 'title'], + ['st', 'subtitle'], + ['c', 'comment'], + ['ci', 'comment_italic'], + ['cb', 'comment_box'], + ['soc', 'start_of_chorus'], + ['eoc', 'end_of_chorus'], + ['sov', 'start_of_verse'], + ['eov', 'end_of_verse'], + ['sob', 'start_of_bridge'], + ['eob', 'end_of_bridge'], + ['sot', 'start_of_tab'], + ['eot', 'end_of_tab'], + ['sog', 'start_of_grid'], + ['eog', 'end_of_grid'], + ]; + } + + #[DataProvider('shortcutProvider')] + public function testShortcuts($shortName, $longName): void + { + $metadata = new Metadata($shortName, 'Test Value'); + $this->assertEquals($longName, $metadata->getName()); + + } +} diff --git a/tests/Notation/FrenchChordNotationTest.php b/tests/Notation/FrenchChordNotationTest.php new file mode 100644 index 0000000..22cc613 --- /dev/null +++ b/tests/Notation/FrenchChordNotationTest.php @@ -0,0 +1,146 @@ +parse($text, [$notation]); + $lines = $song->getLines(); + $firstLine = $lines[0]; + assert($firstLine instanceof Lyrics); + $this->assertSame($targetChord, $firstLine->getBlocks()[0]->getChords()[0]->getRootChord(), 'Notation is not parsed correctly'); + } + + #[DataProvider('formatChordProvider')] + public function testFormat(string $sourceChord, string $targetChord): void + { + $text = "[$sourceChord]Test"; + $notation = new FrenchChordNotation(); + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new HtmlFormatter(); + $html = $formatter->format($song, [ + 'notation' => $notation, + ]); + $this->assertStringContainsString($targetChord, $html, 'Notation is not formatted correctly'); + } +} diff --git a/tests/Notation/GermanChordNotationTest.php b/tests/Notation/GermanChordNotationTest.php new file mode 100644 index 0000000..7086d8b --- /dev/null +++ b/tests/Notation/GermanChordNotationTest.php @@ -0,0 +1,145 @@ +parse($text, [$notation]); + $lines = $song->getLines(); + $firstLine = $lines[0]; + assert($firstLine instanceof Lyrics); + $this->assertSame($targetChord, $firstLine->getBlocks()[0]->getChords()[0]->getRootChord(), 'Notation is not parsed correctly'); + } + + #[DataProvider('formatChordProvider')] + public function testFormat(string $sourceChord, string $targetChord): void + { + $text = "[$sourceChord]Test"; + $notation = new GermanChordNotation(); + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new HtmlFormatter(); + $html = $formatter->format($song, [ + 'notation' => $notation, + ]); + $this->assertStringContainsString($targetChord, $html, 'Notation is not formatted correctly'); + } +} diff --git a/tests/Notation/UtfChordNotationTest.php b/tests/Notation/UtfChordNotationTest.php new file mode 100644 index 0000000..5e83725 --- /dev/null +++ b/tests/Notation/UtfChordNotationTest.php @@ -0,0 +1,96 @@ +parse($text, [$notation]); + $lines = $song->getLines(); + $firstLine = $lines[0]; + assert($firstLine instanceof Lyrics); + $this->assertSame($targetChord, $firstLine->getBlocks()[0]->getChords()[0]->getRootChord(), 'Notation is not parsed correctly'); + } + + #[DataProvider('formatChordProvider')] + public function testFormat(string $sourceChord, string $targetChord): void + { + $text = "[$sourceChord]Test"; + $notation = new UtfChordNotation(); + $parser = new Parser(); + $song = $parser->parse($text); + $formatter = new HtmlFormatter(); + $html = $formatter->format($song, [ + 'notation' => $notation, + ]); + $this->assertStringContainsString($targetChord, $html, 'Notation is not formatted correctly'); + } +} diff --git a/web/example.css b/web/example.css index ac30e15..4192e3e 100644 --- a/web/example.css +++ b/web/example.css @@ -2,7 +2,8 @@ body { font-family: Helvetica; } -/* Metadatas */ +/* Metadata */ + .chordpro-title { font-size: 2em; font-weight: bold; @@ -27,21 +28,21 @@ body { margin: 1em 0; } -/* Verses & Chorus */ +/* Lines & Chorus */ -body.chordpro-verse:first-of-type { +body.chordpro-line:first-of-type { border-top: 1px solid #000; padding-top: 1em; margin-top: 1em; } -.chordpro-verse { +.chordpro-line { height: 2.5em; } .chordpro-chorus { padding-left: 10px; border-left: 4px solid #777; } -.chordpro-elem { +.chordpro-block { position: relative; display: inline-block; } diff --git a/web/example.php b/web/example.php index 72d223a..705d8fd 100644 --- a/web/example.php +++ b/web/example.php @@ -1,12 +1,14 @@ parse($txt); -//$guess = new ChordPro\GuessKey(); -//$key = $guess->guessKey($song); +// Format it! +$html = $htmlFormatter->format($song); +$monospaced = $monospaceFormatter->format($song); +$json = $jsonFormatter->format($song); -$transposer = new ChordPro\Transposer(); -//$transposer->transpose($song,'Dm'); +// Change notation! +$frenchNotation = new ChordPro\Notation\FrenchChordNotation(); +$html_french = $htmlFormatter->format($song, ['notation' => $frenchNotation]); -$options = array('french' => false, 'no_chords' => false); -$txt_html = $html->format($song,$options); -$txt = $monospace->format($song,$options); -$txt_json = $json->format($song,$options); +// Transpose it! +$transposer = new ChordPro\Transposer(); +$transposer->transpose($song, 2); +$utfNotation = new ChordPro\Notation\UtfChordNotation(); +$html_transposed = $htmlFormatter->format($song, ['notation' => $utfNotation]); ?> - + ChordPro PHP +

HTML

- -

Plain text

- '.$txt.''; ?> + +

HTML - French Notation

+ +

HTML - Transposed +2

+ +

Monospaced

+

JSON

+