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.
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 grafemaUn 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.
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.