Almost every browser-based “export to PDF” works the same way: render the document to a canvas with html2canvas, hand the resulting bitmap to jsPDF, one image per page. It is a reasonable engineering choice — it is pixel-accurate, it inherits every CSS feature the browser supports for free, and it is about thirty lines of code.
It also produces a picture of a document:
- Nothing is selectable, copyable or searchable. Ctrl+F finds nothing.
- A screen reader opens it and finds an empty file. This is a hard blocker in government and education procurement.
- Links are painted. They look like links and do nothing.
- A page of plain text costs a few hundred kilobytes instead of a few.
- Zooming or printing shows resampling artefacts, because it is a photograph of text.
The alternative is to write the PDF file format directly. That sounds like a much larger undertaking than it is, for one reason: the hard part of a PDF generator is font embedding and subsetting, and you can skip it entirely.
The 14 standard fonts
Every conforming PDF reader ships Helvetica, Times and Courier in four styles each, plus Symbol and ZapfDingbats. Reference one and you embed nothing: no font parsing, no glyph subsetting, no CMap tables, no licensing question. A text-only page lands around 11 KB.
The file itself is a small object graph and a content stream. A minimal page is genuinely this short:
BT % begin text
/F0 11 Tf % font F0 at 11pt
0 0 0 rg % fill colour black
72 720 Td % position (origin is BOTTOM-left)
(Hello) Tj % show string
ET % end textPlus an xref table of byte offsets and a trailer. That is the whole format for text. The rest of this article is the four things that are not obvious.
Trap 1: you must write a /Widths array
This is the one that silently ruins the output, and it took us a while to see it.
You wrap lines yourself — measuring each word, deciding where the line breaks. The readerlays out the glyphs using the font’s own metrics. If your numbers and its numbers disagree, every line drifts, and long lines overrun the right margin. The document looks broken in a way that is hard to attribute.
The usual fix is shipping the AFM width tables for the base-14 fonts. That is roughly 1,500 lines of data for the faces you need. There is a much better option: PDF lets you override the widths. Write a /Widths array into the font object and the reader is required to honour it, so layout and rendering use the same numbers by construction:
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica
/Encoding /WinAnsiEncoding
/FirstChar 32 /LastChar 255
/Widths [278 278 355 556 556 889 667 191 333 333 ...] >>Which raises the question of where to get the widths without shipping the tables. Measure them:
const g = document.createElement("canvas").getContext("2d");
g.font = "100px Arial, Helvetica, sans-serif";
const width = Math.round(g.measureText(ch).width * 10); // 1000 units per emThis is exact, not approximate, and the reason is a piece of typographic history: Arial was designed to be metrically compatible with Helvetica, Times New Roman with Times, Courier New with Courier. Substituting one for the other must not reflow a document, so their advance widths match by design.
We checked the numbers this produces against the published Helvetica AFM table. They are identical — 278, 278, 355, 556, 556, 889, 667, 191, and on down. Roughly 1,500 lines of data replaced by six lines of measurement, with the correctness guaranteed by the same property that makes the fonts substitutable in the first place.
Trap 2: assemble the file as bytes, not as a string
String concatenation is the obvious way to build a text format, and it works perfectly until the first image.
A PDF is not a text format. /Length on every stream is a byte count, and the xref table is a list of byte offsets. The moment a stream holds binary — an embedded JPEG — a JavaScript string stops being a faithful representation, and every offset after that point is wrong. The file will not open, and the error message will point at the trailer rather than at the image.
const chunks = [];
let length = 0; // byte count, not string length
function push(part) {
const bytes = typeof part === "string" ? latin1Bytes(part) : part;
chunks.push(bytes);
length += bytes.length;
return length; // offsets recorded from here
}Build in Uint8Array from the start. It costs nothing on day one and saves a genuinely nasty debugging session later.
Trap 3: one text operation per word loses your spaces
Wrapping naturally leaves you holding words. It is tempting to emit each one where it belongs, since you already know the position:
BT /F0 11 Tf 36 700 Td (Ordinary) Tj ET
BT /F0 11 Tf 82 700 Td (paragraph) Tj ET
BT /F0 11 Tf 131 700 Td (with) Tj ETThis renders correctly. It is also wrong twice over. The content stream balloons — we measured 26 operations collapsing to 5 on a short document — and, more importantly, text extractors that infer word boundaries from separate show-operations can drop the spaces between them. The user copies a paragraph out of your PDF and gets onelongrunofwords.
Merge adjacent runs that share a font, size and colour back into one operation, spaces included. Only start a new one when something actually changes:
BT /F0 11 Tf 36 700 Td (Ordinary paragraph with ) Tj ET
BT /F1 11 Tf 157 700 Td (bold) Tj ETTrap 4: encoding, and what to do with what you cannot encode
WinAnsiEncoding covers Latin-1 plus the printable CP1252 range. That is one byte per character and it covers Western European text. It does not cover Greek, Cyrillic, CJK, or most of the punctuation a modern editor produces.
Curly quotes, em dashes and ellipses are common enough that they deserve mapping rather than replacing: U+2019 to 146, U+2014 to 151, U+2026 to 133. Non-breaking spaces become spaces. A minus sign becomes a hyphen. Everything else becomes ?, visibly.
Visibly is the point. A character that silently disappears is a worse outcome than one that shows as a question mark, because the second is a bug report and the first is a document quietly missing a word. If you need full Unicode you need embedded fonts with a CMap, and you have re-entered the territory the base-14 fonts let you skip — a reasonable trade to make deliberately, not by accident.
Links, and the things that are nearly free
A real link is an annotation on the page, not a decoration in the content stream:
<< /Type /Annot /Subtype /Link
/Rect [293.38 690.2 331.27 703.2]
/Border [0 0 0]
/A << /S /URI /URI (https://example.com/docs) >> >>You already know the rectangle: you positioned the text. A few more things fall out of the same information almost for free — document metadata so the title is searchable, /Langso a screen reader picks the right voice, and page geometry taken from the document’s own page setup so the PDF matches what page view showed.
Pagination is worth a little more care than it first appears. Producing a flat list of positioned operations beforedeciding page breaks is what makes “keep this heading with the paragraph under it” and “repeat the table header row on the continuation page” possible at all. Interleaving measurement and pagination forces every decision to be final the moment you make it.
Verify with a real parser, not with regexes
It is easy to write assertions against the bytes you just produced — check that %PDF-1.4 is at the front, that Tj appears, that there is an xref. We did, and they all passed on a file we had not yet proven any reader could open.
Those assertions test that you wrote what you intended to write. They say nothing about whether it is a valid PDF. Run it through pdf.js — the parser Firefox ships — and assert on what a consumer actually sees:
const doc = await pdfjs.getDocument({ data }).promise;
const text = (await (await doc.getPage(1)).getTextContent())
.items.map(i => i.str).join("");
assert(text.includes("Ordinary paragraph with bold, italic")); // spaces intact
assert((await doc.getPage(1).then(p => p.getAnnotations()))
.some(a => a.url === "https://example.com/docs")); // real link
// And the check that catches width bugs:
const overflow = items.filter(i => i.transform[4] + i.width > pageWidth - margin);
assert(overflow.length === 0);That last one is the highest-value assertion in the suite. It is the automated version of the failure the /Widths array exists to prevent, and it will fail loudly the day someone changes the measurement code.
One gotcha while writing these tests: pdf.js detaches the typed array you hand it. Any byte-level assertion after getDocument() sees a zero-length buffer. Re-read the file, or keep a copy.
When to still use the raster path
None of this makes canvas rendering wrong. If the deliverable is a pixel-exact reproduction of a heavily-styled layout — CSS grid, filters, custom fonts, gradients — then a bitmap is the honest answer, and a hand-written generator will not match it without reimplementing a browser.
Ship both. Raster when the layout is the point; vector when the text is the point, which for contracts, reports, invoices and anything with an accessibility requirement is nearly always.
The second one is not the hard version. It is about seven hundred lines, it needs no dependency, and it turns an export that fails an accessibility audit into one that passes.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.