Skip to content

Commit 7d799b3

Browse files
authored
Merge pull request #15 from LibreSign/feat/phase1-visible-stamp-layouts
feat: add phase 1 visible stamp layouts
2 parents 6e624b5 + 06a5628 commit 7d799b3

21 files changed

Lines changed: 2292 additions & 114 deletions

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,41 @@ file_put_contents(__DIR__ . '/build/preview.pdf', $pdf);
5555
- `$payload`: transport-agnostic array with `stream`, `resources`, and `bbox`
5656
- `$pdf`: standalone PDF bytes ready to save, stream, or attach to preview workflows
5757

58+
### Scaling a compiled XObject
59+
60+
`CompileRequest::width` and `CompileRequest::height` define the base design size of the template.
61+
If a downstream consumer needs to place the compiled stamp at a different size while preserving the
62+
original aspect ratio, it should keep the compiled XObject unchanged and apply a uniform scale during
63+
PDF placement instead of recompiling the HTML with new dimensions.
64+
65+
- Read the base size from `$result->bbox`
66+
- Compute a single scale factor from the target width or target height
67+
- Apply the same scale to both axes in the placement matrix
68+
69+
```php
70+
[$minX, $minY, $maxX, $maxY] = $result->bbox;
71+
72+
$baseWidth = $maxX - $minX;
73+
$baseHeight = $maxY - $minY;
74+
75+
$targetWidth = 175.0;
76+
$scale = $targetWidth / $baseWidth;
77+
$targetHeight = $baseHeight * $scale;
78+
79+
// Consumer-side PDF placement concept:
80+
$placement = sprintf(
81+
'q %F 0 0 %F %F %F cm /Fm0 Do Q',
82+
$scale,
83+
$scale,
84+
$x,
85+
$y,
86+
);
87+
```
88+
89+
Using a uniform placement scale keeps text, images, spacing, and line breaks visually aligned.
90+
Recompiling only to emulate a proportional resize is usually the wrong integration point for this
91+
package.
92+
5893
## Supported HTML/CSS subset
5994

6095
### HTML
@@ -68,13 +103,17 @@ file_put_contents(__DIR__ . '/build/preview.pdf', $pdf);
68103

69104
- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`
70105
- Layout: `margin`, `padding`, `text-align`, `width`, `height`
71-
- Numeric values can be provided as unitless numbers or `px`
106+
- Structured layout: `display:flex`, `flex-direction`, `justify-content`, `align-items`, `gap`
107+
- Absolute placement: `position:absolute`, `top`, `right`, `bottom`, `left`
108+
- Numeric values can be provided as unitless numbers or `px`; `width`, `height`, and positional offsets also accept `%`
72109
- `px` values are converted to PDF points using the package conversion rules
73110
- Unknown or incomplete CSS declarations are ignored instead of aborting the render
74111

75112
### Rendering notes
76113

77114
- Font family mapping currently targets the built-in Helvetica, Times, and Courier aliases used by the generated PDF resources
115+
- Percentage-based sizing and offsets resolve relative to the current layout container
116+
- 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
78117
- `img` width/height fall back to `32x32` when omitted or invalid
79118
- Image and text placement are clamped to the requested output box
80119
- The compiler output is not tied to any single downstream package; any consumer that understands Form XObject stream/resources/bbox data can use it

infection.json5

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@
1515
"minMsi": 75,
1616
"minCoveredMsi": 82,
1717
"testFramework": "phpunit",
18-
"timeout": 10
18+
"timeout": 120
1919
}

src/Html/SubsetHtmlParser.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ final class SubsetHtmlParser
1616
{
1717
private const HTML_WRAPPER = '<?xml encoding="utf-8" ?><body>%s</body>';
1818
private const LIBXML_HTML_PARSE_FLAGS = 96; // LIBXML_NOERROR | LIBXML_NOWARNING
19+
private const INHERITABLE_STYLE_PROPERTIES = [
20+
'color' => true,
21+
'font-family' => true,
22+
'font-size' => true,
23+
'font-weight' => true,
24+
'line-height' => true,
25+
'text-align' => true,
26+
];
1927

2028
/** @var array<string, true> */
2129
private array $allowedTags = [
@@ -130,6 +138,8 @@ private function collectAttributes(DOMElement $node): array
130138

131139
private function mergeStyle(string $inheritedStyle, string $ownStyle): string
132140
{
141+
$inheritedStyle = $this->filterInheritableStyle($inheritedStyle);
142+
133143
if ($inheritedStyle === '') {
134144
return $ownStyle;
135145
}
@@ -140,4 +150,35 @@ private function mergeStyle(string $inheritedStyle, string $ownStyle): string
140150

141151
return $inheritedStyle . ';' . $ownStyle;
142152
}
153+
154+
private function filterInheritableStyle(string $style): string
155+
{
156+
if ($style === '') {
157+
return '';
158+
}
159+
160+
$resolvedDeclarations = [];
161+
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+
}
172+
173+
$property = strtolower(trim($segments[0]));
174+
$value = trim($segments[1]);
175+
if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) {
176+
continue;
177+
}
178+
179+
$resolvedDeclarations[$property] = $property . ':' . $value;
180+
}
181+
182+
return implode(';', array_values($resolvedDeclarations));
183+
}
143184
}

src/Layout/LayoutStyleResolver.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
use LibreSign\XObjectTemplate\Css\StyleMap;
11+
12+
final readonly class LayoutStyleResolver
13+
{
14+
public function styleValue(StyleMap $style, string $property, string $default): string
15+
{
16+
return $style->get($property, $default) ?? $default;
17+
}
18+
19+
public function toPoints(string $value): float
20+
{
21+
$normalized = strtolower($value);
22+
$number = (float) preg_replace('/[^0-9.\-]/', '', $normalized);
23+
if (str_ends_with($normalized, 'px')) {
24+
return $number * 0.75;
25+
}
26+
27+
return $number;
28+
}
29+
30+
public function resolveRelativeDimension(string $value, float $reference): float
31+
{
32+
$normalized = strtolower(trim($value));
33+
if ($normalized === '') {
34+
return 0.0;
35+
}
36+
37+
if (str_ends_with($normalized, '%')) {
38+
$number = (float) preg_replace('/[^0-9.\-]/', '', $normalized);
39+
40+
return $reference * ($number / 100.0);
41+
}
42+
43+
return $this->toPoints($normalized);
44+
}
45+
46+
public function resolveLineHeight(StyleMap $style, float $fontSize): float
47+
{
48+
$defaultLineHeight = $fontSize * 1.2;
49+
$configuredLineHeight = $this->styleValue($style, 'line-height', '');
50+
51+
if ($configuredLineHeight === '') {
52+
return $defaultLineHeight;
53+
}
54+
55+
return max($defaultLineHeight, $this->toPoints($configuredLineHeight));
56+
}
57+
58+
/**
59+
* @return array{top: float, right: float, bottom: float, left: float}
60+
*/
61+
public function parseBoxSpacing(string $value): array
62+
{
63+
preg_match_all('/\S+/', $value, $matches);
64+
$tokens = $matches[0];
65+
66+
if ($tokens === []) {
67+
return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
68+
}
69+
70+
$points = array_map(fn (string $token): float => $this->toPoints($token), $tokens);
71+
$count = count($points);
72+
73+
return match ($count) {
74+
1 => ['top' => $points[0], 'right' => $points[0], 'bottom' => $points[0], 'left' => $points[0]],
75+
2 => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[0], 'left' => $points[1]],
76+
3 => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[1]],
77+
default => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[3]],
78+
};
79+
}
80+
81+
/**
82+
* @return array{top: float, right: float, bottom: float, left: float}
83+
*/
84+
public function parseBoxSpacingRelative(string $value, float $widthReference, float $heightReference): array
85+
{
86+
preg_match_all('/\S+/', $value, $matches);
87+
$tokens = $matches[0];
88+
89+
if ($tokens === []) {
90+
return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
91+
}
92+
93+
[$top, $right, $bottom, $left] = $this->expandSpacingTokens($tokens);
94+
95+
return [
96+
'top' => $this->resolveRelativeDimension($top, $heightReference),
97+
'right' => $this->resolveRelativeDimension($right, $widthReference),
98+
'bottom' => $this->resolveRelativeDimension($bottom, $heightReference),
99+
'left' => $this->resolveRelativeDimension($left, $widthReference),
100+
];
101+
}
102+
103+
public function resolveFontAlias(string $fontFamily, string $fontWeight): string
104+
{
105+
$primary = strtolower(explode(',', $fontFamily)[0]);
106+
$isBold = $this->isBoldWeight($fontWeight);
107+
108+
if (str_contains($primary, 'times')) {
109+
return $isBold ? 'F4' : 'F3';
110+
}
111+
112+
if (str_contains($primary, 'courier')) {
113+
return $isBold ? 'F6' : 'F5';
114+
}
115+
116+
return $isBold ? 'F2' : 'F1';
117+
}
118+
119+
public function isAbsolutelyPositioned(StyleMap $style): bool
120+
{
121+
return strtolower(trim($this->styleValue($style, 'position', ''))) === 'absolute';
122+
}
123+
124+
/**
125+
* @param list<string> $tokens
126+
* @return array{0: string, 1: string, 2: string, 3: string}
127+
*/
128+
private function expandSpacingTokens(array $tokens): array
129+
{
130+
return match (count($tokens)) {
131+
1 => [$tokens[0], $tokens[0], $tokens[0], $tokens[0]],
132+
2 => [$tokens[0], $tokens[1], $tokens[0], $tokens[1]],
133+
3 => [$tokens[0], $tokens[1], $tokens[2], $tokens[1]],
134+
default => [$tokens[0], $tokens[1], $tokens[2], $tokens[3]],
135+
};
136+
}
137+
138+
private function isBoldWeight(string $fontWeight): bool
139+
{
140+
$normalized = strtolower($fontWeight);
141+
if ($normalized === 'bold' || $normalized === 'bolder') {
142+
return true;
143+
}
144+
145+
if (is_numeric($normalized)) {
146+
return $normalized >= 600;
147+
}
148+
149+
return false;
150+
}
151+
}

0 commit comments

Comments
 (0)