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));
}
}