平铺海报生成器从外观上看很简单:加载图像、选择纸张尺寸,然后下载 PDF。当整个流程必须在浏览器中运行、保留物理尺寸、支持页面重叠、渲染半色调效果并避免耗尽移动设备内存时,实现过程就变得更有趣了。
我在构建 Rasterbator.app 时遇到了这些限制。本文聚焦于该工具背后的工程模型,而非物理打印工作流。
该流程包含五个主要阶段:
- 在本地解码源图像。
- 将纸张设置转换为统一的坐标系。
- 在保留宽高比的同时计算确定性的页面网格。
- 生成预览或半色调表示。
- 将每页渲染为可下载的 PDF。
使用 PDF 点作为布局坐标系
用户界面使用熟悉的纸张单位(如毫米和英寸),但 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
这种区分很重要。可打印宽度是单张纸上显示的内容,逻辑宽度是海报在下一张纸开始之前前进的距离。启用重叠时,相邻纸张会故意覆盖部分相同的图像区域。
在四舍五入页数之前保留图像比例
用户可以按宽度、高度或手动页面网格定义海报。对于宽度驱动的布局,物理海报宽度由请求的逻辑页面数推导得出,高度则根据图像比例确定。
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 等同对待。
从采样像素生成预览
每次更新预览时都渲染全分辨率源图像会非常耗费资源。半色调预览只需为每个网格单元格提供一个代表性颜色。
实现方式是将缩放后的图像绘制到 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.