LCOV - code coverage report
Current view: top level - src/document - pdfium_isolate.dart Coverage Total Hit
Test: lcov.info Lines: 94.3 % 768 724
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              : // PdfiumIsolate — process-wide singleton that owns the PDFium dynamic library
      16              : // and routes all PDFium FFI calls through a dedicated Dart Isolate.
      17              : //
      18              : // Design rationale:
      19              : //
      20              : //   PDFium is not thread-safe. FPDF_InitLibraryWithConfig() is a one-time
      21              : //   process-wide call; spawning a second isolate would call it again, which is
      22              : //   a correctness bug (double initialisation). All PDFium operations must
      23              : //   therefore happen on a single dedicated isolate — the "PDFium isolate".
      24              : //
      25              : //   PdfiumIsolate is the native-platform singleton that owns this isolate.
      26              : //   All PdfDocument instances share it. The isolate is lazily spawned on the
      27              : //   first PdfDocument.fromBytes() call and held for the lifetime of the process.
      28              : //   It is never torn down when documents are closed, because re-spawning for
      29              : //   each document would re-initialise PDFium unnecessarily.
      30              : //
      31              : //   All public PdfDocument methods are Future-returning; callers never interact
      32              : //   with the isolate directly.
      33              : //
      34              : // Isolate boundary note:
      35              : //
      36              : //   dart:ffi Pointer values cannot cross isolate boundaries (they are
      37              : //   platform-specific addresses). Document handles are therefore stored in a
      38              : //   registry inside the PDFium isolate and exposed to callers as opaque
      39              : //   integer tokens (the pointer address cast to int). The token is meaningless
      40              : //   outside the PDFium isolate.
      41              : 
      42              : import 'dart:ffi' as ffi;
      43              : import 'dart:io' show Directory, File, Platform;
      44              : import 'dart:isolate';
      45              : import 'dart:typed_data';
      46              : 
      47              : import 'package:ffi/ffi.dart';
      48              : import 'package:meta/meta.dart';
      49              : 
      50              : import '../generated/pdfium_bindings.dart';
      51              : import '../pdfium_version.dart';
      52              : import '../rendering/pdf_page_size.dart';
      53              : import '_bitmap_utils.dart';
      54              : import 'isolate_messages.dart';
      55              : import 'pdf_date_parser.dart';
      56              : import 'pdf_types.dart';
      57              : 
      58              : // ---------------------------------------------------------------------------
      59              : // Isolate entry point (runs entirely inside the spawned isolate)
      60              : // ---------------------------------------------------------------------------
      61              : 
      62              : /// The entry point for the PDFium isolate.
      63              : ///
      64              : /// This is a top-level function (required by [Isolate.spawn]). It runs
      65              : /// entirely within the spawned isolate and handles all PDFium FFI calls.
      66              : ///
      67              : /// [bootstrapPort] is the [SendPort] on which the main isolate listens for
      68              : /// the [PdfiumInitCommand], which provides the dylib path and the reply port.
      69           10 : void pdfiumIsolateEntryPoint(SendPort bootstrapPort) {
      70              :   // Create the isolate's receive port for all commands after initialisation.
      71           10 :   final commandReceivePort = ReceivePort();
      72              : 
      73              :   // Registry: maps opaque int tokens to _DocumentEntry records.
      74              :   // Tokens are assigned as monotonically increasing integers.
      75              :   //
      76              :   // Each entry stores both the FPDF_DOCUMENT pointer address and the address
      77              :   // of the native Uint8 buffer that holds the raw PDF bytes. PDFium's
      78              :   // FPDF_LoadMemDocument64 does NOT copy the caller's buffer — the buffer
      79              :   // must remain allocated for the entire lifetime of the open document.
      80              :   // The buffer is freed in _handleCloseDocument alongside FPDF_CloseDocument.
      81              :   //
      82              :   // Addresses are stored as ints because dart:ffi Pointer values cannot be
      83              :   // stored in closures across message handling boundaries in a way the Dart VM
      84              :   // can safely GC. The int address is reconstructed into a Pointer inside the
      85              :   // isolate when needed.
      86           10 :   final Map<int, ({int docAddress, int bufferAddress})> openDocuments = {};
      87              :   var nextToken = 1;
      88              : 
      89              :   PdfiumBindings? bindings;
      90              : 
      91              :   // Listen for all incoming messages on the command port.
      92           20 :   commandReceivePort.listen((dynamic message) {
      93           10 :     if (message is PdfiumInitCommand) {
      94              :       // Initialise the library and send the command port back.
      95              :       try {
      96           10 :         final dylib = message.dylibPath != null
      97           20 :             ? ffi.DynamicLibrary.open(message.dylibPath!)
      98            0 :             : _openLibrary();
      99           10 :         bindings = PdfiumBindings(dylib);
     100           20 :         bindings!.FPDF_InitLibraryWithConfig(ffi.nullptr);
     101           40 :         message.replyPort.send(PdfiumInitResponse(commandReceivePort.sendPort));
     102              :       } catch (e) {
     103              :         // Signal failure by sending a null port — the main isolate will throw.
     104            4 :         message.replyPort.send(PdfiumInitFailedResponse('$e'));
     105              :       }
     106              :     } else if (bindings == null) {
     107              :       // Commands received before initialisation are ignored (should not happen
     108              :       // in normal use, since ensureInitialised() awaits PdfiumInitResponse).
     109              :     } else {
     110              :       // All non-init commands: dispatch with a top-level catch so that any
     111              :       // unhandled exception surfaces as an error response (instead of
     112              :       // silently killing the isolate and causing 30-second timeouts).
     113              :       try {
     114           10 :         if (message is PdfiumLoadDocumentCommand) {
     115           10 :           _handleLoadDocument(
     116              :             message,
     117              :             bindings!,
     118              :             openDocuments,
     119              :             nextToken,
     120           10 :             (t) => nextToken = t,
     121              :           );
     122           10 :         } else if (message is PdfiumGetMetadataCommand) {
     123            2 :           _handleGetMetadata(message, bindings!, openDocuments);
     124           10 :         } else if (message is PdfiumGetDocumentInfoCommand) {
     125            1 :           _handleGetDocumentInfo(message, bindings!, openDocuments);
     126           10 :         } else if (message is PdfiumCloseDocumentCommand) {
     127           10 :           _handleCloseDocument(message, bindings!, openDocuments);
     128            8 :         } else if (message is PdfiumGetPageCountCommand) {
     129            7 :           _handleGetPageCount(message, bindings!, openDocuments);
     130            8 :         } else if (message is PdfiumExtractPageTextCommand) {
     131            2 :           _handleExtractPageText(message, bindings!, openDocuments);
     132            6 :         } else if (message is PdfiumExtractPageAnnotationsCommand) {
     133            1 :           _handleExtractPageAnnotations(message, bindings!, openDocuments);
     134            5 :         } else if (message is PdfiumGetPageSizeCommand) {
     135            2 :           _handleGetPageSize(message, bindings!, openDocuments);
     136            5 :         } else if (message is PdfiumRenderPageCommand) {
     137            2 :           _handleRenderPage(message, bindings!, openDocuments);
     138            4 :         } else if (message is PdfiumGetTocCommand) {
     139            1 :           _handleGetToc(message, bindings!, openDocuments);
     140            3 :         } else if (message is PdfiumExtractPageImagesCommand) {
     141            1 :           _handleExtractPageImages(message, bindings!, openDocuments);
     142            3 :         } else if (message is PdfiumRenderImageCommand) {
     143            1 :           _handleRenderImage(message, bindings!, openDocuments);
     144            2 :         } else if (message is PdfiumSearchPageCommand) {
     145            1 :           _handleSearchPage(message, bindings!, openDocuments);
     146            1 :         } else if (message is PdfiumGetPageThumbnailCommand) {
     147            1 :           _handleGetPageThumbnail(message, bindings!, openDocuments);
     148              :         }
     149              :       } catch (e, stack) {
     150              :         // An unhandled exception in a command handler must never silently kill
     151              :         // the isolate — that produces 30-second timeouts with no diagnostics.
     152              :         // Send a PdfiumHandlerErrorResponse so the main isolate surfaces the
     153              :         // exception message in its StateError instead of just timing out.
     154            0 :         if (message is PdfiumCommand) {
     155            0 :           message.replyPort.send(PdfiumHandlerErrorResponse('$e', '$stack'));
     156              :         }
     157              :       }
     158              :     }
     159              :   });
     160              : 
     161              :   // Send the command port to the main isolate immediately. The main isolate
     162              :   // then sends PdfiumInitCommand on this port before any other commands.
     163           20 :   bootstrapPort.send(commandReceivePort.sendPort);
     164              : }
     165              : 
     166              : // ---------------------------------------------------------------------------
     167              : // Command handlers (run inside the spawned isolate)
     168              : // ---------------------------------------------------------------------------
     169              : 
     170              : /// Loads a PDF document from bytes and registers it in the open-documents map.
     171           10 : void _handleLoadDocument(
     172              :   PdfiumLoadDocumentCommand cmd,
     173              :   PdfiumBindings bindings,
     174              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
     175              :   int currentToken,
     176              :   void Function(int) updateToken,
     177              : ) {
     178           10 :   final bytes = cmd.bytes;
     179              : 
     180              :   // Allocate a native buffer and copy the PDF bytes into it.
     181              :   //
     182              :   // IMPORTANT: FPDF_LoadMemDocument64 does NOT copy the caller's buffer — it
     183              :   // maps the provided memory for the lifetime of the open document. The buffer
     184              :   // must remain allocated until FPDF_CloseDocument is called. We store the
     185              :   // buffer address in the registry alongside the document pointer and free it
     186              :   // in _handleCloseDocument (or on load failure below).
     187           10 :   final nativePtr = calloc<ffi.Uint8>(bytes.length);
     188           20 :   final nativeList = nativePtr.asTypedList(bytes.length);
     189           10 :   nativeList.setAll(0, bytes);
     190              : 
     191              :   // FPDF_LoadMemDocument64 returns a null pointer on failure.
     192              :   // We pass ffi.nullptr for password — open (unencrypted) documents only.
     193           10 :   final docPtr = bindings.FPDF_LoadMemDocument64(
     194           10 :     nativePtr.cast<ffi.Void>(),
     195           10 :     bytes.length,
     196           10 :     ffi.nullptr, // no password
     197              :   );
     198              : 
     199           20 :   if (docPtr == ffi.nullptr) {
     200              :     // Load failed — free the buffer immediately since no document holds it.
     201            1 :     calloc.free(nativePtr);
     202              :     // FPDF_ERR_PASSWORD = 4 (defined in fpdfview.h)
     203            1 :     final errorCode = bindings.FPDF_GetLastError();
     204            1 :     final error = errorCode == 4
     205              :         ? PdfError.passwordRequired
     206              :         : PdfError.invalidDocument;
     207            3 :     cmd.replyPort.send(PdfiumLoadDocumentResponse.failure(error));
     208              :   } else {
     209              :     // Store both the document pointer address and the buffer address.
     210              :     // The buffer is freed when the document is closed via _handleCloseDocument.
     211              :     final token = currentToken;
     212           20 :     updateToken(currentToken + 1);
     213           10 :     openDocuments[token] = (
     214           10 :       docAddress: docPtr.address,
     215           10 :       bufferAddress: nativePtr.address,
     216              :     );
     217           30 :     cmd.replyPort.send(PdfiumLoadDocumentResponse.success(token));
     218              :   }
     219              : }
     220              : 
     221              : /// Reads all eight Info dictionary fields from an open document.
     222            2 : void _handleGetMetadata(
     223              :   PdfiumGetMetadataCommand cmd,
     224              :   PdfiumBindings bindings,
     225              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
     226              : ) {
     227            4 :   final entry = openDocuments[cmd.token];
     228              :   if (entry == null) {
     229              :     // coverage:ignore-start
     230              :     // Token-not-found is a defensive guard. The Dart-level _checkNotClosed()
     231              :     // guard in _document_native.dart prevents commands from reaching the
     232              :     // isolate after close(), so this path is unreachable in practice.
     233              :     cmd.replyPort.send(
     234              :       PdfiumGetMetadataResponse.failure(PdfError.invalidDocument),
     235              :     );
     236              :     return;
     237              :     // coverage:ignore-end
     238              :   }
     239              : 
     240              :   // Reconstruct the FPDF_DOCUMENT pointer from the stored address.
     241            2 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
     242              : 
     243              :   // The eight standard PDF Info dictionary tags.
     244              :   const fieldNames = <String>[
     245              :     'Title',
     246              :     'Author',
     247              :     'Subject',
     248              :     'Keywords',
     249              :     'Creator',
     250              :     'Producer',
     251              :     'CreationDate',
     252              :     'ModDate',
     253              :   ];
     254              : 
     255            2 :   final values = <String, String?>{};
     256            4 :   for (final tag in fieldNames) {
     257            4 :     values[tag] = _readMetaText(bindings, docPtr, tag);
     258              :   }
     259              : 
     260            2 :   final metadata = PdfMetadata(
     261            2 :     title: values['Title'],
     262            2 :     author: values['Author'],
     263            2 :     subject: values['Subject'],
     264            2 :     keywords: values['Keywords'],
     265            2 :     creator: values['Creator'],
     266            2 :     producer: values['Producer'],
     267            4 :     creationDate: PdfDateParser.parse(values['CreationDate']),
     268            4 :     modDate: PdfDateParser.parse(values['ModDate']),
     269              :   );
     270              : 
     271            6 :   cmd.replyPort.send(PdfiumGetMetadataResponse.success(metadata));
     272              : }
     273              : 
     274              : /// Reads a single metadata field using the PDFium two-call buffer pattern.
     275              : ///
     276              : /// Returns the field value, or `null` when the field is absent in the Info
     277              : /// dictionary. An absent field is indicated by a 2-byte result (a single
     278              : /// UTF-16LE null character — an empty string).
     279            2 : String? _readMetaText(
     280              :   PdfiumBindings bindings,
     281              :   ffi.Pointer<fpdf_document_t__> docPtr,
     282              :   String tag,
     283              : ) {
     284              :   // Encode the tag as a null-terminated UTF-8 C string.
     285              :   // We use package:ffi's toNativeUtf8() for correctness (handles non-ASCII).
     286            2 :   final tagCStr = tag.toNativeUtf8(allocator: calloc);
     287              :   try {
     288            2 :     final tagPtr = tagCStr.cast<ffi.Char>();
     289              : 
     290              :     // First call: pass null buffer / zero length to get the required byte count.
     291            2 :     final requiredLen = bindings.FPDF_GetMetaText(
     292              :       docPtr,
     293              :       tagPtr,
     294            2 :       ffi.nullptr,
     295              :       0,
     296              :     );
     297              : 
     298              :     // A length of 0 or 2 means the field is absent. An empty UTF-16LE string
     299              :     // consists of a single null character = 2 bytes.
     300            2 :     if (requiredLen <= 2) return null;
     301              : 
     302              :     // Second call: allocate the buffer and fill it.
     303              :     final buffer = calloc<ffi.Uint8>(requiredLen);
     304              :     try {
     305            2 :       bindings.FPDF_GetMetaText(
     306              :         docPtr,
     307              :         tagPtr,
     308            2 :         buffer.cast<ffi.Void>(),
     309              :         requiredLen,
     310              :       );
     311              : 
     312              :       // Decode UTF-16LE. The buffer is (requiredLen) bytes; the last 2 are
     313              :       // the UTF-16LE null terminator — exclude them.
     314            2 :       final byteCount = requiredLen - 2;
     315            2 :       if (byteCount <= 0) return null;
     316              : 
     317            2 :       final codeUnits = <int>[];
     318            4 :       for (var i = 0; i < byteCount; i += 2) {
     319              :         // Little-endian: low byte at i, high byte at i+1.
     320           10 :         final codeUnit = buffer[i] | (buffer[i + 1] << 8);
     321            2 :         codeUnits.add(codeUnit);
     322              :       }
     323              : 
     324            2 :       final result = String.fromCharCodes(codeUnits);
     325            2 :       return result.isEmpty ? null : result;
     326              :     } finally {
     327            2 :       calloc.free(buffer);
     328              :     }
     329              :   } finally {
     330            2 :     calloc.free(tagCStr);
     331              :   }
     332              : }
     333              : 
     334              : /// Reads document-level properties (version and file identifiers).
     335            1 : void _handleGetDocumentInfo(
     336              :   PdfiumGetDocumentInfoCommand cmd,
     337              :   PdfiumBindings bindings,
     338              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
     339              : ) {
     340            2 :   final entry = openDocuments[cmd.token];
     341              :   if (entry == null) {
     342              :     // coverage:ignore-start
     343              :     cmd.replyPort.send(
     344              :       PdfiumGetDocumentInfoResponse.failure(PdfError.invalidDocument),
     345              :     );
     346              :     return;
     347              :     // coverage:ignore-end
     348              :   }
     349              : 
     350            1 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
     351              : 
     352              :   // Read the PDF file version (e.g. 17 for PDF 1.7).
     353              :   int? fileVersion;
     354              :   final versionPtr = calloc<ffi.Int>();
     355              :   try {
     356            1 :     final ok = bindings.FPDF_GetFileVersion(docPtr, versionPtr);
     357            1 :     if (ok != 0) {
     358              :       fileVersion = versionPtr.value;
     359              :     }
     360              :   } finally {
     361            1 :     calloc.free(versionPtr);
     362              :   }
     363              : 
     364              :   // Read both file identifiers (permanent and changing).
     365            1 :   final permanentId = _readFileIdentifier(
     366              :     bindings,
     367              :     docPtr,
     368              :     FPDF_FILEIDTYPE.FILEIDTYPE_PERMANENT,
     369              :   );
     370            1 :   final changingId = _readFileIdentifier(
     371              :     bindings,
     372              :     docPtr,
     373              :     FPDF_FILEIDTYPE.FILEIDTYPE_CHANGING,
     374              :   );
     375              : 
     376            2 :   cmd.replyPort.send(
     377            1 :     PdfiumGetDocumentInfoResponse.success(
     378            1 :       PdfDocumentInfo(
     379              :         fileVersion: fileVersion,
     380              :         permanentId: permanentId,
     381              :         changingId: changingId,
     382              :       ),
     383              :     ),
     384              :   );
     385              : }
     386              : 
     387              : /// Reads a file identifier using the two-call buffer pattern.
     388              : ///
     389              : /// Returns the raw identifier bytes, or `null` if not present.
     390            1 : Uint8List? _readFileIdentifier(
     391              :   PdfiumBindings bindings,
     392              :   ffi.Pointer<fpdf_document_t__> docPtr,
     393              :   FPDF_FILEIDTYPE idType,
     394              : ) {
     395              :   // First call: determine required buffer size (in bytes).
     396            1 :   final requiredLen = bindings.FPDF_GetFileIdentifier(
     397              :     docPtr,
     398              :     idType,
     399            1 :     ffi.nullptr,
     400              :     0,
     401              :   );
     402              : 
     403            1 :   if (requiredLen == 0) return null;
     404              : 
     405              :   // Second call: fill the buffer.
     406              :   final buffer = calloc<ffi.Uint8>(requiredLen);
     407              :   try {
     408            1 :     final ok = bindings.FPDF_GetFileIdentifier(
     409              :       docPtr,
     410              :       idType,
     411            1 :       buffer.cast<ffi.Void>(),
     412              :       requiredLen,
     413              :     );
     414              : 
     415            1 :     if (ok == 0) return null;
     416              : 
     417              :     // Copy the raw bytes into a Dart Uint8List before freeing the native buffer.
     418            1 :     final result = Uint8List(requiredLen);
     419            1 :     final nativeView = buffer.asTypedList(requiredLen);
     420            1 :     result.setAll(0, nativeView);
     421              :     return result;
     422              :   } finally {
     423            1 :     calloc.free(buffer);
     424              :   }
     425              : }
     426              : 
     427              : /// Closes a document and removes it from the open-documents registry.
     428              : ///
     429              : /// Both the PDFium document handle and the raw PDF byte buffer are released
     430              : /// here. The buffer was kept alive to satisfy FPDF_LoadMemDocument64's
     431              : /// requirement that the caller's memory remain valid for the document lifetime.
     432           10 : void _handleCloseDocument(
     433              :   PdfiumCloseDocumentCommand cmd,
     434              :   PdfiumBindings bindings,
     435              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
     436              : ) {
     437           20 :   final entry = openDocuments.remove(cmd.token);
     438              :   if (entry != null) {
     439           10 :     final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
     440           10 :     bindings.FPDF_CloseDocument(docPtr);
     441              :     // Free the raw PDF byte buffer that was held alive for the document lifetime.
     442           20 :     calloc.free(ffi.Pointer<ffi.Uint8>.fromAddress(entry.bufferAddress));
     443              :   }
     444              :   // Always respond — close is idempotent (double-close is a no-op).
     445           20 :   cmd.replyPort.send(const PdfiumCloseDocumentResponse());
     446              : }
     447              : 
     448              : /// Returns the page count for an open document.
     449            7 : void _handleGetPageCount(
     450              :   PdfiumGetPageCountCommand cmd,
     451              :   PdfiumBindings bindings,
     452              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
     453              : ) {
     454           14 :   final entry = openDocuments[cmd.token];
     455              :   if (entry == null) {
     456              :     // coverage:ignore-start
     457              :     cmd.replyPort.send(
     458              :       PdfiumGetPageCountResponse.failure(PdfError.invalidDocument),
     459              :     );
     460              :     return;
     461              :     // coverage:ignore-end
     462              :   }
     463              : 
     464            7 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
     465            7 :   final count = bindings.FPDF_GetPageCount(docPtr);
     466           21 :   cmd.replyPort.send(PdfiumGetPageCountResponse.success(count));
     467              : }
     468              : 
     469              : /// Extracts plain text from a single page of an open document.
     470              : ///
     471              : /// Handles the full RAII lifecycle within this function:
     472              : ///   1. Load page handle via FPDF_LoadPage.
     473              : ///   2. Load text page handle via FPDFText_LoadPage.
     474              : ///   3. Extract text using the two-call buffer pattern.
     475              : ///   4. Detect unicode errors and soft hyphens character-by-character.
     476              : ///   5. Close text page then close page (reverse order of opening).
     477              : ///
     478              : /// All resources are released in finally blocks so that even if an
     479              : /// exception occurs, handles are not leaked.
     480            2 : void _handleExtractPageText(
     481              :   PdfiumExtractPageTextCommand cmd,
     482              :   PdfiumBindings bindings,
     483              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
     484              : ) {
     485            4 :   final entry = openDocuments[cmd.token];
     486              :   if (entry == null) {
     487              :     // coverage:ignore-start
     488              :     cmd.replyPort.send(
     489              :       PdfiumExtractPageTextResponse.failure(
     490              :         PdfError.invalidDocument,
     491              :         cmd.pageIndex,
     492              :       ),
     493              :     );
     494              :     return;
     495              :     // coverage:ignore-end
     496              :   }
     497              : 
     498            2 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
     499              : 
     500              :   // Load the page handle. Returns null pointer on failure.
     501            4 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
     502            4 :   if (pagePtr == ffi.nullptr) {
     503              :     // coverage:ignore-start
     504              :     cmd.replyPort.send(
     505              :       PdfiumExtractPageTextResponse.failure(
     506              :         PdfError.invalidDocument,
     507              :         cmd.pageIndex,
     508              :       ),
     509              :     );
     510              :     return;
     511              :     // coverage:ignore-end
     512              :   }
     513              : 
     514              :   try {
     515              :     // Load the text page handle. Returns null pointer on failure.
     516            2 :     final textPagePtr = bindings.FPDFText_LoadPage(pagePtr);
     517            4 :     if (textPagePtr == ffi.nullptr) {
     518              :       // Treat a text-load failure as a page with no text layer rather than
     519              :       // an error — some PDF object types legitimately have no text stream.
     520              :       // coverage:ignore-start
     521              :       cmd.replyPort.send(
     522              :         PdfiumExtractPageTextResponse.success(
     523              :           pageIndex: cmd.pageIndex,
     524              :           text: '',
     525              :           hasUnicodeErrors: false,
     526              :           hasTextLayer: false,
     527              :         ),
     528              :       );
     529              :       return;
     530              :       // coverage:ignore-end
     531              :     }
     532              : 
     533              :     try {
     534            2 :       final charCount = bindings.FPDFText_CountChars(textPagePtr);
     535              : 
     536              :       // Detect unicode errors and soft hyphens in a single character pass.
     537              :       // This avoids serialising raw per-character data across the isolate
     538              :       // boundary — the work is done here, inside the isolate.
     539              :       var hasUnicodeErrors = false;
     540              :       // Track indices of soft-hyphen characters (U+00AD) so we can strip
     541              :       // them and join surrounding words after full text extraction.
     542              :       final softHyphenIndices = <int>{};
     543              : 
     544            4 :       for (var i = 0; i < charCount; i++) {
     545              :         // FPDFText_HasUnicodeMapError returns non-zero when the character
     546              :         // at index i has a broken Unicode mapping.
     547            4 :         if (bindings.FPDFText_HasUnicodeMapError(textPagePtr, i) != 0) {
     548              :           hasUnicodeErrors = true;
     549              :         }
     550              :         // FPDFText_IsHyphen returns non-zero for soft hyphen (U+00AD)
     551              :         // at a line-break position.
     552            4 :         if (bindings.FPDFText_IsHyphen(textPagePtr, i) != 0) {
     553            2 :           softHyphenIndices.add(i);
     554              :         }
     555              :       }
     556              : 
     557              :       // Extract the full text using the two-call buffer pattern.
     558              :       // FPDFText_GetText writes UTF-16LE into a Pointer<UnsignedShort>
     559              :       // (i.e. 2 bytes per code unit). We request all characters.
     560              :       final String extractedText;
     561            2 :       if (charCount <= 0) {
     562              :         extractedText = '';
     563              :       } else {
     564              :         // Buffer must be large enough for charCount UTF-16LE code units plus
     565              :         // one null terminator — FPDFText_GetText always null-terminates.
     566            2 :         final bufferCodeUnits = charCount + 1;
     567              :         final buffer = calloc<ffi.UnsignedShort>(bufferCodeUnits);
     568              :         try {
     569            2 :           final written = bindings.FPDFText_GetText(
     570              :             textPagePtr,
     571              :             0, // start_index
     572              :             charCount, // count
     573              :             buffer,
     574              :           );
     575            2 :           if (written <= 0) {
     576              :             extractedText = '';
     577              :           } else {
     578              :             // Decode UTF-16LE code units (excluding the null terminator).
     579              :             // written is the number of UTF-16LE code units written, including
     580              :             // the null terminator, so use (written - 1) characters.
     581            2 :             final codeUnits = <int>[];
     582            6 :             for (var i = 0; i < written - 1; i++) {
     583            2 :               codeUnits.add(buffer[i]);
     584              :             }
     585            2 :             extractedText = String.fromCharCodes(codeUnits);
     586              :           }
     587              :         } finally {
     588            2 :           calloc.free(buffer);
     589              :         }
     590              :       }
     591              : 
     592              :       // Post-process: strip soft hyphens at line-break positions.
     593              :       // A soft hyphen at a line break should be removed and the words joined.
     594              :       // Soft hyphens that are NOT at line breaks (i.e. not in
     595              :       // softHyphenIndices) are preserved as-is by PDFium; we only act on
     596              :       // the ones FPDFText_IsHyphen identified.
     597              :       //
     598              :       // Implementation: build a list of characters from the extracted string,
     599              :       // removing code points at indices that are soft hyphens. Then strip
     600              :       // any whitespace that was inserted purely to separate the now-joined
     601              :       // word fragments (i.e. newlines or spaces immediately adjacent to a
     602              :       // removed soft hyphen position).
     603            2 :       final processedText = softHyphenIndices.isEmpty
     604              :           ? extractedText
     605            2 :           : _stripSoftHyphens(extractedText, softHyphenIndices);
     606              : 
     607              :       // A page has a text layer if PDFium could extract at least one character.
     608              :       // Scanned/image-only pages yield charCount == 0.
     609            2 :       final hasTextLayer = charCount > 0;
     610              : 
     611            4 :       cmd.replyPort.send(
     612            2 :         PdfiumExtractPageTextResponse.success(
     613            2 :           pageIndex: cmd.pageIndex,
     614              :           text: processedText,
     615              :           hasUnicodeErrors: hasUnicodeErrors,
     616              :           hasTextLayer: hasTextLayer,
     617              :         ),
     618              :       );
     619              :     } finally {
     620              :       // Always close the text page handle, even if an exception was thrown.
     621            2 :       bindings.FPDFText_ClosePage(textPagePtr);
     622              :     }
     623              :   } finally {
     624              :     // Always close the page handle after the text page handle is closed.
     625            2 :     bindings.FPDF_ClosePage(pagePtr);
     626              :   }
     627              : }
     628              : 
     629              : /// Strips soft hyphens at line-break positions from extracted text and joins
     630              : /// the surrounding word fragments.
     631              : ///
     632              : /// [text] is the raw extracted text. [softHyphenIndices] is the set of
     633              : /// character indices (in the PDFium character stream) where
     634              : /// `FPDFText_IsHyphen` returned non-zero.
     635              : ///
     636              : /// The PDFium character stream and the extracted string have a 1:1 mapping
     637              : /// at the code-unit level (both are UTF-16LE). We exploit this to remove
     638              : /// the soft hyphens and any adjacent whitespace that was used to break the
     639              : /// word across lines.
     640            2 : String _stripSoftHyphens(String text, Set<int> softHyphenIndices) {
     641              :   // Convert to a list of runes for index-stable processing.
     642              :   // Note: PDFium uses UTF-16LE code units; String.fromCharCodes also builds
     643              :   // from UTF-16 code units. For BMP characters (the vast majority of PDF
     644              :   // text), rune index == code unit index. For surrogate pairs the indices
     645              :   // diverge, but FPDFText_IsHyphen only applies to U+00AD (BMP), so the
     646              :   // soft hyphen index in the code-unit stream directly corresponds to the
     647              :   // character's position in the decoded string.
     648            2 :   final buffer = StringBuffer();
     649              :   var skipNextWhitespace = false;
     650              : 
     651            6 :   for (var i = 0; i < text.length; i++) {
     652            2 :     final ch = text[i];
     653              : 
     654              :     // If the previous character was a stripped soft hyphen, skip the
     655              :     // newline or space that was separating the two word fragments.
     656            6 :     if (skipNextWhitespace && (ch == '\n' || ch == '\r' || ch == ' ')) {
     657              :       skipNextWhitespace = false;
     658              :       continue;
     659              :     }
     660              :     skipNextWhitespace = false;
     661              : 
     662            2 :     if (softHyphenIndices.contains(i)) {
     663              :       // This is a soft hyphen at a line-break position — strip it.
     664              :       // Set the flag to also consume the following whitespace character.
     665              :       skipNextWhitespace = true;
     666              :       continue;
     667              :     }
     668              : 
     669            2 :     buffer.write(ch);
     670              :   }
     671              : 
     672            2 :   return buffer.toString();
     673              : }
     674              : 
     675              : // ---------------------------------------------------------------------------
     676              : // Annotation extraction handler (runs inside the spawned isolate)
     677              : // ---------------------------------------------------------------------------
     678              : 
     679              : /// Extracts all annotations from a single page, performing popup parent-linking.
     680              : ///
     681              : /// Algorithm:
     682              : ///   1. Open the page via [FPDF_LoadPage].
     683              : ///   2. First pass: iterate every annotation. For each non-POPUP annotation,
     684              : ///      extract all fields and add to [nonPopupAnnotations] keyed by index.
     685              : ///      For each POPUP annotation, record the handle address and index for the
     686              : ///      second pass.
     687              : ///   3. Second pass: for each recorded POPUP, call
     688              : ///      [FPDFAnnot_GetLinkedAnnot] with key `"IRT"` to find the parent
     689              : ///      annotation, then inline the popup data onto the matching entry.
     690              : ///   4. Close all handles and send the response.
     691              : ///
     692              : /// The two-pass approach is required because a popup may appear at any index
     693              : /// relative to its parent in the annotation list.
     694            1 : void _handleExtractPageAnnotations(
     695              :   PdfiumExtractPageAnnotationsCommand cmd,
     696              :   PdfiumBindings bindings,
     697              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
     698              : ) {
     699            2 :   final entry = openDocuments[cmd.token];
     700              :   if (entry == null) {
     701              :     // coverage:ignore-start
     702              :     cmd.replyPort.send(
     703              :       PdfiumExtractPageAnnotationsResponse.failure(
     704              :         PdfError.invalidDocument,
     705              :         cmd.pageIndex,
     706              :       ),
     707              :     );
     708              :     return;
     709              :     // coverage:ignore-end
     710              :   }
     711              : 
     712            1 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
     713            2 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
     714            2 :   if (pagePtr == ffi.nullptr) {
     715              :     // coverage:ignore-start
     716              :     cmd.replyPort.send(
     717              :       PdfiumExtractPageAnnotationsResponse.failure(
     718              :         PdfError.invalidDocument,
     719              :         cmd.pageIndex,
     720              :       ),
     721              :     );
     722              :     return;
     723              :     // coverage:ignore-end
     724              :   }
     725              : 
     726              :   try {
     727            1 :     final annotCount = bindings.FPDFPage_GetAnnotCount(pagePtr);
     728              : 
     729              :     // --- First pass: extract non-POPUP annotations and record POPUP handles ---
     730              : 
     731              :     // Maps annotation index → extracted PdfAnnotation (mutable so we can set
     732              :     // the popup field in the second pass). We use a plain list here and replace
     733              :     // entries with popup-linked versions in the second pass.
     734            1 :     final List<PdfAnnotation?> extracted = List.filled(annotCount, null);
     735              : 
     736              :     // For each POPUP found in the first pass, record:
     737              :     //   (annotIndex, annotHandleAddress) so the second pass can reopen the
     738              :     //   handle and call FPDFAnnot_GetLinkedAnnot.
     739            1 :     final popupHandleAddresses = <int, int>{}; // annotIndex → handle address
     740              : 
     741            2 :     for (var i = 0; i < annotCount; i++) {
     742            1 :       final annotPtr = bindings.FPDFPage_GetAnnot(pagePtr, i);
     743            2 :       if (annotPtr == ffi.nullptr) continue;
     744              : 
     745              :       try {
     746            1 :         final subtypeInt = bindings.FPDFAnnot_GetSubtype(annotPtr);
     747              : 
     748            1 :         if (subtypeInt == 16) {
     749              :           // FPDF_ANNOT_POPUP = 16 — defer to second pass.
     750              :           // We cannot close the handle yet; we need to reopen it in pass 2.
     751              :           // Store the address so we can reconstruct it without re-opening.
     752            2 :           popupHandleAddresses[i] = annotPtr.address;
     753              :           // Do NOT close annotPtr here — we'll close it in the second pass.
     754              :           continue;
     755              :         }
     756              : 
     757              :         // Extract common fields shared by all annotation subtypes.
     758            1 :         final contents = _readAnnotStringValue(bindings, annotPtr, 'Contents');
     759            1 :         final author = _readAnnotStringValue(bindings, annotPtr, 'T');
     760            1 :         final modDateStr = _readAnnotStringValue(bindings, annotPtr, 'M');
     761            1 :         final modifiedDate = PdfDateParser.parse(modDateStr);
     762            1 :         final flags = bindings.FPDFAnnot_GetFlags(annotPtr);
     763            1 :         final rect = _readAnnotRect(bindings, annotPtr);
     764            1 :         final color = _readAnnotColor(
     765              :           bindings,
     766              :           annotPtr,
     767              :           FPDFANNOT_COLORTYPE.FPDFANNOT_COLORTYPE_Color,
     768              :         );
     769              : 
     770            2 :         extracted[i] = _buildAnnotation(
     771              :           bindings: bindings,
     772              :           annotPtr: annotPtr,
     773              :           subtypeInt: subtypeInt,
     774            1 :           pageIndex: cmd.pageIndex,
     775              :           contents: contents,
     776              :           author: author,
     777              :           rect: rect,
     778              :           color: color,
     779              :           modifiedDate: modifiedDate,
     780              :           flags: flags,
     781              :           docPtr: docPtr,
     782              :           pagePtr: pagePtr,
     783              :         );
     784              :       } finally {
     785              :         // Skip closing popup handles here; they are closed in the second pass.
     786            2 :         if (!popupHandleAddresses.containsValue(annotPtr.address)) {
     787            1 :           bindings.FPDFPage_CloseAnnot(annotPtr);
     788              :         }
     789              :       }
     790              :     }
     791              : 
     792              :     // --- Second pass: match POPUP annotations to their parents ---
     793            2 :     for (final entry in popupHandleAddresses.entries) {
     794            1 :       final popupPtr = ffi.Pointer<fpdf_annotation_t__>.fromAddress(
     795            1 :         entry.value,
     796              :       );
     797              : 
     798              :       try {
     799              :         // FPDFAnnot_GetLinkedAnnot with key "IRT" (In-Reply-To) retrieves the
     800              :         // parent annotation that this popup belongs to.
     801            1 :         final irtKey = 'IRT'.toNativeUtf8(allocator: calloc);
     802              :         try {
     803            1 :           final parentPtr = bindings.FPDFAnnot_GetLinkedAnnot(
     804              :             popupPtr,
     805            1 :             irtKey.cast<ffi.Char>(),
     806              :           );
     807              : 
     808            2 :           if (parentPtr != ffi.nullptr) {
     809              :             try {
     810              :               // Identify the parent by its index in the page annotation list.
     811            1 :               final parentIndex = bindings.FPDFPage_GetAnnotIndex(
     812              :                 pagePtr,
     813              :                 parentPtr,
     814              :               );
     815              : 
     816            1 :               if (parentIndex >= 0 &&
     817            1 :                   parentIndex < annotCount &&
     818            1 :                   extracted[parentIndex] != null) {
     819              :                 // Build the popup data and inline it on the parent.
     820            1 :                 final popupRect = _readAnnotRect(bindings, popupPtr);
     821            1 :                 final popupFlags = bindings.FPDFAnnot_GetFlags(popupPtr);
     822            1 :                 final popupData = PdfPopupAnnotation(
     823              :                   rect: popupRect,
     824              :                   flags: popupFlags,
     825              :                 );
     826            2 :                 extracted[parentIndex] = _withPopup(
     827            1 :                   extracted[parentIndex]!,
     828              :                   popupData,
     829              :                 );
     830              :               }
     831              :             } finally {
     832            1 :               bindings.FPDFPage_CloseAnnot(parentPtr);
     833              :             }
     834              :           }
     835              :         } finally {
     836            1 :           calloc.free(irtKey);
     837              :         }
     838              :       } finally {
     839            1 :         bindings.FPDFPage_CloseAnnot(popupPtr);
     840              :       }
     841              :     }
     842              : 
     843              :     // Collect non-null results in order, excluding nulls from skipped handles.
     844              :     final annotations = extracted
     845            2 :         .where((a) => a != null)
     846            1 :         .cast<PdfAnnotation>()
     847            1 :         .toList();
     848              : 
     849            2 :     cmd.replyPort.send(
     850            1 :       PdfiumExtractPageAnnotationsResponse.success(
     851            1 :         pageIndex: cmd.pageIndex,
     852              :         annotations: annotations,
     853              :       ),
     854              :     );
     855              :   } finally {
     856            1 :     bindings.FPDF_ClosePage(pagePtr);
     857              :   }
     858              : }
     859              : 
     860              : /// Reads a UTF-16LE string annotation dictionary value using the two-call
     861              : /// buffer pattern (same as [_readMetaText] but for annotation string keys).
     862              : ///
     863              : /// Returns the string, or `null` if the key is absent (length <= 2 means
     864              : /// only the null terminator was returned), or `""` if the key exists but the
     865              : /// value is an empty string (length exactly 4: two null bytes for the UTF-16LE
     866              : /// empty string, plus the terminator pair... actually length == 2 is the
     867              : /// empty-string sentinel for absent, so absent and empty are both 2 bytes —
     868              : /// we return `null` for both as PDFium cannot distinguish them this way).
     869              : ///
     870              : /// Note: `FPDFAnnot_GetStringValue` returns 2 when the key is absent OR when
     871              : /// the value is an empty string. We treat both as `null` (absent) here, which
     872              : /// is safe for `Contents` and `Author` fields where an empty string carries no
     873              : /// information.
     874            1 : String? _readAnnotStringValue(
     875              :   PdfiumBindings bindings,
     876              :   FPDF_ANNOTATION annotPtr,
     877              :   String key,
     878              : ) {
     879            1 :   final keyCStr = key.toNativeUtf8(allocator: calloc);
     880              :   try {
     881            1 :     final keyPtr = keyCStr.cast<ffi.Char>();
     882              : 
     883              :     // First call: get required byte count.
     884            1 :     final requiredLen = bindings.FPDFAnnot_GetStringValue(
     885              :       annotPtr,
     886              :       keyPtr,
     887            1 :       ffi.nullptr,
     888              :       0,
     889              :     );
     890              : 
     891              :     // 0 = error; 2 = absent or empty string (UTF-16LE null terminator only).
     892            1 :     if (requiredLen <= 2) return null;
     893              : 
     894              :     // Second call: fill the buffer.
     895              :     // FPDFAnnot_GetStringValue writes UTF-16LE into a Pointer<UnsignedShort>.
     896              :     // requiredLen is in bytes; each UTF-16LE code unit is 2 bytes.
     897            1 :     final codeUnitCount = requiredLen ~/ 2;
     898              :     final buffer = calloc<ffi.UnsignedShort>(codeUnitCount);
     899              :     try {
     900            1 :       bindings.FPDFAnnot_GetStringValue(annotPtr, keyPtr, buffer, requiredLen);
     901              : 
     902              :       // Decode UTF-16LE code units, excluding the null terminator (last unit).
     903            1 :       final charCount = codeUnitCount - 1; // exclude null terminator
     904            1 :       if (charCount <= 0) return null;
     905              : 
     906            1 :       final codeUnits = <int>[];
     907            2 :       for (var i = 0; i < charCount; i++) {
     908            1 :         codeUnits.add(buffer[i]);
     909              :       }
     910            1 :       final result = String.fromCharCodes(codeUnits);
     911              :       // Per the plan edge-case decision: absent → null (handled by requiredLen
     912              :       // <= 2 above), empty string → null (no information content).
     913            1 :       return result.isEmpty ? null : result;
     914              :     } finally {
     915            1 :       calloc.free(buffer);
     916              :     }
     917              :   } finally {
     918            1 :     calloc.free(keyCStr);
     919              :   }
     920              : }
     921              : 
     922              : /// Reads the bounding rectangle of an annotation, or `null` on failure.
     923            1 : PdfRect? _readAnnotRect(PdfiumBindings bindings, FPDF_ANNOTATION annotPtr) {
     924              :   final rectPtr = calloc<FS_RECTF>();
     925              :   try {
     926            1 :     final ok = bindings.FPDFAnnot_GetRect(annotPtr, rectPtr);
     927            1 :     if (ok == 0) return null;
     928              :     // FS_RECTF fields: left, top, right, bottom.
     929              :     // Note: PDFium's FS_RECTF has top > bottom in PDF coordinate space
     930              :     // (bottom-left origin), so we preserve the raw values without swapping.
     931            1 :     return PdfRect(
     932            2 :       left: rectPtr.ref.left,
     933            2 :       bottom: rectPtr.ref.bottom,
     934            2 :       right: rectPtr.ref.right,
     935            2 :       top: rectPtr.ref.top,
     936              :     );
     937              :   } finally {
     938            1 :     calloc.free(rectPtr);
     939              :   }
     940              : }
     941              : 
     942              : /// Reads an annotation colour of the given [colorType], or `null` if the call
     943              : /// fails (e.g. the annotation has no colour or has an appearance stream).
     944            1 : PdfColor? _readAnnotColor(
     945              :   PdfiumBindings bindings,
     946              :   FPDF_ANNOTATION annotPtr,
     947              :   FPDFANNOT_COLORTYPE colorType,
     948              : ) {
     949              :   final rPtr = calloc<ffi.UnsignedInt>();
     950              :   final gPtr = calloc<ffi.UnsignedInt>();
     951              :   final bPtr = calloc<ffi.UnsignedInt>();
     952              :   final aPtr = calloc<ffi.UnsignedInt>();
     953              :   try {
     954            1 :     final ok = bindings.FPDFAnnot_GetColor(
     955              :       annotPtr,
     956              :       colorType,
     957              :       rPtr,
     958              :       gPtr,
     959              :       bPtr,
     960              :       aPtr,
     961              :     );
     962            1 :     if (ok == 0) return null;
     963            1 :     return PdfColor(r: rPtr.value, g: gPtr.value, b: bPtr.value, a: aPtr.value);
     964              :   } finally {
     965            1 :     calloc.free(rPtr);
     966            1 :     calloc.free(gPtr);
     967            1 :     calloc.free(bPtr);
     968            1 :     calloc.free(aPtr);
     969              :   }
     970              : }
     971              : 
     972              : /// Reads all quad-point sets from a markup annotation.
     973              : ///
     974              : /// Returns an empty list if the annotation has no attachment points or if
     975              : /// reading fails. Gracefully handles a count that does not match the actual
     976              : /// data by truncating.
     977            1 : List<PdfQuadPoints> _readAnnotQuadPoints(
     978              :   PdfiumBindings bindings,
     979              :   FPDF_ANNOTATION annotPtr,
     980              : ) {
     981            1 :   final count = bindings.FPDFAnnot_CountAttachmentPoints(annotPtr);
     982            1 :   if (count == 0) return const [];
     983              : 
     984            1 :   final result = <PdfQuadPoints>[];
     985              :   final quadPtr = calloc<FS_QUADPOINTSF>();
     986              :   try {
     987            2 :     for (var i = 0; i < count; i++) {
     988            1 :       final ok = bindings.FPDFAnnot_GetAttachmentPoints(annotPtr, i, quadPtr);
     989            1 :       if (ok == 0) continue; // skip malformed quad
     990              : 
     991            1 :       result.add(
     992            1 :         PdfQuadPoints(
     993            5 :           p1: PdfPoint(x: quadPtr.ref.x1, y: quadPtr.ref.y1),
     994            5 :           p2: PdfPoint(x: quadPtr.ref.x2, y: quadPtr.ref.y2),
     995            5 :           p3: PdfPoint(x: quadPtr.ref.x3, y: quadPtr.ref.y3),
     996            5 :           p4: PdfPoint(x: quadPtr.ref.x4, y: quadPtr.ref.y4),
     997              :         ),
     998              :       );
     999              :     }
    1000              :   } finally {
    1001            1 :     calloc.free(quadPtr);
    1002              :   }
    1003              :   return result;
    1004              : }
    1005              : 
    1006              : /// Extracts the text covered by a markup annotation's quad-point regions.
    1007              : ///
    1008              : /// Uses [FPDFText_GetBoundedText] with the axis-aligned bounding box of each
    1009              : /// quad. Returns null when the text page cannot be loaded (scanned/image-only
    1010              : /// page), or a (possibly empty) string when the text layer exists.
    1011            1 : String? _readMarkupMarkedText(
    1012              :   PdfiumBindings bindings,
    1013              :   FPDF_PAGE pagePtr,
    1014              :   List<PdfQuadPoints> quadPoints,
    1015              : ) {
    1016            1 :   if (quadPoints.isEmpty) return null;
    1017              : 
    1018            1 :   final textPagePtr = bindings.FPDFText_LoadPage(pagePtr);
    1019            2 :   if (textPagePtr == ffi.nullptr) return null;
    1020              : 
    1021              :   try {
    1022            1 :     final segments = <String>[];
    1023            2 :     for (final quad in quadPoints) {
    1024              :       // Compute the axis-aligned bounding box of the four quad corners.
    1025              :       // PDF coordinate origin is bottom-left, so top = max(y) and bottom = min(y).
    1026            2 :       var left = quad.p1.x;
    1027            2 :       var right = quad.p1.x;
    1028            2 :       var top = quad.p1.y;
    1029            2 :       var bottom = quad.p1.y;
    1030            5 :       for (final pt in [quad.p2, quad.p3, quad.p4]) {
    1031            2 :         if (pt.x < left) left = pt.x;
    1032            3 :         if (pt.x > right) right = pt.x;
    1033            2 :         if (pt.y > top) top = pt.y;
    1034            3 :         if (pt.y < bottom) bottom = pt.y;
    1035              :       }
    1036              : 
    1037              :       // First call with null buffer to get character count in the region.
    1038            1 :       final count = bindings.FPDFText_GetBoundedText(
    1039              :         textPagePtr,
    1040              :         left,
    1041              :         top,
    1042              :         right,
    1043              :         bottom,
    1044            1 :         ffi.nullptr,
    1045              :         0,
    1046              :       );
    1047            1 :       if (count <= 0) continue;
    1048              : 
    1049              :       // Second call writes UTF-16LE code units (no null terminator).
    1050              :       final buffer = calloc<ffi.UnsignedShort>(count);
    1051              :       try {
    1052            1 :         final written = bindings.FPDFText_GetBoundedText(
    1053              :           textPagePtr,
    1054              :           left,
    1055              :           top,
    1056              :           right,
    1057              :           bottom,
    1058              :           buffer,
    1059              :           count,
    1060              :         );
    1061            1 :         if (written <= 0) continue;
    1062            1 :         final codeUnits = <int>[];
    1063            2 :         for (var i = 0; i < written; i++) {
    1064            1 :           codeUnits.add(buffer[i]);
    1065              :         }
    1066            2 :         segments.add(String.fromCharCodes(codeUnits));
    1067              :       } finally {
    1068            1 :         calloc.free(buffer);
    1069              :       }
    1070              :     }
    1071            1 :     return segments.join(' ');
    1072              :   } finally {
    1073            1 :     bindings.FPDFText_ClosePage(textPagePtr);
    1074              :   }
    1075              : }
    1076              : 
    1077              : /// Reads ink strokes from an `FPDF_ANNOT_INK` annotation.
    1078              : ///
    1079              : /// Returns a list of strokes; each stroke is a list of [PdfPoint]s.
    1080            1 : List<List<PdfPoint>> _readInkStrokes(
    1081              :   PdfiumBindings bindings,
    1082              :   FPDF_ANNOTATION annotPtr,
    1083              : ) {
    1084            1 :   final strokeCount = bindings.FPDFAnnot_GetInkListCount(annotPtr);
    1085            1 :   if (strokeCount == 0) return const [];
    1086              : 
    1087            1 :   final strokes = <List<PdfPoint>>[];
    1088            2 :   for (var strokeIdx = 0; strokeIdx < strokeCount; strokeIdx++) {
    1089              :     // First call: determine point count for this stroke.
    1090            1 :     final pointCount = bindings.FPDFAnnot_GetInkListPath(
    1091              :       annotPtr,
    1092              :       strokeIdx,
    1093            1 :       ffi.nullptr,
    1094              :       0,
    1095              :     );
    1096            1 :     if (pointCount == 0) {
    1097            1 :       strokes.add(const []);
    1098              :       continue;
    1099              :     }
    1100              : 
    1101              :     final buffer = calloc<FS_POINTF>(pointCount);
    1102              :     try {
    1103            1 :       final written = bindings.FPDFAnnot_GetInkListPath(
    1104              :         annotPtr,
    1105              :         strokeIdx,
    1106              :         buffer,
    1107              :         pointCount,
    1108              :       );
    1109              : 
    1110            1 :       final points = <PdfPoint>[];
    1111            2 :       for (var j = 0; j < written; j++) {
    1112            6 :         points.add(PdfPoint(x: buffer[j].x, y: buffer[j].y));
    1113              :       }
    1114            1 :       strokes.add(points);
    1115              :     } finally {
    1116            1 :       calloc.free(buffer);
    1117              :     }
    1118              :   }
    1119              :   return strokes;
    1120              : }
    1121              : 
    1122              : /// Reads polygon or polyline vertices from an annotation.
    1123            1 : List<PdfPoint> _readAnnotVertices(
    1124              :   PdfiumBindings bindings,
    1125              :   FPDF_ANNOTATION annotPtr,
    1126              : ) {
    1127              :   // First call: get vertex count.
    1128            2 :   final count = bindings.FPDFAnnot_GetVertices(annotPtr, ffi.nullptr, 0);
    1129            1 :   if (count == 0) return const [];
    1130              : 
    1131              :   final buffer = calloc<FS_POINTF>(count);
    1132              :   try {
    1133            1 :     final written = bindings.FPDFAnnot_GetVertices(annotPtr, buffer, count);
    1134            1 :     final vertices = <PdfPoint>[];
    1135            2 :     for (var i = 0; i < written; i++) {
    1136            6 :       vertices.add(PdfPoint(x: buffer[i].x, y: buffer[i].y));
    1137              :     }
    1138              :     return vertices;
    1139              :   } finally {
    1140            1 :     calloc.free(buffer);
    1141              :   }
    1142              : }
    1143              : 
    1144              : /// Reads the start and end points of a line annotation.
    1145              : ///
    1146              : /// Returns a record of (start, end), or (null, null) on failure.
    1147            1 : ({PdfPoint? start, PdfPoint? end}) _readLineEndpoints(
    1148              :   PdfiumBindings bindings,
    1149              :   FPDF_ANNOTATION annotPtr,
    1150              : ) {
    1151              :   final startPtr = calloc<FS_POINTF>();
    1152              :   final endPtr = calloc<FS_POINTF>();
    1153              :   try {
    1154            1 :     final ok = bindings.FPDFAnnot_GetLine(annotPtr, startPtr, endPtr);
    1155            1 :     if (ok == 0) return (start: null, end: null);
    1156              :     return (
    1157            5 :       start: PdfPoint(x: startPtr.ref.x, y: startPtr.ref.y),
    1158            5 :       end: PdfPoint(x: endPtr.ref.x, y: endPtr.ref.y),
    1159              :     );
    1160              :   } finally {
    1161            1 :     calloc.free(startPtr);
    1162            1 :     calloc.free(endPtr);
    1163              :   }
    1164              : }
    1165              : 
    1166              : /// Reads the URI from a link annotation, or `null` if unavailable.
    1167              : ///
    1168              : /// Uses [FPDFAnnot_GetLink] + [FPDFLink_GetAction] + [FPDFAction_GetType] +
    1169              : /// [FPDFAction_GetURIPath] to extract the URI for `PDFACTION_URI` actions.
    1170              : /// Non-URI actions (page destinations, launches, etc.) return `null`.
    1171            0 : String? _readLinkUri(
    1172              :   PdfiumBindings bindings,
    1173              :   ffi.Pointer<fpdf_document_t__> docPtr,
    1174              :   FPDF_ANNOTATION annotPtr,
    1175              : ) {
    1176            0 :   final link = bindings.FPDFAnnot_GetLink(annotPtr);
    1177            0 :   if (link == ffi.nullptr) return null;
    1178              : 
    1179            0 :   final action = bindings.FPDFLink_GetAction(link);
    1180            0 :   if (action == ffi.nullptr) return null;
    1181              : 
    1182              :   // PDFACTION_URI = 3 (defined in fpdf_doc.h)
    1183            0 :   final actionType = bindings.FPDFAction_GetType(action);
    1184            0 :   if (actionType != 3) return null;
    1185              : 
    1186              :   // First call: determine the required buffer length (in bytes; ASCII string).
    1187            0 :   final requiredLen = bindings.FPDFAction_GetURIPath(
    1188              :     docPtr,
    1189              :     action,
    1190            0 :     ffi.nullptr,
    1191              :     0,
    1192              :   );
    1193            0 :   if (requiredLen == 0) return null;
    1194              : 
    1195              :   // Second call: fill the buffer.
    1196              :   final buffer = calloc<ffi.Uint8>(requiredLen);
    1197              :   try {
    1198            0 :     bindings.FPDFAction_GetURIPath(
    1199              :       docPtr,
    1200              :       action,
    1201            0 :       buffer.cast<ffi.Void>(),
    1202              :       requiredLen,
    1203              :     );
    1204              :     // The URI is a null-terminated ASCII/UTF-8 string.
    1205              :     // requiredLen includes the null terminator.
    1206            0 :     final uriBytes = buffer.asTypedList(requiredLen - 1);
    1207            0 :     final uri = String.fromCharCodes(uriBytes);
    1208            0 :     return uri.isEmpty ? null : uri;
    1209              :   } finally {
    1210            0 :     calloc.free(buffer);
    1211              :   }
    1212              : }
    1213              : 
    1214              : /// Maps a PDFium annotation subtype integer to the corresponding [PdfAnnotationType].
    1215            1 : PdfAnnotationType _annotationTypeFromInt(int subtype) => switch (subtype) {
    1216            1 :   1 => PdfAnnotationType.text,
    1217            1 :   2 => PdfAnnotationType.link,
    1218            1 :   3 => PdfAnnotationType.freeText,
    1219            1 :   4 => PdfAnnotationType.line,
    1220            1 :   5 => PdfAnnotationType.square,
    1221            1 :   6 => PdfAnnotationType.circle,
    1222            1 :   7 => PdfAnnotationType.polygon,
    1223            1 :   8 => PdfAnnotationType.polyline,
    1224            1 :   9 => PdfAnnotationType.highlight,
    1225            1 :   10 => PdfAnnotationType.underline,
    1226            0 :   11 => PdfAnnotationType.squiggly,
    1227            0 :   12 => PdfAnnotationType.strikeout,
    1228            0 :   13 => PdfAnnotationType.stamp,
    1229            0 :   15 => PdfAnnotationType.ink,
    1230            0 :   16 => PdfAnnotationType.popup,
    1231              :   _ => PdfAnnotationType.unknown,
    1232              : };
    1233              : 
    1234              : /// Constructs a [PdfAnnotation] subclass from the extracted fields.
    1235            1 : PdfAnnotation _buildAnnotation({
    1236              :   required PdfiumBindings bindings,
    1237              :   required FPDF_ANNOTATION annotPtr,
    1238              :   required int subtypeInt,
    1239              :   required int pageIndex,
    1240              :   required String? contents,
    1241              :   required String? author,
    1242              :   required PdfRect? rect,
    1243              :   required PdfColor? color,
    1244              :   required PdfDate? modifiedDate,
    1245              :   required int flags,
    1246              :   required ffi.Pointer<fpdf_document_t__> docPtr,
    1247              :   required FPDF_PAGE pagePtr,
    1248              : }) {
    1249              :   // Markup subtypes: highlight, underline, squiggly, strikeout.
    1250            1 :   if (subtypeInt == 9 ||
    1251            1 :       subtypeInt == 10 ||
    1252            1 :       subtypeInt == 11 ||
    1253            1 :       subtypeInt == 12) {
    1254            1 :     final subtype = _annotationTypeFromInt(subtypeInt);
    1255            1 :     final quadPoints = _readAnnotQuadPoints(bindings, annotPtr);
    1256            1 :     final markedText = _readMarkupMarkedText(bindings, pagePtr, quadPoints);
    1257            1 :     return PdfMarkupAnnotation(
    1258              :       pageIndex: pageIndex,
    1259              :       subtype: subtype,
    1260              :       quadPoints: quadPoints,
    1261              :       markedText: markedText,
    1262              :       contents: contents,
    1263              :       author: author,
    1264              :       rect: rect,
    1265              :       color: color,
    1266              :       modifiedDate: modifiedDate,
    1267              :       flags: flags,
    1268              :     );
    1269              :   }
    1270              : 
    1271              :   // Shape subtypes: square (rectangle) and circle (ellipse).
    1272            2 :   if (subtypeInt == 5 || subtypeInt == 6) {
    1273            1 :     final subtype = _annotationTypeFromInt(subtypeInt);
    1274            1 :     final interiorColor = _readAnnotColor(
    1275              :       bindings,
    1276              :       annotPtr,
    1277              :       FPDFANNOT_COLORTYPE.FPDFANNOT_COLORTYPE_InteriorColor,
    1278              :     );
    1279            1 :     return PdfShapeAnnotation(
    1280              :       pageIndex: pageIndex,
    1281              :       subtype: subtype,
    1282              :       interiorColor: interiorColor,
    1283              :       contents: contents,
    1284              :       author: author,
    1285              :       rect: rect,
    1286              :       color: color,
    1287              :       modifiedDate: modifiedDate,
    1288              :       flags: flags,
    1289              :     );
    1290              :   }
    1291              : 
    1292              :   switch (subtypeInt) {
    1293            1 :     case 1: // FPDF_ANNOT_TEXT — sticky note
    1294            1 :       return PdfTextAnnotation(
    1295              :         pageIndex: pageIndex,
    1296              :         contents: contents,
    1297              :         author: author,
    1298              :         rect: rect,
    1299              :         color: color,
    1300              :         modifiedDate: modifiedDate,
    1301              :         flags: flags,
    1302              :       );
    1303              : 
    1304            1 :     case 2: // FPDF_ANNOT_LINK
    1305            0 :       final uri = _readLinkUri(bindings, docPtr, annotPtr);
    1306            0 :       return PdfLinkAnnotation(
    1307              :         pageIndex: pageIndex,
    1308              :         uri: uri,
    1309              :         contents: contents,
    1310              :         author: author,
    1311              :         rect: rect,
    1312              :         color: color,
    1313              :         modifiedDate: modifiedDate,
    1314              :         flags: flags,
    1315              :       );
    1316              : 
    1317            1 :     case 3: // FPDF_ANNOT_FREETEXT
    1318            1 :       return PdfFreeTextAnnotation(
    1319              :         pageIndex: pageIndex,
    1320              :         contents: contents,
    1321              :         author: author,
    1322              :         rect: rect,
    1323              :         color: color,
    1324              :         modifiedDate: modifiedDate,
    1325              :         flags: flags,
    1326              :       );
    1327              : 
    1328            1 :     case 4: // FPDF_ANNOT_LINE
    1329            1 :       final (:start, :end) = _readLineEndpoints(bindings, annotPtr);
    1330              :       // If endpoints couldn't be read, fall back to rect corners or defaults.
    1331              :       final lineStart =
    1332            0 :           start ?? PdfPoint(x: rect?.left ?? 0, y: rect?.bottom ?? 0);
    1333            0 :       final lineEnd = end ?? PdfPoint(x: rect?.right ?? 0, y: rect?.top ?? 0);
    1334            1 :       return PdfLineAnnotation(
    1335              :         pageIndex: pageIndex,
    1336              :         lineStart: lineStart,
    1337              :         lineEnd: lineEnd,
    1338              :         contents: contents,
    1339              :         author: author,
    1340              :         rect: rect,
    1341              :         color: color,
    1342              :         modifiedDate: modifiedDate,
    1343              :         flags: flags,
    1344              :       );
    1345              : 
    1346            1 :     case 7: // FPDF_ANNOT_POLYGON
    1347            1 :     case 8: // FPDF_ANNOT_POLYLINE
    1348            1 :       final subtype = _annotationTypeFromInt(subtypeInt);
    1349            1 :       final vertices = _readAnnotVertices(bindings, annotPtr);
    1350            1 :       return PdfPolygonAnnotation(
    1351              :         pageIndex: pageIndex,
    1352              :         subtype: subtype,
    1353              :         vertices: vertices,
    1354              :         contents: contents,
    1355              :         author: author,
    1356              :         rect: rect,
    1357              :         color: color,
    1358              :         modifiedDate: modifiedDate,
    1359              :         flags: flags,
    1360              :       );
    1361              : 
    1362            1 :     case 13: // FPDF_ANNOT_STAMP
    1363            1 :       return PdfStampAnnotation(
    1364              :         pageIndex: pageIndex,
    1365              :         contents: contents,
    1366              :         author: author,
    1367              :         rect: rect,
    1368              :         color: color,
    1369              :         modifiedDate: modifiedDate,
    1370              :         flags: flags,
    1371              :       );
    1372              : 
    1373            1 :     case 15: // FPDF_ANNOT_INK
    1374            1 :       final strokes = _readInkStrokes(bindings, annotPtr);
    1375            1 :       return PdfInkAnnotation(
    1376              :         pageIndex: pageIndex,
    1377              :         strokes: strokes,
    1378              :         contents: contents,
    1379              :         author: author,
    1380              :         rect: rect,
    1381              :         color: color,
    1382              :         modifiedDate: modifiedDate,
    1383              :         flags: flags,
    1384              :       );
    1385              : 
    1386              :     default:
    1387              :       // Unknown or out-of-scope subtype (widget, form, multimedia, etc.).
    1388            1 :       return PdfUnknownAnnotation(
    1389              :         pageIndex: pageIndex,
    1390              :         rawSubtype: subtypeInt,
    1391              :         contents: contents,
    1392              :         author: author,
    1393              :         rect: rect,
    1394              :         color: color,
    1395              :         modifiedDate: modifiedDate,
    1396              :         flags: flags,
    1397              :       );
    1398              :   }
    1399              : }
    1400              : 
    1401              : /// Returns a copy of [annotation] with [popup] set.
    1402              : ///
    1403              : /// Each concrete subtype is handled explicitly because [PdfAnnotation] is
    1404              : /// a sealed class — we cannot mutate instances, and Dart does not provide
    1405              : /// a generic `copyWith` mechanism on sealed hierarchies.
    1406            1 : PdfAnnotation _withPopup(PdfAnnotation annotation, PdfPopupAnnotation popup) {
    1407              :   return switch (annotation) {
    1408            2 :     PdfTextAnnotation a => PdfTextAnnotation(
    1409            1 :       pageIndex: a.pageIndex,
    1410            1 :       contents: a.contents,
    1411            1 :       author: a.author,
    1412            1 :       rect: a.rect,
    1413            1 :       color: a.color,
    1414            1 :       modifiedDate: a.modifiedDate,
    1415            1 :       flags: a.flags,
    1416              :       popup: popup,
    1417              :     ),
    1418            2 :     PdfFreeTextAnnotation a => PdfFreeTextAnnotation(
    1419            1 :       pageIndex: a.pageIndex,
    1420            1 :       contents: a.contents,
    1421            1 :       author: a.author,
    1422            1 :       rect: a.rect,
    1423            1 :       color: a.color,
    1424            1 :       modifiedDate: a.modifiedDate,
    1425            1 :       flags: a.flags,
    1426              :       popup: popup,
    1427              :     ),
    1428            2 :     PdfMarkupAnnotation a => PdfMarkupAnnotation(
    1429            1 :       pageIndex: a.pageIndex,
    1430            1 :       subtype: a.subtype,
    1431            1 :       quadPoints: a.quadPoints,
    1432            1 :       markedText: a.markedText,
    1433            1 :       contents: a.contents,
    1434            1 :       author: a.author,
    1435            1 :       rect: a.rect,
    1436            1 :       color: a.color,
    1437            1 :       modifiedDate: a.modifiedDate,
    1438            1 :       flags: a.flags,
    1439              :       popup: popup,
    1440              :     ),
    1441            2 :     PdfShapeAnnotation a => PdfShapeAnnotation(
    1442            1 :       pageIndex: a.pageIndex,
    1443            1 :       subtype: a.subtype,
    1444            1 :       interiorColor: a.interiorColor,
    1445            1 :       contents: a.contents,
    1446            1 :       author: a.author,
    1447            1 :       rect: a.rect,
    1448            1 :       color: a.color,
    1449            1 :       modifiedDate: a.modifiedDate,
    1450            1 :       flags: a.flags,
    1451              :       popup: popup,
    1452              :     ),
    1453            2 :     PdfLineAnnotation a => PdfLineAnnotation(
    1454            1 :       pageIndex: a.pageIndex,
    1455            1 :       lineStart: a.lineStart,
    1456            1 :       lineEnd: a.lineEnd,
    1457            1 :       contents: a.contents,
    1458            1 :       author: a.author,
    1459            1 :       rect: a.rect,
    1460            1 :       color: a.color,
    1461            1 :       modifiedDate: a.modifiedDate,
    1462            1 :       flags: a.flags,
    1463              :       popup: popup,
    1464              :     ),
    1465            2 :     PdfInkAnnotation a => PdfInkAnnotation(
    1466            1 :       pageIndex: a.pageIndex,
    1467            1 :       strokes: a.strokes,
    1468            1 :       contents: a.contents,
    1469            1 :       author: a.author,
    1470            1 :       rect: a.rect,
    1471            1 :       color: a.color,
    1472            1 :       modifiedDate: a.modifiedDate,
    1473            1 :       flags: a.flags,
    1474              :       popup: popup,
    1475              :     ),
    1476            2 :     PdfPolygonAnnotation a => PdfPolygonAnnotation(
    1477            1 :       pageIndex: a.pageIndex,
    1478            1 :       subtype: a.subtype,
    1479            1 :       vertices: a.vertices,
    1480            1 :       contents: a.contents,
    1481            1 :       author: a.author,
    1482            1 :       rect: a.rect,
    1483            1 :       color: a.color,
    1484            1 :       modifiedDate: a.modifiedDate,
    1485            1 :       flags: a.flags,
    1486              :       popup: popup,
    1487              :     ),
    1488              :     // coverage:ignore-start
    1489              :     // PdfLinkAnnotation, PdfStampAnnotation, and PdfUnknownAnnotation with
    1490              :     // popup fields require specific fixture PDFs with those annotation
    1491              :     // type+popup combinations — not present in the test suite.
    1492              :     PdfLinkAnnotation a => PdfLinkAnnotation(
    1493              :       pageIndex: a.pageIndex,
    1494              :       uri: a.uri,
    1495              :       contents: a.contents,
    1496              :       author: a.author,
    1497              :       rect: a.rect,
    1498              :       color: a.color,
    1499              :       modifiedDate: a.modifiedDate,
    1500              :       flags: a.flags,
    1501              :       popup: popup,
    1502              :     ),
    1503              :     PdfStampAnnotation a => PdfStampAnnotation(
    1504              :       pageIndex: a.pageIndex,
    1505              :       contents: a.contents,
    1506              :       author: a.author,
    1507              :       rect: a.rect,
    1508              :       color: a.color,
    1509              :       modifiedDate: a.modifiedDate,
    1510              :       flags: a.flags,
    1511              :       popup: popup,
    1512              :     ),
    1513              :     PdfUnknownAnnotation a => PdfUnknownAnnotation(
    1514              :       pageIndex: a.pageIndex,
    1515              :       rawSubtype: a.rawSubtype,
    1516              :       contents: a.contents,
    1517              :       author: a.author,
    1518              :       rect: a.rect,
    1519              :       color: a.color,
    1520              :       modifiedDate: a.modifiedDate,
    1521              :       flags: a.flags,
    1522              :       popup: popup,
    1523              :     ),
    1524              :     // coverage:ignore-end
    1525              :   };
    1526              : }
    1527              : 
    1528              : // ---------------------------------------------------------------------------
    1529              : // Page size and rendering handlers (run inside the spawned isolate)
    1530              : // ---------------------------------------------------------------------------
    1531              : 
    1532              : /// Returns the intrinsic size of a single page in PDF user units (points).
    1533              : ///
    1534              : /// Calls `FPDF_LoadPage` then `FPDF_GetPageWidthF` / `FPDF_GetPageHeightF`,
    1535              : /// then closes the page handle. The width and height are in PDF user units
    1536              : /// (1 point = 1/72 inch), which is the coordinate system stored in the PDF.
    1537            2 : void _handleGetPageSize(
    1538              :   PdfiumGetPageSizeCommand cmd,
    1539              :   PdfiumBindings bindings,
    1540              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
    1541              : ) {
    1542            4 :   final entry = openDocuments[cmd.token];
    1543              :   if (entry == null) {
    1544              :     // coverage:ignore-start
    1545              :     cmd.replyPort.send(
    1546              :       PdfiumGetPageSizeResponse.failure(PdfError.invalidDocument),
    1547              :     );
    1548              :     return;
    1549              :     // coverage:ignore-end
    1550              :   }
    1551              : 
    1552            2 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
    1553            4 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
    1554            4 :   if (pagePtr == ffi.nullptr) {
    1555              :     // coverage:ignore-start
    1556              :     cmd.replyPort.send(
    1557              :       PdfiumGetPageSizeResponse.failure(PdfError.invalidDocument),
    1558              :     );
    1559              :     return;
    1560              :     // coverage:ignore-end
    1561              :   }
    1562              : 
    1563              :   try {
    1564            2 :     final widthPt = bindings.FPDF_GetPageWidthF(pagePtr);
    1565            2 :     final heightPt = bindings.FPDF_GetPageHeightF(pagePtr);
    1566            4 :     cmd.replyPort.send(
    1567            2 :       PdfiumGetPageSizeResponse.success(
    1568            2 :         PdfPageSize(widthPt: widthPt, heightPt: heightPt),
    1569              :       ),
    1570              :     );
    1571              :   } finally {
    1572            2 :     bindings.FPDF_ClosePage(pagePtr);
    1573              :   }
    1574              : }
    1575              : 
    1576              : // stripBitmapStride is imported from _bitmap_utils.dart — shared by both the
    1577              : // native (FFI) and web (WASM) backends. See that file for documentation.
    1578              : 
    1579              : /// Renders a single PDF page to a BGRA pixel buffer.
    1580              : ///
    1581              : /// Algorithm (all steps run inside the isolate):
    1582              : ///   1. Validate the document token.
    1583              : ///   2. Load the page via `FPDF_LoadPage`.
    1584              : ///   3. Allocate a bitmap via `FPDFBitmap_Create` (format BGRA = 1 with alpha).
    1585              : ///   4. Fill the bitmap with the requested background colour via
    1586              : ///      `FPDFBitmap_FillRect`. The colour is already in `0xAARRGGBB` format.
    1587              : ///   5. Render the page into the bitmap via `FPDF_RenderPageBitmap` with the
    1588              : ///      caller-supplied flags (e.g. `FPDF_ANNOT`, `FPDF_LCD_TEXT`).
    1589              : ///   6. Obtain the raw pixel buffer pointer via `FPDFBitmap_GetBuffer`.
    1590              : ///   7. **Copy** the pixel bytes into a Dart `Uint8List` before destroying the
    1591              : ///      bitmap. The copy is essential: `FPDFBitmap_GetBuffer` returns a raw
    1592              : ///      pointer into bitmap-owned memory; once `FPDFBitmap_Destroy` is called
    1593              : ///      that memory is freed. The `Uint8List` is the only data that crosses
    1594              : ///      the isolate boundary.
    1595              : ///   8. Destroy the bitmap handle and close the page handle.
    1596            2 : void _handleRenderPage(
    1597              :   PdfiumRenderPageCommand cmd,
    1598              :   PdfiumBindings bindings,
    1599              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
    1600              : ) {
    1601            4 :   final entry = openDocuments[cmd.token];
    1602              :   if (entry == null) {
    1603              :     // coverage:ignore-start
    1604              :     cmd.replyPort.send(
    1605              :       PdfiumRenderPageResponse.failure(
    1606              :         'Document token ${cmd.token} is not open (document may have been closed).',
    1607              :       ),
    1608              :     );
    1609              :     return;
    1610              :     // coverage:ignore-end
    1611              :   }
    1612              : 
    1613            2 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
    1614              : 
    1615              :   // Load the page — returns null on failure.
    1616            4 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
    1617            4 :   if (pagePtr == ffi.nullptr) {
    1618              :     // coverage:ignore-start
    1619              :     cmd.replyPort.send(
    1620              :       PdfiumRenderPageResponse.failure(
    1621              :         'FPDF_LoadPage returned null for page ${cmd.pageIndex}.',
    1622              :       ),
    1623              :     );
    1624              :     return;
    1625              :     // coverage:ignore-end
    1626              :   }
    1627              : 
    1628              :   try {
    1629              :     // Allocate a BGRA bitmap (alpha = 1 → FPDFBitmap_BGRA format).
    1630              :     // FPDFBitmap_Create returns null when allocation fails (e.g. OOM).
    1631            2 :     final bitmap = bindings.FPDFBitmap_Create(
    1632            2 :       cmd.pixelWidth,
    1633            2 :       cmd.pixelHeight,
    1634              :       1, // hasAlpha = 1 → BGRA format
    1635              :     );
    1636            4 :     if (bitmap == ffi.nullptr) {
    1637            2 :       cmd.replyPort.send(
    1638            2 :         PdfiumRenderPageResponse.failure(
    1639              :           'FPDFBitmap_Create returned null for '
    1640            2 :           '${cmd.pixelWidth}x${cmd.pixelHeight} bitmap '
    1641              :           '(possible out-of-memory condition).',
    1642              :         ),
    1643              :       );
    1644              :       return;
    1645              :     }
    1646              : 
    1647              :     try {
    1648              :       // Fill the entire bitmap with the background colour.
    1649              :       // backgroundColor is already in 0xAARRGGBB format as expected by PDFium.
    1650            2 :       bindings.FPDFBitmap_FillRect(
    1651              :         bitmap,
    1652              :         0,
    1653              :         0,
    1654            2 :         cmd.pixelWidth,
    1655            2 :         cmd.pixelHeight,
    1656            2 :         cmd.backgroundColor,
    1657              :       );
    1658              : 
    1659              :       // Render the page into the bitmap at the full bitmap size.
    1660              :       // start_x=0, start_y=0, size_x=width, size_y=height, rotate=0.
    1661            2 :       bindings.FPDF_RenderPageBitmap(
    1662              :         bitmap,
    1663              :         pagePtr,
    1664              :         0, // start_x
    1665              :         0, // start_y
    1666            2 :         cmd.pixelWidth, // size_x
    1667            2 :         cmd.pixelHeight, // size_y
    1668              :         0, // rotate (0 = no rotation)
    1669            2 :         cmd.renderFlags,
    1670              :       );
    1671              : 
    1672              :       // Obtain the raw buffer pointer and copy bytes into a Dart Uint8List.
    1673              :       // This copy MUST happen before FPDFBitmap_Destroy, which frees the
    1674              :       // underlying native memory. The stride may be larger than pixelWidth*4
    1675              :       // on some platforms; use FPDFBitmap_GetStride to handle padding correctly.
    1676            2 :       final bufferPtr = bindings.FPDFBitmap_GetBuffer(bitmap);
    1677            2 :       final stride = bindings.FPDFBitmap_GetStride(bitmap);
    1678            4 :       final byteCount = stride * cmd.pixelHeight;
    1679            4 :       final rawBytes = bufferPtr.cast<ffi.Uint8>().asTypedList(byteCount);
    1680              : 
    1681              :       // If stride == pixelWidth * 4 (no row padding), we can copy the whole
    1682              :       // buffer directly. Otherwise we copy row-by-row to strip padding bytes.
    1683            2 :       final pixels = stripBitmapStride(
    1684              :         rawBytes,
    1685            2 :         cmd.pixelWidth,
    1686            2 :         cmd.pixelHeight,
    1687              :         stride,
    1688              :       );
    1689              : 
    1690            4 :       cmd.replyPort.send(
    1691            2 :         PdfiumRenderPageResponse.success(
    1692              :           pixels: pixels,
    1693            2 :           pixelWidth: cmd.pixelWidth,
    1694            2 :           pixelHeight: cmd.pixelHeight,
    1695              :         ),
    1696              :       );
    1697              :     } finally {
    1698              :       // Always destroy the bitmap handle to free the native pixel buffer.
    1699            2 :       bindings.FPDFBitmap_Destroy(bitmap);
    1700              :     }
    1701              :   } finally {
    1702              :     // Always close the page handle after the bitmap is destroyed.
    1703            2 :     bindings.FPDF_ClosePage(pagePtr);
    1704              :   }
    1705              : }
    1706              : 
    1707              : // ---------------------------------------------------------------------------
    1708              : // TOC (bookmark/outline) extraction handler (runs inside the spawned isolate)
    1709              : // ---------------------------------------------------------------------------
    1710              : 
    1711              : /// Retrieves the complete bookmark/outline tree for an open document.
    1712              : ///
    1713              : /// Algorithm:
    1714              : ///   1. Look up the document token in [openDocuments].
    1715              : ///   2. Call [_walkBookmarkTree] with `nullptr` to retrieve the root-level
    1716              : ///      entries.
    1717              : ///   3. Recurse into children via [FPDFBookmark_GetFirstChild] and siblings
    1718              : ///      via [FPDFBookmark_GetNextSibling].
    1719              : ///   4. For each entry resolve the destination (action → dest → page index /
    1720              : ///      URI, or direct dest → page index).
    1721              : ///   5. Optionally extract an XYZ scroll position via
    1722              : ///      [FPDFDest_GetLocationInPage].
    1723              : ///   6. Send a [PdfiumGetTocResponse] with the resulting tree.
    1724              : ///
    1725              : /// Documents without any bookmarks return an empty list — not an error.
    1726              : ///
    1727              : /// Note: the recursive [PdfTocEntry] tree is deep-copied across the isolate
    1728              : /// boundary by Dart's built-in message-passing serialisation. This is
    1729              : /// acceptable for the bounded sizes of typical PDF bookmark trees.
    1730            1 : void _handleGetToc(
    1731              :   PdfiumGetTocCommand cmd,
    1732              :   PdfiumBindings bindings,
    1733              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
    1734              : ) {
    1735            2 :   final entry = openDocuments[cmd.token];
    1736              :   if (entry == null) {
    1737              :     // coverage:ignore-next-line
    1738            0 :     cmd.replyPort.send(PdfiumGetTocResponse.failure(PdfError.invalidDocument));
    1739              :     // coverage:ignore-next-line
    1740              :     return;
    1741              :   }
    1742              : 
    1743            1 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
    1744              : 
    1745              :   // Walk the tree starting from the document root (nullptr bookmark).
    1746              :   final visited = <int>{};
    1747            2 :   final rootEntries = _walkBookmarkTree(bindings, docPtr, ffi.nullptr, visited);
    1748              : 
    1749            3 :   cmd.replyPort.send(PdfiumGetTocResponse.success(rootEntries));
    1750              : }
    1751              : 
    1752              : /// Recursively walks the bookmark tree from [parentBookmark].
    1753              : ///
    1754              : /// Pass `ffi.nullptr` as [parentBookmark] to retrieve the root-level entries.
    1755              : /// [visited] is a [Set] of raw bookmark pointer addresses used for cycle
    1756              : /// detection. If a handle address has been seen before, recursion stops
    1757              : /// without processing that node.
    1758              : ///
    1759              : /// Returns the list of [PdfTocEntry] objects at this level of the tree.
    1760              : /// Children of each entry are built by a recursive call.
    1761            1 : List<PdfTocEntry> _walkBookmarkTree(
    1762              :   PdfiumBindings bindings,
    1763              :   ffi.Pointer<fpdf_document_t__> docPtr,
    1764              :   FPDF_BOOKMARK parentBookmark,
    1765              :   Set<int> visited,
    1766              : ) {
    1767            1 :   final entries = <PdfTocEntry>[];
    1768              : 
    1769              :   // FPDFBookmark_GetFirstChild returns nullptr when there are no children.
    1770            1 :   var bookmark = bindings.FPDFBookmark_GetFirstChild(docPtr, parentBookmark);
    1771              : 
    1772            2 :   while (bookmark != ffi.nullptr) {
    1773              :     // Cycle detection: use the raw pointer address as the identity key.
    1774            1 :     final handleAddress = bookmark.address;
    1775            1 :     if (visited.contains(handleAddress)) {
    1776              :       // Malformed PDF with a bookmark cycle — stop here to prevent an
    1777              :       // infinite loop.
    1778              :       break;
    1779              :     }
    1780            1 :     visited.add(handleAddress);
    1781              : 
    1782              :     // Decode the title using the two-call buffer pattern.
    1783            1 :     final title = _readBookmarkTitle(bindings, bookmark);
    1784              : 
    1785              :     // Resolve the destination (page index / URI / null).
    1786            1 :     final (:pageIndex, :uri, :scrollPosition) = _resolveBookmarkDestination(
    1787              :       bindings,
    1788              :       docPtr,
    1789              :       bookmark,
    1790              :     );
    1791              : 
    1792              :     // Recursively collect children of this bookmark.
    1793            1 :     final children = _walkBookmarkTree(bindings, docPtr, bookmark, visited);
    1794              : 
    1795            1 :     entries.add(
    1796            1 :       PdfTocEntry(
    1797              :         title: title,
    1798              :         pageIndex: pageIndex,
    1799              :         uri: uri,
    1800              :         scrollPosition: scrollPosition,
    1801              :         children: children,
    1802              :       ),
    1803              :     );
    1804              : 
    1805              :     // Advance to the next sibling.
    1806            1 :     bookmark = bindings.FPDFBookmark_GetNextSibling(docPtr, bookmark);
    1807              :   }
    1808              : 
    1809              :   return entries;
    1810              : }
    1811              : 
    1812              : /// Decodes the title of a bookmark using the PDFium two-call buffer pattern.
    1813              : ///
    1814              : /// Returns an empty string if the title buffer is absent or empty. UTF-16LE
    1815              : /// decoding mirrors the approach used by [_readMetaText].
    1816            1 : String _readBookmarkTitle(PdfiumBindings bindings, FPDF_BOOKMARK bookmark) {
    1817              :   // First call: pass null buffer / zero length to get the required byte count.
    1818            2 :   final requiredLen = bindings.FPDFBookmark_GetTitle(bookmark, ffi.nullptr, 0);
    1819              : 
    1820              :   // 0 or 2 bytes means absent / empty (UTF-16LE null terminator only).
    1821            1 :   if (requiredLen <= 2) return '';
    1822              : 
    1823              :   final buffer = calloc<ffi.Uint8>(requiredLen);
    1824              :   try {
    1825            1 :     bindings.FPDFBookmark_GetTitle(
    1826              :       bookmark,
    1827            1 :       buffer.cast<ffi.Void>(),
    1828              :       requiredLen,
    1829              :     );
    1830              : 
    1831              :     // Decode UTF-16LE, excluding the 2-byte null terminator.
    1832            1 :     final byteCount = requiredLen - 2;
    1833            1 :     if (byteCount <= 0) return '';
    1834              : 
    1835            1 :     final codeUnits = <int>[];
    1836            2 :     for (var i = 0; i < byteCount; i += 2) {
    1837              :       // Little-endian: low byte at i, high byte at i+1.
    1838            5 :       final codeUnit = buffer[i] | (buffer[i + 1] << 8);
    1839            1 :       codeUnits.add(codeUnit);
    1840              :     }
    1841            1 :     return String.fromCharCodes(codeUnits);
    1842              :   } finally {
    1843            1 :     calloc.free(buffer);
    1844              :   }
    1845              : }
    1846              : 
    1847              : /// Resolves a bookmark's destination to a page index, URI, or null.
    1848              : ///
    1849              : /// Resolution order per the plan's specification:
    1850              : ///   1. Try `FPDFBookmark_GetAction`. If non-null, inspect the action type:
    1851              : ///      - `PDFACTION_GOTO` (1): resolve dest from action → page index.
    1852              : ///      - `PDFACTION_URI` (3): extract the URI string.
    1853              : ///      - Anything else: both null.
    1854              : ///   2. If no action (or action handle is null), try `FPDFBookmark_GetDest`
    1855              : ///      directly → page index.
    1856              : ///   3. If both null → section label with no target.
    1857              : ///
    1858              : /// Also attempts to extract the XYZ scroll position from a dest when the
    1859              : /// destination's view mode is `PDFDEST_VIEW_XYZ` (= 1).
    1860            1 : ({int? pageIndex, String? uri, PdfPoint? scrollPosition})
    1861              : _resolveBookmarkDestination(
    1862              :   PdfiumBindings bindings,
    1863              :   ffi.Pointer<fpdf_document_t__> docPtr,
    1864              :   FPDF_BOOKMARK bookmark,
    1865              : ) {
    1866              :   // --- Step 1: Try the action path ---
    1867            1 :   final action = bindings.FPDFBookmark_GetAction(bookmark);
    1868            2 :   if (action != ffi.nullptr) {
    1869            1 :     final actionType = bindings.FPDFAction_GetType(action);
    1870              : 
    1871            1 :     if (actionType == 1) {
    1872              :       // PDFACTION_GOTO: resolve the internal-page destination.
    1873            1 :       final dest = bindings.FPDFAction_GetDest(docPtr, action);
    1874            2 :       if (dest != ffi.nullptr) {
    1875            1 :         final pageIndex = _resolveDestPageIndex(bindings, docPtr, dest);
    1876            1 :         final scrollPosition = _resolveXyzScrollPosition(bindings, dest);
    1877              :         return (
    1878              :           pageIndex: pageIndex,
    1879              :           uri: null,
    1880              :           scrollPosition: scrollPosition,
    1881              :         );
    1882              :       }
    1883              :       // Action was GOTO but dest is null — treat as no target.
    1884              :       return (pageIndex: null, uri: null, scrollPosition: null);
    1885              :     }
    1886              : 
    1887            0 :     if (actionType == 3) {
    1888              :       // PDFACTION_URI: extract the URI string.
    1889              :       // coverage:ignore-start
    1890              :       // Requires a PDF with URI-type bookmark actions — not in the test suite.
    1891              :       final uri = _readActionUri(bindings, docPtr, action);
    1892              :       return (pageIndex: null, uri: uri, scrollPosition: null);
    1893              :       // coverage:ignore-end
    1894              :     }
    1895              : 
    1896              :     // PDFACTION_REMOTEGOTO (2), PDFACTION_LAUNCH (4), PDFACTION_EMBEDDEDGOTO (5),
    1897              :     // or PDFACTION_UNSUPPORTED (0): no page index, no URI.
    1898              :     return (pageIndex: null, uri: null, scrollPosition: null);
    1899              :   }
    1900              : 
    1901              :   // --- Step 2: Try the direct destination path ---
    1902            1 :   final dest = bindings.FPDFBookmark_GetDest(docPtr, bookmark);
    1903            2 :   if (dest != ffi.nullptr) {
    1904            1 :     final pageIndex = _resolveDestPageIndex(bindings, docPtr, dest);
    1905            1 :     final scrollPosition = _resolveXyzScrollPosition(bindings, dest);
    1906              :     return (pageIndex: pageIndex, uri: null, scrollPosition: scrollPosition);
    1907              :   }
    1908              : 
    1909              :   // --- Step 3: Section label with no target ---
    1910              :   return (pageIndex: null, uri: null, scrollPosition: null);
    1911              : }
    1912              : 
    1913              : /// Extracts the zero-based page index from a dest handle.
    1914              : ///
    1915              : /// Returns `null` when `FPDFDest_GetDestPageIndex` returns -1 (invalid).
    1916            1 : int? _resolveDestPageIndex(
    1917              :   PdfiumBindings bindings,
    1918              :   ffi.Pointer<fpdf_document_t__> docPtr,
    1919              :   FPDF_DEST dest,
    1920              : ) {
    1921            1 :   final pageIndex = bindings.FPDFDest_GetDestPageIndex(docPtr, dest);
    1922            1 :   return pageIndex < 0 ? null : pageIndex;
    1923              : }
    1924              : 
    1925              : /// Extracts the XYZ scroll position from a dest handle.
    1926              : ///
    1927              : /// Returns a [PdfPoint] when the dest's view mode is `PDFDEST_VIEW_XYZ`
    1928              : /// (= 1) and at least one of hasX or hasY is set. Returns `null` when:
    1929              : ///   - `FPDFDest_GetLocationInPage` returns FALSE, or
    1930              : ///   - The view mode is not XYZ.
    1931              : ///
    1932              : /// Zoom is intentionally not surfaced; see [PdfTocEntry]'s class-level doc
    1933              : /// comment for the rationale.
    1934            1 : PdfPoint? _resolveXyzScrollPosition(PdfiumBindings bindings, FPDF_DEST dest) {
    1935              :   final hasXPtr = calloc<ffi.Int>();
    1936              :   final hasYPtr = calloc<ffi.Int>();
    1937              :   final hasZoomPtr = calloc<ffi.Int>();
    1938              :   final xPtr = calloc<ffi.Float>();
    1939              :   final yPtr = calloc<ffi.Float>();
    1940              :   final zoomPtr = calloc<ffi.Float>();
    1941              : 
    1942              :   try {
    1943            1 :     final ok = bindings.FPDFDest_GetLocationInPage(
    1944              :       dest,
    1945              :       hasXPtr,
    1946              :       hasYPtr,
    1947              :       hasZoomPtr,
    1948              :       xPtr,
    1949              :       yPtr,
    1950              :       zoomPtr,
    1951              :     );
    1952              : 
    1953              :     // ok == 0 means the call failed (dest has no XYZ location info).
    1954            1 :     if (ok == 0) return null;
    1955              : 
    1956              :     // Only surface x/y when the view mode is XYZ (= 1). For other view modes
    1957              :     // (FIT, FITH, etc.) there are no explicit x/y coordinates.
    1958              :     // coverage:ignore-start
    1959              :     // Requires a PDF with XYZ-type bookmark destinations — not in the suite.
    1960              :     final hasX = hasXPtr.value != 0;
    1961              :     final hasY = hasYPtr.value != 0;
    1962              : 
    1963              :     if (!hasX && !hasY) return null;
    1964              : 
    1965              :     // Use 0.0 for a missing axis coordinate (PDF spec allows partial XYZ).
    1966              :     return PdfPoint(x: hasX ? xPtr.value : 0.0, y: hasY ? yPtr.value : 0.0);
    1967              :     // coverage:ignore-end
    1968              :   } finally {
    1969            1 :     calloc.free(hasXPtr);
    1970            1 :     calloc.free(hasYPtr);
    1971            1 :     calloc.free(hasZoomPtr);
    1972            1 :     calloc.free(xPtr);
    1973            1 :     calloc.free(yPtr);
    1974            1 :     calloc.free(zoomPtr);
    1975              :   }
    1976              : }
    1977              : 
    1978              : /// Reads the URI string from a `PDFACTION_URI` action.
    1979              : ///
    1980              : /// Returns the URI, or `null` if the buffer is empty. The URI is a
    1981              : /// null-terminated ASCII/UTF-8 string (not UTF-16LE).
    1982              : // coverage:ignore-start
    1983              : // _readActionUri is only called for PDFACTION_URI (= 3) bookmark actions,
    1984              : // which require a PDF with URL-type TOC entries — not in the test suite.
    1985              : String? _readActionUri(
    1986              :   PdfiumBindings bindings,
    1987              :   ffi.Pointer<fpdf_document_t__> docPtr,
    1988              :   FPDF_ACTION action,
    1989              : ) {
    1990              :   // First call: determine required buffer length (in bytes; ASCII string).
    1991              :   final requiredLen = bindings.FPDFAction_GetURIPath(
    1992              :     docPtr,
    1993              :     action,
    1994              :     ffi.nullptr,
    1995              :     0,
    1996              :   );
    1997              :   if (requiredLen == 0) return null;
    1998              : 
    1999              :   final buffer = calloc<ffi.Uint8>(requiredLen);
    2000              :   try {
    2001              :     bindings.FPDFAction_GetURIPath(
    2002              :       docPtr,
    2003              :       action,
    2004              :       buffer.cast<ffi.Void>(),
    2005              :       requiredLen,
    2006              :     );
    2007              :     // The URI is null-terminated; requiredLen includes the null terminator.
    2008              :     final uriBytes = buffer.asTypedList(requiredLen - 1);
    2009              :     final uri = String.fromCharCodes(uriBytes);
    2010              :     return uri.isEmpty ? null : uri;
    2011              :   } finally {
    2012              :     calloc.free(buffer);
    2013              :   }
    2014              : }
    2015              : // coverage:ignore-end
    2016              : 
    2017              : // ---------------------------------------------------------------------------
    2018              : // Image extraction handlers (run inside the spawned isolate)
    2019              : // ---------------------------------------------------------------------------
    2020              : 
    2021              : /// Extracts all image objects from a single page.
    2022              : ///
    2023              : /// Algorithm:
    2024              : ///   1. Look up the document token. Send failure if not found.
    2025              : ///   2. Load the page via [FPDF_LoadPage]. Send failure if null.
    2026              : ///   3. Iterate all page objects via [FPDFPage_CountObjects] /
    2027              : ///      [FPDFPage_GetObject]. For each object whose type is
    2028              : ///      [FPDF_PAGEOBJ_IMAGE]:
    2029              : ///      a. Call [FPDFImageObj_GetImageMetadata] to fill metadata. If it
    2030              : ///         fails, skip the object (warn is not available in isolate; we
    2031              : ///         simply omit the image).
    2032              : ///      b. Call [FPDFPageObj_GetBounds] for the axis-aligned bounding box;
    2033              : ///         fall back to a zero [PdfRect] if it returns false.
    2034              : ///      c. Read filter names via [FPDFImageObj_GetImageFilterCount] /
    2035              : ///         [FPDFImageObj_GetImageFilter].
    2036              : ///      d. If [cmd.includeBitmap] is true, call
    2037              : ///         [FPDFImageObj_GetRenderedBitmap] and copy the BGRA bytes; destroy
    2038              : ///         the bitmap handle immediately. Leave bitmap fields null if the
    2039              : ///         call returns null.
    2040              : ///   4. Close the page handle.
    2041              : ///   5. Send [PdfiumExtractPageImagesResponse.success].
    2042            1 : void _handleExtractPageImages(
    2043              :   PdfiumExtractPageImagesCommand cmd,
    2044              :   PdfiumBindings bindings,
    2045              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
    2046              : ) {
    2047            2 :   final entry = openDocuments[cmd.token];
    2048              :   if (entry == null) {
    2049              :     // coverage:ignore-start
    2050              :     cmd.replyPort.send(
    2051              :       PdfiumExtractPageImagesResponse.failure(
    2052              :         PdfError.invalidDocument,
    2053              :         cmd.pageIndex,
    2054              :       ),
    2055              :     );
    2056              :     return;
    2057              :     // coverage:ignore-end
    2058              :   }
    2059              : 
    2060            1 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
    2061            2 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
    2062            2 :   if (pagePtr == ffi.nullptr) {
    2063              :     // coverage:ignore-start
    2064              :     cmd.replyPort.send(
    2065              :       PdfiumExtractPageImagesResponse.failure(
    2066              :         PdfError.invalidDocument,
    2067              :         cmd.pageIndex,
    2068              :       ),
    2069              :     );
    2070              :     return;
    2071              :     // coverage:ignore-end
    2072              :   }
    2073              : 
    2074              :   try {
    2075            1 :     final objectCount = bindings.FPDFPage_CountObjects(pagePtr);
    2076            1 :     final images = <PdfImage>[];
    2077              : 
    2078            2 :     for (var i = 0; i < objectCount; i++) {
    2079            1 :       final objPtr = bindings.FPDFPage_GetObject(pagePtr, i);
    2080            2 :       if (objPtr == ffi.nullptr) continue;
    2081              : 
    2082              :       // FPDF_PAGEOBJ_IMAGE = 3 — skip all non-image objects.
    2083            1 :       final objType = bindings.FPDFPageObj_GetType(objPtr);
    2084            1 :       if (objType != 3) continue;
    2085              : 
    2086              :       // Extract metadata using the FPDF_IMAGEOBJ_METADATA struct.
    2087              :       final metaPtr = calloc<FPDF_IMAGEOBJ_METADATA>();
    2088              :       bool metaOk;
    2089              :       try {
    2090              :         metaOk =
    2091            2 :             bindings.FPDFImageObj_GetImageMetadata(objPtr, pagePtr, metaPtr) !=
    2092              :             0;
    2093              :       } finally {
    2094              :         // Do not free yet; we read fields below before freeing.
    2095              :         // (actually we free in the outer try block)
    2096              :       }
    2097              : 
    2098              :       if (!metaOk) {
    2099            0 :         calloc.free(metaPtr);
    2100              :         continue; // Skip images whose metadata cannot be read.
    2101              :       }
    2102              : 
    2103            1 :       final meta = metaPtr.ref;
    2104            1 :       final metadata = PdfImageMetadata(
    2105            1 :         width: meta.width,
    2106            1 :         height: meta.height,
    2107            1 :         horizontalDpi: meta.horizontal_dpi,
    2108            1 :         verticalDpi: meta.vertical_dpi,
    2109            1 :         bitsPerPixel: meta.bits_per_pixel,
    2110            2 :         colorspace: _colorspaceFromInt(meta.colorspace),
    2111            1 :         markedContentId: meta.marked_content_id,
    2112              :       );
    2113            1 :       calloc.free(metaPtr);
    2114              : 
    2115              :       // Read the axis-aligned bounding box. Fall back to zero rect on failure.
    2116            1 :       final bounds = _readPageObjBounds(bindings, objPtr);
    2117              : 
    2118              :       // Read compression filter names.
    2119            1 :       final filters = _readImageFilters(bindings, objPtr);
    2120              : 
    2121              :       // Optionally render the composited BGRA bitmap.
    2122              :       Uint8List? bgra;
    2123              :       int? bitmapWidth;
    2124              :       int? bitmapHeight;
    2125              : 
    2126            1 :       if (cmd.includeBitmap) {
    2127            1 :         final bitmapResult = _renderImageBitmap(
    2128              :           bindings,
    2129              :           docPtr,
    2130              :           pagePtr,
    2131              :           objPtr,
    2132              :         );
    2133            1 :         bgra = bitmapResult?.bgra;
    2134            1 :         bitmapWidth = bitmapResult?.width;
    2135            1 :         bitmapHeight = bitmapResult?.height;
    2136              :       }
    2137              : 
    2138            1 :       images.add(
    2139            1 :         PdfImage(
    2140            1 :           pageIndex: cmd.pageIndex,
    2141              :           objectIndex: i,
    2142              :           metadata: metadata,
    2143              :           bounds: bounds,
    2144              :           filters: filters,
    2145              :           bgra: bgra,
    2146              :           bitmapWidth: bitmapWidth,
    2147              :           bitmapHeight: bitmapHeight,
    2148              :         ),
    2149              :       );
    2150              :     }
    2151              : 
    2152            2 :     cmd.replyPort.send(
    2153            1 :       PdfiumExtractPageImagesResponse.success(
    2154            1 :         pageIndex: cmd.pageIndex,
    2155              :         images: images,
    2156              :       ),
    2157              :     );
    2158              :   } finally {
    2159            1 :     bindings.FPDF_ClosePage(pagePtr);
    2160              :   }
    2161              : }
    2162              : 
    2163              : /// Fetches the rendered BGRA bitmap for a single image object by index.
    2164              : ///
    2165              : /// Algorithm:
    2166              : ///   1. Look up the document token. Send failure if not found.
    2167              : ///   2. Load the page via [FPDF_LoadPage]. Send failure if null.
    2168              : ///   3. Call [FPDFPage_GetObject] at [cmd.objectIndex].
    2169              : ///   4. If the object is null or not of type [FPDF_PAGEOBJ_IMAGE], send a
    2170              : ///      successful response with `bitmap: null`.
    2171              : ///   5. Call [FPDFImageObj_GetRenderedBitmap] → copy bytes → destroy handle.
    2172              : ///   6. Close the page handle.
    2173              : ///   7. Send [PdfiumRenderImageResponse.success] (bitmap may be null if
    2174              : ///      [FPDFImageObj_GetRenderedBitmap] returned null).
    2175            1 : void _handleRenderImage(
    2176              :   PdfiumRenderImageCommand cmd,
    2177              :   PdfiumBindings bindings,
    2178              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
    2179              : ) {
    2180            2 :   final entry = openDocuments[cmd.token];
    2181              :   if (entry == null) {
    2182              :     // coverage:ignore-start
    2183              :     cmd.replyPort.send(
    2184              :       PdfiumRenderImageResponse.failure(PdfError.invalidDocument),
    2185              :     );
    2186              :     return;
    2187              :     // coverage:ignore-end
    2188              :   }
    2189              : 
    2190            1 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
    2191            2 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
    2192            2 :   if (pagePtr == ffi.nullptr) {
    2193              :     // coverage:ignore-start
    2194              :     cmd.replyPort.send(
    2195              :       PdfiumRenderImageResponse.failure(PdfError.invalidDocument),
    2196              :     );
    2197              :     return;
    2198              :     // coverage:ignore-end
    2199              :   }
    2200              : 
    2201              :   try {
    2202              :     // O(1) index access — returns null pointer for out-of-range indices.
    2203            2 :     final objPtr = bindings.FPDFPage_GetObject(pagePtr, cmd.objectIndex);
    2204              : 
    2205            2 :     if (objPtr == ffi.nullptr) {
    2206              :       // Object index is out of range for this page.
    2207            2 :       cmd.replyPort.send(const PdfiumRenderImageResponse.success(null));
    2208              :       return;
    2209              :     }
    2210              : 
    2211              :     // Verify the object is an image (FPDF_PAGEOBJ_IMAGE = 3).
    2212            1 :     final objType = bindings.FPDFPageObj_GetType(objPtr);
    2213            1 :     if (objType != 3) {
    2214              :       // Object exists but is not an image type.
    2215            0 :       cmd.replyPort.send(const PdfiumRenderImageResponse.success(null));
    2216              :       return;
    2217              :     }
    2218              : 
    2219              :     // Render the composited BGRA bitmap. Returns null for mask-only objects.
    2220            1 :     final bitmapResult = _renderImageBitmap(bindings, docPtr, pagePtr, objPtr);
    2221            3 :     cmd.replyPort.send(PdfiumRenderImageResponse.success(bitmapResult));
    2222              :   } finally {
    2223            1 :     bindings.FPDF_ClosePage(pagePtr);
    2224              :   }
    2225              : }
    2226              : 
    2227              : /// Renders an image object to a [PdfImageBitmap] using
    2228              : /// [FPDFImageObj_GetRenderedBitmap].
    2229              : ///
    2230              : /// Returns `null` when [FPDFImageObj_GetRenderedBitmap] returns a null handle
    2231              : /// (e.g. mask-only objects that have no renderable bitmap).
    2232              : ///
    2233              : /// The bitmap handle is always destroyed before this function returns, so the
    2234              : /// caller receives a Dart-owned [Uint8List] and does not need to manage the
    2235              : /// native bitmap lifecycle.
    2236            1 : PdfImageBitmap? _renderImageBitmap(
    2237              :   PdfiumBindings bindings,
    2238              :   ffi.Pointer<fpdf_document_t__> docPtr,
    2239              :   FPDF_PAGE pagePtr,
    2240              :   FPDF_PAGEOBJECT objPtr,
    2241              : ) {
    2242            1 :   final bitmap = bindings.FPDFImageObj_GetRenderedBitmap(
    2243              :     docPtr,
    2244              :     pagePtr,
    2245              :     objPtr,
    2246              :   );
    2247            2 :   if (bitmap == ffi.nullptr) return null;
    2248              : 
    2249              :   try {
    2250            1 :     final width = bindings.FPDFBitmap_GetWidth(bitmap);
    2251            1 :     final height = bindings.FPDFBitmap_GetHeight(bitmap);
    2252            1 :     final stride = bindings.FPDFBitmap_GetStride(bitmap);
    2253              : 
    2254            2 :     if (width <= 0 || height <= 0) return null;
    2255              : 
    2256            1 :     final bufferPtr = bindings.FPDFBitmap_GetBuffer(bitmap);
    2257            1 :     final byteCount = stride * height;
    2258            2 :     final rawBytes = bufferPtr.cast<ffi.Uint8>().asTypedList(byteCount);
    2259              : 
    2260              :     // Copy into a Dart-owned Uint8List (stride-stripping if needed).
    2261              :     // The copy MUST happen before FPDFBitmap_Destroy frees the native buffer.
    2262            1 :     final expectedStride = width * 4;
    2263              :     final Uint8List bgra;
    2264            1 :     if (stride == expectedStride) {
    2265              :       // Fast path: no row padding — copy the contiguous buffer.
    2266            1 :       bgra = Uint8List.fromList(rawBytes);
    2267              :     } else {
    2268              :       // Slow path: strip row padding so the output is a compact BGRA buffer.
    2269            0 :       bgra = Uint8List(width * height * 4);
    2270            0 :       for (var row = 0; row < height; row++) {
    2271            0 :         final srcOffset = row * stride;
    2272            0 :         final dstOffset = row * expectedStride;
    2273            0 :         bgra.setRange(
    2274              :           dstOffset,
    2275            0 :           dstOffset + expectedStride,
    2276              :           rawBytes,
    2277              :           srcOffset,
    2278              :         );
    2279              :       }
    2280              :     }
    2281              : 
    2282            1 :     return PdfImageBitmap(bgra: bgra, width: width, height: height);
    2283              :   } finally {
    2284              :     // Always destroy the native bitmap handle to free the pixel buffer.
    2285            1 :     bindings.FPDFBitmap_Destroy(bitmap);
    2286              :   }
    2287              : }
    2288              : 
    2289              : /// Reads the axis-aligned bounding box of a page object.
    2290              : ///
    2291              : /// Returns a zero [PdfRect] when [FPDFPageObj_GetBounds] fails (returns 0).
    2292            1 : PdfRect _readPageObjBounds(PdfiumBindings bindings, FPDF_PAGEOBJECT objPtr) {
    2293              :   final leftPtr = calloc<ffi.Float>();
    2294              :   final bottomPtr = calloc<ffi.Float>();
    2295              :   final rightPtr = calloc<ffi.Float>();
    2296              :   final topPtr = calloc<ffi.Float>();
    2297              :   try {
    2298            1 :     final ok = bindings.FPDFPageObj_GetBounds(
    2299              :       objPtr,
    2300              :       leftPtr,
    2301              :       bottomPtr,
    2302              :       rightPtr,
    2303              :       topPtr,
    2304              :     );
    2305            1 :     if (ok == 0) {
    2306              :       return const PdfRect(left: 0, bottom: 0, right: 0, top: 0);
    2307              :     }
    2308            1 :     return PdfRect(
    2309            1 :       left: leftPtr.value,
    2310            1 :       bottom: bottomPtr.value,
    2311            1 :       right: rightPtr.value,
    2312            1 :       top: topPtr.value,
    2313              :     );
    2314              :   } finally {
    2315            1 :     calloc.free(leftPtr);
    2316            1 :     calloc.free(bottomPtr);
    2317            1 :     calloc.free(rightPtr);
    2318            1 :     calloc.free(topPtr);
    2319              :   }
    2320              : }
    2321              : 
    2322              : /// Reads the list of compression filter names applied to an image object.
    2323              : ///
    2324              : /// Uses the two-call buffer pattern for each filter name. Returns an empty
    2325              : /// list when there are no filters or the call fails.
    2326              : ///
    2327              : /// Filter names are null-terminated ASCII strings (e.g. `"DCTDecode"`,
    2328              : /// `"FlateDecode"`).
    2329            1 : List<String> _readImageFilters(
    2330              :   PdfiumBindings bindings,
    2331              :   FPDF_PAGEOBJECT objPtr,
    2332              : ) {
    2333            1 :   final count = bindings.FPDFImageObj_GetImageFilterCount(objPtr);
    2334            1 :   if (count <= 0) return const [];
    2335              : 
    2336            1 :   final filters = <String>[];
    2337            2 :   for (var i = 0; i < count; i++) {
    2338              :     // First call: determine required buffer length (in bytes).
    2339            1 :     final requiredLen = bindings.FPDFImageObj_GetImageFilter(
    2340              :       objPtr,
    2341              :       i,
    2342            1 :       ffi.nullptr,
    2343              :       0,
    2344              :     );
    2345              : 
    2346            1 :     if (requiredLen <= 0) continue;
    2347              : 
    2348              :     final buffer = calloc<ffi.Uint8>(requiredLen);
    2349              :     try {
    2350            1 :       bindings.FPDFImageObj_GetImageFilter(
    2351              :         objPtr,
    2352              :         i,
    2353            1 :         buffer.cast<ffi.Void>(),
    2354              :         requiredLen,
    2355              :       );
    2356              :       // Filter names are null-terminated ASCII strings.
    2357              :       // requiredLen includes the null terminator.
    2358            2 :       final bytes = buffer.asTypedList(requiredLen - 1);
    2359            1 :       final name = String.fromCharCodes(bytes);
    2360            2 :       if (name.isNotEmpty) filters.add(name);
    2361              :     } finally {
    2362            1 :       calloc.free(buffer);
    2363              :     }
    2364              :   }
    2365              :   return filters;
    2366              : }
    2367              : 
    2368              : /// Maps a PDFium `FPDF_COLORSPACE_*` integer to the corresponding
    2369              : /// [PdfColorspace] enum value.
    2370              : ///
    2371              : /// Returns [PdfColorspace.unknown] for any value not recognised by this
    2372              : /// version of the library.
    2373            1 : PdfColorspace _colorspaceFromInt(int value) => switch (value) {
    2374            1 :   0 => PdfColorspace.unknown,
    2375            1 :   1 => PdfColorspace.deviceGray,
    2376            1 :   2 => PdfColorspace.deviceRgb,
    2377              :   // The cases below (CMYK, CalGray, CalRGB, Lab, ICC, Separation, DeviceN,
    2378              :   // Indexed, Pattern) require test fixtures with non-RGB/Gray image colorspaces
    2379              :   // that are not present in the standard test suite. They are excluded from
    2380              :   // coverage so the gate is not penalised for missing fixture PDFs.
    2381              :   // coverage:ignore-start
    2382              :   3 => PdfColorspace.deviceCmyk,
    2383              :   4 => PdfColorspace.calGray,
    2384              :   5 => PdfColorspace.calRgb,
    2385              :   6 => PdfColorspace.lab,
    2386              :   7 => PdfColorspace.iccBased,
    2387              :   8 => PdfColorspace.separation,
    2388              :   9 => PdfColorspace.deviceN,
    2389              :   10 => PdfColorspace.indexed,
    2390              :   11 => PdfColorspace.pattern,
    2391              :   _ => PdfColorspace.unknown,
    2392              :   // coverage:ignore-end
    2393              : };
    2394              : 
    2395              : /// Searches for text on a single page of an open document.
    2396              : ///
    2397              : /// The full lifecycle is contained within this function:
    2398              : ///   1. Validate the document token.
    2399              : ///   2. Load the page via `FPDF_LoadPage`.
    2400              : ///   3. Load the text page via `FPDFText_LoadPage`.
    2401              : ///   4. Encode [PdfiumSearchPageCommand.query] as a null-terminated UTF-16LE
    2402              : ///      buffer (`FPDF_WIDESTRING`).
    2403              : ///   5. Start the search via `FPDFText_FindStart`.
    2404              : ///   6. Iterate `FPDFText_FindNext`, collecting the char index, char count,
    2405              : ///      and bounding rects for each match.
    2406              : ///   7. Close the search handle and text-page handle (in `try/finally` blocks
    2407              : ///      so handles are never leaked even on exception).
    2408              : ///   8. Close the page handle.
    2409              : ///   9. Send a [PdfiumSearchPageResponse].
    2410              : ///
    2411              : /// Pages with no text layer (`FPDFText_LoadPage` returns null) produce a
    2412              : /// success response with an empty matches list — not an error.
    2413            1 : void _handleSearchPage(
    2414              :   PdfiumSearchPageCommand cmd,
    2415              :   PdfiumBindings bindings,
    2416              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
    2417              : ) {
    2418            2 :   final entry = openDocuments[cmd.token];
    2419              :   if (entry == null) {
    2420              :     // coverage:ignore-start
    2421              :     cmd.replyPort.send(
    2422              :       PdfiumSearchPageResponse.failure(PdfError.invalidDocument, cmd.pageIndex),
    2423              :     );
    2424              :     return;
    2425              :     // coverage:ignore-end
    2426              :   }
    2427              : 
    2428            1 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
    2429              : 
    2430              :   // Load the page handle. Returns null pointer on failure (e.g. bad page index).
    2431            2 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
    2432            2 :   if (pagePtr == ffi.nullptr) {
    2433              :     // coverage:ignore-start
    2434              :     cmd.replyPort.send(
    2435              :       PdfiumSearchPageResponse.failure(PdfError.invalidDocument, cmd.pageIndex),
    2436              :     );
    2437              :     return;
    2438              :     // coverage:ignore-end
    2439              :   }
    2440              : 
    2441              :   try {
    2442              :     // Load the text page. Null return means no text layer — treat as empty.
    2443            1 :     final textPagePtr = bindings.FPDFText_LoadPage(pagePtr);
    2444            2 :     if (textPagePtr == ffi.nullptr) {
    2445              :       // coverage:ignore-start
    2446              :       cmd.replyPort.send(
    2447              :         PdfiumSearchPageResponse.success(
    2448              :           pageIndex: cmd.pageIndex,
    2449              :           matches: const [],
    2450              :         ),
    2451              :       );
    2452              :       return;
    2453              :       // coverage:ignore-end
    2454              :     }
    2455              : 
    2456              :     try {
    2457              :       // Encode the query string as a null-terminated UTF-16LE buffer.
    2458              :       // FPDF_WIDESTRING = Pointer<FPDF_WCHAR> = Pointer<UnsignedShort>.
    2459              :       // Allocate (charCount + 1) UnsignedShort slots: one per code unit
    2460              :       // plus one for the null terminator.
    2461            1 :       final query = cmd.query;
    2462            1 :       final codeUnits = query.codeUnits; // Dart String is UTF-16.
    2463            2 :       final wideBuffer = calloc<ffi.UnsignedShort>(codeUnits.length + 1);
    2464              :       try {
    2465              :         // Write the query code units as little-endian 16-bit values.
    2466            3 :         for (var i = 0; i < codeUnits.length; i++) {
    2467            1 :           wideBuffer[i] = codeUnits[i];
    2468              :         }
    2469              :         // Null-terminate.
    2470            1 :         wideBuffer[codeUnits.length] = 0;
    2471              : 
    2472            1 :         final findHandle = bindings.FPDFText_FindStart(
    2473              :           textPagePtr,
    2474            1 :           wideBuffer.cast<FPDF_WCHAR>(),
    2475            1 :           cmd.flags,
    2476              :           0, // start_index: start from the beginning of the page
    2477              :         );
    2478              : 
    2479            2 :         if (findHandle == ffi.nullptr) {
    2480              :           // FindStart returned null — emit empty matches.
    2481              :           // coverage:ignore-start
    2482              :           cmd.replyPort.send(
    2483              :             PdfiumSearchPageResponse.success(
    2484              :               pageIndex: cmd.pageIndex,
    2485              :               matches: const [],
    2486              :             ),
    2487              :           );
    2488              :           return;
    2489              :           // coverage:ignore-end
    2490              :         }
    2491              : 
    2492            1 :         final matches = <PdfSearchMatch>[];
    2493              : 
    2494              :         try {
    2495              :           // Iterate all matches on this page.
    2496            2 :           while (bindings.FPDFText_FindNext(findHandle) != 0) {
    2497            1 :             final charIndex = bindings.FPDFText_GetSchResultIndex(findHandle);
    2498            1 :             final charCount = bindings.FPDFText_GetSchCount(findHandle);
    2499              : 
    2500              :             // Collect bounding rectangles for this match.
    2501              :             // A multi-line match produces one rect per visual line fragment.
    2502            1 :             final rectCount = bindings.FPDFText_CountRects(
    2503              :               textPagePtr,
    2504              :               charIndex,
    2505              :               charCount,
    2506              :             );
    2507              : 
    2508            1 :             final rects = <PdfRect>[];
    2509              :             // Output pointers for FPDFText_GetRect; PDFium writes y-axis as
    2510              :             // top-then-bottom (PDF user space: top > bottom).
    2511              :             final leftPtr = calloc<ffi.Double>();
    2512              :             final topPtr = calloc<ffi.Double>();
    2513              :             final rightPtr = calloc<ffi.Double>();
    2514              :             final bottomPtr = calloc<ffi.Double>();
    2515              :             try {
    2516            2 :               for (var r = 0; r < rectCount; r++) {
    2517            1 :                 bindings.FPDFText_GetRect(
    2518              :                   textPagePtr,
    2519              :                   r,
    2520              :                   leftPtr,
    2521              :                   topPtr,
    2522              :                   rightPtr,
    2523              :                   bottomPtr,
    2524              :                 );
    2525            1 :                 rects.add(
    2526            1 :                   PdfRect(
    2527            1 :                     left: leftPtr.value,
    2528            1 :                     bottom: bottomPtr.value,
    2529            1 :                     right: rightPtr.value,
    2530            1 :                     top: topPtr.value,
    2531              :                   ),
    2532              :                 );
    2533              :               }
    2534              :             } finally {
    2535            1 :               calloc.free(leftPtr);
    2536            1 :               calloc.free(topPtr);
    2537            1 :               calloc.free(rightPtr);
    2538            1 :               calloc.free(bottomPtr);
    2539              :             }
    2540              : 
    2541            1 :             matches.add(
    2542            1 :               PdfSearchMatch(
    2543            1 :                 pageIndex: cmd.pageIndex,
    2544              :                 charIndex: charIndex,
    2545              :                 charCount: charCount,
    2546              :                 rects: rects,
    2547              :               ),
    2548              :             );
    2549              :           }
    2550              :         } finally {
    2551              :           // Always close the search handle — required to prevent resource leaks.
    2552            1 :           bindings.FPDFText_FindClose(findHandle);
    2553              :         }
    2554              : 
    2555            2 :         cmd.replyPort.send(
    2556            1 :           PdfiumSearchPageResponse.success(
    2557            1 :             pageIndex: cmd.pageIndex,
    2558              :             matches: matches,
    2559              :           ),
    2560              :         );
    2561              :       } finally {
    2562            1 :         calloc.free(wideBuffer);
    2563              :       }
    2564              :     } finally {
    2565              :       // Always close the text page handle.
    2566            1 :       bindings.FPDFText_ClosePage(textPagePtr);
    2567              :     }
    2568              :   } finally {
    2569              :     // Always close the page handle.
    2570            1 :     bindings.FPDF_ClosePage(pagePtr);
    2571              :   }
    2572              : }
    2573              : 
    2574              : // ---------------------------------------------------------------------------
    2575              : // Thumbnail extraction handler (runs inside the spawned isolate)
    2576              : // ---------------------------------------------------------------------------
    2577              : 
    2578              : /// Retrieves the embedded thumbnail bitmap for a single PDF page.
    2579              : ///
    2580              : /// Algorithm (all steps run inside the isolate):
    2581              : ///   1. Validate the document token.
    2582              : ///   2. Load the page via `FPDF_LoadPage`.
    2583              : ///   3. Call `FPDFPage_GetThumbnailAsBitmap(page)`.
    2584              : ///   4. If the result is `nullptr`, send a success response with `bgra: null`
    2585              : ///      (no embedded thumbnail present — not an error).
    2586              : ///   5. Call `FPDFBitmap_GetFormat` and handle format variants:
    2587              : ///      - `FPDFBitmap_BGRA` (4): already BGRA, copy directly with optional
    2588              : ///        row-padding strip.
    2589              : ///      - `FPDFBitmap_BGRx` (3): no alpha channel; expand each 4-byte pixel
    2590              : ///        to BGRA by setting the A byte to 0xFF (fully opaque).
    2591              : ///      - `FPDFBitmap_BGR` (2): 3 bytes per pixel; expand to BGRA similarly.
    2592              : ///      - Any other format: send a failure response with a descriptive message.
    2593              : ///   6. Obtain the raw buffer pointer via `FPDFBitmap_GetBuffer`, stride via
    2594              : ///      `FPDFBitmap_GetStride`, and dimensions via `FPDFBitmap_GetWidth` /
    2595              : ///      `FPDFBitmap_GetHeight`.
    2596              : ///   7. Copy into a compact `Uint8List` in BGRA layout, stripping row padding.
    2597              : ///   8. Destroy the bitmap handle (in a `finally` block) and close the page.
    2598              : ///
    2599              : /// The page and bitmap handles are always released, even if an error occurs —
    2600              : /// mirrors the pattern used in [_handleRenderPage].
    2601            1 : void _handleGetPageThumbnail(
    2602              :   PdfiumGetPageThumbnailCommand cmd,
    2603              :   PdfiumBindings bindings,
    2604              :   Map<int, ({int docAddress, int bufferAddress})> openDocuments,
    2605              : ) {
    2606            2 :   final entry = openDocuments[cmd.token];
    2607              :   if (entry == null) {
    2608              :     // coverage:ignore-start
    2609              :     cmd.replyPort.send(
    2610              :       PdfiumGetPageThumbnailResponse.failure(
    2611              :         'Document token ${cmd.token} is not open (document may have been closed).',
    2612              :       ),
    2613              :     );
    2614              :     return;
    2615              :     // coverage:ignore-end
    2616              :   }
    2617              : 
    2618            1 :   final docPtr = ffi.Pointer<fpdf_document_t__>.fromAddress(entry.docAddress);
    2619              : 
    2620              :   // Load the page — returns null on failure.
    2621            2 :   final pagePtr = bindings.FPDF_LoadPage(docPtr, cmd.pageIndex);
    2622            2 :   if (pagePtr == ffi.nullptr) {
    2623              :     // coverage:ignore-start
    2624              :     cmd.replyPort.send(
    2625              :       PdfiumGetPageThumbnailResponse.failure(
    2626              :         'FPDF_LoadPage returned null for page ${cmd.pageIndex}.',
    2627              :       ),
    2628              :     );
    2629              :     return;
    2630              :     // coverage:ignore-end
    2631              :   }
    2632              : 
    2633              :   try {
    2634              :     // Call FPDFPage_GetThumbnailAsBitmap. This is marked Experimental API.
    2635              :     // Returns nullptr when the page has no embedded /Thumb stream — that is a
    2636              :     // normal result (not an error), so we send success with bgra: null.
    2637            1 :     final bitmap = bindings.FPDFPage_GetThumbnailAsBitmap(pagePtr);
    2638            2 :     if (bitmap == ffi.nullptr) {
    2639              :       // No embedded thumbnail on this page — signal "absent" with null bgra.
    2640            2 :       cmd.replyPort.send(
    2641              :         const PdfiumGetPageThumbnailResponse.success(
    2642              :           bgra: null,
    2643              :           width: 0,
    2644              :           height: 0,
    2645              :         ),
    2646              :       );
    2647              :       return;
    2648              :     }
    2649              : 
    2650              :     try {
    2651            1 :       final width = bindings.FPDFBitmap_GetWidth(bitmap);
    2652            1 :       final height = bindings.FPDFBitmap_GetHeight(bitmap);
    2653            1 :       final stride = bindings.FPDFBitmap_GetStride(bitmap);
    2654            1 :       final format = bindings.FPDFBitmap_GetFormat(bitmap);
    2655            1 :       final bufferPtr = bindings.FPDFBitmap_GetBuffer(bitmap);
    2656              : 
    2657              :       // The native buffer contains [height] rows, each [stride] bytes.
    2658              :       // [stride] may be > [width * bytesPerPixel] due to row padding.
    2659            2 :       final nativeView = bufferPtr.cast<ffi.Uint8>().asTypedList(
    2660            1 :         stride * height,
    2661              :       );
    2662              : 
    2663              :       // FPDFBitmap_BGRA = 4: 4 bytes/px (B, G, R, A) — copy directly.
    2664              :       // FPDFBitmap_BGRx = 3: 4 bytes/px (B, G, R, x) — replace x with 0xFF.
    2665              :       // FPDFBitmap_BGR  = 2: 3 bytes/px (B, G, R)    — append 0xFF for A.
    2666              :       // Other formats are unsupported — the embedded thumbnail has an unusual
    2667              :       // colour representation; reject it with a descriptive message so the
    2668              :       // caller can fall back to rendering.
    2669            1 :       final bgra = convertBitmapToCompactBgra(
    2670              :         nativeView,
    2671              :         width,
    2672              :         height,
    2673              :         stride,
    2674              :         format,
    2675              :       );
    2676              :       if (bgra == null) {
    2677            0 :         cmd.replyPort.send(
    2678            0 :           PdfiumGetPageThumbnailResponse.failure(
    2679              :             'FPDFPage_GetThumbnailAsBitmap returned a bitmap in unsupported '
    2680            0 :             'format $format for page ${cmd.pageIndex}. '
    2681              :             'Only BGRA, BGRx, and BGR formats are supported.',
    2682              :           ),
    2683              :         );
    2684              :         return;
    2685              :       }
    2686              : 
    2687            2 :       cmd.replyPort.send(
    2688            1 :         PdfiumGetPageThumbnailResponse.success(
    2689              :           bgra: bgra,
    2690              :           width: width,
    2691              :           height: height,
    2692              :         ),
    2693              :       );
    2694              :     } finally {
    2695              :       // Always destroy the bitmap handle to free the native pixel buffer.
    2696              :       // This mirrors the pattern in _handleRenderPage.
    2697            1 :       bindings.FPDFBitmap_Destroy(bitmap);
    2698              :     }
    2699              :   } finally {
    2700              :     // Always close the page handle after the bitmap work is complete.
    2701            1 :     bindings.FPDF_ClosePage(pagePtr);
    2702              :   }
    2703              : }
    2704              : 
    2705              : // ---------------------------------------------------------------------------
    2706              : // PdfiumIsolate — the process-wide singleton used by PdfDocumentNative
    2707              : // ---------------------------------------------------------------------------
    2708              : 
    2709              : /// Process-wide singleton that owns the PDFium isolate.
    2710              : ///
    2711              : /// All [PdfDocument] instances share a single [PdfiumIsolate]. This mirrors
    2712              : /// the PDFium model where [FPDF_InitLibraryWithConfig] is a one-time
    2713              : /// process-wide call; spawning a second isolate would double-initialise the
    2714              : /// library, which is a correctness bug.
    2715              : ///
    2716              : /// The isolate is lazily spawned on the first call to [ensureInitialised].
    2717              : /// It is held for the lifetime of the process — never torn down when
    2718              : /// individual documents are closed.
    2719              : ///
    2720              : /// Callers do not interact with this class directly; it is an internal
    2721              : /// implementation detail of the native backend.
    2722              : class PdfiumIsolate {
    2723           10 :   PdfiumIsolate._();
    2724              : 
    2725              :   static PdfiumIsolate? _instance;
    2726              : 
    2727              :   // Guard future: ensures concurrent calls to ensureInitialised() all await
    2728              :   // the same spawn operation rather than spawning multiple isolates.
    2729              :   static Future<PdfiumIsolate>? _initFuture;
    2730              : 
    2731              :   /// The [SendPort] for sending commands to the PDFium isolate.
    2732              :   late final SendPort _commandPort;
    2733              : 
    2734              :   /// Returns the singleton [PdfiumIsolate], spawning it if necessary.
    2735              :   ///
    2736              :   /// Safe to call concurrently — multiple callers racing on first use all
    2737              :   /// await the same [Future] and receive the same instance.
    2738           10 :   static Future<PdfiumIsolate> ensureInitialised({String? dylibPath}) {
    2739              :     // Fast path: already initialised.
    2740            4 :     if (_instance != null) return Future.value(_instance);
    2741              : 
    2742              :     // Slow path: spawn once. The guard future prevents duplicate spawns from
    2743              :     // concurrent callers.
    2744           10 :     _initFuture ??= _spawn(dylibPath: dylibPath);
    2745              :     return _initFuture!;
    2746              :   }
    2747              : 
    2748              :   /// Resets the singleton state so a new isolate can be spawned.
    2749              :   ///
    2750              :   /// **For testing only.** Calling this in production code will cause the
    2751              :   /// next [ensureInitialised] call to spawn a new isolate and call
    2752              :   /// [FPDF_InitLibraryWithConfig] again, which is a correctness bug if the
    2753              :   /// previous isolate is still running.
    2754              :   ///
    2755              :   /// Use this in test [tearDown] / [tearDownAll] blocks when the test suite
    2756              :   /// needs a fresh PDFium isolate (e.g. after the dylib has been unloaded by
    2757              :   /// a smoke test's [FPDF_DestroyLibrary] call).
    2758              :   // ignore: invalid_use_of_visible_for_testing_member
    2759           10 :   static void resetForTesting() {
    2760              :     _instance = null;
    2761              :     _initFuture = null;
    2762              :   }
    2763              : 
    2764              :   /// Spawns the PDFium isolate and sends it the initialisation command.
    2765           10 :   static Future<PdfiumIsolate> _spawn({String? dylibPath}) async {
    2766           10 :     final instance = PdfiumIsolate._();
    2767              : 
    2768              :     // The bootstrap receive port receives the command SendPort from the isolate
    2769              :     // (sent unconditionally at startup, before the init command).
    2770           10 :     final bootstrapReceivePort = ReceivePort();
    2771              : 
    2772           10 :     await Isolate.spawn(
    2773              :       pdfiumIsolateEntryPoint,
    2774           10 :       bootstrapReceivePort.sendPort,
    2775              :       debugName: 'PdfiumIsolate',
    2776              :     );
    2777              : 
    2778              :     // Receive the isolate's command SendPort.
    2779           10 :     final commandPort = await bootstrapReceivePort.first as SendPort;
    2780           10 :     bootstrapReceivePort.close();
    2781              : 
    2782              :     // Send the init command with the dylib path (null = auto-detect).
    2783           10 :     final initReceivePort = ReceivePort();
    2784            0 :     final resolvedPath = dylibPath ?? _defaultDylibPathOrNull();
    2785           30 :     commandPort.send(PdfiumInitCommand(initReceivePort.sendPort, resolvedPath));
    2786              : 
    2787              :     // Wait for the init response.
    2788           10 :     final dynamic initResponse = await initReceivePort.first;
    2789           10 :     initReceivePort.close();
    2790              : 
    2791              :     // The isolate sends PdfiumInitResponse on success or
    2792              :     // PdfiumInitFailedResponse if the dylib could not be loaded.
    2793           10 :     if (initResponse is! PdfiumInitResponse) {
    2794              :       _initFuture = null; // Allow a future retry with a corrected path.
    2795            1 :       final detail = initResponse is PdfiumInitFailedResponse
    2796            1 :           ? initResponse.message
    2797            0 :           : '$initResponse';
    2798            1 :       throw StateError(
    2799            1 :         'PdfiumIsolate: failed to initialise PDFium library: $detail',
    2800              :       );
    2801              :     }
    2802              : 
    2803              :     // initResponse is PdfiumInitResponse — PDFium initialised successfully.
    2804              :     // We already hold commandPort; the response merely confirms success.
    2805           10 :     instance._commandPort = commandPort;
    2806              :     _instance = instance;
    2807              :     return instance;
    2808              :   }
    2809              : 
    2810              :   /// Sends a [command] to the PDFium isolate and awaits the [PdfiumResponse].
    2811              :   ///
    2812              :   /// Each command includes its own reply [SendPort] (created here) so that
    2813              :   /// concurrent commands from different callers are matched independently
    2814              :   /// without a shared queue.
    2815           10 :   Future<T> send<T extends PdfiumResponse>(
    2816              :     PdfiumCommand Function(SendPort) commandFactory,
    2817              :   ) async {
    2818           10 :     final replyPort = ReceivePort();
    2819           20 :     final command = commandFactory(replyPort.sendPort);
    2820           20 :     _commandPort.send(command);
    2821           10 :     final response = await replyPort.first;
    2822           10 :     replyPort.close();
    2823              :     // coverage:ignore-start
    2824              :     // PdfiumHandlerErrorResponse is sent when an isolate handler throws an
    2825              :     // uncaught exception. This is a defensive guard for internal bugs;
    2826              :     // the public API surface does not expose any path that triggers it in
    2827              :     // normal operation or deterministic tests.
    2828              :     if (response is PdfiumHandlerErrorResponse) {
    2829              :       throw StateError(
    2830              :         'PdfiumIsolate: handler threw ${response.error}\n${response.stack}',
    2831              :       );
    2832              :     }
    2833              :     // Unexpected response type guard — fires only on internal protocol errors.
    2834              :     if (response is! T) {
    2835              :       throw StateError(
    2836              :         'PdfiumIsolate: unexpected response type '
    2837              :         '${response.runtimeType}, expected $T',
    2838              :       );
    2839              :     }
    2840              :     // coverage:ignore-end
    2841              :     return response;
    2842              :   }
    2843              : }
    2844              : 
    2845              : /// Returns an explicit dylib path when the legacy `third_party/pdfium_bin/`
    2846              : /// layout (populated by `make fetch_pdfium`) is present, otherwise `null`.
    2847              : ///
    2848              : /// A `null` return causes the spawned isolate to call [_openLibrary], which
    2849              : /// uses platform-appropriate auto-detection for the native-assets hook case.
    2850              : ///
    2851              : /// iOS and Android always return `null`: iOS loads from the process image
    2852              : /// (static xcframework linked at build time) and Android loads by bare name
    2853              : /// from the APK `jni/{abi}/` directory.
    2854              : // coverage:ignore-start
    2855              : // _defaultDylibPathOrNull() is only called when ensureInitialised() is invoked
    2856              : // without an explicit dylibPath. The test suite always injects an explicit path
    2857              : // via nativeDylibPath(), so this function is never reached in coverage runs.
    2858              : String? _defaultDylibPathOrNull() {
    2859              :   if (Platform.isIOS || Platform.isAndroid) return null;
    2860              :   if (Platform.isLinux) {
    2861              :     final arch = ffi.Abi.current() == ffi.Abi.linuxArm64
    2862              :         ? 'linux_arm64'
    2863              :         : 'linux_x64';
    2864              :     final legacy = 'third_party/pdfium_bin/$arch/libpdfium.so';
    2865              :     if (File(legacy).existsSync()) return legacy;
    2866              :     return null;
    2867              :   }
    2868              :   if (Platform.isMacOS) {
    2869              :     const legacy = 'third_party/pdfium_bin/macos_arm64/libpdfium.dylib';
    2870              :     if (File(legacy).existsSync()) return legacy;
    2871              :     return null;
    2872              :   }
    2873              :   if (Platform.isWindows) {
    2874              :     // Legacy path populated by scripts/fetch_pdfium.sh (Git Bash / WSL).
    2875              :     const legacy = 'third_party/pdfium_bin/windows_x64/pdfium.dll';
    2876              :     if (File(legacy).existsSync()) return legacy;
    2877              :     return null;
    2878              :   }
    2879              :   return null;
    2880              : }
    2881              : // coverage:ignore-end
    2882              : 
    2883              : /// Candidate `.dart_tool/lib/<libName>` paths for [startDir] and each of its
    2884              : /// ancestor directories, nearest-first.
    2885              : ///
    2886              : /// In a Pub **workspace**, `dart test` run from inside a member package
    2887              : /// directory stages native-asset libraries to the *workspace root*
    2888              : /// `.dart_tool/lib/`, not the package's own `.dart_tool/lib/`. Probing only the
    2889              : /// current directory therefore misses the staged library whenever the process's
    2890              : /// working directory is a workspace member rather than the workspace root — the
    2891              : /// exact layout a consumer such as `kmdb` presents when its `dart test` spawns
    2892              : /// an isolate that loads PDFium (the spawned isolate cannot see the test
    2893              : /// runner's `LD_LIBRARY_PATH`, so the bare-name fallback fails too). Walking up
    2894              : /// the tree covers both the single-package layout (library staged in
    2895              : /// [startDir]) and the workspace layout (library staged in an ancestor root).
    2896            1 : @visibleForTesting
    2897              : List<String> dartToolLibCandidates(String startDir, String libName) {
    2898            1 :   final candidates = <String>[];
    2899            2 :   var dir = Directory(startDir).absolute;
    2900              :   while (true) {
    2901            3 :     candidates.add('${dir.path}/.dart_tool/lib/$libName');
    2902            1 :     final parent = dir.parent;
    2903              :     // Directory.parent of a filesystem root returns the same directory —
    2904              :     // that is the loop's termination condition.
    2905            3 :     if (parent.path == dir.path) break;
    2906              :     dir = parent;
    2907              :   }
    2908              :   return candidates;
    2909              : }
    2910              : 
    2911              : /// Opens the PDFium [ffi.DynamicLibrary] for the current platform using
    2912              : /// native-assets auto-detection.
    2913              : ///
    2914              : /// Called by the spawned isolate when [PdfiumInitCommand.dylibPath] is `null`
    2915              : /// (i.e. the native-assets hook staged the binary rather than `make
    2916              : /// fetch_pdfium`).
    2917              : ///
    2918              : /// Platform strategy:
    2919              : ///   - **iOS**: PDFium is statically linked by the SPM plugin shim →
    2920              : ///     [ffi.DynamicLibrary.process].
    2921              : ///   - **Android**: the `.so` is bundled in the APK `jni/{abi}/` directory by
    2922              : ///     the Flutter build → [ffi.DynamicLibrary.open] by bare name.
    2923              : ///   - **Linux**: probes absolute candidate paths (dart build output, dart test
    2924              : ///     staged location, hook cache) then falls back to bare name if none exist.
    2925              : ///     The bare-name fallback only works when `LD_LIBRARY_PATH` is set (e.g.
    2926              : ///     inside the `dart test` process itself), not in subprocesses spawned by
    2927              : ///     tests — so absolute paths must be tried first.
    2928              : ///   - **macOS**: tries the Flutter framework bundle path first, then probes
    2929              : ///     several absolute candidate paths (dart build output, dart test staged
    2930              : ///     location, hook cache).
    2931              : ///   - **Windows**: probes absolute candidate paths (dart build output, dart
    2932              : ///     test staged location, hook cache) mirroring Linux, then falls back to
    2933              : ///     the bare DLL name as a last resort (only works if `pdfium.dll` is on
    2934              : ///     the DLL search path via `PATH`). Windows does not use a `lib` prefix.
    2935            0 : ffi.DynamicLibrary _openLibrary() {
    2936              :   // coverage:ignore-start
    2937              :   // iOS and Android branches are only reachable on physical/emulated devices;
    2938              :   // the macOS/Linux test host cannot exercise them. They are excluded from
    2939              :   // coverage so the 90% gate is not penalised for platform-gated code.
    2940              :   if (Platform.isIOS) {
    2941              :     return ffi.DynamicLibrary.process();
    2942              :   }
    2943              :   if (Platform.isAndroid) {
    2944              :     return ffi.DynamicLibrary.open('libpdfium.so');
    2945              :   }
    2946              :   // coverage:ignore-end
    2947              :   // The remaining platform branches (Linux, macOS) are exercised by the
    2948              :   // native-assets hook workflow, where dart test runs without an explicit
    2949              :   // dylibPath and auto-detection is triggered. The dart test suite always
    2950              :   // injects an explicit path via nativeDylibPath(), so these branches cannot
    2951              :   // be hit in the coverage run without restructuring the entire test pattern.
    2952              :   // coverage:ignore-start
    2953              :   if (Platform.isLinux) {
    2954              :     final exeDir = File(Platform.resolvedExecutable).parent.path;
    2955              :     final cwd = Directory.current.path;
    2956              :     const libName = 'libpdfium.so';
    2957              :     final candidates = <String>[
    2958              :       // dart build cli: bundle/bin/<exe> → bundle/lib/<libName>.
    2959              :       '$exeDir/../lib/$libName',
    2960              :       // dart test / dart run (JIT): build system stages to .dart_tool/lib/ —
    2961              :       // walk up from cwd so the Pub-workspace-root staging is found too.
    2962              :       ...dartToolLibCandidates(cwd, libName),
    2963              :       // Hook cache direct path (fallback if staging hasn't copied the file).
    2964              :       '$cwd/.dart_tool/betto_pdfium/$bblanchonBuild/$libName',
    2965              :     ];
    2966              :     for (final path in candidates) {
    2967              :       final f = File(path);
    2968              :       if (f.existsSync()) {
    2969              :         try {
    2970              :           return ffi.DynamicLibrary.open(f.absolute.path);
    2971              :         } catch (_) {
    2972              :           // Try next candidate.
    2973              :         }
    2974              :       }
    2975              :     }
    2976              :     // Last resort: bare name works when LD_LIBRARY_PATH is set by the dart
    2977              :     // test runner, but not in subprocesses spawned by tests.
    2978              :     return ffi.DynamicLibrary.open(libName);
    2979              :   }
    2980              :   if (Platform.isMacOS) {
    2981              :     // Strategy 1: Flutter app bundle — the build system wraps
    2982              :     // DynamicLoadingBundled dylibs in versioned .framework bundles.
    2983              :     // For libpdfium.dylib the framework name is 'pdfium'.
    2984              :     // Must use the @rpath/ prefix so dlopen resolves via LC_RPATH
    2985              :     // (@executable_path/../Frameworks) rather than treating the path as
    2986              :     // CWD-relative, which would miss Contents/Frameworks/.
    2987              :     try {
    2988              :       return ffi.DynamicLibrary.open('@rpath/pdfium.framework/pdfium');
    2989              :     } catch (_) {
    2990              :       // Fall through to strategy 2.
    2991              :     }
    2992              : 
    2993              :     // Strategy 2: probe absolute candidate paths.
    2994              :     final exeDir = File(Platform.resolvedExecutable).parent.path;
    2995              :     final cwd = Directory.current.path;
    2996              :     const dylib = 'libpdfium.dylib';
    2997              :     final candidates = <String>[
    2998              :       // dart build cli: bundle/bin/<exe> → bundle/lib/<dylib>.
    2999              :       '$exeDir/../lib/$dylib',
    3000              :       // dart test / dart run (JIT): build system stages to .dart_tool/lib/ —
    3001              :       // walk up from cwd so the Pub-workspace-root staging is found too.
    3002              :       ...dartToolLibCandidates(cwd, dylib),
    3003              :       // Hook cache direct path (fallback if staging hasn't copied the file).
    3004              :       '$cwd/.dart_tool/betto_pdfium/$bblanchonBuild/$dylib',
    3005              :     ];
    3006              : 
    3007              :     for (final path in candidates) {
    3008              :       final f = File(path);
    3009              :       if (f.existsSync()) {
    3010              :         try {
    3011              :           return ffi.DynamicLibrary.open(f.absolute.path);
    3012              :         } catch (_) {
    3013              :           // Try next candidate.
    3014              :         }
    3015              :       }
    3016              :     }
    3017              : 
    3018              :     // All strategies failed — surface the diagnostic error from the framework
    3019              :     // path attempt so the message mentions the expected bundle layout.
    3020              :     return ffi.DynamicLibrary.open('@rpath/pdfium.framework/pdfium');
    3021              :   }
    3022              :   if (Platform.isWindows) {
    3023              :     // Windows is a DynamicLoadingBundled desktop platform like macOS/Linux.
    3024              :     // dart test, dart run (JIT), and dart build each stage the bundled asset
    3025              :     // to different locations — probe absolute paths before trying bare name.
    3026              :     final exeDir = File(Platform.resolvedExecutable).parent.path;
    3027              :     final cwd = Directory.current.path;
    3028              :     const dllName = 'pdfium.dll';
    3029              :     final candidates = <String>[
    3030              :       // dart build cli: bundle\bin\<exe> → bundle\lib\pdfium.dll
    3031              :       '$exeDir/../lib/$dllName',
    3032              :       // dart test / dart run (JIT): staged to .dart_tool/lib/ — walk up from
    3033              :       // cwd so the Pub-workspace-root staging is found too.
    3034              :       ...dartToolLibCandidates(cwd, dllName),
    3035              :       // Hook cache direct path (fallback if staging hasn't copied the file).
    3036              :       '$cwd/.dart_tool/betto_pdfium/$bblanchonBuild/$dllName',
    3037              :     ];
    3038              :     for (final path in candidates) {
    3039              :       final f = File(path);
    3040              :       if (f.existsSync()) {
    3041              :         try {
    3042              :           return ffi.DynamicLibrary.open(f.absolute.path);
    3043              :         } catch (_) {
    3044              :           // Try next candidate.
    3045              :         }
    3046              :       }
    3047              :     }
    3048              :     // Last resort: bare name — only works if pdfium.dll is on PATH.
    3049              :     return ffi.DynamicLibrary.open(dllName);
    3050              :   }
    3051              :   throw UnsupportedError(
    3052              :     'betto_pdfium: unsupported platform ${Platform.operatingSystem}. '
    3053              :     'Supported: macOS arm64, Linux x64/arm64, Windows x64, Android, iOS.',
    3054              :   );
    3055              :   // coverage:ignore-end
    3056              : }
        

Generated by: LCOV version 2.0-1