Kurz gesagt
Eine vierseitige Magazingeschichte aus einer Vorlage: Der Text läuft von selbst von einer Spalte in die nächste und auf neue Seiten weiter und umfließt ein Zitat und den Umriss eines Fotos.
Was Sie setzen
Die Seiten 46 bis 49 von Patio, einer erfundenen Vierteljahreszeitschrift über das Leben in der Stadt: eine Reportage über eine pensionierte Setzerin, die auf dem Dach der Druckerei, in der sie dreißig Jahre gearbeitet hat, vierzehn Bienenvölker hält. Die linke Seite der Auftaktdoppelseite ist das Foto, die Schlagzeile steht über dem Kies, ein getönter Kasten mit Zahlen im Himmel. Die rechte Seite trägt den Vorspann und die ersten zwei Spalten, mit einem herausgestellten Zitat über dem Spaltenabstand und einem freigestellten Bienenkorb aus Stroh, dessen Kuppel die Zeilen der zweiten Spalte folgen. Danach belegt der Text so viele dreispaltige Seiten, wie er braucht, und endet, wo er endet. Die spanische, englische und deutsche Ausgabe sind dieselbe Vorlage mit anderen Wörtern. In der deutschen Ausgabe trennen die Silbentrennmuster des Deutschen lange Komposita wie „Bushaltestellenwartehäuschens“ und „Werkstättengenossenschaft“ in den 60 mm breiten Spalten.
Dieses Rezept beantwortet
- Wie lasse ich einen Magazinartikel durch verkettete Rahmen über mehrere Seiten laufen?
- Wie setze ich ein Magazin in Rubriken, bei dem jeder Artikel auf einer Doppelseite beginnt, mit Aufmacherfoto auf der Gegenseite?
- Kann Postext drei oder mehr Textspalten setzen oder Text um ein Bild fließen lassen?
Die kurze Antwort
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) },
] },
};
Zutaten
- Funktionen
- Verkettete TextrahmenRahmen, die Text verdrängen, als Kasten oder entlang der KonturZusammengesetzte SeitenGespiegelte RänderBilder in SeitengestaltungenSilbentrennung und DokumentspracheKolumnentitel und SeitenzahlenAbsatzformateInitialen in AuftaktenKästenSemantische FarbpaletteAbbildungen und Tabellen als RessourcenSeiten auf einem CanvasPDF-Export
- Nutzt außerdem
- Initialen in TextabsätzenVorlagen für DoppelseitenRahmen, die auf der nächsten Seite weiterlaufenAn Rahmen verankertes DekorAbbildungen genau hierIm PDF eingebettete SchriftenEigene Ressourcentypen
- Schrift
- Literata, Fraunces, Instrument Sans (SIL OFL 1.1)
- Dateien
rooftop-1400.jpgskep-720.png- Painted hives on a factory roof at sunrise (the opening spread) (Generated With Diffusion Models, eigener Text)
- A straw bee skep on a transparent ground (the cut-out the text runs round) (Generated With Diffusion Models, eigener Text)
Zubereitung
#1 · Eine Vorlage, ein Artikel
Den Code zeigt oben die kurze Antwort. Die ganze Reportage ist eine einzige Seitenbegrenzung ::::page{template="apertura" spread}: Die Überschrift kommt in den Rahmen mit takes: 'h1', die Zahlen und der Vorspann in ihre Slots, und alles Übrige, das keinen Slot nennt, in den Flussrahmen col-1. Ist col-1 voll, läuft der Text in col-2 weiter, neu auf dessen Breite umbrochen, und was col-2 nicht fasst, geht auf einer Seite weiter, die nach der Doppelseite angefügt wird (overflow.continueIn: 'template:seguido'), nach dem Vermerk Wird fortgesetzt. Der Text nennt keine Spalte und keine Seite: Streichen Sie einen Absatz, rücken alle folgenden Zeilen zurück.
#2 · Die Seiten, die der Text anfügt
// 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' } },
],
};
Jede angefügte Seite hat drei verkettete Spalten von 60,3 mm, und eine einzige Vorlage dient für alle. Die dritte Spalte läuft in dieser Vorlage selbst weiter, und der Text setzt in dem Rahmen fort, in dem seine Kette beginnt, seguido-1: Jede angefügte Seite füllt alle drei Spalten, so viele es braucht. Auch die Schriftgröße muss nichts festhalten: Ein Rahmen, der auf einer anderen Seite weiterlaufen kann, läuft weiter, bevor der Text kleiner gesetzt wird. Ein Text, der fünf Zeilen zu lang ist, bekommt also eine neue Seite statt 92,5 % auf der letzten.
#3 · Zwei Rahmen, die die Spalten verdrängen
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) },
];
Keiner der beiden Rahmen gehört zum Text, jeder verdrängt nur die Zeilen der Spalten, die er überdeckt. Das herausgestellte Zitat ist am Vorspann verankert, 82 mm tiefer, und sitzt über dem Spaltenabstand, sodass die erste Spalte ihre rechte Seite verliert und die zweite ihre linke. Der Rahmen des Bienenkorbs hat wrap: 'contour': Jede Zeile neben ihm wird um so viel kürzer gesetzt, wie das Bild auf ihrer Höhe breit ist, plus 2,5 mm Abstand. So folgt die zweite Spalte der Kuppel und läuft unter ihrem Fuß wieder auf voller Breite.
#4 · Ein Umriss, der mit dem Bild reist
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];
Der Umbruch liest ein Bild nach seiner Größe, nie nach seinen Pixeln. Deshalb wird der Umriss einmal mit traceResourceContour aus dem Alphakanal des PNG gewonnen und reist als contour.traced mit der Ressource. Der Bienenkorb ist ein PNG mit 720 px, 200 Farben und transparentem Grund (225 KB). Das Foto auf der linken Seite ist ein Bild des Dekors, resourceId: '{attr.foto}': Die Seitenbegrenzung wählt das Bild, die Vorlage seinen Platz. Es trägt fit: 'cover': Welche Proportionen das Foto auch hat, es füllt die linke Seite, und der Kasten schneidet ab, was übersteht.
#5 · Kolumnentitel aus der Seitenbegrenzung
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),
] };
Die Auftaktdoppelseite blendet die Kolumnentitel aus (header: false); die angefügten Seiten tragen links den Namen der Zeitschrift und rechts das Ressort. Das Ressort kommt aus der Seitenbegrenzung, seccion="Stadt", und {attr.seccion} liest es auf den angefügten Seiten genauso wie auf der Doppelseite selbst.
Das ganze Rezept
// ═══ 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 = 'de'; // @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-Beispiel · 110 Zeilen · content.de.md
title: "Patio · Nr. 31" --- ::::page{template="apertura" foto="azotea" seccion="Stadt"} # Die Bienen auf dem Dach :::frame{slot="datos" title="Das Dach in Zahlen"} **14** Bienenvölker auf dem Dach **23 m** über der Straße **380 kg** Honig bei der Juliernte **3 km** Flugradius, ungefähr ::: :::frame{slot="entradilla"} :::paragraphs{style="entradilla"} Dreißig Jahre lang hat Carmen Aldaba im zweiten Stock einer Druckerei Bücher von Hand gesetzt. Als die Druckerei schloss, stieg sie aufs Dach. Dort hält sie heute vierzehn Völker, eine halbe Million Bienen, die in den Linden der halben Stadt sammeln. ::: :::paragraphs{style="firma"} Text: Lucía Ferrán ::: ::: :::frame{slot="cita"} „Ein Bienenstock ist wie ein Setzkasten: Wenn du weißt, wo alles liegt, musst du nicht hinsehen.“ ::: ::resource{id="cesto" slot="recorte"} :::paragraphs{style="inicio"} Um halb sieben am Morgen hält das Dach noch die Kälte der Nacht. Carmen Aldaba steigt die Eisentreppe hinauf, den brennenden Smoker in der einen Hand, eine Kiste mit leeren Rähmchen in der anderen, und die Taschenlampe lässt sie aus: Die zweiundzwanzig Stufen kennt sie auswendig. Oben stehen in einer Reihe auf dem Kies vierzehn Holzbeuten, grün, ocker und blau gestrichen, jede mit einem Kreidebuchstaben auf dem Deckel. ::: „A ist die Stechfreudigste“, sagt sie und hebelt einen Deckel auf. „M ist die Fleißigste. Die anderen kommen und gehen.“ Dreißig Jahre lang arbeitete Aldaba zwei Stockwerke tiefer, in der Setzerei von Gráficas Lezcano, einer Familiendruckerei, die Eisenwarenkataloge druckte, Festprogramme, Gedichtbände auf Kosten ihrer Verfasser und eine lange Zeit auch die Fahrpläne der Stadtbusse. Mit siebzehn fing sie als Lehrling an, als noch von Hand aus Bleilettern gesetzt wurde, und ging 2011, an dem Tag, an dem die Familie die Maschinen an einen Schrotthändler aus Calatayud verkaufte. Das Gebäude übernahm eine Werkstättengenossenschaft. Heute arbeiten darin ein Geigenbauer, zwei Designbüros, eine Töpferschule und eine Bäckerei, die im früheren Papierlager vor Tagesanbruch ihre Öfen anheizt. ## Ein Dach, das niemand brauchte Für das Dach hatte niemand Verwendung. Es war undicht, das Geländer verrostet, und der Schornstein des alten Heizkessels stand noch, weil sich die Genossenschaft nicht traute, ihn abzureißen. In einer Mitgliederversammlung schlug Aldaba vor, zwei Völker „zum Ausprobieren“ aufzustellen, und die Versammlung stimmte zu, unter einer Bedingung: Niemand aus den Werkstätten sollte je in ihre Nähe müssen. Das war im Frühjahr 2013. Aus zwei Völkern sind vierzehn geworden, und der Dachhonig wird in der Bäckerei verkauft, in Viertelkilogläsern mit einem Etikett, das sie selbst auf der alten Tiegeldruckpresse mit Fußantrieb setzt, die im Hof zurückgeblieben ist. Das Handwerk hat sie von ihrem Großvater gelernt, der in einem Dorf in der Provinz Teruel Bienen in Kork- und Strohkörben hielt. Einer dieser geflochtenen Strohkörbe mit dem gewölbten Flugloch am Fuß steht jetzt leer in einer Ecke des Dachs, auf einem Betonklotz. Sie benutzt ihn nicht: Moderne Beuten öffnet man von oben und kontrolliert sie Wabe für Wabe, einen Korb kann man nur leeren, indem man die Waben herausbricht. Aber sie hat ihn aus dem Dorf mitgenommen, als das Haus verkauft wurde, und wollte ihn nicht in einen Abstellraum stellen. ## Wovon Stadtbienen leben Am häufigsten wird sie gefragt, wovon Bienen mitten im Asphalt leben. Die Antwort steht an den Straßen. Eine Arbeiterin fliegt ohne Mühe drei Kilometer weit, und in einem Kreis dieser Größe um die Druckerei stehen Tausende Straßenbäume: Linden, weiß blühende Robinien, Schnurbäume, Zürgelbäume und Platanen, die keinen Nektar geben, aber Harz. Dazu kommen Parks mit Rosmarin und Lavendel, Brachflächen voller Wildrauke und Hunderte Balkone mit Geranien, Basilikum und Tomatenstauden. „Auf dem Land hängt ein Volk davon ab, was in dem Jahr gesät wird“, erklärt sie. „Pflanzt der Nachbar Sonnenblumen, hast du Sonnenblumenhonig. Hier gibt es von allem etwas, und fast jeden Monat blüht irgendetwas.“ Deshalb wechselt der Dachhonig im Lauf des Jahres die Farbe. Der Junihonig aus der Lindenblüte ist hell und riecht nach Minze. Der vom Spätsommer, nach den Schnurbäumen, ist dunkel bernsteinfarben und ein wenig bitter. Aldaba mischt nicht: Jede Ernte kommt in eigene Gläser, mit dem Datum und dem Namen des Baums, der in jenen Wochen am meisten blühte. Im Juli schleuderte sie dreihundertachtzig Kilo, ihr bestes Jahr, seit sie angefangen hat. ## Die Nachbarn Anfangs gab es Beschwerden. Eine Frau im Block gegenüber, die ihre Wäsche auf einer Terrasse in derselben Höhe aufhängt, schrieb ans Rathaus, weil sich die Bienen auf ihre Bettlaken setzten. Aldaba besuchte sie mit einem Glas Honig und einem Vorschlag: die Fluglöcher so zu drehen, dass die Bienen über den Innenhof ausfliegen und nicht zu ihrer Terrasse. Es klappte. Die Nachbarin heißt Rosario, ist einundachtzig und kauft jetzt jeden Herbst zwei Gläser. Den größten Schreck gab es an einem Morgen im Mai 2024. Ein Volk schwärmte, wie es starke Völker im Frühjahr tun: Die alte Königin zog mit der Hälfte der Arbeiterinnen aus, um ein neues Zuhause zu suchen, und die Schwarmtraube, rund fünfzehntausend Bienen, hängte sich zwei Straßen weiter an das Dach eines Bushaltestellenwartehäuschens. Jemand rief die Feuerwehr, die Feuerwehr rief die Stadtpolizei, und die Stadtpolizei, die ihre Nummer schon hatte, rief Aldaba an. Nach zehn Minuten war sie mit einem Pappkarton und einem Bettlaken da, schüttelte die Traube in den Karton und trug ihn aufs Dach. „Ein Schwarm sticht nicht. Die Bienen sind voller Honig und haben nichts zu verteidigen“, sagt sie. „Das wissen die Leute an der Haltestelle natürlich nicht.“ ## Hitze und Milben Ein Dach ist kein leichter Standort für ein Volk. Im August wird der Kies über fünfzig Grad heiß, und das Holz der Beuten heizt sich auf wie ein Bügeleisen. Bienen kühlen ihren Stock, indem sie Wasser eintragen und es mit den Flügeln verdunsten lassen, und ein großes Volk braucht dafür mehr als einen Liter am Tag. Aldaba stellt ihnen Tränken mit schwimmendem Kork hin und hat über die Beutenreihe ein Schattiernetz gespannt, das am Schornstein und am Geländer festgebunden ist. Im Sommer steigt sie zweimal täglich hinauf, bei Sonnenaufgang und in der Abenddämmerung, nie zur Mittagszeit. Der andere Feind ist die Varroamilbe, ein aus Asien eingeschleppter Parasit, der sich von den Larven ernährt und Viren überträgt. Kein Bienenvolk in Europa ist frei davon. Aldaba zählt die Milben, die auf eine Bodenwindel unter jeder Beute fallen, und behandelt im Winter mit Oxalsäure, wenn das Volk brutfrei ist und sich die Milben nicht in den Zellen verstecken können. Für jedes Volk führt sie ein Heft, den Buchstaben auf dem Umschlag, und notiert jede Durchsicht: das Gewicht, die Zahl der Brutwaben, das Temperament der Bienen, das Wetter. ## Ein Volk von innen Mit ihr ein Volk zu öffnen, ist Unterricht ohne Tafel. Sie zieht eine Wabe heraus, hält sie gegen die tief stehende Sonne und zeigt mit dem behandschuhten Finger: hier die verdeckelte Brut unter Wachsdeckeln in der Farbe von Butterkeksen; hier die Larven, weiß und eingerollt am Grund jeder Zelle; oben am Rand der Honig; dazwischen ein Band aus Pollen in vielen Farben, orange von den Ringelblumen, grau von den Zürgelbäumen, fast schwarz vom Mohn. Im Juli zählt ein starkes Volk fünfzigtausend Arbeiterinnen, einige hundert Drohnen und eine einzige Königin, die bis zu zweitausend Eier am Tag legt und vier oder fünf Jahre lebt, wenn niemand sie ersetzt. „Die Königin regiert nicht“, stellt sie klar. „Sie legt Eier. Entscheiden tun die Arbeiterinnen: wann eine neue Königin aufgezogen wird, wann geschwärmt wird, welche Blüte diese Woche angeflogen wird.“ Die Königin im Gewimmel zu finden, braucht Geduld. Sie ist länger als die anderen, mit spitzem Hinterleib und kurzen Flügeln, und die Arbeiterinnen um sie herum bilden einen Kreis, die Köpfe zu ihr gewandt. Aldaba markiert jede Königin mit einem Farbpunkt auf dem Brustteil, in einer Farbe, die vom Schlupfjahr abhängt, nach einem internationalen Farbcode, den jeder Imker lesen kann: weiß, gelb, rot, grün und blau, dann von vorn. In diesem Jahr ist Weiß dran. ## Samstage im Oktober Seit vier Jahren gibt sie an den Oktobersamstagen einen Anfängerkurs für acht Personen, in der alten Setzerei. Es gibt eine Warteliste. Es melden sich vor allem Rentner und Lehrerinnen, mal ein Biologiestudent und im vergangenen Jahr ein Busfahrer, der den Schwarm an der Haltestelle von seinem Fahrersitz aus gesehen hatte und wissen wollte, was da los gewesen war. Die Kursregeln passen auf eine Karteikarte: kein Parfüm, keine schwarze Kleidung, keine hastigen Bewegungen, nicht auf die Waben pusten. Am Ende der vierten Stunde öffnet jeder Teilnehmer ein Volk allein, sie steht daneben, die Hände in den Taschen. „Schwierig ist nicht die Technik“, sagt sie. „Schwierig ist, still zu halten, wenn dir vier Bienen über den Schleier spazieren.“ ## Was ein Glas kostet Der Dachhonig kostet sieben Euro das Viertelkiloglas, im vergangenen Jahr wurden etwa zwölfhundert verkauft. Die Abrechnung, die Aldaba in einem weiteren Heft führt, weist keinen Gewinn aus. Von den Einnahmen bezahlt sie Gläser und Etiketten, den Zucker, mit dem sie im Herbst schwache Völker füttert, die Varroabehandlung, die Mittelwände für neue Rähmchen und eine Haftpflichtversicherung, die die Genossenschaft vom ersten Jahr an verlangt hat. Für jedes verkaufte Glas geht ein Euro in die Gemeinschaftskasse der Werkstätten, „als Dachmiete“. Von diesem Geld wurde im letzten Jahr das undichte Dach repariert. Was übrig bleibt, wenn etwas übrig bleibt, gibt sie für Bücher aus. Im ehemaligen Büro des Setzereileiters hat sie eine kleine Imkereibibliothek, zum Teil selbst gebunden, mit Lehrbüchern aus dem neunzehnten Jahrhundert vom Flohmarkt und Zeitschriften von Imkervereinen aus halb Europa. ## Andere Dächer Das Dach von Gráficas Lezcano ist nicht mehr das einzige. Seit 2019 stehen Bienenvölker auf dem Dach eines Gymnasiums, eines Hotels in der Innenstadt und auf der Terrasse eines Seniorenwohnheims, wo eine Gruppe von Bewohnern sie mit Hilfe eines Imkers aus dem Viertel betreut. Aldaba hat fast alle beraten, und allen sagt sie dasselbe: nicht mehr Völker aufzustellen, als das Viertel ernähren kann. „Stellst du auf einen Quadratkilometer hundert Völker, nehmen sich die Honigbienen gegenseitig das Futter weg, und den Hummeln und Wildbienen auch, die viele Pflanzen der Stadt erst richtig bestäuben“, sagt sie. „Vierzehn sind genug für diese Straße.“ Deshalb hat sie in diesem Jahr kein einziges Volk geteilt, obwohl drei stark genug gewesen wären. Lieber lässt sie den Bienenüberschuss im Frühjahr als Schwarm ausziehen, den dann ein Nachbar mit Bienenstöcken auf dem Land abholt. An der Tür zum Treppenhaus klebt eine Telefonliste, neben dem Kalender der Genossenschaft, und die Stadtpolizei hat eine Kopie. ## Ernte in der Setzerei Geschleudert wird dort, wo sie so viele Jahre gearbeitet hat. Die Setzerei steht seit 2011 leer, bis auf zwei hölzerne Setzregale mit ihren Kästen, die die Genossenschaft „für alle Fälle“ behalten hat. Dort, auf einem langen Tisch, entdeckelt Aldaba die Waben mit einem heißen Messer und stellt sie in eine Vierwabenschleuder mit Handkurbel. Der Honig läuft durch ein Doppelsieb in einen Hobbock und ruht eine Woche, bevor er abgefüllt wird. Die Kinder aus der Töpferschule kommen zum Zuschauen herunter. Manche nehmen ein Stück Wabe auf einem Teller mit, andere sind lieber an den Setzkästen, und Aldaba lässt sie ihren Namen im Winkelhaken setzen und auf der Tiegelpresse drucken, neben dem Honigetikett. „Ein Bienenstock ist wie ein Setzkasten“, sagt sie ihnen. „Alles hat seinen Platz. Wenn du weißt, wo es liegt, musst du nicht hinsehen.“ Ende Oktober wird es still auf dem Dach. Die Winterbienen, die länger leben als die Sommerbienen, drängen sich in der Mitte jeder Beute um die Königin, und Aldaba steigt nur noch hinauf, um nachzusehen, ob kein Wasser eingedrungen ist. Sie lässt den Völkern genug Honig für den Winter und öffnet sie erst im März wieder, wenn im Park die Mandelbäume blühen und die ersten Arbeiterinnen Pollen holen. Bis dahin wird sie wissen, welche Völker die Kälte überstanden haben und ob A noch immer die Stechfreudigste ist. :::paragraphs{style="colofon"} Patio ist eine für dieses Kochbuch erfundene Zeitschrift, ebenso Carmen Aldaba, Gráficas Lezcano und ihr Dach. Fotos mit Diffusionsmodellen erzeugt. Gesetzt aus Literata, Fraunces und 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: in jedem Rezept gleich · 315 Zeilen
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Den Bienenkorb nach seinem Rechteck umfließen
Ohne wrap: 'contour' verdrängt der Rahmen mit seinem Rechteck, und die Zeilen neben der Kuppel enden an einer geraden Kante.
- { name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58),
+ { name: 'recorte', exclude: 'push', layer: 2, width: mm(58),#Den Fortsetzungsvermerk weglassen
Mit mark: false endet das abgeschnittene Stück auf seiner letzten Zeile, ohne Wird fortgesetzt, und die Spalte fasst eine Zeile mehr.
- overflow: { continueIn: 'template:seguido' } },
+ overflow: { continueIn: 'template:seguido', mark: false } },Häufige Fehler
- Eine Überschrift, die die Vorlage in einen eigenen Rahmen schickt, eröffnet in den Textrahmen keine Initiale. Der erste Absatz der Reportage steht in
:::paragraphs{style="inicio"}, und das Format trägt dasdropCap.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Bilder
- Painted hives on a factory roof at sunrise (the opening spread) · Generated With Diffusion Models · eigener Text
- A straw bee skep on a transparent ground (the cut-out the text runs round) · Generated With Diffusion Models · eigener Text
- Schriften
- Literata (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


