Info: show metadata from SVG

This commit is contained in:
Thibault Deckers 2020-12-09 15:49:22 +09:00
parent d40f32b11b
commit 6d9b6b4484
10 changed files with 128 additions and 31 deletions

View file

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

View file

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

View file

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

View file

@ -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<MetadataSectionSliver> with Auto
expandedNotifier: _expandedDirectoryNotifier,
);
}
Widget thumbnail;
final prefixChildren = <Widget>[];
switch (dirName) {
@ -168,7 +170,11 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> 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<MetadataSectionSliver> 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 ?? '';

View file

@ -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<Map<String, Map<String, String>>> getAllMetadata(ImageEntry entry) async {
try {
final result = <String, String>{};
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<String, InfoLinkHandler> getLinkHandlers(SplayTreeMap<String, String> 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();
}
}
}

View file

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

View file

@ -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<String> Function() loader;
const SourceViewerPage({
@required this.entry,
@required this.loader,
});
@override
@ -22,12 +18,10 @@ class SourceViewerPage extends StatefulWidget {
class _SourceViewerPageState extends State<SourceViewerPage> {
Future<String> _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<SourceViewerPage> {
child: FutureBuilder<String>(
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(

View file

@ -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"

View file

@ -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 <style> support: https://github.com/dnfield/flutter_svg/issues/105
# - `The <style> element is not implemented in this library.`: https://github.com/dnfield/flutter_svg/issues/105
# - inconsistent % unit support: https://github.com/dnfield/flutter_svg/issues/110
# video_player (as of v0.10.8+2, backed by ExoPlayer):
@ -87,6 +87,7 @@ dependencies:
streams_channel:
tuple:
url_launcher:
xml:
dev_dependencies:
flutter_test:

View file

@ -0,0 +1,17 @@
import 'package:test/test.dart';
import 'package:aves/utils/string_utils.dart';
void main() {
test('Sentence case', () {
expect('XResolution'.toSentenceCase(), 'X Resolution');
expect('PixelXDimension'.toSentenceCase(), 'Pixel X Dimension');
expect('FocalPointX'.toSentenceCase(), 'Focal Point X');
expect('ISOSpeedRatings[1]'.toSentenceCase(), 'ISO Speed Ratings [1]');
expect('LegacyIPTCDigest'.toSentenceCase(), 'Legacy IPTC Digest');
expect('DocumentID'.toSentenceCase(), 'Document ID');
expect('H'.toSentenceCase(), 'H');
expect('LW[1]'.toSentenceCase(), 'LW [1]');
});
}