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.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,

View file

@ -124,9 +124,9 @@ class RegionFetcher internal constructor(
Glide.with(context).clear(target)
}
}
}
private data class LastDecoderRef(
private data class LastDecoderRef(
val uri: Uri,
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.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)

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) {
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<int> 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}';
}

View file

@ -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);

View file

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

View file

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

View file

@ -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());

View file

@ -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');
}

View file

@ -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);

View file

@ -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) {

View file

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

View file

@ -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 = <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})'),
return ListView(
padding: const EdgeInsets.all(16),
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),
]),
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: children,
])
.toList(),
);
}
}

View file

@ -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<EntryPageView> {
}
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,
),
),
);
if (background == EntryBackground.checkered) {
child = VectorViewCheckeredBackground(
displaySize: entry.displaySize,
applyScale: false,
child: VectorImageView(
entry: entry,
viewStateNotifier: _viewStateNotifier,
child: child,
errorBuilder: (context, error, stackTrace) => ErrorView(
entry: entry,
onTap: _onTap,
),
),
);
}
return child;
}

View file

@ -32,6 +32,7 @@ class RasterImageView extends StatefulWidget {
class _RasterImageViewState extends State<RasterImageView> {
late Size _displaySize;
late bool _useTiles;
bool _isTilingInitialized = false;
late int _maxSampleSize;
late double _tileSide;
@ -44,16 +45,19 @@ class _RasterImageViewState extends State<RasterImageView> {
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent;
ViewState get viewState => viewStateNotifier.value;
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
Rectangle<int> get fullImageRegion => Rectangle<int>(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<RasterImageView> {
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<RasterImageView> {
@override
Widget build(BuildContext context) {
final useTiles = entry.useTiles;
return ValueListenableBuilder<ViewState>(
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<RasterImageView> {
// 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<RasterImageView> {
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<RasterImageView> {
}
}
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<int>? regionRect;
final Rectangle<int> 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<RegionTile> {
class _RegionTileState extends State<_RegionTile> {
late RegionProvider _provider;
AvesEntry get entry => widget.entry;
@ -362,7 +368,7 @@ class _RegionTileState extends State<RegionTile> {
}
@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<RegionTile> {
super.dispose();
}
void _registerWidget(RegionTile widget) {
void _registerWidget(_RegionTile widget) {
_initProvider();
}
void _unregisterWidget(RegionTile widget) {
void _unregisterWidget(_RegionTile widget) {
_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/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<ViewState> 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<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
Widget build(BuildContext context) {
return ValueListenableBuilder<ViewState>(
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!);
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<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());
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(
backgroundFrameBuilder = (child, frame, tileRect) {
return frame == null
? const SizedBox()
: DecoratedBox(
decoration: _CheckeredBackgroundDecoration(
viewportSize: viewportSize,
checkSize: checkSize,
offset: offset,
offset: backgroundOffset - tileRect.topLeft,
),
),
),
child!,
],
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,
);
}
}
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(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', () {