章 2 · 部 I · 基礎
Postextのアーキテクチャー
Postext組版エンジンの技術的なアーキテクチャー
かんたんな説明
このページでは、Postextが内部でどう動いているかを、プログラマー向けに説明します。Postextはまず、画面に何も描かずにすべての単語の幅を測ります。そのためとても速く動き、次にすべての行、図、段をどこに置くかを決め、その計画をメモリーに保存します。何も動かなくなるまで、この作業を繰り返します。最後に、できあがったページをWebページ、canvasの画像、PDFファイルとして描きます。単語を1つ変えると、変わった部分だけをもう一度計算します。
Reactを使ったことがあれば、中心となる仕掛けはすでにご存じです。Reactはメモリー上に仮想DOMを組み立てて差分を取り、そのあとで初めてブラウザーの実際のDOMに手を触れます。Postextも同じことをします。ただし組み立てるのはUIコンポーネントではなく、ページ、段、テキストブロック、バウンディングボックスからなるツリーです。複数ページ、複数段の文書のジオメトリー全体を、1ピクセルも描画しないうちに計算します。段落、見出し、画像、脚注、プルクオートのひとつひとつが正確な座標に置かれ、CSSでは表現できない、何世紀も受け継がれてきた組版の規則に従います。
これを可能にしているのが@chenglou/pretextです。DOMを使わずにテキストを計測するライブラリーで、ブラウザーのレイアウトのリフローより300〜600倍高速です。プロジェクトの経緯(10年にわたる失敗、そのすべてを阻んだボトルネック、そしてそれを取り除いたライブラリー)ははじめにを参照してください。
#核となる考え方
2段組みで74ページの文書を思い浮かべてください。企業の年次報告書かもしれませんし、図版の多い教科書かもしれません。これをPostextに渡すと、エンジンはレイアウト全体をメモリー上に組み立てます。すべてのページ、すべての段、すべての段落の正確な位置とピクセル単位の寸法です。72ページの2段目に何があるかを知りたければ、答えはもうそこにあります。描画は必要ありません。エンジンはすでに、各段落をどこで改行するか、各画像をどこに置くか、ウィドウとオーファンをどう避けるか、隣り合う段のベースラインをどうそろえるかを決めています。
これがなぜそれほど重要なのかを説明します。
組版の規則は、どうしようもないほど深く互いに依存しています。5ページのウィドウ(段の下端に取り残された段落の最後の1行)を、前の段に引き戻して直したとします。それはよいのですが、この変更で5ページの段が短くなり、内容が後ろへずれ、6ページにオーファンが生じるかもしれません。段落から切り離され、新しい段に押し出された最初の1行です。新しい問題を作ってしまったことを検出するだけでも、文書のレイアウト全体を調べられる状態にしておく必要があります。そして、どこかに別の問題を作らずにそれを直すには、全体を調整し、計測し直し、検査し直せなければなりません。
これが「まずすべてを計算し、描画はあとで」という考え方です。性能を上げるための工夫ではありません。プロの組版者が何世紀にもわたって用いてきた、互いに関連しあう何十もの組版規則を適用するには、これしか方法がないのです。
#基本概念
簡単な用語集です。このドキュメントの残りの部分はこれらの用語を前提にしています。意味があいまいになったときは、ここに戻ってください。
| 用語 | 定義 |
|---|---|
| VDT | 仮想文書ツリー(Virtual Document Tree)。文書全体を表す、その場で書き換える可変のデータ構造です。ページ、段、ブロック、インラインのセグメント、バウンディングボックスからなります。仮想DOMに相当するものを、文書レイアウトのジオメトリーについて作ったものです。 |
| ページ | 決まった大きさの長方形の領域です。エンジンは最初からページを意識して動きます。文書はページの順序付きの並びです。 |
| 段 | ページを縦に分割した区画です。段は決まった幅と最大の高さを持ちます。テキストはある段から次の段へ、さらに次のページへと流れます。 |
| ブロック | 段の中で縦方向の空間を占める内容の単位です。段落、見出し、画像、表、引用ブロック、プルクオート、脚注領域があります。 |
| 行 | ブロック内の計測済みのテキストの1行で、Pretextが生成します。各行はバウンディングボックスとベースラインの位置を持ちます。 |
| バウンディングボックス | x, y, width, height (単位はピクセル、ページの原点からの相対値)。VDTのすべてのノードが1つずつ持ちます。 |
| リソース | idで本文と結び付けられる、テキスト以外の要素です。ビットマップ、SVG、表のいずれかです(Resource、kind: 'bitmap' | 'svg' | 'table')。リソースは最初に参照された時点で種類ごとに番号が付き(図1、表2.1…)、その参照に近いページの天または地の帯にフロートとして配置されます。 |
| 注 | 脚注または章末の注です。Markdownでは[^id]のマーカーと[^id]:の定義で書きます(脚注を参照)。傍注と、コンテンツモデルが受け付けるPostextNoteのリストは実装されていません。エンジンはnotesを読みません。 |
| ベースライングリッド | 本文の行の高さから導かれる縦方向の格子です(たとえば16px/1.5なら24px)。本文のベースラインはすべてその倍数の位置に来るべきもので、これによって段どうし、向かい合うページどうしで行がそろいます。 |
| バッドネス | 両端そろえの行の語間が、本来の幅からどれだけずれているかを表す値です。調整比の2乗で、10000で頭打ちになります。Knuth-Plassの行分割における基本のコストです。 |
| デメリット | Knuth-Plassで、改行位置の候補にかかる総コストです。バッドネスにペナルティー(ハイフネーション、ウィドウ・オーファン・ラント、適合クラスの不一致)を加えたものです。アルゴリズムはデメリットの合計が最も小さい改行位置の組を選びます。 |
| 適合クラス | 行がどれだけ詰まっているか、ゆるいかを大まかに分けた区分です。隣り合う行のクラスが大きく異なる場合(詰まった行のすぐ隣にとてもゆるい行がある場合)は追加のデメリットがかかり、段落の組みの見た目がなめらかになります。 |
| スラック | 段の下端に残った、使われていない縦方向の空間です。スラックの2乗のコスト(slackWeightで重み付け)によって、行分割は段をすき間なく埋める改行位置の組に寄せられます。 |
| バックエンド | 収束したVDTを受け取る描画先です。現在は3つあります。canvas(ビットマップのプレビュー)、HTML(DOMによる画面での閲覧)、PDF(postext-pdfによる入稿用の出力)です。3つとも同じ計測(Pretextの上に載るmeasureモジュール)を共有します。4つ目のEPUB(postext-epub)は、同じ文書から本を電子書籍として書き出します。 |
| パス | レイアウトのパイプラインの1段階です。各パスはただ1つの役割を持ち、VDTを読んで書き換えます。 |
| 収束ループ | 後のパスが前の決定を無効にしたときに、レイアウトのパスを再実行する外側のループです。反復は最大5回までです。 |
#システム構成
コンテンツがエンジンの中でたどる道筋は次のとおりです。
- パーサーが拡張Markdownと設定を読み、最初のVDTを組み立てます。型付きのブロックからなるツリーで、まだ位置はなく、内容と構造だけがあります
- レイアウトのパスが引き継ぎ、VDTを順に書き換えます。Pretextでテキストを計測し、ブロックをページと段に流し込み、プロの水準に達するまで組版を仕上げます
- 収束ループが問題を見張ります。後のパス(たとえばウィドウの修正)が前の決定(たとえば段の高さ)を無効にすると、エンジンは影響を受けた地点まで戻って再実行します。すべてが落ち着くまで、最大5回繰り返します
- 最終的なVDTは、レイアウトの完全なジオメトリーです。どの要素も、自分のページ番号、割り当てられた段、位置、バウンディングボックスを知っています。描画の前に、文書は完全に「組み上がって」います
- バックエンドが完成したVDTをたどり、目的の形式に描画します。ラスタライズしたcanvasのビットマップ、位置を指定したHTML要素のDOMツリー、フォントを埋め込んだPDF文書のいずれかです。3つとも同じVDTから作られます。どのバックエンドを選ぶかは、純粋に出力の選択です
#入力層
#コンテンツモデル
コンテンツモデルはデータ構造であると同時に、ひとつの考え方でもあります。書き手は何を言うかを記述し、どうレイアウトするかは記述しません。レイアウトの判断はエンジンが行います。
// packages/postext/src/types.ts
interface PostextContent {
markdown: string; // enriched markdown with :ref / ::resource markers
metadata?: DocumentMetadata; // title, subtitle, author, publishDate, …
resources?: Resource[]; // bitmaps, SVGs, tables — referenced by id
notes?: PostextNote[]; // not read: footnotes are written as [^id] in the markdown
}
interface Resource {
id: string; // stable id, referenced by inline :refs
typeId: string; // ResourceType this resource belongs to ('figure', 'table', …)
kind: 'bitmap' | 'svg' | 'table';
caption?: string; // the type prefix + number are computed, not written here
altText?: string;
createdAt: number;
updatedAt: number;
// Exactly one kind-specific payload:
bitmap?: { fileId: string; format: string; width: number; height: number };
svg?: { fileId: string; width?: number; height?: number };
table?: { model: TableModel };
placement?: ResourcePlacement; // optional per-resource float override
safeArea?: ResourceSafeArea; // bitmap / svg: { x, y, width, height } in fractions, the part always shown
}リソースは視覚的なデータを運びます。キャプション、代替テキスト、そして種類ごとのペイロードです。バイナリーのペイロード(ビットマップ、SVG)は帯域外に置かれます。リソースが保存するのはfileIdだけで、レンダラーが描画時にそれを解決します(Sandboxはバイト列をIndexedDBに保存します)。表のリソースは例外で、そのTableModel(結合とそろえを持つセルの格子)はインラインで運ばれます。バイト列ではなく、構造化されたデータだからです。
注は、内容とマーカーのスタイルを運び、Markdown内のインラインの位置から参照されるものとして設計されています。まだ実装されていません。エンジンはnotesを無視し、Markdownには注を参照する構文がありません。
この分離は意図を持った設計上の選択で、見た目以上に大きな意味があります。Markdownが受け持つのは「読む順序」と「意味の構造」です。何が先に来るか、何が見出しか、脚注がどこで参照されるか。リソースと注の配列が受け持つのは「視覚的なデータ」です。画像の寸法、キャプションの文章、注の内容。両者を分けておくことで、設定を変えるだけで同じMarkdownをまったく異なる方法でレイアウトできます。2段組みの学術論文のレイアウトと1段組みのブログ記事が、同じ原稿を共有できます。そしてエンジンは、原稿に一切手を触れずに配置を決められます。たとえば、ここに入らない画像を次の段へ送るといった判断です。
// Example: a simple article with a referenced figure
const content: PostextContent = {
markdown: `
# The Art of Typography
The history of typography begins with Gutenberg's
movable type, shown in :ref{id="printing-press"}.
His invention transformed the production of books.
The technique spread rapidly across Europe, reaching
Italy by 1465 and France by 1470.
`,
resources: [
{
id: 'printing-press',
typeId: 'figure',
kind: 'bitmap',
caption: 'A reconstruction of the original press.',
altText: "Reconstruction of Gutenberg's printing press",
createdAt: 1765379100000,
updatedAt: 1765379100000,
bitmap: { fileId: 'press-photo', format: 'jpeg', width: 600, height: 400 },
},
],
};リソースはidで本文と結び付けられ、参照するだけで文書に取り込まれます。インラインの:ref{id="printing-press"}は2つの役割を同時に果たします。本文中にリソースの計算された番号(「図1」)を描画し、さらに読む順序で最初の参照の時点で、そのリソースをページにフロートとして配置します。参照の近くのページの天か地に帯を確保するもので、印刷の組版者がするのとまったく同じです。図をもう一度配置する必要はありません。流れの中の決まった位置に置かなければならない例外的なリソースのために、単独の行に書く::resource{id="…"}という任意のブロック埋め込みがあります。これがインラインで描画されるのは、リソースの解決済みのplacement.positionが'here'(フロートしない指定)のときだけです。フロートするリソースの場合は、ただの参照のひとつとして扱われます。書き手は配置について考える必要がありません。それはエンジンの仕事です。
#設定
レイアウトのパイプラインのあらゆる面はPostextConfigで制御します。セクション(ページ、レイアウト、本文、見出し、リスト、数式、柱とノンブルなど)の全体は設定のページにまとめています。このドキュメントに特に関係するのは次のものです。
| 設定 | 制御する内容 | 使われる場所 |
|---|---|---|
bodyText / headings | フィールドごとのウィドウ・オーファン・ラントのペナルティー、分割禁止の規則、両端そろえの限度、ハイフネーション。設定 → 本文を参照 | パス2、パス5 |
tableStyle | kind: 'table'のリソースのセルの文字組み、罫線、角の丸み、見出し行と本体の塗り。さらに、表がtable.styleIdで選ぶtableStylesの名前付きの変種。設定 → 表スタイルを参照 | パス4 |
captionStyle | リソースのキャプションの文字組み。番号付きのラベルと説明文。設定 → キャプションスタイルを参照 | パス4 |
diagramStyle | singleInk + inkColor。SVGのダイアグラムを、1色のインキの濃淡に輝度で対応付けて色を付け直します。これにより、特色1色で印刷しても図が忠実に再現されます。設定 → ダイアグラムスタイルを参照 | バックエンド |
resourceTypes | 種類別のリソースの番号付け。テンプレート、カウンターの書式、リセットの範囲、既定のフロート配置。設定 → リソースの種類を参照 | パス1、パス4 |
TypographyConfig, ColumnConfig, ResourcePlacementConfig, ReferenceConfig, PostextSectionOverride | レガシー。types.tsで宣言されていますが、パイプラインには一度も接続されていません。その役割は上のセクションに吸収されました。参考のためだけに残しています。 | — |
#解析の方針
解析は、パイプラインの中で意図的に最も単純にしてある段階です。Markdownが入力され、ASTに解析され、各ノードがVDTBlockになります。リソースの参照はIDによってresources[]配列と照合して解決されます(notes[]配列はまだ読みません。注は計画中で、実装されていません)。出力は、型と内容を持つブロックの平らなリストです。ただし、ページの割り当ても、段も、位置もありません。
目録のようなものだと考えてください。「見出しがあり、次に200語の段落、次に図の参照、次にまた段落がある」。計測はありません。位置決めもありません。レイアウトの判断は一切ありません。重い作業はパス2から始まります。
#仮想文書ツリー(VDT)
プロの組版者に本1冊のレイアウトを頼んだところ、印刷したページではなく表計算のシートを渡されたと想像してください。各行が1つの要素です。各セルが正確な寸法です。「見出しは(40, 30)にある。最初の段落は(40, 78)から始まり、高さは144px。画像は3ページの2段目の天に入る……」。このシートがVDTです。
仮想文書ツリーはPostextの中心となるデータ構造です。すべてのページ、段、ブロック、行を表す、その場で書き換える可変のツリーで、各要素が正確なバウンディングボックスを持ちます。レイアウトのパイプラインが収束すれば、VDTそのものが答えです。「72ページの2段目に何があるか」を、1ピクセルも描画せずに問い合わせられます。
#なぜ可変なのか
これはゲームエンジンの描画パイプラインと同じ手法です。そこでは、共有された可変のワールドの状態を、いくつものシステムが短い周期のループで順に更新します。理由も同じです。
不変のツリー(Reactの仮想DOMなど)は、変更のたびに新しいオブジェクトを確保します。数百のコンポーネントからなるUIならそれで問題ありません。しかし、7つのパスにわたって最大5回反復し、数千のブロックに触れる可能性のある収束ループでは、メモリー確保の負荷とガベージコレクションによる停止が現実の問題になります。そこでVDTは、その場での書き換えとdirtyフラグのパターンを使います。パスはノードをdirtyとしてマークし、後続のパスはどのノードを調べ直せばよいかを正確に知ります。エンジンは何が変わったかを覚えているので、正しく済んだ作業をやり直しません。
#構造
#型定義
以下の形は説明のために簡略化したものです。packages/postext/src/vdt.tsにある実際の定義には、描画のためのフィールド(フォントの文字列、色、リストの記号、数式の描画結果、デザインスロット)がずっと多く含まれています。ここで重要なのは構造です。
// Simplified — see packages/postext/src/vdt.ts for the full definitions
// The root of the Virtual Document Tree
interface VDTDocument {
pages: VDTPage[];
blocks: VDTBlock[]; // flat view of the same block objects
config: ResolvedConfig; // every sub-config resolved to non-optional
baselineGrid: number; // baseline increment in px (e.g. 24 for 16px/1.5)
converged: boolean;
iterationCount: number;
metadata: DocumentMetadata;
}
// A physical page
interface VDTPage {
index: number;
width: number;
height: number;
columns: VDTColumn[];
header?: VDTDesignSlot; // running header (design slot)
footer?: VDTDesignSlot; // running footer / page number
floats?: VDTBlock[]; // resource bands floated to the top/bottom of this page
pageNumberValue: number;
pageLabel: string; // rendered label ('iv', '7', 'A', …)
}
// A column within a page
interface VDTColumn {
index: number;
bbox: BoundingBox; // position within the page
blocks: VDTBlock[];
availableHeight: number; // remaining vertical space
baselineOffset: number; // current baseline y-position
band?: number; // column band (0 unless a span block split the page)
kind?: 'text' | 'span'; // 'span' = full-width column holding a page-span block
}
// A content block (paragraph, heading, resource, etc.)
type VDTBlockType =
| 'paragraph' | 'heading' | 'resource' | 'blockquote'
| 'listItem' | 'footnoteRef' | 'mathDisplay';
interface VDTBlock {
id: string;
type: VDTBlockType;
bbox: BoundingBox;
lines: VDTLine[]; // for text blocks (populated by Pass 2)
resourceBlock?: ResolvedResourceBlock; // for resource blocks
pageIndex: number;
columnIndex: number;
dirty: boolean; // needs re-layout
snappedToGrid: boolean; // baseline aligned to grid
}
// A measured line of text
interface VDTLine {
text: string;
bbox: BoundingBox; // natural width: a justified line is painted to its block's right edge
baseline: number; // y-position of the text baseline
hyphenated: boolean; // line ends inside a word, or after a closed dash ("say—" | "that’s")
hardHyphen?: boolean; // …after a hyphen the text carries ("well-" | "known"): nothing added
repeatedHyphen?: boolean; // opens with that hyphen repeated ("vencer-" | "-se"), not in the source
segments?: VDTLineSegment[]; // word/space/math runs for justified rendering
isLastLine?: boolean; // last line of its paragraph
justifiedSpaceRatio?: number; // applied space width ÷ normal space width
sourceStart?: number; // markdown source map (char offsets; a line opening with `\$` starts at the backslash)
sourceEnd?: number;
plainStart?: number; // plain-text source map
plainEnd?: number;
}
// A measured, placement-ready resource embed (bitmap / svg / table)
interface ResolvedResourceBlock {
resource: Resource;
kind: 'bitmap' | 'svg' | 'table';
number: string; // computed number, e.g. "1.7"
captionPrefix: string; // e.g. "Figure"
bodyRect: BoundingBox; // the image / table area
bodySource?: ResourceSafeArea; // the part of the picture shown in bodyRect when cropped to its safe area
bodyFlex?: { shrink: number; grow: number; delta: number }; // px the body can still shrink / grow, px applied
fileId?: string; // out-of-band binary (bitmap / svg)
captionLines: VDTLine[]; // measured caption, prefix + number included
table?: VDTResourceTableLayout; // cell geometry for table resources
}
// Bounding box — all values in px, relative to page origin
interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}いくつかのフィールドには補足が必要です。
isLastLine:両端そろえの描画を左右します。段落が両端そろえでも、最終行は行末不ぞろいで描画されます。ただし行長からはみ出す(overfull)最終行は例外で、語間を詰めて行長に収めます(TeXのグルーの設定と同じ意味論です)。sourceStart/sourceEndとplainStart/plainEnd:各行から元のMarkdownと、ブロックのプレーンテキストへ戻るソースマップです。エディターとの連携で、カーソルと選択範囲の同期に使われます。VDTResourceTableLayout(とそのVDTResourceTableCellの項目):表のリソースのレイアウト済みのジオメトリーをすべて運びます。列のx方向の境界、行のy方向の境界、セルごとの矩形とその計測済みの内容の行です。これにより、どのバックエンドも同じ表を描きます。computePageTextExtent(page):ページ上で実際にテキストが占める縦方向の範囲(フロートのキャプションを含む)を返す、小さな公開ヘルパーです。デバッグ用のオーバーレイがこれを使い、ベースライングリッドの線を空のページ下部ではなく実際のテキストの範囲だけに引きます。
#dirtyの追跡
dirtyの追跡によって、エンジンはすでに正しく済ませた作業をやり直さずに済みます。パスがブロックを移動したり大きさを変えたりすると、そのブロックと、同じ段でそれより後ろにあるすべてのブロックにdirty = trueを設定します。後ろのブロックの位置はすべて、変わったブロックに依存しているからです。こうして収束ループは、変わっていない部分木をまるごと飛ばせます。
具体例を挙げます。パス5が12ページの段落にハイフンを入れ、その段落の高さが1行分減ったとします。その段落はdirtyとしてマークされます。同じ段でその下にあるブロックもすべて同様です。どれも1行分上へずらす必要があるからです。一方、11ページ以前のブロックには手が触れられません。次の反復で、パスはそれらを完全に飛ばします。
dirtyフラグは収束の合図も兼ねています。パス5〜7のあとにdirtyなブロックが1つもなければ、レイアウトは収束しており、エンジンは反復を止めます。これで完了です。
#レイアウトのパイプライン
7つのパスが、それぞれ1つの仕事を受け持ちます。レイアウトのパイプラインはこれですべてです。
この設計はゲームエンジンの描画パイプライン(シャドウパス、ライティングパス、ポストプロセスのパス)にならっています。そこでは各システムが共有されたワールドの状態を読んで書き換え、前のシステムが役目を果たしたことを前提にします。これにより、個々のパスを単独で理解し、テストし、最適化しやすくなります。パス3のことを考えずに、パス5のベンチマークを取れます。
ゲームエンジンとの大きな違いは、ゲームは各フレームを1回描画したら次へ進むのに対し、Postextはそうできないことです。組版の判断は深く依存しあっています。ウィドウを直すと段の高さが変わるかもしれず、それが段末そろえに影響し、新たなオーファンを生むかもしれません。そのため、パイプラインはループする必要があります。パス3〜7は収束ループの中で実行され、レイアウトが安定した結果に落ち着くまで最大5回反復します。
#パス1:内容の構造化
- 入力:未処理の
PostextContent - 処理:MarkdownをASTに解析し、リソースの参照をIDによって
resources[]と照合して解決し、最初のVDTBlockノードを作ります(注、したがってnotes[]は計画中で、まだ実装されていません) - 出力:平らな
VDTBlock[](型と内容は持つものの、ページや段の割り当てはありません) - 1回だけ実行(収束ループには含まれません)
#パス2:テキストの計測
- 入力:テキストの内容を持つ
VDTBlock[] - 処理:テキストブロックごとに、専用のmeasureモジュール(
packages/postext/src/measure/)を通して、目的の段の幅で行を計測します。このモジュールはPretextの上に、ハイフネーション、両端そろえ、リッチなインラインのラン、Knuth-Plassの行分割を重ねています。計測したVDTLine[]と全体の高さを各ブロックに保存します - 要点:計測結果はキャッシュされます。
cachedMeasureBlock/cachedMeasureRichBlock(measure/cache.ts内)は、テキスト、フォント、幅、そしてレイアウトに影響するすべてのオプションをキーにするので、変わっていない段落の再計測はマップの参照1回で済みます - 出力:すべてのテキストブロックが正確なピクセル単位の寸法を持ちます
- 再実行の条件:段の幅が変わったとき、またはテキストの内容が変わったとき(たとえばハイフネーションが入ったとき)
このモジュールは役割ごとにきれいに分かれています。plain.tsはプレーンなランを、rich.tsは太字・イタリック・数式が混ざったスパンを計測し、font.tsはフォントの文字列を組み立ててキャッシュのライフサイクルを受け持ち、canvas.tsはcanvasの生のテキスト幅の基本機能を包みます。実際の運用で重要になるライフサイクルの細部が1つあります。clearMeasurementCache()はPretextの内部キャッシュとエンジン自身のテキスト幅のキャッシュの両方を消去します。そのため、代替フォントで計測したグリフの幅は、本来のフォントの読み込みが終わった時点で捨てられます。
中国語、日本語、韓国語の段落は、同じモジュールの中で別の経路を通ります。語間のスペースよりCJKの文字が多い段落は、CJKコンポーザー(cjkCompose.ts)に回されます。コンポーザーはテキストを単位(1文字、ラテン文字の連なり、2倍ダーシや3点リーダー、チップや注のマーカーのような分割できないボックス)に切り分け、各単位を1回だけ計測し、cjkClasses.tsの行頭・行末の規則のもとで、先頭から順に入るだけ行を埋めていきます。行頭に置けない約物は、文字を次の行へ送る前に、約物の空き(cjkPunctuation.ts)を詰めることで行内に収めます。両端そろえの行は、そのあと文字と文字の間に空きを配分します。出力は通常のVDTLineで、そのセグメントは、レンダラーが計測どおりに描くのに必要な情報を持ちます。tracking(各文字のあとのピクセル数で、すでにセグメントの幅に含まれます)、空きを詰めた約物のinkOffset、hangs、autospaceセグメントとしての漢字と欧文の間のアキ、そして中国語の注記の圏点、ルビ、割注の行です。日本語の文書では、同じコンポーザーがJLReqに従います。日本語の各レベルの禁則の分類は、改行時に各単位の端の文字から読み取ります。約物の空きはJLReqの順序で詰め、また戻します。ルビはrubyJis.tsが配置し(1:2:1の空け方、仮名への掛かり、熟語ルビ)、漢文の訓点はその文字の単位の幅を広げます。処理量は段落の長さに比例します。紅樓夢の第1回(6,949字)は1,298字の計測で組めます。異なる文字はそれぞれ1回しか計測しないからです。段落の先頭部分を計測していく方式では636,948字の計測が必要でした。中国語の組版を参照してください。
その土台で、Pretextが真価を発揮します。prepare()の呼び出しは負荷の大きい部分で、canvasのフォントエンジンでテキストを解析し、その結果をキャッシュします。一方、layout()の呼び出しは純粋な算術で、ほとんど負荷がかかりません。この分割がすべてです。テキストを一度準備すれば、エンジンは別の幅で何度でもレイアウトし直せます。段の構成を試したり、段落にハイフンが1つ増えたらどうなるかを試したりしても、負荷は無視できるほどです。準備は1回、レイアウトは必要なだけ何度でも。
// Simplified: how the measure module uses pretext internally
const prepared = prepare(paragraphText, '16px/1.5 Inter');
const { height } = layout(prepared, columnWidth, 24); // 24px line-height
// => "This paragraph is 168px tall at 320px column width — that's 7 lines."#パス3:ページと段への配置
- 入力:計測済みのブロック
- 処理:ブロックを順にページと段へ流し込みます。
VDTPageとVDTColumnのノードを作ります。段ごとにavailableHeightを追跡します。ブロックが入らなければ、次の段または次のページへ進みます - 方針:先頭から順に入る場所へ置く貪欲法の配置です。改段と改ページは、有効な割り当てのうち最も単純なものに従います
- 出力:すべてのブロックに
pageIndex、columnIndex、bboxが割り当てられます
この時点で、VDTは本物の文書になります。このパスの前のブロックは、寸法はあっても住所のない平らなリストにすぎません。パス3はブロックを順にたどり、それぞれをページと段に割り当てます。格子状に並んだ容器に水を注ぐようなものです。1段目をあふれるまで満たし、あふれた分を2段目に流し、ページがいっぱいになったら新しいページを始めます。
内容のブロックを置く前に、このパスは構造上の要素のための空間を確保します。柱とノンブル(config.header / config.footerからデザインスロットとして配置されます)と、新しく開いたページで保留になっているフロートの帯です。この確保によって各段のavailableHeightが減るので、内容のブロックが流れ込み始めたとき、エンジンは使える余地がどれだけあるかを正確に知っています。
段の帯とspan段。page.columnsは読む順序に並んだ平らな配列ですが、ページはいつも1列に並んだ段だけでできているとは限りません。ページ幅のインラインブロック(現在は多段組みのレイアウトでspan: 'page'を指定した:::callout)は、ページを上下に積み重なる「帯」に分けます。現在の帯のテキストの段は切断位置で閉じられ(高さを切り詰め、availableHeightはゼロ)、ブロックはkind: 'span'を持つ専用の全幅の段に入り、その下に新しいテキストの段の帯(band + 1、同じxと幅、置き換える帯と同じ下端)が追加されます。段は追加されるだけなので、columnIndexは引き続きpage.columns[i]を指し、レンダラーに特別な描画は要りません。各段は自分のbboxで切り抜かれます。このbboxはcolumnClipRectで広げられます。グリフのインクのための2ptに加えて、段のデザインのオーバーレイ(見出しのタブや囲みのバッジなど)が段の左右にはみ出す分と、見出しのデザインが段の上に突き出す分です。段の下端は境界のまま変わりません。canvasとPDFのバックエンドで同じ矩形を使います。段間罫は帯ごとに隣り合うテキストの段の間に引かれ、ページの頭でページ幅の見出しが占める帯の下から始まります(columnRuleSegments)。スタイル付きのセクションが独自の罫を設定している場合は、ページ自身の罫で引かれます(VDTPage.columnRule、pageColumnRuleで読み取ります)。段末そろえは、span段と高さゼロの帯を無視します。ページ幅のブロックは、帯の高さがそろっている位置(ページの頭、章扉の見出しの直後、別のページ幅のブロックや天のフロートの帯の直後)ならそのまま切ります。高さのそろっていない帯に来た場合は、代わりに「帯の上限(band cap)」を提案します(packages/postext/src/pipeline/bandCaps.ts)。ある内容のブロックで始まる帯の段をceil(Σ used / N / grid)行に縮めるものです。buildDocumentはこの上限を付けて配置のパスを再実行します(上限を付けた帯があふれたら1行ずつ広げ、追加のパスは数回まで、それでもだめなら次のページに回します)。こうして本文はすべての配置規則を守ったまま縮めた段を埋め、切断位置で高さがそろって終わり、閉じた段に残った余りはavailableHeightとして残って段末そろえが吸収します。同じ仕組みで、章と文書の「最後の」帯もそろえます(headings.balancing.trailing)。章扉、:::part、章を閉じるplacement: 'fixed'の囲み、または文書の終わりに、現在の帯の段の高さがそろわないまま達すると、境界のブロックをキーにしたkind: 'trailing'の上限を提案します。上限は帯を開くブロックをキーにしており、そのブロックは前のページが段末そろえの追加の行を吸収するたびに動きます。そのため最後の帯の上限は、段末そろえが落ち着いたあとに、段末そろえのヒントを固定した状態で決めます。そのあと短い仕上げの回で、切断が短く残した分を調整手段が埋めます。placement: 'fixed'の囲みは流れから外れます。ボックスはページの版面、仕上がりの枠、または裁ち落としの枠に固定され、それが覆うテキストの段はその領域を明け渡し(フロートの帯と同じように下または上から切られ、衝突すれば次のページへ移ります)、枠と子要素はpage.floatsに入ります。
縦組みのページ。layout.writingMode: 'vertical-rl'を指定すると、このパスは横組みのページを時計回りに90度回したものとしてレイアウトします。このようなページでは、版面、段、ブロック、行、フロート、脚注領域は流れの座標(flow coordinates)で表され、VDTPage.flowがそれを紙面に移す回転を持ちます。流れの座標の点(x, y)は、紙面の(ページの幅 − y, x)に来ます。後段の処理はこれを知る必要がありません。改行、フロート、分割禁止の規則、段末そろえは、どのページとも同じように流れの座標系で動きます。流れの1段は紙面では1段(縦組みの段)になり、各バックエンドは描画時に回転を適用します(flowToPageとpageToFlowが点を双方向に変換します)。柱、ノンブル、トンボ、背景は紙面の座標のままです。図と表は正立したブロックとしてレイアウトされ、枠の中で逆向きに回して戻されます。正立する文字は、描画時に1字ずつ回転を戻します。
#パス4:リソースの配置
- 入力:ブロックが段に配置されたVDT
- 処理:参照された各リソースを、最初の参照のあとにある最初の空き枠にフロートとして配置します。参照している段の地、次の空の段の天か地、または次のページの帯です(
packages/postext/src/pipeline/floatPlacement.tsがフロートを計画し、pipeline/floatSlots.tsが枠を列挙して測ります。帯はビルドのパイプラインが確保します) - 配置の解決:リソースごとに、エンジンは
resource.placement→ 種類のresourceType.defaultPlacement→ 組み込みの既定値{ position: 'auto', span: 'column' }の順に解決します
| 配置のフィールド | 動作 |
|---|---|
position: 'auto' | リソースは参照のあとの最初の空き枠に入ります。天でも地でもかまいません。これが既定です |
position: 'top' | 天の枠だけを使います。次の空の段またはページの天の帯に入り、段の内容をその下へ押し下げます |
position: 'bottom' | 地の枠だけを使います。段またはページの地の帯に入り、その上の段を短くします |
position: 'here' | フロートしません。リソースは::resourceディレクティブの位置、つまり流れの中に現れるその場所にインラインで埋め込まれます |
span: 'column' | 帯は1つの段だけを占めます(新しく開いたページでは、エンジンは残りの余地が最も大きい段を選びます) |
span: 'page' | 帯はすべての段にまたがって版面の幅いっぱいに広がり、段の流れを断ち切ります。全幅の帯が先に確保されるので、1段幅のフロートは残りの空間の中に収まります |
- 配置の繰り延べ:現在のページのどの枠にも入らないフロートは、流れが次に開くページを待ちます。縮小も分割もされません。章の境界では、境界より前に開かれたページへ送り出されます
- 出力:リソースはページの帯(
page.floats)に配置され、影響を受けた段の高さが減らされて、本文が帯を避けて流れます
リソースの配置はおもしろいところです。フロートは空間を占めるだけでなく、周りの空間の形を変えるからです。参照を含むブロックが置かれると、保留中のフロートには、そのあとにある現在のページの空き枠が順に提示されます。まずその段の地、次に次の空の段の天と地です(ページ幅のフロートなら、どの段にもまだ余地があるときのページの地)。どこにも入らないものは次のページを待ち、そこでは保留中のフロートが、本文が流れ込む前に自分の帯を確保します。段は帯の間に収まるように縮み、本文は狭くなった段を途切れずに流れます。読者は、図が言及された位置の近く(ただしちょうどその位置ではない)に図を見ることになります。これはプロの組版では標準的な手法で、本ではいつも行われていることです。
配置の規則。どの位置に送るかの振り分けに加えて、フロートは厳格な編集上の制約に従います。
- 参照後の規則。フロートは、本文中の最初の参照のあとにある最初の空き枠に入り、決してその前には入りません。読者はまず参照に出会い、それからリソースを見ます。フロートが入らなければ、後ろの枠やページに繰り延べられ、前に戻ることはありません。
- 番号の系列内での参照順。保留中のフロートは、最初の参照の順に各枠を提示されます。どこにも入らないフロートは、同じ番号の系列で後ろに並ぶフロートを足止めします。表3が表4よりあとに来ることはなく、図12が図11より前に来ることもありません。系列どうしは互いを足止めしません。待っている表があっても、あとの図は先に進めます。長い表が新しいページを待たずに済むよう、空の段の頭を提示された長い表はその段の長さで切られ、次の枠(隣の段、または次のページの帯)に続きます。見出し行は繰り返されます。
- 章の障壁。フロートが章の外に出ることはありません。章扉(
breakBeforeまたはspan: 'page'を持つ見出しレベル)、:::part、floatBarrier: trueを持つ囲みのスタイル、そして文書の終わりでは、境界自身の改ページの前に、保留中のフロートをすべて先に配置します。まずページの空き枠に、次に境界より前に開いたページに置きます(各ページは少なくとも1つのフロートを強制的に配置します)。そのようなページを開く前に、まだ保留中の図や表には、positionにかかわらず、現在のページの空き枠がもう一度提示されます。章の最後のページで参照された、天に置く指定のフロートは、専用のページを使うのではなく、そろえた段の下、そのページの地に入ります(フロートとして配置した囲みは自分の配置を保ちます)。:::pagebreakは、保留中のフロートを、そのあとに続くページへ送ります。奇偶をそろえるための白ページがあれば、そのあとです。 - 本文の最小の余地。新しく開いたページでは、影響を受ける段に本文がまだ少なくとも3行入る場合にだけ帯を確保します。例外が1つあります。大きすぎるフロートは、まだ本文だけの帯に強制的に配置できます。これにより、ページを占めるほどの図がキューをいつまでも止めることはありません。現在のページの枠は、段の残りの高さに収まらなければなりません。そこで3行の規則が適用されるのは、別のフロートの帯の隣にある場合だけです。
- 余白の確保。帯とその隣の本文の間には、本文の行の高さ1つ分の間隔を空けます。
- ベースライングリッドへのそろえ。天の帯はベースライングリッドの倍数に切り上げられ(フロートの下の間隔が広がります)、押し下げられた行もすべてグリッドに乗ります。地のフロートは、キャプションの最後のベースラインがグリッドに乗るように固定されます。キャプションは隣の段の本文の最後の行とベースラインを共有し、ページはどの段でも、向かい合うページでも、同じ高さで終わります。
種類別・初出順の番号付け。リソースの番号はMarkdownには書きません。pipeline/resourceNumbering.tsが、読む順序で最初に参照された時点で、リソースのResourceTypeを使って各リソースに番号を割り当てます。numberingTemplateは、種類ごとのカウンター{n}と、参照の時点で有効な見出しのカウンター{h1}〜{h6}を組み合わせます(たとえば'{h1}.{n}' → 「1.7」)。resetOnはカウンターをいつリセットするか('never'、または任意の見出しレベル)を決め、counterFormatは10進数、ローマ数字、アルファベットのいずれの数字を使うかを選びます。組み込みの「図」と「表」の種類はdefaultResourceTypes(locale)から得られ、文書の言語に合わせてローカライズされます。番号付けは最初の参照の順に従うので、文書の途中に新しい図を挿入すると、それ以降の番号はすべて自動的に振り直されます。原稿を編集する必要はありません。
#パス5:組版の仕上げ
このパスが、組版エンジンと、ただテキストを流し込むだけのものとを分けます。プロの組版者が何世紀にもわたって手作業で適用してきた編集上の品質の規則を強制します。素朴なテキストの描画では、まったく無視される規則です。
パス5は2つのレベルで働きます。各段落の中でのペナルティーによる行分割と、ブロックの間での構造上の分割禁止の強制です。2つは協調して働きますが、別々の仕組みです。
ペナルティーによるウィドウ・オーファン・ラントの回避
ウィドウとオーファンは、素人の組版であることを最もはっきり示す兆候です。
- ウィドウとは、段の下端にひとりで残された段落の1行です。段落は次の段に続きますが、その1行は取り残されたように見えます(段が途中で終わってしまったかのようです)。
- オーファンとは、段の上端に取り残された段落の1行です。段落の大部分は前の段にあり、1行だけがはみ出しています(前後の文脈から切り離されたように見えます)。
- ラントとは、最後の行が短い1語(または2語)だけになった段落です。視覚的には、まともな1行と感じるにはあまりにも短すぎます。構造上はウィドウほど深刻ではありませんが、注意深い読者にとっては同じくらい目障りです。
3つとも、Knuth-Plassの行分割アルゴリズムにデメリットを注入して扱います。段落をレイアウトしてから悪い改行をあとで直そうとするのではなく、ある改行位置の組がほかの組よりコストが高いことを、エンジンは行分割に教え込みます。そうすればアルゴリズムは、可能な限りウィドウ、オーファン、ラントを自然に避ける、全体として最適な改行位置の組を選びます。
具体的には、段落内の改行位置の候補ノードごとに、次のようにします。
- この改行を選ぶと次の段の頭に残る行数が
orphanMinLinesより少なくなる場合、そのノードのデメリットにorphanPenalty(既定は1000)を加えます。 - この改行を選ぶと現在の段の下端に残る行数が
widowMinLinesより少なくなる場合、widowPenalty(既定は1000)を加えます。 - この改行から生じる最終行の内容の幅が
runtMinCharacters × normalSpaceWidth(既定のruntMinCharactersは20で、およそスペース幅20文字分の内容)を下回る場合、runtPenalty(既定は1000)を同等のバッドネスとして、デメリットの2乗の式に注入します。これにより、ラントのペナルティーは行のバッドネス(10000で頭打ち)と同じ尺度で競い合い、バッドネスに埋もれてしまうことはありません。
これらのペナルティーは、通常のデメリット(バッドネス(調整比の2乗)、ハイフネーションのコスト、適合クラスの不一致)と並んで、1つの全体最適化の中に置かれます。描画上の細部が1つ、全体像を補います。両端そろえの段落では最終行は行末不ぞろいで描画されますが、行長からはみ出す最終行は例外で、TeXのグルーの設定の意味論に従って語間を詰めて行長に収めます。これはcanvas、HTML、PDFのバックエンドで同じように適用されます。代わりの選択肢のほうが悪い場合(すべての規則を満たす正当な改行がない段落の場合)は、アルゴリズムはウィドウなどのいずれかを受け入れてもかまいません。しかし、ほとんどの場合はそれらを避ける改行位置の組を見つけます。リストの項目は、avoidOrphansInLists、avoidWidowsInLists、avoidRuntsInLists(いずれも既定はtrue)で同じ保護を受けます。
4つ目のゆるやかな圧力であるslackWeightは、「段の使われていない空間」の2乗のコストに重みを付けます。これによりアルゴリズムは、段をすき間なく埋める改行位置の組を好むようになります。これらのデメリットを合わせると、パス5は行分割の段階での仕上げになります。ウィドウ・オーファン・ラントのほとんどは、あとから字間を調整するのではなく、Knuth-Plassのソルバーの中で解決されます。
これらはすべてBodyTextConfigで調整できます。設定 → オーファン、ウィドウ、ラント、分割禁止の規則を参照してください。いずれかの*Penaltyを0にすると、その規則は事実上無効になります。
構造上の分割禁止の規則
1つの段落より大きなまとまりもあります。隣り合うブロックにまたがるもので、行分割だけでは扱えません。パス5はこれをブロックの配置のレベルで強制し、改段や改ページで分かれてしまうまとまりを、まるごと前へ送ります。
- 見出しとその最初の段落。見出しが導く段落が次の段で始まる場合、見出しを段の下端に置いてはなりません。
headings.keepWithNext(既定はtrue)で強制します。見出しに加えて、次のブロックの本文のウィドウの最小行数(bodyText.widowMinLines、既定は2)を入れる余地がない場合(avoidWidowsがオフのときは1行だけ)、見出しは本文と一緒に移るよう前へ送られます。 - 連続する見出し。複数の見出しが続く場合(たとえばh2のあとにh3、そのあとに段落)、まとまり全体が一緒にいなければなりません。どの見出しも、それが導く内容なしに段の下端に取り残されてはなりません。
- コロンで導かれるリスト。段落がリストを直接導くコロンで終わる場合、コロンのある行はリストの始まりと一緒にいなければなりません。
bodyText.keepColonWithList(既定はtrue)で強制します。段落を置くと最初のリスト項目を始める余地がなくなる場合(リストのオーファンとウィドウの規則で項目がひとまとまりに保たれるときは項目全体、bodyText.colonListRoom: 'line'のときはpostext 1.4までと同じく1行)、コロンのある最後の行(段落が1行ならその段落全体)がリストと一緒に前へ移ります。この規則が段落全体を送らなければならず、しかもその段で段落の直前に見出しが続いている場合は、その見出しも一緒に前へ引き寄せ、keepWithNextが黙って破られないようにします。唯一の例外は、前の反復ですでに前へ送られた見出しだけがその段にある場合です。このときエンジンは段落を見出しと一緒に保ち、ループを避けるために、コロンとリストが離れるという軽いほうの違反を受け入れます。 - 図とそのキャプション。図とキャプションは切り離せない単位です。必ず一緒に移ります。
分割禁止の違反を検出すると、エンジンはまとまり全体を次の段またはページへ送ります。空いた空間は、通常の段を埋める仕組みが扱います(行分割はすでに収まる改行位置の組を選んでいます。その結果の段が少し短ければ、パス7がグリッドを崩す要素の周りで縦方向の空間を配分し直し、ベースライングリッドを正しく保ちます)。
出力
計測結果や配置が変わったブロックは、収束ループの次の反復のためにdirtyとしてマークされます。重い処理はあとからの調整ではなくKnuth-Plassの中で済ませているので、実際にはほとんどの文書がすぐに安定します。行分割は最初から適切な改行位置の組を選び、以降の反復ではブロックの移動と段末そろえの下流への影響を扱うだけで済みます。
うまく行えば、これらの修正は目に見えません(読者が気づくことはないはずです)。しかし、修正がないことは、注意深く読む人には一目でわかります。段の頭にぽつんと置かれた1行や、エンジンがテキストを収めるのをあきらめた不ぞろいなすき間です。プロの出版社は、まさにこうした問題を防ぐためのスタイルガイドをまるごと持っています。Postextはそれを自動化します。
#パス6:段末そろえ
- 入力:組版を仕上げたVDT
- 処理:段の間でブロックを移動して高さの差を最小にし、各ページの段の高さをそろえます(これを切り替えるはずの
ColumnConfig.balancingフラグは、宣言されているだけで接続されていないレガシーのオプションの1つです) - 制約:パス5で確立したウィドウ・オーファンの規則に違反してはなりません
- 出力:ブロックが段の間を移動していることがあり、
dirtyとしてマークされます
段の高さがそろっていないと、特に章の最後のページですぐに目につきます。左の段がいっぱいで右の段がほとんど空だと、未完成に見えます。レイアウトが途中で投げ出されたかのようです。段末そろえは内容を配分し直して2つの段をほぼ同じ高さで終わらせ、見開きに意図して仕上げた印象を与えます。
アルゴリズムは、ページ上のすべてのブロックの内容の高さの合計を求め、段の数で割って目標の高さを出し、各段を目標に最も近づける改段の位置を探します。ただし、単純に分割するわけではありません。これは制約充足問題です。アルゴリズムはkeepTogetherの規則(見出しは最初の段落と一緒にいなければならない)を守り、最小の行数を守り、そして何より、パス5が苦労して確立したウィドウとオーファンの修正を元に戻してはなりません。
#パス7:垂直リズムのそろえ
- 入力:段の高さをそろえたVDT
- 処理:見出し、画像、そのほかのグリッドを崩す要素の周りに間隔の調整を配分して、ベースラインをベースライングリッドに合わせます
- 出力:調整された間隔の値。ベースラインが段をまたいでそろいます
- 参照:アルゴリズムの全体は垂直リズムの仕組みを参照してください
#収束ループ
収束ループは、エンジンが自分自身と議論しているようなものだと考えてください。パス5が、段落Aの中でウィドウを避ける改行位置の組を選びます。しかしそのために段落Aが1行短くなり、2段目の下端にすき間が残ります。パス6がそれを補うために段の高さをそろえ直すと、見出しが新しい段に押し出され、それがkeepWithNextを発動させて、見出しをまるごと次の段へ戻させます。パス7が垂直リズムを調整すると、見出しがあった場所に新しいラントが生じるかもしれません。そこでエンジンはパス3に戻り、更新された計測結果でブロックを配置し直し、全体の手順をもう一度たどります。反復のたびに、生じる問題より多くの問題が解決され、やがてdirtyなものは何もなくなります。
ウィドウ・オーファン・ラントのほとんどは、1回の行分割の中でKnuth-Plassのソルバーの内部で解決されるので、通常の文書は現在1〜2回の反復で収束します。それでもループが必要になるのは、ブロックのレベルの出来事(keepWithNextによる見出しの送り出し、配置によって繰り延べられた図、段末そろえによる高さの均等化)が、パス5が計測の基準にした段の境界を動かしたときです。そうなると、パス3が配置し直し、パス5が新しい制約で改行し直し、ループは落ち着きます。
パス5〜7が完了すると、エンジンはdirtyとマークされたブロックがあるかどうかを確かめます。dirtyなブロックがあり、反復回数が5回未満なら、パイプラインはパス3から再実行します。
収束の条件:
- パス5〜7のあとにdirtyなブロックがない、または
- 反復が最大の5回に達した(その時点で最良の結果を受け入れる)
エンジンは反復のたびに組版上の違反スコアを記録します。残っている問題(ウィドウ、オーファン、そろっていない段、ベースライングリッドのずれ)の重み付きの合計です。違反の種類ごとに、視覚的な深刻さを反映した重みがあります(ウィドウは2pxのグリッドのずれよりはるかに目立ちます)。完全に収束しないまま5回の上限に達した場合、エンジンは違反スコアが最も低かった反復を選びます。最後の反復とは限りません。後の反復は、ある問題を直しながら別の問題を作り、修正しすぎることがあるからです。
段末そろえはセグメントごとに収束します。明示的な区切り(章扉、:::pagebreak)の間にあるページは、互いに独立してレイアウトされます。そうした区切りを越えて流れるものは何もないので、ある一続きのページの中の段末そろえの調整手段が、別の一続きのページの行を動かすことはありません。そのため段末そろえのループは、こうしたセグメントをそれぞれ単独で判定します。1回のパスでは文書全体を配置しますが、各セグメントは、自分の分の調整手段を自分のすき間のスコアで採用または却下し、自分の連鎖をブラックリストに入れ、自分で頭打ちを判断し、自分の試行回数の予算を使います。再試行で悪くなったセグメントは、自分にとって最良だったパスのページに戻され、ほかのセグメントはそのまま先へ進みます。こうして30章の本は、各章を1つずつ処理したときとまったく同じように段末そろえされます。本全体のPDFと、同じ章だけのPDFは同一になります。どこか1か所の連鎖のために、本のすべてのページがパスを1回余分に費やすことはありません。
5回の反復の上限は、実用上の安全弁です。完璧を求めると終わりません。病的なケース(どのように段をそろえても、すべての段落がちょうどウィドウを生む長さになっているページなど)は、決して完全には収束しません。エンジンは「最善の結果」を受け入れて先へ進みます。
#垂直リズムの仕組み
よく組まれた本を光にかざしてみてください。左ページの行は右ページの行とそろっています。1段目の5行目のベースラインは、2段目の5行目のベースラインとまったく同じ高さにあります。これが垂直リズムで、訓練を積んだ目が組版の品質を評価するときに最初に確かめることの1つです。そしてPostextを特徴づける点の1つでもあります。
両方の段に同じ大きさの本文しかなければ、そろえるのは簡単です。どの行も同じ高さなので、ベースラインは自然にそろいます。難しくなるのは、一方の段に大きな文字サイズの見出し、任意のピクセル数の高さを持つ画像、引用ブロックの周りの追加の間隔が入った瞬間です。こうした要素はグリッドを「崩し」ます。その下の内容がベースラインの増分の倍数ではない量だけずれ、急にその段のベースラインが隣の段とそろわなくなります。視覚的な調和が失われます。
目標はそれを取り戻すことです。見出しや画像など、標準と異なる高さの要素が一方の段にだけ現れても、隣り合う段の本文のベースラインは水平にそろわなければなりません。
#ベースライングリッド
すべては1つの数値を基準にします。文書は、本文の行の高さから導かれるbaselineGridの値を定めます。たとえば、本文を16px、line-heightを1.5で組むと、ベースライングリッドは24pxになります。本文のベースラインはすべて、この値の倍数の位置に来るべきです。これが約束事です。
#グリッドを崩す要素
高さがbaselineGridの倍数でないために、どうしてもグリッドを崩してしまう要素があります。
- 見出し(大きな文字サイズ、異なる行の高さ)
- 画像(任意のピクセル数の高さ)
- 表(高さが変わる)
- 引用ブロック(異なる文字サイズやパディングを使うことがある)
- 脚注領域(段の下端の注とその罫。段のテキスト領域はその上で終わる)
#間隔調整のアルゴリズム
各段で個別に間隔を調整したあと、エンジンは段をまたぐそろえを検証します。段をまたいで同じ縦位置にあるベースラインは一致しなければなりません。段ごとにグリッドを崩す要素が異なるために食い違った場合は、2回目のそろえのパスが両方の段の間隔を調整し、共通のリズムを見つけます。
具体例を挙げます。1段目に高さ36pxの見出し(24pxのグリッドの1.5倍)があります。2段目には見出しがありません。見出しのあと、1段目はグリッドから12pxずれています。アルゴリズムは見出しのあとに12pxの間隔を追加し、「見出しのあとの間隔」を16pxから28pxに増やします。これで1段目の次の本文の行は再びグリッド線の上に来て、そのベースラインは2段目の対応する行と一致します。調和が戻りました。
特殊なケース:
- 調整できる間隔よりグリッドを崩す要素のほうが多い段は、部分的なそろえで妥協します(乱れが多すぎて誤差を吸収できる場所が少なすぎると、アルゴリズムは最善を尽くしますが、グリッドへの完全なそろえは保証できません)
- 段より高い画像は、段やページにまたがります(パス4で別に扱います)
- 必要な調整量が目に見えて不自然な間隔を生む場合(たとえば、見出しのあとの間隔が通常16pxなのに40pxになる場合)、アルゴリズムは誤差を1か所に集中させず、複数の間隔に分散します
#バックエンドインターフェース
テキスト計測の信頼できる情報源(source of truth)はただ1つ、Pretextのcanvasフォントメトリクスに基づくmeasureモジュールだけです。どのバックエンドも、そこから得られた収束済みの同じVDTをもとに描画します。
これは意図的な選択で、決定的な理由があります。テキストを計測する方法は、描画する方法と完全に一致していなければなりません。仮に計測にはcanvasのフォントメトリクスを使い、描画バックエンドではカーニングテーブルがわずかに異なるPDFライブラリーを使ったとします。するとレイアウトと出力が一致しません。エンジンが320pxに収まると計測した行が、描画時にはあふれたり足りなくなったりします。1ピクセルのずれでも、それは嘘になります。計測は一度だけ行い、その結果の幾何情報からすべての出力を描画するので、バックエンド同士の食い違いは起こりえません。改行位置、段の高さ、リソースの配置は、どのバックエンドが動くよりも前にVDTの中で確定しています。
たとえばPDFバックエンドがテキストを計測し直さないのはこのためです。PDFバックエンドは収束済みのVDTを受け取り、そのピクセル座標をPDFのポイントに変換します。正となるのはcanvasのメトリクスであり、PDFは運搬手段にすぎません。(postext-pdfパッケージの)renderToPdfを使う場合も、renderToCanvasやrenderToHtmlに渡すのと同じVDTを渡します。3つの出力が一致することは保証されています。
#APIの概要
バックエンドはクラス階層ではなく、VDTDocumentを受け取る普通の関数です。PostextBackendインターフェースは存在しません。あるのは3つの描画エントリーポイントと、出力先ごとに必要なヘルパーだけです。
// Canvas (from 'postext')
renderToCanvas(doc): HTMLCanvasElement[]; // one canvas per page
renderPage(page, doc): HTMLCanvasElement; // a single page
renderPageToCanvas(page, doc, canvas, options?): void; // draw into an existing canvas
// Canvas resource-image registry — decoded images keyed by Resource fileId
registerResourceImage(fileId, image): void;
unregisterResourceImage(fileId): void;
clearResourceImages(): void;
// HTML (from 'postext')
renderToHtml(doc, options?): string;
renderToHtmlIndexed(doc, options?): HtmlRenderIndex; // per-page / per-block breakdown
// PDF (from 'postext-pdf')
renderToPdf(doc, options): Promise<Uint8Array>;
// EPUB (from 'postext-epub'), a book's chapters in order
renderToEpub(docs, options): Promise<Uint8Array>;バイナリーのリソースデータは本体とは別に扱われるため、各バックエンドはそれぞれのやり方でfileIdを解決します。canvasバックエンドはデコード済みのCanvasImageSourceのレジストリーを持ちます。ホストアプリがビットマップやSVGをregisterResourceImage(fileId, image)で一度ずつ登録し、レンダラーは描画時にそれを参照します。HTMLバックエンドはオプションでresourceImageUrl(fileId)リゾルバーを受け取り、ホストが返すURL(オブジェクトURL、データURI、CDNのパスなど)を指す<img>タグを出力します。PDFバックエンドはresourceBytesプロバイダーを受け取り、実際のバイト列を埋め込みます。EPUBの書き出し処理もこれを受け取り、各画像を本の中のファイルとして一度だけ格納します。動画リソースはポスター画像として、ほかのビットマップと同じように描画されます。HTMLバックエンドは再生する動画ファイル用にresourceVideoUrl(fileId)リゾルバーも受け取り、EPUBの書き出し処理はそれらのファイルをmedia/の下に格納します。PDFバックエンドは、再生マークとQRコードをポスター画像の上にベクターのパスとして描き、video.linkPosterがオンのときは、video.linkを開くURIリンク注釈をポスター画像の上に置きます。タグ付きPDFでは、このリンクはポスター画像のFigureに加わり、その代替テキストは種類の名前で始まります(Video: …)。表リソースにはこうした仕組みはいりません。表のモデルはインラインで持たれ、どのバックエンドもVDTの表の幾何情報からセルを描画します。
renderToHtmlIndexedについては補足があります。この関数はHTML文字列全体に加えて、ページごと・ブロックごとの内訳(HtmlRenderIndex)を返します。呼び出し側はこれを前回の描画結果と比較し、HTMLが実際に変わったDOMサブツリーだけを差し替えられます。これがSandboxのライブプレビューで使われている経路です。
#バックエンド
| バックエンド | 計測 | 描画 | 状況 |
|---|---|---|---|
| Canvas | Pretext(canvasのフォントメトリクス) | HTMLCanvasElementへのビットマップ描画(renderToCanvas、renderPage、renderPageToCanvas) | 提供中 |
| HTML | Pretext(canvasと同じメトリクス) | 組版用のCSSを当てた絶対配置のDOMノード(renderToHtml、renderToHtmlIndexed) | 提供中 |
| Pretextで計測済みのVDTを受け取る | pdf-libによるPDFページの構築、ウェイトごとのフォント埋め込み(postext-pdfのrenderToPdf) | 提供中 | |
| EPUB | Pretextで計測済みのVDTを受け取る | EPUB 3ファイル。印刷ページごと(固定レイアウト)または章ごと(リフロー型)に1つのXHTML文書を作り、フォントを埋め込む(postext-epubのrenderToEpub) | 提供中 |
| サーバーサイド | Pretext + node-canvas | SSRやバッチ生成のためのヘッドレス描画 | 今後 |
提供中の4つのバックエンドは、すべて同じVDTDocumentを受け取ります。canvasとHTMLのバックエンドを公開するpostextと、PDFバックエンドを公開するpostext-pdfに分かれているのは、純粋に依存関係の都合です。PDFの経路はpdf-libと@pdf-lib/fontkitを取り込みますが、Webへの組み込みの多くではこれらは必要ありません。postext-pdfは、実際にPDFのバイト列を出力したいときだけインストールしてください。postext-epubも同じ理由で独立したパッケージになっており、必要になるのは電子書籍を作るときだけです。
ブラウザー専用という制約:フェーズ1では、レイアウトの計算はすべてクライアント側のブラウザーで行います。パイプラインはメインスレッド上(buildDocument)でも、専用のWeb Worker内(postext/workerのcreateLayoutWorker)でも実行できます。UIを持つアプリにはワーカーでの実行を推奨します。計測と収束ループをメインスレッドから外せること、AbortSignalによる後勝ちのキャンセルに対応していること、独自の計測キャッシュと数式ラスターキャッシュを持つため再ビルドが続いても軽いままであることが理由です。組み込み方の全体は設定 → Web Workerでレイアウトを実行するを参照してください。サーバーサイドレンダリングは、意図的に後回しにしている範囲です。まずブラウザーでの体験を固め、ほかの環境への展開はそのあとにします。
#パフォーマンス戦略
もたつくツールと、待たされる感覚のないツールの差は、およそ10倍です。レイアウトに500msかかれば、ウィンドウをリサイズするたびに目に見えるカクつきが出ます。50msなら一瞬で、文書が最初からそこにあったかのように感じられます。この差は後から修正で埋められるものではなく、最初の日から設計に組み込んでおく必要があります。
エンジンが相手にするものを考えてみてください。数百ページにわたる数千のテキストブロックがあり、ビューポートをリサイズするたびにレイアウト全体を計算し直す可能性があります。これはゲームエンジンが直面するのと同じ種類の問題です。ゲームエンジンは数千のオブジェクト(ジオメトリー、物理、ライティング、AI)を毎秒60回処理します。その解決策は、パイプライン型のアーキテクチャー(共有された可変の状態に対して複数のパスを走らせ、各パスが1つのことを高速にこなす)と、不要な処理の徹底的な回避(カリング、ダーティーフラグ、空間分割)です。Postextはこうした考え方をすべて取り入れています。
#原則
-
メモリー内での計算。VDT全体がメモリーに収まります。レイアウト中にDOMを読むことはありません。DOMに触れるのは最後の描画のときだけです。
-
ダーティー追跡。ブロックは
dirtyフラグを持ちます。各パスはクリーンなサブツリーを飛ばします。収束ループは、最も早い位置にあるダーティーな地点からだけ再実行されます。 -
反復回数の上限がある収束。反復は最大5回で、これは厳密な保証です。最悪の場合の性能も予測でき、計測できます。
-
Pretextの速さ。テキストの計測がDOMの300〜600倍の速さなので、エンジンは段幅、ハイフネーション位置、トラッキングの調整などを試しながら、投機的にテキストを計測し直す余裕があり、それでもメインスレッドをブロックしません。
-
多層の計測キャッシュ。measureモジュール(
packages/postext/src/measure/)は、テキスト、フォント、幅、そしてレイアウトに影響するすべてのオプションをキーとする計測キャッシュを明示的に持ちます。これはPretext自身のprepare()キャッシュと、テキスト幅の生のキャッシュの上に重なっています。変更のない段落の再計測は、マップを1回引くだけで済みます。clearMeasurementCache()はPretextのキャッシュとテキスト幅のキャッシュを消去するので、フォントの読み込みが終わったあともグリフ幅が正しく保たれます。この関数は引数を取らず、計測キャッシュには触れません。計測キャッシュは新しいものに置き換えて対応します。 -
ホットパスでの改行処理。Knuth-Plassのアクティブノードの扱いは、速度のために書き直されました。ノードが外れるたびにアクティブ集合をその場で詰め、候補は(行、適合クラス)ごとに重複を除いて、キーごとにデメリットが最小のノードだけを残します。アルゴリズムの結果は変わりません。改行位置の集合は同じで、計算が速くなっただけです。
-
メインスレッド外でのビルド。
postext/workerエントリーポイントは、パイプライン全体を専用のWeb Worker内で実行します。メインスレッドは{ content, config }とAbortSignalを送ります。ワーカーはフォントを登録し(ArrayBufferとして転送されます)、収束ループを実行して、完成したVDTDocumentを送り返します。新しいbuild()呼び出しは、前の呼び出しを協調的にキャンセルします。ワーカーはbuildDocumentの中でブロックごとにキャンセル用のフックを確認し、BuildCancelledErrorを投げます。そのため、エディターで入力しているユーザーが、もう不要になったレイアウトを待たされることはありません。ワーカーは永続的な計測キャッシュと、内容をキーとする数式ラスターキャッシュも独自に保持します。これにより、構造化複製されたMathRenderオブジェクトが再ビルドをまたいで生き残り、ラスター化し直さずに済みます。 -
フラットな数値フィールド。バウンディングボックスは、入れ子のオブジェクトではなく、各ノード上のフラットな
x, y, width, heightフィールドとして保持されます。ポインターをたどる処理が減り、キャッシュ効率も上がります。 -
2通りにアクセスできるVDT。ツリー(
pages > columns > blocks)は、ページ単位や段単位で処理する必要があるパス(パス6の段末そろえなど)のための階層的なアクセスを提供します。並行して持つフラットなblocks[]配列は、位置に関係なくすべてのブロックを走査する必要があるパス(パス5のウィドウ・オーファン検出など)のためにO(1)のインデックスアクセスを提供します。2つのビューは同じブロックオブジェクトを参照しています(データの複製はなく、同じデータをたどる方法が2つあるだけです)。
#リサイズへの対応
ユーザーがビューポートをリサイズしても、エンジンは最初から作り直しません。VDT内の段幅を更新し、すべてのテキストブロックをダーティーとしてマークして、パス2からパイプラインを再実行します。ページと段の構造は再利用されます。
ここで可変のVDTが効いてきます。レイアウト全体を捨ててゼロからやり直す代わりに、エンジンはできるかぎり多くの処理結果を再利用します。Pretextのprepare()の結果は有効なままです。これはフォントとテキストの内容に依存し、幅には依存しないからです。再実行が必要なのは、軽いlayout()呼び出しだけです。50ページの文書でも、Markdownを解析し直したり参照を解決し直したりせずに、すべてのテキストブロックを計測し直し(prepare()がキャッシュされているので高速です)、パス3〜7を再実行するだけで、レイアウト全体を組み直せます。ユーザーがウィンドウの端をドラッグすると、レイアウトがリアルタイムで追従します。
#初日からのベンチマーク
どのパスも、vitestのbench APIを使って単独でベンチマークを取れます。
// Example benchmark
bench('layout 50-page document', () => {
const vdt = createVDT(fiftyPageContent, config);
runPipeline(vdt);
}, { time: 100 }); // sample for 100ms and report ops/sec正直に補足しておくと、{ time: 100 }はvitestがベンチマークをサンプリングする時間であり、合否の閾値ではありません。ベンチマークは数値を報告するだけで、ビルドを失敗させることはありません。性能の劣化は、ホットパスを変更したときに実行ごとの数値を比べることで見つけます(Knuth-Plassのアクティブノードを書き直したときもそうしました)。CIの自動チェックで止めているわけではありません。性能は願望ではなく機能です。ただし現時点でそれを担保しているのは、計測とレビューであって、失敗するパイプラインではありません。
#データフロー
#対象外とするもの
以下の制限はどれも意図的な選択です。エンジンはそれだけで十分に複雑であり、ほかが担うべき責任まで引き受けることは、いつまでも完成しない一番の近道になります。
- サーバーサイドレンダリング。レイアウトはすべてブラウザーで実行されます。エンジンは(Pretextを介して)canvasのフォントメトリクスに依存しており、それにはブラウザー環境が必要です。
node-canvasを使うサーバーサイドのバックエンドが将来加わる可能性はありますが、初期の設計には含まれていません。まずはブラウザーです。 - WYSIWYG編集。Postextは組版エンジンであり、エディターではありません。コンテンツを受け取り、幾何情報を返します。カーソル管理、選択、取り消し・やり直し、入力処理といった対話的な編集画面を作ることは、まったく別の問題です。Postextはエディターの描画バックエンドにはなれますが、編集機能そのものは提供しません。
- CSSのcolumn-countのラッパー。PostextはCSSの段組みレイアウトを置き換えるものであり、それを包むものではありません。配置済みの正確な幾何情報をゼロから計算します。ブラウザーの段組みアルゴリズムでは、リソースの配置、ウィドウ・オーファンの防止、段をまたぐ組版規則を制御できないからです。それこそがPostextの存在理由です。
- レスポンシブなブレークポイントの管理。Postextは指定されたページサイズでレイアウトを計算します。いつレイアウトし直すか(ビューポートのリサイズ時、画面の向きの変更時など)は利用する側が決めます。Postextはブレークポイント、メディアクエリー、レスポンシブデザインの判断を管理しません。それは利用する側の仕事です。
- リアルタイムの共同編集。Postextはステートレスなレイアウト計算(コンテンツを受け取り、幾何情報を返す)であり、競合解決、操作変換(operational transform)、複数ユーザーの状態把握を備えた共同編集用の文書システムではありません。
- フォントの読み込みや管理。Postextは、フォントがすでに読み込まれていて計測に使える状態であることを前提にしています。フォントの読み込み、フォールバックの連鎖、フォントのサブセット化は利用する側の責任です。Postextがテキストを計測する時点でフォントが読み込まれていなければ、計測にはブラウザーの代替フォントが使われ、本来のフォントが読み込まれた時点でレイアウトは誤ったものになります。フォントは先に読み込んでください。
#付録:既存の型との対応
packages/postext/src/types.tsで定義されている主な型と、ここまで説明してきたアーキテクチャーとの対応は次のとおりです。
| 型 | アーキテクチャー上の役割 |
|---|---|
PostextContent | エントリーポイント。エンジンへの入力(パス1) |
PostextConfig | すべてのパスにわたって、パイプラインの動作全体を制御する |
Resource | 種類を持つビットマップ/SVG/表のリソース。計測の段階でResolvedResourceBlockになり、種類が'resource'のインラインのVDTBlock(配置が'here'の場合)か、ページの帯に置かれるフロートのどちらかになる(パス4) |
ResourceType | 種類ごとの番号付け、キャプションの接頭辞、参照ラベル、既定のフロート配置を決める(パス1、パス4) |
ResourcePlacement | リソースごとのフロート配置の上書き。position('top' / 'bottom' / 'here')とspan('column' / 'page')で、パス4で解決される |
PostextNote | エンジンは読まない。脚注はMarkdownの中に([^id]で)書き、引用した段の下部か章の後に置かれる。傍注は実装されていない |
PostextResource | 非推奨。旧来のコンテンツモデルのリソース。レンダラーに残る最後の参照(VDTBlock.resource)がResourceモデルに移行するまでの間だけ残している |
PlacementStrategy, ColumnConfig, TypographyConfig, ResourcePlacementConfig, ReferenceConfig, PostextSectionOverride | 旧仕様。宣言されているが、パイプラインには一度も組み込まれていない。bodyText/headings(文字組み)、layout(段組み)、Resourceのフロートモデル(配置)に置き換えられた |
#VDTの型の置き場所
VDTの型(VDTDocument、VDTPage、VDTColumn、VDTBlock、VDTLine、VDTLineSegment、ResolvedResourceBlock、VDTResourceTableLayout、BoundingBoxなど)は、ファクトリーヘルパー(createVDTDocument、createVDTPage、createVDTBlockなど)やcomputePageTextExtentとともにpackages/postext/src/vdt.tsにあります。バックエンド用のインターフェースモジュールは別にありません。バックエンドインターフェースで説明した描画エントリーポイントは、postext(canvas、HTML)とpostext-pdf(PDF)から直接エクスポートされています。