measureText y todas sus métricas
Usar el objeto TextMetrics más allá de width: los ascensos y descensos reales, los de la caja de fuente, y para qué sirve exactamente cada uno.
measureText devuelve mucho más que un ancho, y la diferencia entre sus métricas es la diferencia entre una caja que rodea el texto y una que rodea el hueco donde el texto podría estar. Hay dos familias de medidas —las que describen la tinta real y las que describen la caja de la fuente— y usar la equivocada produce cajas de fondo que bailan, rectángulos de selección con márgenes raros y centrados que no centran.
- Enumerar las métricas de
TextMetricsy agruparlas en sus dos familias. - Distinguir el ancho de avance del ancho de la tinta y decir cuándo difieren.
- Calcular la caja envolvente exacta de un texto dibujado.
- Elegir entre métricas reales y métricas de fuente según el caso.
Las dos familias
const m = ctx.measureText('Hpgjy');
El objeto devuelto contiene, agrupadas por lo que describen:
El avance. width es la distancia horizontal que el cursor avanzaría al escribir ese texto. Es la métrica que usa el motor para colocar el texto siguiente.
La tinta real, lo que ocupa este texto concreto con esta fuente:
actualBoundingBoxLeft: distancia desde el punto de anclaje hasta el borde izquierdo de la tinta, positiva hacia la izquierda.actualBoundingBoxRight: hasta el borde derecho, positiva hacia la derecha.actualBoundingBoxAscent: desde la línea base hasta el punto más alto de la tinta, positiva hacia arriba.actualBoundingBoxDescent: desde la línea base hasta el punto más bajo, positiva hacia abajo.
La caja de la fuente, lo que la fuente reserva independientemente del texto:
fontBoundingBoxAscentyfontBoundingBoxDescent: los extremos de la caja de la fuente respecto a la línea base.
Y un tercer grupo, con soporte más irregular entre motores: emHeightAscent, emHeightDescent, hangingBaseline, alphabeticBaseline e ideographicBaseline, que dan las posiciones de las líneas base alternativas y los extremos de la caja em. Están en la especificación pero no siempre implementadas; compruébalas antes de depender de ellas.
width no es el ancho de la tinta
La diferencia entre width y la suma de las dos métricas laterales reales es real y a veces grande.
width es la suma de los avances de los glifos, ajustada por kerning. Es lo que ocupa el texto en el flujo.
La caja real es lo que ocupan los píxeles pintados. Puede ser mayor que el avance, cuando hay glifos que sobresalen de su caja de avance —una f cursiva, cualquier tipografía caligráfica— y puede ser menor, cuando el último glifo tiene espacio lateral por la derecha, que es lo habitual.
ctx.font = 'italic 64px Georgia, serif';
const m = ctx.measureText('fj');
console.log('avance:', m.width);
console.log('tinta:', m.actualBoundingBoxLeft + m.actualBoundingBoxRight);
// En muchas fuentes la tinta es MAS ancha que el avance
Esa diferencia explica un fallo muy concreto: dibujas un rectángulo de fondo de anchura m.width detrás del texto y el texto se sale por los lados. Para eso hay que usar la caja real, no el avance.
La caja envolvente exacta
Con las cuatro métricas de la caja real se obtiene el rectángulo que encierra exactamente la tinta, relativo al punto de anclaje:
function cajaDeTexto(ctx, texto, x, y) {
const m = ctx.measureText(texto);
return {
x: x - m.actualBoundingBoxLeft,
y: y - m.actualBoundingBoxAscent,
ancho: m.actualBoundingBoxLeft + m.actualBoundingBoxRight,
alto: m.actualBoundingBoxAscent + m.actualBoundingBoxDescent,
};
}
Esa función es correcta con cualquier textAlign y textBaseline, porque las métricas de caja real ya están medidas respecto al punto de anclaje tal como lo interpretan esas propiedades. Es un detalle importante y poco conocido: las métricas cambian cuando cambias textAlign.
ctx.textAlign = 'left';
console.log(ctx.measureText('Hola').actualBoundingBoxLeft); // proximo a 0
ctx.textAlign = 'center';
console.log(ctx.measureText('Hola').actualBoundingBoxLeft); // la mitad del ancho
Eso significa que puedes medir con la alineación que vas a usar y las coordenadas te salen directamente, sin corregir nada.
// Fondo ajustado detras de una etiqueta centrada
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '600 20px system-ui';
const caja = cajaDeTexto(ctx, 'Etiqueta', 200, 100);
const p = 8;
ctx.fillStyle = 'rgba(137,180,250,0.2)';
ctx.beginPath();
ctx.roundRect(caja.x - p, caja.y - p, caja.ancho + p * 2, caja.alto + p * 2, 6);
ctx.fill();
ctx.fillStyle = '#cdd6f4';
ctx.fillText('Etiqueta', 200, 100);
Reales frente a de fuente: cuándo cada una
La elección entre las dos familias tiene una regla clara y es la conclusión práctica de la lección.
Usa las métricas reales cuando la caja deba ajustarse a este texto: un fondo que envuelve una etiqueta, un rectángulo de selección, un recorte, la caja envolvente para hit testing, un centrado óptico.
Usa las métricas de fuente cuando la caja deba ser consistente entre textos distintos: la altura de una fila de tabla, el alto de línea de un párrafo, la altura de un botón, el espaciado vertical de una lista de etiquetas.
// Alto de linea consistente, independiente del contenido
function altoDeLinea(ctx) {
const m = ctx.measureText('Hxg');
return m.fontBoundingBoxAscent + m.fontBoundingBoxDescent;
}
El error de mezclarlas produce un síntoma característico: una lista donde las filas tienen alturas distintas según si su texto lleva tildes o descendentes. La fila con “Página” es más alta que la de “TOTAL”, y eso es feo y no se atribuye fácilmente.
measureText no es una consulta a una tabla: obliga al motor a conformar el texto, es decir, a resolver la familia de fuente aplicable a cada carácter, aplicar el kerning, resolver las ligaduras, ejecutar el algoritmo bidireccional si hace falta y sumar los avances. Es esencialmente el mismo trabajo que hace falta para dibujar, menos la rasterización. En un bucle que mide cada etiqueta de un eje en cada fotograma, eso se convierte en el cuello de botella con una facilidad que sorprende. Hay tres agravantes concretos. El primero: la conformación se rehace desde cero en cada llamada, así que medir la misma cadena mil veces cuesta mil veces. El segundo: si el texto contiene caracteres de varios sistemas de escritura, el motor tiene que resolver el respaldo de fuente para cada tramo, lo que multiplica el coste. El tercero, el peor: el algoritmo de ajuste de línea ingenuo —añadir palabras y medir hasta que no quepa— hace una llamada por palabra y por línea, con lo que un párrafo de doscientas palabras genera cientos de mediciones sobre cadenas cada vez más largas, y el coste total crece con el cuadrado. Las defensas son tres y se aplican juntas. Cachear por cadena y fuente en un Map, porque las etiquetas se repiten mucho más de lo que parece. Medir palabras sueltas y sumar sus anchos en lugar de medir cadenas crecientes, aceptando un error por kerning entre palabras que con separadores de espacio es cero. Y medir un carácter representativo una vez cuando la fuente es monoespaciada, que es el caso de los editores de código y de las tablas de datos, donde el ancho de cualquier cadena es el de un carácter por su longitud y measureText es innecesario por completo.
Una caché de medición
function creadorDeMedidor(ctx) {
const cache = new Map();
const MAX = 5000;
return function medir(texto) {
const clave = ctx.font + ' ' + ctx.textAlign + ' ' + texto;
let m = cache.get(clave);
if (m === undefined) {
const t = ctx.measureText(texto);
m = {
width: t.width,
izq: t.actualBoundingBoxLeft,
der: t.actualBoundingBoxRight,
asc: t.actualBoundingBoxAscent,
desc: t.actualBoundingBoxDescent,
fAsc: t.fontBoundingBoxAscent,
fDesc: t.fontBoundingBoxDescent,
};
if (cache.size >= MAX) cache.clear();
cache.set(clave, m);
}
return m;
};
}
Dos decisiones de esa caché. La clave incluye la fuente y la alineación porque las métricas dependen de ambas; olvidar cualquiera de las dos produce medidas erróneas que aparecen solo cuando cambias de estilo. Y se copian los valores en un objeto plano en lugar de guardar el TextMetrics, porque ese objeto puede ser pesado y porque los objetos planos son mucho más rápidos de leer en un bucle caliente.
Mide la misma cadena de cuarenta caracteres cien mil veces con measureText y cronométralo. Después hazlo con la caché. La diferencia entre ambos números es el presupuesto que estás dedicando a medir texto en cada fotograma de tu aplicación, y suele sorprender.