nazo6 noteblog

WASMでTypstプラグインを作ろう

作成:2024/02/05

更新:2024/11/22

この記事は、Zennにも投稿しています。
最近話題の組版システムのTypstですが、プラグインシステムを備えておりWASMを使って拡張することが可能です。

Plugin Type – Typst Documentation

Documentation for the Plugin type.

typst.app

プラグインを使うことで従来のTypst言語のみでは難しかった様々な処理を行うことができます。
公式のパッケージリストに掲載されているパッケージの中にも内部でWASMプラグインを使用しているものがあります。例えばQuickJSを利用してJavaScriptを実行する「Jogs」やMarkdownをTypstに変換する「cmarker」、さらにはLaTeXをTypst構文に変換して表示する(!)「mitex」なんていうものもあります。
この記事ではRustを使ってTypstのプラグインを作成します。Typst自体Rustで作られているためRustの環境がよく整備されていますが、もちろんWASMにコンパイルできる言語であればどのような言語も使用可能です。ただし、WASIはサポートされていないため、WASIが必須な言語やライブラリを使用する際にはwasi-stubを使用する必要があります。
ZigとCの例がここにある他、その他の言語でもTypstのwasm protocolに従って関数をエクスポートすることでTypstプラグインを作成できます。
プラグインとパッケージ
Typstでのプラグインというのはwasmファイルのことを指します。一方、パッケージはプラグインをロードする処理などが記述されたtypファイルなどを含んだ一連のファイル群のことを指します。パッケージはwasmファイルを含んでいる必要はありません。

Greetプラグイン

まずはHello {name}という文字列を出力するだけのプラグインを作ってみましょう。
cargo new --lib typst-greet
rustup target add wasm32-unknown-unknown
を実行してRustのプロジェクトを作り、wasmにビルドできるようにしておきましょう。
次にCargo.tomlに以下を追記します。
Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-minimal-protocol = { git = "https://github.com/astrale-sharp/wasm-minimal-protocol/" }
crate-type = ["cdylib"]はwasmにビルドするのに必要です。また、wasm-minimal-protocolクレートでは関数をTypstから呼び出すのに必要な諸々をやってくれます。
また、.cargo/config.tomlファイルを作成し、デフォルトでwasmがコンパイルされるようにしておきます。
.cargo/config.toml
[build]
target = "wasm32-unknown-unknown"
次にlib.rsを以下のように書き換えます。
src/lib.rs
use wasm_minimal_protocol::*;

initiate_protocol!();

#[wasm_func]
pub fn greet(name: &[u8]) -> Vec<u8> {
[b"Hello, ", name, b"!"].concat()
}
initiate_protocol!()を実行した後、wasm_funcアトリビュートを関数に付与することで関数をTypstにエクスポートすることができます。
エクスポートされたgreet関数では、バイト列として受け取った引数にHello, を付加してそれをやはりバイト列として返しています。Rustには文字列を表すString型がありますが、Vec<u8>を返しているのは、Typstのプラグインができることは「バイト列を受け取ってバイト列を返すこと」だからです。(ただし将来的にはマクロを使って型の自動変換ぐらいはしてくれるようになるかも?例えばwasm-minimal-protocolのサンプルにあるようにResult型は今でも使えるようです)
では実行するために以下のコマンドでビルドします。
cargo build --release
target/wasm32-unknown-unknown/releaseディレクトリ内にwasmが生成されたはずです。では実際にTypstで読み込んでみましょう。作成プロジェクトのルートに以下のようなTypstファイルを作成します。
sample.typ
#let plugin = plugin("./target/wasm32-unknown-unknown/release/typst_greet.wasm")

#let greet(name) = str(
plugin.greet(
bytes(name),
)
)

#greet("typst")
wasmファイルはplugin関数で読み込みこまれ、後は通常のメソッドのように使うことができます。ただし、バイト列を渡して受け取ることには注意が必要です。
これを
typst compile sample.typ
でコンパイルすれば…
../../../../resource/public/blog/2024/02/typst-plugin/sample1.png

このようなpdfファイルが生成されているはずです。非常に簡単ですね。

Excel読み込みプラグイン

これだけでは面白くないのでもう少し実用的なものを作りましょう。
自分は表を作る時に雑にExcelで作ることが多いのですが、Typstでxlsxファイルは読み込めないのでCSVにいちいち変換しなければならず面倒です。これを簡略化するためにxlsxファイルを直接読み込むTypstプラグインを作ってみましょう。
当然イチからxlsxファイルを読み込む処理を実装するのは非常に大変ですが、プラグインを作るのにRustが使えるということは当然Rustのエコシステムを使えるということです。Rustのエコシステムは結構豊富で、今回の目的にドンピシャなcalamineというクレートを見つけました。Cのラッパーとかでもないのでビルドも難しい所はありません。
ということで実際にプラグインを作っていきましょう。まずは前節と同様にcargo newでRustプロジェクトを作成してから
cargo add calamine
で依存関係を追加し、以下のコードをlib.rsに書きます。
lib.rs
use calamine::{Reader, Xlsx, XlsxError};
use wasm_minimal_protocol::*;

initiate_protocol!();

fn parse_num(num: &[u8]) -> Result<usize, String> {
std::str::from_utf8(num)
.map_err(|e| format!("Invalid number: {e}"))?
.parse()
.map_err(|e| format!("Invalid number: {e}"))
}

#[wasm_func]
pub fn get_table(
data: &[u8],
sheet: &[u8],
col: &[u8],
row: &[u8],
width: &[u8],
height: &[u8],
) -> Result<Vec<u8>, String> {
let sheet = std::str::from_utf8(sheet).map_err(|e| format!("Invalid sheet name: {e}"))?;
let col = parse_num(col)?;
let row = parse_num(row)?;
let width = parse_num(width)?;
let height = parse_num(height)?;

let cursor = std::io::Cursor::new(data);
let mut workbook: Xlsx<_> = calamine::open_workbook_from_rs(cursor)
.map_err(|e: XlsxError| format!("Failed to open workbook: {e}"))?;
let sheet_range = workbook
.worksheet_range(sheet)
.map_err(|e| format!("Sheet read error: {e}"))?;

let mut lines = vec![];

for r in row..row + height {
let mut line = vec![];
for c in col..col + width {
if let Some(cell) =
sheet_range.get_value((r.try_into().unwrap(), c.try_into().unwrap()))
{
line.push(cell.to_string());
} else {
line.push("null".to_string());
}
}
lines.push(line.join("\t"));
}

Ok(lines.join("\n").into_bytes())
}
get_table関数が実際の処理内容で、calamineクレートでバイト列として受け取ったxlsxファイルの内容を解析し、引数として指定された範囲の内容を読み取っています。
読み取ったデータはTypstで解析できるようにtsvの文字列として返しています。Typstの表データとして直接返せれば良いのですがそのような方法は今は無いようです。
sample.typ
#let plugin = plugin("./target/wasm32-unknown-unknown/release/typst_excel.wasm")

#let get_table(file, sheet, col, row, width, height) = csv.decode(
plugin.get_table(
read(file, encoding: none),
bytes(sheet),
bytes(str(col)),
bytes(str(row)),
bytes(str(width)),
bytes(str(height))
),
delimiter: "\t"
)

#table(
columns: 4,
..get_table("Book1.xlsx", "Sheet1", 1, 1, 4, 10).flatten()
)
そしてこちらが上のRustコードから生成されたプラグインWASMを実行するためのファイルです。Rust内のget_table関数にxlsxファイルの内容と引数を渡して実行し、tsvとしてパースすることでxlsxファイル内の値を表示しています。
では試しに以下のようなBook1.xlsxを作ってtypst compileを実行してみましょう。
../../../../resource/public/blog/2024/02/typst-plugin/sample2_book.png

するとこのように期待通りの表が得られました!
../../../../resource/public/blog/2024/02/typst-plugin/sample2_pdf.png

今回始めて知ったのですがxlsxって計算結果もファイルの中に保持してあるんですね。数式を取りたければコード内のworksheet_rangeworksheet_formulaに変えればよさそうです。

ファイルをプラグインから読み込めない理由

このセクションは別に読まなくてもいいです。
先程のコードでは、わざわざTypstからファイルをバイト列として渡していましたが、Rustから直接IOできたりすれば便利なのではないでしょうか?
そのようなことができない実際的な理由としては、「Typstがサポートしているwasm環境向けターゲットのwasm32-unknown-unknownではファイルを読み込めないから」です。しかしながら、TypstがWASIなどをサポートしておらず、フリースタンディングなwasmターゲットしかないのは意図的なものだと思われます。
というのもtypstの関数は純粋でなければならないからです。これによりドキュメントの再現性が確保され、高速な差分コンパイルが実現されています。ここでもしプラグインから外部にアクセスできてしまうと全く純粋ではなくなってしまうわけですね。
なので、プラグイン内で状態を保持することもできません。例えば以下のようなコードを考えてみましょう。
use wasm_minimal_protocol::*;

initiate_protocol!();

static mut COUNTER: u32 = 0;

#[wasm_func]
pub fn count() -> Vec<u8> {
unsafe {
COUNTER += 1;
}
format!("{}", unsafe { COUNTER }).into_bytes()
}
unsafeなどはとりあえず無視して頂くとこれはcountが呼び出される度にCOUNTERを加算するコードなのですが、これをTypstから何回呼び出しても1が表示されます。
とまあ長々と書きましたが、実はこのことはTypstのドキュメントページに全部書いてあります。

最後に

いかがでしたか?とても簡単にTypstのプラグインを作成できることがお分かり頂けたかと思います。既存の言語のエコシステムを使って比較的容易に複雑なプラグインを開発することができることはTypstの大きな利点だと思います。
ちなみに、パッケージを作ったらtypst/packagesリポジトリにPRを送ることで公式のプラグインリストに載り、import "@preview/..."でインポートできるようになります。
参考記事:

Typstの日本語Lipsumパッケージを作ってみた件

zenn.dev

また、今回使用したソースコードは

playground/other/typst-plugin at c0fb192f71e71fbbaafcc57673bdc4e931f3dd39 · nazo6/playground

Contribute to nazo6/playground development by creating an account on GitHub.

github.com

で公開しています。
是非みなさんもTypstプラグインを作ってみてください。