Em poucas palavras
O programa impresso de um congresso de dois dias sobre impressão e letras. A grade de horários deixa uma palestra ocupar todas as salas ou dois horários, com cada tema na sua cor.
O que você vai compor
O programa impresso de um congresso de tipografia inventado em Girona, escrito em catalão. A capa desenha o ele geminado maiúsculo, L·L, com três módulos coloridos sobre preto. Cada dia abre com uma faixa preta de 76 mm e o dia do mês num algarismo de 144 pt. A grade de quinta-feira é uma tabela. A conferência de abertura ocupa uma célula preta que atravessa as três salas, e cada oficina ocupa dois horários. As partes do dia encabeçam suas linhas na largura da grade inteira, e cada palestra é um bloco na cor da sua trilha, identificada pelas amostras de cor da legenda. A grade é mais alta que uma página, então ocupa a página 3 e termina na 4 sob a linha de cabeçalho repetida. A grade de sexta-feira, mais curta, cabe inteira no pé da sua página de abertura.
Esta receita responde a
- Como faço uma tabela com linhas de cabeçalho, células mescladas, larguras de coluna e alinhamento por célula?
- Como divido uma tabela longa entre páginas com o cabeçalho repetido e um aviso de “continua”?
- Como acrescento amostras de cor, como chave de cores, ao texto, às legendas ou às notas de tabela?
- Como faço os rótulos “Figura” e “Tabela” saírem no idioma do meu documento?
- Como faço uma abertura de capítulo com uma faixa de cor sangrada, um número de capítulo grande e o título?
A resposta curta
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
- Funcionalidades
- Tabelas a partir de dadosTabelas que passam de páginaPreenchimento de célulasAmostras de corChips no textoPosicionamento de figurasEstilo de tabelaEstilo de legendaAberturas desenhadasFaixa de capítulo em largura totalAtributos de títuloEstilos de títuloCapas, folhas de rosto e colofõesFigura e Tabela no seu idiomaTipos de recurso personalizadosEstilos de parágrafoQuebras de página e de colunaCabeços e fóliosPaleta de cores semântica
- Também usa
- Bibliografias e glossáriosCitações que posicionam as figurasCabeços por tipo de páginaFiguras e tabelas como recursosCabeços por seçãoCapítulos sem número
- Tipografia
- Schibsted Grotesk, Unbounded, Chivo Mono (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Transforme cada linha do programa numa linha da tabela
O código é a resposta curta lá em cima. Cada dia é texto simples num arquivo de conteúdo, uma linha por horário: | separa as salas, uma letra indica a trilha, * marca uma plenária com fundo preto, / inicia cada linha depois do título e ^ prolonga a sessão do horário de cima. Todas as mesclagens saem de mergeCells. Uma linha com uma só sessão, como a plenária ou um intervalo, ocupa as três colunas de sala. Uma oficina ocupa a sua célula e a de baixo, e uma parte do dia (MATÍ, TARDA), escrita como uma linha sem |, ocupa as quatro colunas. As células cobertas por uma mesclagem continuam na grade, marcadas com hiddenBy, porque a coluna de uma célula é a sua posição na linha (Construir modelos de tabela). headerRowCount: 1 faz dos nomes das salas a linha de cabeçalho. Os columnWidths são pesos relativos, e 12 + 3 × 42 somam os 138 mm da medida, então a coluna da hora tem 12 mm e cada sala 42 mm. setAlignment(…, 'left', 'middle') centraliza cada oficina na vertical nas suas duas linhas.
#2 · Deixe a quinta-feira continuar e a sexta ficar inteira
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' } };
A grade de quinta-feira é citada na página 2 e posicionada no alto de uma página. Uma flutuante nunca sobe acima da sua referência, então a grade abre a página 3. Ela é mais alta que a página, e overflow: 'split' a corta entre duas linhas, nunca dentro de uma célula mesclada (Tabelas mais altas que a página). A página 3 termina com a linha das 17.30 e o aviso embaixo. A página 4 repete a linha de cabeçalho e a legenda inteira, com as amostras de cor, seguida de (continuació), e a nota só aparece sob esta última parte. A grade de sexta-feira vai para o pé de uma página e cabe sob o texto que a cita na página 5. O Postext 1.4.1 traz os nomes dos tipos de recurso e esses dois textos só em inglês e espanhol, então as palavras em catalão, plurais incluídos, são escritas sobre defaultResourceTypes('ca') e no estilo de tabela. numberingTemplate: '{n}' com resetOn: 'never' numera as duas grades numa só série, Taula 1 e Taula 2.
#3 · Corte os preenchimentos em blocos
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, na resposta curta, dá a cada sessão a cor da paleta da sua trilha, e fios de 1,6 pt na cor do papel cortam os preenchimentos em blocos separados por linhas brancas (Estilo de tabela). Uma tabela tem uma única cor de texto para as células do corpo, por isso o texto branco da plenária é um chip cujo estilo define a cor e desliga o preenchimento, a borda e o espaçamento lateral do chip. Os horários e as partes do dia também são chips, em Chivo Mono (Estilos de chip). Um chip nunca quebra entre linhas; o título de uma plenária e a linha do palestrante cabem cada um numa linha da célula de três salas. Na nota da planta, o chip sala deixa o texto como está e mantém numa só linha o número e o nome de cada sala, coisa que um espaço inseparável não faz na 1.4.1.
#4 · Abra cada dia com uma faixa
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 }),
] } };
O design do título de nível 1 desenha a faixa: uma caixa de 76 mm de altura a partir da borda da sangria, com a tira de módulos ao longo da borda inferior. A data vem do atributo date do título, e o algarismo, de day. O 1 da Unbounded 800 em 144 pt começa a tinta 0,76 mm para dentro da sua caixa, e a primeira maiúscula da data entre 0,1 e 0,2 mm para dentro da dela, por isso o algarismo se desloca 0,6 mm para a esquerda, e a data, o algarismo e o texto começam a tinta a menos de 0,2 mm um do outro. O nome do dia fica à direita do algarismo, baixado BASELINE × (144 − 26) pt para que as duas linhas de base coincidam, porque num texto de design com lineHeight: 1 a linha de base fica a 0,8 da altura da caixa. minHeight reserva a página até 7 mm abaixo da faixa, e a grade de linhas de base arredonda essa reserva para cima, até 14 linhas, então a caixa da primeira linha de texto começa 10,2 mm abaixo da faixa. Com AIR = 0 quem define a altura é a caixa do próprio algarismo, que passa da borda inferior da faixa, e o texto começa 5,6 mm abaixo da faixa.
#5 · Recue as linhas seguintes das notas dos palestrantes
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 palestrante é um parágrafo de um bloco :::paragraphs{style="bio"}. O nome em negrito começa na borda da coluna e as linhas de baixo entram 4 mm, em recuo deslocado, e assim os nomes se alinham coluna abaixo como num índice (Estilos de parágrafo). O texto é alinhado à esquerda, e a 1.4.1 não controla linhas curtas em texto em bandeira, por isso cada nota foi reescrita até a última linha ter pelo menos duas palavras.
A receita 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 = '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 }; // 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`---Amostra em Markdown · 83 linhas · 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. :::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íAmostra em Markdown · 16 linhas · 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íAmostra em Markdown · 6 linhas · 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 em todas as receitas · 271 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** 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, '-'); } /** 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 v1 ── 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. */ 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 ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Arredonde os cantos da grade
Uma tabela dividida arredonda os cantos superiores da primeira parte, na página 3, e os inferiores da última, na página 4, onde só o da direita aparece porque a coluna da hora não tem preenchimento.
- cellPadding: mm(1.5) };
+ cellPadding: mm(1.5), borderRadius: mm(2.5) };#Numere a grade de cada dia separadamente
Contar por título de nível 1, como fazem os tipos predefinidos, dá Taula 1.1 e Taula 2.1 e transforma a planta em Figura 2.1; o estilo da capa não é numerado, então a quinta-feira é o 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 dayErros comuns
Erro comum
Um flutuante 'top' nunca cai na página que o cita
Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita. Posicionamento de figuras →
Erro comum
Uma tabela 'here' nunca se divide
Só as tabelas flutuantes se dividem entre colunas e páginas; uma tabela posicionada como 'here' se move inteira. Deixe as tabelas longas flutuarem ou mantenha curtas as tabelas em linha. Tabelas que passam de página →
Erro comum
Traduza Figura e Tabela com defaultResourceTypes(locale)
O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes. Figura e Tabela no seu idioma →
Erro comum
Texto em bandeira nunca é verificado contra linhas curtas
optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta. Viúvas, órfãs e linhas curtas →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
O lineHeight de um texto de design é um múltiplo, nunca uma medida
Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →
Erro comum
O excesso de texto de design é 'ellipsis-end' por padrão
Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →
Erro comum
O texto dentro de um SVG <img> não pode usar fontes web
Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →
Erro comum
Um espaço não separável ainda quebra a linha
No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase. Escapes e caracteres literais →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
- As colunas da página 4 são niveladas à mão, com um
:::columnbreakantes da nota de Rosa Fontanet. O Postext 1.4.1 não nivela a última página de um capítulo quando o resto de uma tabela dividida a abre, então a primeira coluna vai até o pé e a segunda fica curta. Mude a quebra de lugar sempre que o texto de quinta-feira mudar.
Créditos
- Receita
- Ignacio Ferro
- Texto
- 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
- Fontes
- Schibsted Grotesk (SIL OFL 1.1) · Unbounded (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


