From 90f6c5d84104db45e9015089179d89f61f26205f Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 12 Oct 2021 09:30:32 +0900 Subject: [PATCH] info: PNG IPTC display --- .../channel/calls/MetadataFetchHandler.kt | 43 +++++++++++---- .../calls/fetchers/ThumbnailFetcher.kt | 2 +- .../channel/streams/ImageByteStreamHandler.kt | 2 +- .../thibault/aves/metadata/Metadata.kt | 3 ++ .../aves/metadata/MetadataExtractorHelper.kt | 53 +++++++++++++++++++ .../aves/model/provider/ImageProvider.kt | 4 +- .../model/provider/MediaStoreImageProvider.kt | 2 +- .../deckers/thibault/aves/utils/LogUtils.kt | 10 ++-- .../deckers/thibault/aves/utils/MimeTypes.kt | 2 +- .../thibault/aves/utils/StorageUtils.kt | 7 ++- 10 files changed, 105 insertions(+), 23 deletions(-) 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 f36232116..6586b9745 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 @@ -34,16 +34,20 @@ import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription 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.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_TIME_DIR_NAME +import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis 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.isGeoTiff +import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeInt 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.utils.LogUtils 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.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isImage 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.UriUtils.tryParseId 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 dir.parent?.name?.let { thisDirName = "$it/$thisDirName" } - val dirMap = metadataMap[thisDirName] ?: HashMap() + var dirMap = metadataMap[thisDirName] ?: HashMap() metadataMap[thisDirName] = dirMap // tags @@ -168,18 +172,35 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } else { 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) { val tagType = tag.tagType if (tagType == PngDirectory.TAG_TEXTUAL_DATA) { val pairs = dir.getObject(tagType) as List<*> - dirMap.putAll(pairs.map { - val kv = it as KeyValuePair - // PNG spec says encoding charset is always Latin-1 / ISO-8859-1 - // but in practice UTF-8 is sometimes used in PNG-iTXt chunks - val charset = if (baseDirName == "PNG-iTXt") StandardCharsets.UTF_8 else kv.value.charset - Pair(kv.key, String(kv.value.bytes, charset)) - }) + val textPairs = pairs.map { pair -> + val kv = pair as KeyValuePair + val key = kv.key + // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 + val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.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 { 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 which case we trust the file extension // 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 } else { dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index aae2b6da4..d3fad82aa 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -79,7 +79,7 @@ class ThumbnailFetcher internal constructor( } else { var errorDetails: String? = exception?.message 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) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 72f5f8017..4dfc6e5af 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -170,7 +170,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun toErrorDetails(e: Exception): String? { val errorDetails = e.message return if (errorDetails?.isNotEmpty() == true) { - errorDetails.split("\n".toRegex(), 2).first() + errorDetails.split(Regex("\n"), 2).first() } else { errorDetails } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 022b16cd5..56e81c0f5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -17,6 +17,8 @@ import java.util.regex.Pattern object Metadata { private val LOG_TAG = LogUtils.createTag() + const val IPTC_MARKER_BYTE: Byte = 0x1c + // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709) // Examples: // "+37.5090+127.0243/" (Samsung) @@ -31,6 +33,7 @@ object Metadata { const val DIR_XMP = "XMP" // from metadata-extractor const val DIR_MEDIA = "Media" // custom const val DIR_COVER_ART = "Cover" // custom + const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom // types of metadata const val TYPE_EXIF = "exif" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 7295af93e..5c84e153b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -1,15 +1,27 @@ package deckers.thibault.aves.metadata import com.drew.lang.Rational +import com.drew.lang.SequentialByteArrayReader import com.drew.metadata.Directory import com.drew.metadata.exif.ExifIFD0Directory +import com.drew.metadata.iptc.IptcReader +import com.drew.metadata.png.PngDirectory import java.text.SimpleDateFormat import java.util.* 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" + private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt" + 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 fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) { @@ -59,4 +71,45 @@ object MetadataExtractorHelper { 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? { + 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 + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 394a22824..15bbc4d8c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -122,7 +122,7 @@ abstract class ImageProvider { var desiredNameWithoutExtension = if (sourceEntry.path != null) { val sourceFileName = File(sourceEntry.path).name - sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") + sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "") } else { sourceUri.lastPathSegment!! } @@ -765,6 +765,8 @@ abstract class ImageProvider { companion object { private val LOG_TAG = LogUtils.createTag() + val FILE_EXTENSION_PATTERN = Regex("[.][^.]+$") + val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP) // used when skipping a move/creation op because the target file already exists diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index d00fe54bd..2f30626a7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -345,7 +345,7 @@ class MediaStoreImageProvider : ImageProvider() { } val sourceFileName = sourceFile.name - val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") + val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "") val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( activity = activity, dir = destinationDir, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt index eb4d5bd93..e7fb5f79b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt @@ -1,20 +1,20 @@ package deckers.thibault.aves.utils -import java.util.regex.Pattern - object LogUtils { 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 inline fun createTag(): String { val kClass = T::class // 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) { // shorten class name to "a.b.CD" val simpleName = kClass.simpleName!! - val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "") + val shortSimpleName = simpleName.replace(LOWER_CASE_PATTERN, "") logTag = logTag.replace(simpleName, shortSimpleName) if (logTag.length > LOG_TAG_MAX_LENGTH) { // shorten class name to "CD" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 412f3a20a..eb2e8bcd4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -180,5 +180,5 @@ object MimeTypes { else -> null } - val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) + val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 74dcf728a..f19896594 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -29,6 +29,9 @@ import java.util.regex.Pattern object StorageUtils { private val LOG_TAG = LogUtils.createTag() + private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/" + private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)") + /** * 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/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/ fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? { - val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length) - val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded)) + val encoded = treeUri.toString().substring(TREE_URI_ROOT.length) + val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded)) with(matcher) { if (find()) { val uuid = group(1)