本文へスキップ

章 3.19 · 部 II · 技法

プログラムからの利用:.postextバンドル

コードから.postextファイルを開き、組み、描画し、作成する方法、多言語のバンドル、古いバージョンのファイル、低レベルのAPI

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

かんたんな説明

このページは、プログラムを書く人向けです。.postextファイルには、1冊の本の文章、設定、フォント、画像がまるごと入っています。このページでは、そのファイルを開き、ページを組み、描く方法を示します。自分の文章から新しいファイルを作る方法と、2つの言語の本を1つのファイルにまとめる方法も示します。古いバージョンの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: 14が入っています。これは、その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": 14を指定します。古いバンドルのマニフェストにこの値を書き込むのも、現在の規則で読み込むための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)を受け取ります。これも、設定がその項目をまだ指定していない場合に限ります。規則12では、文字グリッド上のページがグリッドの数える行数を収めるようになりました(#717)。11以前のマニフェストは、マージした設定がcjk.grid.enabledをオンにしていれば、有効なbodyTextにbodyText.avoidWidows: trueを、japan地域ではさらにbodyText.avoidOrphans: trueを受け取ります(pinLegacyGridBreakRules)。どちらも、設定がその項目をまだ指定していない場合に限ります。規則12では、割注の途中の改行位置の前後に2字ずつ残すようにもなりました(#720)。こうしたマニフェストは、読み込んだ章に:warichu[の注があるか、マージした設定がcitations.notes: 'warichu'を指定していれば、有効なcjkにcjk.warichu.minChars: 1(pinLegacyNoteBreaks)を受け取ります。これも、設定がその項目をまだ指定していない場合に限ります。規則12では、日本地域で欧文の語の括弧や記号の隣にも和欧間のアキが入るようになりました(#721)。11以前のマニフェストは、マージした設定が日本地域に解決され、読み込んだ章に仮名か漢字があれば、有効なcjkにcjk.latinSpacingEdge: 'letters'(pinLegacyLatinSpacingEdge)を受け取ります。これも、設定がその項目をまだ指定していない場合に限ります。本文中に置いたリソースも、段幅のフロートと同じくplacement.captionSideに従うようになりました(#729)。11以前のマニフェストは、マージした設定がlayout.layoutType: 'oneAndHalf'を指定し、読み込んだ章がリソースを埋め込んでいれば、layout.inlineCaptionSide: false(pinLegacyInlineCaptionSide)を受け取ります。これも、設定がその項目をまだ指定していない場合に限ります。

規則12では、短い段のそろえ方も変わりました。11以前のマニフェストは、組んだときのやり方を保ちます。調整手段の順序(#707):読み込んだ章に見出しがあり、マージした設定が段をそろえる設定で、見出し1つに2行を超えて足すことを許し、この項目をまだ指定していなければ、有効なheadingsにheadings.balancing.maxLinesBeforeLooseを受け取ります。値はその設定自身のmaxLinesPerHeadingで、指定がなければ4です(pinLegacyLooseOrder)。複数の段にまたがる図の下の行(#708):読み込んだ章がリソースを埋め込むか参照する、または囲みをフロートにしていて、マージした設定が段をそろえる設定で、stretchAfterFloatsをオフにしておらず、この項目をまだ指定していなければ、有効なheadingsにheadings.balancing.stretchAfterWideFloats: 'always'(pinLegacyWideFloatStretch)を受け取ります。索引のグループ内での段の改まり(#712):読み込んだ章に、1行を占める:::indexのフェンスがあり、設定がこの項目をまだ指定していなければ、有効なindexにindex.groups.splitMinEntries: 1(pinLegacyIndexGroupSplit)を受け取ります。高さをそろえて切る単位(#711):読み込んだ章が本文の行送りから外れた本文を組む可能性があり(:::のフェンス、コードフェンス、引用、リスト、別行立ての数式、文献の引用)、マージした設定が段をそろえる設定で、trailingかbeforeSpanをオンのままにしており、この項目をまだ指定していなければ、有効なheadingsにheadings.balancing.trailingStep: 'grid'(pinLegacyTrailingStep)を受け取ります。

規則12では、ラベルが語である参照を、ラベルのスペースで改行します(#702)。11以前のマニフェストは、読み込んだ章に参照(:ref{…}、\ref{…}、\eqref{…}、pandoc-crossrefの@id)があり、設定がこの項目を指定していなければ、有効なcrossRefsにcrossRefs.breakLabels: falseを受け取ります(pinLegacyRefLabelBreaks)。どのラベルも、組んだときと同じ1語のままです。

規則12では、文字を含まない語(46.、1367))を周りの文字の文字体系で組みます(#725)。11以前のマニフェストは、設定がこの項目を指定していなければ、章の内容にかかわらず、有効なbodyTextにbodyText.scriptContext: false(pinLegacyScriptContext)を受け取ります。こうした語は、レイアウトしたときと同じく単独で組まれます。この規則は約物を文字体系ごとに描き分けるフォントで働きますが、マニフェストを読む時点では本がどのフォントを読み込むか分からないためです。

規則13では、テキストを組む言語が設定項目になり(#764)、規則14では、文書自身の言語がその既定値になります(#770)。13以前が付いたマニフェストは、設定がこの項目を指定していなければ、章の内容にかかわらず、有効なbodyTextにbodyText.shapingLanguage: 'host'(pinLegacyShapingLanguage)を受け取ります。値を指定しているマニフェストは、その値のままです。そのテキストは、レイアウトしたときと同じく、レイアウトをホストしているページの言語で計測され、ワーカーでは言語なしで計測されます。13が付いたマニフェストはpostext 1.29が書き出したものです。1.29.1と1.29.2は、値を指定していない本をホストの言語でレイアウトしていて、固定はそれと同じ結果になります。既定値が'document'だった1.29.0は、そのような本を文書の言語でレイアウトしましたが、CanvasとPDFは計測に従っていませんでした。この本も'host'で読まれます。14が付いていて値を指定していないマニフェストは、文書の言語でレイアウトされます。

規則13では、タスク項目(- [ ]、- [x])のボックスもエンジンが描きます(#765)。四角い枠線で、完了した項目にはチェックが入り、項目のテキストの前に独自の幅を取ります。12以前のマニフェストは、章にタスク項目があり、設定がこの項目を指定していなければ、有効なunorderedListsにunorderedLists.taskBox: 'character'(pinLegacyTaskBox)を受け取ります。ボックスはリストのフォントで組んだ文字☐または☑のままで、項目のテキストは、レイアウトしたときと同じく、その文字の幅のあとから始まります。この幅はその文字を持つフォント(多くの本ではシステムのフォント)が返していたもので、マニフェストを読む時点では分かりません。

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のページ記録を加えます。