Why don’t the images show up in my HTML-to-PDF file?
The text came across. The headings are in the right order, the tables are readable, and everywhere a photograph, a chart or a logo should be there is a gap — or nothing at all, not even a gap.
The same HTML opens perfectly in a browser on the same machine. Nothing in the output says an image failed. The conversion returned success.
That last part is why this is annoying to diagnose rather than hard to fix. In almost every HTML-to-PDF engine a subresource that will not load is a warning, not an error: the page renders, the process exits zero, and you are handed a PDF quietly missing half its content. There is no exception to search for. So you have to go and ask.
An image is a second request, made by somebody else
When you hand an engine some HTML, it takes one thing: the markup. Every <img> in that markup is
an instruction to go and fetch a different file, and that second fetch is made by the rendering
process — on whatever machine it runs on, as whatever user it runs as, with whatever network it can
see, and with whatever cookies it holds, which is usually none.
Your browser had all of that. It had the page's own address to resolve relative paths against, a session, a disk cache with the image already in it, and a user scrolling around triggering lazy loads. The renderer has almost none of it, and every missing piece is a way for that second request to end up somewhere other than the image.
There are six of them, and they leave different fingerprints.
The URL has nothing to resolve against. This is the most common by far, and it is specific to
one thing you probably did: you passed the HTML in as a string. <img src="/assets/logo.png"> is
not an address, it is half of one. A browser completes it using the address of the page it came
from. A string has no address, so there is nothing to complete it with, and the fetch either never
happens or goes to some default that does not exist. Every SDK has a knob for this and every SDK
calls it something different — base URI, base path, base URL, document URI. In Puppeteer,
page.setContent() takes no base at all, so you inject a <base href="https://example.com/"> into
the head yourself or you load a real URL with page.goto() instead. The fingerprint: all images
missing, no exceptions, on a page whose HTML you built or read from disk.
The engine is refusing to touch local files. If the images live on the same disk and the markup
references them as relative paths or file://, you are asking a rendering engine to read the local
filesystem on behalf of a document — which is exactly the thing browsers spent twenty years locking
down. wkhtmltopdf 0.12.6 turned this off by default and added --enable-local-file-access to turn
it back on; a mountain of "it worked before we upgraded" threads are this and only this. Chromium
blocks local subresources for a page that was not itself loaded from file://. The fingerprint:
works when you open the file by double-clicking it, fails in the pipeline, no network involved
anywhere.
It printed before the image finished arriving. Producing a PDF is a snapshot at one instant, and
you chose the instant. waitUntil: 'load' fires when the document's own resources report done,
which is not the same as decoded and painted. Worse, anything lazy-loaded is waiting for an event
that never comes: loading="lazy" and every IntersectionObserver-based loader trigger on the image
approaching the viewport, and in a headless render nobody scrolls. The fingerprint is the giveaway —
the first screenful of images is present and everything below the fold is missing. The fixes are to
wait properly (networkidle0, or await every img.decode()), to give the page a viewport tall
enough that everything is "visible", or to scroll it programmatically before you print.
They are CSS backgrounds, and backgrounds are not printed. A PDF is generated through the print
path, and printing has always defaulted to leaving background graphics out — that default exists so
you do not empty a toner cartridge on a dark hero image. So background-image disappears while
<img> survives. Chrome and Puppeteer need printBackground: true; wkhtmltopdf needs
--background; the CSS side is print-color-adjust: exact (still -webkit-print-color-adjust in
older engines). The fingerprint: some images are there and some are not, and the missing ones turn
out to be decoration, icons or a hero banner.
The image host said no. The renderer is an anonymous client with a strange user agent coming
from a datacentre address. Hotlink protection blocks it. A bot check blocks it. An image behind the
same login as the page returns HTML, not a JPEG. A self-signed or expired certificate on an internal
host makes the engine drop the request where a browser would have shown you a click-through. And in
a container, http://localhost:3000/logo.png means the container, not your laptop. The fingerprint:
it is exactly the images from one host that are gone.
The engine cannot decode the format. Older layout engines predate the format your build pipeline
now emits. wkhtmltopdf's Qt WebKit has no WebP and no AVIF; some engines do nothing useful with an
inline <svg> or ignore srcset and pick nothing from a <picture>. The fingerprint: one file
type is missing across the whole document, and the same image works as a PNG.
Making the failure visible
Do this before you change any code, because it turns a guess into a fact.
- Turn the warnings into errors. The engine already knows the fetch failed; it has just been
told not to care. In Puppeteer,
page.on('requestfailed', ...)prints every dropped subresource with the reason. wkhtmltopdf splits page failures from media failures —--load-error-handlingcovers the document,--load-media-error-handlingcovers the images, and the media one is the forgiving one, so set both toabortwhile you debug. Most SDKs expose a resource-loading callback for the same purpose. One run usually ends the investigation. - Fetch one image the way the renderer would.
curl -Ithe exact URL from inside the container or on the render host, with no cookies. If that is a 403, a redirect to a login page or a DNS failure, you are done — the problem is not the converter. - Look at the URL as it appears in the markup you actually passed in. If it starts with
/or with nothing, and you passed HTML as a string, that is the whole bug. - Split the missing images into
<img>and CSS background. These are two different mechanisms with two different fixes, and a page usually has both. - Check what did land. Open the output in our PDF inspector — it runs in the browser and uploads nothing — and read the character count. If the text is all there, this is an image problem. If the character count is near zero as well, the images were never the story: the page builds itself with JavaScript and the renderer received an empty shell.
What our converter does here, and what it will not
Worth saying plainly, because you may have arrived from a search and be about to lose ten minutes looking for a setting. Our HTML to PDF is not a browser and does not pretend to be one. It reads the markup and lays out the document underneath it — headings, paragraphs, lists, quotes, preformatted text, table rows, and the address behind every link — on real pages, in one of four fonts. It makes no second request for anything. No CSS, no JavaScript, no images.
That makes it the wrong tool for reproducing a designed page and a good one for the opposite job:
getting an article, a changelog or a documentation page out of a site and into something readable,
with no navigation, no cookie banner and no ads. If your source is a file rather than a live page —
a README, a CHANGELOG.md — Word to PDF takes the same route with Markdown, and
what survives that trip is the same list minus the images. The same
converter refuses a .docx outright rather than half-rendering it, which is
a deliberate decision worth understanding before you try it.
The workaround when two or three pictures genuinely matter
For a one-off — a specification with four diagrams you need to send someone — this takes about five minutes and is more reliable than fighting a pipeline you will use once.
- Convert the page for its text. Paste the address or upload the
.htmlfile, and read the result as a document rather than a screenshot of one. - Screenshot each picture that carries meaning. Save as PNG, not JPEG — a screenshot is text and line art, and JPEG smears both.
- Turn the screenshots into pages. JPG to PDF puts one image on each page,
in the order you arrange them, and embeds a PNG losslessly so the labels stay sharp. Number the
files
01,02,03first or your file picker will hand them over with 10 before 2. - Join the two documents. Merge PDF keeps a bookmark for each file it takes in, so the diagrams end up as a findable section rather than a heap at the back.
When something else is simply the right tool
If the images are the point, use an engine that has a real renderer behind it. All of these are free.
- Your own browser's Print to PDF. Ctrl+P, then Save as PDF. It has the full engine, your session, your cookies and the images already in cache, and it is behind none of the six failures above. For one page, once, stop reading here and do this.
chromium --headless=new --print-to-pdf=out.pdf https://example.com/pageis the same engine from the command line. Drive it through Puppeteer or the DevTools protocol instead of the bare flag when you needprintBackground: true, a wait condition, or an authenticated session.- WeasyPrint is a real print-CSS engine rather than a browser. You hand it a base URL explicitly, it has a URL-fetcher hook so you can serve local files or attach auth headers, and it logs every image it failed to load instead of swallowing it. That last property alone makes it a good debugging instrument even if you ship something else.
- wkhtmltopdf still works and is still everywhere, but it is archived and its layout engine is
from another decade. If your missing images are WebP, or the layout is fighting you as well, it is
more likely the cause than the cure.
--enable-local-file-accessand--backgroundare the two flags to try before you conclude anything.
Whatever you use, run the check in step 1 once you have it working. An engine that renders a missing image as silence will do it again the next time a CDN rotates a hostname, and you will not find out from the exit code.