From aae87e21837305cbc7fba0efc012c5d87e865c58 Mon Sep 17 00:00:00 2001 From: Grzegorz Pietrzak Date: Wed, 17 Jan 2024 16:59:21 +0100 Subject: [PATCH 1/3] Bugfixes and new functionalities Fixed bug with `preg_match` breaking UTF strings. Introduced additional classes for metadata in the HTML formatter. Introduced metadata names in the HTMLFormatter, like `Key: C` instead of just `C`. This does not apply to common metadata like "title". Re-styled metadata in the example.css. Removed chord span when there is only text in the line. Removed text span when there are only chords in the line. Added support for inline chords (Song Lyrics ~ [Am] [F]). JSON formatter supports sections instead of threating them as metadata. --- README.md | 81 +++++--- src/Block.php | 7 +- src/Formatter/HtmlFormatter.php | 89 +++++++-- src/Formatter/JSONFormatter.php | 62 ++++-- src/Formatter/MonospaceFormatter.php | 79 ++++++-- src/Line/Lyrics.php | 17 +- src/Line/Metadata.php | 86 ++++++++ src/Parser.php | 99 ++++++++-- tests/Formatter/HtmlFormatterTest.php | 44 ++--- tests/Formatter/JSONFormatterTest.php | 46 ++--- tests/Formatter/MonospaceFormatterTest.php | 23 ++- tests/ParserTest.php | 4 +- tests/data/song1.pro | 2 +- tests/data/song2.html | 47 +++++ tests/data/song2.json | 216 +++++++++++++++++++++ tests/data/song2.pro | 23 +++ tests/data/song2.txt | 25 +++ tests/data/song2_no_chords.html | 38 ++++ tests/data/song2_no_chords.json | 89 +++++++++ tests/data/song2_no_chords.txt | 17 ++ tests/data/song2_no_metadata.html | 46 +++++ tests/data/song2_no_metadata.json | 210 ++++++++++++++++++++ tests/data/song2_no_metadata.txt | 24 +++ web/example.css | 47 ++--- web/example.php | 42 ++-- web/example.png | Bin 13484 -> 25412 bytes 26 files changed, 1278 insertions(+), 185 deletions(-) create mode 100644 tests/data/song2.html create mode 100644 tests/data/song2.json create mode 100644 tests/data/song2.pro create mode 100644 tests/data/song2.txt create mode 100644 tests/data/song2_no_chords.html create mode 100644 tests/data/song2_no_chords.json create mode 100644 tests/data/song2_no_chords.txt create mode 100644 tests/data/song2_no_metadata.html create mode 100644 tests/data/song2_no_metadata.json create mode 100644 tests/data/song2_no_metadata.txt diff --git a/README.md b/README.md index 99ff6bf..52bc292 100644 --- a/README.md +++ b/README.md @@ -38,32 +38,45 @@ See [web/example.php](web/example.php) for demo with CSS styling. require __DIR__ . '/vendor/autoload.php'; -$txt = "{t:Chordpro-PHP Song} -{st:Nicolas Wurtz} -{c:GPL3 2019 Nicolas Wurtz} +$txt = "{t:A Nice Sample Song} +{st:Grzegorz Pietrzak} {key:C} # Let's start it! -[C]This is the [Dm]beautiful [Em]song -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} -[F] [G] [C]This is the refrain -[F] [G] [C]We could sing it twice +[C]Let's sing this [G]song [Am]together [Em]aloud +[F]It's an [C]example [Dm]with some nice [G]sound + +{soc: Chorus} +[Bb]Whenever you [Am7]need to [Bb]format your [Am7]chords +[Dm]The solution to your [F]problems [G]is very close {eoc} -{c:Final} -[Em/D]This is the [Bb]end. -"; +{comment: Now we recite some text} +Sometimes you write text +And there's no more room for chords + +{comment: Sometimes you play music without any words} +[C] [G] [Am] [Em] + +You don't know where the chords are? ~ [F] [C] +You don't have to know ~ [G] [G/F#] + +{sot: Outro} +E-12---------------------| +B----11-12---------------| +G----------11s13-14------| +D-------------------10-12| +A------------------------| +E------------------------| +{eot} + +{comment: The end} +Let's finish this song. [G] It's the end of the show."; $parser = new ChordPro\Parser(); // 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. $song = $parser->parse($txt); @@ -72,20 +85,18 @@ $song = $parser->parse($txt); $transposer = new ChordPro\Transposer(); // Define how many semitones you want to transpose by. -$transposer->transpose($song, -5); +// $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', + 'ignore_metadata' => 'copyright', ]; // Render ! $html = $htmlFormatter->format($song, $options); -$monospaced = $monospaceFormatter->format($song, $options); -$json = $jsonFormatter->format($song, $options); ``` ![Screenshot](web/example.png) @@ -189,12 +200,12 @@ The ChordPro format allows to organize your songs into sections. The following s Will be converted to: ```html -
Verse 1
-
+ +
...
-
+
...
``` @@ -219,13 +230,18 @@ The library reads ChordPro metadata and renders it as HTML in the following way: ```chordpro {title: Let's Sing!} {c: Very loud} +{key: C} ``` Becomes: ``` html -
Let's Sing!
-
Very loud
+ + + ``` 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: @@ -235,3 +251,18 @@ The names of metadata are not restricted in any way, however, there are some sta - `{c}` → `{comment}` - `{ci}` → `{comment_italic}` - `{cb}` → `{comment_box}` + +## Extensions to ChordPro 6.x + +The library provides some non-standard features that can be useful for songbook creators. + +### Inline chords + +If you don't know how to assign the chord to syllables, or it's not important to you, you can use the inline chords assigned to the lyric line: + +```chordpro +You don't know where the chords are? ~ [F] [C] +You don't have to know ~ [G] [G/F#] +``` + +The chords appear above the line (in the HTML formatter) or to the right (in the monospace formatter). diff --git a/src/Block.php b/src/Block.php index 833f6d7..69e8cbd 100644 --- a/src/Block.php +++ b/src/Block.php @@ -11,7 +11,7 @@ class Block /** * @param Chord[] $chords The chords. */ - public function __construct(private array $chords, private string $text) + public function __construct(private array $chords, private string $text, private bool $lineEnd = false) { } @@ -29,4 +29,9 @@ public function getText(): string { return $this->text; } + + public function isLineEnd(): bool + { + return $this->lineEnd; + } } diff --git a/src/Formatter/HtmlFormatter.php b/src/Formatter/HtmlFormatter.php index 29fe460..8821742 100644 --- a/src/Formatter/HtmlFormatter.php +++ b/src/Formatter/HtmlFormatter.php @@ -52,26 +52,50 @@ private function getMetadataHtml(Metadata $metadata): string return ''; } - $match = []; - if (preg_match('/^start_of_(.*)/', $metadata->getName(), $match) === 1) { - $type = preg_replace('/[\W_\-]/', '', $match[1]); + if ($metadata->isSectionStart()) { + $type = $metadata->getSectionType(); $content = ''; if (null !== $metadata->getValue()) { - $content = '
'.$metadata->getValue()."
\n"; + $content = '\n"; } - return $content.'
'."\n"; - } elseif (preg_match('/^end_of_(.*)/', $metadata->getName()) === 1) { + return $content.'
'."\n"; + } elseif ($metadata->isSectionEnd()) { return "
\n"; } else { - $name = preg_replace('/[\W_\-]/', '', mb_strtolower($metadata->getName())); - return '
'.$metadata->getValue()."
\n"; + if ($metadata->getName() === 'key' && null != $this->notation) { + $value = $this->notation->convertChordRootToNotation($metadata->getValue() ?? ''); + } else { + $value = $metadata->getValue(); + } + $type = $metadata->getNameSlug(); + $output = '\n"; + return $output; } } private function getLyricsHtml(Lyrics $lyrics): string { - $line = '
'."\n"; - foreach ($lyrics->getBlocks() as $block) { + $classes = ['chordpro-line']; + if ($lyrics->hasInlineChords()) { + $classes[] = 'chordpro-line-inline-chords'; + } + if (!$lyrics->hasChords()) { + $classes[] = 'chordpro-line-text-only'; + } + if (!$lyrics->hasText()) { + $classes[] = 'chordpro-line-chords-only'; + } + $line = '
'."\n"; + $lineChords = ''; + $lineText = ''; + foreach ($lyrics->getBlocks() as $num => $block) { $originalChords = []; $chords = []; @@ -96,22 +120,51 @@ private function getLyricsHtml(Lyrics $lyrics): string $originalChord = implode('/', $originalChords); $text = $this->blankChars($block->getText()); - $line .= '' . - ''.$chord.'' . - ''.$text.'' . - ''; + if ($lyrics->hasInlineChords()) { + if ($num === 0) { + $lineText = '
'.$text.'
'; + } else { + $lineChords .= ''.$chord.''; + } + } elseif ($lyrics->hasChords() && $lyrics->hasText()) { + $line .= '' . + ''.$chord.'' . + ''.$text.'' . + ''; + } elseif ($lyrics->hasChords()) { + $line .= '' . + ''.$chord.'' . + ''; + + } elseif ($lyrics->hasText()) { + $line .= '' . + ''.$text.'' . + ''; + } } + + if ($lyrics->hasInlineChords()) { + $line .= '
'; + $line .= '
' . $lineChords . '
'; + $line .= $lineText; + $line .= '
'; + } + $line .= "\n
\n"; return $line; } private function getLyricsOnlyHtml(Lyrics $lyrics): string { - $line = '
'."\n"; + $text = ''; foreach ($lyrics->getBlocks() as $block) { - $line .= ltrim($block->getText()); + $text .= ltrim($block->getText()); + } + $text = rtrim($text); + if ($text === '') { + return ''; + } else { + return '
'."\n".rtrim($text)."\n
\n"; } - $line .= "\n
\n"; - return $line; } } diff --git a/src/Formatter/JSONFormatter.php b/src/Formatter/JSONFormatter.php index 186cc87..dfc3dc3 100644 --- a/src/Formatter/JSONFormatter.php +++ b/src/Formatter/JSONFormatter.php @@ -54,11 +54,32 @@ private function getMetadataJSON(Metadata $metadata): array if (in_array($metadata->getName(), $this->ignoreMetadata, true)) { return []; } - return [ + + if ($metadata->isSectionStart()) { + $metadataItem = [ + 'type' => 'section_start', + 'sectionType' => $metadata->getSectionType(), + ]; + if (null !== $metadata->getValue()) { + $metadataItem['label'] = $metadata->getValue(); + } + } elseif ($metadata->isSectionEnd()) { + $metadataItem = [ + 'type' => 'section_end', + 'sectionType' => $metadata->getSectionType(), + ]; + } else { + $metadataItem = [ 'type' => 'metadata', 'name' => $metadata->getName(), 'value' => $metadata->getValue(), - ]; + ]; + if ($metadata->getHumanName() != $metadata->getName()) { + $metadataItem['humanName'] = $metadata->getHumanName(); + } + } + + return $metadataItem; } /** @@ -80,14 +101,26 @@ private function getLyricsJSON(Lyrics $lyrics): array $originalChords[] = $slicedChord->getOriginalName(); } } - $chord = implode('/', $chords).' '; - $originalChord = implode('/', $originalChords).' '; + $chord = implode('/', $chords); + $originalChord = implode('/', $originalChords); $text = $block->getText(); - $return[] = ['chord' => trim($chord), 'text' => $text, 'originalChord' => trim($originalChord)]; + $blockArray = []; + if ($text !== '') { + $blockArray['text'] = rtrim($text); + } + $chord = trim($chord); + if ($chord !== '') { + $blockArray['chord'] = $chord; + $blockArray['originalChord'] = $originalChord; + } + if ($block->isLineEnd()) { + $blockArray['lineEnd'] = true; + } + $return[] = $blockArray; } return [ - 'type' => 'line', + 'type' => $lyrics->hasInlineChords() ? 'line_inline' : 'line', 'blocks' => $return, ]; } @@ -97,13 +130,18 @@ private function getLyricsJSON(Lyrics $lyrics): array */ private function getLyricsOnlyJSON(Lyrics $lyrics): array { - $return = ''; + $text = ''; foreach ($lyrics->getBlocks() as $block) { - $return .= ltrim($block->getText()); + $text .= ltrim($block->getText()); + } + $text = rtrim($text); + if ($text === '') { + return []; + } else { + return [ + 'type' => 'line', + 'text' => $text, + ]; } - return [ - 'type' => 'line', - 'text' => $return, - ]; } } diff --git a/src/Formatter/MonospaceFormatter.php b/src/Formatter/MonospaceFormatter.php index 4cc2a51..5cb233b 100644 --- a/src/Formatter/MonospaceFormatter.php +++ b/src/Formatter/MonospaceFormatter.php @@ -16,11 +16,13 @@ public function format(Song $song, array $options = []): string { $this->setOptions($options); - $monospace = ''; + $lines = []; foreach ($song->getLines() as $line) { - $monospace .= $this->getLineMonospace($line); + $lines[] = $this->getLineMonospace($line); } - return $monospace; + + $this->transformInlineChords($lines); + return implode("", $lines); } private function getLineMonospace(Line $line): string @@ -43,14 +45,18 @@ private function getMetadataMonospace(Metadata $metadata): string return ''; } - $match = []; - if (preg_match('/^start_of_(.*)/', $metadata->getName(), $match) === 1) { - $content = (null !== $metadata->getValue()) ? $metadata->getValue()."\n" : mb_strtoupper($match[1]) . "\n"; + if ($metadata->isSectionStart()) { + $type = $metadata->getSectionType(); + $content = (null !== $metadata->getValue()) ? mb_strtoupper($metadata->getValue())."\n" : mb_strtoupper($type) . "\n"; return $content; - } elseif (preg_match('/^end_of_(.*)/', $metadata->getName()) === 1) { - return "\n"; + } elseif ($metadata->isSectionEnd()) { + return ''; } else { - return $metadata->getValue()."\n"; + if ($metadata->isNameNecessary()) { + return $metadata->getHumanName().': '.$metadata->getValue()."\n"; + } else { + return $metadata->getValue()."\n"; + } } } @@ -69,8 +75,10 @@ private function generateBlank(int $count): string private function getLyricsMonospace(Lyrics $lyrics): string { - $lineChords = ''; + $lineChords = []; + $lineChordsWithBlanks = ''; $lineTexts = ''; + $lineTextsWithBlanks = ''; foreach ($lyrics->getBlocks() as $block) { $chords = []; @@ -85,16 +93,59 @@ private function getLyricsMonospace(Lyrics $lyrics): string $chord = implode('/', $chords); $text = $block->getText(); + $textWithBlanks = $text; if (mb_strlen($text) < mb_strlen($chord)) { - $text = $text.$this->generateBlank(mb_strlen($chord) - mb_strlen($text)); + $textWithBlanks = $text.$this->generateBlank(mb_strlen($chord) - mb_strlen($text)); } - $lineChords .= $chord.$this->generateBlank(mb_strlen($text) - mb_strlen($chord)); + $lineChordsWithBlanks .= $chord.$this->generateBlank(mb_strlen($text) - mb_strlen($chord)); + $lineChords[] = $chord; $lineTexts .= $text; + $lineTextsWithBlanks .= $textWithBlanks; } - return $lineChords."\n".$lineTexts."\n"; + $output = ''; + if ($lyrics->hasInlineChords()) { + $output .= '~'.$lineTexts."~".implode(' ', $lineChords)."\n"; + } else { + if ($lyrics->hasChords() && $lyrics->hasText()) { + $output .= $lineChordsWithBlanks."\n"; + $output .= $lineTextsWithBlanks."\n"; + } elseif ($lyrics->hasChords()) { + $output .= implode(' ', $lineChords)."\n"; + } elseif ($lyrics->hasText()) { + $output .= $lineTexts."\n"; + } + } + return $output; + } + + /** + * @param string[] $lines + */ + private function transformInlineChords(array &$lines): void + { + $linesToFix = []; + $longest = 0; + foreach ($lines as $num => $line) { + $match = []; + if (preg_match('/^~(.+)~(.+)/', $line, $match) === 1) { + $linesToFix[$num] = [ + 'text' => trim($match[1]), + 'chords' => trim($match[2]), + ]; + if (mb_strlen($match[1]) > $longest) { + $longest = mb_strlen($match[1]); + } + } + } + + $inlineChordPosition = $longest + 4; + foreach ($linesToFix as $num => $lineToFix) { + $lines[$num] = $lineToFix['text'].$this->generateBlank($inlineChordPosition - mb_strlen($lineToFix['text'])); + $lines[$num] .= $lineToFix['chords']."\n"; + } } private function getLyricsOnlyMonospace(Lyrics $lyrics): string @@ -103,6 +154,6 @@ private function getLyricsOnlyMonospace(Lyrics $lyrics): string foreach ($lyrics->getBlocks() as $block) { $texts .= ltrim($block->getText()); } - return $texts."\n"; + return ($texts !== '') ? rtrim($texts)."\n" : ''; } } diff --git a/src/Line/Lyrics.php b/src/Line/Lyrics.php index 7e8ad79..7bb793f 100644 --- a/src/Line/Lyrics.php +++ b/src/Line/Lyrics.php @@ -12,7 +12,7 @@ class Lyrics extends Line /** * @param \ChordPro\Block[] $blocks The blocks of the line. */ - public function __construct(private array $blocks) + public function __construct(private array $blocks = [], private bool $hasChords = false, private bool $hasText = false, private bool $hasInlineChords = false) { } @@ -25,4 +25,19 @@ public function getBlocks(): array { return $this->blocks; } + + public function hasChords(): bool + { + return $this->hasChords; + } + + public function hasText(): bool + { + return $this->hasText; + } + + public function hasInlineChords(): bool + { + return $this->hasInlineChords; + } } diff --git a/src/Line/Metadata.php b/src/Line/Metadata.php index fb71ccd..f113143 100644 --- a/src/Line/Metadata.php +++ b/src/Line/Metadata.php @@ -4,6 +4,8 @@ namespace ChordPro\Line; +use PhpParser\Node\Expr\BinaryOp\BooleanOr; + /** * A class that represents a metadata line in a song. */ @@ -21,6 +23,11 @@ public function getName(): string return $this->name; } + public function getNameSlug(): string + { + return $this->slugify($this->name); + } + public function getValue(): ?string { return $this->value; @@ -54,4 +61,83 @@ private function convertToFullName(string $name): string default => $name, }; } + + /** + * Get the human readable name of the metadata. + */ + public function getHumanName(): string + { + return match($this->name) { + 'title' => 'Title', + 'subtitle' => 'Subtitle', + 'sorttitle' => 'Sort title', + 'comment' => 'Comment', + 'comment_italic' => 'Comment', + 'comment_box' => 'Comment', + 'key' => 'Key', + 'time' => 'Time', + 'tempo' => 'Tempo', + 'duration' => 'Duration', + 'capo' => 'Capo', + 'artist' => 'Artist', + 'composer' => 'Composer', + 'lyricist' => 'Lyricist', + 'album' => 'Album', + 'year' => 'Year', + 'copyright' => 'Copyright', + 'meta' => 'Meta', + default => $this->name, + }; + } + + public function isNameNecessary(): bool + { + return match($this->name) { + 'title' => false, + 'subtitle' => false, + 'comment' => false, + 'comment_italic' => false, + 'comment_box' => false, + 'artist' => false, + default => true, + }; + } + + public function isSectionStart(): bool + { + return str_starts_with($this->name, 'start_of_'); + } + + public function isSectionEnd(): bool + { + return str_starts_with($this->name, 'end_of_'); + } + + public function getSectionType(): string + { + $match = []; + if (preg_match('/^start_of_(.*)/', $this->name, $match) === 1) { + return $this->slugify($match[1]); + } elseif (preg_match('/^end_of_(.*)/', $this->name, $match) === 1) { + return $this->slugify($match[1]); + } else { + return ''; + } + } + + private function slugify(string $text): string + { + $text = preg_replace('~[^\pL\d]+~u', '-', $text); + $text = iconv('utf-8', 'us-ascii//TRANSLIT', (string) $text); + $text = preg_replace('~[^-\w]+~', '', (string) $text); + $text = trim((string) $text, '-'); + $text = preg_replace('~-+~', '-', $text); + $text = strtolower((string) $text); + + if ($text === '') { + return 'n-a'; + } + return $text; + } + } diff --git a/src/Parser.php b/src/Parser.php index 7a1f9ed..6865d2b 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -23,22 +23,24 @@ class Parser public function parse(string $text, array $sourceNotations = []): Song { $lines = []; - $split = preg_split('~\R~', $text); + $split = preg_split("/\\r\\n|\\r|\\n/", $text); if ($split !== false) { foreach ($split as $line) { - $line = trim($line); - switch (substr($line, 0, 1)) { + $line = preg_replace('/^\s+/', '', $line); + $line = preg_replace('/\s+$/', '', (string) $line); + + switch (substr((string) $line, 0, 1)) { case "{": - $lines[] = $this->parseMetadata($line); + $lines[] = $this->parseMetadata((string) $line); break; case "#": - $lines[] = new Comment(trim(substr($line, 1))); + $lines[] = new Comment(trim(substr((string) $line, 1))); break; case "": $lines[] = new EmptyLine(); break; default: - $lines[] = $this->parseLyrics($line, $sourceNotations); + $lines[] = $this->parseLyrics((string) $line, $sourceNotations); } } } @@ -82,6 +84,40 @@ private function parseMetadata(string $line): Metadata private function parseLyrics(string $line, array $sourceNotations = []): Lyrics { $blocks = []; + $possiblyEmptyBlocks = []; + + $hasText = false; + $hasChords = false; + + // First, check for ~ symbol. + $match = []; + if (preg_match('/^([^\[\]]+)~(.+)/', $line, $match) === 1) { + $matchChords = []; + $lineText = $match[1]; + $result = preg_match_all('/\[([^\[\]]+)\]/', $match[2], $matchChords); + if (is_numeric($result) && $result > 0) { + $lineChords = $matchChords[1]; + $blocks[] = new Block( + chords: [], + text: trim($lineText) + ); + foreach ($lineChords as $chord) { + $blocks[] = new Block( + chords: Chord::fromSlice($chord, $sourceNotations), + text: '', + lineEnd: true + ); + } + return new Lyrics( + blocks: $blocks, + hasInlineChords: true, + hasChords: true, + hasText: true, + ); + } + + } + $explodedLine = explode('[', $line); foreach($explodedLine as $num => $lineFragment) { if ($lineFragment !== '') { @@ -89,19 +125,28 @@ private function parseLyrics(string $line, array $sourceNotations = []): Lyrics // If the fragment consists of only a chord without text. if (isset($chordWithText[1]) && $chordWithText[1] == '') { + $hasChords = true; $blocks[] = new Block( chords: Chord::fromSlice($chordWithText[0], $sourceNotations), text: '' ); } - // If first line begins with text and not a chord. + // If first block begins with text and not a chord. elseif ($num == 0 && count($chordWithText) == 1) { $blocks[] = new Block( chords: [], text: $chordWithText[0], ); - // If there is a space after "]", threat it as separate blocks. - } elseif (substr($chordWithText[1], 0, 1) == " ") { + if (preg_match('/\S/', $chordWithText[0]) === 1) { + $hasText = true; + } else { + // Save the block as possibly empty, so we can remove it later. + $possiblyEmptyBlocks[] = array_key_last($blocks); + } + + } elseif (isset($chordWithText[1]) && substr($chordWithText[1], 0, 1) == " ") { + // If there is a space after "]", threat it as two separate blocks. + $hasChords = true; $blocks[] = new Block( chords: Chord::fromSlice($chordWithText[0], $sourceNotations), text: '' @@ -110,15 +155,45 @@ private function parseLyrics(string $line, array $sourceNotations = []): Lyrics chords: [], text: $chordWithText[1] ); - // If there is no space after "]", threat it as chord with text. + if (preg_match('/\S/', $chordWithText[1]) === 1) { + $hasText = true; + } else { + // Save the block as possibly empty, so we can remove it later. + $possiblyEmptyBlocks[] = array_key_last($blocks); + } + } else { + // If there is no space after "]", threat it as chord with text. + $hasChords = true; $blocks[] = new Block( chords: Chord::fromSlice($chordWithText[0], $sourceNotations), - text: $chordWithText[1] + text: $chordWithText[1] ?? '' ); + if (preg_match('/\S/', $chordWithText[1] ?? '') === 1) { + $hasText = true; + } else { + // Save the block as possibly empty, so we can remove it later. + $possiblyEmptyBlocks[] = array_key_last($blocks); + } + } } } - return new Lyrics($blocks); + + // If there are only chords and no text, set text to empty string. + if ($hasChords && !$hasText) { + foreach ($possiblyEmptyBlocks as $blockKey) { + unset($blocks[$blockKey]); + } + } elseif (!$hasChords && !$hasText) { + $blocks = []; + } + + return new Lyrics( + blocks: $blocks, + hasInlineChords: false, + hasChords: $hasChords, + hasText: $hasText, + ); } } diff --git a/tests/Formatter/HtmlFormatterTest.php b/tests/Formatter/HtmlFormatterTest.php index 0b47f38..1e72212 100644 --- a/tests/Formatter/HtmlFormatterTest.php +++ b/tests/Formatter/HtmlFormatterTest.php @@ -10,61 +10,47 @@ final class HtmlFormatterTest extends TestCase { public function testWithChords(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $parser = new Parser(); $song = $parser->parse($text); $formatter = new HtmlFormatter(); $html = $formatter->format($song); - - $expected = '
Test
' . "\n" . - '
' . "\n" . - '
' . "\n" . - '
' . "\n" . - 'C7Test DTest2' . "\n" . - '
' . "\n" . - '
' . "\n"; - + $expected = file_get_contents(__DIR__ . '/../data/song2.html'); $this->assertSame($expected, $html, 'HTML output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $html); + } } public function testWithoutChords(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $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"; - + $expected = file_get_contents(__DIR__ . '/../data/song2_no_chords.html'); $this->assertSame($expected, $html, 'HTML output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $html); + } } public function testWithoutMetadata(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $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"; - + $expected = file_get_contents(__DIR__ . '/../data/song2_no_metadata.html'); $this->assertSame($expected, $html, 'HTML output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $html); + } } } diff --git a/tests/Formatter/JSONFormatterTest.php b/tests/Formatter/JSONFormatterTest.php index 94ba9b3..84ab737 100644 --- a/tests/Formatter/JSONFormatterTest.php +++ b/tests/Formatter/JSONFormatterTest.php @@ -10,55 +10,47 @@ final class JSONFormatterTest extends TestCase { public function testWithChords(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $parser = new Parser(); $song = $parser->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']); + $expected = file_get_contents(__DIR__ . '/../data/song2.json'); + $this->assertSame($expected, $json, 'JSON output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $json); + } } public function testWithoutChords(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $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']); + $expected = file_get_contents(__DIR__ . '/../data/song2_no_chords.json'); + $this->assertSame($expected, $json, 'JSON output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $json); + } } - public function textWithoutMetadata(): void + public function testWithoutMetadata(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $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']); + $expected = file_get_contents(__DIR__ . '/../data/song2_no_metadata.json'); + $this->assertSame($expected, $json, 'JSON output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $json); + } } } diff --git a/tests/Formatter/MonospaceFormatterTest.php b/tests/Formatter/MonospaceFormatterTest.php index df86cfe..f02d752 100644 --- a/tests/Formatter/MonospaceFormatterTest.php +++ b/tests/Formatter/MonospaceFormatterTest.php @@ -10,40 +10,47 @@ final class MonospaceFormatterTest extends TestCase { public function testWithChords(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $parser = new Parser(); $song = $parser->parse($text); $formatter = new MonospaceFormatter(); $monospace = $formatter->format($song); - $expected = "Test\n\nVERSE\nC7 D \nTest Test2\n\n"; + $expected = file_get_contents(__DIR__ . '/../data/song2.txt'); $this->assertSame($expected, $monospace, 'Monospace output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $monospace); + } } public function testWithoutChords(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $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"; - + $expected = file_get_contents(__DIR__ . '/../data/song2_no_chords.txt'); $this->assertSame($expected, $monospace, 'Monospace output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $monospace); + } } public function testWithoutMetadata(): void { - $text = "{title: Test}\n\n{sov}\n[C7]Test [D]Test2\n{eov}\n# Comment"; + $text = file_get_contents(__DIR__ . '/../data/song2.pro'); $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"; - + $expected = file_get_contents(__DIR__ . '/../data/song2_no_metadata.txt'); $this->assertSame($expected, $monospace, 'Monospace output is not as expected'); + for ($i = 1; $i <= 11; $i++) { + $this->assertStringContainsString('Test' . $i, $monospace); + } } } diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 623cbb6..3d25c19 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -78,12 +78,12 @@ public function testParse(): void assert($line instanceof Lyrics); $this->assertCount(4, $line->getBlocks()); - // Sing it [D]loud, sing it [G7/E]cle[A#m]ar, + // Sing it [D]like ą, sing it [G7/E]cle[A#m]ar, $this->assertSame('Sing it ', $line->getBlocks()[0]->getText()); $this->assertCount(0, $line->getBlocks()[0]->getChords()); - $this->assertSame('loud, sing it ', $line->getBlocks()[1]->getText()); + $this->assertSame('like ą, sing it ', $line->getBlocks()[1]->getText()); $this->assertCount(1, $line->getBlocks()[1]->getChords()); $this->assertSame('D', $line->getBlocks()[1]->getChords()[0]->getRootChord()); $this->assertSame('', $line->getBlocks()[1]->getChords()[0]->getExt()); diff --git a/tests/data/song1.pro b/tests/data/song1.pro index ba8c892..0b43490 100644 --- a/tests/data/song1.pro +++ b/tests/data/song1.pro @@ -8,7 +8,7 @@ {c: Seriously!} {start_of_chorus: Refrain} -Sing it [D]loud, sing it [G7/E]cle[A#m]ar, +Sing it [D]like ą, sing it [G7/E]cle[A#m]ar, {end_of_chorus} {start_of_verse} diff --git a/tests/data/song2.html b/tests/data/song2.html new file mode 100644 index 0000000..111cae1 --- /dev/null +++ b/tests/data/song2.html @@ -0,0 +1,47 @@ + + + + + +
+
+Test1 Test2 +
+
+C7/ETest3 DTest4 +
+
+
+Test5 E  Test6 +
+
+C7D +
+
+
+CG +
+
+CG +
+
+C G  +
+
+Test7~ +
+
+
+
+C7Test8 ~ D  +
+
+Test9 ~ D  +
+
+
DC
Test10
+
+
+
EFMaj7/E
Test11 Longer Line Tab
+
+
diff --git a/tests/data/song2.json b/tests/data/song2.json new file mode 100644 index 0000000..5f2906a --- /dev/null +++ b/tests/data/song2.json @@ -0,0 +1,216 @@ +[ + { + "type": "metadata", + "name": "title", + "value": "Test", + "humanName": "Title" + }, + { + "type": "metadata", + "name": "subtitle", + "value": "TestSub", + "humanName": "Subtitle" + }, + { + "type": "metadata", + "name": "comment", + "value": "Comment1", + "humanName": "Comment" + }, + { + "type": "metadata", + "name": "key", + "value": "C", + "humanName": "Key" + }, + { + "type": "section_start", + "sectionType": "verse", + "label": "Verse" + }, + { + "type": "line", + "blocks": [ + { + "text": "Test1 Test2" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "Test3", + "chord": "C7\/E", + "originalChord": "C7\/E" + }, + { + "text": "Test4", + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "empty_line" + }, + { + "type": "line", + "blocks": [ + { + "text": "Test5" + }, + { + "chord": "E", + "originalChord": "E" + }, + { + "text": " Test6" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "chord": "C7", + "originalChord": "C7" + }, + { + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "section_end", + "sectionType": "verse" + }, + { + "type": "line", + "blocks": [ + { + "chord": "C", + "originalChord": "C" + }, + { + "chord": "G", + "originalChord": "G" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "chord": "C", + "originalChord": "C" + }, + { + "chord": "G", + "originalChord": "G" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "x" + }, + { + "chord": "C", + "originalChord": "C" + }, + { + "chord": "G", + "originalChord": "G" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "Test7~" + } + ] + }, + { + "type": "empty_line" + }, + { + "type": "section_start", + "sectionType": "chorus" + }, + { + "type": "line", + "blocks": [ + { + "text": "Test8 ~", + "chord": "C7", + "originalChord": "C7" + }, + { + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "Test9 ~" + }, + { + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "line_inline", + "blocks": [ + { + "text": "Test10" + }, + { + "chord": "D", + "originalChord": "D", + "lineEnd": true + }, + { + "chord": "C", + "originalChord": "C", + "lineEnd": true + } + ] + }, + { + "type": "line_inline", + "blocks": [ + { + "text": "Test11 Longer Line Tab" + }, + { + "chord": "E", + "originalChord": "E", + "lineEnd": true + }, + { + "chord": "FMaj7\/E", + "originalChord": "FMaj7\/E", + "lineEnd": true + } + ] + }, + { + "type": "section_end", + "sectionType": "chorus" + }, + { + "type": "comment", + "content": "Comment" + } +] \ No newline at end of file diff --git a/tests/data/song2.pro b/tests/data/song2.pro new file mode 100644 index 0000000..b76a343 --- /dev/null +++ b/tests/data/song2.pro @@ -0,0 +1,23 @@ +{title: Test} +{subtitle: TestSub} +{c: Comment1} +{key: C} +{sov: Verse} +Test1 Test2 +[C7/E]Test3 [D]Test4 + +Test5 [E] Test6 +[C7] [D] +{eov} +[C][G] + [C][G] + x [C][G] +Test7~ + +{soc} + [C7]Test8 ~ [D] + Test9 ~ [D + Test10 ~ [D][C] + Test11 Longer Line Tab ~ [E][FMaj7/E] +{eoc} +# Comment \ No newline at end of file diff --git a/tests/data/song2.txt b/tests/data/song2.txt new file mode 100644 index 0000000..c4ab203 --- /dev/null +++ b/tests/data/song2.txt @@ -0,0 +1,25 @@ +Test +TestSub +Comment1 +Key: C +VERSE +Test1 Test2 +C7/E D +Test3 Test4 + + E +Test5 Test6 +C7 D +C G +C G + CG +x +Test7~ + +CHORUS +C7 D +Test8 ~ + D +Test9 ~ +Test10 D C +Test11 Longer Line Tab E FMaj7/E diff --git a/tests/data/song2_no_chords.html b/tests/data/song2_no_chords.html new file mode 100644 index 0000000..2c3416f --- /dev/null +++ b/tests/data/song2_no_chords.html @@ -0,0 +1,38 @@ + + + + + +
+
+Test1 Test2 +
+
+Test3 Test4 +
+
+
+Test5 Test6 +
+
+
+x +
+
+Test7~ +
+
+
+
+Test8 ~ +
+
+Test9 ~ +
+
+Test10 +
+
+Test11 Longer Line Tab +
+
diff --git a/tests/data/song2_no_chords.json b/tests/data/song2_no_chords.json new file mode 100644 index 0000000..c8e514d --- /dev/null +++ b/tests/data/song2_no_chords.json @@ -0,0 +1,89 @@ +[ + { + "type": "metadata", + "name": "title", + "value": "Test", + "humanName": "Title" + }, + { + "type": "metadata", + "name": "subtitle", + "value": "TestSub", + "humanName": "Subtitle" + }, + { + "type": "metadata", + "name": "comment", + "value": "Comment1", + "humanName": "Comment" + }, + { + "type": "metadata", + "name": "key", + "value": "C", + "humanName": "Key" + }, + { + "type": "section_start", + "sectionType": "verse", + "label": "Verse" + }, + { + "type": "line", + "text": "Test1 Test2" + }, + { + "type": "line", + "text": "Test3 Test4" + }, + { + "type": "empty_line" + }, + { + "type": "line", + "text": "Test5 Test6" + }, + { + "type": "section_end", + "sectionType": "verse" + }, + { + "type": "line", + "text": "x" + }, + { + "type": "line", + "text": "Test7~" + }, + { + "type": "empty_line" + }, + { + "type": "section_start", + "sectionType": "chorus" + }, + { + "type": "line", + "text": "Test8 ~" + }, + { + "type": "line", + "text": "Test9 ~" + }, + { + "type": "line", + "text": "Test10" + }, + { + "type": "line", + "text": "Test11 Longer Line Tab" + }, + { + "type": "section_end", + "sectionType": "chorus" + }, + { + "type": "comment", + "content": "Comment" + } +] \ No newline at end of file diff --git a/tests/data/song2_no_chords.txt b/tests/data/song2_no_chords.txt new file mode 100644 index 0000000..5b7a165 --- /dev/null +++ b/tests/data/song2_no_chords.txt @@ -0,0 +1,17 @@ +Test +TestSub +Comment1 +Key: C +VERSE +Test1 Test2 +Test3 Test4 + +Test5 Test6 +x +Test7~ + +CHORUS +Test8 ~ +Test9 ~ +Test10 +Test11 Longer Line Tab diff --git a/tests/data/song2_no_metadata.html b/tests/data/song2_no_metadata.html new file mode 100644 index 0000000..3d29754 --- /dev/null +++ b/tests/data/song2_no_metadata.html @@ -0,0 +1,46 @@ + + + + +
+
+Test1 Test2 +
+
+C7/ETest3 DTest4 +
+
+
+Test5 E  Test6 +
+
+C7D +
+
+
+CG +
+
+CG +
+
+C G  +
+
+Test7~ +
+
+
+
+C7Test8 ~ D  +
+
+Test9 ~ D  +
+
+
DC
Test10
+
+
+
EFMaj7/E
Test11 Longer Line Tab
+
+
diff --git a/tests/data/song2_no_metadata.json b/tests/data/song2_no_metadata.json new file mode 100644 index 0000000..3f0cb27 --- /dev/null +++ b/tests/data/song2_no_metadata.json @@ -0,0 +1,210 @@ +[ + { + "type": "metadata", + "name": "subtitle", + "value": "TestSub", + "humanName": "Subtitle" + }, + { + "type": "metadata", + "name": "comment", + "value": "Comment1", + "humanName": "Comment" + }, + { + "type": "metadata", + "name": "key", + "value": "C", + "humanName": "Key" + }, + { + "type": "section_start", + "sectionType": "verse", + "label": "Verse" + }, + { + "type": "line", + "blocks": [ + { + "text": "Test1 Test2" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "Test3", + "chord": "C7\/E", + "originalChord": "C7\/E" + }, + { + "text": "Test4", + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "empty_line" + }, + { + "type": "line", + "blocks": [ + { + "text": "Test5" + }, + { + "chord": "E", + "originalChord": "E" + }, + { + "text": " Test6" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "chord": "C7", + "originalChord": "C7" + }, + { + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "section_end", + "sectionType": "verse" + }, + { + "type": "line", + "blocks": [ + { + "chord": "C", + "originalChord": "C" + }, + { + "chord": "G", + "originalChord": "G" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "chord": "C", + "originalChord": "C" + }, + { + "chord": "G", + "originalChord": "G" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "x" + }, + { + "chord": "C", + "originalChord": "C" + }, + { + "chord": "G", + "originalChord": "G" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "Test7~" + } + ] + }, + { + "type": "empty_line" + }, + { + "type": "section_start", + "sectionType": "chorus" + }, + { + "type": "line", + "blocks": [ + { + "text": "Test8 ~", + "chord": "C7", + "originalChord": "C7" + }, + { + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "line", + "blocks": [ + { + "text": "Test9 ~" + }, + { + "chord": "D", + "originalChord": "D" + } + ] + }, + { + "type": "line_inline", + "blocks": [ + { + "text": "Test10" + }, + { + "chord": "D", + "originalChord": "D", + "lineEnd": true + }, + { + "chord": "C", + "originalChord": "C", + "lineEnd": true + } + ] + }, + { + "type": "line_inline", + "blocks": [ + { + "text": "Test11 Longer Line Tab" + }, + { + "chord": "E", + "originalChord": "E", + "lineEnd": true + }, + { + "chord": "FMaj7\/E", + "originalChord": "FMaj7\/E", + "lineEnd": true + } + ] + }, + { + "type": "section_end", + "sectionType": "chorus" + }, + { + "type": "comment", + "content": "Comment" + } +] \ No newline at end of file diff --git a/tests/data/song2_no_metadata.txt b/tests/data/song2_no_metadata.txt new file mode 100644 index 0000000..4e8d41d --- /dev/null +++ b/tests/data/song2_no_metadata.txt @@ -0,0 +1,24 @@ +TestSub +Comment1 +Key: C +VERSE +Test1 Test2 +C7/E D +Test3 Test4 + + E +Test5 Test6 +C7 D +C G +C G + CG +x +Test7~ + +CHORUS +C7 D +Test8 ~ + D +Test9 ~ +Test10 D C +Test11 Longer Line Tab E FMaj7/E diff --git a/web/example.css b/web/example.css index 4192e3e..53c735c 100644 --- a/web/example.css +++ b/web/example.css @@ -1,9 +1,15 @@ body { - font-family: Helvetica; + font-family: Helvetica; } /* Metadata */ +.chordpro-section-label, +.chordpro-metadata { + font-size: 0.9em; + font-style: italic; + margin: 0.5em 0; +} .chordpro-title { font-size: 2em; font-weight: bold; @@ -12,10 +18,6 @@ body { font-size: 1.5em; font-weight: light; } -.chordpro-comment { - font-style: italic; - font-size: 0.9em; -} .chordpro-key { display: inline-block; font-size: 1.5em; @@ -27,33 +29,34 @@ body { font-weight: bold; margin: 1em 0; } +.chordpro-tab { + font-family: monospace; +} /* Lines & Chorus */ -body.chordpro-line:first-of-type { - border-top: 1px solid #000; - padding-top: 1em; - margin-top: 1em; -} .chordpro-line { - height: 2.5em; + height: 2.5em; +} +.chordpro-line-text-only, .chordpro-line-chords-only { + height: 1.5em; } .chordpro-chorus { - padding-left: 10px; - border-left: 4px solid #777; + padding-left: 10px; + border-left: 4px solid #777; } .chordpro-block { - position: relative; - display: inline-block; + position: relative; + display: inline-block; } .chordpro-chord { - position: relative; - display: block; - padding-right: 5px; - font-weight: bold; - font-size: 0.9em; + position: relative; + display: inline-block; + padding-right: 5px; + font-weight: bold; + font-size: 0.9em; } .chordpro-text { - position: relative; - display: block; + position: relative; + display: block; } diff --git a/web/example.php b/web/example.php index 705d8fd..611e044 100644 --- a/web/example.php +++ b/web/example.php @@ -1,23 +1,39 @@ T*w>Aodgg^p>V8Igz5E$HDg9O)s!2=BL?h`_Q;5xVj2tL^0I!JJLhv065 z`{li-?)mDgd(Q8ibL*@7$JA8y?&;pMd-Yn+dY;ugL`6vk8;cYR1qB8Bi|l7L6ckh^ z6qIMhFVUV_I=54zpT3w~q_kZmoxopREbZ;yYgpQXQ94tdSl+YqzIS%QnVTc%A>@}!Ya6$Xk$KJt8irsw z)BLZ$IDGH^V23*#o3Ihnr2Xvj$0JKxA%3@FltswY<1daw#1)c7=n-=i73gR*f{sP% zhV~r)lf$q1t$WX+j3Q?x_v#z42vs=AIe)%Vv)^7}zQNFxFp8L3O#%ff$_!UDA&Ts4 zI2uZbI1uGMdk+@M#}^>fZ-3qcP@>Q07DTo{uiB+lEE8xK4&Wl>uYg)BmoTq z4`M$~^BwryiHAEQFhm!E&}Q%5rs|kdmhBDiJ&<>^hlf=2F1-aCy)nA;iKkODm*q`>pz93}^Zu_>Ivo7o zMeWzeSBb6Zi1dA|9lmQty#^BO6#4m%e(a2H=oX47wObk4=}+wY&+(Gm=q6O z@~=w~i>}Y+M7Xdsi_894i}J_$E>150`Dt^pGSlwi>}O<_+*zYEx9Q+r8YK3p_xFv( z+%wnVqx{F2fja|g&Yr%>6O%@t7m&;>e&rz%_U~hUao_hw4O7X0)8598_lx+Ts7xPj ziW#kl-NTC;IgXy|O;pN?dEnQVgCyd{gjuHn|uuXDFBouKHNz;^$`PGF~R5ER{O{VD)V8`g|FB zYwgc4vD6Si6>--hqIoTR=Q&$n2|9}>v>gylPI*1XiJVro$CvQ@N~;_%Gu3=rNJ?KI z@1_O0o$uV<3us$&YHGgkUTn+nHb;E+Wjp-Xo|E1jKrA}M=Ko7yRi@@D62WAA@o?DP z*!bvbH?y(yyNipkaX2G$MpaD*s12z_q+w@5qm(ljMFqUiYZ`=8v%MV4wG4fojt4u} z-C0K<79|`OrHov@@M@wJ0mVP~_-q6*47M)DWg13^ z%(G|s2sJe{B5u!3ms7Im9{wEA%N+o&L_5T~>_oYPI<%}_t}h(lp3g!Kf|@==Wki$; z`-g4Sx~%S#M|gWX`eo|9LHtF1@==@12j^8yw%;>YFWH41V!PrH9Gu`Dva-WQM_ts+K^X6uO2j|9)>1n-bYVW2-G90s8&h+erL_5*@ zO>K(}31M~2Z5F^di`K_ROD!u3o#E#+87K(;`P#iI}XFqTC5#fZQs@}lf72%j;k?{8E&){y}u|r z6IoImLPQs;Jd+eA$;@9zNNd#}_}&h8;4T)J8mv>(#X$=A3Ba6IfcL{=9hHWsO!xN} zyMn?2rHQ#ev``cGbhwCG%r77*U0_;Qr1l0W+^X&aXzwE;#FiRWR<)e94xvcch&D0hY8u`kGQ|F zSvGieY2o1FMo4b6tbc!hY{W|TsN6k2Kc_HG_ifAtyiiXx>rqhrFs`0Vu<=>zc@a5A zm&M_(J>0fnQZkDW#fxs{;DB{M_uNo3En+6K#Xhwm>V+X0 z%NP-h>zsWGGPY}R3PVy;`8+LUo~KHqmxOnNk8Kb`Q^DzX&Q^O0iKJ#(I5?VvgOf=n zMUVXPNX&1`EaviC>6Y0rUb4W*E5SvP)=?mPmsVKRtHj*Xztep!dXA6--=|wH^`|f| zlF~n@q@F*q*TiL%NA|jddKu#khItDq#R#B`?FV+!IXpg0{qh&5aFWZfR`L+%<3BDj zegvnRON&cNeiVjHO?6if>sNanUQ>C%Z*&nld<)znW1A_AmzQ!v=N3{axO0w1JWlsZ zU0ztWn`-P8HF^d7=_JgLjj3V8nIh~I`HhB_jbg*{0{kXYUqC@EX2BW|7cFtHre>7@Z{>h|-@JjXA#hnyA%T&mkZ05fZ0hDb8V!xP`YTV~j&^pW zZjvUjD9P~g%6QpX({Ec@SAj)I1b3DX&GKFGk2=mnuhoIY?Kius6!o=&1JSTg*A_g^ zJ4sa>nUX6PH+QMN!@)&1r5;1^=wK;bQ|U*6nyq$n{NXdDBD$L0hq+r8cs9lWCRu7x zp4_)d73Vp|(p;0}**Y6yXz4PAu*jj0;wxqK*A7Q?qLjOLnkL12R5${gEnHvDEIQL2 zweZak)o6r+sW-F*arSXI*%tA2P}xLw6POU-6$p= zi`5jtIq^2)D;xjBlv2Z;oF(}|KsJbF1bR8WRNLFfCF~pYT*`L0#!)`3YCgy7G{?0= zErOrVmOm9%pu{bn>2R*d6QCYx*ls|tUy(vu*u5(lELw!}{an!ROu+|{Nm^1+v$FrP z8m@sWO9nAl>4~otZR5w(Vij3hye}9yDi0}QhWo*@1%!6SZH48eufc&aGb{jiVkaV# z^ZV*cM-V&xqwY>!rW8q93(6kMX2jQi#0nc5`D!Wc08y(xfST?Danvm91V%8neUN1I z8c(oqG%1s@%KFUw=0mNjr{<)6vYW7uBd@a5<*nscDE}`SlLpRnCd(CTR!VHlWUElj zi}XD(&E`_ExBD#=rpkDomug7G`H@rn%kPRX+Jcf29E$Fr2EI?*M+10fn#%d5&~o(U zvyEkr=QGI~fDzxa6zNH)Jg?u2kAAc)JGmI={x_*JFhi z=cdu{dz=_ibD?>j6Gi;q9U+VY`CRwxQXI9JNSO<4+ z*PDgp(dZWti>_wbU=9JjT6vGst?CtH@3Z~i+MVw1oPJp~g8Mx?Pz#?kmdeWu<-Wm8 z_qcvyt8=67&fu=Bz|lK5;=g;e@u25*EE>HfC;|~DMM+szlgU#moSzl-tFgS8@2W4w zLG@&~f*Gv3zvx;86k}Je45+}LZeUz|WlcWc`)Y&B->qT9BJ=p(KH3q6Ai;6NwOFH9YKeD^i+`8kr^l6y# z*H^<3{hWYyt;t?m;=74;a}c3JS_9LA0D^ldYD^Y>@Wz6w_W4Wgo#DE;i1}wBw#%Vu zCSJajONx#6qon|-NTHXe`DWrsvS0RWMO3mhC7xKG|5<*Iiah51bT$~nuULG*waBjIk@(Vmp zaW{S_X*tetNU4P&zt#q7en{o#?lx%xh%XOj}G!DqFz?@=XXr-J@j$()A#f_q|>?)L!`Tspf1t?M&#B78QECV5oa9z zqsjfxK>mDIS<^Z&azj=DCh?c)bxjTL0dJ&O&ve3|YkS=;t*5>Z;KOd??B*Fmq~h7H zN6qK|qqOk9l{@}toqSC<>^MY(MP}xi5mt!m`v;k)wG%`bl(EtXGp*%}{B#d zec4!Cr&2b*|1-U~zHxFAg=_E@K@RE3$7##Sfh{x(_)^>k_~TI*K{@?<^F?#Z&6H6s z9+c@M$ehzW$kAQIlbcrW1fkbl)9W=jp zYLD}xlisp+dD-`$v`5o3zt*=IacW@qMR-YcNS|;TGR$<^kvS3ZkCk1xg|aT#J)ZQo z@e;=^re>>d}|{bs&ac^UW1$LHY=;_6m~xa~gZ!amE{ zlDs|m_{z?HqGxvgk| z0om2uyj)_XXw8;KG?MH1rKLaX*7FjcgACW3-|p-bpAMhbtzGVoR?7G}=tc#v)%LF1 zI11lbyG^&@`Y+wp#?fED%T`I3z|PIg7N!afVlG%{KHYdMqgQ4Yog3OB=Af+=CAay4 z;hf7Ln3=w2?b#jDC#QN;n{jsWoM`gCETHXTsBLG`_V?P&xzq#JNIaN z@#^+qa!34P?V{~Eb+?Uo3j;*4v~*uKs`;?CIM{hSJg+-1C`e^v>+7JWd10yBm)heU zu$DOO?x}ESkVz$SvF?l*4f#5L!Y?(t@XDJd_ z^wW~JqkiAvlaaEyXMG8%a$!rv%dqNOYvOAm<^1?P2}a=eP$M$?YrETN{=?1op6U|d z@O7%^)LxuuGVYSTPX)8rgBp`tWLFUw(d=R1QVBI!xaWNQdRsVOV;F_3;`a0BUnT#` zK{dgJrX_>?hYz@x3CzFU0qtnlKM^vdAd1thw`B)eKK(+L^$#rL$+!q|!C21|DWdG- zAtJmX&hoK%dPrltJ?;~{AfeiTp2~M~ze(aE6n>o4@=Wz~BeM3amC|3}zY8mw%5t1H+!)L!7ZE~u>@5LZvL;osZPKRLev@ZS9GGtM1=mILbA+PyJoJ`nW)M0{ zLtnTK27Axb#0fEv`>%%#mENZ6C5no8Y6ex_pB=sS{q>O=5I34(lTDawd&I$4l1>5+ zi%{g(x=$TX4>`*Y*7!IM+-#zrdh#{(`LnAuoS+WL)6QgCH;ye4Nk zysE$N{nD#N{q=MC$cC_;kYlDVJM*V|E_n3I_nR`+<^WmcL6Lc8a*BK!={||OXC}wRMv7UqfuF%RCRvUU0p!! z@{u_)BdanAM3U&JONOe?Vi<3$boOnowia-n_{o5fJ$J4jOCtHl)P4rqe}($3(hV5D zziay{yrKsi!Booa5R`WLUZ3{ooKDZm4!GP z1`Wi1ji;pyhNz2sw?v`?oK1LjTDkFoerU-j2l!phMLOUR|6zvIMP zh@E4#KOiY4Q64GVgb*`35kgyqk(#ZJd99q`%yrQ5!|7@{w0_*__Gr}kdTjh;-Cg*c zNUd~U?4hHvdovA3YY2F@)Pjx@0+IwV2$%}r$CUl;xr+&ARVDmJTNwmbyRo)*H*@*2ePQACvz- zf{_0?75=~A%y&4l406B63c2p5dN>YdK29?0=W@<_0?VtbJ;XU;$MyrFuYCx%b@>T3o?gm%uD;Alp%vOt#L`CNznj5|6%h;PlKK?y_Dg>qS9xS8?F%WW{=u!5LndhLW_mg&f8JGck^gS%0FDn(apas8xyqt#|EJqqP;#2D z8h4lSa9EAU<=&TZyV}f*y9PPQ%-XdMT+$BHC#H*=A=};kw!eS`?#o6lV-f*FN%|NA zJ^4EM76URI#P5zE7cJ!8IBC1KzO9&F9y0V1m|>9qGJkX=LY8r_OZrmlyl3~A{(e36 zGypkvaSFL0gf)h-?)DtKh9DJUmz6Rm@}|LDM^6$+7y%sFe-aE9yn7NJZX5eo>?Un$ zMM)zMV*VUl73*6Q!0)S05M0tDe>M13%N}af4X(y}T~AF2!&tj7@kl3UypSb3d&%J! zHOr3{?w4zA$6y=sAOGBMG_-f;msLJS4pp#V_2;|u&f?lle0MFw1DofwqTcFDPiPVx z=i@plw3x(5g~MvCzTtH?jr4S9lyknD6%`E`>)uuJ20FXlL?bsiZQ_L#gtAcAg@E!h{=R+G6uEsyTV`OCJCz zM5q07YpCo&T$#5o#MkDoj%DjVli?MT-EEX}_(*R!Ly@~730yEc^yxA4I&VAEcgxSFptckOsWJ`9`wbZhn(?vzKk%B%)E zT|W1hNbCURKF!7<(u9_UW}9`pCptNd8V#|mZJ>tjoSf{n!<6gs?(v^eBlT69J*aQ8;I{LT&%p(IVRTe2FHR(~P zBg5TO^qr%RwSVb_^c!tho+t6~5^AXWoORM^K^5WHVV4PIa*s=-AI>~Tz?<=^Iz4$f zJsL8szE;24dRpl2zD%5Hrrt?TIY2Ac#@klLiXu4D`vTFpJd|RBbs_CsZ$;dSbjCEI z1u25?##?DCnI{dI`iE%05rVnFxdzX*yub|#mm)&{Pt(Q?Okm0KDGUXRmS*QIu!<(4 z0OKd)q37TJUw*AZxAPxYY8h}n40)*B-!5SpA4oPE<;qE*!21U6NPr^lHdx1bPWNXy z<2y}LnE(AM@&A4G$r~F6wxQwWf&THTiSJwc@LaGJ$Ofta(s~McvmAruT+dt4qiFu8 z^5_5Z`Ts&A{qJ|ePlTSR_ze3v2!Pydw9Jcr`23P;GjRGY^;ra;-LQ$wamVv$m zT0u+Msa~oqG96jwRD=g^9y&YvlyODutO)Y{?PMZga)D}Yl4nC7>Cefg7wD$B+up9z z>?Ig`U^7v2pc`0}rRAuEEPq+f*V(9?o~Z~t|NH=+4NLO>d4uG}TSYgeWz}j3EM;PD z?2^pqB<@mW09>;|A88Sl0*Deq{h~)C8hHfgcvL)_?x1ZMT%@sF>)}shp6(Z+X)cogmg> zA2Q5}8aoTMT_S&y=2fHoJOa~!;@Ub#7E>wHUo4+b3_#|2mq!uOGD}BhAzXzHV5v>W zse#On-S-fu@mFEyM4;Y1Jxxp`vY4^$;|$6~7U~qc(5|~X+hk8)0zBv}@9S)}-K_ly z44Rkcg=v}4I&F`6?tSBaExYt_ph@l4J(REbXRoQ_`%jr;1rL;k0FJ?M0r1$7DfVoD{lo zlTuG;U4CJr+2fQVFwHrt@8dLT3Ip<`WiGuWHKtO{Pht>uo+hvfY1KZ=64L$3mBn~N zgqCK%FI4td-5`K3rc|Y70xuziLw@{KR)~W(aN#pUZ&2_r1nkO{7BHG8afNL}&K{uo z-mGjD=evt*@A4iQUiw@;t#m1D+j;VPync3ZhMNH)o`YFk3hdNLmcic2{*#*+1}$)}?f_DBFg+FZSxku+D#YsEiF5$IzKcPi`NT^TZLcoB~<3W2ZE z@qda;cZKgMDFy6cVz)CJafqu{Pj*1ydUU zkb{6K<{kdj8V4@ymXxO$ssJ~SOU~j5D<7+3wY%yEYmNK*p4|ZNkZ&t?KLToAVtLbl z1e8Ai5DIIwm(qKSL*N6DH$O~%Ke^`WF#DnbC*Sa`{Sh@_Qz2S2%ey}`E?exfw75<| z-iNLyfzX<+XQGbZ?EAo{9hAo2P)_{ceHD{BCrXd!0};mxDy9v=$-lt(Q0Pd3U>y++ zSN;4>JpTq{^fd7R?!?-?+llq6QR3C3ql~pwBd+la#L+vaaaurISzeWVFn1B_XGE0+6;F_zr$<69lp@m2VwGPlkfQ0-fTLYJJ=I9Gr?n6sADjjRav zs!%u*x51QXNs31j7*ElXJs%^G)14GUD6HS1-VqTAa75WO;xz3PzVh?s&J~Li+p1+c z`^=Go_R38CsR9Q0;=fe}6AjbGxMOlpj4+UCG!X1QW9cL88K{<-R_1W-)W!!@ME=&z zA-oBK{RTlFm5ei$Wz9<8ID$Yi*-lXd+iorUSS#rv`NQL^uPbimiC23BYmPlUx?gCK zR7)=obXDTOjmgjXY!ljO7Pa226%5MB(On*1vGBge*b`jUW!kLI9H+ut*39mHZvlep z`h7E2tWY{8Y%fu=QEzsvfF}FZnLT6dXl$M9>C@pYQG!$BHkJrQwn%*n)|AO<2|6bSD;631l-7TNN7kgvHb$H@u z2m!w&WcbdE>l1OT()VRoL;5LELS7%L7K}wxsZ9nr{0%6kXZT|oBw*onG*sj)5xT$J zg9S%M`oqq$0$@GWV<7!Z9>(U~1yJZAzV>_FCgTN|SVST8YR=PFw`BrOQb@R`K3G@X zR0Voo9Ph%`V1tIIDc2wB6XUrXY_hx~TIm8-o>n7jW zsnMh(1l6~Cn6RusE|+ku6bP(Zbj#dRg7^S{-z|!pV&zToR>X;ayun3My*SD-P|CAf zpw(FvC?rKwd^v&FsG39Htv{}b6(Dx&UQ7vC`C@kkC z$h75DI^-=_G-p7zrYBUtk1^xWOzS6SFwDgn&R1%v{POc8=JV}leV#0iHwbu_K~co> z{Aa|$gmvDL{cNdLyP<`)jDqJLY$~>}p9&hl&%~tB_Lnus_n`Go5>?kZ3Pl1`=~L0S z7>>Ge0<{*V!Y53`hGNb0-=W=1ti^my9b%{QcBBEuSTCG=LhGf3!x!wPs~l?AUxV1K z)ZEPP<-v~VfZ|IvycB#4W`F?4p!}ZIu9ojS;aC%x)P^0s|B=8xs#i{IaUfnmaVR=f z0G+b7CgHKIN*De%79l2dF9;~^D~#}$<-s_V!VrI;Eh9fEIr~Zps<)5yicI>+ zJ14yI4o4r{DI*_ndT}Km*B2Ugh7Mpps)pu z^#x{1OOQ)E;iJ3xe)OZRz}=j|+*7QQUY(Lcu%q8r-JN{p4oT>Cu>V`f6Wbdqs}*LS z8vy%!Dhiy+v!^A199`OR?p5E^UIQ0A3y45%wH(B~dNo-hir3H2T_m+Y6=>{Jn<3xr zJuS^aqyEJqw$4_0zCW^s2JJXAB!T4q_}C00_!Ck<>g%J+bZ#y=3gcT2VSOALXraQYY)ECDUVCaT( zAYkYumcefO9+2AY$7tFD)`8xj;oPFm=pp8C5Yl8uAGm*4OFhB@Q1WRC$G)}u9P`}n z6>F)bM22(+hfs+|r5zYJWA*M^1y?KVJplD7vjd3((VhwpE5H92wTAzF9Q|KaE&dNj zngfGI}0_ zXdDSK#L3s9&ktyiBr)J|=YA16lD-qw(XW^9L`MHOj>U89-uste5=~pQ9nPT{vHi}x z=v6?o_$ZRok%91Yh-x0|1|lDU`@-Ax+iYfTB*76Q)v!x`e5#(|ZynTDoj37N1%^oK zqS#&;TSYGT>QB|v{E;x^@FvemX;&KRH*VTbfMoW!WC5S_3rgO8%~&@jlJQGj``L@3 zGD#(ul_EZZH-}c9@W-^%er7PLhQ-3aj(94xL6lSZk8R`!b3XS8&@L~9km zGpvoRJ+-Q-O|Ba~<%yA>+^RI%%e^8EU1G+|h z2Y0_gnR@+ivDxP3?la7^^4htd2XzlmHC-s6Sw7_AN*YTGC4u<|y9F~}Q*cMmF#PXT zG53dBcHWWF&p@8A1&tNw=X>IIMK-_nLtr)psdKlsAM1m`<4uguPy0jph6j$Z72c@R^qu;&PyI zfWh(*E<1JIuwR82oKpvb6sU{&afFs0#0W0)vn`M)>CmhhPul9t-?rl(^_8NmNYhIT zU&WoT;Z}&uPl0(qL&)X_ZTP`-9lo0tCHf!#~_w25Kt|$~${Qg&>)zO4di_O8*_D8Ph;* zHXtE~gUs^Xuz&1Z-mv)lx6P+tS4odc3dB`PXi~SW*kP4){wrv6e_$^weu_u$N~IS? z!QN><3#OCTQUks|QogUSUG|tQs9DE#{*4+5GZl)4vAqoaYr8ed?MeX#2VjUNqV6>* z(7|^_?A37?UTzT7NA++w!b#@1Nz25!B6|ILC-6uI&*`- z_%l@^E_{sb^8F{nbs8tm;=&Gj`mnvwpD?CprW+r`fTWw3B^J3o8hJv&+1C|?*qujf zW|av;9QEJFV~Tpzjg=Mkx9(o#WSa`dc2^ouS}}V=q0-5gPMr-NY>hHO_*R6oM;#S2 zHrdws+THt8<;S$1QwxRH2K7R7eB|9kIMqC&4--#Oi7RJkJ`6~2kOEaY&6k5Op{nSG zb}~Eetn05?UdI;_IFV_&1|+v$(YQga51CjdW8&vyfV$Ri>ukD}`u3bZt=GqG0wsM9 zGXw%<^+SboaRP~C*6MO9)sBp#k_@u2Wb*%H?lubTej5{kuTB&RZ0>r@l_r{IpDiI| zvypEG?%ZGbY;Pj2<0~;H`8ZV`3(+cu^LDM=20;Q3D+)`EXG8}=HD3A%f!xBEG+{+m$^RHMZ5%gQ=rF+u9(W*<5_< zK_gnZj3K;SpwlInVhLoeSH;RCr9TXJzrs>gtH@77o9uQMgcJ61U57fL(lV!84>!6K0M2aRlE;@ z{-!r2i|$-pM+dwwISfoEj^uJ^5}U;R5|x&QAkHt}$C7(9t{XZR$sFr<{p1tLg~D8n zDO>C28bVbNJq1qJjcM7JR=>s-gI<7(rDI(In(U?w_8hse4Y|cN`0Dsmg7+I6v?sOx zE9eQ38}nK&t0w9IeRlbh67T3p_l+n(qn1aEfUJBoDB%iLuiuVY$w%gN9#F+SsVazF z$S??Yh4woWdRPv1)i?hQ!qJK^i1XdZgx2N`?Uhc2_RLd0L6@SKv0Hra--^IKG7ay_ z+m0>{Iopz+T67lLD&QRKeny?MFI4by6a*ZD4s~b)fXYM_z$W(+WYVWm#(5#ZAen~~ zp4q5K*1-fqg#AQEa-{L^Jvr4XNzGd7sH!KO-8FLE);7nzmUWjF;8$V=r}nW7^a0$1 zJDIIriJbb;or}goSOQ*lefR{hZ6R#jP8CyF`}FAsAYRmYo9k&d6z-+onIKkIZ8+6F zqi*%$Pw9rPbXH1KRlgkfHNCZ!M`_kxxB|?MK{?+Jb44D&iO-c&y*h}E5s?!ZujJzj z9iP~92?XbY>3R$mtp?Efsq0qvKx)0ALwrz~LoE#nw9)k(*cXZnxE4AAdgMA53$+C| z)8KGp4-~JfG_cPXfA_0S1CxpWm)qP6R{)ZyKwj+#`if zai@xPF8-YUea_Tb?h#Q%k)Fk3sN1X3!F<$ zTKaCyO`GIXIuz|+l+J*9c$21$k1hnm&u8}u4KSA|%)JH99Y9RmPlGDUf?(I%3?WR=xMtwbsavtGK1a?**fvlzrEHooVT-SJ6-%xfJZe zhG(<*3A=Odv3ot6y`i2r`)4vYB^%{$Knx9$o7&OLo*9a+&h=$3ag3J@<(`M-LQ*cO zfR1Oqp{_$$o9D@U?LmE^-hGc|A8naEe~G_=!{uxYh6JC|LvSyX81U>VAp{BYuxx9FUN)vf7$Q>Tmc4qoL@nev|McP60^!tB{}+Dw#Qp7VE?~*Li|Tvn zau^9!p=&bc8b3dU&CPz^{C|ts;{)5${zo_=)5Z%ICTfo_{C@f&a3W`(`Zjw0II@=Zz`Q=-u|@Vytd8iX%yr?%j`sG zvM0MLm`ml~>Z|rp0~a=Ff6JA5t7()s-5F2P^Q`-aF<~W?MK&pDvBeg*OZJ40NA)6| z-Ept$@psXix=*d<;=5t+)m#Z7YJ*XJy9y6!wYl}wMCQR<9uVfijjE#lGC2z?uVsu_ z%H3sl7R^zW+E!1Gkmcx)ZM_3$#L#4(x}$UBm$F1ceya$R8 z#YmTrS@?;rwgq{Q1U`45-Y2!T9nAA=n!iWItQp^%Bd$PVwRDEq%M)!_GU%h*@k>+J zGcsubeFprxNWb2Y#w!6`BGL?j5+`S4pg`6Jnw&17au~W(P9^N>EEdX0$_`1m^|-ws z^7um$W{CzjPDt!JWH`#P%9T(b{4M59-3PV*5f_W^5B3BOUT-T8-i&`L>Yje}b zwUMb6`WWbDo6=NhGxZl;?X1!|%z|K8!Bx^EUG&ln-kS|TeBWi-5=WmrXJz#&IK?4B z>Yw{l1m2M!)npf9l?U5PHAx8snV*)NL7|5&P=QUE4)Gc8?Rt8b{(UAusqSY!;{&AA zQ&hI&d~EK)SY%l=rTx^`b|(E_@G@ zjo#3;+*gF(>>A)KZYh`?dHkEHn7aT#y7~9dXxPDN>6U7=@|hCmpo+lZD)HDs^xoCI zCt}9-Lp^4_O#t#^?BBgSfq6C|((3+x&tNC1>2pNCLi7}ISsDDF|DX;mQmD$?pmieo zZvXdP)E?=GkilH>Wcp;!@+AQ}(Q_9l;qWZc&-}m=!HY3m3Zw&**Cr4YBd?S_6t7}e z7iReVnhNAHKBzNl$2UI`7v)}WNTklxtQe1GH(KM@9ucLTKGIOt?U++gi=F^1d1=F~ z$4*pxQTrA9#g=u4Wc>7vKMY2c*T`}$9}U$IlJzX1>gg*Q5s*{HfX9BrU@AL*_8+_5 zfvLNvBY3j^NA#NiuX&~_sGi);))rfdel@?>V)z=IUQdF3AuVOxC8PUvf~`9rY8gTv zL3Jkn?ct9p*hye!j%)&()m%t3h1z%3&i_KW~Q<{4juvHX!jCh}B^#G)ES0 zSF{Y@oWA2fSdq8dNxpbG=`=XlLa7K*)v~7f^r~iGr;;yzCjH=J*wO|UP?P*AN8Kuz z-bm1j428HUI~QsY(de_R-H;Hn?=fFTJ=MN2+zOBH*|BpiYm0yv%4~Xt2*>4@0#IJj zQ3G<{nA-lbFbkV8#4^>+T*?0k@h7#JGCnEGQO z#GW#lQ36ksUHWBie^|Y_I^U-Dskt5Z%Mf;^#=n)jCHZQel9$Ams=Nh}s75DGM;tD9 zr6_FY^5-MuxqBbkeSL0Z3+YgYrJP+!H45o=^;ueAvdX{TJxrKZOg`gX135}^-dVEw zvP||!kjNpGXvPnrppQDwLoMZA5@NU^h%CVXt(GuS_&rIMtNV6xVBH-Nn)W%tIn8;)N%0{2@PRBdDm-N<$k|i%V>c^)uOjWg<#Yu?Y zs<%`$vlmoo@`4Hvo=+A92G=P9&tP(db?m0jGJ%)IGp?+(d2z6x4#t;TEYOuvF*3(u z(cb4|QnEl(s?A#U;hZ3n*DklX3XI&S1t3O< zKrg4yXALM~$}N4f>nE!%hq2GtN7YtR81KtVxz@H7m$|X#)doKozU)B3p_eNLn4Nl`6{K5J z=H0eNf8%|~Bs_dOugCZ-u~PKrVf6(Br{D99(aFDp7XJZ#@W1bTRgmM5u6NI=ihUox z?qOqwQ&oN4R_71*9~dV!UYEXp35o5E^4dUT$tAK1&y#NNJ7yX`+DQN%T0ssYt2m4!hEqQ$_FbXC$`zUwC<3{ykx)N#50!xLh*-M($cpjjyP5 z#enCr?f#9W%9ly=&0#C&ZbR{(_Ts}Kyh;uQ-w_)Xpo_P&QZZ3UyC4qy=G)g`qPKk5 z<8dGE4=Qlr)Wdy~-OqUQo%bsJ1K_JmM3mVL!AC)vj7P7{nZ$@26xhgus$Aku5v`BEn`L?|&@@?I@pQNU|L z7q~0dM*}Y&4&bAbIQRq*(Kn^N(Kz2Q%P~b27_;Y3qujZgk~;vU_3Bv4+LioRej6%D zQg=(St)jII0v5>>sb7VOE=Gk8n3hx}=-jGTPr1v#`u0T(NS)bZ*>Xg3QsNR#_(*uO z_x*|gUiOfn;tcayXW|7!20iOmCO9eKE5zaM6iSvn|I*Rcnkr`)g^qxD&*_v@^P_G>zLAykStL zjOnvooLkM8?gsrnqK0=aTr+aHeoB^#c_bjV)oAwtRG3aPbmwcsAQnfM1r>L#siSIz zcC8V;BFF;RaKDyh#b(3rk&7PjVMh5Q4t)K(3=75OzuZ>;UOxAfCe{x!xh$v(`g0OF zr5sH&U73iHZ#)`J&`Z%iUdSJ>2iF^IO`}=FK@|w#9pYy8r(Z#ixKEFGD)S12?;I2X zgS}#gQx7R*aPMhJ0B3lw#NGoOmgxZJqjC@Ik)8EctLu@iwH`tFd9b=}yHrDBj%O}W zbTOF|h18pw!5q!%>AR+s$m|?mJ__ELiCA;L_q~ISH(XyF;a^Vk-96GFHla{~+i)^b zq*RSJy*y}Z6P@Ke1QEjw?s|d78<#g`ME+-7?UR3{cb=!df-!by>U^RpC(<@;!}!u)6ChT8&|%iV>FZpaVnqb&p@tTmSl{uZlA5vVX4 z)gS8j@gu;G-kki-U}JwEjB&~H#%E5Su=i~VAznX^b?)EZCiHw zQJ&tK_HT%NhJJLlF+_K2aQNetoI0cd1UPJbhd-!Lc7)!G>L&Y-E?GE&0)njA0d!dRi9d8MyIK z)e%Dr# zUXnR9%JR;8grKk~QC?R=1Jy^d@P#TZGs?tjyS-r` zxR$kQ< zpQwmwyr}Z48g-J{=3f`Zc`3w?$O|imcM6v$tfNr=i4~KGA7LzU{byJ z4KWf){qnO%vo!p2tl{RIgI0$&q+c&?9rO$acP(ti3hl~0^(NOgr4o$zF$wgoDQqW) z3tD*b#Sw$+*gH;KqpPxLV(<18OPGA~lAxp@hO9E1 z*tYCJf9ge9R^Jr$oHmfYC`p!f()Z|0DhQM@osV=+9=ic4r}UHM;qv&Wngp|evVwIK z1uH7ETR<~eNTu^#I@N%-jJkT9tcT$EqW#S~<*fEBB$B&A!Kf$PHKT;H+@d*~OG+OZ zraQU28R0sRY7?$;yEEweTI!y`_4iM0{J7OijgDc$-^KQwoY-k?F>fqCu*2^^?R9ue z@noD1wMAp9`bt^uh-%Jr;@OH}CKouYDwnaIV+<%gE9k(jd*R|t=sMI;FA3z6ERil6 zfECzH0UIm>Lw-CR?3bWUJq~-L*O(V+if>ciB$4t?#evsb;PK~f2& zLxu)PLAnGa2Zj=4hLRQ#5b2Wc5>P;T02vx21nCqQI)otvX+avl*YA91?`vP@+Q0AY zeSPPT^VfQN)%!f_UC+Aj`;{2J{u;(nH zy!~|&5nVQ)RF;UkbSjG!?5@+=I~L2Yrkh(kPWF#9q%*1_i8(+FRfwmpNWX6JF@0TQ zz0EKhXBZ=6&G&6%CxNC-hJ&X%od!Mp%Wh<_)@tw^YRt+1C?Le zWxaNql=FTGbrtrZabk2`xD$5MB`X=pMXY`@IE5yjciU_UXE4;6&CQ?7N6gAA%E6xj z;5Cx)j8g%oySP*TA{s@szFXm2aAJ|k=LP>{jE)Z#6*92>V864#G!AWVTwEQ*{=}HJ zs>d!#O$OWbe8++#o;|Fr+W2*VGKof#onqFiPebIJ0R}sBn!@xwSeapT>b(h6AK~v3 z)^mHMvn_qaOp5q>*7tA<3cIN0HZgg_cIp1<{ec&}5^VD<5QW8}92%^7S;5Vu_G(9$ z)QItY1vop-h_!XhtXfii1#gvpNSv1=vh3)~eP@9oBw^?dRJ|=8dg&Sf%;ogH<_Va& z1W!dtxA4+ud}9B-YrLUl5wTF->9&6694n7U3jbtFs7CJeIl7z;nlO6RA@`naD2!Nm1u+)h$|2s*9R~WYQex8n)H3?5OS_>j@sB&Vh?GcoWZMP-3`Kcexi^7v}F;C?-l-Kqa) zML}VYz`fARJa1bB$2Dtk=4pFvRaKCWPsCIV5_=zs1a;)g{Y$bxocxe&?=Qc!AQxA? z;J0RF2g!`~Z1wXIBQHRK1kAh;8wi;Ts197}ByGfcLGfk@aQx-+J7ekyx5&|nJKjb) z`gL7ZcSz$uUALeK=v0mjHm5LQXj?`zVl+c;nVboD3_mcF5Wr?Au`&D}RC%>^bEk%S z+vsfnXg99)^LsRCiy7h&Q_mH=@I6Es>t046a&1a=lS+=brC`~Lb~S-w-@s_UK+AMB zd)xBMj(mdDy*kOlrZ)Bwb=*MqFYv;jx+Zb_eeprzwdyjfxz-J*jkE+x zY!DF9mN+5ttbp&WNqJ~KrC>Vp%WFs(sv<3VHwUar(h90ki&Q%aV|Be1U3c$ck#Y0 zBHZ{*7a*X0Zh%)%!KNvQBd3B5>$j9-1R4C*#JvXq-)174a-IBY0oMBY)iLA5_^gL_ ztDcP?yhmrTCJO!|mC1en)Y~}6BeCLP6k}11`!`Ub57;9CbAVSqyD36_fz!$x#yy`> zy^`OYLyuUVz_^ZRF7lh^rrz5vc@pMb@F?8L-KHeY$Wq>A5`Tr4b*A}L^tG8Y+X1yw zf7Fb(`4^w;_)AjE6!Apq)Ws<8QCy{GOS@grYKe>?0+Aa@o2tBTXQ0w{WxgZZAQCXn zE*9X)Zp~Kp>qqp=iG{lQL){IwZv9sUUlTQhBVQyg@o-`av`tmkoU~*4%AQh*OSiVA zRrsmaJzV9V|1?<)D;;{&n~(_Aw1w$nZ4XC%?kK0Vpmpl--L|awF7j;Pf~P#ibs|!X zlfQJYwIuB19rX9Y)=mDdVf$mP_>ASH&(wyA=Rp#)Zj*f0(k3@j%SRw-j)Azc7CwvC z2I|PGvNB7npYqd+naa5g)t71n&p9;bHFXw8?ShO@i)$`MFt-4gPhyE3$_S_2BkB(T zzKA^WvWL^zqV;PK(WC$NTQ>R4)#HMV{}jy?Lv=au1zMBmF1G&sGMf zO3I9I=gg_*F*nhaMF9-2#a;euJ70**D%|kx6p>8;w6Chva~8l*or>vtCw8fBK6{1A(t#iC=>6ZD}x=!vE z&kM_xn>CJSsD6->=d2y83#bN!+COc0<`vkyew<3ve!w~mtk6294zRjJ4n|d0Y1_W- zcA$6k8a*E6P1p%Pc+ch^U;tH6eR2o2D)J5)YO$M=xQmD!i2A9KrS>AW*6-&s%HYj7t-UJFiHVWVD)@)ZJ+Kl!nN?9e9QJKFa?TU)T+1|5zY*&dVktm3CR9Z-O&(u^~wg znvEx61$%P_9Vib0f_Tk1_^(!or+owBX=i3Uv~#_s?$xq2_iOB+tFaB`Gv7FzR48TJ z4e;?*n?I~X-=Bs{o=dnB8hHCOI~?XE3<&~p`_TjI4R zAK}N(3zC!N-q^jXxSK>X5w+s?BJhNj8M4PkW?C&4lhs=nApE= zY;uyOR{U_0B=vdWx23Ws)ZxU9+y^)CtV|#oD7t4`-5uiTgs^>F( zl*KjlcDmm0R5Giddm8;7gk*ZnmBBUwghZ+|>~#o8A`pd=5?P9)SW_txCg5W77vzH) zGN#m5w4@-U1n&~TKW#iAME8PtMxua=X-C?s54oI9FDfQ}=#B}~q5m!H!>ugFz&bW~ zg|R8YPmU`R$!qpJ%@}M%pBkj7 z23Z0c+#OlH!WB6vn@y>ZvXoJ-NZsI*QkKenwj9V{Qd6V8<+w&lK%+nrQN}?)khJZff8r9M`$ z1H*@a{_UkjHx1YJx|sOe4RSXIK`qTx+QH&cqT<0%_L+9b6kdbmcucr5u3;&c}idkg59Di!g{CsI0o^07$EK642 znDP$G7j{h-CD^f0TV;AkA2Q!?nojdndRve!NI(Zl9}p418FEes@~kw(jM=5rSi29W zrB>*{*3gYeyU#~jeMj7@JEJ?NTWm3bmO?|^kRS^W-6;)>opC7An~iW8E&N(%UGelh z$Lo1FlN37h+gbdD31nTM@D;-MN##iQyMayYgcLe>VRr*7Ki=jC+tUhO1?WQkZslzM zJ|h_M<%;fb3_0+yE%jAz#k?Y&3Ol<4s^7=fCs5cPvbTZxLKzP@23Mw5O{yWha(%{hfYwLO^&EqgOK-NGvfDxyHJh#Wfor zGNC6_6h3r-YIYZ)cSO(*ta<2UiDjbJYzEiubOtAeOPYCe@dC+Mc_I4I*3ZAk`v{}C z>r~=B=t($|b2Wr}x`40hdNY(@xhfAGb|4uWH$*{Jq5VmOTI}BUWvyMEw763)kRzcy zEEDWHB=*k7mE=4l>t5_0VQhj;+^1fVCkj=dz&cJ&NLpQzq+2xB$mBzc{P+G<*LBvn?mFyP-A-GnlYI4ZYnd-?bUjJvUURuo4_d^IxTB|Y_4U80TpDx( z5a$?!z*Y$~-^o$fy7|Rj4@fb~1U#cp#tbWn@_tN*S!&5anKkxn9EYU`*^JS|m0(M^m?qyOUAvowFBjNq_=Tox>>@%EG^Rfl(B@Km4MycI0{CIB@hS zp5LVbJmC~aV#VeOXuL*JCZ=wyHQ3)Z`F?}pz1nKYW^;*tB%L$!>|T-db&cOg(%(VC z$2othxd@;dw-bDlyy8RNcNSaN1i7lb1gLG1hrN2L!CI zwAzz-7xnDjXx6gBU=$Uv?W}tmA8$4obtD%Osca1K%eNB(8lkYN4ke*Cs6p!_iukjS zpz(t^2)Fp=)OK7q*@pRKRLVxbd>(C+a0`x?|B(@V5rUMXhU5KX@=tju_^_ZG!Ox_+ z|JCo$P^=AT$FtnlBT_63GThm|VWgLOlahKq#^pyFL?iKt7>x4i~P9SSvQ} z23cD&(^`j1Zq4MWpF->RsdCtKo=VY!6KRYgK#v<9lJ#n%xFF{~Bxz)nX{m9&6Xz7% zOpI)hj}|kiq?buPWL3Q9!;e0-0lKph>{yuYgj8SZS$o1QmG`nTB(R_d1zJ$|qTvDu zK@v$1_pM%OC`-ej2A!d`_Z=o&bWldMFzc9~q}(8Jgm&jP)jixdzHD&Bi!BL42WK(a z$J7HHc@%fqzA-wXhxacnf#&1zk}_MQOrVM0;&?eU{@G-UA?Ovp{6{Y(&J4qdrhclS z^f_T}2F**|48z=+P3x$wb1~hIY&s#xj;zn_68~u&|J7E1Oa42XlM66z!2aJY%v#*# zxpbdkuvbdcXeVu_K44D9sBo5VE4x1sDVGA?tbo&i5irD1m&cE;^*!5%nlCSGOF5_d z-}VuQuG}e(l^=|n7xLi#e1O&CjQ7VPxq!vm_o1mu{Tk-*Fb?58z2L>sLyXH+seHN6=9H;wVy zDwr;x88XHMyiiJrLd@Z#gUTSF5i$VBk6-YZ=a%=M{$hn-te~Ep1u|e19vM);PL#VO zZ}_9+2`mh=&Yug|A*%~sh7n>%-o5A;!*_~Yo3V!Rj)!Hqfsf^&SyX||IXJlc)Ib@l z5#5pw@7!*20~L8T5Ow8lsC`Bve83+J7))B1d;oA5*!+j&uZ5n059f=n=D*!VnUZ># z`0!59>u}~j9is`AAjNY0`$IdK3I7;nO54 zz*KPpO0TO4ON$H1!#2a01Z<=1^1*($sxw@_BMz zBFGlGMxOKR6mB);LKK!cuR3_R1(LHhK_G(pL5sc)C@e3S?nBw;|1__lud9#+Jbb>pyln`EaPUValRpb zV6ZQoV1=kW&IN1I8%2Laq+r+>ChxSN$7Fakg_x60AHU-VAqDwR&n})b7)4tT5>CIt z#Myt`)3vhELx!awx_)fLt0W{`b+oI^YNeEYpTtZpqp9K6SH1)S%2KX z=&xf_zJ{iCE!c~VFMHU3WGio4Jy(7`t`32m&mF)J2o;uS)sh0FCR(S;^=X(!J8=nV zO4Fnf6e=8HTk6ikLZ8Xi7j^g42T}MEEf0kzyhp-+t3XBjG`azr`Fv0(HDh#GHY_|! zQDzb>4pCiTP;%lI*2P_7e9rlE+FzMsr14D!nsSYJlJ_8gJpL`{t)I@WJbx6qSkfoj z0Y{;qIuGxuS7~p2Rf{~!w~tUkja>c@&Wo6U&qC=aNjGgR{&K%bIG2Z?Viq zmCxkyxF&(dt-bPBz8_n&O*_AZqCs7)*5hkiuYhe6(P-qFq_Hw&^t#MY6B$(*K*}(B z6dQ;9t}RCdyV?H+8R(?NPs!n~5?It*L}^$ax}-kJyQle^pQ(RvX{bxh*W7xnO0S>D z1afy=LLYovQr|vVqmANynfLmu7JQ2-GLic8R}~$vU=VG$>EO4w-3(nX^q56QTW^)K zsO;Ux>0O@8U2xBqhnf|uTRc?O(1dMm!{78pc`pnvOHB_MMK+!*a~wcg)xsi;&GD19 z(-exQB@ao9r>o1O_dK>x<6ry7g(6ywLbvlSU;VT^?ND5dF>2iVUR8jMbfkFF{kXHe~JV%$j$y}V#C`UToVo0QE;Auor`i1>4-QtCy zGWcMnu}f!XO|=G#8rGY6U#t6$eL2Rp<~9JcSFq2%9}!y*m%e{hR6@PXFDp;~8k@9;9e3kbaQiPmYRBY)V%fk>|Ldyb}$RhGf z!M~)Uyj?bOiS)*oJ|h_TQzhXXXHjAOQOVhj3s{qh=Z zZBLcXyfndgb`4~@u*@E?Qp z$okNiRB(jTnDyx1l2%{@fV}5A?xWTUqWOQNhxPL}7FQ8XB6-O)J@D5Gkh-$AQl)}r G@P7chCQ^w2 literal 13484 zcmcJ$cTiN#`Yt+%f+83|(nwH|oMFgOodHRbL6E3~MV6eCh{OSj!jN=^oO2$+Ckzq> z7;;8(2FV%DeEa@x)jhZN-nI9sI)ALHUR|A5cfb8U&-3;QQ+p#%Mgk@Qfk0#mub~bc6Wz4SWvxLqGlnMC_uV0wZ1}xc`u!CWX@A@3MD3zSeUFf$oz3{SXXH`(5BO zZ(6=kc>w~IMc=(Jy#)e6Bov@8v^*xZ($F@~J7#iO5B>S_OyX`4&)8CKw-8yjCJBBw zYYluL^0w_x%UKA_hV3KbwA?|F#}Jz4K9% zr@A`h$g6=kM`lJ@dSWFyL}Gpn6?3hK3KT8%7d~Q{?`c~w+F6@v-H1pzcAi;XjP9Nj zIls)Ad`0w4T({~FO z%wck+=Q0GDHo_;$UJ^C&q`grh;L^PxtnR1kex6P9k;mXlXt{N1eIp)BS_wgI{q$g9Y<;a6ci| z8X3l8#$Q^;ynrlsumEoA(kIt@_0RJUx+1qj@44owhzjKhK zJg*>nEPi4`0_s1G_)krZ<3cOU56;V!tC@{ACN!TRI!}wQDvn0nGk1-;7-hc!7}Olj zXvVdw3SHs9x3CpCn-IOQA(BJffZp0%$vyw0;IOl_)}FDwkFm^$w3nQOq|9|&-jBQM zCbP9gZwQoSMlayTO?DKHjzd(BkouD+AM}#r*K(E?x2KoZy63ZFp)&>zUUp`?kD-=t z{KRIw8?zYNOP1+Xu9Ur0%QwT)rRC=-bAdiit2=pNOJSJTKe|fdkI*jnR_D{S@zwn##o|^S{ z6}Z^d*V4vX4vmY|d7xJ|?}rk0rdK-5CRG8QBin26!(kibsUKwMae%<;$K#H*8A4sZ zbULFeeRT-6NK=ZJ)5qPBUkDaNL_weq?WQf`HBa-Wftfh_oj)CCI|c}0=q=R_w$&VLxXRL6h-6vwZQCdALT&n7J^g1n>e z^B%tb@#sa3&GRQD`;&9~u|tP~Xk;1B_g#~ni?kz2qAS6w(v8W8%W>0_?fo75bzgpG zp|pGc>NQQ}*@Fcnl<~(a-pdqTCl`RnX-DRsCSpHfu&dj0a?)G@(7rlAWEt{Icn=8U zR;&7F)-s%pTF9|SS9&AawchTIY#9#6{kcLg8MVFhiK`AP;i2_~>8(D(E!UIDyo=<1 zLB#$~Wo6dz%+S^gGu^!D?w9s?eMJv;@drOAVz@nt>WIjW`*lTwBY=6r4=+OMETyFMo ztynM1E(IuVKKx>6Z5oR4Q1)sZ?;k(hwOn_IHZnx;B8(Vg*dIUhIKWmhS?a6xgY!%e zH@<~gbl4tGr*{@gk&``p1@T@fHk#~weEM@r#@FWJ(%$8{5w?#AwVt0hi$-4_R=t)E z0P_EMd^9{{aDXS4w~tg%v!<(cUzDV>gI~S!>Y_iLbarR&Zb9i{zIob67F37HQA0J6 zVthB&4xGeQsQQe=9(eJEdbbA?xyP7?W zk2i*8>$}zaD+wQ{B)DvPq%$}r2A`hU?0Oh`e37{DoQO;IN%1X=Xc5zy-`>j-Z5wwz ztJdcm8*JeB8g^VVrof^c49RITWT-<#xviF)6)&D;MFI1LqeE~Lqe=B)1<;UCfH76%Zc6U09)UiedqRnwjJ4q-?j?-g) zBah1Ez(cX`qQ%uI_~4Ltj1#PD2Urg!4Z**Ce(hYTJ5u0LWS5fTKpfrUViA^oZdGzz z*?iqK8r={d(>fi3&Ou*A{2WYPVBq-LTkLa`*C54Q|AfkGsmEcf=t#)TtFQYApXP*PPo zlWHVJbKy^$+D+{bknK|e{WA`tMYM5@R{{-oaX$DpTQqLU26rL;=h$Q&xc@@IG)C9n z9yfJNgM}oEX_yudP9KWw9k*^WC%=PuO%}bR4mgRm7O|_3(pOkA%IUp|cC#zdD2dBS zwP`ld_DZuR&9W*34sPfPj~FCw_3rOW+TfSVIl{uCB#wWnG#Kefs^HNtC>&25bjSQQiaZb0M7IEvJY;m}GnVFjY)vba=jS$i{T>-s+aSi7thhllD^|pVuT}iZo+Ah~tc02XjRY%JNF-Qk}()8VgB}k&#TQJF%^C|G1nIE#AjjlLW-hN?N8j z^hNojMGvGq;XitLZtpnoEF21jQ;Ns78qO^32~tt1{MFHkjmFGBxx92}>>eL|pwhFr z4A&Pi+i;96ue$dua(l}~{c%}&&u+?NOG_;>-18~)@cqTX_7-_HakKsU5nreU!viu%m)X(TkLj5?)yMiNVK4qs$P+WVQgZ*; zy=~^mvtwIf?da$+0g@o#_Y*Ma_Pu|e=rKrbG;5Euy;q#Hl+b%cB2fJNaeaAeaA5`7 z`<}twa_We~dfhCQZzOIGV-VuFlsR2ivaDOB0vwy4`hBndtp&&}Yy3OL0* zo)p>+TD2)qx0%~uUP@$$@aT3F-pJ;M+a-7sBBSa%so1Qr?NdOz`$*#xFmOi@CRls2 z_Cy;#9FRZZg1GQ>+(Tv*X&sp~T+c5KSUdMfVz?2GMIVv8jVQ zUn8O_U*7E;(=-ug9E;W?Ur0Jn&176w$v?0fizrIY$f+5TX+U4jJsIcpvhjBRz1?Z85aP*j^aWRa6~2 zxW0G?v8G*rzBDtqH6=2E0kxZ2&7*r1r!7ky6f>0T>tY#pRf5_&i;1ir=NBYwRU$M~ zHB=#J-VBG1yP`*4stj(7jMvSK_x2f4`Tc?_fOYxyHZu!x-kI)$qr}T1a=L z&c1Slp4J2GPKUzNS?LS)guuz2xTVW|GVYxLux3pT{XWZd8I-%lG@NCUYz5bPAL-)T zDCHls$#h~~c2BQ9V);n6a6#&nSv%T0r1XY0A899>f#E%#WunR-H52nSs{`}Q;QW;eRuBAn%Jp0wbr{J8z;Ro#03vka@U zwBqTYxFVu>UUrM{<#o}kr5b9wZm;YJ$xJ-~eOX8Pt+}`vM^6{_>6dC-UGZ+VkKYLu zaHxX6KXgq+ARRXo#oj^2HN7NjF(Ryh^X03N^d4tJp4vW zN?l(b6%QCOuaQ}(|ADZf;B89B-hDS?FfL{?S}HhE`ywsK_&sMK_xzi0Z@EHIV6{nw zx%=E;ZeMw;)Tva)ExU~ds2iiLYf;3TD`)Ak>5=O98`3^kB^$|U{Z?trWL?iGsXjb6 zsxNfWpjUFxX^8oX>^vGPY}RCM;mBnLSDCIZLgf7@RD z7~Y6Kf@HNj>6B=0-#|kw64-b2U<`gzVe+W4G);3M_AR40@$Rd&4d0ce@|=V9#l^g7 zI~R!y?~e3i7hTeTR3%7_<=XFnl@;cvZUI6c7;w2nS~&)gUPs7jFS+N_3&lG#+48|+Bb}~{b~4*u**2{Fzxx>44-39oRB>U= zj=Kk+#l>BxO7~6J4Z~0vvz2LOsT(b&dOfV*;ed552HHaSnQx>2ZndacaZIaTh7v2I zXbQ%w0j(PpB$|YDyGMVWG?{a*cJIlypP$dAsd$R5NtCp(>Q~|U@T9UU(!L7i>~C-0 zznZ)jmA>$X;mjy&%vSej{`76wD*d#5UU|uQSFr^lPy#%Yx|W15hx#Hq|0oFsD)^xc;n4)zC$%lZX09qjy~0}?a3=ETZF|}^b7gweH~-6Z zPb_RvUZ&+~_Iz7w!uBo0Z>NTfnso54p@e*Ic9AfzuZ84Y#IbKDW#l_|9}wBEth@3Y z)*>H8$*pMYZdvSqOG^F#KF!#T|qG;E{qw>zjG8-a#cmAqP{qvfqsvwX{ z@NAa>w|E-4A*Au$dM@RFhrHX2kt4*~l`Zc}=2um+@i4;06-ViuB$KUHi=b!r1u#%D9$Jr?DEROQ{mFMg|Sx>p0?Z6vzzmzPm zM>ta^3^%Zyv88BU()$lS4`jz_Pk%kyJvLHB$C${ghHGjon2kQX+=YGGZ@aWZ$MWzc z^g;4D&Jy;izVaX>C*>HlIC-Oc!NiX|OO@ve^`2+g_$e^Dy4 zB@`R7;%S!#)e3?KYRC!#*l+)z2#5cb%J?q|mjHg}TQ$5*C-ZTt(rcet>lIy!R>KSY zz@P^=YXGD`GhI7PeX9_tsEr$u>-R9>VtRVI`RdH(PI725xglr$dzWc#nFk<%p#~UO z?{VUa!)F!^6`loxWcO=fanU^9J}y-n1_AM<;A=@=$I_62db0D3a?tz7 zcP;|=qck(bpt;t&WI?|CLg^Xnbo5vuu=1IB6}ONIc@HZQhkMtXXBPjUIh_2Ovqe(JjMBam>&@(AjbNiTYNu+c59Sbl%mtH%oh zI%d!rBmB3N;qO(6YB2HN>tt0=zTPNlPE3qm)yGkyHj4jeW%t{ujiK#+?(Xhl#bN*d zo$&XTs??}ZMsmuH8Xgi71T61jphyA6VctYk$|?u0s+3gr-P042|57Ob7hCwh7TnpX zvA`3zU)vdQ-L%PNT6#T+Kt{MuT3)cj)AyERe>vP0rfLRe$j;N?yq^WFUa;G>a?5V) zhFM5X;cWPIt(LN7d}`LYL=^QEiFMb50t=h6+OlAedz-+J*Rla+acD7-qRbpmKIhNm$Uy8Z9NuVub3Fs|r>fFFq1URs zt%#`}7B{;0BvR3W0}&&x#2d1fRJoT-VKv9dP^a0SsK>|fN!Y3WE$?40#yr%XHl3}W zg9_Ce3lvo>+KGXXeLvhuMoLe!sECSnW_on>O#5p|_?$*cV!!?}HQY-g?%B8I*y+;Q!R^Mb|$rZ@Cx)R(HuUMtL6`tkM`ylzRxr33z zfOQ1sdaANYy7I!3#BI7NdF~!EkSNI*f)wriK;lT#agyuZ$PA7VQGeYtm?wRoHdY6A-r<0ci85nH!TyEi!O8e~3`i^hyfR;iv zC)DC0c@E&Nn`uRDBLDg|#7Bo2U)6a<2CXNUci}iLZe(9VSD6@qOkP(f@9WvA@6e@& zqimXr0ZH_^z~!^wC35VLdS;F@y|D@e>0?22YfPFMmIln$QoV@{V}(;QgB=q^di6QLKVCls zO2*J>GBtYq;BP=EPWH)PVRF~7Dy61mt@ee2FwCm!o~|m#v3O_O76aSxDKn~DFCse; zsEn|{)__}0PN%xO(^8R+N8(lXRf2D&U)VsjN3i+cjZ>!i$`DN{r#U6=%S&B;!-Y3e z3u-+NrQGB_(h)kDOVT9LIAu8KtC#c@7=B&ni6KY`wcucyT8n!DDdkhn|4a_1@h0Pg zLB9WhqYEEu60;HG0=|rBMwx$q`hW~{6MrF0RQQW(c=Eg%8 zR=zY>L{o-@eVKJIOS)*UrqkKec~Q(LZkso&2*QMTljYj3t565@R9OgbZLcphV#mFe zv|G_wE8iH(0?Q`|D!v+E^H8&m+!@#0M}!R3nb;FzTbtbRlsFXPIrLd>w=fsGA6WKG zcXt67s#+vp#G2m3E&pmOp*Geb;&sm3x!U03W@E@x@YZy>KgYT6X=k{OItK;3%qK_8 zT97Dg%ot%y%Iq8v&)hYCXFxGwY;p%OTQ^+z~c;XuS28x90VLE7W0W8rYjpo{ft zLEexHf*EX+=81TdqC$b}1&um~o<;(SR@lL%eC11Z<*db4bT*`1Bf(S}@2Nay2yj z*1tNpDB=D}r`et&o1^qiKT6is?)vL0ef7;=YN>cqo6W=%vPx!U<4*_a?Se58FB7qB z8B>ocW@@^i?2*7w&rfrvR{BYSg%z;<8Gjlo>xI zm5J4A)NtLLd!3myzKLe{4A@vp$jsn5e74OdZFpHSROUwM3l8+<3kaDI0&h8gD0NW3 zs={Rq`qX}8&{nuYeA8CZRuID}BFENBoc83rm7O{~uHG%FuYo?UnOZ)g%!9^~O%Q7UMM6i8TVD_U1T!F4)6?6yIW4x4tMP*T00_L zBV~NCUlM-}4P)J9jc(v%>6b%sxpy3J=ZNZCJz*cCs+wGlbCH}R3DfobmXckA=7S2n zFYkt8lC`X^s@DA7+{b>|ii_>^p2X36?!wC3VzzTul2CvQ+2P%}x`BM zV*xMUifZ4B;tq#&TZ2_3f9zg3CYu(gWh?FpTfjC;^^NJ1+w$O6&1;c>%VQoM@Wbz6 zsyT9m`vTEGxeTlX!4|^g>5~P94ElV^zpTu}VHN9KXtH$GMMmxEFCU~d$gneFh zMO4UXPE{2+li=55nXXiK!fviOEL8pRh;`NctG?F{(oUh8W>pi9ZpvG^EY3#d#KTc* z8DtT%T2^Xuj4ku@1@oIZDQSL7CZ#?GI76F+a$L+MDZvz9o|9Ff{UhF%{1*@D_`1H)&Z1-E=>$ zc242=dko7hQYL+4{p2{%qkm2?qI4k`=|9?d^S5LgK};m!roQ_<-4miKkGGlqju5;9 z8n)7bI02jWJ0OW;vf_Oef6W|IEI=vWm#W<-?E2Mw|KoRkQ4THVe_HK(An4Dyt*;YycJ zS;D~rN_BxgH!35~28f^eerJ}L9LItPwqmp00yK0qF zFlqZF1GI%~0ya6q5X!5MRAxxo?}skh?CO(oR;*=`vpYQ-soJ;D@Re?mW8y8UNhi@1 z-L8CH`j2i-p$l(uc~HC~&R#GURs13uK3{VKmkUxU>#0?O$O)0*KqmlF4)q5QDq?6X zb(A=S-dKOmiohshS;6vfmeZR!5Dng-8Y<)400Z_uaXCl_M`-pq4O}>vw_A3L2K$fc zxO*&aRW?J?YBH(Gq2kN`ZF5~4y0D3L3{K=61;R;Jux#c7is(0Ymnps*#)o5~+JFej zWJXMFgRN4HTMuqt)Cnm|iBmTJ1MmshFz+Q@I6Iu^MZcnle|~$p06;O|U#zkXz)GfK zMQ#aaN~~(YEvpbaewKj!g(Zyy=8qFVR7dfAsaQk zs^8#>WEn|&Pk41$8TNsT4#jJouv4YgEou}N7GIYhFR^lRc)x%xyq5G*|?%2TDgdkEe7Cu+8Br|OZnTAX9=g}F31jYJx9PD#I5X*=WJjHz9c?;xay@!4xE(8 zn^5&w0}}N>6wVH$>m${7W{KP#yp@J7AHnO#;is(uWS{@J-jQk^!Lt1-#`~`>el8oC z7zdY1U`5~O{Ls6k&2?d>Ma=6v#`4mYap`7}MFR3+mOTZOBHHd)+d#dS_l&l_J85Vk!N_gp9@TVhqytcS8 zQ80tK{Vjd9ARU7kb=&Sto~tYeo_J_fR7m%TT*fsn3F(Seesj&k0mP3O&-}yV$dj&n zSwpOrjG0s76|@(_^8WY`Zj?2AcB7!cv-|do|+Zb*ra+tG}V$DgR}?*S;pp zYdDb(wmvNTeJQ5U<+-^icb~-qvNl{an@h-HLypQrNp8EMhdh1C&?`y%1U+5>WwJMr zEEauXBNu!|feh3xw@30Pjvkokiub%19?rH2E8i;g3QZNAP&tCpYkV9Dn!jH}`^TD} z`<%)^oGk)Z+sKvUg2(aqN8UjAi(iKygKWNJMutVEakG2Nna}0LhL$`9Gkqwguw%@l z;3nqg!h1w^q)8~zNKn-yl@}b38kn__gavAz#qYUlDX6=1pJ^s$jSGPbsB{|S1O&<5RbjSU%>QuD;Nvvn>HSM-1J&+M5v5I?nnV4fKnctC%k#_QQp!p=!Jm? z@sCv|`OH{FT@UhMvc6kzspvLQM0ubOWe4sA_N3$;6{#>os7q>f9%0LXsfKxAbMqY# z6(Md>6jcZ6zZshS(uL^n_x}*-|0itVKPfYXJe~cdz48Dg%<%uZ)zo&FAlQY=?aM+w zZNF-XBXZ&3N)KB>V8ULg1<{Stlt^pMeLpixc78N2uiZx!kcEo}I3`;q)wNzZIcgOo zMTSV6wI)}e{dDK_N(B8jbww(mO|)c@_c?VGG&t9^b8zaGAFFzDh(QUr2VC+sCORxH z!{zH0Bw=bqCQNV3020m2>vv3T3xy8|TJ+0#RB|SeyAZrNg9@K+JR(i6Kb&i&CS_43 zEOjeXgEVb#bybnCtUmrfzUss z05gc%NH@d%BRy;5w=+w|8^~RtbK=q$Bs8XlyB&)c&i*8yx!sP}PjQ`$-bYH1VY$HF7Dbq#4Cc zm}uzrG0``Pxfq_MbN4tjTeH)dFeyVgLM|aIGhm6i?m>v(d!I;WTw#4jiV)a!t?ky? zXIB-9-mXx7tS>^3g)!W2THnvi%PQuT)K7tjuOR6wM0_%KRB*P`y`-0Pl#ju#(KS$} zJt}Gk^-Oc?Ff}?sv7SB(=FY!%6b4#?GR075Fz;gg#?Z1+goSls&;pOTYjg<@Sbo$; z2sftn0AByziP9fVgp*yEa>wN|6{>}@<|TLZy+iFz&-8%d>DtjiVa{j3B0!qQo{|DmcSQ<^PwI%Okc-8}v)!65)D6 zh-dV^LNB3&KkE9B$vW>u3buEhLh=M~%7+0Fyh1A%ATh#MQx=|lZy6Vu#D^Y|ea^mx z7j<3#V+#Gh8I7eQE|N~EeRvG6EQ3B0wiwRVBy3UC%LAsm_3q^tD}J}*`|IclXhTU8 znP*G94|QhG>!OUaWumvtA7QTVxQAd)zJ}_NL5kMQrLEpnyrajTQUBX@R1IiaA^#6qRPylOyFg>_vNWb;e>R#1+;ug9NT zL3aj0h4h?{;3eJ-PDO2=;&AqW!w*?V^&CNy``@wuf=E{6A;aA3MCx-5J_N@(1~?yw za=(@w=$L+(NJ^bAIK#SaU}Nv|HF09~mk60FBo}vQwOKC!O9{98`c}$9-diBo!JNqk z{78lGy+i|SK~1cs(Ka6_EC+vEZ%x?kX1v~6_p`u*U<@W4!-jFh!AT;oei()Car6}m z*(`eLmuJ?=h}b>r%1Qg_Gq9Fy3~{Qhs1JVt__UZjzc)Cbo^c#vDII2(*LiAQ|Da-x zUQhg5SGB`0-!Tn$NM|5cc*ZT;FlK;Tm8ru`W?V?{NS=o5&32xr)#S$xyw&K1T|8_4 zT+IQ~xR)lp8-@p~es6^l<1qw9q}^^APZ3eqO?!jgi3EZgDS9aW_u>Nk?NVJUPcou) zd<*a`Z;YAX%$m14W%g&a`$BXy+edlSa)7sG3<)O8xTJ6U@%;X)6D42vQXx#>SDu$m zEd2*AKl6(roc?$FrG@cP{@_HJy7uPbkfYkxX^l2mxQ!AwrgW`OReM8LGvXC0{ves5!oJf3j*0QqlG%p{B*0@?V+nJ z>|kBY_Su?+N;2dpcVwO0e;HbcSU#M(V7Dz338 z;XyFUYJC_;tx5>ChK#`WZAX`X2m!n~UX!bKl0lMY3U1?(QH1ryIBx z!*>BAWot+Ud7q^A&eIRukH<}sTS}HxR-@v6?}x%hqEamB;0(Xmij7ntnqpBb^frP@ z5ZLmTSL(y$5d$mTt}YBti`JNM%VH2lXapp+oCN^-uLhoJQpOFxX$JMnLbzYfM$&cv zo&WCx$!YCzAP`<<`@iLFbCe+qf_0WYT_*7tRxU%d23Tiwo$Ad1Y?d-4=8?EON{&~A zVf!FtN5^*%RBE6IrBL`WDDzn2YR`UbmX^hzh72kG=AhZimGzu&&RT8g6ZBc`8`qW@GkTd!S+)l+zC@FEEN{LnZK-I z5UjqE-xqe1a7xn$nMZer=M9^+tbiMg>#@gjV`r{akQ*VQgNuz-S z{V-@d{;FJ?(D#?h9I_RHbg=6;_n62@KRTF_;D15*7ZZ+O*0G`OjNXnR(Zp)K=<$SI z5YQK2EP1^Vs~v!4E(FM%+ZG%RYyekr8$Pu$c0^RFeG~wq$~J(^@O$sh_U@SjvAZ6g zVZ#=JloZ(-xZA4jgcn2K=Z0gZyAw{sXqMo zu)ArYYj~G%Ocbxnzf~?_^RAa@`1{o7g1LnrHK5ocC(b`u)fBec#sPRa8B6jA6L(~Jfk5krD=sy z_;0^^S&PaiEx@pYeV>}u*AP$z|8rhKR|EK@HF?%V_zNbM%LqC73{b?oI-QDS=nHsJ zKL*kC6ylx_7Gj@TA8z&22*`vInFz{4RPc79h>!lgSIZEW6=ySc$+nfXD_U6M`>U0W z0{c2G>;8>+)XnZnwvg*^2^Eoj`aGyhf&tmkie)I#_sCP&Anq8ARdRZI?-IJ>;5WU1 z1>?0m3AqfzEbjP~X+?ueb_KPJ+xdnC50V?reWf3P#(H?c&UKRfcb~EcV)&)k863jo z7FBwvMKn37D1~_aGe?7Zgk0L#yL^{jh4KgjU`+hr9AK}wfYuqY&R|T1QXeVhgq{kl z9SyrS~U|m#3jodxwppv;m;?hi@QW z=B+%Z78;b%B`n~IKj$_)ULovE{ z(-DGM!^{5Y1)!$_6rhj*D!uu?VT*rZ3;(9f6>&w=fL>az(jfk~B9H>?4Yc^B$*2DX DHAj@} From 332aaa529263d3d85d766de3292a3772aa86cb94 Mon Sep 17 00:00:00 2001 From: Grzegorz Pietrzak Date: Wed, 17 Jan 2024 17:09:49 +0100 Subject: [PATCH 2/3] Few more tests --- tests/BlockTest.php | 3 ++- tests/Line/LyricsTest.php | 5 ++++- tests/Line/MetadataTest.php | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/BlockTest.php b/tests/BlockTest.php index d67dd74..211b927 100644 --- a/tests/BlockTest.php +++ b/tests/BlockTest.php @@ -12,9 +12,10 @@ public function testBlockClassAccess(): void { $text = 'This is a test'; $chord = new Chord('C'); - $block = new Block([$chord], $text); + $block = new Block([$chord], $text, true); $this->assertSame([$chord], $block->getChords(), 'Chords are not returned'); $this->assertSame($text, $block->getText(), 'Text is not returned'); + $this->assertTrue($block->isLineEnd()); } } diff --git a/tests/Line/LyricsTest.php b/tests/Line/LyricsTest.php index ee3c76b..86bc917 100644 --- a/tests/Line/LyricsTest.php +++ b/tests/Line/LyricsTest.php @@ -15,7 +15,10 @@ public function testClassAccess(): void $chord = new Chord('C'); $block = new Block([$chord], $text); - $lyrics = new Lyrics([$block]); + $lyrics = new Lyrics([$block], true, true, true); $this->assertSame([$block], $lyrics->getBlocks(), 'Blocks are not returned'); + $this->assertTrue($lyrics->hasChords()); + $this->assertTrue($lyrics->hasText()); + $this->assertTrue($lyrics->hasInlineChords()); } } diff --git a/tests/Line/MetadataTest.php b/tests/Line/MetadataTest.php index b5bd1a4..fea059d 100644 --- a/tests/Line/MetadataTest.php +++ b/tests/Line/MetadataTest.php @@ -17,6 +17,10 @@ public function testClassAccess(): void $metadata2 = new Metadata('Test Name', null); $this->assertEquals('Test Name', $metadata2->getName()); $this->assertNull($metadata2->getValue()); + + $metadata3 = new Metadata('key', 'C'); + $this->assertEquals('Key', $metadata3->getHumanName()); + $this->assertTrue($metadata3->isNameNecessary()); } public static function shortcutProvider(): array @@ -47,4 +51,22 @@ public function testShortcuts($shortName, $longName): void $this->assertEquals($longName, $metadata->getName()); } + + public function testSections(): void { + $metadata = new Metadata('start_of_Zażółć%_JAŹŃ', 'TEST'); + $this->assertEquals('start_of_Zażółć%_JAŹŃ', $metadata->getName()); + $this->assertEquals('zazolc-jazn', $metadata->getSectionType()); + $this->assertEquals('start-of-zazolc-jazn', $metadata->getNameSlug()); + $this->assertEquals('TEST', $metadata->getValue()); + $this->assertTrue($metadata->isSectionStart()); + $this->assertFalse($metadata->isSectionEnd()); + + $metadata = new Metadata('end_of_Zażółć%_JAŹŃ', null); + $this->assertEquals('end_of_Zażółć%_JAŹŃ', $metadata->getName()); + $this->assertEquals('zazolc-jazn', $metadata->getSectionType()); + $this->assertEquals('end-of-zazolc-jazn', $metadata->getNameSlug()); + $this->assertNull($metadata->getValue()); + $this->assertFalse($metadata->isSectionStart()); + $this->assertTrue($metadata->isSectionEnd()); + } } From 3b1f9041e4039d11842db8747549072a4e37d511 Mon Sep 17 00:00:00 2001 From: Grzegorz Pietrzak Date: Wed, 17 Jan 2024 17:11:44 +0100 Subject: [PATCH 3/3] A small fix to test --- tests/Line/MetadataTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Line/MetadataTest.php b/tests/Line/MetadataTest.php index fea059d..580ee7e 100644 --- a/tests/Line/MetadataTest.php +++ b/tests/Line/MetadataTest.php @@ -52,7 +52,8 @@ public function testShortcuts($shortName, $longName): void } - public function testSections(): void { + public function testSections(): void + { $metadata = new Metadata('start_of_Zażółć%_JAŹŃ', 'TEST'); $this->assertEquals('start_of_Zażółć%_JAŹŃ', $metadata->getName()); $this->assertEquals('zazolc-jazn', $metadata->getSectionType());