diff --git a/README.md b/README.md index 7d2ec64..190be8a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,28 @@ Using a uniform placement scale keeps text, images, spacing, and line breaks vis Recompiling only to emulate a proportional resize is usually the wrong integration point for this package. +For consumers that want a small helper instead of recalculating the matrix manually, the package also +ships `LibreSign\XObjectTemplate\Integration\XObjectPlacementCalculator` and +`LibreSign\XObjectTemplate\Integration\XObjectPlacement`. + +```php +use LibreSign\XObjectTemplate\Integration\XObjectPlacementCalculator; + +$placement = (new XObjectPlacementCalculator())->fromWidth($result, 175.0, 36.0, 72.0); + +$pdfCommand = $placement->toPdfCommand('Fm0'); +// q 0.729167 0 0 0.729167 36.000000 72.000000 cm /Fm0 Do Q +``` + +### Optional context interpolation + +If the caller passes `CompileRequest::context`, the compiler can interpolate simple `{{ key }}` +placeholders before parsing the HTML subset. + +- Values are HTML-escaped before insertion +- Unknown placeholders are left untouched +- Twig users can keep rendering HTML upstream and skip this feature entirely + ## Supported HTML/CSS subset ### HTML @@ -101,8 +123,9 @@ package. ### CSS used by the renderer -- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color` -- Layout: `margin`, `padding`, `text-align`, `width`, `height` +- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`, `text-align`, `hyphens`, `white-space` +- Layout: `margin`, `padding`, `width`, `height`, `overflow`, `text-overflow` +- Vector box styling: `background-color`, `border-color`, `border-width`, `border-radius` - 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 `%` @@ -112,6 +135,10 @@ package. ### Rendering notes - Font family mapping currently targets the built-in Helvetica, Times, and Courier aliases used by the generated PDF resources +- Text alignment uses measured widths for left, center, right, and basic justified output (`Tw` word spacing) +- Hyphenation supports a small deterministic subset: `hyphens:auto`, `hyphens:manual` with soft hyphens, and `hyphens:none` +- Overflow clipping uses PDF clipping paths; `text-overflow:ellipsis` applies when hidden overflow truncates visible text +- Backgrounds and borders are emitted as vector rectangles, including rounded corners - 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 diff --git a/src/Html/HtmlContextInterpolator.php b/src/Html/HtmlContextInterpolator.php new file mode 100644 index 0000000..4ec4759 --- /dev/null +++ b/src/Html/HtmlContextInterpolator.php @@ -0,0 +1,53 @@ + $context + */ + public function interpolate(string $html, array $context): string + { + if ($context === []) { + return $html; + } + + if (!str_contains($html, '{{')) { + return $html; + } + + $interpolated = preg_replace_callback( + '/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/', + function (array $matches) use ($context): string { + $key = $matches[1] ?? ''; + if ($key === '' || !array_key_exists($key, $context)) { + return $matches[0]; + } + + return htmlspecialchars( + $this->normalizeScalar($context[$key]), + ENT_QUOTES | ENT_SUBSTITUTE, + 'UTF-8', + ); + }, + $html, + ); + + return $interpolated ?? $html; + } + + private function normalizeScalar(string|int|float|bool $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return strval($value); + } +} diff --git a/src/Html/SubsetHtmlParser.php b/src/Html/SubsetHtmlParser.php index dbfb4f7..81e9143 100644 --- a/src/Html/SubsetHtmlParser.php +++ b/src/Html/SubsetHtmlParser.php @@ -21,8 +21,10 @@ final class SubsetHtmlParser 'font-family' => true, 'font-size' => true, 'font-weight' => true, + 'hyphens' => true, 'line-height' => true, 'text-align' => true, + 'white-space' => true, ]; /** @var array */ @@ -158,20 +160,16 @@ private function filterInheritableStyle(string $style): string } $resolvedDeclarations = []; + $matches = []; - foreach (explode(';', $style) as $declaration) { - $trimmedDeclaration = trim($declaration); - if ($trimmedDeclaration === '') { - continue; - } - - $segments = explode(':', $trimmedDeclaration, 2); - if (count($segments) !== 2) { - continue; - } + preg_match_all('/(?:^|;)\s*([A-Za-z-]+)\s*:\s*([^;]+)\s*/', $style, $matches, PREG_SET_ORDER); + if ($matches === []) { + return ''; + } - $property = strtolower(trim($segments[0])); - $value = trim($segments[1]); + foreach ($matches as $match) { + $property = strtolower($match[1]); + $value = trim($match[2]); if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) { continue; } @@ -179,6 +177,6 @@ private function filterInheritableStyle(string $style): string $resolvedDeclarations[$property] = $property . ':' . $value; } - return implode(';', array_values($resolvedDeclarations)); + return implode(';', $resolvedDeclarations); } } diff --git a/src/Integration/XObjectPlacement.php b/src/Integration/XObjectPlacement.php new file mode 100644 index 0000000..97c1885 --- /dev/null +++ b/src/Integration/XObjectPlacement.php @@ -0,0 +1,50 @@ +formatNumber($this->scaleX), + $this->formatNumber($this->scaleY), + $this->formatNumber($this->translateX), + $this->formatNumber($this->translateY), + $normalizedAlias, + ); + } + + private function formatNumber(float $value): string + { + $formatted = rtrim(rtrim(sprintf('%.6F', $value), '0'), '.'); + if ($formatted === '' || $formatted === '-0') { + return '0'; + } + + return $formatted; + } +} diff --git a/src/Integration/XObjectPlacementCalculator.php b/src/Integration/XObjectPlacementCalculator.php new file mode 100644 index 0000000..41a0093 --- /dev/null +++ b/src/Integration/XObjectPlacementCalculator.php @@ -0,0 +1,98 @@ +resolveBoundingBox($result); + $scale = $targetWidth / $baseWidth; + + return new XObjectPlacement( + scaleX: $scale, + scaleY: $scale, + width: $baseWidth * $scale, + height: $baseHeight * $scale, + translateX: $x - ($minX * $scale), + translateY: $y - ($minY * $scale), + ); + } + + public function fromHeight( + CompileResult $result, + float $targetHeight, + float $x = 0.0, + float $y = 0.0, + ): XObjectPlacement { + if ($targetHeight <= 0.0) { + throw new InvalidArgumentException('Placement target height must be greater than zero.'); + } + + [$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result); + $scale = $targetHeight / $baseHeight; + + return new XObjectPlacement( + scaleX: $scale, + scaleY: $scale, + width: $baseWidth * $scale, + height: $baseHeight * $scale, + translateX: $x - ($minX * $scale), + translateY: $y - ($minY * $scale), + ); + } + + public function fromScale( + CompileResult $result, + float $scale, + float $x = 0.0, + float $y = 0.0, + ): XObjectPlacement { + if ($scale <= 0.0) { + throw new InvalidArgumentException('Placement scale must be greater than zero.'); + } + + [$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result); + + return new XObjectPlacement( + scaleX: $scale, + scaleY: $scale, + width: $baseWidth * $scale, + height: $baseHeight * $scale, + translateX: $x - ($minX * $scale), + translateY: $y - ($minY * $scale), + ); + } + + /** + * @return array{0: float, 1: float, 2: float, 3: float} + */ + private function resolveBoundingBox(CompileResult $result): array + { + [$minX, $minY, $maxX, $maxY] = $result->bbox; + $width = $maxX - $minX; + $height = $maxY - $minY; + + if ($width <= 0.0 || $height <= 0.0) { + throw new InvalidArgumentException('CompileResult bbox must describe a positive area.'); + } + + return [$minX, $minY, $width, $height]; + } +} diff --git a/src/Layout/LayoutDecoration.php b/src/Layout/LayoutDecoration.php new file mode 100644 index 0000000..86c6784 --- /dev/null +++ b/src/Layout/LayoutDecoration.php @@ -0,0 +1,23 @@ + $lines * @param list $images + * @param list $decorations */ public function __construct( public array $lines, public array $images, + public array $decorations = [], ) { } } diff --git a/src/Layout/LayoutStyleResolver.php b/src/Layout/LayoutStyleResolver.php index 55b6be4..840bfe7 100644 --- a/src/Layout/LayoutStyleResolver.php +++ b/src/Layout/LayoutStyleResolver.php @@ -19,7 +19,7 @@ public function styleValue(StyleMap $style, string $property, string $default): public function toPoints(string $value): float { $normalized = strtolower($value); - $number = (float) preg_replace('/[^0-9.\-]/', '', $normalized); + $number = $this->extractNumericValue($normalized); if (str_ends_with($normalized, 'px')) { return $number * 0.75; } @@ -29,13 +29,13 @@ public function toPoints(string $value): float public function resolveRelativeDimension(string $value, float $reference): float { - $normalized = strtolower(trim($value)); + $normalized = trim($value); if ($normalized === '') { return 0.0; } if (str_ends_with($normalized, '%')) { - $number = (float) preg_replace('/[^0-9.\-]/', '', $normalized); + $number = $this->extractNumericValue($normalized); return $reference * ($number / 100.0); } @@ -118,7 +118,7 @@ public function resolveFontAlias(string $fontFamily, string $fontWeight): string public function isAbsolutelyPositioned(StyleMap $style): bool { - return strtolower(trim($this->styleValue($style, 'position', ''))) === 'absolute'; + return strtolower($this->styleValue($style, 'position', '')) === 'absolute'; } /** @@ -148,4 +148,9 @@ private function isBoldWeight(string $fontWeight): bool return false; } + + private function extractNumericValue(string $value): float + { + return (float) preg_replace('/[^0-9.\-]/', '', $value); + } } diff --git a/src/Layout/LinearLayoutEngine.php b/src/Layout/LinearLayoutEngine.php index 67a3b9e..61fa90f 100644 --- a/src/Layout/LinearLayoutEngine.php +++ b/src/Layout/LinearLayoutEngine.php @@ -10,17 +10,20 @@ use LibreSign\XObjectTemplate\Css\InlineStyleParser; use LibreSign\XObjectTemplate\Css\StyleMap; use LibreSign\XObjectTemplate\Html\Node; +use LibreSign\XObjectTemplate\Pdf\StandardFontMetrics; final readonly class LinearLayoutEngine { private InlineStyleParser $styleParser; private LayoutStyleResolver $styleResolver; private StructuredLayoutRenderer $structuredRenderer; + private StandardFontMetrics $fontMetrics; public function __construct(?InlineStyleParser $styleParser = null) { $this->styleParser = $styleParser ?? new InlineStyleParser(); $this->styleResolver = new LayoutStyleResolver(); + $this->fontMetrics = new StandardFontMetrics(); $this->structuredRenderer = new StructuredLayoutRenderer($this->styleParser, $this->styleResolver); } @@ -64,10 +67,12 @@ private function layoutLinear(array $nodes, float $width, float $height): Layout $boxWidth = $this->toPoints($this->styleValue($style, 'width', '0')); if ($boxWidth <= 0) { - $boxWidth = max($width - $margin['left'] - $margin['right'] - $padding['left'] - $padding['right'], 0); + $boxWidth = max( + $width - $margin['left'] - $margin['right'] - $padding['left'] - $padding['right'], + $this->toPoints('0'), + ); } $leftBase = $margin['left'] + $padding['left']; - $rightBase = $leftBase + $boxWidth; if ($node->tag === 'img') { $imgWidth = $this->toPoints($this->styleValue($style, 'width', '32')); @@ -103,10 +108,11 @@ private function layoutLinear(array $nodes, float $width, float $height): Layout } $align = strtolower($this->styleValue($style, 'text-align', 'left')); + $textWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $text); $lineX = match ($align) { - 'center' => $leftBase + ($boxWidth / 2.0), - 'right' => max($rightBase - 8.0, 0), - default => $leftBase + 8.0, + 'center' => $leftBase + max(($boxWidth - $textWidth) / 2.0, 0.0), + 'right' => $leftBase + max($boxWidth - $textWidth, 0.0), + default => $leftBase, }; $lines[] = new LayoutLine( @@ -141,7 +147,7 @@ private function requiresStructuredLayout(array $nodes): bool private function containsStructuredLayoutRules(StyleMap $style): bool { - if (strtolower(trim($this->styleValue($style, 'display', ''))) === 'flex') { + if (strtolower($this->styleValue($style, 'display', '')) === 'flex') { return true; } @@ -149,20 +155,36 @@ private function containsStructuredLayoutRules(StyleMap $style): bool return true; } - foreach (['width', 'height', 'left', 'top', 'right', 'bottom', 'gap'] as $property) { + foreach ( + [ + 'background-color', + 'border-color', + 'border-width', + 'border-radius', + 'overflow', + 'text-overflow', + 'hyphens', + 'white-space', + ] as $property + ) { + if ($this->styleValue($style, $property, '') !== '') { + 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)) { + if (strtolower($this->styleValue($style, 'text-align', '')) === 'justify') { return true; } - $alignItems = strtolower(trim($this->styleValue($style, 'align-items', ''))); - - return in_array($alignItems, ['center', 'flex-end'], true); + return false; } private function styleValue(StyleMap $style, string $property, string $default): string diff --git a/src/Layout/StructuredBoxResolver.php b/src/Layout/StructuredBoxResolver.php index fd00492..ba1b7ba 100644 --- a/src/Layout/StructuredBoxResolver.php +++ b/src/Layout/StructuredBoxResolver.php @@ -102,20 +102,36 @@ public function createChildContainer(array $contentBox, float $consumedHeight): 'x' => $contentBox['x'], 'y' => $contentBox['y'] + $consumedHeight, 'width' => $contentBox['width'], - 'height' => $contentBox['height'] > 0.0 - ? max($contentBox['height'] - $consumedHeight, 0.0) - : 0.0, + 'height' => max($contentBox['height'] - $consumedHeight, $this->styleResolver->toPoints('0')), ]; } /** * @param array{top: float, right: float, bottom: float, left: float} $padding */ - public function resolveAutoContainerHeight(float $resolvedHeight, array $padding, float $contentHeight): float - { + public function resolveAutoContainerHeight( + float $resolvedHeight, + array $padding, + float $contentHeight, + ): float { $autoHeight = $padding['top'] + $contentHeight + $padding['bottom']; - return $resolvedHeight > 0.0 ? max($resolvedHeight, $autoHeight) : $autoHeight; + return max($resolvedHeight, $autoHeight); + } + + /** + * @param array{top: float, right: float, bottom: float, left: float} $padding + */ + public function resolveFixedContainerHeight( + float $resolvedHeight, + array $padding, + float $contentHeight, + ): float { + if ($resolvedHeight > 0.0) { + return $resolvedHeight; + } + + return $padding['top'] + $contentHeight + $padding['bottom']; } /** diff --git a/src/Layout/StructuredFlexLayoutPlanner.php b/src/Layout/StructuredFlexLayoutPlanner.php index eac363f..05e03b0 100644 --- a/src/Layout/StructuredFlexLayoutPlanner.php +++ b/src/Layout/StructuredFlexLayoutPlanner.php @@ -9,12 +9,15 @@ use LibreSign\XObjectTemplate\Css\StyleMap; use LibreSign\XObjectTemplate\Html\Node; +use LibreSign\XObjectTemplate\Pdf\StandardFontMetrics; /** @internal */ final readonly class StructuredFlexLayoutPlanner { - public function __construct(private LayoutStyleResolver $styleResolver) - { + public function __construct( + private LayoutStyleResolver $styleResolver, + private StandardFontMetrics $fontMetrics = new StandardFontMetrics(), + ) { } public function normalizeDirection(string $direction): string @@ -39,12 +42,24 @@ public function resolveGap(StyleMap $style, string $direction, array $contentBox */ public function measureItem(Node $node, StyleMap $style, array $container): array { + $text = trim($node->text); $width = $this->styleResolver->resolveRelativeDimension( $this->styleResolver->styleValue($style, 'width', ''), $container['width'], ); if ($width <= 0.0) { - $width = $node->tag === 'img' ? 32.0 : 0.0; + $width = match (true) { + $node->tag === 'img' => 32.0, + $text !== '' => $this->fontMetrics->measureString( + $this->styleResolver->resolveFontAlias( + $this->styleResolver->styleValue($style, 'font-family', 'helvetica'), + $this->styleResolver->styleValue($style, 'font-weight', 'normal'), + ), + $this->styleResolver->toPoints($this->styleResolver->styleValue($style, 'font-size', '10')), + $text, + ), + default => 0.0, + }; } $height = $this->styleResolver->resolveRelativeDimension( @@ -54,7 +69,7 @@ public function measureItem(Node $node, StyleMap $style, array $container): arra if ($height <= 0.0) { $height = match (true) { $node->tag === 'img' => 32.0, - trim($node->text) !== '' => $this->styleResolver->resolveLineHeight( + $text !== '' => $this->styleResolver->resolveLineHeight( $style, $this->styleResolver->toPoints($this->styleResolver->styleValue($style, 'font-size', '10')), ), diff --git a/src/Layout/StructuredLayoutRenderer.php b/src/Layout/StructuredLayoutRenderer.php index d4b25e3..77e1886 100644 --- a/src/Layout/StructuredLayoutRenderer.php +++ b/src/Layout/StructuredLayoutRenderer.php @@ -9,13 +9,14 @@ use LibreSign\XObjectTemplate\Css\InlineStyleParser; use LibreSign\XObjectTemplate\Css\StyleMap; -use LibreSign\XObjectTemplate\Html\Node; +use LibreSign\XObjectTemplate\Pdf\StandardFontMetrics; /** @internal */ final readonly class StructuredLayoutRenderer { private StructuredBoxResolver $boxResolver; private StructuredFlexLayoutPlanner $flexPlanner; + private TextBoxLayouter $textLayouter; public function __construct( private InlineStyleParser $styleParser, @@ -23,39 +24,46 @@ public function __construct( ) { $this->boxResolver = new StructuredBoxResolver($styleResolver); $this->flexPlanner = new StructuredFlexLayoutPlanner($styleResolver); + $this->textLayouter = new TextBoxLayouter($styleResolver, new StandardFontMetrics()); } /** - * @param list $nodes + * @param list<\LibreSign\XObjectTemplate\Html\Node> $nodes */ public function layout(array $nodes, float $width, float $height): LayoutResult { $lines = []; $images = []; + $decorations = []; $imageCount = 0; + $zero = $this->styleResolver->toPoints('0'); $this->layoutNodes( nodes: $nodes, container: [ 'x' => 0.0, 'y' => 0.0, - 'width' => max($width, 0.0), - 'height' => max($height, 0.0), + 'width' => max($width, $zero), + 'height' => max($height, $zero), ], - canvasHeight: max($height, 0.0), + canvasHeight: max($height, $zero), lines: $lines, images: $images, + decorations: $decorations, imageCount: $imageCount, + activeClipBox: null, ); - return new LayoutResult(lines: $lines, images: $images); + return new LayoutResult(lines: $lines, images: $images, decorations: $decorations); } /** - * @param list $nodes + * @param list<\LibreSign\XObjectTemplate\Html\Node> $nodes * @param array{x: float, y: float, width: float, height: float} $container * @param list $lines * @param list $images + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function layoutNodes( array $nodes, @@ -63,15 +71,28 @@ private function layoutNodes( float $canvasHeight, array &$lines, array &$images, + array &$decorations, int &$imageCount, + ?array $activeClipBox, ): float { $consumedHeight = 0.0; + $zero = $this->styleResolver->toPoints('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); + $this->layoutAbsoluteNode( + $node, + $style, + $container, + $canvasHeight, + $lines, + $images, + $decorations, + $imageCount, + $activeClipBox, + ); continue; } @@ -79,7 +100,7 @@ private function layoutNodes( 'x' => $container['x'], 'y' => $container['y'] + $consumedHeight, 'width' => $container['width'], - 'height' => max($container['height'] - $consumedHeight, 0.0), + 'height' => max($container['height'] - $consumedHeight, $zero), ]; $consumedHeight += $this->layoutFlowNode( @@ -89,7 +110,9 @@ private function layoutNodes( $canvasHeight, $lines, $images, + $decorations, $imageCount, + $activeClipBox, ); } @@ -100,15 +123,19 @@ private function layoutNodes( * @param array{x: float, y: float, width: float, height: float} $availableBox * @param list $lines * @param list $images + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function layoutFlowNode( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, StyleMap $style, array $availableBox, float $canvasHeight, array &$lines, array &$images, + array &$decorations, int &$imageCount, + ?array $activeClipBox, ): float { ['margin' => $margin, 'box' => $box] = $this->boxResolver->resolveFlowPlacement($node, $style, $availableBox); @@ -119,7 +146,9 @@ private function layoutFlowNode( $canvasHeight, $lines, $images, + $decorations, $imageCount, + $activeClipBox, ); return $margin['top'] + $renderedHeight + $margin['bottom']; @@ -129,15 +158,19 @@ private function layoutFlowNode( * @param array{x: float, y: float, width: float, height: float} $container * @param list $lines * @param list $images + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function layoutAbsoluteNode( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, StyleMap $style, array $container, float $canvasHeight, array &$lines, array &$images, + array &$decorations, int &$imageCount, + ?array $activeClipBox, ): void { $this->renderResolvedNode( $node, @@ -146,7 +179,9 @@ private function layoutAbsoluteNode( $canvasHeight, $lines, $images, + $decorations, $imageCount, + $activeClipBox, ); } @@ -154,55 +189,77 @@ private function layoutAbsoluteNode( * @param array{x: float, y: float, width: float, height: float} $box * @param list $lines * @param list $images + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function renderResolvedNode( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, StyleMap $style, array $box, float $canvasHeight, array &$lines, array &$images, + array &$decorations, int &$imageCount, + ?array $activeClipBox, ): float { if ($node->tag === 'br') { return 12.0; } if ($node->tag === 'img') { - return $this->renderImage($node, $box, $canvasHeight, $images, $imageCount); + return $this->renderImage($node, $box, $canvasHeight, $images, $imageCount, $activeClipBox); } - 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); + if (strtolower($this->styleResolver->styleValue($style, 'display', '')) === 'flex') { + return $this->renderFlexContainer( + $node, + $style, + $box, + $canvasHeight, + $lines, + $images, + $decorations, + $imageCount, + $activeClipBox, + ); } - return $this->renderBlockContainer($node, $style, $box, $canvasHeight, $lines, $images, $imageCount); + return $this->renderBlockContainer( + $node, + $style, + $box, + $canvasHeight, + $lines, + $images, + $decorations, + $imageCount, + $activeClipBox, + ); } /** * @param array{x: float, y: float, width: float, height: float} $box * @param list $lines * @param list $images + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function renderBlockContainer( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, StyleMap $style, array $box, float $canvasHeight, array &$lines, array &$images, + array &$decorations, int &$imageCount, + ?array $activeClipBox, ): float { ['padding' => $padding, 'contentBox' => $contentBox] = $this->boxResolver->resolveContentBox($style, $box); + $localClipBox = $this->resolveClipBox($style, $box, $activeClipBox); - $contentHeight = 0.0; - if (trim($node->text) !== '') { - $contentHeight += $this->renderTextLine($node, $style, $contentBox, $canvasHeight, $lines); - } + $contentHeight = $this->renderTextLine($node, $style, $contentBox, $canvasHeight, $lines, $localClipBox); if ($node->children !== []) { $contentHeight += $this->layoutNodes( @@ -211,34 +268,47 @@ private function renderBlockContainer( $canvasHeight, $lines, $images, + $decorations, $imageCount, + $localClipBox, ); } - return $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, $contentHeight); + $renderedHeight = $localClipBox === null + ? $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, $contentHeight) + : $this->boxResolver->resolveFixedContainerHeight($box['height'], $padding, $contentHeight); + + $this->appendDecoration($style, $box, $renderedHeight, $canvasHeight, $decorations); + + return $renderedHeight; } /** * @param array{x: float, y: float, width: float, height: float} $box * @param list $lines * @param list $images + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function renderFlexContainer( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, StyleMap $style, array $box, float $canvasHeight, array &$lines, array &$images, + array &$decorations, int &$imageCount, + ?array $activeClipBox, ): float { ['padding' => $padding, 'contentBox' => $contentBox] = $this->boxResolver->resolveContentBox($style, $box); + $localClipBox = $this->resolveClipBox($style, $box, $activeClipBox); $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'))); + $justifyContent = strtolower($this->styleResolver->styleValue($style, 'justify-content', 'flex-start')); + $alignItems = strtolower($this->styleResolver->styleValue($style, 'align-items', 'flex-start')); $gap = $this->flexPlanner->resolveGap($style, $direction, $contentBox); $items = $this->collectFlexItems( @@ -248,11 +318,18 @@ private function renderFlexContainer( $canvasHeight, $lines, $images, + $decorations, $imageCount, + $localClipBox, ); if ($items === []) { - return $box['height'] > 0.0 ? $box['height'] : ($padding['top'] + $padding['bottom']); + $renderedHeight = $localClipBox === null + ? $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, 0.0) + : $this->boxResolver->resolveFixedContainerHeight($box['height'], $padding, 0.0); + $this->appendDecoration($style, $box, $renderedHeight, $canvasHeight, $decorations); + + return $renderedHeight; } $metrics = $this->flexPlanner->calculateMetrics($items, $direction, $justifyContent, $gap, $contentBox); @@ -273,74 +350,59 @@ private function renderFlexContainer( $canvasHeight, $lines, $images, + $decorations, $imageCount, + $localClipBox, ); $cursor += $this->flexPlanner->advanceCursor($item, $direction, $metrics['gap']); } - $autoHeight = $direction === 'row' - ? $padding['top'] + $metrics['crossAxisSize'] + $padding['bottom'] - : $padding['top'] + $metrics['totalMainAxisSize'] + $padding['bottom']; + $contentHeight = $direction === 'row' ? $metrics['crossAxisSize'] : $metrics['totalMainAxisSize']; + $renderedHeight = $localClipBox === null + ? $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, $contentHeight) + : $this->boxResolver->resolveFixedContainerHeight($box['height'], $padding, $contentHeight); + $this->appendDecoration($style, $box, $renderedHeight, $canvasHeight, $decorations); - return $box['height'] > 0.0 ? max($box['height'], $autoHeight) : $autoHeight; + return $renderedHeight; } /** * @param array{x: float, y: float, width: float, height: float} $box * @param list $lines + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function renderTextLine( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, StyleMap $style, array $box, float $canvasHeight, array &$lines, + ?array $activeClipBox, ): float { - $text = trim($node->text); - if ($text === '') { - return 0.0; + $result = $this->textLayouter->layout($node->text, $style, $box, $canvasHeight, $activeClipBox); + foreach ($result['lines'] as $line) { + $lines[] = $line; } - $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; + return $result['consumedHeight']; } /** * @param array{x: float, y: float, width: float, height: float} $box * @param list $images + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox */ private function renderImage( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, array $box, float $canvasHeight, array &$images, int &$imageCount, + ?array $activeClipBox, ): float { - $width = $box['width'] > 0.0 ? $box['width'] : 32.0; - $height = $box['height'] > 0.0 ? $box['height'] : 32.0; + $width = $box['width']; + $height = $box['height']; $images[] = new LayoutImage( alias: 'Im' . $imageCount, @@ -349,6 +411,7 @@ private function renderImage( width: $width, height: $height, source: $node->attributes['src'] ?? '', + clipBox: $this->toPdfClipBox($activeClipBox, $canvasHeight), ); ++$imageCount; @@ -360,23 +423,41 @@ private function renderImage( * @param array{x: float, y: float, width: float, height: float} $contentBox * @param list $lines * @param list $images - * @return list + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox + * @return list */ private function collectFlexItems( - Node $node, + \LibreSign\XObjectTemplate\Html\Node $node, array $box, array $contentBox, float $canvasHeight, array &$lines, array &$images, + array &$decorations, int &$imageCount, + ?array $activeClipBox, ): 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); + $this->layoutAbsoluteNode( + $child, + $childStyle, + $box, + $canvasHeight, + $lines, + $images, + $decorations, + $imageCount, + $activeClipBox, + ); continue; } @@ -389,4 +470,103 @@ private function collectFlexItems( return $items; } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + * @param list $decorations + */ + private function appendDecoration( + StyleMap $style, + array $box, + float $renderedHeight, + float $canvasHeight, + array &$decorations, + ): void { + $fillColor = $this->styleResolver->styleValue($style, 'background-color', ''); + $strokeColor = $this->styleResolver->styleValue($style, 'border-color', ''); + $strokeWidth = $this->styleResolver->toPoints( + $this->styleResolver->styleValue($style, 'border-width', '0'), + ); + $borderRadius = $this->styleResolver->toPoints($this->styleResolver->styleValue($style, 'border-radius', '0')); + + $hasFill = $fillColor !== ''; + $hasVisibleStroke = $strokeColor !== '' && $strokeWidth > 0.0; + + if (!$hasFill && !$hasVisibleStroke) { + return; + } + + $height = max($renderedHeight, $box['height']); + if ($box['width'] <= 0.0 || $height <= 0.0) { + return; + } + + $decorations[] = new LayoutDecoration( + x: $box['x'], + y: max($canvasHeight - ($box['y'] + $height), 0.0), + width: $box['width'], + height: $height, + fillColor: $hasFill ? $fillColor : null, + strokeColor: $strokeColor !== '' ? $strokeColor : null, + strokeWidth: $strokeWidth, + borderRadius: $borderRadius, + ); + } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + * @param array{x: float, y: float, width: float, height: float}|null $activeClipBox + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function resolveClipBox(StyleMap $style, array $box, ?array $activeClipBox): ?array + { + $currentClipBox = $activeClipBox; + if ( + strtolower($this->styleResolver->styleValue($style, 'overflow', 'visible')) === 'hidden' + && $box['width'] > 0.0 + && $box['height'] > 0.0 + ) { + $currentClipBox = $activeClipBox === null ? $box : $this->intersectBoxes($activeClipBox, $box); + } + + return $currentClipBox; + } + + /** + * @param array{x: float, y: float, width: float, height: float} $first + * @param array{x: float, y: float, width: float, height: float} $second + * @return array{x: float, y: float, width: float, height: float} + */ + private function intersectBoxes(array $first, array $second): array + { + $x = max($first['x'], $second['x']); + $y = max($first['y'], $second['y']); + $right = min($first['x'] + $first['width'], $second['x'] + $second['width']); + $bottom = min($first['y'] + $first['height'], $second['y'] + $second['height']); + + return [ + 'x' => $x, + 'y' => $y, + 'width' => max($right - $x, 0.0), + 'height' => max($bottom - $y, 0.0), + ]; + } + + /** + * @param array{x: float, y: float, width: float, height: float}|null $clipBox + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function toPdfClipBox(?array $clipBox, float $canvasHeight): ?array + { + if ($clipBox === null) { + return null; + } + + return [ + 'x' => $clipBox['x'], + 'y' => max($canvasHeight - ($clipBox['y'] + $clipBox['height']), 0.0), + 'width' => $clipBox['width'], + 'height' => $clipBox['height'], + ]; + } } diff --git a/src/Layout/TextBoxLayouter.php b/src/Layout/TextBoxLayouter.php new file mode 100644 index 0000000..128dd4a --- /dev/null +++ b/src/Layout/TextBoxLayouter.php @@ -0,0 +1,338 @@ +lineBreaker = new TextLineBreaker($fontMetrics); + $this->overflowTruncator = new TextOverflowTruncator($fontMetrics); + } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + * @param array{x: float, y: float, width: float, height: float}|null $clipBox + * @return array{lines: list, consumedHeight: float, truncated: bool} + */ + public function layout( + string $text, + StyleMap $style, + array $box, + float $canvasHeight, + ?array $clipBox = null, + ): array { + $text = trim($text); + if ($text === '') { + return ['lines' => [], 'consumedHeight' => 0.0, 'truncated' => false]; + } + + $settings = $this->resolveSettings($style); + $lines = $this->lineBreaker->wrap( + $text, + $box['width'], + $settings['fontAlias'], + $settings['fontSize'], + $settings['hyphens'], + $settings['whiteSpace'], + ); + ['lines' => $lines, 'truncated' => $truncated, 'clipBox' => $effectiveClipBox] = + $this->applyOverflowConstraints($lines, $box, $clipBox, $settings); + + return $this->buildLayoutResult( + $lines, + $box, + $canvasHeight, + $settings, + $truncated, + $effectiveClipBox, + ); + } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + * @return array{ + * fontSize: float, + * lineHeight: float, + * fontAlias: string, + * align: string, + * overflow: string, + * textOverflow: string, + * hyphens: string, + * whiteSpace: string, + * color: string + * } + */ + private function resolveSettings(StyleMap $style): array + { + $fontSize = $this->styleResolver->toPoints($this->styleResolver->styleValue($style, 'font-size', '10')); + + return [ + 'fontSize' => $fontSize, + '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')), + 'overflow' => strtolower($this->styleResolver->styleValue($style, 'overflow', 'visible')), + 'textOverflow' => strtolower($this->styleResolver->styleValue($style, 'text-overflow', 'clip')), + 'hyphens' => strtolower($this->styleResolver->styleValue($style, 'hyphens', 'none')), + 'whiteSpace' => strtolower($this->styleResolver->styleValue($style, 'white-space', 'normal')), + 'color' => $this->styleResolver->styleValue($style, 'color', '#000000'), + ]; + } + + /** + * @param list $lines + * @param array{x: float, y: float, width: float, height: float} $box + * @param array{x: float, y: float, width: float, height: float}|null $clipBox + * @param array{ + * fontSize: float, + * lineHeight: float, + * fontAlias: string, + * align: string, + * overflow: string, + * textOverflow: string, + * hyphens: string, + * whiteSpace: string, + * color: string + * } $settings + * @return array{ + * lines: list, + * truncated: bool, + * clipBox: array{x: float, y: float, width: float, height: float}|null + * } + */ + private function applyOverflowConstraints( + array $lines, + array $box, + ?array $clipBox, + array $settings, + ): array { + if ($settings['overflow'] !== 'hidden' || $box['height'] <= 0.0) { + return ['lines' => $lines, 'truncated' => false, 'clipBox' => $clipBox]; + } + + $effectiveClipBox = $clipBox ?? $box; + $maxVisibleLines = $this->resolveMaxVisibleLines($box['height'], $settings['lineHeight']); + $truncated = count($lines) > $maxVisibleLines; + if ($truncated) { + $lines = array_slice($lines, 0, $maxVisibleLines); + if ($settings['textOverflow'] === 'ellipsis' && $lines !== []) { + $lastIndex = count($lines) - 1; + $lines[$lastIndex] = $this->overflowTruncator->forceEllipsis( + $lines[$lastIndex], + $box['width'], + $settings['fontAlias'], + $settings['fontSize'], + ); + } + } elseif ($lines !== [] && $this->shouldApplyEllipsis($lines, $box['width'], $settings)) { + $lastIndex = count($lines) - 1; + $lines[$lastIndex] = $this->overflowTruncator->truncateWithEllipsis( + $lines[$lastIndex], + $box['width'], + $settings['fontAlias'], + $settings['fontSize'], + ); + $truncated = true; + } + + return ['lines' => $lines, 'truncated' => $truncated, 'clipBox' => $effectiveClipBox]; + } + + /** + * @param list $lines + * @param array{ + * fontSize: float, + * lineHeight: float, + * fontAlias: string, + * align: string, + * overflow: string, + * textOverflow: string, + * hyphens: string, + * whiteSpace: string, + * color: string + * } $settings + */ + private function shouldApplyEllipsis( + array $lines, + float $boxWidth, + array $settings, + ): bool { + if ($settings['textOverflow'] !== 'ellipsis' || $lines === []) { + return false; + } + + $lastLine = $lines[count($lines) - 1]; + + return $this->fontMetrics->measureString($settings['fontAlias'], $settings['fontSize'], $lastLine) > $boxWidth; + } + + /** + * @param list $lines + * @param array{x: float, y: float, width: float, height: float} $box + * @param array{ + * fontSize: float, + * lineHeight: float, + * fontAlias: string, + * align: string, + * overflow: string, + * textOverflow: string, + * hyphens: string, + * whiteSpace: string, + * color: string + * } $settings + * @param array{x: float, y: float, width: float, height: float}|null $clipBox + * @return array{lines: list, consumedHeight: float, truncated: bool} + */ + private function buildLayoutResult( + array $lines, + array $box, + float $canvasHeight, + array $settings, + bool $truncated, + ?array $clipBox, + ): array { + $pdfClipBox = $this->toPdfClipBox($clipBox, $canvasHeight); + $layoutLines = []; + $lineCount = count($lines); + + foreach ($lines as $index => $lineText) { + $layoutLines[] = $this->buildLayoutLine( + $lineText, + $index, + $lineCount, + $box, + $canvasHeight, + $settings, + $pdfClipBox, + ); + } + + return [ + 'lines' => $layoutLines, + 'consumedHeight' => $lineCount * $settings['lineHeight'], + 'truncated' => $truncated, + ]; + } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + * @param array{ + * fontSize: float, + * lineHeight: float, + * fontAlias: string, + * align: string, + * overflow: string, + * textOverflow: string, + * hyphens: string, + * whiteSpace: string, + * color: string + * } $settings + * @param array{x: float, y: float, width: float, height: float}|null $clipBox + */ + private function buildLayoutLine( + string $lineText, + int $index, + int $lineCount, + array $box, + float $canvasHeight, + array $settings, + ?array $clipBox, + ): LayoutLine { + $lineWidth = $this->fontMetrics->measureString($settings['fontAlias'], $settings['fontSize'], $lineText); + + return new LayoutLine( + text: $lineText, + x: $this->resolveLineX($settings['align'], $box, $lineWidth), + y: max($canvasHeight - ($box['y'] + (($index + 1) * $settings['lineHeight'])), 0.0), + fontSize: $settings['fontSize'], + fontAlias: $settings['fontAlias'], + rgbColor: $settings['color'], + wordSpacing: $this->resolveWordSpacing( + $settings['align'], + $index, + $lineCount, + $lineText, + $lineWidth, + $box['width'], + ), + clipBox: $clipBox, + ); + } + + private function resolveMaxVisibleLines(float $boxHeight, float $lineHeight): int + { + return (int) ceil($boxHeight / max($lineHeight, 0.0001)); + } + + private function resolveWordSpacing( + string $align, + int $index, + int $lineCount, + string $lineText, + float $lineWidth, + float $boxWidth, + ): float { + if ($align !== 'justify' || $index >= ($lineCount - 1)) { + return 0.0; + } + + return $this->calculateWordSpacing($lineText, $lineWidth, $boxWidth); + } + + /** + * @param array{x: float, y: float, width: float, height: float}|null $clipBox + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function toPdfClipBox(?array $clipBox, float $canvasHeight): ?array + { + if ($clipBox === null) { + return null; + } + + return [ + 'x' => $clipBox['x'], + 'y' => max($canvasHeight - ($clipBox['y'] + $clipBox['height']), 0.0), + 'width' => $clipBox['width'], + 'height' => $clipBox['height'], + ]; + } + + private function calculateWordSpacing(string $text, float $lineWidth, float $boxWidth): float + { + $spaceCount = substr_count($text, ' '); + if ($spaceCount === 0) { + return 0.0; + } + + return max($boxWidth - $lineWidth, 0.0) / $spaceCount; + } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + */ + private function resolveLineX(string $align, array $box, float $lineWidth): float + { + return match ($align) { + 'center' => $box['x'] + max(($box['width'] - $lineWidth) / 2.0, 0.0), + 'right' => $box['x'] + max($box['width'] - $lineWidth, 0.0), + default => $box['x'], + }; + } +} diff --git a/src/Layout/TextLineBreaker.php b/src/Layout/TextLineBreaker.php new file mode 100644 index 0000000..9eafd4e --- /dev/null +++ b/src/Layout/TextLineBreaker.php @@ -0,0 +1,286 @@ + + */ + public function wrap( + string $text, + float $maxWidth, + string $fontAlias, + float $fontSize, + string $hyphens, + string $whiteSpace, + ): array { + if ($whiteSpace === 'nowrap' || $maxWidth <= 0.0) { + return [$text]; + } + + $words = $this->splitWords($text); + if ($words === []) { + return [$text]; + } + + $lines = []; + $currentLine = ''; + + foreach ($words as $word) { + if ($this->fitsOnCurrentLine($currentLine, $word, $maxWidth, $fontAlias, $fontSize)) { + $currentLine = $this->appendWord($currentLine, $word); + continue; + } + + if ($currentLine !== '') { + $lines[] = $currentLine; + $currentLine = ''; + } + + $this->appendBrokenWord( + $word, + $maxWidth, + $fontAlias, + $fontSize, + $hyphens, + $lines, + $currentLine, + ); + } + + if ($currentLine !== '') { + $lines[] = $currentLine; + } + + return $lines === [] ? [$text] : $lines; + } + + private function fitsOnCurrentLine( + string $currentLine, + string $word, + float $maxWidth, + string $fontAlias, + float $fontSize, + ): bool { + $candidate = $this->appendWord($currentLine, $word); + + return $this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) <= $maxWidth; + } + + private function appendWord(string $currentLine, string $word): string + { + return $currentLine === '' ? $word : $currentLine . ' ' . $word; + } + + /** + * @param list $lines + */ + private function appendBrokenWord( + string $word, + float $maxWidth, + string $fontAlias, + float $fontSize, + string $hyphens, + array &$lines, + string &$currentLine, + ): void { + $segments = $this->breakWord($word, $maxWidth, $fontAlias, $fontSize, $hyphens); + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if ($index === $lastIndex) { + $currentLine = $segment; + continue; + } + + $lines[] = $segment; + } + } + + /** + * @return list + */ + private function breakWord( + string $word, + float $maxWidth, + string $fontAlias, + float $fontSize, + string $hyphens, + ): array { + if ($hyphens === 'none') { + return [$word]; + } + + $manualSegments = $this->resolveManualBreaks($word, $maxWidth, $fontAlias, $fontSize, $hyphens); + if ($manualSegments !== null) { + return $manualSegments; + } + + if ($hyphens !== 'auto') { + return [$word]; + } + + return $this->breakWordAutomatically($word, $maxWidth, $fontAlias, $fontSize); + } + + /** + * @return list|null + */ + private function resolveManualBreaks( + string $word, + float $maxWidth, + string $fontAlias, + float $fontSize, + string $hyphens, + ): ?array { + if ($hyphens !== 'manual' || !str_contains($word, "\u{00AD}")) { + return null; + } + + $manualBreaks = explode("\u{00AD}", $word); + + return count($manualBreaks) > 1 + ? $this->packManualSegments($manualBreaks, $maxWidth, $fontAlias, $fontSize) + : null; + } + + /** + * @return list + */ + private function breakWordAutomatically( + string $word, + float $maxWidth, + string $fontAlias, + float $fontSize, + ): array { + $segments = []; + $remaining = $this->splitCharacters($word); + + while ($remaining !== []) { + ['segment' => $segment, 'consumed' => $consumed] = $this->resolveAutoSegment( + $remaining, + $maxWidth, + $fontAlias, + $fontSize, + ); + + if ($consumed <= 0) { + break; + } + + $remaining = array_slice($remaining, $consumed); + $segments[] = $remaining === [] ? $segment : ($segment . '-'); + } + + return $segments === [] ? [$word] : $segments; + } + + /** + * @param list $remaining + * @return array{segment: string, consumed: int} + */ + private function resolveAutoSegment( + array $remaining, + float $maxWidth, + string $fontAlias, + float $fontSize, + ): array { + $segment = ''; + $consumed = 0; + $remainingCount = count($remaining); + + foreach ($remaining as $index => $character) { + $candidate = $segment . $character; + $isLastCharacter = $index === ($remainingCount - 1); + $candidateWidth = $this->fontMetrics->measureString( + $fontAlias, + $fontSize, + $candidate . ($isLastCharacter ? '' : '-'), + ); + + if ($candidateWidth > $maxWidth && $segment !== '') { + break; + } + + if ($candidateWidth > $maxWidth) { + return ['segment' => $character, 'consumed' => 1]; + } + + $segment = $candidate; + $consumed = $index + 1; + } + + return ['segment' => $segment, 'consumed' => $consumed]; + } + + /** + * @param list $segments + * @return list + */ + private function packManualSegments( + array $segments, + float $maxWidth, + string $fontAlias, + float $fontSize, + ): array { + $packed = []; + $current = ''; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + $candidate = $current . $segment; + $candidateWithHyphen = $candidate . ($index === $lastIndex ? '' : '-'); + if ( + $current !== '' + && $this->fontMetrics->measureString($fontAlias, $fontSize, $candidateWithHyphen) > $maxWidth + ) { + $packed[] = $current . '-'; + $current = $segment; + continue; + } + + $current = $candidate; + } + + if ($current !== '') { + $packed[] = $current; + } + + return $packed === [] ? [implode('', $segments)] : $packed; + } + + /** + * @return list + */ + private function splitWords(string $text): array + { + $words = preg_split('/\s+/u', $text) ?: []; + + return array_values( + array_filter($words, static fn (string $word): bool => $word !== ''), + ); + } + + /** + * @return list + */ + private function splitCharacters(string $text): array + { + $characters = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); + + return $characters === false ? [] : $characters; + } +} diff --git a/src/Layout/TextOverflowTruncator.php b/src/Layout/TextOverflowTruncator.php new file mode 100644 index 0000000..9f72425 --- /dev/null +++ b/src/Layout/TextOverflowTruncator.php @@ -0,0 +1,62 @@ +fontMetrics->measureString($fontAlias, $fontSize, $text) <= $maxWidth) { + return $text; + } + + return $this->forceEllipsis($text, $maxWidth, $fontAlias, $fontSize); + } + + public function forceEllipsis( + string $text, + float $maxWidth, + string $fontAlias, + float $fontSize, + ): string { + $ellipsis = '...'; + $characters = $this->splitCharacters($text); + + while ($characters !== []) { + $candidate = implode('', $characters) . $ellipsis; + if ($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) <= $maxWidth) { + return rtrim(implode('', $characters)) . $ellipsis; + } + + array_pop($characters); + } + + return $ellipsis; + } + + /** + * @return list + */ + private function splitCharacters(string $text): array + { + $characters = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); + + return $characters === false ? [] : $characters; + } +} diff --git a/src/Pdf/ColorParser.php b/src/Pdf/ColorParser.php index 8e02870..4e9cf4f 100644 --- a/src/Pdf/ColorParser.php +++ b/src/Pdf/ColorParser.php @@ -10,6 +10,16 @@ final class ColorParser { public function toPdfRgb(string $hexColor): string + { + return $this->toPdfColor($hexColor, 'rg'); + } + + public function toPdfStrokeRgb(string $hexColor): string + { + return $this->toPdfColor($hexColor, 'RG'); + } + + private function toPdfColor(string $hexColor, string $operator): string { $hex = ltrim(trim($hexColor), '#'); if (strlen($hex) === 3) { @@ -17,7 +27,7 @@ public function toPdfRgb(string $hexColor): string } if (!preg_match('/^[0-9a-f]{6}$/i', $hex)) { - return '0 0 0 rg'; + return sprintf('0 0 0 %s', $operator); } $channels = str_split($hex, 2); @@ -25,6 +35,6 @@ public function toPdfRgb(string $hexColor): string $greenChannel = round(hexdec($channels[1]) / 255, 4); $blueChannel = round(hexdec($channels[2]) / 255, 4); - return sprintf('%s %s %s rg', $redChannel, $greenChannel, $blueChannel); + return sprintf('%s %s %s %s', $redChannel, $greenChannel, $blueChannel, $operator); } } diff --git a/src/Pdf/StandardFontMetrics.php b/src/Pdf/StandardFontMetrics.php new file mode 100644 index 0000000..bb69291 --- /dev/null +++ b/src/Pdf/StandardFontMetrics.php @@ -0,0 +1,92 @@ + 278.0, '!' => 278.0, '"' => 355.0, '#' => 556.0, '$' => 556.0, '%' => 889.0, + '&' => 667.0, '\'' => 222.0, '(' => 333.0, ')' => 333.0, '*' => 389.0, '+' => 584.0, + ',' => 278.0, '-' => 333.0, '.' => 278.0, '/' => 278.0, + '0' => 556.0, '1' => 556.0, '2' => 556.0, '3' => 556.0, '4' => 556.0, + '5' => 556.0, '6' => 556.0, '7' => 556.0, '8' => 556.0, '9' => 556.0, + ':' => 278.0, ';' => 278.0, '<' => 584.0, '=' => 584.0, '>' => 584.0, '?' => 556.0, + '@' => 1015.0, + 'A' => 667.0, 'B' => 667.0, 'C' => 722.0, 'D' => 722.0, 'E' => 667.0, 'F' => 611.0, + 'G' => 778.0, 'H' => 722.0, 'I' => 278.0, 'J' => 500.0, 'K' => 667.0, 'L' => 556.0, + 'M' => 833.0, 'N' => 722.0, 'O' => 778.0, 'P' => 667.0, 'Q' => 778.0, 'R' => 722.0, + 'S' => 667.0, 'T' => 611.0, 'U' => 722.0, 'V' => 667.0, 'W' => 944.0, 'X' => 667.0, + 'Y' => 667.0, 'Z' => 611.0, + '[' => 278.0, '\\' => 278.0, ']' => 278.0, '^' => 469.0, '_' => 556.0, '`' => 333.0, + 'a' => 556.0, 'b' => 556.0, 'c' => 500.0, 'd' => 556.0, 'e' => 556.0, 'f' => 278.0, + 'g' => 556.0, 'h' => 556.0, 'i' => 222.0, 'j' => 222.0, 'k' => 500.0, 'l' => 222.0, + 'm' => 833.0, 'n' => 556.0, 'o' => 556.0, 'p' => 556.0, 'q' => 556.0, 'r' => 333.0, + 's' => 500.0, 't' => 278.0, 'u' => 556.0, 'v' => 500.0, 'w' => 722.0, 'x' => 500.0, + 'y' => 500.0, 'z' => 500.0, + '{' => 334.0, '|' => 260.0, '}' => 334.0, '~' => 584.0, + ]; + private const TIMES_WIDTHS = [ + ' ' => 250.0, '!' => 333.0, '"' => 408.0, '#' => 500.0, '$' => 500.0, '%' => 833.0, + '&' => 778.0, '\'' => 180.0, '(' => 333.0, ')' => 333.0, '*' => 500.0, '+' => 564.0, + ',' => 250.0, '-' => 333.0, '.' => 250.0, '/' => 278.0, + '0' => 500.0, '1' => 500.0, '2' => 500.0, '3' => 500.0, '4' => 500.0, + '5' => 500.0, '6' => 500.0, '7' => 500.0, '8' => 500.0, '9' => 500.0, + ':' => 278.0, ';' => 278.0, '<' => 564.0, '=' => 564.0, '>' => 564.0, '?' => 444.0, + '@' => 921.0, + 'A' => 722.0, 'B' => 667.0, 'C' => 667.0, 'D' => 722.0, 'E' => 611.0, 'F' => 556.0, + 'G' => 722.0, 'H' => 722.0, 'I' => 333.0, 'J' => 389.0, 'K' => 722.0, 'L' => 611.0, + 'M' => 889.0, 'N' => 722.0, 'O' => 722.0, 'P' => 556.0, 'Q' => 722.0, 'R' => 667.0, + 'S' => 556.0, 'T' => 611.0, 'U' => 722.0, 'V' => 722.0, 'W' => 944.0, 'X' => 722.0, + 'Y' => 722.0, 'Z' => 611.0, + '[' => 333.0, '\\' => 278.0, ']' => 333.0, '^' => 469.0, '_' => 500.0, '`' => 333.0, + 'a' => 444.0, 'b' => 500.0, 'c' => 444.0, 'd' => 500.0, 'e' => 444.0, 'f' => 333.0, + 'g' => 500.0, 'h' => 500.0, 'i' => 278.0, 'j' => 278.0, 'k' => 500.0, 'l' => 278.0, + 'm' => 778.0, 'n' => 500.0, 'o' => 500.0, 'p' => 500.0, 'q' => 500.0, 'r' => 333.0, + 's' => 389.0, 't' => 278.0, 'u' => 500.0, 'v' => 500.0, 'w' => 722.0, 'x' => 500.0, + 'y' => 500.0, 'z' => 444.0, + '{' => 480.0, '|' => 200.0, '}' => 480.0, '~' => 541.0, + ]; + + public function measureString(string $fontAlias, float $fontSize, string $text): float + { + if ($text === '' || $fontSize <= 0.0) { + return 0.0; + } + + $width = 0.0; + foreach ($this->splitCharacters($text) as $character) { + $width += $this->resolveCharacterWidth($fontAlias, $character); + } + + return ($width / 1000.0) * $fontSize; + } + + private function resolveCharacterWidth(string $fontAlias, string $character): float + { + if (in_array($fontAlias, ['F5', 'F6'], true)) { + return self::DEFAULT_WIDTH; + } + + $widths = in_array($fontAlias, ['F3', 'F4'], true) + ? self::TIMES_WIDTHS + : self::HELVETICA_WIDTHS; + + return $widths[$character] ?? self::DEFAULT_WIDTH; + } + + /** + * @return list + */ + private function splitCharacters(string $text): array + { + $characters = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); + + return $characters === false ? [] : $characters; + } +} diff --git a/src/Pdf/TemplateDocumentBuilder.php b/src/Pdf/TemplateDocumentBuilder.php index 5c8d4bd..260dd86 100644 --- a/src/Pdf/TemplateDocumentBuilder.php +++ b/src/Pdf/TemplateDocumentBuilder.php @@ -10,7 +10,9 @@ use Closure; use LibreSign\XObjectTemplate\Dto\CompileRequest; use LibreSign\XObjectTemplate\Dto\CompileResult; +use LibreSign\XObjectTemplate\Layout\LayoutDecoration; use LibreSign\XObjectTemplate\Layout\LayoutImage; +use LibreSign\XObjectTemplate\Layout\LayoutLine; use LibreSign\XObjectTemplate\Layout\LayoutResult; final readonly class TemplateDocumentBuilder @@ -80,18 +82,16 @@ public function buildContentStream(LayoutResult $layout): string { $stream = ['q']; + foreach ($layout->decorations as $decoration) { + $stream[] = $this->buildDecorationCommand($decoration); + } + foreach ($layout->images as $image) { $stream[] = $this->buildImageCommand($image); } - $stream[] = 'BT'; - foreach ($layout->lines as $line) { - $stream[] = sprintf('/%s %F Tf', $line->fontAlias, $line->fontSize); - $stream[] = $this->colorParser->toPdfRgb($line->rgbColor); - $stream[] = sprintf('1 0 0 1 %F %F Tm', $line->x, $line->y); - $stream[] = sprintf('(%s) Tj', $this->pdfEscaper->escapeLiteralString($line->text)); - } - $stream[] = 'ET'; + $this->appendTextCommands($stream, $layout->lines); + $stream[] = 'Q'; return implode("\n", $stream); @@ -140,13 +140,219 @@ public function withFontResources(array $fontResources): self private function buildImageCommand(LayoutImage $image): string { - return sprintf( - 'q %F 0 0 %F %F %F cm /%s Do Q', - $image->width, - $image->height, - $image->x, - $image->y, - $image->alias, + if ($image->clipBox === null) { + return sprintf( + 'q %F 0 0 %F %F %F cm /%s Do Q', + $image->width, + $image->height, + $image->x, + $image->y, + $image->alias, + ); + } + + return implode("\n", [ + 'q', + $this->buildClipCommand($image->clipBox), + sprintf( + '%F 0 0 %F %F %F cm /%s Do', + $image->width, + $image->height, + $image->x, + $image->y, + $image->alias, + ), + 'Q', + ]); + } + + /** + * @param list $lines + * @param list $stream + */ + private function appendTextCommands(array &$stream, array $lines): void + { + if ($lines === []) { + return; + } + + if ($this->hasClippedText($lines)) { + foreach ($lines as $line) { + $stream[] = $this->buildTextCommand($line); + } + + return; + } + + foreach ($this->buildGroupedTextCommands($lines) as $command) { + $stream[] = $command; + } + } + + /** + * @param list $lines + */ + private function hasClippedText(array $lines): bool + { + foreach ($lines as $line) { + if ($line->clipBox !== null) { + return true; + } + } + + return false; + } + + /** + * @param list $lines + * @return list + */ + private function buildGroupedTextCommands(array $lines): array + { + $commands = ['BT']; + $currentWordSpacing = 0.0; + + foreach ($lines as $line) { + $commands[] = sprintf('/%s %F Tf', $line->fontAlias, $line->fontSize); + $commands[] = $this->colorParser->toPdfRgb($line->rgbColor); + if ($line->wordSpacing !== $currentWordSpacing) { + $commands[] = sprintf('%F Tw', $line->wordSpacing); + $currentWordSpacing = $line->wordSpacing; + } + + $commands[] = sprintf('1 0 0 1 %F %F Tm', $line->x, $line->y); + $commands[] = sprintf('(%s) Tj', $this->pdfEscaper->escapeLiteralString($line->text)); + } + + $commands[] = 'ET'; + + return $commands; + } + + private function buildTextCommand(LayoutLine $line): string + { + $commands = ['q']; + if ($line->clipBox !== null) { + $commands[] = $this->buildClipCommand($line->clipBox); + } + + $commands[] = 'BT'; + $commands[] = sprintf('/%s %F Tf', $line->fontAlias, $line->fontSize); + $commands[] = $this->colorParser->toPdfRgb($line->rgbColor); + if ($line->wordSpacing !== 0.0) { + $commands[] = sprintf('%F Tw', $line->wordSpacing); + } + + $commands[] = sprintf('1 0 0 1 %F %F Tm', $line->x, $line->y); + $commands[] = sprintf('(%s) Tj', $this->pdfEscaper->escapeLiteralString($line->text)); + $commands[] = 'ET'; + $commands[] = 'Q'; + + return implode("\n", $commands); + } + + private function buildDecorationCommand(LayoutDecoration $decoration): string + { + $hasFill = $decoration->fillColor !== null && $decoration->fillColor !== ''; + $hasStroke = $decoration->strokeColor !== null + && $decoration->strokeColor !== '' + && $decoration->strokeWidth > 0.0; + + if (!$hasFill && !$hasStroke) { + return 'q\nQ'; + } + + $commands = ['q']; + if ($hasFill) { + $commands[] = $this->colorParser->toPdfRgb($decoration->fillColor); + } + + if ($hasStroke) { + $commands[] = $this->colorParser->toPdfStrokeRgb($decoration->strokeColor); + $commands[] = sprintf('%F w', $decoration->strokeWidth); + } + + $commands[] = $this->buildDecorationPath($decoration); + $commands[] = match (true) { + $hasFill && $hasStroke => 'B', + $hasFill => 'f', + default => 'S', + }; + $commands[] = 'Q'; + + return implode("\n", $commands); + } + + /** + * @param array{x: float, y: float, width: float, height: float} $clipBox + */ + private function buildClipCommand(array $clipBox): string + { + return sprintf('%F %F %F %F re W n', $clipBox['x'], $clipBox['y'], $clipBox['width'], $clipBox['height']); + } + + private function buildDecorationPath(LayoutDecoration $decoration): string + { + $radius = min( + max($decoration->borderRadius, 0.0), + $decoration->width / 2.0, + $decoration->height / 2.0, ); + + if ($radius <= 0.0) { + return sprintf('%F %F %F %F re', $decoration->x, $decoration->y, $decoration->width, $decoration->height); + } + + $kappa = 0.5522847498; + $control = $radius * $kappa; + $left = $decoration->x; + $bottom = $decoration->y; + $right = $decoration->x + $decoration->width; + $top = $decoration->y + $decoration->height; + + return implode("\n", [ + sprintf('%F %F m', $left + $radius, $bottom), + sprintf('%F %F l', $right - $radius, $bottom), + sprintf( + '%F %F %F %F %F %F c', + $right - $radius + $control, + $bottom, + $right, + $bottom + $radius - $control, + $right, + $bottom + $radius, + ), + sprintf('%F %F l', $right, $top - $radius), + sprintf( + '%F %F %F %F %F %F c', + $right, + $top - $radius + $control, + $right - $radius + $control, + $top, + $right - $radius, + $top, + ), + sprintf('%F %F l', $left + $radius, $top), + sprintf( + '%F %F %F %F %F %F c', + $left + $radius - $control, + $top, + $left, + $top - $radius + $control, + $left, + $top - $radius, + ), + sprintf('%F %F l', $left, $bottom + $radius), + sprintf( + '%F %F %F %F %F %F c', + $left, + $bottom + $radius - $control, + $left + $radius - $control, + $bottom, + $left + $radius, + $bottom, + ), + 'h', + ]); } } diff --git a/src/XObjectTemplateCompiler.php b/src/XObjectTemplateCompiler.php index 2154712..c7676b6 100644 --- a/src/XObjectTemplateCompiler.php +++ b/src/XObjectTemplateCompiler.php @@ -11,6 +11,7 @@ use LibreSign\XObjectTemplate\Dto\CompileRequest; use LibreSign\XObjectTemplate\Dto\CompileResult; use LibreSign\XObjectTemplate\Exception\UnsupportedSubsetException; +use LibreSign\XObjectTemplate\Html\HtmlContextInterpolator; use LibreSign\XObjectTemplate\Html\SubsetHtmlParser; use LibreSign\XObjectTemplate\Layout\LinearLayoutEngine; use LibreSign\XObjectTemplate\Pdf\ColorParser; @@ -20,6 +21,7 @@ final readonly class XObjectTemplateCompiler implements XObjectTemplateCompilerInterface { private SubsetHtmlParser $htmlParser; + private HtmlContextInterpolator $contextInterpolator; private LinearLayoutEngine $layoutEngine; private TemplateDocumentBuilder $documentBuilder; @@ -29,8 +31,10 @@ public function __construct( ?PdfEscaper $pdfEscaper = null, ?ColorParser $colorParser = null, ?TemplateDocumentBuilder $documentBuilder = null, + ?HtmlContextInterpolator $contextInterpolator = null, ) { $this->htmlParser = $htmlParser ?? new SubsetHtmlParser(); + $this->contextInterpolator = $contextInterpolator ?? new HtmlContextInterpolator(); $this->layoutEngine = $layoutEngine ?? new LinearLayoutEngine(); $this->documentBuilder = $documentBuilder ?? new TemplateDocumentBuilder( $pdfEscaper ?? new PdfEscaper(), @@ -47,7 +51,9 @@ public function compile(CompileRequest $request): CompileResult { $start = hrtime(true); - $nodes = $this->htmlParser->parse($request->html); + $nodes = $this->htmlParser->parse( + $this->contextInterpolator->interpolate($request->html, $request->context), + ); $layout = $this->layoutEngine->layout($nodes, $request->width, $request->height); return $this->documentBuilder->build($request, $layout, $start, count($nodes)); diff --git a/tests/Integration/VisibleStampTemplateScenarioTest.php b/tests/Integration/VisibleStampTemplateScenarioTest.php index 59f1b90..d5b3554 100644 --- a/tests/Integration/VisibleStampTemplateScenarioTest.php +++ b/tests/Integration/VisibleStampTemplateScenarioTest.php @@ -136,7 +136,8 @@ public static function visibleStampLayoutProvider(): iterable 'expectedTexts' => [ 'Signed with LibreSign', 'Preview Issuer', - 'Date: 2026-05-28T16:40:21+00:00', + 'Date:', + '2026-05-28T16:40:21+00:00', ], ]; } diff --git a/tests/Unit/Html/HtmlContextInterpolatorTest.php b/tests/Unit/Html/HtmlContextInterpolatorTest.php new file mode 100644 index 0000000..c781902 --- /dev/null +++ b/tests/Unit/Html/HtmlContextInterpolatorTest.php @@ -0,0 +1,85 @@ +interpolate( + '
Signed by {{ signer }}
', + ['signer' => 'Alice & Bob'], + ); + + self::assertSame( + '
Signed by Alice & Bob
', + $html, + ); + } + + public function testInterpolateLeavesUnknownPlaceholdersUntouched(): void + { + $interpolator = new HtmlContextInterpolator(); + + $html = $interpolator->interpolate('
{{ signer }}
{{ issuer }}
', ['signer' => 'Alice']); + + self::assertSame('
Alice
{{ issuer }}
', $html); + } + + public function testInterpolateLeavesHtmlUnchangedWhenContextIsEmpty(): void + { + $interpolator = new HtmlContextInterpolator(); + + $html = $interpolator->interpolate('
{{ signer }}
', []); + + self::assertSame('
{{ signer }}
', $html); + } + + public function testInterpolateNormalizesNumericAndBooleanScalars(): void + { + $interpolator = new HtmlContextInterpolator(); + + $html = $interpolator->interpolate( + '
{{ count }}|{{ ratio }}|{{ approved }}
', + ['count' => 12, 'ratio' => 1.5, 'approved' => false], + ); + + self::assertSame('
12|1.5|false
', $html); + } + + public function testInterpolateEscapesQuotesAndSubstitutesInvalidUtf8Sequences(): void + { + $interpolator = new HtmlContextInterpolator(); + + $html = $interpolator->interpolate( + '
{{ signer }}
', + ['signer' => "O'Reilly \xC3"], + ); + + self::assertSame("
O'Reilly \u{FFFD}
", $html); + } + + public function testCompilerInterpolatesContextBeforeParsingHtml(): void + { + $compiler = new XObjectTemplateCompiler(); + + $result = $compiler->compile(new CompileRequest( + html: '
Signed by {{ signer }}
', + context: ['signer' => 'Preview User'], + )); + + self::assertStringContainsString('Signed by Preview User', $result->contentStream); + } +} diff --git a/tests/Unit/Html/SubsetHtmlParserTest.php b/tests/Unit/Html/SubsetHtmlParserTest.php index 44ff393..362f628 100644 --- a/tests/Unit/Html/SubsetHtmlParserTest.php +++ b/tests/Unit/Html/SubsetHtmlParserTest.php @@ -167,6 +167,43 @@ public function testParsePreservesUtf8CharactersFromHtmlFragment(): void self::assertSame('ação € 😀', $nodes[0]->children[0]->text); } + public function testParseKeepsAllTopLevelNodesInOrder(): void + { + $parser = new SubsetHtmlParser(); + + $nodes = $parser->parse('FirstSecond'); + + $this->assertCount(2, $nodes); + $this->assertSame('First', $nodes[0]->children[0]->text); + $this->assertSame('Second', $nodes[1]->children[0]->text); + } + + public function testParseFiltersInheritedStylesAfterMalformedDeclarationsAndPreservesColonValues(): void + { + $parser = new SubsetHtmlParser(); + + $nodes = $parser->parse( + '
' + . 'Hello' + . '
', + ); + + $this->assertSame( + '; COLOR : #fff ; broken ; font-family : Times:Bold ; invalid: ; white-space : nowrap ; ' + . 'hyphens : auto ; color : #abc ; line-height : 12 ;', + $nodes[0]->attributes['style'], + ); + $this->assertSame( + 'color:#abc;font-family:Times:Bold;white-space:nowrap;hyphens:auto;line-height:12;font-weight:bold', + $nodes[0]->children[0]->attributes['style'], + ); + $this->assertSame( + 'color:#abc;font-family:Times:Bold;white-space:nowrap;hyphens:auto;line-height:12;font-weight:bold', + $nodes[0]->children[0]->children[0]->attributes['style'], + ); + } + public function testParseClearsPreExistingLibxmlErrorBuffer(): void { $parser = new SubsetHtmlParser(); diff --git a/tests/Unit/Integration/XObjectPlacementCalculatorTest.php b/tests/Unit/Integration/XObjectPlacementCalculatorTest.php new file mode 100644 index 0000000..79e3aae --- /dev/null +++ b/tests/Unit/Integration/XObjectPlacementCalculatorTest.php @@ -0,0 +1,167 @@ +fromWidth( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [12.5, 4.0, 252.5, 88.0]), + 120.0, + 20.0, + 30.0, + ); + + self::assertEqualsWithDelta(0.5, $placement->scaleX, 0.0001); + self::assertEqualsWithDelta(0.5, $placement->scaleY, 0.0001); + self::assertEqualsWithDelta(120.0, $placement->width, 0.0001); + self::assertEqualsWithDelta(42.0, $placement->height, 0.0001); + self::assertEqualsWithDelta(13.75, $placement->translateX, 0.0001); + self::assertEqualsWithDelta(28.0, $placement->translateY, 0.0001); + self::assertSame('q 0.5 0 0 0.5 13.75 28 cm /Fm0 Do Q', $placement->toPdfCommand('Fm0')); + } + + public function testFromHeightCalculatesUniformPlacementFromBoundingBoxHeight(): void + { + $calculator = new XObjectPlacementCalculator(); + $placement = $calculator->fromHeight( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), + 168.0, + 15.0, + 25.0, + ); + + self::assertEqualsWithDelta(2.0, $placement->scaleX, 0.0001); + self::assertEqualsWithDelta(2.0, $placement->scaleY, 0.0001); + self::assertEqualsWithDelta(480.0, $placement->width, 0.0001); + self::assertEqualsWithDelta(168.0, $placement->height, 0.0001); + self::assertEqualsWithDelta(15.0, $placement->translateX, 0.0001); + self::assertEqualsWithDelta(25.0, $placement->translateY, 0.0001); + } + + public function testFromWidthUsesOriginDefaultsWhenCoordinatesAreOmitted(): void + { + $calculator = new XObjectPlacementCalculator(); + + $placement = $calculator->fromWidth( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 70.0]), + 50.0, + ); + + self::assertEqualsWithDelta(0.5, $placement->scaleX, 0.0001); + self::assertEqualsWithDelta(0.5, $placement->scaleY, 0.0001); + self::assertEqualsWithDelta(-5.0, $placement->translateX, 0.0001); + self::assertEqualsWithDelta(-10.0, $placement->translateY, 0.0001); + } + + public function testFromHeightUsesOriginDefaultsWhenCoordinatesAreOmitted(): void + { + $calculator = new XObjectPlacementCalculator(); + + $placement = $calculator->fromHeight( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 70.0]), + 25.0, + ); + + self::assertEqualsWithDelta(0.5, $placement->scaleX, 0.0001); + self::assertEqualsWithDelta(0.5, $placement->scaleY, 0.0001); + self::assertEqualsWithDelta(50.0, $placement->width, 0.0001); + self::assertEqualsWithDelta(25.0, $placement->height, 0.0001); + self::assertEqualsWithDelta(-5.0, $placement->translateX, 0.0001); + self::assertEqualsWithDelta(-10.0, $placement->translateY, 0.0001); + } + + public function testFromScaleUsesOriginDefaultsWhenCoordinatesAreOmitted(): void + { + $calculator = new XObjectPlacementCalculator(); + + $placement = $calculator->fromScale( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 70.0]), + 2.0, + ); + + self::assertEqualsWithDelta(2.0, $placement->scaleX, 0.0001); + self::assertEqualsWithDelta(2.0, $placement->scaleY, 0.0001); + self::assertEqualsWithDelta(200.0, $placement->width, 0.0001); + self::assertEqualsWithDelta(100.0, $placement->height, 0.0001); + self::assertEqualsWithDelta(-20.0, $placement->translateX, 0.0001); + self::assertEqualsWithDelta(-40.0, $placement->translateY, 0.0001); + } + + public function testFromWidthRejectsZeroTargetWidth(): void + { + $calculator = new XObjectPlacementCalculator(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Placement target width must be greater than zero.'); + + $calculator->fromWidth( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), + 0.0, + ); + } + + public function testFromHeightRejectsZeroTargetHeight(): void + { + $calculator = new XObjectPlacementCalculator(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Placement target height must be greater than zero.'); + + $calculator->fromHeight( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), + 0.0, + ); + } + + public function testFromScaleRejectsNonPositiveScale(): void + { + $calculator = new XObjectPlacementCalculator(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Placement scale must be greater than zero.'); + + $calculator->fromScale( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), + 0.0, + ); + } + + public function testPlacementRejectsBoundingBoxesWithoutPositiveArea(): void + { + $calculator = new XObjectPlacementCalculator(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CompileResult bbox must describe a positive area.'); + + $calculator->fromScale( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 10.0, 70.0]), + 1.0, + ); + } + + public function testPlacementRejectsBoundingBoxesWithZeroHeight(): void + { + $calculator = new XObjectPlacementCalculator(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CompileResult bbox must describe a positive area.'); + + $calculator->fromScale( + new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 20.0]), + 1.0, + ); + } +} diff --git a/tests/Unit/Integration/XObjectPlacementTest.php b/tests/Unit/Integration/XObjectPlacementTest.php new file mode 100644 index 0000000..dc76be3 --- /dev/null +++ b/tests/Unit/Integration/XObjectPlacementTest.php @@ -0,0 +1,46 @@ +toPdfCommand(' /Fm0 ')); + } + + public function testToPdfCommandRejectsAliasThatBecomesEmptyAfterNormalization(): void + { + $placement = new XObjectPlacement( + scaleX: 1.0, + scaleY: 1.0, + width: 10.0, + height: 10.0, + translateX: 0.0, + translateY: 0.0, + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Placement alias must not be empty.'); + + $placement->toPdfCommand(' / '); + } +} diff --git a/tests/Unit/Layout/LayoutDecorationTest.php b/tests/Unit/Layout/LayoutDecorationTest.php new file mode 100644 index 0000000..7a80eb5 --- /dev/null +++ b/tests/Unit/Layout/LayoutDecorationTest.php @@ -0,0 +1,28 @@ +x); + self::assertSame(2.0, $decoration->y); + self::assertSame(3.0, $decoration->width); + self::assertSame(4.0, $decoration->height); + self::assertNull($decoration->fillColor); + self::assertNull($decoration->strokeColor); + self::assertSame(0.0, $decoration->strokeWidth); + self::assertSame(0.0, $decoration->borderRadius); + } +} diff --git a/tests/Unit/Layout/LinearLayoutEngineTest.php b/tests/Unit/Layout/LinearLayoutEngineTest.php index 3f62957..57adb46 100644 --- a/tests/Unit/Layout/LinearLayoutEngineTest.php +++ b/tests/Unit/Layout/LinearLayoutEngineTest.php @@ -47,7 +47,7 @@ public function testLayoutSupportsNestedNodesImagesAndStyles(): void self::assertCount(1, $result->images); self::assertSame('Approved', $result->lines[0]->text); self::assertSame('F1', $result->lines[0]->fontAlias); - self::assertSame(8.0, $result->lines[0]->x); + self::assertSame(0.0, $result->lines[0]->x); self::assertSame(78.0, $result->lines[0]->y); self::assertSame('/fixture/sign.png', $result->images[0]->source); self::assertSame('Im0', $result->images[0]->alias); @@ -95,8 +95,8 @@ public function testLayoutSupportsRightAndCenterAlignmentWithFallbackWidth(): vo self::assertCount(2, $result->lines); self::assertSame('Right', $result->lines[0]->text); self::assertSame('Center', $result->lines[1]->text); - self::assertEqualsWithDelta(84.0, $result->lines[0]->x, 0.0001); - self::assertEqualsWithDelta(52.0, $result->lines[1]->x, 0.0001); + self::assertEqualsWithDelta(68.66, $result->lines[0]->x, 0.0001); + self::assertEqualsWithDelta(36.995, $result->lines[1]->x, 0.0001); self::assertEqualsWithDelta(82.0, $result->lines[0]->y, 0.0001); } @@ -190,9 +190,9 @@ public function testLayoutUsesCssSpacingShorthandSemanticsForTwoThreeAndFourValu ], 100.0, 100.0); self::assertCount(3, $result->lines); - self::assertEqualsWithDelta(10.0, $result->lines[0]->x, 0.0001); - self::assertEqualsWithDelta(10.0, $result->lines[1]->x, 0.0001); - self::assertEqualsWithDelta(12.0, $result->lines[2]->x, 0.0001); + self::assertEqualsWithDelta(2.0, $result->lines[0]->x, 0.0001); + self::assertEqualsWithDelta(2.0, $result->lines[1]->x, 0.0001); + self::assertEqualsWithDelta(4.0, $result->lines[2]->x, 0.0001); self::assertEqualsWithDelta(87.0, $result->lines[0]->y, 0.0001); self::assertEqualsWithDelta(73.0, $result->lines[1]->y, 0.0001); self::assertEqualsWithDelta(57.0, $result->lines[2]->y, 0.0001); @@ -252,7 +252,7 @@ public function testLayoutSupportsFlexRowsWithPercentageColumns(): void new Node( tag: 'div', text: '', - attributes: ['style' => 'display:flex;flex-direction:row;width:200;height:100'], + attributes: ['style' => 'display: FLEX ; flex-direction: ROW ; width:200;height:100'], children: [ new Node( tag: 'div', @@ -292,7 +292,7 @@ public function testLayoutSupportsFlexCenteringForImages(): void tag: 'div', text: '', attributes: [ - 'style' => 'display:flex;justify-content:center;align-items:center;width:200;height:100', + 'style' => 'display: FLEX ; justify-content: CENTER ; align-items: CENTER ; width:200;height:100', ], children: [ new Node( @@ -311,6 +311,143 @@ public function testLayoutSupportsFlexCenteringForImages(): void self::assertEqualsWithDelta(40.0, $result->images[0]->height, 0.0001); } + public function testLayoutRoutesTrimmedUppercaseDisplayFlexWithoutOtherStructuredHints(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display: FLEX ;width:40;height:40'], + children: [ + new Node( + tag: 'img', + text: '', + attributes: ['src' => '/left.png', 'style' => 'width:10;height:20'], + ), + new Node( + tag: 'img', + text: '', + attributes: ['src' => '/right.png', 'style' => 'width:10;height:20'], + ), + ], + ), + ], 40.0, 40.0); + + self::assertCount(2, $result->images); + self::assertEqualsWithDelta(0.0, $result->images[0]->x, 0.0001); + self::assertEqualsWithDelta(10.0, $result->images[1]->x, 0.0001); + self::assertEqualsWithDelta(20.0, $result->images[0]->y, 0.0001); + self::assertEqualsWithDelta(20.0, $result->images[1]->y, 0.0001); + } + + public function testLayoutRoutesVectorBackgroundStylesToStructuredRenderer(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'div', + text: 'Decorated', + attributes: [ + 'style' => 'background-color:#ddeeff;border-color:#123456;border-width:2;width:80;height:24', + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame('#ddeeff', $result->decorations[0]->fillColor); + self::assertSame('#123456', $result->decorations[0]->strokeColor); + } + + public function testLayoutRoutesBackgroundColorAloneToStructuredRenderer(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'div', + text: 'Tinted', + attributes: ['style' => 'background-color:#ddeeff;width:80;height:24;font-size:10'], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame('#ddeeff', $result->decorations[0]->fillColor); + self::assertNull($result->decorations[0]->strokeColor); + } + + public function testLayoutRoutesPercentageWidthToStructuredRenderer(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'div', + text: 'Half width', + attributes: ['style' => 'width:50%;height:20;text-align:right;font-size:10'], + ), + ], 200.0, 80.0); + + self::assertCount(1, $result->lines); + self::assertSame('Half width', $result->lines[0]->text); + self::assertGreaterThan(50.0, $result->lines[0]->x); + } + + public function testLayoutRoutesOverflowEllipsisRulesToStructuredRenderer(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'div', + text: 'Wrap this text nicely', + attributes: [ + 'style' => 'overflow:hidden;text-overflow:ellipsis;width:50;height:12;font-size:10', + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->lines); + self::assertStringEndsWith('...', $result->lines[0]->text); + self::assertNotNull($result->lines[0]->clipBox); + } + + public function testLayoutRoutesJustifyAndTrimmedFlexEndTokensToStructuredRenderer(): void + { + $engine = new LinearLayoutEngine(); + + $justifyResult = $engine->layout([ + new Node( + tag: 'div', + text: 'Wrap this text nicely', + attributes: ['style' => 'text-align: JUSTIFY ; width:50;font-size:10'], + ), + ], 120.0, 80.0); + $flexEndResult = $engine->layout([ + new Node( + tag: 'div', + text: '', + attributes: [ + 'style' => 'display:flex;justify-content: FLEX-END ;align-items: FLEX-END ;width:200;height:100', + ], + children: [ + new Node( + tag: 'img', + text: '', + attributes: ['src' => '/fixture/end.png', 'style' => 'width:80;height:40'], + ), + ], + ), + ], 200.0, 100.0); + + self::assertGreaterThan(0.0, $justifyResult->lines[0]->wordSpacing); + self::assertCount(1, $flexEndResult->images); + self::assertEqualsWithDelta(120.0, $flexEndResult->images[0]->x, 0.0001); + self::assertEqualsWithDelta(0.0, $flexEndResult->images[0]->y, 0.0001); + } + public function testConstructorKeepsProvidedInlineStyleParserInstance(): void { $styleParser = new InlineStyleParser(); @@ -338,7 +475,7 @@ public function testLayoutNormalizesTrimmedSpacingFontAndAlignmentValues(): void self::assertCount(1, $result->lines); self::assertSame('Trimmed', $result->lines[0]->text); - self::assertEqualsWithDelta(81.5, $result->lines[0]->x, 0.0001); + self::assertEqualsWithDelta(59.92, $result->lines[0]->x, 0.0001); self::assertEqualsWithDelta(73.5, $result->lines[0]->y, 0.0001); self::assertEqualsWithDelta(7.5, $result->lines[0]->fontSize, 0.0001); self::assertSame('#123456', $result->lines[0]->rgbColor); @@ -452,7 +589,39 @@ public function testLayoutUsesThreeValueSpacingRightSlotForHorizontalPositioning ], 100.0, 100.0); self::assertCount(1, $result->lines); - self::assertEqualsWithDelta(72.0, $result->lines[0]->x, 0.0001); + self::assertEqualsWithDelta(22.2, $result->lines[0]->x, 0.0001); + } + + public function testLayoutUsesMeasuredTextWidthForCenterAndRightAlignment(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'span', + text: 'iiii', + attributes: ['style' => 'text-align:center;width:100;font-size:10'], + ), + new Node( + tag: 'span', + text: 'WWWW', + attributes: ['style' => 'text-align:center;width:100;font-size:10'], + ), + new Node( + tag: 'span', + text: 'iiii', + attributes: ['style' => 'text-align:right;width:100;font-size:10'], + ), + new Node( + tag: 'span', + text: 'WWWW', + attributes: ['style' => 'text-align:right;width:100;font-size:10'], + ), + ], 120.0, 120.0); + + self::assertCount(4, $result->lines); + self::assertGreaterThan($result->lines[1]->x, $result->lines[0]->x); + self::assertGreaterThan($result->lines[3]->x, $result->lines[2]->x); } public function testLayoutRecognizesTrimmedUppercaseBoldTokens(): void diff --git a/tests/Unit/Layout/StructuredBoxResolverTest.php b/tests/Unit/Layout/StructuredBoxResolverTest.php index dddeb82..c70f80c 100644 --- a/tests/Unit/Layout/StructuredBoxResolverTest.php +++ b/tests/Unit/Layout/StructuredBoxResolverTest.php @@ -73,6 +73,50 @@ public function testResolveContentBoxAndChildContainerSubtractPaddingAndConsumed self::assertSame(48.0, $childContainer['height']); } + public function testResolveContentBoxUsesWidthAsVerticalReferenceWhenHeightIsNotPositive(): void + { + $parser = new InlineStyleParser(); + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + $style = $parser->parse('padding:10%'); + + $resolved = $resolver->resolveContentBox( + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 0.0], + ); + + self::assertSame(['top' => 20.0, 'right' => 20.0, 'bottom' => 20.0, 'left' => 20.0], $resolved['padding']); + self::assertSame(['x' => 20.0, 'y' => 20.0, 'width' => 160.0, 'height' => 0.0], $resolved['contentBox']); + } + + public function testResolveContentBoxAndChildContainerClampCollapsedDimensionsToZero(): void + { + $parser = new InlineStyleParser(); + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + $style = $parser->parse('padding:10 15'); + + $resolved = $resolver->resolveContentBox( + $style, + ['x' => 3.0, 'y' => 4.0, 'width' => 20.0, 'height' => 15.0], + ); + $childContainer = $resolver->createChildContainer($resolved['contentBox'], 7.0); + + self::assertSame(0.0, $resolved['contentBox']['width']); + self::assertSame(0.0, $resolved['contentBox']['height']); + self::assertSame(0.0, $childContainer['height']); + } + + public function testCreateChildContainerPreservesPositiveFractionalHeightWithoutPromotingToOne(): void + { + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + + $childContainer = $resolver->createChildContainer( + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.75], + 0.0, + ); + + self::assertSame(0.75, $childContainer['height']); + } + public function testResolveAutoContainerHeightUsesResolvedHeightWhenPresent(): void { $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); @@ -87,5 +131,111 @@ public function testResolveAutoContainerHeightUsesResolvedHeightWhenPresent(): v ['top' => 5.0, 'right' => 0.0, 'bottom' => 5.0, 'left' => 0.0], 40.0, )); + self::assertSame(50.0, $resolver->resolveAutoContainerHeight( + -1.0, + ['top' => 5.0, 'right' => 0.0, 'bottom' => 5.0, 'left' => 0.0], + 40.0, + )); + } + + public function testResolveFixedContainerHeightKeepsExplicitHeightFixed(): void + { + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + + self::assertSame(30.0, $resolver->resolveFixedContainerHeight( + 30.0, + ['top' => 5.0, 'right' => 0.0, 'bottom' => 5.0, 'left' => 0.0], + 40.0, + )); + + self::assertSame(52.0, $resolver->resolveFixedContainerHeight( + 0.0, + ['top' => 5.0, 'right' => 0.0, 'bottom' => 7.0, 'left' => 0.0], + 40.0, + )); + } + + public function testResolveFlowPlacementUsesDefaultBlockAndImageFallbackDimensions(): void + { + $parser = new InlineStyleParser(); + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + $style = $parser->parse('margin:3 4 5 6'); + + $blockPlacement = $resolver->resolveFlowPlacement( + new Node(tag: 'div', text: '', attributes: []), + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 120.0, 'height' => 80.0], + ); + $imagePlacement = $resolver->resolveFlowPlacement( + new Node(tag: 'img', text: '', attributes: ['src' => '/icon.png']), + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 120.0, 'height' => 80.0], + ); + + self::assertSame(['x' => 6.0, 'y' => 3.0, 'width' => 110.0, 'height' => 0.0], $blockPlacement['box']); + self::assertSame(['x' => 6.0, 'y' => 3.0, 'width' => 32.0, 'height' => 32.0], $imagePlacement['box']); + } + + public function testResolveAbsoluteBoxSupportsDefaultMarginsAndLeftTopOffsets(): void + { + $parser = new InlineStyleParser(); + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + + $marginOnlyBox = $resolver->resolveAbsoluteBox( + new Node(tag: 'div', text: '', attributes: []), + $parser->parse('position:absolute;margin:3 4 5 6'), + ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 80.0], + ); + $offsetBox = $resolver->resolveAbsoluteBox( + new Node(tag: 'div', text: '', attributes: []), + $parser->parse('position:absolute;width:20;height:10;left:7;top:9;margin:1 2 3 4'), + ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 80.0], + ); + + self::assertSame(['x' => 6.0, 'y' => 3.0, 'width' => 90.0, 'height' => 72.0], $marginOnlyBox); + self::assertSame(['x' => 11.0, 'y' => 10.0, 'width' => 20.0, 'height' => 10.0], $offsetBox); + } + + public function testResolveFlowAndAbsoluteFallbackDimensionsClampToZeroInsteadOfOne(): void + { + $parser = new InlineStyleParser(); + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + + $flowPlacement = $resolver->resolveFlowPlacement( + new Node(tag: 'div', text: '', attributes: []), + $parser->parse('margin:0 5'), + ['x' => 0.0, 'y' => 0.0, 'width' => 10.0, 'height' => 10.0], + ); + $absolutePlacement = $resolver->resolveAbsoluteBox( + new Node(tag: 'div', text: '', attributes: []), + $parser->parse('position:absolute;margin:5'), + ['x' => 0.0, 'y' => 0.0, 'width' => 10.0, 'height' => 10.0], + ); + $absoluteImagePlacement = $resolver->resolveAbsoluteBox( + new Node(tag: 'img', text: '', attributes: ['src' => '/icon.png']), + $parser->parse('position:absolute'), + ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 80.0], + ); + + self::assertSame(0.0, $flowPlacement['box']['width']); + self::assertSame(0.0, $absolutePlacement['width']); + self::assertSame(0.0, $absolutePlacement['height']); + self::assertSame(32.0, $absoluteImagePlacement['width']); + self::assertSame(32.0, $absoluteImagePlacement['height']); + } + + public function testResolveAbsoluteRightAndBottomClampToZeroInsteadOfOne(): void + { + $parser = new InlineStyleParser(); + $resolver = new StructuredBoxResolver(new LayoutStyleResolver()); + + $box = $resolver->resolveAbsoluteBox( + new Node(tag: 'div', text: '', attributes: []), + $parser->parse('position:absolute;width:30;height:20;right:90;bottom:70'), + ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 80.0], + ); + + self::assertSame(0.0, $box['x']); + self::assertSame(0.0, $box['y']); } } diff --git a/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php index 6d3f2c6..c89b34e 100644 --- a/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php +++ b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php @@ -23,6 +23,8 @@ public function testNormalizeDirectionAndResolveGapUseExpectedAxis(): void self::assertSame('row', $planner->normalizeDirection('ROW')); self::assertSame('column', $planner->normalizeDirection('column')); + self::assertSame('column', $planner->normalizeDirection(' COLUMN ')); + self::assertSame('row', $planner->normalizeDirection(' unexpected ')); self::assertSame( 20.0, $planner->resolveGap($style, 'row', ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0]), @@ -48,9 +50,21 @@ public function testMeasureItemUsesImageAndTextFallbacks(): void $parser->parse('font-size:10'), ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], ); + $trimmedTextSize = $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], + ); + $containerFallbackSize = $planner->measureItem( + new Node(tag: 'div', text: '', attributes: []), + $parser->parse(''), + ['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); + self::assertSame(['width' => 24.46, 'height' => 12.0], $textSize); + self::assertSame(['width' => 24.46, 'height' => 12.0], $trimmedTextSize); + self::assertSame(['width' => 0.0, 'height' => 40.0], $containerFallbackSize); } public function testCalculateMetricsSupportsSpaceBetween(): void @@ -85,6 +99,29 @@ public function testCalculateMetricsSupportsSpaceBetween(): void self::assertSame(100.0, $metrics['crossContainerSize']); } + public function testCalculateMetricsKeepsConfiguredGapForSingleSpaceBetweenItem(): void + { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $parser = new InlineStyleParser(); + $items = [[ + 'node' => new Node('div', '', []), + 'style' => $parser->parse(''), + 'size' => ['width' => 50.0, 'height' => 20.0], + ]]; + + $metrics = $planner->calculateMetrics( + $items, + 'row', + 'space-between', + 7.0, + ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + ); + + self::assertSame(7.0, $metrics['gap']); + self::assertSame(50.0, $metrics['totalMainAxisSize']); + self::assertSame(20.0, $metrics['crossAxisSize']); + } + public function testCreateChildBoxSupportsRowAndColumnLayouts(): void { $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); @@ -103,4 +140,170 @@ public function testCreateChildBoxSupportsRowAndColumnLayouts(): void self::assertSame(57.0, $planner->advanceCursor($item, 'row', 7.0)); self::assertSame(27.0, $planner->advanceCursor($item, 'column', 7.0)); } + + public function testCreateChildBoxFallsBackToContainerDimensionWhenItemCrossSizeIsZero(): void + { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $contentBox = ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0]; + + $rowBox = $planner->createChildBox( + [ + 'node' => new Node('div', '', []), + 'style' => (new InlineStyleParser())->parse(''), + 'size' => ['width' => 50.0, 'height' => 0.0], + ], + 'row', + 'center', + $contentBox, + 100.0, + 30.0, + ); + $columnBox = $planner->createChildBox( + [ + 'node' => new Node('div', '', []), + 'style' => (new InlineStyleParser())->parse(''), + 'size' => ['width' => 0.0, 'height' => 20.0], + ], + 'column', + 'flex-end', + $contentBox, + 200.0, + 15.0, + ); + + self::assertSame(100.0, $rowBox['height']); + self::assertSame(200.0, $columnBox['width']); + } + + public function testCreateChildBoxClampsOversizedAlignmentOffsetsToZero(): void + { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $contentBox = ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0]; + + $rowBox = $planner->createChildBox( + [ + 'node' => new Node('div', '', []), + 'style' => (new InlineStyleParser())->parse(''), + 'size' => ['width' => 50.0, 'height' => 150.0], + ], + 'row', + 'center', + $contentBox, + 100.0, + 30.0, + ); + $columnBox = $planner->createChildBox( + [ + 'node' => new Node('div', '', []), + 'style' => (new InlineStyleParser())->parse(''), + 'size' => ['width' => 250.0, 'height' => 20.0], + ], + 'column', + 'flex-end', + $contentBox, + 200.0, + 15.0, + ); + + self::assertSame(20.0, $rowBox['y']); + self::assertSame(10.0, $columnBox['x']); + } + + public function testMeasureItemTreatsWhitespaceOnlyTextAsEmpty(): void + { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + + $size = $planner->measureItem( + new Node(tag: 'span', text: ' ', attributes: []), + (new InlineStyleParser())->parse('font-size:10'), + ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 0.0], + ); + + self::assertSame(['width' => 0.0, 'height' => 0.0], $size); + } + + public function testMeasureItemUsesTrimmedTextWhenWhitespaceSurroundsVisibleCharacters(): void + { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + + $size = $planner->measureItem( + new Node(tag: 'span', text: ' Label ', attributes: []), + (new InlineStyleParser())->parse('font-size:10'), + ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 0.0], + ); + + self::assertSame(['width' => 24.46, 'height' => 12.0], $size); + } + + public function testCalculateMetricsSupportsThreeItemSpaceBetweenAndEmptyCollections(): void + { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $parser = new InlineStyleParser(); + $items = [ + [ + 'node' => new Node('div', '', []), + 'style' => $parser->parse(''), + 'size' => ['width' => 20.0, 'height' => 10.0], + ], + [ + 'node' => new Node('div', '', []), + 'style' => $parser->parse(''), + 'size' => ['width' => 20.0, 'height' => 10.0], + ], + [ + 'node' => new Node('div', '', []), + 'style' => $parser->parse(''), + 'size' => ['width' => 20.0, 'height' => 10.0], + ], + ]; + + $threeItemMetrics = $planner->calculateMetrics( + $items, + 'row', + 'space-between', + 0.0, + ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + ); + $emptyMetrics = $planner->calculateMetrics( + [], + 'row', + 'flex-start', + 7.0, + ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + ); + + self::assertSame(70.0, $threeItemMetrics['gap']); + self::assertSame(200.0, $threeItemMetrics['totalMainAxisSize']); + self::assertSame(0.0, $emptyMetrics['totalMainAxisSize']); + self::assertSame(0.0, $emptyMetrics['crossAxisSize']); + } + + public function testCalculateMetricsClampsCenterAndFlexEndOffsetsToZero(): void + { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $parser = new InlineStyleParser(); + $items = [[ + 'node' => new Node('div', '', []), + 'style' => $parser->parse(''), + 'size' => ['width' => 250.0, 'height' => 20.0], + ]]; + + $centerMetrics = $planner->calculateMetrics( + $items, + 'row', + 'center', + 0.0, + ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + ); + $flexEndMetrics = $planner->calculateMetrics( + $items, + 'row', + 'flex-end', + 0.0, + ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + ); + + self::assertSame(0.0, $centerMetrics['mainAxisOffset']); + self::assertSame(0.0, $flexEndMetrics['mainAxisOffset']); + } } diff --git a/tests/Unit/Layout/StructuredLayoutRendererTest.php b/tests/Unit/Layout/StructuredLayoutRendererTest.php index 166a336..25e78fb 100644 --- a/tests/Unit/Layout/StructuredLayoutRendererTest.php +++ b/tests/Unit/Layout/StructuredLayoutRendererTest.php @@ -9,6 +9,7 @@ use LibreSign\XObjectTemplate\Css\InlineStyleParser; use LibreSign\XObjectTemplate\Html\Node; +use LibreSign\XObjectTemplate\Layout\LayoutDecoration; use LibreSign\XObjectTemplate\Layout\LayoutStyleResolver; use LibreSign\XObjectTemplate\Layout\StructuredLayoutRenderer; use PHPUnit\Framework\TestCase; @@ -64,6 +65,29 @@ public function testLayoutSupportsTrimmedUppercaseFlexAlignmentAndGapSpacing(): self::assertSame(60.0, $result->images[1]->y); } + public function testLayoutSupportsTrimmedUppercaseDisplayFlexWithoutOtherFlexHints(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display: FLEX ;width:40;height:40'], + children: [ + $this->imageNode('/left.png', 'width:10;height:20'), + $this->imageNode('/right.png', 'width:10;height:20'), + ], + ), + ], 40.0, 40.0); + + self::assertCount(2, $result->images); + self::assertSame(0.0, $result->images[0]->x); + self::assertSame(10.0, $result->images[1]->x); + self::assertSame(20.0, $result->images[0]->y); + self::assertSame(20.0, $result->images[1]->y); + } + public function testLayoutUsesAutoFlexHeightToPositionFollowingSiblings(): void { $renderer = $this->createRenderer(); @@ -114,6 +138,431 @@ public function testLayoutAccumulatesParentTextAndChildHeightBeforeFollowingNode self::assertSame(64.0, $result->lines[2]->y); } + public function testLayoutCreatesVectorDecorationsForBackgroundAndBorders(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: 'Decorated', + attributes: [ + 'style' => 'width:100;height:40;padding:4;background-color:#abcdef;' + . 'border-color:#123456;border-width:2;border-radius:6;font-size:10', + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertInstanceOf(LayoutDecoration::class, $result->decorations[0]); + self::assertSame('#abcdef', $result->decorations[0]->fillColor); + self::assertSame('#123456', $result->decorations[0]->strokeColor); + self::assertSame(2.0, $result->decorations[0]->strokeWidth); + self::assertSame(6.0, $result->decorations[0]->borderRadius); + self::assertSame(100.0, $result->decorations[0]->width); + self::assertSame(40.0, $result->decorations[0]->height); + self::assertSame(40.0, $result->decorations[0]->y); + } + + public function testLayoutUsesAutoHeightForDecoratedBlockContainersWithoutClipping(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: 'Auto height', + attributes: ['style' => 'width:100;height:12;padding:10;background-color:#abcdef;font-size:10'], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(32.0, $result->decorations[0]->height); + self::assertSame(48.0, $result->decorations[0]->y); + } + + public function testLayoutTrimsDecorationColorsFromInlineStyles(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: [ + 'style' => 'width:100;height:20;background-color: #abcdef ;border-color: #123456 ;' + . 'border-width:2', + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame('#abcdef', $result->decorations[0]->fillColor); + self::assertSame('#123456', $result->decorations[0]->strokeColor); + } + + public function testLayoutCreatesBorderOnlyDecorationWhenStrokeHasPositiveWidth(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'width:100;height:20;border-color:#123456;border-width:2'], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertNull($result->decorations[0]->fillColor); + self::assertSame('#123456', $result->decorations[0]->strokeColor); + self::assertSame(2.0, $result->decorations[0]->strokeWidth); + } + + public function testLayoutSkipsBorderOnlyDecorationWhenStrokeWidthIsZero(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'width:100;height:20;border-color:#123456;border-width:0'], + ), + ], 120.0, 80.0); + + self::assertCount(0, $result->decorations); + } + + public function testLayoutSkipsDecorationsForZeroWidthFlexItems(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display:flex;width:20;height:20'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'width:0;height:10;background-color:#abcdef'], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(0, $result->decorations); + } + + public function testLayoutSkipsDecorationsForZeroHeightBlocks(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'width:20;height:0;background-color:#abcdef'], + ), + ], 120.0, 80.0); + + self::assertCount(0, $result->decorations); + } + + public function testLayoutClampsDecorationYAtZeroWhenBoxExtendsBelowCanvas(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'position:absolute;left:0;top:70;width:20;height:20;background-color:#abcdef'], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(0.0, $result->decorations[0]->y); + } + + public function testLayoutAppliesClipBoxesWhenOverflowIsHidden(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: 'Wrap this text nicely', + attributes: [ + 'style' => 'width:50;height:12;overflow:hidden;text-overflow:ellipsis;font-size:10', + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->lines); + self::assertStringEndsWith('...', $result->lines[0]->text); + self::assertSame(['x' => 0.0, 'y' => 68.0, 'width' => 50.0, 'height' => 12.0], $result->lines[0]->clipBox); + } + + public function testLayoutAppliesClipBoxesWhenOverflowHiddenIsTrimmedAndUppercase(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: 'Wrap this text nicely', + attributes: [ + 'style' => 'width:50;height:12;overflow: HIDDEN ;text-overflow:ellipsis;font-size:10', + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->lines); + self::assertSame(['x' => 0.0, 'y' => 68.0, 'width' => 50.0, 'height' => 12.0], $result->lines[0]->clipBox); + } + + public function testLayoutDoesNotApplyClipBoxesWhenOverflowHiddenContainerWidthIsZero(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display:flex;width:20;height:20'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow:hidden;width:0;height:10'], + children: [$this->imageNode('/clip-width-zero.png', 'width:10;height:10')], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->images); + self::assertNull($result->images[0]->clipBox); + } + + public function testLayoutDoesNotApplyClipBoxesWhenOverflowHiddenContainerHeightIsZero(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'width:20;height:0;overflow:hidden'], + children: [$this->imageNode('/clip-height-zero.png', 'width:10;height:10')], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->images); + self::assertNull($result->images[0]->clipBox); + } + + public function testLayoutIntersectsNestedTrimmedUppercaseHiddenClipBoxes(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow: HIDDEN ;width:50;height:40'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'margin:10 0 0 30;overflow: HIDDEN ;width:40;height:30'], + children: [$this->imageNode('/nested-clip.png', 'width:40;height:30')], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->images); + self::assertSame(['x' => 30.0, 'y' => 40.0, 'width' => 20.0, 'height' => 30.0], $result->images[0]->clipBox); + } + + public function testLayoutKeepsZeroWidthForNestedHiddenClipBoxIntersection(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow:hidden;width:20;height:40'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'margin:0 0 0 30;overflow:hidden;width:10;height:10'], + children: [$this->imageNode('/zero-width-clip.png', 'width:10;height:10')], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->images); + self::assertSame(['x' => 30.0, 'y' => 70.0, 'width' => 0.0, 'height' => 10.0], $result->images[0]->clipBox); + } + + public function testLayoutKeepsZeroHeightForNestedHiddenClipBoxIntersection(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow:hidden;width:40;height:20'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'margin:30 0 0 0;overflow:hidden;width:10;height:10'], + children: [$this->imageNode('/zero-height-clip.png', 'width:10;height:10')], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->images); + self::assertSame(['x' => 0.0, 'y' => 50.0, 'width' => 10.0, 'height' => 0.0], $result->images[0]->clipBox); + } + + public function testLayoutClampsClipBoxYAtZeroWhenContainerExtendsBelowCanvas(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'position:absolute;left:0;top:70;overflow:hidden;width:20;height:20'], + children: [$this->imageNode('/below-canvas-clip.png', 'width:20;height:20')], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->images); + self::assertSame(['x' => 0.0, 'y' => 0.0, 'width' => 20.0, 'height' => 20.0], $result->images[0]->clipBox); + } + + public function testLayoutKeepsFixedHeightAndDecorationForClippedFlexContainers(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: [ + 'style' => 'display:flex;overflow:hidden;width:100;height:60;padding:2;' + . 'background-color:#abcdef', + ], + children: [ + $this->imageNode('/flex.png', 'width:10;height:80'), + ], + ), + ], 120.0, 100.0); + + self::assertCount(1, $result->decorations); + self::assertSame(60.0, $result->decorations[0]->height); + self::assertSame(40.0, $result->decorations[0]->y); + } + + public function testLayoutUsesAccumulatedConsumedHeightForLaterPercentageSizedNodes(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + $this->textNode('First'), + $this->textNode('Second'), + $this->imageNode('/remaining.png', 'width:10;height:50%'), + ], 100.0, 100.0); + + self::assertCount(1, $result->images); + self::assertSame(10.0, $result->images[0]->width); + self::assertSame(38.0, $result->images[0]->height); + self::assertSame(38.0, $result->images[0]->y); + } + + public function testLayoutKeepsCollapsedRemainingHeightAtZeroForLaterPercentageImage(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + $this->textNode('Tall line'), + $this->imageNode('/collapsed.png', 'width:10;height:50%'), + ], 100.0, 10.0); + + self::assertCount(1, $result->images); + self::assertSame(10.0, $result->images[0]->width); + self::assertSame(32.0, $result->images[0]->height); + self::assertSame(0.0, $result->images[0]->y); + } + + public function testLayoutKeepsZeroCanvasDimensionsAtZeroForAbsolutePercentageImage(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + $this->imageNode('/zero-canvas.png', 'position:absolute;left:0;top:0;width:100%;height:100%'), + ], 0.0, 0.0); + + self::assertCount(1, $result->images); + self::assertSame(32.0, $result->images[0]->width); + self::assertSame(32.0, $result->images[0]->height); + self::assertSame(0.0, $result->images[0]->x); + self::assertSame(0.0, $result->images[0]->y); + } + + public function testLayoutFallsBackToDefaultImageSizeWhenResolvedDimensionsAreZero(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + $this->imageNode('/zero-dimension.png', 'width:0;height:0'), + ], 100.0, 100.0); + + self::assertCount(1, $result->images); + self::assertSame(32.0, $result->images[0]->width); + self::assertSame(32.0, $result->images[0]->height); + self::assertSame(68.0, $result->images[0]->y); + } + + public function testLayoutAssignsSequentialAliasesToRenderedImages(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + $this->imageNode('/first.png', 'width:10;height:10'), + $this->imageNode('/second.png', 'width:10;height:10'), + ], 100.0, 100.0); + + self::assertCount(2, $result->images); + self::assertSame('Im0', $result->images[0]->alias); + self::assertSame('Im1', $result->images[1]->alias); + } + + public function testLayoutKeepsZeroCanvasHeightWhenRenderingTinyText(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + $this->textNode('i', 'font-size:0.1'), + ], 10.0, 0.0); + + self::assertCount(1, $result->lines); + self::assertSame(0.0, $result->lines[0]->y); + } + private function createRenderer(): StructuredLayoutRenderer { return new StructuredLayoutRenderer(new InlineStyleParser(), new LayoutStyleResolver()); diff --git a/tests/Unit/Layout/TextBoxLayouterTest.php b/tests/Unit/Layout/TextBoxLayouterTest.php new file mode 100644 index 0000000..10072cf --- /dev/null +++ b/tests/Unit/Layout/TextBoxLayouterTest.php @@ -0,0 +1,526 @@ + + */ + public static function alignmentProvider(): iterable + { + yield 'center' => ['center']; + yield 'right' => ['right']; + } + + public function testLayoutWrapsTextIntoMultipleMeasuredLines(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10'); + + $result = $layouter->layout( + 'Wrap this text', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertSame('Wrap this', $result['lines'][0]->text); + self::assertSame('text', $result['lines'][1]->text); + self::assertEqualsWithDelta(24.0, $result['consumedHeight'], 0.0001); + self::assertFalse($result['truncated']); + } + + public function testLayoutUsesPdfCanvasCoordinatesForWrappedLines(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10'); + + $result = $layouter->layout( + 'Wrap this text', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertSame(88.0, $result['lines'][0]->y); + self::assertSame(76.0, $result['lines'][1]->y); + } + + public function testLayoutClampsLineYBelowCanvasToZero(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;white-space:nowrap'); + + $result = $layouter->layout( + 'Wrap this', + $style, + ['x' => 0.0, 'y' => 95.0, 'width' => 90.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertSame(0.0, $result['lines'][0]->y); + } + + public function testLayoutTreatsWhitespaceOnlyInputAsEmptyAndNotTruncated(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;overflow:hidden;text-overflow:ellipsis;height:12'); + + $result = $layouter->layout( + ' ', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ); + + self::assertSame([], $result['lines']); + self::assertSame(0.0, $result['consumedHeight']); + self::assertFalse($result['truncated']); + } + + public function testLayoutAddsWordSpacingOnlyToIntermediateJustifiedLines(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;text-align:justify'); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertGreaterThan(0.0, $result['lines'][0]->wordSpacing); + self::assertSame(0.0, $result['lines'][1]->wordSpacing); + } + + public function testLayoutSupportsUppercaseTrimmedJustifyAlignment(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;text-align: JUSTIFY '); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertGreaterThan(0.0, $result['lines'][0]->wordSpacing); + self::assertSame(0.0, $result['lines'][1]->wordSpacing); + } + + public function testLayoutHyphenatesLongWordsWhenAutoIsEnabled(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;hyphens:auto'); + + $result = $layouter->layout( + 'Supercalifragilistic', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], + 100.0, + ); + + self::assertGreaterThan(1, count($result['lines'])); + self::assertStringEndsWith('-', $result['lines'][0]->text); + } + + public function testLayoutSupportsUppercaseTrimmedAutoHyphenation(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;hyphens: AUTO '); + + $result = $layouter->layout( + 'Supercalifragilistic', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], + 100.0, + ); + + self::assertGreaterThan(1, count($result['lines'])); + self::assertStringEndsWith('-', $result['lines'][0]->text); + } + + public function testLayoutUsesManualSoftHyphenHints(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;hyphens:manual'); + + $result = $layouter->layout( + "hyphen\u{00AD}ation", + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertSame('hyphen-', $result['lines'][0]->text); + self::assertSame('ation', $result['lines'][1]->text); + } + + public function testLayoutKeepsLongWordOnSingleLineWhenHyphenationIsDisabled(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;hyphens:none;white-space:nowrap'); + + $result = $layouter->layout( + 'Supercalifragilistic', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertSame('Supercalifragilistic', $result['lines'][0]->text); + self::assertFalse($result['truncated']); + } + + public function testLayoutSupportsUppercaseTrimmedNowrapWhiteSpace(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;hyphens:none;white-space: NOWRAP '); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertSame('Wrap this text nicely', $result['lines'][0]->text); + self::assertFalse($result['truncated']); + } + + public function testLayoutPreservesProvidedClipBoxWhenOverflowIsHidden(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;overflow:hidden;text-overflow:ellipsis;height:12'); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ['x' => 5.0, 'y' => 6.0, 'width' => 30.0, 'height' => 8.0], + ); + + self::assertCount(1, $result['lines']); + self::assertSame(['x' => 5.0, 'y' => 86.0, 'width' => 30.0, 'height' => 8.0], $result['lines'][0]->clipBox); + } + + public function testLayoutClampsProvidedClipBoxBelowCanvasToZero(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;overflow:hidden;text-overflow:ellipsis;height:12'); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ['x' => 5.0, 'y' => 95.0, 'width' => 30.0, 'height' => 8.0], + ); + + self::assertCount(1, $result['lines']); + self::assertSame(['x' => 5.0, 'y' => 0.0, 'width' => 30.0, 'height' => 8.0], $result['lines'][0]->clipBox); + } + + public function testLayoutDoesNotTruncateWhenOverflowIsHiddenButHeightIsZero(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;overflow:hidden;text-overflow:ellipsis;height:0'); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertFalse($result['truncated']); + self::assertNull($result['lines'][0]->clipBox); + } + + public function testLayoutDoesNotAddEllipsisWhenTextOverflowIsClip(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse( + 'font-size:10;overflow:hidden;text-overflow:clip;white-space:nowrap;height:12', + ); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertFalse($result['truncated']); + self::assertSame('Wrap this text nicely', $result['lines'][0]->text); + } + + public function testLayoutDoesNotAddEllipsisWhenTruncationUsesClipOverflow(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse( + 'font-size:10;overflow:hidden;text-overflow:clip;height:12', + ); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertTrue($result['truncated']); + self::assertSame('Wrap this', $result['lines'][0]->text); + } + + public function testLayoutAddsEllipsisToSingleNowrapLineThatOverflows(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse( + 'font-size:10;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;height:12', + ); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertTrue($result['truncated']); + self::assertStringEndsWith('...', $result['lines'][0]->text); + self::assertSame(['x' => 0.0, 'y' => 88.0, 'width' => 50.0, 'height' => 12.0], $result['lines'][0]->clipBox); + } + + public function testLayoutDoesNotAddEllipsisWhenNowrapLineFitsExactly(): void + { + $fontMetrics = new StandardFontMetrics(); + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), $fontMetrics); + $style = (new InlineStyleParser())->parse( + 'font-size:10;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;height:12', + ); + $text = 'Wrap this'; + $boxWidth = $fontMetrics->measureString('Helvetica', 10.0, $text); + + $result = $layouter->layout( + $text, + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => $boxWidth, 'height' => 12.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertFalse($result['truncated']); + self::assertSame($text, $result['lines'][0]->text); + } + + public function testLayoutTruncatesWithEllipsisWhenOverflowIsHidden(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse( + 'font-size:10;overflow:hidden;text-overflow:ellipsis;height:12', + ); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertTrue($result['truncated']); + self::assertStringEndsWith('...', $result['lines'][0]->text); + self::assertSame(['x' => 0.0, 'y' => 88.0, 'width' => 50.0, 'height' => 12.0], $result['lines'][0]->clipBox); + } + + public function testLayoutTruncatesWithUppercaseTrimmedOverflowAndEllipsis(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse( + 'font-size:10;overflow: HIDDEN ;text-overflow: ELLIPSIS ;height:12', + ); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertTrue($result['truncated']); + self::assertStringEndsWith('...', $result['lines'][0]->text); + self::assertSame(['x' => 0.0, 'y' => 88.0, 'width' => 50.0, 'height' => 12.0], $result['lines'][0]->clipBox); + } + + public function testLayoutTruncatesToTwoVisibleLinesWhenHeightRoundsUp(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse( + 'font-size:10;overflow:hidden;text-overflow:ellipsis;height:13', + ); + + $result = $layouter->layout( + 'Wrap this text nicely again please', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 13.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertSame('Wrap this', $result['lines'][0]->text); + self::assertStringEndsWith('...', $result['lines'][1]->text); + self::assertTrue($result['truncated']); + } + + public function testLayoutComputesExactWordSpacingForJustifiedIntermediateLine(): void + { + $fontMetrics = new StandardFontMetrics(); + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), $fontMetrics); + $style = (new InlineStyleParser())->parse('font-size:10;text-align:justify'); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + + $expectedSpacing = (50.0 - $fontMetrics->measureString('Helvetica', 10.0, 'Wrap this')) / 1.0; + + self::assertCount(2, $result['lines']); + self::assertSame('Wrap this', $result['lines'][0]->text); + self::assertEqualsWithDelta($expectedSpacing, $result['lines'][0]->wordSpacing, 0.0001); + } + + public function testLayoutKeepsWordSpacingAtZeroWhenJustifiedLineHasNoSpaces(): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10;text-align:justify;hyphens:none'); + + $result = $layouter->layout( + 'Supercalifragilistic text', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertSame('Supercalifragilistic', $result['lines'][0]->text); + self::assertSame(0.0, $result['lines'][0]->wordSpacing); + } + + public function testLayoutDividesJustifiedExtraWidthAcrossMultipleSpaces(): void + { + $fontMetrics = new StandardFontMetrics(); + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), $fontMetrics); + $style = (new InlineStyleParser())->parse('font-size:10;text-align:justify'); + $firstLine = 'Wrap this text'; + $boxWidth = $fontMetrics->measureString('Helvetica', 10.0, $firstLine) + 6.0; + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => $boxWidth, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertSame($firstLine, $result['lines'][0]->text); + self::assertEqualsWithDelta(3.0, $result['lines'][0]->wordSpacing, 0.0001); + } + + public function testLayoutKeepsWordSpacingAtZeroWhenJustifiedLineFitsExactly(): void + { + $fontMetrics = new StandardFontMetrics(); + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), $fontMetrics); + $style = (new InlineStyleParser())->parse('font-size:10;text-align:justify'); + $firstLine = 'Wrap this text'; + $boxWidth = $fontMetrics->measureString('Helvetica', 10.0, $firstLine); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => $boxWidth, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertSame($firstLine, $result['lines'][0]->text); + self::assertSame(0.0, $result['lines'][0]->wordSpacing); + } + + /** + * @dataProvider alignmentProvider + */ + public function testLayoutResolvesAlignedXPositions(string $alignment): void + { + $fontMetrics = new StandardFontMetrics(); + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), $fontMetrics); + $style = (new InlineStyleParser())->parse(sprintf('font-size:10;text-align:%s;white-space:nowrap', $alignment)); + + $result = $layouter->layout( + 'Wrap this', + $style, + ['x' => 10.0, 'y' => 0.0, 'width' => 90.0, 'height' => 0.0], + 100.0, + ); + + $line = $result['lines'][0]; + $lineWidth = $fontMetrics->measureString($line->fontAlias, $line->fontSize, $line->text); + + $expectedX = match ($alignment) { + 'center' => 10.0 + ((90.0 - $lineWidth) / 2.0), + 'right' => 10.0 + (90.0 - $lineWidth), + }; + + self::assertEqualsWithDelta($expectedX, $line->x, 0.0001); + } + + /** + * @dataProvider alignmentProvider + */ + public function testLayoutClampsOverflowedAlignedLineToBoxX(string $alignment): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse(sprintf('font-size:10;text-align:%s;white-space:nowrap', $alignment)); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 7.0, 'y' => 0.0, 'width' => 10.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertSame(7.0, $result['lines'][0]->x); + } +} diff --git a/tests/Unit/Pdf/ColorParserTest.php b/tests/Unit/Pdf/ColorParserTest.php index 4bf4aee..7714128 100644 --- a/tests/Unit/Pdf/ColorParserTest.php +++ b/tests/Unit/Pdf/ColorParserTest.php @@ -21,6 +21,14 @@ public function testToPdfRgbConvertsSupportedFormats(string $input, string $expe self::assertSame($expected, $parser->toPdfRgb($input)); } + #[DataProvider('strokeColorProvider')] + public function testToPdfStrokeRgbConvertsSupportedFormats(string $input, string $expected): void + { + $parser = new ColorParser(); + + self::assertSame($expected, $parser->toPdfStrokeRgb($input)); + } + /** * @return iterable */ @@ -56,4 +64,25 @@ public static function colorProvider(): iterable 'expected' => '0 0 0 rg', ]; } + + /** + * @return iterable + */ + public static function strokeColorProvider(): iterable + { + yield 'six-digit hex stroke' => [ + 'input' => '#123456', + 'expected' => '0.0706 0.2039 0.3373 RG', + ]; + + yield 'three-digit hex stroke' => [ + 'input' => '#abc', + 'expected' => '0.6667 0.7333 0.8 RG', + ]; + + yield 'invalid stroke color falls back to black' => [ + 'input' => 'not-a-color', + 'expected' => '0 0 0 RG', + ]; + } } diff --git a/tests/Unit/Pdf/StandardFontMetricsTest.php b/tests/Unit/Pdf/StandardFontMetricsTest.php new file mode 100644 index 0000000..57b845c --- /dev/null +++ b/tests/Unit/Pdf/StandardFontMetricsTest.php @@ -0,0 +1,41 @@ +measureString('F1', 10.0, 'iiii'); + $wide = $metrics->measureString('F1', 10.0, 'WWWW'); + + self::assertGreaterThan($narrow, $wide); + } + + public function testMeasureStringUsesFixedWidthMetricsForCourierFonts(): void + { + $metrics = new StandardFontMetrics(); + + $narrow = $metrics->measureString('F5', 10.0, 'iiii'); + $wide = $metrics->measureString('F5', 10.0, 'WWWW'); + + self::assertEqualsWithDelta($narrow, $wide, 0.0001); + } + + public function testMeasureStringFallsBackToReasonableWidthForUnknownGlyphs(): void + { + $metrics = new StandardFontMetrics(); + + self::assertGreaterThan(0.0, $metrics->measureString('F1', 10.0, "😀")); + } +} diff --git a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php index e08fd80..7ce473c 100644 --- a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php +++ b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php @@ -8,6 +8,7 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; use LibreSign\XObjectTemplate\Dto\CompileRequest; +use LibreSign\XObjectTemplate\Layout\LayoutDecoration; use LibreSign\XObjectTemplate\Layout\LayoutImage; use LibreSign\XObjectTemplate\Layout\LayoutLine; use LibreSign\XObjectTemplate\Layout\LayoutResult; @@ -164,6 +165,324 @@ public function testBuildContentStreamUsesAbsoluteTextMatrixForMultipleLines(): self::assertStringNotContainsString(' Td', $stream); } + public function testBuildContentStreamKeepsGroupedTextInSingleTextObjectAndResetsWordSpacingOnlyWhenNeeded(): void + { + $builder = new TemplateDocumentBuilder(); + + $stream = $builder->buildContentStream(new LayoutResult( + lines: [ + new LayoutLine( + text: 'Alpha', + x: 10.0, + y: 60.0, + fontSize: 10.0, + fontAlias: 'F1', + rgbColor: '#000000', + ), + new LayoutLine( + text: 'Beta', + x: 10.0, + y: 48.0, + fontSize: 10.0, + fontAlias: 'F1', + rgbColor: '#000000', + wordSpacing: 2.5, + ), + new LayoutLine( + text: 'Gamma', + x: 10.0, + y: 36.0, + fontSize: 10.0, + fontAlias: 'F1', + rgbColor: '#000000', + ), + ], + images: [], + )); + + $this->assertSame(1, substr_count($stream, 'BT')); + $this->assertSame(1, substr_count($stream, 'ET')); + $this->assertSame(1, substr_count($stream, '2.500000 Tw')); + $this->assertSame(1, substr_count($stream, '0.000000 Tw')); + $this->assertStringNotContainsString("0.000000 Tw\n1 0 0 1 10.000000 60.000000 Tm", $stream); + $this->assertStringContainsString("0.000000 Tw\n1 0 0 1 10.000000 36.000000 Tm", $stream); + } + + public function testBuildContentStreamSupportsWordSpacingAndTextClipping(): void + { + $builder = new TemplateDocumentBuilder(); + + $stream = $builder->buildContentStream(new LayoutResult( + lines: [ + new LayoutLine( + text: 'Wrap this', + x: 10.0, + y: 50.0, + fontSize: 10.0, + fontAlias: 'F1', + rgbColor: '#000000', + wordSpacing: 2.5, + clipBox: ['x' => 8.0, 'y' => 48.0, 'width' => 60.0, 'height' => 12.0], + ), + ], + images: [], + )); + + self::assertStringContainsString('8.000000 48.000000 60.000000 12.000000 re W n', $stream); + self::assertStringContainsString('2.500000 Tw', $stream); + self::assertStringContainsString('(Wrap this) Tj', $stream); + } + + public function testBuildContentStreamOmitsWordSpacingOperatorForClippedZeroSpacingText(): void + { + $builder = new TemplateDocumentBuilder(); + + $stream = $builder->buildContentStream(new LayoutResult( + lines: [ + new LayoutLine( + text: 'Clip', + x: 10.0, + y: 50.0, + fontSize: 10.0, + fontAlias: 'F1', + rgbColor: '#000000', + clipBox: ['x' => 8.0, 'y' => 48.0, 'width' => 60.0, 'height' => 12.0], + ), + ], + images: [], + )); + + $this->assertStringContainsString( + implode("\n", [ + 'q', + 'q', + '8.000000 48.000000 60.000000 12.000000 re W n', + 'BT', + '/F1 10.000000 Tf', + '0 0 0 rg', + '1 0 0 1 10.000000 50.000000 Tm', + '(Clip) Tj', + 'ET', + 'Q', + ]), + $stream, + ); + $this->assertStringNotContainsString(' Tw', $stream); + } + + public function testBuildContentStreamSupportsClippedImages(): void + { + $builder = new TemplateDocumentBuilder(); + + $stream = $builder->buildContentStream(new LayoutResult( + lines: [], + images: [ + new LayoutImage( + alias: 'Im4', + x: 9.0, + y: 10.0, + width: 7.0, + height: 8.0, + source: '/clip.png', + clipBox: ['x' => 1.0, 'y' => 2.0, 'width' => 3.0, 'height' => 4.0], + ), + ], + )); + + $this->assertSame( + implode("\n", [ + 'q', + 'q', + '1.000000 2.000000 3.000000 4.000000 re W n', + '7.000000 0 0 8.000000 9.000000 10.000000 cm /Im4 Do', + 'Q', + 'Q', + ]), + $stream, + ); + } + + public function testBuildContentStreamRendersRoundedVectorDecorations(): void + { + $builder = new TemplateDocumentBuilder(); + + $stream = $builder->buildContentStream(new LayoutResult( + lines: [], + images: [], + decorations: [ + new LayoutDecoration( + x: 5.0, + y: 6.0, + width: 40.0, + height: 20.0, + fillColor: '#abcdef', + strokeColor: '#123456', + strokeWidth: 1.5, + borderRadius: 4.0, + ), + ], + )); + + self::assertStringContainsString('0.6706 0.8039 0.9373 rg', $stream); + self::assertStringContainsString('0.0706 0.2039 0.3373 RG', $stream); + self::assertStringContainsString('1.500000 w', $stream); + self::assertStringContainsString(" c\n", $stream); + self::assertStringContainsString('B', $stream); + } + + public function testBuildContentStreamRendersRoundedVectorDecorationWithExactPathGeometry(): void + { + $builder = new TemplateDocumentBuilder(); + + $stream = $builder->buildContentStream(new LayoutResult( + lines: [], + images: [], + decorations: [ + new LayoutDecoration( + x: 5.0, + y: 6.0, + width: 40.0, + height: 20.0, + fillColor: '#abcdef', + strokeColor: '#123456', + strokeWidth: 1.5, + borderRadius: 4.0, + ), + ], + )); + + $this->assertSame( + implode("\n", [ + 'q', + 'q', + '0.6706 0.8039 0.9373 rg', + '0.0706 0.2039 0.3373 RG', + '1.500000 w', + '9.000000 6.000000 m', + '41.000000 6.000000 l', + '43.209139 6.000000 45.000000 7.790861 45.000000 10.000000 c', + '45.000000 22.000000 l', + '45.000000 24.209139 43.209139 26.000000 41.000000 26.000000 c', + '9.000000 26.000000 l', + '6.790861 26.000000 5.000000 24.209139 5.000000 22.000000 c', + '5.000000 10.000000 l', + '5.000000 7.790861 6.790861 6.000000 9.000000 6.000000 c', + 'h', + 'B', + 'Q', + 'Q', + ]), + $stream, + ); + } + + public function testBuildContentStreamClampsRoundedVectorRadiusToHalfTheSmallestDimension(): void + { + $builder = new TemplateDocumentBuilder(); + + $stream = $builder->buildContentStream(new LayoutResult( + lines: [], + images: [], + decorations: [ + new LayoutDecoration( + x: 2.0, + y: 3.0, + width: 8.0, + height: 8.0, + fillColor: '#ffffff', + borderRadius: 10.0, + ), + ], + )); + + $this->assertSame( + implode("\n", [ + 'q', + 'q', + '1 1 1 rg', + '6.000000 3.000000 m', + '6.000000 3.000000 l', + '8.209139 3.000000 10.000000 4.790861 10.000000 7.000000 c', + '10.000000 7.000000 l', + '10.000000 9.209139 8.209139 11.000000 6.000000 11.000000 c', + '6.000000 11.000000 l', + '3.790861 11.000000 2.000000 9.209139 2.000000 7.000000 c', + '2.000000 7.000000 l', + '2.000000 4.790861 3.790861 3.000000 6.000000 3.000000 c', + 'h', + 'f', + 'Q', + 'Q', + ]), + $stream, + ); + } + + public function testBuildContentStreamRendersFillOnlyStrokeOnlyAndEmptyDecorationsDistinctly(): void + { + $builder = new TemplateDocumentBuilder(); + + $fillOnlyStream = $builder->buildContentStream(new LayoutResult( + lines: [], + images: [], + decorations: [ + new LayoutDecoration(x: 1.0, y: 2.0, width: 5.0, height: 6.0, fillColor: '#010203'), + ], + )); + $strokeOnlyStream = $builder->buildContentStream(new LayoutResult( + lines: [], + images: [], + decorations: [ + new LayoutDecoration( + x: 1.0, + y: 2.0, + width: 5.0, + height: 6.0, + strokeColor: '#040506', + strokeWidth: 1.5, + ), + ], + )); + $emptyStream = $builder->buildContentStream(new LayoutResult( + lines: [], + images: [], + decorations: [ + new LayoutDecoration(x: 1.0, y: 2.0, width: 5.0, height: 6.0), + ], + )); + + $this->assertSame( + implode("\n", [ + 'q', + 'q', + '0.0039 0.0078 0.0118 rg', + '1.000000 2.000000 5.000000 6.000000 re', + 'f', + 'Q', + 'Q', + ]), + $fillOnlyStream, + ); + $this->assertSame( + implode("\n", [ + 'q', + 'q', + '0.0157 0.0196 0.0235 RG', + '1.500000 w', + '1.000000 2.000000 5.000000 6.000000 re', + 'S', + 'Q', + 'Q', + ]), + $strokeOnlyStream, + ); + $this->assertSame(2, substr_count($emptyStream, 'q')); + $this->assertSame(2, substr_count($emptyStream, 'Q')); + $this->assertStringNotContainsString(' re', $emptyStream); + $this->assertStringNotContainsString(' rg', $emptyStream); + $this->assertStringNotContainsString(' RG', $emptyStream); + } + public function testBuildResourcesExposesImageDictionaryAndCustomFontsFromDerivedBuilder(): void { $builder = (new TemplateDocumentBuilder())->withFontResources([ diff --git a/tests/Unit/XObjectTemplateCompilerTest.php b/tests/Unit/XObjectTemplateCompilerTest.php index ad3c0f8..13b831d 100644 --- a/tests/Unit/XObjectTemplateCompilerTest.php +++ b/tests/Unit/XObjectTemplateCompilerTest.php @@ -64,8 +64,8 @@ public static function htmlProvider(): iterable ]; yield 'margin and padding affect position' => [ - 'html' => '

Offset Text

', - 'expectedSnippet' => '1 0 0 1 20.000000 48.000000 Tm', + 'html' => '

Offset Text

', + 'expectedSnippet' => '1 0 0 1 14.000000 48.000000 Tm', ]; }