この記事は はてなエンジニア Advent Calendar 2024 20 日目の記事です。昨日は
id:tokizuoh さんのCHANGELOG.mdの変更をRSSフィードに流すツールを書いたでした。
背景
ちょっと業務*1で Rust で画像処理する機会があったのでそのときにハマったことを書きます。
業務に関する話は 0 です。全部技術話です。
今回説明したコードのリポジトリは以下となります:
環境/依存など
- Rust: 1.83.0
- crates
やること
以下のような 300 * 300 の黒背景の画像に赤文字*4の "Hoge" を x = 0, y = 0 の位置で 64pt のサイズで合成します。フォントは DejaVu Sans を使います。また、フォントのサイズがばらばらにならないように dpi は 72dpi にしてます。
合成前:

合成後:

愚直な実装
とりあえず、黒背景を描画→文字をその上に描画する感じで実装してみます。以下のようなソースコードを書いて実行します (src/bin/processing_normal.rs)。
use ab_glyph::{Font as _, FontRef}; use image::{Rgba, RgbaImage}; use imageproc::{ drawing::{draw_filled_rect_mut, draw_text_mut}, rect::Rect, }; const WIDTH: u32 = 300; // 画像の幅 const HEIGHT: u32 = 300; // 画像の高さ const OUTPUT_DIR: &str = "output/"; // 画像の保存先 const FONT_SIZE: f32 = 64.0 * 72.0 / 96.0; // フォントサイズ (96dpi -> 72dpi に変換) fn main() { // 1. 300 x 300 の黒背景を作成 let mut image = RgbaImage::new(WIDTH, HEIGHT); draw_filled_rect_mut( &mut image, Rect::at(0, 0).of_size(WIDTH, HEIGHT), Rgba([0, 0, 0, 255]), ); // 2. フォントを読み込む let font = FontRef::try_from_slice(include_bytes!("../font/DejaVuSans.ttf")).unwrap(); // 3. 位置計算 let px_scale = font.pt_to_px_scale(FONT_SIZE).unwrap(); let real_x_pos = 0.0; let real_y_pos = 0.0; // 4. テキストを描画 draw_text_mut( &mut image, Rgba([255u8, 0u8, 0u8, 255u8]), real_x_pos as i32, real_y_pos as i32, px_scale, &font, "Hoge", ); image .save(format!("{}processing_normal.png", OUTPUT_DIR)) .unwrap(); }
実行してみると以下のような画像が生成されます。

見てみるとわかる通り、x, y の位置がずれています。 バグに見えますが、バグではなく仕様です。
詳細は以下の Issue に書いてありますが、 メンテナが
I think It's not the wrong Y position, it's the wrong text_size
と、
Thats probably glyph "bearing" at the top. But text_size is broken for sure!
と言っている感じ、空白 (ここでは bearing) が入るのは仕様っぽく、特に問題はないのこと*5。
位置を修正する
ということで、文字の位置を修正しましょう。imageproc を使った文字位置の修正は以下の他者のエントリでも言及されているので、こちらも参考になるかと思います:
imageproc crate の文字処理は全て ab_glyph crate に委譲されているので、ab_glyph crate を使って色々頑張ります。
x 座標の修正
まず、文字の x 座標の修正からやってみます。これは文字列 'Hoge' に含まれている一文字目 'H' との空白を埋めればいいので、ScaleFont::h_side_bearing*6 を使用して空白部分を取得します。
ソースコードは以下のようになりました:
// 移動する x 座標を計算 fn calc_x<F: Font>(text: &str, px_scale_font: &PxScaleFont<F>) -> f32 { let first_char = text.chars().next().unwrap(); let h_side_beraring = px_scale_font.h_side_bearing(px_scale_font.glyph_id(first_char)); -h_side_beraring }
エラーハンドリングはまともにやっていませんが、text.chars().next().unwrap() で最初の一文字目を取得し、px_scale_font.glyph_id(first_char) で GlyphId を取得、px_scale_font.h_side_bearing()でその文字の空白部分の大きさを取得します。
どうしてこれで取れるかは ab_glyph crate の Glyph layout concepts を参照してください。
また、PxScaleFont は以下のような感じで呼ぶことができます:
let px_scale = font.pt_to_px_scale(FONT_SIZE).unwrap(); let px_scale_font = font.as_scaled(px_scale);
最終的に、呼び出し側はlet real_x_pos = calc_x(TEXT, &px_scale_font); のように呼ぶことができます。
ソースコード URL: https://github.com/KashEight/hatena-advent-2024/blob/f3eee8826393b85d0ce080242d9c3ab1c25fc1b2/src/bin/processing_x.rs
x 座標を修正した結果が以下となります:

しっかり修正できてますね。
y 座標の修正
次は y 座標を修正します。y 座標の修正は中々に厄介で、x 座標のように一発で取れるようなことはできず、一文字ずつ空白の大きさを知る必要があります。
ここでは、OutlineGlyph::px_bounds*7 と ScaleFont::glyph_bounds*8 を使いました。
前者は Glyph、すなわち文字の実際の大きさ、後者は描画したときの大きさとなっており、基本的には ScaleFont::glyph_bounds の方が大きくなります*9。これを利用して、一番小さい ScaleFont::glyph_bounds - OutlineGlyph::px_bounds の値を探します。
ソースコードは以下のようになりました:
// 移動する y 座標を計算 fn calc_y<F: Font>(text: &str, px_scale_font: &PxScaleFont<F>) -> f32 { let result = text .chars() .map(|c| { let outline_glyph = px_scale_font .outline_glyph(px_scale_font.scaled_glyph(c)) .unwrap(); let px_bounds = outline_glyph.px_bounds(); let glyph_bounds = px_scale_font.glyph_bounds(&px_scale_font.scaled_glyph(c)); let min_y_sub = px_bounds.min.y - glyph_bounds.min.y; min_y_sub }) .fold(f32::NAN, |m, v| v.min(m)); -result }
ソースコード URL: https://github.com/KashEight/hatena-advent-2024/blob/f3eee8826393b85d0ce080242d9c3ab1c25fc1b2/src/bin/processing_y.rs
text.chars() で Chars 構造体に変換したあと、map() を使用して一文字ずつ x 座標を求めた要領と同じ感じで差分を求めます。最後に、fold(f32::NAN, |m, v| v.min(m)) とすることで最小値を求めています。f32 型は Ord trait を満たしていないので fold()を使った方法をとっています。
参考: https://qiita.com/lo48576/items/343ca40a03c3b86b67cb
y 座標を修正した結果が以下となります:

こちらもしっかり修正できてますね。
修正後
これはただ単純に結果だけを載せます。
ソースコード URL: https://github.com/KashEight/hatena-advent-2024/blob/f3eee8826393b85d0ce080242d9c3ab1c25fc1b2/src/main.rs

いい感じですね。ほぼ期待していた感じになりました。
まとめ
というわけで、ab_glyph crate をうまく使って Rust で画像処理/文字合成ができるようになりました。Rust で文字合成したいという方は是非ここらへん気をつけてやってみてください*10。
余談
実は上記の方法は x, y が固定の場合に成り立つ方法で、ベースラインに沿った文字列だとちょっとうまくいきません。これに関しては一工夫ですぐ解決できるので別記事で解説したいところですね。
あと、執筆したあとに気づきましたが、y 座標の修正は map() 使わずに ScaleFont::glyph_bounds と ScaleFont::ascent*11 の四則演算でいけますね。これも別記事で書きます。
2025/01/20 追記: 上記 y 座標の修正は勘違いでした、今回の方法以外にやり方はなさそうな気がします
*1:と言っても社内ツールレベル
*2:他の記事[1][2]では rusttype crate を用いていますが、imageproc crate の依存では ab_glyph crate が使われているのでこちらを使います。実際、README では上位互換らしいっぽいので…。
*3:後述するとあるメソッドにバグがあるので git の rev を直接指定してます
*4:RGB: R=255, G=0, B=0 | カラーポイント: #FF0000
*5:ただ text_size メソッドが壊れていたらしい。imageproc crate を git rev で指定したのはこの修正を取り込んだ意図があります。今回はおそらく使わなかったけど。
*6:https://docs.rs/ab_glyph/0.2.29/ab_glyph/trait.ScaleFont.html#method.h_side_bearing
*7:https://docs.rs/ab_glyph/0.2.29/ab_glyph/struct.OutlinedGlyph.html#method.px_bounds
*8:https://docs.rs/ab_glyph/0.2.29/ab_glyph/trait.ScaleFont.html#method.glyph_bounds
*9:実際は、両方とも Rect と呼ばれる構造体を返しますが、内部的には Point という x, y 座標を示す構造体の集まりなので大きさの比較が可能です
*10:実は、最初に出している期待画像はデザインツール (Affinity Designer 2) で合成したものなので今回合成したものとはラスタライズの関係上、色がずれているなどしてます、なので厳密には同じ画像ではなかったりします
*11:https://docs.rs/ab_glyph/0.2.29/ab_glyph/trait.ScaleFont.html#method.ascent