📜

Typstのimage関数にrotationパラメタを追加したかった話

2024/11/03に公開

はじめに

この記事は 2024/10/07 ~ 2024/10/29 に開講された、東京大学工学部電子情報工学科・電気電子工学科の後期実験「大規模ソフトウェアを手探る」の課題レポートを兼ねています。

今回我々は組版ソフトウェアTypstのimage関数に、回転のためのパラメーターを追加するという目標のもと開発を行った。

Typstについて

Typstの実装言語

Typstは以下のgithubからわかる通り、ほぼRustで書かれていた。開発中も他の言語に遭遇することはなく、開発にはRust(とTypst)の知識以外は必要なかったように感じた[1]
https://github.com/typst/typst

Typstのコンパイルシステム

Typstのコンパイルシステムはcrates/typst/src/lib.rs以下に詳しく書かれていた。

https://github.com/typst/typst/blob/main/crates/typst/src/lib.rs#L1-L20

軽い意訳

Parsing(解析)

与えられた.typファイルの平文をトークンに分解していく作業。ここで関数は構文木にとりこまれていく。

Evaluation(評価)

それぞれのトークンを解析し、出力値のスコープやソースファイルの内容を階層的に表現したモジュールを生成する。

Layouting(配置)

それぞれのモジュールをページに配置する。

Exporting(出力)

PDF, PNG, SVGなどに出力。

例えば以下のような文を入力するとする。
init

すると、まずParsingで以下のような構文木になる。

AST

この構文木をEvaluateして、次のようになる。

eval

これをLayouting, Exportingをすると、目的のPDF等が出力される、ということである。

Lorem

開発

開発目標へのアプローチ

上記のコンパイルシステムから、我々は2通りのアプローチを考えた。

  1. Evaluationの時、画像データ自体を回転させる
  2. Layoutingの時にモジュールを回転させる

それぞれについての詳細は以下の通りである。


1. Evaluationの時、画像データ自体を回転させる方法

画像はピクセルの行列と考えられるため、以下のように行列を回転させてあげればよい、という考え[2]

\left(\begin{matrix} 1 & 2 & 3 \\ 4 & 5 & 6 \\ 7 & 8 & 9 \end{matrix}\right) \rightarrow \left(\begin{matrix} 7 & 4 & 1 \\ 8 & 5 & 2 \\ 9 & 6 & 3 \end{matrix}\right)

利点

  • 既にあるパッケージであるimage::imageopsを使用できる
    • 既にTypst内で使用されている(後述)ため、使えることもわかっている

欠点

  • ピクセル行列で書かれていないSVGファイル等の回転には使えない
  • image::imageopsが90°毎の回転にしか対応していない

2. Layoutingの時にモジュールを回転させる

画像を読みこんだ後に、Layoutの時モジュールを回せばいい、という考え。

利点

  • ラスター、ベクター問わず回転できる
  • rotate関数の実装はこちらなので、上手く呼びだしてあげればよい

欠点

  • #image関数がParsingされるタイミングで構文木に干渉しないといけないため、実装がとても大変
  • rotateの呼びだしが複雑であり実装の読み解き難易度も高い

今回私達は1. Evaluationの時、画像データ自体を回転させるのアプローチを採用し、実装していった

環境準備

まずは開発環境を整える。

  1. rustのセットアップ
    公式資料などを参照して、rustupを入れてrustup updateでセットアップする。また、homebrewを使えば、
$ brew install rust

でもいける。

  1. 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
  1. Typstのビルド

レポジトリの中に入り、cargo buildするだけ。

$ cd ./typst
$ cargo build

ビルドしたらブランチを分けて開発を開始した。.gitignoreが標準で実行ファイルのディレクトリ(./target)をignoreするようになっているのでとても便利。

$ git branch dev
$ git checkout dev
  1. デバッガーの準備

開発を行うとき、デバッガーというものを用いることがある。rustはデバッガーの環境がお世事にも整っているとはいえず、選択肢はVSCodeの拡張機能で使えるCodeLLDB以外は特にないように思える。今回もCodeLLDBを持ちいたデバッギングを行ったが、正直使い辛かった。その理由として、変数の読み込みによく失敗するといったデバッガ側の問題や、目標としてる関数の呼び出しが複雑でコールスタックが大変なことになる、などが挙げられる。とはいえ、ブレークポイントで関数の挙動を追えるのは便利ではあった。

VSCodeでCodeLLDBをいれた後、左のパレットからデバッグボタン(虫のついた右向き三角)をクリック→"実行とデバッグ"を押すとデバッガを選ぶことを求められる。
debug
デバッグボタン

"LLDB"を選択すれば自動的にlaunch.jsonファイルが.vscode以下に生成されるはずである。あとは試したい条件をjsonファイルに加えるなどしてデバッグする。例えば私は主に以下の設定のものを用いていた。

.vscode/launch.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

https://github.com/typst/typst/blob/main/crates/typst-layout/src/image.rs#L16-L16

Layoutまで行く前に回転処理したいのでこれではなさそう。

  • typst-pdf

https://github.com/typst/typst/blob/main/crates/typst-pdf/src/image.rs#L15-L15

PDFの埋めこみまでいっている、これではない。

  • typst-render

https://github.com/typst/typst/blob/main/crates/typst-render/src/image.rs#L11-L11

レンダリングをおこなっている、ここではなさそう。

  • typst-svg

https://github.com/typst/typst/blob/main/crates/typst-svg/src/image.rs#L21-L22

名前からしていかにも違いそうだが、内容も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以下を読みといていくと、このような構造体にぶつかる。

https://github.com/typst/typst/blob/main/crates/typst-library/src/visualize/image/mod.rs#L48-L97

この構造体がとる引数は、Typstのimage関数のとる引数とまったく同じである。

typst_image
引用: https://typst.app/docs/reference/visualize/image/

よってここでImage関数を処理していることがわかる。このままmod.rsを読みすすめていくと、Imageという構造体とその関数にぶつかる。

https://github.com/typst/typst/blob/main/crates/typst-library/src/visualize/image/mod.rs#L188-L192

https://github.com/typst/typst/blob/main/crates/typst-library/src/visualize/image/mod.rs#L212-L260

名前からして重要そうであるが、いまいち何がしたいかがわからなかったので、with_fontsが使われている場所を探してみる。すると、typst-layout/src/image.rsで呼ばれていることがわかった(52行目)。

https://github.com/typst/typst/blob/main/crates/typst-layout/src/image.rs#L16-L119

つまり、layout_image関数にImageElemを渡し、Image::with_fonts関数でImage型にし、それをレイアウトするためのフレームをつくり、画像を埋めこんで返しているらしい。


RasterImage::new を読み解く

これでwith_fontsが何をしたいかがわかったため、その中身に注視すると、画像がラスターかベクターかで場合分けし、それぞれ関数で処理している。今回はラスターのみ考えるため、とりあえず呼びだされているRasterImage::newを見にいく。

https://github.com/typst/typst/blob/main/crates/typst-library/src/visualize/image/raster.rs#L28-L63

するとapply_rotationと、これまで回転のパラメーターを見かけた記憶はないのに謎の回転関数がでてくる。そして、その引数であるrotationをみてみると、EXIFデータなるものをみていることがわかる。

ではEXIFデータとはなにか?ここの記事に詳しいが、簡単にいうとJPEGやPNG等のファイルのメタデータの一種であり、画像の撮影された情報を保持している。例えば、カメラを縦にして撮影したときなどに画像を回転して縦の状態にして表示するといったことができる。

よって、この関数の実装と似たものを用いれば画像を回転させられるのではないか?

https://github.com/typst/typst/blob/main/crates/typst-library/src/visualize/image/raster.rs#L158-L176

apply_rotationは上のように実装されている。EXIFデータに対応して、image::imageopsに実装されている関数を呼んでいるだけという、シンプルな実装であった。imageopsについての詳細は公式ドキュメントを参照する。

コード改変

imageopsには鏡像反射もあったため、rotateパラメタに加えmirrorパラメタも実装してみる。まず、TypstのImage関数にパラメタを追加するために、ImageElemの構造体に引数を追加してみる。Typstのコードに角度を扱うための型Angleが実装されていた[4]ため、それをスコープにいれながら実装する。

typst-library/src/visualize/image/mod.rs
+ 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>,
}

ここでコンパイルしてもエラーはなく、rotatemirrorImage関数に渡してもエラーを吐くことはなかった。よって要素の実装はうまくいってそうである。あとはこれをwith_fontsが受けとるように引数を追加する。typst-layout/src/image.rsの下にあるlayout_imageにはImageElemがelemという変数で渡されていることに着目しつつ、

typst-library/src/visualize/image/mod.rs
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> {
        //略
}
typst-layout/src/image.rs
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に引数を与えつつ回転する関数を追加する。

typst-library/src/visualize/image/mod.rs
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)?)
            }
        };
}
        
typst-library/src/visualize/image/raster.rs
+ 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_rotationapply_user_mirrorはそれぞれ以下のような実装。

typst-library/src/visualize/image/raster.rs
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が一部のテスト用プログラムなどへの引数の不足を指摘してくれる。rotatemirror引数はOption<T>で定義されているため、Noneを渡しておけばよい。

結果

以下のようなtypファイルをコンパイルしてみる。

test.typ
== Section 1
#lorem(30)
#figure(
  image("not_desired_behavior.png", width: 25%, rotate: 90deg),
  caption: "Ferris"
)

#lorem(15)

desired

このように、rotate関数では発生してしまう小型化もなく画像を回転させることができた。また、mirror関数も以下のように、

test.typ
== Section 1
#lorem(30)
#figure(
  image("not_desired_behavior.png", width: 25%, rotate: 90deg, mirror: "vertical"),
  caption: "Ferris"
)

#lorem(15)

vertical

ちゃんと垂直方向に回転していることがわかる。

課題

同じパスの画像を異なる角度で2回表示させると、回転/反射は反映されずに画像の縦横比だけ変化するというバグがみつかっている。例えば、

bug.typ
== 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)

bug

のような具合。デバッガーによれば、画像データはraster.rs内では少なくとも期待通りの挙動をみせていたため、原因が不明である。今回はここで時間の制限がきてしまったが、いつか解消したい/解消してくれる人がいることを期待している。

おわりに

コントリビューションについて

今回はバグのある状態であったため、コントリビューション等は行なわなかった。もしTypstにコントリビュートしたい、という人がいるなら、githubにコントリビューションに関する資料があるため参考にするとよいだろう。

https://github.com/typst/typst/blob/main/CONTRIBUTING.md

感想

実際にこの規模のあるコード(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 参照)

脚注
  1. 私達の班にrustを書ける人が一人もいなかったため、0から学ぶこととなった。rustの勉強記事はまたいつか。 ↩︎

  2. 本当は各セルが[r,g,b]の行列(のはず) ↩︎

  3. useはシンボリックリンクのようなもので、関数やら型定義やらをスコープにいれるために用いられる ↩︎

  4. 角度はTypstのrotate関数でも用いられていた(typst-layout/src/transform.rs)ため参考にした ↩︎

GitHubで編集を提案

Discussion