Saltar al contingut principal
Recepta número 33

Receptari · Capítol 6 · Requadres i notes

Fitxa de recepta: ingredients al costat de l'elaboració

Dues pàgines de receptari amb una fitxa amb la pestanya PARA 4: ingredients, etiquetes i llista de tasques a l'esquerra; passos amb xifres vermelles a la dreta.

En aquesta pàgina

pp. 58–59 · 1–2 de 2

  • Mostra en castellà: encara no hi ha edició en català
  • Format 190 × 250 mm
  • 1 columna
  • Figtree 9,4/12,8
  • Caveat
  • Young Serif
  • 2 pàgines
  • Nivell
  • Postext 1.9.2
  • Compost en 13 ms
  • 188 línies de codi

En poques paraules

Dues pàgines d'un llibre de cuina casolana, una recepta a cadascuna. Ensenya a posar els ingredients i els passos de la recepta l'un al costat de l'altre en una fitxa.

Què compondràs

Dues pàgines encarades de Sal y aceite, un receptari casolà inventat de 190 × 250 mm, amb una recepta per pàgina. Una fotografia, generada amb models de difusió i carregada com a JPEG, ocupa a sang els 104 mm superiors: una truita amb una porció separada sobre una taula safrà, un bol de gaspatxo sobre una altra de verd sàlvia. A sobre van l'avanttítol, el títol en Young Serif de 42 pt, píndoles amb el temps, la dificultat i la temporada, i una nota manuscrita en Caveat que assenyala el plat. Sota l'entradeta, una fitxa blanca porta a dalt a la dreta una pestanya vermell tomàquet amb les racions. Els ingredients baixen per l'esquerra amb ratlles verd oliva i xips d'etiqueta; sota els de la truita, un bol, una paella i un plat dibuixats encapçalen una llista de tasques. Cada pas de l'elaboració, a la dreta, porta al davant una xifra elzeviriana de 26 pt.

Aquesta recepta respon a

  • Com poso dues columnes dins d'un requadre (una de text al costat d'una altra amb una figura)?
  • Com afegeixo una pestanya numerada («REQUADRE 1-1»), una icona de cantonada o una icona al marge amb un filet?
  • Com personalitzo les llistes: pics per nivell, numeració (a)/(i), caselles de tasques i un espaiat que respecti la retícula?
  • Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
  • Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?

La resposta curta

script.js · línies 25–47al codi complet
// 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 (gotcha: callout-columns).
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].

Ingredients

Tipografia
Young Serif, Figtree, Caveat (SIL OFL 1.1)
Recursos
  • gazpacho-1520.jpg
  • tortilla-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)

Elaboració

#1 · Un requadre per a cada columna

Amb les llistes soltes, un grup de columnes en un requadre talla on les seves columnes queden més igualades, entre blocs o entre les línies d'un bloc, de manera que el primer pas del gaspatxo es parteix a banda i banda de l'espai entre columnes: la seva primera línia tanca la columna d'ingredients i la resta obre la de l'elaboració. Un requadre niat, com a la resposta curta de dalt, és un sol bloc, i el grup mai no hi talla a dins. Per posar-hi una imatge cal un ::resource amb la col·locació 'here', perquè amb qualsevol altra la imatge flota, surt del requadre i es col·loca com qualsevol figura citada. El label de l'estil de la fitxa converteix el label="PARA 4" de la tanca en la pestanya: un offset igual a la seva height la recolza sobre la vora superior de la fitxa, i el rule va des de la cantonada contrària fins a la forquilla i el ganivet (estils d'avís).

#2 · Centra els números de pas en dues línies

script.js · línies 65–75al codi complet
// 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) };

El número d'una llista es pinta centrat una mica per sobre de la primera línia de base de l'element, així que numberFontSize tot sol deixaria les xifres de 26 pt a l'altura de la primera línia; numberVerticalOffset les baixa 2,5 mm, al centre de la franja que va de les majúscules de la primera línia a la línia de base de la segona. Young Serif té xifres elzevirianes, de manera que el 3, el 4 i el 5 baixen de la línia de base on es recolzen l'1 i el 2. Amb separatorColor, el separador es pinta com un tram a part, i el punt per defecte surt verd oliva al costat de la xifra vermella (llistes ordenades).

#3 · Encapçala la llista amb una tira de dibuixos

script.js · línies 51–61al codi complet
// :::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') } };

El requadre prep només porta el seu títol i la seva icona, i per això fa d'encapçalament: un width de la icona de 3,75 vegades la seva size converteix el seu quadrat en una tira de 18,75 × 5 mm, i els tres dibuixos l'omplen a l'esquerra del títol (estils d'avís). La icona ocupa una columna pròpia al costat de tot el que hi ha al seu requadre, així que les tasques van després del requadre, on s'alineen amb les ratlles dels ingredients en lloc de començar 21 mm més endins. Els elements - [ ] pinten la casella de tasca per defecte, i unorderedLists.marginTop: 0 els deixa seguir l'encapçalament a la distància del seu marginBottom, els mateixos 2,4 mm que guarden els títols de columna (llistes no ordenades).

#4 · Les etiquetes, en xips

script.js · línies 79–81al codi complet
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[Vegetariana]{style="tag"} fica una paraula en una caixa que mai no es parteix entre línies. Un borderRadius més gran que la meitat de l'alçada de la caixa es queda en aquesta meitat i arrodoneix els extrems en semicercle; amb borderWidth: 0, la píndola es dibuixa només amb el to de fons. La caixa fa 11,4 pt d'alçada, menys que els 12,8 pt d'interlineat de la fitxa, així que una segona línia d'etiquetes no tocaria la primera (estils de xip).

#5 · Un disseny i una fotografia per a cada recepta

script.js · línies 85–119al codi complet
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 }); // gotcha: overflow-ellipsis-default
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 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The photo reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead 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="…"} tria l'estil de títol i n'omple les ranures amb els atributs, també l'entradeta en cursiva (atributs d'encapçalament). El resourceId de l'element d'imatge és '{attr.art}', un marcador com els d'un text, així que les dues receptes comparteixen un estil i cada títol anomena la seva fotografia (elements d'imatge). Cada píndola s'ancora amb right-of a l'anterior, el títol amb above a la primera i l'avanttítol sobre el títol, així que un títol més llarg empeny cap amunt l'avanttítol i les píndoles no es mouen de la seva línia. A la 1.4.1 la fotografia no reserva alçada, però l'entradeta que va a sota sí, de manera que la fitxa comença sota seu sense minHeight. La nota s'ancora a la pàgina, com la fotografia i la fletxa, un segon element d'imatge dibuixat en codi, i acaba a 2 mm de la fletxa a les dues pàgines, tot i que amb els marges en mirall la caixa de la pàgina senar comenci 2 mm més a la dreta.

#6 · Registra les imatges com a recursos

script.js · línies 272–294al codi complet
// 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))]);

Els dos elements d'imatge de l'obertura, la icona de la pestanya i la de l'encapçalament de la llista anomenen cadascun un recurs, i el llenç pinta el fitxer que loadImage o loadSvg hi va registrar. Cap :ref no cita aquests recursos, així que cap no es converteix en una figura numerada. Ni una fotografia ni un SVG pintat com a imatge no poden fer servir les fonts web de la pàgina, per això les imatges no porten paraules: les notes manuscrites són text de disseny compost a sobre.

La recepta completa

Sandbox
// ═══ 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.8.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'es'; // @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
// The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs).
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 (gotcha: callout-columns).
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 }); // gotcha: overflow-ellipsis-default
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 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The photo reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead 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 = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  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' },
  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: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    // 'any': each recipe opens the next page, whichever side it is on. span: 'page' paints the
    // opener outside the column's clip: kept in the column, the drawing is cut at the top margin.
    { level: 1, span: 'page', 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`---
Mostra en Markdown · 66 línies · content.es.mdtitle: "Sal y aceite" --- # Tortilla \\ de patatas {style="receta" art="tortilla" kicker="Huevos y patatas" time="45 min" level="Dificultad media" season="Todo el año" note="¡con cebolla, siempre!" lead="La de casa: patata pochada despacio en mucho aceite de oliva, huevo apenas cuajado y diez minutos de reposo para que la patata se empape. Con cebolla, como la de la abuela; sin ella, si en tu casa se discute."} :::callout{type="card" label="PARA 4"} :::columns{count=2} :::callout{type="column" title="Ingredientes"} - **6** huevos camperos grandes - **800 g** de patatas para freír - **1** cebolla mediana - **300 ml** de aceite de oliva virgen extra - Sal fina :chip[Vegetariana]{style="tag"} :chip[Sin gluten]{style="tag"} :chip[De fiambrera]{style="tag"} :::callout{type="prep" title="Antes de empezar"} ::: - [ ] Los huevos, fuera de la nevera - [ ] Un bol para el huevo y la patata - [ ] Un plato más ancho que la sartén ::: :::callout{type="column" title="Elaboración"} 1. Pela las patatas y córtalas en láminas finas; la cebolla, en juliana. Sálalas. 2. Pocha las dos en el aceite, a fuego medio y en una sartén de 24 cm, unos 20 minutos: tiernas y sin dorar. 3. Escúrrelas y guarda el aceite. Bate los huevos con sal, añade la patata aún caliente y deja reposar 10 minutos. 4. Calienta a fuego fuerte una cucharada del aceite, vierte la mezcla y cuájala dos minutos, despegando los bordes. 5. Dale la vuelta con un plato, cuájala un minuto más y sírvela templada. ::: ::: ::: **Guarda el aceite.** Colado y en un tarro, sirve para freír la próxima tortilla. # Gazpacho \\ andaluz {style="receta" art="gazpacho" kicker="Sopas frías" time="25 min + 2 h de nevera" level="Fácil" season="De junio a septiembre" note="¡bien frío!" lead="En muchas casas andaluzas se bebe en vaso, recién sacado de la nevera. Pide tomates maduros de verdad y el aceite en hilo con la batidora en marcha, que lo deja cremoso y anaranjado."} :::callout{type="card" label="PARA 6"} :::columns{count=2} :::callout{type="column" title="Ingredientes"} - **1,5 kg** de tomates de pera maduros - **1** pimiento verde italiano - **1** pepino - **1** diente de ajo - **100 g** de pan del día anterior, remojado - **120 ml** de aceite de oliva virgen extra - **3 cucharadas** de vinagre de Jerez - Sal y agua fría :chip[Vegana]{style="tag"} :chip[Sin fuego]{style="tag"} :chip[De víspera]{style="tag"} ::: :::callout{type="column" title="Elaboración"} 1. Trocea los tomates, el pimiento sin semillas, el pepino pelado y el ajo. 2. Ponlo todo en la batidora con el pan remojado, el vinagre y una cucharadita de sal; deja macerar 15 minutos. 3. Tritura 2 minutos a máxima potencia y, sin parar el motor, añade el aceite en hilo fino hasta que emulsione. 4. Pásalo por un colador fino y aligéralo con agua fría a tu gusto. 5. Rectifica de sal y vinagre y enfríalo al menos 2 horas antes de servirlo. ::: ::: ::: **Hazlo de víspera.** Aguanta dos días en la nevera, en una jarra tapada; remuévelo antes de servirlo. :::paragraphs{style="colophon"} *Sal y aceite* · Compuesto en Young Serif, Figtree y Caveat (SIL OFL) · Recetas originales, CC BY 4.0 · Fotos generadas :::
`; // 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 (gotcha: fonts-first). 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 await loadFonts(FONTS, markdown); // 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 buildWithFonts(() => buildDocument({ markdown, resources, continuation: { pageIndexOffset: 57, pageNumbering: { startAt: 58 } } }, config()), markdown); showPages(doc, { title: t({ en: 'Recipe card', es: 'Tarjeta de receta' }) });
Kit · core, fonts, viewer, images: igual a totes les receptes · 270 línies// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Compta blocs en lloc de tancar les columnes

Sense els requadres de columna, breaks indica en quin bloc comença la columna dreta, i cada element de llista, la línia d'etiquetes i l'encapçalament de la llista de tasques compten com un: el gaspatxo necessita breaks="10" (abans hi van vuit ingredients i les etiquetes) i la truita, breaks="11". Amb els requadres se'n van també els títols de columna i l'espai entre passos de l'estil de columna.

 :::callout{type="card" label="PARA 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredientes"}
+:::columns{count=2 breaks="10"}
 - **1,5 kg** de tomates de pera maduros
@@ set ingredients més i una línia en blanc @@
 :chip[Vegana]{style="tag"} :chip[Sin fuego]{style="tag"} :chip[De víspera]{style="tag"}
-:::
-:::callout{type="column" title="Elaboración"}
+
 1. Trocea los tomates, el pimiento sin semillas, el pepino pelado y el ajo.
@@ passos 2 a 5 @@
 :::
-:::
 :::

#Posa la pestanya a la cantonada esquerra

Amb 'top-left' la pestanya passa a la cantonada esquerra, la forquilla i el ganivet queden a la seva dreta i el filet entra des de la dreta.

     background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+    position: 'top-left', // 'top-right' per defecte

Errors freqüents

Error freqüent

:::columns només funciona dins d'un requadre i no es parteix

:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →

Error freqüent

El número d'una llista es centra a la primera línia, no es recolza en la línia de base

A postext 1.4.1 el número d'una llista numerada es pinta amb la línia de base «middle» del canvas, 0,3 em del text per sobre de la primera línia de base de l'element, així que un número en una altra font o amb un numberFontSize més gran creix cap amunt i cap avall des d'allà en lloc de recolzar-se en la línia: una font de retolació queda alta i un número de pas gran penja sobre la primera línia. Col·loca'l amb orderedLists.numberVerticalOffset i revisa el resultat a mida real. Llistes numerades →

Error freqüent

El lists.color d'un requadre també canvia el color dels números de les seves llistes

A postext 1.4.1 el lists.color d'un estil de requadre arriba als números de les seves llistes numerades sempre que sigui diferent d'unorderedLists.color, encara que orderedLists fixi el seu propi color: si dones així vinyetes verd oliva a un requadre, els seus números de pas també es tornen verd oliva. Per acolorir només les vinyetes d'un requadre, fixa unorderedLists.color al document i deixa lists.color fora de l'estil del requadre. Requadres →

Error freqüent

Els xips més alts que l'interlineat toquen la línia següent

Un xip amb una caixa més alta que l'interlineat toca els xips de la línia següent (l'avís chipOverlap). Redueix-ne el farciment vertical, la vora o el cos, o obre l'interlineat. Chips en línia →

Error freqüent

Llistes 'arabic', recursos 'roman-upper', pàgines 'upper-roman'

Cada opció de numeració escriu els formats a la seva manera: les llistes fan servir numberFormat 'arabic' ('decimal' imprimeix «undefined»), els tipus de recurs counterFormat 'roman-upper' i les pàgines i :::numbering 'upper-roman'. Llistes numerades →

Error freqüent

Les imatges d'una obertura no compten per a l'alçada que reserva

A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

El lineHeight d'un text de disseny és un múltiple, mai una mesura

En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →

Error freqüent

El desbordament del text de disseny és 'ellipsis-end' per defecte

Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →

Error freqüent

Una paleta canviada no arriba als elements de disseny ni al color de les remissions

postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →

Error freqüent

El text dins d'un SVG <img> no pot fer servir fonts web

Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

  • A la 1.4.1 el rètol de la pestanya (label) no té textTransform ni letterSpacing, així que PARA 4 i SERVES 4 ja van en majúscules a les tanques.
  • La casella de tasca ☐ no és a cap de les tres fonts, així que el navegador la dibuixa amb una font del sistema i la seva forma canvia d'un sistema a un altre.
  • Deixa una icona ampla a la columna pròpia del requadre: amb position: 'corner', la 1.4.1 la desplaça la meitat de la seva alçada, no de la seva amplada, i la tira de 18,75 mm sobresurt 16 mm per la vora dreta del requadre.

Crèdits

Text
Text original, CC BY 4.0
Imatges
  • 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
Fonts
Young Serif (SIL OFL 1.1) · Figtree (SIL OFL 1.1) · Caveat (SIL OFL 1.1)
Sandbox