本文へスキップ

章 3.18 · 部 II · 技法

プログラムからの利用:HTML、Folio、EPUB

組み上がった本をWebページに入れる方法、postext-folioで3Dの本にする方法、postext-epubで固定レイアウトまたはリフロー型のEPUB 3にする方法

更新日 2026-10-1618分所属する章: 3 設定enescadefritptzhjaar

かんたんな説明

このページは、プログラムを書いて本を画面に見せたい人向けです。最初の節では、ページをWebページの中に入れ、その表示がcanvasやPDFとどう違うかを説明します。次の節では、本を机の上に開いた3Dの姿で見せ、読者がページをめくれるようにします。最後の節では、ページが固定された電子書籍か、画面に合わせて文章が流れる電子書籍をEPUBで作り、そのファイルを検査します。

#HTMLビューアーの統合

HTMLビューアーは、Postextの画面向けのレンダラーです。ページをビットマップにラスタライズする代わりに、絶対配置のDOMノードを出力し、その位置と大きさは印刷用の出力を生むのと同じパイプラインで決まります。そのため、PDFビューアーを持ち込まずに、ブラウザーで読みやすく、選択でき、サイズ変更に追従する文字組みがほしい場合(読書アプリ、製品内のプレビュー、埋め込みのドキュメント画面など)に適しています。

公開APIの主な要素は次のとおりです。

  • buildDocument(content, config, cache?):組版パイプライン全体を実行し、VDTDocumentを返します。
  • renderToHtmlIndexed(doc, options):VDTを1つのHTML文字列と、ページごと・ブロックごとの内訳に変換します。内訳があれば、描画の間で少数のブロックだけが変わったときに、DOMを低コストで部分的に更新できます。
  • resolveHtmlViewerConfig(partial):HTMLビューアーの既定値(maxCharsPerLine、columnGap、optimalLineBreaking)を補います。
  • buildFontString + measureGlyphWidth + dimensionToPx:目標の文字数から実際の段幅をピクセルで求めるための計測の基本関数です。
  • createMeasurementCache / clearMeasurementCache:差し替え可能なキャッシュで、再レイアウトの間で計測結果を再利用できます。
  • prepareFonts / buildDocumentWithFonts / watchFonts / onFontsChanged:レイアウトの前に文書のフェイスを読み込み、フェイスが届いたらレイアウトし直します(レイアウトの前にフォントを読み込むを参照)。

#ライブサンプル:HTML文字列

下のReactでの統合に入る前に、素のJavaScriptで一連の流れを示します。文書をビルドし、VDTDocumentをrenderToHtmlに渡し、得られた文字列をコンテナーに入れます。mode: 'single'はページを縦に積み重ねます。ページは既定では透明なので、backgroundで色を付けます。このCodePenのサンプルは生成したマークアップも表示するので、レンダラーが出力する絶対配置の行を確認できます。ブラウザーはそれを描画しますが、リフローはしません。

Postext · 文書をHTMLに描画する
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
 
const markdown = `# The Lantern
 
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
 
## Two columns
 
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
 
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
 
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
 
const config = {
  // 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
  page: { sizePreset: '17x24', dpi: 96 },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('italic 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
 
const doc = buildDocument({ markdown }, config);
 
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
 
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
  `${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;
index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
  <summary>Generated HTML</summary>
  <pre id="source"></pre>
</details>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
   The renderer centres pages with an inline style, hence the !important. */
#viewer {
  overflow: auto;
}
#viewer .pt-doc {
  align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
  margin-top: 16px;
}
#source {
  max-height: 240px;
  overflow: auto;
  padding: 8px;
  background: #fff;
  font-size: 11px;
  white-space: pre-wrap;
  word-break: break-all;
}

codepen.ioからインタラクティブなエディターを読み込みます。サンプルは最新リリースのpostextをCDNから読み込みます。

#最小限の統合

次のコードは、役に立つ統合としては最も短いものです。現在のビューポートの大きさで文書をビルドし、コンテナーに描画し、サイズが変わったら再実行します。

import { useEffect, useRef } from 'react';
import {
  buildDocument,
  renderToHtmlIndexed,
  resolveHtmlViewerConfig,
  buildFontString,
  measureGlyphWidth,
  dimensionToPx,
  createMeasurementCache,
  watchFonts,
  onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
 
// Screen-friendly DPI: at 144 DPI, an 8pt body size resolves to 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
 
// Prose sample used to measure the target column width. Proportional fonts
// make "N × average width" unreliable, so we measure a representative string.
const SAMPLE =
  'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
 
function sampleForChars(n: number): string {
  let s = SAMPLE;
  while (s.length < n) s += ' ' + SAMPLE;
  return s.slice(0, n);
}
 
export function PostextHtmlViewer({
  markdown,
  config,
  mode = 'multi',
}: {
  markdown: string;
  config: PostextConfig;
  mode?: 'single' | 'multi';
}) {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
 
  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
 
    const relayout = () => {
      const rect = host.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
 
      const viewer = resolveHtmlViewerConfig(config.htmlViewer);
      const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
      const fontWeight = config.bodyText?.fontWeight ?? 400;
      const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
      const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
 
      // Measure the *actual* column width for N characters of body prose.
      const targetColumnPx = measureGlyphWidth(
        sampleForChars(viewer.maxCharsPerLine),
        buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
      );
 
      const inner = Math.max(rect.width - PADDING_PX * 2, 100);
      let columnWidthPx: number;
      if (mode === 'single') {
        columnWidthPx = Math.min(targetColumnPx, inner);
      } else {
        // Fit as many columns as we can at the target width.
        const count = Math.max(
          1,
          Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
        );
        columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
      }
      columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
 
      // Single mode uses one very tall page; multi mode uses the viewport
      // height so each VDT "page" becomes one column.
      const pageHeightPx =
        mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
 
      const override: PostextConfig = {
        ...config,
        page: {
          ...config.page,
          dpi: HTML_DPI,
          width: { value: columnWidthPx, unit: 'px' },
          height: { value: pageHeightPx, unit: 'px' },
          margins: {
            top: { value: 0, unit: 'px' },
            bottom: { value: 0, unit: 'px' },
            left: { value: 0, unit: 'px' },
            right: { value: 0, unit: 'px' },
          },
        },
        layout: { ...config.layout, layoutType: 'single' },
        bodyText: {
          ...config.bodyText,
          optimalLineBreaking: viewer.optimalLineBreaking,
        },
      };
 
      const doc = buildDocument({ markdown }, override, cacheRef.current);
      const { html } = renderToHtmlIndexed(doc, {
        mode,
        columnGap: viewer.columnGap,
        padding: PADDING_PX,
        background: 'transparent',
      });
 
      host.innerHTML = html;
    };
 
    relayout();
 
    const ro = new ResizeObserver(() => relayout());
    ro.observe(host);
 
    // Re-measure when web fonts land so glyph widths aren't taken from fallbacks.
    const stopWatching = watchFonts();
    const off = onFontsChanged(() => relayout());
 
    return () => {
      ro.disconnect();
      off();
      stopWatching();
    };
  }, [markdown, config, mode]);
 
  return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}

この例で行っていることを補足します。

  • 段幅は概算せずに計測する。maxCharsPerLineは文字数で表した目標なので、実際のピクセル幅は本文のフォントによって変わります。measureGlyphWidthは選んだフォントで実際に計測するので、フォントを差し替えても行の長さが一定に保たれます。
  • ページの書き換え。HTMLビューアーは、VDTの各「ページ」を画面上の1つの段として扱います。この例ではpage.widthを計測した段幅で上書きし、余白をゼロにして(パディングはページの外側、包んでいる.pt-docのdivにあります)、HTML_DPI = 144を使うことで8ptの本文が16pxになるようにしています。
  • フォントの読み込みへの対応。watchFontsはdocument.fontsを監視し、フェイスが届いたファミリーで計測した結果を、1フレームに1回捨てます。続いてonFontsChangedが段をレイアウトし直します。再レイアウトしなければ、最初の描画は代替フォントの計量値を使い、本来のフォントが届いたときに表示が跳ねます。
  • 計測キャッシュの再利用。コンポーネントごとに1回だけキャッシュを作ることで、サイズ変更やフォントの拡大縮小のときに、すべての段落を計測し直さず、前回の描画の計測結果を再利用できます。

#さらに進んだ統合

上の例は意図的に単純にしてあります。実運用の統合では、たいてい次のものを加えます。

  • Shadow DOMによる分離:host.attachShadow({ mode: 'open' })に描画すれば、外側のページのCSSがビューアーに漏れ込みません。
  • 差分の部分更新:renderToHtmlIndexedはpages[i].blocksを返し、各ブロックは安定したidとブロックの外側のHTMLを持ちます。2回の描画の間で変わったブロックが少なければ、innerHTMLを作り直さずに、そのブロックのラッパーだけをその場で置き換えられます。
  • オーバーレイ:各.pt-pageの上に絶対配置のSVGを重ね、カーソル、選択範囲、ベースライングリッドを表示します。
  • リンク:Markdownのリンクの語は<a href="…" rel="noopener noreferrer">で包まれ、テキストの色を受け継ぎ、下線は付きません。文書形式 › リンクを参照してください。エディターのようなビューアーでは、#で始まらないa[href]のクリックを横取りし、新しいタブで開いてください(:refのアンカーは文書内にリンクします)。
  • 単色の図:diagramStyle.singleInkを有効にすると、SVGの<img>にCSSフィルターが掛かります。すでに色を変えてあるURLにはsingleInk: falseを渡してください。キャンバスとHTMLでの単色刷りを参照してください。

SandboxのHtmlPreviewコンポーネント(packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx)は、ここで示したのと同じAPIの上にこれらすべてを実装しており、参考にできます。また、すべてのビルドを共有のレイアウトワーカーに通しているので(Web Workerでレイアウトを実行するを参照)、ライブ編集やサイズ変更がメインスレッドをふさぐことはありません。レイアウトをメインスレッドから外す準備ができたら、上のコードのbuildDocument(...)の直接呼び出しをlayoutWorker.build(...)に置き換えてください。

#HTML出力とcanvas、PDFとの違い

renderToHtmlは、すべての行、図、デザイン要素を、canvasやPDFとまったく同じ位置に置きますが、その周りに描くものは少なくなります。

機能Canvas (renderPage)HTML (renderToHtml)PDF (renderToPdf)
ページの背景白。仕上がりと裁ち落としの範囲にpage.backgroundColorを重ねます。透明。backgroundを渡すかpage.backgroundColorを設定した場合は色が付きます(このときはスラッグを含むページボックス全体を塗ります)。白。仕上がりと裁ち落としの範囲にpage.backgroundColorを重ねます。
ベースライングリッド(page.baselineGrid)描画する描画しない描画する
段間罫(layout.columnRule)描画する描画しない描画する
トンボ(page.cutLines)描画する描画しない。ページボックスには、仕上がりの周りのスラッグも含まれたままです。描画する
ページの反転(ネガ)pageNegativeオプション利用できないpageNegativeオプション
テキストピクセル絶対配置の要素に入った選択可能なテキスト。CSSのフォントファミリーで組まれるので、ページで同じフェイスを読み込む必要があります。fontProviderから得たフォントを埋め込みます。選択と検索ができ、タグ付きです。
縦組みのテキスト(layout.writingMode: 'vertical-rl')文字を1字分の枠ずつ描き、正立に戻します。縦組み用の字形は双子のフェイス(loadVerticalAlternates)から取ります。1つのボックスの中の流れを90度回し、各行を正立に戻してwriting-mode: vertical-rlで組みます。そのため、ブラウザーが縦組み用の字形を使い、文字を立てます。短い数字はtext-combine-upright: allで組みます。ダッシュ、三点リーダー、中黒、波ダッシュは字枠のボックスに入れ(ブラウザーは横組みの幅で送ってしまうため)、ダッシュはflow.dashAdvancesによって字枠いっぱいに伸ばします。各フォントのIdentity-Vの双子で、正立の文字を組みます。PDFの縦組みを参照してください。
画像registerResourceImageresourceImageUrl(fileId)オプション。ない場合は灰色のプレースホルダーのボックス。resourceBytes(fileId)オプション。
数式ベクターパスインラインの<svg>ベクターパス
リンクなし:refの参照はそのリソースにリンクします。目次の行はリンクしません。:refの参照と目次の行、さらにアウトライン(しおり)。

透明なページは、ダークな背景のサイトで問題になります。backgroundのないプレビューは、サイトの暗い背景の上に黒いテキストを表示してしまいます。renderToHtml(doc, { background: '#ffffff' })を渡すか、文書にpage.backgroundColorを設定してください。

ホストページのテキストスタイルは持ち込まれません。各行はエンジンが計測した幅で組まれるので、周囲のページから出力が受け継いだletter-spacing、word-spacing、text-transform、font-variantがあると、グリフの並びが広がって行が重なって印字されてしまいます。そのため.pt-docのルートは、自身のレイアウトの宣言より前に、受け継がれるテキストのプロパティをリセットします。対象は、字間と語間、大文字・小文字の変換、インデント、空白の扱い、フォントのスタイル・バリアント・ウェイト・幅・機能・カーニング、行の高さ、そろえ、テキストの影と強調、ハイフン、方向、書字方向、テキストの輪郭と塗り、モバイルでの文字の自動拡大です。これにより、シャドウルートの中でも、スタイルの付いた要素の下でも、出力は同じに見えます。この一覧は、CSSの宣言を並べた文字列HTML_TEXT_RESETとして公開されています。ページのinnerHtml(renderToHtmlIndexedから得たもの)を自前のコンテナーに入れるホストは、そのコンテナーのルートにこれを設定してください。postext 1.4までは、ルートは何もリセットしていませんでした。回避策はall: initialを指定したラッパーでした。

#3Dの本(postext-folio)

postext-folioは、レイアウト済みのドキュメントを、机の上に開いて置かれた印刷された本として画面に表示します。見開きは奇数ページの規則に従って組まれ、読者は‹ ›ボタン、矢印キー、スワイプ、ページのクリック、またはページの端をつかんで向こう側へドラッグすることで丁をめくります。各丁は紙に応じてthree.jsで湾曲し、下にあるページに本物の影を落とします。WebGLのキャンバスは静止した本もめくられる本も同じように描くので、ページが着地したときに見た目が変わることはありません。レシピ集のレシピと、SandboxのFolioタブで使われているビューアーです。

npm install postext postext-folio three
import { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';
 
const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
  onChange: ({ pages }) => console.log('showing pages', pages),
});
 
// After an edit: the same viewer, on the same page.
book.setDocument(buildDocument({ markdown: edited }, config));
  • ページは必要になったときに描かれます。createFolioFromDocumentは各ページを、ページ枠のデバイスピクセルちょうどの大きさでrenderPageToCanvasにより描きます(WebGLはそれをテクセル対ピクセルで表示するので、Canvasのプレビューと同じくらい鮮明です)。描くのは、開いている見開きの周りの見開きだけです(window、既定では前後に3つずつ)。その範囲から外れたページは解放されるので、1000ページの本でも数ページ分のメモリーしか使いません。遠いページに移動するときは、その見開きを最初に描きます。10ページ先まではページが1枚ずつめくられ、それより遠いと、間にあるページのかたまりが、それらのページを合わせた厚さ(紙厚の合計)を持つ1枚の板として持ち上がり、反対側に着地します。setDocumentは、新しいレイアウトでも内容が同じページの描画を保持します({ repaint: true }を指定すると、画像が届いた後などに、すべてを描き直します)。
  • ドキュメントが本を決めます。最初のページが奇数ページ(pageIndexOffsetが偶数)なら、右側に単独で開きます。右綴じの本(page.binding: 'right'、または縦組みのドキュメント)は左右反転して置かれ、左向きにめくられます。空白ページはページの背景色になり、ページの仕上がり幅(pageWidthMm)に応じて紙厚と表紙の板の縮尺が決まります。continuation付きでレイアウトした章は、本のほかのページ(前はpageIndexOffset、後はbookPageCountまで)を、描かずに2つのページブロックの厚さに数えます(extraPages)。
  • ドキュメントが見た目を決めます。紙、綴じ、机、光は、ドキュメントのfolio設定(doc.config.folio)で決まります。:::paperの範囲内で組まれたページは独自の用紙(VDTPage.paper)を持ち、その丁は、その紙の色、表面、厚さ、こしで描かれます。binding.cover: 'pages'では、最初のページが表表紙の板になり、最後のページが偶数ページなら裏表紙の板になります(covers)。新聞の判型('broadsheet'、'berliner'、'tabloid'、'compact')で、設定が用紙も綴じも指定していなければ、折った新聞用紙として置かれます。ホストが独自のfolioを渡した場合も同じです。
  • コンテナーが大きさを決めます。本はコンテナーいっぱいに広がり、ボタンとページ数は余白に置かれるので、コンテナーには高さを指定してください。リサイズすると、新しい大きさでページを描き直します。幅が560 px未満では1ページずつ表示します(mode: 'auto'。'single'と'double'はどちらかに固定します)。背はページの内側の端に沿い、丁はその上でめくられます。背の方向へのドラッグで先へめくり、背から離れる方向へのスワイプで前に戻り、タップでめくります。
  • ポインターの動作。interaction(後からはsetInteraction)は、左ボタン、1本指、ペンが本に対して何をするかを決めます。'hand'(既定)はページをつかんでめくり、'orbit'は右ドラッグと同じように視点を回し(トラックパッドやタブレット向け)、'select'はポインターをホストに任せます(テキストの選択などのため)。pageAt(event)は、傾けたり回したりした見た目のままの本の上で、ポインターの下にあるページとその位置({ page, x, y }。ページの左上隅からの割合)を返します。pointOnScreen(point)はその逆で、ページ上にキャレットや選択範囲を描くためのものです。refreshPage(src)は、ホストがその場で描き直したページのキャンバスを再表示します。Sandboxはこれらをすべて使って、3Dのページ上でテキストを選択し、エディターのキャレットを追います。
  • 小さな文字を読むルーペ。interaction: 'magnify' は、ポインターのある位置で、黒い縁の丸いレンズを本の上にかざします(指で触れているあいだは、指の上にかざします)。読者の目に見えるとおりの本を、光と紙の反りもそのままに映し、中心をいちばん大きく、縁へ向かって湾曲させて見せます。ホイール、+、− で倍率を変え(setMagnification(zoom)、1.5〜10。既定ではページが 1 ミリあたり約 5.5 CSS px)、Esc でしまいます。magnifier: { zoom, diameter } で最初から両方を指定できます。createFolioFromDocument はレンズの下のページを中心に必要な精細さで描き直すので、新聞の本文も読めます。createFolio はその描画を detail: { paint(index, deviceWidth), release() } から受け取ります。Sandbox ではルーペボタン(M)です。ルーペでは文字の選択もできます。ページの上ではカーソルがテキストカーソルになり、pageAt はレンズ中心の下にある点(タッチ画面では指の上)を返します。Sandbox ではそこでクリックするとキャレットが置かれ、ドラッグすると選択されます。
  • 読者は本の周りを見回せます。右ドラッグで本の周りを視点が回り(真上から70°まで)、丁がめくられている間も回せます。resetView()は設定のtiltとyawへ滑らかに戻し、getView()は現在見えている視点({ tilt, yaw }、度単位)を返すので、それを設定として保存できます。Sandboxはこれらを2つのボタン、視点をリセットと既定の視点として保存に割り当てています。
  • 動画はページの上で再生されます。動画のポスター画像をクリックすると、interactionがどのモードでもページの上で再生が始まり、その丁をめくっている間も止まりません。もう一度クリックすると一時停止し、その動画が見えない見開きで本が静止すると停止します。player.autoplayを指定した動画は、その見開きが初めて表示されたときにひとりでに再生を始めます。ほかの動画と同時に再生する設定(player.exclusive: false)なら、表示されるたびに無音で始まり、めくられると止まり、何本でも同時に再生されます。オプションはvideos、videoUrl、onVideo、ビューアーのメソッドはstopVideo()です。文書形式 › Folioのページで再生する動画を参照してください。
  • フォントと画像を先に。renderPageと同じく、ページを描く前に、ドキュメントが使うフェイスをdocument.fontsに読み込み、リソースの画像をregisterResourceImageで登録しておく必要があります。
  • アクセシブル。ビューアーはフォーカス可能なグループで、←/→(右綴じの本では反転)、Page Up/Down、Home、Endを受け付けます。ボタンとページ数にはラベルが付き(labelsで翻訳できます)、各ページのキャンバスにはaltテキストが付きます(alt: (index) => …)。
  • WebGL2がない場合、または読者が動きを減らす設定にしている場合は、見開きが単に切り替わります。WebGLの本はスマートフォンには重いため(ページの面ごとにテクスチャーを1枚使います)、SandboxはWebGL2が使え、画面の短辺が600 px以上の場合にだけFolioタブを表示します。canFlip()は、その環境で丁が3Dでめくられるかどうか(WebGL2があり、動きを減らす設定でないこと)を返します。

#外観

appearanceオプション(後からはsetAppearance)は、ドキュメントの指定を上書きします。省略したものは、ドキュメントの指定のままです。

const book = createFolioFromDocument(container, doc, {
  appearance: {
    folio: {
      tilt: 22,
      paper: { type: 'bookWove', texture: 'laid' },
      binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
      surface: { type: 'walnut' },
      lighting: { environment: 'lamp' },
    },
    // Photographed desks: a folder laid out as postext.dev's /folio/textures/
    // (manifest.json and one folder per desk). Without it, procedural maps.
    textureBaseUrl: '/folio/textures',
    // The picture for `folio.binding.spineImage`: the resource's URL,
    // or a drawn canvas or image.
    spineImage: spineUrl,
  },
});
 
// A settings panel: the book redrawn in place, nothing painted again.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();
フィールド内容
foliofolio設定:傾き、紙、綴じ、表面、照明。指定すると、ドキュメントの設定を置き換えます。
pageWidthMmページの仕上がり幅(mm)。紙厚と表紙の板は、これを基準に縮尺されます。ドキュメントからは、そのdpiでの仕上がりページの幅。createFolioでは既定値150。
extraPages:渡したページ以外の本のページ。ページブロックの厚さに数えますが、描画はしません。
covers:渡した最初のページが表表紙、最後のページが(偶数ページに当たる場合)裏表紙になります。どちらも板としてめくられ、別の表紙(ケース)は描かれません。ドキュメントからは、最初のページで始まり最後のページで終わる本でbinding.cover: 'pages'を指定した場合。
spineImage背に印刷される画像。URL、キャンバス、画像のいずれか。createFolioFromDocumentはリソースを参照しないので、folio.binding.spineImageが指定するリソースの画像を渡してください。中綴じでは無視されます。
textureBaseUrl写真から作った机のテクスチャーの配信場所。読み込まれるまでの間、または指定しない場合、机はプロシージャルなマップで描かれます。

createFolio(container, { pages })は、任意のページを対象にした同じビューアーです。ページには画像のURL、<img>または<canvas>要素、空白ページを表す""を使えます。ページは{ src, alt, paper }にすることもでき、paperはその丁の:::paper形式の用紙です。PageFlipperはthree.jsのエンジン単体で、見開きのDOMを自分で組むホスト向けです。FlatPageFlipperは3Dの本より前からある平面のページめくりで、レシピ集のライトテーブルのために残されています。オプションの一覧はパッケージのREADMEにあります。

#ライブサンプル:3Dの本

このCodePenのサンプルは、postextとpostext-folioをCDNからインポートし、短いドキュメントをレイアウトして本として開きます。右ページの端をつかんで、向こう側へドラッグしてみてください。

Postext · ドキュメントを3Dの本に
import { buildDocument } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';
 
const paragraph = `The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved. The light it gave was small, but it was enough to find the step.`;
 
// Thirty-six short sections: about ten pages to turn.
const markdown = ['# The Lantern']
  .concat(Array.from({ length: 36 }, (_, i) => `## Evening ${i + 1}\n\n${paragraph} ${paragraph}\n\n${paragraph}`))
  .join('\n\n');
 
const config = {
  page: { sizePreset: '17x24', dpi: 150 },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('italic 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
 
const doc = buildDocument({ markdown }, config);
const status = document.getElementById('status');
 
// The book: drag a page by its edge, click it, or use ← → and the buttons.
// Pages are painted at the size they are shown, around the open spread only.
createFolioFromDocument(document.getElementById('book'), doc, {
  onChange: ({ pages }) => {
    status.textContent = `${doc.pages.length} pages · open at ${pages.map((i) => i + 1).join('–')}`;
  },
});
status.textContent = `${doc.pages.length} pages · drag a page by its edge to turn it`;
index.html
<p id="status">Laying out…</p>
<div id="book"></div>
style.css
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  color: #eee;
  background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
  min-height: 100vh;
}
#status {
  margin: 12px 16px 0;
  font-size: 14px;
  opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
  height: calc(100vh - 48px);
  --postext-folio-accent: #f0b35a;
}

codepen.ioからインタラクティブなエディターを読み込みます。サンプルは最新リリースのpostextをCDNから読み込みます。

#ライブサンプル:ページの画像

キャンバスに描いたページ、空白の最終ページ、紙の色を使ったcreateFolioの例です。

Postext · 画像の3Dの本
import { createFolio } from 'https://esm.sh/postext-folio';
 
// Any pages will do: image URLs, <img> or <canvas> elements, and "" for a
// blank page. Here, eight pages drawn on canvases.
function drawPage(n) {
  const canvas = document.createElement('canvas');
  canvas.width = 600;
  canvas.height = 840;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbf8f1';
  ctx.fillRect(0, 0, 600, 840);
  ctx.fillStyle = `hsl(${n * 45} 45% 45%)`;
  ctx.fillRect(60, 80, 480, 320);
  ctx.fillStyle = '#222';
  ctx.font = 'bold 56px Georgia, serif';
  ctx.fillText(`Plate ${n}`, 60, 480);
  ctx.font = '22px Georgia, serif';
  for (let line = 0; line < 8; line++) ctx.fillRect(60, 530 + line * 30, line === 7 ? 260 : 480, 3);
  ctx.textAlign = 'center';
  ctx.fillText(String(n), 300, 800);
  return { src: canvas, alt: `Plate ${n}` };
}
 
const pages = Array.from({ length: 8 }, (_, i) => drawPage(i + 1));
// A blank page at the end, drawn as paper.
pages.push('');
 
const status = document.getElementById('status');
createFolio(document.getElementById('book'), {
  pages,
  firstPageRecto: true, // page 1 opens alone, on the right
  binding: 'left', // 'right' lays a right-to-left book mirrored
  paper: '#fbf8f1',
  onChange: (state) => {
    status.textContent = `Showing ${state.pages.map((i) => i + 1).join('–')} of ${pages.length}`;
  },
});
status.textContent = 'Drag a page by its edge, click it, or use ← →';
index.html
<p id="status">Drawing pages…</p>
<div id="book"></div>
style.css
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  color: #eee;
  background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
  min-height: 100vh;
}
#status {
  margin: 12px 16px 0;
  font-size: 14px;
  opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
  height: calc(100vh - 48px);
  --postext-folio-accent: #f0b35a;
}

codepen.ioからインタラクティブなエディターを読み込みます。サンプルは最新リリースのpostextをCDNから読み込みます。

#EPUBの本(postext-epub)

postext-epubは、レイアウトした本をEPUB 3.3ファイルとして、ブラウザーでもNodeでもサーバーなしで書き出します。本の場合にrenderToPdfが受け取るのと同じ章のドキュメントを読むので、ページ番号、注、引用、相互参照、目次、索引が解決済みの状態で届きます。ファイルはバイト列として返されます。SandboxのEPUB 3タブで使われている書き出し機能です。

npm install postext postext-epub

postextは、postext-pdfと同じくピア依存関係です。2つは一緒にアップグレードし、CDNでは同じバージョンに固定してください。

#固定レイアウトとリフロー型

EPUB 3は2つのレンディションを定義しており、パッケージのrendition:layoutプロパティで設定します。layoutでどちらかを選びます。

layout: 'fixed'layout: 'reflowable'
EPUBでの名前pre-paginated(固定レイアウト、FXL)reflowable(EPUBの既定)
コンテンツ文書印刷ページごとに1つのXHTML文書。仕上がりのページサイズ(CSS px)章ごとに1つのXHTML文書(部はそれ自身の文書を開始します)
保持するものページ:段、フロート、柱、章扉、改行位置と配置を、埋め込んだフォントで。テキストは本物のテキストのままで、選択、検索、読み上げができますテキストとその構造:見出し、行から組み立て直した段落、リスト、asideとしての囲み、引用する本文の後に置く図と表、注、リンク、印刷ページの目印。設定から導いたスタイルシート
失うもの読者による書体、サイズ、余白の選択。小さな画面ではページが縮小されます段、柱、ページデザイン、正確な改行位置
見開きと方向奇数・偶数と綴じから決まるpage-spread-left / page-spread-right。右綴じの本は右から左に読みます読む方向は綴じから決まります。縦組みの中国語はvertical-rlを保ち、アラビア語はdir="rtl"になります
向いているものデザインされたページ:図版の多い本、教科書、カタログ、雑誌。大きな画面流れる本文:小説、エッセイ、レポート。スマートフォンや電子ペーパー端末

どちらのレンディションも同じナビゲーションを持ちます。見出しと部扉から作る目次、印刷ページのラベルによるページリスト、ランドマーク(表紙、印刷された目次、本文の開始)、古いリーディングシステム向けのNCXです。

#本を書き出す

import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
 
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // one VDTDocument per chapter, in book order
 
const bytes = await renderToEpub(docs, {
  layout: 'reflowable',
  metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
  fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
  resourceBytes: (fileId) => {
    const data = bundle.files.get(fileId);
    return data ? { bytes: data, mediaType: '' } : undefined;
  },
  onWarning: (w) => console.warn(w),
});

単独のドキュメントは、1章だけの本として渡します(renderToEpub([doc], options))。

  • renderToEpub(docs, options): Promise<Uint8Array>:ファイルを書き出します。optionsは{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }です。
  • metadata:titleとlanguage(BCP 47タグ)は必須で、subtitle、creators、identifier、date、publisher、rights、description、modifiedは省略できます。ISBNだけを書くとurn:isbn:…になります。identifierがなければ、本にはタイトル、著者、言語から導いたurn:uuid:が付くので、同じ本の新しい版も読者のライブラリーで同じ位置を保ちます。バイト単位で同一の出力を得るには、modifiedも渡してください。
  • fonts:埋め込むフェイス。{ family, weight, style, bytes, format, unicodeRange? }の形で、formatはwoff2、woff、ttf、otfのいずれかです。各フェイスは1つのファイルと1つの@font-face規則になり、unicodeRangeを持つ複数のファイルで1つのフェイスを構成することもできます(Google Fontsのスライス)。ページが使うファミリー、ウェイト、スタイルに埋め込みフェイスがない場合はmissingFontとして報告され、リーディングシステムが独自のフォントで代替します。ライセンスが許すフォントだけを埋め込んでください。redistributable: falseのフェイスがファイルに書き込まれることはありません(本をそのフェイスで組むことはできますが、ファイル自体は入らず、SVGの画像にも入りません)。
  • resourceBytes(fileId):ページが配置する画像を、同期または非同期で{ bytes, mediaType }として返します。mediaTypeが空ならバイト列から判別します。ビットマップは格納されたまま、SVGはソースのまま渡し、PDFの印刷用マスター(svg.pdfFileId)は渡さないでください。各画像は1回だけ格納されます。単色インクの本(diagramStyle.singleInk)では、ファイル内のSVGが色替えされます。リーディングシステムはSVGを本のフォントが見えない画像として表示するので、SVGには文字が指定するフェイスが埋め込まれます(SVGの文字のフォントを参照)。まずfontsから(その文字を収めるスライス)、本文で使われていないファミリーは次にsvgFonts.providerから取ります。redistributable: falseと指定されたフェイスのファミリーと、svgFonts.withhold(family)が指定するファミリーは入れず、それぞれ1回ずつfontWithheldとして報告します。フェイスのないファミリーはsvgFontUnavailable、svgFonts.maxBytes(2 MiB)を超えるフェイスはsvgFontsTooLargeとして報告されます。svgFonts.inline: false、diagramStyle.inlineFonts: false、リソースのsvg.inlineFonts: falseのいずれかを指定すると、バイト列は渡されたままになります。バイト列のない画像はmissingImageとして報告され、空の枠として残ります。
  • cover:{ bytes, mediaType, alt? }。JPEG、PNG、WebP、SVGの画像です。指定すると、本はそれを収めた表紙の文書から始まり、その画像がパッケージのcover-image(ライブラリーのサムネイル)になります。指定しなければ、固定レイアウトでは最初のページが表紙として指定され、リフロー型の本には表紙画像がありません。
  • onProgress({ phase, done, total }):resources(フォントと画像)、documents(固定レイアウトではページ、リフロー型では章)、packageの順に進みます。signal:ステップの合間で中止します。
  • readEpub(bytes):ビューアーのためにファイルを読み戻します。DOMParserは使いません。レイアウト、メタデータ、読む方向、パスごとのすべてのファイル、マニフェスト、スパイン、目次、ページリスト、固定レイアウトのビューポート、表紙を返します。Sandboxのリーダーはこれをもとに作られています。

どちらのレンディションも、EPUB Accessibility 1.1のメタデータ(アクセスモード、目次や印刷ページ番号などの機能、ハザード、概要)を持ち、既定ではWCAGへの準拠を主張しません。オプションの一覧と制限事項はパッケージのREADMEにあります。

#EPUBCheckでファイルを検査する

W3C EPUBCheckはEPUBのリファレンスとなる検証ツールで、電子書籍ストアは受け取ったファイルをこれで検査します。インストールすると(brew install epubcheck、またはJava版のリリース)、epubcheck book.epubでエラー、警告、使用上の注意が一覧表示されます。Postextの出力に残る使用上の注意(CSS-028、OBS-001、HTM_062)は情報提供のためのものです。Postextのリポジトリーでは、pnpm --filter postext-epub epubcheckがテストスイートのサンプルの本を検査し、node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout bothは1つの.postextファイルまたはプリセットのフォルダーをレイアウトして両方のレンディションを検査し、pnpm --filter postext-epub validateは本の組み合わせ全体(Postextガイド、ショーケースのプリセット、中国語、アラビア語、レシピ集の本)を検査します。どの本もエラーも警告もなく合格します。