pdf_manipulator 1.0.2 copy "pdf_manipulator: ^1.0.2" to clipboard
pdf_manipulator: ^1.0.2 copied to clipboard

Cross-platform PDF toolkit for Flutter. Merge, split, render, extract, search, sign, encrypt, convert, build from scratch. Rust engine, off the main thread.

pdf_manipulator #

Cross-platform PDF manipulation for Dart & Flutter. Merge, split, render, extract, search, sign, encrypt, validate, convert, build from scratch. Native and web. Off the main thread.

Coming from the old Android-only package? See the migration guide.


Contents #


Install #

dependencies:
  pdf_manipulator: ^1.0.0

Web only — run once after install (and after each package update):

flutter pub run pdf_manipulator:setup

Native platforms need nothing extra — the build hook handles everything.


Quick start #

import 'package:pdf_manipulator/pdf_manipulator.dart';

final pdf = Pdf();

// Open a PDF from bytes in memory
final source = MemorySource(pdfBytes);      // your Uint8List
final doc = await pdf.open(source);
print('${doc.pageCount} pages');
await doc.dispose();

// Merge two PDFs into one
final output = MemorySink();
await pdf.merge([sourceA, sourceB], output);
final mergedBytes = output.takeBytes();

// Always dispose when done
pdf.dispose();

That's it. Every operation follows the same pattern: source in, sink out.


Sources & sinks #

A DataSource is where the PDF bytes come from. A DataSink is where the output goes. Two tiny interfaces:

abstract interface class DataSource {
  int get length;
  FutureOr<Uint8List> readAt(int offset, int count);
}

abstract interface class DataSink {
  FutureOr<void> write(Uint8List chunk);
}

The simplest implementations — good for getting started:

class MemorySource implements DataSource {
  MemorySource(this._data);
  final Uint8List _data;

  @override
  int get length => _data.length;

  @override
  Uint8List readAt(int offset, int count) =>
      Uint8List.sublistView(_data, offset, (offset + count).clamp(0, _data.length));
}

class MemorySink implements DataSink {
  final _buf = BytesBuilder(copy: false);

  @override
  void write(Uint8List chunk) => _buf.add(chunk);

  Uint8List takeBytes() => _buf.takeBytes();
}

Wrap whatever you have — Uint8List for memory, RandomAccessFile for disk, Blob.slice for web file pickers, HTTP Range requests for remote files. The engine reads at most 64KB per call, never the whole file. Constant memory regardless of file size.

More implementations: file, HTTP, web blob
// File — mobile/desktop, constant memory for any size
class FileSource implements DataSource {
  FileSource(this._file) : length = _file.lengthSync();

  final File _file;

  @override
  final int length;

  @override
  Future<Uint8List> readAt(int offset, int count) async {
    final raf = await _file.open();
    await raf.setPosition(offset);
    final bytes = await raf.read(count);
    await raf.close();
    return bytes;
  }
}
// HTTP — stream from server without downloading the whole file
class HttpSource implements DataSource {
  HttpSource(this._url, this.length);

  final Uri _url;

  @override
  final int length;

  @override
  Future<Uint8List> readAt(int offset, int count) async {
    final req = await HttpClient().getUrl(_url);
    req.headers.set('Range', 'bytes=$offset-${offset + count - 1}');
    final res = await req.close();
    final builder = BytesBuilder();
    await for (final chunk in res) {
      builder.add(chunk);
    }
    return builder.takeBytes();
  }
}
// Web Blob — browser file picker or drag-and-drop (package: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();
  }
}

Note: DataSource is random-access — the engine jumps to arbitrary positions in the file. Forward-only streams (like a network socket or stdin) need to be buffered into memory or disk first.


What you can do #

Combine & split #

// Merge
await pdf.merge([sourceA, sourceB, sourceC], outputSink);

// Split every N pages
await pdf.split(source, (i) => MemorySink(), every: 5);

// Split by file size
await pdf.splitBySize(source, (i) => MemorySink(), maxBytes: 500000);

// Split at bookmark boundaries
await pdf.splitByBookmarks(source, (i) => MemorySink());

// Pick specific pages
await pdf.extractPages(source, sink, pages: [0, 2, 5]);

// Remove pages
await pdf.deletePages(source, sink, pages: [3]);

// Reorder
await pdf.reorderPages(source, sink, order: [4, 3, 2, 1, 0]);

// Move one page
await pdf.movePage(source, sink, from: 0, to: 4);

Read & query #

Open a PDF once, run any number of queries, dispose when done:

final doc = await pdf.open(source);
print('${doc.pageCount} pages, v${doc.version}');
print('encrypted: ${doc.isEncrypted}, tagged: ${doc.isTagged}');
print('title: ${doc.title}, author: ${doc.author}');

Extract text — plain, markdown, or html:

final text = await doc.extract(pages: PdfPages.all());
final md = await doc.extract(
    pages: PdfPages.single(0), format: PdfExtractionFormat.markdown);
final html = await doc.extract(
    pages: PdfPages.single(0), format: PdfExtractionFormat.html);

Search with bounding rectangles:

final hits = await doc.search(query: 'revenue', pages: PdfPages.all());
for (final hit in hits) {
  print('p${hit.page}: "${hit.text}" at (${hit.rect.x}, ${hit.rect.y})');
}

Render to images — streams one page at a time, constant memory:

await for (final page in doc.render(
    pages: PdfPages.all(), size: PdfRenderSize.thumbnail(200))) {
  // page.width, page.height, page.data (RGBA Uint8List)
}

Extract embedded images:

await for (final img in doc.extractImages(pages: PdfPages.single(0))) {
  print('${img.width}×${img.height} ${img.format}');
}

Validate, classify, inspect:

// PDF/A and PDF/UA compliance
final pdfA = await doc.validatePdfA();
print('PDF/A: ${pdfA.compliant} (${pdfA.errors} errors, ${pdfA.warnings} warnings)');
final accessible = await doc.validatePdfUa();

// Auto-detect page/document type
final pageType = await doc.classifyPage(0);
final docType = await doc.classifyDocument();

// Digital signatures
final sigs = await doc.getSignatures();
final valid = await doc.verifySignatures();

// Bookmark structure
final segments = await doc.planSplitByBookmarks();

await doc.dispose();

Edit & transform #

// Rotate
await pdf.rotatePages(source, sink, pages: {0: 90, 2: 180});
await pdf.rotateAllPages(source, sink, degrees: 90);

// Watermark — centered (default), tiled, corner, or exact position
await pdf.watermark(source, sink,
    text: 'CONFIDENTIAL',
    style: PdfWatermarkStyle(opacity: 0.2, fontSize: 60, rotation: 45));

// Tiled watermark behind content
await pdf.watermark(source, sink,
    text: 'DRAFT',
    position: PdfWatermarkPosition.tiled(columns: 3, rows: 4),
    layer: PdfWatermarkLayer.background);

// Corner watermark
await pdf.watermark(source, sink,
    text: 'SAMPLE',
    position: PdfWatermarkPosition.corner(PdfCorner.topRight));

// Stamps
await pdf.addStamp(source, sink,
    page: 0, type: PdfStampType.approved,
    rect: PdfRect(x: 100, y: 100, width: 200, height: 50));
await pdf.addImageStamp(source, sink,
    page: 0, imageData: imageSource,
    rect: PdfRect(x: 100, y: 100, width: 150, height: 150));

// Compress
await pdf.compress(source, sink, imageQuality: 75);

// Flatten forms / redactions
await pdf.flattenForms(source, sink);
await pdf.applyRedactions(source, sink);

// Embed file / erase regions
await pdf.embedFile(source, sink, name: 'data.csv', fileData: csvSource);
await pdf.eraseRegions(source, sink,
    page: 0, regions: [PdfRect(x: 50, y: 700, width: 200, height: 30)]);

// Convert to PDF/A
await pdf.convertToPdfA(source, sink);

// Images to PDF
await pdf.imagesToPdf([img1, img2, img3], sink);

For multiple edits on the same PDF, use the batch editor — parse once, mutate many, save once.

Security & signing #

// Encrypt
await pdf.encrypt(source, sink,
    encryption: PdfEncryptionConfig(
      ownerPassword: 'secret',
      algorithm: PdfEncryptionAlgorithm.aes256,
      permissions: PdfPermissions(copy: false, modify: false),
    ));

// Decrypt
await pdf.decrypt(source, sink, password: 'secret');

// Sign (PKCS#12)
await pdf.sign(source, sink,
    credentials: PdfSigningCredentials.pkcs12(certBytes, 'cert-pw'),
    reason: 'Approved', location: 'HQ');

// Sign (PEM)
await pdf.sign(source, sink,
    credentials: PdfSigningCredentials.pem(certPem, keyPem));

Convert #

// PDF → Office
await pdf.convertTo(source, sink, format: PdfDocumentFormat.docx);
await pdf.convertTo(source, sink, format: PdfDocumentFormat.pptx);
await pdf.convertTo(source, sink, format: PdfDocumentFormat.xlsx);

// Office → PDF
await pdf.convertToPdf(docxSource, sink, format: PdfDocumentFormat.docx);

Create from scratch #

final builder = await pdf.build();
await builder.setTitle('Invoice #1042');
await builder.setAuthor('Acme Corp');

final page = await builder.addA4Page();
await page.heading(1, 'Invoice');
await page.paragraph('Thank you for your purchase.');
await page.space(20);
await page.textField('notes', PdfRect(x: 50, y: 400, width: 300, height: 100));
await page.checkbox('agree', PdfRect(x: 50, y: 370, width: 14, height: 14));
await page.linkUrl('https://example.com');
await page.footnote('1', 'Terms apply.');
await page.done();

await builder.save(sink);
await builder.dispose();

Text, headings, paragraphs, images, form fields (text, checkbox, combo box, push button, signature), links, footnotes, columns, watermarks — all from Dart. Page sizes: A4, Letter, or custom dimensions.

Batch editing #

When you need to do multiple things to the same PDF, open an editor. It parses the PDF once, applies all your mutations in memory, and writes once on save.

final editor = await pdf.edit(source);

await editor.setTitle('Q4 Report');
await editor.mergeFrom(appendixSource);
await editor.deletePage(4);
await editor.selectPages([0, 1, 2, 5, 6]);
await editor.addWatermark(0, 'FINAL', style: PdfWatermarkStyle(opacity: 0.15));
await editor.optimizeImages(quality: 70);
await editor.convertToPdfA();

await editor.save(sink, options: PdfSaveOptions.incremental());
await editor.dispose();

Save options:

  • PdfSaveOptions.fullRewrite() — default. Recompresses, garbage-collects unused objects.
  • PdfSaveOptions.fullRewrite(encryption: PdfEncryption.config(...)) — encrypt on save.
  • PdfSaveOptions.fullRewrite(encryption: PdfEncryption.remove()) — strip encryption.
  • PdfSaveOptions.incremental() — appends changes without rewriting. Faster, larger file.

Every operation from the sections above is also available on the editor: rotate, stamp, flatten, redact, crop, resize images, embed files, set form field values, scrub metadata, and more.


Error handling #

try {
  await pdf.open(source);
} on PdfPasswordRequired {
  // needs a password — retry with pdf.open(source, password: '...')
} on PdfCorrupted catch (e) {
  print('Bad PDF: ${e.message}');
} on PdfIoError catch (e) {
  print('I/O problem: ${e.message}');
}

Every error is a typed subclass of PdfError. No string matching. No PlatformException.


Platforms #

Platform 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 table below WASM

How native binaries are resolved #

The build hook resolves the native library automatically — no manual steps:

Priority Method When Requires
1 Pre-built binary Default for pub.dev + git tag users Nothing
2 Source compile Binary unavailable (fallback) Rust
3 Submodule init Git dep ref: dev (no vendor dir) Rust + git

The vendored Rust source ships in both the pub.dev tarball and git tags. If the repo disappears, published versions still compile from source.

Web #

Works out of the box on all modern browsers:

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

Setup — run once after install (and after each package update):

flutter pub run pdf_manipulator:setup

The engine compiles to WASM and runs in a Web Worker pool. Your UI thread never does PDF work.

Web I/O modes #

Three modes, auto-detected (best first). No code changes between them:

Mode How it works Streaming Requires
JSPI WASM promise suspension Chrome 137+ · Firefox 139+
Atomics SharedArrayBuffer blocking COOP/COEP headers
OPFS Pre-copy to disk, then process All modern browsers

Force a mode or check which was selected:

// Force
final pdf = Pdf(config: PdfConfig(webIoMode: PdfIoMode.atomics));

// Check
final mode = await pdf.ensureInitialized();
if (mode == PdfIoMode.opfs) {
  // OPFS: pre-copies each source to disk before processing.
  // Slower first byte + uses disk quota vs streaming modes.
  // To get streaming: deploy with COOP/COEP headers (Atomics)
  // or target Chrome 137+ / Firefox 139+ (JSPI auto-detected).
}
Advanced: COOP/COEP headers for Atomics on older browsers

By default on browsers without JSPI support, the package copies your PDF to temporary disk storage (OPFS) before processing — works everywhere, no server config needed.

On Chrome 137+ and Firefox 139+, JSPI mode is auto-detected and gives true streaming without any server config. This is the best mode and requires no action from you.

For older browsers that have SharedArrayBuffer but not JSPI, adding two server headers enables Atomics mode — direct memory reads, no disk copy, lower latency:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

⚠️ These headers have side effects. require-corp blocks loading ANY cross-origin resource (images, fonts, scripts, iframes) that doesn't explicitly opt in via Cross-Origin-Resource-Policy or CORS headers. Google Fonts, CDN images, analytics scripts, OAuth popups, embedded videos — all break unless their servers also send the right headers. Only add these if your app controls all its resource origins or you've tested thoroughly.

With these headers, browser support for streaming goes further back:

Browser Version Released
Chrome / Edge 68+ Jul 2018
Firefox 79+ Jul 2020
Safari / Safari iOS 15.2+ Dec 2021

For development:

flutter run -d chrome --cross-origin-isolation

This adds the COOP/COEP headers to Flutter's dev server automatically.


When NOT to use pdf_manipulator #

  • You only need to display PDFs. Use pdfx or flutter_pdfview.
  • Server-side batch processing. This package is for client-side use. For thousands of PDFs per second, use qpdf or poppler.
  • OCR. This package extracts text already in the PDF. For scanned images, you need Tesseract or similar.

Docs #

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.

69
likes
0
points
1.37k
downloads

Publisher

verified publisherwhuppi.com

Weekly Downloads

Cross-platform PDF toolkit for Flutter. Merge, split, render, extract, search, sign, encrypt, convert, build from scratch. Rust engine, off the main thread.

Repository (GitHub)
View/report issues

Topics

#pdf #ffi #wasm #document #encryption

License

unknown (license)

Dependencies

code_assets, crypto, ffi, hooks, logging, meta, package_config, path, web

More

Packages that depend on pdf_manipulator