diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index ba3c17335..6a54de74a 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/service_policy.dart'; +import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; @@ -24,8 +25,6 @@ class ImageEntry { String _path, _directory, _filename, _extension; int contentId; final String sourceMimeType; - - // TODO TLAD use SVG viewport as width/height int width; int height; int sourceRotationDegrees; @@ -236,10 +235,24 @@ class ImageEntry { // but it would take space and time, so a basic workaround will do. bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height); + static const ratioSeparator = '\u2236'; + static const resolutionSeparator = ' \u00D7 '; + String get resolutionText { final w = width ?? '?'; final h = height ?? '?'; - return isPortrait ? '$h × $w' : '$w × $h'; + return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h'; + } + + String get aspectRatioText { + if (width != null && height != null && width > 0 && height > 0) { + final gcd = width.gcd(height); + final w = width ~/ gcd; + final h = height ~/ gcd; + return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h'; + } else { + return '?$ratioSeparator?'; + } } double get displayAspectRatio { @@ -319,7 +332,7 @@ class ImageEntry { String _bestTitle; String get bestTitle { - _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle; + _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle; return _bestTitle; } @@ -350,7 +363,20 @@ class ImageEntry { Future catalog({bool background = false}) async { if (isCatalogued) return; - catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); + if (isSvg) { + // vector image sizing is not essential, so we should not spend time for it during loading + // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing + final size = await SvgMetadataService.getSize(this); + if (size != null) { + await _applyNewFields({ + 'width': size.width.round(), + 'height': size.height.round(), + }); + } + catalogMetadata = CatalogMetadata(contentId: contentId); + } else { + catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); + } } AddressDetails get addressDetails => _addressDetails; @@ -447,6 +473,12 @@ class ImageEntry { this.sourceTitle = sourceTitle; _bestTitle = null; } + + final width = newFields['width']; + if (width is int) this.width = width; + final height = newFields['height']; + if (height is int) this.height = height; + final dateModifiedSecs = newFields['dateModifiedSecs']; if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; final rotationDegrees = newFields['rotationDegrees']; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index e2042d0f3..04086161c 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -46,10 +46,10 @@ class CatalogMetadata { this.contentId, this.mimeType, this.dateMillis, - this.isAnimated, - this.isFlipped, - this.isGeotiff, - this.is360, + this.isAnimated = false, + this.isFlipped = false, + this.isGeotiff = false, + this.is360 = false, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 28a879ab3..55a1053ca 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -23,7 +23,7 @@ mixin TagMixin on SourceBase { Future catalogEntries() async { // final stopwatch = Stopwatch()..start(); - final todo = rawEntries.where((entry) => !entry.isCatalogued && !entry.isSvg).toList(); + final todo = rawEntries.where((entry) => !entry.isCatalogued).toList(); if (todo.isEmpty) return; var progressDone = 0; diff --git a/lib/services/svg_metadata_service.dart b/lib/services/svg_metadata_service.dart new file mode 100644 index 000000000..8d09750bc --- /dev/null +++ b/lib/services/svg_metadata_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/utils/string_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:xml/xml.dart'; + +class SvgMetadataService { + static const docDirectory = 'Document'; + static const metadataDirectory = 'Metadata'; + + static const _attributes = ['x', 'y', 'width', 'height', 'preserveAspectRatio', 'viewBox']; + static const _textElements = ['title', 'desc']; + static const _metadataElement = 'metadata'; + + static Future getSize(ImageEntry entry) async { + try { + final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); + + final document = XmlDocument.parse(utf8.decode(data)); + final root = document.rootElement; + + String getAttribute(String attributeName) => root.attributes.firstWhere((a) => a.name.qualified == attributeName, orElse: () => null)?.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); + } + + final viewBox = getAttribute('viewBox'); + if (viewBox != null) { + final parts = viewBox.split(RegExp(r'[\s,]+')); + if (parts.length == 4) { + final vbWidth = tryParseWithoutUnit(parts[2]); + final vbHeight = tryParseWithoutUnit(parts[3]); + if (vbWidth > 0 && vbHeight > 0) { + return Size(vbWidth, vbHeight); + } + } + } + } catch (exception, stack) { + debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); + } + return null; + } + + static Future>> getAllMetadata(ImageEntry entry) async { + String formatKey(String key) { + switch (key) { + case 'desc': + return 'Description'; + default: + return key.toSentenceCase(); + } + } + + try { + final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); + + final document = XmlDocument.parse(utf8.decode(data)); + final root = document.rootElement; + + final docDir = Map.fromEntries([ + ...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(formatKey(a.name.qualified), a.value)), + ..._textElements.map((name) => MapEntry(formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null), + ]); + + final metadata = root.getElement(_metadataElement); + final metadataDir = Map.fromEntries([ + if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)), + ]); + + return { + if (docDir.isNotEmpty) docDirectory: docDir, + if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, + }; + } catch (exception, stack) { + debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); + return null; + } + } +} diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index 156bdf306..265b445f0 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -70,7 +70,7 @@ class _MagnifierState extends State { MagnifierScaleStateController _scaleStateController; void _setChildSize(Size childSize) { - _childSize = childSize; + _childSize = childSize.isEmpty ? null : childSize; } @override diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 0f0beb658..4734d7c71 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -160,9 +160,11 @@ class _ImageViewState extends State { colorFilter: colorFilter, ), ), + childSize: entry.displaySize, controller: _magnifierController, minScale: minScale, initialScale: initialScale, + scaleStateCycle: _vectorScaleStateCycle, onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); } @@ -190,6 +192,15 @@ class _ImageViewState extends State { _viewStateNotifier.value = viewState; ViewStateNotification(entry.uri, viewState).dispatch(context); } + + static ScaleState _vectorScaleStateCycle(ScaleState actual) { + switch (actual) { + case ScaleState.initial: + return ScaleState.covering; + default: + return ScaleState.initial; + } + } } class ViewState { diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index a74f09743..852ea2897 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; @@ -10,8 +11,8 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/svg_tile.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; +import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -175,7 +176,7 @@ class _MetadataSectionSliverState extends State with Auto child: InfoRowGroup( dir.tags, maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: dirName == SvgMetadata.metadataDirectory ? SvgMetadata.getLinkHandlers(dir.tags) : null, + linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null, ), ), ], @@ -192,7 +193,7 @@ class _MetadataSectionSliverState extends State with Auto if (entry == null) return; if (_loadedMetadataUri.value == entry.uri) return; if (isVisible) { - final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; + final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; final directories = rawMetadata.entries.map((dirKV) { var directoryName = dirKV.key as String ?? ''; @@ -230,6 +231,25 @@ class _MetadataSectionSliverState extends State with Auto _expandedDirectoryNotifier.value = null; } + static Map getSvgLinkHandlers(SplayTreeMap tags) { + return { + 'Metadata': InfoLinkHandler( + linkText: 'View XML', + onTap: (context) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: SourceViewerPage.routeName), + builder: (context) => SourceViewerPage( + loader: () => SynchronousFuture(tags['Metadata']), + ), + ), + ); + }, + ), + }; + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/fullscreen/info/metadata/svg_tile.dart b/lib/widgets/fullscreen/info/metadata/svg_tile.dart deleted file mode 100644 index 7f519f363..000000000 --- a/lib/widgets/fullscreen/info/metadata/svg_tile.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:aves/model/image_entry.dart'; -import 'package:aves/services/image_file_service.dart'; -import 'package:aves/utils/string_utils.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:xml/xml.dart'; - -class SvgMetadata { - static const docDirectory = 'Document'; - static const metadataDirectory = 'Metadata'; - - static const _attributes = ['x', 'y', 'width', 'height', 'preserveAspectRatio', 'viewBox']; - static const _textElements = ['title', 'desc']; - static const _metadataElement = 'metadata'; - - static Future>> getAllMetadata(ImageEntry entry) async { - try { - final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); - - final document = XmlDocument.parse(utf8.decode(data)); - final root = document.rootElement; - - final docDir = Map.fromEntries([ - ...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(_formatKey(a.name.qualified), a.value)), - ..._textElements.map((name) => MapEntry(_formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null), - ]); - - final metadata = root.getElement(_metadataElement); - final metadataDir = Map.fromEntries([ - if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)), - ]); - - return { - if (docDir.isNotEmpty) docDirectory: docDir, - if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, - }; - } catch (exception, stack) { - debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); - return null; - } - } - - static Map getLinkHandlers(SplayTreeMap tags) { - return { - 'Metadata': InfoLinkHandler( - linkText: 'View XML', - onTap: (context) { - Navigator.push( - context, - MaterialPageRoute( - settings: RouteSettings(name: SourceViewerPage.routeName), - builder: (context) => SourceViewerPage( - loader: () => SynchronousFuture(tags['Metadata']), - ), - ), - ); - }, - ), - }; - } - - static String _formatKey(String key) { - switch (key) { - case 'desc': - return 'Description'; - default: - return key.toSentenceCase(); - } - } -} diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 577c2ad43..b4d87a4cf 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -278,7 +278,7 @@ class _DateRow extends StatelessWidget { DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize), SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), - if (!entry.isSvg) Expanded(flex: 2, child: Text(entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), + Expanded(flex: 2, child: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), ], ); }