Rust+Webassembly+Next.js で拡張子変換アプリを作った
こんにちは!
本日は、茨城高専アドベントカレンダー 7 日目になります
私は茨城高専で一年生をしているものです。
今回は、Rust 初心者の私が作成した拡張子変換アプリについて解説させていただきます。
なぜ作ろうと思ったのか
Web 開発をしていると、画像の容量削減などのために、Webp や svg への変換が必要になるんですよね
概要
この記事では、WebAssembly (WASM) を使用して画像をさまざまな形式に変換するライブラリを作成する方法を紹介します。このライブラリは、画像の読み込み、変換、および基本的な操作を可能にします。
ライブラリの概要
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 などのさまざまな形式に変換できるように設計されています。また、画像の寸法を取得する機能も提供します。
それでは、解説していきます。
コードの解説
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
フィールドを持ちます。
#[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
関数は、画像データを受け取り、画像を読み込みます。画像の読み込みに失敗した場合、エラーが返されます。
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" です。指定された形式がサポートされていない場合、エラーが返されます。
pub fn get_dimensions(&self) -> String {
let (width, height) = self.image.dimensions();
format!("{}x{}", width, height)
}
get_dimensions
関数は、画像の幅と高さを文字列として返します。
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 要素に適切な属性を設定します。
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 からアクセスする必要があります。以下のような例を考えます。
"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;
結果
この例では、画像データを読み込み、画像の寸法を取得し、SVG 形式に変換しています。SVG データを HTML の <canvas>
要素に表示しています。
まとめ
この記事では、画像変換ライブラリの作成方法を紹介しました。このライブラリを使用することで、さまざまな形式の画像を処理し、変換することができます。WASM との統合により、Web ブラウザ上で画像操作を実現することが可能になります。
こちらが作成したものになります
こちらが Github リポジトリです。
アドベントカレンダー
この記事は、アドベントカレンダーとして作成されたものです。毎日、新しいトピックや技術に関する記事が公開されます。ぜひ、他の記事もチェックしてみてください!