meyfa/php-svg

View on GitHub
src/Rasterization/SVGRasterizer.php

Summary

Maintainability
A
45 mins
Test Coverage
B
81%
<?php

namespace SVG\Rasterization;

use InvalidArgumentException;
use RuntimeException;
use SVG\Fonts\FontRegistry;
use SVG\Nodes\SVGNode;
use SVG\Rasterization\Renderers\Renderer;
use SVG\Rasterization\Transform\Transform;
use SVG\Utilities\Units\Length;
use SVG\Utilities\Colors\Color;

/**
 * This class is the main entry point for the rasterization process.
 *
 * Each constructed instance represents one output image.
 * Rasterization happens by invoking `render()` with the id of a specific
 * renderer, e.g. 'ellipse' or 'polygon', which then performs the actual
 * drawing.
 * Note that renderers DO NOT correspond 1:1 to node types (e.g. there is no
 * renderer 'circle', but 'ellipse' with equal radii is used).
 *
 * @SuppressWarnings("coupling")
 */
class SVGRasterizer
{
    /**
     * @var Renderers\Renderer[] $renderers Map of shapes to renderers.
     */
    private static $renderers;

    private $fontRegistry;

    /**
     * @var float[] The document's viewBox (x, y, w, h).
     */
    private $viewBox;

    /**
     * @var int $width  The output image width, in pixels.
     */
    private int $width;
    /**
     * @var int $height The output image height, in pixels.
     */
    private int $height;

    /**
     * @var resource $outImage The output image as a GD resource.
     */
    private $outImage;

    // precomputed properties for getter methods, used often during render
    private $docWidth;
    private $docHeight;
    private $diagonalScale;

    private $transformStack;

    /**
     * @param string|null $docWidth   The original SVG document width, as a string.
     * @param string|null $docHeight  The original SVG document height, as a string.
     * @param float[]|null $viewBox   The document's viewBox.
     * @param int $width              The output image width, in pixels.
     * @param int $height             The output image height, in pixels.
     * @param string|null $background The background color (hex/rgb[a]/hsl[a]/...).
     */
    public function __construct(
        ?string $docWidth,
        ?string $docHeight,
        ?array $viewBox,
        int $width,
        int $height,
        ?string $background = null
    ) {
        $this->viewBox = empty($viewBox) ? null : $viewBox;

        $this->width  = $width;
        $this->height = $height;

        // precompute properties

        $this->docWidth  = Length::convert($docWidth ?: '100%', $width);
        $this->docHeight = Length::convert($docHeight ?: '100%', $height);

        $scaleX =  $width / (!empty($viewBox) ? $viewBox[2] : $this->docWidth ?? 0);
        $scaleY =  $height / (!empty($viewBox) ? $viewBox[3] : $this->docHeight ?? 0);
        $this->diagonalScale = hypot($scaleX, $scaleY) / M_SQRT2;

        $offsetX = !empty($viewBox) ? -($viewBox[0] * $scaleX) : 0;
        $offsetY = !empty($viewBox) ? -($viewBox[1] * $scaleY) : 0;

        // the transform stack starts out with a simple viewport transform
        $transform = Transform::identity();
        $transform->translate($offsetX, $offsetY);
        $transform->scale($scaleX, $scaleY);
        $this->transformStack = [$transform];

        // create image

        $this->outImage = self::createImage($width, $height, $background);

        self::createDependencies();
    }

    /**
     * Sets up a new truecolor GD image resource with the given dimensions.
     *
     * The returned image supports and is filled with transparency.
     *
     * @param int $width              The output image width, in pixels.
     * @param int $height             The output image height, in pixels.
     * @param string|null $background The background color (hex/rgb[a]/hsl[a]/...).
     *
     * @return resource The created GD image resource.
     */
    private static function createImage(int $width, int $height, ?string $background)
    {
        $img = imagecreatetruecolor($width, $height);

        imagealphablending($img, true);
        imagesavealpha($img, true);

        $bgRgb = 0x7F000000;
        if (!empty($background)) {
            $bgColor = Color::parse($background);

            $alpha = 127 - (int) ($bgColor[3] * 127 / 255);
            $bgRgb = ($alpha << 24) + ($bgColor[0] << 16) + ($bgColor[1] << 8) + ($bgColor[2]);
        }
        imagefill($img, 0, 0, $bgRgb);

        return $img;
    }

    /**
     * Makes sure the singleton static variables are all instantiated.
     *
     * This includes registering all of the standard renderers, as well as
     * preparing the path parser and the path approximator.
     *
     * @return void
     */
    private static function createDependencies(): void
    {
        if (isset(self::$renderers)) {
            return;
        }

        self::$renderers = [
            'rect'      => new Renderers\RectRenderer(),
            'line'      => new Renderers\LineRenderer(),
            'ellipse'   => new Renderers\EllipseRenderer(),
            'polygon'   => new Renderers\PolygonRenderer(),
            'path'      => new Renderers\PathRenderer(),
            'image'     => new Renderers\ImageRenderer(),
            'text'      => new Renderers\TextRenderer(),
        ];
    }

    /**
     * Finds the renderer registered with the given id.
     *
     * @param string $id The id of a registered renderer instance.
     *
     * @return Renderer The requested renderer.
     * @throws InvalidArgumentException If no such renderer exists.
     */
    private static function getRenderer(string $id): Renderer
    {
        if (!isset(self::$renderers[$id])) {
            throw new InvalidArgumentException('no such renderer: ' . $id);
        }
        return self::$renderers[$id];
    }

    public function setFontRegistry(FontRegistry $fontRegistry): void
    {
        $this->fontRegistry = $fontRegistry;
    }

    public function getFontRegistry(): ?FontRegistry
    {
        return $this->fontRegistry;
    }

    /**
     * Uses the specified renderer to draw an object, as described via the
     * params attribute, and by utilizing the provided node context.
     *
     * The node is required for access to things like the opacity as well as
     * stroke/fill attributes etc.
     *
     * @param string  $rendererId The id of the renderer to use.
     * @param array $params       An array of options to pass to the renderer.
     * @param SVGNode $context    The SVGNode that serves as drawing context.
     *
     * @return void
     *
     * @throws InvalidArgumentException If no such renderer exists.
     */
    public function render(string $rendererId, array $params, SVGNode $context): void
    {
        $renderer = self::getRenderer($rendererId);
        $renderer->render($this, $params, $context);
    }

    /**
     * @return float|null The original SVG document width, in pixels.
     */
    public function getDocumentWidth(): ?float
    {
        return $this->docWidth;
    }

    /**
     * @return float|null The original SVG document height, in pixels.
     */
    public function getDocumentHeight(): ?float
    {
        return $this->docHeight;
    }

    /**
     * Obtain the normalized diagonal of the SVG viewport. The normalized diagonal is to be used as the reference size
     * for percentages that don't strictly refer to the horizontal or vertical axis. Examples of such values are
     * a circle's radius attribute or the stroke-width.
     *
     * This is computed by the formula <code>hypot(documentWidth, documentHeight)/sqrt(2)</code>.
     *
     * @return float The normalized diagonal length.
     */
    public function getNormalizedDiagonal(): float
    {
        // https://svgwg.org/svg2-draft/coords.html#Units

        // For any other length value expressed as a percentage of the SVG viewport, the percentage must be calculated
        // as a percentage of the normalized diagonal of the ‘viewBox’ applied to that viewport. If no ‘viewBox’ is
        // specified, then the normalized diagonal of the SVG viewport must be used. The normalized diagonal length must
        // be calculated with sqrt((width)**2 + (height)**2)/sqrt(2).

        return hypot($this->docWidth ?? 0, $this->docHeight ?? 0) / M_SQRT2;
    }

    /**
     * @return int The output image width, in pixels.
     */
    public function getWidth(): int
    {
        return $this->width;
    }

    /**
     * @return int The output image height, in pixels.
     */
    public function getHeight(): int
    {
        return $this->height;
    }

    /**
     * Determine the normalized diagonal scaling factor. This is the factor that should be used when scaling percentages
     * for properties that are not strictly horizontal or strictly vertical, such as stroke-width.
     *
     * @return float The scaling factor of the view diagonal.
     */
    public function getDiagonalScale(): float
    {
        return $this->diagonalScale;
    }

    /**
     * @return float[]|null The document's viewBox.
     */
    public function getViewBox(): ?array
    {
        return $this->viewBox;
    }

    /**
     * Obtain a Transform object from userspace coordinates into output image coordinates.
     *
     * This will NOT create a copy, so mutating the returned object is unsafe (= might affect later code in unexpected
     * ways). Instead, perform a call to <code>pushTransform()</code> and mutate the return value of that. Then, when
     * done using the changed transform, call <code>popTransform()</code> to revert back to the previous state.
     *
     * @return Transform The created transform.
     */
    public function getCurrentTransform(): Transform
    {
        return $this->transformStack[count($this->transformStack) - 1];
    }

    /**
     * Create a copy of the current transform and push it onto the transform stack, so that it becomes the new
     * current transform. The copied transform is then returned and can be manipulated. When done rendering with this
     * mutated transform, call <code>popTransform()</code> to revert back to the previous transform.
     *
     * @return Transform The copy of the current transform, ready to have operations appended to it.
     */
    public function pushTransform(): Transform
    {
        $nextTransform = clone $this->transformStack[count($this->transformStack) - 1];
        $this->transformStack[] = $nextTransform;
        return $nextTransform;
    }

    /**
     * Revert back to the previous transform, by removing the last transform that was pushed via
     * <code>pushTransform()</code>. There must be a matching call to <code>popTransform</code> for every call to
     * <code>pushTransform()</code>. Popping a transform when no pushed transform remains is an error.
     *
     * @return void
     * @throws RuntimeException If trying to pop a transform but the stack contains only the initial transform.
     */
    public function popTransform(): void
    {
        if (count($this->transformStack) <= 1) {
            throw new RuntimeException('popTransform() called with no transform on the stack!');
        }
        array_pop($this->transformStack);
    }

    /**
     * Applies final processing steps to the output image. It is then returned.
     *
     * @return resource The GD image resource this rasterizer is operating on.
     */
    public function finish()
    {
        return $this->outImage;
    }

    /**
     * @return resource The GD image resource this rasterizer is operating on.
     */
    public function getImage()
    {
        return $this->outImage;
    }
}