En pocas palabras
Cinco páginas de un cuaderno de ciencias sobre el agua. Cada pregunta va en un recuadro con líneas debajo. Si la pregunta es más larga, su recuadro crece y baja lo demás. Un proyecto largo sigue en la página siguiente.
Lo que vas a componer
Cinco páginas de El agua, de viaje, la unidad 3 de un cuaderno de Ciencias de la Naturaleza inventado para 4.º de Primaria, en 210 × 280 mm. En la cubierta, un dibujo plano del ciclo del agua llega hasta la sangre, sobre un título de 54 pt y una línea para el nombre. Todas las fichas salen de la misma plantilla de página: la línea de la unidad, una banda azul con el título, un texto breve junto a un dibujo y tres recuadros de actividad en azul pálido con una pestaña numerada. Debajo del primer y del tercer recuadro van las líneas para responder. Cada recuadro mide lo que su texto, así que la ficha francesa, una línea más larga aquí y allá, baja sus líneas más que la inglesa sin que cambie ninguna posición de la plantilla. El proyecto, un pluviómetro hecho con una botella, tiene más pasos de los que caben en una página: su recuadro se corta al pie con Continúa y sigue arriba de una página añadida, con seis líneas para las observaciones. Todas las palabras, también las de las pestañas y el título de las observaciones, están en el Markdown.
Esta receta responde a
- ¿Cómo hago que los recuadros de una página crezcan con su texto y empujen hacia abajo los que tienen debajo?
- ¿Cómo hago que una actividad que no cabe siga en la página siguiente, con una marca de «Continúa»?
- ¿Cómo hago una ficha de ejercicios: líneas para rellenar, cajas de respuesta, bancos de palabras, listas de comprobación?
La respuesta corta
// 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'] }],
};
Ingredientes
- Funciones
- Marcos que crecen en un grupoMarcos que siguen en la página siguientePáginas compuestasEspacio vertical explícitoChips en líneaRecuadrosPestañas numeradas en los recuadrosTextos, filetes y cajas en los diseños de páginaImágenes en los diseños de páginaEstilos de títuloListas de viñetas y de comprobaciónListas numeradasMárgenes simétricosCabeceras y foliosPaleta de color semánticaPáginas en un canvasExportación a PDF
- También usa
- Equilibrado de columnasDecoración anclada a los marcosAtributos de títuloFiguras justo aquíFuentes incrustadas en el PDFTipos de recurso propiosFiguras y tablas como recursosMarcos de texto enlazados
- Tipografía
- Andika, Baloo 2, Lexend (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Vierte cada actividad en su recuadro por su nombre
La plantilla es la respuesta corta de arriba. En el Markdown, cada ficha es una valla ::::page: el primer título va al marco que tiene takes: 'h1', el párrafo al marco de flujo y cada :::frame{slot="…"} al marco de ese nombre, con las palabras de la pestaña en label. Los marcos de respuesta no reciben nada, y por eso llevan optional: true: un marco vacío daría el aviso slotEmpty. Una actividad de dibujo guarda su hueco en blanco dentro del propio recuadro con :::space{lines=3}, porque el sitio que necesita el dibujo forma parte de la pregunta.
:::frame{slot="act-1" label="1 · Lee y contesta"}
Marta vive en Zaragoza. El agua de su grifo sale del río Ebro…
:::#2 · Deja que la pila mueva las líneas con los recuadros
Cada recuadro tiene un minHeight de 22 mm, la altura a la que está dibujado, y por encima de eso una altura auto. El grupo coloca sus cinco marcos uno debajo de otro, a 4,5 mm, a partir del sitio donde act-1 se coloca bajo el texto, de modo que un recuadro más alto baja todo lo que lo sigue. Las líneas son del propio marco vacío: su rules dibuja una línea al pie de cada 8 mm de su altura, bajo el texto, de modo que las líneas se mueven con el marco y un espacio de respuesta más largo es un marco más alto. Da a los marcos de respuesta un minHeight igual a su altura: cuando un grupo se queda sin sitio, la escalera de recomposición recupera espacio de los marcos del grupo hasta su mínimo, y el mínimo de un marco vacío es nada, con lo que las líneas acabarían encima del recuadro siguiente. El último recuadro de la ficha, act-3, sigue en la página siguiente (overflow.continueIn: 'next-page') cuando su texto no cabe, y las líneas de respuesta que van debajo se van con él.
#3 · Lleva el proyecto a la página siguiente
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'] }],
};
El recuadro de los pasos no tiene altura fija: mide lo que su texto y se detiene al pie del área. Un marco que puede seguir en otra página sigue antes de componer su texto más pequeño, así que el recuadro se corta entre dos líneas a cuerpo completo, termina con la marca del idioma (Continúa, Continued, À suivre, según locale) y sigue en el marco del mismo nombre de una página de proyecto-sigue (cuando el texto sigue). La página añadida dibuja las líneas de las observaciones bajo el resto del recuadro, con el título que la valla da en notas="Mis observaciones", de modo que las palabras siguen en el Markdown y cambian con cada edición.
#4 · Dibuja la cubierta hasta la sangre
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 cubierta también es una página compuesta: su dibujo es decoración anclada a la sangre, y el título, la línea de la colección y el campo del nombre forman una pila que empieza 162 mm por debajo de la caja. El filete del nombre va anclado al pie de su marco, de modo que una etiqueta que ocupe dos líneas en otro idioma mantiene la línea debajo de las palabras. header: false y footer: false dejan la cubierta sin folio.
La receta completa
// ═══ 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 = 'es'; // @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`---Muestra en Markdown · 100 líneas · content.es.md
title: "El agua, de viaje" subtitle: "Cuaderno de actividades" --- ::::page{template="portada"} # El agua, de viaje {style="portada"} Cuaderno de actividades de Ciencias de la Naturaleza · 4.º de Primaria · Unidad 3 :::frame{slot="nombre"} Nombre y apellidos ::: :::frame{slot="colofon"} Compuesto en Andika, Baloo 2 y Lexend (SIL OFL) · Texto y dibujos: originales, CC BY 4.0 ::: :::: ::::page{template="ficha" tema="Unidad 3 · Ficha 1"} # ¿Dónde está el agua? Casi tres cuartas partes de la Tierra están cubiertas de agua. La mayoría es **agua salada** y llena los mares y los océanos. El **agua dulce**, la que bebemos, es muy poca: corre por los ríos, descansa en los lagos, se esconde bajo el suelo o está helada en los polos. ::resource{id="planeta" slot="dibujo"} :::frame{slot="act-1" label="1 · Lee y contesta"} Marta vive en Zaragoza. El agua de su grifo sale del río Ebro, pasa por una planta potabilizadora y viaja por tuberías hasta su casa. ¿De dónde llega el agua que sale por tu grifo? ¿Por qué crees que hay que limpiarla antes de beberla? ::: :::frame{slot="act-2" label="2 · Completa"} - El agua de los mares y los océanos es :chip[ ]{style="hueco"} y no se puede beber. - En los ríos, los lagos y bajo el suelo hay agua :chip[ ]{style="hueco"}. - En los polos, casi toda el agua está en forma de :chip[ ]{style="hueco"}. ::: :::frame{slot="act-3" label="3 · Dibuja y escribe"} Dibuja un lugar de tu localidad donde haya agua: una fuente, un río, un embalse o la playa. Después escribe en las líneas cómo se llama y para qué lo usan las personas. :::space{lines=3} ::: :::: ::::page{template="ficha" tema="Unidad 3 · Ficha 2"} # Sólida, líquida y gaseosa El agua cambia de estado con el calor y con el frío. Por debajo de cero grados se convierte en **hielo**, que es sólido. Cuando se calienta, el hielo se funde y vuelve a ser **líquido**. Si hierve, se transforma en **vapor**, un gas que no vemos. ::resource{id="estados" slot="dibujo"} :::frame{slot="act-1" label="1 · Observa"} Pon un cubito de hielo en un plato y déjalo encima de la mesa. Míralo cada diez minutos durante media hora. ¿Qué le pasa al cubito? ¿Qué hay en el plato al final? Escribe por qué crees que ocurre. ::: :::frame{slot="act-2" label="2 · Marca las frases verdaderas"} - :chip[ ]{style="casilla"} El hielo es agua en estado sólido. - :chip[ ]{style="casilla"} El vapor de agua se puede coger con la mano. - :chip[ ]{style="casilla"} Un charco se seca porque el agua se evapora con el sol. - :chip[ ]{style="casilla"} El agua del congelador se convierte en vapor. ::: :::frame{slot="act-3" label="3 · Explica"} Cuando se cuece pasta, sale vapor de la cazuela. Si acercas una tapa fría, se llena de gotitas. ¿De dónde salen esas gotas? ::: :::: ::::page{template="proyecto" tema="Unidad 3 · Proyecto" notas="Mis observaciones"} # Construye un pluviómetro Un pluviómetro mide cuánta lluvia cae en un lugar. En las estaciones meteorológicas usan aparatos muy precisos, pero tú puedes fabricar uno con una botella y apuntar la lluvia de una semana entera. ::resource{id="pluviometro" slot="dibujo"} :::frame{slot="material" label="Necesitas"} - Una botella de plástico de dos litros, vacía y limpia. - Unas tijeras, una regla de 20 centímetros y cinta adhesiva. - Un rotulador permanente y un puñado de piedras pequeñas. ::: :::frame{slot="pasos" label="Paso a paso"} 1. Pide a una persona adulta que corte la botella por donde empieza a estrecharse. Guarda la parte de arriba: será el embudo. 2. Mete las piedras en el fondo. Así la botella no se caerá con el viento. 3. Echa agua hasta tapar las piedras y marca esa altura con el rotulador. Esa línea es el cero de tu pluviómetro. 4. Pega la regla por fuera con cinta adhesiva, de modo que el cero de la regla quede justo en la línea que has marcado. 5. Da la vuelta a la parte de arriba y encájala en la botella, con la boca hacia abajo. El embudo recoge la lluvia y no deja que el agua se evapore tan deprisa. 6. Busca un sitio despejado del patio o de la terraza, lejos de los árboles y de los tejados, que desvían la lluvia. 7. Cada día, a la misma hora, mira cuántos milímetros ha subido el agua desde el cero y apúntalo en tu registro. Después vacía el agua hasta la línea del cero. ### Mi registro de la semana - Lunes: :chip[ ]{style="hueco"} milímetros - Martes: :chip[ ]{style="hueco"} milímetros - Miércoles: :chip[ ]{style="hueco"} milímetros - Jueves: :chip[ ]{style="hueco"} milímetros - Viernes: :chip[ ]{style="hueco"} milímetros ### Piensa ¿Qué día llovió más? Suma los cinco días: ¿cuántos milímetros ha llovido en total? Compara tu resultado con el de un compañero que haya puesto su pluviómetro en otro sitio. ::: ::::`; // 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: igual en todas las recetas · 315 líneas
// ─── 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 ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)
Variantes
#Da más líneas a una actividad
El número de líneas es la altura del marco de respuesta: su rules dibuja tantas líneas como caben. Cámbialo en la plantilla y todas las fichas que la usan tendrán esas líneas.
- answer('lineas-1', 3),
+ answer('lineas-1', 5),#Deja que los pasos se reduzcan antes de seguir
Un marco que puede seguir en otra página sigue antes de componer su texto más pequeño. Con fit.preferContinue: false la escalera compone primero los pasos más pequeños, hasta fit.minScale (90 %), y solo los lleva a otra página si eso no basta. En un libro para niños importa más el cuerpo de la letra que ahorrar una página, así que la receta deja el valor por defecto.
- overflow: { continueIn: 'next-page' } },
+ fit: { preferContinue: false }, overflow: { continueIn: 'next-page' } },Errores frecuentes
- Un marco que no recibe nada da el aviso
slotEmptysi no llevaoptional: true. Aquí lo llevan todos los marcos de respuesta. - En una pila, el grupo coloca los recuadros y no su propio
placement, salvo el primero: coloca el primer recuadro del grupo, o la pila empezará arriba del área, encima del título.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Lexend (SIL OFL 1.1)
- Código
- MIT, como Postext
Editar este texto ↗ (se abre en una nueva pestaña)Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)


