diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 09534e2..1182dde 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,18 +1,27 @@ # SPDX-FileCopyrightText: 2026 LibreSign # SPDX-License-Identifier: AGPL-3.0-or-later -name: lint +name: Lint php -on: - pull_request: - push: - branches: [main] +on: pull_request + +permissions: + contents: read + +concurrency: + group: lint-php-${{ github.head_ref || github.run_id }} + cancel-in-progress: true jobs: - lint: + php-lint: runs-on: ubuntu-latest + name: php-lint + steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Detect minimum PHP from composer.json id: php_min run: | @@ -22,6 +31,6 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: ${{ steps.php_min.outputs.version }} - - run: composer install --no-interaction --prefer-dist - - run: composer bin all install --no-interaction --prefer-dist - - run: composer lint + + - name: Lint + run: composer run php:lint diff --git a/composer.json b/composer.json index e1c953f..73b78a1 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ }, "config": { "sort-packages": true, + "process-timeout": 600, "allow-plugins": { "bamarni/composer-bin-plugin": true, "infection/extension-installer": true @@ -44,12 +45,14 @@ }, "scripts": { "lint": [ + "@php:lint", "@cs:check", "@rector:check", "@psalm", "@duplication:check", "@composer:validate" ], + "php:lint": "find . -type f -name '*.php' -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", "cs:check": "vendor-bin/phpcs/vendor/squizlabs/php_codesniffer/bin/phpcs -q", "cs:fix": "vendor-bin/phpcs/vendor/squizlabs/php_codesniffer/bin/phpcbf -q", "rector:check": "vendor-bin/rector/vendor/rector/rector/bin/rector process --dry-run", diff --git a/src/Pdf/FilesystemPdfImageEmbedder.php b/src/Pdf/FilesystemPdfImageEmbedder.php index 33b9f15..8aabfb9 100644 --- a/src/Pdf/FilesystemPdfImageEmbedder.php +++ b/src/Pdf/FilesystemPdfImageEmbedder.php @@ -12,6 +12,8 @@ use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactoryInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactory; use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\Svg\SvgPdfXObjectFactory; +use LibreSign\XObjectTemplate\Pdf\Svg\SvgPdfXObjectFactoryInterface; final readonly class FilesystemPdfImageEmbedder implements PdfImageEmbedderInterface { @@ -19,22 +21,30 @@ private ImageMetadataInspectorInterface $metadataInspector; private JpegPdfImageFactoryInterface $jpegImageFactory; private PngPdfImageFactoryInterface $pngImageFactory; + private SvgPdfXObjectFactoryInterface $svgXObjectFactory; public function __construct( ?FilesystemImageSourceReaderInterface $sourceReader = null, ?ImageMetadataInspectorInterface $metadataInspector = null, ?JpegPdfImageFactoryInterface $jpegImageFactory = null, ?PngPdfImageFactoryInterface $pngImageFactory = null, + ?SvgPdfXObjectFactoryInterface $svgXObjectFactory = null, ) { $this->sourceReader = $sourceReader ?? new FilesystemImageSourceReader(); $this->metadataInspector = $metadataInspector ?? new ImageMetadataInspector(); $this->jpegImageFactory = $jpegImageFactory ?? new JpegPdfImageFactory(); $this->pngImageFactory = $pngImageFactory ?? new PngPdfImageFactory(); + $this->svgXObjectFactory = $svgXObjectFactory ?? new SvgPdfXObjectFactory(); } public function embed(string $source): EmbeddedPdfImage { $contents = $this->sourceReader->read($source); + + if ($this->isSvgSource($source, $contents)) { + return $this->svgXObjectFactory->create($contents, $source); + } + $imageInfo = $this->metadataInspector->detect($contents, $source); $mime = $this->metadataInspector->resolveMimeType($imageInfo, $source); @@ -46,4 +56,16 @@ public function embed(string $source): EmbeddedPdfImage ), }; } + + private function isSvgSource(string $source, string $contents): bool + { + if (preg_match('/\.svgz?$/i', $source) === 1) { + return true; + } + + $trimmed = ltrim($contents); + + return str_starts_with($trimmed, ' $resource) { - if (($resource['Subtype'] ?? null) !== '/Image') { - throw new InvalidArgumentException(sprintf('Unsupported XObject subtype for "%s".', $alias)); - } - $source = $resource['Source'] ?? null; if (!is_string($source) || $source === '') { throw new InvalidArgumentException( - sprintf('Image resource "%s" must expose a non-empty Source.', $alias), + sprintf('XObject resource "%s" must expose a non-empty Source.', $alias), ); } diff --git a/src/Pdf/Svg/ArcParams.php b/src/Pdf/Svg/ArcParams.php new file mode 100644 index 0000000..bfedf7e --- /dev/null +++ b/src/Pdf/Svg/ArcParams.php @@ -0,0 +1,49 @@ +fromX, + $this->fromY, + $this->toX, + $this->toY, + $radiusX, + $radiusY, + $this->cosTh, + $this->sinTh, + $this->largeArc, + $this->sweep, + ); + } +} diff --git a/src/Pdf/Svg/PathCommandContext.php b/src/Pdf/Svg/PathCommandContext.php new file mode 100644 index 0000000..aac03b9 --- /dev/null +++ b/src/Pdf/Svg/PathCommandContext.php @@ -0,0 +1,28 @@ + */ + public array $commands = [], + ) { + } +} diff --git a/src/Pdf/Svg/SvgArcConverter.php b/src/Pdf/Svg/SvgArcConverter.php new file mode 100644 index 0000000..edd96a4 --- /dev/null +++ b/src/Pdf/Svg/SvgArcConverter.php @@ -0,0 +1,94 @@ +> Array of cubic Bézier control points + */ + public function arcToBezierCurves( + float $fromX, + float $fromY, + float $radiusX, + float $radiusY, + float $rotation, + int $largeArc, + int $sweep, + float $toX, + float $toY, + ): array { + if (abs($toX - $fromX) < 1e-10 && abs($toY - $fromY) < 1e-10) { + return []; + } + + if ($radiusX < 1e-10 || $radiusY < 1e-10) { + return [[$toX, $toY, $toX, $toY, $toX, $toY]]; + } + + $theta = deg2rad($rotation); + $params = new ArcParams( + $fromX, + $fromY, + $toX, + $toY, + $radiusX, + $radiusY, + cos($theta), + sin($theta), + $largeArc, + $sweep, + ); + + $params = $this->math->normalizeArcRadii($params); + + [$centerX, $centerY] = $this->math->calculateArcCenter($params); + + [$startAngle, $deltaAngle] = $this->math->calculateArcAngles($params); + + return $this->math->generateArcCurves( + $params, + $centerX, + $centerY, + $startAngle, + $deltaAngle, + ); + } +} diff --git a/src/Pdf/Svg/SvgArcMath.php b/src/Pdf/Svg/SvgArcMath.php new file mode 100644 index 0000000..71b0ebd --- /dev/null +++ b/src/Pdf/Svg/SvgArcMath.php @@ -0,0 +1,167 @@ +fromX - $params->toX) / 2.0; + $deltaY2 = ($params->fromY - $params->toY) / 2.0; + $primeX = $params->cosTh * $deltaX2 + $params->sinTh * $deltaY2; + $primeY = -$params->sinTh * $deltaX2 + $params->cosTh * $deltaY2; + + $radiusX2 = $params->radiusX * $params->radiusX; + $radiusY2 = $params->radiusY * $params->radiusY; + $primeX2 = $primeX * $primeX; + $primeY2 = $primeY * $primeY; + $scale = $primeX2 / $radiusX2 + $primeY2 / $radiusY2; + if ($scale <= 1.0) { + return $params; + } + + $scaleFactor = sqrt($scale); + + return $params->withRadii($params->radiusX * $scaleFactor, $params->radiusY * $scaleFactor); + } + + /** + * @return array{0:float,1:float} [$cx, $cy] + */ + public function calculateArcCenter(ArcParams $params): array + { + [$primeX, $primeY] = $this->calculatePrimeCoordinates($params); + + $radiusX2 = $params->radiusX * $params->radiusX; + $radiusY2 = $params->radiusY * $params->radiusY; + $primeX2 = $primeX * $primeX; + $primeY2 = $primeY * $primeY; + $numerator = max(0.0, $radiusX2 * $radiusY2 - $radiusX2 * $primeY2 - $radiusY2 * $primeX2); + $denominator = $radiusX2 * $primeY2 + $radiusY2 * $primeX2; + $denominatorBucket = floor($denominator * 1e10); + $squareRoot = $denominatorBucket > 0 ? sqrt($numerator / $denominator) : 0.0; + if ($params->largeArc === $params->sweep) { + $squareRoot = -$squareRoot; + } + + $centerX1 = $squareRoot * $params->radiusX * $primeY / $params->radiusY; + $centerY1 = -$squareRoot * $params->radiusY * $primeX / $params->radiusX; + + $midX = ($params->fromX + $params->toX) / 2.0; + $midY = ($params->fromY + $params->toY) / 2.0; + $centerX = $params->cosTh * $centerX1 - $params->sinTh * $centerY1 + $midX; + $centerY = $params->sinTh * $centerX1 + $params->cosTh * $centerY1 + $midY; + + return [$centerX, $centerY]; + } + + /** + * @return array{0:float,1:float} [$startAngle, $dAngle] + */ + public function calculateArcAngles(ArcParams $params): array + { + [$primeX, $primeY] = $this->calculatePrimeCoordinates($params); + + $vectorUX = $primeX / $params->radiusX; + $vectorUY = $primeY / $params->radiusY; + + $startAngle = atan2($vectorUY, $vectorUX); + + $normSquared = $vectorUX * $vectorUX + $vectorUY * $vectorUY; + $magnitudeBucket = floor($normSquared * 1e10); + $deltaAngle = $magnitudeBucket > 0 ? M_PI : M_PI / 2.0; + + if ($params->sweep === 0) { + $deltaAngle -= 2.0 * M_PI; + } + + return [$startAngle, $deltaAngle]; + } + + /** + * @return array> + */ + public function generateArcCurves( + ArcParams $params, + float $centerX, + float $centerY, + float $startAngle, + float $deltaAngle, + ): array { + $segments = max(1, intval(ceil(abs($deltaAngle) / (M_PI / 2.0)))); + $angleStep = $deltaAngle / $segments; + $tanHalfAngleStep = tan($angleStep / 2.0); + $alpha = abs($angleStep) > 1e-10 + ? sin($angleStep) * (sqrt(4.0 + 3.0 * $tanHalfAngleStep * $tanHalfAngleStep) - 1.0) / 3.0 + : 0.0; + + $curves = []; + $angle1 = $startAngle; + $cos1 = cos($angle1); + $sin1 = sin($angle1); + $endX1 = $centerX + $params->cosTh * $params->radiusX * $cos1 - $params->sinTh * $params->radiusY * $sin1; + $endY1 = $centerY + $params->sinTh * $params->radiusX * $cos1 + $params->cosTh * $params->radiusY * $sin1; + + foreach (range(0, $segments - 1) as $i) { + $angle2 = $angle1 + $angleStep; + $cos2 = cos($angle2); + $sin2 = sin($angle2); + + $endX2 = $centerX + $params->cosTh * $params->radiusX * $cos2 - $params->sinTh * $params->radiusY * $sin2; + $endY2 = $centerY + $params->sinTh * $params->radiusX * $cos2 + $params->cosTh * $params->radiusY * $sin2; + + if ($i === $segments - 1) { + $endX2 = $params->toX; + $endY2 = $params->toY; + } + + $tangentXD1 = -$params->cosTh * $params->radiusX * $sin1 - $params->sinTh * $params->radiusY * $cos1; + $tangentYD1 = -$params->sinTh * $params->radiusX * $sin1 + $params->cosTh * $params->radiusY * $cos1; + $tangentXD2 = -$params->cosTh * $params->radiusX * $sin2 - $params->sinTh * $params->radiusY * $cos2; + $tangentYD2 = -$params->sinTh * $params->radiusX * $sin2 + $params->cosTh * $params->radiusY * $cos2; + + $curves[] = [ + $endX1 + $alpha * $tangentXD1, + $endY1 + $alpha * $tangentYD1, + $endX2 - $alpha * $tangentXD2, + $endY2 - $alpha * $tangentYD2, + $endX2, + $endY2, + ]; + + $angle1 = $angle2; + $cos1 = $cos2; + $sin1 = $sin2; + $endX1 = $endX2; + $endY1 = $endY2; + } + + return $curves; + } + + /** + * @return array{0:float,1:float} [$px, $py] + */ + private function calculatePrimeCoordinates(ArcParams $params): array + { + $deltaX2 = ($params->fromX - $params->toX) / 2.0; + $deltaY2 = ($params->fromY - $params->toY) / 2.0; + $primeX = $params->cosTh * $deltaX2 + $params->sinTh * $deltaY2; + $primeY = -$params->sinTh * $deltaX2 + $params->cosTh * $deltaY2; + + return [$primeX, $primeY]; + } +} diff --git a/src/Pdf/Svg/SvgColorResolver.php b/src/Pdf/Svg/SvgColorResolver.php new file mode 100644 index 0000000..878d56f --- /dev/null +++ b/src/Pdf/Svg/SvgColorResolver.php @@ -0,0 +1,300 @@ + $classFills Map of CSS class names to fill colors + * @return ?string The resolved fill color, or null if no fill + */ + public function resolveFillColor(DOMElement $element, array $classFills): ?string + { + return $this->resolveColorAttribute($element, 'fill', $classFills, '#000000'); + } + + /** + * Resolve stroke color for an SVG element. + * + * Checks inline stroke attribute, style attribute, CSS classes, and + * ancestor elements in order. + * + * @param DOMElement $element The SVG element to resolve + * @param array $classStrokes Map of CSS class names to stroke colors + * @return ?string The resolved stroke color, or null if no stroke + */ + public function resolveStrokeColor(DOMElement $element, array $classStrokes): ?string + { + return $this->resolveColorAttribute($element, 'stroke', $classStrokes, null); + } + + /** + * Resolve a generic color attribute with inheritance. + * + * Implements SVG color resolution with cascading fallback: + * 1. Inline attribute (highest priority) + * 2. Style attribute + * 3. CSS class definitions + * 4. Ancestor attributes (inheritance chain) + * 5. Default fallback (lowest priority) + * + * @param DOMElement $element The SVG element + * @param string $attributeName The color attribute name (fill/stroke) + * @param array $classColors CSS class to color mappings + * @param ?string $defaultFallback Default color if no other source found + * @return ?string The resolved color, or null + */ + public function resolveColorAttribute( + DOMElement $element, + string $attributeName, + array $classColors, + ?string $defaultFallback, + ): ?string { + // Check inline style attribute + $inlineStyle = $this->extractColorFromStyleAttribute($element->getAttribute('style'), $attributeName); + if ($inlineStyle === 'none') { + return null; + } + + if ($inlineStyle !== null) { + return $inlineStyle; + } + + // Check CSS classes + $classes = $this->extractClasses($element->getAttribute('class')); + + foreach ($classes as $class) { + if (isset($classColors[$class])) { + return $classColors[$class] === 'none' ? null : $classColors[$class]; + } + } + + // Check inline presentation attribute + $inlineColor = $this->normalizeColor($element->getAttribute($attributeName)); + if ($inlineColor === 'none') { + return null; + } + + if ($inlineColor !== null) { + return $inlineColor; + } + + return $this->checkAncestorForColor( + $element, + $attributeName, + $defaultFallback, + ); + } + + /** + * Walk ancestor elements looking for an inherited color value. + */ + private function checkAncestorForColor( + DOMElement $element, + string $attributeName, + ?string $defaultFallback, + ): ?string { + $ancestor = $element->parentNode; + while ($ancestor instanceof DOMElement) { + $ancestorStyle = $this->extractColorFromStyleAttribute( + $ancestor->getAttribute('style'), + $attributeName, + ); + if ($ancestorStyle !== null) { + return $ancestorStyle === 'none' ? null : $ancestorStyle; + } + + $ancestorColor = $this->normalizeColor($ancestor->getAttribute($attributeName)); + if ($ancestorColor !== null) { + return $ancestorColor === 'none' ? null : $ancestorColor; + } + + $ancestor = $ancestor->parentNode; + } + + return $defaultFallback; + } + + /** + * Extract color from a CSS style attribute. + * + * Parses style declarations to extract specific color properties. + * + * @param string $style The inline style attribute value + * @param string $property The property name to extract (fill/stroke) + * @return ?string The color value or null if not found + */ + public function extractColorFromStyleAttribute(string $style, string $property): ?string + { + return $this->extractValueFromStyleAttribute($style, $property); + } + + /** + * Extract a generic value from a CSS style attribute. + * + * Parses semicolon-separated style declarations to extract property values. + * + * @param string $style The inline style attribute value + * @param string $property The property name to extract + * @return ?string The property value or null if not found + */ + public function extractValueFromStyleAttribute(string $style, string $property): ?string + { + if ($style === '') { + return null; + } + + $declarations = preg_split('/;/', $style); + if (!is_array($declarations)) { + return null; + } + + foreach ($declarations as $declaration) { + if ($declaration === '') { + continue; + } + + if (ctype_space($declaration)) { + continue; + } + + $parts = explode(':', $declaration, 2); + if (count($parts) !== 2) { + continue; + } + + [$candidateProperty, $candidateValue] = array_map(trim(...), $parts); + if (strcasecmp($candidateProperty, $property) === 0) { + return $candidateValue; + } + } + + return null; + } + + /** + * Normalize and validate a color value. + * + * Handles trimming, case normalization, hex colors, rgb() values, + * and named colors. Returns null for empty or invalid colors. + * + * @param string $color The color value to normalize + * @return ?string The normalized color or null/special 'none' sentinel + */ + public function normalizeColor(string $color): ?string + { + $trimmed = strtolower(trim($color)); + if ($trimmed === '') { + return null; + } + + if ($trimmed === 'none') { + return 'none'; + } + + if ($this->isHexColor($trimmed)) { + return $trimmed; + } + + $rgb = $this->parseRgbColor($trimmed); + if ($rgb !== null) { + return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]); + } + + return match ($trimmed) { + 'black' => '#000000', + 'white' => '#ffffff', + 'red' => '#ff0000', + 'green' => '#008000', + 'blue' => '#0000ff', + 'yellow' => '#ffff00', + 'gray', 'grey' => '#808080', + default => null, + }; + } + + /** + * @return list + */ + private function extractClasses(string $classAttribute): array + { + $parts = preg_split('/\s+/', $classAttribute, -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($parts)) { + return []; + } + + return $parts; + } + + private function isHexColor(string $color): bool + { + if (!str_starts_with($color, '#')) { + return false; + } + + $hex = substr($color, 1); + $length = strlen($hex); + + return ($length === 3 || $length === 6) && ctype_xdigit($hex); + } + + /** + * @return array{0: int, 1: int, 2: int}|null + */ + private function parseRgbColor(string $color): ?array + { + if (!str_starts_with($color, 'rgb(') || !str_ends_with($color, ')')) { + return null; + } + + $parts = array_map( + static fn (string $part): string => trim($part), + explode(',', substr($color, 4, -1)), + ); + + if (count($parts) !== 3) { + return null; + } + + $channels = []; + + foreach ($parts as $part) { + if ($part === '' || !ctype_digit($part)) { + return null; + } + + $channel = filter_var($part, FILTER_VALIDATE_INT); + if (!is_int($channel) || $channel < 0) { + return null; + } + + $channels[] = min(255, $channel); + } + + return [$channels[0], $channels[1], $channels[2]]; + } +} diff --git a/src/Pdf/Svg/SvgElementPathBuilder.php b/src/Pdf/Svg/SvgElementPathBuilder.php new file mode 100644 index 0000000..c50390c --- /dev/null +++ b/src/Pdf/Svg/SvgElementPathBuilder.php @@ -0,0 +1,405 @@ +localName; + if (!is_string($localName)) { + return null; + } + + $name = strtolower($localName); + + return match ($name) { + 'path' => $this->buildPathElementPath($element, $minX, $maxY, $source, $transformMatrix), + 'polygon' => $this->buildPolygonElementPath($element, $minX, $maxY, $transformMatrix), + 'polyline' => $this->buildPolylineElementPath($element, $minX, $maxY, $transformMatrix), + 'rect' => $this->buildRectElementPath($element, $minX, $maxY, $transformMatrix), + 'circle' => $this->buildCircleElementPath($element, $minX, $maxY, $transformMatrix), + 'ellipse' => $this->buildEllipseElementPath($element, $minX, $maxY, $transformMatrix), + 'line' => $this->buildLineElementPath($element, $minX, $maxY, $transformMatrix), + default => null, + }; + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildPathElementPath( + DOMElement $element, + float $minX, + float $maxY, + string $source, + array $transformMatrix, + ): ?string { + $pathData = trim($element->getAttribute('d')); + if ($pathData === '') { + return null; + } + + return $this->pathParser->convertPathData($pathData, $minX, $maxY, $source, $transformMatrix); + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildPolygonElementPath( + DOMElement $element, + float $minX, + float $maxY, + array $transformMatrix, + ): ?string { + $points = $element->getAttribute('points'); + if ($points === '') { + return null; + } + + preg_match_all('/[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/', $points, $matches); + + $raw = $matches[0]; + $rawCount = count($raw); + if ($rawCount < 4 || $rawCount % 2 !== 0) { + return null; + } + + $commands = []; + $startX = (float) $raw[0]; + $startY = (float) $raw[1]; + [$startX, $startY] = $this->transformResolver->applyTransformToPoint($transformMatrix, $startX, $startY); + $commands[] = sprintf('%F %F m', $startX - $minX, $maxY - $startY); + + $remainingCoordinates = array_slice($raw, 2); + /** @var list $coordinatePairs */ + $coordinatePairs = array_chunk($remainingCoordinates, 2); + + foreach ($coordinatePairs as [$rawX, $rawY]) { + $pointX = (float) $rawX; + $pointY = (float) $rawY; + [$pointX, $pointY] = $this->transformResolver->applyTransformToPoint($transformMatrix, $pointX, $pointY); + $commands[] = sprintf('%F %F l', $pointX - $minX, $maxY - $pointY); + } + + $commands[] = 'h'; + + return implode("\n", $commands); + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildRectElementPath( + DOMElement $element, + float $minX, + float $maxY, + array $transformMatrix, + ): ?string { + $x = $this->extractNumericSvgLength($element->getAttribute('x')); + $y = $this->extractNumericSvgLength($element->getAttribute('y')); + $width = $this->extractNumericSvgLength($element->getAttribute('width')); + $height = $this->extractNumericSvgLength($element->getAttribute('height')); + + if ($width <= 0.0 || $height <= 0.0) { + return null; + } + + $points = [ + [$x, $y], + [$x + $width, $y], + [$x + $width, $y + $height], + [$x, $y + $height], + ]; + + $commands = []; + foreach ($points as $index => [$pointX, $pointY]) { + [$transformedX, $transformedY] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $pointX, + $pointY + ); + $commands[] = sprintf( + '%F %F %s', + $transformedX - $minX, + $maxY - $transformedY, + $index === 0 ? 'm' : 'l' + ); + } + + $commands[] = 'h'; + + return implode("\n", $commands); + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildPolylineElementPath( + DOMElement $element, + float $minX, + float $maxY, + array $transformMatrix, + ): ?string { + $points = $element->getAttribute('points'); + if ($points === '') { + return null; + } + + preg_match_all('/[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/', $points, $matches); + + $raw = $matches[0]; + $rawCount = count($raw); + if ($rawCount < 4 || $rawCount % 2 !== 0) { + return null; + } + + $commands = []; + [$firstX, $firstY] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + (float) $raw[0], + (float) $raw[1], + ); + $commands[] = sprintf('%F %F m', $firstX - $minX, $maxY - $firstY); + + $remainingCoordinates = array_slice($raw, 2); + /** @var list $coordinatePairs */ + $coordinatePairs = array_chunk($remainingCoordinates, 2); + + foreach ($coordinatePairs as [$rawX, $rawY]) { + [$transformedX, $transformedY] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + (float) $rawX, + (float) $rawY, + ); + $commands[] = sprintf('%F %F l', $transformedX - $minX, $maxY - $transformedY); + } + + return implode("\n", $commands); + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildCircleElementPath( + DOMElement $element, + float $minX, + float $maxY, + array $transformMatrix, + ): ?string { + $centerX = $this->extractNumericSvgLength($element->getAttribute('cx')); + $centerY = $this->extractNumericSvgLength($element->getAttribute('cy')); + $radius = $this->extractNumericSvgLength($element->getAttribute('r')); + + if ($radius <= 0.0) { + return null; + } + + return $this->buildEllipsePath($centerX, $centerY, $radius, $radius, $minX, $maxY, $transformMatrix); + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildEllipseElementPath( + DOMElement $element, + float $minX, + float $maxY, + array $transformMatrix, + ): ?string { + $centerX = $this->extractNumericSvgLength($element->getAttribute('cx')); + $centerY = $this->extractNumericSvgLength($element->getAttribute('cy')); + $radiusX = $this->extractNumericSvgLength($element->getAttribute('rx')); + $radiusY = $this->extractNumericSvgLength($element->getAttribute('ry')); + + if ($radiusX <= 0.0 || $radiusY <= 0.0) { + return null; + } + + return $this->buildEllipsePath($centerX, $centerY, $radiusX, $radiusY, $minX, $maxY, $transformMatrix); + } + + /** + * Approximate an axis-aligned ellipse with 4 cubic Bézier curves (κ = 0.5522847498). + * + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + private function buildEllipsePath( + float $centerX, + float $centerY, + float $radiusX, + float $radiusY, + float $minX, + float $maxY, + array $transformMatrix, + ): string { + $kappa = 0.5522847498; + $kappaX = $kappa * $radiusX; + $kappaY = $kappa * $radiusY; + + $topX = $centerX; + $topY = $centerY - $radiusY; + $rightX = $centerX + $radiusX; + $rightY = $centerY; + $bottomX = $centerX; + $bottomY = $centerY + $radiusY; + $leftX = $centerX - $radiusX; + $leftY = $centerY; + + [$topX, $topY] = $this->transformResolver->applyTransformToPoint($transformMatrix, $topX, $topY); + [$rightX, $rightY] = $this->transformResolver->applyTransformToPoint($transformMatrix, $rightX, $rightY); + [$bottomX, $bottomY] = $this->transformResolver->applyTransformToPoint($transformMatrix, $bottomX, $bottomY); + [$leftX, $leftY] = $this->transformResolver->applyTransformToPoint($transformMatrix, $leftX, $leftY); + + [$cpTopRight1X, $cpTopRight1Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX + $kappaX, + $centerY - $radiusY, + ); + [$cpTopRight2X, $cpTopRight2Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX + $radiusX, + $centerY - $kappaY, + ); + [$cpRightBottom1X, $cpRightBottom1Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX + $radiusX, + $centerY + $kappaY, + ); + [$cpRightBottom2X, $cpRightBottom2Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX + $kappaX, + $centerY + $radiusY, + ); + [$cpBottomLeft1X, $cpBottomLeft1Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX - $kappaX, + $centerY + $radiusY, + ); + [$cpBottomLeft2X, $cpBottomLeft2Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX - $radiusX, + $centerY + $kappaY, + ); + [$cpLeftTop1X, $cpLeftTop1Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX - $radiusX, + $centerY - $kappaY, + ); + [$cpLeftTop2X, $cpLeftTop2Y] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $centerX - $kappaX, + $centerY - $radiusY, + ); + + $commands = []; + $commands[] = sprintf('%F %F m', $topX - $minX, $maxY - $topY); + $commands[] = sprintf( + '%F %F %F %F %F %F c', + $cpTopRight1X - $minX, + $maxY - $cpTopRight1Y, + $cpTopRight2X - $minX, + $maxY - $cpTopRight2Y, + $rightX - $minX, + $maxY - $rightY, + ); + $commands[] = sprintf( + '%F %F %F %F %F %F c', + $cpRightBottom1X - $minX, + $maxY - $cpRightBottom1Y, + $cpRightBottom2X - $minX, + $maxY - $cpRightBottom2Y, + $bottomX - $minX, + $maxY - $bottomY, + ); + $commands[] = sprintf( + '%F %F %F %F %F %F c', + $cpBottomLeft1X - $minX, + $maxY - $cpBottomLeft1Y, + $cpBottomLeft2X - $minX, + $maxY - $cpBottomLeft2Y, + $leftX - $minX, + $maxY - $leftY, + ); + $commands[] = sprintf( + '%F %F %F %F %F %F c', + $cpLeftTop1X - $minX, + $maxY - $cpLeftTop1Y, + $cpLeftTop2X - $minX, + $maxY - $cpLeftTop2Y, + $topX - $minX, + $maxY - $topY, + ); + $commands[] = 'h'; + + return implode("\n", $commands); + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildLineElementPath(DOMElement $element, float $minX, float $maxY, array $transformMatrix): string + { + $startX1 = $this->extractNumericSvgLength($element->getAttribute('x1')); + $startY1 = $this->extractNumericSvgLength($element->getAttribute('y1')); + $endX2 = $this->extractNumericSvgLength($element->getAttribute('x2')); + $endY2 = $this->extractNumericSvgLength($element->getAttribute('y2')); + + [$startX1, $startY1] = $this->transformResolver->applyTransformToPoint($transformMatrix, $startX1, $startY1); + [$endX2, $endY2] = $this->transformResolver->applyTransformToPoint($transformMatrix, $endX2, $endY2); + + return implode("\n", [ + sprintf('%F %F m', $startX1 - $minX, $maxY - $startY1), + sprintf('%F %F l', $endX2 - $minX, $maxY - $endY2), + ]); + } + + private function extractNumericSvgLength(string $value): float + { + $trimmed = trim($value); + if ($trimmed === '') { + return 0.0; + } + + if (preg_match('/^[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/', $trimmed, $matches) !== 1) { + return 0.0; + } + + return (float) $matches[0]; + } +} diff --git a/src/Pdf/Svg/SvgPathCommandParser.php b/src/Pdf/Svg/SvgPathCommandParser.php new file mode 100644 index 0000000..3027c42 --- /dev/null +++ b/src/Pdf/Svg/SvgPathCommandParser.php @@ -0,0 +1,643 @@ +tokenizePathData($pathData, $source); + $state = new PathParsingState(); + $context = new PathCommandContext($transformMatrix, $minX, $maxY, $source); + $this->processPathTokens($tokens, $state, $context); + return implode("\n", $state->commands); + } + + /** + * Tokenize SVG path data string into command and number tokens. + * + * @param string $pathData The SVG path data string + * @param string $source Source identifier for error messages + * @return list Array of tokens + */ + private function tokenizePathData(string $pathData, string $source): array + { + preg_match_all( + '/[A-Za-z]|[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/', + $pathData, + $matches, + ); + + if ($matches[0] === []) { + throw new InvalidArgumentException(sprintf('Unsupported or empty SVG path data in "%s".', $source)); + } + + return $matches[0]; + } + + /** + * Process tokenized SVG path data and generate PDF commands. + * + * @param list $tokens + */ + private function processPathTokens(array $tokens, PathParsingState $state, PathCommandContext $context): void + { + $tokenCount = count($tokens); + $index = 0; + $currentCommand = null; + + while ($index < $tokenCount) { + $previousIndex = $index; + $token = $tokens[$index]; + if ($this->isCommandToken($token)) { + $currentCommand = $token; + ++$index; + } + + if ($currentCommand === null) { + throw new InvalidArgumentException( + sprintf('Invalid SVG path command sequence in "%s".', $context->source) + ); + } + + $isRelative = ctype_lower($currentCommand); + $command = strtoupper($currentCommand); + + $this->handlePathCommand( + $command, + $isRelative, + $tokens, + $index, + $tokenCount, + $state, + $context, + ); + + if ($index !== $previousIndex) { + continue; + } + + $index = $tokenCount; + throw new InvalidArgumentException(sprintf('Malformed SVG path data in "%s".', $context->source)); + } + } + + /** + * Route path command to appropriate handler. + * + * @param list $tokens + */ + private function handlePathCommand( + string $command, + bool $isRelative, + array $tokens, + int &$index, + int $tokenCount, + PathParsingState $state, + PathCommandContext $context, + ): void { + match ($command) { + 'M' => $this->handleMoveCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'L' => $this->handleLineCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'H' => $this->handleHorizontalCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'V' => $this->handleVerticalCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'C' => $this->handleCubicCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'S' => $this->handleSmoothCubicCommand( + $tokens, + $index, + $tokenCount, + $isRelative, + $state, + $context, + ), + 'Q' => $this->handleQuadraticCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'T' => $this->handleSmoothQuadraticCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'A' => $this->handleArcCommand($tokens, $index, $tokenCount, $isRelative, $state, $context), + 'Z' => $this->handleClosePathCommand($state), + default => throw new InvalidArgumentException(sprintf( + 'SVG path command "%s" is not supported for source "%s".', + $command, + $context->source, + )), + }; + } + + /** + * @param list $tokens + */ + private function handleMoveCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 2, $context->source); + $state->currentX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + $state->currentY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]); + $state->subpathStartX = $state->currentX; + $state->subpathStartY = $state->currentY; + [$moveX, $moveY] = $this->transformResolver->applyTransformToPoint( + $context->transformMatrix, + $state->currentX, + $state->currentY, + ); + $state->commands[] = sprintf('%F %F m', $moveX - $context->minX, $context->maxY - $moveY); + $state->lastCubicControlX = null; + $state->lastCubicControlY = null; + + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 2, $context->source); + $nextX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + $nextY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]); + $this->appendLineToState($state, $context, $nextX, $nextY); + } + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + + /** + * @param list $tokens + */ + private function handleLineCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 2, $context->source); + $nextX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + $nextY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]); + $this->appendLineToState($state, $context, $nextX, $nextY); + } + } + + /** + * @param list $tokens + */ + private function handleHorizontalCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 1, $context->source); + $state->currentX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + [$lineX, $lineY] = $this->transformResolver->applyTransformToPoint( + $context->transformMatrix, + $state->currentX, + $state->currentY, + ); + $state->commands[] = sprintf('%F %F l', $lineX - $context->minX, $context->maxY - $lineY); + $state->lastCubicControlX = null; + $state->lastCubicControlY = null; + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + } + + /** + * @param list $tokens + */ + private function handleVerticalCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 1, $context->source); + $state->currentY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[0]); + [$lineX, $lineY] = $this->transformResolver->applyTransformToPoint( + $context->transformMatrix, + $state->currentX, + $state->currentY, + ); + $state->commands[] = sprintf('%F %F l', $lineX - $context->minX, $context->maxY - $lineY); + $state->lastCubicControlX = null; + $state->lastCubicControlY = null; + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + } + + /** + * @param list $tokens + */ + private function handleCubicCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 6, $context->source); + $startX1 = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + $startY1 = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]); + $endX2 = $this->resolveCoord($isRelative, $state->currentX, $coordinates[2]); + $endY2 = $this->resolveCoord($isRelative, $state->currentY, $coordinates[3]); + $x = $this->resolveCoord($isRelative, $state->currentX, $coordinates[4]); + $y = $this->resolveCoord($isRelative, $state->currentY, $coordinates[5]); + + $state->commands[] = $this->buildCubicCurveCommand( + $context->transformMatrix, + $context->minX, + $context->maxY, + $startX1, + $startY1, + $endX2, + $endY2, + $x, + $y, + ); + $state->currentX = $x; + $state->currentY = $y; + $state->lastCubicControlX = $endX2; + $state->lastCubicControlY = $endY2; + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + } + + /** + * @param list $tokens + */ + private function handleSmoothCubicCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 4, $context->source); + $startX1 = $this->reflectControlPoint($state->lastCubicControlX, $state->currentX); + $startY1 = $this->reflectControlPoint($state->lastCubicControlY, $state->currentY); + $endX2 = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + $endY2 = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]); + $x = $this->resolveCoord($isRelative, $state->currentX, $coordinates[2]); + $y = $this->resolveCoord($isRelative, $state->currentY, $coordinates[3]); + + $state->commands[] = $this->buildCubicCurveCommand( + $context->transformMatrix, + $context->minX, + $context->maxY, + $startX1, + $startY1, + $endX2, + $endY2, + $x, + $y, + ); + $state->currentX = $x; + $state->currentY = $y; + $state->lastCubicControlX = $endX2; + $state->lastCubicControlY = $endY2; + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + } + + /** + * @param list $tokens + */ + private function handleQuadraticCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 4, $context->source); + $qcpX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + $qcpY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]); + $x = $this->resolveCoord($isRelative, $state->currentX, $coordinates[2]); + $y = $this->resolveCoord($isRelative, $state->currentY, $coordinates[3]); + + [$controlX1, $controlY1, $controlX2, $controlY2] = $this->quadraticToCubicControlPoints( + $state->currentX, + $state->currentY, + $qcpX, + $qcpY, + $x, + $y, + ); + + $this->appendQuadraticAsCubicToState( + $state, + $context, + $controlX1, + $controlY1, + $controlX2, + $controlY2, + $x, + $y + ); + $state->prevQuadCpX = $qcpX; + $state->prevQuadCpY = $qcpY; + } + } + + /** + * @param list $tokens + */ + private function handleSmoothQuadraticCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 2, $context->source); + $qcpX = $this->reflectControlPoint($state->prevQuadCpX, $state->currentX); + $qcpY = $this->reflectControlPoint($state->prevQuadCpY, $state->currentY); + $x = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]); + $y = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]); + [$controlX1, $controlY1, $controlX2, $controlY2] = $this->quadraticToCubicControlPoints( + $state->currentX, + $state->currentY, + $qcpX, + $qcpY, + $x, + $y, + ); + + $this->appendQuadraticAsCubicToState( + $state, + $context, + $controlX1, + $controlY1, + $controlX2, + $controlY2, + $x, + $y + ); + $state->prevQuadCpX = $qcpX; + $state->prevQuadCpY = $qcpY; + } + } + + /** + * @param list $tokens + */ + private function handleArcCommand( + array $tokens, + int &$index, + int $tokenCount, + bool $isRelative, + PathParsingState $state, + PathCommandContext $context, + ): void { + while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) { + $coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 7, $context->source); + $radiusX = abs($coordinates[0]); + $radiusY = abs($coordinates[1]); + $rotation = $coordinates[2]; + $largeArc = (int) $coordinates[3]; + $sweep = (int) $coordinates[4]; + $x = $this->resolveCoord($isRelative, $state->currentX, $coordinates[5]); + $y = $this->resolveCoord($isRelative, $state->currentY, $coordinates[6]); + + $curves = $this->arcConverter->arcToBezierCurves( + $state->currentX, + $state->currentY, + $radiusX, + $radiusY, + $rotation, + $largeArc, + $sweep, + $x, + $y, + ); + + foreach ($curves as $curve) { + [$controlPoint1X, $controlPoint1Y] = $this->transformResolver->applyTransformToPoint( + $context->transformMatrix, + $curve[0], + $curve[1], + ); + [$controlPoint2X, $controlPoint2Y] = $this->transformResolver->applyTransformToPoint( + $context->transformMatrix, + $curve[2], + $curve[3], + ); + [$endX, $endY] = $this->transformResolver->applyTransformToPoint( + $context->transformMatrix, + $curve[4], + $curve[5], + ); + $state->commands[] = sprintf( + '%F %F %F %F %F %F c', + $controlPoint1X - $context->minX, + $context->maxY - $controlPoint1Y, + $controlPoint2X - $context->minX, + $context->maxY - $controlPoint2Y, + $endX - $context->minX, + $context->maxY - $endY, + ); + } + + $state->currentX = $x; + $state->currentY = $y; + $state->lastCubicControlX = null; + $state->lastCubicControlY = null; + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + } + + private function handleClosePathCommand(PathParsingState $state): void + { + $state->commands[] = 'h'; + $state->currentX = $state->subpathStartX; + $state->currentY = $state->subpathStartY; + $state->lastCubicControlX = null; + $state->lastCubicControlY = null; + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + + /** + * Resolve a coordinate as absolute or relative to current position. + */ + private function resolveCoord(bool $isRelative, float $current, float $coord): float + { + return $isRelative ? $current + $coord : $coord; + } + + private function isCommandToken(string $token): bool + { + return strlen($token) === 1 && ctype_alpha($token); + } + + /** + * Reflect a previous control point over the current position. + * Returns current position when no prior control point exists (SVG spec default). + */ + private function reflectControlPoint(?float $prevControl, float $current): float + { + return $prevControl === null ? $current : (2.0 * $current) - $prevControl; + } + + private function appendLineToState( + PathParsingState $state, + PathCommandContext $context, + float $toX, + float $toY, + ): void { + $state->currentX = $toX; + $state->currentY = $toY; + [$transformedX, $transformedY] = $this->transformResolver->applyTransformToPoint( + $context->transformMatrix, + $toX, + $toY + ); + $state->commands[] = sprintf( + '%F %F l', + $transformedX - $context->minX, + $context->maxY - $transformedY + ); + $state->lastCubicControlX = null; + $state->lastCubicControlY = null; + $state->prevQuadCpX = null; + $state->prevQuadCpY = null; + } + + private function appendQuadraticAsCubicToState( + PathParsingState $state, + PathCommandContext $context, + float $controlX1, + float $controlY1, + float $controlX2, + float $controlY2, + float $x, + float $y, + ): void { + $state->commands[] = $this->buildCubicCurveCommand( + $context->transformMatrix, + $context->minX, + $context->maxY, + $controlX1, + $controlY1, + $controlX2, + $controlY2, + $x, + $y, + ); + $state->currentX = $x; + $state->currentY = $y; + $state->lastCubicControlX = null; + $state->lastCubicControlY = null; + } + + /** + * @return array{0:float,1:float,2:float,3:float} [$controlX1, $controlY1, $controlX2, $controlY2] + */ + private function quadraticToCubicControlPoints( + float $fromX, + float $fromY, + float $qcpX, + float $qcpY, + float $toX, + float $toY, + ): array { + $controlX1 = $fromX + (2.0 / 3.0) * ($qcpX - $fromX); + $controlY1 = $fromY + (2.0 / 3.0) * ($qcpY - $fromY); + $controlX2 = $toX + (2.0 / 3.0) * ($qcpX - $toX); + $controlY2 = $toY + (2.0 / 3.0) * ($qcpY - $toY); + + return [$controlX1, $controlY1, $controlX2, $controlY2]; + } + + /** + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix + */ + private function buildCubicCurveCommand( + array $transformMatrix, + float $minX, + float $maxY, + float $startX1, + float $startY1, + float $endX2, + float $endY2, + float $x, + float $y, + ): string { + [$transformX1, $transformY1] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $startX1, + $startY1 + ); + [$transformX2, $transformY2] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $endX2, + $endY2 + ); + [$transformX, $transformY] = $this->transformResolver->applyTransformToPoint( + $transformMatrix, + $x, + $y + ); + + return sprintf( + '%F %F %F %F %F %F c', + $transformX1 - $minX, + $maxY - $transformY1, + $transformX2 - $minX, + $maxY - $transformY2, + $transformX - $minX, + $maxY - $transformY, + ); + } +} diff --git a/src/Pdf/Svg/SvgPathNumberReader.php b/src/Pdf/Svg/SvgPathNumberReader.php new file mode 100644 index 0000000..27da2ba --- /dev/null +++ b/src/Pdf/Svg/SvgPathNumberReader.php @@ -0,0 +1,38 @@ + $tokens + * @return list + */ + public function readPathNumbers(array $tokens, int &$index, int $count, string $source): array + { + $values = []; + + for ($i = 0; $i < $count; ++$i) { + $token = $tokens[$index] ?? null; + + if ($token === null || strlen($token) === 1 && ctype_alpha($token)) { + throw new InvalidArgumentException(sprintf( + 'Malformed SVG path data in "%s".', + $source, + )); + } + + $values[] = (float) $token; + ++$index; + } + + return $values; + } +} diff --git a/src/Pdf/Svg/SvgPdfXObjectFactory.php b/src/Pdf/Svg/SvgPdfXObjectFactory.php new file mode 100644 index 0000000..64f6c9c --- /dev/null +++ b/src/Pdf/Svg/SvgPdfXObjectFactory.php @@ -0,0 +1,263 @@ +parseSvgRoot($svgContents, $source); + [$minX, $minY, $width, $height] = $this->resolveViewBox($svg, $source); + $maxY = $minY + $height; + + /** @var array{0: array, 1: array} $classColorMaps */ + $classColorMaps = $this->extractClassColorMaps($svg); + $classFills = $classColorMaps[0]; + $classStrokes = $classColorMaps[1]; + $commands = []; + + foreach ($this->iterateDrawableElements($svg) as $element) { + $transformMatrix = $this->transformResolver->resolveElementTransformMatrix($element); + $path = $this->elementPathBuilder->buildElementPath($element, $minX, $maxY, $source, $transformMatrix); + if ($path === null) { + continue; + } + + $fillColor = $this->colorResolver->resolveFillColor($element, $classFills); + $strokeColor = $this->colorResolver->resolveStrokeColor($element, $classStrokes); + + if ($fillColor === null && $strokeColor === null) { + continue; + } + + $commands[] = 'q'; + + if ($fillColor !== null) { + $commands[] = $this->colorParser->toPdfRgb($fillColor); + } + + if ($strokeColor !== null) { + $commands[] = $this->colorParser->toPdfStrokeRgb($strokeColor); + $commands[] = sprintf('%F w', $this->resolveStrokeWidth($element)); + } + + $commands[] = $path; + + $commands[] = match (true) { + $fillColor !== null && $strokeColor !== null => 'B', + $fillColor !== null => 'f', + default => 'S', + }; + + $commands[] = 'Q'; + } + + return new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Form', + 'FormType' => 1, + 'BBox' => [0.0, 0.0, $width, $height], + 'Matrix' => [1.0 / $width, 0.0, 0.0, 1.0 / $height, 0.0, 0.0], + ], + stream: implode("\n", $commands), + ); + } + + private function parseSvgRoot(string $svgContents, string $source): DOMElement + { + if ($svgContents === '') { + throw new InvalidArgumentException(sprintf('Unable to parse SVG source "%s".', $source)); + } + + $document = new DOMDocument('1.0', 'UTF-8'); + $previousErrors = libxml_use_internal_errors(true); + + $document->loadXML($svgContents, self::LIBXML_PARSE_FLAGS); + libxml_clear_errors(); + libxml_use_internal_errors($previousErrors); + + $root = $document->documentElement; + $rootName = $root instanceof DOMElement ? $this->normalizeLocalName($root->localName) : ''; + + if ($rootName !== 'svg') { + throw new InvalidArgumentException(sprintf('Unable to parse SVG source "%s".', $source)); + } + + /** @var DOMElement $root */ + return $root; + } + + /** + * @return array{0: float, 1: float, 2: float, 3: float} + */ + private function resolveViewBox(DOMElement $svg, string $source): array + { + $viewBox = trim($svg->getAttribute('viewBox')); + if ($viewBox !== '') { + return $this->parseViewBoxValues($viewBox, $source); + } + + $width = $this->extractNumericSvgLength($svg->getAttribute('width')); + $height = $this->extractNumericSvgLength($svg->getAttribute('height')); + + if ($width <= 0.0 || $height <= 0.0) { + throw new InvalidArgumentException(sprintf( + 'SVG source "%s" must define either a valid viewBox or positive width/height.', + $source, + )); + } + + return [0.0, 0.0, $width, $height]; + } + + /** + * @return array{0: float, 1: float, 2: float, 3: float} + */ + private function parseViewBoxValues(string $viewBox, string $source): array + { + $parts = preg_split('/[\s,]+/', $viewBox); + if (!is_array($parts) || count($parts) !== 4) { + throw new InvalidArgumentException(sprintf('Invalid viewBox in SVG source "%s".', $source)); + } + + $parsedViewBox = []; + + foreach ($parts as $part) { + $parsedPart = filter_var($part, FILTER_VALIDATE_FLOAT); + if (!is_float($parsedPart)) { + throw new InvalidArgumentException(sprintf('Invalid viewBox in SVG source "%s".', $source)); + } + + $parsedViewBox[] = $parsedPart; + } + + [$minX, $minY, $width, $height] = $parsedViewBox; + + if ($width <= 0.0 || $height <= 0.0) { + throw new InvalidArgumentException(sprintf('SVG source "%s" must define a positive viewBox.', $source)); + } + + return [$minX, $minY, $width, $height]; + } + + private function extractNumericSvgLength(string $value): float + { + $trimmed = trim($value); + if ($trimmed === '') { + return 0.0; + } + + if (preg_match('/^[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/', $trimmed, $matches) !== 1) { + return 0.0; + } + + return (float) $matches[0]; + } + + /** + * @return array{0: array, 1: array} + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + private function extractClassColorMaps(DOMElement $svg): array + { + /** @var array $fills */ + $fills = []; + /** @var array $strokes */ + $strokes = []; + + foreach ($svg->getElementsByTagName('style') as $styleNode) { + /** @var DOMElement $styleNode */ + $css = $styleNode->textContent; + if ($css === '') { + continue; + } + + $ruleCount = preg_match_all('/\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/', $css, $rules, PREG_SET_ORDER); + if ($ruleCount < 1) { + continue; + } + + /** @var list $rules */ + foreach ($rules as $rule) { + if (preg_match('/(?:^|;)\s*fill\s*:\s*([^;]+)/i', $rule[2], $fillMatch) === 1) { + $color = $this->colorResolver->normalizeColor($fillMatch[1]); + if ($color !== null) { + $fills[$rule[1]] = $color; + } + } + + if (preg_match('/(?:^|;)\s*stroke\s*:\s*([^;]+)/i', $rule[2], $strokeMatch) === 1) { + $color = $this->colorResolver->normalizeColor($strokeMatch[1]); + if ($color !== null) { + $strokes[$rule[1]] = $color; + } + } + } + } + + return [$fills, $strokes]; + } + + /** + * @return list + */ + private function iterateDrawableElements(DOMElement $svg): array + { + $elements = []; + + foreach ($svg->getElementsByTagName('*') as $element) { + /** @var DOMElement $element */ + $name = $this->normalizeLocalName($element->localName); + if (in_array($name, ['path', 'polygon', 'polyline', 'rect', 'circle', 'ellipse', 'line'], true)) { + $elements[] = $element; + } + } + + return $elements; + } + + private function normalizeLocalName(?string $localName): string + { + return strtolower($localName ?? ''); + } + + private function resolveStrokeWidth(DOMElement $element): float + { + $styleWidth = $this->colorResolver->extractValueFromStyleAttribute( + $element->getAttribute('style'), + 'stroke-width', + ); + if ($styleWidth !== null) { + return max(0.0, $this->extractNumericSvgLength($styleWidth)); + } + + $attr = $element->getAttribute('stroke-width'); + if ($attr !== '') { + return max(0.0, $this->extractNumericSvgLength($attr)); + } + + return 1.0; + } +} diff --git a/src/Pdf/Svg/SvgPdfXObjectFactoryInterface.php b/src/Pdf/Svg/SvgPdfXObjectFactoryInterface.php new file mode 100644 index 0000000..861275d --- /dev/null +++ b/src/Pdf/Svg/SvgPdfXObjectFactoryInterface.php @@ -0,0 +1,15 @@ +parentNode; + } + + for ($index = count($ancestors) - 1; $index >= 0; --$index) { + $transform = $ancestors[$index]->getAttribute('transform'); + if (!preg_match('/\S/', $transform)) { + continue; + } + + $matrix = $this->multiplyMatrices($matrix, $this->parseTransformList($transform)); + } + + return $matrix; + } + + /** + * Apply a transform matrix to a point. + * + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $matrix The transform matrix + * @param float $x The input X coordinate + * @param float $y The input Y coordinate + * @return array{0:float,1:float} Transformed [x, y] + */ + public function applyTransformToPoint(array $matrix, float $x, float $y): array + { + return [ + $matrix[0] * $x + $matrix[2] * $y + $matrix[4], + $matrix[1] * $x + $matrix[3] * $y + $matrix[5], + ]; + } + + /** + * Parse a transform attribute string into a composite matrix. + * + * Handles matrix, translate, scale, rotate, skewX, and skewY transforms. + * + * @return array{0:float,1:float,2:float,3:float,4:float,5:float} + */ + private function parseTransformList(string $transform): array + { + $matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + + if ( + preg_match_all( + '/([a-zA-Z]+)\s*\(([^)]*)\)/', + $transform, + $matches, + PREG_SET_ORDER, + ) < 1 + ) { + return $matrix; + } + + foreach ($matches as $match) { + $operatorName = strtolower($match[1]); + /** @var list $args */ + $args = preg_split('/[\s,]+/', $match[2], -1, PREG_SPLIT_NO_EMPTY); + $values = array_map( + static fn(string $arg): float => (float) $arg, + $args, + ); + + $operationMatrix = $this->buildTransformMatrix($operatorName, $values); + $matrix = $this->multiplyMatrices($matrix, $operationMatrix); + } + + return $matrix; + } + + /** + * Build a transform matrix for a single operator. + * + * @param string $operatorName Transform operator name (matrix, translate, etc.) + * @param list $values Transform values + * @return array{0:float,1:float,2:float,3:float,4:float,5:float} + */ + private function buildTransformMatrix(string $operatorName, array $values): array + { + return match ($operatorName) { + 'matrix' => count($values) >= 6 + ? [$values[0], $values[1], $values[2], $values[3], $values[4], $values[5]] + : [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + 'translate' => [1.0, 0.0, 0.0, 1.0, $values[0] ?? 0.0, $values[1] ?? 0.0], + 'scale' => [ + $values[0] ?? 1.0, + 0.0, + 0.0, + $values[1] ?? ($values[0] ?? 1.0), + 0.0, + 0.0, + ], + 'rotate' => $this->buildRotateMatrix($values), + 'skewx' => [1.0, 0.0, tan(deg2rad($values[0] ?? 0.0)), 1.0, 0.0, 0.0], + 'skewy' => [1.0, tan(deg2rad($values[0] ?? 0.0)), 0.0, 1.0, 0.0, 0.0], + default => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + }; + } + + /** + * Build a rotation matrix, optionally with rotation center offset. + * + * @param list $values Angle[, cx, cy] values + * @return array{0:float,1:float,2:float,3:float,4:float,5:float} + */ + private function buildRotateMatrix(array $values): array + { + $angle = deg2rad($values[0] ?? 0.0); + $cos = cos($angle); + $sin = sin($angle); + + $rotation = [$cos, $sin, -$sin, $cos, 0.0, 0.0]; + + if (count($values) < 3) { + return $rotation; + } + + $centerX = $values[1]; + $centerY = $values[2]; + + return $this->multiplyMatrices( + $this->multiplyMatrices([1.0, 0.0, 0.0, 1.0, $centerX, $centerY], $rotation), + [1.0, 0.0, 0.0, 1.0, -$centerX, -$centerY], + ); + } + + /** + * Multiply two SVG affine matrices. + * + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $left + * @param array{0:float,1:float,2:float,3:float,4:float,5:float} $right + * @return array{0:float,1:float,2:float,3:float,4:float,5:float} + */ + private function multiplyMatrices(array $left, array $right): array + { + return [ + $left[0] * $right[0] + $left[2] * $right[1], + $left[1] * $right[0] + $left[3] * $right[1], + $left[0] * $right[2] + $left[2] * $right[3], + $left[1] * $right[2] + $left[3] * $right[3], + $left[0] * $right[4] + $left[2] * $right[5] + $left[4], + $left[1] * $right[4] + $left[3] * $right[5] + $left[5], + ]; + } +} diff --git a/tests/Fixtures/Pdf/Svg/govbr-logo.svg b/tests/Fixtures/Pdf/Svg/govbr-logo.svg new file mode 100644 index 0000000..031a079 --- /dev/null +++ b/tests/Fixtures/Pdf/Svg/govbr-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/tests/Integration/VisibleStampTemplateScenarioTest.php b/tests/Integration/VisibleStampTemplateScenarioTest.php index d5b3554..f6f0d1c 100644 --- a/tests/Integration/VisibleStampTemplateScenarioTest.php +++ b/tests/Integration/VisibleStampTemplateScenarioTest.php @@ -8,6 +8,7 @@ namespace LibreSign\XObjectTemplate\Tests\Integration; use LibreSign\XObjectTemplate\Dto\CompileRequest; +use LibreSign\XObjectTemplate\Dto\CompileResult; use LibreSign\XObjectTemplate\Pdf\SinglePagePdfExporter; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; use LibreSign\XObjectTemplate\XObjectTemplateCompiler; @@ -26,54 +27,60 @@ public function testPhaseOneVisibleStampLayoutsCanBeCompiledAndExported( int $expectedImageCount, array $expectedTexts, ): void { - $previewRoot = dirname(__DIR__, 2) . '/build/visible-stamp-previews'; - $assetRoot = $previewRoot . '/assets'; - $this->ensureDirectoryExists($previewRoot); - $this->ensureDirectoryExists($assetRoot); + ['assetRoot' => $assetRoot] = $this->ensurePreviewDirectories(); $backgroundPath = $this->createBackgroundPreview($assetRoot . '/background-' . $slug . '.png'); $signaturePath = $this->layoutUsesSignatureImage($layout) ? $this->createSignaturePreview($assetRoot . '/signature-' . $slug . '.png') : null; - $compiler = new XObjectTemplateCompiler(); - $result = $compiler->compile(new CompileRequest( - html: $this->buildLayoutHtml($layout, $backgroundPath, $signaturePath), - width: (float) self::PREVIEW_WIDTH, - height: (float) self::PREVIEW_HEIGHT, - )); + ['result' => $result, 'pdf' => $pdf, 'previewPath' => $previewPath] = $this->compilePreview( + $slug, + $this->buildLayoutHtml($layout, $backgroundPath, $signaturePath), + ); - $pdf = (new SinglePagePdfExporter())->export($result); - $previewPath = $previewRoot . '/' . $slug . '.pdf'; - file_put_contents($previewPath, $pdf); + $this->assertBasePreviewExport($result, $pdf, $previewPath, $expectedImageCount); - self::assertSame($expectedImageCount, count($result->resources['XObject'] ?? [])); - self::assertSame((float) self::PREVIEW_WIDTH, $result->resources['XObject']['Im0']['Width']); - self::assertSame((float) self::PREVIEW_HEIGHT, $result->resources['XObject']['Im0']['Height']); + foreach ($expectedTexts as $expectedText) { + self::assertStringContainsString($expectedText, $result->contentStream); + self::assertStringContainsString($expectedText, $pdf); + } + } + + public function testGovBrLikeVisibleStampCanBeCompiledAndExportedUsingSupportedHtmlAndCssOnly(): void + { + $logoPath = dirname(__DIR__) . '/Fixtures/Pdf/Svg/govbr-logo.svg'; + self::assertFileExists($logoPath); + + ['result' => $result, 'pdf' => $pdf, 'previewPath' => $previewPath] = $this->compilePreviewWithSize( + 'govbr-like-visible-stamp', + $this->buildGovBrLikeLayoutHtml($logoPath), + 760.0, + 190.0, + ); + + self::assertSame(1, count($result->resources['XObject'] ?? [])); self::assertStringStartsWith("%PDF-1.4\n", $pdf); self::assertStringContainsString('/Subtype /Form', $pdf); - self::assertStringContainsString( - sprintf( - 'q %F 0 0 %F %F %F cm /Im0 Do Q', - (float) self::PREVIEW_WIDTH, - (float) self::PREVIEW_HEIGHT, - 0.0, - 0.0, - ), - $result->contentStream, - ); self::assertStringContainsString('/Im0 Do', $result->contentStream); self::assertFileExists($previewPath); self::assertSame($pdf, file_get_contents($previewPath)); + self::assertSame($logoPath, $result->resources['XObject']['Im0']['Source']); - if ($expectedImageCount > 1) { - self::assertStringContainsString('/Im1 Do', $result->contentStream); - } + $expectedTexts = [ + 'Documento assinado digitalmente', + 'ASSINANTE DE EXEMPLO', + 'Data: 01/01/2026 12:00:00-0300', + 'Verifique em https://verificador.iti.br', + ]; foreach ($expectedTexts as $expectedText) { self::assertStringContainsString($expectedText, $result->contentStream); self::assertStringContainsString($expectedText, $pdf); } + + self::assertStringContainsString('1 1 1 rg', $result->contentStream); + self::assertStringContainsString('RG', $result->contentStream); } /** @@ -216,6 +223,122 @@ private function buildLayoutHtml(string $layout, string $backgroundPath, ?string }; } + private function buildGovBrLikeLayoutHtml(string $logoPath): string + { + return sprintf( + '
' + . '
' + . '' + . '
' + . '
' + . '
Documento assinado digitalmente
' + . '
ASSINANTE DE EXEMPLO
' + . '
Data: 01/01/2026 12:00:00-0300
' + . '
Verifique em https://verificador.iti.br
' + . '
' + . '
', + $this->escapeAttribute($logoPath), + ); + } + + /** + * @return array{previewRoot: string, assetRoot: string} + */ + private function ensurePreviewDirectories(): array + { + $previewRoot = dirname(__DIR__, 2) . '/build/visible-stamp-previews'; + $assetRoot = $previewRoot . '/assets'; + $this->ensureDirectoryExists($previewRoot); + $this->ensureDirectoryExists($assetRoot); + + return [ + 'previewRoot' => $previewRoot, + 'assetRoot' => $assetRoot, + ]; + } + + /** + * @return array{result: CompileResult, pdf: string, previewPath: string} + */ + private function compilePreview(string $slug, string $html): array + { + return $this->compilePreviewWithSize($slug, $html, (float) self::PREVIEW_WIDTH, (float) self::PREVIEW_HEIGHT); + } + + /** + * @return array{result: CompileResult, pdf: string, previewPath: string} + */ + private function compilePreviewWithSize(string $slug, string $html, float $width, float $height): array + { + ['previewRoot' => $previewRoot] = $this->ensurePreviewDirectories(); + $this->removeLegacyPreviewPngs($previewRoot, $slug); + + $compiler = new XObjectTemplateCompiler(); + $result = $compiler->compile(new CompileRequest( + html: $html, + width: $width, + height: $height, + )); + + $pdf = (new SinglePagePdfExporter())->export($result); + $previewPath = $previewRoot . '/' . $slug . '.pdf'; + file_put_contents($previewPath, $pdf); + + return [ + 'result' => $result, + 'pdf' => $pdf, + 'previewPath' => $previewPath, + ]; + } + + private function removeLegacyPreviewPngs(string $previewRoot, string $slug): void + { + $legacyCandidates = [ + $previewRoot . '/' . $slug . '.png', + $previewRoot . '/' . $slug . '-1.png', + ]; + + foreach ($legacyCandidates as $legacyCandidate) { + if (is_file($legacyCandidate)) { + unlink($legacyCandidate); + } + } + } + + private function assertBasePreviewExport( + CompileResult $result, + string $pdf, + string $previewPath, + int $expectedImageCount, + ): void { + self::assertSame($expectedImageCount, count($result->resources['XObject'] ?? [])); + self::assertSame((float) self::PREVIEW_WIDTH, $result->resources['XObject']['Im0']['Width']); + self::assertSame((float) self::PREVIEW_HEIGHT, $result->resources['XObject']['Im0']['Height']); + self::assertStringStartsWith("%PDF-1.4\n", $pdf); + self::assertStringContainsString('/Subtype /Form', $pdf); + self::assertStringContainsString( + sprintf( + 'q %F 0 0 %F %F %F cm /Im0 Do Q', + (float) self::PREVIEW_WIDTH, + (float) self::PREVIEW_HEIGHT, + 0.0, + 0.0, + ), + $result->contentStream, + ); + self::assertStringContainsString('/Im0 Do', $result->contentStream); + self::assertFileExists($previewPath); + self::assertSame($pdf, file_get_contents($previewPath)); + + if ($expectedImageCount > 1) { + self::assertStringContainsString('/Im1 Do', $result->contentStream); + } + } + private function createBackgroundPreview(string $path): string { if (is_file($path)) { @@ -290,6 +413,7 @@ function (int $x, int $y, int $width, int $height): array { return $path; } + private function requireSignaturePath(?string $signaturePath): string { if ($signaturePath === null) { diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php index 6d8a145..54b975e 100644 --- a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php +++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php @@ -13,6 +13,7 @@ use LibreSign\XObjectTemplate\Pdf\ImageMetadataInspectorInterface; use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactoryInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\Svg\SvgPdfXObjectFactoryInterface; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; use LibreSign\XObjectTemplate\Tests\Support\UsesTemporaryFiles; use PHPUnit\Framework\Attributes\DataProvider; @@ -111,6 +112,62 @@ public function create(string $contents): EmbeddedPdfImage self::assertSame($expectedImage, $embedder->embed('/tmp/virtual-image.png')); } + public function testEmbedSupportsSvgSourcesViaInjectedFactory(): void + { + $expectedImage = new EmbeddedPdfImage(['Type' => '/XObject', 'Subtype' => '/Form'], 'svg-form-stream'); + $embedder = new FilesystemPdfImageEmbedder( + new class implements FilesystemImageSourceReaderInterface { + public function read(string $source): string + { + return ''; + } + }, + new class implements ImageMetadataInspectorInterface { + public function detect(string $contents, string $source): array + { + throw new \RuntimeException('Metadata inspector should not run for native SVG embeds.'); + } + + public function resolveMimeType(array $imageInfo, string $source): string + { + throw new \RuntimeException('MIME resolution should not run for native SVG embeds.'); + } + }, + new class implements JpegPdfImageFactoryInterface { + public function create(string $contents, array $imageInfo): EmbeddedPdfImage + { + throw new \RuntimeException('JPEG factory should not be used for SVG images.'); + } + }, + new class ($expectedImage) implements PngPdfImageFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $expectedImage) + { + } + + public function create(string $contents): EmbeddedPdfImage + { + throw new \RuntimeException('PNG factory should not be used for SVG images.'); + } + }, + new class ($expectedImage) implements SvgPdfXObjectFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $expectedImage) + { + } + + public function create(string $svgContents, string $source): EmbeddedPdfImage + { + if (str_contains($svgContents, 'expectedImage; + } + }, + ); + + self::assertSame($expectedImage, $embedder->embed('/tmp/virtual-image.svg')); + } + public function testEmbedReturnsPredictorBackedImageForOpaqueRgbPng(): void { $embedder = new FilesystemPdfImageEmbedder(); @@ -440,6 +497,154 @@ public function testEmbedSeparatesMultiRowRgbaPixelsIntoColorAndAlphaStreams(): self::assertSame("\x00\x40\x80\x00\xc0\xff", gzuncompress($image->softMask->stream)); } + #[DataProvider('svgExtensionBoundaryProvider')] + public function testEmbedCorrectlyDetectsSvgByFileExtensionBoundary(string $source, array $expectedDictionary): void + { + $svgImage = new EmbeddedPdfImage(['Type' => '/XObject', 'Subtype' => '/Form'], 'svg-stream'); + $pngImage = new EmbeddedPdfImage(['Type' => '/Image'], 'png-stream'); + $embedder = $this->buildSvgDetectionEmbedder($svgImage, $pngImage, "\x89PNG\r\n\x1a\n"); + + $image = $embedder->embed($source); + + foreach ($expectedDictionary as $key => $expectedValue) { + self::assertSame($expectedValue, $image->dictionary[$key]); + } + } + + /** + * @return iterable}> + */ + public static function svgExtensionBoundaryProvider(): iterable + { + yield 'SVG extension detected' => [ + 'source' => '/path/to/file.svg', + 'expectedDictionary' => ['Type' => '/XObject', 'Subtype' => '/Form'], + ]; + yield 'SVGZ extension detected' => [ + 'source' => '/path/to/file.svgz', + 'expectedDictionary' => ['Type' => '/XObject', 'Subtype' => '/Form'], + ]; + yield 'SVG extension case-insensitive uppercase' => [ + 'source' => '/path/to/file.SVG', + 'expectedDictionary' => ['Type' => '/XObject', 'Subtype' => '/Form'], + ]; + yield 'SVG extension case-insensitive mixed' => [ + 'source' => '/path/to/file.Svg', + 'expectedDictionary' => ['Type' => '/XObject', 'Subtype' => '/Form'], + ]; + yield 'SVG in middle of filename not detected as SVG' => [ + 'source' => '/path/svg.backup', + 'expectedDictionary' => ['Type' => '/Image'], + ]; + yield 'SVG in filename but different extension' => [ + 'source' => '/path/my.svg.txt', + 'expectedDictionary' => ['Type' => '/Image'], + ]; + } + + #[DataProvider('svgContentDetectionProvider')] + public function testEmbedCorrectlyDetectsSvgByContentBoundary(string $content, array $expectedDictionary): void + { + $svgImage = new EmbeddedPdfImage(['Type' => '/XObject', 'Subtype' => '/Form'], 'svg-stream'); + $pngImage = new EmbeddedPdfImage(['Type' => '/Image'], 'png-stream'); + $embedder = $this->buildSvgDetectionEmbedder($svgImage, $pngImage, $content); + + $image = $embedder->embed('/path/to/image.bin'); + + foreach ($expectedDictionary as $key => $expectedValue) { + self::assertSame($expectedValue, $image->dictionary[$key]); + } + } + + /** + * @return iterable}> + */ + public static function svgContentDetectionProvider(): iterable + { + yield 'direct svg tag detected by content' => [ + 'content' => '', + 'expectedDictionary' => ['Type' => '/XObject', 'Subtype' => '/Form'], + ]; + yield 'svg tag with leading whitespace is trimmed and detected' => [ + 'content' => " \n", + 'expectedDictionary' => ['Type' => '/XObject', 'Subtype' => '/Form'], + ]; + yield 'xml declaration followed by svg tag is detected' => [ + 'content' => '', + 'expectedDictionary' => ['Type' => '/XObject', 'Subtype' => '/Form'], + ]; + yield 'xml declaration without svg tag is not detected as svg' => [ + 'content' => '', + 'expectedDictionary' => ['Type' => '/Image'], + ]; + } + + private function buildSvgDetectionEmbedder( + EmbeddedPdfImage $svgImage, + EmbeddedPdfImage $pngImage, + string $sourceContent, + ): FilesystemPdfImageEmbedder { + $sourceReader = new class ($sourceContent) implements FilesystemImageSourceReaderInterface { + public function __construct(private readonly string $content) + { + } + + public function read(string $source): string + { + return $this->content; + } + }; + + $metadataInspector = new class implements ImageMetadataInspectorInterface { + public function detect(string $contents, string $source): array + { + return []; + } + + public function resolveMimeType(array $imageInfo, string $source): string + { + return 'image/png'; + } + }; + + $jpegFactory = new class implements JpegPdfImageFactoryInterface { + public function create(string $contents, array $imageInfo): EmbeddedPdfImage + { + throw new \RuntimeException('JPEG factory should not be used.'); + } + }; + + $pngFactory = new class ($pngImage) implements PngPdfImageFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $image) + { + } + + public function create(string $contents): EmbeddedPdfImage + { + return $this->image; + } + }; + + $svgFactory = new class ($svgImage) implements SvgPdfXObjectFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $image) + { + } + + public function create(string $svgContents, string $source): EmbeddedPdfImage + { + return $this->image; + } + }; + + return new FilesystemPdfImageEmbedder( + $sourceReader, + $metadataInspector, + $jpegFactory, + $pngFactory, + $svgFactory, + ); + } + /** * @return iterable */ diff --git a/tests/Unit/Pdf/SinglePagePdfExporterTest.php b/tests/Unit/Pdf/SinglePagePdfExporterTest.php index 18583da..f577ad6 100644 --- a/tests/Unit/Pdf/SinglePagePdfExporterTest.php +++ b/tests/Unit/Pdf/SinglePagePdfExporterTest.php @@ -527,24 +527,6 @@ public static function invalidCompileResultProvider(): iterable 'expectedMessage' => 'XObject resources must be an array.', ]; - yield 'unsupported xobject subtype' => [ - 'result' => new CompileResult( - contentStream: 'BT ET', - resources: [ - 'Font' => [], - 'XObject' => [ - 'Im0' => [ - 'Type' => '/XObject', - 'Subtype' => '/Form', - 'Source' => '/tmp/form.xobject', - ], - ], - ], - bbox: [0.0, 0.0, 40.0, 40.0], - ), - 'expectedMessage' => 'Unsupported XObject subtype for "Im0".', - ]; - yield 'missing image source' => [ 'result' => new CompileResult( contentStream: 'BT ET', @@ -560,7 +542,7 @@ public static function invalidCompileResultProvider(): iterable ], bbox: [0.0, 0.0, 40.0, 40.0], ), - 'expectedMessage' => 'Image resource "Im0" must expose a non-empty Source.', + 'expectedMessage' => 'XObject resource "Im0" must expose a non-empty Source.', ]; } diff --git a/tests/Unit/Pdf/Svg/ArcParamsTest.php b/tests/Unit/Pdf/Svg/ArcParamsTest.php new file mode 100644 index 0000000..1cfee22 --- /dev/null +++ b/tests/Unit/Pdf/Svg/ArcParamsTest.php @@ -0,0 +1,46 @@ +withRadii(9.0, 10.0); + + self::assertNotSame($params, $updated); + self::assertSame(1.0, $updated->fromX); + self::assertSame(2.0, $updated->fromY); + self::assertSame(3.0, $updated->toX); + self::assertSame(4.0, $updated->toY); + self::assertSame(9.0, $updated->radiusX); + self::assertSame(10.0, $updated->radiusY); + self::assertSame(0.7, $updated->cosTh); + self::assertSame(0.8, $updated->sinTh); + self::assertSame(1, $updated->largeArc); + self::assertSame(0, $updated->sweep); + self::assertSame(5.0, $params->radiusX); + self::assertSame(6.0, $params->radiusY); + } +} diff --git a/tests/Unit/Pdf/Svg/PathCommandContextTest.php b/tests/Unit/Pdf/Svg/PathCommandContextTest.php new file mode 100644 index 0000000..ff0b3cf --- /dev/null +++ b/tests/Unit/Pdf/Svg/PathCommandContextTest.php @@ -0,0 +1,31 @@ +transformMatrix); + self::assertSame(7.5, $context->minX); + self::assertSame(8.5, $context->maxY); + self::assertSame('/tmp/example.svg', $context->source); + } +} diff --git a/tests/Unit/Pdf/Svg/PathParsingStateTest.php b/tests/Unit/Pdf/Svg/PathParsingStateTest.php new file mode 100644 index 0000000..4ca8b97 --- /dev/null +++ b/tests/Unit/Pdf/Svg/PathParsingStateTest.php @@ -0,0 +1,54 @@ +currentX); + self::assertSame(0.0, $state->currentY); + self::assertSame(0.0, $state->subpathStartX); + self::assertSame(0.0, $state->subpathStartY); + self::assertNull($state->lastCubicControlX); + self::assertNull($state->lastCubicControlY); + self::assertNull($state->prevQuadCpX); + self::assertNull($state->prevQuadCpY); + self::assertSame([], $state->commands); + } + + public function testConstructorAcceptsExplicitStateValues(): void + { + $state = new PathParsingState( + currentX: 10.5, + currentY: 20.5, + subpathStartX: 5.5, + subpathStartY: 15.5, + lastCubicControlX: 30.5, + lastCubicControlY: 40.5, + prevQuadCpX: 50.5, + prevQuadCpY: 60.5, + commands: ['m', 'l'], + ); + + self::assertSame(10.5, $state->currentX); + self::assertSame(20.5, $state->currentY); + self::assertSame(5.5, $state->subpathStartX); + self::assertSame(15.5, $state->subpathStartY); + self::assertSame(30.5, $state->lastCubicControlX); + self::assertSame(40.5, $state->lastCubicControlY); + self::assertSame(50.5, $state->prevQuadCpX); + self::assertSame(60.5, $state->prevQuadCpY); + self::assertSame(['m', 'l'], $state->commands); + } +} diff --git a/tests/Unit/Pdf/Svg/SvgArcConverterTest.php b/tests/Unit/Pdf/Svg/SvgArcConverterTest.php new file mode 100644 index 0000000..274fcb3 --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgArcConverterTest.php @@ -0,0 +1,624 @@ + $expected + * @param array $actual + */ + private static function assertCurveMatches(array $expected, array $actual, float $delta = 0.0001): void + { + self::assertCount(count($expected), $actual); + + foreach ($expected as $index => $expectedValue) { + self::assertEqualsWithDelta( + $expectedValue, + $actual[$index], + $delta, + sprintf('Curve index %d differs.', $index), + ); + } + } + + #[DataProvider('provideBoundaryArcBehaviorScenarios')] + public function testArcToBezierCurvesMatchesBoundaryBehaviorThroughPublicApi( + float $fromX, + float $fromY, + float $radiusX, + float $radiusY, + float $rotation, + int $largeArc, + int $sweep, + float $toX, + float $toY, + int $expectedSegmentCount, + array $expectedFirstCurve, + ): void { + $converter = new SvgArcConverter(); + + $curves = $converter->arcToBezierCurves( + $fromX, + $fromY, + $radiusX, + $radiusY, + $rotation, + $largeArc, + $sweep, + $toX, + $toY, + ); + + self::assertCount($expectedSegmentCount, $curves); + self::assertCurveMatches($expectedFirstCurve, $curves[0], 1.0E-12); + } + + public function testArcToBezierCurvesReturnsEmptyArrayWhenStartAndEndPointsMatch(): void + { + $converter = new SvgArcConverter(); + + self::assertSame([], $converter->arcToBezierCurves(10.0, 10.0, 5.0, 6.0, 30.0, 0, 1, 10.0, 10.0)); + } + + public function testArcToBezierCurvesReturnsEmptyArrayWhenBothAxisDeltasStayBelowTolerance(): void + { + $converter = new SvgArcConverter(); + + self::assertSame( + [], + $converter->arcToBezierCurves(10.0, 10.0, 5.0, 6.0, 30.0, 0, 1, 10.0 + 5.0e-11, 10.0 - 5.0e-11), + ); + } + + /** + * @return iterable, + * }> + */ + public static function provideBoundaryArcBehaviorScenarios(): iterable + { + yield 'near-zero span uses denominator floor guard path' => [ + 'fromX' => 2.0E-6, + 'fromY' => 2.0E-6, + 'radiusX' => 1.0, + 'radiusY' => 1.0, + 'rotation' => 0.0, + 'largeArc' => 0, + 'sweep' => 1, + 'toX' => 0.0, + 'toY' => 0.0, + 'expectedSegmentCount' => 1, + 'expectedFirstCurve' => [ + 0.31920047711974003, + 1.0950150852533551, + 0.3879073040668076, + 0.38790730406680757, + 0.0, + 0.0, + ], + ]; + + yield 'tiny horizontal arc keeps one segment for sweep one' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 1.0, + 'radiusY' => 1.0, + 'rotation' => 0.0, + 'largeArc' => 0, + 'sweep' => 1, + 'toX' => 1.5491933384829667E-5, + 'toY' => 0.0, + 'expectedSegmentCount' => 1, + 'expectedFirstCurve' => [ + -0.9999922540333077, + -0.5485837703548633, + -0.5485682784214786, + 1.0077320376439051E-16, + 1.5491933384829667E-5, + 0.0, + ], + ]; + + yield 'tiny horizontal arc with reverse sweep expands to three segments' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 1.0, + 'radiusY' => 1.0, + 'rotation' => 0.0, + 'largeArc' => 0, + 'sweep' => 0, + 'toX' => 1.5491933384829667E-5, + 'toY' => 0.0, + 'expectedSegmentCount' => 3, + 'expectedFirstCurve' => [ + -0.9999922540333075, + 0.5485837703548635, + -0.5485760243881709, + 1.0, + 7.745966692476065E-6, + 1.0, + ], + ]; + } + + #[DataProvider('provideNotSamePointScenarios')] + public function testArcToBezierCurvesDoesNotTreatBoundaryDeltasAsSamePoint( + float $fromX, + float $fromY, + float $toX, + float $toY, + float $radiusX, + float $radiusY, + float $rotation, + ): void { + $converter = new SvgArcConverter(); + + $curves = $converter->arcToBezierCurves($fromX, $fromY, $radiusX, $radiusY, $rotation, 0, 1, $toX, $toY); + + self::assertNotSame([], $curves); + } + + #[DataProvider('provideDegenerateRadiusFallbackScenarios')] + public function testArcToBezierCurvesFallsBackToDegenerateLineForTinyRadius( + float $fromX, + float $fromY, + float $radiusX, + float $radiusY, + float $toX, + float $toY, + ): void { + $converter = new SvgArcConverter(); + + self::assertSame( + [[$toX, $toY, $toX, $toY, $toX, $toY]], + $converter->arcToBezierCurves($fromX, $fromY, $radiusX, $radiusY, 0.0, 0, 1, $toX, $toY), + ); + } + + #[DataProvider('provideNonDegenerateRadiusBoundaryScenarios')] + public function testArcToBezierCurvesDoesNotDegenerateAtBoundaryRadii( + float $radiusX, + float $radiusY, + ): void { + $converter = new SvgArcConverter(); + + $curves = $converter->arcToBezierCurves(0.0, 0.0, $radiusX, $radiusY, 0.0, 0, 1, 20.0, 30.0); + + self::assertNotSame([], $curves); + self::assertNotSame([[20.0, 30.0, 20.0, 30.0, 20.0, 30.0]], $curves); + } + + public function testArcToBezierCurvesUsesSweepAndLargeArcFlagsToChooseDifferentSolutions(): void + { + $converter = new SvgArcConverter(); + + $smallSweep = $converter->arcToBezierCurves(10.0, 0.0, 10.0, 10.0, 0.0, 0, 1, 0.0, 10.0); + $smallReverseSweep = $converter->arcToBezierCurves(10.0, 0.0, 10.0, 10.0, 0.0, 0, 0, 0.0, 10.0); + $largeSweep = $converter->arcToBezierCurves(10.0, 0.0, 10.0, 10.0, 0.0, 1, 1, 0.0, 10.0); + + self::assertNotSame([], $smallSweep); + self::assertNotSame([], $smallReverseSweep); + self::assertNotSame([], $largeSweep); + self::assertNotEquals($smallSweep, $smallReverseSweep); + self::assertNotEquals($smallSweep, $largeSweep); + } + + public function testArcToBezierCurvesReturnsFiniteControlPointsForNormalizedRotatedArc(): void + { + $converter = new SvgArcConverter(); + + $curves = $converter->arcToBezierCurves(0.0, 0.0, 4.0, 3.0, 35.0, 1, 0, 25.0, 8.0); + + self::assertNotSame([], $curves); + + foreach ($curves as $curve) { + foreach ($curve as $value) { + self::assertTrue(is_finite($value)); + } + } + } + + public function testArcToBezierCurvesMatchesExpectedHalfEllipseControlPoints(): void + { + $converter = new SvgArcConverter(); + + $curves = $converter->arcToBezierCurves(0.0, 5.0, 10.0, 5.0, 0.0, 0, 1, 20.0, 5.0); + + self::assertCount(2, $curves); + self::assertCurveMatches( + [ + -6.7182135842927015E-16, + 2.257081148225684, + 4.514162296451365, + 5.038660188219526E-16, + 9.999999999999998, + 0.0, + ], + $curves[0], + ); + self::assertCurveMatches( + [ + 15.485837703548633, + -5.038660188219526E-16, + 20.0, + 2.2570811482256823, + 20.0, + 4.999999999999999, + ], + $curves[1], + ); + } + + public function testArcToBezierCurvesMatchesExpectedNormalizedArcControlPoints(): void + { + $converter = new SvgArcConverter(); + + $curves = $converter->arcToBezierCurves(0.0, 0.0, 5.0, 5.0, 0.0, 0, 1, 30.0, 0.0); + + self::assertCount(2, $curves); + self::assertCurveMatches( + [ + -1.0077320376439053E-15, + -8.22875655532295, + 6.7712434446770455, + -14.999999999999998, + 14.999999999999996, + -15.0, + ], + $curves[0], + ); + self::assertCurveMatches( + [ + 23.228756555322946, + -15.000000000000002, + 29.999999999999996, + -8.228756555322954, + 30.0, + -3.67394039744206E-15, + ], + $curves[1], + ); + } + + public function testArcToBezierCurvesMatchesExpectedQuarterArcSolutionsForFlagVariants(): void + { + $converter = new SvgArcConverter(); + + $largeSweep = $converter->arcToBezierCurves(10.0, 0.0, 10.0, 10.0, 0.0, 1, 1, 0.0, 10.0); + $smallSweep = $converter->arcToBezierCurves(10.0, 0.0, 10.0, 10.0, 0.0, 0, 1, 0.0, 10.0); + + self::assertCount(2, $largeSweep); + self::assertCount(2, $smallSweep); + self::assertCurveMatches( + [ + 20.95014085253355, + 6.808005228802601, + 20.95014085253355, + 13.191994771197399, + 17.071067811865476, + 17.071067811865476, + ], + $largeSweep[0], + ); + self::assertCurveMatches( + [ + 10.95014085253355, + -3.191994771197398, + 10.95014085253355, + 3.191994771197398, + 7.0710678118654755, + 7.071067811865475, + ], + $smallSweep[0], + ); + } + + #[DataProvider('provideArcScenarios')] + public function testArcToBezierCurvesGeneratesExpectedCurveShape( + float $fromX, + float $fromY, + float $radiusX, + float $radiusY, + float $rotation, + int $largeArc, + int $sweep, + float $toX, + float $toY, + int $expectedSegmentCount, + ): void { + $converter = new SvgArcConverter(); + + $curves = $converter->arcToBezierCurves( + $fromX, + $fromY, + $radiusX, + $radiusY, + $rotation, + $largeArc, + $sweep, + $toX, + $toY, + ); + + self::assertCount($expectedSegmentCount, $curves); + + foreach ($curves as $curve) { + self::assertCount(6, $curve); + } + + $lastCurve = $curves[array_key_last($curves)]; + self::assertEqualsWithDelta($toX, $lastCurve[4], 0.0001); + self::assertEqualsWithDelta($toY, $lastCurve[5], 0.0001); + } + + /** + * @return iterable + */ + public static function provideArcScenarios(): iterable + { + yield 'symmetric half ellipse arc' => [ + 'fromX' => 0.0, + 'fromY' => 5.0, + 'radiusX' => 10.0, + 'radiusY' => 5.0, + 'rotation' => 0.0, + 'largeArc' => 0, + 'sweep' => 1, + 'toX' => 20.0, + 'toY' => 5.0, + 'expectedSegmentCount' => 2, + ]; + + yield 'rotated arc uses multiple segments' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 40.0, + 'radiusY' => 20.0, + 'rotation' => 45.0, + 'largeArc' => 1, + 'sweep' => 1, + 'toX' => 60.0, + 'toY' => 0.0, + 'expectedSegmentCount' => 2, + ]; + + yield 'undersized radii are normalized to still produce a curve' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 5.0, + 'radiusY' => 5.0, + 'rotation' => 0.0, + 'largeArc' => 0, + 'sweep' => 1, + 'toX' => 30.0, + 'toY' => 0.0, + 'expectedSegmentCount' => 2, + ]; + + yield 'quarter-turn with reverse sweep still resolves to two segments' => [ + 'fromX' => 10.0, + 'fromY' => 0.0, + 'radiusX' => 10.0, + 'radiusY' => 10.0, + 'rotation' => 0.0, + 'largeArc' => 0, + 'sweep' => 0, + 'toX' => 0.0, + 'toY' => 10.0, + 'expectedSegmentCount' => 2, + ]; + + yield 'quarter-turn with large arc flag uses two segments' => [ + 'fromX' => 10.0, + 'fromY' => 0.0, + 'radiusX' => 10.0, + 'radiusY' => 10.0, + 'rotation' => 0.0, + 'largeArc' => 1, + 'sweep' => 1, + 'toX' => 0.0, + 'toY' => 10.0, + 'expectedSegmentCount' => 2, + ]; + } + + /** + * @return iterable + */ + public static function provideNotSamePointScenarios(): iterable + { + yield 'single-axis delta on y' => [ + 'fromX' => 10.0, + 'fromY' => 10.0, + 'toX' => 10.0, + 'toY' => 14.0, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 30.0, + ]; + + yield 'exact tolerance on x' => [ + 'fromX' => 10.0, + 'fromY' => 10.0, + 'toX' => 10.0 + 1.0e-10, + 'toY' => 10.0, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 30.0, + ]; + + yield 'exact tolerance x and sub-tolerance y' => [ + 'fromX' => 10.0, + 'fromY' => 10.0, + 'toX' => 10.0 + 1.0e-10, + 'toY' => 10.0 + 5.0e-11, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 30.0, + ]; + + yield 'sub-tolerance x and exact tolerance y' => [ + 'fromX' => 10.0, + 'fromY' => 10.0, + 'toX' => 10.0 + 5.0e-11, + 'toY' => 10.0 + 1.0e-10, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 30.0, + ]; + + yield 'origin exact tolerance on x' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'toX' => 1.0e-10, + 'toY' => 5.0e-11, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 0.0, + ]; + + yield 'origin exact tolerance on y' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'toX' => 5.0e-11, + 'toY' => 1.0e-10, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 0.0, + ]; + + yield 'negative exact tolerance on x' => [ + 'fromX' => 10.0, + 'fromY' => 10.0, + 'toX' => 10.0 - 1.0e-10, + 'toY' => 10.0, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 30.0, + ]; + + yield 'negative exact tolerance on y' => [ + 'fromX' => 10.0, + 'fromY' => 10.0, + 'toX' => 10.0, + 'toY' => 10.0 - 1.0e-10, + 'radiusX' => 5.0, + 'radiusY' => 6.0, + 'rotation' => 30.0, + ]; + } + + /** + * @return iterable + */ + public static function provideDegenerateRadiusFallbackScenarios(): iterable + { + yield 'radius x is zero' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 0.0, + 'radiusY' => 5.0, + 'toX' => 20.0, + 'toY' => 30.0, + ]; + + yield 'radius y below tolerance' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 5.0, + 'radiusY' => 1.0e-12, + 'toX' => 20.0, + 'toY' => 30.0, + ]; + + yield 'radius x below tolerance' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 1.0e-12, + 'radiusY' => 5.0, + 'toX' => 20.0, + 'toY' => 30.0, + ]; + + yield 'both radii below tolerance' => [ + 'fromX' => 0.0, + 'fromY' => 0.0, + 'radiusX' => 1.0e-12, + 'radiusY' => 1.0e-12, + 'toX' => 20.0, + 'toY' => 30.0, + ]; + } + + /** + * @return iterable + */ + public static function provideNonDegenerateRadiusBoundaryScenarios(): iterable + { + yield 'both radii just above tolerance' => [ + 'radiusX' => 1.1e-10, + 'radiusY' => 2.2e-10, + ]; + + yield 'x radius at exact tolerance' => [ + 'radiusX' => 1.0e-10, + 'radiusY' => 2.0e-10, + ]; + + yield 'y radius at exact tolerance' => [ + 'radiusX' => 2.0e-10, + 'radiusY' => 1.0e-10, + ]; + + yield 'both radii at exact tolerance' => [ + 'radiusX' => 1.0e-10, + 'radiusY' => 1.0e-10, + ]; + } +} diff --git a/tests/Unit/Pdf/Svg/SvgArcMathTest.php b/tests/Unit/Pdf/Svg/SvgArcMathTest.php new file mode 100644 index 0000000..4e4d711 --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgArcMathTest.php @@ -0,0 +1,348 @@ + $expected + * @param array $actual + */ + private static function assertCurveMatches(array $expected, array $actual, float $delta = 0.0001): void + { + self::assertCount(count($expected), $actual); + + foreach ($expected as $index => $expectedValue) { + self::assertEqualsWithDelta( + $expectedValue, + $actual[$index], + $delta, + sprintf('Curve index %d differs.', $index), + ); + } + } + + private static function createCurveGenerationParams( + float $centerX, + float $centerY, + float $radiusX, + float $radiusY, + float $cosTh, + float $sinTh, + float $startAngle, + float $deltaAngle, + ): ArcParams { + $endAngle = $startAngle + $deltaAngle; + $endCos = cos($endAngle); + $endSin = sin($endAngle); + + return new ArcParams( + 0.0, + 0.0, + $centerX + $cosTh * $radiusX * $endCos - $sinTh * $radiusY * $endSin, + $centerY + $sinTh * $radiusX * $endCos + $cosTh * $radiusY * $endSin, + $radiusX, + $radiusY, + $cosTh, + $sinTh, + 0, + 1, + ); + } + + public function testNormalizeArcRadiiReturnsSameInstanceWhenScaleIsExactlyOne(): void + { + $math = new SvgArcMath(); + $params = new ArcParams( + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0, + 1, + ); + + $normalized = $math->normalizeArcRadii($params); + + self::assertSame($params, $normalized); + } + + public function testNormalizeArcRadiiMatchesExpectedScaleForRotatedArc(): void + { + $math = new SvgArcMath(); + + $cosTh = cos(deg2rad(45.0)); + $sinTh = sin(deg2rad(45.0)); + $params = new ArcParams( + 0.0, + 0.0, + 6.0, + 2.0, + 1.0, + 2.0, + $cosTh, + $sinTh, + 0, + 1, + ); + + $normalized = $math->normalizeArcRadii($params); + + self::assertNotSame($params, $normalized); + + $deltaX2 = ($params->fromX - $params->toX) / 2.0; + $deltaY2 = ($params->fromY - $params->toY) / 2.0; + $primeX = $params->cosTh * $deltaX2 + $params->sinTh * $deltaY2; + $primeY = -$params->sinTh * $deltaX2 + $params->cosTh * $deltaY2; + $radiusX2 = $params->radiusX * $params->radiusX; + $radiusY2 = $params->radiusY * $params->radiusY; + $scale = ($primeX * $primeX) / $radiusX2 + ($primeY * $primeY) / $radiusY2; + $scaleFactor = sqrt($scale); + + self::assertGreaterThan(1.0, $scale); + self::assertEqualsWithDelta($params->radiusX * $scaleFactor, $normalized->radiusX, 1.0E-12); + self::assertEqualsWithDelta($params->radiusY * $scaleFactor, $normalized->radiusY, 1.0E-12); + } + + #[DataProvider('provideCalculateArcCenterScenarios')] + public function testCalculateArcCenterBoundaryScenarios( + ArcParams $params, + float $expectedCenterX, + float $expectedCenterY, + ): void { + $math = new SvgArcMath(); + + [$centerX, $centerY] = $math->calculateArcCenter($params); + + self::assertEqualsWithDelta($expectedCenterX, $centerX, 1.0E-15); + self::assertEqualsWithDelta($expectedCenterY, $centerY, 1.0E-15); + } + + /** + * @return iterable + */ + public static function provideCalculateArcCenterScenarios(): iterable + { + yield 'denominator bucket zero uses midpoint' => [ + 'params' => new ArcParams( + 2.0E-6, + 2.0E-6, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0, + 1, + ), + 'expectedCenterX' => 1.0E-6, + 'expectedCenterY' => 1.0E-6, + ]; + + yield 'floor versus round boundary keeps midpoint branch' => [ + 'params' => new ArcParams( + 0.0, + 0.0, + 1.5491933384829667E-5, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0, + 1, + ), + 'expectedCenterX' => 7.745966692414834E-6, + 'expectedCenterY' => 0.0, + ]; + } + + #[DataProvider('provideCalculateArcAnglesScenarios')] + public function testCalculateArcAnglesBoundaryScenarios( + ArcParams $params, + float $expectedStartAngle, + float $expectedDeltaAngle, + ): void { + $math = new SvgArcMath(); + + [$startAngle, $deltaAngle] = $math->calculateArcAngles($params); + + self::assertEqualsWithDelta($expectedStartAngle, $startAngle, 1.0E-12); + self::assertEqualsWithDelta($expectedDeltaAngle, $deltaAngle, 1.0E-12); + } + + /** + * @return iterable + */ + public static function provideCalculateArcAnglesScenarios(): iterable + { + yield 'near-zero magnitude with sweep adjustment' => [ + 'params' => new ArcParams( + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0, + 0, + ), + 'expectedStartAngle' => 0.0, + 'expectedDeltaAngle' => -(3.0 * M_PI / 2.0), + ]; + + yield 'stable magnitude uses pi branch' => [ + 'params' => new ArcParams( + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0, + 1, + ), + 'expectedStartAngle' => 0.0, + 'expectedDeltaAngle' => M_PI, + ]; + + yield 'rounding boundary keeps half-pi branch' => [ + 'params' => new ArcParams( + 0.0, + 0.0, + 1.5491933384829667E-5, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0, + 1, + ), + 'expectedStartAngle' => M_PI, + 'expectedDeltaAngle' => M_PI / 2.0, + ]; + } + + #[DataProvider('provideGenerateArcCurvesScenarios')] + public function testGenerateArcCurvesBoundaryScenarios( + ArcParams $params, + float $centerX, + float $centerY, + float $startAngle, + float $deltaAngle, + int $expectedCurveCount, + array $expectedFirstCurve, + ): void { + $math = new SvgArcMath(); + + $curves = $math->generateArcCurves($params, $centerX, $centerY, $startAngle, $deltaAngle); + + self::assertCount($expectedCurveCount, $curves); + self::assertCurveMatches($expectedFirstCurve, $curves[0], 1.0E-12); + } + + /** + * @return iterable, + * }> + */ + public static function provideGenerateArcCurvesScenarios(): iterable + { + $ceilDeltaAngle = 1.1 * (M_PI / 2.0); + $zeroDeltaAngle = 0.0; + $thresholdDeltaAngle = 1.0E-10; + $alphaDeltaAngle = M_PI / 3.0; + + $alphaTanHalfAngleStep = tan($alphaDeltaAngle / 2.0); + $alpha = sin($alphaDeltaAngle) + * (sqrt(4.0 + 3.0 * $alphaTanHalfAngleStep * $alphaTanHalfAngleStep) - 1.0) + / 3.0; + + yield 'ceil boundary keeps two segments' => [ + 'params' => self::createCurveGenerationParams(0.0, 0.0, 10.0, 7.0, 1.0, 0.0, 0.0, $ceilDeltaAngle), + 'centerX' => 0.0, + 'centerY' => 0.0, + 'startAngle' => 0.0, + 'deltaAngle' => $ceilDeltaAngle, + 'expectedCurveCount' => 2, + 'expectedFirstCurve' => [ + 10.0, + 2.046640160464945, + 8.71773389395062, + 3.993655301352086, + 6.494480483301835, + 5.322841759200218, + ], + ]; + + yield 'zero delta still emits one curve' => [ + 'params' => self::createCurveGenerationParams(0.0, 0.0, 10.0, 7.0, 1.0, 0.0, 0.0, $zeroDeltaAngle), + 'centerX' => 0.0, + 'centerY' => 0.0, + 'startAngle' => 0.0, + 'deltaAngle' => $zeroDeltaAngle, + 'expectedCurveCount' => 1, + 'expectedFirstCurve' => [10.0, 0.0, 10.0, 0.0, 10.0, 0.0], + ]; + + yield 'threshold angle keeps alpha zero' => [ + 'params' => self::createCurveGenerationParams(0.0, 0.0, 9.0, 4.0, 1.0, 0.0, 0.0, $thresholdDeltaAngle), + 'centerX' => 0.0, + 'centerY' => 0.0, + 'startAngle' => 0.0, + 'deltaAngle' => $thresholdDeltaAngle, + 'expectedCurveCount' => 1, + 'expectedFirstCurve' => [ + 9.0, + 0.0, + 9.0, + 4.0E-10, + 9.0, + 4.0E-10, + ], + ]; + + yield 'alpha formula keeps squared tan term' => [ + 'params' => self::createCurveGenerationParams(0.0, 0.0, 12.0, 8.0, 1.0, 0.0, 0.0, $alphaDeltaAngle), + 'centerX' => 0.0, + 'centerY' => 0.0, + 'startAngle' => 0.0, + 'deltaAngle' => $alphaDeltaAngle, + 'expectedCurveCount' => 1, + 'expectedFirstCurve' => [ + 12.0, + $alpha * 8.0, + 9.708203932499371, + 5.500914871183149, + 6.000000000000002, + 6.928203230275509, + ], + ]; + } +} diff --git a/tests/Unit/Pdf/Svg/SvgColorResolverTest.php b/tests/Unit/Pdf/Svg/SvgColorResolverTest.php new file mode 100644 index 0000000..b7df823 --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgColorResolverTest.php @@ -0,0 +1,399 @@ +createElement('path', [ + 'fill' => ' #AaBbCc ', + 'style' => 'fill:#ff0000', + 'class' => 'accent', + ]); + + self::assertSame('#ff0000', $resolver->resolveFillColor($element, ['accent' => '#123456'])); + + $element->setAttribute('fill', 'none'); + + self::assertSame('#ff0000', $resolver->resolveFillColor($element, ['accent' => '#123456'])); + } + + public function testResolveFillColorFallsBackToStyleClassAncestorAndDefault(): void + { + $resolver = new SvgColorResolver(); + + $styleElement = $this->createElement('path', [ + 'style' => 'stroke:#fff; fill: rgb(10,20,30);', + ]); + self::assertSame('rgb(10,20,30)', $resolver->resolveFillColor($styleElement, [])); + + $classElement = $this->createElement('path', [ + 'class' => ' primary secondary ', + ]); + self::assertSame('#112233', $resolver->resolveFillColor($classElement, ['secondary' => '#112233'])); + + $ancestor = $this->createElement('g', ['style' => 'fill:#abcdef']); + $document = $ancestor->ownerDocument; + self::assertInstanceOf(DOMDocument::class, $document); + $descendant = $document->createElement('path'); + $ancestor->appendChild($descendant); + self::assertSame('#abcdef', $resolver->resolveFillColor($descendant, [])); + + $fallback = $this->createElement('path'); + self::assertSame('#000000', $resolver->resolveFillColor($fallback, [])); + } + + public function testResolveStrokeColorFallsBackToClassAndAncestorButDefaultsToNull(): void + { + $resolver = new SvgColorResolver(); + + $classElement = $this->createElement('line', ['class' => 'outline']); + self::assertSame('#ff0000', $resolver->resolveStrokeColor($classElement, ['outline' => '#ff0000'])); + + $ancestor = $this->createElement('g', ['stroke' => 'rgb(0,128,255)']); + $document = $ancestor->ownerDocument; + self::assertInstanceOf(DOMDocument::class, $document); + $descendant = $document->createElement('line'); + $ancestor->appendChild($descendant); + self::assertSame('#0080ff', $resolver->resolveStrokeColor($descendant, [])); + + $noneByClass = $this->createElement('line', ['class' => 'ghost']); + self::assertNull($resolver->resolveStrokeColor($noneByClass, ['ghost' => 'none'])); + + $fallback = $this->createElement('line'); + self::assertNull($resolver->resolveStrokeColor($fallback, [])); + } + + public function testResolveStrokeColorPrefersStyleOverPresentationAttribute(): void + { + $resolver = new SvgColorResolver(); + $element = $this->createElement('line', [ + 'stroke' => '#ff0000', + 'style' => 'stroke:#00ff00', + ]); + + self::assertSame('#00ff00', $resolver->resolveStrokeColor($element, [])); + } + + public function testResolveColorAttributeRemainsCallableFromOutsideTheClass(): void + { + $resolver = new SvgColorResolver(); + + self::assertTrue(is_callable($resolver->resolveColorAttribute(...))); + } + + #[DataProvider('provideExtractValueFromStyleAttributeScenarios')] + public function testExtractValueFromStyleAttributeReturnsRequestedProperty( + string $style, + string $property, + ?string $expected, + bool $useColorExtractor = false, + ): void { + $resolver = new SvgColorResolver(); + + $result = $useColorExtractor + ? $resolver->extractColorFromStyleAttribute($style, $property) + : $resolver->extractValueFromStyleAttribute($style, $property); + + self::assertSame($expected, $result); + } + + /** + * @return iterable + */ + public static function provideExtractValueFromStyleAttributeScenarios(): iterable + { + yield 'extract generic property' => [ + 'style' => 'fill:#fff; stroke-width: 2.5 ;', + 'property' => 'stroke-width', + 'expected' => '2.5', + ]; + + yield 'extract fill color case-insensitive' => [ + 'style' => ' FiLl : #fff ; ', + 'property' => 'fill', + 'expected' => '#fff', + 'useColorExtractor' => true, + ]; + + yield 'extract dotted property name' => [ + 'style' => 'fill.opacity: 0.5', + 'property' => 'fill.opacity', + 'expected' => '0.5', + ]; + + yield 'extract value containing colon characters' => [ + 'style' => 'fill:url(http://example.com/a:b.svg)', + 'property' => 'fill', + 'expected' => 'url(http://example.com/a:b.svg)', + ]; + + yield 'skip blank declarations before valid property' => [ + 'style' => ' ; ; fill : red ; stroke:#000', + 'property' => 'fill', + 'expected' => 'red', + 'useColorExtractor' => true, + ]; + + yield 'skip tab and newline declaration before valid property' => [ + 'style' => "\t\n;fill:#00ff00", + 'property' => 'fill', + 'expected' => '#00ff00', + 'useColorExtractor' => true, + ]; + + yield 'skip truly empty declarations before valid property' => [ + 'style' => ';;fill:#123456', + 'property' => 'fill', + 'expected' => '#123456', + 'useColorExtractor' => true, + ]; + + yield 'skip malformed declaration before valid property' => [ + 'style' => 'fill red;fill:#abcdef', + 'property' => 'fill', + 'expected' => '#abcdef', + 'useColorExtractor' => true, + ]; + + yield 'skip whitespace-only declarations before valid property' => [ + 'style' => " \t \n; fill: red", + 'property' => 'fill', + 'expected' => 'red', + 'useColorExtractor' => true, + ]; + + yield 'skip whitespace-only declarations before another property' => [ + 'style' => " \t\n;stroke:none;fill:#aabbcc", + 'property' => 'fill', + 'expected' => '#aabbcc', + ]; + + yield 'skip all-whitespace declarations before valid color' => [ + 'style' => " ; \t ; \n ; fill: #123456", + 'property' => 'fill', + 'expected' => '#123456', + 'useColorExtractor' => true, + ]; + + yield 'empty style returns null' => [ + 'style' => '', + 'property' => 'fill', + 'expected' => null, + ]; + + yield 'missing property returns null' => [ + 'style' => 'stroke:#000', + 'property' => 'fill', + 'expected' => null, + ]; + } + + #[DataProvider('provideNormalizeColorScenarios')] + public function testNormalizeColorSupportsExpectedFormats(string $input, ?string $expected): void + { + $resolver = new SvgColorResolver(); + + self::assertSame($expected, $resolver->normalizeColor($input)); + } + + /** + * @return iterable + */ + public static function provideNormalizeColorScenarios(): iterable + { + yield 'empty string' => ['input' => ' ', 'expected' => null]; + yield 'none sentinel' => ['input' => 'none', 'expected' => 'none']; + yield 'short hex' => ['input' => '#abc', 'expected' => '#abc']; + yield 'long hex uppercased and trimmed' => ['input' => ' #AABBCC ', 'expected' => '#aabbcc']; + yield 'hex with invalid prefix' => ['input' => 'x#abc', 'expected' => null]; + yield 'hex with invalid suffix' => ['input' => '#abcx', 'expected' => null]; + yield 'hex with invalid medium length' => ['input' => '#1234', 'expected' => null]; + yield 'rgb notation rejects negative channel' => ['input' => 'rgb(300,-1,12)', 'expected' => null]; + yield 'rgb notation valid' => ['input' => 'rgb(255, 0, 12)', 'expected' => '#ff000c']; + yield 'rgb notation clamps overflowing red' => ['input' => 'rgb(256,0,0)', 'expected' => '#ff0000']; + yield 'rgb notation preserves max green' => ['input' => 'rgb(0,255,0)', 'expected' => '#00ff00']; + yield 'rgb notation clamps overflowing green' => ['input' => 'rgb(0,256,0)', 'expected' => '#00ff00']; + yield 'rgb notation preserves zero blue' => ['input' => 'rgb(0,0,0)', 'expected' => '#000000']; + yield 'rgb notation clamps overflowing blue' => ['input' => 'rgb(0,0,256)', 'expected' => '#0000ff']; + yield 'rgb notation rejects prefixed content' => ['input' => 'prefix rgb(255,0,12)', 'expected' => null]; + yield 'rgb notation rejects suffixed content' => ['input' => 'rgb(255,0,12) suffix', 'expected' => null]; + yield 'rgb notation rejects missing closing parenthesis' => ['input' => 'rgb(255,0,12', 'expected' => null]; + yield 'rgb notation rejects missing opening marker' => ['input' => '255,0,12)', 'expected' => null]; + yield 'rgb notation rejects contaminated channel' => ['input' => 'rgb(12x,0,0)', 'expected' => null]; + yield 'rgb notation rejects missing channel' => ['input' => 'rgb(0,0)', 'expected' => null]; + yield 'rgb notation rejects extra channel' => ['input' => 'rgb(0,0,0,0)', 'expected' => null]; + yield 'rgb notation clamps overflowing all channels' => [ + 'input' => 'rgb(300,300,300)', + 'expected' => '#ffffff', + ]; + yield 'hex with invalid characters' => ['input' => '#gggggg', 'expected' => null]; + yield 'named black' => ['input' => 'black', 'expected' => '#000000']; + yield 'named white' => ['input' => 'white', 'expected' => '#ffffff']; + yield 'named red' => ['input' => 'red', 'expected' => '#ff0000']; + yield 'named green' => ['input' => 'green', 'expected' => '#008000']; + yield 'named gray alias' => ['input' => 'grey', 'expected' => '#808080']; + yield 'named gray canonical' => ['input' => 'gray', 'expected' => '#808080']; + yield 'named blue' => ['input' => 'blue', 'expected' => '#0000ff']; + yield 'named yellow' => ['input' => 'yellow', 'expected' => '#ffff00']; + yield 'invalid color' => ['input' => 'chartreuse-ish', 'expected' => null]; + } + + #[DataProvider('provideNormalizeColorWhitespaceScenarios')] + public function testNormalizeColorTrimsWhitespaceBeforeProcessing(string $input, ?string $expected): void + { + $resolver = new SvgColorResolver(); + + self::assertSame($expected, $resolver->normalizeColor($input)); + } + + /** + * @return iterable + */ + public static function provideNormalizeColorWhitespaceScenarios(): iterable + { + yield 'trim hex color' => ['input' => ' #aabbcc ', 'expected' => '#aabbcc']; + yield 'trim named color' => ['input' => ' black ', 'expected' => '#000000']; + yield 'trim rgb color' => ['input' => ' rgb(255, 0, 0) ', 'expected' => '#ff0000']; + yield 'trim rgb with tabs' => ['input' => "\t #fff \n", 'expected' => '#fff']; + yield 'trim named color with trailing tab' => ['input' => " black \t", 'expected' => '#000000']; + yield 'trim rgb color with spaces' => ['input' => ' rgb(10,20,30) ', 'expected' => '#0a141e']; + } + + #[DataProvider('provideResolveFillColorClassExtractionScenarios')] + public function testResolveFillColorHandlesClassExtraction( + array $attributes, + array $classColors, + string $expected, + ): void { + $resolver = new SvgColorResolver(); + $element = $this->createElement('div', $attributes); + + $result = $resolver->resolveFillColor($element, $classColors); + + self::assertSame($expected, $result); + } + + /** + * @return iterable, classColors: array, + * expected: string}> + */ + public static function provideResolveFillColorClassExtractionScenarios(): iterable + { + yield 'multiple spaces keep first matching class' => [ + 'attributes' => ['class' => ' primary secondary tertiary '], + 'classColors' => [ + 'primary' => '#111111', + 'secondary' => '#222222', + 'tertiary' => '#333333', + ], + 'expected' => '#111111', + ]; + + yield 'blank class tokens do not win over real class' => [ + 'attributes' => ['class' => ' primary '], + 'classColors' => [ + '' => '#999999', + 'primary' => '#111111', + ], + 'expected' => '#111111', + ]; + + yield 'empty class falls back to default fill' => [ + 'attributes' => ['class' => ''], + 'classColors' => ['primary' => '#ff0000'], + 'expected' => '#000000', + ]; + + yield 'whitespace only class falls back to default fill' => [ + 'attributes' => ['class' => ' '], + 'classColors' => [ + '' => '#999999', + 'primary' => '#ff0000', + ], + 'expected' => '#000000', + ]; + } + + #[DataProvider('provideNormalizeColorRgbValidationScenarios')] + public function testNormalizeColorValidatesRgbChannels(string $input, ?string $expected): void + { + $resolver = new SvgColorResolver(); + + self::assertSame($expected, $resolver->normalizeColor($input)); + } + + /** + * @return iterable + */ + public static function provideNormalizeColorRgbValidationScenarios(): iterable + { + yield 'accepts numeric rgb channels' => ['input' => 'rgb(255, 0, 0)', 'expected' => '#ff0000']; + yield 'accepts rgb channels with spaces' => ['input' => 'rgb( 255 , 0 , 0 )', 'expected' => '#ff0000']; + yield 'rejects non numeric channels' => ['input' => 'rgb(25a, 0, 0)', 'expected' => null]; + yield 'rejects negative channels' => ['input' => 'rgb(255, -1, 0)', 'expected' => null]; + yield 'rejects explicit positive sign' => ['input' => 'rgb(+12, 0, 0)', 'expected' => null]; + yield 'rejects empty channel' => ['input' => 'rgb(, 0, 0)', 'expected' => null]; + yield 'clamps overflowing channels' => ['input' => 'rgb(256, 0, 0)', 'expected' => '#ff0000']; + yield 'rejects integer overflow channel' => [ + 'input' => 'rgb(999999999999999999999999, 0, 0)', + 'expected' => null, + ]; + yield 'rejects alphanumeric suffix' => ['input' => 'rgb(123abc, 0, 0)', 'expected' => null]; + yield 'rejects alphanumeric prefix' => ['input' => 'rgb(abc123, 0, 0)', 'expected' => null]; + yield 'rejects embedded spaces inside channel digits' => ['input' => 'rgb(12 3, 0, 0)', 'expected' => null]; + } + + #[DataProvider('provideExtractColorFromStyleScenarios')] + public function testExtractColorFromStyleAttributeHandlesExpectedInputs(string $style, ?string $expected): void + { + $resolver = new SvgColorResolver(); + + $result = $resolver->extractColorFromStyleAttribute($style, 'fill'); + + self::assertSame($expected, $result); + } + + /** + * @return iterable + */ + public static function provideExtractColorFromStyleScenarios(): iterable + { + yield 'extract from multiple declarations' => [ + 'style' => 'fill: red; stroke: blue; opacity: 0.5;', + 'expected' => 'red', + ]; + yield 'extract from style with blank declarations' => [ + 'style' => ' ; ; fill: red ; stroke: blue;', + 'expected' => 'red', + ]; + yield 'empty style returns null' => ['style' => '', 'expected' => null]; + yield 'whitespace style returns null' => ['style' => ' ', 'expected' => null]; + } + + private function createElement(string $name, array $attributes = []): DOMElement + { + $document = new DOMDocument('1.0', 'UTF-8'); + $element = $document->createElement($name); + $document->appendChild($element); + + foreach ($attributes as $attribute => $value) { + $element->setAttribute($attribute, $value); + } + + return $element; + } +} diff --git a/tests/Unit/Pdf/Svg/SvgElementPathBuilderTest.php b/tests/Unit/Pdf/Svg/SvgElementPathBuilderTest.php new file mode 100644 index 0000000..eaf2fac --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgElementPathBuilderTest.php @@ -0,0 +1,268 @@ +loadXML('' . $markup . ''); + self::assertTrue($loaded); + + $element = $document->documentElement?->firstElementChild; + self::assertInstanceOf(DOMElement::class, $element); + + return $element; + } + + #[DataProvider('provideInvalidPathScenarios')] + public function testBuildElementPathReturnsNullForInvalidScenarios( + string $markup, + float $minX, + float $maxY, + ): void { + $builder = new SvgElementPathBuilder(); + $element = self::createElement($markup); + + $result = $builder->buildElementPath($element, $minX, $maxY, '/tmp/source.svg', self::identityTransform()); + + self::assertNull($result); + } + + #[DataProvider('provideExpectedPathScenarios')] + public function testBuildElementPathBuildsExpectedPath( + string $markup, + float $minX, + float $maxY, + string $expected, + ): void { + $builder = new SvgElementPathBuilder(); + $element = self::createElement($markup); + + $result = $builder->buildElementPath($element, $minX, $maxY, '/tmp/source.svg', self::identityTransform()); + + self::assertSame($expected, $result); + } + + public function testBuildElementPathBuildsCircleWithFourCubicSegmentsAndClosedPath(): void + { + $builder = new SvgElementPathBuilder(); + $element = self::createElement(''); + + $result = $builder->buildElementPath($element, 2.0, 10.0, '/tmp/source.svg', self::identityTransform()); + + self::assertSame( + "3.000000 7.000000 m\n" + . "4.104569 7.000000 5.000000 6.104569 5.000000 5.000000 c\n" + . "5.000000 3.895431 4.104569 3.000000 3.000000 3.000000 c\n" + . "1.895431 3.000000 1.000000 3.895431 1.000000 5.000000 c\n" + . "1.000000 6.104569 1.895431 7.000000 3.000000 7.000000 c\n" + . "h", + $result, + ); + } + + public function testBuildElementPathBuildsEllipsePathWithExpectedAnchorPoints(): void + { + $builder = new SvgElementPathBuilder(); + $element = self::createElement(''); + + $result = $builder->buildElementPath($element, 2.0, 20.0, '/tmp/source.svg', self::identityTransform()); + + self::assertSame( + "8.000000 12.000000 m\n" + . "10.209139 12.000000 12.000000 11.104569 12.000000 10.000000 c\n" + . "12.000000 8.895431 10.209139 8.000000 8.000000 8.000000 c\n" + . "5.790861 8.000000 4.000000 8.895431 4.000000 10.000000 c\n" + . "4.000000 11.104569 5.790861 12.000000 8.000000 12.000000 c\n" + . "h", + $result, + ); + } + + /** + * @return iterable + */ + public static function provideInvalidPathScenarios(): iterable + { + yield 'unsupported element' => [ + 'markup' => 'noop', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'path with only whitespace' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'polygon with only whitespace points' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'polygon with odd coordinate count' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'polyline with only whitespace points' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'polyline with odd coordinate count' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'polyline with five numeric tokens' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'rect with zero width' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'rect with zero height' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'circle with zero radius' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'ellipse with zero rx' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'ellipse with zero ry' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'non numeric prefix in length' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + + yield 'missing width attribute is treated as empty length' => [ + 'markup' => '', + 'minX' => 0.0, + 'maxY' => 10.0, + ]; + } + + /** + * @return iterable + */ + public static function provideExpectedPathScenarios(): iterable + { + yield 'path command with minX offset' => [ + 'markup' => '', + 'minX' => 2.0, + 'maxY' => 10.0, + 'expected' => "-1.000000 8.000000 m\n1.000000 6.000000 l", + ]; + + yield 'polygon with spaces' => [ + 'markup' => '', + 'minX' => 2.0, + 'maxY' => 10.0, + 'expected' => "-1.000000 9.000000 m\n1.000000 9.000000 l\n1.000000 7.000000 l\nh", + ]; + + yield 'polygon first pair used as move-to' => [ + 'markup' => '', + 'minX' => 1.0, + 'maxY' => 12.0, + 'expected' => "1.000000 5.000000 m\n3.000000 11.000000 l\n5.000000 10.000000 l\nh", + ]; + + yield 'polygon with exactly four numbers' => [ + 'markup' => '', + 'minX' => 2.0, + 'maxY' => 10.0, + 'expected' => "-1.000000 9.000000 m\n1.000000 7.000000 l\nh", + ]; + + yield 'polyline with two points' => [ + 'markup' => '', + 'minX' => 1.0, + 'maxY' => 12.0, + 'expected' => "1.000000 5.000000 m\n7.000000 9.000000 l", + ]; + + yield 'polyline with three points' => [ + 'markup' => '', + 'minX' => 2.0, + 'maxY' => 10.0, + 'expected' => "-1.000000 9.000000 m\n2.000000 8.000000 l\n4.000000 5.000000 l", + ]; + + yield 'polyline preserves distinct first x and y' => [ + 'markup' => '', + 'minX' => 1.0, + 'maxY' => 12.0, + 'expected' => "1.000000 5.000000 m\n3.000000 11.000000 l\n5.000000 10.000000 l", + ]; + + yield 'rect clockwise path' => [ + 'markup' => '', + 'minX' => 2.0, + 'maxY' => 10.0, + 'expected' => "-1.000000 8.000000 m\n2.000000 8.000000 l\n2.000000 4.000000 l\n-1.000000 4.000000 l\nh", + ]; + + yield 'line basic lengths' => [ + 'markup' => '', + 'minX' => 2.0, + 'maxY' => 10.0, + 'expected' => "-1.000000 8.000000 m\n1.000000 6.000000 l", + ]; + + yield 'line with whitespace lengths' => [ + 'markup' => '', + 'minX' => 2.0, + 'maxY' => 10.0, + 'expected' => "-1.000000 8.000000 m\n1.000000 6.000000 l", + ]; + } +} diff --git a/tests/Unit/Pdf/Svg/SvgPathCommandParserTest.php b/tests/Unit/Pdf/Svg/SvgPathCommandParserTest.php new file mode 100644 index 0000000..6c6d9a8 --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgPathCommandParserTest.php @@ -0,0 +1,749 @@ +, + * unexpectedSnippets?: list + * } + * @phpstan-type TransformScenario array{ + * pathData: string, + * minX: float, + * maxY: float, + * transformMatrix: array, + * source: string, + * expectedSnippets: list + * } + * @phpstan-type CurveScenario array{ + * pathData: string, + * maxY: float, + * expectedSnippets: list, + * expectedCurveCount?: int + * } + */ +final class SvgPathCommandParserTest extends TestCase +{ + #[DataProvider('provideBasicPathConversionScenarios')] + public function testConvertPathDataHandlesBasicCommands( + string $pathData, + float $minX, + float $maxY, + string $source, + array $expectedSnippets, + array $unexpectedSnippets = [], + ): void { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + $pathData, + $minX, + $maxY, + $source, + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + foreach ($expectedSnippets as $snippet) { + self::assertStringContainsString($snippet, $result); + } + + foreach ($unexpectedSnippets as $snippet) { + self::assertStringNotContainsString($snippet, $result); + } + } + + #[DataProvider('provideTransformAndCoordinateConversionScenarios')] + public function testConvertPathDataAppliesTransformationAndCoordinateSystem( + string $pathData, + float $minX, + float $maxY, + array $transformMatrix, + string $source, + array $expectedSnippets, + ): void { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + $pathData, + $minX, + $maxY, + $source, + $transformMatrix, + ); + + foreach ($expectedSnippets as $snippet) { + self::assertStringContainsString($snippet, $result); + } + } + + #[DataProvider('provideCurveAndArcConversionScenarios')] + public function testConvertPathDataHandlesComplexCurvesAndArcs( + string $pathData, + float $maxY, + array $expectedSnippets, + int $expectedCurveCount = 0, + ): void { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + $pathData, + 0.0, + $maxY, + '/tmp/test.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + foreach ($expectedSnippets as $snippet) { + self::assertStringContainsString($snippet, $result); + } + + self::assertGreaterThanOrEqual( + $expectedCurveCount, + substr_count($result, ' c'), + 'Expected at least ' . $expectedCurveCount . ' cubic curves', + ); + } + + #[DataProvider('provideArcNormalizationScenarios')] + public function testConvertPathDataNormalizesArcParameters( + string $pathData1, + string $pathData2, + string $description, + ): void { + $parser = new SvgPathCommandParser(); + + $result1 = $parser->convertPathData( + $pathData1, + 0.0, + 20.0, + '/tmp/variant1.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + $result2 = $parser->convertPathData( + $pathData2, + 0.0, + 20.0, + '/tmp/variant2.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + self::assertSame($result1, $result2, sprintf('Arc normalization failed for %s', $description)); + } + + #[DataProvider('provideSmoothCubicResetScenarios')] + public function testConvertPathDataResetsSmoothCubicStateAfterStateBreakingCommands( + string $pathData, + array $expectedSnippets, + string $description, + ): void { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + $pathData, + 0.0, + 10.0, + '/tmp/smooth-cubic-state.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + foreach ($expectedSnippets as $snippet) { + self::assertStringContainsString($snippet, $result, $description); + } + } + + #[DataProvider('provideSmoothQuadraticResetScenarios')] + public function testConvertPathDataResetsSmoothQuadraticStateAfterStateBreakingCommands( + string $pathData, + float $height, + string $source, + string $expectedSnippet, + ): void { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + $pathData, + 0.0, + $height, + $source, + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + self::assertStringContainsString($expectedSnippet, $result); + } + + #[DataProvider('provideFinalCommandScenarios')] + public function testConvertPathDataAllowsFinalCommandWithoutMalformedError( + string $pathData, + float $height, + string $source, + string $expectedSnippet, + ): void { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + $pathData, + 0.0, + $height, + $source, + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + self::assertStringContainsString($expectedSnippet, $result); + } + + #[DataProvider('provideRelativeAfterClosePathScenarios')] + public function testConvertPathDataUsesSubpathStartAsCurrentPointAfterClosePath( + string $pathData, + array $expectedSnippets, + string $description, + ): void { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + $pathData, + 0.0, + 10.0, + '/tmp/relative-after-close.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + foreach ($expectedSnippets as $snippet) { + self::assertStringContainsString($snippet, $result, $description); + } + } + + #[DataProvider('provideInvalidPathScenarios')] + public function testConvertPathDataRejectsInvalidSequences(string $pathData, string $expectedMessage): void + { + $parser = new SvgPathCommandParser(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + $parser->convertPathData( + $pathData, + 0.0, + 10.0, + '/tmp/invalid.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + } + + public function testConvertPathDataRejectsTrailingNumbersAfterClosePath(): void + { + $parser = new SvgPathCommandParser(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Malformed SVG path data in "/tmp/invalid.svg".'); + + $parser->convertPathData( + 'M 0 0 Z 1', + 0.0, + 10.0, + '/tmp/invalid.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + } + + public function testConvertPathDataSubtractsMinXFromFirstCubicControlPoint(): void + { + $parser = new SvgPathCommandParser(); + + $result = $parser->convertPathData( + 'M 10 10 C 11 9 12 8 13 7', + 5.0, + 20.0, + '/tmp/cubic-minx.svg', + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + ); + + self::assertStringContainsString('6.000000 11.000000 7.000000 12.000000 8.000000 13.000000 c', $result); + self::assertStringNotContainsString('16.000000 11.000000 7.000000 12.000000 8.000000 13.000000 c', $result); + } + + /** + * @return iterable + */ + public static function provideBasicPathConversionScenarios(): iterable + { + yield 'line commands and close path with minX offset' => [ + 'pathData' => 'M 0 0 L 10 0 H 15 V 5 Z', + 'minX' => 2.0, + 'maxY' => 10.0, + 'source' => '/tmp/shape.svg', + 'expectedSnippets' => [ + '-2.000000 10.000000 m', + '8.000000 10.000000 l', + '13.000000 10.000000 l', + '13.000000 5.000000 l', + ], + 'unexpectedSnippets' => [], + ]; + + yield 'relative move with implicit lines and transform matrix' => [ + 'pathData' => 'm 1 1 2 0 0 2', + 'minX' => 0.0, + 'maxY' => 20.0, + 'source' => '/tmp/relative.svg', + 'expectedSnippets' => [ + '1.000000 19.000000 m', + '3.000000 19.000000 l', + '3.000000 17.000000 l', + ], + ]; + + yield 'distinct move and line coordinates preserved' => [ + 'pathData' => 'M 3 7 L 4 8', + 'minX' => 0.0, + 'maxY' => 20.0, + 'source' => '/tmp/distinct-move.svg', + 'expectedSnippets' => [ + '3.000000 13.000000 m', + '4.000000 12.000000 l', + ], + ]; + + yield 'relative commands with scientific notation' => [ + 'pathData' => 'M 1e1 1e1 l -5 0 h 2 v -3', + 'minX' => 0.0, + 'maxY' => 20.0, + 'source' => '/tmp/scientific.svg', + 'expectedSnippets' => [ + '10.000000 10.000000 m', + '5.000000 10.000000 l', + '7.000000 10.000000 l', + '7.000000 13.000000 l', + ], + ]; + + yield 'smooth cubic without previous control point' => [ + 'pathData' => 'M 2 2 S 4 4 6 2 T 10 2', + 'minX' => 0.0, + 'maxY' => 12.0, + 'source' => '/tmp/smooth.svg', + 'expectedSnippets' => [ + '2.000000 10.000000 m', + '2.000000 10.000000 4.000000 8.000000 6.000000 10.000000 c', + '6.000000 10.000000 7.333333 10.000000 10.000000 10.000000 c', + ], + ]; + + yield 'smooth cubic after previous cubic' => [ + 'pathData' => 'M 0 0 C 2 2 4 2 6 0 S 10 -2 12 0', + 'minX' => 0.0, + 'maxY' => 10.0, + 'source' => '/tmp/smooth-cubic.svg', + 'expectedSnippets' => [ + '6.000000 10.000000 c', + '8.000000 12.000000 10.000000 12.000000 12.000000 10.000000 c', + ], + ]; + + yield 'smooth quadratic after previous quadratic' => [ + 'pathData' => 'M 0 0 Q 2 2 4 0 T 8 0', + 'minX' => 0.0, + 'maxY' => 10.0, + 'source' => '/tmp/smooth-quadratic-reflection.svg', + 'expectedSnippets' => [ + '5.333333 11.333333 6.666667 11.333333 8.000000 10.000000 c', + ], + ]; + + yield 'multiple line commands keep progress strictly increasing' => [ + 'pathData' => 'M 0 0 L 10 10 L 5 5', + 'minX' => 0.0, + 'maxY' => 10.0, + 'source' => '/tmp/progress.svg', + 'expectedSnippets' => [ + '0.000000 10.000000 m', + '10.000000 0.000000 l', + '5.000000 5.000000 l', + ], + ]; + } + + /** + * @return iterable + */ + public static function provideTransformAndCoordinateConversionScenarios(): iterable + { + yield 'relative move with transform matrix applied' => [ + 'pathData' => 'm 1 1 2 0 0 2', + 'minX' => 0.0, + 'maxY' => 20.0, + 'transformMatrix' => [2.0, 0.0, 0.0, 2.0, 1.0, 3.0], + 'source' => '/tmp/transform.svg', + 'expectedSnippets' => [ + '3.000000 15.000000 m', + '7.000000 15.000000 l', + '7.000000 11.000000 l', + ], + ]; + + yield 'minx offset for move and line commands' => [ + 'pathData' => 'M 10 10 L 12 8 H 14 V 6', + 'minX' => 5.0, + 'maxY' => 20.0, + 'transformMatrix' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + 'source' => '/tmp/minx-lines.svg (via transform)', + 'expectedSnippets' => [ + '5.000000 10.000000 m', + '7.000000 12.000000 l', + '9.000000 12.000000 l', + '9.000000 14.000000 l', + ], + ]; + + yield 'minx offset for cubic and quadratic commands' => [ + 'pathData' => 'M 10 10 C 11 9 12 8 13 7 Q 14 6 15 5', + 'minX' => 5.0, + 'maxY' => 20.0, + 'transformMatrix' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + 'source' => '/tmp/minx-curves.svg (via transform)', + 'expectedSnippets' => [ + '6.000000 11.000000 7.000000 12.000000 8.000000 13.000000 c', + '8.666667 13.666667 9.333333 14.333333 10.000000 15.000000 c', + ], + ]; + + yield 'minx offset for cubic command' => [ + 'pathData' => 'M 2 0 C 4 2 6 2 8 0', + 'minX' => 2.0, + 'maxY' => 10.0, + 'transformMatrix' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + 'source' => '/tmp/minx-cubic.svg (via transform)', + 'expectedSnippets' => [ + '0.000000 10.000000 m', + '2.000000 8.000000 4.000000 8.000000 6.000000 10.000000 c', + ], + ]; + + yield 'minx offset for arc commands' => [ + 'pathData' => 'M 0 10 A 6 4 0 0 1 12 10', + 'minX' => 5.0, + 'maxY' => 20.0, + 'transformMatrix' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + 'source' => '/tmp/minx-arc.svg (via transform)', + 'expectedSnippets' => [ + '-5.000000 12.194335 -2.291503 14.000000 1.000000 14.000000 c', + '4.291503 14.000000 7.000000 12.194335 7.000000 10.000000 c', + ], + ]; + } + + /** + * @return iterable + */ + public static function provideCurveAndArcConversionScenarios(): iterable + { + yield 'cubic, quadratic, and arc commands mixed' => [ + 'pathData' => 'M 0 10 C 2 8 4 8 6 10 Q 8 12 10 10 T 14 10 A 4 2 0 0 1 18 10', + 'maxY' => 20.0, + 'expectedSnippets' => [ + '0.000000 10.000000 m', + '2.000000 12.000000 4.000000 12.000000 6.000000 10.000000 c', + '7.333333 8.666667 8.666667 8.666667 10.000000 10.000000 c', + '11.333333 11.333333 12.666667 11.333333 14.000000 10.000000 c', + ], + 'expectedCurveCount' => 4, + ]; + + yield 'distinct cubic control points' => [ + 'pathData' => 'M 0 0 C 1 2 3 4 5 6', + 'maxY' => 20.0, + 'expectedSnippets' => [ + '1.000000 18.000000 3.000000 16.000000 5.000000 14.000000 c', + ], + ]; + + yield 'distinct quadratic control point' => [ + 'pathData' => 'M 0 0 Q 3 5 7 11', + 'maxY' => 20.0, + 'expectedSnippets' => [ + '2.000000 16.666667 4.333333 13.000000 7.000000 9.000000 c', + ], + ]; + + yield 'arc curve points with distinct coordinates' => [ + 'pathData' => 'M 0 10 A 6 4 0 0 1 12 10', + 'maxY' => 20.0, + 'expectedSnippets' => [ + '-0.000000 12.194335 2.708497 14.000000 6.000000 14.000000 c', + '9.291503 14.000000 12.000000 12.194335 12.000000 10.000000 c', + ], + ]; + + yield 'arc flags and sweep from correct slots with rotation' => [ + 'pathData' => 'M 2 3 A 7 5 2.5 0 1 0 9', + 'maxY' => 20.0, + 'expectedSnippets' => [ + '1.007795 19.138991 3.354053 16.371429 2.470197 13.719862 c', + '1.586341 11.068294 3.735147 10.288287 0.000000 11.000000 c', + ], + ]; + + yield 'arc rotation sensitivity affects control point calculation' => [ + 'pathData' => 'M 2 3 A 7 5 1.2 0 1 0 9', + 'maxY' => 20.0, + 'expectedSnippets' => [ + '1.002032 19.139077 3.346601 16.397452 2.459942 13.737475 c', + '1.573283 11.077498 3.735898 10.328215 0.000000 11.000000 c', + ], + ]; + + yield 'relative arc command in complex path' => [ + 'pathData' => 'M 1e1 1e1 l -5 0 h 2 v -3 a 4 2 0 0 1 6 0', + 'maxY' => 20.0, + 'expectedSnippets' => [ + '10.000000 10.000000 m', + '5.000000 10.000000 l', + '7.000000 10.000000 l', + '7.000000 13.000000 l', + ], + 'expectedCurveCount' => 2, + ]; + } + + /** + * @return iterable + */ + public static function provideArcNormalizationScenarios(): iterable + { + yield 'negative arc radii normalized to positive' => [ + 'pathData1' => 'M 0 10 A 4 2 0 0 1 8 10', + 'pathData2' => 'M 0 10 A -4 -2 0 0 1 8 10', + 'description' => 'negative radius normalization', + ]; + + yield 'decimal arc flags cast to integers' => [ + 'pathData1' => 'M 0 10 A 4 2 0 1 0 8 10', + 'pathData2' => 'M 0 10 A 4 2 0 1.9 0.2 8 10', + 'description' => 'arc flag decimal casting', + ]; + + yield 'both negative radii and decimal flags normalized together' => [ + 'pathData1' => 'M 0 10 A 5 3 0 0 1 10 10', + 'pathData2' => 'M 0 10 A -5 -3 0 0.1 1.9 10 10', + 'description' => 'combined arc normalization', + ]; + } + + /** + * @return iterable + */ + public static function provideInvalidPathScenarios(): iterable + { + yield 'empty path data' => [ + 'pathData' => '', + 'expectedMessage' => 'Unsupported or empty SVG path data in "/tmp/invalid.svg".', + ]; + + yield 'path without initial command' => [ + 'pathData' => '1 2 3', + 'expectedMessage' => 'Invalid SVG path command sequence in "/tmp/invalid.svg".', + ]; + + yield 'malformed move command' => [ + 'pathData' => 'M 1', + 'expectedMessage' => 'Malformed SVG path data in "/tmp/invalid.svg".', + ]; + + yield 'unsupported command' => [ + 'pathData' => 'M 1 1 R 2 2', + 'expectedMessage' => 'SVG path command "R" is not supported for source "/tmp/invalid.svg".', + ]; + + yield 'malformed arc command missing endpoint' => [ + 'pathData' => 'M 0 0 A 1 2 0 0 1', + 'expectedMessage' => 'Malformed SVG path data in "/tmp/invalid.svg".', + ]; + + yield 'trailing scalar after close command' => [ + 'pathData' => 'M 0 0 Z 1', + 'expectedMessage' => 'Malformed SVG path data in "/tmp/invalid.svg".', + ]; + + yield 'trailing scalar after lowercase close command' => [ + 'pathData' => 'M 0 0 z 1', + 'expectedMessage' => 'Malformed SVG path data in "/tmp/invalid.svg".', + ]; + } + + /** + * @return iterable, description: string}> + */ + public static function provideSmoothCubicResetScenarios(): iterable + { + yield 'resets smooth cubic state after line command' => [ + 'pathData' => 'M 0 0 C 2 2 4 2 6 0 L 8 0 S 10 2 12 0', + 'expectedSnippets' => [ + '8.000000 10.000000 10.000000 8.000000 12.000000 10.000000 c', + ], + 'description' => 'S after L should not use previous cubic endpoint as control point', + ]; + + yield 'resets smooth cubic state after arc command' => [ + 'pathData' => 'M 0 10 C 2 8 4 8 6 10 A 2 2 0 0 1 10 10 S 12 12 14 10', + 'expectedSnippets' => [ + '10.000000 0.000000 12.000000 -2.000000 14.000000 0.000000 c', + ], + 'description' => 'S after A should not use previous cubic control point as reflection', + ]; + + yield 'maintains smooth cubic state across relative and absolute commands' => [ + 'pathData' => 'M 0 0 c 2 2 4 2 6 0 s 4 -2 6 0', + 'expectedSnippets' => [ + '2.000000 8.000000 4.000000 8.000000 6.000000 10.000000 c', + '8.000000 12.000000 10.000000 12.000000 12.000000 10.000000 c', + ], + 'description' => 'relative and absolute smooth cubics should maintain control point state', + ]; + } + + /** + * @return iterable + */ + public static function provideSmoothQuadraticResetScenarios(): iterable + { + yield 'after line command' => [ + 'pathData' => 'M 0 0 Q 2 2 4 0 L 5 0 T 7 2', + 'height' => 10.0, + 'source' => '/tmp/smooth-quadratic-after-line.svg', + 'expectedSnippet' => '5.000000 10.000000 5.666667 9.333333 7.000000 8.000000 c', + ]; + + yield 'after cubic command' => [ + 'pathData' => 'M 0 0 Q 2 2 4 0 C 5 1 6 1 7 0 T 9 2', + 'height' => 10.0, + 'source' => '/tmp/smooth-quadratic-after-cubic.svg', + 'expectedSnippet' => '7.000000 10.000000 7.666667 9.333333 9.000000 8.000000 c', + ]; + + yield 'after arc command' => [ + 'pathData' => 'M 0 10 Q 2 12 4 10 A 2 2 0 0 1 8 10 T 10 12', + 'height' => 20.0, + 'source' => '/tmp/smooth-quadratic-after-arc.svg', + 'expectedSnippet' => '8.000000 10.000000 8.666667 9.333333 10.000000 8.000000 c', + ]; + + yield 'after horizontal command' => [ + 'pathData' => 'M 0 0 Q 2 2 4 0 H 5 T 7 2', + 'height' => 10.0, + 'source' => '/tmp/smooth-quadratic-after-horizontal.svg', + 'expectedSnippet' => '5.000000 10.000000 5.666667 9.333333 7.000000 8.000000 c', + ]; + + yield 'after vertical command' => [ + 'pathData' => 'M 0 0 Q 2 2 4 0 V 1 T 6 3', + 'height' => 10.0, + 'source' => '/tmp/smooth-quadratic-after-vertical.svg', + 'expectedSnippet' => '4.000000 9.000000 4.666667 8.333333 6.000000 7.000000 c', + ]; + + yield 'after close path command' => [ + 'pathData' => 'M 0 0 Q 2 2 4 0 Z T 6 2', + 'height' => 10.0, + 'source' => '/tmp/smooth-quadratic-after-close.svg', + 'expectedSnippet' => '0.000000 10.000000 2.000000 9.333333 6.000000 8.000000 c', + ]; + } + + /** + * @return iterable + */ + public static function provideFinalCommandScenarios(): iterable + { + yield 'final horizontal command' => [ + 'pathData' => 'M 0 0 H 5', + 'height' => 10.0, + 'source' => '/tmp/final-horizontal.svg', + 'expectedSnippet' => '5.000000 10.000000 l', + ]; + + yield 'final vertical command' => [ + 'pathData' => 'M 0 0 V 5', + 'height' => 10.0, + 'source' => '/tmp/final-vertical.svg', + 'expectedSnippet' => '0.000000 5.000000 l', + ]; + + yield 'final cubic command' => [ + 'pathData' => 'M 0 0 C 1 2 3 4 5 6', + 'height' => 20.0, + 'source' => '/tmp/final-cubic.svg', + 'expectedSnippet' => '1.000000 18.000000 3.000000 16.000000 5.000000 14.000000 c', + ]; + + yield 'final quadratic command' => [ + 'pathData' => 'M 0 0 Q 3 5 7 11', + 'height' => 20.0, + 'source' => '/tmp/final-quadratic.svg', + 'expectedSnippet' => '2.000000 16.666667 4.333333 13.000000 7.000000 9.000000 c', + ]; + + yield 'final smooth quadratic command' => [ + 'pathData' => 'M 0 0 Q 2 2 4 0 T 8 0', + 'height' => 10.0, + 'source' => '/tmp/final-smooth-quadratic.svg', + 'expectedSnippet' => '5.333333 11.333333 6.666667 11.333333 8.000000 10.000000 c', + ]; + + yield 'final arc command' => [ + 'pathData' => 'M 0 10 A 6 4 0 0 1 12 10', + 'height' => 20.0, + 'source' => '/tmp/final-arc.svg', + 'expectedSnippet' => '9.291503 14.000000 12.000000 12.194335 12.000000 10.000000 c', + ]; + } + + /** + * @return iterable, description: string}> + */ + public static function provideRelativeAfterClosePathScenarios(): iterable + { + yield 'relative line after close path uses subpath start' => [ + 'pathData' => 'M 1 1 L 3 1 Z l 1 0', + 'expectedSnippets' => [ + '1.000000 9.000000 m', + '3.000000 9.000000 l', + '2.000000 9.000000 l', + ], + 'description' => 'current point should reset to subpath start after Z', + ]; + + yield 'multiple subpaths with close and relative commands' => [ + 'pathData' => 'M 0 0 L 5 0 Z m 2 2 l 3 0', + 'expectedSnippets' => [ + '0.000000 10.000000 m', + '5.000000 10.000000 l', + '2.000000 8.000000 m', + '5.000000 8.000000 l', + ], + 'description' => 'new subpath should start independently', + ]; + + yield 'close path resets control points for smooth commands' => [ + 'pathData' => 'M 0 0 C 2 2 4 2 6 0 Z S 8 0 10 2', + 'expectedSnippets' => [ + '6.000000 10.000000 c', + '0.000000 10.000000 8.000000 10.000000 10.000000 8.000000 c', + ], + 'description' => 'S after Z should treat previous curve as non-existent', + ]; + } +} diff --git a/tests/Unit/Pdf/Svg/SvgPathNumberReaderTest.php b/tests/Unit/Pdf/Svg/SvgPathNumberReaderTest.php new file mode 100644 index 0000000..36e5606 --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgPathNumberReaderTest.php @@ -0,0 +1,78 @@ +readPathNumbers($tokens, $index, 3, '/tmp/path.svg'); + + self::assertSame([10.0, -2.5, 30.0], $values); + self::assertSame(3, $index); + } + + public function testReadPathNumbersAcceptsSingleDigitNumericToken(): void + { + $reader = new SvgPathNumberReader(); + $tokens = ['7']; + $index = 0; + + $values = $reader->readPathNumbers($tokens, $index, 1, '/tmp/path.svg'); + + self::assertSame([7.0], $values); + self::assertSame(1, $index); + } + + #[DataProvider('provideMalformedTokenScenarios')] + public function testReadPathNumbersRejectsMalformedSequences( + array $tokens, + int $index, + int $count, + ): void { + $reader = new SvgPathNumberReader(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Malformed SVG path data in "/tmp/path.svg".'); + + $reader->readPathNumbers($tokens, $index, $count, '/tmp/path.svg'); + } + + /** + * @return iterable, index: int, count: int}> + */ + public static function provideMalformedTokenScenarios(): iterable + { + yield 'command token encountered before all numeric values' => [ + 'tokens' => ['10', 'M', '20'], + 'index' => 1, + 'count' => 1, + ]; + + yield 'index already beyond available token list' => [ + 'tokens' => ['10'], + 'index' => 1, + 'count' => 1, + ]; + + yield 'requested count runs into command token' => [ + 'tokens' => ['10', '20', 'L'], + 'index' => 0, + 'count' => 3, + ]; + } +} diff --git a/tests/Unit/Pdf/Svg/SvgPdfXObjectFactoryTest.php b/tests/Unit/Pdf/Svg/SvgPdfXObjectFactoryTest.php new file mode 100644 index 0000000..bdb1960 --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgPdfXObjectFactoryTest.php @@ -0,0 +1,1033 @@ +create( + <<<'SVG' + + + + + +SVG, + '/tmp/test.svg', + ); + + self::assertSame('/XObject', $xObject->dictionary['Type']); + self::assertSame('/Form', $xObject->dictionary['Subtype']); + self::assertSame(1, $xObject->dictionary['FormType']); + self::assertSame([0.0, 0.0, 10.0, 8.0], $xObject->dictionary['BBox']); + self::assertSame([0.1, 0.0, 0.0, 0.125, 0.0, 0.0], $xObject->dictionary['Matrix']); + self::assertStringContainsString('0.0667 0.1333 0.2 rg', $xObject->stream); + self::assertStringContainsString('0.000000 8.000000 m', $xObject->stream); + self::assertStringContainsString('10.000000 0.000000 l', $xObject->stream); + self::assertStringContainsString('h', $xObject->stream); + self::assertStringContainsString('f', $xObject->stream); + } + + public function testCreateSupportsPolygonAndRectElements(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + <<<'SVG' + + + + +SVG, + '/tmp/polygon-rect.svg', + ); + + self::assertSame([0.0, 0.0, 20.0, 20.0], $xObject->dictionary['BBox']); + self::assertSame([0.05, 0.0, 0.0, 0.05, 0.0, 0.0], $xObject->dictionary['Matrix']); + self::assertStringContainsString('1 0 0 rg', $xObject->stream); + self::assertStringContainsString('0.000000 20.000000 m', $xObject->stream); + self::assertStringContainsString('0 1 0 rg', $xObject->stream); + self::assertStringContainsString('10.000000 10.000000 m', $xObject->stream); + self::assertStringContainsString('20.000000 0.000000 l', $xObject->stream); + } + + #[DataProvider('provideInvalidSvgSources')] + public function testCreateRejectsInvalidSvgSources( + string $svg, + string $sourcePath, + string $expectedMessage, + ): void { + $factory = new SvgPdfXObjectFactory(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + $factory->create($svg, $sourcePath); + } + + #[DataProvider('provideInvalidViewportScenarios')] + public function testCreateRejectsInvalidViewportScenarios(string $svg, string $expectedMessage): void + { + $factory = new SvgPdfXObjectFactory(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + $factory->create($svg, '/tmp/invalid-viewport.svg'); + } + + public function testCreateSupportsTransformOperationsAndStrokeInheritance(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + <<<'SVG' + + + + + + + + + +SVG, + '/tmp/transforms.svg', + ); + + self::assertSame([0.0, 0.0, 40.0, 20.0], $xObject->dictionary['BBox']); + self::assertStringContainsString('0.0392 0.0784 0.1176 rg', $xObject->stream); + self::assertStringContainsString('1 0 0 RG', $xObject->stream); + self::assertStringContainsString('0 1 0 RG', $xObject->stream); + self::assertStringContainsString('B', $xObject->stream); + self::assertStringContainsString('S', $xObject->stream); + } + + public function testCreateSkipsInvalidOrUnpaintedShapesWithoutFailing(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + <<<'SVG' + + + + + + + + + +SVG, + '/tmp/skip-invalid-shapes.svg', + ); + + self::assertSame([0.0, 0.0, 20.0, 20.0], $xObject->dictionary['BBox']); + self::assertStringContainsString('0.0706 0.2039 0.3373 rg', $xObject->stream); + self::assertStringContainsString('f', $xObject->stream); + self::assertStringNotContainsString('RG', $xObject->stream); + } + + public function testCreateRejectsMalformedAndUnsupportedPathCommands(): void + { + $factory = new SvgPdfXObjectFactory(); + + try { + $factory->create( + '', + '/tmp/malformed-path.svg', + ); + self::fail('Expected malformed path to be rejected.'); + } catch (InvalidArgumentException $exception) { + self::assertStringContainsString('Malformed SVG path data', $exception->getMessage()); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SVG path command "R" is not supported'); + + $factory->create( + '', + '/tmp/unsupported-path.svg', + ); + } + + #[DataProvider('provideSupportedShapeScenarios')] + public function testCreateSupportsShapeScenarios( + string $svg, + string $sourcePath, + array $expectedBBox, + array $requiredStreamFragments, + int $minimumCubicBezierOperators = 0, + ): void { + $factory = new SvgPdfXObjectFactory(); + $xObject = $factory->create($svg, $sourcePath); + + self::assertSame($expectedBBox, $xObject->dictionary['BBox']); + + foreach ($requiredStreamFragments as $requiredStreamFragment) { + self::assertStringContainsString($requiredStreamFragment, $xObject->stream); + } + + self::assertGreaterThanOrEqual($minimumCubicBezierOperators, substr_count($xObject->stream, ' c')); + } + + #[DataProvider('providePaintModeScenarios')] + public function testCreateRendersPaintModeScenarios( + string $svg, + string $sourcePath, + array $expectedBBox, + array $requiredStreamFragments, + array $forbiddenStreamFragments, + ): void { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create($svg, $sourcePath); + + self::assertSame($expectedBBox, $xObject->dictionary['BBox']); + + foreach ($requiredStreamFragments as $requiredStreamFragment) { + self::assertStringContainsString($requiredStreamFragment, $xObject->stream); + } + + foreach ($forbiddenStreamFragments as $forbiddenStreamFragment) { + self::assertStringNotContainsString($forbiddenStreamFragment, $xObject->stream); + } + } + + #[DataProvider('provideDimensionScenarios')] + public function testCreateWithVariousDimensions( + string $svg, + string $sourcePath, + array $expectedBBox, + ): void { + $factory = new SvgPdfXObjectFactory(); + $xObject = $factory->create($svg, $sourcePath); + + self::assertSame($expectedBBox, $xObject->dictionary['BBox']); + + foreach ($xObject->dictionary['BBox'] as $value) { + self::assertIsFloat($value); + } + } + + #[DataProvider('provideStrokeWidthScenarios')] + public function testCreateWithVariousStrokeWidths( + string $svg, + string $sourcePath, + string $expectedStrokeContent, + array $forbiddenStreamFragments = [], + ): void { + $factory = new SvgPdfXObjectFactory(); + $xObject = $factory->create($svg, $sourcePath); + self::assertStringContainsString($expectedStrokeContent, $xObject->stream); + + foreach ($forbiddenStreamFragments as $forbiddenStreamFragment) { + self::assertStringNotContainsString($forbiddenStreamFragment, $xObject->stream); + } + } + + #[DataProvider('provideShapeElementScenarios')] + public function testCreateWithShapeElements( + string $svg, + string $sourcePath, + array $requiredStreamFragments, + ): void { + $factory = new SvgPdfXObjectFactory(); + $xObject = $factory->create($svg, $sourcePath); + foreach ($requiredStreamFragments as $fragment) { + self::assertStringContainsString($fragment, $xObject->stream); + } + } + + #[DataProvider('provideColorScenarios')] + public function testCreateWithColorScenarios( + string $svg, + string $sourcePath, + array $expectedColors, + array $forbiddenColors = [], + ): void { + $factory = new SvgPdfXObjectFactory(); + $xObject = $factory->create($svg, $sourcePath); + foreach ($expectedColors as $color) { + self::assertStringContainsString($color, $xObject->stream); + } + + foreach ($forbiddenColors as $color) { + self::assertStringNotContainsString($color, $xObject->stream); + } + } + + public function testCreateSkipsUnrecognizedShapeElementsGracefully(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + <<<'SVG' + + Ignored + + + +SVG, + '/tmp/mixed-elements.svg', + ); + + // Only rect should be rendered + self::assertStringContainsString('1 0 0 rg', $xObject->stream); + } + + public function testCreateAcceptsUppercaseSvgRootElementName(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + '' + . '' + . '', + '/tmp/uppercase-root.svg', + ); + + self::assertSame([0.0, 0.0, 10.0, 10.0], $xObject->dictionary['BBox']); + self::assertStringContainsString('1 0 0 rg', $xObject->stream); + } + + public function testCreateRestoresLibxmlInternalErrorStateAfterParsing(): void + { + $factory = new SvgPdfXObjectFactory(); + + $previousSetting = libxml_use_internal_errors(false); + + try { + $factory->create( + '' + . '' + . '', + '/tmp/libxml-state.svg', + ); + + self::assertFalse(libxml_use_internal_errors()); + } finally { + libxml_use_internal_errors($previousSetting); + libxml_clear_errors(); + } + } + + public function testCreateClearsAccumulatedLibxmlErrorsAfterParsing(): void + { + $factory = new SvgPdfXObjectFactory(); + + $previousSetting = libxml_use_internal_errors(true); + + try { + // Seed libxml error buffer before invoking factory parsing. + $document = new \DOMDocument('1.0', 'UTF-8'); + $document->loadXML('create( + '' + . '' + . '', + '/tmp/libxml-clear-errors.svg', + ); + + self::assertSame([], libxml_get_errors()); + } finally { + libxml_use_internal_errors($previousSetting); + libxml_clear_errors(); + } + } + + public function testCreateRespectsViewBoxMinXAndMinYOriginsWhenBuildingPaths(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + <<<'SVG' + + + + SVG, + '/tmp/viewbox-origin.svg', + ); + + self::assertSame([0.0, 0.0, 10.0, 6.0], $xObject->dictionary['BBox']); + self::assertStringContainsString('0.000000 6.000000 m', $xObject->stream); + self::assertStringContainsString('10.000000 0.000000 l', $xObject->stream); + } + + public function testCreateTreatsWhitespaceOnlyViewBoxAsAbsentAndFallsBackToDimensions(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + '' + . '' + . '', + '/tmp/whitespace-viewbox.svg', + ); + + self::assertSame([0.0, 0.0, 12.0, 8.0], $xObject->dictionary['BBox']); + } + + public function testCreateParsesWidthAndHeightWithSurroundingWhitespace(): void + { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create( + '' + . '' + . '', + '/tmp/whitespace-dimensions.svg', + ); + + self::assertSame([0.0, 0.0, 12.5, 7.25], $xObject->dictionary['BBox']); + } + + #[DataProvider('provideStyleBlockColorExtractionScenarios')] + public function testCreateResolvesClassColorMapsFromStyleBlocks( + string $svg, + string $sourcePath, + array $requiredStreamFragments, + ): void { + $factory = new SvgPdfXObjectFactory(); + + $xObject = $factory->create($svg, $sourcePath); + + foreach ($requiredStreamFragments as $fragment) { + self::assertStringContainsString($fragment, $xObject->stream); + } + } + + public function testCreateWithViewBoxAndDimensionsCombination(): void + { + $factory = new SvgPdfXObjectFactory(); + + // ViewBox takes precedence over width/height + $xObject = $factory->create( + <<<'SVG' + + + +SVG, + '/tmp/viewbox-precedence.svg', + ); + + self::assertSame([0.0, 0.0, 50.0, 50.0], $xObject->dictionary['BBox']); + } + + public static function provideSupportedShapeScenarios(): iterable + { + yield 'quadratic bezier path commands' => [ + <<<'SVG' + + + +SVG, + '/tmp/quadratic.svg', + [0.0, 0.0, 20.0, 10.0], + [' c', '0 0 1 rg'], + 0, + ]; + + yield 'absolute arc command' => [ + <<<'SVG' + + + +SVG, + '/tmp/arc.svg', + [0.0, 0.0, 20.0, 10.0], + [' c', '1 0 0 rg'], + 0, + ]; + + yield 'relative arc command' => [ + <<<'SVG' + + + +SVG, + '/tmp/arc-relative.svg', + [0.0, 0.0, 20.0, 10.0], + [' c'], + 0, + ]; + + yield 'circle element' => [ + <<<'SVG' + + + +SVG, + '/tmp/circle.svg', + [0.0, 0.0, 20.0, 20.0], + [], + 4, + ]; + + yield 'ellipse element' => [ + <<<'SVG' + + + +SVG, + '/tmp/ellipse.svg', + [0.0, 0.0, 30.0, 20.0], + [], + 4, + ]; + + yield 'fill inherited from parent group' => [ + <<<'SVG' + + + + + +SVG, + '/tmp/group-fill.svg', + [0.0, 0.0, 10.0, 10.0], + ['1 0 0 rg'], + 0, + ]; + + yield 'group translate transform affects child geometry' => [ + <<<'SVG' + + + + + +SVG, + '/tmp/group-transform-translate.svg', + [0.0, 0.0, 20.0, 10.0], + ['6.000000 3.000000 m', '9.000000 1.000000 l'], + 0, + ]; + } + + public static function provideInvalidViewportScenarios(): iterable + { + yield 'invalid viewbox token count' => [ + '', + 'Invalid viewBox in SVG source "/tmp/invalid-viewport.svg".', + ]; + + yield 'invalid viewbox with non numeric minY' => [ + '', + 'Invalid viewBox in SVG source "/tmp/invalid-viewport.svg".', + ]; + + yield 'non-positive viewbox dimensions' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define a positive viewBox.', + ]; + + yield 'zero height viewbox dimensions' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define a positive viewBox.', + ]; + + yield 'missing usable viewport and dimensions' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define either a valid viewBox or positive width/height.', + ]; + + yield 'missing dimensions and viewbox' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define either a valid viewBox or positive width/height.', + ]; + + yield 'zero width without viewbox' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define either a valid viewBox or positive width/height.', + ]; + + yield 'zero height without viewbox' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define either a valid viewBox or positive width/height.', + ]; + + yield 'non numeric dimension prefix' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define either a valid viewBox or positive width/height.', + ]; + + yield 'viewbox with zero dimensions' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define a positive viewBox.', + ]; + + yield 'viewbox with negative height' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define a positive viewBox.', + ]; + + yield 'positive viewbox dimensions are required' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define a positive viewBox.', + ]; + + yield 'negative viewbox height is rejected' => [ + '', + 'SVG source "/tmp/invalid-viewport.svg" must define a positive viewBox.', + ]; + } + + public static function provideInvalidSvgSources(): iterable + { + yield 'empty svg string' => [ + '', + '/tmp/empty.svg', + 'Unable to parse SVG source "/tmp/empty.svg".', + ]; + + yield 'html payload' => [ + '', + '/tmp/invalid.svg', + 'Unable to parse SVG source "/tmp/invalid.svg".', + ]; + + yield 'non svg root element' => [ + '', + '/tmp/wrong-root.svg', + 'Unable to parse SVG source "/tmp/wrong-root.svg".', + ]; + + yield 'malformed svg root' => [ + '', + '/tmp/malformed-root.svg', + 'Unable to parse SVG source "/tmp/malformed-root.svg".', + ]; + } + + public static function provideStyleBlockColorExtractionScenarios(): iterable + { + yield 'uppercase fill and stroke properties' => [ + <<<'SVG' + + + + + SVG, + '/tmp/uppercase-css-style.svg', + ['0.0667 0.1333 0.2 rg', '1 0 0 RG', '2.000000 w'], + ]; + + yield 'ignore empty and non matching style blocks' => [ + <<<'SVG' + + + + + + +SVG, + '/tmp/style-continue-coverage.svg', + ['0.0667 0.1333 0.2 rg', '1 0 0 RG', '2.000000 w'], + ]; + + yield 'css class matching applies both rules' => [ + <<<'SVG' + + + + + +SVG, + '/tmp/css-rules.svg', + ['1 0 0 rg', '0 1 0 rg'], + ]; + } + + public static function provideDimensionScenarios(): iterable + { + yield 'signed dimensions with leading +' => [ + <<<'SVG' + + + +SVG, + '/tmp/signed-dims.svg', + [0.0, 0.0, 100.0, 50.0], + ]; + + yield 'fractional dimensions' => [ + <<<'SVG' + + + +SVG, + '/tmp/fractional-dims.svg', + [0.0, 0.0, 12.5, 7.25], + ]; + + yield 'scientific notation in dimensions' => [ + <<<'SVG' + + + +SVG, + '/tmp/scientific-notation.svg', + [0.0, 0.0, 100.0, 50.0], + ]; + + yield 'very small fractional dimensions' => [ + <<<'SVG' + + + +SVG, + '/tmp/tiny-dims.svg', + [0.0, 0.0, 0.125, 0.25], + ]; + + yield 'large dimensions' => [ + <<<'SVG' + + + +SVG, + '/tmp/large-dims.svg', + [0.0, 0.0, 10000.0, 8000.0], + ]; + + yield 'trimmed dimensions remain numeric floats' => [ + <<<'SVG' + + + +SVG, + '/tmp/trimmed-dims.svg', + [0.0, 0.0, 10.5, 20.75], + ]; + } + + public static function provideStrokeWidthScenarios(): iterable + { + yield 'stroke width from style attribute' => [ + <<<'SVG' + + + +SVG, + '/tmp/style-stroke.svg', + '2.500000 w', + ]; + + yield 'stroke width style overrides presentation attribute' => [ + <<<'SVG' + + + +SVG, + '/tmp/style-overrides-stroke-width.svg', + '2.500000 w', + ['1.000000 w'], + ]; + + yield 'negative stroke width clamped to 0' => [ + <<<'SVG' + + + +SVG, + '/tmp/negative-stroke.svg', + '0.000000 w', + ]; + + yield 'stroke width with leading whitespace' => [ + <<<'SVG' + + + +SVG, + '/tmp/whitespace-stroke.svg', + '2.500000 w', + ]; + + yield 'zero stroke width from style' => [ + <<<'SVG' + + + +SVG, + '/tmp/zero-style-stroke.svg', + '0.000000 w', + ]; + + yield 'default stroke width is 1.0' => [ + <<<'SVG' + + + +SVG, + '/tmp/default-stroke.svg', + '1.000000 w', + ['-1.000000 w', '0.000000 w'], + ]; + + yield 'large stroke width' => [ + <<<'SVG' + + + +SVG, + '/tmp/large-stroke.svg', + '10.000000 w', + ]; + + yield 'very small stroke width' => [ + <<<'SVG' + + + +SVG, + '/tmp/tiny-stroke.svg', + '0.010000 w', + ]; + } + + public static function provideShapeElementScenarios(): iterable + { + yield 'path element' => [ + <<<'SVG' + + + +SVG, + '/tmp/path.svg', + ['1 0 0 rg'], + ]; + + yield 'rect element' => [ + <<<'SVG' + + + +SVG, + '/tmp/rect.svg', + ['0 1 0 rg'], + ]; + + yield 'circle element with colors' => [ + <<<'SVG' + + + +SVG, + '/tmp/circle-elem.svg', + ['0 0 1 rg'], + ]; + + yield 'ellipse element' => [ + <<<'SVG' + + + +SVG, + '/tmp/ellipse-elem.svg', + ['1 0.4 0 rg'], + ]; + + yield 'line element' => [ + <<<'SVG' + + + +SVG, + '/tmp/line-elem.svg', + ['0 0 0 RG', '2.000000 w'], + ]; + + yield 'polyline element' => [ + <<<'SVG' + + + +SVG, + '/tmp/polyline-elem.svg', + ['0.6667 0 0.6667 RG', '1.500000 w'], + ]; + + yield 'polygon element' => [ + <<<'SVG' + + + +SVG, + '/tmp/polygon-elem.svg', + ['1 1 0 rg'], + ]; + + yield 'multiple elements mixed' => [ + <<<'SVG' + + + + + +SVG, + '/tmp/multi-shapes-elem.svg', + ['1 0 0 rg', '0 1 0 rg', '0 0 1 rg'], + ]; + + yield 'unrecognized text element is skipped with shape parsing' => [ + <<<'SVG' + + + + Ignored + + +SVG, + '/tmp/element-filter.svg', + ['1 0 0 rg'], + ]; + } + + public static function provideColorScenarios(): iterable + { + yield 'class-based colors from style block' => [ + <<<'SVG' + + + + + + +SVG, + '/tmp/multi-class-styles.svg', + ['1 0 0 rg', '0 1 0 rg', '0 0 1 rg'], + ]; + + yield 'stroke and fill from style attribute' => [ + <<<'SVG' + + + +SVG, + '/tmp/style-stroke-fill.svg', + ['3.500000 w', '1 0 0 RG'], + ]; + + yield 'fill and stroke combined' => [ + <<<'SVG' + + + +SVG, + '/tmp/both-colors-style.svg', + ['0.0706 0.2039 0.3373 rg', '1 0 0 RG', '1.500000 w'], + ]; + + yield 'element case insensitivity with colors' => [ + <<<'SVG' + + + + + +SVG, + '/tmp/uppercase-colors.svg', + ['1 0 0 rg', '0 1 0 rg'], + ]; + + yield 'style fill beats presentation attribute fill' => [ + <<<'SVG' + + + +SVG, + '/tmp/fill-priority.svg', + ['0 1 0 rg'], + ['1 0 0 rg'], + ]; + + yield 'rgb color notation' => [ + <<<'SVG' + + + +SVG, + '/tmp/rgb-color.svg', + ['1 0 0 rg'], + ]; + } + + public static function providePaintModeScenarios(): iterable + { + yield 'stroke only path without fill' => [ + <<<'SVG' + + + +SVG, + '/tmp/stroke-only.svg', + [0.0, 0.0, 10.0, 10.0], + ['0 0 1 RG', '2.000000 w', "\nS\n"], + ["\nf\n"], + ]; + + yield 'fill and stroke together' => [ + <<<'SVG' + + + +SVG, + '/tmp/fill-stroke.svg', + [0.0, 0.0, 10.0, 10.0], + ['1 0 0 rg', '0 0 0 RG', "\nB\n"], + [], + ]; + + yield 'fill only without stroke' => [ + <<<'SVG' + + + +SVG, + '/tmp/fill-only.svg', + [0.0, 0.0, 10.0, 10.0], + ['0 1 0 rg', "\nf\n"], + ['RG', "\nS\n"], + ]; + + yield 'stroke attribute with color' => [ + <<<'SVG' + + + +SVG, + '/tmp/stroke-attr.svg', + [0.0, 0.0, 10.0, 10.0], + ['1 0 1 RG', '2.000000 w', "\nS\n"], + ["\nf\n"], + ]; + } +} diff --git a/tests/Unit/Pdf/Svg/SvgTransformResolverTest.php b/tests/Unit/Pdf/Svg/SvgTransformResolverTest.php new file mode 100644 index 0000000..a3d0d48 --- /dev/null +++ b/tests/Unit/Pdf/Svg/SvgTransformResolverTest.php @@ -0,0 +1,397 @@ + $expectedValue) { + self::assertEqualsWithDelta($expectedValue, $actual[$index], 0.0001); + } + } + + public function testApplyTransformToPointUsesAffineMatrixCoordinates(): void + { + $resolver = new SvgTransformResolver(); + + self::assertSame([16.0, 20.0], $resolver->applyTransformToPoint([2.0, 3.0, 4.0, 5.0, 6.0, 7.0], 1.0, 2.0)); + } + + #[DataProvider('provideSingleTransformScenarios')] + public function testResolveElementTransformMatrixSupportsIndividualTransformOperators( + string $transform, + float $x, + float $y, + array $expectedPoint, + ): void { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement([$transform]); + + $matrix = $resolver->resolveElementTransformMatrix($element); + [$actualX, $actualY] = $resolver->applyTransformToPoint($matrix, $x, $y); + + self::assertEqualsWithDelta($expectedPoint[0], $actualX, 0.0001); + self::assertEqualsWithDelta($expectedPoint[1], $actualY, 0.0001); + } + + public function testResolveElementTransformMatrixCombinesAncestorTransformsFromRootToLeaf(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement(['translate(2,3)', 'scale(2,2)']); + + $matrix = $resolver->resolveElementTransformMatrix($element); + [$actualX, $actualY] = $resolver->applyTransformToPoint($matrix, 1.0, 1.0); + + self::assertEqualsWithDelta(4.0, $actualX, 0.0001); + self::assertEqualsWithDelta(5.0, $actualY, 0.0001); + } + + public function testResolveElementTransformMatrixIncludesSvgRootAndTargetTransforms(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createElementWithRootAndTargetTransforms( + rootTransform: ' translate(3,0) ', + targetTransform: 'translate(4,1)', + ); + + $matrix = $resolver->resolveElementTransformMatrix($element); + [$actualX, $actualY] = $resolver->applyTransformToPoint($matrix, 1.0, 1.0); + + self::assertEqualsWithDelta(8.0, $actualX, 0.0001); + self::assertEqualsWithDelta(2.0, $actualY, 0.0001); + } + + public function testResolveElementTransformMatrixCapsAncestorDepthToPreventUnlimitedTraversal(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createDeepElementWithUnitTranslateTransforms(2100); + + $matrix = $resolver->resolveElementTransformMatrix($element); + [$actualX, $actualY] = $resolver->applyTransformToPoint($matrix, 0.0, 0.0); + + self::assertEqualsWithDelta(2048.0, $actualX, 0.0001); + self::assertEqualsWithDelta(0.0, $actualY, 0.0001); + } + + public function testResolveElementTransformMatrixIgnoresWhitespaceOnlyAncestorTransform(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement([' ', 'translate(4,1)']); + + $matrix = $resolver->resolveElementTransformMatrix($element); + [$actualX, $actualY] = $resolver->applyTransformToPoint($matrix, 1.0, 1.0); + + self::assertEqualsWithDelta(5.0, $actualX, 0.0001); + self::assertEqualsWithDelta(2.0, $actualY, 0.0001); + } + + #[DataProvider('provideDefaultingTransformScenarios')] + public function testResolveElementTransformMatrixSupportsOperatorDefaults( + string $transform, + float $x, + float $y, + array $expectedPoint, + ): void { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement([$transform]); + + $matrix = $resolver->resolveElementTransformMatrix($element); + [$actualX, $actualY] = $resolver->applyTransformToPoint($matrix, $x, $y); + + self::assertEqualsWithDelta($expectedPoint[0], $actualX, 0.0001); + self::assertEqualsWithDelta($expectedPoint[1], $actualY, 0.0001); + } + + #[DataProvider('provideIdentityFallbackTransformScenarios')] + public function testResolveElementTransformMatrixFallsBackToIdentityForUnsupportedOrMalformedTransformText( + string $transform, + ): void { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement([$transform]); + + self::assertSame([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], $resolver->resolveElementTransformMatrix($element)); + } + + public function testResolveElementTransformMatrixBuildsExpectedCompositeMatrix(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement(['translate(2,3) scale(4,5)']); + + $matrix = $resolver->resolveElementTransformMatrix($element); + + self::assertSame([4.0, 0.0, 0.0, 5.0, 2.0, 3.0], $matrix); + } + + #[DataProvider('provideTranslateWithEmptyArgumentScenarios')] + public function testResolveElementTransformMatrixSkipsEmptyTranslateArguments(string $transform): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement([$transform]); + + $matrix = $resolver->resolveElementTransformMatrix($element); + + self::assertSame([1.0, 0.0, 0.0, 1.0, 5.0, 7.0], $matrix); + } + + public function testResolveElementTransformMatrixReturnsRotationAroundOriginWhenCenterMissing(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement(['rotate(90)']); + + $matrix = $resolver->resolveElementTransformMatrix($element); + + self::assertMatrixEqualsWithDelta( + [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], + $matrix, + ); + } + + public function testResolveElementTransformMatrixReturnsRotationAroundSpecifiedCenter(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement(['rotate(90 1 2)']); + + $matrix = $resolver->resolveElementTransformMatrix($element); + + self::assertMatrixEqualsWithDelta( + [0.0, 1.0, -1.0, 0.0, 3.0, 1.0], + $matrix, + ); + } + + public function testResolveElementTransformMatrixMultipliesSequentialMatrices(): void + { + $resolver = new SvgTransformResolver(); + $element = $this->createNestedElement([ + 'matrix(1,2,3,4,5,6) matrix(7,8,9,10,11,12)', + ]); + + $matrix = $resolver->resolveElementTransformMatrix($element); + + self::assertSame([31.0, 46.0, 39.0, 58.0, 52.0, 76.0], $matrix); + } + + /** + * @return iterable + */ + public static function provideSingleTransformScenarios(): iterable + { + yield 'matrix operator' => [ + 'transform' => 'matrix(1,2,3,4,5,6)', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [12.0, 16.0], + ]; + + yield 'translate operator' => [ + 'transform' => 'translate(5,7)', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [6.0, 9.0], + ]; + + yield 'translate operator with extra whitespace' => [ + 'transform' => ' translate( 5 , 7 ) ', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [6.0, 9.0], + ]; + + yield 'scale operator with implicit y scale' => [ + 'transform' => 'scale(3)', + 'x' => 2.0, + 'y' => 4.0, + 'expectedPoint' => [6.0, 12.0], + ]; + + yield 'rotate operator around origin' => [ + 'transform' => 'rotate(90)', + 'x' => 2.0, + 'y' => 0.0, + 'expectedPoint' => [0.0, 2.0], + ]; + + yield 'rotate operator around center' => [ + 'transform' => 'rotate(90 1 2)', + 'x' => 2.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 3.0], + ]; + + yield 'skewX operator' => [ + 'transform' => 'skewX(45)', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [3.0, 2.0], + ]; + + yield 'skewY operator' => [ + 'transform' => 'skewY(45)', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 3.0], + ]; + } + + /** + * @return iterable + */ + public static function provideDefaultingTransformScenarios(): iterable + { + yield 'translate defaults missing y to zero' => [ + 'transform' => 'translate(5)', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [6.0, 2.0], + ]; + + yield 'translate without arguments is identity' => [ + 'transform' => 'translate()', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 2.0], + ]; + + yield 'matrix with insufficient arguments is identity' => [ + 'transform' => 'matrix(1,2,3)', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 2.0], + ]; + + yield 'scale without arguments is identity' => [ + 'transform' => 'scale()', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 2.0], + ]; + + yield 'skewX without arguments is identity' => [ + 'transform' => 'skewX()', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 2.0], + ]; + + yield 'skewY without arguments is identity' => [ + 'transform' => 'skewY()', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 2.0], + ]; + + yield 'rotate without arguments is identity' => [ + 'transform' => 'rotate()', + 'x' => 1.0, + 'y' => 2.0, + 'expectedPoint' => [1.0, 2.0], + ]; + } + + /** + * @return iterable + */ + public static function provideIdentityFallbackTransformScenarios(): iterable + { + yield 'unsupported transform operator text' => [ + 'transform' => 'banana(10)', + ]; + + yield 'malformed transform syntax without arguments list' => [ + 'transform' => 'translate', + ]; + } + + /** + * @return iterable + */ + public static function provideTranslateWithEmptyArgumentScenarios(): iterable + { + yield 'skips empty argument between separators' => [ + 'transform' => 'translate(5,,7)', + ]; + + yield 'skips leading empty argument' => [ + 'transform' => 'translate(,5,7)', + ]; + } + + /** + * @param list $transforms + */ + private function createNestedElement(array $transforms): DOMElement + { + $document = new DOMDocument('1.0', 'UTF-8'); + $svg = $document->createElement('svg'); + $document->appendChild($svg); + + $current = $svg; + + foreach ($transforms as $transform) { + $next = $document->createElement('g'); + $next->setAttribute('transform', $transform); + $current->appendChild($next); + $current = $next; + } + + $target = $document->createElement('path'); + $current->appendChild($target); + + return $target; + } + + private function createElementWithRootAndTargetTransforms( + string $rootTransform, + string $targetTransform, + ): DOMElement { + $document = new DOMDocument('1.0', 'UTF-8'); + $svg = $document->createElement('svg'); + $svg->setAttribute('transform', $rootTransform); + $document->appendChild($svg); + + $target = $document->createElement('path'); + $target->setAttribute('transform', $targetTransform); + $svg->appendChild($target); + + return $target; + } + + private function createDeepElementWithUnitTranslateTransforms(int $groupCount): DOMElement + { + $document = new DOMDocument('1.0', 'UTF-8'); + $svg = $document->createElement('svg'); + $svg->setAttribute('transform', 'translate(1,0)'); + $document->appendChild($svg); + + $current = $svg; + + for ($index = 0; $index < $groupCount; ++$index) { + $group = $document->createElement('g'); + $group->setAttribute('transform', 'translate(1,0)'); + $current->appendChild($group); + $current = $group; + } + + $target = $document->createElement('path'); + $target->setAttribute('transform', 'translate(1,0)'); + $current->appendChild($target); + + return $target; + } +}