拼貼海報產生器看起來很簡單:載入一張圖片、選擇紙張尺寸,然後下載 PDF。當整個流程必須在瀏覽器中執行、保留實體尺寸、支援重疊頁面、渲染半色調效果,並避免耗盡行動裝置記憶體時,實作就會變得更有趣。
我在建立 Rasterbator.app 時,最近就遇到了這些限制。這篇文章的重點在於工具背後的工程模型,而非實體列印流程。
整個流程主要有五個階段:
- 在本機解碼來源圖片。
- 將紙張設定轉換為一致的座標系統。
- 在保留長寬比的同時,計算出確定的頁面網格。
- 產生預覽或半色調呈現。
- 將每頁渲染為可下載的 PDF。
使用 PDF point 作為版面座標系統
使用者介面提供熟悉的紙張單位,例如毫米和英寸,但 PDF 渲染器是以 point 為單位。及早轉換所有單位,可避免版面程式碼混用不同單位。
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
這個區別很重要。可列印寬度是單張紙上顯示的內容,邏輯寬度則是海報在下一張紙開始之前前進的距離。啟用重疊功能時,相鄰的紙張會刻意覆蓋相同的圖像區域。
在四捨五入頁數前保留圖像比例
使用者可以依寬度、高度或手動頁面網格來定義海報。對於以寬度驅動的版面,實體海報寬度來自請求的邏輯頁數,高度則依圖像比例計算。
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 是另一種情況。它們需要網路擷取,且當來源不允許跨來源存取時,可能需要代理伺服器。使用者介面和隱私說明應清楚區分這兩種情況,而非將本機檔案和遠端 URL 視為等同。
從取樣像素產生預覽
若每次更新預覽都渲染全解析度的來源圖片,成本會非常高。半色調預覽只需要每個網格單元的一個代表性顏色。
實作方式是將縮放後的圖片繪製到 Canvas 並讀取像素資料。對於可管理的版面,它會對每個輸出單元取樣一個小的 3×3 區域並取平均值。對於較大的版面,則會退回為每個單元取樣一個像素。
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
相同的取樣顏色資料可以支援多種輸出模式:
- 淺色背景上的黑色標記,
- 深色背景上的白色標記,
- 單一自訂標記顏色,
- 從來源圖像取色的標記,
- 圓形、方形或水平線條。
將亮度轉尺寸的函式與繪圖程式碼分開,可以更容易管理這些組合。
一次渲染一個 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]);
// Render only the source window that belongs to this page.
}
}
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 頁面。
對於半色調模式,渲染器會找出可能與目前頁面相交的網格單元,並只繪製那些形狀。這避免了為每一頁掃描所有點。
裁剪幾何形狀,而不僅是來源圖像
大圓可能會超出其網格單元的中心。即使單元中心位於可列印區域內,其幾何形狀仍可能溢出到頁面邊界。
一個簡單的保護措施是先渲染標記,然後用白色矩形覆蓋四個邊界條帶。這會建立嚴格的可列印區域遮罩。
裁剪標記和頁面標籤是在海報內容之後加入。標籤使用試算表式的欄列編號:
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 迴圈之前先估計工作量,然後回傳有用的訊息,例如「減少頁數」或「增加網格大小」。
在第二次實作中,我會更早區分的事項
有三個界線特別有用:
- 版面數學與渲染。 預覽和 PDF 應使用相同的版面物件,而不是獨立計算尺寸。
-
來源載入與隱私聲明。 本機
Blob和遠端 URL 遵循不同的資料路徑,應有不同的描述。 - 輸出品質與可行性。 取樣密度、JPEG 品質、頁數和網格大小是相關的資源控制,而非獨立的設定。
這個一般模式不僅適用於海報。任何將單一視覺資產轉換為多個實體頁面的瀏覽器工具,都需要穩定的座標系統、明確的剪裁視窗、確定的四捨五入以及早期的資源檢查。
您可以在 Rasterbator.app 測試目前的實作。我特別有興趣探討預覽取樣和大型文件記憶體管理的替代方法。
免責聲明:本文是以 AI 協助撰寫,之後再根據專案實作進行審核。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.