Em poucas palavras
Quando uma coluna para uma linha acima do pé porque um parágrafo não pode ser quebrado ali, este ajuste refaz as linhas de um parágrafo da coluna para que ela chegue até embaixo.
O que você vai compor
Três páginas de Littoral, uma revista do litoral: uma longa matéria sobre a lente de Fresnel, aberta pela pintura de uma lente acesa em sua lanterna. O texto corre em duas colunas alinhadas à esquerda em Public Sans, com uma linha de grade em branco entre os parágrafos, e as regras de viúvas e órfãs mantêm pelo menos duas linhas de um parágrafo de cada lado de uma quebra. O pen compõe a matéria duas vezes. Sem o ajuste, na abertura a coluna da esquerda termina uma linha acima da da direita: o parágrafo seguinte não pode começar com uma só linha no pé. Com ele, na mesma página, o primeiro parágrafo ganha uma linha e as duas colunas terminam na última linha da grade. Na página seguinte, o ajuste também recolhe o respiro a mais que o equilíbrio havia posto acima de dois intertítulos.
Esta receita responde a
- Por que uma coluna termina uma ou duas linhas antes do pé, e como a preencho sem quebrar as regras de viúvas e órfãs?
- Como deixo as colunas alinhadas no pé e a última página equilibrada (justificação vertical)?
- Como evito viúvas, órfãs e últimas linhas de uma só palavra, e mantenho cada título junto do seu texto?
A 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,
};
Ingredientes
- Funcionalidades
- Colunas curtas preenchidasEquilíbrio de colunasViúvas, órfãs e linhas curtasQuebra de linha ótima (Knuth–Plass)Aberturas desenhadasImagens nos designs de páginaAtributos de títuloCabeços e fóliosCabeços por tipo de páginaPaleta de cores semânticaEstilos de parágrafo
- Tipografia
- Public Sans, Libre Caslon Display, Libre Caslon Text (SIL OFL 1.1)
- Materiais
lens-1400.jpg- A lit Fresnel lens in a lighthouse lantern at dusk (Generated With Diffusion Models, original)
Preparo
#1 · Dê à coluna alinhada à esquerda uma alavanca própria
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,
};
O equilíbrio de colunas preenche uma coluna curta acrescentando linhas de grade acima de um título, depois de uma lista, sob um box ou uma figura e, em último caso, compondo um parágrafo justificado uma linha mais folgado. Uma coluna de texto corrido alinhada à esquerda não tem nada disso entre seus intertítulos, e a linha que as regras de viúvas e órfãs seguram fica vazia. fillShortColumns deixa um parágrafo dessa coluna ocupar uma linha a mais com o próprio espaçamento; quando nenhum consegue, compõe um com uma linha a menos para que o parágrafo depois da quebra suba e ocupe a linha. O motor só mantém a mudança se menos colunas terminarem curtas, então ativá-lo nunca piora uma página. maxLinesBeforeLoose: 0 tenta os parágrafos antes dos títulos, e cada intertítulo mantém uma única linha de respiro acima.
#2 · Componha a matéria duas vezes e compare
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) };
Os dois documentos saem da mesma configuração e da mesma string Markdown. A segunda composição passa a configuração como está; a primeira, uma cópia com o ajuste desligado, de modo que o pacote aberto no Sandbox leva fillShortColumns: true. Os dois botões acima da mesa alternam entre as composições. Só as páginas que continuam são preenchidas até o pé: a página 3 fecha a matéria, e suas colunas são cortadas na mesma altura nas duas composições.
#3 · Abra com a 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) },
] },
};
A pintura é um elemento de imagem ancorado à sangria, com 150 mm de altura, e por isso chega ao corte em três lados, o que nenhum flutuante faz. O chapéu, o título, a linha fina e a assinatura vêm dos atributos da linha #. A assinatura termina em um preenchimento de 6 mm que conta na altura da abertura, e as colunas começam na primeira linha de grade abaixo dela.
#4 · Cabeços fora da abertura
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
A página par traz a revista e a edição do frontmatter, a ímpar a matéria, e pages: 'body' tira os dois da abertura, que só leva um fólio no pé. O rótulo desce 0,35 mm em relação ao fólio para que os dois corpos dividam a linha de base. O fio abaixo deles mede 197 mm, a largura da mancha.
A receita 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 = {
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Deixe os intertítulos ocuparem o vão primeiro
Na ordem padrão, um título ocupa até duas linhas de grade antes de um parágrafo ser alongado, e os intertítulos da página seguinte voltam a se abrir.
- maxLinesBeforeLoose: 0,
+ maxLinesBeforeLoose: 2,#Justifique o texto
Um parágrafo justificado já podia ocupar uma linha a mais sem o ajuste, dentro de maxWordSpacing. Com fillShortColumns ele também pode ocupar uma linha a menos, e nunca é composto mais folgado que sua linha mais folgada.
- textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+ textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line betweenErros comuns
- O ajuste fecha um vão de uma linha. Uma coluna à qual faltam duas, por exemplo uma que termina depois do espaço entre parágrafos com lugar para uma só linha do seguinte, pode ficar curta: ajuste o texto com uma frase.
- Um parágrafo cuja nova composição terminaria em uma linha curta demais não é usado, então uma coluna pode ficar curta quando só tem parágrafos breves.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imagens
- A lit Fresnel lens in a lighthouse lantern at dusk · Generated With Diffusion Models · original
- Fontes
- Public Sans (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Libre Caslon Text (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


