In short
Tell the layout which part of each photo must always show. When a column comes up a few lines short, the photo gets taller instead of leaving a gap.
What you'll build
Three pages of a coastal magazine: a feature on the trades of an estuary town, with a bleed photo across the opener and five photos in the columns. The pen sets the feature twice from the same text. In the first setting the photos keep the shape of their files, and on page 2 both columns end a line short of the grid: the left one under its last paragraph, the right one above the photo at its foot. In the second each photo names the part of the frame that must always show, and the engine crops outside it: on the same page the shellfish gatherer and the net mender each grow by a line, and both columns end on the last line of the grid. Nobody had to choose the crops.
This recipe answers
- How do I let a photo be cropped to fill a short column while its subject always stays in view?
- How do I get flush column bottoms and a balanced last page (vertical justification)?
- How do I control where a figure goes: top of page, across both columns, exactly here, or in the margin?
The short answer
// 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 };
A safe area per photo, and balancing that may only grow pictures
Ingredients
- Features
- Picture safe areaColumn balancingFigure placementFigures and tables as resourcesCitations that place figuresFigures exactly hereCaption styleCustom resource typesDesigned openersPictures in page designsHeading attributesOne or two columnsMirrored marginsRunning heads and foliosHeads by page roleSemantic colour paletteParagraph stylesPages on a canvasPDF export
- Type
- Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1)
- Assets
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)
Method
#1 · Mark what must stay, nothing else
The code is the short answer above. A safe area is four fractions of the picture, measured from its top-left corner. The engine may show the photo at any shape between the whole file and that rectangle, as wide as its column: taller by cutting the sides, lower by cutting the top and bottom. What lies outside the area goes in proportion to the margins on each side, so Rosa, right of centre on the quay, stays right of centre when the sides are cut.
#2 · Let balancing take the slack only through the photos
The same region sets the balancer. Its usual levers add white: a line above a heading, air under a photo, a paragraph run one line long. Here they are off, so the plain setting shows every short column as it is. A photo with a safe area is tried first anyway, before any of those levers, because a taller photo leaves no hole in the text.
#3 · The safe area belongs to the resource
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 sorts cockles on her plot of the estuary at low water.
A woman in green boots kneels on wet sand beside a rake and a mesh basket.
redeira
Rosa Doval mends a purse-seine panel on the quay.
A woman on a stool sews a green net spread around her; fishing boats behind.
carpintero
Manuel Barreiro planes the planking of the gamela.
An old man planes the side of a half-built wooden boat inside a shed.
faro
Arnela point: the lighthouse and the coastal path.
A white lighthouse on granite rocks; a walker with a red backpack below it.
pulpeira
Lucía Fraga lifts an octopus out of the copper cauldron.
A woman in an apron lifts a boiled octopus from a steaming copper pot.
`;
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 sits on the resource next to its file, caption and placement, so a photo keeps it wherever it floats and in every document that uses it. The pen passes it or leaves it out, and that is the only difference between the two settings. The opener's estuary is drawn by the heading style, not floated, so it needs none.
#4 · Build both and compare
const build = (safe) => buildWithFonts(() =>
buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };
Both documents come from one config and one Markdown string. Only the pages that flow on are filled to the foot: page 3 closes the feature, and a closing page only has its columns end level with each other, which here they already do, so no photo grows there. The PDF button builds whichever setting is on the desk, and the PDF clips each photo to its frame in the same way.
The whole recipe
// ═══ 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 = 'en'; // @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`---Markdown sample · 65 lines · content.en.md
title: "Tides" subtitle: "A coastal magazine · Issue 14" --- # People of the low tide {kicker="Working the sea · Arnela estuary" lead="Twice a day the water drains out of the estuary and leaves a kilometre of sand behind. That is when the shellfish gatherers work; on the quay, the net menders sew up what the sea tore the night before. We spent a week in October with five trades that keep the tide's time."} Arnela has fourteen hundred people, a harbour with eleven inshore boats and an estuary that all but empties at low water. From the coast road you can see the whole village: white houses packed onto the hillside, the church at the top and, at the bottom, the fish market under its corrugated roof. What you cannot see from up there is the timetable. Nobody in Arnela arranges to meet at five or at six; they meet "at half tide" or "when it's out", and the tide table the fishermen's guild pins up each month beside the market door counts for more than any clock. This week low water falls at a quarter past seven in the morning and half past seven in the evening. An hour before, the sand is already full of people. ## On foot at low water Carmen Lago started gathering shellfish at fourteen, alongside her mother, and she is sixty-one now. She works with a long-handled rake, a bucket and a mesh basket, always on the same plot of the estuary, the one the guild assigns each season to her group of nine women (:ref{id="mariscadora" case="lower"}). She drags the rake against the current, crouches, sorts out by hand the cockles that fall short of the minimum size and puts them back in the hole they came from. "The small ones stay," she says. "Take them today and there's nothing in March." Each gatherer may take a fixed daily quota per species, which the guild reviews according to the state of the beds. In October Carmen's is three kilos of carpet-shell clams and eight of cockles. Anything over the quota goes back into the sand. At nine, as the water starts to cover the first pools, the group climbs the slope to the market with their buckets. There the catch is washed, weighed gatherer by gatherer and graded by size before the auction at eleven. The work does not end on the sand. Two days a week the group "sows": they move young clams from the patches where they crowd together to the ones that have emptied, and clear the sand of weed and of the starfish that eat the young. Nobody is paid for those hours. "It's like watering the vegetable patch," says Carmen, who still remembers the winters when there was nothing to take. ## The eleven o'clock auction Arnela's fish market is a long shed with a concrete floor that is never dry. By a quarter to eleven the morning's crates are lined up on the belt: the gatherers' shellfish, sorted by species and size, and the inshore boats' fish, line-caught hake, horse mackerel, the odd turbot. Each crate carries a label with its weight, its zone and the name of whoever brought it in. The auction runs downwards. An electronic board starts at a high price and drops ten cents at a time; the first buyer to press a handset takes the crate at that price. Three wholesalers from the district buy here, two restaurants in the village and a processing plant that packs clams for the whole province. In October a kilo of carpet-shell clams fetches around twenty-six euros and a kilo of cockles about seven. On a good morning two tonnes are sold in forty minutes. Marisa Couto has kept the market's books for nineteen years. She notes every sale on a sheet, with the buyer and the price, and at the end of the month pays out each gatherer's and each boat's share, less the guild's commission. "We used to shout," she remembers. "Now all you hear is the board beeping, and by one o'clock the floor's been hosed down." ## The net menders on the quay At the end of the quay, beside the market's stacked crates, Rosa Doval works on a low stool with a green net spread around her like a rug (:ref{id="redeira" case="lower"}). The purse seiners came in before dawn with sardines and with three torn panels; a rock split one of them, and the hole is big enough for a person to climb through. Rosa sews with a plastic needle as long as her hand, loaded with twine, and a small wooden gauge that sets the size of each mesh. She trims the frayed edges back to whole meshes, counts the ones missing and knots the patch in mesh by mesh. A large hole takes her a morning. A new panel, two to three hundred metres of net, takes four women most of a month. Seven net menders are left in Arnela. They organised twelve years ago, when the boat owners began sending their nets to workshops inland, and now they are paid by the hour rather than by the patch. The youngest is twenty-eight and learned on a course run by the guild; the oldest is eighty-one and only sews on fine days. Rosa prefers the quay to the shed the council lent them: "With the net in the sun you see the holes better, and I hear what every boat has brought in." ## The last boatyard on the shore At the head of the cove, where the coastal path turns into a dirt track, a timber shed with its doors open smells of resin and fresh-cut pine. It is Manuel Barreiro's boatyard, the last of the five the estuary once had. Manuel is seventy-four and works with his nephew on a six-metre gamela, a flat-bottomed boat once used for gathering shellfish afloat (:ref{id="carpintero" case="lower"}). The hull has been on the slipway since August. First comes the keel, then the oak frames, bent over a fire, and last the pine planks of the skin, fixed one by one with copper nails. Manuel works without drawings. He takes the shapes from plywood templates he inherited from his father, which hang on the back wall, each with the name of the boat it was made for pencilled on it. In the eighties the yard launched fifteen boats a year. Now it builds two or three, nearly always for rowing clubs or for families who want their grandfather's boat back. This gamela was ordered by the residents' association, which wants to race it in the summer regattas in the village colours. Manuel reckons it will be in the water by April, if the winter lets him work with the doors open. ## The lighthouse and the path From the boatyard the path climbs through gorse to Arnela point, where a white lighthouse with a red lantern marks the mouth of the estuary (:ref{id="faro" case="lower"}). It was automated in 1993 and nobody has lived in the keeper's house since, but Xosé Pazos, who looked after it for twenty-two years, still walks up twice a week to check the batteries and note the wind and the state of the sea in an exercise book. The trail that leads there is part of an eleven-kilometre route around the whole estuary, beach to beach, which the district waymarked three years ago with wooden posts and yellow arrows. In October few walkers pass: a group of pensioners, couples with rucksacks and, at weekends, runners heading down to the harbour. If they ask, Pazos shows them the way down to Cantal cove without slipping on the wet rock. In bad weather the waves break on the point and run up the rocks to the foot of the tower. Pazos keeps a photograph from 1987 in which the water reaches the door. That winter he spent three days unable to get down to the village, with the radio, a box of tins and the exercise book. ## The Sunday cauldron On Sundays the covered market opens at nine, and by ten there is a queue at Lucía Fraga's stall. Lucía cooks octopus the way her mother did, in an eighty-litre copper cauldron over a wood fire: she "scares" it by dipping it in the boiling water three times so the skin stays on, lets it cook for about twenty minutes and lifts it out on a hook when the tip of a tentacle gives under the fork. ::resource{id="pulpeira"} She cuts it with scissors onto wooden plates, adds coarse salt, sweet and hot paprika in equal parts and a stream of olive oil, and serves it with cachelos, potatoes boiled in the same water. A plate costs twelve euros. On summer Sundays Lucía cooks thirty octopuses; in October, fourteen or fifteen, bought on Friday at the market from the harbour's own pot boats. By two in the afternoon the tide is low again. On the sand the first buckets are out and, on the quay, a net lies drying in the sun. :::paragraphs{style="colophon"} Arnela and its people are imaginary · Set in Newsreader, Archivo and Archivo Narrow (SIL Open Font License) · Text: CC BY 4.0 · Photographs: diffusion models :::`; // 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`mariscadoraMarkdown sample · 18 lines · content.figures.en.md
Carmen Lago sorts cockles on her plot of the estuary at low water. A woman in green boots kneels on wet sand beside a rake and a mesh basket. redeira Rosa Doval mends a purse-seine panel on the quay. A woman on a stool sews a green net spread around her; fishing boats behind. carpintero Manuel Barreiro planes the planking of the gamela. An old man planes the side of a half-built wooden boat inside a shed. faro Arnela point: the lighthouse and the coastal path. A white lighthouse on granite rocks; a walker with a red backpack below it. pulpeira Lucía Fraga lifts an octopus out of the copper cauldron. A woman in an apron lifts a boiled octopus from a steaming copper pot.`; 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: the same in every recipe · 310 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)
Variations
#Keep an inline photo with its text
A photo set with position: 'here' and a safe area is cropped from the top and bottom when it is a little too tall for the room left in its column, instead of moving to the next one.
-const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
+const PLACEMENT = { pulpeira: { position: 'here' }, faro: { position: 'here' } };#Give the balancer its other levers back
With the defaults the photos still go first, and the headings and floats take what a photo's safe area cannot.
-const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
- stretchAfterFloats: false, looseParagraphs: false };
+const balancing = { enabled: true };Pitfalls
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
Design text overflow defaults to 'ellipsis-end'
A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →
Pitfall
Localise Figure/Table with defaultResourceTypes(locale)
The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes. Figure and Table in your language →
Pitfall
Only 8 locales hyphenate, by exact code
Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
- A safe area that covers the whole picture, or one with a side under 2 % of it, is ignored: the photo is shown whole, as without one.
- Only column floats and inline photos grow. A photo floated across both columns keeps its shape, because growing it would push every column under it at once.
Credits
- Recipe
- 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
- Images
- 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)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


