Em poucas palavras
Um pequeno manual de apicultura escrito em arquivos separados, um por capítulo. O Postext junta tudo num livro só, e os números de página, capítulo e figura continuam de um arquivo para o outro.
O que você vai compor
A Beekeeper’s Year é um manual pequeno em quatro capítulos, um por estação. Ele começa pelo outono (Autumn), porque o ano do apicultor começa quando sai o último mel. Cada capítulo é um arquivo Markdown separado, sem nenhum número de página, capítulo ou figura. buildBundle leva essas contagens de um arquivo para o seguinte e compõe os capítulos, com um quinto arquivo para a capa e o sumário, como um livro de onze páginas. Todo capítulo abre numa página ímpar, sob um canto de favo que traz o número dele. O sumário, na página par diante de Autumn, espelha essa abertura com o título encostado na lombada e dá o fólio de cada capítulo. Um quadro de cria, desenhado em código, aparece uma vez em cada capítulo, das Figuras 1.1 a 4.1. A página 6 fica em branco porque Winter cabe numa página e Spring precisa abrir numa ímpar.
Esta receita responde a
- Como faço para compor um livro de vários capítulos de modo que a numeração de páginas, figuras e capítulos continue de um para o outro?
- Como acrescento um sumário que se atualiza sozinho (pontilhados, números de página, autores, linhas de parte)?
- Como forço uma quebra de página ou de coluna e faço cada capítulo começar em página ímpar?
- Como oculto os cabeços nas aberturas e nas páginas em branco, ou pinto uma página par em branco com a cor da parte?
A resposta curta
// buildBundle lays the documents out in order with one config and carries state from each to
// the next: the pages already set (so parity goes on), the folio, the chapter and figure counts.
const book = () => buildBundle({ chapters, config: config(), resources });
const config = () => ({ // a factory: the engine caches resolved configs per object
headings: { ...display, levels: [
// Every chapter opens on a recto: after a chapter that ends on one, the next document
// starts with a blank verso of its own. Restated, because any headings object drops
// the H1 break (gotcha: headings-drop-h1-break).
{ level: 1, breakBefore: { enabled: true, parity: 'odd' },
// '{1}' puts the number in the PDF bookmarks ('1 Autumn'); the contents and
// {chapterNumber} count the chapters in order either way.
numberingTemplate: '{1}', advancedDesign: opener,
span: 'page' }, // a page-wide opener, painted unclipped: the comb reaches the top edge
subhead,
] },
// The cover and the contents are headings that take no number, no contents entry and no
// running heads, so Autumn is still chapter 1. Both inherit span 'page', which starts
// each on a page of its own, and parity 'odd', which the contents turn off: it would
// leave page 2 blank (gotcha: style-inherits-break).
headingStyles: [
{ id: 'cover', numbered: false, toc: false, advancedDesign: cover, ...bare },
{ id: 'contents', numbered: false, toc: false, breakBefore: { enabled: false },
advancedDesign: contentsOpener, ...bare },
],
// Figures number {h1}.{n} and the counters carry on (Winter's is 2.1). The types are passed
// only because config.locale does not turn Figure into Figura (gotcha: resource-types-locale).
resourceTypes: defaultResourceTypes(LANG),
// :::toc in the first document lists the whole book with the folio each chapter lands on:
// buildBundle lays the book out again (three passes at most) until those folios settle.
toc: contents,
locale: t({ en: 'en-us', es: 'es' }), // hyphenation, by exact code (gotcha: hyphenation-locales)
colorPalette, page, layout: { layoutType: 'single' }, bodyText, captionStyle, calloutStyles,
header, footer, // the look: above, and in the regions below
});
Ingredientes
- Funcionalidades
- Livros compostos capítulo a capítuloSumárioCapítulos que abrem em página ímparLegendas numeradasCitações que posicionam as figurasTítulos numeradosEstilos de títuloCapítulos sem númeroCabeços por seçãoCabeços e fóliosCabeços por tipo de páginaAberturas desenhadasAtributos de títuloImagens nos designs de páginaMargens espelhadasBoxes flutuantesExportação para PDFMarcadores do PDFFigura e Tabela no seu idiomaCapas, folhas de rosto e colofões
- Também usa
- BoxesFaixa de capítulo em largura totalCor do papelFontes incorporadas ao PDFTipos de recurso personalizadosFiguras e tabelas como recursosQuebras de linha nos títulos
- Tipografia
- Andada Pro, Rozha One, Figtree (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Um arquivo por capítulo
// Nothing in a chapter says where it lands: buildBundle works that out from the order.
const chapters = [front, autumn, winter, spring, summer].map((markdown) => ({ markdown }));
Cada estação é um documento próprio, então você pode reescrever um capítulo sem abrir os outros. O primeiro documento tem o frontmatter, a capa e o sumário. No Postext 1.4.1 os documentos seguintes não herdam nenhum desses metadados, e o PDF lê o título e a autoria só do primeiro documento.
#2 · Regras que passam de um documento para outro
O código deste passo está na resposta curta, mais acima. buildBundle diagrama os documentos em ordem com uma só configuração e passa a cada um uma continuation com o que veio antes: o número de páginas físicas já compostas, para que a paridade, as margens espelhadas e os cabeços de páginas pares e ímpares continuem; o fólio seguinte; e os contadores de capítulos e figuras. Com parity: 'odd', um capítulo que vem depois de outro terminado numa página ímpar começa com uma página par em branco, e essa página pertence ao próprio documento dele (quebra antes). A capa e o sumário são estilos de título com numbered: false, e por isso Autumn continua sendo o capítulo 1. Os dois herdam span: 'page' do nível de capítulo, o que põe cada um numa página só dele sem :::pagebreak. O estilo do sumário desliga a paridade herdada, que de outro modo deixaria a página 2 em branco.
#3 · Sumário do livro inteiro
const contents = {
levels: [
// The numbers sit ~0.7 mm high in Postext 1.4.1: they are centred on the line
// (gotcha: toc-number-baseline).
{ level: 1, fontFamily: 'Rozha One', fontSize: pt(16), lineHeight: pt(18),
numberFontFamily: 'Figtree', numberFontSize: pt(11), numberFontWeight: 700,
numberColor: col('accent'), numberWidth: mm(7), numberGap: mm(4), marginTop: pt(8) },
// Sections: 9.3 pt in the muted colour, indented 11 mm (number 7 + gap 4) to the titles.
{ level: 2, fontSize: pt(9.3), lineHeight: pt(13.5), indent: mm(11), color: col('muted') },
],
pageNumber: { fontFamily: 'Figtree', fontSize: pt(8.5), fontWeight: 600, width: mm(7) },
leader: { char: '. ', gap: mm(2) },
// A second line under each chapter, from its {months="…"} heading attribute.
subtitle: { enabled: true, attr: 'months', fontFamily: 'Andada Pro', fontSize: pt(9),
color: col('muted') }, // italic by default
};
:::toc fica no primeiro documento, mas lista os capítulos e as seções do livro inteiro. buildBundle diagrama o livro, recolhe cada título com o rótulo de página que ele recebeu e diagrama o livro de novo com esse esquema até os rótulos pararem de mudar, no máximo três passadas (sumário). Os meses sob cada título vêm de um atributo do título, {months="…"}, que a abertura também imprime.
#4 · Uma abertura, espelhada no sumário
const corner = (id, edge) => ({ kind: 'image', id, resourceId: id,
placement: { anchor: { to: 'bleed', edge }, size: { width: mm(CORNER.width) } } });
const months = { kind: 'text', id: 'months', content: '{attr.months}', ...label,
color: col('accent'), placement: at('top-left', 0, 14, 'container') };
const title = { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(42),
lineHeight: 1.05, align: 'left', overflow: 'wrap', placement: below('months', 1.5, 100) };
const opener = { enabled: true, minHeight: mm(52), slot: { elements: [
corner('cells', 'top-right'),
// The number's box is centred on its cell. Comb and number both hang from the bleed's top
// right, so a bleed (page.cutLines) moves them together.
{ kind: 'text', id: 'number', content: '{chapterNumber}', ...display, fontSize: pt(40),
lineHeight: 1, align: 'center', placement: { size: { width: mm(NUMBER.box) },
...at('top-right', NUMBER_CELL.x + NUMBER.box / 2 - CORNER.width,
NUMBER_CELL.y - NUMBER.rise, 'bleed') } },
months, title,
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'Andada Pro', italic: true,
fontSize: pt(11.5), lineHeight: 1.35, color: col('ink'), align: 'left', overflow: 'wrap',
placement: below('title', 3, 88) },
] } };
// The contents mirror it on the verso: the comb in the outer corner, the book's subtitle and
// the title set flush right against the spine, on the same lines as Autumn's across the spread.
const flushRight = (element, placement) => ({ ...element, align: 'right',
placement: { ...placement, size: { width: 'fill' } } });
const contentsOpener = { ...opener, minHeight: mm(40), slot: { elements: [
corner('comb', 'top-left'),
flushRight({ ...months, content: '{subtitle}' }, months.placement),
flushRight(title, below('months', 1.5)),
] } };
{chapterNumber} continua contando a partir dos documentos diagramados antes, e assim a abertura de Spring imprime 3, embora o arquivo dela não tenha número nenhum. A caixa do número é calculada a partir do centro da célula do favo, e número e favo pendem os dois do canto superior direito da sangria; mudar o formato ou acrescentar sangria para a gráfica os move juntos. Na página do sumário, o chapéu, lido de {subtitle}, e o título ficam alinhados à direita, encostados na lombada, e o favo passa para o canto externo da página par. O título fica então na mesma altura que o de Autumn na página em frente.
#5 · Cabeços em todos os documentos
const head = (id, content, parity, placement, extra = {}) => ({
kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
...label, color: col('muted'), placement, ...extra,
});
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('accent') };
// In Postext 1.4.1 {title} is blank from the second document on (Autumn included): only the
// first one has frontmatter (gotcha: bundle-metadata). So the verso writes the title out.
const BOOK_TITLE = t({ en: 'A Beekeeper’s Year', es: 'Un año de colmenar' });
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
head('verso-title', BOOK_TITLE, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
// {chapterTitle} and {pageNumber} are worked out page by page, in every document.
head('recto-title', '{chapterTitle}', 'odd',
at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
] };
// Openers carry a drop folio instead, 8 mm under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all',
at('top', 0, 8, 'container'), { pages: 'opener', fontWeight: 700 })] };
Só o primeiro documento tem frontmatter, então no Postext 1.4.1 {title} não imprime nada nos documentos seguintes, Autumn incluído. Por isso o cabeço das páginas pares traz o título do livro digitado como texto fixo. {chapterTitle} e {pageNumber} são resolvidos página a página e saem em todos os documentos; a página 11, a única ímpar de texto corrido, traz SUMMER ao lado do fólio. pages: 'body' tira os cabeços das aberturas e da página em branco.
#6 · Páginas duplas na tela, um PDF para o livro
const text = chapters.map((chapter) => chapter.markdown).join('\n');
await loadFonts(FONTS, text);
const art = { cover: coverArt(), cells: cornerArt(true), comb: cornerArt(false) };
for (const [season, plan] of Object.entries(SEASONS)) art[`${season}-frame`] = frameArt(plan);
for (const [id, markup] of Object.entries(art)) await loadSvg(`${id}.svg`, markup);
const docs = await buildWithFonts(book, text); // one VDTDocument per Markdown document
showPages(docs, { title: BOOK_TITLE });
// renderToPdf takes the array: one file for the book, with a bookmark per chapter.
offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`);
buildBundle devolve uma lista de documentos, um por arquivo Markdown. showPages emparelha as páginas por pageIndexOffset + page.index, então a página 1 fica sozinha como ímpar, e as páginas 2 e 3, 4 e 5 e assim por diante ficam frente a frente. renderToPdf recebe a mesma lista e grava um único arquivo com os rótulos de página do livro e um marcador para cada capítulo e seção.
A receita completa
// ═══ Postext Cookbook · Nº 007 · One book from separate chapters ══════════════════════════ // https://postext.dev/en/cookbook/book-from-chapters // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Andada Pro, Rozha One, Figtree (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A handbook in four seasons written as five Markdown documents, laid out by buildBundle as // one book: parity, folios, chapter and figure numbers and the contents run straight through. import { buildBundle, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'book-from-chapters'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these, so the book can be retinted ink: '#2a2218', // text and display type honey: '#d99a1e', // the drawings only: the cover and the comb cells accent: '#7a4e12', // the text accent (7:1 on paper): numbers, labels, subheads; main-color too pollen: '#c4692b', comb: '#f7e7c4', rule: '#d8c8a8', // figures: pollen, wax, wood and walls muted: '#6e634f', paper: '#fffdf7', // running heads, colophon, contents sections; the page }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // The geometry, in mm. The drawings, the opener and the running heads are derived from it. const TRIM = { width: 150, height: 200 }; // a small handbook const MARGIN = { top: 22, bottom: 22, inner: 19, outer: 15 }; // mirrored const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // the text width: 116 mm const SQRT3 = Math.sqrt(3); // comb cells of radius r sit √3·r apart, their rows 1.5·r apart const CORNER = { width: 84, height: 70, r: 9.5 }; // the openers' comb, and its cell radius const NUMBER_CELL = { x: 4 * SQRT3 * CORNER.r, y: 2 * 1.5 * CORNER.r }; // row 2, cell 4 // The chapter number's box: 20 mm wide, its top 8 mm above the cell's centre, which is where // a 40 pt Rozha One figure sits optically centred in the cell. const NUMBER = { box: 20, rise: 8 }; const FRAME = { width: MEASURE, height: 50 }; // the brood frame figures, at the text width const HEAD = { y: 12, gap: 8 }; // running heads 12 mm from the top edge, 8 mm folio to text const LEAD = 14.2; // body leading in pt: the baseline grid // The look: a small handbook, mirrored, justified Andada Pro on the grid; Rozha One titles. const page = { // mirror: left is the inner margin, right the outer sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true }, }; const bodyText = { // justified, hyphenated, optimal line breaks and widow control: by default fontFamily: 'Andada Pro', fontSize: pt(10.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false, // Spaces stretch to 1.7× at most (the default is 2): the line breaker then takes the // hyphens it would otherwise avoid, and no line gapes. maxWordSpacing: 1.7, }; const display = { fontFamily: 'Rozha One', fontWeight: 400, color: col('ink') }; // titles // Subheads: the line box itself is a line and a half deep, so the gap to the text under it // is the same everywhere. A margin above would not do it: it drops at the head of a page. const subhead = { level: 2, fontFamily: 'Andada Pro', fontWeight: 700, fontSize: pt(12.5), lineHeight: pt(LEAD * 1.5), color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0) }; const captionStyle = { fontFamily: 'Figtree', fontSize: pt(7.6), gap: mm(2.5), labelColor: col('accent') }; // The colophon floats to the foot of the last page: a box with no background, placed // 'bottom'. One statement per paragraph, because a no-break space would not keep // "CC BY 4.0" on one line (gotcha: nbsp-breaks). const calloutStyles = [{ id: 'colophon', placement: 'bottom', backgroundEnabled: false, padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0), body: { fontFamily: 'Figtree', fontSize: pt(7), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } }]; // #region answer: five Markdown documents, one book: buildBundle and the rules they share // buildBundle lays the documents out in order with one config and carries state from each to // the next: the pages already set (so parity goes on), the folio, the chapter and figure counts. const book = () => buildBundle({ chapters, config: config(), resources }); const config = () => ({ // a factory: the engine caches resolved configs per object headings: { ...display, levels: [ // Every chapter opens on a recto: after a chapter that ends on one, the next document // starts with a blank verso of its own. Restated, because any headings object drops // the H1 break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // '{1}' puts the number in the PDF bookmarks ('1 Autumn'); the contents and // {chapterNumber} count the chapters in order either way. numberingTemplate: '{1}', advancedDesign: opener, span: 'page' }, // a page-wide opener, painted unclipped: the comb reaches the top edge subhead, ] }, // The cover and the contents are headings that take no number, no contents entry and no // running heads, so Autumn is still chapter 1. Both inherit span 'page', which starts // each on a page of its own, and parity 'odd', which the contents turn off: it would // leave page 2 blank (gotcha: style-inherits-break). headingStyles: [ { id: 'cover', numbered: false, toc: false, advancedDesign: cover, ...bare }, { id: 'contents', numbered: false, toc: false, breakBefore: { enabled: false }, advancedDesign: contentsOpener, ...bare }, ], // Figures number {h1}.{n} and the counters carry on (Winter's is 2.1). The types are passed // only because config.locale does not turn Figure into Figura (gotcha: resource-types-locale). resourceTypes: defaultResourceTypes(LANG), // :::toc in the first document lists the whole book with the folio each chapter lands on: // buildBundle lays the book out again (three passes at most) until those folios settle. toc: contents, locale: t({ en: 'en-us', es: 'es' }), // hyphenation, by exact code (gotcha: hyphenation-locales) colorPalette, page, layout: { layoutType: 'single' }, bodyText, captionStyle, calloutStyles, header, footer, // the look: above, and in the regions below }); // #endregion const bare = { header: { elements: [] }, footer: { elements: [] } }; // no running heads // #region art: honeycomb drawn in code, seeded so that every run draws the same cells let seed = 2026; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const paint = (id, opacity = 1) => `fill="${palette[id]}" fill-opacity="${opacity}"`; // A w × h mm sheet (10 px per mm) of hexagonal cells of radius r; cell(x, y) paints each one. function comb(w, h, r, cell, under = '') { let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}">${under}`; for (let row = 0, y = 0; y < h + r; y = ++row * 1.5 * r) { for (let x = (row % 2) * SQRT3 / 2 * r; x < w + 2 * r; x += SQRT3 * r) { const corner = (a) => `${(x + 0.88 * r * Math.sin(a)).toFixed(2)} ` + (y - 0.88 * r * Math.cos(a)).toFixed(2); const hexagon = [0, 1, 2, 3, 4, 5].map((i) => corner(i * Math.PI / 3)).join('L'); const fill = cell(x, y); if (fill) svg += `<path d="M${hexagon}Z" ${fill}/>`; } } return `${svg}</svg>`; } // Comb that thins out with the distance d from a corner: solid cells with some open ones up to // d = 0.78, faint open cells up to 1, nothing beyond. const fade = (d, full, open, k = 1) => (d > 1 ? '' : d > 0.78 ? paint(open, 0.14 * k) : rand() < 0.3 ? paint(open, 0.28 * k) : paint(full, (d < 0.5 ? 0.95 : 0.6) * k)); const coverArt = () => comb(TRIM.width, TRIM.height, 7.5, (x, y) => fade(Math.hypot( (TRIM.width - x) / TRIM.width, y / (TRIM.height - 10)) + rand() * 0.22, 'comb', 'accent'), `<rect width="${TRIM.width}" height="${TRIM.height}" ${paint('honey')}/>`); // A paler cluster from the outer top corner; on a recto the cell under the number is solid. const cornerArt = (recto) => comb(CORNER.width, CORNER.height, CORNER.r, (x, y) => (recto && Math.hypot(x - NUMBER_CELL.x, y - NUMBER_CELL.y) < 1 ? paint('honey') : fade(Math.hypot(((recto ? CORNER.width : 0) - x) / (CORNER.width - 4), y / (CORNER.height - 6)) + rand() * 0.25, 'honey', 'honey', 0.85))); // One brood frame through the year. Per season: the brood nest (centre v, half-width, // half-height, what fills it) and where the honey sits, on a frame that runs −1…1 each way. const SEASONS = { autumn: [0.5, 0.3, 0.42, 'accent', () => true], winter: [0.4, 0.42, 0.62, 'ink', (u, v) => v < 0.25 - 0.4 * (1 - u * u)], // ink: the cluster spring: [0.2, 0.62, 0.8, 'accent', (u, v) => v < -0.3 && Math.abs(u) > 0.45], summer: [0.45, 0.5, 0.6, 'accent', (u, v) => v < 0.35 || Math.abs(u) > 0.7], }; function frameArt([cv, ru, rv, nest, honey]) { const { width: w, height: h } = FRAME; // a top bar with 4 mm lugs, slim side bars const wood = `<path d="M0 0H${w}V4.5H${w - 4}V${h}H4V4.5H0Z" ${paint('rule')}/>` + `<rect x="6.5" y="4.5" width="${w - 13}" height="${h - 7}" ${paint('rule', 0.5)}/>`; return comb(w, h, 2.5, (x, y) => { const [u, v] = [(x - w / 2) / (w / 2 - 7.5), (y - h / 2 - 1) / (h / 2 - 4)]; const d = Math.hypot(u / ru, (v - cv) / rv) + rand() * 0.12; if (x < 7 || x > w - 7 || y < 6 || y > h - 3.5) return ''; return paint(d < 1 ? nest : d < 1.3 && nest === 'accent' ? 'pollen' : honey(u, v) ? 'honey' : 'comb', d < 1 && nest === 'ink' ? 0.8 : 1); }, wood); } // #endregion // The cover: comb over a honey page, the title low on the inner side where the comb runs out. const label = { fontFamily: 'Figtree', fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase' }; const at = (edge, x, y, to = 'page') => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const below = (id, y, width) => ({ ...at('below', 0, y, `#${id}`), ...(width && { size: { width: mm(width) } }) }); // No minHeight: the contents heading, which inherits span 'page', starts the next page. const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'text', id: 'kicker', content: '{subtitle}', ...label, fontSize: pt(8.5), color: col('ink'), placement: at('top-left', 17, 116) }, // A design text's lineHeight is a multiple (gotcha: design-lineheight-multiple). { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(54), lineHeight: 0.98, align: 'left', overflow: 'wrap', placement: below('kicker', 4, 125) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(2), color: col('ink'), placement: { ...below('title', 6), size: { width: mm(16) } } }, { kind: 'text', id: 'author', content: '{author}', ...label, fontSize: pt(9), fontWeight: 700, color: col('ink'), placement: below('rule', 5) }, ] } }; // #region opener: each chapter's opener: the number in a honey cell, months, title and lead const corner = (id, edge) => ({ kind: 'image', id, resourceId: id, placement: { anchor: { to: 'bleed', edge }, size: { width: mm(CORNER.width) } } }); const months = { kind: 'text', id: 'months', content: '{attr.months}', ...label, color: col('accent'), placement: at('top-left', 0, 14, 'container') }; const title = { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(42), lineHeight: 1.05, align: 'left', overflow: 'wrap', placement: below('months', 1.5, 100) }; const opener = { enabled: true, minHeight: mm(52), slot: { elements: [ corner('cells', 'top-right'), // The number's box is centred on its cell. Comb and number both hang from the bleed's top // right, so a bleed (page.cutLines) moves them together. { kind: 'text', id: 'number', content: '{chapterNumber}', ...display, fontSize: pt(40), lineHeight: 1, align: 'center', placement: { size: { width: mm(NUMBER.box) }, ...at('top-right', NUMBER_CELL.x + NUMBER.box / 2 - CORNER.width, NUMBER_CELL.y - NUMBER.rise, 'bleed') } }, months, title, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'Andada Pro', italic: true, fontSize: pt(11.5), lineHeight: 1.35, color: col('ink'), align: 'left', overflow: 'wrap', placement: below('title', 3, 88) }, ] } }; // The contents mirror it on the verso: the comb in the outer corner, the book's subtitle and // the title set flush right against the spine, on the same lines as Autumn's across the spread. const flushRight = (element, placement) => ({ ...element, align: 'right', placement: { ...placement, size: { width: 'fill' } } }); const contentsOpener = { ...opener, minHeight: mm(40), slot: { elements: [ corner('comb', 'top-left'), flushRight({ ...months, content: '{subtitle}' }, months.placement), flushRight(title, below('months', 1.5)), ] } }; // #endregion // #region contents: what :::toc prints: numbers in the accent, dotted leaders, folios const contents = { levels: [ // The numbers sit ~0.7 mm high in Postext 1.4.1: they are centred on the line // (gotcha: toc-number-baseline). { level: 1, fontFamily: 'Rozha One', fontSize: pt(16), lineHeight: pt(18), numberFontFamily: 'Figtree', numberFontSize: pt(11), numberFontWeight: 700, numberColor: col('accent'), numberWidth: mm(7), numberGap: mm(4), marginTop: pt(8) }, // Sections: 9.3 pt in the muted colour, indented 11 mm (number 7 + gap 4) to the titles. { level: 2, fontSize: pt(9.3), lineHeight: pt(13.5), indent: mm(11), color: col('muted') }, ], pageNumber: { fontFamily: 'Figtree', fontSize: pt(8.5), fontWeight: 600, width: mm(7) }, leader: { char: '. ', gap: mm(2) }, // A second line under each chapter, from its {months="…"} heading attribute. subtitle: { enabled: true, attr: 'months', fontFamily: 'Andada Pro', fontSize: pt(9), color: col('muted') }, // italic by default }; // #endregion // #region running-heads: the book on the verso, the chapter on the recto, folios outside const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages ...label, color: col('muted'), placement, ...extra, }); const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('accent') }; // In Postext 1.4.1 {title} is blank from the second document on (Autumn included): only the // first one has frontmatter (gotcha: bundle-metadata). So the verso writes the title out. const BOOK_TITLE = t({ en: 'A Beekeeper’s Year', es: 'Un año de colmenar' }); const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio), head('verso-title', BOOK_TITLE, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)), // {chapterTitle} and {pageNumber} are worked out page by page, in every document. head('recto-title', '{chapterTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)), head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio), ] }; // Openers carry a drop folio instead, 8 mm under the text block. const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', at('top', 0, 8, 'container'), { pages: 'opener', fontWeight: 700 })] }; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const front = String.raw`---Amostra em Markdown · 10 linhas · content.en.md
title: "A Beekeeper’s Year" subtitle: "A handbook in four seasons" author: "Clara Ibarrola" --- # A Beekeeper’s \\ Year {style="cover"} # Contents {style="contents"} :::toc`; // frontmatter, cover and contents (content.<lang>.md) const autumn = String.raw`# Autumn {months="September · October · November" lead="The beekeeper’s year begins when the last honey comes off. What you do in the next eight weeks decides whether there is a colony to wake in March."}Amostra em Markdown · 16 linhas · content.autumn.en.md
Most calendars start in January, and most beekeeping books in spring, with the first warm day and the first bees on the crocuses. This one starts in September, because that is where the bees start. The colony that flies next April is being raised now: the workers born in autumn live five or six months instead of five or six weeks, and they are the ones that carry the colony through the winter. A colony that goes into October with a young queen, twenty kilos of stores and few mites will usually come out strong in spring. ## Taking the harvest Take the last supers off by the end of August, when at least four fifths of each frame is capped. Clear the bees with a board the evening before and lift the boxes early, before the robbers are up. Leave the brood box alone; the honey in it is the bees’ winter food. Then weigh what is left. A colony in a single brood box needs about twenty kilos of stores to see out a winter like ours, which means frames like the one in :ref{id="autumn-frame"}, capped honey from the top bar almost to the bottom one. If the hive is lighter than that, feed. ## Stores for winter Feed thick syrup, two kilos of sugar to a litre of water, in a feeder over the crownboard, and feed it fast: the bees must take it down, dry it and cap it while the days are still warm enough to fly. By the middle of October it is too late for syrup, and what they have then is what they will eat. A hive that covers fewer than five frames at the end of September seldom lasts the winter on its own. Unite it with a stronger one over a sheet of newspaper: the bees chew through the paper in a day or two and mix without fighting. Treat for varroa as soon as the supers are off, before the winter bees are raised. Narrow the entrance to one bee’s width against wasps, and fit a mouse guard before the first frost. Then close the hive, and do not open it again until the first warm day of spring.`; // content.autumn.<lang>.md, and so on const winter = String.raw`# Winter {months="December · January · February" lead="Below fourteen degrees the bees draw together into a ball on the comb and live on their autumn stores."}Amostra em Markdown · 2 linhas · content.winter.en.md
The cluster in :ref{id="winter-frame"} is how the colony gets through the cold. The bees on the outside pack tight, heads in, and hold the heat in; those inside shiver their flight muscles to keep the core warm, and the whole ball moves slowly up the frames as it eats. Do not open the hive. Once a month, heft it from the back to feel the weight and clear the dead bees from the entrance.`; const spring = String.raw`# Spring {months="March · April · May" lead="The colony wakes before you do. Wait for the first warm afternoon to open the hive and see what the winter has left behind."}Amostra em Markdown · 14 linhas · content.spring.en.md
On a still day above fifteen degrees, when the bees are flying freely and coming home with pollen, you can open the hive for the first time. Work quickly and quietly, with a little smoke at the entrance, and ask only three questions: is there a laying queen, is there enough food, and is there room? Keep the visit to ten minutes at most, and close up before the brood can chill. ## The first inspection Look for eggs rather than for the queen. Eggs in the cells mean she was there within the last three days, and an egg standing upright on the base of the cell was laid yesterday. A good frame at the end of April looks like :ref{id="spring-frame"}: a solid oval of capped brood with few gaps, a band of pollen round it and the last of the winter honey in the upper corners. Patchy brood, or eggs laid two and three to a cell, means trouble with the queen; find out now, while there is still time to replace her. If the frames are light, feed. More colonies starve in March and April than in the depth of winter, because the brood is growing fast and the flowers are not yet out. Give a light colony a frame of stores from a strong one, or a block of fondant over the feed hole; syrup can wait for warmer nights. ## Room to grow By May the colony can double in a few weeks. When the bees cover seven or eight frames of the brood box, add a super, and add the next one before the first is full. A crowded colony prepares to swarm, and from now until July you should look through the brood box every seven days for queen cells. Spring is also the time to renew the comb. Early in the season, move three or four of the darkest frames to the edge of the brood box; once the queen has left them, take them out and put in frames of fresh foundation. With nectar coming in, the bees draw new comb eagerly, and in three years every frame in the box has been replaced.`; const summer = String.raw`# Summer {months="June · July · August" lead="The colony is at its largest and the main flow is on. Miss a week in June and the bees may swarm into the apple tree."}Amostra em Markdown · 32 linhas · content.summer.en.md
In summer the brood box fills with honey from the top down, as :ref{id="summer-frame"} shows, and the supers fill after it. Add supers before the bees need them and look through the brood box for queen cells every week. Leave full frames on the hive until they are capped. In June a missed week can cost you half the colony and most of the crop, so write down what you find at every visit. ## Swarm season Swarming is how a colony reproduces. When the old queen leaves with half the workers, the bees that stay raise a new one, and you have two colonies where you had one, or one colony and a cluster of bees hanging from the nearest branch. The signs are queen cells along the bottom of the frames, a crowded brood box and a queen who has slimmed down to fly. If you find queen cells with larvae in them and the old queen is still at home, make an artificial swarm. Move the old queen to a new box on the old site with one frame of brood, and move the parent colony, with its queen cells, a metre or two away. The flying bees return to the old site and to their queen, and the colony behaves as if it had already swarmed. Within ten days or so a new queen emerges in the parent box, and within a month she is laying. ## The main flow In a good year the flow comes in late June and July, from lime, bramble and clover, and a strong colony can bring in two or three kilos of nectar a day. The supers fill faster than you expect, so keep adding them. Harvest when the frames are capped and the honey no longer shakes out of the cells: it is ripe below eighteen per cent water. Take the supers off at the end of July or in August, clearing the bees as the first chapter describes, and carry the boxes indoors before the robbers find them. ## From comb to jar Uncap the frames over a tray with a knife or a fork, spin them in the extractor, and run the honey through a coarse sieve and then a fine one. Leave it in a settling tank for two or three days in a warm room: the wax and the air bubbles rise, you skim the froth off the top, and what runs from the tap at the bottom is clear. Jar it then, and label each batch with the month and the flowers it came from. Honey keeps for years if it is dry, but it draws water from the air, so seal the jars and store them somewhere cool and dark. Most of it sets in time: spring honey from oilseed rape turns solid within weeks, lime honey stays runny for months, and neither has gone off. A jar that has set runs clear again in a warm water bath, never above forty degrees. Put the wet frames back on the hives in the evening, when the robbers have stopped flying, and let the bees clean them. By September the workers have driven the drones out and the colony is shrinking; the next jobs, feeding and treating for varroa, are in the first chapter. :::callout{type="colophon"} Set in Andada Pro, Rozha One and Figtree (SIL Open Font License). Text and drawings: original, CC BY 4.0. The front matter and the four seasons are five Markdown documents; Postext sets them as one book. An imaginary handbook by an imaginary author. :::`; // #region chapters: five Markdown documents in reading order: the front matter, then a year // Nothing in a chapter says where it lands: buildBundle works that out from the order. const chapters = [front, autumn, winter, spring, summer].map((markdown) => ({ markdown })); // #endregion const svg = (id, [width, height], caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption, altText: caption, svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 } }); // as comb() draws const CAPTIONS = t({ en: { autumn: 'A frame in October: honey (gold) round the last brood (brown) and pollen (russet).', winter: 'The same frame in January: the cluster (dark) eats its way up from empty comb (pale).', spring: 'The same frame in late April: brood across the middle, the winter honey nearly gone.', summer: 'The same frame in July: an arch of new honey presses down on the brood.', }, es: { autumn: 'Un cuadro en octubre: la miel (dorada) rodea la última cría (marrón) ' + 'y el polen (rojizo).', winter: 'El mismo cuadro en enero: el racimo (oscuro) deja la cera vacía (clara) y sube.', spring: 'El mismo cuadro a finales de abril: cría en el centro; queda poca miel del invierno.', summer: 'El mismo cuadro en julio: un arco de miel nueva aprieta la cría hacia abajo.', } }); const resources = [svg('cover', [TRIM.width, TRIM.height]), svg('cells', [CORNER.width, CORNER.height]), svg('comb', [CORNER.width, CORNER.height]), ...Object.keys(SEASONS).map((season) => svg(`${season}-frame`, [FRAME.width, FRAME.height], CAPTIONS[season]))]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). // Rozha One ships one face: renderToPdf still asks for its bold and italic, which the kit's // provider snaps to that face (gotcha: pdf-provider-all-styles). const FONTS = { // text, display and labels 'Andada Pro': ['400', '400i', '700'], 'Rozha One': ['400'], Figtree: ['400', '600', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: draw, lay the book out, show it as spreads, offer one PDF of all chapters const text = chapters.map((chapter) => chapter.markdown).join('\n'); await loadFonts(FONTS, text); const art = { cover: coverArt(), cells: cornerArt(true), comb: cornerArt(false) }; for (const [season, plan] of Object.entries(SEASONS)) art[`${season}-frame`] = frameArt(plan); for (const [id, markup] of Object.entries(art)) await loadSvg(`${id}.svg`, markup); const docs = await buildWithFonts(book, text); // one VDTDocument per Markdown document showPages(docs, { title: BOOK_TITLE }); // renderToPdf takes the array: one file for the book, with a bookmark per chapter. offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregionKit · core, fonts, viewer, pdf, images: igual em todas as receitas · 316 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 · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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
#Imprimir um capítulo com os fólios do livro
Passe um só documento para renderToPdf para tirar uma prova de Spring sozinho, numerado de 6 a 8 como no livro. A página 6 em branco vem junto, porque uma página par em branco antes de uma abertura pertence ao capítulo que abre depois dela.
-offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
+offerPdf(() => renderToPdf(docs[3], { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),#Numerar as figuras em sequência no livro todo
Um contador de figuras que nunca reinicia numera os quatro quadros de 1 a 4 através dos documentos, em vez de 1.1 a 4.1.
- resourceTypes: defaultResourceTypes(LANG),
+ resourceTypes: defaultResourceTypes(LANG)
+ .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' })),Erros comuns
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
buildBundle não passa metadados entre capítulos; {totalPages} é por capítulo
buildBundle não passa metadados de um capítulo para o seguinte: {title}, {author} e os demais vêm só do frontmatter do próprio capítulo, então todo capítulo sem frontmatter os imprime vazios (e o Sandbox descarta o frontmatter de todos os capítulos depois do primeiro), e {totalPages} conta as páginas de cada capítulo. Escreva o título do livro literalmente nos cabeços. Livros compostos capítulo a capítulo →
Erro comum
Um estilo de título herda a quebra de página do seu nível
Uma entrada de headingStyles recebe do seu nível de título todos os campos que não define, inclusive breakBefore. Um sumário ou um colofão com estilo sobre um H1 depois de um :::pagebreak herda a paridade 'odd' e cai depois de uma página em branco. Dê a esse estilo breakBefore: { enabled: false }. Estilos de título →
Erro comum
Um :ref para a figura de um capítulo anterior a posiciona de novo
Com buildBundle, no postext 1.4.1, um :ref para uma figura citada pela primeira vez num capítulo anterior imprime o número certo, mas faz a figura flutuar uma segunda vez no capítulo posterior, onde conta como primeira referência. Remeta a ela com palavras (o quadro de outubro, figura 1.1). Livros compostos capítulo a capítulo →
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
A página 1 é ímpar: planeje com os números físicos
A página 1 fica à direita e a 2 é a primeira página par, então planeje as páginas duplas com os números físicos de página: uma abertura em página par fica de frente para a ímpar que vem depois dela. Quebras de página e de coluna →
Erro comum
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 →
Erro comum
O PDF pede todos os pesos e estilos de cada família
renderToPdf pede ao provedor de fontes o negrito, o itálico e o negrito itálico de cada família que um bloco poderia usar, mesmo os que nunca são impressos, e uma única recusa interrompe a exportação. O provedor deve se ajustar ao peso mais próximo que a família oferece e voltar ao romano quando não houver itálico. Fontes incorporadas ao PDF →
Erro comum
Um espaço não separável ainda quebra a linha
No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase. Escapes e caracteres literais →
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
Os números do sumário ficam um pouco acima da linha de base da entrada
No postext 1.4.1, :::toc pinta o número de cada entrada centralizado na linha, e não sobre a linha de base do texto, então os números de capítulo ficam um pouco altos ao lado dos títulos, cerca de 0,7 mm junto a um título de 16 pt, qualquer que seja a fonte ou o tamanho. Ainda não há opção de toc que os desloque, então confira o sumário em tamanho real antes de imprimir. Sumário →
Verificação do Sandbox · chapterFrontmatterIgnored
Frontmatter do capítulo ignorado
Por quê. Só o frontmatter do primeiro capítulo vale; um capítulo posterior começa com um bloco de frontmatter que é descartado.
Correção. Mantenha os metadados do livro no capítulo um e use atributos de título para os valores de cada capítulo. Docs →
- No alto de uma página, e sob uma figura que encabeça a página, um intertítulo perde o
marginTop. Um intertítulo que tira o espaço da margem fica a uma linha do texto nesses dois lugares e a uma linha e meia em todos os outros. Aqui o espaço está na própria linha do intertítulo:lineHeighté uma linha e meia e a margem, uma linha, de modo que todo intertítulo do livro fica à mesma distância do seu texto. - Por padrão, uma linha justificada pode esticar os espaços até o dobro da largura, e o algoritmo de quebra prefere isso a um hífen. Com esse padrão, a edição inglesa não tinha hífen nenhum, e linhas com espaços de quase o dobro da largura normal ficavam ao lado de linhas apertadas a 0,7.
maxWordSpacing: 1.7faz o algoritmo hifenizar. Depois de mudar esse valor, confira a última linha de cada parágrafo: um limite mais apertado pode deixar uma palavra sozinha ali, e trocar umas poucas palavras do parágrafo resolve.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fontes
- Andada Pro (SIL OFL 1.1) · Rozha One (SIL OFL 1.1) · Figtree (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)


