diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 6304c3ec1..a86fbfee5 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -293,6 +293,12 @@ class Constants { licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher', ), + Dependency( + name: 'XML', + license: 'MIT', + licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE', + sourceUrl: 'https://github.com/renggli/dart-xml', + ), ]; } diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart new file mode 100644 index 000000000..d1d747aad --- /dev/null +++ b/lib/utils/string_utils.dart @@ -0,0 +1,9 @@ +extension ExtraString on String { + static final _sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); + static final _sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); + + String toSentenceCase() { + var s = replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); + return s.replaceAllMapped(_sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(_sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); + } +} diff --git a/lib/widgets/fullscreen/entry_action_delegate.dart b/lib/widgets/fullscreen/entry_action_delegate.dart index cc7dbc506..0ee1078ed 100644 --- a/lib/widgets/fullscreen/entry_action_delegate.dart +++ b/lib/widgets/fullscreen/entry_action_delegate.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/image_entry.dart'; @@ -202,7 +204,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { context, MaterialPageRoute( settings: RouteSettings(name: SourceViewerPage.routeName), - builder: (context) => SourceViewerPage(entry: entry), + builder: (context) => SourceViewerPage( + loader: () => ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode), + ), ), ); } diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index ef9121ccd..d37f9a6a1 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -10,6 +10,7 @@ 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:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -135,6 +136,7 @@ class _MetadataSectionSliverState extends State with Auto expandedNotifier: _expandedDirectoryNotifier, ); } + Widget thumbnail; final prefixChildren = []; switch (dirName) { @@ -168,7 +170,11 @@ class _MetadataSectionSliverState extends State with Auto if (thumbnail != null) thumbnail, Padding( padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength), + child: InfoRowGroup( + dir.tags, + maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: dirName == SvgMetadata.directory ? SvgMetadata.getLinkHandlers(dir.tags) : null, + ), ), ], ); @@ -184,7 +190,7 @@ class _MetadataSectionSliverState extends State with Auto if (entry == null) return; if (_loadedMetadataUri.value == entry.uri) return; if (isVisible) { - final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {}; + final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; final directories = rawMetadata.entries.map((dirKV) { var directoryName = dirKV.key as String ?? ''; diff --git a/lib/widgets/fullscreen/info/metadata/svg_tile.dart b/lib/widgets/fullscreen/info/metadata/svg_tile.dart new file mode 100644 index 000000000..eba81f2b6 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/svg_tile.dart @@ -0,0 +1,72 @@ +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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:xml/xml.dart'; + +class SvgMetadata { + static const directory = 'SVG'; + + 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 result = {}; + final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); + + final document = XmlDocument.parse(utf8.decode(data)); + final root = document.rootElement; + + final metadata = root.getElement(_metadataElement); + result.addEntries([ + ...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), + if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)), + ]); + + return { + directory: result, + }; + } 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/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart index de15d6b32..0d9a5c23d 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -1,6 +1,7 @@ import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/utils/string_utils.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:collection/collection.dart'; @@ -103,23 +104,14 @@ class XmpProp { final String path, value; final String displayKey; - static final sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); - static final sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); - XmpProp(this.path, this.value) : displayKey = formatKey(path); static String formatKey(String propPath) { return propPath.splitMapJoin(XMP.structFieldSeparator, onMatch: (match) => ' ${match.group(0)} ', onNonMatch: (s) { - // strip namespace - var key = s.split(XMP.propNamespaceSeparator).last; - - // uppercase first letter - key = key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); - - // sentence case - return key.replaceAllMapped(sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); + // strip namespace & format + return s.split(XMP.propNamespaceSeparator).last.toSentenceCase(); }); } diff --git a/lib/widgets/fullscreen/source_viewer_page.dart b/lib/widgets/fullscreen/source_viewer_page.dart index d6a5a0557..08d91b8a1 100644 --- a/lib/widgets/fullscreen/source_viewer_page.dart +++ b/lib/widgets/fullscreen/source_viewer_page.dart @@ -1,7 +1,3 @@ -import 'dart:convert'; - -import 'package:aves/model/image_entry.dart'; -import 'package:aves/services/image_file_service.dart'; import 'package:aves/widgets/common/aves_highlight.dart'; import 'package:flutter/material.dart'; import 'package:flutter_highlight/themes/darcula.dart'; @@ -9,10 +5,10 @@ import 'package:flutter_highlight/themes/darcula.dart'; class SourceViewerPage extends StatefulWidget { static const routeName = '/fullscreen/source'; - final ImageEntry entry; + final Future Function() loader; const SourceViewerPage({ - @required this.entry, + @required this.loader, }); @override @@ -22,12 +18,10 @@ class SourceViewerPage extends StatefulWidget { class _SourceViewerPageState extends State { Future _loader; - ImageEntry get entry => widget.entry; - @override void initState() { super.initState(); - _loader = ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode); + _loader = widget.loader(); } @override @@ -40,12 +34,8 @@ class _SourceViewerPageState extends State { child: FutureBuilder( future: _loader, builder: (context, snapshot) { - if (snapshot.hasError) { - return Text(snapshot.error.toString()); - } - if (snapshot.connectionState != ConnectionState.done) { - return SizedBox.shrink(); - } + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (!snapshot.hasData) return SizedBox.shrink(); final source = snapshot.data; final highlightView = AvesHighlightView( diff --git a/pubspec.lock b/pubspec.lock index 3857dc12b..c9ce41578 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1146,7 +1146,7 @@ packages: source: hosted version: "0.1.2" xml: - dependency: transitive + dependency: "direct main" description: name: xml url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 088af199f..05fc25cbf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ version: 1.2.8+34 # dnfield/flutter_svg (as of v0.19.1): # - `Could not parse "currentColor" as a color`: https://github.com/dnfield/flutter_svg/issues/31 -# - no