クリップボードに画像をコピーする

writeImage()(権限 clipboard-manager:allow-write-image)で canvas や PNG を画像としてコピーする。image-png 機能が要る場面と JPEG の扱い、Rust 版も示す。

システム情報 対象: Tauri 2.x 更新日: 読了目安: 約10分 sys-009
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. canvas や表示中の画像をコピーする
  4. 画像ファイルをコピーする
  5. クリップボードの画像を読み取る
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

描いたグラフや編集中の画像を「画像としてコピー」できると、チャットや文書にそのまま貼ってもらえます。書き込みは Clipboard Manager プラグインの writeImage()(Rust は write_image())で行います。ファイルのパス、PNG のバイト列、RGBA の生データなどを渡せますが、渡し方によって Cargo の image-png 機能の要否が変わり、JPEG はそのままでは扱えません。この違いと、どんな画像でもコピーできる canvas 経由の方法を説明します。テキストは クリップボードのテキストを読み書きする で扱います。

前提条件

npm run tauri add clipboard-manager

clipboard-manager:default は何も許可しないので、書き込みの clipboard-manager:allow-write-image を追加します。後述の読み取り(貼り付け)も使うなら clipboard-manager:allow-read-image も足します。Image.new() など画像オブジェクトを扱う関数の権限は core:image:default に含まれるので、core:default があれば追加は不要です。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "clipboard-manager:allow-write-image",
    "clipboard-manager:allow-read-image"
  ]
}

PNG のファイルやバイト列から画像を作るには、src-tauri/Cargo.toml の tauri に image-png 機能を付けます(ICO なら image-ico)。画像の読み込みは Rust 側で行われるため、JS から渡す場合でもこの設定が要ります。

[dependencies]
tauri = { version = "2", features = ["image-png"] }

writeImage() に渡せるものは次のとおりです。

渡すもの例image-png
ファイルのパス(文字列)writeImage('C:/img/a.png')必要
PNG / ICO のバイト列Uint8Array、ArrayBuffer必要
Image オブジェクトImage.new()、readImage() の戻り値作り方による

Uint8Array や数値の配列は、常に「PNG などのファイルの中身」として扱われます。RGBA の生データを渡したいときは、幅と高さを添えて Image.new(rgba, width, height) で包みます。

1. フロントエンドから実装する (TypeScript)

canvas や表示中の画像をコピーする

JPEG や WebP、SVG は Rust 側では読み込めませんが、WebView なら表示できます。そこで一度 canvas に描き、PNG に変換してから渡せば、画面に出せる画像ならどれでもコピーできます。

import { writeImage } from '@tauri-apps/plugin-clipboard-manager';

// canvas の内容を PNG のバイト列にする
async function toPngBytes(canvas: HTMLCanvasElement): Promise<Uint8Array> {
  const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
  if (!blob) throw new Error('PNG に変換できませんでした');
  return new Uint8Array(await blob.arrayBuffer());
}

export async function copyCanvas(canvas: HTMLCanvasElement) {
  await writeImage(await toPngBytes(canvas)); // allow-write-image と image-png 機能が必要
}

// <img> に表示している画像(JPEG / WebP / SVG も可)をコピーする
export async function copyImageElement(img: HTMLImageElement) {
  await img.decode(); // 読み込みの完了を待つ
  const canvas = document.createElement('canvas');
  canvas.width = img.naturalWidth;
  canvas.height = img.naturalHeight;
  canvas.getContext('2d')?.drawImage(img, 0, 0);
  await copyCanvas(canvas);
}

Uint8Array は IPC で数値の配列に変換されて送られるので、大きな画像ほど時間がかかります。RGBA の生データ(幅 × 高さ × 4 バイト)で渡すより、PNG に圧縮した方が送る量はずっと小さくなります。

画像ファイルをコピーする

PNG ファイルならパスの文字列を渡すだけです。ファイルは Rust 側で読まれるので、fs プラグインは要りません。パスは ファイルを開くダイアログ で選ばせるか、同梱した画像 なら resolveResource() で絶対パスにします。相対パスはアプリの作業ディレクトリが基準になり、起動のしかたで変わるので避けます。

import { writeImage } from '@tauri-apps/plugin-clipboard-manager';
import { resolveResource } from '@tauri-apps/api/path';

export async function copyBundledImage() {
  // bundle.resources に入れた images/logo.png の絶対パス
  const path = await resolveResource('images/logo.png');
  await writeImage(path); // PNG / ICO のみ。JPEG は読み込みに失敗する
}

クリップボードの画像を読み取る

readImage() は RGBA の生データを持つ Image を返します。PNG ではないので、new Blob([rgba]) のように画像ファイルとしては扱えません。ImageData にして canvas に描きます。Image の実体は Rust 側に残るので、使い終わったら close() で解放します。

import { readImage } from '@tauri-apps/plugin-clipboard-manager';

export async function pasteImageTo(canvas: HTMLCanvasElement) {
  const image = await readImage(); // allow-read-image。画像が無ければ例外
  try {
    const { width, height } = await image.size();
    const rgba = await image.rgba();
    canvas.width = width;
    canvas.height = height;
    const data = new ImageData(new Uint8ClampedArray(rgba), width, height);
    canvas.getContext('2d')?.putImageData(data, 0, 0);
  } finally {
    await image.close(); // Rust 側に残っている画像を解放する
  }
}

2. バックエンドから実装する (Rust)

Rust では tauri::image::Image を作って app.clipboard().write_image(&image) に渡します。ファイルから作る Image::from_path() / Image::from_bytes() には image-png 機能が要ります。アプリに埋め込む小さな画像なら、tauri::include_image! でビルド時に RGBA へ変換しておけば実行時の読み込みは不要です(パスは Cargo.toml のあるフォルダーが基準)。RGBA のまま埋め込まれるので、大きな画像では実行ファイルが膨らみます。画素を自分で作るときは、左上から 1 行ずつ R・G・B・A の順に並べて Image::new_owned() に渡します。

use tauri::image::Image;
use tauri_plugin_clipboard_manager::ClipboardExt;

// ビルド時に RGBA へ変換して埋め込む
const LOGO: Image<'static> = tauri::include_image!("./icons/32x32.png");

#[tauri::command]
fn copy_logo(app: tauri::AppHandle) -> Result<(), String> {
    app.clipboard().write_image(&LOGO).map_err(|e| e.to_string())
}

// PNG ファイルをコピーする(image-png 機能が必要)
#[tauri::command]
async fn copy_png_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
    let image = Image::from_path(&path).map_err(|e| format!("{path}: {e}"))?;
    app.clipboard().write_image(&image).map_err(|e| e.to_string())
}

// 画素を自分で作る(8 ピクセルごとに赤と青が交互の 64 x 64)
#[tauri::command]
fn copy_stripes(app: tauri::AppHandle) -> Result<(), String> {
    let (w, h) = (64u32, 64u32);
    let mut rgba = Vec::with_capacity((w * h * 4) as usize);
    for y in 0..h {
        let px: [u8; 4] = if (y / 8) % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
        for _ in 0..w {
            rgba.extend_from_slice(&px);
        }
    }
    app.clipboard()
        .write_image(&Image::new_owned(rgba, w, h))
        .map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_clipboard_manager::init())
        .invoke_handler(tauri::generate_handler![copy_logo, copy_png_file, copy_stripes])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

await invoke('copy_png_file', { path: 'C:/Users/me/Pictures/chart.png' });
await invoke('copy_logo'); // Rust から書き込むので JS 側に allow-write-image は不要

Rust で read_image() を使う場合は、テキストの読み取りと同じくメインスレッドを避けて async fn のコマンドにします。Linux ではメインスレッドで読むとアプリが固まることがあります。

動作確認

npm run tauri dev で起動し、canvas に何か描いてから copyCanvas() を呼び、ペイントやチャットアプリに貼り付けると同じ画像が出ます。copy_stripes の後に貼ると赤と青の横縞になり、RGBA の並び順を確かめられます。読み取りは、ブラウザーなどで画像をコピーしてから次を実行します。

import { readImage } from '@tauri-apps/plugin-clipboard-manager';

const image = await readImage();
console.log(await image.size()); // 例: { width: 64, height: 64 }
await image.close();

よくあるエラーと対処法

  • 「clipboard-manager.write_image not allowed. Permissions associated with this command: clipboard-manager:allow-write-image」: 権限の追加漏れです。リリースビルドでは「Command plugin:clipboard-manager|write_image not allowed by ACL」になります。
  • 「expected RGBA image data, found raw bytes」(パスなら「found a file path」): image-png 機能が付いていません。Cargo.toml に追加してビルドし直します。Image.fromBytes() では「from_bytes is only supported if the image-ico or image-png Cargo features are enabled」になります。
  • 「failed to process image:」で始まるエラー: JPEG など PNG / ICO 以外を渡したか、RGBA の生データを Image.new() で包まずに渡しています。canvas 経由で PNG にするか、Image.new(rgba, width, height) を使います。
  • canvas の書き出しで「汚染された(tainted)canvas は書き出せない」という趣旨の SecurityError: 別オリジンの画像を描いた canvas は PNG にできません。convertFileSrc() で表示した画像も別オリジン扱いなので、src を設定する前に img.crossOrigin = 'anonymous' を付けます。
  • readImage() で「The clipboard contents were not available in the requested format or the clipboard is empty.」: 画像が入っていません。テキストしか無い場合も同じです。

OS ごとの違いと注意点

  • Android / iOS: writeImage() と readImage() は非対応で、「Unsupported on this platform」で失敗します。モバイルで扱えるのはテキストだけです。
  • Linux: Rust から read_image() を呼ぶときは、前述のとおりメインスレッドを避けます。
  • 権限の範囲: パスを渡した writeImage() は、fs プラグインのスコープと関係なくファイルを読みます。外部のページを表示するウィンドウには allow-write-image を付けません。
  • サイズ: 画像は RGBA に展開されて渡るので、4K の画面全体なら約 33 MB になります。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する