Skip to content

Commit dcc5a97

Browse files
committed
fix(android): read IFD0 tags misplaced in ExifIFD
Some image editors (e.g. ON1 Photo RAW) place IFD0 tags like Make, Model, Artist, Copyright inside ExifIFD. Android's ExifInterface ignores them there. Add a lightweight fallback JPEG parser that scans the ExifIFD for these missing tags.
1 parent 7295517 commit dcc5a97

3 files changed

Lines changed: 225 additions & 2 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package com.lodev09.exify
2+
3+
import java.io.InputStream
4+
import java.nio.ByteBuffer
5+
import java.nio.ByteOrder
6+
7+
/**
8+
* IFD0 string tags that ExifInterface may miss when an image editor
9+
* (e.g. ON1 Photo RAW) places them inside the ExifIFD instead of IFD0.
10+
*
11+
* Maps EXIF tag number → ExifInterface tag name.
12+
*/
13+
private val FALLBACK_TAGS =
14+
mapOf(
15+
0x010F to "Make",
16+
0x0110 to "Model",
17+
0x013B to "Artist",
18+
0x8298 to "Copyright",
19+
0x010E to "ImageDescription",
20+
0x0131 to "Software",
21+
)
22+
23+
private const val IFD_FORMAT_STRING = 2
24+
private const val IFD_FORMAT_UNDEFINED = 7
25+
private const val EXIF_IFD_POINTER_TAG = 0x8769
26+
27+
/**
28+
* Scans raw JPEG bytes for IFD0 string tags that may have been placed in
29+
* the ExifIFD. Returns a map of tag name → value for any tags found.
30+
*/
31+
fun readFallbackTags(
32+
inputStream: InputStream,
33+
missingTags: Set<String>,
34+
): Map<String, String> {
35+
if (missingTags.isEmpty()) return emptyMap()
36+
37+
val neededTagNumbers = FALLBACK_TAGS.filterValues { it in missingTags }.keys
38+
if (neededTagNumbers.isEmpty()) return emptyMap()
39+
40+
val bytes = readExifSegment(inputStream) ?: return emptyMap()
41+
val result = mutableMapOf<String, String>()
42+
43+
val app1 = findApp1Exif(bytes) ?: return emptyMap()
44+
val tiffOffset = app1.tiffOffset
45+
val buf = ByteBuffer.wrap(bytes)
46+
buf.order(app1.byteOrder)
47+
48+
// Read IFD0 to find ExifIFD offset (offsets are relative to TIFF start)
49+
val ifd0Offset = buf.getInt(tiffOffset + 4)
50+
val exifIfdOffset = findExifIfdOffset(buf, tiffOffset, ifd0Offset) ?: return emptyMap()
51+
52+
// Scan ExifIFD entries for our missing tags
53+
scanIfd(buf, tiffOffset, exifIfdOffset, neededTagNumbers, result)
54+
55+
return result
56+
}
57+
58+
/**
59+
* Reads only the JPEG header segments up to and including the EXIF APP1,
60+
* avoiding reading the full image file into memory.
61+
*/
62+
private fun readExifSegment(inputStream: InputStream): ByteArray? {
63+
val dis = java.io.DataInputStream(inputStream)
64+
val header = ByteArray(2)
65+
dis.readFully(header)
66+
if (header[0] != 0xFF.toByte() || header[1] != 0xD8.toByte()) return null
67+
68+
val out = java.io.ByteArrayOutputStream(65536)
69+
out.write(header)
70+
71+
val segHeader = ByteArray(4)
72+
while (true) {
73+
try {
74+
dis.readFully(segHeader)
75+
} catch (_: java.io.EOFException) {
76+
break
77+
}
78+
val marker = segHeader[1].toInt() and 0xFF
79+
val segLen = ((segHeader[2].toInt() and 0xFF) shl 8) or (segHeader[3].toInt() and 0xFF)
80+
81+
if (segHeader[0] != 0xFF.toByte() || segLen < 2) break
82+
83+
out.write(segHeader)
84+
val segData = ByteArray(segLen - 2)
85+
dis.readFully(segData)
86+
out.write(segData)
87+
88+
// Found EXIF APP1 — we have enough
89+
if (marker == 0xE1 && segData.size >= 6 &&
90+
segData[0] == 0x45.toByte() &&
91+
segData[1] == 0x78.toByte() &&
92+
segData[2] == 0x69.toByte() &&
93+
segData[3] == 0x66.toByte()
94+
) {
95+
break
96+
}
97+
98+
// Stop if we hit SOS or image data
99+
if (marker == 0xDA) break
100+
}
101+
102+
return out.toByteArray()
103+
}
104+
105+
private data class App1Info(val tiffOffset: Int, val byteOrder: ByteOrder)
106+
107+
private fun findApp1Exif(bytes: ByteArray): App1Info? {
108+
if (bytes.size < 4 || bytes[0] != 0xFF.toByte() || bytes[1] != 0xD8.toByte()) return null
109+
110+
var pos = 2
111+
while (pos + 4 < bytes.size) {
112+
if (bytes[pos] != 0xFF.toByte()) return null
113+
val marker = bytes[pos + 1].toInt() and 0xFF
114+
115+
// Read segment length (big-endian)
116+
val segLen = ((bytes[pos + 2].toInt() and 0xFF) shl 8) or (bytes[pos + 3].toInt() and 0xFF)
117+
118+
if (marker == 0xE1 && segLen >= 8) {
119+
// Check for "Exif\0\0"
120+
if (pos + 10 < bytes.size &&
121+
bytes[pos + 4] == 0x45.toByte() && // E
122+
bytes[pos + 5] == 0x78.toByte() && // x
123+
bytes[pos + 6] == 0x69.toByte() && // i
124+
bytes[pos + 7] == 0x66.toByte() && // f
125+
bytes[pos + 8] == 0x00.toByte() &&
126+
bytes[pos + 9] == 0x00.toByte()
127+
) {
128+
val tiffOffset = pos + 10
129+
val order =
130+
if (bytes[tiffOffset] == 0x49.toByte() && bytes[tiffOffset + 1] == 0x49.toByte()) {
131+
ByteOrder.LITTLE_ENDIAN
132+
} else {
133+
ByteOrder.BIG_ENDIAN
134+
}
135+
return App1Info(tiffOffset, order)
136+
}
137+
}
138+
139+
pos += 2 + segLen
140+
}
141+
return null
142+
}
143+
144+
private fun findExifIfdOffset(
145+
buf: ByteBuffer,
146+
tiffOffset: Int,
147+
ifdOffset: Int,
148+
): Int? {
149+
if (ifdOffset < 0 || tiffOffset + ifdOffset + 2 > buf.limit()) return null
150+
151+
val count = buf.getShort(tiffOffset + ifdOffset).toInt() and 0xFFFF
152+
for (i in 0 until count) {
153+
val entryOffset = tiffOffset + ifdOffset + 2 + i * 12
154+
if (entryOffset + 12 > buf.limit()) return null
155+
156+
val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF
157+
if (tagNumber == EXIF_IFD_POINTER_TAG) {
158+
return buf.getInt(entryOffset + 8)
159+
}
160+
}
161+
return null
162+
}
163+
164+
private fun scanIfd(
165+
buf: ByteBuffer,
166+
tiffOffset: Int,
167+
ifdOffset: Int,
168+
neededTagNumbers: Set<Int>,
169+
result: MutableMap<String, String>,
170+
) {
171+
val absOffset = tiffOffset + ifdOffset
172+
if (absOffset + 2 > buf.limit()) return
173+
174+
val count = buf.getShort(absOffset).toInt() and 0xFFFF
175+
for (i in 0 until count) {
176+
val entryOffset = absOffset + 2 + i * 12
177+
if (entryOffset + 12 > buf.limit()) return
178+
179+
val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF
180+
if (tagNumber !in neededTagNumbers) continue
181+
182+
val format = buf.getShort(entryOffset + 2).toInt() and 0xFFFF
183+
val componentCount = buf.getInt(entryOffset + 4)
184+
185+
if (format != IFD_FORMAT_STRING && format != IFD_FORMAT_UNDEFINED) continue
186+
if (componentCount <= 0 || componentCount > 1024) continue
187+
188+
val dataOffset =
189+
if (componentCount <= 4) {
190+
entryOffset + 8
191+
} else {
192+
tiffOffset + buf.getInt(entryOffset + 8)
193+
}
194+
195+
if (dataOffset < 0 || dataOffset + componentCount > buf.limit()) continue
196+
197+
val strBytes = ByteArray(componentCount)
198+
buf.position(dataOffset)
199+
buf.get(strBytes)
200+
201+
// Trim trailing null bytes
202+
var len = strBytes.size
203+
while (len > 0 && strBytes[len - 1] == 0.toByte()) len--
204+
if (len == 0) continue
205+
206+
val tagName = FALLBACK_TAGS[tagNumber] ?: continue
207+
result[tagName] = String(strBytes, 0, len, Charsets.UTF_8).trim()
208+
}
209+
}

android/src/main/java/com/lodev09/exify/ExifyTags.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@ package com.lodev09.exify
22

33
import androidx.exifinterface.media.ExifInterface
44

5+
/**
6+
* IFD0 string tags that ExifInterface may fail to read when they are
7+
* incorrectly placed inside the ExifIFD by some image editors.
8+
*/
9+
val IFD0_STRING_TAGS =
10+
setOf(
11+
ExifInterface.TAG_MAKE,
12+
ExifInterface.TAG_MODEL,
13+
ExifInterface.TAG_ARTIST,
14+
ExifInterface.TAG_COPYRIGHT,
15+
ExifInterface.TAG_IMAGE_DESCRIPTION,
16+
ExifInterface.TAG_SOFTWARE,
17+
)
18+
519
/**
620
* Supported Exif Tags
721
* Note: Latitude, Longitude and Altitude tags are updated separately

android/src/main/java/com/lodev09/exify/ExifyUtils.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ package com.lodev09.exify
22

33
import androidx.exifinterface.media.ExifInterface
44
import com.facebook.react.bridge.Arguments
5-
import com.facebook.react.bridge.ReadableMap
5+
import com.facebook.react.bridge.WritableMap
66

77
object ExifyUtils {
88
@JvmStatic
9-
fun formatTags(exif: ExifInterface): ReadableMap {
9+
fun formatTags(exif: ExifInterface): WritableMap {
1010
val tags = Arguments.createMap()
1111

1212
for ((type, tag) in EXIFY_TAGS) {

0 commit comments

Comments
 (0)