betto_pdfium 0.1.0-dev.4 copy "betto_pdfium: ^0.1.0-dev.4" to clipboard
betto_pdfium: ^0.1.0-dev.4 copied to clipboard

A pure Dart package wrapping the PDFium library for PDF rendering, text extraction, and annotation support.

betto_pdfium #

A pure Dart package that wraps PDFium via Dart FFI. No dependency on dart:ui or Flutter — works in CLI tools, server-side Dart, and Flutter apps alike.

Pre-built PDFium binaries are sourced from bblanchon/pdfium-binaries and downloaded automatically by the native-assets hook on first use.

Platform support #

Platform Status
macOS arm64 Supported
Linux x86_64 Supported
Linux arm64 Supported
iOS arm64 Supported (xcframework)
Android arm64 Supported
Android x86_64 Supported
Windows x86_64 Supported
Web (WASM) Supported

Web (WASM): the full API surface works, and PDFium calls run inside a dedicated Web Worker rather than the browser main thread — see Web (WASM) below for setup and the "Adopting the Web Worker backend" guide.

Installation #

Add to your pubspec.yaml:

dependencies:
  betto_pdfium: ^0.1.0-dev.3

Flutter iOS apps also need the companion plugin, which delivers the PDFium xcframework via Swift Package Manager:

dependencies:
  betto_pdfium: ^0.1.0-dev.3
  betto_pdfium_ios: ^0.1.0-dev.3

No additional setup is needed on desktop — the PDFium binary is fetched automatically when you first run dart test or dart run. For iOS and Android see Mobile below.

Quick start #

import 'dart:io';
import 'package:betto_pdfium/betto_pdfium.dart';

void main() async {
  final bytes = await File('document.pdf').readAsBytes();
  final doc = await PdfDocument.fromBytes(bytes);
  try {
    final meta = await doc.getMetadata();
    print(meta.title);
  } finally {
    await doc.close();
  }
}

API #

All capabilities are on PdfDocument. Import a single file:

import 'package:betto_pdfium/betto_pdfium.dart';

Loading a document #

final bytes = await File('document.pdf').readAsBytes();
final doc = await PdfDocument.fromBytes(bytes);

Throws PdfExtractionException on failure. Inspect exception.error to distinguish recoverable conditions:

try {
  final doc = await PdfDocument.fromBytes(bytes);
} on PdfExtractionException catch (e) {
  switch (e.error) {
    case PdfError.passwordRequired:
      print('Password required.');
    case PdfError.invalidDocument:
      print('Not a valid PDF.');
  }
}

Closing and resource management #

Always call close() when finished. A Finalizer is registered as a safety net, but explicit disposal is strongly preferred:

final doc = await PdfDocument.fromBytes(bytes);
try {
  // work with doc
} finally {
  await doc.close();
}

After close() returns, all other methods throw StateError. Calling close() more than once is safe.

Metadata #

final meta = await doc.getMetadata();
print(meta.title);
print(meta.author);
print(meta.creationDate?.value?.toIso8601String());

All fields on PdfMetadata are nullable — a null means the entry was absent from the PDF's Info dictionary.

Document info #

Returns the PDF file version and the permanent/changing file identifiers:

final info = await doc.getDocumentInfo();
print('PDF version: ${info.fileVersion}');

final hex = info.permanentId
    ?.map((b) => b.toRadixString(16).padLeft(2, '0'))
    .join();
print('Permanent ID: $hex');

Page count #

final count = await doc.pageCount;
print('Pages: $count');

Text extraction #

Streams pages one at a time, in index order:

await for (final page in doc.extractPlainText()) {
  if (page.hasTextLayer) {
    print('Page ${page.pageIndex}: ${page.text}');
  } else {
    print('Page ${page.pageIndex}: scanned (no text layer)');
  }
}

Extract a single page by index:

final page = await doc.extractPlainText(pageIndex: 0).first;

Check whether the document is worth extracting before streaming all pages:

if (!await doc.isPlainTextExtractable()) {
  print('Document appears to be scanned.');
}

Page size #

Returns the page's intrinsic dimensions in PDF user units (points, 1/72 inch):

final size = await doc.getPageSize(0);
print('${size.widthPt} × ${size.heightPt} pt');
print('Aspect ratio: ${size.aspectRatio}');

Convert to pixels for a specific DPI:

final px = size.sizeForDpi(150);
// px.width, px.height are doubles

Rendering #

Renders a page to a raw BGRA byte buffer:

final size = await doc.getPageSize(0);
final px = size.sizeForDpi(150);
final result = await doc.renderPageToBytes(
  0,
  px.width.round(),
  px.height.round(),
);

// result.pixels  — Uint8List of BGRA bytes
// result.pixelWidth, result.pixelHeight
assert(result.pixels.length == result.pixelWidth * result.pixelHeight * 4);

In a Flutter app, decode the BGRA buffer into a dart:ui Image via decodeImageFromPixels. The rendering surface is intentionally kept at the pure-Dart layer so it can be used outside Flutter.

Optional flags:

final result = await doc.renderPageToBytes(
  pageIndex,
  width,
  height,
  renderAnnotations: true,   // default: true  (FPDF_ANNOT)
  lcdText: false,            // default: false (FPDF_LCD_TEXT)
  backgroundColor: 0xFFFFFFFF, // default: opaque white (ARGB)
);

Annotations #

Streams one PdfPageAnnotations per page. Pages with no annotations yield an entry with an empty annotations list:

await for (final page in doc.extractAnnotations()) {
  for (final annot in page.annotations) {
    switch (annot) {
      case PdfTextAnnotation(:final contents, :final rect):
        print('Note on page ${page.pageIndex}: $contents at $rect');
      case PdfMarkupAnnotation(:final subtype, :final quadPoints)
          when subtype == PdfAnnotationType.highlight:
        print('Highlight on page ${page.pageIndex}');
      case PdfInkAnnotation(:final strokes):
        print('Ink with ${strokes.length} stroke(s)');
      default:
        print('Other annotation: ${annot.runtimeType}');
    }
  }
}

Extract a single page:

final page = await doc.extractAnnotations(pageIndex: 2).first;

Images #

Enumerate image objects page by page:

await for (final page in doc.extractImages()) {
  for (final img in page.images) {
    print('Image ${img.objectIndex}: '
        '${img.metadata.width}×${img.metadata.height} '
        '${img.filters.join(",")}');
  }
}

Fetch the rendered BGRA bitmap for a specific image object:

final bitmap = await doc.renderImage(pageIndex, objectIndex);
if (bitmap != null) {
  // bitmap.bgra, bitmap.width, bitmap.height
}

For bulk extraction, pass includeBitmap: true to extractImages() to retrieve bitmaps in a single stream pass.

await for (final match in doc.search('example')) {
  print('Match on page ${match.pageIndex + 1}: '
      'char ${match.charIndex}, ${match.rects.length} rect(s)');
}

Control matching behaviour with PdfSearchFlag values:

final matches = doc.search(
  'Dart',
  flags: {PdfSearchFlag.matchCase, PdfSearchFlag.matchWholeWord},
  pageIndex: 0, // restrict to one page
);

Match rects are in PDF user-space (origin bottom-left). Apply FPDF_PageToDevice if you need screen coordinates.

Table of contents #

final toc = await doc.tableOfContents;
for (final entry in toc) {
  final target = entry.pageIndex != null
      ? 'page ${entry.pageIndex! + 1}'
      : '(no target)';
  print('${entry.title} → $target');
  for (final child in entry.children) {
    print('  ${child.title}');
  }
}

Returns an empty list when the document has no bookmarks — not an error.

Thumbnails #

final thumb = await doc.getThumbnail(0);
if (thumb != null) {
  // thumb.bgra, thumb.width, thumb.height
  // thumb.source == PdfThumbnailSource.embedded
  //             or PdfThumbnailSource.rendered
}

In Flutter, scale maxDimension by MediaQuery.of(context).devicePixelRatio for crisp thumbnails on high-DPI displays:

final dpr = MediaQuery.of(context).devicePixelRatio;
final thumb = await doc.getThumbnail(0, maxDimension: (256 * dpr).round());

Pass generateIfAbsent: false to return null rather than rendering a fallback when no embedded thumbnail exists.

Error types #

Exception When thrown
PdfExtractionException fromBytes() fails (wrong password, corrupt file)
PdfiumException Unexpected PDFium native failure (allocation, render)
StateError Any method called after close()
RangeError Page index out of range, non-positive render dimensions

Architecture #

The platform backend is selected automatically via Dart's conditional import mechanism — callers import only betto_pdfium.dart and receive the correct implementation for their target.

Native platforms (macOS, Linux, iOS, Android, Windows): PDFium is not thread-safe. All PDFium calls run on a dedicated background Isolate (PdfiumIsolate) so the calling isolate (e.g. the Flutter UI isolate) is never blocked. The isolate is spawned lazily on the first PdfDocument.fromBytes() call and held for the process lifetime.

Web (WASM): dart:isolate is not supported on any web compile target, so PDFium calls run inside a dedicated Web Worker instead, communicating with the main thread via a hand-rolled postMessage protocol — mirroring the shape of the native isolate architecture (single owner of PDFium state, typed request/response messages) via a different mechanism. The main thread is never blocked. See the Web (WASM) section for details.

Running the examples #

From the repo root:

dart run example/main.dart   # metadata extraction
dart run example/extract.dart  # full text extraction
dart run bin/pdfinfo.dart    # pdfinfo CLI tool

See example/README.md for more details.

Web (WASM) #

betto_pdfium includes a dart:js_interop-based backend for Flutter web and dart2wasm. It uses the PDFium Emscripten module from bblanchon/pdfium-binaries, running inside a dedicated Web Worker.

Web setup (one-time per app) #

The PDFium WASM, JS, and Worker files must be placed at web/assets/pdfium/ in your Flutter app before building. Run the helper script from the repo root:

make fetch_wasm_assets

This downloads pdfium-wasm.tgz, verifies its SHA-256, extracts pdfium.wasm and pdfium.js, and copies betto_pdfium's own checked-in pdfium_worker.js (the compiled Web Worker entry point) alongside them — all three files, co-located. Re-run this after every betto_pdfium version bump.

Web Worker offload #

All PDFium calls run inside a dedicated Web Worker, not the browser main thread. dart:isolate is not supported on any web compile target (dart2js or dart2wasm), so the web backend uses a hand-rolled Worker + postMessage protocol instead — mirroring the shape of the native PdfiumIsolate architecture (single owner of PDFium state, typed request/response messages, opaque document tokens) via a different mechanism. One shared Worker is spawned lazily per page and reused across all open documents.

No Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy server headers are required — that requirement is specific to SharedArrayBuffer/shared-memory threading, which this message-passing protocol does not use.

See spec/02_pdfium_isolate.md's "Web Worker concurrency model" section for the full architecture, and plan_wasm_web_worker_offload.md for the design investigation.

Web usage #

The public API is identical on all platforms. No code changes are needed when targeting the web — the correct backend is selected automatically via Dart's conditional import mechanism.

Adopting the Web Worker backend #

This section is for maintainers of existing betto_pdfium web consumers (e.g. betto_pdf_widgets) upgrading from a pre-Worker-offload version.

What changes, and what doesn't. The PdfDocument public API is completely unchanged — no code changes are required in your app. What changes is purely internal: PDFium calls that used to run synchronously on the browser main thread now run inside a dedicated Web Worker.

Distribution/setup delta. make fetch_wasm_assets now places three files at web/assets/pdfium/ instead of two: pdfium.wasm, pdfium.js, and the new pdfium_worker.js. You do not need to reference pdfium_worker.js anywhere in your own code or web/index.htmlbetto_pdfium spawns the Worker itself from a well-known relative URL. Just make sure your build/deploy process serves the whole web/assets/pdfium/ directory, as it presumably already does for pdfium.wasm/pdfium.js.

New behavioural characteristics worth testing. The main thread no longer blocks during large renders or extractions — this is the whole point of the change, but it has a real UX implication: any loading indicator in your UI that was previously optional or purely cosmetic (because the main-thread freeze made the point moot) may now be load-bearing. A large-document render that used to freeze the whole tab (implicitly communicating "something is happening") now returns control to the UI immediately while the work continues in the background — if your UI doesn't show its own progress state for that Future, users may perceive the app as idle rather than working. Audit call sites of renderPageToBytes, getThumbnail, and the streaming extraction methods for large documents and confirm they show appropriate loading/progress UI.

Migration checklist:

  1. Bump your betto_pdfium dependency.
  2. Re-run make fetch_wasm_assets (or your equivalent asset-sync step).
  3. Confirm pdfium_worker.js is present in your deployed web/assets/pdfium/ output alongside pdfium.wasm/pdfium.js.
  4. Smoke-test opening and rendering a large document on web.
  5. Open Chrome DevTools' Performance panel, record a large-document render, and confirm the main thread's timeline stays free of long tasks during the render (the work should now appear on a separate Worker thread track).
  6. Audit loading-state UI for renders/extractions per the behavioural note above.

Known limitations carried over or newly introduced:

  • Worker startup (module bootstrap + WASM instantiation) happens once per page lifetime, same as the previous main-thread module load — no new first-load latency beyond what already existed.
  • Streaming methods (extractPlainText, extractAnnotations, extractImages, search) fetch all requested pages in a single Worker round trip rather than incrementally; the public Stream API's page-by-page yielding is preserved locally on the client side, but very large documents mean the Worker computes everything before the first item is yielded (still off the main thread, so this does not block the UI, but it means the stream's first yield may take as long as the whole operation for extremely large documents).
  • Multiple documents open at once on the same page share one Worker and are processed one request at a time (matching the native backend's own single-isolate-serializes-everything model) — they do not render/extract in true parallel.
// Works identically on native and web.
import 'package:betto_pdfium/betto_pdfium.dart';

final doc = await PdfDocument.fromBytes(bytes);
final meta = await doc.getMetadata();
await doc.close();

On the web, fromBytes returns after the PDFium WASM module is loaded and initialised (on the first call this may take a moment while the browser downloads pdfium.wasm). Subsequent calls reuse the already-loaded module.

Mobile (iOS / Android) #

iOS support is provided by the companion package betto_pdfium_ios, which wraps the PDFium xcframework as a Flutter plugin and delivers it via Swift Package Manager. Add it to your Flutter app's pubspec.yaml alongside betto_pdfium (see Installation).

iOS and Android require additional one-time setup. See integration_test_app/ and the repo Makefile for targets that fetch mobile binaries and run on-device tests:

make fetch_mobile_binaries   # Android only — iOS xcframework fetched by SPM
make ios_test
make android_test

iOS prerequisite (one-time global setup):

flutter config --enable-swift-package-manager

Licenses #

This package is provided under Apache License, Version 2.0 — see LICENSE.

The binaries used by this package are accessed from https://github.com/bblanchon/pdfium-binaries. The repository carries the MIT License, Copyright 2014-2025 Benoit Blanchon.

PDFium is licensed under the Apache License, Version 2.0 — see PDFium LICENSE.

1
likes
0
points
320
downloads

Publisher

verified publisherbettongia.com

Weekly Downloads

A pure Dart package wrapping the PDFium library for PDF rendering, text extraction, and annotation support.

Homepage
Repository (GitHub)
View/report issues

Topics

#pdfium #pdf #ffi

License

unknown (license)

Dependencies

args, code_assets, collection, ffi, hooks, meta, web

More

Packages that depend on betto_pdfium