Lo que vas a componer
El programa impreso, en catalán, de unas jornadas de tipografía inventadas que se celebran en Girona. La cubierta dibuja la ele geminada mayúscula, L·L, con tres módulos de color sobre negro. Cada jornada se abre con una banda negra de 76 mm y el día del mes en una cifra de 144 pt. La parrilla del jueves es una tabla. La conferencia inaugural ocupa una celda negra que abarca las tres salas, y cada taller, dos franjas horarias. Las partes del día encabezan sus filas a todo lo ancho, y cada ponencia es una pieza del color de su itinerario; las muestras de color del pie hacen de leyenda. La parrilla no cabe en una página, así que llena la 3 y termina en la 4, bajo su fila de cabecera repetida. La del viernes, más corta, cabe entera al pie de su página de apertura.
Esta receta responde a
- ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?
- ¿Cómo parto una tabla larga entre páginas con la cabecera repetida y un aviso de «continúa»?
- ¿Cómo añado muestras de color como leyenda en el texto, los pies o las notas de tabla?
- ¿Cómo consigo las etiquetas «Figura» y «Tabla» en el idioma de mi documento?
- ¿Cómo hago una apertura de capítulo con una banda de color a sangre, un número de capítulo grande y el título?
La respuesta corta
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)
}
Ingredientes
- Funciones
- Tablas a partir de datosTablas que pasan de páginaRellenos de celdaMuestras de colorChips en líneaColocación de figurasEstilo de tablasEstilo de los piesAperturas diseñadasBanda de capítulo a todo el anchoAtributos de títuloEstilos de títuloCubiertas, portadas y colofonesFigura y Tabla en tu idiomaTipos de recurso propiosEstilos de párrafoSaltos de página y de columnaCabeceras y foliosPaleta de color semántica
- También usa
- Bibliografías y glosariosCitas que colocan las figurasCabeceras según el tipo de páginaFiguras y tablas como recursosCabeceras por secciónCapítulos sin número
- Tipografía
- Schibsted Grotesk, Unbounded, Chivo Mono (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Convierte cada línea del programa en una fila
El código es la respuesta corta de arriba. Cada jornada es texto plano en un archivo de contenido, con una línea por franja horaria: | separa las salas, una letra indica el itinerario, * marca una plenaria sobre relleno negro, / abre cada línea que sigue al título y ^ prolonga la sesión de la franja anterior. Todas las combinaciones salen de mergeCells. Una línea con una sola sesión, como la plenaria o una pausa, ocupa las tres columnas de sala. Un taller ocupa su celda y la de debajo, y una parte del día (MATÍ, TARDA), que se escribe como una línea sin |, las cuatro columnas. Las celdas cubiertas siguen en la cuadrícula, marcadas con hiddenBy, porque la columna de cada celda es su posición en la fila (Construir modelos de tabla). headerRowCount: 1 convierte los nombres de las salas en la fila de cabecera. Los columnWidths son pesos relativos, y 12 + 3 × 42 suman los 138 mm del ancho de caja, así que la columna de la hora mide 12 mm y cada sala 42. setAlignment(…, 'left', 'middle') centra cada taller en vertical en sus dos filas.
#2 · Deja que el jueves continúe y el viernes quede entero
const CATALAN = { // 1.4.1 names types in English and Spanish only (gotcha: resource-types-locale)
figure: { name: 'Figura', namePlural: 'Figures', shortLabel: 'fig.', captionPrefix: 'Figura' },
table: { name: 'Taula', namePlural: 'Taules', shortLabel: 'taula', captionPrefix: 'Taula',
captionStyle: { position: 'above' } },
};
const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type, ...CATALAN[type.id],
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'
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 parrilla del jueves se cita en la página 2 y va en la cabeza de una página. Un flotante nunca sube por encima de su referencia, así que la parrilla abre la página 3. No cabe en ella, y overflow: 'split' la corta entre dos filas, nunca dentro de una celda combinada (Tablas más altas que la página). La página 3 acaba con la fila de las 17.30 y el aviso debajo. La 4 repite la fila de cabecera y el pie completo, con la leyenda de colores, seguido de (continuació), y la nota solo aparece bajo esta última parte. La del viernes va al pie de una página y cabe bajo el texto que la cita en la página 5. Postext 1.4.1 solo trae en inglés y en español los nombres de los tipos de recurso y estos dos textos, así que las palabras catalanas, plurales incluidos, se escriben sobre defaultResourceTypes('ca') y en el estilo de tabla. numberingTemplate: '{n}' con resetOn: 'never' numera las dos parrillas en una sola serie: Taula 1 y Taula 2.
#3 · Corta los rellenos en piezas
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 (gotcha: nbsp-breaks)
setCellBackground, en la respuesta corta, da a cada sesión el color de la paleta de su itinerario, y unos filetes de 1,6 pt del color del papel cortan los rellenos en piezas separadas por líneas blancas (Estilo de tablas). Una tabla tiene un solo color de texto para las celdas del cuerpo, así que el blanco de la plenaria es un chip cuyo estilo fija el color y quita el relleno, el borde y el margen lateral del chip. Las horas y las partes del día también son chips, en Chivo Mono (Estilos de chip). Un chip nunca se parte entre dos líneas; el título de una plenaria y la línea de su ponente caben cada uno en una línea de la celda de tres salas. En la nota del plano, el chip sala deja el texto tal cual y mantiene en una línea el número y el nombre de cada sala, cosa que en 1.4.1 no consigue un espacio de no separación.
#4 · Abre cada jornada con una banda
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 }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
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 multiple (gotcha: design-lineheight-multiple)
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 }),
] } };
El diseño del título de primer nivel dibuja la banda: una caja de 76 mm de alto desde el borde de sangre, con la franja de módulos a lo largo de su pie. La fecha sale del atributo date del título, y la cifra, de day. El trazo del 1 de Unbounded 800 a 144 pt empieza 0,76 mm dentro de su caja, y el de la primera mayúscula de la fecha, entre 0,1 y 0,2 mm dentro de la suya; por eso la cifra se desplaza 0,6 mm a la izquierda, y la fecha, la cifra y el texto empiezan su trazo a menos de 0,2 mm unos de otros. El nombre del día va a la derecha de la cifra, bajado BASELINE × (144 − 26) pt para que las dos líneas de base coincidan, porque en un texto de diseño con lineHeight: 1 la línea de base queda a 0,8 de la altura de su caja. minHeight reserva la página hasta 7 mm por debajo de la banda, y la rejilla base redondea esa reserva hacia arriba hasta 14 líneas, así que la caja de la primera línea de texto empieza 10,2 mm bajo la banda. Con AIR = 0 manda la caja de la propia cifra, que baja más allá del pie de la banda, y el texto empieza 5,6 mm bajo la banda.
#5 · Sangra las notas de los ponentes
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) },
];
Cada ponente es un párrafo de un bloque :::paragraphs{style="bio"}. El nombre en negrita empieza en el borde de la columna y las líneas siguientes entran 4 mm, en sangría francesa, así que los nombres quedan alineados columna abajo, como en un índice (Estilos de párrafo). El texto va en bandera, un ajuste en el que 1.4.1 no evita las últimas líneas de una sola palabra, así que cada nota se reescribió hasta que su última línea tuvo al menos dos.
La receta 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.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, mergeCells, setCellContent, setCellBackground, setAlignment, } from 'https://esm.sh/postext'; const LANG = 'es'; // @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 }; // 1.4.1 paints design slots from the hex, not the id: col() writes both // (gotcha: palette-skips-designs) 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 const CATALAN = { // 1.4.1 names types in English and Spanish only (gotcha: resource-types-locale) figure: { name: 'Figura', namePlural: 'Figures', shortLabel: 'fig.', captionPrefix: 'Figura' }, table: { name: 'Taula', namePlural: 'Taules', shortLabel: 'taula', captionPrefix: 'Taula', captionStyle: { position: 'above' } }, }; const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type, ...CATALAN[type.id], 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' 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 (gotcha: nbsp-breaks) // #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 }); // not '…' at the edge (gotcha: overflow-ellipsis-default) 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 multiple (gotcha: design-lineheight-multiple) 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 = () => ({ // a factory: configs are cached by identity // (gotcha: config-cache-identity) 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: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { 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`---Muestra en Markdown · 83 líneas · content.es.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. :::columnbreak **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íMuestra en Markdown · 16 líneas · 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íMuestra en Markdown · 6 líneas · 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, because an SVG picture cannot use the page's web fonts (gotcha: // svg-no-webfonts); 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 (gotcha: fonts-first) '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 loadFonts(FONTS, allText); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: 'Jornades de Tipografia i Edició Digital · Programa' });Kit · core, fonts, viewer, images: igual en todas las recetas · 270 líneas
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗
Variantes
#Redondea las esquinas de la parrilla
Una tabla partida redondea las esquinas superiores de su primera parte, en la página 3, y las inferiores de la última, en la página 4, donde solo se ve la derecha porque la columna de la hora no tiene relleno.
- cellPadding: mm(1.5) };
+ cellPadding: mm(1.5), borderRadius: mm(2.5) };#Numera cada parrilla por jornada
Si se cuenta por títulos de primer nivel, como en los tipos predefinidos, salen la Taula 1.1 y la Taula 2.1, y el plano pasa a ser la Figura 2.1; el estilo de la cubierta no se numera, así que el jueves es el título 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 dayErrores frecuentes
Error frecuente
Un flotante 'top' nunca cae en la página que lo cita
Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita. Colocación de figuras →
Error frecuente
Una tabla 'here' nunca se parte
Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea. Tablas que pasan de página →
Error frecuente
Traduce Figura y Tabla con defaultResourceTypes(locale)
El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →
Error frecuente
En el texto en bandera no se evitan las líneas cortas
optimalLineBreaking, avoidRunts, runtPenalty y runtMinCharacters actúan sobre el algoritmo de Knuth–Plass, que postext 1.4.1 solo aplica al texto justificado. Un párrafo en bandera se corta línea a línea y puede terminar en una sola palabra corta, digan lo que digan esos ajustes. Revisa las últimas líneas del texto en bandera y reescribe el párrafo que acabe en una línea corta. Viudas, huérfanas y líneas cortas →
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones
postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →
Error frecuente
El lineHeight de un texto de diseño es un múltiplo, nunca una medida
En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →
Error frecuente
El desbordamiento del texto de diseño es 'ellipsis-end' por defecto
Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →
Error frecuente
El texto dentro de un SVG <img> no puede usar fuentes web
Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →
Error frecuente
Un espacio de no separación sigue partiendo la línea
En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase. Escapes y caracteres literales →
Error frecuente
Una configuración se cachea por identidad: crea un objeto nuevo
El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
- Las columnas de la página 4 se igualan a mano, con un
:::columnbreakantes de la nota de Rosa Fontanet. Postext 1.4.1 no iguala la última página de un capítulo cuando la abre el resto de una tabla partida, así que la primera columna llega al pie y la segunda se queda corta. Mueve el salto cada vez que cambie el texto del jueves.
Créditos
- Receta
- Ignacio Ferro
- Texto
- El texto catalán del programa, con las dos parrillas, escrito para esta receta, y los dibujos de la cubierta, la banda y el plano, generados en código · Ignacio Ferro · CC BY 4.0
- Fuentes
- Schibsted Grotesk (SIL OFL 1.1) · Unbounded (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)
- Código
- MIT, como Postext


