本文へスキップ

章 12 · 部 II · 技法

設定:プログラムからの利用

コードからPostextを使う:buildDocument、Web Worker、HTMLビューアー、PDF、3Dの本、EPUB、.postextバンドル

更新日 2026-10-108分enescaptzhjaar

かんたんな説明

このページは、プログラムを書く人向けです。1回の関数呼び出しで本を組む方法と、返ってくる警告の読み方を示します。ページの動きを軽く保つために、処理を裏で動かす方法を説明します。Web表示、PDFファイル、3Dの本、EPUBの電子書籍を作る方法を示します。最後の節では、本をまるごとフォントや画像と一緒に運ぶファイルを説明します。

#プログラムからの利用

推奨する方法:Web Workerを使う。ブラウザーでの統合は、ほぼすべての場合、メインスレッドでbuildDocumentを直接呼ぶのではなく、postext/workerのcreateLayoutWorker()を通して組版パイプラインを動かすべきです。ワーカーを使えば、ビルド中もUIが応答し続け、差分の再ビルドをまたいでテキストの計測結果がキャッシュされ、「最後の要求が勝つ」キャンセル(last-wins)が組み込まれるので、新しいキー入力が実行中の古いビルドを中止します。標準の手順はWeb Workerでレイアウトを実行するにまとめてあります。この節の残りの内容(buildDocumentの直接呼び出し、リゾルバー、既定値の除去関数、キャッシュ)も役に立ちます。ワーカーはまったく同じ入力と出力を公開しているからです。ただし、UIのコードではワーカーのラッパーから始めるのが正解です。メインスレッドでbuildDocumentを呼ぶのは、1回きりの書き出し、サーバーサイドでの描画(Node)、テストに限ってください。

#文書のビルド

buildDocument関数は組版パイプライン全体を実行し、すべての要素の正確な座標を持つ仮想文書ツリー(Virtual Document Tree、VDT)を返します。これが最も低レベルの入口です。UIのコードではWeb Workerのラッパーを使ってください。ラッパーは専用のワーカースレッドの中で、同じ引数でbuildDocumentを呼びます。

import { buildDocument } from 'postext';
 
const content = {
  markdown: '# Chapter One\n\nThe story begins here...',
};
 
const config = {
  page: { sizePreset: '17x24' },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
};
 
// Build the layout — produces a VDT with one entry per page in `vdt.pages`
const vdt = buildDocument(content, config);
console.log(`Document has ${vdt.pages.length} pages`);

#文書の中の警告

buildDocumentは、誤った参照や未知のスタイルがあっても止まりません。代わりの値を当て、何をしたかをdoc.contentWarningsに記録します。レイアウトがやむを得ず配置したボックスはdoc.warningsに入ります。こちらはpostext 1.4のときの形を保っており、各項目はpageIndex、columnIndex、overflowPxを持つcalloutOverflowです。報告することがなければ、それぞれのフィールドは存在しません。どの項目にもkindがあります。内容に関する種類は、その構文のソース範囲(渡したMarkdown内のオフセットであるsourceStart / sourceEnd。フロントマターも含めて数えます)と、構文がページに配置された場合はそのpageIndexを持ちます。

種類発生する条件出力の扱い
calloutOverflow:::calloutのボックスがどの段にも収まらず、どこで切っても分割できない。空のサイド段より高いspan: 'side'のボックスも同じです(postext 1.25から)。それでも配置され、段からoverflowPxだけはみ出します(pageIndex / columnIndexの位置)。doc.warningsに入る唯一の種類で、以下の種類はdoc.contentWarningsに入ります。
invalidFrontmatterフロントマターが正しいYAMLではありません(閉じていない引用符、引用符で囲んだ値の後に続く文字など)。messageはパーサーが示す理由と、その行と列です。文書はメタデータなしで組版されます。閉じの---より後の本文は通常どおり組版されます。
unknownResourceId::resourceの埋め込み(usage: 'embed')、インラインの:ref('ref')、表のセルの画像('cellImage')が、どのリソースにもないidを指している。埋め込みは省かれ、参照は番号もリンクもなしに?(またはtext=のラベル)を印字し、セルはテキストだけになります。inResourceは、その参照をキャプション、注、セルに含むリソースを示します。
unknownDirective名前がディレクティブでもコンテナーでもない:::nameの行。その行はテキストとして組まれます。
malformedEmbed単独で立つ正しい形の埋め込みになっていない::nameの行。idが引用符なしか一重引用符で書かれている、または別の属性を持つ::resourceや、空行を挟まずに段落の下に続けて書かれた行です。その行はテキストとして組まれます。
fullwidthMarkup中国語や日本語の入力方式で入力されたマークアップを含む行。:::のフェンス、#の見出し、[^…]の脚注記号、フェンスや見出しの後の{…}属性、**…**の太字が対象です。typedは書かれたままのマークアップ、asciiは入力すべき形です。1行につき1件。その行はテキストとして組まれ、何も変換されません。
attributeKeyInvalid属性のキーにASCII以外の文字が含まれている(作者=曹雪芹)。キーの位置を指します。その属性は無視されます。
unknownParagraphStyle:::paragraphsstyleの名前に該当する段落スタイルがない。段落は本文として組まれます。
unknownCalloutType:::callouttypeの名前がcalloutStylesのどれにも該当しない。囲みスタイルが1つ以上設定されているときにだけ発生します。ボックスには最初の囲みスタイルが使われます。
columnsFlowUnknown:::columnsflowがsnakeでもparallelでもない。valueは書かれている値です。グループには既定値が使われます。breaksがあればparallel、なければsnakeです。
unknownChipStyle:chip[…]styleの名前に該当するチップスタイルがない。チップには最初のチップスタイルが使われます。
undefinedFootnoteどの[^id]:段落でも定義されていない脚注記号[^id](idはその注のid)。番号は印字され、注は空になります。
unusedFootnoteどの記号からも参照されていない脚注定義[^id]:。注は組まれません。
indexMarkInvalid語のない索引マーク。:index、または角かっこのテキストを持たないマークにtermのない属性を付けたものです。そのマークは何も索引に載せません。
indexSeeUnknownseeまたはseealsoの参照先(target)が、その索引(index。主索引は'')の項目にない。:::indexの行を指します。相互参照はそのまま印字されます。
indexRangeUnclosed対応するrange="end"のないrange="start"のマーク、またはその逆(missingは欠けている側、termは項目を示します)。:::indexの行を指します。範囲はその1ページだけを印字します。
unknownHeadingStyle見出しのstyle="…"の名前に該当する見出しスタイルがない(levelはその見出しのレベル)。見出しとその節は、そのレベル自体の設定のままになります。
unknownTableStyle表リソースのtable.styleIdがtableStylesのどの項目にも該当しない。表はtableStyleで組まれます。
raggedTableGrid結合を数に入れると、表のグリッドが長方形にならない(表モデルの構築を参照)。セルが結合部分にずれ込むか、穴が残ります。reason('spanOverlap' / 'missingCells')、row、colが最初の問題の位置を示し、countが問題の数を示します。
lineNumberOverlaplineNumbers.position: 'side'で、行番号がサイド段の囲み、キャプション、図に重なる。番号を付けた行を指し、numberは印字される番号です。番号はそのまま描かれ、どちらも動きません。
dropCapドロップキャップで始まる段落が、設定どおりにそれを組めない。reasonは、'shortParagraph'(頭文字が沈む行数より段落の行が少ない。handlingはshortParagraphが行った処理、linesは縮めた頭文字がまたがる行数)、'split'(短すぎる段に単独で置かれ、頭文字の最後の行より前で分割される)、'joiningScript'(最初の文字が次の文字と連結する)、'verticalText'、'noLetter'(参照、数式、注の印で始まる)のいずれかです。textはその1行目です。警告が示すとおり、スペースが確保されるか、頭文字が縮むか、頭文字が省かれます。
codeOverflowコードリストに枠より長い行がある。modeはcodeStyle.overflowが行った処理('wrap'、'shrink'、'clip')、linesは長すぎた元の行の数、scaleは縮小したコードリストを組んだサイズ(fontSizeに対する割合)、langはフェンスの言語です。コードリストを指します。modeが示すとおり、行は折り返されるか、小さく組まれるか、切られます。
floatShrunkフロートの図版を、置き場所の空きに収めるため元より小さく組みました(placement.shrink)。resourceIdはその図版、scaleは保った幅の比率です。overflowPxがあれば、ほかに行き場のない新しいページで、最小倍率(placement.minScale)で組んでもなお版面の下端からはみ出す量です。最初に引用している段落を指します。図版はその倍率で印刷されます。版面からはみ出すのはoverflowPxがあるときだけです。
textWrap本文を回り込ませる設定のリソースまたはボックス(placement.wrap、ボックスのwrap)が指定どおりに組まれなかったこと。reason:'tooNarrow'(横の本文がlayout.wrap.minTextWidthより狭くなる)、'fewLines'(layout.wrap.minLinesBeside行より低い)、'moved'(段の残りに収まらない本文中の対象がアンカーとともに次の段へ移った)、'verticalText'。resourceIdはリソース、boxはボックスのスタイル。埋め込みまたはボックスを指します。理由に応じて、対象は全幅を占めるか、次の段に置かれます。
columnsTooNarrow:::columnsグループの小段が、そのテキストの6 em より狭い。columns段で、それぞれwidthPx。グループのフェンスを指します。グループは指定どおりに組まれ、1行に入る語はわずかになります。
afterTextspan: 'side'のボックス、またはサイド段の図・表(resourceId)が、本文のないページに置かれた。サイド段の空きを待っているうちに、章または文書の本文が終わった。ボックスまたはフロートごとに1件。ボックス(またはフロートを参照するブロック)を指し、ページが付きます。postext 1.25から。本文の後に開いたページのサイド段に置かれます。ボックスはフェンスの順に並びます。
unplaced組版が終わった時点でまだ空き位置を待っていたボックスまたはフロートのリソース(resourceId)。そのために開いたページには置けなかった(サイド段のないページでのサイド段のボックスなど)。ボックス、またはリソースを参照するブロックを指します。ページはありません。postext 1.25から。どのページにもありません。postext 1.24までは、警告なしに消えていました。
fontFallbackテキストを組んだフェイス(family、weight、style)を、ビルドの時点でフォントセットが用意できなかったこと。reason: 'missing'は、そのファミリーのフェイスが1つも読み込まれておらず、インストールもされていない場合、またはそのウェイトと傾きに対応するフェイスがまだ読み込まれていなかった場合。'synthesized'は、ファミリーにそのウェイトや傾きのフェイスがなく、ブラウザーが別のフェイスをそのまま、あるいは太くしたり傾けたりして使っている場合です(600を700のフェイスで組んだとき、400のレギュラーしかないファミリーに700のイタリックを求めたときなど)。フォントセットがある環境(document.fonts、ワーカーのself.fonts、BuildDocumentOptions.fontSet)で検査し、debug.warnings.missingFontで切り替えます。ページもソースの範囲も持ちません。テキストは代替フォントで、またはファミリーの別のフェイス(そのまま、あるいはブラウザーが太くしたり傾けたりしたもの)で計測・描画されます。そのフェイスが届くと改行が変わります。レイアウトの前にフォントを読み込むを参照してください。

リソースに関する警告(表スタイル、グリッド、キャプションや注、セルの中の参照)は、本文の中でそのリソースが最初に埋め込まれた位置か参照された位置を指し、リソースごとに1回だけ挙がります。検査するのは文書が使うリソースだけです。本の1章は、本が持つすべての表ではなく、その章が参照する表について報告します。

import { buildDocument, formatWarning } from 'postext';
 
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.\n\n:::sidebar\nNotes.' }, config);
for (const w of [...(doc.warnings ?? []), ...(doc.contentWarnings ?? []), ...(doc.configWarnings ?? [])]) console.warn(formatWarning(w));
// Unknown resource id "fig-map" in :ref — it prints "?" (or its text= label), with no number or link (page 1, offset 4)
// Unknown directive ":::sidebar" — the line is set as text (page 1, offset 25)
 
// Narrow on `kind` to read the fields of a kind.
const missing = (doc.contentWarnings ?? []).flatMap((w) => (w.kind === 'unknownResourceId' ? [w.resourceId] : []));

formatWarning(w)は英語の説明を1行で返します。メッセージをローカライズするホストは、代わりにkindで分岐してください。マイナーリリースで種類が増えることがあるので、既定の分岐も残しておきます。collectContentWarnings(markdown, config, resources)は、何もレイアウトせずに内容の警告を返します(ビルドが加えるのと同じリストで、pageIndexはありません)。入力しながらテキストを検査するエディター向けです。collectHeadingDesignCuts(doc)は、組み上がったレイアウトを調べ、見出しデザインのテキストがページや段の下端を越えているものを探します(kind: 'headingDesignCut'。確保する高さを参照)。これはレイアウト自体は報告しないもので、その結果もformatWarningで説明できます。Sandboxの検査パネルは、これらをすべて一覧にします。

レンダラーは、指定どおりに描けなかったものをonWarningオプションで報告します。対象はrenderPageToCanvas、renderPage、renderToCanvas(RenderPageOptions)、renderToHtmlとrenderToHtmlIndexed(RenderHtmlOptions)、renderToPdf(RenderToPdfOptions。PDFワーカー経由でも可)です。描画時の主な種類はmissingImageです。描くものがない画像(図、表のセルの画像、囲みのアイコン、デザイン画像)は中立的なプレースホルダーとして描かれ、fileIdと描画呼び出しごとに1回報告されます。報告にはpageIndex、描画側がわかる場合はresourceId(図とセルの画像)、PDFでは複数文書の描画におけるdocumentIndexが含まれます。描くものがないとは、canvasではそのfileIdのregisterResourceImageがないこと、HTMLではresourceImageUrlからURLが得られないこと、PDFではresourceBytesからバイト列が得られないか、得られたバイト列をデコードできないことを指します。fileIdをまったく持たないビットマップやSVGのリソースは、要求するものがないので、報告なしにプレースホルダーとして描かれます。ほかに2つの種類が、SVGの画像にフォントを埋め込むホスト(registerSvgImage、registerBundleImages、bundleImageUrl、inlineSvgFontsを指定したrenderToHtml、postext-epub。SVGの文字のフォントを参照)から、画像のfileIdとresourceIdとともに報告されます。svgFontUnavailable(family、weight、style)は、文字が指定するファミリーに埋め込めるフェイスがないことを示し、画像はその文字を代替のフェイスで組みます。svgFontsTooLarge(bytes、maxBytes)は、フェイスがサイズ上限を超え、1つも埋め込まれなかったことを示します。描画時の警告はVDTには保存されません。ホストが用意できるものはレイアウトの後で変わるからです。

import { buildDocument, renderPage, type RenderWarning, type Resource } from 'postext';
 
const map: Resource = {
  id: 'fig-map', typeId: 'figure', kind: 'bitmap', caption: 'The route.', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'map-file', format: 'png', width: 1200, height: 800 },
};
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.', resources: [map] }, config);
 
const warnings: RenderWarning[] = [];
const canvas = renderPage(doc.pages[0], doc, { onWarning: (w) => warnings.push(w) });
// Until 'map-file' is registered with registerResourceImage:
// [{ kind: 'missingImage', fileId: 'map-file', resourceId: 'fig-map', pageIndex: 0 }]

#ページをビットマップに描画する

各ページは個別にラスタライズできます。renderPage(page, doc)を使うと、指定したページのHTMLCanvasElementが得られます。このcanvasはページの寸法(設定したDPIでのピクセル数)ちょうどの大きさのビットマップなので、表示や書き出しに使ったり、任意の画像処理に渡したりできます。

import { buildDocument, renderPage } from 'postext';
 
const vdt = buildDocument(content, config);
 
// Render page 3 (zero-indexed) to a bitmap canvas
const pageNumber = 2;
const page = vdt.pages[pageNumber];
if (!page) throw new Error(`Page ${pageNumber} does not exist`);
 
const canvas = renderPage(page, vdt);
// canvas.width / canvas.height are the page bitmap size in pixels
 
// Show it in the DOM
document.body.appendChild(canvas);
 
// …or export it as a PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
 
// …or get a Blob for download / upload
canvas.toBlob((blob) => {
  if (blob) saveAs(blob, `page-${pageNumber + 1}.png`);
}, 'image/png');
 
// …or grab raw RGBA pixels
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

すでに持っているcanvas(たとえば特定のレイアウトでDOMに配置したもの)に描きたい場合は、renderPageToCanvas(page, doc, canvas)を使います。新しいcanvasを作る代わりに、渡したcanvasの大きさを変えて描画します。

全ページを描画するにはvdt.pagesを順に処理します。

const bitmaps = vdt.pages.map((page) => renderPage(page, vdt));

ライブサンプル:ページを画像にする

ここまでの内容をブラウザーで動かしたものです。このCodePenのサンプルは、最新のpostextリリースをCDNからインポートし、Webフォントを待ってから短い2段組みの文書をレイアウトし、最初のページをcanvasに描いて、そのビットマップをPNGとして提供します。CodePenで実行を押すとエディターが読み込まれ、Markdownや設定を変更できます。編集するたびにページが描き直されます。

Postext · ページを画像に描画する
import { buildDocument, renderPage } 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 = {
  // 150 dpi: crisp enough for a preview, light enough to paint instantly.
  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
]);
 
// The whole layout: one entry per page in doc.pages, with exact coordinates.
const doc = buildDocument({ markdown }, config);
 
// Rasterise the first page. The canvas is sized to the page at the configured dpi.
const canvas = renderPage(doc.pages[0], doc);
document.getElementById('page').replaceChildren(canvas);
document.getElementById('status').textContent =
  `${doc.pages.length} page(s) · page 1 is ${canvas.width} × ${canvas.height} px`;
 
// The same bitmap as a PNG file.
canvas.toBlob((blob) => {
  const link = document.getElementById('download');
  link.href = URL.createObjectURL(blob);
  link.hidden = false;
}, 'image/png');
index.html
<p id="status">Laying out…</p>
<a id="download" download="page-1.png" hidden>Download page 1 as PNG</a>
<div id="page"></div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#page canvas {
  display: block;
  max-width: 100%;
  height: auto;
  margin-top: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

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

#React

postext/reactはcreateLayout(content, config?)を公開しています。これはマウント時に文書を1回だけレイアウトし、各ページを<div>の中の<canvas>として表示するコンポーネントです。

import { createLayout } from 'postext/react';
 
const Article = createLayout(
  { markdown: '# Hello\n\nThe first paragraph of the article.' },
  { page: { sizePreset: '17x24' } },
);
 
export function ArticlePage() {
  return <Article className="pages" style={{ maxWidth: 480 }} />;
}
  • メインスレッドで1回だけ。ページは文書の解像度で描かれ、コンテナーの幅に合わせて拡大縮小されます。contentとconfigはcreateLayoutを呼んだ時点で固定されるので、別の内容を表示するには別のコンポーネントを作ります。ライブプレビューには、Web Workerでビルドし、そこにあるReactの例のようにrenderPageToCanvasで描画してください。
  • フォントと画像を先に。コンポーネントをマウントする前に文書のWebフォントを読み込み、画像をregisterResourceImageで登録しておきます。Markdownに$が含まれていれば、コンポーネントが自分で数式エンジンを起動します。
  • Reactはメインのエントリーに含まれない。postextはReactをインポートせず、インポートするのはpostext/reactだけです。既存のコードが動き続けるよう、createLayoutは今もpostextから公開されていますが、非推奨です。呼び出したときにpostext/reactを読み込み、それが届くまでコンポーネントはサスペンドします(届けばReactが自動で再描画します)。postext/reactからインポートしてください。
  • 非推奨のコンポーネントはサスペンドする。postext/reactが届くまで、postextのcreateLayoutには、コンカレントルート(createRoot)か、上位の<Suspense>境界が必要です。レガシーのReactDOM.renderルートやrenderToStringで境界がなければ、Reactはエラーを報告します。バンドラーがこの遅延インポートを解決できるよう、reactは引き続き必須のピア依存関係です。

#既定値の解決

リゾルバー関数は、部分的な設定オブジェクトに既定値を補います。調べたり比べたりするために完全な設定が必要なときに便利です。

import { resolvePageConfig, resolveBodyTextConfig } from 'postext';
 
const fullPage = resolvePageConfig({ sizePreset: '21x28' });
// => { sizePreset: '21x28', width: { value: 21, unit: 'cm' }, height: { value: 28, unit: 'cm' },
//      margins: { top: { value: 2, unit: 'cm' }, ... }, dpi: 300, cutLines: { enabled: false, ... }, ... }
 
const fullBody = resolveBodyTextConfig({ fontFamily: 'Inter' });
// => { fontFamily: 'Inter', fontSize: { value: 8, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }

リゾルバーはトップレベルの節ごとに1つあります。resolvePageConfig、resolveLayoutConfig、resolveBodyTextConfig、resolveHeadingsConfig、resolveHeadingStylesConfig、resolveTocConfig、resolvePartsConfig、resolveUnorderedListsConfig、resolveOrderedListsConfig、resolveMathConfig、resolveTableStyleConfig、resolveCaptionStyleConfig、resolveDiagramStyleConfig、resolveParagraphStylesConfig、resolveCalloutStylesConfig、resolveHeaderFooterConfig、resolveDebugConfig、resolveHtmlViewerConfig、resolvePdfGenerationConfig。これに加えて、デザインスロット1つを解決するresolveDesignSlotがあります。カラーパレットは、applyPaletteToConfig(config)、applyPaletteToResolvedConfig(resolved, palette)、resolveColorValue(value, palette, fallback)で別に適用します。カラーパレットを参照してください。

既定値が別の節から引き継がれるリゾルバーは、解決済みのその節を追加の引数に取ります。リストのfontFamilyとcolorの既定値は本文から引き継がれるので、resolveUnorderedListsConfigとresolveOrderedListsConfigは解決済みの本文を取ります。resolveCalloutStylesConfigは解決済みの本文、見出し、箇条書きリストを取り(囲みスタイルの例を参照)、resolveHeadingStylesConfigは解決済みのページ、本文、2つのリストの節を取ります。それぞれの正確なシグネチャーは、パッケージの型宣言で確認してください。

import { resolveBodyTextConfig, resolveUnorderedListsConfig } from 'postext';
 
const body = resolveBodyTextConfig({ fontFamily: 'Inter' });
const lists = resolveUnorderedListsConfig({ bulletChar: '—' }, body);
// => lists.fontFamily === 'Inter' (inherited)

静的な既定値(引き継ぎが関わらないときに使われる値)も公開されています。DEFAULT_PAGE_CONFIG、DEFAULT_CUT_LINES、DEFAULT_PAGE_NUMBERING、PAGE_SIZE_PRESETS、DEFAULT_LAYOUT_CONFIG、DEFAULT_COLUMN_RULE、DEFAULT_COLUMN_BALANCING、DEFAULT_BODY_TEXT_CONFIG、DEFAULT_HYPHENATION_CONFIG、DEFAULT_HEADINGS_CONFIG、DEFAULT_UNORDERED_LISTS_STATIC、DEFAULT_ORDERED_LISTS_STATIC、DEFAULT_PARAGRAPH_STYLES、DEFAULT_CALLOUT_STYLES、DEFAULT_CALLOUT_STYLE_STATIC、DEFAULT_PARTS_CONFIG、DEFAULT_HEADING_STYLES、DEFAULT_TOC_CONFIG、DEFAULT_MATH_CONFIG、DEFAULT_DIAGRAM_STYLE_CONFIG、DEFAULT_DEBUG_CONFIG、DEFAULT_HTML_VIEWER_CONFIG、DEFAULT_PDF_GENERATION_CONFIG、DEFAULT_COLOR_PALETTE、DEFAULT_MAIN_COLOR、DEFAULT_MAIN_COLOR_ID、DEFAULT_MAIN_COLOR_NAME、DEFAULT_MAIN_COLOR_HEX、柱とノンブルの要素の既定値(DEFAULT_HEADER_FOOTER_SLOT、DEFAULT_HEADER_SLOT、DEFAULT_FOOTER_SLOT、DEFAULT_TEXT_ELEMENT、DEFAULT_RULE_ELEMENT、DEFAULT_BOX_ELEMENT)、そしてロケールに応じたdefaultResourceTypes(locale)です(リソースの種類を参照)。

#既定値の除去

設定を保存するとき(localStorageやファイルなど)は、stripConfigDefaultsで既定値と一致する値を取り除きます。保存する設定が最小限になり、意図して変更した値だけが残ります。

import { stripConfigDefaults } from 'postext';
 
const minimal = stripConfigDefaults(fullConfig);
// Only properties that differ from defaults remain

リゾルバーごとに個別の除去関数もあります。stripPageDefaults、stripLayoutDefaults、stripBodyTextDefaults、stripHeadingsDefaults、stripHeadingStylesDefaults、stripTocDefaults、stripPartsDefaults、stripUnorderedListsDefaults、stripOrderedListsDefaults、stripMathDefaults、stripTableStyleDefaults、stripCaptionStyleDefaults、stripDiagramStyleDefaults、stripParagraphStylesDefaults、stripCalloutStylesDefaults、stripHeaderFooterDefaults、stripDesignSlotDefaults、stripDebugDefaults、stripHtmlViewerDefaults、stripPdfGenerationDefaults。

既定値のなかには、設定のほかの部分で決まるものがあります。段末そろえは文字グリッドと縦組みでは既定でオフになり、脚注、キャプション、索引の既定値は文書の言語に従います。stripConfigDefaultsは、渡された設定そのものの既定値とそれぞれの値を比べます。そのため、段末そろえが既定でオフになるところではheadings.balancing.enabled: trueが残り、falseは取り除かれます。除去関数を単独で呼ぶときは、その文脈を引数で渡します。stripHeadingsDefaults(headings, balancingOnByDefault(config))、stripIndexDefaults(index, locale)、stripCaptionStyleDefaults(captionStyle, locale)、stripFootnotesDefaults(footnotes, locale, writingMode)。

「何もない」ことを表す値は、何もないことが既定でないところでは残ります。見出しスタイルは、自分で指定しなければ文書のヘッダーとフッターを使います。そのため、それらを空にしたスタイル(表紙のfooter: { elements: [] })は空のスロットを保ち、margins、layout、bodyStyleも空のまま残ります。見出し全体の値を変えたうえで、あるレベルを自身の既定値に戻した場合、その値は残ります。calloutStyles: []とchipStyles: []は空のリストのままです。省くと組み込みのスタイルが戻ってしまうからです。どの場合も、resolveAllConfig(stripConfigDefaults(config))はresolveAllConfig(config)と同じ結果になります。

#解析

エンジンは、Markdownのトークナイザーとフロントマターの読み取り関数を公開しています。ビルドの前に文書を調べたり、Postextが見るのと同じブロック構造をほかのツールに渡したりするのに使えます。

import { parseMarkdown, extractFrontmatter } from 'postext';
 
const source = '---\ntitle: Chapter One\n---\n\n# Opening\n\nThe story begins here.';
 
const { metadata, content } = extractFrontmatter(source);
// metadata.title === 'Chapter One'
 
const blocks = parseMarkdown(content);
// => [ { type: 'heading', level: 1, text: 'Opening', … },
//      { type: 'paragraph', text: 'The story begins here.', … } ]

Postextが認識するMarkdownの構文の全一覧は、文書形式のページを参照してください。

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

レイアウトは、実行した時点でフォントセットにあるフェイスでテキストを計測します。prepareFontsは最初のビルドの前に、設定とそのテキストが求めるすべてのフェイスを読み込みます。対象は本文、見出し、リスト、囲みのタイトルと本文、表、デザインのテキスト、柱、目次、コード、漫画の写植で、設定が指定するウェイトと傾きのそれぞれを読み込みます(本文のファミリーは4つです。**と*がその中で太字とイタリックを組むためです)。各フェイスは文書が組む文字の分だけ読み込むので、unicode-rangeのスライスとして配信されるファミリー(Latin Extended、ギリシア文字、アラビア文字、Google FontsとFontsourceのCJKのスライス)からは、テキストに必要なファイルが届きます。

import { prepareFonts, buildDocument, buildDocumentWithFonts } from 'postext';
 
// ホストのファイル。PDFのフォントプロバイダーと同じ取り決めなので、1つの関数を両方で使える。
async function resolve(family: string, weight: number, style: 'normal' | 'italic') {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
}
 
const report = await prepareFonts(content, config, { resolve });
// report.loaded、report.missing、report.synthesized:{ family, weight, style }[]
const doc = buildDocument(content, config);
 
// 1回の呼び出しでもよい:準備し、ビルドし、ページが使ったのに無かったフェイスを読み込んで、もう一度ビルドする。
const same = await buildDocumentWithFonts(content, config, { resolve });
  • ページが宣言しているフェイス(@font-face規則、追加済みのFontFace)は、フォントセットを通して読み込みます(document.fonts.load、ワーカーではself.fonts)。宣言していないフェイスはresolve(family, weight, style, { text, codePoints })に問い合わせます。リゾルバーは、ファイル1つ(バイト列またはURL)、複数のファイル(1つのフェイスのスライス)、スライスや可変の範囲を表す{ source, unicodeRange, weight, style }、またはnullを返します。エンジンはすべての読み込みが終わってから、設定の順と各回答の順(リゾルバーがそう返せば、latin、latin-ext、greekの順)にそれらをFontFaceとして追加します。そのため、ファイルがどんな順で届いてもフォントセットは同じになります。さらに、SVGの画像とレイアウトのワーカーが読むフォントレジストリにも登録します。リゾルバーが自分でフェイスを宣言し(スタイルシートを追加して)、nullを返してもかまいません。
  • レポートには、読み込んだフェイス(またはインストール済みのファミリー)で賄えるフェイス、まだmissingのフェイス、ブラウザーが別のウェイトや傾きからsynthesizeするフェイスが並びます。待ち時間の上限はtimeoutMs(既定は10 000)で、その時点でまだ読み込み中のフェイスは欠落として数えます。フォントセットがない環境(Node)では、prepareFontsは何もせず、すべてのフェイスを読み込み済みとして報告します。
  • buildDocumentWithFonts(content, config, options):フォントを準備し、buildDocumentAsyncでビルドし、ページが実際にテキストを組んだフェイスを読み取り、フォントセットが用意できなかったものを読み込んで(ページを組んで初めてわかるウェイトは、ファミリーの別のウェイトで代用できる場合でもresolveに求めます)、もう一度ビルドします(追加のビルドは最大2回)。withLoadedFonts(build, options)は、任意のビルド関数のまわりで同じことを行います。章ごとにビルドする本や、複数の文書を返すバンドル(buildBundle)に使えます。options.onFontsは最終的なレポートを受け取ります。
  • ビルドの後、テキストを組んだフェイスのうちフォントセットが用意できなかったものは、すべてdoc.contentWarningsにfontFallbackとして挙がります(文書の中の警告を参照)。

後から届いたフェイスは、エンジンが自分で拾います。エンジンはフォントセットごとに、前回確かめたときに各ファミリーのどのフェイスが読み込まれていたかを覚えています。ビルドは開始時にもう一度確かめ(フォントセットが増えたか減ったか、読み込み中か、フェイスの読み込みが終わったときだけ)、フェイスが変わったファミリーで計測した結果を捨てます。watchFonts(fontSet)はフェイスが読み込まれるたびに同じことを行い、一度に届くスライスがいくつあっても、アニメーションフレームあたり最大1回にとどめます。onFontsChanged(listener)は、どのファミリーが変わったかをホストに知らせるので、ホストはページをレイアウトし直せます。

import { watchFonts, onFontsChanged } from 'postext';
 
const stop = watchFonts();                         // 既定はdocument.fonts
const off = onFontsChanged((families) => relayout());

prepareFontsは、読み込み先のフォントセットの監視を始めます(watch: falseで無効になります)。

#計測キャッシュ

テキストの計測は、レイアウトの中で負荷の高い処理です。2種類のキャッシュがこれを軽くしています。

  • 自分で持つブロックキャッシュ。createMeasurementCache()はMeasurementCacheを返します。これは計測したすべての段落を、テキスト、フォント、幅、改行のオプション、有効なハイフネーション辞書をキーとして記憶します。buildDocument(またはbuildDocumentAsync)の3番目の引数に渡すと、収束パスの間やビルドの間で計測結果を再利用できます。キー入力のたびに文書をレイアウトするエディターなら、変更された段落だけを計測すれば済みます。キャッシュがなければ、パスのたびにすべてのブロックを計測し直します。キャッシュから読んだ段落は新たに計測した段落と同じなので、キャッシュを使ったビルドは、使わないビルドとすべての行を同じように組みます。postext 1.4.1では、キャッシュした段落から最終行がラントであるという印が失われ、ラントの詰めや段末そろえで違う改行になることがありました。キャッシュは、自分が埋められたときの計測の世代を持っています。あるファミリーのフェイスが届いたり消えたりすると、次の参照のときにそのファミリーで組んだブロックを捨てるので、フォントの読み込みをまたいで保持したキャッシュが、代替フォントで計測した行を返すことはありません。
  • グローバルな幅のキャッシュ。語の幅は、ページ内のすべてのビルドが共有するモジュールの状態に、フォント文字列ごとにキャッシュされます。pretextも独自のキャッシュを持っています。エンジンは、あるファミリーのフェイスが変わると(ビルドの開始時、watchFonts、prepareFonts、loadBundleFonts)、そのファミリーの幅を捨てます。pretextのキャッシュにはファミリーごとの索引がないので、丸ごと消去します。
import { buildDocument, createMeasurementCache, evictFontFamilies, clearMeasurementCache } from 'postext';
 
const cache = createMeasurementCache();
let doc = buildDocument(content, config, cache);
 
// "EB Garamond"のフェイスがdocument.fontsに追加された:次のビルドはそれを見て、
// 同じキャッシュのまま、そのファミリーを計測し直す。
doc = buildDocument(content, config, cache);
 
// エンジンから見えないフェイス(ホスト独自のフォントセット)を変えるホストは、そう伝える:
evictFontFamilies(['EB Garamond']);   // そのファミリーの幅とキャッシュしたブロック
clearMeasurementCache();              // すべてのファミリー

テキストを少しずつ計測するアプリケーション向けに、cachedMeasureBlock(text, font, maxWidthPx, lineHeightPx, options, cache)とcachedMeasureRichBlock(spans, normalFont, boldFont, italicFont, boldItalicFont, maxWidthPx, lineHeightPx, options, cache)があります。measureBlockとmeasureRichBlockの引数に加えて、最後にキャッシュを取ります。

#1つのページで共有されるグローバルな状態

Postextの状態の一部は、モジュールレベルの変数にあります。同じJavaScriptのレルム(realm)でpostextをインポートするものは、すべてこれを共有します。ページとそのスクリプトは1つのコピーを共有し、iframeやワーカーはそれぞれ自分のコピーを持ちます。1ページに1文書なら問題は起きません。1ページに複数の文書がある場合(2つのライブプレビュー、サンプルのギャラリーなど)は影響が出ます。

  • リソースの画像。registerResourceImage(fileId, image)は、fileIdをキーとする1つの登録簿に書き込み、renderPageとrenderPageToCanvasがそこから読みます。2つの文書がどちらもfigure.svgを登録すると、その項目を共有し、最後の登録が両方に適用されます。ファイルidには文書ごとの接頭辞を付け、文書が不要になったらunregisterResourceImage(fileId)かclearResourceImages()を呼んでください。canvasバックエンドがキャッシュするラスターも同じキーで管理され、画像と一緒に破棄されます。
  • テキストの計測。計測した幅は、フォント文字列とテキストごとにレルム全体でキャッシュされます。あるファミリーのフェイスが届いたり消えたりすると、エンジンはすべての文書についてそのファミリーの幅を捨てます(レイアウトの前にフォントを読み込むを参照)。clearMeasurementCache()はすべてを捨てます。
  • 解決済みの設定。設定オブジェクトはそれぞれ1回だけ解決され、その結果がオブジェクトに結び付けてキャッシュされます。ビルドのたびに、まずオブジェクトを解決したときのテキストと比べるので(JSON.stringifyを1回。55 KBの本の設定で約0.1 ms、240 KBの設定で約1.5 ms)、その場で書き換えた設定は、どの深さの変更でも(config.bodyText.fontSize = …、パレットの色)解決し直されます。invalidateConfig(config)を呼べば、解決結果を手動で捨てられます。stableStringifyとhashStringは、キーの順序に左右されない内容のキーを返します。設定ごとにレイアウトをキャッシュするホスト向けです。
  • ハイフネーションの言語。ビルドのたびに、プロセス全体のハイフネーション言語が、その文書のbodyText.hyphenation.localeに設定されます。公開されているhyphenateText(text)とlayoutDesignSlotは、言語を渡さなければ最後のビルドの言語を使います。hyphenateText(text, 'es')のように呼んでください。
  • 数式エンジン。MathJaxのエンジンと描画済み数式のキャッシュは、レルムに1つずつです。initMathEngine()は全員のためにそれを起動します。

最も簡単な分離は、文書ごとにレルムを分けることです。ライブサンプルごとにiframeを使う(CodePenの埋め込みがこれにあたります)か、計測とハイフネーションのために文書ごとにレイアウトのワーカーを使います(画像は引き続きページに登録されます)。

#Web Workerでレイアウトを実行する

ブラウザーでPostextを使うときは、この方法を推奨します。ライブプレビュー、エディター、サイズ変更に追従するビューアー、Sandboxのような試用環境など、対話的なものを作るなら、postext/workerのcreateLayoutWorker()を通してパイプラインを動かしてください。UIのコードでは、メインスレッドでbuildDocumentを直接呼ばないでください。

メインスレッドでbuildDocumentを呼ぶと、パイプライン全体(解析、計測、7つのパス、最大5回の収束の反復)が、呼び出したスレッドで実行されます。1回きりの書き出しならそれで問題ありません。対話的なUIにとっては不適切なスレッドです。150ミリ秒のレイアウトが入力イベントをふさぎ、キー入力が溜まり、スクロールが引っかかります。ワーカーは、その時間をすべてバックグラウンドのスレッドに移します。

Postextには専用のWeb Workerのエントリーポイントpostext/workerがあり、パイプラインをメインスレッドから外します。大半の統合で使われることを想定している方法です。SandboxのCanvas、HTML、PDFのビューポートは、1つのuseLayoutWorkerフック(packages/postext-sandbox/src/worker/useLayoutWorker.ts)を通して同じcreateLayoutWorker()のハンドルを共有し、「最後の要求が勝つ」キャンセルで動かしています。新しいキー入力は、実行中のビルドを終わる前に中止します。

標準的な統合の概要は次のとおりです。

  1. 作成:ビューポートごとに1回、createLayoutWorker()でワーカーを作ります。
  2. フォントの登録:ファミリーごとに1回、registerFonts(payloads)で転送可能なArrayBufferを送ります。
  3. ビルド:build(content, config, { signal })でビルドします。古いビルドをキャンセルできるよう、呼ぶたびに新しいAbortSignalを渡します。
  4. 置き換え:次のビルドを始める前に、前のビルドのシグナルを中止します。これが「最後の要求が勝つ」パターンです。
  5. 破棄:ワーカーを所有するコンポーネントがアンマウントされたら、ワーカーを破棄します。

build(...)から返る同じVDTDocumentが、後段のすべてのレンダラーに渡ります。canvasにはrenderPage/renderPageToCanvas、HTMLにはrenderToHtmlIndexed、PDFには(postext-pdfの)renderToPdfです。ビルドはワーカーで1回行い、ラスタライズはメインスレッドでUIが必要とするだけ何度でも行えます。

#ワーカーで得られるもの

  • メインスレッドが空いたまま。解析、計測、7パスの収束ループはすべてワーカーの中で動きます。メインスレッドに処理が戻るのは、完成したVDTDocumentが送り返されるときだけです。
  • 「最後の要求が勝つ」キャンセル。build(content, config, { signal })はAbortSignalをワーカーまで通します。完了前に中止すると、メイン側ではAbortErrorが発生します。ワーカーの中では、パイプラインが次のブロックごとのキャンセル確認点でBuildCancelledErrorを投げ、すぐに止まります。
  • ワーカーごとの計測キャッシュ。ワーカーは、存続している間1つのMeasurementCacheを持ちます。フォント、テキスト、幅が同じ後続のビルドは、キャッシュした行の計測を再利用します。長い文書に1文字入力しても、計測し直すのは入力が実際に変わったブロックだけです。
  • メインスレッドと同一の計量値。フォントは転送可能なArrayBufferとしてワーカーに送られ、ワーカー自身のFontFaceSetにnew FontFace(...)で登録されます。ワーカーはメインスレッドと同じcanvasのフォント計量値で計測するので、改行と段の高さはバイト単位で一致します。
  • 数式のラスターキャッシュがワーカーのビルドをまたいで残る。数式レンダラーは、同一性をキーとするキャッシュに加えて、内容をキーとするラスターキャッシュを持っています。そうしなければ、MathRenderをワーカーの境界を越えて構造化複製するたびに、再ビルドのたびに同一性のキャッシュが外れてしまいます。

#公開API

ワーカーのクライアントはpostext/workerサブパスにあり、名前はわずかです。

  • createLayoutWorker(opts?): LayoutWorkerHandle:専用のワーカーを起動し(opts.workerで渡したワーカーを包むか、opts.urlのワーカーエントリーを起動することもできます)、型付きのハンドルを返します。CDNからワーカーを読み込むを参照してください。
  • LayoutWorkerHandle.registerFonts(faces: FontPayload[]): Promise<void>:フォントのバイト列をワーカーに送ります。バッファーは転送されるので、後で送り直す必要があるなら、メインスレッドに新しいコピーを残しておいてください。
  • LayoutWorkerHandle.build(content, config?, { signal? }): Promise<VDTDocument>:パイプラインを実行します。シグナルを中止すると、実行中のビルドがキャンセルされます。
  • LayoutWorkerHandle.dispose(): void:ワーカーを終了し、保留中のビルドをAbortErrorで拒否します。
  • FontPayload:{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }。weightはCSSのウェイトの文字列('700'、'bold')です。registerFontsは数値(700)も受け付けます。bufferは、registerFontsを呼んだときにワーカーへ転送されます。
  • BuildCancelledError(postextから再エクスポート):options.shouldCancelがtrueを返したときにbuildDocumentが内部で投げるものです。メインスレッドでは通常目にしません。ワーカーのプロトコルが、コードに届く前にAbortErrorに変換するからです。

パッケージは、コンパイル済みのワーカースクリプトを指すpostext/worker/entryパスも公開しています。createLayoutWorker()はこのURLを自動で解決します。明示的に参照する必要があるのは、バンドラーが手で組み立てたnew Worker(new URL(...), { type: 'module' })呼び出しを要求する場合か、エントリーを自分で配信する場合(opts.url)だけです。

#最小限の統合

import { createLayoutWorker } from 'postext/worker';
import type { FontPayload, LayoutWorkerHandle } from 'postext/worker';
import type { PostextConfig, VDTDocument } from 'postext';
 
// 1. Create the worker once and keep the handle for the lifetime of your viewport.
const layout: LayoutWorkerHandle = createLayoutWorker();
 
// 2. Register fonts once per family (transferable ArrayBuffers).
//    getConfigFontFamilies(config) is a helper that lists the families your config will render.
const payloads: FontPayload[] = await collectFontPayloadsForFamilies([
  'EB Garamond',
  'Open Sans',
]);
await layout.registerFonts(payloads);
 
// 3. Drive builds with last-wins cancellation: abort the previous signal
//    before starting a new build. A stale build is thrown away inside the worker.
let pending: AbortController | null = null;
 
async function rebuild(
  markdown: string,
  config: PostextConfig,
): Promise<VDTDocument | null> {
  pending?.abort();
  pending = new AbortController();
  try {
    return await layout.build({ markdown }, config, { signal: pending.signal });
  } catch (err) {
    if ((err as { name?: string } | null)?.name === 'AbortError') return null;
    throw err;
  }
}
 
// 4. Dispose when the component that owns the worker unmounts.
//    Pending builds reject with AbortError.
layout.dispose();

Reactのコンポーネントに包むと、次の形になります。

import { useEffect, useRef } from 'react';
import { createLayoutWorker } from 'postext/worker';
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderPageToCanvas } from 'postext';
import type { PostextConfig } from 'postext';
 
export function CanvasPreview({
  markdown,
  config,
}: {
  markdown: string;
  config: PostextConfig;
}) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const workerRef = useRef<LayoutWorkerHandle | null>(null);
  const pendingRef = useRef<AbortController | null>(null);
 
  // Mount: spin up the worker and ship the fonts once.
  useEffect(() => {
    const handle = createLayoutWorker();
    workerRef.current = handle;
    (async () => {
      const payloads = await collectFontPayloadsForFamilies(
        getConfigFontFamilies(config),
      );
      await handle.registerFonts(payloads);
    })();
    return () => {
      pendingRef.current?.abort();
      handle.dispose();
    };
  }, []); // fonts registered once; re-register only when the family set changes
 
  // Every keystroke or config change: supersede the in-flight build and kick a new one.
  useEffect(() => {
    const handle = workerRef.current;
    if (!handle) return;
    pendingRef.current?.abort();
    const ac = new AbortController();
    pendingRef.current = ac;
    (async () => {
      try {
        const vdt = await handle.build({ markdown }, config, { signal: ac.signal });
        const canvas = canvasRef.current;
        if (!canvas || !vdt.pages[0]) return;
        renderPageToCanvas(vdt.pages[0], vdt, canvas); // rasterise on the main thread
      } catch (err) {
        if ((err as { name?: string } | null)?.name !== 'AbortError') throw err;
      }
    })();
  }, [markdown, config]);
 
  return <canvas ref={canvasRef} />;
}

パターンは常に同じです。作成は1回、フォントの登録も1回、AbortSignal付きのビルドは何度でも、アンマウントで破棄。

#CDNからワーカーを読み込む

ワーカーのスクリプトはページと同じオリジンから来なければなりません。そのため、CDNが配信するpostext/workerのコピーは、隣にあるlayout.worker.jsファイルを起動できません。createLayoutWorker()がこれに対処します。

  • esm.shならオプションなし。postext/worker自体がesm.shから読み込まれている場合(モジュールのURLがhttps://esm.sh/postext@1.5.0/es2022/worker.mjsのような形)、クライアントは、対応するhttps://esm.sh/postext@1.5.0/worker/entryを、それをインポートするだけの1行の同一オリジンのblobモジュールを通して起動します。?deps=、?external=、?alias=を付けたインポートや、https://esm.sh/*postext@1.5.0/workerの形でも同じです。ワーカーには外部の依存関係を解決するインポートマップがないので、常にそのバージョンの素のビルドが使われます。
  • ほかのサーバーならurlを指定。jsDelivr(/+esm)やunpkgなど、ほかのCDNは検出されません。createLayoutWorker({ url })はurlにあるワーカーエントリーのモジュールを起動します。同一オリジンのURLは直接、別オリジンのURLは同じblobのラッパーを通して起動します。そのサーバーはクロスオリジンのリクエスト(CORS)を許可している必要があります。
  • バンドラーでは何も変わらない。Vite、webpack、Next.jsでは、引き続きオプションなしでcreateLayoutWorker()を呼んでください。バンドラーがワーカーをアプリのチャンクとして出力します。
import { createLayoutWorker } from 'https://esm.sh/postext/worker';
 
const layout = createLayoutWorker();
const face = async (weight, style) => ({
  family: 'EB Garamond',
  weight,
  style,
  buffer: await (await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/eb-garamond@5/files/eb-garamond-latin-${weight}-${style}.woff2`)).arrayBuffer(),
});
await layout.registerFonts(await Promise.all([face(400, 'normal'), face(700, 'normal'), face(400, 'italic')]));
const doc = await layout.build({ markdown }, { bodyText: { fontFamily: 'EB Garamond' } });

ワーカーにはページのフォントが見えません。ワーカーは独自のフォントセットを持ち、そこにあるのはregisterFontsで送ったフェイスと、システムにインストールされたフォントだけです。ワーカーが見つけられないファミリーでテキストを組むビルドでは、そのテキストは代替フォントで計測されるため、改行がページと一致しません。このときクライアントは、ファミリーごとに1回コンソールに警告を出し("EB Garamond" is not available inside the layout worker…)、そのファミリーをBuildStats.missingFontsに挙げます。これはbuildのonStatsコールバックが受け取ります。文書自体も、同じフェイスをfontFallbackのコンテンツ警告として持っています。

handle.prepareFonts(content, config, options)は、ページでprepareFontsを実行し、見つかったフェイスのうちフォントレジストリがファイルを持っているもの(リゾルバーのファイル、バンドルのフェイス、ページの読み取り可能な@font-face規則)を、文書の文字を含むスライスに限ってワーカーに送ります。フェイスがワーカーに届くと、ワーカーはそのファミリーの計測結果と、仕上がった文書のキャッシュだけを捨てます。

#フォントペイロードの収集(Fontsource / Google Fonts)

registerFontsはフォントの生のバイト列を受け取ります。取得するのはメインスレッドが適しています。Google Fontsはブラウザーらしいユーザーエージェント文字列にしかWOFF2を返さないこと、また中央のキャッシュがあれば複数のワーカーインスタンスで同じバイト列を共有できることが理由です。

SandboxのcollectFontPayloadsForFamilies(packages/postext-sandbox/src/controls/fontLoader.ts)は、そのまま使える参考実装です。次の処理を行います。

  1. https://api.fontsource.org/v1/fonts/{family-id}に問い合わせ、利用できるウェイトと、ファミリーが可変軸を持つかどうかを調べます。
  2. ファミリーが提供するすべてのウェイトとスタイルを含むGoogle FontsのCSS2のURLを組み立てます。
  3. 生成された@font-faceのスタイルシートを取得し、各src: url(...) format('woff2')宣言を抜き出して、生のバイト列をダウンロードします。
  4. 呼び出しごとにbufferが新しいArrayBufferになっているFontPayload[]を返します。registerFontsはバッファーを転送し、送信側のコピーを切り離された(detached)状態にするので、これが重要です。

getConfigFontFamilies(config)と組み合わせると、あるPostextConfigが実際に描画するファミリー(本文、見出し、リストの記号、番号付きリストの番号)の一覧が得られます。

#エンジン内部の協調的キャンセル

buildDocumentを自分で動かす場合(たとえば独自のワーカーの中で)、パイプラインが公開しているshouldCancelフックを直接使えます。

import { buildDocument, BuildCancelledError } from 'postext';
 
let superseded = false;
try {
  const vdt = buildDocument(content, config, cache, {
    shouldCancel: () => superseded,
  });
} catch (err) {
  if (err instanceof BuildCancelledError) return; // a newer build took over
  throw err;
}

shouldCancelは、配置の間、トップレベルのブロックごとに1回呼ばれます。このフックは意図的に協調的な仕組みになっています。pretext自体のレイアウト呼び出しを行の途中で止めることはできませんが、キャンセルの粒度を十分小さく(ミリ秒単位に)保つので、速く入力するユーザーが古いビルドを待たされることはありません。

#ワーカーからのPDF書き出し

PDFバックエンドは、できあがったVDTDocumentを受け取ってPDFのバイト列にします。レイアウトを再実行することはありません。そのため、ブラウザーでの標準的なPDFの流れはワーカーときれいに組み合わさります。VDTをワーカーで(メインスレッドの外で、キャンセル可能に、キャッシュを再利用して)ビルドし、同じVDTに対してメインスレッドでrenderToPdfを呼びます。

import type { LayoutWorkerHandle } from 'postext/worker';
import { renderToPdf } from 'postext-pdf';
import type { PostextConfig } from 'postext';
import { createPdfFontProvider } from './pdfFontProvider';
 
const fontProvider = createPdfFontProvider();
 
export async function exportPdf(
  layout: LayoutWorkerHandle,
  markdown: string,
  config: PostextConfig,
): Promise<Uint8Array> {
  // 1. Build the VDT in the worker — UI stays responsive during the layout passes.
  const vdt = await layout.build({ markdown }, config);
 
  // 2. Rasterise to PDF on the main thread. renderToPdf is fast once the VDT exists
  //    because it is walking precomputed coordinates, not remeasuring text.
  return renderToPdf(vdt, {
    fontProvider,
    // pdfGeneration config on `vdt.config` is honoured automatically.
  });
}

ライブプレビュー用のワーカーのハンドルをすでに持っているなら、2つ目のワーカーを起動せずに、書き出しにもそれを再利用してください。ワーカーの中の計測キャッシュのおかげで、画面のプレビューに続くPDFの書き出しには、ほとんど費用がかかりません。

長い本では、PDFそのものの書き出しにも数秒かかります。postext-pdf/workerは、その段階を専用のワーカーで実行します(ワーカーでPDFを描画するを参照)。

#ワーカーを使う場面、使わない場面

ワーカーを使うのは次の場合です。

  • ライブプレビュー、エディター、試用環境。ユーザーの入力に応じて文書をビルドし直すものすべて。
  • サイズ変更に追従するHTMLビューアー。ResizeObserverが通知するたびにレイアウトを再実行するもの。
  • ブラウザー内でのPDF書き出し。ライブプレビューをすでに持つUIから実行する場合。既存のワーカーのハンドルを再利用すれば、書き出しが計測キャッシュの恩恵を受けられます。
  • 複数の出力タブ。すべてが同じVDTを必要とする場合(SandboxのCanvas / HTML / PDFのビューポートは、ビューポートのマウントごとに1つのワーカーのハンドルを共有します)。

ワーカーを使わないのは次の場合です。

  • サーバーサイドでの生成。NodeにはブラウザーのFontFaceSetがなく、スレッドはいずれにしても自分で制御できます。
  • 単発の書き出し(CLI、ヘッドレスの書き出しスクリプト、Cloud Function)で、ふさいでしまう対話的なUIがない場合。buildDocumentを直接呼ぶほうが簡単で、最初のフォント転送の費用もかかりません。

#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を指定したラッパーでした。

#PDFの生成

PDFの出力は別パッケージ(postext-pdf)が受け持ちます。Webだけで使う組み込みがpdf-libと@pdf-lib/fontkitのコストを負わずに済むようにするためです。PDFバックエンドはテキストを計測し直しません。renderToCanvasやrenderToHtmlに渡すのとまったく同じVDTDocumentを受け取り、そのピクセル単位の座標をPDFのポイントに変換します。そのため3つの出力は、改行位置、段の高さ、リソースの配置が必ず一致します。

ブラウザーでは、VDTをWeb Workerで構築してください。renderToPdf自体はVDTができていれば高速で、時間がかかるのはVDTを生成したレイアウトのパイプラインのほうです。このパイプラインをワーカーで動かせばUIの応答性が保たれ、PDFの書き出しで、ライブプレビューがすでに温めた計測キャッシュを再利用できます。推奨する流れはワーカーからのPDF書き出しを参照してください。以下のメインスレッドの例は、各引数が何を意味するかを示すリファレンスです。UIのコードでは、まずワーカーでVDTを構築し、renderToPdfだけを直接呼び出してください。

#インストール

npm install postext postext-pdf

postextはpostext-pdfのピア依存関係(peer dependency)です。postext-pdfの各リリースには、同時にリリースされたpostextか、同じメジャーバージョンのそれより新しいものが必要です(ピア依存の範囲はそのバージョンに^を付けたもので、1.5.0なら^1.5.0)。そのリリースでpostextに追加されたヘルパーをインポートしているためです。2つは一緒にアップグレードし、CDNでは同じバージョンに固定してください。

#公開API

このパッケージは、エントリーポイントを1つと、いくつかの型を公開しています。

  • renderToPdf(doc, options): Promise<Uint8Array> — VDTDocument(または本の章を並べたその配列)を受け取り、PDFの生のバイト列を返します。
  • PdfFontProvider — renderToPdfが新しいファミリー・ウェイト・スタイルの組み合わせを埋め込む必要があるときに、フォントのバイト列を要求するために使うコールバックのシグネチャ(family, weight, style, request?) => Promise<Uint8Array | Uint8Array[]>です。request.codePointsには、ページがそのフェイスで組む文字が入ります。応答は1つのファイルか、合わせて1つのフェイスを構成する複数のファイルです(中国語・日本語・韓国語のフォントを参照)。
  • RenderToPdfOptions — { fontProvider, resourceBytes?, outlines?, accessible?, colorSpace?, pageNegative?, characterGrid?, onProgress?, onWarning?, rasterizeSvg?, harfbuzzWasm?, print?, outputProfile?, profileBaseUrl? }。outlines、accessible、colorSpaceは、省略するとドキュメントのpdfGenerationの値になります(PDF生成(設定)を参照)。resourceBytesはリソースのバイト列と印刷用マスターで、onWarningはプロバイダーに要求されるフェイスと文書の中の警告で説明します。characterGrid: trueは、cjk.grid.showが画面に描くグリッドを印刷します。指定しなければPDFには含まれません(文字グリッドを参照)。harfbuzzWasmは、右から左に書く文字や連結する文字を含むドキュメントのために、HarfBuzzのharfbuzz.wasmをどこから読み込むかを指定します(ページからの相対URL、またはファイルのバイト列)。省略すると、postext-pdfのモジュールの隣にあるコピー、次にjsDelivrとesm.shにある同じharfbuzzjsのリリースを使います。printは印刷出力の設定(PDF/X規格、出力プロファイル、黒の扱い、プリフライト)を受け取り、省略時は文書のprintを使います。PDF/X規格、またはcolorSpace: 'cmyk'を指定すると、すべての色がICC出力プロファイルで分版されます。プロファイルのバイト列はoutputProfileで渡し、省略時はprofileBaseUrl(既定はnpm CDN上のpostextのicc/フォルダー)から取得します。
  • PdfWarning — onWarningで報告される致命的でない問題です。kindで絞り込みます。'fontFallback'(PdfFontFallbackWarning)はフェイスがファミリーの別のカットで組まれたこと、'missingGlyph'(PdfMissingGlyphWarning)はフェイスのどのファイルにもグリフがない文字、'variableFontDefaultInstance'(PdfVariableFontWarning)は可変フォントが既定のインスタンス以外のウェイトで要求されたこと、'cffEmbeddedWhole'(PdfCffEmbeddedWholeWarning)は2 MBを超えるCFFフェイスが丸ごと埋め込まれたこと、'complexShapingUnavailable'(PdfComplexShapingWarning)はHarfBuzzを読み込めず(reasonに探した場所が並びます)、右から左に書く文字や連結する文字がHarfBuzzなしで描かれたため、アラビア文字の符号の位置がずれることを示します。'missingImage'は、バイト列のない画像がプレースホルダーとして描かれたことを示します(これは渡したonWarningにだけ報告されます。それがないとき、フォントの警告はconsole.warnに出ます)。
  • decompressWoff2(bytes): Uint8Array — WOFF2ファイルを、pdf-libがそのまま埋め込める形式であるTTFのバイト列に変換するヘルパーです。
  • createPdfWorker(options?)(postext-pdf/workerから) — 同じ描画をWeb Workerで行います。ワーカーでPDFを描画するを参照してください。

#最小限の例

import { buildDocument } from 'postext';
import { renderToPdf } from 'postext-pdf';
 
const vdt = buildDocument(
  { markdown: '# Chapter One\n\nThe story begins here…' },
  {
    page: { sizePreset: '17x24' },
    layout: { layoutType: 'double' },
    bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
  },
);
 
const pdfBytes = await renderToPdf(vdt, {
  fontProvider: async (family, weight, style) => {
    // Return TTF bytes for this family/weight/style.
    // See the "Font provider" section below for a real implementation.
    const res = await fetch(`/fonts/${family}-${weight}${style === 'italic' ? 'i' : ''}.ttf`);
    return new Uint8Array(await res.arrayBuffer());
  },
});
 
// `pdfBytes` is a Uint8Array — save, download, or stream it.
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url);

#なぜフォントプロバイダーが必要か

pdf-libはPDFに実際のフォントファイルを埋め込みます。描画時にはブラウザーにインストールされたフォントを使えず、画面上の計測のためだけに読み込んだフォントも、それだけでは自己完結したPDFを作るのに足りません。renderToPdfはページを走査して、ページが描くすべてのフェイス(family|weight|styleの組み合わせごとに1つ。プロバイダーに要求されるフェイスを参照)を集め、固有の組み合わせごとに1回プロバイダーを呼び出します。プロバイダーはTTFまたはOTFのバイト列をUint8Arrayで返します。複数のファイルで提供されるフェイスなら、そのリストを返します(中国語・日本語・韓国語のフォントを参照)。pdf-libはTrueTypeのアウトラインをサブセット化し、CFF(.otf)ファイルは丸ごと埋め込みます。ファミリーにない太字の代わりにレギュラーのカットを返す場合のように、プロバイダーが同じファイルで応えたフェイスどうしは、埋め込みフォントを1つ共有します。SVGの図を画像として描くときのその図のテキストのフェイスのように、最終的にどのページの描画にも使われないフェイスはファイルに含まれません。

可変フォント1つではなく、ウェイトごとの静的フォントを使ってください。Google Fontsは多くの場合、ファミリーごとにウェイト軸全体を覆う可変WOFF2を1つ配信しています。pdf-libは可変フォントのファイルから既定のインスタンスしか埋め込めないため、太字の段落がレギュラーのウェイトで描かれてしまいます。Fontsourceはウェイトごとの静的WOFF2ファイルを公開しており、この問題をきれいに解決できます。Sandboxが使っているのもこの方法です。既定のインスタンス以外のウェイトで要求された可変フォントのファイルは、variableFontDefaultInstance警告として報告されます。

テキストのどの語も、レイアウトが置いた位置に描かれます。段落、リスト項目、引用、囲みなど流し込まれるテキストでは、各語がVDTの計測した位置から始まるので、ブラウザーの幅と埋め込んだフェイスの幅の差が行に沿って積み重なることはありません。インライン書式のない行は、語と語の間でペンを動かす1つのテキストオブジェクトとして描かれます。両端そろえの行、中央そろえの行、書式を含む行は語ごとに描かれます。フェイスにグリフのない文字は、ブラウザーが別のフォントで計測し、PDFではフェイスの欠落グリフの箱として描かれますが、後に続く語を動かすことはなく、フェイスごとに1回missingGlyph警告として報告されます。狭いノーブレークスペースや数字幅スペースのようにフェイスにないスペースは、ブラウザーが与えた幅をとり、単語結合子(word joiner)やゼロ幅スペースのような不可視の文字は描かれません。フェイスにないノーブレークハイフン(U+2011)は、ブラウザーの表示と同じく、フェイスのハイフン(U+2010)で描かれ、それもなければハイフンマイナスで描かれます。Open SansやOutfitなどは、その両方を持っていません。どちらの場合も欠落グリフには数えません。例外が2つあります。右から左に書く文字を含む行は、従来どおり1つのランとして描かれます(言語と文字体系を参照)。デザインが配置するテキスト(柱、フッター、章扉、囲みのタイトルなどのデザイン要素)は、埋め込んだフェイス自身の幅で組まれるため、そこでフェイスにないグリフがあると、行の残りが動きます。

#プロバイダーに要求されるフェイス

renderToPdfはページを描くときと同じ順にたどり、実際に描かれるフェイスだけをプロバイダーに要求します。

  • 1行でも組むブロックのレギュラーのフェイスと、実際に太字、イタリック、太字イタリックで組まれる各ランのフェイス
  • チップのラン、リストのマーカー、デザインのスロット(柱、ノンブル、章扉と部扉の帯)のテキスト
  • すべてのリソースのキャプション、注記、表のセルのテキスト
  • SVGの図を埋め込むときに、その<text>が指定するフェイス。プロバイダーがまったく提供できないファミリーは、SVGのfont-familyリストの次のファミリーに引き継がれます

そのため、どこでもイタリックにしない見出しのファミリーのイタリックが要求されることはなく、注記のない図に注記のフェイスが必要になることもありません。

プロバイダーがフェイスを拒否しても、描画は続きます。同じファミリーの別のフェイスが代わりに埋め込まれ、PdfWarningが報告されます。代わりになるのは最初に読み込めたフェイスで、9つの標準ウェイト(100〜900)をCSSのフォントマッチングと同じ順に試します。これはプレビューでブラウザーが表示するフェイスでもあります。

  1. まず同じスタイル。400〜500のウェイトでは、500までのウェイトを先に試し、次に軽いウェイトを近いものから順に、その後600以上の重いウェイトを試します。400未満のウェイトでは、軽いウェイトを近いものから先に試し、次に重いウェイトを試します。500を超えるウェイトでは、重いウェイトを先に試し、次に軽いウェイトを試します。
  2. 次にもう一方のスタイル(立体ならイタリック、イタリックなら立体)を、要求されたウェイトとほかのウェイトについて、同じ順で試します。

したがって、イタリックのないファミリーはイタリックのランを立体で組み、400と700だけのファミリーは600を700で組み、カットが1つしかないファミリーはすべてをそのカットで組みます。プロバイダーへの要求はこの順に1フェイスずつ行い、同じフェイスを2度要求することはないので、何にも使われないフェイスが埋め込まれることはありません。プロバイダーがまったく提供できないファミリーについては、描画が失敗するまでにこの18のフェイスすべてが要求されます。

const bytes = await renderToPdf(doc, {
  fontProvider,
  onWarning: (w) => {
    // { kind: 'fontFallback', family: 'Oswald', weight: 700, style: 'italic',
    //   fallback: { weight: 700, style: 'normal' }, reason: '…', message: '…' }
    console.info(w.message);
  },
});

onWarningがないと、メッセージはconsole.warnに出ます。テキストの位置はVDTから来ており、ブラウザーが持っていたフェイスで計測されたものなので、幅の異なる代替フェイスでは詰まって見えたり、ゆるく見えたりすることがあります。直すには本来のフェイスを提供してください。描画が失敗する(postext-pdf: failed to load font(s): …)のは、プロバイダーがあるファミリーのどのフェイスも、標準ウェイトで立体・イタリックのいずれでも提供できない場合だけです。

#ブラウザー向けフォントプロバイダー(Fontsource + WOFF2)

SandboxにはcreatePdfFontProvider()(packages/postext-sandbox/src/viewport/pdfFontProvider.ts)があり、どのブラウザーアプリにもコピーして使えます。要点は次のとおりです。

import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
 
const bytesCache = new Map<string, Promise<Uint8Array>>();
 
function fontsourceId(family: string): string {
  return family.toLowerCase().replace(/\s+/g, '-');
}
 
function fontsourceWoff2Url(
  family: string,
  weight: number,
  style: 'normal' | 'italic',
): string {
  const id = fontsourceId(family);
  return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
}
 
export function createPdfFontProvider(): PdfFontProvider {
  return async (family, weight, style) => {
    const key = `${family}|${weight}|${style}`;
    const cached = bytesCache.get(key);
    if (cached) return cached;
 
    const promise = (async (): Promise<Uint8Array> => {
      const url = fontsourceWoff2Url(family, weight, style);
      const res = await fetch(url, { mode: 'cors' });
      if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
      // pdf-lib needs TTF bytes, so decompress the WOFF2 wrapper client-side.
      return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    })();
 
    bytesCache.set(key, promise);
    return promise;
  };
}

本番用の実装では、さらに次のことも行ってください。

  • 利用できるウェイトを問い合わせ(https://api.fontsource.org/v1/fonts/{id}を使用)、要求されたウェイトを、ファミリーが実際に提供する最も近いウェイトに合わせます。こうすれば、{400, 700}しかないファミリーにweight: 600を要求しても成功します。
  • イタリックから立体にフォールバックします。要求されたウェイトにイタリックのカットがないファミリーでも、描画全体を失敗させずに済みます。
  • 描画をまたいでキャッシュを再利用します(bytesCacheは呼び出しごとではなく、モジュールスコープに置きます)。こうすれば、設定を変えた後のPDFの再生成には実質的にコストがかかりません。

#中国語・日本語・韓国語のフォント

CJKのフェイスは小さな1つのファイルでは提供されません。FontsourceはNoto Serif SCを1ウェイトあたり約100のファイルで配信しており、各ファイルは文字の一部を収め、ファミリーのスタイルシート(@fontsource/noto-serif-sc/400.css)でそのunicode-rangeとともに宣言されています。ブラウザーは、ページのテキストが使うファイルをダウンロードします。上のプロバイダーが取得するlatinファイルには漢字がまったく含まれず、名前付きのサブセットも不完全です。Noto Serif SCのchinese-simplifiedには釵がなく、Noto Serif TCのchinese-traditionalには全角の約物(),!?:;が1つもありません。

そこで、プロバイダーは1つのフェイスに複数のファイルで応えることができます。renderToPdfは、ページがそのフェイスで組む文字(request.codePoints)をプロバイダーに渡します。これは描画を始める前にすべての章から集めたものです。プロバイダーは、それらの文字を収めるファイルを、ブラウザーが参照するのと同じ順で返します。各ファイルはそれぞれ独立したサブセットとして埋め込まれ、各文字は、その文字のグリフを持つ最初のファイルから描かれます。60のスライスを使う章には、60の小さなサブセットが埋め込まれます。SVGの図のテキストなどのために同じフェイスが再び要求されるときは、それまでのファイルにない文字だけが要求されます。Uint8Arrayを1つ返すプロバイダーは従来どおり動作します。Sandboxのプロバイダーは、そのウェイトとスタイルのFontsourceのスタイルシートを読み、範囲がテキストの文字を含むファイルを取得します。その中核部分は次のとおりです。

import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
 
type Slice = { url: string; ranges: Array<[number, number]> };
 
async function fontsourceSlices(family: string, weight: number, style: 'normal' | 'italic'): Promise<Slice[]> {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const cssUrl = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  const css = await (await fetch(cssUrl)).text();
  return [...css.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => ({
    url: new URL(/url\(\.?\/?([^)]+\.woff2)\)/.exec(rule)![1], cssUrl).href,
    ranges: /unicode-range:\s*([^;]+);/.exec(rule)![1].split(',').map((part) => {
      const [lo, hi = lo] = part.trim().slice(2).split('-');
      return [parseInt(lo, 16), parseInt(hi, 16)] as [number, number];
    }),
  }));
}
 
export const sliceFontProvider: PdfFontProvider = async (family, weight, style, request) => {
  // Where ranges overlap, the browser tries the last rule first.
  const slices = (await fontsourceSlices(family, weight, style)).reverse();
  const picked = new Set<Slice>();
  for (const cp of request?.codePoints ?? []) {
    const slice = slices.find((s) => s.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
    if (slice) picked.add(slice);
  }
  if (picked.size === 0) picked.add(slices[0]!);
  return Promise.all(slices.filter((s) => picked.has(s)).map(async (s) =>
    decompressWoff2(new Uint8Array(await (await fetch(s.url)).arrayBuffer()))));
};

ラテン文字のファミリーも同じコードで扱えます。英語のテキストにはlatinファイルだけが、チェコ語にはlatinとlatin-extが使われます。

フェイスのどのファイルにもグリフのない文字は、フォントの.notdefグリフ(多くのフォントでは空の四角)で描かれ、renderToPdfはページを描き終えた後に、フェイスごとに1回それを報告します。

// { kind: 'missingGlyph', family: 'Noto Serif TC', weight: 400, style: 'normal',
//   characters: [',', '!', '?'], message: '…' }

Sandboxは、これと以下の2つの警告を、PDFを生成するたびに検査パネルに一覧表示します。本、その設定、リソースが変わると、次のPDFで置き換わるまで、それらは以前のPDFによるものとして示されます。別の本を開くと消えます。

  • 太字にはウェイトごとの静的ファイルが必要です。FontsourceはNoto Serif SCとTCの各ウェイトを別々の静的ファイルとして配信しているので、Sandboxでは太字が使えます。Google Fontsのファイル(NotoSerifSC[wght].ttf、25 MB)は可変フォントです。pdf-libはその既定のインスタンスを埋め込むため、700のフェイスは400で印刷され、renderToPdfはそれをvariableFontDefaultInstanceとして報告します。バンドルでは、fontToolsでウェイトごとに静的インスタンスを1つ切り出し(fonttools varLib.instancer NotoSerifSC[wght].ttf wght=700)、pyftsubsetで本の文字だけにサブセット化してください。
  • TrueType版を使ってください。Source Han SerifとNoto Serif CJKの.otfファイルはCFFアウトラインで、postext-pdfはこれを丸ごと、1ウェイトあたり8〜25 MB埋め込みます。2 MBを超えるCFFフェイスはcffEmbeddedWholeとして報告されます。TrueType版(Google Fonts、Fontsource)は、使われたグリフだけにサブセット化されます。日本語では、Noto Serif JPとNoto Sans JP(Google Fonts、またはFontsourceの番号付きスライス)、Shippori Mincho、Zen Old Mincho、BIZ UDMinchoはTrueTypeで提供されています。Source Han Serif JPと、Noto Serif CJKのJPの.otfファイルはCFFです。
  • 汎CJKフェイスの日本語字形。漢字、約物、引用符の同じコードポイントでも、日本と中国では字形が異なることがあり、汎CJKフェイス(Source Han、Noto CJK)は両方を持っています。PDFは、日本語のドキュメント(locale: 'ja')と、どのドキュメントでも日本語のアイソレート(:ltr[…]{lang=ja})を、OpenTypeの言語システムJAN でシェーピングします。そのため、フェイスのlocl機能によって、CanvasとHTMLがlangを通じて表示するのと同じ日本語の字形が印刷されます。日本語の本の中にある別の言語のアイソレートは、その言語の字形(中国語ではフォントの既定の字形)でシェーピングされ、その/Langを持つSpanとしてタグ付けされます。中国語などのドキュメントは、従来どおりフォントの既定の字形でシェーピングされます。Noto Serif JPのように日本語用に作られたフェイスは既定の字形が日本語のものですが、それでも日本語のテキストの“ ”はJAN の字形で組まれます。

あるファミリーに欠けている文字を、別のファミリーから借りることはありません。Noto Serif TCはNoto Serif SCから借りません。収録範囲はフォントファイルを作る段階で決着させてください。红楼梦のショーケースは、TCのサブセットに欠けているグリフをSCのフェイスからコピーしています。

#サーバー側のフォントプロバイダー(Node、ローカルファイル)

Nodeでは、WOFF2の手順を丸ごと省き、ディスクからTTF/OTFファイルを読み込めます。

import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { PdfFontProvider } from 'postext-pdf';
 
const FONT_DIR = '/path/to/fonts';
 
function filename(family: string, weight: number, style: 'normal' | 'italic'): string {
  const slug = family.replace(/\s+/g, '');
  const styleSuffix = style === 'italic' ? 'Italic' : '';
  const weightName =
    weight >= 700 ? 'Bold'
    : weight >= 600 ? 'SemiBold'
    : weight >= 500 ? 'Medium'
    : weight >= 300 ? 'Light'
    : 'Regular';
  return `${slug}-${weightName}${styleSuffix}.ttf`;
}
 
export const localFontProvider: PdfFontProvider = async (family, weight, style) => {
  const buf = await readFile(join(FONT_DIR, filename(family, weight, style)));
  return new Uint8Array(buf);
};

#リソースのバイト列と印刷用マスター

resourceBytes(fileId)は画像の生のバイト列を返し、バックエンドはその形式を判別します。

  • PNG、JPEG、GIF、WebPは画像として埋め込まれます
  • SVGのマークアップはベクターのパスとして描かれ、そのtextは文書に埋め込んだフォントで実際のテキストとして組まれます。ベクターのサブセットにない機能を使っている場合は、ブラウザーで600 dpiでラスタライズされます。@font-face規則だけを含む<style>(作者が埋め込んだフェイス)は読み飛ばされ、図はベクターのままです(postext-pdf 1.25以降)。それ以外のスタイルシートがあると図はラスターになり、そのラスターは、文字が指定するフェイスをfontProviderから得て埋め込んでから作られます(diagramStyle.inlineFontsまたはリソースのsvg.inlineFontsがfalseの場合を除きます)
  • PDFは最初のページがそのまま、フォームXObjectとして埋め込まれます

各画像は、何回描かれてもファイルには1回だけ格納されます。ベクターのパスとして描かれるSVGは、すべてのページが描画するフォームXObjectになるので、30ページのドキュメントのページデザインにある枠やロゴは、30回ではなく1回だけ書き込まれます。ページが1つ増えるごとに増えるのは数百バイトです。postext-pdf 1.4までは、各ページがパスのコピーをそれぞれ持っていました。

SVGの図は、svg.pdfFileIdで印刷用マスターを指定できます。同じ図の1ページのPDFで、通常はSVGの書き出し元になった原本です。renderToPdfはまずresourceBytesにマスターのidを問い合わせます。そして、図として、表のセルの画像(TableCell.image)として、デザインの画像として、囲みのアイコン(そのmarkerも)として、SVGが描かれるすべての場所で、フォント、グラデーション、色空間を保ったまま、そのページをSVGの代わりに埋め込みます。VDTはそれぞれの使用箇所にマスターのidを持っているので(図のリソースにはsvg.pdfFileId、セルの画像とデザインの画像ブロックにはpdfFileId)、本ではどの章にもマスターが使われます。CanvasとHTMLのバックエンドは引き続きSVGを描きます。SVG自身のバイト列が代わりに使われるのは、マスターがない場合、マスターがPDFでない場合、単色インクが有効な場合(diagramStyle.singleInkはSVGのマークアップだけを色替えします)の3つです。

const resources: Resource[] = [{
  id: 'map', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'map.svg', width: 800, height: 600, pdfFileId: 'map.pdf' },
}];
const files = new Map([['map.svg', svgBytes], ['map.pdf', masterPdfBytes]]);
const pdf = await renderToPdf(buildDocument({ markdown, resources }, config), {
  fontProvider,
  resourceBytes: (fileId) => files.get(fileId),
});

ホストは、bundleResourceBytesのように、SVG自身のidに対してマスターのバイト列を返すこともできます。どちらの方法でも動作します。

#PDFの縦組み

縦組みのページ(layout.writingMode: 'vertical-rl')は、Canvasが描くのと同じく90度回転した座標系を通して描かれ、テキストは縦方向に組まれます。

  • 正立する文字は、同じ埋め込みファイルから作る2つ目のType0フォントで表示されます。CIDFont、幅、ToUnicodeマップは同じで、Encoding /Identity-V(縦書きモード)を使います。ひと続きの文字は1つのテキストオブジェクトになり、そのグリフは自ら1 emずつ下へ進むので(DW2 [880 −1000])、PDFビューアーでは縦の1行を1行として選択・抽出できます。グリフはOpenTypeのvertとfwidでシェーピングされ、括弧、引用符、中国大陸式の読点、三点リーダー、ダッシュが縦組み用の字形になります。そのままで正立する文字は横組み用のグリフのままです。フォントのどの部分も2回埋め込まれることはありません。
  • ラテン文字の単語と長い数字は横組み用のフォントで横倒しに組まれ、1マスに収める数字は正立し、1字幅より広いときは横方向に詰めて1字幅に収めます。フォントに縦組み用の字形がない記号は、Canvasと同じく回転または移動されます。
  • トラッキングは文字間にTJの数値として書き込まれ、縦書きモードではペンを下へ移動させます。
  • 縦組みの各行には、そのテキストの/ActualTextが付くので、コピーやテキスト抽出では書かれたとおりに読み取られます。pdftotextとpdf.jsは、行を上から下へ、右から左へと読みます。pdf.jsは1マスに収めた数字の位置で新しい行を始めます。
  • リンク、しおり、移動先は用紙上の座標に対応付けられます。縦組みの行の上にあるリンクは縦長の細い矩形になり、しおりは見出しの行の上端でページを開きます。
  • タグ付きPDFは、Document要素で組方向を宣言します(Layout属性のWritingMode /TbRl。すべての要素がこれを継承します)。縦組みの章はPDF/UA-1の検証(veraPDF)に合格します。
  • ビューアー:Acrobat、Preview、Chrome(PDFium)、pdf.js、Popplerは縦組み用のフォントを表示できます。右綴じの本(page.binding)は、見開きを右から左に並べるようビューアーにも求めます(/Direction /R2L、/PageLayout /TwoPageRight)。AcrobatとFoxitはこれに従いますが、Chromeは従いません。

Noto Serif TC(本で使う文字だけ、TrueType)で組んだ43ページの章は約820 KBで、その大半を2つのフォントサブセットが占めます。

#PDFのリンク

Markdownのリンク(文書形式 › リンクを参照)の語は、URIリンク注釈になります。行ごとに、リンクされた語のひと続きにつき1つです。各注釈は行のボックスを覆い、枠線はありません。アクセシブルな描画では、ひと続きごとにLink要素になり、その/Contentsはそのテキストです。リンクになるのは、絶対URLのhttp:、https:、mailto:、tel:、ftp:のターゲットだけです。相対URLはPDFの中では基準となるURLを持たないためです。印字可能なASCII以外の文字はパーセントエンコードされます。:refによる参照と目次の行は、ドキュメント内へのリンクのままです。

#完全なブラウザーの例:構築、描画、ダウンロード

すべてを組み合わせて、VDTを構築し、PDFに描画し、ブラウザーからダウンロードを開始します。

import { buildDocument, createMeasurementCache } from 'postext';
import { renderToPdf } from 'postext-pdf';
import { createPdfFontProvider } from './pdfFontProvider';
 
const fontProvider = createPdfFontProvider();
 
export async function downloadPdf(markdown: string, config: PostextConfig) {
  const cache = createMeasurementCache();
  const vdt = buildDocument({ markdown }, config, cache);
 
  const bytes = await renderToPdf(vdt, { fontProvider });
 
  const blob = new Blob([bytes.slice().buffer], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'document.pdf';
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

重要:Webフォントを参照する設定では、buildDocumentの前にensureConfigFontsLoaded(config)(または同等の処理)を呼び出してください。レイアウトは、そのファミリーについてブラウザーがその時点で持っているフォントメトリクスで計測されます。本来のフォントがまだ届いていなければ、VDTは代替フォントで計測され、PDFはCanvasやHTMLの出力と一致しません。Sandboxは描画のたびに、その前にこれを明示的に行っています(packages/postext-sandbox/src/viewport/PdfViewport.tsxを参照)。

#ライブサンプル:ブラウザーでPDFを作る

上の一連の流れをブラウザーで実行します。このCodePenのサンプルは、postextとpostext-pdfをCDNからインポートし、Webフォントを読み込み、ドキュメントを構築し、フォントプロバイダーを通じてFontsourceのカットを埋め込み、そのバイト列を、ファイルを新しいタブで開くリンクとダウンロードリンクに渡します。得られるPDFは、CanvasやHTMLの出力と同じ改行位置を持ち、実際のフォントが埋め込まれ、アウトラインのしおりも付いています。

Postext · ブラウザーでPDFを生成
import { buildDocument } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
 
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.`;
 
const config = {
  page: { sizePreset: '17x24' },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// The PDF embeds real font files. Fontsource publishes one static WOFF2 per
// weight and style; decompress it to the TTF bytes pdf-lib can embed.
const fontProvider = async (family, weight, style) => {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
};
 
// Layout is measured with the browser's fonts, so load them before building:
// otherwise the PDF would not match the canvas or HTML output.
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);
 
// Same VDT, now translated to PDF points: identical line breaks and placement.
const bytes = await renderToPdf(doc, { fontProvider });
 
// A PDF viewer cannot run inside this sandboxed result frame,
// so hand the file to a new tab and to a download link.
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
document.getElementById('status').textContent =
  `${doc.pages.length} page(s) · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
index.html
<p id="status">Rendering…</p>
<p id="links" hidden>
  <a id="open" target="_blank" rel="noopener">Open lantern.pdf in a new tab</a> ·
  <a id="download" download="lantern.pdf">Download it</a>
</p>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
}

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

#ワーカーでPDFを描画する

postext-pdf/workerは、renderToPdfをメインスレッドの外に移します。ワーカーはテキスト、ベクターの図、構造ツリー、ファイルそのものを書き出します。Webページ側でしかできない処理が2つあり、ワーカーはそれらをメインスレッドに依頼します。フォントの取得と、<img>を通じたSVGのラスタライズです。数百ページの本では、描画にかかる数秒のあいだページが固まってしまうのを防げます。数ページなら、renderToPdfを直接呼び出すほうが簡単です。

import { createPdfWorker } from 'postext-pdf/worker';
 
const pdfWorker = createPdfWorker();
const bytes = await pdfWorker.render(docs, {
  fontProvider,                                     // runs on this thread
  resourceBytes: new Map([['map.svg', svgBytes]]),  // a Map; its buffers move to the worker
  onProgress: ({ phase, pages, totalPages }) => showProgress(phase, pages, totalPages),
  onWarning: (w) => console.info(w.message),
});
pdfWorker.dispose();
  • render(docs, options)は、1つのドキュメント、または本の章のドキュメントの配列を受け取ります。オプションはrenderToPdfのものですが、違いが2つあります。resourceBytesはMap<string, Uint8Array>で、そのバッファーは転送されるため、手元に残すバイト列はコピーを渡してください。rasterizeSvgは、指定した場合はメインスレッドで実行されます。指定しなければ、ページ自身のImageとcanvasが処理します。
  • 1つのハンドルが描画するドキュメントは一度に1つです。dispose()はワーカーを終了し、保留中の描画をすべて拒否(reject)します。
  • createPdfWorker({ worker })は、自分で作成したWorkerを受け取ります。ワーカーのURLを制御するビルドツール向けです。そのワーカーではpostext-pdf/worker/entryを実行する必要があります。

CDNから使う場合。既定では、ワーカーのスクリプトはパッケージ自身のURL(new URL('./pdf.worker.js', import.meta.url))から読み込まれます。別のオリジンのページでは、これを起動できないことがあります。esm.shからインポートすると、createPdfWorker()はFailed to construct 'Worker': Script at 'https://esm.sh/postext-pdf@…/pdf.worker.js' cannot be accessed from origin …を投げます。代わりに、エントリーをインポートする同一オリジンのモジュールワーカーを起動してください(バージョンを固定する場合は、両方のURLで同じバージョンに固定します)。

import { createPdfWorker } from 'https://esm.sh/postext-pdf/worker';
 
const entry = URL.createObjectURL(new Blob(
  ["import 'https://esm.sh/postext-pdf/worker/entry';"],
  { type: 'text/javascript' },
));
const pdfWorker = createPdfWorker({ worker: new Worker(entry, { type: 'module' }) });

postext/workerのレイアウトワーカーにも、postext/worker/entryを包む同じラッパーが必要です(Web Workerでレイアウトを実行するを参照)。

#印刷入稿用のPDF

本番の印刷ワークフローでは、描画の前に次の設定を調整してください。

  • page.cutLines.enabled: true — 仕上がり線の周りに裁ち落とし領域とトンボを加え、各ページにTrimBoxとBleedBoxを設定します。トンボを参照してください。
  • print: { standard: 'pdfx4', outputProfile: 'fogra51' }(または'pdfx1a') — 印刷所が確認する出力インテント、識別情報、ボックスを備えたPDF/Xファイルを書き出します。すべての色とRGBの画像はICCプロファイルで分版され、K 100%はオーバープリントになり、大きな黒い面はリッチブラックで刷られます。印刷出力(設定)を参照してください。
  • colorSpace: 'cmyk'(またはpdfGeneration: { forceColorSpace: true, colorSpace: 'cmyk' }) — PDF/Xの識別情報なしで、同じ分版を行います(トンボは常にレジストレーションカラーです)。PDFの印刷用マスターはそのまま埋め込まれます。
  • page.dpi: 300 — レイアウトの1インチあたりのpx数です。自身の解像度を持たないビットマップは、原寸ではこの解像度で印刷されます。印刷サイズで300 ppiに満たない画像は、プリフライトが報告します。
  • ColorValue.cmyk — CMYKで指定した色は、その値のまま印刷されます。
  • { pageNegative: true }(RenderToPdfOptionsのオプション) — Differenceブレンドモードで仕上がり領域を反転します(トンボは反転しません)。明るい地に暗い文字の組版を、プリフライトで確認するのに便利です。

#リファレンス実装

SandboxのPdfViewportコンポーネント(packages/postext-sandbox/src/viewport/PdfViewport.tsx)は、上の要素を組み合わせて、再計算、ダウンロード、印刷のボタンを備えたライブプレビューにしたもので、ブラウザー内でPDFを扱う組み込みの出発点に適しています。VDTは共有のレイアウトワーカーで構築するので(Web Workerでレイアウトを実行するを参照)、再計算をクリックしても、パイプラインの実行中にUIが固まりません。メインスレッドが受け持つのはrenderToPdfだけです(VDTができていれば、これは高速です)。

#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ガイド、ショーケースのプリセット、中国語、アラビア語、レシピ集の本)を検査します。どの本もエラーも警告もなく合格します。

#バンドル(.postextファイル)

.postextファイルは、1冊の本をまるごと収めた1つのファイルです。zipアーカイブで、preset.jsonマニフェスト、章ごとに1つのMarkdownファイル、リソースのデータ本体(ビットマップ、SVG、PDFの印刷用マスター)、設定が指定するフォントファイルを収めます。Sandboxがこれを書き出し・読み込みし、エージェントスキルがこれを出力します。postextパッケージでも作成し、開くことができるので、本はこれらのツールと自分のプログラムの間を何も失わずに行き来できます。

my-book.postext
├── preset.json            manifest: name, locale, chapters, config, resources, fonts
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2

マニフェストの各フィールドは、Sandboxの付録プリセットバンドルの形式で説明しています。ファイルにはlayouts.jsonを含めることもできます。これはSandboxのページ数の記録で、本がSandboxで最初からページ分けされた状態で開きます。多言語の本では版ごとに1つずつ持つこともできます(layouts.zh-Hant.json。こちらが先に読まれます)。openBundleはこれらを無視します。

APIはpostext本体と、低レベルのヘルパーを加えたpostext/bundleサブパスから書き出されています。描画もする場合はpostextからインポートしてください。そうすればバンドルのアダプターとレンダラーが1つのモジュールインスタンスを共有します。これは、エントリーポイントごとに別のビルドになるesm.shのようなCDNで重要です。

#バンドルを開く

openBundleはファイルのバイト列(Uint8Array、ArrayBuffer、または<input type="file">から得たBlob / File)を受け取り、エンジンとそのバックエンドが必要とするものをすべて返します。

import { openBundle } from 'postext';
 
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
 
bundle.chapters;   // [{ title, file, markdown }, …] in book order
bundle.config;     // PostextConfig, ready for buildDocument
bundle.resources;  // Resource[]
bundle.files;      // Map<path, Uint8Array>: every file of the bundle
フィールド内容
manifest検証済みのpreset.json。
id, name, descriptionマニフェストから取得した値。
locale, locales内容を読んだロケールと、多言語のバンドルが持つすべてのロケール。options.localeでどれを読むかを選びます。まず完全に一致するタグ、次に基本言語、最後にバンドル自身のロケールの順に探します。
chapters章ごとの{ title, file, markdown }。マニフェストにタイトルがない章は、最初の#見出しのテキストをタイトルにします。
config既定のカラーパレットとバンドルの言語でのリソースタイプの上に、マニフェストのconfig、さらにロケールの上書きを重ねたもの。バンドルの言語は上のlocaleなので、1言語のバンドルはoptions.localeが何を求めても、その言語のラベルになります。言語を指定していないマニフェストでは、そのconfigが設定する言語(locale、次にハイフネーションのロケール)を使い、それもなければoptions.localeを使います。customFontsにはバンドルのフォントファミリーが並びます。Sandboxがバンドルを開くときと同じ設定です。
resources選んだロケールのキャプションを付けたリソース。マニフェストにないサイズはファイルから読み取ります。
fontsフェイスごとに1項目:{ family, weight, style, format, file, bytes }。
filesアーカイブ内のすべてのファイル。キーはそのパスです。
thumbnail, canvasScope表紙画像のパスと、バンドルが求める表示方法。マニフェストのviewの上に、読み込んだ言語のlocalized[…].viewを重ねたものです。
startバンドルがもっと長い本の一部だけを収めているときの、本の始まりの位置。マニフェストのstart、または読み込んだ言語のlocalized[…].startです。最初から始まる本にはありません。
warnings致命的でない問題。対応していないフォントファイル、印刷用マスターの欠落など。

データ本体のfileIdは、バンドル内でのそのパスです。resource.svg.fileId、resource.bitmap.fileId、customFontsの各バリアントのfileIdは、そのままbundle.filesで引けます。openBundleは、バイト列がzipでない場合、有効なpreset.jsonが(ルートにも、トップレベルの1つのフォルダーの下にも)ない場合、マニフェストが指定するファイルが欠けている場合に例外を投げます。

postext 1.4以前で書き出したバンドル

createBundleとSandboxが書き出すマニフェストには、必ずconfigVersion: 11が入っています。これは、そのconfigがどの設定規則を前提に書かれたかを示します。これを持たないマニフェストはpostext 1.4以前で書き出されたもので、次の18点の扱いが現在と異なります。

  • 見出しの改ページ(規則3):1.4までは、H1の改ページを持たないheadingsオブジェクトでは改ページが入りませんでした(レベルごとの上書きを参照)。
  • 数式のサイズ(規則4):1.4までは、数式がfontSizeScaleの指定より1.131倍大きく組まれていました(数式のサイズを参照)。
  • インラインのリソースの下のアキ(規則5):1.4までは、placement.position: 'here'の図や表の後のテキストが、その下にフロートのアキを取らずに次のグリッド線から再開していました(レイアウトのlayout.inlineResourceGapを参照)。
  • ボックスの中のインラインのリソースの周りのアキ(規則6):1.4までは、そうしたリソースが周りのボックスのテキストに接して置かれていました(レイアウトのlayout.inlineResourceGapInBoxesを参照)。
  • 見出し内の文字書式(規則6):1.4までは、見出しの中の*italic*、**bold**などのマークが付いた語を、見出し自身の素のスタイルで印字していました(見出しのheadings.inlineMarksを参照)。
  • ドロップキャップのサイズ(規則6):1.4までは、fontSizeのないデザインテキストのdropCapは、またがる行ボックス全体と同じ高さになり、上端が1行目より上に出ていました(テキスト要素のdropCapを参照)。
  • コロンで終わる行の下の余地(規則6):1.4までは、keepColonWithListはコロンで終わる行の下に1行分の余地があればリストには十分とみなしていました。そのため、オーファンとウィドウの規則が分割しない2行の最初の項目は、コロンの行を残して次の段に送られていました(bodyText.colonListRoomを参照)。
  • ボックスの分割で残る行(規則6):1.4までは、段落やリスト項目の途中で分割したボックスは、ボックスの各側に合計でsplitMinLines行あれば、その段落や項目の1行だけを片側に残すことがありました(レイアウトのlayout.boxChildSplitMinLinesを参照)。
  • ダッシュでの改行(規則7):1.4までは、Knuth-Plassは語と語の間に前後を詰めて置いたemダッシュやenダッシュ(say—that’s)の後で行を終えることがなく、書式付きのテキストを1行ずつ組む改行処理は2文字の間でだけ改行していました(本文のbodyText.breakAfterDashesを参照)。
  • 行末不ぞろいのテキスト(規則7):1.4までは、optimalLineBreakingの値にかかわらず、行末不ぞろいの本文テキストを1行ずつ、各行を埋めてから次の行に進んで組んでいました(本文のbodyText.optimalRaggedを参照)。
  • 見出しの下での分割(規則8):1.4までは、段の末尾にある見出しの下の段落は、次の段に送られる行がどれほど少なくても、そこに入るだけの行を残していました(見出しのheadings.keepWithNextSplitを参照)。
  • :::paragraphsコンテナーの下のアキ(規則8):1.4までは、スタイルのアキをグリッドへのスナップの前に最後の段落の下に置き、その下に次のブロックの上のアキ(見出しのmarginTop)を加え、テキストの段落間隔は含めていませんでした(本文のbodyText.paragraphContainerSpacingを参照)。
  • 複合語のハイフンでの改行(規則8):1.4までは、インライン書式のない段落では、Knuth-Plassは2文字の間のハイフン(well-known)の後で両端そろえの行を終えることがなく、書式のある段落では終えていました(本文のbodyText.breakAfterHyphensを参照)。
  • 区切りのない詩(規則9):1.22までは、行に||のない:::verseの詩は各行を1つの半句として中央に置いて組んでいました(詩のbodyText.verse.layoutを参照)。
  • 1行目の字下げとぶら下げインデントの併用(規則9):1.22までは、段落スタイルのhangingIndentがfirstLineIndentに取って代わり、1行目はindentから始まっていました(段落スタイルを参照)。
  • 行末のバックスラッシュ(規則9):1.22までは、段落・引用・リスト項目の行末のバックスラッシュと、後ろにスペースのある\\はそのまま印字され、行はスペースを挟んでつながっていました(本文のbodyText.hardLineBreaksを参照)。
  • コードフェンス(規則9):1.22までは、```や~~~のフェンスとその中の行をMarkdownとして読んでいました。行は段落に結合され、#の行は見出しになり、フェンスは印字されていました(コードリストのcodeStyle.blocksを参照)。
  • 詩行の折り返し(規則10):1.23では、一行ずつ組む詩で行長より長い行は、はみ出しがわずかでも自然な語間のまま折り返していました(詩のbodyText.verse.tightenを参照)。

openBundleとreadBundleは、そのようなマニフェストのconfigと、各ロケールのlocalizedの設定をmigrateConfigを通して読み込みます。この関数は1.4が組んだ改ページを明示的に書き出し、数式の倍率に1.131を掛けます(emで指定した別行立て数式のアキはこの値で割ります)。1.5のプレリリースが書いた3から7の値を持つマニフェストには、その値より後の規則の固定だけが入ります。3なら数式のサイズ、インラインのアキ、規則6の5つの固定、規則7の2つ、規則8の3つ。4ならインラインのアキと、規則6、7、8の固定。5なら規則6、7、8の固定。6なら規則7と8の固定。7なら規則8の固定だけです。見出しの下での分割(pinLegacyHeadingSplit)は、各層を重ねた結果有効になるheadingsにheadings.keepWithNextSplit: 'fill'として書き込まれます。条件は、読み込んだ章に見出しがあり、設定が独自の値を指定しておらず、headings.keepWithNextを有効のままにし、bodyText.avoidOrphansを無効にしていないことです。複合語の改行(pinLegacyHyphenBreaks)は、有効なbodyTextにbodyText.breakAfterHyphens: falseとして書き込まれます。条件は、読み込んだ章に2文字の間のハイフンがあり、設定がこの値をすでに設定しておらず、optimalLineBreakingも無効にしていないことです。コンテナーの下のアキ(pinLegacyParagraphContainerSpacing)は、有効なbodyTextにbodyText.paragraphContainerSpacing: 'add'として書き込まれます。条件は、設定が段落スタイルを(paragraphStylesまたはHTMLビューアーの上書きで)宣言し、読み込んだ章が単独の行で:::paragraphsコンテナーを開き、設定がこの値をすでに設定していないことです。ダッシュでの改行(pinLegacyDashBreaks)は、有効なbodyTextにbodyText.breakAfterDashes: falseとして書き込まれます。条件は、読み込んだ章に語と語の間に前後を詰めて置いたemダッシュやenダッシュがあり(前に文字、数字、閉じる約物があり、後ろに文字、数字、開き括弧や開き引用符があるもの。ダッシュの前の引用符は、その引用符の前に文字、数字、閉じる約物、ノーブレークスペースがあれば数に入ります。"no"—andは該当し、said "—Holaは該当しません。ダッシュや引用符に接するインラインのマーク、たとえば**riddles.**—Iの**は、どちら側にあっても数に入ります)、設定がこの値をすでに設定していないことです。行末不ぞろいの改行(pinLegacyRaggedBreaking)は、有効なbodyTextにbodyText.optimalRagged: falseとして書き込まれます。条件は、設定が本文テキストのどこかを行末不ぞろいにし(本文テキスト、段落スタイル、囲みの本文、部の本文、節のスタイルの本文(headingStyles[].bodyStyle)、またはHTMLビューアーの上書きで、'justify'以外のtextAlignを指定する)、この値をすでに設定しておらず、optimalLineBreakingも無効にしていないことです。ボックスの中のアキ(pinLegacyBoxResourceGap)は、有効なlayoutにlayout.inlineResourceGapInBoxes: falseとして書き込まれます。条件は、読み込んだ章の:::calloutの中にリソースが単独の行で埋め込まれ、設定がこの値をすでに設定していないことです。ボックスの分割(pinLegacyBoxChildCut)は、有効なlayoutにlayout.boxChildSplitMinLines: 1として書き込まれます。条件は、読み込んだ章が単独の行で:::calloutを開き、設定がこの値をすでに設定していないことです。見出しのマーク(pinLegacyHeadingMarks)は、各層を重ねた結果有効になるheadingsにheadings.inlineMarks: falseとして書き込まれます。条件は、読み込んだ章の見出しがマーク(タイトル内の*、_、^、~、:smallcaps[、またはリンク)を含み、設定が独自の値を指定していないことです。ドロップキャップ(pinLegacyDropCapSize)は、どこにあっても1.4のサイズがdropCap.fontSizeとして書き出されます。単位は、要素の行送りが長さで指定されていればその単位、そうでなければフォントサイズの単位です。コロンで終わる行の下の余地(pinLegacyColonListRoom)は、有効なbodyTextにbodyText.colonListRoom: 'line'として書き込まれます。条件は、読み込んだ章のリストがコロンで終わる行に続き(間に空行があってもかまいません)、設定が余地を指定しておらず、keepColonWithListも無効にしていないことです。インラインのアキ(pinLegacyInlineGap)は、各層を重ねた結果有効になるlayoutにlayout.inlineResourceGap: 'above'として書き込まれます。条件は、読み込んだ章のある行がリソースを埋め込み(パーサーの解釈どおり、::resource{id="…"}が単独で行にあるもの。本文中やコードスパンでの言及は数に入りません)、設定が独自のアキを指定していないことです。サイズは、各層を重ねた結果有効になるmathに固定されます(ロケール独自のmathは共有のものを置き換えます)。これは読み込んだ章に$がある場合に限られ、数式のないバンドルのconfigは書かれたまま残ります。マニフェストもロケールもmathを指定しない場合、有効なものはreadBundleのbaseConfig(読み込む側自身の設定)のもので、これも固定されます。1.4はバンドルの数式をそのサイズで組んでいたからです。基本設定のfontSizeScale: 1.5は1.5 × 1.1312として読まれます。基本設定の見出しの改ページはそのまま使われます。このように古いバンドルはこれらの規則で組まれた結果を保ち、bundle.configには、実際に組むときの改ページ、数式のサイズ、アキ、見出しのマーク、ドロップキャップのサイズ、コロンで終わる行の下の余地、ボックスの分割、ダッシュでの改行、複合語の改行、行末不ぞろいの改行、見出しの下での分割、コンテナーの下のアキが表示されます。1.5のレイアウトの修正には固定がなく、ほかの本と同じく古いバンドルにも適用されるため、それらが関わるページは動くことがあります(一覧は数式のサイズを参照)。現在の規則に合わせて手で書くpreset.jsonには"configVersion": 11を指定します。古いバンドルのマニフェストにこの値を書き込むのも、現在の規則で読み込むための1行で済む方法です(その場合、バージョンのないバンドルは見出しの改ページの固定も失います)。8と記されたマニフェスト(postext 1.5〜1.22が書いたもの)は規則9の4つの固定だけを受け、それより古いものも同様に受けます。詩の組み方(pinLegacyVerseLayout)は、読んだ章に開始行で組み方を指定せず行に半句の区切りもない:::verseの詩があり、設定がまだ決めていなければ、有効なbodyTextにbodyText.verse.layout: 'bayt'として書き込まれます。組になった字下げ(pinLegacyPairedIndents)は、0でないhangingIndentも決めている段落スタイル(paragraphStylesまたはHTMLビューアーの上書き設定のもの)すべてからfirstLineIndentを外します。強制改行(pinLegacyHardBreaks)は、読んだ章に、段落・引用・リスト項目の行末がバックスラッシュで終わって次の行に続く箇所か、後ろにスペースと文字が続く\\があり(インラインのコードと数式、別行立ての数式、見出し、:::verseの詩は除く)、設定がまだ決めていなければ、有効なbodyTextにbodyText.hardLineBreaks: falseとして書き込まれます。コードフェンス(pinLegacyCodeBlocks)は、読んだ章が```か~~~のフェンス(3文字以上、行頭からのインデントは3スペースまで)を開き、設定がまだ決めていなければ、codeStyle.blocks: falseとして書き込まれます。9が付いたマニフェスト(postext 1.23で書き出したもの)には規則10の固定だけが適用され、それより古いものにも適用されます。詩行の折り返し(pinLegacyVerseTightening)は、読んだ章が詩を一行ずつ組んでいて(:::verseのフェンスがlayout=linesを指定しているか、組み方を指定せず各行に半句の区切りがなく、設定がそのような詩をバイトとして組んでいない場合)、設定がまだ決めていなければ、有効なbodyTextにbodyText.verse.tighten: falseとして書き込まれます。10が付いたマニフェスト(postext 1.24で書き出したもの)には規則11の固定だけが適用され、それより古いものにも適用されます。文字グリッドの段末そろえ(pinLegacyGridBalancing)は、統合後の設定が横組みでcjk.grid.enabledを指定し、enabled自体は指定していなければ、有効なheadingsにheadings.balancing.enabled: trueとして書き込まれます。1.24はそのようなページを既定でそろえていたためです。規則11では、書名を1字目の後で分割しないこと、丸数字を漢字として組むこと、CJKのデザインテキストを本文の規則で組むことも加わった(#637)。10以前のマニフェストは、読み込んだ章に書名(《、〈、:book[)があればcjk.titleMinChars: 1(pinLegacyTitleBreaks)、丸数字(U+2460〜U+24FF、U+2776〜U+2793)があればcjk.circledNumbers: 'western'(pinLegacyCircledNumbers)、読み込んだ章か設定そのものにCJKのテキストがあればcjk.composeDesignText: false(pinLegacyDesignText)を、それぞれ有効なcjkに受け取る。いずれも設定がその項目をまだ指定していない場合に限る。規則11では、インラインの表を切ることと、本文中で:::columnsを組むことも加わりました(#634)。10以前のマニフェストは、読み込んだ章がリソースを埋め込んでいれば有効なtableStyleにtableStyle.splitInline: false(pinLegacyInlineTableSplit)を、読み込んだ章が単独の行で:::columnsフェンスを開いていれば有効なlayoutにlayout.flowColumns: false(pinLegacyFlowColumns)を受け取ります。どちらも、設定がその項目をまだ指定していない場合に限ります。規則11では、ページ幅の章扉のある段の先頭もフロートの空き位置になりました(#639)。10以前のマニフェストは、マージした設定がspan: 'page'の見出しレベルまたは見出しスタイルを設定し、読み込んだ章に見出しがあれば、有効なlayoutにlayout.floatsUnderOpener: false(pinLegacyOpenerHeadFloats)を受け取ります。これも、設定がその項目をまだ指定していない場合に限ります。

import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
 
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
//      layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
//      headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // today's rules: `config` itself

contentは、その設定で組むMarkdownです(文字列または章のリスト)。これがない場合、数式のサイズは数式が有効なら常に、コンテナーの下のアキは設定が段落スタイルを宣言していれば常に固定されます。2つのアキ、見出しのマーク、コロンで終わる行の下の余地、ボックスの分割、ダッシュでの改行、見出しの下での分割、複合語の改行は無条件に固定されます。本に数式、:::paragraphsコンテナー、インラインの図、マークを含む見出し、コロンで導入するリスト、ボックス、詰めて置いたダッシュ、見出し、複合語があるかどうかを、エンジンが判断できないためです。行末不ぞろいの改行は、内容の有無にかかわらず設定だけで固定されます。保存した設定は一度だけ移行し、CONFIG_VERSIONで保存し直してください。数式の固定は倍率に掛け算をするので、2回移行した設定は2回大きくなります。これがないときは詩の組み方とコードフェンスも固定されます。本に区切りのない:::verseの詩やフェンスがあるかもしれないからです。組になった字下げは設定だけを見て固定されます。1.23の詩行の折り返しも固定されます。本に一行ずつ組む詩があるかもしれないからです。

#バンドルを組んで描画する

開いたバンドルをエンジンとバックエンドにつなぐヘルパーは4つあります。

  • loadBundleFonts(bundle):バンドルのフェイスをdocument.fontsに登録し、SVGの画像のために、そのバイト列をエンジンのフォントレジストリにも登録します(registerFontBytes)。レイアウトはブラウザーが持つフォントでテキストを計測するので、組む前にその完了を待ってください。バンドルが指定していても収めていないファミリー(Google Fonts)は、ほかの文書と同じく自分で読み込む必要があります。
  • registerBundleImages(bundle):canvasバックエンド(renderPage、renderToCanvas)のために画像をデコードします。動画のポスターも含みます。renderToHtmlのリゾルバーは2つあり、bundleImageUrl(bundle)(resourceImageUrl)は画像を、bundleVideoUrl(bundle)(resourceVideoUrl)はバンドルが収める動画ファイルを解決します。どちらもdiagramStyle.singleInkが有効な場合はSVGの図を一度だけ色替えします。マークアップを色替えし、画像に印を付けて、どのバックエンドも重ねて色を付けないようにします(キャンバスとHTMLでの単色刷りを参照)。どちらも、各SVGに文字が指定するフェイスを、まずバンドル自身のフォントから、次にエンジンに登録されたフェイスから埋め込みます(SVGの文字のフォントを参照)。registerBundleImages(bundle, { onWarning })とbundleImageUrl(bundle, { onWarning })は、フェイスのないファミリーを報告します。
  • buildBundle(bundle):章を順に組み、章ごとに1つのVDTDocumentを返します。各章は前の章を引き継ぎます。見出しとリソースのカウンター、開いている部、ページの奇偶、ノンブルです。目次(:::toc)や索引(:::index)を印字する章は、本全体のアウトラインを受け取ります。buildDocumentと同じオプションに加えて、バンドルの設定を上書きするconfig、計測キャッシュを共有するcache、metadata(後述)を受け付けます。
  • bundleResourceBytes(bundle)、bundleFontProvider(bundle, { decodeWoff2, fallback }):postext-pdfのrenderToPdfのresourceBytesオプションとfontProviderオプションです。フォントプロバイダーは、求められたスタイルのうち最も近いウェイトをバンドルから選びます。.woff2のフェイスにはdecompressWoff2が必要です。バンドルが収めていないファミリーについては、requestを含むレンダラーの引数でfallbackを呼び、その戻り値をそのまま渡します。ファミリーのlatinファイルだけを取得するフォールバックでは、バンドルが埋め込んでいない中国語のファミリーが空の四角として印字されます。中国語・日本語・韓国語のフォントのsliceFontProviderのようにスライスで応えるフォールバックなら、すべて印字されます。
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
  bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
 
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
 
const docs = buildBundle(bundle);                        // one VDTDocument per chapter
const firstPage = renderPage(docs[0].pages[0], docs[0]); // a <canvas>
 
const pdf = await renderToPdf(docs, {                    // the whole book
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
  resourceBytes: bundleResourceBytes(bundle),
});

1つの章だけを自分で組むには、ほかの文書と同じく、bundle.chapters[i].markdown、bundle.resources、bundle.configをbuildDocumentに渡します。

本のメタデータ。Sandboxと同じく、最初の章のフロントマターが本のメタデータになります。buildBundleはそのtitle、authorなどをすべての章に渡すので、{title}や{author}の柱はどのページにも入り、どの章のdoc.metadataにもそれらが入ります。2章目以降の先頭にあるフロントマターのブロックは無視されます。---の行で見つけられ、解析されずに空白に置き換えられるので、パーサーが受け付けないYAMLでも害はありません。options.metadataは、フロントマターが設定しない値を補います(フロントマターが優先されます)。本のページ数もすべての章に届きます。{bookTotalPages}はそれを印字し、{totalPages}は章のページを数えます(本全体のページ数を参照)。

const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title;   // the first chapter's `title:`

本の始まりの位置。バンドルは、もっと長い出版物の一部だけを収めることができます。ある号の58〜61ページや、教科書の第4章などです。startは、その前に何があるかを、buildDocumentのcontinuationと同じ項目で示します。最初のページより前のページ数(pageIndexOffset。1ページ目が左右どちらの面になるかを決め、それに応じて左右対称のマージンと奇数・偶数ページの柱も決まります)、その時点のページ番号の付け方(pageNumbering)、見出しのカウンター(headings)、開いたままの部(part)、そしてリソース、番号付きの項目、脚注、行番号のカウンターです。createBundleはこれをpreset.jsonのstartに書き込み、openBundleはbundle.startとして返します。buildBundleは最初の章をこれでレイアウトし(buildDocument({ markdown, continuation: start }, config)と同じ結果になります)、続く章をそこからつなげます。{bookTotalPages}は本より前のページも数えます。startのないマニフェストはこれまでどおりに読まれ、この項目を知らない読み取り側は無視します。bookPageCountは含まれません。ページ数は読み取り側が数えます。複数の言語を収めたバンドルでは、localized[…].startでその版だけの始まりを指定できます。

const { bytes } = await createBundle({
  name: 'Field notes, chapter 4',
  markdown,
  config,
  // Page 58, an even page, in chapter 4.
  start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start;                 // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel;       // '58'

#ライブサンプル:バンドルを開く

このCodePenのサンプルは、リポジトリーから2章の見本の本(独自の書体、SVGの図、表を持つlantern.postext)を読み込みます。バンドルのフォントと画像を登録し、buildBundleで本を組んで、全ページを描画します。「Make the PDF」ボタンは、同じ文書をpostext-pdfで描画し、バンドルのフォントを埋め込みます。Sandboxから書き出したものなど、自分の.postextファイルを選べば、同じように表示されます。

Postext · .postextバンドルを開く
import {
  openBundle,
  loadBundleFonts,
  registerBundleImages,
  buildBundle,
  bundleResourceBytes,
  bundleFontProvider,
  renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
 
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
 
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
 
async function show(data) {
  // Chapters, config (fonts wired to the bundle's own files), resources and
  // every file, keyed by its path inside the bundle.
  const bundle = await openBundle(data);
 
  // Layout measures text with the fonts the browser has: register the
  // bundle's faces, and load the Google Fonts it names but does not carry
  // (the default running heads use Open Sans; see the pen's CSS).
  await loadBundleFonts(bundle);
  await document.fonts.load('600 16px "Open Sans"');
  await registerBundleImages(bundle);
 
  // One VDTDocument per chapter, each continuing the one before it.
  const docs = buildBundle(bundle);
  const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
  document.getElementById('pages').replaceChildren(...pages);
  status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
    + (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
  current = { bundle, docs };
  pdfButton.disabled = false;
  document.getElementById('links').hidden = true;
}
 
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
  if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
 
pdfButton.addEventListener('click', async () => {
  pdfButton.disabled = true;
  status.textContent = 'Rendering the PDF…';
  const { bundle, docs } = current;
  const bytes = await renderToPdf(docs, {
    fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
    resourceBytes: bundleResourceBytes(bundle),
  });
  const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
  document.getElementById('open').href = url;
  document.getElementById('download').href = url;
  document.getElementById('links').hidden = false;
  status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
  pdfButton.disabled = false;
});
 
document.getElementById('file').addEventListener('change', async (event) => {
  const file = event.target.files[0];
  if (!file) return;
  status.textContent = `Opening ${file.name}…`;
  await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
 
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());
index.html
<p>
  <label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
  <button id="pdf" disabled>Make the PDF</button>
  <span id="links" hidden>
    <a id="open" target="_blank" rel="noopener">open it</a> ·
    <a id="download" download="book.pdf">download it</a>
  </span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#pages {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: flex-start;
}
#pages canvas {
  display: block;
  width: 240px;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

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

#バンドルを作成する

createBundleは、文書(章、設定、リソース、それらが参照するデータ本体)から.postextファイルを書き出します。

import { createBundle } from 'postext';
 
const { bytes, manifest, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [
    { markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
    { title: 'Night', markdown: '# Night\n\n…' },
  ],
  config,
  resources: [{
    id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
    svg: { fileId: 'lantern.svg', width: 240, height: 150 },
    createdAt: 0, updatedAt: 0,
  }],
  files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});
入力意味
name, id, description, localeマニフェストのメタデータ。idの既定値はnameのスラッグです。
chapters or markdown本の内容。章ごとに1つの{ title?, markdown }、または1つの文書です。
configPostextConfig。既定値と同じ値はマニフェストに書き出しません。
resourcesリソース。画像はbitmap.fileId / svg.fileId(印刷用マスターはsvg.pdfFileId)でデータ本体を指定します。
filesfileIdごとのデータ本体(オブジェクトまたはMap)。リソースが参照する画像と、config.customFontsのバリアントが参照するフォントファイルです。値はUint8Array、ArrayBuffer、Blob、文字列(SVGのマークアップ)のいずれかです。
thumbnail{ data, mime }:表紙画像(PNG、JPEG、WebP、GIF、SVG)。
canvasScope'book'にすると、本全体を1つのキャンバスとして組むようビューアーに求めます。
startバンドルがもっと長い本の一部だけを収めているときの、本の始まりの位置。単独の文書をビルドするときに渡すcontinuationと同じものです。マニフェストのstartとして書き込まれます。
mtimeアーカイブのすべてのファイルに書き込む更新日時(Date、タイムスタンプ、日付文字列のいずれか)。省略すると呼び出した時刻になるので、同じ入力でも2回の呼び出しで異なるバイト列になります。固定の日付を渡せば同じ入力から同じバイト列が得られ、ハッシュを取ったり比較したりできます。zipが保持する日時はタイムゾーンを持たず、2秒刻みで、1980年から2099年までです。日付はマシンのローカル時刻で書き込まれます。どのマシンでも一致するバイト列を得るには、new Date(1980, 0, 1)のようにローカル時刻の各フィールドから日付を作ります。タイムスタンプやZで終わる文字列は特定の瞬間を指すので、タイムゾーンごとに異なるローカル時刻になります('1980-01-01T00:00:00Z'はUTCより西では1979年のままです)。ローカル時刻でこの範囲の外にある日付は例外になります。
localized同じ本の別の言語。ロケールタグごとに{ es: { chapters?, config?, resources? } }の形で指定します。この場合、上の入力は、必須のlocaleの内容になります。多言語のバンドルを参照してください。

戻り値は、アーカイブのbytes、preset.jsonとして書き出したmanifest、すべてのファイルを収めたfiles(パス → バイト列)、warningsのリストです。ファイル名はリソースのid(resources/lantern.svg)またはフォントのファイル名(fonts/…)から、章のファイル名は順番とタイトル(chapters/01-dusk.md)から付けます。フォントはマニフェストのfontsで宣言し、config.customFontsの中には決して書きません。次のものは、それぞれ警告を出して除外します。

  • データ本体がfilesにないリソースやフォントのフェイス
  • .woffのフェイス(PDFバックエンドが埋め込めないため)
  • redistributable: falseが付いたファミリー

ブラウザーでは、bytesをダウンロードリンクに渡します:URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }))。Nodeではfs.writeFileで書き込みます。createBundleとopenBundleにDOMは不要です。配布物のモジュールパスには拡張子がないので、バンドラーを使わない素のNodeではresolveフックが必要です。リポジトリーのdocs/examples/open-bundle/build-sample.mjsに、数行で書いた例があります。

#多言語のバンドル

.postextファイルは本を複数の言語で収めることができ、openBundle(bytes, { locale })はそのどの言語でも読み込めます。createBundleはlocalizedからこれを書き出します。追加のロケールごとに1項目で、それぞれ主となる内容(localeの入力)と異なる部分を持ちます。

const { bytes, manifest } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
  config,
  resources: [lanternFigure, hoursTable],
  files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
  localized: {
    es: {
      chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
      config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
      resources: [
        { id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
        { id: 'hours', caption: 'Horas de luz.' },
      ],
    },
  },
});
 
const es = await openBundle(bytes, { locale: 'es' });   // Spanish chapters, config and captions
  • chapters:その言語での本。章のファイルはロケールごとに1つのフォルダーに入り(chapters/en/01-dusk.md、chapters/es/01-anochecer.md)、マニフェストのchaptersはロケール → 章のマップになります。chaptersのないロケールは主言語の章を読みます。独自の章を持つロケールが1つもなければ、章は1つのリストのままです。
  • config:その言語の設定。そのロケールでバンドルを読むと、トップレベルの各キーが共有のキーを丸ごと置き換えます。上の例のheadingsはheadingsオブジェクト全体を置き換えます。省略したキーや共有のものと同じキーは共有され、書き出されません。そのため、ロケールの設定全体を渡しても、変わる数個のキーだけを渡しても同じように動きます。共有のキーが既定値でないのにロケール側を既定値にしたキー(layout: {})は指定どおり書き出されるので、共有の値をリセットします。フォントは共有です。ロケールのcustomFontsのファミリーはバンドルのfontsに加わります。
  • resources:共有のリソースの文言で、idで対応づけます。caption、note、altText、表のtableです。文字を含む画像は独自のアートワークを持てます。bitmap.fileIdやsvg.fileId(とsvg.pdfFileId)でfiles内の別のデータ本体を指定すると、resources/es/lantern.svgとして書き出されます。タイプや配置などほかのフィールドは共有です。resourcesにないidは警告を出して除外し、ロケールの画像が欠けている場合も、警告を出してそのロケールは共有の画像を使います。

マニフェストはlocalesにすべての言語を並べ(['en', 'es'])、主言語をlocaleに残し、それ以外をlocalizedに格納します。ロケールを指定しないopenBundleは主言語を読みます。

読み手が得る言語。openBundle(bytes, { locale })は、完全に一致するロケール、なければその基本言語(es-MXならes)、それもなければ主言語を返し、どれを返したかをbundle.localeが示します。章と文言は常に同じ言語から取られます。localizedが主言語の地域変種を持っていても、主言語は共有の文言を保ちます。pt-BRの項目を持つpt-PTのバンドルは、pt-BRに対してだけブラジルのキャプションを読み、pt-PTとptには共有のキャプションを読みます。

#ライブサンプル:バンドルを作成する

このCodePenのサンプルは、SVGの図を持つ2章の本を作り、createBundleが書き出したファイルとマニフェストを一覧表示します。アーカイブをダウンロードできるようにしてから、openBundleで開き直して最初のページを描画します。数行で往復のすべてを行うサンプルです。ダウンロードしたファイルをSandboxで読み込めば、そこで作業を続けられます。

Postext · .postextバンドルを作成する
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
 
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
  <rect width="240" height="150" fill="#f3efe6"/>
  <path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
  <rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
  <circle cx="120" cy="79" r="11" fill="#fff4c2"/>
  <path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
 
const resources = [{
  id: 'lantern',
  typeId: 'figure',
  kind: 'svg',
  caption: 'The lantern by the door.',
  svg: { fileId: 'lantern.svg', width: 240, height: 150 },
  createdAt: 0,
  updatedAt: 0,
}];
 
const text = '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.';
 
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
  { markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
  { markdown: `# Night\n\n${text}\n\n${text}` },
];
 
const config = {
  layout: { layoutType: 'double' },
  // Two short chapters that run on, with no blank verso between them (an
  // H1 otherwise opens on a fresh recto), as in the open-bundle sample.
  headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
 
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters,
  config,
  resources,
  files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
 
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
  const li = document.createElement('li');
  li.textContent = `${path} (${data.length} B)`;
  return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
 
// Round trip: open the file just written, the way any program would.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
  `${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;
index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
  <a id="download" download="lantern.postext">Download lantern.postext</a> ·
  <a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
  <section>
    <h3>Files in the bundle</h3>
    <ul id="files"></ul>
    <h3>preset.json</h3>
    <pre id="manifest"></pre>
  </section>
  <section>
    <h3>Opened again: page 1</h3>
    <div id="page"></div>
  </section>
</div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#output {
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
  align-items: flex-start;
}
#output section {
  flex: 1 1 280px;
  min-width: 0;
}
h3 {
  margin: 8px 0;
  font-size: 14px;
}
ul {
  margin: 0;
  padding-left: 20px;
  font-family: ui-monospace, monospace;
  font-size: 13px;
}
pre {
  max-height: 320px;
  overflow: auto;
  padding: 8px;
  background: #fff;
  font-size: 12px;
}
#page canvas {
  display: block;
  max-width: 100%;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

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

#バンドルの活用

Sandbox、エージェントスキル、postextパッケージはどれも同じファイルを読み書きするので、.postextファイルは本をツールからツールへ渡す手軽な手段になります。

  • バンドルから始める。既存の出版物をエージェントスキルで移植するか、Sandboxで本をデザインして書き出します(本パネルの、その本の行の⋯メニューにあるダウンロード(.postext))。そのファイルをプログラムからopenBundleで読み込み、canvas、HTML、PDFに描画します。ファイルを本のソースとして手元に置き、章、設定、リソースをコードで編集してcreateBundleで書き戻すか、変わるたびに読み込み直します。
  • Sandboxでデバッグし、微調整する。プログラムの出力に手直しが必要なとき(図が違うページに入る、見出しのスタイル、段末そろえなど)は、プログラムが組んだものをcreateBundleで書き出します。そのファイルをSandboxで読み込み(本 → 新規 → .postextファイルを開く…)、ライブプレビュー、検査パネル、PDFビューを見ながらテキスト、デザイン、図を直して、もう一度書き出します。プログラムは修正されたファイルをopenBundleで読み込みます。あるいは、変わった部分をコードに書き戻します。マニフェストのconfigには既定値と異なる値だけが入っているので、短い差分として読めます。

#低レベルAPI

postext/bundleは、openBundleとcreateBundleの土台になる部品も書き出しています。バンドルを独自の方法で保存したり配信したりするホスト(HTTPで配信する展開済みのディレクトリー、データベースのレコードなど)向けです。

  • openBundleZip(bytes) / zipBundle(files, { mtime }):アーカイブの層。開くときはトップレベルのフォルダーを許容し、__MACOSXの項目とドットファイルを無視します。バンドルの外に出るパスは拒否します。mtimeはcreateBundleの入力と同じようにファイルの日時を決めます。
  • readBundle(manifest, readFile, options):マニフェストとreadFile(path)コールバックから、章、設定、リソース、画像、フォントを読み込みます。optionsでは、ロケール、ファイルidの命名方法(ids)、基本設定(baseConfig。マニフェストの設定の下に置かれます。既定ではバンドルの言語でのbundleBaseConfigのパレットとリソースタイプで、その言語はresolveBundleConfigLocale(manifest, locale)が返します。独自のbaseConfigを渡すホストは、それをこの言語にローカライズしてください。configVersion: 4より古いマニフェストではそのmathがバンドルのものと一緒に固定され、5より古ければlayoutのインラインのアキ、6より古ければlayoutのボックスの中のアキ、bodyTextのコロンで終わる行の下の余地、headingsのインラインのマーク、ドロップキャップのサイズ、7より古ければbodyTextのダッシュでの改行と行末不ぞろいの改行、8より古ければheadingsの見出しの下での分割と、bodyTextの複合語の改行および:::paragraphsコンテナーの下のアキが固定されます。postext 1.4以前で書き出したバンドルを参照)、固有のサイズの計測方法を指定します。readResolutionは各ビットマップの解像度をファイルから読み取り、bitmap.fileResolutionに入れます。バンドルがlayout.bitmapResolution: 'file'を指定しているときは、既定でtrueです。
  • planBundle(meta, content) / resolveBundleFiles(plan, sources):書き出し側。純粋な計画(ファイル名とマニフェスト)と、readBlob / readFontコールバックによるバイト列の解決に分かれています。
  • isBundleManifest(value)、ロケールの選択関数(pickChapterSpecs、pickLocaleOverrides、pickBundleView、resolveBundleLocale、resolveBundleConfigLocale)、svgSize / bitmapSize / bitmapInfo(ビットマップのピクセル数と、ファイルに記載された解像度)、形式の型(BundleManifest、BundleResourceSpec、BundleFontFamilySpec、…)。
  • CONFIG_VERSION、migrateConfig(config, configVersion, { content })、pinLegacyHeadingBreaks(config)、pinLegacyMathSize(config)、pinLegacyInlineGap(config)、pinLegacyBoxResourceGap(config)、pinLegacyHeadingMarks(config)、pinLegacyDropCapSize(config)、pinLegacyColonListRoom(config)、pinLegacyBoxChildCut(config)、pinLegacyDashBreaks(config)、pinLegacyRaggedBreaking(config)、pinLegacyHeadingSplit(config)、pinLegacyParagraphContainerSpacing(config)、pinLegacyHyphenBreaks(config)、LEGACY_MATH_SIZE(0.5 ÷ 0.442):保存した設定を現在の規則で表したものにします(postext 1.4以前で書き出したバンドルを参照)。readBundleはこれを適用します。設定を独自の方法で保存するホストも、保存したコピーごとに1回ずつ適用できます。

Sandboxはこれらの上に作られています。Sandboxは独自の保存用idとlayouts.jsonのページ記録を加えます。