Em poucas palavras
As duas primeiras páginas de uma reportagem de viagem numa revista pequena, com uma faixa colorida e uma imagem. Mostra como um texto simples com alguns títulos vira uma página diagramada com as suas cores.
O que você vai compor
As páginas de abertura de uma reportagem de viagem em Caderno de campo, uma revista pequena. A página 1 é uma abertura em página ímpar: uma fotografia de salinas e de uma duna ao anoitecer nos 58% de cima da página, o título em Young Serif de 54 pt e, embaixo, duas colunas justificadas que começam o texto com uma abertura em negrito. A página 2, no verso, continua sob um cabeço em maiúsculas espacejadas e termina com um sinal de fim e um colofão. O design inteiro cabe numa função de configuração e numa paleta de seis cores. O título vem do título de nível 1, e a linha fina, a autora e a data vêm do frontmatter da string Markdown, então o mesmo pen compõe qualquer matéria cujo título caiba em duas linhas; só a fotografia é exclusiva desta.
Esta receita responde a
- Como evito que títulos, negritos e marcadores de lista saiam em azul?
- Como configuro uma página de livro: formato refilado, margens interna e externa espelhadas, duas colunas e medianiz?
- Como faço para transformar uma string Markdown em páginas compostas e desenhar uma delas em um canvas?
A resposta curta
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
locale: t({ en: 'en-us', es: 'es', pt: 'pt' }), // exact codes (gotcha: hyphenation-locales)
colorPalette: colorPalette(),
page: { // mirror: left is the inner margin and right the outer one; versos swap them
width: mm(PAGE.width), height: mm(PAGE.height),
margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
right: mm(MARGIN.outer), mirror: true },
},
// 'double' is the default, stated so that the whole page setup reads in one place
layout: { layoutType: 'double', gutterWidth: mm(6) },
bodyText: { // justified, hyphenated and broken by paragraph: all on by default
fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false,
// Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
// tightened by at most 4 thousandths of an em, so the grey of the text stays even.
minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
},
headings: {
fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
levels: [
// span: 'page' opens the H1 on a new page, across both columns. The restated break
// (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
advancedDesign: opener() },
// A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
// default) rounds up to 3, so the text below lands back on the grid.
{ level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
marginBottom: pt(0) },
],
},
unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
marginTop: pt(0), marginBottom: pt(0) },
calloutStyles: [colophon()],
header: header(),
footer: footer(),
});
Ingredientes
- Funcionalidades
- Metadados do documentoPaleta de cores semânticaCabeços e fóliosPáginas em um canvasAberturas desenhadasFaixa de capítulo em largura totalImagens nos designs de páginaAncoragem de elementos de designCabeços por tipo de páginaFontes antes da diagramaçãoFormato da páginaMargens espelhadasUma ou duas colunasTipografia do textoNegrito, itálico e suas coresAmostras de corHifenização e idioma do documentoListas com marcadores e de verificaçãoBoxesQuebras de linha nos títulos
- Também usa
- Figuras e tabelas como recursos
- Tipografia
- Newsreader, Young Serif, Inter Tight (SIL OFL 1.1)
- Materiais
salt-road-1440.jpg- As salinas e a duna ao anoitecer da abertura, uma fotografia (Generated With Diffusion Models, original)
Preparo
#1 · Todas as fontes, antes da primeira composição
const FONTS = {
Newsreader: ['400', '400i', '700'], // text
'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
O Postext mede o texto com as fontes que o navegador já carregou e guarda as larguras em cache, então uma composição que roda antes de as fontes chegarem mantém as quebras de linha erradas. O kit carrega estes arquivos do Fontsource, os mesmos que um PDF incorporaria. Depois, buildWithFonts percorre as fontes que as páginas usam. Se um bloco ou um elemento de design estiver composto numa fonte que não foi carregada, ele emite um aviso no console, carrega a fonte e compõe de novo; quando o Fontsource não tem essa fonte, o pen para com um erro (veja “Erros comuns”). As variantes negrito e itálico de uma fonte de texto carregam sem aviso, e só quando a família as tem.
#2 · Dê nome às cores e aponte para os nomes
const palette = {
ink: '#1b1e23', // text: a cool near-black, never #000
band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
sand: '#e9dcc0', // the kicker, the title and the byline
salt: '#f7f4ee', // the standfirst
rule: '#d6d3cc', // the hairline over the colophon
muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the folios and the drop folio too.
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 defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
// point it at the accent. Header, footer and opener defaults do not follow it, so they
// are restated below.
{ id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
Toda cor da configuração é col('id'), um identificador da paleta com o hexadecimal ao lado. bodyText.boldColor, italicColor, referenceColor e unorderedLists.color apontam para ink, e headings.color aponta para a cor de destaque. Os padrões do motor para títulos, negrito, itálico e marcadores estão ligados a main-color, e esta paleta aponta essa entrada para a cor de destaque, de modo que qualquer um deles que você deixar de fora sai anil, e não azul. O Postext 1.4.1 pinta os elementos de cabeçalho, de rodapé e de abertura, e também referenceColor, com o hexadecimal e ignora o identificador; por isso os objetos de design são funções: config() as chama a cada composição, e col() copia de palette o hexadecimal vigente toda vez. O sinal de fim é uma amostra de cor em linha que aponta para band, então uma troca de cor também chega a ele. O cabeço padrão não lê a paleta e continua azul até você substituí-lo (passo 5).
#3 · Uma fotografia cortada no seu quadro
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
height: 1120 },
altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
+ 'footprints crossing the pans towards it.' };
As salinas, a duna e o marco de pedras são uma fotografia gerada com modelos de difusão, feita ao anoitecer para que o título claro se leia contra um céu escuro. O JPEG está cortado no quadro da faixa, PAGE.width × BAND milímetros, e o recurso declara seus pixels, 1440 × 1120, de modo que na largura inteira a imagem preenche a faixa exatamente. Uma faixa mais alta pede uma imagem cortada no novo quadro. A abertura a mostra por meio de um elemento de imagem que nomeia o recurso pelo identificador.
#4 · O frontmatter escreve a abertura
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
enabled: true,
minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
slot: {
elements: [
// The photograph is PAGE.width × BAND (see art): at full width it fills the band.
{ kind: 'image', id: 'art', resourceId: 'landscape',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
{ kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
offset: { x: mm(MARGIN.inner), y: mm(20) } } },
// {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
// text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
placement: below('kicker', 3, TITLE_W) },
{ kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
overflow: 'wrap', placement: below('title', 5, DECK_W) },
{ kind: 'text', id: 'byline', ...label, color: col('sand'),
content: t({ en: 'By {author} · {publishDate}',
es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}' }),
placement: below('deck', 4.5) },
],
},
});
O título de nível 1 ocupa as duas colunas, e sua abertura é um encaixe de elementos: a fotografia até a sangria e quatro textos encadeados um abaixo do outro. Um título mais longo empurra a linha fina e a assinatura para baixo em vez de se sobrepor a elas, mas a partir da terceira linha a assinatura cai sobre as salinas claras. {titleText} é o texto do título; {subtitle}, {author} e {publishDate} são campos do frontmatter. A \\ no título quebra a linha onde você a puser (quebras de linha em títulos). minHeight conta a partir da margem superior, então a altura reservada termina 5 mm abaixo da faixa.
#5 · Cabeços no lugar do azul padrão
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
elements: [ // folios on the outer margin's edge; each label hangs off its folio
head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
],
});
const footer = () => ({
elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
...label, color: col('band'),
placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
Deixe header de fora e volta o padrão do motor: Open Sans sobre um fio de largura inteira, em todas as páginas. Aqui cada fólio é ancorado à página física e filtrado pela paridade, de modo que fica sempre na margem externa. O texto que o acompanha é ancorado ao fólio, a GAP milímetros, com right-of ou left-of, e um fólio de três ou quatro algarismos empurra o texto em vez de esbarrar nele. pages: 'body' deixa os quatro elementos fora da abertura. O rodapé tem um único fólio, o da abertura, e como footer está definido, as páginas de texto perdem o fólio centralizado padrão.
#6 · Componha com uma configuração nova e pinte
await loadFonts(FONTS, markdown);
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
title: t({ en: 'From a Markdown string to a designed page',
es: 'De una cadena Markdown a una página diseñada',
pt: 'De uma string Markdown a uma página diagramada' }),
});
config() é uma função porque o motor guarda em cache cada configuração resolvida pela identidade do objeto, então um ajuste editado só faz efeito se cada composição receber um objeto novo. showPages dispõe as páginas como ficam num livro encadernado, com a página 1 sozinha à direita, e pinta cada uma com renderPageToCanvas.
A receita completa
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════ // https://postext.dev/en/cookbook/first-page-from-markdown // Code: MIT · Text: original (CC BY 4.0) · Photo: diffusion models // Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // // This pen sets a Markdown string with a frontmatter block on two magazine pages and paints // them on canvases. The whole design is in config(); swap config() for {} in the build // below to see the engine's defaults. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'pt'; // @lang: the language of the sample document ('en' | 'es' | 'pt') const RECIPE = 'first-page-from-markdown'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Every setting that shapes the pages is in this part, from PAGE down to config(). const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on // #region palette: six named colours; every colour in the config links to one of them const palette = { ink: '#1b1e23', // text: a cool near-black, never #000 band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark sand: '#e9dcc0', // the kicker, the title and the byline salt: '#f7f4ee', // the standfirst rule: '#d6d3cc', // the hairline over the colophon muted: '#66686e', // running heads and the colophon }; // Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers, // footers and openers from the hex (gotcha: palette-skips-designs). The design objects // below are factories that config() calls, so col() copies the hex out of `palette` on // every build, and a retint reaches the folios and the drop folio too. 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 defaults of the text styles (headings, bold, italic, bullets) link to 'main-color': // point it at the accent. Header, footer and opener defaults do not follow it, so they // are restated below. { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } }, ]; // #endregion // #region art: a photograph of the salt pans and the dunes at Sorra, cut for a page × band frame // A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at // full width it fills the band exactly. A deeper band needs a picture cut to the new frame. const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440, height: 1120 }, altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of ' + 'footprints crossing the pans towards it.' }; // #endregion // #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it const BAND = 140; // mm from the top edge: the band holds the top 58% of the page const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline // down onto the pale salt pans: keep titles short const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo' }); const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase' }; const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); const opener = () => ({ enabled: true, minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm slot: { elements: [ // The photograph is PAGE.width × BAND (see art): at full width it fills the band. { kind: 'image', id: 'art', resourceId: 'landscape', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'), placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left offset: { x: mm(MARGIN.inner), y: mm(20) } } }, // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple). { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif', fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left', overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default) placement: below('kicker', 3, TITLE_W) }, { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader', fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left', overflow: 'wrap', placement: below('title', 5, DECK_W) }, { kind: 'text', id: 'byline', ...label, color: col('sand'), content: t({ en: 'By {author} · {publishDate}', es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}' }), placement: below('deck', 4.5) }, ], }, }); // #endregion // #region running-heads: folio and title on body pages, a drop folio under the opener const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot const GAP = 3; // mm between a folio and its label, however many digits the folio has const head = (id, content, parity, placement, color = col('muted')) => ({ kind: 'text', id, content, parity, pages: 'body', ...label, color, placement, }); const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }); const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } }); const header = () => ({ elements: [ // folios on the outer margin's edge; each label hangs off its folio head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')), head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)), head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')), head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)), ], }); const footer = () => ({ elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', ...label, color: col('band'), placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }], }); // #endregion // The colophon: small sans under a 0.5 pt hairline, with no box around it. const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD), stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) }, body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }); // #region answer: one config factory in place of the default skin: page, type, colour, slots const config = () => ({ // a new object per build (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es', pt: 'pt' }), // exact codes (gotcha: hyphenation-locales) colorPalette: colorPalette(), page: { // mirror: left is the inner margin and right the outer one; versos swap them width: mm(PAGE.width), height: mm(PAGE.height), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true }, }, // 'double' is the default, stated so that the whole page setup reads in one place layout: { layoutType: 'double', gutterWidth: mm(6) }, bodyText: { // justified, hyphenated and broken by paragraph: all on by default fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name) fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts // tightened by at most 4 thousandths of an em, so the grey of the text stays even. minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4, }, headings: { fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight levels: [ // span: 'page' opens the H1 on a new page, across both columns. The restated break // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener() }, // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by // default) rounds up to 3, so the text below lands back on the grid. { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ], }, unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–', marginTop: pt(0), marginBottom: pt(0) }, calloutStyles: [colophon()], header: header(), footer: footer(), }); // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── // content.<lang>.md, inlined by the Cookbook: every frontmatter value is quoted, since an ISO // date or a number would print empty (gotcha: quote-frontmatter). The end mark is an inline // swatch that names `band`, so it follows the palette too. const markdown = String.raw`---Amostra em Markdown · 46 linhas · content.pt.md
title: "A trilha do sal" subtitle: "Quatro dias a pé pelo caminho dos carregadores, das salinas de Arvela ao mercado de Castrel." author: "Lena Varga" publishDate: "maio de 2026" --- # A trilha \\ do sal **Os carregadores partiam do dique de Arvela.** Na maré baixa, o lodaçal se estende por três quilômetros abaixo dele, uma planície cinzenta riscada de canais, e do alto do dique as salinas aparecem antes do mar: quarenta tanques rasos de salmoura arrumados como um tabuleiro de xadrez, cada um deles um tom mais branco que o anterior. Durante seis séculos, o sal de Arvela saiu do litoral nas costas de homens e mulheres. Eles carregavam ao amanhecer, trinta quilos por cesto de vime, e seguiam terra adentro por uma trilha que nunca foi calçada: atravessavam o lodaçal, as dunas de Sorra e o pinheiral e subiam até a velha cidade de feira de Castrel, a quatro dias de caminhada. Em 1911 o trem acabou com o ofício, mas os caminhantes mantiveram a trilha aberta desde então. Fiz a trilha em abril com Tomás Reis. A avó dele carregava sal quando era menina, e ele já passou vinte verões marcando a rota com pilhas de pedras brancas. “Procure o *sal* no chão”, ele me disse na primeira manhã. “Onde ele caiu, não nasce nada.” No segundo dia, já nas dunas, o caminho some por horas, e de repente aparece na areia um veio claro de poucos centímetros de largura, como se alguém o tivesse riscado a giz. É o sal que caiu dos cestos ao longo de seis séculos, uma crosta que o vento descobre a cada primavera. Tomás anda ao lado dela e nunca pisa em cima. Naquela noite dormimos numa baixada entre as dunas que os carregadores chamavam de Cozinha, porque ali o vento nunca entra. Tomás não acendeu fogo. Comeu pão e peixe seco e falou da avó, que provava um único grão e sabia dizer se vinha de Arvela ou das salinas do sul, e que nunca, segundo ele, perdeu a trilha na neblina. ## A carga da volta Os cestos voltavam cheios para a costa. Um livro de contas de 1887, guardado no arquivo paroquial de Sorra, lista as cargas de retorno da sua primeira primavera: - **Grãos**, sobretudo centeio e cevada, dos sítios da serra acima de Castrel. - **Resina de pinheiro** e piche, para calafetar todo inverno os barcos de pesca de Arvela. - **Lã e couro** da feira de maio de Castrel. - **Pregos e anzóis** da ferraria de Orsa. Até a chegada do trem, um cesto de sal valia um cesto de grãos. Um bom carregador fazia onze viagens de ida e volta por ano, oitenta e oito dias de trilha, e entre uma e outra puxava sal com o rodo nas salinas. O livro tem três letras. A primeira, uma caligrafia caprichada, anota cada carga com precisão de meio quilo; a segunda, a partir de 1893, só escreve números; a terceira, uma letra de criança, aparece nos invernos e escreve o nome da cidade como Castrell. O último registro, no meio de uma página do outono de 1910, é uma carga de cevada que nunca foi dada como entregue, e ninguém em Sorra sabe por quê. Tomás acha que o carregador foi assentar trilhos para a ferrovia, que pagava em moeda. ## Entre os pinheiros O terceiro dia é o mais longo. Depois de Orsa, a trilha passa oito horas subindo por um pinheiral que cheira a resina, entre casas de pouso em ruínas em que os carregadores dormiam na palha e pagavam o pernoite com sal. Ao lado de cada porta há uma pilha de pedras, e no alto de cada pilha, uma pedra branca que Tomás pôs ali. A mais bem conservada fica nos Nove Poços, uma hora abaixo do alto da serra. A verga da porta tem fileiras de entalhes curtos, um para cada noite que um carregador dormiu ali, e em alguns trechos estão tão juntos que se gastaram num sulco só. Tomás tentou contá-los num inverno e desistiu quando chegou a quatro mil. Na quarta tarde, a trilha sai do pinheiral para uma crista sem árvores, e lá embaixo aparece Castrel: uma cidade cinzenta em volta de uma praça que ainda se chama Mercado do Sal, embora ninguém venda sal ali há cem anos. Como faziam os carregadores, Tomás deixa um punhado de sal na borda de pedra do chafariz antes de beber. Depois se vira e toma o caminho do mar. Os carregadores nunca dormiam em Castrel, ele diz, porque as estalagens de lá cobravam em moeda e um carregador recebia em grãos. :swatch{color="band"} :::callout{type="colophon"} Obra de ficção: Arvela, Sorra, Orsa e Castrel são lugares imaginários. Composto em Newsreader, Young Serif e Inter Tight (SIL Open Font License) · Texto: original, CC BY 4.0 · Foto: modelos de difusão. :::`; // The opener's image element points at the photograph by id. const resources = [landscape]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // #region fonts: every face the design uses, loaded first (gotcha: fonts-first) const FONTS = { Newsreader: ['400', '400i', '700'], // text 'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400 'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: fonts, the photograph, one buildDocument call with a fresh config, then paint await loadFonts(FONTS, markdown); await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); // showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }). showPages(doc, { title: t({ en: 'From a Markdown string to a designed page', es: 'De una cadena Markdown a una página diseñada', pt: 'De uma string Markdown a uma página diagramada' }), }); // #endregionKit · 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
#Veja o que a configuração substitui
Componha com uma configuração vazia e o mesmo Markdown sai numa página de 17 × 24 cm em EB Garamond de 8 pt, com títulos azuis em Open Sans e um cabeço azul sobre um fio; o kit carrega essas fontes para você, com um aviso no console.
- () => buildDocument({ markdown, resources }, config()), markdown);
+ () => buildDocument({ markdown, resources }, {}), markdown);#Troque a cor da página inteira
Mude a cor de destaque e a execução seguinte pinta na nova cor os fólios, os intertítulos e o sinal de fim, porque col() copia o novo hexadecimal ao lado do identificador cada vez que config() roda. A fotografia mantém as próprias cores.
- band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
+ band: '#3f5b3a', // the one accent (a pine green): the folios, the subheads, the end mark#Pinte uma página no seu próprio canvas
renderPageToCanvas dimensiona o canvas com o tamanho da página em pixels na resolução do documento (300 dpi por padrão) vezes scale, então scale: 0.5 pinta a cerca de 150 dpi, e o CSS decide o tamanho com que aparece na tela.
-showPages(doc, {
- title: t({ en: 'From a Markdown string to a designed page',
- es: 'De una cadena Markdown a una página diseñada',
- pt: 'De uma string Markdown a uma página diagramada' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });Erros comuns
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
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
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 →
Erro comum
fontFamily é um nome de família, nunca uma pilha CSS
Uma pilha como 'Lora, serif' é lida como uma única família que não existe, então o texto é medido sem aviso com uma fonte substituta, e canvas, HTML e PDF não coincidem. Informe uma única família. Fontes antes da diagramação →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
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
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 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 →
- A Young Serif tem um peso só. Os títulos saem em negrito por padrão, então aqui
headings.fontWeight: 400é obrigatório: sem ele, o kit procura uma Young Serif negrito que o Fontsource não tem, e o pen para com um erro.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imagens
- As salinas e a duna ao anoitecer da abertura, uma fotografia · Generated With Diffusion Models · original
- Fontes
- Newsreader (SIL OFL 1.1) · Young Serif (SIL OFL 1.1) · Inter Tight (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)


