Em poucas palavras
Os três primeiros capítulos de um romance brasileiro numa edição em brochura. Cada capítulo começa numa página da direita, mais abaixo, sob um número de capítulo grande.
O que você vai compor
Oito páginas de uma brochura de 140 × 216 mm trazem os três primeiros capítulos de Quincas Borba (1891), de Machado de Assis. A capa, uma pintura gerada com modelos de difusão e carregada como JPEG, mostra o mar entre os troncos de duas árvores: um veleiro no horizonte e uma sombrinha branca subindo da praia entre flores amarelas e brancas. O colofão fica no pé do verso da capa. Cada capítulo abre numa página ímpar com o texto a 81 mm do topo, sob um numeral em itálico de 48 pt, um fio vermelho-ferrugem de 18 mm e a primeira frase do capítulo em versaletes, centralizada e, quando ocupa duas linhas, quebrada pelo sentido. Os capítulos I e II, curtos, terminam nas próprias aberturas, nas páginas 3 e 5, com uma vinheta de camomila; as páginas 4 e 6 ficam em branco, sem cabeço nem fólio, e o capítulo III abre na página 7. Os cabeços trazem o autor nas páginas pares e o título em itálico nas ímpares; nestas oito páginas, só a página 8 leva cabeço.
Esta receita responde a
- Como forço uma quebra de página ou de coluna e faço cada capítulo começar em página ímpar?
- Como acrescento a uma abertura uma linha de autor, uma linha fina ou um primeiro parágrafo com capitular?
- Como insiro páginas em branco de propósito, ou começo uma seção em uma nova página dupla?
- Como configuro os cabeços: título do livro na página esquerda, título do capítulo na direita e fólio do lado de fora?
- Como faço uma capa, uma falsa folha de rosto, uma folha de rosto e um colofão?
- Como acrescento espaço vertical entre dois blocos, se as linhas em branco não fazem nada?
- Como componho ilustrações sem numeração: ornamentos, vinhetas, logotipos?
A resposta curta
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
enabled: true,
// The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
// starts the text on line BODY_AT (a one-line lead would leave two).
minHeight: line(BODY_AT),
slot: { elements: [
{ kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
align: 'center', placement: centred(mm(NUMERAL_Y)) },
{ kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
marginBottom: pt(0), italic: true, advancedDesign: opener };
Ingredientes
- Funcionalidades
- Aberturas desenhadasCapítulos que abrem em página ímparCabeços por seçãoMargens espelhadasEstilos de títuloCapas, folhas de rosto e colofõesAtributos de títuloTextos, fios e caixas nos designs de páginaImagens nos designs de páginaGeometria por seçãoCabeços por tipo de páginaCabeços e fóliosMetadados do documentoEstilos de parágrafoEspaço vertical explícitoFiguras e tabelas como recursosTipos de recurso personalizadosFiguras exatamente aquiCor do papelViúvas, órfãs e linhas curtas
- Tipografia
- Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1)
- Materiais
cover-1190.jpg- A vista do mar da capa, uma pintura (Generated With Diffusion Models, original)
- A vinheta de camomila, desenhada em código com a paleta da página (Ignacio Ferro, MIT)
Preparo
#1 · A abertura sai do título e do seu atributo
O código é a resposta curta acima. Os capítulos de Machado não têm título, então cada título só traz o numeral (# I), que {titleText} imprime. {attr.lead} imprime o atributo lead do título, que guarda a primeira frase do capítulo; o parágrafo Markdown sob o título começa com a frase seguinte (atributos de título). Texto de design não pode ser justificado, por isso a primeira frase fica centralizada, e o \n a quebra pelo sentido, depois de enseada, e depois de há; a primeira frase do capítulo III, Um criado trouxe o café., cabe numa linha e não leva \n. Esse \n só quebra a linha quando paragraphIndent é maior que zero; com 0, a frase imprime os dois caracteres. A caixa da primeira frase começa nove linhas da grade abaixo do topo da mancha, e um minHeight de 12 linhas deixa uma linha em branco embaixo dela; sem minHeight, o texto começa logo abaixo da frase (span e design avançado). marginBottom: pt(0) substitui a margem padrão de meio eme do nível, que empurraria o texto uma linha para baixo.
breakBefore com paridade 'odd' manda cada capítulo para uma página ímpar. A config repete isso porque qualquer objeto headings descarta a quebra padrão do nível (quebrar antes). Para forçar uma página ímpar onde não há título, escreva :::pagebreak{parity="odd"} no Markdown; numa diagramação de duas colunas ou mais, :::columnbreak passa o texto para a coluna seguinte (:::pagebreak).
#2 · Uma página de brochura com linhas inteiras
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
backgroundColor: col('paper'),
margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
left: mm(INNER), right: mm(OUTER), mirror: true },
};
A margem inferior é o que sobra dos 216 mm do refile depois da margem superior e de 35 linhas de 14 pt, então toda página cheia termina na mesma linha, a última da mancha, onde também termina o colofão da página 2. Com mirror: true, a margem interna de 20 mm cai do lado da lombada tanto nas ímpares quanto nas pares (margens espelhadas). A medida de 104,5 mm comporta uns 69 caracteres de Crimson Pro de 10,5 pt. O texto corrido mantém os espaços entre palavras entre 0,7 e 1,9 do normal; com o mínimo padrão de 0,6, uma linha destas páginas se fecha até 0,62. Ele também define maxRuntTracking: 0. Na edição em inglês, com o valor padrão, o parágrafo que termina em nodding good-by to him. é composto com uma linha a menos usando um tracking negativo que o canvas não pinta, e os espaços entre palavras saem esmagados.
#3 · Cabeços que pulam aberturas e páginas em branco
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
fontSize: pt(9), color: col('muted'), ...style,
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
parity: 'even' põe o autor em versaletes de Cormorant SC nas páginas pares, e 'odd' põe o título em itálico nas ímpares; os fólios ficam na borda externa da mancha. pages: 'body' deixa os quatro elementos fora das aberturas e das páginas 4 e 6, as páginas pares em branco que a quebra com paridade acrescenta. Com pages: 'all', a página 6 imprimiria 6 e Machado de Assis. As aberturas recebem no lugar um fólio no pé, um elemento de rodapé que pages: 'opener' restringe a elas (elementos de texto).
#4 · A capa é uma seção sem cabeços
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
id: 'cover',
span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
margins: { top: mm(COLOPHON_TOP) },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'cover',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
{ kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
placement: onPage(155) },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
{ kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
A capa é um título, # Quincas Borba {style="cover"}. O estilo dele põe a pintura nos 134 mm de cima, o título do frontmatter em 50 pt e o autor em versaletes. O estilo também vale para a seção que o título abre, as páginas 1 e 2, até o capítulo I. O header e o footer vazios tiram o fólio no pé da capa e os cabeços do colofão. A margem superior dele põe o colofão nas seis últimas linhas da grade da mancha, então a última linha do colofão fica na última linha da mancha, a 35ª da grade (estilos de título). O estilo precisa de span: 'page' mesmo com uma só coluna. Um design preso à coluna é cortado no topo da coluna, aqui a 165 mm; sem span: 'page', a pintura não seria impressa e o título perderia a parte de cima.

#5 · Uma vinheta fecha cada capítulo
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a '
+ 'white sunshade comes up from the beach through the camomile.',
pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha '
+ 'branca sobe da praia por um caminho entre flores amarelas e brancas.' }) };
const resources = [
painting,
// ::resource places each ornament once, so each chapter end has its own id.
...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'] }).map((n, i) => svg(`tailpiece-${i + 1}`,
'fleuron.svg', 40, 12, t({ en: `A camomile flower between two sprigs closes chapter ${n}.`,
pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.` }))),
];
O tipo ornamento não tem prefixo de legenda, então a flor de camomila sai sem rótulo, com 17% da medida de largura, onde está ::resource{id="tailpiece-1"} (tipos de recurso). Um elemento incorporado no texto mantém uma linha da grade acima dele, e o :::space que vem antes acrescenta uma segunda entre a última linha do capítulo e a flor (:::space). As três vinhetas desenham o mesmo arquivo, fleuron.svg, sob três ids, porque um recurso só é posicionado uma vez.
A receita completa
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══ // https://postext.dev/en/cookbook/trade-paperback-novel // Code: MIT · Text: The Awakening (Gutenberg #160), Quincas Borba (PD) · Cover: diffusion models // Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'pt'; // @lang: the sample's language: 'en' (Chopin) | 'pt' (Machado de Assis) const RECIPE = 'trade-paperback-novel'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#231d18', // the text: a warm near-black rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece muted: '#6e655b', // running heads, folios on openers, the colophon paper: '#fbf7ef', // a cream book paper }; // Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs). 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 (headings, lists, callouts, captions) link to 'main-color', the rubric. { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } }, ]; const TEXT = 'Crimson Pro'; // the text face const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch const line = (n) => pt(n * LEAD); // n grid lines // #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto const LINES = 35; // every full page ends on the same line const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro const MM_PER_PT = 25.4 / 72; const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT), left: mm(INNER), right: mm(OUTER), mirror: true }, }; // #endregion // #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps // # I {lead="A green and yellow parrot, … kept repeating over and over:"} // The heading's text is the numeral, printed by {titleText}; the chapter's first sentence // travels as the heading's lead attribute, printed by {attr.lead} in small capitals and // centred, because design text is never justified (gotcha: design-text-ragged). const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts const NUMERAL = 48; // pt const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } }); const opener = { enabled: true, // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and // starts the text on line BODY_AT (a one-line lead would leave two). minHeight: line(BODY_AT), slot: { elements: [ { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'), align: 'center', placement: centred(mm(NUMERAL_Y)) }, { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600, fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center', lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple) overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default) paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline) placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } }, ] }, }; // Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on // a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default // 0.5 em would start the text a line lower. const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(0), italic: true, advancedDesign: opener }; // #endregion // #region heads: the author on the verso, the title on the recto, folios outside // {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter). const HEAD_Y = 12; // mm from the top edge to the top of the running heads const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages fontSize: pt(9), color: col('muted'), ...style, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } }, }); const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) }; const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) }; const folio = { fontFamily: TEXT, color: col('ink') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps), head('recto-title', '{title}', 'odd', 'top', SHIFT, italic), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; // Openers carry a drop folio instead, centred under the text block. const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] }; // #endregion // #region cover: a section of its own: art, title, author, no heads, a colophon on its verso // Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and // chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it. // The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines. const COLOPHON_LINES = 6; const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down const cover = { id: 'cover', span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2 margins: { top: mm(COLOPHON_TOP) }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center', placement: onPage(155) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } }, { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12), letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) }, ] } }, }; const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) }; // #endregion // #region tailpiece: an ornament type that prints no caption, where ::resource stands // At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha // resource-double-quotes). The space adds a line to the gap the embed keeps above it. const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here', width: 0.17, align: 'center' } }; const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg', svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 }); // The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels. const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 }, altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a ' + 'white sunshade comes up from the beach through the camomile.', pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha ' + 'branca sobe da praia por um caminho entre flores amarelas e brancas.' }) }; const resources = [ painting, // ::resource places each ornament once, so each chapter end has its own id. ...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'] }).map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12, t({ en: `A camomile flower between two sprigs closes chapter ${n}.`, pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.` }))), ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', pt: 'pt' }), // exact codes (gotcha: hyphenation-locales) colorPalette, resourceTypes: [ornament], page, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62 maxRuntTracking: 0, // gotcha: runt-tracking-unpainted }, // The designs paint every heading; the level's own face is the numeral's (500 italic), so the // kit has no unused face to load. headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] }, headingStyles: [cover], paragraphStyles: [colophon], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 53 linhas · content.pt.md
title: "Quincas Borba" author: "Machado de Assis" --- # Quincas Borba {style="cover"} :::paragraphs{style="colophon"} Publicado em livro por B. L. Garnier, Rio de Janeiro, em 1891. Os capítulos I a III seguem a primeira edição, transcrita no Wikisource, com a ortografia atualizada. Composto em Crimson Pro, Cormorant Garamond e Cormorant SC (SIL Open Font License). A capa, uma pintura, mostra uma praia vista entre as árvores. Uma edição das Receitas do Postext. O texto está em domínio público. ::: # I {lead="Rubião fitava a enseada,\n— eram oito horas da manhã."} Quem o visse, com os polegares metidos no cordão do chambre, à janela de uma grande casa de Botafogo, cuidaria que ele admirava aquele pedaço de água quieta; mas, em verdade, vos digo que pensava em outra coisa. Cotejava o passado com o presente. Que era, há um ano? Professor. Que é agora? Capitalista. Olha para si, para as chinelas (umas chinelas de Túnis, que lhe deu recente amigo, Cristiano Palha), para a casa, para o jardim, para a enseada, para os morros e para o céu; e tudo, desde as chinelas até o céu, tudo entra na mesma sensação de propriedade. — Vejam como Deus escreve direito por linhas tortas, pensa ele. Se a mana Piedade tem casado com o Quincas Borba, apenas me daria uma esperança colateral. Não casou; ambos morreram, e aqui está tudo comigo; de modo que o que parecia uma desgraça... :::space ::resource{id="tailpiece-1"} # II {lead="Que abismo que há\nentre o espírito e o coração!"} O espírito do ex-professor, vexado daquele pensamento, arrepiou caminho, buscou outro assunto, uma canoa que ia passando; o coração, porém, deixou-se estar a bater de alegria. Que lhe importa a canoa nem o canoeiro, que os olhos de Rubião acompanham, arregalados? Ele, coração, vai dizendo que, uma vez que a mana Piedade tinha de morrer, foi bom que não casasse; podia vir um filho ou uma filha... — Bonita canoa! — Antes assim! — Como obedece bem aos remos do homem! — O certo é que eles estão no céu! :::space ::resource{id="tailpiece-2"} # III {lead="Um criado trouxe o café."} Rubião pegou na xícara e, enquanto lhe deitava açúcar, ia disfarçadamente mirando a bandeja, que era de prata lavrada. Prata, ouro, eram os metais que amava de coração; não gostava de bronze, mas o amigo Palha disse-lhe que era matéria de preço, e assim se explica este par de figuras que aqui está na sala, um Mefistófeles e um Fausto. Tivesse, porém, de escolher, escolheria a bandeja, — primor de argentaria, execução fina e acabada. O criado esperava teso e sério. Era espanhol; e não foi sem resistência que Rubião o aceitou das mãos de Cristiano; por mais que lhe dissesse que estava acostumado aos seus crioulos de Minas, e não queria línguas estrangeiras em casa, o amigo Palha insistiu, demonstrando-lhe a necessidade de ter criados brancos. Rubião cedeu com pena. O seu bom pajem, que ele queria pôr na sala, como um pedaço da província, nem o pôde deixar na cozinha, onde reinava um francês, Jean; foi degradado a outros serviços. — Quincas Borba está muito impaciente? perguntou Rubião bebendo o último gole de café, e lançando um último olhar à bandeja. — Me parece que sí. — Lá vou soltá-lo. Não foi; deixou-se ficar, algum tempo, a olhar para os móveis. Vendo as pequenas gravuras inglesas, que pendiam da parede por cima dos dois bronzes, Rubião pensou na bela Sofia, mulher do Palha, deu alguns passos, e foi sentar-se no pouf, ao centro da sala, olhando para longe... — Foi ela que me recomendou aqueles dois quadrinhos, quando andávamos, os três, a ver coisas para comprar. Estava tão bonita! Mas o que eu mais gosto dela são os ombros, que vi no baile do coronel. Que ombros! Parecem de cera; tão lisos, tão brancos! Os braços também; oh! os braços! Que bem feitos! Rubião suspirou, cruzou as pernas, e bateu com as borlas do chambre sobre os joelhos. Sentia que não era inteiramente feliz; mas sentia também que não estava longe a felicidade completa. Recompunha de cabeça uns modos, uns olhos, uns requebros sem explicação, a não ser esta, que ela o amava, e que o amava muito. Não era velho; ia fazer quarenta e um anos; e, rigorosamente, parecia menos. Esta observação foi acompanhada de um gesto; passou a mão pelo queixo, barbeado todos os dias, coisa que não fazia dantes, por economia e desnecessidade. Um simples professor! Usava suíças, (mais tarde deixou crescer a barba toda), — tão macias, que dava gosto passar os dedos por elas... E recordava assim o primeiro encontro, na estação de Vassouras, onde Sofia e o marido entraram no trem da estrada de ferro, no mesmo carro em que ele descia de Minas; foi ali que achou aquele par de olhos viçosos, que pareciam repetir a exortação do profeta: Todos vós que tendes sede, vinde às águas. Não trazia ideias adequadas ao convite, é verdade; vinha com a herança na cabeça, o testamento, o inventário, coisas que é preciso explicar primeiro, a fim de entender o presente e o futuro. Deixemos Rubião na sala de Botafogo, batendo com as borlas do chambre nos joelhos, e cuidando na bela Sofia. Vem comigo, leitor; vamos vê-lo, meses antes, à cabeceira do Quincas Borba. :::space ::resource{id="tailpiece-3"}`; // content.<lang>.md, inlined by the Cookbook // #region art: the fleuron, drawn in millimetres in the page's colour function drawFleuron(color) { // a camomile head between two leafy sprigs const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" ` + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join(''); const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" ` + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>` + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>` + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>` + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" ' + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}` + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Crimson Pro': ['400', '400i'], // text, colophon, folios 'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head 'Cormorant SC': ['600'], // the leads, the author on the cover and the verso }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadImage(painting.bitmap.fileId, asset(painting.bitmap.fileId)); await loadSvg('fleuron.svg', drawFleuron(palette.rubric)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Trade paperback: sunk openers and recto chapters', pt: 'Romance em brochura: aberturas rebaixadas em página ímpar' }) });Kit · core, fonts, viewer, images: igual em todas as receitas · 271 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { 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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), 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 /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ 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 · images v1 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
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
#Deixar o capítulo II seguir na página seguinte
Com paridade 'any', o capítulo II abre na página 4, uma página par, o III na página 5, e o livro termina na página 6; o fólio no pé é centralizado na mancha, não na página, então nessa página par ele continua sob o meio do texto.
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },#Marcar uma quebra de cena dentro de um capítulo
Entre duas linhas :::space, a mesma flor marca uma quebra de cena, e o parágrafo seguinte começa sem recuo porque indentAfterHeading está desligado. Na edição em inglês, posta depois de seemed long to him., a flor cai no pé da página 4; o segundo espaço já não cabe ali e é descartado. A quebra acrescenta quatro linhas, que empurram a vinheta do capítulo I sozinha para a página 6, sob um cabeço, então ajuste o texto do capítulo de novo depois de acrescentar uma.
const resources = [
+ svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),That was why the morning seemed long to him.
:::space
::resource{id="break-1"}
:::space
“You are burnt beyond recognition,” he added, …Erros comuns
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
A página 1 é ímpar: planeje com os números físicos
A página 1 fica à direita e a 2 é a primeira página par, então planeje as páginas duplas com os números físicos de página: uma abertura em página par fica de frente para a ímpar que vem depois dela. Quebras de página e de coluna →
Erro comum
\n em um atributo só quebra linha com paragraphIndent > 0
Em um elemento de texto de design, um \n escrito no valor de um atributo só abre uma linha nova quando paragraphIndent é maior que zero ou há capitular; caso contrário, o texto fica em uma linha só. Dê a paragraphIndent um valor mínimo (0,01 pt) ou use um atributo por linha. Textos, fios e caixas nos designs de página →
Erro comum
O lineHeight de um texto de design é um múltiplo, nunca uma medida
Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →
Erro comum
Texto de design nunca é justificado, então o texto ao lado da capitular fica em bandeira
No postext 1.4.1, um elemento de texto de design se alinha à esquerda, ao centro ou à direita e quebra palavra por palavra: não há alinhamento justificado, e hyphenate: true só divide uma palavra longa demais para uma linha inteira. As linhas de um texto inicial composto ao lado de uma dropCap terminam, portanto, em bandeira junto ao texto corrido justificado. Limite o texto inicial às linhas ao lado da capitular e ajuste-as à mão: com lines: 1 (uma capitular elevada) o texto inicial é uma linha só, que o gap da dropCap consegue deixar rente; o parágrafo continua no Markdown. Capitulares nas aberturas →
Erro comum
O excesso de texto de design é 'ellipsis-end' por padrão
Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →
Erro comum
O ajuste de linhas curtas pode apertar um tracking que nunca é pintado
No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta. Viúvas, órfãs e linhas curtas →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
::resource{id="…"} só aceita aspas duplas
Uma inserção em bloco só é reconhecida como ::resource{id="…"} com aspas duplas; qualquer outra forma fica no texto como uma linha visível. Figuras exatamente aqui →
Erro comum
Só 8 idiomas têm hifenização, com o código exato
A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →
Erro comum
Coloque entre aspas cada valor do frontmatter
O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984". Metadados do documento →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Verificação do Sandbox · parityCascade
Cascata de paridade
Por quê. Quebras com paridade se acumulam e produzem mais de duas páginas em branco seguidas.
Correção. Revise as quebras always-odd e as paridades de breakBefore; em capítulos curtos, a paridade any costuma evitar essas páginas em branco. Docs →
- Com
'always-odd', uma página separadora em branco vem depois de cada seção, antes de qualquer preenchimento de paridade, então, na edição em inglês, o capítulo I passaria para a página 5, depois de duas páginas em branco, e o livro chegaria a dez páginas. - As páginas da edição em inglês foram ajustadas linha a linha. Com uma margem externa de 16 mm em vez de 15,5, a página 7 parte after-din-ner num segundo hífen e termina uma linha antes, a página 8 fecha um parágrafo com o fragmento spondent., e a segunda vinheta cai numa nona página. Confira o pé de todas as páginas depois de mudar a medida, o corpo ou o rebaixamento.
- Na edição em inglês, Mr. e Mrs. ficam presos ao nome por um espaço não separável (U+00A0). Na 1.4.1 ele se mantém em parágrafos simples, mas num parágrafo com itálico quebra como um espaço comum. O parágrafo que termina em seemed long to him. fica com um espaço comum, porque prender o nome ali abre a primeira linha dele até 2,2 vezes o espaço normal entre palavras.
Créditos
- Receita
- Ignacio Ferro
- Texto
- The Awakening (O despertar, 1899), capítulos I e II, na edição em inglês · Kate Chopin · domínio público
- Quincas Borba (1891), capítulos I a III, com a ortografia atualizada · Machado de Assis · domínio público
- O colofão · Postext Cookbook · original
- Imagens
- A vista do mar da capa, uma pintura · Generated With Diffusion Models · original
- A vinheta de camomila, desenhada em código com a paleta da página · Ignacio Ferro · MIT
- Fontes
- Crimson Pro (SIL OFL 1.1) · Cormorant Garamond (SIL OFL 1.1) · Cormorant SC (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)


