TL;DR
- Generating native PDFs can reuse the layout of a document already rendered in the user's browser. JavaScript reads its geometry and writes PDF objects without starting Chromium on a server.
- Browser coordinates need an origin adjustment, a pixels-to-points conversion, and a vertical flip. Text also needs its baseline, rather than the top of its bounding box.
- Text can remain selectable, inputs can become fillable AcroForm fields, and supported SVG shapes can remain vectors. These require separate PDF emitters.
- This approach fits application-owned invoices, receipts, and reports. It still requires accessible font and image bytes, template testing, and fallbacks for unsupported content.
Generating native PDFs for an invoice usually starts with Chromium: it reads the HTML and CSS, lays everything out, and produces a printable page. I normally use it too. But starting a browser just to render a receipt adds a browser binary, a process to run, and time and memory costs. The invoice I wanted to export was already in the user's browser. Its position, style, and size had already been resolved.
This article walks through reading that layout and rebuilding it as native PDF text, paths, and fillable fields, including the mistakes that appeared in the first implementation.
Example environment
The runnable example uses Garri 0.1.0-alpha.3, whose standalone build includes pdf-lib 1.17.1 and @pdf-lib/fontkit 1.1.1. The examples were checked in Chrome for Testing 152.0.7977.54 on macOS arm64. You'll need a page served over HTTP or HTTPS and basic DOM, CSS, and JavaScript knowledge. The complete implementation is on GitHub.
Why generating native PDFs starts with the DOM
The first question was whether we needed another layout engine at all. The browser had already drawn the invoice. Why not read those values and rebuild the document with pdf-lib?
This is different from taking a screenshot and putting it in a PDF. For example, html2pdf.js documents its image-based rendering pipeline, which makes the resulting text unselectable and unsearchable. Native PDF generation writes text and shapes as PDF objects instead.
The invoice has text, borders, a total, and two fields for the recipient's billing contact. Those fields should remain fillable after download. Here is the starting HTML rendered in the browser:
The document's structure is small. This excerpt from the runnable invoice example contains the parts we need to preserve:
<article class="invoice">
<p>Invoice · INV-2048</p>
<h1>Billing details</h1>
<p>Confirm the recipient before the invoice is returned.</p>
<form aria-label="Recipient contact">
<label>
Recipient name
<input name="recipientName" type="text" required />
</label>
<label>
Email address
<input name="recipientEmail" type="email" required />
</label>
</form>
<p>Native PDF implementation · $1,480.00</p>
<p>Total due · $1,480.00</p>
</article>Once the browser has rendered that structure, its APIs expose different parts of the result:
| Browser source | What it provides |
|---|---|
getBoundingClientRect() | An element's position and size relative to the viewport |
getComputedStyle() | Resolved colors, borders, fonts, and clipping properties |
Range.getClientRects() | Rectangles for rendered text fragments |
Canvas measureText() | Font metrics used to recover text baselines |
| Form controls | Field type, name, current value, and required state |
SVG getScreenCTM() | A matrix mapping SVG coordinates into viewport coordinates |
The original invoice is 600 × 380 CSS pixels. The heading uses a 360 × 36 pixel box, and the first input uses a 255 × 40 pixel box. These are measurements of the rendered document, rather than dimensions guessed by the PDF generator:
You can inspect the outer box in the example's browser console. Waiting for fonts avoids measuring the layout before its web font is ready:
await document.fonts.ready;
const invoice = document.querySelector(".invoice");
const rect = invoice.getBoundingClientRect();
const style = getComputedStyle(invoice);
console.table({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
background: style.backgroundColor,
borderWidth: style.borderTopWidth,
borderColor: style.borderTopColor,
borderRadius: style.borderTopLeftRadius,
});The width and height should read 600 and 380. The viewport position depends on your window and scroll position. As MDN explains, these coordinates include the element's padding and border and change as you scroll.
Reading the values gives us a starting point. It does not tell pdf-lib how to draw every HTML or CSS feature. That translation is still our job.
How to fix PDF coordinates
I first copied the browser's x and y values into the PDF. The parts of the invoice appeared in the opposite vertical order. The comparison makes the mistake visible:
Browser coordinates start at the top left, while a default PDF page uses a bottom-left origin. They also use different units. CSS defines 96 pixels and 72 points per inch, so one CSS pixel corresponds to 0.75 PDF points at this scale. This is independent of the screen's device pixel ratio.
Apply the conversion in this order:
- Subtract the invoice's viewport origin from each child's coordinates.
- Add the PDF page margins and multiply CSS pixels by
0.75. - Flip the vertical axis. For a rectangle, subtract its height as well, because PDF positions its lower edge.
This console example computes where the first input rectangle belongs on an A4 page with an 18 mm margin:
const rootBox = document.querySelector(".invoice").getBoundingClientRect();
const inputBox = document.querySelector("input").getBoundingClientRect();
const pointsPerPixel = 72 / 96;
const pageHeightPt = (297 / 25.4) * 72;
const marginPt = (18 / 25.4) * 72;
const xPt = marginPt + (inputBox.left - rootBox.left) * pointsPerPixel;
const yPt = pageHeightPt - marginPt
- (inputBox.top - rootBox.top + inputBox.height) * pointsPerPixel;
console.table({
xPt,
yPt,
widthPt: inputBox.width * pointsPerPixel,
heightPt: inputBox.height * pointsPerPixel,
});Scroll the page and run it again. The input's viewport coordinates change, but its position relative to the invoice should stay the same. That is the coordinate space the export needs.
Place text on its baseline
Fixing rectangles did not fix the text. I used the top of each text box as the position for its PDF text, and the heading moved into the invoice number above it:
PDF text is positioned on its baseline: the invisible line the letters sit on. An element's box includes padding and line-height space, so its top is not the text baseline.
For an ordinary horizontal text fragment in the tested Chromium environment, I recovered the baseline from a text-node Range and the matching canvas font metrics. fontBoundingBoxAscent measures the distance from the baseline to the font bounding box's top. It differs from actualBoundingBoxAscent, which measures the ink of a particular string.
The invoice heading is a single text node, so this console snippet can measure it directly:
await document.fonts.ready;
const heading = document.querySelector(".invoice h1");
const headingStyle = getComputedStyle(heading);
const range = document.createRange();
range.selectNodeContents(heading);
const context = document.createElement("canvas").getContext("2d");
context.font = `${headingStyle.fontStyle} ${headingStyle.fontWeight} ${headingStyle.fontSize} ${headingStyle.fontFamily}`;
context.textBaseline = "alphabetic";
const textBox = range.getBoundingClientRect();
const ascent = context.measureText(heading.textContent).fontBoundingBoxAscent;
const baselinePx = textBox.top + ascent;
console.table({ topPx: textBox.top, ascentPx: ascent, baselinePx });For text, convert this baseline into the PDF's coordinate space. Do not subtract the text box's height again. If a fragment's top is at 100px and its ascent is 31px, its baseline is at 131px in the browser's top-down coordinates.
Wrapped text needs more work: group character ranges into individual line fragments, measure each fragment, and preserve its font and spacing. The browser has already chosen the line breaks; the exporter observes them.
I checked the text geometry fixture against Chromium's own PDF output. In the current check, all 12 matched text fragments had a reported mean and maximum baseline difference of 0.0000px. That is a focused fixture result, not a guarantee for every font, browser, or writing system. The earlier experiment described in the original article measured 18 fragments, rather than 18 separate test runs.
The result also depends on writing PDF text with an appropriate font. Measuring the browser's positions does not replace font embedding or complex-script shaping.
Keep input fields fillable
With text in place, the invoice looked usable, but drawing the inputs as backgrounds and borders only preserved their appearance. The recipient still could not type into them:
PDF uses AcroForm for interactive fields. For each supported HTML input, the converter creates a PDF field over the corresponding rectangle, derives a unique field name from the HTML name, and copies its current value and required flag. pdf-lib provides a createTextField() API for this.
Field behavior needs its own translation. An HTML type="email" input becomes a PDF text field; its browser email validation does not automatically transfer. A PDF required flag is metadata for the viewer, not a substitute for validating data when the completed document comes back.
PDF widgets also support less styling than HTML controls. That is why the native fields have different corners in the comparison.
You can also combine a styled input UI with a fillable field: draw the input's background, border, and rounded corners as page content, then place an AcroForm field over the same rectangle. Configure the widget's appearance with a transparent background and no border, and let the field render the value so the text is not drawn twice. This preserves the drawn styling while keeping the field editable.
Test both their appearance and their behavior in the PDF readers your users actually use. This is part of the developer experience of an export, just as much as the download button.
Preserve SVG transforms
The next addition was a small SVG invoice mark with two paths and a gradient. Reading the path data alone seemed sufficient, until the mark disappeared from the top-right corner:
A path describes geometry in its own SVG coordinate system. It does not include the SVG's page position, its viewBox scale, or ancestor transforms.
getScreenCTM() supplies the matrix that maps a shape into viewport coordinates. You then compose it with the same viewport-to-PDF conversion used for the invoice.
The runnable example includes the mark. Inspect its first path's matrix in the browser console:
const path = document.querySelector(".invoice svg path");
const matrix = path.getScreenCTM();
console.table({
a: matrix.a,
b: matrix.b,
c: matrix.c,
d: matrix.d,
e: matrix.e,
f: matrix.f,
});The a, b, c, and d entries describe the linear transform, and e and f describe translation. Moving the invoice changes the translation. Changing the mark's CSS size relative to its viewBox changes the scale.
The emitter still needs to write the path, stroke, and gradient into the PDF. For this mark, the supported gradient becomes a PDF shading and the paths stay vector paths. An empty pdfimages -list output confirms that no bitmap was embedded; it does not establish that the mark's position, colors, or geometry are correct. Those need a visual comparison too.
Paginate without changing the document's width
The one-page invoice worked, so I moved on to page breaks. I turned the invoice itself into a column container, with one column for each PDF page. The invoice became wider, and the mark moved with its right edge:
The element used for pagination should not also be the document being rendered. Widening the invoice changes any child positioned from its right edge. The exporter would be measuring a different document from the one the user saw.
The fix was a plain wrapper around the invoice. The wrapper becomes the column container, while the invoice keeps its original width. Browser fragmentation then supplies page-sized column layouts that can be mapped to PDF pages. The runnable example uses a .pdf-document wrapper as the export root and keeps the 600 pixel .invoice inside it.
This lets us reuse the browser's layout work, but pagination remains an implementation task. The exporter has to account for page geometry, page breaks, repeated table headers, and content that crosses a boundary. A single-page receipt does not prove that a long statement will paginate correctly.
Generate and verify the invoice
These fixes became Garri, an open-source library that performs the capture and PDF emission inside the browser. You can open the complete runnable invoice example, enter the recipient details, and download its PDF. The example uses a same-origin Figtree font so the generator can read and embed its bytes.
For a bundler-based application, install the versions used here:
npm install garri@0.1.0-alpha.3 pdf-lib@1.17.1 @pdf-lib/fontkit@1.1.1Once the invoice is rendered, pass its .pdf-document wrapper to the download helper from your export action:
import fontkit from "@pdf-lib/fontkit";
import * as PDFLib from "pdf-lib";
import { download } from "garri";
await document.fonts.ready;
const documentRoot = document.querySelector(".pdf-document");
const result = await download(documentRoot, "invoice.pdf", {
pdfLib: PDFLib,
fontkit,
page: { widthMm: 210, heightMm: 297, marginMm: 18 },
});
console.log(`Created ${result.pages} page(s)`);
console.table(result.diagnostics);The standalone script in the runnable example exposes the same helper as Garri.download() and includes the two PDF dependencies. The invoice should produce one page with two fillable text fields. Inspect diagnostics after generation, especially resource-access and font-substitution messages.
The original experiment's final comparison shows the browser invoice beside its native PDF. The text remains text, and the two input fields remain interactive:
You can also download the original generated invoice PDF to inspect the output behind that comparison. It uses a custom page size; the runnable example above uses A4.
For command-line checks, install Poppler. On macOS, use Homebrew:
brew install popplerOn Debian or Ubuntu, use the corresponding package:
sudo apt install poppler-utilsRun these commands from the directory containing your downloaded invoice.pdf:
pdfinfo invoice.pdf
pdfimages -list invoice.pdf
pdftotext invoice.pdf -Check three separate properties: pdfinfo should report one page and Form: AcroForm; pdfimages -list should contain no image rows for this text-and-vector invoice; and pdftotext should recover its invoice number, labels, and total. Then open the PDF, select the text, fill both fields, save it, and reopen it to confirm that the values persist.
For longer documents, compare page count and page assignment with a reference PDF as well. Chromium's Page.pdf() uses print CSS by default, so keep media styles and page geometry consistent when comparing it with the captured browser layout. A good engineering tutorial verifies the exported artifact, rather than treating a successful download as proof of correct rendering.
When this approach breaks down
The invoice proves that browser measurements can support native PDF reconstruction. It does not make the browser a complete PDF conversion API. Three boundaries matter in practice:
- There must be a live browser document to read. This approach fits receipts, invoices, and reports already displayed inside an application. A scheduled server job or a service that accepts arbitrary webpages still needs another rendering strategy. If the user only needs a manual printout, the browser's print dialog may already be enough.
- Layout measurements do not solve fonts, resources, or shaping. Fonts and images must expose bytes JavaScript is allowed to fetch. A browser can display a cross-origin resource that the exporter cannot read. System fonts may be substituted, and Garri's current Arabic and Devanagari cluster shaping is not reliable. Check the compatibility notes before choosing it for those documents.
- The emitter supports a bounded set of paint and document features. Canvas content and outer box shadows use raster fallbacks. Some CSS and SVG effects are unsupported, and fillable widgets cannot reproduce all HTML styling. Selectable text also does not imply an accessible tagged PDF: Garri does not currently emit tagged structure. Review the feature matrix and test your actual templates.
Within those limits, the architecture solves the problem I started with. The document is laid out in the browser the user already has open, and the generator writes the PDF there. A server can still provide data, check access, store the result, or send it, but it does not need to run the layout engine for each download.
This is an updated version of the original article published on drreamer.digital.

