Zum Hauptinhalt springen
Rezept Nummer 34

Rezepte · Kapitel 7 · Abbildungen und Bilder

Katalogeinträge gegenüber ihren Tafeln

Jeder Eintrag beginnt links und nennt seine Tafel im ersten Satz; die Tafel gleitet auf die rechte Seite gegenüber, skaliert auf den Satzspiegel.

Auf dieser Seite

S. 4–5 von 8

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 230 × 280 mm
  • Anderthalb Spalten, 7 mm Spaltenabstand
  • Ibarra Real Nova 11,5/16
  • Libre Bodoni
  • Sofia Sans Condensed
  • 8 Seiten
  • Stufe
  • Postext 1.31.0
  • In 75 ms gesetzt
  • 246 Zeilen Code

Kurz gesagt

Ein kleiner Ausstellungskatalog mit drei alten Buchillustrationen zum Don Quijote. Jede Beschreibung steht auf einer linken Seite, ihr Bild füllt die rechte Seite gegenüber.

Was Sie setzen

Ein kleiner Katalog für eine Kabinettausstellung mit drei Holzstichen aus Gustave Dorés Don Quixote (Paris, 1863), auf einer Seite von 230 × 280 mm. Der Umschlag setzt DORÉ in Libre Bodoni zu 88 pt auf warmem Schwarz, unter einem gerahmten Ausschnitt der Windmühlentafel. Jeder Eintrag beginnt auf einer linken Seite mit einer sepiafarbenen Katalognummer neben einem kursiven Titel, den Werkangaben in der Außenspalte, einem Vorspann mit dreizeiliger Initiale und dem Kommentar. Der erste Satz des Kommentars nennt die Tafel, die die rechte Seite gegenüber eröffnet, zentriert und so skaliert, dass Tafel, Bildunterschrift und Nachweiszeile den 215 mm hohen Satzspiegel füllen. Die letzte Seite ist eine Werkliste mit einer Miniatur in der ersten Zelle jeder Zeile und der Seite, auf der jede Tafel gedruckt ist.

Dieses Rezept beantwortet

  • Wie gestalte ich einen Kunstkatalog: Eintrag auf der linken Seite, Tafel auf der gegenüberliegenden rechten?
  • Wie bestimme ich, wo eine Abbildung steht: oben auf der Seite, über beide Spalten, genau hier oder am Rand?
  • Wie setze ich Bilder oder Symbole in Tabellenzellen?
  • Wie füge ich einem Auftakt eine Autorenzeile, einen Vorspann oder einen Einstieg mit Initiale hinzu?

Die kurze Antwort

script.js · Zeilen 38–60im vollständigen Code
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  breakBefore: { enabled: true, parity: 'even' }, // an entry opens a verso, facing its plate
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
  dropCap: { lines: 3, fontFamily: DISPLAY, color: col('sepia'), gap: mm(1.6) }, // the lead's
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  // A plate taller than the recto's text block is scaled, proportions kept, until plate, caption
  // and credit line fit it; the caption takes the plate's width, and both are centred.
  defaultPlacement: { position: 'top', span: 'page', align: 'center',
    shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
};
// Under the plate (pt): a caption and a credit line, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 283 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH }, createdAt: 0, updatedAt: 0,
});

Zutaten

Schrift
Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (SIL OFL 1.1)
Dateien
  • library-835.jpg
  • vigil-835.jpg
  • windmills-835.jpg
  • windmills-detail-540.jpg
  • Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, gemeinfrei)
  • Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, gemeinfrei)
  • Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, gemeinfrei)
  • The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px (Gustave Doré · Héliodore Pisan, gemeinfrei)

Zubereitung

#1 · Die Tafel wartet auf die Seite gegenüber

Den Code finden Sie oben unter Die kurze Antwort. Jeder Eintrag beginnt auf einer geraden Seite, und ein Gleitobjekt mit top landet nie auf der Seite, die es zitiert; die im ersten Satz des Kommentars genannte Tafel überspringt also die linke Seite und eröffnet die rechte gegenüber. Die defaultPlacement des Tafeltyps verlangt shrink: 'page': In voller Satzbreite wäre eine Tafel 242 mm hoch, mehr als der Satzspiegel der rechten Seite fasst, also skaliert Postext sie proportional, bis Tafel, Bildunterschrift und Nachweiszeile in den Bereich über dem Abstand des Gleitobjekts passen (Platzierung). minScale: 0.5 ließe eine Tafel bis auf die halbe Breite schrumpfen; hier behält jede etwa neun Zehntel davon. captionMeasure: 'body' setzt Bildunterschrift und Nachweiszeile in der Breite der Tafel, und align: 'center' zentriert beide auf der Seite.

#2 · Der Kopf des Eintrags kommt aus seiner Überschrift

script.js · Zeilen 81–113im vollständigen Code
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt; a multiple of the size
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
// mm: the lead, the entry's first paragraph, 5 mm under the hairline on the next grid line;
// the opener ends a line above it (the level's marginBottom), and the tombstone faces it.
const LEAD_Y = Math.ceil((HEAD_RULE + 5) / PT / LEAD) * LEAD * PT;
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const BASE = 0.8; // a line's first baseline stands 0.8 down its line box
// em: Libre Bodoni's capitals, and its figures, which stop short of them ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [0.754, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT)) * PT;
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y - LEAD * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; the attribute's \n breaks give its three lines.
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, reserve: false }), // beside the lead: no room of its own
] } });

Jede Überschrift # trägt Kapitel und Werkangaben als Attribute, und der Design-Slot des Auftakts zeichnet sie. Die große Zahl ist die eigene {number} der Überschrift aus numberingTemplate: '{1}'; bei einer Tafel pro Eintrag läuft sie 1, 2, 3 im Gleichschritt mit dem Zähler Cat. der Tafeln. Der Vorspann ist der erste Absatz des Eintrags, in einer Gruppe :::paragraphs{style="lead"} zu 13,5 pt, und das dropCap der Ebene eröffnet ihn mit einer dreizeiligen sepiafarbenen Initiale, deren Oberkante an die Versalien der ersten Zeile stößt und deren Fuß auf der dritten Grundlinie steht, beide Versalhöhen aus den Schriften gemessen (Initialen). Der Vorspann steht im Flattersatz: Im Blocksatz würden sich bei dieser Größe und wenigen Wörtern pro Zeile die Wortabstände weiter öffnen als die 1,5-fache Grenze des Kommentars. minHeight beendet den Auftakt eine Zeile über LEAD_Y, der Rasterlinie, auf der der Vorspann beginnt; die Werkangaben stehen der ersten Zeile des Vorspanns gegenüber, ohne eigenen Platz zu beanspruchen (reserve: false), und ein :::space unter dem Vorspann lässt den Kommentar eine Zeile tiefer bündig beginnen. TITLE_Y setzt den Titel 1,2 mm tiefer als die Ziffer, was seine Versalien mit den Ziffern der Zahl fluchten lässt: In Libre Bodoni sind die Ziffern 0,716 em hoch und die Versalien 0,754 em.

#3 · Den Master angeben, eine kleinere Kopie zeichnen

script.js · Zeilen 316–333im vollständigen Code
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
  const res = await fetch(asset(file));
  if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
  return createImageBitmap(await res.blob());
}
async function loadPlates() {
  const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
  const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
    'Detail of Cat. 3: the knight and his horse caught on the sail.')];
  for (const [i, p] of PLATES.entries()) {
    registerResourceImage(p.file, onPaper(scans[i]));
    resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
      picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
  }
  return resources;
}

Postext bemisst eine Bitmap nach den Pixeln, die sie angibt, bei der dpi-Zahl des Dokuments, und druckt sie nie breiter, als diese Pixel erlauben. plate() gibt den Master jeder Tafel an, den Commons-Scan auf 2.400 px Höhe verkleinert: 1.900 × 2.400 px für Cat. 1, was bei page.dpi: 150 bis 322 mm reichen könnte. Die Größe bestimmt also der Satzspiegel mit 215 mm Höhe, und in dieser Höhe ergeben 2.400 px etwa 283 dpi. Bei den vorgegebenen 300 dpi bliebe die Tafel bei 161 mm Breite stehen. Der Canvas zeichnet in diesen Rahmen, welches Bild auch immer registriert ist, daher lädt der Pen die drei Kopien mit 835 px aus assets/, 0,97 MB gegenüber 5,8 MB für die Master. Aus den Mastern mit 2.400 px gezeichnet, füllen sich die Himmel mit Moiré-Streifen, und die Bilder der Aufnahme wachsen um etwa 0,3 MB, über die Grenze des Rezeptbuchs von 1,4 MB hinaus.

#4 · Ein Umschlag aus einem Überschriftenstil

script.js · Zeilen 117–141im vollständigen Code
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more) => text(id, content, family,
  size, color, at('page', 'top', 0, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // Tracked capitals centre on their letters: the tracking after the É is left out.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});

Der Umschlag ist die erste Überschrift, # Doré {style="cover" …}, gezeichnet von einem Stil ohne Pagina. Sein Bildelement zeichnet windmills-detail-540.jpg, registriert als Ressource, die kein Absatz zitiert, sodass sie weder Nummer noch Bildunterschrift erhält. Die Datei ist ein Quadrat von 1.100 px aus dem Windmühlenscan, verkleinert auf 540 px, seine Breite auf einer Seite von 1.000 px. Der Name ist um 8 pt gesperrt, und die Sperrung hinter seinem letzten Buchstaben bleibt bei der Zentrierung unberücksichtigt, sodass die vier Versalien ohne Versatz auf der Mittelachse der Seite stehen.

#5 · Miniaturen in den Zellen der Werkliste

script.js · Zeilen 145–167im vollständigen Code
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false, dropCap: false, // no initial
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };

Das image einer Zelle passt eine Bitmap-Ressource an die Innenbreite der Zelle an, hier 23 mm. Jede Miniatur ist eine eigene Kopie mit 300 px, in dieser Größe angegeben, statt der beim Zeichnen verkleinerten Tafel mit 835 px. Die Tabelle hat keinen Legendentext und ihr Typ ein leeres Legendenpräfix, also wird keine Legende gezeichnet. Der Stil der Werkliste setzt nur numbered: false und sein Design und übernimmt die Spannweite über die Seite, den Umbruch auf eine linke Seite und den unteren Rand von Ebene 1.

#6 · Anhalten, wenn ein Eintrag über seine Seite hinauswächst

script.js · Zeilen 349–354im vollständigen Code
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
  const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
  kitFail(error); // the viewer's bar says why
  throw error;
}

Ein Eintrag, der über seine linke Seite hinausläuft, schickt seine Tafel trotzdem auf die nächste Seite. Der Rest des Kommentars folgt dann der Tafel, und eine Vakatseite hält den nächsten Eintrag auf einer linken Seite, sodass jede spätere Tafel zwei Seiten nach der Seite gedruckt wird, die die Werkliste für sie angibt. Die Prüfung hält den Build bei der ersten Seite an, die kein Auftakt ist und keine Tafel enthält, und zeigt deren Nummer in der Leiste des Betrachters.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 034 · Catalogue entries facing their plates ══════════════
// https://postext.dev/en/cookbook/catalogue-facing-plates
// Code: MIT · Text: original (CC BY 4.0), Ormsby 1885 (PD) · Plates: Doré and Pisan, 1863 (PD)
// Fonts: Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'catalogue-facing-plates';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1b1918', paper: '#faf7f1', night: '#2a2724', // text; the page and plates; the cover
  sepia: '#8a6a45', gilt: '#c9ad86', // the accent on paper (4.6:1) and on the night (6.9:1)
  rule: '#cfc6b8', muted: '#6c665e', // hairlines; tombstones, credit lines, folios
};
// col(id): a colour linked to its palette entry, with the entry's hex beside the id.
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' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Ibarra Real Nova', 'Libre Bodoni', 'Sofia Sans Condensed'];
const PT = 25.4 / 72; // mm per point
const [SIZE, LEAD, LINES] = [11.5, 16, 41]; // body pt, leading pt; 41 lines make the text block
const TRIM = { w: 230, h: 280 }; // mm: a catalogue trim
const [TOP, INNER, OUTER] = [24, 20, 18]; // margins, mm
const BLOCK_W = TRIM.w - INNER - OUTER; // 192 mm: the text block, and the widest plate
const BLOCK_H = LINES * LEAD * PT; // 231.4 mm
const [SIDE_PC, GUTTER] = [34, 7]; // the outer column: 65.3 mm for numeral and tombstone; mm
const SIDE = (BLOCK_W * SIDE_PC) / 100;
const MAIN_X = SIDE + GUTTER; // mm from the block's outer edge to the text column
const MAIN = BLOCK_W - MAIN_X; // 119.7 mm: the measure, about 71 characters

// #region answer: a Plate type that floats to the head of the facing recto, sized to fill it
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  breakBefore: { enabled: true, parity: 'even' }, // an entry opens a verso, facing its plate
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
  dropCap: { lines: 3, fontFamily: DISPLAY, color: col('sepia'), gap: mm(1.6) }, // the lead's
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  // A plate taller than the recto's text block is scaled, proportions kept, until plate, caption
  // and credit line fit it; the caption takes the plate's width, and both are centred.
  defaultPlacement: { position: 'top', span: 'page', align: 'center',
    shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
};
// Under the plate (pt): a caption and a credit line, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 283 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH }, createdAt: 0, updatedAt: 0,
});
// #endregion
const MASTER_PX = { library: [1900, 2400], vigil: [1921, 2400], windmills: [1923, 2400] };
const CREDIT = 'Public domain; scan from Wikimedia Commons.'; // the artists are in the tombstone
// A picture drawn only in a design slot or a table cell: registered, never cited or numbered.
function picture(id, image, altText) {
  registerResourceImage(`${id}.jpg`, image);
  return { id, typeId: 'plate', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}.jpg`, format: 'jpeg', width: image.width, height: image.height } };
}

// Design-slot shorthands; every text wraps, the folio lines too.
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) });
const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text',
  id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...more });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.2) });

// #region opener: the entry's head, a numeral and tombstone beside the title and the lead
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt; a multiple of the size
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
// mm: the lead, the entry's first paragraph, 5 mm under the hairline on the next grid line;
// the opener ends a line above it (the level's marginBottom), and the tombstone faces it.
const LEAD_Y = Math.ceil((HEAD_RULE + 5) / PT / LEAD) * LEAD * PT;
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const BASE = 0.8; // a line's first baseline stands 0.8 down its line box
// em: Libre Bodoni's capitals, and its figures, which stop short of them ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [0.754, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT)) * PT;
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y - LEAD * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; the attribute's \n breaks give its three lines.
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, reserve: false }), // beside the lead: no room of its own
] } });
// #endregion

// #region cover: a heading style on warm black, a framed detail of Cat. 3 and the name
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more) => text(id, content, family,
  size, color, at('page', 'top', 0, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // Tracked capitals centre on their letters: the tracking after the É is left out.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});
// #endregion

// #region checklist: a table of the works with a thumbnail in each first cell
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false, dropCap: false, // no initial
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
// #endregion

const foot = (id, content, parity, edge, x, more) => text(id, content, LABEL, 7.5, 'muted',
  at('page', edge, x, TRIM.h - 15), { ...caps(7.5), parity, ...more });
const footer = { elements: [ // folios at the outer foot; entries (versos) add the title
  foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }),
  foot('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER,
    { color: col('ink'), align: 'right' }),
] };

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette, resourceTypes: [plateType, listType],
  // A bitmap is never set wider than its declared pixels at this dpi: at 150 dpi a 1,900-px
  // scan may reach 322 mm, so the text block decides (at 300 dpi it stops at 161 mm).
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.h - TOP - BLOCK_H), left: mm(INNER),
      right: mm(OUTER), mirror: true } },
  // Body text never enters the outer column: the openers draw in it and the plates span it.
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: SIDE_PC, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('sepia'), referenceBold: false, // 'Cat. 1' in the accent, regular weight
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.5,
  },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [entryLevel()] },
  headingStyles: [coverStyle(), checklistStyle()],
  captionStyle: { fontFamily: LABEL, fontSize: pt(CAPTION.size), gap: pt(CAPTION.gap),
    labelColor: col('sepia'), // the label is bold by default
    note: { fontSize: pt(CAPTION.note), gap: pt(CAPTION.noteGap), color: col('muted') } },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    cellPadding: mm(1.8), headerBackground: col('ink'), headerColor: col('paper'),
    headerFontFamily: LABEL, headerFontSize: pt(8), bodyFontFamily: TEXT, bodyFontSize: pt(9.5) },
  paragraphStyles: [
    // The entry's lead, a size larger and ragged; a :::space under it starts the commentary flush.
    { id: 'lead', fontSize: pt(LEADIN.size), lineHeight: pt(LEADIN.lead), textAlign: 'left',
      firstLineIndent: pt(0) },
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header: { elements: [] }, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown-Beispiel · 61 Zeilen · content.en.mdtitle: "Doré: Three Plates for Don Quixote" author: "The Print Room" --- # Doré {style="cover" kicker="The Print Room · A cabinet exhibition" subtitle="Three plates for Don Quixote, 1863" foot="Wood engravings by Héliodore Pisan after Gustave Doré"} # Don Quixote \\ in His Library {chapter="Part I · Chapter I" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"} :::paragraphs{style="lead"} Doré opens the book with its hero wide awake. Seated upright in a carved chair, the hidalgo holds up a sword in his right hand and a book in his left, while everything he has read comes loose from the page and fills the room around him. ::: :::space The plate (:ref{id="library"}, opposite) sets out the sentence in which the hidalgo loses his wits. In John Ormsby’s translation his fancy grew full of ‘enchantments, quarrels, battles, challenges, wounds, wooings, loves, agonies, and all sorts of impossible nonsense’, and Doré draws the list almost item by item. Knights ride out from behind the chair, a dragon uncoils above a shield of arms, a knight no taller than a folio stands on a book beside a giant’s severed head, and a lady in chains kneels at the fore-edge of a volume lettered AMADIS. Cervantes says the hidalgo ‘sold many an acre of tillageland to buy books of chivalry to read’, and Doré scatters them about the room: open on the table under the window, fallen open at his feet, and one propped on its edge at the lower left, beside the giant’s head. Daylight comes in at the window on the left and falls on his face and on the open page. The figures crowding the rest of the room have come out of the books, down to the two knights no taller than a hand who joust on the backs of mice among the flagstones. Doré gives these phantoms the same firm outline as the man who imagines them. In wood engraving whatever the tool cuts away prints white, so Pisan had to turn each of Doré’s greys into a pattern of strokes: close parallel cuts for the heavy curtain, finer and more open ones for the light on the wall, broken curls for the smoke of figures behind the chair. Both men signed the block, Doré in script at the lower left and Pisan in capitals at the lower right. # The Vigil \\ of Arms {chapter="Part I · Chapter III" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"} :::paragraphs{style="lead"} The innkeeper has no chapel, so Don Quixote keeps his vigil of arms in the yard. Doré makes the scene a nocturne: the armour stands on the well-head, and the bareheaded knight lifts his lance towards a full moon breaking through cloud. ::: :::space The plate (:ref{id="vigil"}, opposite) follows the text closely. The innkeeper has told his guest that armour may be watched anywhere, so Don Quixote lays his on a trough beside the well and paces up and down in front of it. In Ormsby’s translation the night closes in ‘with a light from the moon so brilliant that it might vie with his that lent it’. Doré stands the armour upright on the well-head, helmet and breastplate and gauntlets, so that it reads as a second knight facing the thin man in his shirt who will wear it. On the right the inn is a dark mass with an outside stair, and a figure watches from its landing: in the novel the guests ‘flocked to see it from a distance’. Water lies in the ruts of the yard and catches the light, and the knight’s shadow runs across it towards the viewer. Cervantes plays the vigil as farce. Two carriers come in turn to water their mules, each goes to move the armour off the trough, and each is laid out with the lance; the landlord then hurries the dubbing through before anyone else is hurt. Doré leaves all of that out and keeps the hour before it, when the knight is alone with his arms and the moon. In this sky Pisan widens his level cuts as they near the moon, until the black left between them thins to hairlines around the halo. Short flicks pick out the edges of the clouds. A dark bar of cloud crosses the disc on the left, and a few faint strokes shade the moon’s face. # The Adventure \\ of the Windmills {chapter="Part I · Chapter VIII" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"} :::paragraphs{style="lead"} Doré passes over the charge and draws the moment after it. The turning sail has caught the lance and lifted horse and rider clear of the plain, and Rocinante’s legs thrash at the empty air below them. ::: :::space The plate (:ref{id="windmills"}, opposite) illustrates the sentence in which the charge ends. As the knight drove his lance into the sail, in Ormsby’s words, ‘the wind whirled it round with such force that it shivered the lance to pieces, sweeping with it horse and rider, who went rolling over on the plain’. Doré stops before the fall. The broken lance is still caught in the canvas, and the knight hangs tangled against the sail with his shield flung out beside him. The composition is built on one diagonal. The sail runs from the top left corner towards the centre, the horse hangs from it, and the line carries on down the slope to the thistles in the foreground. Against it Doré sets the level plain of La Mancha, where Cervantes counts ‘thirty or forty windmills’ and the plate lines up a row of them along the horizon, turning in the same wind. Sancho, hatless, throws up one arm and clutches his head with the other; his hat lies on the ground and his ass waits behind him. He had warned his master, in Ormsby’s version, that ‘what we see there are not giants but windmills, and what seem to be their arms are the sails that turned by the wind make the millstone go’. Pisan cuts the sky in long even lines and the patched canvas of the sail in torn, crossing strokes. The mills on the horizon are drawn with a few lines each. # Works in \\ the Exhibition {style="checklist" kicker="Checklist" range="1–3"} All three plates come from the first volume of *L’Ingénieux Hidalgo Don Quichotte de la Manche*, in the French translation by Louis Viardot, published in two folio volumes by L. Hachette et Cie, Paris, in 1863 with 377 compositions by Gustave Doré, 120 of them full-page. Héliodore Pisan engraved the three plates on wood, and both men signed them in the block. Quotations are from John Ormsby’s English translation of 1885. ::resource{id="checklist"} :::paragraphs{style="colophon"} Set in Ibarra Real Nova, Libre Bodoni and Sofia Sans Condensed (SIL Open Font License). Commentary written for this catalogue under a CC BY 4.0 licence. The plates are in the public domain and reproduced from scans on Wikimedia Commons; the cover shows a detail of Cat. 3. Ormsby’s translation is Project Gutenberg eBook #996. :::
`; // content.<lang>.md, inlined by the Cookbook // id, scan file, caption, the chapter it illustrates and alt text: one block per plate. const plateTexts = String.raw`library
Markdown-Beispiel · 16 Zeilen · content.plates.en.mdlibrary-835.jpg Don Quixote in His Library Part I, ch. I: ‘which treats of the character and pursuits of the famous gentleman Don Quixote of La Mancha’ Don Quixote sits upright in a carved chair, a book in his left hand and a sword raised in his right, surrounded by the figures of his reading: knights on horseback, a dragon, a giant’s severed head, a lady in chains and two tiny knights jousting on mice. vigil vigil-835.jpg The Vigil of Arms Part I, ch. III: ‘wherein is related the droll way in which Don Quixote had himself dubbed a knight’ A moonlit inn yard. Don Quixote, bareheaded and holding a lance and a buckler, looks up at the moon; his armour stands propped on the well-head facing him, and a figure watches from the inn’s outside stair. windmills windmills-835.jpg The Adventure of the Windmills Part I, ch. VIII: ‘the terrible and undreamt-of adventure of the windmills’ A windmill’s sail sweeps Don Quixote and Rocinante off the ground, the knight tangled against the canvas and his buckler flung out; a row of mills stands on the horizon and Sancho, hatless, throws up one arm and clutches his head beside his ass.
`; const FIELDS = ['id', 'file', 'caption', 'illustrates', 'altText']; const PLATES = plateTexts.trim().split(/\n\s*\n/).map((block) => Object.fromEntries(block.split('\n').map((line, i) => [FIELDS[i], line.trim()]))); // #region art: the engravings printed on the page's paper // Multiplying by the paper colour turns the scan's white into the page's cream. function onPaper(source) { const canvas = new OffscreenCanvas(source.width, source.height); const ctx = canvas.getContext('2d'); ctx.drawImage(source, 0, 0); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = palette.paper; ctx.fillRect(0, 0, source.width, source.height); return canvas.transferToImageBitmap(); } const resized = (scan, width) => createImageBitmap(scan, { resizeWidth: width, resizeQuality: 'high' }); // #endregion // #region scans: the plates, their thumbnails and the cover's detail, fetched from assets/ // plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX); // the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box. async function scan(file) { const res = await fetch(asset(file)); if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`); return createImageBitmap(await res.blob()); } async function loadPlates() { const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan)); const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail), 'Detail of Cat. 3: the knight and his horse caught on the sail.')]; for (const [i, p] of PLATES.entries()) { registerResourceImage(p.file, onPaper(scans[i])); resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText)); } return resources; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Ibarra Real Nova': ['400', '400i'], // text, the lead, the checklist 'Libre Bodoni': ['400', '400i'], // numerals, titles, drop caps, the cover 'Sofia Sans Condensed': ['400', '600', '700'], // labels, tombstones, captions, folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const words = `${markdown}\n${plateTexts}\n${CREDIT}`; // their letters decide the font subsets const [, resources] = await Promise.all([ prepareFonts(words, config(), kitFonts(FONTS)), loadPlates()]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); // #region check: every page past an opener holds a plate, so each plate faces its entry const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length); if (astray) { // text run past its verso, or the blank page that follows it const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`); kitFail(error); // the viewer's bar says why throw error; } // #endregion showPages(doc, { title: t({ en: 'Catalogue entries facing their plates' }) });
Kit · core, fonts, viewer: in jedem Rezept gleich · 224 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 ───────────────────────────────────────────────────────────────────────

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

#Luft unter den Tafeln lassen

Ein Breitenanteil verschmälert das Gleitobjekt selbst: Bei 78 % des Satzspiegels ist jede Tafel etwa 188 mm hoch, zentriert, mit etwa 27 mm leerem Satzspiegel unter ihrer Nachweiszeile, und keine muss verkleinert werden.

-  defaultPlacement: { position: 'top', span: 'page', align: 'center',
-    shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
+  defaultPlacement: { position: 'top', span: 'page', align: 'center', width: 0.78 },

#Abbildungen in die Außenspalte setzen

Für Abbildungen und Glossen, die sich in einer Außenspalte nur für Gleitobjekte neben dem zitierenden Absatz stapeln, siehe das Lehrbuch mit Randspalte.

Häufige Fehler

Häufiger Fehler

Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen

Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen. Platzierung von Abbildungen →

Häufiger Fehler

Bitmaps beziehen ihre Druckgröße aus einer Auflösung: diese angeben, nicht Pixel in Druckgröße

Eine Bitmap ohne Auflösung wird mit einem Pixel pro Layoutpixel bei der dpi des Dokuments gesetzt, sodass ein Foto mit 2400 px auf einer Seite mit 150 dpi 406 mm breit ist. Geben Sie die echten Pixel der Datei an und dem Bild seine Auflösung (bitmap.resolution: 300), oder setzen Sie layout.bitmapResolution für jede Bitmap: einen ppi-Wert oder 'file', um den in der Datei angegebenen zu lesen. Die Spalte begrenzt das Bild weiterhin. Abbildungen und Tabellen als Ressourcen →

Sandbox-Prüfung · bitmapTooSmall

Bild mit geringer Auflösung

Ursache. Eine Bitmap wird mehr als 1,5-mal breiter gezeichnet als ihre Pixel, sie wirkt im Druck daher unscharf.

Lösung. Liefern Sie etwa 300 dpi in der Druckgröße und geben Sie die tatsächliche Breite und Höhe der Bitmap an. Doku →

Nachweise

Text
Bilder
Schriften
Ibarra Real Nova (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Sofia Sans Condensed (SIL OFL 1.1)
Sandbox