I run a free invoice generator, PDF Bill Builder. The PDF is built entirely in the visitor's browser, so nothing is uploaded unless they sign in to save a document. Three problems took longer than I expected. Here is how each one ended up.
1. Render the preview, then slice it
The design lives in one React component: the live preview. To keep the download identical to the preview, I capture that same DOM with html2canvas-pro and place the image into a PDF with jsPDF. The preview has a fixed width (794px for A4, 816px for US Letter), which makes the pixel to millimetre conversion a single division.
const pxToMm = pageW / el.offsetWidth;
const contentHpx = contentHeightMm / pxToMm;
The trade-off, stated plainly: the result is an image inside a PDF. The text is not selectable. For invoices that people send and print this is usually fine, but if you need selectable text or accessibility, use a text-based PDF library instead. In return I get exact visual parity, any font, any currency symbol, and a logo that just works.
2. Never cut a row in half
A long invoice needs several pages. Slicing the canvas every pageHeight pixels cuts table rows through the middle. The fix is to let the preview mark which elements may not be split:
<tr data-pdf-block> ... </tr>
Before slicing, I collect the top and bottom y-position of every marked element, relative to the captured root:
root.querySelectorAll<HTMLElement>("[data-pdf-block]").forEach((el) => {
const r = el.getBoundingClientRect();
ys.add(Math.round(r.top - rootTop));
ys.add(Math.round(r.bottom - rootTop));
});
For each page I pick the largest candidate that is no further than one page height from the start, and no earlier than 35% of the page, so I never produce a nearly empty page. If a single block is taller than a page, I fall back to a hard cut. Page numbers are drawn on multi-page documents.
3. Money maths: one pure function
Tax added on top versus already included, a percentage or fixed discount, and 18 currencies all multiply the edge cases. I moved everything into one pure function, so the preview and the PDF read the same numbers and cannot disagree.
Two details mattered:
- Round every line to two decimals, then sum the rounded lines. That matches what the printed invoice shows.
-
toFixedis not safe for money:1.005.toFixed(2)gives"1.00". I round half up with a small epsilon instead.
export function round2(n: number): number {
if (!isFinite(n)) return 0;
const sign = n < 0 ? -1 : 1;
return sign * Math.round((Math.abs(n) + Number.EPSILON) * 100) / 100;
}
Tax "included" is informational only (the total stays the same), while tax "on top" adds to the total. The function is covered by unit tests, which is the cheapest insurance I found against sending a client a wrong total.
What I would tell my past self
- Treat the preview as the single source of truth for layout.
- Mark break-safe blocks in the markup instead of guessing in the slicer.
- Keep money maths pure and tested, away from React.
If you invoice clients, I would like to hear what is missing. The generator is free with no signup: https://www.pdfbillbuilder.com
Top comments (0)