Kurz gesagt
Ein Landschaftsfotobuch, das einen Tag in den Bergen in sechs Bildern zeigt. Es zeigt, wie man ganze Seiten bis an den Rand mit Fotos füllt und ein Bild über zwei gegenüberliegende Seiten zieht.
Was Sie setzen
Sierra, ein Landschaftsfotobuch auf einer Seite von 280 × 210 mm: ein Tag in einem Granitgebirge in sechs Tafeln, vom ersten Licht bis zum Morgen nach dem ersten Schnee. Jede Tafel füllt ihre Seite bis zum Endformat und trägt nur eine kleine Ziffer und die Uhrzeit in der linken unteren Ecke; die erste trägt außerdem den Titel, „SIERRA“ in Syne zu 72 pt, weiß aus dem Morgenhimmel ausgespart. Das Gewitter läuft als ein Bild über den Bund der Seiten 4 und 5. Drei kurze Texte, in einer Spalte von 100 mm nahe am Bund gesetzt, stehen den Tafeln gegenüber, die auf sie folgen. Der Text zum Gewitter endet über einer kleineren Abendtafel, die unter ihm gleitet und ihre Ziffer in der Bildunterschrift trägt. Die letzte Tafel zeigt den Kessel der zweiten vom selben Felsblock aus, unter Schnee. Die Tafeln sind mit Diffusionsmodellen erzeugte Fotografien; die Schneetafel entstand als Bearbeitung der Mittagstafel, damit die beiden Ansichten übereinstimmen.
Dieses Rezept beantwortet
- Wie gebe ich jedem Kapitel ein eigenes Foto, eine eigene Farbe oder eine eigene Auftaktvariante?
- Wie füge ich gezielt Vakatseiten ein oder beginne einen Abschnitt auf einer neuen Doppelseite?
- Wie blende ich Kolumnentitel auf Auftakten und Vakatseiten aus oder färbe eine leere linke Seite in der Teilfarbe?
- Wie setze ich nicht nummerierte Grafiken: Ornamente, Vignetten, Logos?
Die kurze Antwort
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
{ id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
{ id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
{ id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
{ id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
{ id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
id, span: 'page', // an opener: a span heading always starts a page of its own
// The left half opens on an even page, a verso, so the right half faces it across the
// gutter (gotcha: parity-page1-recto).
...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
// The picture is as deep as the page, more than the column holds: the plate claims the
// whole page, and what follows starts on the next one.
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
{ width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
] } },
});
Zutaten
- Funktionen
- Bilder in SeitengestaltungenÜberschriftenformateGestaltete AuftakteGestaltungselemente verankernAbschnittsgeometrieÜberschriftenattributeKapitel, die rechts beginnenKopfzeilen nach SeitenrolleVerweise, die Abbildungen platzierenEigene RessourcentypenFormat der BildunterschriftUmschläge, Titelseiten und ImpressumPapierfarbeGespiegelte RänderText, Linien und Kästen in SeitengestaltungenSilbentrennung und DokumentspracheSemantische FarbpaletteAbbildungen und Tabellen als RessourcenSeiten auf einem Canvas
- Schrift
- Andada Pro, Syne, Syne Mono (SIL OFL 1.1)
- Dateien
alba-1600.jpgatardecer-2000.jpgmediodia-1600.jpgnieve-1600.jpgnoche-1600.jpgtormenta-2400.jpg- Plate I, dawn: ridges fading into a peach haze under a dark blue sky (Generated With Diffusion Models, eigener Text)
- Plate II, noon: a granite cirque and its lake (Generated With Diffusion Models, eigener Text)
- Plate III, storm: rain, lightning and a break of sun across a spread (Generated With Diffusion Models, eigener Text)
- Plate IV, dusk: the crest lit orange under a violet sky (Generated With Diffusion Models, eigener Text)
- Plate V, night: stars over the ridges and a lit refuge (Generated With Diffusion Models, eigener Text)
- Plate VI, snow: the cirque of plate II the morning after (Generated With Diffusion Models, eigener Text)
Zubereitung
#1 · Ein Überschriftenformat je Tafel, aus einer Liste gebaut
Der Code steht oben in der kurzen Antwort. plate() baut aus jeder Zeile von PLATES ein Überschriftenformat, und das Markdown legt die Reihenfolge des Essays fest, eine Zeile je Tafel: # Night {style="lamina" art="noche" n="V" hora="22:10"}. Die resourceId des Bildelements ist '{attr.art}', ein Platzhalter wie in einem Text; so teilen sich alle einfachen Tafeln das Format lamina und nennen ihr Bild in ihrer Überschriftenzeile, und nur der Umschlag und die letzte Tafel, die Zusätzliches zeichnen, behalten ein eigenes Format (Bildelemente). span: 'page' beginnt jede Tafel auf einer eigenen Seite. Das Bild ist 210 mm hoch, so hoch wie die Seite, und ein Design reserviert Raum bis zum Fuß seiner Bilder. Das ist mehr als die Spalte von 158 mm der Textseiten; die Tafel beansprucht also die ganze Seite, und was ihr folgt, beginnt auf der nächsten, ohne minHeight und ohne eigene Ränder.
Die beiden Formate der Doppelseite, pliego und pliego-recto, zeichnen dasselbe Bild von 560 mm, hier art="tormenta": die linke Hälfte an der linken Kante der Seite, die rechte Hälfte 280 mm weiter links, sodass jede Seite ihre eigene Hälfte zeigt und der Canvas den Rest am Endformat abschneidet. parity: 'even' hält die beiden Hälften einander gegenüber. Ohne diese Einstellung würde das Entfernen der Mittagstafel aus dem Markdown die linke Hälfte auf Seite 3, eine rechte Seite, und die rechte Hälfte umseitig auf Seite 4 setzen; mit ihr bleibt Seite 3 leer, und das Gewitter füllt weiterhin die Seiten 4 und 5.
#2 · Ziffer und Uhrzeit stammen aus der Überschriftenzeile
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
{ kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
fontSize: pt(NUMERAL.size), color: colour, align: 'left',
placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
{ kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
Ziffer und Uhrzeit sind Attribute der Überschrift; der Inhalt bleibt also im Markdown, und ein einziges Design dient jeder Tafel. Die Uhrzeit hängt right-of an der Ziffer, was ihre Oberkanten ausrichtet. Die Grundlinie eines Designtexts liegt 0,8 seiner Zeilenhöhe unter der Oberkante des Elements, und die Zeilenhöhe beträgt standardmäßig das 1,2-Fache des Schriftgrads; dropTo(13, 8) senkt die Uhrzeit daher um 0,8 × 1,2 × (13 − 8) pt und setzt die Uhrzeit zu 8 pt auf die Grundlinie der Ziffer zu 13 pt, 193,4 mm unter der Oberkante der Seite. Die Uhrzeit beginnt mit einem Mittelpunkt, wie in der Bildunterschrift von Tafel IV, weil das I der Syne ein einfacher Strich ist und allein neben der Uhrzeit wie eine Linie wirkt. Die rechte Hälfte des Gewitters zeichnet keine Bildunterschrift, und die Schneetafel setzt ihre Bildunterschrift in der Textfarbe, weil ihr Vordergrund weiß ist.
#3 · Titel und Impressum auf den Tafeln selbst
const cover = (colour) => [
{ kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
{ kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
color: colour, align: 'right',
placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
Die erste Tafel ist der Umschlag. {title} und {subtitle} lesen das Frontmatter, und extra in PLATES fügt beide Elemente dem Design dieser Tafel hinzu, in der Farbe ihrer Bildunterschrift. Das Impressum kommt auf dieselbe Weise auf die letzte Tafel, in der Textfarbe auf dem Schnee: zwei Elemente zu 7,5 pt, eines je Zeile, aus den Attributen colofon und tipos, rechtsbündig, das zweite auf der Grundlinie der Ziffer. Ein einziges umbrechendes Element würde dort umbrechen, wo seine Breite endet; mit einem Element je Zeile fällt der Umbruch hinter die Lizenz.
#4 · Texte folgen ihren Tafeln ohne Umbruch
const textOpener = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
letterSpacing: pt(0.8), color: col('accent'), align: 'left',
placement: at('container', 'top-left', 0, 0) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
fontSize: pt(28), lineHeight: 1.05, // a multiple of the size
color: col('ink'), align: 'left', overflow: 'wrap',
placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
foot('folio', '{pageNumber}', { color: col('ink'),
placement: at('container', 'top-left', 0, FOOT) }),
foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
Die Tafel vor einem Text füllt ihre Seite, also beginnt der Text ohne eigenen Umbruch auf der nächsten Seite. Eine Seite, deren erster Block eine Überschrift mit Umbruch ist, ist eine Auftaktseite, und pages: 'body' der Pagina lässt sie aus. Ohne Umbruch behalten die Textseiten ihre Pagina; die Tafeln, die von Überschriften über die ganze Seitenbreite eröffnet werden, bekommen keine. breakBefore: { enabled: false } schaltet den Umbruch auf eine rechte Seite aus, den eine Überschrift der Ebene 1 standardmäßig hat; mit diesem Umbruch wäre die Seite jedes Texts eine Auftaktseite, ohne Pagina. FOOT setzt die Pagina auf die Grundlinie der Tafelziffern, 193,4 mm unter der Oberkante der Seite, sodass die Pagina eines Texts und die Ziffer der gegenüberliegenden Tafel auf einer Linie stehen.
#5 · Jede Tafel deklarieren, nur die gleitende zitieren
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
resetOn: 'never', counterFormat: 'decimal' };
// JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi).
const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap',
bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
createdAt: 0, updatedAt: 0, altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.',
es: 'Ocho crestas que se pierden en la bruma del alba.' }),
picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.',
es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning '
+ 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
+ 'izquierda; un rayo y un claro de sol a la derecha.' }),
picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.',
es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.',
es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
// 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure.
picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.',
es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
{ caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];
Das Bildelement eines Designs zeichnet eine deklarierte Ressource; alle sechs Bilder stehen daher in resources: JPEGs im Ordner assets/ des Rezepts, jedes auf die Form der Seite oder Doppelseite zugeschnitten, die es füllt, und mit seiner Pixelgröße deklariert, denn nur diese liest das Layout. loadImage() registriert jede Datei vor dem Build. Tafel IV ist die einzige, die mit :ref zitiert wird, und damit die einzige, die gleitet. Sie nimmt den ersten freien Platz nach dem Zitat, auf der Seite ihres Texts, eine Zeile unter dem letzten Absatz. Ein nummerierter Typ würde sie mit „Plate 1“ beschriften, da sie die einzige gleitende Ressource ihres Typs ist. lamina hat stattdessen ein leeres numberingTemplate, und die Bildunterschrift selbst beginnt mit IV. Im Satz nennt das text von :ref die Tafel, weil ein bloßes :ref nur das Wort „plate“ druckt.
Das ganze Rezept
// ═══ Postext Cookbook · Nº 068 · Photo essay with full-bleed plates ═══════════════ // https://postext.dev/en/cookbook/photo-essay-full-bleed // Code: MIT · Text: original (CC BY 4.0) · Plates: diffusion models // Fonts: Andada Pro, Syne, Syne Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // Sierra, a landscape photobook: one day in a mountain range in six plates, each on a page of // its own and one across the gutter of a spread, with three short texts between them. import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'photo-essay-full-bleed'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: the paper between the plates, the ink and one rust for the labels const palette = { ink: '#1c1f27', // text: the blue-black of the night plate paper: '#f3f0ea', // every page's ground: a pale stone, seen only between the plates accent: '#94462e', // the times over each text: the dusk plate's rust, dark enough for 8 pt muted: '#5f646d', // the running foot and the caption of the small plate white: '#fbfaf7', // type set on the plates }; // 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' } })); // #endregion const PAGE = { width: 280, height: 210 }; // a landscape photobook, in mm const MARGIN = { top: 28, bottom: 24, inner: 40, outer: 140 }; // text pages: a 100 mm measure const LEAD = 15; // body leading in pt const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); // #region answer: the plates' heading styles, generated from a list // '{attr.art}' is filled in per heading, so a plain plate names its picture on its line: // # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras. const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread: { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) }, ]; const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({ id, span: 'page', // an opener: a span heading always starts a page of its own // The left half opens on an even page, a verso, so the right half faces it across the // gutter (gotcha: parity-page1-recto). ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }), // The picture is as deep as the page, more than the column holds: the plate claims the // whole page, and what follows starts on the next one. advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture, placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) }, ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)), ] } }, }); // #endregion // #region caption: the plate's numeral and hour, lower left, from the heading's attributes const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt // A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside // a larger one this much lower and the two share a baseline. const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72; const caption = (colour) => [ { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700, fontSize: pt(NUMERAL.size), color: colour, align: 'left', placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) }, { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8), letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) }, ]; // #endregion // #region cover: the book's title reversed out of the dawn sky of the first plate const cover = (colour) => [ { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800, fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase', color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) }, { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono', fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour, align: 'left', placement: at('#title', 'below', 1.5, 3) }, ]; // The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and // the second line on the baseline of the plate's numeral. const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 }; const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key, content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2), color: colour, align: 'right', placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) })); // #endregion // #region texts: a text between plates opens on the next page with its hours above it const textOpener = { enabled: true, slot: { elements: [ { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8), letterSpacing: pt(0.8), color: col('accent'), align: 'left', placement: at('container', 'top-left', 0, 0) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700, fontSize: pt(28), lineHeight: 1.05, // a multiple of the size color: col('ink'), align: 'left', overflow: 'wrap', placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) }, ] } }; // The folio and the book's title under the text block, flush with its left edge, on the // baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so // they are opener pages, and 'body' leaves them out. const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body', fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'), align: 'left', ...extra }); const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot const footer = { elements: [ foot('folio', '{pageNumber}', { color: col('ink'), placement: at('container', 'top-left', 0, FOOT) }), foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }), ] }; // #endregion const config = () => ({ // The English sample is British English: every English tag hyphenates with the US patterns. locale: t({ en: 'en-us', es: 'es' }), colorPalette, resourceTypes: [lamina], page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150, backgroundColor: col('paper'), // the ground of every page; the plates cover it margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left is the inner margin layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Andada Pro', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to blue referenceColor: col('ink'), referenceBold: false, // 'lámina IV' reads as a word of the text firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default minWordSpacing: 0.7, maxWordSpacing: 1.6 }, // a narrower range than the defaults, 0.6 to 2 // A heading's own line is measured even where its design paints the title: set it in a face // the page loads, or the kit fetches Open Sans for it. headings: { fontFamily: 'Syne', levels: [ // A text follows its plate with no break of its own, so the default H1 break is switched // off: the plate fills its page, the text still starts a page, and that page stays a body // page, with its running foot. { level: 1, breakBefore: { enabled: false }, advancedDesign: textOpener }, ] }, headingStyles: PLATES.map(plate), captionStyle: { fontFamily: 'Syne Mono', fontSize: pt(7.5), color: col('muted') }, header: { elements: [] }, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown-Beispiel · 43 Zeilen · content.en.md
title: "Sierra" subtitle: "An essay in six lights" --- # Dawn {style="alba" n="I" hora="08:12"} # The climb {hora="06:40–14:06 · 1770–2180 m"} We left the car park at 6:40 with head torches, on the paved track that climbs between the broom. At that hour the granite still holds the night’s cold, and our boots sound louder than they should. From the first bend we can see the lights of the village below, eleven of them, counted twice; from the second we can’t. At 8:12 the sun touches the highest point of the crest. It is a pink edge that lasts as long as it takes to get the camera out; then the light works its way down the gullies, slowly, and takes almost an hour to reach the path. The meadow grass is white with frost and crunches under our boots. An ibex watches us from a boulder and does not move, so we are the ones who go round. We stop at the pass to eat. There is a spring that is not on the map, an iron spout driven into the rock, and the water is cold enough to make our teeth ache. Andrés notes the time and the altitude of every photograph in a notebook; at one o’clock the thermometer hanging from his rucksack reads eleven degrees in the sun. Solar noon falls after two here, on summer time. By then the shadows have gone under the stones, and the lake, seen from the pass, looks like a sheet of tin someone has left behind between the walls. # Noon {style="lamina" art="mediodia" n="II" hora="14:06"} # Storm {style="pliego" art="tormenta" n="III" hora="16:40"} # Storm {style="pliego-recto" art="tormenta"} # Eleven seconds {hora="15:00–19:50 · 2180–1950 m"} At three o’clock the air grows heavy. A cloud that at first we take for another mountain is building in the west. We pack in a hurry. The first thunder comes eleven seconds after the flash: nearly four kilometres away, Andrés reckons, because he always counts. The next one comes after four. We take shelter under the overhang of a boulder as big as a house. First small hail, then rain; the wall opposite fades behind the curtain and comes back darker, washed. Nobody speaks for a good hour. Around half past seven the storm drifts away down the valley and leaves the sky broken into strips. For a moment the sun comes out below the cloud, low in the west, and turns the whole crest a colour that lasts less than three minutes (:ref{id="atardecer" text="plate IV"}). We go down to the refuge soaked through, beside the stream, which is running full. # Night {style="lamina" art="noche" n="V" hora="22:10"} # First snow {hora="22:10–09:00 · 1950 m"} At ten the sky is clear, and the refuge window cannot hold all its stars. At midnight somebody opens the door and says it is snowing. We go out in our socks. The snow falls straight down, with no wind, in large flakes that are slow to melt on our sleeves, and the torch lights only a cone of white points coming towards us. In the morning the thermometer at the window reads two below. There is a hand’s depth of snow at the door and a rim of thin ice along the lake. The ridges that were grey yesterday are white with black streaks, the ribs where the snow will not stick, and the sky is the same colour as the ground. Andrés takes the last photograph at 8:31, from the same boulder as at noon yesterday. It takes him some time to find it, because the stones he used as markers are now identical white mounds. In the end he places it by matching the crest to yesterday’s picture on the camera screen. It is the first snow of the autumn, two weeks early, the warden says. We set off at nine. The paved track is under the snow, so we go down from one cairn to the next. # Snow {style="nieve" n="VI" hora="08:31" colofon="Sierra. An essay in six lights · Plates generated with diffusion models · Text: CC BY 4.0" tipos="Set in Andada Pro, Syne and Syne Mono (SIL OFL)"}`; // content.<lang>.md, inlined by the Cookbook // #region plates: every picture is a resource; only plate IV is cited, so only it floats // Plate IV is the one plate that floats, so a counter of its type would number it 1. The type // prints no number (no caption prefix, an empty template) and the caption carries the numeral. // The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else. const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; // JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi). const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap', bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0, altText: t(alt), ...extra }); const resources = [ // the five the heading styles draw, never cited, and plate IV picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.', es: 'Ocho crestas que se pierden en la bruma del alba.' }), picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.', es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }), picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning ' + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la ' + 'izquierda; un rayo y un claro de sol a la derecha.' }), picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.', es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }), picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.', es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }), // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure. picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.', es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' }, { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build 'Andada Pro': ['400'], Syne: ['700', '800'], 'Syne Mono': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const files = resources.map((r) => r.bitmap.fileId); // a resource names a file: load each await Promise.all(files.map((f) => loadImage(f, asset(f)))); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Sierra: a photo essay in landscape', es: 'Sierra: un ensayo fotográfico apaisado' }) });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
#Tafeln gegenüber ihren Einträgen gleiten lassen
Für Tafeln, die auf die rechte Seite gegenüber einem Katalogeintrag gleiten und deren Breite sich aus ihren Proportionen ergibt, siehe Katalogeinträge gegenüber ihren Tafeln.
#Einen Artikel mit einem randabfallenden Foto eröffnen
Für ein Foto, das randabfallend über den Kopf eines Artikels läuft, mit Schlagzeile und Vorspann darüber, siehe die Magazinreportage.
Häufige Fehler
Häufiger Fehler
Seite 1 ist eine rechte Seite: Seiten mit physischen Nummern planen
Seite 1 ist eine rechte Seite und Seite 2 die erste linke, planen Sie Doppelseiten also mit physischen Seitenzahlen: ein Auftakt auf einer geraden Seite steht der ungeraden Seite danach gegenüber. Seiten- und Spaltenumbrüche →
Häufiger Fehler
Kein <marker> und keine Filter in SVG-Grafiken (Raster-Ersatz)
Eine SVG-Abbildung bleibt im PDF nur ohne <marker>, Filter und Masken ein Vektor; andernfalls fällt sie auf ein Raster zurück, und tief verschachtelte Filter können sie in Chrome leer erscheinen lassen. Zeichnen Sie Pfeilspitzen als Pfade. Abbildungen und Tabellen als Ressourcen →
- Zitieren Sie nie eine Tafel, die ein Design zeichnet. Zitiert, gleitet sie zusätzlich als Abbildung: Ein
:refauf die Mittagstafel im ersten Text setzt eine zweite Kopie davon auf Seite 3, und das Gewitter rückt nach einer Vakatseite zwei Seiten weiter. - Die Abendtafel steht eine Zeile unter ihrem Text, nicht am Fuß der Seite. Auf der Schlussseite eines Texts rückt ein Gleitobjekt, das unter dem Text liegt, zu ihm hinauf (
layout.hugClosingFloats, standardmäßig aktiv), undposition: 'bottom'ändert dort nichts. - Jeder Text ist in beiden Ausgaben auf eine Seite eingepasst. Läuft der erste auf Seite 3 weiter, rückt die Mittagstafel auf Seite 4 und das Gewitter nach einer leeren Seite 5 auf die Seiten 6 und 7.
- Die rechte Hälfte des Gewitters ist eine zweite Überschrift
# Storm. Die Seite druckt dafür keinen Titel, aber ein aus den Überschriften gebautes:::tocführt das Gewitter zweimal auf, auf den Seiten 4 und 5. - Die Tafeln enden am Endformat, dem Rand des Canvas. Mit aktiviertem
page.cutLineswächst der Canvas um das Endformat, und der Anschnitt um jede Tafel zeigt die Papierfarbe. Für den Druck verankern Sie die Tafeln an'bleed'und vergrößern jede auf allen Seiten um den Anschnitt.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- The three texts, the captions and the colophon, in Spanish and English, and the six plates, drawn in code · Postext Cookbook · CC BY 4.0
- Bilder
- Plate I, dawn: ridges fading into a peach haze under a dark blue sky · Generated With Diffusion Models · eigener Text
- Plate II, noon: a granite cirque and its lake · Generated With Diffusion Models · eigener Text
- Plate III, storm: rain, lightning and a break of sun across a spread · Generated With Diffusion Models · eigener Text
- Plate IV, dusk: the crest lit orange under a violet sky · Generated With Diffusion Models · eigener Text
- Plate V, night: stars over the ridges and a lit refuge · Generated With Diffusion Models · eigener Text
- Plate VI, snow: the cirque of plate II the morning after · Generated With Diffusion Models · eigener Text
- Schriften
- Andada Pro (SIL OFL 1.1) · Syne (SIL OFL 1.1) · Syne Mono (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


