Zum Hauptinhalt springen
Rezept Nummer 52

Rezepte · Kapitel 8 · Tabellen

Bistrokarte: Preise auf einem Tabulator

Eine zweiseitige Bistrokarte: Preise rechtsbündig auf einem Tabulator, Weine in einer Tabelle ohne Linien und eine Messinglinie beiderseits jedes Gangtitels.

Auf dieser Seite

S. 1 von 2

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 230 × 310 mm
  • 1 Spalte
  • Noticia Text 10,5/14,5
  • Josefin Sans
  • Limelight
  • 2 Seiten
  • Stufe
  • Postext 1.29.1
  • In 48 ms gesetzt
  • 154 Zeilen Code

Kurz gesagt

Eine zweiseitige Speisekarte für ein erfundenes Pariser Restaurant. Sie zeigt, wie ein Tabulator jeden Preis an den rechten Rand schiebt und wie eine Weinkarte als Tabelle ohne sichtbare Linien entsteht.

Was Sie setzen

Die Herbstkarte von Les Tanneurs, einem erfundenen Pariser Bistro: ein Bogen, 230 × 310 mm, beidseitig bedruckt. Vorn hängt eine gestreifte Markise über dem Namen, gesetzt in der Limelight. Jeder Gangtitel steht zwischen zwei Messinglinien, die bis an die Ränder laufen, darunter vier Gerichte. Ein Gericht besteht aus seinem französischen Namen fett mit der Beilage, darunter einer englischen Zeile kursiv und dem Preis fett, rechtsbündig. Eine dunkelgrüne V-Scheibe kennzeichnet die vegetarischen Gerichte, eine umrissene SG-Pille die glutenfreien. Jedes Gericht ist ein Absatz, und ein Tabulator schickt seinen Preis an einen Tabstopp am rechten Rand. Hinten steht die Weinkarte unter einem Bord mit Flaschen, die Glas- und Flaschenpreise in Spalten mit den Köpfen 12 cl und 75 cl, in einer Tabelle ohne Linien und ohne Legende.

Dieses Rezept beantwortet

  • Wie erstelle ich eine Tabelle mit Kopfzeilen, verbundenen Zellen, Spaltenbreiten und Ausrichtung je Zelle?
  • Wie erstelle ich Inline-Chips: Tastaturtasten, Schlagwörter, Wortspeicher für Übungen?
  • Wie setze ich nicht nummerierte Grafiken: Ornamente, Vignetten, Logos?
  • Wie füge ich Bilder und Tabellen im Code (Ressourcen) statt mit Markdown ![]() hinzu?

Die kurze Antwort

script.js · Zeilen 28–42im vollständigen Code
// 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

Zutaten

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

Zubereitung

#1 · Ein Tabstopp, wo der Preis steht

Der Code steht oben in der kurzen Antwort. Jeder Gang ist eine Gruppe :::paragraphs{style="dish"}, ein Absatz pro Gericht (Absatzformate). Das Format dish hat einen Tabstopp bei 'end', dem Ende der Satzbreite, mit align: 'end': Der Text nach einem Tabulator endet auf dem Stopp. So schickt :tab **7** nach der Beilage und den Zeichen den Preis an den rechten Rand, wie lang das Gericht auch ist (Tabulatoren und Tabstopps). :tab schluckt die Leerzeichen um sich herum, und der Backslash am Zeilenende ist ein erzwungener Zeilenumbruch, sodass die englische Zeile kursiv eine zweite Zeile desselben Absatzes beginnt und der Preis auf der ersten bleibt. spaceBetween setzt eine halbe Zeile zwischen zwei Gerichte, jedes Gericht nimmt also zweieinhalb Zeilen des Zeilenabstands von 14,5 pt ein. indent und endIndent von einer Viertelzeile halten die Gerichte auf der Flucht des Textes in den Zellen der Weinkarte auf der Rückseite. Ein Absatz mit Tabulatoren wird Zeile für Zeile gesetzt; hier steht ohnehin jede Zeile im Flattersatz.

#2 · Ein Ressourcentyp, an Ort und Stelle und nie nummeriert

script.js · Zeilen 46–53im vollständigen Code
// 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 });

Die eingebauten Typen für Abbildungen und Tabellen drucken eine Legendenzeile und lassen jede Ressource an den ersten freien Platz nach ihrem ersten Verweis gleiten. Der Typ menu hat kein Legendenpräfix, und seine Teile haben keine Legende, deshalb steht unter den Zeichnungen und der Weinkarte nichts. Geben Sie ihm captionPrefix: 'Menu', und unter jedem Teil erscheint eine nummerierte Zeile, beginnend mit „Menu 1.“ unter der Markise, was die Karte auf vier Seiten bringt. defaultPlacement setzt die Weinkarte und jede Zeichnung an ihre Zeile ::resource (Blockeinbettung). Ohne diese Angabe gleitet die Weinkarte an den Fuß der Rückseite, unter „Le Comptoir“ und das Impressum. Die Markise und die Flaschen sind SVG-Ressourcen desselben Typs, im Code gezeichnet und mit loadSvg aus dem Kit registriert.

#3 · Die Regionszeilen verbinden

script.js · Zeilen 57–70im vollständigen Code
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 } });
}

Die Weinkarte bleibt eine Tabelle: Ihre beiden Preise stehen in Spalten unter ihren Köpfen, 12 cl und 75 cl, die eine Reihe von Tabstopps ohne Kopf ließe. parseTSV(tsv, { headerRows: 1 }) liest die erste Zeile als Kopfzeile, sodass „Appellation · cépage“, „12 cl“ und „75 cl“ über ihren Spalten in der Kopfschrift stehen, der Josefin Sans. Eine Zeile mit nur einem Feld nennt eine Region. Ihre Zelle wird zu einer zentrierten Kopfzelle, und mergeCells zieht sie über die drei Spalten und markiert die beiden Zellen, die sie überdeckt, mit hiddenBy (Tabellenmodelle aufbauen). Zentriert allein in der ersten Spalte stünde „BLANCS“ 22,6 mm links der Achse, auf der „La Cave“ und „Le Comptoir“ zentriert sind. Eine Leerzeile im TSV wird zu einer Zeile leerer Zellen, anderthalb Zeilen hoch, und diese Zeile ist der Abstand über „Blancs“ und „Rouges“.

#4 · Eine Linie an jede Seite des Gangtitels hängen

script.js · Zeilen 74–88im vollständigen Code
// 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),
] } };

Ein Gangtitel ist eine Gestaltung in der Spalte. Das Element {titleText} hat keine Breite, sein Kasten ist also so breit wie seine Versalien, und der Anker 'top' zentriert ihn in der Spalte. Jede Linie hängt mit 'left-of' oder 'right-of' an einer Kante des Titels und füllt bis zum Rand der Spalte (Platzierung von Elementen), sodass die Linien neben „PLATS“ 66,7 mm lang sind und die neben „LE COMPTOIR“ 56,2 mm, jede 4 mm von den Buchstaben entfernt. Das lineHeight des Titels ist ein Faktor, 0.96, der seinen Kasten 14,4 pt hoch macht und ihn innerhalb einer Rasterzeile von 14,5 pt hält. Bei 1 ist der Kasten 15 pt hoch, jeder Titel nimmt zwei Zeilen ein, und die Karte läuft auf vier Seiten.

#5 · Zeichen als Chips, Kleingedrucktes als Absatzformate

script.js · Zeilen 92–110im vollständigen Code
// 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) },
];

Ein Chip zeichnet einen Kasten um ein Stück Text innerhalb der Zeile (Chip-Formate), sodass :chip[V]{style="veg"}, nach der Beilage getippt, die Scheibe vor dem Tabulator auf die französische Zeile des Gerichts setzt. Die Chip-Formate setzen bold: true, weil ein Chip die Schriftstärke des umgebenden Textes übernimmt, nach einer Beilage 400, und FONTS die Josefin Sans nur in 700 lädt. Die Hinweise unter den Desserts und das Impressum auf der Rückseite sind Blöcke :::paragraphs in 9 und 7,5 pt, und beide Formate tragen ein marginTop von einer Zeile (Absatzformate). Ohne diesen Abstand beginnen die Hinweise auf der Zeile nach der englischen Zeile der Käseplatte, als gehörten sie zum Gericht.

#6 · Die Karte auf eine Achse zentrieren

script.js · Zeilen 114–126im vollständigen Code
// 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 und bodyText.textAlign stellen alles außer den Gerichten und der Weinkarte auf die Mittelachse der Karte. lineHeight: pt(3 * LEAD) gibt dem Namen in 48 pt drei Rasterzeilen. Ohne diese Angabe nimmt der Name den Standardzeilenabstand und vier Zeilen ein, die zweite Zeile der Hinweise rückt auf eine eigene Seite, und die Karte läuft auf vier Seiten. Ebene 1 schaltet den Umbruch vor H1 ab, breakBefore: { enabled: false }, weil der Name und „La Cave“ auf die Seite der Zeichnung über ihnen gehören. Bleibt der Standardwert { enabled: true, parity: 'always-odd' }, steht jede Zeichnung allein auf einer Seite, sieben Seiten insgesamt. Die Konfiguration setzt keine locale: Nichts auf dieser Karte steht im Blocksatz, und Flattersatz wird nur mit eingeschaltetem bodyText.hyphenation.ragged getrennt.

Das ganze Rezept

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`---
Markdown-Beispiel · 94 Zeilen · 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
Markdown-Beispiel · 16 Zeilen · 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: in jedem Rezept gleich · 270 Zeilen// ─── 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 ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Varianten

#Die Linien bei fester Länge enden lassen

Eine feste Breite statt 'fill' hält jede Linie 20 mm lang und 4 mm vom Titel entfernt, sodass das Paar neben „PLATS“ näher an der Mitte sitzt als neben „LE COMPTOIR“.

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

#Punkte bis zum Preis führen

Eine Füllzeichenfolge füllt den Raum vor dem Stopp. '. ' wiederholt einen Punkt und ein Leerzeichen, von einem halben Geviert nach dem letzten Zeichen oder Wort des Gerichts bis ein halbes Geviert vor seinen Preis.

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

#Die Weinkarte wie ein Kassenbuch linieren

rules: 'horizontal' zieht eine Haarlinie in Messing von 0,5 pt über und unter jede Zeile der Weinkarte. Linien nehmen keinen Raum ein, beide Seiten behalten also ihr Layout, und die leeren Zeilen über „Blancs“ und „Rouges“ bekommen ein eigenes Paar Haarlinien.

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

Häufige Fehler

Häufiger Fehler

Verbundene Zellen brauchen hiddenBy-Platzhalter: mergeCells verwenden

Zellen werden nach ihrer Position im Array der Zeile gesetzt, deshalb braucht eine verbundene Zelle Platzhalterzellen mit hiddenBy, wo sie sich ausdehnt; lässt man sie weg, wie in HTML, verschiebt sich jede spätere Spalte. Erstellen Sie Verbindungen mit mergeCells. Seit postext 1.5 wird eine Tabelle mit unregelmäßigem Raster in doc.contentWarnings als raggedTableGrid gemeldet, und parseTSV(text, { headerRows }) kennzeichnet die Kopfzeilen. Tabellen aus Daten →

Lassen Sie auf der ersten Zeile des Gerichts Platz für den Preis. Laufen Gericht und Beilage über die Stelle hinaus, die der Preis braucht, nimmt der Stopp das letzte Wort des Gerichts mit dem Preis auf die nächste Zeile mit, und die englische Zeile folgt danach: Das Gericht nimmt dann drei Zeilen ein, und der Gang endet eine Zeile tiefer.

Nachweise

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