Lo que vas a componer
Cartel de un club de astronomía ficticio para cuatro charlas, los jueves de abril, antes del eclipse total de Sol del 2 de agosto de 2027. Un cielo azul ultramar llena los dos tercios superiores de un A3. Una Luna negra tapa un Sol amarillo limón cortado por el borde derecho, y detrás de ambos se extiende una corona dibujada con código. El título, Vuelve la sombra en Funnel Display de 120 pt, se apoya en su entradilla al pie del cielo. Bajo el horizonte, cuatro fechas azules encabezan las charlas, y la letra pequeña va a dos columnas sobre una banda azul. Cada elemento cuelga de la sangre, de la página, de su ranura o de otro, y sus desplazamientos se miden desde ahí. La segunda hoja enmarca en rosa la caja de cada elemento y los contenedores de cabecera y pie, y rotula trece elementos con su anclaje.
Esta receta responde a
- ¿Cómo coloco los elementos de diseño respecto a la página, la sangre o entre sí, en lugar de por coordenadas?
- ¿Cómo añado una marca de agua, un fondo de color o una imagen decorativa en todas las páginas?
La respuesta corta
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
// 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
{ kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
// The headline stands on the horizon and grows upwards: 'above' puts an element's
// bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
Ingredientes
- Funciones
- Anclaje de elementos de diseñoTextos, filetes y cajas en los diseños de páginaImágenes en los diseños de páginaAperturas diseñadasBanda de capítulo a todo el anchoBandas a sangre y pestañasEstilos de títuloCabeceras por secciónAtributos de títuloSaltos de línea en los títulosPaleta de color semánticaEstilos de párrafoFiguras y tablas como recursosPáginas en un canvas
- También usa
- Sangrado y marcas de corte
- Tipografía
- Funnel Display, Funnel Sans, Martian Mono (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Ancla dos elementos a la hoja y cuelga los demás de ellos
El código es la respuesta corta de arriba. En él, anchor.to dice de qué cuelga un elemento: 'bleed' y 'page' son marcos de la hoja (la misma caja mientras page.cutLines esté desactivado), 'container' es la caja de la propia ranura y '#id' es otro elemento (posicionamiento de elementos). Una caja sin ancho llega hasta el borde opuesto de su marco, así que el cielo ocupa la sangre de lado a lado. Un elemento colocado respecto al cielo se mide desde el borde izquierdo del cielo, que es el de la sangre; por eso la x de la entradilla es INSET, 18 mm. El titular se monta de abajo arriba desde el borde inferior del cielo. 'align-bottom' deja la base de la caja de la entradilla AIR, 14 mm, por encima de ese borde, y 'above' apoya cada elemento sobre el de debajo, de modo que un título con más líneas sube el antetítulo en vez de pisar la entradilla. Bajo el diseño, la letra pequeña empieza en la primera línea de la rejilla base de 16 pt que queda al menos 3,2 mm por debajo del elemento más bajo, la línea de los ponentes; esos 3,2 mm son el margen inferior que el título lleva por defecto.
#2 · Cuelga la Luna y la corona del Sol
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
{ kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
El Sol es el único elemento anclado a la página, que es la caja de corte. Contada desde la esquina 'top-right', una x positiva sale por el borde derecho, y 30 mm del disco quedan fuera. La Luna y la corona cuelgan del Sol con 'align-top', así que se mueven con él. Sus desplazamientos se cuentan desde la esquina superior izquierda del Sol: la Luna se corre 2 mm a la derecha y 2,5 mm hacia abajo, lo que deja un filo de Sol arriba a la izquierda, y la corona empieza HALO, 75 mm, más arriba y más a la izquierda, lo que centra su cuadrado de 300 mm sobre el disco de 150 mm. La corona es un elemento de imagen que toma el tamaño de su caja (elementos de imagen) y va en la lista antes que los discos para pintarse detrás de ellos.
#3 · Encadena las cuatro tardes en columnas
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
: at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
Cada fecha, charla y ponente tiene un size.width fijo de COL, y 'right-of' más GUT pone la fecha siguiente una columna más allá. Sin ancho, una fecha se ajusta a sus cifras (el «8» mide 17 mm), así que el «15» empezaría 7 mm después y cada charla correría en una sola línea hasta meterse en la columna siguiente. COL reparte el ancho del texto entre cuatro con el mismo medianil de 7 mm que las dos columnas de letra pequeña de debajo, por lo que la segunda columna de letra pequeña empieza bajo el 22. Cada charla cuelga 'below' de su fecha y cada ponente de su charla, de modo que una charla que ocupara tres líneas bajaría a su ponente y a ningún otro.
#4 · Lleva la banda al pie, en todas las páginas
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
// The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
{ kind: 'image', id: 'mark', resourceId: 'mark',
placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
Lo que tenga que salir en todas las páginas, como esta banda, va en la ranura de la cabecera o del pie, y un estilo de título con cabecera o pie propios la sustituye en las páginas de su sección. El pie del estilo de las guías repite estos cuatro elementos con sus marcos y sus etiquetas; por eso la banda y el emblema salen en las dos hojas. El contenedor del pie es el margen inferior, del texto al corte: 'bottom-left' cuenta hacia arriba desde el corte y 'top-right' hacia abajo desde el texto, y el −10 del emblema y el +10 de la línea «Entrada libre» caen en la misma franja de 10 mm, en mitad de la banda (encabezados y pies). El pie se pinta después del texto y no le quita sitio, de modo que FOOT es a la vez el margen inferior y la altura de la banda, que así nunca pisa las líneas de encima.
#5 · Dibuja las líneas de construcción desde la misma configuración
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
`${edge} ${to}`, // the two words of anchor: { to, edge }
['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
: el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
: [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
guides() recorre los elementos del cartel. Un texto recibe un borde en su caja, y una caja cuadrada, un borde en su estilo; ninguno de los dos mueve nada, porque solo el padding agranda una caja. Una imagen o un disco redondo recibe una caja cuadrada de su mismo tamaño colgada de su esquina superior izquierda, así que también queda dibujada la esquina del Sol de la que cuelgan la Luna y la corona. Cada etiqueta es un texto colgado de su elemento y escrito por describe() a partir del placement de ese elemento, así que ninguna etiqueta puede contradecir la configuración que rotula. {style="guides"} en el segundo título trae este diseño, con una cabecera y un pie propios (estilos de encabezado). Una etiqueta sin ancho solo dispone del espacio entre su ancla y el borde derecho del contenedor; por eso la etiqueta de #free empieza 30 mm a la izquierda de su texto. pageIndexOffset: 1, en la continuation del contenido, convierte el cartel en página par, y así el visor pone las dos hojas una junto a otra.
La receta completa
// ═══ Postext Cookbook · Nº 029 · Anchoring cheat sheet: a poster built from chained elements ═══ // https://postext.dev/en/cookbook/anchoring-cheat-sheet // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Funnel Display, Funnel Sans, Martian Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // An A3 poster whose elements hang from the bleed, the page, their slot or one another, never // from coordinates; the second sheet is the same poster with each element framed and tagged. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'anchoring-cheat-sheet'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#0b0b0c', // text, and the Moon sky: '#1b3bff', // the sky, the foot band and the dates sun: '#ffe53b', // the Sun and the kicker paper: '#ffffff', // type on the sky muted: '#5c5f66', // speakers and the colophon guide: '#ff2d9b', // frames and tags on the construction sheet }; // col(id): a palette-linked colour that also carries its hex, because 1.4.1 paints design // elements from the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'sky (defaults)', value: { hex: palette.sky, model: 'hex' } }, ]; const A3 = { width: 297, height: 420 }; // mm, the trim const MARGIN = 18; // mm at the top and sides: the body's edges, and the header container's const FOOT = 30; // mm, the bottom margin: the footer container and its band const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below // at(): a placement. Offsets are distances from the anchor point, never page coordinates. const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const wide = (w) => ({ width: mm(w) }); // fixed, so a text is as wide as its column const square = (d) => ({ width: mm(d), height: mm(d) }); // Design text is centred and cut with '…' by default (gotcha: overflow-ellipsis-default). const text = (id, content, face, placement) => ({ kind: 'text', id, content, overflow: 'wrap', align: 'left', ...face, placement }); const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size), fontWeight, color: col(colour), ...more }); const caps = (tracking) => ({ letterSpacing: pt(tracking), textTransform: 'uppercase' }); const FACE = { // lineHeight: a multiple, never pt() (gotcha: design-lineheight-multiple) kicker: face('Martian Mono', 12, 500, 'sun', caps(1.8)), title: face('Funnel Display', 120, 800, 'paper', { lineHeight: 0.86 }), deck: face('Funnel Sans', 22, 400, 'paper', { lineHeight: 1.22 }), day: face('Funnel Display', 84, 800, 'sky', { lineHeight: 0.9 }), talk: face('Funnel Sans', 19, 600, 'ink', { lineHeight: 1.14 }), who: face('Funnel Sans', 12.5, 400, 'muted', { lineHeight: 1.3, italic: true }), foot: face('Martian Mono', 9, 500, 'paper', caps(0.9)), }; // #region answer: two elements pinned to frames of the sheet, every other one to an element const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge const AIR = 14; // mm between the standfirst and the horizon const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box. { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) }, ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun // The headline stands on the horizon and grows upwards: 'above' puts an element's // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker. text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR), size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\ text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)), ...programme(), // four columns chained off the horizon ] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() } // {titleText} is the heading's text; each {attr.*} is written on its line in content.md. // #endregion // #region eclipse: the Sun pinned to the trim's corner, the Moon and the corona to the Sun const SUN = 150; // mm across; the Moon is drawn the same size const CORONA = 300; // mm, the square picture of the corona const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement, size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } }); const eclipse = () => [ { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) }, disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)), ]; // #endregion // #region programme: four evenings in fixed-width columns, chained right-of and below const GUT = 7; // mm between the evenings, and between the two columns of text under them const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm const DROP = 12; // mm from the horizon to the dates const programme = () => [1, 2, 3, 4].flatMap((n) => [ text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1 ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))), text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))), ]); // #endregion // #region footer: a band and the club's mark in the footer slot, which prints on every page const MARK = 10; // mm, the club's mark const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it const footer = { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) }, // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down. { kind: 'image', id: 'mark', resourceId: 'mark', placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) }, text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)), text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)), ] }; // #endregion // #region guides: the same slots, each element framed and tagged with its own placement const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) }; const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`, `${edge} ${to}`, // the two words of anchor: { to, edge } ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '), ].filter(Boolean).join(' · '); // A border never changes an element's size (padding does), so each frame traces its box. // An image or a rounded box gets a square frame of its size, hung from its top-left corner. const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }] : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }] : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE, placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]); const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el), face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'), padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }), at(`#${el.id}`, edge, x, y)); const guides = (elements, tags) => [...elements.flatMap(frame), ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))]; const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) }); const TAGS = { // where each tag sits against its element: [edge, x, y, text] sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'], moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'], day1: ['above', 0, -6], day2: ['above'], who1: ['below'], mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2], header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })], footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })], }; // #endregion const LEAD = 16; // body leading in pt const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) colorPalette, page: { width: mm(A3.width), height: mm(A3.height), dpi: 150, // 150 dpi is for the screen cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) }, margins: { top: mm(MARGIN), bottom: mm(FOOT), left: mm(MARGIN), right: mm(MARGIN) } }, layout: { layoutType: 'double', gutterWidth: mm(GUT) }, // the text columns under the evenings bodyText: { fontFamily: 'Funnel Sans', fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // The hidden title is still measured, so it needs a face FONTS loads. headings: { fontFamily: 'Funnel Display', fontWeight: 800, levels: [{ level: 1, span: 'page', advancedDesign: poster(), breakBefore: { enabled: true, parity: 'any' } }] }, // gotcha: headings-drop-h1-break // {style="guides"}: the same design framed and tagged, and a header and footer of its own. headingStyles: [{ id: 'guides', advancedDesign: { enabled: true, slot: { elements: guides(poster().slot.elements, TAGS) } }, header: { elements: guides([container('header')], TAGS) }, footer: { elements: guides([...footer.elements, container('footer')], TAGS) } }], paragraphStyles: [{ id: 'colophon', fontFamily: 'Martian Mono', fontSize: pt(8), lineHeight: pt(LEAD * 0.75), color: col('muted') }], header: { elements: [] }, // the poster has none; the guides page draws the empty container footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`# Vuelve\\la\\sombra {kicker="Jueves de abril de 2027 · 19:30" deck="Cuatro charlas para preparar el eclipse total del 2 de agosto de 2027, cuando la sombra de la Luna cruce el Estrecho." d1="8" t1="Por qué la Luna tapa justo el Sol" s1="Marta Iribarren, astrónoma" d2="15" t2="Por dónde cruzará la sombra" s2="Óscar Beltrán, meteorólogo" d3="22" t3="Lo que nos cuenta la corona" s3="Lucía Ferrándiz, física" d4="29" t4="Mirar al Sol sin quemarse los ojos" s4="Tomás Rey, optometrista" club="Círculo Umbra · astrónomos aficionados desde 1987" free="Entrada libre · sin reserva"}Muestra en Markdown · 14 líneas · content.es.md
Cada charla empieza a las 19:30 y dura alrededor de una hora, con preguntas. La sede del Círculo Umbra está en la calle del Faro, 14; las puertas se abren a las 19:00, la entrada es libre y no hay reserva, así que las 120 butacas son para quien llegue antes. El 29 de abril cada asistente se lleva unas gafas de eclipse conformes a la norma ISO 12312-2: úsalas en cada fase parcial y quítatelas solo mientras el Sol esté cubierto del todo. La mañana del eclipse, el Círculo monta telescopios con filtro en el muelle. # Vuelve\\la\\sombra {style="guides" kicker="Jueves de abril de 2027 · 19:30" deck="Cuatro charlas para preparar el eclipse total del 2 de agosto de 2027, cuando la sombra de la Luna cruce el Estrecho." d1="8" t1="Por qué la Luna tapa justo el Sol" s1="Marta Iribarren, astrónoma" d2="15" t2="Por dónde cruzará la sombra" s2="Óscar Beltrán, meteorólogo" d3="22" t3="Lo que nos cuenta la corona" s3="Lucía Ferrándiz, física" d4="29" t4="Mirar al Sol sin quemarse los ojos" s4="Tomás Rey, optometrista" club="Círculo Umbra · astrónomos aficionados desde 1987" free="Entrada libre · sin reserva"} **Cómo leer esta hoja.** Cada marco rosa traza la caja de un elemento, y una etiqueta da su id, el borde por el que se ancla, a qué se ancla y el desplazamiento en milímetros, si lo hay. Por encima del pie, solo dos elementos cuelgan de la hoja misma: el cielo, de la sangre, y el Sol, de la página, que es el corte. Todos los demás cuelgan de otro elemento, así que un título más largo sube el antetítulo y las cuatro tardes siguen al horizonte. Los marcos de los márgenes superior e inferior son los contenedores de la cabecera y del pie. El del pie va del texto al corte, así que las anclas de abajo cuentan hacia arriba desde el corte y las de arriba, hacia abajo desde el texto. :::paragraphs{style="colophon"} Tipos: Funnel Display, Funnel Sans y Martian Mono (SIL OFL) · Texto y dibujos: originales, CC BY 4.0 · El Círculo Umbra y sus ponentes son ficticios. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the corona and the club's mark, drawn in code, and their resources function mulberry32(seed) { // a seeded generator: the same corona on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function coronaSvg() { // a glow, then 14 streamers, 70 polar plumes and 160 fine rays const rand = mulberry32(20270802); const R = (500 * SUN) / CORONA; // the Sun's radius in the 1000-unit square const TILT = Math.PI / 4; // the Sun's equator: long streamers go up-left, clear of the kicker const TYPE = [2.2, 3.05]; // radians from the centre where the type is: rays there stop at 9 mm const p = (r, a) => `${(500 + r * Math.cos(a)).toFixed(1)} ${(500 + r * Math.sin(a)).toFixed(1)}`; const ray = (a, full, half, opacity) => { // a petal from the limb, tapered to a point const turn = ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); const length = turn > TYPE[0] && turn < TYPE[1] ? Math.min(full, R * 0.12) : full; const bend = (rand() - 0.5) * 0.06; return `<path d="M${p(R * 0.97, a - half)}C${p(R + length * 0.35, a - half * 1.1)} ` + `${p(R + length * 0.7, a + bend - half * 0.25)} ${p(R + length, a + bend)}` + `C${p(R + length * 0.7, a + bend + half * 0.25)} ${p(R + length * 0.35, a + half * 1.1)} ` + `${p(R * 0.97, a + half)}Z" fill="${palette.paper}" fill-opacity="${opacity.toFixed(3)}"/>`; }; const glow = Array.from({ length: 28 }, (_, i) => `<circle cx="500" cy="500" ` + `r="${(R * (1.01 + i * 0.022)).toFixed(1)}" fill="${palette.paper}" fill-opacity="0.022"/>`) .reverse().join(''); const out = []; for (let i = 0; i < 14; i++) { // helmet streamers, two fans across the equator const a = TILT + (i % 2) * Math.PI + (rand() - 0.5) * 1.1; out.push(ray(a, R * (0.55 + 0.45 * rand()), 0.1 + 0.14 * rand(), 0.06 + 0.06 * rand())); } for (let i = 0; i < 70; i++) { // polar plumes, shorter and thinner than the streamers const a = TILT + Math.PI / 2 + (i % 2) * Math.PI + (rand() - 0.5) * 1.3; out.push(ray(a, R * (0.18 + 0.3 * rand()), 0.008 + 0.012 * rand(), 0.1 + 0.12 * rand())); } for (let i = 0; i < 160; i++) { // fine rays all round const a = (i / 160) * 2 * Math.PI + (rand() - 0.5) * 0.04; out.push(ray(a, R * (0.12 + 0.3 * rand()), 0.006 + 0.014 * rand(), 0.05 + 0.07 * rand())); } return `<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1500" ` + `viewBox="0 0 1000 1000">${glow}${out.join('')}</svg>`; } function markSvg() { // the Umbra Circle's mark: a ring round an eclipsed Sun return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100">` + `<circle cx="50" cy="50" r="45" fill="none" stroke="${palette.paper}" stroke-width="6"/>` + `<circle cx="50" cy="50" r="27" fill="${palette.sun}"/>` + `<circle cx="56" cy="45" r="27" fill="${palette.sky}"/></svg>`; } const svg = (id, size, altText) => ({ id, typeId: 'figure', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: size, height: size }, createdAt: 0, updatedAt: 0 }); // Image elements draw resources. Nothing cites them, so neither is placed or numbered as a figure. const resources = [ svg('corona', 1500, t({ en: 'The solar corona round the eclipsed Sun', es: 'La corona solar alrededor del Sol eclipsado' })), svg('mark', 200, t({ en: 'The Umbra Circle’s mark', es: 'El emblema del Círculo Umbra' })), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Funnel Display': ['800'], 'Funnel Sans': ['400', '400i', '600', '700'], 'Martian Mono': ['400', '500'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('corona.svg', coronaSvg()); await loadSvg('mark.svg', markSvg()); await loadFonts(FONTS, markdown); // pageIndexOffset 1 makes the poster a verso, so the viewer sets the two sheets side by side. const content = { markdown, resources, continuation: { pageIndexOffset: 1 } }; const doc = await buildWithFonts(() => buildDocument(content, config()), markdown); showPages(doc, { title: t({ en: 'Anchoring cheat sheet', es: 'Chuleta de anclajes' }) });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
#Añade 3 mm de sangre y marcas de corte
Con 3 mm de BLEED se activan las marcas de corte, y el cielo y la banda del pie, que cuelgan de la sangre, entran 3 mm en ella mientras INSET deja el texto en el margen; el Sol, la Luna y la corona cuelgan de la página, y como la 1.4.1 no recorta los elementos de diseño a la sangre, siguen por el margen blanco hasta el borde de la hoja.
-const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
+const BLEED = 3; // mm; 3 for the printer, which switches on page.cutLines below#Sube el horizonte
Con 20 mm menos de SKY sube el borde del cielo, y con él la entradilla, el título, el antetítulo y los doce textos del programa, mientras la letra pequeña pasa a una línea más alta de la rejilla base; el Sol cuelga de la página, así que el segundo cambio lo sube los mismos 20 mm para que guarde la distancia con el título, que en la edición inglesa llegaría a tocar el filo de Sol.
-const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
+const SKY = 252; // mm from the trim's top edge to the horizon, the sky's lower edge
- disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
+ disc('sun', 'sun', at('page', 'top-right', 30, 5)), // 30 mm of it past the right edgeErrores frecuentes
Error frecuente
Los elementos de cabecera y pie se pintan encima del texto
Los elementos de cabecera y pie se pintan sobre la página y el área de texto no les deja sitio. Mantenlos dentro de los márgenes, que son los que les reservan el espacio. Cabeceras y folios →
Error frecuente
Valores de atributo: sin { ni }; comillas simples si llevan "
Un valor de atributo termina en la llave de cierre, así que no puede contener { ni }. Un valor que lleve comillas dobles va entre comillas simples; el signo de dólar no da problemas. Atributos de título →
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
El lineHeight de un texto de diseño es un múltiplo, nunca una medida
En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →
Error frecuente
Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones
postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →
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
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 →
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 →
Comprobación del Sandbox · designCyclicAnchor
Referencia de anclaje cíclica
Por qué. Los elementos de diseño se anclan unos a otros en bucle, así que ninguno puede colocarse.
Solución. Ancla un elemento de la cadena al contenedor, a la página o a la sangre. Documentación →
Comprobación del Sandbox · designDanglingAnchor
Referencia de anclaje rota
Por qué. Un elemento de diseño se ancla a un #id que no tiene ningún elemento de la ranura.
Solución. Corrige el id o añade el elemento al que se refiere. Documentación →
- En un pen, la versión 1.4.1 no avisa de un anclaje cíclico ni de uno roto. El panel Revisión del Sandbox los señala en la cabecera, el pie, las portadillas de parte y los niveles de título, pero no en los estilos de título como
guides. Un elemento anclado a un#idque no existe se coloca respecto al contenedor: un borde de elemento como'below'cae en la esquina superior izquierda del contenedor, con su desplazamiento, y un borde de contenedor como'top-right', en esa esquina. En un bucle, un elemento cae de la misma manera y los demás se encadenan a él. - En la 1.4.1,
'align-left'y'align-top'llevan al mismo punto: los dos ponen la esquina superior izquierda del elemento sobre la de su ancla. - Un borde se pinta centrado sobre el contorno de la caja, así que cada marco rosa de 1 pt sobresale 0,5 pt de la caja que traza, aunque la documentación de la configuración dice que los trazos se pintan por dentro.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Funnel Display (SIL OFL 1.1) · Funnel Sans (SIL OFL 1.1) · Martian Mono (SIL OFL 1.1)
- Código
- MIT, como Postext


