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: '