En bref
Deux pages d’un livre de cuisine familiale, une recette par page. On y voit comment placer les ingrédients et les étapes de préparation côte à côte sur une fiche.
Ce que vous allez composer
Deux pages en regard de Salt & Olive Oil, un livre de cuisine familiale espagnole inventé, au format 190 × 250 mm, une recette par page. Une photographie, générée avec des modèles de diffusion et chargée en JPEG, occupe à fond perdu les 104 mm du haut : une tortilla dont on a retiré une part sur une table safran, un bol de gazpacho sur une table vert sauge. Par-dessus se posent un surtitre, le titre en Young Serif de 42 pt, des pilules pour le temps, la difficulté et la saison, et une note manuscrite en Caveat qui pointe vers le plat. Sous le chapeau en italique, une fiche blanche porte un onglet de portions rouge tomate à son angle supérieur droit. Les ingrédients descendent dans sa colonne de gauche avec des tirets olive et des pastilles d’étiquettes ; sous ceux de la tortilla, des dessins au trait d’un bol, d’une poêle et d’une assiette coiffent une liste à cocher. La préparation remplit la colonne de droite, chaque étape menée par un chiffre elzévirien de 26 pt.
Cette recette répond à
- Comment mettre deux colonnes dans un encadré (une colonne de texte à côté d’une colonne de figure) ?
- Comment ajouter un onglet numéroté (« BOX 1-1 »), une icône de coin ou une icône de marge avec un filet ?
- Comment personnaliser les listes : puces par niveau, numérotation (a)/(i), cases à cocher, espacement qui reste sur la grille ?
- Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?
- Comment ajouter à une ouverture une ligne d’auteur, un chapeau ou une attaque avec lettrine ?
La réponse courte
// 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].
Ingrédients
- Fonctionnalités
- Colonnes dans un encadré ou dans le texteOnglets numérotés d’encadréListes numérotéesEncadrés imbriquésIcônes d’encadré et badges d’anglePastilles dans le texteListes à puces et listes de contrôleEncadrésOuvertures composéesBandeau de chapitre sur toute la largeurStyles de titreAttributs de titreTexte, filets et boîtes dans les maquettes de pageImages dans les maquettes de pageSauts de ligne dans les titresCouleur du papierTitres courants et foliosPalette de couleurs sémantiqueStyles de paragrapheFigures et tableaux comme ressources
- Typographie
- Young Serif, Figtree, Caveat (SIL OFL 1.1)
- Ressources
gazpacho-1520.jpgtortilla-1520.jpg- The tortilla on its plate, a photograph (Generated With Diffusion Models, original)
- The bowl of gazpacho, a photograph (Generated With Diffusion Models, original)
- The note’s arrow and the pictograms, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)
Préparation
#1 · Donner à chaque colonne son propre encadré
Avec des listes libres, un groupe de colonnes dans un encadré coupe là où ses colonnes s’équilibrent le mieux, entre deux blocs ou entre les lignes d’un même bloc : la première étape du gazpacho se coupe alors de part et d’autre de la gouttière, sa première ligne fermant la colonne des ingrédients et le reste ouvrant la colonne de la préparation. Un encadré imbriqué, comme dans la réponse courte plus haut, forme un seul bloc, et le groupe ne coupe jamais à l’intérieur. Une image placée dans l’un d’eux demande un ::resource au placement 'here', car tout autre placement en fait un flottant, qui quitte l’encadré et se place comme n’importe quelle figure citée. Le label du style de fiche transforme le label="SERVES 4" du bloc en onglet : un offset égal à sa height le pose sur le bord supérieur de la fiche, et le rule court de l’angle opposé jusqu’à la fourchette et au couteau (styles d’encadré).
#2 · Centrer les numéros d’étape sur deux lignes
// 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) };
Un numéro de liste se peint centré un peu au-dessus de la première ligne de base de l’élément : numberFontSize seul alignerait donc les chiffres de 26 pt sur la première ligne ; numberVerticalOffset les abaisse de 2,5 mm, au milieu de la bande qui va des capitales de la première ligne à la ligne de base de la deuxième. Young Serif a des chiffres elzéviriens : 3, 4 et 5 descendent sous la ligne de base sur laquelle reposent 1 et 2. Quand separatorColor est défini, le séparateur se peint comme un segment à part, et le point par défaut s’imprime en olive à côté d’un chiffre rouge (listes ordonnées).
#3 · Coiffer la liste à cocher d’une bande de dessins
// :::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') } };
L’encadré prep ne contient que son titre et son icône : il fait office d’en-tête. Une width d’icône égale à 3,75 fois sa size étire le carré de l’icône en une bande de 18,75 × 5 mm, et les trois dessins la remplissent à gauche du titre (styles d’encadré). Une icône prend sa propre colonne à côté de tout le contenu de son encadré : les tâches vont donc après l’encadré, où elles s’alignent sur les tirets des ingrédients au lieu de commencer 21 mm plus loin. Les éléments - [ ] impriment la case à cocher par défaut, et unorderedLists.marginTop: 0 les laisse suivre l’en-tête à sa marginBottom, l’écart de 2,4 mm que gardent les titres de colonne (listes non ordonnées).
#4 · Faire des étiquettes des pastilles
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"} met un mot dans un bloc qui ne se coupe jamais d’une ligne à l’autre. Un borderRadius supérieur à la moitié de la hauteur du bloc est ramené à cette moitié, ce qui change les deux bouts en demi-cercles ; avec borderWidth: 0, la pilule se dessine dans la seule teinte. Le bloc mesure 11,4 pt de haut, moins que l’interlignage de 12,8 pt de la fiche : une deuxième ligne d’étiquettes ne toucherait pas la première (styles de pastille).
#5 · Une maquette, une photographie par recette
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="…"} choisit le style de titre et remplit ses emplacements à partir des attributs, dont le chapeau en italique (attributs de titre). Le resourceId de l’élément image vaut '{attr.art}', un espace réservé comme celui d’un texte : les deux recettes partagent un même style et chaque titre nomme sa photographie (éléments image). Chaque pilule est ancrée right-of la précédente, le titre above la première pilule et le surtitre au-dessus du titre : un titre plus long repousse le surtitre vers le haut, tandis que les pilules gardent leur ligne. Le chapeau est l’élément le plus bas de la maquette : l’ouverture réserve donc la place jusqu’à sa dernière ligne, et la fiche commence en dessous sans minHeight. La note est ancrée à la page, comme la photographie et la flèche, un second élément image dessiné en code, et s’arrête 2 mm avant la flèche sur les deux pages, bien que les marges en miroir décalent le bloc de texte de 2 mm vers la droite sur le recto.
#6 · Déclarer les images comme ressources
// 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))]);
Les deux éléments image de l’ouverture, l’icône de l’onglet et celle de l’en-tête de la liste à cocher nomment chacun une ressource, et le canvas peint le fichier que loadImage ou loadSvg a enregistré pour elle. Aucun :ref ne cite ces ressources : aucune ne devient une figure numérotée. Les images ne portent aucun mot : les notes manuscrites sont du texte de maquette posé par-dessus.
La recette complète
// ═══ 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`---Exemple Markdown · 66 lignes · 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 : identique dans toutes les recettes · 270 lignes
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Compter les blocs au lieu d’encadrer les colonnes
Sans les encadrés de colonne, breaks nomme l’enfant qui ouvre la colonne de droite, et chaque élément de liste, la ligne d’étiquettes et l’en-tête de la liste à cocher comptent pour un : le gazpacho demande breaks="10" (huit ingrédients et les étiquettes viennent d’abord), la tortilla breaks="11". Les titres de colonne et l’espace entre les étapes du style de colonne disparaissent avec eux.
:::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 @@
:::
-:::
:::#Poser l’onglet sur l’angle gauche
'top-left' inverse l’étiquette : l’onglet passe à l’angle gauche, la fourchette et le couteau à sa droite, et le filet arrive depuis la droite.
background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+ position: 'top-left', // 'top-right' by defaultErreurs fréquentes
Erreur fréquente
Un numéro de liste est centré sur sa première ligne, pas posé sur la ligne de base
Le numéro d’une liste ordonnée est peint avec la ligne de base 'middle' du canvas, à 0,3 em de texte au-dessus de la première ligne de base de l’élément : un numéro dans un autre caractère ou avec un numberFontSize plus grand s’étend vers le haut et vers le bas à partir de là au lieu de reposer sur la ligne. Un caractère de titrage monte trop haut, et un grand numéro d’étape déborde sur la première ligne. Placez-le avec orderedLists.numberVerticalOffset et vérifiez le résultat en taille réelle. Les numéros de la table des matières reposent sur la ligne de base depuis postext 1.5 ; ceux des listes sont toujours centrés. Listes numérotées →
Erreur fréquente
Le lists.color d’un encadré recolore aussi ses numéros de liste
Le lists.color d’un style d’encadré colore à la fois les puces de l’encadré et les numéros de ses listes ordonnées, dès que le style le définit : donnez ainsi des puces olive à un encadré, et ses numéros d’étape deviennent olive eux aussi. (Jusqu’à postext 1.4, il n’atteignait les numéros que s’il différait de unorderedLists.color.) Pour ne colorer que les puces d’un encadré, réglez unorderedLists.color pour le document et retirez lists.color du style de l’encadré : les numéros gardent alors orderedLists.color. Encadrés →
Erreur fréquente
Les pastilles plus hautes que le pas des lignes touchent la ligne suivante
Une pastille dont la boîte est plus haute que le pas des lignes touche les pastilles de la ligne suivante (l’avertissement chipOverlap). Réduisez sa marge intérieure verticale, sa bordure ou son corps, ou ouvrez l’interlignage. Pastilles dans le texte →
- Une étiquette n’a ni
textTransformniletterSpacing: SERVES 4 et PARA 4 sont donc tapés en capitales dans les blocs.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Images
- The tortilla on its plate, a photograph · Generated With Diffusion Models · original
- The bowl of gazpacho, a photograph · Generated With Diffusion Models · original
- The note’s arrow and the pictograms, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- Polices
- Young Serif (SIL OFL 1.1) · Figtree (SIL OFL 1.1) · Caveat (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


