Line data Source code
1 : // Copyright 2026 The Authors.
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // https://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : /// Public PdfDocument interface with conditional platform import.
16 : ///
17 : /// The platform split is entirely hidden behind this file. Callers import only
18 : /// pdf_document.dart and receive the correct backend automatically:
19 : ///
20 : /// dart.library.ffi → _document_native.dart (iOS, Android, macOS, Windows, Linux)
21 : /// dart.library.js_interop → _document_web.dart (web / WASM)
22 : /// (fallback) → _document_stub.dart (throws UnsupportedError)
23 : library;
24 :
25 : import 'dart:typed_data';
26 :
27 : import '_document_stub.dart'
28 : if (dart.library.ffi) '_document_native.dart'
29 : if (dart.library.js_interop) '_document_web.dart';
30 :
31 : import 'pdf_types.dart';
32 : import '../rendering/pdf_page_size.dart';
33 :
34 : export 'pdf_types.dart';
35 : export '../rendering/pdf_page_size.dart';
36 :
37 : /// A loaded PDF document.
38 : ///
39 : /// [PdfDocument] is the top-level Dart abstraction for a PDF file. It mirrors
40 : /// the PDFium model: `FPDF_LoadMemDocument64` returns a single document handle
41 : /// used for all subsequent operations. [PdfDocument] is the Dart owner of that
42 : /// handle and exposes document-level capabilities as async methods.
43 : ///
44 : /// ## Loading
45 : ///
46 : /// Use [fromBytes] to load a document from raw PDF bytes:
47 : ///
48 : /// ```dart
49 : /// final bytes = await File('document.pdf').readAsBytes();
50 : /// final doc = await PdfDocument.fromBytes(bytes);
51 : /// ```
52 : ///
53 : /// ## Error handling
54 : ///
55 : /// [fromBytes] throws [PdfExtractionException] when the document cannot be
56 : /// loaded. Inspect [PdfExtractionException.error] to distinguish between
57 : /// [PdfError.passwordRequired] and [PdfError.invalidDocument] so callers
58 : /// can give users an actionable message.
59 : ///
60 : /// ## Resource management
61 : ///
62 : /// Always call [close] when the document is no longer needed to release the
63 : /// native PDFium handle:
64 : ///
65 : /// ```dart
66 : /// final doc = await PdfDocument.fromBytes(bytes);
67 : /// try {
68 : /// final meta = await doc.getMetadata();
69 : /// // use meta…
70 : /// } finally {
71 : /// await doc.close();
72 : /// }
73 : /// ```
74 : ///
75 : /// A [Finalizer] is registered internally as a safety net in case [close] is
76 : /// forgotten, but explicit disposal is strongly preferred.
77 : ///
78 : /// ## Platform support
79 : ///
80 : /// The public API is identical on all platforms. The backend differs:
81 : ///
82 : /// | Platform | Backend |
83 : /// | --------------------- | ------------------------- |
84 : /// | iOS, Android, macOS, | dart:ffi + PdfiumIsolate |
85 : /// | Linux, Windows | |
86 : /// | Web | PDFium WASM |
87 : ///
88 : /// On native platforms all PDFium calls run on a dedicated [Isolate] so the
89 : /// caller's isolate (typically the UI isolate) is never blocked. On web,
90 : /// `dart:isolate` is not supported on any compile target, so PDFium calls
91 : /// run inside a dedicated Web Worker instead, communicating with the main
92 : /// thread via a hand-rolled `postMessage` protocol.
93 : ///
94 : /// ## Future capabilities
95 : ///
96 : /// [PdfDocument] is designed to be the foundation for future capabilities:
97 : /// text extraction (`document.openTextExtractor()`), annotation access, and
98 : /// page rendering. This plan establishes the class and its metadata surface;
99 : /// future plans add capabilities without breaking the existing API.
100 : class PdfDocument {
101 10 : PdfDocument._(this._impl);
102 :
103 : final PdfDocumentImpl _impl;
104 :
105 : /// Loads a PDF document from raw [bytes].
106 : ///
107 : /// Returns a [PdfDocument] on success.
108 : ///
109 : /// Throws [PdfExtractionException] with:
110 : /// - [PdfError.passwordRequired] if the document is password-protected.
111 : /// - [PdfError.invalidDocument] if the bytes are corrupt or not a valid PDF.
112 : ///
113 : /// The optional [dylibPath] overrides the default PDFium dynamic library
114 : /// location. It is intended for testing only.
115 10 : static Future<PdfDocument> fromBytes(
116 : Uint8List bytes, {
117 : String? dylibPath,
118 : }) async {
119 10 : final impl = await PdfDocumentImpl.fromBytes(bytes, dylibPath: dylibPath);
120 10 : return PdfDocument._(impl);
121 : }
122 :
123 : /// Returns the metadata extracted from the PDF Info dictionary.
124 : ///
125 : /// All fields on [PdfMetadata] are nullable. A `null` field means the
126 : /// corresponding entry was not present in the document's Info dictionary —
127 : /// this is distinct from a field that is present but empty.
128 : ///
129 : /// Throws [StateError] if [close] has already been called.
130 9 : Future<PdfMetadata> getMetadata() => _impl.getMetadata();
131 :
132 : /// Returns document-level properties: PDF file version and file identifiers.
133 : ///
134 : /// File identifiers ([PdfDocumentInfo.permanentId] and
135 : /// [PdfDocumentInfo.changingId]) are raw bytes, typically 16-byte MD5
136 : /// hashes. Hex-encode them if a string representation is needed:
137 : ///
138 : /// ```dart
139 : /// final info = await doc.getDocumentInfo();
140 : /// final hex = info.permanentId
141 : /// ?.map((b) => b.toRadixString(16).padLeft(2, '0'))
142 : /// .join();
143 : /// ```
144 : ///
145 : /// Throws [StateError] if [close] has already been called.
146 6 : Future<PdfDocumentInfo> getDocumentInfo() => _impl.getDocumentInfo();
147 :
148 : /// The total number of pages in the document.
149 : ///
150 : /// Throws [StateError] if [close] has already been called.
151 12 : Future<int> get pageCount => _impl.pageCount;
152 :
153 : /// Extracts plain text from one or all pages of the document.
154 : ///
155 : /// When [pageIndex] is `null`, the stream yields all pages in index order.
156 : /// When [pageIndex] is specified, the stream yields exactly one [PdfPageText].
157 : ///
158 : /// Throws [RangeError] if [pageIndex] is out of range.
159 : /// Throws [StateError] if the document has been closed before or during
160 : /// extraction.
161 : ///
162 : /// Cancelling the stream subscription immediately stops further processing.
163 : /// Page-level PDFium handles are released after each round-trip, so there
164 : /// are no handle leaks on cancellation.
165 : ///
166 : /// [close] terminates any active stream: the stream stops emitting events
167 : /// and the subscription is silently cancelled.
168 : ///
169 : /// Example — extract all pages and print each one:
170 : ///
171 : /// ```dart
172 : /// await for (final page in doc.extractPlainText()) {
173 : /// if (page.hasTextLayer) {
174 : /// print('Page ${page.pageIndex}: ${page.text}');
175 : /// } else {
176 : /// print('Page ${page.pageIndex}: no text layer (scanned page)');
177 : /// }
178 : /// }
179 : /// ```
180 4 : Stream<PdfPageText> extractPlainText({
181 : int? pageIndex,
182 : PdfTextExtractorConfig config = const PdfTextExtractorConfig(),
183 8 : }) => _impl.extractPlainText(pageIndex: pageIndex, config: config);
184 :
185 : /// Extracts all annotations from one or all pages of the document.
186 : ///
187 : /// When [pageIndex] is `null`, the stream yields one [PdfPageAnnotations] per
188 : /// page in index order. Pages with no annotations emit an entry with an empty
189 : /// [PdfPageAnnotations.annotations] list, so callers can track page coverage
190 : /// without gaps.
191 : ///
192 : /// When [pageIndex] is specified, the stream yields exactly one
193 : /// [PdfPageAnnotations] for that page.
194 : ///
195 : /// Throws [RangeError] if [pageIndex] is out of range.
196 : /// Throws [StateError] if the document has been closed before or during
197 : /// extraction.
198 : ///
199 : /// [close] terminates any active stream: the stream stops emitting events and
200 : /// all page-level annotation handles are released. Callers do not need to
201 : /// cancel streams manually before calling [close].
202 : ///
203 : /// **Note:** This method does not accept a page range — only a single optional
204 : /// page index. Range-based extraction is a known limitation to revisit in a
205 : /// future plan.
206 : ///
207 : /// **Platform support:** Native (dart:ffi) only. Stubs on unsupported
208 : /// platforms throw [UnsupportedError] immediately.
209 : ///
210 : /// Example — collect all highlights from every page:
211 : ///
212 : /// ```dart
213 : /// await for (final page in doc.extractAnnotations()) {
214 : /// for (final annot in page.annotations) {
215 : /// if (annot case PdfMarkupAnnotation(:final subtype, :final quadPoints)
216 : /// when subtype == PdfAnnotationType.highlight) {
217 : /// print('Highlight on page ${page.pageIndex}: $quadPoints');
218 : /// }
219 : /// }
220 : /// }
221 : /// ```
222 2 : Stream<PdfPageAnnotations> extractAnnotations({int? pageIndex}) =>
223 4 : _impl.extractAnnotations(pageIndex: pageIndex);
224 :
225 : /// Returns `true` when fewer than [PdfTextExtractorConfig.scannedPageRatio] of pages lack a
226 : /// text layer (i.e. the document is suitable for plain-text extraction).
227 : ///
228 : /// Internally runs [extractPlainText] to completion and counts pages where
229 : /// [PdfPageText.hasTextLayer] is `false`. Returns `false` when the proportion
230 : /// of such pages meets or exceeds [PdfTextExtractorConfig.scannedPageRatio].
231 : ///
232 : /// Use per-page [PdfPageText.hasTextLayer] for finer-grained control.
233 : ///
234 : /// Throws [StateError] if the document has been closed.
235 2 : Future<bool> isPlainTextExtractable({
236 : PdfTextExtractorConfig config = const PdfTextExtractorConfig(),
237 4 : }) => _impl.isPlainTextExtractable(config: config);
238 :
239 : /// Returns the intrinsic size of a page in PDF user units (points).
240 : ///
241 : /// One PDF user unit equals 1/72 inch. This is a storage-level measurement
242 : /// independent of rendering resolution. Use [PdfPageSize.sizeForDpi] to
243 : /// convert to pixel dimensions for a [renderPageToBytes] call.
244 : ///
245 : /// Throws [RangeError] if [pageIndex] is out of range.
246 : /// Throws [StateError] if [close] has already been called.
247 : ///
248 : /// ## Example
249 : ///
250 : /// ```dart
251 : /// final size = await doc.getPageSize(0);
252 : /// final px = size.sizeForDpi(150);
253 : /// final result = await doc.renderPageToBytes(0, px.width.round(), px.height.round());
254 : /// ```
255 2 : Future<PdfPageSize> getPageSize(int pageIndex) =>
256 4 : _impl.getPageSize(pageIndex);
257 :
258 : /// Renders a page to a raw BGRA pixel buffer.
259 : ///
260 : /// The page at [pageIndex] is rendered at [pixelWidth] × [pixelHeight]
261 : /// pixels. The returned record exposes `pixels` (BGRA bytes),
262 : /// [pixelWidth], and [pixelHeight].
263 : ///
264 : /// For Flutter apps, decode the returned BGRA bytes into a `dart:ui Image`
265 : /// via `decodeImageFromPixels`.
266 : ///
267 : /// [renderAnnotations] maps to the PDFium `FPDF_ANNOT` flag (default true).
268 : /// [lcdText] maps to `FPDF_LCD_TEXT` (default false).
269 : /// [backgroundColor] is an ARGB packed integer; default `0xFFFFFFFF`
270 : /// (opaque white).
271 : ///
272 : /// Throws [RangeError] if [pageIndex] is out of range.
273 : /// Throws [StateError] if [close] has been called before or during render.
274 : /// Throws [PdfiumException] if a PDFium native call fails.
275 1 : Future<({Uint8List pixels, int pixelWidth, int pixelHeight})>
276 : renderPageToBytes(
277 : int pageIndex,
278 : int pixelWidth,
279 : int pixelHeight, {
280 : bool renderAnnotations = true,
281 : bool lcdText = false,
282 : int backgroundColor = 0xFFFFFFFF,
283 2 : }) => _impl.renderPageToBytes(
284 : pageIndex,
285 : pixelWidth,
286 : pixelHeight,
287 : renderAnnotations: renderAnnotations,
288 : lcdText: lcdText,
289 : backgroundColor: backgroundColor,
290 : );
291 :
292 : /// Extracts all image objects from one or all pages of the document.
293 : ///
294 : /// When [pageIndex] is `null`, the stream yields one [PdfPageImages] per
295 : /// page in index order. Pages with no image objects emit an entry with an
296 : /// empty [PdfPageImages.images] list so callers can track page coverage
297 : /// without gaps.
298 : ///
299 : /// When [pageIndex] is specified, the stream yields exactly one
300 : /// [PdfPageImages] for that page.
301 : ///
302 : /// ## Bitmap mode
303 : ///
304 : /// When [includeBitmap] is `false` (the default), [PdfImage.bgra],
305 : /// [PdfImage.bitmapWidth], and [PdfImage.bitmapHeight] are `null` on every
306 : /// returned [PdfImage]. Only metadata ([PdfImage.metadata]) and the
307 : /// bounding box ([PdfImage.bounds]) are populated. This is the cheap,
308 : /// memory-efficient path for enumerating images.
309 : ///
310 : /// When [includeBitmap] is `true`, the rendered BGRA bitmap is fetched for
311 : /// every image object on each page. For documents with many large photographs
312 : /// this can produce very large allocations. Prefer calling [renderImage]
313 : /// selectively after inspecting [PdfImageMetadata] for image dimensions and
314 : /// colorspace.
315 : ///
316 : /// ## Image masks
317 : ///
318 : /// Image mask objects (`metadata.bitsPerPixel == 1`) are included in the
319 : /// output and are not suppressed automatically. Callers can identify them
320 : /// via `image.metadata.bitsPerPixel == 1`.
321 : ///
322 : /// ## Error handling
323 : ///
324 : /// Throws [RangeError] if [pageIndex] is out of range.
325 : /// Throws [StateError] if the document has been closed before or during
326 : /// extraction.
327 : ///
328 : /// [close] terminates any active stream: the stream stops emitting events
329 : /// and the subscription is silently cancelled. Callers do not need to cancel
330 : /// streams manually before calling [close].
331 : ///
332 : /// **Platform support:** Native (dart:ffi) only. Stubs on unsupported
333 : /// platforms throw [UnsupportedError] immediately.
334 : ///
335 : /// Example — collect all JPEG images from a document:
336 : ///
337 : /// ```dart
338 : /// await for (final page in doc.extractImages()) {
339 : /// for (final img in page.images) {
340 : /// if (img.filters.contains('DCTDecode')) {
341 : /// final bitmap = await doc.renderImage(img.pageIndex, img.objectIndex);
342 : /// // use bitmap…
343 : /// }
344 : /// }
345 : /// }
346 : /// ```
347 2 : Stream<PdfPageImages> extractImages({
348 : int? pageIndex,
349 : bool includeBitmap = false,
350 4 : }) => _impl.extractImages(pageIndex: pageIndex, includeBitmap: includeBitmap);
351 :
352 : /// Fetches the rendered BGRA bitmap for a single image object on a page.
353 : ///
354 : /// [pageIndex] is the zero-based page index. [objectIndex] is the position
355 : /// of the image object in the page's object list, as reported by
356 : /// [PdfImage.objectIndex] from [extractImages].
357 : ///
358 : /// Returns a [PdfImageBitmap] containing the composited BGRA pixel data,
359 : /// or `null` when the object has no renderable bitmap (e.g. a mask-only
360 : /// image where `FPDFImageObj_GetRenderedBitmap` returns null).
361 : ///
362 : /// Each call is one isolate round-trip. For bulk extraction of many images
363 : /// from the same page, prefer calling [extractImages] with
364 : /// `includeBitmap: true` instead.
365 : ///
366 : /// Throws [RangeError] if [pageIndex] is out of range for the document.
367 : /// Throws [RangeError] if [objectIndex] is negative, or if [objectIndex]
368 : /// is out of range for the page (the object does not exist).
369 : /// Throws [StateError] if the document has been closed.
370 : ///
371 : /// **Platform support:** Native (dart:ffi) only. Stubs on unsupported
372 : /// platforms throw [UnsupportedError].
373 : ///
374 : /// Example — render only large images:
375 : ///
376 : /// ```dart
377 : /// await for (final page in doc.extractImages()) {
378 : /// for (final img in page.images) {
379 : /// if (img.metadata.width > 500 && img.metadata.height > 500) {
380 : /// final bitmap = await doc.renderImage(img.pageIndex, img.objectIndex);
381 : /// if (bitmap != null) {
382 : /// // process bitmap.bgra…
383 : /// }
384 : /// }
385 : /// }
386 : /// }
387 : /// ```
388 1 : Future<PdfImageBitmap?> renderImage(int pageIndex, int objectIndex) =>
389 2 : _impl.renderImage(pageIndex, objectIndex);
390 :
391 : /// Searches the document for [query] and streams all matches.
392 : ///
393 : /// Results are yielded page-by-page in ascending page order. An empty stream
394 : /// means no matches were found. An empty [query] string returns an empty
395 : /// stream immediately without invoking any PDFium calls.
396 : ///
397 : /// [flags] controls case-sensitivity, whole-word matching, and overlapping
398 : /// matches. Defaults to case-insensitive, non-whole-word, non-overlapping.
399 : ///
400 : /// When [pageIndex] is specified, the search is restricted to that single
401 : /// page. Omit it to search all pages. Throws [RangeError] if [pageIndex] is
402 : /// out of range for the document.
403 : ///
404 : /// Bounding rectangles in each [PdfSearchMatch] are in **PDF user-space**
405 : /// (origin bottom-left, units in points). Callers that need screen-space
406 : /// coordinates must apply `FPDF_PageToDevice()` themselves.
407 : ///
408 : /// Cancelling the stream subscription immediately stops further processing.
409 : /// Page-level PDFium handles are released inside the isolate after each
410 : /// round-trip, so there are no handle leaks on cancellation.
411 : ///
412 : /// [close] terminates any active stream: the stream stops emitting events
413 : /// and the subscription is silently cancelled.
414 : ///
415 : /// Throws [StateError] if the document has been closed before or during
416 : /// the search.
417 : ///
418 : /// **Platform support:** Native (dart:ffi) only. Stubs on unsupported
419 : /// platforms throw [UnsupportedError] immediately.
420 : ///
421 : /// Example — search for a term and print each match location:
422 : ///
423 : /// ```dart
424 : /// await for (final match in doc.search('example')) {
425 : /// print('Match on page ${match.pageIndex + 1}: '
426 : /// 'char ${match.charIndex}, '
427 : /// '${match.rects.length} rect(s)');
428 : /// }
429 : /// ```
430 1 : Stream<PdfSearchMatch> search(
431 : String query, {
432 : Set<PdfSearchFlag> flags = const {},
433 : int? pageIndex,
434 2 : }) => _impl.search(query, flags: flags, pageIndex: pageIndex);
435 :
436 : /// Returns the complete Table of Contents (bookmark/outline tree) for the
437 : /// document.
438 : ///
439 : /// Each [PdfTocEntry] in the returned list is a root-level bookmark entry.
440 : /// [PdfTocEntry.children] provides nested sub-entries at arbitrary depth.
441 : ///
442 : /// Returns an empty list when the document has no bookmarks — this is not
443 : /// an error condition.
444 : ///
445 : /// Throws [StateError] if [close] has already been called.
446 : ///
447 : /// **Platform support:** Native (dart:ffi) only. Stubs on unsupported
448 : /// platforms throw [UnsupportedError] immediately.
449 : ///
450 : /// Example — print all top-level bookmark titles and their page numbers:
451 : ///
452 : /// ```dart
453 : /// final toc = await doc.tableOfContents;
454 : /// for (final entry in toc) {
455 : /// final page = entry.pageIndex != null ? 'page ${entry.pageIndex! + 1}' : '(no target)';
456 : /// print('${entry.title} → $page');
457 : /// }
458 : /// ```
459 6 : Future<List<PdfTocEntry>> get tableOfContents => _impl.tableOfContents;
460 :
461 : /// Returns a thumbnail image for the page at [pageIndex].
462 : ///
463 : /// When the page contains an embedded `/Thumb` stream, that bitmap is decoded
464 : /// and returned at its native dimensions with
465 : /// [PdfThumbnailSource.embedded]. Not all PDFs contain embedded thumbnails —
466 : /// modern tools such as `pdflatex` typically do not produce them.
467 : ///
468 : /// When no embedded thumbnail is present and [generateIfAbsent] is `true`
469 : /// (the default), the page is rendered at a size proportional to
470 : /// [maxDimension] (longest edge ≤ [maxDimension] pixels, aspect ratio
471 : /// preserved) and returned with [PdfThumbnailSource.rendered].
472 : ///
473 : /// When no embedded thumbnail is present and [generateIfAbsent] is `false`,
474 : /// `null` is returned without any render pass. This is useful for callers
475 : /// that only wish to surface natively-embedded previews.
476 : ///
477 : /// [maxDimension] is a **logical pixel budget** — it applies only to the
478 : /// fallback render path and is ignored for embedded thumbnails. On high-DPI
479 : /// displays (e.g. Retina), multiply [maxDimension] by
480 : /// `MediaQuery.of(context).devicePixelRatio` before calling to obtain a
481 : /// full-resolution fallback render. This method lives in the pure-Dart layer
482 : /// and cannot access `MediaQuery` itself.
483 : ///
484 : /// ## Error contract
485 : ///
486 : /// Throws [RangeError] if [pageIndex] is out of range for the document.
487 : /// Throws [ArgumentError] if [maxDimension] ≤ 0.
488 : /// Throws [StateError] if [close] has been called before or during the call.
489 : /// Throws [PdfiumException] if a PDFium native call fails unexpectedly.
490 : ///
491 : /// **Platform support:** Native (dart:ffi) only. Stubs on unsupported
492 : /// platforms throw [UnsupportedError] immediately.
493 : ///
494 : /// Example — display a thumbnail in a Flutter widget:
495 : ///
496 : /// ```dart
497 : /// final dpr = MediaQuery.of(context).devicePixelRatio;
498 : /// final thumb = await doc.getThumbnail(0, maxDimension: (256 * dpr).round());
499 : /// if (thumb != null) {
500 : /// // thumb.bgra is BGRA bytes; thumb.width × thumb.height is the size.
501 : /// }
502 : /// ```
503 2 : Future<PdfThumbnail?> getThumbnail(
504 : int pageIndex, {
505 : bool generateIfAbsent = true,
506 : int maxDimension = 256,
507 4 : }) => _impl.getThumbnail(
508 : pageIndex,
509 : generateIfAbsent: generateIfAbsent,
510 : maxDimension: maxDimension,
511 : );
512 :
513 : /// Closes the document and releases the native PDFium handle.
514 : ///
515 : /// Safe to call more than once — subsequent calls are no-ops. After [close]
516 : /// returns, all other methods throw [StateError].
517 : ///
518 : /// Any active [extractPlainText] stream is terminated: the stream stops
519 : /// emitting events and the subscription is silently cancelled. Callers do
520 : /// not need to cancel streams manually before calling [close].
521 30 : Future<void> close() => _impl.close();
522 : }
|