Reactで、画像を回転・反転して保存する方法
画像の回転と反転は、canvas に描くときの座標の変換だけで作れます。90° か 270° 回すときは canvas の幅と高さを入れ替え、rotate() と scale(-1, 1) で向きを変えてから画像を描きます。保存は、その canvas を toBlob() でファイルにしてダウンロードさせるだけです。
ここでは、画像の向きを表す Orientation と、それを canvas に描く drawOriented、ボタンで向きを変えて保存する App を作りました。気をつけたいのは、回したあとに「左右反転」を押したときです。反転の状態を true/false で持つだけだと、画面では左右ではなく上下が入れ替わります。
検証環境は 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 です。Playwright で Chromium のボタンを押して画像を保存し、保存したファイルを Pillow で同じ操作をした画像と1画素ずつ比べました。画像は写真ではなく、Pillow で描いた 601×401 の画像です。Firefox と Safari、スマートフォンのブラウザ、PNG・JPEG 以外の形式、90° 単位ではない角度の回転は試していません。

向きは「反転するかどうか」と「回転の角度」の2つで持つ
90° 単位の回転と反転の組み合わせは、全部で8通りあります。そこで、向きを「元の画像を左右反転するかどうか」と「そのあと時計回りに何度回すか」の2つで表しました。上下反転は「左右反転してから 180° 回す」と同じなので、別に持つ必要はありません。
export type Rotation = 0 | 90 | 180 | 270;
// 画像の向き。「flip が true なら元の画像を左右反転し、そのあと rotation 度だけ時計回りに回す」と読む
export type Orientation = { rotation: Rotation; flip: boolean };
export const initialOrientation: Orientation = { rotation: 0, flip: false };
const normalize = (deg: number) => (((deg % 360) + 360) % 360) as Rotation;
export const rotateRight = (o: Orientation): Orientation => ({ ...o, rotation: normalize(o.rotation + 90) });
export const rotateLeft = (o: Orientation): Orientation => ({ ...o, rotation: normalize(o.rotation - 90) });
// いま画面に見えている向きのまま、左右を入れ替える
export const flipHorizontal = (o: Orientation): Orientation => ({ rotation: normalize(-o.rotation), flip: !o.flip });
// いま画面に見えている向きのまま、上下を入れ替える
export const flipVertical = (o: Orientation): Orientation => ({ rotation: normalize(180 - o.rotation), flip: !o.flip });
flipHorizontal が flip を切り替えるだけでなく、角度を -rotation にしているのがポイントです。Orientation では反転を回転より先に行うと決めているので、回したあとの画像を画面の左右で入れ替えるには、反転を入れたうえで回転の向きを逆にします。上下反転は、それをさらに 180° 回したものです。
4つの関数は、どれも前の向きを受け取って新しい向きを返します。そのため、setOrientation(rotateRight) のように state の更新用の関数としてそのまま渡せます。
canvas に描くときは、先に原点を動かしてから回す
rotate() は、canvas の原点(左上)を中心に、時計回りに回します。回転の中心は動かせないので、回したあとの画像が左上 (0, 0) からぴったり収まるように、先に translate() で原点を動かしておきます。左右反転は、scale() に -1 を渡すと左右が入れ替わることを使います。
// 向きを変えた画像を canvas に描く。90° と 270° のときは、canvas の幅と高さを入れ替える
export function drawOriented(canvas: HTMLCanvasElement, image: ImageBitmap, { rotation, flip }: Orientation) {
const w = image.width;
const h = image.height;
const sideways = rotation === 90 || rotation === 270;
canvas.width = sideways ? h : w;
canvas.height = sideways ? w : h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas が使えません');
// 回したあとの画像が canvas の左上 (0, 0) からぴったり収まるように、先に原点を動かしておく
if (rotation === 90) ctx.translate(h, 0);
if (rotation === 180) ctx.translate(w, h);
if (rotation === 270) ctx.translate(0, w);
ctx.rotate((rotation * Math.PI) / 180);
if (flip) {
ctx.translate(w, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(image, 0, 0);
}
canvas の width と height を設定し直すと、それまでの絵と変換は消えます。そのため、ボタンを押すたびにこの関数を呼んでも、前の回転が積み重なることはありません。
保存は toBlob とダウンロード用のリンクで行う
保存は、canvas を toBlob() で Blob にし、download 属性を付けたリンクをクリックしてダウンロードさせます。
export function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('画像を書き出せませんでした'))), type, quality);
});
}
// Blob をファイルとしてダウンロードさせる
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
click() の直後に URL.revokeObjectURL() で URL を解放しても、検証環境の Chromium では、試したすべての保存でファイルをダウンロードできました。
ボタンで向きを変えて保存するコンポーネント
App では、選んだファイルを createImageBitmap() で読み込み、向きが変わるたびにプレビューの canvas を drawOriented で描き直します。保存するときは、このプレビューの canvas をそのままファイルにします。表示はCSSで縮めていますが、canvas 自体は元の画像と同じ解像度なので、画面に見えている向きの画像が元の大きさで保存されます。
import { useEffect, useRef, useState } from 'react';
import type { ChangeEvent } from 'react';
import {
drawOriented, flipHorizontal, flipVertical, initialOrientation, rotateLeft, rotateRight,
} from './orientation';
import type { Orientation } from './orientation';
import { canvasToBlob, downloadBlob } from './save';
type Loaded = { bitmap: ImageBitmap; name: string; type: string };
export function App() {
const [image, setImage] = useState<Loaded | null>(null);
const [orientation, setOrientation] = useState<Orientation>(initialOrientation);
const canvasRef = useRef<HTMLCanvasElement>(null);
// 向きが変わるたびに、プレビューの canvas を描き直す
useEffect(() => {
if (image && canvasRef.current) drawOriented(canvasRef.current, image.bitmap, orientation);
}, [image, orientation]);
async function handleFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const bitmap = await createImageBitmap(file);
image?.bitmap.close();
setOrientation(initialOrientation);
setImage({ bitmap, name: file.name, type: file.type });
}
async function handleSave() {
const canvas = canvasRef.current;
if (!image || !canvas) return;
// JPEG は JPEG のまま、それ以外は PNG で保存する
const type = image.type === 'image/jpeg' ? 'image/jpeg' : 'image/png';
const blob = await canvasToBlob(canvas, type, 0.92);
const base = image.name.replace(/\.[^.]+$/, '');
downloadBlob(blob, `${base}-edited.${type === 'image/jpeg' ? 'jpg' : 'png'}`);
}
const sideways = orientation.rotation % 180 !== 0;
return (
<main>
<input type="file" accept="image/*" aria-label="画像を選ぶ" onChange={handleFile} />
{image && (
<>
<div className="toolbar">
<button type="button" onClick={() => setOrientation(rotateLeft)}>左に回す</button>
<button type="button" onClick={() => setOrientation(rotateRight)}>右に回す</button>
<button type="button" onClick={() => setOrientation(flipHorizontal)}>左右反転</button>
<button type="button" onClick={() => setOrientation(flipVertical)}>上下反転</button>
<button type="button" onClick={() => setOrientation(initialOrientation)}>元に戻す</button>
</div>
<canvas ref={canvasRef} className="preview" role="img" aria-label="向きを変えた画像のプレビュー" />
<p className="info">
回転:{orientation.rotation}° / 反転:{orientation.flip ? 'あり' : 'なし'} / 保存する大きさ:
{sideways ? image.bitmap.height : image.bitmap.width}×{sideways ? image.bitmap.width : image.bitmap.height}px
</p>
<button type="button" className="save" onClick={handleSave}>保存する</button>
</>
)}
</main>
);
}
プレビューの canvas には、画面の幅に収まるように次のCSSを当てています。
.preview { display: block; max-width: 100%; max-height: 360px; height: auto; width: auto; border: 1px solid #ccd; }
保存した画像を、Pillow で同じ操作をした画像と比べる
601×401 の PNG を選び、ボタンを押す順番を変えて保存しました。保存したファイルを、Python の画像ライブラリ Pillow で同じ順に回転・反転した画像と1画素ずつ比べた結果の一部です。
右に回す: PNG 401x601 RGBA | 色が違う画素 0 / 241001
左に回す: PNG 401x601 RGBA | 色が違う画素 0 / 241001
上下反転: PNG 601x401 RGBA | 色が違う画素 0 / 241001
右に回す → 左右反転: PNG 401x601 RGBA | 色が違う画素 0 / 241001
右に回す → 上下反転: PNG 401x601 RGBA | 色が違う画素 0 / 241001
左右反転 → 右に回す: PNG 401x601 RGBA | 色が違う画素 0 / 241001
右に回す → 左右反転 → 左に回す → 上下反転: PNG 601x401 RGBA | 色が違う画素 0 / 241001
試した15通りの押し方すべてで、色が違う画素は0でした。幅と高さが奇数の画像でも、回転で画素がぼやけることはありませんでした。「右に回す → 左右反転 → 左に回す → 上下反転」のように、回転と反転が打ち消し合う押し方では、表示も「回転:0° / 反転:なし」に戻ります。保存した PNG は、元の画像が RGB でも、透明度を含む RGBA になっていました。
反転を true/false で持つと、回したあとの左右反転が上下反転になる
比べるために、左右反転と上下反転をそれぞれ flipX・flipY の true/false で持つ書き方でも描いてみました。ボタンを押すたびに値を切り替えるだけなら、右に回してから「左右反転」を押した状態は rotation: 90, flipX: true になります。描くときは、canvas の中心へ原点を動かし、回転してから scale(flipX ? -1 : 1, flipY ? -1 : 1) をかけます。
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
ctx.drawImage(image, -w / 2, -h / 2);
この状態で描いた画像を、期待する画像と比べました。
右に回す → 左右反転: 期待(右に回す → 左右反転)と比べて 色が違う画素 126478 / 241001 | 右に回す → 上下反転 と比べて 色が違う画素 0 / 241001
期待した画像とは半分以上の画素が違い、右に回してから上下反転した画像と完全に一致しました。つまりこの書き方では、flipX は画面の左右ではなく元の画像の左右を入れ替えることになり、90° 回したあとの画面では上下の入れ替えに見えます。最初の節の flipHorizontal のように、画面で見た操作を向きの値に直してから持てば、このずれは起きません。
CSS の transform で回すと、まわりの配置は回る前のまま
プレビューだけなら、<img> に transform: rotate(90deg) を付けても回せます。ただし CSS の transform は、まわりの要素の配置には影響しません。幅 300px の <img> を 90° 回して、まわりの大きさを測りました。
枠(.box): top 0, 幅 304, 高さ 204.15625
img の見た目: top -47.921875, 幅 200.15625, 高さ 300
次の段落: top 220.15625, 幅 560, 高さ 21
画像の見た目は高さ 300px になりましたが、囲んでいる枠の高さは、回す前の画像の高さ(約 200px)に枠線を足した約 204px のままでした。画像は枠の上へ約 48px はみ出し、下は次の段落の上に重なります。canvas に描き直す方法なら、canvas 自体の幅と高さが入れ替わるので、こうしたずれはありません。保存する画像とプレビューが同じ処理で作られるのも利点です。
JPEG で保存したときの画質と、EXIF の向き
元の画像が JPEG なら、toBlob() に 'image/jpeg' と品質 0.92 を渡して JPEG で保存しています。JPEG で保存すると圧縮し直されるので、画素の値は元と同じにはなりません。元の JPEG を右に回して保存した画像を、Pillow で同じく右に回した画像と比べた結果です。
保存した画像: JPEG 401x601 EXIF の Orientation = None
元の JPEG を Pillow で右に回した画像と比べて: 色が違う画素 77735 / 241001、差のいちばん大きい画素で 57(0〜255)
JPEG には、画素は回さずに保存し、表示するときの向きを EXIF の Orientation に書いておく仕組みがあります(ExifTool の Orientation の値の一覧)。createImageBitmap() は、imageOrientation を指定しなければ、この EXIF の向きを反映して読み込みます。ボタンで回した角度が EXIF の向きとどう組み合わさるかを、次の JPEG で確かめました。検証用の画像を左に 90° 回して 401×601 で保存し、Orientation=6(表示するときに時計回りに 90° 回す)を付けたものです。
何も押さない: 回転:0° / 反転:なし / 保存する大きさ:601×401px | 保存したファイル名: exif6-edited.jpg
右に回す: 回転:90° / 反転:なし / 保存する大きさ:401×601px | 保存したファイル名: exif6-edited.jpg
読み込んだ時点で、EXIF の向きを反映した 601×401 として扱われました。保存した画像には Orientation が付いておらず、画素そのものが正しい向きになっていました。元の画像に Pillow で同じ操作をした画像と比べると、ノイズの部分を除いた差の平均は、何も押さずに保存した画像で 1.40、右に回して保存した画像で 1.41 でした。180° 回した画像と比べると、どちらも 51.94 です。ボタンで回した角度は、EXIF の向きを直したあとの見た目に対してかかるので、画面で見たとおりの向きで保存されます。





