"Convert this PDF to PNG" sounds like an image conversion task.
But a PDF page is not necessarily an image.
It can contain:
- vector text
- fonts
- shapes
- embedded images
- clipping paths
- transparency
- annotations
- multiple layers of drawing instructions
So a PDF → PNG workflow usually has to render the page, not simply extract an image from it.
That difference explains almost everything about how browser-side PDF-to-image tools work.
Mental model: render first, encode second
The pipeline is:
PDF bytes
↓
Parse document
↓
Load page
↓
Create viewport
↓
Render page to Canvas
↓
Encode Canvas as JPG / PNG
↓
Download
Mozilla's PDF.js is a web-standards-based PDF parsing and rendering platform.
Its examples show exactly this kind of browser rendering workflow.
Load a PDF
Assume the user selects a local PDF:
<input
id="pdfInput"
type="file"
accept="application/pdf"
/>
Read the bytes:
const file =
pdfInput.files[0];
const arrayBuffer =
await file.arrayBuffer();
Then load the document with PDF.js:
const loadingTask =
pdfjsLib.getDocument({
data: arrayBuffer
});
const pdf =
await loadingTask.promise;
console.log(
pdf.numPages
);
Nothing has been uploaded.
The PDF bytes can stay inside the browser.
Render one page
Let's render page 1.
const page =
await pdf.getPage(1);
PDF.js uses a viewport to determine rendering dimensions.
const viewport =
page.getViewport({
scale: 2
});
Create a Canvas:
const canvas =
document.createElement(
"canvas"
);
const ctx =
canvas.getContext("2d");
canvas.width =
Math.ceil(viewport.width);
canvas.height =
Math.ceil(viewport.height);
Then render:
await page.render({
canvasContext: ctx,
viewport
}).promise;
At this point, the composed PDF page has become pixels in a Canvas.
Why scale matters
Suppose the PDF page's base viewport is approximately:
612 × 792
At:
scale: 1
you render around:
612 × 792
At:
scale: 2
you render around:
1224 × 1584
At:
scale: 3
you render around:
1836 × 2376
Higher scale means:
- more pixels
- potentially sharper output
- larger files
- more memory usage
- more rendering time
So "quality" is not only an encoder setting.
For a PDF page, render resolution itself is a major quality decision.
Export as PNG
Once the page is on Canvas:
const blob =
await new Promise(
(resolve, reject) => {
canvas.toBlob(
(result) => {
result
? resolve(result)
: reject(
new Error(
"PNG encoding failed"
)
);
},
"image/png"
);
}
);
PNG is useful when:
- text must remain crisp
- screenshots/diagrams dominate
- transparency matters
- lossless output is preferred
Export as JPEG
For JPEG:
const blob =
await new Promise(
(resolve, reject) => {
canvas.toBlob(
(result) => {
result
? resolve(result)
: reject(
new Error(
"JPEG encoding failed"
)
);
},
"image/jpeg",
0.9
);
}
);
The third argument controls JPEG quality.
Example:
0.6 → smaller / more artifacts
0.8 → useful balance
0.9 → higher quality / larger
JPEG is often better for photo-heavy PDF pages.
Download the page
const url =
URL.createObjectURL(blob);
const link =
document.createElement("a");
link.href = url;
link.download = "page-1.png";
link.click();
URL.revokeObjectURL(url);
One page is easy.
The interesting engineering begins when the PDF has 100 pages.
Render a page range
Users rarely want to process every page blindly.
A simple range parser can start with input like:
1-3,7,10-12
Conceptually:
function parsePageRange(
input,
maxPages
) {
const pages = new Set();
for (
const part
of input.split(",")
) {
const token =
part.trim();
if (!token) continue;
if (token.includes("-")) {
const [a, b] =
token
.split("-")
.map(Number);
for (
let page = a;
page <= b;
page++
) {
if (
page >= 1 &&
page <= maxPages
) {
pages.add(page);
}
}
} else {
const page =
Number(token);
if (
page >= 1 &&
page <= maxPages
) {
pages.add(page);
}
}
}
return [...pages]
.sort((a, b) => a - b);
}
Then:
const selectedPages =
parsePageRange(
"1-3,7",
pdf.numPages
);
Do not render 100 pages simultaneously
This is the easiest way to turn a browser tab into a memory problem.
Imagine each rendered page becomes:
2000 × 2800
That is:
5.6 million pixels
Raw RGBA memory alone can be roughly:
5.6M × 4 bytes
≈ 22.4 MB
per canvas before counting PDF.js internals, encoded Blobs, JavaScript objects, and browser overhead.
Ten pages at once can become expensive quickly.
A better strategy is:
page
→ render
→ encode
→ store Blob / ZIP entry
→ release Canvas
→ next page
That limits peak memory.
Sequential renderer
async function renderPages(
pdf,
pages,
scale = 2
) {
const results = [];
for (const pageNumber of pages) {
const page =
await pdf.getPage(
pageNumber
);
const viewport =
page.getViewport({
scale
});
const canvas =
document.createElement(
"canvas"
);
canvas.width =
Math.ceil(
viewport.width
);
canvas.height =
Math.ceil(
viewport.height
);
const ctx =
canvas.getContext("2d");
await page.render({
canvasContext: ctx,
viewport
}).promise;
const blob =
await new Promise(
(resolve, reject) => {
canvas.toBlob(
(result) => {
result
? resolve(result)
: reject(
new Error(
"Encoding failed"
)
);
},
"image/png"
);
}
);
results.push({
pageNumber,
blob
});
canvas.width = 1;
canvas.height = 1;
page.cleanup?.();
}
return results;
}
This is not necessarily the fastest possible implementation.
It is deliberately conservative.
For a browser tool, predictable memory behavior is often more valuable than maximum concurrency.
PDF → Image is not image extraction
This distinction is worth repeating.
Suppose a PDF page contains:
logo.svg-like vectors
+ selectable text
+ a photograph
+ a table
Extracting the embedded photograph would give you only one asset.
Rendering the page gives you the complete visual composition.
That is why "PDF to PNG" normally means:
render the page into raster pixels.
Handling passwords and invalid PDFs
Production UX needs to account for:
- encrypted PDFs
- malformed files
- zero-page edge cases
- very large files
- unsupported structures
- rendering errors
Do not collapse all failures into:
Something went wrong
Useful errors are part of the product.
For example:
This PDF requires a password.
Page 17 could not be rendered.
The requested render size is too large for this browser.
Progress matters
A 70-page conversion should not feel frozen.
Useful progress:
Rendering page 23 of 70
or:
33%
Internally, the operation can report:
onProgress?.({
completed: index + 1,
total: pages.length
});
If the user understands that work is happening, they are much less likely to refresh the page.
ZIP download is the natural multi-page output
If you render:
page-001.png
page-002.png
page-003.png
...
asking the user to click 30 downloads is terrible UX.
The natural result is:
document-pages.zip
containing all outputs.
This is one reason a finished PDF-to-image tool needs more than a 20-line PDF.js demo.
The rendering API is only the engine.
The product still needs:
- page selection
- quality controls
- format controls
- naming
- progress
- ZIP packaging
- memory management
- download UX
Why local processing is useful for PDFs
PDFs are often more sensitive than ordinary images.
They may contain:
- invoices
- internal documents
- contracts
- presentations
- reports
- customer records
When a PDF-to-image workflow can run locally, the document does not need to be uploaded merely to render its pages.
That is one of the reasons I like browser-side PDF rendering.
For the straightforward "I need these PDF pages as JPG/PNG" use case, I added a dedicated local workflow to BatchSet.
It supports page selection, JPG/PNG output, quality controls, and ZIP download.
If you need the rendered pages rather than the rendering code:
Convert PDF to JPG/PNG with BatchSet
Final takeaway
A PDF page is a document surface, not simply an image file.
The practical browser pipeline is:
parse
→ load page
→ choose render scale
→ render to Canvas
→ encode JPG/PNG
→ package outputs
Once you understand that, the quality, memory, and performance trade-offs become much easier to reason about.
And if your goal is just to get the images, you can let the browser do the rendering without building the surrounding workflow yourself.
Top comments (0)