Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ Using a uniform placement scale keeps text, images, spacing, and line breaks vis
Recompiling only to emulate a proportional resize is usually the wrong integration point for this
package.

For consumers that want a small helper instead of recalculating the matrix manually, the package also
ships `LibreSign\XObjectTemplate\Integration\XObjectPlacementCalculator` and
`LibreSign\XObjectTemplate\Integration\XObjectPlacement`.

```php
use LibreSign\XObjectTemplate\Integration\XObjectPlacementCalculator;

$placement = (new XObjectPlacementCalculator())->fromWidth($result, 175.0, 36.0, 72.0);

$pdfCommand = $placement->toPdfCommand('Fm0');
// q 0.729167 0 0 0.729167 36.000000 72.000000 cm /Fm0 Do Q
```

### Optional context interpolation

If the caller passes `CompileRequest::context`, the compiler can interpolate simple `{{ key }}`
placeholders before parsing the HTML subset.

- Values are HTML-escaped before insertion
- Unknown placeholders are left untouched
- Twig users can keep rendering HTML upstream and skip this feature entirely

## Supported HTML/CSS subset

### HTML
Expand All @@ -101,8 +123,9 @@ package.

### CSS used by the renderer

- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`
- Layout: `margin`, `padding`, `text-align`, `width`, `height`
- Typography: `font-size`, `font-family`, `font-weight`, `line-height`, `color`, `text-align`, `hyphens`, `white-space`
- Layout: `margin`, `padding`, `width`, `height`, `overflow`, `text-overflow`
- Vector box styling: `background-color`, `border-color`, `border-width`, `border-radius`
- 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 `%`
Expand All @@ -112,6 +135,10 @@ package.
### Rendering notes

- Font family mapping currently targets the built-in Helvetica, Times, and Courier aliases used by the generated PDF resources
- Text alignment uses measured widths for left, center, right, and basic justified output (`Tw` word spacing)
- Hyphenation supports a small deterministic subset: `hyphens:auto`, `hyphens:manual` with soft hyphens, and `hyphens:none`
- Overflow clipping uses PDF clipping paths; `text-overflow:ellipsis` applies when hidden overflow truncates visible text
- Backgrounds and borders are emitted as vector rectangles, including rounded corners
- 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
Expand Down
53 changes: 53 additions & 0 deletions src/Html/HtmlContextInterpolator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

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

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Html;

final readonly class HtmlContextInterpolator
{
/**
* @param array<string, scalar> $context
*/
public function interpolate(string $html, array $context): string
{
if ($context === []) {
return $html;
}

if (!str_contains($html, '{{')) {
return $html;
}

$interpolated = preg_replace_callback(
'/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/',
function (array $matches) use ($context): string {
$key = $matches[1] ?? '';
if ($key === '' || !array_key_exists($key, $context)) {
return $matches[0];
}

return htmlspecialchars(
$this->normalizeScalar($context[$key]),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8',
);
},
$html,
);

return $interpolated ?? $html;
}

private function normalizeScalar(string|int|float|bool $value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}

return strval($value);
}
}
24 changes: 11 additions & 13 deletions src/Html/SubsetHtmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ final class SubsetHtmlParser
'font-family' => true,
'font-size' => true,
'font-weight' => true,
'hyphens' => true,
'line-height' => true,
'text-align' => true,
'white-space' => true,
];

/** @var array<string, true> */
Expand Down Expand Up @@ -158,27 +160,23 @@ private function filterInheritableStyle(string $style): string
}

$resolvedDeclarations = [];
$matches = [];

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

$segments = explode(':', $trimmedDeclaration, 2);
if (count($segments) !== 2) {
continue;
}
preg_match_all('/(?:^|;)\s*([A-Za-z-]+)\s*:\s*([^;]+)\s*/', $style, $matches, PREG_SET_ORDER);
if ($matches === []) {
return '';
}

$property = strtolower(trim($segments[0]));
$value = trim($segments[1]);
foreach ($matches as $match) {
$property = strtolower($match[1]);
$value = trim($match[2]);
if ($value === '' || !isset(self::INHERITABLE_STYLE_PROPERTIES[$property])) {
continue;
}

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

return implode(';', array_values($resolvedDeclarations));
return implode(';', $resolvedDeclarations);
}
}
50 changes: 50 additions & 0 deletions src/Integration/XObjectPlacement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

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

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Integration;

use InvalidArgumentException;

final readonly class XObjectPlacement
{
public function __construct(
public float $scaleX,
public float $scaleY,
public float $width,
public float $height,
public float $translateX,
public float $translateY,
) {
}

public function toPdfCommand(string $alias): string
{
$normalizedAlias = ltrim(trim($alias), '/');
if ($normalizedAlias === '') {
throw new InvalidArgumentException('Placement alias must not be empty.');
}

return sprintf(
'q %s 0 0 %s %s %s cm /%s Do Q',
$this->formatNumber($this->scaleX),
$this->formatNumber($this->scaleY),
$this->formatNumber($this->translateX),
$this->formatNumber($this->translateY),
$normalizedAlias,
);
}

private function formatNumber(float $value): string
{
$formatted = rtrim(rtrim(sprintf('%.6F', $value), '0'), '.');
if ($formatted === '' || $formatted === '-0') {
return '0';
}

return $formatted;
}
}
98 changes: 98 additions & 0 deletions src/Integration/XObjectPlacementCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

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

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Integration;

use InvalidArgumentException;
use LibreSign\XObjectTemplate\Dto\CompileResult;

final readonly class XObjectPlacementCalculator
{
public function fromWidth(
CompileResult $result,
float $targetWidth,
float $x = 0.0,
float $y = 0.0,
): XObjectPlacement {
if ($targetWidth <= 0.0) {
throw new InvalidArgumentException('Placement target width must be greater than zero.');
}

[$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result);
$scale = $targetWidth / $baseWidth;

return new XObjectPlacement(
scaleX: $scale,
scaleY: $scale,
width: $baseWidth * $scale,
height: $baseHeight * $scale,
translateX: $x - ($minX * $scale),
translateY: $y - ($minY * $scale),
);
}

public function fromHeight(
CompileResult $result,
float $targetHeight,
float $x = 0.0,
float $y = 0.0,
): XObjectPlacement {
if ($targetHeight <= 0.0) {
throw new InvalidArgumentException('Placement target height must be greater than zero.');
}

[$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result);
$scale = $targetHeight / $baseHeight;

return new XObjectPlacement(
scaleX: $scale,
scaleY: $scale,
width: $baseWidth * $scale,
height: $baseHeight * $scale,
translateX: $x - ($minX * $scale),
translateY: $y - ($minY * $scale),
);
}

public function fromScale(
CompileResult $result,
float $scale,
float $x = 0.0,
float $y = 0.0,
): XObjectPlacement {
if ($scale <= 0.0) {
throw new InvalidArgumentException('Placement scale must be greater than zero.');
}

[$minX, $minY, $baseWidth, $baseHeight] = $this->resolveBoundingBox($result);

return new XObjectPlacement(
scaleX: $scale,
scaleY: $scale,
width: $baseWidth * $scale,
height: $baseHeight * $scale,
translateX: $x - ($minX * $scale),
translateY: $y - ($minY * $scale),
);
}

/**
* @return array{0: float, 1: float, 2: float, 3: float}
*/
private function resolveBoundingBox(CompileResult $result): array
{
[$minX, $minY, $maxX, $maxY] = $result->bbox;
$width = $maxX - $minX;
$height = $maxY - $minY;

if ($width <= 0.0 || $height <= 0.0) {
throw new InvalidArgumentException('CompileResult bbox must describe a positive area.');
}

return [$minX, $minY, $width, $height];
}
}
23 changes: 23 additions & 0 deletions src/Layout/LayoutDecoration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

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

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Layout;

final readonly class LayoutDecoration
{
public function __construct(
public float $x,
public float $y,
public float $width,
public float $height,
public ?string $fillColor = null,
public ?string $strokeColor = null,
public float $strokeWidth = 0.0,
public float $borderRadius = 0.0,
) {
}
}
4 changes: 4 additions & 0 deletions src/Layout/LayoutImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@

final readonly class LayoutImage
{
/**
* @param array{x: float, y: float, width: float, height: float}|null $clipBox
*/
public function __construct(
public string $alias,
public float $x,
public float $y,
public float $width,
public float $height,
public string $source,
public ?array $clipBox = null,
) {
}
}
5 changes: 5 additions & 0 deletions src/Layout/LayoutLine.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@

final readonly class LayoutLine
{
/**
* @param array{x: float, y: float, width: float, height: float}|null $clipBox
*/
public function __construct(
public string $text,
public float $x,
public float $y,
public float $fontSize,
public string $fontAlias,
public string $rgbColor,
public float $wordSpacing = 0.0,
public ?array $clipBox = null,
) {
}
}
2 changes: 2 additions & 0 deletions src/Layout/LayoutResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
/**
* @param list<LayoutLine> $lines
* @param list<LayoutImage> $images
* @param list<LayoutDecoration> $decorations
*/
public function __construct(
public array $lines,
public array $images,
public array $decorations = [],
) {
}
}
Loading
Loading