info: PNG IPTC display
This commit is contained in:
parent
f72b3e775f
commit
90f6c5d841
10 changed files with 105 additions and 23 deletions
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -180,5 +180,5 @@ object MimeTypes {
|
|||
else -> null
|
||||
}
|
||||
|
||||
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue