En bref
Un dépliant carré pour un festival de musique, montré dans quatre jeux de couleurs. Il montre comment changer d’un coup toutes les couleurs du document en choisissant un nouveau jeu de couleurs.
Ce que vous allez composer
Le programme de Riverside Music Week, un festival fictif dans une ville au bord d’une rivière, imprimé en dépliant carré de 200 mm. Le recto porte un aplat de couleur sur sa moitié haute, avec le titre en Syne 800 de 46 pt et une forme d’onde de barres arrondies pâles ; dessous, deux colonnes en drapeau listent la semaine jour par jour, chaque événement avec une heure en gras et une pastille de prix. Le verso présente la semaine sous forme de grille de cases colorées sous une barre de légende, puis liste les journées du mardi au vendredi et se termine par un encadré des billets sous un liseré et par le colophon. Au-dessus des pages, le pen montre quatre épreuves, en rouge, sarcelle, violet et sable. Leurs couleurs viennent de sept entrées de palette nommées selon leur rôle, plus main-color réglé sur la couleur de la bande. Cliquez sur une épreuve, et tout le document se recompose dans cette palette.
Cette recette répond à
- Comment empêcher les titres, les mots en gras et les puces de sortir en bleu ?
- Comment colorer les termes clés (en gras ou en italique) dans le texte ou dans les encadrés ?
- Comment ajouter des pastilles de couleur de légende au texte, aux légendes ou aux notes de tableau ?
La réponse courte
// col(id) links a colour to a palette entry. The link decides what is printed, in text, lists,
// boxes, chips, captions, table fills, swatches, design slots and references alike; the hex
// beside it (the house red's) only fills the field a colour must have.
const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id });
const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id,
value: { hex, model: 'hex' } })); // the shape of config.colorPalette
// A colourway is a palette. retint() writes it into the one config; the next build sees the
// change and sets the whole programme in the new colours.
function retint(way) {
const palette = { ...NEUTRALS, ...COLOURWAYS[way] };
config.colorPalette = entries({ ...palette, 'main-color': palette.band }); // defaults: band
return config;
}
Ingrédients
- Fonctionnalités
- Palette de couleurs sémantiqueGras, italique et leurs couleursPastilles de couleurPastilles dans le texteFonds de celluleStyle des légendesOuvertures composéesBandeau de chapitre sur toute la largeurTexte, filets et boîtes dans les maquettes de pageListes à puces et listes de contrôleEncadrésTitres courants et foliosTitres courants selon le rôle de la pageAttributs de titreFigures et tableaux comme ressourcesPages sur un canvas
- Utilise aussi
- Des citations qui placent les figuresFigure et Tableau dans votre langueStyles de paragrapheTypes de ressource personnalisésSauts de ligne dans les titres
- Typographie
- Syne, Plus Jakarta Sans (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Nommer les couleurs selon leur fonction
const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' };
const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper
red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' },
teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' },
violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' },
sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' },
}; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink
const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the house edition: red
band remplit les aplats de couleur, onBand colore le texte composé dessus, deep est l’accent du texte sur papier blanc et tint le fond clair. Chaque jeu de couleurs règle ces quatre-là et partage les trois neutres. Sur blanc, la bande sarcelle mesure 3,3:1 et la bande sable 2,1:1, trop peu pour du texte blanc sur la bande ou pour du texte couleur de bande sur le papier. Ces deux palettes composent onBand à l’encre, et les quatre composent le gras et les titres en deep, une nuance plus sombre qui mesure entre 5,99:1 (sable) et 8,40:1 (violet) sur blanc.
#2 · Lier tout ce que le moteur imprimerait en bleu
const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD),
color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited)
boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [
// The programme opens on a recto.
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
{ level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) },
] };
const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4),
marginTop: pt(0), marginBottom: pt(0) };
const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6),
paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) };
const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf
{ id: 'ticket', background: col('band'), color: col('onBand'), ...chip },
{ id: 'free', background: col('tint'), color: col('deep'), ...chip,
borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white
];
const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } });
const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content,
parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] });
const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head
{ ...caps(7.5), color: col('deep') });
const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS,
fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'),
borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9),
left: mm(2.4) } } });
Le bleu du moteur est une entrée de palette, main-color (#295AA3), et ses valeurs par défaut y font pointer les titres, le gras, l’italique, les renvois, les puces et le titre courant. Ici, chacun de ces éléments porte un identifiant de palette : heures et mots-clés en gras deep, italiques à l’encre, puces en band, et en page 2 un titre courant en deep et le folio en onBand sur une pilule band. retint() fait aussi pointer main-color sur la bande ; comme cette configuration règle chaque couleur, cela ne change rien sur ces pages, mais un style ajouté plus tard sans couleur prendra la bande au lieu du bleu. L’encadré des billets ne règle pas sa propre couleur de gras : il hérite donc de bodyText.boldColor avec son identifiant de palette, et son gras change avec le jeu de couleurs.
#3 · Dessiner l’illustration avec des boîtes de maquette
const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left',
overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style });
const opener = { enabled: true,
minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone)
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('band') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
...waveform(), // boxes filled with col('tint'): one more link the palette decides
text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)),
text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY,
fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }),
text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10),
lineHeight: 1.36 }),
] },
};
L’ouverture est un emplacement de maquette qui contient une boîte à fond perdu sur le bord supérieur, 29 barres arrondies pour la forme d’onde et trois éléments de texte : le texte même du titre comme titre, et le surtitre et le chapeau tirés de ses attributs. Les barres sont des boîtes plutôt qu’un SVG, si bien que leur fond est un lien tint de plus, qui suit la palette ; un SVG devrait être redessiné et enregistré de nouveau pour chaque jeu de couleurs. Seule, la boîte de la bande ferait commencer le texte sur la ligne de grille située 2,4 mm sous la bande ; minHeight, 18 lignes ou 85,1 mm sous la marge du haut, réserve davantage et place la première ligne 7 mm sous la bande.
#4 · Laisser la palette remplir le tableau
const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'],
es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] });
const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday
[t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'],
[t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'],
[t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'],
[t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'],
[t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'],
[t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'],
];
const fill = { t: col('band'), f: col('tint') }; // cell fills are palette links too
const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2
caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }),
note: t({ en: ':swatch{color="band"} ticketed concert :swatch{color="tint"} free event',
es: ':swatch{color="band"} concierto con entrada :swatch{color="tint"} acto gratuito' }),
altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.',
es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }),
table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [
[{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({
content: day, isHeader: true, align: 'center' }))],
...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) =>
(m === '.' ? { content: '' } : { content: '', background: fill[m] }))]),
] } } }];
La grille est une ressource tableau, en dehors de la configuration. Ses fonds de cellule portent quand même des identifiants de palette, et le moteur les résout à partir de la palette du document quand il compose le tableau ; les :swatch{color="band"} et :swatch{color="tint"} de la légende, dans la note du tableau, se résolvent de la même façon. Les pastilles des listes utilisent les deux mêmes identifiants : un carré plein, la pastille à côté d’une heure et l’échantillon de la légende concordent dans chaque jeu de couleurs.
#5 · Changer de palette et recomposer
const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' },
es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } });
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions">
<div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs',
es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live"
role="img"></canvas><div class="buttons" role="group"></div></div></section>`);
const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas,
{ scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width });
const buttons = Object.keys(COLOURWAYS).map((way) => {
const button = document.querySelector('#editions .buttons')
.appendChild(Object.assign(document.createElement('button'), { type: 'button' }));
button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint']
.map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`;
paint(button.firstChild, docs[way]);
// The same config with another palette on every click; the fonts are loaded by now.
button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way)));
return [way, button];
});
const live = document.getElementById('live');
let shown; // the document on the live page
function show(way, doc) {
paint(live, (shown = doc));
live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`;
for (const [id, button] of buttons) button.ariaPressed = String(id === way);
showPages(doc, { title: `${TITLE} · ${NAMES[way]}` });
}
show('red', docs.red);
new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size
paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way]));
}).observe(live);
Chaque bouton appelle retint(), qui affecte les entrées du jeu de couleurs à colorPalette sur l’unique objet de configuration, puis recompose. Le moteur résout une configuration une seule fois et met le résultat en cache pour cet objet ; depuis postext 1.25, chaque composition vérifie d’abord que l’objet se lit toujours comme à ce moment-là, si bien qu’une modification faite sur place est résolue de nouveau. Chaque lien suit : le texte, les listes, les pastilles et la barre de légende, et avec eux la bande, la forme d’onde, le titre courant, la pilule du folio et « Table 1 ». Les quatre épreuves gardent leurs couleurs quand la palette change de nouveau, car chaque document conserve les couleurs avec lesquelles il a été composé. Le pen les compose avec buildDocumentWithFonts ; au moment où l’on clique sur un bouton, les polices sont chargées, et buildDocument suffit.
La recette complète
// ═══ Postext Cookbook · Nº 054 · Retint a whole document from one palette ═══════════ // https://postext.dev/en/cookbook/live-palette-retint // Code: MIT · Text: original (CC BY 4.0) · Artwork: design boxes generated in code (CC BY 4.0) // Fonts: Syne, Plus Jakarta Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, buildDocumentWithFonts, renderPageToCanvas, defaultResourceTypes, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'live-palette-retint'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: three neutrals every edition shares, four entries each colourway sets const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' }; const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' }, teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' }, violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' }, sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' }, }; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the house edition: red // #endregion // #region answer: every colour is a link to a palette entry; retint() swaps the palette // col(id) links a colour to a palette entry. The link decides what is printed, in text, lists, // boxes, chips, captions, table fills, swatches, design slots and references alike; the hex // beside it (the house red's) only fills the field a colour must have. const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id }); const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // the shape of config.colorPalette // A colourway is a palette. retint() writes it into the one config; the next build sees the // change and sets the whole programme in the new colours. function retint(way) { const palette = { ...NEUTRALS, ...COLOURWAYS[way] }; config.colorPalette = entries({ ...palette, 'main-color': palette.band }); // defaults: band return config; } // #endregion const [DISPLAY, SANS, BODY, LEAD] = ['Syne', 'Plus Jakarta Sans', 9.4, 13.4]; // pt: 33 lines const [TRIM, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 22, 22, 17, 15, 6]; // mm: square, mirrored const BAND = 100; // mm, trim top to the colour field's foot ('bleed' is the trim: no cut lines) const lines = (n) => pt(n * LEAD); const caps = (size) => ({ fontFamily: SANS, fontSize: pt(size), fontWeight: 700, letterSpacing: pt(size * 0.16), textTransform: 'uppercase' }); // #region opener: the colour field, a waveform of palette-linked boxes, the title on top const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left', overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style }); const opener = { enabled: true, minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone) slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, ...waveform(), // boxes filled with col('tint'): one more link the palette decides text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)), text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY, fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }), text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10), lineHeight: 1.36 }), ] }, }; // #endregion // #region links: every colour the engine would print in its blue, linked to a palette id const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited) boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) }; const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [ // The programme opens on a recto. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) }, ] }; const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4), marginTop: pt(0), marginBottom: pt(0) }; const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6), paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) }; const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf { id: 'ticket', background: col('band'), color: col('onBand'), ...chip }, { id: 'free', background: col('tint'), color: col('deep'), ...chip, borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white ]; const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } }); const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content, parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] }); const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head { ...caps(7.5), color: col('deep') }); const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS, fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'), borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9), left: mm(2.4) } } }); // #endregion const config = { // one object for every edition: retint() changes its palette in place locale: t({ en: 'en-us', es: 'es' }), // the programme's language, and its bundle's // The built-in types in the edition's language, renumbered: "Table 1", not "1.1" resourceTypes: defaultResourceTypes(LANG).map((r) => ({ ...r, numberingTemplate: '{n}' })), colorPalette: entries(HOUSE), // the red edition; retint() replaces it page: { width: mm(TRIM), height: mm(TRIM), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText, headings, unorderedLists, chipStyles, calloutStyles: [{ id: 'tickets', backgroundEnabled: false, // no fill, a stripe on top stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') }, padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { fontFamily: DISPLAY, fontSize: pt(11), color: col('deep'), gap: mm(1.2) } }], tableStyle: { borderColor: col('paper'), borderWidth: pt(1.6), // white rules cut the tiles headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5), bodyFontSize: pt(8.2), bodyColor: col('ink'), cellPadding: mm(1) }, captionStyle: { fontSize: pt(8), color: col('onBand'), labelColor: col('onBand'), position: 'above', backgroundEnabled: true, background: col('band'), padding: mm(1.2), gap: mm(1.2), note: { fontSize: pt(7.5), color: col('muted') } }, paragraphStyles: [{ id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted') }], header, footer, }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 51 lignes · content.en.md
title: "Riverside Music Week" subtitle: "Programme" author: "Almarén Music Society" --- # Riverside \\ Music Week {kicker="Almarén · 12–18 June 2027" standfirst="Fourteen events on both banks of the Almar, from the Quay Stage to the Boathouse. Eight of them are free; the rest cost €9 to €14."} The ninth Riverside Music Week keeps close to the water. Every venue is within ten minutes’ walk of the **Quay Stage**, and the **Iron Bridge** joins the two banks. :ref{id="week" style="full"} overleaf maps the week by venue and day. Free events need no ticket, but the Market Hall seats only 300, so come early. Concerts on the Quay go ahead in light rain. ## Saturday 12 June - **18.00** :chip[Free]{style="free"} The Almar Brass Band leads the opening parade from the Iron Bridge steps to the Quay Stage. - **21.30** :chip[€12]{style="ticket"} The Almar Chamber Orchestra plays Handel’s *Water Music* from two barges moored off the Quay. ## Sunday 13 June - **11.00** :chip[Free]{style="free"} Children’s percussion in the Market Hall. Bring a saucepan and a wooden spoon from home. - **17.00** :chip[Free]{style="free"} The Rowing Club choir sings shanties on the Boathouse slipway. - **20.00** :chip[€14]{style="ticket"} In St Clare’s Cloister, Cuarteto Ribera plays Haydn’s *Lark* quartet and Ravel’s Quartet in F. ## Monday 14 June - **19.00** :chip[Free]{style="free"} Fado on the Quay: Marta Loureiro sings with Portuguese and classical guitar. - **22.00** :chip[€9]{style="ticket"} The Almar Jazz Trio plays standards and its own tunes at the Boathouse. ## Tuesday 15 June - **21.30** :chip[€10]{style="ticket"} Murnau’s silent film *Sunrise* (1927) on a screen in the Tannery Yard, with a new score played live by the Tannery Ensemble. The yard opens at 21.00. ## Wednesday 16 June - **18.30** :chip[Free]{style="free"} Folk dance on the Quay. A caller walks everyone through each dance before the band starts it. ## Thursday 17 June - **18.00** :chip[Free]{style="free"} Open rehearsal: the Almar Chamber Orchestra works through *Vltava* on the Quay Stage. Sit on the steps and listen. - **20.00** :chip[Free]{style="free"} Six Almarén composers, one river song each, sung by the Community Choir in the Market Hall. - **22.30** :chip[€9]{style="ticket"} Cumbia night at the Boathouse with Los Remeros. ## Friday 18 June - **21.00** :chip[€12]{style="ticket"} Closing concert on the Quay Stage: the Almar Chamber Orchestra plays Smetana’s *Vltava*, which follows a river from its two springs to Prague. - **23.45** :chip[Free]{style="free"} The Community Choir sings at midnight on the Iron Bridge steps. Candles are handed out from 23.15. :::callout{type="tickets" title="Tickets"} A €48 **week pass** covers all six ticketed concerts. Single tickets are sold at the Market Hall box office from Monday 7 June, 10.00 to 14.00, and at the door 45 minutes before the start. **Under-16s** go free with a paying adult. ::: :::paragraphs{style="colophon"} Riverside Music Week is run by the Almarén Music Society. Programme correct on 3 May 2027; changes are posted at the box office. Set in Syne and Plus Jakarta Sans (SIL OFL) · Text CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region grid: the week at a glance, filled from palette ids the engine resolves itself const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'], es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] }); const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday [t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'], [t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'], [t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'], [t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'], [t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'], [t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'], ]; const fill = { t: col('band'), f: col('tint') }; // cell fills are palette links too const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2 caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }), note: t({ en: ':swatch{color="band"} ticketed concert :swatch{color="tint"} free event', es: ':swatch{color="band"} concierto con entrada :swatch{color="tint"} acto gratuito' }), altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.', es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }), table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [ [{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({ content: day, isHeader: true, align: 'center' }))], ...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) => (m === '.' ? { content: '' } : { content: '', background: fill[m] }))]), ] } } }]; // #endregion // #region art: a waveform over the river, 29 rounded bars from a seeded generator function waveform() { let seed = 0x5eed; // Mulberry32: the same bars on every run const random = () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; const [axis, n, width, gap] = [84, 29, 1.6, 0.95]; // mm; axis: the waterline const x0 = TRIM - OUTER - (n - 1) * (width + gap) - width; // the last bar ends at the margin return Array.from({ length: n }, (_, i) => { const envelope = Math.sin(((i + 0.5) / n) * Math.PI) ** 0.8; const up = 3 + 26 * envelope * (0.35 + 0.65 * random()); // mm above the waterline const down = up * 0.42; // and its reflection below it return { kind: 'box', id: `bar-${i}`, style: { backgroundColor: col('tint'), borderRadius: mm(width / 2) }, placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x0 + i * (width + gap)), y: mm(axis - up) }, size: { width: mm(width), height: mm(up + down) } } }; }); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Plus Jakarta Sans': ['400', '400i', '700'], Syne: ['700', '800'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const TITLE = t({ en: 'One programme, four palettes', es: 'Un programa, cuatro paletas' }); const build = (way) => buildDocumentWithFonts({ markdown, resources }, retint(way), kitFonts(FONTS)); const docs = {}; // red is built last: the capture shows the last build on its pages for (const way of ['sand', 'violet', 'teal', 'red']) docs[way] = await build(way); // #region live: four buttons, each a build of the whole programme in one colourway const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' }, es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } }); document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions"> <div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs', es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live" role="img"></canvas><div class="buttons" role="group"></div></div></section>`); const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas, { scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width }); const buttons = Object.keys(COLOURWAYS).map((way) => { const button = document.querySelector('#editions .buttons') .appendChild(Object.assign(document.createElement('button'), { type: 'button' })); button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint'] .map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`; paint(button.firstChild, docs[way]); // The same config with another palette on every click; the fonts are loaded by now. button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way))); return [way, button]; }); const live = document.getElementById('live'); let shown; // the document on the live page function show(way, doc) { paint(live, (shown = doc)); live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`; for (const [id, button] of buttons) button.ariaPressed = String(id === way); showPages(doc, { title: `${TITLE} · ${NAMES[way]}` }); } show('red', docs.red); new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way])); }).observe(live); // #endregionKit · core, fonts, viewer : identique dans toutes les recettes · 224 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 ───────────────────────────────────────────────────────────────────────
/* The proof desk above the pages: the live page, and the four colourways as buttons.
Sizes are in container units, so the desk keeps its 4:3 shape at any width up to 1120 px. */
#editions {
container-type: inline-size; max-width: 1120px; margin: 0 auto; background: #16181d;
color: #b9bcc4; font: 400 13px/1.4 "Plus Jakarta Sans", system-ui, sans-serif;
}
#editions .desk {
box-sizing: border-box; display: grid; aspect-ratio: 4 / 3; padding: 4cqw;
grid-template: 'page head' auto 'page ways' minmax(0, 1fr) / 67cqw minmax(0, 1fr);
gap: 2.6cqw 3.5cqw;
}
#editions header { grid-area: head; }
#editions .kicker {
margin: 0 0 0.8cqw; font-size: max(10px, 1cqw); font-weight: 700; letter-spacing: .18em;
text-transform: uppercase; color: #8d9098;
}
#editions h2 { margin: 0; font: 800 2.2cqw/1.1 Syne, sans-serif; color: #f4f1ea; }
#live {
grid-area: page; display: block; width: 67cqw; height: 67cqw; background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 2.4cqw 4.8cqw -1.8cqw rgb(0 0 0 / .85);
}
#editions .buttons {
grid-area: ways; display: grid; grid-template-rows: repeat(4, minmax(0, 1fr)); gap: 1.3cqw;
}
#editions button {
display: flex; align-items: center; gap: 1.6cqw; padding: 0; border: 0; background: none;
color: #f4f1ea; font: 700 1.7cqw/1.15 Syne, sans-serif; text-align: left; cursor: pointer;
}
#editions button canvas {
flex: none; width: 11cqw; height: 11cqw; background: #fff;
outline: 2px solid transparent; outline-offset: 0.45cqw; transition: outline-color .15s;
}
#editions button[aria-pressed="true"] canvas { outline-color: #f4f1ea; }
#editions button:hover canvas, #editions button:focus-visible canvas { outline-color: #8d9098; }
#editions button:focus-visible { outline: none; }
#editions button i { display: flex; gap: 0.5cqw; margin-top: 0.8cqw; }
#editions button b {
width: 1.4cqw; height: 1.4cqw; border-radius: 50%;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14);
}
@container (max-width: 640px) {
#editions .desk {
aspect-ratio: auto; padding: 24px 16px; gap: 18px;
grid-template: 'head' auto 'page' auto 'ways' auto / minmax(0, 1fr);
}
#editions h2 { font-size: 24px; }
#live { width: 100%; height: auto; aspect-ratio: 1; }
#editions .buttons { grid-template: auto / repeat(4, minmax(0, 1fr)); gap: 10px; }
#editions button { flex-direction: column; align-items: stretch; gap: 6px; font-size: 13px; }
#editions button canvas { width: 100%; height: auto; aspect-ratio: 1; }
#editions button i { gap: 4px; margin-top: 4px; }
#editions button b { width: 10px; height: 10px; }
}
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
#Garder le gras à l’encre
Laissez l’accent aux titres, aux puces et aux pastilles, et composez les heures et les mots-clés dans la couleur du texte.
- boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
+ boldColor: col('ink'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };#Donner à chaque partie d’un livre sa propre couleur
Pour des couleurs qui changent à l’intérieur d’un même document, partie par partie, voyez Des parties en couleur à partir d’un seul attribut.
Erreurs fréquentes
- Une image garde les couleurs avec lesquelles elle a été dessinée. Un SVG ou une photographie ne suit pas la palette, et c’est pourquoi la forme d’onde est faite de boîtes de maquette.
- Un identifiant absent de la palette se rabat sur la valeur hexadécimale écrite à côté. Faites une faute de frappe dans un
paletteId, et cette couleur reste dans le rouge maison quel que soit le jeu de couleurs.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Syne (SIL OFL 1.1) · Plus Jakarta Sans (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)


