diff --git a/.vscode/settings.json b/.vscode/settings.json index f8ac88c..f838cf7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "alloc", "ANNOT", + "Annots", "Antialiasing", "ARGB", "bgra", @@ -43,6 +44,7 @@ "Pdfjs", "pdfrx", "pubspec", + "RECTF", "rects", "reentrantly", "relayout", @@ -138,4 +140,4 @@ "xloctime": "cpp", "xstring": "cpp" } -} \ No newline at end of file +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 82af2ea..d0a38dd 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -67,8 +67,11 @@ class _MyAppState extends State { // documentSize: Size(x, height), // ); // }, - // Thumbs for vertical/horizontal scroll + // + // Scroll-thumbs example + // viewerOverlayBuilder: (context, size) => [ + // Show vertical scroll thumb on the right; it has page number on it PdfViewerScrollThumb( controller: controller, orientation: ScrollbarOrientation.right, @@ -85,6 +88,7 @@ class _MyAppState extends State { ), ), ), + // Just a simple horizontal scroll thumb on the bottom PdfViewerScrollThumb( controller: controller, orientation: ScrollbarOrientation.bottom, @@ -96,6 +100,31 @@ class _MyAppState extends State { ), ), ], + // + // Loading progress indicator example + // + loadingBannerBuilder: (context, bytesDownloaded, totalBytes) => + Center( + child: CircularProgressIndicator( + value: totalBytes != null + ? bytesDownloaded / totalBytes + : null, + backgroundColor: Colors.grey, + ), + ), + // + // Link handling example + // + // FIXME: a link with several areas (link that contains line-break) does not correctly + // show the hover status + // FIXME: gestures other than tap should be passed-through to the underlying widget + linkWidgetBuilder: (context, link, size) => Material( + color: Colors.transparent, + child: InkWell( + onTap: () => print('link tapped: ${link.url}'), + hoverColor: Colors.blue.withOpacity(0.2), + ), + ), ), ), AnimatedPositioned( diff --git a/lib/pdfrx.dart b/lib/pdfrx.dart index cb9b80f..50efbe0 100644 --- a/lib/pdfrx.dart +++ b/lib/pdfrx.dart @@ -1,6 +1,7 @@ export 'src/pdf_api.dart'; export 'src/pdf_document_store.dart'; export 'src/pdf_file_cache.dart'; +export 'src/pdf_page_text.dart'; export 'src/pdf_viewer_params.dart'; export 'src/pdf_viewer_scroll_thumb.dart'; export 'src/pdf_widgets.dart'; diff --git a/lib/src/pdf_api.dart b/lib/src/pdf_api.dart index 11fb4dd..ca93308 100644 --- a/lib/src/pdf_api.dart +++ b/lib/src/pdf_api.dart @@ -50,6 +50,7 @@ abstract class PdfDocumentFactory { Uri uri, { String? password, PdfPasswordProvider? passwordProvider, + PdfDownloadProgressCallback? progressCallback, }); /// Singleton [PdfDocumentFactory] instance. @@ -59,6 +60,15 @@ abstract class PdfDocumentFactory { static PdfDocumentFactory instance = PdfDocumentFactoryImpl(); } +/// Callback function to notify download progress. +/// +/// [downloadedBytes] is the number of bytes downloaded so far. +/// [totalBytes] is the total number of bytes to download. It may be `null` if the total size is unknown. +typedef PdfDownloadProgressCallback = void Function( + int downloadedBytes, [ + int? totalBytes, +]); + /// Function to provide password for encrypted PDF. /// /// The function is called when PDF requires password. @@ -163,16 +173,20 @@ abstract class PdfDocument { /// /// For Flutter Web, the implementation uses browser's function and restricted by CORS. // ignore: comment_references - /// For other platforms, it uses [pdfDocumentFromUri] that uses HTTP's range request to download the file . + /// For other platforms, it uses [pdfDocumentFromUri] that uses HTTP's range request to download the file. + /// + /// [progressCallback] is called when the download progress is updated (Not supported on Web). static Future openUri( Uri uri, { String? password, PdfPasswordProvider? passwordProvider, + PdfDownloadProgressCallback? progressCallback, }) => PdfDocumentFactory.instance.openUri( uri, password: password, passwordProvider: passwordProvider, + progressCallback: progressCallback, ); /// Pages. @@ -240,6 +254,8 @@ abstract class PdfPage { /// Create Text object to extract text from the page. /// The returned object should be disposed after use. Future loadText(); + + Future> loadLinks(); } /// Annotation rendering mode. diff --git a/lib/src/pdf_document_store.dart b/lib/src/pdf_document_store.dart index ab7b593..9c8a230 100644 --- a/lib/src/pdf_document_store.dart +++ b/lib/src/pdf_document_store.dart @@ -18,6 +18,8 @@ class PdfDocumentRef extends Listenable { PdfDocument? _document; Object? _error; int _revision; + int _bytesDownloaded = 0; + int? _totalBytes; /// The [PdfDocument] instance if available. PdfDocument? get document => _document; @@ -27,6 +29,9 @@ class PdfDocumentRef extends Listenable { int get revision => _revision; + int get bytesDownloaded => _bytesDownloaded; + int? get totalBytes => _totalBytes; + @override void addListener(VoidCallback listener) { _listeners.add(listener); @@ -53,15 +58,21 @@ class PdfDocumentRef extends Listenable { _document = null; } + void _progress(int progress, [int? total]) { + _bytesDownloaded = progress; + _totalBytes = total; + notifyListeners(); + } + Future setDocument( - FutureOr Function() documentLoader, { + PdfDocumentLoaderFunction documentLoader, { bool resetOnError = false, }) => store.synchronized( () async { try { final oldDocument = _document; - final newDocument = await documentLoader(); + final newDocument = await documentLoader(_progress); if (newDocument == oldDocument) { return false; } @@ -84,6 +95,12 @@ class PdfDocumentRef extends Listenable { ); } +/// Function to load a [PdfDocument]. +/// +/// The load process may call [progressCallback] to report the download/load progress if loader can do that. +typedef PdfDocumentLoaderFunction = Future Function( + PdfDownloadProgressCallback progressCallback); + /// A store to maintain [PdfDocumentRef] instances. /// /// [PdfViewer] instances using the same [PdfDocumentStore] share the same [PdfDocumentRef] instances. @@ -103,7 +120,9 @@ class PdfDocumentStore { /// does nothing and returns existing [PdfDocumentRef] instance that indicates the error. PdfDocumentRef load( String sourceName, { - required Future Function() documentLoader, + required Future Function( + PdfDownloadProgressCallback progressCallback) + documentLoader, bool retryIfError = false, }) { final docRef = _docRefs.putIfAbsent( diff --git a/lib/src/pdf_file_cache.dart b/lib/src/pdf_file_cache.dart index 8ee1054..0cd21c4 100644 --- a/lib/src/pdf_file_cache.dart +++ b/lib/src/pdf_file_cache.dart @@ -254,7 +254,7 @@ class PdfFileCacheNative extends PdfFileCache { final dir1 = fnHash.substring(0, 2); final dir2 = fnHash.substring(2, 4); final body = fnHash.substring(4); - final dir = Directory(path.join(cacheDir.path, dir1, dir2)); + final dir = Directory(path.join(cacheDir.path, 'pdfrx.cache', dir1, dir2)); await dir.create(recursive: true); return File(path.join(dir.path, '$body.pdf')); } @@ -278,12 +278,15 @@ Future pdfDocumentFromUri( String? password, PdfPasswordProvider? passwordProvider, int? blockSize, + PdfFileCache? cache, + PdfDownloadProgressCallback? progressCallback, }) async { - final cache = await PdfFileCache.fromUri(uri); + progressCallback?.call(0); + cache ??= await PdfFileCache.fromUri(uri); if (!cache.isInitialized) { cache.setBlockSize(blockSize ?? PdfFileCache.defaultBlockSize); - final result = await _downloadBlock(uri, cache, 0); + final result = await _downloadBlock(uri, cache, progressCallback, 0); if (result.isFullDownload) { return PdfDocument.openFile( cache.filePath, @@ -297,7 +300,7 @@ Future pdfDocumentFromUri( final eTag = response.headers['etag']; if (eTag != cache.eTag) { await cache.resetAll(); - final result = await _downloadBlock(uri, cache, 0); + final result = await _downloadBlock(uri, cache, progressCallback, 0); if (result.isFullDownload) { return PdfDocument.openFile( cache.filePath, @@ -314,10 +317,10 @@ Future pdfDocumentFromUri( final end = position + size; int bufferPosition = 0; for (int p = position; p < end;) { - final blockId = p ~/ cache.blockSize; + final blockId = p ~/ cache!.blockSize; final isAvailable = cache.isCached(blockId); if (!isAvailable) { - await _downloadBlock(uri, cache, blockId); + await _downloadBlock(uri, cache, progressCallback, blockId); } final readEnd = min(p + size, (blockId + 1) * cache.blockSize); final sizeToRead = readEnd - p; @@ -332,7 +335,7 @@ Future pdfDocumentFromUri( passwordProvider: passwordProvider, fileSize: cache.fileSize, sourceName: uri.toString(), - onDispose: () => cache.close(), + onDispose: () => cache!.close(), ); } @@ -343,8 +346,13 @@ class _DownloadResult { } // Download blocks of the file and cache the data to file. -Future<_DownloadResult> _downloadBlock(Uri uri, PdfFileCache cache, int blockId, - {int blockCount = 1}) async { +Future<_DownloadResult> _downloadBlock( + Uri uri, + PdfFileCache cache, + PdfDownloadProgressCallback? progressCallback, + int blockId, { + int blockCount = 1, +}) async { int? fileSize; final blockOffset = blockId * cache.blockSize; final end = blockOffset + cache.blockSize * blockCount; @@ -370,5 +378,6 @@ Future<_DownloadResult> _downloadBlock(Uri uri, PdfFileCache cache, int blockId, } else { await cache.setCached(blockId, lastBlock: blockId + blockCount - 1); } + progressCallback?.call(cache.cachedBytes, fileSize); return _DownloadResult(fileSize!, isFullDownload); } diff --git a/lib/src/pdf_page_text_overlay.dart b/lib/src/pdf_page_text_overlay.dart new file mode 100644 index 0000000..60acd3c --- /dev/null +++ b/lib/src/pdf_page_text_overlay.dart @@ -0,0 +1,475 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../pdfrx.dart'; + +/// Still too much experimental but provided as is. +class PdfPageTextOverlay extends StatefulWidget { + const PdfPageTextOverlay({ + required this.page, + required this.pageRect, + required this.pageText, + super.key, + }); + + final PdfPage page; + final Rect pageRect; + final PdfPageText pageText; + + @override + State createState() => _PdfPageTextOverlayState(); +} + +class _PdfPageTextOverlayState extends State { + @override + Widget build(BuildContext context) { + return _generateSelectionArea( + widget.pageText.fragments, widget.page, widget.pageRect); + } + + Widget _generateSelectionArea( + List fragments, + PdfPage page, + Rect pageRect, + ) { + final scale = pageRect.height / page.height; + Rect? finalBounds; + for (final fragment in fragments) { + if (fragment.bounds.isEmpty) continue; + final rect = fragment.bounds.toRect(height: page.height, scale: scale); + if (rect.isEmpty) continue; + if (finalBounds == null) { + finalBounds = rect; + } else { + finalBounds = finalBounds.expandToInclude(rect); + } + } + if (finalBounds == null) return Container(); + + return Positioned( + left: pageRect.left + finalBounds.left, + top: pageRect.top + finalBounds.top, + width: finalBounds.width, + height: finalBounds.height, + child: SelectionArea( + child: Builder(builder: (context) { + final registrar = SelectionContainer.maybeOf(context); + return Stack( + children: _generateTextSelectionWidgets( + finalBounds!, fragments, page, pageRect, registrar), + ); + }), + ), + ); + } + + /// This function exists only to receive the [registrar] parameter :( + List _generateTextSelectionWidgets( + Rect finalBounds, + List fragments, + PdfPage page, + Rect pageRect, + SelectionRegistrar? registrar, + ) { + final scale = pageRect.height / page.height; + final texts = []; + + for (final fragment in fragments) { + if (fragment.bounds.isEmpty) continue; + final rect = fragment.bounds.toRect(height: page.height, scale: scale); + if (rect.isEmpty || fragment.text.isEmpty) continue; + texts.add( + Positioned( + key: ValueKey(fragment.index), + left: rect.left - finalBounds.left, + top: rect.top - finalBounds.top, + width: rect.width, + height: rect.height, + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: _PdfTextWidget( + registrar, + fragment, + fragment.charRects + ?.map((e) => e + .toRect(height: page.height, scale: scale) + .translate(-rect.left, -rect.top)) + .toList(), + rect.size, + ), + ), + ), + ); + } + return texts; + } +} + +/// The code is based on the code on [Making a widget selectable](https://api.flutter.dev/flutter/widgets/SelectableRegion-class.html#widgets).SelectableRegion.2] +class _PdfTextWidget extends LeafRenderObjectWidget { + const _PdfTextWidget( + this.registrar, this.fragment, this.charRects, this.size); + + final SelectionRegistrar? registrar; + final PdfPageTextFragment fragment; + final List? charRects; + final Size size; + + @override + RenderObject createRenderObject(BuildContext context) => _PdfTextRenderBox( + DefaultSelectionStyle.of(context).selectionColor!, this); + + @override + void updateRenderObject( + BuildContext context, _PdfTextRenderBox renderObject) { + renderObject + ..selectionColor = DefaultSelectionStyle.of(context).selectionColor! + ..registrar = registrar; + } +} + +/// The code is based on the code on [Making a widget selectable](https://api.flutter.dev/flutter/widgets/SelectableRegion-class.html#widgets).SelectableRegion.2] +class _PdfTextRenderBox extends RenderBox with Selectable, SelectionRegistrant { + _PdfTextRenderBox( + this._selectionColor, + this.widget, + ) : _geometry = ValueNotifier(_noSelection) { + registrar = widget.registrar; + _geometry.addListener(markNeedsPaint); + } + + final _PdfTextWidget widget; + + static const SelectionGeometry _noSelection = + SelectionGeometry(status: SelectionStatus.none, hasContent: true); + + final ValueNotifier _geometry; + + Color _selectionColor; + Color get selectionColor => _selectionColor; + set selectionColor(Color value) { + if (_selectionColor == value) return; + _selectionColor = value; + markNeedsPaint(); + } + + @override + void dispose() { + _geometry.dispose(); + super.dispose(); + } + + @override + bool get sizedByParent => true; + @override + double computeMinIntrinsicWidth(double height) => widget.size.width; + @override + double computeMaxIntrinsicWidth(double height) => widget.size.width; + @override + double computeMinIntrinsicHeight(double width) => widget.size.height; + @override + double computeMaxIntrinsicHeight(double width) => widget.size.height; + @override + Size computeDryLayout(BoxConstraints constraints) => + constraints.constrain(widget.size); + + @override + void addListener(VoidCallback listener) => _geometry.addListener(listener); + + @override + void removeListener(VoidCallback listener) => + _geometry.removeListener(listener); + + @override + SelectionGeometry get value => _geometry.value; + + Rect _getSelectionHighlightRect() => Offset.zero & size; + + Offset? _start; + Offset? _end; + String? _selectedText; + Rect? _selectedRect; + Size? _sizeOnSelection; + + void _updateGeometry() { + if (_start == null || _end == null) { + _geometry.value = _noSelection; + return; + } + final renderObjectRect = Rect.fromLTWH(0, 0, size.width, size.height); + var selectionRect = Rect.fromPoints(_start!, _end!); + if (renderObjectRect.intersect(selectionRect).isEmpty) { + _geometry.value = _noSelection; + } else { + selectionRect = + !selectionRect.isEmpty ? selectionRect : _getSelectionHighlightRect(); + + final selectionRects = []; + final sb = StringBuffer(); + _selectedRect = null; + if (widget.charRects != null) { + final scale = size.width / widget.size.width; + for (int i = 0; i < widget.charRects!.length; i++) { + final charRect = widget.charRects![i] * scale; + if (charRect.intersect(selectionRect).isEmpty) continue; + selectionRects.add(charRect); + sb.write(widget.fragment.text[i]); + + if (_selectedRect == null) { + _selectedRect = charRect; + } else { + _selectedRect = _selectedRect!.expandToInclude(charRect); + } + } + _selectedText = sb.toString(); + } else { + selectionRects.add(selectionRect); + _selectedText = widget.fragment.text; + _selectedRect = _getSelectionHighlightRect(); + } + + final firstSelectionPoint = SelectionPoint( + localPosition: _selectedRect!.bottomLeft, + lineHeight: _selectedRect!.height, + handleType: TextSelectionHandleType.left, + ); + final secondSelectionPoint = SelectionPoint( + localPosition: _selectedRect!.bottomRight, + lineHeight: _selectedRect!.height, + handleType: TextSelectionHandleType.right, + ); + final bool isReversed; + if (_start!.dy > _end!.dy) { + isReversed = true; + } else if (_start!.dy < _end!.dy) { + isReversed = false; + } else { + isReversed = _start!.dx > _end!.dx; + } + + _sizeOnSelection = size; + _geometry.value = SelectionGeometry( + status: _selectedText!.isNotEmpty + ? SelectionStatus.uncollapsed + : SelectionStatus.collapsed, + hasContent: true, + startSelectionPoint: + isReversed ? secondSelectionPoint : firstSelectionPoint, + endSelectionPoint: + isReversed ? firstSelectionPoint : secondSelectionPoint, + selectionRects: selectionRects, + ); + } + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + var result = SelectionResult.none; + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + case SelectionEventType.endEdgeUpdate: + final renderObjectRect = Rect.fromLTWH(0, 0, size.width, size.height); + final point = + globalToLocal((event as SelectionEdgeUpdateEvent).globalPosition); + final adjustedPoint = + SelectionUtils.adjustDragOffset(renderObjectRect, point); + if (event.type == SelectionEventType.startEdgeUpdate) { + _start = adjustedPoint; + } else { + _end = adjustedPoint; + } + result = SelectionUtils.getResultBasedOnRect(renderObjectRect, point); + break; + case SelectionEventType.clear: + _start = _end = null; + case SelectionEventType.selectAll: + case SelectionEventType.selectWord: + _start = Offset.zero; + _end = Offset.infinite; + case SelectionEventType.granularlyExtendSelection: + result = SelectionResult.end; + final extendSelectionEvent = event as GranularlyExtendSelectionEvent; + // Initialize the offset it there is no ongoing selection. + if (_start == null || _end == null) { + if (extendSelectionEvent.forward) { + _start = _end = Offset.zero; + } else { + _start = _end = Offset.infinite; + } + } + // Move the corresponding selection edge. + final newOffset = + extendSelectionEvent.forward ? Offset.infinite : Offset.zero; + if (extendSelectionEvent.isEnd) { + if (newOffset == _end) { + result = extendSelectionEvent.forward + ? SelectionResult.next + : SelectionResult.previous; + } + _end = newOffset; + } else { + if (newOffset == _start) { + result = extendSelectionEvent.forward + ? SelectionResult.next + : SelectionResult.previous; + } + _start = newOffset; + } + case SelectionEventType.directionallyExtendSelection: + result = SelectionResult.end; + final extendSelectionEvent = event as DirectionallyExtendSelectionEvent; + // Convert to local coordinates. + final horizontalBaseLine = globalToLocal(Offset(event.dx, 0)).dx; + final Offset newOffset; + final bool forward; + switch (extendSelectionEvent.direction) { + case SelectionExtendDirection.backward: + case SelectionExtendDirection.previousLine: + forward = false; + // Initialize the offset it there is no ongoing selection. + if (_start == null || _end == null) { + _start = _end = Offset.infinite; + } + // Move the corresponding selection edge. + if (extendSelectionEvent.direction == + SelectionExtendDirection.previousLine || + horizontalBaseLine < 0) { + newOffset = Offset.zero; + } else { + newOffset = Offset.infinite; + } + case SelectionExtendDirection.nextLine: + case SelectionExtendDirection.forward: + forward = true; + // Initialize the offset it there is no ongoing selection. + if (_start == null || _end == null) { + _start = _end = Offset.zero; + } + // Move the corresponding selection edge. + if (extendSelectionEvent.direction == + SelectionExtendDirection.nextLine || + horizontalBaseLine > size.width) { + newOffset = Offset.infinite; + } else { + newOffset = Offset.zero; + } + } + if (extendSelectionEvent.isEnd) { + if (newOffset == _end) { + result = forward ? SelectionResult.next : SelectionResult.previous; + } + _end = newOffset; + } else { + if (newOffset == _start) { + result = forward ? SelectionResult.next : SelectionResult.previous; + } + _start = newOffset; + } + } + _updateGeometry(); + return result; + } + + @override + SelectedContent? getSelectedContent() => + value.hasSelection ? SelectedContent(plainText: _selectedText!) : null; + + LayerLink? _startHandle; + LayerLink? _endHandle; + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + if (_startHandle == startHandle && _endHandle == endHandle) { + return; + } + _startHandle = startHandle; + _endHandle = endHandle; + // FIXME: pushHandleLayers sometimes called after dispose... + if (debugDisposed != true) { + markNeedsPaint(); + } + } + + // static int colorIndex = 0; + // final colors = [ + // Colors.red, + // Colors.deepOrangeAccent, + // Colors.purpleAccent, + // ]; + + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset); + + // context.canvas.drawRect( + // _getSelectionHighlightRect().shift(offset), + // Paint() + // ..style = PaintingStyle.stroke + // ..color = colors[colorIndex], + // ); + // colorIndex = (colorIndex + 1) % colors.length; + + if (!_geometry.value.hasSelection) { + return; + } + + if (_start == null || _end == null) { + return; + } + + final scale = size.width / _sizeOnSelection!.width; + + context.canvas.drawRect( + (_selectedRect! * scale).shift(offset), + Paint() + ..style = PaintingStyle.fill + ..color = _selectionColor, + ); + + if (_startHandle != null) { + context.pushLayer( + LeaderLayer( + link: _startHandle!, + offset: offset + (value.startSelectionPoint!.localPosition * scale), + )..applyTransform(null, Matrix4.diagonal3Values(scale, scale, 1.0)), + (context, offset) {}, + Offset.zero, + ); + } + if (_endHandle != null) { + context.pushLayer( + LeaderLayer( + link: _endHandle!, + offset: offset + (value.endSelectionPoint!.localPosition * scale), + )..applyTransform(null, Matrix4.diagonal3Values(scale, scale, 1.0)), + (context, offset) {}, + Offset.zero, + ); + } + + if (size != _sizeOnSelection) { + Future.microtask( + () { + final sp = _geometry.value.startSelectionPoint!; + final ep = _geometry.value.endSelectionPoint!; + _sizeOnSelection = size; + _selectedRect = _selectedRect! * scale; + _geometry.value = _geometry.value.copyWith( + startSelectionPoint: SelectionPoint( + handleType: sp.handleType, + lineHeight: sp.lineHeight * scale, + localPosition: sp.localPosition * scale), + endSelectionPoint: SelectionPoint( + handleType: ep.handleType, + lineHeight: ep.lineHeight * scale, + localPosition: ep.localPosition * scale), + selectionRects: + _geometry.value.selectionRects.map((r) => r * scale).toList(), + ); + markNeedsPaint(); + }, + ); + return; + } + } +} diff --git a/lib/src/pdf_viewer_params.dart b/lib/src/pdf_viewer_params.dart index 0e19743..3cbaa3d 100644 --- a/lib/src/pdf_viewer_params.dart +++ b/lib/src/pdf_viewer_params.dart @@ -37,6 +37,8 @@ class PdfViewerParams { this.enableRealSizeRendering = true, this.viewerOverlayBuilder, this.pageOverlayBuilder, + this.loadingBannerBuilder, + this.linkWidgetBuilder, this.forceReload = false, }); @@ -214,6 +216,28 @@ class PdfViewerParams { /// ``` final PdfPageOverlayBuilder? pageOverlayBuilder; + /// Build loading banner. + /// + /// Please note that the progress is only reported for [PdfViewer.uri] on non-Web platforms. + /// + /// The following fragment illustrates how to build loading banner that shows the download progress: + /// + /// ```dart + /// loadingBannerBuilder: (context, bytesDownloaded, totalBytes) { + /// return Center( + /// child: CircularProgressIndicator( + /// // totalBytes is null if the total bytes is unknown + /// value: totalBytes != null ? bytesDownloaded / totalBytes : null, + /// backgroundColor: Colors.grey, + /// ), + /// ); + /// }, + /// ``` + final PdfViewerLoadingBannerBuilder? loadingBannerBuilder; + + /// Build link widget. + final PdfLinkWidgetBuilder? linkWidgetBuilder; + /// Force reload the viewer. /// /// Normally whether to reload the viewer is determined by the changes of the parameters but @@ -274,6 +298,8 @@ class PdfViewerParams { other.enableRealSizeRendering == enableRealSizeRendering && other.viewerOverlayBuilder == viewerOverlayBuilder && other.pageOverlayBuilder == pageOverlayBuilder && + other.loadingBannerBuilder == loadingBannerBuilder && + other.linkWidgetBuilder == linkWidgetBuilder && other.forceReload == forceReload; } @@ -302,6 +328,8 @@ class PdfViewerParams { enableRealSizeRendering.hashCode ^ viewerOverlayBuilder.hashCode ^ pageOverlayBuilder.hashCode ^ + loadingBannerBuilder.hashCode ^ + linkWidgetBuilder.hashCode ^ forceReload.hashCode; } } @@ -345,6 +373,16 @@ typedef PdfViewerOverlaysBuilder = List Function( typedef PdfPageOverlayBuilder = Widget? Function( BuildContext context, Rect pageRect, PdfPage page); +/// Function to build loading banner. +/// +/// [bytesDownloaded] is the number of bytes downloaded so far. +/// [totalBytes] is the total number of bytes to be downloaded if available. +typedef PdfViewerLoadingBannerBuilder = Widget Function( + BuildContext context, int bytesDownloaded, int? totalBytes); + +typedef PdfLinkWidgetBuilder = Widget? Function( + BuildContext context, PdfLink link, Size size); + /// When [PdfViewerController.goToPage] is called, the page is aligned to the specified anchor. /// /// If the viewer area is smaller than the page, only some part of the page is shown in the viewer. diff --git a/lib/src/pdf_widgets.dart b/lib/src/pdf_widgets.dart index 3aaee6c..d9ba253 100644 --- a/lib/src/pdf_widgets.dart +++ b/lib/src/pdf_widgets.dart @@ -38,7 +38,7 @@ class PdfViewer extends StatefulWidget { key: key, documentRef: (store ?? PdfDocumentStore.defaultStore).load( '##PdfViewer:asset:$name', - documentLoader: () => + documentLoader: (_) => PdfDocument.openAsset(name, password: password), ), controller: controller, @@ -58,7 +58,7 @@ class PdfViewer extends StatefulWidget { key: key, documentRef: (store ?? PdfDocumentStore.defaultStore).load( '##PdfViewer:file:$path', - documentLoader: () => + documentLoader: (_) => PdfDocument.openFile(path, password: password), ), controller: controller, @@ -78,7 +78,11 @@ class PdfViewer extends StatefulWidget { key: key, documentRef: (store ?? PdfDocumentStore.defaultStore).load( '##PdfViewer:uri:$uri', - documentLoader: () => PdfDocument.openUri(uri, password: password), + documentLoader: (progressCallback) => PdfDocument.openUri( + uri, + password: password, + progressCallback: progressCallback, + ), ), controller: controller, params: displayParams, @@ -98,7 +102,7 @@ class PdfViewer extends StatefulWidget { key: key, documentRef: (store ?? PdfDocumentStore.defaultStore).load( '##PdfViewer:data:${sourceName ?? bytes.hashCode}', - documentLoader: () => PdfDocument.openData(bytes, + documentLoader: (_) => PdfDocument.openData(bytes, password: password, sourceName: sourceName), ), controller: controller, @@ -121,7 +125,7 @@ class PdfViewer extends StatefulWidget { key: key, documentRef: (store ?? PdfDocumentStore.defaultStore).load( '##PdfViewer:custom:$sourceName', - documentLoader: () => PdfDocument.openCustom( + documentLoader: (_) => PdfDocument.openCustom( read: read, fileSize: fileSize, sourceName: sourceName, @@ -162,6 +166,7 @@ class _PdfViewerState extends State final _realSized = {}; final _pageTexts = {}; + final _pageLinks = >{}; final _stream = BehaviorSubject(); @@ -217,6 +222,7 @@ class _PdfViewerState extends State _realSized.clear(); _pageTexts.clear(); + _pageLinks.clear(); _pageNumber = null; _initialized = false; _controller?.removeListener(_onMatrixChanged); @@ -251,6 +257,7 @@ class _PdfViewerState extends State widget.documentRef.removeListener(_onDocumentChanged); _realSized.clear(); _pageTexts.clear(); + _pageLinks.clear(); _controller!.removeListener(_onMatrixChanged); _controller!._attach(null); super.dispose(); @@ -262,7 +269,13 @@ class _PdfViewerState extends State @override Widget build(BuildContext context) { - if (_document == null) return Container(); + if (_document == null) { + return widget.params.loadingBannerBuilder?.call( + context, + widget.documentRef.bytesDownloaded, + widget.documentRef.totalBytes) ?? + Container(); + } return LayoutBuilder(builder: (context, constraints) { if (_calcViewSizeAndCoverScale( Size(constraints.maxWidth, constraints.maxHeight))) { @@ -508,17 +521,8 @@ class _PdfViewerState extends State (z1 - z2).abs() < 0.01; List _buildPageOverlayWidgets() { - if (widget.params.pageOverlayBuilder == null) return []; final renderBox = context.findRenderObject(); if (renderBox is! RenderBox) return []; - Rect? documentToRenderBox(Rect rect) { - final tl = _controller?.documentToGlobal(rect.topLeft); - if (tl == null) return null; - final br = _controller?.documentToGlobal(rect.bottomRight); - if (br == null) return null; - return Rect.fromPoints( - renderBox.globalToLocal(tl), renderBox.globalToLocal(br)); - } final widgets = []; final visibleRect = _controller!.visibleRect; @@ -530,27 +534,80 @@ class _PdfViewerState extends State if (intersection.isEmpty) continue; final page = _document!.pages[i]; - final rectExternal = documentToRenderBox(rect); + final rectExternal = documentToRenderBox(rect, renderBox); if (rectExternal != null) { - final overlay = widget.params.pageOverlayBuilder!( + final overlayWidgets = []; + _createLinkOverlays(overlayWidgets, rectExternal, page); + final overlay = widget.params.pageOverlayBuilder?.call( context, rectExternal, page, ); - if (overlay != null) { - widgets.add(Positioned( - left: rectExternal.left, - top: rectExternal.top, - width: rectExternal.width, - height: rectExternal.height, - child: overlay, - )); - } + + widgets.add(Positioned( + left: rectExternal.left, + top: rectExternal.top, + width: rectExternal.width, + height: rectExternal.height, + child: Stack( + children: [ + ...overlayWidgets, + if (overlay != null) overlay, + ], + ), + )); } } return widgets; } + void _createLinkOverlays( + List widgets, + Rect rect, + PdfPage page, + ) { + if (widget.params.linkWidgetBuilder == null) return; + final pageLinks = _pageLinks[page.pageNumber]; + if (pageLinks == null) { + Future.microtask( + () async { + await _loadPageLinks(page); + _invalidate(); + }, + ); + return; + } + + final scale = rect.height / page.height; + for (final link in pageLinks) { + for (final rect in link.rects) { + final rectLink = rect.toRect(height: page.height, scale: scale); + final linkWidget = + widget.params.linkWidgetBuilder!(context, link, rectLink.size); + if (linkWidget != null) { + widgets.add( + Positioned( + left: rectLink.left, + top: rectLink.top, + width: rectLink.width, + height: rectLink.height, + child: linkWidget, + ), + ); + } + } + } + } + + Rect? documentToRenderBox(Rect rect, RenderBox renderBox) { + final tl = _controller?.documentToGlobal(rect.topLeft); + if (tl == null) return null; + final br = _controller?.documentToGlobal(rect.bottomRight); + if (br == null) return null; + return Rect.fromPoints( + renderBox.globalToLocal(tl), renderBox.globalToLocal(br)); + } + final _cancellationTokens = >{}; void _addCancellationToken( @@ -657,6 +714,7 @@ class _PdfViewerState extends State (pageNumber) { _realSized.remove(pageNumber); _pageTexts.remove(pageNumber); + _pageLinks.remove(pageNumber); }, ); } @@ -747,6 +805,17 @@ class _PdfViewerState extends State }, ); + Future> _loadPageLinks(PdfPage page) => synchronized( + () async { + var pageLinks = _pageLinks[page.pageNumber]; + if (pageLinks == null) { + pageLinks = await page.loadLinks(); + _pageLinks[page.pageNumber] = pageLinks; + } + return pageLinks; + }, + ); + void _onWheelDelta(Offset delta) { final m = _controller!.value.clone(); m.translate( diff --git a/lib/src/pdfium/pdfrx_pdfium.dart b/lib/src/pdfium/pdfrx_pdfium.dart index 0fb7d0c..46909c0 100644 --- a/lib/src/pdfium/pdfrx_pdfium.dart +++ b/lib/src/pdfium/pdfrx_pdfium.dart @@ -213,11 +213,13 @@ class PdfDocumentFactoryImpl extends PdfDocumentFactory { Uri uri, { String? password, PdfPasswordProvider? passwordProvider, + PdfDownloadProgressCallback? progressCallback, }) => pdfDocumentFromUri( uri, password: password, passwordProvider: passwordProvider, + progressCallback: progressCallback, ); static bool _isPasswordError({int? error}) { @@ -527,6 +529,99 @@ class PdfPagePdfium extends PdfPage { @override Future loadText() => PdfPageTextPdfium._loadText(this); + + @override + Future> loadLinks() => document.synchronized( + () async => (await document._worker).compute( + (params) { + pdfium_bindings.FPDF_TEXTPAGE textPage = nullptr; + pdfium_bindings.FPDF_PAGELINK linkPage = nullptr; + try { + textPage = pdfium.FPDFText_LoadPage( + pdfium_bindings.FPDF_PAGE.fromAddress(params.page)); + if (textPage == nullptr) return []; + linkPage = pdfium.FPDFLink_LoadWebLinks(textPage); + if (linkPage == nullptr) return []; + + final doubleSize = sizeOf(); + return using((arena) { + final rectBuffer = arena.allocate(4 * doubleSize); + return List.generate( + pdfium.FPDFLink_CountWebLinks(linkPage), + (index) { + return PdfLink( + Uri.parse(_getLinkUrl(linkPage, index, arena)), + List.generate( + pdfium.FPDFLink_CountRects(linkPage, index), + (rectIndex) { + pdfium.FPDFLink_GetRect( + linkPage, + index, + rectIndex, + rectBuffer, + rectBuffer.offset(doubleSize), + rectBuffer.offset(doubleSize * 2), + rectBuffer.offset(doubleSize * 3), + ); + return _rectFromLTRBBuffer(rectBuffer); + }, + ), + ); + }, + ); + }); + } finally { + pdfium.FPDFLink_CloseWebLinks(linkPage); + pdfium.FPDFText_ClosePage(textPage); + } + }, + (page: page.address), + ), + ); + + static String _getLinkUrl( + pdfium_bindings.FPDF_PAGELINK linkPage, int linkIndex, Arena arena) { + final urlLength = pdfium.FPDFLink_GetURL(linkPage, linkIndex, nullptr, 0); + final urlBuffer = + arena.allocate(urlLength * sizeOf()); + pdfium.FPDFLink_GetURL(linkPage, linkIndex, urlBuffer, urlLength); + return String.fromCharCodes( + urlBuffer.cast().asTypedList(urlLength)); + } + + Future _loadAnnots() => document.synchronized( + () async => (await document._worker).compute( + (params) async { + try { + await using( + (arena) async { + final page = + pdfium_bindings.FPDF_PAGE.fromAddress(params.page); + final count = pdfium.FPDFPage_GetAnnotCount(page); + final rectf = arena.allocate( + sizeOf()); + for (int i = 0; i < count; i++) { + final annot = pdfium.FPDFPage_GetAnnot(page, i); + pdfium.FPDFAnnot_GetRect(annot, rectf); + final rect = PdfRect( + rectf.ref.left, + rectf.ref.top, + rectf.ref.right, + rectf.ref.bottom, + ); + final link = pdfium.FPDFAnnot_GetLink(annot); + final dest = pdfium.FPDFLink_GetDest(document.doc, link); + final action = pdfium.FPDFLink_GetAction(link); + + pdfium.FPDFPage_CloseAnnot(annot); + } + }, + ); + } finally {} + }, + (page: page.address), + ), + ); } class PdfPageRenderCancellationTokenPdfium @@ -698,12 +793,12 @@ class PdfPageTextPdfium extends PdfPageText { pdfium.FPDFText_GetCharBox( textPage, from + i, - buffer, - buffer.offset(doubleSize), - buffer.offset(doubleSize * 2), - buffer.offset(doubleSize * 3), + buffer, // L + buffer.offset(doubleSize * 2), // R + buffer.offset(doubleSize * 3), // B + buffer.offset(doubleSize), // T ); - final rect = _rectFromPointer(buffer); + final rect = _rectFromLTRBBuffer(buffer); if (char == _charSpace) { if (lastChar == _charSpace) continue; if (sb.length > wordStart) { @@ -789,56 +884,10 @@ class PdfPageTextPdfium extends PdfPageText { textPage, from, length, buffer.cast()); return String.fromCharCodes(buffer.asTypedList(length)); } - - static Future> _getLinks( - pdfium_bindings.FPDF_TEXTPAGE textPage, PdfPage page, Arena arena) async { - return await page.document.synchronized(() { - final linkPage = pdfium.FPDFLink_LoadWebLinks(textPage); - try { - final doubleSize = sizeOf(); - final rectBuffer = arena.allocate(4 * doubleSize); - return List.generate( - pdfium.FPDFLink_CountWebLinks(linkPage), - (index) { - return PdfLink( - Uri.parse(_getLinkUrl(linkPage, index, arena)), - List.generate( - pdfium.FPDFLink_CountRects(linkPage, index), - (rectIndex) { - pdfium.FPDFLink_GetRect( - linkPage, - index, - rectIndex, - rectBuffer, - rectBuffer.offset(doubleSize), - rectBuffer.offset(doubleSize * 2), - rectBuffer.offset(doubleSize * 3), - ); - return _rectFromPointer(rectBuffer); - }, - ), - ); - }, - ); - } finally { - pdfium.FPDFLink_CloseWebLinks(linkPage); - } - }); - } - - static String _getLinkUrl( - pdfium_bindings.FPDF_PAGELINK linkPage, int linkIndex, Arena arena) { - final urlLength = pdfium.FPDFLink_GetURL(linkPage, linkIndex, nullptr, 0); - final urlBuffer = - arena.allocate(urlLength * sizeOf()); - pdfium.FPDFLink_GetURL(linkPage, linkIndex, urlBuffer, urlLength); - return String.fromCharCodes( - urlBuffer.cast().asTypedList(urlLength)); - } } -PdfRect _rectFromPointer(Pointer buffer) => - PdfRect(buffer[0], buffer[3], buffer[1], buffer[2]); +PdfRect _rectFromLTRBBuffer(Pointer buffer) => + PdfRect(buffer[0], buffer[1], buffer[2], buffer[3]); extension _PointerExt on Pointer { Pointer offset(int offsetInBytes) => diff --git a/lib/src/web/pdfrx_web.dart b/lib/src/web/pdfrx_web.dart index ba4f3f6..a3dc691 100644 --- a/lib/src/web/pdfrx_web.dart +++ b/lib/src/web/pdfrx_web.dart @@ -125,6 +125,7 @@ class PdfDocumentFactoryImpl extends PdfDocumentFactory { Uri uri, { String? password, PdfPasswordProvider? passwordProvider, + PdfDownloadProgressCallback? progressCallback, }) => openFile( uri.path, @@ -330,6 +331,12 @@ class PdfPageWeb extends PdfPage { @override Future loadText() => PdfPageTextWeb._loadText(this); + + @override + Future> loadLinks() { + // TODO: implement loadLinks + throw UnimplementedError(); + } } class PdfPageRenderCancellationTokenWeb extends PdfPageRenderCancellationToken { diff --git a/src/pdfium_interop.cpp b/src/pdfium_interop.cpp index 0e383d8..400d70e 100644 --- a/src/pdfium_interop.cpp +++ b/src/pdfium_interop.cpp @@ -219,11 +219,37 @@ extern "C" EXPORT void const *const *INTEROP_API pdfrx_binding() reinterpret_cast(FPDFText_FindClose), reinterpret_cast(FPDFLink_LoadWebLinks), reinterpret_cast(FPDFLink_CountWebLinks), + reinterpret_cast(FPDFLink_LoadWebLinks), + reinterpret_cast(FPDFLink_CountWebLinks), reinterpret_cast(FPDFLink_GetURL), reinterpret_cast(FPDFLink_CountRects), reinterpret_cast(FPDFLink_GetRect), reinterpret_cast(FPDFLink_GetTextRange), reinterpret_cast(FPDFLink_CloseWebLinks), + reinterpret_cast(FPDFLink_GetLinkAtPoint), + reinterpret_cast(FPDFLink_GetLinkZOrderAtPoint), + reinterpret_cast(FPDFLink_GetDest), + reinterpret_cast(FPDFLink_GetAction), + reinterpret_cast(FPDFLink_Enumerate), + reinterpret_cast(FPDFLink_GetAnnot), + reinterpret_cast(FPDFLink_GetAnnotRect), + reinterpret_cast(FPDFLink_CountQuadPoints), + reinterpret_cast(FPDFLink_GetQuadPoints), + reinterpret_cast(FPDFLink_CloseWebLinks), + reinterpret_cast(FPDFAction_GetType), + reinterpret_cast(FPDFAction_GetDest), + reinterpret_cast(FPDFAction_GetFilePath), + reinterpret_cast(FPDFAction_GetURIPath), + reinterpret_cast(FPDFDest_GetDestPageIndex), + reinterpret_cast(FPDFDest_GetView), + reinterpret_cast(FPDFDest_GetLocationInPage), + reinterpret_cast(FPDFBookmark_GetFirstChild), + reinterpret_cast(FPDFBookmark_GetNextSibling), + reinterpret_cast(FPDFBookmark_GetTitle), + reinterpret_cast(FPDFBookmark_GetCount), + reinterpret_cast(FPDFBookmark_Find), + reinterpret_cast(FPDFBookmark_GetDest), + reinterpret_cast(FPDFBookmark_GetAction), reinterpret_cast(FPDFDOC_InitFormFillEnvironment), reinterpret_cast(FPDFDOC_ExitFormFillEnvironment), reinterpret_cast(FPDF_FFLDraw),