#304 use xmp namespace URIs instead of prefixes

This commit is contained in:
Thibault Deckers 2022-08-20 22:04:52 +02:00
parent 5b717d69d4
commit cf5711e0f6
24 changed files with 445 additions and 323 deletions

View file

@ -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<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val dataPropPath = call.argument<String>("propPath")
val dataProp = call.argument<List<Any>>("propPath")
val embedMimeType = call.argument<String>("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) {

View file

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

View file

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

View file

@ -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<String> {
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<Any>): 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
}
}
}
class XMPPropName(val nsUri: String, private val prop: String) {
private fun resolve(): String = "${XMPMetaFactory.getSchemaRegistry().getNamespacePrefix(nsUri)}$prop"
override fun toString(): String = resolve()
}

View file

@ -9,7 +9,7 @@ abstract class EmbeddedDataService {
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry);
Future<Map> extractXmpDataProp(AvesEntry entry, String? propPath, String? propMimeType);
Future<Map> extractXmpDataProp(AvesEntry entry, List<dynamic>? props, String? propMimeType);
}
class PlatformEmbeddedDataService implements EmbeddedDataService {
@ -61,14 +61,14 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
}
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String? propPath, String? propMimeType) async {
Future<Map> extractXmpDataProp(AvesEntry entry, List<dynamic>? props, String? propMimeType) async {
try {
final result = await _platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'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;

View file

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

View file

@ -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')) {

View file

@ -6,12 +6,12 @@ enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
@immutable
class OpenEmbeddedDataNotification extends Notification {
final EmbeddedDataSource source;
final String? propPath;
final List<dynamic>? 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<dynamic> 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}';
}

View file

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

View file

@ -26,110 +26,58 @@ import 'package:provider/provider.dart';
@immutable
class XmpNamespace extends Equatable {
final String namespace;
final String nsUri, nsPrefix;
final Map<String, String> rawProps;
@override
List<Object?> get props => [namespace];
List<Object?> get props => [nsUri, nsPrefix];
const XmpNamespace(this.namespace, this.rawProps);
const XmpNamespace(this.nsUri, this.nsPrefix, this.rawProps);
factory XmpNamespace.create(String namespace, Map<String, String> 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<String, String> 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<String, String> 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<String, String> get buildProps => rawProps;

View file

@ -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 = <int, Map<String, String>>{};
final gbc = <int, Map<String, String>>{};
@ -21,7 +20,7 @@ class XmpCrsNamespace extends XmpNamespace {
final look = <String, String>{};
final rmmi = <String, String>{};
XmpCrsNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpCrsNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.crs, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {

View file

@ -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 = <int, Map<String, String>>{};
XmpDarktableNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpDarktableNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.darktable, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, historyPattern, history);

View file

@ -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 = <String, String>{};
final event = <String, String>{};
@ -25,7 +24,7 @@ class XmpDwcNamespace extends XmpNamespace {
final resourceRelationship = <String, String>{};
final taxon = <String, String>{};
XmpDwcNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpDwcNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.dwc, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {

View file

@ -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<String, String> rawProps) : super(ns, rawProps);
const XmpExifNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.exif, nsPrefix, rawProps);
@override
String formatValue(XmpProp prop) {

View file

@ -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<String, String> rawProps) : super(ns, rawProps);
const XmpGoogleNamespace(String nsUri, String nsPrefix, Map<String, String> rawProps) : super(nsUri, nsPrefix, rawProps);
List<Tuple2<String, String>> 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<String, String> rawProps) : super(ns, rawProps);
const XmpGAudioNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.gAudio, nsPrefix, rawProps);
@override
List<Tuple2<String, String>> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')];
List<Tuple2<String, String>> get dataProps => [Tuple2('${nsPrefix}Data', '${nsPrefix}Mime')];
}
class XmpGDepthNamespace extends XmpGoogleNamespace {
static const ns = 'GDepth';
const XmpGDepthNamespace(Map<String, String> rawProps) : super(ns, rawProps);
const XmpGDepthNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.gDepth, nsPrefix, rawProps);
@override
List<Tuple2<String, String>> get dataProps => const [
Tuple2('$ns:Data', '$ns:Mime'),
Tuple2('$ns:Confidence', '$ns:ConfidenceMime'),
List<Tuple2<String, String>> get dataProps => [
Tuple2('${nsPrefix}Data', '${nsPrefix}Mime'),
Tuple2('${nsPrefix}Confidence', '${nsPrefix}ConfidenceMime'),
];
}
class XmpGImageNamespace extends XmpGoogleNamespace {
static const ns = 'GImage';
const XmpGImageNamespace(Map<String, String> rawProps) : super(ns, rawProps);
const XmpGImageNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.gImage, nsPrefix, rawProps);
@override
List<Tuple2<String, String>> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')];
List<Tuple2<String, String>> 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 = <int, Map<String, String>>{};
XmpContainer(Map<String, String> rawProps) : super(ns, rawProps);
XmpContainer(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.container, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, directoryPattern, directories);

View file

@ -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 = <String, String>{};
XmpIptcCoreNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpIptcCoreNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.iptc4xmpCore, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo);

View file

@ -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 = <int, Map<String, String>>{};
XmpIptc4xmpExtNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpIptc4xmpExtNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.iptc4xmpExt, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, aooPattern, aoo);

View file

@ -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 = <int, Map<String, String>>{};
XmpMPNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpMPNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.mp, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, regionListPattern, regionList);

View file

@ -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 = <String, String>{};
final regionList = <int, Map<String, String>>{};
XmpMgwRegionsNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpMgwRegionsNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.mwgrs, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {

View file

@ -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 = <int, Map<String, String>>{};
XmpPhotoshopNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpPhotoshopNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.photoshop, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {

View file

@ -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 = <int, Map<String, String>>{};
XmpPlusNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpPlusNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.plus, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, licensorPattern, licensor);

View file

@ -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<String, String> rawProps) : super(ns, rawProps);
const XmpTiffNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.tiff, nsPrefix, rawProps);
@override
String formatValue(XmpProp prop) {

View file

@ -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 = <int, Map<String, String>>{};
XmpBasicNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpBasicNamespace(String nsPrefix, Map<String, String> 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 = <String, String>{};
final history = <int, Map<String, String>>{};
final ingredients = <int, Map<String, String>>{};
final pantry = <int, Map<String, String>>{};
XmpMMNamespace(Map<String, String> rawProps) : super(ns, rawProps);
XmpMMNamespace(String nsPrefix, Map<String, String> 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<String, String> rawProps) : super(ns, rawProps);
XmpNoteNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.xmpNote, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {

View file

@ -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<String, String> tags;
final SplayTreeMap<String, String> allTags, tags;
final ValueNotifier<String?>? 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<XmpDirTile> {
late final Map<String, String> _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<String, dynamic> 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<MapEntry<String, String>, String>(widget.tags.entries, (kv) {
final sections = groupBy<MapEntry<String, String>, 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