← All posts

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.

  1. 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-handling covers the document, --load-media-error-handling covers the images, and the media one is the forgiving one, so set both to abort while you debug. Most SDKs expose a resource-loading callback for the same purpose. One run usually ends the investigation.
  2. Fetch one image the way the renderer would. curl -I the 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.
  3. 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.
  4. Split the missing images into <img> and CSS background. These are two different mechanisms with two different fixes, and a page usually has both.
  5. 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.mdWord 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.

  1. Convert the page for its text. Paste the address or upload the .html file, and read the result as a document rather than a screenshot of one.
  2. Screenshot each picture that carries meaning. Save as PNG, not JPEG — a screenshot is text and line art, and JPEG smears both.
  3. 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, 03 first or your file picker will hand them over with 10 before 2.
  4. 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.

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.