Making a PDF look scanned is a surprisingly practical document-processing problem. Users often want a digital PDF to resemble a printed, signed, and rescanned document without using a physical printer or scanner.
This article explains a browser-based technical approach for building a Make PDF Look Scanned component. The component accepts a PDF file, renders each page to Canvas, applies scanner-like visual effects, and exports a new image-based PDF.
Component Goal
The component has one job:
Convert a clean digital PDF into a realistic scanned-looking PDF directly in the browser.
A good implementation should support:
PDF upload
Page-by-page rendering
Adjustable scanner effects
Local-only processing
Preview
Downloadable scanned-looking PDF
The high-level pipeline looks like this:
PDF file
-> render pages with PDF.js
-> draw each page to Canvas
-> apply scanner-style Canvas effects
-> encode pages as JPEG
-> assemble image-based PDF
-> download result
Why Use Browser-Side Processing?
A scanned-looking PDF tool is a good fit for browser-side processing because the input file may contain sensitive content.
Keeping the conversion local has several advantages:
The original PDF does not need to be uploaded.
The server does not pay the cost of PDF rendering or image processing.
The user gets faster feedback.
The component can work as a self-contained frontend tool.
The browser already has most of what is needed: file input, Canvas, Blob URLs, JPEG encoding, and Web Workers through PDF.js.
Rendering PDF Pages
Browsers cannot natively expose PDF pages as pixels, so the component uses PDF.js to render each page into a Canvas.
The component reads the uploaded file as an ArrayBuffer, passes it to PDF.js, then loops through every page:
const source = new Uint8Array(await file.arrayBuffer());
const pdf = await pdfjs.getDocument({ data: source.slice() }).promise;
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({
scale: dpi / 72,
});
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
await page.render({
canvasContext: ctx,
viewport,
}).promise;
}
The dpi / 72 scale is important. PDF pages are measured in points, where 72 points equal one inch. By adjusting DPI, the component controls the raster resolution of the final scanned page.
Applying Scanner Effects
Once a PDF page is rendered to Canvas, the component transforms it into a scanned-looking image.
The goal is not to create an obvious filter. Real scanned documents usually contain subtle imperfections:
slight rotation
warm paper tone
grayscale or reduced contrast
small blur
fine grain
edge shadow
JPEG compression
The component applies these effects using standard Canvas APIs.
Slight Page Rotation
A perfectly aligned document looks digital. Real scanned pages are often slightly misaligned.
The component rotates the rendered page around the center:
const angle = ((rng() - 0.5) * 2 * skew * Math.PI) / 180;
ctx.translate(width / 2, height / 2);
ctx.rotate(angle);
ctx.drawImage(sourceCanvas, -width / 2, -height / 2);
The default rotation should be small. A value around 0.5° to 1° is usually enough.
Grayscale, Blur, and Contrast
Canvas supports ctx.filter, which can combine common image effects before drawing:
ctx.filter = [
grayscale ? 'grayscale(1)' : '',
blur > 0 ? blur(${blur}px) : '',
paperTone > 0 ? contrast(${1 - paperTone * 0.08}) : '',
]
.filter(Boolean)
.join(' ');
A realistic scanned look depends on restraint. Too much blur makes text hard to read. Too much contrast reduction makes the output look artificially processed.
Paper Tone
Clean digital PDFs usually have a pure white background. Scanned pages often have a warmer, softer paper color.
The component first fills the output canvas with a light paper-like color:
ctx.fillStyle = paperFill(paperTone);
ctx.fillRect(0, 0, width, height);
Then it overlays a subtle warm tint:
ctx.globalCompositeOperation = 'multiply';
ctx.globalAlpha = Math.min(0.14, paperTone * 0.14);
ctx.fillStyle = 'rgb(255, 248, 226)';
ctx.fillRect(0, 0, width, height);
The opacity must stay low. The goal is to remove the sterile digital-white look, not to turn the page yellow.
Fine Grain
Noise is one of the most important effects, but it is also easy to get wrong.
If noise is applied in large blocks, the page background can look pixelated or mosaic-like. A better approach is fine per-pixel grain with very low intensity.
const image = ctx.getImageData(0, 0, width, height);
const data = image.data;
for (let y = 0; y < height; y++) {
let rowBias = 0;
if (y % 3 === 0) {
rowBias = (rng() - 0.5) * amount * 6;
}
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const luminance =
0.299 * data[idx] +
0.587 * data[idx + 1] +
0.114 * data[idx + 2];
const paperBoost = luminance > 210 ? 1.15 : 0.72;
const grain =
((rng() + rng() + rng()) / 3 - 0.5) *
amplitude *
paperBoost +
rowBias;
data[idx] = clampByte(data[idx] + grain);
data[idx + 1] = clampByte(data[idx + 1] + grain);
data[idx + 2] = clampByte(data[idx + 2] + grain);
}
}
ctx.putImageData(image, 0, 0);
This creates a finer texture and avoids obvious square artifacts.
The row bias adds a very subtle scanner-line feel without turning the document into a striped image.
Edge Shadow
Real paper is rarely perfectly flat against scanner glass. Slight edge darkening helps sell the illusion.
The component applies edge shadows with linear gradients:
ctx.globalCompositeOperation = 'multiply';
drawEdgeGradient(ctx, 0, 0, band, height, 'left', alpha);
drawEdgeGradient(ctx, width - band, 0, band, height, 'right', alpha);
drawEdgeGradient(ctx, 0, 0, width, band, 'top', alpha);
drawEdgeGradient(ctx, 0, height - band, width, band, 'bottom', alpha);
A directional gradient is better than a heavy vignette because documents are rectangular. The effect should look like paper on glass, not a photo filter.
Encoding Pages as JPEG
After the effects are applied, each processed Canvas is encoded as JPEG:
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(nextBlob) =>
nextBlob
? resolve(nextBlob)
: reject(new Error('Failed to encode JPEG.')),
'image/jpeg',
quality
);
});
const jpegBytes = new Uint8Array(await blob.arrayBuffer());
JPEG compression is part of the scanned look. Real scanned PDFs often contain image compression artifacts. However, quality should not be too low, or text readability suffers.
A default quality around 0.7 to 0.8 usually works well.
Rebuilding the PDF
The final output is an image-based PDF. Each processed JPEG page is embedded into a new PDF page with the original page dimensions.
Conceptually:
for each processed page:
create PDF page with original width/height
embed JPEG as full-page image
draw image at 0,0 covering the whole page
This produces a PDF that behaves like a scanned document: each page is an image, not selectable text.
This is useful because scanned documents are usually rasterized. It also helps avoid the result looking like a normal digital PDF with a filter applied on top.
Preview and Download
Once the new PDF bytes are created, the component turns them into a Blob URL:
const blob = new Blob([pdfBuffer], {
type: 'application/pdf',
});
const url = URL.createObjectURL(blob);
The same URL can be used for:
an iframe preview
a download link
a generated filename such as document.scanned.pdf
For performance and memory safety, the component should revoke old Blob URLs when a new file is selected or when the component unmounts.
URL.revokeObjectURL(previousUrl);
Recommended Default Settings
A good scanned effect should be subtle. One practical default set is:
const defaultParams = {
dpi: 144,
skew: 0.8,
grayscale: true,
paperTone: 0.52,
noise: 0.035,
blur: 0.35,
edgeShadow: 0.18,
jpegQuality: 74,
};
These values prioritize readability while still making the document look less digitally perfect.
Common Mistakes
Too Much Noise
Strong noise makes the page dirty and distracting. Block-based noise can create mosaic artifacts. Use fine, low-intensity grain instead.
Too Much Rotation
A scanned page should look slightly imperfect, not broken. Large rotation values quickly look fake.
Excessive Blur
Blur helps simulate scanner optics, but too much blur destroys text clarity.
Overly Yellow Paper
Paper tone should be barely noticeable. A strong yellow overlay makes the document look artificially aged rather than scanned.
Keeping Text as Text
If the goal is to simulate a scanned document, exporting vector text is not ideal. Rasterizing each page into an image-based PDF creates a more convincing result.
Final Architecture
A clean component architecture can be kept simple:
MakeLookScanned component
- handles file input
- loads PDF.js
- renders PDF pages
- applies Canvas effects
- encodes JPEG pages
- assembles final PDF
- manages preview/download URLs The component does not require a backend service. It can run entirely in the browser.
Conclusion
A “Make PDF Look Scanned” component can be built with standard browser technologies:
PDF.js for rendering PDF pages
Canvas for visual scanner effects
JPEG encoding for compression artifacts
Blob URLs for preview and download
image-based PDF assembly for the final output
The key to realism is subtlety. Slight rotation, fine grain, soft blur, warm paper tone, and light edge shadows can make a clean digital PDF look like it came from a physical scanner without making the output unreadable or obviously filtered.
Top comments (0)