You need to display PDFs in your app. Not just open them in the system viewer — actually render pages inside your UI. Thumbnails in a grid. Full pages with zoom and pan. Annotations overlaid. Print previews.
PDFium is Google's open-source PDF engine — the same one that renders PDFs in Chrome. It's written in C/C++, handles the full PDF spec (text, images, forms, annotations, encryption), and is the engine behind several Flutter PDF packages. Understanding the FFI layer beneath those packages lets you customize rendering, handle edge cases, and build features the packages don't expose.
Getting PDFium
The pdfx or pdfrx package route
Several Flutter packages bundle PDFium already. pdfrx by espresso3389 is one of the most capable — it uses PDFium via FFI internally and gives you a high-level API:
dependencies:
pdfrx: ^1.0.0For most PDF viewing needs, start here. The rest of this post covers the direct FFI approach for when you need lower-level control.
Building PDFium from source
PDFium's build system uses Google's depot_tools and the Chromium build infrastructure. It's not a simple cmake && make:
# Install depot_tools
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$PATH:$(pwd)/depot_tools"
# Get PDFium source
mkdir pdfium && cd pdfium
gclient config --unmanaged https://pdfium.googlesource.com/pdfium.git
gclient sync
# Build for Android arm64
cd pdfium
gn gen out/android_arm64 --args='
target_os="android"
target_cpu="arm64"
is_debug=false
pdf_is_standalone=true
is_component_build=false
pdf_enable_v8=false
pdf_enable_xfa=false
'
ninja -C out/android_arm64 pdfiumThis produces libpdfium.so (or a static library). For iOS, change target_os to "ios".
The build is heavy — the full Chromium toolchain downloads several gigabytes. Pre-built binaries from the pdfium-binaries project are a saner option for most people.
The C API
PDFium's public C API is well-structured. The core flow:
FPDF_InitLibrary() → Initialize PDFium (once per process)
FPDF_LoadDocument(path, pwd) → Open a PDF file
FPDF_GetPageCount(doc) → How many pages
FPDF_LoadPage(doc, index) → Load a specific page
FPDF_GetPageWidthF(page) → Page dimensions (in points, 72 points = 1 inch)
FPDF_GetPageHeightF(page)
FPDFBitmap_Create(w, h, ...) → Create a bitmap buffer for rendering
FPDFBitmap_FillRect(...) → Fill with background color
FPDF_RenderPageBitmap(...) → Render the page into the bitmap
FPDFBitmap_GetBuffer(bitmap) → Get pointer to pixel data
FPDFBitmap_Destroy(bitmap) → Free the bitmap
FPDF_ClosePage(page) → Close the page
FPDF_CloseDocument(doc) → Close the document
FPDF_DestroyLibrary() → Cleanup (once, at app exit)Dart bindings
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
final DynamicLibrary _pdfium = Platform.isAndroid
? DynamicLibrary.open('libpdfium.so')
: DynamicLibrary.process();
// Init/destroy
final fpdfInitLibrary = _pdfium.lookupFunction<
Void Function(), void Function()>('FPDF_InitLibrary');
final fpdfDestroyLibrary = _pdfium.lookupFunction<
Void Function(), void Function()>('FPDF_DestroyLibrary');
// Load/close document
final fpdfLoadDocument = _pdfium.lookupFunction<
Pointer<Void> Function(Pointer<Utf8>, Pointer<Utf8>),
Pointer<Void> Function(Pointer<Utf8>, Pointer<Utf8>)
>('FPDF_LoadDocument');
final fpdfCloseDocument = _pdfium.lookupFunction<
Void Function(Pointer<Void>),
void Function(Pointer<Void>)
>('FPDF_CloseDocument');
// Page count
final fpdfGetPageCount = _pdfium.lookupFunction<
Int32 Function(Pointer<Void>),
int Function(Pointer<Void>)
>('FPDF_GetPageCount');
// Load/close page
final fpdfLoadPage = _pdfium.lookupFunction<
Pointer<Void> Function(Pointer<Void>, Int32),
Pointer<Void> Function(Pointer<Void>, int)
>('FPDF_LoadPage');
final fpdfClosePage = _pdfium.lookupFunction<
Void Function(Pointer<Void>),
void Function(Pointer<Void>)
>('FPDF_ClosePage');
// Page dimensions
final fpdfGetPageWidthF = _pdfium.lookupFunction<
Float Function(Pointer<Void>),
double Function(Pointer<Void>)
>('FPDF_GetPageWidthF');
final fpdfGetPageHeightF = _pdfium.lookupFunction<
Float Function(Pointer<Void>),
double Function(Pointer<Void>)
>('FPDF_GetPageHeightF');
// Bitmap
final fpdfBitmapCreate = _pdfium.lookupFunction<
Pointer<Void> Function(Int32, Int32, Int32),
Pointer<Void> Function(int, int, int)
>('FPDFBitmap_Create');
final fpdfBitmapFillRect = _pdfium.lookupFunction<
Void Function(Pointer<Void>, Int32, Int32, Int32, Int32, Uint32),
void Function(Pointer<Void>, int, int, int, int, int)
>('FPDFBitmap_FillRect');
final fpdfRenderPageBitmap = _pdfium.lookupFunction<
Void Function(Pointer<Void>, Pointer<Void>, Int32, Int32, Int32, Int32, Int32, Int32),
void Function(Pointer<Void>, Pointer<Void>, int, int, int, int, int, int)
>('FPDF_RenderPageBitmap');
final fpdfBitmapGetBuffer = _pdfium.lookupFunction<
Pointer<Uint8> Function(Pointer<Void>),
Pointer<Uint8> Function(Pointer<Void>)
>('FPDFBitmap_GetBuffer');
final fpdfBitmapDestroy = _pdfium.lookupFunction<
Void Function(Pointer<Void>),
void Function(Pointer<Void>)
>('FPDFBitmap_Destroy');A clean Dart API
class PdfRenderer {
static bool _initialized = false;
static void init() {
if (!_initialized) {
fpdfInitLibrary();
_initialized = true;
}
}
final Pointer<Void> _doc;
final int pageCount;
PdfRenderer._(this._doc, this.pageCount);
/// Open a PDF file. Returns null if the file can't be opened.
static PdfRenderer? open(String filePath, {String? password}) {
init();
final pathPtr = filePath.toNativeUtf8();
final pwdPtr = password?.toNativeUtf8() ?? nullptr.cast<Utf8>();
try {
final doc = fpdfLoadDocument(pathPtr, pwdPtr);
if (doc == nullptr) return null;
final pageCount = fpdfGetPageCount(doc);
return PdfRenderer._(doc, pageCount);
} finally {
calloc.free(pathPtr);
if (password != null) calloc.free(pwdPtr);
}
}
/// Render a page to RGBA pixels.
/// [scale] controls resolution: 1.0 = 72 DPI, 2.0 = 144 DPI, etc.
Uint8List? renderPage(int pageIndex, {double scale = 2.0}) {
final page = fpdfLoadPage(_doc, pageIndex);
if (page == nullptr) return null;
try {
final pageWidth = fpdfGetPageWidthF(page);
final pageHeight = fpdfGetPageHeightF(page);
final pixelWidth = (pageWidth * scale).round();
final pixelHeight = (pageHeight * scale).round();
// PDFium bitmap formats:
// 1 = FPDFBitmap_Gray (8-bit gray)
// 2 = FPDFBitmap_BGR (24-bit BGR, no alpha)
// 3 = FPDFBitmap_BGRx (32-bit, alpha byte ignored)
// 4 = FPDFBitmap_BGRA (32-bit BGRA)
// We want BGRA (4) for compositing with an alpha channel.
final bitmap = fpdfBitmapCreate(pixelWidth, pixelHeight, 4);
if (bitmap == nullptr) return null;
try {
// Fill with white background
fpdfBitmapFillRect(bitmap, 0, 0, pixelWidth, pixelHeight, 0xFFFFFFFF);
// Render flags (from fpdfview.h):
// FPDF_ANNOT = 0x01 — render annotations
// FPDF_LCD_TEXT = 0x02 — use LCD-optimized text rendering
fpdfRenderPageBitmap(
bitmap, page,
0, 0, // start x, y
pixelWidth, pixelHeight, // size
0, // rotation (0 = normal)
0x01 | 0x02, // flags: ANNOT | LCD_TEXT
);
// Read pixels
final buffer = fpdfBitmapGetBuffer(bitmap);
final byteCount = pixelWidth * pixelHeight * 4;
return Uint8List.fromList(buffer.asTypedList(byteCount));
} finally {
fpdfBitmapDestroy(bitmap);
}
} finally {
fpdfClosePage(page);
}
}
/// Get page dimensions in points (72 points = 1 inch).
({double width, double height}) getPageSize(int pageIndex) {
final page = fpdfLoadPage(_doc, pageIndex);
if (page == nullptr) return (width: 0, height: 0);
try {
return (
width: fpdfGetPageWidthF(page),
height: fpdfGetPageHeightF(page),
);
} finally {
fpdfClosePage(page);
}
}
void close() {
fpdfCloseDocument(_doc);
}
}Displaying rendered pages in Flutter
The rendered bytes are BGRA pixels. Convert to a Flutter Image:
import 'dart:ui' as ui;
Future<ui.Image> bytesToImage(Uint8List bgraBytes, int width, int height) async {
// PDFium renders BGRA, Flutter expects RGBA — swap B and R channels
for (int i = 0; i < bgraBytes.length; i += 4) {
final b = bgraBytes[i];
bgraBytes[i] = bgraBytes[i + 2]; // R
bgraBytes[i + 2] = b; // B
}
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
bgraBytes,
width,
height,
ui.PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}
// Usage in a widget
class PdfPageWidget extends StatefulWidget {
final PdfRenderer renderer;
final int pageIndex;
const PdfPageWidget({required this.renderer, required this.pageIndex});
@override
State<PdfPageWidget> createState() => _PdfPageWidgetState();
}
class _PdfPageWidgetState extends State<PdfPageWidget> {
ui.Image? _image;
@override
void initState() {
super.initState();
_renderPage();
}
Future<void> _renderPage() async {
final size = widget.renderer.getPageSize(widget.pageIndex);
final scale = 2.0;
final width = (size.width * scale).round();
final height = (size.height * scale).round();
// Native pointers can't cross isolate boundaries — the PdfRenderer's
// internal FPDF_DOCUMENT handle is owned by the isolate that opened it.
// For background rendering, open the document on the worker isolate
// using the file path and close it there. The simple approach is to
// render on the current isolate; if pages are large enough to jank,
// refactor to a long-lived worker isolate that holds its own renderer.
final bytes = widget.renderer.renderPage(widget.pageIndex, scale: scale);
if (bytes != null && mounted) {
final image = await bytesToImage(bytes, width, height);
setState(() => _image = image);
}
}
@override
Widget build(BuildContext context) {
if (_image == null) return const Center(child: CircularProgressIndicator());
return RawImage(image: _image, fit: BoxFit.contain);
}
}Common errors
PDFium returns nullptr for every document
Cause: FPDF_InitLibrary() was never called. PDFium requires initialization before any other API call.
Fix: Call FPDF_InitLibrary() once at app startup, before opening any documents.
Rendered page is all black
Cause: You didn't fill the bitmap with a background color before rendering. PDFium renders onto the existing bitmap content — if it's zero-initialized (black), transparent areas stay black.
Fix: Call FPDFBitmap_FillRect with white (0xFFFFFFFF) before rendering.
Colors look wrong (blue faces, red sky)
Cause: PDFium renders in BGRA byte order. Flutter expects RGBA. If you display BGRA as RGBA, the red and blue channels are swapped.
Fix: Swap bytes [0] and [2] in each pixel, as shown in the bytesToImage function above.
Password-protected PDF won't open
Cause: You're passing nullptr as the password parameter to FPDF_LoadDocument.
Fix: Pass the password as a UTF-8 string. If the password is empty or wrong, FPDF_LoadDocument returns nullptr. Call FPDF_GetLastError() to get a specific error code (2 = wrong password, 3 = security handler error).
Rendering large pages causes OOM
Cause: A letter-size page at scale 4.0 is ~2,500 x 3,200 pixels = 32MB of RGBA data. A 10-page document rendered at high resolution uses 320MB.
Fix:
- Render at a lower scale for thumbnails (0.5 or 1.0)
- Only render visible pages (lazy loading)
- Dispose rendered images when pages scroll off-screen
- For zoom, render the visible viewport area only, not the entire page
Memory leak from unclosed documents/pages
Cause: FPDF_LoadPage and FPDF_LoadDocument allocate native memory. Forgetting to call the corresponding Close function leaks it.
Fix: Always pair opens with closes. Use try/finally:
final page = fpdfLoadPage(doc, index);
try {
// ... render ...
} finally {
fpdfClosePage(page);
}This is Post 16 of the FFI series. Next: Real-Time Audio With Opus.