Rust+Webassembly+Next.jsで拡張子変換アプリを作った

# Rust+Webassembly+Next.js で拡張子変換アプリを作った

こんにちは！
本日は、茨城高専アドベントカレンダー 7 日目になります
私は[茨城高専](https://www.ibaraki-ct.ac.jp)で一年生をしているものです。
今回は、Rust 初心者の私が作成した拡張子変換アプリについて解説させていただきます。

## なぜ作ろうと思ったのか

Web 開発をしていると、画像の容量削減などのために、Webp や svg への変換が必要になるんですよね

## 概要

この記事では、WebAssembly (WASM) を使用して画像をさまざまな形式に変換するライブラリを作成する方法を紹介します。このライブラリは、画像の読み込み、変換、および基本的な操作を可能にします。

## ライブラリの概要

```rust:lib.rs
use image::{DynamicImage, GenericImageView, ImageOutputFormat, Rgba};
use std::io::Cursor;
use svg::node::element::Rectangle;
use svg::Document;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct ImageConverter {
    image: DynamicImage,
}

#[wasm_bindgen]
impl ImageConverter {
    #[wasm_bindgen(constructor)]
    pub fn new(data: &[u8]) -> Result<ImageConverter, JsError> {
        let img = match image::load_from_memory(data) {
            Ok(img) => img,
            Err(e) => return Err(JsError::new(&format!("Failed to load image: {}", e))),
        };

        Ok(ImageConverter { image: img })
    }

    pub fn convert_to(&self, format: &str) -> Result<Vec<u8>, JsError> {
        let mut buffer = Vec::new();
        let format = match format {
            "jpeg" => ImageOutputFormat::Jpeg(),
            "png" => ImageOutputFormat::Png(),
            "gif" => ImageOutputFormat::Gif,
            "bmp" => ImageOutputFormat::Bmp,
            "ico" => ImageOutputFormat::Ico,
            "tiff" => ImageOutputFormat::Tiff,
            "webp" => ImageOutputFormat::WebP,
            "svg" => return self.convert_to_svg(),
            _ => return Err(JsError::new("Unsupported format")),
        };

        match self.image.write_to(&mut Cursor::new(&mut buffer), format) {
            Ok(_) => Ok(buffer),
            Err(e) => Err(JsError::new(&format!("Failed to convert image: {}", e))),
        }
    }

    pub fn get_dimensions(&self) -> String {
        let (width, height) = self.image.dimensions();
        format!("{}x{}", width, height)
    }

    fn convert_to_svg(&self) -> Result<Vec<u8>, JsError> {
        let (width, height) = self.image.dimensions();
        let mut svg_document = Document::new().set("viewBox", (0, 0, width, height));

        for y in 0..height {
            let mut x = 0;
            while x < width {
                let pixel_color = self.image.get_pixel(x, y);
                let rgb_alpha = pixel_color[3];
                if rgb_alpha == 0 {
                    x += 1;
                    continue;
                }

                let mut line_length = 1;
                while x + line_length < width
                    && self.image.get_pixel(x + line_length, y) == pixel_color
                {
                    line_length += 1;
                }

                let opacity = if rgb_alpha == 255 {
                    1.0
                } else {
                    f32::from(rgb_alpha) / 255.0
                };

                let line = Rectangle::new()
                    .set("x", x)
                    .set("y", y)
                    .set("width", line_length)
                    .set("height", 1)
                    .set("fill", rgb_to_hex(pixel_color))
                    .set("fill-opacity", opacity);
                svg_document = svg_document.add(line);

                x += line_length;
            }
        }

        let mut svg_data = Vec::new();
        match svg::write(&mut svg_data, &svg_document) {
            Ok(_) => Ok(svg_data),
            Err(e) => Err(JsError::new(&format!("Failed to write SVG: {}", e))),
        }
    }
}

fn rgb_to_hex(rgb: Rgba<u8>) -> String {
    format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
}
```

このライブラリは、画像を読み込み、JPEG、PNG、GIF、BMP、ICO、TIFF、WebP、SVG などのさまざまな形式に変換できるように設計されています。また、画像の寸法を取得する機能も提供します。

それでは、解説していきます。

## コードの解説

```rust
use image::{DynamicImage, GenericImageView, ImageOutputFormat, Rgba};
use std::io::Cursor;
use svg::node::element::Rectangle;
use svg::Document;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct ImageConverter {
    image: DynamicImage,
}
```

まず、必要なライブラリをインポートします。`image` ライブラリは画像の読み込みと操作を処理し、`svg` ライブラリは SVG ファイルの作成に使用されます。`wasm_bindgen` ライブラリは WASM とのインターフェースを提供します。

`ImageConverter` 構造体は、画像を保持する `image` フィールドを持ちます。

```rust
#[wasm_bindgen(constructor)]
pub fn new(data: &[u8]) -> Result<ImageConverter, JsError> {
    let img = match image::load_from_memory(data) {
        Ok(img) => img,
        Err(e) => return Err(JsError::new(&format!("Failed to load image: {}", e))),
    };

    Ok(ImageConverter { image: img })
}
```

`new` 関数は、画像データを受け取り、画像を読み込みます。画像の読み込みに失敗した場合、エラーが返されます。

```rust
pub fn convert_to(&self, format: &str) -> Result<Vec<u8>, JsError> {
    let mut buffer = Vec::new();
    let format = match format {
        "jpeg" => ImageOutputFormat::Jpeg(),
        "png" => ImageOutputFormat::Png(),
        "gif" => ImageOutputFormat::Gif,
        "bmp" => ImageOutputFormat::Bmp,
        "ico" => ImageOutputFormat::Ico,
        "tiff" => ImageOutputFormat::Tiff,
        "webp" => ImageOutputFormat::WebP,
        "svg" => return self.convert_to_svg(),
        _ => return Err(JsError::new("Unsupported format")),
    };

    match self.image.write_to(&mut Cursor::new(&mut buffer), format) {
        Ok(_) => Ok(buffer),
        Err(e) => Err(JsError::new(&format!("Failed to convert image: {}", e))),
    }
}
```

`convert_to` 関数は、指定された形式に画像を変換します。サポートされている形式は、"jpeg"、"png"、"gif"、"bmp"、"ico"、"tiff"、"webp"、"svg" です。指定された形式がサポートされていない場合、エラーが返されます。

```rust
pub fn get_dimensions(&self) -> String {
    let (width, height) = self.image.dimensions();
    format!("{}x{}", width, height)
}
```

`get_dimensions` 関数は、画像の幅と高さを文字列として返します。

```rust
fn convert_to_svg(&self) -> Result<Vec<u8>, JsError> {
    let (width, height) = self.image.dimensions();
    let mut svg_document = Document::new().set("viewBox", (0, 0, width, height));

    for y in 0..height {
        let mut x = 0;
        while x < width {
            let pixel_color = self.image.get_pixel(x, y);
            let rgb_alpha = pixel_color[3];
            if rgb_alpha == 0 {
                x += 1;
                continue;
            }

            let mut line_length = 1;
            while x + line_length < width
                && self.image.get_pixel(x + line_length, y) == pixel_color
            {
                line_length += 1;
            }

            let opacity = if rgb_alpha == 255 {
                1.0
            } else {
                f32::from(rgb_alpha) / 255.0
            };

            let line = Rectangle::new()
                .set("x", x)
                .set("y", y)
                .set("width", line_length)
                .set("height", 1)
                .set("fill", rgb_to_hex(pixel_color))
                .set("fill-opacity", opacity);
            svg_document = svg_document.add(line);

            x += line_length;
        }
    }

    let mut svg_data = Vec::new();
    match svg::write(&mut svg_data, &svg_document) {
        Ok(_) => Ok(svg_data),
        Err(e) => Err(JsError::new(&format!("Failed to write SVG: {}", e))),
    }
}
```

`convert_to_svg` 関数は、画像を SVG 形式に変換します。画像の各ピクセルを処理し、SVG ドキュメントに追加します。透明度を考慮し、SVG 要素に適切な属性を設定します。

```rust
fn rgb_to_hex(rgb: Rgba<u8>) -> String {
    format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
}
```

`rgb_to_hex` 関数は、RGB 値を 16 進数の文字列に変換します。

## 使い方

このライブラリを使用するには、WASM ファイルを HTML ファイルに埋め込み、JavaScript からアクセスする必要があります。以下のような例を考えます。

```typescript:page.tsx
"use client";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, Image as ImageIcon, Download } from "lucide-react";
import Head from "next/head";
import { motion } from "framer-motion";

const ImageConverter = () => {
  const [file, setFile] = useState<File | null>(null);
  const [convertedImage, setConvertedImage] = useState<{
    url: string;
    dimensions: string;
    format: string;
  } | null>(null);
  const [isConverting, setIsConverting] = useState(false);
  const [selectedFormat, setSelectedFormat] = useState("png");
  const [error, setError] = useState("");

  const onDrop = useCallback(async (acceptedFiles: File[]) => {
    setError("");
    const file = acceptedFiles[0];
    if (file) {
      setFile(file);
      setConvertedImage(null);
    }
  }, []);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: {
      "image/*": [
        ".jpeg",
        ".jpg",
        ".png",
        ".gif",
        ".webp",
        ".bmp",
        ".ico",
        ".tiff",
        ".pdf",
      ],
    },
    multiple: false,
  });

  const handleConvert = async () => {
    if (!file) return;

    setIsConverting(true);
    setError("");

    try {
      const imageModule = await import("../../../rust/pkg/image_converter");
      await imageModule.default();
      const arrayBuffer = await (file as File).arrayBuffer();
      const uint8Array = new Uint8Array(arrayBuffer);

      const converter = new imageModule.ImageConverter(uint8Array);
      const convertedData = converter.convert_to(selectedFormat);
      const dimensions = converter.get_dimensions();

      const blob = new Blob([convertedData], {
        type: `image/${selectedFormat}`,
      });
      setConvertedImage({
        url: URL.createObjectURL(blob),
        dimensions,
        format: selectedFormat,
      });
    } catch (err) {
      setError((err as Error).message || "Failed to convert image");
    } finally {
      setIsConverting(false);
    }
  };

  const handleDownload = () => {
    if (!convertedImage) return;

    const link = document.createElement("a");
    link.href = convertedImage?.url;
    link.download = `converted-image.${convertedImage?.format}`;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };

  return (
    <>
      <Head>
        <title>Image Converter</title>
        <meta name="description" content="Image Converter" />
      </Head>
      <div className="py-12 px-4 sm:px-6 lg:px-8">
        <div className="max-w-4xl mx-auto">
          <motion.h1
            className="text-4xl font-extrabold text-center mb-8 bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent"
            initial={{ opacity: 0, y: -50 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.5 }}
          >
            Image Format Converter
          </motion.h1>

          <div className="grid md:grid-cols-2 gap-8">
            {/* Input Section */}
            <motion.div
              className="space-y-6 bg-white p-6 rounded-2xl shadow-lg"
              initial={{ opacity: 0, scale: 0.9 }}
              animate={{ opacity: 1, scale: 1 }}
              transition={{ duration: 0.5 }}
            >
              <motion.div
                {...getRootProps({ refKey: 'ref' })}
                className={`border-3 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-300 ease-in-out transform hover:scale-[1.02]
                  ${
                    isDragActive
                      ? "border-blue-500 bg-blue-50"
                      : "border-gray-300 hover:border-blue-400"
                  }`}
                whileHover={{ scale: 1.05 }}
              >
                <input {...getInputProps()} />
                <Upload className="mx-auto h-12 w-12 text-blue-500 mb-4" />
                <p className="text-gray-600 font-medium">
                  {isDragActive
                    ? "Drop the image here"
                    : "Drag & drop an image, or click to select"}
                </p>
              </motion.div>

              {file && (
                <motion.div
                  className="space-y-4"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: 0.5 }}
                >
                  <div className="flex items-center space-x-4 bg-gray-50 p-3 rounded-lg">
                    <ImageIcon className="h-5 w-5 text-blue-500" />
                    <span className="text-sm text-gray-700 font-medium">
                      {file?.name}
                    </span>
                  </div>

                  <div className="flex space-x-4">
                    <select
                      title="Select format"
                      value={selectedFormat}
                      onChange={(e) => setSelectedFormat(e.target.value)}
                      className="block w-full rounded-lg border-gray-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white"
                    >
                      <option value="png">PNG</option>
                      <option value="jpeg">JPEG</option>
                      <option value="webp">WebP</option>
                      <option value="gif">GIF</option>
                      <option value="bmp">BMP</option>
                      <option value="ico">ICO</option>
                      <option value="tiff">TIFF</option>
                      <option value="svg">SVG</option>
                      <option value="png">Lossless (PNG)</option>
                    </select>

                    <motion.button
                      type="button"
                      onClick={handleConvert}
                      disabled={isConverting || !file}
                      className="px-6 py-2 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-lg hover:from-blue-600 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 transition-all duration-200 font-medium shadow-md"
                      whileHover={{ scale: 1.05 }}
                    >
                      {isConverting ? "Converting..." : "Convert"}
                    </motion.button>
                  </div>
                </motion.div>
              )}
            </motion.div>

            {/* Output Section */}
            <motion.div
              className="space-y-6 bg-white p-6 rounded-2xl shadow-lg"
              initial={{ opacity: 0, scale: 0.9 }}
              animate={{ opacity: 1, scale: 1 }}
              transition={{ duration: 0.5 }}
            >
              {error && (
                <motion.div
                  className="p-4 bg-red-50 text-red-700 rounded-xl border border-red-200"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: 0.5 }}
                >
                  {error}
                </motion.div>
              )}

              {convertedImage && (
                <motion.div
                  className="space-y-4"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: 0.5 }}
                >
                  <div className="aspect-w-16 aspect-h-9 bg-gray-50 rounded-xl overflow-hidden shadow-inner">
                    <img
                      src={convertedImage?.url}
                      alt="Converted"
                      className="object-contain"
                    />
                  </div>

                  <div className="flex items-center justify-between">
                    <div className="text-sm text-gray-600 font-medium px-3 py-1 bg-gray-100 rounded-full">
                      {convertedImage?.dimensions} •{" "}
                      {convertedImage?.format.toUpperCase()}
                    </div>

                    <motion.button
                      type="button"
                      onClick={handleDownload}
                      className="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-lg hover:from-green-600 hover:to-emerald-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-all duration-200 font-medium shadow-md"
                      whileHover={{ scale: 1.05 }}
                    >
                      <Download className="h-4 w-4" />
                      <span>Download</span>
                    </motion.button>
                  </div>
                </motion.div>
              )}
            </motion.div>
          </div>
        </div>
      </div>
    </>
  );
};

export default ImageConverter;
```
### 結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929661/15a91df9-0560-8e65-e010-879a86841279.png)

この例では、画像データを読み込み、画像の寸法を取得し、SVG 形式に変換しています。SVG データを HTML の `<canvas>` 要素に表示しています。

## まとめ

この記事では、画像変換ライブラリの作成方法を紹介しました。このライブラリを使用することで、さまざまな形式の画像を処理し、変換することができます。WASM との統合により、Web ブラウザ上で画像操作を実現することが可能になります。

[こちら](https://fukayatti0-toolsbox.vercel.app/image-converter)が作成したものになります

https://fukayatti0-toolsbox.vercel.app/image-converter

[こちら](https://github.com/fukayatti0/my-toolsbox/)が Github リポジトリです。

https://github.com/fukayatti0/my-toolsbox/

---

**アドベントカレンダー**

この記事は、アドベントカレンダーとして作成されたものです。毎日、新しいトピックや技術に関する記事が公開されます。ぜひ、他の記事もチェックしてみてください！