SVG migration: viewer

This commit is contained in:
Thibault Deckers 2021-07-02 09:33:03 +09:00
parent 92178ca409
commit 88d3fa7991
19 changed files with 654 additions and 160 deletions

View file

@ -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.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher 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.ThumbnailFetcher
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
import deckers.thibault.aves.model.ExifOrientationOp 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) val regionRect = Rect(x, y, x + width, y + height)
when (mimeType) { when (mimeType) {
MimeTypes.SVG -> SvgRegionFetcher(activity).fetch(
uri = uri,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
result = result,
)
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch( MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
uri = uri, uri = uri,
page = pageId ?: 0, page = pageId ?: 0,

View file

@ -124,9 +124,9 @@ class RegionFetcher internal constructor(
Glide.with(context).clear(target) Glide.with(context).clear(target)
} }
} }
}
private data class LastDecoderRef( private data class LastDecoderRef(
val uri: Uri, val uri: Uri,
val decoder: BitmapRegionDecoder, val decoder: BitmapRegionDecoder,
) )
}

View file

@ -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,
)
}

View file

@ -18,6 +18,7 @@ import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException import com.caverock.androidsvg.SVGParseException
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import kotlin.math.ceil 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 -> StorageUtils.openInputStream(context, uri)?.use { input ->
try { try {
SVG.getFromInputStream(input)?.let { svg -> SVG.getFromInputStream(input)?.let { svg ->
val svgWidth = svg.documentWidth svg.normalizeSize()
val svgHeight = svg.documentHeight val viewBox = svg.documentViewBox
val svgWidth = viewBox.width()
val svgHeight = viewBox.height()
val bitmapWidth = if (svgWidth > 0) ceil(svgWidth).toInt() else width val bitmapWidth: Int
val bitmapHeight = if (svgHeight > 0) ceil(svgHeight).toInt() else height 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 bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)

View file

@ -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%")
}
}

View file

@ -20,7 +20,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) { ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: 1.0,
informationCollector: () sync* { informationCollector: () sync* {
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}'); yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}');
}, },
@ -71,7 +71,6 @@ class RegionProviderKey {
final bool isFlipped; final bool isFlipped;
final Rectangle<int> region; final Rectangle<int> region;
final Size imageSize; final Size imageSize;
final double scale;
const RegionProviderKey({ const RegionProviderKey({
required this.uri, required this.uri,
@ -82,13 +81,12 @@ class RegionProviderKey {
required this.sampleSize, required this.sampleSize,
required this.region, required this.region,
required this.imageSize, required this.imageSize,
this.scale = 1.0,
}); });
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; 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 @override
@ -101,9 +99,8 @@ class RegionProviderKey {
sampleSize, sampleSize,
region, region,
imageSize, imageSize,
scale,
); );
@override @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}';
} }

View file

@ -435,8 +435,8 @@ class AvesEntry {
final size = await SvgMetadataService.getSize(this); final size = await SvgMetadataService.getSize(this);
if (size != null) { if (size != null) {
await _applyNewFields({ await _applyNewFields({
'width': size.width.round(), 'width': size.width.ceil(),
'height': size.height.round(), 'height': size.height.ceil(),
}, persist: persist); }, persist: persist);
} }
catalogMetadata = CatalogMetadata(contentId: contentId); catalogMetadata = CatalogMetadata(contentId: contentId);

View file

@ -27,21 +27,22 @@ extension ExtraAvesEntry on AvesEntry {
); );
} }
RegionProvider getRegion({required int sampleSize, Rectangle<int>? region}) { RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
return RegionProvider(_getRegionProviderKey(sampleSize, region)); return RegionProvider(RegionProviderKey(
}
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int>? region) {
return RegionProviderKey(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: isFlipped, isFlipped: isFlipped,
sampleSize: sampleSize, sampleSize: sampleSize,
region: region ?? Rectangle<int>(0, 0, width, height), region: Rectangle(
imageSize: Size(width.toDouble(), height.toDouble()), (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( UriImage get uriImage => UriImage(

View file

@ -1,6 +1,6 @@
enum CoordinateFormat { dms, decimal } enum CoordinateFormat { dms, decimal }
enum EntryBackground { black, white, transparent, checkered } enum EntryBackground { black, white, checkered }
enum HomePageSetting { collection, albums } enum HomePageSetting { collection, albums }

View file

@ -297,7 +297,7 @@ class Settings extends ChangeNotifier {
// rendering // 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()); set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString());

View file

@ -26,12 +26,9 @@ class SvgMetadataService {
String? getAttribute(String attributeName) => root.attributes.firstWhereOrNull((a) => a.name.qualified == attributeName)?.value; 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%]'), '')); double? tryParseWithoutUnit(String? s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), ''));
final width = tryParseWithoutUnit(getAttribute('width')); // prefer the viewbox over the viewport to determine size
final height = tryParseWithoutUnit(getAttribute('height'));
if (width != null && height != null) {
return Size(width, height);
}
// viewbox
final viewBox = getAttribute('viewBox'); final viewBox = getAttribute('viewBox');
if (viewBox != null) { if (viewBox != null) {
final parts = viewBox.split(RegExp(r'[\s,]+')); 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) { } catch (error, stack) {
debugPrint('failed to parse XML from SVG with error=$error\n$stack'); debugPrint('failed to parse XML from SVG with error=$error\n$stack');
} }

View file

@ -1,12 +1,13 @@
import 'dart:math'; import 'dart:math';
final double _log2 = log(2);
const double _piOver180 = pi / 180.0; const double _piOver180 = pi / 180.0;
double toDegrees(num radians) => radians / _piOver180; double toDegrees(num radians) => radians / _piOver180;
double toRadians(num degrees) => degrees * _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); double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);

View file

@ -21,8 +21,8 @@ class CheckeredPainter extends CustomPainter {
final dx = offset.dx % (checkSize * 2); final dx = offset.dx % (checkSize * 2);
final dy = offset.dy % (checkSize * 2); final dy = offset.dy % (checkSize * 2);
final xMax = size.width / checkSize; final xMax = (size.width / checkSize).ceil();
final yMax = size.height / checkSize; final yMax = (size.height / checkSize).ceil();
for (var x = -2; x < xMax; x++) { for (var x = -2; x < xMax; x++) {
for (var y = -2; y < yMax; y++) { for (var y = -2; y < yMax; y++) {
if ((x + y) % 2 == 0) { if ((x + y) % 2 == 0) {

View file

@ -41,29 +41,7 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
EntryBackground.white, EntryBackground.white,
EntryBackground.black, EntryBackground.black,
EntryBackground.checkered, EntryBackground.checkered,
EntryBackground.transparent,
].map((selected) { ].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<EntryBackground>( return DropdownMenuItem<EntryBackground>(
value: selected, value: selected,
child: Container( child: Container(
@ -74,7 +52,15 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
border: AvesBorder.border, border: AvesBorder.border,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: child, child: selected == EntryBackground.checkered
? ClipOval(
child: CustomPaint(
painter: CheckeredPainter(
checkSize: radius,
),
),
)
: null,
), ),
); );
}).toList(); }).toList();

View file

@ -1,5 +1,4 @@
import 'package:aves/app_mode.dart'; 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.dart';
import 'package:aves/model/entry_images.dart'; import 'package:aves/model/entry_images.dart';
import 'package:aves/theme/icons.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/debug/metadata.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -132,36 +130,30 @@ class ViewerDebugPage extends StatelessWidget {
} }
Widget _buildThumbnailsTabView() { Widget _buildThumbnailsTabView() {
final children = <Widget>[];
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( return ListView(
padding: const EdgeInsets.all(16), 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(),
); );
} }
} }

View file

@ -1,9 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/entry.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/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/image.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:aves/widgets/viewer/visual/video.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class EntryPageView extends StatefulWidget { class EntryPageView extends StatefulWidget {
@ -163,28 +159,19 @@ class _EntryPageViewState extends State<EntryPageView> {
} }
Widget _buildSvgView() { Widget _buildSvgView() {
final background = settings.vectorBackground;
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
var child = _buildMagnifier( var child = _buildMagnifier(
maxScale: const ScaleLevel(factor: double.infinity), maxScale: const ScaleLevel(factor: 25),
scaleStateCycle: _vectorScaleStateCycle, scaleStateCycle: _vectorScaleStateCycle,
child: SvgPicture( applyScale: false,
UriPicture( child: VectorImageView(
uri: entry.uri, entry: entry,
mimeType: entry.mimeType, viewStateNotifier: _viewStateNotifier,
colorFilter: colorFilter, errorBuilder: (context, error, stackTrace) => ErrorView(
entry: entry,
onTap: _onTap,
), ),
), ),
); );
if (background == EntryBackground.checkered) {
child = VectorViewCheckeredBackground(
displaySize: entry.displaySize,
viewStateNotifier: _viewStateNotifier,
child: child,
);
}
return child; return child;
} }

View file

@ -32,6 +32,7 @@ class RasterImageView extends StatefulWidget {
class _RasterImageViewState extends State<RasterImageView> { class _RasterImageViewState extends State<RasterImageView> {
late Size _displaySize; late Size _displaySize;
late bool _useTiles;
bool _isTilingInitialized = false; bool _isTilingInitialized = false;
late int _maxSampleSize; late int _maxSampleSize;
late double _tileSide; late double _tileSide;
@ -44,16 +45,19 @@ class _RasterImageViewState extends State<RasterImageView> {
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier; ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent;
ViewState get viewState => viewStateNotifier.value; ViewState get viewState => viewStateNotifier.value;
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail; ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
Rectangle<int> get fullImageRegion => Rectangle<int>(0, 0, entry.width, entry.height);
ImageProvider get fullImageProvider { ImageProvider get fullImageProvider {
if (entry.useTiles) { if (_useTiles) {
assert(_isTilingInitialized); assert(_isTilingInitialized);
return entry.getRegion(sampleSize: _maxSampleSize); return entry.getRegion(
sampleSize: _maxSampleSize,
region: fullImageRegion,
);
} else { } else {
return entry.uriImage; return entry.uriImage;
} }
@ -66,8 +70,9 @@ class _RasterImageViewState extends State<RasterImageView> {
void initState() { void initState() {
super.initState(); super.initState();
_displaySize = entry.displaySize; _displaySize = entry.displaySize;
_useTiles = entry.useTiles;
_fullImageListener = ImageStreamListener(_onFullImageCompleted); _fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!entry.useTiles) _registerFullImage(); if (!_useTiles) _registerFullImage();
} }
@override @override
@ -106,23 +111,23 @@ class _RasterImageViewState extends State<RasterImageView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final useTiles = entry.useTiles;
return ValueListenableBuilder<ViewState>( return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier, valueListenable: viewStateNotifier,
builder: (context, viewState, child) { builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize; final viewportSize = viewState.viewportSize;
final viewportSized = viewportSize?.isEmpty == false; final viewportSized = viewportSize?.isEmpty == false;
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize!); if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
return SizedBox.fromSize( return SizedBox.fromSize(
size: _displaySize * viewState.scale!, size: _displaySize * viewState.scale!,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
if (useBackground && viewportSized) _buildBackground(), if (entry.canHaveAlpha && viewportSized) _buildBackground(),
_buildLoading(), _buildLoading(),
if (useTiles) ..._getTiles(), if (_useTiles)
if (!useTiles) ..._getTiles()
else
Image( Image(
image: fullImageProvider, image: fullImageProvider,
gaplessPlayback: true, gaplessPlayback: true,
@ -230,9 +235,10 @@ class _RasterImageViewState extends State<RasterImageView> {
// for the largest sample size (matching the initial scale), the whole image is in view // for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image without tiling // so we subsample the whole image without tiling
final fullImageRegionTile = RegionTile( final fullImageRegionTile = _RegionTile(
entry: entry, entry: entry,
tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale), tileRect: Rect.fromLTWH(0, 0, displayWidth * scale, displayHeight * scale),
regionRect: fullImageRegion,
sampleSize: _maxSampleSize, sampleSize: _maxSampleSize,
); );
final tiles = [fullImageRegionTile]; final tiles = [fullImageRegionTile];
@ -253,7 +259,7 @@ class _RasterImageViewState extends State<RasterImageView> {
viewRect: viewRect, viewRect: viewRect,
); );
if (rects != null) { if (rects != null) {
tiles.add(RegionTile( tiles.add(_RegionTile(
entry: entry, entry: entry,
tileRect: rects.item1, tileRect: rects.item1,
regionRect: rects.item2, regionRect: rects.item2,
@ -320,20 +326,20 @@ class _RasterImageViewState extends State<RasterImageView> {
} }
} }
class RegionTile extends StatefulWidget { class _RegionTile extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
// `tileRect` uses Flutter view coordinates // `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates // `regionRect` uses the raw image pixel coordinates
final Rect tileRect; final Rect tileRect;
final Rectangle<int>? regionRect; final Rectangle<int> regionRect;
final int sampleSize; final int sampleSize;
const RegionTile({ const _RegionTile({
Key? key, Key? key,
required this.entry, required this.entry,
required this.tileRect, required this.tileRect,
this.regionRect, required this.regionRect,
required this.sampleSize, required this.sampleSize,
}) : super(key: key); }) : super(key: key);
@ -350,7 +356,7 @@ class RegionTile extends StatefulWidget {
} }
} }
class _RegionTileState extends State<RegionTile> { class _RegionTileState extends State<_RegionTile> {
late RegionProvider _provider; late RegionProvider _provider;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@ -362,7 +368,7 @@ class _RegionTileState extends State<RegionTile> {
} }
@override @override
void didUpdateWidget(covariant RegionTile oldWidget) { void didUpdateWidget(covariant _RegionTile oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) { if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
_unregisterWidget(oldWidget); _unregisterWidget(oldWidget);
@ -376,11 +382,11 @@ class _RegionTileState extends State<RegionTile> {
super.dispose(); super.dispose();
} }
void _registerWidget(RegionTile widget) { void _registerWidget(_RegionTile widget) {
_initProvider(); _initProvider();
} }
void _unregisterWidget(RegionTile widget) { void _unregisterWidget(_RegionTile widget) {
_pauseProvider(); _pauseProvider();
} }

View file

@ -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/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
class VectorViewCheckeredBackground extends StatelessWidget { class VectorImageView extends StatefulWidget {
final Size displaySize; final AvesEntry entry;
final ValueNotifier<ViewState> viewStateNotifier; final ValueNotifier<ViewState> viewStateNotifier;
final Widget child; final ImageErrorWidgetBuilder errorBuilder;
const VectorViewCheckeredBackground({ const VectorImageView({
Key? key, Key? key,
required this.displaySize, required this.entry,
required this.viewStateNotifier, required this.viewStateNotifier,
required this.child, required this.errorBuilder,
}) : super(key: key); }) : super(key: key);
@override
_VectorImageViewState createState() => _VectorImageViewState();
}
class _VectorImageViewState extends State<VectorImageView> {
late Size _displaySize;
bool _isTilingInitialized = false;
late double _minScale;
late double _tileSide;
ImageStream? _fullImageStream;
late ImageStreamListener _fullImageListener;
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
AvesEntry get entry => widget.entry;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
ViewState get viewState => viewStateNotifier.value;
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
Rectangle<double> get fullImageRegion => Rectangle<double>(.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<ViewState>( return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier, valueListenable: viewStateNotifier,
builder: (context, viewState, child) { builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize; final viewportSize = viewState.viewportSize;
if (viewportSize == null) return child!; final viewportSized = viewportSize?.isEmpty == false;
if (viewportSized && !_isTilingInitialized) _initTiling(viewportSize!);
final side = viewportSize.shortestSide; return SizedBox.fromSize(
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round()); size: _displaySize * viewState.scale!,
child: Stack(
final viewSize = displaySize * viewState.scale!; alignment: Alignment.center,
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; children: [
final offset = ((decorationSize - viewportSize) as Offset) / 2; _buildLoading(),
..._getTiles(),
return Stack( ],
alignment: Alignment.center, ),
children: [
Positioned(
width: decorationSize.width,
height: decorationSize.height,
child: CustomPaint(
painter: CheckeredPainter(
checkSize: checkSize,
offset: offset,
),
),
),
child!,
],
); );
}, },
);
}
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<bool>(
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<Widget> _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 = <Widget>[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<Rect, Rectangle<double>>? _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<double>(x, y, thisRegionWidth, thisRegionHeight);
return Tuple2<Rect, Rectangle<double>>(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<double> 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<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<double>>('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, 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);
}
}

View file

@ -19,6 +19,17 @@ void main() {
expect(highestPowerOf2(42), 32); expect(highestPowerOf2(42), 32);
expect(highestPowerOf2(0), 0); expect(highestPowerOf2(0), 0);
expect(highestPowerOf2(-42), 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', () { test('rounding to a given precision after the decimal', () {