diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 3adb176..d80bc9f 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -7,9 +7,15 @@ ARG UID=1000 ARG GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends git unzip zip bash curl ca-certificates \ + && apt-get install -y --no-install-recommends git unzip zip bash curl ca-certificates python3 python3-pip python3-venv \ && rm -rf /var/lib/apt/lists/* +COPY docs/requirements-docs.txt /tmp/requirements-docs.txt + +RUN python3 -m venv /opt/docs-venv \ + && /opt/docs-venv/bin/pip install --no-cache-dir -r /tmp/requirements-docs.txt \ + && ln -sf /opt/docs-venv/bin/mkdocs /usr/local/bin/mkdocs + RUN pecl install xdebug \ && docker-php-ext-enable xdebug diff --git a/.github/.performance/baseline.json b/.github/.performance/baseline.json new file mode 100644 index 0000000..68d5a90 --- /dev/null +++ b/.github/.performance/baseline.json @@ -0,0 +1,16 @@ +{ + "version": "1.0.0", + "created_at": "2026-06-01", + "allowed_regression_pct": 25.0, + "stale_threshold_pct": 35.0, + "benchmarks": { + "LibreSign\\XObjectTemplate\\Benchmarks\\CompilerBench::benchSimpleHtml": { + "mean": 0.356085, + "memory_real": 768 + }, + "LibreSign\\XObjectTemplate\\Benchmarks\\CompilerBench::benchComplexHtml": { + "mean": 1.366, + "memory_real": 1024 + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 33ec828..e5fdf6c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,12 +9,14 @@ body: id: summary attributes: label: Summary + description: Provide a short, objective problem statement. validations: required: true - type: textarea id: steps attributes: label: Steps to reproduce + description: Include exact input and commands. validations: required: true - type: textarea @@ -23,3 +25,14 @@ body: label: Expected behavior validations: required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: input + id: runtime + attributes: + label: Runtime details + placeholder: PHP version, package version/commit, OS diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2e55af7..2be618c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,3 +6,6 @@ contact_links: - name: Security disclosure url: https://github.com/LibreSign/xobject-template/security about: Please report vulnerabilities privately. + - name: Sponsor maintenance + url: https://github.com/sponsors/LibreSign + about: Funding helps prioritize fixes and compatibility work. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..656ae97 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 LibreSign +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Documentation +description: Report documentation errors or suggest improvements +labels: [documentation] +body: + - type: textarea + id: problem + attributes: + label: What is unclear or incorrect? + validations: + required: true + - type: input + id: page + attributes: + label: Affected page/path + placeholder: docs/reference/supported-svg.md + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Keep suggestions objective and user-focused. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0350a47..d87bb0e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -9,11 +9,18 @@ body: id: problem attributes: label: Problem statement + description: Describe the user or integration problem first. validations: required: true - type: textarea id: proposal attributes: label: Proposed solution + description: Keep scope explicit and include expected API/behavior impact. validations: required: true + - type: textarea + id: non_goals + attributes: + label: Non-goals / out of scope + description: Clarify what should not be included. diff --git a/.github/ISSUE_TEMPLATE/rendering_bug.yml b/.github/ISSUE_TEMPLATE/rendering_bug.yml new file mode 100644 index 0000000..29a60a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/rendering_bug.yml @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 LibreSign +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Rendering bug +description: Report a minimal reproducible rendering issue for HTML/CSS/SVG to XObject output +labels: [bug, rendering] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: Describe the rendering issue in one or two sentences. + validations: + required: true + - type: textarea + id: minimal_input + attributes: + label: Minimal HTML/CSS/SVG input + description: Paste the smallest possible input that reproduces the issue. + render: markdown + validations: + required: true + - type: textarea + id: expected_output + attributes: + label: Expected output + validations: + required: true + - type: textarea + id: actual_output + attributes: + label: Actual output + validations: + required: true + - type: textarea + id: payload_details + attributes: + label: Generated content stream/resources (if possible) + description: Include compile result content stream/resources snippet when available. + - type: dropdown + id: affected_area + attributes: + label: Affected area + options: + - text + - PNG/JPEG + - SVG + - layout + - CSS + - interpolation + - placement + validations: + required: true + - type: input + id: php_version + attributes: + label: PHP version + placeholder: '8.2.x' + validations: + required: true + - type: input + id: package_version + attributes: + label: Package version or commit + placeholder: 'composer version, tag, or commit hash' + validations: + required: true + - type: textarea + id: fixture + attributes: + label: Minimal fixture attachment details + description: If possible, attach or describe a minimal fixture file set. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0dfe019..b282c69 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,11 +10,24 @@ - [ ] Tests added/updated - [ ] Relevant checks executed +## Documentation + +- [ ] Documentation updated +- [ ] Examples updated if public behavior changed +- [ ] Supported HTML/CSS/SVG matrix updated if relevant +- [ ] Documentation drift checks pass + ## Performance - [ ] Performance impact assessed - [ ] Benchmark threshold still green +## Output integrity + +- [ ] No unsupported feature documented as supported +- [ ] No generated build artifacts committed to the source branch +- [ ] LibreSign use case impact considered if relevant + ## Compliance - [ ] DCO sign-off in all commits diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f8a5bf1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2026 LibreSign +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: docs + +on: + push: + branches: + - main + paths: + - 'docs/**' + - '.github/workflows/docs.yml' + +permissions: + contents: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install docs dependencies + run: pip install -r docs/requirements-docs.txt + + - name: Deploy to gh-pages + run: mkdocs gh-deploy --force --config-file docs/mkdocs.yml diff --git a/README.md b/README.md index 190be8a..e6a1814 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,59 @@ -# xobject-template +# XObject Template -> Compile a minimal HTML+CSS subset into reusable PDF Form XObject payloads. +Minimal HTML+CSS to reusable PDF Form XObject compiler for visible signature appearances and document overlays. -`xobject-template` is a focused rendering engine for projects that need **beautiful, vector-first, stable** reusable overlays inside PDF workflows. +## What is xobject-template? -## Why this package +`xobject-template` is a focused PHP rendering engine that compiles a deterministic HTML/CSS subset into Form XObject-oriented output (`contentStream`, `resources`, `bbox`) for downstream PDF workflows. -- 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 +## Why it exists -## Quick integration +Many systems need stable visual signature/stamp/label appearances, but do not want browser-grade rendering complexity inside PDF pipelines. -Use the compiler to generate a reusable XObject result. Consumers that prefer arrays over DTOs can adapt the result into a generic payload shape. +This package provides a scoped, integration-friendly rendering layer for that gap. -```php -use LibreSign\XObjectTemplate\Dto\CompileRequest; -use LibreSign\XObjectTemplate\Integration\XObjectPayloadAdapter; -use LibreSign\XObjectTemplate\Pdf\SinglePagePdfExporter; -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); -$pdf = (new SinglePagePdfExporter())->export($result); +## Install -file_put_contents(__DIR__ . '/build/preview.pdf', $pdf); +```bash +composer require libresign/xobject-template ``` -### Standalone PDF export - -`SinglePagePdfExporter` wraps a compiled XObject result into a one-page PDF whose `MediaBox` matches the compiled `bbox` size exactly. - -- The page size is derived from `$result->bbox` -- Non-zero bounding boxes are translated back to the page origin automatically -- Local PNG and JPEG image sources are embedded into the standalone PDF during export - -### 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` -- `$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 +## Quick example ```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. +fromWidth($result, 175.0, 36.0, 72.0); +$result = $compiler->compile(new CompileRequest( + html: '
Signed by {{ name }}
', + width: 240.0, + height: 84.0, + context: ['name' => 'Alice'], +)); -$pdfCommand = $placement->toPdfCommand('Fm0'); -// q 0.729167 0 0 0.729167 36.000000 72.000000 cm /Fm0 Do Q +// $result->contentStream +// $result->resources +// $result->bbox ``` -### 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 +## LibreSign use case -- 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 +- LibreSign project: -### CSS used by the renderer +## Contributing -- 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 `%` -- `px` values are converted to PDF points using the package conversion rules -- Unknown or incomplete CSS declarations are ignored instead of aborting the render +- Repository guide: `CONTRIBUTING.md` -### Rendering notes +If this package helps your project generate reliable PDF signature appearances, please star the repository. It helps other developers discover the project and signals that this work is worth maintaining. -- 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 -- 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 +## Support the project -## Failure modes +- Sponsor maintenance: -- 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/REUSE.toml b/REUSE.toml index 0e962fb..f134394 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -17,3 +17,13 @@ path = ["phpunit.xml.dist", "vendor-bin/**/composer.json"] precedence = "override" SPDX-FileCopyrightText = "2026 LibreSign" SPDX-License-Identifier = "AGPL-3.0-or-later" + +[[annotations]] +path = [ + "docs/source/examples/assets/sample.svg", + "docs/source/examples/assets/tiny-jpeg.base64", + "docs/source/examples/assets/tiny-png.base64", +] +precedence = "override" +SPDX-FileCopyrightText = "2026 LibreSign" +SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/composer.json b/composer.json index e619327..f78466f 100644 --- a/composer.json +++ b/composer.json @@ -73,6 +73,8 @@ "sh:security": "shellcheck scripts/*.sh", "test:unit": "vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --testsuite unit", "test:integration": "vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --testsuite integration", + "examples:test": "vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --testsuite integration --filter ExamplesOutputScenarioTest", + "docs:watch": "mkdocs serve -f docs/mkdocs.yml -a 0.0.0.0:8000", "test:coverage": "XDEBUG_MODE=coverage vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --coverage-text --coverage-clover=build/coverage/clover.xml", "mutation:test": "vendor-bin/mutation/vendor/infection/infection/bin/infection --threads=max", "benchmark:run": "vendor-bin/phpbench/vendor/phpbench/phpbench/bin/phpbench run --config=phpbench.json --report=aggregate", diff --git a/docker-compose.yml b/docker-compose.yml index be31fab..291c889 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,4 +24,6 @@ services: # Optional override (default shown): COMPOSER_CACHE_DIR=${HOME}/.composer - ${COMPOSER_CACHE_DIR:-${HOME}/.composer}:/var/www/.composer/ - ./.profile:/var/www/html/.profile - command: ["bash", "-lc", "composer --version && bash"] + ports: + - "${DOCS_PORT:-8000}:8000" + command: ["bash", "-lc", "composer run docs:watch"] diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..1a77e3a --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2026 LibreSign +# SPDX-License-Identifier: AGPL-3.0-or-later + +site_name: XObject Template +site_description: Minimal HTML+CSS to reusable PDF Form XObject compiler. +site_url: https://libresign.github.io/xobject-template/ +repo_url: https://github.com/LibreSign/xobject-template +repo_name: LibreSign/xobject-template +edit_uri: edit/main/docs/source/ + +docs_dir: source +site_dir: ../build/docs-site + +theme: + name: material + logo: https://raw.githubusercontent.com/LibreSign/documentation/main/main/images/logo.png + favicon: https://avatars.githubusercontent.com/u/79158919?v=4 + palette: + - scheme: default + primary: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - scheme: slate + primary: indigo + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - navigation.instant + - navigation.top + - toc.integrate + - content.code.copy + - content.action.edit + +nav: + - Home: index.md + - Getting started: guides/getting-started.md + - Examples: reference/examples.md + - Visible signature use case: use-cases/visible-signatures.md + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + use_pygments: true + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.inlinehilite + - pymdownx.snippets: + base_path: + - docs/source + check_paths: true diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt new file mode 100644 index 0000000..6ab9a75 --- /dev/null +++ b/docs/requirements-docs.txt @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 LibreSign +# SPDX-License-Identifier: AGPL-3.0-or-later + +mkdocs>=1.6 +mkdocs-material>=9.5 diff --git a/docs/source/examples/assets/sample.svg b/docs/source/examples/assets/sample.svg new file mode 100644 index 0000000..523d596 --- /dev/null +++ b/docs/source/examples/assets/sample.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/source/examples/assets/tiny-jpeg.base64 b/docs/source/examples/assets/tiny-jpeg.base64 new file mode 100644 index 0000000..12a3eeb --- /dev/null +++ b/docs/source/examples/assets/tiny-jpeg.base64 @@ -0,0 +1 @@ +/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDi6KKK+ZP3E//Z diff --git a/docs/source/examples/assets/tiny-png.base64 b/docs/source/examples/assets/tiny-png.base64 new file mode 100644 index 0000000..eb83931 --- /dev/null +++ b/docs/source/examples/assets/tiny-png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5X2s8AAAAASUVORK5CYII= diff --git a/docs/source/examples/basic-template.php b/docs/source/examples/basic-template.php new file mode 100644 index 0000000..6f19f74 --- /dev/null +++ b/docs/source/examples/basic-template.php @@ -0,0 +1,44 @@ +compile(new CompileRequest( + html: '

Signed by {{ name }}
' + . '
Role: {{ role }}
', + width: 240.0, + height: 84.0, + context: [ + 'name' => 'Alice', + 'role' => 'Approver', + ], +)); + +$outputFile = $outputDir . '/basic-template-result.json'; +file_put_contents($outputFile, json_encode([ + 'content_stream_length' => strlen($result->contentStream), + 'resources' => $result->resources, + 'bbox' => $result->bbox, + 'metadata' => $result->metadata, +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + +return [ + 'example' => 'basic-template', + 'generated_files' => [$outputFile], + 'content_stream' => $result->contentStream, + 'resources' => $result->resources, + 'bbox' => $result->bbox, +]; diff --git a/docs/source/examples/images.php b/docs/source/examples/images.php new file mode 100644 index 0000000..dd7cf12 --- /dev/null +++ b/docs/source/examples/images.php @@ -0,0 +1,66 @@ +' + . '' + . '' + . 'PNG + JPEG' + . '
'; + +$result = (new XObjectTemplateCompiler())->compile(new CompileRequest( + html: $html, + width: 260.0, + height: 90.0, +)); + +$pdfFile = $outputDir . '/images-preview.pdf'; +$pdfBytes = (new SinglePagePdfExporter())->export($result); +file_put_contents($pdfFile, $pdfBytes); + +$jsonFile = $outputDir . '/images-result.json'; +file_put_contents($jsonFile, json_encode([ + 'bbox' => $result->bbox, + 'resources' => $result->resources, + 'metadata' => $result->metadata, +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + +return [ + 'example' => 'images', + 'generated_files' => [$pngPath, $jpegPath, $pdfFile, $jsonFile], + 'content_stream' => $result->contentStream, + 'resources' => $result->resources, + 'bbox' => $result->bbox, + 'pdf_file' => $pdfFile, +]; diff --git a/docs/source/examples/interpolation.php b/docs/source/examples/interpolation.php new file mode 100644 index 0000000..eef92eb --- /dev/null +++ b/docs/source/examples/interpolation.php @@ -0,0 +1,45 @@ +compile(new CompileRequest( + html: '
Signed by {{ name }}
' + . '
Role: {{ role }}
' + . '
Missing: {{ missing }}
', + width: 260.0, + height: 90.0, + context: [ + 'name' => 'Alice ', + 'role' => 'Approver', + ], +)); + +$outputFile = $outputDir . '/interpolation-result.json'; +file_put_contents($outputFile, json_encode([ + 'bbox' => $result->bbox, + 'content_stream' => $result->contentStream, + 'resources' => $result->resources, + 'metadata' => $result->metadata, +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + +return [ + 'example' => 'interpolation', + 'generated_files' => [$outputFile], + 'content_stream' => $result->contentStream, + 'resources' => $result->resources, + 'bbox' => $result->bbox, +]; diff --git a/docs/source/examples/placement.php b/docs/source/examples/placement.php new file mode 100644 index 0000000..8613911 --- /dev/null +++ b/docs/source/examples/placement.php @@ -0,0 +1,44 @@ +compile(new CompileRequest( + html: '
Placement baseline
', + width: 240.0, + height: 84.0, +)); + +$calculator = new XObjectPlacementCalculator(); +$fromWidth = $calculator->fromWidth($result, 175.0, 36.0, 72.0); +$fromHeight = $calculator->fromHeight($result, 61.25, 36.0, 72.0); +$fromScale = $calculator->fromScale($result, 0.729167, 36.0, 72.0); + +$outputFile = $outputDir . '/placement-result.json'; +file_put_contents($outputFile, json_encode([ + 'from_width_command' => $fromWidth->toPdfCommand('Fm0'), + 'from_height_command' => $fromHeight->toPdfCommand('Fm0'), + 'from_scale_command' => $fromScale->toPdfCommand('Fm0'), +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + +return [ + 'example' => 'placement', + 'generated_files' => [$outputFile], + 'content_stream' => $result->contentStream, + 'resources' => $result->resources, + 'bbox' => $result->bbox, +]; diff --git a/docs/source/examples/preview-pdf.php b/docs/source/examples/preview-pdf.php new file mode 100644 index 0000000..25f7f17 --- /dev/null +++ b/docs/source/examples/preview-pdf.php @@ -0,0 +1,38 @@ +compile(new CompileRequest( + html: '
Preview me
' + . '
Visible appearance test
', + width: 260.0, + height: 90.0, +)); + +$pdfBytes = (new SinglePagePdfExporter())->export($result); +$pdfFile = $outputDir . '/preview.pdf'; +file_put_contents($pdfFile, $pdfBytes); + +return [ + 'example' => 'preview-pdf', + 'generated_files' => [$pdfFile], + 'content_stream' => $result->contentStream, + 'resources' => $result->resources, + 'bbox' => $result->bbox, + 'pdf_file' => $pdfFile, +]; diff --git a/docs/source/examples/svg.php b/docs/source/examples/svg.php new file mode 100644 index 0000000..1801994 --- /dev/null +++ b/docs/source/examples/svg.php @@ -0,0 +1,51 @@ +' + . '' + . 'SVG sample' + . ''; + +$result = (new XObjectTemplateCompiler())->compile(new CompileRequest( + html: $html, + width: 280.0, + height: 100.0, +)); + +$pdfFile = $outputDir . '/svg-preview.pdf'; +$pdfBytes = (new SinglePagePdfExporter())->export($result); +file_put_contents($pdfFile, $pdfBytes); + +$jsonFile = $outputDir . '/svg-result.json'; +file_put_contents($jsonFile, json_encode([ + 'bbox' => $result->bbox, + 'resources' => $result->resources, + 'metadata' => $result->metadata, +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + +return [ + 'example' => 'svg', + 'generated_files' => [$pdfFile, $jsonFile], + 'content_stream' => $result->contentStream, + 'resources' => $result->resources, + 'bbox' => $result->bbox, + 'pdf_file' => $pdfFile, +]; diff --git a/docs/source/guides/getting-started.md b/docs/source/guides/getting-started.md new file mode 100644 index 0000000..ad356e5 --- /dev/null +++ b/docs/source/guides/getting-started.md @@ -0,0 +1,51 @@ + + + +# Getting started + +## Install + +```bash +composer require libresign/xobject-template +``` + +## Render a template + +```php +compile(new CompileRequest( + html: '
Signed by {{ name }}
', + width: 240.0, + height: 84.0, + context: ['name' => 'Alice'], +)); + +// $result->contentStream +// $result->resources +// $result->bbox +``` + +## Validate the examples + +Run the integration scenario for examples to execute every example file and verify the generated artifacts: + +```bash +composer run examples:test +``` + +This command also materializes additional reusable integration scenarios under +`build/examples/integration-scenarios/`. + +Visible stamp scenarios (including GovBR-like) are also materialized under +`build/examples/visible-stamp/`. + +## Where to look next + +- [Examples](../reference/examples.md) +- [Visible signature use case](../use-cases/visible-signatures.md) diff --git a/docs/source/index.md b/docs/source/index.md new file mode 100644 index 0000000..250a6b7 --- /dev/null +++ b/docs/source/index.md @@ -0,0 +1,31 @@ + + + +# XObject Template documentation + +`xobject-template` compiles a HTML/CSS template into PDF Form XObject output. + +## Start here + +- [Getting started](guides/getting-started.md) +- [Examples](reference/examples.md) +- [Visible signature use case](use-cases/visible-signatures.md) + +## Quick example + +```php +compile(new CompileRequest( + html: '
Signed by {{ name }}
', + width: 240.0, + height: 84.0, + context: ['name' => 'Alice'], +)); +``` + +For executable samples, see the [examples page](reference/examples.md). diff --git a/docs/source/reference/examples.md b/docs/source/reference/examples.md new file mode 100644 index 0000000..25751b9 --- /dev/null +++ b/docs/source/reference/examples.md @@ -0,0 +1,72 @@ + + + +# Examples + +The examples are executable PHP files under `docs/source/examples/`. +Each one is executed and asserted by `tests/Integration/ExamplesOutputScenarioTest.php`, +which also validates the generated artifacts in `build/examples/`. + +## Included examples + +Expand an example to inspect the source inline. + +??? example "basic-template.php — compile a simple template with a context variable" + [Open on GitHub](https://github.com/LibreSign/xobject-template/blob/main/docs/source/examples/basic-template.php) + + ```php + --8<-- "examples/basic-template.php" + ``` + +??? example "preview-pdf.php — export a single-page PDF preview" + [Open on GitHub](https://github.com/LibreSign/xobject-template/blob/main/docs/source/examples/preview-pdf.php) + + ```php + --8<-- "examples/preview-pdf.php" + ``` + +??? example "images.php — embed PNG and JPEG assets from base64 fixtures" + [Open on GitHub](https://github.com/LibreSign/xobject-template/blob/main/docs/source/examples/images.php) + + ```php + --8<-- "examples/images.php" + ``` + +??? example "svg.php — render SVG content as an image source" + [Open on GitHub](https://github.com/LibreSign/xobject-template/blob/main/docs/source/examples/svg.php) + + ```php + --8<-- "examples/svg.php" + ``` + +??? example "placement.php — demonstrate placement calculations" + [Open on GitHub](https://github.com/LibreSign/xobject-template/blob/main/docs/source/examples/placement.php) + + ```php + --8<-- "examples/placement.php" + ``` + +??? example "interpolation.php — interpolate context variables in text" + [Open on GitHub](https://github.com/LibreSign/xobject-template/blob/main/docs/source/examples/interpolation.php) + + ```php + --8<-- "examples/interpolation.php" + ``` + +## How to run them + +```bash +composer run examples:test +``` + +## Output files + +The integration scenario for examples writes generated artifacts to `build/examples/` +so the repository stays clean and the outputs remain disposable. + +It also exports additional artifacts from reusable integration rendering scenarios to +`build/examples/integration-scenarios/`, so integration coverage doubles as practical examples. + +Visible stamp integration scenarios are exported to `build/examples/visible-stamp/` +using the same shared scenario catalog and preview factory, including the GovBR-like +appearance as a first-class validated example. diff --git a/docs/source/use-cases/visible-signatures.md b/docs/source/use-cases/visible-signatures.md new file mode 100644 index 0000000..d23b425 --- /dev/null +++ b/docs/source/use-cases/visible-signatures.md @@ -0,0 +1,27 @@ + + + +# Visible signature appearances + +This library exists to support cases where a PDF needs a predictable visible appearance: + +- signature labels, +- approval stamps, +- overlay annotations, +- status blocks, +- other compact PDF decorations. + +## Why this matters + +PDF pipelines usually need stable layout more than browser-grade rendering. `xobject-template` keeps the surface area small so the output is easier to test and reason about. + +## Typical flow + +1. Build a `CompileRequest`. +2. Pass HTML, dimensions, and context to `XObjectTemplateCompiler`. +3. Consume the generated `contentStream`, `resources`, and `bbox` in downstream PDF tooling. + +## Related pages + +- [Getting started](../guides/getting-started.md) +- [Examples](../reference/examples.md) diff --git a/tests/Integration/ExamplesOutputScenarioTest.php b/tests/Integration/ExamplesOutputScenarioTest.php new file mode 100644 index 0000000..9334686 --- /dev/null +++ b/tests/Integration/ExamplesOutputScenarioTest.php @@ -0,0 +1,221 @@ + $generatedFiles */ + $generatedFiles = $result['generated_files']; + self::assertNotSame([], $generatedFiles); + + foreach ($generatedFiles as $generatedFile) { + self::assertStringStartsWith($examplesOutputRoot . '/', $generatedFile); + self::assertFileExists($generatedFile); + $contents = file_get_contents($generatedFile); + self::assertIsString($contents); + self::assertNotSame('', $contents); + + if (str_ends_with($generatedFile, '.pdf')) { + self::assertStringStartsWith('%PDF-', $contents); + self::assertStringContainsString('%%EOF', $contents); + } + } + + if (isset($result['pdf_file']) && is_string($result['pdf_file'])) { + self::assertStringStartsWith($examplesOutputRoot . '/', $result['pdf_file']); + $pdfContents = file_get_contents($result['pdf_file']); + self::assertIsString($pdfContents); + self::assertStringStartsWith('%PDF-', $pdfContents); + } + } + + public function testEveryPhpExampleFileIsCoveredByTheProvider(): void + { + $projectRoot = dirname(__DIR__, 2); + $allPhpExamples = glob($projectRoot . '/docs/source/examples/*.php'); + self::assertIsArray($allPhpExamples); + + $actual = array_map(static fn (string $path): string => basename($path), $allPhpExamples); + sort($actual); + + $expected = self::documentedExamples(); + sort($expected); + + self::assertSame($expected, $actual); + } + + /** + * @return list + */ + private static function documentedExamples(): array + { + return [ + 'basic-template.php', + 'preview-pdf.php', + 'images.php', + 'svg.php', + 'placement.php', + 'interpolation.php', + ]; + } + + /** + * @return iterable + */ + public static function exampleProvider(): iterable + { + foreach (self::documentedExamples() as $exampleFile) { + yield $exampleFile => ['exampleFile' => $exampleFile]; + } + } + + #[DataProvider('integrationRenderingScenarioProvider')] + public function testIntegrationRenderingScenariosAlsoGenerateExampleArtifacts( + string $slug, + string $html, + float $width, + float $height, + int $maxStreamLength, + ): void { + $projectRoot = dirname(__DIR__, 2); + $scenarioOutputRoot = $projectRoot . '/build/examples/integration-scenarios'; + + if (!is_dir($scenarioOutputRoot)) { + mkdir($scenarioOutputRoot, 0777, true); + } + + $resolvedHtml = $this->resolveIntegrationScenarioHtmlAssets($html, $scenarioOutputRoot); + + $compiler = new XObjectTemplateCompiler(); + $result = $compiler->compile(new CompileRequest( + html: $resolvedHtml, + width: $width, + height: $height, + )); + + self::assertStringContainsString('BT', $result->contentStream); + self::assertStringContainsString('ET', $result->contentStream); + self::assertLessThan($maxStreamLength, strlen($result->contentStream)); + + $pdf = (new SinglePagePdfExporter())->export($result); + $pdfFile = $scenarioOutputRoot . '/' . $slug . '.pdf'; + file_put_contents($pdfFile, $pdf); + + $jsonFile = $scenarioOutputRoot . '/' . $slug . '.json'; + file_put_contents($jsonFile, json_encode([ + 'slug' => $slug, + 'bbox' => $result->bbox, + 'resources' => $result->resources, + 'content_stream_length' => strlen($result->contentStream), + 'metadata' => $result->metadata, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + self::assertFileExists($pdfFile); + self::assertFileExists($jsonFile); + + $pdfContents = file_get_contents($pdfFile); + self::assertIsString($pdfContents); + self::assertStringStartsWith('%PDF-', $pdfContents); + self::assertStringContainsString('%%EOF', $pdfContents); + + $jsonContents = file_get_contents($jsonFile); + self::assertIsString($jsonContents); + self::assertStringContainsString('"content_stream_length"', $jsonContents); + self::assertStringContainsString('"resources"', $jsonContents); + } + + private function resolveIntegrationScenarioHtmlAssets(string $html, string $scenarioOutputRoot): string + { + if (!str_contains($html, '/fixture/example-image.png')) { + return $html; + } + + $assetRoot = $scenarioOutputRoot . '/assets'; + if (!is_dir($assetRoot)) { + mkdir($assetRoot, 0777, true); + } + + $imagePath = $assetRoot . '/example-image.png'; + if (!is_file($imagePath)) { + $contents = PngFixtureFactory::createRgbaPngFromPixelRenderer( + 20, + 20, + static fn (): array => [48, 98, 188, 255], + ); + file_put_contents($imagePath, $contents); + } + + return str_replace('/fixture/example-image.png', $imagePath, $html); + } + + /** + * @return iterable + */ + public static function integrationRenderingScenarioProvider(): iterable + { + yield 'title and status block' => [ + 'slug' => 'title-status-block', + 'html' => '
Prepared for Demo User
' + . '

Document approved

', + 'width' => 260.0, + 'height' => 90.0, + 'maxStreamLength' => 1200, + ]; + + yield 'image with reference text' => [ + 'slug' => 'image-reference-text', + 'html' => '' + . 'ID 42', + 'width' => 260.0, + 'height' => 90.0, + 'maxStreamLength' => 1400, + ]; + + yield 'styled block with alignment and spacing' => [ + 'slug' => 'styled-aligned-spacing', + 'html' => '
Prepared by Styled User
', + 'width' => 260.0, + 'height' => 90.0, + 'maxStreamLength' => 1800, + ]; + } +} diff --git a/tests/Integration/VisibleStampTemplateScenarioTest.php b/tests/Integration/VisibleStampTemplateScenarioTest.php index f6f0d1c..b47688d 100644 --- a/tests/Integration/VisibleStampTemplateScenarioTest.php +++ b/tests/Integration/VisibleStampTemplateScenarioTest.php @@ -17,85 +17,70 @@ final class VisibleStampTemplateScenarioTest extends TestCase { - private const PREVIEW_WIDTH = 804; - private const PREVIEW_HEIGHT = 230; - - #[DataProvider('visibleStampLayoutProvider')] - public function testPhaseOneVisibleStampLayoutsCanBeCompiledAndExported( + #[DataProvider('visibleStampLayoutScenarios')] + public function testVisibleStampLayoutsCanBeCompiledAndExported( string $slug, string $layout, + float $width, + float $height, int $expectedImageCount, array $expectedTexts, ): void { - ['assetRoot' => $assetRoot] = $this->ensurePreviewDirectories(); + $projectRoot = dirname(__DIR__, 2); + $previewRoot = $projectRoot . '/build/examples/visible-stamp'; + $assetRoot = $previewRoot . '/assets'; + self::ensureDirectoryExists($previewRoot); + self::ensureDirectoryExists($assetRoot); - $backgroundPath = $this->createBackgroundPreview($assetRoot . '/background-' . $slug . '.png'); - $signaturePath = $this->layoutUsesSignatureImage($layout) - ? $this->createSignaturePreview($assetRoot . '/signature-' . $slug . '.png') + $backgroundPath = self::createBackgroundPreview( + $assetRoot . '/background-' . $slug . '.png', + (int) $width, + (int) $height, + ); + $signaturePath = self::layoutUsesSignatureImage($layout) + ? self::createSignaturePreview($assetRoot . '/signature-' . $slug . '.png') : null; - ['result' => $result, 'pdf' => $pdf, 'previewPath' => $previewPath] = $this->compilePreview( + ['result' => $result, 'pdf' => $pdf, 'previewPath' => $previewPath] = self::compilePreviewWithSize( $slug, - $this->buildLayoutHtml($layout, $backgroundPath, $signaturePath), + self::buildLayoutHtml($layout, $backgroundPath, $signaturePath), + $width, + $height, + $previewRoot, ); - $this->assertBasePreviewExport($result, $pdf, $previewPath, $expectedImageCount); - - foreach ($expectedTexts as $expectedText) { - self::assertStringContainsString($expectedText, $result->contentStream); - self::assertStringContainsString($expectedText, $pdf); - } - } - - public function testGovBrLikeVisibleStampCanBeCompiledAndExportedUsingSupportedHtmlAndCssOnly(): void - { - $logoPath = dirname(__DIR__) . '/Fixtures/Pdf/Svg/govbr-logo.svg'; - self::assertFileExists($logoPath); - - ['result' => $result, 'pdf' => $pdf, 'previewPath' => $previewPath] = $this->compilePreviewWithSize( - 'govbr-like-visible-stamp', - $this->buildGovBrLikeLayoutHtml($logoPath), - 760.0, - 190.0, + $this->assertBasePreviewExport( + $result, + $pdf, + $previewPath, + $expectedImageCount, + $width, + $height, ); - self::assertSame(1, count($result->resources['XObject'] ?? [])); - self::assertStringStartsWith("%PDF-1.4\n", $pdf); - self::assertStringContainsString('/Subtype /Form', $pdf); - self::assertStringContainsString('/Im0 Do', $result->contentStream); - self::assertFileExists($previewPath); - self::assertSame($pdf, file_get_contents($previewPath)); - self::assertSame($logoPath, $result->resources['XObject']['Im0']['Source']); - - $expectedTexts = [ - 'Documento assinado digitalmente', - 'ASSINANTE DE EXEMPLO', - 'Data: 01/01/2026 12:00:00-0300', - 'Verifique em https://verificador.iti.br', - ]; - foreach ($expectedTexts as $expectedText) { self::assertStringContainsString($expectedText, $result->contentStream); self::assertStringContainsString($expectedText, $pdf); } - - self::assertStringContainsString('1 1 1 rg', $result->contentStream); - self::assertStringContainsString('RG', $result->contentStream); } /** * @return iterable * }> */ - public static function visibleStampLayoutProvider(): iterable + public static function visibleStampLayoutScenarios(): iterable { yield 'signature and metadata at right' => [ 'slug' => 'signature-and-metadata-right', 'layout' => 'signature_and_metadata_right', + 'width' => 804.0, + 'height' => 230.0, 'expectedImageCount' => 2, 'expectedTexts' => [ 'Signed with LibreSign', @@ -108,6 +93,8 @@ public static function visibleStampLayoutProvider(): iterable yield 'label and metadata at right' => [ 'slug' => 'label-and-metadata-right', 'layout' => 'label_and_metadata_right', + 'width' => 804.0, + 'height' => 230.0, 'expectedImageCount' => 1, 'expectedTexts' => [ 'admin', @@ -120,6 +107,8 @@ public static function visibleStampLayoutProvider(): iterable yield 'signature centered' => [ 'slug' => 'signature-centered', 'layout' => 'signature_centered', + 'width' => 804.0, + 'height' => 230.0, 'expectedImageCount' => 2, 'expectedTexts' => [], ]; @@ -127,6 +116,8 @@ public static function visibleStampLayoutProvider(): iterable yield 'metadata only at top left' => [ 'slug' => 'metadata-only-top-left', 'layout' => 'metadata_only_top_left', + 'width' => 804.0, + 'height' => 230.0, 'expectedImageCount' => 1, 'expectedTexts' => [ 'Signed with LibreSign', @@ -139,6 +130,8 @@ public static function visibleStampLayoutProvider(): iterable yield 'two columns with centered cells' => [ 'slug' => 'two-columns-centered-cells', 'layout' => 'two_columns_centered_cells', + 'width' => 804.0, + 'height' => 230.0, 'expectedImageCount' => 2, 'expectedTexts' => [ 'Signed with LibreSign', @@ -149,11 +142,115 @@ public static function visibleStampLayoutProvider(): iterable ]; } - private function buildLayoutHtml(string $layout, string $backgroundPath, ?string $signaturePath): string + public function testGovBrLikeVisibleStampCanBeCompiledAndExportedUsingSupportedHtmlAndCssOnly(): void + { + $projectRoot = dirname(__DIR__, 2); + $slug = 'govbr-like-visible-stamp'; + $expectedImageCount = 1; + $expectedTexts = [ + 'Documento assinado digitalmente', + 'ASSINANTE DE EXEMPLO', + 'Data: 01/01/2026 12:00:00-0300', + 'Verifique em https://verificador.iti.br', + ]; + + $width = 400.0; + $height = 100.0; + $logoPath = $projectRoot . '/tests/Fixtures/Pdf/Svg/govbr-logo.svg'; + if (!is_file($logoPath)) { + throw new \InvalidArgumentException(sprintf('GovBR logo fixture was not found at "%s".', $logoPath)); + } + + $html = sprintf( + << + +
+
+ Documento assinado digitalmente +
+
+ ASSINANTE DE EXEMPLO +
+
+ Data: 01/01/2026 12:00:00-0300 +
+
+ Verifique em https://verificador.iti.br +
+
+ + HTML, + htmlspecialchars($logoPath, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + ); + + [ + 'result' => $result, + 'pdf' => $pdf, + 'previewPath' => $previewPath, + ] = self::compilePreviewWithSize( + $slug, + $html, + $width, + $height, + $projectRoot . '/build/examples/visible-stamp', + ); + + $this->assertBasePreviewExport( + $result, + $pdf, + $previewPath, + $expectedImageCount, + ); + self::assertSame($width, $result->bbox[2] - $result->bbox[0]); + self::assertSame($height, $result->bbox[3] - $result->bbox[1]); + self::assertSame($logoPath, $result->resources['XObject']['Im0']['Source']); + + foreach ($expectedTexts as $expectedText) { + self::assertStringContainsString($expectedText, $result->contentStream); + self::assertStringContainsString($expectedText, $pdf); + } + + self::assertStringContainsString('1 1 1 rg', $result->contentStream); + self::assertStringNotContainsString('RG', $result->contentStream); + } + + /** + * @return array{result: CompileResult, pdf: string, previewPath: string} + */ + private static function compilePreviewWithSize( + string $slug, + string $html, + float $width, + float $height, + string $previewRoot, + ): array { + self::ensureDirectoryExists($previewRoot); + self::removeLegacyPreviewPngs($previewRoot, $slug); + + $compiler = new XObjectTemplateCompiler(); + $result = $compiler->compile(new CompileRequest( + html: $html, + width: $width, + height: $height, + )); + + $pdf = (new SinglePagePdfExporter())->export($result); + $previewPath = $previewRoot . '/' . $slug . '.pdf'; + file_put_contents($previewPath, $pdf); + + return [ + 'result' => $result, + 'pdf' => $pdf, + 'previewPath' => $previewPath, + ]; + } + + private static function buildLayoutHtml(string $layout, string $backgroundPath, ?string $signaturePath): string { $background = sprintf( '', - $this->escapeAttribute($backgroundPath), + self::escapeAttribute($backgroundPath), ); return match ($layout) { @@ -170,7 +267,7 @@ private function buildLayoutHtml(string $layout, string $backgroundPath, ?string . '' . '', $background, - $this->requireSignaturePath($signaturePath), + self::requireSignaturePath($signaturePath), ), 'label_and_metadata_right' => sprintf( '
%s' @@ -190,7 +287,7 @@ private function buildLayoutHtml(string $layout, string $backgroundPath, ?string . '' . '
', $background, - $this->requireSignaturePath($signaturePath), + self::requireSignaturePath($signaturePath), ), 'metadata_only_top_left' => sprintf( '
%s' @@ -217,137 +314,21 @@ private function buildLayoutHtml(string $layout, string $backgroundPath, ?string . '
' . '', $background, - $this->requireSignaturePath($signaturePath), + self::requireSignaturePath($signaturePath), ), default => throw new \InvalidArgumentException(sprintf('Unknown visible stamp layout "%s".', $layout)), }; } - private function buildGovBrLikeLayoutHtml(string $logoPath): string - { - return sprintf( - '
' - . '
' - . '' - . '
' - . '
' - . '
Documento assinado digitalmente
' - . '
ASSINANTE DE EXEMPLO
' - . '
Data: 01/01/2026 12:00:00-0300
' - . '
Verifique em https://verificador.iti.br
' - . '
' - . '
', - $this->escapeAttribute($logoPath), - ); - } - - /** - * @return array{previewRoot: string, assetRoot: string} - */ - private function ensurePreviewDirectories(): array - { - $previewRoot = dirname(__DIR__, 2) . '/build/visible-stamp-previews'; - $assetRoot = $previewRoot . '/assets'; - $this->ensureDirectoryExists($previewRoot); - $this->ensureDirectoryExists($assetRoot); - - return [ - 'previewRoot' => $previewRoot, - 'assetRoot' => $assetRoot, - ]; - } - - /** - * @return array{result: CompileResult, pdf: string, previewPath: string} - */ - private function compilePreview(string $slug, string $html): array - { - return $this->compilePreviewWithSize($slug, $html, (float) self::PREVIEW_WIDTH, (float) self::PREVIEW_HEIGHT); - } - - /** - * @return array{result: CompileResult, pdf: string, previewPath: string} - */ - private function compilePreviewWithSize(string $slug, string $html, float $width, float $height): array - { - ['previewRoot' => $previewRoot] = $this->ensurePreviewDirectories(); - $this->removeLegacyPreviewPngs($previewRoot, $slug); - - $compiler = new XObjectTemplateCompiler(); - $result = $compiler->compile(new CompileRequest( - html: $html, - width: $width, - height: $height, - )); - - $pdf = (new SinglePagePdfExporter())->export($result); - $previewPath = $previewRoot . '/' . $slug . '.pdf'; - file_put_contents($previewPath, $pdf); - - return [ - 'result' => $result, - 'pdf' => $pdf, - 'previewPath' => $previewPath, - ]; - } - - private function removeLegacyPreviewPngs(string $previewRoot, string $slug): void - { - $legacyCandidates = [ - $previewRoot . '/' . $slug . '.png', - $previewRoot . '/' . $slug . '-1.png', - ]; - - foreach ($legacyCandidates as $legacyCandidate) { - if (is_file($legacyCandidate)) { - unlink($legacyCandidate); - } - } - } - - private function assertBasePreviewExport( - CompileResult $result, - string $pdf, - string $previewPath, - int $expectedImageCount, - ): void { - self::assertSame($expectedImageCount, count($result->resources['XObject'] ?? [])); - self::assertSame((float) self::PREVIEW_WIDTH, $result->resources['XObject']['Im0']['Width']); - self::assertSame((float) self::PREVIEW_HEIGHT, $result->resources['XObject']['Im0']['Height']); - self::assertStringStartsWith("%PDF-1.4\n", $pdf); - self::assertStringContainsString('/Subtype /Form', $pdf); - self::assertStringContainsString( - sprintf( - 'q %F 0 0 %F %F %F cm /Im0 Do Q', - (float) self::PREVIEW_WIDTH, - (float) self::PREVIEW_HEIGHT, - 0.0, - 0.0, - ), - $result->contentStream, - ); - self::assertStringContainsString('/Im0 Do', $result->contentStream); - self::assertFileExists($previewPath); - self::assertSame($pdf, file_get_contents($previewPath)); - - if ($expectedImageCount > 1) { - self::assertStringContainsString('/Im1 Do', $result->contentStream); - } - } - - private function createBackgroundPreview(string $path): string + private static function createBackgroundPreview(string $path, int $width, int $height): string { if (is_file($path)) { return $path; } $contents = PngFixtureFactory::createRgbaPngFromPixelRenderer( - self::PREVIEW_WIDTH, - self::PREVIEW_HEIGHT, + $width, + $height, function (int $x, int $y, int $width, int $height): array { $background = [245, 247, 250, 255]; $diagonal = abs(($height * $x) - ($width * $y)); @@ -373,7 +354,7 @@ function (int $x, int $y, int $width, int $height): array { return $path; } - private function createSignaturePreview(string $path): string + private static function createSignaturePreview(string $path): string { if (is_file($path)) { return $path; @@ -413,17 +394,30 @@ function (int $x, int $y, int $width, int $height): array { return $path; } + private static function removeLegacyPreviewPngs(string $previewRoot, string $slug): void + { + $legacyCandidates = [ + $previewRoot . '/' . $slug . '.png', + $previewRoot . '/' . $slug . '-1.png', + ]; - private function requireSignaturePath(?string $signaturePath): string + foreach ($legacyCandidates as $legacyCandidate) { + if (is_file($legacyCandidate)) { + unlink($legacyCandidate); + } + } + } + + private static function requireSignaturePath(?string $signaturePath): string { if ($signaturePath === null) { throw new \InvalidArgumentException('This visible stamp layout requires a signature image.'); } - return $this->escapeAttribute($signaturePath); + return self::escapeAttribute($signaturePath); } - private function ensureDirectoryExists(string $directory): void + private static function ensureDirectoryExists(string $directory): void { if (is_dir($directory)) { return; @@ -432,7 +426,7 @@ private function ensureDirectoryExists(string $directory): void mkdir($directory, 0777, true); } - private function layoutUsesSignatureImage(string $layout): bool + private static function layoutUsesSignatureImage(string $layout): bool { return in_array( $layout, @@ -441,8 +435,44 @@ private function layoutUsesSignatureImage(string $layout): bool ); } - private function escapeAttribute(string $value): string + private static function escapeAttribute(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } + + private function assertBasePreviewExport( + CompileResult $result, + string $pdf, + string $previewPath, + int $expectedImageCount, + ?float $expectedWidth = null, + ?float $expectedHeight = null, + ): void { + self::assertSame($expectedImageCount, count($result->resources['XObject'] ?? [])); + + if ($expectedWidth !== null && $expectedHeight !== null) { + self::assertSame($expectedWidth, $result->resources['XObject']['Im0']['Width']); + self::assertSame($expectedHeight, $result->resources['XObject']['Im0']['Height']); + self::assertStringContainsString( + sprintf( + 'q %F 0 0 %F %F %F cm /Im0 Do Q', + $expectedWidth, + $expectedHeight, + 0.0, + 0.0, + ), + $result->contentStream, + ); + } + + self::assertStringStartsWith("%PDF-1.4\n", $pdf); + self::assertStringContainsString('/Subtype /Form', $pdf); + self::assertStringContainsString('/Im0 Do', $result->contentStream); + self::assertFileExists($previewPath); + self::assertSame($pdf, file_get_contents($previewPath)); + + if ($expectedImageCount > 1) { + self::assertStringContainsString('/Im1 Do', $result->contentStream); + } + } } diff --git a/tests/Integration/XObjectRenderingScenarioTest.php b/tests/Integration/XObjectRenderingScenarioTest.php index e2ddd7c..885d49f 100644 --- a/tests/Integration/XObjectRenderingScenarioTest.php +++ b/tests/Integration/XObjectRenderingScenarioTest.php @@ -15,10 +15,10 @@ final class XObjectRenderingScenarioTest extends TestCase { #[DataProvider('renderingScenarioProvider')] - public function testReusableXObjectScenarios(string $html, int $maxStreamLength): void + public function testReusableXObjectScenarios(string $html, float $width, float $height, int $maxStreamLength): void { $compiler = new XObjectTemplateCompiler(); - $result = $compiler->compile(new CompileRequest(html: $html, width: 260.0, height: 90.0)); + $result = $compiler->compile(new CompileRequest(html: $html, width: $width, height: $height)); self::assertStringContainsString('BT', $result->contentStream); self::assertStringContainsString('ET', $result->contentStream); @@ -28,25 +28,31 @@ public function testReusableXObjectScenarios(string $html, int $maxStreamLength) } /** - * @return iterable + * @return iterable */ public static function renderingScenarioProvider(): iterable { yield 'title and status block' => [ 'html' => '
Prepared for Demo User
' . '

Document approved

', + 'width' => 260.0, + 'height' => 90.0, 'maxStreamLength' => 1200, ]; yield 'image with reference text' => [ 'html' => '' . 'ID 42', + 'width' => 260.0, + 'height' => 90.0, 'maxStreamLength' => 1400, ]; yield 'styled block with alignment and spacing' => [ 'html' => '
Prepared by Styled User
', + 'width' => 260.0, + 'height' => 90.0, 'maxStreamLength' => 1800, ]; }