Typst：実験レポートをあと二時間で書き上げなきゃいけないけどスタイルは統一したい人のための組版エンジン

# はじめに

こんにちは。
アドベントカレンダー登録時にちょっといきがって「ポエム：LLM と Local-first Software」みたいな記事を書こうとしたらぜんぜん筆がのらない挙句ちょっと休憩のつもりが爆睡してしまった結果現在予約投稿4時間前なので今さら記事のカロリーを落としにきた shio です。
あるいは、三月なのかです。

さて高専生のみなさん、実験レポート、何で書いてますか？
Word、Markdown、Notion、LaTeX、メモ帳...。
プレーンテキストを PDF に変換するシステムはいろいろありますが、急いでいるときほどスタイルのことなんて気にせず書きたいですよね。
まるでコードを書くみたいに。
その点 Markdown は LaTeX よりも簡潔に手っ取り早く記述できますが、LaTeX ほど参照機能が充実していなかったりして、表目次や参考文献欄を作りにくいのが問題です。
たまに「表目次がないレポートは減点対象です」とか言ってくる教員には、どう対応したら良いでしょうか？

そんな人におすすめしたいのが、「Typst」です。

# Typst、永遠なるもの。

Typst は 2023年 くらいに Public Beta となった比較的新しいマークアップベースの組版システムです。
想定用途は LaTeX のように論文などのような科学分野での組版が主です。
ただし、Typst 自体は非常に汎用的なシステムであるため、論文の執筆に限らず、小説や技術書やポスターに至るまで、その対応範囲は幅広いです。

Typst の大きな特徴の一つとして、LaTeX の代替であるという点が挙げられます。
これは、Typst のマークアップの簡潔性や PDF 化の処理の速さに由来するものです。
実際に見ていきましょう。

# Typst で実験レポートを書く

Typst で作成された二時間レポートの一部を以下に示します。
人の名前が入っているところは黒塗り加工しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2470169/a4bd5d0c-1ea7-fc47-249b-7d8ccc5abead.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2470169/fb8ab5d8-0e1a-08a0-287b-40ee2f5551dd.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2470169/9d0578fe-27a9-803d-02f8-81cbc6d1acbf.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2470169/270af0a7-2140-ca42-0ae8-f1830428fb34.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2470169/23daa7b3-4262-6399-1435-048c2f91ed56.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2470169/7b132417-e2d9-e015-c1a3-16393125ff62.png)

いかがでしょう、非常に美しいと思いませんか？
以下に、特に美しい部分を列挙していきます。

## メタ情報が書きやすい

まず表紙が美しいですよね。
これは以下のコードによって記述されています。

```typst:report.typ
#import "@preview/codelst:2.0.1"
#import "./template.typ": *

#show: paper.with(
  title: "戦略プログラミング演習",
  instructor: "...",
  dates: (
    datetime(year: 2024, month: 11, day: 15),
    datetime(year: 2024, month: 11, day: 22),
  ),
  deadline: datetime(year: 2024, month: 11, day: 29),
  acceptance: datetime(year: 2024, month: 12, day: 6),
  submission: datetime.today(),
  author: "5I (22) 塩畑 晴人",
  co-experimenters: (
    "...",
    "...",
  ),
  bibliography_file: "./references.bib",
)
```

まず、`#import "./template.typ": *` では以下の template ファイルを import しています。
これにより、日本語での図表示の機能や目次の自動生成などがあらかじめ設定されます。

<details><summary>template.typ</summary>

```typst:template.typ
#let image_num(_) = {
  locate(loc => {
    let c = counter("image-chapter")
    let n = c.at(loc).at(0)
    str(n + 1)
  })
}

#let table_num(_) = {
  locate(loc => {
    let c = counter("table-chapter")
    let n = c.at(loc).at(0)
    str(n + 1)
  })
}

#let equation_num(_) = {
  locate(loc => {
    let chapt = counter(heading).at(loc).at(0)
    let c = counter(math.equation)
    let n = c.at(loc).at(0)
    "(" + str(chapt) + "." + str(n) + ")"
  })
}

#let img(img, caption: "") = {
  figure(
    img,
    caption: caption,
    supplement: [図],
    numbering: image_num,
    kind: "image",
  )
}

#let tbl(tbl, caption: "") = {
  figure(
    tbl,
    caption: caption,
    supplement: [表],
    numbering: table_num,
    gap: 0em,
    kind: "table",
  )
}

#let to_string(content) = {
  if content.has("text") {
    content.text
  } else if content.has("children") {
    content.children.map(to_string).join("")
  } else if content.has("body") {
    to_string(content.body)
  } else if content == [ ] {
    " "
  }
}

#let toc() = {
  align(left)[
    #text(size: 20pt, weight: "bold")[
      #v(30pt)
      目次
      #v(30pt)
    ]
  ]

  set text(size: 12pt)
  set par(leading: 1.24em, first-line-indent: 0pt)
  locate(loc => {
    let elements = query(heading.where(outlined: true), loc)
    for el in elements {
      let before_toc = query(heading.where(outlined: true).before(loc), loc).find((one) => {one.body == el.body}) != none
      let page_num = if before_toc {
        numbering("i", counter(page).at(el.location()).first())
      } else {
        counter(page).at(el.location()).first()
      }

      link(el.location())[#{
        let chapt_num = if el.numbering != none {
          numbering(el.numbering, ..counter(heading).at(el.location()))
        } else {none}

        if el.level == 1 {
          set text(weight: "black")
          if chapt_num == none {} else {
            chapt_num
            "  "
          }
          let rebody = to_string(el.body)
          rebody
        } else if el.level == 2 {
          h(2em)
          chapt_num
          " "
          let rebody = to_string(el.body)
          rebody
        } else {
          h(5em)
          chapt_num
          " "
          let rebody = to_string(el.body)
          rebody
        }
      }]
      box(width: 1fr, h(0.5em) + box(width: 1fr, repeat[.]) + h(0.5em))
      [#page_num]
      linebreak()
    }
  })
}

#let toc_img() = {
  align(left)[
    #text(size: 20pt, weight: "bold")[
      #v(30pt)
      図目次
      #v(30pt)
    ]
  ]

  set text(size: 12pt)
  set par(leading: 1.24em, first-line-indent: 0pt)
  locate(loc => {
    let elements = query(figure.where(outlined: true, kind: "image"), loc)
    for el in elements {
      let num = counter(el.kind + "-chapter").at(el.location()).at(0) + 1
      let page_num = counter(page).at(el.location()).first()
      let caption_body = to_string(el.caption.body)
      str(num)
      h(1em)
      caption_body
      box(width: 1fr, h(0.5em) + box(width: 1fr, repeat[.]) + h(0.5em))
      [#page_num]
      linebreak()
    }
  })
}

#let toc_tbl() = {
  align(left)[
    #text(size: 20pt, weight: "bold")[
      #v(30pt)
      表目次
      #v(30pt)
    ]
  ]

  set text(size: 12pt)
  set par(leading: 1.24em, first-line-indent: 0pt)
   locate(loc => {
    let elements = query(figure.where(outlined: true, kind: "table"), loc)
    for el in elements {
      let num = counter(el.kind + "-chapter").at(el.location()).at(0) + 1
      let page_num = counter(page).at(el.location()).first()
      let caption_body = to_string(el.caption.body)
      str(num)
      h(1em)
      caption_body
      box(width: 1fr, h(0.5em) + box(width: 1fr, repeat[.]) + h(0.5em))
      [#page_num]
      linebreak()
    }
  })
}

#let empty_par() = {
  v(-1em)
  box()
}

#let paper(
  title: "",
  instructor: "",
  dates: array,
  deadline: datetime,
  acceptance: datetime,
  submission: datetime,
  author: "",
  co-experimenters: array,
  bibliography_file: none,
  paper-size: "a4",
  body,
) = {
  show ref: it => {
    if it.element != none and it.element.func() == figure {
      let el = it.element
      let loc = el.location()

      link(loc)[#if el.kind == "image" or el.kind == "table" {
          let num = counter(el.kind + "-chapter").at(loc).at(0) + 1
          it.element.supplement
          str(num)
        } else {
          it
        }
      ]
    } else if it.element != none and it.element.func() == math.equation {
      let el = it.element
      let loc = el.location()
      let chapt = counter(heading).at(loc).at(0)
      let num = counter(math.equation).at(loc).at(0)

      it.element.supplement
      " ("
      str(chapt)
      "."
      str(num)
      ")"
    } else if it.element != none and it.element.func() == heading {
      let el = it.element
      let loc = el.location()
      let num = numbering(el.numbering, ..counter(heading).at(loc))
      if el.level == 1 {
        str(num)
        "章"
      } else if el.level == 2 {
        str(num)
        "節"
      } else if el.level == 3 {
        str(num)
        "項"
      }
    } else {
      it
    }
  }

  show figure: it => {
    set align(center)
    if it.kind == "image" {
      set text(size: 12pt)
      it.body
      it.supplement
      " " + it.counter.display(it.numbering)
      " " + it.caption.body
      locate(loc => {
        let c = counter("image-chapter")
        c.step()
      })
    } else if it.kind == "table" {
      set text(size: 12pt)
      it.supplement
      " " + it.counter.display(it.numbering)
      " " + it.caption.body
      set text(size: 10.5pt)
      it.body
      locate(loc => {
        let c = counter("table-chapter")
        c.step()
      })
    } else {
      it
    }
  }

  set document(title: title, author: author)

  set text(font: "Noto Serif JP")

  show heading: it => {
    set text(font:"Hiragino Kaku Gothic ProN", weight: "bold", size: 16pt)
    it
  }

  show raw.line: it => {
    set text(font: ("Fira Code", "HackGen Console NFJ"), size: 10pt)
    it
  }

  set page(
    paper: paper-size,
    margin: (bottom: 1.75cm, top: 2.25cm),
  )

  align(center)[
    #set text(
      font: "Hiragino Kaku Gothic ProN",
    )

    #v(80pt)
    #text(
      font: "Hiragino Kaku Gothic ProN",
      weight: "bold",
      size: 16pt,
    )[
      2024年度 情報工学実験Ⅳ 報告書
    ]
    #v(50pt)
    #text(
      size: 22pt,
      weight: "bold"
    )[
      #title
    ]

    #text(
      size: 16pt,
    )[
      指導教員：#instructor
    ]
    #v(50pt)

    #table(
      columns: 2,
      align: left,
      column-gutter: 16pt,
      stroke: none,
      text(weight: "bold", size: 12pt)[実験日], stack(
        dir: ttb,
        spacing: 10pt,
        ..dates.map(date => {
          text(size: 12pt)[#date.display("[year]年[month]月[day]日")]
        })
      ),
    )
    #v(20pt)

    #table(
      columns: 2,
      align: left,
      column-gutter: 16pt,
      stroke: none,
      text(weight: "bold", size: 12pt)[レポート提出締切日], text(size: 12pt)[#deadline.display("[year]年[month]月[day]日")],
      text(weight: "bold", size: 12pt)[レポート受理最終日], text(size: 12pt)[#acceptance.display("[year]年[month]月[day]日")],
      text(weight: "bold", size: 12pt)[レポート提出日], text(size: 12pt)[#submission.display("[year]年[month]月[day]日")],
    )

    #v(40pt)

    #text(
      size: 16pt,
    )[
      報告者：#author
    ]

    #v(20pt)

    #table(
      columns: 2,
      align: left,
      column-gutter: 16pt,
      stroke: none,
      text(weight: "bold", size: 12pt)[共同実験者], stack(
        dir: ttb,
        spacing: 10pt,
        ..co-experimenters.map(name => {
          text(size: 12pt)[#name]
        })
      ),
    )

    #pagebreak()
  ]

  set page(
    footer: [
      #align(center)[#counter(page).display("i")]
    ]
  )

  counter(page).update(1)

  set par(leading: 0.78em, first-line-indent: 12pt, justify: true)
  show par: set block(spacing: 0.78em)

  set heading(numbering: (..nums) => {
    nums.pos().map(str).join(".") + " "
  })
  show heading.where(level: 1): it => {
    counter(math.equation).update(0)

    set par(leading: 0.78em, first-line-indent: 0pt, justify: true)
    set text(font:"Hiragino Kaku Gothic ProN", weight: "bold", size: 18pt)
    let pre_chapt = if it.numbering != none {
      text()[
        #numbering(it.numbering, ..counter(heading).at(it.location()))
      ]
    } else {
      none
    }

    if pre_chapt != none {
      if pre_chapt.children.filter(c => c != [ ]).at(0).text != "1 " {
        v(50pt)
      }
    }

    text()[#pre_chapt #it.body]
    v(0.2em)
  }
  show heading.where(level: 2): it => {
    set text(weight: "bold", size: 16pt)
    set block(above: 1.5em, below: 1.5em)
    it
  }

  show heading: it => {
    set text(weight: "bold", size: 14pt)
    set block(above: 1.5em, below: 1.5em)
    it
  } + empty_par()

  toc()
  pagebreak()
  toc_img()
  pagebreak()
  toc_tbl()

  set page(
    footer: [
      #align(center)[#counter(page).display("1")]
    ]
  )

  counter(page).update(1)
  set math.equation(supplement: [式], numbering: equation_num)

  body

  if bibliography_file != none {
    show bibliography: set text(12pt)
    bibliography(bibliography_file, title: "参考文献", style: "ieee")
  }
}

```
</div>
</details>


そしてより注目すべきは、`#show: paper.with()` の部分です。
この関数の引数を調整するだけで、上述のような美しい表紙を作ることができます。
美しいですね。

## コードの import ができる

「実験レポートにソースコードを含めよ」なんていう教員はめずらしくありません。
そんなとき、ソースコードを一つずつレポートファイルにコピペしていては、レポートの全体の文章がかさ増しされてしまって本文が見えにくくなってしまいます。
ただし、Typst なら以下のようにたった一行で任意のソースコードを import することができます。

```typst:report.typ
#codelst.sourcefile(lang: "c",  showrange: (77, 96), read("../src/chicken-game/main.c"))
```

美しいですね。

## 参照が楽

「図〇〇を参照せよ」みたいな文言を書くとき、レポート全体を書いていく中で図の前後を入れ替えるのはよくあります。
ただそうすると、図番号も入れ替わってしまって非常に面倒です。
そんなとき Typst では、図や参考文献を任意の文字列で識別することができるため、一意に参照することができます。

```typst:report.typ
Extortionate ZD戦略の利得関係の一例を @extortionate-payoff-relationship に示す。

#img(
  image("./figure/extortionate-payoff-relationship.png"),
  caption: [Extortionate ZD戦略の利得関係],
) <extortionate-payoff-relationship>
```

```typst:report.typ
Zero-Determinant (ZD) 戦略は、ゲーム理論における囚人のジレンマの文脈において、William H. PressとFreeman J. Dysonによって初めて提案された戦略である。 @iterated-prisoners-dilemma-contains-strategies-that-dominate-any-evolutionary-opponent この戦略は、Memory-one戦略に基づくマルコフ連鎖によってモデル化された確率論的な制御を通じて、プレイヤー間の利得の線形関係を強制するものである。
```

美しいですね (投げやり

# おわりに

Markdown や LaTeX よりも美しくスピーディにレポートを作成できるシステム、Typst について解説しました。
これを機に、弊学での Typst ユーザーが増えれば嬉しいなと思います。
そして、アドベントカレンダーは月初のほうに入れてはいけないと反省しました。

明日は、@12r さんの茨香祭で使ったレジ周りについての記事です。
お楽しみに〜 👋