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 : }
|