Skip to content

Commit f59690f

Browse files
authored
Merge feat/advanced-layout-capabilities for infection fixes
2 parents 89d297e + 1058144 commit f59690f

36 files changed

Lines changed: 3889 additions & 141 deletions

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,28 @@ Using a uniform placement scale keeps text, images, spacing, and line breaks vis
9090
Recompiling only to emulate a proportional resize is usually the wrong integration point for this
9191
package.
9292

93+
For consumers that want a small helper instead of recalculating the matrix manually, the package also
94+
ships `LibreSign\XObjectTemplate\Integration\XObjectPlacementCalculator` and
95+
`LibreSign\XObjectTemplate\Integration\XObjectPlacement`.
96+
97+
```php
98+
use LibreSign\XObjectTemplate\Integration\XObjectPlacementCalculator;
99+
100+
$placement = (new XObjectPlacementCalculator())->fromWidth($result, 175.0, 36.0, 72.0);
101+
102+
$pdfCommand = $placement->toPdfCommand('Fm0');
103+
// q 0.729167 0 0 0.729167 36.000000 72.000000 cm /Fm0 Do Q
104+
```
105+
106+
### Optional context interpolation
107+
108+
If the caller passes `CompileRequest::context`, the compiler can interpolate simple `{{ key }}`
109+
placeholders before parsing the HTML subset.
110+
111+
- Values are HTML-escaped before insertion
112+
- Unknown placeholders are left untouched
113+
- Twig users can keep rendering HTML upstream and skip this feature entirely
114+
93115
## Supported HTML/CSS subset
94116

95117
### HTML
@@ -101,8 +123,9 @@ package.
101123

102124
### CSS used by the renderer
103125

104-
- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`
105-
- Layout: `margin`, `padding`, `text-align`, `width`, `height`
126+
- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`, `text-align`, `hyphens`, `white-space`
127+
- Layout: `margin`, `padding`, `width`, `height`, `overflow`, `text-overflow`
128+
- Vector box styling: `background-color`, `border-color`, `border-width`, `border-radius`
106129
- Structured layout: `display:flex`, `flex-direction`, `justify-content`, `align-items`, `gap`
107130
- Absolute placement: `position:absolute`, `top`, `right`, `bottom`, `left`
108131
- Numeric values can be provided as unitless numbers or `px`; `width`, `height`, and positional offsets also accept `%`
@@ -112,6 +135,10 @@ package.
112135
### Rendering notes
113136

114137
- Font family mapping currently targets the built-in Helvetica, Times, and Courier aliases used by the generated PDF resources
138+
- Text alignment uses measured widths for left, center, right, and basic justified output (`Tw` word spacing)
139+
- Hyphenation supports a small deterministic subset: `hyphens:auto`, `hyphens:manual` with soft hyphens, and `hyphens:none`
140+
- Overflow clipping uses PDF clipping paths; `text-overflow:ellipsis` applies when hidden overflow truncates visible text
141+
- Backgrounds and borders are emitted as vector rectangles, including rounded corners
115142
- Percentage-based sizing and offsets resolve relative to the current layout container
116143
- Flex layouts are intentionally small-scope and predictable: the engine supports deterministic row/column compositions for stamps, labels, and approval blocks rather than full browser-grade CSS
117144
- `img` width/height fall back to `32x32` when omitted or invalid
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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\Html;
9+
10+
final readonly class HtmlContextInterpolator
11+
{
12+
/**
13+
* @param array<string, scalar> $context
14+
*/
15+
public function interpolate(string $html, array $context): string
16+
{
17+
if ($context === []) {
18+
return $html;
19+
}
20+
21+
if (!str_contains($html, '{{')) {
22+
return $html;
23+
}
24+
25+
$interpolated = preg_replace_callback(
26+
'/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/',
27+
function (array $matches) use ($context): string {
28+
$key = $matches[1] ?? '';
29+
if ($key === '' || !array_key_exists($key, $context)) {
30+
return $matches[0];
31+
}
32+
33+
return htmlspecialchars(
34+
$this->normalizeScalar($context[$key]),
35+
ENT_QUOTES | ENT_SUBSTITUTE,
36+
'UTF-8',
37+
);
38+
},
39+
$html,
40+
);
41+
42+
return $interpolated ?? $html;
43+
}
44+
45+
private function normalizeScalar(string|int|float|bool $value): string
46+
{
47+
if (is_bool($value)) {
48+
return $value ? 'true' : 'false';
49+
}
50+
51+
return strval($value);
52+
}
53+
}

src/Html/SubsetHtmlParser.php

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ final class SubsetHtmlParser
2121
'font-family' => true,
2222
'font-size' => true,
2323
'font-weight' => true,
24+
'hyphens' => true,
2425
'line-height' => true,
2526
'text-align' => true,
27+
'white-space' => true,
2628
];
2729

2830
/** @var array<string, true> */
@@ -158,27 +160,23 @@ private function filterInheritableStyle(string $style): string
158160
}
159161

160162
$resolvedDeclarations = [];
163+
$matches = [];
161164

162-
foreach (explode(';', $style) as $declaration) {
163-
$trimmedDeclaration = trim($declaration);
164-
if ($trimmedDeclaration === '') {
165-
continue;
166-
}
167-
168-
$segments = explode(':', $trimmedDeclaration, 2);
169-
if (count($segments) !== 2) {
170-
continue;
171-
}
165+
preg_match_all('/(?:^|;)\s*([A-Za-z-]+)\s*:\s*([^;]+)\s*/', $style, $matches, PREG_SET_ORDER);
166+
if ($matches === []) {
167+
return '';
168+
}
172169

173-
$property = strtolower(trim($segments[0]));
174-
$value = trim($segments[1]);
170+
foreach ($matches as $match) {
171+
$property = strtolower($match[1]);
172+
$value = trim($match[2]);
175173
if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) {
176174
continue;
177175
}
178176

179177
$resolvedDeclarations[$property] = $property . ':' . $value;
180178
}
181179

182-
return implode(';', array_values($resolvedDeclarations));
180+
return implode(';', $resolvedDeclarations);
183181
}
184182
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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\Integration;
9+
10+
use InvalidArgumentException;
11+
12+
final readonly class XObjectPlacement
13+
{
14+
public function __construct(
15+
public float $scaleX,
16+
public float $scaleY,
17+
public float $width,
18+
public float $height,
19+
public float $translateX,
20+
public float $translateY,
21+
) {
22+
}
23+
24+
public function toPdfCommand(string $alias): string
25+
{
26+
$normalizedAlias = ltrim(trim($alias), '/');
27+
if ($normalizedAlias === '') {
28+
throw new InvalidArgumentException('Placement alias must not be empty.');
29+
}
30+
31+
return sprintf(
32+
'q %s 0 0 %s %s %s cm /%s Do Q',
33+
$this->formatNumber($this->scaleX),
34+
$this->formatNumber($this->scaleY),
35+
$this->formatNumber($this->translateX),
36+
$this->formatNumber($this->translateY),
37+
$normalizedAlias,
38+
);
39+
}
40+
41+
private function formatNumber(float $value): string
42+
{
43+
$formatted = rtrim(rtrim(sprintf('%.6F', $value), '0'), '.');
44+
if ($formatted === '' || $formatted === '-0') {
45+
return '0';
46+
}
47+
48+
return $formatted;
49+
}
50+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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\Integration;
9+
10+
use InvalidArgumentException;
11+
use LibreSign\XObjectTemplate\Dto\CompileResult;
12+
13+
final readonly class XObjectPlacementCalculator
14+
{
15+
public function fromWidth(
16+
CompileResult $result,
17+
float $targetWidth,
18+
float $x = 0.0,
19+
float $y = 0.0,
20+
): XObjectPlacement {
21+
if ($targetWidth <= 0.0) {
22+
throw new InvalidArgumentException('Placement target width must be greater than zero.');
23+
}
24+
25+
[$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result);
26+
$scale = $targetWidth / $baseWidth;
27+
28+
return new XObjectPlacement(
29+
scaleX: $scale,
30+
scaleY: $scale,
31+
width: $baseWidth * $scale,
32+
height: $baseHeight * $scale,
33+
translateX: $x - ($minX * $scale),
34+
translateY: $y - ($minY * $scale),
35+
);
36+
}
37+
38+
public function fromHeight(
39+
CompileResult $result,
40+
float $targetHeight,
41+
float $x = 0.0,
42+
float $y = 0.0,
43+
): XObjectPlacement {
44+
if ($targetHeight <= 0.0) {
45+
throw new InvalidArgumentException('Placement target height must be greater than zero.');
46+
}
47+
48+
[$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result);
49+
$scale = $targetHeight / $baseHeight;
50+
51+
return new XObjectPlacement(
52+
scaleX: $scale,
53+
scaleY: $scale,
54+
width: $baseWidth * $scale,
55+
height: $baseHeight * $scale,
56+
translateX: $x - ($minX * $scale),
57+
translateY: $y - ($minY * $scale),
58+
);
59+
}
60+
61+
public function fromScale(
62+
CompileResult $result,
63+
float $scale,
64+
float $x = 0.0,
65+
float $y = 0.0,
66+
): XObjectPlacement {
67+
if ($scale <= 0.0) {
68+
throw new InvalidArgumentException('Placement scale must be greater than zero.');
69+
}
70+
71+
[$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result);
72+
73+
return new XObjectPlacement(
74+
scaleX: $scale,
75+
scaleY: $scale,
76+
width: $baseWidth * $scale,
77+
height: $baseHeight * $scale,
78+
translateX: $x - ($minX * $scale),
79+
translateY: $y - ($minY * $scale),
80+
);
81+
}
82+
83+
/**
84+
* @return array{0: float, 1: float, 2: float, 3: float}
85+
*/
86+
private function resolveBoundingBox(CompileResult $result): array
87+
{
88+
[$minX, $minY, $maxX, $maxY] = $result->bbox;
89+
$width = $maxX - $minX;
90+
$height = $maxY - $minY;
91+
92+
if ($width <= 0.0 || $height <= 0.0) {
93+
throw new InvalidArgumentException('CompileResult bbox must describe a positive area.');
94+
}
95+
96+
return [$minX, $minY, $width, $height];
97+
}
98+
}

src/Layout/LayoutDecoration.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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\Layout;
9+
10+
final readonly class LayoutDecoration
11+
{
12+
public function __construct(
13+
public float $x,
14+
public float $y,
15+
public float $width,
16+
public float $height,
17+
public ?string $fillColor = null,
18+
public ?string $strokeColor = null,
19+
public float $strokeWidth = 0.0,
20+
public float $borderRadius = 0.0,
21+
) {
22+
}
23+
}

src/Layout/LayoutImage.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@
99

1010
final readonly class LayoutImage
1111
{
12+
/**
13+
* @param array{x: float, y: float, width: float, height: float}|null $clipBox
14+
*/
1215
public function __construct(
1316
public string $alias,
1417
public float $x,
1518
public float $y,
1619
public float $width,
1720
public float $height,
1821
public string $source,
22+
public ?array $clipBox = null,
1923
) {
2024
}
2125
}

src/Layout/LayoutLine.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@
99

1010
final readonly class LayoutLine
1111
{
12+
/**
13+
* @param array{x: float, y: float, width: float, height: float}|null $clipBox
14+
*/
1215
public function __construct(
1316
public string $text,
1417
public float $x,
1518
public float $y,
1619
public float $fontSize,
1720
public string $fontAlias,
1821
public string $rgbColor,
22+
public float $wordSpacing = 0.0,
23+
public ?array $clipBox = null,
1924
) {
2025
}
2126
}

src/Layout/LayoutResult.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
/**
1313
* @param list<LayoutLine> $lines
1414
* @param list<LayoutImage> $images
15+
* @param list<LayoutDecoration> $decorations
1516
*/
1617
public function __construct(
1718
public array $lines,
1819
public array $images,
20+
public array $decorations = [],
1921
) {
2022
}
2123
}

0 commit comments

Comments
 (0)