In breve
Due pagine di un ricettario casalingo, una ricetta per pagina. Mostra come mettere gli ingredienti e i passi della preparazione uno accanto all’altro su una scheda.
Che cosa comporrai
Due pagine affiancate di Salt & Olive Oil, un ricettario casalingo spagnolo immaginario, su un formato di 190 × 250 mm, una ricetta per pagina. Una fotografia, generata con modelli di diffusione e caricata come JPEG, esce al vivo sui 104 mm superiori: una tortilla con una fetta tirata fuori su un tavolo color zafferano, una ciotola di gazpacho su uno color salvia. Sopra stanno un occhiello, il titolo in Young Serif da 42 pt, pillole per tempo, difficoltà e stagione, e una nota scritta a mano in Caveat che indica il piatto. Sotto la nota introduttiva in corsivo, una scheda bianca porta una linguetta rosso pomodoro con le porzioni sull’angolo in alto a destra. Gli ingredienti scendono lungo la sua colonna di sinistra con trattini color oliva e chip di tag; sotto quelli della tortilla, i disegni al tratto di una ciotola, una padella e un piatto aprono una checklist. Il procedimento riempie la colonna di destra, ogni passo introdotto da una cifra elzeviriana da 26 pt.
Questa ricetta risponde a
- Come metto due colonne dentro un riquadro (una colonna di testo accanto a una colonna con una figura)?
- Come aggiungo una linguetta numerata («BOX 1-1»), un’icona d’angolo o un’icona a margine con un filetto?
- Come personalizzo gli elenchi: punti elenco per livello, numerazione (a)/(i), caselle di controllo, spaziature che restano sulla griglia?
- Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
- Come aggiungo a un’apertura una riga d’autore, un sommario o un attacco con capolettera?
La risposta breve
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two.
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
// No space of its own above: the tab starts on the first grid line under the opener.
columnGap: mm(7), marginTop: mm(0),
// label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
// beside it and a rule from the far corner that makes the tab part of the card.
label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
Ingredienti
- Funzionalità
- Colonne in un riquadro o nel testoLinguette numerate dei riquadriElenchi numeratiRiquadri annidatiIcone dei riquadri e badge d’angoloChip in lineaElenchi puntati e liste di controlloRiquadriAperture progettateFascia di capitolo a tutta larghezzaStili di titoloAttributi dei titoliTesto, filetti e riquadri nei progetti di paginaImmagini nei progetti di paginaA capo nei titoliColore della cartaTestatine e folioTavolozza di colori semanticaStili di paragrafoFigure e tabelle come risorse
- Tipo
- Young Serif, Figtree, Caveat (SIL OFL 1.1)
- Risorse
gazpacho-1520.jpgtortilla-1520.jpg- The tortilla on its plate, a photograph (Generated With Diffusion Models, originale)
- The bowl of gazpacho, a photograph (Generated With Diffusion Models, originale)
- The note’s arrow and the pictograms, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)
Preparazione
#1 · Dai a ogni colonna un riquadro proprio
Con elenchi liberi, un gruppo di colonne in un riquadro taglia dove le sue colonne si pareggiano meglio, tra i blocchi o tra le righe di uno stesso blocco, così il primo passo del gazpacho si divide attraverso l’intercolonna: la sua prima riga chiude la colonna degli ingredienti e il resto apre quella del procedimento. Un riquadro annidato, come nella risposta breve qui sopra, è un unico blocco, e il gruppo non taglia mai al suo interno. Un’immagine composta in un riquadro annidato richiede un ::resource con collocazione 'here', perché qualsiasi altra collocazione la rende un elemento flottante, che esce dal riquadro e viene collocato come qualsiasi figura citata. La label dello stile della scheda trasforma il label="SERVES 4" della recinzione nella linguetta: un offset uguale alla sua height la appoggia sul bordo superiore della scheda, e la rule corre dall’angolo opposto fino a forchetta e coltello (stili dei riquadri).
#2 · Centra i numeri dei passi su due righe
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
numberFontSize: pt(STEP), color: col('tomato'),
separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
numberVerticalOffset: pt(DROP) };
Il numero di un elenco viene dipinto centrato un po’ sopra la prima linea di base della voce, quindi numberFontSize da solo metterebbe le cifre da 26 pt alla pari con la prima riga; numberVerticalOffset le abbassa di 2,5 mm, al centro della fascia che va dalle maiuscole della prima riga alla linea di base della seconda. Young Serif ha cifre elzeviriane: 3, 4 e 5 scendono sotto la linea di base su cui poggiano 1 e 2. Con separatorColor impostato, il separatore viene dipinto come un tratto a sé, e il punto predefinito si stampa color oliva accanto a una cifra rossa (elenchi numerati).
#3 · Apri la checklist con una striscia di disegni
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
align: 'center' }, // the title centred on the strip
titleStyle: { ...column.titleStyle, color: col('olive') } };
Il riquadro prep contiene solo il titolo e l’icona, quindi funziona come intestazione: una width dell’icona pari a 3,75 volte la sua size allunga il quadrato dell’icona in una striscia di 18,75 × 5 mm, e i tre disegni la riempiono a sinistra del titolo (stili dei riquadri). Un’icona occupa una colonna propria accanto a tutto ciò che sta nel suo riquadro, quindi le attività vanno dopo il riquadro, dove si allineano ai trattini degli ingredienti invece di cominciare 21 mm più in dentro. Le voci - [ ] stampano la casella predefinita, e unorderedLists.marginTop: 0 le fa seguire l’intestazione al suo marginBottom, lo spazio di 2,4 mm che mantengono i titoli delle colonne (elenchi puntati).
#4 · Fai dei tag dei chip
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
:chip[Vegetarian]{style="tag"} mette una parola in un riquadro che non va mai a capo. Un borderRadius più grande di metà dell’altezza del riquadro viene limitato a quella metà, il che trasforma entrambe le estremità in semicerchi; con borderWidth: 0 la pillola è disegnata solo con la tinta. Il riquadro è alto 11,4 pt, sotto l’interlinea di 12,8 pt della scheda, quindi una seconda riga di tag non toccherebbe la prima (stili dei chip).
#5 · Un solo progetto grafico, una fotografia per ricetta
const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
overflow: 'wrap', placement, ...extra }); // a foot wraps too, where the default is '…'
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
{ to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style for every recipe; each heading names its photo: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: mm(PAGE.w), height: mm(BAND) } } },
{ kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: mm(PAGE.w), height: mm(BAND) } } },
text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
{ fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo
pill('time'), pill('level', 'time'), pill('season', 'level'),
text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
{ lineHeight: 1 }), // set solid: 42 pt from line to line
text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
// The lead is the design's lowest element: the opener reserves down to its last line, and
// the card starts under it without a minHeight.
text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
{ italic: true, lineHeight: 1.45 }),
] } } };
# Tortilla \\ de patatas {style="receta" art="tortilla" kicker="…" lead="…"} sceglie lo stile di titolo e ne riempie gli slot con gli attributi, tra cui la nota introduttiva in corsivo (attributi dei titoli). La resourceId dell’elemento immagine è '{attr.art}', un segnaposto come quello di un testo, così entrambe le ricette condividono uno stile e ogni titolo nomina la sua fotografia (elementi immagine). Ogni pillola è ancorata right-of alla precedente, il titolo above la prima pillola e l’occhiello sopra il titolo, così un titolo più lungo spinge in alto l’occhiello mentre le pillole restano sulla loro riga. La nota introduttiva è l’elemento più basso del progetto grafico, quindi l’apertura riserva spazio fino alla sua ultima riga e la scheda comincia sotto di essa senza minHeight. La nota a mano è ancorata alla pagina, come la fotografia e la freccia, un secondo elemento immagine disegnato via codice, e finisce 2 mm prima della freccia su entrambe le pagine, anche se i margini speculari spostano la gabbia del testo di 2 mm a destra sul recto.
#6 · Registra le immagini come risorse
// The opener's image elements and both icons name a resource id; the resource names the file
// the canvas paints (loadImage or loadSvg registers it). No :ref cites them, so none is numbered.
const photo = (id, altText) => ({ id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
altText, bitmap: { fileId: `${id}-1520.jpg`, format: 'jpeg', width: 1520, height: 832 } });
const ART = { // markup, width and height in mm, and the alt text
arrow: [arrow, PAGE.w, BAND, t({ en: 'An arrow to the dish', es: 'Una flecha al plato' })],
cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = [
photo('tortilla', t({ en: 'A potato omelette with a slice pulled out, on a blue-rimmed plate '
+ 'and a red-checked napkin', es: 'Una tortilla de patatas con una porción separada, en un '
+ 'plato de borde azul sobre una servilleta de cuadros rojos' })),
photo('gazpacho', t({ en: 'A bowl of gazpacho with diced vegetables and a spoon, on a '
+ 'blue-checked napkin beside a tomato', es: 'Un cuenco de gazpacho con dados de verdura '
+ 'y una cuchara, sobre una servilleta de cuadros azules junto a un tomate' })),
...Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
height: h * 10 } })), // 10 px a millimetre: the sizes only set the aspect ratio here
];
const photos = resources.filter((r) => r.kind === 'bitmap').map((r) => r.bitmap.fileId);
await Promise.all([...photos.map((file) => loadImage(file, asset(file))),
...Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))]);
I due elementi immagine dell’apertura, l’icona della linguetta e l’icona dell’intestazione della checklist nominano ciascuno una risorsa, e il canvas dipinge il file che loadImage o loadSvg ha registrato per essa. Nessun :ref cita queste risorse, quindi nessuna diventa una figura numerata. Le immagini non contengono parole: le note scritte a mano sono testo grafico composto sopra di esse.
La ricetta completa
// ═══ Postext Cookbook · Nº 033 · Recipe card: ingredients beside the method ═══════ // https://postext.dev/en/cookbook/recipe-card // Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models · Pictograms: CC BY 4.0 // Fonts: Young Serif, Figtree, Caveat (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'recipe-card'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#2b2118', muted: '#76634e', // text; folios and the colophon paper: '#f7eddb', card: '#fffdf8', // the cream page; the white recipe card on it tomato: '#bf3d29', olive: '#6b7a3a', tint: '#f6e3c1' }; // numbers and tab; dashes; tags // Each colour names its palette entry and carries its hex. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the tomato, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.tomato }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, HAND] = ['Figtree', 'Young Serif', 'Caveat']; const PAGE = { w: 190, h: 250, top: 22, inner: 18, outer: 16 }; // mm, mirrored margins const [BODY, LEAD] = [9.4, 12.8]; // pt: the text of the cards and the notes under them // #region answer: a white card with a servings tab, two columns inside it // In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group // of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method. // The group levels its columns by cutting between blocks or lines, so loose lists would run // the method on under the ingredients. A nested box is one block that never splits, so the // only cut left is between the two. const TAB = 5.6; // mm: the tab's height, and how far it rises above the card const card = { id: 'card', background: col('card'), padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) }, // No space of its own above: the tab starts on the first grid line under the opener. columnGap: mm(7), marginTop: mm(0), // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram // beside it and a rule from the far corner that makes the tab part of the card. label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6), icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } }; // The two columns: frameless boxes whose only device is a tracked title. const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; const column = { id: 'column', backgroundEnabled: false, padding: NONE, lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } }; // config() plugs them in: calloutStyles: [card, column, prep]. // #endregion // #region prep: a checklist under a header with a strip of three pictograms // :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that // holds only its title and icon. The icon is one picture of three drawings; a width KIT times // its size makes its box a strip, and the picture is fitted into width × size, left of the // title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]' // items follow the box, where they print the default task box, '☐'. const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height const prep = { id: 'prep', backgroundEnabled: false, padding: NONE, marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT), align: 'center' }, // the title centred on the strip titleStyle: { ...column.titleStyle, color: col('olive') } }; // #endregion // #region steps: big step numbers in the display face, an olive full stop after each // Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em // x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's // first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young // Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two // lines, from the cap height of the first to the baseline of the second. const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only numberFontSize: pt(STEP), color: col('tomato'), separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run numberVerticalOffset: pt(DROP) }; // #endregion // #region chips: tags for diet and occasion, as pills in the text const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'), borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }]; // #endregion // #region opener: the photograph bled across the head, the title and pills set on it const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left', overflow: 'wrap', placement, ...extra }); // a foot wraps too, where the default is '…' const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) }; // Pills are text boxes chained right-of each other; each prints one heading attribute. const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor: { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), { fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3), padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } }); // One heading style for every recipe; each heading names its photo: art="tortilla". const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(BAND) } } }, { kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(BAND) } } }, text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } }, { fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo pill('time'), pill('level', 'time'), pill('season', 'level'), text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' }, offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading { lineHeight: 1 }), // set solid: 42 pt from line to line text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' }, offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked), // The lead is the design's lowest element: the opener reserves down to its last line, and // the card starts under it without a minHeight. text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }), { italic: true, lineHeight: 1.45 }), ] } } }; // #endregion // Folios at the foot of the outer corner: the book on versos, the recipe on rectos. const foot = (id, content, parity, edge, x, look) => text(id, content, TEXT, 7.6, { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-12) } }, { color: col('muted'), parity, align: edge.endsWith('left') ? 'left' : 'right', ...look }); const folio = { fontFamily: DISPLAY, fontSize: pt(10), color: col('tomato') }; const footer = { elements: [ foot('verso-folio', '{pageNumber}', 'even', 'bottom-left', PAGE.outer, folio), foot('verso-book', '{title}', 'even', 'bottom-left', PAGE.outer + 9, tracked), foot('recto-dish', '{chapterTitle}', 'odd', 'bottom-right', -(PAGE.outer + 9), tracked), foot('recto-folio', '{pageNumber}', 'odd', 'bottom-right', -PAGE.outer, folio), ] }; const config = () => ({ colorPalette, chipStyles, orderedLists, footer, header: { elements: [] }, page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(PAGE.top), bottom: mm(20), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, // left is the inner margin layout: { layoutType: 'single' }, locale: t({ en: 'en-us', es: 'es' }), // the edition's language bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: mm(0) }, // ragged and flush: the notes under the cards // A designed heading's own text is hidden but still measured: in Young Serif 400, the only // weight it ships, not in the default Open Sans 700 that FONTS does not load. headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // 'any': each recipe opens the next page, whichever side it is on. { level: 1, breakBefore: { enabled: true, parity: 'any' } }, ] }, headingStyles: [opener], // Olive dashes for the whole document: an olive lists.color on the column style would turn // the step numbers olive too (gotcha: box-list-colour-numbers). unorderedLists: { bulletChar: '–', color: col('olive'), marginTop: mm(0), // under the checklist's header, the header's marginBottom alone marginBottom: pt(LEAD / 2) }, // half a line above the tags calloutStyles: [card, column, prep], paragraphStyles: [{ id: 'colophon', fontSize: pt(7.4), lineHeight: pt(10), color: col('muted'), marginTop: pt(LEAD) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Esempio Markdown · 66 righe · content.en.md
title: "Salt & Olive Oil" --- # Tortilla \\ de patatas {style="receta" art="tortilla" kicker="Spanish potato omelette" time="45 min" level="Intermediate" season="All year" note="onion, always!" lead="Our house version: potatoes poached slowly in plenty of oil, eggs barely set and ten minutes’ rest so the potato soaks up the egg. With onion, as my grandmother made it; without, if your family argues about it."} :::callout{type="card" label="SERVES 4"} :::columns{count=2} :::callout{type="column" title="Ingredients"} - **6** large free-range eggs - **800 g** frying potatoes - **1** medium onion - **300 ml** extra virgin olive oil - Fine salt :chip[Vegetarian]{style="tag"} :chip[Gluten-free]{style="tag"} :chip[Picnic]{style="tag"} :::callout{type="prep" title="Before you start"} ::: - [ ] Eggs out of the fridge - [ ] A bowl for eggs and potato - [ ] A plate wider than the pan ::: :::callout{type="column" title="Method"} 1. Peel and thinly slice the potatoes, cut the onion into fine strips and salt them. 2. Poach them in the oil over a medium heat, in a 24 cm pan, until tender but pale: about 20 minutes. 3. Drain, keeping the oil, and stir the hot potato into the eggs, beaten with salt. Leave for 10 minutes. 4. Heat a spoonful of the oil over a high heat, pour in the mixture and let it set for 2 minutes, loosening the edges. 5. Turn it over with a plate, let it set a minute more and serve it warm. ::: ::: ::: **Keep the oil.** Strained into a jar, it will fry the next tortilla. # Gazpacho \\ andaluz {style="receta" art="gazpacho" kicker="Andalusian cold soup" time="25 min + 2 h chilling" level="Easy" season="June to September" note="serve it ice cold!" lead="In many Andalusian homes it is drunk from a glass, straight from the fridge. It needs truly ripe tomatoes, and oil poured in a thin stream while the blender runs, which leaves it creamy and orange."} :::callout{type="card" label="SERVES 6"} :::columns{count=2} :::callout{type="column" title="Ingredients"} - **1.5 kg** ripe plum tomatoes - **1** long green pepper - **1** cucumber - **1** garlic clove - **100 g** day-old bread, soaked in water - **120 ml** extra virgin olive oil - **3 tbsp** sherry vinegar - Salt and cold water :chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"} ::: :::callout{type="column" title="Method"} 1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic. 2. Put it all in the blender with the soaked bread, the vinegar and a teaspoon of salt; leave for 15 minutes. 3. Blend on full power for 2 minutes, then, with the motor running, add the oil in a thin stream until it emulsifies. 4. Press it through a fine sieve and thin it with cold water to taste. 5. Season with salt and vinegar and chill for at least 2 hours before serving. ::: ::: ::: **Make it ahead.** It keeps two days in a covered jug in the fridge; stir it before you pour. :::paragraphs{style="colophon"} *Salt & Olive Oil* · Set in Young Serif, Figtree and Caveat (SIL OFL) · Recipes: original, CC BY 4.0 · Photos: generated :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the note's arrow and the pictograms, drawn in the palette's colours // The dishes are photographs, JPEGs in assets/ cut to the band's 190 × 104 mm. The arrow from // the handwritten note to the dish and the line pictograms stay vector; the note itself is a // design element set in Caveat. const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`); const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${body}</g>`; // A hand-drawn line and its head, as paths, never a <marker>, in a box as large as the photo: // it runs from the end of the note, over the plate's rim, to the edge of the dish. const arrow = svgDoc(PAGE.w, BAND, stroke('M90 29C97 26 102 28 106 33', palette.ink, 0.55) + group(106, 33, 62, stroke('M-2.4-1.3L0 0-2.4 1.5', palette.ink, 0.55))); const cutlery = svgDoc(6, 8, stroke('M1.6 .6V7.4M.6 .6V2.6C.6 3.4 2.6 3.4 2.6 2.6V.6', palette.tomato, 0.55) + stroke('M4.6 7.4V.6C5.8 1.4 5.8 3.6 4.6 4.4', palette.tomato, 0.55)); // The kit in line drawings: a bowl with two eggs, the frying pan and the plate that turns the // tortilla over, in a box KIT times as wide as it is tall. const egg = (x, turn) => `<ellipse cx="${x}" cy="2.5" rx="1.05" ry="1.35" ` + `transform="rotate(${turn} ${x} 2.5)" fill="none" stroke="${palette.olive}" ` + 'stroke-width=".6"/>'; const kit = svgDoc(8 * KIT, 8, egg(3, -12) + egg(5, 14) + stroke('M.6 3.9H7.4M1 3.9C1 6.4 2.4 7.4 4 7.4S7 6.4 7 3.9', palette.olive, 0.7) + stroke('M9.8 4.4H16.8M10.2 4.4L10.8 6.7C10.9 7.1 11.2 7.3 11.6 7.3H15C15.4 7.3 15.7 7.1 ' + '15.8 6.7L16.4 4.4', palette.olive, 0.7) + stroke('M16.8 5L20.2 4.1', palette.olive, 1.1) + circle(26.2, 4.2, 3.3, 'none', ` stroke="${palette.olive}" stroke-width=".7"`) + circle(26.2, 4.2, 2, 'none', ` stroke="${palette.olive}" stroke-width=".5"`)); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build. const FONTS = { Figtree: ['400', '400i', '600', '700'], 'Young Serif': ['400'], Caveat: ['600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: the photos and the pictograms are resources, cited by id, never by :ref // The opener's image elements and both icons name a resource id; the resource names the file // the canvas paints (loadImage or loadSvg registers it). No :ref cites them, so none is numbered. const photo = (id, altText) => ({ id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText, bitmap: { fileId: `${id}-1520.jpg`, format: 'jpeg', width: 1520, height: 832 } }); const ART = { // markup, width and height in mm, and the alt text arrow: [arrow, PAGE.w, BAND, t({ en: 'An arrow to the dish', es: 'Una flecha al plato' })], cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })], kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate', es: 'Un bol con dos huevos, una sartén y un plato' })] }; const resources = [ photo('tortilla', t({ en: 'A potato omelette with a slice pulled out, on a blue-rimmed plate ' + 'and a red-checked napkin', es: 'Una tortilla de patatas con una porción separada, en un ' + 'plato de borde azul sobre una servilleta de cuadros rojos' })), photo('gazpacho', t({ en: 'A bowl of gazpacho with diced vegetables and a spoon, on a ' + 'blue-checked napkin beside a tomato', es: 'Un cuenco de gazpacho con dados de verdura ' + 'y una cuchara, sobre una servilleta de cuadros azules junto a un tomate' })), ...Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 } })), // 10 px a millimetre: the sizes only set the aspect ratio here ]; const photos = resources.filter((r) => r.kind === 'bitmap').map((r) => r.bitmap.fileId); await Promise.all([...photos.map((file) => loadImage(file, asset(file))), ...Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))]); // #endregion // The excerpt is pages 58 and 59 of the book: 57 pages come before it, so the tortilla opens // on a verso and the two recipes face each other. const doc = await buildDocumentWithFonts({ markdown, resources, continuation: { pageIndexOffset: 57, pageNumbering: { startAt: 58 } } }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Recipe card', es: 'Tarjeta de receta' }) });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
#Conta i blocchi invece di mettere le colonne in riquadri
Senza i riquadri delle colonne, breaks indica il figlio che apre la colonna di destra, e ogni voce dell’elenco, la riga dei tag e l’intestazione della checklist contano come uno: il gazpacho richiede breaks="10" (vengono prima otto ingredienti e i tag), la tortilla breaks="11". I titoli delle colonne e lo spazio tra i passi dato dallo stile della colonna spariscono con loro.
:::callout{type="card" label="SERVES 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredients"}
+:::columns{count=2 breaks="10"}
- **1.5 kg** ripe plum tomatoes
@@ seven more ingredients and a blank line @@
:chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
-:::
-:::callout{type="column" title="Method"}
+
1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
@@ steps 2 to 5 @@
:::
-:::
:::#Metti la linguetta sull’angolo sinistro
'top-left' specchia l’etichetta: la linguetta passa all’angolo sinistro, forchetta e coltello alla sua destra, e il filetto arriva da destra.
background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+ position: 'top-left', // 'top-right' by defaultErrori frequenti
Errore frequente
Il numero di un elenco è centrato sulla prima riga, non poggiato sulla linea di base
Il numero di un elenco numerato viene disegnato con la linea di base 'middle' del canvas, 0,3 em del testo sopra la prima linea di base della voce, quindi un numero in un altro carattere o con un numberFontSize maggiore cresce da lì verso l’alto e verso il basso invece di poggiare sulla riga: un carattere da titolazione sale troppo, e un grande numero di passaggio scende attraverso la prima riga. Posizionalo con orderedLists.numberVerticalOffset e controlla il risultato a grandezza reale. I numeri dell’indice poggiano sulla linea di base da postext 1.5; quelli degli elenchi sono ancora centrati. Elenchi numerati →
Errore frequente
Il lists.color di un riquadro ricolora anche i numeri dei suoi elenchi
Il lists.color di uno stile di riquadro colora allo stesso modo i punti elenco del riquadro e i numeri dei suoi elenchi numerati, ogni volta che lo stile lo imposta: se dai così a un riquadro punti elenco verde oliva, diventano verde oliva anche i numeri dei passaggi. (Fino a postext 1.4 raggiungeva i numeri solo quando era diverso da unorderedLists.color.) Per colorare solo i punti elenco di un riquadro, imposta unorderedLists.color per il documento e togli lists.color dallo stile del riquadro: così i numeri restano in orderedLists.color. Riquadri →
Errore frequente
I chip più alti del passo di riga toccano la riga successiva
Un chip il cui riquadro è più alto del passo di riga tocca i chip della riga successiva (l’avviso chipOverlap). Riduci la sua spaziatura interna verticale, il bordo o il corpo, o aumenta l’interlinea. Chip in linea →
- Un’etichetta non ha
textTransformnéletterSpacing, quindi «SERVES 4» e «PARA 4» sono scritti in maiuscolo nelle recinzioni.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Immagini
- The tortilla on its plate, a photograph · Generated With Diffusion Models · originale
- The bowl of gazpacho, a photograph · Generated With Diffusion Models · originale
- The note’s arrow and the pictograms, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- Font
- Young Serif (SIL OFL 1.1) · Figtree (SIL OFL 1.1) · Caveat (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)


