拼貼海報產生器看起來很簡單:載入一張圖片、選擇紙張尺寸,然後下載 PDF。當整個流程必須在瀏覽器中執行、保留實體尺寸、支援重疊頁面、渲染半色調效果,並避免耗盡行動裝置記憶體時,實作就會變得更有趣。

我在建立 Rasterbator.app 時,最近就遇到了這些限制。這篇文章的重點在於工具背後的工程模型,而非實體列印流程。

整個流程主要有五個階段:

  1. 在本機解碼來源圖片。
  2. 將紙張設定轉換為一致的座標系統。
  3. 在保留長寬比的同時,計算出確定的頁面網格。
  4. 產生預覽或半色調呈現。
  5. 將每頁渲染為可下載的 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 迴圈之前先估計工作量,然後回傳有用的訊息,例如「減少頁數」或「增加網格大小」。

在第二次實作中,我會更早區分的事項

有三個界線特別有用:

  1. 版面數學與渲染。 預覽和 PDF 應使用相同的版面物件,而不是獨立計算尺寸。
  2. 來源載入與隱私聲明。 本機 Blob 和遠端 URL 遵循不同的資料路徑,應有不同的描述。
  3. 輸出品質與可行性。 取樣密度、JPEG 品質、頁數和網格大小是相關的資源控制,而非獨立的設定。

這個一般模式不僅適用於海報。任何將單一視覺資產轉換為多個實體頁面的瀏覽器工具,都需要穩定的座標系統、明確的剪裁視窗、確定的四捨五入以及早期的資源檢查。

您可以在 Rasterbator.app 測試目前的實作。我特別有興趣探討預覽取樣和大型文件記憶體管理的替代方法。

免責聲明:本文是以 AI 協助撰寫,之後再根據專案實作進行審核。