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
28 changes: 27 additions & 1 deletion .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ name: coverage
on:
pull_request:

env:
COVERAGE_THRESHOLDS: '95 95'

jobs:
coverage:
runs-on: ubuntu-latest
Expand All @@ -23,4 +26,27 @@ jobs:
coverage: xdebug
- run: composer install --no-interaction --prefer-dist
- run: composer bin phpunit install --no-interaction --prefer-dist
- run: composer run test:coverage
- name: Generate coverage reports
run: XDEBUG_MODE=coverage vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --coverage-text --coverage-clover=build/coverage/clover.xml --coverage-cobertura=build/coverage/cobertura.xml --coverage-html=build/coverage/html
- name: Store temporary coverage cache
uses: actions/cache/save@v4
with:
path: build/coverage
key: coverage-${{ github.event.pull_request.number }}-${{ github.sha }}
- name: Upload temporary coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ github.event.pull_request.number }}-${{ github.sha }}
path: build/coverage
retention-days: 7
- name: Validate minimum line coverage
uses: irongut/CodeCoverageSummary@v1.3.0
with:
filename: build/coverage/cobertura.xml
fail_below_min: true
format: text
hide_branch_rate: false
hide_complexity: true
indicators: true
output: console
thresholds: ${{ env.COVERAGE_THRESHOLDS }}
25 changes: 0 additions & 25 deletions .github/workflows/licenses-compliance.yml

This file was deleted.

6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ Run key checks before opening/updating PR:
- `composer run test:unit`
- `composer run test:coverage`
- `composer run deps:audit`

## Coverage policy

- CI generates Clover XML and HTML reports under `build/coverage/`
- Pull requests must keep **minimum line coverage at 95%**
- Coverage reports are stored temporarily in GitHub Actions ephemeral storage for post-PR analysis
65 changes: 60 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,71 @@

# xobject-template

> Compile a minimal HTML+CSS subset into reusable PDF Form XObject templates for visible signatures.
> Compile a minimal HTML+CSS subset into reusable PDF Form XObject payloads.

`xobject-template` is a focused rendering engine for digital-signature ecosystems that need **beautiful, vector-first, stable** appearance templates with predictable performance.
`xobject-template` is a focused rendering engine for projects that need **beautiful, vector-first, stable** reusable overlays inside PDF workflows.

## Why this package

- Product-ready visible signature templates (text + image) as real XObject payloads
- Clean integration path for `pdf-signer-php` and LibreSign-like platforms
- Reusable XObject payloads for labels, stamps, approvals, and other document overlays
- Clean integration path for any PDF pipeline that can embed Form XObjects
- Lean API designed for long-term compatibility and monetizable maintainability

## Quick integration

Use the compiler to generate `content stream`, `resources`, and `bbox`, then map the output to your signature appearance DTO adapter.
Use the compiler to generate a reusable XObject result. Consumers that prefer arrays over DTOs can adapt the result into a generic payload shape.

```php
use LibreSign\XObjectTemplate\Dto\CompileRequest;
use LibreSign\XObjectTemplate\Integration\XObjectPayloadAdapter;
use LibreSign\XObjectTemplate\XObjectTemplateCompiler;

$compiler = new XObjectTemplateCompiler();

$result = $compiler->compile(new CompileRequest(
html: '<div style="font-size:10px;color:#111111">Rendered for Alice</div>'
. '<img src="/tmp/example-image.png" style="width:24px;height:24px" />',
width: 240.0,
height: 84.0,
));

$payload = (new XObjectPayloadAdapter())->toXObjectPayload($result);
```

### Output contract

- `$result->contentStream`: PDF operators ready for a Form XObject stream
- `$result->resources`: font/image resource dictionary keyed for PDF serialization
- `$result->bbox`: bounding box as `[x1, y1, x2, y2]`
- `$result->metadata`: render diagnostics such as `line_count`, `image_count`, `node_count`, and `render_ms`
- `$payload`: transport-agnostic array with `stream`, `resources`, and `bbox`

## Supported HTML/CSS subset

### HTML

- Supported elements: `<div>`, `<p>`, `<span>`, `<br>`, `<img>`
- Text fragments are normalized into inline text nodes internally
- Inline styles are read from the `style` attribute
- Images use the `src` attribute as the source reference included in the resource dictionary

### CSS used by the renderer

- 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`
- `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
- `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

## Failure modes

- Unsupported HTML elements raise `UnsupportedSubsetException`
- Malformed HTML fragments are normalized by `DOMDocument` before traversal
- Empty text nodes and invalid inline-style fragments are ignored during parsing/rendering
7 changes: 4 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
{
"name": "libresign/xobject-template",
"description": "Minimal HTML+CSS to PDF Form XObject compiler for visible signature appearance.",
"description": "Minimal HTML+CSS to reusable PDF Form XObject compiler.",
"type": "library",
"license": "AGPL-3.0-or-later",
"keywords": [
"pdf",
"xobject",
"signature",
"template",
"rendering",
"layout",
"html",
"css"
],
Expand Down Expand Up @@ -59,7 +61,6 @@
"composer:validate": "composer validate --strict",
"composer:normalize:check": "vendor-bin/qa/vendor/ergebnis/composer-normalize/bin/composer-normalize --dry-run",
"bc:check": "vendor-bin/qa/vendor/roave/backward-compatibility-check/bin/roave-backward-compatibility-check --from=origin/main --to=HEAD",
"licenses:check": "php scripts/licenses-check.php",
"changelog:check": "echo 'Handled by GitHub Action: .github/workflows/changelog.yml'",
"release:draft": "echo 'Handled by GitHub Action: .github/workflows/release-draft.yml'",
"deps:audit": "composer audit",
Expand Down
37 changes: 0 additions & 37 deletions scripts/licenses-check.php

This file was deleted.

6 changes: 6 additions & 0 deletions src/Contract/XObjectTemplateCompilerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@

use LibreSign\XObjectTemplate\Dto\CompileRequest;
use LibreSign\XObjectTemplate\Dto\CompileResult;
use LibreSign\XObjectTemplate\Exception\UnsupportedSubsetException;

interface XObjectTemplateCompilerInterface
{
/**
* Compile the supported HTML+CSS subset into a reusable PDF Form XObject payload.
*
* @throws UnsupportedSubsetException If the HTML fragment contains an unsupported element.
*/
public function compile(CompileRequest $request): CompileResult;
}
2 changes: 2 additions & 0 deletions src/Html/SubsetHtmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ final class SubsetHtmlParser

/**
* @return list<Node>
*
* @throws UnsupportedSubsetException If the HTML fragment contains an element outside the supported subset.
*/
public function parse(string $html): array
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@

use LibreSign\XObjectTemplate\Dto\CompileResult;

final class SignatureAppearanceXObjectAdapter
final class XObjectPayloadAdapter
{
/**
* Output compatible with consumers expecting stream/resources pair.
* Output compatible with consumers expecting a generic Form XObject payload.
*
* @return array{
* stream: string,
* resources: array<string, mixed>,
* bbox: array{0: float, 1: float, 2: float, 3: float}
* }
*/
public function toPdfSignerPayload(CompileResult $result): array
public function toXObjectPayload(CompileResult $result): array
{
return [
'stream' => $result->contentStream,
Expand Down
6 changes: 6 additions & 0 deletions src/XObjectTemplateCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use LibreSign\XObjectTemplate\Contract\XObjectTemplateCompilerInterface;
use LibreSign\XObjectTemplate\Dto\CompileRequest;
use LibreSign\XObjectTemplate\Dto\CompileResult;
use LibreSign\XObjectTemplate\Exception\UnsupportedSubsetException;
use LibreSign\XObjectTemplate\Html\SubsetHtmlParser;
use LibreSign\XObjectTemplate\Layout\LinearLayoutEngine;
use LibreSign\XObjectTemplate\Pdf\ColorParser;
Expand Down Expand Up @@ -37,6 +38,11 @@ public function __construct(
);
}

/**
* Compile the supported HTML+CSS subset into a reusable PDF Form XObject payload.
*
* @throws UnsupportedSubsetException If the HTML fragment contains an unsupported element.
*/
public function compile(CompileRequest $request): CompileResult
{
$start = hrtime(true);
Expand Down
2 changes: 2 additions & 0 deletions tests/Fixtures/overlay-basic.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<div style="font-size:10;color:#000000">Prepared for {{display_name}}</div>
<p style="font-size:9;color:#333333">Reference: {{reference}}</p>
2 changes: 0 additions & 2 deletions tests/Fixtures/signature-basic.html

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class VisibleSignatureBusinessRuleTest extends TestCase
final class XObjectRenderingScenarioTest extends TestCase
{
#[DataProvider('signatureScenarioProvider')]
public function testVisibleSignatureTemplateScenarios(string $html, int $maxStreamLength): void
#[DataProvider('renderingScenarioProvider')]
public function testReusableXObjectScenarios(string $html, int $maxStreamLength): void
{
$compiler = new XObjectTemplateCompiler();
$result = $compiler->compile(new CompileRequest(html: $html, width: 260.0, height: 90.0));
Expand All @@ -30,22 +30,23 @@ public function testVisibleSignatureTemplateScenarios(string $html, int $maxStre
/**
* @return iterable<string, array{html: string, maxStreamLength: int}>
*/
public static function signatureScenarioProvider(): iterable
public static function renderingScenarioProvider(): iterable
{
yield 'signer name and status' => [
'html' => '<div style="font-size:10">Signed by Demo User</div><p style="font-size:9">Document approved</p>',
yield 'title and status block' => [
'html' => '<div style="font-size:10">Prepared for Demo User</div>'
. '<p style="font-size:9">Document approved</p>',
'maxStreamLength' => 1200,
];

yield 'signer with image mark' => [
'html' => '<img src="/fixture/sign.png" style="width:20px;height:20px" />'
yield 'image with reference text' => [
'html' => '<img src="/fixture/example-image.png" style="width:20px;height:20px" />'
. '<span style="font-size:9">ID 42</span>',
'maxStreamLength' => 1400,
];

yield 'styled signer with alignment and spacing' => [
yield 'styled block with alignment and spacing' => [
'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>',
. 'margin:6;padding:2;width:220">Prepared by Styled User</div>',
'maxStreamLength' => 1800,
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@
namespace LibreSign\XObjectTemplate\Tests\Unit\Integration;

use LibreSign\XObjectTemplate\Dto\CompileResult;
use LibreSign\XObjectTemplate\Integration\SignatureAppearanceXObjectAdapter;
use LibreSign\XObjectTemplate\Integration\XObjectPayloadAdapter;
use PHPUnit\Framework\TestCase;

final class SignatureAppearanceXObjectAdapterTest extends TestCase
final class XObjectPayloadAdapterTest extends TestCase
{
public function testAdapterMapsToExpectedPayload(): void
{
$adapter = new SignatureAppearanceXObjectAdapter();
$adapter = new XObjectPayloadAdapter();
$result = new CompileResult(
contentStream: 'BT\n(Foo) Tj\nET',
resources: ['Font' => ['F1' => ['BaseFont' => '/Helvetica']]],
bbox: [0.0, 0.0, 240.0, 84.0],
);

$payload = $adapter->toPdfSignerPayload($result);
$payload = $adapter->toXObjectPayload($result);

self::assertSame('BT\n(Foo) Tj\nET', $payload['stream']);
self::assertSame(['Font' => ['F1' => ['BaseFont' => '/Helvetica']]], $payload['resources']);
Expand Down
4 changes: 2 additions & 2 deletions tests/Unit/Layout/LinearLayoutEngineTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,14 @@ public function testLayoutTreatsNonPositiveImageDimensionsAsDefaultsAndClampsToC
tag: 'img',
text: '',
attributes: [
'src' => '/fixture/signature.png',
'src' => '/fixture/example-image.png',
'style' => 'width:0;height:-10',
],
),
], 20.0, 15.0);

self::assertCount(1, $result->images);
self::assertSame('/fixture/signature.png', $result->images[0]->source);
self::assertSame('/fixture/example-image.png', $result->images[0]->source);
self::assertSame('Im0', $result->images[0]->alias);
self::assertEqualsWithDelta(20.0, $result->images[0]->width, 0.0001);
self::assertEqualsWithDelta(15.0, $result->images[0]->height, 0.0001);
Expand Down
Loading
Loading