Saltar al contenido principal
Receta número 177

Recetario · Capítulo 1 · Página y retícula

La columna que la regla de viudas dejaba corta

Columnas en bandera que acaban en la rejilla: fillShortColumns alarga o acorta una línea un párrafo de la columna corta en vez de dejar un hueco al pie.

En esta página
Salida
Canvas
Postext
Probada con Postext 1.65.0
Requiere ≥ 1.65.0
Licencia
Actualizada el 31 oct 2026
Código MIT · Texto CC BY 4.0

p. 1 · 4 de 6

  • Formato 225 × 297 mm
  • 2 columnas, medianil de 7 mm
  • Public Sans 9,4/13,2
  • Libre Caslon Display
  • Libre Caslon Text
  • 6 páginas
  • Nivel
  • Postext 1.65.0
  • Compuesto en 92 ms
  • 162 líneas de código

En pocas palabras

Cuando una columna se queda una línea por encima del pie porque un párrafo no se puede partir ahí, este ajuste vuelve a cortar las líneas de un párrafo de esa columna para que llegue abajo.

Lo que vas a componer

Tres páginas de Litoral, una revista de costa: un reportaje largo sobre la lente de Fresnel que abre con la pintura de una lente encendida en su linterna. El texto va en dos columnas en bandera de Public Sans, con una línea de rejilla en blanco entre párrafos, y las reglas de viudas y huérfanas dejan al menos dos líneas de cada párrafo a cada lado de un corte. El pen compone el reportaje dos veces. Sin el ajuste, en la apertura la columna derecha se queda una línea por encima de la izquierda: el párrafo siguiente no puede empezar con una sola línea al pie. Con él, en la misma página, el párrafo de esa columna se corta en una línea más y las dos columnas acaban en la última línea de la rejilla. En la página siguiente, el ajuste también recoge el aire de más que el equilibrado ponía sobre un ladillo.

Esta receta responde a

  • ¿Por qué una columna acaba una o dos líneas antes del pie, y cómo la relleno sin romper las reglas de viudas y huérfanas?
  • ¿Cómo consigo columnas a ras del pie y una última página equilibrada (justificación vertical)?
  • ¿Cómo evito viudas, huérfanas y últimas líneas de una sola palabra, y mantengo cada título con su texto?

La respuesta corta

script.js · líneas 37–55en el código completo
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};

Ingredientes

Tipografía
Public Sans, Libre Caslon Display, Libre Caslon Text (SIL OFL 1.1)
Recursos
  • lens-1400.jpg
  • Una lente de Fresnel encendida en la linterna de un faro al anochecer (Generated With Diffusion Models, original)

Elaboración

#1 · Dale a la columna en bandera una palanca propia

script.js · líneas 37–55en el código completo
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};

El equilibrado de columnas rellena una columna corta con líneas de rejilla sobre un título, tras una lista, bajo un recuadro o una figura y, como último recurso, componiendo un párrafo justificado una línea más holgado. Una columna de texto corrido en bandera no tiene nada de eso entre sus ladillos, así que la línea que retienen las reglas de viudas y huérfanas se queda vacía. fillShortColumns deja que un párrafo en bandera de esa columna ocupe una línea más con su propio espaciado; cuando ninguno puede, compone uno con una línea menos para que el párrafo de después del corte suba y ocupe esa línea. El motor solo conserva el cambio si acaban cortas menos columnas, así que activarlo nunca empeora una página. Con maxLinesBeforeLoose: 0 se prueban los párrafos antes que los títulos, y cada ladillo conserva una sola línea de aire encima.

#2 · Compón el reportaje dos veces y compara

script.js · líneas 204–208en el código completo
const withFill = (fill) => ({ ...config, headings: { ...config.headings,
  balancing: { ...balancing, fillShortColumns: fill } } }); // a copy: config keeps true
const build = (fill) => buildDocumentWithFonts({ markdown, resources }, withFill(fill),
  kitFonts(FONTS));
const docs = { off: await build(false), on: await build(true) };

Los dos documentos salen de una misma configuración y un mismo Markdown. La segunda composición pasa la configuración tal cual; la primera, una copia con el ajuste apagado, de modo que el paquete que se abre en el Sandbox lleva fillShortColumns: true. Los dos botones sobre la mesa cambian de una composición a otra. Solo se llenan hasta el pie las páginas que siguen: la página 3 cierra el reportaje y sus columnas se cortan a la misma altura, igual en las dos composiciones.

#3 · Abre con la pintura

script.js · líneas 59–85en el código completo
const DEPTH = 150; // mm: how far down the page the painting bleeds
const sans = { fontFamily: 'Public Sans', fontWeight: 700, textTransform: 'uppercase' };
const at = (to, edge, x, y, width) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const air = { padding: { bottom: mm(6) } }; // counts: the text starts 6 mm under the byline
const opener = {
  enabled: true,
  slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'lens', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
      fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
      placement: at('container', 'top-right', 0, DEPTH - TOP + 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
      letterSpacing: pt(1.7), color: col('brass'), align: 'left',
      placement: at('container', 'top-left', 0, DEPTH - TOP + 9) },
    { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(46), lineHeight: 1.02, color: col('ink'), align: 'left',
      placement: at('#kicker', 'below', 0, 2.5, 112) },
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: 'Libre Caslon Text',
      italic: true, fontSize: pt(12), lineHeight: 1.3, color: col('sea'), align: 'left',
      placement: at('#headline', 'right-of', 8, 2.5) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
      letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
      placement: at('#standfirst', 'below', 0, 3.5) },
  ] },
};

La pintura es un elemento de imagen anclado al sangrado, de 150 mm de alto, así que llega al corte por tres lados, algo que ningún flotante hace. El antetítulo, el título, la entradilla y la firma salen de los atributos de la línea #. La firma termina en un relleno de 6 mm que cuenta en la altura de la apertura, así que las columnas empiezan en la primera línea de rejilla por debajo.

#4 · Cabeceras fuera de la apertura

script.js · líneas 89–106en el código completo
const label = { ...sans, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.3),
  color: col('muted') };
const folio = { fontFamily: 'Public Sans', fontWeight: 700, fontSize: pt(8.5), color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER, 12) },
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 9), 12.35) }, // the smaller size's baseline
  { kind: 'rule', id: `rule-${parity}`, direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { ...pin(`top-${edge}`, s * OUTER, 17),
      size: { width: mm(197) } } },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the opener
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // the opener's only folio

La página par nombra la revista y el número desde el frontmatter, la impar el reportaje, y pages: 'body' las saca de la apertura, que solo lleva un folio al pie. La etiqueta baja 0,35 mm respecto al folio para que los dos cuerpos compartan línea base. El filete que va debajo mide 197 mm, el ancho de la caja de texto.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 177 · Filling the column the widow rule left short ═════
// https://postext.dev/en/cookbook/filling-short-columns
// Code: MIT · Text: original (CC BY 4.0) · Painting: diffusion model
// Fonts: Public Sans, Libre Caslon Display, Libre Caslon Text (OFL) · Needs postext ≥ 1.65.0
//
// A magazine long read in two ragged columns, set twice. Without fillShortColumns a column
// whose paragraph would split into a widow ends a line above the foot; with it, a paragraph
// of that column is set a line longer or shorter, and the column ends on the last grid line.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'filling-short-columns';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#1b2026', // text: a slate near-black
  brass: '#875a14', // the accent: kicker and crossheads (6:1 on paper)
  sea: '#2c4a5e', // the standfirst
  rule: '#c9ccd0', // hairlines
  muted: '#5d656e', // running heads, credit, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'brass (defaults)', value: { hex: palette.brass, model: 'hex' } },
];
const TOP = 22; // top margin in mm: the opener's container starts here
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.2; // body leading in pt: the baseline grid every column ends on

// #region answer: ragged columns that end on the grid: fillShortColumns
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {

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 que los ladillos tomen el hueco primero

Con el orden por defecto, un título toma hasta dos líneas de rejilla antes de que un párrafo se alargue, y los ladillos de la página siguiente vuelven a abrirse.

-  maxLinesBeforeLoose: 0,
+  maxLinesBeforeLoose: 2,

#Justifica el texto

Un párrafo justificado ya podía ocupar una línea más sin el ajuste, dentro de maxWordSpacing. Con fillShortColumns también puede ocupar una línea menos, y nunca se compone más holgado que su línea más holgada.

-  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+  textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between

Errores frecuentes

  • El ajuste cierra un hueco de una línea. Una columna a la que le faltan dos, por ejemplo una que acaba tras el blanco entre párrafos con sitio para una sola línea del siguiente, puede quedarse corta: ajusta el texto con una frase.
  • No se usa un párrafo cuya nueva composición acabaría en una línea corta, así que una columna puede quedarse corta cuando solo tiene párrafos breves.

Créditos

Texto
Texto original, CC BY 4.0
Imágenes
  • Una lente de Fresnel encendida en la linterna de un faro al anochecer · Generated With Diffusion Models · original
Fuentes
Public Sans (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Libre Caslon Text (SIL OFL 1.1)
Sandbox