Aller au contenu principal
Recette numéro 116

Recettes · Chapitre 7 · Figures et images

Des photos qui grandissent pour remplir une colonne courte

Chaque photo marque une zone sûre ; l’équilibrage peut rogner hors d’elle pour l’allonger, et une colonne raccourcie par un titre ou un flottant finit au pied.

Sur cette page
Sortie
Canvas · PDF
Postext
Testé avec Postext 1.31.0
Nécessite ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Licence
Mis à jour le 10 oct. 2026
Code MIT · Texte CC BY 4.0
  • Exemple en anglais : pas encore d’édition française
  • Format 225 × 297 mm
  • 2 colonnes, Gouttière de 7 mm
  • Newsreader 9,8/13,6
  • Archivo
  • Archivo Narrow
  • 6 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 80 ms
  • 178 lignes de code

En bref

Indiquez à la mise en page quelle partie de chaque photo doit toujours rester visible. Quand il manque quelques lignes à une colonne, la photo s’allonge au lieu de laisser un trou.

Ce que vous allez composer

Trois pages d’un magazine du littoral : un reportage sur les métiers d’une ville d’estuaire, avec une photo à fond perdu sur l’ouverture et cinq photos dans les colonnes. Le pen compose le reportage deux fois à partir du même texte. Dans la première composition, les photos gardent la forme de leurs fichiers, et en page 2 les deux colonnes finissent une ligne avant le bas de la grille : celle de gauche sous son dernier paragraphe, celle de droite au-dessus de la photo placée à son pied. Dans la seconde, chaque photo désigne la partie du cadre qui doit toujours rester visible, et le moteur rogne en dehors : sur la même page, la pêcheuse à pied et le ramendeur de filets grandissent chacun d’une ligne, et les deux colonnes finissent sur la dernière ligne de la grille. Personne n’a eu à choisir les recadrages.

Cette recette répond à

  • Comment laisser une photo être recadrée pour remplir une colonne courte tout en gardant toujours son sujet visible ?
  • Comment obtenir des colonnes alignées en pied et une dernière page équilibrée (justification verticale) ?
  • Comment choisir où va une figure : en haut de page, sur les deux colonnes, exactement ici ou dans la marge ?

La réponse courte

script.js · lignes 46–62dans le code complet
// 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 };

Ingrédients

Typographie
Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1)
Ressources
  • 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, 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)

Préparation

#1 · Marquer ce qui doit rester, rien d’autre

Le code est la réponse courte ci-dessus. Une zone sûre se compose de quatre fractions de l’image, mesurées depuis son coin supérieur gauche. Le moteur peut montrer la photo sous toute forme comprise entre le fichier entier et ce rectangle, à la largeur de sa colonne : plus haute en coupant les côtés, plus basse en coupant le haut et le bas. Ce qui se trouve hors de la zone disparaît en proportion des marges de chaque côté : Rosa, à droite du centre sur le quai, reste à droite du centre quand les côtés sont coupés.

#2 · Ne laisser l’équilibrage rattraper le jeu que par les photos

La même région règle l’équilibrage. Ses leviers habituels ajoutent du blanc : une ligne au-dessus d’un titre, de l’air sous une photo, un paragraphe allongé d’une ligne. Ils sont désactivés ici, si bien que la composition simple montre chaque colonne courte telle qu’elle est. Une photo à zone sûre est de toute façon essayée en premier, avant tous ces leviers, parce qu’une photo plus haute ne laisse aucun trou dans le texte.

#3 · La zone sûre appartient à la ressource

script.js · lignes 210–244dans le code complet
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 se trouve sur la ressource à côté de son fichier, de sa légende et de son placement : une photo la garde où qu’elle flotte et dans chaque document qui l’utilise. Le pen la transmet ou l’omet, et c’est la seule différence entre les deux compositions. L’estuaire de l’ouverture est dessiné par le style de titre, pas placé en flottant : il n’en a pas besoin.

#4 · Composer les deux et comparer

script.js · lignes 254–256dans le code complet
const build = (safe) => buildDocumentWithFonts({ markdown, resources: resources(safe) }, config(),
  kitFonts(FONTS));
const docs = { plain: await build(false), safe: await build(true) };

Les deux documents viennent d’une seule configuration et d’une seule chaîne Markdown. Seules les pages où le texte continue sont remplies jusqu’au pied : la page 3 ferme le reportage, et une page de fin se contente de colonnes de même hauteur, ce qui est déjà le cas ici : aucune photo n’y grandit. Le bouton PDF compose celle des deux versions qui est sur la table de travail, et le PDF découpe chaque photo à son cadre de la même façon.

La recette complète

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`---
Exemple Markdown · 65 lignes · 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
Exemple Markdown · 18 lignes · 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 : identique dans toutes les recettes · 315 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Garder une photo en ligne avec son texte

Une photo placée avec position: 'here' et une zone sûre est rognée en haut et en bas quand elle est un peu trop haute pour la place restant dans sa colonne, au lieu de passer à la suivante.

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

#Rendre à l’équilibrage ses autres leviers

Avec les valeurs par défaut, les photos passent toujours en premier, et les titres et les flottants prennent ce que la zone sûre d’une photo ne peut pas absorber.

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

Erreurs fréquentes

  • Une zone sûre qui couvre toute l’image, ou dont un côté fait moins de 2 % de celle-ci, est ignorée : la photo est montrée entière, comme sans zone sûre.
  • Seuls les flottants de colonne et les photos en ligne grandissent. Une photo flottante sur les deux colonnes garde sa forme, parce que l’agrandir repousserait d’un coup toutes les colonnes en dessous.

Crédits

Texte
  • 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
Polices
Newsreader (SIL OFL 1.1) · Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
SandboxPDF