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

Receitas · Capítulo 2 · Tipo e texto

Cartilha de alfabetização com sílabas em chips

Uma unidade de cartilha em espanhol para a letra M: sílabas em chips na cor da sua vogal e uma grade de palavras ilustradas, com uma aquarela em cada célula.

p. 37 · 1 de 3

  • Amostra em espanhol: ainda sem edição em português
  • Refile 210 × 260 mm
  • 1 coluna
  • Andika 11/15
  • DynaPuff
  • Playpen Sans
  • 3 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 9 ms
  • 180 linhas de código

Em poucas palavras

Três páginas de uma cartilha em espanhol para crianças pequenas, dedicadas à letra M. Cada sílaba fica numa etiqueta colorida, e cada palavra da grade tem o seu desenho.

O que você vai compor

A unidade 7 de Letra a letra, uma cartilha espanhola inventada: três páginas sobre a letra M. As cartilhas espanholas ensinam a ler sílaba por sílaba, por isso a amostra existe só em espanhol. A abertura põe um Mm em DynaPuff de 190 pt numa faixa creme, ao lado de duas coisas cujo nome em espanhol começa com m, uma borboleta (mariposa) e uma maçã (manzana). As sílabas da unidade são chips na cor da sua vogal: a vermelho, e laranja, i verde-azulado, o azul, u roxo. As sílabas com m são preenchidas e as outras só contornadas, e assim a criança vê qual metade de mesa pertence a esta unidade. A página 38 traz seis palavras ilustradas numa grade arredondada, com uma aquarela em cada célula, e depois três linhas de letras para cobrir. As aquarelas foram geradas com modelos de difusão; a faixa, os ícones e as linhas para cobrir são desenhados em código. A página 39 tem quatro frases em 24 pt, um box de sílabas para colorir e um recado para a família.

Esta receita responde a

  • Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?
  • Como coloco imagens ou ícones dentro das células de uma tabela?
  • Como acrescento espaço vertical entre dois blocos, se as linhas em branco não fazem nada?
  • Como monto uma ficha de exercícios: linhas para preencher, caixas de resposta, bancos de palavras, listas de verificação?

A resposta curta

script.js · linhas 27–47no código completo
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, end) => `:chip[${s}]{style="${vowel(s)}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, before ? '-frase' : '')).join(''));

Ingredientes

Tipografia
Andika, DynaPuff, Playpen Sans (SIL OFL 1.1)
Materiais
  • mano-640.jpg
  • manzana-480.jpg
  • mapa-640.jpg
  • mariposa-660.jpg
  • mesa-640.jpg
  • mimosa-640.jpg
  • mono-640.jpg
  • muneca-640.jpg
  • A watercolour of the butterfly on the opener (Generated With Diffusion Models, original)
  • A watercolour of the apple on the opener (Generated With Diffusion Models, original)
  • A watercolour of the hand in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the map in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the table in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the mimosa sprig in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the monkey in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the doll in the picture grid (Generated With Diffusion Models, original)
  • The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette (Ignacio Ferro, MIT)

Preparo

#1 · Um estilo de chip por vogal e uma função que escreve os chips

A resposta curta monta 21 estilos de chip espalhando um mesmo objeto, syllable. Há um estilo preenchido por vogal para as sílabas com m, um contornado por vogal para as outras, um gêmeo -frase de cada um desses dez e pinta, o contorno em tinta que a criança pinta. syllables() transforma {mi·mo·sa} em três :chip[…] sem nada entre eles, de modo que as sílabas de uma palavra se encostam e a borda branca desenha a emenda. Do outro lado de um espaço entre palavras, um chip mantém pelo menos o seu gap, e 0,5 em deixa as palavras de uma fileira de 24 pt a 4,2 mm uma da outra, onde o espaço sozinho deixaria 2,3 mm. Dentro de uma frase, esse vão quase dobraria o espaço antes da palavra nova, então uma palavra que vem depois de outra recebe os gêmeos -frase, cujo gap de 0 deixa o espaço de 2,3 mm como está.

#2 · Uma entrelinha que dá espaço aos chips

script.js · linhas 145–155no código completo
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],

O enchimento vertical e a borda de um chip são pintados fora da sua linha e não somam entrelinha, então um chip mais alto que o passo da linha esbarra nos chips da linha de baixo. Um chip de sílaba de 24 pt tem 10,4 mm de altura; com entrelinha de 30 pt (10,6 mm), os chips de duas frases ficariam a 0,1 mm um do outro. As linhas que a criança lê recebem 45 pt e as fileiras de sílabas 60 pt, três e quatro linhas da grade de 15 pt das instruções.

#3 · Uma aquarela em cada célula

script.js · linhas 94–106no código completo
const PICTURE_WORDS = ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'];
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});

setCellImage põe uma imagem acima do texto de uma célula, com uma fração da largura interna da célula, aqui 0,72 (36,5 mm), e a linha cresce para acomodá-la. Cada aquarela é um JPEG pintado sobre branco e multiplicado pelo creme dos ladrilhos, então ela assenta no ladrilho sem caixa em volta. Abaixo de cada aquarela vem a palavra em chips de sílaba. Uma célula de tabela aceita marcação de chip como qualquer parágrafo. O estilo de tabela dibujos arredonda a moldura e os fundos em 5 mm e separa os ladrilhos creme com fios brancos de 4 pt.

#4 · Uma linha de espaço antes do box

script.js · linhas 158–170no código completo
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],

O Markdown põe :::space{lines=1} entre a última frase e o box para colorir, uma linha de 15 pt (5,3 mm). O marginTop do box é 0, então essa linha é todo o espaço; sem ela, o box começaria onde termina a linha da última frase. Um :::space que abre um box é descartado, então o box começa com o enunciado e os chips da legenda de cores, e o seu próprio :::space vem depois deles, entre a legenda e a tarefa de colorir.

#5 · A abertura da letra

script.js · linhas 59–77no código completo
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// Images reserve no height in an opener (gotcha: opener-image-no-reserve): by itself the
// opener ends at the foot of the word manzana, 88.7 mm down, and the first title starts on
// the wave's edge. 16 grid lines of minHeight start it on the grid, 11.4 mm below the wave.
const opener = { enabled: true, minHeight: pt(16 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('butterfly', 'mariposa', at(146, 12, { width: mm(40) })),
    picture('apple', 'manzana', at(151, 53, { width: mm(30) })),
    ...[['mariposa', 45.5], ['manzana', 84.5]].map(([name, y]) => words(name, name, LABEL, 10,
      600, 'muted', at(146, y, { width: mm(40) }), { align: 'center' })), // under each picture
  ] } };

O Mm é o texto do título ({titleText}); o número da unidade, a série e o nome da letra são atributos na mesma linha do título. A faixa, a borboleta e a maçã são imagens, e uma abertura não reserva altura para imagens. Sem minHeight, a abertura termina no pé da palavra manzana, a 88,7 mm do alto da página, e o título da primeira atividade começa a 99,3 mm, 2,6 mm acima do ponto mais baixo da onda (101,9 mm). Um minHeight de 16 linhas da grade termina a abertura a 102,7 mm, e as duas linhas de marginTop do título o fazem começar a 113,3 mm, na grade e 11,4 mm abaixo da onda.

#6 · Ícones ao lado dos títulos das atividades

script.js · linhas 81–90no código completo
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });

Cada título de atividade indica o seu estilo de título, como em ## Lee las frases {style="lee"}, e o estilo desenha um ícone com o título centralizado ao lado. Os títulos continuam sendo de nível 2, então o nível H2 define o espaçamento: duas linhas da grade de lineHeight para o título e mais duas de marginTop acima dele.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 060 · Early reader with syllable chips ════════════════
// https://postext.dev/en/cookbook/reading-primer-syllables
// Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models · Icons: drawn in code
// Fonts: Andika, DynaPuff, Playpen Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, setCellImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'reading-primer-syllables';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#242832', paper: '#ffffff', muted: '#5f6470', // text; instructions
  cream: '#f6f0e3', rule: '#d3c9b6', // the band, the tiles and the pictures' ground; guides
  // The vowel code: a syllable takes its vowel's colour. White on these fills measures
  // 3.2–4.9:1, above WCAG's 3:1 for large text: the vowel chips are bold, 20 pt or more.
  a: '#d9482b', e: '#dd740c', i: '#1f9a8f', o: '#3f6fd8', u: '#9152cf' };
// Design elements read the hex and ignore the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults are linked to 'main-color': point it at the ink.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'DynaPuff', 'Playpen Sans']; // Andika: one-storey a
const [TOP, SIDE, GRID] = [18, 20, 15]; // mm, mm, pt: every activity starts on the 15 pt grid

// #region answer: one chip style per vowel, and {ma·má} in the text written out as chips
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, end) => `:chip[${s}]{style="${vowel(s)}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, before ? '-frase' : '')).join(''));
// #endregion

const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const words = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text',
  id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color),
  align: 'left', overflow: 'wrap', placement, ...extra });
const picture = (id, resourceId, placement) => ({ kind: 'image', id, resourceId, placement });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.5) };

// #region opener: the letter's page: a cream band, a giant Mm and two things that start with m
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// Images reserve no height in an opener (gotcha: opener-image-no-reserve): by itself the
// opener ends at the foot of the word manzana, 88.7 mm down, and the first title starts on
// the wave's edge. 16 grid lines of minHeight start it on the grid, 11.4 mm below the wave.
const opener = { enabled: true, minHeight: pt(16 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('butterfly', 'mariposa', at(146, 12, { width: mm(40) })),
    picture('apple', 'manzana', at(151, 53, { width: mm(30) })),
    ...[['mariposa', 45.5], ['manzana', 84.5]].map(([name, y]) => words(name, name, LABEL, 10,
      600, 'muted', at(146, y, { width: mm(40) }), { align: 'center' })), // under each picture
  ] } };
// #endregion

// #region activities: each activity heading draws its icon beside the title
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });
// #endregion

// #region pictures: six picture words in a rounded grid, each picture inside its cell
const PICTURE_WORDS = ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'];
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});
// #endregion

// The type of every picture and the table: its empty captionPrefix prints no caption line.
const sheet = { id: 'lamina', name: 'Lámina', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
const here = (id, rest) => ({ id, typeId: 'lamina', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, ...rest });

const FOLIO = 9; // mm: the folio's disc
const folio = (parity, edge, x, textEdge, textX) => [
  words(`n-${parity}`, '{pageNumber}', DISPLAY, 12, 700, 'paper',
    at(x, -10, { width: mm(FOLIO), height: mm(FOLIO) }, edge),
    { align: 'center', verticalAlign: 'middle', parity,
      box: { backgroundColor: col('ink'), borderRadius: mm(FOLIO / 2) } }),
  words(`s-${parity}`, '{title} · {attr.unit}: {attr.name}', LABEL, 8, 600, 'muted',
    at(textX, -10, { width: mm(100), height: mm(FOLIO) }, textEdge),
    { parity, verticalAlign: 'middle', align: textEdge.endsWith('left') ? 'left' : 'right' }),
];

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, chipStyles, tableStyles, resourceTypes: [sheet],
  page: { width: mm(210), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, // equal sides: nothing to mirror
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(GRID), color: col('ink'),
    referenceColor: col('ink'), // the palette never reaches it (gotcha: palette-skips-designs)
    textAlign: 'left', firstLineIndent: pt(0) }, // ragged: 1.4.1 hyphenates no ragged text
  headings: { fontFamily: LABEL, // the titles print through designs, but are measured in it
    balancing: { enabled: false }, // or page 37's spare grid line goes above its first title
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // A unit opens on a recto. span 'page' paints the band into the top margin, where a
      // design kept in the column is cut off at the column's top edge.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener }, H2] },
  headingStyles: [activity('oye', 'oreja'), activity('lee', 'libro'), activity('mira', 'ojo'),
    activity('repasa', 'lapiz')],
  // #region reading: the child's lines on multiples of the 15 pt grid, the instructions at 10 pt
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],
  // #endregion
  // #region boxes: an activity on cream, the note for the family under a rule
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],
  // #endregion
  header: { elements: [] },
  footer: { elements: [ // the folio on the outer edge, the series beside it
    ...folio('odd', 'bottom-right', -SIDE, 'bottom-right', -(SIDE + FOLIO + 3)),
    ...folio('even', 'bottom-left', SIDE, 'bottom-left', SIDE + FOLIO + 3)] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 90 linhas · content.es.mdtitle: "Letra a letra" --- # Mm {kicker="Letra a letra · Cartilla de lectura 1" unit="Unidad 7" name="la eme"} ## Escucha y repite {style="oye"} :::paragraphs{style="consigna"} Lee las sílabas en voz alta, en orden y después salteadas. Abajo, la eme va al final. ::: :::paragraphs{style="fila"} {ma} {me} {mi} {mo} {mu} {am} {em} {im} {om} {um} ::: ## Lee las palabras {style="lee"} :::paragraphs{style="consigna"} Cada sílaba lleva el color de su vocal. Las que tienen eme van rellenas. ::: :::paragraphs{style="palabras"} {ma·má} {a·mo} {Me·mo} {e·me} {mí·o} {mi·mo} {ma·mi} {mí·a} ::: ## Mira y lee {style="mira"} :::paragraphs{style="consigna"} Di el nombre de cada dibujo y léelo por sílabas. ::: ::resource{id="dibujos"} ## Repasa {style="repasa"} :::paragraphs{style="consigna"} Empieza en el punto y sigue la flecha. Luego repasa las sílabas. ::: ::resource{id="trazos"} ## Lee las frases {style="lee"} :::paragraphs{style="consigna"} Lee despacio. La primera vez que sale una palabra nueva, va partida en sílabas. ::: :::paragraphs{style="lectura"} Mi mamá me {mi·ma}. Mamá me {a·ma}. Yo amo a mi mamá. Memo mima a {Mi·mí}. ::: :::space{lines=1} :::callout{type="colorea" title="Lee y colorea"} Estos son los colores de las vocales: :::paragraphs{style="palabras"} :chip[a]{style="a"} :chip[e]{style="e"} :chip[i]{style="i"} :chip[o]{style="o"} :chip[u]{style="u"} ::: :::space{lines=1} Colorea cada sílaba con el color de su vocal. :::paragraphs{style="palabras"} :chip[mo]{style="pinta"} :chip[ma]{style="pinta"} :chip[mu]{style="pinta"} :chip[me]{style="pinta"} :chip[mi]{style="pinta"} :chip[ma]{style="pinta"} :chip[mi]{style="pinta"} :chip[me]{style="pinta"} :chip[mo]{style="pinta"} :chip[mu]{style="pinta"} :chip[ma]{style="pinta"} :chip[mo]{style="pinta"} ::: ::: :::callout{type="familia" title="Para la familia"} Lean juntos las sílabas de la página 37: primero en orden y después salteadas. Si su hijo o su hija duda entre *ma* y *me*, no le dé la respuesta: señale la vocal y pregúntele de qué color es. Diez minutos al día bastan; es mejor parar cuando todavía tiene ganas de seguir. ::: :::paragraphs{style="colofon"} *Letra a letra* es una cartilla inventada para el Recetario de Postext. Texto: CC BY 4.0. Acuarelas: modelos de difusión. Compuesta en Andika, DynaPuff y Playpen Sans (SIL Open Font License). :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the band, the icons and the tracing rows, drawn in the palette's colours // The picture words are watercolours, JPEGs in assets/ painted on white and multiplied by the // cream, so they sit on the band and the tiles without a box. The rest is drawn here, with no // words in it: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts), so the // tracing letters are strokes and every label is set by the page. const P = palette; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const shape = (d, fill) => `<path d="${d}" fill="${fill}"/>`; const line = (d, color, w, extra = '') => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const dot = (x, y, r, fill) => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="${fill}"/>`; const drawings = { banda: () => svgDoc(210, BAND, shape(`M0 0H210V${BAND - 6}C190 ${BAND - 1} 172 ${BAND - 9} 150 ` + `${BAND - 6}S102 ${BAND + 1} 76 ${BAND - 5} 28 ${BAND - 12} 0 ${BAND - 6}Z`, P.cream)), }; // The activity icons: a white glyph on an ink disc. const icon = (glyph) => svgDoc(20, 20, dot(10, 10, 10, P.ink) + glyph); Object.assign(drawings, { oreja: () => icon(line('M7.5 8.5C7.5 5 10 3.5 12.5 3.5C15.5 3.5 16.5 6 16.5 8C16.5 11 13.5 ' + '11.5 13 14C12.5 16.5 10 17 8.5 15.5M10 8.5C10 7 11 6 12.5 6C13.8 6 14.3 7.2 14 8.3', P.paper, 1.5)), ojo: () => icon(line('M3.5 10Q10 3.5 16.5 10Q10 16.5 3.5 10Z', P.paper, 1.5) + dot(10, 10, 2.4, P.paper)), libro: () => icon(line('M10 6.5C7.5 5 5.5 5 4 5.8V14.8C5.5 14 7.5 14 10 15.5C12.5 14 14.5 14 ' + '16 14.8V5.8C14.5 5 12.5 5 10 6.5ZM10 6.5V15.5', P.paper, 1.3)), lapiz: () => icon(line('M5.5 14.5L6.5 11L13.5 4L16 6.5L9 13.5ZM12 5.5L14.5 8', P.paper, 1.3)), }); // Tracing: rows on writing guides, a model letter in ink, then dashed ones to go over. const TRACE = { w: 170, first: 26, pitch: 20.5 }; // mm const guides = (b) => line(`M0 ${b - 16}H${TRACE.w}M0 ${b}H${TRACE.w}`, P.rule, 0.4) + line(`M0 ${b - 8}H${TRACE.w}`, P.rule, 0.35, ' stroke-dasharray="1 1"'); // Letters as strokes: x-height 8, capitals 16, from the baseline b; [path, width]. const glyphs = { m: (x, b) => [`M${x} ${b - 8}V${b}M${x} ${b - 5.4}C${x} ${b - 7.4} ${x + 1.4} ${b - 8} ` + `${x + 2.7} ${b - 8}S${x + 5.3} ${b - 7.2} ${x + 5.3} ${b - 5.4}V${b}M${x + 5.3} ${b - 5.4}` + `C${x + 5.3} ${b - 7.4} ${x + 6.7} ${b - 8} ${x + 8} ${b - 8}S${x + 10.6} ${b - 7.2} ` + `${x + 10.6} ${b - 5.4}V${b}`, 10.6], M: (x, b) => [`M${x} ${b}V${b - 16}L${x + 6} ${b - 6}L${x + 12} ${b - 16}V${b}`, 12], a: (x, b) => [`M${x + 6.4} ${b - 4}A3.2 4 0 0 0 ${x} ${b - 4}A3.2 4 0 0 0 ${x + 6.4} ${b - 4}` + `M${x + 6.4} ${b - 8}V${b}`, 6.4], e: (x, b) => [`M${x + 0.2} ${b - 4}H${x + 6.8}C${x + 6.8} ${b - 6.4} ${x + 5.2} ${b - 8} ` + `${x + 3.5} ${b - 8}S${x + 0.2} ${b - 6.2} ${x + 0.2} ${b - 4}S${x + 1.8} ${b} ${x + 3.7} ` + `${b}C${x + 5} ${b} ${x + 6} ${b - 0.5} ${x + 6.7} ${b - 1.3}`, 6.8], i: (x, b) => [`M${x + 0.6} ${b - 8}V${b}M${x + 0.6} ${b - 11.4}V${b - 11}`, 1.2], o: (x, b) => [`M${x + 6.8} ${b - 4}A3.4 4 0 0 0 ${x} ${b - 4}A3.4 4 0 0 0 ${x + 6.8} ${b - 4}`, 6.8], u: (x, b) => [`M${x} ${b - 8}V${b - 2.8}C${x} ${b - 1} ${x + 1.3} ${b} ${x + 3} ${b}S${x + 6.2} ` + `${b - 1} ${x + 6.2} ${b - 2.8}M${x + 6.2} ${b - 8}V${b}`, 6.2], }; const KERN = 1.8; // mm between letters const write = (word, x, b) => word.split('').reduce(([d, at], ch) => { const [path, w] = glyphs[ch](at, b); return [d + path, at + w + KERN]; }, ['', x]); // [path, x after the last letter + KERN] const dashed = ([d]) => line(d, P.muted, 1, ' stroke-dasharray="1.1 1"'); // The model: ink strokes, a teal dot where the pencil starts and an arrow beside the stem // pointing the way the first stroke goes (dir 1 down, −1 up). const model = (letter, b, [x, y], dir) => line(write(letter, 4, b)[0], P.ink, 1.3) + dot(x, y, 1.2, P.i) + line(`M${x - 2.4} ${y + dir * 1.5}V${y + dir * 6}`, P.i, 0.6) + shape(`M${x - 3.5} ${y + dir * 5.2}H${x - 1.3}L${x - 2.4} ${y + dir * 7.2}Z`, P.i); const repeat = (letter, b) => Array.from({ length: 7 }, (_, k) => dashed(write(letter, TRACE.first + k * TRACE.pitch, b))).join(''); // The five syllables, each centred in a fifth of the row. const syllableRow = (b) => ['ma', 'me', 'mi', 'mo', 'mu'].map((syll, k) => { const w = write(syll, 0, b)[1] - KERN; return dashed(write(syll, (k + 0.5) * (TRACE.w / 5) - w / 2, b)); }).join(''); drawings.trazos = () => svgDoc(TRACE.w, 67, guides(17) + model('m', 17, [4, 9], 1) + repeat('m', 17) + guides(41) + model('M', 41, [4, 41], -1) + repeat('M', 41) + guides(65) + syllableRow(65)); // The watercolours, by resource id: [file, width, height] in px. const PAINTINGS = { mariposa: ['mariposa-660.jpg', 660, 540], manzana: ['manzana-480.jpg', 480, 480], ...Object.fromEntries(['mano', 'mapa', 'mesa', 'mimosa', 'mono', 'muneca'] .map((id) => [id, [`${id}-640.jpg`, 640, 480]])) }; const ALT = { banda: 'Franja de color crema con el borde ondulado', manzana: 'Una manzana roja con una hoja', mariposa: 'Una mariposa morada con manchas amarillas', mano: 'Una mano abierta', mapa: 'Un mapa del tesoro con un río y un camino hasta una cruz', mesa: 'Una mesa con una manzana y una taza', mono: 'Un mono sentado', mimosa: 'Una rama de mimosa con flores amarillas y hojas plumosas', muneca: 'Una muñeca de trapo con coletas y vestido morado', oreja: 'Una oreja', ojo: 'Un ojo', libro: 'Un libro abierto', lapiz: 'Un lápiz', trazos: 'Tres pautas: la eme minúscula y la mayúscula, cada una con un modelo y siete de puntos ' + 'para repasar, y las sílabas ma, me, mi, mo y mu de puntos' }; // Each picture is a resource that the opener, a heading style, a table cell or the text names // by id. Drawings in px, 10 to the millimetre (the viewBox's own). const size = (svg) => svg.match(/width="(\d+)" height="(\d+)"/).slice(1).map(Number); const common = (id) => ({ id, typeId: 'lamina', altText: ALT[id], createdAt: 0, updatedAt: 0 }); const artwork = [...Object.entries(drawings).map(([id, draw]) => { const [width, height] = size(draw()); return { ...common(id), kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, ...(id === 'trazos' && here(id)) }; }), ...Object.entries(PAINTINGS).map(([id, [fileId, w, h]]) => ({ ...common(id), kind: 'bitmap', bitmap: { fileId, format: 'jpeg', width: w, height: h } }))]; // #endregion const resources = [...artwork, here('dibujos', { kind: 'table', table: { styleId: 'dibujos', model: grid } })]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], DynaPuff: ['700'], 'Playpen Sans': ['400', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const text = syllables(markdown); await Promise.all([loadFonts(FONTS, text), // every face before the build (gotcha: fonts-first) ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())), ...Object.values(PAINTINGS).map(([file]) => loadImage(file, asset(file)))]); const continuation = { pageIndexOffset: 36, pageNumbering: { startAt: 37 } }; // 37: a recto const doc = await buildWithFonts(() => buildDocument({ markdown: text, resources, continuation }, config()), text); showPages(doc, { title: 'Letra a letra · La eme' });
Kit · core, fonts, viewer, images: igual em todas as receitas · 271 linhas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Arredonde as sílabas em pílulas

Um raio maior que metade da altura do chip é limitado a essa metade, então um valor grande desenha pontas redondas. Uma sílaba de uma letra só sai quase um círculo, e as emendas dentro de uma palavra viram entalhes.

-  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
+  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5), borderRadius: em(1) };

Erros comuns

Erro comum

Chips mais altos que a entrelinha encostam na linha seguinte

Um chip cuja caixa é mais alta que a entrelinha encosta nos chips da linha seguinte (o aviso chipOverlap). Reduza o preenchimento vertical, a borda ou o tamanho do chip, ou aumente a entrelinha. Chips no texto →

Erro comum

O texto dentro de um SVG <img> não pode usar fontes web

Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →

Erro comum

:::space é descartado no topo de um boxe ou de uma coluna

:::space é descartado no topo de uma coluna, de um boxe ou de um grupo :::columns, mesmo quando é o único conteúdo do boxe, então um boxe de resposta feito só de espaço fica sem altura. Abra o boxe com um título ou uma linha de enunciado e acrescente o espaço depois. Espaço vertical explícito →

Erro comum

As imagens de uma abertura nunca contam para a altura que ela reserva

No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →

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

Uma paleta trocada não chega aos elementos de design nem à cor das referências

postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →

Erro comum

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

A configuração fica em cache pela identidade: crie um objeto novo

O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →

Verificação do Sandbox · chipOverlap

Chips encostam na linha seguinte

Por quê. A caixa de um estilo de chip é mais alta que a entrelinha, então os chips de linhas consecutivas se tocam.

Correção. Reduza o preenchimento vertical, o contorno ou o tamanho do chip, ou aumente a entrelinha. Docs →

Dê a minHeight linhas inteiras da grade. Com 85 mm, 0,33 mm além da 16ª linha, o primeiro título começou 0,33 mm fora da grade. O bloco do título então se estendeu até a linha seguinte da grade, com 15,5 mm de altura em vez de 10,6, e tudo abaixo dele na página 37 desceu 5,3 mm.

Com o equilíbrio de colunas ligado, um minHeight de 16 linhas deixava as atividades da página 37 onde 17 linhas as punham: a página tinha uma linha da grade de sobra, e o equilíbrio a colocava acima do título da primeira atividade. A configuração desliga o equilíbrio (headings.balancing), e a linha de sobra fica no pé da página.

Créditos

Texto
Texto original, CC BY 4.0
Imagens
  • A watercolour of the butterfly on the opener · Generated With Diffusion Models · original
  • A watercolour of the apple on the opener · Generated With Diffusion Models · original
  • A watercolour of the hand in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the map in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the table in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the mimosa sprig in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the monkey in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the doll in the picture grid · Generated With Diffusion Models · original
  • The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette · Ignacio Ferro · MIT
Fontes
Andika (SIL OFL 1.1) · DynaPuff (SIL OFL 1.1) · Playpen Sans (SIL OFL 1.1)
Sandbox