← Blog
Mayank

The logo that never rendered

Niblr invoices have carried a logo since the day templates shipped. No invoice ever showed one. Two silent failures, a library that logs and carries on, and the test that finally looked at the PDF.

For the whole time Niblr has had invoice templates, the renderer has drawn the store's logo at the top of every invoice — and no customer has ever received one with a logo on it. The code was right. The data was right. Two things in between failed quietly, and the library that noticed logged a line and kept going. This is how a feature can be shipped, tested, previewed and still not exist.

What was supposed to happen?

A merchant uploads a logo in their store settings. When an order is paid, Niblr builds a tax invoice as a PDF: supplier block, TRN, sequential number, line items, VAT. The supplier block starts with the logo. The renderer had one line for it — if there is a logo URL, draw the image — and that line was there from the first commit of the templates work.

There is also a preview. Pick a template in the admin, click Preview, and the same renderer produces a sample invoice from your real settings. That preview never showed a logo either. Nobody flagged it, because "no logo" looks exactly like "this merchant has not uploaded one."

So what went wrong?

I only found out because I was looking at the preview for another reason and thought: this should show the brand logo on top. I went to add the feature and found it already there. So the question became why the line that draws the image draws nothing, and the answer was two separate things.

The URL was relative. Our CMS stores media and hands back its address as /api/media/file/logo.svg — correct for a browser, which resolves it against the page it is on. The PDF library is not a browser. It runs on a server, sees a leading slash, and treats the string as a path on disk. There is no such file. It records "not found" and moves on to the next element.

The file was WebP. Every image a merchant uploads is converted to WebP on the way in, because that is the right format to serve to shoppers — smaller files, same picture. The PDF library decodes JPEG, PNG and SVG. Hand it WebP and it says "not a valid image" and, again, moves on.

So the fix that seemed obvious — make the URL absolute — would have lit up exactly one store I know of, whose logo happens to be an SVG and so escaped the conversion. Every merchant who uploaded a PNG through the admin and got a WebP back would have stayed blank. I would have looked at that one store, seen a logo, and called it done.

Why didn't anything catch it?

Because every layer did something reasonable, and nobody owned the outcome.

The CMS returned a URL that is correct for its main consumer. The upload pipeline converted to a format that is correct for its main consumer. The PDF library, when handed something it cannot draw, made the defensible choice not to crash a document over a decoration — it logged and continued. Three correct local decisions, and the result was an invoice with a hole in it that nothing reported.

And the tests? The component rendered an <Image>. The data carried a URL. A guard scans the source to make sure the document is built one way. Everything that could be checked about the code was green. What nobody checked was the PDF.

I wrote about this shape of bug before: a test written from the same understanding as the code will pass for the same wrong reasons. This was the same lesson from a different angle. The tests were not even wrong — they were checking the wrong artefact.

What changed?

The renderer no longer receives a URL. It receives bytes.

Before the PDF is built, a small function takes the stored URL, makes it absolute against the store's own address, fetches it with a five-second limit, and runs whatever comes back through an image library that outputs PNG. WebP, AVIF, GIF, JPEG, PNG — all become PNG. SVG is rasterised at print resolution rather than screen resolution, so it is crisp on paper. The renderer now has one format to draw and no decisions to make.

If any step fails — the file is gone, the host is slow, the bytes are not an image — the function returns nothing and the invoice issues without a logo. That is deliberate. A logo is decoration. An invoice is a legal document that a customer is waiting for. The decoration must never be the reason the document did not go out.

And the test opens the PDF. A PDF stores each drawn image as an object whose header says, in plain text, that it is an image. The test renders the real document twice — once without a logo, once with — and counts those objects. Zero, then one. That is a direct observation that something was drawn, not an inference from the tree that was supposed to draw it.

The first time I ran the whole thing against a live store's real logo, the count came back two, not one. I stared at that for a minute before working out that a rasterised SVG has transparency, and PDF stores transparency as a second image. The test with an opaque fixture was right; the world had an extra channel. Worth knowing before someone "fixes" the test to say two.

What I'd take from it

Test the artefact, not the intent. If the deliverable is a PDF, open the PDF. If it is a rendered page, read the page. A test that asserts the code asked for the right thing cannot tell you whether the thing happened.

Silent by design is still silent. Every library that logs-and-continues is making a choice on your behalf about what matters. That is usually the right choice for the library and sometimes the wrong one for you. Know which calls have that behaviour, and put your own check after them.

"Nobody has complained" is not evidence. No logo on an invoice looks like no logo was uploaded. Absence is the hardest failure to notice because it does not look like a failure. The only reason this got found is that someone looked at the output and asked for something that was already there.

The fix is live. The next paid order on any store with a logo will be the first Niblr invoice to carry one. If you self-host, it is in the public build from the next release.