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' + . '
' + . '
admin
' + . '
' + . '
' + . '
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( + '
' + . '
Title
' + . '
', + ); + + 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', ]; }