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