Cross-platform PDF manipulation for Dart & Flutter. Merge, split, render, extract, search, sign, encrypt, validate, convert, or build from scratch. Every operation runs off the main thread and streams large files in chunks. Any of it can be cancelled mid-flight.
like it? a โญ star or ๐ like is the entire marketing budget. Bugs & features โ
Coming from the old Android-only package? everything changed, but the migration guide has the before/after for every call.
๐ Peek inside
Install
Add the dependency
dependencies:
pdf_manipulator: ^2.1.1
Native
Nothing to do. On iOS, Android, macOS, Windows, and Linux, the build hook downloads the right binary on first build.
Web
Web can't auto-download native assets, so run setup once. It fetches the prebuilt WASM engine. Run it again after any pub upgrade, since the asset is tied to the package version:
flutter pub run pdf_manipulator:setup
Pin the version, too, so a pub upgrade can't bump it behind your back and leave that fetched asset stale:
pdf_manipulator: 2.1.1 # exact version
๐งฐ all the setup commands
flutter pub run pdf_manipulator:setup # web (default)
flutter pub run pdf_manipulator:setup <target> # web|android|ios|macos|linux|windows
flutter pub run pdf_manipulator:setup --force <target> # re-resolve (debugging)
๐งฉ wait โ why does web need a setup step?
Flutter's build system automatically downloads native binaries for iOS, Android, etc., but it doesn't support web assets (WASM, JS) yet. The setup command fills that gap: it downloads the pre-built WASM engine, or compiles it from the vendored Rust source if the download isn't available.
This will go away when Dart/Flutter adds WASM/JS asset support to build hooks. Tracking: dart-lang/native#988
Quick start
Merge two PDFs
Merge two PDFs, using bytes in memory (works on web too):
import 'package:pdf_manipulator/pdf_manipulator.dart';
// one Pdf instance, reused everywhere
final pdf = Pdf();
// your two PDFs, as sources
final firstPdf = MemorySource(firstPdfBytes);
final secondPdf = MemorySource(secondPdfBytes);
// where the merged PDF lands
final output = MemorySink();
// combine the two into the output
await pdf.merge([firstPdf, secondPdf], output);
// take the merged bytes, then dispose it
final merged = output.takeBytes();
await pdf.dispose();
On mobile or desktop, point the same program at files; only the sources, sink, and import change:
import 'package:pdf_manipulator/pdf_manipulator.dart';
import 'package:pdf_manipulator/io.dart'; // adds FileSource / FileSink
final pdf = Pdf();
// read the two PDFs straight from disk
final firstPdf = FileSource(File('first.pdf'));
final secondPdf = FileSource(File('second.pdf'));
// write the result straight to a file
final output = await FileSink.create(File('merged.pdf'));
// the merge call is identical
await pdf.merge([firstPdf, secondPdf], output);
// flush the file, then dispose it
await output.close();
await pdf.dispose();
That's the shape of every edit: source in, sink out. Watermark, compress, split, sign are the same shape, a different verb. Reading is the other shape: open a document and query it, no sink needed. See Usage for the full menu of both.
Cancellation
Long job the user no longer needs? You can stop it. Every operation is a PdfTask: an ordinary Future you await, plus a cancel() button.
Keep the task in a variable: await it as usual, and cancel() it to stop early, from a Cancel button or your widget's dispose().
// run it and await it โ your normal code
final task = pdf.merge([firstPdf, secondPdf], output); // starts running
try {
await task;
final merged = output.takeBytes(); // success
} on PdfCancelled {
// cancelled โ the Pdf and everything else keep working
}
// stop it โ from a Cancel button, or your widget's dispose()
task.cancel(); // the await above now throws PdfCancelled
Three rules and you're safe:
- The
cancel()call never throws โ it sends a stop request and returns. Notry/catchneeded; it's safe to call twice, and a no-op once the task is done. PdfCancelledonly appears when youawaitthe task โ never a half-finished result. That's the spot to wrap intry/catch.- Never await the task? Nothing to handle โ cancel it and move on, no error, no crash.
To stop everything, pdf.dispose() cancels every operation and returns immediately; it never waits for in-flight work to drain.
๐งฉ Advanced: when do i want more than one Pdf?
Most apps need just one Pdf instance: create it once, reuse it everywhere.
You'd want a second one when two parts of your app should stop independently. Say each screen runs its own PDF work: give each screen its own Pdf, then dispose() it when the user leaves, which cancels only that screen's jobs and leaves the rest running. (With one shared Pdf, dispose() would stop everything, everywhere.)
Each Pdf instance runs its own pool of background workers, so operations go in parallel. Want more or fewer at a time?
final pdf = Pdf(config: PdfConfig(maxLanes: 8)); // default: max(2, cores รท 2)
Sources & sinks
Every operation reads from a source and writes to a sink. You've already used the built-ins: MemorySource/MemorySink for bytes, FileSource/FileSink for files. Write your own for anything else (a server, a database, a Blob).
๐งฉ what are sources & sinks, really? (+ rolling your own)
A source is how pdf_manipulator reads your PDF, never all at once, just small bites:
abstract interface class DataSource {
int get length; // how big are you?
FutureOr<Uint8List> readAt(int offset, int count); // count bytes from offset
}
It only nibbles โ "give me 64KB starting here", then the next bit, then the next. So you hand it a reader, not your whole file dumped into one giant Uint8List. (It hops around to any spot in the file, so a one-way stream like a live socket can't be a source; stash those in memory or a file first.)
A sink is the mirror, one method:
abstract interface class DataSink {
FutureOr<void> write(Uint8List chunk); // here's a chunk of output
}
Build your own for any backing store. Here's a PDF on a server, streamed over HTTP, pulling only the byte ranges asked for, never the whole download:
import 'package:http/http.dart' as http;
class UrlSource implements DataSource {
UrlSource(this.uri, this.length); // length from a HEAD request
final Uri uri;
@override
final int length;
@override
Future<Uint8List> readAt(int offset, int count) async {
final res = await http.get(uri,
headers: {'range': 'bytes=$offset-${offset + count - 1}'});
return res.bodyBytes;
}
}
// then use it exactly like any other source
final doc = await pdf.open(UrlSource(uri, contentLength));
Or a browser Blob from a file picker / drag-and-drop:
import 'package:web/web.dart' as web;
class BlobSource implements DataSource {
BlobSource(this._blob) : length = _blob.size;
final web.Blob _blob;
@override
final int length;
@override
Future<Uint8List> readAt(int offset, int count) async {
final slice = _blob.slice(offset, offset + count);
final bytes = await slice.arrayBuffer().toDart;
return bytes.asUint8List();
}
}
Same idea for a custom DataSink: implement write(chunk) for an upload stream, a database column, wherever the bytes should go.
Usage
Everything goes through one of four doors. Pick the one that fits what you're doing. Highlights below; every method and full signature lives in the API reference.
One-shot operations
A single change: call it straight on pdf, source in, sink out:
await pdf.watermark(source, output,
text: 'CONFIDENTIAL',
style: PdfWatermarkStyle(opacity: 0.2, fontSize: 60));
await pdf.compress(source, output, imageQuality: 75);
await pdf.encrypt(source, output,
encryption: PdfEncryptionConfig(
ownerPassword: 'secret',
algorithm: PdfEncryptionAlgorithm.aes256,
));
await pdf.split(source, (i) => MemorySink(), every: 5);
The full one-shot set, all the same shape: merge, split / splitBySize / splitByBookmarks, extractPages, deletePages, reorderPages, movePage, rotatePages / rotateAllPages, addStamp / addImageStamp, flattenForms, applyRedactions, embedFile, eraseRegions, decrypt, sign, convertTo (DOCX/PPTX/XLSX), convertToPdf, convertToPdfA, imagesToPdf.
Doing several of these to the same PDF? Use the editor (below); it parses once instead of re-parsing per call.
Read a document
pdf.open gives you a document to query as much as you like, then dispose:
final doc = await pdf.open(source);
print('${doc.pageCount} pages ยท encrypted: ${doc.isEncrypted}');
final text = await doc.extract(pages: PdfPages.all());
final hits = await doc.search(query: 'revenue', pages: PdfPages.all());
await for (final page in doc.render(
pages: PdfPages.all(), size: PdfRenderSize.thumbnail(200))) {
// page.width, page.height, page.data โ PNG-encoded bytes; decode to read pixels
}
await doc.dispose();
Also on the document: extract (plain / markdown / html), extractImages, getSignatures / verifySignatures, validatePdfA / validatePdfUa, classifyPage / classifyDocument, planSplitByBookmarks, plus metadata getters (title, author, version, isTagged).
Edit a document
pdf.edit is for many changes to one PDF. It parses once, applies everything in memory, and writes once on save:
final editor = await pdf.edit(source);
await editor.setTitle('Q4 Report');
await editor.mergeFrom(appendix);
await editor.deletePage(4);
await editor.addWatermark(0, 'FINAL', style: PdfWatermarkStyle(opacity: 0.15));
await editor.optimizeImages(quality: 70);
await editor.save(output); // see save options below
await editor.dispose();
Also on the editor: selectPages, rotatePage / rotateAllPages, addStamp / addImageStamp, embedFile, eraseRegions, cropMargins, resizeImage, flattenForms / flattenAllAnnotations, setFormFieldValue, unembedStandardFonts, convertToPdfA, scrubMetadata, and metadata get/set.
Save options:
PdfSaveOptions.fullRewrite()โ default; recompresses and drops unused objects.PdfSaveOptions.fullRewrite(encryption: ...)โ encrypt on save (orPdfEncryption.remove()to strip it).PdfSaveOptions.incremental()โ appends changes; faster, larger file.
Redaction is a mark-then-apply lifecycle (the content is removed, not just hidden):
editor.addRedaction(0, PdfRect(x: 72, y: 700, width: 200, height: 20));
print(await editor.redactionCount(0)); // pending marks on this page
await editor.applyRedactions(); // gone for good
Build from scratch
pdf.build hands you an empty PDF; add pages, then content:
final builder = await pdf.build();
await builder.setTitle('Invoice #1042');
final page = await builder.addA4Page();
await page.heading(1, 'Invoice');
await page.paragraph('Thank you for your purchase.');
await page.textField('notes', PdfRect(x: 50, y: 400, width: 300, height: 100));
await page.linkUrl('https://example.com');
await builder.save(output);
await builder.dispose();
Pages: addA4Page / addLetterPage / addPage (custom size). Content: text, heading, paragraph, space, image, columns, footnote, watermark; form fields (textField, checkbox, radioGroup, comboBox, pushButton, signatureField) with Acrobat JS actions (fieldFormat, fieldValidate, fieldCalculate, fieldKeystroke); links (linkUrl, linkPage).
Error handling
Every failure is a typed subclass of PdfError: no string matching, no PlatformException. Catch the cases you handle specially; let the rest fall to a catch-all. Each error carries a human-readable message.
try {
final doc = await pdf.open(source);
// ... use the document, then doc.dispose()
} on PdfPasswordRequired {
// encrypted โ retry with pdf.open(source, password: '...')
} on PdfWrongPassword {
// wrong password โ ask the user again
} on PdfCorrupted catch (e) {
print('Not a valid PDF: ${e.message}');
} on PdfError catch (e) {
// anything else โ I/O failure, unsupported feature, page out of range...
print(e.message);
}
PdfError is sealed, so you can also catch (e) once and switch over it, and the compiler flags any case you haven't handled. PdfCancelled is part of the same hierarchy (the one Cancellation throws).
Platform support
Every platform Flutter runs on, one API. Native platforms run a Rust core; web runs that same core compiled to WASM.
| Target | Architectures | Minimum version | Engine |
|---|---|---|---|
| Android | arm64, arm, x64, x86 | API 21 (Android 5.0) | Native (Rust) |
| iOS | arm64 device, arm64 + x64 simulator | 13.0 | Native (Rust) |
| macOS | arm64, x64 | 10.15 (Catalina) | Native (Rust) |
| Linux | x64, arm64 | glibc 2.31+ (Ubuntu 20.04+) | Native (Rust) |
| Windows | x64, arm64 | Windows 10 | Native (Rust) |
| Web | All modern browsers | See browser support | WASM |
๐งฉ how the binary actually shows up (build-time magic)
You never call this; it runs at build time. For the curious (or when a build fails), here's the order it tries to get the binary, native and web alike:
| Priority | Method | When | Requires |
|---|---|---|---|
| 1 | Cached | File exists + SHA-256 hash matches | Nothing |
| 2 | Download | Fetch pre-built from GitHub Releases | Internet |
| 3 | Source compile | Binary unavailable, vendor source on disk | Rust |
| 4 | Submodule init | Git dep ref: dev (no vendor dir) |
Rust + git |
| 5 | Error | Nothing worked | A clear message listing your options |
The vendored Rust source ships in both the pub.dev tarball and git tags. If the repo disappears, published versions still compile from source.
Browser support
Minimum versions, with no special setup:
| Browser | Version | Released |
|---|---|---|
| Chrome / Edge | 102+ | May 2022 |
| Firefox | 111+ | Mar 2023 |
| Safari / Safari iOS | 15.2+ | Dec 2021 |
| Chrome Android | 102+ | May 2022 |
| Samsung Internet | 21+ | 2023 |
The engine compiles to WASM and runs in isolated Web Workers, so your UI thread never touches PDF work. No jank, even on large files. (These floors can go further back with two extra headers; see Web I/O modes.)
Web I/O modes
On web you don't configure anything; the package auto-detects the best mode the browser supports and uses it. Everything works regardless of mode; the only thing that varies is how fast it reads large files.
Under the hood the modes differ in how the WASM engine gets your bytes: the top two stream them on demand, while the fallback copies the whole file into private browser storage first (works everywhere, just a slower first byte).
| Mode | What it means for you | Streams large files | Picked when |
|---|---|---|---|
| JSPI | Best: streams, zero setup | โ | Chrome 137+ ยท Firefox 139+ |
| Atomics | Streams, but needs two server headers | โ | COOP/COEP headers are set |
| OPFS | Always works; copies the file to disk first, then reads | โ | any modern browser (the fallback) |
๐งฉ ok but what do JSPI, Atomics, and OPFS actually mean?
All three solve the same puzzle (synchronous WASM code needs bytes that arrive asynchronously from Dart), just in different ways:
- JSPI (JavaScript Promise Integration) โ the browser lets the WASM call pause and resume while it waits for the next chunk. Cleanest path, no setup; needs a recent browser. Used automatically where available.
- Atomics โ the WASM side blocks on a
SharedArrayBufferwhile a worker fills it. Works on older browsers, butSharedArrayBufferonly switches on when your site sends two security headers (see below). - OPFS (Origin Private File System) โ when neither of the above is available, the package copies your file into the browser's private on-disk storage and reads from there. Works everywhere; the copy means a slower first byte and a little disk use.
You never choose; the package tries JSPI, then Atomics, then OPFS, and uses the first that works.
๐งฉ Advanced: force a mode, or unlock streaming on older browsers
Pin a mode, or just check which one was chosen (native ignores this):
final pdf = Pdf(config: PdfConfig(webIoMode: PdfIoMode.atomics)); // pin a mode
final mode = await pdf.ensureInitialized(); // or just check
Unlock streaming on older browsers. Chrome 137+ / Firefox 139+ already stream via JSPI with no setup. For older browsers that have SharedArrayBuffer, two server headers switch the fallback from disk-copy (OPFS) to streaming (Atomics):
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
โ ๏ธ These headers have side effects. require-corp blocks any cross-origin resource (images, fonts, scripts, iframes) that doesn't opt in via Cross-Origin-Resource-Policy or CORS. Google Fonts, CDN images, analytics, OAuth popups, embedded video all break unless their servers send the right headers. Add them only if your app controls its resource origins, or you've tested thoroughly.
With the headers set, streaming reaches further back:
| Browser | Version | Released |
|---|---|---|
| Chrome / Edge | 68+ | Jul 2018 |
| Firefox | 79+ | Jul 2020 |
| Safari / Safari iOS | 15.2+ | Dec 2021 |
For local dev, Flutter adds the headers for you:
flutter run -d chrome --cross-origin-isolation
Not in the box
A tiny wishlist โ what the shipped package doesn't do yet, and what to grab in the meantime. For the full engine-vs-shipped picture, see the capability roadmap.
- A viewer to display PDFs. This is a manipulation library, not a UI widget. To put a PDF on screen, use
pdfxorflutter_pdfview. It pairs well: pre-process here (merge, decrypt, watermark), display there. (It does render pages to image bytes viadoc.render(...)if you'd rather draw your own surface.) - OCR and table extraction.
extractandsearchread the text already in a PDF, so a scanned page (just an image) comes back empty, and clean rows-and-columns is a separate problem. The engine has both (a PaddleOCR pipeline and ML table detection); the default build leaves them out so it doesn't pull the ONNX runtime and ~12 MB of models into every install. An opt-in build is on the roadmap; until then, run them externally (Tesseract, Camelot, or a cloud API) and feed the result back. Want them first-party? Open an issue; it's how we gauge demand.
Docs
The README covers the everyday stuff. wanna go deeper?
| Doc | What's inside |
|---|---|
| Architecture | How it's built: layers, streaming I/O, three web modes |
| Capabilities | What's shipped, what's planned |
| Updating | Maintaining the vendored Rust engine |
| Migration | Upgrading from the old Android-only version |
| Contributing | Setup, PR workflow, adding operations |
License
MIT. See LICENSE.
Libraries
- io
- File-backed
DataSource/DataSinkhelpers for native platforms. - pdf_manipulator
- Cross-platform PDF manipulation.