En poques paraules
Quan una columna es queda una línia per sobre del peu perquè un paràgraf no es pot partir allà, aquest ajust torna a tallar les línies d'un paràgraf d'aquesta columna perquè arribi a baix.
Què compondràs
Tres pàgines de Litoral, una revista de costa: un reportatge llarg sobre la lent de Fresnel que s'obre amb la pintura d'una lent encesa a la seva llanterna. El text va en dues columnes en bandera de Public Sans, amb una línia de reixeta en blanc entre paràgrafs, i les regles de vídues i òrfenes deixen almenys dues línies de cada paràgraf a cada costat d'un tall. El pen compon el reportatge dues vegades. Sense l'ajust, a l'obertura la columna dreta es queda una línia per sobre de l'esquerra: el paràgraf següent no pot començar amb una sola línia al peu. Amb l'ajust, a la mateixa pàgina, el paràgraf d'aquesta columna es talla en una línia més i les dues columnes acaben a l'última línia de la reixeta. A la pàgina següent, l'ajust també recull l'aire de més que l'equilibri posava sobre un subtítol.
Aquesta recepta respon a
- Per què una columna acaba una o dues línies abans del peu, i com l'omplo sense trencar les regles de vídues i òrfenes?
- Com aconsegueixo columnes arran del peu i una última pàgina equilibrada (justificació vertical)?
- Com evito vídues, òrfenes i últimes línies d'una sola paraula, i mantinc cada títol amb el seu text?
La resposta curta
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,
};
Ingredients
- Funcions
- Columnes curtes omplertesEquilibri de columnesVídues, òrfenes i línies curtesTall de línies òptim (Knuth–Plass)Obertures dissenyadesImatges als dissenys de pàginaAtributs de títolCapçaleres i folisCapçaleres segons el tipus de pàginaPaleta de color semànticaEstils de paràgraf
- Tipografia
- Public Sans, Libre Caslon Display, Libre Caslon Text (SIL OFL 1.1)
- Recursos
lens-1400.jpg- A lit Fresnel lens in a lighthouse lantern at dusk (Generated With Diffusion Models, original)
Elaboració
#1 · Dona a la columna en bandera una palanca pròpia
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,
};
L'equilibri de columnes omple una columna curta amb línies de reixeta sobre un títol, després d'una llista, sota un requadre o una figura i, com a últim recurs, component un paràgraf justificat una línia més ample. Una columna de text corregut en bandera no té res d'això entre els seus subtítols, així que la línia que retenen les regles de vídues i òrfenes es queda buida. fillShortColumns deixa que un paràgraf en bandera d'aquesta columna ocupi una línia més amb el seu propi espaiat; quan cap no pot, en compon un amb una línia menys perquè el paràgraf de després del tall pugi i ocupi aquesta línia. El motor només conserva el canvi si acaben curtes menys columnes, així que activar-lo mai no empitjora una pàgina. Amb maxLinesBeforeLoose: 0 es proven els paràgrafs abans que els títols, i cada subtítol conserva una sola línia d'aire a sobre.
#2 · Compon el reportatge dues vegades i compara
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) };
Els dos documents surten d'una mateixa configuració i un mateix Markdown. La segona composició passa la configuració tal com és; la primera, una còpia amb l'ajust apagat, de manera que el paquet que s'obre al Sandbox porta fillShortColumns: true. Els dos botons sobre la taula canvien d'una composició a l'altra. Només s'omplen fins al peu les pàgines que continuen: la pàgina 3 tanca el reportatge i les seves columnes es tallen a la mateixa altura, igual en les dues composicions.
#3 · Obre amb la pintura
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 és un element d'imatge ancorat al sagnat, de 150 mm d'alt, així que arriba al tall per tres costats, cosa que cap flotant no fa. L'avanttítol, el títol, l'entradeta i la signatura surten dels atributs de la línia #. La signatura acaba en un farciment de 6 mm que compta en l'alçada de l'obertura, així que les columnes comencen a la primera línia de reixeta de sota.
#4 · Capçaleres fora de l'obertura
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 parella anomena la revista i el número des del frontmatter, la senar el reportatge, i pages: 'body' les treu de l'obertura, que només porta un foli al peu. L'etiqueta baixa 0,35 mm respecte al foli perquè els dos cossos comparteixin línia de base. El filet de sota fa 197 mm, l'amplada de la caixa de text.
La recepta completa
// ═══ 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 = {
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 que els subtítols prenguin el buit primer
Amb l'ordre per defecte, un títol pren fins a dues línies de reixeta abans que un paràgraf s'allargui, i els subtítols de la pàgina següent tornen a obrir-se.
- maxLinesBeforeLoose: 0,
+ maxLinesBeforeLoose: 2,#Justifica el text
Un paràgraf justificat ja podia ocupar una línia més sense l'ajust, dins de maxWordSpacing. Amb fillShortColumns també pot ocupar una línia menys, i mai no es compon més ample que la seva línia més ampla.
- textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+ textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line betweenErrors freqüents
- L'ajust tanca un buit d'una línia. Una columna a la qual en falten dues, per exemple una que acaba després del blanc entre paràgrafs amb lloc per a una sola línia del següent, es pot quedar curta: ajusta el text amb una frase.
- No es fa servir un paràgraf la nova composició del qual acabaria en una línia curta, així que una columna es pot quedar curta quan només té paràgrafs breus.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Imatges
- A lit Fresnel lens in a lighthouse lantern at dusk · Generated With Diffusion Models · original
- Fonts
- Public Sans (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Libre Caslon Text (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


