diff --git a/lib/ref/exif.dart b/lib/ref/exif.dart new file mode 100644 index 000000000..1c6b890cf --- /dev/null +++ b/lib/ref/exif.dart @@ -0,0 +1,639 @@ +class Exif { + static String getColorSpaceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'sRGB'; + case 65535: + return 'Uncalibrated'; + default: + return 'Unknown ($value)'; + } + } + + static String getContrastDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal'; + case 1: + return 'Soft'; + case 2: + return 'Hard'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getCompressionDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Uncompressed'; + case 2: + return 'CCITT 1D'; + case 3: + return 'T4/Group 3 Fax'; + case 4: + return 'T6/Group 4 Fax'; + case 5: + return 'LZW'; + case 6: + return 'JPEG (old-style)'; + case 7: + return 'JPEG'; + case 8: + return 'Adobe Deflate'; + case 9: + return 'JBIG B&W'; + case 10: + return 'JBIG Color'; + case 99: + return 'JPEG'; + case 262: + return 'Kodak 262'; + case 32766: + return 'Next'; + case 32767: + return 'Sony ARW Compressed'; + case 32769: + return 'Packed RAW'; + case 32770: + return 'Samsung SRW Compressed'; + case 32771: + return 'CCIRLEW'; + case 32772: + return 'Samsung SRW Compressed 2'; + case 32773: + return 'PackBits'; + case 32809: + return 'Thunderscan'; + case 32867: + return 'Kodak KDC Compressed'; + case 32895: + return 'IT8CTPAD'; + case 32896: + return 'IT8LW'; + case 32897: + return 'IT8MP'; + case 32898: + return 'IT8BL'; + case 32908: + return 'PixarFilm'; + case 32909: + return 'PixarLog'; + case 32946: + return 'Deflate'; + case 32947: + return 'DCS'; + case 34661: + return 'JBIG'; + case 34676: + return 'SGILog'; + case 34677: + return 'SGILog24'; + case 34712: + return 'JPEG 2000'; + case 34713: + return 'Nikon NEF Compressed'; + case 34715: + return 'JBIG2 TIFF FX'; + case 34718: + return 'Microsoft Document Imaging (MDI) Binary Level Codec'; + case 34719: + return 'Microsoft Document Imaging (MDI) Progressive Transform Codec'; + case 34720: + return 'Microsoft Document Imaging (MDI) Vector'; + case 34892: + return 'Lossy JPEG'; + case 65000: + return 'Kodak DCR Compressed'; + case 65535: + return 'Pentax PEF Compressed'; + default: + return 'Unknown ($value)'; + } + } + + static String getCustomRenderedDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal process'; + case 1: + return 'Custom process'; + default: + return 'Unknown ($value)'; + } + } + + static String getExifVersionDescription(String valueString) { + if (valueString?.length == 4) { + final major = int.tryParse(valueString.substring(0, 2)); + final minor = int.tryParse(valueString.substring(2, 4)); + if (major != null && minor != null) { + return '$major.$minor'; + } + } + return valueString; + } + + static String getExposureModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Auto exposure'; + case 1: + return 'Manual exposure'; + case 2: + return 'Auto bracket'; + default: + return 'Unknown ($value)'; + } + } + + static String getExposureProgramDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Manual'; + case 2: + return 'Normal program'; + case 3: + return 'Aperture priority'; + case 4: + return 'Shutter priority'; + case 5: + return 'Creative program'; + case 6: + return 'Action program'; + case 7: + return 'Portrait mode'; + case 8: + return 'Landscape mode'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getFileSourceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Film Scanner'; + case 2: + return 'Reflection Print Scanner'; + case 3: + return 'Digital Still Camera (DSC)'; + default: + return 'Unknown ($value)'; + } + } + + static String getLightSourceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Daylight'; + case 2: + return 'Fluorescent'; + case 3: + return 'Tungsten (Incandescent)'; + case 4: + return 'Flash'; + case 9: + return 'Fine Weather'; + case 10: + return 'Cloudy Weather'; + case 11: + return 'Shade'; + case 12: + return 'Daylight Fluorescent (D 5700 – 7100K)'; + case 13: + return 'Day White Fluorescent (N 4600 – 5400K)'; + case 14: + return 'Cool White Fluorescent (W 3900 – 4500K)'; + case 15: + return 'White Fluorescent (WW 3200 – 3700K)'; + case 16: + return 'Warm White Fluorescent (WW 2600 - 3250K)'; + case 17: + return 'Standard light A'; + case 18: + return 'Standard light B'; + case 19: + return 'Standard light C'; + case 20: + return 'D55'; + case 21: + return 'D65'; + case 22: + return 'D75'; + case 23: + return 'D50'; + case 24: + return 'ISO Studio Tungsten'; + case 255: + return 'Other'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getOrientationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Top, left side (Horizontal / normal)'; + case 2: + return 'Top, right side (Mirror horizontal)'; + case 3: + return 'Bottom, right side (Rotate 180)'; + case 4: + return 'Bottom, left side (Mirror vertical)'; + case 5: + return 'Left side, top (Mirror horizontal and rotate 270 CW)'; + case 6: + return 'Right side, top (Rotate 90 CW)'; + case 7: + return 'Right side, bottom (Mirror horizontal and rotate 90 CW)'; + case 8: + return 'Left side, bottom (Rotate 270 CW)'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getPhotometricInterpretationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'WhiteIsZero'; + case 1: + return 'BlackIsZero'; + case 2: + return 'RGB'; + case 3: + return 'RGB Palette'; + case 4: + return 'Transparency Mask'; + case 5: + return 'CMYK'; + case 6: + return 'YCbCr'; + case 8: + return 'CIELab'; + case 9: + return 'ICCLab'; + case 10: + return 'ITULab'; + case 32803: + return 'Color Filter Array'; + case 32844: + return 'Pixar LogL'; + case 32845: + return 'Pixar LogLuv'; + case 32892: + return 'Linear Raw'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getPlanarConfigurationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Chunky (contiguous for each subsampling pixel)'; + case 2: + return 'Separate (Y-plane/Cb-plane/Cr-plane format)'; + default: + return 'Unknown ($value)'; + } + } + + // adapted from `metadata-extractor` + static String getResolutionUnitDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return '(No unit)'; + case 2: + return 'Inch'; + case 3: + return 'cm'; + default: + return 'Unknown ($value)'; + } + } + + static String getGainControlDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'None'; + case 1: + return 'Low gain up'; + case 2: + return 'High gain up'; + case 3: + return 'Low gain down'; + case 4: + return 'High gain down'; + default: + return 'Unknown ($value)'; + } + } + + static String getMeteringModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Average'; + case 2: + return 'Center weighted average'; + case 3: + return 'Spot'; + case 4: + return 'Multi-spot'; + case 5: + return 'Pattern'; + case 6: + return 'Partial'; + case 255: + return 'Other'; + default: + return 'Unknown ($value)'; + } + } + + static String getSaturationDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal'; + case 1: + return 'Low saturation'; + case 2: + return 'High saturation'; + default: + return 'Unknown ($value)'; + } + } + + static String getSceneCaptureTypeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Standard'; + case 1: + return 'Landscape'; + case 2: + return 'Portrait'; + case 3: + return 'Night scene'; + default: + return 'Unknown ($value)'; + } + } + + static String getSceneTypeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Directly photographed image'; + default: + return 'Unknown ($value)'; + } + } + + static String getSensingMethodDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Not defined'; + case 2: + return 'One-chip colour area sensor'; + case 3: + return 'Two-chip colour area sensor'; + case 4: + return 'Three-chip colour area sensor'; + case 5: + return 'Colour sequential area sensor'; + case 7: + return 'Trilinear sensor'; + case 8: + return 'Colour sequential linear sensor'; + default: + return 'Unknown ($value)'; + } + } + + static String getSharpnessDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Normal'; + case 1: + return 'Soft'; + case 2: + return 'Hard'; + default: + return 'Unknown ($value)'; + } + } + + static String getSubjectDistanceRangeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Macro'; + case 2: + return 'Close view'; + case 3: + return 'Distant view'; + default: + return 'Unknown ($value)'; + } + } + + static String getWhiteBalanceDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Auto'; + case 1: + return 'Manual'; + default: + return 'Unknown ($value)'; + } + } + + static String getYCbCrPositioningDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 1: + return 'Centered'; + case 2: + return 'Co-sited'; + default: + return 'Unknown ($value)'; + } + } + + // Flash + + static String getFlashModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Unknown'; + case 1: + return 'Compulsory flash firing'; + case 2: + return 'Compulsory flash suppression'; + case 3: + return 'Auto mode'; + default: + return 'Unknown ($value)'; + } + } + + static String getFlashReturnDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'No strobe return detection'; + case 2: + return 'Strobe return light not detected'; + case 3: + return 'Strobe return light detected'; + default: + return 'Unknown ($value)'; + } + } + + // GPS + + static String getGPSAltitudeRefDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Above sea level'; + case 1: + return 'Below sea level'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSDifferentialDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Without correction'; + case 1: + return 'Correction applied'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSDirectionRefDescription(String value) { + switch (value) { + case 'T': + return 'True direction'; + case 'M': + return 'Magnetic direction'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSMeasureModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 2: + return 'Two-dimensional measurement'; + case 3: + return 'Three-dimensional measurement'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSDestDistanceRefDescription(String value) { + switch (value) { + case 'K': + return 'kilometers'; + case 'M': + return 'miles'; + case 'N': + return 'knots'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSSpeedRefDescription(String value) { + switch (value) { + case 'K': + return 'kilometers per hour'; + case 'M': + return 'miles per hour'; + case 'N': + return 'knots'; + default: + return 'Unknown ($value)'; + } + } + + static String getGPSStatusDescription(String value) { + switch (value) { + case 'A': + return 'Measurement in progress'; + case 'V': + return 'Measurement is interoperability'; + default: + return 'Unknown ($value)'; + } + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart index 9fc45fa16..cfcb92ecc 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -3,11 +3,42 @@ import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +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); + + bool get isOpenable => XMP.dataProps.contains(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(); + }); + } + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; + } +} + class XmpNamespace { final String namespace; @@ -16,34 +47,32 @@ class XmpNamespace { String get displayTitle => XMP.namespaces[namespace] ?? namespace; List buildNamespaceSection({ - @required List> props, + @required List> rawProps, @required void Function(String propPath) openEmbeddedData, }) { final linkHandlers = {}; - final entries = props - .map((prop) { - final propPath = prop.key; - final value = formatValue(prop.value); - if (extractData(propPath, value)) return null; + final props = rawProps + .map((kv) { + final prop = XmpProp(kv.key, kv.value); + if (extractData(prop)) return null; - final displayKey = _formatKey(propPath); - if (XMP.dataProps.contains(propPath)) { + if (prop.isOpenable) { linkHandlers.putIfAbsent( - displayKey, - () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(propPath)), + prop.displayKey, + () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(prop.path)), ); } - return MapEntry(displayKey, value); + return prop; }) .where((e) => e != null) .toList() - ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); + ..sort((a, b) => compareAsciiUpperCaseNatural(a.displayKey, b.displayKey)); final content = [ - if (entries.isNotEmpty) + if (props.isNotEmpty) InfoRowGroup( - Map.fromEntries(entries), + Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))), maxValueLength: Constants.infoGroupMaxValueLength, linkHandlers: linkHandlers, ), @@ -66,42 +95,33 @@ class XmpNamespace { : []; } - String _formatKey(String propPath) { - return propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { - // strip namespace - final key = s.split(XMP.propNamespaceSeparator).last; - // uppercase first letter - return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); - }); - } - - bool _extractStruct(String propPath, String value, RegExp pattern, Map store) { - final matches = pattern.allMatches(propPath); + bool extractStruct(XmpProp prop, RegExp pattern, Map store) { + final matches = pattern.allMatches(prop.path); if (matches.isEmpty) return false; final match = matches.first; - final field = _formatKey(match.group(1)); - store[field] = value; + final field = XmpProp.formatKey(match.group(1)); + store[field] = formatValue(prop); return true; } - bool _extractIndexedStruct(String propPath, String value, RegExp pattern, Map> store) { - final matches = pattern.allMatches(propPath); + bool extractIndexedStruct(XmpProp prop, RegExp pattern, Map> store) { + final matches = pattern.allMatches(prop.path); if (matches.isEmpty) return false; final match = matches.first; final index = int.parse(match.group(1)); - final field = _formatKey(match.group(2)); + final field = XmpProp.formatKey(match.group(2)); final fields = store.putIfAbsent(index, () => {}); - fields[field] = value; + fields[field] = formatValue(prop); return true; } - bool extractData(String propPath, String value) => false; + bool extractData(XmpProp prop) => false; List buildFromExtractedData() => []; - String formatValue(String value) => value; + String formatValue(XmpProp prop) => prop.value; // identity @@ -119,100 +139,3 @@ class XmpNamespace { return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; } } - -class XmpBasicNamespace extends XmpNamespace { - static const ns = 'xmp'; - - static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); - - final thumbnails = >{}; - - XmpBasicNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) => _extractIndexedStruct(propPath, value, thumbnailsPattern, thumbnails); - - @override - List buildFromExtractedData() => [ - if (thumbnails.isNotEmpty) - XmpStructArrayCard( - title: 'Thumbnail', - structByIndex: thumbnails, - ) - ]; -} - -class XmpIptcCoreNamespace extends XmpNamespace { - static const ns = 'Iptc4xmpCore'; - - static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)'); - - final creatorContactInfo = {}; - - XmpIptcCoreNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) => _extractStruct(propPath, value, creatorContactInfoPattern, creatorContactInfo); - - @override - List buildFromExtractedData() => [ - if (creatorContactInfo.isNotEmpty) - XmpStructCard( - title: 'Creator Contact Info', - struct: creatorContactInfo, - ), - ]; -} - -class XmpMMNamespace extends XmpNamespace { - static const ns = 'xmpMM'; - - static const didPrefix = 'xmp.did:'; - static const iidPrefix = 'xmp.iid:'; - - static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); - static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); - - final derivedFrom = {}; - final history = >{}; - - XmpMMNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) => _extractStruct(propPath, value, derivedFromPattern, derivedFrom) || _extractIndexedStruct(propPath, value, historyPattern, history); - - @override - List buildFromExtractedData() => [ - if (derivedFrom.isNotEmpty) - XmpStructCard( - title: 'Derived From', - struct: derivedFrom, - ), - if (history.isNotEmpty) - XmpStructArrayCard( - title: 'History', - structByIndex: history, - ), - ]; - - @override - String formatValue(String value) { - if (value.startsWith(didPrefix)) return value.replaceFirst(didPrefix, ''); - if (value.startsWith(iidPrefix)) return value.replaceFirst(iidPrefix, ''); - return value; - } -} - -class XmpNoteNamespace extends XmpNamespace { - static const ns = 'xmpNote'; - - // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users - static const hasExtendedXmp = '$ns:HasExtendedXMP'; - - XmpNoteNamespace() : super(ns); - - @override - bool extractData(String propPath, String value) { - return propPath == hasExtendedXmp; - } -} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart new file mode 100644 index 000000000..a4c17f069 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart @@ -0,0 +1,75 @@ +import 'package:aves/ref/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; + +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/exif.md +class XmpExifNamespace extends XmpNamespace { + static const ns = 'exif'; + + XmpExifNamespace() : super(ns); + + @override + String formatValue(XmpProp prop) { + final v = prop.value; + switch (prop.path) { + case 'exif:ColorSpace': + return Exif.getColorSpaceDescription(v); + case 'exif:Contrast': + return Exif.getContrastDescription(v); + case 'exif:CustomRendered': + return Exif.getCustomRenderedDescription(v); + case 'exif:ExifVersion': + case 'exif:FlashpixVersion': + return Exif.getExifVersionDescription(v); + case 'exif:ExposureMode': + return Exif.getExposureModeDescription(v); + case 'exif:ExposureProgram': + return Exif.getExposureProgramDescription(v); + case 'exif:FileSource': + return Exif.getFileSourceDescription(v); + case 'exif:Flash/exif:Mode': + return Exif.getFlashModeDescription(v); + case 'exif:Flash/exif:Return': + return Exif.getFlashReturnDescription(v); + case 'exif:FocalPlaneResolutionUnit': + return Exif.getResolutionUnitDescription(v); + case 'exif:GainControl': + return Exif.getGainControlDescription(v); + case 'exif:LightSource': + return Exif.getLightSourceDescription(v); + case 'exif:MeteringMode': + return Exif.getMeteringModeDescription(v); + case 'exif:Saturation': + return Exif.getSaturationDescription(v); + case 'exif:SceneCaptureType': + return Exif.getSceneCaptureTypeDescription(v); + case 'exif:SceneType': + return Exif.getSceneTypeDescription(v); + case 'exif:SensingMethod': + return Exif.getSensingMethodDescription(v); + case 'exif:Sharpness': + return Exif.getSharpnessDescription(v); + case 'exif:SubjectDistanceRange': + return Exif.getSubjectDistanceRangeDescription(v); + case 'exif:WhiteBalance': + return Exif.getWhiteBalanceDescription(v); + case 'exif:GPSAltitudeRef': + return Exif.getGPSAltitudeRefDescription(v); + case 'exif:GPSDestBearingRef': + case 'exif:GPSImgDirectionRef': + case 'exif:GPSTrackRef': + return Exif.getGPSDirectionRefDescription(v); + case 'exif:GPSDestDistanceRef': + return Exif.getGPSDestDistanceRefDescription(v); + case 'exif:GPSDifferential': + return Exif.getGPSDifferentialDescription(v); + case 'exif:GPSMeasureMode': + return Exif.getGPSMeasureModeDescription(v); + case 'exif:GPSSpeedRef': + return Exif.getGPSSpeedRefDescription(v); + case 'exif:GPSStatus': + return Exif.getGPSStatusDescription(v); + default: + return v; + } + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart new file mode 100644 index 000000000..a6654e3e5 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart @@ -0,0 +1,25 @@ +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:flutter/material.dart'; + +class XmpIptcCoreNamespace extends XmpNamespace { + static const ns = 'Iptc4xmpCore'; + + static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)'); + + final creatorContactInfo = {}; + + XmpIptcCoreNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo); + + @override + List buildFromExtractedData() => [ + if (creatorContactInfo.isNotEmpty) + XmpStructCard( + title: 'Creator Contact Info', + struct: creatorContactInfo, + ), + ]; +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart new file mode 100644 index 000000000..d659bd627 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart @@ -0,0 +1,44 @@ +// cf photoshop:ColorMode +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; + +class XmpPhotoshopNamespace extends XmpNamespace { + static const ns = 'photoshop'; + + XmpPhotoshopNamespace() : super(ns); + + @override + String formatValue(XmpProp prop) { + final value = prop.value; + switch (prop.path) { + case 'photoshop:ColorMode': + return getColorModeDescription(value); + } + return value; + } + + static String getColorModeDescription(String valueString) { + final value = int.tryParse(valueString); + if (value == null) return valueString; + switch (value) { + case 0: + return 'Bitmap'; + case 1: + return 'Gray scale'; + case 2: + return 'Indexed colour'; + case 3: + return 'RGB colour'; + case 4: + return 'CMYK colour'; + case 7: + return 'Multi-channel'; + case 8: + return 'Duotone'; + case 9: + return 'LAB colour'; + default: + return 'Unknown ($value)'; + } + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart new file mode 100644 index 000000000..38e6f0937 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart @@ -0,0 +1,29 @@ +import 'package:aves/ref/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; + +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/tiff.md +class XmpTiffNamespace extends XmpNamespace { + static const ns = 'tiff'; + + XmpTiffNamespace() : super(ns); + + @override + String formatValue(XmpProp prop) { + final value = prop.value; + switch (prop.path) { + case 'tiff:Compression': + return Exif.getCompressionDescription(value); + case 'tiff:Orientation': + return Exif.getOrientationDescription(value); + case 'tiff:PhotometricInterpretation': + return Exif.getPhotometricInterpretationDescription(value); + case 'tiff:PlanarConfiguration': + return Exif.getPlanarConfigurationDescription(value); + case 'tiff:ResolutionUnit': + return Exif.getResolutionUnitDescription(value); + case 'tiff:YCbCrPositioning': + return Exif.getYCbCrPositioningDescription(value); + } + return value; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart new file mode 100644 index 000000000..36376e1ae --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart @@ -0,0 +1,79 @@ +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:flutter/material.dart'; + +class XmpBasicNamespace extends XmpNamespace { + static const ns = 'xmp'; + + static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); + + final thumbnails = >{}; + + XmpBasicNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails); + + @override + List buildFromExtractedData() => [ + if (thumbnails.isNotEmpty) + XmpStructArrayCard( + title: 'Thumbnail', + structByIndex: thumbnails, + ) + ]; +} + +class XmpMMNamespace extends XmpNamespace { + static const ns = 'xmpMM'; + + static const didPrefix = 'xmp.did:'; + static const iidPrefix = 'xmp.iid:'; + + static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); + static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); + + final derivedFrom = {}; + final history = >{}; + + XmpMMNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) => extractStruct(prop, derivedFromPattern, derivedFrom) || extractIndexedStruct(prop, historyPattern, history); + + @override + List buildFromExtractedData() => [ + if (derivedFrom.isNotEmpty) + XmpStructCard( + title: 'Derived From', + struct: derivedFrom, + ), + if (history.isNotEmpty) + XmpStructArrayCard( + title: 'History', + structByIndex: history, + ), + ]; + + @override + String formatValue(XmpProp prop) { + final value = prop.value; + if (value.startsWith(didPrefix)) return value.replaceFirst(didPrefix, ''); + if (value.startsWith(iidPrefix)) return value.replaceFirst(iidPrefix, ''); + return value; + } +} + +class XmpNoteNamespace extends XmpNamespace { + static const ns = 'xmpNote'; + + // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users + static const hasExtendedXmp = '$ns:HasExtendedXMP'; + + XmpNoteNamespace() : super(ns); + + @override + bool extractData(XmpProp prop) { + return prop.path == hasExtendedXmp; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index c557eadf7..80cef89e9 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -12,6 +12,11 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; @@ -45,12 +50,18 @@ class _XmpDirTileState extends State with FeedbackMixin { switch (namespace) { case XmpBasicNamespace.ns: return XmpBasicNamespace(); + case XmpExifNamespace.ns: + return XmpExifNamespace(); case XmpIptcCoreNamespace.ns: return XmpIptcCoreNamespace(); case XmpMMNamespace.ns: return XmpMMNamespace(); case XmpNoteNamespace.ns: return XmpNoteNamespace(); + case XmpPhotoshopNamespace.ns: + return XmpPhotoshopNamespace(); + case XmpTiffNamespace.ns: + return XmpTiffNamespace(); default: return XmpNamespace(namespace); } @@ -68,7 +79,7 @@ class _XmpDirTileState extends State with FeedbackMixin { crossAxisAlignment: CrossAxisAlignment.start, children: sections.entries .expand((kv) => kv.key.buildNamespaceSection( - props: kv.value, + rawProps: kv.value, openEmbeddedData: _openEmbeddedData, )) .toList(),