wandres.dev
TEXTO EN CANVAS · Medir y componer

fillText y strokeText: dibujar texto y sus límites

Usar los dos métodos de texto del canvas, entender el parámetro maxWidth que casi nadie usa bien, y aceptar de entrada lo que el canvas no hace con el texto.

⏱ 16 min

El canvas dibuja texto con dos métodos y una propiedad de fuente, y ahí se acaba su modelo tipográfico. No hay saltos de línea, no hay justificación, no hay selección, no hay búsqueda, no hay ajuste al contenedor. Lo que sí hay es acceso al motor de rasterización de fuentes del navegador, que es excelente, y una capacidad de medición que permite construir a mano todo lo que falta.

🎯 Al terminar esta lección sabrás
  • Dibujar texto relleno y trazado, y combinar ambos correctamente.
  • Usar el parámetro maxWidth sabiendo qué hace exactamente y qué no.
  • Enumerar lo que el canvas no ofrece del modelo de texto y sus implicaciones.
  • Garantizar que la fuente está cargada antes de dibujar con ella.

Los dos métodos

ctx.fillText(texto, x, y, maxWidth);
ctx.strokeText(texto, x, y, maxWidth);

fillText rellena los glifos con fillStyle; strokeText traza su contorno con strokeStyle y lineWidth. Las coordenadas x e y son el punto de anclaje, cuyo significado depende de textAlign y textBaseline, que tienen su propia lección.

Combinarlos produce texto con contorno, y el orden importa por la misma razón que en las formas: el trazo se centra en el contorno del glifo, así que la mitad interior tapa al relleno.

ctx.font = 'bold 64px system-ui';
ctx.lineJoin = 'round';                  // evita puas en los vertices de los glifos
ctx.strokeStyle = '#11111b';
ctx.lineWidth = 8;
ctx.strokeText('Canvas', 40, 90);        // primero el contorno, mas grueso
ctx.fillStyle = '#f9e2af';
ctx.fillText('Canvas', 40, 90);          // el relleno tapa la mitad interior

El lineJoin = 'round' de ahí no es opcional: los contornos de los glifos tienen vértices muy agudos, especialmente en las serifas y en los remates, y con la unión de inglete por defecto salen púas disparadas. Es un caso de libro del límite de inglete.

maxWidth: comprimir, no cortar

El cuarto parámetro es opcional y hace algo muy concreto y muy poco intuitivo: si el texto mide más que maxWidth, se condensa horizontalmente hasta caber. No se corta, no se trunca, no se recorta: se estrecha.

ctx.font = '24px system-ui';
ctx.fillText('Un texto bastante largo', 20, 40);        // ancho natural
ctx.fillText('Un texto bastante largo', 20, 80, 120);   // aplastado a 120 px

El resultado a menudo es ilegible, y por eso el parámetro se usa poco y a menudo mal. Sus casos legítimos son dos: cuando una diferencia pequeña es aceptable —una etiqueta que se pasa un cinco por ciento— y cuando el aplastamiento es preferible al desbordamiento, como en una tabla de datos densa.

La forma en que el navegador condensa no está estrictamente especificada. Puede escalar horizontalmente los glifos o seleccionar una variante más estrecha de la fuente si existe. El resultado difiere entre motores, lo que es otra razón para no depender de él.

Lo que casi siempre quieres en su lugar es truncar con puntos suspensivos, y eso hay que escribirlo:

function textoTruncado(ctx, texto, maxAncho) {
  if (ctx.measureText(texto).width <= maxAncho) return texto;
  const puntos = '…';
  const anchoPuntos = ctx.measureText(puntos).width;
  let bajo = 0, alto = texto.length;
  while (bajo < alto) {
    const medio = Math.ceil((bajo + alto) / 2);
    if (ctx.measureText(texto.slice(0, medio)).width + anchoPuntos <= maxAncho) {
      bajo = medio;
    } else {
      alto = medio - 1;
    }
  }
  return texto.slice(0, bajo).trimEnd() + puntos;
}

La búsqueda binaria evita medir carácter a carácter, que en textos largos es notablemente más lento.

Lo que el canvas no hace con el texto

Conviene tener la lista completa desde el principio, porque cada punto es trabajo que tendrás que hacer tú o una razón para no usar canvas.

No hay saltos de línea. Un salto en la cadena no salta: se dibuja como un glifo desconocido o se ignora, según el motor. El ajuste de línea hay que implementarlo.

No hay ajuste automático al ancho. Nada envuelve el texto al llegar al borde.

No hay selección ni copia. El usuario no puede seleccionar el texto de un canvas.

No hay búsqueda en la página. El buscador del navegador no encuentra texto dibujado en canvas.

No hay accesibilidad. Un lector de pantalla no lee el texto de un canvas salvo que lo dupliques en el contenido de reserva.

No hay traducción automática. Las herramientas de traducción del navegador no ven el texto.

No hay ajuste al zoom de texto del usuario. Quien aumenta solo el tamaño de fuente en su navegador no cambia nada dentro del canvas.

Ninguna de esas ausencias es un fallo: son consecuencias del modo inmediato. Pero juntas explican por qué poner texto largo en un canvas es casi siempre un error, y por qué las visualizaciones bien hechas ponen el gráfico en canvas y las etiquetas en HTML posicionado encima.

La fuente que no ha cargado se sustituye en silencio, y el texto sale con otro ancho

Este es el fallo de texto en canvas que más veces aparece en producción y que nunca se reproduce en desarrollo, donde la fuente ya está en caché. La secuencia es esta: tu código asigna ctx.font = '16px Inter', la fuente todavía no ha terminado de cargar, el navegador no espera y dibuja con la fuente de reserva. Lo pintado se queda ahí, porque el canvas es de modo inmediato: cuando la fuente llega no se repinta nada. El resultado es un gráfico con la tipografía equivocada y, lo que es peor, con anchos de texto equivocados, así que las etiquetas que habías centrado con measureText aparecen descolocadas. En un canvas que se redibuja cada fotograma el problema se corrige solo al siguiente; en uno estático se queda para siempre. La solución es esperar explícitamente antes del primer pintado, y la API existe:

async function conFuenteLista(especificacion, texto = 'ABC') {
  if (document.fonts) {
    await document.fonts.load(especificacion, texto);
    await document.fonts.ready;
  }
}
await conFuenteLista('16px Inter');
ctx.font = '16px Inter';
pintar();

Dos matices que se pasan por alto. Primero: document.fonts.load necesita la especificación con tamaño incluido, porque una familia puede tener varios ficheros por rango de tamaño o de peso. Segundo: document.fonts.ready sola no basta si la fuente no se ha empezado a usar en ninguna parte del documento, porque los navegadores cargan las fuentes de forma perezosa y una fuente que nadie usa nunca llega a cargarse. Por eso hay que llamar a load primero, con el texto que vas a dibujar si contiene caracteres poco comunes. Y en un canvas que vive dentro de un worker, document no existe: allí hay que usar self.fonts con un FontFace construido a mano.

Sombras y texto

Las propiedades de sombra funcionan con texto igual que con formas, y son la forma más directa de conseguir legibilidad sobre un fondo variable:

ctx.font = '600 20px system-ui';
ctx.shadowColor = 'rgba(0,0,0,0.85)';
ctx.shadowBlur = 6;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 1;
ctx.fillStyle = '#fff';
ctx.fillText('Etiqueta sobre imagen', 20, 40);
ctx.shadowBlur = 0;                       // restaurar SIEMPRE
ctx.shadowColor = 'rgba(0,0,0,0)';

Dos advertencias. La primera: shadowBlur es de las operaciones más caras del canvas, porque implica un desenfoque sobre la región afectada. En un bucle con cientos de etiquetas hunde el rendimiento; la alternativa es dibujar el texto varias veces con desplazamientos pequeños, o poner un rectángulo semitransparente detrás.

La segunda: las propiedades de sombra son estado y persisten. Una función que pone shadowBlur y no lo restaura deja todo el dibujo posterior con sombra, y el síntoma tarda en atribuirse a su causa.

Un ejemplo completo

<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="620" height="240" style="background:#1e1e2e"></canvas>
<script>
  const ctx = document.getElementById('c').getContext('2d');

  function truncar(texto, maxAncho) {
    if (ctx.measureText(texto).width <= maxAncho) return texto;
    const p = '…', ap = ctx.measureText(p).width;
    let b = 0, a = texto.length;
    while (b < a) {
      const m = Math.ceil((b + a) / 2);
      if (ctx.measureText(texto.slice(0, m)).width + ap <= maxAncho) b = m; else a = m - 1;
    }
    return texto.slice(0, b).trimEnd() + p;
  }

  (async () => {
    if (document.fonts) await document.fonts.ready;

    ctx.font = 'bold 46px system-ui';
    ctx.lineJoin = 'round';
    ctx.strokeStyle = '#11111b'; ctx.lineWidth = 8;
    ctx.strokeText('Texto en canvas', 30, 60);
    ctx.fillStyle = '#f9e2af';
    ctx.fillText('Texto en canvas', 30, 60);

    ctx.font = '18px system-ui';
    ctx.fillStyle = '#a6adc8';
    const largo = 'Una etiqueta demasiado larga para el espacio disponible';
    ctx.fillText(truncar(largo, 260), 30, 110);         // truncado correcto
    ctx.fillText(largo, 30, 145, 260);                  // maxWidth: aplastado
    ctx.fillStyle = '#585b70';
    ctx.font = '13px system-ui';
    ctx.fillText('arriba: truncado con puntos · abajo: maxWidth condensa', 30, 180);
  })();
</script>

Comparar las dos líneas centrales deja claro por qué maxWidth casi nunca es lo que quieres: la de arriba se lee, la de abajo parece un error de renderizado.