JavaScript で Exif の Orientation をパースして画像を正しく表示する方法

雑にこの記事で目指すのをまとめると以下の状態です。


  • JavaScript(ブラウザ)だけで対応

  • Exif Orientation のパースをライブラリ使わずやる

  • background-image でも使いたいので CSS transform は使わない

手法だけ知りたい方は下の方にコードがあるのでそれを見てみてください。


前段

iPhone などのスマホのカメラで撮った写真を無邪気にファイルアップロードして表示すると、逆さまになっている…という体験をしたことありますか? 世に出回っているソフトウェアはそんなことないのですが、雑に作ったシステムだとありえます。

例えば以下のようなコード。これはブラウザでファイルを選択した時に画像のプレビューを表示する JavaScript です。よくある実装です。これはたいていの条件下のときには上手くいっているように見えますが、失敗することもあります。

<input type="file" id="file" />

<script>
const embedImageTag = dataURL => {
const img = new Image()
img.src = dataURL
img.width = '200'
document.body.appendChild(img)
}

document.getElementById('file').addEventListener('change', (e) => {
const file = e.target.files[0]
const reader = new FileReader()
reader.addEventListener('load', () => {
embedImageTag(reader.result)
})
reader.readAsDataURL(file)
})
</script>

失敗するケースというのは、iPhone で撮った写真を PC に転送して PC のブラウザでアップロードをすると画像が正しい方向で表示されないことがあります。

example1.jpg

iPhone の Safari では方向を認識して正しき表示してくれます。しかし PC の Safari では認識せずに逆さまに表示されています。これは iPhone で写真を撮ったときの向きに依存して変わります。

example2.jpg

ホームボタンの位置によって方向が変わります。これらの情報は Exif の Orientation というタグに記録されています。Mac のプレビューアプリで開いて情報を見ると確認できます。

というわけで前段が長くなりましたが、今回の記事では写真に記録されている Exif の Orientation を見て正しく写真を表示させるように JavaScript でなんとかする方法を紹介していきます。


Exif をパースする

まず JPG 画像には Exif と呼ばれるデータが記録されている場合があります。カメラで撮るとだいたい付いてきます。まずは Exif があるかどうかを見て、あれば情報を読み取って、Orientation の値を取得するのを目指します。

Exif は仕様が公開されているのでまずはそれを確認してみましょう。

JPG はまず SOI と呼ばれるマーカーから始まります。0xFFD8 の 2byte です。Exif は必ず SOI マーカーの直後に APP1 マーカーを置きます。APP1 メーカーは 0xFFE1 の 2byte です。

つまり JPG 画像の 3-4byte に 0xFFE1 が存在しなかったら Exif はないと判断できます。

スクリーンショット 2018-10-26 12.30.40.png

これが Exif の存在する JPG の先頭バイトコードです。Orientation を取るという目的で重要なのは色を塗ってある APP1 Marker と TIFF Header の ByteOrder の2箇所だけです。ByteOrder では Little Endian か Big Endian かを示しています。パースをする上で重要なのでどちらなのかを取得しておきます。

なお、Mac の Hex Fiend というバイナリエディタアプリがあるので、それを使ってみてみると楽です。

bin.jpg

次に実際に Exif のタグ情報が格納されている IFD セグメントを読んでいきます。この中に欲しい Orientation のタグがあります。

スクリーンショット 2018-10-26 12.43.31.png

最初にフィールドが何個あるのかを示すカウント用の 2byte があります。フィールドは全体で 12byte あるので、このカウントの数 * 12byte ずつ読み取っていきます。

フィールドの中身は Tag / Type / Count / Value Offset の構成になっていて Tag がフィールドの種類を示しています。Orientation の場合は 0x0112 です。つまりカウントの数ループして先頭の 2byte が 0x0112 のフィールドを探してあげればいいことになります。

値は Value Offset を読みます。Type / Count は Orientation の場合は固定で決まっているので読む必要はなく決め打ちで OK です。Value Offset には 1-8 が入っていて、これが写真の方向になります。

スクリーンショット 2018-10-26 12.59.15.png

例えば 3 であれば 180 度回転させる必要があります。6であれば時計方向に 90 度です。


JavaScript で Exif をパース

それでは次に JavaScript で Exif をパースして Orientation の値を取得します。Exif.js という便利ライブラリがありますが Orientation を取るだけにしては大仰なライブラリなので地道にバイト探索を行います。

<input type="file" id="file" />

<script>
const getOrientation = buffer => {
const dv = new DataView(buffer)
let app1MarkerStart = 2
// もし JFIF で APP0 Marker がある場合は APP1 Marker の取得位置をずらす
if (dv.getUint16(app1MarkerStart) !== 65505) {
const length = dv.getUint16(4)
app1MarkerStart += length + 2
}
if (dv.getUint16(app1MarkerStart) !== 65505) {
return 0
}
// エンディアンを取得
const littleEndian = dv.getUint8(app1MarkerStart + 10) === 73
// フィールドの数を確認
const count = dv.getUint16(app1MarkerStart + 18, littleEndian)
for (let i = 0; i < count; i++) {
const start = app1MarkerStart + 20 + i * 12
const tag = dv.getUint16(start, littleEndian)
// Orientation の Tag は 274
if (tag === 274) {
// Orientation は Type が SHORT なので 2byte だけ読む
return dv.getUint16(start + 8, littleEndian)
}
}
return 0
}

document.getElementById('file').addEventListener('change', (e) => {
const file = e.target.files[0]
const reader = new FileReader()
reader.addEventListener('load', () => {
const orientation = getOrientation(reader.result)
console.log(orientation)
})
// ArrayBuffer で読みたいのでこちら
reader.readAsArrayBuffer(file)
})
</script>

画像を ArrayBuffer として使いたいので FileReader API の readAsArrayBuffer を利用します。また、エンディアンを意識するのが面倒くさいので DataView API を使います。これはゲッターにエンディアンを渡すと勝手に計算してくれる便利なやつです。

処理の流れとしては以下のようにしています。


  1. 3-4byte 目に 0xFFE1 (65505) が来なかったら Exif がないので 0 を返却

  2. 13byte 目に 0x49 (72) が来たら LittleEndian

  3. 21byte 目の 2byte でフィールドの数を取得

  4. フィールド 12byte 分をループ

  5. 先頭 2byte が 0x0112 (274) のものを探す

  6. 見つかったら 8byte 先の 2byte を読み取る

これだけです。簡単ですね。Exif すべてをちゃんとパースしようとするとけっこう大変ですが決め打ちで抜くのであればこんな感じでできます。(もしコード間違ってたら教えてください!)


画像を正しい方向にする

JavaScript に限らずブラウザ上で画像を回転させる方法は主に2種類あります。Canvas か CSS transform 。今回は Canvas を利用します。理由は画像を background-image でも利用したいからです。CSS transform は background-image には適用できないので、Canvas で回転させた後に DataURL にして出力するようにします。

つまり、画像アップロード -> Canvas -> 回転 -> DataURL という流れになります。


ArrayBuffer を Canvas へ

Orientation を取得するために ArrayBuffer にしたデータを Canvas へ流し込む必要があります。まず ArrayBuffer を Blob にして、そこから ObjectURL にし Image に変換します。

document.getElementById('file').addEventListener('change', (e) => {

const file = e.target.files[0]
const reader = new FileReader()
reader.addEventListener('load', () => {
const blob = new Blob([reader.result], { type: 'image/jpeg' })
const object = window.URL.createObjectURL(blob)
const img = new Image()
img.src = object
})
reader.readAsArrayBuffer(file)
})

URL.createObjectURL したものは必要なくなったら解放しておきましょう。 URL.revokeObjectURL を適当な場所で呼びます。

Image にできればあとは Canvas に変換するだけです。

const canvas = document.createElement('canvas')

const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)


Canvas を Orientation に応じて回転

Canvas の transform を使って 2-8 に合った回転をします。1 はそのままで OK です。

const createTransformedCanvas = (orientation, img) => {

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if ([5,6,7,8].indexOf(orientation) > -1) {
canvas.width = img.height;
canvas.height = img.width;
} else {
canvas.width = img.width;
canvas.height = img.height;
}
switch (orientation) {
case 2: ctx.transform(-1, 0, 0, 1, img.width, 0); break;
case 3: ctx.transform(-1, 0, 0, -1, img.width, img.height); break;
case 4: ctx.transform(1, 0, 0, -1, 0, img.height); break;
case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
case 6: ctx.transform(0, 1, -1, 0, img.height, 0); break;
case 7: ctx.transform(0, -1, -1, 0, img.height, img.width); break;
case 8: ctx.transform(0, -1, 1, 0, 0, img.width); break;
}
ctx.drawImage(img, 0, 0)
return canvas
}


Canvas を DataURL へ

画像リソースとして使うために Canvas から DataURL へ変換します。

canvas.toDataURL('image/jpeg')

以上で、だいたいの処理は終わりです。

最後にすべての処理をまとめたサンプルコードを掲載しておきます。

<!DOCTYPE html>

<html>
<head>
<meta charset="utf-8">
<title></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div><input type="file" id="file" /></div>
<script>
const getOrientation = buffer => {
const dv = new DataView(buffer)
if (dv.getUint16(2) !== 65505) {
return 0
}
const littleEndian = dv.getUint8(12) === 73
const count = dv.getUint16(20, littleEndian)
for (let i = 0; i < count; i++) {
const start = 22 + i * 12
const tag = dv.getUint16(start, littleEndian)
if (tag === 274) {
const value = dv.getUint16(start + 8, littleEndian)
return value
}
}
return 0
}

const arrayBufferToDataURL = arrBuf => {
const blob = new Blob([arrBuf], { type: 'image/jpeg' })
return window.URL.createObjectURL(blob)
}

const embedImageTag = dataURL => {
const img = new Image()
img.src = dataURL
img.width = '200'
document.body.appendChild(img)
return img
}

const createTransformedCanvas = (orientation, img) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if ([5,6,7,8].indexOf(orientation) > -1) {
canvas.width = img.height
canvas.height = img.width
} else {
canvas.width = img.width
canvas.height = img.height
}
switch (orientation) {
case 2: ctx.transform(-1, 0, 0, 1, img.width, 0); break
case 3: ctx.transform(-1, 0, 0, -1, img.width, img.height); break
case 4: ctx.transform(1, 0, 0, -1, 0, img.height); break
case 5: ctx.transform(0, 1, 1, 0, 0, 0); break
case 6: ctx.transform(0, 1, -1, 0, img.height, 0); break
case 7: ctx.transform(0, -1, -1, 0, img.height, img.width); break
case 8: ctx.transform(0, -1, 1, 0, 0, img.width); break
}
ctx.drawImage(img, 0, 0)
return canvas
}

document.getElementById('file').addEventListener('change', (e) => {
const file = e.target.files[0]
const reader = new FileReader()
reader.addEventListener('load', () => {
const orientation = getOrientation(reader.result)
if (orientation === 0 || orientation === 1) {
const data = arrayBufferToDataURL(reader.result)
const img = embedImageTag(data)
img.addEventListener('load', () => {
window.URL.revokeObjectURL(data)
})
} else {
const img = new Image()
img.src = arrayBufferToDataURL(reader.result)
img.addEventListener('load', () => {
const canvas = createTransformedCanvas(orientation, img)
window.URL.revokeObjectURL(img.src)
embedImageTag(canvas.toDataURL('image/jpeg'))
})
}
})
reader.readAsArrayBuffer(file)
})

</script>
</body>
</html>

以上で JavaScript で画像の方向を認識して正しく表示することができました。