PDF con fuentes Unicode en jsPDF: símbolos lógicos, tildes y un plan B
Generar un PDF en el navegador con jsPDF es fácil hasta que el texto deja de ser solo español corriente. CrasterDoc exporta trabajos académicos, y una tarea de lógica proposicional trae símbolos como ∧, ∨, ¬, →, ↔ y subíndices como Pₐ. Con las fuentes que trae jsPDF por defecto (Helvetica, Times, Courier) esos caracteres no salen: la tilde sí, el símbolo lógico no.
Este artículo cuenta el problema y la solución que hay en src/lib/fuentes-pdf.ts y src/lib/formula.ts del repositorio.
Por qué fallan las fuentes base
Las fuentes estándar del PDF usan la codificación WinAnsi: poco más de 200 caracteres, suficientes para español e inglés, pero sin flechas, operadores lógicos ni subíndices. jsPDF no puede medir ni dibujar lo que la fuente no tiene, y el resultado es texto cortado o basura. La salida es incrustar una fuente TrueType que sí traiga esos glifos.
Incrustar DejaVu Serif
CrasterDoc usa DejaVu Serif, de licencia libre (la licencia viaja en public/fonts/), en sus cuatro estilos: normal, negrita, cursiva y negrita cursiva. Los archivos TTF viven en public/fonts/ y se cargan solo al exportar, con carga perezosa y una sola vez por sesión:
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 // si falla, se puede reintentar
return null
})
}
return cargando
}
Hay dos detalles que me costaron:
- La promesa se guarda, no el resultado. Si dos exportaciones arrancan a la vez comparten la misma descarga. Y si falla, se limpia la variable para que el siguiente intento vuelva a probar en vez de quedarse con un error cacheado.
- Base64 por bloques. jsPDF pide la fuente como base64 (
addFileToVFS). Convertir un archivo grande conString.fromCharCode(...bytes)de una sola vez revienta la pila de llamadas; el código lo hace en trozos de 32 KB.
Saber qué glifos tiene la fuente
Incrustar la fuente no basta: ni siquiera DejaVu tiene todos los caracteres Unicode. Si el documento trae un símbolo que la fuente no dibuja, hay que enterarse antes de escribirlo. registrarFuentesUnicodePdf registra los cuatro estilos y, de paso, devuelve un detector de glifos que consulta la tabla interna de la fuente:
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
}
Un characterToGlyph mayor que cero significa «la fuente tiene este carácter». El detector guarda los resultados en un Map para no consultar mil veces el mismo símbolo en un documento largo.
Degradar solo lo que falta
Con el detector, la función textoParaPdf decide carácter por carácter. Si la fuente lo tiene, se queda tal cual. Si no, intenta tres salidas en este orden:
s = s.replace(/[^\t\n\r -~ -ÿ]/gu, (ch) => {
if (tieneGlifo(ch)) return ch // la fuente lo dibuja: no se toca
const sustituto = SUSTITUTO_UNICODE[ch] // p. ej. ∅ → Ø
if (sustituto && [...sustituto].every(tieneGlifo)) return sustituto
const ascii = ASCII_PDF[ch] // p. ej. ∧ → " /\ ", → → " -> "
if (ascii) return ascii
const n = ch.normalize('NFKD').replace(/[̀-ͯ]/g, '')
return n && [...n].every(tieneGlifo) ? n : ' '
})
Y si la fuente Unicode no pudo cargarse (sin red, archivo bloqueado), el detector es null y se toma la rama ASCII para todo: ∧ pasa a /\, ↔ a <->, los subíndices a _a. El PDF sale peor, pero sale. Y la exportación avisa de ello en vez de callarse: «No se pudo cargar la fuente con símbolos lógicos: el PDF usa Times…».
Subíndices y exponentes
Los subíndices Unicode (ₐ ₑ …) no existen para todas las letras: Pₐ tiene glifo, pero P_g no. Si el PDF mezclara los dos, el documento se vería inconsistente. partirSubindices trata ambos casos igual: separa el texto en trozos «normal», «subíndice» y «superíndice», y el PDF y el Word los dibujan de forma uniforme. Así Pₐ y P_g salen los dos como «P» más un subíndice, y 2^n sale como «2» más un superíndice, sin un ^ crudo en la página.
Emojis: avisar en lugar de perder en silencio
Los emojis tampoco están en DejaVu Serif. En lugar de omitirlos sin decir nada, el generador detecta los que no tienen glifo y registra un aviso para el usuario («Algunos emojis no se pueden mostrar en el PDF y se omitieron; el Word sí los conserva»). Es el mismo criterio que sigue el producto en general: nada se pierde sin que la persona lo sepa.
Lo que me llevo
- Una fuente incrustada pesa, así que se carga perezosa, solo al exportar y solo en el modo que la necesita (el académico con fuente serif).
- Pregunta a la fuente, no supongas. Un detector de glifos de cuatro líneas evita mantener una lista a mano de qué símbolos funcionan.
- Todo plan A necesita un plan B explícito. El fallback a ASCII existe, está documentado y avisa.
El resultado se ve en cualquier PDF de CrasterDoc con lógica proposicional. Puedes probarlo gratis en crasterdoc.morelodev.com, conocer más del producto en su ficha o leer la página Cómo se construyó.