In breve
Un pieghevole quadrato per un festival musicale, mostrato in quattro combinazioni di colori. Mostra come cambiare in un colpo solo tutti i colori del documento scegliendo un nuovo gruppo di colori.
Che cosa comporrai
Il programma di Riverside Music Week, un festival immaginario in una città sul fiume, stampato come pieghevole quadrato di 200 mm. La prima pagina ha un campo di colore sulla metà superiore, con il titolo in Syne 800 a 46 pt e una forma d’onda di barre arrotondate chiare; sotto, due colonne a bandiera elencano la settimana giorno per giorno, ogni evento con un orario in grassetto e un chip del prezzo. Il retro disegna la settimana come una griglia di tessere colorate sotto una barra della didascalia, poi elenca i giorni da «Tuesday» a «Friday» e chiude con un riquadro dei biglietti sotto una striscia e il colophon. Sopra le pagine il pen mostra quattro prove, in rosso, petrolio, viola e sabbia. I loro colori vengono da sette voci di palette nominate per ruolo, più main-color impostato sul colore della fascia. Fai clic su una prova e tutto il documento si ricompone in quella palette.
Questa ricetta risponde a
- Come evito che titoli, parole in grassetto e punti elenco escano in blu?
- Come coloro i termini chiave (in grassetto o in corsivo) nel testo o dentro i riquadri?
- Come aggiungo campioni di colore come legenda nel testo, nelle didascalie o nelle note di tabella?
La risposta breve
// 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;
}
Ingredienti
- Funzionalità
- Tavolozza di colori semanticaGrassetto, corsivo e i loro coloriCampioni di coloreChip in lineaRiempimenti delle celleStile delle didascalieAperture progettateFascia di capitolo a tutta larghezzaTesto, filetti e riquadri nei progetti di paginaElenchi puntati e liste di controlloRiquadriTestatine e folioTestatine secondo il ruolo della paginaAttributi dei titoliFigure e tabelle come risorsePagine su un canvas
- Usa anche
- Citazioni che collocano le figureFigura e Tabella nella tua linguaStili di paragrafoTipi di risorsa personalizzatiA capo nei titoli
- Tipo
- Syne, Plus Jakarta Sans (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Nomina i colori per il lavoro che fanno
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 riempie i campi di colore, onBand colora il testo composto sopra di essi, deep è l’accento per il testo sulla carta bianca e tint il fondo chiaro. Ogni variante imposta questi quattro e condivide i tre neutri. Sul bianco la fascia petrolio misura 3,3:1 e quella sabbia 2,1:1, troppo poco per un testo bianco sulla fascia o per un testo del colore della fascia sulla carta. Queste due palette impostano onBand in nero, e tutte e quattro compongono grassetti e titoli in deep, una tonalità più scura che misura tra 5,99:1 (sabbia) e 8,40:1 (viola) sul bianco.
#2 · Collega tutto ciò che il motore stamperebbe in blu
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) } } });
Il blu del motore è una voce di palette, main-color (#295AA3), e i suoi valori predefiniti vi puntano titoli, grassetto, corsivo, rimandi, punti elenco e testatina. Qui ognuno di essi porta un id di palette: orari e termini chiave in grassetto deep, corsivi in nero, punti elenco in band, e a pagina 2 una testatina in deep e il folio in onBand su una pillola band. retint() punta anche main-color sulla fascia; poiché questa configurazione imposta ogni colore, su queste pagine non cambia nulla, ma uno stile aggiunto più tardi senza colore prende la fascia invece del blu. Il riquadro dei biglietti non imposta un colore proprio per il grassetto, quindi eredita bodyText.boldColor con il suo id di palette, e il suo grassetto cambia con la variante.
#3 · Disegna la grafica con i riquadri del progetto grafico
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’apertura è uno slot di progetto grafico che contiene un riquadro al vivo sul bordo superiore, 29 barre arrotondate per la forma d’onda e tre elementi di testo: il testo stesso del titolo come titolo, e l’occhiello e il sommario dai suoi attributi. Le barre sono riquadri e non un SVG, quindi il loro fondo è un altro collegamento a tint che segue la palette; un SVG andrebbe ridisegnato e registrato di nuovo per ogni variante. Da solo, il riquadro della fascia farebbe partire il testo sulla riga di griglia 2,4 mm sotto la fascia; minHeight, 18 righe o 85,1 mm sotto il margine superiore, riserva di più e sposta la prima riga a 7 mm sotto di essa.
#4 · Lascia che la palette riempia la tabella
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 griglia è una risorsa tabella, fuori dalla configurazione. I suoi fondi di cella portano comunque id di palette, e il motore li risolve dalla palette del documento quando impagina la tabella; allo stesso modo si risolvono i :swatch{color="band"} e :swatch{color="tint"} della legenda nella nota della tabella. I chip negli elenchi usano gli stessi due id, così un quadrato pieno, il chip accanto a un orario e il campione della legenda concordano in ogni variante.
#5 · Cambia palette e ricomponi
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);
Ogni pulsante chiama retint(), che assegna le voci della variante a colorPalette sull’unico oggetto di configurazione, e ricompone. Il motore risolve una configurazione una volta e mette in cache il risultato associandolo a quell’oggetto; da postext 1.25 ogni composizione controlla prima che l’oggetto si legga ancora come allora, quindi una modifica fatta sul posto viene risolta di nuovo. Ogni collegamento segue: il testo, gli elenchi, i chip e la barra della didascalia, e con loro la fascia, la forma d’onda, la testatina, la pillola del folio e «Table 1». Le quattro prove mantengono i loro colori quando la palette cambia ancora, perché ogni documento conserva i colori con cui è stato impaginato. Il pen le compone con buildDocumentWithFonts; quando si fa clic su un pulsante i caratteri sono già caricati, e basta buildDocument.
La ricetta completa
// ═══ 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`---Esempio Markdown · 51 righe · 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: uguale in ogni ricetta · 224 righe
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
/* 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; }
}
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Tieni il grassetto in nero
Lascia l’accento a titoli, punti elenco e chip, e componi orari e termini chiave nel colore del testo.
- boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
+ boldColor: col('ink'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };#Dai a ogni parte di un libro il suo colore
Per colori che cambiano dentro un unico documento, parte per parte, vedi Parti a colori da un solo attributo.
Errori frequenti
- Un’immagine mantiene i colori con cui è stata disegnata. Un SVG o una fotografia non seguono la palette, ed è per questo che la forma d’onda è fatta di riquadri del progetto grafico.
- Un id che la palette non contiene ripiega sull’esadecimale scritto accanto. Sbaglia a scrivere un
paletteIde quel colore resta nel rosso della casa in ogni variante.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Font
- Syne (SIL OFL 1.1) · Plus Jakarta Sans (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


