diff --git a/android/build.gradle b/android/build.gradle index ac72c92..e3bc546 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -64,5 +64,5 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation "androidx.exifinterface:exifinterface:1.3.7" + implementation "androidx.exifinterface:exifinterface:1.4.2" } diff --git a/android/src/main/java/com/lodev09/exify/ExifFallback.kt b/android/src/main/java/com/lodev09/exify/ExifFallback.kt new file mode 100644 index 0000000..6a0ffe8 --- /dev/null +++ b/android/src/main/java/com/lodev09/exify/ExifFallback.kt @@ -0,0 +1,245 @@ +package com.lodev09.exify + +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * IFD0 tags that ExifInterface may miss when an image editor + * (e.g. ON1 Photo RAW) places them inside the ExifIFD instead of IFD0. + * + * Maps EXIF tag number → ExifInterface tag name. + */ +private val FALLBACK_TAGS = + mapOf( + 0x010E to "ImageDescription", + 0x010F to "Make", + 0x0110 to "Model", + 0x0112 to "Orientation", + 0x011A to "XResolution", + 0x011B to "YResolution", + 0x0128 to "ResolutionUnit", + 0x0131 to "Software", + 0x013B to "Artist", + 0x8298 to "Copyright", + ) + +private const val IFD_FORMAT_SHORT = 3 +private const val IFD_FORMAT_LONG = 4 +private const val IFD_FORMAT_RATIONAL = 5 +private const val IFD_FORMAT_STRING = 2 +private const val IFD_FORMAT_UNDEFINED = 7 +private const val EXIF_IFD_POINTER_TAG = 0x8769 + +/** + * Scans raw JPEG bytes for IFD0 tags that may have been placed in + * the ExifIFD. Returns a map of tag name → value for any tags found. + */ +fun readFallbackTags( + inputStream: InputStream, + missingTags: Set, +): Map { + if (missingTags.isEmpty()) return emptyMap() + + val neededTagNumbers = FALLBACK_TAGS.filterValues { it in missingTags }.keys + if (neededTagNumbers.isEmpty()) return emptyMap() + + val bytes = readExifSegment(inputStream) ?: return emptyMap() + val result = mutableMapOf() + + val app1 = findApp1Exif(bytes) ?: return emptyMap() + val tiffOffset = app1.tiffOffset + val buf = ByteBuffer.wrap(bytes) + buf.order(app1.byteOrder) + + // Read IFD0 to find ExifIFD offset (offsets are relative to TIFF start) + val ifd0Offset = buf.getInt(tiffOffset + 4) + val exifIfdOffset = findExifIfdOffset(buf, tiffOffset, ifd0Offset) ?: return emptyMap() + + // Scan ExifIFD entries for our missing tags + scanIfd(buf, tiffOffset, exifIfdOffset, neededTagNumbers, result) + + return result +} + +/** + * Reads only the JPEG header segments up to and including the EXIF APP1, + * avoiding reading the full image file into memory. + */ +private fun readExifSegment(inputStream: InputStream): ByteArray? { + val dis = java.io.DataInputStream(inputStream) + val header = ByteArray(2) + dis.readFully(header) + if (header[0] != 0xFF.toByte() || header[1] != 0xD8.toByte()) return null + + val out = java.io.ByteArrayOutputStream(65536) + out.write(header) + + val segHeader = ByteArray(4) + while (true) { + try { + dis.readFully(segHeader) + } catch (_: java.io.EOFException) { + break + } + val marker = segHeader[1].toInt() and 0xFF + val segLen = ((segHeader[2].toInt() and 0xFF) shl 8) or (segHeader[3].toInt() and 0xFF) + + if (segHeader[0] != 0xFF.toByte() || segLen < 2) break + + out.write(segHeader) + val segData = ByteArray(segLen - 2) + dis.readFully(segData) + out.write(segData) + + // Found EXIF APP1 — we have enough + if (marker == 0xE1 && segData.size >= 6 && + segData[0] == 0x45.toByte() && + segData[1] == 0x78.toByte() && + segData[2] == 0x69.toByte() && + segData[3] == 0x66.toByte() + ) { + break + } + + // Stop if we hit SOS or image data + if (marker == 0xDA) break + } + + return out.toByteArray() +} + +private data class App1Info( + val tiffOffset: Int, + val byteOrder: ByteOrder, +) + +private fun findApp1Exif(bytes: ByteArray): App1Info? { + if (bytes.size < 4 || bytes[0] != 0xFF.toByte() || bytes[1] != 0xD8.toByte()) return null + + var pos = 2 + while (pos + 4 < bytes.size) { + if (bytes[pos] != 0xFF.toByte()) return null + val marker = bytes[pos + 1].toInt() and 0xFF + + // Read segment length (big-endian) + val segLen = ((bytes[pos + 2].toInt() and 0xFF) shl 8) or (bytes[pos + 3].toInt() and 0xFF) + + if (marker == 0xE1 && segLen >= 8) { + // Check for "Exif\0\0" + if (pos + 10 < bytes.size && + bytes[pos + 4] == 0x45.toByte() && // E + bytes[pos + 5] == 0x78.toByte() && // x + bytes[pos + 6] == 0x69.toByte() && // i + bytes[pos + 7] == 0x66.toByte() && // f + bytes[pos + 8] == 0x00.toByte() && + bytes[pos + 9] == 0x00.toByte() + ) { + val tiffOffset = pos + 10 + val order = + if (bytes[tiffOffset] == 0x49.toByte() && bytes[tiffOffset + 1] == 0x49.toByte()) { + ByteOrder.LITTLE_ENDIAN + } else { + ByteOrder.BIG_ENDIAN + } + return App1Info(tiffOffset, order) + } + } + + pos += 2 + segLen + } + return null +} + +private fun findExifIfdOffset( + buf: ByteBuffer, + tiffOffset: Int, + ifdOffset: Int, +): Int? { + if (ifdOffset < 0 || tiffOffset + ifdOffset + 2 > buf.limit()) return null + + val count = buf.getShort(tiffOffset + ifdOffset).toInt() and 0xFFFF + for (i in 0 until count) { + val entryOffset = tiffOffset + ifdOffset + 2 + i * 12 + if (entryOffset + 12 > buf.limit()) return null + + val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF + if (tagNumber == EXIF_IFD_POINTER_TAG) { + return buf.getInt(entryOffset + 8) + } + } + return null +} + +private fun scanIfd( + buf: ByteBuffer, + tiffOffset: Int, + ifdOffset: Int, + neededTagNumbers: Set, + result: MutableMap, +) { + val absOffset = tiffOffset + ifdOffset + if (absOffset + 2 > buf.limit()) return + + val count = buf.getShort(absOffset).toInt() and 0xFFFF + for (i in 0 until count) { + val entryOffset = absOffset + 2 + i * 12 + if (entryOffset + 12 > buf.limit()) return + + val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF + if (tagNumber !in neededTagNumbers) continue + + val format = buf.getShort(entryOffset + 2).toInt() and 0xFFFF + val componentCount = buf.getInt(entryOffset + 4) + if (componentCount <= 0) continue + + val tagName = FALLBACK_TAGS[tagNumber] ?: continue + + when (format) { + IFD_FORMAT_SHORT -> { + if (componentCount != 1) continue + val value = buf.getShort(entryOffset + 8).toInt() and 0xFFFF + if (value == 0) continue + result[tagName] = value + } + + IFD_FORMAT_LONG -> { + if (componentCount != 1) continue + val value = buf.getInt(entryOffset + 8).toLong() and 0xFFFFFFFFL + if (value == 0L) continue + result[tagName] = value.toInt() + } + + IFD_FORMAT_RATIONAL -> { + if (componentCount != 1) continue + val dataOffset = tiffOffset + buf.getInt(entryOffset + 8) + if (dataOffset < 0 || dataOffset + 8 > buf.limit()) continue + val numerator = buf.getInt(dataOffset).toLong() and 0xFFFFFFFFL + val denominator = buf.getInt(dataOffset + 4).toLong() and 0xFFFFFFFFL + if (denominator == 0L) continue + result[tagName] = numerator.toDouble() / denominator.toDouble() + } + + IFD_FORMAT_STRING, IFD_FORMAT_UNDEFINED -> { + if (componentCount > 1024) continue + val dataOffset = + if (componentCount <= 4) { + entryOffset + 8 + } else { + tiffOffset + buf.getInt(entryOffset + 8) + } + if (dataOffset < 0 || dataOffset + componentCount > buf.limit()) continue + + val strBytes = ByteArray(componentCount) + buf.position(dataOffset) + buf.get(strBytes) + + var len = strBytes.size + while (len > 0 && strBytes[len - 1] == 0.toByte()) len-- + if (len == 0) continue + + result[tagName] = String(strBytes, 0, len, Charsets.UTF_8).trim() + } + } + } +} diff --git a/android/src/main/java/com/lodev09/exify/ExifyModule.kt b/android/src/main/java/com/lodev09/exify/ExifyModule.kt index 1171124..1327411 100644 --- a/android/src/main/java/com/lodev09/exify/ExifyModule.kt +++ b/android/src/main/java/com/lodev09/exify/ExifyModule.kt @@ -16,6 +16,7 @@ import com.facebook.react.modules.core.PermissionAwareActivity import com.facebook.react.modules.core.PermissionListener import com.facebook.react.util.RNLog import com.lodev09.exify.ExifyUtils.formatTags +import java.io.File import java.io.IOException private const val ERROR_TAG = "E_EXIFY_ERROR" @@ -72,35 +73,77 @@ class ExifyModule( promise: Promise, ) { try { - val inputStream = - if (scheme == "http" || scheme == "https") { - java.net.URL(uri).openStream() - } else if (scheme == "content" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - try { - context.contentResolver.openInputStream(MediaStore.setRequireOriginal(photoUri)) - } catch (e: SecurityException) { - context.contentResolver.openInputStream(photoUri) - } + val exif = + if (scheme == "file") { + ExifInterface(photoUri.path!!) } else { - context.contentResolver.openInputStream(photoUri) + val inputStream = + if (scheme == "content" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + context.contentResolver.openInputStream(MediaStore.setRequireOriginal(photoUri)) + } catch (_: SecurityException) { + context.contentResolver.openInputStream(photoUri) + } + } else if (scheme == "http" || scheme == "https") { + java.net.URL(uri).openStream() + } else { + context.contentResolver.openInputStream(photoUri) + } + + if (inputStream == null) { + RNLog.w(context, "Exify: Could not open URI: $uri") + promise.reject(ERROR_TAG, "Could not open URI: $uri") + return + } + + inputStream.use { ExifInterface(it) } } - if (inputStream == null) { - RNLog.w(context, "Exify: Could not open URI: $uri") - promise.reject(ERROR_TAG, "Could not open URI: $uri") - return - } + val tags = formatTags(exif) - inputStream.use { - val tags = formatTags(ExifInterface(it)) - promise.resolve(tags) + // ExifInterface ignores IFD0 tags placed in ExifIFD (non-standard but common + // with some image editors). Fall back to raw EXIF parsing for missing tags. + val missingTags = + IFD0_FALLBACK_TAGS + .filter { + if (!tags.hasKey(it)) return@filter true + val type = tags.getType(it) + (type == ReadableType.Number && tags.getDouble(it) == 0.0) + }.toSet() + if (missingTags.isNotEmpty()) { + val fallback = + try { + openInputStream(uri, photoUri, scheme)?.use { readFallbackTags(it, missingTags) } + } catch (_: Exception) { + null + } + fallback?.forEach { (tag, value) -> + when (value) { + is String -> tags.putString(tag, value) + is Int -> tags.putInt(tag, value) + is Double -> tags.putDouble(tag, value) + } + } } + + promise.resolve(tags) } catch (e: Exception) { RNLog.w(context, "Exify: ${e.message}") promise.reject(ERROR_TAG, e.message, e) } } + private fun openInputStream( + uri: String, + photoUri: Uri, + scheme: String, + ): java.io.InputStream? = + when (scheme) { + "file" -> File(photoUri.path!!).inputStream() + "content" -> context.contentResolver.openInputStream(photoUri) + else -> java.net.URL(uri).openStream() + } + @Throws(IOException::class) override fun write( uri: String, @@ -141,7 +184,10 @@ class ExifyModule( exif.setAttribute(tag, value.toBigDecimal().toPlainString()) } } - else -> exif.setAttribute(tag, tags.getDouble(tag).toInt().toString()) + + else -> { + exif.setAttribute(tag, tags.getDouble(tag).toInt().toString()) + } } } @@ -150,7 +196,9 @@ class ExifyModule( } ReadableType.Array -> { - exif.setAttribute(tag, tags.getArray(tag).toString()) + val arr = tags.getArray(tag)!! + val values = (0 until arr.size()).joinToString(", ") { arr.getInt(it).toString() } + exif.setAttribute(tag, values) } else -> { diff --git a/android/src/main/java/com/lodev09/exify/ExifyTags.kt b/android/src/main/java/com/lodev09/exify/ExifyTags.kt index 7535e9d..3445b03 100644 --- a/android/src/main/java/com/lodev09/exify/ExifyTags.kt +++ b/android/src/main/java/com/lodev09/exify/ExifyTags.kt @@ -2,6 +2,24 @@ package com.lodev09.exify import androidx.exifinterface.media.ExifInterface +/** + * IFD0 tags that ExifInterface may fail to read when they are + * incorrectly placed inside the ExifIFD by some image editors. + */ +val IFD0_FALLBACK_TAGS = + setOf( + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_COPYRIGHT, + ExifInterface.TAG_IMAGE_DESCRIPTION, + ExifInterface.TAG_SOFTWARE, + ExifInterface.TAG_ORIENTATION, + ExifInterface.TAG_X_RESOLUTION, + ExifInterface.TAG_Y_RESOLUTION, + ExifInterface.TAG_RESOLUTION_UNIT, + ) + /** * Supported Exif Tags * Note: Latitude, Longitude and Altitude tags are updated separately @@ -64,13 +82,14 @@ val EXIFY_TAGS = arrayOf("double", ExifInterface.TAG_FOCAL_LENGTH), arrayOf("string", ExifInterface.TAG_LENS_MAKE), arrayOf("string", ExifInterface.TAG_LENS_MODEL), + arrayOf("string", ExifInterface.TAG_BODY_SERIAL_NUMBER), arrayOf("array", ExifInterface.TAG_LENS_SPECIFICATION), arrayOf("int", ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), arrayOf("int", ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT), arrayOf("double", ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION), arrayOf("double", ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION), arrayOf("int", ExifInterface.TAG_GAIN_CONTROL), - arrayOf("string", ExifInterface.TAG_ISO_SPEED_RATINGS), + arrayOf("int_array", ExifInterface.TAG_ISO_SPEED_RATINGS), arrayOf("string", ExifInterface.TAG_IMAGE_UNIQUE_ID), arrayOf("int", ExifInterface.TAG_LIGHT_SOURCE), arrayOf("string", ExifInterface.TAG_MAKER_NOTE), diff --git a/android/src/main/java/com/lodev09/exify/ExifyUtils.kt b/android/src/main/java/com/lodev09/exify/ExifyUtils.kt index 6421bd1..59f63e7 100644 --- a/android/src/main/java/com/lodev09/exify/ExifyUtils.kt +++ b/android/src/main/java/com/lodev09/exify/ExifyUtils.kt @@ -2,11 +2,11 @@ package com.lodev09.exify import androidx.exifinterface.media.ExifInterface import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap object ExifyUtils { @JvmStatic - fun formatTags(exif: ExifInterface): ReadableMap { + fun formatTags(exif: ExifInterface): WritableMap { val tags = Arguments.createMap() for ((type, tag) in EXIFY_TAGS) { @@ -18,13 +18,23 @@ object ExifyUtils { } "int" -> { - tags.putInt(tag, exif.getAttributeInt(tag, 0)) + val intVal = exif.getAttributeInt(tag, 0) + if (tag == ExifInterface.TAG_ORIENTATION && intVal == 0) continue + tags.putInt(tag, intVal) } "double" -> { tags.putDouble(tag, exif.getAttributeDouble(tag, 0.0)) } + "int_array" -> { + val array = Arguments.createArray() + attribute.split(", ").forEach { part -> + part.trim().toIntOrNull()?.let { array.pushInt(it) } + } + if (array.size() > 0) tags.putArray(tag, array) + } + "array" -> { val array = Arguments.createArray() exif.getAttributeRange(tag)?.forEach { value -> diff --git a/ios/Exify.mm b/ios/Exify.mm index 178588d..6900cf3 100644 --- a/ios/Exify.mm +++ b/ios/Exify.mm @@ -43,6 +43,17 @@ static void addTagEntries(CFStringRef dictionary, NSDictionary *metadata, if (![value isKindOfClass:[NSDictionary class]] && ![key isEqualToString:compressionKey]) { tags[key] = value; + + // Also emit standard EXIF names for CGImageSource-specific keys + if ([key isEqualToString:@"PixelWidth"]) { + tags[@"PixelXDimension"] = value; + } else if ([key isEqualToString:@"PixelHeight"]) { + tags[@"PixelYDimension"] = value; + } else if ([key isEqualToString:@"DPIWidth"]) { + tags[@"XResolution"] = value; + } else if ([key isEqualToString:@"DPIHeight"]) { + tags[@"YResolution"] = value; + } } } diff --git a/src/types.ts b/src/types.ts index 7e415b5..eb3f6bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,13 +63,14 @@ export interface ExifTags { LightSource?: number; UserComment?: string; GainControl?: number; - ISOSpeedRatings?: string; + ISOSpeedRatings?: number[]; FocalPlaneResolutionUnit?: number; FocalPlaneXResolution?: number; YCbCrCoefficients?: number; FocalLengthIn35mmFilm?: number; LensMake?: string; LensModel?: string; + BodySerialNumber?: string; LensSpecification?: number[]; ISO?: number; FlashpixVersion?: number[];