Vai al contenuto principale
Ricetta numero 52

Ricettario · Capitolo 8 · Tabelle

Menu da bistrot: i prezzi su una tabulazione

Un menu da bistrot fronte e retro: prezzi allineati a destra su una tabulazione, vini in una tabella senza bordi e un filetto ottone ai lati di ogni portata.

In questa pagina
  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 230 × 310 mm
  • 1 colonna
  • Noticia Text 10,5/14,5
  • Josefin Sans
  • Limelight
  • 2 pagine
  • Livello
  • Postext 1.29.1
  • Impaginato in 48 ms
  • 154 righe di codice

In breve

Un menu fronte e retro per un ristorante parigino inventato. Mostra come spingere ogni prezzo al margine destro con una tabulazione e come comporre la carta dei vini in una tabella senza linee visibili.

Che cosa comporrai

La carta d’autunno di Les Tanneurs, un bistrot parigino immaginario: un solo foglio, 230 × 310 mm, stampato fronte e retro. Sul fronte una tenda a strisce pende sopra il nome, composto in Limelight. Ogni titolo di portata sta tra due filetti ottone che arrivano fino ai margini, con quattro piatti sotto. Un piatto è il suo nome francese in grassetto con il contorno, una riga inglese in corsivo sotto e il prezzo in grassetto, allineato a destra. Un disco verde scuro «V» segna i piatti vegetariani e una pillola contornata «SG» quelli senza glutine. Ogni piatto è un paragrafo, e una tabulazione manda il prezzo a un arresto sul margine destro. Sul retro la carta dei vini sta sotto una mensola di bottiglie, con i prezzi al calice e alla bottiglia in colonne intestate «12 cl» e «75 cl», in una tabella senza filetti e senza didascalia.

Questa ricetta risponde a

  • Come creo una tabella con righe di intestazione, celle unite, larghezze delle colonne e allineamento per cella?
  • Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
  • Come compongo illustrazioni non numerate: fregi, vignette, loghi?
  • Come aggiungo immagini e tabelle dal codice (risorse) invece che con ![]() in Markdown?

La risposta breve

script.js · righe 28–42nel codice completo
// 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

Ingredienti

Tipo
Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1)
Risorse
  • The awning and the shelf of bottles, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

Preparazione

#1 · Un arresto di tabulazione dove va il prezzo

Il codice è nella risposta breve qui sopra. Ogni portata è un gruppo :::paragraphs{style="dish"}, un paragrafo per piatto (Stili di paragrafo). Lo stile del piatto ha un solo arresto di tabulazione, in 'end', la fine della giustezza, con align: 'end': il testo dopo una tabulazione finisce sull’arresto. Così :tab **7** dopo il contorno e i simboli manda il prezzo a destra, per quanto lungo sia il piatto (Tabulazioni e arresti di tabulazione). :tab assorbe gli spazi che lo circondano, e la barra rovesciata a fine riga è un a capo forzato, così la riga inglese in corsivo comincia una seconda riga dello stesso paragrafo e il prezzo resta sulla prima. spaceBetween mette mezza riga tra due piatti, quindi ogni piatto occupa due righe e mezza dell’interlinea di 14,5 pt. indent e endIndent di un quarto di riga tengono i piatti allineati al testo delle celle della carta dei vini sul retro. Un paragrafo con tabulazioni si compone riga per riga; qui tutte le righe sono comunque a bandiera.

#2 · Un tipo di risorsa, al suo posto e mai numerato

script.js · righe 46–53nel codice completo
// 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 });

I tipi predefiniti figura e tabella stampano una riga di didascalia e fanno flottare ogni risorsa nel primo spazio libero dopo il suo primo rimando. Il tipo menu non ha prefisso di didascalia e i suoi pezzi non hanno didascalia, quindi sotto i disegni e la carta dei vini non si stampa nulla. Dagli captionPrefix: 'Menu' e sotto ogni pezzo compare una riga numerata, a cominciare da «Menu 1.» sotto la tenda, e il cartoncino arriva a quattro pagine. defaultPlacement mette la carta dei vini e ogni disegno sulla sua riga ::resource (Inserimento di blocco). Senza, la carta dei vini flotta al piede del retro, sotto «Le Comptoir» e il colophon. La tenda e le bottiglie sono risorse SVG dello stesso tipo, disegnate nel codice e registrate con il loadSvg del kit.

#3 · Unisci le righe delle regioni

script.js · righe 57–70nel codice completo
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 carta dei vini resta una tabella: i suoi due prezzi stanno in colonne sotto le loro intestazioni, «12 cl» e «75 cl», che una riga di tabulazioni lascerebbe senza intestazione. parseTSV(tsv, { headerRows: 1 }) legge la prima riga come riga di intestazione, così «Appellation · cépage», «12 cl» e «75 cl» si stampano sopra le loro colonne nel carattere dell’intestazione, Josefin Sans. Una riga con un solo campo nomina una regione. La sua cella diventa una cella di intestazione centrata, e mergeCells la allarga sulle tre colonne e segna le due celle che copre con hiddenBy (Costruire i modelli di tabella). Centrato nella sola prima colonna, «BLANCS» starebbe 22,6 mm a sinistra dell’asse su cui sono centrati «La Cave» e «Le Comptoir». Una riga vuota nel TSV diventa una riga di celle vuote, alta una riga e mezza, ed è quella riga a fare lo spazio sopra «Blancs» e «Rouges».

#4 · Appendi un filetto a ogni lato del titolo della portata

script.js · righe 74–88nel codice completo
// 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 titolo di portata è un progetto grafico nella colonna. L’elemento {titleText} non ha larghezza, quindi il suo riquadro è largo quanto le sue maiuscole, e l’ancoraggio 'top' lo centra sulla colonna. Ogni filetto si aggancia a un bordo del titolo con 'left-of' o 'right-of' e si estende fino al bordo della colonna (Posizionamento degli elementi), così i filetti accanto a «PLATS» sono lunghi 66,7 mm e quelli accanto a «LE COMPTOIR» 56,2 mm, ciascuno a 4 mm dalle lettere. Il lineHeight del titolo è un multiplo, 0,96, che rende il suo riquadro alto 14,4 pt e lo tiene dentro una sola riga di griglia da 14,5 pt. Con 1 il riquadro misura 15 pt, ogni titolo occupa due righe e il cartoncino arriva a quattro pagine.

#5 · Simboli come chip, caratteri piccoli come stili di paragrafo

script.js · righe 92–110nel codice completo
// 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) },
];

Un chip disegna un riquadro attorno a un tratto di testo dentro la riga (Stili dei chip), così :chip[V]{style="veg"} digitato dopo il contorno mette il disco sulla riga francese del piatto, prima della tabulazione. Gli stili dei chip impostano bold: true perché un chip prende il peso del testo che lo circonda, 400 dopo un contorno, e FONTS carica Josefin Sans solo nel peso 700. Le note sotto i dessert e il colophon sul retro sono blocchi :::paragraphs a 9 e 7,5 pt, ed entrambi gli stili hanno un marginTop di una riga (Stili di paragrafo). Senza quel margine le note cominciano sulla riga dopo la riga inglese del piatto di formaggi, come se appartenessero al piatto.

#6 · Centra il cartoncino su un solo asse

script.js · righe 114–126nel codice completo
// 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 e bodyText.textAlign mettono tutto, tranne i piatti e la carta dei vini, sulla linea mediana del cartoncino. lineHeight: pt(3 * LEAD) dà al nome da 48 pt tre righe di griglia. Senza, il nome prende l’interlinea predefinita e quattro righe, la seconda riga di note passa su una pagina sua e il cartoncino arriva a quattro pagine. Il livello 1 disattiva l’interruzione prima dell’H1, breakBefore: { enabled: false }, perché il nome e «La Cave» vanno sulla pagina del disegno sopra di loro. Lascia il valore predefinito, { enabled: true, parity: 'always-odd' }, e ogni disegno resta da solo su una pagina, sette pagine in tutto. La configurazione non imposta locale: su questo cartoncino nulla è giustificato, e il testo a bandiera si sillaba solo con bodyText.hyphenation.ragged attivo.

La ricetta completa

Sandbox
// ═══ 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`---
Esempio Markdown · 94 righe · content.en.mdtitle: "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 cl
Esempio Markdown · 16 righe · content.cave.en.mdBULLES **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: uguale in ogni ricetta · 270 righe// ─── 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Ferma i filetti a una lunghezza fissa

Una larghezza fissa al posto di 'fill' tiene ogni filetto lungo 20 mm e a 4 mm dal titolo, così la coppia sta più vicina al centro accanto a «PLATS» che accanto a «LE COMPTOIR».

-    size: { width: 'fill' }, // to the column's edge
+    size: { width: mm(20) }, // 20 mm, wherever the title ends

#Porta una fila di puntini fino al prezzo

Un riempitivo occupa lo spazio prima dell’arresto. '. ' ripete un punto e uno spazio, da mezzo em dopo l’ultimo simbolo o l’ultima parola del piatto fino a mezzo em prima del prezzo.

-  tabStops: [{ position: 'end', align: 'end' }],
+  tabStops: [{ position: 'end', align: 'end', leader: '. ' }],

#Filetta la carta dei vini come un registro

rules: 'horizontal' traccia un filetto sottile ottone da 0,5 pt sopra e sotto ogni riga della carta dei vini. I filetti non occupano spazio, quindi entrambi i lati mantengono l’impaginazione, e le righe vuote sopra «Blancs» e «Rouges» ricevono una coppia di filetti tutta loro.

-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),

Errori frequenti

Errore frequente

Le celle unite richiedono segnaposto hiddenBy: usa mergeCells

Le celle si dispongono secondo la loro posizione nell’array della riga, quindi una cella unita richiede celle segnaposto marcate hiddenBy dove si estende; ometterle, come fa l’HTML, sposta tutte le colonne successive. Costruisci le unioni con mergeCells. Da postext 1.5 una tabella con una griglia irregolare viene segnalata in doc.contentWarnings come raggedTableGrid, e parseTSV(text, { headerRows }) marca le righe di testa. Tabelle dai dati →

Lascia spazio per il prezzo sulla prima riga del piatto. Quando il piatto e il contorno superano il punto che serve al prezzo, l’arresto porta l’ultima parola del piatto alla riga successiva insieme al prezzo, e la riga inglese viene dopo: il piatto occupa allora tre righe e la portata finisce una riga più in basso.

Crediti

Testo
Testo originale, CC BY 4.0
Immagini
  • The awning and the shelf of bottles, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
Font
Limelight (SIL OFL 1.1) · Noticia Text (SIL OFL 1.1) · Josefin Sans (SIL OFL 1.1)
Sandbox