LCOV - code coverage report
Current view: top level - src/document - _document_native.dart Coverage Total Hit
Test: lcov.info Lines: 91.7 % 216 198
Test Date: 2026-08-24 04:28:45 Functions: - 0 0

            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              : // Native backend for PdfDocument (dart:ffi + PdfiumIsolate).
      16              : //
      17              : // This file is selected by the conditional import in pdf_document.dart when
      18              : // dart.library.ffi is present (iOS, Android, macOS, Windows, Linux).
      19              : //
      20              : // All PDFium FFI calls are routed through PdfiumIsolate, which is a
      21              : // process-wide singleton that owns the single dedicated PDFium isolate.
      22              : // Callers never interact with the isolate directly.
      23              : 
      24              : import 'dart:async';
      25              : import 'dart:typed_data';
      26              : 
      27              : import 'isolate_messages.dart';
      28              : import 'pdf_types.dart';
      29              : import 'pdfium_isolate.dart';
      30              : import '../pdf_exception.dart';
      31              : import '../rendering/pdf_page_size.dart';
      32              : 
      33              : /// Native implementation of [PdfDocument] using dart:ffi and [PdfiumIsolate].
      34              : ///
      35              : /// All PDFium operations run on the shared PDFium isolate. Methods are
      36              : /// [Future]-returning; callers never interact with native code or the isolate
      37              : /// directly.
      38              : ///
      39              : /// Use [fromBytes] to load a document. Always call [close] when done to
      40              : /// release the native document handle. A [Finalizer] is registered as a
      41              : /// safety net, but explicit [close] is preferred.
      42              : class PdfDocumentImpl {
      43           10 :   PdfDocumentImpl._(this._token, this._isolate) {
      44              :     // Register a Finalizer as a safety net against forgotten close() calls.
      45              :     // If the Dart GC collects this object without close() having been called,
      46              :     // the finalizer sends the close command to the isolate.
      47              :     //
      48              :     // This is a best-effort mechanism — the isolate may have already been
      49              :     // torn down by the time the finalizer runs. close() is the primary path.
      50           50 :     _finalizer.attach(this, _FinalizerToken(_token, _isolate), detach: this);
      51              :   }
      52              : 
      53              :   final int _token;
      54              :   final PdfiumIsolate _isolate;
      55              :   bool _closed = false;
      56              : 
      57              :   // Finalizer for the native document handle. The token type is a simple
      58              :   // record so the finalizer callback can send the close command without
      59              :   // holding a reference to the (potentially GC'd) PdfDocumentImpl.
      60           20 :   static final Finalizer<_FinalizerToken> _finalizer =
      61           10 :       Finalizer<_FinalizerToken>((token) async {
      62              :         // Best-effort: send close command. Errors are silently swallowed —
      63              :         // we are in a finalizer callback, not a user-controlled call site.
      64              :         // coverage:ignore-start
      65              :         // The Finalizer callback is invoked by the GC when a PdfDocumentImpl
      66              :         // is collected without close() being called. This is non-deterministic
      67              :         // and cannot be reliably triggered in a test suite.
      68              :         try {
      69              :           await token.isolate.send<PdfiumCloseDocumentResponse>(
      70              :             (replyPort) =>
      71              :                 PdfiumCloseDocumentCommand(replyPort, token.docToken),
      72              :           );
      73              :         } catch (_) {
      74              :           // Ignore — the isolate may no longer be running.
      75              :         }
      76              :         // coverage:ignore-end
      77              :       });
      78              : 
      79              :   /// Loads a PDF document from raw [bytes].
      80              :   ///
      81              :   /// Returns a [PdfDocumentImpl] on success. Throws [PdfExtractionException]
      82              :   /// if the document is invalid, corrupt, or password-protected.
      83              :   ///
      84              :   /// The optional [dylibPath] overrides the default PDFium library location;
      85              :   /// it is used in tests to inject a path to the staged dylib.
      86           10 :   static Future<PdfDocumentImpl> fromBytes(
      87              :     Uint8List bytes, {
      88              :     String? dylibPath,
      89              :   }) async {
      90           10 :     final isolate = await PdfiumIsolate.ensureInitialised(dylibPath: dylibPath);
      91              : 
      92           10 :     final response = await isolate.send<PdfiumLoadDocumentResponse>(
      93           20 :       (replyPort) => PdfiumLoadDocumentCommand(replyPort, bytes),
      94              :     );
      95              : 
      96           10 :     if (!response.isSuccess) {
      97            2 :       throw PdfExtractionException(response.error!);
      98              :     }
      99              : 
     100           20 :     return PdfDocumentImpl._(response.token!, isolate);
     101              :   }
     102              : 
     103              :   /// Returns the metadata extracted from the PDF Info dictionary.
     104              :   ///
     105              :   /// All fields on the returned [PdfMetadata] are nullable; a `null` value
     106              :   /// means the field was not present in the document's Info dictionary.
     107              :   ///
     108              :   /// Throws [StateError] if [close] has already been called.
     109            3 :   Future<PdfMetadata> getMetadata() async {
     110            3 :     _checkNotClosed();
     111            4 :     final response = await _isolate.send<PdfiumGetMetadataResponse>(
     112            6 :       (replyPort) => PdfiumGetMetadataCommand(replyPort, _token),
     113              :     );
     114            2 :     if (response.metadata == null) {
     115            0 :       throw PdfExtractionException(response.error!);
     116              :     }
     117            2 :     return response.metadata!;
     118              :   }
     119              : 
     120              :   /// Returns document-level properties: file version and file identifiers.
     121              :   ///
     122              :   /// File identifiers are raw bytes (typically 16-byte MD5 hashes). Use
     123              :   /// hex encoding if a string representation is needed.
     124              :   ///
     125              :   /// Throws [StateError] if [close] has already been called.
     126            2 :   Future<PdfDocumentInfo> getDocumentInfo() async {
     127            2 :     _checkNotClosed();
     128            2 :     final response = await _isolate.send<PdfiumGetDocumentInfoResponse>(
     129            3 :       (replyPort) => PdfiumGetDocumentInfoCommand(replyPort, _token),
     130              :     );
     131            1 :     if (response.info == null) {
     132            0 :       throw PdfExtractionException(response.error!);
     133              :     }
     134            1 :     return response.info!;
     135              :   }
     136              : 
     137              :   /// Returns the total number of pages in the document.
     138              :   ///
     139              :   /// Throws [StateError] if [close] has already been called.
     140            8 :   Future<int> get pageCount async {
     141            8 :     _checkNotClosed();
     142           14 :     final response = await _isolate.send<PdfiumGetPageCountResponse>(
     143           21 :       (replyPort) => PdfiumGetPageCountCommand(replyPort, _token),
     144              :     );
     145            7 :     if (response.pageCount == null) {
     146            0 :       throw PdfExtractionException(response.error!);
     147              :     }
     148            7 :     return response.pageCount!;
     149              :   }
     150              : 
     151              :   /// Extracts plain text from one or all pages of the document.
     152              :   ///
     153              :   /// When [pageIndex] is null, the stream yields all pages in index order.
     154              :   /// When [pageIndex] is specified, the stream yields exactly one [PdfPageText].
     155              :   ///
     156              :   /// Throws [RangeError] if [pageIndex] is out of range.
     157              :   /// Throws [StateError] if the document has been closed before or during
     158              :   /// extraction.
     159              :   ///
     160              :   /// Cancelling the subscription immediately stops further processing. Any
     161              :   /// page-level PDFium handles are released within the isolate after each
     162              :   /// round-trip completes, so there are no handle leaks on cancellation.
     163              :   ///
     164              :   /// [PdfDocumentImpl.close] terminates any active stream: the stream simply
     165              :   /// stops emitting events and the subscription is silently cancelled.
     166            4 :   Stream<PdfPageText> extractPlainText({
     167              :     int? pageIndex,
     168              :     PdfTextExtractorConfig config = const PdfTextExtractorConfig(),
     169              :   }) {
     170              :     // Use an async generator so that cancellation via StreamSubscription.cancel()
     171              :     // causes the generator to exit cleanly at the next yield/await point.
     172            4 :     return _extractPlainTextImpl(pageIndex: pageIndex, config: config);
     173              :   }
     174              : 
     175              :   /// Internal async generator implementing [extractPlainText].
     176            4 :   Stream<PdfPageText> _extractPlainTextImpl({
     177              :     int? pageIndex,
     178              :     required PdfTextExtractorConfig config,
     179              :   }) async* {
     180            4 :     _checkNotClosed();
     181              : 
     182              :     // Determine which page indices to process.
     183            3 :     final count = await pageCount;
     184            3 :     _checkNotClosed();
     185              : 
     186              :     final List<int> indices;
     187              :     if (pageIndex != null) {
     188            4 :       if (pageIndex < 0 || pageIndex >= count) {
     189            2 :         throw RangeError.range(pageIndex, 0, count - 1, 'pageIndex');
     190              :       }
     191            1 :       indices = [pageIndex];
     192              :     } else {
     193            4 :       indices = List.generate(count, (i) => i);
     194              :     }
     195              : 
     196            4 :     for (final idx in indices) {
     197              :       // Check closed state on each iteration so that PdfDocumentImpl.close()
     198              :       // terminates the stream promptly. We check before issuing the command
     199              :       // to avoid sending a command to the isolate for a closed document.
     200            2 :       if (_closed) return;
     201              : 
     202            4 :       final response = await _isolate.send<PdfiumExtractPageTextResponse>(
     203            6 :         (replyPort) => PdfiumExtractPageTextCommand(replyPort, _token, idx),
     204              :       );
     205              : 
     206            2 :       if (!response.isSuccess) {
     207            0 :         throw PdfExtractionException(response.error!);
     208              :       }
     209              : 
     210            2 :       yield PdfPageText(
     211            2 :         pageIndex: response.pageIndex,
     212            2 :         text: response.text,
     213            2 :         hasUnicodeErrors: response.hasUnicodeErrors,
     214            2 :         hasTextLayer: response.hasTextLayer,
     215              :       );
     216              :     }
     217              :   }
     218              : 
     219              :   /// Extracts all annotations from one or all pages of the document.
     220              :   ///
     221              :   /// When [pageIndex] is null, the stream yields one [PdfPageAnnotations] per
     222              :   /// page in index order. Pages with no annotations emit an entry with an empty
     223              :   /// [PdfPageAnnotations.annotations] list so callers can track page coverage.
     224              :   ///
     225              :   /// When [pageIndex] is specified, the stream yields exactly one
     226              :   /// [PdfPageAnnotations] for that page.
     227              :   ///
     228              :   /// Throws [RangeError] if [pageIndex] is out of range.
     229              :   /// Throws [StateError] if the document has been closed before or during
     230              :   /// extraction.
     231              :   ///
     232              :   /// [PdfDocumentImpl.close] terminates any active stream: the stream stops
     233              :   /// emitting events and the subscription is silently cancelled, releasing all
     234              :   /// page-level annotation handles.
     235            2 :   Stream<PdfPageAnnotations> extractAnnotations({int? pageIndex}) {
     236            2 :     return _extractAnnotationsImpl(pageIndex: pageIndex);
     237              :   }
     238              : 
     239              :   /// Internal async generator implementing [extractAnnotations].
     240            2 :   Stream<PdfPageAnnotations> _extractAnnotationsImpl({int? pageIndex}) async* {
     241            2 :     _checkNotClosed();
     242              : 
     243            1 :     final count = await pageCount;
     244            1 :     _checkNotClosed();
     245              : 
     246              :     final List<int> indices;
     247              :     if (pageIndex != null) {
     248            2 :       if (pageIndex < 0 || pageIndex >= count) {
     249            2 :         throw RangeError.range(pageIndex, 0, count - 1, 'pageIndex');
     250              :       }
     251            1 :       indices = [pageIndex];
     252              :     } else {
     253            2 :       indices = List.generate(count, (i) => i);
     254              :     }
     255              : 
     256            2 :     for (final idx in indices) {
     257              :       // Check closed state before each isolate round-trip so that close()
     258              :       // terminates the stream promptly without sending commands for a closed doc.
     259            1 :       if (_closed) return;
     260              : 
     261            1 :       final response = await _isolate
     262            1 :           .send<PdfiumExtractPageAnnotationsResponse>(
     263            1 :             (replyPort) =>
     264            2 :                 PdfiumExtractPageAnnotationsCommand(replyPort, _token, idx),
     265              :           );
     266              : 
     267            1 :       if (!response.isSuccess) {
     268            0 :         throw PdfExtractionException(response.error!);
     269              :       }
     270              : 
     271            1 :       yield PdfPageAnnotations(
     272            1 :         pageIndex: response.pageIndex,
     273            1 :         annotations: response.annotations,
     274              :       );
     275              :     }
     276              :   }
     277              : 
     278              :   /// Returns true when fewer than [config.scannedPageRatio] of pages lack a
     279              :   /// text layer.
     280              :   ///
     281              :   /// Internally runs [extractPlainText] to completion and counts pages where
     282              :   /// [PdfPageText.hasTextLayer] is false. Returns false when the proportion
     283              :   /// of such pages meets or exceeds [config.scannedPageRatio].
     284              :   ///
     285              :   /// Use per-page [PdfPageText.hasTextLayer] for finer-grained control.
     286              :   ///
     287              :   /// Throws [StateError] if the document has been closed.
     288            2 :   Future<bool> isPlainTextExtractable({
     289              :     PdfTextExtractorConfig config = const PdfTextExtractorConfig(),
     290              :   }) async {
     291              :     var totalPages = 0;
     292              :     var noTextLayerPages = 0;
     293              : 
     294            6 :     await for (final page in extractPlainText(config: config)) {
     295            2 :       totalPages++;
     296            2 :       if (!page.hasTextLayer) noTextLayerPages++;
     297              :     }
     298              : 
     299            2 :     if (totalPages == 0) return false;
     300              : 
     301            2 :     final scannedRatio = noTextLayerPages / totalPages;
     302            4 :     return scannedRatio < config.scannedPageRatio;
     303              :   }
     304              : 
     305              :   /// Returns the intrinsic size of a page in PDF user units (points).
     306              :   ///
     307              :   /// One PDF user unit equals 1/72 inch. This is a storage-level measurement
     308              :   /// independent of rendering resolution. Use [PdfPageSize.sizeForDpi] to
     309              :   /// convert to pixel dimensions for a [renderPage] call.
     310              :   ///
     311              :   /// Throws [RangeError] if [pageIndex] is out of range.
     312              :   /// Throws [StateError] if [close] has already been called.
     313            3 :   Future<PdfPageSize> getPageSize(int pageIndex) async {
     314            3 :     _checkNotClosed();
     315              : 
     316              :     // Validate the page index against the document page count before
     317              :     // dispatching to the isolate, so callers receive a RangeError rather
     318              :     // than a generic isolate failure for out-of-range indices.
     319            2 :     final count = await pageCount;
     320            2 :     _checkNotClosed();
     321            4 :     RangeError.checkValidIndex(pageIndex, _PageIndexRange(count), 'pageIndex');
     322              : 
     323            4 :     final response = await _isolate.send<PdfiumGetPageSizeResponse>(
     324            6 :       (replyPort) => PdfiumGetPageSizeCommand(replyPort, _token, pageIndex),
     325              :     );
     326            2 :     if (!response.isSuccess) {
     327            0 :       throw PdfExtractionException(response.error!);
     328              :     }
     329            2 :     return response.pageSize!;
     330              :   }
     331              : 
     332              :   /// Renders a page to a raw BGRA pixel buffer.
     333              :   ///
     334              :   /// The page at [pageIndex] is rendered at [pixelWidth] × [pixelHeight]
     335              :   /// pixels. The returned record contains the BGRA [pixels] and the actual
     336              :   /// [pixelWidth] and [pixelHeight] from the render command.
     337              :   ///
     338              :   /// [renderAnnotations] maps to the PDFium `FPDF_ANNOT` flag.
     339              :   /// [lcdText] maps to the PDFium `FPDF_LCD_TEXT` flag.
     340              :   /// [backgroundColor] is an ARGB packed integer (e.g. `0xFFFFFFFF` for
     341              :   /// opaque white) passed directly to `FPDFBitmap_FillRect`.
     342              :   ///
     343              :   /// Throws [RangeError] if [pageIndex] is out of range.
     344              :   /// Throws [StateError] if [close] has been called before or during the
     345              :   /// render.
     346              :   /// Throws [PdfiumException] if a PDFium native call fails unexpectedly.
     347            2 :   Future<({Uint8List pixels, int pixelWidth, int pixelHeight})>
     348              :   renderPageToBytes(
     349              :     int pageIndex,
     350              :     int pixelWidth,
     351              :     int pixelHeight, {
     352              :     bool renderAnnotations = true,
     353              :     bool lcdText = false,
     354              :     int backgroundColor = 0xFFFFFFFF,
     355              :   }) async {
     356            2 :     _checkNotClosed();
     357              : 
     358              :     // Validate index eagerly against the page count.
     359            2 :     final count = await pageCount;
     360            2 :     _checkNotClosed();
     361            4 :     RangeError.checkValidIndex(pageIndex, _PageIndexRange(count), 'pageIndex');
     362              : 
     363              :     // FPDF_ANNOT = 0x01, FPDF_LCD_TEXT = 0x02 (defined in fpdfview.h).
     364              :     var flags = 0;
     365            2 :     if (renderAnnotations) flags |= 0x01;
     366            1 :     if (lcdText) flags |= 0x02;
     367              : 
     368            4 :     final response = await _isolate.send<PdfiumRenderPageResponse>(
     369            4 :       (replyPort) => PdfiumRenderPageCommand(
     370              :         replyPort,
     371            2 :         _token,
     372              :         pageIndex,
     373              :         pixelWidth,
     374              :         pixelHeight,
     375              :         flags,
     376              :         backgroundColor,
     377              :       ),
     378              :     );
     379              : 
     380            2 :     if (!response.isSuccess) {
     381            1 :       final msg = response.errorMessage;
     382            1 :       if (msg.startsWith('Document token')) {
     383            0 :         throw StateError(
     384              :           'PdfDocument has been closed. '
     385              :           'Create a new PdfDocument with PdfDocument.fromBytes().',
     386              :         );
     387              :       }
     388            1 :       throw PdfiumException(msg);
     389              :     }
     390              : 
     391              :     return (
     392            2 :       pixels: response.pixels,
     393            2 :       pixelWidth: response.pixelWidth,
     394            2 :       pixelHeight: response.pixelHeight,
     395              :     );
     396              :   }
     397              : 
     398              :   /// Extracts all image objects from one or all pages of the document.
     399              :   ///
     400              :   /// When [pageIndex] is null, the stream yields one [PdfPageImages] per page
     401              :   /// in index order. Pages with no image objects emit an entry with an empty
     402              :   /// [PdfPageImages.images] list so callers can track page coverage without gaps.
     403              :   ///
     404              :   /// When [pageIndex] is specified, the stream yields exactly one [PdfPageImages]
     405              :   /// for that page.
     406              :   ///
     407              :   /// When [includeBitmap] is false (the default), [PdfImage.bgra],
     408              :   /// [PdfImage.bitmapWidth], and [PdfImage.bitmapHeight] are all null on
     409              :   /// every returned [PdfImage] — only metadata and bounds are populated. This
     410              :   /// is the fast, memory-efficient path for enumerating images.
     411              :   ///
     412              :   /// When [includeBitmap] is true, the rendered BGRA bitmap is fetched for
     413              :   /// every image object. For documents with many large images this can produce
     414              :   /// large allocations. Prefer calling [renderImage] selectively after
     415              :   /// inspecting [PdfImageMetadata] for image dimensions and colorspace.
     416              :   ///
     417              :   /// Throws [RangeError] if [pageIndex] is out of range.
     418              :   /// Throws [StateError] if the document has been closed before or during
     419              :   /// extraction.
     420              :   ///
     421              :   /// [close] terminates any active stream.
     422            2 :   Stream<PdfPageImages> extractImages({
     423              :     int? pageIndex,
     424              :     bool includeBitmap = false,
     425              :   }) {
     426            2 :     return _extractImagesImpl(
     427              :       pageIndex: pageIndex,
     428              :       includeBitmap: includeBitmap,
     429              :     );
     430              :   }
     431              : 
     432              :   /// Internal async generator implementing [extractImages].
     433            2 :   Stream<PdfPageImages> _extractImagesImpl({
     434              :     int? pageIndex,
     435              :     required bool includeBitmap,
     436              :   }) async* {
     437            2 :     _checkNotClosed();
     438              : 
     439            1 :     final count = await pageCount;
     440            1 :     _checkNotClosed();
     441              : 
     442              :     final List<int> indices;
     443              :     if (pageIndex != null) {
     444            2 :       if (pageIndex < 0 || pageIndex >= count) {
     445            2 :         throw RangeError.range(pageIndex, 0, count - 1, 'pageIndex');
     446              :       }
     447            1 :       indices = [pageIndex];
     448              :     } else {
     449            2 :       indices = List.generate(count, (i) => i);
     450              :     }
     451              : 
     452            2 :     for (final idx in indices) {
     453              :       // Check closed state before each isolate round-trip so that close()
     454              :       // terminates the stream promptly without sending commands for a closed doc.
     455            1 :       if (_closed) return;
     456              : 
     457            2 :       final response = await _isolate.send<PdfiumExtractPageImagesResponse>(
     458            2 :         (replyPort) => PdfiumExtractPageImagesCommand(
     459              :           replyPort,
     460            1 :           _token,
     461              :           idx,
     462              :           includeBitmap: includeBitmap,
     463              :         ),
     464              :       );
     465              : 
     466            1 :       if (!response.isSuccess) {
     467            0 :         throw PdfExtractionException(response.error!);
     468              :       }
     469              : 
     470            1 :       yield PdfPageImages(
     471            1 :         pageIndex: response.pageIndex,
     472            1 :         images: response.images,
     473              :       );
     474              :     }
     475              :   }
     476              : 
     477              :   /// Fetches the rendered BGRA bitmap for a single image object on a page.
     478              :   ///
     479              :   /// [pageIndex] and [objectIndex] together identify the image: [objectIndex]
     480              :   /// is the position in the page's object list, as reported by
     481              :   /// [PdfImage.objectIndex] from [extractImages].
     482              :   ///
     483              :   /// Returns a [PdfImageBitmap] with the composited BGRA pixel data, or `null`
     484              :   /// when the object has no renderable bitmap (e.g. a mask-only object where
     485              :   /// `FPDFImageObj_GetRenderedBitmap` returns null).
     486              :   ///
     487              :   /// Throws [RangeError] if [pageIndex] or [objectIndex] is out of range.
     488              :   /// Throws [StateError] if the document has been closed.
     489            1 :   Future<PdfImageBitmap?> renderImage(int pageIndex, int objectIndex) async {
     490            1 :     _checkNotClosed();
     491              : 
     492              :     // Validate page index eagerly.
     493            1 :     final count = await pageCount;
     494            1 :     _checkNotClosed();
     495            2 :     if (pageIndex < 0 || pageIndex >= count) {
     496            2 :       throw RangeError.range(pageIndex, 0, count - 1, 'pageIndex');
     497              :     }
     498              : 
     499              :     // Validate object index: we need the page object count, which requires
     500              :     // a round-trip to the isolate. We use a metadata-only extractImages call
     501              :     // on the single page to get the image count efficiently. However, the
     502              :     // objectIndex is a raw page-object index (not just an image index), so we
     503              :     // cannot validate it against image count alone. Instead, we dispatch the
     504              :     // render command and treat a null bitmap from the isolate for a null-object
     505              :     // case as an out-of-range signal.
     506              :     //
     507              :     // A dedicated object-count validation call would require another message
     508              :     // type. Instead, per the plan spec, FPDFPage_GetObject returns null for
     509              :     // out-of-range indices and the isolate returns bitmap: null in that case.
     510              :     // We map that to a RangeError here only when the caller passes a negative
     511              :     // index (clearly invalid without an isolate round-trip).
     512            1 :     if (objectIndex < 0) {
     513            1 :       throw RangeError.value(objectIndex, 'objectIndex');
     514              :     }
     515              : 
     516            2 :     final response = await _isolate.send<PdfiumRenderImageResponse>(
     517            1 :       (replyPort) =>
     518            2 :           PdfiumRenderImageCommand(replyPort, _token, pageIndex, objectIndex),
     519              :     );
     520              : 
     521            1 :     if (!response.isSuccess) {
     522            0 :       if (response.error == PdfError.invalidDocument) {
     523              :         // The isolate could not load the page — treat as out-of-range.
     524            0 :         throw RangeError.range(pageIndex, 0, count - 1, 'pageIndex');
     525              :       }
     526            0 :       throw PdfExtractionException(response.error!);
     527              :     }
     528              : 
     529            1 :     return response.bitmap;
     530              :   }
     531              : 
     532              :   /// Searches the document for [query] and streams all matches.
     533              :   ///
     534              :   /// Results are yielded page-by-page in ascending page order. An empty stream
     535              :   /// means no matches were found. An empty [query] string returns an empty
     536              :   /// stream immediately without issuing any PDFium calls.
     537              :   ///
     538              :   /// [flags] controls case-sensitivity, whole-word matching, and overlapping
     539              :   /// matches. Defaults to case-insensitive, non-whole-word, non-overlapping.
     540              :   ///
     541              :   /// When [pageIndex] is specified, the search is restricted to that page.
     542              :   /// Throws [RangeError] if [pageIndex] is out of range.
     543              :   ///
     544              :   /// Throws [StateError] if the document has been closed before or during
     545              :   /// the search.
     546            1 :   Stream<PdfSearchMatch> search(
     547              :     String query, {
     548              :     Set<PdfSearchFlag> flags = const {},
     549              :     int? pageIndex,
     550              :   }) {
     551            1 :     return _searchImpl(query, flags: flags, pageIndex: pageIndex);
     552              :   }
     553              : 
     554              :   /// Internal async generator implementing [search].
     555            1 :   Stream<PdfSearchMatch> _searchImpl(
     556              :     String query, {
     557              :     required Set<PdfSearchFlag> flags,
     558              :     int? pageIndex,
     559              :   }) async* {
     560              :     // Guard: empty query returns an empty stream immediately.
     561            1 :     if (query.isEmpty) return;
     562              : 
     563            1 :     _checkNotClosed();
     564              : 
     565              :     // Build the PDFium flags bitmask from the [PdfSearchFlag] set.
     566              :     // FPDF_MATCHCASE = 0x01, FPDF_MATCHWHOLEWORD = 0x02, FPDF_CONSECUTIVE = 0x04.
     567              :     var flagsMask = 0;
     568            2 :     if (flags.contains(PdfSearchFlag.matchCase)) flagsMask |= 0x01;
     569            2 :     if (flags.contains(PdfSearchFlag.matchWholeWord)) flagsMask |= 0x02;
     570            1 :     if (flags.contains(PdfSearchFlag.consecutive)) flagsMask |= 0x04;
     571              : 
     572            1 :     final count = await pageCount;
     573            1 :     _checkNotClosed();
     574              : 
     575              :     final List<int> indices;
     576              :     if (pageIndex != null) {
     577            2 :       if (pageIndex < 0 || pageIndex >= count) {
     578            2 :         throw RangeError.range(pageIndex, 0, count - 1, 'pageIndex');
     579              :       }
     580            1 :       indices = [pageIndex];
     581              :     } else {
     582            2 :       indices = List.generate(count, (i) => i);
     583              :     }
     584              : 
     585            2 :     for (final idx in indices) {
     586              :       // Check closed state before each isolate round-trip.
     587            1 :       if (_closed) return;
     588              : 
     589            2 :       final response = await _isolate.send<PdfiumSearchPageResponse>(
     590            1 :         (replyPort) =>
     591            2 :             PdfiumSearchPageCommand(replyPort, _token, idx, query, flagsMask),
     592              :       );
     593              : 
     594            1 :       if (!response.isSuccess) {
     595            0 :         throw PdfExtractionException(response.error!);
     596              :       }
     597              : 
     598              :       // Yield each match from this page individually so callers get results
     599              :       // incrementally (early termination via stream cancel is supported).
     600            2 :       for (final match in response.matches) {
     601            1 :         if (_closed) return;
     602              :         yield match;
     603              :       }
     604              :     }
     605              :   }
     606              : 
     607              :   /// Returns a thumbnail for the page at [pageIndex].
     608              :   ///
     609              :   /// When the page has an embedded `/Thumb` stream, that bitmap is returned
     610              :   /// with [PdfThumbnailSource.embedded] at its native dimensions.
     611              :   ///
     612              :   /// When no embedded thumbnail is present and [generateIfAbsent] is `true`
     613              :   /// (the default), the page is rendered at a size where the longest edge is
     614              :   /// at most [maxDimension] pixels, preserving aspect ratio, and returned with
     615              :   /// [PdfThumbnailSource.rendered].
     616              :   ///
     617              :   /// When no embedded thumbnail is present and [generateIfAbsent] is `false`,
     618              :   /// `null` is returned without any render pass.
     619              :   ///
     620              :   /// [maxDimension] only affects the fallback render path. Embedded thumbnails
     621              :   /// are returned at their native size.
     622              :   ///
     623              :   /// Throws [RangeError] if [pageIndex] is out of range.
     624              :   /// Throws [ArgumentError] if [maxDimension] ≤ 0.
     625              :   /// Throws [StateError] if [close] has been called before or during the call.
     626              :   /// Throws [PdfiumException] if a PDFium native call fails.
     627            2 :   Future<PdfThumbnail?> getThumbnail(
     628              :     int pageIndex, {
     629              :     bool generateIfAbsent = true,
     630              :     int maxDimension = 256,
     631              :   }) async {
     632            2 :     if (maxDimension <= 0) {
     633            1 :       throw ArgumentError.value(
     634              :         maxDimension,
     635              :         'maxDimension',
     636              :         'maxDimension must be greater than 0',
     637              :       );
     638              :     }
     639            2 :     _checkNotClosed();
     640              : 
     641              :     // Validate the page index against the live page count before dispatching
     642              :     // to the isolate, so callers receive a RangeError for out-of-range values.
     643            1 :     final count = await pageCount;
     644            1 :     _checkNotClosed();
     645            2 :     RangeError.checkValidIndex(pageIndex, _PageIndexRange(count), 'pageIndex');
     646              : 
     647              :     // Ask the isolate to extract the embedded thumbnail (if any).
     648            2 :     final response = await _isolate.send<PdfiumGetPageThumbnailResponse>(
     649            1 :       (replyPort) =>
     650            2 :           PdfiumGetPageThumbnailCommand(replyPort, _token, pageIndex),
     651              :     );
     652              : 
     653            1 :     if (!response.isSuccess) {
     654            0 :       final msg = response.errorMessage;
     655            0 :       if (msg.startsWith('Document token')) {
     656            0 :         throw StateError(
     657              :           'PdfDocument has been closed. '
     658              :           'Create a new PdfDocument with PdfDocument.fromBytes().',
     659              :         );
     660              :       }
     661            0 :       throw PdfiumException(msg);
     662              :     }
     663              : 
     664              :     // If an embedded thumbnail was found, return it immediately.
     665            1 :     if (response.bgra != null) {
     666            1 :       return PdfThumbnail(
     667            1 :         bgra: response.bgra!,
     668            1 :         width: response.width,
     669            1 :         height: response.height,
     670              :         source: PdfThumbnailSource.embedded,
     671              :       );
     672              :     }
     673              : 
     674              :     // No embedded thumbnail. If the caller does not want a fallback, return null.
     675              :     if (!generateIfAbsent) return null;
     676              : 
     677              :     // Fallback: render the page at a size proportional to maxDimension.
     678              :     // Call _checkNotClosed() again because close() may have been called
     679              :     // between the thumbnail round-trip above and the page-size round-trip
     680              :     // below — consistent with the guard pattern in _extractPlainTextImpl.
     681            1 :     _checkNotClosed();
     682            1 :     final size = await getPageSize(pageIndex);
     683            1 :     _checkNotClosed();
     684              : 
     685              :     // Scale so the longest edge equals maxDimension, preserving aspect ratio.
     686              :     // Ensure a minimum of 1 pixel on the short edge to avoid zero-dimension
     687              :     // renders on extremely elongated pages.
     688              :     final double scale;
     689            3 :     if (size.widthPt >= size.heightPt) {
     690            2 :       scale = maxDimension / size.widthPt;
     691              :     } else {
     692            0 :       scale = maxDimension / size.heightPt;
     693              :     }
     694            4 :     final pixelWidth = (size.widthPt * scale).round().clamp(1, maxDimension);
     695            4 :     final pixelHeight = (size.heightPt * scale).round().clamp(1, maxDimension);
     696              : 
     697              :     // renderPageToBytes re-throws StateError / PdfiumException directly — do
     698              :     // not wrap here, consistent with all other methods in this class.
     699            1 :     final rendered = await renderPageToBytes(
     700              :       pageIndex,
     701              :       pixelWidth,
     702              :       pixelHeight,
     703              :     );
     704              : 
     705            1 :     return PdfThumbnail(
     706              :       bgra: rendered.pixels,
     707              :       width: rendered.pixelWidth,
     708              :       height: rendered.pixelHeight,
     709              :       source: PdfThumbnailSource.rendered,
     710              :     );
     711              :   }
     712              : 
     713              :   /// Returns the complete Table of Contents (bookmark/outline tree) for the
     714              :   /// document.
     715              :   ///
     716              :   /// Each [PdfTocEntry] in the returned list is a root-level bookmark entry.
     717              :   /// Children are accessed via [PdfTocEntry.children], forming a tree of
     718              :   /// arbitrary depth.
     719              :   ///
     720              :   /// Returns an empty list when the document has no bookmarks — this is not
     721              :   /// an error condition.
     722              :   ///
     723              :   /// Throws [StateError] if [close] has already been called.
     724            2 :   Future<List<PdfTocEntry>> get tableOfContents async {
     725            2 :     _checkNotClosed();
     726            2 :     final response = await _isolate.send<PdfiumGetTocResponse>(
     727            3 :       (replyPort) => PdfiumGetTocCommand(replyPort, _token),
     728              :     );
     729            1 :     if (!response.isSuccess) {
     730            0 :       throw PdfExtractionException(response.error!);
     731              :     }
     732            1 :     return response.entries!;
     733              :   }
     734              : 
     735              :   /// Closes the document and releases the native PDFium handle.
     736              :   ///
     737              :   /// Safe to call more than once — subsequent calls are no-ops.
     738              :   /// After [close] returns, all other methods throw [StateError].
     739              :   ///
     740              :   /// Any active [extractPlainText] stream is terminated: the stream stops
     741              :   /// emitting events and the subscription is silently cancelled. Callers do
     742              :   /// not need to cancel streams manually before calling [close].
     743           10 :   Future<void> close() async {
     744           10 :     if (_closed) return;
     745           10 :     _closed = true;
     746              :     // Detach the finalizer so it does not send a second close after GC.
     747           20 :     _finalizer.detach(this);
     748           20 :     await _isolate.send<PdfiumCloseDocumentResponse>(
     749           30 :       (replyPort) => PdfiumCloseDocumentCommand(replyPort, _token),
     750              :     );
     751              :   }
     752              : 
     753              :   /// Throws [StateError] if the document has already been closed.
     754           10 :   void _checkNotClosed() {
     755           10 :     if (_closed) {
     756            8 :       throw StateError(
     757              :         'PdfDocument has been closed. '
     758              :         'Create a new PdfDocument with PdfDocument.fromBytes().',
     759              :       );
     760              :     }
     761              :   }
     762              : }
     763              : 
     764              : /// Data carrier for the [PdfDocumentImpl] finalizer.
     765              : ///
     766              : /// Holds the minimum information needed to send a close command without
     767              : /// retaining a reference to the [PdfDocumentImpl] itself (which would prevent
     768              : /// GC and make the finalizer never fire).
     769              : class _FinalizerToken {
     770           10 :   const _FinalizerToken(this.docToken, this.isolate);
     771              : 
     772              :   /// The opaque document token.
     773              :   final int docToken;
     774              : 
     775              :   /// The isolate to send the close command to.
     776              :   final PdfiumIsolate isolate;
     777              : }
     778              : 
     779              : /// Minimal [Iterable] adapter that satisfies [RangeError.checkValidIndex]'s
     780              : /// requirement for a `length` getter.
     781              : ///
     782              : /// [RangeError.checkValidIndex] expects an indexable object with a [length]
     783              : /// property. This thin wrapper around a page count avoids allocating a
     784              : /// real list just to validate a page index.
     785              : class _PageIndexRange {
     786            2 :   const _PageIndexRange(this.length);
     787              : 
     788              :   /// The number of pages in the document.
     789              :   final int length;
     790              : }
        

Generated by: LCOV version 2.0-1