pdf_manipulator โ€” cross-platform PDF manipulation for Dart & Flutter

pub package likes pub points GitHub stars license: MIT

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. No try/catch needed; it's safe to call twice, and a no-op once the task is done.
  • PdfCancelled only appears when you await the task โ€” never a half-finished result. That's the spot to wrap in try/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 (or PdfEncryption.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 SharedArrayBuffer while a worker fills it. Works on older browsers, but SharedArrayBuffer only 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 pdfx or flutter_pdfview. It pairs well: pre-process here (merge, decrypt, watermark), display there. (It does render pages to image bytes via doc.render(...) if you'd rather draw your own surface.)
  • OCR and table extraction. extract and search read 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 / DataSink helpers for native platforms.
pdf_manipulator
Cross-platform PDF manipulation.