diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 93b4e62c..076743a0 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -10,75 +10,101 @@ jobs: cs-fix: runs-on: ubuntu-24.04 name: PHP-CS-Fixer + container: + image: php:8.1-cli + steps: - name: Checkout uses: actions/checkout@v4 - - name: Fix CS - uses: docker://oskarstark/php-cs-fixer-ga - with: - args: --diff --dry-run + + - name: Install dependencies + run: | + apt-get update && apt-get install -y \ + git unzip zip \ + libzip-dev libpng-dev libjpeg-dev libfreetype6-dev + + docker-php-ext-configure gd --with-freetype --with-jpeg + docker-php-ext-install gd zip + + curl -sS https://getcomposer.org/installer | php \ + -- --install-dir=/usr/local/bin --filename=composer + + composer install --prefer-dist --no-progress + + - name: Run PHP CS Fixer + run: bin/php-cs-fixer fix --diff --dry-run + tests: + runs-on: ubuntu-24.04 + strategy: matrix: - container: [ "php81", "php82", "php83" ] + php-version: ['8.1', '8.2', '8.3'] + + container: + image: php:${{ matrix.php-version }}-cli - runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Build Docker image - run: docker compose build ${{ matrix.container }} + - name: Install system dependencies + run: | + apt-get update && apt-get install -y \ + git unzip zip \ + libpng-dev libjpeg-dev libfreetype6-dev libzip-dev - - name: Check PHP version + - name: Install PHP extensions run: | - VERSION=$(echo "${{ matrix.container }}" | sed 's/php//') - MAJOR=${VERSION:0:1} - MINOR=${VERSION:1:1} - MIN_VERSION=$((MAJOR * 10000 + MINOR * 100)) - MAX_VERSION=$(((MAJOR * 10000 + (MINOR + 1) * 100))) - docker compose run --rm ${{ matrix.container }} php -r "if (PHP_VERSION_ID < $MIN_VERSION || PHP_VERSION_ID >= $MAX_VERSION) { echo 'Expected PHP $MAJOR.$MINOR but got ' . PHP_VERSION . PHP_EOL; exit(1); }" + docker-php-ext-configure gd --with-freetype --with-jpeg + docker-php-ext-install gd zip - - name: Validate composer.json - run: docker compose run -u $(id -u):$(id -g) --rm ${{ matrix.container }} composer validate + - name: Install Composer + run: | + curl -sS https://getcomposer.org/installer | php \ + -- --install-dir=/usr/local/bin --filename=composer - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-${{ matrix.container }}-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.container }} + - name: Validate composer.json + run: composer validate - name: Install dependencies - run: docker compose run -u $(id -u):$(id -g) --rm ${{ matrix.container }} composer install --prefer-dist --no-progress + run: composer install --prefer-dist --no-progress - name: Run tests - run: CONTAINER=${{ matrix.container }} make tests + run: bin/phpunit --display-warnings --do-not-fail-on-warning + tests-php84: runs-on: ubuntu-24.04 + container: + image: php:8.4-cli + steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Build Docker image - run: docker compose build php84 + - name: Install system dependencies + run: | + apt-get update && apt-get install -y \ + git unzip zip \ + libpng-dev libjpeg-dev libfreetype6-dev libzip-dev - - name: Validate composer.json - run: docker compose run -u $(id -u):$(id -g) --rm php84 composer validate + - name: Install PHP extensions + run: | + docker-php-ext-configure gd --with-freetype --with-jpeg + docker-php-ext-install gd zip - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-php84-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php84 + - name: Install Composer + run: | + curl -sS https://getcomposer.org/installer | php \ + -- --install-dir=/usr/local/bin --filename=composer + + - name: Validate composer.json + run: composer validate - name: Install dependencies - run: docker compose run -u $(id -u):$(id -g) --rm php84 composer install --prefer-dist --no-progress + run: composer install --prefer-dist --no-progress - name: Run tests - run: make tests + run: bin/phpunit --display-warnings --do-not-fail-on-warning \ No newline at end of file diff --git a/src/Badge.php b/src/Badge.php index 4a9e722d..6eb5fee3 100644 --- a/src/Badge.php +++ b/src/Badge.php @@ -32,18 +32,30 @@ class Badge private string $color; private string $style; private string $format; + private ?string $labelColor; + private ?string $logo; + private ?string $logoColor; - public function __construct(string $subject, string $status, string $color, string $style = self::DEFAULT_STYLE, string $format = self::DEFAULT_FORMAT) + public function __construct(string $subject, string $status, string $color, string $style = self::DEFAULT_STYLE, string $format = self::DEFAULT_FORMAT, ?string $labelColor = null, ?string $logo = null, ?string $logoColor = null) { - $this->subject = $this->escapeValue($subject); - $this->status = $this->escapeValue($status); - $this->color = $this->getColorHex($color); - $this->style = $this->escapeValue($style); - $this->format = $this->escapeValue($format); + $this->subject = $this->escapeValue($subject); + $this->status = $this->escapeValue($status); + $this->color = $this->getColorHex($color); + $this->style = $this->escapeValue($style); + $this->format = $this->escapeValue($format); + $this->labelColor = $labelColor ? $this->getColorHex($labelColor) : null; + $this->logo = $logo ? $this->escapeValue($logo) : null; + $this->logoColor = $logoColor ? $this->getColorHex($logoColor) : null; if (!$this->isValidColorHex($this->color)) { throw new \InvalidArgumentException(\sprintf('Color not valid %s', $this->color)); } + if ($this->labelColor && !$this->isValidColorHex($this->labelColor)) { + throw new \InvalidArgumentException(\sprintf('Label color not valid %s', $this->labelColor)); + } + if ($this->logoColor && !$this->isValidColorHex($this->logoColor)) { + throw new \InvalidArgumentException(\sprintf('Logo color not valid %s', $this->logoColor)); + } } /** @@ -77,13 +89,16 @@ public static function fromURI(string $URI): self if (1 !== \preg_match($regex, $path, $match) && (6 > \count($match))) { throw new \InvalidArgumentException('The URI given is not a valid URI' . $URI); } - $subject = $match[1]; - $status = $match[3]; - $color = $match[5]; - $style = isset($query['style']) && '' !== $query['style'] ? $query['style'] : self::DEFAULT_STYLE; - $format = $match[8] ?? self::DEFAULT_FORMAT; - - return new self($subject, $status, $color, $style, $format); + $subject = $match[1]; + $status = $match[3]; + $color = $match[5]; + $style = isset($query['style']) && '' !== $query['style'] ? $query['style'] : self::DEFAULT_STYLE; + $format = $match[8] ?? self::DEFAULT_FORMAT; + $labelColor = $query['labelColor'] ?? null; + $logo = $query['logo'] ?? null; + $logoColor = $query['logoColor'] ?? null; + + return new self($subject, $status, $color, $style, $format, $labelColor, $logo, $logoColor); } /** @@ -120,6 +135,21 @@ public function getSubject(): string return $this->subject; } + public function getLabelColor(): ?string + { + return $this->labelColor ? '#' . $this->labelColor : null; + } + + public function getLogo(): ?string + { + return $this->logo; + } + + public function getLogoColor(): ?string + { + return $this->logoColor ? '#' . $this->logoColor : null; + } + public function __toString(): string { return \sprintf( @@ -156,7 +186,18 @@ private function escapeValue(string $value): string private function getColorHex(string $color): string { - return \array_key_exists($color, self::$colorScheme) ? self::$colorScheme[$color] : $color; + $color = \array_key_exists($color, self::$colorScheme) ? self::$colorScheme[$color] : $color; + $color = \ltrim($color, '#'); + + // Convert 3-digit hex to 6-digit hex + if (3 === \strlen($color)) { + $r = $color[0] . $color[0]; + $g = $color[1] . $color[1]; + $b = $color[2] . $color[2]; + $color = $r . $g . $b; + } + + return $color; } /** @@ -165,7 +206,7 @@ private function getColorHex(string $color): string private function isValidColorHex(string $color) { $color = \ltrim($color, '#'); - $regex = '/^[0-9a-fA-F]{6}$/'; + $regex = '/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/'; return \preg_match($regex, $color); } diff --git a/src/Calculator/SvgTextSizeCalculator.php b/src/Calculator/SvgTextSizeCalculator.php index 887fa3c9..81a0b86b 100644 --- a/src/Calculator/SvgTextSizeCalculator.php +++ b/src/Calculator/SvgTextSizeCalculator.php @@ -15,27 +15,41 @@ use Cog\Unicode\UnicodeString; /** + * SVG-based text size calculator using SVG fonts. + * + * This calculator provides more accurate text measurements using SVG fonts + * but requires additional dependencies (cog/svg-font and cog/unicode). + * * @author Anton Komarev */ class SvgTextSizeCalculator implements TextSizeCalculatorInterface { /** - * Calculate the width of the text box. + * Calculate the width of the text box using SVG fonts. + * + * @throws \RuntimeException If SVG font dependencies are not available */ public function calculateWidth(string $text, int $size = self::TEXT_SIZE): float { - $font = FontList::ofFile(__DIR__ . '/Font/DejaVuSans.svg')->getById('DejaVuSansBook'); - - $letterSpacing = 0.0; - - $width = $font->computeStringWidth( - UnicodeString::of($text), - $size, - $letterSpacing, - ); - - $shieldPaddingX = self::SHIELD_PADDING_EXTERNAL + self::SHIELD_PADDING_INTERNAL; - - return \round($width + $shieldPaddingX, 1); + if (!\class_exists(FontList::class)) { + throw new \RuntimeException('SVG font dependencies not available. Please install cog/svg-font and cog/unicode packages, or use GDTextSizeCalculator instead.'); + } + + try { + $font = FontList::ofFile(__DIR__ . '/Font/DejaVuSans.svg')->getById('DejaVuSansBook'); + $letterSpacing = 0.0; + + $width = $font->computeStringWidth( + UnicodeString::of($text), + $size, + $letterSpacing, + ); + + $shieldPaddingX = self::SHIELD_PADDING_EXTERNAL + self::SHIELD_PADDING_INTERNAL; + + return \round($width + $shieldPaddingX, 1); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to calculate text width with SVG fonts: ' . $e->getMessage(), 0, $e); + } } } diff --git a/src/Poser.php b/src/Poser.php index fe03a674..f20f21b2 100644 --- a/src/Poser.php +++ b/src/Poser.php @@ -54,7 +54,7 @@ private function addStyleRender(RenderInterface $render): void $this->renders[$render->getBadgeStyle()] = $render; } - private function getRenderFor(string $style): RenderInterface + public function getRenderFor(string $style): RenderInterface { if (!isset($this->renders[$style])) { throw new \InvalidArgumentException(\sprintf('No render founds for this style [%s]', $style)); diff --git a/src/Render/LocalSvgRenderer.php b/src/Render/LocalSvgRenderer.php index b2e40127..a689cdde 100644 --- a/src/Render/LocalSvgRenderer.php +++ b/src/Render/LocalSvgRenderer.php @@ -26,11 +26,38 @@ abstract class LocalSvgRenderer implements RenderInterface { public const VENDOR_COLOR = '#555'; + /** + * Horizontal padding on each side of a text section (px). + * Shields.io uses 5 px per side; we match that for visual parity. + */ + protected const PADDING_H = 2; + + /** + * Gap between logo right edge and text start (px). + */ + protected const LOGO_TEXT_GAP = 3; + + /** + * Logo size (px). + */ + protected const LOGO_WIDTH = 14; + + /** + * Logo vertical offset for 20 px-tall badges. + * Override in subclasses for different badge heights. + */ + protected function logoY(): int + { + return 3; + } + private TextSizeCalculatorInterface $textSizeCalculator; private string $templatesDirectory; - public function __construct(?TextSizeCalculatorInterface $textSizeCalculator = null, ?string $templatesDirectory = null) - { + public function __construct( + ?TextSizeCalculatorInterface $textSizeCalculator = null, + ?string $templatesDirectory = null + ) { $this->textSizeCalculator = $textSizeCalculator ?? new GDTextSizeCalculator(); $this->templatesDirectory = $templatesDirectory ?? (__DIR__ . '/../Resources/templates'); } @@ -45,9 +72,6 @@ public function render(Badge $badge): Image abstract protected function getTemplateName(): string; - /** - * @return string SVG content of the template - */ private function getTemplate(string $style): string { if (null === $this->templatesDirectory) { @@ -63,26 +87,26 @@ private function getTemplate(string $style): string return \file_get_contents($filepath); } - private function stringWidth(string $text): float + protected function stringWidth(string $text): float { - if (null === $this->textSizeCalculator) { - throw new \InvalidArgumentException('TextSizeCalculator cannot be null'); - } - return $this->textSizeCalculator->calculateWidth($text); } private function renderSvg(string $render, array $parameters, string $style): Image { foreach ($parameters as $key => $variable) { - $render = \str_replace(\sprintf('{{ %s }}', $key), $variable, $render); + $render = \str_replace(\sprintf('{{ %s }}', $key), (string) $variable, $render); } + $render = \preg_replace('/\s+/', ' ', $render); + $render = \str_replace('> <', '><', $render); + try { $xml = new \SimpleXMLElement($render); } catch (\Exception $e) { - throw new \RuntimeException('Generated string is not a valid XML'); + throw new \RuntimeException('Generated string is not a valid XML: ' . $e->getMessage()); } + if ('svg' !== $xml->getName()) { throw new \RuntimeException('Generated xml is not a SVG'); } @@ -92,18 +116,75 @@ private function renderSvg(string $render, array $parameters, string $style): Im protected function buildParameters(Badge $badge): array { - $parameters = []; - - $parameters['vendorWidth'] = $this->stringWidth($badge->getSubject()); - $parameters['valueWidth'] = $this->stringWidth($badge->getStatus()); - $parameters['totalWidth'] = $parameters['valueWidth'] + $parameters['vendorWidth']; - $parameters['vendorColor'] = static::VENDOR_COLOR; - $parameters['valueColor'] = $badge->getHexColor(); - $parameters['vendor'] = $badge->getSubject(); - $parameters['value'] = $badge->getStatus(); - $parameters['vendorStartPosition'] = \round($parameters['vendorWidth'] / 2, 1) + 1; - $parameters['valueStartPosition'] = $parameters['vendorWidth'] + \round($parameters['valueWidth'] / 2, 1) - 1; - - return $parameters; + $hasLogo = (bool) $badge->getLogo(); + $logoOffset = $hasLogo ? (self::LOGO_WIDTH + self::LOGO_TEXT_GAP) : 0; + $subject = $badge->getSubject(); + $status = $badge->getStatus(); + $subjectW = (int) \round($this->stringWidth($subject)); + $statusW = (int) \round($this->stringWidth($status)); + $vendorWidth = self::PADDING_H + $logoOffset + $subjectW + self::PADDING_H; + $valueWidth = self::PADDING_H + $statusW + self::PADDING_H; + $totalWidth = $vendorWidth + $valueWidth; + $vendorCenter = self::PADDING_H + $logoOffset + ($subjectW / 2); + $valueCenter = self::PADDING_H + ($statusW / 2); + $vendorStartX = (int) \round($vendorCenter * 10); + $valueStartX = (int) \round(($vendorWidth + $valueCenter) * 10); + $vendorTextLen = $subjectW * 10; + $valueTextLen = $statusW * 10; + + return [ + 'vendorWidth' => $vendorWidth, + 'valueWidth' => $valueWidth, + 'totalWidth' => $totalWidth, + 'vendorColor' => $badge->getLabelColor() ?: static::VENDOR_COLOR, + 'valueColor' => $badge->getHexColor(), + 'vendor' => $subject, + 'value' => $status, + 'vendorUpper' => \strtoupper($subject), + 'valueUpper' => \strtoupper($status), + 'vendorStartX' => $vendorStartX, + 'valueStartX' => $valueStartX, + 'vendorTextLength' => $vendorTextLen, + 'valueTextLength' => $valueTextLen, + 'vendorWidthMinus1' => $vendorWidth - 1, + 'valueWidthMinus1' => $valueWidth - 1, + 'valueRectX' => $vendorWidth + 0.5, + 'separatorX' => $vendorWidth + 0.5, + 'logoElement' => $hasLogo ? $this->buildLogoElement($badge) : '', + ]; + } + + private function buildLogoElement(Badge $badge): string + { + $y = $this->logoY(); + $logoColor = $badge->getLogoColor() ?: '#fff'; + $logo = $badge->getLogo(); + + if (\str_starts_with($logo, 'data:image/svg+xml;base64,')) { + $b64 = \preg_replace('/\s+/', '', \substr($logo, \strlen('data:image/svg+xml;base64,'))); + $href = 'data:image/svg+xml;base64,' . $b64; + + return \sprintf('', $y, $href); + } + + if (\str_starts_with($logo, 'data:image/')) { + $href = \str_replace(' ', '+', $logo); + + return \sprintf('', $y, \htmlspecialchars($href)); + } + + if (\str_starts_with($logo, 'http')) { + return \sprintf('', $y, \htmlspecialchars($logo)); + } + + $svg = \sprintf( + '', + $logoColor, + $logo + ); + + $href = 'data:image/svg+xml;base64,' . \base64_encode($svg); + + return \sprintf('', $y, $href); } } diff --git a/src/Render/SvgBaseRenderer.php b/src/Render/SvgBaseRenderer.php new file mode 100644 index 00000000..fd4d9e19 --- /dev/null +++ b/src/Render/SvgBaseRenderer.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PUGX\Poser\Render; + +/** + * Base class for simple SVG renderers that follow the standard pattern. + * + * @author Refactored for better maintainability + */ +abstract class SvgBaseRenderer extends LocalSvgRenderer +{ + /** + * Get the template name which should match the badge style. + */ + protected function getTemplateName(): string + { + return $this->getBadgeStyle(); + } +} diff --git a/src/Render/SvgFlatRender.php b/src/Render/SvgFlatRender.php index b3875662..261e06f5 100644 --- a/src/Render/SvgFlatRender.php +++ b/src/Render/SvgFlatRender.php @@ -16,15 +16,10 @@ * * @author Giulio De Donato */ -class SvgFlatRender extends LocalSvgRenderer +class SvgFlatRender extends SvgBaseRenderer { public function getBadgeStyle(): string { return 'flat'; } - - protected function getTemplateName(): string - { - return $this->getBadgeStyle(); - } } diff --git a/src/Render/SvgFlatSquareRender.php b/src/Render/SvgFlatSquareRender.php index 979bb412..10c49149 100644 --- a/src/Render/SvgFlatSquareRender.php +++ b/src/Render/SvgFlatSquareRender.php @@ -16,15 +16,10 @@ * * @author Giulio De Donato */ -class SvgFlatSquareRender extends LocalSvgRenderer +class SvgFlatSquareRender extends SvgBaseRenderer { public function getBadgeStyle(): string { return 'flat-square'; } - - protected function getTemplateName(): string - { - return $this->getBadgeStyle(); - } } diff --git a/src/Render/SvgForTheBadgeRenderer.php b/src/Render/SvgForTheBadgeRenderer.php index f8e24588..66068039 100644 --- a/src/Render/SvgForTheBadgeRenderer.php +++ b/src/Render/SvgForTheBadgeRenderer.php @@ -14,29 +14,28 @@ use PUGX\Poser\Badge; use PUGX\Poser\Calculator\TextSizeCalculatorInterface; +/** + * Renderer for the "for-the-badge" style. + * + * Differences vs. base: + * • Badge height = 28 px → logo sits at y = 7 (vertically centred). + * • Both labels are upper-cased before measuring and rendering. + * • Uses wider per-section padding (10 px each side) to match the + * reference Shields.io implementation for this style. + */ class SvgForTheBadgeRenderer extends LocalSvgRenderer { - public const VENDOR_TEXT_FONT = __DIR__ . '/../Calculator/Font/Verdana.svg'; - public const VALUE_TEXT_FONT = __DIR__ . '/../Calculator/Font/Verdana-Bold.svg'; - public const TEXT_FONT_SIZE = 10; - public const TEXT_FONT_COLOR = '#FFFFFF'; - public const TEXT_LETTER_SPACING = 0.1; - public const PADDING_X = 10; - - private \EasySVG $easy; + /** + * Padding per side for the for-the-badge style. + * Shields.io uses 10 px to give the uppercased text more breathing room. + */ + private const FTB_PADDING = 3; public function __construct( - ?\EasySVG $easySVG = null, ?TextSizeCalculatorInterface $textSizeCalculator = null, ?string $templatesDirectory = null ) { parent::__construct($textSizeCalculator, $templatesDirectory); - - if (null === $easySVG) { - $easySVG = new \EasySVG(); - } - - $this->easy = $easySVG; } public function getBadgeStyle(): string @@ -49,28 +48,45 @@ protected function getTemplateName(): string return $this->getBadgeStyle(); } - protected function buildParameters(Badge $badge): array + protected function logoY(): int { - $parameters = parent::buildParameters($badge); - - $parameters['vendor'] = \mb_strtoupper($parameters['vendor']); - $parameters['value'] = \mb_strtoupper($parameters['value']); - - $this->easy->clearSVG(); - $this->easy->setLetterSpacing(self::TEXT_LETTER_SPACING); - $this->easy->setFont(self::VENDOR_TEXT_FONT, self::TEXT_FONT_SIZE, self::TEXT_FONT_COLOR); - $vendorDimensions = $this->easy->textDimensions($parameters['vendor']); - $parameters['vendorWidth'] = $vendorDimensions[0] + 2 * self::PADDING_X; - $parameters['vendorStartPosition'] = \round($parameters['vendorWidth'] / 2, 1) + 1; - - $this->easy->clearSVG(); - $this->easy->setLetterSpacing(self::TEXT_LETTER_SPACING); - $this->easy->setFont(self::VALUE_TEXT_FONT, self::TEXT_FONT_SIZE, self::TEXT_FONT_COLOR); - $valueDimensions = $this->easy->textDimensions($parameters['value']); - $parameters['valueWidth'] = $valueDimensions[0] + 2 * self::PADDING_X; - $parameters['valueStartPosition'] = $parameters['vendorWidth'] + \round($parameters['valueWidth'] / 2, 1) - 1; + return 7; + } - $parameters['totalWidth'] = $parameters['valueWidth'] + $parameters['vendorWidth']; + protected function buildParameters(Badge $badge): array + { + $parameters = parent::buildParameters($badge); + $hasLogo = (bool) $badge->getLogo(); + $logoOffset = $hasLogo ? (self::LOGO_WIDTH + self::LOGO_TEXT_GAP) : 0; + $vendorText = \mb_strtoupper($badge->getSubject()); + $valueText = \mb_strtoupper($badge->getStatus()); + $subjectW = (int) \round($this->stringWidth($vendorText)); + $statusW = (int) \round($this->stringWidth($valueText)); + $vendorWidth = self::FTB_PADDING + $logoOffset + $subjectW + self::FTB_PADDING; + $valueWidth = self::FTB_PADDING + $statusW + self::FTB_PADDING; + $totalWidth = $vendorWidth + $valueWidth; + $vendorCenter = self::FTB_PADDING + $logoOffset + ($subjectW / 2); + $valueCenter = self::FTB_PADDING + ($statusW / 2); + $vendorStartX = (int) \round($vendorCenter * 10); + $valueStartX = (int) \round(($vendorWidth + $valueCenter) * 10); + $vendorTextLen = $subjectW * 10; + $valueTextLen = $statusW * 10; + + $parameters['vendor'] = $vendorText; + $parameters['value'] = $valueText; + $parameters['vendorUpper'] = $vendorText; + $parameters['valueUpper'] = $valueText; + $parameters['vendorWidth'] = $vendorWidth; + $parameters['valueWidth'] = $valueWidth; + $parameters['totalWidth'] = $totalWidth; + $parameters['vendorStartX'] = $vendorStartX; + $parameters['valueStartX'] = $valueStartX; + $parameters['vendorTextLength'] = $vendorTextLen; + $parameters['valueTextLength'] = $valueTextLen; + $parameters['vendorWidthMinus1'] = $vendorWidth - 1; + $parameters['valueWidthMinus1'] = $valueWidth - 1; + $parameters['valueRectX'] = $vendorWidth + 0.5; + $parameters['separatorX'] = $vendorWidth + 0.5; return $parameters; } diff --git a/src/Render/SvgPlasticRender.php b/src/Render/SvgPlasticRender.php index 69b71122..142a5a74 100644 --- a/src/Render/SvgPlasticRender.php +++ b/src/Render/SvgPlasticRender.php @@ -17,15 +17,10 @@ * @author Claudio D'Alicandro * @author Giulio De Donato */ -class SvgPlasticRender extends LocalSvgRenderer +class SvgPlasticRender extends SvgBaseRenderer { public function getBadgeStyle(): string { return 'plastic'; } - - protected function getTemplateName(): string - { - return $this->getBadgeStyle(); - } } diff --git a/src/Render/SvgSocialRender.php b/src/Render/SvgSocialRender.php new file mode 100644 index 00000000..bb4e5edd --- /dev/null +++ b/src/Render/SvgSocialRender.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PUGX\Poser\Render; + +use PUGX\Poser\Badge; + +/** + * Renderer for the "social" style. + * + * The social badge has a 6 px gap between the left (vendor) pill and the + * right (value) pill. That gap widens totalWidth and shifts the value + * text centre, but the individual pill widths are unchanged. + * + * Template variables specific to social.svg: + * • valueRectX – x origin of the right pill rect (vendorWidth + GAP + 0.5) + * • vendorWidthMinus1 – vendorWidth - 1 (used for the left pill stroke rect) + * • valueWidthMinus1 – valueWidth - 1 (used for the right pill stroke rect) + * • separatorX – x of the small chevron separator (vendorWidth + 0.5) + * • valueStartX – 10× text centre of the right pill (accounts for the gap) + */ +class SvgSocialRender extends SvgBaseRenderer +{ + private const PILL_GAP = 3; + + public function getBadgeStyle(): string + { + return 'social'; + } + + protected function buildParameters(Badge $badge): array + { + $parameters = parent::buildParameters($badge); + + $vendorWidth = $parameters['vendorWidth']; + $valueWidth = $parameters['valueWidth']; + $statusW = (int) \round((int) $parameters['valueTextLength'] / 10); + $parameters['totalWidth'] = $vendorWidth + self::PILL_GAP + $valueWidth; + $parameters['valueRectX'] = $vendorWidth + self::PILL_GAP + 0.5; + $parameters['vendorWidthMinus1'] = $vendorWidth - 1; + $parameters['valueWidthMinus1'] = $valueWidth - 1; + $parameters['separatorX'] = $vendorWidth + 0.5; + $valueCenter = $vendorWidth + self::PILL_GAP + self::PADDING_H + ($statusW / 2); + $parameters['valueStartX'] = (int) \round($valueCenter * 10); + + return $parameters; + } +} diff --git a/src/Resources/templates/flat-square.svg b/src/Resources/templates/flat-square.svg index 8bb37724..c24aa4d2 100644 --- a/src/Resources/templates/flat-square.svg +++ b/src/Resources/templates/flat-square.svg @@ -1,20 +1,12 @@ - - - - - - - - - - + + {{ vendor }}: {{ value }} + + - - - {{ vendor }} - {{ vendor }} - {{ value }} - {{ value }} + {{ logoElement }} + + {{ vendor }} + {{ value }} - + \ No newline at end of file diff --git a/src/Resources/templates/flat.svg b/src/Resources/templates/flat.svg index bf8eb641..a994cff5 100644 --- a/src/Resources/templates/flat.svg +++ b/src/Resources/templates/flat.svg @@ -1,20 +1,22 @@ - - + + {{ vendor }}: {{ value }} + - + - - - + + + - + - - {{ vendor }} - {{ vendor }} - {{ value }} - {{ value }} + {{ logoElement }} + + + {{ vendor }} + + {{ value }} - + \ No newline at end of file diff --git a/src/Resources/templates/for-the-badge.svg b/src/Resources/templates/for-the-badge.svg index e334c7ff..25fa77ef 100644 --- a/src/Resources/templates/for-the-badge.svg +++ b/src/Resources/templates/for-the-badge.svg @@ -1,11 +1,12 @@ - - {{ vendor }}: {{ value }} - - - - - - {{ vendor }} - {{ value }} - - + + {{ vendorUpper }}: {{ valueUpper }} + + + + + {{ logoElement }} + + {{ vendorUpper }} + {{ valueUpper }} + + \ No newline at end of file diff --git a/src/Resources/templates/plastic.svg b/src/Resources/templates/plastic.svg index fc36097e..f27cec6d 100644 --- a/src/Resources/templates/plastic.svg +++ b/src/Resources/templates/plastic.svg @@ -1,18 +1,24 @@ - - - + + {{ vendor }}: {{ value }} + + - + - - - - - - {{ vendor }} - {{ vendor }} - {{ value }} - {{ value }} + + + + + + + - + {{ logoElement }} + + + {{ vendor }} + + {{ value }} + + \ No newline at end of file diff --git a/src/Resources/templates/social.svg b/src/Resources/templates/social.svg new file mode 100644 index 00000000..378f2436 --- /dev/null +++ b/src/Resources/templates/social.svg @@ -0,0 +1,27 @@ + + {{ vendor }}: {{ value }} + + + + + + + + + + + + + + + + + + {{ logoElement }} + + \ No newline at end of file diff --git a/src/UI/Command.php b/src/UI/Command.php index 262f4a15..7c9df0ef 100644 --- a/src/UI/Command.php +++ b/src/UI/Command.php @@ -8,6 +8,7 @@ use PUGX\Poser\Render\SvgFlatSquareRender; use PUGX\Poser\Render\SvgForTheBadgeRenderer; use PUGX\Poser\Render\SvgPlasticRender; +use PUGX\Poser\Render\SvgSocialRender; use PUGX\Poser\ValueObject\InputRequest; use Symfony\Component\Console\Command\Command as BaseCommand; use Symfony\Component\Console\Input\InputArgument; @@ -31,32 +32,20 @@ class Command extends BaseCommand public function __construct(?string $name = null) { - parent::__construct($name); - $this->poser = new Poser([ new SvgPlasticRender(), new SvgFlatRender(), new SvgFlatSquareRender(), new SvgForTheBadgeRenderer(), + new SvgSocialRender(), ]); $this->header = self::HEADER; - } - private function init(): void - { - $this->poser = new Poser([ - new SvgPlasticRender(), - new SvgFlatRender(), - new SvgFlatSquareRender(), - new SvgForTheBadgeRenderer(), - ]); - $this->header = self::HEADER; + parent::__construct($name); } protected function configure(): void { - $this->init(); - $this ->setName('generate') ->setDescription('Create a badge you are a Poser.') diff --git a/tests/Fixtures/flat-square.svg b/tests/Fixtures/flat-square.svg index 6e7903c5..2118a20e 100644 --- a/tests/Fixtures/flat-square.svg +++ b/tests/Fixtures/flat-square.svg @@ -1,20 +1 @@ - - - - - - - - - - - - - - - license - license - MIT - MIT - - +license: MITlicenseMIT \ No newline at end of file diff --git a/tests/Fixtures/flat.svg b/tests/Fixtures/flat.svg index a86cc245..ffc1e052 100644 --- a/tests/Fixtures/flat.svg +++ b/tests/Fixtures/flat.svg @@ -1,20 +1 @@ - - - - - - - - - - - - - - - license - license - MIT - MIT - - +license: MITlicenseMIT \ No newline at end of file diff --git a/tests/Fixtures/for-the-badge-with-logo.svg b/tests/Fixtures/for-the-badge-with-logo.svg new file mode 100644 index 00000000..e49b47c9 --- /dev/null +++ b/tests/Fixtures/for-the-badge-with-logo.svg @@ -0,0 +1 @@ +BUILD: PASSINGBUILDPASSING \ No newline at end of file diff --git a/tests/Fixtures/for-the-badge.svg b/tests/Fixtures/for-the-badge.svg new file mode 100644 index 00000000..d5859aa5 --- /dev/null +++ b/tests/Fixtures/for-the-badge.svg @@ -0,0 +1 @@ +LICENSE: MITLICENSEMIT \ No newline at end of file diff --git a/tests/Fixtures/social-with-logo.svg b/tests/Fixtures/social-with-logo.svg new file mode 100644 index 00000000..64fed060 --- /dev/null +++ b/tests/Fixtures/social-with-logo.svg @@ -0,0 +1 @@ +github: stars \ No newline at end of file diff --git a/tests/Fixtures/social.svg b/tests/Fixtures/social.svg new file mode 100644 index 00000000..df3be1be --- /dev/null +++ b/tests/Fixtures/social.svg @@ -0,0 +1 @@ +twitter: follow \ No newline at end of file diff --git a/tests/Render/SvgForTheBadgeRendererTest.php b/tests/Render/SvgForTheBadgeRendererTest.php new file mode 100644 index 00000000..72f3a2c4 --- /dev/null +++ b/tests/Render/SvgForTheBadgeRendererTest.php @@ -0,0 +1,96 @@ +calculator = new GDTextSizeCalculator(); + $this->render = new SvgForTheBadgeRenderer($this->calculator); + } + + #[Test] + public function itShouldRenderASvg(): void + { + $badge = Badge::fromURI('version-stable-97CA00.svg?style=for-the-badge'); + $image = $this->render->render($badge); + + $this->assertValidSVGImage((string) $image); + } + + #[Test] + public function itShouldRenderALicenseMitExactlyLikeThisSvg(): void + { + $fixture = __DIR__ . '/../Fixtures/for-the-badge.svg'; + $template = \file_get_contents($fixture); + $badge = Badge::fromURI('license-MIT-blue.svg?style=for-the-badge'); + $image = $this->render->render($badge); + + $this->assertEquals($template, (string) $image); + } + + #[Test] + public function itShouldRenderWithLogoExactlyLikeThisSvg(): void + { + $fixture = __DIR__ . '/../Fixtures/for-the-badge-with-logo.svg'; + $template = \file_get_contents($fixture); + $badge = Badge::fromURI('build-passing-brightgreen.svg?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0xIDE3SDl2LTJoMnYyeiIvPjwvc3ZnPg==&style=for-the-badge'); + $image = $this->render->render($badge); + + $this->assertEquals($template, (string) $image); + } + + #[Test] + public function itShouldReturnCorrectBadgeStyle(): void + { + $this->assertEquals('for-the-badge', $this->render->getBadgeStyle()); + } + + #[Test] + public function itShouldUseCorrectLogoYPosition(): void + { + $badge = Badge::fromURI('test-test-000000.svg?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0xIDE3SDl2LTJoMnYyeiIvPjwvc3ZnPg==&style=for-the-badge'); + $image = (string) $this->render->render($badge); + + $this->assertStringContainsString('y="7"', $image); + } + + #[Test] + public function itShouldUppercaseText(): void + { + $badge = Badge::fromURI('test-lowercase-000000.svg?style=for-the-badge'); + $image = (string) $this->render->render($badge); + + $this->assertStringContainsString('TEST', $image); + $this->assertStringContainsString('LOWERCASE', $image); + } + + #[Test] + public function itShouldUseCorrectPadding(): void + { + $badge = Badge::fromURI('test-test-000000.svg?style=for-the-badge'); + $image = (string) $this->render->render($badge); + + $this->assertValidSVGImage($image); + $this->assertStringContainsString('assertStringContainsString('assertMatchesRegularExpression($regex, $svg); + } +} diff --git a/tests/Render/SvgSocialRenderTest.php b/tests/Render/SvgSocialRenderTest.php new file mode 100644 index 00000000..7189f013 --- /dev/null +++ b/tests/Render/SvgSocialRenderTest.php @@ -0,0 +1,115 @@ +calculator = new GDTextSizeCalculator(); + $this->render = new SvgSocialRender($this->calculator); + } + + #[Test] + public function itShouldRenderASvg(): void + { + $badge = Badge::fromURI('twitter-follow-1da1f2.svg?style=social&logo=twitter'); + $image = $this->render->render($badge); + + $this->assertValidSVGImage((string) $image); + } + + #[Test] + public function itShouldRenderTwitterSocialExactlyLikeThisSvg(): void + { + $fixture = __DIR__ . '/../Fixtures/social.svg'; + $template = \file_get_contents($fixture); + $badge = Badge::fromURI('twitter-follow-1da1f2.svg?style=social&logo=twitter'); + $image = $this->render->render($badge); + + $this->assertEquals($template, (string) $image); + } + + #[Test] + public function itShouldRenderGithubSocialExactlyLikeThisSvg(): void + { + $fixture = __DIR__ . '/../Fixtures/social-with-logo.svg'; + $template = \file_get_contents($fixture); + $badge = Badge::fromURI('github-stars-333.svg?style=social&logo=github'); + $image = $this->render->render($badge); + + $this->assertEquals($template, (string) $image); + } + + #[Test] + public function itShouldReturnCorrectBadgeStyle(): void + { + $this->assertEquals('social', $this->render->getBadgeStyle()); + } + + #[Test] + public function itShouldApplyPillGapToTotalWidth(): void + { + $badge = Badge::fromURI('test-test-000000.svg?style=social'); + $image = (string) $this->render->render($badge); + + $this->assertValidSVGImage($image); + $this->assertStringContainsString('assertStringContainsString('render->render($badge); + + $this->assertValidSVGImage($image); + } + + #[Test] + public function itShouldShiftValueStartXForGap(): void + { + $badge = Badge::fromURI('test-test-000000.svg?style=social'); + $image = (string) $this->render->render($badge); + + $this->assertValidSVGImage($image); + $this->assertStringContainsString('transform="scale(.1)"', $image); + } + + #[Test] + public function itShouldRenderWithLogo(): void + { + $badge = Badge::fromURI('test-test-000000.svg?style=social&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0xIDE3SDl2LTJoMnYyeiIvPjwvc3ZnPg=='); + $image = (string) $this->render->render($badge); + + $this->assertValidSVGImage($image); + $this->assertStringContainsString('render->render($badge); + + $this->assertValidSVGImage($image); + } + + private function assertValidSVGImage(string $svg): void + { + $regex = '/^$/'; + $this->assertMatchesRegularExpression($regex, $svg); + } +}