Saltar al contingut principal
Recepta número 60

Receptari · Capítol 2 · Text i tipografia

Cartilla de lectura amb síl·labes en xips

Una unitat de cartilla dedicada a la ema, amb cada síl·laba en un xip del color de la seva vocal i sis paraules en una taula, cadascuna sota la seva aquarel·la.

En aquesta pàgina
  • Mostra en castellà: encara no hi ha edició en català
  • Format 210 × 260 mm
  • 1 columna
  • Andika 11/15
  • DynaPuff
  • Playpen Sans
  • 3 pàgines
  • Nivell
  • Postext 1.9.2
  • Compost en 8 ms
  • 180 línies de codi

En poques paraules

Tres pàgines d'una cartilla per aprendre a llegir, dedicades a la lletra M. Cada síl·laba va en una etiqueta del color de la seva vocal i cada paraula de la quadrícula porta el seu dibuix.

Què compondràs

La unitat 7 de Letra a letra, una cartilla de lectura inventada: tres pàgines dedicades a la ema. L'obertura compon una Mm de DynaPuff a 190 pt sobre una franja crema, al costat de dues coses el nom de les quals comença per ema en castellà, una papallona (mariposa) i una poma (manzana). Les síl·labes de la unitat van en xips del color de la seva vocal: vermell la a, taronja la e, turquesa la i, blau la o i morat la u. Les que porten ema van emplenades i les altres, només amb el contorn, de manera que el nen veu quina meitat de mesa és d'aquesta unitat. La pàgina 38 reuneix sis paraules amb la seva aquarel·la en una taula de cantonades arrodonides i tres pautes per repassar. Les aquarel·les es van generar amb models de difusió; la franja, les icones i les pautes estan dibuixades en codi. La 39 porta quatre frases a 24 pt, un requadre de síl·labes per pintar i una nota per a la família.

Aquesta recepta respon a

  • Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
  • Com poso imatges o icones dins de les cel·les d'una taula?
  • Com afegeixo espai vertical entre dos blocs, si les línies en blanc no fan res?
  • Com faig una fitxa d'exercicis: línies per omplir, caixes de resposta, bancs de paraules, llistes de comprovació?

La resposta curta

script.js · línies 27–47al codi complet
// 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(''));

Ingredients

Tipografia
Andika, DynaPuff, Playpen Sans (SIL OFL 1.1)
Recursos
  • 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)

Elaboració

#1 · Un estil de xip per vocal i una funció que escriu els xips

La resposta curta treu els 21 estils de xip d'un mateix objecte, syllable. N'hi ha un d'emplenat per vocal per a les síl·labes amb ema, un altre amb contorn per vocal per a les altres, un bessó -frase de cadascun d'aquests deu i pinta, el contorn en tinta que el nen pinta. La funció syllables() converteix {mi·mo·sa} en tres :chip[…] sense res entre ells, de manera que les síl·labes d'una paraula es toquen i la vora blanca marca la junta. On hi ha un espai entre paraules, el xip guarda com a mínim el seu gap: amb 0,5 em, les paraules d'una fila de 24 pt queden a 4,2 mm, i l'espai tot sol en deixaria 2,3 mm. Dins d'una frase, aquest gap gairebé duplicaria l'espai davant de la paraula nova; per això, quan la paraula va darrere d'una altra, porta els bessons -frase, amb gap 0, i l'espai es queda en 2,3 mm.

#2 · Un interlineat que deixa lloc als xips

script.js · línies 145–155al codi complet
  // 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) },
  ],

El farciment vertical i la vora d'un xip es pinten fora de la seva línia i no sumen interlineat, així que un xip més alt que l'interlineat xoca amb els xips de la línia de sota. Un xip de síl·laba a 24 pt fa 10,4 mm d'alçada; amb 30 pt d'interlineat (10,6 mm), els xips de dues frases quedarien a 0,1 mm. Per això les línies que llegeix el nen porten 45 pt i les files de síl·labes 60 pt, tres i quatre línies de la retícula de base de 15 pt en què van les consignes.

#3 · Una aquarel·la a cada cel·la

script.js · línies 94–106al codi complet
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 posa una imatge damunt del text d'una cel·la amb una fracció de la seva amplada interior, aquí 0,72 (36,5 mm), i la fila creix perquè hi càpiga. Cada aquarel·la és un JPEG pintat sobre blanc i multiplicat pel crema de les rajoles, de manera que s'assenta a la rajola sense requadre. Sota cada aquarel·la va la paraula en xips de síl·labes, que una cel·la admet com qualsevol paràgraf. L'estil de taula dibujos arrodoneix el marc i els seus emplenaments a 5 mm i separa les rajoles crema amb filets blancs de 4 pt.

#4 · Una línia d'espai abans del requadre

script.js · línies 158–170al codi complet
  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) } },
  ],

Entre l'última frase i el requadre per pintar, el Markdown porta :::space{lines=1}, una línia de 15 pt (5,3 mm). El marginTop del requadre és 0, així que aquesta línia és tot l'espai; sense ella, el requadre començaria just on acaba la línia de l'última frase. Un :::space que obre un requadre es descarta, de manera que el requadre comença amb el seu enunciat i els xips de la mostra de colors, i el seu propi :::space va darrere, entre la mostra i la tasca de pintar.

#5 · L'obertura de la lletra

script.js · línies 59–77al codi complet
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
  ] } };

La Mm és el text del títol ({titleText}), i el número de la unitat, la sèrie i el nom de la lletra són atributs d'aquesta mateixa línia. La franja, la papallona i la poma són imatges, i una obertura no reserva alçada per a les imatges. Sense minHeight, l'obertura acaba al peu de la paraula manzana, a 88,7 mm de la vora superior de la pàgina, i el títol de la primera activitat comença a 99,3 mm, 2,6 mm per sobre del punt més baix de l'ona (101,9 mm). Amb un minHeight de 16 línies de la retícula de base, l'obertura acaba a 102,7 mm, i les dues línies de marginTop del títol el baixen a 113,3 mm, sobre la retícula i 11,4 mm per sota de l'ona.

#6 · Icones al costat dels títols d'activitat

script.js · línies 81–90al codi complet
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ítol d'activitat anomena el seu estil, com a ## Lee las frases {style="lee"}, i l'estil dibuixa una icona amb el títol centrat al costat. Continuen sent títols de nivell 2, així que l'espai el fixa el nivell H2: dues línies de la retícula de base de lineHeight per al títol i dues més de marginTop a sobre.

La recepta 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`---
Mostra en Markdown · 90 línies · 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 a totes les receptes · 270 línies// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; 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` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs 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; its * bold, italic and bold-italic variants are listed whether or not used. */ 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' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Converteix les síl·labes en píndoles

Un radi més gran que la meitat de l'alçada del xip es retalla a aquesta meitat, de manera que un valor gran dibuixa extrems rodons. Una síl·laba d'una sola lletra surt gairebé rodona, i les juntes dins d'una paraula es converteixen en osques.

-  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) };

Errors freqüents

Error freqüent

Els xips més alts que l'interlineat toquen la línia següent

Un xip amb una caixa més alta que l'interlineat toca els xips de la línia següent (l'avís chipOverlap). Redueix-ne el farciment vertical, la vora o el cos, o obre l'interlineat. Chips en línia →

Error freqüent

El text dins d'un SVG <img> no pot fer servir fonts web

Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →

Error freqüent

:::space es descarta al principi d'un requadre o columna

:::space es descarta al principi d'una columna, d'un requadre o d'un grup :::columns, encara que sigui l'únic contingut de la caixa, així que una caixa de resposta feta només d'espai es queda sense alçada. Obre la caixa amb un títol o una línia d'enunciat i afegeix-hi després l'espai. Espai vertical explícit →

Error freqüent

Les imatges d'una obertura no compten per a l'alçada que reserva

A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Una paleta canviada no arriba als elements de disseny ni al color de les remissions

postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

Comprovació del Sandbox · chipOverlap

Els xips toquen la línia següent

Per què. La caixa d'un estil de xip és més alta que l'interlineat, de manera que els xips de línies seguides es toquen.

Solució. Redueix el farciment vertical, el contorn o el cos del xip, o augmenta l'interlineat. Documentació →

Dona a minHeight línies senceres de la retícula de base. Amb 85 mm, 0,33 mm més enllà de la línia 16, el primer títol va començar 0,33 mm fora de la retícula. El seu bloc es va allargar llavors fins a la línia següent, 15,5 mm en lloc de 10,6, i tot el que va a sota a la pàgina 37 va baixar 5,3 mm.

Amb l'equilibratge de columnes actiu, un minHeight de 16 línies deixava les activitats de la pàgina 37 on les deixaven 17: a la pàgina li sobrava una línia de la retícula de base, i l'equilibratge la posava damunt del títol de la primera activitat. La configuració el desactiva (headings.balancing), així que la línia sobrant es queda al peu de la pàgina.

Crèdits

Text
Text original, CC BY 4.0
Imatges
  • 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
Fonts
Andika (SIL OFL 1.1) · DynaPuff (SIL OFL 1.1) · Playpen Sans (SIL OFL 1.1)
Sandbox