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
Flutter iOS apps also need the companion plugin, which delivers the PDFium xcframework via Swift Package Manager:
dependencies:
betto_pdfium: ^0.1.0
betto_pdfium_ios: ^0.1.0
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.
Search
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.html — betto_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:
- Bump your
betto_pdfiumdependency. - Re-run
make fetch_wasm_assets(or your equivalent asset-sync step). - Confirm
pdfium_worker.jsis present in your deployedweb/assets/pdfium/output alongsidepdfium.wasm/pdfium.js. - Smoke-test opening and rendering a large document on web.
- 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).
- 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 publicStreamAPI'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 firstyieldmay 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.
Libraries
- betto_pdfium
- Pure-Dart entry point for betto_pdfium.