From 88d3fa799189c08f05e9a33c4e2e84faa2a7972c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 2 Jul 2021 09:33:03 +0900 Subject: [PATCH] SVG migration: viewer --- .../aves/channel/calls/ImageFileHandler.kt | 8 + .../channel/calls/fetchers/RegionFetcher.kt | 10 +- .../calls/fetchers/SvgRegionFetcher.kt | 106 +++++ .../thibault/aves/decoder/SvgGlideModule.kt | 18 +- .../thibault/aves/metadata/SvgHelper.kt | 13 + lib/image_providers/region_provider.dart | 9 +- lib/model/entry.dart | 4 +- lib/model/entry_images.dart | 19 +- lib/model/settings/enums.dart | 2 +- lib/model/settings/settings.dart | 2 +- lib/services/svg_metadata_service.dart | 14 +- lib/utils/math_utils.dart | 5 +- .../common/fx/checkered_decoration.dart | 4 +- .../settings/viewer/entry_background.dart | 32 +- lib/widgets/viewer/debug/debug_page.dart | 52 +-- .../viewer/visual/entry_page_view.dart | 29 +- lib/widgets/viewer/visual/raster.dart | 46 +- lib/widgets/viewer/visual/vector.dart | 430 ++++++++++++++++-- test/utils/math_utils_test.dart | 11 + 19 files changed, 654 insertions(+), 160 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/metadata/SvgHelper.kt diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index aded25222..ac092169f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -7,6 +7,7 @@ import com.bumptech.glide.Glide import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher +import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher import deckers.thibault.aves.model.ExifOrientationOp @@ -113,6 +114,13 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val regionRect = Rect(x, y, x + width, y + height) when (mimeType) { + MimeTypes.SVG -> SvgRegionFetcher(activity).fetch( + uri = uri, + regionRect = regionRect, + imageWidth = imageWidth, + imageHeight = imageHeight, + result = result, + ) MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch( uri = uri, page = pageId ?: 0, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index 19a1374de..925dd90de 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -124,9 +124,9 @@ class RegionFetcher internal constructor( Glide.with(context).clear(target) } } -} -private data class LastDecoderRef( - val uri: Uri, - val decoder: BitmapRegionDecoder, -) + private data class LastDecoderRef( + val uri: Uri, + val decoder: BitmapRegionDecoder, + ) +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt new file mode 100644 index 000000000..c8acbd44a --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt @@ -0,0 +1,106 @@ +package deckers.thibault.aves.channel.calls.fetchers + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.RectF +import android.net.Uri +import com.caverock.androidsvg.PreserveAspectRatio +import com.caverock.androidsvg.RenderOptions +import com.caverock.androidsvg.SVG +import com.caverock.androidsvg.SVGParseException +import deckers.thibault.aves.metadata.SvgHelper.normalizeSize +import deckers.thibault.aves.utils.BitmapUtils.getBytes +import deckers.thibault.aves.utils.StorageUtils +import io.flutter.plugin.common.MethodChannel +import kotlin.math.ceil + +class SvgRegionFetcher internal constructor( + private val context: Context, +) { + private var lastSvgRef: LastSvgRef? = null + + suspend fun fetch( + uri: Uri, + regionRect: Rect, + imageWidth: Int, + imageHeight: Int, + result: MethodChannel.Result, + ) { + var currentSvgRef = lastSvgRef + if (currentSvgRef != null && currentSvgRef.uri != uri) { + currentSvgRef = null + } + + try { + if (currentSvgRef == null) { + val newSvg = StorageUtils.openInputStream(context, uri)?.use { input -> + try { + SVG.getFromInputStream(input) + } catch (ex: SVGParseException) { + result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null) + return + } + } + + if (newSvg == null) { + result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null) + return + } + + newSvg.normalizeSize() + currentSvgRef = LastSvgRef(uri, newSvg) + } + val svg = currentSvgRef.svg + lastSvgRef = currentSvgRef + + // we scale the requested region accordingly to the viewbox size + val viewBox = svg.documentViewBox + val svgWidth = viewBox.width() + val svgHeight = viewBox.height() + val xf = imageWidth / ceil(svgWidth) + val yf = imageHeight / ceil(svgHeight) + // some SVG paths do not respect the rendering viewbox and do not reach its edges + // so we render to a slightly larger bitmap, using a slightly larger viewbox, + // and crop that bitmap to the target region size + val bleedX = xf.toInt() + val bleedY = yf.toInt() + val effectiveRect = RectF( + (regionRect.left - bleedX) / xf, + (regionRect.top - bleedY) / yf, + (regionRect.right + bleedX) / xf, + (regionRect.bottom + bleedY) / yf, + ) + + val renderOptions = RenderOptions() + renderOptions.viewBox(effectiveRect.left, effectiveRect.top, effectiveRect.width(), effectiveRect.height()) + renderOptions.preserveAspectRatio(PreserveAspectRatio.FULLSCREEN_START) + + val targetBitmapWidth = regionRect.width() + val targetBitmapHeight = regionRect.height() + var bitmap = Bitmap.createBitmap( + targetBitmapWidth + bleedX * 2, + targetBitmapHeight + bleedY * 2, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + svg.renderToCanvas(canvas, renderOptions) + + bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight) + + if (bitmap != null) { + result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) + } else { + result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null) + } + } catch (e: Exception) { + result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message) + } + } + + private data class LastSvgRef( + val uri: Uri, + val svg: SVG, + ) +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt index 550085f47..4a90afec2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt @@ -18,6 +18,7 @@ import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.signature.ObjectKey import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVGParseException +import deckers.thibault.aves.metadata.SvgHelper.normalizeSize import deckers.thibault.aves.utils.StorageUtils import kotlin.math.ceil @@ -52,11 +53,20 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I StorageUtils.openInputStream(context, uri)?.use { input -> try { SVG.getFromInputStream(input)?.let { svg -> - val svgWidth = svg.documentWidth - val svgHeight = svg.documentHeight + svg.normalizeSize() + val viewBox = svg.documentViewBox + val svgWidth = viewBox.width() + val svgHeight = viewBox.height() - val bitmapWidth = if (svgWidth > 0) ceil(svgWidth).toInt() else width - val bitmapHeight = if (svgHeight > 0) ceil(svgHeight).toInt() else height + val bitmapWidth: Int + val bitmapHeight: Int + if (width / height > svgWidth / svgHeight) { + bitmapWidth = ceil(svgWidth * height / svgHeight).toInt() + bitmapHeight = height; + } else { + bitmapWidth = width + bitmapHeight = ceil(svgHeight * width / svgWidth).toInt() + } val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SvgHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SvgHelper.kt new file mode 100644 index 000000000..08efc7bb5 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SvgHelper.kt @@ -0,0 +1,13 @@ +package deckers.thibault.aves.metadata + +import com.caverock.androidsvg.SVG + +object SvgHelper { + fun SVG.normalizeSize() { + if (documentViewBox == null) { + setDocumentViewBox(0f, 0f, documentWidth, documentHeight) + } + setDocumentWidth("100%") + setDocumentHeight("100%") + } +} \ No newline at end of file diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index 1808b8e8a..0cc1f2c4a 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -20,7 +20,7 @@ class RegionProvider extends ImageProvider { ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) { return MultiFrameImageStreamCompleter( codec: _loadAsync(key, decode), - scale: key.scale, + scale: 1.0, informationCollector: () sync* { yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}'); }, @@ -71,7 +71,6 @@ class RegionProviderKey { final bool isFlipped; final Rectangle region; final Size imageSize; - final double scale; const RegionProviderKey({ required this.uri, @@ -82,13 +81,12 @@ class RegionProviderKey { required this.sampleSize, required this.region, required this.imageSize, - this.scale = 1.0, }); @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale; + return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize; } @override @@ -101,9 +99,8 @@ class RegionProviderKey { sampleSize, region, imageSize, - scale, ); @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}'; } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 56885996f..0bd25a583 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -435,8 +435,8 @@ class AvesEntry { final size = await SvgMetadataService.getSize(this); if (size != null) { await _applyNewFields({ - 'width': size.width.round(), - 'height': size.height.round(), + 'width': size.width.ceil(), + 'height': size.height.ceil(), }, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index d62dfabd7..fc286bb8c 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -27,21 +27,22 @@ extension ExtraAvesEntry on AvesEntry { ); } - RegionProvider getRegion({required int sampleSize, Rectangle? region}) { - return RegionProvider(_getRegionProviderKey(sampleSize, region)); - } - - RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle? region) { - return RegionProviderKey( + RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle region}) { + return RegionProvider(RegionProviderKey( uri: uri, mimeType: mimeType, pageId: pageId, rotationDegrees: rotationDegrees, isFlipped: isFlipped, sampleSize: sampleSize, - region: region ?? Rectangle(0, 0, width, height), - imageSize: Size(width.toDouble(), height.toDouble()), - ); + region: Rectangle( + (region.left * scale).round(), + (region.top * scale).round(), + (region.width * scale).round(), + (region.height * scale).round(), + ), + imageSize: Size((width * scale).toDouble(), (height * scale).toDouble()), + )); } UriImage get uriImage => UriImage( diff --git a/lib/model/settings/enums.dart b/lib/model/settings/enums.dart index c65fb738c..c7f9a5e98 100644 --- a/lib/model/settings/enums.dart +++ b/lib/model/settings/enums.dart @@ -1,6 +1,6 @@ enum CoordinateFormat { dms, decimal } -enum EntryBackground { black, white, transparent, checkered } +enum EntryBackground { black, white, checkered } enum HomePageSetting { collection, albums } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 41b8da7ea..4c85bfbaf 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -297,7 +297,7 @@ class Settings extends ChangeNotifier { // rendering - EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values); + EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.white, EntryBackground.values); set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString()); diff --git a/lib/services/svg_metadata_service.dart b/lib/services/svg_metadata_service.dart index 365eeb6ec..348edbfb7 100644 --- a/lib/services/svg_metadata_service.dart +++ b/lib/services/svg_metadata_service.dart @@ -26,12 +26,9 @@ class SvgMetadataService { String? getAttribute(String attributeName) => root.attributes.firstWhereOrNull((a) => a.name.qualified == attributeName)?.value; double? tryParseWithoutUnit(String? s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), '')); - final width = tryParseWithoutUnit(getAttribute('width')); - final height = tryParseWithoutUnit(getAttribute('height')); - if (width != null && height != null) { - return Size(width, height); - } + // prefer the viewbox over the viewport to determine size + // viewbox final viewBox = getAttribute('viewBox'); if (viewBox != null) { final parts = viewBox.split(RegExp(r'[\s,]+')); @@ -43,6 +40,13 @@ class SvgMetadataService { } } } + + // viewport + final width = tryParseWithoutUnit(getAttribute('width')); + final height = tryParseWithoutUnit(getAttribute('height')); + if (width != null && height != null) { + return Size(width, height); + } } catch (error, stack) { debugPrint('failed to parse XML from SVG with error=$error\n$stack'); } diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart index 2dd6e1ede..583d131e9 100644 --- a/lib/utils/math_utils.dart +++ b/lib/utils/math_utils.dart @@ -1,12 +1,13 @@ import 'dart:math'; -final double _log2 = log(2); const double _piOver180 = pi / 180.0; double toDegrees(num radians) => radians / _piOver180; double toRadians(num degrees) => degrees * _piOver180; -int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor()) as int; +int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt(); + +int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt(); double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals); diff --git a/lib/widgets/common/fx/checkered_decoration.dart b/lib/widgets/common/fx/checkered_decoration.dart index d1d7bdb99..ad2de6e62 100644 --- a/lib/widgets/common/fx/checkered_decoration.dart +++ b/lib/widgets/common/fx/checkered_decoration.dart @@ -21,8 +21,8 @@ class CheckeredPainter extends CustomPainter { final dx = offset.dx % (checkSize * 2); final dy = offset.dy % (checkSize * 2); - final xMax = size.width / checkSize; - final yMax = size.height / checkSize; + final xMax = (size.width / checkSize).ceil(); + final yMax = (size.height / checkSize).ceil(); for (var x = -2; x < xMax; x++) { for (var y = -2; y < yMax; y++) { if ((x + y) % 2 == 0) { diff --git a/lib/widgets/settings/viewer/entry_background.dart b/lib/widgets/settings/viewer/entry_background.dart index acf78076c..23a9017e0 100644 --- a/lib/widgets/settings/viewer/entry_background.dart +++ b/lib/widgets/settings/viewer/entry_background.dart @@ -41,29 +41,7 @@ class _EntryBackgroundSelectorState extends State { EntryBackground.white, EntryBackground.black, EntryBackground.checkered, - EntryBackground.transparent, ].map((selected) { - Widget? child; - switch (selected) { - case EntryBackground.transparent: - child = const Icon( - Icons.clear, - size: 20, - color: Colors.white30, - ); - break; - case EntryBackground.checkered: - child = ClipOval( - child: CustomPaint( - painter: CheckeredPainter( - checkSize: radius, - ), - ), - ); - break; - default: - break; - } return DropdownMenuItem( value: selected, child: Container( @@ -74,7 +52,15 @@ class _EntryBackgroundSelectorState extends State { border: AvesBorder.border, shape: BoxShape.circle, ), - child: child, + child: selected == EntryBackground.checkered + ? ClipOval( + child: CustomPaint( + painter: CheckeredPainter( + checkSize: radius, + ), + ), + ) + : null, ), ); }).toList(); diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index d1a68c470..d351d935b 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -1,5 +1,4 @@ import 'package:aves/app_mode.dart'; -import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/theme/icons.dart'; @@ -7,7 +6,6 @@ import 'package:aves/widgets/viewer/debug/db.dart'; import 'package:aves/widgets/viewer/debug/metadata.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -132,36 +130,30 @@ class ViewerDebugPage extends StatelessWidget { } Widget _buildThumbnailsTabView() { - final children = []; - if (entry.isSvg) { - const extent = 128.0; - children.addAll([ - const Text('SVG ($extent)'), - SvgPicture( - UriPicture( - uri: entry.uri, - mimeType: entry.mimeType, - ), - width: extent, - height: extent, - ) - ]); - } else { - children.addAll( - entry.cachedThumbnails.expand((provider) => [ - Text('Raster (${provider.key.extent})'), - Center( - child: Image( - image: provider, - ), - ), - const SizedBox(height: 16), - ]), - ); - } return ListView( padding: const EdgeInsets.all(16), - children: children, + children: entry.cachedThumbnails + .expand((provider) => [ + Text('Extent: ${provider.key.extent}'), + Center( + child: Image( + image: provider, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + return Container( + foregroundDecoration: const BoxDecoration( + border: Border.fromBorderSide(BorderSide( + color: Colors.amber, + width: .1, + )), + ), + child: child, + ); + }, + ), + ), + const SizedBox(height: 16), + ]) + .toList(), ); } } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 9a50dd384..432657db2 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -1,9 +1,6 @@ import 'dart:async'; -import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/settings/entry_background.dart'; -import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/image.dart'; @@ -25,7 +22,6 @@ import 'package:aves/widgets/viewer/visual/vector.dart'; import 'package:aves/widgets/viewer/visual/video.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; class EntryPageView extends StatefulWidget { @@ -163,28 +159,19 @@ class _EntryPageViewState extends State { } Widget _buildSvgView() { - final background = settings.vectorBackground; - final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null; - var child = _buildMagnifier( - maxScale: const ScaleLevel(factor: double.infinity), + maxScale: const ScaleLevel(factor: 25), scaleStateCycle: _vectorScaleStateCycle, - child: SvgPicture( - UriPicture( - uri: entry.uri, - mimeType: entry.mimeType, - colorFilter: colorFilter, + applyScale: false, + child: VectorImageView( + entry: entry, + viewStateNotifier: _viewStateNotifier, + errorBuilder: (context, error, stackTrace) => ErrorView( + entry: entry, + onTap: _onTap, ), ), ); - - if (background == EntryBackground.checkered) { - child = VectorViewCheckeredBackground( - displaySize: entry.displaySize, - viewStateNotifier: _viewStateNotifier, - child: child, - ); - } return child; } diff --git a/lib/widgets/viewer/visual/raster.dart b/lib/widgets/viewer/visual/raster.dart index a4c1c97c3..2e56898cf 100644 --- a/lib/widgets/viewer/visual/raster.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -32,6 +32,7 @@ class RasterImageView extends StatefulWidget { class _RasterImageViewState extends State { late Size _displaySize; + late bool _useTiles; bool _isTilingInitialized = false; late int _maxSampleSize; late double _tileSide; @@ -44,16 +45,19 @@ class _RasterImageViewState extends State { ValueNotifier get viewStateNotifier => widget.viewStateNotifier; - bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent; - ViewState get viewState => viewStateNotifier.value; ImageProvider get thumbnailProvider => entry.bestCachedThumbnail; + Rectangle get fullImageRegion => Rectangle(0, 0, entry.width, entry.height); + ImageProvider get fullImageProvider { - if (entry.useTiles) { + if (_useTiles) { assert(_isTilingInitialized); - return entry.getRegion(sampleSize: _maxSampleSize); + return entry.getRegion( + sampleSize: _maxSampleSize, + region: fullImageRegion, + ); } else { return entry.uriImage; } @@ -66,8 +70,9 @@ class _RasterImageViewState extends State { void initState() { super.initState(); _displaySize = entry.displaySize; + _useTiles = entry.useTiles; _fullImageListener = ImageStreamListener(_onFullImageCompleted); - if (!entry.useTiles) _registerFullImage(); + if (!_useTiles) _registerFullImage(); } @override @@ -106,23 +111,23 @@ class _RasterImageViewState extends State { @override Widget build(BuildContext context) { - final useTiles = entry.useTiles; return ValueListenableBuilder( valueListenable: viewStateNotifier, builder: (context, viewState, child) { final viewportSize = viewState.viewportSize; final viewportSized = viewportSize?.isEmpty == false; - if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize!); + if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!); return SizedBox.fromSize( size: _displaySize * viewState.scale!, child: Stack( alignment: Alignment.center, children: [ - if (useBackground && viewportSized) _buildBackground(), + if (entry.canHaveAlpha && viewportSized) _buildBackground(), _buildLoading(), - if (useTiles) ..._getTiles(), - if (!useTiles) + if (_useTiles) + ..._getTiles() + else Image( image: fullImageProvider, gaplessPlayback: true, @@ -230,9 +235,10 @@ class _RasterImageViewState extends State { // for the largest sample size (matching the initial scale), the whole image is in view // so we subsample the whole image without tiling - final fullImageRegionTile = RegionTile( + final fullImageRegionTile = _RegionTile( entry: entry, tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale), + regionRect: fullImageRegion, sampleSize: _maxSampleSize, ); final tiles = [fullImageRegionTile]; @@ -253,7 +259,7 @@ class _RasterImageViewState extends State { viewRect: viewRect, ); if (rects != null) { - tiles.add(RegionTile( + tiles.add(_RegionTile( entry: entry, tileRect: rects.item1, regionRect: rects.item2, @@ -320,20 +326,20 @@ class _RasterImageViewState extends State { } } -class RegionTile extends StatefulWidget { +class _RegionTile extends StatefulWidget { final AvesEntry entry; // `tileRect` uses Flutter view coordinates // `regionRect` uses the raw image pixel coordinates final Rect tileRect; - final Rectangle? regionRect; + final Rectangle regionRect; final int sampleSize; - const RegionTile({ + const _RegionTile({ Key? key, required this.entry, required this.tileRect, - this.regionRect, + required this.regionRect, required this.sampleSize, }) : super(key: key); @@ -350,7 +356,7 @@ class RegionTile extends StatefulWidget { } } -class _RegionTileState extends State { +class _RegionTileState extends State<_RegionTile> { late RegionProvider _provider; AvesEntry get entry => widget.entry; @@ -362,7 +368,7 @@ class _RegionTileState extends State { } @override - void didUpdateWidget(covariant RegionTile oldWidget) { + void didUpdateWidget(covariant _RegionTile oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) { _unregisterWidget(oldWidget); @@ -376,11 +382,11 @@ class _RegionTileState extends State { super.dispose(); } - void _registerWidget(RegionTile widget) { + void _registerWidget(_RegionTile widget) { _initProvider(); } - void _unregisterWidget(RegionTile widget) { + void _unregisterWidget(_RegionTile widget) { _pauseProvider(); } diff --git a/lib/widgets/viewer/visual/vector.dart b/lib/widgets/viewer/visual/vector.dart index aa5489586..8d1b6ef91 100644 --- a/lib/widgets/viewer/visual/vector.dart +++ b/lib/widgets/viewer/visual/vector.dart @@ -1,54 +1,426 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:aves/image_providers/region_provider.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_images.dart'; +import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; -class VectorViewCheckeredBackground extends StatelessWidget { - final Size displaySize; +class VectorImageView extends StatefulWidget { + final AvesEntry entry; final ValueNotifier viewStateNotifier; - final Widget child; + final ImageErrorWidgetBuilder errorBuilder; - const VectorViewCheckeredBackground({ + const VectorImageView({ Key? key, - required this.displaySize, + required this.entry, required this.viewStateNotifier, - required this.child, + required this.errorBuilder, }) : super(key: key); + @override + _VectorImageViewState createState() => _VectorImageViewState(); +} + +class _VectorImageViewState extends State { + late Size _displaySize; + bool _isTilingInitialized = false; + late double _minScale; + late double _tileSide; + ImageStream? _fullImageStream; + late ImageStreamListener _fullImageListener; + final ValueNotifier _fullImageLoaded = ValueNotifier(false); + + AvesEntry get entry => widget.entry; + + ValueNotifier get viewStateNotifier => widget.viewStateNotifier; + + ViewState get viewState => viewStateNotifier.value; + + ImageProvider get thumbnailProvider => entry.bestCachedThumbnail; + + Rectangle get fullImageRegion => Rectangle(.0, .0, entry.width.toDouble(), entry.height.toDouble()); + + ImageProvider get fullImageProvider { + assert(_isTilingInitialized); + return entry.getRegion( + scale: _minScale, + region: fullImageRegion, + ); + } + + @override + void initState() { + super.initState(); + _displaySize = entry.displaySize; + _fullImageListener = ImageStreamListener(_onFullImageCompleted); + } + + @override + void didUpdateWidget(covariant VectorImageView oldWidget) { + super.didUpdateWidget(oldWidget); + + final oldViewState = oldWidget.viewStateNotifier.value; + final viewState = widget.viewStateNotifier.value; + if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) { + _isTilingInitialized = false; + _fullImageLoaded.value = false; + _unregisterFullImage(); + } + } + + @override + void dispose() { + _unregisterFullImage(); + super.dispose(); + } + + void _registerFullImage() { + _fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty); + _fullImageStream!.addListener(_fullImageListener); + } + + void _unregisterFullImage() { + _fullImageStream?.removeListener(_fullImageListener); + _fullImageStream = null; + } + + void _onFullImageCompleted(ImageInfo image, bool synchronousCall) { + _unregisterFullImage(); + _fullImageLoaded.value = true; + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: viewStateNotifier, builder: (context, viewState, child) { final viewportSize = viewState.viewportSize; - if (viewportSize == null) return child!; + final viewportSized = viewportSize?.isEmpty == false; + if (viewportSized && !_isTilingInitialized) _initTiling(viewportSize!); - final side = viewportSize.shortestSide; - final checkSize = side / ((side / EntryPageView.decorationCheckSize).round()); - - final viewSize = displaySize * viewState.scale!; - final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; - final offset = ((decorationSize - viewportSize) as Offset) / 2; - - return Stack( - alignment: Alignment.center, - children: [ - Positioned( - width: decorationSize.width, - height: decorationSize.height, - child: CustomPaint( - painter: CheckeredPainter( - checkSize: checkSize, - offset: offset, - ), - ), - ), - child!, - ], + return SizedBox.fromSize( + size: _displaySize * viewState.scale!, + child: Stack( + alignment: Alignment.center, + children: [ + _buildLoading(), + ..._getTiles(), + ], + ), ); }, + ); + } + + void _initTiling(Size viewportSize) { + _tileSide = _displaySize.longestSide; + // scale for initial state `contained` + final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height); + _minScale = _imageScaleForViewScale(containedScale); + + _isTilingInitialized = true; + _registerFullImage(); + } + + Widget _buildLoading() { + return ValueListenableBuilder( + valueListenable: _fullImageLoaded, + builder: (context, fullImageLoaded, child) { + if (fullImageLoaded) return const SizedBox.shrink(); + + return Center( + child: AspectRatio( + // enforce original aspect ratio, as some thumbnails aspect ratios slightly differ + aspectRatio: entry.displayAspectRatio, + child: Image( + image: thumbnailProvider, + fit: BoxFit.fill, + ), + ), + ); + }, + ); + } + + List _getTiles() { + if (!_isTilingInitialized) return []; + + final displayWidth = _displaySize.width; + final displayHeight = _displaySize.height; + final viewRect = _getViewRect(displayWidth, displayHeight); + final viewScale = viewState.scale!; + final background = settings.vectorBackground; + + Color? backgroundColor; + _BackgroundFrameBuilder? backgroundFrameBuilder; + if (background.isColor) { + backgroundColor = background.color; + } else if (background == EntryBackground.checkered) { + final viewportSize = viewState.viewportSize!; + final viewSize = _displaySize * viewState.scale!; + + final backgroundSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; + var backgroundOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position; + backgroundOffset = Offset(max(0, backgroundOffset.dx), max(0, backgroundOffset.dy)); + backgroundOffset += ((backgroundSize - viewportSize) as Offset) / 2; + + final side = viewportSize.shortestSide; + final checkSize = side / ((side / EntryPageView.decorationCheckSize).round()); + + backgroundFrameBuilder = (child, frame, tileRect) { + return frame == null + ? const SizedBox() + : DecoratedBox( + decoration: _CheckeredBackgroundDecoration( + viewportSize: viewportSize, + checkSize: checkSize, + offset: backgroundOffset - tileRect.topLeft, + ), + child: child, + ); + }; + } + + // for the largest sample size (matching the initial scale), the whole image is in view + // so we subsample the whole image without tiling + final fullImageRegionTile = _RegionTile( + entry: entry, + tileRect: Rect.fromLTWH(0, 0, displayWidth * viewScale, displayHeight * viewScale), + regionRect: fullImageRegion, + scale: _minScale, + backgroundColor: backgroundColor, + backgroundFrameBuilder: backgroundFrameBuilder, + ); + final tiles = [fullImageRegionTile]; + + final maxSvgScale = max(_imageScaleForViewScale(viewScale), _minScale); + double nextScale(double scale) => scale * 2; + // add `alpha` to the region side so that tiles do not align across layers, + // which helps the checkered background deflation workaround + // for the tile background bleeding issue + var alpha = 0; + for (var svgScale = nextScale(_minScale); svgScale <= maxSvgScale; svgScale = nextScale(svgScale)) { + final regionSide = (_tileSide + alpha++) / (svgScale / _minScale); + for (var x = .0; x < displayWidth; x += regionSide) { + for (var y = .0; y < displayHeight; y += regionSide) { + final rects = _getTileRects( + x: x, + y: y, + regionSide: regionSide, + displayWidth: displayWidth, + displayHeight: displayHeight, + scale: viewScale, + viewRect: viewRect, + ); + if (rects != null) { + tiles.add(_RegionTile( + entry: entry, + tileRect: rects.item1, + regionRect: rects.item2, + scale: svgScale, + backgroundColor: backgroundColor, + backgroundFrameBuilder: backgroundFrameBuilder, + )); + } + } + } + } + return tiles; + } + + Rect _getViewRect(double displayWidth, double displayHeight) { + final scale = viewState.scale!; + final centerOffset = viewState.position; + final viewportSize = viewState.viewportSize!; + final viewOrigin = Offset( + ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), + ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), + ); + return viewOrigin & viewportSize; + } + + Tuple2>? _getTileRects({ + required double x, + required double y, + required double regionSide, + required double displayWidth, + required double displayHeight, + required double scale, + required Rect viewRect, + }) { + final nextX = x + regionSide; + final nextY = y + regionSide; + final thisRegionWidth = regionSide - (nextX >= displayWidth ? nextX - displayWidth : 0); + final thisRegionHeight = regionSide - (nextY >= displayHeight ? nextY - displayHeight : 0); + final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale); + + // only build visible tiles + if (!viewRect.overlaps(tileRect)) return null; + + final regionRect = Rectangle(x, y, thisRegionWidth, thisRegionHeight); + return Tuple2>(tileRect, regionRect); + } + + double _imageScaleForViewScale(double scale) => smallestPowerOf2(scale * window.devicePixelRatio).toDouble(); +} + +typedef _BackgroundFrameBuilder = Widget Function(Widget child, int? frame, Rect tileRect); + +class _RegionTile extends StatefulWidget { + final AvesEntry entry; + + // `tileRect` uses Flutter view coordinates + // `regionRect` uses the raw image pixel coordinates + final Rect tileRect; + final Rectangle regionRect; + final double scale; + final Color? backgroundColor; + final _BackgroundFrameBuilder? backgroundFrameBuilder; + + const _RegionTile({ + Key? key, + required this.entry, + required this.tileRect, + required this.regionRect, + required this.scale, + required this.backgroundColor, + required this.backgroundFrameBuilder, + }) : super(key: key); + + @override + _RegionTileState createState() => _RegionTileState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('contentId', entry.contentId)); + properties.add(DiagnosticsProperty('tileRect', tileRect)); + properties.add(DiagnosticsProperty>('regionRect', regionRect)); + properties.add(DoubleProperty('scale', scale)); + } +} + +class _RegionTileState extends State<_RegionTile> { + late RegionProvider _provider; + + AvesEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant _RegionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.scale != widget.scale || oldWidget.scale != widget.scale) { + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(_RegionTile widget) { + _initProvider(); + } + + void _unregisterWidget(_RegionTile widget) { + _pauseProvider(); + } + + void _initProvider() { + _provider = entry.getRegion( + scale: widget.scale, + region: widget.regionRect, + ); + } + + void _pauseProvider() => _provider.pause(); + + @override + Widget build(BuildContext context) { + final tileRect = widget.tileRect; + + Widget child = Image( + image: _provider, + frameBuilder: (_, child, frame, __) => widget.backgroundFrameBuilder?.call(child, frame, tileRect) ?? child, + width: tileRect.width, + height: tileRect.height, + color: widget.backgroundColor, + colorBlendMode: BlendMode.dstOver, + fit: BoxFit.fill, + ); + + return Positioned.fromRect( + rect: tileRect, child: child, ); } } + +class _CheckeredBackgroundDecoration extends Decoration { + final Size viewportSize; + final double checkSize; + final Offset offset; + + const _CheckeredBackgroundDecoration({ + required this.viewportSize, + required this.checkSize, + required this.offset, + }); + + @override + _CheckeredBackgroundDecorationPainter createBoxPainter([VoidCallback? onChanged]) { + return _CheckeredBackgroundDecorationPainter(this, onChanged); + } +} + +class _CheckeredBackgroundDecorationPainter extends BoxPainter { + final _CheckeredBackgroundDecoration decoration; + + const _CheckeredBackgroundDecorationPainter(this.decoration, VoidCallback? onChanged) : super(onChanged); + + static const deflation = Offset(.5, .5); + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + if (size == null) return; + + var decorated = offset & size; + // deflate background as a workaround for background bleeding beyond tile image + decorated = Rect.fromLTRB( + decorated.left + deflation.dx, + decorated.top + deflation.dy, + decorated.right - deflation.dx, + decorated.bottom - deflation.dy, + ); + + final visible = decorated.intersect(Offset.zero & decoration.viewportSize); + final checkOffset = decoration.offset + decorated.topLeft - visible.topLeft - deflation; + + final translation = Offset(max(0, offset.dx + deflation.dx), max(0, offset.dy + deflation.dy)); + canvas.translate(translation.dx, translation.dy); + CheckeredPainter( + checkSize: decoration.checkSize, + offset: checkOffset, + ).paint(canvas, visible.size); + canvas.translate(-translation.dx, -translation.dy); + } +} diff --git a/test/utils/math_utils_test.dart b/test/utils/math_utils_test.dart index 5ae397247..f0986f22a 100644 --- a/test/utils/math_utils_test.dart +++ b/test/utils/math_utils_test.dart @@ -19,6 +19,17 @@ void main() { expect(highestPowerOf2(42), 32); expect(highestPowerOf2(0), 0); expect(highestPowerOf2(-42), 0); + expect(highestPowerOf2(.5), 0); + expect(highestPowerOf2(1.5), 1); + }); + + test('smallest power of 2 that is larger than or equal to the number', () { + expect(smallestPowerOf2(1024), 1024); + expect(smallestPowerOf2(42), 64); + expect(smallestPowerOf2(0), 1); + expect(smallestPowerOf2(-42), 1); + expect(smallestPowerOf2(.5), 1); + expect(smallestPowerOf2(1.5), 2); }); test('rounding to a given precision after the decimal', () {