SATySFi で湯婆婆

この記事は [SATySFi Advent Calendar 2020](https://adventar.org/calendars/4965) の 5 日目の記事です。
昨日は [na4zagin3](https://adventar.org/users/21948) さんによる「[2020 年 Satyrographos 周辺の動きと次メジャーアップデートに向けた展望](https://qiita.com/na4zagin3/items/cc2fa57032b0d4ac8f27)」でした。
明日は [puripuri2100](https://adventar.org/users/21551)さんによる「[SATySFiでトンボと裁ち落としを設定するパッケージを作った話](https://puripuri2100.hatenablog.com/entry/2020/12/12/110604)」です。


[(La)TeXで湯婆婆を実装してみる](https://qiita.com/ikuzak/items/ad4d9233d805df3ee186) という記事があったので。こりゃ SATySFi でもやらなアカンということで作りました。ネタ記事です。

# SATySFi とは

熱心な湯婆婆ファンの中には SATySFi のことをご存知でない方もいると思いますので、SATySFi という言語について簡単に説明します。

[SATySFi](https://github.com/gfngfn/SATySFi) とは、2018年に開発された新しい組版処理システムです。組版処理システムといえばTeX/LaTeXが有名ですが、SATySFi は LaTeX 風のマークアップ用構文と、 OCaml 風のプログラミング用構文を兼ね備えています。SATySFi 自体は静的型付けの関数型言語であるため

* 強力な静的解析
* 理解しやすいエラーメッセージ

といった強みがあり、TeX/LaTeX の代替としての役割が期待されています。本記事執筆時点でのバージョンはまだ `0.0.5` ですが、現時点でもある程度実用的な原稿を組むことができます。

より詳しく知りたい方は、以下のリンクの記事がおすすめです。ぜひ、 [SATySFi advent calendar 2020](https://adventar.org/calendars/4965) で公開されている他の記事も読んでみてください。

* [公式 README](https://github.com/gfngfn/SATySFi/blob/master/README-ja.md)
* [SATySFi wiki](https://github.com/gfngfn/SATySFi/wiki)
* https://qiita.com/puripuri2100/items/a63e0c48f245dfcb0446
* https://qiita.com/na4zagin3/items/a6e025c17ef991a4c923

# 仕様

元ネタの [Javaで湯婆婆を実装してみる](https://qiita.com/Nemesis/items/c7192a7c510788d2cba2) に可能な限り準拠することにします。

# コード

以下のコードを `yubaba.saty` という名前で保存します。

```yubaba.saty
@require: pervasives
@require: base/random
@require: base/list-ext

let-inline ctx \math m = script-guard Latin (embed-math ctx m)
let-block ctx +p it = line-break true true ctx (read-inline ctx it ++ inline-fil)
let-inline ctx \textbf it =
  let ctx = ctx
      |> set-font Kana (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Bold`, 1.0, 0.0)
      |> set-font HanIdeographic (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Bold`, 1.0, 0.0)
  in
  read-inline ctx it

let dialogue name name-abbrev =
  '<
    +p{
      湯婆婆「契約書だよ。そこに名前を書きな。」
    }
    +p{
      氏名：#name;
    }
    +p{
      湯婆婆「
      フン。#name;というのかい。贅沢な名だねえ。
      今からお前の名前は#name-abbrev;だ。いいかい、#name-abbrev;だよ。
      分かったら返事をするんだ、#name-abbrev;！
      」
    }
  >

let yubabify ctx name seed =
  let _ = () |> List.repeat seed |> List.map Random.random in
  let name-str = extract-string (read-inline ctx name) in
  let n = string-length name-str in
  let idx = (Random.random ()) mod n in
  let char = string-sub name-str idx 1 in
  embed-string char

let document record =
  let ctx =
    get-initial-context 440pt (command \math)
      |> set-dominant-narrow-script Latin
      |> set-dominant-wide-script Kana
      |> set-font Kana (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Regular`, 1.0, 0.0)
      |> set-font HanIdeographic (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Regular`, 1.0, 0.0)
      |> set-language Kana Japanese
      |> set-language HanIdeographic Japanese
      |> set-language Latin English
      |> set-hyphen-penalty 100
      |> set-font-size 10pt
      |> set-paragraph-margin 10pt 10pt
  in
  let bt = dialogue record#name (yubabify ctx record#name record#seed) in
  let bb = read-block ctx bt in
  page-break A4Paper
    (fun _ -> (| text-origin = (80pt, 100pt); text-height = 630pt; |))
      (fun _ -> (|
        header-origin = (0pt, 0pt);
        header-content = block-nil;
        footer-origin = (0pt, 0pt);
        footer-content = block-nil;
      |))
        bb

in

document (|
  name = {荻野千尋};
  seed = 1;
|)
```

実行には [satysfi-base パッケージ](https://github.com/nyuichi/satysfi-base) 及び [Noto Serif CJK JP](https://www.google.com/get/noto/#serif-jpan) が必要ですので、必要に応じてインストールしてください。Satyrographos を使えば以下のコマンドを走らせることで簡単にインストールできます。

```
opam install satysfi-base satysfi-noto-serif-cjk-jp
satyrographos install
```

SATySFi の環境が整えば、シェルで以下のコマンドを走らせることができます。

```
satysfi yubaba.saty
```

うまく行けば同ディレクトリに以下のような PDF が生成されるはずです。

![yubaba1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231599/790a5d96-c428-043e-6328-c4808d999e66.png)

ちゃんと原作のやり取りが再現されていますね！ `seed` の値を変えれば湯婆婆の挙動も変わるので、好きな0以上の整数値を設定してみてください。

![yubaba2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231599/f939e466-9cb3-2a74-fba2-2aa17f7f0ad0.png)

## 手軽に上のコードを試したい人へ

湯婆婆コード試してみたいけど手元のPCでSATySFiの環境構築するの面倒くさい…そんな人へ。Gitpod を使えば簡単に試すことができます。

![yubaba-demo](https://user-images.githubusercontent.com/48883418/101228538-7a284b00-36df-11eb-9ee4-af9af4c3c11a.gif)

詳しくは [monaqa/satysfi-yubaba](https://github.com/monaqa/satysfi-yubaba) 参照。


# コードの解説

上からコードを説明します。

```
@require: pervasives
@require: base/random
@require: base/list-ext
```

ここは SATySFi の「ヘッダ」と呼ばれる箇所で、必要なパッケージをインポートしています。

```
let-block ctx +p it = line-break true true ctx (read-inline ctx it ++ inline-fil)
let-inline ctx \math m = script-guard Latin (embed-math ctx m)
let-inline ctx \textbf it =
  let ctx = ctx
      |> set-font Kana (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Bold`, 1.0, 0.0)
      |> set-font HanIdeographic (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Bold`, 1.0, 0.0)
  in
  read-inline ctx it
```

ここでは、後に登場するコマンドを定義しています。`+p` は段落を組むコマンド、`\math` は数式を組むコマンド、`\textbf` はテキストを太字にするコマンドです。

```
let dialogue name name-abbrev =
  '<
    +p{
      湯婆婆「契約書だよ。そこに名前を書きな。」
    }
    +p{
      氏名：#name;
    }
    +p{
      湯婆婆「
      フン。#name;というのかい。贅沢な名だねえ。
      今からお前の名前は#name-abbrev;だ。いいかい、#name-abbrev;だよ。
      分かったら返事をするんだ、#name-abbrev;！
      」
    }
  >
```

今回のコードの1つ目の肝です。ここは原作のセリフを再現する関数です。`name` と `name-abbrev` という2つのインラインテキストを引数にとり、いくつかの段落からなるブロックを作成します。SATySFi ではグラフィックを入れたり文字を枠で囲んだりすることもできますから、契約書が簡素すぎるなあと感じた方はぜひ上の関数をいじってよりファンシーにしてみてください。

```
let yubabify ctx name seed =
  let _ = () |> List.repeat seed |> List.map Random.random in
  let name-str = extract-string (read-inline ctx name) in
  let n = string-length name-str in
  let idx = (Random.random ()) mod n in
  let char = string-sub name-str idx 1 in
  embed-string char
```

今回のコードの2つ目の肝です。ここは湯婆婆の魔法で名前を奪う関数です。`name` 引数に与えたインラインテキストから、適当な1文字を取り出したインラインテキストを返します。`base/random` にある `Random` モジュールの `random` 関数を用いて、乱数を生成しています。`Random.random` は副作用を持つ関数であり、呼び出すたびに結果が変わります（ランダムな値を生成する関数のため、当然の仕様といえます）。今回は値にバリエーションを持たせるため、 `Random.random` 関数を予め `seed` で与えた回数だけ呼び出してから適当な乱数を取得することにしました。

```
let document record =
  let ctx =
    get-initial-context 440pt (command \math)
      |> set-dominant-narrow-script Latin
      |> set-dominant-wide-script Kana
      |> set-font Kana (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Regular`, 1.0, 0.0)
      |> set-font HanIdeographic (`fonts-noto-serif-cjk-jp:NotoSerifCJKjp-Regular`, 1.0, 0.0)
      |> set-language Kana Japanese
      |> set-language HanIdeographic Japanese
      |> set-language Latin English
      |> set-hyphen-penalty 100
      |> set-font-size 10pt
      |> set-paragraph-margin 10pt 10pt
  in
  let bt = dialogue record#name (yubabify ctx record#name record#seed) in
  let bb = read-block ctx bt in
  page-break A4Paper
    (fun _ -> (| text-origin = (80pt, 100pt); text-height = 630pt; |))
      (fun _ -> (|
        header-origin = (0pt, 0pt);
        header-content = block-nil;
        footer-origin = (0pt, 0pt);
        footer-content = block-nil;
      |))
        bb
```

ここは全体のドキュメントを作成するためのコマンドですが、ややこしいので適当に読み飛ばしても構いません。途中の `dialogue record#name (yubabify ctx record#name record#seed)` という部分で、上で説明した2つの関数を呼び出しています。

実際に SATySFi を用いる場合、このあたりの設定はクラスファイル（`stdja` パッケージなど）が勝手に行ってくれるため、通常の SATySFi の文書ではこういったコマンドを書く必要はほぼありません。今回はクラスファイルを用いず原作を再現したため、このような設定を書いています。

```
document (|
  name = {荻野千尋};
  seed = 1;
|)
```

`document` 関数を呼び出し、実際に文書を生成します。`name` で名前を、 `seed` で乱数のシードを指定します。

# 名前を変えてみる

「千と千尋の神隠し」では名前が千尋でしたが、それ以外の名前にも対応していなければ完全な湯婆婆とはいえません。ちゃんと対応しているかどうか確かめましょう。

## ふん。获野千尋というのかい。

実は、原作において主人公の荻野千尋は自分の名前を「获野千尋」と書いています。

https://twitter.com/kinro_ntv/status/535766361666904065

わかりにくいですが、一文字目が「荻」（右下が"火"）ではなく「获」（右下が"犬"）になっています。「获」になっている理由はいろいろな考察が行われているようですが、いずれにせよ今までの例が原作を忠実に再現したものではなかったことは確実です。ちゃんと原作を再現しなければなりません。

幸い、SATySFi では書きたい文字に対応するグリフのあるフォントさえ選んでおけば（今回は Noto Serif CJK JP を用いました）、簡単に対応することができます。

```
document (|
  % name = {荻野千尋};  ※ TeX 同様、% からは行末までコメントアウトされる
  name = {获野千尋};
  seed = 1;
|)
```

![yubaba3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231599/a1acee84-09c4-d43a-1b5b-089e2579e7c2.png)

なお、他の湯婆婆実装だと「𠮷田」という例が多いですが、𠮷田さんにもちゃんと対応しています。これで、全国の𠮷田さんも安心して神隠しできるようになりました。

## ふん。SATySFiというのかい。

LaTeX という名前が、より正式には $\mathrm{\LaTeX}$ と表記されるように、SATySFi にも正式な（？）組み方があります。SATySFi では、 `pervasives` パッケージの `\SATySFi;` コマンドを用いて組むことができます。

```
document (|
  name = {\SATySFi;};
  seed = 1;
|)
```

![yubaba4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231599/c9fb8f8e-c227-5e4a-f9d8-6671809134fb.png)

もし映画「AとSATySFiの神隠し」が今後公開される運びとなっても、これなら問題なく対処できますね。

## ふん。というのかい。

SATySFi は、可能な限り静的解析の段階でエラーを検出しよう、という思想で設計されています。たとえば1000ページもあるような大規模な文書をタイプセットするとき、995ページ目まで組んでしまってからエラーを検出して処理を中断してしまうと、それまでの処理に費やした時間が全て無駄になってしまうためです。

そんな理由によって SATySFi はあまり実行時エラーを出さないのですが、上のコードで `name` に空のインラインテキストを与えると（少なくとも`v0.0.5`時点では）実行時エラーとなります。[Javaで湯婆婆を実装してみる](https://qiita.com/Nemesis/items/c7192a7c510788d2cba2) の「名前が空だと実行時エラーになる」という要件も再現されていますね。素晴らしい！（？）


```
document (|
  name = {};
  seed = 1;
|)
```

```
! [Error during Evaluation] division by zero
```

これはエラーメッセージを見ると明らかなように、ゼロ除算が原因です。

```
  let n = string-length name-str in
  let idx = (Random.random ()) mod n in
```

という箇所で `(Random.random ())` によって得られた整数の `n` で割った余りを求めていますが、`name-str` が空文字列だと `n` が0になってしまい、ゼロ除算エラーが起きるというわけです。SATySFi では型検査を行うものの `(int) mod (int)` という演算自体は定義されており、`1 mod 0` といった演算は型検査でも引っかかりません。従ってゼロ除算は（少なくとも現時点では）実行時エラーとなるように設計されています。

## ふん。`\textbf{获野千尋}`というのかい。

原作での千尋は普通のペンを使って名前を書いたものの、千尋が極太サインペンしか持っていないケースも当然考えられます。そのような場合は、入力を

```
document (|
  name = {\textbf{获野千尋}};
  seed = 1;
|)
```

とするのが至極真っ当ですね。試してみましょう。

![yubaba5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231599/05c77e1e-4866-ef85-b96b-0e6714b97d58.png)

契約書と、湯婆婆による本名の読み上げのときは正しく名前が太字になりましたが、その後は通常のウェイトに戻ってしまいました。これは、湯婆婆の魔法のときに「一度インラインテキストを文字列型に変換する」という作業を挟むことによって、フォントに関する情報が失われてしまうためです。
湯婆婆の魔法は、フォントのウェイト等といった文脈をも消し去ってしまうほどに強力なのかもしれません。

# まとめ

SATySFi を使えば、誰でも簡単に湯婆婆することができます。みなさんも Let's SATySFi!