Em poucas palavras
Uma unidade de livro didático sobre um rio, com nove páginas. Cada tema é uma página dupla sobre um fundo colorido, com aba de título, texto, imagens e caixas. O mapa não tem nenhuma palavra pintada: os nomes vêm do texto.
O que você vai compor
Nove páginas de Nosso rio, unidade 4 de um livro inventado de Ciências Sociais do 3.º ano, numa página de 210 × 280 mm, em espanhol, catalão e inglês. Um vale pintado abre a unidade, uma página de sumário lista duas partes e seguem três temas em página dupla, cada um sobre o seu fundo: azul-água, verde-campo, areia-sol. Cada tema tem uma aba com o número e o título, texto em duas colunas e caixas com aba que crescem com o texto. O segundo tema é um único mapa pintado em página dupla, com o rio, as aldeias e os três cursos nomeados a partir do Markdown. Os três textos diferem em um quinto ou mais e cabem nos mesmos modelos: uma caixa toma a altura que as suas palavras pedem e empurra a seguinte, e o mapa é reduzido um pouco antes de qualquer texto.
Esta receita responde a
- Como componho uma unidade de livro didático em três idiomas com os mesmos modelos de página, se o texto de cada idioma tem um comprimento diferente?
- Como faço os boxes de uma página crescerem com o texto e empurrarem para baixo os que estão embaixo?
- Como coloco os nomes de um mapa a partir do texto, para que uma tradução troque as palavras e não o desenho?
A resposta curta
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
box: { backgroundColor: col('paper'), borderRadius: mm(6) },
placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
placement: at('container', 'top-left', TAB) };
const base = {
id: 'tema', spread: true, gutter: mm(12),
decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
fit: { minScale: 0.9 } }],
groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
{ id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
{ id: 'number', color: col(f) }] },
...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
height: mm(LINE * lines), minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
placement: at(`#${name}`, 'top-right', -5, 8) });
Ingredientes
- Funcionalidades
- Páginas compostasQuadros que crescem num grupoAbas numeradas nos boxesRótulos sobre a imagemSumárioModelos em página duplaBoxesTextos, fios e caixas nos designs de páginaImagens nos designs de páginaAncoragem de elementos de designDecoração ancorada aos blocosMargens espelhadasCabeços e fóliosEstilos de títuloPaleta de cores semânticaFiguras e tabelas como recursosPáginas em um canvasExportação para PDF
- Também usa
- Blocos que afastam o texto, pela caixa ou pelo contornoAtributos de títuloAberturas desenhadasPáginas de abertura de parteFontes incorporadas ao PDFTipos de recurso personalizadosCapítulos sem número
- Tipografia
- Nunito, Fredoka (SIL OFL 1.1)
- Materiais
arroyo-720.jpgcascada-720.jpglimpieza-720.jpgmapa-1900.jpgmolino-720.jpgnacimiento-720.jpgpiraguas-720.jpgportada-1400.jpgriego-720.jpg- The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water. (Generated With Diffusion Models, original)
- The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown. (Generated With Diffusion Models, original)
- A spring between mossy rocks. (Generated With Diffusion Models, original)
- A mountain stream over round stones. (Generated With Diffusion Models, original)
- A small waterfall into a green pool. (Generated With Diffusion Models, original)
- A farmer opening the gate of an irrigation canal. (Generated With Diffusion Models, original)
- An old stone watermill with its wheel. (Generated With Diffusion Models, original)
- Children paddling canoes on a calm river. (Generated With Diffusion Models, original)
- Children and a teacher picking up litter on a river bank. (Generated With Diffusion Models, original)
Preparo
#1 · Construa as caixas com extends
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
{ id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
...Object.keys(FAMILIES).flatMap((f) => [
{ id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
{ id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
{ id: `palabras-${f}`, extends: `caja-${f}`,
border: { enabled: true, color: col(f), width: pt(1.4) } },
{ id: `actividad-${f}`, extends: `caja-${f}`,
stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
]),
// Plain frames: the title (its tab is decoration), a picture's card and an answer space.
{ id: 'titulo', extends: 'caja', backgroundEnabled: false,
padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
{ id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
{ id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
{ id: 'foto', extends: 'caja', borderRadius: mm(2),
padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
{ id: 'respuesta', extends: 'caja', padding: none },
{ id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
Um estilo de caixa se faz em três passos. caja guarda a forma, a tipografia e a geometria da aba; caja-agua, caja-campo e caja-sol põem a cor de uma família na aba e nos marcadores; dato-, palabras- e actividad- fixam o tipo: caixa com fundo, com borda ou com faixa. Uma família nova são três cores em FAMILIES e nenhum estilo se escreve duas vezes.
#2 · Faça cada tema herdar de um modelo base
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
box: { backgroundColor: col('paper'), borderRadius: mm(6) },
placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
placement: at('container', 'top-left', TAB) };
const base = {
id: 'tema', spread: true, gutter: mm(12),
decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
fit: { minScale: 0.9 } }],
groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
{ id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
{ id: 'number', color: col(f) }] },
...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
height: mm(LINE * lines), minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
placement: at(`#${name}`, 'top-right', -5, 8) });
A base é uma página dupla: um fundo até a sangria, uma aba de título com o número e um texto em duas colunas. Um tema a estende (extends: 'tema'), muda as cores de três elementos de decoração pelo seu id e acrescenta seus blocos. A aba e o número são decoração ancorada ao bloco do título: a aba é tão alta quanto o título que o idioma pede, e o número fica à esquerda do bloco, onde nada o cobre.
#3 · Deixe os grupos dividirem o espaço
Cada tema tem dois grupos. A página esquerda é uma pilha: título, texto e depois a tira de imagens ou o mapa. A direita é outra pilha de caixas, ou uma fileira de três caixas que dividem a largura. Um bloco é tão alto quanto as suas palavras e nunca menos que o seu minHeight: o texto em espanhol empurra tudo o que tem embaixo e nenhuma posição do modelo muda. Quando falta espaço a uma página, a escada de ajuste recupera espaço, reduz o mapa e as imagens e deixa o texto por último, até 90 %. O pnpm cookbook bench recompõe as três edições com o texto 30 % mais longo.
#4 · Ponha os nomes do mapa a partir do texto
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
{ id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
textTransform: 'uppercase', letterSpacing: 0.14, halo },
{ id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
color: { hex: '#0b3a5c', model: 'hex' }, halo },
{ id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
{ id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
{ id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
padding: mm(1.6) },
];
const ANCHORS = [
{ id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
{ id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
{ id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
{ id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
{ id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
{ id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
{ id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
{ id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
{ id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
{ id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
{ id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
O mapa é uma pintura sem uma letra. Cada lugar é uma âncora da imagem, um ponto em frações da largura e da altura com um estilo de rótulo, e cada edição escreve as suas próprias linhas ::label nessas âncoras. Penalta Reservoir, Pantà de Penyalta e Embalse de Peñalta ficam no mesmo lugar, e um nome mais longo se quebra dentro do width da âncora.
#5 · Mantenha cada parte do sumário unida
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
{ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
fontWeight: 600, color: col('paper'), align: 'left',
placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
levels: [
{ level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
marginTop: pt(4) },
{ level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
color: col('ink') },
],
pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
leader: { char: '.', gap: mm(1.5) },
parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
{ kind: 'box', id: 'band', decorative: true,
placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
{ kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
fontWeight: 700, color: col('paper'), align: 'center',
box: { backgroundColor: col('agua'), borderRadius: mm(4) },
placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
] } },
};
:::toc{keep="part" levels="1-2"} lista os temas e as suas seções e mantém cada linha de parte com as suas entradas. Uma cerca :::part com parts.page: false é só uma linha do sumário e uma seção para os cabeçalhos: não acrescenta página de abertura. O sumário é uma página de texto corrido e não um bloco, porque um :::toc dentro de um bloco perde as linhas de parte.
A receita completa
// ═══ Postext Cookbook · Nº 156 · A children's textbook unit in three languages ═══ // https://postext.dev/en/cookbook/children-textbook-pages // Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models // Fonts: Nunito, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.40.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('es' | 'en' | 'ca') const RECIPE = 'children-textbook-pages'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one ink and three families (water, field, sun), each with an accent, a tint // for its boxes and a ground for its pages const FAMILIES = { agua: { main: '#1b6aa0', tint: '#e1f0fa', ground: '#d3e8f6' }, campo: { main: '#336f27', tint: '#e5f1dc', ground: '#d9ebcb' }, sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' }, }; const palette = { ink: '#1d2f3f', muted: '#4f6272', rule: '#7e98ab', paper: '#ffffff' }; for (const [f, c] of Object.entries(FAMILIES)) { Object.assign(palette, { [f]: c.main, [`${f}-tint`]: c.tint, [`${f}-ground`]: c.ground }); } 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' } })), { id: 'main-color', name: 'water (defaults)', value: { hex: palette.agua, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY] = ['Nunito', 'Fredoka']; const PAGE = { w: 210, h: 280, top: 18, bottom: 18, inner: 14, outer: 12 }; // mm, mirrored const HALF = PAGE.w - PAGE.inner - PAGE.outer; // one page's type area: 184 mm const SHEET = 2 * HALF + 2 * PAGE.inner; // a spread's content area: 396 mm const RIGHT = HALF + 2 * PAGE.inner; // where the right page's type area starts on the sheet const LINE = 8; // mm between two answer lines const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const onRight = at('container', 'top-left', RIGHT); // every frame of the right page's stack // #region boxes: callout styles that extend each other // Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind. const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'), fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2), offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } }); const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left', paragraphSpacing: true }; const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding const calloutStyles = [ { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3), border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) }, body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } }, ...Object.keys(FAMILIES).flatMap((f) => [ { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } }, { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) }, { id: `palabras-${f}`, extends: `caja-${f}`, border: { enabled: true, color: col(f), width: pt(1.4) } }, { id: `actividad-${f}`, extends: `caja-${f}`, stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } }, ]), // Plain frames: the title (its tab is decoration), a picture's card and an answer space. { id: 'titulo', extends: 'caja', backgroundEnabled: false, padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } }, { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } }, { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4), padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } }, { id: 'foto', extends: 'caja', borderRadius: mm(2), padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } }, { id: 'respuesta', extends: 'caja', padding: none }, { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none, body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } }, ]; // #endregion // #region answer: one base template and a family of topics that extend it // Ground, title tab and number are decoration anchored to the title frame, so the tab is as // tall as the title needs. A topic extends the base and changes its colours and adds frames. const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } }, style: { backgroundColor: col(`${f}-ground`) } }); const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true, placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } }, style: { backgroundColor: col(f), borderRadius: mm(4) } }); const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY, fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center', box: { backgroundColor: col('paper'), borderRadius: mm(6) }, placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4), size: { width: mm(12), height: mm(12) } } }); const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB), placement: at('container', 'top-left', TAB) }; const base = { id: 'tema', spread: true, gutter: mm(12), decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] }, frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF), fit: { minScale: 0.9 } }], groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }], }; const topic = (id, f, more) => ({ id, extends: 'tema', decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } }, { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } }, { id: 'number', color: col(f) }] }, ...more }); const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF), minHeight: mm(minHeight), ...more }); const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF), height: mm(LINE * lines), minHeight: mm(LINE * lines), rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } }); // A picture that sits at the corner of a box and pushes its text aside. const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push', placement: at(`#${name}`, 'top-right', -5, 8) }); // #endregion const questions = [1, 2, 3].flatMap((n) => [box(`pregunta-${n}`, 'dato-agua', 14), answer(`respuesta-${n}`, 3)]); const pageTemplates = [ base, // Water: text, a strip of three pictures and a box; three boxes grow down the right page. topic('tema-agua', 'agua', { frames: [ ...[1, 2, 3].map((n) => ({ name: `foto-${n}`, style: 'foto', width: mm((HALF - 10) / 3), ...(n === 1 && { placement: at('#texto', 'below', 0, 8) }) })), box('probar', 'palabras-agua', 46, { placement: at('#foto-1', 'below', 0, 10) }), { ...box('sabias', 'dato-agua', 40), placement: onRight }, { ...box('palabras', 'palabras-agua', 62), placement: onRight }, { ...box('actividades', 'actividad-agua', 58), placement: onRight }, ], groups: [ { name: 'fotos', kind: 'row', gap: mm(5), frames: ['foto-1', 'foto-2', 'foto-3'] }, { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['sabias', 'palabras', 'actividades'] }, ], }), // Field: one map across the spread; under it a text, and three boxes that share a width. topic('tema-campo', 'campo', { frames: [ { name: 'mapa', crossGutter: true, width: mm(SHEET) }, box('actividad', 'actividad-campo', 24), ...[1, 2, 3].map((n) => ({ name: `curso-${n}`, style: 'dato-campo', width: mm(56), minHeight: mm(46), shrink: 1, ...(n === 1 && { placement: at('#mapa', 'below', RIGHT, 6) }) })), ], groups: [ { name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'mapa', 'texto', 'actividad'] }, { name: 'cursos', kind: 'row', gap: mm(6), frames: ['curso-1', 'curso-2', 'curso-3'] }, ], }), // Sun: boxes with a picture at their corner that pushes the text aside. topic('tema-sol', 'sol', { frames: [ box('cuidar', 'palabras-sol', 64, { padding: { right: mm(64) } }), corner('cuidar'), box('turno', 'actividad-sol', 40), ...[1, 2, 3].flatMap((n) => [ box(`uso-${n}`, 'dato-sol', 66, { padding: { right: mm(64) }, placement: onRight }), corner(`uso-${n}`), ]), ], groups: [ { name: 'izquierda', kind: 'stack', gap: mm(12), frames: ['titulo', 'texto', 'cuidar', 'turno'] }, { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['uso-1', 'uso-2', 'uso-3'] }, ], }), // Before the unit: questions, each with room to write. { id: 'saberes', header: false, decoration: { elements: [titleTab('agua', 0)] }, frames: [{ ...title, style: 'titulo-plano', width: mm(HALF), placement: at('container', 'top-left') }, { name: 'texto', flow: true, width: mm(HALF) }, ...questions], groups: [{ name: 'pagina', kind: 'stack', gap: mm(7), frames: ['titulo', 'texto', ...questions.map((q) => q.name)] }] }, // The opener: a painting to the bleed, the title over its foot. { id: 'unidad', header: false, footer: false, decoration: { elements: [ { kind: 'image', id: 'portada', resourceId: 'portada', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(157.5) } } }, { kind: 'text', id: 'kicker', content: '{attr.unidad}', fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, letterSpacing: pt(1), textTransform: 'uppercase', color: col('agua'), box: { backgroundColor: col('paper'), borderRadius: mm(2) }, placement: { ...at('#titulo', 'top-left', 0, -9), size: { height: mm(7) } } }, ] }, frames: [ { name: 'titulo', takes: 'h1', style: 'portada', width: mm(120), placement: at('container', 'top-left', 0, 104) }, { name: 'texto', flow: true, width: mm(160) }, box('aprenderas', 'dato-agua', 36), { name: 'colofon', style: 'colofon', width: mm(HALF), placement: at('container', 'bottom-left') }, ], groups: [{ name: 'pagina', kind: 'stack', gap: mm(7), frames: ['titulo', 'texto', 'aprenderas'] }] }, ]; const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity, fontFamily: TEXT, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'), textTransform: 'uppercase', placement: at('page', edge, x, 9) }); const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity, fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, color: col('paper'), align: 'center', box: { backgroundColor: col('agua'), borderRadius: mm(4) }, placement: { ...at('page', edge, x, -8), size: { width: mm(8), height: mm(8) } } }); // #region contents: a flow page, because a :::toc cannot be laid out inside a frame yet // The heading style paints the title tab; the entries come from the headings and the parts. const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [ { kind: 'box', id: 'tab', decorative: true, placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } }, style: { backgroundColor: col('agua'), borderRadius: mm(4) } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28), fontWeight: 600, color: col('paper'), align: 'left', placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } }, ] } }; const toc = { levels: [ { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500, color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9), marginTop: pt(4) }, { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9), color: col('ink') }, ], pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') }, leader: { char: '.', gap: mm(1.5) }, parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [ { kind: 'box', id: 'band', decorative: true, placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } }, style: { backgroundColor: col('paper'), borderRadius: mm(3) } }, { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16), fontWeight: 700, color: col('paper'), align: 'center', box: { backgroundColor: col('agua'), borderRadius: mm(4) }, placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } }, { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) }, ] } }, }; // #endregion // #region art-labels: the map's label styles and anchors; the words are in the Markdown // An anchor is a point of the painting (fractions of its width and height) and a label style; // every language's ::label lines use the same anchors. const halo = { color: col('paper'), width: pt(1.8) }; const labelStyles = [ { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'), textTransform: 'uppercase', letterSpacing: 0.14, halo }, { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic', color: { hex: '#0b3a5c', model: 'hex' }, halo }, { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic', color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } }, { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo }, { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'), textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'), padding: mm(1.6) }, ]; const ANCHORS = [ { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' }, { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' }, { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' }, { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 }, { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' }, { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' }, { id: 'mar', x: 0.965, y: 0.28, style: 'mar' }, { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 }, { id: 'alto', x: 0.17, y: 0.45, style: 'curso' }, { id: 'medio', x: 0.4, y: 0.77, style: 'curso' }, { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' }, ]; // #endregion const config = { locale: t({ es: 'es', en: 'en-gb', ca: 'ca' }), resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture', ca: 'Dibuix' }), shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '', defaultPlacement: { position: 'here' } }], colorPalette, page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [ { level: 1, fontSize: pt(28), lineHeight: pt(32), color: col('paper'), marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: false } }, { level: 2, fontSize: pt(15), lineHeight: pt(19), marginTop: pt(9), marginBottom: pt(3) }, ] }, // Headings that are not entries of the unit: no number, no row in the contents. headingStyles: [ { id: 'portada', numbered: false, toc: false, fontSize: pt(46), lineHeight: pt(48) }, { id: 'previo', numbered: false, toc: false }, { id: 'indice', numbered: false, toc: false, fontSize: pt(28), advancedDesign: indiceOpener }, ], toc, parts: { page: false }, // a part is a row of the contents, with no divider page unorderedLists: { bulletChar: '•', color: col('agua'), fontWeight: 700, itemSpacing: pt(3) }, orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 600, color: col('agua'), itemSpacing: pt(4) }, labelStyles, captionStyle: { fontFamily: TEXT, fontSize: pt(9), color: col('ink'), descriptionItalic: true, gap: pt(4) }, calloutStyles, header: { elements: [ head('materia', '{subtitle}', 'even', 'top-left', PAGE.outer), head('unidad', '{title}', 'odd', 'top-right', -PAGE.outer), ] }, footer: { elements: [folio('folio-par', 'even', 'bottom-left', PAGE.outer), folio('folio-impar', 'odd', 'bottom-right', -PAGE.outer)] }, pageTemplates, }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 188 linhas · content.en.md
title: "Our river" subtitle: "Social science · Year 3" --- ::::page{template="unidad" unidad="Unit 4 · Social science"} # Our river {style="portada"} Every town you know stands near water. This unit follows one river, the Aldara, from the snow where it starts to the sea where it ends. You will find out where a river is born, how it changes on its way down, and how the people of Fontalba, Molinar and Aldamar live beside it. :::frame{slot="aprenderas" label="You will learn"} - How a spring becomes a stream, and a stream becomes a river. - What the upper, middle and lower course of a river are. - How people use river water, and how to look after it. ::: :::frame{slot="colofon"} Set in Nunito and Fredoka (SIL OFL) · Original text (CC BY 4.0) · Illustrations generated with diffusion models ::: :::: # In this unit {style="indice"} :::toc{keep="part" levels="1-2"} :::figure{resource="rio"} ::alt[Painted map of the valley of the River Aldara, from the snowy mountains to the sea.] ::caption[The River Aldara from its spring to the sea. The map is on pages 6 and 7.] ::: ::::page{template="saberes"} # What do you know about rivers? {style="previo"} Before you start, think about the rivers you have seen. Write your ideas on the lines. At the end of the unit, come back to them and see what has changed. :::frame{slot="pregunta-1" label="Think 1"} Where do you think a river starts? ::: :::frame{slot="pregunta-2" label="Think 2"} What do people use a river for? ::: :::frame{slot="pregunta-3" label="Think 3"} Which animals and plants live in or beside a river? ::: :::: :::part{number="A" title="A river from the mountains to the sea"} ::: ::::page{template="tema-agua" spread numero="1"} # Where a river is born Rain and snow fall on the mountains. Some of the water runs down the slopes and some soaks into the ground. Underground it moves slowly, until it meets rock it cannot cross. Then it comes out into the open. A place where water comes out of the ground is called a **spring**. ## From spring to stream A spring gives only a trickle. The trickle runs downhill and meets other trickles, and together they make a **stream**. On a steep slope a stream runs fast. It rolls pebbles along and wears them smooth. When streams meet, they make a bigger one. The River Aldara begins like this, in the snow of the White Range, 1,850 metres above the sea. :::figure{resource="nacimiento" slot="foto-1"} ::alt[Clear water trickling out between mossy rocks.] ::caption[A spring. The water has been underground for months.] ::: :::figure{resource="arroyo" slot="foto-2"} ::alt[A stream tumbling over round stones.] ::caption[A stream, steep and fast, with pebbles worn smooth.] ::: :::figure{resource="cascada" slot="foto-3"} ::alt[A small waterfall falling into a green pool.] ::caption[A waterfall, where the stream drops over a step of rock.] ::: :::frame{slot="probar" label="Try it"} Hold a wet sponge over a tray and squeeze it gently. Where does the water come out? Watch how it drips and then runs. That is how a spring feeds a stream. ::: :::frame{slot="sabias" label="Did you know?"} At its spring, the Aldara is a trickle you could step over. After 94 kilometres it is as wide as a football pitch is long. ::: :::frame{slot="palabras" label="Words to learn"} **Spring:** a place where water comes out of the ground. **Stream:** a small river that runs fast down a slope. **Tributary:** a stream that flows into a bigger river. **Bank:** the land along the side of a river. ::: :::frame{slot="actividades" label="Activities"} 1. Look at the three pictures. Which one shows a spring? How can you tell? 2. Why does a stream run faster in the mountains than in the valley? 3. Which of the four words in the box can you see in the pictures? ::: :::: ::::page{template="tema-campo" spread numero="2"} # From the mountains to the sea The River Aldara is 94 kilometres long. It starts in the White Range, crosses a valley and a plain, and reaches the sea at Aldamar. Geographers split a river into three parts. Each part is called a **course**, and each one looks different. :::figure{resource="mapa" slot="mapa"} ::alt[A painted map of the valley: snowy mountains and a spring at the left, the village of Fontalba, a reservoir with a dam, the town of Molinar with its bridge and mill, farm fields, the river bending across a plain, and the port of Aldamar by the sea at the right.] ::label[White Range]{at="sierra"} ::label[Spring]{at="nacimiento"} ::label[Fontalba]{at="fontalba"} ::label[Penalta Reservoir]{at="embalse"} ::label[Molinar]{at="molinar"} ::label[Aldamar]{at="aldamar"} ::label[Sea]{at="mar"} ::label[River Aldara]{at="rio"} ::label[Upper course]{at="alto"} ::label[Middle course]{at="medio"} ::label[Lower course]{at="bajo"} ::: :::frame{slot="actividad" label="Look at the map"} Follow the river with your finger from the spring to the sea. Which town is nearest to the sea? ::: :::frame{slot="curso-1" label="Upper course"} The slope is steep. The water is cold, clear and fast, and it cuts into the rock. Fontalba stands here. ::: :::frame{slot="curso-2" label="Middle course"} The valley is wider and the river slows down. Farmers water their fields, and Molinar grew around a bridge and a mill. ::: :::frame{slot="curso-3" label="Lower course"} The land is almost flat. The river is wide and slow, and it bends in great loops. At Aldamar it flows into the sea. ::: :::: :::part{number="B" title="People and the river"} ::: ::::page{template="tema-sol" spread numero="3"} # People and the river People have always settled near rivers. They need water to drink, to wash and to grow food. A river also gives fish, a road for boats and the push that turns a wheel. ## Looking after the river A river stays clean only if we keep it clean. Rubbish, oil and dirty water make the river sick, and the fish and plants that live in it suffer. Everything that goes down a drain or a street ends up in a river or in the sea. :::frame{slot="cuidar" label="We look after our river"} - We put rubbish in the bin, never in the water. - We use water carefully and turn off the tap. - We keep to the paths and leave plants and animals alone. - We help to clean the bank when the school organises it. ::: :::frame{slot="turno" label="Your turn"} 1. Draw a poster with three rules to keep our river clean. 2. Ask at home: where does the water from your tap come from? ::: :::figure{resource="limpieza" slot="cuidar-foto"} ::alt[Children with gloves and paper bags picking up litter on a river bank.] ::: :::frame{slot="uso-1" label="1 · Water for the fields"} In the middle course, farmers open small canals so the river water runs between the rows. Without it, tomatoes and lettuces would not grow in summer. A single canal can carry water for many kilometres. ::: :::figure{resource="riego" slot="uso-1-foto"} ::alt[A farmer lifting a gate in an irrigation canal in a field of vegetables.] ::: :::frame{slot="uso-2" label="2 · Mills"} Long ago the falling water turned the wheel of the mill at Molinar. The wheel moved the stones that ground wheat into flour for the whole valley. Today the old mill is a small museum. ::: :::figure{resource="molino" slot="uso-2-foto"} ::alt[An old stone mill with a big wooden wheel beside a river.] ::: :::frame{slot="uso-3" label="3 · Sport and play"} Now the calm lower course is a place for canoes. On summer mornings children from Aldamar learn to paddle with a monitor, and everyone wears a life jacket. ::: :::figure{resource="piraguas" slot="uso-3-foto"} ::alt[Children in life jackets paddling two canoes on a calm river.] ::: ::::`; // content.<lang>.md, inlined by the Cookbook // #region art: nine paintings (JPEGs in assets/), the pictures of the unit // Painted with a diffusion model in a coloured-pencil and watercolour manner, cut to the shape // of the frames that show them and declared with their pixels. Nothing is written on them: the // names on the map are ::label lines of the Markdown, standing at the anchors below. const ART = { portada: ['portada-1400.jpg', 1400, 1021, 165], mapa: ['mapa-1900.jpg', 1900, 730, 122], ...Object.fromEntries(['nacimiento', 'arroyo', 'cascada', 'riego', 'molino', 'piraguas', 'limpieza'].map((id) => [id, [`${id}-720.jpg`, 720, 540, 330]])) }; const resources = await Promise.all(Object.entries(ART).map(async ([id, [fileId, w, h, ppi]]) => { await loadImage(fileId, asset(fileId)); return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: '', createdAt: 0, updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi }, ...(id === 'mapa' && { anchors: ANCHORS }) }; })); // The map again with no anchors, small, under the contents. resources.push({ ...resources.find((r) => r.id === 'mapa'), id: 'rio', anchors: [] }); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Nunito: ['400', '400i', '700'], Fredoka: ['500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS)); showPages(doc, { title: t({ es: 'Nuestro río', en: 'Our river', ca: 'El nostre riu' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, images: igual em todas as receitas · 315 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `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. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; 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 at = fontsourceAt(id); 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 = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** 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, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** 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 v2 ── 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. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its 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 files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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
#Acrescente uma quarta família
Um tema sobre o mar é uma quarta entrada de FAMILIES e um modelo feito com topic('tema-mar', 'mar', { frames }). O laço sobre FAMILIES escreve os seus estilos, caja-mar, dato-mar e os demais.
- sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+ sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+ mar: { main: '#0f5a6b', tint: '#dcf0f2', ground: '#cbe6ea' },#Deixe o texto no seu tamanho
Um bloco que não deve reduzir o texto leva fit: { shrinkText: false }. Um texto mais longo empurra então com mais força os blocos em volta e a escada chega antes às imagens.
- frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
- fit: { minScale: 0.9 } }],
+ frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
+ fit: { shrinkText: false } }],Erros comuns
Erro comum
Um sumário não pode ser composto dentro de um quadro de uma página composta
Um :::toc dentro de um :::frame de uma página composta é composto como os parágrafos do quadro: as linhas de parte de toc.parts.design não são desenhadas e deixam uma linha vazia, cada entrada é seguida do espaçamento de parágrafo do documento e o título do próprio sumário aparece nele. Componha o sumário como uma página de texto corrido: um estilo de título (numbered: false, toc: false) cujo advancedDesign pinta o título, seguido de :::toc{keep="part"} no texto, onde as linhas de parte, os pontilhados e a regra keep funcionam. O seu desenho para na margem da coluna, de modo que a cor de fundo dessa página terá de esperar que o motor componha o sumário como página composta. Sumário →
- Um nome pintado dentro de uma imagem não poderia mudar com o idioma: o mapa não leva nenhuma palavra e cada nome é um rótulo.
- Um elemento de decoração que fica sob um bloco não pode ser clicado no Sandbox; por isso o número de cada tema está à esquerda do bloco do título e não dentro.
- Duas figuras que definem o mesmo recurso tomam as palavras da primeira: o mapa pequeno do sumário é o mesmo arquivo sob outro recurso,
rio, sem âncoras.
Créditos
- Receita
- Your Name
- Texto
- Our river, a children's textbook unit written for this recipe in Spanish, Catalan and English · Postext Cookbook · CC BY 4.0
- Imagens
- The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water. · Generated With Diffusion Models · original
- The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown. · Generated With Diffusion Models · original
- A spring between mossy rocks. · Generated With Diffusion Models · original
- A mountain stream over round stones. · Generated With Diffusion Models · original
- A small waterfall into a green pool. · Generated With Diffusion Models · original
- A farmer opening the gate of an irrigation canal. · Generated With Diffusion Models · original
- An old stone watermill with its wheel. · Generated With Diffusion Models · original
- Children paddling canoes on a calm river. · Generated With Diffusion Models · original
- Children and a teacher picking up litter on a river bank. · Generated With Diffusion Models · original
- Fontes
- Nunito (SIL OFL 1.1) · Fredoka (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)


