Lo que vas a componer
El capítulo 2 de Materia viva, libro de biología de bachillerato en página de 210 × 280 mm, se abre bajo una célula que se sale por la esquina superior de la página, con el número del capítulo dentro. Siguen seis tipos de recuadro en tres colores, cada uno con un elemento gráfico propio, para que el alumno distinga un objetivo de una advertencia antes de leerlos. En magenta van los objetivos, sobre una franja con una diana, y la autoevaluación, con una casilla marcada fuera del marco. En verde, el «Para recordar», junto a un filete fino rematado por una bombilla, y la práctica, con tres pictogramas de seguridad. La advertencia es ámbar y lleva un distintivo de alerta en la esquina exterior. El RECUADRO 2.1 es lavanda, bajo una pestaña negra y un matraz. Las negritas toman el color del recuadro, magenta en el RECUADRO 2.1.
Esta receta responde a
- ¿Cómo hago recuadros de nota, consejo o advertencia con icono, franja de color y esquinas redondeadas?
- ¿Cómo añado una pestaña numerada («RECUADRO 1-1»), un icono de esquina o un icono al margen con un filete?
- ¿Cómo doy color a los términos clave (en negrita o cursiva) en el texto o dentro de los recuadros?
La respuesta corta
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
// The first style is also the one a missing or misspelt type falls back to.
box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
box('feature', 'band', { background: col('mist'),
titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
// Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
border: { enabled: true, color: col('warn'), width: pt(0.75) },
// The badge hangs half past the outer corner: sides of half badge + GAP align the title.
padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
left: mm(BADGE / 2 + GAP) },
icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
Ingredientes
- Funciones
- RecuadrosPestañas numeradas en los recuadrosIconos y distintivos de esquinaColumna de marca junto al recuadroColumnas dentro de un recuadroRecuadros a todo el anchoRecuadros flotantesNegrita, cursiva y sus coloresPaleta de color semánticaAperturas diseñadasBanda de capítulo a todo el anchoImágenes en los diseños de páginaAtributos de títuloSaltos de línea en los títulosCabeceras y foliosMárgenes simétricosFiguras y tablas como recursosCitas que colocan las figurasColocación de figurasPáginas en un canvas
- También usa
- Títulos numeradosFiguras justo aquíFigura y Tabla en tu idiomaCabeceras según el tipo de páginaEstilos de párrafoTipos de recurso propios
- Tipografía
- Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Dale un color a cada tipo de recuadro
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
Seis tipos de recuadro se reparten tres tonos: magenta para los objetivos y la autoevaluación, verde para el «Para recordar» y la práctica, y ámbar para la advertencia. El RECUADRO 2.1 tiene fondo lavanda y usa el magenta en sus términos clave. Cada tono tiene un tinte claro para los fondos, y todos los ajustes de color enlazan con una entrada de la paleta, así que para cambiar un tono basta con editar un valor. Con un cuerpo tan pequeño, el texto necesita un contraste de 4,5:1. El magenta sobre el lavanda llega a 4,9:1, pero el ámbar sobre el fondo de la advertencia se queda en 2,2:1; por eso la advertencia reserva el ámbar para el marco y el distintivo, y compone el título y los términos clave en warnInk, un ámbar más oscuro que llega a 5,2:1.
#2 · Comparte todo lo demás
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
box() reúne los ajustes comunes a todos los recuadros: los márgenes, el relleno, la letra del título y del cuerpo, y el espaciado de las listas. Su argumento hue colorea el título, las viñetas y números y las negritas. body.boldColor pone en magenta el modelo de mosaico fluido de los objetivos y en ocre la pared celular de la advertencia; las negritas del texto corrido siguen en tinta (tipografía dentro de un recuadro). Si declaras calloutStyles, sustituyes el único estilo que trae el motor, un recuadro gris llamado note. Un recuadro cuyo type falta o está mal escrito toma el primer estilo, así que la lista empieza por los objetivos, que aparecen en todos los capítulos.
#3 · Franja, pestaña y distintivo
El código es la respuesta corta de arriba. La franja de los objetivos mide 7,5 mm y su diana 5 mm, así que el icono queda dentro del color con aire a los dos lados. El offset de la pestaña es igual a su altura (height), de modo que la pestaña se apoya en el borde superior del recuadro, sin tocar el título. 'top-left' es la esquina exterior de esta página par; el matraz va a continuación de la pestaña, y el filete recorre el borde superior desde el matraz hasta la esquina opuesta. position: 'corner' cuelga el distintivo de la advertencia con la mitad de su ancho fuera del borde, y cornerSide: 'outer' lo lleva al lado exterior, el izquierdo en esta página par. En ese lado el motor sangra el título para que no choque con el distintivo, salvo que el relleno ya cubra medio distintivo más el espacio del título; por eso la advertencia lleva BADGE / 2 + GAP de relleno a los dos lados, y el título queda alineado con el texto. Solo este recuadro lleva borderRadius. En uno con franja, la franja se pinta aparte, como una barra rectangular, y las esquinas de ese lado seguirían rectas (estilos de aviso).
#4 · Tres elementos gráficos más
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
// A stripe narrower than its icon: the badge sits on it at the top and hides it there.
padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
icon: icon('bulb', BULB) }),
box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
box('check', 'band', { background: col('tintBand'),
// Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
La franja del «Para recordar» mide 2,5 pt, frente a los 6,4 mm de su distintivo, de modo que se ve como un filete fino con la bombilla encima de su extremo superior; un relleno izquierdo de medio distintivo más 2 mm aparta el texto de la bombilla. icon.width encaja la imagen en una caja ancha en vez de en un cuadrado, así que los tres pictogramas de seguridad, dibujados como una sola imagen de 24,5 mm, forman la columna de iconos del recuadro de seguridad, centrada respecto a su texto. La marca de la autoevaluación va fuera del marco, en este orden: marca, filete, hueco y caja. Su casilla cuadrada sobre un filete de 0,75 pt la distingue del «Para recordar», que lleva un distintivo redondo. La valla añade span="page" placement="bottom" para que la autoevaluación flote al pie de la página 29.
#5 · Los iconos también son recursos
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
// Uncited, so never placed as figures: the box styles and the opener use them by id.
...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
+ 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
+ 'glucosa, colesterol gris, azúcares ámbar.' }),
altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
+ 'hybrid just after fusion and the same hybrid 40 minutes later.',
es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
+ 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
+ 'net flow of water.',
es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
+ 'indican el flujo neto de agua.' }),
altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
Todos los iconos, el matraz de la pestaña y la célula de la apertura son recursos SVG declarados junto al contenido y registrados por su identificador de archivo. Los estilos los nombran por su id de recurso y, como el texto no los cita, ninguno se convierte en una figura numerada. Glifos como ✓ o ☞ serían más cómodos de escribir, pero no están en los archivos latin de Fontsource que cargan las páginas: el lienzo los tomaría de una fuente del sistema y un PDF los perdería. Los dibujos tampoco llevan texto, porque un SVG pintado como imagen no puede usar las fuentes de la página; los colores se nombran en los pies.
#6 · Abre el capítulo con una célula
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
{ kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
{ fontWeight: 800, lineHeight: 1, align: 'right' }),
text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
edge: 'top-left' }, offset: { y: mm(3) } },
{ fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
{ fontWeight: 700, lineHeight: 1.04 }),
text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
{ italic: true, lineHeight: 1.42 }),
] } };
La célula es un elemento de imagen anclado a la esquina superior derecha de la página y desplazado más allá de ella, de modo que asoma sobre todo su cuarto inferior izquierdo, con el número dentro. Ambos caben en los 66 mm que minHeight reserva para la apertura. El antetítulo y la entradilla salen de la línea del título, # Células y \\ membranas {unit="La célula" lead="…"}, y el \\ parte el título solo en la apertura.
La receta completa
// ═══ Postext Cookbook · Nº 008 · A family of textbook boxes ═══════════════════════ // https://postext.dev/en/cookbook/textbook-box-family // Code: MIT · Text: original (CC BY 4.0) · Drawings and icons: generated in code (CC BY 4.0) // Fonts: Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'textbook-box-family'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: three hues for six kinds of box; everything links to an entry const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the band, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion // Label face (box text and titles, tabs, captions, heads); leading in pt; outer margin in mm const LABEL = 'Barlow Semi Condensed', LEAD = 13.4, OUTER = 16, GAP = 1.5; // GAP: title gap, mm // #region base: what every box shares; each kind brings its colour and its one device const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2), padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) }, titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) }, body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'), boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers ...device }); // the kind's title, fill and device: its spread wins over the base // An SVG or bitmap resource, fitted into a square of that size (width × size with `width`). const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more }); // #endregion // #region answer: three devices: an icon on a wide stripe, a numbered tab, an outer badge // box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms. const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size const calloutStyles = [ // The first style is also the one a missing or misspelt type falls back to. box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }), background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') }, icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it box('feature', 'band', { background: col('mist'), titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) }, // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them. label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'), position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default) height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) }, rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }), box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4), border: { enabled: true, color: col('warn'), width: pt(0.75) }, // The badge hangs half past the outer corner: sides of half badge + GAP align the title. padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2), left: mm(BADGE / 2 + GAP) }, icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }), ]; // straight into config(), followed by the 'more' styles // #endregion // #region more: a badge threaded on a thin rule, a strip of pictograms, a marker outside const BULB = 6.4; // the study tip's badge in mm: the text clears it const moreStyles = [ box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }), backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') }, // A stripe narrower than its icon: the badge sits on it at the top and hides it there. padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) }, icon: icon('bulb', BULB) }), box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image box('check', 'band', { background: col('tintBand'), // Outside the frame: [marker][rule][gap][box], the rule as tall as the box. marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3), rule: { enabled: true, color: col('band'), width: pt(0.75) } } }), ]; // #endregion // #region opener: a cell cut by the corner of the page, the chapter number inside it const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, size: { width: mm(width) } }); const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' }); const opener = { enabled: true, minHeight: mm(66), slot: { elements: [ { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } }, text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } }, { fontWeight: 800, lineHeight: 1, align: 'right' }), text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(3) } }, { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }), text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112), { fontWeight: 700, lineHeight: 1.04 }), text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98), { italic: true, lineHeight: 1.42 }), ] } }; // #endregion // Heads 12.8 mm below the trim (the larger folio 0.4 mm higher), titles 8.5 mm in from folios. const head = (id, content, parity, edge, x, extra, y = 12.8) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.8), fontWeight: 600, letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } } }); const folio = { fontFamily: 'Lexend', fontSize: pt(9), fontWeight: 700, color: col('band') }; const header = { elements: [ // folios on the fore-edge, never on the opener head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio, 12.4), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8.5), head('recto-title', `${chapter} · {chapterTitle}`, 'odd', 'top-right', -(OUTER + 8.5)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio, 12.4), ] }; // the opener's folio drops to the foot: const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio, -12), pages: 'opener' }] }; const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes: defaultResourceTypes(LANG), // "Figura" (gotcha: resource-types-locale) colorPalette, header, footer, page: { width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(24), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true } }, // left is the inner margin layout: { layoutType: 'double', gutterWidth: mm(8) }, bodyText: { fontFamily: 'Noto Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // hyphens: default headings: { fontFamily: 'Lexend', color: col('band'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', marginTop: pt(LEAD * 1.4), marginBottom: pt(LEAD * 0.6) }, // 3 lines, close to its text { level: 3, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('tip'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a line clear of the text, on its box ] }, // In 1.4.1 a box's lists.color reaches its numbers only if it differs from these bullets'. unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(0) }, orderedLists: { fontFamily: 'Lexend', color: col('tip'), marginTop: pt(0), marginBottom: pt(0) }, calloutStyles: [...calloutStyles, ...moreStyles], captionStyle: { fontFamily: LABEL, fontSize: pt(8.8), color: col('ink'), labelColor: col('band'), gap: mm(1.8) }, paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.6), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 87 líneas · content.es.md
title: "Materia viva" subtitle: "Biología para bachillerato" --- # Células y \\ membranas {unit="La célula" lead="Toda célula viva está envuelta en una película diez mil veces más fina que una hoja de papel. El oxígeno la cruza directamente; las sales y los azúcares solo pasan por donde una proteína les abre paso."} Una célula es un taller abarrotado y lleno de agua. Sus enzimas necesitan sales, azúcares y piezas a mano, y los desechos deben salir antes de estorbar. Entre ese taller y el mundo se interpone la **membrana plasmática**, una capa de unos ocho nanómetros de grosor. En este capítulo verás de qué está hecha y por qué se comporta más como un líquido que como un muro. Algunas sustancias la cruzan sin que la célula gaste nada; otras, solo si la célula gasta energía. :::callout{type="objectives"} - Usar el **modelo de mosaico fluido** para describir la membrana plasmática. - Explicar cómo la **difusión** y la **ósmosis** mueven sustancias sin gastar energía. - Predecir cómo cambia una célula en una disolución hipertónica, isotónica o hipotónica. - Comparar el **transporte activo** con el pasivo. ::: ## Una frontera con puertas Las membranas están hechas sobre todo de **fosfolípidos**. Cada molécula tiene una cabeza afín al agua y dos colas grasas que la rehúyen. En el agua, los fosfolípidos se ordenan de modo que las colas queden ocultas: forman una doble capa, la **bicapa lipídica**, con las cabezas vueltas hacia el medio acuoso de ambos lados y las colas enfrentadas en el centro (:ref{id="mosaic"}). Como su interior es oleoso, la bicapa deja que las moléculas pequeñas y sin carga, como el oxígeno y el dióxido de carbono, la crucen sin más. Los iones y las moléculas polares más grandes, como la glucosa, se quedan fuera. Esa propiedad de dejar pasar unas sustancias y otras no se llama **permeabilidad selectiva**. Gracias a ella, la célula conserva un interior distinto de su entorno; a cambio, los iones y la glucosa necesitan proteínas de la membrana para cruzarla. :::callout{type="tip"} Lee el nombre del modelo como una descripción. **Fluido** quiere decir que los lípidos se desplazan de lado e intercambian su sitio con sus vecinos millones de veces por segundo, y **mosaico** significa que las proteínas están incrustadas en la capa como las teselas de un suelo. ::: ## El mosaico fluido En 1972, S. Jonathan Singer y Garth Nicolson propusieron el modelo que seguimos usando hoy. La bicapa es la base fluida y en ella flotan las proteínas: unas atraviesan la membrana de lado a lado y otras se apoyan en una de sus caras. Las **proteínas de canal** forman poros llenos de agua para iones concretos, y las **proteínas transportadoras** cambian de forma para llevar una molécula de un lado al otro. Hacia fuera asoman cadenas de azúcares, unidas a proteínas y lípidos, que otras células reconocen como etiquetas de identidad. El colesterol, encajado entre las colas de los fosfolípidos, actúa como amortiguador. Con calor sujeta las colas y evita que la membrana se vuelva demasiado líquida; con frío impide que se apiñen. Los seres vivos ajustan sus membranas a la temperatura: las bacterias que crecen en frío fabrican lípidos con colas más dobladas para que su capa siga fluida, y muchas plantas hacen lo mismo al llegar el invierno, igual que los peces de las aguas heladas. :::callout{type="feature" label="RECUADRO 2.1" title="Proteínas a la deriva" span="page"} :::columns{count=2 breaks="2"} ::resource{id="fusion"} En 1970, Larry Frye y Michael Edidin fusionaron una célula de ratón con una humana. Habían marcado las proteínas de membrana del ratón con un colorante fluorescente verde y las humanas con uno rojo. Al principio, cada color se quedaba en su mitad de la célula híbrida (:ref{id="fusion"}), pero a los cuarenta minutos, a 37 °C, los colores estaban mezclados por completo. En frío, los híbridos tardaban mucho más en mezclarse. Las proteínas iban solas **a la deriva** por una capa fluida que el frío volvía más rígida. ::: ::: :::callout{type="warning" title="¿Membrana o pared?"} Las plantas, los hongos y la mayoría de las bacterias tienen además una **pared celular** por fuera de la membrana. La pared da forma a la célula y evita que estalle, pero deja pasar casi todo. Solo la membrana tiene permeabilidad selectiva. ::: Los lípidos y muchas de las proteínas de la membrana se mueven sin parar, y aun así la capa no se deshace, porque las colas de cada lípido siguen a resguardo del agua vaya donde vaya. Por la misma razón, un agujero pequeño en una bicapa se cierra solo. ## Atravesar la membrana Las partículas de una disolución no dejan de moverse nunca. La **difusión** es su movimiento neto desde donde están más concentradas hacia donde lo están menos, hasta repartirse por igual. No le cuesta energía a la célula, porque el propio movimiento de las partículas hace el trabajo: el oxígeno entra por difusión en una célula que lo consume sin parar, y el dióxido de carbono sale del mismo modo. La **ósmosis** es la difusión del agua a través de una membrana con permeabilidad selectiva. Si la disolución que rodea a una célula tiene más partículas disueltas que el citoplasma, es **hipertónica**, y la célula pierde agua y se encoge; si tiene menos, es **hipotónica**, y el agua entra a raudales; si las dos están igualadas, es **isotónica**, y la célula conserva su tamaño (:ref{id="osmosis"}). Un glóbulo rojo en agua pura se hincha hasta reventar. Las células vegetales no revientan: la pared las mantiene firmes, y por eso una planta regada se sostiene erguida y una seca se marchita. La difusión es rápida en distancias cortas y lentísima en las largas: una molécula de oxígeno cruza una célula en centésimas de segundo, pero tardaría unas siete horas en recorrer un centímetro de agua. Esa es una de las razones de que las células sean pequeñas y de que los animales grandes necesiten pulmones, branquias y sangre. El paso a favor de gradiente a través de un canal o de una proteína transportadora se llama **difusión facilitada** y sigue siendo pasivo: la proteína abre el camino, pero no gasta energía. Para mover una sustancia cuesta arriba, de la concentración baja a la alta, la célula tiene que gastar energía, normalmente en forma de ATP; ese paso se llama **transporte activo**. La bomba de sodio y potasio de tus neuronas saca tres iones de sodio y mete dos de potasio por cada ATP, y bombas como ella consumen buena parte de la energía de una célula en reposo. :::callout{type="check" title="Comprueba lo que sabes" span="page" placement="bottom"} :::columns{count=2 breaks="4"} 1. ¿Por qué el oxígeno cruza la bicapa y la glucosa no? 2. ¿Qué significa **fluido** en el modelo de mosaico fluido? 3. Dejas una rodaja de pepino en agua con sal. Usa la **ósmosis** para predecir qué les pasa a sus células. 4. ¿Por qué el trabajo de la bomba de sodio y potasio es **transporte activo** y no difusión? 5. ¿Podría una célula de membrana rígida usar la **endocitosis** para tragarse una bacteria? Explícalo. ::: ::: ## Cargas a granel Hay cargas demasiado grandes para cualquier canal o transportadora. Un glóbulo blanco se traga una bacteria entera: la envuelve con su membrana y la encierra en una burbuja, una **vesícula**, que se desprende hacia el citoplasma. Es la **endocitosis**. Las células también beben así: en la pinocitosis, la membrana se repliega alrededor de una gotita del líquido exterior, con todo lo que lleve disuelto. El proceso inverso, la **exocitosis**, saca materiales al exterior: las vesículas cargadas de hormonas o de enzimas digestivas se funden con la membrana plasmática y vacían fuera su contenido. Casi todas las señales que una neurona pasa a la siguiente salen del mismo modo: las vesículas liberan unas moléculas mensajeras, los neurotransmisores, en el estrecho espacio que separa las dos células. Ambos procesos gastan energía y solo funcionan porque la bicapa es lo bastante fluida para romperse y cerrarse sin fugas. Las membranas no se quedan en la superficie: dentro de la célula, la misma bicapa envuelve el núcleo, las mitocondrias y un laberinto de compartimentos. En el capítulo 3 seguirás a una proteína desde que se fabrica hasta que sale de la célula. ### Experimenta: ósmosis en una patata :::callout{type="safety" title="Seguridad"} Ponte gafas y guantes de protección, y corta alejando el filo de los dedos. ::: 1. Corta dos tiras iguales de patata, de unos cinco centímetros, y pésalas. 2. Deja una en agua del grifo y otra en agua muy salada durante media hora. 3. Sécalas, vuelve a pesarlas e intenta doblarlas. 4. Explica con la ósmosis por qué han cambiado su masa y su rigidez. :::paragraphs{style="colophon"} **Materia viva** es un libro de texto inventado para el Recetario de Postext, con texto e ilustraciones originales (CC BY 4.0). Compuesto en Noto Serif, Lexend y Barlow Semi Condensed (SIL OFL). :::`; // content.<lang>.md, inlined by the Cookbook // #region icons: icons are resources too: declared with the content, registered by file id const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra }); const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref const resources = [ // Uncited, so never placed as figures: the box styles and the opener use them by id. ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)), svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000), svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({ en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its ' + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su ' + 'glucosa, colesterol gris, azúcares ámbar.' }), altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }), svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({ en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the ' + 'hybrid just after fusion and the same hybrid 40 minutes later.', es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos ' + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }), altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }), svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({ en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the ' + 'net flow of water.', es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas ' + 'indican el flujo neto de agua.' }), altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }), ]; // #endregion // #region art: the icons and the drawings, in the palette's colours (seeded) // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). function rng(seed) { // Mulberry32: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const n = (v) => +v.toFixed(2); const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const capsule = (x, y, w, h, fill, turn = 0, extra = '') => `<rect x="${n(x - w / 2)}" ` + `y="${n(y - h / 2)}" width="${n(w)}" height="${n(h)}" rx="${n(Math.min(w, h) / 2)}" ` + `fill="${fill}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})"${extra}/>`; const arrow = (x, y, len, turn, fill) => shape(`M${x} ${y - 0.9}h${len - 4}v-1.9l4 2.8-4 2.8` + `v-1.9H${x}Z`, fill, ` transform="rotate(${turn} ${x} ${y})"`); // a path, never a marker function target() { // white rings on the magenta stripe return svg(24, 24, `<g fill="none" stroke="${palette.paper}" stroke-width="2.3">` + `<circle cx="12" cy="12" r="9.8"/><circle cx="12" cy="12" r="5.3"/></g>` + dot(12, 12, 1.9, palette.paper)); } function bulb() { return svg(24, 24, dot(12, 12, 12, palette.tip) + shape('M12 4.4a5.6 5.6 0 0 0-3.3 10.1c.7.5 1 ' + '1.1 1 1.9v.6h4.6v-.6c0-.8.3-1.4 1-1.9A5.6 5.6 0 0 0 12 4.4Z', palette.paper) + capsule(12, 18.6, 4.6, 1.3, palette.paper) + capsule(12, 20.3, 3, 1.2, palette.paper)); } function flask() { const body = 'M8 2v7L2.4 19.4A2.1 2.1 0 0 0 4.3 22.5h11.4a2.1 2.1 0 0 0 1.9-3.1L12 9V2'; return svg(20, 24, shape(`${body}Z`, palette.paper) + shape('M5.3 14h9.4l3 5.4a1.1 1.1 0 0 1-1 ' + '1.6H3.3a1.1 1.1 0 0 1-1-1.6Z', palette.band) + dot(8.6, 17.4, 1.1, palette.paper) + dot(11.8, 18.7, 0.8, palette.paper) + line(body, palette.ink, 1.7) + line('M6.4 2h7.2', palette.ink, 1.7)); } function caution() { // ink on amber: white on amber would fail contrast return svg(24, 24, dot(12, 12, 11, palette.warn, ` stroke="${palette.paper}" stroke-width="2"`) + capsule(12, 10, 2.8, 9.2, palette.ink) + dot(12, 17.6, 1.7, palette.ink)); } function check() { // a ticked tile: square where the study tip's badge is round return svg(24, 24, `<rect width="24" height="24" rx="5.5" fill="${palette.band}"/>` + line('M6.3 12.6l3.8 3.8 7.6-8', palette.paper, 2.9)); } function safety() { // goggles, a glove and a blade: three mandatory-action discs const goggles = `<g fill="none" stroke="${palette.paper}" stroke-width="1.5">` + '<rect x="4.3" y="9" width="6.6" height="5.6" rx="2.4"/>' + '<rect x="13.1" y="9" width="6.6" height="5.6" rx="2.4"/></g>' + line('M10.9 11.4q1.1-1 2.2 0M2.6 11.6h1.7M19.7 11.6h1.7', palette.paper, 1.4); const glove = shape('M32.4 20.5v-6.6l-2.3-2.7a1.2 1.2 0 0 1 1.8-1.6l1.4 1.5V6.2a1.1 1.1 0 0 1 ' + '2.2 0v5.2V4.9a1.1 1.1 0 0 1 2.2 0v6.5V5.5a1.1 1.1 0 0 1 2.2 0v6.2V7a1.1 1.1 0 0 1 2.2 0v8.6' + 'l-1.2 4.9Z', palette.paper); const blade = `<g transform="rotate(-45 64 12)">${capsule(64, 16.3, 3.6, 9, palette.paper)}` + shape('M62.2 11.4V6.3L64 3.2l1.8 3.1v5.1Z', palette.paper) + '</g>'; return svg(76, 24, [12, 38, 64].map((x) => dot(x, 12, 11.4, palette.ink)).join('') + goggles + glove + blade); } function blob(cx, cy, rx, ry, r, fill, extra = '', square = 2.6) { // a soft, uneven shape const pts = Array.from({ length: 16 }, (_, i) => { const [c, sn] = [Math.cos((i * Math.PI) / 8), Math.sin((i * Math.PI) / 8)]; const k = 1 + (r() - 0.5) * 0.12; const f = (v) => Math.sign(v) * Math.abs(v) ** (2 / square); // squarer than an ellipse return [cx + f(c) * rx * k, cy + f(sn) * ry * k]; }); const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2].map(n); const d = pts.map((p, i) => `Q${p.map(n)} ${mid(p, pts[(i + 1) % 16])}`).join(''); return shape(`M${mid(pts[15], pts[0])}${d}Z`, fill, extra); } const shine = (cx, cy, rx, ry, r) => blob(cx - rx * 0.28, cy - ry * 0.34, rx * 0.46, ry * 0.4, r, palette.paper, ' fill-opacity=".22"'); const pale = ` stroke="${palette.tip}" stroke-width="1.6"`; // a light green protein, outlined const sugars = (x, y, r, count = 5) => { // a chain of sugars, branched once const sugar = (sx, sy) => dot(sx, sy, 2.1, palette.warn, ` stroke="${palette.paper}" stroke-width=".7"`); let out = ''; for (let k = 0; k < count; k++, y -= 4.3, x += (r() - 0.5) * 4) { out += sugar(x, y) + (k === 2 ? sugar(x + 4.3, y - 1.6) : ''); } return out; }; function cell() { // a cell drawn as a disc of bilayer: mostly its lower-left quarter shows let out = dot(100, 100, 71, palette.band); for (let a = 0; a < 360; a += 3.05) { const [c, s] = [Math.cos((a * Math.PI) / 180), Math.sin((a * Math.PI) / 180)]; const at = (d) => [100 + c * d, 100 + s * d]; const tail = (d0, d1) => line(`M${at(d0).map(n)}L${at(d1).map(n)}`, palette.rule, 0.8); out += tail(90.5, 84.8) + tail(75.8, 81.2) + dot(...at(92.6), 2.6, palette.band) + dot(...at(73.6), 2.5, palette.paper); } [106, 133, 157, 184].forEach((a, k) => { // proteins across the visible arc const rad = (a * Math.PI) / 180; const fill = k % 2 ? palette.warn : palette.tip; out += capsule(100 + Math.cos(rad) * 83, 100 + Math.sin(rad) * 83, 26, 7, fill, a); }); return svg(200, 200, out); } function mosaic() { // the membrane in section, 174 × 62 mm; the outside of the cell on top const r = rng(5); const W = 348; const yc = (x) => 70 + 3.4 * Math.sin(x / 44); const proteins = [[56, 34, palette.tip, 'channel'], [148, 38, palette.warn, 'carrier'], [234, 27, palette.tip], [306, 32, palette.tintTip, 'pale']]; const clear = (x) => proteins.every(([px, w]) => Math.abs(x - px) > w / 2 + 1.8); let out = `<rect width="${W}" height="70" fill="${palette.tintBand}"/>` + `<rect y="70" width="${W}" height="54" fill="${palette.mist}"/>`; for (let x = 4; x < W; x += 5.9) { // phospholipids: heads out, two tails in if (!clear(x)) continue; for (const s of [-1, 1]) { const y = yc(x) + s * 13; for (const dx of [-1, 1]) { out += line(`M${n(x + dx)} ${n(y - s * 2.4)}l${n(r() - 0.5)} ${-s * 4}` + `l${n(r() - 0.5)} ${-s * 4.6}`, palette.band, 0.75, ' stroke-opacity=".4"'); } out += dot(x, y, 2.75, palette.band); if (r() < 0.13 && clear(x + 3)) { // cholesterol among the tails: the only grey rods out += capsule(x + 3, y - s * 7.4, 2.1, 7.2, palette.ink, 0, ' fill-opacity=".62"'); } } } out += sugars(196, yc(196) - 16.5, r, 3); // a glycolipid for (const [px, w, fill, kind] of proteins) { // proteins across the bilayer const y = yc(px); const halves = kind === 'channel' ? [px - w / 4 - 1.3, px + w / 4 + 1.3] : [px]; for (const x of halves) { const rx = kind === 'channel' ? w / 4 : w / 2; out += blob(x, y, rx, 21, r, fill, kind === 'pale' ? pale : '') + shine(x, y, rx, 21, r); } if (kind === 'channel') { out += dot(px, y - 7, 2, palette.warnInk) + dot(px, y + 4, 2, palette.warnInk); } if (kind === 'carrier') { // a glucose held in the carrier's open mouth out += shape(`M${px - 7} ${y - 24}L${px} ${y - 12}L${px + 7} ${y - 24}Z`, palette.tintBand) + shape(`M${px - 3.6} ${y - 19.5}l1.8-3.1h3.6l1.8 3.1-1.8 3.1h-3.6Z`, palette.paper, ` stroke="${palette.ink}" stroke-width=".8"`); } else out += sugars(px + (r() - 0.5) * 5, y - 22.5, r); } out += blob(100, yc(100) + 22.5, 11, 5.5, r, palette.tintTip, pale) // proteins on one face only + blob(270, yc(270) - 22, 8.5, 5, r, palette.tip); for (let x = 6; x < W; x += 3.2) { // the cytoskeleton under the membrane out += dot(x, 111 + 2.2 * Math.sin(x / 7), 1.4, palette.muted, ' fill-opacity=".45"'); } for (let k = 0; k < 9; k++) { // oxygen outside the cell const [x, y] = [14 + r() * 320, 8 + r() * 26]; for (const dx of [0, 2.6]) out += dot(x + dx, y, 1.5, palette.ink, ' fill-opacity=".5"'); } return svg(W, 124, out); } function fusion() { // two cells, the hybrid just after fusion, the hybrid 40 minutes later const r = rng(3); const cellAt = (cx, rx, fill, nuclei, pick) => { let out = `<ellipse cx="${cx}" cy="25" rx="${rx}" ry="${Math.min(rx, 15)}" fill="${fill}" ` + `stroke="${palette.ink}" stroke-width=".8"/>` + nuclei.map((x) => dot(x, 25, 4.2, palette.rule)).join(''); for (let a = 0; a < 360; a += 12) { const rad = (a * Math.PI) / 180; out += dot(cx + Math.cos(rad) * rx, 25 + Math.sin(rad) * Math.min(rx, 15), 1.7, pick(Math.cos(rad), r())); } return out; }; return svg(180, 50, cellAt(16, 13, palette.tintTip, [16], () => palette.tip) + cellAt(45, 13, palette.tintBand, [45], () => palette.band) + arrow(65, 25, 12, 0, palette.muted) + cellAt(104, 22, palette.paper, [96, 112], (c) => (c < 0 ? palette.tip : palette.band)) + arrow(132, 25, 12, 0, palette.muted) + cellAt(163, 15.5, palette.paper, [158, 168], (c, k) => (k < 0.5 ? palette.tip : palette.band))); } function osmosis() { // three solutions: water leaves, stays even, floods in const r = rng(9); const panel = (x, solutes, body, flow) => { let out = `<rect x="${x}" width="108" height="86" rx="4" fill="${palette.mist}"/>`; for (let k = 0; k < solutes; k++) { const [px, py] = [x + 5 + r() * 98, 5 + r() * 76]; if (Math.hypot(px - x - 54, py - 43) > 30) out += dot(px, py, 1.5, palette.muted); } for (let k = 0; k < 6; k++) { // arrows: the net flow of water, out (+1) or in (-1) const rad = (k * Math.PI) / 3 + 0.5; if (flow === 0 && k % 3) continue; const d = flow < 0 || (flow === 0 && k) ? 40 : 27; out += arrow(x + 54 + Math.cos(rad) * d, 43 + Math.sin(rad) * d, 10, (rad * 180) / Math.PI + (d > 30 ? 180 : 0), palette.tip); } return out + body; }; const at = (k, d) => [54 + Math.cos((k * Math.PI) / 11) * d, 43 + Math.sin((k * Math.PI) / 11) * d].map(n); const crenated = `M${at(0, 13)}${Array.from({ length: 22 }, (_, k) => (k % 2 ? '' : `Q${at(k + 1, 19)} ${at(k + 2, 13)}`)).join('')}Z`; // bumps: control points outside const disc = (x, rr) => dot(x + 54, 43, rr, palette.band) + dot(x + 54, 43, rr * 0.45, palette.tintBand); return svg(348, 86, panel(0, 70, shape(crenated, palette.band), 1) + panel(120, 34, disc(120, 17), 0) + panel(240, 8, disc(240, 23), -1)); } const drawings = { target, bulb, flask, caution, safety, check, cell, mosaic, fusion, osmosis }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif': ['400', '400i', '700'], Lexend: ['700', '800'], // text, display, 'Barlow Semi Condensed': ['400', '600', '700'] }; // labels (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); // Folio 27 is odd like page 1, always a recto: parity follows the page (gotcha: parity-page1-recto) const continuation = { pageNumbering: { startAt: 27 }, headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Living Matter, chapter 2', es: 'Materia viva, capítulo 2' }) });Kit · core, fonts, viewer, images: igual en todas las recetas · 270 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
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 ↗
Variantes
#Extiende un recuadro sobre las dos columnas
span="page" en la valla extiende cualquier recuadro sobre las dos columnas; el RECUADRO 2.1 anida además un grupo :::columns para poner la figura junto al texto.
-:::callout{type="warning" title="¿Membrana o pared?"}
+:::callout{type="warning" title="¿Membrana o pared?" span="page"}#Cuelga el distintivo del lado del lomo
'inner' lleva el distintivo al lado del lomo, el izquierdo en la página impar y el derecho en la par; deja sitio en ese lado para la mitad que sobresale del recuadro.
- icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
+ icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'inner' }) }),#Pon la pestaña en la esquina derecha
'top-right', el valor por defecto, refleja toda la etiqueta: la pestaña pasa a la esquina derecha, el matraz a su izquierda y el filete llega desde la izquierda.
- position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
+ position: 'top-right',Errores frecuentes
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
El texto en bandera puede dejar sola la puntuación junto a una negrita o un :ref
En postext 1.4.1, el texto que no va justificado (cuerpos de recuadro, párrafos en bandera) puede partir la línea entre una negrita, una cursiva o un :ref y el signo de puntuación pegado a ellos: un punto puede abrir la línea siguiente y el «(» de una remisión puede cerrar la anterior. El texto justificado nunca se parte ahí. Revisa los recuadros de cada edición y reescribe la frase afectada para que ese tramo quede en mitad de la línea. Negrita, cursiva y sus colores →
Error frecuente
Un espacio de no separación sigue partiendo la línea
En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase. Escapes y caracteres literales →
Error frecuente
Los archivos latin de Fontsource solo traen glifos del rango latino
El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin. Fuentes incrustadas en el PDF →
Error frecuente
El texto dentro de un SVG <img> no puede usar fuentes web
Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →
Error frecuente
Un flotante 'top' nunca cae en la página que lo cita
Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita. Colocación de figuras →
Error frecuente
:::columns solo funciona dentro de un recuadro y no se parte
:::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno. Columnas dentro de un recuadro →
Error frecuente
Traduce Figura y Tabla con defaultResourceTypes(locale)
El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
Error frecuente
El desbordamiento del texto de diseño es 'ellipsis-end' por defecto
Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →
Error frecuente
La página 1 es impar: planifica con números físicos
La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue. Saltos de página y de columna →
Error frecuente
Una configuración se cachea por identidad: crea un objeto nuevo
El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →
Aviso de maquetación · unknownCalloutType
Tipo de recuadro desconocido
Por qué. Un :::callout nombra un tipo que calloutStyles no define, así que toma sin avisar el primer estilo.
Solución. Define el estilo o corrige el atributo type. Documentación →
- La pestaña solo se imprime si la valla lleva el atributo
label="RECUADRO 2.1"y el estilo tiene un objetolabel. Un estilo sin él ignora el atributo. - Postext no numera los recuadros. La etiqueta es el texto literal de la valla, así que cada número, RECUADRO 2.1, RECUADRO 2.2, se escribe a mano.
- Un distintivo de esquina sobresale la mitad de su ancho por el lado del recuadro, hacia el margen en el lado exterior de la página. En la otra columna de esa misma página sobresale hacia el medianil, así que deja los recuadros con distintivo en la columna exterior o dales sitio.
- Un recuadro solo cambia el color de sus viñetas y números si su
lists.colores distinto deunorderedLists.color. Si los dos son el mismo magenta, una lista numerada dentro del recuadro tomaorderedLists.color; por eso este documento pone sus propias viñetas en tinta.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Noto Serif (SIL OFL 1.1) · Lexend (SIL OFL 1.1) · Barlow Semi Condensed (SIL OFL 1.1)
- Código
- MIT, como Postext


