Zum Hauptinhalt springen
Rezept Nummer 116

Rezepte · Kapitel 7 · Abbildungen und Bilder

Fotos, die wachsen, um eine kurze Spalte zu füllen

Jedes Foto markiert einen Sicherheitsbereich; der Spaltenausgleich darf außerhalb beschneiden und das Foto höher setzen, sodass eine kurze Spalte bündig endet.

Auf dieser Seite
Ausgabe
Canvas · PDF
Stufe
Mittel
Postext
Getestet mit Postext 1.31.0
Erfordert ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Lizenz
Aktualisiert am 10. Okt. 2026
Code MIT · Text CC BY 4.0

S. 1 von 6

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 225 × 297 mm
  • 2 Spalten, 7 mm Spaltenabstand
  • Newsreader 9,8/13,6
  • Archivo
  • Archivo Narrow
  • 6 Seiten
  • Stufe
  • Postext 1.31.0
  • In 80 ms gesetzt
  • 178 Zeilen Code

Kurz gesagt

Sagen Sie dem Layout, welcher Teil jedes Fotos immer sichtbar sein muss. Fehlen einer Spalte ein paar Zeilen, wird das Foto höher, statt eine Lücke zu lassen.

Was Sie setzen

Drei Seiten eines Küstenmagazins: eine Reportage über die Handwerke einer Stadt an einer Flussmündung, mit einem randabfallenden Foto über den Auftakt und fünf Fotos in den Spalten. Der Pen setzt die Reportage zweimal aus demselben Text. In der ersten Fassung behalten die Fotos das Format ihrer Dateien, und auf Seite 2 enden beide Spalten eine Zeile vor dem Ende des Rasters: die linke unter ihrem letzten Absatz, die rechte über dem Foto an ihrem Fuß. In der zweiten nennt jedes Foto den Teil des Bildausschnitts, der immer sichtbar bleiben muss, und die Engine beschneidet außerhalb davon: Auf derselben Seite wachsen die Muschelsammlerin und der Netzflicker um je eine Zeile, und beide Spalten enden auf der letzten Rasterzeile. Niemand musste die Zuschnitte wählen.

Dieses Rezept beantwortet

  • Wie lasse ich ein Foto beschneiden, damit es eine kurze Spalte füllt, während sein Motiv immer sichtbar bleibt?
  • Wie erreiche ich bündige Spaltenenden und eine ausgeglichene letzte Seite (vertikaler Ausgleich)?
  • Wie bestimme ich, wo eine Abbildung steht: oben auf der Seite, über beide Spalten, genau hier oder am Rand?

Die kurze Antwort

script.js · Zeilen 46–62im vollständigen Code
// 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 };

Zutaten

Schrift
Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1)
Dateien
  • carpintero-1600.jpg
  • estuario-1700.jpg
  • faro-1600.jpg
  • mariscadora-1600.jpg
  • pulpeira-1600.jpg
  • redeira-1600.jpg
  • The estuary at low water at dawn, with shellfish gatherers (Generated With Diffusion Models, eigener Text)
  • A shellfish gatherer kneeling on the wet sand (Generated With Diffusion Models, eigener Text)
  • A net mender on the quay (Generated With Diffusion Models, eigener Text)
  • A shipwright planing a wooden boat in his shed (Generated With Diffusion Models, eigener Text)
  • A lighthouse on a granite point and a walker below it (Generated With Diffusion Models, eigener Text)
  • A cook lifting an octopus from a copper cauldron at a market (Generated With Diffusion Models, eigener Text)

Zubereitung

#1 · Markieren, was bleiben muss, sonst nichts

Der Code steht oben in der kurzen Antwort. Ein Sicherheitsbereich besteht aus vier Bruchteilen des Bilds, gemessen von seiner oberen linken Ecke. Die Engine darf das Foto in jedem Format zwischen der ganzen Datei und diesem Rechteck zeigen, so breit wie seine Spalte: höher, indem sie die Seiten beschneidet, niedriger, indem sie oben und unten beschneidet. Was außerhalb des Sicherheitsbereichs liegt, fällt im Verhältnis zu den Rändern auf jeder Seite weg, sodass Rosa, rechts der Mitte auf dem Kai, rechts der Mitte bleibt, wenn die Seiten beschnitten werden.

#2 · Den Ausgleich den Spielraum nur über die Fotos nehmen lassen

Dieselbe Region stellt den Ausgleich ein. Seine üblichen Hebel fügen Weißraum hinzu: eine Zeile über einer Überschrift, Luft unter einem Foto, einen Absatz, der eine Zeile länger läuft. Hier sind sie ausgeschaltet, sodass die schlichte Fassung jede kurze Spalte so zeigt, wie sie ist. Ein Foto mit Sicherheitsbereich wird ohnehin zuerst versucht, vor jedem dieser Hebel, weil ein höheres Foto kein Loch im Text hinterlässt.

#3 · Der Sicherheitsbereich gehört zur Ressource

script.js · Zeilen 210–244im vollständigen Code
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 steht an der Ressource neben Datei, Bildunterschrift und Platzierung, sodass ein Foto sie behält, wohin es auch gleitet, und in jedem Dokument, das es verwendet. Der Pen übergibt sie oder lässt sie weg, und das ist der einzige Unterschied zwischen den beiden Fassungen. Die Flussmündung des Auftakts zeichnet der Überschriftenstil, sie gleitet nicht und braucht deshalb keine.

#4 · Beide bauen und vergleichen

script.js · Zeilen 254–256im vollständigen Code
const build = (safe) => buildDocumentWithFonts({ markdown, resources: resources(safe) }, config(),
  kitFonts(FONTS));
const docs = { plain: await build(false), safe: await build(true) };

Beide Dokumente entstehen aus einer Konfiguration und einem Markdown-String. Nur die Seiten, auf denen der Text weiterläuft, werden bis zum Fuß gefüllt: Seite 3 schließt die Reportage ab, und auf einer Schlussseite müssen die Spalten nur untereinander gleich hoch enden, was sie hier schon tun, also wächst dort kein Foto. Die PDF-Schaltfläche baut die Fassung, die gerade auf dem Tisch liegt, und das PDF beschneidet jedes Foto auf dieselbe Weise auf seinen Rahmen.

Das ganze Rezept

Sandbox
// ═══ 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.25.0
//
// 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 {
  buildDocumentWithFonts, renderPageToCanvas, 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 }); // the running heads ask for an ellipsis instead
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,
  // The opener reserves down to the standfirst's last line; minHeight is a floor under that,
  // which leaves about two lines of air before the text in both editions.
  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 = () => ({
  locale: t({ en: 'en-gb', es: 'es' }), // the hyphenation patterns of each edition
  resourceTypes: types(), // the built-in types of the edition's language, renumbered
  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: [
      // parity 'odd': the feature opens on a recto, after a blank verso only when needed.
      { 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-Beispiel · 65 Zeilen · content.en.mdtitle: "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`mariscadora
Markdown-Beispiel · 18 Zeilen · content.figures.en.mdCarmen 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 Newsreader: ['400', '400i', '600'], Archivo: ['800'], 'Archivo Narrow': ['600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── 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) => buildDocumentWithFonts({ markdown, resources: resources(safe) }, config(), kitFonts(FONTS)); 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: in jedem Rezept gleich · 315 Zeilen// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { 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 text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Varianten

#Ein Foto im Textfluss bei seinem Text halten

Ein mit position: 'here' gesetztes Foto mit Sicherheitsbereich wird oben und unten beschnitten, wenn es für den Platz, der in seiner Spalte bleibt, etwas zu hoch ist, statt in die nächste zu rücken.

-const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
+const PLACEMENT = { pulpeira: { position: 'here' }, faro: { position: 'here' } };

#Dem Ausgleich seine übrigen Hebel zurückgeben

Mit den Standardwerten kommen die Fotos weiterhin zuerst, und Überschriften und Gleitobjekte übernehmen, was der Sicherheitsbereich eines Fotos nicht schafft.

-const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
-  stretchAfterFloats: false, looseParagraphs: false };
+const balancing = { enabled: true };

Häufige Fehler

  • Ein Sicherheitsbereich, der das ganze Bild abdeckt, oder einer mit einer Seite unter 2 % davon wird ignoriert: Das Foto wird ganz gezeigt, wie ohne Sicherheitsbereich.
  • Nur Gleitobjekte in einer Spalte und Fotos im Textfluss wachsen. Ein Foto, das über beide Spalten gleitet, behält sein Format, weil sein Wachsen jede Spalte darunter zugleich verschieben würde.

Nachweise

Text
  • The feature, the captions and the colophon, in Spanish and English; Arnela and its people are imaginary · Postext Cookbook · CC BY 4.0
Bilder
  • The estuary at low water at dawn, with shellfish gatherers · Generated With Diffusion Models · eigener Text
  • A shellfish gatherer kneeling on the wet sand · Generated With Diffusion Models · eigener Text
  • A net mender on the quay · Generated With Diffusion Models · eigener Text
  • A shipwright planing a wooden boat in his shed · Generated With Diffusion Models · eigener Text
  • A lighthouse on a granite point and a walker below it · Generated With Diffusion Models · eigener Text
  • A cook lifting an octopus from a copper cauldron at a market · Generated With Diffusion Models · eigener Text
Schriften
Newsreader (SIL OFL 1.1) · Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
SandboxPDF