Filling out a PDF form in a browser is straightforward. But the last mile — locking those form fields so no one can edit them after you hit send — requires flattening. That's where interactive elements are burned into the static page content, transforming an editable template into a permanent, read-only document.
In this post, I'll walk through building a browser-based PDF flattening tool with Vue 3 and pdf-lib, processing everything locally for maximum privacy.
Why flatten?
Interactive PDF forms are great for data collection — users can type, check, and select without needing specialized software. But once a form is complete and ready to share, interactivity becomes a liability:
- Recipients can accidentally (or intentionally) modify field values
- Automated processing systems may reject documents with active form fields
- Archived records lose fidelity when the raw field values don't match the visual state
Flattening solves all of this by merging the form layer into the page content stream.
The stack
- Vue 3 with Composition API
- pdf-lib for PDF manipulation and flattening
- Vite for bundling
The core implementation
pdf-lib provides page.flatten() which handles the heavy lifting:
<script setup lang="ts">
import { ref } from 'vue'
import { PDFDocument } from 'pdf-lib'
const file = ref<File | null>(null)
const totalPages = ref(0)
const fieldCount = ref(0)
const annotationCount = ref(0)
const flattening = ref(false)
const result = ref<Uint8Array | null>(null)
async function flattenPdf() {
if (!file.value) return
flattening.value = true
const arrayBuffer = await file.value.arrayBuffer()
const pdf = await PDFDocument.load(arrayBuffer)
const form = pdf.getForm()
const fields = form.getFields()
fieldCount.value = fields.length
const pages = pdf.getPages()
totalPages.value = pages.length
let annots = 0
for (const page of pages) {
annots += page.node.Annots()?.size() ?? 0
}
annotationCount.value = annots
// Flatten all form fields into page content
form.flatten()
const flattened = await pdf.save()
result.value = flattened
flattening.value = false
}
</script>
Key implementation details
1. What form.flatten() actually does
pdf-lib's flatten() method performs several operations internally:
- For each form field, reads its current value and appearance stream
- Writes the appearance stream into the page's content stream at the field's coordinates
-
Removes the widget annotation from the page's
/Annotsarray -
Optionally removes the field from the form's
/AcroFormfield tree
The result is that every visual aspect of the field — its value, border, background — becomes part of the page content. There's no residual field object a reader could interact with.
2. Handling different field types
pdf-lib supports flattening all standard AcroForm field types:
const form = pdf.getForm()
// Text fields — values are rendered as text
const nameField = form.getTextField('name')
console.log(nameField.getText()) // "John Smith"
// Checkboxes — rendered as check mark or custom symbol
const agreeCheckbox = form.getCheckBox('agree')
// Radio buttons — selected option rendered
const colorRadio = form.getRadioGroup('color')
// Dropdown lists — selected value rendered
const stateDropdown = form.getDropdown('state')
// Signature fields — signature appearance rendered
const sigField = form.getSignature('signature')
// Flatten everything at once
form.flatten()
3. Annotations flattening
Annotations (text notes, highlights, ink drawings, stamps) are separate from form fields but can also be flattened. pdf-lib flattens annotations by rendering their appearance streams into the page content:
const pages = pdf.getPages()
for (const page of pages) {
const annotations = page.node.Annots()
if (annotations) {
// Annotations are flattened as part of page.flatten()
// or via form.flatten() which calls page.flatten() internally
}
}
form.flatten() // This flattens both form fields and annotations
4. Preserving document navigation
When flattening, you might want to keep internal links working. Links are technically annotations too — but they serve a navigation purpose, not an editing one. pdf-lib's flatten() is smart enough to preserve link annotations by default while flattening form widgets and visual annotations.
If you want to remove links as well, use removePdfLinks as a separate operation.
5. What about read-only fields?
Some forms set fields to read-only (/Ff bit 1) instead of flattening them. This is not the same as flattening:
// ❌ Read-only — fields still exist, just can't be edited in some viewers
field.enableReadOnly()
// ✅ Flattened — fields are gone, values are part of the page
form.flatten()
Read-only fields can still be unlocked by a determined recipient. Flattened fields cannot — the interactive element simply doesn't exist anymore.
Edge cases
- Form fields with no value: Empty fields (no typed text, no selection) are flattened as empty rectangles. They'll appear as blank areas on the page — which is correct, since that's what the user saw before flattening.
- Rich text fields: Some PDF forms support rich text (bold, italic, colored text) in form fields. pdf-lib renders the appearance stream as-is, so formatting is preserved.
-
Calculation fields: If a field's value is computed by JavaScript (e.g.,
total = price * quantity), flattening captures the final computed value. The formula itself is lost — which is expected, since interactivity is removed. - Encrypted PDFs: You need to unlock password-protected PDFs before flattening. Attempting to save a still-encrypted document after flattening will fail.
Summary
Building a browser-based PDF flattening tool with pdf-lib involves:
- Loading the PDF with
PDFDocument.load(arrayBuffer) - Inspecting form fields via
pdf.getForm().getFields() - Flattening everything with
form.flatten() - Saving with
pdf.save() - Downloading the locked-down result
The entire pipeline runs in the browser — your sensitive filled forms, signed contracts, and tax documents never leave your device. Try it at en.sotool.top/flatten-pdf.
Top comments (0)