En bref
Une unité de manuel scolaire sur un fleuve, de neuf pages. Chaque thème est une double page sur un fond coloré, avec onglet de titre, texte, images et encadrés. La carte ne porte aucun mot peint : ses noms viennent du texte.
Ce que vous allez composer
Neuf pages de Notre fleuve, unité 4 d’un manuel inventé de sciences sociales pour le CE2, sur une page de 210 × 280 mm, en espagnol, en catalan et en anglais. Une vallée peinte ouvre l’unité, une page de sommaire liste deux parties, puis trois thèmes se suivent en doubles pages, chacun sur son fond : bleu eau, vert champ, sable soleil. Chaque thème a un onglet avec son numéro et son titre, un texte sur deux colonnes et des encadrés à onglet qui grandissent avec leur texte. Le deuxième thème est une seule carte peinte sur la double page, où le fleuve, les villages et les trois cours sont nommés depuis le Markdown. Les trois textes diffèrent d’un cinquième ou plus et tiennent dans les mêmes maquettes : un encadré prend la hauteur que demandent ses mots et repousse le suivant, et la carte est réduite un peu avant tout texte.
Cette recette répond à
- Comment composer une unité de manuel scolaire en trois langues avec les mêmes maquettes de page, quand le texte de chaque langue n’a pas la même longueur ?
- Comment faire grandir les encadrés d’une page avec leur texte et repousser vers le bas ceux qui les suivent ?
- Comment poser les noms d’une carte depuis le texte, pour qu’une traduction change les mots et pas le dessin ?
La réponse courte
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
box: { backgroundColor: col('paper'), borderRadius: mm(6) },
placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
placement: at('container', 'top-left', TAB) };
const base = {
id: 'tema', spread: true, gutter: mm(12),
decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
fit: { minScale: 0.9 } }],
groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
{ id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
{ id: 'number', color: col(f) }] },
...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
height: mm(LINE * lines), minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
placement: at(`#${name}`, 'top-right', -5, 8) });
Ingrédients
- Fonctionnalités
- Pages composéesBlocs qui s’agrandissent dans un groupeOnglets numérotés d’encadréÉtiquettes sur l’imageTable des matièresModèles en double pageEncadrésTexte, filets et boîtes dans les maquettes de pageImages dans les maquettes de pageAncrer les éléments de maquetteDécor ancré aux blocsMarges en miroirTitres courants et foliosStyles de titrePalette de couleurs sémantiqueFigures et tableaux comme ressourcesPages sur un canvasExport PDF
- Utilise aussi
- Blocs qui repoussent le texte, au cadre ou au contourAttributs de titreOuvertures composéesPages de partiePolices intégrées au PDFTypes de ressource personnalisésChapitres non numérotés
- Typographie
- Nunito, Fredoka (SIL OFL 1.1)
- Ressources
arroyo-720.jpgcascada-720.jpglimpieza-720.jpgmapa-1900.jpgmolino-720.jpgnacimiento-720.jpgpiraguas-720.jpgportada-1400.jpgriego-720.jpg- The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water. (Generated With Diffusion Models, original)
- The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown. (Generated With Diffusion Models, original)
- A spring between mossy rocks. (Generated With Diffusion Models, original)
- A mountain stream over round stones. (Generated With Diffusion Models, original)
- A small waterfall into a green pool. (Generated With Diffusion Models, original)
- A farmer opening the gate of an irrigation canal. (Generated With Diffusion Models, original)
- An old stone watermill with its wheel. (Generated With Diffusion Models, original)
- Children paddling canoes on a calm river. (Generated With Diffusion Models, original)
- Children and a teacher picking up litter on a river bank. (Generated With Diffusion Models, original)
Préparation
#1 · Construisez les encadrés avec extends
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
{ id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
...Object.keys(FAMILIES).flatMap((f) => [
{ id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
{ id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
{ id: `palabras-${f}`, extends: `caja-${f}`,
border: { enabled: true, color: col(f), width: pt(1.4) } },
{ id: `actividad-${f}`, extends: `caja-${f}`,
stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
]),
// Plain frames: the title (its tab is decoration), a picture's card and an answer space.
{ id: 'titulo', extends: 'caja', backgroundEnabled: false,
padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
{ id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
{ id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
{ id: 'foto', extends: 'caja', borderRadius: mm(2),
padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
{ id: 'respuesta', extends: 'caja', padding: none },
{ id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
Un style d’encadré se fait en trois temps. caja garde la forme, la typographie et la géométrie de l’onglet ; caja-agua, caja-campo et caja-sol mettent la couleur d’une famille sur l’onglet et les puces ; dato-, palabras- et actividad- fixent le genre : encadré teinté, cerné ou à liseré. Une nouvelle famille, ce sont trois couleurs dans FAMILIES, et aucun style n’est écrit deux fois.
#2 · Faites hériter chaque thème d’une maquette de base
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
box: { backgroundColor: col('paper'), borderRadius: mm(6) },
placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
placement: at('container', 'top-left', TAB) };
const base = {
id: 'tema', spread: true, gutter: mm(12),
decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
fit: { minScale: 0.9 } }],
groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
{ id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
{ id: 'number', color: col(f) }] },
...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
height: mm(LINE * lines), minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
placement: at(`#${name}`, 'top-right', -5, 8) });
La base est une double page : un fond jusqu’au fond perdu, un onglet de titre avec son numéro et un texte sur deux colonnes. Un thème l’étend (extends: 'tema'), change les couleurs de trois éléments de décor par leur id et ajoute ses blocs. L’onglet et le numéro sont du décor ancré au bloc du titre : l’onglet est donc aussi haut que le titre que la langue demande, et le numéro se tient à gauche du bloc, là où rien ne le couvre.
#3 · Laissez les groupes se partager la place
Chaque thème a deux groupes. La page de gauche est une pile : titre, texte, puis la bande d’images ou la carte. Celle de droite est une autre pile d’encadrés, ou une rangée de trois encadrés qui se partagent la largeur. Un bloc est aussi haut que ses mots, jamais moins que son minHeight : le texte espagnol repousse tout ce qui le suit et aucune position de la maquette ne change. Quand une page manque de place, l’échelle d’ajustement reprend de l’espace, réduit la carte et les images, puis le texte en dernier, jusqu’à 90 %. pnpm cookbook bench recompose les trois éditions avec un texte plus long de 30 %.
#4 · Posez les noms de la carte depuis le texte
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
{ id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
textTransform: 'uppercase', letterSpacing: 0.14, halo },
{ id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
color: { hex: '#0b3a5c', model: 'hex' }, halo },
{ id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
{ id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
{ id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
padding: mm(1.6) },
];
const ANCHORS = [
{ id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
{ id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
{ id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
{ id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
{ id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
{ id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
{ id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
{ id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
{ id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
{ id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
{ id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
La carte est une peinture sans une lettre. Chaque lieu est une ancre de l’image, un point en fractions de sa largeur et de sa hauteur avec un style d’étiquette, et chaque édition écrit ses propres lignes ::label à ces ancres. Penalta Reservoir, Pantà de Penyalta et Embalse de Peñalta tiennent au même endroit, et un nom plus long se coupe dans le width de l’ancre.
#5 · Gardez chaque partie du sommaire d’un seul tenant
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
{ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
fontWeight: 600, color: col('paper'), align: 'left',
placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
levels: [
{ level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
marginTop: pt(4) },
{ level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
color: col('ink') },
],
pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
leader: { char: '.', gap: mm(1.5) },
parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
{ kind: 'box', id: 'band', decorative: true,
placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
{ kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
fontWeight: 700, color: col('paper'), align: 'center',
box: { backgroundColor: col('agua'), borderRadius: mm(4) },
placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
] } },
};
:::toc{keep="part" levels="1-2"} liste les thèmes et leurs sections et garde chaque ligne de partie avec ses entrées. Un délimiteur :::part avec parts.page: false n’est qu’une ligne du sommaire et une section pour les titres courants : il n’ajoute pas de page de titre. Le sommaire est une page de texte courant et non un bloc, car un :::toc dans un bloc perd ses lignes de partie.
La recette complète
// ═══ Postext Cookbook · Nº 156 · A children's textbook unit in three languages ═══ // https://postext.dev/en/cookbook/children-textbook-pages // Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models // Fonts: Nunito, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.40.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('es' | 'en' | 'ca') const RECIPE = 'children-textbook-pages'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one ink and three families (water, field, sun), each with an accent, a tint // for its boxes and a ground for its pages const FAMILIES = { agua: { main: '#1b6aa0', tint: '#e1f0fa', ground: '#d3e8f6' }, campo: { main: '#336f27', tint: '#e5f1dc', ground: '#d9ebcb' }, sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' }, }; const palette = { ink: '#1d2f3f', muted: '#4f6272', rule: '#7e98ab', paper: '#ffffff' }; for (const [f, c] of Object.entries(FAMILIES)) { Object.assign(palette, { [f]: c.main, [`${f}-tint`]: c.tint, [`${f}-ground`]: c.ground }); } const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'water (defaults)', value: { hex: palette.agua, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY] = ['Nunito', 'Fredoka']; const PAGE = { w: 210, h: 280, top: 18, bottom: 18, inner: 14, outer: 12 }; // mm, mirrored const HALF = PAGE.w - PAGE.inner - PAGE.outer; // one page's type area: 184 mm const SHEET = 2 * HALF + 2 * PAGE.inner; // a spread's content area: 396 mm const RIGHT = HALF + 2 * PAGE.inner; // where the right page's type area starts on the sheet const LINE = 8; // mm between two answer lines const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const onRight = at('container', 'top-left', RIGHT); // every frame of the right page's stack // #region boxes: callout styles that extend each other // Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind. const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'), fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2), offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } }); const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left', paragraphSpacing: true }; const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding const calloutStyles = [ { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3), border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) }, body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } }, ...Object.keys(FAMILIES).flatMap((f) => [ { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } }, { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) }, { id: `palabras-${f}`, extends: `caja-${f}`, border: { enabled: true, color: col(f), width: pt(1.4) } }, { id: `actividad-${f}`, extends: `caja-${f}`, stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } }, ]), // Plain frames: the title (its tab is decoration), a picture's card and an answer space. { id: 'titulo', extends: 'caja', backgroundEnabled: false, padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } }, { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } }, { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4), padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } }, { id: 'foto', extends: 'caja', borderRadius: mm(2), padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } }, { id: 'respuesta', extends: 'caja', padding: none }, { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none, body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } }, ]; // #endregion // #region answer: one base template and a family of topics that extend it // Ground, title tab and number are decoration anchored to the title frame, so the tab is as // tall as the title needs. A topic extends the base and changes its colours and adds frames. const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } }, style: { backgroundColor: col(`${f}-ground`) } }); const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true, placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } }, style: { backgroundColor: col(f), borderRadius: mm(4) } }); const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY, fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center', box: { backgroundColor: col('paper'), borderRadius: mm(6) }, placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4), size: { width: mm(12), height: mm(12) } } }); const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB), placement: at('container', 'top-left', TAB) }; const base = { id: 'tema', spread: true, gutter: mm(12), decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] }, frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF), fit: { minScale: 0.9 } }], groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }], }; const topic = (id, f, more) => ({ id, extends: 'tema', decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } }, { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } }, { id: 'number', color: col(f) }] }, ...more }); const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF), minHeight: mm(minHeight), ...more }); const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF), height: mm(LINE * lines), minHeight: mm(LINE * lines), rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } }); // A picture that sits at the corner of a box and pushes its text aside. const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push', placement: at(`#${name}`, 'top-right', -5, 8) }); // #endregion const questions = [1, 2, 3].flatMap((n) => [box(`pregunta-${n}`, 'dato-agua', 14), answer(`respuesta-${n}`, 3)]); const pageTemplates = [ base, // Water: text, a strip of three pictures and a box; three boxes grow down the right page. topic('tema-agua', 'agua', { frames: [ ...[1, 2, 3].map((n) => ({ name: `foto-${n}`, style: 'foto', width: mm((HALF - 10) / 3), ...(n === 1 && { placement: at('#texto', 'below', 0, 8) }) })), box('probar', 'palabras-agua', 46, { placement: at('#foto-1', 'below', 0, 10) }), { ...box('sabias', 'dato-agua', 40), placement: onRight }, { ...box('palabras', 'palabras-agua', 62), placement: onRight }, { ...box('actividades', 'actividad-agua', 58), placement: onRight }, ], groups: [ { name: 'fotos', kind: 'row', gap: mm(5), frames: ['foto-1', 'foto-2', 'foto-3'] }, { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['sabias', 'palabras', 'actividades'] }, ], }), // Field: one map across the spread; under it a text, and three boxes that share a width. topic('tema-campo', 'campo', { frames: [ { name: 'mapa', crossGutter: true, width: mm(SHEET) }, box('actividad', 'actividad-campo', 24), ...[1, 2, 3].map((n) => ({ name: `curso-${n}`, style: 'dato-campo', width: mm(56), minHeight: mm(46), shrink: 1, ...(n === 1 && { placement: at('#mapa', 'below', RIGHT, 6) }) })), ], groups: [ { name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'mapa', 'texto', 'actividad'] }, { name: 'cursos', kind: 'row', gap: mm(6), frames: ['curso-1', 'curso-2', 'curso-3'] }, ], }), // Sun: boxes with a picture at their corner that pushes the text aside. topic('tema-sol', 'sol', { frames: [ box('cuidar', 'palabras-sol', 64, { padding: { right: mm(64) } }), corner('cuidar'), box('turno', 'actividad-sol', 40), ...[1, 2, 3].flatMap((n) => [ box(`uso-${n}`, 'dato-sol', 66, { padding: { right: mm(64) }, placement: onRight }), corner(`uso-${n}`), ]), ], groups: [ { name: 'izquierda', kind: 'stack', gap: mm(12), frames: ['titulo', 'texto', 'cuidar', 'turno'] }, { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['uso-1', 'uso-2', 'uso-3'] }, ], }), // Before the unit: questions, each with room to write. { id: 'saberes', header: false, decoration: { elements: [titleTab('agua', 0)] }, frames: [{ ...title, style: 'titulo-plano', width: mm(HALF), placement: at('container', 'top-left') }, { name: 'texto', flow: true, width: mm(HALF) }, ...questions], groups: [{ name: 'pagina', kind: 'stack', gap: mm(7), frames: ['titulo', 'texto', ...questions.map((q) => q.name)] }] }, // The opener: a painting to the bleed, the title over its foot. { id: 'unidad', header: false, footer: false, decoration: { elements: [ { kind: 'image', id: 'portada', resourceId: 'portada', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(157.5) } } }, { kind: 'text', id: 'kicker', content: '{attr.unidad}', fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, letterSpacing: pt(1), textTransform: 'uppercase', color: col('agua'), box: { backgroundColor: col('paper'), borderRadius: mm(2) }, placement: { ...at('#titulo', 'top-left', 0, -9), size: { height: mm(7) } } }, ] }, frames: [ { name: 'titulo', takes: 'h1', style: 'portada', width: mm(120), placement: at('container', 'top-left', 0, 104) }, { name: 'texto', flow: true, width: mm(160) }, box('aprenderas', 'dato-agua', 36), { name: 'colofon', style: 'colofon', width: mm(HALF), placement: at('container', 'bottom-left') }, ], groups: [{ name: 'pagina', kind: 'stack', gap: mm(7), frames: ['titulo', 'texto', 'aprenderas'] }] }, ]; const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity, fontFamily: TEXT, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'), textTransform: 'uppercase', placement: at('page', edge, x, 9) }); const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity, fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, color: col('paper'), align: 'center', box: { backgroundColor: col('agua'), borderRadius: mm(4) }, placement: { ...at('page', edge, x, -8), size: { width: mm(8), height: mm(8) } } }); // #region contents: a flow page, because a :::toc cannot be laid out inside a frame yet // The heading style paints the title tab; the entries come from the headings and the parts. const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [ { kind: 'box', id: 'tab', decorative: true, placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } }, style: { backgroundColor: col('agua'), borderRadius: mm(4) } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28), fontWeight: 600, color: col('paper'), align: 'left', placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } }, ] } }; const toc = { levels: [ { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500, color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9), marginTop: pt(4) }, { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9), color: col('ink') }, ], pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') }, leader: { char: '.', gap: mm(1.5) }, parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [ { kind: 'box', id: 'band', decorative: true, placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } }, style: { backgroundColor: col('paper'), borderRadius: mm(3) } }, { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16), fontWeight: 700, color: col('paper'), align: 'center', box: { backgroundColor: col('agua'), borderRadius: mm(4) }, placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } }, { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) }, ] } }, }; // #endregion // #region art-labels: the map's label styles and anchors; the words are in the Markdown // An anchor is a point of the painting (fractions of its width and height) and a label style; // every language's ::label lines use the same anchors. const halo = { color: col('paper'), width: pt(1.8) }; const labelStyles = [ { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'), textTransform: 'uppercase', letterSpacing: 0.14, halo }, { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic', color: { hex: '#0b3a5c', model: 'hex' }, halo }, { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic', color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } }, { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo }, { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'), textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'), padding: mm(1.6) }, ]; const ANCHORS = [ { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' }, { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' }, { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' }, { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 }, { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' }, { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' }, { id: 'mar', x: 0.965, y: 0.28, style: 'mar' }, { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 }, { id: 'alto', x: 0.17, y: 0.45, style: 'curso' }, { id: 'medio', x: 0.4, y: 0.77, style: 'curso' }, { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' }, ]; // #endregion const config = { locale: t({ es: 'es', en: 'en-gb', ca: 'ca' }), resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture', ca: 'Dibuix' }), shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '', defaultPlacement: { position: 'here' } }], colorPalette, page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [ { level: 1, fontSize: pt(28), lineHeight: pt(32), color: col('paper'), marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: false } }, { level: 2, fontSize: pt(15), lineHeight: pt(19), marginTop: pt(9), marginBottom: pt(3) }, ] }, // Headings that are not entries of the unit: no number, no row in the contents. headingStyles: [ { id: 'portada', numbered: false, toc: false, fontSize: pt(46), lineHeight: pt(48) }, { id: 'previo', numbered: false, toc: false }, { id: 'indice', numbered: false, toc: false, fontSize: pt(28), advancedDesign: indiceOpener }, ], toc, parts: { page: false }, // a part is a row of the contents, with no divider page unorderedLists: { bulletChar: '•', color: col('agua'), fontWeight: 700, itemSpacing: pt(3) }, orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 600, color: col('agua'), itemSpacing: pt(4) }, labelStyles, captionStyle: { fontFamily: TEXT, fontSize: pt(9), color: col('ink'), descriptionItalic: true, gap: pt(4) }, calloutStyles, header: { elements: [ head('materia', '{subtitle}', 'even', 'top-left', PAGE.outer), head('unidad', '{title}', 'odd', 'top-right', -PAGE.outer), ] }, footer: { elements: [folio('folio-par', 'even', 'bottom-left', PAGE.outer), folio('folio-impar', 'odd', 'bottom-right', -PAGE.outer)] }, pageTemplates, }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 188 lignes · content.en.md
title: "Our river" subtitle: "Social science · Year 3" --- ::::page{template="unidad" unidad="Unit 4 · Social science"} # Our river {style="portada"} Every town you know stands near water. This unit follows one river, the Aldara, from the snow where it starts to the sea where it ends. You will find out where a river is born, how it changes on its way down, and how the people of Fontalba, Molinar and Aldamar live beside it. :::frame{slot="aprenderas" label="You will learn"} - How a spring becomes a stream, and a stream becomes a river. - What the upper, middle and lower course of a river are. - How people use river water, and how to look after it. ::: :::frame{slot="colofon"} Set in Nunito and Fredoka (SIL OFL) · Original text (CC BY 4.0) · Illustrations generated with diffusion models ::: :::: # In this unit {style="indice"} :::toc{keep="part" levels="1-2"} :::figure{resource="rio"} ::alt[Painted map of the valley of the River Aldara, from the snowy mountains to the sea.] ::caption[The River Aldara from its spring to the sea. The map is on pages 6 and 7.] ::: ::::page{template="saberes"} # What do you know about rivers? {style="previo"} Before you start, think about the rivers you have seen. Write your ideas on the lines. At the end of the unit, come back to them and see what has changed. :::frame{slot="pregunta-1" label="Think 1"} Where do you think a river starts? ::: :::frame{slot="pregunta-2" label="Think 2"} What do people use a river for? ::: :::frame{slot="pregunta-3" label="Think 3"} Which animals and plants live in or beside a river? ::: :::: :::part{number="A" title="A river from the mountains to the sea"} ::: ::::page{template="tema-agua" spread numero="1"} # Where a river is born Rain and snow fall on the mountains. Some of the water runs down the slopes and some soaks into the ground. Underground it moves slowly, until it meets rock it cannot cross. Then it comes out into the open. A place where water comes out of the ground is called a **spring**. ## From spring to stream A spring gives only a trickle. The trickle runs downhill and meets other trickles, and together they make a **stream**. On a steep slope a stream runs fast. It rolls pebbles along and wears them smooth. When streams meet, they make a bigger one. The River Aldara begins like this, in the snow of the White Range, 1,850 metres above the sea. :::figure{resource="nacimiento" slot="foto-1"} ::alt[Clear water trickling out between mossy rocks.] ::caption[A spring. The water has been underground for months.] ::: :::figure{resource="arroyo" slot="foto-2"} ::alt[A stream tumbling over round stones.] ::caption[A stream, steep and fast, with pebbles worn smooth.] ::: :::figure{resource="cascada" slot="foto-3"} ::alt[A small waterfall falling into a green pool.] ::caption[A waterfall, where the stream drops over a step of rock.] ::: :::frame{slot="probar" label="Try it"} Hold a wet sponge over a tray and squeeze it gently. Where does the water come out? Watch how it drips and then runs. That is how a spring feeds a stream. ::: :::frame{slot="sabias" label="Did you know?"} At its spring, the Aldara is a trickle you could step over. After 94 kilometres it is as wide as a football pitch is long. ::: :::frame{slot="palabras" label="Words to learn"} **Spring:** a place where water comes out of the ground. **Stream:** a small river that runs fast down a slope. **Tributary:** a stream that flows into a bigger river. **Bank:** the land along the side of a river. ::: :::frame{slot="actividades" label="Activities"} 1. Look at the three pictures. Which one shows a spring? How can you tell? 2. Why does a stream run faster in the mountains than in the valley? 3. Which of the four words in the box can you see in the pictures? ::: :::: ::::page{template="tema-campo" spread numero="2"} # From the mountains to the sea The River Aldara is 94 kilometres long. It starts in the White Range, crosses a valley and a plain, and reaches the sea at Aldamar. Geographers split a river into three parts. Each part is called a **course**, and each one looks different. :::figure{resource="mapa" slot="mapa"} ::alt[A painted map of the valley: snowy mountains and a spring at the left, the village of Fontalba, a reservoir with a dam, the town of Molinar with its bridge and mill, farm fields, the river bending across a plain, and the port of Aldamar by the sea at the right.] ::label[White Range]{at="sierra"} ::label[Spring]{at="nacimiento"} ::label[Fontalba]{at="fontalba"} ::label[Penalta Reservoir]{at="embalse"} ::label[Molinar]{at="molinar"} ::label[Aldamar]{at="aldamar"} ::label[Sea]{at="mar"} ::label[River Aldara]{at="rio"} ::label[Upper course]{at="alto"} ::label[Middle course]{at="medio"} ::label[Lower course]{at="bajo"} ::: :::frame{slot="actividad" label="Look at the map"} Follow the river with your finger from the spring to the sea. Which town is nearest to the sea? ::: :::frame{slot="curso-1" label="Upper course"} The slope is steep. The water is cold, clear and fast, and it cuts into the rock. Fontalba stands here. ::: :::frame{slot="curso-2" label="Middle course"} The valley is wider and the river slows down. Farmers water their fields, and Molinar grew around a bridge and a mill. ::: :::frame{slot="curso-3" label="Lower course"} The land is almost flat. The river is wide and slow, and it bends in great loops. At Aldamar it flows into the sea. ::: :::: :::part{number="B" title="People and the river"} ::: ::::page{template="tema-sol" spread numero="3"} # People and the river People have always settled near rivers. They need water to drink, to wash and to grow food. A river also gives fish, a road for boats and the push that turns a wheel. ## Looking after the river A river stays clean only if we keep it clean. Rubbish, oil and dirty water make the river sick, and the fish and plants that live in it suffer. Everything that goes down a drain or a street ends up in a river or in the sea. :::frame{slot="cuidar" label="We look after our river"} - We put rubbish in the bin, never in the water. - We use water carefully and turn off the tap. - We keep to the paths and leave plants and animals alone. - We help to clean the bank when the school organises it. ::: :::frame{slot="turno" label="Your turn"} 1. Draw a poster with three rules to keep our river clean. 2. Ask at home: where does the water from your tap come from? ::: :::figure{resource="limpieza" slot="cuidar-foto"} ::alt[Children with gloves and paper bags picking up litter on a river bank.] ::: :::frame{slot="uso-1" label="1 · Water for the fields"} In the middle course, farmers open small canals so the river water runs between the rows. Without it, tomatoes and lettuces would not grow in summer. A single canal can carry water for many kilometres. ::: :::figure{resource="riego" slot="uso-1-foto"} ::alt[A farmer lifting a gate in an irrigation canal in a field of vegetables.] ::: :::frame{slot="uso-2" label="2 · Mills"} Long ago the falling water turned the wheel of the mill at Molinar. The wheel moved the stones that ground wheat into flour for the whole valley. Today the old mill is a small museum. ::: :::figure{resource="molino" slot="uso-2-foto"} ::alt[An old stone mill with a big wooden wheel beside a river.] ::: :::frame{slot="uso-3" label="3 · Sport and play"} Now the calm lower course is a place for canoes. On summer mornings children from Aldamar learn to paddle with a monitor, and everyone wears a life jacket. ::: :::figure{resource="piraguas" slot="uso-3-foto"} ::alt[Children in life jackets paddling two canoes on a calm river.] ::: ::::`; // content.<lang>.md, inlined by the Cookbook // #region art: nine paintings (JPEGs in assets/), the pictures of the unit // Painted with a diffusion model in a coloured-pencil and watercolour manner, cut to the shape // of the frames that show them and declared with their pixels. Nothing is written on them: the // names on the map are ::label lines of the Markdown, standing at the anchors below. const ART = { portada: ['portada-1400.jpg', 1400, 1021, 165], mapa: ['mapa-1900.jpg', 1900, 730, 122], ...Object.fromEntries(['nacimiento', 'arroyo', 'cascada', 'riego', 'molino', 'piraguas', 'limpieza'].map((id) => [id, [`${id}-720.jpg`, 720, 540, 330]])) }; const resources = await Promise.all(Object.entries(ART).map(async ([id, [fileId, w, h, ppi]]) => { await loadImage(fileId, asset(fileId)); return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: '', createdAt: 0, updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi }, ...(id === 'mapa' && { anchors: ANCHORS }) }; })); // The map again with no anchors, small, under the contents. resources.push({ ...resources.find((r) => r.id === 'mapa'), id: 'rio', anchors: [] }); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Nunito: ['400', '400i', '700'], Fredoka: ['500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS)); showPages(doc, { title: t({ es: 'Nuestro río', en: 'Our river', ca: 'El nostre riu' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, images : identique dans toutes les recettes · 315 lignes
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Ajouter une quatrième famille
Un thème sur la mer est une quatrième entrée de FAMILIES et une maquette faite avec topic('tema-mar', 'mar', { frames }). La boucle sur FAMILIES en écrit les styles, caja-mar, dato-mar et les autres.
- sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+ sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+ mar: { main: '#0f5a6b', tint: '#dcf0f2', ground: '#cbe6ea' },#Garder le texte à sa taille
Un bloc qui ne doit pas réduire son texte prend fit: { shrinkText: false }. Un texte plus long pousse alors plus fort sur les blocs voisins et l’échelle atteint les images plus tôt.
- frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
- fit: { minScale: 0.9 } }],
+ frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
+ fit: { shrinkText: false } }],Erreurs fréquentes
Erreur fréquente
Une table des matières ne peut pas être composée dans un bloc d’une page composée
Un :::toc placé dans un :::frame d’une page composée est composé comme les paragraphes du bloc : les lignes de partie de toc.parts.design ne sont pas dessinées et laissent une ligne vide, chaque entrée est suivie de l’espacement de paragraphe du document et le titre de la table elle-même y figure. Composez la table comme une page de texte courant : un style de titre (numbered: false, toc: false) dont l’advancedDesign dessine le titre, suivi de :::toc{keep="part"} dans le texte, où les lignes de partie, les points de conduite et la règle keep fonctionnent. Sa maquette s’arrête à la marge de la colonne : la couleur de fond de cette page devra attendre que le moteur compose la table comme page composée. Table des matières →
- Un nom peint dans une image ne pourrait pas changer avec la langue : la carte ne porte aucun mot et chaque nom est une étiquette.
- Un élément de décor placé sous un bloc ne peut pas être cliqué dans le Sandbox ; c’est pourquoi le numéro de chaque thème est à gauche du bloc du titre et non dedans.
- Deux figures qui posent la même ressource prennent les mots de la première : la petite carte du sommaire est le même fichier sous une autre ressource,
rio, sans ancres.
Crédits
- Recette
- Your Name
- Texte
- Our river, a children's textbook unit written for this recipe in Spanish, Catalan and English · Postext Cookbook · CC BY 4.0
- Images
- The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water. · Generated With Diffusion Models · original
- The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown. · Generated With Diffusion Models · original
- A spring between mossy rocks. · Generated With Diffusion Models · original
- A mountain stream over round stones. · Generated With Diffusion Models · original
- A small waterfall into a green pool. · Generated With Diffusion Models · original
- A farmer opening the gate of an irrigation canal. · Generated With Diffusion Models · original
- An old stone watermill with its wheel. · Generated With Diffusion Models · original
- Children paddling canoes on a calm river. · Generated With Diffusion Models · original
- Children and a teacher picking up litter on a river bank. · Generated With Diffusion Models · original
- Polices
- Nunito (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


