Reactで、ファイルの先頭のバイトから画像の種類を調べる方法
ファイルの名前を photo.png に変えれば、中身が JPEG でもテキストでも、ブラウザの file.type は image/png になります。中身の種類を知るには、ファイルの先頭の数バイトを読み、PNG なら 89 50 4E 47 0D 0A 1A 0A のような、形式ごとに決まった並び(シグネチャ)と比べます。React では、ファイルを選んだら file.slice(0, 16) で先頭だけを読み、結果を画面に出します。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。画像のファイルは sharp 0.35.5 で作り、BMP などは手で組み立てました。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright でファイルを選んで表示を確かめています。Firefox、Safari、スマートフォンのブラウザ、ICO・カーソル・HEIC のファイルは試していません。

file.type は拡張子から決まる
<input type="file"> で選んだファイルの type は、ファイルの中身ではなく拡張子から決まります。MDN の Blob.type の説明にも、PNG のファイルの名前を .txt に変えると text/plain になること、これだけを確認の手段にしないことが書かれています。
検証環境の Chromium で、名前が .png のファイルを3つ選んだときの file.type と、先頭のバイトは次のとおりです。
| ファイル | 中身 | file.type |
先頭のバイト |
|---|---|---|---|
cat.png |
PNG | image/png |
89 50 4e 47 0d 0a 1a 0a … |
jpeg-renamed.png |
JPEG | image/png |
ff d8 ff db … |
text-renamed.png |
hello と改行 |
image/png |
68 65 6c 6c 6f 0a |
3つとも image/png です。拡張子のない PNG(cat)を選ぶと、file.type は空の文字列でした。
<input> の accept 属性も、選ばれたファイルの種類を確かめるものではありません。MDN には、ブラウザがユーザーを正しい種類のファイルへ導くためのヒントであり、多くの場合、ユーザーはファイルを選ぶ画面の設定を切り替えれば、ほかのファイルも選べると書かれています。
形式ごとの先頭のバイト
ブラウザが中身から画像の種類を判断するときのシグネチャは、WHATWG の MIME Sniffing の仕様にある、画像の種類を判定する表にまとまっています。
| 種類 | 先頭のバイト |
|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| JPEG | FF D8 FF |
| GIF | GIF87a または GIF89a の文字 |
| WebP | RIFF、任意の4バイト、WEBPVP の文字 |
| BMP | BM の文字 |
| ICO・カーソル | 00 00 01 00・00 00 02 00 |
この表に AVIF はありません。AVIF は ftyp というボックスで形式を示す作りで、AVIF の仕様では、その中に avif(静止画)や avis(連続した画像)というブランドを書くと決められています。検証環境で sharp が書き出した AVIF は、5バイト目から ftyp、9バイト目から avif が並んでいました。
00 00 00 1c 66 74 79 70 61 76 69 66 00 00 00 00
f t y p a v i f
先頭のバイトを読んで、表と比べる
判定の部分は、React から切り離して関数にしておきます。
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | 'image/bmp' | 'image/x-icon' | 'image/avif';
// null は「どの値でもよい」バイト
type Signature = { type: ImageType; bytes: (number | null)[] };
const ascii = (s: string) => Array.from(s, (c) => c.charCodeAt(0));
const any = (n: number) => Array<null>(n).fill(null);
const SIGNATURES: Signature[] = [
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
{ type: 'image/gif', bytes: ascii('GIF87a') },
{ type: 'image/gif', bytes: ascii('GIF89a') },
{ type: 'image/webp', bytes: [...ascii('RIFF'), ...any(4), ...ascii('WEBPVP')] },
{ type: 'image/bmp', bytes: ascii('BM') },
{ type: 'image/x-icon', bytes: [0x00, 0x00, 0x01, 0x00] },
{ type: 'image/x-icon', bytes: [0x00, 0x00, 0x02, 0x00] },
// AVIF:5バイト目からの ftyp の文字に続く、最初のブランドが avif か avis
{ type: 'image/avif', bytes: [...any(4), ...ascii('ftypavif')] },
{ type: 'image/avif', bytes: [...any(4), ...ascii('ftypavis')] },
];
export const HEAD_SIZE = 16;
export function matchImageType(head: Uint8Array): ImageType | null {
const hit = SIGNATURES.find(
(s) => head.length >= s.bytes.length && s.bytes.every((b, i) => b === null || head[i] === b),
);
return hit?.type ?? null;
}
// ファイル全体ではなく、先頭の16バイトだけを読む
export async function readHead(file: Blob): Promise<Uint8Array> {
return new Uint8Array(await file.slice(0, HEAD_SIZE).arrayBuffer());
}
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(' ');
}
シグネチャは数値の配列で持ち、WebP の大きさの部分のように値が決まっていないバイトは null にしています。matchImageType() は、表の上から順に比べて、最初に合った種類を返します。ファイルが短く、シグネチャの長さに届かないときは合わなかったことにします。空のファイルは長さが足りずにどれにも合わず、text-renamed.png も合うシグネチャがなく、どちらも null になりました。
先頭を読むには、Blob.slice() で先頭の16バイトだけを切り出してから、arrayBuffer() で読みます。File は Blob を元にしているので、同じメソッドが使えます。
先頭だけを読むとどれだけ速いかを、200MB のファイルで測りました。3回とも、slice(0, 16) を読むのは 2.4〜3.6ms、ファイル全体を arrayBuffer() で読むのは 255.7〜950.7ms でした。種類を調べるだけなら、全体を読む必要はありません。
ファイルを選んだら、useEffect で調べる
コンポーネントは次のとおりです。
import { useEffect, useId, useState } from 'react';
import { matchImageType, readHead, toHex, type ImageType } from './imageType';
type Result = { file: File; head: string; detected: ImageType | null };
export function ImageTypeCheck() {
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<Result | null>(null);
const id = useId();
useEffect(() => {
if (!file) return;
let ignore = false;
readHead(file).then((head) => {
if (!ignore) setResult({ file, head: toHex(head), detected: matchImageType(head) });
});
return () => {
ignore = true; // 別のファイルを選んだら、前のファイルの結果は捨てる
};
}, [file]);
const current = result?.file === file ? result : null;
return (
<div className="image-type">
<p>
<label htmlFor={id}>画像ファイル</label>
<input id={id} type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</p>
{file && (
<section aria-live="polite">
<p className="name">{file.name}</p>
<p>拡張子から決まった type:<code className="declared">{file.type || '(空)'}</code></p>
{current && (
<>
<p>先頭のバイト:<code className="head">{current.head || '(なし)'}</code></p>
<p>中身から調べた種類:<code className="detected">{current.detected ?? '画像ではない'}</code></p>
<Verdict declared={file.type} detected={current.detected} />
</>
)}
</section>
)}
</div>
);
}
function Verdict({ declared, detected }: { declared: string; detected: ImageType | null }) {
if (!detected) return <p className="verdict ng">対応している画像ではありません</p>;
if (!declared) return <p className="verdict warn">拡張子から種類が分かりません(中身は {detected})</p>;
if (declared !== detected) return <p className="verdict warn">拡張子と中身が違います(中身は {detected})</p>;
return <p className="verdict ok">拡張子と中身が一致しています</p>;
}
state に持つのは、選んだファイル(file)と、調べた結果(result)の2つです。arrayBuffer() は Promise を返すので、読み終わる前に別のファイルが選ばれることがあります。そこで結果に「どのファイルを調べたものか」も入れ、今選んでいるファイルの結果だけを表示します。
読み込みは useEffect の中で始めます。ファイルが変わると、React は前の Effect のクリーンアップ関数を呼んでから、新しい Effect を動かします。クリーンアップで ignore を true にしておけば、前のファイルの結果があとから届いても state に入りません。React の公式ドキュメントにある、データを取得する Effect でのクリーンアップと同じ書き方です。
検証環境で11個のファイルを順に選んだときの表示は、次のとおりです。左から、ファイル名、file.type、中身から調べた種類、画面に出た判定です。
cat.png | image/png | image/png | 拡張子と中身が一致しています
cat.jpg | image/jpeg | image/jpeg | 拡張子と中身が一致しています
cat.gif | image/gif | image/gif | 拡張子と中身が一致しています
cat.webp | image/webp | image/webp | 拡張子と中身が一致しています
cat.avif | image/avif | image/avif | 拡張子と中身が一致しています
cat.bmp | image/bmp | image/bmp | 拡張子と中身が一致しています
jpeg-renamed.png | image/png | image/jpeg | 拡張子と中身が違います(中身は image/jpeg)
text-renamed.png | image/png | 画像ではない | 対応している画像ではありません
empty.png | image/png | 画像ではない | 対応している画像ではありません
cat | (空) | image/png | 拡張子から種類が分かりません(中身は image/png)
mif1-brand.avif | image/avif | 画像ではない | 対応している画像ではありません
最後の mif1-brand.avif は、ftyp の最初のブランドが mif1 で、互換ブランドの一覧にだけ avif を書いた、ヘッダーだけのファイルです。この記事の SIGNATURES は最初のブランドしか見ないので、AVIF とは判定しません。AVIF の仕様では avif か avis のブランドが ftyp に載っていればよいので、このような並びのファイルも受け付けたいときは、2つめ以降のブランドも調べる必要があります。
先頭が合っていても、画像として読めるとは限らない
シグネチャで分かるのは、ファイルがその形式の書き出しで始まっていることだけです。先頭の16バイトを cat.png からコピーし、そのあとに 200MB の乱数を続けたファイルは、ImageTypeCheck では image/png で「拡張子と中身が一致しています」と出ました。
画像として読めるかは、ブラウザに実際に読ませて確かめます。検証環境で各ファイルを createImageBitmap() に渡した結果は、次のとおりです。
cat.png 64×64
cat.jpg 64×64
cat.gif 64×64
cat.webp 64×64
cat.avif 64×64
cat.bmp 1×1
jpeg-renamed.png 64×64
text-renamed.png InvalidStateError
mif1-brand.avif InvalidStateError
large-200mb.png InvalidStateError
先頭だけ PNG の 200MB のファイル(large-200mb.png)は、InvalidStateError で読めませんでした。名前が .png で中身が JPEG のファイルは、64×64 の画像として読めています。
ブラウザでの判定は、ファイルを選んだ人にその場で知らせるためのものです。MDN の accept 属性の説明にあるとおり、アップロードを受け付けるときは、サーバー側でも種類を確かめます。





