It started with a feature that sounded easy
While I was working on Miya Travel, our travel CRM, one of my colleagues was building a module where users had to download a PDF. On paper this is one of the simplest requirements you can get: there is already a page on screen, so just let the user save it as a PDF.
It did not stay simple for long.
Attempt one: take a screenshot of the browser
The first approach was the one most people reach for. Convert the HTML to a canvas with html2canvas, which is basically a screenshot of the element, and put that image into a PDF with jsPDF.
It worked on one screen. On a different screen it was a mess.
The output depended on the size of the window it was generated in, so a laptop, a bigger monitor and a phone all produced different PDFs. The text was no longer text either, just pixels in a picture. You could not select it or search it, and it got blurry when you zoomed in or printed it.
Attempt two: the pipeline we built in Helios
So we did what most teams do: keep the screenshot idea and write code around it to fix each problem. In our Saber codebase, the Helios app has a use-pdf-export hook that does exactly this. It still uses html2canvas-pro and jsPDF, and then a few hundred lines of our own code sit around them:
- wait for fonts and every image to finish loading
- download each image again and turn it into a base64 string, to get around cross-origin problems
- force the light theme on a cloned copy, so a user in dark mode does not get a dark PDF
- remove anything marked
data-pdf-hide, such as buttons - cap the canvas scale so a long page does not exceed the browser's maximum canvas size
- work out the page breaks ourselves, by walking the DOM, measuring every block, and searching for a position where a page can end
- crop the big canvas into one JPEG per page and add each one to the PDF
- put the clickable links back by hand, because an image has no links
Page breaking was the hardest part. The slicing code has to decide whether a block may be split or not, how much of it must stay on the first page, and how little can be carried over to the next. It needed tuning constants and a data-pdf-avoid-break attribute that we had to remember to add to cards that must never be cut. Even then, the result was still a set of JPEG pictures of the page.
All of that, in about 470 lines across four files, for "download this page as a PDF".
The real cost was the glue code
By this point the feature worked, but look at what it took:
- two packages that had to be used together
- a lot of our own code to connect them and patch their gaps
- special handling for screen sizes, themes and cross-origin images
- special handling for page breaks, with manual markers
- a setup that nobody on the team really wanted to touch
Every new document meant fighting the same problems again.
The question that changed things
That was when I asked myself: why can't we just create our own package, built for exactly what we need, with almost zero setup code?
I wrote down what I wanted from it:
- One line to use it. Pass an element, get a PDF.
- The PDF looks the same no matter what screen it was generated on.
- Text stays real text, so it is selectable, searchable and sharp when zoomed.
- A page never ends halfway through a line or a table row.
- No server, no headless browser, no screenshots.
That became @kaisarsofi/html-to-pdf.
Using it
import { downloadPdf } from '@kaisarsofi/html-to-pdf'; await downloadPdf('#invoice', 'invoice.pdf');
That is the whole integration. You pass an element or a CSS selector, and everything else is optional. In React it is just a click handler:
const ref = useRef<HTMLDivElement>(null); <button onClick={() => downloadPdf(ref.current!, 'report.pdf')}> Download </button>
How it works: measure first, draw second
The old approaches either take a picture of the page or hand the layout to a print engine you cannot control. This library does neither. It asks the browser where everything already is, and then draws exactly that into a real PDF.
- Render. The element is copied into a hidden frame laid out at a desktop width in the light theme. This is why the PDF looks the same on a laptop, a phone, or in dark mode.
- Extract. Every visible piece, such as text, backgrounds, borders, images and links, is read with its real position and size straight from the browser's own layout.
- Break. Page breaks are chosen from those measurements, so a page only ends where no line, row, image or card is crossing it.
- Write. Everything is drawn into a real PDF with
pdf-lib. Text stays text.
The part I like most is step 3. The old tools guessed where to cut. This one looks first.
There is also a continuous mode that makes a single page exactly as tall as the document, for things that are only ever read on a screen.
Before and after in Helios
Once the package existed, I added it to Helios next to the old hook. The new hook is a short function. It loads the package and makes one call:
const { downloadPdf } = await import('@kaisarsofi/html-to-pdf'); await downloadPdf(element, `${documentTitle}.pdf`, { page: { mode: 'continuous' }, });
The font waiting, image inlining, theme forcing, page slicing and link handling that used to be our code now live inside the package, where they are tested once and reused everywhere. The PDF is real text and vector shapes instead of JPEG slices, so it stays sharp and searchable, and the page breaks come from measured layout instead of hand-tuned constants.
Honest limits
It only runs in the browser, because it works by measuring a real layout, so there is no Node version. A few CSS features, like inset shadows and backdrop-filter, are skipped, and radial and conic gradients are approximated. Anything that could only be approximated, or that changed the output, is reported back to you in notes and warnings, so you get a PDF and an honest account of what differs.
What I took from this
Looking back, the lesson was not really about PDFs. When you find yourself stacking packages and writing glue code to make them agree with each other, that is usually a sign to stop and ask what you actually need. Sometimes the smallest solution is a small, focused tool of your own, built for your requirements, with the setup hidden away so the next person only writes one line.
If you need PDFs from your own pages, you can try it:
npm install @kaisarsofi/html-to-pdf
The code is on GitHub and the package is on npm. If you hit a bug or want a feature, I would love to hear about it.