Zum Hauptinhalt springen
Rezept Nummer 42

Rezepte · Kapitel 1 · Seite und Raster

Vereinsblatt: Aufmacher und Kurzmeldungen

Zweiseitiges Kleingärtnerblatt, anderthalbspaltig: Der Aufmacher füllt die breite Spalte, springt auf Seite 2; Umbrüche schicken Meldungen in die schmale.

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

S. 1 von 2

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 210 × 297 mm
  • Anderthalb Spalten, 8 mm Spaltenabstand
  • Work Sans 9,4/13,4
  • Courier Prime
  • Titan One
  • 2 Seiten
  • Stufe
  • Postext 1.29.1
  • In 68 ms gesetzt
  • 168 Zeilen Code

Kurz gesagt

Ein zweiseitiges Mitteilungsblatt für einen Gartenverein. Der Hauptartikel füllt eine breite Spalte und geht auf Seite 2 weiter, kurze Meldungen füllen eine schmale Spalte daneben.

Was Sie setzen

Die Frühjahrsausgabe von The Allotment Gazette, dem Mitteilungsblatt eines erfundenen Kleingartenvereins, beidseitig auf ein A4-Blatt gedruckt. Ein strohfarbenes Band trägt den Namen in der Titan One auf zwei Zeilen, eine Reihe Setzlinge an seinem Fuß und einen tomatenroten Tab mit Ausgabenummer und Jahreszeit. Darunter teilt sich die Seite in eine 113 mm und eine 57 mm breite Spalte. Der Aufmacher über drei neue Kompostboxen füllt die breite Spalte unter einem in Code gezeichneten Plan der Anlage und endet bei einem Etikett „Continued on page 2“. Die schmale Spalte enthält fünf Kurzmeldungen, jede beginnend mit pillenförmigen Etiketten. Seite 2 nimmt den Artikel unter einer Fortsetzungsüberschrift wieder auf und zentriert ein Temperaturdiagramm in der Spalte. Weiter unten läuft der Absatz über ein Pflanzbeet neben dessen Zeichnung, die rechts in der Spalte steht. Acht Bekanntmachungen füllen die schmale Spalte.

Dieses Rezept beantwortet

  • Wie setze ich ein Layout mit anderthalb Spalten, mit breiter Textspalte und schmaler Seitenspalte?
  • Kann Postext drei oder mehr Textspalten setzen oder Text um ein Bild fließen lassen?
  • Wie setze ich zwei Spalten in einen Kasten (eine Textspalte neben einer Abbildungsspalte)?
  • Wie erstelle ich Inline-Chips: Tastaturtasten, Schlagwörter, Wortspeicher für Übungen?

Die kurze Antwort

script.js · Zeilen 33–49im vollständigen Code
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column

Zutaten

Schrift
Work Sans, Titan One, Courier Prime (SIL OFL 1.1)
Dateien
  • The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

Zubereitung

#1 · Das Markdown jede Spalte füllen lassen

Die Layouteinstellungen stehen oben in der kurzen Antwort. In einem Layout oneAndHalf nimmt die schmale Spalte standardmäßig Text auf, und der Text läuft die breite Spalte hinunter, dann die schmale, dann die breite Spalte der nächsten Seite (Layouttypen). Ein :::columnbreak beendet eine Spalte dort, wo es steht (Direktiven): Nach dem Etikett „Continued on page 2“ des Aufmachers eröffnet es die schmale Spalte für die Kurzmeldungen, und nach der letzten Kurzmeldung, in der letzten Spalte der Seite, eröffnet es Seite 2. In dieser Ausgabe sind beide Spalten von Seite 1 so eingepasst, dass sie auf derselben Rasterlinie enden, 275 mm unter der Oberkante; wer einen der beiden Umbrüche löscht, ändert die Seiten also nicht. Die Umbrüche zählen, sobald sich der Text ändert. Streichen Sie den zweiten Absatz des Aufmachers, und ohne den ersten Umbruch beginnt „In brief“ 247 mm tief in der breiten Spalte unter dem Etikett, und die erste Kurzmeldung läuft in die schmale Spalte über, wo ihre zweite Hälfte für die Satzbreite von 57 mm neu umbrochen wird.

#2 · Den Zeitungskopf aus dem Frontmatter bauen

script.js · Zeilen 53–75im vollständigen Code
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band is 62 mm deep. The column rule starts under the masthead, where the text does.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // 54 pt from line to line
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the frontmatter.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };

Die H1 nimmt einen Überschriftenstil, dessen Gestaltung den Zeitungskopf zeichnet, und span: 'page' setzt diese Gestaltung über beide Spalten. {titleText} in 60 pt bricht in seinem 178 mm breiten Kasten in zwei Zeilen um. Der Tab verbindet {attr.issue}, in der H1-Zeile geschrieben, mit {publishDate} aus dem Frontmatter (Textelemente). Ohne minHeight würde der Kopf Platz bis zum Fuß des Bands reservieren, 62 mm unter der Oberkante, und der Text begänne auf der nächsten Rasterlinie bei 62,5 mm, direkt am Band. Ein minHeight von zehn Rasterlinien lässt ihn bei 67 mm beginnen.

#3 · Die Kurzmeldungen mit Chips etikettieren

script.js · Zeilen 79–89im vollständigen Code
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];

Wer chipStyles deklariert, ersetzt den eingebauten blassblauen Chip, und ein Chip ohne style nimmt den ersten (Chip-Stile). Ein Radius, der größer ist als die halbe Höhe des Kastens, zeichnet eine Pille. Ein Chip bricht in sich nie um, eine Zeile bricht also nur am Leerzeichen vor oder nach einem Chip, und ein Datum wie „Sat 11 April“ bleibt auf einer Zeile. Die Etiketten sind im Markdown in Versalien getippt, weil Chip-Stile keine Einstellung für Groß- und Kleinschreibung haben. Der vertikale Innenabstand wird außerhalb der Zeile gezeichnet, sodass das 13,4-pt-Raster hält.

#4 · Den Text neben der Zeichnung laufen lassen

script.js · Zeilen 93–98im vollständigen Code
// wrap sets the drawing at that side of its column, width of it, and the text after the
// ::resource line runs beside it at the measure left over, then under it at full width:
//   #### Sixteen squares by the gate {style="kicker"}
//   ::resource{id="bed"}                     ← 51 mm at the right of the 113 mm column
//   The demonstration bed by the gate is 1.2 metres square …   ← beside it, then under it
const WRAP = { wrap: 'right', width: 0.45 }; // a body line of white between it and the text

wrap: 'right' setzt die Beetzeichnung an den rechten Rand der 113 mm breiten Spalte, 45 % davon breit (51 mm), und der Absatz nach der Zeile ::resource läuft daneben auf den verbleibenden 57 mm, eine Grundtextzeile Abstand zur Zeichnung und ihrer Bildunterschrift (Textumfluss). Die Oberkante der Zeichnung steht auf Höhe der ersten Zeile des Absatzes, und jede Zeile bleibt im 13,4-pt-Raster. Ein längerer Absatz liefe unter der Zeichnung auf vollen 113 mm weiter, als ein Absatz; eine Überschrift oder eine weitere Abbildung, die käme, solange die Zeichnung noch neben dem Text steht, rückte darunter. Die Überschrift der Ebene 4 darüber nimmt einen Überschriftenstil, der nur ihre Farbe in das Blattgrün ändert.

#5 · Ein schmales Diagramm in seinem Band zentrieren

script.js · Zeilen 102–114im vollständigen Code
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50, WRAP), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];

width: 0.62 setzt das Diagramm 70,1 mm breit in die 113 mm breite Spalte, und align: 'center' lässt auf jeder Seite 21,5 mm Papier frei (Platzierung). Der Text darüber und darunter behält die volle Satzbreite und fließt nie in diesen Weißraum. Die Zeichnungen gehören zu einem Ressourcentyp mit leerem captionPrefix, sodass jede Bildunterschrift mit ihren eigenen fetten Wörtern beginnt, ohne ein „Figure 1“ davor.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 042 · Community newsletter: lead story and briefs ═════
// https://postext.dev/en/cookbook/community-newsletter
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Work Sans, Titan One, Courier Prime (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, inlineSvgFonts,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'community-newsletter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: garden colours, every one linked by id
// ink: text · leaf: nameplate and headlines · tomato, the accent: tags, tab, column heads ·
// straw: the band · marigold: second tag · tint: pale greens · rule: hairlines · muted: notes
const palette = { ink: '#1d211c', leaf: '#2f6b3a', tomato: '#c43f2a', straw: '#f2d492',
  marigold: '#e2b33c', tint: '#eef4e8', rule: '#cfd3c6', muted: '#5f6659', paper: '#fbfaf5' };
// Each colour carries its hex and the palette entry it follows.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the leaf, never blue
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Work Sans', 'Titan One', 'Courier Prime'];
const SIDE = 16; // mm: the side margins of one A4 sheet, printed on both sides
const LEAD = 13.4; // body leading in pt: the baseline grid
const caps = (size, colour) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  textTransform: 'uppercase', letterSpacing: pt(size * 0.12), color: col(colour) });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: a wide column for the lead, a narrow one for the briefs, one flow
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column
// #endregion

// #region masthead: the H1 is the nameplate; the issue tab reads the frontmatter
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band is 62 mm deep. The column rule starts under the masthead, where the text does.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // 54 pt from line to line
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the frontmatter.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };
// #endregion

// #region tags: four chip styles replace the built-in pale-blue chip
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];
// #endregion

// #region wrap: the bed drawing at the right of the column, the paragraph beside it
// wrap sets the drawing at that side of its column, width of it, and the text after the
// ::resource line runs beside it at the measure left over, then under it at full width:
//   #### Sixteen squares by the gate {style="kicker"}
//   ::resource{id="bed"}                     ← 51 mm at the right of the 113 mm column
//   The demonstration bed by the gate is 1.2 metres square …   ← beside it, then under it
const WRAP = { wrap: 'right', width: 0.45 }; // a body line of white between it and the text
// #endregion

// #region band: a narrower figure without wrap keeps the whole band of its column
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50, WRAP), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
// #endregion

const head = (id, content, edge, x, y, look) => ({ kind: 'text', id, content, pages: 'body',
  align: edge.slice(4), placement: at('page', edge, x, y), ...look });
const folio = { elements: [ // page 2's head: title and date over a hairline, the folio right
  head('name', '{title} · {publishDate}', 'top-left', SIDE, 11, caps(7.5, 'leaf')),
  head('folio', '{pageNumber}', 'top-right', -SIDE, 9.6,
    { fontFamily: DISPLAY, fontSize: pt(12), color: col('tomato') }),
  { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), pages: 'body',
    placement: { ...at('page', 'top-left', SIDE, 15.5), size: { width: mm(210 - 2 * SIDE) } } },
] };

const label = (size, look = {}) => ({ fontFamily: LABEL, fontSize: pt(size), ...look });
const config = () => ({
  colorPalette, resourceTypes, chipStyles, layout,
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  page: { width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(20), bottom: mm(18), left: mm(SIDE), right: mm(SIDE) } },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    referenceColor: col('leaf'), // a :ref label, if one is added, prints in the leaf green
    // Ragged, and so without hyphens: bodyText.hyphenation.ragged is off by default.
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('leaf'), levels: [
    // span: 'page' sets the masthead over both columns.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(24), lineHeight: pt(2 * LEAD), marginTop: pt(0),
      marginBottom: pt(LEAD / 2) },
    { level: 3, fontFamily: TEXT, fontWeight: 700, fontSize: pt(10.2), lineHeight: pt(LEAD),
      color: col('ink'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a brief's name
    { level: 4, ...caps(8.5, 'tomato'), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }, // a notice's name
  ] },
  headingStyles: [
    // marginBottom 0: the level's 0.5 em is added under minHeight and would drop the text a line
    { id: 'masthead', marginBottom: pt(0), advancedDesign: masthead },
    { id: 'rail', fontSize: pt(17), lineHeight: pt(2 * LEAD), color: col('tomato') },
    { id: 'jump', fontSize: pt(19), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD / 2) },
    { id: 'kicker', color: col('leaf') }, // the level-4 caps in the leaf green: a box head
  ],
  paragraphStyles: [
    { id: 'standfirst', fontSize: pt(12), lineHeight: pt(16) },
    { id: 'byline', ...label(8, { color: col('muted'), marginBottom: pt(LEAD / 2) }) },
    { id: 'jump', ...label(8, { textAlign: 'right' }) }, // 'Continued on page 2'
    { id: 'from', ...label(8) }, // 'Continued from page 1'
    { id: 'colophon', ...label(7, { lineHeight: pt(9.4), color: col('muted'),
      marginTop: pt(LEAD) }) },
  ],
  captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), gap: mm(1.6),
    note: { fontFamily: LABEL, fontSize: pt(6.8), color: col('muted') } },
  header: folio,
  footer: { elements: [] }, // the folio is at the head of page 2; page 1 has the masthead
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown-Beispiel · 114 Zeilen · content.en.mdtitle: "The Allotment Gazette" subtitle: "Newsletter of the Wren Lane Allotment Society" publishDate: "Spring 2026" --- # The Allotment Gazette {style="masthead" issue="No. 47"} :chip[SITE NEWS]{style="tag"} ## Shared compost bays open by the top gate :::paragraphs{style="standfirst"} Sixty plots now feed three timber bays instead of a heap each, and bay one reached 63 °C in five days. Its compost goes back to the plots in September. ::: :::paragraphs{style="byline"} Maureen Adeyemi, site secretary ::: ::resource{id="plan"} On the first Saturday of March eleven of us put up three bays of reclaimed scaffold boards by the top gate. Each is a metre and a half square and a metre high, with loose boards at the front that lift out, so a heap can be forked into the next bay. Hallam’s yard on Station Road let us have the boards for the cost of the van, and Ron Ellis brought his drill. Until now each plot has kept its own heap, and most of those heaps never get warm. A heap needs about a cubic metre of mixed material before it holds its heat, and few of us fill that much in a season. In one bay, with the stable manure the riding school leaves at the gate, the same waste heats up within days. :::paragraphs{style="jump"} :chip[Continued on page 2]{style="jump"} ::: :::columnbreak ## In brief {style="rail"} ### Path day :chip[WORK PARTY]{style="tag"} :chip[Sun 12 April]{style="when"} The grass paths between the plots need edging, and the main path a fresh load of gravel. Andy Price brings his edging iron; there are spades and barrows. Meet at the hut at nine. ### Seed swap :chip[EVENT]{style="tag"} :chip[Sat 11 April]{style="when"} Bring spare seed in labelled envelopes to the hut between ten and twelve. Seed saved from open-pollinated plants is welcome; seed from F1 hybrids will not come true. Tea and flapjack, 50p. ### Stable manure :chip[FREE]{style="free"} Whitecross Riding School leaves a trailer-load at the top gate every other Tuesday. Take what your plot needs, but top up bay one first, and keep the gateway clear for the tractor. ### Jam jars and a gazebo :chip[WANTED]{style="tag"} Clean jars with lids for the jam stall at the summer show, and a gazebo to borrow on Saturday 18 July. Leave jars in the crate by the hut door; Sue Marsh collects them on Fridays. ### New neighbours :chip[WELCOME]{style="free"} Four plots changed hands this winter. Welcome to the Okafors on plot 9, Priya and Tom on 23a, Mr Hensley on 31, and the Year 5 class at St Anne’s, who share plot 40 with Mrs Dunn. :::columnbreak :::paragraphs{style="from"} :chip[Continued from page 1]{style="jump"} ::: ## What goes into the bays {style="jump"} Bay one was filled on 7 March. On the 12th the thermometer at its centre read 63 °C, and the heap stayed at 55 °C or more for six days, long enough to kill most weed seeds. The morning readings are chalked on the board by the gate. Fill one bay at a time, with green and brown in equal barrow-loads: grass cuttings, weeds without seed heads, spent crops and peelings with torn cardboard, straw and manure. Chop anything thicker than a thumb. Keep out clubroot-infected brassica roots, bindweed and couch grass, and cooked food, which brings rats. ::resource{id="heat"} Turning lets air into the middle of the heap, and the temperature climbs again: bay one, turned on 18 March, was back above 60 °C two days later. The rota on the hut door puts two plots in charge of the fork each week. In September every plot can take away two barrow-loads of finished compost, and whatever is left goes on the demonstration bed by the gate. #### Sixteen squares by the gate {style="kicker"} ::resource{id="bed"} The demonstration bed by the gate is 1.2 metres square, edged with the same scaffold boards as the bays. This year it holds compost from the council depot, 15 centimetres deep; next spring it gets our own. Strings divide the bed into sixteen squares, 30 centimetres a side, each planted with one, four, nine or sixteen plants according to the size of the crop. Year 5 from St Anne’s planted it on 20 March and will keep its diary. :::columnbreak ## Notices {style="rail"} #### Rents Rents for 2026 are £52 for a full plot and £28 for a half, due by the end of April. Pay Dev Sharma on plot 18, or by bank transfer: the details are on the hut door. #### Water The troughs are filled from 1 April to the end of October. Use a hose to fill cans only, and never leave one running on a bed. #### Bonfires None from April to September, and none at any time when the wind blows towards the houses on Wren Lane. #### Sheds A new shed over two metres tall needs the committee’s approval. Send a sketch with its size to the secretary before you buy. #### Waiting list Thirty-one names are on the list. We expect to offer eight plots this year, most of them half plots. #### Bees On sunny Saturdays in April, Jan inspects the two hives on plot 52. Keep to the main path while her flag is up. #### Lost A green ten-litre watering can with a brass rose. Please bring it back to plot 7. #### Next issue Please leave copy for the summer issue in the letterbox on the hut door by 15 June. :::paragraphs{style="colophon"} The Allotment Gazette is written and put together by members of the Wren Lane Allotment Society. Set in Work Sans, Titan One and Courier Prime (SIL Open Font License) · Text: original, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the site plan, the heap's temperature and the square-metre bed function mulberry32(seed) { // a seeded PRNG: the same plots on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k const f = (v) => +v.toFixed(2); const P = palette; const SOIL = mix(P.tomato, P.ink, 0.62); const EARTH = mix(SOIL, P.straw, 0.28); const GRASS = mix(P.tint, P.leaf, 0.3); const DEEP = mix(P.leaf, P.ink, 0.35); const svgOf = (w, h, body, face = '') => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}"${face}>${body}</svg>`; const dot = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`; const rect = (x, y, w, h, fill, extra = '') => `<rect x="${f(x)}" y="${f(y)}" width="${f(w)}" ` + `height="${f(h)}" fill="${fill}"${extra}/>`; const line = (x1, y1, x2, y2, stroke, width) => `<path d="M${f(x1)} ${f(y1)}L${f(x2)} ${f(y2)}" ` + `stroke="${stroke}" stroke-width="${width}" stroke-linecap="round"/>`; // One plot seen from above: rows of crops across it, sometimes a shed or a greenhouse. function plot(x, y, w, h, rand, extra) { let out = rect(x, y, w, h, EARTH); const strips = 2 + Math.floor(rand() * 3); let top = y + 0.8; for (let s = 0; s < strips; s++) { const room = y + h - 0.8 - top; if (room < 2) break; let sh = s === strips - 1 ? room : Math.max(4, (h - 1.6) * (0.2 + rand() * 0.3)); if (room - sh < 4) sh = room; // no strip thinner than 4 mm const kind = Math.floor(rand() * 6); const [x0, x1] = [x + 0.9, x + w - 0.9]; if (kind === 0) { // rows of lettuce and onions for (let yy = top + 1; yy < top + sh - 0.6; yy += 1.7) { for (let xx = x0 + 0.6; xx < x1; xx += 1.5) out += dot(xx, yy, 0.55, P.leaf); } } else if (kind === 1) { // peas and beans up canes for (let yy = top + 1.2; yy < top + sh - 0.8; yy += 2.6) out += line(x0, yy, x1, yy, DEEP, 1); } else if (kind === 2) { // brassicas under netting out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, mix(P.tint, P.paper, 0.4)); for (let yy = top + 1.6; yy < top + sh - 1.2; yy += 3) { for (let xx = x0 + 1.4; xx < x1 - 0.8; xx += 3) { out += dot(xx, yy, 1.1, mix(P.leaf, P.rule, 0.45)); } } } else if (kind === 3) { // rhubarb and squashes for (let xx = x0 + 2; xx < x1 - 1; xx += 4 + rand() * 2) { const yy = top + sh / 2 + (rand() - 0.5) * (sh - 3); out += dot(xx, yy, 1.8, P.leaf) + dot(xx + 0.6, yy - 0.5, 0.45, P.marigold); } } else if (kind === 4) { // dug over and raked for (let yy = top + 0.7; yy < top + sh - 0.3; yy += 0.9) { out += line(x0, yy, x1, yy, SOIL, 0.25); } } else { // straw round the strawberries out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, P.straw); for (let xx = x0 + 1; xx < x1; xx += 2.2) { out += dot(xx, top + sh / 2 - 0.2, 0.8, P.leaf) + dot(xx + 0.4, top + sh / 2 + 0.3, 0.3, P.tomato); } } top += sh; } if (extra === 'shed') { // a shed with its felt roof in tomato, and a water butt const sx = rand() < 0.5 ? x + 0.6 : x + w - 5.1; out += rect(sx, y + 0.6, 4.5, 3.4, P.tomato) + line(sx, y + 2.3, sx + 4.5, y + 2.3, SOIL, 0.3) + dot(sx + (sx > x + 1 ? -1.3 : 5.8), y + 1.6, 0.9, P.ink); } else if (extra === 'glass') { // a greenhouse: glass over the whole end of the plot out += rect(x + 0.8, y + h - 7.2, w - 1.6, 6.4, mix(P.paper, P.tint, 0.6)) + [0.25, 0.5, 0.75].map((k) => line(x + 0.8 + k * (w - 1.6), y + h - 7.2, x + 0.8 + k * (w - 1.6), y + h - 0.8, P.rule, 0.3)).join(''); } return out; } function planSvg(face) { // 114 × 56.5 mm: the top of the Wren Lane site, north up const rand = mulberry32(1921); // the year the society took the lease const [W, H, PATH] = [114, 56.5, 26.5]; const label = (lx, ly, s, fill, anchor = 'start') => `<text x="${lx}" y="${ly}" ` + `font-size="2.3" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(0, 0, W, H, GRASS) + rect(0, PATH, W, 4.5, P.straw); // the main path, gravel out += label(W - 2, PATH + 3, t({ en: 'MAIN PATH', es: 'CAMINO' }), SOIL, 'end'); // The top gate at the end of the path, and the corner behind it. out += rect(0, PATH - 0.8, 1.2, 1.2, P.ink) + rect(0, PATH + 4.1, 1.2, 1.2, P.ink) + label(2.2, PATH + 3, t({ en: 'TOP GATE', es: 'PUERTA DE ARRIBA' }), SOIL); out += rect(2, 2.4, 11, 8, P.tomato) + rect(2, 6.4, 11, 4, mix(P.tomato, P.ink, 0.22)) // the hut + label(7.5, 7.2, t({ en: 'HUT', es: 'CASETA' }), P.paper, 'middle'); out += rect(15.5, 3.2, 11.5, 3.6, mix(P.rule, P.ink, 0.3)) // the trough + rect(16.1, 3.8, 10.3, 2.4, mix(P.tint, P.rule, 0.4)); for (let i = 0; i < 3; i++) { // the three bays in fresh boards: full, half full, empty const bx = 2 + i * 8.6; out += rect(bx, 13.5, 7.8, 7.8, P.marigold) + rect(bx + 0.8, 14.3, 6.2, 6.2, SOIL); for (let k = 0; k < 16 - i * 7; k++) { out += dot(bx + 1.5 + rand() * 4.8, 15 + rand() * 4.8, 0.5, i === 0 ? DEEP : EARTH); } out += label(bx + 3.9, 24.6, i + 1, P.ink, 'middle'); } out += dot(25.5, 10.5, 3.6, DEEP); // the old pear // Plots: four above the path, five below; one split in halves, grass paths between. const row = (x0, x1, y, h, n, extras) => { const w = (x1 - x0 - (n - 1) * 1.4) / n; for (let i = 0; i < n; i++) { const px = x0 + i * (w + 1.4); if (extras[i] === 'halves') { const half = (h - 1.4) / 2; out += plot(px, y, w, half, rand) + plot(px, y + half + 1.4, w, half, rand, 'shed'); } else out += plot(px, y, w, h, rand, extras[i]); } }; row(31, W - 2, 2, PATH - 3.5, 4, ['shed', 'halves', '', 'shed']); row(2, W - 2, PATH + 6.5, H - PATH - 8.5, 5, ['', 'shed', 'glass', '', 'shed']); return svgOf(W, H, out, face); } // The plan and the chart name their label face on the root element, and loadSvg embeds it. const FACE = ` font-family="${LABEL}" font-weight="700"`; const HEAT_LOG = [12, 24, 38, 49, 57, 63, 64, 62, 59, 55, 51, 47, 56, 61, 62, 60, 57, 54, 51, 48, 46, 44]; // °C at the heap's centre, 7 to 28 March; turned after the reading on the 18th function heatSvg(face) { // 68 × 40 mm; the plot runs 7–28 March and 0–70 °C const [W, H, X0, X1, Y0, Y1] = [68, 40, 9, 66, 3, 33]; const x = (day) => X0 + ((day - 7) / 21) * (X1 - X0); const y = (deg) => Y1 - (deg / 70) * (Y1 - Y0); const text = (tx, ty, s, fill, anchor = 'end', size = 2.5) => `<text x="${f(tx)}" y="${f(ty)}" ` + `font-size="${size}" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(X0, y(65), X1 - X0, y(55) - y(65), P.straw); // where weed seeds die for (const deg of [20, 40, 60]) { out += line(X0, y(deg), X1, y(deg), P.rule, 0.2) + text(X0 - 1.2, y(deg) + 0.9, deg, P.muted); } out += text(X0 - 1.2, y(70) + 0.9, '°C', P.muted); // the unit, over the scale for (const day of [7, 14, 21, 28]) { out += line(x(day), Y1, x(day), Y1 + 1, P.ink, 0.25) + text(x(day), Y1 + 3.8, day === 28 ? t({ en: '28 March', es: '28 marzo' }) : day, P.muted, day === 28 ? 'end' : 'middle'); } out += line(x(18.5), y(70), x(18.5), Y1, P.leaf, 0.35) + text(x(18.5) + 1, y(70) + 2.2, t({ en: 'turned', es: 'volteo' }), P.leaf, 'start'); const pts = HEAT_LOG.map((deg, i) => [x(7 + i), y(deg)]); out += `<path d="M${pts.map(([px, py]) => `${f(px)} ${f(py)}`).join('L')}" fill="none" ` + `stroke="${P.tomato}" stroke-width="0.55" stroke-linejoin="round"/>`; out += pts.map(([px, py]) => dot(px, py, 0.6, P.tomato)).join(''); out += line(X0, Y1, X1, Y1, P.ink, 0.3); return svgOf(W, H, out, face); } // The square-metre bed: sixteen squares of 30 cm, one to sixteen plants in each. const CROPS = { // plants per square, size and colour of each plant seen from above cabbage: [1, 4.2, mix(P.leaf, P.rule, 0.35)], lettuce: [4, 2.1, mix(P.leaf, P.straw, 0.35)], chard: [4, 1.9, mix(P.leaf, P.tomato, 0.3)], marigold: [4, 1.5, P.marigold], beetroot: [9, 1.1, mix(P.tomato, P.ink, 0.35)], beans: [9, 1.2, P.leaf], onion: [9, 0.8, mix(P.straw, P.paper, 0.3)], radish: [16, 0.6, P.tomato], carrot: [16, 0.55, mix(P.marigold, P.tomato, 0.35)], }; const BED = [['cabbage', 'lettuce', 'lettuce', 'marigold'], ['beetroot', 'radish', 'carrot', 'onion'], ['chard', 'beans', 'beetroot', 'radish'], ['marigold', 'onion', 'carrot', 'lettuce']]; function bedSvg() { // 50 × 50 mm: 1.2 m of bed inside its scaffold boards const [S, BOARD] = [50, 2.6]; const cell = (S - 2 * BOARD) / 4; let out = rect(0, 0, S, S, P.marigold) + rect(BOARD, BOARD, S - 2 * BOARD, S - 2 * BOARD, SOIL); BED.forEach((cropRow, r) => cropRow.forEach((crop, c) => { const [n, size, fill] = CROPS[crop]; const k = Math.sqrt(n); for (let i = 0; i < n; i++) { const cx = BOARD + c * cell + ((i % k) + 0.5) * (cell / k); const cy = BOARD + r * cell + (Math.floor(i / k) + 0.5) * (cell / k); out += dot(cx, cy, size, fill); if (crop === 'marigold') out += dot(cx, cy, size * 0.4, P.tomato); } })); for (let i = 1; i < 4; i++) { // the strings const at = BOARD + i * cell; out += line(at, BOARD, at, S - BOARD, P.paper, 0.25) + line(BOARD, at, S - BOARD, at, P.paper, 0.25); } return svgOf(S, S, out); } // A drill of seedlings along the foot of the masthead band, 210 × 8 mm: some show only their // seed leaves, the rest their first true leaves too. function leaf(x, y, len, wid, deg, fill) { // a leaf from its stalk end, deg from the vertical const a = (deg * Math.PI) / 180; const [dx, dy] = [Math.sin(a), -Math.cos(a)]; const [mx, my, px, py] = [x + (dx * len) / 2, y + (dy * len) / 2, -dy * wid, dx * wid]; return `<path d="M${f(x)} ${f(y)}Q${f(mx + px)} ${f(my + py)} ${f(x + dx * len)} ` + `${f(y + dy * len)}Q${f(mx - px)} ${f(my - py)} ${f(x)} ${f(y)}Z" fill="${fill}"/>`; } function drillSvg() { const rand = mulberry32(47); // the issue number const [W, H, RIDGE] = [210, 8, 1.3]; const SEED = mix(P.leaf, P.straw, 0.3); let out = rect(0, H - RIDGE, W, RIDGE, SOIL); for (let x = 2.6; x < W - 1; x += 4.6 + rand() * 1.2) { const ground = H - RIDGE; const grown = rand() < 0.62; const stem = grown ? 2.6 + rand() * 1.2 : 1.2 + rand() * 0.8; const lean = (rand() - 0.5) * 10; out += line(x, ground, x, ground - stem, P.leaf, 0.45); const top = ground - stem; if (grown) { out += leaf(x, ground - stem * 0.45, 1.6, 0.55, -68 + lean, SEED) + leaf(x, ground - stem * 0.45, 1.6, 0.55, 68 + lean, SEED) + leaf(x, top, 2.6 + rand() * 0.5, 1, -32 + lean, P.leaf) + leaf(x, top, 2.6 + rand() * 0.5, 1, 32 + lean, P.leaf); } else { out += leaf(x, top, 1.7, 0.6, -58 + lean, SEED) + leaf(x, top, 1.7, 0.6, 58 + lean, SEED); } } return svgOf(W, H, out); } // What the drawings say under them, in the sample's language: caption, credit note, alt text. const CAPTIONS = { plan: [t({ en: '**The top of the Wren Lane site, from the air.** The three new bays stand between ' + 'the hut and the top gate.', es: '**La parte alta del Soto, desde el aire.** Los tres cajones nuevos están entre la ' + 'caseta y la puerta de arriba.' }), t({ en: 'Drawing: The Gazette, from the society’s site plan', es: 'Dibujo: La Gaceta, a partir del plano de la asociación' }), t({ en: 'Plan of allotment plots seen from above, with three compost bays by the gate.', es: 'Plano de parcelas de huerto vistas desde arriba, con tres cajones de compost junto a la ' + 'puerta.' })], heat: [t({ en: '**Bay one in March.** Temperature at the centre of the heap, read at nine each ' + 'morning. In the shaded band, 55 to 65 °C, most weed seeds die.', es: '**El cajón uno en marzo.** Temperatura en el centro, a las nueve. En la franja, de ' + '55 a 65 °C, mueren casi todas las semillas de malas hierbas.' }), t({ en: 'Readings: the compost rota', es: 'Lecturas: el turno del compost' }), t({ en: 'Line chart: the heap rises from 12 °C to 64 °C in six days, cools to 47 °C, and ' + 'climbs back to 62 °C after it is turned on 18 March.', es: 'Gráfico de líneas: el montón sube de 12 °C a 64 °C en seis días, baja a 47 °C y vuelve ' + 'a 62 °C después del volteo del 18 de marzo.' })], bed: [t({ en: '**What goes where.** One cabbage to a square; four lettuces, chard or marigolds; ' + 'nine beetroot, beans or onions; sixteen radishes or carrots.', es: '**Cómo se planta el bancal.** Una col por cuadro; cuatro lechugas, acelgas o ' + 'caléndulas; nueve remolachas, judías o cebollas; dieciséis rábanos o zanahorias.' }), t({ en: 'Drawing: The Gazette', es: 'Dibujo: La Gaceta' }), t({ en: 'A square bed divided by strings into sixteen squares, each planted with one to ' + 'sixteen plants.', es: 'Un bancal cuadrado dividido con cuerdas en dieciséis cuadros, ' + 'cada uno con entre una y dieciséis plantas.' })], }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Text, display and label faces, loaded before the build. const FONTS = { 'Work Sans': ['400', '700'], 'Titan One': ['400'], 'Courier Prime': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadSvg('plan.svg', planSvg(FACE)), loadSvg('heat.svg', heatSvg(FACE)), loadSvg('bed.svg', bedSvg()), loadSvg('drill.svg', drillSvg())]); const doc = await buildDocumentWithFonts({ markdown, resources: resources() }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Community newsletter', es: 'Boletín vecinal' }) });
Kit · core, fonts, viewer, images: in jedem Rezept gleich · 270 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 · 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

Die breite Spalte wird weiterhin zuerst gelesen, also setzt dasselbe Markdown den Aufmacher rechts und die Kurzmeldungen links.

   layoutType: 'oneAndHalf',
+  sideColumnSide: 'left',

#Die Kurzmeldungen über die Seitenbreite setzen

Grundtext läuft in höchstens zwei Spalten. Die Titelseite einer Zeitung setzt vier Kurzmeldungen quer über den Fuß der Seite in einen Kasten mit :::columns{count=4}.

Nachweise

Text
Eigener Text, CC BY 4.0
Bilder
  • The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
Schriften
Work Sans (SIL OFL 1.1) · Titan One (SIL OFL 1.1) · Courier Prime (SIL OFL 1.1)
Sandbox