Skip to main content
Recipe number 160

Cookbook · Chapter 9 · Complete publications

A magazine feature threaded through frames

A composed spread whose two columns are chained with next, then go on through overflow.continueIn to pages it adds, round a pull quote and a cut-out.

On this page
Output
Canvas · PDF
Postext
Tested with Postext 1.38.0
Needs ≥ 1.38.0 · postext-pdf ≥ 1.38.0
Licence
Updated 17 Oct 2026
Code MIT · Text CC BY 4.0

pp. 46–47 · 1–2 of 4

  • Trim 225 × 297 mm
  • 1 column
  • Literata 9.8/13.6
  • Fraunces
  • Instrument Sans
  • 4 pages
  • Level
  • Postext 1.38.0
  • Laid out in 425 ms
  • 210 lines of code

In short

A four-page magazine story laid out from a template: the text runs from one column to the next and on to new pages by itself, and wraps round a quote and the outline of a photo.

What you'll build

Pages 46 to 49 of Patio, an invented quarterly about city life: a feature on a retired typesetter who keeps fourteen hives on the roof of the printing works where she worked for thirty years. The verso of the opening spread is the photograph, with the headline set over the gravel and a tinted box of figures in the sky. The recto carries the standfirst and the first two columns, with a pull quote across the gutter and a straw skep cut out of its background, the lines of the second column following its dome. The story then runs on to as many pages as it needs, three columns each, and ends where it ends. The Spanish, English and German editions are the same template with other words, and German runs its long compounds through 60 mm columns.

This recipe answers

  • How do I thread a magazine article through linked frames across several pages?
  • How do I lay out a magazine in sections, each article opening on a spread with its lead photo on the facing page?
  • Can Postext set three or more text columns, or wrap text around an image?

The short answer

script.js · lines 61–89in full code
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) },
  ] },
};

The opening spread: a story in two chained columns that goes on

Ingredients

Type
Literata, Fraunces, Instrument Sans (SIL OFL 1.1)
Assets
  • rooftop-1400.jpg
  • skep-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)

Method

#1 · One template, one story

The code is the short answer above. The whole feature is one ::::page{template="apertura" spread} fence: the heading goes to the frame that takes: 'h1', the figures and the standfirst to their slots, and everything else, which names no slot, to the flow frame col-1. When col-1 is full the text goes on in col-2, broken again at its width, and what col-2 cannot hold goes on to a page added after the spread (overflow.continueIn: 'template:seguido'), after the words Continued in the reader's language. The text names no column and no page: cut a paragraph and every line after it moves back.

#2 · The pages the story adds

script.js · lines 93–105in full code
// 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' } },
  ],
};

Each added page is three chained columns of 60.3 mm, and one template serves all of them. The third column continues into this same template, and the story goes on in the frame its chain starts in, seguido-1: every added page fills all three columns, however many it takes. Nothing has to pin the type size either: a frame that can go on to another page goes on before the text is set smaller, so a story five lines too long gets a new page, not 92.5 % on its last.

#3 · Two frames that push the columns aside

script.js · lines 50–57in full code
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) },
];

Neither frame takes part in the story: each pushes the lines of the columns it overlaps. The pull quote is anchored to the standfirst, 82 mm down, and straddles the gutter, so the first column loses its right side and the second its left. The skep's frame has wrap: 'contour': the lines beside it are set as short as the picture is wide at their height, plus a 2.5 mm margin, so the second column follows the dome and runs full width again under its base.

#4 · An outline that travels with the picture

script.js · lines 300–316in full code
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];

The layout reads a picture by its size, never by its pixels, so the outline comes from the PNG's alpha channel once, with traceResourceContour, and travels on the resource as contour.traced. The skep is a 720 px PNG with 200 colours and a transparent ground (225 KB). The photograph on the verso is a decoration image, resourceId: '{attr.foto}', so the fence chooses the picture and the template its place. It is set with fit: 'cover': whatever the photograph's proportions, it fills the verso and the box crops what runs past it.

#5 · Running heads that read the fence

script.js · lines 109–120in full code
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),
] };

The opening spread hides the running heads (header: false); the added pages print the magazine's name on the verso and the section on the recto. The section comes from the fence, seccion="City", which {attr.seccion} reads on the pages the story adds as on the spread itself.

The whole recipe

Sandbox
// ═══ 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`---
Markdown sample · 110 lines · content.en.mdtitle: "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: the same in every recipe · 315 lines// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts 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 ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)

Variations

#Wrap the skep by its box

Without wrap: 'contour' the frame pushes with its rectangle, and the lines beside the dome stop at a straight edge.

-  { name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58),
+  { name: 'recorte', exclude: 'push', layer: 2, width: mm(58),

#Drop the continuation mark

mark: false ends the cut piece on its last line, without the words Continued: the column holds one line more.

-      overflow: { continueIn: 'template:seguido' } },
+      overflow: { continueIn: 'template:seguido', mark: false } },

Pitfalls

  • A heading the template sends to a frame of its own opens no drop cap in the text frames. The first paragraph of the story is wrapped in :::paragraphs{style="inicio"}, and the style carries the dropCap.

Credits

Text
Original prose, 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
Fonts
Literata (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
SandboxPDF