Saltar al contingut principal
Recepta número 88

Receptari · Capítol 5 · Estructura del llibre

Una ponència a dues columnes en estil IEEE

Una ponència a dues columnes: títol a tota l'amplada, citacions IEEE amb intervals [2]–[4], remissions a la secció II i una llista numerada compacta.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Intermedi
Postext
Provada amb Postext 1.12.0
Requereix ≥ 1.12.0 · postext-pdf ≥ 1.12.0
Llicència
Actualitzada el 1 d’oct. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 215,9 × 279,4 mm
  • 2 columnes, espai entre columnes de 6,3 mm
  • STIX Two Text 10/12
  • Schibsted Grotesk
  • 2 pàgines
  • Nivell
  • Postext 1.12.0
  • Compost en 262 ms
  • 128 línies de codi

En poques paraules

Un article breu per a un congrés, a dues columnes. L'autor escriu un codi per a cada font; Postext numera les citacions entre claudàtors, remet a les seccions per número i pàgina i imprimeix al final la llista numerada de referències.

Què compondràs

Una ponència de dues pàgines per a un petit taller d'enginyeria documental, composta com componen les seves les actes dels congressos de l'IEEE: carta US, dues columnes de tres polzades i mitja, una lletra de tipus Times a 10 pt i seccions numerades I, II, III en majúscules centrades. El títol, els tres blocs d'autor, el resum i les paraules clau ocupen les dues columnes sota una banda pàl·lida en el blau del taller, l'únic color de les pàgines. Els autors citen amb claus de BibTeX; Postext imprimeix [1] en l'ordre en què se cita cada obra per primera vegada, agrupa tres obres seguides com [2]–[4], escriu «Knuth i Plass [1]» quan els noms formen part de la frase i munta al final la llista numerada a 8 pt. Les remissions a seccions diuen «la secció IV, a la pàg. 2» i continuen sent certes quan el text es mou.

Aquesta recepta respon a

  • Com cito obres i componc la bibliografia en APA, IEEE o un altre estil de cita?
  • Com remeto a una secció i a la pàgina on és, i mantinc totes dues correctes quan el llibre canvia?

La resposta curta

script.js · línies 32–46al codi complet
// [@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
  },
};

Ingredients

Tipografia
STIX Two Text, Schibsted Grotesk (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · L'estil IEEE, amb els seus intervals

El codi és la resposta curta de més amunt. Un cop registrat, postext-citeproc compon cada [@clave] amb citeproc-js i l'estil CSL d'IEEE, i numera les obres en l'ordre en què se citen per primera vegada. El fitxer d'estil que porta el motor escriu tres obres seguides com [2], [3], [4]; la guia editorial d'IEEE les uneix com [2]–[4]. N'hi ha prou d'afegir collapse="citation-number" a l'element <citation> de l'estil i passar el resultat com a customStyle perquè citeproc-js ho faci. labelWidth dona als números de la llista una columna pròpia, de manera que la segona línia de cada entrada comença sota la primera paraula i no sota el claudàtor.

#2 · Un bloc de títol a dues columnes

script.js · línies 50–72al codi complet
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ítol és l'únic títol de nivell 1 del document, amb l'estil paper. L'estil ocupa tota la pàgina i dibuixa el títol amb el seu disseny: la línia del taller, el títol i tres blocs d'autor d'un terç de la mesura, cadascun llegit d'un atribut del títol el \n del qual separa les línies. La banda és una caixa ancorada al tall, així que arriba fins a la vora superior del paper.

#3 · Resum i paraules clau en negreta

script.js · línies 76–82al codi complet
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 requadre a tota l'amplada porta el resum i les paraules clau. El cos va a 9 pt en negreta, com el compon IEEE, i ***Resumen*—** al text dona l'etiqueta en cursiva amb la seva ratlla. El filet de dalt és la franja del requadre, i el farciment estreny la mesura a un centenar de caràcters.

#4 · Seccions numerades i remissions a les seccions

script.js · línies 86–97al codi complet
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 les seccions I., II., III. Una remissió imprimeix el número sense el punt, així que :ref{id="sec:results"} dona «secció IV» amb la plantilla de crossRefs, i :ref{id="sec:results" style=page} dona «pàg. 2». Totes dues es calculen sobre les pàgines ja compostes, de manera que segueixen el text si s'afegeix un paràgraf o una secció canvia de lloc. Els títols d'agraïments i referències porten l'estil back i queden sense número.

script.js · línies 16–22al codi complet
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 recepta 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`---
Mostra en Markdown · 91 línies · 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 a totes les receptes · 275 línies// ─── 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Deixa l'estil tal com ve

Sense el canvi, tres obres seguides s'imprimeixen una per una, com prefereixen algunes revistes.

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

#Escriu els intervals dins d'un sol parell de claudàtors

Altres estils numèrics imprimeixen [2–4]. La marca brackets escriu els números pel seu compte i uneix els consecutius.

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

Errors freqüents

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

Error freqüent

Posa entre cometes cada valor del frontmatter

YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984". Metadades del document →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

  • Si les dues columnes de l'última pàgina no acaben a la mateixa altura, mira què queda on es tallarien: el motor no deixa un títol de secció al peu d'una columna, lluny del seu text, i renuncia al tall. Una frase més o menys a les últimes seccions porta el tall més enllà del títol.
  • Les ordres de BibTeX com {\TeX} no s'expandeixen. Escriu The {TeX}book; les claus mantenen les majúscules com són.

Crèdits

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