PerfectVector
Por Irene Kim

Ajuste de texto SVG: crea líneas medidas con tspan

Ajusta etiquetas SVG midiendo palabras con la fuente real y generando tspans. Prueba etiquetas estrechas, rechaza palabras o líneas que desborden y guarda el resultado.

12 min de lectura
En esta página

Para dividir una etiqueta SVG cambiante en líneas explícitas, mide grupos de palabras candidatos con getComputedTextLength() y escribe después un <tspan> por cada línea aceptada. Espera primero a las fuentes del documento y decide qué debe ocurrir si una palabra es más ancha que la etiqueta o si el resultado supera el límite de líneas.

La plantilla siguiente usa palabras separadas por espacios y rechaza ambos tipos de desbordamiento sin generar una etiqueta parcial. Mantiene una ilustración original de rombo hueco separada del texto, de modo que cambiar el ancho de la etiqueta no redibuja el diseño.

Elige una política de ajuste antes de colocar las líneas

El elemento tspan permite colocar partes de texto SVG. Nuestra rutina establece el mismo x en cada línea y un desplazamiento dy en las posteriores. La rutina decide los saltos de línea; el elemento contiene el resultado.

Este es un método de distribución explícita. La especificación de texto de SVG 2 también define el ajuste automático mediante un área de contenido y la distribución de texto CSS. Este artículo no prueba esas funciones ni afirma que ningún navegador las tenga.

Para un párrafo HTML fuera de la ilustración, ajusta el texto alrededor de una silueta SVG flotante con CSS. Esa distribución sigue el contorno de una imagen mientras el texto sigue siendo HTML normal, en lugar de convertirse en líneas de etiqueta SVG colocadas explícitamente.

Para este pequeño ejemplo de etiqueta en inglés, la política es:

  • Reduce los espacios en blanco y prueba las palabras en su orden original, separadas por espacios simples.
  • Conserva una línea candidata solo mientras su longitud medida quepa en el ancho disponible.
  • Rechaza una palabra que no quepa por sí sola; no inventes un punto de separación silábica.
  • Rechaza todo el resultado si necesita más de maxLines; no elimines silenciosamente las últimas palabras.

Un límite de líneas es una decisión de distribución. Elígelo junto con la línea base inicial, el interlineado y la altura disponible. El ajustador no calcula por ti un recuadro de contenido vertical.

Cambia el ancho de etiqueta y conserva el diseño
Comparación conceptual de una ilustración de rombo hueco junto a líneas largas en una etiqueta ancha y líneas cortas en una etiqueta estrecha
Ilustración: una etiqueta más estrecha puede necesitar más líneas. Las barras abstractas y los marcos punteados son referencias conceptuales; el texto real, las mediciones y los estados de rechazo de abajo proceden de la plantilla independiente del navegador.

Mide con la fuente que dibujará la etiqueta

getComputedTextLength() mide la longitud calculada del texto SVG. Esta plantilla clona el elemento de texto de la etiqueta como una sonda oculta dentro del mismo SVG insertado y la elimina después de planificar las líneas. Ambos usan explícitamente Arial, sans-serif a 22 unidades.

El código espera a document.fonts.ready antes de medir. Eso espera a la carga de fuentes y la distribución asociada del documento; no incrusta una fuente en el SVG exportado. Otra fuente disponible puede producir longitudes de línea diferentes. Vuelve a calcular la distribución cuando cambien el texto, los ajustes de fuente o el ancho disponible.

Ejecuta la plantilla completa sin dependencias

Guarda esto como starter.html y ábrelo en un navegador. Crea seis casos, imprime sus mediciones y serializa cada SVG resultante. Los dos primeros solo cambian el ancho de la etiqueta; el tercero cambia el texto. Los demás prueban el rechazo y el texto literal.

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Measured SVG label lines</title>
<style>
  body { margin: 24px; font: 16px system-ui; background: #f6f2e8; }
  #cases { display: grid; grid-template-columns: repeat(2, 360px); gap: 20px; }
  figure { margin: 0; }
  svg { display: block; background: white; }
  figcaption { margin: 6px 0; }
  pre { white-space: pre-wrap; font-size: 12px; }
</style>
<h1>Keep artwork and label layout separate</h1>
<div id="cases"></div>
<pre id="report"></pre>
<script>
  const ns = 'http://www.w3.org/2000/svg';
  const emblem = 'M60 32 L100 72 L60 112 L20 72 Z M60 54 L78 72 L60 90 L42 72 Z';
  function element(name, attributes) {
    const node = document.createElementNS(ns, name);
    for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
    return node;
  }
  function wrapWords(text, source, { width, maxLines, lineHeight }) {
    text.replaceChildren();
    if (!(width > 0 && lineHeight > 0 && Number.isInteger(maxLines) && maxLines > 0)) {
      return { status: 'invalid-options', lines: [] };
    }
    const words = source.trim().split(/\s+/u).filter(Boolean);
    const probe = text.cloneNode(false);
    probe.setAttribute('visibility', 'hidden');
    text.parentNode.append(probe);
    const measure = value => { probe.textContent = value; return probe.getComputedTextLength(); };
    const lines = [];
    let line = '';
    try {
      for (const word of words) {
        const wordWidth = measure(word);
        if (wordWidth > width) {
          return { status: 'overlong-word', word, measuredWidth: wordWidth, lines: [] };
        }
        const candidate = line ? line + ' ' + word : word;
        if (line && measure(candidate) > width) {
          lines.push(line);
          line = word;
        } else line = candidate;
      }
      if (line) lines.push(line);
      if (lines.length > maxLines) {
        return { status: 'too-many-lines', requiredLines: lines.length, lines: [] };
      }
      const x = text.getAttribute('x');
      lines.forEach((value, index) => {
        const span = element('tspan', { x, dy: index ? lineHeight : 0 });
        span.textContent = value;
        text.append(span);
      });
      return { status: 'ok', lines,
        lengths: [...text.children].map(span => span.getComputedTextLength()) };
    } finally { probe.remove(); }
  }
  const cases = [
    { id: 'normal', source: 'Keep artwork and labels separate', width: 200, maxLines: 4 },
    { id: 'narrow', source: 'Keep artwork and labels separate', width: 110, maxLines: 5 },
    { id: 'changed', source: 'Keep the original artwork beside every exported label', width: 200, maxLines: 4 },
    { id: 'long-word', source: 'Artwork supercalifragilisticexpialidocious', width: 110, maxLines: 5 },
    { id: 'line-limit', source: 'Keep artwork and labels separate', width: 110, maxLines: 2 },
    { id: 'literal', source: 'Cove <g> & field', width: 200, maxLines: 4 },
  ];
  const entries = cases.map(test => {
    const figure = document.createElement('figure');
    const svg = element('svg', { width: 360, height: 240, viewBox: '0 0 360 240' });
    svg.append(element('path', { d: emblem, fill: '#176b5b', 'fill-rule': 'evenodd' }));
    svg.append(element('rect', { x: 140, y: 34, width: test.width, height: 170,
      fill: 'none', stroke: '#ce7b55', 'stroke-dasharray': '4 4' }));
    const text = element('text', { x: 140, y: 60, 'font-family': 'Arial, sans-serif',
      'font-size': 22, fill: '#172b34' });
    svg.append(text);
    const caption = document.createElement('figcaption');
    figure.append(svg, caption);
    document.getElementById('cases').append(figure);
    return { test, svg, text, caption };
  });
  async function run() {
    await document.fonts.ready;
    const results = entries.map(({ test, svg, text, caption }) => {
      const result = wrapWords(text, test.source, { ...test, lineHeight: 30 });
      caption.textContent = test.id + ': ' + result.status;
      return { ...test, ...result, fontFamily: getComputedStyle(text).fontFamily,
        fontSize: getComputedStyle(text).fontSize, tspanCount: text.children.length,
        pathD: svg.querySelector('path').getAttribute('d'),
        pathCount: svg.querySelectorAll('path').length,
        imageCount: svg.querySelectorAll('image').length,
        foreignObjectCount: svg.querySelectorAll('foreignObject').length,
        serialized: new XMLSerializer().serializeToString(svg) };
    });
    document.getElementById('report').textContent = JSON.stringify({
      fontStatus: document.fonts.status, policy: 'Whitespace words; reject a long word or too many lines without partial output', results,
    }, null, 2);
  }
  run();
</script>
</html>

La rutina crea cada línea con textContent, en lugar de insertar el texto como marcado. En el caso de texto literal, Cove <g> & field sigue siendo texto normal: el SVG serializado contiene signos angulares y un ampersand escapados, no un elemento de grupo adicional.

El clon de medición está insertado pero oculto, con los mismos atributos de fuente que la etiqueta visible. Se elimina en finally, también en caso de rechazo. Las líneas solo se añaden después de superar las comprobaciones de ancho de palabra y número máximo de líneas. La rutina borra primero cualquier etiqueta anterior, así que una actualización rechazada no deja texto antiguo ni parcial; gestiona el estado devuelto en la interfaz de tu aplicación.

Qué produjeron los seis casos originales

Ejecutamos exactamente esta plantilla en Chrome con las fuentes del documento informando loaded. Las longitudes medidas siguientes son longitudes de texto SVG de ese entorno, no límites de glifos pintados ni una garantía de portabilidad entre fuentes.

CasoAncho / máximo de líneasResultadoLínea más larga observada
Frase original200 / 4Dos líneas: Keep artwork and; labels separate172.44
Misma frase, recuadro estrecho110 / 5Cuatro líneas: Keep; artwork; and labels; separate100.30
Texto más largo200 / 4Tres líneas198.13
Palabra demasiado larga110 / 5overlong-word; cero tspansPalabra de 311.82 de ancho medido
Frase estrecha, límite de dos líneas110 / 2too-many-lines; necesita cuatro; cero tspansNo se generó etiqueta
Signos angulares y ampersand literales200 / 4Una línea literal162.66

La frase original es Keep artwork and labels separate. La versión más larga es Keep the original artwork beside every exported label. Todas las líneas aceptadas midieron dentro de su ancho elegido. Los casos rechazados dejaron visible la ilustración y omitieron la etiqueta, lo que facilita revisar el estado de fallo.

Los seis archivos serializados conservaron el trazado original idéntico del rombo, un elemento de texto nativo y ningún elemento de imagen ni foreignObject. Los casos aceptados contenían dos, cuatro, tres y un tspans respectivamente; ambos casos rechazados contenían cero. También volvimos a abrir los seis SVG serializados como imágenes en el mismo navegador, donde todos cargaron a 360×240. Eso comprueba estos archivos y este navegador, no todos los editores o renderizadores.

Guarda el SVG distribuido y prueba después la entrega

XMLSerializer.serializeToString() crea el marcado serializado del informe. Guarda la cadena serialized de un caso correcto como archivo .svg. El archivo ya contiene líneas explícitas; no necesita esta rutina JavaScript para recrearlas.

Las palabras siguen siendo texto activo. El archivo guardado conserva la solicitud de familia de fuentes, no un archivo de fuente ni glifos convertidos en trazados. Un destino con otra fuente puede cambiar las letras aunque las cadenas de línea y las posiciones permanezcan. Conserva el texto original y las entradas de distribución junto a la copia exportada y revisa el destino real antes de entregar. Para fijar la forma de las letras, crea una copia de entrega con el texto convertido en trazados en un editor de origen adecuado, manteniendo el texto activo en el maestro.

La guía de texto con foreignObject aborda etiquetas HTML que desaparecen en otro renderizador. Esta plantilla crea texto nativo desde el principio. La guía de textLength cubre el ajuste de una línea mediante la modificación de su longitud; plantea una decisión distinta de añadir líneas medidas. Usa la guía de alineación vertical cuando necesites colocar toda la etiqueta respecto al diseño circundante.

Explicita los límites del ajustador

Dividir por espacios en blanco es una política deliberadamente pequeña. Reduce los espacios repetidos, las tabulaciones y los saltos de línea explícitos, y no gestiona reglas de salto propias de cada idioma, separación silábica, párrafos bidireccionales ni fragmentos enriquecidos con estilos mezclados. Elige otro sistema de distribución o amplía y prueba la política cuando tu texto necesite esas funciones.

La comprobación de ancho tampoco garantiza que los salientes decorativos de los glifos permanezcan dentro de un marco dibujado ni que cinco líneas base quepan en la altura elegida. Revisa las letras visibles al tamaño de entrega. Nuestro archivo usa etiquetas horizontales, atributos de fuente fijos y un paso de línea de 30 unidades; no traslades sus medidas a otra tipografía sin volver a medir.

Si tu ilustración solo existe como PNG o JPG y necesitas un diseño editable, prepara un SVG de clipart con PerfectVector, inspecciona sus bordes y aberturas internas y conserva el diseño separado de la etiqueta. Vuelve a escribir el texto como texto nativo, ejecuta la política de ajuste y revisa la exportación ensamblada. La vectorización prepara los trazados de la ilustración; no elige saltos de línea ni recupera palabras editables de letras calcadas. Los trazados buenos existentes pueden quedarse como están. La guía de vectorización explica esa etapa de recuperación del origen.

Preguntas frecuentes

¿tspan decide automáticamente dónde salta cada línea? Esta plantilla decide midiendo grupos de palabras y después crea un tspan colocado por cada línea aceptada. No prueba el ajuste automático de SVG 2 basado en CSS.

¿Qué ocurre con una palabra más ancha que la etiqueta? La rutina devuelve overlong-word y no genera etiqueta. Amplía el área, acorta el texto o proporciona una política de división probada por separado en lugar de recortarlo silenciosamente.

¿maxLines elimina las palabras restantes? No. Esta política rechaza el resultado si necesita demasiadas líneas, informa del número requerido y deja cero tspans. La aplicación debe gestionar ese estado.

¿Las líneas exportadas se verán idénticas con otra fuente? Las palabras y posiciones explícitas permanecen, pero el archivo no incrusta una fuente ni convierte glifos en trazados. Revisa la fuente disponible y las letras en el destino real.

Fuentes

  1. MDN, elemento tspan — Subtexto colocado y atributos de desplazamiento de línea.
  2. W3C, texto de SVG 2 — Colocación explícita y modelo separado de ajuste automático.
  3. MDN getComputedTextLength — Medición del texto SVG real.
  4. MDN FontFaceSet.ready — Espera a la carga de fuentes y distribución.
  5. MDN textContent — Inserción de texto normal en lugar de marcado.
  6. MDN XMLSerializer — Guardado del marcado SVG ya distribuido.

Para tu ilustración ráster, prepara un clipart editable, inspecciona sus formas y conserva el maestro. Añade el texto por separado y verifica después las líneas aceptadas y las letras visibles en el SVG guardado antes de entregar.

Más del blog

PerfectVector

Empieza con un SVG más limpio
y fácil de editar

Sin tarjeta de crédito