En poques paraules
Un capítol d'un llibre de física amb una columna ampla de text i una altra d'estreta al costat de la vora exterior. L'estreta porta diagrames i notes breus al costat del text que acompanyen.
Què compondràs
El capítol 4 de Palanca y lente, un manual de física en pàgina de 210 × 275 mm. El text ocupa una columna ampla i no entra mai al marge exterior, un canal de 53 mm. El número del capítol, un 4 verd, va en aquest canal a l'altura del títol, i els objectius, a sota. Els diagrames s'apilen des del capdamunt del canal, i els termes clau van en glosses verd clar al costat del passatge que els defineix. Les figures que es queden a la columna de text, sobre fons fosc o clar, porten el peu al marge, a l'altura de la seva base. Només el panell del prisma creua les dues columnes. Els marges són simètrics, així que el canal queda sempre al costat exterior de la pàgina, a la dreta a les senars i a l'esquerra a les parelles.
Aquesta recepta respon a
- Com compono en columna i mitja, amb una columna de text ampla i una de lateral estreta?
- Com poso notes al marge o glosses al costat del paràgraf que expliquen?
- Com afegeixo una figura amb peu numerat i la cito en el text («vegeu la fig. 3.2»)?
- Com decideixo on va una figura: al capdamunt de la pàgina, a l'amplada de les dues columnes, just aquí o al marge?
- Com poso el peu al costat d'una figura, o damunt d'una taula sobre una barra de color?
- Com numero els títols (1, 1.1, 1.1.1) i dono a cada nivell un estil diferent?
La resposta curta
const layout = {
layoutType: 'oneAndHalf', // a wide main column and a narrow side column
sideColumnPercent: 30, // of the 176 mm content width: a 52.8 mm channel
sideColumnRole: 'floats', // no body text: side figures, side captions and side boxes only
sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored)
gutterWidth: mm(7), // the text column keeps the rest: 176 − 52.8 − 7 = 116 mm
};
// A figure placed with span 'side' stacks in the channel from the head of the page that cites it.
// The stack ignores the opener's numeral: on a first page, cite side figures after the objectives.
const side = { span: 'side' };
// A figure left in the text column (the default span) sets its caption in the channel beside
// it; page-wide floats ignore captionSide and keep theirs underneath.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
: { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale
// A box fenced :::callout{type="term" span="side"} leaves the flow and lands in the channel at the
// height the text has reached. Fence each gloss after a paragraph, never straight after a heading
// (gotcha: side-box-after-heading).
Ingredients
- Funcions
- Columna al marge per a flotantsPeus lateralsNotes al margeColumna i mitjaMarges simètricsCites que col·loquen les figuresCol·locació de figuresPeus numeratsFigures i taules com a recursosFigura i Taula en la teva llenguaObertures dissenyadesAncoratge d'elements de dissenyAtributs de títolTítols numeratsEstils de títolCapítols que obren en pàgina senarRequadresCapçaleres i folisCapçaleres segons el tipus de pàginaPaleta de color semàntica
- Tipografia
- Merriweather, Merriweather Sans (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Reserva el marge per als flotants
El codi d'aquest pas és la resposta curta de més amunt. En una disposició en columna i mitja, sideColumnPercent: 30 dona a la columna lateral el 30 % dels 176 mm d'amplada de la caixa (52,8 mm), i la columna de text es queda amb el que sobra després de l'espai entre columnes de 7 mm (116 mm, uns 75 caràcters de Merriweather de 9,3 pt). sideColumnRole: 'floats' tanca la columna lateral al text, i sideColumnSide: 'outer' la posa al costat exterior, que amb marges simètrics canvia d'una pàgina a l'altra. Les figures hi van amb span: 'side'. Un requadre amb span="side" a la tanca surt del flux i entra al canal a l'altura per on va el text, així que cada glossa comença al costat del bloc que segueix la seva tanca.
#2 · Deixa que la cita col·loqui cada figura
const drawings = new Map(); // fileId → SVG markup, registered before the build
const figure = (id, { width, height, markup }, placement) => {
drawings.set(`${id}.svg`, markup);
return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption: t(captions[id]),
altText: t(captions[id]), // read aloud in HTML and tagged PDF; the canvas does not use it
svg: { fileId: `${id}.svg`, width, height }, ...(placement && { placement }) };
};
const resources = [ // no placement: a main-column float, its caption in the channel
figure('burning-glass', burningGlass()),
figure('refraction', refraction(), side),
figure('critical-angle', criticalAngle(), side),
figure('fibre', fibre()),
figure('prism', prism(), { span: 'page', position: 'top' }), // across text column and channel
figure('principal-rays', principalRays()),
figure('diverging', diverging(), side),
];
El primer :ref a una figura la numera i la situa on indica la seva col·locació. Una figura amb span: 'side' s'apila des del capdamunt del canal de la pàgina que la cita, encara que la cita quedi més avall, i passa a la pàgina següent si no li basta la resta del canal. Les figures 4.2 i 4.3, citades totes dues a la pàgina 88, baixen pel canal d'aquella pàgina amb la glossa de l'angle límit entre elles. El prisma és un flotant top a tota l'amplada citat a la pàgina 88, i un flotant no queda mai per sobre de la seva cita, així que obre la 89 sobre les dues columnes. Les figures 4.1, 4.4 i 4.6 es queden a la columna de text i prenen captionSide del defaultPlacement del tipus figura. Ocupen l'espai inferior de la columna, així que cada peu queda a l'altura de la base de la seva figura; a l'espai superior s'alinearia amb la vora de dalt.
#3 · Obre el capítol al marge
// Every element counts toward the opener's depth, the page-anchored numeral too (gotcha:
// opener-reserves-anchored). minHeight fixes that depth at nine lines of the grid, room for a
// one-line title, the rule and a four-line standfirst (41.2 mm), so the text starts on the same
// line in every such chapter, however short its standfirst and even with a smaller numeral. Kicker
// and numeral (40.6 mm) reach the ninth line too; a deeper opener grows past it, line by line.
const [KICKER, NUMERAL] = [8, 104]; // pt
const opener = {
enabled: true,
minHeight: pt(LEAD * 9), // 42.9 mm: the text starts on the eleventh line, after marginBottom
slot: {
elements: [
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: SANS, fontWeight: 800,
fontSize: pt(32), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('accent'),
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4) },
size: { width: 'fill' } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
fontSize: pt(10.5), lineHeight: 1.45, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(3.5) },
size: { width: 'fill' } } },
// The kicker hangs from the page's top-right corner, not from the heading: the channel lies
// outside the heading's column, and on the right only on a recto (so chapters open on one).
// The numeral hangs from the kicker.
{ kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...label,
fontSize: pt(KICKER), align: 'left', placement: { anchor: { to: 'page', edge: 'top-right' },
offset: { x: mm(-OUTER), y: mm(TOP) }, size: { width: mm(CHANNEL) } } },
{ kind: 'text', id: 'numeral', content: '{chapterNumber}', fontFamily: SANS, fontWeight: 800,
fontSize: pt(NUMERAL), lineHeight: 1, color: col('accent'), align: 'left',
placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(0.5) },
size: { width: mm(CHANNEL) } } },
],
},
};
El títol de primer nivell es queda a la columna de text, on una ranura de disseny n'apila el text, el filet i l'entradeta; l'avanttítol penja de la cantonada superior dreta de la pàgina i el número, de l'avanttítol, tots dos amb l'amplada del canal. minHeight fixa l'alçada de l'obertura en nou línies de la retícula (42,9 mm), que donen cabuda a un títol d'una línia, el filet i una entradeta de quatre, així que amb una entradeta més curta el text no puja. L'avanttítol i el número de 104 pt acaben 40,6 mm per sota del marge superior, dins d'aquestes nou línies, així que tampoc no fan baixar el text. El canal només és a la dreta a les pàgines senars, i per això el títol de primer nivell salta a pàgina senar. El requadre d'objectius va després del primer paràgraf i no just després del títol, i cap figura del marge no es cita abans que ell (ho explica «Errors freqüents»).
#4 · Porta els folis al costat del canal
const [HEAD_Y, FOOT_Y, HEAD_GAP] = [12.5, -12, 9]; // mm from the top and bottom trim; folio to head
// A text on the physical page: edge picks the corner, x and y are its offsets in mm.
const head = ({ edge, x, y = HEAD_Y, ...text }) => ({
kind: 'text', pages: 'body', ...label, color: col('muted'), ...text,
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const folio = { content: '{pageNumber}', fontSize: pt(8.5), letterSpacing: pt(0),
color: col('accent') };
const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge
const recto = { parity: 'odd', edge: 'top-right' }; // x counts back from the right edge
const header = { elements: [
head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }),
head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }),
head({ id: 'recto-title', ...recto, x: -(OUTER + HEAD_GAP),
content: t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
es: 'Capítulo {chapterNumber} · {chapterTitle}' }) }),
head({ id: 'recto-folio', ...recto, ...folio, x: -OUTER }),
] };
// A chapter's first page, always a recto, carries a drop folio at the foot of the channel instead.
const footer = { elements: [
head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
y: FOOT_Y }),
] };
Cada element s'ancora a la pàgina física i es filtra per parity, així que el foli i la capçalera queden a la mateixa vora que el canal a totes dues bandes del plec. pages: 'body' els aparta de l'obertura, que porta en canvi un foli al peu del canal.
#5 · Comença el llibre al capítol 4
const continuation = { pageNumbering: { startAt: 87 }, // odd, like page 1: a recto
headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Textbook with a margin column',
es: 'Libro de texto con columna al margen' }) });
Aquestes pàgines són el capítol 4 d'un llibre més llarg. La continuació deixa el comptador de capítols en 3, així que {chapterNumber} imprimeix 4 i el numberingTemplate: '{1}.{2}' del segon nivell numera els apartats del 4.1 al 4.3; les figures van de la 4.1 a la 4.7. ## Preguntas {style="plain"} pren una entrada de headingStyles amb numbered: false, així que aquest títol no porta número. Els folis comencen al 87 perquè la primera pàgina cau a la dreta, i a la dreta van els folis senars.
#6 · Anomena els colors una sola vegada
const palette = {
ink: '#1a222d', // text, and the dark panels of figures 4.1, 4.4 and 4.5
accent: '#17774f', // the only accent colour: numerals, folios, section headings, labels
ray: '#f2a516', // light rays in every diagram
glass: '#cfe6dd', // glass in the diagrams
tint: '#edf5f1', // the key-term glosses and the light plate of a construction diagram
muted: '#5b6863', // running heads, the normals in the diagrams, the colophon
paper: '#ffffff',
};
// A colour carries its id and its hex: 1.4.1 paints design elements and referenceColor from the
// hex alone (gotcha: palette-skips-designs), so retint by editing `palette`, not colorPalette.
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' } })),
// The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
{ id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
Cada color de la configuració enllaça amb una entrada de la paleta i porta a més el seu hex, que col() copia del mateix objecte. Postext 1.4.1 pinta els elements de disseny i el color de les remissions amb aquest hex, no amb l'entrada de la paleta (ho explica «Errors freqüents»). Per canviar els colors del capítol, edita palette: els diagrames llegeixen el mateix objecte, així que el vidre, els raigs, els panells foscos i el fons clar canvien alhora que números, avanttítols i glosses. main-color apunta al color d'accent, així que els colors per defecte dels estils de text que la configuració no fixa surten en verd i no en el blau del motor.
La recepta completa
// ═══ Postext Cookbook · Nº 001 · Textbook with a margin column ═══════════════════ // https://postext.dev/en/cookbook/textbook-margin-column // Code: MIT · Text: original (CC BY 4.0) · Diagrams: generated in code (CC BY 4.0) // Fonts: Merriweather, Merriweather Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A chapter of a physics textbook in the column-and-a-half layout: the body text keeps to // the main column, and the outer margin is a channel for diagrams, captions and glosses. 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-margin-column'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: semantic colours, each linked by id and written out in hex const palette = { ink: '#1a222d', // text, and the dark panels of figures 4.1, 4.4 and 4.5 accent: '#17774f', // the only accent colour: numerals, folios, section headings, labels ray: '#f2a516', // light rays in every diagram glass: '#cfe6dd', // glass in the diagrams tint: '#edf5f1', // the key-term glosses and the light plate of a construction diagram muted: '#5b6863', // running heads, the normals in the diagrams, the colophon paper: '#ffffff', }; // A colour carries its id and its hex: 1.4.1 paints design elements and referenceColor from the // hex alone (gotcha: palette-skips-designs), so retint by editing `palette`, not colorPalette. 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' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // #endregion // The page in mm, named once: the channel, the opener and the running heads derive from it. const [TRIM_W, TRIM_H] = [210, 275]; const [TOP, BOTTOM, INNER, OUTER] = [24, 22, 20, 14]; // inner and outer swap on a verso const LEAD = 13.5; // body leading in pt const [SERIF, SANS] = ['Merriweather', 'Merriweather Sans']; // #region answer: a float-only channel on the outer edge, and what goes into it const layout = { layoutType: 'oneAndHalf', // a wide main column and a narrow side column sideColumnPercent: 30, // of the 176 mm content width: a 52.8 mm channel sideColumnRole: 'floats', // no body text: side figures, side captions and side boxes only sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored) gutterWidth: mm(7), // the text column keeps the rest: 176 − 52.8 − 7 = 116 mm }; // A figure placed with span 'side' stacks in the channel from the head of the page that cites it. // The stack ignores the opener's numeral: on a first page, cite side figures after the objectives. const side = { span: 'side' }; // A figure left in the text column (the default span) sets its caption in the channel beside // it; page-wide floats ignore captionSide and keep theirs underneath. const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type : { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale // A box fenced :::callout{type="term" span="side"} leaves the flow and lands in the channel at the // height the text has reached. Fence each gloss after a paragraph, never straight after a heading // (gotcha: side-box-after-heading). // #endregion // The channel's width, the measure of everything the opener and the heads set in it: 52.8 mm. const CHANNEL = ((TRIM_W - INNER - OUTER) * layout.sideColumnPercent) / 100; // The channel's own type, and the two boxes that stand in it. const label = { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 700, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('accent') }; // A box's text takes the body's ink for text, bold and italic; only face, size and setting change. const note = { fontFamily: SANS, fontSize: pt(8), lineHeight: pt(11.25), textAlign: 'left', firstLineIndent: pt(0) }; const calloutStyles = [ { id: 'panel', backgroundEnabled: false, // objectives and key ideas; each fence names its title padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) }, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') }, titleStyle: { ...label, gap: mm(2) }, body: note, marginTop: pt(0), marginBottom: pt(LEAD), lists: { color: col('accent'), indent: mm(3), itemSpacing: pt(3) } }, { id: 'term', title: t({ en: 'Key term', es: 'Término clave' }), background: col('tint'), padding: { top: mm(2.6), right: mm(3), bottom: mm(3), left: mm(3) }, titleStyle: { ...label, gap: mm(1.2) }, body: note, marginTop: pt(0), marginBottom: pt(LEAD) }, ]; // #region opener: the title in the main column, the chapter number standing in the channel // Every element counts toward the opener's depth, the page-anchored numeral too (gotcha: // opener-reserves-anchored). minHeight fixes that depth at nine lines of the grid, room for a // one-line title, the rule and a four-line standfirst (41.2 mm), so the text starts on the same // line in every such chapter, however short its standfirst and even with a smaller numeral. Kicker // and numeral (40.6 mm) reach the ninth line too; a deeper opener grows past it, line by line. const [KICKER, NUMERAL] = [8, 104]; // pt const opener = { enabled: true, minHeight: pt(LEAD * 9), // 42.9 mm: the text starts on the eleventh line, after marginBottom slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SANS, fontWeight: 800, fontSize: pt(32), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('accent'), placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4) }, size: { width: 'fill' } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true, fontSize: pt(10.5), lineHeight: 1.45, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(3.5) }, size: { width: 'fill' } } }, // The kicker hangs from the page's top-right corner, not from the heading: the channel lies // outside the heading's column, and on the right only on a recto (so chapters open on one). // The numeral hangs from the kicker. { kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...label, fontSize: pt(KICKER), align: 'left', placement: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(TOP) }, size: { width: mm(CHANNEL) } } }, { kind: 'text', id: 'numeral', content: '{chapterNumber}', fontFamily: SANS, fontWeight: 800, fontSize: pt(NUMERAL), lineHeight: 1, color: col('accent'), align: 'left', placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(0.5) }, size: { width: mm(CHANNEL) } } }, ], }, }; // #endregion // #region heads: book title on the verso, chapter on the recto, folios on the outer edge const [HEAD_Y, FOOT_Y, HEAD_GAP] = [12.5, -12, 9]; // mm from the top and bottom trim; folio to head // A text on the physical page: edge picks the corner, x and y are its offsets in mm. const head = ({ edge, x, y = HEAD_Y, ...text }) => ({ kind: 'text', pages: 'body', ...label, color: col('muted'), ...text, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, }); const folio = { content: '{pageNumber}', fontSize: pt(8.5), letterSpacing: pt(0), color: col('accent') }; const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge const recto = { parity: 'odd', edge: 'top-right' }; // x counts back from the right edge const header = { elements: [ head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }), head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }), head({ id: 'recto-title', ...recto, x: -(OUTER + HEAD_GAP), content: t({ en: 'Chapter {chapterNumber} · {chapterTitle}', es: 'Capítulo {chapterNumber} · {chapterTitle}' }) }), head({ id: 'recto-folio', ...recto, ...folio, x: -OUTER }), ] }; // A chapter's first page, always a recto, carries a drop folio at the foot of the channel instead. const footer = { elements: [ head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER, y: FOOT_Y }), ] }; // #endregion const config = () => ({ // a factory: a fresh object per build (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes, colorPalette, page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, // left: recto's inner layout, bodyText: { // hyphenation, optimal line breaking and widow control are on by default fontFamily: SERIF, fontWeight: 300, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'), referenceBold: false, textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, headings: { fontFamily: SANS, color: col('ink'), fontWeight: 800, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // 'odd': kicker, numeral and drop folio sit at the right edge, the outer one only on a recto. // The heading stays in the main column; its design draws it. { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(LEAD), advancedDesign: opener }, { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', color: col('accent'), marginTop: pt(LEAD * 1.5), marginBottom: pt(0) }, ], }, headingStyles: [{ id: 'plain', numbered: false }], // ## Questions {style="plain"} orderedLists: { numberFormat: 'arabic', // the default, written out: 'decimal' prints 'undefined' fontFamily: SANS, fontWeight: 800, color: col('accent'), marginTop: pt(0), marginBottom: pt(0) }, calloutStyles, captionStyle: { fontFamily: SANS, fontSize: pt(7.6), labelColor: col('accent'), gap: mm(2) }, paragraphStyles: [{ id: 'aside', firstLineIndent: pt(0), marginTop: pt(LEAD * 0.5) }, { id: 'colophon', fontFamily: SANS, fontSize: pt(6.8), lineHeight: pt(9), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 87 línies · content.es.md
title: "Palanca y lente" subtitle: "Curso de introducción" --- # Luz y lentes {lead="La luz cambia de dirección al pasar del aire al vidrio o al agua, y cuánto se desvía depende de los dos materiales y del ángulo con que llega. Las lentes se sirven de esa desviación para formar imágenes, y un prisma muestra que cambia un poco con cada color."} Pon una lupa al sol y podrás reunir su luz en un punto tan brillante que llega a chamuscar el papel (:ref{id="burning-glass" case="lower"}). :::callout{type="panel" span="side" title="En este capítulo"} - Explicar la refracción a partir del cambio de velocidad de la luz. - Usar el índice de refracción y la ley de Snell para predecir cuánto se desvía un rayo. - Trazar los rayos principales en lentes convergentes y divergentes. - Describir la dispersión y explicar cómo forma un espectro un prisma. ::: La lupa reúne en pocos milímetros cuadrados toda la luz que le llega. Cada rayo cambia de dirección en las dos caras curvas, talladas para que todos los rayos lleguen al mismo punto. Ese cambio de dirección se llama *refracción*, y actúa en todas las lentes: en la cámara del móvil, en unas gafas, en el microscopio del laboratorio del instituto y en el propio ojo, donde la córnea y el cristalino enfocan la imagen sobre la retina. Para entender cualquiera de ellas hacen falta dos ideas. La primera, que la luz viaja más despacio en el vidrio o en el agua que en el aire. La segunda, que un haz que llega oblicuo a una superficie la cruza primero por un borde y después por el otro. :::callout{type="term" span="side"} **Índice de refracción** *n*: la velocidad de la luz en el vacío dividida entre su velocidad en el material. No tiene unidades. Agua, 1,33; vidrio *crown*, 1,52; diamante, 2,42. ::: ## Refracción En el vacío, la luz recorre casi exactamente trescientos mil kilómetros por segundo. En el aire va apenas un poco más despacio, pero en el agua avanza unos doscientos veinticinco mil kilómetros cada segundo, y en el vidrio corriente, unos doscientos mil. El cociente entre la velocidad en el vacío y la velocidad en un material es el *índice de refracción* de ese material, que se representa con la letra *n*. Un desfile muestra por qué la luz cambia de dirección al cambiar de velocidad. Supón que una fila de personas pasa en diagonal de una plaza pavimentada a un campo embarrado. Las de un extremo pisan el barro primero y frenan, mientras las del otro siguen a paso normal, así que toda la fila gira. Un haz de luz hace lo mismo al entrar en el vidrio y se *acerca a la normal*, la línea perpendicular a la superficie (:ref{id="refraction" case="lower"}). Al salir de una lámina de caras paralelas, recupera la velocidad y deshace el giro. Cuánto se desvía el rayo depende de los dos índices de refracción y del ángulo con el que llega. La regla la halló el matemático neerlandés Willebrord Snell en 1621 y se conoce como *ley de Snell*: *n*~1~ sen *i* = *n*~2~ sen *r*, donde *i* es el ángulo de incidencia y *r* el de refracción, ambos medidos desde la normal. Un rayo que incide en el vidrio a 40° de la normal, por ejemplo, se refracta a 25°, porque sen *r* = sen 40° ÷ 1,52 = 0,42. Un rayo que llega siguiendo la normal no se desvía. :::callout{type="term" span="side"} **Ángulo límite** *c*: el ángulo de incidencia, dentro del material más denso, por encima del cual la luz ya no sale: sen *c* = 1 ÷ *n*. Unos 41° en el vidrio *crown* y 49° en el agua. ::: Si el rayo va al revés, del vidrio al aire, se aleja de la normal. A medida que crece el ángulo dentro del vidrio, el rayo que sale se inclina cada vez más hacia la superficie, hasta que, en el *ángulo límite*, la recorre rasante. Por encima de ese ángulo no sale nada de luz: toda se refleja de vuelta al interior del vidrio (:ref{id="critical-angle" case="lower"}). Esta *reflexión total interna* devuelve más luz que el mejor de los espejos. La reflexión total interna lleva llamadas telefónicas y datos de internet de un lado a otro de los océanos. Una fibra óptica es un hilo de vidrio purísimo, más fino que un cabello, envuelto en una funda de vidrio con un índice de refracción algo menor. La luz que entra por un extremo incide siempre en la frontera con un ángulo mayor que el límite, así que avanza en zigzag por la fibra durante decenas de kilómetros casi sin pérdidas (:ref{id="fibre" case="lower"}). El mismo efecto hace brillar un diamante tallado: su ángulo límite es de solo 24°, de modo que la luz que entra en la piedra rebota varias veces en su interior antes de encontrar la salida. ## Dispersión Hasta aquí hemos tratado el índice de refracción como un número único, pero depende del color. En el vidrio, la luz violeta viaja algo más despacio que la roja, así que se refracta un poco más: el índice del vidrio *crown* es 1,51 para la luz roja y 1,53 para la violeta. La diferencia es pequeña, pero un prisma la hace visible (:ref{id="prism" case="lower"}). Sus dos caras están inclinadas una hacia la otra, de modo que la desviación en la segunda cara se suma a la de la primera en lugar de deshacerla, y cada color sale con su propio ángulo. En 1666, Isaac Newton dejó entrar un haz de luz solar en una habitación a oscuras por un agujero del postigo y lo hizo pasar por un prisma. En la pared del fondo vio una banda de colores, del rojo al violeta, y demostró que un segundo prisma, colocado al revés, volvía a reunir los colores en luz blanca. Concluyó que la luz blanca es una mezcla de todos los colores y que el prisma solo los separa. Esta separación de la luz en sus colores se llama *dispersión*, y la banda de color que produce es un *espectro*. :::callout{type="term" span="side"} **Dispersión**: la separación de la luz blanca en sus colores, porque el índice de refracción de un material cambia un poco con cada color. ::: Un arcoíris es luz del sol dispersada por las gotas de lluvia. Cada gota la refracta al entrar, la refleja una vez en su parte posterior y vuelve a refractarla al salir. La luz roja sale a unos 42° de la dirección de los rayos del sol, y la violeta a unos 40°, así que cada gota envía un solo color a tus ojos y entre todas dibujan un arco. :::callout{type="term" span="side"} **Distancia focal** *f*: la distancia entre el centro de una lente y su foco principal. Su potencia, en dioptrías, es 1 ÷ *f*, con *f* en metros. ::: ## Lentes Una lente es un vidrio o un plástico de caras curvas que acercan la luz a su eje o la alejan de él. Una lente *convergente* es más gruesa en el centro que en los bordes. Los rayos que llegan paralelos a su eje se desvían hacia él y se cortan en un punto detrás de la lente, el *foco principal* F (:ref{id="principal-rays" case="lower"}). La distancia entre el centro de la lente y F es la *distancia focal* *f*. Una lente más gruesa y más curvada desvía más la luz y tiene una distancia focal más corta. Para saber dónde forma la imagen una lente se trazan, desde la punta del objeto, tres rayos cuyo camino se conoce de antemano. Un rayo paralelo al eje sale pasando por el foco. Un rayo que pasa por el centro de la lente sigue recto. Un rayo que pasa por el foco situado delante de la lente sale paralelo al eje. La imagen de la punta se forma donde se cortan, y con dos de ellos basta para situarla. Cuando el objeto está a más de dos distancias focales de una lente convergente, como en una cámara, la imagen es *real*, *invertida* y menor que el objeto. Real significa que la luz del objeto llega hasta la imagen, así que esta se puede recoger en una pantalla o en un sensor. Si acercas el objeto, la imagen crece y se aleja de la lente. Si lo pones más cerca que el foco, los rayos que salen de la lente ya no llegan a cortarse: se separan, y tu ojo los prolonga hacia atrás hasta una imagen *virtual*, derecha y mayor, situada al mismo lado que el objeto. Así funciona una lupa. Una lente *divergente* es más delgada en el centro que en los bordes. Separa los rayos paralelos como si salieran de un foco situado delante de la lente (:ref{id="diverging" case="lower"}), y la imagen que forma es siempre virtual, derecha y menor que el objeto. Los ojos miopes enfocan la luz por delante de la retina, y una lente divergente en unas gafas devuelve la imagen a la retina. Los ojos hipermétropes necesitan lo contrario: una lente convergente. El capítulo 5 sigue la luz dentro del ojo, donde la córnea hace la mayor parte del enfoque y el cristalino lo ajusta según la distancia del objeto. Después pasa al microscopio y al telescopio, que amplían lo que el ojo puede ver. :::callout{type="panel" span="side" title="Ideas clave"} - La luz viaja más despacio en el vidrio y en el agua que en el aire; el índice de refracción *n* mide cuánto. - Un rayo que entra en un material más denso se acerca a la normal, como describe la ley de Snell. - Por encima del ángulo límite, la luz que viaja dentro del vidrio o del agua se refleja por completo. - Una lente convergente forma imágenes reales o virtuales; una divergente, solo virtuales. ::: ## Preguntas {style="plain"} 1. Un rayo de luz pasa del aire al agua (*n* = 1,33) formando 50° con la normal. Calcula el ángulo de refracción. 2. Una pajita metida en un vaso de agua parece doblarse justo donde entra en el agua. Explica por qué con un diagrama de rayos. 3. ¿Por qué brilla un diamante tallado? Calcula su ángulo límite (*n* = 2,42). 4. ¿Qué distancia focal tiene una lupa de lectura de +2,5 dioptrías? 5. Un objeto está entre una lente convergente y su foco. Traza los tres rayos principales y describe la imagen. :::paragraphs{style="aside"} *Las soluciones de las preguntas numéricas están al final del libro.* ::: :::paragraphs{style="colophon"} Compuesto en Merriweather y Merriweather Sans (SIL OFL 1.1) · Texto y diagramas: originales, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // Captions carry the labels the diagrams leave out (gotcha: svg-no-webfonts). const captions = { 'burning-glass': { en: 'A burning glass. A converging lens bends parallel rays of sunlight so that they all ' + 'meet at one point, the focus, where a card begins to scorch.', es: 'Una lupa al sol. Una lente convergente desvía los rayos paralelos de luz para que ' + 'coincidan en un punto, el foco, donde una cartulina empieza a quemarse.' }, 'refraction': { en: 'Entering glass, a ray bends towards the normal (dashed): the angle of refraction (green) ' + 'is less than the angle of incidence (amber).', es: 'Al entrar en el vidrio, el rayo se acerca a la normal (a trazos): el ángulo de refracción ' + '(verde) es menor que el de incidencia (ámbar).' }, 'critical-angle': { en: 'Rays aimed at the centre of a semicircular block. At 25° the ray escapes, bent away ' + 'from the normal; at 58°, past the critical angle, all of it is reflected.', es: 'Rayos dirigidos al centro de un bloque semicircular. A 25° el rayo sale, alejándose de ' + 'la normal; a 58°, pasado el ángulo límite, se refleja por completo.' }, 'fibre': { en: 'An optical fibre. Light meets the wall of the core at more than the critical angle, ' + 'so it is totally reflected each time and cannot leak out.', es: 'Una fibra óptica. La luz incide en la pared del núcleo con un ángulo mayor que el límite, ' + 'así que se refleja por completo cada vez y no puede escaparse por el camino.' }, 'prism': { en: 'Dispersion. The prism bends every colour towards its base, red least and violet most ' + '(the spread is exaggerated).', es: 'Dispersión. El prisma desvía todos los colores hacia su base: el rojo, menos, y el ' + 'violeta, más (la separación está exagerada).' }, 'principal-rays': { en: 'The three principal rays from the tip of an object beyond 2F meet at the tip of a real, ' + 'inverted, smaller image (green). Dots mark the foci F; open circles, the points 2F.', es: 'Los tres rayos principales que parten de la punta de un objeto situado más allá de 2F se ' + 'cortan en la punta de una imagen real, invertida y menor (verde). Los puntos marcan los ' + 'focos F, y los círculos, los puntos 2F.' }, 'diverging': { en: 'A diverging lens. Parallel rays leave as if they came from the focus in front of the ' + 'lens (dashed lines), so the image is virtual.', es: 'Una lente divergente. Los rayos paralelos salen como si vinieran del foco situado delante ' + 'de la lente (líneas a trazos), así que la imagen es virtual.' }, }; // #region art: seven diagrams drawn in code: amber rays, green glass, no text const f1 = (n) => Math.round(n * 10) / 10; const pts = (list) => list.map(([x, y]) => `${f1(x)} ${f1(y)}`).join('L'); const svg = (width, height, body) => ({ width, height, markup: '<svg ' + `xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` + `viewBox="0 0 ${width} ${height}">${body}</svg>` }); const stroke = (list, color, width, extra = '') => `<path d="M${pts(list)}" fill="none" ` + `stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"` + `${extra}/>`; const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const deg = (a) => (a * Math.PI) / 180; // degrees to radians // An arrowhead is a path, never a <marker> (gotcha: svg-no-marker-filters). const tip = ([x, y], [dx, dy], color, s = 12) => { const l = Math.hypot(dx, dy); const [u, v] = [dx / l, dy / l]; return shape(`M${pts([[x + u * s, y + v * s], [x - v * s * 0.5, y + u * s * 0.5], [x + v * s * 0.5, y - u * s * 0.5]])}Z`, color); }; // A ray through its points, with an arrowhead halfway along the first segment. const ray = (list, color = palette.ray, width = 3, at = 0.5) => { const [[x0, y0], [x1, y1]] = list; return stroke(list, color, width) + tip([x0 + (x1 - x0) * at, y0 + (y1 - y0) * at], [x1 - x0, y1 - y0], color, width * 4); }; const dot = (x, y, r, fill, extra = '') => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" ` + `fill="${fill}"${extra}/>`; const lens = (x, top, bottom, bulge, fill, line, width = 2.5, extra = '') => { const mid = (top + bottom) / 2; return shape(`M${x} ${top}Q${x + bulge} ${mid} ${x} ${bottom}Q${x - bulge} ${mid} ${x} ${top}Z`, fill, ` stroke="${line}" stroke-width="${width}"${extra}`); }; // 4.1 · A burning glass on a dark panel: the Sun, seven parallel rays, the focus on a card. function burningGlass() { const [W, H, LX, FX, AX] = [1162, 540, 470, 900, 270]; const ys = [120, 170, 220, 270, 320, 370, 420]; const cone = `M${LX} ${ys[0]}L${FX} ${AX}L${LX} ${ys[6]}Z`; return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>` + shape(cone, palette.ray, ' fill-opacity=".1"') + dot(-60, AX, 200, palette.ray) + dot(-60, AX, 150, palette.paper, ' fill-opacity=".2"') + ys.map((y) => ray([[200, y], [LX, y], [FX, AX]], palette.ray, 3, 0.55)).join('') + lens(LX, 60, 480, 80, palette.glass, palette.paper, 3, ' fill-opacity=".3"') + [34, 22, 13].map((r, i) => dot(FX, AX, r, palette.ray, ` fill-opacity="${0.12 + i * 0.14}"`)) .join('') + dot(FX, AX, 6, palette.paper) + shape(`M${FX + 2} 150H${FX + 12}V390H${FX + 2}Z`, palette.paper, ' fill-opacity=".85"')); } // 4.2 · Refraction at an air-glass boundary: the ray bends towards the normal. function refraction() { const [W, H, X, Y, L] = [528, 360, 250, 172, 250]; const [si, ci] = [Math.sin(deg(50)), Math.cos(deg(50))]; const sr = si / 1.52; const cr = Math.sqrt(1 - sr * sr); const wedge = (dy, ux, uy, color) => shape(`M${X} ${Y}L${X} ${Y + dy}A80 80 0 0 0 ` + `${f1(X + ux * 80)} ${f1(Y + uy * 80)}Z`, color, ' fill-opacity=".45"'); return svg(W, H, shape(`M0 ${Y}H${W}V${H}H0Z`, palette.glass) + stroke([[0, Y], [W, Y]], palette.ink, 2.5) + stroke([[X, 14], [X, H - 14]], palette.muted, 2, ' stroke-dasharray="10 8"') + wedge(-80, -si, -ci, palette.ray) + wedge(80, sr, cr, palette.accent) + ray([[X - si * L, Y - ci * L], [X, Y], [X + sr * 205, Y + cr * 205]])); } // 4.3 · A semicircular block: a shallow ray escapes, a steep one is totally reflected. function criticalAngle() { const [W, H, X, Y, R] = [528, 372, 264, 110, 250]; const inside = (a, len) => [X - Math.sin(deg(a)) * len, Y + Math.cos(deg(a)) * len]; const out = Math.asin(1.52 * Math.sin(deg(25))); return svg(W, H, shape(`M${X - R} ${Y}A${R} ${R} 0 0 0 ${X + R} ${Y}Z`, palette.glass, ` stroke="${palette.ink}" stroke-width="2.5"`) + stroke([[X, 10], [X, Y + R - 10]], palette.muted, 2, ' stroke-dasharray="10 8"') + ray([inside(25, R - 8), [X, Y], [X + Math.sin(out) * 150, Y - Math.cos(out) * 150]]) + ray([inside(58, R - 8), [X, Y], [X + Math.sin(deg(58)) * (R - 8), Y + Math.cos(deg(58)) * (R - 8)]], palette.accent, 3, 0.45)); } // 4.4 · An optical fibre on a dark panel: light zigzags along the core, reflected at each wall. function fibre() { const [W, H, CORE_TOP, CORE_BOT, END] = [1162, 360, 140, 220, 1080]; const zig = [[20, 96], [70, 180]]; // from the source into the core, then wall to wall for (let x = 145, i = 0; x < END; x += 150, i++) zig.push([x, i % 2 ? CORE_TOP : CORE_BOT]); const [lx, ly] = zig.at(-1); const exit = [END, ly + ((ly === CORE_BOT ? CORE_TOP : CORE_BOT) - ly) * ((END - lx) / 150)]; const glow = ([x, y], radii) => radii.map((r, i) => dot(x, y, r, palette.ray, ` fill-opacity="${0.2 + (i * 0.6) / radii.length}"`)).join(''); return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>` + shape(`M70 100H${END}V260H70Z`, palette.glass, ' fill-opacity=".14"') // the cladding + shape(`M70 ${CORE_TOP}H${END}V${CORE_BOT}H70Z`, palette.glass, ' fill-opacity=".3"') + [100, 260].map((y) => stroke([[70, y], [END, y]], palette.paper, 2, ' stroke-opacity=".35"')) .join('') + [-70, 0, 70].map((dy) => stroke([exit, [W, exit[1] + dy]], palette.ray, 3, ' stroke-opacity=".8"')).join('') + glow(exit, [26, 15]) + glow(zig[0], [30, 18, 9]) + ray([...zig, exit], palette.ray, 3.5, 0.5) + zig.slice(2, 6).map((p, i) => tip([(p[0] + zig[i + 3][0]) / 2, (p[1] + zig[i + 3][1]) / 2], [zig[i + 3][0] - p[0], zig[i + 3][1] - p[1]], palette.ray, 14)).join('')); } // 4.5 · Dispersion on a dark panel: a white beam crosses a prism at minimum deviation, and each // colour leaves bent towards the base, red least and violet most (the spread is exaggerated). function prism() { const [W, H, SX, BEAM] = [1760, 720, 1690, 8]; // the panel, the screen's x, half the beam const hues = ['#e5484d', '#f0892a', '#f5cf3a', '#58b86b', '#3b8fd0', '#4f5ab8', '#7c4fb8']; const [A, B, C] = [[800, 75], [580, 485], [1020, 485]]; // apex, base left, base right const along = (p, d, t) => [p[0] + d[0] * t, p[1] + d[1] * t]; const into = (q, r) => { // the unit normal of the face q→r that points into the glass const l = Math.hypot(r[0] - q[0], r[1] - q[1]); return [(q[1] - r[1]) / l, (r[0] - q[0]) / l]; }; // Snell's law with vectors: m is the face normal against the ray, eta = n before ÷ n after. const refract = (d, m, eta) => { const c = -(d[0] * m[0] + d[1] * m[1]); return along([eta * d[0], eta * d[1]], m, eta * c - Math.sqrt(1 - eta * eta * (1 - c * c))); }; const meet = (p, d, [q, r]) => { // where the ray from p along d crosses the line q–r const [ex, ey] = [r[0] - q[0], r[1] - q[1]]; return along(p, d, ((q[0] - p[0]) * ey - (q[1] - p[1]) * ex) / (d[0] * ey - d[1] * ex)); }; // At minimum deviation the beam crosses the glass parallel to the base: it rises to the first // face at half the deviation of the middle colour (n = 1.52), and every colour falls after. const half = Math.atan2(C[0] - A[0], C[1] - A[1]); // half the apex angle const lift = Math.asin(1.52 * Math.sin(half)) - half; const d0 = [Math.cos(lift), -Math.sin(lift)]; const across = [Math.sin(lift), Math.cos(lift)]; // square to the beam, downwards const mid = along(A, [B[0] - A[0], B[1] - A[1]], 0.5); // the beam meets the first face halfway const slit = along(mid, d0, (70 - mid[0]) / d0[0]); const edge = (s) => along(slit, across, s * BEAM); // s = -1: the beam's upper edge; 1: lower const [top, bottom] = [meet(edge(-1), d0, [B, A]), meet(edge(1), d0, [B, A])]; // The seven bands' eight edges, red (0) to violet (7), each refracted with its own index. const edges = Array.from({ length: 8 }, (_, k) => { const n = 1.46 + k * 0.02; const p = along(top, [bottom[0] - top[0], bottom[1] - top[1]], k / 7); const inside = refract(d0, into(A, B), 1 / n); const out = meet(p, inside, [A, C]); return [p, out, meet(out, refract(inside, into(A, C), n), [[SX, 0], [SX, H]])]; }); const ys = edges.map(([, , hit]) => hit[1]); const jaw = (from, to) => shape(`M${pts([edge(from), edge(to), along(edge(to), d0, -30), along(edge(from), d0, -30)])}Z`, palette.muted); return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>` + jaw(-1.3, -7.5) + jaw(1.3, 7.5) // the slit + shape(`M${pts([edge(-1), top, bottom, edge(1)])}Z`, palette.paper, ' fill-opacity=".95"') + shape(`M${pts([top, edges[0][1], edges[7][1], bottom])}Z`, palette.paper, ' fill-opacity=".45"') + hues.map((hue, i) => shape(`M${pts([edges[i][1], edges[i][2], edges[i + 1][2], edges[i + 1][1]])}Z`, hue, ' fill-opacity=".85"')).join('') + shape(`M${pts([A, B, C])}Z`, palette.glass, ` fill-opacity=".16" stroke="${palette.paper}" ` + 'stroke-opacity=".75" stroke-width="3" stroke-linejoin="round"') + shape(`M${pts([A, [A[0] + 40, B[1]], C])}Z`, palette.paper, ' fill-opacity=".07"') // a facet + shape(`M${SX} ${f1(Math.min(...ys) - 10)}H${SX + 16}V${f1(Math.max(...ys) + 10)}H${SX}Z`, palette.paper, ' fill-opacity=".25"') // the screen + tip(along(slit, d0, 260), d0, palette.ink, 16)); } // 4.6 · The three principal rays of a converging lens meet at the tip of a real image. function principalRays() { const [W, H, AX, LX, F] = [1162, 470, 235, 581, 200]; const [ox, oy] = [121, 95]; // the object's tip, beyond 2F const v = 1 / (1 / F - 1 / (LX - ox)); // the lens formula gives the image distance const [ix, iy] = [LX + v, AX + (AX - oy) * (v / (LX - ox))]; const along = (p, q, x) => [x, p[1] + ((q[1] - p[1]) * (x - p[0])) / (q[0] - p[0])]; const hit = along([ox, oy], [LX - F, AX], LX); // where the ray through F meets the lens const arrow = (x, y, color) => stroke([[x, AX], [x, y + Math.sign(AX - y) * 18]], color, 5) + tip([x, y + Math.sign(AX - y) * 20], [0, y - AX], color, 20); return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.tint}"/>` // a light plate + stroke([[0, AX], [W, AX]], palette.muted, 1.5) + lens(LX, 30, 440, 70, palette.glass, palette.ink) + [LX - 2 * F, LX + 2 * F].map((x) => dot(x, AX, 6, palette.paper, ` stroke="${palette.ink}" stroke-width="2.5"`)).join('') + [LX - F, LX + F].map((x) => dot(x, AX, 7, palette.ink)).join('') + ray([[ox, oy], [LX, oy], along([LX, oy], [LX + F, AX], 1110)], palette.ray, 3, 0.45) + ray([[ox, oy], along([ox, oy], [LX, AX], 1110)], palette.ray, 3, 0.28) + ray([[ox, oy], hit, [1110, hit[1]]], palette.ray, 3, 0.6) // through F, then parallel + arrow(ox, oy, palette.ink) + arrow(ix, iy, palette.accent) + dot(ix, iy, 7, palette.ray)); } // 4.7 · A diverging lens spreads parallel rays as if they came from the focus in front of it. function diverging() { const [W, H, AX, LX, F, OUT] = [528, 380, 190, 300, 150, 185]; // A ray leaves the lens along the line from the virtual focus, and every one runs OUT px. const away = (y) => { const l = Math.hypot(F, y - AX); return [LX + (F * OUT) / l, y + ((y - AX) * OUT) / l]; }; return svg(W, H, stroke([[0, AX], [W, AX]], palette.muted, 1.5) + shape(`M${LX - 26} 40H${LX + 26}Q${LX + 4} ${AX} ${LX + 26} 340H${LX - 26}Q${LX - 4} ${AX} ` + `${LX - 26} 40Z`, palette.glass, ` stroke="${palette.ink}" stroke-width="2.5"`) + dot(LX - F, AX, 7, palette.ink) + [105, 150, 230, 275].map((y) => stroke([[LX - F, AX], [LX, y]], palette.muted, 1.5, ' stroke-dasharray="8 7"') + ray([[20, y], [LX, y], away(y)], palette.ray, 3, 0.55)).join('') + ray([[20, AX], [LX + OUT, AX]], palette.ray, 3, 0.3)); } // #endregion // #region figures: where each diagram goes, set by its placement and its first citation const drawings = new Map(); // fileId → SVG markup, registered before the build const figure = (id, { width, height, markup }, placement) => { drawings.set(`${id}.svg`, markup); return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption: t(captions[id]), altText: t(captions[id]), // read aloud in HTML and tagged PDF; the canvas does not use it svg: { fileId: `${id}.svg`, width, height }, ...(placement && { placement }) }; }; const resources = [ // no placement: a main-column float, its caption in the channel figure('burning-glass', burningGlass()), figure('refraction', refraction(), side), figure('critical-angle', criticalAngle(), side), figure('fibre', fibre()), figure('prism', prism(), { span: 'page', position: 'top' }), // across text column and channel figure('principal-rays', principalRays()), figure('diverging', diverging(), side), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Merriweather: ['300', '300i', '400i', '700'], 'Merriweather Sans': ['300', '300i', '700', '800'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([...drawings].map(([fileId, markup]) => loadSvg(fileId, markup))); // #region build: chapter 4 of a longer book, so the counters start where chapter 3 ended const continuation = { pageNumbering: { startAt: 87 }, // odd, like page 1: a recto headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Textbook with a margin column', es: 'Libro de texto con columna al margen' }) }); // #endregionKit · core, fonts, viewer, images: igual a totes les receptes · 270 línies
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Deixa els peus sota les figures
Amb els valors per defecte del tipus figura, els peus de les figures 4.1, 4.4 i 4.6 ocupen línies de la columna de text, i el canal queda només per a diagrames i glosses.
-const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
- : { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale
+const resourceTypes = defaultResourceTypes(LANG); // gotcha: resource-types-locale#Posa el canal a la dreta a totes les pàgines
Per a un document que es llegeix pàgina a pàgina en pantalla, desactiva els marges simètrics i passa a la vora dreta el foli i la capçalera de les parelles; així els capítols poden obrir-se a qualsevol pàgina.
- sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored)
+ sideColumnSide: 'right', // what 'outer' means anyway once the margins stop mirroring
- bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, // left: recto's inner
+ bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: false } },
- { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(LEAD),
+ { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(LEAD),
-const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge
+const verso = { parity: 'even', edge: 'top-right' };
- head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }),
- head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }),
+ head({ id: 'verso-folio', ...verso, ...folio, x: -OUTER }),
+ head({ id: 'verso-title', ...verso, content: '{title}', x: -(OUTER + HEAD_GAP) }),
- head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
+ head({ id: 'drop-folio', ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,#Obre el capítol sota una banda de color
Obertura de capítol sobre banda a sang porta una banda de color a sang per dalt de la pàgina i hi posa a sobre el títol i un número de capítol de 168 pt.
Errors freqüents
Error freqüent
Els requadres laterals no floten: esperen lloc
Un requadre amb span: 'side' no flota: es col·loca al costat del bloc al qual segueix i, si el canal del marge és ple, espera la pàgina següent. Posa cada glossa just després del paràgraf que explica. Notes al marge →
Error freqüent
Un requadre lateral després d'un títol sagna el paràgraf següent
A postext 1.4.1, un requadre amb span: 'side' entre un títol i el seu primer paràgraf dona a aquest paràgraf sagnat de primera línia, encara que indentAfterHeading sigui false: el requadre surt del flux, però els seus blocs continuen comptant com el bloc que segueix el títol. Col·loca el requadre després del primer paràgraf. Notes al marge →
Error freqüent
Un flotant 'top' no cau mai a la pàgina que el cita
Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita. Col·locació de figures →
Error freqüent
Una obertura reserva alçada fins al seu element ancorat més baix
Una obertura de disseny avançat reserva l'alçada del seu element més baix, i també hi compten els ancorats a la pàgina o a la sang que queden per sota del títol, així que un ornament al peu de la pàgina empeny el text a la següent. Deixa aquests ornaments per sobre del títol, passa'ls a una ranura de capçalera o de peu, o fixa la reserva amb minHeight. Obertures dissenyades →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Tradueix Figura i Taula amb defaultResourceTypes(locale)
El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
Error freqüent
Un espai de no separació continua partint la línia
A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase. Escapaments i caràcters literals →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Sense <marker> ni filtres als SVG, o passen a mapa de bits
Una figura SVG només continua sent vectorial al PDF sense <marker>, filtres ni màscares; si no, passa a mapa de bits, i els filtres molt imbricats la poden deixar en blanc a Chrome. Dibuixa les puntes de fletxa com a traçats. Figures i taules com a recursos →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
- Figures i glosses s'apilen al canal en l'ordre en què el text hi arriba, mai l'una al costat de l'altra. La glossa de l'angle límit va després del paràgraf de la llei de Snell i abans del que cita la figura 4.3, així que a la pàgina 88 s'apila entre les figures 4.2 i 4.3, al costat del paràgraf que presenta el terme. Amb la tanca després d'aquesta cita, cauria sota la figura 4.3.
- A la primera pàgina d'un capítol, cita les figures del marge només després del requadre d'objectius. Les figures laterals s'apilen des del capdamunt del canal sense deixar lloc a l'avanttítol ni al número ancorats allà, així que una figura citada al primer paràgraf es pinta a sobre d'ells.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- Merriweather (SIL OFL 1.1) · Merriweather Sans (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


