3.8 簡易画像編集ツール

(1/1)
簡易画像編集ツール
AI生成コンテンツ / AI-generated content
撮った写真には、切り落としたい部分や隠したい情報が写り込むことがある。大きさや保存形式を変えたい場合もある。本項では、画像1枚のトリミング、90度回転、モザイク、縦横比を保ったリサイズ、形式変換を行う「簡易画像編集ツール」(QuickPic.html)を作る。パソコンでは画像をドラッグするか「読み込み」を押し、スマートフォンでは「読み込み」をタップして使う。処理はブラウザ内で完結し、画像を外部へ送らない。
作成の動機に続き、画像を小さな点の集まりとして扱うCanvas API、画面と画像の座標の違い、マウスと指を共通に扱う仕組みを説明する。そのうえで各編集処理と保存方法を読み解き、最後にCodexへ渡すプログラム仕様書を示す。初めて読む場合は各項目の説明と図を追い、細かなコードやBMPの組み立ては後から読み返してもよい。処理の目的と確認すべき結果を理解することが、開発を進める手掛かりになる。

目次

作成の動機

外出先でスマホで撮影した写真を、その場で傾きを直してトリミングしたり、表札や車のナンバーが読めるのでぼかしたい、4000ピクセルを超える大きさのままでは重いので縮めたい、ページに載せるならHEICやJPGではなくWEBPにしたい‥‥これらは、Windowsに付属するペイントでできることに、モザイクとWEBP出力を加えた程度の作業である。ところが、出先のパソコンにペイントの入ったWindowsがあるとは限らず、スマートフォンだけのときもある。かといって、この程度の作業のために本格的な画像編集ソフトを入れる気にはならない。既存の手段を下表に比較する。
写真を軽く加工する手段の比較
手段できること困る点
Windowsのペイント、フォトトリミング、回転、リサイズ、形式変換Windowsが必要。モザイクはない
スマートフォンの標準の編集機能トリミング、回転、明るさ調整モザイクや形式変換は機種によって異なる
オンラインの画像編集サービスほぼすべてサービスによっては画像をサーバへ送る必要がある
GIMP、Photoshopなどの編集ソフトほぼすべて導入が必要で、起動も操作も重い
簡易画像編集ツール(本項)トリミング、回転、モザイク、リサイズ、形式変換1枚ずつしか扱えない。やり直しができない
この表のうち、私がもっとも気にしたのは画像のアップロードが必要なオンラインサービスの「画像をサーバへ送る」という点である。モザイクをかける目的は、多くの場合、写り込んだ個人情報を隠すことである。隠したい情報が含まれた画像を、他人の運用するサーバへ送ってから隠すのでは順序が逆である。送信前の画像がどこにどれだけ残るかを、利用者の側から確かめる手立てはない。

そこで、HTML・CSS・JavaScriptを1つのファイルにまとめ、ブラウザの中だけで完結する形にした。ファイルをブラウザで開けば動き、HTTPサーバもインターネット接続も必要としない。画像は端末の外に出ない。一度に扱えるのは1枚だけで、操作を戻す機能も付けていない。まとめて処理したい場合は、「5.5 画像一括リサイズ・変換ツール」で作るWindowsアプリの方が向いている。

Canvas APIで画像をピクセルとして扱う

写真を思い切り拡大すると、細かい四角形が格子状に並んでいるのが見える。この四角形1つがピクセル(pixel、画素)である。横4000、縦3000の写真とは、ピクセルが横に4000個、縦に3000個並んだもので、合計1200万個になる。1個のピクセルは、赤(Red)・緑(Green)・青(Blue)の強さと、不透明度(Alpha)の4つの数値で表す。それぞれ0から255までの整数で、頭文字を並べてRGBAと呼ぶ。赤は (255, 0, 0, 255)、白は (255, 255, 255, 255) である。

つまり、画像の加工とは、この数値の並びを書き換える作業である。ところが、Webページで画像を表示する <img> 要素は表示専用で、中の数値を読んだり書いたりできない。そこで使うのがCanvas APIである。canvas(キャンバス)は「画布」を意味し、「2.3 スペースインベーダーゲームを作る」ではプログラムから自由に描ける「デジタルの黒板」として紹介した。本項では、そこに描いた結果の数値を読み書きする道具として使う。

<canvas> 要素は、置いただけでは何も表示されない。描画するには、コンテキスト(context、文脈)と呼ばれる操作窓口を取り出す。
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
getContext('2d') は「平面の絵を描く窓口をください」という指示である。以降、線を引く、画像を貼る、ピクセルを読むといった操作は、すべてこの ctx を通して行う。2つ目に渡した willReadFrequently: true は、「このcanvasはピクセルを何度も読み出す使い方をする」という予告である。ブラウザは通常、描画を速くするため画面制御用の部品(GPU)に絵を預けるが、そこから数値を読み戻すのは遅い。この予告があると、読み出しやすい場所に絵を置くようになる。QuickPicはトリミングやモザイクでピクセルを繰り返し読むため、これを指定している。

読み込んだ画像をcanvasに貼り付けるのは、次の3行である。
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
ctx.drawImage(image, 0, 0);
naturalWidth、naturalHeight は、画面上の表示寸法ではなく、画像が本来持っているピクセル数である。canvasの width と height にこれを代入し、drawImage() で左上(座標0, 0)を基準に貼り付ける。canvasの座標は左上が原点で、右へ進むとxが増え、下へ進むとyが増える。数学のグラフとは上下が逆である点に注意したい。

なお、canvasの width・height を代入し直すと、それまで描かれていた内容はすべて消える。この性質は、後述するトリミングやリサイズで「canvasを作り直してから描き戻す」という手順の理由になっている。QuickPicは編集途中の画像をこのcanvas1枚だけで持ち、読み込んだ元画像を別に保存していない。履歴を持つ場合よりメモリを節約できるが、この構成のままでは「1手戻す」ことはできない。
写真を拡大するとピクセルの格子になり、1ピクセルがRGBAの4つの数値で表される図

ドラッグ&ドロップとBlob URLで画像を読み込む

QuickPicには画像の入り口が2つある。1つは「読み込み」ボタンで、これは「3.6 重複データ検出・並べ替え」で解説したFile APIの仕組みそのままである。もう1つがドラッグ&ドロップで、エクスプローラーや写真アプリから画像を画面上に放り込む方式である。手元にファイル選択ダイアログを開く手間がなく、外出先での「とりあえず1枚直したい」という使い方に合う。

ドラッグ&ドロップを受け付けるには、少し変わった作法が必要である。ブラウザには「ファイルを窓に放り込まれたら、そのファイルを開いて表示する」という既定の動作が備わっている。この動作を止めない限り、画像をドロップした瞬間に画面が画像単体の表示に切り替わり、ツールが消えてしまう。止める指示が preventDefault()(既定動作を防ぐ)である。
['dragenter', 'dragover'].forEach((name) => stage.addEventListener(name, (event) => {
event.preventDefault();
stage.classList.add('dragover');
}));
注意すべきは、drop(放した瞬間)だけでなく dragover(上を通過している間)でも止める必要がある点である。dragover を止めないと、ブラウザは「ここは受け取らない領域だ」と判断し、drop が起きない。あわせて dragover のときにCSSのクラスを付け、枠の色を変えて「ここに置ける」と知らせている。

放されたファイルは、イベントの dataTransfer.files に入っている。これは配列によく似ているが配列ではないため、「3.6 重複データ検出・並べ替え」で述べたスプレッド構文で配列に直してから件数を調べる。
const files = [...event.dataTransfer.files];
if (files.length !== 1) throw new Error('画像ファイルは1つだけドロップしてください。');
仕様で「一度に処理できる画像ファイルは1つ」と決めたため、2枚以上まとめて放されたときは黙って1枚目を使うのではなく、はっきり断ってエラー表示に回している。利用者が意図しない画像を編集してしまう事故を防ぐためである。

受け取ったファイルは、そのままではcanvasに貼れない。いったん画像として読み込む必要がある。そこで使うのがBlob URLである。Blob(Binary Large OBject)とは、種類を問わないひとかたまりのデータを指す言葉で、ファイルの中身もBlobの一種である。
const url = URL.createObjectURL(file);
try {
const image = new Image();
image.decoding = 'async';
image.src = url;
await image.decode();
・・・
} finally {
URL.revokeObjectURL(url);
}
URL.createObjectURL() は、端末の中にあるデータに対して「blob:から始まる一時的な住所」を発行する。見た目はURLだが、指しているのはブラウザのメモリ上のデータであり、インターネットへ問い合わせは行われない。用が済んだら URL.revokeObjectURL() で住所を破棄する。破棄しないと、画像の実体がメモリに残り続ける。何十枚も読み込むうちに動作が重くなる原因になるため、finally(成功でも失敗でも必ず実行する部分)に置いてある。

image.src に住所を代入しても、その瞬間に画像が使える状態になるわけではない。読み込みと展開には時間がかかる。従来は「読み終わったら知らせて」という onload を書いたが、decode() は「3.6 重複データ検出・並べ替え」で解説したPromiseを返すため、await を付けて「読み終わるまでここで待つ」と素直に書ける。待った後で幅と高さを調べ、0であれば壊れたファイル、9999を超えていれば仕様の上限超過として、それぞれエラーにしている。

表示上の座標を画像の座標に変換する

ここからが、画像編集ツールで最もつまずきやすい部分である。読み込んだ写真が横4000ピクセルだったとしても、画面にそのまま4000ピクセルで表示されるわけではない。QuickPicはCSSで max-width: 100% と max-height: 72vh を指定しているため、canvasは領域に収まる大きさへ自動的に縮小されて表示される。たとえば横800ピクセル分の幅で表示されていれば、見えている絵は実物の5分の1である。

一方、マウスや指の位置としてイベントが知らせてくる clientX、clientY は、画面上の座標である。この数値をそのまま「画像の何ピクセル目か」として使うと、5分の1の位置を指してしまう。そこで、表示上の座標を画像本来の座標へ換算する必要がある。換算には、要素が画面上のどこにどんな大きさで表示されているかを返す getBoundingClientRect() を使う。
const pointerCanvas = (event) => {
const box = canvas.getBoundingClientRect();
return {
x: clamp((event.clientX - box.left) * canvas.width / box.width, 0, canvas.width),
y: clamp((event.clientY - box.top) * canvas.height / box.height, 0, canvas.height),
};
};
まず event.clientX - box.left で、canvasの左端から何ピクセル右かを求める。次に canvas.width / box.width を掛ける。これが「実物は表示の何倍か」という倍率である。先の例なら5倍になる。最後の clamp() は、値を下限と上限の間に押し込める自作の関数で、canvasの外にはみ出した指の位置を端で止めている。

この換算を忘れると、症状が分かりにくい不具合になる。枠は指の通りに動いて見えるのに、「決定」を押すと想定と違う場所が切り取られる、という現象である。画面上では正しく見えているため、原因がプログラムのどこにあるのか見当がつきにくい。Codexに依頼するときも「トリミング枠の位置と実際に切り取られる位置がずれる」と症状を具体的に伝えると、この換算部分に目を向けてもらいやすい。

逆向きの換算も必要である。選択範囲は画像の座標で記録しているため、枠を画面に描くときは表示上の座標へ戻す。
const box = canvas.getBoundingClientRect();
const sx = box.width / canvas.width;
const sy = box.height / canvas.height;
overlay.style.left = `${canvas.offsetLeft + selection.x * sx}px`;
overlay.style.width = `${selection.w * sx}px`;
倍率が先ほどと逆数になっている点に注目したい。「画像から表示へ」は縮小、「表示から画像へ」は拡大である。この2つを取り違えると、枠が画面から飛び出したり、極端に小さくなったりする。

表示の倍率は固定ではない。ウィンドウの幅を変えたり、スマートフォンを横向きにしたりすると変わる。そのため、resize イベントで枠を描き直している。編集中でないときは描き直す必要がないので、範囲選択の最中だけ実行する。
window.addEventListener('resize', () => { if (mode) drawSelection(); });

Pointer Eventsでマウスも指も同じように扱う

トリミング枠やモザイク枠は、つかんで動かす操作で調整する。パソコンではマウスで、スマートフォンでは指で行う。ところが、この2つは長いあいだ別の仕組みで扱われてきた。マウスは mousedown・mousemove・mouseup、指は touchstart・touchmove・touchend である。両方に対応しようとすると、ほぼ同じ処理を2組書くことになり、片方だけ直して食い違うという不具合が起きやすい。

Pointer Events は、この2つを1つにまとめた仕組みである。マウス、指、ペンをすべて「ポインタ」として扱い、pointerdown(押した)・pointermove(動いた)・pointerup(離した)・pointercancel(中断された)という共通のイベントで知らせる。どの手段で操作されたかは、必要なら event.pointerType で調べられるが、QuickPicでは区別する必要がないため使っていない。
3つの入力方式の扱い方
操作マウス専用タッチ専用Pointer Events
押したmousedowntouchstartpointerdown
動かしたmousemovetouchmovepointermove
離したmouseuptouchendpointerup
中断された(相当なし)touchcancelpointercancel
位置の取り出しevent.clientXevent.touches[0].clientXevent.clientX
つかんで動かす操作には、もう1つ厄介な問題がある。枠をつかんだまま素早く動かすと、指やマウスが枠の外へはみ出す。すると、イベントは枠ではなく、その下にある別の要素へ届くようになり、追従が止まってしまう。これを防ぐのが setPointerCapture() である。
overlay.addEventListener('pointerdown', (event) => {
if (!mode) return;
event.preventDefault();
overlay.setPointerCapture(event.pointerId);
drag = { start: pointerCanvas(event), original: { ...selection }, handle: event.target.dataset.h | 'move' };
});
pointerId は、その1本の指やマウスに割り振られた番号である。この番号のポインタが離されるまで、位置の変化はすべてこの要素へ届く、という予約を入れているのである。あわせて、押した時点の位置(start)と、その時点の選択範囲(original)を控えておく。動いている間は「押した位置からどれだけずれたか」だけを見て、控えた範囲に足し込む。こうすると、途中の計算誤差が積み重ならない。

最後の event.target.dataset.h は、8個ある丸いつまみのうちどれを押したかを表す。QuickPicは各つまみに data-h="nw"、data-h="n" のような目印を付けており、nは北(上辺)、sは南(下辺)、eは東(右辺)、wは西(左辺)を表す。つまみ以外の内側を押した場合は目印がないので、| 'move' によって「枠全体の移動」と解釈する。イベントの届いた場所によって処理を振り分けるこの書き方は、「3.5 ToDoリスト」で解説したイベント委譲と同じ考え方である。

もう1つ、CSS側の対策が必要である。スマートフォンで指を動かすと、ブラウザは通常それを画面のスクロールや拡大縮小として解釈する。枠を動かそうとしたのにページ全体が動いてしまう。これを止めるのが touch-action である。
#overlay { touch-action: none; }
.stage canvas { touch-action: none; }
none は「この要素の上での指の動きを、ブラウザ側の操作に使わないでほしい」という指定である。JavaScriptではなくCSSで指定する点が見落としやすい。スマートフォンで枠が動かないときは、ここも確認したい。

着信や通知で操作が中断されたときは pointercancel が届く。QuickPicは pointerup と同じ処理を割り当て、つかんでいる状態を解除している。中断を放置すると、指を離したのに枠がまだつかまれたままになる。

getImageDataでトリミングする

簡易画像編集ツールの実画面。テスト画像の四辺を囲むトリミング枠と8個の丸いつまみが表示されている
「決定」ボタンを押したときの処理を見ていく。選択範囲は指の動きに合わせて小数で記録されているが、ピクセルは整数でしか数えられない。そこで、まず整数に直す。
const x = Math.floor(selection.x);
const y = Math.floor(selection.y);
const w = Math.max(1, Math.min(canvas.width - x, Math.ceil(selection.x + selection.w) - x));
const h = Math.max(1, Math.min(canvas.height - y, Math.ceil(selection.y + selection.h) - y));
左上は Math.floor()(切り捨て)で外側へ広げ、右下は Math.ceil()(切り上げ)で外側へ広げている。こうすると、枠にわずかでも含まれたピクセルは残る。切り捨てと切り上げを取り違えると、枠の縁が1ピクセル欠ける。Math.min() は画像の端を超えないための歯止めで、Math.max(1, …) は幅や高さが0になるのを防いでいる。幅0の画像は作れないためである。

範囲が決まったら、その部分のピクセルを取り出す。
const data = ctx.getImageData(x, y, w, h);
canvas.width = w;
canvas.height = h;
ctx.putImageData(data, 0, 0);
getImageData() は、指定した長方形のピクセルを ImageData という形で返す。その中身の data は、RGBAの数値が1列に並んだ長い配列である。横100・縦80の範囲なら、100×80×4=3万2000個の数値が並ぶ。2次元の絵を1列に並べているため、任意のピクセルを指すには計算が必要になる。左からx番目、上からy番目のピクセルの赤の位置は (y * w + x) * 4 で、続く3つが緑・青・不透明度である。

注目したいのは4行の順序である。canvasの width と height を代入し直すと中身が消える、と前に述べた。だから、先に getImageData() でピクセルを手元に取り出し、その後でcanvasを新しい大きさに作り替え、putImageData() で書き戻している。順序を入れ替えると、元の画像が消え、透明な空の画像を扱うことになる。

putImageData() は drawImage() と似ているが、性質が違う。drawImage は拡大・縮小や半透明の重ね合わせを行うが、putImageData は数値をそのまま置き換える。拡大や合成をせず、指定位置にピクセルを置く。ただし、半透明のピクセルはブラウザ内部の色の変換に伴って数値が丸められる場合があり、常に1ビットも変わらないとは限らない。トリミングは「切り取るだけで色は変えない」処理なので、こちらが適している。

なお、切り落としたピクセルは完全に失われる。QuickPicは元画像を保持しないため、トリミングをやり直すには読み込みからやり直すことになる。仕様を決める段階で「操作を戻せる」機能を入れるかどうかは、判断の分かれ目である。戻せるようにするには編集の履歴を持つ必要があり、大きな画像では消費するメモリが数十メガバイトから百メガバイト単位になる。今回は「外出先で1枚だけ手早く直す」用途に絞り、機能を入れないことにした。

モザイクはブロックの代表色で塗る

モザイクとは、細かな模様を粗いブロックの並びに置き換え、内容を判別できなくする処理である。もとの語は、小片を並べて絵を作る装飾技法を指す。写真に対しては、範囲を格子状のブロックに区切り、各ブロックを1色で塗りつぶすことで実現する。ブロックが大きいほど何が写っていたか分からなくなり、小さいほど元の形が残る。

ブロックをどれだけの大きさにするかが、この処理の設計の要である。固定値にすると具合が悪い。たとえば常に10ピクセル角にすると、100ピクセル角の小さな範囲では10×10ブロックとなって形が大きく崩れ、2000ピクセル角の広い範囲では200×200ブロックとなって元の文字が読めてしまう。そこでQuickPicは、範囲の短い辺を基準に決めている。
const block = Math.max(4, Math.round(Math.min(w, h) / 18));
Math.min(w, h) で短い辺を取り、18で割る。短辺方向に約18個のブロックが並ぶ計算である。Math.max(4, …) は下限で、極端に狭い範囲を指定しても最低4ピクセル角は確保する。1ピクセル角では、モザイクをかけたのに何も変わらないという結果になるためである。

ブロックごとの色は、そのブロックの中心にあるピクセルの色を採用している。
const data = ctx.getImageData(x, y, w, h);
for (let by = 0; by < h; by += block) {
for (let bx = 0; bx < w; bx += block) {
const i = ((Math.min(by + Math.floor(block / 2), h - 1) * w)
  1. Math.min(bx + Math.floor(block / 2), w - 1)) * 4;
ctx.fillStyle = `rgb(${data.data[i]},${data.data[i + 1]},${data.data[i + 2]})`;ctx.fillRect(x + bx, y + by, Math.min(block, w - bx), Math.min(block, h - by));
}
}
外側の繰り返しが縦方向、内側が横方向で、いずれも block ずつ進む。Math.floor(block / 2) を足しているのが「中心へ寄せる」部分で、Math.min(…, h - 1) は範囲の端で外へ出ないための歯止めである。取り出した位置 i から3つの数値を読み、fillStyle に色を設定し、fillRect() で長方形を塗る。塗る大きさに Math.min(block, w - bx) を使っているのは、右端と下端で余りが出るとき、はみ出さないように詰めるためである。

色を読み出す元は data、すなわち塗り始める前に一度だけ取得したピクセルである。これにより、getImageData() をブロックごとに呼ぶ必要がなくなる。今回のコードではブロック同士が重ならないため、各ブロックを塗る直前にその中心の色を読み直しても、前のブロックの色を拾うことにはならない。処理前にまとめて読む主な利点は、読み出す回数を減らし、元データと描画先を分けて扱えることである。

ブロックの代表色として、中心の1点ではなくブロック内の平均を使う方法もある。見た目はなめらかになるが、ブロックごとにすべてのピクセルを足し合わせる必要があり、計算量が増える。4000×3000の写真の全面にかける場合は、端末の性能によって待ち時間が長くなることがある。今回は待ち時間の短さを優先し、中心の1点を採る方式にした。

最後に、隠す目的で使う場合の注意である。QuickPicのモザイクは、ブロック内のピクセルを1色に塗り替えて捨てるため、後から元の画素を計算で取り戻すことはできない。ただし、ブロックが小さければ、残った色の並びから元の文字を推定できる場合がある。実際に、モザイクやぼかしをかけた文字を推定する研究や事例が報告されている。番号や氏名など確実に隠したい部分は、モザイクよりも黒い長方形で塗りつぶす方が安全である。また、この処理はcanvas上の画像を書き換えるだけなので、保存し忘れれば元の画像がそのまま残る点にも注意したい。
モザイク処理の手順。範囲をブロックに区切り、各ブロック中心の色で塗りつぶす図

90度回転は座標系を回して描き直す

スマートフォンを横に構えて撮った写真が、パソコンで開くと縦になっていることがある。この直し方として、ピクセルを1つずつ新しい位置へ移し替える方法が思い浮かぶ。横4000・縦3000なら1200万回の移し替えである。書けはするが、遅い。Canvas APIには、もっと簡単な方法が用意されている。絵ではなく座標系そのものを動かしてしまうのである。

座標系とは、「原点がどこで、どの向きが右か」という決まりである。canvasには、原点をずらす translate() と、座標系を回す rotate() が用意されている。回転の中心は常に原点なので、回したい位置に原点を移してから回す、という順序になる。
const temp = document.createElement('canvas');
temp.width = canvas.height;
temp.height = canvas.width;
const tctx = temp.getContext('2d');
tctx.translate(temp.width, 0);
tctx.rotate(Math.PI / 2);
tctx.drawImage(canvas, 0, 0);
1行目の document.createElement('canvas') は、作業用のcanvasを作っている。画面に置く必要がないので、HTMLには書かず、appendChild() もしない。このような、画面に出さない作業用canvasをオフスクリーンcanvasと呼ぶ。2行目と3行目で、縦と横を入れ替えた大きさを与えている。時計回りに90度回せば、縦長は横長になるからである。

translate(temp.width, 0) は、描画先の原点を右上の角へ移す指示である。左上を原点としたまま時計回りに90度回すと、元画像の下方向が描画先の左方向になり、画像が左にはみ出す。そこで原点を、元画像の高さに等しい temp.width だけ右へ移す。元画像の左上は描画先の右上に対応し、回転後の画像全体が新しいcanvasの中に収まる。

Math.PI / 2 が回す角度である。JavaScriptの三角関数や回転は、度ではなくラジアンという単位で角度を指定する。円を1周する角度が2π(約6.28)で、90度はその4分の1、すなわちπ/2である。Math.PI は円周率を表す定数なので、Math.PI / 2 と書く。度で考えたい場合は 角度 * Math.PI / 180 と換算する。

仕上げに、本体のcanvasを縦横入れ替えた大きさに作り替え、作業用canvasの内容を描き戻す。
canvas.width = temp.width;
canvas.height = temp.height;
ctx.drawImage(temp, 0, 0);
canvasは、それ自体を drawImage() の材料として渡せる。画像ファイルと同じように扱えるため、このような受け渡しが簡単に書ける。

90度単位の回転には、ほかの加工と違う利点がある。ピクセルの位置が入れ替わるだけで、色の値は1つも変わらない。したがって、何度回しても画質は落ちない。これが30度や45度といった中途半端な角度になると、新しい格子の上に元の格子が斜めに乗るため、周囲のピクセルから色を推定して埋める必要が生じる。この推定を補間といい、回すたびに輪郭がわずかにぼやける。QuickPicの仕様を「90度ごと」に限ったのは、操作を単純にするためだけでなく、画質を保つためでもある。

縦横比を保ってリサイズする

リサイズは、画像の大きさを変える処理である。仕様では「縦と横の比率は変えない」と定めた。横だけを縮めれば人の顔は痩せて写り、縦だけを縮めれば潰れて写る。そこで、片方の数値を入力したら、もう片方を比率から自動計算する。この比率をアスペクト比という。QuickPicは画像を読み込んだときと、トリミング・回転・リサイズ後に比率を更新する。
aspect = canvas.width / canvas.height;
横1600・縦1200なら、比率は約1.333である。横に800を入力したら縦は 800 ÷ 1.333 = 600、縦に300を入力したら横は 300 × 1.333 = 400 となる。

自動計算には、注意すべき落とし穴がある。
const updateLinkedSize = (changed) => {
if (syncingSize | !loaded) return;
・・・
syncingSize = true;
input.value = value;
other.value = changed === 'width'
? clamp(Math.round(value / aspect), 1, 9999)
: clamp(Math.round(value * aspect), 1, 9999);
syncingSize = false;
};
横の欄を利用者が変更すると、縦の欄を計算して書き換える。ここで、JavaScriptによる input.value への代入だけでは、input イベントは発生しない。したがって、このコードが代入だけで無限に呼び合うわけではない。syncingSize は、入力欄を更新している間であることを示す目印である。更新中に別の処理からこの関数が呼ばれた場合は、先頭の条件で戻る。自動的なイベント発生を止めるものではなく、更新処理の重複を防ぐための備えと読むとよい。

Math.round() で整数に丸めるため、縦横比を完全には維持できない寸法もある。横1600・縦1200の画像で横802と入力すると、縦は601.5を丸めて602となる。その602から横を求め直すと803になる。入力欄を交互に編集すれば、このようなずれが生じ得る。QuickPicは現在の画像の実寸を基準に比率を保ち、リサイズ後には新しい実寸で比率を更新する。厳密な寸法が必要なときは、確定前の入力値と確定後の表示を確かめる必要がある。

「1/2」「1/3」「1/5」のボタンは、現在の寸法を割った値を入力欄に書き込むだけで、画像には触れない。確定するには「サイズ変更」を押す。押した瞬間に縮まる作りにしなかったのは、間違えて押したときに戻せないためである。数値を欄に見せてから確定させる二段構えにすると、「1/5と1/3を押し間違えた」という事故を、確定前に取り消せる。

確定したときの処理は、回転と同じくオフスクリーンcanvasを使う。
const temp = document.createElement('canvas');
temp.width = width;
temp.height = height;
temp.getContext('2d').drawImage(canvas, 0, 0, width, height);
canvas.width = width;
canvas.height = height;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(temp, 0, 0);
drawImage() に幅と高さを渡すと、指定した寸法に拡大・縮小して描く。imageSmoothingEnabled は補間の有無、imageSmoothingQuality は補間品質の希望を表す。補間方法や対応状況はブラウザに依存する。掲載コードでは、実際の縮小を作業用canvasへの描画で済ませ、その後に本体の ctx に high を指定している。そのため、この high は縮小時の画質には効かない。縮小品質を指定するなら、作業用canvasのコンテキストを変数に取り出し、その imageSmoothingEnabled と imageSmoothingQuality を設定してから drawImage を呼ぶ必要がある。

逆に、小さい画像を大きくしても、失われた細部は戻らない。補間は「周りの色から、もっともらしい色を作る」処理であって、写っていなかったものを復元するわけではない。拡大するとぼやけるのは、この理由による。仕様で上限を9999ピクセルとしたのは、画面の入力欄を4桁に収めるためと、極端に大きなcanvasがブラウザの制限に触れるのを避けるためである。

canvas.toBlobで画像フォーマットを変換する

編集が済んだら、指定した形式で保存する。canvasの内容を画像データに変換するのが toBlob() である。
canvas.toBlob(callback, type, quality);
1つ目は、変換が終わったときに呼ばれる関数である。2つ目の type は image/png、image/jpeg、image/webp のような形式の名前で、3つ目の quality は品質を0から1で表す数値である。QuickPicは、JPGのときだけ0.92を渡し、ほかは指定していない。

形式ごとの性質を、下表にまとめる。ここで鍵になるのが可逆圧縮と非可逆圧縮の違いである。可逆圧縮は、圧縮して展開すると元の数値に完全に戻る方式で、書庫ファイルのZIPと同じ考え方である。非可逆圧縮は、人の目に分かりにくい情報を捨てることでファイルを小さくする方式で、捨てた分は戻らない。
QuickPicが扱う4つの画像フォーマット
形式圧縮透明大きさの目安向いている用途
BMPなし今回は非対応非常に大きい古いソフトへの受け渡し
PNG可逆対応中図、スクリーンショット、透明を含む画像
JPG非可逆非対応小写真
WEBP非可逆(canvasからの出力)対応小(画像と品質設定による)Webページへの掲載
写真をPNGで保存すると、JPGの数倍の大きさになることがある。写真は隣り合うピクセルの色が細かく異なるため、可逆圧縮では縮みにくいのである。逆に、文字や線画をJPGで保存すると、輪郭の周りに薄い汚れのような模様が現れる。捨てた情報の跡であり、これを圧縮ノイズという。用途に応じて選ぶ必要がある。

toBlob() は、結果を戻り値ではなく関数呼び出しで受け取る古い書き方である。「3.6 重複データ検出・並べ替え」で解説した await と組み合わせるため、QuickPicではPromiseで包んでいる。
const canvasBlob = (type, quality) => new Promise((resolve, reject) =>
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('画像データを作成できません。'));
}, type, quality));
new Promise() に渡した関数の中で、成功したら resolve()、失敗したら reject() を呼ぶ。こうすると、呼び出す側は const blob = await canvasBlob(type) と1行で書ける。古い書き方の部品を新しい書き方に合わせる、この包み直しの手口はプロミス化と呼ばれ、覚えておくと応用が広い。

落とし穴が1つある。ブラウザが対応していない形式を指定しても、エラーにはならない。仕様上、toBlob() は要求された形式を作れない場合、黙ってPNGを返してよいことになっている。すると、拡張子はwebpなのに中身はPNGという食い違ったファイルができあがる。そこで、できあがったBlobの種類を確かめている。
if (blob.type !== type) throw new Error(`${format.toUpperCase()}形式はこのブラウザで保存できません。`);
blob.type には、実際に作られた形式の名前が入っている。要求と違っていれば、その場で断る。「保存できたはずのファイルが開けない」という分かりにくい不具合を、「この形式は保存できません」という理解できる案内に変えているのである。仕様書に「例外・エラー処理」の項を設けた効果が、このような細部に現れる。
陽奈がノートPCの前で保存形式BMP、JPG、PNG、WEBPを選んでいるイラスト

BMPファイルをバイト列から組み立てる

仕様書には保存形式として bmp, jpg, png, webp の4つを挙げた。ところが、toBlob() がBMPを作れるブラウザは、事実上存在しない。仕様を満たすには、BMPファイルの中身を自分で1バイトずつ組み立てる必要がある。これは本項でもっとも込み入った部分だが、「ファイルとは何か」を理解する良い題材でもある。

BMP(Bitmap、ビットマップ)は、Windowsの標準的な画像形式である。今回のBMPは圧縮を行わないため構造が単純で、ヘッダ(見出し情報)に続いてピクセルの数値が並ぶだけである。QuickPicが作るのは、1ピクセルを24ビット(赤・緑・青を各8ビット)で表す、もっとも基本的な形である。
24ビットBMPファイルの構造
位置大きさ内容QuickPicが書く値
02バイト目印0x42, 0x4D(文字の BM)
24バイトファイル全体の大きさ54+行の長さ×高さ
64バイト予備0
104バイトピクセルデータの開始位置54
144バイト情報ヘッダの大きさ40
184バイト横のピクセル数canvasの幅
224バイト縦のピクセル数canvasの高さ
262バイト面の数1
282バイト1ピクセルのビット数24
304バイト圧縮方式0(無圧縮)
344バイトピクセルデータの大きさ行の長さ×高さ
3816バイト解像度、色数など0
54可変ピクセルデータ下の行から順にBGRで並べる
このバイト列を作るために、3つの道具を組み合わせて使う。
const buffer = new ArrayBuffer(size);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
ArrayBuffer は、指定した大きさのメモリ領域そのものである。「3.6 重複データ検出・並べ替え」でファイルの中身をバイト列として受け取ったときに登場した。ただし、ArrayBuffer自体には読み書きの手段がない。そこで、DataView と Uint8Array という2種類の窓口をかぶせる。DataViewは「何バイト目に、4バイトの整数として書き込む」といった指定ができる窓口で、Uint8Arrayは領域全体を1バイトずつの配列として扱う窓口である。同じ領域に2つの窓口をかぶせているので、どちらから書いても同じ場所に反映される。

ヘッダの書き込みは、次のようになる。
bytes.set([0x42, 0x4d]);
view.setUint32(2, size, true);
view.setUint32(10, 54, true);
view.setUint32(14, 40, true);
view.setInt32(18, width, true);
view.setInt32(22, height, true);
view.setUint16(26, 1, true);
view.setUint16(28, 24, true);
view.setUint32(34, row * height, true);
各行の最後の true がリトルエンディアンの指定である。4バイトの数値をメモリに置くとき、下位のバイトを先に置くのがリトルエンディアン、上位を先に置くのがビッグエンディアンである。「3.6 重複データ検出・並べ替え」ではBOMの説明で触れた。BMPはWindowsで生まれた形式であり、常にリトルエンディアンで記録すると決まっている。ここを false にすると、幅や高さがとんでもない数値として読まれ、画像を開けない。

表にある「予備」「圧縮方式」「解像度、色数」は、いずれも0を書く欄である。プログラムには、これらを書く行がない。ArrayBuffer は確保した時点で全体が0で埋まっているため、書かなければ0のままになるからである。書く必要のない欄を書かないのは、行数を減らすためだけではない。書き間違いの余地をなくすという意味もある。

ピクセルデータには、BMP特有の2つの決まりがある。1つ目は、1行の長さを4の倍数にそろえることである。
const row = Math.ceil((width * 3) / 4) * 4;
1ピクセルが3バイトなので、横100ピクセルなら300バイトで、これは4で割り切れる。しかし横101ピクセルなら303バイトとなり、割り切れない。この場合は305ではなく304バイトまで伸ばし、余った1バイトは使わないまま残す。この詰め物をパディングという。昔のコンピュータが4バイト単位でメモリを読む方が速かったことに由来する決まりで、現在も互換性のために守られている。ここを忘れると、行がずれて、画像が斜めに傾いたような模様になる。

2つ目は、行が下から上へ並ぶことである。QuickPicのように高さを正の値で記録するBMPでは、画像の最下行を先に記録する。BMPには、負の高さを指定して上から記録する形式もある。canvasは上から下へ数えるため、変換のときに上下を入れ替える必要がある。
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const src = ((height - 1 - y) * width + x) * 4;
const dst = 54 + y * row + x * 3;
bytes[dst] = pixels[src + 2];
bytes[dst + 1] = pixels[src + 1];
bytes[dst + 2] = pixels[src];
}
}
読み出し位置の (height - 1 - y) が上下の入れ替えである。yが0のとき、canvasの最下行(height - 1)を読む。書き込みの3行では、赤と青を入れ替えている。canvasのImageDataはR・G・B・Aの順だが、BMPはB・G・Rの順で記録するためである。この入れ替えを忘れると、青空が赤くなり、肌が青くなった画像ができる。「色がおかしい」という不具合の典型例である。なお、Aすなわち不透明度は24ビットBMPでは記録できないため、捨てている。透明な部分を含む画像をBMPで保存すると、その情報は失われる。

最後に、組み立てた領域をBlobにまとめる。
return new Blob([buffer], { type: 'image/bmp' });
ここまで読んで、「自分にはとても書けない」と感じたとしても心配はない。実際、私はこのバイト列を1行も書いていない。仕様書に「保存する画像フォーマットとして bmp, jpg, png, webpをラジオボタンで選択できる」と書き、Codexに実装させた結果である。人間の役割は、できあがったBMPファイルが本当にWindowsのペイントやフォトで開けるかを確かめ、開けなければ「BMPで保存したファイルがペイントで開けない」と具体的に伝えることである。この「確かめる仕組みを持つ」という考え方は、「3.7 ハーネスエンジニアリング」で述べたセンサーそのものである。

download属性で編集結果を保存する

できあがったBlobを、端末のファイルとして保存する。QuickPicでは、広く使えるダウンロード用リンクの仕組みを利用する。リンクを1つ作り、それを自分でクリックする、という回り道を使う。
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${sourceName}_edited.${format}`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
a 要素はリンクを表す要素である。通常は href が指す先へ画面が移動するが、download 属性を付けると、移動せずにダウンロードとして扱われる。属性に指定した文字列が、保存されるファイル名になる。href に渡すのは、読み込みのときと同じ Blob URL である。

細かいが大事な点が3つある。1つ目は、appendChild() で文書に加えてから click() を呼び、直後に remove() で取り除いていることである。ブラウザによっては、文書に加えられていない要素のクリックを無視する。画面には一瞬も表示されないが、この出し入れが必要になる。

2つ目は、setTimeout() で1秒後にBlob URLを破棄していることである。click() を呼んだ直後にダウンロードが完了しているとは限らない。すぐ破棄すると、ダウンロード開始前に参照が無効になり、保存に失敗するおそれがある。そのため少し待ってから破棄する。ただし、1秒は完了を保証する時間ではないため、利用するブラウザで保存結果を確認する必要がある。破棄しないままにすれば確実だが、保存を繰り返すたびに画像データがメモリに残り続ける。

3つ目は、ファイル名に _edited を付け加えていることである。読み込んだファイルが family.jpg なら、保存されるのは family_edited.png となる。元と同じ名前にすると、既存のファイルを上書きしたり、family (1).jpg のような紛らわしい名前で並んだりする。編集前と編集後を、名前だけで見分けられるようにしている。

どこに保存されるかは、ブラウザと端末の設定によって変わる。パソコンでは、多くの場合「ダウンロード」フォルダに黙って保存されるか、保存先を尋ねるダイアログが開く。iPhoneやiPadのSafariでは「ファイル」アプリのダウンロード先に入り、Androidでは端末のダウンロード先に入る。この download 属性を使う方式では、プログラムの側から保存先を指定することはできない。これはブラウザの安全上の制限であり、Webページが利用者のファイルを勝手な場所に置けないようにするための決まりである。ツールの取扱説明書には、この点を書き添えておく方が親切である。

簡易画像編集ツールのプログラム仕様書

以上を踏まえて、簡易画像編集ツール「QuickPic.html」のプログラム仕様書を示す。これがCodexに渡すプロンプトそのものになる。ここまで解説してきた Canvas API、getImageData、Pointer Events、DataView といった具体的な部品の名前は、仕様書には1つも書いていない。書いてあるのは「指定範囲のモザイク処理」「縦と横の比率は変えない」「サムネイルの四辺を囲むトリミングバーが表れ」といった、実現したいことだけである。どの部品を使うかはCodexが選ぶ。人間が決めるのは、何ができれば合格かという線引きである。

今回の仕様書で効いた記述を3つ挙げる。「クライアントPCのブラウザで動作し、スマートフォンでも利用できること」は、マウスとタッチの両対応を求める記述であり、Pointer Events と touch-action の採用につながった。「HTTPサーバーやNode.jsなどのサーバー技術は使用せず、ブラウザの機能だけで完結すること」は、Blob URLとcanvasを中心に組み立てる構成を導いた。「外部と通信しない」は、画像が端末の外に出ないという、このツールの一番の値打ちを担保している。いずれも、実現したい動作や使用しない技術を条件として示している点が重要である。

逆に、書かなかったためにCodexへ任された判断もある。モザイクのブロックを短辺の18分の1にすること、JPGの品質を0.92にすること、「1/2」ボタンを押しても即座には縮小せず入力欄に数値を入れるだけにすることは、いずれも仕様書に書いていない。仕様書の前提条件に「安全かつ合理的に判断できる軽微な事項は、判断内容を明記して作業を進める」と書いてあるため、Codexはこれらの判断をプログラム仕様書(SPECIFICATION.md)へ書き足して報告した。人間の仕事は、その報告を読み、自分の意図と合っているかを確かめることである。実際、「1/2」ボタンの挙動は初版から変更しており、バージョン1.1.0で「決定」ボタン方式と縮小率プリセットの形に落ち着いた。
プログラム仕様書(プロンプト)
# 簡易画像編集ツール

# 目標 入力した画像ファイルに対して、次の編集を行う。 - トリミング - 回転(90度ごと) - 指定範囲のモザイク処理 - リサイズ(縦横比は維持) - フォーマット変換 一度に処理できる画像ファイルは1つ。
# ツール名称 簡易画像編集ツール
# プログラム・ファイル名 QuickPic.html
# プロジェクト・フォルダ作成 - プログラム・ファイル名の拡張子を除いた主ファイル名と同じ名前のサブフォルダを作成し、以降の作業はサブフォルダで行う。 - すでにサブフォルダがあれば、そのサブフォルダに移動して以降の作業を進める。
## 画面構成と処理 - プログラム上部にツール名称、バージョン番号、著作権者を記載する。 - サムネイル表示エリアがある。起動時には何も表示しておらず、ここへ画像ファイルをドラッグ&ドロップすると、全体をサムネイル表示する。 - 「読み込み」ボタンをクリック(タップ、以下同じ)するとファイルダイアログを表示し、画像ファイルを1つだけ読み込める。 - 「トリミング」ボタンをクリックすると、サムネイルの四辺を囲むトリミングバーが表れ、それをドラッグ(タップ)することで画像のトリミングを行うことができる。「決定」ボタンをクリックすると、トリミングを実行する。 - 「回転」ボタンをクリックすると、1回クリックする毎に、画像が時計回りに90度回転する。 - 「モザイク」ボタンをクリックすると、画像の範囲を指定する矩形があらわれ、ドラッグして移動、四辺をつまんで範囲の拡大・縮小ができる。「決定」ボタンをクリックすると、その範囲をモザイク処理する。 - 画像サイズ(縦・横のピクセル数)の直下に縦と横のピクセル数を入力するテキストボックスがある。初期値は、元の画像のピクセル数。縦または横の数値を変更し「サイズ変更」ボタンをクリックすると、画像サイズを変更する。縦と横の比率は変えない。つまり、縦のピクセル数が入力されたら、比率に応じて横のピクセル数を自動変更する。最大値は、縦・横ともに9999。「1/2」「1/3」「1/5」のボタンをクリックすると、変更後のピクセル数が「1/2」「1/3」「1/5」になる。 - 保存する画像フォーマットとして bmp, jpg, png, webpをラジオボタンで選択できる。「保存」ボタンをクリックすると、編集後の画像を指定フォーマットで保存する。
## 記録 - なし
## 通信 - 外部と通信しない。
## 例外・エラー処理 - 無限ループに陥ったり、システム・エラーが出たときは、画面にエラー情報を表示して終了すること。
# テスト観点・合格条件 - Codexは、ダミーの画像データ(大きさやフォーマットが異なるもの)を5本作成し、上記仕様の通りに動作するか検証する。 - 前提条件、制約条件が守られていること。
# 前提条件 - 仕様変更があった場合は、本書を更新する。 - バージョン番号、著作権者、MIT Licenseであることを画面に表示する。 - 仕様で分からないことがあり、その違いが実装結果へ大きく影響する場合はユーザーに質問する。 - 安全かつ合理的に判断できる軽微な事項は、判断内容を明記して作業を進める。 - HTML、CSS、JavaScriptを1つのHTMLファイルに記述する。 - クライアントPCのブラウザで動作し、スマートフォンでも利用できること。 - HTTPサーバーやNode.jsなどのサーバー技術は使用せず、ブラウザの機能だけで完結すること。 - コーディングは「Airbnb JavaScript Style Guide」に可能な限り準拠すること。 - プログラムファイルのコメントに、名称、バージョン、目的、動作環境、著作権表示および使用条件、インストール方法、お問い合わせを記載すること。
# 制約条件 - 原則として、インターネットとのデータ送受信は行わないこと。 - 外部ライブラリを使用する場合は、下記のサイトに限定すること https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://ajax.googleapis.com/ https://code.jquery.com/ https://ajax.aspnetcdn.com/ - プログラムがMIT Licenseおよび第三者の権利に違反していないこと。 - ユーザーの許可なくプロジェクトフォルダ外のファイルを変更しないこと。 - ユーザーの許可なく既存ファイルを削除しないこと。 - Gitの履歴を破壊する操作を行わないこと。
# 合格判定 - すべての実装とテストが完了したら、テスト結果を一覧で表示する。 - 合格項目、不合格項目、未確認項目を明確に区別する。 - 不合格項目がある場合は原因と修正方針を表示する。 - 未確認項目がある場合は未確認の理由とユーザーによる確認方法を表示する。 - テスト結果を表示した後、合格としてよいかユーザーに質問する。 - ユーザーが合格と回答するまでは、Gitへの最終コミットと配布用ZIPファイルの作成を行わない。 - ユーザーが不合格と回答した場合は、指摘内容を修正して再テストする。
# 簡易取扱説明書の作成 - HTMLファイルと同じ場所に、簡易取扱説明書「README.txt」を作成する。 - 説明書には「プログラムの名称」「バージョン」「目的」「動作環境」「著作権表示および使用条件」「インストール方法」「使い方」「操作方法」「編集結果の保存」「既知の制限」「変更履歴」「お問い合わせ」を含める。 - インストール方法には、ZIPファイルを展開し、本ツールをブラウザで直接開く手順を記載する。 - マウス、キーボード、画面タッチの操作方法を記載する。
# 著作権表示および使用条件 このプログラムは OpenAI社の Codex によって作成し、作者が動作を確認しました。 このプログラムは外部とのデータ通信を行いません。
本アプリケーションはMIT Licenseです。 商用を含む無償利用が可能です。自由に改造できます。 再配布の際は、下記の著作権表記、URLおよび本使用条件を必ず明記してください。
Copyright by (c)studio pahoo https://www.pahoo.org/
MITライセンスについては、下記のリンク先を参考にしてください。 http://ja.wikipedia.org/wiki/MIT_License http://www.opensource.org/licenses/mit-license.php
なお、本アプリケーションの利用または改造によって生じた得失については一切関知いたしません。また、二次利用先の組織・企業・団体の目的・内容・活動については一切関知いたしません。
# お問い合わせ ぱふぅ家のホームページ https://www.pahoo.org/
- サイト案内 - お問い合わせ
# リソース管理 - 作業開始前に、今回の作業が「新規作成(評価用)」「新規作成(配布用)」「メジャーバージョンアップ」「マイナーバージョンアップ」「不具合修正」のどれに該当するかユーザーに質問する。 - 新規作成(評価用)は0.1.0、新規作成(配布用)は1.0.0とする。メジャー、マイナー、不具合修正は既存バージョンを確認して適切に更新する。 - 既存バージョンを確認できない場合は推測せずユーザーに質問する。 - 決定したバージョン番号をツール、README.txt、本書、配布用ZIPファイル名へ反映する。 - ユーザーがテスト結果を合格と判定した後、プログラム、README.txt、本書、および必要なカード画像をGitへコミットする。 - Gitリポジトリが存在しない場合やコミットできない場合は、勝手に新規リポジトリを作成せず、理由を報告して指示を求める。 - コミットメッセージには、プログラム名、バージョン番号、変更区分が分かる内容を使用する。
# 配布ファイルの作成 - ユーザーがテスト結果を合格と判定した後、ツール、README.txt、本書を1つのZIPファイルに圧縮する。 - ZIPファイル名は「プログラム・ファイル名(拡張子を除く)_バージョン番号.zip」とする。 - ZIPファイル内に一時ファイル、テスト用ファイル、Git管理ファイル、デバッグ専用ファイルを含めない。 - 完了後、作成したZIPファイルの絶対パスをユーザーに伝える。
できあがったツールの主要なファイルは、program/QuickPic/ の QuickPic.html、README.txt、SPECIFICATION.md、TEST_RESULTS.md である。バージョンは1.1.0である。取扱説明書にある制限に、今回のコード確認で見つかった注意点を加えて下表にまとめた。縮小品質の指定位置や寸法の上下限での縦横比は、改善の余地がある点である。こうした制限を説明書にも反映しておくと、後日「なぜ動かないのか」と悩む時間を減らせる。
簡易画像編集ツール
簡易画像編集ツール
簡易画像編集ツール 1.1.0 の既知の制限
制限理由
読み込める形式はブラウザに依存する画像の展開はブラウザの機能を使っている
WEBP保存はブラウザに依存するcanvasのtoBlobが対応していない場合がある
非常に大きな画像は処理できない場合がある端末のメモリ量による制限
縦・横は9999ピクセルまで入力欄と処理サイズを制限するため。端末によっては9999未満でも処理できない
編集の取り消しができない元画像を保持せず、canvas1枚で編集しているため
モザイク部分の透明度は保持しない不透明なRGB色でブロックを塗っているため
縮小時の補間品質は既定値になるhighの指定が実際に縮小する作業用canvasに適用されていないため
寸法の上限・下限付近で縦横比が変わる場合がある縦と横を個別に1~9999へ収める実装であるため
一度に扱えるのは1枚だけ仕様として1枚に絞ったため

参考サイト

(この項おわり)
header