En bref
Une carte recto verso pour un restaurant parisien imaginaire. On y voit comment pousser chaque prix contre le bord droit avec une tabulation, et comment composer une carte des vins dans un tableau sans lignes visibles.
Ce que vous allez composer
La carte d’automne des Tanneurs, un bistrot parisien imaginaire : une feuille de 230 × 310 mm imprimée recto verso. Au recto, un store rayé surplombe le nom, composé en Limelight. Chaque titre de service se tient entre deux filets laiton qui courent jusqu’aux marges, avec quatre plats dessous. Un plat, c’est son nom français en gras avec la garniture, une ligne anglaise en italique dessous et le prix en gras, au fer à droite. Une pastille ronde vert foncé « V » signale les plats végétariens et une pastille au contour « SG » les plats sans gluten. Chaque plat est un paragraphe, et une tabulation envoie son prix sur un taquet placé contre la marge droite. Au verso, la carte des vins se trouve sous une étagère de bouteilles, avec les prix au verre et à la bouteille dans des colonnes intitulées « 12 cl » et « 75 cl », dans un tableau sans filets ni légende.
Cette recette répond à
- Comment faire un tableau avec rangées d’en-tête, cellules fusionnées, largeurs de colonne et alignement par cellule ?
- Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?
- Comment composer des illustrations non numérotées : ornements, vignettes, logos ?
- Comment ajouter des images et des tableaux depuis le code (ressources) plutôt qu’avec ![]() en Markdown ?
La réponse courte
// Each course is a :::paragraphs group in the dish style. Its one tab stop stands at the end of
// the measure and ends there the text after the tab, so `:tab **7**` sets the price flush right
// however long the dish is. A backslash at the end of the line breaks it: the dish and its
// translation are one paragraph, and the price shares the dish's first line.
const dish = { id: 'dish', name: 'Dish', textAlign: 'left', // the card is centred; a dish is not
tabStops: [{ position: 'end', align: 'end' }],
spaceBetween: pt(LEAD / 2), // half a line between two dishes: each dish 2½ lines of LEAD
marginTop: pt(LEAD), // a line of air under the course head
indent: pt(LEAD / 4), endIndent: pt(LEAD / 4) }; // in line with the wine list's cell text
// The wine list stays a table: its prices stand in two columns under the heads 12 cl and 75 cl.
// The document's tableStyle turns off every rule and fill (#region wines).
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a row: 1½ 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
Ingrédients
- Fonctionnalités
- Taquets de tabulation et points de conduiteStyles de paragrapheTableaux à partir de donnéesAncrer les éléments de maquetteStyle de tableauPastilles dans le texteOuvertures composéesTexte, filets et boîtes dans les maquettes de pageTypes de ressource personnalisésDes figures exactement iciFigures et tableaux comme ressourcesCouleur du papierNiveaux de titrePalette de couleurs sémantiqueSauts de page et de colonnePages sur un canvas
- Typographie
- Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1)
- Ressources
- The awning and the shelf of bottles, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
Préparation
#1 · Un taquet de tabulation là où va le prix
Le code est dans la réponse courte plus haut. Chaque service est un groupe :::paragraphs{style="dish"}, un paragraphe par plat (Styles de paragraphe). Le style des plats a un seul taquet, à 'end', la fin de la longueur de ligne, avec align: 'end' : le texte qui suit une tabulation se termine sur le taquet. Ainsi, :tab **7** après la garniture et les pastilles envoie le prix au fer à droite, quelle que soit la longueur du plat (Tabulations et taquets). :tab absorbe les espaces qui l’entourent, et la barre oblique inverse en fin de ligne est un saut de ligne forcé : la ligne anglaise en italique commence donc une deuxième ligne du même paragraphe, et le prix reste sur la première. spaceBetween met une demi-ligne entre deux plats : chaque plat prend ainsi deux lignes et demie de l’interlignage de 14,5 pt. indent et endIndent d’un quart de ligne alignent les plats sur le texte des cellules de la carte des vins, au verso. Un paragraphe avec tabulations se compose ligne à ligne ; ici, de toute façon, chaque ligne est en drapeau.
#2 · Un type de ressource, sur place et jamais numéroté
// No caption prefix and no caption, so no 'Table 1' line prints under the wine list. Placement
// 'here' sets each piece at its ::resource line; a list that outgrows the page is cut
// between rows and goes on at the head of the next one.
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 });
Les types figure et tableau intégrés impriment une ligne de légende et font flotter chaque ressource jusqu’au premier emplacement libre après son premier renvoi. Le type menu n’a pas de préfixe de légende et ses pièces n’ont pas de légende : rien ne s’imprime sous les dessins ni sous la carte des vins. Donnez-lui captionPrefix: 'Menu' et une ligne numérotée s’imprime sous chaque pièce, en commençant par « Menu 1. » sous le store, ce qui porte la carte à quatre pages. defaultPlacement place la carte des vins et chaque dessin à leur ligne ::resource (Insertion en bloc). Sans lui, la carte des vins flotte jusqu’au pied du verso, sous « Le Comptoir » et le colophon. Le store et les bouteilles sont des ressources SVG du même type, dessinées par le code et enregistrées avec le loadSvg du kit.
#3 · Fusionner les rangées des régions
function wineList(tsv) {
let m = { ...parseTSV(tsv, { headerRows: 1 }), columnWidths: [5, 1, 1] }; // the head row
m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { ...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 } });
}
La carte des vins reste un tableau : ses deux prix se tiennent en colonnes sous leurs intitulés, « 12 cl » et « 75 cl », qu’une rangée de taquets laisserait sans en-tête. parseTSV(tsv, { headerRows: 1 }) lit la première ligne comme rangée d’en-tête : « Appellation · cépage », « 12 cl » et « 75 cl » s’impriment au-dessus de leurs colonnes dans le caractère de l’en-tête, Josefin Sans. Une ligne à un seul champ nomme une région. Sa cellule devient une cellule d’en-tête centrée, et mergeCells l’étend sur les trois colonnes et marque hiddenBy les deux cellules qu’elle recouvre (Construire des modèles de tableau). Centré dans la seule première colonne, « BLANCS » se tiendrait 22,6 mm à gauche de l’axe sur lequel sont centrés « La Cave » et « Le Comptoir ». Une ligne vide dans le TSV devient une rangée de cellules vides, haute d’une ligne et demie, et cette rangée forme le blanc au-dessus de « Blancs » et de « Rouges ».
#4 · Accrocher un filet de chaque côté du titre de service
// 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), // heavier than the default 0.5 pt hairline
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, // 14.4 pt: the title stays inside one 14.5 pt grid line
textTransform: 'uppercase', color: col('wine'),
placement: { anchor: { to: 'container', edge: 'top' } } },
rule('left-of', -4), rule('right-of', 4),
] } };
Un titre de service est une maquette dans la colonne. L’élément {titleText} n’a pas de largeur : sa boîte est donc aussi large que ses capitales, et l’ancre 'top' la centre sur la colonne. Chaque filet s’accroche à un bord du titre avec 'left-of' ou 'right-of' et s’étend jusqu’au bord de la colonne (Placement des éléments) : les filets de part et d’autre de « PLATS » mesurent ainsi 66,7 mm et ceux de « LE COMPTOIR » 56,2 mm, chacun à 4 mm des lettres. Le lineHeight du titre est un multiple, 0,96, qui donne à sa boîte 14,4 pt et la garde dans une seule ligne de grille de 14,5 pt. À 1, la boîte mesure 15 pt, chaque titre prend deux lignes et la carte passe à quatre pages.
#5 · Les pictogrammes en pastilles, les petites lignes en styles de paragraphe
// 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, a
// line of air above each.
const paragraphStyles = [dish,
{ id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
{ id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
marginTop: pt(LEAD) },
];
Une pastille dessine une boîte autour d’un passage de texte dans la ligne (Styles de pastille) : :chip[V]{style="veg"} saisi après la garniture place donc le rond sur la ligne française du plat, avant la tabulation. Les styles de pastille règlent bold: true parce qu’une pastille prend la graisse du texte qui l’entoure, 400 après une garniture, et que FONTS ne charge Josefin Sans qu’en 700. Les notes sous les desserts et le colophon du verso sont des blocs :::paragraphs en 9 et 7,5 pt, et les deux styles portent une marginTop d’une ligne (Styles de paragraphe). Sans cette marge, les notes commencent à la ligne qui suit la ligne anglaise de l’assiette de fromages, comme si elles appartenaient au plat.
#6 · Centrer la carte sur un seul axe
// bodyText (in config) centres the lines under the name and the notes; only the dishes and the
// wine list keep a left edge. Ragged text is hyphenated only with bodyText.hyphenation.ragged,
// so no word on this card is divided, the French dish names included. The config's locale
// names each edition's language, English or Spanish, for the bundle the Sandbox opens.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
levels: [ // Limelight ships one weight, 400, and no italic
// The H1 break is off: by default an H1 opens a recto, which 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 dishes below add a line of their own
] };
headings.textAlign et bodyText.textAlign placent tout sauf les plats et la carte des vins sur l’axe central de la carte. lineHeight: pt(3 * LEAD) donne au nom de 48 pt trois lignes de grille. Sans lui, le nom prend l’interlignage par défaut et quatre lignes, la deuxième ligne de notes passe sur une page à elle et la carte passe à quatre pages. Le niveau 1 désactive le saut avant le H1, breakBefore: { enabled: false }, parce que le nom et « La Cave » appartiennent à la page du dessin qui les surmonte. Gardez la valeur par défaut, { enabled: true, parity: 'always-odd' }, et chaque dessin se retrouve seul sur une page, sept pages en tout. La configuration ne règle aucune locale : rien n’est justifié sur cette carte, et le texte en drapeau n’est coupé que si bodyText.hyphenation.ragged est actif.
La recette complète
// ═══ Postext Cookbook · Nº 052 · Bistro menu: prices on a tab stop ═══════════════════ // 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.25.0 // The autumn menu of an imaginary Paris bistro: two sides of one card, each dish a paragraph // whose price a tab sends to the right margin, the wine list a table with no rules. import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, parseTSV, mergeCells, } from 'https://esm.sh/postext'; const LANG = 'en'; // @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 tab stop at the end of the measure // Each course is a :::paragraphs group in the dish style. Its one tab stop stands at the end of // the measure and ends there the text after the tab, so `:tab **7**` sets the price flush right // however long the dish is. A backslash at the end of the line breaks it: the dish and its // translation are one paragraph, and the price shares the dish's first line. const dish = { id: 'dish', name: 'Dish', textAlign: 'left', // the card is centred; a dish is not tabStops: [{ position: 'end', align: 'end' }], spaceBetween: pt(LEAD / 2), // half a line between two dishes: each dish 2½ lines of LEAD marginTop: pt(LEAD), // a line of air under the course head indent: pt(LEAD / 4), endIndent: pt(LEAD / 4) }; // in line with the wine list's cell text // The wine list stays a table: its prices stand in two columns under the heads 12 cl and 75 cl. // The document's tableStyle turns off every rule and fill (#region wines). const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a row: 1½ 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 // #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 the wine list. Placement // 'here' sets each piece at its ::resource line; a list that outgrows the page is cut // between rows and goes on at the head of the next one. 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, { headerRows: 1 }), columnWidths: [5, 1, 1] }; // the head row m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { ...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), // heavier than the default 0.5 pt hairline 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, // 14.4 pt: the title stays inside one 14.5 pt grid 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, a // line of air above each. const paragraphStyles = [dish, { 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 dishes and the // wine list keep a left edge. Ragged text is hyphenated only with bodyText.hyphenation.ragged, // so no word on this card is divided, the French dish names included. The config's locale // names each edition's language, English or Spanish, for the bundle the Sandbox opens. const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center', levels: [ // Limelight ships one weight, 400, and no italic // The H1 break is off: by default an H1 opens a recto, which 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 dishes below add a line of their 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 = () => ({ colorPalette, tableStyle, resourceTypes, locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle 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`---Exemple Markdown · 94 lignes · content.en.md
title: "Les Tanneurs" --- ::resource{id="awning"} # Les Tanneurs *Bistrot parisien depuis 1931* Carte d’automne 2026 · *autumn menu* ## Entrées :::paragraphs{style="dish"} **Œuf mayonnaise**, cornichons de la maison :chip[V]{style="veg"} :chip[SG]{style="gf"} :tab **7**\ *Egg mayonnaise, house pickles* **Velouté de potimarron**, crème crue, noisettes torréfiées :chip[V]{style="veg"} :chip[SG]{style="gf"} :tab **9**\ *Red kuri squash soup, raw cream, toasted hazelnuts* **Terrine de campagne** au poivre vert, pain grillé :tab **11**\ *Country terrine with green peppercorns, toast* **Poireaux vinaigrette**, œuf mimosa :chip[V]{style="veg"} :chip[SG]{style="gf"} :tab **10**\ *Leeks in vinaigrette, egg mimosa* ::: ## Plats :::paragraphs{style="dish"} **Blanquette de veau** à l’ancienne, riz pilaf :tab **24**\ *Veal blanquette the old way, pilaf rice* **Paleron de bœuf** braisé au vin rouge, carottes fondantes :tab **26**\ *Beef chuck braised in red wine, slow-cooked carrots* **Filet de lieu jaune**, beurre blanc, poireaux fondus :chip[SG]{style="gf"} :tab **27**\ *Pollack fillet, beurre blanc, softened leeks* **Risotto aux cèpes**, mascarpone et sauge :chip[V]{style="veg"} :chip[SG]{style="gf"} :tab **22**\ *Cep risotto, mascarpone and sage* ::: ## Desserts :::paragraphs{style="dish"} **Tarte fine aux pommes**, crème fraîche :tab **10**\ *Thin apple tart, crème fraîche* **Mousse au chocolat noir** :tab **9**\ *Dark chocolate mousse* **Île flottante**, pralines roses :tab **9**\ *Floating island, pink pralines* **Trois fromages affinés**, confiture de cerises noires :tab **12**\ *Three matured cheeses, black cherry jam* ::: :::paragraphs{style="notes"} :chip[V]{style="veg"} Plat végétarien · *vegetarian* · :chip[SG]{style="gf"} Sans gluten · *gluten-free* Bœuf d’origine France · *French beef* · Prix nets, service compris · *service included* ::: :::pagebreak ::resource{id="bottles"} # La Cave Vins au verre et à la bouteille · *wine by the glass and by the bottle* ::resource{id="wines"} ## Le Comptoir :::paragraphs{style="dish"} **Kir** au vin blanc et cassis :tab **6**\ *White wine with blackcurrant liqueur* **Pastis**, carafe d’eau fraîche :tab **5**\ *Pastis with a jug of cold water* **Bière à la pression**, 25 cl :tab **5**\ *Draught beer, 25 cl* **Café**, noisette ou allongé :tab **3**\ *Espresso, macchiato or lungo* ::: :::paragraphs{style="colophon"} Set in Limelight, Noticia Text and Josefin Sans (SIL OFL) · Les Tanneurs is an imaginary bistro · Text and drawings CC BY 4.0 :::`; // the two sides of the card, in French const wines = String.raw`Appellation · cépage 12 cl 75 clExemple Markdown · 16 lignes · 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.' }), wineList(wines), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build. const FONTS = { 'Noticia Text': ['400', '400i', '700'], Limelight: ['400'], 'Josefin Sans': ['700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = markdown + wines; await prepareFonts(allText, config(), kitFonts(FONTS)); await loadSvg('awning.svg', awning()); await loadSvg('bottles.svg', bottles()); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), { ...kitFonts(FONTS), text: allText }); showPages(doc, { title: t({ en: 'Les Tanneurs: autumn menu', es: 'Les Tanneurs: carta de otoño' }) });Kit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Arrêter les filets à une longueur fixe
Une largeur fixe à la place de 'fill' garde chaque filet à 20 mm de long et à 4 mm du titre : la paire se tient alors plus près du centre à côté de « PLATS » qu’à côté de « LE COMPTOIR ».
- size: { width: 'fill' }, // to the column's edge
+ size: { width: mm(20) }, // 20 mm, wherever the title ends#Mener des points de conduite jusqu’au prix
Des points de conduite remplissent l’espace avant le taquet. '. ' répète un point et une espace, d’un demi-em après la dernière pastille ou le dernier mot du plat jusqu’à un demi-em avant son prix.
- tabStops: [{ position: 'end', align: 'end' }],
+ tabStops: [{ position: 'end', align: 'end', leader: '. ' }],#Tracer des filets de registre sur la carte des vins
rules: 'horizontal' trace un filet laiton de 0,5 pt au-dessus et au-dessous de chaque rangée de la carte des vins. Les filets ne prennent pas de place : les deux faces gardent leur mise en page, et les rangées vides au-dessus de « Blancs » et de « Rouges » reçoivent leur propre paire de filets.
-const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a row: 1½ lines of LEAD
+const tableStyle = { rules: 'horizontal', borderColor: col('brass'),
+ borderWidth: pt(0.5), cellPadding: pt(LEAD / 4),Erreurs fréquentes
Laissez de la place au prix sur la première ligne du plat. Quand le plat et la garniture dépassent l’endroit dont le prix a besoin, le taquet emporte le dernier mot du plat à la ligne suivante avec le prix, et la ligne anglaise vient après eux : le plat prend alors trois lignes et le service se termine une ligne plus bas.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Images
- The awning and the shelf of bottles, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- Polices
- Limelight (SIL OFL 1.1) · Noticia Text (SIL OFL 1.1) · Josefin Sans (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


