In breve
Il programma stampato di un convegno di due giorni su stampa e lettering. Il suo orario permette a un intervento di estendersi su tutte le sale o su due fasce orarie, con ogni tema nel suo colore.
Che cosa comporrai
Il programma stampato di un immaginario convegno di tipografia a Girona, scritto in catalano. La copertina disegna la ela geminada maiuscola, L·L, come tre moduli colorati su fondo inchiostro. Ogni giornata si apre su una fascia color inchiostro di 76 mm con il giorno del mese come numerale di 144 pt. L’orario del giovedì è una tabella. La lezione inaugurale riempie una sola cella color inchiostro sulle tre sale, e ogni laboratorio occupa due fasce orarie. Le parti della giornata intestano le loro righe su tutta la griglia, e ogni intervento è una tessera nel colore del suo filone, spiegato dai campioni nella didascalia. La griglia è più alta di una pagina, quindi riempie la pagina 3 e finisce a pagina 4 sotto la sua riga d’intestazione ripetuta. La griglia più corta del venerdì sta intera al piede della sua pagina d’apertura.
Questa ricetta risponde a
- Come creo una tabella con righe di intestazione, celle unite, larghezze delle colonne e allineamento per cella?
- Come divido una tabella lunga su più pagine con l’intestazione ripetuta e un’indicazione «continua»?
- Come aggiungo campioni di colore come legenda nel testo, nelle didascalie o nelle note di tabella?
- Come ottengo le etichette «Figura» e «Tabella» nella lingua del mio documento?
- Come creo un’apertura di capitolo con una fascia di colore al vivo, un grande numero di capitolo e il titolo?
La risposta breve
const ROOMS = ['Sala Gran', 'Sala de les Premses', 'Aula Taller'];
const TRACKS = { T: 'tipo', D: 'digital', E: 'edicio', '*': 'ink' }; // '*': a plenary
const at = (row, c) => ({ row, col: c });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const lines = (code, text) => text.split(' / ').map((line, i) => { // title, then speaker
const run = i === 0 ? `**${line}**` : line;
return code === '*' ? chip(run, 'blanc') : run; // paper-white type on the ink fill
}).join('\n'); // a line break in a cell starts a new paragraph
function schedule(text) { // '11.15 | T Title / Speaker | ^' · a bare line: a part of the day
let m = { headerRowCount: 1, columnWidths: [12, 42, 42, 42], // weights of the 138 mm measure
rows: [['Hora', ...ROOMS].map((room) => ({ content: room.toUpperCase(), isHeader: true }))] };
for (const line of text.trim().split('\n')) {
const [time, ...slots] = line.split(' | ');
const r = m.rows.push(['', ...ROOMS].map(() => ({ content: '' }))) - 1; // four empty cells
if (!slots.length) { // one cell across the table; a split keeps it with the rows it heads
m = setCellContent(m, at(r, 0), chip(time.toUpperCase(), 'franja'));
m = mergeCells(m, span(r, 0, r, 3));
continue;
}
m = setCellContent(m, at(r, 0), chip(time, 'hora'));
slots.forEach((slot, i) => {
if (slot === '^') { // the session above runs on: one cell down both rows, centred in them
m = mergeCells(m, span(r - 1, i + 1, r, i + 1));
m = setAlignment(m, at(r - 1, i + 1), 'left', 'middle');
return;
}
const [, code = '', body = slot] = /^([TDE*]) (.+)$/.exec(slot) ?? [];
m = setCellContent(m, at(r, i + 1), lines(code, body));
m = setCellBackground(m, at(r, i + 1), col(TRACKS[code] ?? 'pause'));
});
if (slots.length === 1) m = mergeCells(m, span(r, 1, r, 3)); // the same for all three rooms
}
return m; // mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby)
}
Ingredienti
- Funzionalità
- Tabelle dai datiTabelle su più pagineRiempimenti delle celleCampioni di coloreChip in lineaCollocazione delle figureStile delle tabelleStile delle didascalieAperture progettateFascia di capitolo a tutta larghezzaAttributi dei titoliStili di titoloCopertine, frontespizi e colophonFigura e Tabella nella tua linguaTipi di risorsa personalizzatiStili di paragrafoTestatine e folioTavolozza di colori semantica
- Usa anche
- Bibliografie e glossariCitazioni che collocano le figureTestatine secondo il ruolo della paginaFigure e tabelle come risorseTestatine per sezioneCapitoli non numerati
- Tipo
- Schibsted Grotesk, Unbounded, Chivo Mono (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Trasforma ogni riga del programma in una riga della tabella
Il codice è la risposta breve qui sopra. Ogni giornata è testo semplice in un file di contenuto, una riga per fascia oraria: | separa le sale, una lettera nomina il filone, * segna una plenaria su fondo inchiostro, / comincia ogni riga dopo il titolo e ^ prosegue la sessione della fascia sopra. mergeCells crea ogni estensione. Una riga con una sola sessione, come la plenaria o una pausa, occupa le tre colonne delle sale. Un laboratorio occupa la sua cella e quella sotto, e una parte della giornata (MATÍ, TARDA), scritta come una riga senza |, occupa tutte e quattro le colonne. Le celle coperte da un’unione restano nella griglia, segnate hiddenBy, perché la colonna di una cella è il suo indice nella riga (costruire modelli di tabella). headerRowCount: 1 fa dei nomi delle sale la riga d’intestazione. Le columnWidths sono pesi relativi, e 12 + 3 × 42 fa i 138 mm della giustezza, quindi la colonna delle ore è larga 12 mm e ogni sala 42 mm. setAlignment(…, 'left', 'middle') centra in verticale ogni laboratorio nelle sue due righe.
#2 · Lascia proseguire il giovedì e tieni intero il venerdì
// The built-in types in Catalan, Figura and Taula; a grid's caption goes above it.
const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type,
...(type.id === 'table' && { captionStyle: { position: 'above' } }),
numberingTemplate: '{n}', resetOn: 'never' })); // Taula 1, Taula 2: one count, not per day
const SPLIT = { overflow: 'split', // the default; the other values are 'clip' and 'hide'
// The engine's own Catalan strings are '(cont.)' and 'Continua': these spell them out.
continuedSuffix: '(continuació)', // after the caption of every part but the first
continuesMarker: 'Continua a la pàgina següent' }; // under every part but the last
// Thursday is cited on page 2 and placed at the top, so it opens page 3 (gotcha:
// top-float-next-page) and splits; Friday goes whole to the foot of the page that cites it.
const PLACE = { dijous: { position: 'top', span: 'page' },
divendres: { position: 'bottom', span: 'page' } };
La griglia del giovedì è citata a pagina 2 e collocata in testa a una pagina. Un elemento flottante non sale mai sopra il suo rimando, quindi la griglia apre la pagina 3. È più alta della pagina, e overflow: 'split' la taglia tra due righe, mai dentro una cella unita (tabelle più alte della pagina). La pagina 3 finisce con la riga delle 17.30 e l’avviso sotto di essa. La pagina 4 ripete la riga d’intestazione e l’intera didascalia, legenda dei campioni compresa, seguita da (continuació), e la nota si stampa solo sotto quest’ultima parte. La griglia del venerdì va al piede di una pagina e sta sotto il testo che la cita a pagina 5. defaultResourceTypes('ca') nomina i tipi in catalano, Figura e Taula. Le stringhe catalane del motore per una tabella divisa sono (cont.) e Continua; lo stile della tabella le scrive invece per esteso. numberingTemplate: '{n}' con resetOn: 'never' conta le due griglie in un’unica serie, Taula 1 e Taula 2.
#3 · Taglia i riempimenti in tessere
const tableStyle = { ...SPLIT, borderColor: col('paper'), borderWidth: pt(1.6), // grid rules
headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
headerFontSize: pt(7.5), bodyFontSize: pt(8.2),
cellPadding: mm(1.5) };
const bare = (id, extra) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
paddingX: pt(0), ...extra }); // a chip that only changes the face, the size or the colour
const chipStyles = [bare('blanc', { color: col('paper') }), // the plenary's type, on ink
bare('hora', { fontFamily: LABEL, fontSize: em(0.96), bold: true }),
bare('franja', { fontFamily: LABEL, fontSize: em(0.92), bold: true, color: col('accent') }),
bare('sala')]; // keeps each room of the plan's note on one line
setCellBackground nella risposta breve dà a ogni sessione la voce di tavolozza del suo filone, e i filetti della griglia, larghi 1,6 pt nel colore della carta, tagliano i riempimenti in tessere separate da intercapedini bianche (stile della tabella). Una tabella ha un solo colore del testo per le celle del corpo, quindi il testo bianco della plenaria è un chip il cui stile imposta il colore e disattiva riempimento, bordo e padding laterale del chip. Anche gli orari e le parti della giornata sono chip, in Chivo Mono (stili dei chip). Un chip non va mai a capo; il titolo di una plenaria e la riga del relatore stanno ciascuno su una riga della cella delle tre sale. Nella nota della pianta, il chip sala lascia il testo com’è e tiene il numero e il nome di ogni sala su una riga, come farebbe uno spazio indivisibile tra le parole.
#4 · Apri ogni giornata su una fascia
const BAND = 76, STRIP = 8, AIR = 7; // mm: band from the trim, its strip of modules, air below
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: 'left',
overflow: 'wrap', ...extra }); // a long line wraps, in the running heads too
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
...(size && { size }) });
const caps = (size) => ({ fontWeight: 500, textTransform: 'uppercase',
letterSpacing: pt(size * 0.18) });
const NUMERAL = 144; // pt
// mm: Unbounded 800's 1 at 144 pt starts its ink 0.76 mm into its box, the date's first capital
// at 8 pt 0.1 to 0.2 mm into its own (P 0.19, S 0.09): 0.6 mm to the left lines up the two inks
const BEARING = 0.6;
const BASELINE = 0.8; // a design line's baseline sits 0.8 down its box (lineHeight 1)
const opener = { enabled: true, minHeight: mm(BAND - PAGE.top + AIR), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
placement: pin('bleed', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
{ kind: 'image', id: 'strip', resourceId: 'strip', placement: pin('page', 'top-left', 0,
BAND - STRIP, { width: mm(PAGE.w), height: mm(STRIP) }) }, // on the band's foot
text('date', '{attr.date}', LABEL, 8, 'edicio', pin('container', 'top-left', 0, 0), caps(8)),
text('numeral', '{attr.day}', DISPLAY, NUMERAL, 'paper', pin('#date', 'below', -BEARING, 1),
{ fontWeight: 800, lineHeight: 1 }), // a 144 pt line box
text('title', '{titleText}', DISPLAY, 26, 'paper', // its baseline level with the numeral's
pin('#numeral', 'right-of', 5, BASELINE * (NUMERAL - 26) * PT),
{ fontWeight: 700, lineHeight: 1 }),
] } };
Il disegno del titolo di primo livello traccia la fascia: un riquadro profondo 76 mm dal bordo dell’abbondanza, con la striscia di moduli lungo il piede. La data viene dall’attributo date del titolo e il numerale da day. L’1 di Unbounded 800 a 144 pt comincia il suo inchiostro 0,76 mm dentro il suo riquadro e la prima maiuscola della data da 0,1 a 0,2 mm dentro il proprio, quindi il numerale si sposta di 0,6 mm a sinistra, e data, numerale e testo cominciano il loro inchiostro entro 0,2 mm l’uno dall’altro. Il nome del giorno sta a destra del numerale, abbassato di BASELINE × (144 − 26) pt perché le due linee di base coincidano, dato che un testo di disegno con lineHeight: 1 ha la linea di base a 0,8 dell’altezza del suo riquadro. minHeight riserva la pagina fino a 7 mm sotto la fascia, e la griglia della linea di base arrotonda la riserva a 14 righe, così il primo riquadro di riga del testo comincia 10,2 mm sotto la fascia. Con AIR = 0 è invece il riquadro del numerale, che scende oltre il piede della fascia, a fissare l’altezza, e il testo comincia 5,6 mm sotto la fascia.
#5 · Rientra le righe successive dei relatori
const paragraphStyles = [
{ id: 'bio', hangingIndent: mm(4) }, // the name at the column edge, the lines under it 4 mm in
{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.4), lineHeight: pt(LEAD * 0.75),
textAlign: 'left', color: col('muted'), firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
Ogni relatore è un paragrafo di un blocco :::paragraphs{style="bio"}. Il nome in grassetto comincia sul bordo della colonna e le righe sotto rientrano di 4 mm, così i nomi si allineano lungo la colonna come in un indice (stili di paragrafo).
La ricetta completa
// ═══ Postext Cookbook · Nº 053 · Conference programme with a merged schedule grid ═══════ // https://postext.dev/en/cookbook/conference-programme // Code: MIT · Text: original, in Catalan (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Schibsted Grotesk, Unbounded, Chivo Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes, mergeCells, setCellContent, setCellBackground, setAlignment, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'conference-programme'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1d1b24', paper: '#ffffff', // a violet near-black; type on the ink fills tipo: '#b09cf2', digital: '#4fc7bb', edicio: '#f4b43a', // the three tracks: fills and swatches pause: '#ecebf1', // registration, breaks and meals accent: '#6a3ed3', // kickers, caption labels, the parts of the day muted: '#67636f', // running heads and notes }; // Each colour carries its hex and the palette entry it follows. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent }) // defaults' id .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Schibsted Grotesk', 'Unbounded', 'Chivo Mono']; const PAGE = { w: 170, h: 240, top: 22, bottom: 20, inner: 17, outer: 15 }; // mm, mirrored const LEAD = 13; // pt: the body's leading and baseline grid const PT = 25.4 / 72; // mm in a point const chip = (text, style) => `:chip[${text}]{style="${style}"}`; // #region answer: a day's schedule as a table with merged cells, track fills and a head row const ROOMS = ['Sala Gran', 'Sala de les Premses', 'Aula Taller']; const TRACKS = { T: 'tipo', D: 'digital', E: 'edicio', '*': 'ink' }; // '*': a plenary const at = (row, c) => ({ row, col: c }); const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) }); const lines = (code, text) => text.split(' / ').map((line, i) => { // title, then speaker const run = i === 0 ? `**${line}**` : line; return code === '*' ? chip(run, 'blanc') : run; // paper-white type on the ink fill }).join('\n'); // a line break in a cell starts a new paragraph function schedule(text) { // '11.15 | T Title / Speaker | ^' · a bare line: a part of the day let m = { headerRowCount: 1, columnWidths: [12, 42, 42, 42], // weights of the 138 mm measure rows: [['Hora', ...ROOMS].map((room) => ({ content: room.toUpperCase(), isHeader: true }))] }; for (const line of text.trim().split('\n')) { const [time, ...slots] = line.split(' | '); const r = m.rows.push(['', ...ROOMS].map(() => ({ content: '' }))) - 1; // four empty cells if (!slots.length) { // one cell across the table; a split keeps it with the rows it heads m = setCellContent(m, at(r, 0), chip(time.toUpperCase(), 'franja')); m = mergeCells(m, span(r, 0, r, 3)); continue; } m = setCellContent(m, at(r, 0), chip(time, 'hora')); slots.forEach((slot, i) => { if (slot === '^') { // the session above runs on: one cell down both rows, centred in them m = mergeCells(m, span(r - 1, i + 1, r, i + 1)); m = setAlignment(m, at(r - 1, i + 1), 'left', 'middle'); return; } const [, code = '', body = slot] = /^([TDE*]) (.+)$/.exec(slot) ?? []; m = setCellContent(m, at(r, i + 1), lines(code, body)); m = setCellBackground(m, at(r, i + 1), col(TRACKS[code] ?? 'pause')); }); if (slots.length === 1) m = mergeCells(m, span(r, 1, r, 3)); // the same for all three rooms } return m; // mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby) } // #endregion // #region split: where each day's grid lands and how it continues, labelled in Catalan // The built-in types in Catalan, Figura and Taula; a grid's caption goes above it. const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type, ...(type.id === 'table' && { captionStyle: { position: 'above' } }), numberingTemplate: '{n}', resetOn: 'never' })); // Taula 1, Taula 2: one count, not per day const SPLIT = { overflow: 'split', // the default; the other values are 'clip' and 'hide' // The engine's own Catalan strings are '(cont.)' and 'Continua': these spell them out. continuedSuffix: '(continuació)', // after the caption of every part but the first continuesMarker: 'Continua a la pàgina següent' }; // under every part but the last // Thursday is cited on page 2 and placed at the top, so it opens page 3 (gotcha: // top-float-next-page) and splits; Friday goes whole to the foot of the page that cites it. const PLACE = { dijous: { position: 'top', span: 'page' }, divendres: { position: 'bottom', span: 'page' } }; // #endregion // #region tiles: paper-coloured rules cut the fills into tiles; chips set the times and heads const tableStyle = { ...SPLIT, borderColor: col('paper'), borderWidth: pt(1.6), // grid rules headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(7.5), bodyFontSize: pt(8.2), cellPadding: mm(1.5) }; const bare = (id, extra) => ({ id, backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), ...extra }); // a chip that only changes the face, the size or the colour const chipStyles = [bare('blanc', { color: col('paper') }), // the plenary's type, on ink bare('hora', { fontFamily: LABEL, fontSize: em(0.96), bold: true }), bare('franja', { fontFamily: LABEL, fontSize: em(0.92), bold: true, color: col('accent') }), bare('sala')]; // keeps each room of the plan's note on one line // #endregion // #region opener: an ink band per day with the date, a 144 pt numeral and the day's name const BAND = 76, STRIP = 8, AIR = 7; // mm: band from the trim, its strip of modules, air below const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: 'left', overflow: 'wrap', ...extra }); // a long line wraps, in the running heads too const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const caps = (size) => ({ fontWeight: 500, textTransform: 'uppercase', letterSpacing: pt(size * 0.18) }); const NUMERAL = 144; // pt // mm: Unbounded 800's 1 at 144 pt starts its ink 0.76 mm into its box, the date's first capital // at 8 pt 0.1 to 0.2 mm into its own (P 0.19, S 0.09): 0.6 mm to the left lines up the two inks const BEARING = 0.6; const BASELINE = 0.8; // a design line's baseline sits 0.8 down its box (lineHeight 1) const opener = { enabled: true, minHeight: mm(BAND - PAGE.top + AIR), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('ink') }, placement: pin('bleed', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) }, { kind: 'image', id: 'strip', resourceId: 'strip', placement: pin('page', 'top-left', 0, BAND - STRIP, { width: mm(PAGE.w), height: mm(STRIP) }) }, // on the band's foot text('date', '{attr.date}', LABEL, 8, 'edicio', pin('container', 'top-left', 0, 0), caps(8)), text('numeral', '{attr.day}', DISPLAY, NUMERAL, 'paper', pin('#date', 'below', -BEARING, 1), { fontWeight: 800, lineHeight: 1 }), // a 144 pt line box text('title', '{titleText}', DISPLAY, 26, 'paper', // its baseline level with the numeral's pin('#numeral', 'right-of', 5, BASELINE * (NUMERAL - 26) * PT), { fontWeight: 700, lineHeight: 1 }), ] } }; // #endregion // #region bios: a hanging indent for the speakers' notes const paragraphStyles = [ { id: 'bio', hangingIndent: mm(4) }, // the name at the column edge, the lines under it 4 mm in { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.4), lineHeight: pt(LEAD * 0.75), textAlign: 'left', color: col('muted'), firstLineIndent: pt(0), marginTop: pt(LEAD) }, ]; // #endregion const head = (id, content, parity, x, extra) => text(id, content, LABEL, 7.5, 'muted', pin('page', x > 0 ? 'top-left' : 'top-right', x, 11), { ...caps(7.5), parity, pages: 'body', ...extra }); // body pages only: the cover and the openers have none const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', PAGE.outer, folio), head('verso-title', '{title}', 'even', PAGE.outer + 8), head('recto-title', '{chapterTitle} {attr.day} de novembre', 'odd', -(PAGE.outer + 8)), head('recto-folio', '{pageNumber}', 'odd', -PAGE.outer, folio)] }; const footer = { elements: [text('drop-folio', '{pageNumber}', LABEL, 7.5, 'ink', pin('page', 'bottom', 0, -11), { ...folio, pages: 'opener', align: 'center' })] }; // The cover is a heading style: an ink page with the L·L of modules and the title. The next // heading breaks the page, so the cover needs no :::pagebreak after it. const coverStyle = { id: 'coberta', numbered: false, header: { elements: [] }, footer: { elements: [] }, // no running heads on the cover advancedDesign: { enabled: true, minHeight: mm(PAGE.h - PAGE.top - PAGE.bottom), slot: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('ink') }, placement: pin('bleed', 'top-left', 0, 0, { width: 'fill', height: mm(PAGE.h) }) }, { kind: 'image', id: 'modules', resourceId: 'modules', placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(142) }) }, text('kicker', '{attr.kicker}', LABEL, 8.5, 'edicio', pin('page', 'top-left', 17, 162), caps(8.5)), text('name', '{titleText}', DISPLAY, 31, 'paper', pin('#kicker', 'below', -0.8, 3, { width: mm(138) }), { fontWeight: 700, lineHeight: 1.08 }), text('dates', '{attr.dates}', DISPLAY, 17, 'tipo', pin('#name', 'below', 0, 6), { fontWeight: 700, lineHeight: 1 }), text('place', '{attr.place}', LABEL, 8.5, 'paper', pin('#dates', 'below', 0.8, 3), caps(8.5)), ] } } }; const config = () => ({ locale: 'ca', // the programme is in Catalan in both editions: written out, never LANG resourceTypes, colorPalette, tableStyle, chipStyles, paragraphStyles, header, footer, headingStyles: [coverStyle], page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, // a 1,004 px canvas per page margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(6) }, bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // ragged, spaced headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [ // A day opens a new page, on either side. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener, marginBottom: pt(0) }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) }, ] }, captionStyle: { fontFamily: LABEL, fontSize: pt(7.6), labelColor: col('accent'), gap: mm(2), note: { fontSize: pt(7.4), color: col('muted') } }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Esempio Markdown · 81 righe · content.en.md
title: "7es Jornades de Tipografia i Edició Digital" author: "Comitè organitzador de les Jornades" --- # Jornades de Tipografia i Edició Digital {style="coberta" kicker="7es Jornades · Programa" dates="12–13.11.2026" place="Girona · La Impremta, Barri Vell"} # Dijous {day="12" date="Primera jornada · 12 de novembre"} La Impremta, l’antiga fàbrica de llibretes del Barri Vell, torna a acollir les Jornades. Hi ha ponències a la Sala de les Premses, on encara funciona la minerva de 1908, i a la Sala Gran, la nau on hi havia les guillotines. Els tallers ocupen l’Aula Taller, al primer pis. El plànol de l’última pàgina situa les sales i l’escala. L’acreditació obre a les 9.00 al vestíbul. La tarja dona accés a totes les sessions; els tallers, de vint places cadascun, demanen inscripció al mateix taulell fins a mitja hora abans de començar. Les ponències de la Sala Gran es tradueixen a l’anglès: demaneu els auriculars al taulell. La :ref{id="dijous" style="full" case="lower"} recull el programa del dia, franja a franja. Cada sessió porta el color del seu itinerari: :swatch{color="tipo"} Tipografia, :swatch{color="digital"} Digital o :swatch{color="edicio"} Edició. Els tallers de l’Aula Taller ocupen dues franges seguides. La conferència inaugural, en negre, no coincideix amb cap altra sessió. ## Qui parla dijous :::paragraphs{style="bio"} **Marta Casadevall** (Barcelona, 1971) dibuixa lletres des del 1998. Ha fet famílies de text per a tres diaris en català i, amb la Galligants, de 2021, va proposar un glif propi per a la ela geminada. Obre les Jornades. **Pere Ametller** (Vic, 1965) és filòleg i estudia com han compost el punt volat les impremtes catalanes des de les Normes ortogràfiques de 1913. Prepara una història gràfica de l’ortografia. **Laia Pujolàs** (Girona, 1983) dirigeix la correcció d’Edicions Llumeneres, on ha passat tot el procés d’edició a XML. Explicarà què ha guanyat la correcció d’estil i què hi ha perdut. **Quim Serrallonga** (Olot, 1958) va treballar trenta anys de caixista i ara ensenya composició manual a La Impremta. Al seu taller cada participant compon i estampa una targeta amb tipus de plom. **Irene Bosch** (València, 1989) programa interfícies per a una agència de notícies i fa servir fonts variables per ajustar l’amplada dels titulars a cada pantalla, del rellotge al televisor. **Arnau Vidal Roca** (Manresa, 1977) publica llibres de butxaca amb tirades de tres-cents exemplars i impressió digital. Ensenyarà els comptes de deu anys d’editorial, títol per títol. **Sílvia Ferrer** (Lleida, 1980) assessora editorials en accessibilitat. Des que la directiva europea s’aplica als llibres electrònics, el juny de 2025, revisa fitxers EPUB per a segells petits. Modera la taula rodona. **Jordi Baulenas** (Terrassa, 1969) aplega mostraris de les foneries catalanes i estudia el Supertipo Veloz, els mòduls que Joan Trochut va publicar el 1942. **Clara Tió** (Sabadell, 1986) fa fonts per encàrrec i ensenya a editors i maquetistes a treure profit de les taules OpenType: lligadures, versaletes, xifres elzevirianes i formes locals. **Nil Garriga** (Reus, 1992) treballa en un motor de maquetació per a navegadors. Explicarà per què el guionet de final de ratlla no parteix igual les paraules en català, en castellà i en anglès. **Ada Rigau** (Barcelona, 1990) dissenya lletres grotesques. Ha dibuixat els accents, la ce trencada i la ela geminada d’una família de set gruixos i en portarà els esbossos. **Rosa Fontanet** (Girona, 1975) fotografia els rètols pintats de la ciutat des del 2004 i n’ha publicat dos llibres. Fa una ponència a la tarda i guia el passeig del vespre pel Barri Vell. **Oriol Mas** (Tarragona, 1984) compon llibres amb codi: escriu el text en Markdown i descriu la pàgina en un fitxer de configuració. Ha compost així aquest programa. ::: ## Al vespre El passeig tipogràfic surt a les 19.00 del vestíbul i dura una hora i mitja: rètols pintats, làpides i aparadors, amb parades a la Rambla i a la plaça de l’Oli. Es fa encara que plogui; porteu calçat pla, perquè el Barri Vell és ple d’escales. El sopar, al pati, comença a les 21.00. Costa 28 euros, amb menú vegetarià per a qui el demani, i cal reservar-lo al taulell d’acreditació abans de les 14.00. Si plou, es fa a la Sala Gran. # Divendres {day="13" date="Segona jornada · 13 de novembre"} Divendres és mitja jornada: la cloenda acaba a les 14.00, a temps per als trens de la tarda. La :ref{id="divendres" style="full" case="lower"} fa servir els mateixos colors que la de dijous, i el taller de pinzell ocupa dues franges. L’acreditació obre a les 9.00; qui ja té la tarja pot anar directament a la Sala Gran (:ref{id="planol" style="full" case="lower"}). ## Qui parla divendres :::paragraphs{style="bio"} **Ingrid Aalto** (Hèlsinki, 1968) dissenya fonts per a les llengües del nord d’Europa i ha estudiat com llegim en pantalla les llengües amb molts diacrítics. Parla en anglès, amb traducció. **Pau Estrany** (Palma, 1987) escriu programari de composició i explicarà com mesura el text un navegador, de la mètrica de la font fins a la línia. **Montse Garcia** (Badalona, 1972) és bibliotecària i coordina el dipòsit de llibres digitals d’una xarxa de biblioteques públiques, que ja en conserva més de dotze mil. **Joana Pla** (Figueres, 1981) és retolista. Al seu taller s’hi pinta amb pinzell pla sobre paper d’embalar: majúscules romanes primer, cursiva després. **Toni Masó** (Alcoi, 1966) ha digitalitzat quatre tipus de plom d’una foneria valenciana a partir del mostrari de 1920. Portarà les proves de cada gruix, impreses en tres cossos. **Berta Soms** (Mataró, 1990) prepara PDF etiquetats per a lectors de pantalla i ensenya a revisar-los. **Jaume Rovira** (Girona, 1979) és editor i publica els preus de fabricació de cada llibre de la seva editorial. **Núria Solà** (Vilafranca del Penedès, 1985) ajusta l’espaiat de fonts per a revistes. Parlarà de l’apòstrof: *l’*, *d’*, *s’hi*. ::: ## Com arribar La Impremta és al Barri Vell, a un quart d’hora a peu de les estacions de tren i d’autobusos; des del pont de Pedra, seguiu els senyals de les Jornades. La :ref{id="planol" style="full" case="lower"} situa les sales. L’entrada és pel vestíbul, on hi ha l’acreditació i el guarda-roba; el pati fa de menjador a les pauses. :::paragraphs{style="colophon"} Programa compost amb Postext en Schibsted Grotesk, Unbounded i Chivo Mono (SIL OFL 1.1). Text i dibuixos: CC BY 4.0. Les Jornades, La Impremta i les persones d’aquest programa són inventades. :::`; // the programme's text, in Catalan const dijous = String.raw`MatíEsempio Markdown · 16 righe · content.dijous.en.md
9.00 | Acreditació i cafè de benvinguda / Vestíbul 9.45 | * Una llengua amb punt volat / Marta Casadevall · conferència inaugural 10.45 | Pausa / Pati 11.15 | T El punt volat, de 1913 a Unicode / Pere Ametller | E Corregir dins d’un flux XML / Laia Pujolàs | T Taller: compondre a mà amb tipus de plom / Quim Serrallonga / 11.15–12.45 · 20 places 12.00 | D Fonts variables, del rellotge al televisor / Irene Bosch | E Tirades de tres-cents exemplars / Arnau Vidal Roca | ^ Migdia 12.45 | Dinar lliure als restaurants del Barri Vell Tarda 14.30 | E EPUB accessible: què demana la llei / Sílvia Ferrer | T El Supertipo Veloz de Trochut / Jordi Baulenas | D Taller: OpenType per a editors / Clara Tió / 14.30–16.00 · 20 places 15.15 | D El guionet de final de ratlla / Nil Garriga | T Accents, ce trencada i ela geminada / Ada Rigau | ^ 16.00 | Pausa / Pati 16.30 | E Taula rodona: editar en català / Sílvia Ferrer, amb Laia Pujolàs i Arnau Vidal Roca | T Rètols pintats de Girona / Rosa Fontanet | D Del Markdown a la pàgina / Oriol Mas 17.30 | E Presentació: la col·lecció Lletra Petita / Arnau Vidal Roca | D Demostració: eixos variables en directe / Irene Bosch | T Visita a la minerva de 1908 / Quim Serrallonga Vespre 19.00 | T Passeig tipogràfic pel Barri Vell / Rosa Fontanet · sortida del vestíbul 21.00 | Sopar de les Jornades / Al pati · amb inscripció prèvia`; // Thursday's sessions, one line per time slot const divendres = String.raw`MatíEsempio Markdown · 6 righe · content.divendres.en.md
9.30 | * El català a la pantalla, vist de fora / Ingrid Aalto · en anglès, amb traducció 10.30 | D Com mesura el text un navegador / Pau Estrany | E El dipòsit dels llibres digitals / Montse Garcia | T Taller: majúscules amb pinzell pla / Joana Pla / 10.30–12.00 · 20 places 11.15 | T Del plom al fitxer variable / Toni Masó | D PDF per a lectors de pantalla / Berta Soms | ^ 12.00 | Pausa / Pati 12.30 | E Què costa fer un llibre / Jaume Rovira | T L’apòstrof i l’espaiat / Núria Solà | T Taller obert de composició / Quim Serrallonga 13.30 | * Cloenda / Comitè organitzador`; // Friday's // #region art: the cover's L·L in modules, the band's strip and the venue's floor plan const n = (v) => +v.toFixed(2); function mulberry32(seed) { // a seeded PRNG: the same modules in every capture return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const svg = (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 P = palette; // One module in an s × s cell at (x, y), turned by quarter turns about the cell's centre: // 0 a quarter disc, 1 a half disc, 2 a half-width bar, 3 a disc, 4 a square. function module(kind, turn, x, y, s, fill) { const c = `transform="rotate(${turn * 90} ${n(x + s / 2)} ${n(y + s / 2)})" fill="${fill}"`; if (kind === 0) return `<path d="M${n(x)} ${n(y)}h${s}A${s} ${s} 0 0 1 ${n(x)} ${n(y + s)}Z" ` + `${c}/>`; if (kind === 1) return `<path d="M${n(x)} ${n(y + s)}A${s / 2} ${s / 2} 0 0 1 ${n(x + s)} ` + `${n(y + s)}Z" ${c}/>`; if (kind === 2) return `<rect x="${n(x)}" y="${n(y)}" width="${n(s / 2)}" height="${s}" ${c}/>`; if (kind === 3) return `<circle cx="${n(x + s / 2)}" cy="${n(y + s / 2)}" r="${n(s / 2)}" ` + `${c}/>`; return `<rect x="${n(x)}" y="${n(y)}" width="${s}" height="${s}" ${c}/>`; } // The cover: L·L, the capital ela geminada, drawn with the modules on a 26 mm grid: each L is // a stem five cells tall with a quarter-disc foot, the punt volat a disc at mid cap height. function coverArt(w, h, s, x0, y0) { const ell = (c, id) => { const x = x0 + c * s; // one path: no seam between stem and foot return `<path d="M${x} ${y0}h${s}v${4 * s}a${s} ${s} 0 0 1 ${s} ${s}H${x}Z" ` + `fill="${P[id]}"/>`; }; const dot = `<circle cx="${x0 + 2.5 * s}" cy="${y0 + 2.5 * s}" r="${n(0.36 * s)}" ` + `fill="${P.edicio}"/>`; return svg(w, h, ell(0, 'tipo') + dot + ell(3, 'digital')); } function strip(w, s) { // the band's foot: one row of modules in the three track colours const rand = mulberry32(12); let out = ''; for (let x = 0; x < w; x += s) { out += module(Math.floor(rand() * 5), Math.floor(rand() * 4), x, 0, s, P[['tipo', 'digital', 'edicio'][Math.floor(rand() * 3)]]); } return svg(w, s, out); } // The floor plan: ink walls, a tinted courtyard and the rooms numbered in discs. The digits // are strokes, so the plan carries no font; the caption's note names the rooms. const DIGITS = { 1: 'M1.8 2.6 3.6 1V9', 2: 'M1 3A2.5 2.5 0 1 1 5.2 4.8L1 9H5.4', 3: 'M1.2 1H5L2.8 4.2A2.6 2.6 0 1 1 1 8.2', 4: 'M4.2 9V1L.8 6.6H5.6', 5: 'M5.2 1H1.6L1.2 4.6A2.8 2.8 0 1 1 1.2 8.6', 6: 'M4.6 1Q1 2.2.8 6.5A2.5 2.5 0 0 0 5.8 6.5 2.5 2.5 0 0 0 .8 6.5' }; const disc = (d, x, y) => `<circle cx="${x}" cy="${y}" r="3.4" fill="${P.ink}"/>` + `<path d="${DIGITS[d]}" transform="translate(${n(x - 1.05)} ${n(y - 1.65)}) scale(.33)" ` + 'fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>'; const line = (d, w, color = P.ink) => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="square"/>`; function plan() { // ground floor, 138 × 66 mm: the street is at the foot, the courtyard east const treads = Array.from({ length: 6 }, (_, i) => `M${77 + i * 2.2} 41V55`).join(''); return svg(138, 66, `<rect x="2" y="2" width="96" height="36" fill="${P.pause}"/>` // rooms 1–2 + `<rect x="98" y="2" width="38" height="56" fill="${P.digital}" fill-opacity=".18"/>` + `<circle cx="126" cy="14" r="6" fill="${P.digital}"/>` // two trees in the courtyard + `<circle cx="108" cy="48" r="4.5" fill="${P.digital}"/>` + line(treads, 0.35, P.muted) // the stairs, and the lift beside them + line('M91 42h5v12h-5zM91 42l5 12M96 42l-5 12', 0.35, P.muted) + line('M44 58A6 6 0 0 1 50 52M56 58A6 6 0 0 0 50 52', 0.3, P.muted) // the street doors + line('M44 58H2V2H98V14M98 26V58H56', 1) // outer walls, with the doors left open + line('M52 2V38M2 38H34M44 38H60M70 38H98M26 38V43M26 53V58M74 38V43M74 53V58', 0.6) + line('M98 2H136V58H98', 0.6) // the courtyard + `<path d="M49.3 65.5V61.2H47L50 58.6l3 2.6h-2.3v4.3Z" fill="${P.accent}"/>` // way in + disc(1, 27, 20) + disc(2, 75, 20) + disc(3, 83.5, 48) + disc(4, 50, 47) + disc(5, 14, 48) + disc(6, 117, 30)); } // #endregion // #region resources: the two grids, keyed by swatches in their captions, and the plan const KEY = ':swatch{color="tipo"} Tipografia · :swatch{color="digital"} Digital · ' + ':swatch{color="edicio"} Edició'; const table = (id, caption, model, note) => ({ id, typeId: 'table', kind: 'table', caption: `${caption} ${KEY}`, note, table: { model }, placement: PLACE[id], createdAt: 0, updatedAt: 0 }); const picture = (id, markup, w, h, extra) => ({ id, typeId: 'figure', kind: 'svg', markup, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0, ...extra }); const drawings = [ // the cover and the band draw the first two by id; the plan is cited picture('modules', coverArt(PAGE.w, 142, 26, 20, 12), PAGE.w, 142, { altText: 'L·L, la ela geminada majúscula, feta de mòduls violeta, turquesa i ambre' }), picture('strip', strip(PAGE.w, STRIP), PAGE.w, STRIP, { altText: 'Una filera de mòduls de lletra en els colors dels itineraris' }), // Cited on page 5, under the opener, so it heads page 6 (gotcha: top-float-next-page). picture('planol', plan(), 138, 66, { caption: 'La Impremta, planta baixa.', note: ['1 Sala Gran', '2 Sala de les Premses', '3 Aula Taller, al primer pis', '4 Vestíbul', '5 Guarda-roba', '6 Pati'].map((room) => chip(room, 'sala')).join(' · '), placement: { position: 'top', span: 'page' }, altText: 'Planta de La Impremta amb les sales numerades de l’1 al 6' }), ]; const resources = [...drawings.map(({ markup, ...r }) => r), table('dijous', 'Dijous 12 de novembre.', schedule(dijous), 'Les ponències duren quaranta ' + 'minuts, més cinc de preguntes; els tallers, noranta.'), table('divendres', 'Divendres 13 de novembre.', schedule(divendres)), ]; for (const { svg: { fileId }, markup } of drawings) await loadSvg(fileId, markup); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the layout uses, loaded before the build 'Schibsted Grotesk': ['400', '400i', '700'], // text, bios and cells Unbounded: ['600', '700', '800'], // sections; titles; the day's numeral 'Chivo Mono': ['400', '400i', '500', '700'] }; // labels, times, captions and the colophon // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, dijous, divendres].join('\n'); await prepareFonts(allText, config(), kitFonts(FONTS)); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), { ...kitFonts(FONTS), text: allText }); showPages(doc, { title: 'Jornades de Tipografia i Edició Digital · Programa' });Kit · core, fonts, viewer, images: uguale in ogni ricetta · 270 righe
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Arrotonda gli angoli della griglia
Una tabella divisa arrotonda gli angoli superiori della sua prima parte, a pagina 3, e quelli inferiori dell’ultima, a pagina 4, dove si vede solo quello destro perché la colonna delle ore non ha riempimento.
- cellPadding: mm(1.5) };
+ cellPadding: mm(1.5), borderRadius: mm(2.5) };#Numera a parte la griglia di ogni giornata
Contando per titolo di livello 1, come fanno i tipi predefiniti, si ottengono Taula 1.1 e Taula 2.1 e la pianta diventa Figura 2.1; lo stile della copertina non è numerato, quindi il giovedì è il titolo 1.
- numberingTemplate: '{n}', resetOn: 'never' })); // Taula 1, Taula 2: one count, not per day
+ numberingTemplate: '{h1}.{n}', resetOn: 'h1' })); // Taula 1.1, Taula 2.1: per dayErrori frequenti
Errore frequente
Un elemento flottante 'top' va alla pagina dopo la sua citazione, a meno che non possa aprire la pagina che lo cita
Per impostazione predefinita un elemento flottante non va mai sopra il proprio riferimento, quindi un flottante 'top' a tutta larghezza citato a pagina N apre la pagina N+1. Imposta placement.citingPage: true sulla risorsa o sul defaultPlacement del suo tipo (o layout.floatsAtCitingPage per tutto il libro, da postext 1.25) e aprirà invece la pagina N, con il testo sopra il riferimento che scende sotto di esso, quando entra entro layout.maxTopFraction e la riga che lo cita resta su quella pagina. Non apre mai la prima pagina di un capitolo né una pagina dopo un’interruzione forzata: lì apre ancora la pagina successiva, e 'auto' o 'bottom' possono prendere il piede della pagina che lo cita. Collocazione delle figure →
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- The programme’s Catalan text, both schedules included, written for this recipe, and the cover, band and floor-plan drawings, generated in code · Ignacio Ferro · CC BY 4.0
- Font
- Schibsted Grotesk (SIL OFL 1.1) · Unbounded (SIL OFL 1.1) · Chivo Mono (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)


