タイルポスタージェネレーターは一見シンプルに見えます: 画像を読み込み、用紙サイズを選択してPDFをダウンロードするだけです。ブラウザ上でパイプライン全体を実行し、物理的な寸法を保持し、重なり合うページをサポートし、ハーフトーン効果をレンダリングし、モバイルのメモリを枯渇させないようにする必要がある場合、実装はより興味深いものになります。
私は最近、Rasterbator.appの構築を通じてこれらの制約に取り組みました。この記事では、物理的な印刷ワークフローではなく、このツールの背後にあるエンジニアリングモデルに焦点を当てています。
パイプラインには5つの主要な段階があります:
- ソース画像をローカルでデコードします。
- 用紙設定を一貫した座標系に変換します。
- アスペクト比を保持しながら決定論的なページグリッドを計算します。
- プレビューまたはハーフトーン表現を生成します。
- 各ページをダウンロード可能なPDFにレンダリングします。
PDFポイントをレイアウト座標系として使用する
UIではミリメートルやインチなどの使い慣れた用紙単位を公開していますが、PDFレンダラーはポイントで動作します。すべてを早期に変換することで、レイアウトコードが単位を混在させることを防ぎます。
const MM_TO_PTS = 72 / 25.4;
const PAPER_SIZES = {
A4: [210 * MM_TO_PTS, 297 * MM_TO_PTS],
A3: [297 * MM_TO_PTS, 420 * MM_TO_PTS],
LETTER: [8.5 * 72, 11 * 72],
LEGAL: [8.5 * 72, 14 * 72],
};
Enter fullscreen mode Exit fullscreen mode
用紙の矩形は使用可能なポスターの矩形とは異なります。ページには外側の余白と次のタイルと共有する重なり領域がある場合があります。
const printableWidth = paperWidth - 2 * margin;
const printableHeight = paperHeight - 2 * margin;
const logicalWidth = printableWidth - overlap;
const logicalHeight = printableHeight - overlap;
Enter fullscreen mode Exit fullscreen mode
この区別は重要です。印刷可能幅は1枚のシートに表示されるものです。論理幅は次のシートが始まる前にポスターが前進する距離です。重なりが有効な場合、隣接するシートは意図的に同じ画像領域の一部をカバーします。
ページ数を丸める前に画像比率を保持する
ユーザーは幅、高さ、または手動ページグリッドでポスターを定義できます。幅駆動レイアウトの場合、物理的なポスター幅は要求された論理ページ数から導き出されます。高さは画像比率から導き出されます。
const imageAspect = imageHeight / imageWidth;
const physicalWidth = pagesWide * logicalWidth + overlap;
const physicalHeight = physicalWidth * imageAspect;
const pagesHighFloat = (physicalHeight - overlap) / logicalHeight;
const pagesHigh = Math.ceil(pagesHighFloat);
Enter fullscreen mode Exit fullscreen mode
最後のMath.ceilは避けられません。端の狭い部分的なストリップでも完全な物理シートが必要です。
丸め処理は別の問題を引き起こします: 丸められたページグリッドは通常、正確なポスター寸法よりわずかに大きくなります。画像をそのグリッド内で水平方向に中央揃えし、垂直方向には上揃えにします。オフセットは以降のすべての座標計算の一部になります。
const trueGridWidth = pagesWideOut * logicalWidth + overlap;
const paperOffsetX = (trueGridWidth - physicalWidth) / 2;
const paperOffsetY = 0;
Enter fullscreen mode Exit fullscreen mode
これにより、プレビューとPDFレンダラーにポスターの開始位置の同じ定義が与えられます。
ローカルファイルをローカルに保つ
ローカルアップロードの場合、ブラウザはすでにFileを提供しており、これはBlobでもあります。ソース画像をアプリケーションサーバーに送信せずにデコードできます。
function loadImageFromBlob(blob) {
const objectUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error("Failed to decode image"));
};
image.src = objectUrl;
});
}
Enter fullscreen mode Exit fullscreen mode
リモート画像URLは別のケースです。ネットワークフェッチが必要で、オリジンがクロスオリジンアクセスを許可しない場合はプロキシが必要になる場合があります。UIとプライバシー言語では、ローカルファイルとリモートURLを同等として扱うのではなく、この区別を明確にする必要があります。
サンプリングされたピクセルからプレビューを生成する
プレビュー更新ごとにフル解像度のソース画像をレンダリングするのはコストがかかります。ハーフトーンプレビューでは、各グリッドセルに1つの代表色が必要です。
実装では、Canvasにスケーリングされた画像を描画してピクセルデータを読み取ります。管理可能なレイアウトでは、出力セルごとに小さな3×3エリアをサンプリングして値を平均化します。より大きなレイアウトでは、セルごとに1ピクセルにフォールバックします。
const canvas = document.createElement("canvas");
canvas.width = sampleWidth;
canvas.height = sampleHeight;
const context = canvas.getContext("2d", {
willReadFrequently: true,
});
context.drawImage(image, 0, 0, sampleWidth, sampleHeight);
const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
Enter fullscreen mode Exit fullscreen mode
最終的なプレビューはSVGとして表現されます。これにより、大きなビットマップを繰り返しペイントすることなく、円、正方形、線形状、ページ境界、ラベルを簡単に合成できます。
輝度をハーフトーンサイズにマッピングする
基本的なハーフトーンモデルでは、暗いソースピクセルを大きなマークに、明るいソースピクセルを小さなマークに変換します。
相対輝度はRGBチャネルから計算されます:
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
const brightness = luminance / 255;
Enter fullscreen mode Exit fullscreen mode
次に、明るさをユーザー選択の最小および最大ドットサイズ範囲にマッピングします:
const minScale = rasterSizeMin / 100;
const maxScale = rasterSizeMax / 100;
const dotScale = maxScale - brightness * (maxScale - minScale);
const radius = dotScale * maximumRadius;
Enter fullscreen mode Exit fullscreen mode
同じサンプリングされたカラーデータは、いくつかの出力モードをサポートできます:
- 明るい背景に黒いマーク、
- 暗い背景に白いマーク、
- 単一のカスタムマーク色、
- ソース画像から色付けされたマーク、
- 円、正方形、または水平線。
輝度からサイズへの関数を描画コードから分離しておくことで、これらの組み合わせをより簡単に管理できます。
一度に1つのPDFページウィンドウをレンダリングする
PDFはpdf-libを使用してブラウザで作成されます。ドキュメントは説明ページから始まり、その後に印刷可能なポスターページが続きます。
const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
const pdf = await PDFDocument.create();
for (let row = 0; row < pagesHigh; row += 1) {
for (let column = 0; column < pagesWide; column += 1) {
const page = pdf.addPage([paperWidth, paperHeight]);
// このページに属するソースウィンドウのみをレンダリングします。 }
}
Enter fullscreen mode Exit fullscreen mode
各ページは完全なポスター座標系へのウィンドウを表します:
const windowLeft = column * logicalWidth;
const windowTop = row * logicalHeight;
const windowRight = windowLeft + printableWidth;
const windowBottom = windowTop + printableHeight;
Enter fullscreen mode Exit fullscreen mode
プレーン画像モードの場合、そのウィンドウはソース画像ピクセル座標に変換されます。交差する画像領域のみが一時的なCanvasに描画され、JPEGとしてエンコードされて現在のPDFページに埋め込まれます。
ハーフトーンモードの場合、レンダラーは現在のページと交差する可能性のあるグリッドセルを見つけて、それらの形状のみを描画します。これにより、すべてのページですべてのドットをスキャンすることを避けられます。
ソース画像だけでなくジオメトリをクロップする
大きな円はグリッドセルの中心を超えて拡張できる場合があります。セル中心が印刷可能領域内にある場合でも、そのジオメトリがページ余白に溢れる場合があります。
簡単な安全策は、マークをレンダリングしてから4つの余白ストリップを白い矩形でカバーすることです。これにより厳密な印刷可能エリアマスクが作成されます。
クロップマークとページラベルはポスターコンテンツの後に追加されます。ラベルはスプレッドシート形式の列と行番号を使用します:
A1, B1, C1
A2, B2, C2
Enter fullscreen mode Exit fullscreen mode
ラベルジェネレーターは列Zを超えて継続する必要があるため、実装ではスプレッドシート列によく使用される同じbase-26パターンを使用します。
明示的なブラウザリソース制限を追加する
クライアントサイド処理はサーバーからインフラストラクチャコストを移行しますが、コストを除去するわけではありません。大きなCanvas、数百万のサンプリングされたセル、数十のPDFページは依然としてブラウザタブをフリーズさせたり、メモリを枯渇させたりする可能性があります。
現在の安全策は以下のレイアウトを拒否します:
- モバイルでは24ページまたは450,000サンプリングセル、
- デスクトップでは80ページまたは1,600,000サンプリングセル。
これらの数値は普遍的なブラウザ制限ではなく、製品制限です。重要な設計選択は、高コストのプレビューまたはPDFループを開始する前に作業量を見積もり、「ページ数を減らす」または「グリッドサイズを増やす」などの有用なメッセージを返すことです。
2回目の実装でより早期に分離するもの
3つの境界が特に有用でした:
- レイアウト数学とレンダリング。 プレビューとPDFは、寸法を個別に計算するのではなく、同じレイアウトオブジェクトを消費する必要があります。
-
ソース読み込みとプライバシー主張。 ローカル
BlobとリモートURLは異なるデータパスをたどり、異なる方法で説明する必要があります。 - 出力品質と実現可能性。 サンプリング密度、JPEG品質、ページ数、グリッドサイズは分離された設定ではなく、関連するリソース制御です。
一般的なパターンはポスターを超えて適用されます。1つの視覚的資産を複数の物理ページに変換するブラウザツールには、安定した座標系、明示的なクリッピングウィンドウ、決定論的な丸め処理、早期リソースチェックが必要です。
現在の実装はRasterbator.appでテストできます。私は特にプレビューサンプリングと大規模ドキュメントのメモリ管理の代替アプローチに興味があります。
開示: この記事はAI支援で下書きされ、その後プロジェクト実装に対してレビューされました。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.