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.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) {

View file

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

View file

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

View file

@ -17,6 +17,8 @@ import java.util.regex.Pattern
object 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)
// 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"

View file

@ -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<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) {
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<ImageProvider>()
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

View file

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

View file

@ -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 <reified T> 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"

View file

@ -180,5 +180,5 @@ object MimeTypes {
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 {
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
*/
@ -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)