jsPDF's .html() is the first answer most people find for "HTML to PDF in JavaScript". It runs in the browser, needs no server and takes four lines. We ran it on an ordinary 40-row invoice (CSS grid header, a link, a euro total, a Polish city and one line of Japanese), with the default options, with tuned options and with an embedded font. Then we sent the same HTML to a real Chromium and compared the files.
| Approach | What came out |
|---|---|
| jsPDF, default snippet new jsPDF() + doc.html(el) | 5 pages, 135 KB. The invoice is drawn several times too large, so only the "Item" column fits: Qty and Price are cut off the right edge. |
| jsPDF, tuned pt, A4, width + windowWidth | 2 pages, 138 KB, layout right. But Inter became Helvetica, "Łódź" printed as "Aódz", the Japanese line came out as gqN¬0ª0Õ0£0¹, ✓ became an apostrophe, and the link isn't clickable. |
| jsPDF + embedded TTF addFileToVFS + matching @font-face | Latin text and ✓ right, but 428 KB, the Japanese line blank, still no clickable link. Without the matching CSS font, words overlapped. |
| Chromium via an API element HTML → your server → /v1/pdf | 65 KB, 2 pages, Inter kept, every character right (Japanese included), the link clickable, "1/2" page numbers, 0.46 s from click to download. |
Versions: jsPDF 4.2.1 and html2canvas 1.4.1, run in headless Chromium. Every PDF was checked with poppler (pdftotext, pdffonts, pdfinfo) and by counting link annotations, not by eye.
doc.html() doesn't print the page. It walks the element with html2canvas, which redraws it onto a fake canvas context that jsPDF turns into PDF drawing commands. Text does come out as real text (unlike html2pdf.js, which pastes a screenshot), but it is drawn with jsPDF's own fonts. Out of the box those are the 14 standard PDF fonts (Helvetica, Times, Courier...) in WinAnsi encoding, which covers Western European letters and nothing else. Any character outside that set is mapped to the wrong glyph, so "Ł" (U+0141) prints as "A" and Japanese becomes Latin-1 junk. Your CSS font is ignored unless you register a TTF with the same name.
Two smaller problems showed up in every jsPDF file. Extracted text has spaces in odd places ("Design work , sprint 1", "INV-2026 - 0142"), which hurts search and copy-paste. And poppler printed Syntax Error: Restoring state when no valid states to pop on each page: viewers tolerate it, but strict PDF tooling may not.
// The snippet most tutorials show (jsPDF 4.2.1 + html2canvas 1.4.1 from a CDN or npm)
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
doc.html(document.getElementById("invoice"), {
callback: doc => doc.save("invoice.pdf"),
});
Without width and windowWidth, .html() doesn't fit the element to the page. Our 760 px invoice came out several times too large: 5 pages of giant text, with the Qty and Price columns lost past the right edge. This is the source of most "jsPDF html() output is huge / cut off" questions.
// The tuned version: points, A4, scale the 760px element to the 555pt text width, page by text lines
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: "pt", format: "a4" });
doc.html(document.getElementById("invoice"), {
x: 20,
y: 20,
width: 555, // target width in the PDF, in pt
windowWidth: 760, // the CSS width the element is laid out at
autoPaging: "text",
callback: doc => doc.save("invoice.pdf"),
});
width and windowWidth together set the scale (555 pt of PDF for 760 CSS px), and autoPaging: "text" avoids cutting a line of text in half at the page break. The layout is now right: 2 pages, rows whole, the grid header in place. The font problems above all remain: Helvetica instead of Inter, "Aódz", the garbled Japanese line, and a link that is drawn blue and underlined but has no link annotation, so clicking it does nothing.
// In a <script type="module"> or an async function (top-level await).
// Unicode text needs a TTF in jsPDF's virtual file system AND the same font in the page's CSS,
// or html2canvas measures with one font while jsPDF draws another and the words collide.
const toBase64 = async url => {
const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
};
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: "pt", format: "a4" });
doc.addFileToVFS("DejaVuSans.ttf", await toBase64("/fonts/DejaVuSans.ttf"));
doc.addFont("DejaVuSans.ttf", "DejaVuSans", "normal");
doc.addFileToVFS("DejaVuSans-Bold.ttf", await toBase64("/fonts/DejaVuSans-Bold.ttf"));
doc.addFont("DejaVuSans-Bold.ttf", "DejaVuSans", "bold");
// In your CSS: @font-face { font-family: DejaVuSans; src: url(/fonts/DejaVuSans.ttf) } (+ the bold face),
// body { font-family: DejaVuSans }, and wait for document.fonts.ready before calling doc.html().
This is jsPDF's documented fix, and it half works. With DejaVu Sans registered for regular and bold, "Łódź", "Zoë" and ✓ came out right. The costs: the file grew from 138 KB to 428 KB because the two font programs are embedded uncompressed (about 145 KB and 138 KB, even after jsPDF's subsetting), the Japanese line was blank because DejaVu has no CJK glyphs (you'd need a separate, multi-megabyte CJK font per script), and the link still wasn't clickable. If you skip the matching @font-face, html2canvas measures the text in the browser's font while jsPDF draws DejaVu, and the words run into each other ("Billedto: ZoëMüller").
If your documents are Latin-only, never need clickable links, and you can live with Helvetica or an embedded TTF, the tuned jsPDF setup is fine and costs nothing. Anything else is easier with a real browser.
Chromium's own PDF printer already handles fonts (subset and embedded), every script, links, break-inside and page numbers. You keep the element you already have on screen: send its HTML and the page's CSS to your server, and let the server call the API so the key never reaches the browser.
// Browser: send the element's HTML plus the page's CSS to your server, download the PDF it returns.
async function downloadPdf(el, filename) {
const css = [...document.styleSheets]
.map(s => { try { return [...s.cssRules].map(r => r.cssText).join("\n"); } catch { return ""; } }) // skips cross-origin sheets
.join("\n");
const html = `<!doctype html><meta charset="utf-8"><base href="${location.href}"><style>${css}</style>${el.outerHTML}`;
const r = await fetch("/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html }),
});
if (!r.ok) throw new Error(await r.text());
const a = document.createElement("a");
a.href = URL.createObjectURL(await r.blob());
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
document.getElementById("download").onclick = () => downloadPdf(document.getElementById("invoice"), "invoice.pdf");
The server is one route. It forwards the HTML to /v1/pdf and streams the PDF back:
// server.mjs: npm i express, then SHOTMINT_KEY=your_key node server.mjs
import express from "express";
const app = express();
app.use(express.json({ limit: "2mb" }));
app.use(express.static("public")); // your page with the invoice and download.js
app.post("/pdf", async (req, res) => {
// In production, check the user's session here so this isn't an open PDF printer.
const r = await fetch("https://shotmint.creasework.com/v1/pdf", {
method: "POST",
headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ html: req.body.html, format: "A4", margin: "12mm", page_numbers: true }),
});
if (!r.ok) return res.status(502).send(await r.text());
res.set("Content-Type", "application/pdf");
res.send(Buffer.from(await r.arrayBuffer()));
});
app.listen(3001, () => console.log("http://localhost:3001"));
We ran both files as shown (Node 22, Express 5), clicked the button in headless Chromium and checked the download: a 65,459-byte A4 PDF with 2 pages, Inter and the Japanese line embedded as font subsets, "Łódź" and ✓ correct, the billing link clickable, "1/2" and "2/2" at the bottom of the pages, and 0 poppler errors. It cost 1 credit.
Things to know:
localhost images and fonts can't load. Use absolute public URLs or inline them as data URLs; the <base> tag fixes relative URLs on a public site.cssRules, so the snippet skips them. Add a <link rel="stylesheet" href="https://..."> to the HTML string for those instead.break-inside: avoid on rows and cards, break-before: page for sections (page break guide). Custom fonts: HTML to PDF with custom fonts.Related: HTML to PDF in Node.js, free HTML to PDF converter, API reference.
One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons