Kurz gesagt
Das erste Kapitel eines kurzen Romans, gezeigt als echtes Buch, das aufgeschlagen auf einem Schreibtisch liegt. Sie blättern die Seiten mit der Maus um, und Papier, Einband und Tisch werden in den Einstellungen gewählt.
Was Sie setzen
Das erste Kapitel von The Ferryman’s Ledger, einem kurzen Roman auf einem Format von 129 × 198 mm, als Buch auf einem Nussbaumtisch aufgeschlagen. Die Decke ist flaschengrünes Leinen, die Seiten sind ein voluminöses cremefarbenes Romanpapier, und der Leser fasst die rechte Seite an ihrer Kante und blättert sie um, wobei sich das Blatt wölbt und seinen Schatten auf die Seite darunter wirft. Der Text wird einmal mit buildDocument gesetzt; ein Aufruf des Pakets postext-folio macht aus diesem Satz das Buch. Alles, woraus das Buch besteht (Papier, Bindung, Tisch, Licht, Blickwinkel), ist Teil der Konfiguration, sodass dasselbe Dokument sein Aussehen in den Tab Folio der Sandbox mitnimmt.
Dieses Rezept beantwortet
- Wie zeige ich mein Dokument als 3D-Buch, dessen Seiten der Leser von Hand umblättert?
- Wie sehe ich Bindung, Umschlag, Papier und Beleuchtung eines Buchs vor dem Druck in der Vorschau?
Die kurze Antwort
const stage = document.createElement('section');
stage.id = 'folio';
stage.ariaLabel = 'The book in 3D';
stage.style.cssText = 'height: min(78vh, 720px); margin: 0 auto; max-width: 1280px';
document.getElementById('pages').before(stage);
createFolioFromDocument(stage, doc, {
// The paper, binding, desk and light come from doc.config.folio. Only the photographed
// desk is the host's choice: without textureBaseUrl the walnut is drawn procedurally.
appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' },
onChange: ({ pages }) => kitStatus(`${doc.pages.length} pages · open at `
+ pages.map((i) => doc.pages[i].pageLabel || i + 1).join('–')),
});
Zutaten
- Funktionen
- 3D-Buchbetrachter (Folio)Papier, Bindung, Tisch und Licht (Folio)Seiten auf einem CanvasGestaltete AuftakteÜberschriftenattributeKolumnentitel und SeitenzahlenKopfzeilen nach SeitenrolleGespiegelte RänderWitwen, Waisen und kurze AusgangszeilenAbsatzformateSemantische FarbpaletteSchriften vor dem Layout
- Schrift
- Literata, Fraunces, Work Sans (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Angeben, woraus das Buch besteht
const folio = {
tilt: 26, // degrees from straight above: enough to see the depth of the page block
paper: { type: 'bookWove', grammage: 70, shade: col('cream') }, // a bulky cream novel paper
binding: { type: 'hardcover', cover: 'case', coverMaterial: 'cloth',
coverColor: col('accent') }, // the case wraps the pages; boards a little larger
surface: { type: 'walnut' },
lighting: { environment: 'studio', intensity: 1, shadows: true },
};
config.folio liest nur der 3D-Betrachter: Layout, Canvas, PDF und HTML-Ausgabe ignorieren es, sodass ein anderes Papier nie eine Zeile verschiebt. bookWove ist ein voluminöses Romanpapier, und 70 g/m² bei diesem Volumen ergeben ein Blatt von etwa 112 µm Dicke; der Betrachter multipliziert das mit der Seitenzahl für die Tiefe des Buchblocks. Die Leinenfarbe ist mit dem Akzent der Palette verknüpft, demselben Grün wie Dachzeile und Paginas.
#2 · Ein ruhiger Auftakt und Kolumnentitel
const opener = { enabled: true, minHeight: mm(50), slot: { elements: [
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: 'Work Sans',
fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase',
color: col('accent'), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(10) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Fraunces', fontSize: pt(28),
lineHeight: 1.05, fontWeight: 600, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(3) },
size: { width: mm(90) } } },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.25), color: col('accent'),
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4.5) },
size: { width: mm(12) } } },
] } };
Das Kapitel beginnt ein Drittel unterhalb des Kopfes einer rechten Seite, unter einer Dachzeile aus den Attributen der Überschrift. In 3D wirkt ein abgesenkter Auftakt wie in einem gedruckten Roman: Der Weißraum am Kopf der Seite ist das Erste, was der Leser beim Aufschlagen der Decke sieht.
const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity,
pages: 'body', fontFamily: 'Work Sans', fontSize: pt(7), fontWeight: 500,
letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'),
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } }, ...extra });
const folioNumber = { fontWeight: 600, color: col('accent') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folioNumber),
head('verso-author', '{author}', 'even', 'top-left', OUTER + 8),
head('recto-title', '{title}', 'odd', 'top-right', -(OUTER + 8)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folioNumber),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
{ ...folioNumber, pages: 'opener', placement: { anchor: { to: 'page', edge: 'bottom' },
offset: { y: mm(-11) } } })] };
#3 · Das Dokument als Buch öffnen
Der Code steht oben in der kurzen Antwort. createFolioFromDocument nimmt einen Container und das gesetzte Dokument: Es malt jede Seite mit renderPageToCanvas in der genauen Größe eines Seitenplatzes, nur rund um die aufgeschlagene Doppelseite, und passt das Buch in den Container ein. Der Container braucht eine eigene Höhe, sonst hat das Buch keinen Platz. textureBaseUrl zeigt auf die fotografierten Tische auf postext.dev; ohne diese Angabe wird der Nussbaum mit prozeduralen Texturen gezeichnet.
Das ganze Rezept
// ═══ Postext Cookbook · Nº 100 · A laid-out book on the desk, in 3D ═══════════════════ // https://postext.dev/en/cookbook/folio-book-on-a-desk // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Literata, Fraunces, Work Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // // A chapter of a short novel, laid out on a trade-book page and opened with postext-folio as a // cloth-bound book lying on a walnut desk. Take the right-hand page by its edge and drag it // over, click a page, or use the arrow keys. The flat pages follow under the book. import { buildDocumentWithFonts, renderPageToCanvas } from 'https://esm.sh/postext'; import { createFolioFromDocument } from 'https://esm.sh/postext-folio'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'folio-book-on-a-desk'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: semantic colours, every one linked by id const palette = { ink: '#211d1a', // text: a warm near-black accent: '#2f5d50', // bottle green: kicker, rule, folios, and the cloth of the case rule: '#c9bfae', // the colophon's hairline muted: '#6d655c', // running heads and the colophon cream: '#f7f0e1', // the book wove the pages are printed on (the 3D paper only) }; 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' } })), { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // #endregion const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [129, 198, 20, 22, 17, 15]; // mm: B-format const LEAD = 14; // pt: the body leading // #region folio: what the book is made of and where it lies; layout never reads it const folio = { tilt: 26, // degrees from straight above: enough to see the depth of the page block paper: { type: 'bookWove', grammage: 70, shade: col('cream') }, // a bulky cream novel paper binding: { type: 'hardcover', cover: 'case', coverMaterial: 'cloth', coverColor: col('accent') }, // the case wraps the pages; boards a little larger surface: { type: 'walnut' }, lighting: { environment: 'studio', intensity: 1, shadows: true }, }; // #endregion // #region opener: a sunk chapter opening, kicker, title and a short rule const opener = { enabled: true, minHeight: mm(50), slot: { elements: [ { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: 'Work Sans', fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase', color: col('accent'), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(10) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Fraunces', fontSize: pt(28), lineHeight: 1.05, fontWeight: 600, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(3) }, size: { width: mm(90) } } }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.25), color: col('accent'), placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4.5) }, size: { width: mm(12) } } }, ] } }; // #endregion // #region running-heads: author on the verso, book title on the recto, folios outside const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: 'Work Sans', fontSize: pt(7), fontWeight: 500, letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } }, ...extra }); const folioNumber = { fontWeight: 600, color: col('accent') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folioNumber), head('verso-author', '{author}', 'even', 'top-left', OUTER + 8), head('recto-title', '{title}', 'odd', 'top-right', -(OUTER + 8)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folioNumber), ] }; const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0, { ...folioNumber, pages: 'opener', placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-11) } } })] }; // #endregion const config = () => ({ locale: 'en-us', colorPalette, page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Literata', fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: 'Fraunces', color: col('ink'), fontWeight: 600, levels: [ // Chapters open on a recto; 'odd' leaves a blank verso only when one is needed. { level: 1, fontSize: pt(28), breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), fontWeight: 400, italic: true, color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, paragraphStyles: [{ id: 'colophon', fontFamily: 'Work Sans', fontSize: pt(6.8), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, folio, // the 3D book's materials, carried in the document as doc.config.folio }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown-Beispiel · 55 Zeilen · content.en.md
title: "The Ferryman’s Ledger" author: "Mara Ellison" --- # The Last Crossing {kicker="Chapter one"} **The ferry left the north bank at six** every evening, whatever the weather, and for thirty-one years Tobias Wren had written its passengers into a ledger bound in green cloth. He wrote the date, the hour, the state of the river, and then a line for every soul who came aboard: a name if they gave one, a description if they did not. *A woman with a cello case. Two boys and a dog that would not sit. A priest, reading.* The ledger lived in a drawer under the wheel, wrapped in oilskin, and nobody but Tobias had ever opened it. The river at Hallam is four hundred yards wide and never still. It comes down from the moors brown with peat, carrying branches and once, in the flood of the year his daughter was born, the roof of a barn with a goat standing on it. Tobias took the goat across for nothing and wrote it down as a passenger. He said afterwards that it had paid more attention to the crossing than most. On the evening of the last crossing the light was going early. October had turned cold all at once, and the willows on the south bank had dropped their leaves in a single night, so that the landing stage looked bare and new. Tobias came down the lane at half past five with his collar up and found a young man already waiting at the rail, a canvas bag at his feet and his hands deep in his pockets. “You’re early,” Tobias said. “I didn’t want to miss it. They told me in the village it was the last one.” “The last one I take,” said Tobias. “The council has a bridge now. You’ll have seen it.” Everyone had seen it. It stood a mile upstream on six concrete legs, grey and patient, and cars went over it in less time than it took Tobias to cast off. ## Six o’clock He let the young man aboard and went through the small rituals of the boat without thinking about them: the engine coaxed into life, the lamps lit fore and aft, the ropes looped and stowed. The ferry was a flat-bottomed thing of tarred oak, older than Tobias, with a wheelhouse no bigger than a wardrobe. It smelled of diesel and wet rope and, faintly, of the apples a farmer had carried across that morning. Nobody else came. At six exactly Tobias rang the bell, as he always did, once for the north bank and once for the south, and took the ferry out into the current. The young man stood at the bow and watched the far bank come slowly towards them. Halfway across, where the river runs deepest and the water turns from brown to black, Tobias cut the engine. He did it every night. The current took the boat and turned it gently downstream, and for a minute the only sounds were the water against the hull and the rooks going home over the woods. Passengers who knew the crossing waited for that minute; the ones who did not usually asked whether something had broken. The young man did not ask. He turned round and looked at Tobias through the wheelhouse window, and Tobias saw that he was holding a book against his chest, a thin book bound in green cloth, its corners worn white. ## The other ledger “My grandmother kept this,” the young man said when the engine was running again. “She crossed with you every Saturday for nine years, to see her sister in Calder. She wrote down the crossings too. I thought you would want to see it before it ends.” Tobias took the book in both hands. The cloth was the same green as his own, from the same stationer in the market square, which had closed a decade ago. Inside, in a small upright hand, were the Saturdays: the date, the weather, the colour of the river, and then a line about the ferryman. *He was whistling today. He has cut his hair. He gave the boy with the bicycle an apple and told him it was the fare.* He read for the rest of the crossing, and when the ferry bumped against the south landing he did not move to tie up. The young man did it for him, clumsily but well enough, and then sat on the rail and waited. “Ada Pell,” Tobias said at last. “Grey coat, a basket with a lid. She always stood on the left of the bow.” “The left was the side of the house she grew up in. You can see the chimney from the middle of the river, if you know where to look.” Tobias had never known that. He had written her into his own ledger more than four hundred times, and never once wondered why she stood where she did. He turned the pages of her book until he found the last entry, a Saturday in March eleven years ago. *River high and fast. He waited for me at the top of the lane, though I was late. I think he knows I won’t be coming back.* He had known. He remembered the day. He had not written down what he knew, only her name and the time, because the ledger was for the crossing and not for the people in it. He saw now that she had kept the better book. ## After The young man would not take the ledger back. “She wrote it about you,” he said. “It should be on the boat.” Then he shouldered his bag and walked up the lane towards the village, where a bus would take him to the station and the station would take him home. Tobias watched him out of sight. He did not go back across that night. The ferry stayed tied to the south landing with its lamps lit until the oil ran out, and in the morning the men from the council found Tobias asleep in the wheelhouse with two green ledgers open on his knees. They let him keep the boat. It sits on the bank now below the willows, and children climb in it and steer it across rivers that are not there. The ledgers went to the library in Hallam, where they are kept side by side in a glass case, open at the same Saturday in March: on the left page a name and an hour, and on the right a few lines that say everything the left page leaves out. :::paragraphs{style="colophon"} Set in Literata, Fraunces and Work Sans (SIL Open Font License) · Text: original, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Literata: ['400', '400i', '700'], Fraunces: ['400i', '600'], 'Work Sans': ['400', '500', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown }, config(), kitFonts(FONTS)); showPages(doc, { title: 'The Ferryman’s Ledger · in Folio' }); // #region answer: the document opened as a book; the container gives it its size const stage = document.createElement('section'); stage.id = 'folio'; stage.ariaLabel = 'The book in 3D'; stage.style.cssText = 'height: min(78vh, 720px); margin: 0 auto; max-width: 1280px'; document.getElementById('pages').before(stage); createFolioFromDocument(stage, doc, { // The paper, binding, desk and light come from doc.config.folio. Only the photographed // desk is the host's choice: without textureBaseUrl the walnut is drawn procedurally. appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' }, onChange: ({ pages }) => kitStatus(`${doc.pages.length} pages · open at ` + pages.map((i) => doc.pages[i].pageLabel || i + 1).join('–')), }); // #endregionKit · 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
#Ein Taschenbuch auf Eiche bei Tageslicht
Ändern Sie drei Einstellungen, und dasselbe Kapitel ist ein Softcover auf einem hellen Tisch; nichts wird neu gesetzt.
- binding: { type: 'hardcover', cover: 'case', coverMaterial: 'cloth',
- coverColor: col('accent') }, // the case wraps the pages; boards a little larger
- surface: { type: 'walnut' },
- lighting: { environment: 'studio', intensity: 1, shadows: true },
+ binding: { type: 'paperback', cover: 'case', coverMaterial: 'paper',
+ coverColor: col('accent') },
+ surface: { type: 'oak' },
+ lighting: { environment: 'daylight', intensity: 1, shadows: true },#Jedes Blatt auf einem anderen Papier
Ein Papiermusterbuch druckt jedes Blatt mit :::paper auf einem eigenen Papier, und der Prüfstand für die Einstellungen ändert jede Einstellung live mit setAppearance.
Häufige Fehler
Häufiger Fehler
Der Folio-Betrachter übernimmt die Größe seines Containers
createFolioFromDocument passt das Buch in das Element ein, das Sie übergeben, mit den Schaltflächen und der Seitenzahl in seinen Rändern. Ein Element ohne Höhe (ein leeres div im normalen Fluss) zeigt nichts: Geben Sie ihm in CSS eine Höhe. 3D-Buchbetrachter (Folio) →
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- Literata (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Work Sans (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


