Aller au contenu principal
Recette numéro 42

Recettes · Chapitre 1 · Page et grille

Bulletin associatif : article principal et brèves

Bulletin de jardins de deux pages en colonne et demie : l’article remplit la large colonne et saute en page 2 ; des sauts de colonne mènent les brèves à droite.

  • Exemple en anglais : pas encore d’édition française
  • Format 210 × 297 mm
  • Colonne et demie, Gouttière de 8 mm
  • Work Sans 9,4/13,4
  • Courier Prime
  • Titan One
  • 2 pages
  • Niveau
  • Postext 1.29.1
  • Mis en page en 68 ms
  • 168 lignes de code

En bref

Un bulletin de deux pages pour un club de jardinage. L’article principal remplit une large colonne et continue en page 2, tandis que les brèves remplissent une colonne étroite à côté.

Ce que vous allez composer

Le numéro de printemps de The Allotment Gazette, le bulletin d’une société de jardins familiaux imaginaire, imprimé recto verso sur une feuille A4. Un bandeau paille porte le nom en Titan One sur deux lignes, une rangée de jeunes plants le long de son pied et un onglet tomate avec le numéro et la saison. Dessous, la page se divise en une colonne de 113 mm et une de 57 mm. L’article principal, sur trois nouveaux bacs à compost, remplit la large colonne sous un plan du site dessiné en code et s’arrête sur une étiquette « Continued on page 2 ». La colonne étroite contient cinq brèves, chacune ouverte par des étiquettes en forme de gélule. La page 2 reprend l’article sous un titre de suite et centre un graphique de température dans la colonne. Plus bas, le paragraphe sur une planche de culture court à côté de son dessin, placé à droite de la colonne. Huit avis remplissent la colonne étroite.

Cette recette répond à

  • Comment faire une mise en page en colonne et demie, avec une colonne de texte large et une colonne latérale étroite ?
  • Postext peut-il composer trois colonnes de texte ou plus, ou habiller une image avec du texte ?
  • Comment mettre deux colonnes dans un encadré (une colonne de texte à côté d’une colonne de figure) ?
  • Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?

La réponse courte

script.js · lignes 33–49dans le code complet
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

Ingrédients

Typographie
Work Sans, Titan One, Courier Prime (SIL OFL 1.1)
Ressources
  • 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)

Préparation

#1 · Laisser le Markdown remplir chaque colonne

Les réglages de mise en page sont dans la réponse courte plus haut. Dans une mise en page oneAndHalf, la colonne étroite reçoit du texte par défaut, et le texte descend la large colonne, puis l’étroite, puis la large colonne de la page suivante (types de mise en page). Un :::columnbreak termine une colonne là où il se trouve (directives) : après l’étiquette « Continued on page 2 » de l’article, il ouvre la colonne étroite pour les brèves, et après la dernière brève, dans la dernière colonne de la page, il ouvre la page 2. Dans ce numéro, les deux colonnes de la page 1 sont ajustées pour finir sur la même ligne de la grille, à 275 mm du bord supérieur : supprimer l’un ou l’autre saut laisse donc les pages telles quelles. Les sauts comptent dès que le texte change. Coupez le deuxième paragraphe de l’article et, sans le premier saut, « In brief » commence à 247 mm du haut dans la large colonne, sous l’étiquette, et la première brève déborde dans la colonne étroite, où sa seconde moitié est recoupée pour la justification de 57 mm de la colonne.

#2 · Construire le titre du bulletin à partir du frontmatter

script.js · lignes 53–75dans le code complet
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) },
  ] } };

Le H1 prend un style de titre dont la maquette dessine le titre du bulletin, et span: 'page' place cette maquette au-dessus des deux colonnes. {titleText} à 60 pt se coupe en deux lignes dans sa boîte de 178 mm. L’onglet réunit {attr.issue}, écrit sur la ligne du H1, et {publishDate} tiré du frontmatter (éléments de texte). Sans minHeight, la tête réserverait la place jusqu’au pied du bandeau, à 62 mm du bord supérieur, et le texte commencerait sur la ligne suivante de la grille, à 62,5 mm, collé au bandeau. Une minHeight de dix lignes de la grille le fait commencer à 67 mm.

#3 · Étiqueter les brèves avec des pastilles

script.js · lignes 79–89dans le code complet
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') }),
];

Déclarer chipStyles remplace la pastille bleu pâle intégrée, et une pastille sans style prend la première (styles de pastille). Un rayon supérieur à la moitié de la hauteur de la boîte dessine une gélule. Une pastille ne se coupe jamais à l’intérieur : une ligne ne se coupe qu’à l’espace qui précède ou suit une pastille, et une date comme « Sat 11 April » reste sur une ligne. Les étiquettes sont saisies en capitales dans le Markdown parce que les styles de pastille n’ont pas de réglage de casse. La marge intérieure verticale est peinte hors de la ligne : la grille de 13,4 pt tient.

#4 · Faire courir le texte à côté du dessin

script.js · lignes 93–98dans le code complet
// 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' place le dessin de la planche contre le bord droit de la colonne de 113 mm, sur 45 % de sa largeur (51 mm), et le paragraphe qui suit la ligne ::resource court à côté sur les 57 mm restants, à une ligne de texte du dessin et de sa légende (habillage). Le haut du dessin est aligné sur la première ligne du paragraphe, et chaque ligne reste sur la grille de 13,4 pt. Un paragraphe plus long continuerait sous le dessin sur les 113 mm complets, en un seul paragraphe ; un titre ou une autre figure arrivant alors que le dessin est encore à côté du texte passerait dessous. Le titre de niveau 4 au-dessus prend un style de titre qui ne change que sa couleur, en vert feuille.

#5 · Centrer un graphique étroit dans sa bande

script.js · lignes 102–114dans le code complet
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 donne au graphique 70,1 mm de large dans la colonne de 113 mm, et align: 'center' laisse 21,5 mm de papier de chaque côté (placement). Le texte au-dessus et au-dessous garde toute la justification et ne coule jamais dans ce blanc. Les dessins appartiennent à un type de ressource au captionPrefix vide : chaque légende commence donc par ses propres mots en gras, sans « Figure 1 » devant.

La recette complète

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`---
Exemple Markdown · 114 lignes · 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 : identique dans toutes les recettes · 270 lignes// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

Variantes

#Mettre les brèves à gauche

La large colonne se lit toujours en premier : le même Markdown place donc l’article à droite et les brèves à gauche.

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

#Composer les brèves sur toute la largeur de la page

Le texte courant s’écoule sur deux colonnes au plus. La une de journal fait courir quatre brèves au pied de la page dans un encadré avec :::columns{count=4}.

Crédits

Texte
Texte original, CC BY 4.0
Images
  • 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
Polices
Work Sans (SIL OFL 1.1) · Titan One (SIL OFL 1.1) · Courier Prime (SIL OFL 1.1)
Sandbox