In breve
Un notiziario di due pagine per un circolo di ortolani. L’articolo principale riempie una colonna larga e prosegue a pagina 2, mentre le notizie brevi riempiono una colonna stretta accanto.
Che cosa comporrai
Il numero di primavera di The Allotment Gazette, il notiziario di un’immaginaria associazione di orti urbani, stampato fronte e retro su un unico foglio A4. Una fascia color paglia porta il nome in Titan One su due righe, una fila di piantine lungo il bordo inferiore e una linguetta color pomodoro con il numero e la stagione. Sotto, la pagina si divide in una colonna di 113 mm e una di 57 mm. L’articolo di apertura, su tre nuove compostiere, riempie la colonna larga sotto una pianta del terreno disegnata nel codice e si ferma su un’etichetta «Continued on page 2». La colonna stretta contiene cinque brevi, ciascuna aperta da etichette a forma di pillola. La pagina 2 riprende l’articolo sotto un titolo di rinvio e centra nella colonna un grafico delle temperature. Più in basso, il paragrafo su un’aiuola scorre accanto al suo disegno, posto a destra della colonna. Otto avvisi riempiono la colonna stretta.
Questa ricetta risponde a
- Come creo un layout a colonna e mezza, con una colonna di testo larga e una colonna laterale stretta?
- Postext può comporre tre o più colonne di testo, o far scorrere il testo intorno a un’immagine?
- Come metto due colonne dentro un riquadro (una colonna di testo accanto a una colonna con una figura)?
- Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
La risposta breve
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
Ingredienti
- Funzionalità
- Colonna e mezzaInterruzioni di pagina e di colonnaChip in lineaScontorno del testo attorno a immagini e riquadriStili di titoloCollocazione delle figureRiquadriFigure proprio quiFiletto tra le colonneMetadati del documentoAperture progettateAttributi dei titoliTestatine e folioTestatine secondo il ruolo della paginaStili di paragrafoTipi di risorsa personalizzatiRighe di fonte e creditiTavolozza di colori semanticaPagine su un canvasFigure e tabelle come risorse
- Tipo
- Work Sans, Titan One, Courier Prime (SIL OFL 1.1)
- Risorse
- 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)
Preparazione
#1 · Lascia che il Markdown riempia ogni colonna
Le impostazioni dell’impaginazione sono nella risposta breve qui sopra. In un layout oneAndHalf la colonna stretta accoglie testo per impostazione predefinita, e il testo scende lungo la colonna larga, poi quella stretta, poi la colonna larga della pagina successiva (tipi di layout). Un :::columnbreak chiude una colonna nel punto in cui si trova (direttive): dopo l’etichetta «Continued on page 2» dell’apertura apre la colonna stretta per le brevi, e dopo l’ultima breve, nell’ultima colonna della pagina, apre la pagina 2. In questo numero entrambe le colonne di pagina 1 sono adattate per finire sulla stessa riga della griglia, a 275 mm dal bordo superiore, quindi eliminando l’uno o l’altro salto le pagine restano come sono. I salti contano quando il testo cambia. Togli il secondo paragrafo dell’apertura e, senza il primo salto, «In brief» comincia a 247 mm nella colonna larga, sotto l’etichetta, e la prima breve trabocca nella colonna stretta, dove la sua seconda metà viene di nuovo divisa in righe sulla giustezza di 57 mm della colonna.
#2 · Costruisci la testata dal 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) },
] } };
L’H1 prende uno stile di titolo il cui disegno traccia la testata, e span: 'page' colloca quel disegno sopra entrambe le colonne. {titleText} a 60 pt va a capo in due righe nel suo riquadro di 178 mm. La linguetta unisce {attr.issue}, scritto sulla riga dell’H1, e {publishDate} dal frontmatter (elementi di testo). Senza minHeight la testata riserverebbe spazio fino al piede della fascia, a 62 mm dal bordo superiore, e il testo comincerebbe sulla riga successiva della griglia, a 62,5 mm, a contatto con la fascia. Un minHeight di dieci righe della griglia lo fa cominciare a 67 mm.
#3 · Etichetta le brevi con i 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') }),
];
Dichiarare chipStyles sostituisce il chip azzurro pallido predefinito, e un chip senza style prende il primo (stili dei chip). Un raggio maggiore di metà dell’altezza del riquadro disegna una pillola. Un chip non si spezza mai al suo interno, quindi una riga va a capo solo nello spazio prima o dopo un chip, e una data come «Sat 11 April» resta su una riga. Le etichette sono scritte in maiuscolo nel Markdown perché gli stili dei chip non hanno un’impostazione per maiuscole e minuscole. Il padding verticale è dipinto fuori dalla riga, così la griglia di 13,4 pt resta intatta.
#4 · Fai scorrere il testo accanto al disegno
// 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' colloca il disegno dell’aiuola sul bordo destro della colonna di 113 mm, largo il 45 % di essa (51 mm), e il paragrafo dopo la riga ::resource scorre accanto nei 57 mm che restano, a una riga di testo di distanza dal disegno e dalla sua didascalia (scontorno del testo). La sommità del disegno è allineata con la prima riga del paragrafo, e ogni riga resta sulla griglia di 13,4 pt. Un paragrafo più lungo proseguirebbe sotto il disegno sui 113 mm pieni, come un unico paragrafo; un titolo o un’altra figura che arrivasse mentre il disegno è ancora accanto al testo andrebbe sotto di esso. Il titolo di livello 4 sopra di esso prende uno stile di titolo che cambia solo il suo colore nel verde foglia.
#5 · Centra un grafico stretto nella sua fascia
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 rende il grafico largo 70,1 mm nella colonna di 113 mm, e align: 'center' lascia 21,5 mm di carta per lato (collocazione). Il testo sopra e sotto mantiene la giustezza piena e non scorre mai in quel bianco. I disegni appartengono a un tipo di risorsa con captionPrefix vuoto, così ogni didascalia comincia con le sue parole in grassetto, senza «Figure 1» davanti.
La ricetta completa
// ═══ 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`---Esempio Markdown · 114 righe · content.en.md
title: "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: uguale in ogni ricetta · 270 righe
// ─── 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Metti le brevi a sinistra
La colonna larga si legge comunque per prima, quindi lo stesso Markdown compone l’apertura a destra e le brevi a sinistra.
layoutType: 'oneAndHalf',
+ sideColumnSide: 'left',#Componi le brevi su tutta la pagina
Il testo corrente scorre al massimo su due colonne. La prima pagina di giornale mette quattro brevi lungo il piede della pagina in un riquadro con :::columns{count=4}.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Immagini
- 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
- Font
- Work Sans (SIL OFL 1.1) · Titan One (SIL OFL 1.1) · Courier Prime (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


