From b616fc76672d3df983cbc5ca937f71558b19f03c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 28 May 2026 12:02:19 -0300 Subject: [PATCH 1/4] refactor: generalize XObject API and simplify CI Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/coverage.yml | 28 +++++++- .github/workflows/licenses-compliance.yml | 25 ------- CONTRIBUTING.md | 6 ++ README.md | 65 +++++++++++++++++-- composer.json | 7 +- scripts/licenses-check.php | 37 ----------- .../XObjectTemplateCompilerInterface.php | 6 ++ src/Html/SubsetHtmlParser.php | 2 + ...tAdapter.php => XObjectPayloadAdapter.php} | 6 +- src/XObjectTemplateCompiler.php | 6 ++ tests/Fixtures/overlay-basic.html | 2 + tests/Fixtures/signature-basic.html | 2 - ...t.php => XObjectRenderingScenarioTest.php} | 21 +++--- ...Test.php => XObjectPayloadAdapterTest.php} | 8 +-- tests/Unit/Layout/LinearLayoutEngineTest.php | 4 +- .../Unit/Pdf/TemplateDocumentBuilderTest.php | 12 ++-- tests/Unit/XObjectTemplateCompilerTest.php | 8 +-- 17 files changed, 143 insertions(+), 102 deletions(-) delete mode 100644 .github/workflows/licenses-compliance.yml delete mode 100644 scripts/licenses-check.php rename src/Integration/{SignatureAppearanceXObjectAdapter.php => XObjectPayloadAdapter.php} (75%) create mode 100644 tests/Fixtures/overlay-basic.html delete mode 100644 tests/Fixtures/signature-basic.html rename tests/Integration/{VisibleSignatureBusinessRuleTest.php => XObjectRenderingScenarioTest.php} (64%) rename tests/Unit/Integration/{SignatureAppearanceXObjectAdapterTest.php => XObjectPayloadAdapterTest.php} (75%) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index acf64a7..c10b571 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -6,6 +6,9 @@ name: coverage on: pull_request: +env: + COVERAGE_THRESHOLDS: '95 95' + jobs: coverage: runs-on: ubuntu-latest @@ -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 }} diff --git a/.github/workflows/licenses-compliance.yml b/.github/workflows/licenses-compliance.yml deleted file mode 100644 index f793b6f..0000000 --- a/.github/workflows/licenses-compliance.yml +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: 2026 LibreSign -# SPDX-License-Identifier: AGPL-3.0-or-later - -name: licenses-compliance - -on: - pull_request: - -jobs: - licenses-compliance: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Detect minimum PHP from composer.json - id: php_min - run: | - php_version=$(grep -Po '"php"\s*:\s*"\K[^"]+' composer.json | grep -Eo '[0-9]+\.[0-9]+' | head -n1) - [[ -n "$php_version" ]] || { echo "Could not determine minimum PHP version"; exit 1; } - echo "version=$php_version" >> "$GITHUB_OUTPUT" - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ steps.php_min.outputs.version }} - - run: composer install --no-interaction --prefer-dist - - run: composer bin qa install --no-interaction --prefer-dist - - run: composer run licenses:check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27c4bc1..0057cfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index d1ec745..cd9cafd 100644 --- a/README.md +++ b/README.md @@ -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: '
',
+ 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: ``, ``, ` Reference: {{reference}} Reason: {{reason}} Document approved Document approved
`, ``
+- 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
diff --git a/composer.json b/composer.json
index 2f381d5..c985bcb 100644
--- a/composer.json
+++ b/composer.json
@@ -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"
],
@@ -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",
diff --git a/scripts/licenses-check.php b/scripts/licenses-check.php
deleted file mode 100644
index 1c00e8d..0000000
--- a/scripts/licenses-check.php
+++ /dev/null
@@ -1,37 +0,0 @@
-
+ *
+ * @throws UnsupportedSubsetException If the HTML fragment contains an element outside the supported subset.
*/
public function parse(string $html): array
{
diff --git a/src/Integration/SignatureAppearanceXObjectAdapter.php b/src/Integration/XObjectPayloadAdapter.php
similarity index 75%
rename from src/Integration/SignatureAppearanceXObjectAdapter.php
rename to src/Integration/XObjectPayloadAdapter.php
index 38463e4..cf5f796 100644
--- a/src/Integration/SignatureAppearanceXObjectAdapter.php
+++ b/src/Integration/XObjectPayloadAdapter.php
@@ -9,10 +9,10 @@
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,
@@ -20,7 +20,7 @@ final class SignatureAppearanceXObjectAdapter
* 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,
diff --git a/src/XObjectTemplateCompiler.php b/src/XObjectTemplateCompiler.php
index f2b6545..2154712 100644
--- a/src/XObjectTemplateCompiler.php
+++ b/src/XObjectTemplateCompiler.php
@@ -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;
@@ -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);
diff --git a/tests/Fixtures/overlay-basic.html b/tests/Fixtures/overlay-basic.html
new file mode 100644
index 0000000..83f5d44
--- /dev/null
+++ b/tests/Fixtures/overlay-basic.html
@@ -0,0 +1,2 @@
+
'
+ yield 'image with reference text' => [
+ 'html' => '
'
. 'ID 42',
'maxStreamLength' => 1400,
];
- yield 'styled signer with alignment and spacing' => [
+ yield 'styled block with alignment and spacing' => [
'html' => '
Signed by Alice
', - 'expectedSnippet' => '(Signed by Alice) Tj', + 'html' => 'Rendered for Alice
', + 'expectedSnippet' => '(Rendered for Alice) Tj', ]; yield 'line break text' => [ @@ -49,7 +49,7 @@ public static function htmlProvider(): iterable ]; yield 'image command' => [ - 'html' => '
',
+ 'html' => '
',
'expectedSnippet' => '/Im0 Do',
];
@@ -86,7 +86,7 @@ public function testCompileUsesProvidedTemplateDocumentBuilderInstance(): void
self::assertArrayNotHasKey('F1', $result->resources['Font']);
}
- public function testCompilerConstructorStillAcceptsLegacyPdfDependencies(): void
+ public function testCompilerConstructorAcceptsExplicitPdfDependencies(): void
{
$pdfEscaper = new PdfEscaper();
$colorParser = new ColorParser();
From 63cd8ea625611764ddceb0a6248bb6b19ac287bd Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Thu, 28 May 2026 12:17:45 -0300
Subject: [PATCH 2/4] fix: restore signature adapter compatibility
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
.../SignatureAppearanceXObjectAdapter.php | 27 ++++++++++++++++
.../SignatureAppearanceXObjectAdapterTest.php | 31 +++++++++++++++++++
2 files changed, 58 insertions(+)
create mode 100644 src/Integration/SignatureAppearanceXObjectAdapter.php
create mode 100644 tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php
diff --git a/src/Integration/SignatureAppearanceXObjectAdapter.php b/src/Integration/SignatureAppearanceXObjectAdapter.php
new file mode 100644
index 0000000..37818e4
--- /dev/null
+++ b/src/Integration/SignatureAppearanceXObjectAdapter.php
@@ -0,0 +1,27 @@
+,
+ * bbox: array{0: float, 1: float, 2: float, 3: float}
+ * }
+ */
+ public function toPdfSignerPayload(CompileResult $result): array
+ {
+ return (new XObjectPayloadAdapter())->toXObjectPayload($result);
+ }
+}
diff --git a/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php b/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php
new file mode 100644
index 0000000..6edbe9b
--- /dev/null
+++ b/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php
@@ -0,0 +1,31 @@
+ ['F1' => ['BaseFont' => '/Helvetica']]],
+ bbox: [0.0, 0.0, 240.0, 84.0],
+ );
+
+ $payload = $adapter->toPdfSignerPayload($result);
+
+ self::assertSame('BT\n(Foo) Tj\nET', $payload['stream']);
+ self::assertSame(['Font' => ['F1' => ['BaseFont' => '/Helvetica']]], $payload['resources']);
+ self::assertSame([0.0, 0.0, 240.0, 84.0], $payload['bbox']);
+ }
+}
From b74509f38ad8dc1998bbb2f393f012a7660f5cf0 Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Thu, 28 May 2026 12:26:21 -0300
Subject: [PATCH 3/4] build: defer BC check until first release
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
.github/workflows/bc-check.yml | 28 +++++++++++++++--
.../SignatureAppearanceXObjectAdapter.php | 27 ----------------
.../SignatureAppearanceXObjectAdapterTest.php | 31 -------------------
3 files changed, 25 insertions(+), 61 deletions(-)
delete mode 100644 src/Integration/SignatureAppearanceXObjectAdapter.php
delete mode 100644 tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php
diff --git a/.github/workflows/bc-check.yml b/.github/workflows/bc-check.yml
index ef53db5..80567fb 100644
--- a/.github/workflows/bc-check.yml
+++ b/.github/workflows/bc-check.yml
@@ -13,15 +13,37 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
+ - name: Detect backward-compatibility baseline
+ id: bc_base
+ run: |
+ tag=$(git tag --list 'v*' --sort=-version:refname | head -n1)
+
+ if [[ -z "$tag" ]]; then
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ echo "No release tag found yet; skipping backward-compatibility check until the first tagged release."
+ exit 0
+ fi
+
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ echo "ref=$tag" >> "$GITHUB_OUTPUT"
+ echo "Using backward-compatibility baseline: $tag"
- name: Detect minimum PHP from composer.json
+ if: steps.bc_base.outputs.skip != 'true'
id: php_min
run: |
php_version=$(grep -Po '"php"\s*:\s*"\K[^"]+' composer.json | grep -Eo '[0-9]+\.[0-9]+' | head -n1)
[[ -n "$php_version" ]] || { echo "Could not determine minimum PHP version"; exit 1; }
echo "version=$php_version" >> "$GITHUB_OUTPUT"
- uses: shivammathur/setup-php@v2
+ if: steps.bc_base.outputs.skip != 'true'
with:
php-version: ${{ steps.php_min.outputs.version }}
- - run: composer install --no-interaction --prefer-dist
- - run: composer bin qa install --no-interaction --prefer-dist
- - run: composer run bc:check
+ - name: Skip BC check before first release
+ if: steps.bc_base.outputs.skip == 'true'
+ run: echo "Skipping backward-compatibility check because the package has no tagged release yet."
+ - if: steps.bc_base.outputs.skip != 'true'
+ run: composer install --no-interaction --prefer-dist
+ - if: steps.bc_base.outputs.skip != 'true'
+ run: composer bin qa install --no-interaction --prefer-dist
+ - if: steps.bc_base.outputs.skip != 'true'
+ run: vendor-bin/qa/vendor/roave/backward-compatibility-check/bin/roave-backward-compatibility-check --from="${{ steps.bc_base.outputs.ref }}" --to=HEAD
diff --git a/src/Integration/SignatureAppearanceXObjectAdapter.php b/src/Integration/SignatureAppearanceXObjectAdapter.php
deleted file mode 100644
index 37818e4..0000000
--- a/src/Integration/SignatureAppearanceXObjectAdapter.php
+++ /dev/null
@@ -1,27 +0,0 @@
-,
- * bbox: array{0: float, 1: float, 2: float, 3: float}
- * }
- */
- public function toPdfSignerPayload(CompileResult $result): array
- {
- return (new XObjectPayloadAdapter())->toXObjectPayload($result);
- }
-}
diff --git a/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php b/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php
deleted file mode 100644
index 6edbe9b..0000000
--- a/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php
+++ /dev/null
@@ -1,31 +0,0 @@
- ['F1' => ['BaseFont' => '/Helvetica']]],
- bbox: [0.0, 0.0, 240.0, 84.0],
- );
-
- $payload = $adapter->toPdfSignerPayload($result);
-
- self::assertSame('BT\n(Foo) Tj\nET', $payload['stream']);
- self::assertSame(['Font' => ['F1' => ['BaseFont' => '/Helvetica']]], $payload['resources']);
- self::assertSame([0.0, 0.0, 240.0, 84.0], $payload['bbox']);
- }
-}
From 899da8e84da6291620d46899e6a276dd72b78a3b Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Thu, 28 May 2026 13:29:30 -0300
Subject: [PATCH 4/4] revert: drop temporary bc-check bypass
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
.github/workflows/bc-check.yml | 28 +++-------------------------
1 file changed, 3 insertions(+), 25 deletions(-)
diff --git a/.github/workflows/bc-check.yml b/.github/workflows/bc-check.yml
index 80567fb..ef53db5 100644
--- a/.github/workflows/bc-check.yml
+++ b/.github/workflows/bc-check.yml
@@ -13,37 +13,15 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: Detect backward-compatibility baseline
- id: bc_base
- run: |
- tag=$(git tag --list 'v*' --sort=-version:refname | head -n1)
-
- if [[ -z "$tag" ]]; then
- echo "skip=true" >> "$GITHUB_OUTPUT"
- echo "No release tag found yet; skipping backward-compatibility check until the first tagged release."
- exit 0
- fi
-
- echo "skip=false" >> "$GITHUB_OUTPUT"
- echo "ref=$tag" >> "$GITHUB_OUTPUT"
- echo "Using backward-compatibility baseline: $tag"
- name: Detect minimum PHP from composer.json
- if: steps.bc_base.outputs.skip != 'true'
id: php_min
run: |
php_version=$(grep -Po '"php"\s*:\s*"\K[^"]+' composer.json | grep -Eo '[0-9]+\.[0-9]+' | head -n1)
[[ -n "$php_version" ]] || { echo "Could not determine minimum PHP version"; exit 1; }
echo "version=$php_version" >> "$GITHUB_OUTPUT"
- uses: shivammathur/setup-php@v2
- if: steps.bc_base.outputs.skip != 'true'
with:
php-version: ${{ steps.php_min.outputs.version }}
- - name: Skip BC check before first release
- if: steps.bc_base.outputs.skip == 'true'
- run: echo "Skipping backward-compatibility check because the package has no tagged release yet."
- - if: steps.bc_base.outputs.skip != 'true'
- run: composer install --no-interaction --prefer-dist
- - if: steps.bc_base.outputs.skip != 'true'
- run: composer bin qa install --no-interaction --prefer-dist
- - if: steps.bc_base.outputs.skip != 'true'
- run: vendor-bin/qa/vendor/roave/backward-compatibility-check/bin/roave-backward-compatibility-check --from="${{ steps.bc_base.outputs.ref }}" --to=HEAD
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer bin qa install --no-interaction --prefer-dist
+ - run: composer run bc:check