Two PDFs can look identical on screen and be completely different files. In one you can select a line, search for an invoice number and print it sharp at 300 dpi. The other is a photograph of a page: nothing to select, nothing to find, and every zoom step makes the text softer. Both came from HTML. Both came from something calling itself an HTML to PDF converter. The difference is what happened in the last few milliseconds of the render.
The full write-up, with the file internals and the font-embedding detail, is on the HTML to Image blog as How HTML to PDF conversion actually works. This is the short version: the pipeline, what "selectable text" really means, where fonts go, and three commands that tell you in seconds which kind of PDF you have been handed.
Same pipeline, different last step
A browser-based converter, whether that is headless Chrome on your own box, a Puppeteer script or a hosted API, runs exactly the pipeline it uses to put a page on your monitor. Parse HTML into a DOM. Resolve CSS into computed styles. Lay out every box in CSS pixels and break text into lines. Paint the result into a display list: fill this rect, stroke this border, draw these glyphs here.
The fork is stage five, when that display list is replayed into a backend. A raster backend replays it into pixels and writes a PNG. Chromium's PDF backend, part of the Skia graphics library, replays the same commands into PDF operators and writes a document. The commands are identical. What differs is what the backend is allowed to keep: text stays text, paths stay paths, fonts get subset and embedded, and only <img> and <canvas> content is stored as pixels.
That is also why a Chrome PDF matches a Chrome PNG so closely, and why the two most common failures, missing fonts and rasterised pages, both happen at stage five.
Pixels in, points out
CSS measures in pixels at 96 per inch. PDF measures in points at 72 per inch. So every CSS pixel becomes 0.75 pt, and an A4 page is 794 by 1123 CSS pixels in layout and roughly 595 by 842 pt in the file. You can see the arithmetic in pdfinfo:
$ pdfinfo invoice.pdf
Creator: Chromium
Producer: Skia/PDF m131
Tagged: yes
Pages: 1
Page size: 595.92 x 841.92 pts (A4)
File size: 69467 bytes
The unit change explains a rule that surprises people: DPI does nothing to a PDF. Raising the device pixel ratio makes a screenshot sharper because it adds pixels. A vector page has no pixels to add, so a 1x render and a 4x render of the same HTML produce identical vector content.
What a text layer actually is
People say a PDF "has a text layer" as if it were a transparent sheet over an image. That is how OCR repairs a scan, and it is where the phrase comes from, but a properly generated PDF has no such thing. The text is not a layer on the page. The text is the page.
Inside a page's content stream you find text objects: select a font, set a position, draw a run of glyph IDs. Those IDs are indices into the embedded font, and on their own they mean nothing to a search box. What makes the text selectable is a second structure, the ToUnicode CMap, which maps each glyph ID back to the character it was drawn from. If it is missing, the text renders and scales perfectly but cannot be copied, and pdftotext returns garbage.
Run it on the invoice and every word comes back in reading order, including a word drawn with an inline <svg> <text> element, because Skia turns SVG text into the same text objects as HTML text:
$ pdftotext -layout invoice.pdf -
Northgate Coffee, invoice #1042
Issued 3 September 2026. Payment due within 30 days.
ITEM QTY PRICE
Ethiopian Yirgacheffe, 1 kg 4 $72.00
Filter papers, box of 100 2 $9.50
Total $81.50
How fonts get inside the file
A PDF that names a font without carrying it is at the mercy of whichever machine opens it, and the line lengths shift with every substitution. So the backend embeds fonts, and to keep files small it subsets them: only the glyphs actually drawn go in. The six-letter prefix on the name is the subset tag.
$ pdffonts invoice.pdf
name type emb sub uni
--------------------------------- ------------- --- --- ---
AAAAAA+ManropeExtraLight_800wght Type 3 yes yes yes
BAAAAA+ManropeExtraLight_500wght Type 3 yes yes yes
FAAAAA+JetBrainsMono-Medium CID TrueType yes yes yes
Read the three right-hand columns. emb is whether the font data is in the file, sub whether it was subset, uni whether a ToUnicode map exists. You want yes yes yes on every row.
The type column has a story in it. Both faces came from Google Fonts. JetBrains Mono was served as a static file and went in as CID TrueType, the original outlines, the format every PDF tool likes. Manrope was served as a variable font, and Chrome embedded each instance as Type 3, where every glyph is its own little drawing procedure. Still vector, still selectable, and every mainstream viewer handles it, but PDF/A validators and some accessibility checkers are unhappy about Type 3. If your documents have to pass those, serve static font files instead of variable ones. Nothing else changes.
Vector where it can, raster where it must
Text, borders, gradients, shadows and inline SVG all go in as geometry. <img> and <canvas> go in as pixels at whatever resolution they arrived, which pdfimages -list shows you:
$ pdfimages -list invoice.pdf
page num type width height size ratio
1 0 image 64 64 98B 0.8%
One small logo, 98 bytes. That is what a healthy document's image list looks like. If you are printing, supply bitmaps at two or three times their CSS size, or supply them as SVG and let them go in as paths.
The screenshot in a wrapper
Now the file from the first paragraph. It comes from tools with no PDF backend at all: they screenshot the page and write the bitmap into a PDF container, one image per page. The three commands expose it instantly.
$ pdffonts wrapped.pdf
name type encoding emb sub uni
------ ------ ---------- --- --- ---
$ pdfimages -list wrapped.pdf
page num type width height size ratio
1 0 image 1654 2339 1.9M 38%
$ pdftotext wrapped.pdf -
$
No fonts, because there is no text. One page-sized image per page. Nothing from pdftotext. The file is often ten to thirty times larger, and it gets worse exactly where PDFs matter, which is printing: 1654 by 2339 is 200 ppi on A4, and body text goes soft on a laser printer.
So "can I select the text" is the single best acceptance test for a converter. Not "does it look right", which every tool passes.
The three-command check
All three ship with Poppler (apt install poppler-utils, brew install poppler):
pdffonts output.pdf # every row: emb yes, sub yes, uni yes
pdfimages -list output.pdf # one entry per real picture, no page-sized bitmaps
pdftotext output.pdf - | head # your content, in reading order
Empty pdffonts means a screenshot in a wrapper. emb: no means the document looks different on every machine. uni: no means text that renders but cannot be searched. A page-sized image in pdfimages means the page was rasterised.
If you want to try it on a real file, the HTML to PDF converter returns an A4 PDF from the same Skia backend described above, and from code it is one field on an image request to the HTML to PDF API: add "format": "pdf" and the response url points at a .pdf. The longer version on the blog covers screen versus print CSS, webfont timing, pagination and what actually drives file size: How HTML to PDF conversion actually works.
What is the worst PDF you have been handed by a converter, and did pdffonts come back empty? Share it in the comments.


Top comments (0)