Skip to main content
Back to blog
jsPDFPDFUnicodeCrasterDoc

PDFs with Unicode fonts in jsPDF: logic symbols, accents, and a plan B

5 min read

Generating a PDF in the browser with jsPDF is easy until the text stops being plain Spanish or English. CrasterDoc exports academic work, and a propositional-logic assignment carries symbols such as ∧, ∨, ¬, →, ↔ and subscripts such as Pₐ. With the default jsPDF fonts (Helvetica, Times, Courier) those characters do not render: the accent does, the logic symbol does not.

This article covers the problem and the solution in src/lib/fuentes-pdf.ts and src/lib/formula.ts of the repository.

Why the base fonts fail

The standard PDF fonts use the WinAnsi encoding: a bit over 200 characters, enough for Spanish and English, but with no arrows, logic operators, or subscripts. jsPDF cannot measure or draw what the font does not have, and the result is clipped text or garbage. The way out is to embed a TrueType font that does include those glyphs.

Embedding DejaVu Serif

CrasterDoc uses DejaVu Serif, freely licensed (the license ships in public/fonts/), in all four styles: regular, bold, italic, and bold italic. The TTF files live in public/fonts/ and are loaded only when exporting, lazily and once per session:

const ARCHIVOS: Record<EstiloFuentePdf, string> = {
  normal: 'DejaVuSerif.ttf',
  bold: 'DejaVuSerif-Bold.ttf',
  italic: 'DejaVuSerif-Italic.ttf',
  bolditalic: 'DejaVuSerif-BoldItalic.ttf',
}

let cargando: Promise<FuentesUnicodePdf | null> | null = null

export function cargarFuentesUnicodePdf() {
  if (!cargando) {
    cargando = (async () => {
      const estilos = Object.keys(ARCHIVOS) as EstiloFuentePdf[]
      const datos = await Promise.all(estilos.map((e) => leerFuente(ARCHIVOS[e])))
      return Object.fromEntries(estilos.map((e, i) => [e, bytesABase64(datos[i])]))
    })().catch(() => {
      cargando = null // on failure, a retry is possible
      return null
    })
  }
  return cargando
}

Two details cost me time:

Knowing which glyphs the font has

Embedding the font is not enough: not even DejaVu has every Unicode character. If the document contains a symbol the font cannot draw, you need to know before writing it. registrarFuentesUnicodePdf registers the four styles and also returns a glyph detector that queries the font’s internal table:

pdf.addFileToVFS(ARCHIVOS[estilo], fuentes[estilo])
pdf.addFont(ARCHIVOS[estilo], FAMILIA_UNICODE_PDF, estilo)
// …
const metadata = (pdf.getFont() as unknown as {
  metadata?: { characterToGlyph?: (codigo: number) => number }
}).metadata

return (ch: string) => {
  const codigo = ch.codePointAt(0) ?? 0
  return codigo > 0 && metadata.characterToGlyph!(codigo) > 0
}

A characterToGlyph above zero means “the font has this character”. The detector caches results in a Map so a long document does not query the same symbol a thousand times.

Downgrading only what is missing

With the detector, textoParaPdf decides character by character. If the font has it, it stays as is. If not, it tries three exits in order:

s = s.replace(/[^\t\n\r -~ -ÿ]/gu, (ch) => {
  if (tieneGlifo(ch)) return ch // the font draws it: leave it alone
  const sustituto = SUSTITUTO_UNICODE[ch] // e.g. ∅ → Ø
  if (sustituto && [...sustituto].every(tieneGlifo)) return sustituto
  const ascii = ASCII_PDF[ch] // e.g. ∧ → " /\ ", → → " -> "
  if (ascii) return ascii
  const n = ch.normalize('NFKD').replace(/[̀-ͯ]/g, '')
  return n && [...n].every(tieneGlifo) ? n : ' '
})

And if the Unicode font could not be loaded (offline, blocked file), the detector is null and the ASCII branch is used for everything: ∧ becomes /\, ↔ becomes <->, subscripts become _a. The PDF comes out worse, but it comes out. And the export says so instead of staying quiet: “The font with logic symbols could not be loaded: the PDF uses Times…”.

Subscripts and exponents

Unicode subscripts (ₐ ₑ …) do not exist for every letter: Pₐ has a glyph, P_g does not. If the PDF mixed both, the document would look inconsistent. partirSubindices treats both cases the same: it splits the text into “normal”, “subscript”, and “superscript” runs, and the PDF and Word exports draw them uniformly. So Pₐ and P_g both come out as “P” plus a subscript, and 2^n comes out as “2” plus a superscript, with no raw ^ on the page.

Emoji: warn instead of losing silently

Emoji are not in DejaVu Serif either. Instead of dropping them without a word, the generator detects those with no glyph and records a warning for the user (“Some emoji cannot be shown in the PDF and were omitted; Word keeps them”). It is the same rule the product follows in general: nothing is lost without the person knowing.

What I take away

  1. An embedded font is heavy, so it loads lazily, only when exporting, and only in the mode that needs it (academic with a serif font).
  2. Ask the font, do not guess. A four-line glyph detector saves you from maintaining a hand-written list of which symbols work.
  3. Every plan A needs an explicit plan B. The ASCII fallback exists, is documented, and warns.

You can see the result in any CrasterDoc PDF with propositional logic. Try it free at crasterdoc.morelodev.com, learn more on the product page, or read How it was built.