diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index 15511c3bf..83a2d1ac4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -14,8 +14,8 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.MetadataExtractorHelper import deckers.thibault.aves.metadata.MultiPage -import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.XMP.getSafeStructField +import deckers.thibault.aves.metadata.XMPPropName import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.provider.ContentImageProvider import deckers.thibault.aves.model.provider.ImageProvider @@ -140,13 +140,21 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { val uri = call.argument("uri")?.let { Uri.parse(it) } val sizeBytes = call.argument("sizeBytes")?.toLong() val displayName = call.argument("displayName") - val dataPropPath = call.argument("propPath") + val dataProp = call.argument>("propPath") val embedMimeType = call.argument("propMimeType") - if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) { + if (mimeType == null || uri == null || dataProp == null || embedMimeType == null) { result.error("extractXmpDataProp-args", "missing arguments", null) return } + val props = dataProp.mapNotNull { + when (it) { + is List<*> -> XMPPropName(it.first() as String, it.last() as String) + is Int -> it + else -> null + } + } + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> @@ -155,11 +163,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { // which is returned as a second XMP directory val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) try { - val embedBytes: ByteArray = if (!dataPropPath.contains('/')) { - val propNs = XMP.namespaceForPropPath(dataPropPath) - xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.first() + val embedBytes: ByteArray = if (props.size == 1) { + val prop = props.first() as XMPPropName + xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(prop.nsUri, prop.toString()) }.first() } else { - xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(dataPropPath) }.first().let { + xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(props) }.first().let { XMPUtils.decodeBase64(it.value) } } @@ -167,7 +175,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream()) return } catch (e: XMPException) { - result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message) + result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataProp", e.message) return } } @@ -179,7 +187,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { Log.w(LOG_TAG, "failed to extract file from XMP", e) } } - result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null) + result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataProp", null) } private fun copyEmbeddedBytes(result: MethodChannel.Result, mimeType: String, displayName: String?, embeddedByteStream: InputStream) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 2280ddd9c..9c0d85986 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -59,6 +59,8 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir +import deckers.thibault.aves.metadata.XMP.doesPropExist +import deckers.thibault.aves.metadata.XMP.getPropArrayItemValues import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText @@ -83,6 +85,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import org.json.JSONObject import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.text.DecimalFormat @@ -280,6 +283,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } // remove this stat as it is not actual XMP data dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) + // add schema prefixes for namespace resolution + val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes + dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString() } if (dir is Mp4UuidBoxDirectory) { @@ -509,22 +515,21 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { val xmpMeta = dir.xmpMeta try { - if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)) { - val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME) - val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME, it).value } + if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) { + val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME) metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR) } - xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE] = it } + xmpMeta.getSafeLocalizedText(XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } + xmpMeta.getSafeDateMillis(XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } + xmpMeta.getSafeDateMillis(XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } } } - xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it } + xmpMeta.getSafeInt(XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it } if (!metadataMap.containsKey(KEY_RATING)) { - xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating -> + xmpMeta.getSafeInt(XMP.MS_RATING_PROP_NAME) { percentRating -> // values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars val standardRating = (percentRating / 25f).roundToInt() + 1 metadataMap[KEY_RATING] = standardRating @@ -834,13 +839,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { ) for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { val xmpMeta = dir.xmpMeta - xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it } - xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it } - xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it } - xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it } - xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it } - xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it } - xmpMeta.getSafeString(XMP.GPANO_SCHEMA_NS, XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it } + xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it } + xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it } + xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it } + xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it } + xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it } + xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it } + xmpMeta.getSafeString(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it } } result.success(fields) return @@ -1071,8 +1076,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { val xmpMeta = dir.xmpMeta try { - if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME)) { - xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME) { description = it } + if (xmpMeta.doesPropExist(XMP.DC_DESCRIPTION_PROP_NAME)) { + xmpMeta.getSafeLocalizedText(XMP.DC_DESCRIPTION_PROP_NAME) { description = it } } } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index 282353d81..f11011a43 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -9,6 +9,8 @@ import android.os.Build import android.os.ParcelFileDescriptor import android.util.Log import com.drew.metadata.xmp.XmpDirectory +import deckers.thibault.aves.metadata.XMP.countPropArrayItems +import deckers.thibault.aves.metadata.XMP.doesPropExist import deckers.thibault.aves.metadata.XMP.getSafeLong import deckers.thibault.aves.metadata.XMP.getSafeStructField import deckers.thibault.aves.model.FieldMap @@ -146,17 +148,17 @@ object MultiPage { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { var offsetFromEnd: Long? = null val xmpMeta = dir.xmpMeta - if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) { + if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) { // GCamera motion photo - xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } - } else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) { + xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } + } else if (xmpMeta.doesPropExist(XMP.CONTAINER_DIRECTORY_PROP_NAME)) { // Container motion photo - val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME) + val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME) if (count == 2) { // expect the video to be the second item val i = 2 - val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value - val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value + val mime = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_MIME_PROP_NAME))?.value + val length = xmpMeta.getSafeStructField(listOf(XMP.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_PROP_NAME))?.value if (MimeTypes.isVideo(mime) && length != null) { offsetFromEnd = length.toLong() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 8bdaf33d1..5dc46b657 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -4,6 +4,7 @@ import android.util.Log import com.adobe.internal.xmp.XMPError import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPMeta +import com.adobe.internal.xmp.XMPMetaFactory import com.adobe.internal.xmp.properties.XMPProperty import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes @@ -14,74 +15,65 @@ object XMP { // standard namespaces // cf com.adobe.internal.xmp.XMPConst - const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" - const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/" - const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" - const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" - private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" + private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/" + private const val MICROSOFTPHOTO_NS_URI = "http://ns.microsoft.com/photo/1.0/" + private const val PHOTOSHOP_NS_URI = "http://ns.adobe.com/photoshop/1.0/" + private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/" // other namespaces - private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" - const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/" - private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" - private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" - const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/" - private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/" + private const val CONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/" + private const val CONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/" + private const val GAUDIO_NS_URI = "http://ns.google.com/photos/1.0/audio/" + private const val GCAMERA_NS_URI = "http://ns.google.com/photos/1.0/camera/" + private const val GDEPTH_NS_URI = "http://ns.google.com/photos/1.0/depthmap/" + private const val GIMAGE_NS_URI = "http://ns.google.com/photos/1.0/image/" + private const val GPANO_NS_URI = "http://ns.google.com/photos/1.0/panorama/" + private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01" - const val DC_SUBJECT_PROP_NAME = "dc:subject" - const val DC_DESCRIPTION_PROP_NAME = "dc:description" - const val DC_TITLE_PROP_NAME = "dc:title" - const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating" - const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated" - const val XMP_CREATE_DATE_PROP_NAME = "xmp:CreateDate" - const val XMP_RATING_PROP_NAME = "xmp:Rating" + val DC_SUBJECT_PROP_NAME = XMPPropName(DC_NS_URI, "subject") + val DC_DESCRIPTION_PROP_NAME = XMPPropName(DC_NS_URI, "description") + val DC_TITLE_PROP_NAME = XMPPropName(DC_NS_URI, "title") + val MS_RATING_PROP_NAME = XMPPropName(MICROSOFTPHOTO_NS_URI, "Rating") + val PS_DATE_CREATED_PROP_NAME = XMPPropName(PHOTOSHOP_NS_URI, "DateCreated") + val XMP_CREATE_DATE_PROP_NAME = XMPPropName(XMP_NS_URI, "CreateDate") + val XMP_RATING_PROP_NAME = XMPPropName(XMP_NS_URI, "Rating") private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" - private val schemas = hashMapOf( - "Container" to CONTAINER_SCHEMA_NS, - "GAudio" to GAUDIO_SCHEMA_NS, - "GDepth" to GDEPTH_SCHEMA_NS, - "GImage" to GIMAGE_SCHEMA_NS, - "Item" to CONTAINER_ITEM_SCHEMA_NS, - "xmp" to XMP_SCHEMA_NS, - "xmpGImg" to XMP_GIMG_SCHEMA_NS, - ) - - fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]] - // embedded media data properties // cf https://developers.google.com/depthmap-metadata // cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format - private val knownDataPaths = listOf("GAudio:Data", "GImage:Data", "GDepth:Data", "GDepth:Confidence") + private val knownDataProps = listOf( + XMPPropName(GAUDIO_NS_URI, "Data"), + XMPPropName(GIMAGE_NS_URI, "Data"), + XMPPropName(GDEPTH_NS_URI, "Data"), + XMPPropName(GDEPTH_NS_URI, "Confidence"), + ) - fun isDataPath(path: String) = knownDataPaths.contains(path) + fun isDataPath(path: String) = knownDataProps.map { it.toString() }.any { path == it } // motion photo - const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset" - const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory" - const val CONTAINER_ITEM_PROP_NAME = "Container:Item" - const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length" - const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime" + val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset") + val CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Directory") + val CONTAINER_ITEM_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Item") + val CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Length") + val CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Mime") // panorama // cf https://developers.google.com/streetview/spherical-metadata - const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/" - private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01" - - const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels" - const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels" - const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels" - const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels" - const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels" - const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels" - const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType" + val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageHeightPixels") + val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageWidthPixels") + val GPANO_CROPPED_AREA_LEFT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaLeftPixels") + val GPANO_CROPPED_AREA_TOP_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaTopPixels") + val GPANO_FULL_PANO_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoHeightPixels") + val GPANO_FULL_PANO_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoWidthPixels") + val GPANO_PROJECTION_TYPE_PROP_NAME = XMPPropName(GPANO_NS_URI, "ProjectionType") const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular" - private const val PMTM_IS_PANO360 = "pmtm:IsPano360" + private val PMTM_IS_PANO360_PROP_NAME = XMPPropName(PMTM_NS_URI, "IsPano360") // `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default // `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode) @@ -98,17 +90,17 @@ object XMP { fun XMPMeta.isMotionPhoto(): Boolean { try { // GCamera motion photo - if (doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true + if (doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true // Container motion photo - if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) { - val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME) + if (doesPropExist(CONTAINER_DIRECTORY_PROP_NAME)) { + val count = countPropArrayItems(CONTAINER_DIRECTORY_PROP_NAME) if (count == 2) { var hasImage = false var hasVideo = false for (i in 1 until count + 1) { - val mime = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_MIME_PROP_NAME")?.value - val length = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_LENGTH_PROP_NAME")?.value + val mime = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_MIME_PROP_NAME))?.value + val length = getSafeStructField(listOf(CONTAINER_DIRECTORY_PROP_NAME, i, CONTAINER_ITEM_PROP_NAME, CONTAINER_ITEM_LENGTH_PROP_NAME))?.value hasImage = hasImage || MimeTypes.isImage(mime) && length != null hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null } @@ -130,7 +122,7 @@ object XMP { fun XMPMeta.isPanorama(): Boolean { // Google try { - if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true + if (gpanoRequiredProps.all { doesPropExist(it) }) return true } catch (e: XMPException) { if (e.errorCode != XMPError.BADSCHEMA) { // `BADSCHEMA` code is reported when we check a property @@ -141,7 +133,7 @@ object XMP { // Photomatix try { - if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true + if (getPropertyString(PMTM_IS_PANO360_PROP_NAME.nsUri, PMTM_IS_PANO360_PROP_NAME.toString()) == "Yes") return true } catch (e: XMPException) { if (e.errorCode != XMPError.BADSCHEMA) { // `BADSCHEMA` code is reported when we check a property @@ -153,7 +145,24 @@ object XMP { return false } - fun XMPMeta.getSafeInt(schema: String, propName: String, save: (value: Int) -> Unit) { + fun XMPMeta.doesPropExist(prop: XMPPropName): Boolean { + return doesPropertyExist(prop.nsUri, prop.toString()) + } + + fun XMPMeta.countPropArrayItems(prop: XMPPropName): Int { + return countArrayItems(prop.nsUri, prop.toString()) + } + + fun XMPMeta.getPropArrayItemValues(prop: XMPPropName): List { + val schema = prop.nsUri + val propName = prop.toString() + val count = countArrayItems(schema, propName) + return (1 until count + 1).map { getArrayItem(schema, propName, it).value } + } + + fun XMPMeta.getSafeInt(prop: XMPPropName, save: (value: Int) -> Unit) { + val schema = prop.nsUri + val propName = prop.toString() try { if (doesPropertyExist(schema, propName)) { val item = getPropertyInteger(schema, propName) @@ -167,7 +176,9 @@ object XMP { } } - fun XMPMeta.getSafeLong(schema: String, propName: String, save: (value: Long) -> Unit) { + fun XMPMeta.getSafeLong(prop: XMPPropName, save: (value: Long) -> Unit) { + val schema = prop.nsUri + val propName = prop.toString() try { if (doesPropertyExist(schema, propName)) { val item = getPropertyLong(schema, propName) @@ -181,7 +192,9 @@ object XMP { } } - fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) { + fun XMPMeta.getSafeString(prop: XMPPropName, save: (value: String) -> Unit) { + val schema = prop.nsUri + val propName = prop.toString() try { if (doesPropertyExist(schema, propName)) { val item = getPropertyString(schema, propName) @@ -195,7 +208,9 @@ object XMP { } } - fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, acceptBlank: Boolean = true, save: (value: String) -> Unit) { + fun XMPMeta.getSafeLocalizedText(prop: XMPPropName, acceptBlank: Boolean = true, save: (value: String) -> Unit) { + val schema = prop.nsUri + val propName = prop.toString() try { if (doesPropertyExist(schema, propName)) { val item = getLocalizedText(schema, propName, GENERIC_LANG, SPECIFIC_LANG) @@ -209,7 +224,9 @@ object XMP { } } - fun XMPMeta.getSafeDateMillis(schema: String, propName: String, save: (value: Long) -> Unit) { + fun XMPMeta.getSafeDateMillis(prop: XMPPropName, save: (value: Long) -> Unit) { + val schema = prop.nsUri + val propName = prop.toString() try { if (doesPropertyExist(schema, propName)) { val item = getPropertyDate(schema, propName) @@ -226,20 +243,38 @@ object XMP { } } - // e.g. 'Container:Directory[42]/Container:Item/Item:Mime' - fun XMPMeta.getSafeStructField(path: String): XMPProperty? { - val separator = path.lastIndexOf("/") - if (separator != -1) { - val structName = path.substring(0, separator) - val structNs = namespaceForPropPath(structName) - val fieldName = path.substring(separator + 1) - val fieldNs = namespaceForPropPath(fieldName) - try { - return getStructField(structNs, structName, fieldNs, fieldName) - } catch (e: XMPException) { - Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e) + // e.g. path 'Container:Directory[42]/Container:Item/Item:Mime' matches: + // - structNs: "http://ns.google.com/photos/1.0/container/" + // - structName: "Container:Directory[42]/Container:Item" + // - fieldNs: "http://ns.google.com/photos/1.0/container/item/" + // - fieldName: "Item:Mime" + fun XMPMeta.getSafeStructField(props: List): XMPProperty? { + if (props.size >= 2) { + val structFirst = props.first() + val field = props.last() + if (structFirst is XMPPropName && field is XMPPropName) { + val structName = props.take(props.size - 1).mapIndexed { index, prop -> + when (prop) { + is XMPPropName -> "${if (index == 0) "" else "/"}$prop" + is Int -> "[$prop]" + else -> null + } + }.filterNotNull().joinToString("") + val fieldName = field.toString() + + try { + return getStructField(structFirst.nsUri, structName, field.nsUri, fieldName) + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to get XMP struct field for props=$props", e) + } } } return null } -} \ No newline at end of file +} + +class XMPPropName(val nsUri: String, private val prop: String) { + private fun resolve(): String = "${XMPMetaFactory.getSchemaRegistry().getNamespacePrefix(nsUri)}$prop" + + override fun toString(): String = resolve() +} diff --git a/lib/services/media/embedded_data_service.dart b/lib/services/media/embedded_data_service.dart index 477bc1703..496e9ad0c 100644 --- a/lib/services/media/embedded_data_service.dart +++ b/lib/services/media/embedded_data_service.dart @@ -9,7 +9,7 @@ abstract class EmbeddedDataService { Future extractVideoEmbeddedPicture(AvesEntry entry); - Future extractXmpDataProp(AvesEntry entry, String? propPath, String? propMimeType); + Future extractXmpDataProp(AvesEntry entry, List? props, String? propMimeType); } class PlatformEmbeddedDataService implements EmbeddedDataService { @@ -61,14 +61,14 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { } @override - Future extractXmpDataProp(AvesEntry entry, String? propPath, String? propMimeType) async { + Future extractXmpDataProp(AvesEntry entry, List? props, String? propMimeType) async { try { final result = await _platform.invokeMethod('extractXmpDataProp', { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, - 'displayName': '${entry.bestTitle} • $propPath', - 'propPath': propPath, + 'displayName': '${entry.bestTitle} • $props', + 'propPath': props, 'propMimeType': propMimeType, }); if (result != null) return result as Map; diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index 8d4f98c0a..7b6b67cad 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -2,14 +2,127 @@ import 'package:intl/intl.dart'; import 'package:xml/xml.dart'; class Namespaces { + static const acdsee = 'http://ns.acdsee.com/iptc/1.0/'; + static const adsmlat = 'http://adsml.org/xmlns/'; + static const avm = 'http://www.communicatingastronomy.org/avm/1.0/'; + static const cc = 'http://creativecommons.org/ns#'; static const container = 'http://ns.google.com/photos/1.0/container/'; + static const creatorAtom = 'http://ns.adobe.com/creatorAtom/1.0/'; + static const crd = 'http://ns.adobe.com/camera-raw-defaults/1.0/'; + static const crs = 'http://ns.adobe.com/camera-raw-settings/1.0/'; + static const crss = 'http://ns.adobe.com/camera-raw-saved-settings/1.0/'; + static const darktable = 'http://darktable.sf.net/'; static const dc = 'http://purl.org/dc/elements/1.1/'; + static const dcterms = 'http://purl.org/dc/terms/'; + static const dicom = 'http://ns.adobe.com/DICOM/'; + static const digiKam = 'http://www.digikam.org/ns/1.0/'; + static const droneDji = 'http://www.dji.com/drone-dji/1.0/'; + static const dwc = 'http://rs.tdwg.org/dwc/index.htm'; + static const dwciri = 'http://rs.tdwg.org/dwc/iri/'; + static const exif = 'http://ns.adobe.com/exif/1.0/'; + static const exifAux = 'http://ns.adobe.com/exif/1.0/aux/'; + static const exifEx = 'http://cipa.jp/exif/1.0/'; + static const gAudio = 'http://ns.google.com/photos/1.0/audio/'; static const gCamera = 'http://ns.google.com/photos/1.0/camera/'; + static const gCreations = 'http://ns.google.com/photos/1.0/creations/'; + static const gDepth = 'http://ns.google.com/photos/1.0/depthmap/'; + static const gettyImagesGift = 'http://xmp.gettyimages.com/gift/1.0/'; + static const gFocus = 'http://ns.google.com/photos/1.0/focus/'; + static const gImage = 'http://ns.google.com/photos/1.0/image/'; + static const gimp = 'http://www.gimp.org/ns/2.10/'; + static const gPano = 'http://ns.google.com/photos/1.0/panorama/'; + static const gSpherical = 'http://ns.google.com/videos/1.0/spherical/'; + static const illustrator = 'http://ns.adobe.com/illustrator/1.0/'; + static const iptc4xmpCore = 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/'; + static const iptc4xmpExt = 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/'; + static const lr = 'http://ns.adobe.com/lightroom/1.0/'; + static const mediapro = 'http://ns.iview-multimedia.com/mediapro/1.0/'; + + // also seen in the wild for prefix `MicrosoftPhoto`: 'http://ns.microsoft.com/photo/1.0' static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/'; + static const mp1 = 'http://ns.microsoft.com/photo/1.1'; + static const mp = 'http://ns.microsoft.com/photo/1.2/'; + static const mpri = 'http://ns.microsoft.com/photo/1.2/t/RegionInfo#'; + static const mpreg = 'http://ns.microsoft.com/photo/1.2/t/Region#'; + static const mwgrs = 'http://www.metadataworkinggroup.com/schemas/regions/'; + static const nga = 'https://standards.nga.gov/metadata/media/image/artobject/1.0'; + static const panorama = 'http://ns.adobe.com/photoshop/1.0/panorama-profile'; + static const panoStudio = 'http://www.tshsoft.com/xmlns'; + static const pdf = 'http://ns.adobe.com/pdf/1.3/'; + static const pdfX = 'http://ns.adobe.com/pdfx/1.3/'; + static const photoMechanic = 'http://ns.camerabits.com/photomechanic/1.0/'; + static const photoshop = 'http://ns.adobe.com/photoshop/1.0/'; + static const plus = 'http://ns.useplus.org/ldf/xmp/1.0/'; + static const pmtm = 'http://www.hdrsoft.com/photomatix_settings01'; static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + static const stEvt = 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'; + static const stRef = 'http://ns.adobe.com/xap/1.0/sType/ResourceRef#'; + static const tiff = 'http://ns.adobe.com/tiff/1.0/'; static const x = 'adobe:ns:meta/'; static const xmp = 'http://ns.adobe.com/xap/1.0/'; + static const xmpBJ = 'http://ns.adobe.com/xap/1.0/bj/'; + static const xmpDM = 'http://ns.adobe.com/xmp/1.0/DynamicMedia/'; + static const xmpGImg = 'http://ns.adobe.com/xap/1.0/g/img/'; + static const xmpMM = 'http://ns.adobe.com/xap/1.0/mm/'; static const xmpNote = 'http://ns.adobe.com/xmp/note/'; + static const xmpRights = 'http://ns.adobe.com/xap/1.0/rights/'; + static const xmpTPg = 'http://ns.adobe.com/xap/1.0/t/pg/'; + + // cf https://exiftool.org/TagNames/XMP.html + static const Map nsTitles = { + acdsee: 'ACDSee', + adsmlat: 'AdsML', + exifAux: 'Exif Aux', + avm: 'Astronomy Visualization', + cc: 'Creative Commons', + container: 'Container', + crd: 'Camera Raw Defaults', + creatorAtom: 'After Effects', + crs: 'Camera Raw Settings', + crss: 'Camera Raw Saved Settings', + darktable: 'darktable', + dc: 'Dublin Core', + digiKam: 'digiKam', + droneDji: 'DJI Drone', + dwc: 'Darwin Core', + exif: 'Exif', + exifEx: 'Exif Ex', + gettyImagesGift: 'Getty Images', + gAudio: 'Google Audio', + gCamera: 'Google Camera', + gCreations: 'Google Creations', + gDepth: 'Google Depth', + gFocus: 'Google Focus', + gImage: 'Google Image', + gimp: 'GIMP', + gPano: 'Google Panorama', + gSpherical: 'Google Spherical', + illustrator: 'Illustrator', + iptc4xmpCore: 'IPTC Core', + iptc4xmpExt: 'IPTC Extension', + lr: 'Lightroom', + mediapro: 'MediaPro', + microsoftPhoto: 'Microsoft Photo 1.0', + mp1: 'Microsoft Photo 1.1', + mp: 'Microsoft Photo 1.2', + mwgrs: 'Regions', + nga: 'National Gallery of Art', + panorama: 'Panorama', + panoStudio: 'PanoramaStudio', + pdf: 'PDF', + pdfX: 'PDF/X', + photoMechanic: 'Photo Mechanic', + photoshop: 'Photoshop', + plus: 'PLUS', + pmtm: 'Photomatix', + tiff: 'TIFF', + xmp: 'Basic', + xmpBJ: 'Basic Job Ticket', + xmpDM: 'Dynamic Media', + xmpMM: 'Media Management', + xmpRights: 'Rights Management', + xmpTPg: 'Paged-Text', + }; static final defaultPrefixes = { container: 'Container', @@ -19,6 +132,7 @@ class Namespaces { rdf: 'rdf', x: 'x', xmp: 'xmp', + xmpGImg: 'xmpGImg', xmpNote: 'xmpNote', }; } diff --git a/lib/widgets/viewer/embedded/embedded_data_opener.dart b/lib/widgets/viewer/embedded/embedded_data_opener.dart index 3152d7461..7349fa289 100644 --- a/lib/widgets/viewer/embedded/embedded_data_opener.dart +++ b/lib/widgets/viewer/embedded/embedded_data_opener.dart @@ -42,7 +42,7 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin { fields = await embeddedDataService.extractVideoEmbeddedPicture(entry); break; case EmbeddedDataSource.xmp: - fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType); + fields = await embeddedDataService.extractXmpDataProp(entry, notification.props, notification.mimeType); break; } if (!fields.containsKey('mimeType') || !fields.containsKey('uri')) { diff --git a/lib/widgets/viewer/embedded/notifications.dart b/lib/widgets/viewer/embedded/notifications.dart index d320e6d85..cc1581d35 100644 --- a/lib/widgets/viewer/embedded/notifications.dart +++ b/lib/widgets/viewer/embedded/notifications.dart @@ -6,12 +6,12 @@ enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp } @immutable class OpenEmbeddedDataNotification extends Notification { final EmbeddedDataSource source; - final String? propPath; + final List? props; final String? mimeType; const OpenEmbeddedDataNotification._private({ required this.source, - this.propPath, + this.props, this.mimeType, }); @@ -24,15 +24,15 @@ class OpenEmbeddedDataNotification extends Notification { ); factory OpenEmbeddedDataNotification.xmp({ - required String propPath, + required List props, required String mimeType, }) => OpenEmbeddedDataNotification._private( source: EmbeddedDataSource.xmp, - propPath: propPath, + props: props, mimeType: mimeType, ); @override - String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}'; + String toString() => '$runtimeType#${shortHash(this)}{source=$source, props=$props, mimeType=$mimeType}'; } diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 51b1502e8..4afeb60dc 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -38,13 +38,14 @@ class MetadataDirTile extends StatelessWidget { @override Widget build(BuildContext context) { var tags = dir.tags; - if (tags.isEmpty) return const SizedBox.shrink(); + if (tags.isEmpty) return const SizedBox(); final dirName = dir.name; if (dirName == MetadataDirectory.xmpDirectory) { return XmpDirTile( entry: entry, title: title, + allTags: dir.allTags, tags: tags, expandedNotifier: expandedDirectoryNotifier, initiallyExpanded: initiallyExpanded, diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 453172941..626c1ef83 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -26,110 +26,58 @@ import 'package:provider/provider.dart'; @immutable class XmpNamespace extends Equatable { - final String namespace; + final String nsUri, nsPrefix; final Map rawProps; @override - List get props => [namespace]; + List get props => [nsUri, nsPrefix]; - const XmpNamespace(this.namespace, this.rawProps); + const XmpNamespace(this.nsUri, this.nsPrefix, this.rawProps); - factory XmpNamespace.create(String namespace, Map rawProps) { - switch (namespace) { - case XmpBasicNamespace.ns: - return XmpBasicNamespace(rawProps); - case XmpContainer.ns: - return XmpContainer(rawProps); - case XmpCrsNamespace.ns: - return XmpCrsNamespace(rawProps); - case XmpDarktableNamespace.ns: - return XmpDarktableNamespace(rawProps); - case XmpDwcNamespace.ns: - return XmpDwcNamespace(rawProps); - case XmpExifNamespace.ns: - return XmpExifNamespace(rawProps); - case XmpGAudioNamespace.ns: - return XmpGAudioNamespace(rawProps); - case XmpGDepthNamespace.ns: - return XmpGDepthNamespace(rawProps); - case XmpGImageNamespace.ns: - return XmpGImageNamespace(rawProps); - case XmpIptcCoreNamespace.ns: - return XmpIptcCoreNamespace(rawProps); - case XmpIptc4xmpExtNamespace.ns: - return XmpIptc4xmpExtNamespace(rawProps); - case XmpMgwRegionsNamespace.ns: - return XmpMgwRegionsNamespace(rawProps); - case XmpMMNamespace.ns: - return XmpMMNamespace(rawProps); - case XmpMPNamespace.ns: - return XmpMPNamespace(rawProps); - case XmpNoteNamespace.ns: - return XmpNoteNamespace(rawProps); - case XmpPhotoshopNamespace.ns: - return XmpPhotoshopNamespace(rawProps); - case XmpPlusNamespace.ns: - return XmpPlusNamespace(rawProps); - case XmpTiffNamespace.ns: - return XmpTiffNamespace(rawProps); + factory XmpNamespace.create(String nsUri, String nsPrefix, Map rawProps) { + switch (nsUri) { + case Namespaces.container: + return XmpContainer(nsPrefix, rawProps); + case Namespaces.crs: + return XmpCrsNamespace(nsPrefix, rawProps); + case Namespaces.darktable: + return XmpDarktableNamespace(nsPrefix, rawProps); + case Namespaces.dwc: + return XmpDwcNamespace(nsPrefix, rawProps); + case Namespaces.exif: + return XmpExifNamespace(nsPrefix, rawProps); + case Namespaces.gAudio: + return XmpGAudioNamespace(nsPrefix, rawProps); + case Namespaces.gDepth: + return XmpGDepthNamespace(nsPrefix, rawProps); + case Namespaces.gImage: + return XmpGImageNamespace(nsPrefix, rawProps); + case Namespaces.iptc4xmpCore: + return XmpIptcCoreNamespace(nsPrefix, rawProps); + case Namespaces.iptc4xmpExt: + return XmpIptc4xmpExtNamespace(nsPrefix, rawProps); + case Namespaces.mwgrs: + return XmpMgwRegionsNamespace(nsPrefix, rawProps); + case Namespaces.mp: + return XmpMPNamespace(nsPrefix, rawProps); + case Namespaces.photoshop: + return XmpPhotoshopNamespace(nsPrefix, rawProps); + case Namespaces.plus: + return XmpPlusNamespace(nsPrefix, rawProps); + case Namespaces.tiff: + return XmpTiffNamespace(nsPrefix, rawProps); + case Namespaces.xmp: + return XmpBasicNamespace(nsPrefix, rawProps); + case Namespaces.xmpMM: + return XmpMMNamespace(nsPrefix, rawProps); + case Namespaces.xmpNote: + return XmpNoteNamespace(nsPrefix, rawProps); default: - return XmpNamespace(namespace, rawProps); + return XmpNamespace(nsUri, nsPrefix, rawProps); } } - // cf https://exiftool.org/TagNames/XMP.html - static const Map nsTitles = { - 'acdsee': 'ACDSee', - 'adsml-at': 'AdsML', - 'aux': 'Exif Aux', - 'avm': 'Astronomy Visualization', - 'Camera': 'Camera', - 'cc': 'Creative Commons', - 'crd': 'Camera Raw Defaults', - 'creatorAtom': 'After Effects', - 'crs': 'Camera Raw Settings', - 'dc': 'Dublin Core', - 'drone-dji': 'DJI Drone', - 'dwc': 'Darwin Core', - 'exif': 'Exif', - 'exifEX': 'Exif Ex', - 'GettyImagesGIFT': 'Getty Images', - 'GAudio': 'Google Audio', - 'GDepth': 'Google Depth', - 'GImage': 'Google Image', - 'GIMP': 'GIMP', - 'GCamera': 'Google Camera', - 'GCreations': 'Google Creations', - 'GFocus': 'Google Focus', - 'GPano': 'Google Panorama', - 'illustrator': 'Illustrator', - 'Iptc4xmpCore': 'IPTC Core', - 'Iptc4xmpExt': 'IPTC Extension', - 'lr': 'Lightroom', - 'mediapro': 'MediaPro', - 'MicrosoftPhoto': 'Microsoft Photo 1.0', - 'MP1': 'Microsoft Photo 1.1', - 'MP': 'Microsoft Photo 1.2', - 'mwg-rs': 'Regions', - 'nga': 'National Gallery of Art', - 'panorama': 'Panorama', - 'PanoStudioXMP': 'PanoramaStudio', - 'pdf': 'PDF', - 'pdfx': 'PDF/X', - 'photomechanic': 'Photo Mechanic', - 'photoshop': 'Photoshop', - 'plus': 'PLUS', - 'pmtm': 'Photomatix', - 'tiff': 'TIFF', - 'xmp': 'Basic', - 'xmpBJ': 'Basic Job Ticket', - 'xmpDM': 'Dynamic Media', - 'xmpMM': 'Media Management', - 'xmpRights': 'Rights Management', - 'xmpTPg': 'Paged-Text', - }; - - String get displayTitle => nsTitles[namespace] ?? namespace; + String get displayTitle => Namespaces.nsTitles[nsUri] ?? '${nsPrefix.substring(0, nsPrefix.length - 1)} ($nsUri)'; Map get buildProps => rawProps; diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart index 509ea70ec..40933671f 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart @@ -1,17 +1,16 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/widgets.dart'; class XmpCrsNamespace extends XmpNamespace { - static const ns = 'crs'; - - static final cgbcPattern = RegExp(ns + r':CircularGradientBasedCorrections\[(\d+)\]/(.*)'); - static final gbcPattern = RegExp(ns + r':GradientBasedCorrections\[(\d+)\]/(.*)'); - static final mgbcPattern = RegExp(ns + r':MaskGroupBasedCorrections\[(\d+)\]/(.*)'); - static final pbcPattern = RegExp(ns + r':PaintBasedCorrections\[(\d+)\]/(.*)'); - static final retouchAreasPattern = RegExp(ns + r':RetouchAreas\[(\d+)\]/(.*)'); - static final lookPattern = RegExp(ns + r':Look/(.*)'); - static final rmmiPattern = RegExp(ns + r':RangeMaskMapInfo/' + ns + r':RangeMaskMapInfo/(.*)'); + late final cgbcPattern = RegExp(nsPrefix + r'CircularGradientBasedCorrections\[(\d+)\]/(.*)'); + late final gbcPattern = RegExp(nsPrefix + r'GradientBasedCorrections\[(\d+)\]/(.*)'); + late final mgbcPattern = RegExp(nsPrefix + r'MaskGroupBasedCorrections\[(\d+)\]/(.*)'); + late final pbcPattern = RegExp(nsPrefix + r'PaintBasedCorrections\[(\d+)\]/(.*)'); + late final retouchAreasPattern = RegExp(nsPrefix + r'RetouchAreas\[(\d+)\]/(.*)'); + late final lookPattern = RegExp(nsPrefix + r'Look/(.*)'); + late final rmmiPattern = RegExp(nsPrefix + r'RangeMaskMapInfo/' + nsPrefix + r'RangeMaskMapInfo/(.*)'); final cgbc = >{}; final gbc = >{}; @@ -21,7 +20,7 @@ class XmpCrsNamespace extends XmpNamespace { final look = {}; final rmmi = {}; - XmpCrsNamespace(Map rawProps) : super(ns, rawProps); + XmpCrsNamespace(String nsPrefix, Map rawProps) : super(Namespaces.crs, nsPrefix, rawProps); @override bool extractData(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart b/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart index a06d75964..e47f67b4e 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart @@ -1,15 +1,14 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; class XmpDarktableNamespace extends XmpNamespace { - static const ns = 'darktable'; - - static final historyPattern = RegExp(ns + r':history\[(\d+)\]/(.*)'); + late final historyPattern = RegExp(nsPrefix + r'history\[(\d+)\]/(.*)'); final history = >{}; - XmpDarktableNamespace(Map rawProps) : super(ns, rawProps); + XmpDarktableNamespace(String nsPrefix, Map rawProps) : super(Namespaces.darktable, nsPrefix, rawProps); @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, historyPattern, history); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/dwc.dart b/lib/widgets/viewer/info/metadata/xmp_ns/dwc.dart index 81b543f33..ad5fe4212 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/dwc.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/dwc.dart @@ -1,19 +1,18 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/widgets.dart'; class XmpDwcNamespace extends XmpNamespace { - static const ns = 'dwc'; - - static final dcTermsLocationPattern = RegExp(ns + r':dctermsLocation/(.*)'); - static final eventPattern = RegExp(ns + r':Event/(.*)'); - static final geologicalContextPattern = RegExp(ns + r':GeologicalContext/(.*)'); - static final identificationPattern = RegExp(ns + r':Identification/(.*)'); - static final measurementOrFactPattern = RegExp(ns + r':MeasurementOrFact/(.*)'); - static final occurrencePattern = RegExp(ns + r':Occurrence/(.*)'); - static final recordPattern = RegExp(ns + r':Record/(.*)'); - static final resourceRelationshipPattern = RegExp(ns + r':ResourceRelationship/(.*)'); - static final taxonPattern = RegExp(ns + r':Taxon/(.*)'); + late final dcTermsLocationPattern = RegExp(nsPrefix + r'dctermsLocation/(.*)'); + late final eventPattern = RegExp(nsPrefix + r'Event/(.*)'); + late final geologicalContextPattern = RegExp(nsPrefix + r'GeologicalContext/(.*)'); + late final identificationPattern = RegExp(nsPrefix + r'Identification/(.*)'); + late final measurementOrFactPattern = RegExp(nsPrefix + r'MeasurementOrFact/(.*)'); + late final occurrencePattern = RegExp(nsPrefix + r'Occurrence/(.*)'); + late final recordPattern = RegExp(nsPrefix + r'Record/(.*)'); + late final resourceRelationshipPattern = RegExp(nsPrefix + r'ResourceRelationship/(.*)'); + late final taxonPattern = RegExp(nsPrefix + r'Taxon/(.*)'); final dcTermsLocation = {}; final event = {}; @@ -25,7 +24,7 @@ class XmpDwcNamespace extends XmpNamespace { final resourceRelationship = {}; final taxon = {}; - XmpDwcNamespace(Map rawProps) : super(ns, rawProps); + XmpDwcNamespace(String nsPrefix, Map rawProps) : super(Namespaces.dwc, nsPrefix, rawProps); @override bool extractData(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart index da2855a2b..991908f0b 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart @@ -1,11 +1,10 @@ import 'package:aves/ref/exif.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/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'; - - const XmpExifNamespace(Map rawProps) : super(ns, rawProps); + const XmpExifNamespace(String nsPrefix, Map rawProps) : super(Namespaces.exif, nsPrefix, rawProps); @override String formatValue(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index 950241428..9a3270386 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -1,3 +1,4 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/common.dart'; @@ -8,7 +9,7 @@ import 'package:flutter/widgets.dart'; import 'package:tuple/tuple.dart'; abstract class XmpGoogleNamespace extends XmpNamespace { - const XmpGoogleNamespace(String ns, Map rawProps) : super(ns, rawProps); + const XmpGoogleNamespace(String nsUri, String nsPrefix, Map rawProps) : super(nsUri, nsPrefix, rawProps); List> get dataProps; @@ -24,10 +25,29 @@ abstract class XmpGoogleNamespace extends XmpNamespace { dataProp.displayKey, InfoRowGroup.linkSpanBuilder( linkText: (context) => context.l10n.viewerInfoOpenLinkText, - onTap: (context) => OpenEmbeddedDataNotification.xmp( - propPath: dataProp.path, - mimeType: mimeProp.value, - ).dispatch(context), + onTap: (context) { + final pattern = RegExp(r'(.+):(.+)([(\d)])?'); + final props = dataProp.path.split('/').expand((part) { + var match = pattern.firstMatch(part); + if (match == null) return []; + + // ignore namespace prefix + final propName = match.group(2); + final prop = [nsUri, propName]; + + final indexString = match.groupCount >= 4 ? match.group(4) : null; + final index = indexString != null ? int.tryParse(indexString) : null; + if (index != null) { + return [prop, index]; + } else { + return [prop]; + } + }).toList(); + return OpenEmbeddedDataNotification.xmp( + props: props, + mimeType: mimeProp.value, + ).dispatch(context); + }, )) : null; }).whereNotNull()); @@ -35,43 +55,35 @@ abstract class XmpGoogleNamespace extends XmpNamespace { } class XmpGAudioNamespace extends XmpGoogleNamespace { - static const ns = 'GAudio'; - - const XmpGAudioNamespace(Map rawProps) : super(ns, rawProps); + const XmpGAudioNamespace(String nsPrefix, Map rawProps) : super(Namespaces.gAudio, nsPrefix, rawProps); @override - List> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')]; + List> get dataProps => [Tuple2('${nsPrefix}Data', '${nsPrefix}Mime')]; } class XmpGDepthNamespace extends XmpGoogleNamespace { - static const ns = 'GDepth'; - - const XmpGDepthNamespace(Map rawProps) : super(ns, rawProps); + const XmpGDepthNamespace(String nsPrefix, Map rawProps) : super(Namespaces.gDepth, nsPrefix, rawProps); @override - List> get dataProps => const [ - Tuple2('$ns:Data', '$ns:Mime'), - Tuple2('$ns:Confidence', '$ns:ConfidenceMime'), + List> get dataProps => [ + Tuple2('${nsPrefix}Data', '${nsPrefix}Mime'), + Tuple2('${nsPrefix}Confidence', '${nsPrefix}ConfidenceMime'), ]; } class XmpGImageNamespace extends XmpGoogleNamespace { - static const ns = 'GImage'; - - const XmpGImageNamespace(Map rawProps) : super(ns, rawProps); + const XmpGImageNamespace(String nsPrefix, Map rawProps) : super(Namespaces.gImage, nsPrefix, rawProps); @override - List> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')]; + List> get dataProps => [Tuple2('${nsPrefix}Data', '${nsPrefix}Mime')]; } class XmpContainer extends XmpNamespace { - static const ns = 'Container'; - - static final directoryPattern = RegExp('$ns:Directory\\[(\\d+)\\]/$ns:Item/(.*)'); + late final directoryPattern = RegExp('${nsPrefix}Directory\\[(\\d+)\\]/${nsPrefix}Item/(.*)'); final directories = >{}; - XmpContainer(Map rawProps) : super(ns, rawProps); + XmpContainer(String nsPrefix, Map rawProps) : super(Namespaces.container, nsPrefix, rawProps); @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, directoryPattern, directories); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart b/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart index cdfca8868..167069b2a 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart @@ -1,15 +1,14 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; class XmpIptcCoreNamespace extends XmpNamespace { - static const ns = 'Iptc4xmpCore'; - - static final creatorContactInfoPattern = RegExp(ns + r':CreatorContactInfo/(.*)'); + late final creatorContactInfoPattern = RegExp(nsPrefix + r'CreatorContactInfo/(.*)'); final creatorContactInfo = {}; - XmpIptcCoreNamespace(Map rawProps) : super(ns, rawProps); + XmpIptcCoreNamespace(String nsPrefix, Map rawProps) : super(Namespaces.iptc4xmpCore, nsPrefix, rawProps); @override bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/iptc4xmpext.dart b/lib/widgets/viewer/info/metadata/xmp_ns/iptc4xmpext.dart index 147cb48fe..9d72e8952 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/iptc4xmpext.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/iptc4xmpext.dart @@ -1,15 +1,14 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; class XmpIptc4xmpExtNamespace extends XmpNamespace { - static const ns = 'Iptc4xmpExt'; - - static final aooPattern = RegExp(ns + r':ArtworkOrObject\[(\d+)\]/(.*)'); + late final aooPattern = RegExp(nsPrefix + r'ArtworkOrObject\[(\d+)\]/(.*)'); final aoo = >{}; - XmpIptc4xmpExtNamespace(Map rawProps) : super(ns, rawProps); + XmpIptc4xmpExtNamespace(String nsPrefix, Map rawProps) : super(Namespaces.iptc4xmpExt, nsPrefix, rawProps); @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, aooPattern, aoo); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/microsoft.dart b/lib/widgets/viewer/info/metadata/xmp_ns/microsoft.dart index 78366c98f..af5d1b9cc 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/microsoft.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/microsoft.dart @@ -1,15 +1,14 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/widgets.dart'; class XmpMPNamespace extends XmpNamespace { - static const ns = 'MP'; - - static final regionListPattern = RegExp(ns + r':RegionInfo/MPRI:Regions\[(\d+)\]/(.*)'); + late final regionListPattern = RegExp(nsPrefix + r'RegionInfo/MPRI:Regions\[(\d+)\]/(.*)'); final regionList = >{}; - XmpMPNamespace(Map rawProps) : super(ns, rawProps); + XmpMPNamespace(String nsPrefix, Map rawProps) : super(Namespaces.mp, nsPrefix, rawProps); @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, regionListPattern, regionList); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart b/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart index fd4e6ac99..3c74449f6 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart @@ -1,18 +1,17 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/widgets.dart'; // cf www.metadataworkinggroup.org/pdf/mwg_guidance.pdf (down, as of 2021/02/15) class XmpMgwRegionsNamespace extends XmpNamespace { - static const ns = 'mwg-rs'; - - static final dimensionsPattern = RegExp(ns + r':Regions/mwg-rs:AppliedToDimensions/(.*)'); - static final regionListPattern = RegExp(ns + r':Regions/mwg-rs:RegionList\[(\d+)\]/(.*)'); + late final dimensionsPattern = RegExp(nsPrefix + r'Regions/mwg-rs:AppliedToDimensions/(.*)'); + late final regionListPattern = RegExp(nsPrefix + r'Regions/mwg-rs:RegionList\[(\d+)\]/(.*)'); final dimensions = {}; final regionList = >{}; - XmpMgwRegionsNamespace(Map rawProps) : super(ns, rawProps); + XmpMgwRegionsNamespace(String nsPrefix, Map rawProps) : super(Namespaces.mwgrs, nsPrefix, rawProps); @override bool extractData(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart index 8b724c00d..992d96306 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart @@ -1,16 +1,15 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/widgets.dart'; // cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md class XmpPhotoshopNamespace extends XmpNamespace { - static const ns = 'photoshop'; - - static final textLayersPattern = RegExp(ns + r':TextLayers\[(\d+)\]/(.*)'); + late final textLayersPattern = RegExp(nsPrefix + r'TextLayers\[(\d+)\]/(.*)'); final textLayers = >{}; - XmpPhotoshopNamespace(Map rawProps) : super(ns, rawProps); + XmpPhotoshopNamespace(String nsPrefix, Map rawProps) : super(Namespaces.photoshop, nsPrefix, rawProps); @override bool extractData(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/plus.dart b/lib/widgets/viewer/info/metadata/xmp_ns/plus.dart index ab200ed67..34ab0862e 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/plus.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/plus.dart @@ -1,15 +1,14 @@ +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; class XmpPlusNamespace extends XmpNamespace { - static const ns = 'plus'; - - static final licensorPattern = RegExp(ns + r':Licensor\[(\d+)\]/(.*)'); + late final licensorPattern = RegExp(nsPrefix + r'Licensor\[(\d+)\]/(.*)'); final licensor = >{}; - XmpPlusNamespace(Map rawProps) : super(ns, rawProps); + XmpPlusNamespace(String nsPrefix, Map rawProps) : super(Namespaces.plus, nsPrefix, rawProps); @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, licensorPattern, licensor); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart index 90d7e0fcc..bf027ccf4 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart @@ -1,11 +1,10 @@ import 'package:aves/ref/exif.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/viewer/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'; - - const XmpTiffNamespace(Map rawProps) : super(ns, rawProps); + const XmpTiffNamespace(String nsPrefix, Map rawProps) : super(Namespaces.tiff, nsPrefix, rawProps); @override String formatValue(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart index 2c4e0fe65..042037017 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart @@ -1,4 +1,5 @@ import 'package:aves/ref/mime_types.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/common.dart'; @@ -7,14 +8,12 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; class XmpBasicNamespace extends XmpNamespace { - static const ns = 'xmp'; - - static final thumbnailsPattern = RegExp(ns + r':Thumbnails\[(\d+)\]/(.*)'); + late final thumbnailsPattern = RegExp(nsPrefix + r'Thumbnails\[(\d+)\]/(.*)'); static const thumbnailDataDisplayKey = 'Image'; final thumbnails = >{}; - XmpBasicNamespace(Map rawProps) : super(ns, rawProps); + XmpBasicNamespace(String nsPrefix, Map rawProps) : super(Namespaces.xmp, nsPrefix, rawProps); @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails); @@ -32,7 +31,11 @@ class XmpBasicNamespace extends XmpNamespace { thumbnailDataDisplayKey: InfoRowGroup.linkSpanBuilder( linkText: (context) => context.l10n.viewerInfoOpenLinkText, onTap: (context) => OpenEmbeddedDataNotification.xmp( - propPath: 'xmp:Thumbnails[$index]/xmpGImg:image', + props: [ + const [Namespaces.xmp, 'Thumbnails'], + index, + const [Namespaces.xmpGImg, 'image'], + ], mimeType: MimeTypes.jpeg, ).dispatch(context), ), @@ -43,22 +46,17 @@ class XmpBasicNamespace extends XmpNamespace { } class XmpMMNamespace extends XmpNamespace { - static const ns = 'xmpMM'; - - static const didPrefix = 'xmp.did:'; - static const iidPrefix = 'xmp.iid:'; - - static final derivedFromPattern = RegExp(ns + r':DerivedFrom/(.*)'); - static final historyPattern = RegExp(ns + r':History\[(\d+)\]/(.*)'); - static final ingredientsPattern = RegExp(ns + r':Ingredients\[(\d+)\]/(.*)'); - static final pantryPattern = RegExp(ns + r':Pantry\[(\d+)\]/(.*)'); + late final derivedFromPattern = RegExp(nsPrefix + r'DerivedFrom/(.*)'); + late final historyPattern = RegExp(nsPrefix + r'History\[(\d+)\]/(.*)'); + late final ingredientsPattern = RegExp(nsPrefix + r'Ingredients\[(\d+)\]/(.*)'); + late final pantryPattern = RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)'); final derivedFrom = {}; final history = >{}; final ingredients = >{}; final pantry = >{}; - XmpMMNamespace(Map rawProps) : super(ns, rawProps); + XmpMMNamespace(String nsPrefix, Map rawProps) : super(Namespaces.xmpMM, nsPrefix, rawProps); @override bool extractData(XmpProp prop) { @@ -92,23 +90,13 @@ class XmpMMNamespace extends XmpNamespace { structByIndex: pantry, ), ]; - - @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'; + late final hasExtendedXmp = '${nsPrefix}HasExtendedXMP'; - const XmpNoteNamespace(Map rawProps) : super(ns, rawProps); + XmpNoteNamespace(String nsPrefix, Map rawProps) : super(Namespaces.xmpNote, nsPrefix, rawProps); @override bool extractData(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index dcacff9a7..0a7a4748d 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:convert'; import 'package:aves/model/entry.dart'; import 'package:aves/theme/colors.dart'; @@ -12,7 +13,7 @@ import 'package:provider/provider.dart'; class XmpDirTile extends StatefulWidget { final AvesEntry entry; final String title; - final SplayTreeMap tags; + final SplayTreeMap allTags, tags; final ValueNotifier? expandedNotifier; final bool initiallyExpanded; @@ -20,6 +21,7 @@ class XmpDirTile extends StatefulWidget { super.key, required this.entry, required this.title, + required this.allTags, required this.tags, required this.expandedNotifier, required this.initiallyExpanded, @@ -30,16 +32,34 @@ class XmpDirTile extends StatefulWidget { } class _XmpDirTileState extends State { + late final Map _schemaRegistryPrefixes, _tags; + AvesEntry get entry => widget.entry; + static const schemaRegistryPrefixesKey = 'schemaRegistryPrefixes'; + + @override + void initState() { + super.initState(); + _tags = Map.from(widget.tags)..remove(schemaRegistryPrefixesKey); + final prefixesJson = widget.allTags[schemaRegistryPrefixesKey]; + final Map prefixesDecoded = prefixesJson != null ? json.decode(prefixesJson) : {}; + _schemaRegistryPrefixes = Map.fromEntries(prefixesDecoded.entries.map((kv) => MapEntry(kv.key, kv.value as String))); + } + @override Widget build(BuildContext context) { - final sections = groupBy, String>(widget.tags.entries, (kv) { + final sections = groupBy, String>(_tags.entries, (kv) { final fullKey = kv.key; final i = fullKey.indexOf(XMP.propNamespaceSeparator); - final namespace = i == -1 ? '' : fullKey.substring(0, i); - return namespace; - }).entries.map((kv) => XmpNamespace.create(kv.key, Map.fromEntries(kv.value))).toList() + final nsPrefix = i == -1 ? '' : fullKey.substring(0, i + 1); + return nsPrefix; + }).entries.map((kv) { + final nsPrefix = kv.key; + final nsUri = _schemaRegistryPrefixes[nsPrefix] ?? ''; + final rawProps = Map.fromEntries(kv.value); + return XmpNamespace.create(nsUri, nsPrefix, rawProps); + }).toList() ..sort((a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle)); return AvesExpansionTile( // title may contain parent to distinguish multiple XMP directories