Technical Specification
betto_pdfium
- Package:
betto_pdfium - Version: 0.1.0
- Dart SDK: ≥ 3.13.0
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:
- iOS dead-stripping: the static xcframework
dead-stripped all PDFium symbols except those reachable from
FPDF_InitLibraryWithConfig, causing every API call beyondfromBytes/pageCount/renderPageToBytesto fail withdlsym(RTLD_DEFAULT, FPDF_GetMetaText): symbol not found. - Binary size: iOS static
.aslices were ~258 MB each; bblanchon’s iOS dylibs are 6.5 MB — eliminating the dead-stripping problem class entirely. - Maintenance cost: the pipeline required patching PDFium GN build files, managing Clang toolchain compatibility, and tracking upstream API changes.
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):
- Reads
BBLANCHON_BUILDto determine the required bblanchon build number. - Detects the host platform (
uname -s/uname -m). - If
third_party/pdfium_bin/VERSIONalready matches andthird_party/pdfium/public/exists, exits immediately (idempotent). - Reads the expected SHA-256 from
version_pdfium.jsonfor the platform. - Downloads the bblanchon tarball with
curl. - Verifies the SHA-256 of the tarball before extraction.
- Extracts
lib/libpdfium.{dylib,so}from the verified tarball. - Installs the binary to
INSTALL_DIR. - On macOS: ad-hoc signs the dylib with
codesign --force --sign -sodlopen()succeeds without Gatekeeper quarantine errors. - Extracts the
include/directory from the same tarball intothird_party/pdfium/public/. - 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:
- Reads
version_pdfium.jsonfrom the package root to determine the platform download URL, lib path within the tarball, and expected SHA-256. - 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). - Downloads the bblanchon
.tgztarball for the target platform. - Verifies SHA-256 of the tarball before extraction — the checksum is over the whole tarball, not the extracted library.
- Atomically renames the verified tarball.
- Extracts the shared library (
lib_pathfield) from the tarball. - On macOS: strips
com.apple.quarantineand related xattrs viaxattr -csodlopen()and Flutter’s bundler work without Gatekeeper errors. - Emits a
CodeAssetwithDynamicLoadingBundledlink 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:
pdfiumVersion = 'chromium/NNNN'— human-readable display identifier (used only in log messages; must not be used as a path segment).bblanchonBuild = 'NNNN'— slash-free build number used at runtime by_openLibrary()to construct the hook cache path as a fallback when the build system hasn’t staged the binary.
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)
- Module pattern: global
Moduleobject (notMODULARIZE=1). The script auto-initialises when loaded via<script>tag. - Initialization signal:
Module["onRuntimeInitialized"]callback is fired when the WASM binary is loaded andFPDF_InitLibraryWithConfigcan be called. - C function exports: underscore-prefixed —
Module["_FPDF_CloseDocument"],Module["_FPDF_LoadMemDocument64"],Module["_malloc"],Module["_free"], etc. All functions are synchronous (no Asyncify wrapping). - Heap views:
Module["HEAPU8"],Module["HEAP8"],Module["HEAP16"],Module["HEAPU16"],Module["HEAP32"],Module["HEAPU32"],Module["HEAPF32"],Module["HEAPF64"]are typed array views into the WASM linear memory. - WASM loading:
pdfium.jsexpectspdfium.wasmin the same directory, resolved vialocateFile(). Both files must be co-located.
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:
- Run
make fetch_wasm_assets(orscripts/fetch_wasm_assets.shdirectly). This downloadspdfium-wasm.tgz, verifies its SHA-256, and extractspdfium.jsandpdfium.wasmtointegration_test_app/web/assets/pdfium/(default; override viaWASM_OUTPUT_DIR). The same step also (re-)copiesbetto_pdfium’s own checked-inlib/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. - Copy the extracted files to your Flutter web app’s
web/assets/pdfium/directory (or run the script withWASM_OUTPUT_DIRpointing there). All three files —pdfium.js,pdfium.wasm,pdfium_worker.js— must be co-located. - The
_document_web.dartbackend spawns a dedicatedWorkerfrom the relative URLassets/pdfium/pdfium_worker.jsat the app origin, which in turn loadsassets/pdfium/pdfium.jsviaimportScripts().
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:
pdfium_worker.jsis abetto_pdfium-authored artifact, not part of the bblanchon tarball. It is pre-compiled once bybetto_pdfium’s own release process (make build_wasm_worker) and checked intolib/assets/pdfium_worker.js, mirroring howpdfium.wasm/pdfium.jsthemselves are pre-built binaries the package merely distributes.- One shared
Workerper page, spawned lazily on the firstPdfDocument.fromBytes()call and reused by every open document (multiplexed via opaque integer tokens) — mirroring the native backend’s one-isolate-per-processPdfiumIsolatemodel. - No
Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policyheaders are required. That requirement is specific toSharedArrayBuffer/shared- memory threading; a message-copy/transferWorker+postMessageprotocol does not need it, and the bundledpdfium.wasmhas no threads and no Asyncify. Verified empirically:build/webserved via a plain HTTP server with no cross-origin-isolation headers loads and functions correctly. - Memory management: the PDF byte buffer is copied
into the WASM heap (inside the worker) via
Module["_malloc"]and must remain allocated for the entire lifetime of the open document;Module["_free"]is called onclose(). A main-threadFinalizer(backed byFinalizationRegistry) provides a leak-prevention backstop ifclose()is not called explicitly — since the heap now lives in the worker, the Finalizer callback posts a fire-and-forget “close” request to the worker rather than freeing memory directly.
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:
- Downloads
pdfium-ios-device-arm64.tgzandpdfium-ios-simulator-arm64.tgz. - Extracts
lib/libpdfium.dylibfrom each. - Renames each dylib to
pdfium(frameworks use the bare name withoutlibprefix or extension). - Patches the install name:
install_name_tool -id @rpath/pdfium.framework/pdfium pdfium.framework/pdfium - Writes a minimal
Info.plistfor eachpdfium.framework/bundle:CFBundleExecutable,CFBundleIdentifier,MinimumOSVersion,CFBundleSupportedPlatforms. - Runs
xcodebuild -create-xcframeworkto combine device + simulator frameworks. - Zips the result into
pdfium.xcframework.zipand prints the SHA-256. - Uploads to
bettongia/pdfiumGitHub Releases taggedbblanchon-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):
- Update
BBLANCHON_BUILDwith the new bblanchon build number. - Run
make repack_ios_xcframework— downloads bblanchon iOS tarballs, builds thepdfium.xcframework, and uploads it to a newbettongia/pdfiumrelease taggedbblanchon-chromium-<NEW_BUILD>. - Run
make update_pdfium_manifest— downloads each bblanchon tarball (includingpdfium-wasm.tgz), computes SHA-256s, rewritesversion_pdfium.json(including thewasmentry) andlib/src/pdfium_version.dart, and updatesPackage.swift. - Run
make fetch_pdfiumto install the new binary and headers locally. - Run
make fetch_wasm_assetsto install the new WASM assets locally. - Run
make ffi_bindingsif the bblanchon headers differ from the previous release (PDFium’s public API is stable but occasionally updated). - Commit
BBLANCHON_BUILD,version_pdfium.json,lib/src/pdfium_version.dart,Package.swift, and any regeneratedlib/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 Isolate — PdfiumIsolate — 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
_pdfium_wasm_engine.dart— the PDFium marshalling engine: one function per operation (load, close, pageCount, metadata, documentInfo, pageSize, render, thumbnail, extract text/annotations/images, renderImage, search, toc), each taking an explicitPdfiumModuleand document pointer/registry data as parameters. No static, main-thread-only globals — this is what makes the same functions safely callable both from a direct main-thread test and from inside the worker._pdfium_worker_entry.dart— the worker-side dispatch shell. Avoid main()that installs amessagelistener on the worker’s global scope, bootstraps the PDFium module viaimportScripts('pdfium.js')(the worker-context equivalent of the main thread’s<script>-tag injection — DOM APIs are unavailable in worker scope), owns the module and the document registry (Map<int, ({int docPtr, int bufPtr})>, exactly mirroring native’s isolate-side registry shape), and dispatches incoming requests to the engine functions above. This file is pre-compiled once viadart compile js(make build_wasm_worker) into the checked-inlib/assets/pdfium_worker.jsartifact — seespec/01_binary_distribution.md._pdfium_worker_protocol.dart— the pure-Dart (nodart:js_interop) wire format:WorkerRequest/WorkerResponseenvelopes with an integer correlation id (replacing whatSendPort/ReceivePortgive for free to the native isolate),WorkerOpconstants (one per operation, mirroring the nativePdfiumCommandsubclasses), and JSON encode/decode functions for everyPdfDocumentresult type, including the fullPdfAnnotationsealed-class hierarchy. This file is imported by both the main-thread client and the worker entry point, despite the two being compiled into separate JS bundles — each compiles its own copy of the logic independently, so this does not create a shared runtime dependency._pdfium_worker_wire.dart— thedart:js_interop-dependent glue that translatesWorkerRequest/WorkerResponseto/from the plainJSObjectshape actually sent overpostMessage._document_web.dart— the thin main-threadPdfDocumentImplRPC client. Owns no PDFium state directly; every method builds a request, sends it, and decodes the response.
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:
_pdfium_wasm_engine.dart(the marshalling logic) stays plain, directly-callable Dart, exercised both by the worker at runtime and directly bytest/pdfium_wasm_engine_test.dart(which bypasses the worker entirely, calling the bootstrap and engine functions on the main thread) — this is what keeps it visible to the web coverage gate._pdfium_worker_entry.dart’s dispatch shell (the part that only ever runs inside the worker) is marked// coverage:ignore-start/-end, consistent with the project’s existing convention for platform-dispatch code in this same file’s native counterpart,pdfium_isolate.dart.
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
Define the command — extend
PdfiumCommand, document all fields, putreplyPortfirst, keep fieldsfinal.Define the response — extend
PdfiumResponsewith.success(…)and.failure(…)named constructors and anisSuccessgetter (see convention above).Add a dispatch branch — add an
else if (message is YourCommand)branch in the message handler loop inpdfium_isolate.dart. Wrap all PDFium handle lifecycle in atry/finallyto guarantee cleanup (see below).Implement the public method — send the command,
awaitthe reply port, cast the response, and propagate errors via the establishedPdfErrorexception path.Stub and web — add the method to
_document_stub.dartand_document_web.dart._document_stub.dartmust throwUnsupportedError— notUnimplementedError— to signal that the fallback platform does not support the operation, consistent with all other unsupported-platform methods in this codebase._document_web.dartshould implement the operation via aWorkerOpround-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:
- Encode the Dart
Stringto aUint16List(UTF-16LE code units). - Allocate a native buffer with
calloc<Uint16>(codeUnits.length + 1)— the+1provides the null terminator;calloczero-fills. - Copy the code units into the buffer.
- Pass the buffer pointer as
FPDF_WIDESTRING. - Free the buffer with
calloc.free(ptr)in atry/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:
- Call
FPDFBookmark_GetAction. If the action is non-null, inspectFPDFAction_GetType:PDFACTION_GOTO(1): callFPDFAction_GetDest→ page index viaFPDFDest_GetDestPageIndex. Optionally extract an XYZ scroll position.PDFACTION_URI(3): callFPDFAction_GetURIPath→uristring.- All other action types (
PDFACTION_REMOTEGOTO,PDFACTION_LAUNCH,PDFACTION_EMBEDDEDGOTO,PDFACTION_UNSUPPORTED): bothpageIndexanduriarenull.
- If the action is null, call
FPDFBookmark_GetDestdirectly → page index. - If both the action and the direct destination are null, the entry is
a section label with no navigation target. Both
pageIndexanduriarenull.
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
- Each entry is indented two spaces per nesting level (root entries are indented two spaces).
- Internal-page destinations show
→ page N(1-based). - URI destinations show
→ <uri>. - Section-label entries with no target show the title only.
- When the document has no bookmarks,
(no bookmarks)is printed.
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:
PdfError.passwordRequired— the document is password-protected. Passwords are not supported in v1; callers should surface a meaningful message to the user.PdfError.invalidDocument— the bytes are corrupt, truncated, or not a valid PDF.
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:
- First pass — iterate every annotation index;
extract non-
POPUPannotations and recordPOPUPannotation pointers with their handle addresses. - 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:
- Extracting embedded photographs or diagrams for display or storage.
- Inspecting image metadata (DPI, colourspace) for quality assurance.
- Detecting scanned pages by examining image dimensions relative to page size.
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:
FPDF_LoadPage— open the page handle.FPDFPage_GetObject(page, objectIndex)— O(1) index access.- Verify object type is
FPDF_PAGEOBJ_IMAGE. FPDFImageObj_GetRenderedBitmap— composite the image with transforms and mask.- Copy BGRA bytes into a
Uint8List, accounting for stride padding. FPDFBitmap_DestroyandFPDF_ClosePage— release native handles.- Return
PdfImageBitmap(ornull).
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():
- Cancelling the subscription immediately stops further processing.
Page-level PDFium handles (
FPDF_TEXTPAGE,FPDF_SCHHANDLE) are released inside the isolate after each per-page round-trip completes, so there are no handle leaks on cancellation. PdfDocument.close()terminates any active stream. The stream stops emitting events and the subscription is silently cancelled. Callers do not need to cancel streams manually before callingclose().- Each isolate round-trip handles one page: loads the text page, runs
the full find loop for that page, closes all handles, and returns the
matches as a list. This mirrors
extractPlainText()andextractAnnotations().
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:
FPDF_LoadPage— may fail (bad page index or corrupt page); returns error on failure.FPDFText_LoadPage— may return null (no text layer); returns empty matches, not an error.- UTF-16LE buffer allocation.
FPDFText_FindStart— starts the search; may return null; returns empty matches if null.- Loop:
FPDFText_FindNext→FPDFText_GetSchResultIndex/FPDFText_GetSchCount→FPDFText_CountRects/FPDFText_GetRect. FPDFText_FindClose— always called in afinallyblock.calloc.free— frees the UTF-16LE buffer (always in afinallyblock).FPDFText_ClosePage— always called in afinallyblock.FPDF_ClosePage— always called in afinallyblock.
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_widgets’
PageView 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
Embedded thumbnail present. If the page has an embedded
/Thumbstream, PDFium decodes it viaFPDFPage_GetThumbnailAsBitmap. The bitmap is read, row padding is stripped, and the result is returned as aPdfThumbnailwithsource: PdfThumbnailSource.embeddedat its native dimensions.maxDimensionis ignored.No embedded thumbnail,
generateIfAbsent: true(default). The page is rendered via the existingrenderPageToBytespipeline. The render dimensions are computed by scaling the page’s intrinsic PDF size (getPageSize()) so the longest edge equalsmaxDimensionpixels, preserving aspect ratio and clamping the short edge to at minimum 1 pixel. The result is returned as aPdfThumbnailwithsource: PdfThumbnailSource.rendered.No embedded thumbnail,
generateIfAbsent: false.nullis 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.fromUint8List →
ImageDescriptor.raw → instantiateCodec →
codec.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.dart11.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-managerRun tests on the default iOS simulator:
make ios_testThis 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_createEnvironment 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_testThis 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_createEnvironment 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_stop11.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:
- Update
packages/betto_pdfium/BBLANCHON_BUILDwith the new build number (e.g.7906→7907). - Run
make repack_ios_xcframework— downloads bblanchon iOS device + simulator tarballs, repacks them intopdfium.xcframework, and uploads the zip to a newbettongia/pdfiumGitHub Release taggedbblanchon-chromium-<BUILD>. - Run
make update_pdfium_manifest— downloads each bblanchon tarball, computes SHA-256s, rewritesversion_pdfium.jsonandlib/src/pdfium_version.dart, and updatesPackage.swiftwith the new iOS xcframework URL and checksum. - Run
make fetch_pdfiumto install the binary and headers locally. - If the PDFium public API changed: run
make ffi_bindingsto regeneratelib/src/generated/pdfium_bindings.dart. - Run
make pre_committo verify everything passes. - 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:
- All tests pass:
make pre_commit - Coverage ≥ 90%:
make coverage - Both packages have identical version numbers in their
pubspec.yamlfiles. - Each package’s
CHANGELOG.mdhas an entry for the new version (packages/betto_pdfium/CHANGELOG.mdandpackages/betto_pdfium_ios/CHANGELOG.md). There is no repo-rootCHANGELOG.md. - The PDFium binary manifest
packages/betto_pdfium/version_pdfium.jsonis committed with checksums for all platforms (see PDFium Binary Distribution). - The install snippets in both package
README.mdfiles 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):
0.x.y— pre-stable; breaking changes allowed between minor versions.1.x.y— stable public API; breaking changes require a major version bump.
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-runResolve any warnings before proceeding. Common issues:
- Missing
README.md(required by pub.dev). publish_to: nonestill set inpubspec.yaml(remove before publishing).- Files that should be excluded listed in
.pubignore.
12.7 Publishing order
12.7.1 Step 1 — publish betto_pdfium
cd packages/betto_pdfium
dart pub publishWait 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 publish12.8 Post-release
- Tag the release in git:
git tag <version> && git push --tags(tags are bare version numbers — e.g.0.1.0, notv0.1.0) - Create a GitHub Release with the tag; paste the relevant
CHANGELOG.mdentry as the release notes. - Announce in the appropriate channels.
12.9 Staying in sync
betto_pdfium and betto_pdfium_ios are
versioned together because:
betto_pdfium_iosbundles the same PDFium build asbetto_pdfium. If the PDFium SHA is bumped (new binary release), both packages must ship together so that the iOS xcframework and the Dart FFI bindings remain at the same upstream commit.- The
DynamicLibrary.process()call in_document_native.dartrelies onbetto_pdfium_ioshaving loaded PDFium symbols into the process. A version mismatch between the two packages could produce subtle ABI errors at runtime.
There is no automated enforcement of this constraint — it is the release author’s responsibility to keep versions in sync.