Skip to content
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,41 @@ file_put_contents(__DIR__ . '/build/preview.pdf', $pdf);
- `$payload`: transport-agnostic array with `stream`, `resources`, and `bbox`
- `$pdf`: standalone PDF bytes ready to save, stream, or attach to preview workflows

### Scaling a compiled XObject

`CompileRequest::width` and `CompileRequest::height` define the base design size of the template.
If a downstream consumer needs to place the compiled stamp at a different size while preserving the
original aspect ratio, it should keep the compiled XObject unchanged and apply a uniform scale during
PDF placement instead of recompiling the HTML with new dimensions.

- Read the base size from `$result->bbox`
- Compute a single scale factor from the target width or target height
- Apply the same scale to both axes in the placement matrix

```php
[$minX, $minY, $maxX, $maxY] = $result->bbox;

$baseWidth = $maxX - $minX;
$baseHeight = $maxY - $minY;

$targetWidth = 175.0;
$scale = $targetWidth / $baseWidth;
$targetHeight = $baseHeight * $scale;

// Consumer-side PDF placement concept:
$placement = sprintf(
'q %F 0 0 %F %F %F cm /Fm0 Do Q',
$scale,
$scale,
$x,
$y,
);
```

Using a uniform placement scale keeps text, images, spacing, and line breaks visually aligned.
Recompiling only to emulate a proportional resize is usually the wrong integration point for this
package.

## Supported HTML/CSS subset

### HTML
Expand All @@ -68,13 +103,17 @@ file_put_contents(__DIR__ . '/build/preview.pdf', $pdf);

- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`
- Layout: `margin`, `padding`, `text-align`, `width`, `height`
- Numeric values can be provided as unitless numbers or `px`
- 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 `%`
- `px` values are converted to PDF points using the package conversion rules
- Unknown or incomplete CSS declarations are ignored instead of aborting the render

### Rendering notes

- Font family mapping currently targets the built-in Helvetica, Times, and Courier aliases used by the generated PDF resources
- 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
- Image and text placement are clamped to the requested output box
- The compiler output is not tied to any single downstream package; any consumer that understands Form XObject stream/resources/bbox data can use it
Expand Down
2 changes: 1 addition & 1 deletion infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"minMsi": 75,
"minCoveredMsi": 82,
"testFramework": "phpunit",
"timeout": 10
"timeout": 120
}
41 changes: 41 additions & 0 deletions src/Html/SubsetHtmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
{
private const HTML_WRAPPER = '<?xml encoding="utf-8" ?><body>%s</body>';
private const LIBXML_HTML_PARSE_FLAGS = 96; // LIBXML_NOERROR | LIBXML_NOWARNING
private const INHERITABLE_STYLE_PROPERTIES = [
'color' => true,
'font-family' => true,
'font-size' => true,
'font-weight' => true,
'line-height' => true,
'text-align' => true,
];

/** @var array<string, true> */
private array $allowedTags = [
Expand Down Expand Up @@ -130,6 +138,8 @@

private function mergeStyle(string $inheritedStyle, string $ownStyle): string
{
$inheritedStyle = $this->filterInheritableStyle($inheritedStyle);

if ($inheritedStyle === '') {
return $ownStyle;
}
Expand All @@ -140,4 +150,35 @@

return $inheritedStyle . ';' . $ownStyle;
}

private function filterInheritableStyle(string $style): string
{
if ($style === '') {
return '';
}

$resolvedDeclarations = [];

foreach (explode(';', $style) as $declaration) {
$trimmedDeclaration = trim($declaration);

Check warning on line 163 in src/Html/SubsetHtmlParser.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "UnwrapTrim": @@ @@ } $resolvedDeclarations = []; foreach (explode(';', $style) as $declaration) { - $trimmedDeclaration = trim($declaration); + $trimmedDeclaration = $declaration; if ($trimmedDeclaration === '') { continue; }
if ($trimmedDeclaration === '') {
continue;
}

$segments = explode(':', $trimmedDeclaration, 2);

Check warning on line 168 in src/Html/SubsetHtmlParser.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "IncrementInteger": @@ @@ if ($trimmedDeclaration === '') { continue; } - $segments = explode(':', $trimmedDeclaration, 2); + $segments = explode(':', $trimmedDeclaration, 3); if (count($segments) !== 2) { continue; }
if (count($segments) !== 2) {
continue;
}

$property = strtolower(trim($segments[0]));

Check warning on line 173 in src/Html/SubsetHtmlParser.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "UnwrapTrim": @@ @@ if (count($segments) !== 2) { continue; } - $property = strtolower(trim($segments[0])); + $property = strtolower($segments[0]); $value = trim($segments[1]); if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) { continue;

Check warning on line 173 in src/Html/SubsetHtmlParser.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "UnwrapStrToLower": @@ @@ if (count($segments) !== 2) { continue; } - $property = strtolower(trim($segments[0])); + $property = trim($segments[0]); $value = trim($segments[1]); if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) { continue;
$value = trim($segments[1]);

Check warning on line 174 in src/Html/SubsetHtmlParser.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "UnwrapTrim": @@ @@ continue; } $property = strtolower(trim($segments[0])); - $value = trim($segments[1]); + $value = $segments[1]; if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) { continue; }
if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) {
continue;
}

$resolvedDeclarations[$property] = $property . ':' . $value;
}

return implode(';', array_values($resolvedDeclarations));

Check warning on line 182 in src/Html/SubsetHtmlParser.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "UnwrapArrayValues": @@ @@ } $resolvedDeclarations[$property] = $property . ':' . $value; } - return implode(';', array_values($resolvedDeclarations)); + return implode(';', $resolvedDeclarations); } }
}
}
151 changes: 151 additions & 0 deletions src/Layout/LayoutStyleResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Layout;

use LibreSign\XObjectTemplate\Css\StyleMap;

final readonly class LayoutStyleResolver
{
public function styleValue(StyleMap $style, string $property, string $default): string
{
return $style->get($property, $default) ?? $default;
}

public function toPoints(string $value): float
{
$normalized = strtolower($value);
$number = (float) preg_replace('/[^0-9.\-]/', '', $normalized);
if (str_ends_with($normalized, 'px')) {
return $number * 0.75;
}

return $number;
}

public function resolveRelativeDimension(string $value, float $reference): float
{
$normalized = strtolower(trim($value));

Check warning on line 32 in src/Layout/LayoutStyleResolver.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "UnwrapStrToLower": @@ @@ } public function resolveRelativeDimension(string $value, float $reference): float { - $normalized = strtolower(trim($value)); + $normalized = trim($value); if ($normalized === '') { return 0.0; }
if ($normalized === '') {
return 0.0;
}

if (str_ends_with($normalized, '%')) {
$number = (float) preg_replace('/[^0-9.\-]/', '', $normalized);

Check warning on line 38 in src/Layout/LayoutStyleResolver.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ return 0.0; } if (str_ends_with($normalized, '%')) { - $number = (float) preg_replace('/[^0-9.\-]/', '', $normalized); + $number = preg_replace('/[^0-9.\-]/', '', $normalized); return $reference * ($number / 100.0); } return $this->toPoints($normalized);

return $reference * ($number / 100.0);
}

return $this->toPoints($normalized);
}

public function resolveLineHeight(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}
*/
public function parseBoxSpacing(string $value): array
{
preg_match_all('/\S+/', $value, $matches);
$tokens = $matches[0];

if ($tokens === []) {
return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
}

$points = array_map(fn (string $token): float => $this->toPoints($token), $tokens);
$count = count($points);

return match ($count) {
1 => ['top' => $points[0], 'right' => $points[0], 'bottom' => $points[0], 'left' => $points[0]],
2 => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[0], 'left' => $points[1]],
3 => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[1]],
default => ['top' => $points[0], 'right' => $points[1], 'bottom' => $points[2], 'left' => $points[3]],
};
}

/**
* @return array{top: float, right: float, bottom: float, left: float}
*/
public function parseBoxSpacingRelative(string $value, float $widthReference, float $heightReference): array
{
preg_match_all('/\S+/', $value, $matches);
$tokens = $matches[0];

if ($tokens === []) {
return ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0];
}

[$top, $right, $bottom, $left] = $this->expandSpacingTokens($tokens);

return [
'top' => $this->resolveRelativeDimension($top, $heightReference),
'right' => $this->resolveRelativeDimension($right, $widthReference),
'bottom' => $this->resolveRelativeDimension($bottom, $heightReference),
'left' => $this->resolveRelativeDimension($left, $widthReference),
];
}

public function resolveFontAlias(string $fontFamily, string $fontWeight): string
{
$primary = strtolower(explode(',', $fontFamily)[0]);
$isBold = $this->isBoldWeight($fontWeight);

if (str_contains($primary, 'times')) {
return $isBold ? 'F4' : 'F3';
}

if (str_contains($primary, 'courier')) {
return $isBold ? 'F6' : 'F5';
}

return $isBold ? 'F2' : 'F1';
}

public function isAbsolutelyPositioned(StyleMap $style): bool
{
return strtolower(trim($this->styleValue($style, 'position', ''))) === 'absolute';

Check warning on line 121 in src/Layout/LayoutStyleResolver.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "UnwrapTrim": @@ @@ } public function isAbsolutelyPositioned(StyleMap $style): bool { - return strtolower(trim($this->styleValue($style, 'position', ''))) === 'absolute'; + return strtolower($this->styleValue($style, 'position', '')) === 'absolute'; } /** * @param list<string> $tokens
}

/**
* @param list<string> $tokens
* @return array{0: string, 1: string, 2: string, 3: string}
*/
private function expandSpacingTokens(array $tokens): array
{
return match (count($tokens)) {
1 => [$tokens[0], $tokens[0], $tokens[0], $tokens[0]],
2 => [$tokens[0], $tokens[1], $tokens[0], $tokens[1]],
3 => [$tokens[0], $tokens[1], $tokens[2], $tokens[1]],
default => [$tokens[0], $tokens[1], $tokens[2], $tokens[3]],
};
}

private function isBoldWeight(string $fontWeight): bool
{
$normalized = strtolower($fontWeight);
if ($normalized === 'bold' || $normalized === 'bolder') {
return true;
}

if (is_numeric($normalized)) {
return $normalized >= 600;
}

return false;
}
}
Loading
Loading