Technical Specification

betto_pdfium

1 Overview

betto_pdfium is a pure-Dart package that wraps the PDFium C++ library via Dart FFI. The spec describes the public API, internal architecture, platform behaviour, and edge-case handling for each feature area.

1.1 Sections

1.1.1 PDFium Isolate Architecture

Internal architecture reference for contributors adding new features. Covers the PdfiumIsolate singleton, the typed command/response message protocol, the response class convention (.success/.failure named constructors), document tokens, memory management patterns, and UTF-16LE string handling.

1.1.2 Metadata Extraction

API for reading the standard PDF Info dictionary fields (title, author, subject, keywords, creator, producer, and both date fields) and document-level properties (file version, file identifiers). Works across all supported platforms.

1.1.3 Text Extraction

Streaming plain-text extraction from a PDF’s text layer via extractPlainText(). Covers the PdfPageText result type, the isPlainTextExtractable() heuristic, scanned-PDF and Unicode-error handling, and the v1 limitation on multi-column / RTL reading order.

1.1.4 Annotation Extraction

Streaming API for reading all PDF annotations from a document — highlights, sticky notes, underlines, ink drawings, shapes, links, and more — as a typed PdfAnnotation hierarchy. Native platforms only; covers the popup-inlining approach, the two-pass algorithm, and the fpdf_annot.h Experimental API caveat.

1.1.5 Table of Contents Extraction

Single Future-returning API for retrieving the bookmark/outline tree embedded in a PDF. Describes destination resolution (page, XYZ anchor, URI), cycle detection for malformed PDFs, and the deliberate omission of zoom values from XYZ destinations.

1.1.6 Page Rendering

API for rasterising a PDF page into raw BGRA bytes via renderPageToBytes(), plus the package:betto_pdf_widgets Flutter layer — the renderPage() extension that decodes to a dart:ui Image, and the PageView / PageViewer widgets that wrap it. Covers RenderOptions, the BGRA rendering pipeline inside the isolate, high-DPI scaling, caching behaviour, and in-flight cancellation.

1.1.7 Testing

How to run the Dart test suite, measure coverage, and execute mobile integration tests on iOS simulators and Android emulators. Covers the betto_pdfium_ios companion plugin’s role in iOS testing and the integration_test_app/ Flutter app.

1.1.8 Releasing

Release process for publishing betto_pdfium and betto_pdfium_ios to pub.dev. Covers lock-step versioning, the required publish order (betto_pdfium first), dry-run validation, and the rationale for keeping both packages at the same version.

2 PDFium Binary Distribution

Pre-built PDFium binaries are sourced from bblanchon/pdfium-binaries, a community-maintained set of cross-platform PDFium releases. This document is the authoritative contract between the upstream binary source and the main branch fetch mechanism.

2.1 Why bblanchon/pdfium-binaries

The original approach built PDFium from source via a bespoke pdfium-build CI pipeline. This was superseded by the following blocking problems:

bblanchon/pdfium-binaries provides community-tested dynamic libraries for all target platforms (macOS, Linux, iOS, Android, WASM, Windows). Adopting these removes the bespoke pipeline and fixes the above problems in one migration.

Supply-chain trade-off: adopting bblanchon trades a pipeline we control for a third-party release cadence. SHA-256 pinning in version_pdfium.json mitigates tampering; availability risk (bblanchon stops publishing) is accepted given the maintenance win. This is a documented, conscious decision.

2.2 bblanchon release structure

Release tag: chromium/NNNN (e.g. chromium/7906) Download URL: https://github.com/bblanchon/pdfium-binaries/releases/download/chromium%2FNNNN/<artifact>

Artifact Contents
pdfium-mac-arm64.tgz lib/libpdfium.dylib, include/*.h
pdfium-linux-x64.tgz lib/libpdfium.so, include/*.h
pdfium-linux-arm64.tgz lib/libpdfium.so, include/*.h
pdfium-ios-device-arm64.tgz lib/libpdfium.dylib (arm64 device)
pdfium-ios-simulator-arm64.tgz lib/libpdfium.dylib (arm64 simulator)
pdfium-android-arm64.tgz lib/libpdfium.so
pdfium-android-x64.tgz lib/libpdfium.so
pdfium-wasm.tgz lib/pdfium.wasm, lib/pdfium.js
pdfium-win-x64.tgz bin/pdfium.dll

Each tarball also contains VERSION (MAJOR=151 MINOR=0 BUILD=NNNN PATCH=0) and args.gn.

bblanchon does not publish separate .sha256 sidecar files. SHA-256 checksums are computed after download and pinned in version_pdfium.json. make update_pdfium_manifest automates this computation.

2.3 Installed layout

make fetch_pdfium installs the platform binary and public headers into third_party/ (both directories gitignored):

third_party/pdfium_bin/
  macos_arm64/
    libpdfium.dylib         ← loaded by Dart FFI on macOS arm64
  linux_x64/
    libpdfium.so            ← loaded by Dart FFI on Linux x86_64
  linux_arm64/
    libpdfium.so            ← loaded by Dart FFI on Linux arm64
  windows_x64/
    pdfium.dll              ← loaded by Dart FFI on Windows x64 (no lib prefix)
  VERSION                   ← single line: the installed bblanchon build number
third_party/pdfium/
  public/                   ← PDFium public headers (extracted from the platform tarball)
    fpdfview.h
    fpdf_doc.h
    fpdf_text.h
    …

The VERSION file contains a single line — the bare build number (e.g. 7906) with no trailing newline. make check_pdfium_version compares this against BBLANCHON_BUILD and verifies third_party/pdfium/public/ exists, failing with a clear error if either is missing or mismatched.

2.4 Fetch mechanism

scripts/fetch_pdfium.sh (invoked via make fetch_pdfium):

  1. Reads BBLANCHON_BUILD to determine the required bblanchon build number.
  2. Detects the host platform (uname -s / uname -m).
  3. If third_party/pdfium_bin/VERSION already matches and third_party/pdfium/public/ exists, exits immediately (idempotent).
  4. Reads the expected SHA-256 from version_pdfium.json for the platform.
  5. Downloads the bblanchon tarball with curl.
  6. Verifies the SHA-256 of the tarball before extraction.
  7. Extracts lib/libpdfium.{dylib,so} from the verified tarball.
  8. Installs the binary to INSTALL_DIR.
  9. On macOS: ad-hoc signs the dylib with codesign --force --sign - so dlopen() succeeds without Gatekeeper quarantine errors.
  10. Extracts the include/ directory from the same tarball into third_party/pdfium/public/.
  11. Writes third_party/pdfium_bin/VERSION.

2.5 Checksum verification

bblanchon does not publish sidecar .sha256 files. Checksums are computed by make update_pdfium_manifest after downloading each tarball, and stored in version_pdfium.json. Verification uses shasum -a 256 on macOS and sha256sum on Linux.

The SHA-256 is over the tarball (.tgz), not the extracted library. Verification happens before extraction to prevent a corrupt download from being extracted even partially.

2.6 Native-assets hook

hook/build.dart is the Dart native-assets build hook for betto_pdfium. It runs automatically when dart build, dart run, or dart test is invoked (including by downstream packages). It:

  1. Reads version_pdfium.json from the package root to determine the platform download URL, lib path within the tarball, and expected SHA-256.
  2. Checks a per-version cache at .dart_tool/betto_pdfium/{bblanchon_build}/ — if the binary is present and the SHA-256 sidecar matches the tarball hash, the download is skipped (fast path).
  3. Downloads the bblanchon .tgz tarball for the target platform.
  4. Verifies SHA-256 of the tarball before extraction — the checksum is over the whole tarball, not the extracted library.
  5. Atomically renames the verified tarball.
  6. Extracts the shared library (lib_path field) from the tarball.
  7. On macOS: strips com.apple.quarantine and related xattrs via xattr -c so dlopen() and Flutter’s bundler work without Gatekeeper errors.
  8. Emits a CodeAsset with DynamicLoadingBundled link mode so the build system bundles the binary alongside the executable.

2.6.1 Platform manifest — version_pdfium.json

version_pdfium.json at the package root is the single source of truth for download URLs, lib paths, and SHA-256 digests. It must be updated whenever BBLANCHON_BUILD is bumped. scripts/update_pdfium_manifest.sh (run via make update_pdfium_manifest) downloads each tarball, computes the SHA-256, and rewrites this file automatically.

The manifest schema:

{
  "bblanchon_build": "NNNN",
  "platforms": {
    "macos-arm64": {
      "url": "https://github.com/bblanchon/pdfium-binaries/.../pdfium-mac-arm64.tgz",
      "lib_path": "lib/libpdfium.dylib",
      "sha256": "<sha256 of .tgz>"
    },
    "linux-x64":    { "url": "...", "lib_path": "lib/libpdfium.so",    "sha256": "..." },
    "linux-arm64":  { "url": "...", "lib_path": "lib/libpdfium.so",    "sha256": "..." },
    "windows-x64":  { "url": "...", "lib_path": "bin/pdfium.dll",      "sha256": "..." },
    "android-arm64":{ "url": "...", "lib_path": "lib/libpdfium.so",    "sha256": "..." },
    "android-x64":  { "url": "...", "lib_path": "lib/libpdfium.so",    "sha256": "..." },
    "wasm": {
      "url": "https://github.com/bblanchon/pdfium-binaries/.../pdfium-wasm.tgz",
      "lib_paths": ["lib/pdfium.wasm", "lib/pdfium.js"],
      "sha256": "<sha256 of .tgz>"
    }
  }
}

Notes: - iOS is excluded from the manifest — the xcframework is referenced from Package.swift (downloaded by SPM, not the hook). - lib_path is the path within the tarball to the shared library (single-file platforms). - lib_paths is used for multi-file artifacts (WASM only). It supersedes lib_path when present. - SHA-256 is over the .tgz file, not the extracted library.

Consumer mapping:

Platform key Consumer Purpose
macos-arm64 hook/build.dart Native-assets dylib staging
linux-arm64 hook/build.dart Native-assets .so staging
linux-x64 hook/build.dart Native-assets .so staging
windows-x64 hook/build.dart Native-assets DLL staging
android-arm64 fetch_mobile_binaries.sh Android integration test app only
android-x64 fetch_mobile_binaries.sh Android integration test app only
wasm fetch_wasm_assets.sh Flutter web / dart2wasm assets

lib/src/pdfium_version.dart exports two constants:

2.6.2 Unsupported platforms (hook)

Platform Status Notes
iOS Hook skipped Dynamic xcframework via SPM binaryTarget; DynamicLibrary.process() at runtime
Android Hook skipped .so in jniLibs/ via fetch_mobile_binaries.sh; DynamicLibrary.open('libpdfium.so') at runtime
Windows Supported pdfium.dll staged via hook/build.dart like macOS/Linux; no codesign step (not applicable on Windows)
Web/WASM Hook skipped Static file assets; distributed via fetch_wasm_assets.sh (see below)

2.7 Web (WASM) assets

bblanchon ships pdfium-wasm.tgz alongside every chromium/NNNN release. The tarball contains two files: lib/pdfium.wasm (the WebAssembly binary) and lib/pdfium.js (the Emscripten glue). No Emscripten build is required.

2.7.1 Emscripten build details (verified from bblanchon chromium/7906)

2.7.2 Distribution mechanism

betto_pdfium is a pure-Dart package with no Flutter dependency; it cannot declare Flutter web assets on the user’s behalf. WASM assets — now a trio, not a pair — are distributed as a developer-side static file copy:

  1. Run make fetch_wasm_assets (or scripts/fetch_wasm_assets.sh directly). This downloads pdfium-wasm.tgz, verifies its SHA-256, and extracts pdfium.js and pdfium.wasm to integration_test_app/web/assets/pdfium/ (default; override via WASM_OUTPUT_DIR). The same step also (re-)copies betto_pdfium’s own checked-in lib/assets/pdfium_worker.js — the compiled PDFium Worker entry point (see “Web Worker offload” below) — into the same output directory, unconditionally, independent of the bblanchon-build idempotency check that gates the tarball download.
  2. Copy the extracted files to your Flutter web app’s web/assets/pdfium/ directory (or run the script with WASM_OUTPUT_DIR pointing there). All three files — pdfium.js, pdfium.wasm, pdfium_worker.js — must be co-located.
  3. The _document_web.dart backend spawns a dedicated Worker from the relative URL assets/pdfium/pdfium_worker.js at the app origin, which in turn loads assets/pdfium/pdfium.js via importScripts().

Run this once per PDFium version bump, or in CI before a web build. The tarball-download step is idempotent — it skips extraction if the target directory already holds the correct build number — but the pdfium_worker.js copy always runs, so a locally rebuilt worker (via make build_wasm_worker, a maintainer-only step) is always picked up.

2.7.3 Web Worker offload

PDFium WASM work no longer runs on the browser main thread. All PDFium calls are dispatched to a dedicated Worker (pdfium_worker.js, compiled from lib/src/document/_pdfium_worker_entry.dart via dart compile js) over a hand-rolled postMessage request/response protocol — dart:isolate is not usable on web (confirmed against the Flutter and Dart docs; isolates are unsupported on all web compile targets, and compute() on web runs on the main thread). See spec/02_pdfium_isolate.md’s “Web Worker concurrency model” section for the full protocol description.

Consequences for distribution specifically:

2.8 iOS xcframework

bblanchon provides separate tarballs for the iOS device and simulator slices. We repack them into a single pdfium.xcframework hosted on the bettongia/pdfium GitHub Release tagged bblanchon-chromium-<BUILD>.

2.8.1 Repack process (make repack_ios_xcframework)

scripts/repack_ios_xcframework.sh:

  1. Downloads pdfium-ios-device-arm64.tgz and pdfium-ios-simulator-arm64.tgz.
  2. Extracts lib/libpdfium.dylib from each.
  3. Renames each dylib to pdfium (frameworks use the bare name without lib prefix or extension).
  4. Patches the install name: install_name_tool -id @rpath/pdfium.framework/pdfium pdfium.framework/pdfium
  5. Writes a minimal Info.plist for each pdfium.framework/ bundle: CFBundleExecutable, CFBundleIdentifier, MinimumOSVersion, CFBundleSupportedPlatforms.
  6. Runs xcodebuild -create-xcframework to combine device + simulator frameworks.
  7. Zips the result into pdfium.xcframework.zip and prints the SHA-256.
  8. Uploads to bettongia/pdfium GitHub Releases tagged bblanchon-chromium-<BUILD>.

2.8.2 SPM package (Package.swift)

packages/betto_pdfium_ios/ios/betto_pdfium_ios/Package.swift declares a two-target chain:

targets: [
    .target(
        name: "betto_pdfium_ios",
        dependencies: ["pdfium_binary"],
        path: "Sources/PdfiumIos",
    ),
    .binaryTarget(
        name: "pdfium_binary",
        url: "<bettongia/pdfium release URL>/pdfium.xcframework.zip",
        checksum: "<sha256 of xcframework zip>",
    ),
]

Because the xcframework contains dynamic frameworks (not static archives), Xcode automatically embeds them in the app bundle — no force-load flags or anchor workarounds are required. DynamicLibrary.process() locates all PDFium symbols at runtime because the embedded dynamic framework is loaded into the process image at launch.

Run make update_pdfium_manifest after make repack_ios_xcframework to update Package.swift with the new URL and checksum.

2.9 Android shared libraries

integration_test_app/scripts/fetch_mobile_binaries.sh downloads the Android .tgz tarballs from bblanchon, verifies SHA-256, extracts lib/libpdfium.so, and places the files in:

android/app/src/main/jniLibs/arm64-v8a/libpdfium.so
android/app/src/main/jniLibs/x86_64/libpdfium.so

Flutter’s Gradle build picks up jniLibs/ automatically. At runtime, DynamicLibrary.open('libpdfium.so') resolves the library by its bare name (the OS loads it from the APK’s lib/{abi}/ directory).

2.10 Bumping the bblanchon version

A single-commit workflow (no CI pipeline to wait for):

  1. Update BBLANCHON_BUILD with the new bblanchon build number.
  2. Run make repack_ios_xcframework — downloads bblanchon iOS tarballs, builds the pdfium.xcframework, and uploads it to a new bettongia/pdfium release tagged bblanchon-chromium-<NEW_BUILD>.
  3. Run make update_pdfium_manifest — downloads each bblanchon tarball (including pdfium-wasm.tgz), computes SHA-256s, rewrites version_pdfium.json (including the wasm entry) and lib/src/pdfium_version.dart, and updates Package.swift.
  4. Run make fetch_pdfium to install the new binary and headers locally.
  5. Run make fetch_wasm_assets to install the new WASM assets locally.
  6. Run make ffi_bindings if the bblanchon headers differ from the previous release (PDFium’s public API is stable but occasionally updated).
  7. Commit BBLANCHON_BUILD, version_pdfium.json, lib/src/pdfium_version.dart, Package.swift, and any regenerated lib/src/generated/pdfium_bindings.dart.

3 PDFium Isolate Architecture

3.1 Overview

PDFium is not thread-safe. All FFI calls into the native library must happen on a single, dedicated OS thread. In Dart, that thread is owned by a dedicated IsolatePdfiumIsolate — that runs for the lifetime of the process. All PdfDocument instances share it; the caller’s isolate (typically the UI isolate) communicates with it via typed message-passing and is never blocked.

PdfiumIsolate is a process-wide singleton. It is lazily spawned on the first PdfDocument.fromBytes() call. Do not spawn a second isolate, and do not call FPDF_InitLibraryWithConfig() more than once — doing so is a correctness bug.

This describes the native backend (macOS, Linux, iOS, Android, Windows). See “Web Worker concurrency model” below for the equivalent web (WASM) architecture — the shape is the same (a single dedicated execution context owns all PDFium state; the caller communicates via typed messages and is never blocked), but the underlying mechanism is necessarily different on web.

3.2 Web Worker concurrency model

PDFium is not thread-safe on web either, and WASM linear memory is private to whichever thread instantiated the module — so, just as on native, all PDFium work for the whole page must happen in one place. Unlike native, that place cannot be a dart:isolate Isolate: isolates are not supported on any web compile target (confirmed against both the Flutter isolates doc and the Dart concurrency doc; compute() on web runs on the main thread, not a background one). The web backend therefore uses a dedicated Worker (package:web) with a hand-rolled postMessage protocol instead — mirroring the shape of the native isolate architecture above (single owner of PDFium state, typed request/response messages, opaque document tokens) while replacing the mechanism entirely.

See plan_wasm_web_worker_offload.md for the full design investigation and rationale.

3.2.1 Components

3.2.2 One shared Worker per page

A single Worker is spawned lazily on the first PdfDocument.fromBytes() call and reused by every subsequently opened document, multiplexed over it via opaque integer tokens the worker assigns — directly mirroring native’s one-isolate-per-process model. This avoids N× WASM module instantiation cost (~5.2 MB each) for apps with multiple documents open at once, at the cost of documents queuing behind one another’s in-flight worker requests rather than running in true parallel (PDFium is not thread-safe regardless, so this matches the native model’s own trade-off).

3.2.3 Request/response correlation and per-token ordering

Each request carries a monotonically increasing integer id; the client keeps a Map<int, Completer<WorkerResponse>> of in-flight requests, resolved from a single shared onmessage handler when the matching response arrives. This replaces what SendPort/ReceivePort give for free to the native isolate.

Every request for a given document token is additionally serialized through a per-token request queue (_sendForToken in _document_web.dart) — each request is chained onto the previous one for that same token. This guarantees a close() call is never processed by the worker while an earlier request for the same document is still in flight, and vice versa, without requiring any explicit locking inside the worker itself (the queue lives entirely on the main-thread client side). Requests for different tokens are not serialized against each other and may interleave.

3.2.4 Streaming operations: one round trip, not one message per page

Unlike a page-by-page message exchange, the streaming operations (extractPlainText, extractAnnotations, extractImages, search) fetch all requested pages in a single worker round trip — the worker computes them synchronously in one dispatch, since there is no await boundary between pages inside the worker (PDFium calls are synchronous). The main-thread client then yields the already-fetched results locally via Future.delayed(Duration.zero) between items, preserving the public Stream API’s cooperative-yielding shape (and, for search, inserting a yield point whenever the source page changes) without needing a multi-message streaming sub-protocol.

3.2.5 Transferable buffers and the detach caveat

BGRA bitmap results (renderPageToBytes, getThumbnail, renderImage, and extractImages(includeBitmap: true)) are transferred back from the worker as ArrayBuffers via postMessage’s transfer-list parameter, rather than embedded in the JSON payload or copied — this matters for the multi-megabyte buffers these operations can return.

A transferred ArrayBuffer is neutered on the sender side once the transfer completes. The one place this had to be handled deliberately: PdfDocument.fromBytes(bytes) does not transfer the caller-supplied bytes buffer — only worker-generated output buffers are transferred. If bytes were transferred, a caller reusing the same buffer for a second fromBytes() call (or simply expecting to still be able to read it afterwards) would see it silently neutered as an unexpected side effect of the first call. WorkerRequest.transferBuffers (default true) exists specifically so fromBytes()’s request can opt out and use a structured-clone copy instead, matching the native backend’s copy-not-move semantics for caller-supplied input.

3.2.6 Memory management

The WASM heap (and therefore the PDF byte buffer + PDFium document handle) lives entirely inside the worker. A main-thread Finalizer (backed by FinalizationRegistry) remains as a safety net against forgotten close() calls, but its callback can no longer free memory directly — it posts a fire-and-forget “close” request for the garbage-collected document’s token to the worker instead, which performs the actual FPDF_CloseDocument/free calls there. The response to that fire-and-forget request has no registered Completer and is silently ignored when it arrives.

3.2.7 Coverage note

Code executing inside a spawned Worker runs in a separate Chrome DevTools Protocol target that dart test -p chrome --coverage’s collector cannot instrument (confirmed by reading the pinned test-1.31.2 chrome.dart source directly — coverage collection attaches only to a single tab connection with no worker-target discovery logic). Consequently:

3.3 Command/Response protocol

All messages are typed Dart classes defined in isolate_messages.dart.

3.3.1 Commands

Every command extends the sealed base class PdfiumCommand, which carries a replyPort — the SendPort on which the isolate sends its response:

sealed class PdfiumCommand {
  const PdfiumCommand(this.replyPort);
  final SendPort replyPort;
}

Each command is a plain const-constructible class. Fields are named and documented. The replyPort is always the first constructor argument.

3.3.2 Responses

Every response extends the sealed base class PdfiumResponse:

sealed class PdfiumResponse {
  const PdfiumResponse();
}

3.3.2.1 Response class convention

For operations that can fail, use a single response class with .success(…) and .failure(…) named constructors and an isSuccess getter. Do not create separate success/failure subclasses.

class PdfiumExampleResponse extends PdfiumResponse {
  /// Creates a successful response.
  const PdfiumExampleResponse.success(this.result) : error = null;

  /// Creates a failed response.
  const PdfiumExampleResponse.failure(this.error) : result = null;

  /// The result, or `null` on failure.
  final SomeType? result;

  /// The error that occurred, or `null` on success.
  final PdfError? error;

  /// Whether this response represents a successful operation.
  bool get isSuccess => error == null;
}

Payload fields are nullable; they are null on the opposite outcome. Callers check isSuccess (or error == null) before accessing the payload. For responses that carry multiple success fields, make those fields private and expose them via non-nullable getters that assert (!) — callers only reach those getters after checking isSuccess, so the assertion never fires in correct code.

Responses for operations that cannot fail (e.g. PdfiumCloseDocumentResponse) need no named constructors — a plain const constructor is sufficient.

3.3.3 Document tokens

PdfiumLoadDocumentCommand returns an opaque int token representing the live FPDF_DOCUMENT handle inside the isolate. All subsequent per-document commands carry this token. The token is only meaningful inside the isolate; it is never a valid pointer in the caller’s address space.

3.4 Adding a new operation

  1. Define the command — extend PdfiumCommand, document all fields, put replyPort first, keep fields final.

  2. Define the response — extend PdfiumResponse with .success(…) and .failure(…) named constructors and an isSuccess getter (see convention above).

  3. Add a dispatch branch — add an else if (message is YourCommand) branch in the message handler loop in pdfium_isolate.dart. Wrap all PDFium handle lifecycle in a try/finally to guarantee cleanup (see below).

  4. Implement the public method — send the command, await the reply port, cast the response, and propagate errors via the established PdfError exception path.

  5. Stub and web — add the method to _document_stub.dart and _document_web.dart. _document_stub.dart must throw UnsupportedError — not UnimplementedError — to signal that the fallback platform does not support the operation, consistent with all other unsupported-platform methods in this codebase. _document_web.dart should implement the operation via a WorkerOp round-trip to the PDFium WASM worker rather than throwing, unless the underlying PDFium WASM build genuinely cannot support it.

3.5 Memory management inside the isolate

Every PDFium handle has a matching Close or Destroy function. Dart’s garbage collector does not call these. Inside the isolate handler, always close page-level and text-level handles in a try/finally:

final textPage = bindings.FPDFText_LoadPage(doc, pageIndex);
try {
  // ... work with textPage ...
} finally {
  bindings.FPDFText_ClosePage(textPage);
}

Document handles (FPDF_DOCUMENT) are closed by PdfiumCloseDocumentCommand and must not be closed elsewhere.

3.6 UTF-16LE strings (FPDF_WIDESTRING)

Several PDFium APIs accept or return FPDF_WIDESTRING — a null-terminated UTF-16LE C string. The established pattern in pdfium_isolate.dart (used by the TOC implementation) is:

  1. Encode the Dart String to a Uint16List (UTF-16LE code units).
  2. Allocate a native buffer with calloc<Uint16>(codeUnits.length + 1) — the +1 provides the null terminator; calloc zero-fills.
  3. Copy the code units into the buffer.
  4. Pass the buffer pointer as FPDF_WIDESTRING.
  5. Free the buffer with calloc.free(ptr) in a try/finally.

Reuse the existing helper in pdfium_isolate.dart rather than duplicating this pattern.

3.7 Coordinate system

PDFium uses PDF user space: origin at the bottom-left of the page, units in points (1/72 inch). Flutter and most UI frameworks use an origin at the top-left. Use FPDF_PageToDevice() / FPDF_DeviceToPage() for all coordinate conversions. Expose raw PDF coordinates in public API types and document the coordinate system clearly — callers are responsible for any display transform.

4 Text Extraction

4.1 Overview

The text extraction API allows a caller to extract plain Unicode text from a PDF document. It works across all supported platforms — iOS, Android, macOS, Windows, Linux, and web — without requiring platform-specific code from the caller. The primary use case is feeding extracted text into a search index.

4.2 Public API

4.2.1 PdfTextExtractorConfig

Configuration for the heuristics used to classify documents.

Property Type Default Description
scannedPageRatio double 0.5 Fraction of pages that must have no text layer for isPlainTextExtractable() to return false.

A single image or figure page in an otherwise text-based document will not trigger isPlainTextExtractable() returning false at the default ratio of 0.5.

4.2.2 PdfDocument.fromBytes(Uint8List bytes)

Static factory. Accepts raw PDF bytes and returns a PdfDocument. Throws PdfExtractionException(PdfError.invalidDocument) if the document is corrupt or not a valid PDF, or PdfExtractionException(PdfError.passwordRequired) if the document is password-protected.

4.2.3 Text extraction methods on PdfDocument

Member Description
pageCount Future<int> — total number of pages.
extractPlainText({int? pageIndex, PdfTextExtractorConfig config}) Stream<PdfPageText> — yields all pages when pageIndex is null, or exactly one page when specified.
isPlainTextExtractable({PdfTextExtractorConfig config}) Future<bool> — returns false when the proportion of pages without a text layer meets or exceeds scannedPageRatio.
close() Release all resources. Safe to call more than once. Terminates any active extractPlainText() stream.

4.2.4 PdfPageText

Immutable result for a single page.

Property Type Description
pageIndex int 0-based page index.
text String Extracted Unicode text in PDFium’s native extraction order.
hasTextLayer bool True when PDFium extracted at least one character from the page.
hasUnicodeErrors bool True when one or more characters had no Unicode mapping.

4.2.5 Stream lifecycle

Cancelling the extractPlainText() subscription immediately releases all page-level native/WASM resources. PdfDocument.close() terminates any active extractPlainText() stream and releases its page-level handles before closing the document handle. Callers do not need to cancel streams manually before calling close().

4.3 Behaviour by scenario

Scenario Behaviour
Scanned page hasTextLayer false, text empty, no exception. isPlainTextExtractable() returns false when ratio exceeded.
Unmapped character Silently omitted by PDFium; hasUnicodeErrors true on that page.
Soft hyphen Detected and stripped; adjacent word fragments are joined.
Multi-column text Native PDFium extraction order (see Limitations).
RTL text Native PDFium extraction order (see Limitations).
Password-protected PDF PdfExtractionException(PdfError.passwordRequired).
Corrupt / non-PDF bytes PdfExtractionException(PdfError.invalidDocument).
Page index out of range RangeError.

4.4 Platform notes

On native platforms (iOS, Android, macOS, Windows, Linux) all PDFium calls run on a dedicated PdfiumIsolate — a process-wide singleton that owns the PDFium library handle and serialises all FFI calls. The caller’s isolate (typically the UI isolate) is never blocked. On web, PDFium is compiled to WebAssembly and runs inside a dedicated Worker, not the browser’s main thread — see “Web: Worker offload” below.

4.5 Limitations

4.5.1 Web: Worker offload

Status: implemented. See plan_wasm_web_worker_offload.md and spec/02_pdfium_isolate.md’s “Web Worker concurrency model” section for the full design.

All PDFium WASM calls, including extractPlainText(), now run inside a dedicated Worker rather than the browser main thread — dart:isolate is not usable on web, so the web backend uses a hand-rolled Worker + postMessage RPC protocol mirroring the shape of the native isolate model. The extractPlainText() stream still yields between pages locally via Future.delayed(Duration.zero) after the worker returns its results, to preserve the cooperative-yielding shape of the public Stream API, but the underlying PDFium work no longer contends with the main thread’s own event loop or rendering.

The layout-aware reordering work (plan_layout_aware_reordering.md) remains a separate, future item — it is about extraction order (see “Text extraction order” below), not about threading. The two were previously expected to land together to avoid revisiting the web architecture twice; that coupling no longer applies now that Worker offload has landed on its own.

4.5.2 Text extraction order

Status: v1 limitation; remediation planned.

Text is returned in PDFium’s native content-stream order, which does not always match visual reading order. Multi-column documents and RTL text (Arabic, Hebrew) are most affected. For search indexing this is generally acceptable; for use cases requiring correct reading order, see plan_layout_aware_reordering.md.

4.5.3 Scanned PDFs

No OCR capability. Pages without a text layer return an empty string with hasTextLayer: false. External OCR must be applied before extraction if text content is required.

4.5.4 Password-protected PDFs

Not supported in v1. Password-protected documents surface as PdfError.passwordRequired, which is distinct from PdfError.invalidDocument (used for corrupt or non-PDF bytes) so callers can give users a meaningful error message.

5 Table of Contents Extraction

5.1 Overview

The Table of Contents (TOC) extraction API allows a caller to retrieve the bookmark/outline tree embedded in a PDF document. PDFs use an “Outline” dictionary as their native TOC structure; each entry has a display title and an optional destination — an internal page index, an XYZ scroll anchor, or a URI. Entries nest arbitrarily deeply to form a tree.

The API is a single Future-returning property on PdfDocument — no streaming is needed because the entire bookmark tree is a small, bounded data structure. The resulting tree is returned as a List<PdfTocEntry> whose elements may each carry a children list of their own PdfTocEntry values.

This feature belongs to the pure-Dart entry point (package:betto_pdfium/betto_pdfium.dart) and has no dependency on dart:ui or Flutter.

5.2 Public API

5.2.1 PdfDocument.tableOfContents

Future<List<PdfTocEntry>> get tableOfContents;

Returns the root-level bookmark entries. Each entry may carry child entries accessible via PdfTocEntry.children.

Returns an empty list when the document has no bookmarks. Never throws for a well-formed open document.

Throws StateError if called after PdfDocument.close().

Platform support: Native (dart:ffi) and web (PDFium WASM via the worker). On the fallback stub platform, tableOfContents throws UnsupportedError immediately.

5.2.2 PdfTocEntry

Immutable value type representing a single bookmark entry.

Property Type Description
title String Display title of the bookmark. May be empty for bookmarks with no title text.
pageIndex int? Zero-based page index this entry navigates to, or null if no internal-page destination is present.
uri String? URI string for PDFACTION_URI bookmarks, or null for all other entry types.
scrollPosition PdfPoint? XYZ scroll anchor within the destination page (PDF user space, bottom-left origin), or null if the destination does not carry explicit position coordinates.
children List<PdfTocEntry> Nested child entries in document order. Empty for leaf entries.

PdfTocEntry implements ==, hashCode, and toString(). Equality is deep-recursive over children.

5.2.2.1 Zoom omission

FPDFDest_GetLocationInPage returns an (x, y, zoom) triple for PDFDEST_VIEW_XYZ destinations. The zoom value is intentionally not surfaced. Exposing zoom risks overriding the user’s OS accessibility zoom settings or Flutter’s textScaleFactor, which would create a hostile experience for users who rely on display magnification. Only the (x, y) scroll anchor is captured via scrollPosition.

5.3 Destination resolution

A bookmark’s target is resolved by the following algorithm inside the PDFium isolate:

  1. Call FPDFBookmark_GetAction. If the action is non-null, inspect FPDFAction_GetType:
    • PDFACTION_GOTO (1): call FPDFAction_GetDest → page index via FPDFDest_GetDestPageIndex. Optionally extract an XYZ scroll position.
    • PDFACTION_URI (3): call FPDFAction_GetURIPathuri string.
    • All other action types (PDFACTION_REMOTEGOTO, PDFACTION_LAUNCH, PDFACTION_EMBEDDEDGOTO, PDFACTION_UNSUPPORTED): both pageIndex and uri are null.
  2. If the action is null, call FPDFBookmark_GetDest directly → page index.
  3. If both the action and the direct destination are null, the entry is a section label with no navigation target. Both pageIndex and uri are null.

FPDFDest_GetDestPageIndex returning -1 is treated as pageIndex = null.

5.4 Tree walk

The tree is walked recursively inside the PDFium isolate using FPDFBookmark_GetFirstChild and FPDFBookmark_GetNextSibling. Passing a null pointer as the bookmark argument to FPDFBookmark_GetFirstChild retrieves the root-level entries.

Cycle detection: a Set<int> of visited raw pointer addresses guards against malformed PDFs that contain cycles in the bookmark dictionary. When a previously-seen handle address is encountered, recursion stops without emitting that entry.

FPDFBookmark_GetCount: this function returns -1 for an unknown child count. It is not used to pre-size lists; GetFirstChild/GetNextSibling drive traversal unconditionally.

5.5 Isolate boundary

The complete List<PdfTocEntry> tree is built inside the PDFium isolate and deep-copied to the calling isolate by Dart’s standard message-passing serialisation. This is safe and correct for the bounded sizes of typical PDF bookmark trees (hundreds to low thousands of entries at most).

5.6 Behaviour by scenario

Scenario Behaviour
No bookmarks Returns an empty list. No error.
Section-label entry (no dest, no action) pageIndex == null, uri == null, scrollPosition == null. Entry is included in the tree.
URI action entry uri is non-null, pageIndex == null.
GOTO action entry pageIndex is the zero-based page index. scrollPosition is set when the dest carries XYZ coordinates.
Remote / launch / embedded action pageIndex == null, uri == null. Entry is included with those null fields.
FPDFBookmark_GetTitle returns empty buffer title is an empty string; the entry is still included.
FPDFDest_GetDestPageIndex returns -1 pageIndex == null.
FPDFDest_GetLocationInPage returns FALSE scrollPosition == null. The entry is still included.
Cycle in bookmark tree Recursion stops at the repeated handle. The cyclic entry is silently omitted.
tableOfContents after close() Throws StateError.
Stub platform Throws UnsupportedError.

5.7 Platform notes

On native platforms all PDFium calls run on the PdfiumIsolate — the process-wide singleton that serialises all FFI calls. The caller’s isolate is never blocked. On web, tableOfContents runs inside the dedicated Web Worker hosting the PDFium WASM build.

5.8 bin/pdfinfo.dart CLI

The --toc flag causes pdfinfo to call tableOfContents and print the bookmark tree to stdout. Output format (plain text):

--- Table of Contents ---
  Chapter 1 → page 1
  Chapter 2 → page 3
    Section 2.1 → page 4
  Appendix

In JSON mode (--json --toc), a "toc" key is added to the root object. Each entry is a JSON object with "title", optional "pageIndex" (0-based), optional "uri", optional "scrollPosition" ({"x": …, "y": …}), and optional "children" array. Omitting --toc omits the "toc" key entirely.

6 Metadata Extraction (Info Dictionary)

6.1 Overview

The metadata extraction API allows a caller to read the standard PDF Info dictionary fields from a loaded document. It works across all supported platforms — iOS, Android, macOS, Windows, Linux, and web — without requiring platform-specific code from the caller.

6.2 Public API

6.2.1 PdfDocument

The top-level abstraction for a loaded PDF file. All document-level operations are methods on this class.

6.2.1.1 PdfDocument.fromBytes(Uint8List bytes)

Factory that loads a PDF from raw bytes.

Throws PdfExtractionException with:

6.2.1.2 getMetadata()Future<PdfMetadata>

Returns all eight standard Info dictionary fields in a single round-trip.

6.2.1.3 getDocumentInfo()Future<PdfDocumentInfo>

Returns document-level properties: PDF file version and file identifiers (permanent and changing), in a single round-trip.

6.2.1.4 close()Future<void>

Releases the native PDFium document handle. Safe to call more than once. After close() returns, getMetadata() and getDocumentInfo() throw StateError.

6.2.2 PdfMetadata

Immutable value object returned by getMetadata(). All fields are nullable; a null value means the field was not present in the Info dictionary (as opposed to being present but empty — these are distinct states in the PDF specification).

Field Type PDF Tag Description
title String? Title Document title
author String? Author Author name(s)
subject String? Subject Subject or description
keywords String? Keywords Comma-separated keywords
creator String? Creator Application that created the original
producer String? Producer Application that converted to PDF
creationDate PdfDate? CreationDate Date and time the document was created
modDate PdfDate? ModDate Date and time of last modification

6.2.3 PdfDate

Holds both the raw string and the parsed DateTime for date fields.

Property Type Description
raw String The raw string as stored in the PDF Info dictionary
value DateTime? Parsed UTC DateTime, or null if parsing failed

The PDF date format is D:YYYYMMDDHHmmSSOHH'mm' (ISO 8601-like but distinct). The D: prefix is optional. Truncated formats (e.g. date-only) are handled. When parsing fails, value is null and raw is preserved for debugging.

6.2.4 PdfDocumentInfo

Immutable value object returned by getDocumentInfo().

Property Type Description
fileVersion int? PDF file version as an integer (e.g. 17 for PDF 1.7)
permanentId Uint8List? Permanent file identifier bytes (typically 16-byte MD5)
changingId Uint8List? Changing file identifier bytes (typically 16-byte MD5)

File identifiers are raw bytes. To obtain a hex string:

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

6.2.5 PdfError

Enum of error reasons surfaced via PdfExtractionException.

Value Meaning
invalidDocument Bytes are corrupt, truncated, or not a valid PDF
passwordRequired Document is password-protected; passwords not supported

6.2.6 PdfExtractionException

Thrown when a PDF operation fails. Holds a PdfError in its error field.

6.3 Behaviour by scenario

Scenario Behaviour
Field not in Info dictionary null on the corresponding PdfMetadata field
PDF has no Info dictionary All PdfMetadata fields are null
Malformed date string PdfDate.value is null; PdfDate.raw is preserved
Password-protected PDF PdfExtractionException(PdfError.passwordRequired)
Corrupt or non-PDF bytes PdfExtractionException(PdfError.invalidDocument)
File identifiers absent PdfDocumentInfo.permanentId and .changingId are null
close() called twice Second call is a no-op; no exception
Method called after close() StateError is thrown

6.4 Platform notes

On native platforms (iOS, Android, macOS, Windows, Linux) all PDFium calls run on a dedicated Isolate (the PdfiumIsolate singleton). The caller’s isolate is never blocked.

On web, PDFium is compiled to WebAssembly and runs inside a dedicated Worker, not the browser main thread — see spec/02_pdfium_isolate.md’s “Web Worker concurrency model” section and plan_wasm_web_worker_offload.md.

6.5 Limitations

6.5.1 Passwords not supported

Password-protected documents cannot be opened in v1. The passwordRequired error allows callers to surface a clear message to the user. Support for user-password-protected documents is deferred to a future plan.

6.5.2 XMP metadata not included

This API covers only the Info dictionary. XMP metadata (the richer modern format) is deferred to plan_xmp_metadata_extraction.md (v0.05). When XMP is not present, the Info dictionary is the fallback.

6.5.3 FPDF_GetDocPermissions not exposed

Document permissions (edit, print, copy restrictions) are not surfaced in v1. They are deferred to a future plan focused on encryption and DRM.

6.6 Developer CLI

A developer tool is available for inspecting real-world PDF files:

dart run bin/pdfinfo.dart <path-to-pdf>

Prints all Info dictionary fields and document properties in a readable key/value format. Date fields show both the parsed ISO 8601 value and the original raw string. File identifiers are shown as hex strings. Errors produce distinct, actionable messages with a non-zero exit code.

7 Annotation Extraction

7.1 Overview

The annotation extraction API allows a caller to read all PDF annotations from a document. It surfaces reader-workflow artefacts — highlights, sticky notes, underlines, free-text comments, ink drawings, shapes, links, and more — as a typed Dart object hierarchy.

Annotation extraction is implemented via fpdf_annot.h, which is marked // Experimental API. throughout the PDFium headers. All FFI bindings are kept behind a Dart abstraction layer so that upstream signature changes are localised to the implementation files and do not break callers.

Platform availability: native platforms (iOS, Android, macOS, Windows, Linux) and web (PDFium WASM via the worker). The stub backend throws UnsupportedError. Form-field annotations (FPDF_ANNOT_WIDGET, FPDF_ANNOT_XFAWIDGET) are out of scope for v0.02 and are earmarked for a dedicated form-extraction plan.

7.2 Public API

7.2.1 PdfDocument.extractAnnotations({int? pageIndex})

Returns Stream<PdfPageAnnotations>. When pageIndex is null (the default) the stream yields one PdfPageAnnotations per page in index order — including pages with zero annotations, so callers can track full page coverage. When pageIndex is provided the stream yields exactly one entry for that page.

Calling PdfDocument.close() while a stream is active terminates the stream immediately and releases all page-level annotation handles. Callers do not need to cancel the stream subscription before calling close().

A RangeError is thrown when pageIndex is provided but is outside [0, pageCount).

A StateError is thrown if the document has already been closed.

7.2.2 PdfPageAnnotations

Immutable result for a single page.

Property Type Description
pageIndex int 0-based page index.
annotations List<PdfAnnotation> All annotations on this page. Empty when the page has no annotations.

7.2.3 Annotation type hierarchy

All annotation types extend the sealed base class PdfAnnotation.

7.2.3.1 PdfAnnotation (sealed base)

Property Type Description
pageIndex int 0-based index of the page this annotation belongs to.
contents String? Text content (/Contents entry). null when the key is absent; "" when present but empty — the two cases are intentionally distinguishable.
author String? Author (/T entry).
rect PdfRect? Bounding rectangle in PDF page coordinates (bottom-left origin). null when absent or malformed.
color PdfColor? Stroke / border colour. null when no colour entry is present.
modifiedDate PdfDate? Modification date (/M entry), parsed via pdf_date_parser.dart. null when absent or unparseable.
flags int Raw FPDF_ANNOT_FLAG_* bitmask.
popup PdfPopupAnnotation? Inlined popup window. null when no popup is attached.

7.2.3.2 Concrete subtypes

Class PdfAnnotationType Type-specific fields
PdfTextAnnotation text (base fields only — sticky notes)
PdfFreeTextAnnotation freeText (base fields only)
PdfMarkupAnnotation highlight, underline, squiggly, strikeout subtype, quadPoints: List<PdfQuadPoints>
PdfShapeAnnotation square, circle subtype, interiorColor: PdfColor?
PdfLineAnnotation line lineStart: PdfPoint, lineEnd: PdfPoint
PdfInkAnnotation ink strokes: List<List<PdfPoint>> — outer list is strokes, inner list is points per stroke
PdfPolygonAnnotation polygon, polyline subtype, vertices: List<PdfPoint>
PdfLinkAnnotation link uri: String?null when the link targets a page destination rather than a URI
PdfStampAnnotation stamp (base fields only)
PdfUnknownAnnotation unknown rawSubtype: int — raw FPDF_ANNOT_* integer for debugging

7.2.3.3 PdfAnnotationType enum

enum PdfAnnotationType {
  text, freeText, highlight, underline, squiggly, strikeout,
  square, circle, line, ink, polygon, polyline, link, stamp, popup, unknown,
}

popup appears in this enum for completeness but is never emitted as a top-level annotation — see Popup annotations below.

7.2.4 Supporting value types

All value types implement ==, hashCode, and toString.

Type Fields
PdfRect left, bottom, right, top (all double)
PdfPoint x, y (both double)
PdfQuadPoints p1, p2, p3, p4 (PdfPoint — four corners of one highlighted quad)
PdfColor r, g, b, a (all double, range 0–255)
PdfPopupAnnotation rect: PdfRect?, flags: int

7.3 Coordinate system

All coordinates are in the PDF page coordinate space with a bottom-left origin. This is consistent with how the text layer currently reports character bounds. Callers that need screen coordinates (top-left origin) must apply FPDF_PageToDevice() / FPDF_DeviceToPage() themselves.

7.4 Popup annotations

FPDF_ANNOT_POPUP annotations are the floating comment windows that PDF viewers display alongside sticky notes and free-text annotations. They are not emitted as top-level entries in PdfPageAnnotations.annotations. Instead, when a popup is linked to a parent annotation via FPDFAnnot_GetLinkedAnnot(), its data is inlined as the optional popup field on the parent PdfAnnotation. If no popup is present, popup is null.

7.5 Out-of-scope annotation types

The following annotation subtypes are skipped by the extractor and never appear in the output:

Subtype Reason
FPDF_ANNOT_WIDGET, FPDF_ANNOT_XFAWIDGET Form fields — out of scope for v0.02; earmarked for a dedicated form-extraction plan.
FPDF_ANNOT_FILEATTACHMENT, FPDF_ANNOT_SOUND, FPDF_ANNOT_MOVIE, FPDF_ANNOT_SCREEN, FPDF_ANNOT_REDACT, FPDF_ANNOT_WATERMARK, FPDF_ANNOT_THREED, FPDF_ANNOT_RICHMEDIA Multimedia / special types — out of scope for v0.02.

Annotations whose subtype is unrecognised by the current binding are emitted as PdfUnknownAnnotation with rawSubtype carrying the raw integer, so no information is silently discarded.

7.6 Behaviour by scenario

Scenario Behaviour
Page with no annotations PdfPageAnnotations.annotations is an empty list. No error.
contents key absent annotation.contents is null.
contents key present but empty annotation.contents is "". Distinguishable from absent.
No colour entry annotation.color is null.
Malformed /Rect entry annotation.rect is null. No crash.
Quad-points count not a multiple of 8 Trailing incomplete quad is truncated.
FPDF_ANNOT_UNKNOWN or unmapped subtype Emitted as PdfUnknownAnnotation with rawSubtype.
Popup annotation Inlined as annotation.popup on parent; not emitted top-level.
Widget / form annotation Silently skipped.
Concurrent extractAnnotations() calls Permitted. The isolate handles concurrent streams via per-request SendPorts.
Document with no pages Stream completes immediately with no items.
Password-protected PDF PdfExtractionException(PdfError.passwordRequired).
Corrupt / non-PDF bytes PdfExtractionException(PdfError.invalidDocument).
pageIndex out of range RangeError.
Called after close() StateError.
close() called mid-stream Stream terminates immediately; all page-level handles released.

7.7 Platform notes

On native platforms all PDFium calls run on the PdfiumIsolate singleton. The UI isolate is never blocked. The isolate uses a two-pass algorithm per page:

  1. First pass — iterate every annotation index; extract non-POPUP annotations and record POPUP annotation pointers with their handle addresses.
  2. Second pass — for each recorded popup, call FPDFAnnot_GetLinkedAnnot() to retrieve the parent handle, look it up in the first-pass index by page-annotation index (FPDFPage_GetAnnotIndex()), and inline the popup data onto the parent.

On web, extractAnnotations() runs inside the dedicated Web Worker hosting the PDFium WASM build. On the stub (non-FFI) backend, extractAnnotations() throws UnsupportedError. An empty stream is explicitly avoided because it would silently appear to succeed with no data, masking the unsupported platform.

7.8 Limitations

7.8.1 No per-page range filter

extractAnnotations() accepts an optional single pageIndex but not a page range. Range-based extraction is a known limitation to revisit in a future plan.

7.8.2 Read-only

Annotation write-back is not supported in v0.02. Modifying or creating annotations requires fpdf_save.h and is earmarked for a future plan.

7.8.3 fpdf_annot.h is Experimental API

Nearly all functions in fpdf_annot.h carry the // Experimental API. comment in the PDFium headers. The FFI bindings are kept behind Dart abstraction so that upstream signature changes are isolated to implementation files.

8 Image Extraction

8.1 Overview

The image extraction API allows a caller to enumerate every raster image object embedded in a PDF document, inspect each image’s metadata (dimensions, DPI, colourspace, compression filters), and retrieve its rendered BGRA pixel data. Extraction is available on all native platforms (iOS, Android, macOS, Windows, Linux) via PDFium FFI, and on web via the PDFium WASM build running in a dedicated Web Worker.

The primary use cases are:

8.2 Public API

8.2.1 PdfColorspace

Typed enumeration of PDF colourspace values, mapped from the PDFium FPDF_COLORSPACE_* constants. Raw integer constants are not exposed in the public API.

Value Description
unknown Unrecognised or absent colourspace.
deviceGray DeviceGray (monochrome).
deviceRgb DeviceRGB.
deviceCmyk DeviceCMYK.
calGray CIE-calibrated monochrome.
calRgb CIE-calibrated RGB.
lab CIE L*a*b*.
iccBased ICC profile-based colourspace.
separation Separation (spot colour).
deviceN DeviceN (multi-component spot).
indexed Indexed (palette).
pattern Pattern.

8.2.2 PdfImageMetadata

Immutable value type carrying the intrinsic properties of an image as stored in the PDF.

Property Type Description
width int Source pixel width (before any page transformation).
height int Source pixel height.
horizontalDpi double Horizontal resolution in dots per inch.
verticalDpi double Vertical resolution in dots per inch.
bitsPerPixel int Bit depth per pixel (e.g. 1 for masks, 8 for greyscale, 24 for RGB).
colorspace PdfColorspace Colourspace of the image data.
markedContentId int Marked-content identifier (links to the PDF structure tree for alt-text lookup); -1 when absent.

PdfImageMetadata implements == and hashCode based on all fields, and provides a toString() for debugging.

8.2.3 PdfImage

Immutable value type representing a single image object on a PDF page.

Property Type Description
pageIndex int 0-based page index.
objectIndex int 0-based position of this object in the page’s object list. Stable within a PdfDocument session; use with PdfDocument.renderImage.
metadata PdfImageMetadata Intrinsic image properties.
bounds PdfRect Axis-aligned bounding box in PDF user-space (origin bottom-left).
filters List<String> Compression filter names in order (e.g. ['DCTDecode']). Empty when no filter is present.
bgra Uint8List? Rendered BGRA pixel bytes. null unless extractImages was called with includeBitmap: true.
bitmapWidth int? Rendered pixel width. May differ from metadata.width after page-level transforms. null when bgra is null.
bitmapHeight int? Rendered pixel height. null when bgra is null.

Equality is based on all fields except bgra (pixel data is excluded from comparison to keep equality fast and allocation-free). hashCode and toString() follow the same convention.

Image masks: Image objects with metadata.bitsPerPixel == 1 are stencil masks. They appear in the extractImages output and are not suppressed automatically. Callers can identify them via metadata.bitsPerPixel. Note that FPDFImageObj_GetRenderedBitmap composites the mask when rendering the owning image; the mask object itself may return null from renderImage.

8.2.4 PdfImageBitmap

Immutable value type returned by PdfDocument.renderImage. All fields are non-nullable.

Property Type Description
bgra Uint8List Rendered BGRA pixel bytes. Length equals width * height * 4.
width int Rendered pixel width.
height int Rendered pixel height.

Equality and hashCode are based on width and height only (pixel data excluded for performance). toString() includes all three fields.

8.2.5 PdfPageImages

Immutable container for the image objects on a single page.

Property Type Description
pageIndex int 0-based page index.
images List<PdfImage> Image objects found on this page, in page-object-list order. Empty when the page has no image objects.

8.2.6 Image extraction methods on PdfDocument

Member Description
extractImages({int? pageIndex, bool includeBitmap = false}) Stream<PdfPageImages> — yields one PdfPageImages per page. All pages are yielded when pageIndex is null; exactly one page when specified. When includeBitmap is false (the default) the bgra, bitmapWidth, and bitmapHeight fields on each PdfImage are null.
renderImage(int pageIndex, int objectIndex) Future<PdfImageBitmap?> — fetches the rendered bitmap for a specific image object on demand. Returns null when the object has no renderable bitmap (e.g. stencil mask objects). Throws RangeError for an out-of-range pageIndex or for an objectIndex that does not identify an image object. Throws StateError if the document has been closed.

8.3 Behaviour by scenario

Scenario Behaviour
Page with no images PdfPageImages.images is an empty list; no error or exception.
extractImages(includeBitmap: false) bgra, bitmapWidth, bitmapHeight on every PdfImage are null.
extractImages(includeBitmap: true) bgra is a Uint8List of length bitmapWidth * bitmapHeight * 4; all three fields are non-null for every image that has a renderable bitmap.
renderImage on a valid image Returns PdfImageBitmap with bgra.length == width * height * 4.
renderImage on a stencil mask Returns null (PDFium GetRenderedBitmap returns null for mask-only objects).
renderImage with out-of-range pageIndex Throws RangeError.
renderImage with objectIndex not an image Throws RangeError.
renderImage after close() Throws StateError.
extractImages with out-of-range pageIndex Stream emits an error event and terminates; no crash.
FPDFImageObj_GetImageMetadata returns false Image object is skipped silently; a warning is logged in debug mode.
FPDFPageObj_GetBounds returns false bounds is set to a zero PdfRect; image is still included in the output.
Multi-page document extractImages() yields one PdfPageImages per page in ascending order.
close() during active stream Stream terminates cleanly; all page-level native handles are released.
Web platform Both extractImages and renderImage are supported via the WASM worker.
Stub/unsupported platform Both methods throw UnsupportedError.
Password-protected PDF PdfDocument.fromBytes throws PdfExtractionException(PdfError.passwordRequired) before image extraction is attempted.
Corrupt / non-PDF bytes PdfDocument.fromBytes throws PdfExtractionException(PdfError.invalidDocument).

8.4 Stream lifecycle

Cancelling the extractImages() subscription immediately releases all page-level native resources for the current page. PdfDocument.close() terminates any active extractImages() stream and releases all its handles before closing the document handle. Callers do not need to cancel the stream manually before calling close().

The renderImage() future is a single isolate round-trip:

  1. FPDF_LoadPage — open the page handle.
  2. FPDFPage_GetObject(page, objectIndex) — O(1) index access.
  3. Verify object type is FPDF_PAGEOBJ_IMAGE.
  4. FPDFImageObj_GetRenderedBitmap — composite the image with transforms and mask.
  5. Copy BGRA bytes into a Uint8List, accounting for stride padding.
  6. FPDFBitmap_Destroy and FPDF_ClosePage — release native handles.
  7. Return PdfImageBitmap (or null).

No native handle crosses the isolate boundary; objectIndex is a plain integer.

8.5 Platform notes

On native platforms (iOS, Android, macOS, Windows, Linux) all PDFium calls run on the PdfiumIsolate — a process-wide singleton isolate that owns the PDFium library handle and serialises all FFI calls. The caller’s isolate (typically the UI isolate) is never blocked.

On web, extractImages and renderImage run inside a dedicated Web Worker hosting the PDFium WASM build; results are marshalled back to the caller’s isolate via postMessage, mirroring the native isolate round-trip semantics.

8.6 Coordinate system

PdfImage.bounds is an axis-aligned bounding box in PDF user-space. PDF origin is bottom-left; Flutter/screen origin is top-left. Use FPDF_PageToDevice / FPDF_DeviceToPage for coordinate conversion when mapping bounds to screen coordinates. See PdfRect for the field definitions (left, bottom, right, top).

The rendered bitmap dimensions (bitmapWidth, bitmapHeight) reflect the composited size after the page-level transform is applied and may differ from the intrinsic metadata.width / metadata.height.

8.7 Limitations

8.7.1 Inline images

Inline images (PDF operator BI … ID … EI) are surfaced by PDFium as regular FPDF_PAGEOBJ_IMAGE objects with the same type constant. No special handling is required; they appear in the extractImages output alongside stream-based images.

8.7.2 Raw and decoded byte access

FPDFImageObj_GetImageDataRaw (compressed bytes) and FPDFImageObj_GetImageDataDecoded (uncompressed bytes) are not exposed in v1. Only the composited rendered bitmap is available. Raw access is deferred to a follow-on plan.

8.7.3 ICC profile data

FPDFImageObj_GetIccProfileDataDecoded is not exposed in v1. The markedContentId field on PdfImageMetadata allows callers to look up ICC profile data via the structure tree (fpdf_structtree.h) independently; that path is also out of scope here.

8.7.4 Image mask objects

Stencil mask objects (bitsPerPixel == 1) appear in the output and are not filtered automatically. renderImage may return null for these objects because PDFium’s GetRenderedBitmap returns null for mask-only objects. Callers should gate rendering on metadata.bitsPerPixel > 1 when masks are not wanted.

8.7.5 Memory usage with includeBitmap: true

A full-resolution photograph at 300 DPI on an A4 page can produce a BGRA bitmap of around 70–100 MB. Calling extractImages(includeBitmap: true) on a document with many such pages will allocate one Uint8List per image inside the isolate before streaming results to the caller. For large documents, prefer extractImages(includeBitmap: false) combined with selective renderImage calls gated on metadata.width * metadata.height.

9 Text Search

9.1 Overview

The search API allows a caller to locate all occurrences of a query string within a PDF document. It works on all native platforms (iOS, Android, macOS, Windows, Linux) where dart:ffi is available. Results are streamed page-by-page so callers can react to early matches without waiting for a full document scan.

The feature is exposed as a pure-Dart method on PdfDocument — it has no dependency on dart:ui or Flutter and is available from package:betto_pdfium/betto_pdfium.dart.

9.2 Public API

9.2.1 PdfSearchFlag

An enum controlling search matching behaviour. Values are combined in a Set:

Value PDFium constant Meaning
matchCase FPDF_MATCHCASE (0x01) Case-sensitive match.
matchWholeWord FPDF_MATCHWHOLEWORD (0x02) Whole-word match only.
consecutive FPDF_CONSECUTIVE (0x04) Allow overlapping matches.

9.2.2 PdfSearchMatch

Immutable result for a single search match.

Property Type Description
pageIndex int Zero-based page index.
charIndex int Zero-based character index of the first matched character on this page.
charCount int Number of matched characters.
rects List<PdfRect> Bounding rectangles in PDF user-space (origin bottom-left, points). One rect per visual line fragment.

9.2.3 PdfDocument.search

Stream<PdfSearchMatch> search(
  String query, {
  Set<PdfSearchFlag> flags = const {},
  int? pageIndex,
})

Searches the document for query and streams all matches in ascending page order.

Parameter Description
query The text to search for. An empty string produces an empty stream immediately.
flags Controls case-sensitivity, whole-word matching, and overlapping. Defaults to case-insensitive, non-whole-word, non-overlapping.
pageIndex When set, restricts the search to that single page. Throws RangeError if out of range.

9.3 Coordinate system

PdfSearchMatch.rects are in PDF user space: origin at the bottom-left of the page, units in points (1 point = 1/72 inch). This is consistent with the coordinate system used by PdfRect and page-size values throughout this library.

Callers that need screen coordinates must transform them using FPDF_PageToDevice() / FPDF_DeviceToPage() from the PDFium bindings, or apply a simple y-axis flip when the device origin is top-left.

A note on multi-column layout: PDFium returns text in content-stream order, not visual reading order. A match that spans a line-wrapping point on a multi-column page may produce bounding rectangles that appear on different visual columns. This is a known v1 limitation consistent with the text extraction behaviour.

9.4 Stream lifecycle

The search() stream follows the same lifecycle as extractPlainText():

9.5 Behaviour by scenario

Scenario Behaviour
Empty query string Returns an empty stream immediately without any PDFium calls.
Query not found on page FPDFText_FindNext returns 0; the page produces no matches and the stream moves to the next page.
Page has no text layer FPDFText_LoadPage returns null; the page produces no matches. Not an error.
Multi-line match Multiple rects from FPDFText_GetRect; all included in PdfSearchMatch.rects.
close() called during active stream _closed flag is checked before each isolate round-trip and before yielding each match; stream terminates promptly.
Scanned (image-only) PDF All pages have no text layer; stream completes empty.
pageIndex out of range Throws RangeError before any PDFium calls are made.
Overlapping matches Only produced when PdfSearchFlag.consecutive is set.
Very long query string UTF-16LE encoding handles any Dart string; no length limit in the PDFium API.

9.6 Platform notes

Platform Status
iOS, Android, macOS, Windows, Linux Fully supported via dart:ffi + PdfiumIsolate.
Web Fully supported via the PDFium WASM worker.
Stub (other) Throws UnsupportedError.

9.7 Implementation notes

9.7.1 Isolate protocol

The search is implemented as a new PdfiumSearchPageCommand message type in the PdfiumIsolate protocol. Each invocation of the command handles exactly one page (matching the per-page model used by PdfiumExtractPageTextCommand and PdfiumExtractPageAnnotationsCommand).

The query string is encoded inside the isolate as a null-terminated UTF-16LE buffer (FPDF_WIDESTRING = Pointer<UnsignedShort>) before being passed to FPDFText_FindStart. Dart strings are natively UTF-16, so the encoding is a direct code-unit copy followed by a null terminator — no surrogates are decoded or re-encoded.

9.7.2 Handle lifecycle (inside the isolate)

For each PdfiumSearchPageCommand:

  1. FPDF_LoadPage — may fail (bad page index or corrupt page); returns error on failure.
  2. FPDFText_LoadPage — may return null (no text layer); returns empty matches, not an error.
  3. UTF-16LE buffer allocation.
  4. FPDFText_FindStart — starts the search; may return null; returns empty matches if null.
  5. Loop: FPDFText_FindNextFPDFText_GetSchResultIndex / FPDFText_GetSchCountFPDFText_CountRects / FPDFText_GetRect.
  6. FPDFText_FindClose — always called in a finally block.
  7. calloc.free — frees the UTF-16LE buffer (always in a finally block).
  8. FPDFText_ClosePage — always called in a finally block.
  9. FPDF_ClosePage — always called in a finally block.

The nested try/finally structure ensures no handle is leaked even when an exception occurs mid-loop.

10 Page Rendering

10.1 Overview

The page rendering API allows a caller to rasterise a PDF page into a raw BGRA pixel buffer. Rendering runs on the shared PdfiumIsolate so the caller’s isolate is never blocked. The API is available on native platforms (iOS, Android, macOS, Windows, Linux) that can load the PDFium dylib, and on web via the PDFium WASM worker. On the stub platform the method throws UnsupportedError.

PdfDocument.renderPageToBytes(), documented below, lives in the pure-Dart package:betto_pdfium and has no dependency on dart:ui or Flutter — it returns a Uint8List directly. The Flutter-facing layer — the renderPage() extension that converts that buffer into a dart:ui Image, plus the page viewer widgets — lives in the separate package:betto_pdf_widgets package. See Flutter widgets below.

10.2 Public API

10.2.1 PdfPageSize

An immutable value type representing the intrinsic size of a PDF page.

Property / Method Type Description
widthPt double Page width in PDF user units (points, 1 pt = 1/72 inch).
heightPt double Page height in PDF user units (points).
aspectRatio double widthPt / heightPt. Returns 1.0 on malformed pages where heightPt is zero.
sizeForDpi(double dpi) Size Returns pixel dimensions at the given DPI. Computed as widthPt * dpi / 72 × heightPt * dpi / 72. Returns Size.zero when dpi ≤ 0.

PDF user units are storage-level measurements, not tied to any screen or rendering resolution. sizeForDpi(72) returns a Size numerically equal to the point dimensions; sizeForDpi(150) returns the pixel dimensions needed for 150 DPI rendering.

10.2.2 Rendering methods on PdfDocument

Method Description
getPageSize(int pageIndex) Future<PdfPageSize> — returns the intrinsic size of the given page.
renderPageToBytes(int pageIndex, int pixelWidth, int pixelHeight, {bool renderAnnotations, bool lcdText, int backgroundColor}) Future<({Uint8List pixels, int pixelWidth, int pixelHeight})> — rasterises the page at the given pixel dimensions and returns raw BGRA bytes.

10.2.2.1 getPageSize(int pageIndex)

Returns the intrinsic size of the page at pageIndex (0-based).

Throws: - RangeError if pageIndex is outside [0, pageCount). Use RangeError.checkValidIndex semantics. - StateError if close() has already been called.

10.2.2.2 renderPageToBytes(int pageIndex, int pixelWidth, int pixelHeight, {bool renderAnnotations = true, bool lcdText = false, int backgroundColor = 0xFFFFFFFF})

Rasterises the page at pageIndex into a raw BGRA pixel buffer of exactly pixelWidth × pixelHeight pixels. All PDFium calls run inside PdfiumIsolate; only the Uint8List pixel buffer crosses the isolate boundary.

Parameter Default Description
renderAnnotations true Maps to the PDFium FPDF_ANNOT flag — annotations (highlights, ink, stamps, etc.) are drawn on top of the page content.
lcdText false Maps to FPDF_LCD_TEXT — sub-pixel text rendering. Produces sharper text on LCD screens but may cause colour fringing on non-LCD surfaces.
backgroundColor 0xFFFFFFFF Opaque white, packed ARGB. The bitmap is filled with this colour before rendering, eliminating garbage pixels on transparent areas.

Rendering pipeline (inside PdfiumIsolate): 1. FPDFBitmap_Create(pixelWidth, pixelHeight, hasAlpha=1) — allocate a BGRA bitmap. 2. FPDFBitmap_FillRect(bitmap, 0, 0, w, h, color) — fill with backgroundColor. 3. FPDF_RenderPageBitmap(bitmap, page, 0, 0, w, h, 0, flags) — rasterise. Flags are built from renderAnnotations and lcdText. 4. FPDFBitmap_GetBuffer(bitmap) — obtain raw pointer; copy w × h × 4 bytes into a Uint8List before FPDFBitmap_Destroy. 5. FPDFBitmap_Destroy(bitmap) and FPDF_ClosePage(page) — release all native handles.

betto_pdfium returns the raw Uint8List and does not depend on dart:ui. Flutter callers convert the buffer to a dart:ui Image themselves (e.g. via decodeImageFromPixels, or the renderPage() extension shipped by package:betto_pdf_widgets — see Flutter widgets below), passing PixelFormat.bgra8888.

For sharp output on high-DPI displays, multiply the widget’s logical width by MediaQuery.devicePixelRatio before passing pixelWidth to renderPageToBytes. betto_pdf_widgetsPageView and PageViewer do this automatically.

Throws: - RangeError if pageIndex is outside [0, pageCount). - StateError if close() has been called before or during the render. When close() is called while a render future is in flight, the future completes with StateError — consistent with the Dart convention for post-disposal access. - PdfiumException if a PDFium native call fails unexpectedly (e.g. FPDFBitmap_Create returns null due to an out-of-memory condition).

10.2.3 getThumbnail(int pageIndex, {bool generateIfAbsent, int maxDimension})

Returns a thumbnail image for the page at pageIndex (0-based).

10.2.3.1 Public API signature

Future<PdfThumbnail?> getThumbnail(
  int pageIndex, {
  bool generateIfAbsent = true,
  int maxDimension = 256,
})

PdfThumbnail carries bgra (compact BGRA pixel bytes), width, height, and source (PdfThumbnailSource.embedded or PdfThumbnailSource.rendered).

10.2.3.2 Behaviour

  1. Embedded thumbnail present. If the page has an embedded /Thumb stream, PDFium decodes it via FPDFPage_GetThumbnailAsBitmap. The bitmap is read, row padding is stripped, and the result is returned as a PdfThumbnail with source: PdfThumbnailSource.embedded at its native dimensions. maxDimension is ignored.

  2. No embedded thumbnail, generateIfAbsent: true (default). The page is rendered via the existing renderPageToBytes pipeline. The render dimensions are computed by scaling the page’s intrinsic PDF size (getPageSize()) so the longest edge equals maxDimension pixels, preserving aspect ratio and clamping the short edge to at minimum 1 pixel. The result is returned as a PdfThumbnail with source: PdfThumbnailSource.rendered.

  3. No embedded thumbnail, generateIfAbsent: false. null is returned immediately without any render pass. Useful for callers that only want to surface natively-embedded previews.

10.2.3.3 maxDimension semantics

maxDimension is a logical pixel budget that applies only to the fallback render path. Embedded thumbnails are returned at their native dimensions regardless of maxDimension. On high-DPI displays, multiply maxDimension by MediaQuery.devicePixelRatio before calling to obtain a retina-sharp fallback render — this method cannot access MediaQuery as it lives in the pure-Dart layer (package:betto_pdfium/betto_pdfium.dart).

10.2.3.4 Error contract

Exception Condition
RangeError pageIndex < 0 or pageIndex >= pageCount.
ArgumentError maxDimension ≤ 0.
StateError close() has been called before or during the call (including between the thumbnail round-trip and the fallback render pass).
PdfiumException A PDFium native call fails unexpectedly (e.g. FPDF_LoadPage returns null, or a bitmap read error).

Errors from the fallback renderPageToBytes call (StateError, PdfiumException) are re-thrown directly — they are not wrapped.

10.2.3.5 Platform support

Platform Supported Notes
macOS, iOS, Android, Windows, Linux Yes Via dart:ffi + PdfiumIsolate.
Web Yes Via the PDFium WASM worker.
Stub No Throws UnsupportedError.

10.2.4 PdfiumException

A general-purpose exception for unexpected PDFium native failures.

Property Type Description
message String A descriptive message identifying the failed PDFium call and any available context.

PdfiumException is thrown only for unexpected native failures (e.g. bitmap allocation failure). Logical errors (out-of-range index, closed document) use standard Dart exception types (RangeError, StateError).

10.3 Flutter widgets (betto_pdf_widgets)

The Flutter-facing rendering layer — the ui.Image conversion and the page viewer widgets — lives in the separate package:betto_pdf_widgets package, not in package:betto_pdfium. betto_pdf_widgets depends on betto_pdfium and adds a dart:ui / Flutter dependency that the pure-Dart package deliberately avoids.

In addition to the rendering pieces documented below, betto_pdf_widgets ships companion widgets for the other betto_pdfium extraction APIs — SearchView, ThumbnailGrid, AnnotationView, TocView, and InfoView — which are out of scope for this spec.

10.3.1 RenderOptions

Options controlling how the renderPage() extension (below) rasterises the page. Defined in package:betto_pdf_widgets; wraps the plain named parameters accepted by PdfDocument.renderPageToBytes().

Field Type Default Description
renderAnnotations bool true When true, maps to the PDFium FPDF_ANNOT flag — annotations (highlights, ink, stamps, etc.) are drawn on top of the page content.
lcdText bool false When true, maps to FPDF_LCD_TEXT — sub-pixel text rendering. Produces sharper text on LCD screens but may cause colour fringing on non-LCD surfaces.
backgroundColor Color Color(0xFFFFFFFF) Opaque white. The bitmap is filled with this colour before rendering, eliminating garbage pixels on transparent areas.

The backgroundColor field uses dart:ui Color for Flutter interoperability; renderPage() converts it to PDFium’s 0xAARRGGBB integer format before calling renderPageToBytes().

Note on zoom and scale: RenderOptions intentionally has no scale field. The caller controls output resolution via the pixelWidth and pixelHeight arguments on renderPage(). Zoom/scale is a widget-level concern handled by ViewerController and PageViewer (below).

10.3.2 renderPage() extension

package:betto_pdf_widgets adds a renderPage() extension method to PdfDocument that wraps renderPageToBytes() and decodes the result into a dart:ui Image:

Future<ui.Image> renderPage(
  int pageIndex,
  int pixelWidth,
  int pixelHeight, {
  RenderOptions options = const RenderOptions(),
})

It converts the Uint8List via ImmutableBuffer.fromUint8ListImageDescriptor.rawinstantiateCodeccodec.getNextFrame(), using PixelFormat.bgra8888. The returned ui.Image is owned by the caller and must be disposed via ui.Image.dispose() when no longer needed. It throws the same RangeError / StateError / PdfiumException as renderPageToBytes().

10.3.3 PageView

A stateful Flutter widget that renders a single page of a PdfDocument fit-to-width.

PageView(
  document: doc,
  pageIndex: 0,
  options: RenderOptions(renderAnnotations: false),
  semanticLabel: 'Research paper – page 1',
)
Property Type Required Description
document PdfDocument yes The document to render.
pageIndex int yes The zero-based page index to display.
options RenderOptions no Render options. Defaults to RenderOptions().
semanticLabel String? no Accessibility label for the rendered canvas (e.g. the document title). Falls back to "PDF page N".

Layout: Uses LayoutBuilder to obtain the available logical width. The widget renders at the full available width and derives the height from the page’s aspect ratio. The logical width is multiplied by MediaQuery.devicePixelRatio when computing pixelWidth and pixelHeight for the renderPage() call, producing sharp output on retina displays.

Loading state: A CircularProgressIndicator is shown while the render is in flight. The spinner is suppressed when MediaQuery.disableAnimations is true, consistent with accessibility preferences that reduce motion.

Caching: The last successfully rendered ui.Image is cached. A re-render is triggered only when pageIndex changes, document changes, or the available logical width changes by more than 2 pixels.

In-flight cancellation: If pageIndex (or document) changes while a render is in flight, the in-flight result is silently discarded via a generation counter. The new page’s render starts immediately.

Error handling: RangeError, StateError, and PdfiumException from renderPage() are caught and displayed as a centred error message. The widget does not rethrow; the error persists until pageIndex or document changes.

Accessibility: All three states (loading, rendered, error) expose a Semantics label. The rendered canvas is tagged as an image (isImage: true). Provide a meaningful semanticLabel (e.g. the document file name or title) for best screen-reader experience.

Resource management: Each ui.Image is disposed when replaced by a new render or when the widget is disposed. The PdfDocument is owned by the caller; PageView never calls close() on it.

10.3.4 ViewerController

A ChangeNotifier that holds all view-level state for a single open PDF: the current page, zoom mode, annotation toggle, and active search matches.

final controller = ViewerController();

// Navigate to a page:
controller.setPage(2, pageCount: doc.pageCount);

// Change zoom mode:
controller.setZoom(ZoomMode.fitPage);

// Step zoom by 10 % relative to the current visual scale:
controller.setZoom(ZoomMode.custom, factor: controller.effectiveZoomFactor + 0.1);

// Toggle annotations:
controller.renderAnnotations = !controller.renderAnnotations;

// Apply search results from SearchView:
controller.setSearchMatches(matches);
controller.clearSearch();

// Always dispose when the document is closed:
controller.dispose();
Property / Method Type Description
currentPage int Zero-based index of the currently displayed page. Read-only; set via setPage.
zoomMode ZoomMode Current zoom mode: fitPage, fitWidth, or custom.
zoomFactor double Scale factor used when zoomMode == ZoomMode.custom. Relative to the available viewport width.
effectiveZoomFactor double The actual rendered scale as a fraction of viewport width, updated by PageViewer after each render. Use this as the base when stepping zoom.
renderAnnotations bool Whether annotations are drawn on the page. Maps to FPDF_ANNOT. Default true.
activeSearchMatches List<PdfSearchMatch> Matches currently displayed as overlays by PageViewer.
searchQuery String Last query typed in SearchView; persists across tab switches.
searchCompleted bool Whether the last search stream completed.
searchPageTexts Map<int, String> Per-page extracted text cache populated by SearchView.
setPage(int, {int pageCount}) void Clamps to [0, pageCount − 1] and notifies. No-op when pageCount ≤ 0.
nextPage({int pageCount}) void Advances one page; no-op at last page.
previousPage() void Moves back one page; no-op at page 0.
setZoom(ZoomMode, {double factor}) void Sets mode and optional custom factor; notifies.
setSearchMatches(List<PdfSearchMatch>) void Replaces active matches; notifies so PageViewer repaints overlays.
clearSearch() void Clears matches and resets all search persistence fields; notifies.

Ownership: One controller per open document. The controller does not own the PdfDocument handle; that is owned by the caller.

10.3.5 PageViewer

A stateful Flutter widget that renders a single PDF page with support for three zoom modes and search-match overlays.

PageViewer(
  document: doc,
  pageCount: pageCount,
  controller: controller,
  semanticLabel: 'Annual report, page 1',
)
Property Type Required Description
document PdfDocument yes The document to render.
pageCount int yes Total pages; used to validate page indices.
controller ViewerController yes Drives zoom, page, annotations, and search overlays.
semanticLabel String? no Accessibility label for the page canvas.

Zoom modes:

Mode Canvas width Scrolling
fitPage min(widthBudget, heightBudget × aspectRatio) with 24 dp border None
fitWidth Full available width Vertical via SingleChildScrollView
custom availableWidth × controller.zoomFactor Pan via InteractiveViewer

After each successful render, PageViewer writes renderLogicalWidth / logicalWidth into controller.effectiveZoomFactor so the toolbar zoom buttons can step from the actual visual scale.

Search overlays: For each PdfSearchMatch on the current page, PageViewer draws a translucent amber rectangle (50 % opacity) over the match’s bounding box. Coordinates are converted from PDF user space (bottom-left origin) to Flutter screen space (top-left origin):

flutterX = pdfRect.left / pageWidthPt * widgetWidth
flutterY = (pageHeightPt − pdfRect.top) / pageHeightPt * widgetHeight
rectW    = (pdfRect.right − pdfRect.left) / pageWidthPt * widgetWidth
rectH    = (pdfRect.top − pdfRect.bottom) / pageHeightPt * widgetHeight

In-flight cancellation: A generation counter discards stale results when the page, zoom, or document changes during an async render.

Resource management: PageViewer disposes each ui.Image when it is replaced or when the widget is disposed. It never calls close() on the document.

10.4 Platform support

Platform Rendering Notes
macOS, iOS, Android, Windows, Linux Supported Via dart:ffi + PDFium dylib.
Web Supported Via the PDFium WASM worker.
Stub Unsupported Throws UnsupportedError.

10.5 Coordinate system

PDF origin is bottom-left; Flutter/screen origin is top-left. For this phase (display only, no hit-testing) the coordinate flip is invisible — PDFium renders the page into the bitmap with the correct orientation. Hit-testing via FPDF_PageToDevice() / FPDF_DeviceToPage() is deferred to a future zoom/selection plan.

10.6 Memory considerations

Each rendered page allocates pixelWidth × pixelHeight × 4 bytes. A typical A4 page at 150 DPI on a retina display (≈ 1440 × 1800 px) uses ~10 MB. PageView holds exactly one ui.Image per instance; with N open tabs there are N live images. No LRU cache is used in this phase.

11 Testing

11.1 Overview

betto_pdfium has three testing surfaces:

Surface Tool When to run
Dart unit + integration tests dart test Always — primary gate
iOS on-device / simulator flutter test integration_test/ Before mobile releases
Android on-device / emulator flutter test integration_test/ Before mobile releases

All make commands run from the repo root.

11.2 Dart test suite

The Dart test suite in packages/betto_pdfium/test/ covers all public API surfaces on the native FFI backend. The native-assets hook downloads the platform binary automatically on the first run — no manual setup required.

make test          # dart test (all files)
make coverage      # dart test --coverage + genhtml → site/coverage/

To run a single file:

dart test packages/betto_pdfium/test/pdf_types_test.dart

11.2.1 Coverage

Coverage is measured with make coverage. The generated HTML report is written to site/coverage/. The */generated/* path (auto-generated FFI bindings) is excluded from the lcov report by the Makefile --remove step.

Minimum required coverage: 90%. Check after every implementation step.

11.3 Mobile integration test app

packages/betto_pdfium/integration_test_app/ is a Flutter app that runs the same test suite on a connected iOS or Android device or simulator. Tests load PDF fixtures from the Flutter asset bundle rather than the filesystem, which is why a separate Flutter app is needed.

11.3.1 iOS

iOS support requires packages/betto_pdfium_ios/ — a companion Flutter plugin that links the PDFium static xcframework. Flutter auto-discovers it via the integration test app’s pubspec.yaml path dependency and wires it into FlutterGeneratedPluginSwiftPackage automatically. No manual Xcode steps are required.

The xcframework is declared as a URL-based SPM binary target in betto_pdfium_ios/ios/betto_pdfium_ios/Package.swift. SPM downloads and caches it directly from the GitHub Release during flutter pub get — no manual binary fetch is needed for iOS.

One-time global setup:

flutter config --enable-swift-package-manager

Run tests on the default iOS simulator:

make ios_test

This target runs sync_fixtures (copies test fixtures into the asset bundle), flutter pub get (which triggers SPM to download the xcframework), and flutter test integration_test/ on the simulator named by $EMULATOR_IOS (default: ios-emulator).

Create the simulator (one-time):

make emulator_ios_create

Environment variables:

Variable Default Description
EMULATOR_IOS ios-emulator Simulator name
EMULATOR_IOS_DEVICE iPhone 17 Simulator device type
EMULATOR_IOS_RUNTIME iOS26.5 Simulator runtime

Run tests manually on a specific device:

cd packages/betto_pdfium/integration_test_app
flutter test integration_test/ -d <device-id>

11.3.2 Android

Run tests on the default Android emulator:

make android_test

This target runs sync_fixtures, fetch_mobile_binaries, and flutter test integration_test/ on the AVD named by $EMULATOR_ANDROID (default: android-emulator).

Create the AVD (one-time):

make emulator_android_create

Environment variables:

Variable Default Description
EMULATOR_ANDROID android-emulator AVD name
ADB_BINARY_PATH ~/Library/Android/sdk/platform-tools Path to adb

Stop all emulators:

make emulators_stop

11.4 CI

The cicd.yml workflow runs make cicd (format check, analyze, license check, test, and doc site) on Ubuntu. A separate test matrix job runs dart test on macOS arm64, Linux arm64, and Windows x64 after a successful build, verifying platform binary downloads on real native runners.

Mobile integration tests are not run in CI — they require a connected device or simulator and are intended for pre-release validation on a developer machine.

11.5 Platform-specific test constraints

11.5.1 Windows

Two test limitations exist on Windows that do not affect macOS or Linux:

CLI subprocess tests are skipped. pdfinfo_test.dart and pdf_search_test.dart include groups that invoke the pdfinfo CLI tool via dart run bin/pdfinfo.dart. On Windows, each dart run invocation triggers native-assets bundling, which tries to delete and replace .dart_tool\lib\pdfium.dll. Because the dart test process already has pdfium.dll loaded, Windows denies the deletion (Access is denied, errno 5). There is no runtime workaround. These groups are declared with skip: Platform.isWindows ? '...' : null at the group() call site, which prevents test bodies from executing entirely (unlike markTestSkipped() in setUp, which marks a test skipped but still runs the body). The underlying library functionality (PdfDocument.search, TOC extraction, etc.) is fully covered by the non-CLI tests that run without issue.

password.pdf error code differs. On macOS and Linux, loading a password-protected PDF without supplying the password causes PDFium to return FPDF_ERR_PASSWORD (4), which the isolate maps to PdfError.passwordRequired. On the bblanchon Windows x64 build, the same fixture returns FPDF_ERR_FORMAT (3), mapped to PdfError.invalidDocument. The error_handling_test.dart password test accepts PdfError.invalidDocument on Platform.isWindows and PdfError.passwordRequired on all other platforms.

12 Releasing

12.1 Overview

This repository contains two pub packages that must be released in lock-step:

Package Path pub.dev
betto_pdfium packages/betto_pdfium/ Published first
betto_pdfium_ios packages/betto_pdfium_ios/ Published second

betto_pdfium_ios does not declare a Dart dependency on betto_pdfium — it is a no-op Flutter plugin whose only job is to carry the PDFium xcframework as an SPM binary target. The two are nonetheless versioned in lock-step and published together (see Staying in sync). Publish betto_pdfium first so that a consumer adding both packages at the same version never sees a half-published pair on pub.dev.

12.2 Bumping the PDFium version (bblanchon)

PDFium binaries are sourced from bblanchon/pdfium-binaries. To adopt a new bblanchon release:

  1. Update packages/betto_pdfium/BBLANCHON_BUILD with the new build number (e.g. 79067907).
  2. Run make repack_ios_xcframework — downloads bblanchon iOS device + simulator tarballs, repacks them into pdfium.xcframework, and uploads the zip to a new bettongia/pdfium GitHub Release tagged bblanchon-chromium-<BUILD>.
  3. Run make update_pdfium_manifest — downloads each bblanchon tarball, computes SHA-256s, rewrites version_pdfium.json and lib/src/pdfium_version.dart, and updates Package.swift with the new iOS xcframework URL and checksum.
  4. Run make fetch_pdfium to install the binary and headers locally.
  5. If the PDFium public API changed: run make ffi_bindings to regenerate lib/src/generated/pdfium_bindings.dart.
  6. Run make pre_commit to verify everything passes.
  7. Commit BBLANCHON_BUILD, version_pdfium.json, lib/src/pdfium_version.dart, Package.swift, and any regenerated bindings with a message like: "Bump PDFium to bblanchon chromium/<NEW_BUILD>".

See PDFium Binary Distribution for the full contract.

12.3 Pre-release checklist

Before publishing either package:

  1. All tests pass: make pre_commit
  2. Coverage ≥ 90%: make coverage
  3. Both packages have identical version numbers in their pubspec.yaml files.
  4. Each package’s CHANGELOG.md has an entry for the new version (packages/betto_pdfium/CHANGELOG.md and packages/betto_pdfium_ios/CHANGELOG.md). There is no repo-root CHANGELOG.md.
  5. The PDFium binary manifest packages/betto_pdfium/version_pdfium.json is committed with checksums for all platforms (see PDFium Binary Distribution).
  6. The install snippets in both package README.md files reference the version being released, not a superseded one — they render on the pub.dev landing page.

12.4 Version numbering

Both packages use the same version number. Version numbers follow pub.dev versioning (semantic versioning):

Update the version in both pubspec.yaml files together:

packages/betto_pdfium/pubspec.yaml
packages/betto_pdfium_ios/pubspec.yaml

12.5 Updating the README install snippets

Both package README.md files show the constraint consumers should add to their pubspec.yaml:

dependencies:
  betto_pdfium: ^<version>
  betto_pdfium_ios: ^<version>

Update these whenever the version changes. Use a caret constraint rather than an exact pin so consumers can adopt patch releases.

Note that a caret constraint on a stable version (^0.1.0) does not match a prerelease (0.1.0-dev.1). While the packages are on -dev.N snapshots the snippets must name the snapshot explicitly, or downstream pub get will fail to resolve.

12.6 Dry-run validation

Validate each package before publishing:

cd packages/betto_pdfium
dart pub publish --dry-run

cd packages/betto_pdfium_ios
dart pub publish --dry-run

Resolve any warnings before proceeding. Common issues:

12.7 Publishing order

12.7.1 Step 1 — publish betto_pdfium

cd packages/betto_pdfium
dart pub publish

Wait for the package to appear on pub.dev before proceeding. The pub.dev propagation delay is typically under 30 seconds but can take a few minutes.

12.7.2 Step 2 — publish betto_pdfium_ios

cd packages/betto_pdfium_ios
dart pub publish

12.8 Post-release

  1. Tag the release in git: git tag <version> && git push --tags (tags are bare version numbers — e.g. 0.1.0, not v0.1.0)
  2. Create a GitHub Release with the tag; paste the relevant CHANGELOG.md entry as the release notes.
  3. Announce in the appropriate channels.

12.9 Staying in sync

betto_pdfium and betto_pdfium_ios are versioned together because:

There is no automated enforcement of this constraint — it is the release author’s responsibility to keep versions in sync.