❄️

RustでOG画像をどこでも生成できるライブラリを作りました。

2022/01/30に公開

Overview

og_image_wrirter というOG画像を生成できるRustのライブラリを作りました。

このライブラリはwasmを通してどこでも動きます。WebでもEdgeでも動きます。
wasiでも動くので、試せてないですがFastlyのlucetなどのruntimeでも動くはずです。
先日Cloudflare Workersで実際に動かす記事を書きました。

APIはCSS likeなスタイリングをサポートしており、比較的直感的にスタイリングできます。(現状、完全に仕様に準拠しているわけではないです、、)

今回は主にRustでの使い方について紹介します。
また、現状最低限の文字列操作と画像操作、単純なレイアウトしかサポートしていないので他にも欲しい機能などあればissueを投げていただけるととても嬉しいです。

使い方

背景画像に対して描画する

以下のような背景画像に対して

紫の背景色で左下にkeiya01のサムネイルが載っている

中央揃えで文字を表示する方法を紹介します。

example for simple case in og_image_writer

実装例は以下のような感じです。

use og_image_writer::{style, writer::OGImageWriter, img::ImageInputFormat};
use std::path::Path;

fn main() -> anyhow::Result<()> {
    let text = "This is Open Graphic Image Writer for Web Developer.";

    let mut writer = OGImageWriter::from_data(
        style::WindowStyle {
            align_items: style::AlignItems::Center,
            justify_content: style::JustifyContent::Center,
            ..style::WindowStyle::default()
        },
	// 背景画像を読み込む
        include_bytes!("../../assets/og_template.png"),
	// 背景画像の画像形式を選ぶ
        ImageInputFormat::Png,
    )?;

    let font = Vec::from(include_bytes!("../../fonts/Mplus1-Black.ttf") as &[u8]);

    writer.set_text(
        text,
        style::Style {
            margin: style::Margin(0, 20, 0, 20),
            line_height: 1.8,
            font_size: 100.,
            word_break: style::WordBreak::Normal,
            color: style::Rgba([255, 255, 255, 255]),
            text_align: style::TextAlign::Start,
            ..style::Style::default()
        },
        Some(font),
    )?;

    let out_dir = "../examples";
    let out_filename = "output_background_image.png";

    writer.generate(Path::new(&format!("{}/{}", out_dir, out_filename)))?;

    Ok(())
}

まず OGImageWriter についてですが、このstructが画像を描画するための構造体です。
OGImageWriter::from_dataはdata(画像)を背景とする画像を生成するための構造体を返します。
OGImageWriter::from_data以外にもOGImageWriter::newがあり、こちらは直接背景色を指定することができて、それが背景となります。

WindowStyleはwindow、つまり背景画像のスタイルを示します。windowの上に文字などを描画していきます。

HTMLで表すと以下のような状態です。

<div class="window">
  <span>windowの上に文字を描画する</span>
</div>

次に文字の描画についてです。

writer.set_text(
  text,
  style::Style {
    margin: style::Margin(0, 20, 0, 20),
    line_height: 1.8,
    font_size: 100.,
    word_break: style::WordBreak::Normal,
    color: style::Rgba([255, 255, 255, 255]),
    text_align: style::TextAlign::Start,
    ..style::Style::default()
  },
  Some(font),
)?;

文字の描画にはwriter.set_textを使います。これはOGImageWriterのメソッドです。

HTMLで表すと以下のような状態です。

<div class="window">
  <span class="text">This is Open Graphic Image Writer for Web Developer.</span>
</div>

第1引数に描画したいテキストを入力し、第2引数にStyle構造体を入れます。また、第3引数にはfontを指定します。

wasmで動くようにする関係でfontは必ず外部から読み込む必要があります。

Styleはwindow以外の要素のスタイリングに用います。font_sizemarginなどのいろいろなスタイルを使えます。

最後に画像の描画 + 画像ファイルの生成をします。これを行うにはwriter.generateを使います。

writer.generate(Path::new(&format!("{}/{}", out_dir, out_filename)))?;

この関数は描画と画像生成までを行います。もし、描画だけして画像生成は違うプロセスで行いたい場合は、writer.painを使います。
さらに、描画された画像をvectorとして吐き出したい場合はwriter.into_vecを使います。

writer.pain();
let data = writer.into_vec();
do_somthing(data);

画像の描画

画像を描画する方法について紹介します。
以下のような画像を生成します。

紫の背景色で左上にkeiya01のアイコンがある

以下が実装例です。

use og_image_writer::{img::ImageInputFormat, style, writer::OGImageWriter, Error};
use std::path::Path;

fn main() -> anyhow::Result<()> {
    let text = "This is Open Graphic Image Writer for Web Developer.";

    let mut writer = OGImageWriter::new(style::WindowStyle {
        width: 1024,
        height: 512,
        background_color: Some(style::Rgba([70, 40, 90, 255])),
        align_items: style::AlignItems::Center,
        justify_content: style::JustifyContent::Center,
        ..style::WindowStyle::default()
    })?;

    writer.set_img_with_data(
        include_bytes!("../../../assets/thumbnail_circle.png"),
        100,
        100,
        ImageInputFormat::Png,
        style::Style {
            margin: style::Margin(0, 20, 0, 20),
            position: style::Position::Absolute,
            text_align: style::TextAlign::End,
            top: Some(20),
            left: Some(0),
            border_radius: style::BorderRadius(50, 50, 50, 50),
            ..style::Style::default()
        },
    )?;

    let font = Vec::from(include_bytes!("../../../fonts/Mplus1-Black.ttf") as &[u8]);

    writer.set_text(
        text,
        style::Style {
            margin: style::Margin(0, 20, 0, 20),
            line_height: 1.8,
            font_size: 50.,
            color: style::Rgba([255, 255, 255, 255]),
            text_align: style::TextAlign::End,
            max_height: Some(150),
            text_overflow: style::TextOverflow::Ellipsis,
            position: style::Position::Absolute,
            bottom: Some(0),
            right: Some(0),
            ..style::Style::default()
        },
        Some(font),
    )?;

    let out_dir = "../examples";
    let out_filename = "output_absolute.png";

    writer.generate(Path::new(&format!("{}/{}", out_dir, out_filename)))?;

    Ok(())
}

大体は最初の例と同じなので省きます。
画像を描画する関数であるwriter.set_img_with_dataについて説明します。

writer.set_img_with_data(
  include_bytes!("../../../assets/thumbnail_circle.png"),
  100,
  100,
  ImageInputFormat::Png,
  style::Style {
    margin: style::Margin(0, 20, 0, 20),
    position: style::Position::Absolute,
    text_align: style::TextAlign::End,
    top: Some(20),
    left: Some(0),
    border_radius: style::BorderRadius(50, 50, 50, 50),
    ..style::Style::default()
  },
)?;

第1引数に描画したい画像を指定します。画像を描画するメソッドは2種類あります。set_img_with_dataはdataを受け取り、set_imgはpathを受け取り自動で画像を読み込みます。
第2、第3引数にはwidthとheightを指定します。
第4引数にformatを指定し、第5引数にStyleを入れます。

border_radiusはアンチエイリアシングに対応しています。

Container要素

writer.set_containerを使うとwriter自身をwindowに描画できます。

pub fn main() -> anyhow::Result<(), Error> {
    // ...

    // windowに描画するためのcontainerを定義する
    let mut container = OGImageWriter::new(style::WindowStyle {
        width: 500,
        height: 250,
        background_color: Some(style::Rgba([70, 40, 90, 255])),
        align_items: style::AlignItems::Center,
        justify_content: style::JustifyContent::Center,
        ..style::WindowStyle::default()
    })?;

    // ...

    let mut writer = OGImageWriter::new(style::WindowStyle {
        width: 1024,
        height: 512,
        background_color: Some(style::Rgba([255, 255, 255, 255])),
        align_items: style::AlignItems::Center,
        justify_content: style::JustifyContent::Center,
        ..style::WindowStyle::default()
    })?;

    // containerをwindowに描画する
    writer.set_container(
        &mut container,
        style::Style {
            margin: style::Margin(0, 0, 10, 0),
            text_align: style::TextAlign::Center,
            border_radius: style::BorderRadius(10, 10, 10, 10),
            ..style::Style::default()
        },
    )?;

    // ...
}

完全な例はexampleを参考にしてください。

以下のようなボックスを描画できます。

白い背景色に紫のボックスがあり、その下にHello Worldと描画されている

HTMLで表すと以下のような感じです。

<div class="window">
  <div class="container">
    <span class="text">...</span>
  </div>
  <span class="text">Hello World</span>
</div>

いろいろなfontを使う

単語や文章によって異なるfontを使いたいことがあると思います。そのような時にはFontContextTextAreaが役に立ちます。

fn main() -> anyhow::Result<(), Error> {
    // ...

    // Set global font data
    let mut fc = FontContext::new();
    fc.push(Vec::from(
        include_bytes!("../../../fonts/OpenSansCondensed-Light.ttf") as &[u8],
    ))?;
    fc.push(Vec::from(
        include_bytes!("../../../fonts/Roboto-Light.ttf") as &[u8]
    ))?;
    fc.push(Vec::from(
        include_bytes!("../../../fonts/Mplus1-Black.ttf") as &[u8]
    ))?;

    let mut textarea = TextArea::new();
    textarea.push_text("こんにちは。 ");
    textarea.push_text("This is ");
    textarea.push(
        "Open Graphic Image Writer",
        style::Style {
            color: style::Rgba([255, 0, 255, 255]),
            font_size: 100.,
            ..style::Style::default()
        },
        None,
    )?;
    textarea.push_text(" for ");
    textarea.push(
        "Web Developer(Web開発者)",
        style::Style {
            color: style::Rgba([255, 0, 0, 255]),
            font_size: 100.,
            ..style::Style::default()
        },
        Some(Vec::from(
            include_bytes!("../../../fonts/Roboto-Light.ttf") as &[u8]
        )),
    )?;
    textarea.push_text("!!!");

    writer.set_textarea(
        textarea,
        style::Style {
            margin: style::Margin(0, 20, 0, 20),
            line_height: 1.8,
            font_size: 100.,
            // word_break: style::WordBreak::Normal,
            color: style::Rgba([255, 255, 255, 255]),
            text_align: style::TextAlign::Start,
            word_break: style::WordBreak::BreakAll,
            ..style::Style::default()
        },
        None,
    )?;

    // ...
}

完全な例はexampleから確認できます。

FontContextにはglobalで共有されるfontを追加します。ここで指定されたfontは入力された文字がset_textset_textareaに指定されたfontに一致しないときに使用されます。

またwriter.paint()が実行されたタイミングでFontContextはリセットされます。

writer.set_textareaはTextAreaを描画します。TextAreaは文字列の集合を表す構造体で、TextArea::newで構造体を作った後、textarea.pushtextarea.push_textを使うことで一つの文字の塊ごとに文字とそのスタイルを追加できます。

HTMLで表すと以下のようなイメージです。

<p class="textarea">
  <span>Hello </span>
  <span>World</span>
</p>

writer.set_textareaで指定されたスタイルはtextarea.pushまたはtextarea.push_textのスタイルに継承されます。もしtextarea.pushでスタイルが指定されていた場合にはそちらが優先されます。

fontの優先度は以下のような順番で優先度が決まります。

  1. textarea.pushに指定されたStyle
  2. writer.set_textareaに指定されたStyle

fontに関しても同じで、writer.set_textareaで指定されたfontはtextarea.pushまたはtextarea.push_textのfontに継承されます。もしtextarea.pushでfontが指定されていた場合にはそちらが優先されます。

fontの優先度は以下のような順番で優先度が決まります。

  1. textarea.pushに指定されたFont
  2. writer.set_textareaまたはwriter.set_textに指定されたFont
  3. FontContext

いろいろなfontが雑に描画されている画像

その他の例

実際の例はog_image_writer/dev/componentsに載っています。
また、成果物として生成される画像はog_image_writer/dev/snapshotsに載っています。

注意点

現状、指定された文字に対応するfontがないときはエラーを投げるようにしています。これは開発の過程で空文字で動いている時にすぐに気づけるようにするためです。そのため、exampleを参考にFontContextを使ってfallbackのfontを指定しておくと安全です。
現在はこのような挙動ですが、無数の文字を考慮するのは難しいため、対応するfontが見つからない時は豆腐や空白を表示するような修正を入れようかと考えています。

対応するかもしれないこと

  • fontがない時の豆腐の描画
  • 絵文字の描画
  • ボーダーのサポート
  • object-fit: cover; 的なやつ
  • 宣言的に定義できるようにする

などなど。

これ以外で欲しい機能があったり、このリストに載っているもので早く使いたい機能があればissueなどで起票いただけると早めに実装するかもしれません。

まとめ

まだまだプロダクション等で使うとなると足りない部分が多そうですが、もし使っていただけた際にはfeedbackいただけると嬉しいです。

またPlayground兼ドキュメントみたいなものも作っています。
https://og-image-writer.pages.dev/

Discussion