本文へスキップ
レシピ番号100

レシピ集 · 第10章 · 出力と組み込み

組版した本を机の上に3Dで開く

postext-folioパッケージのcreateFolioFromDocumentが、組版した章をクロス装の本としてクルミ材の机の上に開きます。見た目はconfig.folioで決めます。

このページの内容
レベル
中級
Postext
Postext 1.13.4で検証済み
1.13.4以上が必要
ライセンス
2026年10月4日更新
コードMIT · 本文CC BY 4.0

1ページ(全4ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり129 × 198 mm
  • 1段
  • Literata 9.8/14
  • Fraunces
  • Work Sans
  • 4ページ
  • レベル
  • Postext 1.13.4
  • 組版時間12 ms
  • コード120行

かんたんな説明

短い小説の第1章を、机の上に開いて置かれた本物の本として見せます。ページはマウスでめくり、紙、表紙、机は設定で選びます。

できあがり

129 × 198mmのページの短い小説『The Ferryman’s Ledger』の第1章を、クルミ材の机の上の本として開きます。表紙はボトルグリーンのクロス装、本文用紙はかさ高のクリーム色の小説用紙です。読者は右ページの端をつまんでめくり、紙は丸まりながら下のページに影を落とします。本文はbuildDocumentで一度だけ組み、postext-folioパッケージを1回呼び出せば、そのレイアウトが本になります。紙、製本、机、光、視点の角度と、本を形づくるものはすべて設定の一部なので、同じ文書はSandboxのFolioタブでも同じ見た目になります。

このレシピが答える質問

  • 文書を、読者が手でページをめくる3Dの本として表示するには?
  • 入稿の前に、本の綴じ、表紙、用紙、照明をプレビューするには?

手短な答え

script.js · 175–186行コード全体で見る
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('–')),
});

材料

種類
Literata, Fraunces, Work Sans (SIL OFL 1.1)
素材
なし:図はすべてコードで描画

作り方

#1 · 本の材料を指定する

script.js · 34–41行コード全体で見る
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を読むのは3Dビューアーだけです。レイアウト、Canvas、PDF、HTMLの出力はこれを無視するので、紙を変えても行は1本も動きません。bookWoveはかさ高の小説用紙で、このかさ高で70g/m²なら1枚の厚さは約112µmです。ビューアーはこれにページ数を掛けて束の厚さを決めます。クロスの色はパレットのアクセントにつながっていて、キッカー(題の上の小さな文字)やノンブルと同じ緑です。

#2 · 控えめな章扉と柱

script.js · 45–57行コード全体で見る
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) } } },
] } };

章は奇数ページの上から3分の1下がった位置で、見出しの属性から取ったキッカーの下で始まります。3Dでも、下げて組んだ章扉は印刷された小説と同じように読めます。表紙を開いたとき読者が最初に目にするのは、ページの上部の余白です。

script.js · 61–74行コード全体で見る
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 · 文書を本として開く

コードは上の手短な答えにあります。createFolioFromDocumentはコンテナーと組版済みの文書を受け取ります。開いている見開きの周辺だけ、各ページをrenderPageToCanvasでページ枠ちょうどの大きさに描画し、本をコンテナーに合わせて収めます。コンテナーには独自の高さが必要で、ないと本を置く場所がありません。textureBaseUrlはpostext.devにある机の写真を指します。これがないと、クルミ材はプロシージャルなマップで描かれます。

レシピの全体

Sandbox
// ═══ 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.13.4
//
// 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 { buildDocument, renderPageToCanvas, clearMeasurementCache } 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 = () => ({ // a factory: the engine caches resolved configs per object
  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: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { 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の見本 · 55行 · content.en.mdtitle: "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 ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); 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('–')), }); // #endregion
キット · core, fonts, viewer:全レシピ共通 · 235行// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 ───────────────────────────────────────────────────────────────────────

組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#昼の光の中、オーク材の机にペーパーバック

設定を3つ変えると、同じ章が淡い色の机の上のソフトカバーになります。組み直しは起こりません。

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

#丁ごとに別の紙

紙の見本帳は:::paperで丁ごとに別の用紙に刷り、設定の試験台はsetAppearanceですべての設定をその場で変えます。

よくあるつまずき

つまずき

Folioビューアーはコンテナーの大きさに合わせる

createFolioFromDocumentは、渡した要素の中に本を収め、余白にボタンとページ数を置きます。高さのない要素(通常フローの空のdiv)では何も表示されないので、CSSで高さを与えてください。 3Dの本のビューアー(Folio) →

つまずき

レイアウトの前にすべてのフォントを読み込む

レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →

つまずき

headingsオブジェクトを渡すとH1の改ページが消える

既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →

つまずき

設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る

エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
フォント
Literata (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Work Sans (SIL OFL 1.1)
Sandbox