SATySFi 0.1.0の新しいエコシステムSapheのプロトタイプについて(前編)

最終更新:

記事 SATySFi プログラミング言語 組版

昨年のSATySFi Conf 2023で発表させてもらった,SATySFi 0.1.0に向けたエコシステム Saphe(セイフ)について,設計とプロトタイプ実装が固まってきたため,そのおおよその仕組みを共有しようと思います.発表の内容とも一部オーバーラップします.

前編である本稿は,Sapheがどのように使えるツールなのかをハンズオン的に記載します.後編はSapheの詳細な設計・実装について述べる予定です.→ 追記: 後編ができました.

本稿執筆時点でのSapheの実装は,以下のPRの c48c047 です(ちなみに本記事の初公開時は 44ff21b でした):

Sapheの役割

Saphe(SATySFi Package-Handling Ecosystem,/seɪf/ と発音)は,簡単に言えばSATySFiのためのパッケージマネージャとビルドシステムが複合したものです.具体的には,以下のようなことを円滑に行なうためのソフトウェアです:

  1. ユーザがコンフィグファイルによって指定した依存パッケージたちをパッケージレジストリと呼ばれる集積所から取得し,ユーザのマシンで使えるようにする.
    • この過程で,依存制約を充足するように,各々のパッケージに対して適切なバージョンを選択する必要がある.同一パッケージの異なるバージョン間の互換性はバージョン番号によって判別できるようになっており,共存できる組み合わせがある場合はそのうちの1つを選ぶ依存解決を行なう.
  2. 文書組版の再現性を担保する.
    • 主にこの目的のために,依存解決の結果はロックファイルに出力される.ロックファイルもバージョン管理に含めるのが望ましい.
  3. 取得したパッケージを,SATySFiで文書を組むのに使ったり,または更にそれらに依存するパッケージを実装するのに使う.

すなわち,SATySFiに於けるSapheは,Rustに対するCargoや,Haskellに対するCabalまたはStackに相当する機構です.

なぜ新しいエコシステムが必要だったか

SATySFi 0.0.xには,既にSatyrographosという便利なデファクトスタンダードのエコシステムがあります.そのため,Satyrographosを拡張した上でSATySFi 0.1.0に関しても継続して使うという選択肢もあります.では,Sapheを新たに設計・実装する必要性はどこから生じたのでしょうか? それは,SATySFi 0.0.xがしばらく使われるうちに顕在化してきた以下のような課題です:

  • 依存関係ゆえに同一パッケージの複数のバージョンを共存させざるを得ない状況が生じることがあるが,こうした共存は現行のエコシステムではかなり回りくどい方法でしか実現できない.また,SATySFi 0.0.x本体の言語設計に於いても,同一パッケージの複数バージョンが共存処理するためには同一の名前のモジュールが共存できる必要があるが,実現可能ながらも筋の良い方法ではなく,制御が難しい.
  • 任意の文字列がバージョン番号に指定でき,また後方互換性の判定もバージョンによって自動化されたりはしていないため,互換性の記述がパッケージ作成者の気づかいや技量に大きく依存している

SATySFi処理系本体の問題点を差し引くと,これらの課題を生じる原因は実は共通しています: Satyrographosがパッケージ管理に関してOPAMというOCamlやCoq向けのパッケージマネージャに依存していることです.したがって,これらの課題を解消するには,以下のような改修が望ましそうです:

  • SATySFi本体の言語設計を,同一パッケージの複数バージョン共存などにも堪えうる筋の良いものに修正する
  • Satyrographosの設計・実装をOPAMに依存しないものにする

実際,前者がSATySFi 0.1.0を開発している目的の1つでもあるわけです.しかしながら,後者のSatyrographosの改修にはやや障壁がありました:

  • Satyrographosの作者であるSakamotoさんが本業でかなり多忙になられており,大規模な改修の労力を捻出してもらうのは難しそうだった
  • そもそも複数バージョン共存なども要件に含めるとSatyrographosとSATySFi 0.1.0の間での責務分担はかなり非自明であり,Sakamotoさんと相談しながら分担して開発を行なうにしても取っ掛かりが掴めなかった
  • 一方で,私自身がSatyrographosの既存のコードベースに大幅に手を加えて0.0.x向けの機能を壊さないように0.1.0向けの機能を追加するのは労力的に割に合いそうにないと感じた

そこで,SATySFiを改築しつつエコシステムのプロトタイプを同時にゼロから構築していき,適宜それぞれの設計を修正することで責務分担の吟味を1人で反復するのが早かろうと考え,まずはそうしたプロトタイプであるSapheをつくることにした,という次第です.2024年1月の現時点でSapheはあくまでもプロトタイプ実装として位置づけており,将来的にも現状の形式をとり続けるか,または相談の上でSatyrographosに一部分として取り込んでもらうことになるかなどは未定ですが,実用に耐えるくらいには実際に動作するものになりました.

サブコマンド

現在のところ,以下の5つのサブコマンドがあります:

  • $ saphe init [document|library] <file-path>
    • プロジェクト(文書またはパッケージ)を初期化する.
  • $ saphe solve <file-path>
    • パッケージの依存解決を行ない,ロックファイルを生成・更新する.
  • $ saphe update <file-path>
    • パッケージレジストリの情報を最新のものに更新する.
  • $ saphe build <file-path>
    • 文書を組む,またはライブラリを検査する.
  • $ saphe test <file-path>
    • ライブラリのテストを実行する.

update の解説は後編に譲りますが,他のコマンドは本稿の以下の章でハンズオン的に紹介します.

文書を組みたい場合のSapheの使い方

1. 初期化

カレントディレクトリ(以下 /path/to とします)に foo.saty という文書をつくって書き始めたいとします.この場合,まず以下を実行してファイルの雛形を生成します:

$ saphe init document foo.saty
  created a package config '/path/to/foo.saphe.yaml'
  created '/path/to/foo.saty'

以下のような2つのファイルが生成されました.

.
├── foo.saphe.yaml
└── foo.saty

foo.saty が文書本体,foo.saphe.yamlコンフィグファイルです.文書本体 foo.saty は以下のような内容で生成されています:

use package open StdJaReport

document (|
  title = {The Title of Your Document},
  author = {Your Name},
|) '<
  +chapter{First Chapter}<
    +p{
      Hello, world!
    }
  >
>

従来のSATySFi 0.0.xに馴染んでいるユーザからすると,1行目の use package … が目新しいほか,注意深く見るとレコード (| … |) の内容がセミコロン ; ではなくコンマ , で区切られていたりしますが,基本的にはほぼ見覚えのあるファイルですね.

コンフィグファイル foo.saphe.yaml は以下のような内容です:

saphe: "^0.0.1"
satysfi: "^0.1.0"
name: "your-document"
authors:
  - "Your Name"
registries:
  - name: "default"
    git:
      url: "https://github.com/SATySFi/default-registry"
      branch: "temp-dev-saphe"
contents:
  document: {}
dependencies:
  - used_as: "StdJaReport"
    registered:
      registry: "default"
      name: "std-ja-report"
      requirement: "^0.0.1"

CargoやCabalを使ったことがある人なら,なんとなく各部分が何を指定しているか想像できるかもしれません.saphe には想定するSapheのバージョンを,satysfi には想定するSATySFi本体のバージョンを,それぞれ指定します.Sapheはコンフィグファイルを読むときにまず saphe を見て,自身がそのコンフィグファイルを適切に処理できる(とユーザが考えている)ことを確認します.^0.0.1 は「0.0.1 またはそれに後方互換なバージョン」を指す記述です.バージョン番号は基本的にはSemantic Versioningに準拠してつけますが,Sapheはバージョン番号のつけ方に追加で制約を要請します: メジャーバージョンが 0 の場合,マイナーバージョンを保ったままパッチバージョンを加算する更新は,後方互換なリリースであるとみなします(これはCargoが採用している方針と共通しています).

name および authors は現状必須のフィールドながら文書では特に実用上の用途がありません.後述のライブラリ作成では意味をもつフィールドですが,文書の場合はひとまず文書名と著者リストで埋めておいてください.

registries はパッケージレジストリを指定する箇所です.基本的にはここをいじる機会は少なく,生成された記述をずっと使うのが典型的であろうと思います.社内ライブラリなど,一般には公開しないパッケージを集積するパッケージレジストリを社内ネットワークに用意したい場合は,ここに追記することになります.

dependencies には依存パッケージの指定が入ります.典型的なユーザが最もいじる箇所はこの部分であろうと思います.初期値では「std-ja-report パッケージの 0.0.1 またはそれに後方互換なバージョン」が依存パッケージとして指定されています.ここで書かれている used_as がSapheでの特徴的な指定なのですが,これについては後述します.

2. パッケージの依存解決

さて,この文書を組むには,まずはパッケージレジストリのデータをもとに依存パッケージの依存関係を辿って必要なパッケージとそのバージョンを決定し,必要なものを取得する必要があります.これを行なうのが solve サブコマンドです:

$ saphe solve foo.saty
  package dependencies to solve:
  - std-ja-report (^0.0.1) used as StdJaReport
  package dependency solutions:
  - annot 0.0.1
  - code 0.0.1
  - font-ipa-ex 0.0.1
  - font-junicode 0.0.1
  - font-latin-modern 0.0.1
  - font-latin-modern-math 0.0.1
  - footnote-scheme 0.0.1
  - math 0.0.1
  - std-ja-report 0.0.1
  - stdlib 0.0.1
(中略.取得処理が並ぶ)
  lock config written on '/path/to/foo.saphe.lock.yaml'.

依存パッケージは std-ja-report ただ1つでしたが,std-ja-report を介して間接依存するパッケージも全部かき集められて取得され,その結果がロックファイル foo.saphe.lock.yaml に書き出されました.

.
├── foo.saphe.lock.yaml
├── foo.saphe.yaml
└── foo.saty

saphe solveの動作の概略図

生成されたロックファイル foo.saphe.lock.yaml は,ユーザが中身を読み書きすることは全くありませんが,処理の再現性のために,Gitなどによるバージョン管理には含めるのが望ましいものです.

3. 文書を組む

さて,いよいよ文書を組む準備ができたので,実際に文書を組みます.

$ saphe build foo.saty
  deps file: '/path/to/foo.satysfi-deps.yaml'
 ---- ---- ---- ----
  target file: '/path/to/foo.pdf'
  dump file: '/path/to/foo.satysfi-aux' (will be created)
  parsing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/annot/annot.0.0.1/src/annot.satyh' ...
(中略)
  parsing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/hdecoset.satyh' ...
 ---- ---- ---- ----
  type checking '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/option.satyg' ...
  type check passed.
(中略)
  type checking '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/std-ja-report/std-ja-report.0.0.1/src/std-ja-report.satyh' ...
  type check passed.
  parsing '/path/to/foo.saty' ...
 ---- ---- ---- ----
  type checking '/path/to/foo.saty' ...
  type check passed. (document)
  preprocessing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/option.satyg' ...
(中略)
  preprocessing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/std-ja-report/std-ja-report.0.0.1/src/std-ja-report.satyh' ...
  evaluating '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/option.satyg' ...
(中略)
  evaluating '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/std-ja-report/std-ja-report.0.0.1/src/std-ja-report.satyh' ...
 ---- ---- ---- ----
  evaluating texts ...
modified version of stdjareport.
  evaluation done.
 ---- ---- ---- ----
  breaking contents into pages ...
(中略)
  all cross references were solved.
 ---- ---- ---- ----
  embedding fonts ...
 ---- ---- ---- ----
  writing pages ...
 ---- ---- ---- ----
  output written on '/path/to/foo.pdf'.
  deps config written on '/path/to/foo.satysfi-deps.yaml'.

随分と色々うるさく標準出力に出ますが,最終的に無事 foo.pdf が生成できています.開いて見てみましょう:

出力された文書の画像

組めていますね.やったぜ.

実は,saphe build はPDF以外にも若干ファイルを生成します.最終的には以下のようになっているはずです:

.
├── foo.pdf
├── foo.saphe.lock.yaml
├── foo.saphe.yaml
├── foo.saty
├── foo.satysfi-aux
└── foo.satysfi-deps.yaml

foo.satysfi-aux はSATySFi本体が組版処理中に出力したファイルで,0.0.x時代からある相互参照のダンプファイルです(TeX/LaTeXでいうところの .aux と等価なもの).もう一方の foo.satysfi-deps.yaml は,SATySFi本体を起動する前にSapheがロックファイル foo.saphe.lock.yaml をもとに生成し,依存パッケージの配置や読み込むべき順序などをSATySFi本体に伝えるためのファイルで,依存コンフィグファイルと呼んでいます.これら2つはバージョン管理から除外すべきファイルです.$saphe build の動作を図にまとめると以下のような具合になります:

saphe buildの動作の概略図

こうして $ saphe init$ saphe solve$ saphe build の3コマンドで文書が組めるようになっているわけです.

used_as とは何なのか

used_as は,簡単に言えば「そのパッケージがどのようなモジュール名でプロジェクト中の use package … で参照されるか」を指定する箇所です.

もう少し前提から紹介すると,SATySFi 0.1.xおよびSapheでは「各パッケージはメインモジュールと呼ばれるただ1つのモジュールだけを公開する」という仕組みがとられています.例えばパッケージ stdlibStdlib をメインモジュールとし,このモジュールの入れ子のメンバとして Stdlib.OptionStdlib.List などのモジュールを提供するという具合です.

このメインモジュールの名前は,使う側がコンフィグファイル中で指定して替えられるようになっています.その指定を行なう箇所が used_as だったというわけです.ためしに先ほどの例の used_as を変更して処理してみましょう:

 dependencies:
-  - used_as: "StdJaReport"
+  - used_as: "FooBar"
     registered:
       registry: "default"
       name: "std-ja-report"
       requirement: "^0.0.1"
$ saphe solve foo.saty
(中略)
  lock config written on '/path/to/foo.saphe.lock.yaml'.
$ saphe build foo.saty
(中略)
  parsing '/path/to/foo.saty' ...
! [Error] at "foo.saty", line 1, characters 17-28:
    dependency on unknown package 'StdJaReport'

StdJaReport が知らないものとして弾かれました.文書の方も合わせて修正して処理してみましょう:

-use package open StdJaReport
+use package open FooBar
$ saphe build foo.saty
(中略)
  output written on '/path/to/foo.pdf'.

処理できました.このように,used_as で依存パッケージをどのように参照するかが指定できるようになっています.

とはいえ,現実的にはこの used_as に指定するモジュール名としてはパッケージ名をcapitalizeしたものを書けば事足りることがほとんどです.上記の例でも std-ja-report をcapitalizeした StdJaReport を指定しているだけでしたし,FooBar のような別名にするとわかりにくいため積極的に変える理由はありません.ではなぜこんな仕組みがあるのかというと,同一パッケージの異なるバージョンが共存する際にこのような名前の変更が必要となるためです.

例えば,base パッケージの 1.x.y2.x.y を両方使わねばならない場面があるとしましょう.何もなければ両方使う必要が生じることはまずありませんが,例えば依存パッケージAとBがそれぞれ base1.x.y2.x.y のみに依存していて,両方必要になることがありえます.このとき,used_as を使うと両者を BaseOldBase などと参照し分けることができます:

dependencies:
  - used_as: "BaseOld"
    registered:
      registry: "default"
      name: "base"
      requirement: "^1.0.0"
  - used_as: "Base"
    registered:
      registry: "default"
      name: "base"
      requirement: "^2.0.0"

この used_as という機構によって,同一パッケージの複数の非互換なバージョンの共存が許容できる仕組みになっています.

ライブラリを実装したい場合のSapheの使い方

1. 初期化

ライブラリの実装を始めたいときは,以下のように初期化します:

$ saphe init library .
  created a package config '/path/to/saphe.yaml'
  created '/path/to/src/Calc.satyh'
  created '/path/to/test/CalcTest.satyh'

以下のようなファイルができました.

.
├── saphe.yaml
├── src
│   └── Calc.satyh
└── test
    └── CalcTest.satyh

コンフィグファイル saphe.yaml は以下のような内容です:

saphe: "^0.0.1"
satysfi: "^0.1.0"
name: "your-library"
authors:
  - "Your Name"
registries:
  - name: "default"
    git:
      url: "https://github.com/SATySFi/default-registry"
      branch: "temp-dev-saphe"
contents:
  library:
    main_module: "Calc"
    source_directories:
    - "./src"
    test_directories:
    - "./test"
dependencies:
  - used_as: "Stdlib"
    registered:
      registry: "default"
      name: "stdlib"
      requirement: "^0.0.1"
test_dependencies:
  - used_as: "Testing"
    registered:
      registry: "default"
      name: "testing"
      requirement: "^0.0.1"

contents 以外は文書の場合と同じです.contents 直下には library というフィールドがあり,その下の dependencies もやはり文書の場合と同様です.test_dependenciesdependencies とよく似た項目を書くことができ,ここにはテストでのみ使用する依存パッケージが列挙されます.典型的にはテストフレームワークを提供するパッケージが記述されることになるでしょう.

source_directoriesソースファイルを置くディレクトリを指定する項目で,ここに列挙されたディレクトリにあるファイルのうち適切な拡張子をもつものがライブラリに属するソースファイルとして認識されます.ここでは ./src のみが指定されており,./src/Calc.satyh のみがソースファイルです.同様に,test_directories にはテストファイルを置くディレクトリを指定します.ここでは ./test のみが書かれており,./test/CalcTest.satyh のみがテストファイルです.

main_module にはこのライブラリのメインモジュールを指定します.ここでは唯一のソースファイルである Calc.satyh がメインモジュール Calc を定義しており,これが指定されています.

初期化された Calc.satyh の内容は以下になっています:

module Calc :> sig
  val succ : int -> int
end = struct
  val succ n = n + 1
end

テストは一旦置いておいて,まずはこのソースファイルを検査してみましょう.

2. パッケージの依存解決

文書の場合と同様に,まずはパッケージの依存解決をします:

$ saphe solve .
  envelope config written on '/path/to/satysfi-envelope.yaml'.
  package dependencies to solve:
  - stdlib (^0.0.1) used as Stdlib
  - testing (^0.0.1, test_only) used as Testing
  package dependency solutions:
  - stdlib 0.0.1
  - testing 0.0.1
(中略.取得処理が走る)
  lock config written on '/path/to/saphe.lock.yaml'.
.
├── saphe.lock.yaml
├── saphe.yaml
├── satysfi-envelope.yaml
├── src
│   └── Calc.satyh
└── test
    └── CalcTest.satyh

ロックファイル saphe.lock.yaml が生成されるのは文書の場合と同様ですが,これに加えてエンベロープファイル satysfi-envelope.yaml も生成されました.エンベロープファイルはSATySFi 0.1.0処理系本体が読むためのファイルであり,簡単に言えばソースファイルやテストファイルの位置がコンフィグファイルから転記されているものです.ロックファイルと同様,ユーザが読み書きすることはありませんが,バージョン管理には含めるべきファイルです.

ちなみに,エンベロープとは(Sapheではなく)SATySFi処理系本体から見えるパッケージのような単位のことなのですが,これについては本稿の取り扱い範囲とせず後編に譲ります.実際,ユーザはエンベロープの存在を意識せずともSapheを介してSATySFi 0.1.0が使えるようになっています.

3. 型検査を走らせる

ライブラリの型検査はやはり build サブコマンドで行なわれます:

$ saphe build .
  deps file: '/path/to/satysfi-deps.yaml'
  parsing '/path/to/src/Calc.satyh' ...
  parsing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/paper-size.satyh' ...
(中略)
  type checking '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/stdlib.satyh' ...
  type check passed.
 ---- ---- ---- ----
  type checking '/path/to/src/Calc.satyh' ...
  type check passed.
  deps config written on '/path/to/satysfi-deps.yaml'.

通りました.やったぜ.

4. テストを走らせる

今度はユニットテストを実行してみましょう.まず,生成された CalcTest.satyh は以下のようなファイルです:

use Calc
use package Testing

module CalcTest = struct
  module IntTarget = struct
    type t = int
    val equal m n = (m == n)
    val show = arabic
  end
  module IntEquality = Testing.Equality.Make IntTarget

  #[test]
  val succ-test =
    IntEquality.assert-equal 43 (Calc.succ 42)
end

use … は同一パッケージ内の他のファイルが定義するモジュールを参照する指定で,Haskellの import qualified … と同様のものです.ここでは Calc.succ をテスト対象として使うために Calc.satyh が定義する Calc を参照しています.

構文的にRustとよく似ていますが,#[test] つきの val はテスト項目として扱われます.ここでは,Calc.succ 42 の結果が 43 であることをテストする項目が書かれています.現状の Testing の用法はファンクタを使用した若干ややこしいものですが,とりあえず IntEquality.assert-equalint 型の等価性をテストする函数であることだけ把握してもらえれば差し支えありません.

さて,実際にテストを走らせてみましょう:

$ saphe test .
  deps file: '/path/to/satysfi-deps.yaml'
  parsing '/path/to/src/Calc.satyh' ...
  parsing '/path/to/test/CalcTest.satyh' ...
  parsing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/paper-size.satyh' ...
(中略)
  parsing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/testing/testing.0.0.1/src/testing.satyg' ...
 ---- ---- ---- ----
  type checking '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/testing/testing.0.0.1/src/testing.satyg' ...
  type check passed.
(中略)
 ---- ---- ---- ----
  type checking '/path/to/src/Calc.satyh' ...
  type check passed.
 ---- ---- ---- ----
  type checking '/path/to/test/CalcTest.satyh' ...
  type check passed.
  preprocessing '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/testing/testing.0.0.1/src/testing.satyg' ...
(中略)
  preprocessing '/path/to/test/CalcTest.satyh' ...
  evaluating '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/testing/testing.0.0.1/src/testing.satyg' ...
(中略)
  evaluating '~/.saphe/packages/6f2b80e9bb7c4e8af2104999fc25dbb3/stdlib/stdlib.0.0.1/src/stdlib.satyh' ...
  evaluating '/path/to/src/Calc.satyh' ...
  evaluating '/path/to/test/CalcTest.satyh' ...
  OK: succ-test
 ---- ---- ---- ----
  all tests have passed.

テストが通りました.やったぜ.ちなみに,失敗する場合はどうなるでしょうか? 実装を間違ったものに変えて実行してみましょう:

 module Calc :> sig
   val succ : int -> int
 end = struct
-  val succ n = n + 1
+  val succ n = n + 15
 end
  evaluating '/path/to/test/CalcTest.satyh' ...
! FAILED: succ-test
  expected: 43, got: 57
 ---- ---- ---- ----
! some test has failed.

失敗が報告されました.これでスムーズにライブラリの開発ができそうですね.

フォントパッケージをつくりたい場合のSapheの使い方

ここまで触れてきませんでしたが,SATySFi 0.1.0が0.0.xから大きく変わっている点のひとつとして,フォントの読み込み方法があります.もともと0.0.xでは ~/.satysfi/dist/fonts/ といったディレクトリにフォントファイルを置き,~/.satysfi/dist/hash/fonts.satysfi-hash などの設定ファイルにフォントを指し示すための名前文字列とフォントファイルの紐づけを記載するという方法を採っていました.これは仕組みとしては単純ですが,基本的にはOSのユーザ単位のグローバルな設定を書き換えねばならないため,以下のような難点がありました:

  • 複数の文書・ライブラリ間で,使いたいフォント名が衝突するおそれがある.
  • フォントに関して組版処理の再現性が担保しにくい.
  • 最初にSATySFiをインストールするときに,デフォルトのフォントとその設定を記述したファイルをシステムにインストールせねばならず,自動で行なわれるにせよユーザが手動で行なうにせよ手順が煩雑化しやすく,移植性も下がりやすい.

Satyrographosはフォントをパッケージ単位に分割して取り扱う機能を提供し,上記の問題をうまく隠蔽してくれていましたが,今回パッケージの仕組みを改修し,処理系本体も或る程度パッケージの単位を認識するように修正したついでに,こうしたやや筋の悪かったフォントの取り扱いも刷新することにしました.その結果できたのがフォントパッケージという概念です.発想自体は従来のSatyrographosによるフォントの取り扱い方とよく似ていますが,フォントパッケージの下では従来の `Junicode-it` といった名前文字列による指定はなくなり,FontJunicode.italic といった識別子でのみフォントを指し示すようになります.

ファイルの配置とコンフィグファイルの概要

フォントパッケージを $ saphe init などで初期化する機能が未実装なこともあり,ここではハンズオンではなく実例を追うだけにとどめます.フォントパッケージ font-junicode を例にとって見てみます.まず,ファイルの配置は以下のような具合です:

.
├── saphe.yaml
└── fonts
    ├── Junicode.ttf
    ├── Junicode-Bold.ttf
    ├── Junicode-Italic.ttf
    └── Junicode-BoldItalic.ttf

ライブラリと同様にコンフィグファイル saphe.yaml がディレクトリ直下に配置されており,おおよそ以下のような内容が記述されています:

saphe: "^0.0.1"
satysfi: "^0.1.0"
name: "font-junicode"
authors: []
contents:
  font:
    main_module: "FontJunicode"
    files:
      - path: "./fonts/Junicode.ttf"
        opentype_single:
          name: "normal"
      - path: "./fonts/Junicode-Bold.ttf"
        opentype_single:
          name: "bold"
      - path: "./fonts/Junicode-Italic.ttf"
        opentype_single:
          name: "italic"
      - path: "./fonts/Junicode-BoldItalic.ttf"
        opentype_single:
          name: "bold-italic"

やはり contents 以外は文書やライブラリと同じです.contents 直下には font という項目があり,このフォントパッケージに属する各フォントファイルとそれに関する設定が files に列挙されています.各要素は path でまずフォントファイルへの相対パスを記述しており,そして内容の読み方を他の項目で指定しています.ここではいずれも opentype_single という項目を記載し,単一のフォントをもつOpenTypeフォントファイルであることを指定しています.この opentype_single の下には,name という項目により「言語中でどのような識別子でそのフォントを扱ってほしいか」を指定します.例えば,通常のローマンのJunicodeは normal,Junicode Bold Italicは bold-italic という識別子でアクセスできる font 型の値になります.実際には,このフォントパッケージを依存パッケージとする文書などで used_as: FontJunicode などとメインモジュールが指定され,そのメンバとして FontJunicode.normal とか FontJunicode.bold-italic といった方法でアクセスできるようになります.

(ちなみに,実は上記の main_module という指定には特に意味がありません.ライブラリの場合と同様に,フォントパッケージを使う側が必ず used_as でメインモジュール名を与えるがゆえに,フォントパッケージ側でメインモジュールの名前を指定する必要がないからです.正式にリリースする頃には廃止しているのではないかと思います.)

数式フォントとして使いたい場合は,name の他に math: true という指定をつけます(より正確には,math という項目は省略可能で,デフォルト値が false であるということです).例えば font-latin-modern-math パッケージのコンフィグファイルの contents は以下のような記述になっています:

contents:
  font:
    main_module: "FontLatinModernMath"
    files:
      - path: "./fonts/latinmodern-math.otf"
        opentype_single:
          name: "main"
          math: true

該当ファイルがOpenType Collectionである場合は,opentype_single ではなく opentype_collection という項目を記載します.こちらはフォントファイルの要素となるフォントと同じ個数だけ namemath からなる要素が並んだリストで内容の扱い方を指定します(具体例はまだないため省略).

外部からフォントファイルを取得する場合の記述

実際には,フォントファイルが陽に path で指定された箇所に置かれている必要はありません.フォントパッケージがエンドユーザの手元に取得されるタイミングで,エンドユーザの環境に於いてフォントファイルが特定のURLから取得されるように指定することができます.こうした仕組みにより再配布にはならない形でフォントの利用手段を提供でき,より幅広いライセンスに対応できたり,該当URLへのアクセス権限をもつ人だけが使えるフォントパッケージが作成できたりします.

これを実現するのが external_resources という項目です.実際の現状の font-junicode パッケージは,この external_resources を利用してJunicodeの配布元からフォントファイルを取得する処理が書かれたものになっています:

saphe: "^0.0.1"
satysfi: "^0.1.0"
name: "font-junicode"
authors: []
external_resources:
  - name: "junicode-1.002"
    zip:
      url: "https://downloads.sourceforge.net/project/junicode/junicode/junicode-1.002/junicode-1.002.zip"
      checksum: "b1d6ed8796d8d1eacd733c0d568d939f"
      extractions:
        - from: "Junicode.ttf"
          to: "./fonts/Junicode.ttf"
        - from: "Junicode-Bold.ttf"
          to: "./fonts/Junicode-Bold.ttf"
        - from: "Junicode-Italic.ttf"
          to: "./fonts/Junicode-Italic.ttf"
        - from: "Junicode-BoldItalic.ttf"
          to: "./fonts/Junicode-BoldItalic.ttf"
contents:
  font:
(以降略)

external_resources 直下には外部から取得するファイルが列挙されたリストがあります.各要素は name で一意に名前が振られており,形式・取得方法・展開方法などが関連づけられています.現在のところ,対応している形式は zip で指定されるZIPファイルの取得のみです.zip の直下には取得元URLである url と内容確認用のMD5チェックサムである checksum,そして取得したZIPファイルの中身を解凍してどう配置するかの指定 extractions が記載されます.

ネットワークを介して任意のファイルを取得し配置する処理であるため,正式にリリースするにはセキュリティホールにならないように注意を払う必要がありますが,SATySFiでフォントを筋良く扱うには有望な仕組みであろうと考えています.

サブディレクトリを別のパッケージとして扱う

特定のサブディレクトリを,“実装が固定された別のパッケージ” と看なして扱うことができます.今のところ上位のディレクトリも指定できるので,実際にはサブディレクトリに限らない任意のディレクトリです.これは,例えば以下のような状況で便利です:

  1. 文書を書いているうちに特定部分だけ切り出してライブラリにしたくなってきたが,再利用性が高い形で切り分けられるかまだ自信がないし,別個のリポジトリを作成してそこに移植し修正を重ねるたびにレジストリに登録するという手間を払ってよいと言えるほど自信がないので,特定の実装を別のパッケージとして括り出しつつも手間のかからない方法が欲しい
  2. フォントパッケージにはなっていないが手元にあるフォントファイルをSATySFiで使いたい.→サブディレクトリをそのフォント用のフォントパッケージにする方法によって手頃に実現できる.

指定はとても簡単で,以下のような記述を dependencies に追加することで相対ディレクトリ ./your-local-package/ が依存パッケージのひとつとして使えるようになります:

 dependencies:
   - used_as: "StdJaReport"
     registered:
       registry: "default"
       name: "std-ja-report"
       requirement: "^0.0.1"
+  - used_as: "YourLocalPackage"
+    local:
+      path: "./your-local-package/"

まとめ

Sapheは以下のようなコマンドで簡単に文書やライブラリを記述することのできるエコシステムです.

  • 文書を組む場合:
    1. $ saphe init document foo.saty で文書ファイル foo.satyコンフィグファイル foo.saphe.yaml を初期化し,適宜書き換える.
    2. $ saphe solve foo.saty で依存パッケージの解決を行ない,配置する.この結果ロックファイル foo.saphe.lock.yaml が書き出される.
    3. $ saphe build foo.saty で文書を実際に組み,foo.pdf を出力することができる.副産物として依存コンフィグファイル foo.satysfi-deps.yaml とダンプファイル foo.satysfi-aux が生成される.
  • ライブラリを書く場合:
    1. $ saphe init library .コンフィグファイル saphe.yaml,ソースファイルのディレクトリ src/,テストファイルのディレクトリ test/ を初期化し,適宜書き換える.
    2. $ saphe solve . で依存パッケージの解決を行ない,配置する.この結果ロックファイル saphe.lock.yamlエンベロープファイル satysfi-envelope.yaml が書き出される.
    3. $ saphe build . でライブラリの実装を型検査できる.
    4. $ saphe test . でユニットテストを走らせることができる.

後編は,Sapheの内部的な設計や実装について記載する予定です.