Skip to content

Commit afe67ea

Browse files
committed
feat: expand css subset support in mvp renderer
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent 0901423 commit afe67ea

5 files changed

Lines changed: 166 additions & 14 deletions

File tree

src/Html/SubsetHtmlParser.php

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public function parse(string $html): array
3535

3636
$nodes = [];
3737
foreach ($body->childNodes as $child) {
38-
$parsed = $this->parseDomNode($child);
38+
$parsed = $this->parseDomNode($child, '');
3939
if ($parsed !== null) {
4040
$nodes[] = $parsed;
4141
}
@@ -44,7 +44,7 @@ public function parse(string $html): array
4444
return $nodes;
4545
}
4646

47-
private function parseDomNode(DOMNode $node): ?Node
47+
private function parseDomNode(DOMNode $node, string $inheritedStyle): ?Node
4848
{
4949
if ($node instanceof DOMElement) {
5050
$tag = strtolower($node->tagName);
@@ -57,17 +57,23 @@ private function parseDomNode(DOMNode $node): ?Node
5757
$attributes[strtolower($attribute->name)] = $attribute->value;
5858
}
5959

60+
$ownStyle = $attributes['style'] ?? '';
61+
$effectiveStyle = $this->mergeStyle($inheritedStyle, $ownStyle);
62+
if ($effectiveStyle !== '') {
63+
$attributes['style'] = $effectiveStyle;
64+
}
65+
6066
$children = [];
6167
foreach ($node->childNodes as $childNode) {
62-
$child = $this->parseDomNode($childNode);
68+
$child = $this->parseDomNode($childNode, $effectiveStyle);
6369
if ($child !== null) {
6470
$children[] = $child;
6571
}
6672
}
6773

6874
return new Node(
6975
tag: $tag,
70-
text: trim($node->textContent ?? ''),
76+
text: '',
7177
attributes: $attributes,
7278
children: $children,
7379
);
@@ -78,6 +84,27 @@ private function parseDomNode(DOMNode $node): ?Node
7884
return null;
7985
}
8086

81-
return new Node(tag: 'span', text: $text);
87+
$attributes = [];
88+
if ($inheritedStyle !== '') {
89+
$attributes['style'] = $inheritedStyle;
90+
}
91+
92+
return new Node(tag: 'span', text: $text, attributes: $attributes);
93+
}
94+
95+
private function mergeStyle(string $inheritedStyle, string $ownStyle): string
96+
{
97+
$inheritedStyle = trim($inheritedStyle);
98+
$ownStyle = trim($ownStyle);
99+
100+
if ($inheritedStyle === '') {
101+
return $ownStyle;
102+
}
103+
104+
if ($ownStyle === '') {
105+
return $inheritedStyle;
106+
}
107+
108+
return $inheritedStyle . ';' . $ownStyle;
82109
}
83110
}

src/Layout/LinearLayoutEngine.php

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,42 @@ public function layout(array $nodes, float $width, float $height): LayoutResult
3030

3131
foreach ($this->walk($nodes) as $node) {
3232
$style = $this->styleParser->parse($node->attributes['style'] ?? '');
33+
$margin = $this->parseBoxSpacing((string) $style->get('margin', '0'));
34+
$padding = $this->parseBoxSpacing((string) $style->get('padding', '0'));
35+
36+
$cursorY -= ($margin['top'] + $padding['top']);
37+
3338
$fontSize = $this->toPoints($style->get('font-size', '10'));
3439
$lineHeight = max($fontSize * 1.2, $this->toPoints($style->get('line-height', (string) ($fontSize * 1.2))));
40+
$fontAlias = $this->resolveFontAlias((string) $style->get('font-family', 'helvetica'), (string) $style->get('font-weight', 'normal'));
41+
42+
$boxWidth = $this->toPoints((string) $style->get('width', '0'));
43+
if ($boxWidth <= 0) {
44+
$boxWidth = max($width - $margin['left'] - $margin['right'] - $padding['left'] - $padding['right'], 0);
45+
}
46+
$leftBase = $margin['left'] + $padding['left'];
47+
$rightBase = $leftBase + $boxWidth;
3548

3649
if ($node->tag === 'img') {
37-
$imgWidth = $this->toPoints($style->get('width', '32'));
38-
$imgHeight = $this->toPoints($style->get('height', '32'));
50+
$imgWidth = $this->toPoints((string) $style->get('width', '32'));
51+
$imgHeight = $this->toPoints((string) $style->get('height', '32'));
52+
if ($imgWidth <= 0) {
53+
$imgWidth = 32.0;
54+
}
55+
if ($imgHeight <= 0) {
56+
$imgHeight = 32.0;
57+
}
58+
3959
$images[] = new LayoutImage(
4060
alias: 'Im' . $imageCount,
41-
x: 4.0,
61+
x: $leftBase,
4262
y: max($cursorY - $imgHeight, 0),
4363
width: min($imgWidth, $width),
4464
height: min($imgHeight, $height),
4565
source: $node->attributes['src'] ?? '',
4666
);
4767
++$imageCount;
48-
$cursorY -= ($imgHeight + 2.0);
68+
$cursorY -= ($imgHeight + 2.0 + $margin['bottom'] + $padding['bottom']);
4969
continue;
5070
}
5171

@@ -61,21 +81,21 @@ public function layout(array $nodes, float $width, float $height): LayoutResult
6181

6282
$align = strtolower((string) $style->get('text-align', 'left'));
6383
$x = match ($align) {
64-
'center' => $width / 2.0,
65-
'right' => max($width - 8.0, 0),
66-
default => 8.0,
84+
'center' => $leftBase + ($boxWidth / 2.0),
85+
'right' => max($rightBase - 8.0, 0),
86+
default => $leftBase + 8.0,
6787
};
6888

6989
$lines[] = new LayoutLine(
7090
text: $text,
7191
x: $x,
7292
y: max($cursorY, 0),
7393
fontSize: $fontSize,
74-
fontAlias: 'F1',
94+
fontAlias: $fontAlias,
7595
rgbColor: (string) $style->get('color', '#000000'),
7696
);
7797

78-
$cursorY -= $lineHeight;
98+
$cursorY -= ($lineHeight + $margin['bottom'] + $padding['bottom']);
7999
}
80100

81101
return new LayoutResult(lines: $lines, images: $images);
@@ -112,4 +132,64 @@ private function toPoints(string $value): float
112132

113133
return $number;
114134
}
135+
136+
/**
137+
* @return array{top: float, right: float, bottom: float, left: float}
138+
*/
139+
private function parseBoxSpacing(string $value): array
140+
{
141+
$tokens = preg_split('/\s+/', trim($value));
142+
$tokens = array_values(array_filter($tokens ?: [], static fn (string $token): bool => $token !== ''));
143+
144+
if ($tokens === []) {
145+
return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
146+
}
147+
148+
$points = array_map(fn (string $token): float => $this->toPoints($token), $tokens);
149+
$count = count($points);
150+
151+
if ($count === 1) {
152+
return ['top' => $points[0], 'right' => $points[0], 'bottom' => $points[0], 'left' => $points[0]];
153+
}
154+
155+
if ($count === 2) {
156+
return ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[0], 'left' => $points[1]];
157+
}
158+
159+
if ($count === 3) {
160+
return ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[1]];
161+
}
162+
163+
return ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[3]];
164+
}
165+
166+
private function resolveFontAlias(string $fontFamily, string $fontWeight): string
167+
{
168+
$primary = strtolower(trim(explode(',', $fontFamily)[0], " \t\n\r\0\x0B'\""));
169+
$isBold = $this->isBoldWeight($fontWeight);
170+
171+
if (str_contains($primary, 'times')) {
172+
return $isBold ? 'F4' : 'F3';
173+
}
174+
175+
if (str_contains($primary, 'courier')) {
176+
return $isBold ? 'F6' : 'F5';
177+
}
178+
179+
return $isBold ? 'F2' : 'F1';
180+
}
181+
182+
private function isBoldWeight(string $fontWeight): bool
183+
{
184+
$normalized = strtolower(trim($fontWeight));
185+
if ($normalized === 'bold' || $normalized === 'bolder') {
186+
return true;
187+
}
188+
189+
if (is_numeric($normalized)) {
190+
return (int) $normalized >= 600;
191+
}
192+
193+
return false;
194+
}
115195
}

src/XObjectTemplateCompiler.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,31 @@ public function compile(CompileRequest $request): CompileResult
6262
'Subtype' => '/Type1',
6363
'BaseFont' => '/Helvetica',
6464
],
65+
'F2' => [
66+
'Type' => '/Font',
67+
'Subtype' => '/Type1',
68+
'BaseFont' => '/Helvetica-Bold',
69+
],
70+
'F3' => [
71+
'Type' => '/Font',
72+
'Subtype' => '/Type1',
73+
'BaseFont' => '/Times-Roman',
74+
],
75+
'F4' => [
76+
'Type' => '/Font',
77+
'Subtype' => '/Type1',
78+
'BaseFont' => '/Times-Bold',
79+
],
80+
'F5' => [
81+
'Type' => '/Font',
82+
'Subtype' => '/Type1',
83+
'BaseFont' => '/Courier',
84+
],
85+
'F6' => [
86+
'Type' => '/Font',
87+
'Subtype' => '/Type1',
88+
'BaseFont' => '/Courier-Bold',
89+
],
6590
],
6691
'XObject' => [],
6792
];

tests/Integration/VisibleSignatureBusinessRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,10 @@ public static function signatureScenarioProvider(): iterable
3838
'html' => '<img src="/fixture/sign.png" style="width:20px;height:20px" /><span style="font-size:9">ID 42</span>',
3939
'maxStreamLength' => 1400,
4040
];
41+
42+
yield 'styled signer with alignment and spacing' => [
43+
'html' => '<div style="font-family:Times New Roman;font-weight:700;text-align:right;margin:6;padding:2;width:220">Signed by Styled User</div>',
44+
'maxStreamLength' => 1800,
45+
];
4146
}
4247
}

tests/Unit/XObjectTemplateCompilerTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,20 @@ public static function htmlProvider(): iterable
4343
'html' => '<img src="/tmp/signature.png" style="width:24px;height:24px" />',
4444
'expectedSnippet' => '/Im0 Do',
4545
];
46+
47+
yield 'bold font weight mapping' => [
48+
'html' => '<p style="font-size:10;font-weight:bold">Bold Name</p>',
49+
'expectedSnippet' => '/F2 10.000000 Tf',
50+
];
51+
52+
yield 'font family mapping times' => [
53+
'html' => '<p style="font-size:10;font-family:Times New Roman">Times Text</p>',
54+
'expectedSnippet' => '/F3 10.000000 Tf',
55+
];
56+
57+
yield 'margin and padding affect position' => [
58+
'html' => '<p style="font-size:10;margin:8;padding:4">Offset Text</p>',
59+
'expectedSnippet' => '20.000000 48.000000 Td',
60+
];
4661
}
4762
}

0 commit comments

Comments
 (0)