Jh2pdf — JavaScript HTML 2 PDF

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 license

Overview

Generating PDFs from HTML usually ends up in one of these dead ends:

OptionProblem
Chrome / Puppeteer on the serverneeds a VPS or container; does not run on shared hosting
wkhtmltopdfa binary on the server, outdated CSS, discontinued project
Dompdf / mPDF / TCPDF parsing the HTMLonly understand CSS 2.1: no flexbox, grid, variables, clamp(), filters… the PDF looks different from the screen
External PDF APIcost, 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.

Matches the screenflexbox, grid, CSS variables, calc(): anything the browser understands, because the browser does the measuring.
Real textselectable, searchable text with embedded (subset) fonts.
Simple hostingjust PHP 8.1 + GD + mbstring. No Chrome, no Node, no binaries.
Secure by designthe server never interprets HTML, CSS or JS: only validated numbers, colors and text.

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
  1. 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).
  2. 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).
  3. 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

WhereRequirementNotes
Browserany modern browser (Chromium, Firefox, Safari)uses ES modules, Range.getClientRects, document.fonts
ServerPHP ≥ 8.1tested on 8.2
Servergd and mbstring extensionsGD: images, filters and shadows · mbstring: UTF-8 text
Serverdompdf/dompdf ^3.1installed by Composer (see Dependencies)
Servera writable directoryfont 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.

OptionTypeDefaultDescription
width, heightnumber (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.
hostCssstring—Extra CSS inside the measuring container (for example, a reset). Scope it with [data-jh2pdf-host].
marksbooleanfalseDraws 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.

Tip: since measuring happens inside your document, scope the page CSS under a class (for example, .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.

ClassRole
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.
OptionsConfiguration. Only tempDir is required (see Options).
ImageResolverInterface load(string $src): ?string. You decide where images come from; return null to refuse.
DataUriImageResolverDefault resolver. Only accepts data:image/(png|jpeg|gif|webp);base64,…, up to 12 MB (__construct(int $maxBytes)).
ExceptionExtends RuntimeException. Generic message with no server paths; details are in getPrevious().
Layoutclean(array, ?Options) validates the JSON · num($v, $min, $max) and color($v) are safe converters · OPS = 'RBTILMCE'.
ImageFilterapply(string $dataUri, string $filter, …): ?string applies CSS filters with GD. Usable on its own.
ImagesInternal: resolves, filters, downsizes (shrink) and renders shadows (shadow). dimensionsOk() validates the header before decoding.
FontFacescss(Options $o, string|callable $url = '', string $display = 'block'): string generates the browser's @font-face CSS from the same fonts the PDF uses.
FontIndexInternal: the list of fonts the server has (addFont + folders), the single source for the PDF and for FontFaces.
FontsInternal: chooses the file per family, style and weight (CSS rules) and registers it in Dompdf; synthetic italic when the family has none.
Marks, SvgInternal: read the mark drawings (SVG inside CSS) and flatten SVG paths into polygons.

Options

PropertyTypeDefaultDescription
tempDirstring—Required (1st constructor argument). Writable directory: Dompdf font cache and temporary images.
fontDirstringthe 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?ImageResolverDataUriImageResolverWhere images come from.
marksCss?stringresources/marks.cssCSS file with the hand-drawn mark shapes.
creatorstring'Jh2Pdf'PDF "Creator" metadata.
fallbackFontstring'DejaVu Sans'Font used when no family of the font-family list exists on the server. See strictFonts.
strictFontsboolfalseThrows Jh2Pdf\Exception (Missing fonts: Roboto 400.) before drawing, instead of silently using fallbackFont. Recommended in development.
onMissingFont?callablenullCalled once per missing font with ($family, $weight, $italic), before drawing. Use it to log.
maxPagesint1000Pages beyond this are dropped.
maxOpsPerPageint60,000Operations per page.
maxOpsTotalint400,000Operations in the whole document.
maxImagesint200Images processed per PDF (each one costs CPU and memory).
memoryLimit?string'512M'Raises memory_limit during rendering (never lowers it). null = leave it alone.
pxToPtfloat0.75CSS 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" }
  ] } ] }
OpNameMain fields
Rfilled rectanglex y w h c a r[4]
Bbordersx y w h s[4]=[width,color,a] (top, right, bottom, left) r[4]
Ttext (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)
Iimagex y w h u k[4] f a r[4] (u = src, k = clip box, f = CSS filter)
Mhand-drawn markm v x y w h c z
Llinex y x2 y2 c w a
C / Ebegin / end rounded clipx 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.

FeatureStatusDetails
Text: family (with fallbacks), weight 100–900, italic, size, color, opacityYesUses 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-spacingYesThe final width is always the one measured by the browser.
text-decoration (underline, line-through)PartialThe 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-gradientPartialVertical 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)Partialdashed, dotted and double render as solid. Very thick rounded borders may show thin seams.
box-shadow without blur (outer and inset)YesMultiple shadows and spread. Inset: offset only, no spread.
Blurred box-shadow, text-shadowNoIgnored.
opacityYesMultiplied through ancestors.
overflow: hidden with border-radiusYesRounded clipping of everything inside.
<img> PNG, JPEG, GIF, WebPYesWith object-fit (cover, contain, none, scale-down) and rounded corners.
filter on imagesYesgrayscale sepia brightness contrast invert blur saturate hue-rotate opacity drop-shadow (applied with GD).
Lists: disc circle square decimal lower/upper-alpha, ::markerYesIncluding start and value.
Empty ::before/::after (content: '')PartialWith background, border, corners and solid shadow, when position: absolute or display: block. Does not work with inline-block.
Pseudo-elements with text, counters, ::first-letterNoUse a real element (<span>) for numbers and labels.
background-image: url(), transform, clip-path, maskNo
Inline SVG, <canvas>, <video>, <iframe>NoConvert a canvas to <img src="data:…"> before extracting (the demo does this).
Font ligaturesNoFor example, => in Fira Code becomes two characters.
Clickable links in the PDFNoNo 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).

FormatCSS px (width × height)PDF (pt)
A4 portrait794 × 1123595 × 842
A4 landscape1123 × 794842 × 595
US Letter816 × 1056612 × 792
16:9 slide1280 × 720960 × 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:

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

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.

AttackDefense
Injecting HTML, JS or CSS into textText 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 byteReduced 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 memoryLimits 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 messagesOnly Jh2Pdf\Exception with a generic message is thrown.
Leftover temporary filesRandom names, deleted at the end of each PDF and swept after 1 hour.
Your responsibility: authentication, CSRF, rate limiting and a request body size limit (rendering PDFs is expensive), plus the 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:

StageResult
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

Troubleshooting

SymptomLikely cause and fix
Text in a different font than on screenThe 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 outThe 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 appearThe 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 pageThere is no automatic pagination. Split into more pages.
Your app's styles "leak" into the pageThe container inherits from <body>. Scope the page CSS and set font, color and line-height on the root.
Dompdf is not installedRun composer install or load vendor/autoload.php first.
Options::$tempDir must be an existing writable directoryCreate the directory and give the PHP user write permission.
Error 500 with no detailsThat's intentional. Log $e->getPrevious().

Dependencies

Production

PackageVersionLicensePurpose
dompdf/dompdf^3.1 (tested 3.1.6)LGPL-2.1Only Cpdf (PDF writing), font metrics and font registration. Dompdf's HTML/CSS parser is not used.
dompdf/php-font-lib1.0.2LGPL-2.1-or-laterReads the TTFs and builds the subset embedded in the PDF.
dompdf/php-svg-lib1.0.2LGPL-3.0-or-laterDompdf dependency (not used directly).
masterminds/html52.11.0MITDompdf dependency (not used directly).
sabberworm/php-css-parser9.5.0MITDompdf dependency (not used directly).
gd extension—PHPImage decoding, downsizing, filters and shadows.
mbstring extension—PHPUTF-8 text.
Fonts in fonts/—SIL OFL 1.1Embedded in the PDFs. Notices in fonts/OFL.txt.

In the browser: no dependencies (js/jh2pdf.js is plain JavaScript).

Development only

ToolLicensePurpose
ComposerMITInstall Dompdf and run composer test.
Python 3 + fonttools + brotliMITtools/generate-fonts.py: generate static TTFs from variable fonts.
LGPL in practice: Dompdf is used as an unmodified library installed by Composer. That is compatible with Jh2Pdf's MIT license and with use in closed-source software. The LGPL only requires that, if you modify Dompdf itself and distribute it, you publish those changes, and that users can swap the Dompdf version (which Composer already allows).
Reserved Font Names (RFN): Lora, Merriweather, Playfair Display and Source Serif 4 (reserved name "Source") have a Reserved Font Name. Our static instances are modified versions (generated from the variable fonts), and the OFL forbids modified versions from using the reserved name. That is why they ship renamed as Jh Serif, Jh Serif Text, Jh Display and Jh Serif Book, with the original copyright notices kept in 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