Reactで、複数の画像を並べて1枚にまとめる方法

​​React​Canvas API,File API,Playwright,TypeScript

複数の画像を1枚にまとめるには、1つの canvas に drawImage() で画像を順に描き、toBlob() で PNG にします。手間がかかるのは、大きさも縦横比も違う画像を、どの辺でそろえて置くかを決める部分です。React では、選んだ画像と並べる順番を別々の state に持つと、並べ替えたときに画像を解放してしまう失敗を避けられます。

補足

検証環境は 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 です。.tsx を tsc で型チェックしてから esbuild でビルドし、StrictMode で包んだ開発用のビルドを Playwright で操作して、保存された PNG の大きさと画素の色を確かめました。試す画像は、SVG で描いた 1200×800、600×900、800×800、1600×900 の4枚です。Firefox、Safari、スマートフォンのブラウザ、JPEG での保存、写真の向き(Exif)の情報を持つ画像、ドラッグでの並べ替えは試していません。

ファイルを選ぶボタン、選んだ4つのファイル名の一覧(2つめ以降に↑ボタン)、並べ方・すき間・背景の設定、まとめた画像、「PNGで保存」ボタンが縦に並んだ画面。並べ方を「格子に並べる」に変えると、横一列だった1〜4の画像が2×2に並び、画像の枠がピンクになる。3-square.png の↑を押すと3が2つめに移り、一覧のその行と画像の枠がピンクになる。保存を押すと「collage.png(1248×1248)を保存しました」がピンクで出る。下の帯に、何回目の操作かが出ている
並べ方の変更、並べ替え、保存の3回の操作。画像が変わった瞬間と保存した瞬間に、枠や表示がピンクになる

並べ方は、どの辺をそろえるかで決まる

大きさの違う画像を並べるとき、すべてを同じ大きさに引き伸ばすと、縦横比がくずれます。ここでは並べ方を3通り用意し、それぞれ次のようにそろえました。

  • 横に並べる:高さをそろえ、幅は元の縦横比から決める
  • 縦に並べる:幅をそろえ、高さは元の縦横比から決める
  • 格子に並べる:正方形のマスに、はみ出す部分を切り落として入れる

どこに何を描くかの計算は、canvas を使わない関数 layoutCollage() にしました。画像の幅と高さの一覧を受け取り、まとめた画像の大きさと、1枚ごとの「元の画像のどこを、まとめた画像のどこに描くか」を返します。

ts
export type Layout = 'row' | 'column' | 'grid';

export type CollageOptions = {
  layout: Layout;
  size: number; // 横並びなら高さ、縦並びなら幅、格子なら1マスの一辺(px)
  gap: number; // 画像どうしの間と、外側の余白(px)
  background: string;
  maxSide: number; // 書き出す画像の長い辺の上限(px)
};

type Size = { width: number; height: number };
// 元の画像のどこ(s)を、まとめた画像のどこ(d)に描くか
export type Placement = { sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number };

// 1マスを埋めるように、元の画像の真ん中を切り抜く範囲
function coverCrop(img: Size, cw: number, ch: number) {
  const scale = Math.max(cw / img.width, ch / img.height);
  const sw = cw / scale;
  const sh = ch / scale;
  return { sx: (img.width - sw) / 2, sy: (img.height - sh) / 2, sw, sh };
}

export function layoutCollage(images: Size[], o: CollageOptions): Size & { items: Placement[] } {
  const { size, gap } = o;
  const items: Placement[] = [];
  let width = 0;
  let height = 0;

  if (o.layout === 'row') {
    // 高さをそろえ、幅は元の縦横比のまま
    let x = gap;
    for (const img of images) {
      const dw = Math.round((img.width * size) / img.height);
      items.push({ sx: 0, sy: 0, sw: img.width, sh: img.height, dx: x, dy: gap, dw, dh: size });
      x += dw + gap;
    }
    width = x;
    height = size + gap * 2;
  } else if (o.layout === 'column') {
    // 幅をそろえ、高さは元の縦横比のまま
    let y = gap;
    for (const img of images) {
      const dh = Math.round((img.height * size) / img.width);
      items.push({ sx: 0, sy: 0, sw: img.width, sh: img.height, dx: gap, dy: y, dw: size, dh });
      y += dh + gap;
    }
    width = size + gap * 2;
    height = y;
  } else {
    // 正方形のマスに、はみ出す部分を切り落として入れる
    const cols = Math.ceil(Math.sqrt(images.length));
    const rows = Math.ceil(images.length / cols);
    images.forEach((img, i) => {
      const dx = gap + (i % cols) * (size + gap);
      const dy = gap + Math.floor(i / cols) * (size + gap);
      items.push({ ...coverCrop(img, size, size), dx, dy, dw: size, dh: size });
    });
    width = gap + cols * (size + gap);
    height = gap + rows * (size + gap);
  }

  // 長い辺が上限を超えたら、全体を同じ割合で縮める
  const k = Math.min(1, o.maxSide / Math.max(width, height));
  if (k < 1) {
    for (const it of items) {
      it.dx *= k; it.dy *= k; it.dw *= k; it.dh *= k;
    }
    width = Math.round(width * k);
    height = Math.round(height * k);
  }
  return { width, height, items };
}

検証環境で、4枚の画像を高さ 800px・すき間 16px で横に並べると、まとめた画像は 4035×832 になりました。600×900 の縦長の画像は、高さを 800px にそろえたので、幅が 533px になっています。縦に並べたときは 832×3063、600px のマスで格子に並べたときは 1248×1248 でした。

上に、1〜4の番号を描いた4枚の画像を、高さをそろえて横一列に並べた画像(4035×832)。左下に、幅をそろえて縦一列に並べた画像(832×3063)。右下に、2×2の正方形のマスに並べた画像(1248×1248)。格子では、1と4の白い枠の左右、2の白い枠の上下が切り落とされている
同じ4枚を、3通りの並べ方でまとめて保存した画像

格子の列の数は、画像の枚数の平方根を切り上げた数にし、行の数はそこから割り出しています。4枚なら2列×2行です。3枚でも2列×2行になり、検証環境では、空いた右下のマスが背景の色のまま残りました。

格子では、真ん中を正方形に切り抜く

格子のマスに入れるときは、coverCrop() で元の画像から切り抜く範囲を決めています。マスをすき間なく埋めるように、縦と横の拡大率のうち大きいほうを使い、はみ出す分を左右か上下から同じだけ切り落とします。

切り抜いた範囲を描くには、drawImage() に引数を9つ渡す書き方を使います。画像の次の4つ(sx、sy、sWidth、sHeight)が元の画像から切り出す範囲で、最後の4つ(dx、dy、dWidth、dHeight)が canvas のどこに、どの大きさで描くかです。

検証環境で 600px のマスに入れたとき、元の画像から切り出した範囲は次のとおりでした。

元の画像 切り出した範囲
1200×800 x=200 から幅 800、高さ 800(左右を 200px ずつ)
600×900 y=150 から幅 600、高さ 600(上下を 150px ずつ)
800×800 全体
1600×900 x=350 から幅 900、高さ 900(左右を 350px ずつ)

横並びと縦並びでは、元の画像の全体(sx、sy が 0、幅と高さが元のまま)を、縮めた大きさで描いています。

書き出せる大きさには、ブラウザの上限がある

横に並べる画像が増えると、まとめた画像はどんどん横に長くなります。1200×800 の画像を6枚、高さ 800px で横に並べると 7312×832 です。

canvas の大きさの上限は、ブラウザと環境によって違います。MDN の canvas 要素の説明(canvas の最大の大きさの節)には、多くの場合 10,000×10,000 を超えるものの、iOS の端末では 4,096×4,096 までに制限されると書かれています。

検証環境の Chromium で、一色で塗った canvas を toBlob() で書き出したところ、次のようになりました。

text
65535×832:canvas の大きさ 65535×832、toBlob の結果 Blob 1006579 バイト(246ms)、canvasToBlob では 成功
65536×832:canvas の大きさ 65536×832、toBlob の結果 null(0ms)、canvasToBlob では 失敗「画像を書き出せませんでした」
16384×16384:canvas の大きさ 16384×16384、toBlob の結果 Blob 5012119 バイト(733ms)、canvasToBlob では 成功
16384×16385:canvas の大きさ 16384×16385、toBlob の結果 null(0ms)、canvasToBlob では 失敗「画像を書き出せませんでした」

上限を超えても、canvas.width と canvas.height には指定した値が入ったままで、例外も出ません。toBlob() のコールバックに null が渡されるだけです。MDN の toBlob() の説明にも、何らかの理由で画像を作れないときは null が渡されることがある、と書かれています。

そこで layoutCollage() では、長い辺が maxSide を超えたら、置く位置と大きさを同じ割合で縮めています。コンポーネントでは maxSide を 4096 にしました。1200×800 を6枚横に並べた例では、まとめた画像が 4096×466 になり、1枚目は 672×448 で描かれました。縮めるのは描く先(dx、dy、dw、dh)だけで、切り出す範囲はそのままです。

canvas に描いて PNG で保存する

描く関数と、保存に使う2つの関数は次のとおりです。保存の2つは、以前の記事「Reactで、画像を回転・反転して保存する方法」で使ったものと、関数の本体が同じです。

ts
export function drawCollage(canvas: HTMLCanvasElement, images: ImageBitmap[], o: CollageOptions) {
  const { width, height, items } = layoutCollage(images, o);
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d')!;
  ctx.fillStyle = o.background;
  ctx.fillRect(0, 0, width, height);
  ctx.imageSmoothingQuality = 'high';
  items.forEach((p, i) => ctx.drawImage(images[i], p.sx, p.sy, p.sw, p.sh, p.dx, p.dy, p.dw, p.dh));
}

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);
  });
}

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);
}

drawCollage() は、canvas の大きさを layoutCollage() の結果に合わせ、背景の色で塗ってから、画像を1枚ずつ描きます。背景を先に塗っておくと、すき間と、格子の空いたマスがその色になります。検証環境で背景を黒、すき間を 0 にすると、まとめた画像は 3955×800 になり、左上の隅には1枚目の画像の色がそのまま出ました。すき間を 16px に戻すと、外側の余白が黒になりました。

縮めて描くときの補間の質を高くするため、imageSmoothingQuality を high にしています。

画像を選んで並べるコンポーネント

tsx
import { useEffect, useId, useRef, useState } from 'react';
import { canvasToBlob, downloadBlob, drawCollage, type Layout } from './collage';

type Picked = { name: string; bitmap: ImageBitmap };

export function ImageCollage() {
  const [picked, setPicked] = useState<Picked[]>([]); // 選んだ画像(選んだ順)
  const [order, setOrder] = useState<number[]>([]); // 並べる順(picked の番号)
  const [layout, setLayout] = useState<Layout>('row');
  const [gap, setGap] = useState(16);
  const [background, setBackground] = useState('#ffffff');
  const [message, setMessage] = useState('');
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const id = useId();

  // 選び直したり、画面から取り除かれたりしたら、前の ImageBitmap を解放する
  useEffect(() => () => picked.forEach((p) => p.bitmap.close()), [picked]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || order.length === 0) return;
    const size = layout === 'grid' ? 600 : 800;
    const bitmaps = order.map((i) => picked[i].bitmap);
    drawCollage(canvas, bitmaps, { layout, size, gap, background, maxSide: 4096 });
  }, [picked, order, layout, gap, background]);

  async function handleFiles(list: FileList | null) {
    const files = Array.from(list ?? []).filter((f) => f.type.startsWith('image/'));
    if (files.length === 0) return;
    // Promise.all の結果は、読み込みが終わった順ではなく、渡した順に並ぶ
    const bitmaps = await Promise.all(files.map((f) => createImageBitmap(f)));
    setPicked(bitmaps.map((bitmap, i) => ({ name: files[i].name, bitmap })));
    setOrder(files.map((_, i) => i));
    setMessage('');
  }

  // 並びの pos 番目を、1つ前へ移す
  function moveForward(pos: number) {
    setOrder((prev) => {
      const next = [...prev];
      [next[pos - 1], next[pos]] = [next[pos], next[pos - 1]];
      return next;
    });
  }

  async function save() {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const blob = await canvasToBlob(canvas, 'image/png');
    downloadBlob(blob, 'collage.png');
    setMessage(`collage.png(${canvas.width}×${canvas.height})を保存しました`);
  }

  return (
    <div className="collage">
      <p>
        <label htmlFor={`${id}-files`}>画像(複数選べます)</label>
        <input id={`${id}-files`} type="file" accept="image/*" multiple onChange={(e) => handleFiles(e.target.files)} />
      </p>
      {order.length > 0 && (
        <ol className="order">
          {order.map((i, pos) => (
            <li key={i}>
              {picked[i].name}
              {pos > 0 && (
                <button type="button" onClick={() => moveForward(pos)} aria-label={`${picked[i].name} を前へ`}>
                  ↑
                </button>
              )}
            </li>
          ))}
        </ol>
      )}
      <p className="row">
        <label>
          並べ方
          <select value={layout} onChange={(e) => setLayout(e.target.value as Layout)}>
            <option value="row">横に並べる</option>
            <option value="column">縦に並べる</option>
            <option value="grid">格子に並べる</option>
          </select>
        </label>
        <label>
          すき間
          <input type="number" min={0} max={80} value={gap} onChange={(e) => setGap(Number(e.target.value) || 0)} />
        </label>
        <label>
          背景
          <input type="color" value={background} onChange={(e) => setBackground(e.target.value)} />
        </label>
      </p>
      {order.length > 0 && <canvas ref={canvasRef} aria-label="まとめた画像" />}
      <p>
        <button type="button" onClick={save} disabled={order.length === 0}>PNGで保存</button>
      </p>
      <p className="message" aria-live="polite">{message}</p>
    </div>
  );
}

ファイルを選ぶ欄に multiple 属性を付けると、一度に複数のファイルを選べます。選んだファイルは createImageBitmap() でまとめて読み込みます。

読み込みは並行して進むので、終わる順番は選んだ順とは限りません。検証環境で、6000×4000 の大きな画像を1枚目にして小さな画像2枚と一緒に読み込むと、終わった順は2枚目、3枚目、1枚目でした。それでも Promise.all() の結果は、終わった順ではなく渡した順に並ぶので、選んだ順のまま並べられます。

text
終わった順:2-portrait.png → 3-square.png → big.png(6000×4000)
Promise.all の結果の順:6000×4000 → 600×900 → 800×800

state は、選んだ画像の配列 picked と、並べる順番 order に分けました。order には picked の中の番号を入れ、↑ボタンでは order だけを入れ替えます。検証環境で 3-square.png の↑を押すと、一覧と格子の並びが「1、3、2、4」に変わり、保存した PNG でも2つめのマスに3の画像が入っていました。

設定が変わるたびに、useEffect の中で drawCollage() を呼び、最初から描き直します。描いた結果は state に持たず、canvas に残しています。開発用のビルドの StrictMode で、並べ方・並べ替え・すき間・背景・選び直しをひととおり操作し、ページでエラーが出ないことを確かめました。

並べ替えで、使っている画像を解放してしまう書き方

ImageBitmap は、使い終わったら close() で解放できます。React の公式ドキュメントにあるクリーンアップの説明のとおり、useEffect のクリーンアップは、依存する値が変わって Effect が動き直す前と、コンポーネントが取り除かれるときに呼ばれます。上のコンポーネントでは、この仕組みを使って、picked が変わったときと画面から取り除かれるときに前の画像を解放しています。

ここで、画像の配列を1つの state に持ち、並べ替えも同じ state で行うと、うまくいきません。比べるために、次の書き方を試しました。

tsx
// images が変わるたびに、前の配列の ImageBitmap を解放する
useEffect(() => () => images.forEach((bm) => bm.close()), [images]);

// 1枚目と2枚目を入れ替える(同じ ImageBitmap を入れた、新しい配列になる)
function swap() {
  setImages((prev) => [prev[1], prev[0], ...prev.slice(2)]);
}

React の state の配列は、書き換えずに新しい配列を作って更新します。並べ替えても新しい配列になるので、images が変わったとみなされ、クリーンアップが前の配列の画像を close() します。ところが、新しい配列に入っているのは同じ ImageBitmap です。検証環境では、入れ替えたあとの drawImage() で次のエラーが出て、画面からコンポーネントがすべて消えました。

text
InvalidStateError: Failed to execute 'drawImage' on 'CanvasRenderingContext2D': The image source is detached

close() したあとの ImageBitmap は、幅も高さも 0 になっていました。選んだ画像と並べる順番を別の state にしておけば、並べ替えで変わるのは order だけなので、クリーンアップは選び直したときにしか動きません。

​Posted by 86