Typstのimage関数にrotationパラメタを追加したかった話
はじめに
この記事は 2024/10/07 ~ 2024/10/29 に開講された、東京大学工学部電子情報工学科・電気電子工学科の後期実験「大規模ソフトウェアを手探る」の課題レポートを兼ねています。
今回我々は組版ソフトウェアTypstのimage
関数に、回転のためのパラメーターを追加するという目標のもと開発を行った。
Typstについて
Typstの実装言語
Typstは以下のgithubからわかる通り、ほぼRustで書かれていた。開発中も他の言語に遭遇することはなく、開発にはRust(とTypst)の知識以外は必要なかったように感じた[1]。
Typstのコンパイルシステム
Typstのコンパイルシステムはcrates/typst/src/lib.rs
以下に詳しく書かれていた。
軽い意訳
Parsing(解析)
与えられた.typ
ファイルの平文をトークンに分解していく作業。ここで関数は構文木にとりこまれていく。
Evaluation(評価)
それぞれのトークンを解析し、出力値のスコープやソースファイルの内容を階層的に表現したモジュールを生成する。
Layouting(配置)
それぞれのモジュールをページに配置する。
Exporting(出力)
PDF, PNG, SVGなどに出力。
例えば以下のような文を入力するとする。
すると、まずParsingで以下のような構文木になる。
この構文木をEvaluateして、次のようになる。
これをLayouting, Exportingをすると、目的のPDF等が出力される、ということである。
開発
開発目標へのアプローチ
上記のコンパイルシステムから、我々は2通りのアプローチを考えた。
- Evaluationの時、画像データ自体を回転させる
- Layoutingの時にモジュールを回転させる
それぞれについての詳細は以下の通りである。
1. Evaluationの時、画像データ自体を回転させる方法
画像はピクセルの行列と考えられるため、以下のように行列を回転させてあげればよい、という考え[2]。
利点
- 既にあるパッケージである
image::imageops
を使用できる- 既にTypst内で使用されている(後述)ため、使えることもわかっている
欠点
- ピクセル行列で書かれていないSVGファイル等の回転には使えない
-
image::imageops
が90°毎の回転にしか対応していない
2. Layoutingの時にモジュールを回転させる
画像を読みこんだ後に、Layoutの時モジュールを回せばいい、という考え。
利点
- ラスター、ベクター問わず回転できる
-
rotate
関数の実装はこちらなので、上手く呼びだしてあげればよい
欠点
-
#image
関数がParsingされるタイミングで構文木に干渉しないといけないため、実装がとても大変 -
rotate
の呼びだしが複雑であり実装の読み解き難易度も高い
今回私達は1. Evaluationの時、画像データ自体を回転させるのアプローチを採用し、実装していった
環境準備
まずは開発環境を整える。
- rustのセットアップ
公式資料などを参照して、rustup
を入れてrustup update
でセットアップする。また、homebrewを使えば、
$ brew install rust
でもいける。
- Typstのダウンロード・作業レポジトリの準備
公式レポジトリからクローンしてくる。
$ git clone git@github.com:typst/typst.git
今回は班での共同開発だったため、共同作業用のリモートレポジトリを用意しそこをorigin
とし、本流はupstream
として定期的にpullするようにした。
$ git remote rename origin upstream
$ git remote add origin git@github.com:path/to/remote
- Typstのビルド
レポジトリの中に入り、cargo build
するだけ。
$ cd ./typst
$ cargo build
ビルドしたらブランチを分けて開発を開始した。.gitignoreが標準で実行ファイルのディレクトリ(./target)をignoreするようになっているのでとても便利。
$ git branch dev
$ git checkout dev
- デバッガーの準備
開発を行うとき、デバッガーというものを用いることがある。rustはデバッガーの環境がお世事にも整っているとはいえず、選択肢はVSCodeの拡張機能で使えるCodeLLDB以外は特にないように思える。今回もCodeLLDBを持ちいたデバッギングを行ったが、正直使い辛かった。その理由として、変数の読み込みによく失敗するといったデバッガ側の問題や、目標としてる関数の呼び出しが複雑でコールスタックが大変なことになる、などが挙げられる。とはいえ、ブレークポイントで関数の挙動を追えるのは便利ではあった。
VSCodeでCodeLLDBをいれた後、左のパレットからデバッグボタン(虫のついた右向き三角)をクリック→"実行とデバッグ"を押すとデバッガを選ぶことを求められる。
デバッグボタン
"LLDB"を選択すれば自動的にlaunch.jsonファイルが.vscode以下に生成されるはずである。あとは試したい条件をjsonファイルに加えるなどしてデバッグする。例えば私は主に以下の設定のものを用いていた。
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'typst'",
"cargo": {
"args": [
"build",
"--bin=typst",
"--package=typst-cli"
],
"filter": {
"name": "typst",
"kind": "bin"
}
},
"args": ["compile", "${workspaceFolder}/tests/mytest/test.typ"],
"cwd": "${workspaceFolder}"
},
],
"rust-analyzer.linkedProjects": ["${workspaceFolder}/Cargo.toml"],
}
また、コードを改変するにあたりrustのlspであるrust-analyzerやlinterのClippyなどを入れておくとよい。これらはrustup
で入れることができる。
$ rustup component add rust-analyzer clippy
また、cargo check
で実行ファイルを作らずにコンパイル可能か調べられる。
コード読解
ここでははじめ、関数呼び出しをはじめから読みimage
関数の呼び出しを探ろうとした。しかし前述の通りコールスタックが大量に発生していて全て追っていくのは非現実的であるという結論になった。そして関数名やファイル名から関連ファイルを探していく方向へとシフトしていった。
このブログでは後半の関数名探索からの話に絞り、本質ではない前半の模索部分は開発中につけていた進捗ブログにまかせるとする(だいたい4日目あたりが該当するはず)。
関数を探す
では関数名として、当然思いつくのはimage
という名前だろう。なのでまずファイル名を検索してみる。VSCodeの"検索"からでもできるし、以下のコマンドでもよい。
$ ls ./crates/** | grep image
./crates/typst-layout/src/image.rs
./crates/typst-library/src/visualize/image/mod.rs
./crates/typst-library/src/visualize/image/raster.rs
./crates/typst-library/src/visualize/image/svg.rs
./crates/typst-pdf/src/image.rs
./crates/typst-render/src/image.rs
./crates/typst-svg/src/image.rs
色々出てくる。とはいえ、Typstのコードは親切なコメントがついてくるので判別はむずかしくない。
それぞれのファイルの中身
- typst-layout
Layoutまで行く前に回転処理したいのでこれではなさそう。
- typst-pdf
PDFの埋めこみまでいっている、これではない。
- typst-render
レンダリングをおこなっている、ここではなさそう。
- typst-svg
名前からしていかにも違いそうだが、内容もSVGファイルの扱いであり今回用はない。
- typst-library/src/visualize/image
さて、このファイルが目当てのものである。これは実は他のimage.rsファイルに
use typst_library::visualize::{
Image, ImageElem, ImageFit, ImageFormat, Path, RasterFormat, VectorFormat,
};
といった記述があったことからもわかる[3]。
typst-library/src/visualize/imageを読み解く
ということで visualize/image以下を読みといていくと、このような構造体にぶつかる。
この構造体がとる引数は、Typstのimage
関数のとる引数とまったく同じである。
引用: https://typst.app/docs/reference/visualize/image/
よってここでImage関数を処理していることがわかる。このままmod.rsを読みすすめていくと、Image
という構造体とその関数にぶつかる。
名前からして重要そうであるが、いまいち何がしたいかがわからなかったので、with_fonts
が使われている場所を探してみる。すると、typst-layout/src/image.rsで呼ばれていることがわかった(52行目)。
つまり、layout_image
関数にImageElem
を渡し、Image::with_fonts
関数でImage
型にし、それをレイアウトするためのフレームをつくり、画像を埋めこんで返しているらしい。
RasterImage::new を読み解く
これでwith_fonts
が何をしたいかがわかったため、その中身に注視すると、画像がラスターかベクターかで場合分けし、それぞれ関数で処理している。今回はラスターのみ考えるため、とりあえず呼びだされているRasterImage::new
を見にいく。
するとapply_rotation
と、これまで回転のパラメーターを見かけた記憶はないのに謎の回転関数がでてくる。そして、その引数であるrotation
をみてみると、EXIFデータなるものをみていることがわかる。
ではEXIFデータとはなにか?ここの記事に詳しいが、簡単にいうとJPEGやPNG等のファイルのメタデータの一種であり、画像の撮影された情報を保持している。例えば、カメラを縦にして撮影したときなどに画像を回転して縦の状態にして表示するといったことができる。
よって、この関数の実装と似たものを用いれば画像を回転させられるのではないか?
apply_rotation
は上のように実装されている。EXIFデータに対応して、image::imageops
に実装されている関数を呼んでいるだけという、シンプルな実装であった。imageops
についての詳細は公式ドキュメントを参照する。
コード改変
imageops
には鏡像反射もあったため、rotate
パラメタに加えmirror
パラメタも実装してみる。まず、TypstのImage関数にパラメタを追加するために、ImageElem
の構造体に引数を追加してみる。Typstのコードに角度を扱うための型Angle
が実装されていた[4]ため、それをスコープにいれながら実装する。
+ use crate::layout::Angle;
pub struct ImageElem {
//略
pub alt: Option<EcoString>,
#[default(ImageFit::Cover)]
pub fit: ImageFit,
+ pub rotate: Option<Angle>,
+ pub mirror: Option<Ecostring>,
}
ここでコンパイルしてもエラーはなく、rotate
やmirror
をImage
関数に渡してもエラーを吐くことはなかった。よって要素の実装はうまくいってそうである。あとはこれをwith_fonts
が受けとるように引数を追加する。typst-layout/src/image.rsの下にあるlayout_image
にはImageElemがelem
という変数で渡されていることに着目しつつ、
impl Image {
//略
pub fn with_fonts(
data: Bytes,
format: ImageFormat,
alt: Option<EcoString>,
+ rotation: Option<Angle>,
+ mirror: Option<EcoString>,
world: Tracked<dyn World + '_>,
families: &[&str],
) -> StrResult<Image> {
//略
}
pub fn layout_image(
//略
// Construct the image itself.
let image = Image::with_fonts(
data.clone().into(),
format,
elem.alt(styles),
+ elem.rotate(styles),
+ elem.mirror(styles),
engine.world,
&families(styles).collect::<Vec<_>>(),
)
.at(span)?;
//略
}
あとはRasterImage
に引数を与えつつ回転する関数を追加する。
impl Image {
//略
pub fn with_fonts(
//略
) -> StrResult<Image> {
let kind = match format {
ImageFormat::Raster(format) => {
- ImageKind::Raster(RasterImage::new(data, format)?)
+ ImageKind::Raster(RasterImage::new(data, format, rotation, mirror)?)
}
ImageFormat::Vector(VectorFormat::Svg) => {
ImageKind::Svg(SvgImage::with_fonts(data, world, families)?)
}
};
}
+ use crate::layout::Angle;
//略
impl RasterImage {
/// Decode a raster image.
#[comemo::memoize]
pub fn new(
data: Bytes,
format: RasterFormat,
+ my_rotation: Option<Angle>,
+ my_mirror: Option<EcoString>,
) -> StrResult<RasterImage> {
fn decode_with<T: ImageDecoder>(
decoder: ImageResult<T>,
) -> ImageResult<(image::DynamicImage, Option<Vec<u8>>)> {
//略
let exif = exif::Reader::new()
.read_from_container(&mut std::io::Cursor::new(&data))
.ok();
// Apply rotation from EXIF metadata.
if let Some(rotation) = exif.as_ref().and_then(exif_rotation) {
apply_rotation(&mut dynamic, rotation);
}
+ if let Some(my_rotation) = my_rotation {
+ apply_user_rotation(&mut dynamic, my_rotation);
+ }
+ if let Some(my_mirror) = my_mirror {
+ apply_user_mirror(&mut dynamic, my_mirror);
+ }
// Extract pixel density.
let dpi = determine_dpi(&data, exif.as_ref());
Ok(Self(Arc::new(Repr { data, format, dynamic, icc, dpi })))
}
apply_user_rotation
とapply_user_mirror
はそれぞれ以下のような実装。
fn apply_user_rotation(image: &mut DynamicImage, rotation: Angle) {
use image::imageops as ops;
let rotation = rotation.to_deg() as i32;
if rotation % 90 != 0 {
eprintln!(
"Invalid rotation angle: {rotation}, The angle must be a multiple of 90."
);
return;
}
let rotation = rotation.rem_euclid(360);
match rotation {
90 => *image = image.rotate90(),
180 => ops::rotate180_in_place(image),
270 => *image = image.rotate270(),
_ => {},
}
}
fn apply_user_mirror(image: &mut DynamicImage, mirror: EcoString) {
use image::imageops as ops;
let mirror = mirror.as_str();
match mirror {
"horizontal" => ops::flip_horizontal_in_place(image),
"vertical" => ops::flip_vertical_in_place(image),
_ => {}
}
}
あとはrust-analyzerが一部のテスト用プログラムなどへの引数の不足を指摘してくれる。rotate
とmirror
引数はOption<T>
で定義されているため、None
を渡しておけばよい。
結果
以下のようなtypファイルをコンパイルしてみる。
== Section 1
#lorem(30)
#figure(
image("not_desired_behavior.png", width: 25%, rotate: 90deg),
caption: "Ferris"
)
#lorem(15)
このように、rotate
関数では発生してしまう小型化もなく画像を回転させることができた。また、mirror関数も以下のように、
== Section 1
#lorem(30)
#figure(
image("not_desired_behavior.png", width: 25%, rotate: 90deg, mirror: "vertical"),
caption: "Ferris"
)
#lorem(15)
ちゃんと垂直方向に回転していることがわかる。
課題
同じパスの画像を異なる角度で2回表示させると、回転/反射は反映されずに画像の縦横比だけ変化するというバグがみつかっている。例えば、
== Section 1
#lorem(30)
#figure(
image("rgby.png", width: 25%),
caption: "1:1.3"
)
#figure(
image("rgby.png", width: 25%, rotate: 90deg),
caption: "1.3:1"
)
#lorem(15)
のような具合。デバッガーによれば、画像データはraster.rs内では少なくとも期待通りの挙動をみせていたため、原因が不明である。今回はここで時間の制限がきてしまったが、いつか解消したい/解消してくれる人がいることを期待している。
おわりに
コントリビューションについて
今回はバグのある状態であったため、コントリビューション等は行なわなかった。もしTypstにコントリビュートしたい、という人がいるなら、githubにコントリビューションに関する資料があるため参考にするとよいだろう。
感想
実際にこの規模のあるコード(7万行!)をいじるどころか見ることする初めてだったので不安もあった。実際にいじったコードが動いた時はちょっと感動を覚えるほどだった。今回は技術力不足もあり不完全な結果に終ったが、個人的にレポート等でお世話になっているのでいつか不満点をコントリビュートしてみたいものである。
参照
https://typst.app/docs/reference/visualize/image/ (2024-11-02 参照)
https://qiita.com/yoya/items/4e14f696e1afd5a54403 (2024-11-02 参照)
https://docs.rs/image/latest/image/imageops/index.html (2024-11-02 参照)
Discussion