Adding page numbers to a PDF is one of those features that sounds trivial until you try to build it. Users expect to choose the position, set a start number, skip the cover page, and pick a color. And they expect it all to happen without uploading their document to a server.
I built en.sotool.top/page-numbers/ to do exactly that. Here's how it works with Vue 3 and pdf-lib.
Why Client-Side?
PDFs often contain sensitive information. Contracts, medical records, financial statements, research papers. Even a "simple" page numbering tool should not force users to upload files to a server.
Client-side benefits:
- No upload bandwidth or size limits
- No server storage or cleanup
- Instant processing for normal files
- Works offline after the page loads
The tradeoff is that everything has to run in the browser, which limits the libraries you can use.
The Stack
- Vue 3 — UI and state
- pdf-lib — Load, manipulate, and save PDFs
- HTML5 Canvas — Render page numbers as PNG images
- File API — Read the uploaded file
- lucide-vue-next — Icons
npm install pdf-lib
Why Canvas-Rendered PNGs?
I considered three approaches:
-
page.drawText— Requires embedding a font. Standard fonts don't support Chinese, and embedding a CJK font adds 1–3 MB to the output. - SVG vectors — pdf-lib doesn't support direct SVG embedding. Converting SVG paths to PDF operations is complex.
- Canvas → PNG — No font embedding, small output, consistent rendering. This is what I shipped.
The only downside is that page numbers are raster images, so extreme zoom shows slight pixelation. For normal reading and printing, it's not noticeable.
Loading the PDF
First, read the file into an ArrayBuffer and load it with pdf-lib.
import { PDFDocument } from 'pdf-lib'
const pdfFile = ref<File | null>(null)
const totalPages = ref(0)
async function handleFile(files: File[]) {
if (files.length === 0) return
pdfFile.value = files[0]
const bytes = await files[0].arrayBuffer()
const pdf = await PDFDocument.load(bytes, { ignoreEncryption: true })
totalPages.value = pdf.getPageCount()
}
I use ignoreEncryption: true because some PDFs have permission passwords that prevent editing but not opening. Without this flag, those files throw on load.
Rendering the Page Number as a PNG
I render each number off-screen so it can be embedded as an image.
async function createPageNumberImage(
numberValue: number,
fontSizeValue: number,
colorValue: string,
) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
ctx.font = `bold ${fontSizeValue}px "Microsoft YaHei", "PingFang SC", sans-serif`
const text = String(numberValue)
const metrics = ctx.measureText(text)
const textW = metrics.width
const textH = fontSizeValue * 1.2
canvas.width = Math.ceil(textW)
canvas.height = Math.ceil(textH)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.font = `bold ${fontSizeValue}px "Microsoft YaHei", "PingFang SC", sans-serif`
const rgb = hexToRgb(colorValue)
ctx.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)`
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.fillText(text, 0, 0)
const blob = await new Promise<Blob | null>(resolve =>
canvas.toBlob(resolve, 'image/png'),
)
if (!blob) throw new Error('Canvas to blob failed')
return new Uint8Array(await blob.arrayBuffer())
}
The font is set twice intentionally. The first time measures the text. After resizing the canvas, some browsers reset the context state, so the font is set again before drawing.
Positioning the Numbers
Page numbers sit at the bottom of the page. I support three positions.
function getPositionCoords(
pos: string,
pageW: number,
pageH: number,
textW: number,
margin = 30,
) {
switch (pos) {
case 'bottom-left':
return { x: margin, y: margin }
case 'bottom-center':
return { x: (pageW - textW) / 2, y: margin }
case 'bottom-right':
return { x: pageW - textW - margin, y: margin }
default:
return { x: (pageW - textW) / 2, y: margin }
}
}
pdf-lib uses a coordinate system where the origin is the bottom-left corner, so y = margin places the image near the bottom edge.
Drawing Numbers onto Each Page
async function addPageNumbers() {
if (!pdfFile.value) return
const bytes = await pdfFile.value.arrayBuffer()
const pdf = await PDFDocument.load(bytes, { ignoreEncryption: true })
const pages = pdf.getPages()
for (let i = 0; i < pages.length; i++) {
if (skipCover.value && i === 0) continue
const page = pages[i]
const { width, height } = page.getSize()
const pageNum = startNumber.value + i - (skipCover.value ? 1 : 0)
const pngBytes = await createPageNumberImage(pageNum, fontSize.value, color.value)
const pngImage = await pdf.embedPng(pngBytes)
const imgDims = pngImage.scale(1)
const coords = getPositionCoords(position.value, width, height, imgDims.width)
page.drawImage(pngImage, {
x: coords.x,
y: coords.y,
width: imgDims.width,
height: imgDims.height,
})
}
const blob = new Blob([await pdf.save()], { type: 'application/pdf' })
downloadBlob(blob, 'pagenumbers.pdf')
}
The skipCover flag leaves the first page blank. The startNumber lets users begin from any integer, which is useful when continuing numbering across merged documents.
Lessons Learned
Set the font twice on canvas. After resizing, some browsers reset the context. Measure first, resize, then set the font again before drawing.
Use ignoreEncryption: true. Permission-protected PDFs are common. This flag lets you load them for harmless edits like adding page numbers.
Read each page's size individually. PDFs can mix A4, Letter, and landscape pages in one file. page.getSize() keeps placement correct everywhere.
Default to dark gray. #333333 is less harsh than pure black and looks more professional against body text.
Watch out for rotated pages. If a page is rotated 90 degrees, coordinates are still relative to the page's internal coordinate system. We haven't handled this automatically yet.
Try It
The tool is live at en.sotool.top/page-numbers/.
Free, no signup, nothing uploads to a server.
Full source is on GitHub. The page-numbers logic is in src/views/PageNumbers.vue.
Want More Advanced PDF Tools?
If you need OCR, form editing, digital signatures, or batch processing, Wondershare PDFelement is a solid desktop option. It keeps everything local.
This post contains affiliate links.
Have you built PDF manipulation tools in the browser? What rendering approach did you use?












