I normally use Chromium to render HTML to PDF because it already knows how to read HTML and CSS, lay everything out and produce a printable page.
But starting a browser just to render a receipt costs time and memory. It also means a small serverless function is no longer enough on its own. You need a Chromium binary and somewhere that can run it reliably.
Then I thought: the document I want to print is already in the DOM. The browser already knows the position, style and size of every element. Why start another Chromium instance? Why not read those values and rebuild the document with pdf-lib?
As always, I went on to test the theory.
Let's say we want to render an invoice to PDF
The invoice has text, borders, a total and two fields for the recipient's billing contact. The fields should still be fillable in the PDF.
The starting point: a normal HTML invoice.
html
<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>
css
.invoice {
position: relative;
width: 600px;
height: 380px;
overflow: hidden;
border: 2px solid #14231b;
border-radius: 24px;
background: #fffdf7;
}
Read what the browser already knows
Now, we ask the browser for the values it used to draw the invoice.
| Browser source | What we get |
|---|---|
getBoundingClientRect() |
Position and size |
getComputedStyle() |
Colors, borders, type and clipping |
Range.getClientRects() |
The position of each line and word |
measureText() |
Where the letters sit inside their box |
| Form controls | Field type, name, value and required state |
getScreenCTM() |
The final position and scale of an SVG |
The invoice is 600 × 380 px. The heading uses a 360 × 36 px box. The first input uses a 255 × 40 px box.
These are the boxes the browser gives us.
javascript
const invoice = document.querySelector('.invoice');
const rect = invoice.getBoundingClientRect();
const style = getComputedStyle(invoice);
console.log({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
background: style.backgroundColor,
borderWidth: style.borderTopWidth,
borderColor: style.borderTopColor,
borderRadius: style.borderTopLeftRadius,
});
javascript
{
x: 48,
y: 48,
width: 600,
height: 380,
background: "rgb(255, 253, 247)",
borderWidth: "2px",
borderColor: "rgb(20, 35, 27)",
borderRadius: "24px"
}
With this, we can now make a first PDF.
The first result was upside down
I copied the browser's x and y values into the PDF. But then I saw that the parts of the invoice appeared in the opposite vertical order.
Copying the browser's vertical position directly puts the PDF elements in the wrong order.
Turns out, browser coordinates start at the top left, while PDF coordinates start at the bottom left. They also use different units: the browser uses pixels and the PDF uses points. One CSS pixel is 0.75 PDF points.
javascript
const xPt = (marginLeftPx + xPx) * 0.75;
const yPt = pageHeightPt - (marginTopPx + yPx) * 0.75;
We can fix it by converting the pixels to points and subtracting the vertical position from the page height.
With this, we can now draw the invoice the right way up.
The text still did not sit correctly
I first used the top of each text box as the position for its PDF text. But then I saw that the heading moved into the invoice number above it.
The box starts above the line where the letters should sit.
Turns out, PDF places text on the invisible line that the letters sit on, not from the top of its box. The browser already takes care of this when it draws HTML. For the PDF, we have to find that line ourselves.
The browser gives us the top of the text box. measureText() gives us the distance from that top edge to the line the letters sit on. That distance is the font ascent.
javascript
const textBox = range.getBoundingClientRect();
const ascent = context.measureText(text).fontBoundingBoxAscent;
const textLineY = textBox.top + ascent;
Here, the text box starts at y = 100 and the ascent is 31px. The PDF text should therefore sit at y = 131.
With this, we can now put each word on the same line as the browser. The browser has already handled wrapping, spacing, font changes and right-to-left text.
I compared these positions with the browser's own PDF output. Across 18 runs, the text lines matched. The largest left-edge difference was less than one hundredth of a pixel.
Keep the input fields fillable
The invoice is meant to be filled after it is downloaded, so the two inputs need to remain interactive in the PDF.
A visual reconstruction has no fields. The AcroForm version has two.
We could draw each input like any other background and border, but that would only preserve its appearance. PDF uses a separate format called AcroForm for interactive fields.
For each HTML input, we create an AcroForm text field over the same rectangle. We also copy its name, value and required state. PDF fields support less styling than HTML inputs, which is why the corners differ in the image.
With this, we can now select the text and fill the two fields. The PDF still does not contain a screenshot of the page.
The commands used to check the PDF
pdfinfo and pdfimages are command-line tools from Poppler. On macOS, install the poppler Homebrew package. On Debian or Ubuntu, install poppler-utils.
bash
# macOS
brew install poppler
# Debian or Ubuntu
sudo apt install poppler-utils
bash
pdfinfo card-basic.pdf
pdfimages -list card-basic.pdf
pdfinfo reports the page count and tells us that the file contains an AcroForm. pdfimages is the second command; -list tells it to print a list of raster images inside the PDF. It returns no rows for this invoice, so the page was not saved as an image.
Add an SVG
Now, let's add a small SVG invoice mark.
html
<svg viewBox="0 0 66 66" aria-label="Invoice mark">
<defs>
<linearGradient id="mark-fill" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f1a48d" />
<stop offset="1" stop-color="#fff3ea" />
</linearGradient>
</defs>
<path
d="M9 3 H45 L57 15 V61 H9 Z"
fill="url(#mark-fill)"
stroke="#c84725"
/>
<path
d="M45 3 V15 H57 M19 30 H47 M19 39 H47 M19 48 H37"
fill="none"
stroke="#c84725"
/>
</svg>
I first read the two path values and drew them in the PDF. But then I saw that the mark was missing from the top-right corner.
The path data contains the shape, but not its final position in the invoice.
Turns out, an SVG path uses coordinates inside its own SVG. The path does not contain the SVG's position, its viewBox scale or its CSS transforms. The browser already applies all of those when it draws the page.
getScreenCTM() gives us the final transform the browser used:
javascript
{ a: 1, b: 0, c: 0, d: 1, e: 546, f: 78 }
With this, we can now apply the same transform in the PDF. The gradient becomes a PDF gradient, and the paths stay as paths.
pdfimages -list still returns no images. That is the only check needed here: it shows that the SVG was not turned into a bitmap.
Pagination moved the invoice
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. But then I saw that the invoice became wider and the mark moved with its right edge.
Changing the invoice's width also changes the layout inside it.
Turns out, the element used for pagination should not also be the document being rendered. Making the invoice wider changes every child positioned from its right edge.
We can fix that by adding a plain wrapper around the invoice and turning the wrapper into columns instead. The browser splits the wrapper, while the invoice keeps its original width.
With this, we can now map each browser column to one PDF page without changing the invoice itself.
What still has to be handled
The invoice proves that the browser gives us enough information to build the PDF. It does not do the whole conversion for us.
- It gives us text positions, but we still have to embed the font and write the PDF text.
- It gives us the final CSS values, but it does not give us one simple list of the order in which everything was drawn.
- It gives us form controls, but PDF fields cannot reproduce every CSS style.
- It lays out one long page, but we still have to split that layout into PDF pages.
Some things still need a fallback. Canvas content and outer box shadows become images. Fonts and images must also be readable by JavaScript; the browser may display a cross-origin file that the page is not allowed to fetch.
These limits are manageable when the document belongs to your application and you know which HTML and CSS it uses.
Back to the receipt
I started with one problem: I did not want to start Chromium on the server every time someone downloaded a receipt.
With this approach, the receipt or invoice is laid out once in the browser the user already has open. The PDF generator reads that DOM and writes the PDF in the same browser. There is no second browser process, and the serverless function does not need a Chromium binary.
If a server is still involved, it can provide the receipt data, check access, store the PDF or send it. It does not have to run the layout engine. The download can also happen entirely in the browser.
This does not replace Chromium when there is no browser page to read, when a job must run without a user, or when any webpage must be supported. It fits receipts, invoices, statements and other documents that are already rendered inside an application.
So, for this kind of document, I do not need Chromium on the server. The browser has already done the layout. I only need to read that result and rebuild it as a PDF.
I made this implementation into Garri, a browser library for generating native PDFs from rendered HTML. Check it out if you want to see the code or try it in your own application.
Preview the generated invoice PDF.
Happy Hacking!
Originally published on https://drreamer.digital/blog/generating-native-pdfs-from-the-browsers-resolved-layout







Top comments (0)