Info: show metadata from SVG
This commit is contained in:
parent
d40f32b11b
commit
6d9b6b4484
10 changed files with 128 additions and 31 deletions
|
@ -293,6 +293,12 @@ class Constants {
|
||||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
|
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',
|
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',
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
9
lib/utils/string_utils.dart
Normal file
9
lib/utils/string_utils.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||||
import 'package:aves/model/actions/entry_actions.dart';
|
import 'package:aves/model/actions/entry_actions.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
|
@ -202,7 +204,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: RouteSettings(name: SourceViewerPage.routeName),
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/common.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.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/info/metadata/xmp_tile.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -135,6 +136,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
expandedNotifier: _expandedDirectoryNotifier,
|
expandedNotifier: _expandedDirectoryNotifier,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget thumbnail;
|
Widget thumbnail;
|
||||||
final prefixChildren = <Widget>[];
|
final prefixChildren = <Widget>[];
|
||||||
switch (dirName) {
|
switch (dirName) {
|
||||||
|
@ -168,7 +170,11 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
if (thumbnail != null) thumbnail,
|
if (thumbnail != null) thumbnail,
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
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 (entry == null) return;
|
||||||
if (_loadedMetadataUri.value == entry.uri) return;
|
if (_loadedMetadataUri.value == entry.uri) return;
|
||||||
if (isVisible) {
|
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) {
|
final directories = rawMetadata.entries.map((dirKV) {
|
||||||
var directoryName = dirKV.key as String ?? '';
|
var directoryName = dirKV.key as String ?? '';
|
||||||
|
|
||||||
|
|
72
lib/widgets/fullscreen/info/metadata/svg_tile.dart
Normal file
72
lib/widgets/fullscreen/info/metadata/svg_tile.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/ref/brand_colors.dart';
|
import 'package:aves/ref/brand_colors.dart';
|
||||||
import 'package:aves/ref/xmp.dart';
|
import 'package:aves/ref/xmp.dart';
|
||||||
import 'package:aves/utils/constants.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/common/identity/highlight_title.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -103,23 +104,14 @@ class XmpProp {
|
||||||
final String path, value;
|
final String path, value;
|
||||||
final String displayKey;
|
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);
|
XmpProp(this.path, this.value) : displayKey = formatKey(path);
|
||||||
|
|
||||||
static String formatKey(String propPath) {
|
static String formatKey(String propPath) {
|
||||||
return propPath.splitMapJoin(XMP.structFieldSeparator,
|
return propPath.splitMapJoin(XMP.structFieldSeparator,
|
||||||
onMatch: (match) => ' ${match.group(0)} ',
|
onMatch: (match) => ' ${match.group(0)} ',
|
||||||
onNonMatch: (s) {
|
onNonMatch: (s) {
|
||||||
// strip namespace
|
// strip namespace & format
|
||||||
var key = s.split(XMP.propNamespaceSeparator).last;
|
return s.split(XMP.propNamespaceSeparator).last.toSentenceCase();
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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:aves/widgets/common/aves_highlight.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_highlight/themes/darcula.dart';
|
import 'package:flutter_highlight/themes/darcula.dart';
|
||||||
|
@ -9,10 +5,10 @@ import 'package:flutter_highlight/themes/darcula.dart';
|
||||||
class SourceViewerPage extends StatefulWidget {
|
class SourceViewerPage extends StatefulWidget {
|
||||||
static const routeName = '/fullscreen/source';
|
static const routeName = '/fullscreen/source';
|
||||||
|
|
||||||
final ImageEntry entry;
|
final Future<String> Function() loader;
|
||||||
|
|
||||||
const SourceViewerPage({
|
const SourceViewerPage({
|
||||||
@required this.entry,
|
@required this.loader,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -22,12 +18,10 @@ class SourceViewerPage extends StatefulWidget {
|
||||||
class _SourceViewerPageState extends State<SourceViewerPage> {
|
class _SourceViewerPageState extends State<SourceViewerPage> {
|
||||||
Future<String> _loader;
|
Future<String> _loader;
|
||||||
|
|
||||||
ImageEntry get entry => widget.entry;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loader = ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode);
|
_loader = widget.loader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -40,12 +34,8 @@ class _SourceViewerPageState extends State<SourceViewerPage> {
|
||||||
child: FutureBuilder<String>(
|
child: FutureBuilder<String>(
|
||||||
future: _loader,
|
future: _loader,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||||
return Text(snapshot.error.toString());
|
if (!snapshot.hasData) return SizedBox.shrink();
|
||||||
}
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
|
||||||
return SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final source = snapshot.data;
|
final source = snapshot.data;
|
||||||
final highlightView = AvesHighlightView(
|
final highlightView = AvesHighlightView(
|
||||||
|
|
|
@ -1146,7 +1146,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.2"
|
version: "0.1.2"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
|
|
|
@ -11,7 +11,7 @@ version: 1.2.8+34
|
||||||
|
|
||||||
# dnfield/flutter_svg (as of v0.19.1):
|
# dnfield/flutter_svg (as of v0.19.1):
|
||||||
# - `Could not parse "currentColor" as a color`: https://github.com/dnfield/flutter_svg/issues/31
|
# - `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
|
# - inconsistent % unit support: https://github.com/dnfield/flutter_svg/issues/110
|
||||||
|
|
||||||
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
||||||
|
@ -87,6 +87,7 @@ dependencies:
|
||||||
streams_channel:
|
streams_channel:
|
||||||
tuple:
|
tuple:
|
||||||
url_launcher:
|
url_launcher:
|
||||||
|
xml:
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
17
test/utils/string_utils_test.dart
Normal file
17
test/utils/string_utils_test.dart
Normal 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]');
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue