Em poucas palavras
As páginas de abertura de capítulo de um livro de geologia. Uma faixa colorida com um número grande vai até a borda de cima da página, e o desenho é definido uma vez só para todos os capítulos.
O que você vai compor
As aberturas de capítulo de Earth in Motion, um livro didático de introdução à geologia numa página de 210 × 280 mm. O capítulo 3 abre sob uma faixa terracota sangrada no alto da página. Nela vão um chapéu espacejado, o título quebrado onde o autor quis, uma linha fina em itálico e um número de capítulo de 168 pt apoiado no fio escuro da faixa, junto à margem externa. Duas colunas justificadas começam sob a faixa, e o corte de uma placa que afunda ocupa o pé da página. O capítulo 4 repete o desenho em verde-azulado, até o marcador lateral, os números de seção, as remissões e o rótulo da sua figura. O desenho é definido uma vez, no título de nível 1. A linha de título de cada capítulo traz como atributos o tema do chapéu e a linha fina, e nomeia um estilo quando o capítulo muda de cor.
Esta receita responde a
- Como faço uma abertura de capítulo com uma faixa de cor sangrada, um número de capítulo grande e o título?
- Como coloco o título do capítulo na largura da página enquanto o texto segue em duas colunas abaixo?
- Como acrescento a uma abertura uma linha de autor, uma linha fina ou um primeiro parágrafo com capitular?
- Como dou a cada capítulo sua própria foto, cor ou variante de abertura?
- Como numero os títulos (1, 1.1, 1.1.1) e dou a cada nível um estilo diferente?
A resposta curta
const TOP = 24; // top margin, mm: the opener's container starts here
const OUTER = 16; // outer margin, mm: the numeral and the folios line up on it
const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
const BAND = 100; // mm from the trim to the foot of the band
const NUMERAL = 168; // pt; the baseline of a design text sits 0.8 down its line box
const PT = 25.4 / 72; // mm in a point
const AIR = 12; // mm, foot to text: ≥ 0.2 × NUMERAL × PT ≈ 11.9, the numeral box below its baseline
const BEARING = 2; // mm past the text edge: a rough optical nudge, as each digit's bearing differs
const bleedTop = { to: 'bleed', edge: 'top-left' }; // the trim's corner, pushed out by BLEED
// Design text centres and cuts with '…' by default (gotcha: overflow-ellipsis-default).
const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) },
size: { width: mm(width) } }); // chained: a longer title pushes the standfirst down
const opener = { enabled: true,
// The text starts below the lowest element or minHeight (gotcha: opener-reserves-anchored).
minHeight: mm(BAND - TOP + AIR), // clears the numeral, so the text starts AIR below the foot
slot: {
elements: [ // array order is paint order: the band first, the type on top
{ kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
anchor: bleedTop, size: { height: mm(BLEED + BAND) } } }, // no width: runs to the far edge
{ kind: 'rule', id: 'foot', color: col('ink'), thickness: mm(1.6), placement: {
anchor: bleedTop, offset: { y: mm(BLEED + BAND) } } },
// {numberDecimal} is empty on headings (gotcha: heading-number-placeholders)
{ kind: 'text', id: 'numeral', content: '{chapterNumber}', ...onBand,
fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(NUMERAL),
lineHeight: 1, // a multiple of the size, never pt() (gotcha: design-lineheight-multiple)
placement: { anchor: { to: 'page', edge: 'top-right' }, // the fore-edge of a recto
offset: { x: mm(BEARING - OUTER), y: mm(BAND - 0.8 * NUMERAL * PT) } } }, // on the foot
{ kind: 'text', id: 'kicker', ...onBand, fontFamily: 'Archivo Narrow', fontWeight: 600,
content: `${t({ en: 'Chapter', es: 'Capítulo' })} {chapterNumber} · {attr.topic}`,
fontSize: pt(8.5), letterSpacing: pt(2), textTransform: 'uppercase',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(7) } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, // breaks at the \\
fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(34), lineHeight: 1.02,
placement: below('kicker', 3, 118) },
{ kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: 'Roboto Serif',
italic: true, fontSize: pt(10.5), lineHeight: 1.36, placement: below('title', 6, 100) },
],
},
}; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener }
Ingredientes
- Funcionalidades
- Aberturas desenhadasFaixa de capítulo em largura totalAtributos de títuloQuebras de linha nos títulosEstilos de títuloCores por parteTítulos numeradosCapítulos que abrem em página ímparAncoragem de elementos de designTextos, fios e caixas nos designs de páginaFaixas sangradas e guias lateraisPaleta de cores semânticaCabeços e fóliosCabeços por tipo de páginaCitações que posicionam as figurasPosicionamento de figurasFiguras e tabelas como recursosLivros compostos capítulo a capítuloPáginas em um canvas
- Também usa
- Sangria e marcas de corteFigura e Tabela no seu idiomaEstilos de parágrafoTipos de recurso personalizados
- Tipografia
- Roboto Serif, Archivo, Archivo Narrow (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Pendurar a faixa e o número na página
O código é a resposta curta lá em cima. A faixa e o seu fio pendem do canto superior esquerdo da sangria. Como toda caixa ou fio sem largura própria, eles se esticam até a borda oposta dessa moldura, então chegam ao refile, e à sangria assim que BLEED é definido. O número pende do canto superior direito da página: y: BAND - 0.8 * NUMERAL * PT apoia a sua linha de base no fio, e BEARING desloca a sua caixa 2 mm para dentro da margem para compensar o espaço lateral do algarismo, de modo que a tinta fica mais ou menos alinhada com a borda do texto. O chapéu pende do contêiner. O título pende abaixo do chapéu e a linha fina abaixo do título, por âncoras below com overflow: 'wrap', então um título mais longo empurra a linha fina para baixo. A linha fina é um elemento de texto preenchido com o atributo lead do título; uma linha de autor seria mais um, {attr.author}, encadeado do mesmo jeito. minHeight: BAND - TOP + AIR define onde começa o texto. A caixa do número desce 0,2 vez o seu corpo (11,9 mm) abaixo do fio, e uma abertura reserva espaço até o seu elemento mais baixo, por isso AIR precisa ter pelo menos 11,9 mm; com menos, seria a caixa do número que definiria onde começa o texto.
#2 · Dar a página ao título do capítulo
headings: {
fontFamily: 'Archivo', color: col('band'),
levels: [
// span: 'page' alone opens each chapter on the next page, whatever its side. The break,
// which any headings object drops (gotcha: headings-drop-h1-break), is restated for its
// parity: 'odd' keeps openers on rectos, so the numeral's right-edge anchor is the fore-edge.
// The copy is fitted so no chapter spills a few lines onto a page before an odd opener:
// 1.4.1 sets such a page's column shorter than its text and drops the last line.
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
// 0: the hidden title's default 0.5 em would add a grid line, and AIR alone sets the gap
marginBottom: pt(0), advancedDesign: opener },
{ level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below
],
},
span: 'page' abre cada capítulo numa página nova e estende o título de nível 1 sobre as duas colunas; advancedDesign desenha a abertura no lugar do texto do título, e o corpo começa abaixo da altura reservada. Sozinho, span toma a página seguinte, recto ou verso, e qualquer objeto headings desativa a quebra padrão para um recto. Por isso a quebra é declarada de novo com parity: 'odd', que põe cada abertura num recto, onde a âncora à direita do número é a margem externa. No nível 2, numberingTemplate: '{1}.{2}' numera as seções 3.1, 3.2… na cor da faixa; um nível 3 com '{1}.{2}.{3}' seguiria com 3.1.1.
#3 · Nomear a cor do capítulo uma vez só
const palette = {
ink: '#1b1f23', // text: a cool near-black
band: '#c2562b', // the chapter colour: band, tab, section numbers, references, captions
band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
land: '#d5cbc0', // the crust in the drawings
muted: '#6b6259', // running heads and the colophon
paper: '#ffffff', // type on the band
};
// col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design
// elements and the reference colour from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
{ id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// {style="teal"} on a heading line swaps 'band' up to the next level-1 heading: the style's
// palette reaches design slots through their link and repaints every text colour equal to
// the base 'band'. (Swapping colorPalette itself would not reach the design slots.)
const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
Cada cor da configuração se liga a uma destas entradas. Os próprios valores padrão do motor se ligam a main-color, então apontar essa entrada para a cor da faixa muda também todos os valores padrão que a configuração deixa sem definir. A linha de título do capítulo 4, # Fire from \\ Below {style="teal" topic="Volcanoes" lead="…"}, escolhe o estilo, que troca band até o próximo título de nível 1. Os espaços de design acompanham a troca pela sua ligação com a paleta, e as cores de texto iguais ao band de base são repintadas, de modo que o marcador lateral, os números de seção e de lista, as remissões, o rótulo da legenda e o fólio no pé ficam verde-azulados nas páginas 43 e 44. A mesma linha preenche {attr.topic} e {attr.lead}, e a sua \\ quebra {titleText} onde o autor quer (atributos de título, quebras de linha nos títulos).
#4 · Deixar a primeira citação posicionar cada figura
const figure = (id, placement, caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id].width, height: ART[id].height },
placement, caption, altText: ALT[id] }); // altText: read aloud in a PDF or HTML edition
const resources = [
// 'auto' may take the citing page's foot, 'top' never can (gotcha: top-float-next-page)
figure('plate-cycle', { position: 'auto', span: 'page' },
t({ en: 'A plate’s life in cross-section: born at a ridge (left), it cools as it drifts away '
+ 'and sinks under a continent; dots mark earthquakes.',
es: 'La vida de una placa, en corte: nace en la dorsal (izquierda), se enfría al alejarse y '
+ 'se hunde bajo el continente; los puntos son terremotos.' })),
figure('two-volcanoes', { position: 'bottom', span: 'page' }, // under the text on a last page
t({ en: 'Two volcanoes in cross-section: a broad shield built of runny basalt flows (left) '
+ 'and a steep stratovolcano of lava and ash layers (right).',
es: 'Dos volcanes en corte: un escudo ancho, de coladas de basalto fluido (izquierda), y un '
+ 'estratovolcán de capas de lava y ceniza (derecha).' })),
];
Um :ref numera a sua figura e a coloca no primeiro espaço livre depois do parágrafo que a cita. A figura 3.1 é auto. Ela é citada na abertura enquanto as duas colunas ainda têm espaço, então ocupa o pé dessa página; como flutuante top, teria aberto a página 42, porque um flutuante nunca sobe acima da sua referência. A figura 4.1 é citada na parte de baixo da segunda coluna da página 43, onde uma faixa na largura da página já não cabe, e bottom a manda para a página 44. É a última página do capítulo, e numa última página o motor sobe os flutuantes do pé até ficarem logo abaixo das últimas linhas de texto, sem espaço acima deles.
#5 · Cabeços, marcador lateral e fólio no pé
const HEAD = 15.5; // mm from the trim to the folio's and the head's baseline: 8.5 above TOP
const INSET = 9; // mm from the folio's edge to the head: room for a three-digit folio
const TAB = { y: 36, w: 9, h: 26 }; // the thumb tab, mm: its top, its width, its height
const tabSize = (w) => ({ width: mm(w), height: mm(TAB.h) });
const baseline = (size) => mm(HEAD - 0.96 * size * PT); // box: 1.2 × size, baseline 0.8 down
const label = { fontFamily: 'Archivo Narrow', fontSize: pt(7.5), fontWeight: 600,
letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted') };
const folio = { fontFamily: 'Archivo', fontSize: pt(9), fontWeight: 800, color: col('ink') };
const side = (parity) => { // the fore-edge is a verso's left edge and a recto's right edge
const [edge, s] = parity === 'even' ? ['left', 1] : ['right', -1]; // s: into the page
const at = (to, x, y, size) => ({ anchor: { to, edge: `top-${edge}` },
offset: { x: mm(s * x), y }, ...(size && { size }) });
return [
{ kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
placement: at('page', OUTER, baseline(9)) },
// Textbooks name the chapter on the verso and the section on the recto; 1.4.1 has no
// section placeholder, so the recto names the book.
{ kind: 'text', id: `head-${parity}`, content: s > 0 ? '{chapterTitle}' : '{title}',
...label, placement: at('page', OUTER + INSET, baseline(7.5)) },
{ kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('band') }, // off the trim
placement: at('bleed', 0, mm(BLEED + TAB.y), tabSize(BLEED + TAB.w)) },
{ kind: 'text', id: `tab-no-${parity}`, content: '{chapterNumber}', ...folio, fontSize: pt(13),
color: col('paper'), // design text centres by default: here, on the trimmed tab
placement: at('page', 0, mm(TAB.y), tabSize(TAB.w)) },
].map((element) => ({ ...element, parity, pages: 'body' })); // never on an opener
};
const header = { elements: [...side('even'), ...side('odd')] };
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
...folio, color: col('band'), pages: 'opener', // centred by its anchor
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }] };
Cada elemento do cabeçalho pende de um canto superior da página e é filtrado por parity, então o fólio e o marcador ficam sempre na margem externa. A caixa do marcador pende do canto da sangria e se alarga com BLEED, para continuar sangrando depois do refile. pages: 'body' tira todos esses elementos das aberturas, que recebem um fólio no pé definido no rodapé. O verso nomeia o capítulo com {chapterTitle}, numa só linha, porque a \\ só quebra o título na abertura. Livros didáticos costumam nomear a seção no recto. O Postext 1.4.1 não tem marcador de posição para a seção, então o cabeço do recto imprime o {title} do livro; nestas quatro páginas ele nunca aparece, porque os dois rectos são aberturas.
#6 · Começar o livro no capítulo 3
const continuation = { pageNumbering: { startAt: 41 }, // an odd folio: page 1 is still a recto
headings: { h1: 2, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 3
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Chapter opener on a full-bleed band',
es: 'Apertura de capítulo sobre banda a sangre' }) });
Estas páginas são os capítulos 3 e 4 de um livro mais longo, então buildDocument recebe uma continuation com os contadores como os capítulos 1 e 2 os deixaram. Com h1: 2 nos seus headings, o próximo # é o capítulo 3: {chapterNumber} imprime 3, as seções são numeradas a partir de 3.1 e a primeira figura passa a ser a 3.1. pageNumbering.startAt: 41 começa os fólios em 41. A primeira página continua sendo um recto qualquer que seja o seu fólio, porque a paridade é contada sobre a página física e pageIndexOffset fica em 0; o primeiro fólio, 41, é ímpar para combinar.
A receita completa
// ═══ Postext Cookbook · Nº 003 · Chapter opener on a full-bleed band ══════════════ // https://postext.dev/en/cookbook/chapter-opener-bleed-band // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Roboto Serif, Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // Two chapters of a geology textbook. Every level-1 heading becomes a colour band bled off the // top of the page, its number standing on the band's foot; a heading style recolours chapter 4. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'chapter-opener-bleed-band'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: semantic colours, and the style that swaps a chapter's const palette = { ink: '#1b1f23', // text: a cool near-black band: '#c2562b', // the chapter colour: band, tab, section numbers, references, captions band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1 land: '#d5cbc0', // the crust in the drawings muted: '#6b6259', // running heads and the colophon paper: '#ffffff', // type on the band }; // col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design // elements and the reference colour from the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the band, so nothing prints blue. { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } }, ]; // {style="teal"} on a heading line swaps 'band' up to the next level-1 heading: the style's // palette reaches design slots through their link and repaints every text colour equal to // the base 'band'. (Swapping colorPalette itself would not reach the design slots.) const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }]; // #endregion // #region answer: the opener: a bleed band, the numeral on its foot, texts from the heading const TOP = 24; // top margin, mm: the opener's container starts here const OUTER = 16; // outer margin, mm: the numeral and the folios line up on it const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines const BAND = 100; // mm from the trim to the foot of the band const NUMERAL = 168; // pt; the baseline of a design text sits 0.8 down its line box const PT = 25.4 / 72; // mm in a point const AIR = 12; // mm, foot to text: ≥ 0.2 × NUMERAL × PT ≈ 11.9, the numeral box below its baseline const BEARING = 2; // mm past the text edge: a rough optical nudge, as each digit's bearing differs const bleedTop = { to: 'bleed', edge: 'top-left' }; // the trim's corner, pushed out by BLEED // Design text centres and cuts with '…' by default (gotcha: overflow-ellipsis-default). const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' }; const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, size: { width: mm(width) } }); // chained: a longer title pushes the standfirst down const opener = { enabled: true, // The text starts below the lowest element or minHeight (gotcha: opener-reserves-anchored). minHeight: mm(BAND - TOP + AIR), // clears the numeral, so the text starts AIR below the foot slot: { elements: [ // array order is paint order: the band first, the type on top { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: { anchor: bleedTop, size: { height: mm(BLEED + BAND) } } }, // no width: runs to the far edge { kind: 'rule', id: 'foot', color: col('ink'), thickness: mm(1.6), placement: { anchor: bleedTop, offset: { y: mm(BLEED + BAND) } } }, // {numberDecimal} is empty on headings (gotcha: heading-number-placeholders) { kind: 'text', id: 'numeral', content: '{chapterNumber}', ...onBand, fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(NUMERAL), lineHeight: 1, // a multiple of the size, never pt() (gotcha: design-lineheight-multiple) placement: { anchor: { to: 'page', edge: 'top-right' }, // the fore-edge of a recto offset: { x: mm(BEARING - OUTER), y: mm(BAND - 0.8 * NUMERAL * PT) } } }, // on the foot { kind: 'text', id: 'kicker', ...onBand, fontFamily: 'Archivo Narrow', fontWeight: 600, content: `${t({ en: 'Chapter', es: 'Capítulo' })} {chapterNumber} · {attr.topic}`, fontSize: pt(8.5), letterSpacing: pt(2), textTransform: 'uppercase', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(7) } } }, { kind: 'text', id: 'title', content: '{titleText}', ...onBand, // breaks at the \\ fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(34), lineHeight: 1.02, placement: below('kicker', 3, 118) }, { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: 'Roboto Serif', italic: true, fontSize: pt(10.5), lineHeight: 1.36, placement: below('title', 6, 100) }, ], }, }; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener } // #endregion // #region heads: folios and heads on body pages, a thumb tab that bleeds, drop folios const HEAD = 15.5; // mm from the trim to the folio's and the head's baseline: 8.5 above TOP const INSET = 9; // mm from the folio's edge to the head: room for a three-digit folio const TAB = { y: 36, w: 9, h: 26 }; // the thumb tab, mm: its top, its width, its height const tabSize = (w) => ({ width: mm(w), height: mm(TAB.h) }); const baseline = (size) => mm(HEAD - 0.96 * size * PT); // box: 1.2 × size, baseline 0.8 down const label = { fontFamily: 'Archivo Narrow', fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted') }; const folio = { fontFamily: 'Archivo', fontSize: pt(9), fontWeight: 800, color: col('ink') }; const side = (parity) => { // the fore-edge is a verso's left edge and a recto's right edge const [edge, s] = parity === 'even' ? ['left', 1] : ['right', -1]; // s: into the page const at = (to, x, y, size) => ({ anchor: { to, edge: `top-${edge}` }, offset: { x: mm(s * x), y }, ...(size && { size }) }); return [ { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio, placement: at('page', OUTER, baseline(9)) }, // Textbooks name the chapter on the verso and the section on the recto; 1.4.1 has no // section placeholder, so the recto names the book. { kind: 'text', id: `head-${parity}`, content: s > 0 ? '{chapterTitle}' : '{title}', ...label, placement: at('page', OUTER + INSET, baseline(7.5)) }, { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('band') }, // off the trim placement: at('bleed', 0, mm(BLEED + TAB.y), tabSize(BLEED + TAB.w)) }, { kind: 'text', id: `tab-no-${parity}`, content: '{chapterNumber}', ...folio, fontSize: pt(13), color: col('paper'), // design text centres by default: here, on the trimmed tab placement: at('page', 0, mm(TAB.y), tabSize(TAB.w)) }, ].map((element) => ({ ...element, parity, pages: 'body' })); // never on an opener }; const header = { elements: [...side('even'), ...side('odd')] }; const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...folio, color: col('band'), pages: 'opener', // centred by its anchor placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }] }; // #endregion const LEAD = 13.2; // body leading in pt: the baseline grid const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes: defaultResourceTypes(LANG), // "Figura" in Spanish (gotcha: resource-types-locale) colorPalette, headingStyles, page: { width: mm(210), height: mm(280), dpi: 150, // a textbook trim; 150 dpi is for the screen cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) }, // bleed and crop marks once BLEED is set margins: { top: mm(TOP), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(8) }, bodyText: { fontFamily: 'Roboto Serif', fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // book texture // #region levels: the H1 takes the page and draws the opener; sections number in the band headings: { fontFamily: 'Archivo', color: col('band'), levels: [ // span: 'page' alone opens each chapter on the next page, whatever its side. The break, // which any headings object drops (gotcha: headings-drop-h1-break), is restated for its // parity: 'odd' keeps openers on rectos, so the numeral's right-edge anchor is the fore-edge. // The copy is fitted so no chapter spills a few lines onto a page before an odd opener: // 1.4.1 sets such a page's column shorter than its text and drops the last line. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, // 0: the hidden title's default 0.5 em would add a grid line, and AIR alone sets the gap marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below ], }, // #endregion orderedLists: { fontFamily: 'Archivo', color: col('band'), // numbers bold by default marginTop: pt(0), marginBottom: pt(0) }, captionStyle: { fontFamily: 'Archivo Narrow', fontSize: pt(8.5), labelColor: col('band'), gap: mm(2.2) }, // the text in bodyText's ink, the label bold in the chapter colour paragraphStyles: [{ id: 'colophon', fontFamily: 'Archivo Narrow', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 83 linhas · content.en.md
title: "Earth in Motion" subtitle: "An Introduction to Geology" --- # The Restless \\ Crust {topic="Plate tectonics" lead="The ground under your feet is moving at about the speed your fingernails grow. This chapter follows the plates that carry the continents and the oceans, and the seams where they part, collide and grind past each other."} The Earth’s surface is a mosaic of rigid plates, some of them larger than a continent, that move slowly but without pause over the hotter rock beneath them. New sea floor forms along the ridges in the middle of the oceans and is destroyed where it sinks back into the mantle at the deep trenches (:ref{id="plate-cycle"}). The continents travel with the plates like luggage on a conveyor belt, and nearly every earthquake, volcano and mountain range on the planet lies where two plates meet. The idea took a long time to be accepted. In 1915 the German meteorologist Alfred Wegener noticed that the coasts on either side of the Atlantic fit together like the pieces of a jigsaw, and argued that the continents had once been joined in a single landmass. He pointed to fossils of the same land reptiles on both sides of the ocean, rock formations that matched across it and the scratches of ancient glaciers in what are now the tropics. What he could not find was a force strong enough to push a continent through solid rock, and for half a century most geologists rejected the idea. The answer came from the ocean floor, where surveys after the Second World War revealed long undersea mountain chains, and rocks that grow steadily older with distance from their crests. ## A planet in pieces The outer shell of the Earth, the lithosphere, is cold and rigid. It takes in the crust and the uppermost part of the mantle, and it is about a hundred kilometres thick: much more under the old cores of the continents, much less under young oceans. Beneath it lies the asthenosphere, rock that is solid but hot enough to flow, very slowly, like warm wax. The lithosphere is broken into seven large plates and a dozen or so smaller ones, and each of them moves as a single slab over the softer layer below. Plates carry two kinds of crust. Oceanic crust is thin, about seven kilometres, and made of dark, dense basalt. Continental crust is 30 to 50 kilometres thick and made largely of lighter, granite-like rock. When plates meet, the denser ocean floor sinks into the mantle and the continent stays on top. That is why the oldest ocean floor anywhere is only about 180 million years old, while rocks on the continents reach back four billion years. Plates move between one and fifteen centimetres a year. At five centimetres a year a plate travels fifty kilometres in a million years, and the Atlantic has opened by several thousand kilometres since the age of the dinosaurs. ## Where plates meet Geologists recognise three kinds of plate boundary, named for the way the plates move against each other: 1. **Divergent boundaries**, where plates pull apart and magma rises to fill the gap. Most lie along the mid-ocean ridges; Iceland is a piece of the Mid-Atlantic Ridge that stands above the sea. 2. **Convergent boundaries**, where plates collide. When ocean floor meets a continent, the denser plate slides beneath the other and sinks into the mantle, a process called *subduction*. When two continents meet, neither sinks, and the crust crumples into ranges such as the Himalaya. 3. **Transform boundaries**, where plates slide past each other sideways: they lock, strain and then slip again. The San Andreas Fault in California is the best-known example on land. Each kind has its own pattern of earthquakes and volcanoes. Where plates part, the earthquakes are shallow and the eruptions quiet, pouring out runny basalt. Where they collide, they cause the most powerful earthquakes on Earth, open deep trenches and raise chains of explosive volcanoes. Where they slide past each other, earthquakes are frequent but volcanoes are almost absent, because crust is neither made nor destroyed there. ## Reading the evidence The clearest evidence for plate tectonics comes from the magnetism of the sea floor. As basalt cools at a ridge, tiny crystals of magnetite line up with the Earth’s magnetic field, like compass needles frozen in place. The field reverses its direction every few hundred thousand years on average, so the sea floor records the reversals as stripes of normal and reversed magnetism. In 1963 Fred Vine and Drummond Matthews explained the stripes, and by 1966 surveys had shown that those on either side of a ridge are mirror images of each other, which is what you would expect if new floor forms at the crest and moves away in both directions. The earthquake record agrees. On a map, earthquakes fall in narrow belts along the plate boundaries. Beneath the trenches they fall on an inclined plane that dips under the neighbouring plate to depths of 700 kilometres, marking the path of the sinking slab. Chains of volcanic islands add a third line of evidence. The Hawaiian Islands grew over a hot spot, a plume of unusually hot rock rising from deep in the mantle that stays put while the plate slides over it. Each volcano is carried away from the plume in turn and dies, and a new one grows in its place. The volcanoes line up like a trail of footprints: the Big Island still erupts, Kauai is about five million years old, and the drowned volcanoes beyond Midway are older still. The direction and the spacing of the chain record where the Pacific Plate has gone, and how fast. Today the motion can be measured directly. GPS receivers bolted to bedrock on every continent fix their own positions to within a millimetre or two, and they show Hawaii creeping north-west towards Japan and Australia moving so fast, about seven centimetres a year, that the country has to revise its official coordinates every few decades. Wegener thought that the continents ploughed through the rock of the ocean floor. In fact they ride on the plates, and the plates are driven by the slow escape of heat from the Earth’s interior. The next chapter turns to the places where that heat reaches the surface. # Fire from \\ Below {style="teal" topic="Volcanoes" lead="Volcanoes are where the heat of the Earth’s interior reaches the surface. This chapter asks why solid rock melts, why some magma pours out quietly while some explodes, and how the shape of a mountain records the way it erupts."} About 1,350 volcanoes on land are considered potentially active, meaning that they have erupted in the last 10,000 years, and many more lie hidden on the sea floor. Three quarters of the active ones stand around the rim of the Pacific Ocean, the so-called Ring of Fire, above the subduction zones described in the previous chapter (:ref{id="plate-cycle"}). Most of the others line the mid-ocean ridges or sit over isolated hot spots such as Hawaii. Every volcano is a pile of its own eruptions. Each one adds a layer of lava or ash to the slopes, so the shape of the mountain records how it has erupted over thousands of years. Its eruptions, in turn, depend on where and how its magma formed. ## Where magma comes from The mantle is almost entirely solid. It melts only when something upsets the balance between temperature and pressure, and there are three ways that happens. At ridges and hot spots, hot rock rises and the pressure on it falls; lower pressure lowers the melting point, so some of the rock melts without getting any hotter. This is *decompression melting*. At subduction zones, the sinking slab releases water into the hot mantle above it, and water, like salt on an icy road, lowers the melting point. This is *flux melting*, and it feeds the volcanoes of the Andes, Japan and the Cascades. Finally, a body of hot magma can melt the crust around it, adding to its own volume and changing its chemistry. Molten rock is lighter than the rock around it, so it rises and collects in magma chambers a few kilometres below the surface. There it may cool slowly into granite or gabbro and never erupt at all, or it may find a way out through the central conduit or through a crack in the side of the mountain. ## Quiet and violent eruptions Whether an eruption oozes or explodes depends mostly on two things: how much silica the magma holds and how much gas is dissolved in it. Silica makes magma thick. Basalt, about half silica by weight, is runny, and its gas escapes easily; its eruptions send out rivers of lava, as on Hawaii and Iceland, and build broad, gently sloping shield volcanoes. Magmas richer in silica, such as andesite and rhyolite, are so stiff that the gas cannot get out. Pressure builds until the magma shatters into ash and pumice, and the volcano blows apart. The cone volcanoes of subduction zones, called *stratovolcanoes*, record both kinds of behaviour. They are built of alternating layers of lava and ash, each layer an eruption, which is why their slopes are steep and their outlines so regular (:ref{id="two-volcanoes"}). Mount Fuji is one. Mount St Helens had the same outline until 18 May 1980, when the north side of the mountain collapsed in a landslide and the sudden release of pressure drove a sideways blast across 600 square kilometres of forest. The largest explosive eruptions reach far beyond their own region. In 1991 Mount Pinatubo in the Philippines injected about fifteen million tonnes of sulphur dioxide into the stratosphere. There it formed a veil of fine droplets that reflected sunlight back into space and lowered the average temperature of the whole planet by about half a degree Celsius for more than a year. Volcanologists compare eruptions on the Volcanic Explosivity Index, a scale from 0 to 8 based mainly on the volume of rock and ash thrown out, on which most steps are ten times larger than the one below. The lava fountains of Kilauea rate 0 or 1, Mount St Helens rated 5 and Pinatubo 6. In 1815 Tambora, in Indonesia, rated 7, and the year that followed was remembered in Europe and North America as the year without a summer. ## Living with volcanoes Some 800 million people live within a hundred kilometres of an active volcano, drawn by fertile soils and geothermal heat, or because their families have always lived there. Most volcanoes give warning before they erupt: swarms of small earthquakes as magma forces its way upwards, a swelling of the ground that satellites can measure to the centimetre, and changes in the gases that seep from the summit. Observatories that watch for these signs have given many towns time to evacuate. In Iceland, geothermal heat from young volcanic rock warms about nine homes in every ten and generates nearly a third of the country’s electricity, and the richest farmland of Java and southern Italy lies on weathered volcanic ash. Most lava flows move slowly enough for people to walk away; they bury houses, roads and fields but seldom take lives. Far more people have died in pyroclastic flows, fast, scorching avalanches of gas, ash and rock that can race down a mountainside at more than a hundred kilometres an hour, and in mudflows, which form when an eruption melts snow or when rain soaks loose ash. Scientists can say that an eruption is becoming more likely, but they can rarely say when it will happen or how large it will be, and a false alarm has its own cost when a city has to be emptied. Around Naples, where more than half a million people live in the zone most exposed to Vesuvius, the emergency plan counts on about three days of warning to move them all to safety. ## Check your understanding 1. Why does ocean floor sink at a subduction zone while a continent stays at the surface? 2. Explain how adding water to hot mantle rock can make it melt without heating it. 3. Mount Fuji and Mauna Loa are both volcanoes. Why is one steep and the other broad? 4. What signs warn volcanologists that magma is rising towards the surface? 5. Why can a single large eruption lower the temperature of the whole planet for a year? :::paragraphs{style="colophon"} From *Earth in Motion*, an imaginary textbook. Set in Roboto Serif, Archivo and Archivo Narrow (SIL OFL) · Text and drawings: original, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: two drawings made of shapes only; their labels live in the captions // No text in the SVG: an SVG image cannot use the page's web fonts (gotcha: svg-no-webfonts). const PX = 10; // SVG pixels per viewBox unit: 348 units = 174 mm, so about 500 dpi in print const svg = (w, h, body) => ({ width: w * PX, height: h * PX, // the size the resource declares markup: `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" height="${h * PX}" ` + `viewBox="0 0 ${w} ${h}">${body}</svg>` }); const fill = (id, a = 1) => `fill="${palette[id]}" fill-opacity="${a}"`; const path = (d, paint) => `<path d="${d}" ${paint}/>`; const poly = (pts, paint) => path(`M${pts.join('L').replaceAll(',', ' ')}Z`, paint); const dot = (x, y, r, paint) => `<circle cx="${x}" cy="${y}" r="${r}" ${paint}/>`; const plume = (x, y, w, h, paint, m = y + h / 2, f = y + h * 0.8) => path(`M${x} ${y}` + `C${x} ${m} ${x + w} ${m} ${x + w} ${f}A${w} ${h / 5} 0 0 1 ${x - w} ${f}` + `C${x - w} ${m} ${x} ${m} ${x} ${y}Z`, paint); // a teardrop of magma, its tip at (x, y) const arrow = (x, y, dx, id, a = 1, turn = 0, s = Math.sign(dx)) => path( // never a <marker> `M${x} ${y - 0.8}h${dx - s * 5}v-1.8l${s * 5} 2.6l${-s * 5} 2.6v-1.8H${x}Z`, `${fill(id, a)} transform="rotate(${turn} ${x} ${y})"`); // Each drawing takes its chapter's colour: a picture's colours are fixed when it is drawn. function plateCycle(hot) { // a plate is born at a ridge and sinks under a continent const top = [[0, 30], [40, 23], [80, 30], [160, 32], [212, 34], [222, 41], [254, 54], [300, 74], [348, 96]]; // the sea floor, then the top of the sinking slab const under = (d) => top.map(([x, y], i) => [x, y + (d[i] ?? d.at(-1))]); const back = (points) => [...points].reverse(); const crust = under([5]); const lith = under([9, 4, 11, 15]); const quakes = [226, 239, 252, 265, 278, 291, 304, 317, 330].map((x, i) => // in the slab dot(x, 40 + (x - 222) * 0.44 + (i % 3), 1.5, `${fill('ink')} stroke="${palette.paper}" ` + 'stroke-width=".6"')); return svg(348, 102, `<rect y="24" width="348" height="80" ${fill(hot, 0.15)}/>` + poly([[0, 16], [238, 16], [222, 41], ...back(top.slice(0, 5))], fill('band2', 0.3)) + poly([...crust, ...back(lith)], fill('muted', 0.45)) // the plate + poly([...top, ...back(crust)], fill('ink', 0.82)) // its oceanic crust + poly([[222, 41], [238, 16], [262, 15], [270, 10], [277, 5], [281, 5], [288, 10], [296, 14], [318, 12], [327, 9], [336, 12], [348, 10], [348, 50], [300, 45], [258, 44], [240, 47]], fill('land')) // the continent and its volcano + plume(41, 23, 9, 26, fill(hot)) + plume(279, 5, 5.5, 26, fill(hot)) + quakes.join('') + dot(272, 51, 2.2, fill(hot)) + dot(275, 43, 1.7, fill(hot)) // melt rising + arrow(34, 19, -26, 'ink', 0.7) + arrow(90, 23, 70, 'ink', 0.7) // the plates part + arrow(60, 68, 56, hot, 0.5) + arrow(284, 74, 31, 'paper', 1, 25)); } function twoVolcanoes(hot) { // a shield (left) and a stratovolcano (right), cut in half const G = 70; // the ground line const shield = (x, w, h) => `M${x - w} ${G}C${x - w * 0.55} ${G - 2} ${x - w * 0.35} ${G - h} ` + `${x} ${G - h}C${x + w * 0.35} ${G - h} ${x + w * 0.55} ${G - 2} ${x + w} ${G}Z`; // convex const cone = (x, w, h, c = w * 0.055) => `M${x - w} ${G}C${x - w * 0.5} ${G - 3} ${x - w * 0.2} ` + `${G - h * 0.6} ${x - c} ${G - h}H${x + c}` // concave flanks around a crater + `C${x + w * 0.2} ${G - h * 0.6} ${x + w * 0.5} ${G - 3} ${x + w} ${G}Z`; const layers = (shape, x, sizes, paints) => sizes.map(([w, h], i) => // eruption on eruption path(shape(x, w, h), fill('paper')) + path(shape(x, w, h), paints[i % 2])).join(''); const ash = [[267, 10, 4], [274, 7, 5.5], [282, 8.5, 5], [289, 5.5, 5], [297, 7, 4.5], [304, 5, 3.5], [279, 12, 3.5], [292, 10, 3.5]].map(([x, y, r]) => dot(x, y, r, fill('land'))); return svg(348, 92, `<rect y="${G}" width="348" height="${92 - G}" ${fill('land')}/>` + layers(shield, 91, [[86, 24], [70, 18], [54, 12], [38, 7]], [fill(hot, 0.3), fill(hot, 0.5)]) + layers(cone, 269, [[74, 56], [61, 46], [48, 36], [35, 26], [22, 16]], [fill(hot, 0.55), fill('muted', 0.35)]) // lava, then ash + ash.join('') // the explosive one blows ash; the shield only pours lava + `<ellipse cx="91" cy="80" rx="24" ry="4.5" ${fill(hot)}/>` // a shallow magma chamber + `<ellipse cx="269" cy="86" rx="20" ry="5" ${fill(hot)}/>` // a deeper one + path(`M89.5 77V${G - 24}h3V77Z`, fill(hot)) + path(`M267.5 83V${G - 56}h3V83Z`, fill(hot))); } const ART = { 'plate-cycle': plateCycle('band'), 'two-volcanoes': twoVolcanoes('band2') }; const ALT = { // the drawings described, for the figures' altText 'plate-cycle': t({ en: 'A dark plate spreads from a ridge on the left, where magma rises, runs ' + 'under a pale sea and bends down beneath a continent topped by a volcano; a row of dots ' + 'along the sinking slab marks earthquakes, and arrows show the plates moving apart.', es: 'Una placa oscura nace en una dorsal a la izquierda, donde sube el magma, corre bajo un mar ' + 'claro y se dobla hacia abajo bajo un continente coronado por un volcán; una fila de puntos ' + 'a lo largo de la placa que se hunde marca los terremotos, y unas flechas muestran cómo se ' + 'separan las placas.' }), 'two-volcanoes': t({ en: 'On the left, a low, broad volcano of thin layers over a shallow magma ' + 'chamber; on the right, a steep cone of alternating lava and ash layers over a deeper ' + 'chamber, with a cloud of ash above its crater.', es: 'A la izquierda, un volcán bajo y ancho, de capas finas, sobre una cámara magmática poco ' + 'profunda; a la derecha, un cono empinado de capas alternas de lava y ceniza sobre una ' + 'cámara más profunda, con una nube de ceniza sobre el cráter.' }), }; // #endregion // #region figure: each figure takes the first free slot after its first citation const figure = (id, placement, caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id].width, height: ART[id].height }, placement, caption, altText: ALT[id] }); // altText: read aloud in a PDF or HTML edition const resources = [ // 'auto' may take the citing page's foot, 'top' never can (gotcha: top-float-next-page) figure('plate-cycle', { position: 'auto', span: 'page' }, t({ en: 'A plate’s life in cross-section: born at a ridge (left), it cools as it drifts away ' + 'and sinks under a continent; dots mark earthquakes.', es: 'La vida de una placa, en corte: nace en la dorsal (izquierda), se enfría al alejarse y ' + 'se hunde bajo el continente; los puntos son terremotos.' })), figure('two-volcanoes', { position: 'bottom', span: 'page' }, // under the text on a last page t({ en: 'Two volcanoes in cross-section: a broad shield built of runny basalt flows (left) ' + 'and a steep stratovolcano of lava and ash layers (right).', es: 'Dos volcanes en corte: un escudo ancho, de coladas de basalto fluido (izquierda), y un ' + 'estratovolcán de capas de lava y ceniza (derecha).' })), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Roboto Serif': ['400', '400i', '700'], Archivo: ['700', '800'], 'Archivo Narrow': ['400', '400i', '600', '700'] }; // 400i: the colophon's book title // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); for (const [id, art] of Object.entries(ART)) await loadSvg(`${id}.svg`, art.markup); // #region build: chapters 3 and 4 of a longer book, so the counters start where 2 ended const continuation = { pageNumbering: { startAt: 41 }, // an odd folio: page 1 is still a recto headings: { h1: 2, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 3 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Chapter opener on a full-bleed band', es: 'Apertura de capítulo sobre banda a sangre' }) }); // #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
#Fazer a faixa mais alta
BAND define a altura da faixa, a posição do número e a altura reservada acima do texto. Uma faixa mais alta empurra texto para a página seguinte, então reajuste cada capítulo (veja o último erro comum) ou deixe os capítulos correrem seguidos.
-const BAND = 100; // mm from the trim to the foot of the band
+const BAND = 128; // mm from the trim to the foot of the band#Imprimir com sangria
Com uma sangria de 3 mm, a faixa, o seu fio e o marcador passam 3 mm do refile, o número continua sobre o fio e page.cutLines é ativado com o mesmo valor.
-const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
+const BLEED = 3; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines#Acrescentar uma cor para outro capítulo
Cada cor de capítulo é mais uma entrada na paleta e mais um estilo; escreva {style="slate"} na linha de título desse capítulo.
band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
+ band3: '#3d4f6b', // a third chapter colour, a slate blue
@@
-const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
+const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } },
+ { id: 'slate', palette: { band: palette.band3 } }];#Deixar os capítulos correrem seguidos
Com 'any', um capítulo abre na página seguinte. Depois de um capítulo que termina num recto, ele abre no verso, sem página em branco antes, e o seu número fica junto à lombada, já que pende da borda direita da página. Estas páginas não mudam, porque o capítulo 3 já termina num verso.
- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },Erros comuns
Erro comum
Uma abertura reserva altura até o seu elemento ancorado mais baixo
Uma abertura com design avançado reserva a altura do seu elemento mais baixo, e os elementos ancorados à página ou à sangria abaixo do título também contam, então um ornamento no pé da página empurra o texto para a página seguinte. Mantenha esses ornamentos acima do título, passe-os para uma posição do cabeçalho ou do rodapé, ou defina a reserva com minHeight. Aberturas desenhadas →
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 →
Erro comum
{number}/{chapterNumber} imprimem o número do H1; {numberRoman} só nas partes
{number} e {chapterNumber} imprimem o número já formatado do título, mas {numberRoman}, {numberDecimal} e as outras variantes numéricas só são preenchidas nas páginas de parte. Formate o número do capítulo no seu numberingTemplate ({1:I}) ou passe-o como atributo. Títulos numerados →
Erro comum
Valores de atributo: sem { ou }; aspas simples se tiver "
Um valor de atributo termina na chave de fechamento, por isso não pode conter { nem }. Um valor que contém aspas duplas vai entre aspas simples; o cifrão não causa problema. Atributos de título →
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
Um flutuante 'top' nunca cai na página que o cita
Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita. Posicionamento de figuras →
Erro comum
O texto dentro de um SVG <img> não pode usar fontes web
Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →
Erro comum
Traduza Figura e Tabela com defaultResourceTypes(locale)
O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes. Figura e Tabela no seu idioma →
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 →
- A faixa tem altura fixa, enquanto o chapéu, o título e a linha fina pendem da margem superior e crescem para baixo, então uma linha fina mais longa ou um título de três linhas batem no fio. Limite as linhas finas a umas cinco linhas, como as duas edições fazem, ou calcule
BANDa partir da abertura mais longa. - A fórmula do
ydo número supõelineHeight: 1. A linha de base de um texto de design fica a 0,8 da altura da sua caixa de linha, e0.8 * NUMERALsó mede essa distância quando a caixa é tão alta quanto o corpo. Se você mudar a entrelinha ou a âncora, a fórmula tem que mudar junto.BEARINGé ajustado a olho, porque cada algarismo tem o seu próprio espaço lateral (aqui o 3 e o 4 terminam a 0,8 mm um do outro). - A paleta de um estilo muda a cor dos elementos de design e do texto. Uma imagem mantém as cores com que foi desenhada: a figura 4.1 é verde-azulada porque o código a desenha a partir de
band2, e uma figura terracota posta no capítulo 4 continuaria terracota. - Com
parity: 'odd', um capítulo que passa umas poucas linhas da sua última página deixa uma página quase vazia e um verso em branco antes da próxima abertura, e o postext 1.4.1 compõe essa página com uma coluna mais curta que o seu texto, então a última linha do capítulo se perde. Ajuste o texto, como as duas edições fazem aqui, e confira a última página de cada capítulo sempre que o texto ouBANDmudarem.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fontes
- Roboto Serif (SIL OFL 1.1) · Archivo (SIL OFL 1.1) · Archivo Narrow (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)


