diff --git a/README.md b/README.md
index bd3ae25..7d2ec64 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,41 @@ file_put_contents(__DIR__ . '/build/preview.pdf', $pdf);
- `$payload`: transport-agnostic array with `stream`, `resources`, and `bbox`
- `$pdf`: standalone PDF bytes ready to save, stream, or attach to preview workflows
+### Scaling a compiled XObject
+
+`CompileRequest::width` and `CompileRequest::height` define the base design size of the template.
+If a downstream consumer needs to place the compiled stamp at a different size while preserving the
+original aspect ratio, it should keep the compiled XObject unchanged and apply a uniform scale during
+PDF placement instead of recompiling the HTML with new dimensions.
+
+- Read the base size from `$result->bbox`
+- Compute a single scale factor from the target width or target height
+- Apply the same scale to both axes in the placement matrix
+
+```php
+[$minX, $minY, $maxX, $maxY] = $result->bbox;
+
+$baseWidth = $maxX - $minX;
+$baseHeight = $maxY - $minY;
+
+$targetWidth = 175.0;
+$scale = $targetWidth / $baseWidth;
+$targetHeight = $baseHeight * $scale;
+
+// Consumer-side PDF placement concept:
+$placement = sprintf(
+ 'q %F 0 0 %F %F %F cm /Fm0 Do Q',
+ $scale,
+ $scale,
+ $x,
+ $y,
+);
+```
+
+Using a uniform placement scale keeps text, images, spacing, and line breaks visually aligned.
+Recompiling only to emulate a proportional resize is usually the wrong integration point for this
+package.
+
## Supported HTML/CSS subset
### HTML
@@ -68,13 +103,17 @@ file_put_contents(__DIR__ . '/build/preview.pdf', $pdf);
- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`
- Layout: `margin`, `padding`, `text-align`, `width`, `height`
-- Numeric values can be provided as unitless numbers or `px`
+- Structured layout: `display:flex`, `flex-direction`, `justify-content`, `align-items`, `gap`
+- Absolute placement: `position:absolute`, `top`, `right`, `bottom`, `left`
+- Numeric values can be provided as unitless numbers or `px`; `width`, `height`, and positional offsets also accept `%`
- `px` values are converted to PDF points using the package conversion rules
- Unknown or incomplete CSS declarations are ignored instead of aborting the render
### Rendering notes
- Font family mapping currently targets the built-in Helvetica, Times, and Courier aliases used by the generated PDF resources
+- Percentage-based sizing and offsets resolve relative to the current layout container
+- 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
- `img` width/height fall back to `32x32` when omitted or invalid
- Image and text placement are clamped to the requested output box
- The compiler output is not tied to any single downstream package; any consumer that understands Form XObject stream/resources/bbox data can use it
diff --git a/infection.json5 b/infection.json5
index 92b1ab0..4a66b6a 100644
--- a/infection.json5
+++ b/infection.json5
@@ -15,5 +15,5 @@
"minMsi": 75,
"minCoveredMsi": 82,
"testFramework": "phpunit",
- "timeout": 10
+ "timeout": 120
}
diff --git a/src/Html/SubsetHtmlParser.php b/src/Html/SubsetHtmlParser.php
index 8dd58ac..dbfb4f7 100644
--- a/src/Html/SubsetHtmlParser.php
+++ b/src/Html/SubsetHtmlParser.php
@@ -16,6 +16,14 @@ final class SubsetHtmlParser
{
private const HTML_WRAPPER = '
%s';
private const LIBXML_HTML_PARSE_FLAGS = 96; // LIBXML_NOERROR | LIBXML_NOWARNING
+ private const INHERITABLE_STYLE_PROPERTIES = [
+ 'color' => true,
+ 'font-family' => true,
+ 'font-size' => true,
+ 'font-weight' => true,
+ 'line-height' => true,
+ 'text-align' => true,
+ ];
/** @var array */
private array $allowedTags = [
@@ -130,6 +138,8 @@ private function collectAttributes(DOMElement $node): array
private function mergeStyle(string $inheritedStyle, string $ownStyle): string
{
+ $inheritedStyle = $this->filterInheritableStyle($inheritedStyle);
+
if ($inheritedStyle === '') {
return $ownStyle;
}
@@ -140,4 +150,35 @@ private function mergeStyle(string $inheritedStyle, string $ownStyle): string
return $inheritedStyle . ';' . $ownStyle;
}
+
+ private function filterInheritableStyle(string $style): string
+ {
+ if ($style === '') {
+ return '';
+ }
+
+ $resolvedDeclarations = [];
+
+ foreach (explode(';', $style) as $declaration) {
+ $trimmedDeclaration = trim($declaration);
+ if ($trimmedDeclaration === '') {
+ continue;
+ }
+
+ $segments = explode(':', $trimmedDeclaration, 2);
+ if (count($segments) !== 2) {
+ continue;
+ }
+
+ $property = strtolower(trim($segments[0]));
+ $value = trim($segments[1]);
+ if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) {
+ continue;
+ }
+
+ $resolvedDeclarations[$property] = $property . ':' . $value;
+ }
+
+ return implode(';', array_values($resolvedDeclarations));
+ }
}
diff --git a/src/Layout/LayoutStyleResolver.php b/src/Layout/LayoutStyleResolver.php
new file mode 100644
index 0000000..55b6be4
--- /dev/null
+++ b/src/Layout/LayoutStyleResolver.php
@@ -0,0 +1,151 @@
+get($property, $default) ?? $default;
+ }
+
+ public function toPoints(string $value): float
+ {
+ $normalized = strtolower($value);
+ $number = (float) preg_replace('/[^0-9.\-]/', '', $normalized);
+ if (str_ends_with($normalized, 'px')) {
+ return $number * 0.75;
+ }
+
+ return $number;
+ }
+
+ public function resolveRelativeDimension(string $value, float $reference): float
+ {
+ $normalized = strtolower(trim($value));
+ if ($normalized === '') {
+ return 0.0;
+ }
+
+ if (str_ends_with($normalized, '%')) {
+ $number = (float) preg_replace('/[^0-9.\-]/', '', $normalized);
+
+ return $reference * ($number / 100.0);
+ }
+
+ return $this->toPoints($normalized);
+ }
+
+ public function resolveLineHeight(StyleMap $style, float $fontSize): float
+ {
+ $defaultLineHeight = $fontSize * 1.2;
+ $configuredLineHeight = $this->styleValue($style, 'line-height', '');
+
+ if ($configuredLineHeight === '') {
+ return $defaultLineHeight;
+ }
+
+ return max($defaultLineHeight, $this->toPoints($configuredLineHeight));
+ }
+
+ /**
+ * @return array{top: float, right: float, bottom: float, left: float}
+ */
+ public function parseBoxSpacing(string $value): array
+ {
+ preg_match_all('/\S+/', $value, $matches);
+ $tokens = $matches[0];
+
+ if ($tokens === []) {
+ return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
+ }
+
+ $points = array_map(fn (string $token): float => $this->toPoints($token), $tokens);
+ $count = count($points);
+
+ return match ($count) {
+ 1 => ['top' => $points[0], 'right' => $points[0], 'bottom' => $points[0], 'left' => $points[0]],
+ 2 => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[0], 'left' => $points[1]],
+ 3 => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[1]],
+ default => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[3]],
+ };
+ }
+
+ /**
+ * @return array{top: float, right: float, bottom: float, left: float}
+ */
+ public function parseBoxSpacingRelative(string $value, float $widthReference, float $heightReference): array
+ {
+ preg_match_all('/\S+/', $value, $matches);
+ $tokens = $matches[0];
+
+ if ($tokens === []) {
+ return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
+ }
+
+ [$top, $right, $bottom, $left] = $this->expandSpacingTokens($tokens);
+
+ return [
+ 'top' => $this->resolveRelativeDimension($top, $heightReference),
+ 'right' => $this->resolveRelativeDimension($right, $widthReference),
+ 'bottom' => $this->resolveRelativeDimension($bottom, $heightReference),
+ 'left' => $this->resolveRelativeDimension($left, $widthReference),
+ ];
+ }
+
+ public function resolveFontAlias(string $fontFamily, string $fontWeight): string
+ {
+ $primary = strtolower(explode(',', $fontFamily)[0]);
+ $isBold = $this->isBoldWeight($fontWeight);
+
+ if (str_contains($primary, 'times')) {
+ return $isBold ? 'F4' : 'F3';
+ }
+
+ if (str_contains($primary, 'courier')) {
+ return $isBold ? 'F6' : 'F5';
+ }
+
+ return $isBold ? 'F2' : 'F1';
+ }
+
+ public function isAbsolutelyPositioned(StyleMap $style): bool
+ {
+ return strtolower(trim($this->styleValue($style, 'position', ''))) === 'absolute';
+ }
+
+ /**
+ * @param list $tokens
+ * @return array{0: string, 1: string, 2: string, 3: string}
+ */
+ private function expandSpacingTokens(array $tokens): array
+ {
+ return match (count($tokens)) {
+ 1 => [$tokens[0], $tokens[0], $tokens[0], $tokens[0]],
+ 2 => [$tokens[0], $tokens[1], $tokens[0], $tokens[1]],
+ 3 => [$tokens[0], $tokens[1], $tokens[2], $tokens[1]],
+ default => [$tokens[0], $tokens[1], $tokens[2], $tokens[3]],
+ };
+ }
+
+ private function isBoldWeight(string $fontWeight): bool
+ {
+ $normalized = strtolower($fontWeight);
+ if ($normalized === 'bold' || $normalized === 'bolder') {
+ return true;
+ }
+
+ if (is_numeric($normalized)) {
+ return $normalized >= 600;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Layout/LinearLayoutEngine.php b/src/Layout/LinearLayoutEngine.php
index 277188a..67a3b9e 100644
--- a/src/Layout/LinearLayoutEngine.php
+++ b/src/Layout/LinearLayoutEngine.php
@@ -8,21 +8,38 @@
namespace LibreSign\XObjectTemplate\Layout;
use LibreSign\XObjectTemplate\Css\InlineStyleParser;
+use LibreSign\XObjectTemplate\Css\StyleMap;
use LibreSign\XObjectTemplate\Html\Node;
final readonly class LinearLayoutEngine
{
private InlineStyleParser $styleParser;
+ private LayoutStyleResolver $styleResolver;
+ private StructuredLayoutRenderer $structuredRenderer;
public function __construct(?InlineStyleParser $styleParser = null)
{
$this->styleParser = $styleParser ?? new InlineStyleParser();
+ $this->styleResolver = new LayoutStyleResolver();
+ $this->structuredRenderer = new StructuredLayoutRenderer($this->styleParser, $this->styleResolver);
}
/**
* @param list $nodes
*/
public function layout(array $nodes, float $width, float $height): LayoutResult
+ {
+ if ($this->requiresStructuredLayout($nodes)) {
+ return $this->structuredRenderer->layout($nodes, $width, $height);
+ }
+
+ return $this->layoutLinear($nodes, $width, $height);
+ }
+
+ /**
+ * @param list $nodes
+ */
+ private function layoutLinear(array $nodes, float $width, float $height): LayoutResult
{
$lines = [];
$images = [];
@@ -107,12 +124,50 @@ public function layout(array $nodes, float $width, float $height): LayoutResult
return new LayoutResult(lines: $lines, images: $images);
}
- private function styleValue(
- \LibreSign\XObjectTemplate\Css\StyleMap $style,
- string $property,
- string $default,
- ): string {
- return $style->get($property, $default) ?? $default;
+ /**
+ * @param list $nodes
+ */
+ private function requiresStructuredLayout(array $nodes): bool
+ {
+ foreach ($this->walk($nodes) as $node) {
+ $style = $this->styleParser->parse($node->attributes['style'] ?? '');
+ if ($this->containsStructuredLayoutRules($style)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function containsStructuredLayoutRules(StyleMap $style): bool
+ {
+ if (strtolower(trim($this->styleValue($style, 'display', ''))) === 'flex') {
+ return true;
+ }
+
+ if ($this->styleResolver->isAbsolutelyPositioned($style)) {
+ return true;
+ }
+
+ foreach (['width', 'height', 'left', 'top', 'right', 'bottom', 'gap'] as $property) {
+ if (str_contains($this->styleValue($style, $property, ''), '%')) {
+ return true;
+ }
+ }
+
+ $justifyContent = strtolower(trim($this->styleValue($style, 'justify-content', '')));
+ if (in_array($justifyContent, ['center', 'flex-end', 'space-between'], true)) {
+ return true;
+ }
+
+ $alignItems = strtolower(trim($this->styleValue($style, 'align-items', '')));
+
+ return in_array($alignItems, ['center', 'flex-end'], true);
+ }
+
+ private function styleValue(StyleMap $style, string $property, string $default): string
+ {
+ return $this->styleResolver->styleValue($style, $property, $default);
}
/**
@@ -142,27 +197,12 @@ private function walk(array $nodes): array
private function toPoints(string $value): float
{
- $normalized = strtolower($value);
- $number = (float) preg_replace('/[^0-9.\-]/', '', $normalized);
- if (str_ends_with($normalized, 'px')) {
- return $number * 0.75;
- }
-
- return $number;
+ return $this->styleResolver->toPoints($value);
}
- private function resolveLineHeight(
- \LibreSign\XObjectTemplate\Css\StyleMap $style,
- float $fontSize,
- ): float {
- $defaultLineHeight = $fontSize * 1.2;
- $configuredLineHeight = $this->styleValue($style, 'line-height', '');
-
- if ($configuredLineHeight === '') {
- return $defaultLineHeight;
- }
-
- return max($defaultLineHeight, $this->toPoints($configuredLineHeight));
+ private function resolveLineHeight(StyleMap $style, float $fontSize): float
+ {
+ return $this->styleResolver->resolveLineHeight($style, $fontSize);
}
/**
@@ -170,58 +210,11 @@ private function resolveLineHeight(
*/
private function parseBoxSpacing(string $value): array
{
- preg_match_all('/\S+/', $value, $matches);
- $tokens = $matches[0];
-
- if ($tokens === []) {
- return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
- }
-
- $points = array_map(fn (string $token): float => $this->toPoints($token), $tokens);
- $count = count($points);
-
- if ($count === 1) {
- return ['top' => $points[0], 'right' => $points[0], 'bottom' => $points[0], 'left' => $points[0]];
- }
-
- if ($count === 2) {
- return ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[0], 'left' => $points[1]];
- }
-
- if ($count === 3) {
- return ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[1]];
- }
-
- return ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[3]];
+ return $this->styleResolver->parseBoxSpacing($value);
}
private function resolveFontAlias(string $fontFamily, string $fontWeight): string
{
- $primary = strtolower(explode(',', $fontFamily)[0]);
- $isBold = $this->isBoldWeight($fontWeight);
-
- if (str_contains($primary, 'times')) {
- return $isBold ? 'F4' : 'F3';
- }
-
- if (str_contains($primary, 'courier')) {
- return $isBold ? 'F6' : 'F5';
- }
-
- return $isBold ? 'F2' : 'F1';
- }
-
- private function isBoldWeight(string $fontWeight): bool
- {
- $normalized = strtolower($fontWeight);
- if ($normalized === 'bold' || $normalized === 'bolder') {
- return true;
- }
-
- if (is_numeric($normalized)) {
- return $normalized >= 600;
- }
-
- return false;
+ return $this->styleResolver->resolveFontAlias($fontFamily, $fontWeight);
}
}
diff --git a/src/Layout/StructuredBoxResolver.php b/src/Layout/StructuredBoxResolver.php
new file mode 100644
index 0000000..fd00492
--- /dev/null
+++ b/src/Layout/StructuredBoxResolver.php
@@ -0,0 +1,259 @@
+styleResolver->parseBoxSpacingRelative(
+ $this->styleResolver->styleValue($style, 'margin', '0'),
+ $availableBox['width'],
+ $availableBox['height'],
+ );
+
+ return [
+ 'margin' => $margin,
+ 'box' => [
+ 'x' => $availableBox['x'] + $margin['left'],
+ 'y' => $availableBox['y'] + $margin['top'],
+ 'width' => $this->resolveFlowWidth($node, $style, $availableBox, $margin),
+ 'height' => $this->resolveFlowHeight($node, $style, $availableBox),
+ ],
+ ];
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $container
+ * @return array{x: float, y: float, width: float, height: float}
+ */
+ public function resolveAbsoluteBox(Node $node, StyleMap $style, array $container): array
+ {
+ $margin = $this->styleResolver->parseBoxSpacingRelative(
+ $this->styleResolver->styleValue($style, 'margin', '0'),
+ $container['width'],
+ $container['height'],
+ );
+
+ $resolvedWidth = $this->resolveAbsoluteWidth($node, $style, $container, $margin);
+ $resolvedHeight = $this->resolveAbsoluteHeight($node, $style, $container, $margin);
+
+ return [
+ 'x' => $this->resolveAbsoluteX($style, $container, $resolvedWidth, $margin),
+ 'y' => $this->resolveAbsoluteY($style, $container, $resolvedHeight, $margin),
+ 'width' => $resolvedWidth,
+ 'height' => $resolvedHeight,
+ ];
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $box
+ * @return array{
+ * padding: array{top: float, right: float, bottom: float, left: float},
+ * contentBox: array{x: float, y: float, width: float, height: float}
+ * }
+ */
+ public function resolveContentBox(StyleMap $style, array $box): array
+ {
+ $padding = $this->styleResolver->parseBoxSpacingRelative(
+ $this->styleResolver->styleValue($style, 'padding', '0'),
+ $box['width'],
+ $box['height'] > 0.0 ? $box['height'] : $box['width'],
+ );
+
+ return [
+ 'padding' => $padding,
+ 'contentBox' => [
+ 'x' => $box['x'] + $padding['left'],
+ 'y' => $box['y'] + $padding['top'],
+ 'width' => max($box['width'] - $padding['left'] - $padding['right'], 0.0),
+ 'height' => max($box['height'] - $padding['top'] - $padding['bottom'], 0.0),
+ ],
+ ];
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $contentBox
+ * @return array{x: float, y: float, width: float, height: float}
+ */
+ public function createChildContainer(array $contentBox, float $consumedHeight): array
+ {
+ return [
+ 'x' => $contentBox['x'],
+ 'y' => $contentBox['y'] + $consumedHeight,
+ 'width' => $contentBox['width'],
+ 'height' => $contentBox['height'] > 0.0
+ ? max($contentBox['height'] - $consumedHeight, 0.0)
+ : 0.0,
+ ];
+ }
+
+ /**
+ * @param array{top: float, right: float, bottom: float, left: float} $padding
+ */
+ public function resolveAutoContainerHeight(float $resolvedHeight, array $padding, float $contentHeight): float
+ {
+ $autoHeight = $padding['top'] + $contentHeight + $padding['bottom'];
+
+ return $resolvedHeight > 0.0 ? max($resolvedHeight, $autoHeight) : $autoHeight;
+ }
+
+ /**
+ * @param array{top: float, right: float, bottom: float, left: float} $margin
+ * @param array{x: float, y: float, width: float, height: float} $availableBox
+ */
+ private function resolveFlowWidth(Node $node, StyleMap $style, array $availableBox, array $margin): float
+ {
+ $resolvedWidth = $this->styleResolver->resolveRelativeDimension(
+ $this->styleResolver->styleValue($style, 'width', ''),
+ $availableBox['width'],
+ );
+
+ if ($resolvedWidth > 0.0) {
+ return $resolvedWidth;
+ }
+
+ if ($node->tag === 'img') {
+ return 32.0;
+ }
+
+ return max($availableBox['width'] - $margin['left'] - $margin['right'], 0.0);
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $availableBox
+ */
+ private function resolveFlowHeight(Node $node, StyleMap $style, array $availableBox): float
+ {
+ $resolvedHeight = $this->styleResolver->resolveRelativeDimension(
+ $this->styleResolver->styleValue($style, 'height', ''),
+ $availableBox['height'],
+ );
+
+ if ($resolvedHeight > 0.0) {
+ return $resolvedHeight;
+ }
+
+ return $node->tag === 'img' ? 32.0 : 0.0;
+ }
+
+ /**
+ * @param array{top: float, right: float, bottom: float, left: float} $margin
+ * @param array{x: float, y: float, width: float, height: float} $container
+ */
+ private function resolveAbsoluteWidth(Node $node, StyleMap $style, array $container, array $margin): float
+ {
+ $resolvedWidth = $this->styleResolver->resolveRelativeDimension(
+ $this->styleResolver->styleValue($style, 'width', ''),
+ $container['width'],
+ );
+
+ if ($resolvedWidth > 0.0) {
+ return $resolvedWidth;
+ }
+
+ if ($node->tag === 'img') {
+ return 32.0;
+ }
+
+ return max($container['width'] - $margin['left'] - $margin['right'], 0.0);
+ }
+
+ /**
+ * @param array{top: float, right: float, bottom: float, left: float} $margin
+ * @param array{x: float, y: float, width: float, height: float} $container
+ */
+ private function resolveAbsoluteHeight(Node $node, StyleMap $style, array $container, array $margin): float
+ {
+ $resolvedHeight = $this->styleResolver->resolveRelativeDimension(
+ $this->styleResolver->styleValue($style, 'height', ''),
+ $container['height'],
+ );
+
+ if ($resolvedHeight > 0.0) {
+ return $resolvedHeight;
+ }
+
+ if ($node->tag === 'img') {
+ return 32.0;
+ }
+
+ return max($container['height'] - $margin['top'] - $margin['bottom'], 0.0);
+ }
+
+ /**
+ * @param array{top: float, right: float, bottom: float, left: float} $margin
+ * @param array{x: float, y: float, width: float, height: float} $container
+ */
+ private function resolveAbsoluteX(StyleMap $style, array $container, float $resolvedWidth, array $margin): float
+ {
+ $left = $this->styleResolver->styleValue($style, 'left', '');
+ if ($left !== '') {
+ return $container['x']
+ + $this->styleResolver->resolveRelativeDimension($left, $container['width'])
+ + $margin['left'];
+ }
+
+ $right = $this->styleResolver->styleValue($style, 'right', '');
+ if ($right === '') {
+ return $container['x'] + $margin['left'];
+ }
+
+ return $container['x']
+ + max(
+ $container['width']
+ - $resolvedWidth
+ - $this->styleResolver->resolveRelativeDimension($right, $container['width'])
+ - $margin['right'],
+ 0.0,
+ );
+ }
+
+ /**
+ * @param array{top: float, right: float, bottom: float, left: float} $margin
+ * @param array{x: float, y: float, width: float, height: float} $container
+ */
+ private function resolveAbsoluteY(StyleMap $style, array $container, float $resolvedHeight, array $margin): float
+ {
+ $top = $this->styleResolver->styleValue($style, 'top', '');
+ if ($top !== '') {
+ return $container['y']
+ + $this->styleResolver->resolveRelativeDimension($top, $container['height'])
+ + $margin['top'];
+ }
+
+ $bottom = $this->styleResolver->styleValue($style, 'bottom', '');
+ if ($bottom === '') {
+ return $container['y'] + $margin['top'];
+ }
+
+ return $container['y']
+ + max(
+ $container['height']
+ - $resolvedHeight
+ - $this->styleResolver->resolveRelativeDimension($bottom, $container['height'])
+ - $margin['bottom'],
+ 0.0,
+ );
+ }
+}
diff --git a/src/Layout/StructuredFlexLayoutPlanner.php b/src/Layout/StructuredFlexLayoutPlanner.php
new file mode 100644
index 0000000..eac363f
--- /dev/null
+++ b/src/Layout/StructuredFlexLayoutPlanner.php
@@ -0,0 +1,174 @@
+styleResolver->resolveRelativeDimension(
+ $this->styleResolver->styleValue($style, 'gap', '0'),
+ $direction === 'column' ? $contentBox['height'] : $contentBox['width'],
+ );
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $container
+ * @return array{width: float, height: float}
+ */
+ public function measureItem(Node $node, StyleMap $style, array $container): array
+ {
+ $width = $this->styleResolver->resolveRelativeDimension(
+ $this->styleResolver->styleValue($style, 'width', ''),
+ $container['width'],
+ );
+ if ($width <= 0.0) {
+ $width = $node->tag === 'img' ? 32.0 : 0.0;
+ }
+
+ $height = $this->styleResolver->resolveRelativeDimension(
+ $this->styleResolver->styleValue($style, 'height', ''),
+ $container['height'],
+ );
+ if ($height <= 0.0) {
+ $height = match (true) {
+ $node->tag === 'img' => 32.0,
+ trim($node->text) !== '' => $this->styleResolver->resolveLineHeight(
+ $style,
+ $this->styleResolver->toPoints($this->styleResolver->styleValue($style, 'font-size', '10')),
+ ),
+ default => max($container['height'], 0.0),
+ };
+ }
+
+ return [
+ 'width' => max($width, 0.0),
+ 'height' => max($height, 0.0),
+ ];
+ }
+
+ /**
+ * @param list $items
+ * @param array{x: float, y: float, width: float, height: float} $contentBox
+ * @return array{
+ * gap: float,
+ * mainAxisOffset: float,
+ * totalMainAxisSize: float,
+ * crossAxisSize: float,
+ * crossContainerSize: float
+ * }
+ */
+ public function calculateMetrics(
+ array $items,
+ string $direction,
+ string $justifyContent,
+ float $gap,
+ array $contentBox,
+ ): array {
+ $mainAxisSize = 0.0;
+ $crossAxisSize = 0.0;
+ foreach ($items as $item) {
+ $mainAxisSize += $direction === 'row' ? $item['size']['width'] : $item['size']['height'];
+ $crossAxisSize = max(
+ $crossAxisSize,
+ $direction === 'row' ? $item['size']['height'] : $item['size']['width'],
+ );
+ }
+
+ $mainContainerSize = $direction === 'row' ? $contentBox['width'] : $contentBox['height'];
+ $crossContainerSize = $direction === 'row' ? $contentBox['height'] : $contentBox['width'];
+
+ if ($justifyContent === 'space-between' && count($items) > 1) {
+ $gap = max($gap, ($mainContainerSize - $mainAxisSize) / (count($items) - 1));
+ }
+
+ $totalMainAxisSize = $mainAxisSize + ($gap * max(count($items) - 1, 0));
+
+ return [
+ 'gap' => $gap,
+ 'mainAxisOffset' => $this->resolveMainAxisOffset($justifyContent, $mainContainerSize, $totalMainAxisSize),
+ 'totalMainAxisSize' => $totalMainAxisSize,
+ 'crossAxisSize' => $crossAxisSize,
+ 'crossContainerSize' => $crossContainerSize,
+ ];
+ }
+
+ /**
+ * @param array{node: Node, style: StyleMap, size: array{width: float, height: float}} $item
+ * @param array{x: float, y: float, width: float, height: float} $contentBox
+ * @return array{x: float, y: float, width: float, height: float}
+ */
+ public function createChildBox(
+ array $item,
+ string $direction,
+ string $alignItems,
+ array $contentBox,
+ float $crossContainerSize,
+ float $cursor,
+ ): array {
+ if ($direction === 'row') {
+ return [
+ 'x' => $contentBox['x'] + $cursor,
+ 'y' => $contentBox['y']
+ + $this->resolveCrossAxisOffset($alignItems, $crossContainerSize, $item['size']['height']),
+ 'width' => $item['size']['width'],
+ 'height' => $item['size']['height'] > 0.0 ? $item['size']['height'] : $contentBox['height'],
+ ];
+ }
+
+ return [
+ 'x' => $contentBox['x']
+ + $this->resolveCrossAxisOffset($alignItems, $crossContainerSize, $item['size']['width']),
+ 'y' => $contentBox['y'] + $cursor,
+ 'width' => $item['size']['width'] > 0.0 ? $item['size']['width'] : $contentBox['width'],
+ 'height' => $item['size']['height'],
+ ];
+ }
+
+ /**
+ * @param array{node: Node, style: StyleMap, size: array{width: float, height: float}} $item
+ */
+ public function advanceCursor(array $item, string $direction, float $gap): float
+ {
+ return ($direction === 'row' ? $item['size']['width'] : $item['size']['height']) + $gap;
+ }
+
+ private function resolveMainAxisOffset(string $justifyContent, float $containerSize, float $contentSize): float
+ {
+ return match ($justifyContent) {
+ 'center' => max(($containerSize - $contentSize) / 2.0, 0.0),
+ 'flex-end' => max($containerSize - $contentSize, 0.0),
+ default => 0.0,
+ };
+ }
+
+ private function resolveCrossAxisOffset(string $alignItems, float $containerSize, float $itemSize): float
+ {
+ return match ($alignItems) {
+ 'center' => max(($containerSize - $itemSize) / 2.0, 0.0),
+ 'flex-end' => max($containerSize - $itemSize, 0.0),
+ default => 0.0,
+ };
+ }
+}
diff --git a/src/Layout/StructuredLayoutRenderer.php b/src/Layout/StructuredLayoutRenderer.php
new file mode 100644
index 0000000..d4b25e3
--- /dev/null
+++ b/src/Layout/StructuredLayoutRenderer.php
@@ -0,0 +1,392 @@
+boxResolver = new StructuredBoxResolver($styleResolver);
+ $this->flexPlanner = new StructuredFlexLayoutPlanner($styleResolver);
+ }
+
+ /**
+ * @param list $nodes
+ */
+ public function layout(array $nodes, float $width, float $height): LayoutResult
+ {
+ $lines = [];
+ $images = [];
+ $imageCount = 0;
+
+ $this->layoutNodes(
+ nodes: $nodes,
+ container: [
+ 'x' => 0.0,
+ 'y' => 0.0,
+ 'width' => max($width, 0.0),
+ 'height' => max($height, 0.0),
+ ],
+ canvasHeight: max($height, 0.0),
+ lines: $lines,
+ images: $images,
+ imageCount: $imageCount,
+ );
+
+ return new LayoutResult(lines: $lines, images: $images);
+ }
+
+ /**
+ * @param list $nodes
+ * @param array{x: float, y: float, width: float, height: float} $container
+ * @param list $lines
+ * @param list $images
+ */
+ private function layoutNodes(
+ array $nodes,
+ array $container,
+ float $canvasHeight,
+ array &$lines,
+ array &$images,
+ int &$imageCount,
+ ): float {
+ $consumedHeight = 0.0;
+
+ foreach ($nodes as $node) {
+ $style = $this->styleParser->parse($node->attributes['style'] ?? '');
+
+ if ($this->styleResolver->isAbsolutelyPositioned($style)) {
+ $this->layoutAbsoluteNode($node, $style, $container, $canvasHeight, $lines, $images, $imageCount);
+ continue;
+ }
+
+ $availableBox = [
+ 'x' => $container['x'],
+ 'y' => $container['y'] + $consumedHeight,
+ 'width' => $container['width'],
+ 'height' => max($container['height'] - $consumedHeight, 0.0),
+ ];
+
+ $consumedHeight += $this->layoutFlowNode(
+ $node,
+ $style,
+ $availableBox,
+ $canvasHeight,
+ $lines,
+ $images,
+ $imageCount,
+ );
+ }
+
+ return $consumedHeight;
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $availableBox
+ * @param list $lines
+ * @param list $images
+ */
+ private function layoutFlowNode(
+ Node $node,
+ StyleMap $style,
+ array $availableBox,
+ float $canvasHeight,
+ array &$lines,
+ array &$images,
+ int &$imageCount,
+ ): float {
+ ['margin' => $margin, 'box' => $box] = $this->boxResolver->resolveFlowPlacement($node, $style, $availableBox);
+
+ $renderedHeight = $this->renderResolvedNode(
+ $node,
+ $style,
+ $box,
+ $canvasHeight,
+ $lines,
+ $images,
+ $imageCount,
+ );
+
+ return $margin['top'] + $renderedHeight + $margin['bottom'];
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $container
+ * @param list $lines
+ * @param list $images
+ */
+ private function layoutAbsoluteNode(
+ Node $node,
+ StyleMap $style,
+ array $container,
+ float $canvasHeight,
+ array &$lines,
+ array &$images,
+ int &$imageCount,
+ ): void {
+ $this->renderResolvedNode(
+ $node,
+ $style,
+ $this->boxResolver->resolveAbsoluteBox($node, $style, $container),
+ $canvasHeight,
+ $lines,
+ $images,
+ $imageCount,
+ );
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $box
+ * @param list $lines
+ * @param list $images
+ */
+ private function renderResolvedNode(
+ Node $node,
+ StyleMap $style,
+ array $box,
+ float $canvasHeight,
+ array &$lines,
+ array &$images,
+ int &$imageCount,
+ ): float {
+ if ($node->tag === 'br') {
+ return 12.0;
+ }
+
+ if ($node->tag === 'img') {
+ return $this->renderImage($node, $box, $canvasHeight, $images, $imageCount);
+ }
+
+ if (trim($node->text) !== '' && $node->children === []) {
+ return $this->renderBlockContainer($node, $style, $box, $canvasHeight, $lines, $images, $imageCount);
+ }
+
+ if (strtolower(trim($this->styleResolver->styleValue($style, 'display', ''))) === 'flex') {
+ return $this->renderFlexContainer($node, $style, $box, $canvasHeight, $lines, $images, $imageCount);
+ }
+
+ return $this->renderBlockContainer($node, $style, $box, $canvasHeight, $lines, $images, $imageCount);
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $box
+ * @param list $lines
+ * @param list $images
+ */
+ private function renderBlockContainer(
+ Node $node,
+ StyleMap $style,
+ array $box,
+ float $canvasHeight,
+ array &$lines,
+ array &$images,
+ int &$imageCount,
+ ): float {
+ ['padding' => $padding, 'contentBox' => $contentBox] = $this->boxResolver->resolveContentBox($style, $box);
+
+ $contentHeight = 0.0;
+ if (trim($node->text) !== '') {
+ $contentHeight += $this->renderTextLine($node, $style, $contentBox, $canvasHeight, $lines);
+ }
+
+ if ($node->children !== []) {
+ $contentHeight += $this->layoutNodes(
+ $node->children,
+ $this->boxResolver->createChildContainer($contentBox, $contentHeight),
+ $canvasHeight,
+ $lines,
+ $images,
+ $imageCount,
+ );
+ }
+
+ return $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, $contentHeight);
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $box
+ * @param list $lines
+ * @param list $images
+ */
+ private function renderFlexContainer(
+ Node $node,
+ StyleMap $style,
+ array $box,
+ float $canvasHeight,
+ array &$lines,
+ array &$images,
+ int &$imageCount,
+ ): float {
+ ['padding' => $padding, 'contentBox' => $contentBox] = $this->boxResolver->resolveContentBox($style, $box);
+
+ $direction = $this->flexPlanner->normalizeDirection(
+ $this->styleResolver->styleValue($style, 'flex-direction', 'row'),
+ );
+ $justifyContent = strtolower(trim($this->styleResolver->styleValue($style, 'justify-content', 'flex-start')));
+ $alignItems = strtolower(trim($this->styleResolver->styleValue($style, 'align-items', 'flex-start')));
+ $gap = $this->flexPlanner->resolveGap($style, $direction, $contentBox);
+
+ $items = $this->collectFlexItems(
+ $node,
+ $box,
+ $contentBox,
+ $canvasHeight,
+ $lines,
+ $images,
+ $imageCount,
+ );
+
+ if ($items === []) {
+ return $box['height'] > 0.0 ? $box['height'] : ($padding['top'] + $padding['bottom']);
+ }
+
+ $metrics = $this->flexPlanner->calculateMetrics($items, $direction, $justifyContent, $gap, $contentBox);
+ $cursor = $metrics['mainAxisOffset'];
+
+ foreach ($items as $item) {
+ $this->renderResolvedNode(
+ $item['node'],
+ $item['style'],
+ $this->flexPlanner->createChildBox(
+ $item,
+ $direction,
+ $alignItems,
+ $contentBox,
+ $metrics['crossContainerSize'],
+ $cursor,
+ ),
+ $canvasHeight,
+ $lines,
+ $images,
+ $imageCount,
+ );
+
+ $cursor += $this->flexPlanner->advanceCursor($item, $direction, $metrics['gap']);
+ }
+
+ $autoHeight = $direction === 'row'
+ ? $padding['top'] + $metrics['crossAxisSize'] + $padding['bottom']
+ : $padding['top'] + $metrics['totalMainAxisSize'] + $padding['bottom'];
+
+ return $box['height'] > 0.0 ? max($box['height'], $autoHeight) : $autoHeight;
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $box
+ * @param list $lines
+ */
+ private function renderTextLine(
+ Node $node,
+ StyleMap $style,
+ array $box,
+ float $canvasHeight,
+ array &$lines,
+ ): float {
+ $text = trim($node->text);
+ if ($text === '') {
+ return 0.0;
+ }
+
+ $fontSize = $this->styleResolver->toPoints($this->styleResolver->styleValue($style, 'font-size', '10'));
+ $lineHeight = $this->styleResolver->resolveLineHeight($style, $fontSize);
+ $fontAlias = $this->styleResolver->resolveFontAlias(
+ $this->styleResolver->styleValue($style, 'font-family', 'helvetica'),
+ $this->styleResolver->styleValue($style, 'font-weight', 'normal'),
+ );
+
+ $align = strtolower($this->styleResolver->styleValue($style, 'text-align', 'left'));
+ $lineX = match ($align) {
+ 'center' => $box['x'] + ($box['width'] / 2.0),
+ 'right' => max($box['x'] + $box['width'], 0.0),
+ default => $box['x'],
+ };
+
+ $lines[] = new LayoutLine(
+ text: $text,
+ x: $lineX,
+ y: max($canvasHeight - ($box['y'] + $lineHeight), 0.0),
+ fontSize: $fontSize,
+ fontAlias: $fontAlias,
+ rgbColor: $this->styleResolver->styleValue($style, 'color', '#000000'),
+ );
+
+ return $lineHeight;
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $box
+ * @param list $images
+ */
+ private function renderImage(
+ Node $node,
+ array $box,
+ float $canvasHeight,
+ array &$images,
+ int &$imageCount,
+ ): float {
+ $width = $box['width'] > 0.0 ? $box['width'] : 32.0;
+ $height = $box['height'] > 0.0 ? $box['height'] : 32.0;
+
+ $images[] = new LayoutImage(
+ alias: 'Im' . $imageCount,
+ x: $box['x'],
+ y: max($canvasHeight - ($box['y'] + $height), 0.0),
+ width: $width,
+ height: $height,
+ source: $node->attributes['src'] ?? '',
+ );
+ ++$imageCount;
+
+ return $height;
+ }
+
+ /**
+ * @param array{x: float, y: float, width: float, height: float} $box
+ * @param array{x: float, y: float, width: float, height: float} $contentBox
+ * @param list $lines
+ * @param list $images
+ * @return list
+ */
+ private function collectFlexItems(
+ Node $node,
+ array $box,
+ array $contentBox,
+ float $canvasHeight,
+ array &$lines,
+ array &$images,
+ int &$imageCount,
+ ): array {
+ $items = [];
+
+ foreach ($node->children as $child) {
+ $childStyle = $this->styleParser->parse($child->attributes['style'] ?? '');
+ if ($this->styleResolver->isAbsolutelyPositioned($childStyle)) {
+ $this->layoutAbsoluteNode($child, $childStyle, $box, $canvasHeight, $lines, $images, $imageCount);
+ continue;
+ }
+
+ $items[] = [
+ 'node' => $child,
+ 'style' => $childStyle,
+ 'size' => $this->flexPlanner->measureItem($child, $childStyle, $contentBox),
+ ];
+ }
+
+ return $items;
+ }
+}
diff --git a/src/Pdf/TemplateDocumentBuilder.php b/src/Pdf/TemplateDocumentBuilder.php
index 66fa03c..5c8d4bd 100644
--- a/src/Pdf/TemplateDocumentBuilder.php
+++ b/src/Pdf/TemplateDocumentBuilder.php
@@ -88,7 +88,7 @@ public function buildContentStream(LayoutResult $layout): string
foreach ($layout->lines as $line) {
$stream[] = sprintf('/%s %F Tf', $line->fontAlias, $line->fontSize);
$stream[] = $this->colorParser->toPdfRgb($line->rgbColor);
- $stream[] = sprintf('%F %F Td', $line->x, $line->y);
+ $stream[] = sprintf('1 0 0 1 %F %F Tm', $line->x, $line->y);
$stream[] = sprintf('(%s) Tj', $this->pdfEscaper->escapeLiteralString($line->text));
}
$stream[] = 'ET';
diff --git a/tests/Integration/VisibleStampTemplateScenarioTest.php b/tests/Integration/VisibleStampTemplateScenarioTest.php
new file mode 100644
index 0000000..59f1b90
--- /dev/null
+++ b/tests/Integration/VisibleStampTemplateScenarioTest.php
@@ -0,0 +1,323 @@
+ensureDirectoryExists($previewRoot);
+ $this->ensureDirectoryExists($assetRoot);
+
+ $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,
+ ));
+
+ $pdf = (new SinglePagePdfExporter())->export($result);
+ $previewPath = $previewRoot . '/' . $slug . '.pdf';
+ file_put_contents($previewPath, $pdf);
+
+ 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);
+ }
+
+ foreach ($expectedTexts as $expectedText) {
+ self::assertStringContainsString($expectedText, $result->contentStream);
+ self::assertStringContainsString($expectedText, $pdf);
+ }
+ }
+
+ /**
+ * @return iterable
+ * }>
+ */
+ public static function visibleStampLayoutProvider(): iterable
+ {
+ yield 'signature and metadata at right' => [
+ 'slug' => 'signature-and-metadata-right',
+ 'layout' => 'signature_and_metadata_right',
+ 'expectedImageCount' => 2,
+ 'expectedTexts' => [
+ 'Signed with LibreSign',
+ 'admin',
+ 'Issuer: Preview Issuer',
+ 'Date: 2026-05-28T16:40:21+00:00',
+ ],
+ ];
+
+ yield 'label and metadata at right' => [
+ 'slug' => 'label-and-metadata-right',
+ 'layout' => 'label_and_metadata_right',
+ 'expectedImageCount' => 1,
+ 'expectedTexts' => [
+ 'admin',
+ 'Signed with LibreSign',
+ 'Issuer: Preview Issuer',
+ 'Date: 2026-05-28T16:40:21+00:00',
+ ],
+ ];
+
+ yield 'signature centered' => [
+ 'slug' => 'signature-centered',
+ 'layout' => 'signature_centered',
+ 'expectedImageCount' => 2,
+ 'expectedTexts' => [],
+ ];
+
+ yield 'metadata only at top left' => [
+ 'slug' => 'metadata-only-top-left',
+ 'layout' => 'metadata_only_top_left',
+ 'expectedImageCount' => 1,
+ 'expectedTexts' => [
+ 'Signed with LibreSign',
+ 'admin',
+ 'Issuer: Preview Issuer',
+ 'Date: 2026-05-28T16:40:21+00:00',
+ ],
+ ];
+
+ yield 'two columns with centered cells' => [
+ 'slug' => 'two-columns-centered-cells',
+ 'layout' => 'two_columns_centered_cells',
+ 'expectedImageCount' => 2,
+ 'expectedTexts' => [
+ 'Signed with LibreSign',
+ 'Preview Issuer',
+ 'Date: 2026-05-28T16:40:21+00:00',
+ ],
+ ];
+ }
+
+ private function buildLayoutHtml(string $layout, string $backgroundPath, ?string $signaturePath): string
+ {
+ $background = sprintf(
+ '
',
+ $this->escapeAttribute($backgroundPath),
+ );
+
+ return match ($layout) {
+ 'signature_and_metadata_right' => sprintf(
+ '%s'
+ . '
'
+ . '

'
+ . '
'
+ . '
'
+ . '
Signed with LibreSign
'
+ . '
admin
'
+ . '
Issuer: Preview Issuer
'
+ . '
Date: 2026-05-28T16:40:21+00:00
'
+ . '
'
+ . '
',
+ $background,
+ $this->requireSignaturePath($signaturePath),
+ ),
+ 'label_and_metadata_right' => sprintf(
+ '%s'
+ . '
'
+ . '
'
+ . '
Signed with LibreSign
'
+ . '
Issuer: Preview Issuer
'
+ . '
Date: 2026-05-28T16:40:21+00:00
'
+ . '
'
+ . '
',
+ $background,
+ ),
+ 'signature_centered' => sprintf(
+ '%s'
+ . '

'
+ . '
',
+ $background,
+ $this->requireSignaturePath($signaturePath),
+ ),
+ 'metadata_only_top_left' => sprintf(
+ '%s'
+ . '
'
+ . '
Signed with LibreSign
'
+ . '
admin
'
+ . '
Issuer: Preview Issuer
'
+ . '
Date: 2026-05-28T16:40:21+00:00
'
+ . '
'
+ . '
',
+ $background,
+ ),
+ 'two_columns_centered_cells' => sprintf(
+ '%s'
+ . '
'
+ . '

'
+ . '
'
+ . '
'
+ . '
'
+ . '
Signed with LibreSign
'
+ . '
Preview Issuer
'
+ . '
Date: 2026-05-28T16:40:21+00:00
'
+ . '
'
+ . '
'
+ . '
',
+ $background,
+ $this->requireSignaturePath($signaturePath),
+ ),
+ default => throw new \InvalidArgumentException(sprintf('Unknown visible stamp layout "%s".', $layout)),
+ };
+ }
+
+ private function createBackgroundPreview(string $path): string
+ {
+ if (is_file($path)) {
+ return $path;
+ }
+
+ $contents = PngFixtureFactory::createRgbaPngFromPixelRenderer(
+ self::PREVIEW_WIDTH,
+ self::PREVIEW_HEIGHT,
+ function (int $x, int $y, int $width, int $height): array {
+ $background = [245, 247, 250, 255];
+ $diagonal = abs(($height * $x) - ($width * $y));
+ $inverseDiagonal = abs(($height * ($width - $x)) - ($width * $y));
+ $bandStrength = ($diagonal < $width * 10 || $inverseDiagonal < $width * 10) ? 55 : 0;
+ $ringCenterX = $width * 0.74;
+ $ringCenterY = $height * 0.5;
+ $distance = sqrt((($x - $ringCenterX) ** 2) + (($y - $ringCenterY) ** 2));
+ $ringStrength = ($distance > $height * 0.18 && $distance < $height * 0.24) ? 35 : 0;
+ $strength = max($bandStrength, $ringStrength);
+
+ return [
+ max(0, $background[0] - $strength),
+ max(0, $background[1] - $strength),
+ max(0, $background[2] - $strength),
+ 255,
+ ];
+ },
+ );
+
+ file_put_contents($path, $contents);
+
+ return $path;
+ }
+
+ private function createSignaturePreview(string $path): string
+ {
+ if (is_file($path)) {
+ return $path;
+ }
+
+ $contents = PngFixtureFactory::createRgbaPngFromPixelRenderer(
+ 640,
+ 180,
+ function (int $x, int $y, int $width, int $height): array {
+ $normalizedX = $width === 1 ? 0.0 : $x / ($width - 1);
+ $primaryWave = ($height * 0.56)
+ + sin($normalizedX * 7.1) * ($height * 0.13)
+ + sin($normalizedX * 14.2) * ($height * 0.035);
+ $secondaryWave = ($height * 0.4)
+ + cos($normalizedX * 16.0) * ($height * 0.05);
+ $underline = ($height * 0.78) + sin($normalizedX * 5.6) * ($height * 0.015);
+
+ $alpha = 0;
+ if (abs($y - $primaryWave) <= 2.2 && $normalizedX >= 0.08 && $normalizedX <= 0.92) {
+ $alpha = 255;
+ }
+
+ if (abs($y - $secondaryWave) <= 1.6 && $normalizedX >= 0.0 && $normalizedX <= 0.18) {
+ $alpha = max($alpha, 220);
+ }
+
+ if (abs($y - $underline) <= 1.0 && $normalizedX >= 0.16 && $normalizedX <= 0.9) {
+ $alpha = max($alpha, 185);
+ }
+
+ return [20, 28, 40, $alpha];
+ },
+ );
+
+ file_put_contents($path, $contents);
+
+ return $path;
+ }
+
+ private function requireSignaturePath(?string $signaturePath): string
+ {
+ if ($signaturePath === null) {
+ throw new \InvalidArgumentException('This visible stamp layout requires a signature image.');
+ }
+
+ return $this->escapeAttribute($signaturePath);
+ }
+
+ private function ensureDirectoryExists(string $directory): void
+ {
+ if (is_dir($directory)) {
+ return;
+ }
+
+ mkdir($directory, 0777, true);
+ }
+
+ private function layoutUsesSignatureImage(string $layout): bool
+ {
+ return in_array(
+ $layout,
+ ['signature_and_metadata_right', 'signature_centered', 'two_columns_centered_cells'],
+ true,
+ );
+ }
+
+ private function escapeAttribute(string $value): string
+ {
+ return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+}
diff --git a/tests/Support/PngFixtureFactory.php b/tests/Support/PngFixtureFactory.php
new file mode 100644
index 0000000..50d96fb
--- /dev/null
+++ b/tests/Support/PngFixtureFactory.php
@@ -0,0 +1,78 @@
+children);
self::assertSame('span', $nodes[0]->children[0]->tag);
self::assertSame('Hello', $nodes[0]->children[0]->children[0]->text);
+ self::assertSame('font-size:10;font-weight:bold', $nodes[0]->children[0]->attributes['style']);
self::assertSame(
- 'font-size:10; margin:2;font-weight:bold',
+ 'font-size:10;font-weight:bold',
$nodes[0]->children[0]->children[0]->attributes['style'],
);
self::assertSame('br', $nodes[0]->children[1]->tag);
@@ -61,6 +62,33 @@ public function testParseMergesInheritedStylesAndKeepsAllowedTags(): void
self::assertSame('font-size:10; margin:2', $nodes[0]->children[2]->attributes['style']);
}
+ public function testParseOnlyInheritsTextualStylesToDescendants(): void
+ {
+ $parser = new SubsetHtmlParser();
+
+ $nodes = $parser->parse(
+ '',
+ );
+
+ self::assertSame(
+ 'width:58%;height:100%;padding:18 24;font-size:20;color:#123456',
+ $nodes[0]->attributes['style'],
+ );
+ self::assertSame(
+ 'font-size:20;color:#123456;font-weight:700',
+ $nodes[0]->children[0]->attributes['style'],
+ );
+ self::assertSame(
+ 'font-size:20;color:#123456;font-weight:700',
+ $nodes[0]->children[0]->children[0]->attributes['style'],
+ );
+ self::assertStringNotContainsString('width:58%', $nodes[0]->children[0]->attributes['style']);
+ self::assertStringNotContainsString('height:100%', $nodes[0]->children[0]->attributes['style']);
+ self::assertStringNotContainsString('padding:18 24', $nodes[0]->children[0]->attributes['style']);
+ }
+
public function testParseNormalizesTagAndAttributeNamesAndKeepsAllAttributes(): void
{
$parser = new SubsetHtmlParser();
diff --git a/tests/Unit/Layout/LayoutStyleResolverTest.php b/tests/Unit/Layout/LayoutStyleResolverTest.php
new file mode 100644
index 0000000..d87dc4f
--- /dev/null
+++ b/tests/Unit/Layout/LayoutStyleResolverTest.php
@@ -0,0 +1,70 @@
+toPoints('10PX'));
+ self::assertSame(10.0, $resolver->toPoints('10'));
+ self::assertSame(20.0, $resolver->resolveRelativeDimension(' 25% ', 80.0));
+ self::assertSame(7.5, $resolver->resolveRelativeDimension(' 10PX ', 80.0));
+ self::assertSame(0.0, $resolver->resolveRelativeDimension(' ', 80.0));
+ }
+
+ public function testParseBoxSpacingRelativeUsesAxisSpecificReferencesAcrossShorthandVariants(): void
+ {
+ $resolver = new LayoutStyleResolver();
+
+ self::assertSame(
+ ['top' => 10.0, 'right' => 20.0, 'bottom' => 10.0, 'left' => 20.0],
+ $resolver->parseBoxSpacingRelative('10%', 200.0, 100.0),
+ );
+ self::assertSame(
+ ['top' => 10.0, 'right' => 40.0, 'bottom' => 10.0, 'left' => 40.0],
+ $resolver->parseBoxSpacingRelative('10% 20%', 200.0, 100.0),
+ );
+ self::assertSame(
+ ['top' => 10.0, 'right' => 40.0, 'bottom' => 30.0, 'left' => 40.0],
+ $resolver->parseBoxSpacingRelative('10% 20% 30%', 200.0, 100.0),
+ );
+ self::assertSame(
+ ['top' => 10.0, 'right' => 40.0, 'bottom' => 30.0, 'left' => 80.0],
+ $resolver->parseBoxSpacingRelative('10% 20% 30% 40%', 200.0, 100.0),
+ );
+ }
+
+ public function testParseBoxSpacingRelativeReturnsZeroSlotsForWhitespaceOnlyInput(): void
+ {
+ $resolver = new LayoutStyleResolver();
+
+ self::assertSame(
+ ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0],
+ $resolver->parseBoxSpacingRelative(" \t\n ", 200.0, 100.0),
+ );
+ }
+
+ public function testPositionAndFontResolutionNormalizeWhitespaceAndCase(): void
+ {
+ $parser = new InlineStyleParser();
+ $resolver = new LayoutStyleResolver();
+
+ self::assertTrue($resolver->isAbsolutelyPositioned($parser->parse('position: ABSOLUTE ')));
+ self::assertFalse($resolver->isAbsolutelyPositioned($parser->parse('position: relative')));
+ self::assertSame('F6', $resolver->resolveFontAlias('Courier New', '600'));
+ self::assertSame('F4', $resolver->resolveFontAlias('Times New Roman', 'BOLD'));
+ self::assertSame('F1', $resolver->resolveFontAlias('Helvetica', '500'));
+ }
+}
diff --git a/tests/Unit/Layout/LinearLayoutEngineTest.php b/tests/Unit/Layout/LinearLayoutEngineTest.php
index fc53666..3f62957 100644
--- a/tests/Unit/Layout/LinearLayoutEngineTest.php
+++ b/tests/Unit/Layout/LinearLayoutEngineTest.php
@@ -211,6 +211,106 @@ public function testLayoutTreatsZeroImageHeightAsDefaultDimension(): void
self::assertEqualsWithDelta(32.0, $result->images[0]->height, 0.0001);
}
+ public function testLayoutSupportsAbsolutelyPositionedImagesWithoutAdvancingFlow(): void
+ {
+ $engine = new LinearLayoutEngine();
+
+ $result = $engine->layout([
+ new Node(
+ tag: 'div',
+ text: '',
+ attributes: ['style' => 'width:200;height:100'],
+ children: [
+ new Node(
+ tag: 'img',
+ text: '',
+ attributes: [
+ 'src' => '/fixture/background.png',
+ 'style' => 'position:absolute;left:0;top:0;width:100%;height:100%',
+ ],
+ ),
+ new Node(tag: 'span', text: 'Foreground text', attributes: ['style' => 'font-size:10']),
+ ],
+ ),
+ ], 200.0, 100.0);
+
+ self::assertCount(1, $result->images);
+ self::assertCount(1, $result->lines);
+ self::assertEqualsWithDelta(0.0, $result->images[0]->x, 0.0001);
+ self::assertEqualsWithDelta(0.0, $result->images[0]->y, 0.0001);
+ self::assertEqualsWithDelta(200.0, $result->images[0]->width, 0.0001);
+ self::assertEqualsWithDelta(100.0, $result->images[0]->height, 0.0001);
+ self::assertSame('Foreground text', $result->lines[0]->text);
+ self::assertEqualsWithDelta(88.0, $result->lines[0]->y, 0.0001);
+ }
+
+ public function testLayoutSupportsFlexRowsWithPercentageColumns(): void
+ {
+ $engine = new LinearLayoutEngine();
+
+ $result = $engine->layout([
+ new Node(
+ tag: 'div',
+ text: '',
+ attributes: ['style' => 'display:flex;flex-direction:row;width:200;height:100'],
+ children: [
+ new Node(
+ tag: 'div',
+ text: '',
+ attributes: ['style' => 'width:50%;height:100%'],
+ children: [
+ new Node(tag: 'span', text: 'Left column', attributes: ['style' => 'font-size:10']),
+ ],
+ ),
+ new Node(
+ tag: 'div',
+ text: '',
+ attributes: ['style' => 'width:50%;height:100%'],
+ children: [
+ new Node(tag: 'span', text: 'Right column', attributes: ['style' => 'font-size:10']),
+ ],
+ ),
+ ],
+ ),
+ ], 200.0, 100.0);
+
+ self::assertCount(2, $result->lines);
+ self::assertSame('Left column', $result->lines[0]->text);
+ self::assertSame('Right column', $result->lines[1]->text);
+ self::assertEqualsWithDelta(0.0, $result->lines[0]->x, 0.0001);
+ self::assertEqualsWithDelta(100.0, $result->lines[1]->x, 0.0001);
+ self::assertEqualsWithDelta(88.0, $result->lines[0]->y, 0.0001);
+ self::assertEqualsWithDelta(88.0, $result->lines[1]->y, 0.0001);
+ }
+
+ public function testLayoutSupportsFlexCenteringForImages(): void
+ {
+ $engine = new LinearLayoutEngine();
+
+ $result = $engine->layout([
+ new Node(
+ tag: 'div',
+ text: '',
+ attributes: [
+ 'style' => 'display:flex;justify-content:center;align-items:center;width:200;height:100',
+ ],
+ children: [
+ new Node(
+ tag: 'img',
+ text: '',
+ attributes: ['src' => '/fixture/center.png', 'style' => 'width:80;height:40'],
+ ),
+ ],
+ ),
+ ], 200.0, 100.0);
+
+ self::assertCount(1, $result->images);
+ self::assertEqualsWithDelta(60.0, $result->images[0]->x, 0.0001);
+ self::assertEqualsWithDelta(30.0, $result->images[0]->y, 0.0001);
+ self::assertEqualsWithDelta(80.0, $result->images[0]->width, 0.0001);
+ self::assertEqualsWithDelta(40.0, $result->images[0]->height, 0.0001);
+ }
+
public function testConstructorKeepsProvidedInlineStyleParserInstance(): void
{
$styleParser = new InlineStyleParser();
diff --git a/tests/Unit/Layout/StructuredBoxResolverTest.php b/tests/Unit/Layout/StructuredBoxResolverTest.php
new file mode 100644
index 0000000..dddeb82
--- /dev/null
+++ b/tests/Unit/Layout/StructuredBoxResolverTest.php
@@ -0,0 +1,91 @@
+parse('width:50%;height:40%;margin:2 4 6 8');
+
+ $placement = $resolver->resolveFlowPlacement(
+ new Node(tag: 'img', text: '', attributes: ['src' => '/preview.png']),
+ $style,
+ ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0],
+ );
+
+ self::assertSame(['top' => 2.0, 'right' => 4.0, 'bottom' => 6.0, 'left' => 8.0], $placement['margin']);
+ self::assertSame(8.0, $placement['box']['x']);
+ self::assertSame(2.0, $placement['box']['y']);
+ self::assertSame(100.0, $placement['box']['width']);
+ self::assertSame(40.0, $placement['box']['height']);
+ }
+
+ public function testResolveAbsoluteBoxSupportsRightAndBottomOffsets(): void
+ {
+ $parser = new InlineStyleParser();
+ $resolver = new StructuredBoxResolver(new LayoutStyleResolver());
+ $style = $parser->parse('position:absolute;width:60;height:20;right:10;bottom:15;margin:1 2 3 4');
+
+ $box = $resolver->resolveAbsoluteBox(
+ new Node(tag: 'div', text: '', attributes: []),
+ $style,
+ ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0],
+ );
+
+ self::assertSame(128.0, $box['x']);
+ self::assertSame(62.0, $box['y']);
+ self::assertSame(60.0, $box['width']);
+ self::assertSame(20.0, $box['height']);
+ }
+
+ public function testResolveContentBoxAndChildContainerSubtractPaddingAndConsumedHeight(): void
+ {
+ $parser = new InlineStyleParser();
+ $resolver = new StructuredBoxResolver(new LayoutStyleResolver());
+ $style = $parser->parse('padding:10 20 30 40');
+
+ $resolved = $resolver->resolveContentBox(
+ $style,
+ ['x' => 5.0, 'y' => 6.0, 'width' => 200.0, 'height' => 100.0],
+ );
+ $childContainer = $resolver->createChildContainer($resolved['contentBox'], 12.0);
+
+ self::assertSame(['top' => 10.0, 'right' => 20.0, 'bottom' => 30.0, 'left' => 40.0], $resolved['padding']);
+ self::assertSame(45.0, $resolved['contentBox']['x']);
+ self::assertSame(16.0, $resolved['contentBox']['y']);
+ self::assertSame(140.0, $resolved['contentBox']['width']);
+ self::assertSame(60.0, $resolved['contentBox']['height']);
+ self::assertSame(28.0, $childContainer['y']);
+ self::assertSame(48.0, $childContainer['height']);
+ }
+
+ public function testResolveAutoContainerHeightUsesResolvedHeightWhenPresent(): void
+ {
+ $resolver = new StructuredBoxResolver(new LayoutStyleResolver());
+
+ self::assertSame(80.0, $resolver->resolveAutoContainerHeight(
+ 80.0,
+ ['top' => 5.0, 'right' => 0.0, 'bottom' => 5.0, 'left' => 0.0],
+ 40.0,
+ ));
+ self::assertSame(50.0, $resolver->resolveAutoContainerHeight(
+ 0.0,
+ ['top' => 5.0, 'right' => 0.0, 'bottom' => 5.0, 'left' => 0.0],
+ 40.0,
+ ));
+ }
+}
diff --git a/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php
new file mode 100644
index 0000000..6d3f2c6
--- /dev/null
+++ b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php
@@ -0,0 +1,106 @@
+parse('gap:10%');
+
+ self::assertSame('row', $planner->normalizeDirection('ROW'));
+ self::assertSame('column', $planner->normalizeDirection('column'));
+ self::assertSame(
+ 20.0,
+ $planner->resolveGap($style, 'row', ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0]),
+ );
+ self::assertSame(
+ 5.0,
+ $planner->resolveGap($style, 'column', ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0]),
+ );
+ }
+
+ public function testMeasureItemUsesImageAndTextFallbacks(): void
+ {
+ $parser = new InlineStyleParser();
+ $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver());
+
+ $imageSize = $planner->measureItem(
+ new Node(tag: 'img', text: '', attributes: ['src' => '/icon.png']),
+ $parser->parse(''),
+ ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0],
+ );
+ $textSize = $planner->measureItem(
+ new Node(tag: 'span', text: 'Label', attributes: []),
+ $parser->parse('font-size:10'),
+ ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0],
+ );
+
+ self::assertSame(['width' => 32.0, 'height' => 32.0], $imageSize);
+ self::assertSame(['width' => 0.0, 'height' => 12.0], $textSize);
+ }
+
+ public function testCalculateMetricsSupportsSpaceBetween(): void
+ {
+ $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver());
+ $parser = new InlineStyleParser();
+ $items = [
+ [
+ 'node' => new Node('div', '', []),
+ 'style' => $parser->parse(''),
+ 'size' => ['width' => 50.0, 'height' => 20.0],
+ ],
+ [
+ 'node' => new Node('div', '', []),
+ 'style' => $parser->parse(''),
+ 'size' => ['width' => 50.0, 'height' => 30.0],
+ ],
+ ];
+
+ $metrics = $planner->calculateMetrics(
+ $items,
+ 'row',
+ 'space-between',
+ 0.0,
+ ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0],
+ );
+
+ self::assertSame(100.0, $metrics['gap']);
+ self::assertSame(0.0, $metrics['mainAxisOffset']);
+ self::assertSame(200.0, $metrics['totalMainAxisSize']);
+ self::assertSame(30.0, $metrics['crossAxisSize']);
+ self::assertSame(100.0, $metrics['crossContainerSize']);
+ }
+
+ public function testCreateChildBoxSupportsRowAndColumnLayouts(): void
+ {
+ $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver());
+ $item = [
+ 'node' => new Node('div', '', []),
+ 'style' => (new InlineStyleParser())->parse(''),
+ 'size' => ['width' => 50.0, 'height' => 20.0],
+ ];
+ $contentBox = ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0];
+
+ $rowBox = $planner->createChildBox($item, 'row', 'center', $contentBox, 100.0, 30.0);
+ $columnBox = $planner->createChildBox($item, 'column', 'flex-end', $contentBox, 200.0, 15.0);
+
+ self::assertSame(['x' => 40.0, 'y' => 60.0, 'width' => 50.0, 'height' => 20.0], $rowBox);
+ self::assertSame(['x' => 160.0, 'y' => 35.0, 'width' => 50.0, 'height' => 20.0], $columnBox);
+ self::assertSame(57.0, $planner->advanceCursor($item, 'row', 7.0));
+ self::assertSame(27.0, $planner->advanceCursor($item, 'column', 7.0));
+ }
+}
diff --git a/tests/Unit/Layout/StructuredLayoutRendererTest.php b/tests/Unit/Layout/StructuredLayoutRendererTest.php
new file mode 100644
index 0000000..166a336
--- /dev/null
+++ b/tests/Unit/Layout/StructuredLayoutRendererTest.php
@@ -0,0 +1,139 @@
+createRenderer();
+
+ $result = $renderer->layout([
+ $this->imageNode('/bg.png', 'position:absolute;left:0;top:0;width:100;height:20'),
+ $this->textNode(' First ', 'font-size:10;margin:5 0 7 0'),
+ $this->textNode('Second'),
+ ], 100.0, 100.0);
+
+ self::assertCount(1, $result->images);
+ self::assertCount(2, $result->lines);
+ self::assertSame('/bg.png', $result->images[0]->source);
+ self::assertSame(0.0, $result->images[0]->x);
+ self::assertSame(80.0, $result->images[0]->y);
+ self::assertSame(100.0, $result->images[0]->width);
+ self::assertSame(20.0, $result->images[0]->height);
+ self::assertSame('First', $result->lines[0]->text);
+ self::assertSame(83.0, $result->lines[0]->y);
+ self::assertSame('Second', $result->lines[1]->text);
+ self::assertSame(64.0, $result->lines[1]->y);
+ }
+
+ public function testLayoutSupportsTrimmedUppercaseFlexAlignmentAndGapSpacing(): void
+ {
+ $renderer = $this->createRenderer();
+
+ $result = $renderer->layout([
+ new Node(
+ tag: 'div',
+ text: '',
+ attributes: [
+ 'style' => 'display:flex;justify-content: CENTER ;align-items: FLEX-END ;gap:10;'
+ . 'width:100;height:40',
+ ],
+ children: [
+ $this->imageNode('/left.png', 'width:10;height:20'),
+ $this->imageNode('/right.png', 'width:10;height:20'),
+ ],
+ ),
+ ], 100.0, 100.0);
+
+ self::assertCount(2, $result->images);
+ self::assertSame(35.0, $result->images[0]->x);
+ self::assertSame(55.0, $result->images[1]->x);
+ self::assertSame(60.0, $result->images[0]->y);
+ self::assertSame(60.0, $result->images[1]->y);
+ }
+
+ public function testLayoutUsesAutoFlexHeightToPositionFollowingSiblings(): void
+ {
+ $renderer = $this->createRenderer();
+
+ $result = $renderer->layout([
+ new Node(
+ tag: 'div',
+ text: '',
+ attributes: ['style' => 'display:flex;gap:10;width:100;padding:2 0 3 0'],
+ children: [
+ $this->imageNode('/first.png', 'width:10;height:20'),
+ $this->imageNode('/second.png', 'width:10;height:20'),
+ ],
+ ),
+ $this->textNode('Below'),
+ ], 100.0, 100.0);
+
+ self::assertCount(2, $result->images);
+ self::assertCount(1, $result->lines);
+ self::assertSame(0.0, $result->images[0]->x);
+ self::assertSame(20.0, $result->images[1]->x);
+ self::assertSame(78.0, $result->images[0]->y);
+ self::assertSame(78.0, $result->images[1]->y);
+ self::assertSame('Below', $result->lines[0]->text);
+ self::assertSame(63.0, $result->lines[0]->y);
+ }
+
+ public function testLayoutAccumulatesParentTextAndChildHeightBeforeFollowingNodes(): void
+ {
+ $renderer = $this->createRenderer();
+
+ $result = $renderer->layout([
+ new Node(
+ tag: 'div',
+ text: 'Parent',
+ attributes: ['style' => 'font-size:10'],
+ children: [$this->textNode('Child')],
+ ),
+ $this->textNode('After'),
+ ], 100.0, 100.0);
+
+ self::assertCount(3, $result->lines);
+ self::assertSame('Parent', $result->lines[0]->text);
+ self::assertSame(88.0, $result->lines[0]->y);
+ self::assertSame('Child', $result->lines[1]->text);
+ self::assertSame(76.0, $result->lines[1]->y);
+ self::assertSame('After', $result->lines[2]->text);
+ self::assertSame(64.0, $result->lines[2]->y);
+ }
+
+ private function createRenderer(): StructuredLayoutRenderer
+ {
+ return new StructuredLayoutRenderer(new InlineStyleParser(), new LayoutStyleResolver());
+ }
+
+ private function imageNode(string $source, string $style): Node
+ {
+ return new Node(
+ tag: 'img',
+ text: '',
+ attributes: ['src' => $source, 'style' => $style],
+ );
+ }
+
+ private function textNode(string $text, string $style = 'font-size:10'): Node
+ {
+ return new Node(
+ tag: 'span',
+ text: $text,
+ attributes: ['style' => $style],
+ );
+ }
+}
diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php
index 3a7d304..eda85dc 100644
--- a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php
+++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php
@@ -8,8 +8,10 @@
namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf;
use LibreSign\XObjectTemplate\Pdf\FilesystemPdfImageEmbedder;
+use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
final class FilesystemPdfImageEmbedderTest extends TestCase
{
@@ -28,7 +30,7 @@ protected function tearDown(): void
public function testEmbedReturnsPredictorBackedImageForOpaqueRgbPng(): void
{
$embedder = new FilesystemPdfImageEmbedder();
- $pngPath = $this->createTemporaryFile('png', $this->createPng(
+ $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng(
width: 1,
height: 1,
colorType: 2,
@@ -57,7 +59,7 @@ public function testEmbedReturnsPredictorBackedImageForOpaqueRgbPng(): void
public function testEmbedCreatesSoftMaskForRgbaPng(): void
{
$embedder = new FilesystemPdfImageEmbedder();
- $pngPath = $this->createTemporaryFile('png', $this->createPng(
+ $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng(
width: 1,
height: 1,
colorType: 6,
@@ -80,7 +82,7 @@ public function testEmbedCreatesSoftMaskForRgbaPng(): void
public function testEmbedSupportsAllRgbaPredictorFilters(int $filterType): void
{
$embedder = new FilesystemPdfImageEmbedder();
- $pngPath = $this->createTemporaryFile('png', $this->createPng(
+ $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng(
width: 1,
height: 1,
colorType: 6,
@@ -98,7 +100,7 @@ public function testEmbedSupportsAllRgbaPredictorFilters(int $filterType): void
public function testEmbedRejectsUnsupportedPngHeaders(int $bitDepth, int $interlace, string $expectedMessage): void
{
$embedder = new FilesystemPdfImageEmbedder();
- $pngPath = $this->createTemporaryFile('png', $this->createPng(
+ $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng(
width: 1,
height: 1,
colorType: 2,
@@ -181,7 +183,7 @@ public function testEmbedRejectsUnknownBinaryPayloads(): void
public function testEmbedRejectsUnsupportedRowFilters(): void
{
$embedder = new FilesystemPdfImageEmbedder();
- $pngPath = $this->createTemporaryFile('png', $this->createPng(
+ $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng(
width: 1,
height: 1,
colorType: 6,
@@ -194,6 +196,49 @@ public function testEmbedRejectsUnsupportedRowFilters(): void
$embedder->embed($pngPath);
}
+ public function testUnfilterPngRowSupportsAverageFilterWithMultiPixelContext(): void
+ {
+ $embedder = new FilesystemPdfImageEmbedder();
+ $method = new ReflectionMethod($embedder, 'unfilterPngRow');
+
+ $decodedRow = $method->invoke(
+ $embedder,
+ 3,
+ "\x55\x5a\x5f\x64\x3c\x3c\x3c\x3c",
+ "\x0a\x14\x1e\x28\x32\x3c\x46\x50",
+ 4,
+ );
+
+ self::assertSame("\x5a\x64\x6e\x78\x82\x8c\x96\xa0", $decodedRow);
+ }
+
+ public function testUnfilterPngRowSupportsPaethFilterWithMultiPixelContext(): void
+ {
+ $embedder = new FilesystemPdfImageEmbedder();
+ $method = new ReflectionMethod($embedder, 'unfilterPngRow');
+
+ $decodedRow = $method->invoke(
+ $embedder,
+ 4,
+ "\x0a\x0a\x0a\x0a\x0a\x14\x1e\x28",
+ "\x0a\x14\x1e\x28\x3c\x46\x50\x5a",
+ 4,
+ );
+
+ self::assertSame("\x14\x1e\x28\x32\x46\x5a\x6e\x82", $decodedRow);
+ }
+
+ public function testPaethPredictorSelectsExpectedNeighborAcrossTieCases(): void
+ {
+ $embedder = new FilesystemPdfImageEmbedder();
+ $method = new ReflectionMethod($embedder, 'paethPredictor');
+
+ self::assertSame(20, $method->invoke($embedder, 20, 20, 10));
+ self::assertSame(50, $method->invoke($embedder, 50, 10, 20));
+ self::assertSame(50, $method->invoke($embedder, 10, 50, 20));
+ self::assertSame(20, $method->invoke($embedder, 10, 30, 20));
+ }
+
/**
* @return iterable
*/
@@ -237,34 +282,4 @@ private function createTemporaryFile(string $extension, string $contents): strin
return $pathWithExtension;
}
-
- private function createPng(
- int $width,
- int $height,
- int $colorType,
- string $scanlines,
- int $bitDepth = 8,
- int $interlace = 0,
- ): string {
- $ihdr = pack('NNCCCCC', $width, $height, $bitDepth, $colorType, 0, 0, $interlace);
- $idat = gzcompress($scanlines);
-
- return "\x89PNG\r\n\x1a\n"
- . $this->createChunk('IHDR', $ihdr)
- . $this->createChunk('IDAT', $idat)
- . $this->createChunk('IEND', '');
- }
-
- private function createChunk(string $type, string $data): string
- {
- $crc = crc32($type . $data);
- if ($crc < 0) {
- $crc += 4_294_967_296;
- }
-
- return pack('N', strlen($data))
- . $type
- . $data
- . pack('N', $crc);
- }
}
diff --git a/tests/Unit/Pdf/SinglePagePdfExporterTest.php b/tests/Unit/Pdf/SinglePagePdfExporterTest.php
index b36842d..25226ac 100644
--- a/tests/Unit/Pdf/SinglePagePdfExporterTest.php
+++ b/tests/Unit/Pdf/SinglePagePdfExporterTest.php
@@ -170,6 +170,144 @@ public function embed(string $source): EmbeddedPdfImage
self::assertStringContainsString('/Interpolate true', $pdf);
}
+ public function testExportPreservesAllFontAndImageReferencesInPageTreeAndTrailer(): void
+ {
+ $embedder = new class () implements PdfImageEmbedderInterface
+ {
+ /** @var list */
+ public array $sources = [];
+
+ public function embed(string $source): EmbeddedPdfImage
+ {
+ $this->sources[] = $source;
+
+ return new EmbeddedPdfImage(
+ dictionary: [
+ 'Type' => '/XObject',
+ 'Subtype' => '/Image',
+ 'Width' => 1,
+ 'Height' => 1,
+ 'ColorSpace' => '/DeviceRGB',
+ 'BitsPerComponent' => 8,
+ 'Filter' => '/FlateDecode',
+ ],
+ stream: 'RGB',
+ );
+ }
+ };
+
+ $exporter = new SinglePagePdfExporter($embedder);
+
+ $pdf = $exporter->export(new CompileResult(
+ contentStream: 'BT ET',
+ resources: [
+ 'Font' => [
+ 'F1' => [
+ 'Type' => '/Font',
+ 'Subtype' => '/Type1',
+ 'BaseFont' => '/Helvetica',
+ ],
+ 'F2' => [
+ 'Type' => '/Font',
+ 'Subtype' => '/Type1',
+ 'BaseFont' => '/Times-Roman',
+ ],
+ ],
+ 'XObject' => [
+ 'Im0' => [
+ 'Type' => '/XObject',
+ 'Subtype' => '/Image',
+ 'Source' => '/tmp/left.png',
+ 'Width' => 10.0,
+ 'Height' => 10.0,
+ ],
+ 'Im1' => [
+ 'Type' => '/XObject',
+ 'Subtype' => '/Image',
+ 'Source' => '/tmp/right.png',
+ 'Width' => 10.0,
+ 'Height' => 10.0,
+ ],
+ ],
+ ],
+ bbox: [0.0, 0.0, 40.0, 20.0],
+ ));
+
+ $expectedCatalogObject = implode("\n", [
+ '1 0 obj',
+ '<< /Type /Catalog /Pages 2 0 R >>',
+ 'endobj',
+ ]);
+ $expectedPagesObject = implode("\n", [
+ '2 0 obj',
+ '<< /Type /Pages /Count 1 /Kids [3 0 R] >>',
+ 'endobj',
+ ]);
+ $expectedPageObject = implode("\n", [
+ '3 0 obj',
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] '
+ . '/Resources << /XObject << /Fm0 5 0 R >> >> /Contents 4 0 R >>',
+ 'endobj',
+ ]);
+ $expectedFormResources = '/Resources << /Font << /F1 6 0 R /F2 7 0 R >> '
+ . '/XObject << /Im0 8 0 R /Im1 9 0 R >> >>';
+
+ self::assertSame(['/tmp/left.png', '/tmp/right.png'], $embedder->sources);
+ self::assertStringContainsString($expectedCatalogObject, $pdf);
+ self::assertStringContainsString($expectedPagesObject, $pdf);
+ self::assertStringContainsString($expectedPageObject, $pdf);
+ self::assertStringContainsString('/Type /XObject /Subtype /Form /FormType 1', $pdf);
+ self::assertStringContainsString($expectedFormResources, $pdf);
+ self::assertStringContainsString("xref\n0 10\n", $pdf);
+ self::assertStringContainsString("trailer\n<< /Size 10 /Root 1 0 R >>", $pdf);
+ }
+
+ public function testExportWrapsPageAndFormStreamsInExpectedOrder(): void
+ {
+ $exporter = new SinglePagePdfExporter(new class () implements PdfImageEmbedderInterface
+ {
+ public function embed(string $source): EmbeddedPdfImage
+ {
+ throw new \LogicException(sprintf('Image embedding should not be called for %s.', $source));
+ }
+ });
+
+ $contentStream = "BT\n/F1 10 Tf\n0 0 0 rg\n8 12 Td\n(Hello) Tj\nET";
+ $pageStream = 'q 1 0 0 1 0 0 cm /Fm0 Do Q';
+
+ $pdf = $exporter->export(new CompileResult(
+ contentStream: $contentStream,
+ resources: [
+ 'Font' => [
+ 'F1' => [
+ 'Type' => '/Font',
+ 'Subtype' => '/Type1',
+ 'BaseFont' => '/Helvetica',
+ ],
+ ],
+ ],
+ bbox: [0.0, 0.0, 40.0, 20.0],
+ ));
+
+ $expectedPageContentObject = implode("\n", [
+ '4 0 obj',
+ sprintf('<< /Length %d >>', strlen($pageStream)),
+ 'stream',
+ $pageStream,
+ 'endstream',
+ 'endobj',
+ ]);
+ $expectedFormStreamFragment = implode("\n", [
+ sprintf('/Length %d >>', strlen($contentStream)),
+ 'stream',
+ $contentStream,
+ 'endstream',
+ ]);
+
+ self::assertStringContainsString($expectedPageContentObject, $pdf);
+ self::assertStringContainsString($expectedFormStreamFragment, $pdf);
+ }
+
#[DataProvider('invalidCompileResultProvider')]
public function testExportRejectsInvalidCompileResults(CompileResult $result, string $expectedMessage): void
{
@@ -212,6 +350,15 @@ public static function invalidCompileResultProvider(): iterable
'expectedMessage' => 'CompileResult bbox must describe a positive area.',
];
+ yield 'bbox without positive height' => [
+ 'result' => new CompileResult(
+ contentStream: 'BT ET',
+ resources: ['Font' => []],
+ bbox: [0.0, 0.0, 40.0, 0.0],
+ ),
+ 'expectedMessage' => 'CompileResult bbox must describe a positive area.',
+ ];
+
yield 'font resource must be an array' => [
'result' => new CompileResult(
contentStream: 'BT ET',
diff --git a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php
index a8e52eb..e08fd80 100644
--- a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php
+++ b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php
@@ -129,9 +129,41 @@ public function testBuildContentStreamIsDirectlyUsableForImagesAndEscapedText():
self::assertStringContainsString('q 3.000000 0 0 4.000000 1.000000 2.000000 cm /Im7 Do Q', $stream);
self::assertStringContainsString('/F2 9.000000 Tf', $stream);
self::assertStringContainsString('0.6706 0.8039 0.9373 rg', $stream);
+ self::assertStringContainsString('1 0 0 1 12.000000 22.000000 Tm', $stream);
self::assertStringContainsString('(Marker \\(QA\\)) Tj', $stream);
}
+ public function testBuildContentStreamUsesAbsoluteTextMatrixForMultipleLines(): void
+ {
+ $builder = new TemplateDocumentBuilder();
+
+ $stream = $builder->buildContentStream(new LayoutResult(
+ lines: [
+ new LayoutLine(
+ text: 'First line',
+ x: 18.0,
+ y: 72.0,
+ fontSize: 10.0,
+ fontAlias: 'F1',
+ rgbColor: '#000000',
+ ),
+ new LayoutLine(
+ text: 'Second line',
+ x: 120.0,
+ y: 40.0,
+ fontSize: 10.0,
+ fontAlias: 'F1',
+ rgbColor: '#000000',
+ ),
+ ],
+ images: [],
+ ));
+
+ self::assertStringContainsString('1 0 0 1 18.000000 72.000000 Tm', $stream);
+ self::assertStringContainsString('1 0 0 1 120.000000 40.000000 Tm', $stream);
+ self::assertStringNotContainsString(' Td', $stream);
+ }
+
public function testBuildResourcesExposesImageDictionaryAndCustomFontsFromDerivedBuilder(): void
{
$builder = (new TemplateDocumentBuilder())->withFontResources([
diff --git a/tests/Unit/XObjectTemplateCompilerTest.php b/tests/Unit/XObjectTemplateCompilerTest.php
index d838c1c..ad3c0f8 100644
--- a/tests/Unit/XObjectTemplateCompilerTest.php
+++ b/tests/Unit/XObjectTemplateCompilerTest.php
@@ -65,7 +65,7 @@ public static function htmlProvider(): iterable
yield 'margin and padding affect position' => [
'html' => 'Offset Text
',
- 'expectedSnippet' => '20.000000 48.000000 Td',
+ 'expectedSnippet' => '1 0 0 1 20.000000 48.000000 Tm',
];
}