En pocas palabras
Dile a la maquetación qué parte de cada foto tiene que verse siempre. Cuando a una columna le faltan unas líneas, la foto se alarga en lugar de dejar un hueco.
Lo que vas a componer
Tres páginas de una revista de costa: un reportaje sobre los oficios de un pueblo de ría, con una foto a sangre en la apertura y cinco fotos en las columnas. La receta compone el reportaje dos veces con el mismo texto. En la primera las fotos conservan la forma de su archivo, y en la página 2 las dos columnas se quedan una línea por encima de la rejilla: la izquierda bajo su último párrafo, la derecha encima de la foto del pie. En la segunda cada foto dice qué parte del encuadre tiene que verse siempre, y el motor recorta fuera de ella: en la misma página la mariscadora y la redeira crecen una línea cada una y las dos columnas terminan en la última línea de la rejilla. Nadie ha tenido que elegir los recortes.
Esta receta responde a
- ¿Cómo dejo que una foto se recorte para llenar una columna corta sin que su motivo salga nunca del encuadre?
- ¿Cómo consigo columnas a ras del pie y una última página equilibrada (justificación vertical)?
- ¿Cómo decido dónde va una figura: en la cabeza de la página, a lo ancho de las dos columnas, justo aquí o al margen?
La respuesta corta
// Each photo names the rectangle that must always show, in fractions of the file:
// x and width of its width, y and height of its height. Outside it the engine may crop,
// so the photo can stand taller (sides cut, down to the area's width) or lower (top and
// bottom cut, down to its height) than the file, always as wide as its column.
const SAFE_AREAS = {
mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket
redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap
carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull
faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it
pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron
};
// Columns end flush on this grid by whole lines. A heading or a photo that does not fit a
// column's foot leaves lines empty there; these settings forbid the usual fixes (space above
// a heading, under a photo, or a paragraph run long), so only a photo with a safe area can
// take them, by growing a line at a time. Without safe areas the short columns stay short.
const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
stretchAfterFloats: false, looseParagraphs: false };
Ingredientes
- Funciones
- Zona segura de la imagenEquilibrado de columnasColocación de figurasFiguras y tablas como recursosCitas que colocan las figurasFiguras justo aquíEstilo de los piesTipos de recurso propiosAperturas diseñadasImágenes en los diseños de páginaAtributos de títuloUna o dos columnasMárgenes simétricosCabeceras y foliosCabeceras según el tipo de páginaPaleta de color semánticaEstilos de párrafoPáginas en un canvasExportación a PDF
- También usa
- Figura y Tabla en tu idiomaBanda de capítulo a todo el anchoFuentes incrustadas en el PDF
- Tipografía
- Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1)
- Recursos
carpintero-1600.jpgestuario-1700.jpgfaro-1600.jpgmariscadora-1600.jpgpulpeira-1600.jpgredeira-1600.jpg- La ría en bajamar al amanecer, con mariscadoras (Generated With Diffusion Models, original)
- Una mariscadora de rodillas en la arena mojada (Generated With Diffusion Models, original)
- Una redeira en el muelle (Generated With Diffusion Models, original)
- Un carpintero de ribera cepillando una barca en su nave (Generated With Diffusion Models, original)
- Un faro sobre una punta de granito y una caminante a sus pies (Generated With Diffusion Models, original)
- Una pulpeira sacando un pulpo de un caldero de cobre en el mercado (Generated With Diffusion Models, original)
Elaboración
#1 · Marca lo que tiene que quedar, nada más
El código es la respuesta corta de arriba. La zona segura son cuatro fracciones de la imagen, medidas desde su esquina superior izquierda. El motor puede mostrar la foto con cualquier forma entre el archivo entero y ese rectángulo, siempre con el ancho de su columna: más alta si recorta los lados, más baja si recorta arriba y abajo. Lo que queda fuera se recorta en proporción a los márgenes de cada lado, así que Rosa, a la derecha del centro en el muelle, sigue a la derecha cuando se cortan los lados.
#2 · Que el equilibrado solo tire de las fotos
La misma región configura el equilibrado. Sus recursos habituales añaden blanco: una línea sobre un título, aire bajo una foto, un párrafo una línea más largo. Aquí están apagados, para que la composición sin zonas seguras muestre cada columna corta tal como queda. Una foto con zona segura se prueba antes que cualquiera de ellos, porque una foto más alta no abre ningún hueco en el texto.
#3 · La zona segura es del recurso
const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067
// Caption and alt text of each photo, one block per photo: content.figures.<lang>.md.
const figureTexts = String.raw`mariscadora
Carmen Lago separa berberechos en su parcela de la ría, en bajamar.
Una mujer con botas verdes, de rodillas en la arena mojada junto a un rastro y una cesta.
redeira
Rosa Doval remienda un paño de cerco en el muelle.
Una mujer sentada en un taburete cose una red verde; detrás, barcos de pesca.
carpintero
Manuel Barreiro cepilla el forro de la gamela.
Un hombre mayor cepilla el costado de una barca de madera a medio construir.
faro
La punta de Arnela: el faro y el camino de la costa.
Un faro blanco sobre rocas de granito; abajo, una caminante con mochila roja.
pulpeira
Lucía Fraga saca un pulpo del caldero de cobre.
Una mujer con mandil saca un pulpo cocido de un caldero de cobre humeante.
`;
const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
.map((block) => block.split('\n').map((line) => line.trim()))
.map(([id, caption, alt]) => [id, [caption, alt]]));
const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
const photo = (id, safe) => {
const [w, h] = FILES[id] ?? [1600, 1067];
const [caption, alt] = CAPTIONS[id] ?? [];
return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }),
...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) };
};
const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe));
safeArea está en el recurso, junto a su archivo, su pie y su colocación, de modo que la foto la conserva flote donde flote y en cualquier documento que la use. La receta la pasa o la omite, y esa es la única diferencia entre las dos composiciones. La ría de la apertura la dibuja el estilo del título y no es un flotante, así que no la necesita.
#4 · Compón las dos y compáralas
const build = (safe) => buildWithFonts(() =>
buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };
Los dos documentos salen de la misma configuración y del mismo Markdown. Solo las páginas que continúan se llenan hasta el pie: en la página 3, la última del reportaje, las columnas solo tienen que acabar a la misma altura. Sin zonas seguras la segunda termina una línea antes que la primera; con ellas la foto del pulpo crece esa línea y las dos acaban igualadas. El botón del PDF compone la versión que está sobre la mesa, y el PDF recorta cada foto a su marco de la misma manera.
La receta completa
// ═══ Postext Cookbook · Nº 116 · Photos that grow to fill a short column ═══════════ // https://postext.dev/en/cookbook/photos-fill-short-columns // Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models // Fonts: Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.16.1 // // A magazine feature set twice. Without safe areas some columns end short; with them the // engine crops each photo outside its area to set it taller, and the photos fill those lines. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en') const RECIPE = 'photos-fill-short-columns'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: six named colours, the accents taken from the photos: sea slate, net green const palette = { ink: '#1d2326', // text: a cold near-black sea: '#2f5d73', // the accent: kicker, caption labels, folios, references net: '#3f6b55', // the second colour: the opener's rule rule: '#c9d1d3', // hairlines muted: '#5f6a6e', // running heads, credits, the colophon paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': pointed at the accent, no default blue shows. { id: 'main-color', name: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY, LABEL] = ['Newsreader', 'Archivo', 'Archivo Narrow']; const LEAD = 13.6; // body leading in pt: the grid the photos grow by const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [225, 297, 22, 22, 18, 16, 7]; // mm const PHOTO_H = 150; // the opener's bleed photo: 225 × 150 mm, the shape of the file const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: 'left', overflow: 'wrap', ...extra }); // wrap, not '…' (gotcha: overflow-ellipsis-default) const caps = (size) => ({ fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(size * 0.18) }); // #region answer: a safe area per photo, and balancing that may only grow pictures // Each photo names the rectangle that must always show, in fractions of the file: // x and width of its width, y and height of its height. Outside it the engine may crop, // so the photo can stand taller (sides cut, down to the area's width) or lower (top and // bottom cut, down to its height) than the file, always as wide as its column. const SAFE_AREAS = { mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron }; // Columns end flush on this grid by whole lines. A heading or a photo that does not fit a // column's foot leaves lines empty there; these settings forbid the usual fixes (space above // a heading, under a photo, or a paragraph run long), so only a photo with a safe area can // take them, by growing a line at a time. Without safe areas the short columns stay short. const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false, stretchAfterFloats: false, looseParagraphs: false }; // #endregion // #region opener: the estuary across the head of the page, then kicker, title and standfirst const opener = { enabled: true, // Images reserve no height (gotcha: opener-image-no-reserve): minHeight keeps the text // under the photo, the kicker, the title and the standfirst. minHeight: mm(188), slot: { elements: [ { kind: 'image', id: 'photo', resourceId: 'estuario', // bleeds off the top and both sides placement: at('page', 'top-left', 0, 0, { width: mm(PAGE_W), height: mm(PHOTO_H) }) }, text('kicker', '{attr.kicker}', LABEL, 8.5, 'sea', at('container', 'top-left', 0, PHOTO_H - TOP + 9), caps(8.5)), text('title', '{titleText}', DISPLAY, 34, 'ink', at('#kicker', 'below', 0, 2.5, { width: mm(150), height: 'auto' }), { fontWeight: 800, lineHeight: 1.04 }), { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(2), color: col('net'), placement: at('#title', 'below', 0, 4, { width: mm(16) }) }, text('lead', '{attr.lead}', TEXT, 11.2, 'ink', at('#rule', 'below', 0, 4, { width: mm(150), height: 'auto' }), { italic: true, lineHeight: 1.36, hyphenate: true }), ] }, }; // #endregion // #region furniture: magazine and issue on the verso, the feature's title on the recto const HEAD_Y = 12; // mm from the top edge const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted', at('page', edge, x, HEAD_Y), { ...caps(7.6), fontWeight: 600, parity, pages: 'body', overflow: 'ellipsis', ...extra }); const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'sea', at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra }); const header = { elements: [ folio('verso-folio', 'even', 'top-left', OUTER), head('verso-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }), folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }), ] }; const footer = { elements: [] }; // the opener carries no folio: its photo bleeds off the head // #endregion const types = () => defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' })); // 'Figura 3', not '1.3', in a one-article issue const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales) resourceTypes: types(), // "Figura" in Spanish (gotcha: resource-types-locale) colorPalette, page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('sea'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true, }, headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'), balancing, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontSize: pt(34), span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below ], }, captionStyle: { fontFamily: LABEL, fontSize: pt(8), color: col('ink'), gap: mm(2), labelBold: true, labelColor: col('sea'), descriptionItalic: false, note: { color: col('muted') } }, paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 65 líneas · content.es.md
title: "Mareas" subtitle: "Revista de la costa · Número 14" --- # Gente de la bajamar {kicker="Oficios del mar · Ría de Arnela" lead="Dos veces al día el agua se retira de la ría y deja al descubierto un kilómetro de arena. En ese rato trabajan las mariscadoras; en el muelle, las redeiras cosen lo que el mar rompió la noche anterior. Pasamos una semana de octubre con cinco oficios que siguen el reloj de la marea."} Arnela tiene mil cuatrocientos vecinos, un puerto con once barcos de bajura y una ría que en bajamar se queda casi seca. Desde la carretera de la costa se ve el pueblo entero: las casas blancas apretadas en la ladera, la iglesia arriba y, abajo, la lonja con su tejado de uralita. Lo que no se ve desde arriba es el horario. En Arnela nadie queda a las cinco o a las seis; se queda «a media marea» o «cuando baje», y la tabla de mareas que la cofradía pega cada mes junto a la puerta de la lonja manda más que cualquier reloj. Esta semana la bajamar cae a las siete y cuarto de la mañana y a las siete y media de la tarde. Una hora antes, el arenal ya está lleno de gente. ## A pie de marea Carmen Lago empezó a mariscar a los catorce años, con su madre, y tiene sesenta y uno. Trabaja con un rastro de mango largo, un balde y una cesta de malla, siempre en la misma parcela de la ría, la que la cofradía asigna cada temporada a su grupo de nueve mujeres (:ref{id="mariscadora" case="lower"}). Rastrilla la arena a contrapelo de la corriente, se agacha, aparta con la mano los berberechos que no llegan a la talla mínima y los devuelve al agujero del que salieron. —El berberecho chico se queda —dice—. Si lo coges hoy, en marzo no hay. Cada mariscadora puede sacar al día un cupo fijo por especie, que la cofradía revisa según el estado del banco. En octubre el de Carmen son tres kilos de almeja fina y ocho de berberecho. Lo que pasa del cupo vuelve a la arena. A las nueve, cuando el agua empieza a cubrir las primeras charcas, el grupo sube la cuesta hasta la lonja con los baldes. Allí se lava el marisco, se pesa por mariscadora y se clasifica por calibre antes de la subasta de las once. El trabajo no termina en el arenal. Dos días a la semana el grupo «siembra»: traslada almeja pequeña de las zonas donde se amontona a las que se han quedado vacías, y limpia la arena de algas y de las estrellas de mar que se comen la cría. Nadie cobra por esas horas. «Es como regar la huerta», explica Carmen, que todavía se acuerda de los inviernos en que no había nada que sacar. ## La subasta de las once La lonja de Arnela es una nave alargada con el suelo de hormigón siempre mojado. A las once menos cuarto ya están alineadas sobre la cinta las cajas de la mañana: el marisco de las mariscadoras, separado por especie y calibre, y el pescado de los barcos de bajura, merluza de pincho, jurel, algún rodaballo. Cada caja lleva una etiqueta con el peso, la zona y el nombre de quien la trajo. La subasta es a la baja. Un panel electrónico empieza por un precio alto y va bajando de diez en diez céntimos; el primer comprador que pulsa su mando se queda la caja a ese precio. Compran tres mayoristas de la comarca, dos restaurantes del pueblo y un cocedero que envasa almeja para toda la provincia. En octubre el kilo de almeja fina ronda los veintiséis euros y el de berberecho, los siete. Una mañana buena se subastan dos toneladas en cuarenta minutos. Marisa Couto lleva el registro de la lonja desde hace diecinueve años. Anota en una hoja cada venta, con el comprador y el precio, y a final de mes reparte lo que corresponde a cada mariscadora y a cada barco, descontada la comisión de la cofradía. «Antes se gritaba», recuerda. «Ahora solo se oye el pitido del panel, y a la una ya está todo fregado.» ## Las redeiras del muelle En el extremo del muelle, junto a las cajas apiladas de la lonja, trabaja Rosa Doval sobre un taburete bajo, con una red verde extendida a su alrededor como una alfombra (:ref{id="redeira" case="lower"}). Los barcos de cerco entraron de madrugada con la sardina y con tres paños rotos; uno de ellos lo partió una roca y tiene un agujero en el que cabe una persona. Rosa cose con una aguja de plástico del largo de una mano, cargada de hilo, y una tablilla que fija el tamaño de cada malla. Corta los bordes deshilachados hasta dejar mallas enteras, cuenta las que faltan y teje el remiendo nudo a nudo. Un agujero grande le lleva una mañana. Un paño nuevo, entre doscientos y trescientos metros de red, casi un mes de trabajo de cuatro mujeres. En Arnela quedan siete redeiras. Se organizaron hace doce años, cuando los armadores empezaron a mandar las redes a talleres del interior, y ahora cobran por hora y no por remiendo. La más joven tiene veintiocho años y aprendió en un curso de la cofradía; la mayor, ochenta y uno, y ya solo cose los días de buen tiempo. Rosa prefiere el muelle a la nave que les cedió el ayuntamiento: «Con la red al sol se ven mejor los agujeros, y además me entero de lo que trae cada barco». ## La última carpintería de ribera Al fondo de la ensenada, donde el camino de la costa se convierte en pista de tierra, una nave de madera con las puertas abiertas huele a resina y a pino recién cortado. Es el astillero de Manuel Barreiro, el último de los cinco que llegó a tener la ría. Manuel tiene setenta y cuatro años y trabaja con su sobrino en una gamela de seis metros, una barca de fondo plano que se usaba para el marisqueo a flote (:ref{id="carpintero" case="lower"}). El casco está montado sobre la grada desde agosto. Primero se pone la quilla, después las cuadernas de roble, curvadas al fuego, y por último las tablas de pino que forman el forro, clavadas una a una con clavos de cobre. Manuel no usa planos. Las formas las saca de unas plantillas de tablero que heredó de su padre y que cuelgan de la pared del fondo, cada una con el nombre de la barca para la que se hizo escrito a lápiz. En los años ochenta el astillero botaba quince barcos al año. Ahora hace dos o tres, casi siempre para clubes de remo o para familias que quieren recuperar la barca del abuelo. Esta gamela la encargó la asociación de vecinos, que quiere sacarla en las regatas de verano con los colores del pueblo. Manuel calcula que estará en el agua en abril, si el invierno deja trabajar con las puertas abiertas. ## El faro y el camino Desde el astillero, el camino sube entre tojos hasta la punta de Arnela, donde un faro blanco con la linterna roja marca la entrada de la ría (:ref{id="faro" case="lower"}). Lo automatizaron en 1993, y desde entonces nadie vive en la casa del torrero, pero Xosé Pazos, que lo atendió durante veintidós años, sigue subiendo dos veces por semana a revisar las baterías y a anotar en un cuaderno el viento y el estado de la mar. El sendero que lleva hasta allí forma parte de una ruta de once kilómetros que rodea toda la ría, de playa en playa, y que la mancomunidad señalizó hace tres años con postes de madera y flechas amarillas. En octubre se cruzan pocos caminantes: algún grupo de jubilados, parejas con mochila y, los fines de semana, corredores que bajan a la carrera hasta el puerto. Pazos les indica, si preguntan, por dónde se baja a la cala del Cantal sin resbalar en la roca mojada. Con mal tiempo la ola rompe contra la punta y sube por las rocas hasta la base de la torre. Pazos guarda una foto de 1987 en la que el agua llega a la puerta. Aquel invierno se quedó tres días sin poder bajar al pueblo, con la radio, una caja de latas y el cuaderno. ## El caldero de los domingos El domingo, el mercado de abastos abre a las nueve y a las diez ya hay cola delante del puesto de Lucía Fraga. Lucía cuece el pulpo como lo cocía su madre, en un caldero de cobre de ochenta litros sobre un fuego de leña: lo «asusta» metiéndolo y sacándolo del agua hirviendo tres veces para que la piel no se despegue, lo deja cocer unos veinte minutos y lo saca con un gancho cuando la punta del tentáculo cede al pincharla. ::resource{id="pulpeira"} Lo corta con tijera sobre platos de madera, le echa sal gruesa, pimentón dulce y picante a partes iguales y un chorro de aceite, y lo sirve con cachelos, patatas cocidas en la misma agua. Una ración cuesta doce euros. Los domingos de verano Lucía cuece treinta pulpos; en octubre, catorce o quince, que compra el viernes en la lonja a los barcos de nasa del propio puerto. A las dos de la tarde la marea vuelve a estar baja. En el arenal ya se distinguen los primeros baldes y, en el muelle, una red tendida al sol. :::paragraphs{style="colophon"} Arnela y sus vecinos son imaginarios · Compuesto en Newsreader, Archivo y Archivo Narrow (SIL Open Font License) · Texto: CC BY 4.0 · Fotografías: modelos de difusión :::`; // content.<lang>.md, inlined by the Cookbook // #region photos: one resource per file; the safe area is the only difference between builds const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067 // Caption and alt text of each photo, one block per photo: content.figures.<lang>.md. const figureTexts = String.raw`mariscadoraMuestra en Markdown · 18 líneas · content.figures.es.md
Carmen Lago separa berberechos en su parcela de la ría, en bajamar. Una mujer con botas verdes, de rodillas en la arena mojada junto a un rastro y una cesta. redeira Rosa Doval remienda un paño de cerco en el muelle. Una mujer sentada en un taburete cose una red verde; detrás, barcos de pesca. carpintero Manuel Barreiro cepilla el forro de la gamela. Un hombre mayor cepilla el costado de una barca de madera a medio construir. faro La punta de Arnela: el faro y el camino de la costa. Un faro blanco sobre rocas de granito; abajo, una caminante con mochila roja. pulpeira Lucía Fraga saca un pulpo del caldero de cobre. Una mujer con mandil saca un pulpo cocido de un caldero de cobre humeante.`; const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/) .map((block) => block.split('\n').map((line) => line.trim())) .map(([id, caption, alt]) => [id, [caption, alt]])); const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot const photo = (id, safe) => { const [w, h] = FILES[id] ?? [1600, 1067]; const [caption, alt] = CAPTIONS[id] ?? []; return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, ...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }), ...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) }; }; const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe)); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Newsreader: ['400', '400i', '600'], Archivo: ['800'], 'Archivo Narrow': ['600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all(resources(false).map((r) => loadImage(r.bitmap.fileId, asset(r.bitmap.fileId)))); // #region builds: the same text, config and photos, without and then with the safe areas const build = (safe) => buildWithFonts(() => buildDocument({ markdown, resources: resources(safe) }, config()), markdown); const docs = { plain: await build(false), safe: await build(true) }; // #endregion const title = t({ en: 'Photos that grow to fill a short column', es: 'Fotos que crecen hasta llenar la columna' }); // Two buttons put either build on the desk; the PDF follows the one shown. const LABELS = { plain: t({ en: 'Without safe areas', es: 'Sin zona segura' }), safe: t({ en: 'With safe areas', es: 'Con zona segura' }) }; const switches = Object.keys(docs).map((key) => Object.assign(document.createElement('button'), { type: 'button', value: key, textContent: LABELS[key] })); const show = (key) => { showPages(docs[key], { title }); for (const b of switches) b.ariaPressed = String(b.value === key); document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove()); offerPdf(() => renderToPdf(docs[key], { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}-${key}.pdf`); }; for (const b of switches) b.addEventListener('click', () => show(b.value)); show('safe'); document.getElementById('pt-actions').prepend(...switches); document.head.insertAdjacentHTML('beforeend', '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>');Kit · core, fonts, viewer, pdf, images: igual en todas las recetas · 310 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images 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 ↗ (se abre en una nueva pestaña)
Variantes
#Mantén una foto en línea junto a su texto
Una foto con position: 'here' y zona segura se recorta por arriba y por abajo cuando es un poco más alta que el hueco que le queda en su columna, en lugar de pasar a la siguiente.
-const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
+const PLACEMENT = { pulpeira: { position: 'here' }, faro: { position: 'here' } };#Devuelve al equilibrado sus otros recursos
Con los valores por defecto las fotos siguen yendo primero, y los títulos y los flotantes toman lo que la zona segura de una foto no alcanza.
-const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
- stretchAfterFloats: false, looseParagraphs: false };
+const balancing = { enabled: true };Errores frecuentes
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Las imágenes de una apertura no cuentan para la altura que reserva
En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →
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
Traduce Figura y Tabla con defaultResourceTypes(locale)
El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
- Una zona segura que cubre la imagen entera, o con un lado menor que el 2 % de ella, no se tiene en cuenta: la foto se muestra completa, como sin ella.
- Solo crecen los flotantes de columna y las fotos en línea. Una foto flotante a lo ancho de las dos columnas mantiene su forma, porque al crecer empujaría a la vez todas las columnas que tiene debajo.
Créditos
- Receta
- Ignacio Ferro
- Texto
- El reportaje, los pies y el colofón, en español y en inglés; Arnela y sus vecinos son imaginarios · Postext Cookbook · CC BY 4.0
- Imágenes
- La ría en bajamar al amanecer, con mariscadoras · Generated With Diffusion Models · original
- Una mariscadora de rodillas en la arena mojada · Generated With Diffusion Models · original
- Una redeira en el muelle · Generated With Diffusion Models · original
- Un carpintero de ribera cepillando una barca en su nave · Generated With Diffusion Models · original
- Un faro sobre una punta de granito y una caminante a sus pies · Generated With Diffusion Models · original
- Una pulpeira sacando un pulpo de un caldero de cobre en el mercado · Generated With Diffusion Models · original
- Fuentes
- Newsreader (SIL OFL 1.1) · Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
- Código
- MIT, como Postext
Editar este texto ↗ (se abre en una nueva pestaña)Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)


