Skip to content

Commit 99789f6

Browse files
authored
Merge pull request #23 from LibreSign/feat/native-svg-xobject-hardening
feat: native SVG Form XObject support with transform coverage
2 parents 3ddf914 + ca010b2 commit 99789f6

31 files changed

Lines changed: 6666 additions & 63 deletions

.github/workflows/lint.yml

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
# SPDX-FileCopyrightText: 2026 LibreSign
22
# SPDX-License-Identifier: AGPL-3.0-or-later
33

4-
name: lint
4+
name: Lint php
55

6-
on:
7-
pull_request:
8-
push:
9-
branches: [main]
6+
on: pull_request
7+
8+
permissions:
9+
contents: read
10+
11+
concurrency:
12+
group: lint-php-${{ github.head_ref || github.run_id }}
13+
cancel-in-progress: true
1014

1115
jobs:
12-
lint:
16+
php-lint:
1317
runs-on: ubuntu-latest
18+
name: php-lint
19+
1420
steps:
15-
- uses: actions/checkout@v6
21+
- name: Checkout
22+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
23+
with:
24+
persist-credentials: false
1625
- name: Detect minimum PHP from composer.json
1726
id: php_min
1827
run: |
@@ -22,6 +31,6 @@ jobs:
2231
- uses: shivammathur/setup-php@v2
2332
with:
2433
php-version: ${{ steps.php_min.outputs.version }}
25-
- run: composer install --no-interaction --prefer-dist
26-
- run: composer bin all install --no-interaction --prefer-dist
27-
- run: composer lint
34+
35+
- name: Lint
36+
run: composer run php:lint

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
},
3232
"config": {
3333
"sort-packages": true,
34+
"process-timeout": 600,
3435
"allow-plugins": {
3536
"bamarni/composer-bin-plugin": true,
3637
"infection/extension-installer": true
@@ -44,12 +45,14 @@
4445
},
4546
"scripts": {
4647
"lint": [
48+
"@php:lint",
4749
"@cs:check",
4850
"@rector:check",
4951
"@psalm",
5052
"@duplication:check",
5153
"@composer:validate"
5254
],
55+
"php:lint": "find . -type f -name '*.php' -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
5356
"cs:check": "vendor-bin/phpcs/vendor/squizlabs/php_codesniffer/bin/phpcs -q",
5457
"cs:fix": "vendor-bin/phpcs/vendor/squizlabs/php_codesniffer/bin/phpcbf -q",
5558
"rector:check": "vendor-bin/rector/vendor/rector/rector/bin/rector process --dry-run",

src/Pdf/FilesystemPdfImageEmbedder.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,39 @@
1212
use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactoryInterface;
1313
use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactory;
1414
use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactoryInterface;
15+
use LibreSign\XObjectTemplate\Pdf\Svg\SvgPdfXObjectFactory;
16+
use LibreSign\XObjectTemplate\Pdf\Svg\SvgPdfXObjectFactoryInterface;
1517

1618
final readonly class FilesystemPdfImageEmbedder implements PdfImageEmbedderInterface
1719
{
1820
private FilesystemImageSourceReaderInterface $sourceReader;
1921
private ImageMetadataInspectorInterface $metadataInspector;
2022
private JpegPdfImageFactoryInterface $jpegImageFactory;
2123
private PngPdfImageFactoryInterface $pngImageFactory;
24+
private SvgPdfXObjectFactoryInterface $svgXObjectFactory;
2225

2326
public function __construct(
2427
?FilesystemImageSourceReaderInterface $sourceReader = null,
2528
?ImageMetadataInspectorInterface $metadataInspector = null,
2629
?JpegPdfImageFactoryInterface $jpegImageFactory = null,
2730
?PngPdfImageFactoryInterface $pngImageFactory = null,
31+
?SvgPdfXObjectFactoryInterface $svgXObjectFactory = null,
2832
) {
2933
$this->sourceReader = $sourceReader ?? new FilesystemImageSourceReader();
3034
$this->metadataInspector = $metadataInspector ?? new ImageMetadataInspector();
3135
$this->jpegImageFactory = $jpegImageFactory ?? new JpegPdfImageFactory();
3236
$this->pngImageFactory = $pngImageFactory ?? new PngPdfImageFactory();
37+
$this->svgXObjectFactory = $svgXObjectFactory ?? new SvgPdfXObjectFactory();
3338
}
3439

3540
public function embed(string $source): EmbeddedPdfImage
3641
{
3742
$contents = $this->sourceReader->read($source);
43+
44+
if ($this->isSvgSource($source, $contents)) {
45+
return $this->svgXObjectFactory->create($contents, $source);
46+
}
47+
3848
$imageInfo = $this->metadataInspector->detect($contents, $source);
3949
$mime = $this->metadataInspector->resolveMimeType($imageInfo, $source);
4050

@@ -46,4 +56,16 @@ public function embed(string $source): EmbeddedPdfImage
4656
),
4757
};
4858
}
59+
60+
private function isSvgSource(string $source, string $contents): bool
61+
{
62+
if (preg_match('/\.svgz?$/i', $source) === 1) {
63+
return true;
64+
}
65+
66+
$trimmed = ltrim($contents);
67+
68+
return str_starts_with($trimmed, '<svg')
69+
|| (str_starts_with($trimmed, '<?xml') && str_contains($trimmed, '<svg'));
70+
}
4971
}

src/Pdf/SinglePagePdfExporter.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,10 @@ private function createImageObjects(array &$objects, array $xObjects): array
127127
$imageReferences = [];
128128

129129
foreach ($xObjects as $alias => $resource) {
130-
if (($resource['Subtype'] ?? null) !== '/Image') {
131-
throw new InvalidArgumentException(sprintf('Unsupported XObject subtype for "%s".', $alias));
132-
}
133-
134130
$source = $resource['Source'] ?? null;
135131
if (!is_string($source) || $source === '') {
136132
throw new InvalidArgumentException(
137-
sprintf('Image resource "%s" must expose a non-empty Source.', $alias),
133+
sprintf('XObject resource "%s" must expose a non-empty Source.', $alias),
138134
);
139135
}
140136

src/Pdf/Svg/ArcParams.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// SPDX-FileCopyrightText: 2026 LibreSign
6+
// SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
namespace LibreSign\XObjectTemplate\Pdf\Svg;
9+
10+
/**
11+
* Internal value object grouping the common arc parameters.
12+
*
13+
* @internal
14+
*/
15+
final readonly class ArcParams
16+
{
17+
/**
18+
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
19+
*/
20+
public function __construct(
21+
public float $fromX,
22+
public float $fromY,
23+
public float $toX,
24+
public float $toY,
25+
public float $radiusX,
26+
public float $radiusY,
27+
public float $cosTh,
28+
public float $sinTh,
29+
public int $largeArc,
30+
public int $sweep,
31+
) {
32+
}
33+
34+
public function withRadii(float $radiusX, float $radiusY): self
35+
{
36+
return new self(
37+
$this->fromX,
38+
$this->fromY,
39+
$this->toX,
40+
$this->toY,
41+
$radiusX,
42+
$radiusY,
43+
$this->cosTh,
44+
$this->sinTh,
45+
$this->largeArc,
46+
$this->sweep,
47+
);
48+
}
49+
}

src/Pdf/Svg/PathCommandContext.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreSign
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreSign\XObjectTemplate\Pdf\Svg;
9+
10+
/**
11+
* Context for path command processing.
12+
* Encapsulates transform and coordinate parameters.
13+
*
14+
* @internal
15+
*/
16+
final readonly class PathCommandContext
17+
{
18+
/**
19+
* @param array{0:float,1:float,2:float,3:float,4:float,5:float} $transformMatrix
20+
*/
21+
public function __construct(
22+
public array $transformMatrix,
23+
public float $minX,
24+
public float $maxY,
25+
public string $source,
26+
) {
27+
}
28+
}

src/Pdf/Svg/PathParsingState.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreSign
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreSign\XObjectTemplate\Pdf\Svg;
9+
10+
/**
11+
* Mutable state container for SVG path parsing.
12+
* Tracks current position, control points, and accumulated commands.
13+
*
14+
* @internal
15+
*/
16+
final class PathParsingState
17+
{
18+
public function __construct(
19+
public float $currentX = 0.0,
20+
public float $currentY = 0.0,
21+
public float $subpathStartX = 0.0,
22+
public float $subpathStartY = 0.0,
23+
public ?float $lastCubicControlX = null,
24+
public ?float $lastCubicControlY = null,
25+
public ?float $prevQuadCpX = null,
26+
public ?float $prevQuadCpY = null,
27+
/** @var list<string> */
28+
public array $commands = [],
29+
) {
30+
}
31+
}

src/Pdf/Svg/SvgArcConverter.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// SPDX-FileCopyrightText: 2026 LibreSign
6+
// SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
namespace LibreSign\XObjectTemplate\Pdf\Svg;
9+
10+
use LibreSign\XObjectTemplate\Pdf\Svg\SvgArcMath;
11+
12+
/**
13+
* Converts SVG arc commands to cubic Bézier curve approximations.
14+
*
15+
* This class encapsulates the mathematical transformation of SVG arc path
16+
* commands (A/a) into a series of cubic Bézier curves, which are directly
17+
* supported by the PDF specification.
18+
*
19+
* The algorithm implements the SVG 2 specification's arc-to-Bézier conversion,
20+
* decomposing the arc into multiple segments for accurate curve approximation.
21+
*/
22+
final readonly class SvgArcConverter
23+
{
24+
public function __construct(
25+
private SvgArcMath $math = new SvgArcMath(),
26+
) {
27+
}
28+
29+
/**
30+
* Convert SVG arc command parameters to cubic Bézier curves.
31+
*
32+
* This method takes the arc parameters as specified in SVG and converts
33+
* them to an array of cubic Bézier curve control points that approximate
34+
* the arc within the PDF coordinate space.
35+
*
36+
* @param float $fromX Starting X coordinate
37+
* @param float $fromY Starting Y coordinate
38+
* @param float $rx X-axis radius
39+
* @param float $ry Y-axis radius
40+
* @param float $rotation Rotation angle in degrees
41+
* @param int $largeArc Large arc flag (0 or 1)
42+
* @param int $sweep Sweep flag (0 or 1)
43+
* @param float $toX Ending X coordinate
44+
* @param float $toY Ending Y coordinate
45+
* @return array<int, array<int, float>> Array of cubic Bézier control points
46+
*/
47+
public function arcToBezierCurves(
48+
float $fromX,
49+
float $fromY,
50+
float $radiusX,
51+
float $radiusY,
52+
float $rotation,
53+
int $largeArc,
54+
int $sweep,
55+
float $toX,
56+
float $toY,
57+
): array {
58+
if (abs($toX - $fromX) < 1e-10 && abs($toY - $fromY) < 1e-10) {
59+
return [];
60+
}
61+
62+
if ($radiusX < 1e-10 || $radiusY < 1e-10) {
63+
return [[$toX, $toY, $toX, $toY, $toX, $toY]];
64+
}
65+
66+
$theta = deg2rad($rotation);
67+
$params = new ArcParams(
68+
$fromX,
69+
$fromY,
70+
$toX,
71+
$toY,
72+
$radiusX,
73+
$radiusY,
74+
cos($theta),
75+
sin($theta),
76+
$largeArc,
77+
$sweep,
78+
);
79+
80+
$params = $this->math->normalizeArcRadii($params);
81+
82+
[$centerX, $centerY] = $this->math->calculateArcCenter($params);
83+
84+
[$startAngle, $deltaAngle] = $this->math->calculateArcAngles($params);
85+
86+
return $this->math->generateArcCurves(
87+
$params,
88+
$centerX,
89+
$centerY,
90+
$startAngle,
91+
$deltaAngle,
92+
);
93+
}
94+
}

0 commit comments

Comments
 (0)