En bref
Un reportage de quatre pages monté à partir d’un modèle : le texte passe seul d’une colonne à l’autre puis sur de nouvelles pages, et contourne une citation et la silhouette d’une photo.
Ce que vous allez composer
Les pages 46 à 49 de Patio, un trimestriel imaginaire consacré à la vie en ville : un reportage sur une typographe à la retraite qui soigne quatorze ruches sur le toit de l’imprimerie où elle a travaillé trente ans. Le verso de la double page d’ouverture est la photographie, avec le titre posé sur le gravier et un encadré de chiffres sur fond teinté dans le ciel. Le recto porte le chapeau et les deux premières colonnes, avec une citation en exergue à cheval sur la gouttière et une ruche en paille détourée, dont la coupole guide les lignes de la deuxième colonne. Le texte continue ensuite sur autant de pages qu’il lui en faut, à trois colonnes, et s’arrête là où il s’arrête. Les éditions espagnole, anglaise et allemande sont le même modèle avec d’autres mots, et l’allemand fait passer ses longs mots composés dans des colonnes de 60 mm.
Cette recette répond à
- Comment faire courir un article de magazine dans des blocs chaînés sur plusieurs pages ?
- Comment mettre en page un magazine en rubriques, chaque article s’ouvrant sur une double page avec sa photo principale sur la page en regard ?
- Postext peut-il composer trois colonnes de texte ou plus, ou habiller une image avec du texte ?
La réponse courte
const opening = {
id: 'apertura',
spread: true,
header: false,
frames: [
{ name: 'titulo', takes: 'h1', layer: 2, width: mm(150),
placement: at('container', 'bottom-left', 4, -14) },
{ name: 'datos', style: 'datos', layer: 2, width: mm(64),
placement: at('container', 'top-left', 4, 6) },
{ name: 'entradilla', width: mm(2 * COLUMN + GAP),
placement: at('container', 'top-left', RECTO) },
// The story names no slot, so it goes to the flow frame, then on down the chain.
{ name: 'col-1', flow: true, next: 'col-2', width: mm(COLUMN), height: 'fill',
placement: at('#entradilla', 'below', 0, 7) },
// What the last column cannot hold goes on to a page added after the spread.
{ name: 'col-2', width: mm(COLUMN), height: 'fill',
placement: at('#col-1', 'right-of', GAP),
overflow: { continueIn: 'template:seguido' } },
...pushers,
],
decoration: { elements: [
{ kind: 'image', id: 'foto', resourceId: '{attr.foto}',
fit: 'cover',
placement: { ...at('bleed', 'top-left'), size: { width: mm(W), height: 'fill' } } },
{ kind: 'text', id: 'seccion', content: '{attr.seccion}', fontFamily: SANS, fontSize: pt(9),
fontWeight: 700, letterSpacing: pt(2), textTransform: 'uppercase', color: col('paper'),
placement: at('#titulo', 'above', 0, -3) },
] },
};
Ingrédients
- Fonctionnalités
- Blocs de texte chaînésBlocs qui repoussent le texte, au cadre ou au contourPages composéesMarges en miroirImages dans les maquettes de pageCésure et langue du documentTitres courants et foliosStyles de paragrapheLettrines dans les ouverturesEncadrésPalette de couleurs sémantiqueFigures et tableaux comme ressourcesPages sur un canvasExport PDF
- Utilise aussi
- Lettrines dans les paragraphesModèles en double pageBlocs poursuivis à la page suivanteDécor ancré aux blocsDes figures exactement iciPolices intégrées au PDFTypes de ressource personnalisés
- Typographie
- Literata, Fraunces, Instrument Sans (SIL OFL 1.1)
- Ressources
rooftop-1400.jpgskep-720.png- Painted hives on a factory roof at sunrise (the opening spread) (Generated With Diffusion Models, original)
- A straw bee skep on a transparent ground (the cut-out the text runs round) (Generated With Diffusion Models, original)
Préparation
#1 · Un modèle, un texte
Le code est dans la réponse courte plus haut. Tout le reportage tient en une seule directive ::::page{template="apertura" spread} : le titre va au bloc qui takes: 'h1', les chiffres et le chapeau à leurs emplacements, et tout le reste, qui ne nomme aucun emplacement, au bloc de flux col-1. Quand col-1 est plein, le texte continue dans col-2, recoupé à sa largeur, et ce que col-2 ne peut pas contenir passe sur une page ajoutée après la double page (overflow.continueIn: 'template:seguido'), derrière le mot Continued dans la langue de l’édition. Le texte ne nomme ni colonne ni page : supprimez un paragraphe et toutes les lignes qui le suivent remontent.
#2 · Les pages qu’ajoute le texte
// The third column continues into this same template: the story goes on in the frame its
// chain starts in, so every added page fills all three columns.
const seguido = {
id: 'seguido',
frames: [
{ name: 'seguido-1', flow: true, next: 'seguido-2', height: 'fill', width: mm(60.3) },
{ name: 'seguido-2', next: 'seguido-3', height: 'fill', width: mm(60.3),
placement: at('#seguido-1', 'right-of', 5) },
{ name: 'seguido-3', height: 'fill', width: mm(60.3),
placement: at('#seguido-2', 'right-of', 5),
overflow: { continueIn: 'template:seguido' } },
],
};
Chaque page ajoutée compte trois colonnes chaînées de 60,3 mm, et un seul modèle sert pour toutes. La troisième colonne continue dans ce même modèle, et le texte reprend dans le bloc où sa chaîne commence, seguido-1 : chaque page ajoutée remplit ses trois colonnes, autant de fois qu’il le faut. Rien non plus ne fixe le corps : un bloc qui peut continuer sur une autre page continue avant que le texte soit composé plus petit, si bien qu’un texte trop long de cinq lignes obtient une page nouvelle, et non 92,5 % sur la dernière.
#3 · Deux blocs qui écartent les colonnes
const pushers = [
// The pull quote straddles the gutter: each column loses the side it covers.
{ name: 'cita', style: 'cita', exclude: 'push', width: mm(62),
placement: at('#entradilla', 'below', COLUMN - 27.5, 82) },
// The skep's frame pushes with the shape of its picture, read from the PNG's alpha.
{ name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58),
placement: at('container', 'bottom-right', 9, -8) },
];
Aucun des deux blocs ne fait partie du texte : chacun repousse les lignes des colonnes qu’il recouvre. La citation est ancrée au chapeau, 82 mm plus bas, et se tient à cheval sur la gouttière : la première colonne perd son côté droit, la seconde son côté gauche. Le bloc de la ruche a wrap: 'contour' : chaque ligne voisine est raccourcie de la largeur de l’image à sa hauteur, plus une marge de 2,5 mm, si bien que la deuxième colonne suit la coupole et retrouve toute sa largeur sous la base.
#4 · Un contour qui voyage avec l’image
const photo = { id: 'azotea', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0,
caption: '', altText: t({
es: 'Seis colmenas de colores en la azotea de una fábrica, con la ciudad al amanecer',
en: 'Six painted hives on a factory roof, with the city behind them at sunrise',
de: 'Sechs bunte Bienenkästen auf einem Fabrikdach, dahinter die Stadt im Morgenlicht' }),
bitmap: { fileId: 'rooftop-1400.jpg', format: 'jpeg', width: 1400, height: 1848 } };
let skep = { id: 'cesto', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0,
caption: '', altText: t({ es: 'Un cesto de paja para abejas', en: 'A straw bee skep',
de: 'Ein Bienenkorb aus Stroh' }),
contour: { threshold: 0.5, margin: mm(2.5) },
bitmap: { fileId: 'skep-720.png', format: 'png', width: 720, height: 828 } };
await loadImage('rooftop-1400.jpg', asset('rooftop-1400.jpg'));
await loadImage('skep-720.png', asset('skep-720.png'));
// The layout reads pictures by size, never by pixels: the outline travels as a trace.
const trace = await traceResourceContour(skep, imageBytes('skep-720.png'), 'image/png');
skep = withContourTrace(skep, trace);
const resources = [photo, skep];
La mise en page lit une image par sa taille, jamais par ses pixels : le contour est donc tiré une seule fois du canal alpha du PNG, avec traceResourceContour, et accompagne la ressource sous la forme contour.traced. La ruche est un PNG de 720 px à 200 couleurs sur fond transparent (225 Ko). La photographie du verso est une image de décor, resourceId: '{attr.foto}' : la directive choisit la photo, le modèle sa place. Elle porte fit: 'cover' : quelles que soient les proportions de la photo, elle remplit le verso et le cadre rogne ce qui dépasse.
#5 · Des titres courants qui lisent la directive
const head = (id, content, parity, edge, x, extra = {}) => ({
kind: 'text', id, content, parity, fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600,
letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
placement: at('page', edge, x, 11), ...extra,
});
const folio = { fontWeight: 700, color: col('honey') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUT, folio),
head('verso-title', '{title}', 'even', 'top-left', OUT + 9),
head('recto-title', '{attr.seccion}', 'odd', 'top-right', -(OUT + 9)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUT, folio),
] };
La double page d’ouverture masque les titres courants (header: false) ; les pages ajoutées impriment le nom du magazine au verso et la rubrique au recto. La rubrique vient de la directive, seccion="City", que {attr.seccion} lit sur les pages ajoutées comme sur la double page d’ouverture.
La recette complète
// ═══ Postext Cookbook · Nº 160 · A magazine feature threaded through frames ═════ // https://postext.dev/en/cookbook/magazine-threaded-frames // Code: MIT · Text: original (CC BY 4.0) · Pictures: Generated With Diffusion Models // Fonts: Literata, Fraunces, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.38.0 // // A six-page feature of an invented city magazine, set as one composed page: an opening // spread whose frames are drawn in the config, and pages the text adds as it runs on. // Every word, the photo's id and the section name are in content.<lang>.md. import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, traceResourceContour, withContourTrace, } 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' | 'de') const RECIPE = 'magazine-threaded-frames'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: honey on warm paper, every colour linked by id const palette = { ink: '#211c18', // text honey: '#a65a12', // the accent: kickers, rules, the drop cap, the pull quote tint: '#f5ecdc', // the fact box rule: '#d8c6a6', // hairlines muted: '#6b6158', // running heads, credits paper: '#ffffff', shade: '#1a1612', // the plate under the headline on the photo }; 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' } })), { id: 'main-color', name: 'honey (defaults)', value: { hex: palette.honey, model: 'hex' } }, ]; // #endregion const SERIF = 'Literata'; const DISPLAY = 'Fraunces'; const SANS = 'Instrument Sans'; const W = 225; // trim width in mm; the spread's sheet is twice as wide const IN = 16; // inner margin const OUT = 18; // outer margin const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // The spread's content area runs from the verso's outer margin to the recto's: a frame // on the recto starts RECTO mm in from its left edge. const RECTO = W - OUT + IN; const COLUMN = 92; // the two columns of the recto const GAP = 7; // #region push: two frames that push the columns' lines aside, by box and by outline const pushers = [ // The pull quote straddles the gutter: each column loses the side it covers. { name: 'cita', style: 'cita', exclude: 'push', width: mm(62), placement: at('#entradilla', 'below', COLUMN - 27.5, 82) }, // The skep's frame pushes with the shape of its picture, read from the PNG's alpha. { name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58), placement: at('container', 'bottom-right', 9, -8) }, ]; // #endregion // #region answer: the opening spread: a story in two chained columns that goes on const opening = { id: 'apertura', spread: true, header: false, frames: [ { name: 'titulo', takes: 'h1', layer: 2, width: mm(150), placement: at('container', 'bottom-left', 4, -14) }, { name: 'datos', style: 'datos', layer: 2, width: mm(64), placement: at('container', 'top-left', 4, 6) }, { name: 'entradilla', width: mm(2 * COLUMN + GAP), placement: at('container', 'top-left', RECTO) }, // The story names no slot, so it goes to the flow frame, then on down the chain. { name: 'col-1', flow: true, next: 'col-2', width: mm(COLUMN), height: 'fill', placement: at('#entradilla', 'below', 0, 7) }, // What the last column cannot hold goes on to a page added after the spread. { name: 'col-2', width: mm(COLUMN), height: 'fill', placement: at('#col-1', 'right-of', GAP), overflow: { continueIn: 'template:seguido' } }, ...pushers, ], decoration: { elements: [ { kind: 'image', id: 'foto', resourceId: '{attr.foto}', fit: 'cover', placement: { ...at('bleed', 'top-left'), size: { width: mm(W), height: 'fill' } } }, { kind: 'text', id: 'seccion', content: '{attr.seccion}', fontFamily: SANS, fontSize: pt(9), fontWeight: 700, letterSpacing: pt(2), textTransform: 'uppercase', color: col('paper'), placement: at('#titulo', 'above', 0, -3) }, ] }, }; // #endregion // #region pages-after: the page the story adds, three chained columns, as often as needed // The third column continues into this same template: the story goes on in the frame its // chain starts in, so every added page fills all three columns. const seguido = { id: 'seguido', frames: [ { name: 'seguido-1', flow: true, next: 'seguido-2', height: 'fill', width: mm(60.3) }, { name: 'seguido-2', next: 'seguido-3', height: 'fill', width: mm(60.3), placement: at('#seguido-1', 'right-of', 5) }, { name: 'seguido-3', height: 'fill', width: mm(60.3), placement: at('#seguido-2', 'right-of', 5), overflow: { continueIn: 'template:seguido' } }, ], }; // #endregion // #region running-heads: magazine name on the verso, section on the recto, folios outside const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'), placement: at('page', edge, x, 11), ...extra, }); const folio = { fontWeight: 700, color: col('honey') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUT, folio), head('verso-title', '{title}', 'even', 'top-left', OUT + 9), head('recto-title', '{attr.seccion}', 'odd', 'top-right', -(OUT + 9)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUT, folio), ] }; // #endregion const body = { fontFamily: SERIF, fontSize: pt(9.8), lineHeight: pt(13.6), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4) }; const config = { locale: t({ es: 'es', en: 'en-gb', de: 'de' }), // Pictures with no number and no caption label: a magazine credits them in the text. resourceTypes: [{ id: 'foto', name: t({ es: 'Foto', en: 'Photo', de: 'Foto' }), shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '', defaultPlacement: { position: 'here' } }], colorPalette, page: { sizePreset: 'custom', width: mm(W), height: mm(297), dpi: 150, margins: { top: mm(20), bottom: mm(22), left: mm(IN), right: mm(OUT), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: { ...body, referenceColor: col('ink'), textAlign: 'justify', indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, maxJustifyTracking: 12, avoidWidows: true, avoidOrphans: true, avoidRunts: true, }, headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 600, levels: [ { level: 1, fontSize: pt(58), lineHeight: pt(56), color: col('paper'), marginBottom: pt(0), breakBefore: { enabled: false } }, { level: 2, fontFamily: SANS, fontSize: pt(8.5), lineHeight: pt(13.6), fontWeight: 700, letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('honey'), marginTop: pt(13.6), marginBottom: pt(0) }, ], }, paragraphStyles: [ { id: 'entradilla', fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(20), italic: true, textAlign: 'left', firstLineIndent: pt(0), color: col('ink') }, // The first paragraph of the story opens with an initial in a frame, as it would in the flow. { id: 'inicio', firstLineIndent: pt(0), dropCap: { lines: 3, fontFamily: DISPLAY, fontWeight: 600, color: col('honey'), leadIn: { words: 3 } } }, { id: 'firma', fontFamily: SANS, fontSize: pt(8), lineHeight: pt(12), fontWeight: 600, textTransform: 'uppercase', textAlign: 'left', firstLineIndent: pt(0), color: col('honey'), marginTop: pt(6) }, { id: 'colofon', fontFamily: SANS, fontSize: pt(6.8), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(8) }, ], calloutStyles: [ { id: 'cita', backgroundEnabled: false, border: { enabled: false }, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('honey') }, padding: { top: mm(3), right: mm(0), bottom: mm(3), left: mm(0) }, body: { fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(18.5), italic: true, color: col('honey'), textAlign: 'left', firstLineIndent: pt(0) } }, { id: 'datos', backgroundEnabled: true, background: col('tint'), border: { enabled: false }, padding: { top: mm(3.5), right: mm(4), bottom: mm(3.5), left: mm(4) }, titleStyle: { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.2), color: col('honey') }, body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(11.6), color: col('ink'), boldColor: col('honey'), firstLineIndent: pt(0), textAlign: 'left' } }, ], captionStyle: { fontFamily: SANS, fontSize: pt(7.5), color: col('muted'), labelBold: true, labelColor: col('honey'), note: { color: col('muted') } }, header, footer: { elements: [] }, pageTemplates: [opening, seguido], }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 110 lignes · content.en.md
title: "Patio · No. 31" --- ::::page{template="apertura" foto="azotea" seccion="City"} # The bees on the roof :::frame{slot="datos" title="The roof in numbers"} **14** hives on the roof **23 m** above the street **380 kg** of honey in the July harvest **3 km** flying radius, give or take ::: :::frame{slot="entradilla"} :::paragraphs{style="entradilla"} For thirty years Carmen Aldaba set books by hand on the second floor of a printing works. When the works closed, she went up to the roof. She now keeps fourteen hives there, and half a million bees that work the lime trees of half the city. ::: :::paragraphs{style="firma"} Words: Lucía Ferrán ::: ::: :::frame{slot="cita"} “A hive is like a case of type: if you know where everything is, you don’t have to look.” ::: ::resource{id="cesto" slot="recorte"} :::paragraphs{style="inicio"} At half past six in the morning the roof still holds the cold of the night. Carmen Aldaba climbs the iron stairs with a lit smoker in one hand and a box of empty frames in the other, and she does not switch on her torch: she knows all twenty-two steps by heart. At the top, in a row on the gravel, stand fourteen wooden boxes painted green, ochre and blue, each with a letter chalked on its lid. ::: “A is the fierce one,” she says, levering up a lid. “M is the hard worker. The rest come and go.” For thirty years Aldaba worked two floors below, in the composing room of Gráficas Lezcano, a family printer that turned out hardware catalogues, festival programmes, books of poems paid for by their poets and, for a long stretch, the timetables of the city buses. She started as an apprentice at seventeen, when type was still set by hand from cases of lead, and left in 2011, on the day the family sold the presses to a scrap dealer from Calatayud. The building passed to a cooperative of workshops. Today it houses a luthier, two design studios, a pottery school and a bakery that fires its ovens before dawn in the old paper store. ## A roof nobody wanted Nobody had a use for the roof. It leaked, its railing had rusted, and the chimney of the old boiler still stood there because the cooperative did not dare pull it down. At a members’ meeting Aldaba proposed two hives “to see how it goes”, and the meeting said yes on one condition: that nobody from the workshops would ever have to go near them. That was the spring of 2013. The two hives are now fourteen, and the roof’s honey is sold in the bakery, in quarter-kilo jars with a label she sets herself on the old treadle platen press that stayed behind in the yard. She learned the craft from her grandfather, who kept bees in cork and straw hives in a village in Teruel. One of those woven straw skeps, with its arched doorway at the foot, now sits empty in a corner of the roof on a concrete block. She does not use it: modern hives open from the top and can be checked frame by frame, while the only way to empty a skep is to break the combs. But she brought it back from the village when the house was sold, and she would not leave it in a storeroom. ## What city bees eat The question she hears most is how bees live in the middle of all that tarmac. The answer is in the streets. A worker bee flies three kilometres from its hive without much effort, and within a circle that size around the printing works there are thousands of street trees: limes, white-flowered locusts, pagoda trees, hackberries, and plane trees that give no nectar but plenty of resin. There are parks with rosemary and lavender, empty lots full of wild rocket, and hundreds of balconies with geraniums, basil and tomato plants. “In the country a hive depends on what gets sown that year,” she explains. “If your neighbour plants sunflowers, you get sunflower. Here there is a bit of everything, and something is in flower nearly every month.” That is why the honey from the roof changes colour through the year. June’s, when the limes flower, is pale and smells of mint. Late summer’s, after the pagoda trees, is dark amber and a little bitter. Aldaba does not blend them: each harvest goes into its own jars, with the date and the name of the tree most in flower in those weeks. In July she took off three hundred and eighty kilos, her best year since she started. ## The neighbours At first there were complaints. A woman in the block opposite, who hangs her washing on a terrace at the same height, wrote to the council because the bees were landing on her sheets. Aldaba went round with a jar of honey and a proposal: to turn the hive entrances so that the bees flew out over the inner courtyard instead of towards her terrace. It worked. The neighbour, whose name is Rosario and who is eighty-one, now buys two jars every autumn. The worst scare came one morning in May 2024. A hive swarmed, as strong colonies do in spring: the old queen left with half the workers to look for a new home, and the swarm, a ball of some fifteen thousand bees, hung itself from the shelter of a bus stop two streets away. Someone called the fire brigade, the fire brigade called the local police, and the local police, who already had her number, called Aldaba. She was there in ten minutes with a cardboard box and a sheet, shook the cluster into the box and carried it back up to the roof. “A swarm won’t sting you. The bees are full of honey and have nothing to defend,” she says. “Of course, the people waiting for the bus don’t know that.” ## Heat and the mite A roof is a hard place for a hive. In August the gravel passes fifty degrees and the wood of the boxes heats up like an iron. Bees cool the hive by carrying water and fanning it with their wings, and a large colony can get through more than a litre a day. Aldaba leaves them drinking trays with floating cork and has strung a shade net over the row of boxes, tied to the chimney and the railing. In summer she goes up twice a day, at dawn and at dusk, never at midday. The other enemy is varroa, a mite from Asia that feeds on the larvae and spreads viruses. No hive in Europe is free of it. Aldaba counts the mites that drop onto a tray under each box and treats with oxalic acid in winter, when the colony has no brood and the mites have no cells to hide in. She keeps a notebook for each hive, its letter on the cover, and writes down every inspection: the weight, the number of frames with brood, the temper of the bees, the weather. ## Inside a colony Opening a hive with her is a lesson without a blackboard. She lifts out a frame, holds it against the low sun and points with a gloved finger: here the sealed brood, under caps of biscuit-coloured wax; here the larvae, white and curled at the bottom of each cell; along the top edge the honey; between them a band of pollen in many colours, orange from marigolds, grey from hackberries, nearly black from poppies. In July a strong colony gathers fifty thousand workers, a few hundred drones and a single queen, who lays up to two thousand eggs a day and lives four or five years if nobody replaces her. “The queen doesn’t rule anything,” she says. “She lays eggs. The workers decide: when to raise a new queen, when to swarm, which flower to work this week.” Finding her in the crowd takes patience. She is longer than the others, with a pointed abdomen and short wings, and the workers around her form a ring, facing inwards. Aldaba marks every queen with a dot of paint on the thorax, in a colour that depends on the year she was born, following an international code any beekeeper can read: white, yellow, red, green and blue, then round again. This year it is white. ## Saturdays in October For four years she has taught a beginners’ course on the Saturdays of October, for eight people, in the composing room. There is a waiting list. Most of those who sign up are retired people and teachers, the odd biology student and, last year, a bus driver who had watched the swarm on the bus shelter from his seat and wanted to know what had happened. The rules of the course fit on a card: no perfume, no black clothes, no sudden movements, no blowing on the frames. At the end of the fourth session each student opens a hive alone, with her standing beside them, hands in her pockets. “The technique isn’t the hard part,” she says. “The hard part is keeping still with four bees walking across your veil.” ## What a jar costs The roof’s honey sells at seven euros for a quarter-kilo jar, and last year about twelve hundred were sold. The accounts, which Aldaba keeps in another notebook, show no profit. What comes in pays for the jars, the labels, the sugar she feeds the weak colonies in autumn, the varroa treatments, the foundation wax for new frames and a liability insurance the cooperative asked for from the first year. For each jar sold, one euro goes into the workshops’ common fund, “as rent for the roof”. Last year that money paid for mending the leaks. Whatever is left, when anything is, she spends on books. She has a small library of beekeeping in the old overseer’s office, partly bound by herself, with nineteenth-century treatises bought at flea markets and the journals of beekeepers’ associations from half of Europe. ## Other roofs The roof of Gráficas Lezcano is no longer the only one. Since 2019 hives have appeared on the roof of a secondary school, on a hotel in the centre and on the terrace of a care home, where a group of residents looks after them with help from a local beekeeper. Aldaba has advised almost all of them, and she tells each the same thing: not to keep more hives than the neighbourhood can feed. “Put a hundred hives in a square kilometre and the honeybees take the food from one another, and from the bumblebees and the solitary bees, which are the ones that really pollinate a lot of the city’s plants,” she says. “Fourteen is plenty for this street.” So this year she has not split a single colony, though three were strong enough. She would rather the surplus bees left as a swarm in spring and were collected by a neighbour with hives in the country. A list of phone numbers is taped to the door of the stairwell, next to the cooperative’s calendar, and the local police have a copy. ## Harvest in the composing room The honey is extracted where she worked for so many years. The composing room has stood empty since 2011, apart from two wooden type cabinets with their cases, which the cooperative kept “just in case”. There, on a long table, Aldaba uncaps the frames with a hot knife and loads them into a four-frame extractor turned by hand. The honey runs into a drum through a double filter and settles for a week before it is jarred. Children from the pottery school come down to watch. Some take away a piece of comb on a plate; others prefer the type cases, and Aldaba lets them set their names in a composing stick and print them on the platen press, next to the honey label. “A hive is like a case of type,” she tells them. “Everything has its place. If you know where it is, you don’t have to look.” By the end of October the roof goes still. The winter bees, which live longer than the summer ones, cluster in the middle of each box around the queen, and Aldaba only goes up to check that no water has got in. She leaves the hives enough honey to see the winter through and does not open them again until March, when the almond trees in the park flower and the first workers go out for pollen. By then she will know which colonies came through the cold, and whether A is still the fierce one. :::paragraphs{style="colofon"} Patio is a magazine invented for this cookbook; so are Carmen Aldaba, Gráficas Lezcano and their roof. Photographs generated with diffusion models. Set in Literata, Fraunces and Instrument Sans (SIL OFL) · Text: CC BY 4.0 ::: ::::`; // content.<lang>.md, inlined by the Cookbook // #region pictures: the photo, and a cut-out whose alpha channel is the wrap's outline const photo = { id: 'azotea', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0, caption: '', altText: t({ es: 'Seis colmenas de colores en la azotea de una fábrica, con la ciudad al amanecer', en: 'Six painted hives on a factory roof, with the city behind them at sunrise', de: 'Sechs bunte Bienenkästen auf einem Fabrikdach, dahinter die Stadt im Morgenlicht' }), bitmap: { fileId: 'rooftop-1400.jpg', format: 'jpeg', width: 1400, height: 1848 } }; let skep = { id: 'cesto', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0, caption: '', altText: t({ es: 'Un cesto de paja para abejas', en: 'A straw bee skep', de: 'Ein Bienenkorb aus Stroh' }), contour: { threshold: 0.5, margin: mm(2.5) }, bitmap: { fileId: 'skep-720.png', format: 'png', width: 720, height: 828 } }; await loadImage('rooftop-1400.jpg', asset('rooftop-1400.jpg')); await loadImage('skep-720.png', asset('skep-720.png')); // The layout reads pictures by size, never by pixels: the outline travels as a trace. const trace = await traceResourceContour(skep, imageBytes('skep-720.png'), 'image/png'); skep = withContourTrace(skep, trace); const resources = [photo, skep]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Literata: ['400', '400i', '700'], Fraunces: ['600', '600i'], 'Instrument Sans': ['600', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Page 1 is the verso numbered 46: the feature opens on a spread inside the issue. const continuation = { pageIndexOffset: 45, pageNumbering: { startAt: 46 } }; const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config, kitFonts(FONTS)); showPages(doc, { title: t({ es: 'Las abejas del tejado', en: 'The bees on the roof', de: 'Die Bienen auf dem Dach' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);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
#Habiller la ruche par son rectangle
Sans wrap: 'contour', le bloc repousse le texte avec son rectangle, et les lignes voisines de la coupole s’arrêtent sur un bord droit.
- { name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58),
+ { name: 'recorte', exclude: 'push', layer: 2, width: mm(58),#Supprimer la mention de suite
mark: false termine le morceau coupé sur sa dernière ligne, sans le mot Continued : la colonne gagne une ligne.
- overflow: { continueIn: 'template:seguido' } },
+ overflow: { continueIn: 'template:seguido', mark: false } },Erreurs fréquentes
- Un titre que le modèle envoie dans un bloc à part n’ouvre pas de lettrine dans les blocs de texte. Le premier paragraphe du reportage est enveloppé dans
:::paragraphs{style="inicio"}, et c’est le style qui porte ledropCap.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Images
- Painted hives on a factory roof at sunrise (the opening spread) · Generated With Diffusion Models · original
- A straw bee skep on a transparent ground (the cut-out the text runs round) · Generated With Diffusion Models · original
- Polices
- Literata (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


