En bref
Cinq pages d’un cahier de sciences pour enfants sur l’eau. Chaque question a son encadré, avec des lignes dessous. Une question plus longue agrandit son encadré et pousse le reste vers le bas. Le projet continue page suivante.
Ce que vous allez composer
Cinq pages de L’eau en voyage, l’unité 3 d’un cahier de sciences inventé pour le CM1, au format 210 × 280 mm. Sur la couverture, un dessin en aplats du cycle de l’eau va jusqu’au fond perdu, au-dessus d’un titre de 54 pt et d’une ligne pour le nom. Chaque fiche sort du même gabarit de page : la ligne de l’unité, un bandeau bleu pour le titre, un court texte à côté d’un dessin, puis trois encadrés d’activité bleu pâle avec un onglet numéroté. Des lignes pour répondre suivent le premier et le troisième encadré. Chaque encadré est aussi haut que son texte : la fiche française, plus longue d’une ligne ici et là, descend ses lignes plus bas que la fiche anglaise, sans qu’aucune position du gabarit change. Le projet, un pluviomètre fait d’une bouteille, compte plus d’étapes qu’une page n’en contient : son encadré s’arrête au pied avec À suivre et reprend en tête d’une page ajoutée, suivi de six lignes pour les observations. Tous les mots, ceux des onglets et du titre des observations compris, sont dans le Markdown.
Cette recette répond à
- Comment faire grandir les encadrés d’une page avec leur texte et repousser vers le bas ceux qui les suivent ?
- Comment faire continuer à la page suivante une activité qui ne tient pas, avec la mention « À suivre » ?
- Comment construire une fiche d’exercices : lignes à remplir, cases de réponse, banques de mots, listes de contrôle ?
La réponse courte
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
id: 'ficha',
decoration: { elements: [kicker] },
frames: [
...opening,
// The group starts where its first box is placed: under the text, and runs down from there.
{ ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
answer('lineas-1', 3),
box('act-2', 'actividad', 22),
// The last box goes on overleaf, with the answer space after it, when its text outgrows
// the page; the other two push it down instead.
{ ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
answer('lineas-3', 2),
],
groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
Ingrédients
- Fonctionnalités
- Blocs qui s’agrandissent dans un groupeBlocs poursuivis à la page suivantePages composéesEspace vertical explicitePastilles dans le texteEncadrésOnglets numérotés d’encadréTexte, filets et boîtes dans les maquettes de pageImages dans les maquettes de pageStyles de titreListes à puces et listes de contrôleListes numérotéesMarges en miroirTitres courants et foliosPalette de couleurs sémantiquePages sur un canvasExport PDF
- Utilise aussi
- Équilibrage des colonnesDécor ancré aux blocsAttributs de titreDes figures exactement iciPolices intégrées au PDFTypes de ressource personnalisésFigures et tableaux comme ressourcesBlocs de texte chaînés
- Typographie
- Andika, Baloo 2, Lexend (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Verser chaque activité dans son encadré par son nom
Le gabarit est la réponse courte ci-dessus. Dans le Markdown, une fiche est un bloc ::::page : le premier titre va au bloc qui a takes: 'h1', le paragraphe au bloc de flux, et chaque :::frame{slot="…"} au bloc de ce nom, avec les mots de l’onglet dans label. Les blocs de réponse ne reçoivent rien, d’où leur optional: true : un bloc vide lèverait sinon l’avertissement slotEmpty. Une activité de dessin garde son espace blanc dans son propre encadré avec :::space{lines=3}, parce que la place qu’il faut pour dessiner fait partie de la consigne.
:::frame{slot="act-1" label="1 · Lis et réponds"}
Léa habite à Lyon. L’eau de son robinet est pompée dans la nappe qui longe le Rhône…
:::#2 · Laisser la pile déplacer les lignes avec les encadrés
Chaque encadré a un minHeight de 22 mm, la hauteur à laquelle il est dessiné, et au-delà une hauteur auto. Le groupe pose ses cinq blocs l’un sous l’autre, à 4,5 mm d’écart, à partir de l’endroit où act-1 est placé sous le texte : un encadré plus haut fait descendre tout ce qui le suit. Les lignes appartiennent au bloc vide : son rules trace une ligne au pied de chaque tranche de 8 mm de sa hauteur, sous le texte, si bien que les lignes suivent le bloc et qu’un espace de réponse plus long est un bloc plus haut. Donnez aux blocs de réponse un minHeight égal à leur hauteur : quand un groupe manque de place, l’échelle de recomposition reprend de l’espace aux blocs du groupe jusqu’à leur minimum, et le minimum d’un bloc vide est nul ; les lignes finiraient alors sur l’encadré suivant. Le dernier encadré de la fiche, act-3, continue sur la page suivante (overflow.continueIn: 'next-page') quand son texte dépasse la page, et les lignes de réponse placées dessous l’accompagnent.
#3 · Faire passer le projet à la page suivante
const project = {
id: 'proyecto',
overflowTemplate: 'proyecto-sigue',
decoration: { elements: [kicker] },
frames: [
...opening,
{ ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
// As tall as its text, never past the foot of the page, at full size: the rest ends
// with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
{ name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
],
groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
id: 'proyecto-sigue',
decoration: { elements: [kicker,
{ kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
fontSize: pt(15), fontWeight: 700, color: col('sea'),
placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
frames: [
{ name: 'pasos', style: 'proyecto', width: mm(W),
placement: at('container', 'top-left', 0, 9) },
answer('notas', 6),
],
groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
L’encadré des étapes n’a pas de hauteur fixe : il est aussi haut que son texte et s’arrête au pied de la zone. Un bloc qui peut continuer sur une autre page continue avant que son texte soit composé plus petit ; l’encadré est donc coupé entre deux lignes, en corps plein, se termine par la mention de la langue (Continúa, Continued, À suivre, selon locale) et reprend dans le bloc du même nom d’une page de proyecto-sigue (quand le texte se poursuit). La page ajoutée trace les lignes des observations sous la fin de l’encadré, avec le titre que le bloc de page donne dans notas="Mes observations" : les mots restent dans le Markdown et changent avec chaque édition.
#4 · Dessiner la couverture jusqu’au fond perdu
const cover = {
id: 'portada', header: false, footer: false,
decoration: { elements: [
{ kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
{ kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
size: { width: mm(W) } } },
] },
frames: [
{ name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
{ name: 'subtitulo', flow: true, width: mm(W) },
{ name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
{ name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
],
groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
frames: ['titulo', 'subtitulo', 'nombre'] }],
};
La couverture est elle aussi une page composée : son dessin est un élément du décor ancré au fond perdu, et le titre, la ligne de la collection et le champ du nom forment une pile qui commence 162 mm sous le haut de la zone. Le filet du nom est ancré au pied de son bloc : une étiquette qui tiendrait sur deux lignes dans une autre langue garde la ligne sous les mots. header: false et footer: false retirent le folio de la couverture.
La recette complète
// ═══ Postext Cookbook · Nº 159 · Activity book with boxes that grow ══════════════ // https://postext.dev/en/cookbook/activity-book-growing-boxes // Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code // Fonts: Andika, Baloo 2, Lexend (SIL OFL 1.1) · Needs postext ≥ 1.38.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'fr'; // @lang: the language of the sample document ('en' | 'es' | 'fr') const RECIPE = 'activity-book-growing-boxes'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one ink, the water blue, the sun, and the pale fields of the boxes const palette = { ink: '#1f2d38', // text sea: '#1d659c', // titles, tabs, list numbers sky: '#e2f0f9', // activity boxes, the cover's sky sun: '#f0a531', // the project's box, the sun leaf: '#4c8f55', // the cover's hills sand: '#fcf0d8', // the materials box rule: '#93bcd8', // answer lines, blanks paper: '#ffffff', }; 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: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Lexend']; // Andika: for early readers const W = 174; // mm: the width of the type area, and of every box const LINE = 8; // mm between two answer lines: room for a child's handwriting const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // The head every sheet shares: the unit over the title band, the text with its drawing. const kicker = { kind: 'text', id: 'tema', content: '{attr.tema}', fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 600, color: col('sea'), textTransform: 'uppercase', letterSpacing: pt(1.4), placement: at('container', 'top-left') }; const opening = [ { name: 'titulo', takes: 'h1', style: 'banda', width: mm(W), placement: at('container', 'top-left', 0, 6) }, { name: 'texto', flow: true, width: mm(W - 52), minHeight: mm(36), placement: at('#titulo', 'below', 0, 4) }, { name: 'dibujo', width: mm(44), placement: at('#titulo', 'below', W - 44, 4) }, ]; // #region answer: boxes in a stack grow with their text; empty ruled frames hold the answers // An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty). // Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part // of the frame and move with it when a box above grows. minHeight too: a box that runs out of // room may not take the lines away from the child. const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines), minHeight: mm(LINE * lines), rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } }); // A box is as tall as its text, never less than drawn: a longer text makes it taller and // pushes everything after it in the group down the page. const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) }); const sheet = { id: 'ficha', decoration: { elements: [kicker] }, frames: [ ...opening, // The group starts where its first box is placed: under the text, and runs down from there. { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) }, answer('lineas-1', 3), box('act-2', 'actividad', 22), // The last box goes on overleaf, with the answer space after it, when its text outgrows // the page; the other two push it down instead. { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } }, answer('lineas-3', 2), ], groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5), frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }], }; // #endregion // #region continue: the project's steps go on on a page of their own, after their mark const project = { id: 'proyecto', overflowTemplate: 'proyecto-sigue', decoration: { elements: [kicker] }, frames: [ ...opening, { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) }, // As tall as its text, never past the foot of the page, at full size: the rest ends // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page. { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } }, ], groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }], }; // The added page: the rest of the steps at the head, then lines for the child's notes, // under a heading the page fence names (notas="…"). const projectGoesOn = { id: 'proyecto-sigue', decoration: { elements: [kicker, { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 700, color: col('sea'), placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] }, frames: [ { name: 'pasos', style: 'proyecto', width: mm(W), placement: at('container', 'top-left', 0, 9) }, answer('notas', 6), ], groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }], }; // #endregion // #region cover: a sky drawn to the bleed, the title, and a line for the child's name const cover = { id: 'portada', header: false, footer: false, decoration: { elements: [ { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } }, { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1), color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1), size: { width: mm(W) } } }, ] }, frames: [ { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) }, { name: 'subtitulo', flow: true, width: mm(W) }, { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) }, { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') }, ], groups: [{ name: 'portada', kind: 'stack', gap: mm(4), frames: ['titulo', 'subtitulo', 'nombre'] }], }; // #endregion // A box with a tab: the tab's words come from the Markdown (label="…"). const tabbed = (id, fill, tab, tabInk, extra = {}) => ({ id, backgroundEnabled: true, background: col(fill), borderRadius: mm(3), border: { enabled: false }, padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) }, label: { position: 'top-left', background: col(tab), color: col(tabInk), fontFamily: LABEL, fontSize: pt(9), fontWeight: 600, height: mm(6), paddingX: mm(3), offset: mm(6), inset: mm(4), rule: { enabled: false, color: col(tab) } }, body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0) }, ...extra, }); const plain = (id, size, lead, color, extra = {}) => ({ id, backgroundEnabled: false, border: { enabled: false }, padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }, body: { fontFamily: LABEL, fontSize: pt(size), lineHeight: pt(lead), color: col(color), boldColor: col(color), italicColor: col(color), firstLineIndent: pt(0) }, ...extra }); const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity, fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 800, color: col('paper'), align: 'center', box: { backgroundColor: col('sea'), borderRadius: mm(4) }, placement: { ...at('page', edge, x, -9), size: { width: mm(8), height: mm(8) } } }); const bookTitle = (id, parity, edge, x) => ({ kind: 'text', id, content: '{title} · {subtitle}', parity, fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 500, color: col('sea'), textTransform: 'uppercase', letterSpacing: pt(1.1), placement: at('page', edge, x, -11) }); const config = { locale: t({ es: 'es', en: 'en-gb', fr: 'fr' }), // hyphenation and the continuation mark resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Drawing', fr: 'Dessin' }), shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '', defaultPlacement: { position: 'here' } }], colorPalette, page: { sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(20), bottom: mm(22), left: mm(20), right: mm(16), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), 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, color: col('sea'), fontWeight: 700, balancing: { enabled: false }, levels: [ // Every sheet is a composed page, which starts a page of its own. { level: 1, fontSize: pt(25), lineHeight: pt(30), fontWeight: 800, color: col('paper'), breakBefore: { enabled: false }, marginTop: pt(0), marginBottom: pt(0) }, { level: 3, fontSize: pt(13.5), lineHeight: pt(17), marginTop: pt(6), marginBottom: pt(2) }, ], }, headingStyles: [{ id: 'portada', fontSize: pt(54), lineHeight: pt(54), fontWeight: 800, color: col('sea'), marginTop: pt(0), marginBottom: pt(0) }], unorderedLists: { bulletChar: '•', color: col('sea'), fontWeight: 700, itemSpacing: pt(3) }, orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 800, color: col('sea'), itemSpacing: pt(4) }, chipStyles: [ // An empty chip, :chip[ ]{style="hueco"}, is a blank to write in: padding and an outline. { id: 'hueco', fontFamily: LABEL, background: col('paper'), borderColor: col('rule'), borderWidth: pt(0.9), borderRadius: mm(1.4), paddingX: mm(13), paddingY: em(0.1) }, { id: 'casilla', fontFamily: LABEL, background: col('paper'), borderColor: col('sea'), borderWidth: pt(0.9), borderRadius: mm(1), paddingX: em(0.55), paddingY: em(0.05), gap: em(0.5) }, ], calloutStyles: [ tabbed('actividad', 'sky', 'sea', 'paper'), tabbed('material', 'sand', 'sun', 'ink'), tabbed('proyecto', 'paper', 'sun', 'ink', { border: { enabled: true, color: col('sun'), width: pt(1.5) } }), { ...plain('banda', 25, 30, 'paper'), backgroundEnabled: true, background: col('sea'), borderRadius: mm(3), padding: { top: mm(2.5), right: mm(5), bottom: mm(3), left: mm(5) } }, plain('campo', 10, 14, 'sea', { padding: { top: mm(5), right: mm(0), bottom: mm(0), left: mm(0) } }), plain('colofon', 7.5, 10, 'ink'), ], header: { elements: [] }, footer: { elements: [ folio('folio-par', 'even', 'bottom-left', 20), bookTitle('titulo-par', 'even', 'bottom-left', 32), folio('folio-impar', 'odd', 'bottom-right', -20), bookTitle('titulo-impar', 'odd', 'bottom-right', -32), ] }, pageTemplates: [cover, sheet, project, projectGoesOn], }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 100 lignes · content.fr.md
title: "L’eau en voyage" subtitle: "Cahier d’activités" --- ::::page{template="portada"} # L’eau en voyage {style="portada"} Cahier d’activités de sciences · CM1 · Unité 3 :::frame{slot="nombre"} Prénom et nom ::: :::frame{slot="colofon"} Composé en Andika, Baloo 2 et Lexend (SIL OFL) · Texte et dessins : originaux, CC BY 4.0 ::: :::: ::::page{template="ficha" tema="Unité 3 · Fiche 1"} # Où se trouve l’eau ? Près des trois quarts de la Terre sont couverts d’eau. Presque toute cette eau est **salée** et remplit les mers et les océans. L’**eau douce**, celle que nous buvons, est très rare : elle coule dans les rivières, dort dans les lacs, se cache sous le sol ou reste gelée aux pôles. ::resource{id="planeta" slot="dibujo"} :::frame{slot="act-1" label="1 · Lis et réponds"} Léa habite à Lyon. L’eau de son robinet est pompée dans la nappe qui longe le Rhône, passe par une usine de traitement, puis voyage dans des tuyaux jusqu’à son immeuble. D’où vient l’eau qui coule de ton robinet ? Pourquoi faut-il la nettoyer avant de la boire, à ton avis ? ::: :::frame{slot="act-2" label="2 · Complète"} - L’eau des mers et des océans est :chip[ ]{style="hueco"} : on ne peut pas la boire. - Dans les rivières, les lacs et sous le sol, l’eau est :chip[ ]{style="hueco"}. - Aux pôles, presque toute l’eau est sous forme de :chip[ ]{style="hueco"}. ::: :::frame{slot="act-3" label="3 · Dessine et écris"} Dessine un endroit près de chez toi où il y a de l’eau : une fontaine, une rivière, un lac ou la plage. Écris ensuite sur les lignes comment il s’appelle et à quoi il sert. :::space{lines=3} ::: :::: ::::page{template="ficha" tema="Unité 3 · Fiche 2"} # Solide, liquide et gazeuse L’eau change d’état avec la chaleur et le froid. Au-dessous de zéro degré, elle devient de la **glace**, un solide. Quand la glace se réchauffe, elle fond et redevient **liquide**. Quand l’eau bout, elle se transforme en **vapeur**, un gaz invisible. ::resource{id="estados" slot="dibujo"} :::frame{slot="act-1" label="1 · Observe"} Pose un glaçon dans une assiette et laisse-le sur la table. Regarde-le toutes les dix minutes pendant une demi-heure. Que devient le glaçon ? Que reste-t-il dans l’assiette à la fin ? Écris pourquoi, selon toi, cela se produit. ::: :::frame{slot="act-2" label="2 · Coche les phrases vraies"} - :chip[ ]{style="casilla"} La glace est de l’eau à l’état solide. - :chip[ ]{style="casilla"} On peut attraper la vapeur d’eau avec la main. - :chip[ ]{style="casilla"} Une flaque sèche parce que le soleil fait s’évaporer l’eau. - :chip[ ]{style="casilla"} L’eau du congélateur se transforme en vapeur. ::: :::frame{slot="act-3" label="3 · Explique"} Quand on fait cuire des pâtes, de la vapeur sort de la casserole. Si tu approches un couvercle froid, il se couvre de gouttelettes. D’où viennent ces gouttes ? ::: :::: ::::page{template="proyecto" tema="Unité 3 · Projet" notas="Mes observations"} # Fabrique un pluviomètre Un pluviomètre mesure la quantité de pluie qui tombe à un endroit. Les stations météo utilisent des appareils très précis, mais tu peux en fabriquer un avec une bouteille et noter la pluie pendant toute une semaine. ::resource{id="pluviometro" slot="dibujo"} :::frame{slot="material" label="Il te faut"} - Une bouteille en plastique de deux litres, vide et propre. - Des ciseaux, une règle de 20 centimètres et du ruban adhésif. - Un feutre indélébile et une poignée de petits cailloux. ::: :::frame{slot="pasos" label="Étape par étape"} 1. Demande à un adulte de couper la bouteille à l’endroit où elle commence à se rétrécir. Garde le haut : ce sera l’entonnoir. 2. Mets les cailloux au fond. Ainsi, la bouteille ne se renversera pas avec le vent. 3. Verse de l’eau jusqu’à recouvrir les cailloux et trace un trait au feutre à ce niveau. Ce trait est le zéro de ton pluviomètre. 4. Colle la règle à l’extérieur avec du ruban adhésif, de sorte que le zéro de la règle tombe juste sur le trait que tu as tracé. 5. Retourne le haut de la bouteille et emboîte-le, goulot vers le bas. L’entonnoir recueille la pluie et ralentit l’évaporation. 6. Cherche un endroit dégagé de la cour ou du jardin, loin des arbres et des toits, qui détournent la pluie. 7. Chaque jour à la même heure, regarde de combien de millimètres l’eau est montée au-dessus du zéro et note-le dans ton relevé. Vide ensuite l’eau jusqu’au trait du zéro. ### Mon relevé de la semaine - Lundi : :chip[ ]{style="hueco"} millimètres - Mardi : :chip[ ]{style="hueco"} millimètres - Mercredi : :chip[ ]{style="hueco"} millimètres - Jeudi : :chip[ ]{style="hueco"} millimètres - Vendredi : :chip[ ]{style="hueco"} millimètres ### Réfléchis Quel jour a-t-il le plus plu ? Additionne les cinq jours : combien de millimètres sont tombés en tout ? Compare ton résultat avec celui d’un camarade qui a installé son pluviomètre ailleurs. ::: ::::`; // content.<lang>.md, inlined by the Cookbook // #region art: the water cycle on the cover, the planet, the three states, the rain gauge const P = palette; const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" ` + `width="${w * 10}" height="${h * 10}">${body}</svg>`; const cloud = (x, y, s, fill = P.paper) => `<g transform="translate(${x} ${y}) scale(${s})" ` + `fill="${fill}">` + '<circle cx="0" cy="0" r="9"/><circle cx="11" cy="-5" r="11"/><circle cx="24" cy="0" r="9"/>' + '<rect x="-9" y="0" width="42" height="9" rx="4.5"/></g>'; const arrow = (x1, y1, x2, y2, color) => { const a = Math.atan2(y2 - y1, x2 - x1); const tip = (d, o) => `${(x2 - d * Math.cos(a + o)).toFixed(1)},` + `${(y2 - d * Math.sin(a + o)).toFixed(1)}`; return `<path d="M${x1} ${y1} L${x2} ${y2}" stroke="${color}" stroke-width="1.6" ` + `stroke-linecap="round" stroke-dasharray="3 3" fill="none"/>` + `<path d="M${x2} ${y2} L${tip(5, 0.45)} L${tip(5, -0.45)} Z" fill="${color}"/>`; }; const rain = (x, y) => [0, 6, 12, 18].map((dx, i) => `<path d="M${x + dx} ${y + (i % 2) * 3} ` + `l-2 7" stroke="${P.sea}" stroke-width="1.6" stroke-linecap="round"/>`).join(''); const ART = { portada: [210, 178, () => `<rect width="210" height="178" fill="${P.sky}"/>` + `<circle cx="168" cy="38" r="17" fill="${P.sun}"/>` + Array.from({ length: 12 }, (_, i) => { const a = (i * Math.PI) / 6; const p = (r) => `${(168 + r * Math.cos(a)).toFixed(1)} ${(38 + r * Math.sin(a)).toFixed(1)}`; return `<path d="M${p(22)} L${p(29)}" stroke="${P.sun}" stroke-width="2.4" ` + 'stroke-linecap="round"/>'; }).join('') + cloud(28, 40, 1.2) + cloud(86, 26, 0.9) + rain(30, 58) + `<path d="M0 132 L34 84 L52 104 L74 70 L112 132 Z" fill="${P.leaf}"/>` + `<path d="M74 70 L66 82 L72 80 L76 85 L81 80 L74 70 Z" fill="${P.paper}"/>` + `<path d="M58 132 C70 120 64 112 76 104" stroke="${P.sea}" stroke-width="3" fill="none"/>` + `<path d="M0 132 L210 132 L210 178 L0 178 Z" fill="${P.sea}"/>` + [140, 152, 164].map((y) => `<path d="M${y - 120} ${y} q8 -4 16 0 t16 0 t16 0" ` + `stroke="${P.sky}" stroke-width="1.4" fill="none"/>`).join('') + arrow(150, 126, 128, 66, P.paper) + arrow(176, 124, 160, 70, P.paper) + arrow(120, 30, 70, 34, P.paper)], planeta: [56, 46, () => `<circle cx="26" cy="23" r="20" fill="${P.sea}"/>` + `<path d="M14 10 q8 -2 10 6 q-2 7 -9 6 q-5 -4 -1 -12 Z" fill="${P.leaf}"/>` + `<path d="M30 26 q9 -3 11 4 q-1 8 -8 9 q-6 -5 -3 -13 Z" fill="${P.leaf}"/>` + `<path d="M20 5 q6 -3 12 0 q-6 3 -12 0 Z" fill="${P.sky}"/>` + `<path d="M48 18 q-5 8 0 12 q5 -4 0 -12 Z" fill="${P.sea}" stroke="${P.paper}" ` + 'stroke-width="1"/>'], estados: [56, 46, () => `<rect x="3" y="24" width="14" height="14" rx="2.5" fill="${P.sky}" ` + `stroke="${P.sea}" stroke-width="1.4"/>` + `<path d="M22 18 L24 42 L36 42 L38 18 Z" fill="${P.sky}" stroke="${P.sea}" ` + 'stroke-width="1.4"/>' + `<path d="M23 27 L37 27" stroke="${P.sea}" stroke-width="1"/>` + `<path d="M23.3 27 L24 42 L36 42 L36.7 27 Z" fill="${P.sea}"/>` + [44, 49, 54].map((x) => `<path d="M${x - 4} 40 q-3 -6 0 -12 t0 -12" stroke="${P.rule}" ` + `stroke-width="1.6" fill="none" stroke-linecap="round"/>`).join('')], pluviometro: [56, 46, () => `<path d="M18 10 L40 10 L40 42 Q40 44 38 44 L20 44 Q18 44 18 42 Z" ` + `fill="${P.sky}" stroke="${P.sea}" stroke-width="1.4"/>` + `<path d="M16 10 L42 10 L33 19 L25 19 Z" fill="${P.paper}" stroke="${P.sea}" ` + 'stroke-width="1.4"/>' + '<path d="M18.7 30 L39.3 30 L39.3 42 Q39.3 43.3 38 43.3 L20 43.3 Q18.7 43.3 18.7 42 Z" ' + `fill="${P.sea}"/>` + [22, 26, 30, 34, 38].map((y) => `<path d="M40 ${y} l5 0" stroke="${P.ink}" ` + 'stroke-width="1"/>').join('') + cloud(4, 4, 0.35, P.rule) + rain(6, 8).replaceAll('l-2 7', 'l-1 4')], }; const ALT = { portada: t({ es: 'El ciclo del agua: el sol calienta el mar, el vapor sube a las nubes y la lluvia cae ' + 'en la montaña y vuelve por el río', en: 'The water cycle: the sun warms the sea, vapour rises to the clouds, and rain falls on ' + 'the hills and runs back down the river', fr: 'Le cycle de l’eau : le soleil chauffe la mer, la vapeur monte aux nuages, la pluie ' + 'tombe sur la montagne et revient par la rivière' }), planeta: t({ es: 'La Tierra, casi toda azul, y una gota de agua', en: 'The Earth, mostly blue, and a drop of water', fr: 'La Terre, presque toute bleue, et une goutte d’eau' }), estados: t({ es: 'Un cubito de hielo, un vaso de agua y vapor', en: 'An ice cube, a glass of water and steam', fr: 'Un glaçon, un verre d’eau et de la vapeur' }), pluviometro: t({ es: 'Una botella cortada con una regla: el pluviómetro', en: 'A cut bottle with a ruler: the rain gauge', fr: 'Une bouteille coupée avec une règle : le pluviomètre' }), }; const resources = Object.entries(ART).map(([id, [w, h]]) => ({ id, typeId: 'dibujo', kind: 'svg', caption: '', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, })); for (const [id, [w, h, draw]] of Object.entries(ART)) { await loadSvg(`${id}.svg`, svgDoc(w, h, draw())); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build Andika: ['400', '400i', '700'], 'Baloo 2': ['700', '800'], Lexend: ['500', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS)); showPages(doc, { title: t({ es: 'El agua, de viaje', en: 'Water on the move', fr: 'L’eau en voyage' }) }); 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
#Donner plus de lignes à une activité
Le nombre de lignes, c’est la hauteur du bloc de réponse : son rules trace autant de lignes qu’il en tient. Changez-la dans le gabarit, et toutes les fiches qui l’utilisent auront ces lignes.
- answer('lineas-1', 3),
+ answer('lineas-1', 5),#Laisser les étapes rapetisser avant de continuer
Un bloc qui peut continuer sur une autre page continue avant que son texte soit composé plus petit. Avec fit.preferContinue: false, l’échelle compose d’abord les étapes plus petit, jusqu’à fit.minScale (90 %), et ne les envoie sur une autre page que si cela ne suffit pas. Dans un livre pour enfants, le corps du texte compte plus qu’une page économisée ; la recette garde donc la valeur par défaut.
- overflow: { continueIn: 'next-page' } },
+ fit: { preferContinue: false }, overflow: { continueIn: 'next-page' } },Erreurs fréquentes
- Un bloc qui ne reçoit rien lève
slotEmptys’il n’a pasoptional: true. Ici, tous les blocs de réponse l’ont. - Dans une pile, c’est le groupe qui place les encadrés, pas leur propre
placement, sauf pour le premier : placez le premier encadré du groupe, sinon la pile commence en haut de la zone, par-dessus le titre.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Lexend (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)


