Skip to content

Instantly share code, notes, and snippets.

@CPatchane
Last active May 3, 2024 15:41
Show Gist options
  • Save CPatchane/bcd523298e64b1fa813cfae82b0f2b42 to your computer and use it in GitHub Desktop.
Save CPatchane/bcd523298e64b1fa813cfae82b0f2b42 to your computer and use it in GitHub Desktop.
getaround-tech-blog_exif-data-manipulation-javascript
// Gist used for this getaround tech article: https://getaround.tech/exif-data-manipulation-javascript/
const getUpdatedImage = (image, onReady) => {
const reader = new FileReader()
reader.addEventListener("load", ({ target }) => {
if (!target) throw new Error("no blob found")
const { result: buffer } = target
if (!buffer || typeof buffer === "string") {
throw new Error("not a valid JPEG")
}
const view = new DataView(buffer)
let offset = 0
const SOI = 0xFFD8
if (view.getUint16(offset) !== SOI) throw new Error("not a valid JPEG")
const SOS = 0xFFDA
const APP1 = 0xFFE1
// We can skip the last two bytes 0000 and just read the four first bytes
const EXIF = 0x45786966
const LITTLE_ENDIAN = 0x4949
const BIG_ENDIAN = 0x4D4D
const TAG_ID_EXIF_SUB_IFD_POINTER = 0x8769
const TAG_ID_ORIENTATION = 0x0112
const newOrientationValue = 1
const TAG_ID_EXIF_IMAGE_WIDTH = 0xA002
const TAG_ID_EXIF_IMAGE_HEIGHT = 0xA003
const newWidthValue = 1920
const newHeightValue = 1080
let marker
// The first two bytes (offset 0-1) was the SOI marker
offset += 2
while (marker !== SOS) {
marker = view.getUint16(offset)
const size = view.getUint16(offset + 2)
if (marker === APP1 && view.getUint32(offset + 4) === EXIF) {
// The APP1 here is at the very beginning of the file
// So at this point offset = 2,
// + 10 to skip to the bytes after the Exif word
offset += 10
let isLittleEndian = null
if (view.getUint16(offset) === LITTLE_ENDIAN) isLittleEndian = true
if (view.getUint16(offset) === BIG_ENDIAN) isLittleEndian = false
if (!isLittleEndian) throw new Error("invalid endian")
// From now, the endianness must be specify each time we read bytes
// 42
if (view.getUint16(offset + 2, isLittleEndian) !== 0x2a) {
throw new Error("invalid endian")
}
// At this point offset = 12
// IFD0 offset is given by the next 4 bytes after 42
const ifd0Offset = view.getUint32(offset + 4, isLittleEndian)
const ifd0TagsCount = view.getUint16(offset + ifd0Offset, isLittleEndian)
// IFD0 ends after the two-byte tags count word + all the tags
const endOfIFD0TagsOffset = offset + ifd0Offset + 2 + ifd0TagsCount * 12
// To store the Exif IFD offset
let exifSubIfdOffset = 0
for (
let i = offset + ifd0Offset + 2;
i < endOfIFD0TagsOffset;
i += 12
) {
// First 2 bytes = tag type
const tagId = view.getUint16(i, isLittleEndian)
// If Orientation tag
if (tagId === TAG_ID_ORIENTATION) {
// Then 2 bytes for the tag type
// 3 = SHORT type
if (view.getUint16(i + 2, isLittleEndian) !== 3) {
throw new Error("Wrong orientation data type")
}
// Then 4 bytes for the count
if (view.getUint32(i + 4, isLittleEndian) !== 1) {
throw new Error("Wrong orientation data count")
}
// Since it's a SHORT, 2 bytes must be written
view.setUint16(i + 8, newOrientationValue, isLittleEndian)
}
// If ExifIFD offset tag
if (tagId === TAG_ID_EXIF_SUB_IFD_POINTER) {
// It's a LONG, so 4 bytes must be read
exifSubIfdOffset = view.getUint32(i + 8, isLittleEndian)
}
}
if (exifSubIfdOffset) {
const exifSubIfdTagsCount = view.getUint16(offset + exifSubIfdOffset, isLittleEndian)
// This IFD also ends after the two-byte tags count word + all the tags
const endOfExifSubIfdTagsOffset =
offset +
exifSubIfdOffset +
2 +
exifSubIfdTagsCount * 12
for (
let i = offset + exifSubIfdOffset + 2;
i < endOfExifSubIfdTagsOffset;
i += 12
) {
// First 2 bytes = tag type
const tagId = view.getUint16(i, isLittleEndian)
// If wanted tags found
if (tagId === TAG_ID_EXIF_IMAGE_WIDTH || tagId === TAG_ID_EXIF_IMAGE_HEIGHT) {
// Then 2 bytes for the tag type
// 3 = SHORT type
if (view.getUint16(i + 2, isLittleEndian) !== 4) {
throw new Error("Wrong data type")
}
// Then 4 bytes for the count
if (view.getUint32(i + 4, isLittleEndian) !== 1) {
throw new Error("Wrong data count")
}
if (tagId === TAG_ID_EXIF_IMAGE_WIDTH) {
// Since it's a LONG, 4 bytes must be written
view.setUint32(i + 8, newWidthValue, isLittleEndian)
} else if (tagId === TAG_ID_EXIF_IMAGE_HEIGHT) {
// Since it's a LONG, 4 bytes must be written
view.setUint32(i + 8, newHeightValue, isLittleEndian)
}
}
}
}
return onReady(new Blob([view]))
}
// We skip the entire segment (header of 2 bytes + size of the segment)
offset += 2 + size
}
return
})
// The image is given here as a a Blob, but readAsArrayBuffer can also take a File
reader.readAsArrayBuffer(image)
}
// 200x200 image
const base64ImageUrl = "https://picsum.photos/id/314/200.jpg"
fetch(base64ImageUrl)
.then(res => res.blob())
.then((imageBlob) => getUpdatedImage(imageBlob, (newImageBlob) => {
// Exif data (ExifImageWidth and ExifImageHeight) now display 1920x1080
// You might want to change other metadata like size or image width/height
const dataURL = URL.createObjectURL(newImageBlob)
const link = document.createElement("a")
link.download = "update-exif-test.jpeg"
link.href = dataURL
link.click()
}))
@CPatchane
Copy link
Author

CPatchane commented May 3, 2024

Uncaught RangeError: Offset is outside the bounds of the DataView

@TimurAUG. Thanks for the feedback, are you sure you're running it on a true JPEG image? Things are completely different for other image types like PNG or WEBP, metadata are encoded differently.
This piece of code was for this article that is focusing only on JPEG images.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment