Pular para o conteúdo principal
Receita número 12

Receitas · Capítulo 10 · Saída e integração

De uma string Markdown a uma página diagramada

Uma string Markdown com frontmatter vira páginas de revista em duas colunas pintadas em canvas, com seis cores nomeadas no lugar do azul padrão.

Nesta página
Saída
Canvas
Postext
Testada com o Postext 1.20.3
Requer ≥ 1.4.1
Licença
Atualizada em 26 de set. de 2026
Código MIT · Texto CC BY 4.0
  • Refile 180 × 240 mm
  • 2 colunas, medianiz de 6 mm
  • Newsreader 9,5/13,5
  • Inter Tight
  • Young Serif
  • 2 páginas
  • Nível
  • Postext 1.20.3
  • Diagramado em 49 ms
  • 174 linhas de código

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

script.js · linhas 126–163no código completo
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

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

script.js · linhas 223–227no código completo
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

script.js · linhas 23–43no código completo
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

script.js · linhas 47–53no código completo
// 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

script.js · linhas 57–92no código completo
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

script.js · linhas 96–115no código completo
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

script.js · linhas 232–241no código completo
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

Sandbox
// ═══ 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.mdtitle: "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' }), }); // #endregion
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

#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

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)
Sandbox