wandres.dev
TEXTO EN CANVAS · Medir y componer

Texto multilínea: el ajuste de línea que hay que escribir

Implementar el ajuste de línea que el canvas no tiene, segmentando correctamente por palabras y por grafemas, y componiendo párrafos con interlineado real.

⏱ 18 min

El canvas no envuelve texto, así que hay que escribirlo. La versión ingenua —partir por espacios y acumular hasta que no quepa— funciona en inglés y falla en cuanto aparece una lengua sin espacios, un emoji compuesto, una palabra más larga que la línea o un guion. Escribir un ajustador que aguante esos casos no es difícil, pero exige conocer las herramientas de segmentación que el navegador ya trae.

🎯 Al terminar esta lección sabrás
  • Implementar un algoritmo de ajuste de línea correcto y eficiente.
  • Usar Intl.Segmenter para segmentar por palabras y por grafemas.
  • Manejar los casos difíciles: palabras largas, saltos explícitos, emoji compuestos.
  • Componer un bloque de párrafo con interlineado y alineación consistentes.

El algoritmo ingenuo y sus fallos

function ajustarIngenuo(ctx, texto, maxAncho) {
  const palabras = texto.split(' ');
  const lineas = [];
  let actual = '';
  for (const p of palabras) {
    const prueba = actual ? actual + ' ' + p : p;
    if (ctx.measureText(prueba).width > maxAncho && actual) {
      lineas.push(actual);
      actual = p;
    } else {
      actual = prueba;
    }
  }
  if (actual) lineas.push(actual);
  return lineas;
}

Funciona y tiene cuatro fallos que hay que conocer antes de decidir si te importan.

Ignora los saltos explícitos. Un salto de línea en el texto se trata como parte de una palabra.

Desborda con palabras largas. Una dirección web o un identificador más largo que la línea produce una línea que se sale de la caja.

Solo sirve para escrituras con espacios. En chino, japonés y tailandés no hay espacios entre palabras y el resultado es una única línea gigantesca.

Es cuadrático. Mide cadenas cada vez más largas, y el coste total crece con el cuadrado de la longitud del párrafo.

Para una etiqueta corta, ninguno de los cuatro importa. Para un bloque de texto real, los cuatro importan.

Segmentar bien

El navegador trae un segmentador consciente del idioma y de las reglas de Unicode:

const porPalabras = new Intl.Segmenter('es', { granularity: 'word' });
const porGrafemas = new Intl.Segmenter('es', { granularity: 'grapheme' });

[...porPalabras.segment('Hola, ¿qué tal?')].map(s => s.segment);
// ['Hola', ',', ' ', '¿', 'qué', ' ', 'tal', '?']

Con granularidad de palabra, el segmentador funciona también en japonés y en tailandés, donde aplica reglas específicas del idioma en lugar de buscar espacios. Los segmentos incluyen los separadores, y cada uno lleva una propiedad isWordLike que distingue palabras de puntuación y espacios.

Con granularidad de grafema se obtienen caracteres percibidos por el usuario: un emoji con modificador de tono de piel, una familia compuesta con uniones de ancho cero, una letra con diacrítico combinante. Cortar por índices de cadena parte esos grupos y produce basura; cortar por grafemas no.

const familia = '\u{1F468}\u{1F469}\u{1F467}';
familia.length;                                   // 11 unidades de codigo
[...familia].length;                              // 5 puntos de codigo
[...porGrafemas.segment(familia)].length;         // 1 grafema

Un ajustador que aguanta

/**
 * Divide texto en lineas que caben en maxAncho.
 * Respeta saltos explicitos, parte palabras demasiado largas por grafemas
 * y funciona en escrituras sin espacios.
 */
function ajustarTexto(ctx, texto, maxAncho, idioma = 'es') {
  const segPalabra = new Intl.Segmenter(idioma, { granularity: 'word' });
  const segGrafema = new Intl.Segmenter(idioma, { granularity: 'grapheme' });
  const anchoDe = new Map();
  const medir = s => {
    let w = anchoDe.get(s);
    if (w === undefined) { w = ctx.measureText(s).width; anchoDe.set(s, w); }
    return w;
  };

  const lineas = [];

  for (const parrafo of texto.split('\n')) {
    if (parrafo === '') { lineas.push(''); continue; }

    let linea = '', anchoLinea = 0;
    const empujar = () => { lineas.push(linea); linea = ''; anchoLinea = 0; };

    for (const { segment } of segPalabra.segment(parrafo)) {
      const w = medir(segment);

      // La pieza sola no cabe: partirla por grafemas
      if (w > maxAncho) {
        if (linea) empujar();
        let trozo = '', anchoTrozo = 0;
        for (const g of segGrafema.segment(segment)) {
          const wg = medir(g.segment);
          if (anchoTrozo + wg > maxAncho && trozo) {
            lineas.push(trozo); trozo = ''; anchoTrozo = 0;
          }
          trozo += g.segment; anchoTrozo += wg;
        }
        linea = trozo; anchoLinea = anchoTrozo;
        continue;
      }

      if (anchoLinea + w > maxAncho && linea) {
        empujar();
        if (/^\s+$/.test(segment)) continue;   // no arrastrar espacios al inicio
      }
      linea += segment;
      anchoLinea += w;
    }
    if (linea) lineas.push(linea.trimEnd());
  }
  return lineas;
}

Cinco decisiones de esa implementación. Los saltos explícitos se procesan antes que nada, dividiendo en párrafos. Las piezas que no caben ni solas se parten por grafemas, nunca por índices. Los espacios al principio de una línea nueva se descartan, que es lo que hace cualquier motor de texto. La medición se cachea por segmento, lo que convierte el coste cuadrático en lineal porque los segmentos son cortos y se repiten. Y el ancho se acumula sumando en lugar de remedir la línea entera, que es la otra mitad del ahorro.

Esa suma de anchos introduce un error respecto a medir la línea completa: se pierde el kerning entre segmentos. Con separadores de espacio el error es cero; con puntuación pegada puede ser de una fracción de píxel. Es un intercambio razonable, y si necesitas exactitud, mide la línea completa solo al final para decidir la alineación.

Componer el bloque

Con las líneas ya calculadas, dibujar el párrafo es directo, y la única decisión es el interlineado.

function pintarParrafo(ctx, texto, x, y, maxAncho, opciones = {}) {
  const { interlineado = 1.4, alineacion = 'left', idioma = 'es' } = opciones;
  const lineas = ajustarTexto(ctx, texto, maxAncho, idioma);

  // Alto de linea consistente: metricas de FUENTE, no de tinta
  const m = ctx.measureText('Hxg');
  const altoFuente = m.fontBoundingBoxAscent + m.fontBoundingBoxDescent;
  const paso = altoFuente * interlineado;

  ctx.textBaseline = 'alphabetic';
  ctx.textAlign = alineacion === 'center' ? 'center'
                : alineacion === 'right' ? 'right' : 'left';
  const xBase = alineacion === 'center' ? x + maxAncho / 2
              : alineacion === 'right' ? x + maxAncho : x;

  let cursorY = y + m.fontBoundingBoxAscent;
  for (const linea of lineas) {
    ctx.fillText(linea, xBase, cursorY);
    cursorY += paso;
  }
  return { lineas: lineas.length, alto: lineas.length * paso };
}

El uso de fontBoundingBoxAscent para el desplazamiento inicial es lo que hace que el bloque empiece exactamente en y, en lugar de que la primera línea base caiga ahí y el texto suba por encima de la caja.

El justificado a ambos márgenes es fácil de hacer y difícil de hacer bien

Añadir justificación completa parece el paso natural: reparte el espacio sobrante entre los huecos de cada línea y dibuja palabra a palabra. Diez líneas de código y ya tienes texto justificado. El problema es que el resultado se ve mal, y la razón es que la justificación ingenua produce ríos: canales blancos verticales que atraviesan el párrafo cuando varias líneas seguidas tienen huecos grandes en posiciones similares. El ojo los sigue y la lectura se rompe. Los motores de texto serios no reparten espacio línea a línea: usan un algoritmo de optimización global —el de Knuth y Plass, el mismo de TeX— que elige los puntos de corte de todo el párrafo minimizando una función de penalización que suma el estiramiento de cada línea al cuadrado. Eso permite apretar una línea para que la siguiente no tenga que abrirse tanto, y el resultado es visiblemente mejor. Implementarlo en canvas es perfectamente viable con programación dinámica y unas ochenta líneas, y en un texto de trescientas palabras se ejecuta en menos de un milisegundo. Hay dos detalles que hay que respetar para que compense: repartir el espacio también entre letras en pequeñas cantidades además de entre palabras, porque el ojo tolera mucho mejor un espaciado de letras del uno por ciento que un espacio doble entre palabras; y no justificar nunca la última línea de un párrafo. Y hay una conclusión honesta que hay que decir en voz alta: si tu contenido es un párrafo de texto que necesita justificación, ríos, guionado y control de viudas, no lo pongas en un canvas. Ponlo en HTML encima del canvas y deja que el motor de texto del navegador, que lleva treinta años puliéndose, haga su trabajo. El ajuste de línea en canvas es para etiquetas, tooltips y anotaciones cortas, no para prosa.

Ejemplo completo

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

  function ajustarTexto(ctx, texto, maxAncho, idioma = 'es') {
    const segP = new Intl.Segmenter(idioma, { granularity: 'word' });
    const segG = new Intl.Segmenter(idioma, { granularity: 'grapheme' });
    const cache = new Map();
    const medir = s => {
      let w = cache.get(s);
      if (w === undefined) { w = ctx.measureText(s).width; cache.set(s, w); }
      return w;
    };
    const lineas = [];
    for (const parrafo of texto.split('\n')) {
      if (parrafo === '') { lineas.push(''); continue; }
      let linea = '', ancho = 0;
      for (const { segment } of segP.segment(parrafo)) {
        const w = medir(segment);
        if (w > maxAncho) {
          if (linea) { lineas.push(linea); linea = ''; ancho = 0; }
          let t = '', at = 0;
          for (const g of segG.segment(segment)) {
            const wg = medir(g.segment);
            if (at + wg > maxAncho && t) { lineas.push(t); t = ''; at = 0; }
            t += g.segment; at += wg;
          }
          linea = t; ancho = at;
          continue;
        }
        if (ancho + w > maxAncho && linea) {
          lineas.push(linea); linea = ''; ancho = 0;
          if (/^\s+$/.test(segment)) continue;
        }
        linea += segment; ancho += w;
      }
      if (linea) lineas.push(linea.trimEnd());
    }
    return lineas;
  }

  function pintarParrafo(ctx, texto, x, y, maxAncho, interlineado = 1.4) {
    const lineas = ajustarTexto(ctx, texto, maxAncho);
    const m = ctx.measureText('Hxg');
    const paso = (m.fontBoundingBoxAscent + m.fontBoundingBoxDescent) * interlineado;
    ctx.textBaseline = 'alphabetic';
    ctx.textAlign = 'left';
    let cy = y + m.fontBoundingBoxAscent;
    for (const l of lineas) { ctx.fillText(l, x, cy); cy += paso; }
    return lineas.length * paso;
  }

  const texto =
    'El canvas no envuelve texto por si mismo, asi que el ajuste de linea ' +
    'hay que implementarlo.\n\nUna palabra larguisima como ' +
    'supercalifragilisticoespialidosoyademasconcola se parte por grafemas ' +
    'si no cabe entera en la linea disponible.';

  (async () => {
    if (document.fonts) await document.fonts.ready;
    ctx.font = '17px system-ui';
    ctx.fillStyle = '#cdd6f4';
    ctx.strokeStyle = '#45475a';
    ctx.strokeRect(30.5, 30.5, 320, 250);
    pintarParrafo(ctx, texto, 30, 30, 320, 1.45);

    ctx.font = '13px system-ui';
    ctx.fillStyle = '#7f849c';
    ctx.strokeRect(380.5, 30.5, 210, 250);
    pintarParrafo(ctx, texto, 380, 30, 210, 1.35);
  })();
</script>

Los dos bloques usan el mismo texto y la misma función con anchos distintos: el ajuste se recalcula por completo, que es lo correcto, porque la partición de líneas depende del ancho disponible y no se puede cachear entre tamaños.