En poques paraules
Digues a la maquetació quina part de cada foto s'ha de veure sempre. Quan a una columna li falten unes línies, la foto s'allarga en lloc de deixar un forat.
Què compondràs
Tres pàgines d'una revista de costa: un reportatge sobre els oficis d'un poble de ria, amb una foto a sang a l'obertura i cinc fotos a les columnes. La recepta compon el reportatge dues vegades amb el mateix text. En la primera, les fotos conserven la forma del seu arxiu, i a la pàgina 2 les dues columnes es queden una línia per sobre de la retícula: l'esquerra sota l'últim paràgraf, la dreta damunt la foto del peu. En la segona, cada foto diu quina part de l'enquadrament s'ha de veure sempre, i el motor en retalla l'exterior: a la mateixa pàgina la marisquera i la remendadora creixen una línia cadascuna i les dues columnes acaben a l'última línia de la retícula. Ningú no ha hagut de triar els retalls.
Aquesta recepta respon a
- Com deixo que una foto es retalli per omplir una columna curta sense que el motiu surti mai de l'enquadrament?
- Com aconsegueixo columnes arran del peu i una última pàgina equilibrada (justificació vertical)?
- Com decideixo on va una figura: al capdamunt de la pàgina, a l'amplada de les dues columnes, just aquí o al marge?
La resposta curta
// 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 };
Ingredients
- Funcions
- Zona segura de la imatgeEquilibri de columnesCol·locació de figuresFigures i taules com a recursosCites que col·loquen les figuresFigures just aquíEstil dels peusTipus de recurs propisObertures dissenyadesImatges als dissenys de pàginaAtributs de títolUna o dues columnesMarges simètricsCapçaleres i folisCapçaleres segons el tipus de pàginaPaleta de color semànticaEstils de paràgrafPàgines en un canvasExportació a PDF
- També fa servir
- Figura i Taula en la teva llenguaBanda de capítol d'amplada completaFonts incrustades al PDF
- Tipografia
- Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1)
- Recursos
carpintero-1600.jpgestuario-1700.jpgfaro-1600.jpgmariscadora-1600.jpgpulpeira-1600.jpgredeira-1600.jpg- The estuary at low water at dawn, with shellfish gatherers (Generated With Diffusion Models, original)
- A shellfish gatherer kneeling on the wet sand (Generated With Diffusion Models, original)
- A net mender on the quay (Generated With Diffusion Models, original)
- A shipwright planing a wooden boat in his shed (Generated With Diffusion Models, original)
- A lighthouse on a granite point and a walker below it (Generated With Diffusion Models, original)
- A cook lifting an octopus from a copper cauldron at a market (Generated With Diffusion Models, original)
Elaboració
#1 · Marca el que s'ha de quedar, res més
El codi és la resposta curta de dalt. La zona segura són quatre fraccions de la imatge, mesurades des del seu angle superior esquerre. El motor pot mostrar la foto amb qualsevol forma entre l'arxiu sencer i aquest rectangle, sempre amb l'amplada de la seva columna: més alta si en retalla els costats, més baixa si en retalla la part de dalt i la de baix. El que queda fora es retalla en proporció als marges de cada costat, de manera que la Rosa, a la dreta del centre al moll, continua a la dreta quan es tallen els costats.
#2 · Que l'equilibrat només estiri de les fotos
La mateixa regió configura l'equilibrat. Els seus recursos habituals afegeixen blanc: una línia sobre un títol, aire sota una foto, un paràgraf una línia més llarg. Aquí estan apagats, perquè la composició sense zones segures mostri cada columna curta tal com queda. Una foto amb zona segura es prova abans que qualsevol d'aquests recursos, perquè una foto més alta no obre cap forat en el text.
#3 · La zona segura és del recurs
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 és al recurs, al costat del seu arxiu, el seu peu i la seva col·locació, de manera que la foto la conserva floti on floti i en qualsevol document que la faci servir. La recepta la passa o l'omet, i aquesta és l'única diferència entre les dues composicions. La ria de l'obertura la dibuixa l'estil del títol i no és un flotant, així que no la necessita.
#4 · Compon-les totes dues i compara-les
const build = (safe) => buildWithFonts(() =>
buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };
Els dos documents surten de la mateixa configuració i del mateix Markdown. Només les pàgines que continuen s'omplen fins al peu: a la pàgina 3, l'última del reportatge, les columnes només han d'acabar a la mateixa altura. Sense zones segures, la segona acaba una línia abans que la primera; amb elles, la foto del pop creix aquesta línia i totes dues acaben igualades. El botó del PDF compon la versió que hi ha damunt la taula, i el PDF retalla cada foto al seu marc de la mateixa manera.
La recepta 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`---Mostra en Markdown · 65 línies · 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`mariscadoraMostra en Markdown · 18 línies · 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 a totes les receptes · 310 línies
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Mantén una foto en línia al costat del seu text
Una foto amb position: 'here' i zona segura es retalla per dalt i per baix quan és una mica més alta que l'espai que li queda a la columna, en lloc de passar a la següent.
-const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
+const PLACEMENT = { pulpeira: { position: 'here' }, faro: { position: 'here' } };#Torna a l'equilibrat els seus altres recursos
Amb els valors per defecte les fotos continuen anant primer, i els títols i els flotants prenen el que la zona segura d'una foto no arriba a cobrir.
-const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
- stretchAfterFloats: false, looseParagraphs: false };
+const balancing = { enabled: true };Errors freqüents
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Les imatges d'una obertura no compten per a l'alçada que reserva
A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Tradueix Figura i Taula amb defaultResourceTypes(locale)
El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
- Una zona segura que cobreix la imatge sencera, o amb un costat menor que el 2 % d'aquesta, no es té en compte: la foto es mostra sencera, com sense zona.
- Només creixen els flotants de columna i les fotos en línia. Una foto flotant a l'amplada de les dues columnes manté la seva forma, perquè en créixer empentaria alhora totes les columnes que té a sota.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- The feature, the captions and the colophon, in Spanish and English; Arnela and its people are imaginary · Postext Cookbook · CC BY 4.0
- Imatges
- The estuary at low water at dawn, with shellfish gatherers · Generated With Diffusion Models · original
- A shellfish gatherer kneeling on the wet sand · Generated With Diffusion Models · original
- A net mender on the quay · Generated With Diffusion Models · original
- A shipwright planing a wooden boat in his shed · Generated With Diffusion Models · original
- A lighthouse on a granite point and a walker below it · Generated With Diffusion Models · original
- A cook lifting an octopus from a copper cauldron at a market · Generated With Diffusion Models · original
- Fonts
- Newsreader (SIL OFL 1.1) · Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


