Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f09f50e
test: add regression coverage for nested layout traversal
vitormattos May 28, 2026
d64ee41
refactor: optimize layout node traversal
vitormattos May 28, 2026
abf614b
ci: raise infection quality thresholds
vitormattos May 28, 2026
2fef125
ci: calibrate covered mutation threshold to measured baseline
vitormattos May 28, 2026
4560819
test: strengthen inline style parser mutation coverage
vitormattos May 28, 2026
b658324
test: cover malformed html parser cleanup behavior
vitormattos May 28, 2026
19ff848
test: harden layout engine mutation coverage
vitormattos May 28, 2026
5d55492
test: verify compiler dependency wiring
vitormattos May 28, 2026
cae441a
refactor: simplify color channel extraction
vitormattos May 28, 2026
08b5d6a
refactor: inject deterministic builder clock
vitormattos May 28, 2026
96cbe92
refactor: remove equivalent layout mutation hotspots
vitormattos May 28, 2026
4dae854
refactor: normalize parser style attribute handling
vitormattos May 28, 2026
d749ccc
test: extend inline style parser regressions
vitormattos May 28, 2026
405d940
test: harden subset html parser coverage
vitormattos May 28, 2026
7c6bcbf
test: expand layout engine mutation coverage
vitormattos May 28, 2026
54ab3aa
test: make builder metadata assertions deterministic
vitormattos May 28, 2026
7138b85
refactor: harden html wrapper and parser load flags
vitormattos May 28, 2026
62f9a87
test: add utf8 and libxml buffer parser regressions
vitormattos May 28, 2026
1a0ea70
refactor: harden iterative layout traversal and point parsing
vitormattos May 28, 2026
e498962
refactor: remove unreachable dom attribute null guard
vitormattos May 28, 2026
512a232
test: cover spacing empty-token guard via reflection
vitormattos May 28, 2026
b7829da
fix: resolve psalm null-safety and phpmd code quality violations
vitormattos May 28, 2026
ccbbe06
fix: exclude test classes from phpmd too-many-public-methods rule
vitormattos May 28, 2026
619dc0e
fix: exclude test files from phpmd rules
vitormattos May 28, 2026
5db2b57
fix: keep layout tests in one file and suppress phpmd warning
vitormattos May 28, 2026
9239505
fix: ignore test classes in phpmd ruleset
vitormattos May 28, 2026
dc4eaf7
fix: restore phpmd src validation
vitormattos May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"phpUnit": {
"customPath": "vendor-bin/phpunit/vendor/bin/phpunit"
},
"minMsi": 70,
"minCoveredMsi": 80,
"minMsi": 75,
"minCoveredMsi": 82,
"testFramework": "phpunit",
"timeout": 10
}
15 changes: 8 additions & 7 deletions phpmd.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<description>PHPMD baseline ruleset.</description>
<rule ref="rulesets/cleancode.xml">
<exclude name="ErrorControlOperator"/>
</rule>
<rule ref="rulesets/codesize.xml">
<exclude name="CyclomaticComplexity"/>
<exclude name="NPathComplexity"/>
</rule>
<rule ref="rulesets/cleancode.xml"/>
<rule ref="rulesets/codesize.xml"/>
<exclude-pattern>*tests/*</exclude-pattern>
<rule ref="rulesets/design.xml"/>
<rule ref="rulesets/naming.xml">
<exclude name="ShortVariable"/>
</rule>
<rule ref="rulesets/naming.xml/ShortVariable">
<properties>
<property name="exceptions" value="x,y"/>
</properties>
</rule>
</ruleset>
26 changes: 13 additions & 13 deletions src/Html/SubsetHtmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

final class SubsetHtmlParser
{
private const HTML_WRAPPER = '<?xml encoding="utf-8" ?><body>%s</body>';
private const LIBXML_HTML_PARSE_FLAGS = 96; // LIBXML_NOERROR | LIBXML_NOWARNING

/** @var array<string, true> */
private array $allowedTags = [
'div' => true,
Expand All @@ -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(
'<?xml encoding="utf-8" ?><body>' . $html . '</body>',
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 [];
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -112,22 +116,18 @@ 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;
}

private function mergeStyle(string $inheritedStyle, string $ownStyle): string
{
$inheritedStyle = trim($inheritedStyle);
$ownStyle = trim($ownStyle);

if ($inheritedStyle === '') {
return $ownStyle;
}
Expand Down
81 changes: 52 additions & 29 deletions src/Layout/LinearLayoutEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,31 +33,28 @@ 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);
}
$leftBase = $margin['left'] + $padding['left'];
$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;
}
Expand Down Expand Up @@ -88,20 +85,20 @@ 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,
};

$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']);
Expand All @@ -110,17 +107,33 @@ 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<Node> $nodes
* @return list<Node>
*/
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;
}
}

Expand All @@ -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;
Expand All @@ -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];
Expand All @@ -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')) {
Expand All @@ -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;
Expand Down
9 changes: 5 additions & 4 deletions src/Pdf/ColorParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
9 changes: 7 additions & 2 deletions src/Pdf/TemplateDocumentBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,14 +48,18 @@
],
];

private Closure $clock;

/**
* @param array<string, array<string, mixed>> $fontResources
*/
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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions tests/Unit/Css/InlineStyleParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, array{
* style: string,
* expectedPresent: array<string, string>,
* expectedAbsent: list<string>
* }>
*/
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'],
];
}
}
Loading