A tiled poster generator looks simple from the outside: load an image, choose a paper size, and download a PDF. The implementation becomes more interesting when the entire pipeline has to run in the browser, preserve physical dimensions, support overlapping pages, render a halftone effect, and avoid exhausting mobile memory.
I recently worked through those constraints while building Rasterbator.app. This article focuses on the engineering model behind the tool rather than the physical printing workflow.
The pipeline has five main stages:
- Decode the source image locally.
- Convert paper settings into one consistent coordinate system.
- Calculate a deterministic page grid while preserving aspect ratio.
- Generate a preview or halftone representation.
- Render each page into a downloadable PDF.
Use PDF points as the layout coordinate system
The UI exposes familiar paper units such as millimeters and inches, but the PDF renderer works in points. Converting everything early prevents the layout code from mixing units.
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],
};
The paper rectangle is not the same as the usable poster rectangle. A page can have an outer margin and an overlap region shared with the next tile.
const printableWidth = paperWidth - 2 * margin;
const printableHeight = paperHeight - 2 * margin;
const logicalWidth = printableWidth - overlap;
const logicalHeight = printableHeight - overlap;
That distinction matters. The printable width is what appears on one sheet. The logical width is how far the poster advances before the next sheet begins. When overlap is enabled, adjacent sheets intentionally cover some of the same image area.
Preserve the image ratio before rounding the page count
Users may define the poster by width, height, or a manual page grid. For a width-driven layout, the physical poster width is derived from the requested number of logical pages. The height follows from the image ratio.
const imageAspect = imageHeight / imageWidth;
const physicalWidth = pagesWide * logicalWidth + overlap;
const physicalHeight = physicalWidth * imageAspect;
const pagesHighFloat = (physicalHeight - overlap) / logicalHeight;
const pagesHigh = Math.ceil(pagesHighFloat);
The final Math.ceil is unavoidable. A narrow partial strip at the edge still requires a complete physical sheet.
Rounding creates another problem: the rounded page grid is usually slightly larger than the exact poster dimensions. I center the image horizontally inside that grid and keep it top-aligned vertically. The offset becomes part of every later coordinate calculation.
const trueGridWidth = pagesWideOut * logicalWidth + overlap;
const paperOffsetX = (trueGridWidth - physicalWidth) / 2;
const paperOffsetY = 0;
This gives the preview and the PDF renderer the same definition of where the poster begins.
Keep local files local
For a local upload, the browser already gives us a File, which is also a Blob. It can be decoded without sending the source image to an application server.
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;
});
}
Remote image URLs are a separate case. They require a network fetch and may require a proxy when the origin does not allow cross-origin access. The UI and privacy language should make that distinction clear instead of treating local files and remote URLs as equivalent.
Generate the preview from sampled pixels
Rendering the full-resolution source image for every preview update would be expensive. The halftone preview only needs one representative color for each grid cell.
The implementation draws a scaled image to a Canvas and reads the pixel data. For manageable layouts, it samples a small 3×3 area per output cell and averages the values. For larger layouts, it falls back to one pixel per cell.
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;
The final preview is represented as SVG. That makes circles, squares, line shapes, page boundaries, and labels easy to compose without repeatedly painting a large bitmap.
Map luminance to halftone size
A basic halftone model turns darker source pixels into larger marks and lighter source pixels into smaller marks.
Relative luminance is calculated from the RGB channels:
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
const brightness = luminance / 255;
Then brightness is mapped into the user-selected minimum and maximum dot-size range:
const minScale = rasterSizeMin / 100;
const maxScale = rasterSizeMax / 100;
const dotScale = maxScale - brightness * (maxScale - minScale);
const radius = dotScale * maximumRadius;
The same sampled color data can support several output modes:
- black marks on a light background,
- white marks on a dark background,
- a single custom mark color,
- marks colored from the source image,
- circles, squares, or horizontal lines.
Keeping the luminance-to-size function separate from the drawing code makes those combinations easier to manage.
Render one PDF page window at a time
The PDF is created in the browser with pdf-lib. The document begins with an instruction page, followed by the printable poster pages.
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.
}
}
Each page represents a window into the full poster coordinate system:
const windowLeft = column * logicalWidth;
const windowTop = row * logicalHeight;
const windowRight = windowLeft + printableWidth;
const windowBottom = windowTop + printableHeight;
For the plain-image mode, that window is converted back into source-image pixel coordinates. Only the intersecting image region is drawn to a temporary Canvas, encoded as JPEG, and embedded in the current PDF page.
For the halftone modes, the renderer finds the grid cells that can intersect the current page and draws only those shapes. This avoids scanning every dot for every page.
Crop the geometry, not just the source image
Large circles can extend beyond the center of their grid cell. Even when a cell center is inside the printable region, its geometry may spill into the page margin.
A straightforward safeguard is to render the marks and then cover the four margin strips with white rectangles. This creates a strict printable-area mask.
Crop marks and page labels are added after the poster content. Labels use spreadsheet-style columns and row numbers:
A1, B1, C1
A2, B2, C2
The label generator must continue past column Z, so the implementation uses the same base-26 pattern commonly used for spreadsheet columns.
Add explicit browser resource limits
Client-side processing shifts infrastructure cost away from a server, but it does not remove cost. Large Canvases, millions of sampled cells, and dozens of PDF pages can still freeze a browser tab or exhaust memory.
The current safeguards reject layouts above:
- 24 pages or 450,000 sampled cells on mobile,
- 80 pages or 1,600,000 sampled cells on desktop.
Those numbers are product limits rather than universal browser limits. The important design choice is to estimate work before starting the expensive preview or PDF loop, then return a useful message such as “reduce the page count” or “increase the grid size.”
What I would separate earlier in a second implementation
Three boundaries have been especially useful:
- Layout math versus rendering. The preview and PDF should consume the same layout object instead of calculating dimensions independently.
-
Source loading versus privacy claims. A local
Bloband a remote URL follow different data paths and should be described differently. - Output quality versus feasibility. Sampling density, JPEG quality, page count, and grid size are related resource controls, not isolated settings.
The general pattern applies beyond posters. Any browser tool that turns one visual asset into multiple physical pages needs a stable coordinate system, explicit clipping windows, deterministic rounding, and early resource checks.
You can test the current implementation at Rasterbator.app. I am particularly interested in alternative approaches to preview sampling and large-document memory management.
Disclosure: this article was drafted with AI assistance and then reviewed against the project implementation.

Top comments (0)