Kurz gesagt
Das gedruckte Programm einer zweitägigen Tagung über Druck und Schriftgestaltung. Im Zeitplan kann ein Vortrag über alle Säle oder über zwei Zeitfenster reichen, und jedes Thema hat seine eigene Farbe.
Was Sie setzen
Das gedruckte Programm einer erfundenen Typografietagung in Girona, auf Katalanisch geschrieben. Der Umschlag zeichnet die großgeschriebene ela geminada, L·L, als drei farbige Module auf Schwarz. Jeder Tag beginnt auf einem 76 mm hohen dunklen Band mit dem Tag des Monats als Ziffer zu 144 pt. Der Zeitplan des Donnerstags ist eine Tabelle. Der Eröffnungsvortrag füllt eine dunkle Zelle über die drei Säle, und jeder Workshop belegt zwei Zeitfenster. Die Tagesabschnitte führen ihre Zeilen über das ganze Raster an, und jeder Vortrag ist eine Kachel in der Farbe seines Themenstrangs, erklärt durch die Farbfelder in der Legende. Das Raster ist höher als eine Seite, füllt also Seite 3 und endet auf Seite 4 unter seiner wiederholten Kopfzeile. Das kürzere Raster des Freitags passt ganz an den Fuß seiner Auftaktseite.
Dieses Rezept beantwortet
- Wie erstelle ich eine Tabelle mit Kopfzeilen, verbundenen Zellen, Spaltenbreiten und Ausrichtung je Zelle?
- Wie teile ich eine lange Tabelle über mehrere Seiten, mit wiederholtem Kopf und einem Fortsetzungsvermerk?
- Wie füge ich Farbfelder als Farblegende in Text, Bildunterschriften oder Tabellenanmerkungen ein?
- Wie erhalte ich die Beschriftungen „Abbildung“ und „Tabelle“ in der Sprache meines Dokuments?
- Wie gestalte ich einen Kapitelauftakt mit randabfallendem Farbband, großer Kapitelnummer und Titel?
Die kurze Antwort
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)
}
Zutaten
- Funktionen
- Tabellen aus DatenTabellen über mehrere SeitenZellfüllungenFarbfelderInline-ChipsPlatzierung von AbbildungenTabellenformatFormat der BildunterschriftGestaltete AuftakteKapitelband über die ganze BreiteÜberschriftenattributeÜberschriftenformateUmschläge, Titelseiten und ImpressumAbbildung und Tabelle in Ihrer SpracheEigene RessourcentypenAbsatzformateKolumnentitel und SeitenzahlenSemantische Farbpalette
- Nutzt außerdem
- Literaturverzeichnisse und GlossareVerweise, die Abbildungen platzierenKopfzeilen nach SeitenrolleAbbildungen und Tabellen als RessourcenKolumnentitel je AbschnittKapitel ohne Nummer
- Schrift
- Schibsted Grotesk, Unbounded, Chivo Mono (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Jede Programmzeile in eine Tabellenzeile verwandeln
Der Code steht oben in der kurzen Antwort. Jeder Tag ist reiner Text in einer Inhaltsdatei, eine Zeile pro Zeitfenster: | trennt die Säle, ein Buchstabe benennt den Themenstrang, * markiert ein Plenum auf dunkler Füllung, / beginnt jede Zeile nach dem Titel, und ^ setzt die Sitzung des Zeitfensters darüber fort. mergeCells erzeugt jede Verbindung. Eine Zeile mit einer einzigen Sitzung, etwa dem Plenum oder einer Pause, belegt die drei Saalspalten. Ein Workshop belegt seine Zelle und die darunter, und ein Tagesabschnitt (MATÍ, TARDA), geschrieben als Zeile ohne |, belegt alle vier Spalten. Die Zellen, die eine Verbindung überdeckt, bleiben im Raster und sind mit hiddenBy markiert, denn die Spalte einer Zelle ist ihr Index in der Zeile (Tabellenmodelle bauen). headerRowCount: 1 macht die Saalnamen zur Kopfzeile. Die columnWidths sind relative Gewichte, und 12 + 3 × 42 ergibt die Satzbreite von 138 mm, sodass die Stundenspalte 12 mm und jeder Saal 42 mm breit ist. setAlignment(…, 'left', 'middle') zentriert jeden Workshop senkrecht in seinen zwei Zeilen.
#2 · Den Donnerstag umbrechen und den Freitag ganz halten
// 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' } };
Das Raster des Donnerstags wird auf Seite 2 zitiert und an den Kopf einer Seite gestellt. Ein Gleitobjekt steht nie über seinem Verweis, also eröffnet das Raster Seite 3. Es ist höher als die Seite, und overflow: 'split' teilt es zwischen zwei Zeilen, nie innerhalb einer verbundenen Zelle (Tabellen, die höher als die Seite sind). Seite 3 endet mit der Zeile 17.30 und dem Hinweis darunter. Seite 4 wiederholt die Kopfzeile und die ganze Legende samt der Erklärung der Farbfelder, gefolgt von (continuació), und die Anmerkung erscheint nur unter diesem letzten Teil. Das Raster des Freitags kommt an den Fuß einer Seite und passt unter den Text, der es auf Seite 5 zitiert. defaultResourceTypes('ca') benennt die Typen auf Katalanisch, Figura und Taula. Die eigenen katalanischen Texte der Engine für eine geteilte Tabelle sind (cont.) und Continua; das Tabellenformat schreibt stattdessen beide aus. numberingTemplate: '{n}' mit resetOn: 'never' zählt beide Raster in einer Reihe, Taula 1 und Taula 2.
#3 · Die Füllungen in Kacheln schneiden
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 in der kurzen Antwort gibt jeder Sitzung den Paletteneintrag ihres Themenstrangs, und 1,6 pt breite Rasterlinien in Papierfarbe schneiden die Füllungen in Kacheln mit weißen Zwischenräumen (Tabellenformat). Eine Tabelle hat für ihre Textzellen eine einzige Textfarbe, darum ist die weiße Schrift des Plenums ein Chip, dessen Format die Farbe setzt und Füllung, Rahmen und seitlichen Innenabstand des Chips abschaltet. Uhrzeiten und Tagesabschnitte sind ebenfalls Chips, in Chivo Mono (Chip-Formate). Ein Chip bricht nie über Zeilen um; Titel und Sprecherzeile eines Plenums passen jeweils auf eine Zeile der Zelle über drei Säle. In der Anmerkung zum Grundriss lässt der Chip sala den Text, wie er ist, und hält Nummer und Namen jedes Saals auf einer Zeile, wie es ein geschütztes Leerzeichen zwischen den Wörtern täte.
#4 · Jeden Tag auf einem Band eröffnen
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 }),
] } };
Die Gestaltung der Überschrift erster Ebene zeichnet das Band: einen Kasten von 76 mm Höhe ab der Anschnittkante, mit dem Streifen aus Modulen an seinem Fuß. Das Datum kommt aus dem Attribut date der Überschrift und die Ziffer aus day. Die 1 der Unbounded 800 zu 144 pt beginnt mit ihrer Farbe 0,76 mm innerhalb ihres Kastens und die erste Versalie des Datums 0,1 bis 0,2 mm innerhalb des ihren, also rückt die Ziffer 0,6 mm nach links, und Datum, Ziffer und Text beginnen mit ihrer Farbe innerhalb von 0,2 mm voneinander. Der Name des Tages steht rechts neben der Ziffer, abgesenkt um BASELINE × (144 − 26) pt, damit sich die beiden Grundlinien treffen, denn ein Gestaltungstext mit lineHeight: 1 hat seine Grundlinie bei 0,8 seiner Kastenhöhe. minHeight reserviert die Seite bis 7 mm unter das Band, und das Grundlinienraster rundet die Reserve auf 14 Zeilen auf, sodass der erste Zeilenkasten des Textes 10,2 mm unter dem Band beginnt. Mit AIR = 0 bestimmt der eigene Kasten der Ziffer, der über den Fuß des Bandes hinausreicht, die Höhe, und der Text beginnt 5,6 mm unter dem Band.
#5 · Die Folgezeilen der Vortragenden einziehen
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) },
];
Jeder Vortragende ist ein Absatz eines Blocks :::paragraphs{style="bio"}. Der fette Name beginnt am Spaltenrand, und die Zeilen darunter rücken 4 mm ein, sodass die Namen wie in einem Register die Spalte hinunter fluchten (Absatzformate).
Das ganze Rezept
// ═══ 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`---Markdown-Beispiel · 81 Zeilen · 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íMarkdown-Beispiel · 16 Zeilen · 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íMarkdown-Beispiel · 6 Zeilen · 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: in jedem Rezept gleich · 270 Zeilen
// ─── 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Die Ecken des Rasters abrunden
Eine geteilte Tabelle rundet die oberen Ecken ihres ersten Teils auf Seite 3 und die unteren Ecken ihres letzten Teils auf Seite 4 ab, wo nur die rechte zu sehen ist, weil die Stundenspalte keine Füllung hat.
- cellPadding: mm(1.5) };
+ cellPadding: mm(1.5), borderRadius: mm(2.5) };#Das Raster jedes Tages für sich nummerieren
Eine Zählung pro Überschrift der Ebene 1, wie bei den eingebauten Typen, ergibt Taula 1.1 und Taula 2.1 und macht aus dem Grundriss Figura 2.1; das Format des Umschlags ist nicht nummeriert, also ist der Donnerstag Überschrift 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 dayHäufige Fehler
Häufiger Fehler
Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen
Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen. Platzierung von Abbildungen →
Nachweise
- Rezept
- Ignacio Ferro
- Text
- 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
- Schriften
- Schibsted Grotesk (SIL OFL 1.1) · Unbounded (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


