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: '
Rendered for Alice
' + . '', + 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: `
`, `

`, ``, `
`, `` +- 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 @@ +

Prepared for {{display_name}}
+

Reference: {{reference}}

diff --git a/tests/Fixtures/signature-basic.html b/tests/Fixtures/signature-basic.html deleted file mode 100644 index 399f180..0000000 --- a/tests/Fixtures/signature-basic.html +++ /dev/null @@ -1,2 +0,0 @@ -
Signed by {{signer_name}}
-

Reason: {{reason}}

diff --git a/tests/Integration/VisibleSignatureBusinessRuleTest.php b/tests/Integration/XObjectRenderingScenarioTest.php similarity index 64% rename from tests/Integration/VisibleSignatureBusinessRuleTest.php rename to tests/Integration/XObjectRenderingScenarioTest.php index aae663e..e2ddd7c 100644 --- a/tests/Integration/VisibleSignatureBusinessRuleTest.php +++ b/tests/Integration/XObjectRenderingScenarioTest.php @@ -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)); @@ -30,22 +30,23 @@ public function testVisibleSignatureTemplateScenarios(string $html, int $maxStre /** * @return iterable */ - public static function signatureScenarioProvider(): iterable + public static function renderingScenarioProvider(): iterable { - yield 'signer name and status' => [ - 'html' => '
Signed by Demo User

Document approved

', + yield 'title and status block' => [ + 'html' => '
Prepared for Demo User
' + . '

Document approved

', 'maxStreamLength' => 1200, ]; - yield 'signer with image mark' => [ - 'html' => '' + 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 Styled User
', + . 'margin:6;padding:2;width:220">Prepared by Styled User
', 'maxStreamLength' => 1800, ]; } diff --git a/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php b/tests/Unit/Integration/XObjectPayloadAdapterTest.php similarity index 75% rename from tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php rename to tests/Unit/Integration/XObjectPayloadAdapterTest.php index 6edbe9b..fea77a9 100644 --- a/tests/Unit/Integration/SignatureAppearanceXObjectAdapterTest.php +++ b/tests/Unit/Integration/XObjectPayloadAdapterTest.php @@ -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']); diff --git a/tests/Unit/Layout/LinearLayoutEngineTest.php b/tests/Unit/Layout/LinearLayoutEngineTest.php index 50cc5cd..fc53666 100644 --- a/tests/Unit/Layout/LinearLayoutEngineTest.php +++ b/tests/Unit/Layout/LinearLayoutEngineTest.php @@ -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); diff --git a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php index ba5d8d8..a8e52eb 100644 --- a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php +++ b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php @@ -23,7 +23,7 @@ public function testBuildCreatesDocumentPayloadParts(): void $layout = new LayoutResult( lines: [ new LayoutLine( - text: 'Signed by Alice', + text: 'Rendered for Alice', x: 12.0, y: 48.0, fontSize: 10.0, @@ -38,7 +38,7 @@ public function testBuildCreatesDocumentPayloadParts(): void y: 4.0, width: 24.0, height: 24.0, - source: '/tmp/signature.png', + source: '/tmp/example-image.png', ), ], ); @@ -50,7 +50,7 @@ public function testBuildCreatesDocumentPayloadParts(): void self::assertStringContainsString('/Im0 Do', $result->contentStream); self::assertStringContainsString("\nET\nQ", $result->contentStream); self::assertSame([0.0, 0.0, 240.0, 84.0], $result->bbox); - self::assertSame('Signed by Alice', $layout->lines[0]->text); + self::assertSame('Rendered for Alice', $layout->lines[0]->text); self::assertArrayHasKey('Font', $result->resources); self::assertArrayHasKey('XObject', $result->resources); self::assertSame('/Helvetica', $result->resources['Font']['F1']['BaseFont']); @@ -58,7 +58,7 @@ public function testBuildCreatesDocumentPayloadParts(): void self::assertSame('/Image', $result->resources['XObject']['Im0']['Subtype']); self::assertSame(24.0, $result->resources['XObject']['Im0']['Width']); self::assertSame(24.0, $result->resources['XObject']['Im0']['Height']); - self::assertSame('/tmp/signature.png', $result->resources['XObject']['Im0']['Source']); + self::assertSame('/tmp/example-image.png', $result->resources['XObject']['Im0']['Source']); self::assertSame(1, $result->metadata['line_count']); self::assertSame(1, $result->metadata['image_count']); self::assertSame(2, $result->metadata['node_count']); @@ -113,7 +113,7 @@ public function testBuildContentStreamIsDirectlyUsableForImagesAndEscapedText(): $stream = $builder->buildContentStream(new LayoutResult( lines: [ new LayoutLine( - text: 'Signer (QA)', + text: 'Marker (QA)', x: 12.0, y: 22.0, fontSize: 9.0, @@ -129,7 +129,7 @@ public function testBuildContentStreamIsDirectlyUsableForImagesAndEscapedText(): self::assertStringContainsString('q 3.000000 0 0 4.000000 1.000000 2.000000 cm /Im7 Do Q', $stream); self::assertStringContainsString('/F2 9.000000 Tf', $stream); self::assertStringContainsString('0.6706 0.8039 0.9373 rg', $stream); - self::assertStringContainsString('(Signer \\(QA\\)) Tj', $stream); + self::assertStringContainsString('(Marker \\(QA\\)) Tj', $stream); } public function testBuildResourcesExposesImageDictionaryAndCustomFontsFromDerivedBuilder(): void diff --git a/tests/Unit/XObjectTemplateCompilerTest.php b/tests/Unit/XObjectTemplateCompilerTest.php index ec032c2..d838c1c 100644 --- a/tests/Unit/XObjectTemplateCompilerTest.php +++ b/tests/Unit/XObjectTemplateCompilerTest.php @@ -39,8 +39,8 @@ public function testCompileSubsetHtmlGeneratesExpectedOperators(string $html, st public static function htmlProvider(): iterable { yield 'paragraph text' => [ - '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();