diff --git a/infection.json5 b/infection.json5 index ead46a9..92b1ab0 100644 --- a/infection.json5 +++ b/infection.json5 @@ -12,8 +12,8 @@ "phpUnit": { "customPath": "vendor-bin/phpunit/vendor/bin/phpunit" }, - "minMsi": 70, - "minCoveredMsi": 80, + "minMsi": 75, + "minCoveredMsi": 82, "testFramework": "phpunit", "timeout": 10 } diff --git a/phpmd.xml b/phpmd.xml index 0091d18..7bfef05 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -6,15 +6,16 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://pmd.sf.net/ruleset/1.0.0 https://pmd.github.io/pmd-6.55.0/ruleset_xml_schema.xsd"> PHPMD baseline ruleset. - - - - - - - + + + *tests/* + + + + + diff --git a/src/Html/SubsetHtmlParser.php b/src/Html/SubsetHtmlParser.php index bc11bbe..6a2b02f 100644 --- a/src/Html/SubsetHtmlParser.php +++ b/src/Html/SubsetHtmlParser.php @@ -14,6 +14,9 @@ final class SubsetHtmlParser { + private const HTML_WRAPPER = '%s'; + private const LIBXML_HTML_PARSE_FLAGS = 96; // LIBXML_NOERROR | LIBXML_NOWARNING + /** @var array */ private array $allowedTags = [ 'div' => true, @@ -31,14 +34,14 @@ public function parse(string $html): array $dom = new DOMDocument('1.0', 'UTF-8'); $prevLibxmlErrors = libxml_use_internal_errors(true); $dom->loadHTML( - '' . $html . '', - LIBXML_NOERROR | LIBXML_NOWARNING, + sprintf(self::HTML_WRAPPER, $html), + self::LIBXML_HTML_PARSE_FLAGS, ); libxml_clear_errors(); libxml_use_internal_errors($prevLibxmlErrors); $body = $dom->getElementsByTagName('body')->item(0); - if (!$body instanceof DOMElement) { + if ($body === null) { return []; } @@ -64,13 +67,14 @@ private function parseDomNode(DOMNode $node, string $inheritedStyle): ?Node private function parseElementNode(DOMElement $node, string $inheritedStyle): Node { - $tag = strtolower($node->tagName); + $tag = $node->tagName; if (!isset($this->allowedTags[$tag])) { throw new UnsupportedSubsetException(sprintf('Tag <%s> is not supported.', $tag)); } $attributes = $this->collectAttributes($node); $effectiveStyle = $this->mergeStyle($inheritedStyle, $attributes['style'] ?? ''); + unset($attributes['style']); if ($effectiveStyle !== '') { $attributes['style'] = $effectiveStyle; } @@ -112,12 +116,11 @@ private function parseTextNode(DOMNode $node, string $inheritedStyle): ?Node private function collectAttributes(DOMElement $node): array { $attributes = []; - if ($node->attributes === null) { - return $attributes; - } - - foreach ($node->attributes as $attribute) { - $attributes[strtolower($attribute->name)] = $attribute->value; + $nodeAttrs = $node->attributes; + if ($nodeAttrs !== null) { + foreach ($nodeAttrs as $attribute) { + $attributes[$attribute->name] = trim($attribute->value); + } } return $attributes; @@ -125,9 +128,6 @@ private function collectAttributes(DOMElement $node): array private function mergeStyle(string $inheritedStyle, string $ownStyle): string { - $inheritedStyle = trim($inheritedStyle); - $ownStyle = trim($ownStyle); - if ($inheritedStyle === '') { return $ownStyle; } diff --git a/src/Layout/LinearLayoutEngine.php b/src/Layout/LinearLayoutEngine.php index 57e17f7..277188a 100644 --- a/src/Layout/LinearLayoutEngine.php +++ b/src/Layout/LinearLayoutEngine.php @@ -33,22 +33,19 @@ public function layout(array $nodes, float $width, float $height): LayoutResult foreach ($this->walk($nodes) as $node) { $style = $this->styleParser->parse($node->attributes['style'] ?? ''); - $margin = $this->parseBoxSpacing((string) $style->get('margin', '0')); - $padding = $this->parseBoxSpacing((string) $style->get('padding', '0')); + $margin = $this->parseBoxSpacing($this->styleValue($style, 'margin', '0')); + $padding = $this->parseBoxSpacing($this->styleValue($style, 'padding', '0')); $cursorY -= ($margin['top'] + $padding['top']); - $fontSize = $this->toPoints((string) $style->get('font-size', '10')); - $lineHeight = max( - $fontSize * 1.2, - $this->toPoints((string) $style->get('line-height', (string) ($fontSize * 1.2))), - ); + $fontSize = $this->toPoints($this->styleValue($style, 'font-size', '10')); + $lineHeight = $this->resolveLineHeight($style, $fontSize); $fontAlias = $this->resolveFontAlias( - (string) $style->get('font-family', 'helvetica'), - (string) $style->get('font-weight', 'normal'), + $this->styleValue($style, 'font-family', 'helvetica'), + $this->styleValue($style, 'font-weight', 'normal'), ); - $boxWidth = $this->toPoints((string) $style->get('width', '0')); + $boxWidth = $this->toPoints($this->styleValue($style, 'width', '0')); if ($boxWidth <= 0) { $boxWidth = max($width - $margin['left'] - $margin['right'] - $padding['left'] - $padding['right'], 0); } @@ -56,8 +53,8 @@ public function layout(array $nodes, float $width, float $height): LayoutResult $rightBase = $leftBase + $boxWidth; if ($node->tag === 'img') { - $imgWidth = $this->toPoints((string) $style->get('width', '32')); - $imgHeight = $this->toPoints((string) $style->get('height', '32')); + $imgWidth = $this->toPoints($this->styleValue($style, 'width', '32')); + $imgHeight = $this->toPoints($this->styleValue($style, 'height', '32')); if ($imgWidth <= 0) { $imgWidth = 32.0; } @@ -88,8 +85,8 @@ public function layout(array $nodes, float $width, float $height): LayoutResult continue; } - $align = strtolower((string) $style->get('text-align', 'left')); - $x = match ($align) { + $align = strtolower($this->styleValue($style, 'text-align', 'left')); + $lineX = match ($align) { 'center' => $leftBase + ($boxWidth / 2.0), 'right' => max($rightBase - 8.0, 0), default => $leftBase + 8.0, @@ -97,11 +94,11 @@ public function layout(array $nodes, float $width, float $height): LayoutResult $lines[] = new LayoutLine( text: $text, - x: $x, + x: $lineX, y: max($cursorY, 0), fontSize: $fontSize, fontAlias: $fontAlias, - rgbColor: (string) $style->get('color', '#000000'), + rgbColor: $this->styleValue($style, 'color', '#000000'), ); $cursorY -= ($lineHeight + $margin['bottom'] + $padding['bottom']); @@ -110,6 +107,14 @@ public function layout(array $nodes, float $width, float $height): LayoutResult return new LayoutResult(lines: $lines, images: $images); } + private function styleValue( + \LibreSign\XObjectTemplate\Css\StyleMap $style, + string $property, + string $default, + ): string { + return $style->get($property, $default) ?? $default; + } + /** * @param list $nodes * @return list @@ -117,10 +122,18 @@ public function layout(array $nodes, float $width, float $height): LayoutResult private function walk(array $nodes): array { $result = []; - foreach ($nodes as $node) { + $stack = array_reverse($nodes); + + while ($stack !== []) { + $node = array_pop($stack); $result[] = $node; - if ($node->children !== []) { - $result = [...$result, ...$this->walk($node->children)]; + + if ($node->children === []) { + continue; + } + + foreach (array_reverse($node->children) as $child) { + $stack[] = $child; } } @@ -129,11 +142,7 @@ private function walk(array $nodes): array private function toPoints(string $value): float { - $normalized = trim(strtolower($value)); - if ($normalized === '') { - return 0.0; - } - + $normalized = strtolower($value); $number = (float) preg_replace('/[^0-9.\-]/', '', $normalized); if (str_ends_with($normalized, 'px')) { return $number * 0.75; @@ -142,13 +151,27 @@ private function toPoints(string $value): float return $number; } + private function resolveLineHeight( + \LibreSign\XObjectTemplate\Css\StyleMap $style, + float $fontSize, + ): float { + $defaultLineHeight = $fontSize * 1.2; + $configuredLineHeight = $this->styleValue($style, 'line-height', ''); + + if ($configuredLineHeight === '') { + return $defaultLineHeight; + } + + return max($defaultLineHeight, $this->toPoints($configuredLineHeight)); + } + /** * @return array{top: float, right: float, bottom: float, left: float} */ private function parseBoxSpacing(string $value): array { - $tokens = preg_split('/\s+/', trim($value)); - $tokens = array_values(array_filter($tokens ?: [], static fn (string $token): bool => $token !== '')); + preg_match_all('/\S+/', $value, $matches); + $tokens = $matches[0]; if ($tokens === []) { return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0]; @@ -174,7 +197,7 @@ private function parseBoxSpacing(string $value): array private function resolveFontAlias(string $fontFamily, string $fontWeight): string { - $primary = strtolower(trim(explode(',', $fontFamily)[0], " \t\n\r\0\x0B'\"")); + $primary = strtolower(explode(',', $fontFamily)[0]); $isBold = $this->isBoldWeight($fontWeight); if (str_contains($primary, 'times')) { @@ -190,13 +213,13 @@ private function resolveFontAlias(string $fontFamily, string $fontWeight): strin private function isBoldWeight(string $fontWeight): bool { - $normalized = strtolower(trim($fontWeight)); + $normalized = strtolower($fontWeight); if ($normalized === 'bold' || $normalized === 'bolder') { return true; } if (is_numeric($normalized)) { - return (int) $normalized >= 600; + return $normalized >= 600; } return false; diff --git a/src/Pdf/ColorParser.php b/src/Pdf/ColorParser.php index 548c77f..8e02870 100644 --- a/src/Pdf/ColorParser.php +++ b/src/Pdf/ColorParser.php @@ -20,10 +20,11 @@ public function toPdfRgb(string $hexColor): string return '0 0 0 rg'; } - $r = round(hexdec(substr($hex, 0, 2)) / 255, 4); - $g = round(hexdec(substr($hex, 2, 2)) / 255, 4); - $b = round(hexdec(substr($hex, 4, 2)) / 255, 4); + $channels = str_split($hex, 2); + $redChannel = round(hexdec($channels[0]) / 255, 4); + $greenChannel = round(hexdec($channels[1]) / 255, 4); + $blueChannel = round(hexdec($channels[2]) / 255, 4); - return sprintf('%s %s %s rg', $r, $g, $b); + return sprintf('%s %s %s rg', $redChannel, $greenChannel, $blueChannel); } } diff --git a/src/Pdf/TemplateDocumentBuilder.php b/src/Pdf/TemplateDocumentBuilder.php index 58eb189..66fa03c 100644 --- a/src/Pdf/TemplateDocumentBuilder.php +++ b/src/Pdf/TemplateDocumentBuilder.php @@ -7,6 +7,7 @@ namespace LibreSign\XObjectTemplate\Pdf; +use Closure; use LibreSign\XObjectTemplate\Dto\CompileRequest; use LibreSign\XObjectTemplate\Dto\CompileResult; use LibreSign\XObjectTemplate\Layout\LayoutImage; @@ -47,6 +48,8 @@ ], ]; + private Closure $clock; + /** * @param array> $fontResources */ @@ -54,7 +57,9 @@ public function __construct( private PdfEscaper $pdfEscaper = new PdfEscaper(), private ColorParser $colorParser = new ColorParser(), private array $fontResources = self::DEFAULT_FONT_RESOURCES, + ?Closure $clock = null, ) { + $this->clock = $clock ?? static fn (): int => hrtime(true); } public function build( @@ -121,7 +126,7 @@ public function buildResources(LayoutResult $layout): array public function buildMetadata(LayoutResult $layout, int $startedAtNs, int $nodeCount = 0): array { return [ - 'render_ms' => round((hrtime(true) - $startedAtNs) / 1_000_000, 3), + 'render_ms' => round((($this->clock)() - $startedAtNs) / 1_000_000, 3), 'line_count' => count($layout->lines), 'image_count' => count($layout->images), 'node_count' => $nodeCount, @@ -130,7 +135,7 @@ public function buildMetadata(LayoutResult $layout, int $startedAtNs, int $nodeC public function withFontResources(array $fontResources): self { - return new self($this->pdfEscaper, $this->colorParser, $fontResources); + return new self($this->pdfEscaper, $this->colorParser, $fontResources, $this->clock); } private function buildImageCommand(LayoutImage $image): string diff --git a/tests/Unit/Css/InlineStyleParserTest.php b/tests/Unit/Css/InlineStyleParserTest.php index 6d03110..98b613f 100644 --- a/tests/Unit/Css/InlineStyleParserTest.php +++ b/tests/Unit/Css/InlineStyleParserTest.php @@ -8,6 +8,7 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Css; use LibreSign\XObjectTemplate\Css\InlineStyleParser; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class InlineStyleParserTest extends TestCase @@ -42,5 +43,47 @@ public function testParseSkipsEmptyNameOrValueWithoutStoppingTheLoop(): void self::assertSame('red', $map->get('color')); self::assertSame('4', $map->get('padding')); self::assertNull($map->get('font-size')); + self::assertNull($map->get('')); + } + + #[DataProvider('invalidChunkProvider')] + public function testParseSkipsEachInvalidChunkVariantAndContinuesParsing( + string $style, + array $expectedPresent, + array $expectedAbsent, + ): void { + $parser = new InlineStyleParser(); + + $map = $parser->parse($style); + + foreach ($expectedPresent as $name => $value) { + self::assertSame($value, $map->get($name)); + } + + foreach ($expectedAbsent as $name) { + self::assertNull($map->get($name)); + } + } + + /** + * @return iterable, + * expectedAbsent: list + * }> + */ + public static function invalidChunkProvider(): iterable + { + yield 'empty name does not stop later declarations' => [ + 'style' => ':red; color:blue; padding:1', + 'expectedPresent' => ['color' => 'blue', 'padding' => '1'], + 'expectedAbsent' => [''], + ]; + + yield 'empty value does not stop later declarations' => [ + 'style' => 'font-size:; color:green; margin:2', + 'expectedPresent' => ['color' => 'green', 'margin' => '2'], + 'expectedAbsent' => ['font-size'], + ]; } } diff --git a/tests/Unit/Html/SubsetHtmlParserTest.php b/tests/Unit/Html/SubsetHtmlParserTest.php index f1c6a3b..42acb36 100644 --- a/tests/Unit/Html/SubsetHtmlParserTest.php +++ b/tests/Unit/Html/SubsetHtmlParserTest.php @@ -7,6 +7,7 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Html; +use DOMDocument; use LibreSign\XObjectTemplate\Exception\UnsupportedSubsetException; use LibreSign\XObjectTemplate\Html\SubsetHtmlParser; use PHPUnit\Framework\TestCase; @@ -64,7 +65,7 @@ public function testParseNormalizesTagAndAttributeNamesAndKeepsAllAttributes(): { $parser = new SubsetHtmlParser(); - $nodes = $parser->parse('
Hi
'); + $nodes = $parser->parse('
Hi
'); self::assertCount(1, $nodes); self::assertSame('div', $nodes[0]->tag); @@ -74,13 +75,25 @@ public function testParseNormalizesTagAndAttributeNamesAndKeepsAllAttributes(): self::assertSame('42', $nodes[0]->attributes['data-id']); } + public function testParseDoesNotKeepEmptyStyleAttributeWhenNoStyleIsResolved(): void + { + $parser = new SubsetHtmlParser(); + + $nodes = $parser->parse('
Hello
'); + + self::assertCount(1, $nodes); + self::assertArrayNotHasKey('style', $nodes[0]->attributes); + self::assertArrayNotHasKey('style', $nodes[0]->children[0]->attributes); + self::assertArrayNotHasKey('style', $nodes[0]->children[0]->children[0]->attributes); + } + public function testParseTrimsInheritedStyleAndRestoresLibxmlInternalErrorFlag(): void { $parser = new SubsetHtmlParser(); $previous = libxml_use_internal_errors(false); try { - $nodes = $parser->parse('
Hi
'); + $nodes = $parser->parse('
Hi
'); } finally { $current = libxml_use_internal_errors(false); libxml_use_internal_errors($previous); @@ -88,6 +101,64 @@ public function testParseTrimsInheritedStyleAndRestoresLibxmlInternalErrorFlag() self::assertFalse($current); self::assertSame('font-size:10', $nodes[0]->attributes['style']); - self::assertSame('font-size:10', $nodes[0]->children[0]->children[0]->attributes['style']); + self::assertSame( + 'font-size:10;font-weight:bold', + $nodes[0]->children[0]->children[0]->attributes['style'], + ); + } + + public function testParseClearsLibxmlErrorsAfterMalformedHtmlAndRestoresPreviousFlag(): void + { + $parser = new SubsetHtmlParser(); + libxml_clear_errors(); + $previous = libxml_use_internal_errors(true); + + try { + $nodes = $parser->parse('
Broken'); + $current = libxml_use_internal_errors(true); + $errors = libxml_get_errors(); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + + self::assertTrue($current); + self::assertSame([], $errors); + self::assertCount(1, $nodes); + self::assertSame('div', $nodes[0]->tag); + self::assertSame('Broken', $nodes[0]->children[0]->children[0]->text); + } + + public function testParsePreservesUtf8CharactersFromHtmlFragment(): void + { + $parser = new SubsetHtmlParser(); + + $nodes = $parser->parse('ação € 😀'); + + self::assertCount(1, $nodes); + self::assertSame('ação € 😀', $nodes[0]->children[0]->text); + } + + public function testParseClearsPreExistingLibxmlErrorBuffer(): void + { + $parser = new SubsetHtmlParser(); + libxml_clear_errors(); + $previous = libxml_use_internal_errors(true); + + try { + $probe = new DOMDocument('1.0', 'UTF-8'); + $probe->loadXML(''); + $errorsBefore = libxml_get_errors(); + + $parser->parse('
ok
'); + + $errorsAfter = libxml_get_errors(); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + + self::assertNotSame([], $errorsBefore); + self::assertSame([], $errorsAfter); } } diff --git a/tests/Unit/Layout/LinearLayoutEngineTest.php b/tests/Unit/Layout/LinearLayoutEngineTest.php index f8e9002..50cc5cd 100644 --- a/tests/Unit/Layout/LinearLayoutEngineTest.php +++ b/tests/Unit/Layout/LinearLayoutEngineTest.php @@ -7,9 +7,13 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Layout; +use LibreSign\XObjectTemplate\Css\InlineStyleParser; use LibreSign\XObjectTemplate\Html\Node; use LibreSign\XObjectTemplate\Layout\LinearLayoutEngine; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use ReflectionProperty; final class LinearLayoutEngineTest extends TestCase { @@ -206,4 +210,247 @@ public function testLayoutTreatsZeroImageHeightAsDefaultDimension(): void self::assertEqualsWithDelta(12.0, $result->images[0]->width, 0.0001); self::assertEqualsWithDelta(32.0, $result->images[0]->height, 0.0001); } + + public function testConstructorKeepsProvidedInlineStyleParserInstance(): void + { + $styleParser = new InlineStyleParser(); + $engine = new LinearLayoutEngine($styleParser); + + $property = new ReflectionProperty($engine, 'styleParser'); + + self::assertSame($styleParser, $property->getValue($engine)); + } + + public function testLayoutNormalizesTrimmedSpacingFontAndAlignmentValues(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'p', + text: 'Trimmed', + attributes: [ + 'style' => 'margin: 4px 8px ; padding: 2px 6px 4px 8px ; ' + . 'font-size: 10PX; text-align: RIGHT ; color:#123456', + ], + ), + ], 100.0, 90.0); + + self::assertCount(1, $result->lines); + self::assertSame('Trimmed', $result->lines[0]->text); + self::assertEqualsWithDelta(81.5, $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); + } + + public function testLayoutTrimsTextUsesDefaultLineHeightMultiplierAndAdvancesBreaks(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node(tag: 'span', text: ' Trim me ', attributes: ['style' => 'font-size:10']), + new Node(tag: 'br', text: '', attributes: []), + new Node(tag: 'span', text: 'Next', attributes: ['style' => 'font-size:10']), + ], 120.0, 90.0); + + self::assertCount(2, $result->lines); + self::assertSame('Trim me', $result->lines[0]->text); + self::assertSame('Next', $result->lines[1]->text); + self::assertEqualsWithDelta(78.0, $result->lines[0]->y, 0.0001); + self::assertEqualsWithDelta(54.0, $result->lines[1]->y, 0.0001); + } + + public function testLayoutClampsRightAlignmentAndLineYToZeroOnTinyCanvas(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'span', + text: 'Tiny', + attributes: ['style' => 'text-align:right;width:0;margin:0;padding:0;font-size:10'], + ), + ], 4.0, 6.0); + + self::assertCount(1, $result->lines); + self::assertEqualsWithDelta(0.0, $result->lines[0]->x, 0.0001); + self::assertEqualsWithDelta(0.0, $result->lines[0]->y, 0.0001); + } + + public function testLayoutPrefersExplicitLineHeightWhenItExceedsFontDefault(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node(tag: 'span', text: 'First', attributes: ['style' => 'font-size:10;line-height:30']), + new Node(tag: 'br', text: '', attributes: []), + new Node(tag: 'span', text: 'Second', attributes: ['style' => 'font-size:10']), + ], 120.0, 120.0); + + self::assertCount(2, $result->lines); + self::assertEqualsWithDelta(108.0, $result->lines[0]->y, 0.0001); + self::assertEqualsWithDelta(66.0, $result->lines[1]->y, 0.0001); + } + + public function testLayoutKeepsZeroFallbackWidthForCenteredTinyCanvas(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'span', + text: 'Center', + attributes: ['style' => 'text-align:center;width:0;font-size:10'], + ), + ], 0.0, 20.0); + + self::assertCount(1, $result->lines); + self::assertEqualsWithDelta(0.0, $result->lines[0]->x, 0.0001); + } + + public function testLayoutClampsNegativeAvailableWidthToZero(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'span', + text: 'Negative width', + attributes: ['style' => 'text-align:center;width:0;font-size:10'], + ), + ], -1.0, 20.0); + + self::assertCount(1, $result->lines); + self::assertEqualsWithDelta(0.0, $result->lines[0]->x, 0.0001); + } + + public function testLayoutSubtractsBottomSpacingFromFollowingLinePosition(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node(tag: 'span', text: 'First', attributes: ['style' => 'font-size:10;margin:0 0 5;padding:0 0 7']), + new Node(tag: 'span', text: 'Second', attributes: ['style' => 'font-size:10']), + ], 100.0, 100.0); + + self::assertCount(2, $result->lines); + self::assertEqualsWithDelta(88.0, $result->lines[0]->y, 0.0001); + self::assertEqualsWithDelta(64.0, $result->lines[1]->y, 0.0001); + } + + public function testLayoutUsesThreeValueSpacingRightSlotForHorizontalPositioning(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'span', + text: 'Three values', + attributes: ['style' => 'font-size:10;text-align:right;margin:1 20 3;width:0'], + ), + ], 100.0, 100.0); + + self::assertCount(1, $result->lines); + self::assertEqualsWithDelta(72.0, $result->lines[0]->x, 0.0001); + } + + public function testLayoutRecognizesTrimmedUppercaseBoldTokens(): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout([ + new Node( + tag: 'span', + text: 'Bold text', + attributes: ['style' => 'font-size:10;font-family:Courier;font-weight: BOLD '], + ), + new Node( + tag: 'span', + text: 'Numeric bold', + attributes: ['style' => 'font-size:10;font-family:Helvetica;font-weight: 600 '], + ), + ], 120.0, 100.0); + + self::assertCount(2, $result->lines); + self::assertSame('F6', $result->lines[0]->fontAlias); + self::assertSame('F2', $result->lines[1]->fontAlias); + } + + #[DataProvider('nestedTraversalProvider')] + public function testLayoutKeepsDepthFirstTraversalOrderForNestedNodes(array $nodes, array $expectedTexts): void + { + $engine = new LinearLayoutEngine(); + + $result = $engine->layout($nodes, 240.0, 90.0); + + self::assertSame($expectedTexts, array_map(static fn ($line): string => $line->text, $result->lines)); + } + + public function testLayoutHandlesDeeplyNestedTreeWithoutDroppingLeafText(): void + { + $engine = new LinearLayoutEngine(); + + $depth = 120; + $leaf = new Node(tag: 'span', text: 'Deep leaf', attributes: ['style' => 'font-size:10']); + for ($i = 0; $i < $depth; ++$i) { + $leaf = new Node(tag: 'div', text: '', attributes: [], children: [$leaf]); + } + + $result = $engine->layout([$leaf], 240.0, 90.0); + + self::assertCount(1, $result->lines); + self::assertSame('Deep leaf', $result->lines[0]->text); + } + + public function testParseBoxSpacingReturnsZeroSlotsForWhitespaceOnlyInput(): void + { + $engine = new LinearLayoutEngine(); + $method = new ReflectionMethod($engine, 'parseBoxSpacing'); + + /** @var array{top: float, right: float, bottom: float, left: float} $spacing */ + $spacing = $method->invoke($engine, ' '); + + self::assertSame( + ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0], + $spacing, + ); + } + + /** + * @return iterable, expectedTexts: list}> + */ + public static function nestedTraversalProvider(): iterable + { + yield 'root before children and sibling after subtree' => [ + 'nodes' => [ + new Node( + tag: 'div', + text: 'A', + attributes: ['style' => 'font-size:10'], + children: [ + new Node(tag: 'span', text: 'B', attributes: ['style' => 'font-size:10']), + new Node( + tag: 'span', + text: 'C', + attributes: ['style' => 'font-size:10'], + children: [ + new Node(tag: 'span', text: 'D', attributes: ['style' => 'font-size:10']), + ], + ), + ], + ), + new Node(tag: 'p', text: 'E', attributes: ['style' => 'font-size:10']), + ], + 'expectedTexts' => ['A', 'B', 'C', 'D', 'E'], + ]; + + yield 'multiple roots preserve insertion order' => [ + 'nodes' => [ + new Node(tag: 'span', text: 'First', attributes: ['style' => 'font-size:10']), + new Node(tag: 'span', text: 'Second', attributes: ['style' => 'font-size:10']), + ], + 'expectedTexts' => ['First', 'Second'], + ]; + } } diff --git a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php index 80b644f..ba5d8d8 100644 --- a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php +++ b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php @@ -12,6 +12,7 @@ use LibreSign\XObjectTemplate\Layout\LayoutLine; use LibreSign\XObjectTemplate\Layout\LayoutResult; use LibreSign\XObjectTemplate\Pdf\TemplateDocumentBuilder; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class TemplateDocumentBuilderTest extends TestCase @@ -66,17 +67,15 @@ public function testBuildCreatesDocumentPayloadParts(): void public function testBuildMetadataDefaultsNodeCountAndUsesRoundedMilliseconds(): void { - $builder = new TemplateDocumentBuilder(); + $builder = new TemplateDocumentBuilder(clock: static fn (): int => 2_000_000); $layout = new LayoutResult(lines: [], images: []); - $startedAtNs = hrtime(true) - 2_000_000; - $metadata = $builder->buildMetadata($layout, $startedAtNs); + $metadata = $builder->buildMetadata($layout, 0); self::assertSame(0, $metadata['line_count']); self::assertSame(0, $metadata['image_count']); self::assertSame(0, $metadata['node_count']); - self::assertGreaterThan(0.5, $metadata['render_ms']); - self::assertLessThan(1000.0, $metadata['render_ms']); + self::assertSame(2.0, $metadata['render_ms']); } public function testBuilderBuildsPayloadWithCustomMetadataCount(): void @@ -107,4 +106,90 @@ public function testBuilderBuildUsesDefaultNodeCountWhenNotProvided(): void self::assertSame(0, $result->metadata['node_count']); } + + public function testBuildContentStreamIsDirectlyUsableForImagesAndEscapedText(): void + { + $builder = new TemplateDocumentBuilder(); + $stream = $builder->buildContentStream(new LayoutResult( + lines: [ + new LayoutLine( + text: 'Signer (QA)', + x: 12.0, + y: 22.0, + fontSize: 9.0, + fontAlias: 'F2', + rgbColor: '#abcdef', + ), + ], + images: [ + new LayoutImage(alias: 'Im7', x: 1.0, y: 2.0, width: 3.0, height: 4.0, source: '/img.png'), + ], + )); + + self::assertStringContainsString('q 3.000000 0 0 4.000000 1.000000 2.000000 cm /Im7 Do Q', $stream); + self::assertStringContainsString('/F2 9.000000 Tf', $stream); + self::assertStringContainsString('0.6706 0.8039 0.9373 rg', $stream); + self::assertStringContainsString('(Signer \\(QA\\)) Tj', $stream); + } + + public function testBuildResourcesExposesImageDictionaryAndCustomFontsFromDerivedBuilder(): void + { + $builder = (new TemplateDocumentBuilder())->withFontResources([ + 'Z9' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'BaseFont' => '/Courier', + ], + ]); + + $resources = $builder->buildResources(new LayoutResult( + lines: [], + images: [ + new LayoutImage(alias: 'Im9', x: 0.0, y: 0.0, width: 10.0, height: 11.0, source: '/proof.png'), + ], + )); + + self::assertArrayHasKey('Z9', $resources['Font']); + self::assertArrayNotHasKey('F1', $resources['Font']); + self::assertSame('/proof.png', $resources['XObject']['Im9']['Source']); + self::assertSame(10.0, $resources['XObject']['Im9']['Width']); + self::assertSame(11.0, $resources['XObject']['Im9']['Height']); + } + + #[DataProvider('metadataRoundingProvider')] + public function testBuildMetadataUsesDeterministicClockRounding( + int $finishedAtNs, + int $startedAtNs, + float $expectedRenderMs, + ): void { + $builder = new TemplateDocumentBuilder(clock: static fn (): int => $finishedAtNs); + + $metadata = $builder->buildMetadata(new LayoutResult(lines: [], images: []), $startedAtNs); + + self::assertSame($expectedRenderMs, $metadata['render_ms']); + } + + /** + * @return iterable + */ + public static function metadataRoundingProvider(): iterable + { + yield 'three-decimal rounding stays exact' => [ + 'finishedAtNs' => 123_456_789, + 'startedAtNs' => 0, + 'expectedRenderMs' => 123.457, + ]; + + yield 'denominator stays at one million nanoseconds' => [ + 'finishedAtNs' => 500_499_001, + 'startedAtNs' => 0, + 'expectedRenderMs' => 500.499, + ]; + + yield 'elapsed time subtracts start and keeps third-decimal rounding' => [ + 'finishedAtNs' => 510_499_500, + 'startedAtNs' => 10_000_000, + 'expectedRenderMs' => 500.5, + ]; + } } diff --git a/tests/Unit/XObjectTemplateCompilerTest.php b/tests/Unit/XObjectTemplateCompilerTest.php index 76384ba..ec032c2 100644 --- a/tests/Unit/XObjectTemplateCompilerTest.php +++ b/tests/Unit/XObjectTemplateCompilerTest.php @@ -8,12 +8,15 @@ namespace LibreSign\XObjectTemplate\Tests\Unit; use LibreSign\XObjectTemplate\Dto\CompileRequest; +use LibreSign\XObjectTemplate\Html\SubsetHtmlParser; +use LibreSign\XObjectTemplate\Layout\LinearLayoutEngine; use LibreSign\XObjectTemplate\Pdf\ColorParser; use LibreSign\XObjectTemplate\Pdf\PdfEscaper; use LibreSign\XObjectTemplate\Pdf\TemplateDocumentBuilder; use LibreSign\XObjectTemplate\XObjectTemplateCompiler; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionProperty; final class XObjectTemplateCompilerTest extends TestCase { @@ -85,17 +88,45 @@ public function testCompileUsesProvidedTemplateDocumentBuilderInstance(): void public function testCompilerConstructorStillAcceptsLegacyPdfDependencies(): void { + $pdfEscaper = new PdfEscaper(); + $colorParser = new ColorParser(); $compiler = new XObjectTemplateCompiler( null, null, - new PdfEscaper(), - new ColorParser(), + $pdfEscaper, + $colorParser, ); $result = $compiler->compile(new CompileRequest(html: '

Hello

')); + $builderProp = new ReflectionProperty($compiler, 'documentBuilder'); + $builder = $builderProp->getValue($compiler); + $escProp = new ReflectionProperty($builder, 'pdfEscaper'); + $colProp = new ReflectionProperty($builder, 'colorParser'); + self::assertStringContainsString('(Hello) Tj', $result->contentStream); self::assertSame([0.0, 0.0, 240.0, 84.0], $result->bbox); self::assertArrayHasKey('Font', $result->resources); + self::assertSame($pdfEscaper, $escProp->getValue($builder)); + self::assertSame($colorParser, $colProp->getValue($builder)); + } + + private function getBuilderProperty(object $obj, string $name): object + { + $prop = new ReflectionProperty($obj, $name); + return $prop->getValue($obj); + } + + public function testCompilerConstructorKeepsProvidedParserAndLayoutInstances(): void + { + $htmlParser = new SubsetHtmlParser(); + $layoutEngine = new LinearLayoutEngine(); + $compiler = new XObjectTemplateCompiler($htmlParser, $layoutEngine); + + $htmlParserProperty = new ReflectionProperty($compiler, 'htmlParser'); + $layoutEngineProperty = new ReflectionProperty($compiler, 'layoutEngine'); + + self::assertSame($htmlParser, $htmlParserProperty->getValue($compiler)); + self::assertSame($layoutEngine, $layoutEngineProperty->getValue($compiler)); } }