Lo que vas a componer
La carta de otoño de Les Tanneurs, un bistró parisino imaginario: una sola hoja de 230 × 310 mm impresa por las dos caras. En el anverso, un toldo a rayas cuelga sobre el nombre, compuesto en Limelight. Cada título de apartado va entre dos filetes de latón que llegan a los márgenes, y debajo vienen cuatro platos. Cada plato lleva el nombre francés en negrita con su guarnición, la traducción en cursiva debajo y el precio en negrita, pegado al margen derecho. Un disco verde oscuro con una V marca los platos vegetarianos, y una pastilla perfilada con SG, los que no llevan gluten. En el dorso, la carta de vinos va bajo un estante de botellas, con el precio de la copa y el de la botella en columnas encabezadas por 12 cl y 75 cl. Postext no tiene tabuladores, así que cada columna de precios pertenece a una tabla sin filetes ni pie.
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 hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?
- ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?
- ¿Cómo añado imágenes y tablas desde el código (recursos) en lugar de ![]() de Markdown?
La respuesta corta
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
.map(([, dish, translation, price]) => [
{ content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
// Both cells start at the top of the row, so the price shares the dish's first baseline.
{ content: `**${price.content}**`, align: 'right' },
]);
// columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
Ingredientes
- Funciones
- Tablas a partir de datosEstilo de tablasAnclaje de elementos de diseñoChips en líneaEstilos de párrafoAperturas diseñadasTextos, filetes y cajas en los diseños de páginaTipos de recurso propiosFiguras justo aquíFiguras y tablas como recursosColor del papelNiveles de títuloPaleta de color semánticaSaltos de página y de columnaPáginas en un canvas
- Tipografía
- Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1)
- Recursos
- El toldo y el estante de botellas, dibujados en código con la paleta de la página (Ignacio Ferro, CC BY 4.0)
Elaboración
#1 · Una tabla donde irían los tabuladores
El código está en la respuesta corta, más arriba. Cada apartado es una tabla de dos columnas. El plato, un salto de línea y la traducción en cursiva comparten la primera celda, y el precio va alineado a la derecha en la segunda, sobre la primera línea base del plato, porque las dos celdas empiezan en lo alto de su fila. columnWidths recibe pesos, así que [6, 1] reserva para los precios 22,6 mm de los 158 mm de la medida. rules: 'none' y headerBackgroundEnabled: false quitan la cuadrícula y la fila de cabecera gris que pone el estilo de tablas por defecto. cellPadding: pt(LEAD / 4) hace que cada plato ocupe dos líneas y media del interlineado de 14,5 pt, de modo que un apartado de cuatro platos acaba en su décima línea de la rejilla base. Con el relleno por defecto, 0,375 em, cada apartado acaba 0,9 mm más abajo, cada título que viene detrás baja una línea y la carta pasa a cuatro páginas.
#2 · Un solo tipo de recurso, en su sitio y sin número
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
...body });
Los tipos de figura y tabla que trae el motor imprimen una línea de pie y hacen flotar cada recurso al primer hueco libre después de su primera referencia. El tipo menu no tiene prefijo de pie y sus piezas no llevan pie, así que bajo un apartado no se imprime nada. Con captionPrefix: 'Menu', cada pieza lleva debajo una línea numerada, empezando por «Menu 1.» bajo el toldo, y la carta pasa a cuatro páginas. defaultPlacement coloca cada tabla y cada dibujo en su línea ::resource (inserción en bloque). Sin él, los tres títulos de apartado quedan uno tras otro en lo alto del anverso y las tablas flotan debajo, primero los postres. El toldo y las botellas son recursos SVG del mismo tipo, dibujados en código y registrados con la función loadSvg del kit.
#3 · Combina las filas de región
function wineList(tsv) {
let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
// One cell across the table, centred on the card like the course heads; a label centred
// in the first column alone would sit 22.6 mm left of them. The label face is the header's.
m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
// mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
});
return piece('wines', 'table', { table: { model: m } });
}
En la 1.4.1, parseTSV no marca ninguna fila como cabecera, así que wineList fija headerRowCount: 1 a mano. Appellation · cépage, 12 cl y 75 cl salen entonces sobre sus columnas en Josefin Sans, la letra de las celdas de cabecera. Una línea con un solo campo nombra una región. Su celda pasa a ser de cabecera, centrada, y mergeCells la extiende por las tres columnas y marca con hiddenBy las dos que cubre (construir modelos de tabla). Centrado solo en la primera columna, BLANCS quedaría 22,6 mm a la izquierda del eje de la carta, sobre el que van La Cave y Le Comptoir. Una línea en blanco del TSV se convierte en una fila de celdas vacías, de línea y media de alto, que deja aire sobre Blancs y Rouges.
#4 · Cuelga un filete a cada lado del título de apartado
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
placement: { anchor: { to: '#title', edge },
size: { width: 'fill' }, // to the column's edge
offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
textTransform: 'uppercase', color: col('wine'),
placement: { anchor: { to: 'container', edge: 'top' } } },
rule('left-of', -4), rule('right-of', 4),
] } };
Un título de apartado es un diseño dentro de la columna. El elemento {titleText} no tiene ancho, así que su caja mide lo que sus mayúsculas, y el ancla 'top' lo centra en la columna. Cada filete cuelga de un borde del título con 'left-of' o 'right-of' y se estira hasta el borde de la columna (posicionamiento de elementos), de modo que los filetes junto a PLATS miden 66,7 mm y los de LE COMPTOIR 56,2 mm, siempre a 4 mm de las letras. El lineHeight del título es un múltiplo, 0,96, que deja su caja en 14,4 pt, dentro de una línea de 14,5 pt de la rejilla. Con 1, la caja mide 15 pt, cada título ocupa dos líneas y la carta pasa a cuatro páginas.
#5 · Distintivos en chips, letra pequeña en estilos de párrafo
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
// V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
// outline, 0.5 pt of main-color, which would ring the green in wine red.
{ id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
background: col('bottle'), color: col('paper') },
{ id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
{ id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
{ id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
marginTop: pt(LEAD) },
];
Un chip dibuja una caja alrededor de un trozo de texto dentro de la línea (estilos de chip), así que :chip[V]{style="veg"}, escrito tras la guarnición, pone el disco en la línea francesa del plato, igual en una celda de tabla que en un párrafo. Los estilos de chip llevan bold: true porque un chip toma el peso del texto que lo rodea, y tras la guarnición ese texto es de peso 400, que FONTS no carga para Josefin Sans. Las notas bajo los postres y el colofón del dorso son bloques :::paragraphs de 9 y 7,5 pt, y los dos estilos llevan un marginTop de una línea (estilos de párrafo), porque una tabla colocada con 'here' no deja espacio debajo. Sin ese margen, las notas empiezan en la línea que sigue a la traducción de los quesos, como si fueran parte del plato.
#6 · Centra la carta sobre un eje
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
levels: [ // Limelight ships one weight, 400, and no italic
// Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
// a break before an H1 would part the name from the awning and La Cave from its bottles.
{ level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
{ level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
] };
headings.textAlign y bodyText.textAlign llevan al eje de la carta todo lo que no es tabla. lineHeight: pt(3 * LEAD) da al nombre de 48 pt tres líneas de la rejilla. Sin él, el nombre toma el interlineado por defecto y ocupa cuatro líneas; la segunda línea de notas pasa entonces a una página propia y la carta llega a cuatro páginas. En la 1.4.1, cualquier objeto headings anula el salto del H1, y aun así el nivel 1 declara breakBefore: { enabled: false }, porque el nombre y La Cave van en la página del dibujo que tienen encima. Si recuperas el valor por defecto, { enabled: true, parity: 'always-odd' }, cada dibujo se queda solo en su página y la carta llega a siete. La configuración no fija ningún locale, porque la 1.4.1 solo aplica la separación silábica al texto justificado y en esta carta no hay nada justificado.
La receta completa
// ═══ Postext Cookbook · Nº 052 · Bistro menu: prices aligned without tab stops ═══════ // https://postext.dev/en/cookbook/bistro-menu // Code: MIT · Text: original, in French (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // The autumn menu of an imaginary Paris bistro: two sides of one card, every price column a // table with its rules switched off. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'bistro-menu'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1f2a24', paper: '#f6efdf', // green-black text on cream card wine: '#6d1f2c', brass: '#a9823a', // the name, heads and labels; rules and the drawings' metal straw: '#e8d4a8', bottle: '#2f4235', sage: '#8a9a78', // awning stripes and labels; glass muted: '#6b6457' }; // the colophon const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color' (#295aa3, a blue); this palette makes it the wine red. const colorPalette = Object.entries({ ...palette, 'main-color': palette.wine }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Noticia Text', 'Limelight', 'Josefin Sans']; const [BODY, LEAD] = [10.5, 14.5]; // pt: the text, and the leading every table row keeps // #region answer: dish on the left, price flush right: a two-column table with no rules // Postext has no tab stops, so each course is a table. The document's tableStyle turns off // every rule and fill, so nothing on the page shows that a table is there. The wine list's head // row and merged region rows are in #region wines. const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'), headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'), headerBackgroundEnabled: false }; // header cells: the wine list's labels // The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price. function course(id, tsv) { const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id) .map(([, dish, translation, price]) => [ { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines // Both cells start at the top of the row, so the price shares the dish's first baseline. { content: `**${price.content}**`, align: 'right' }, ]); // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm. return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } }); } // #endregion // #region type: one resource type for the whole card: set where it stands, never numbered // No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement // 'here' sets each piece at its ::resource line; such a table never splits, so a course that // outgrows the page moves to the next one whole (gotcha: here-table-no-split). const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here' } }]; const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0, ...body }); // #endregion // #region wines: region rows merged across the three columns, prices under their labels function wineList(tsv) { let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] }; m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right', content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes' m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region if (r === 0 || !first.content || rest.some((cell) => cell.content)) return; // One cell across the table, centred on the card like the course heads; a label centred // in the first column alone would sit 22.6 mm left of them. The label face is the header's. m.rows[r][0] = { ...first, isHeader: true, align: 'center' }; // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby). m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } }); }); return piece('wines', 'table', { table: { model: m } }); } // #endregion // #region heads: a course head: its title centred, a brass rule anchored to each side of it // The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each // rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it // to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR. const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'), thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing placement: { anchor: { to: '#title', edge }, size: { width: 'fill' }, // to the column's edge offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals const courseHead = { enabled: true, slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15), lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line textTransform: 'uppercase', color: col('wine'), placement: { anchor: { to: 'container', edge: 'top' } } }, rule('left-of', -4), rule('right-of', 4), ] } }; // #endregion // #region badges: dietary badges as chips, explained in small type under the desserts // A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip // takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded. const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700 borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height const chipStyles = [ // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default // outline, 0.5 pt of main-color, which would ring the green in wine red. { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0), background: col('bottle'), color: col('paper') }, { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false, borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') }, ]; // The notes under the desserts and the colophon on the back: smaller, on the card's axis. // marginTop gives the line of air that a table set 'here' does not leave below itself. const paragraphStyles = [ { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) }, { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'), marginTop: pt(LEAD) }, ]; // #endregion // #region centred: one axis for the card: the name, the course heads and the notes centred // bodyText (in config) centres the lines under the name and the notes; only the tables keep a // left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config // sets no locale: French patterns would change nothing on this card. const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center', levels: [ // Limelight ships one weight, 400, and no italic // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since // a break before an H1 would part the name from the awning and La Cave from its bottles. { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own ] }; // #endregion // #region art: a striped awning over the name, a shelf of bottles over the wine list function awning() { // 158 × 20 mm; the canopy narrows 6 % toward the wall const [W, N, ROD, DROP, HEM, INSET] = [1660, 19, 12, 104, 46, 50]; const s = W / N, top = (i) => INSET + i * (W - 2 * INSET) / N, bottom = (i) => i * s; const shade = { [palette.wine]: mix(palette.wine, palette.ink, 0.25), // the valance: each [palette.straw]: mix(palette.straw, palette.brass, 0.4) }; // stripe a shade darker let shapes = ''; for (let i = 0; i < N; i++) { const fill = i % 2 ? palette.straw : palette.wine, y = ROD + DROP; shapes += `<path d="M${top(i)} ${ROD}H${top(i + 1)}L${bottom(i + 1)} ${y}H${bottom(i)}Z" ` + `fill="${fill}"/><path d="M${bottom(i)} ${y}h${s}v${HEM}a${s / 2} ${s / 2} 0 0 1 ` + `${-s} 0Z" fill="${shade[fill]}"/>`; } shapes += `<rect x="${INSET - 16}" y="0" width="${W - 2 * INSET + 32}" height="${ROD}" rx="6" ` + `fill="${palette.brass}"/><rect x="0" y="${ROD + DROP - 3}" width="${W}" height="6" ` + `fill="${palette.brass}"/>`; // the rod on the wall, a brass bead along the front edge return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 212" width="${W}" ` + `height="212">${shapes}</svg>`; } function mix(a, b, t) { // a blend of two palette colours, t of the way from a to b const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [x, y] = [rgb(a), rgb(b)]; return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0')) .join('')}`; } function bottles() { // 158 × 29 mm: glasses, bottles and a carafe on a brass shelf const [W, H, BASE] = [1660, 300, 286]; const glass = (x) => // a tulip glass, a third full `<path d="M${x - 34} ${BASE - 170}C${x - 36} ${BASE - 110} ${x - 20} ${BASE - 88} ${x} ` + `${BASE - 86}C${x + 20} ${BASE - 88} ${x + 36} ${BASE - 110} ${x + 34} ${BASE - 170}Z" ` + `fill="none" stroke="${palette.bottle}" stroke-width="4"/>` + `<path d="M${x - 33} ${BASE - 132}C${x - 30} ${BASE - 104} ${x - 16} ${BASE - 91} ${x} ` + `${BASE - 90}C${x + 16} ${BASE - 91} ${x + 30} ${BASE - 104} ${x + 33} ${BASE - 132}Z" ` + `fill="${palette.wine}"/>` + `<rect x="${x - 2.5}" y="${BASE - 88}" width="5" height="82" fill="${palette.bottle}"/>` + `<ellipse cx="${x}" cy="${BASE - 5}" rx="30" ry="5" fill="${palette.bottle}"/>`; const bottle = (x, h, w, shoulder, body, foil) => { // straight or sloping shoulders const neck = 13, top = BASE - h, sh = BASE - h * 0.62; return `<path d="M${x - w} ${BASE}V${sh}C${x - w} ${sh - shoulder} ` + `${x - neck} ${sh - shoulder} ${x - neck} ${sh - shoulder * 1.6}` + `V${top + 6}Q${x - neck} ${top} ${x - neck + 6} ${top}` + `H${x + neck - 6}Q${x + neck} ${top} ${x + neck} ${top + 6}V${sh - shoulder * 1.6}` + `C${x + neck} ${sh - shoulder} ${x + w} ${sh - shoulder} ${x + w} ${sh}V${BASE}Z" ` + `fill="${body}"/>` + `<rect x="${x - neck - 1}" y="${top}" width="${2 * neck + 2}" height="${h * 0.16}" ` + `rx="4" fill="${foil}"/>` + `<rect x="${x - w + 8}" y="${BASE - h * 0.44}" width="${2 * w - 16}" height="${h * 0.26}" ` + `fill="${palette.straw}"/>` + `<rect x="${x - w + 8}" y="${BASE - h * 0.3}" width="${2 * w - 16}" height="6" ` + `fill="${foil}"/>`; }; const carafe = (x) => `<path d="M${x - 16} ${BASE - 200}H${x + 16}V${BASE - 150}` + `C${x + 70} ${BASE - 120} ${x + 76} ${BASE - 20} ${x + 44} ${BASE}H${x - 44}` + `C${x - 76} ${BASE - 20} ${x - 70} ${BASE - 120} ${x - 16} ${BASE - 150}Z" fill="none" ` + `stroke="${palette.bottle}" stroke-width="4"/>` + `<path d="M${x - 64} ${BASE - 74}C${x - 70} ${BASE - 30} ${x - 58} ${BASE - 8} ${x - 42} ` + `${BASE - 4}H${x + 42}C${x + 58} ${BASE - 8} ${x + 70} ${BASE - 30} ${x + 64} ${BASE - 74}Z" ` + `fill="${palette.wine}"/>`; const C = W / 2; const art = glass(C - 330) + bottle(C - 225, 250, 38, 12, palette.bottle, palette.wine) + bottle(C - 125, 262, 42, 34, palette.sage, palette.brass) + carafe(C) + bottle(C + 125, 256, 44, 36, palette.bottle, palette.brass) + bottle(C + 225, 250, 38, 12, palette.bottle, palette.wine) + glass(C + 330) + `<rect x="${C - 420}" y="${BASE}" width="840" height="6" fill="${palette.brass}"/>`; return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" ` + `height="${H}">${art}</svg>`; } // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) colorPalette, tableStyle, resourceTypes, page: { sizePreset: 'custom', width: mm(230), height: mm(310), dpi: 150, backgroundColor: col('paper'), // one card, printed both sides: margins are not mirrored margins: { top: mm(22), bottom: mm(20), left: mm(36), right: mm(36) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to main-color, the wine red textAlign: 'center', firstLineIndent: mm(0) }, // the lines under the name and the notes headings, // the card's axis (#region centred) chipStyles, paragraphStyles, // the badges and the small print (#region badges) header: { elements: [] }, footer: { elements: [] }, // a menu has no running heads or folios }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 46 líneas · content.es.md
title: "Les Tanneurs" --- ::resource{id="awning"} # Les Tanneurs *Bistrot parisien depuis 1931* Carte d’automne 2026 · *carta de otoño* ## Entrées ::resource{id="entrees"} ## Plats ::resource{id="plats"} ## Desserts ::resource{id="desserts"} :::paragraphs{style="notes"} :chip[V]{style="veg"} Plat végétarien · *vegetariano* · :chip[SG]{style="gf"} Sans gluten · *sin gluten* Bœuf d’origine France · *vacuno francés* · Prix nets, service compris · *servicio incluido* ::: :::pagebreak ::resource{id="bottles"} # La Cave Vins au verre et à la bouteille · *vinos por copa y por botella* ::resource{id="wines"} ## Le Comptoir ::resource{id="counter"} :::paragraphs{style="colophon"} Compuesto en Limelight, Noticia Text y Josefin Sans (SIL OFL) · Les Tanneurs es un bistró imaginario · Texto y dibujos CC BY 4.0 :::`; // the two sides of the card, in French const dishes = String.raw`entrees **Œuf mayonnaise**, cornichons de la maison :chip[V]{style="veg"} :chip[SG]{style="gf"} Huevo con mayonesa, pepinillos de la casa 7Muestra en Markdown · 15 líneas · content.carte.es.md
entrees **Velouté de potimarron**, crème crue, noisettes torréfiées :chip[V]{style="veg"} :chip[SG]{style="gf"} Crema de calabaza, nata fresca, avellanas tostadas 9 entrees **Terrine de campagne** au poivre vert, pain grillé Terrina de campaña a la pimienta verde, pan tostado 11 entrees **Poireaux vinaigrette**, œuf mimosa :chip[V]{style="veg"} :chip[SG]{style="gf"} Puerros a la vinagreta, huevo mimosa 10 plats **Blanquette de veau** à l’ancienne, riz pilaf Blanqueta de ternera a la antigua, arroz pilaf 24 plats **Paleron de bœuf** braisé au vin rouge, carottes fondantes Aguja de vacuno guisada al vino tinto, zanahorias melosas 26 plats **Filet de lieu jaune**, beurre blanc, poireaux fondus :chip[SG]{style="gf"} Filete de abadejo, beurre blanc, puerros pochados 27 plats **Risotto aux cèpes**, mascarpone et sauge :chip[V]{style="veg"} :chip[SG]{style="gf"} Risotto de boletus, mascarpone y salvia 22 desserts **Tarte fine aux pommes**, crème fraîche Tarta fina de manzana, crème fraîche 10 desserts **Mousse au chocolat noir** Mousse de chocolate negro 9 desserts **Île flottante**, pralines roses Isla flotante, garrapiñadas rosas 9 desserts **Trois fromages affinés**, confiture de cerises noires Tres quesos curados, confitura de cereza negra 12 counter **Kir** au vin blanc et cassis Vino blanco con licor de grosella negra 6 counter **Pastis**, carafe d’eau fraîche Pastís con una jarra de agua fría 5 counter **Bière à la pression**, 25 cl Cerveza de barril, 25 cl 5 counter **Café**, noisette ou allongé Café solo, cortado o largo 3`; // TSV: course · dish · translation · price const wines = String.raw`Appellation · cépage 12 cl 75 clMuestra en Markdown · 16 líneas · content.cave.en.md
BULLES **Crémant de Loire** brut · *chenin blanc* 9 44 BLANCS **Muscadet Sèvre-et-Maine** sur lie 2023 · *melon de Bourgogne* 7 32 **Mâcon-Villages** 2022 · *chardonnay* 9 40 **Sancerre** 2023 · *sauvignon blanc* 11 52 **Chablis** 2022 · *chardonnay* 12 58 ROUGES **Côtes-du-Rhône** 2022 · *grenache, syrah* 7 32 **Saumur-Champigny** 2022 · *cabernet franc* 9 42 **Morgon** 2022 · *gamay* 10 46 **Bordeaux supérieur** 2020 · *merlot, cabernet sauvignon* 8 38 **Saint-Joseph** 2021 · *syrah* 12 56 **Saint-Émilion grand cru** 2018 · *merlot, cabernet franc* — 78`; // TSV: head row, regions, wines; one file for both editions const resources = [ piece('awning', 'svg', { svg: { fileId: 'awning.svg', width: 1660, height: 212 }, altText: 'A striped wine-red and straw awning on a brass rod.' }), piece('bottles', 'svg', { svg: { fileId: 'bottles.svg', width: 1660, height: 300 }, altText: 'Two glasses of red wine, four bottles and a carafe on a brass shelf.' }), course('entrees', dishes), course('plats', dishes), course('desserts', dishes), wineList(wines), course('counter', dishes), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Noticia Text': ['400', '400i', '700'], Limelight: ['400'], 'Josefin Sans': ['700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = markdown + dishes + wines; await loadFonts(FONTS, allText); await loadSvg('awning.svg', awning()); await loadSvg('bottles.svg', bottles()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: t({ en: 'Les Tanneurs: autumn menu', es: 'Les Tanneurs: carta de otoño' }) });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
#Fija la longitud de los filetes
Un ancho fijo en lugar de 'fill' deja cada filete en 20 mm, a 4 mm del título, así que junto a PLATS la pareja queda más cerca del centro que junto a LE COMPTOIR.
- size: { width: 'fill' }, // to the column's edge
+ size: { width: mm(20) }, // 20 mm, wherever the title ends#Raya las filas como un libro de cuentas
rules: 'horizontal' traza un filete de latón de 0,5 pt encima y debajo de cada fila. Los filetes no ocupan sitio, así que las dos caras conservan su composición, y las filas en blanco sobre Blancs y Rouges llevan también su par de filetes.
-const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
+const tableStyle = { rules: 'horizontal', borderColor: col('brass'),
+ borderWidth: pt(0.5), cellPadding: pt(LEAD / 4),Errores frecuentes
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
Una figura en línea lleva aire encima, pero no debajo
En postext 1.4.1, una figura que ::resource coloca con la posición 'here' lleva una línea de la rejilla base de aire encima, pero debajo solo lo que sobra cuando la línea siguiente se ajusta a la rejilla: desde una línea entera hasta casi nada, así que el párrafo siguiente puede empezar pegado al pie. Pon :::space{lines=1} tras la línea ::resource; como todo :::space, se descarta en la cabeza de una columna. Figuras justo aquí →
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
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
En el texto en bandera no hay separación silábica
La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida. Separación silábica e idioma del documento →
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 →
Da a cada elemento rule su thickness. En la 1.4.1 un filete sin grosor no se dibuja, aunque la referencia de configuración da 0,5 pt por defecto. Quita thickness de rule() y desaparecen los dos filetes de cada título de apartado.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imágenes
- El toldo y el estante de botellas, dibujados en código con la paleta de la página · Ignacio Ferro · CC BY 4.0
- Fuentes
- Limelight (SIL OFL 1.1) · Noticia Text (SIL OFL 1.1) · Josefin Sans (SIL OFL 1.1)
- Código
- MIT, como Postext


