🦶

Typstでリンク先のURLを脚注に出したい、自動で

2024/09/29に公開

背景

紙版と電子版の両方のフォーマットで出力することが想定される場合、記事中のリンクのURLを脚注に示したい、ということがあると思います。

Typstではlinkをカスタマイズすることでこれが実現できます。ただ、いくつか難しい点があったので、ここにまとめておきます。試したことを順を追って書いていきますので、結果だけ知りたい方は「まとめ」を見てください。

footnote に URL を出す

まず思いつくのは以下のようなやり方です。

// 大体それっぽく動く

#show link: it => {
  it
  footnote(it.dest)
}

#link("https://example.com")[Example]

これでほとんど期待通りに動きます。ただ、すぐに気がつく問題があります。脚注にある URL はリンクになっていない、つまりクリックしてもリンク先に遷移できないのです。

footnote の中で link() を使う→失敗

そこで、footnote()の中でlink()を使う方法を試してみました。しかし、こうすると無限ループが発生するようで、コンパイルが終わらなくなってしまいます。

// これはコンパイルが終わらない

#show link: it => {
  it
  footnote(link(it.dest, it.dest))
}

#link("https://example.com")[Example]

実は、この他にも問題があります。

冗長な脚注が出力される問題

記事中に URL が書かれていて自動リンクになると、冗長な footnote が生成されてしまうのです。具体例を見てみましょう。

// 冗長なfootnoteができてしまう例

#show link: it => {
  it
  footnote(it.dest)
}

https://example.com

こうすると、本文には https://example.com 1 と出力され(1は脚注へのリンク)、脚注には 1 https://example.com が出力されます。本文中にURLが書かれているので、わざわざ脚注にもURLを出す必要はありません。こういうケースは通常のlinkとして振る舞ってほしいわけです。

比較演算でいける?→失敗

Typstが自動でリンクを生成する場合は、リンクテキストとリンク先が同一のリンクが生成されるようです。したがって、比較演算子を使って比較すれば良いだろう...と思うのですが、そこまで簡単ではありません。

// うまくいかない

#show link: it => {
  it
  if it.dest != it.body { // この条件は常に成立してしまう
    footnote(it.dest)
  }
}

https://example.com

it.deststring 型で、 it.bodycontent 型なので、常に it.dest != it.body が成立してしまい、必ず脚注が出てしまいます。

contentstringに変換してから比較する

contentstringに変換してから比較したいところですが、これがそれほど簡単ではないようです。検索していると https://github.com/typst/typst/issues/2196#issuecomment-1728135476 にやり方を書いているコメントがありました。content-to-string()に名前を変えつつ、以下のようにしてみます。

// リンクテキストとリンク先が同一の場合はリンクしない

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

#show link: it => {
  it
  if it.dest != content-to-string(it.body) {
    footnote(it.dest)
  }
}

#link("https://example.com")[Example]
https://example.com

こうすると、#link で書いた方だけが脚注になります。

脚注がリンクにならない問題、再訪

実は、冗長な脚注が出る問題が解決できると、脚注のURLがクリッカブルではない問題も解決できるようになります。以下のようにします。

// 脚注もクリッカブルになる

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

#show link: it => {
  it
  if it.dest != content-to-string(it.body) {
    footnote(link(it.dest, it.dest))
  }
}

#link("https://example.com")[Example]
https://example.com

今度は、再帰的にlinkが呼び出されても、リンクテキストとリンク先が一致するケースになるため、脚注がさらに追加されることはありません。これで完成です。めでたしめでたし。

と思ったのですが、まだ問題がありました。Typstのリンクは外部URLだけでないのです。

内部リンクを壊さないようにする

たとえば以下のような原稿があるとエラーが出てしまいます。

// 内部リンクがあるとエラーがでる

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

#show link: it => {
  it
  if it.dest != content-to-string(it.body) {
    footnote(link(it.dest, it.dest))
  }
}

= chapter
<chapter>

#link(<chapter>)[Internal link]

この場合、 id.destlabel 型になっています。 type(it.dest)string であることを確認すればよいでしょう。

まとめ

というわけで、まとめると、以下のようにしてリンクのURLを自動で脚注に出力できるようになります。

// 必要に応じてリンクを脚注に自動で出す

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

#show link: it => {
  it
  if type(it.dest) == "string" and it.dest != content-to-string(it.body) {
    footnote(link(it.dest, it.dest))
  }
}

= chapter
<chapter>

#link("https://example.com")[Example]
https://example.com
#link(<chapter>)[Internal link]

Discussion