HTML to PDF that looks exactly like the browser, without a browser on the server. The exporting user's browser computes the layout; PHP only draws the PDF. Runs on shared hosting (PHP + GD).
See the examples galleryJSON format (format.md)MIT licenseOverview
Generating PDFs from HTML usually ends up in one of these dead ends:
| Option | Problem |
|---|---|
| Chrome / Puppeteer on the server | needs a VPS or container; does not run on shared hosting |
| wkhtmltopdf | a binary on the server, outdated CSS, discontinued project |
| Dompdf / mPDF / TCPDF parsing the HTML | only understand CSS 2.1: no flexbox, grid, variables, clamp(), filters… the PDF looks different from the screen |
| External PDF API | cost, third-party dependency, and your content leaves your server |
Jh2Pdf starts from a different idea: the exporting user's browser is already the best layout engine there is, and it is already open. Instead of reimplementing CSS in PHP, the browser computes where every word, box, border and image goes, and PHP only draws those positions. There is no line breaking or layout computation on the server to get wrong.
calc(): anything the browser understands, because the browser does the measuring.How it works
BROWSER (js/jh2pdf.js) SERVER (PHP)
────────────────────── ────────────
your HTML + your CSS Options (tempDir, fontDir, imageResolver…)
│ renders each page off-screen │
│ (waits for fonts and images) ▼
▼ Layout::clean() validates everything (the JSON is hostile)
extractLayout() walks the DOM: │
getComputedStyle + Range.getClientRects per word ▼
│ Renderer::render() → Dompdf\Cpdf
▼ R rectangle · B borders · T text · I image
JSON of operations ──────── POST ───────────────────► M hand mark · L line · C/E clip
{ w, h, pages: [{ bg, ops: [...] }] } │
▼
PDF bytes
- Measuring (browser). Each page is inserted into an off-screen container inside your own document, so your CSS applies.
The module waits for fonts (
document.fonts.ready) and images (up to 4 s each). - Extraction. For each visible element it reads the computed style and boxes; for each word, its exact position and width. The result is an ordered list of drawing operations (the order is the paint order).
- Drawing (server). PHP validates the JSON and draws the operations with Dompdf's
Cpdf, used only as a PDF "canvas". Each word is placed on its baseline and its letter spacing is adjusted so it has exactly the measured width.
Requirements
| Where | Requirement | Notes |
|---|---|---|
| Browser | any modern browser (Chromium, Firefox, Safari) | uses ES modules, Range.getClientRects, document.fonts |
| Server | PHP ≥ 8.1 | tested on 8.2 |
| Server | gd and mbstring extensions | GD: images, filters and shadows · mbstring: UTF-8 text |
| Server | dompdf/dompdf ^3.1 | installed by Composer (see Dependencies) |
| Server | a writable directory | font cache and temporary images (Options::$tempDir) |
Installation
With Composer
composer require geisonq/jh2pdf
Or, from this repository: composer install at the root (installs Dompdf into vendor/).
Without Composer
Copy the folder and use the bundled autoloader. Dompdf is still required and you must load it yourself:
require '/path/jh2pdf/autoload.php'; // Jh2Pdf\* classes
require '/path/vendor/autoload.php'; // Dompdf
In the browser
Copy js/jh2pdf.js to your site. It is an ES module with no dependencies (~30 KB, unminified).
Quick start
1. Browser
import { extractLayout } from './jh2pdf.js';
const layout = await extractLayout(
['<h1>Hello</h1><p>First page</p>', '<p>Second page</p>'], // one HTML fragment per page
{ width: 794, height: 1123 } // A4 portrait in CSS px
);
const res = await fetch('/pdf.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(layout),
});
const pdf = await res.blob(); // application/pdf
window.open(URL.createObjectURL(pdf));
2. Server (pdf.php)
require 'vendor/autoload.php';
$raw = file_get_contents('php://input', false, null, 0, 5_000_000); // limit the body size
$layout = json_decode((string)$raw, true);
if (!is_array($layout)) { http_response_code(400); exit; }
$options = new Jh2Pdf\Options('/writable/path');
try {
$pdf = (new Jh2Pdf\Renderer($options))->render($layout, 'Document title');
} catch (Jh2Pdf\Exception $e) {
error_log((string)$e->getPrevious()); // details are in the previous exception
http_response_code(500); exit;
}
header('Content-Type: application/pdf');
echo $pdf;
Runnable examples: examples/minimal/ (the bare minimum) and demo/ (gallery with tables, effects and images).
JavaScript API
extractLayout(pages, options) → Promise<Layout>
pages is an array with one entry per page: an HTML string, a { html } object, or any value if you pass wrap.
| Option | Type | Default | Description |
|---|---|---|---|
width, height | number (px) | — | Required. Page size in CSS px. In the PDF it becomes pt × 0.75 (794×1123 px = A4). |
wrap(page, i) | function → string | <div>…</div> | The complete HTML of the page, with a single root element. The root's background-color becomes the page background. Handy for passing existing elements: wrap: (el) => el.outerHTML. |
hostCss | string | — | Extra CSS inside the measuring container (for example, a reset). Scope it with [data-jh2pdf-host]. |
marks | boolean | false | Draws hand-drawn marks (.mk.mk-circle etc.). |
skip(el) | function → boolean | — | Return true to not draw an element and its contents. |
acceptImage(src) | function → boolean | — | Filters the image src values that are sent. The server makes the real decision. |
sanitize(html) | function → string | — | Sanitizer for each page's HTML (for example, DOMPurify). The built-in filter still runs on top. See Security. |
Returns { w, h, pages: [{ bg, ops }] }, ready for JSON.stringify. Throws Error if width or height is missing.
.page) and don't depend on the surrounding container.
The container is position: fixed, off-screen, and inherits from <body>. Declare font, color and line-height on the page root.PHP API
Namespace Jh2Pdf, PHP 8.1 with strict_types.
| Class | Role |
|---|---|
Renderer | __construct(Options $o) · render(array $layout, string $title = ''): string returns the PDF bytes and throws Jh2Pdf\Exception on failure · missingFonts() lists the fonts of the last render that the server did not have. |
Options | Configuration. Only tempDir is required (see Options). |
ImageResolver | Interface load(string $src): ?string. You decide where images come from; return null to refuse. |
DataUriImageResolver | Default resolver. Only accepts data:image/(png|jpeg|gif|webp);base64,…, up to 12 MB (__construct(int $maxBytes)). |
Exception | Extends RuntimeException. Generic message with no server paths; details are in getPrevious(). |
Layout | clean(array, ?Options) validates the JSON · num($v, $min, $max) and color($v) are safe converters · OPS = 'RBTILMCE'. |
ImageFilter | apply(string $dataUri, string $filter, …): ?string applies CSS filters with GD. Usable on its own. |
Images | Internal: resolves, filters, downsizes (shrink) and renders shadows (shadow). dimensionsOk() validates the header before decoding. |
FontFaces | css(Options $o, string|callable $url = '', string $display = 'block'): string generates the browser's @font-face CSS from the same fonts the PDF uses. |
FontIndex | Internal: the list of fonts the server has (addFont + folders), the single source for the PDF and for FontFaces. |
Fonts | Internal: chooses the file per family, style and weight (CSS rules) and registers it in Dompdf; synthetic italic when the family has none. |
Marks, Svg | Internal: read the mark drawings (SVG inside CSS) and flatten SVG paths into polygons. |
Options
| Property | Type | Default | Description |
|---|---|---|---|
tempDir | string | — | Required (1st constructor argument). Writable directory: Dompdf font cache and temporary images. |
fontDir | string | the library's fonts/ | Directory of static TTFs named <family>-<weight>[-italic].ttf (2nd constructor argument). |
addFont($family, $weight, $file, $italic) | method | — | Registers one static TTF for a family, a weight (100–900) and a style, with any file name. Wins over the folders. Returns $this. |
addFontDir($dir) | method | — | Another folder of TTFs named <family>-<weight>[-italic].ttf. Returns $this. |
imageResolver | ?ImageResolver | DataUriImageResolver | Where images come from. |
marksCss | ?string | resources/marks.css | CSS file with the hand-drawn mark shapes. |
creator | string | 'Jh2Pdf' | PDF "Creator" metadata. |
fallbackFont | string | 'DejaVu Sans' | Font used when no family of the font-family list exists on the server. See strictFonts. |
strictFonts | bool | false | Throws Jh2Pdf\Exception (Missing fonts: Roboto 400.) before drawing, instead of silently using fallbackFont. Recommended in development. |
onMissingFont | ?callable | null | Called once per missing font with ($family, $weight, $italic), before drawing. Use it to log. |
maxPages | int | 1000 | Pages beyond this are dropped. |
maxOpsPerPage | int | 60,000 | Operations per page. |
maxOpsTotal | int | 400,000 | Operations in the whole document. |
maxImages | int | 200 | Images processed per PDF (each one costs CPU and memory). |
memoryLimit | ?string | '512M' | Raises memory_limit during rendering (never lowers it). null = leave it alone. |
pxToPt | float | 0.75 | CSS px to pt conversion (96 dpi). |
JSON format
This is the contract between the JS and PHP sides. Measurements in CSS px, origin at the top-left corner, colors as #rrggbb, opacity from 0 to 1.
The full reference is in docs/format.md (in Portuguese).
{ "w": 794, "h": 1123,
"pages": [ { "bg": "#ffffff", "ops": [
{ "t": "R", "x": 0, "y": 0, "w": 794, "h": 300, "c": "#4f46e5", "a": 1, "r": [12,12,0,0] },
{ "t": "T", "x": 60, "y": 80, "w": 92.4, "h": 44, "s": "Lorem", "f": "Jh Display", "g": 700, "z": 40, "c": "#ffffff" }
] } ] }
| Op | Name | Main fields |
|---|---|---|
R | filled rectangle | x y w h c a r[4] |
B | borders | x y w h s[4]=[width,color,a] (top, right, bottom, left) r[4] |
T | text (one word) | x y w h s f fb g i z c a l d (fb: fallback families of the font-family list; g: weight 100–900; d: 1 underline, 2 line-through) |
I | image | x y w h u k[4] f a r[4] (u = src, k = clip box, f = CSS filter) |
M | hand-drawn mark | m v x y w h c z |
L | line | x y x2 y2 c w a |
C / E | begin / end rounded clip | x y w h r[4] (nestable up to 32 levels) |
Supported CSS
Layout itself (flexbox, grid, positioning, calc(), variables, units) is always supported, because the browser computes it.
The table below is about what gets drawn.
| Feature | Status | Details |
|---|---|---|
| Text: family (with fallbacks), weight 100–900, italic, size, color, opacity | Yes | Uses the closest weight you have, as CSS does. Families without italic are slanted 12°, as the browser does. Needs a static TTF on the server: see Fonts. |
text-transform, letter-spacing | Yes | The final width is always the one measured by the browser. |
text-decoration (underline, line-through) | Partial | The line is drawn word by word (it does not run under spaces). Decoration color and style are ignored. |
background-color, border-radius (per corner) | Yes | |
linear-gradient | Partial | Vertical and horizontal, multiple stops, drawn as thin bands. Diagonal angles snap to the nearest direction. No radial, repeating or layered gradients. |
| Borders (width and color per side) | Partial | dashed, dotted and double render as solid. Very thick rounded borders may show thin seams. |
box-shadow without blur (outer and inset) | Yes | Multiple shadows and spread. Inset: offset only, no spread. |
Blurred box-shadow, text-shadow | No | Ignored. |
opacity | Yes | Multiplied through ancestors. |
overflow: hidden with border-radius | Yes | Rounded clipping of everything inside. |
<img> PNG, JPEG, GIF, WebP | Yes | With object-fit (cover, contain, none, scale-down) and rounded corners. |
filter on images | Yes | grayscale sepia brightness contrast invert blur saturate hue-rotate opacity drop-shadow (applied with GD). |
Lists: disc circle square decimal lower/upper-alpha, ::marker | Yes | Including start and value. |
Empty ::before/::after (content: '') | Partial | With background, border, corners and solid shadow, when position: absolute or display: block. Does not work with inline-block. |
Pseudo-elements with text, counters, ::first-letter | No | Use a real element (<span>) for numbers and labels. |
background-image: url(), transform, clip-path, mask | No | |
Inline SVG, <canvas>, <video>, <iframe> | No | Convert a canvas to <img src="data:…"> before extracting (the demo does this). |
| Font ligatures | No | For example, => in Fira Code becomes two characters. |
| Clickable links in the PDF | No | No annotations or actions are created (a security decision). |
Pages and sizes
Each item in pages is one PDF page, sized width × height. There is no automatic pagination:
anything taller than the page is cut off. Split your content into pages before extracting (for example, one section per page, or by measuring heights in the browser).
| Format | CSS px (width × height) | PDF (pt) |
|---|---|---|
| A4 portrait | 794 × 1123 | 595 × 842 |
| A4 landscape | 1123 × 794 | 842 × 595 |
| US Letter | 816 × 1056 | 612 × 792 |
| 16:9 slide | 1280 × 720 | 960 × 540 |
Accepted sizes range from 200 to 4000 px per side.
Fonts
For the PDF to look exactly like the screen, the browser and the server must use the same font files. Dompdf cannot read woff2 or variable fonts, so the server uses static TTFs, one per weight. There are three ways to give it fonts:
| Way | When to use it |
|---|---|
fonts/ folder (Options::$fontDir) | files named <family>-<weight>[-italic].ttf: dm-sans-600.ttf, dm-sans-400-italic.ttf (family in lowercase with hyphens, weight 100–900) |
$options->addFontDir($dir) | another folder with the same naming; use it for your own fonts and keep the bundled ones |
$options->addFont($family, $weight, $file, $italic) | one file with any name; it wins over the folders |
$options = new Jh2Pdf\Options('/writable/path');
$options->addFont('Roboto', 400, '/fonts/Roboto-Regular.ttf')
->addFont('Roboto', 400, '/fonts/Roboto-Italic.ttf', italic: true)
->addFont('Roboto', 700, '/fonts/Roboto-Bold.ttf')
->addFontDir('/more/fonts');
How a font is chosen
The same rules as CSS:
- Family: the first family of the
font-familylist that the server has ('Roboto', Arial, sans-serifuses Roboto, or Arial if you have it; generic names such assans-serifare not fonts). The browser sends up to 5 fallbacks after the first. - Style: italic if the family has an italic file; otherwise the regular style slanted 12°, as the browser does.
- Weight: from 100 to 900, the closest file you have: asking for 800 with only 400 and 700 uses 700; 300 uses 400.
Make the browser use the same files
Generate the @font-face CSS from the very same fonts, so there are never two lists to keep in sync:
// once, when you deploy (reading the names inside the TTFs is slow to do on every request)
file_put_contents('public/fonts.css', Jh2Pdf\FontFaces::css($options, '/fonts/'));
or from the command line: php tools/fonts-css.php --dir fonts --url /fonts/ --out public/fonts.css With Composer, the script is vendor/geisonq/jh2pdf/tools/fonts-css.php.
Never fail silently
A family the server does not have is drawn in fallbackFont (DejaVu Sans), and the PDF looks different from the screen. To catch it:
$options->strictFonts = true; // throws Jh2Pdf\Exception: "Missing fonts: Roboto 400, Roboto 700 italic."
$options->onMissingFont = fn(string $family, int $weight, bool $italic) => error_log("Missing font: $family $weight");
$pdf = $renderer->render($layout);
$renderer->missingFonts(); // [['family' => 'Roboto', 'weight' => 400, 'italic' => false], …]
The check runs before anything is drawn. Use strictFonts in development and tests; in production, log with onMissingFont or missingFonts().
Adding a font from a variable font
pip install fonttools brotli
python tools/generate-fonts.py --fonts fonts.json --src font-source-folder --out fonts
Python is only needed to generate files (--weights 300,400,700 chooses the weights; the fonts.json format is in the script header). It writes the right family, style and weight inside each TTF. To fix the names of static TTFs you already have: python tools/generate-fonts.py --fix-names folder. The server uses the ready-made TTFs. Check that the font's license allows embedding it in PDFs.
Bundled (12 families, SIL OFL 1.1): Barlow Condensed, DM Sans, Fira Code, Inter, JetBrains Mono, Montserrat, Nunito, Open Sans, and four renamed serif families: Jh Serif (from Lora), Jh Serif Text (from Merriweather), Jh Display (from Playfair Display) and Jh Serif Book (from Source Serif 4). With italics: DM Sans and Jh Serif.
Images
Each image's src comes from the browser and is not trusted. By default only data: is accepted (no disk, no network).
To serve images from your system, implement a resolver and check that this user may see this file:
final class MyResolver implements Jh2Pdf\ImageResolver
{
public function load(string $src): ?string
{
// only your URL pattern; never concatenate src into a path
if (!preg_match('~^/uploads/([a-f0-9]{16}\.(png|jpg))$~', $src, $m)) return null;
$f = '/var/app/uploads/' . $m[1];
return is_file($f) ? file_get_contents($f) : null;
}
}
$options->imageResolver = new MyResolver();
In the browser, acceptImage avoids sending what the server would refuse. Images are downsized to their displayed size (~150 dpi):
opaque ones become JPEG, transparent ones stay PNG.
Hand-drawn marks
An optional extension, off by default (it is not standard HTML/CSS), for highlighting text with a "hand-drawn" stroke: underline, circle, highlight, box, strike, cross.
<span class="mk mk-circle" style="--mk-color:#e5484d">important</span>
With marks: true, the extractor sends the box and color of the marked text; PHP draws the shape's SVG from resources/marks.css
(or your own CSS, via Options::$marksCss). Without --mk-color, the text color is used. Even elements use the 2nd variant (:nth-of-type(even)), and a mark with display: inline
that wraps gets one drawing per line. Without marks: true, .mk classes are treated like any other element.
The on-screen CSS (a mask on ::after) is in demo/pages.css.
Security
There are two trust boundaries, and on each one whatever comes from the other side is treated as hostile.
Server: the JSON from the browser
Anyone who can call your endpoint can send hand-crafted JSON. By design, the server never interprets HTML, CSS or JavaScript.
| Attack | Defense |
|---|---|
| Injecting HTML, JS or CSS into text | Text goes into the PDF as glyphs (hex-encoded) and is never interpreted. |
Injecting PDF objects (/JS, /OpenAction, /URI, /Launch) | Hex-encoded text, escaped title, and no action or annotation objects are created. There is a test for this. |
Font name with ../, ) or a null byte | Reduced to [A-Za-z0-9 -] (60 characters; at most 5 fallback families) and looked up only among the fonts you registered. Paths come from your configuration, never from the JSON. |
Reading local files or making requests (file://, http://, ../) | Images only go through the ImageResolver. Dompdf runs with isRemoteEnabled, PHP and JS disabled, plus a chroot. |
| Image bomb (a tiny PNG declaring millions of pixels) | The header is read before decoding: side ≤ 20,000 px and ≤ 25 megapixels. |
| Exhausting CPU or memory | Limits on pages, operations, images and nested clips (≤ 32), caps on shadows and blurs, set_time_limit(180). |
Absurd numbers or types (1e999, NaN, arrays, url(...)) | Layout::num() clamps to a range and Layout::color() only accepts #rgb/#rrggbb. |
| Leaking paths in error messages | Only Jh2Pdf\Exception with a generic message is thrown. |
| Leftover temporary files | Random names, deleted at the end of each PDF and swept after 1 hour. |
ImageResolver when it reads files. Keep Dompdf, PHP and GD up to date.Browser: the input HTML
Each page's HTML goes into the exporting user's DOM. If it comes from someone else, it is hostile input (XSS). The module first places the HTML in an inert
<template> and removes script, iframe, object, embed, link, meta,
base, on* attributes, srcdoc, javascript: URLs and non-image data: URLs, and expression().
This is a fallback filter: for third-party content, use a real sanitizer:
extractLayout(pages, { width, height, sanitize: (h) => DOMPurify.sanitize(h) });
<style> and url() in CSS are still allowed. They don't execute code, but they can trigger requests (tracking).
Performance
Measured with the examples gallery (7 A4 pages, ~2,400 operations, 9 filtered images) on an ordinary PC with XAMPP and PHP 8.2:
| Stage | Result |
|---|---|
| JSON sent | ~460 KB (mostly the repeated data: image) |
| Generated PDF | ~300 KB, fonts embedded as subsets |
| PHP, first run | ~6–9 s (registers fonts in the Dompdf cache) |
| PHP, later runs | ~2–3 s, dominated by image filters in GD |
- Filters (
blur,drop-shadow) are the most expensive part. Without them, text and table pages take a fraction of a second. - The same image with the same filter and size is processed only once per PDF (cached).
- For large documents, send images by URL and use an
ImageResolverinstead of repeatingdata:in the JSON.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| Text in a different font than on screen | The server has no static TTF for any family of the font-family list, so it used fallbackFont. Add the font (see Fonts); strictFonts or missingFonts() tell you which ones are missing. |
| Words squeezed or spaced out | The browser measured with another font (the fallback or a system font). Load the same files with @font-face: generate it with FontFaces::css(). |
| Image does not appear | The src is not data: and there is no ImageResolver, or the resolver returned null, or the image exceeded the limits. |
| Content cut off at the bottom of the page | There is no automatic pagination. Split into more pages. |
| Your app's styles "leak" into the page | The container inherits from <body>. Scope the page CSS and set font, color and line-height on the root. |
Dompdf is not installed | Run composer install or load vendor/autoload.php first. |
Options::$tempDir must be an existing writable directory | Create the directory and give the PHP user write permission. |
| Error 500 with no details | That's intentional. Log $e->getPrevious(). |
Dependencies
Production
| Package | Version | License | Purpose |
|---|---|---|---|
| dompdf/dompdf | ^3.1 (tested 3.1.6) | LGPL-2.1 | Only Cpdf (PDF writing), font metrics and font registration. Dompdf's HTML/CSS parser is not used. |
| dompdf/php-font-lib | 1.0.2 | LGPL-2.1-or-later | Reads the TTFs and builds the subset embedded in the PDF. |
| dompdf/php-svg-lib | 1.0.2 | LGPL-3.0-or-later | Dompdf dependency (not used directly). |
| masterminds/html5 | 2.11.0 | MIT | Dompdf dependency (not used directly). |
| sabberworm/php-css-parser | 9.5.0 | MIT | Dompdf dependency (not used directly). |
gd extension | — | PHP | Image decoding, downsizing, filters and shadows. |
mbstring extension | — | PHP | UTF-8 text. |
Fonts in fonts/ | — | SIL OFL 1.1 | Embedded in the PDFs. Notices in fonts/OFL.txt. |
In the browser: no dependencies (js/jh2pdf.js is plain JavaScript).
Development only
| Tool | License | Purpose |
|---|---|---|
| Composer | MIT | Install Dompdf and run composer test. |
Python 3 + fonttools + brotli | MIT | tools/generate-fonts.py: generate static TTFs from variable fonts. |
fonts/OFL.txt.
If you generate new instances of an RFN font, rename them as well.License
Jh2Pdf's code is released under the MIT license, © 2026 Geison Quevedo. Anyone may use, copy, modify, merge, publish, distribute, sublicense and sell it, including in closed-source projects, as long as the copyright notice and license text are kept. The fonts (OFL) and Dompdf (LGPL) have their own licenses, described in Dependencies.
Structure
jh2pdf/
├── LICENSE MIT
├── README.md summary and quick start
├── composer.json package (PSR-4 autoload: Jh2Pdf\ → src/)
├── autoload.php autoloader for projects without Composer
├── src/ PHP: Renderer, Options, Layout, ImageResolver, DataUriImageResolver,
│ Images, ImageFilter, Fonts, Marks, Svg, Exception
├── js/jh2pdf.js layout extractor (ES module, no dependencies)
├── fonts/ static TTFs (OFL) + OFL.txt
├── resources/marks.css SVG drawings of the hand-drawn marks
├── docs/ this documentation (en, pt-BR, es) + format.md (the JSON format)
├── demo/ examples gallery (index.html, pages.css, fonts.css, pdf.php)
├── examples/minimal/ the smallest complete example
├── tests/run.php tests (php tests/run.php or composer test)
├── tools/fonts-css.php generates the browser's @font-face CSS from the same fonts
└── tools/generate-fonts.py generates the TTFs from variable fonts
Contributing
- Tests:
composer installandcomposer test. There is no test framework, justtests/run.php. - Quality bar: match the screen. Produce a PDF from the browser (Print → Save as PDF) and from this library and compare them
side by side, zoomed in (for example, with PyMuPDF:
page.get_pixmap(dpi=200)). - New operation (for example, radial gradients): (1) the extractor in
js/jh2pdf.jsemits the operation; (2)Layout::OPSgets the letter; (3)Renderer::rendergets thecase; (4) document it indocs/format.md; (5) test it intests/run.php. Validate every new field: it is hostile input. - Style: PHP 8.1 with
declare(strict_types=1), no dependencies besides Dompdf. Dependency-free JS, with top-level names prefixed withpl. - Contributions are accepted under the project's license (MIT).
