info: PNG IPTC display

This commit is contained in:
Thibault Deckers 2021-10-12 09:30:32 +09:00
parent f72b3e775f
commit 90f6c5d841
10 changed files with 105 additions and 23 deletions

View file

@ -34,16 +34,20 @@ import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeInt
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
@ -53,12 +57,12 @@ import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -143,7 +147,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// optional parent to distinguish child directories of the same type // optional parent to distinguish child directories of the same type
dir.parent?.name?.let { thisDirName = "$it/$thisDirName" } dir.parent?.name?.let { thisDirName = "$it/$thisDirName" }
val dirMap = metadataMap[thisDirName] ?: HashMap() var dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap metadataMap[thisDirName] = dirMap
// tags // tags
@ -168,18 +172,35 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} else { } else {
dirMap.putAll(tags.map { tagMapper(it) }) dirMap.putAll(tags.map { tagMapper(it) })
} }
} else if (dir is PngDirectory) { } else if (dir.isPngTextDir()) {
metadataMap.remove(thisDirName)
dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
for (tag in tags) { for (tag in tags) {
val tagType = tag.tagType val tagType = tag.tagType
if (tagType == PngDirectory.TAG_TEXTUAL_DATA) { if (tagType == PngDirectory.TAG_TEXTUAL_DATA) {
val pairs = dir.getObject(tagType) as List<*> val pairs = dir.getObject(tagType) as List<*>
dirMap.putAll(pairs.map { val textPairs = pairs.map { pair ->
val kv = it as KeyValuePair val kv = pair as KeyValuePair
// PNG spec says encoding charset is always Latin-1 / ISO-8859-1 val key = kv.key
// but in practice UTF-8 is sometimes used in PNG-iTXt chunks // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
val charset = if (baseDirName == "PNG-iTXt") StandardCharsets.UTF_8 else kv.value.charset val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.charset
Pair(kv.key, String(kv.value.bytes, charset)) val valueString = String(kv.value.bytes, charset)
}) val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) {
dirs.forEach { profileDir ->
val profileDirName = profileDir.name
val profileDirMap = metadataMap[profileDirName] ?: HashMap()
metadataMap[profileDirName] = profileDirMap
profileDirMap.putAll(profileDir.tags.map { Pair(it.tagName, it.description) })
}
null
} else {
Pair(key, valueString)
}
}
dirMap.putAll(textPairs.filterNotNull())
} else { } else {
dirMap[tag.tagName] = tag.description dirMap[tag.tagName] = tag.description
} }
@ -383,7 +404,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives), // In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
// in which case we trust the file extension // in which case we trust the file extension
// cf https://github.com/drewnoakes/metadata-extractor/issues/296 // cf https://github.com/drewnoakes/metadata-extractor/issues/296
if (path?.matches(tiffExtensionPattern) == true) { if (path?.matches(TIFF_EXTENSION_PATTERN) == true) {
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
} else { } else {
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {

View file

@ -79,7 +79,7 @@ class ThumbnailFetcher internal constructor(
} else { } else {
var errorDetails: String? = exception?.message var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) { if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first() errorDetails = errorDetails.split(Regex("\n"), 2).first()
} }
result.error("getThumbnail-null", "failed to get thumbnail for mimeType=$mimeType uri=$uri", errorDetails) result.error("getThumbnail-null", "failed to get thumbnail for mimeType=$mimeType uri=$uri", errorDetails)
} }

View file

@ -170,7 +170,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun toErrorDetails(e: Exception): String? { private fun toErrorDetails(e: Exception): String? {
val errorDetails = e.message val errorDetails = e.message
return if (errorDetails?.isNotEmpty() == true) { return if (errorDetails?.isNotEmpty() == true) {
errorDetails.split("\n".toRegex(), 2).first() errorDetails.split(Regex("\n"), 2).first()
} else { } else {
errorDetails errorDetails
} }

View file

@ -17,6 +17,8 @@ import java.util.regex.Pattern
object Metadata { object Metadata {
private val LOG_TAG = LogUtils.createTag<Metadata>() private val LOG_TAG = LogUtils.createTag<Metadata>()
const val IPTC_MARKER_BYTE: Byte = 0x1c
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709) // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples: // Examples:
// "+37.5090+127.0243/" (Samsung) // "+37.5090+127.0243/" (Samsung)
@ -31,6 +33,7 @@ object Metadata {
const val DIR_XMP = "XMP" // from metadata-extractor const val DIR_XMP = "XMP" // from metadata-extractor
const val DIR_MEDIA = "Media" // custom const val DIR_MEDIA = "Media" // custom
const val DIR_COVER_ART = "Cover" // custom const val DIR_COVER_ART = "Cover" // custom
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
// types of metadata // types of metadata
const val TYPE_EXIF = "exif" const val TYPE_EXIF = "exif"

View file

@ -1,15 +1,27 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.lang.SequentialByteArrayReader
import com.drew.metadata.Directory import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.png.PngDirectory
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
object MetadataExtractorHelper { object MetadataExtractorHelper {
const val PNG_ITXT_DIR_NAME = "PNG-iTXt"
private const val PNG_TEXT_DIR_NAME = "PNG-tEXt"
const val PNG_TIME_DIR_NAME = "PNG-tIME" const val PNG_TIME_DIR_NAME = "PNG-tIME"
private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt"
val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
// Pattern to extract profile name, length, and text data
// of raw profiles (EXIF, IPTC, etc.) in PNG `zTXt` chunks
// e.g. "iptc [...] 114 [...] 3842494d040400[...]"
private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
// extensions // extensions
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) { fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
@ -59,4 +71,45 @@ object MetadataExtractorHelper {
return true return true
} }
// PNG
fun Directory.isPngTextDir(): Boolean = this is PngDirectory && setOf(PNG_ITXT_DIR_NAME, PNG_TEXT_DIR_NAME, PNG_ZTXT_DIR_NAME).contains(this.name)
fun extractPngProfile(key: String, valueString: String): Iterable<Directory>? {
when (key) {
"Raw profile type iptc" -> {
val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString)
if (match != null) {
val dataString = match.groupValues[3]
val hexString = dataString.replace(Regex("[\\r\\n]"), "")
val dataBytes = hexStringToByteArray(hexString)
if (dataBytes != null) {
val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE)
if (start != -1) {
val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size - start)
val metadata = com.drew.metadata.Metadata()
IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong())
return metadata.directories
}
}
}
}
}
return null
}
// convenience methods
private fun hexStringToByteArray(hexString: String): ByteArray? {
if (hexString.length % 2 != 0) return null
val dataBytes = ByteArray(hexString.length / 2)
var i = 0
while (i < hexString.length) {
dataBytes[i / 2] = hexString.substring(i, i + 2).toByte(16)
i += 2
}
return dataBytes
}
} }

View file

@ -122,7 +122,7 @@ abstract class ImageProvider {
var desiredNameWithoutExtension = if (sourceEntry.path != null) { var desiredNameWithoutExtension = if (sourceEntry.path != null) {
val sourceFileName = File(sourceEntry.path).name val sourceFileName = File(sourceEntry.path).name
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
} else { } else {
sourceUri.lastPathSegment!! sourceUri.lastPathSegment!!
} }
@ -765,6 +765,8 @@ abstract class ImageProvider {
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageProvider>() private val LOG_TAG = LogUtils.createTag<ImageProvider>()
val FILE_EXTENSION_PATTERN = Regex("[.][^.]+$")
val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP) val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP)
// used when skipping a move/creation op because the target file already exists // used when skipping a move/creation op because the target file already exists

View file

@ -345,7 +345,7 @@ class MediaStoreImageProvider : ImageProvider() {
} }
val sourceFileName = sourceFile.name val sourceFileName = sourceFile.name
val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity, activity = activity,
dir = destinationDir, dir = destinationDir,

View file

@ -1,20 +1,20 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import java.util.regex.Pattern
object LogUtils { object LogUtils {
const val LOG_TAG_MAX_LENGTH = 23 const val LOG_TAG_MAX_LENGTH = 23
val LOG_TAG_PACKAGE_PATTERN: Pattern = Pattern.compile("(\\w)(\\w*)\\.")
val LOG_TAG_PACKAGE_PATTERN = Regex("(\\w)(\\w*)\\.")
val LOWER_CASE_PATTERN = Regex("[a-z]")
// create an Android logger friendly log tag for the specified class // create an Android logger friendly log tag for the specified class
inline fun <reified T> createTag(): String { inline fun <reified T> createTag(): String {
val kClass = T::class val kClass = T::class
// shorten class name to "a.b.CccDdd" // shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(kClass.qualifiedName!!).replaceAll("$1.") var logTag = LOG_TAG_PACKAGE_PATTERN.replace(kClass.qualifiedName!!, "$1.")
if (logTag.length > LOG_TAG_MAX_LENGTH) { if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "a.b.CD" // shorten class name to "a.b.CD"
val simpleName = kClass.simpleName!! val simpleName = kClass.simpleName!!
val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "") val shortSimpleName = simpleName.replace(LOWER_CASE_PATTERN, "")
logTag = logTag.replace(simpleName, shortSimpleName) logTag = logTag.replace(simpleName, shortSimpleName)
if (logTag.length > LOG_TAG_MAX_LENGTH) { if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "CD" // shorten class name to "CD"

View file

@ -180,5 +180,5 @@ object MimeTypes {
else -> null else -> null
} }
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
} }

View file

@ -29,6 +29,9 @@ import java.util.regex.Pattern
object StorageUtils { object StorageUtils {
private val LOG_TAG = LogUtils.createTag<StorageUtils>() private val LOG_TAG = LogUtils.createTag<StorageUtils>()
private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/"
private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)")
/** /**
* Volume paths * Volume paths
*/ */
@ -269,8 +272,8 @@ object StorageUtils {
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/ // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/ // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? { fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length) val encoded = treeUri.toString().substring(TREE_URI_ROOT.length)
val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded)) val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded))
with(matcher) { with(matcher) {
if (find()) { if (find()) {
val uuid = group(1) val uuid = group(1)