章 3.17 · 部 II · 技法
プログラムからの利用:PDFファイル
コードから使うpostext-pdf:インストール、API、ブラウザーとNodeのフォントプロバイダー、CJKフォント、リソースのバイト列、縦組み、ワーカー、印刷入稿用のファイル
かんたんな説明
このページは、プログラムを書いてPDFファイルを作りたい人向けです。PDFのパッケージをインストールし、組み上がった本を数行でファイルにする方法を示します。このパッケージにはフォントのファイルが必要なので、ブラウザーでもサーバーでも、それを渡す方法を説明します。中国語、日本語、韓国語のフォント、画像、縦組みの文章、リンクも扱います。最後に、全体を通した例と、印刷所に渡せるファイルを示します。
#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-pdfpostextは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つのフェイスを構成する複数のファイルです(中国語・日本語・韓国語のフォントを参照)。declaredFontFile(bytes, { unicodeRange?, order? }): PdfFontFile— ページがどう宣言したかを伝える、フェイスのファイルです。@font-face規則のunicode-rangeと、宣言順での位置を持ちます。PdfFontFileは、この2つの省略可能なプロパティを持つUint8Arrayです(中国語・日本語・韓国語のフォントを参照)。postext-pdf 1.30以降。RenderToPdfOptions—{ fontProvider, resourceBytes?, outlines?, accessible?, colorSpace?, pageNegative?, characterGrid?, onProgress?, onWarning?, rasterizeSvg?, harfbuzzWasm?, print?, outputProfile?, profileBaseUrl?, date? }。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/フォルダー)から取得します。date(Date、ミリ秒の時刻、または日付の文字列。createBundleのmtimeと同じ型)は、ファイルの作成日と更新日になります。PDF/XではXMPパケットの日付にもなり、文書IDもここから求めます。同じ文書を同じdateで2回書き出すと、バイト列は一致します。省略時は呼び出した時刻が入ります。無効な日付はエラーになります。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のフォントマッチングと同じ順に試します。これはプレビューでブラウザーが表示するフェイスでもあります。
- まず同じスタイル。400〜500のウェイトでは、500までのウェイトを先に試し、次に軽いウェイトを近いものから順に、その後600以上の重いウェイトを試します。400未満のウェイトでは、軽いウェイトを近いものから先に試し、次に重いウェイトを試します。500を超えるウェイトでは、重いウェイトを先に試し、次に軽いウェイトを試します。
- 次にもう一方のスタイル(立体ならイタリック、イタリックなら立体)を、要求されたウェイトとほかのウェイトについて、同じ順で試します。
したがって、イタリックのないファミリーはイタリックのランを立体で組み、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}@5/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が使われます。
ブラウザーは、グリフを持つ最初のファイルを使うわけではありません。unicode-rangeにその文字が入っているファイルのうち、最後に宣言されたものでその文字を組みます。そしてファミリーのファイルには重なりがあります。FontsourceはNoto Serif JPのASCII文字を、latinファイルにも番号付きのスライスにも入れています。別々のファイルで組んだ2つの文字の間にはカーニングがかからないので、どのファイルを使うかで幅が決まります。Chrome 154は、スライスをlatinの後に宣言した場合、Shift_JISを幅4.25emで組みます。latinファイルだけではt_と_Jにカーニングがかかり、幅は4.32emです。postext-pdf 1.30以降、プロバイダーは、ページが各ファイルをどう宣言したかを伝えられます。declaredFontFile(bytes, { unicodeRange, order })は同じバイト列を返し、そこに、そのファイルの規則のunicode-range(CSSの書き方のまま)と、フェイスの規則の中でのその規則の位置を持たせます。位置は宣言順に大きくなる数であれば何でもよく、最も大きいものが最後に宣言されたファイルです。
import { declaredFontFile } from 'postext-pdf';
declaredFontFile(bytes, { unicodeRange: 'U+0000-00FF,U+0131,U+0152-0153', order: 0 });フェイスのファイルが範囲を持っている場合、文書自身の言語で組む文書(bodyText.shapingLanguage: 'document'、既定値)では、各文字は、範囲にその文字が入っていてグリフも持つファイルのうち、最後に宣言されたものから描かれます。ブラウザーが@font-face規則の中から選ぶのと同じです。文字とその結合記号は1つのファイルにまとめます。どの範囲にも入らない文字は、グリフを持つ最初のファイルから描かれます。この情報を持たないファイルと、'host'の文書のすべてのファイルは、従来どおり渡された順に読まれます。そのため、ファイルの情報を伝えるプロバイダーも、最後に宣言されたファイルを先頭に並べます。こうして情報を伝えたフェイスは、まだ要求されていなかったすべての文字について、もう一度要求されます。後に宣言されたファイルがその文字を持っているかもしれないからです。Sandboxのプロバイダーは、ファイルの情報を伝えます。
フェイスのどのファイルにもグリフのない文字は、フォントの.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を通じて表示するのと同じ日本語の字形が印刷されます。日本語の本の中にある別の言語のアイソレートは、その言語の字形('host'では、中国語はフォントの既定の字形)でシェーピングされ、その/Langを持つSpanとしてタグ付けされます。bodyText.shapingLanguage: 'document'(postext 1.30以降の既定値)では、どのドキュメントもどのアイソレートも、レイアウトが計測したとおり、それぞれの言語でシェーピングされます(簡体字中国語はZHS)。'host'では、中国語などのドキュメントは、従来どおりフォントの既定の字形でシェーピングされます。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の出力と同じ改行位置を持ち、実際のフォントが埋め込まれ、アウトラインのしおりも付いています。
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ができていれば、これは高速です)。