Parents and teachers print coloring pages at home on ordinary printers. That sounds simple until you try it: a drawing that looks fine on screen comes out cropped, stretched, or tiny in the middle of the sheet, and the PDF a phone produces is a different size from the one a laptop produces.
We build Colorir Agora, a free site of printable coloring pages and activities. The interface is in Brazilian Portuguese, but the export problem is the same in any language: take whatever the visitor has made (their colors, an optional name, a frame) and give them a PNG and a PDF that print on a full A4 page without cropping. We wanted to do it without a server round trip, so the drawing never leaves the device.
Here is how the pipeline works and the few things that surprised us.
1. Pick one page size and never ask the printer to guess
Everything is rendered onto a single canvas the size of an A4 sheet at 300 dpi:
const PAGE_WIDTH = 2480,
PAGE_HEIGHT = 3508;
The drawing is then fitted into a fixed box inside that page, leaving a 120 px margin (about 10 mm at 300 dpi) so home printers do not clip the edges. The fit is a plain "contain": scale by whichever side runs out first, then center.
const scale = Math.min(2240 / view.width, 3148 / view.height);
const width = view.width * scale, height = view.height * scale;
return { x: (2480 - width) / 2, y: 120 + (3148 - height) / 2, width, height };
Because the same box is used for the PNG, the PDF and the print view, all three come out identical. Proportions are never changed, and nothing is cropped to fill the sheet.
2. Compose as SVG, rasterize once
The coloring itself is stored as a composition (regions, colors, text, frame) and serialized to SVG. Custom fonts for names and labels are embedded straight into that SVG as base64 @font-face rules, because an SVG loaded as an image cannot fetch external fonts:
return `<style>@font-face {font-family: '${info.family}';src:url(data:font/woff;base64,${data}) format('woff');}</style>`;
The SVG becomes a Blob URL, is loaded into an Image, and is drawn once onto the white A4 canvas. That single canvas is the source for both file types.
One small detail: we wait for the image's load event rather than decode(). On iOS, load still fires when the page is in the background, for example when someone switches apps while a download is being prepared.
3. Free the canvas as soon as you can
A 2480 × 3508 RGBA canvas is roughly 35 MB of pixels. iOS Safari caps the total canvas memory a page may hold, so keeping a couple of these around leads to blank exports. We release the canvas the moment the file exists:
const release = () => {
canvas.width = canvas.height = 0;
};
Setting the dimensions to zero lets the browser drop the backing store immediately instead of waiting for garbage collection.
4. Write the PNG ourselves
canvas.toBlob("image/png") works, but it gives you no control over metadata, and we wanted printing apps to know the image is A4 at 300 dpi rather than a 2480 px wide screenshot. So we write the PNG by hand, which is less code than it sounds:
- an
IHDRchunk: width, height, 8-bit RGB, no interlace; - a
pHYschunk with 11,811 pixels per metre (300 dpi), which is what tells print dialogs the physical size; - an
IDATchunk with the pixel rows, each prefixed by filter type 0; - an
IENDchunk, plus a CRC32 for each chunk from a small lookup table.
The compression comes from the browser itself:
const stream = new Blob([bytes]).stream()
.pipeThrough(new CompressionStream("deflate"));
In our use, the browser's native deflate was clearly faster than the canvas PNG encoder, and it avoids shipping a compression library. If CompressionStream is missing, we fall back to canvas.toBlob.
5. Build the PDF from the same pixels
For the PDF we lazy-load pdf-lib only when someone asks for a PDF, so it never weighs on the first page load. The page is A4 in points (595.28 × 841.89), and the image fills it exactly.
The usual approach is to hand pdf-lib a PNG and let it decode and re-encode the pixels in JavaScript. Instead we deflate the raw RGB rows once more (without the PNG filter bytes) and register them directly as an image XObject with FlateDecode:
pdf.context.stream(data, {
Type: "XObject", Subtype: "Image",
Width: width, Height: height,
ColorSpace: "DeviceRGB", BitsPerComponent: 8,
Filter: "FlateDecode",
});
That skips the decode step entirely. When CompressionStream is not available, we embed a high-quality JPEG of the canvas instead.
What we would tell anyone doing the same
- Decide the physical page size first and derive every pixel number from it.
- Use one fitting function for every output so print, PNG and PDF cannot drift apart.
- Embed fonts into the SVG before rasterizing, or they silently fall back.
- On mobile, treat a full-page canvas as an expensive resource and release it explicitly.
- The PNG and PDF formats are friendlier than they look. A few dozen lines replace a dependency and give you exact control over the output.
If you want to see the result, any drawing on Colorir Agora can be colored online and exported this way. The site is in Portuguese, but the download and print buttons are easy to find.
Top comments (0)