In breve
Quando una colonna si ferma una riga sopra il piede perché un paragrafo non si può spezzare lì, questa impostazione ricompone un paragrafo della colonna perché arrivi fino in fondo.
Che cosa comporrai
Tre pagine di Littoral, una rivista di costa: un lungo articolo sulla lente di Fresnel, aperto dal dipinto di una lente accesa nella sua lanterna. Il testo corre su due colonne a bandiera in Public Sans, con una riga di griglia vuota tra i paragrafi, e le regole su vedove e orfane tengono almeno due righe di un paragrafo da ciascun lato di un'interruzione. Il pen compone l'articolo due volte. Senza l'impostazione, nell'apertura la colonna sinistra si ferma una riga sopra la destra: il paragrafo seguente non può cominciare con una sola riga al piede. Con l'impostazione, nella stessa pagina, il primo paragrafo va a capo su una riga in più e le due colonne finiscono sull'ultima riga della griglia. Nella pagina seguente l'impostazione riprende anche l'aria in più che il bilanciamento aveva messo sopra due titoletti.
Questa ricetta risponde a
- Perché una colonna finisce una o due righe prima del piede, e come la riempio senza violare le regole su vedove e orfane?
- Come ottengo colonne pareggiate al piede e un’ultima pagina bilanciata (giustificazione verticale)?
- Come evito vedove, orfane e ultime righe di una sola parola (righe finali troppo corte), e tengo un titolo unito al suo testo?
La risposta breve
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,
};
Ingredienti
- Funzionalità
- Colonne corte riempiteBilanciamento delle colonneVedove, orfane e righe corteDivisione in righe ottimale (Knuth–Plass)Aperture progettateImmagini nei progetti di paginaAttributi dei titoliTestatine e folioTestatine secondo il ruolo della paginaTavolozza di colori semanticaStili di paragrafo
- Tipo
- Public Sans, Libre Caslon Display, Libre Caslon Text (SIL OFL 1.1)
- Risorse
lens-1400.jpg- A lit Fresnel lens in a lighthouse lantern at dusk (Generated With Diffusion Models, originale)
Preparazione
#1 · Dai alla colonna a bandiera una leva sua
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,
};
Il bilanciamento delle colonne riempie una colonna corta aggiungendo righe di griglia sopra un titolo, dopo un elenco, sotto un riquadro o una figura e, come ultima risorsa, componendo un paragrafo giustificato una riga più largo. Una colonna di testo corrente a bandiera non ha nulla di tutto questo tra i suoi titoletti, e la riga che le regole su vedove e orfane trattengono resta vuota. fillShortColumns lascia che un paragrafo a bandiera di quella colonna occupi una riga in più con la propria spaziatura; quando nessuno può, ne compone uno con una riga in meno perché il paragrafo dopo l'interruzione salga e riempia la riga. Il motore tiene la modifica solo se finiscono corte meno colonne, quindi attivarla non peggiora mai una pagina. maxLinesBeforeLoose: 0 prova i paragrafi prima dei titoli, così ogni titoletto conserva una sola riga d'aria sopra di sé.
#2 · Componi l'articolo due volte e confronta
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) };
I due documenti nascono dalla stessa configurazione e dalla stessa stringa Markdown. La seconda composizione passa la configurazione così com'è; la prima, una copia con l'impostazione spenta, così il pacchetto che si apre nel Sandbox porta fillShortColumns: true. I due pulsanti sopra il tavolo passano da una composizione all'altra. Si riempiono fino al piede solo le pagine che proseguono: la pagina 3 chiude l'articolo e le sue colonne vengono tagliate alla stessa altezza in entrambe le composizioni.
#3 · Apri con il dipinto
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) },
] },
};
Il dipinto è un elemento immagine ancorato al margine di stampa, alto 150 mm, quindi arriva al taglio su tre lati, cosa che nessun elemento flottante fa. L'occhiello, il titolo, il sommario e la firma vengono dagli attributi della riga #. La firma termina con un margine interno di 6 mm che conta nell'altezza dell'apertura, così le colonne cominciano sulla prima riga di griglia sottostante.
#4 · Testatine fuori dall'apertura
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 pagina pari nomina la rivista e il numero dal frontmatter, la dispari l'articolo, e pages: 'body' le tiene fuori dall'apertura, che porta solo un numero di pagina al piede. L'etichetta scende di 0,35 mm rispetto al numero di pagina perché i due corpi condividano la linea di base. Il filetto sotto misura 197 mm, la larghezza della gabbia del testo.
La ricetta 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 = 'en'; // @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 = {
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Lascia che i titoletti prendano il vuoto per primi
Con l'ordine predefinito un titolo prende fino a due righe di griglia prima che un paragrafo si allunghi, e i titoletti della pagina seguente tornano ad aprirsi.
- maxLinesBeforeLoose: 0,
+ maxLinesBeforeLoose: 2,#Giustifica il testo
Un paragrafo giustificato poteva già occupare una riga in più senza l'impostazione, entro maxWordSpacing. Con fillShortColumns può occuparne anche una in meno, e non viene mai composto più largo della sua riga più larga.
- textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+ textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line betweenErrori frequenti
- L'impostazione chiude un vuoto di una riga. Una colonna a cui ne mancano due, per esempio una che finisce dopo lo spazio tra paragrafi con posto per una sola riga del seguente, può restare corta: aggiusta il testo con una frase.
- Un paragrafo la cui nuova composizione finirebbe su una riga troppo corta non viene usato, quindi una colonna può restare corta quando ha solo paragrafi brevi.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Immagini
- A lit Fresnel lens in a lighthouse lantern at dusk · Generated With Diffusion Models · originale
- Font
- Public Sans (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Libre Caslon Text (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


