En poques paraules
Una carta de dues cares per a un restaurant inventat de París. Ensenya a alinear els plats i els preus en files ordenades amb taules sense línies visibles.
Què compondràs
La carta de tardor de Les Tanneurs, un bistrot parisenc imaginari: un sol full de 230 × 310 mm imprès per les dues cares. Al recto, un tendal a ratlles penja sobre el nom, compost en Limelight. Cada títol d'apartat va entre dos filets de llautó que arriben als marges, i a sota vénen quatre plats. Cada plat porta el nom francès en negreta amb la guarnició, la traducció en cursiva a sota i el preu en negreta, enganxat al marge dret. Un disc verd fosc amb una V marca els plats vegetarians, i una pastilla perfilada amb SG, els que no porten gluten. Al verso, la carta de vins va sota un prestatge d'ampolles, amb el preu de la copa i el de l'ampolla en columnes encapçalades per 12 cl i 75 cl. Postext no té tabuladors, així que cada columna de preus pertany a una taula sense filets ni peu.
Aquesta recepta respon a
- Com faig una taula amb files de capçalera, cel·les combinades, amplades de columna i alineació per cel·la?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com componc il·lustracions sense numerar: ornaments, vinyetes, logotips?
- Com afegeixo imatges i taules des del codi (recursos) en lloc de ![]() de Markdown?
La resposta curta
// 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] } } });
}
Ingredients
- Funcions
- Taules a partir de dadesEstil de taulesAncoratge d'elements de dissenyChips en líniaEstils de paràgrafObertures dissenyadesTextos, filets i caixes als dissenys de pàginaTipus de recurs propisFigures just aquíFigures i taules com a recursosColor del paperNivells de títolPaleta de color semànticaSalts de pàgina i de columnaPàgines en un canvas
- Tipografia
- Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1)
- Recursos
- The awning and the shelf of bottles, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
Elaboració
#1 · Una taula on anirien els tabuladors
El codi és a la resposta curta, més amunt. Cada apartat és una taula de dues columnes. El plat, un salt de línia i la traducció en cursiva comparteixen la primera cel·la, i el preu va alineat a la dreta a la segona, sobre la primera línia de base del plat, perquè les dues cel·les comencen a dalt de la seva fila. columnWidths rep pesos, així que [6, 1] reserva per als preus 22,6 mm dels 158 mm de la mesura. rules: 'none' i headerBackgroundEnabled: false treuen la quadrícula i la fila de capçalera grisa que posa l'estil de taules per defecte. cellPadding: pt(LEAD / 4) fa que cada plat ocupi dues línies i mitja de l'interlineat de 14,5 pt, de manera que un apartat de quatre plats acaba a la seva desena línia de la retícula de base. Amb el farciment per defecte, 0,375 em, cada apartat acaba 0,9 mm més avall, cada títol que ve darrere baixa una línia i la carta passa a quatre pàgines.
#2 · Un sol tipus de recurs, al seu lloc i sense 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 });
Els tipus de figura i taula que porta el motor imprimeixen una línia de peu i fan flotar cada recurs al primer forat lliure després de la seva primera referència. El tipus menu no té prefix de peu i les seves peces no porten peu, així que sota un apartat no s'imprimeix res. Amb captionPrefix: 'Menu', cada peça porta a sota una línia numerada, començant per «Menu 1.» sota el tendal, i la carta passa a quatre pàgines. defaultPlacement col·loca cada taula i cada dibuix a la seva línia ::resource (inserció en bloc). Sense ell, els tres títols d'apartat queden l'un darrere l'altre a dalt del recto i les taules floten a sota, primer les postres. El tendal i les ampolles són recursos SVG del mateix tipus, dibuixats en codi i registrats amb la funció loadSvg del kit.
#3 · Combina les files de regió
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 } });
}
A la 1.4.1, parseTSV no marca cap fila com a capçalera, així que wineList fixa headerRowCount: 1 a mà. Appellation · cépage, 12 cl i 75 cl surten llavors sobre les seves columnes en Josefin Sans, la lletra de les cel·les de capçalera. Una línia amb un sol camp anomena una regió. La seva cel·la passa a ser de capçalera, centrada, i mergeCells l'estén per les tres columnes i marca amb hiddenBy les dues que cobreix (construir models de taula). Centrat només a la primera columna, BLANCS quedaria 22,6 mm a l'esquerra de l'eix de la carta, sobre el qual van La Cave i Le Comptoir. Una línia en blanc del TSV es converteix en una fila de cel·les buides, d'una línia i mitja d'alt, que deixa aire sobre Blancs i Rouges.
#4 · Penja un filet a cada costat del títol d'apartat
// 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ítol d'apartat és un disseny dins de la columna. L'element {titleText} no té amplada, així que la seva caixa fa el que fan les seves majúscules, i l'àncora 'top' el centra a la columna. Cada filet penja d'una vora del títol amb 'left-of' o 'right-of' i s'estira fins a la vora de la columna (posicionament d'elements), de manera que els filets al costat de PLATS fan 66,7 mm i els de LE COMPTOIR 56,2 mm, sempre a 4 mm de les lletres. El lineHeight del títol és un múltiple, 0,96, que deixa la seva caixa en 14,4 pt, dins d'una línia de 14,5 pt de la retícula. Amb 1, la caixa fa 15 pt, cada títol ocupa dues línies i la carta passa a quatre pàgines.
#5 · Distintius en xips, lletra petita en estils de paràgraf
// 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 xip dibuixa una caixa al voltant d'un tros de text dins de la línia (estils de xip), així que :chip[V]{style="veg"}, escrit després de la guarnició, posa el disc a la línia francesa del plat, igual en una cel·la de taula que en un paràgraf. Els estils de xip porten bold: true perquè un xip pren el pes del text que l'envolta, i després de la guarnició aquest text és de pes 400, que FONTS no carrega per a Josefin Sans. Les notes sota les postres i el colofó del verso són blocs :::paragraphs de 9 i 7,5 pt, i els dos estils porten un marginTop d'una línia (estils de paràgraf), perquè una taula col·locada amb 'here' no deixa espai a sota. Sense aquest marge, les notes comencen a la línia que segueix la traducció dels formatges, com si formessin part del plat.
#6 · Centra la carta sobre un eix
// 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 i bodyText.textAlign porten a l'eix de la carta tot el que no és taula. lineHeight: pt(3 * LEAD) dona al nom de 48 pt tres línies de la retícula. Sense ell, el nom pren l'interlineat per defecte i ocupa quatre línies; la segona línia de notes passa llavors a una pàgina pròpia i la carta arriba a quatre pàgines. A la 1.4.1, qualsevol objecte headings anul·la el salt de l'H1, i tot i així el nivell 1 declara breakBefore: { enabled: false }, perquè el nom i La Cave van a la pàgina del dibuix que tenen a sobre. Si recuperes el valor per defecte, { enabled: true, parity: 'always-odd' }, cada dibuix es queda sol a la seva pàgina i la carta arriba a set. La configuració no fixa cap locale, perquè la 1.4.1 només aplica la partició de mots al text justificat i en aquesta carta no hi ha res justificat.
La recepta 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`---Mostra en Markdown · 46 línies · 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 7Mostra en Markdown · 15 línies · 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 clMostra en Markdown · 16 línies · 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 a totes les receptes · 270 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Fixa la longitud dels filets
Una amplada fixa en lloc de 'fill' deixa cada filet en 20 mm, a 4 mm del títol, així que al costat de PLATS la parella queda més a prop del centre que al costat de LE COMPTOIR.
- size: { width: 'fill' }, // to the column's edge
+ size: { width: mm(20) }, // 20 mm, wherever the title ends#Ratlla les files com un llibre de comptes
rules: 'horizontal' traça un filet de llautó de 0,5 pt a sobre i a sota de cada fila. Els filets no ocupen espai, així que les dues cares conserven la composició, i les files en blanc sobre Blancs i Rouges també porten el seu parell de filets.
-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),Errors freqüents
Error freqüent
Una taula 'here' no es parteix mai
Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia. Taules que passen de pàgina →
Error freqüent
Una figura en línia porta aire a sobre, però no a sota
A postext 1.4.1, una figura que ::resource col·loca amb la posició 'here' porta una línia de la retícula de base d'aire a sobre, però a sota només el que sobra quan la línia següent s'ajusta a la retícula: des d'una línia sencera fins a gairebé res, de manera que el paràgraf següent pot començar enganxat al peu. Posa :::space{lines=1} després de la línia ::resource; com tot :::space, es descarta al capdamunt d'una columna. Figures just aquí →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
En el text en bandera no hi ha partició de mots
La partició de mots només s'aplica al text justificat; el text en bandera talla entre paraules, així que una columna estreta en bandera queda molt desigual. Justifica el passatge o eixampla la mesura. Partició de mots i llengua del document →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Dona a cada element rule el seu thickness. A la 1.4.1 un filet sense gruix no es dibuixa, encara que la referència de configuració dona 0,5 pt per defecte. Treu thickness de rule() i desapareixen els dos filets de cada títol d'apartat.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Imatges
- The awning and the shelf of bottles, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- Fonts
- Limelight (SIL OFL 1.1) · Noticia Text (SIL OFL 1.1) · Josefin Sans (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


