Kurz gesagt
Ein vierseitiger Saatgutkatalog mit Preisliste. Jede Sorte hat in der Tabelle ein kleines Gemälde ihrer Samentüte, und die Liste läuft über zwei gegenüberliegende Seiten weiter.
Was Sie setzen
Die Frühjahrspreisliste von Brindlewood Seed Co., einer erfundenen Saatgutgärtnerei, als vierseitiges Heft auf chamoisfarbenem Papier. Der Umschlag setzt „Spring Seeds“ in Alfa Slab One über ein Gemälde einer Sonnenblume und einer Tomatenpflanze, darunter den Brief an die Gärtner in zwei Spalten. Die Seiten 2 und 3 tragen die Liste. Jede Sorte hat oben in ihrer Zelle ein Gemälde ihrer Samentüte und darunter ihren Namen, und die Gemüsearten laufen eine verbundene erste Spalte hinunter. Die Preise stehen rechtsbündig in einer schmalen Auszeichnungsschrift, und rote und grüne Chips kennzeichnen neues und biologisches Saatgut. Die Liste bricht nach der Bratpaprika um und läuft auf der gegenüberliegenden Seite unter demselben roten Band weiter, mit „(continued)“ gekennzeichnet. Die letzte Seite ist der Bestellschein, zwei abgerundete Raster mit leeren Zeilen zum Ausfüllen.
Dieses Rezept beantwortet
- Wie setze ich Bilder oder Symbole in Tabellenzellen?
- Wie teile ich eine lange Tabelle über mehrere Seiten, mit wiederholtem Kopf und einem Fortsetzungsvermerk?
- Wie gestalte ich mehrere Tabellen in einem Dokument unterschiedlich (Füllungen, Zebrastreifen, abgerundete Rahmen)?
- Wie erstelle ich eine Tabelle mit Kopfzeilen, verbundenen Zellen, Spaltenbreiten und Ausrichtung je Zelle?
Die kurze Antwort
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
let m = { ...parseTSV(tsv, { headerRows: 1 }), columnWidths: [20, 40, 63, 17, 17] }; // mm
for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
for (let r = 1; r < m.rows.length; r++) {
// The picture is a resource of its own; the cell sets it at the top, centred, with the
// variety's name under it, and the row grows to hold both.
const variety = m.rows[r][1].content;
m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
m = setCellContent(m, at(r, 1), `**${variety}**`);
m = setAlignment(m, at(r, 1), 'center');
for (const c of [3, 4]) { // prices in the label face: lining figures, flush right
m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
m = setAlignment(m, at(r, c), 'right');
}
if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
let end = r;
while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
// One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
// marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
m = setAlignment(m, at(r, 0), 'center', 'middle');
m = setCellBackground(m, at(r, 0), col('cream'));
}
return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column.
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
caption: '**Vegetable Seeds**', placement: { span: 'page' },
note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });
Zutaten
- Funktionen
- Bilder in TabellenzellenTabellen über mehrere SeitenTabellen aus DatenBenannte TabellenformateTabellenformatZellfüllungenFormat der BildunterschriftInline-ChipsEigene RessourcentypenAbbildungen und Tabellen als RessourcenPlatzierung von AbbildungenVerweise, die Abbildungen platzierenÜberschriftenformateÜberschriftenattributeGestaltete AuftakteBilder in SeitengestaltungenUmschläge, Titelseiten und ImpressumKolumnentitel und SeitenzahlenPapierfarbeSemantische Farbpalette
- Nutzt außerdem
- Abbildungen genau hierKopfzeilen nach SeitenrolleAbsatzformateAbschnittsgeometrieKolumnentitel je Abschnitt
- Schrift
- Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1)
- Dateien
amish-paste-360.jpgblack-beauty-360.jpgbrandywine-360.jpgcherokee-purple-360.jpgcover-1500.jpgdragon-tongue-360.jpggold-medal-360.jpgjimmy-nardello-360.jpgkentucky-wonder-360.jpgwaltham-butternut-360.jpg- The cover's sunflower and tomato plant, a painting (Generated With Diffusion Models, eigener Text)
- The Brandywine seed packet (Generated With Diffusion Models, eigener Text)
- The Cherokee Purple seed packet (Generated With Diffusion Models, eigener Text)
- The Gold Medal seed packet (Generated With Diffusion Models, eigener Text)
- The Amish Paste seed packet (Generated With Diffusion Models, eigener Text)
- The Jimmy Nardello seed packet (Generated With Diffusion Models, eigener Text)
- The Kentucky Wonder seed packet (Generated With Diffusion Models, eigener Text)
- The Dragon Tongue seed packet (Generated With Diffusion Models, eigener Text)
- The Black Beauty seed packet (Generated With Diffusion Models, eigener Text)
- The Waltham Butternut seed packet (Generated With Diffusion Models, eigener Text)
Zubereitung
#1 · Ergänzen, was das TSV auslässt
Der Code ist die kurze Antwort oben. Das TSV enthält nur Wörter und Preise. setCellImage setzt die Tüte jeder Sorte, eine nach der Sorte benannte Ressource, oben in ihre Zelle, mit 55 % der Innenbreite, sodass der Name unter einem Bild von 20,3 × 27,1 mm steht und jede Zeile auf 36 mm wächst. mergeCells führt jede Art die erste Spalte hinunter und markiert die Zellen, die sie überdeckt, mit hiddenBy. Die Zellen eines Tabellenkörpers teilen sich eine Schrift, hier Gelasio, deshalb ist jeder Preis ein Chip ohne Füllung, Rahmen oder seitlichen Innenabstand, gesetzt in fetter Cabin Condensed mit 1,12 em wegen ihrer Versalziffern.
#2 · Die Liste zitieren und über die Doppelseite teilen
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
// 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
// page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
overflow: 'split', continuedSuffix: '(continued)',
continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };
Der Brief auf Seite 1 zitiert die Liste mit :ref{id="vegetables" text="the price list"}, und text druckt diese Wörter anstelle einer Nummer. Eine zitierte Tabelle gleitet an die erste freie Stelle nach dem Zitat, hier an den Kopf von Seite 2. Dort passt die Liste nicht hin, also trennt die Engine sie zwischen zwei Zeilen nach Jimmy Nardello, nie innerhalb einer verbundenen Art. Seite 3 wiederholt die Kopfzeile und das Band der Tabellenüberschrift mit dem Zusatz; der Hinweis steht unter dem ersten Teil und die Notiz unter dem letzten (Tabellen, die höher als die Seite sind). parseTSV(tsv, { headerRows: 1 }) macht die erste Zeile zur Kopfzeile, die als solche gezeichnet und auf Seite 3 wiederholt wird. Zusatz und Hinweis sind kursiv und übernehmen die Schrift der Tabellenüberschrift, sodass das Band in Gelasio bleibt; Cabin Condensed hat keine Kursive.
#3 · Eine Ressource für jede Tüte
// Nothing cites the pictures, so none is numbered or placed: the cover design and the cells
// draw them by id, and their typeId is never looked up.
const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap',
altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 });
const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content);
const resources = [vegetableList(list), shipTo, orderForm,
picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm
'A sunflower and a staked tomato plant in a ploughed field.'),
...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4
`Seed packet of ${name}.`))];
await prepareFonts(markdown + list, config(), kitFonts(FONTS));
await Promise.all(resources.flatMap(({ bitmap }) => bitmap
? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : []));
Ein Zellenbild benennt eine Ressource über ihre ID, deshalb ist jede Tüte eine eigene Bitmap-Ressource, ein JPEG von 360 × 480 px, auf das Seitenverhältnis 3 : 4 der Tüte zugeschnitten, und loadImage aus dem Kit registriert es mit registerResourceImage unter der fileId. Nichts zitiert die Tüten, also wird keine nummeriert oder außerhalb ihrer Zelle platziert. Die Gemälde enthalten keine Beschriftung: Der Name ist als Zellentext in den Schriften der Seite gesetzt.
#4 · Ein zweiter Tabellenstil für den Bestellschein
function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height
let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) =>
widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) };
for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) });
m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff
if (r === 0 || !cell.content || cell.hiddenBy) return;
m = setCellBackground(m, at(r, c), col('paper'));
if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels
}));
return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' },
table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 };
}
const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'],
['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]);
const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet
const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces',
'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'],
['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]),
'We guarantee every packet to grow. If a variety fails to come up in your garden, write to '
+ 'us before August 1 and we will send a new packet or refund what you paid for it.');
Die beiden Raster verwenden den benannten Stil form, mit Rasterlinien, einem Radius von 2,5 mm am äußeren Rahmen und einer cremefarbenen Füllung zum Beschreiben. Die Preisliste behält den tableStyle des Dokuments (Benannte Tabellenstile). Eine leere Zelle braucht keinen Platzhalter, weil ihre Zeile trotzdem eine Zeile Höhe einnimmt, hier 7,3 mm. Jede Summe verbindet vier Spalten zu einem rechtsbündig gesetzten Label. Das Überschriftenformat des Bestellscheins schaltet Seite 4 auf eine einzige Spalte um, sodass beide Raster über die volle Satzbreite von 157 mm laufen.
#5 · Den Brief unter dem Gemälde halten
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the painting and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
// The opener reserves down to the painting's foot; minHeight is a floor AIR lower, on the
// grid. The frame reaches the page's foot, so it stays out of that count: reserve: false
// (gotcha: opener-reserves-anchored).
advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
{ kind: 'box', id: 'frame', reserve: false,
style: { borderColor: col('basil'), borderWidth: pt(1.2) },
placement: on('page', 'top-left', FRAME, FRAME,
{ width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) },
text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
left: mm(4), right: mm(4) } } }),
{ kind: 'image', id: 'art', resourceId: 'cover-art',
placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
] } },
};
Der Umschlag reserviert Platz bis zum Fuß seines Gemäldes, 164 mm von der Oberkante der Seite, und ohne minHeight würde der Brief auf der ersten Rasterlinie darunter beginnen, direkt an der Erde. minHeight ist eine etwas tiefer gesetzte Untergrenze: SINK fügt 10 mm Luft unter dem Gemälde hinzu und rundet die Tiefe unterhalb des Kopfstegs auf ganze Zeilen von 13 pt auf, 34 davon, sodass beide Spalten des Briefs auf dem Raster beginnen, 177,9 mm von der Oberkante der Seite. Der Rahmen des Umschlags reicht bis zum Fuß der Seite; reserve: false nimmt ihn aus dieser Rechnung heraus.
Das ganze Rezept
// ═══ Postext Cookbook · Nº 036 · Mail-order catalogue with pictures in cells ═══════ // https://postext.dev/en/cookbook/seed-catalogue // Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models // Fonts: Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // A fictional seed farm's spring list: a table read from TSV, a packet pictured in each row. import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, parseTSV, mergeCells, setAlignment, setCellBackground, setCellContent, setCellImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'seed-catalogue'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // eight named colours; every colour in the config links to one of them ink: '#2d2620', paper: '#f0e2c4', // brown-black text on buff cream: '#fcf7ea', tomato: '#c23b22', // cells to write in; the accent: bands, titles, NEW basil: '#2f6b3b', sun: '#e8b53a', // rules, ORGANIC, the form's head; the cover's banner rule: '#c7ae86', muted: '#6d5f50', // hairlines; running heads and notes }; // col() links a colour to its palette entry; the hex is the value the entry holds. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries(palette) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const TRIM = { w: 190, h: 250 }; // mm: a stapled mail-order booklet const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored: 157 mm wide const LEAD = 13; // pt: the body leading, the grid the headings keep to const TEXT = 'Gelasio', DISPLAY = 'Alfa Slab One', LABEL = 'Cabin Condensed'; const PACKET = 0.55; // the packet picture's share of its cell's inner width const caps = (size, fontWeight = 600) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight, letterSpacing: pt(size * 0.18), textTransform: 'uppercase' }); const on = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement, overflow: 'wrap', ...style }); // a long line takes a second one, in the heads too const chip = (id, size, ink, box) => ({ id, fontFamily: LABEL, bold: true, fontSize: em(size), color: col(ink), borderRadius: pt(1.2), ...box }); const rule = (id, below, gap, thickness) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(thickness), color: col('basil'), placement: { ...on(`#${below}`, 'below', 0, gap), size: { width: 'fill' } } }); // #region answer: a price list from TSV: a packet in each variety's cell, kinds merged down const at = (row, c) => ({ row, col: c }); const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal' function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row let m = { ...parseTSV(tsv, { headerRows: 1 }), columnWidths: [20, 40, 63, 17, 17] }; // mm for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right'); for (let r = 1; r < m.rows.length; r++) { // The picture is a resource of its own; the cell sets it at the top, centred, with the // variety's name under it, and the row grows to hold both. const variety = m.rows[r][1].content; m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET }); m = setCellContent(m, at(r, 1), `**${variety}**`); m = setAlignment(m, at(r, 1), 'center'); for (const c of [3, 4]) { // prices in the label face: lining figures, flush right m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`); m = setAlignment(m, at(r, c), 'right'); } if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above let end = r; while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++; // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid, // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it. m = mergeCells(m, { start: at(r, 0), end: at(end, 0) }); m = setAlignment(m, at(r, 0), 'center', 'middle'); m = setCellBackground(m, at(r, 0), col('cream')); } return m; } // The letter on page 1 cites the list, so it floats to the first free slot after the // letter, page 2, and is cut between rows where the page ends; span 'page' gives it the // full 157 mm, not a 75 mm column. const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table', caption: '**Vegetable Seeds**', placement: { span: 'page' }, note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.', table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 }); // #endregion // #region split: the list's style says what a continued part prints; the forms have their own const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'), rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5), // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts. overflow: 'split', continuedSuffix: '(continued)', continuesMarker: 'Continued on the facing page' }; const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'), bodyFontFamily: LABEL, bodyFontSize: pt(8.5), bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }]; // The caption sits above the table on a tomato band and is repeated on every part. Its note // and the marker take the caption's face, so the caption keeps the body's Gelasio, which has // the italic the suffix and the marker are set in; Cabin Condensed has none. const captionStyle = { fontSize: pt(12), color: col('cream'), position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2), gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } }; // #endregion const run = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity, pages: 'body', ...caps(7.5), color: col('muted'), placement: on('page', edge, x, 12.5), ...extra }); const folio = { fontWeight: 700, color: col('ink') }; const header = { elements: [ // folios outside, the booklet's name on the verso, its issue opposite run('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio), run('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 6), run('recto-title', '{subtitle}', 'odd', 'top-right', -(MARGIN.outer + 6)), run('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, folio), ] }; const footer = { elements: [{ ...run('drop-folio', '{pageNumber}', 'all', 'bottom', 0, folio), pages: 'opener', placement: on('page', 'bottom', 0, -11) }] }; // #region cover: the frontmatter and the heading set the type; the painting fills the middle const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page const FRAME = 8; // mm: the cover's frame, in from the trim const AIR = 10; // mm: at least this much between the painting and the letter const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines const cover = { id: 'cover', span: 'page', footer: { elements: [] }, // The opener reserves down to the painting's foot; minHeight is a floor AIR lower, on the // grid. The frame reaches the page's foot, so it stays out of that count: reserve: false // (gotcha: opener-reserves-anchored). advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [ { kind: 'box', id: 'frame', reserve: false, style: { borderColor: col('basil'), borderWidth: pt(1.2) }, placement: on('page', 'top-left', FRAME, FRAME, { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }, text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }), text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY, fontSize: pt(58), lineHeight: 1, color: col('tomato') }), text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'), box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1), left: mm(4), right: mm(4) } } }), { kind: 'image', id: 'art', resourceId: 'cover-art', placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) }, ] } }, }; // #endregion // #region heads: slab-serif section heads over a basil double rule, and the order sheet const h2 = { level: 2, marginTop: pt(0), marginBottom: pt(0), advancedDesign: { // both open a enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [ // column: no space above text('title', '{titleText}', on('container', 'top-left', 0, 0), { fontFamily: DISPLAY, fontSize: pt(14), lineHeight: 1.1, color: col('tomato') }), rule('thick', 'title', 1.4, 1.2), rule('thin', 'thick', 0.7, 0.4), ] } } }; const order = { id: 'order', span: 'page', layout: { layoutType: 'single' }, // one wide column advancedDesign: { enabled: true, slot: { elements: [ text('address', '{attr.address}', on('container', 'top-left', 0, 0), { ...caps(8), color: col('basil') }), text('title', '{titleText}', on('#address', 'below', 0, 1.5), { fontFamily: DISPLAY, fontSize: pt(34), lineHeight: 1, color: col('tomato') }), rule('thick', 'title', 2.2, 1.6), rule('thin', 'thick', 0.8, 0.5), text('how', '{attr.how}', { ...on('#thin', 'below', 0, 3), size: { width: mm(150) } }, { fontFamily: TEXT, italic: true, fontSize: pt(10.5), lineHeight: 1.35, align: 'left', color: col('ink') }), ] } } }; // #endregion const config = () => ({ locale: 'en-us', // the sample is in English colorPalette, page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(7) }, // two 75 mm columns of letter text bodyText: { fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceBold: false, referenceItalic: true, firstLineIndent: mm(4), indentAfterHeading: false, // ~48 characters to a 75 mm column, so maxWordSpacing: 1.6 }, // a cap under the default 2; Knuth–Plass can exceed it, so reword to fit headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // parity 'any': an H1 opens on the next page, either side (the default waits for a recto). { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, h2] }, headingStyles: [cover, order], paragraphStyles: [ { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], chipStyles: [ // a price is a bare chip: the label face, bold, a little larger than the text chip('price', 1.12, 'ink', { backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }), chip('new', 0.8, 'cream', { background: col('tomato'), borderWidth: pt(0) }), chip('organic', 0.8, 'basil', { backgroundEnabled: false, borderColor: col('basil'), borderWidth: pt(0.6) })], resourceTypes: [{ id: 'list', name: 'Price list', shortLabel: 'List', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no prefix: the // caption is the list's name alone, with no "Table 1". tableStyle, tableStyles, captionStyle, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown-Beispiel · 35 Zeilen · content.en.md
title: "Brindlewood Seed Co." subtitle: "Catalog No. 12 · Spring 2027" --- # Spring Seeds {style="cover"} ## To Our Growers This is our twelfth spring catalog, and it lists only the vegetables we grew out ourselves last summer at Brindlewood Farm, nine of them, each tested for germination in the packing shed this January. You will find each of them, with a picture of its packet, in :ref{id="vegetables" text="the price list"} on the next two pages. Tomatoes open the list, as they have done since our first catalog in 2016. Two varieties are new to it this spring and carry a red :chip[NEW]{style="new"} tag, while four were grown on the certified organic field behind the barn and are marked :chip[ORGANIC]{style="organic"}. The rest came from farms we have bought seed from for years, and each one was grown out beside our own before we listed it. Order early. Last spring the Brandywine and the Jimmy Nardello were sold out by March. We mail seed three days a week from February 1 to May 31. The order sheet is on the back page; tear it out, or copy it by hand and keep the catalog whole. :::paragraphs{style="signature"} *Ada Brindle and Tom Okafor* Brindlewood Farm, Marrow Creek ::: ## From Our Trial Garden Every variety on these pages spent last summer in our trial rows. We set out twenty plants of each and score them for how quickly they come up, how long they bear and whether the fruit comes true to type. A tomato that throws odd fruit on two of its twenty plants is dropped from the list. Each lot was tested in January: a hundred seeds on damp paper in a warm room, counted after one week and again after two, and the figure is printed on the packet. Stored cool and dry in a sealed jar, tomato and squash seed stays good for four years, bean seed for three and pepper seed for two. Onion and parsnip seed keeps for only one year, which is why this list carries neither. # Order Sheet {style="order" address="Mail to Brindlewood Seed Co. · Box 12 · Marrow Creek" how="Write one line for each variety: its number, its name and how many packets or ounces you want. Add the postage, and mail the sheet with a check or money order."} ::resource{id="ship-to"} ::resource{id="order"} :::paragraphs{style="colophon"} Set in Gelasio, Alfa Slab One and Cabin Condensed (SIL OFL) · Text: CC BY 4.0 · Pictures: generated · A fictional seed house :::`; // the letter, the trial notes and the order sheet const list = String.raw`KIND VARIETY DESCRIPTION PACKET OUNCEMarkdown-Beispiel · 9 Zeilen · content.list.en.md
**Heirloom tomatoes** Brandywine *No. 101 · 90 days.* The pink beefsteak a Philadelphia seed house first listed in 1889. Fruit of a pound or more on tall, potato-leaved vines, sweet but with enough bite to taste of tomato. Stake the vines well. :chip[ORGANIC]{style="organic"} 3.95 42.00 Cherokee Purple *No. 102 · 80 days.* Dusky rose fruit of 10 to 12 ounces with green shoulders and brick-red flesh, kept by a Tennessee family long before it reached seed lists in the 1990s. Rich and smoky; slice it thick. 3.95 42.00 Gold Medal *No. 103 · 85 days.* A yellow beefsteak streaked red inside and out, so every slice comes out marbled. Mild and low in acid. Give it the longest season you have. :chip[NEW]{style="new"} 4.25 46.00 Amish Paste *No. 104 · 80 days.* Heart-shaped fruit of 8 to 12 ounces, meaty enough for sauce and juicy enough to eat off the vine. A paste tomato holds less water and fewer seeds, so it cooks down faster. 3.95 38.00 **Frying pepper** Jimmy Nardello *No. 201 · 80 days.* A long, thin-walled Italian frying pepper, sweet and red when ripe; fry it whole in olive oil. Brought from Basilicata to Connecticut in 1887 by the Nardello family. :chip[ORGANIC]{style="organic"} 4.25 48.00 **Beans** Kentucky Wonder *No. 301 · 65 days.* Pole bean. Meaty green pods 7 to 9 inches long, stringless if you pick them young, and in American gardens since the 1870s. Give it a pole or a fence 6 feet tall. :chip[ORGANIC]{style="organic"} 3.50 4.75 Dragon Tongue *No. 302 · 60 days.* A bush bean from the Netherlands. Flat, pale yellow pods streaked violet, tender up to 7 inches long. Cooking turns them plain yellow. :chip[NEW]{style="new"} 3.75 5.25 **Squash** Black Beauty *No. 401 · 50 days.* Zucchini. Glossy green-black fruit on an open bush that is easy to pick over. Cut them at 6 to 8 inches, every other day in July, and the plants keep bearing. On seed lists since the 1920s. 3.50 9.50 Waltham Butternut *No. 402 · 100 days.* Winter squash of 4 to 5 pounds with sweet orange flesh. Its hard, solid stems resist the vine borer that kills other squash, and it keeps until March in a cool room. :chip[ORGANIC]{style="organic"} 3.75 11.00`; // the price list: TSV, as a spreadsheet exports it // #region forms: the order sheet: empty rows to write in, labels on buff, totals merged function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) => widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) }; for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) }); m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff if (r === 0 || !cell.content || cell.hiddenBy) return; m = setCellBackground(m, at(r, c), col('paper')); if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels })); return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' }, table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 }; } const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'], ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]); const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces', 'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'], ['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]), 'We guarantee every packet to grow. If a variety fails to come up in your garden, write to ' + 'us before August 1 and we will send a new packet or refund what you paid for it.'); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build Gelasio: ['400', '400i', '700'], // text, captions, notes and the list 'Alfa Slab One': ['400'], // display: the cover, section heads, the order sheet 'Cabin Condensed': ['400', '600', '700'], // labels: heads, prices, chips, the forms }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: every picture is a resource of its own, a JPEG in assets/ at its pixels // Nothing cites the pictures, so none is numbered or placed: the cover design and the cells // draw them by id, and their typeId is never looked up. const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap', altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 }); const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content); const resources = [vegetableList(list), shipTo, orderForm, picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm 'A sunflower and a staked tomato plant in a ploughed field.'), ...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4 `Seed packet of ${name}.`))]; await prepareFonts(markdown + list, config(), kitFonts(FONTS)); await Promise.all(resources.flatMap(({ bitmap }) => bitmap ? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : [])); // #endregion const doc = await buildDocumentWithFonts({ markdown, resources }, config(), { ...kitFonts(FONTS), text: markdown + list }); showPages(doc, { title: 'Brindlewood Seed Co. · Spring 2027' });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
#Nur behalten, was passt
Mit overflow: 'clip' endet die Liste nach den fünf Sorten, die auf Seite 2 passen, mit der Notiz darunter, und Seite 3 enthält nur die Notizen aus dem Versuchsgarten, in ihrer ersten Spalte.
- overflow: 'split', continuedSuffix: '(continued)',
+ overflow: 'clip', continuedSuffix: '(continued)',#Die Tüten verkleinern
Bei 40 % schrumpfen die Zeilen auf 29 mm, doch die beiden Bohnen teilen sich eine verbundene Zelle und brauchen zusammen 57 mm, mehr als die 44 mm, die auf Seite 2 übrig sind. Die Notizen aus dem Versuchsgarten rücken in diesen Platz nach, und Seite 3 enthält nur den Rest der Liste, mit leerer Seite unterhalb von 157 mm.
-const PACKET = 0.55; // the packet picture's share of its cell's inner width
+const PACKET = 0.4; // the packet picture's share of its cell's inner widthHäufige Fehler
Häufiger Fehler
Ein Auftakt reserviert Höhe bis zu seinem tiefsten an der Seite verankerten Element
Ein Auftakt mit erweiterter Gestaltung reserviert die Höhe seines tiefsten Elements, und an der Seite oder am Anschnitt verankerte Elemente unterhalb der Überschrift zählen mit, sodass Schmuck am Seitenfuß den Text auf die nächste Seite schiebt. Geben Sie einem solchen Element reserve: false (seit postext 1.5): es wird dort gemalt, wo es verankert ist, und reserviert nichts. Elemente, die dem Band selbst folgen (an seinem Fuß verankert oder 'fill' hoch), bestimmen nie seine Höhe. Gestaltete Auftakte →
- Eine Ressource, die niemand zitiert, wird nie platziert. Nehmen Sie das
:refaus dem Brief, und der Katalog hat keine Preisliste. - In Spalten von 75 mm kann Knuth-Plass eine Blocksatzzeile hinterlassen, deren Wortabstände über
maxWordSpacinghinaus gedehnt sind;bodyText.maxJustifyTrackinglässt eine solche Zeile stattdessen etwas Laufweite aufnehmen. Hier wurden Brief und Notizen aus dem Versuchsgarten so lange umformuliert, bis die lockerste Zeile ihre Wortabstände auf das 1,33-Fache ihrer normalen Breite dehnte.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Bilder
- The cover's sunflower and tomato plant, a painting · Generated With Diffusion Models · eigener Text
- The Brandywine seed packet · Generated With Diffusion Models · eigener Text
- The Cherokee Purple seed packet · Generated With Diffusion Models · eigener Text
- The Gold Medal seed packet · Generated With Diffusion Models · eigener Text
- The Amish Paste seed packet · Generated With Diffusion Models · eigener Text
- The Jimmy Nardello seed packet · Generated With Diffusion Models · eigener Text
- The Kentucky Wonder seed packet · Generated With Diffusion Models · eigener Text
- The Dragon Tongue seed packet · Generated With Diffusion Models · eigener Text
- The Black Beauty seed packet · Generated With Diffusion Models · eigener Text
- The Waltham Butternut seed packet · Generated With Diffusion Models · eigener Text
- Schriften
- Gelasio (SIL OFL 1.1) · Alfa Slab One (SIL OFL 1.1) · Cabin Condensed (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


