Saltar al contenido principal
Receta número 88

Recetario · Capítulo 5 · Estructura del libro

Una ponencia a dos columnas en estilo IEEE

Una ponencia a dos columnas: título a todo el ancho, citas IEEE con intervalos [2]–[4], remisiones a la sección II y una lista numerada compacta.

En esta página
Salida
Canvas · PDF
Postext
Probada con Postext 1.12.0
Requiere ≥ 1.12.0 · postext-pdf ≥ 1.12.0
Licencia
Actualizada el 1 oct 2026
Código MIT · Texto CC BY 4.0
  • Formato 215,9 × 279,4 mm
  • 2 columnas, medianil de 6,3 mm
  • STIX Two Text 10/12
  • Schibsted Grotesk
  • 2 páginas
  • Nivel
  • Postext 1.12.0
  • Compuesto en 262 ms
  • 128 líneas de código

En pocas palabras

Un artículo breve para un congreso, a dos columnas. El autor escribe un código para cada fuente; Postext numera las citas entre corchetes, remite a las secciones por número y página e imprime al final la lista numerada de referencias.

Lo que vas a componer

Una ponencia de dos páginas para un pequeño taller de ingeniería documental, compuesta como componen las suyas las actas de los congresos del IEEE: carta US, dos columnas de tres pulgadas y media, una letra de tipo Times a 10 pt y secciones numeradas I, II, III en mayúsculas centradas. El título, los tres bloques de autor, el resumen y las palabras clave ocupan las dos columnas bajo una banda pálida en el azul del taller, el único color de las páginas. Los autores citan con claves de BibTeX; Postext imprime [1] en el orden en que se cita cada obra por primera vez, agrupa tres obras seguidas como [2]–[4], escribe «Knuth y Plass [1]» cuando los nombres forman parte de la frase y monta al final la lista numerada a 8 pt. Las remisiones a secciones dicen «la sección IV, en la pág. 2» y siguen siendo ciertas cuando el texto se mueve.

Esta receta responde a

  • ¿Cómo cito obras y compongo la bibliografía en APA, IEEE u otro estilo de cita?
  • ¿Cómo remito a una sección y a la página en que está, y mantengo ambas correctas cuando el libro cambia?

La respuesta corta

script.js · líneas 32–46en el código completo
// [@key] prints [1] in order of first citation, @key in the sentence "Knuth and Plass [1]".
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
// The bundled IEEE style lists [2], [3], [4]; the IEEE editorial guide writes [2]–[4].
// One attribute on the CSL <citation> element makes citeproc-js join the run.
const ieee = STYLES.ieee.replace('<citation>', '<citation collapse="citation-number">');
const citations = {
  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
  link: true,
  bibliography: {
    fontSize: em(0.8), lineHeight: pt(9.4), // 8 pt on 9.4 pt, two sizes under the text
    labelWidth: mm(6), // the [n] column: wide enough for [10], the turnovers align after it
    entrySpacing: pt(1.2),
    doi: 'text', // printed, not linked: the PDF stays black
  },
};

Ingredientes

Tipografía
STIX Two Text, Schibsted Grotesk (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · El estilo IEEE, con sus intervalos

El código es la respuesta corta de arriba. Registrado una vez, postext-citeproc compone cada [@clave] con citeproc-js y el estilo CSL de IEEE, y numera las obras en el orden en que se citan por primera vez. El archivo de estilo que trae el motor escribe tres obras seguidas como [2], [3], [4]; la guía editorial de IEEE las une como [2]–[4]. Basta añadir collapse="citation-number" al elemento <citation> del estilo y pasar el resultado como customStyle para que citeproc-js lo haga. labelWidth da a los números de la lista una columna propia, de modo que la segunda línea de cada entrada empieza bajo la primera palabra y no bajo el corchete.

#2 · Un bloque de título a dos columnas

script.js · líneas 50–72en el código completo
const text = (id, content, size, extra) => ({ kind: 'text', id, content, fontFamily: SERIF,
  fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const AUTHOR_W = MEASURE / 3;
const titleBlock = {
  enabled: true,
  minHeight: mm(64), // venue line, two lines of title and five of author block
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('tint') }, // from the trim's top
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(TOP + 66) } } },
    text('venue', '{attr.venue}', 7.5, { fontFamily: SANS, fontWeight: 600, color: col('accent'),
      letterSpacing: pt(1.2), textTransform: 'uppercase',
      placement: at('container', 'top-left', 0, 0, MEASURE) }),
    { kind: 'rule', id: 'venue-rule', thickness: pt(0.5), color: col('rule'),
      placement: at('#venue', 'below', 0, 2, MEASURE) },
    text('title', '{titleText}', 23, { lineHeight: 1.1, placement: at('#venue', 'below', 0, 7,
      MEASURE) }),
    ...['a1', 'a2', 'a3'].map((id, i) => text(id, `{attr.${id}}`, 9.5, { lineHeight: 1.25,
      placement: at('#title', 'below', i * AUTHOR_W, 6, AUTHOR_W) })),
  ] },
};

El título es el único título de nivel 1 del documento, con el estilo paper. El estilo ocupa toda la página y dibuja el título con su diseño: la línea del taller, el título y tres bloques de autor de un tercio de la medida, cada uno leído de un atributo del título cuyo \n separa las líneas. La banda es una caja anclada al corte, así que llega hasta el borde superior del papel.

#3 · Resumen y palabras clave en negrita

script.js · líneas 76–82en el código completo
const calloutStyles = [{ id: 'abstract', span: 'page', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: true, side: 'top', width: pt(0.5),
    color: col('rule') },
  padding: { top: mm(3), right: mm(14), bottom: mm(1), left: mm(14) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontSize: pt(9), lineHeight: pt(11), fontWeight: 700, firstLineIndent: pt(0),
    textAlign: 'justify', paragraphSpacing: true } }];

Un recuadro a todo el ancho lleva el resumen y las palabras clave. Su cuerpo va a 9 pt en negrita, como lo compone IEEE, y ***Resumen*—** en el texto da la etiqueta en cursiva con su raya. El filete de arriba es la franja del recuadro, y el relleno estrecha la medida a unos cien caracteres.

#4 · Secciones numeradas y remisiones a ellas

script.js · líneas 86–97en el código completo
const levels = [
  { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // gotcha: headings-drop-h1-break
  { level: 2, numberingTemplate: '{2:I}.', numberSeparator: ' ', fontSize: pt(9),
    lineHeight: pt(LEAD), fontWeight: 400, letterSpacing: em(0.06), textTransform: 'uppercase',
    marginTop: pt(LEAD), marginBottom: pt(0) }, // a line above, none below: at a column's head
  // the margin above drops and the text still starts on the next grid line
];
const headingStyles = [
  { id: 'paper', numbered: false, span: 'page', advancedDesign: titleBlock },
  { id: 'back', numbered: false }, // Acknowledgment and References: no number
];
const crossRefs = { section: t({ en: 'Section {n}', es: 'sección {n}' }) };

{2:I}. numera las secciones I., II., III. Una remisión imprime el número sin el punto, así que :ref{id="sec:results"} da «sección IV» con la plantilla de crossRefs, y :ref{id="sec:results" style=page} da «pág. 2». Las dos se calculan sobre las páginas ya compuestas, de modo que siguen al texto si se añade un párrafo o una sección cambia de sitio. Los títulos de agradecimientos y referencias llevan el estilo back y quedan sin número.

script.js · líneas 16–22en el código completo
const palette = {
  ink: '#16181d', accent: '#1d4a7a', tint: '#e9eef5', rule: '#aeb6c2', muted: '#5a606b',
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 088 · A two-column conference paper in IEEE style ═══════
// https://postext.dev/en/cookbook/ieee-conference-paper
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: STIX Two Text, Schibsted Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.12.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'ieee-conference-paper';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black type on white, with one blue for the workshop's own marks
const palette = {
  ink: '#16181d', accent: '#1d4a7a', tint: '#e9eef5', rule: '#aeb6c2', muted: '#5a606b',
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [SERIF, SANS] = ['STIX Two Text', 'Schibsted Grotesk'];
// The IEEE conference template in mm: US letter, 0.75 in head, 1 in foot, 0.625 in sides,
// two columns of 3.5 in with 0.25 in between.
const [TRIM_W, TRIM_H, TOP, BOTTOM, SIDE, GUTTER] = [215.9, 279.4, 19, 25.4, 15.9, 6.35];
const MEASURE = TRIM_W - 2 * SIDE;
const LEAD = 12; // pt: 10 pt type on 12 pt, as the template sets it

// #region answer: IEEE numbers, collapsed ranges and a references list with a label column
// [@key] prints [1] in order of first citation, @key in the sentence "Knuth and Plass [1]".
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
// The bundled IEEE style lists [2], [3], [4]; the IEEE editorial guide writes [2]–[4].
// One attribute on the CSL <citation> element makes citeproc-js join the run.
const ieee = STYLES.ieee.replace('<citation>', '<citation collapse="citation-number">');
const citations = {
  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
  link: true,
  bibliography: {
    fontSize: em(0.8), lineHeight: pt(9.4), // 8 pt on 9.4 pt, two sizes under the text
    labelWidth: mm(6), // the [n] column: wide enough for [10], the turnovers align after it
    entrySpacing: pt(1.2),
    doi: 'text', // printed, not linked: the PDF stays black
  },
};
// #endregion

// #region title: the title block across both columns, three authors side by side
const text = (id, content, size, extra) => ({ kind: 'text', id, content, fontFamily: SERIF,
  fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const AUTHOR_W = MEASURE / 3;
const titleBlock = {
  enabled: true,
  minHeight: mm(64), // venue line, two lines of title and five of author block
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('tint') }, // from the trim's top
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(TOP + 66) } } },
    text('venue', '{attr.venue}', 7.5, { fontFamily: SANS, fontWeight: 600, color: col('accent'),
      letterSpacing: pt(1.2), textTransform: 'uppercase',
      placement: at('container', 'top-left', 0, 0, MEASURE) }),
    { kind: 'rule', id: 'venue-rule', thickness: pt(0.5), color: col('rule'),
      placement: at('#venue', 'below', 0, 2, MEASURE) },
    text('title', '{titleText}', 23, { lineHeight: 1.1, placement: at('#venue', 'below', 0, 7,
      MEASURE) }),
    ...['a1', 'a2', 'a3'].map((id, i) => text(id, `{attr.${id}}`, 9.5, { lineHeight: 1.25,
      placement: at('#title', 'below', i * AUTHOR_W, 6, AUTHOR_W) })),
  ] },
};
// #endregion

// #region abstract: abstract and index terms in bold 9 pt, between two hairlines
const calloutStyles = [{ id: 'abstract', span: 'page', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: true, side: 'top', width: pt(0.5),
    color: col('rule') },
  padding: { top: mm(3), right: mm(14), bottom: mm(1), left: mm(14) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontSize: pt(9), lineHeight: pt(11), fontWeight: 700, firstLineIndent: pt(0),
    textAlign: 'justify', paragraphSpacing: true } }];
// #endregion

// #region sections: I. INTRODUCTION, centred capitals; references print "Section II"
const levels = [
  { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // gotcha: headings-drop-h1-break
  { level: 2, numberingTemplate: '{2:I}.', numberSeparator: ' ', fontSize: pt(9),
    lineHeight: pt(LEAD), fontWeight: 400, letterSpacing: em(0.06), textTransform: 'uppercase',
    marginTop: pt(LEAD), marginBottom: pt(0) }, // a line above, none below: at a column's head
  // the margin above drops and the text still starts on the next grid line
];
const headingStyles = [
  { id: 'paper', numbered: false, span: 'page', advancedDesign: titleBlock },
  { id: 'back', numbered: false }, // Acknowledgment and References: no number
];
const crossRefs = { section: t({ en: 'Section {n}', es: 'sección {n}' }) };
// #endregion

const footer = { elements: [
  { kind: 'text', id: 'folio', content: '{pageNumber}', fontFamily: SANS, fontSize: pt(7.5),
    color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(SIDE), y: mm(-13) },
      size: { width: mm(MEASURE) } } },
] };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }),
  colorPalette, citations, crossRefs, calloutStyles, headingStyles,
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(SIDE), right: mm(SIDE) } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: SERIF, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    referenceBold: false, // "Section II" sits in the text like the citations
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: SERIF, color: col('ink'), textAlign: 'center', levels },
  header: { elements: [] },
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 91 líneas · content.es.mdtitle: "Corte óptimo de líneas en el navegador" author: "Irene Valcárcel, Tomás Brandt y Aiko Nwosu" --- # Corte óptimo de líneas en el navegador: \\ cuánto cuesta y cuándo compensa {style="paper" venue="DocWeb ’26 · Taller de Ingeniería Documental para la Web" a1="Irene Valcárcel\nDpto. de Ciencias de la Computación\nUniversidad de Almenara\nAlmenara, España\nivalcarcel@almenara.example" a2="Tomás Brandt\nGrupo de Composición Tipográfica\nNorthgate College\nDunmore, Reino Unido\ntbrandt@northgate.example" a3="Aiko Nwosu\nEscuela de Diseño\nHarrow Hill Institute\nHarrow Hill, Canadá\nanwosu@harrowhill.example"} :::callout{type="abstract"} ***Resumen*—**Los navegadores cortan el texto justificado línea a línea, y las líneas flojas que eso deja son la principal queja contra el texto justificado en pantalla. El método de ajuste total que usa TeX elige a la vez los cortes de todo el párrafo y evita la mayoría, pero se lo considera demasiado lento para una página que se recompone cada vez que cambia de tamaño. Compusimos un corpus de 2400 párrafos en cuatro lenguas a seis anchos de columna con ambos métodos, con un programa que se ejecuta en el navegador, y medimos el tiempo por párrafo y el espaciado de cada línea. El ajuste total tardó 0,21 ms por párrafo en un portátil de gama media, 3,4 veces lo que tarda el primer ajuste, y redujo la proporción de líneas cuyos espacios se estiran más de una vez y media su ancho natural del 11,8 % al 1,9 %. La mejora es mayor en columnas estrechas y en alemán. Concluimos que el coste es asumible para un texto que se compone una vez por cambio de tamaño, y damos una regla para saber cuándo basta con el primer ajuste. ***Palabras clave*—**corte de líneas, justificación, división silábica, composición tipográfica, navegadores web, rendimiento ::: ## Introducción {#sec:intro} El navegador corta un párrafo justificado línea a línea. Cada línea toma tantas palabras como caben, y el espacio sobrante se reparte entre ellas. El método es rápido y previsible, y explica la mala fama del texto justificado en la web, con sus calles y sus líneas flojas: una línea seguida de una palabra larga tiene que llenar con espacio el hueco de esa palabra, y nada de lo que hay antes en el párrafo puede ayudarla. Los impresores disponen de un método mejor desde hace cuarenta años. @knuthplass1981 tratan el párrafo como un todo. Cada corte posible es un nodo de un grafo, cada línea una arista con su peso, y los cortes elegidos son los del camino de menor penalización total, de modo que una línea algo apretada al principio puede ahorrar otra muy floja más adelante. Los patrones de división de Liang, el trabajo de Plass sobre el corte de páginas y el propio TeX salieron del mismo proyecto de Stanford [@liang1983; @plass1981; @knuth1984], y el método sigue siendo la referencia con la que se juzga cualquier otro. No ha llegado al navegador. La razón que suele darse es la velocidad: una página que se recompone cada vez que la ventana cambia de tamaño no puede permitirse una búsqueda en cada párrafo. Ponemos a prueba esa razón. La :ref{id="sec:related"} sitúa la cuestión entre los trabajos anteriores, la :ref{id="sec:method"} describe el corpus y el banco de medida, y la :ref{id="sec:results"}, en la :ref{id="sec:results" style=page}, da los resultados. La :ref{id="sec:discussion"} los convierte en una regla que un diseñador de páginas puede aplicar. ## Trabajos relacionados {#sec:related} @knuthplass1981 describieron el algoritmo de ajuste total por completo, con el modelo de cajas, pegamento y penalizaciones que conservaron las implementaciones posteriores. En el peor caso la búsqueda es cuadrática, pero un corte factible solo puede caer a menos de una línea del anterior, y en la práctica la lista de cortes candidatos se mantiene corta. Los autores dan tiempos para un ordenador central de la época; no conocemos ninguna medida en un motor de navegador moderno. La división de palabras es la otra mitad del problema. Los patrones de @liang1983 encuentran la mayoría de los cortes admisibles de una palabra inglesa con una tabla de unos pocos miles de entradas, y en ellos se basan los diccionarios de división que todavía incluyen navegadores y procesadores de texto. Un algoritmo de ajuste total que no puede dividir palabras pierde casi toda su ventaja en columnas estrechas, y por eso medimos las dos cosas juntas. La práctica tipográfica fija el objetivo. @bringhurst2004 pide una medida de 45 a 75 caracteres y considera los espacios que se abren más allá de su ancho natural la primera señal de un párrafo mal compuesto. Los estudios de lectura sugieren por qué: el ojo avanza en sacadas de siete a nueve caracteres, y una textura irregular cambia el punto donde cae [@rayner1998]. En pantalla, @dyson2001 observaron que la longitud de línea afecta de modo distinto a la velocidad y a la comprensión, lo que aconseja no juzgar una maqueta por un solo número. Aquí medimos el espaciado de las líneas y no la lectura, que dejamos para un trabajo posterior. ## Método {#sec:method} El corpus tiene 2400 párrafos, 600 en cada lengua (inglés, español, alemán y francés), tomados de novelas y ensayos de dominio público. Se descartaron los párrafos de menos de cuatro líneas en la medida más ancha, porque un párrafo tan corto deja poco que elegir. Cada párrafo se compuso a seis anchos de columna, de 30 a 80 caracteres de la letra del texto, con dos algoritmos: el primer ajuste, que toma la línea más larga que cabe, y el ajuste total descrito en [-@knuthplass1981]. Los dos usaron los mismos patrones de Liang para cada lengua, el mismo pegamento (un espacio de un tercio de cuadratín que puede estirarse la mitad y encogerse un tercio) y la misma letra, medida una vez por palabra con la API de texto del canvas. El programa que lo hace se ejecuta en el navegador, sin servidor. En cada composición registramos el tiempo de corte del párrafo, sin contar la medida de las palabras, que ambos métodos comparten, y la razón de estiramiento de cada línea salvo la última. Una línea cuya razón pasa de 1,5 cuenta como floja. Los tiempos son de un portátil con un procesador de gama media de 2023, en la versión estable de tres navegadores, con cada párrafo medido cincuenta veces tras diez de calentamiento. ## Resultados {#sec:results} El ajuste total tardó de media 0,21 ms por párrafo, frente a 0,062 ms del primer ajuste: una razón de 3,4. La razón creció con el ancho de la columna, de 2,6 a 30 caracteres a 4,1 a 80, porque una línea más ancha admite más cortes candidatos. El párrafo más lento, uno alemán de 31 líneas a 80 caracteres, tardó 1,9 ms. Un artículo largo de 120 párrafos se corta en unos 25 ms, muy por debajo del tiempo que un navegador se concede para responder a un cambio de tamaño. El espaciado mejoró en todas las lenguas y en todos los anchos. En el conjunto del corpus, la proporción de líneas flojas bajó del 11,8 % al 1,9 %. En columnas de 30 a 40 caracteres, la medida de una página a dos columnas en un teléfono, bajó del 27 % al 4,6 %. El alemán fue el que más ganó, del 16,3 % al 2,2 %, porque sus compuestos largos dejan al primer ajuste las decisiones más difíciles; el inglés, el que menos, del 8,9 % al 1,6 %. El número de líneas con división aumentó en una quinta parte con el ajuste total, que acepta un guion donde así se ahorra una línea floja más abajo. A partir de 70 caracteres, el primer ajuste dejó flojas menos del 4 % de sus líneas en todas las lenguas. Con esa medida la diferencia entre los métodos apenas se ve en la página, y un lector que viera juntas las dos composiciones de un mismo párrafo rara vez sabría decir cuál es cuál. ## Discusión {#sec:discussion} El ajuste total cuesta unas décimas de milisegundo por párrafo, y el texto de una página corriente se corta en menos tiempo del que el navegador tarda en pintarla. Para un texto que se compone una vez y después se lee, como un artículo, un capítulo de libro o una ponencia como esta, el coste no es un argumento en contra. La edición en vivo es otro caso: allí solo hay que volver a cortar el párrafo que se edita, y el tiempo de un párrafo es pequeño. Los resultados dan también una regla para saber cuándo basta con el primer ajuste. En una sola columna de 70 caracteres o más, el lector rara vez encontrará una línea floja con cualquiera de los dos métodos, y el diseñador que no puede elegir el algoritmo pierde poco. En columnas estrechas, y en lenguas de palabras largas, la diferencia es grande y se ve, y el ajuste total con división de palabras es el método que hay que pedir. Coincide con el consejo de los impresores [@bringhurst2004, cap. 2], que prefieren componer en bandera una columna estrecha antes que justificarla sin cuidado. ## Conclusión Medimos en un navegador el coste de cortar párrafos como lo hace TeX y resultó pequeño: 0,21 ms por párrafo, 3,4 veces lo que cuesta el método del propio navegador, a cambio de seis veces menos líneas flojas. La objeción al ajuste total en pantalla descansa en la velocidad, y con el equipo actual la velocidad ya no la sostiene. ## Agradecimientos {style="back"} Los autores agradecen sus comentarios al borrador a los lectores del comité de DocWeb ’26. Compuesto en STIX Two Text y Schibsted Grotesk (SIL OFL). Texto: original, CC BY 4.0. Los autores, las instituciones y las medidas son inventados para este ejemplo. ## Referencias {style="back"} :::bibliography{title=""} :::references{format=bibtex} @article{knuthplass1981, author = {Knuth, Donald E. and Plass, Michael F.}, title = {Breaking paragraphs into lines}, journal = {Software: Practice and Experience}, volume = 11, number = 11, pages = {1119--1184}, year = 1981, doi = {10.1002/spe.4380111102}} @phdthesis{liang1983, author = {Liang, Franklin Mark}, title = {Word Hy-phen-a-tion by Com-put-er}, school = {Stanford University}, address = {Stanford, CA}, year = 1983} @phdthesis{plass1981, author = {Plass, Michael Frederick}, title = {Optimal Pagination Techniques for Automatic Typesetting Systems}, school = {Stanford University}, address = {Stanford, CA}, year = 1981} @book{knuth1984, author = {Knuth, Donald E.}, title = {The {TeX}book}, publisher = {Addison-Wesley}, address = {Reading, MA}, year = 1984} @book{bringhurst2004, author = {Bringhurst, Robert}, title = {The Elements of Typographic Style}, edition = {3rd}, publisher = {Hartley \& Marks}, address = {Point Roberts, WA}, year = 2004} @article{rayner1998, author = {Rayner, Keith}, title = {Eye movements in reading and information processing: 20 years of research}, journal = {Psychological Bulletin}, volume = 124, number = 3, pages = {372--422}, year = 1998, doi = {10.1037/0033-2909.124.3.372}} @article{dyson2001, author = {Dyson, Mary C. and Haselgrove, Mark}, title = {The influence of reading speed and line length on the effectiveness of reading from screen}, journal = {International Journal of Human-Computer Studies}, volume = 54, number = 4, pages = {585--612}, year = 2001, doi = {10.1006/ijhc.2001.0458}} :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'STIX Two Text': ['400', '400i', '700', '700i'], 'Schibsted Grotesk': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'An IEEE conference paper', es: 'Una ponencia en estilo IEEE' }); showPages(doc, { title }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf: igual en todas las recetas · 275 líneas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)

Variantes

#Deja el estilo como viene

Sin el cambio, tres obras seguidas se imprimen una a una, como prefieren algunas revistas.

-  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
+  style: 'ieee',

#Escribe los intervalos dentro de un solo par de corchetes

Otros estilos numéricos imprimen [2–4]. La marca brackets escribe los números por su cuenta y une los consecutivos.

-  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
+  style: 'ieee', marker: 'brackets', collapseRanges: true,

Errores frecuentes

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

Error frecuente

Entrecomilla cada valor del frontmatter

YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →

Error frecuente

Una configuración se cachea por identidad: crea un objeto nuevo

El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →

  • Si las dos columnas de la última página no acaban a la misma altura, mira qué queda donde se cortarían: el motor no deja un título de sección al pie de una columna, lejos de su texto, y renuncia al corte. Una frase más o menos en las últimas secciones lleva el corte más allá del título.
  • Las órdenes de BibTeX como {\TeX} no se expanden. Escribe The {TeX}book; las llaves mantienen las mayúsculas como están.

Créditos

Texto
Texto original, CC BY 4.0
Fuentes
STIX Two Text (SIL OFL 1.1) · Schibsted Grotesk (SIL OFL 1.1)
SandboxPDF