anti scroll

ブラウザと小説の新しい関係を模索する

TypeNovelをTypeScriptで書き直しました

TypeNovelは、制約と注釈の組み合わせによって、型付きの小説を記述するための言語です。

参考:プロとアマの小説の特徴を数値化して比較してみたらやっぱり差があったので、それを埋めるための型付き小説記述用言語 TypeNovel を公開した件について

これまでF#で開発してきたのですが、今後はTypeScriptで開発することになりました。

これによって、コンパイラのインストールは

npm install -g typenovel

で、完了します。

インストールが成功すると、/usr/local/bin/tncが使えるようになるはずです。

[foo@local] tnc --version
v1.0.0

書き直した理由

一言でいうと「TypeNovelReaderのファイルサイズが大きくなってしまうから」です。

F#のアプリケーションを配布するときには、.NETCoreのランタイムも一緒に配布しないといけないのですが、これが80Mbyte近くあります。

これを除外して、ファイルサイズを削減したかった、というのが主な理由です。

しかしMac/Linux用のバイナリを100Mb以下にする、という当初の目標は達成できませんでした…つまりGithub(100MBの制約がある)にはMac/Linux版をアップロードできません。

その他にも「コンパイラの配布が簡単になる」とか「メジャーな言語なので開発者を募りやすくなる」とかもありますが…

Fsharpの良かった点

  • パーサーが書きやすい

Fsharpの辛かった点

  • 開発者が少ない(ように感じる)
  • ドキュメントが少ない
  • paket周りの運用が少し面倒に感じた
  • ランタイムがでかい(80M前後)
  • プログラムの立ち上がりが少し遅い

TypeScriptの良い点

  • TypeNovelReaderのファイルサイズが減る(20Mbyte近く削減)
  • npmで簡単に配布できる(インストーラーを配布する必要がない)
  • 書ける人がたくさんいる

TypeScriptにして辛い点

  • Nearley(パーサー生成系)やMoo(字句解析機の生成系)などの優れたライブラリを使うことで軽減されるが、F#に比べるとパーサーを書くのは面倒くさい

変わった仕様について

  • コンパイルオプションの--release--minifyに変更されました。
  • --formatオプションを指定することで、出力フォーマットとして、htmlだけではなくtextも選べるようになりました。
  • tnconfig.jsonにおいて、warnXXX系のフラグは、compilerOptionsというフィールドで記述する仕様に変更されました。
// 変更前
{
  warnUndefinedConstraint: true,
  ...
}

// 変更後
{
  "compilerOptions": {
    warnUndefinedConstraint: true,
    ...
  }
}

TypeNovelで電子書籍を公開する方法

TypeNovelで記述した原稿を公開する時、そのファイルをそのままTypeNovelReaderで開いても、それなりには表示されます。

しかし、どうせなら作品タイトルや、作者の情報や、作中キャラクターの情報などが表示されるように公開したいものです。

ここではそうした情報を盛り込んだ上で作品を公開する方法を簡単に説明します。

ちなみにTypeNovelReaderは以下のURLからダウンロードできます。

Windows版は.exeで、Mac版は.dmgで、Linux版は.AppImageです。TypeNovelコンパイラも付属しているので、別途TypeNovelをダウンロードする必要もありません。

メイン原稿はindex.tn

まずはindex.tnというファイルを作って、次のように記述します。

@scene(){
  あいうえお。
}

作品情報はdata.json

次にdata.jsonというファイルを作って、こんな感じで記述します。コピペして必要な箇所だけ書き換えたらいいでしょう。

ちなみにwritingModeの部分をhorizontal-tbとすると、横書きになります。

その他の項目についての詳細はSemanticNovelというページを参照して下さい。

{
  "title": "作品のタイトル",
  "theme": "default",
  "author": "あなたのお名前",
  "email": "あなたのメールアドレス",
  "homepage": "あなたのホームページ",
  "writingMode": "vertical-rl",
  "displayTypeNovelError": true,
  "enableSemanticUI": true,
  "speechAvatarSize": 50,
  "characters": {
    "hanako": {
      "names": ["田中", "花子"],
      "images": {
        "normal": "images/tanaka-hanako.png"
      },
      "description": "田中花子の詳細をここに書く"
    },
    "taro: {
      "names": ["山田", "太郎"],
      "images": {
        "normal": "images/yamada-tarou.png"
      },
      "description": "山田太郎の詳細をここに書く"
    }
  }
}

必要に応じて画像ファイルを追加

上のdata.jsonでは、キャラクターの画像としてimages/tanaka-hanako.pngなどのようなファイルが入っているので、キャラクター画像が用意できるなら、imagesフォルダーを作って、そこにtanaka-hanako.pngのようなファイルを放り込んで下さい。

画像がなければ、data.jsonからimagesの項目そのものを削除しても構いません。

ファイルをzipでまとめる

あとは上記のファイルすべてを一つのフォルダーに入れて、zipで圧縮してまとめます。

zipにするのが面倒なら、index.tnと同じディレクトリにdata.jsonをおいておくだけでも構いません。

ファイルをTypeNovelReaderで開く

上で作ったzipファイル(もしくはdata.jsonと同じディレクトリにあるindex.tnファイル)をTypeNovelReaderで開きます。

基本的にはこれだけですが、原稿の中身がちょっと寂しいので、少し台詞などを足してみましょう。

原稿に台詞を足してみる

@scene(){
  @speak("taro"){ 「ぼくは太郎さ」 }
  @speak("hanako"){ 「わたしは花子よ」 }
}

原稿を修正したらリロード

TypeNovelReaderの上部メニューに「Rebuild」というボタンがあるので、押すと変更が反映されます(ファイルを開き直す必要はありません)。

リビルドすると、台詞が表示されるだけじゃなく、それぞれの台詞の上に人物アイコンのようなものが表示されます。

アイコンにカーソルを乗せると、人物名がポップアップし、さらにそのアイコンをクリックすると、data.jsonで記述したキャラクターの詳細文がダイアログで表示されます。

その他の表現

よく使いそうなものを紹介します。

ルビ

$ruby("漢字", "かんじ")にルビをふる。

圏点・傍点

日本語で$fdot("圏点")を打つ。
日本語で$odot("圏点")を打つ。
日本語で$ftriangle("圏点")を打つ。
日本語で$otriangle("圏点")を打つ。
日本語で$fsesame("傍点")を打つ。
日本語で$osesame("傍点")を打つ。

縦中横

これは事件だ$tcy("!!")

チップ・リンク

チップ・リンクが最初に登場したのは、@tip("街"){
 @p(){ 1998年に現在のSpikeChunsoftの前身であるChunsoftが発売したゲーム。 }
 @p(){ ザッピング等のシステムが斬新で、今も不朽の名作として知られるアドベンチャーゲームの最高峰。}
}というゲームだったように思う。

脚注リンク

メッシはクラッキ@notes(){
  ポルトガル語で「名手」を意味する言葉
}である。

画像

$img("images/tb.png", 100, 100)

画像(行頭方向に寄せる)

$img("images/tb.png", 100, 100, "float start")

画像(行末方向に寄せる)

$img("images/tb.png", 100, 100, "float end")

シーンの制約(時間)

season(季節)とtime(時刻)とdate(日付)をシーンの制約に含めると、その結果がUIのテーマに反映されます。

「読者に読解力と記憶力を求めない小説」は可能か? TypeNovel用の電子書籍リーダー「TypeNovelReader」を公開しました

TypeNovel用のリーダーアプリ「TypeNovelReader」を公開しました。

github.com

TypeNovelで記載した「時間」とか「人物」などといった情報も、表示に反映されます。

ちなみにTypeNovelコンパイラも一緒に入っているので、別途ダウンロードする必要はありません。

Windows用のインストーラーは.exe Mac用のインストーラーは.dmg Linux用は.AppImage

使い方

TypeNovelで書かれた原稿を、ドラッグ・アンド・ドロップで放り込むだけです。

FileメニューからOpenを選んでもいいです。

設定ファイル(この記事の下で解説)や、複数ファイルをまとめてzip化したものを開くこともできます。あと*.html, *.txt, *.mdも可。

コンパイルエラーは(もしあれば)画面下のほうに表示されます。

特徴

TypeNovelで執筆した原稿をTypeNovelReaderで表示させることで、以下のような「読者を補助する機能」が、簡単に利用できます。

誰のセリフなのかが分かり、かつその人物が何者なのかを思い出せるUI

@speakタグを使って台詞を記述すると、各セリフの上に人物アイコンが出ます。

f:id:convertical:20190727091724p:plain
通常の台詞

カーソルを上に乗せると人物名がポップアップします。

f:id:convertical:20190727091808p:plain
名前がポップアップ

また@sb-start@sb-endを使うと、画像つきの吹き出しセリフを表示します。

f:id:convertical:20190727091946p:plain
吹き出しを使った台詞

@sb-startを使うとアバター画像が先に表示され、台詞が次に表示されます。@sb-endはその逆で、台詞が先でアバターが後です。

人物アイコンやアバター画像をクリックすると、詳細なプロフィールが表示されます。

f:id:convertical:20190727092017p:plain
キャラクターの詳細を表示

一般に登場人物の多い作品は、それぞれの名前を覚えるのに難儀しますので、こういうUIは有効なのではないでしょうか。

シーンの切り替わりで改ページが入る

一般にシーンの切り替えは、場所、時間などの切り替えを伴うはずです。

よって、そのタイミングで読者の視覚に全く変化がないのは直感的におかしいと考え、シーンの切り替えで改ページが入るようにしました。

シーンに記述された季節や時刻がUIのテーマに反映される

シーンにseason(季節)とdate(日付)とtime(時刻)の記述があった場合、その情報がUIに反映されます。

例えば季節が秋だと、メニューと本文の間にある補助線が紅葉に代わり、時刻が夕方だとオレンジ色の空の画像が左上に表示されます。

f:id:convertical:20190727092044p:plain
秋の装い

「逆に情緒が削がれる!」という場合は、画面上部のメニューボタンから無効にすることも出来ます。あるいは後ほど解説する設定ファイル(data.json)で無効にすることも可能です。

チップ機能と脚注機能

チップ機能は、文章の中の特定の単語に補足の説明を付け足したいときに使います。

f:id:convertical:20190727092125p:plain
チップリンク

クリックすると、補足情報が表示されます。

f:id:convertical:20190727092144p:plain
クリックで補足情報が表示される

部分的にちょっとした情報を付け足したいときには、脚注が便利です。

f:id:convertical:20190727092206p:plain
脚注リンク

これも同じく、クリックすると補足情報が表示されます。

f:id:convertical:20190727092224p:plain
クリックすると補足情報が表示される

目次項目を追従

作中に目次が設定されていれば、ステータス部分で現在読んでいる章が表示されます。

縦書きにも横書きにも対応

nehanを使っているので当たり前なのですが、縦書き横書き(のページ送り)に対応しています。

ルビ、圏点・傍点、ドロップキャプス、画像などにも対応

基本的に縦書き文庫で使える組版は全て使用可能です。

f:id:convertical:20190727093941p:plain
ルビ・圏点・傍点

ルビの書き方とかは、公式のサンプルを見て下さい。

執筆する人たち向けの情報

以下は執筆者向けの情報です。

まずはサンプルを動かす

先の表示例は公式のサンプルによるものです。

公式サンプルのsemantic-novel-jp.zipをTypeNovelReaderにドラッグ・アンド・ドロップしてください。

あるいは解凍して、中身のindex.tnを放り込んでも同じ結果になります。

次にサンプルを解凍して、中にあるindex.tnとかdata.jsonを眺め、表示結果と比べてみてください。

なんとなく書き方がわかると思います。

ちなみに解凍した先のディレクトリからindex.tnだけをTypeNovelReaderに放り込んでも、同じように作品を表示させることができます。

なお、原稿の中身を書き換えた場合は、画面上部のメニューに再構築(Rebuild)用のボタンが便利です。押すと作品が再コンパイルされ、変更が反映されます。

f:id:convertical:20190727092250p:plain
リビルドボタン

注意点

執筆するにあたり、いくつかの注意点があります。

注意1 メインの原稿ファイルはindex.tn

TypeNovelでは、各ソースの中で外部の原稿を$include("chapter1.tn")のようにして取り込むことができます。

なので、複数のファイルをzip化して開くときなどに、どのファイルが原稿の入り口なのかがわからないと、困ったことになります。

というわけで、TypeNovelReaderでは、その入り口に該当するファイルをindex.tnである、と規定しています。

とはいえzip化せず、直に原稿ファイル(*.tn)を放り込むのであれば、どんなファイル名でも問題ないのですが、いちおう「そういうルールがあるんだな」ぐらいに覚えておいてください。

注意2 作品情報はdata.jsonに記述

中身はこんな感じのファイルです。なんとなくですが、見たらわかるのではないでしょうか。

作品のタイトル、作者情報、初期の書式モード(縦書きか、横書きか)、登場人物など、色々なことについて設定できます。

内容については、各自で自分の作品用に直して使ってください。

{
  "title": "サンプル作品",
  "author": "縦書き文庫",
  "email": "lambda.watanabe@gmail.com",
  "homepage": "https://tb.antiscroll.com",
  "writingMode": "vertical-rl",
  "speechAvatarSize": 50,
  "enableSemanticUI": true,
  "displayTypeNovelError": true,
  "characters": {
    "john": {
      "names": ["John", "Adams"],
      "images": {
        "normal": "images/avatar2.svg"
      },
      "description": "Desription of John Adams"
    },
    "taro": {
      "names": ["山田", "太郎"],
      "images": {
        "normal": "images/avatar1.svg"
      },
      "description": "山田太郎の詳細をここに書く"
    }
  }
}

注意3 tnconfig.jsonはなくても大丈夫

サンプルにも含まれていませんが、tnconfig.jsonがない場合は公式の初期設定ファイルが勝手に使用されます。

公式の設定に加えて独自のマークアップなどを追加したい場合は、このファイルを編集して、index.tnと同じ場所に置くことになります。

しかし不注意に弄ると公式のマークアップと矛盾したり、公式の更新と衝突したりするので、あまり手を出さないほうが無難だと思います…

注意4 SemanticNovelについて

サンプルのファイル名がsemantic-novel-jp.zipとあるのですが、実はこのサンプルはSemanticNovelというTypeNovelのマークアップ仕様に沿って記述されています。

で、TypeNovelReaderも、この仕様に沿ったものを表示するという前提で開発されています。

つまり、この仕様に則っていない場合は、せっかくTypeNovelで型を書いても、それを十分に活かせないということになります。

ただこの仕様、まだまだ検討中のもので、今後大きく変わることもあり得るので、ご注意下さい。

SemanticNovelについて、何かしら新しいアイデアや要望があるかたは、Githubの上のページでご提案いただけたらと思います。

オープンソースという文化の性質上、やむを得ずページは英語で公開していますが、もちろん日本語で投稿していただいても全く問題ないです。

ライセンス

ライセンスはGPLv3です。

つまり無償で利用できるし、無償で改変できるけれども、改変したソースもまたGPLv3であることが求められます。

ということで、ソフトウェアを改変した後に、ソースを秘密にしたまま別アプリとかウェブサービスとして再配布、みたいなことはできません。

利用するにしても、ソースはGPLv3で公開して下さい、ということになります。

不具合について

不具合の報告はGithubや、Twitterまでどうぞ。

TypeNovelでルビをふる

ルビタグをそのまま記述しても良いのですが、こういう感じの設定をtnconfig.jsonに加えると(気持ち?)楽になります。

{
  "markupMap":{
    "$ruby":{
      "validate": false,
      "content": "<arg1><rt><arg2></rt>"
    }
  }
}

本文中はこんな感じでマークアップします。

$ruby("漢字", "かんじ")にふりがなをふる。

コンパイルすると、こうなります。

<ruby>漢字<rt>かんじ</rt></ruby>にふりがなをふる

ちなみに$rubyのmarkupMapで"validate":falseとなっているので、制約としての警告は出ないことに注意して下さい。

このように、純粋にインラインタグとして使いたいだけの場合は、markupMapで"validate":falseと設定しておくのが便利です。

ちなみに、この$rubyについてはv0.9.2より、最初から設定に含まれるようになる予定です。

TypeNovelにおいて、注釈しなくても良い制約を明示的に宣言できるようになりました

どういうものか

次のように、制約の値を"?"で始まる値にすると、その制約は本文中で注釈されなくてもエラーになりません。

@scene({
  time:"?" // 注釈する必要のないtime制約
}){
  本文中でtimeの注釈をしなくてもエラーにならない!
}

どうして必要なのか

注釈しなくても良い制約なら、そもそも宣言しなければよいのでは? と思われるかもしれませんが、敢えて宣言したい場合があります。

例えば「時刻が"?"のとき」は「不明瞭な時刻のようなもの」を連想させるために、ビューアーのテーマをグレースケールにさせたい、とか。

つまり「時刻が不明瞭である」という情報を、HTMLを扱うアプリケーション側が欲しがる場合です。

例えば上の例をコンパイルすると、@sceneブロックは次のようなHTMLにコンパイルされます。

<scene data-time="?">
  本文中でtimeの注釈をしなくてもエラーにならない!
</scene>

このように、HTMLにdata-time="?"という形で「時刻が不明瞭ですよ」という情報が盛り込まれるわけです。

というわけで今回、新しい仕様を付け足すことにしました。

これにより、制約値が不明瞭であることをHTMLに反映しつつ、TypeNovel側の本文では注釈しなくてもエラーが出ないようになりました。

v0.9.1から有効になりますので、興味のある方は最新バージョン(すでにダウンロードできるようになっています)をご利用下さい。

TypeNovelで出力したhtmlを縦書き文庫のビューアーで開く方法

最初に

Windows用とLinux用のexeのzipができましたので、ご利用下さい。

まずは適当な文章を書く

適当にこんな感じの文章を書いたとします。

@scene({season:"summer"}){
  @scene({time:"morning"}){
    遅刻したので、$time("朝食")はたべません!
  }
  @scene({time:"noon"}){
    $time("ランチでも")いかが?
  }
  @scene({time:"night"}){
    $time("日も暮れたし")もうそろそろ帰らない?
  }
}

リリースモードでコンパイル

これをリリースモードコンパイルします(リリースモードは、途中の余計な空白を取り除くモードです。出力結果がコンパクトになります)。

[foo@localhost] tnc --release sample.tn > sample.html

コンパイルのexeがMacでは小文字のtncですが、Windowsでは大文字のTnc.exeだと思います。各自ここは書き換えて使って下さい。

できあがったsample.htmlを見ると、こんな感じになっています。

/Users/u1/Downloads/sample.tn(line:1) 'season' is not annotated in this block!
error count = 1

<scene data-season='summer'><scene data-time='morning'>遅刻したので、<time>朝食</time>はたべません!</scene><scene data-time='noon'><time>ランチでも</time>いかが?</scene><scene data-time='night'><time>日も暮れたし</time>もうそろそろ帰らない?</scene></scene>

冒頭に「season制約(summer)を注釈してないよ!」というエラーが出ていますが、ここでは気にしないことにします。

縦書き文庫のビューアーにドラッグ・アンド・ドロップ

とりあえずhtmlファイルができたので、縦書き文庫に行って、適当な作品を開きます。

開いたら、作品の本文部分にsample.htmlをドラッグ・アンド・ドロップしてみましょう。するとダイアログが開くので、一般には次のように設定して「読み込む」を押します。

f:id:convertical:20190709172558p:plain
確認ダイアログ

すると次のように内容が表示されます。

f:id:convertical:20190709181258p:plain
表示結果(おかしい)

しかしちょっと変だということに気付いたでしょうか。冒頭のエラーのことではありません。別々の@sceneで分けたブロックが、すべてつながって一行の中にまとめられてしまっているのが問題です。

なぜこんなことになるかというと「本来HTMLに<scene>なんていうタグはないから」ということになります。

そして、一般に登録されていないタグは、インライン・タグと認識されるのがお約束です。

つまり<scene><b>とか<span>とかと同じようなタグと認識されたので、全ての文章が一行につながってしまったわけです。

設定ファイルを作成する

これをどうにかするために、まずは次のコマンドを打って、tnconfig.jsonを作成します。

[foo@localhost] tnc --init

上のコマンドをうつと、作業ディレクトリにtnconfig.jsonというファイルができるはずです。

さっそくこのtnconfig.jsonをエディタで開いてみると、途中に@sceneタグを<scene>ではなく<div>タグにマッピングする処理が書かれています。次がそれに該当する箇所です。

{
  "markupMap":{
    (中略)
    "@scene":{
      "tagName": "div",
      "className": "<name> <arg2>"
    },
    (中略)
  }
}

上の抜粋した部分では、初期状態のtnconfig.json@scene<div class='scene'>に変える、と書いてあります。

さて、tncは作業ディレクトリにtnconfig.jsonがあれば、それを設定ファイルとして利用する仕様なので、このファイルがある状態でコンパイルし直したら、@scene<div class="scene">として出力されそうです。

もういっかい先程と同じようにリリースモードでビルドし、出来上がったhtmlを見てみると、今度は次のようになっています。

/Users/u1/Downloads/sample.tn(line:1) 'season' is not annotated in this block!
error count = 1

<div class='scene' data-season='summer'><div class='scene' data-time='morning'>遅刻したので、<time>朝食</time>はたべません!</div><div class='scene' data-time='noon'><time>ランチでも</time>いかが?</div><div class='scene' data-time='night'><time>日も暮れたし</time>もうそろそろ帰らない?</div></div>

今度は<scene>タグではなく、<div>タグで出力されていまるのがわかりますでしょうか。<div>というのはもちろん通常のHTMLに用意されているタグで、ブロックタグを表すタグですから、今度は別々のシーンが別々の段落になっているはずです。

ではもう一度、同じファイルを縦書き文庫のビューアーの本文部分にドラッグ・アンド・ドロップしてみると、、、

f:id:convertical:20190709181337p:plain
表示結果(正常)

やりました! だいたい望み通りの出力です。

というわけで、こうやって縦書き文庫を利用することで、一応は出力結果を本のようにして読むことが可能になっています。

ちなみに「縦書きは嫌だ!」とか「文字が小さい!」とか不満がある方は「表示設定」というボタンから変更できます。

いずれ専用のビューアーは別に作るつもりですが、しばらくはこれでご容赦いただきたく。

Paket(.NETのパッケージマネージャー)とFAKE(F#のMake)について

調べてみたのですが、日本語の文献がほとんどなかったので、ここに記しておきます。

間違いなどありましたら、指摘していただけますと幸いです。

予備知識

PaketFAKEの説明の前に、dotnetの予備知識などを少々。

管理単位「ソリューション」と「プロジェクト」

プロジェクトは個々に別々のプログラムを指します。例えばアプリケーション本体とかテストプログラムとか。

ソリューションは、プロジェクトをまとめた単位です。

monoアプリケーションとその実行について

dotnetでは色々な*.exeを必要とすることが多いのですけど、*.exeの形式になってるのは大抵monoのアプリケーションです。

そういうプログラムは(少なくともMacでは)単体での実行ができないので(Windowsではどうか知らない)、次のようにmonoコマンドを経由する必要があります。

mono some_app.exe

Paketのインストール

ちょっと紛らわしいですが、Packetではなく、Paketです(間の「c」がいらない)。

グローバルにインストールするなら

dotnet tool install --global Paket --version 5.215.0

とします。

しかし通常はソースをcloneした人が個別にそんなことをしなくてもいいように、paket.bootstrapper.exeというものを提供する方式が一般的なようです。

その場合どうやって運用するかというと、

  1. ソリューションのルートディレクトリに.paketというディレクトリを作る。
  2. .paketの中に、公式が配布しているpaket.bootstrapper.exeというファイルをpaket.exeという名前で保存する(後述するMagic Mode)。
  3. で、mono .paket/paket.exe initとすると、Paketを実行するために必要なものが用意される。

Magic Modeについて

objectxさんに教わったのですが、.paketディレクトリにpaket.bootstrapper.exepaket.exeという名前で置いて、それをあたかも本物のpaket.exeのように使用するのはMagic Modeと呼ばれる使い方です。

Magic Modepaket.exe(実体はpaket.bootstrapper.exeで70kbyteぐらいしかない)は、初めて起動したときに本体のpaket.exe(8Mbyteもある)をネット越しにとってきて、dotnetの一時ディレクトリに展開します。

で、それ以降はpaket.exe(実際はpaket.bootstrapper.exe)に対するコマンドは、その本体に対して送られるようになる、ということみたいです。

本体ではないのに、本体のように使えるからMagic Modeということなんだと思います。

なんでそんな事をするかというと、

  1. 本体のpaket.exeは頻繁に更新されるし、サイズもでかいのでリポジトリに入れるのはしんどい。
  2. めったに更新されないpaket.bootstrapper.exepaket.exeとしてリポジトリに入れておき、それを経由してpaket.exe(本体)を実行する仕組みにすれば、paket.exe(本体)の更新に強くなる。

といった理由だと思います。

ちなみにPaketのドキュメントでpaket.exeと記述されているときは、それがMagic Modepaket.exe(つまり本当はpaket.bootstrapper.exe)のことを指すのか、本体のpaket.exeなのかを、文脈で判断する必要があって、ちょっとややこしいです。

Paketの依存性管理ファイル群

色々な管理ファイルがありますが、先に説明したソリューションプロジェクトという単位を知らないと混乱します。

paket.dependencies

paket.dependenciesはソリューション単位の依存関係を記述するファイルです。ソリューションはプロジェクトの集合でしたから、つまりは全プロジェクトを横断して必要なパッケージを記述するファイルになります。

このファイルでの依存性の書き方はThe packet.dependencies fileを参照して下さい。

ちなみに自分の場合は、objectxさんに全ておまかせしてしまいました(笑)。

paket.references

paket.referencesはプロジェクト単位の依存性を記述するファイルです。当該プロジェクトで必要とする依存性だけを記述します。

先のpaket.dependenciesとは違い、単にパッケージ名を羅列するだけで良いみたいです。

例えばTypeNovelの場合、コンパイラ本体のプログラム(プロジェクト名はTnc)のpaket.referencesはこんな感じです。

FSharp.Core
Argu

で、コンパイラが参照するTypeNovel用のライブラリ(プロジェクト名はLib)だとこうなっています。

FSharp.Core
FSharp.Data
FsLexYacc

paket.lock

これは後述するコマンド(packet updateやpacket restoreなど)を利用して依存性を更新すると、自動的に作られるファイルです。

paket.dependenciesに記述したすべてのパッケージが間接的に必要とするものまで含めて、すべての依存性がこのファイルに記述されます。

gitなどでは、依存性をはっきりと固定させるために、commitの対象にすることが推奨されています(参考:Why should I commit the lock file?)。

.paket/Paket.Restore.targets

paket.lockと同様に、パッケージの更新やインストールによって、自動で更新されるファイルです。

.paket/Paket.Restore.targetsは、*.fsproj(各プロジェクトのルートディレクトリにあるプロジェクト構成ファイル)にて、次のように取り込まれます。

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.2</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="Program.fs" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Lib\Lib.fsproj" />
  </ItemGroup>
  <Import Project="..\.paket\Paket.Restore.targets" />
</Project>

最終行の<Import Project="..\.paket\Paket.Restore.targets" />というタグがそれです。

このタグによって、使用しているパッケージへの参照が通ります。

パッケージの更新処理など

グローバルにすでにpaketがインストールされてるならpaket [コマンド名]でいいのですが、そうじゃない場合は、先に説明したMagic Modeでの実行、つまりmono .paket/paket.exe [コマンド名]として下さい。以降は記述を簡潔にするため、グローバルにインストールされている体裁で説明します。

色々とコマンドがあるのですが、ようするにpaket.lockというファイルの扱いがそれぞれ異なるからコマンド名が分かれている、と考えればわかりやすいです。

paket outdated

新しいバージョンが用意されているパッケージの一覧を表示してくれます。

一覧するだけなので、paket.lockファイルには影響がありません。

paket install

paket.lockの内容に従い依存関係を調べ、必要なパッケージをインストールしてくれます。

ただしpaket.dependenciesに変更があった場合は、先にpaket.lockの更新処理が入ります。

paket update

現在の依存関係を更新して、必要ならば新しいパッケージをインストールします。

paket updateとするとすべての依存関係、paket update [パッケージID]とすると、指定したパッケージだけが更新されます。

このコマンドを走らせると、まず最初にpaket.lockファイルが削除されて、一から作り直されます。

で、この新しいpaket.lockを元に(必要ならば)新しいパッケージがインストールされます。

先のpaket installとは違い、paket.lockを作り直してからインストールすることに注意して下さい。

paket restore

このコマンドを走らせると、paket.lockファイルが更新されます。ただし更新されるだけで、インストールまではされません。

つまりpaket update = paket restore + paket installです。

ちなみになにも引数を与えないとpaket.lockに対しての更新になりますが、 次のように--references-fileオプションで各プロジェクトのpaket.referencesを指定することもできます。

paket restore --references-file App/paket.references
paket install

FAKEについて

FAKEはF#のMakeです。ドメイン固有言語で記述するということになっていますが、実体は普通のF#コードです。

概念についても通常のMakeと同じで、それぞれの生成物(Target)が何に依存し、何を実行して作られるのか、みたいなことを記述します。

ちなみにfake-cliがインストールされてなかったら、勝手にダウンロードして実行、みたいなことまでしてくれるみたいです。

インストール

グローバルにインストールするなら

dotnet tool install fake-cli -g

そして、ディレクトリを指定するなら

dotnet tool install fake-cli --tool-path /path/to/tool

とやります。

ちなみにdotnet tool installのグローバルインストール先は、初期設定では$HOME/.dotnet/tools/です。

テンプレート作成

Fakefileをその都度つくるのはしんどいので、テンプレートから雛形を作ることができます。

まずはテンプレート一覧みたいなのを次のコマンドで取得します。

dotnet new -i "fake-template::*"

で、テンプレートの作成は次のようにします。

dotnet new fake

すると、カレントディレクトリに.fakeディレクトリ、build.fsxfake.cmdfake.shが作成されます。

fake.cmdWindows用のビルドスクリプトで、fake.shMac/Linux用です。

で、もっぱらMake処理を書く先はbuild.fsxで、中身は普通のF#コードです。こんな感じになっています。

#load ".fake/build.fsx/intellisense.fsx"
open Fake.Core
open Fake.DotNet
open Fake.IO
open Fake.IO.FileSystemOperators
open Fake.IO.Globbing.Operators
open Fake.Core.TargetOperators

Target.create "Clean" (fun _ ->
    !! "src/**/bin"
    ++ "src/**/obj"
    |> Shell.cleanDirs 
)

Target.create "Build" (fun _ ->
    !! "src/**/*.*proj"
    |> Seq.iter (DotNet.build id)
)

Target.create "All" ignore

"Clean"
  ==> "Build"
  ==> "All"

Target.runOrDefault "All"

Targetの作り方

Target.create関数を使います。

Target.create "Build" (fun _ ->
  Trace.log "--- Building the app ---"
  DotNet.build (Path.Combine ("src", "demo"))
)

https://gist.github.com/KarandikarMihir/7776c887cd73364c5cfc279f8c270934#file-build-fsx

DotNet.buildはFAKEが提供するビルド用のモジュールで、ようするにdotnet buildというコマンドラインの処理をしてくれます。

依存関係の書き方

open Fake.Core.TargetOperators

"Clean" ==> "Restore" ==> "Build"

https://gist.github.com/KarandikarMihir/1b6903ef77655ed65bd2f6c6c1cd618f#file-build-fsx

上の==>とかは、Fake.Core.TargetOperatorsからインポートされたものです。

で、上のコードの意味するところはBuildしたかったらRestoreしろ、RestoreしたかったらCleanしろ、ということです。

実行される順番を記述したもの、と考えてもいいかもしれませんね。

BuildRestoreに依存し、RestoreCleanに依存している、ということだから、makeで書くとこんな感じでしょうか。

build: restore
       @echo "do build"

restore: clean
       @echo "do restore"

clean:
       @echo "do clean"

FakeのOperators

Fakeには記述を読みやすくするための二項演算子がたくさん定義されています。

見ておいたほうがよさそうなのは、

です。

例えばGlobbingOperatorは検索するファイルの候補のリストを検索して作成するものなんですが、こんなイメージ。

!! x // [x]
++ y // [x; y]
-- y // [x]

!!は引数をIncludeのただ一つの候補として初期化。

++はそのIncludeの候補に追加。

--は削除です。

次にTargetOperatorsは、Makeの依存関係を書くための二項演算子を定義しているのですが、

"Clean" ?=> "Build"

というのは、Buildの前にCleanを実行するけど、Cleanが実行できなくてもBuildを実行しちゃってもいいよ、という緩やかな依存関係を意味します。一方で、

"Clean" ==> "All"

は、Allの処理をするまえにCleanは絶対に実行しなくちゃ駄目だよ、という厳密な依存関係です。

ビルドの仕方

winならfake.cmd buildで、Linxu/Macならfake.sh buildです。

最後に

なんていうか、知らない間にすごい洗練されていたんだなあと実感しました。

プロとアマの小説の特徴を数値化して比較してみたらやっぱり差があったので、それを埋めるための型付き小説記述用言語 TypeNovel を公開した件について

ラノベのタイトルみたいな記事を書く、という夢が叶いました。

github.com

開発に至った動機

以前から、アマチュアの小説はプロに比べると、描写不足な傾向があるのかもしれない、と思っていました。

特に不足がちだと感じるのは「時間」に関する描写です。

季節がわからなかったり、昼か夜か、平日か休日かみたいなことが不明瞭な作品が多い気がします。

しかし印象だけで語ってもアレなので、実際に差があるのかどうかを計測してみました。

計算式は、

時間描写の文の数 * 時間描写分布のエントロピー / 文の数

です。

「時間描写分布のエントロピー」というのは「全体を通じて、どれだけ満遍なく時間表現が書かれているか」という数字だと思ってください。

例えば時間描写が冒頭部にしかなかったりすると数値が小さくなり、全編を通じて満遍なく描写されていると、数値が大きくなります。

あと時間描写というのは、一応「季節、曜日、朝昼晩、平日・休日」みたいなことがわかる表現のことを指しています(例:初春、休日、早朝、深夜、夏休み、など)

例として、以下に夏目漱石「門」の計測結果を示します。

histgram: [30, 24, 33, 21, 29, 29, 30, 24, 24, 17]
score: 0.221913(sentence_size = 3879, total = 261, entropy = 3.298082)

histgramの部分は、全編を10分割して、それぞれの区間の時間表現の個数を記したものです。

夏目漱石の「門」は、わりと満遍なく描写されているのがわかると思います。

時間表現の個数は、形態素解析した名詞や形容詞の組み合わせから、時間表現か否かを判定するスクリプトを作って計測しました。

中身は「春」とか「夏」とか、「暗い」+「空」とか、そういう時間表現っぽい文言をいっぱい定義して、どれかに合致するかどうかを判定しただけのものです。

一応ソースです。

https://github.com/tategakibunko/time-heatmap

さて、実際に上記の計算式で計算すると、拙作サイトである縦書き文庫にてランダムに選んだアマチュアさんの平均スコアは、だいたい0.1に届かないことがわかりました。

user1: average score:0.055084
user2: average score:0.057143
user3: average score:0.020505
user4: average score:0.071971
user5: average score:0.045247
user6: average score:0.042603
user7: average score:0.086316
user8: average score:0.051926
user9: average score:0.089533

しかしプロ(文豪)の平均スコアを計算すると、だいたい0.1を超えています。

pro1: average score:0.120076, アーサー・コナン・ドイル
pro2: average score:0.210235, チェーホフ
pro3: average score:0.124371, ジェイムズ・ジョイス
pro4: average score:0.101132, ドストエフスキー
pro5: average score:0.138546, 野村胡堂
pro6: average score:0.153463, 森鴎外
pro7: average score:0.148902, クリスチャン・アンデルセン
pro8: average score:0.110636, 夏目漱石
pro9: average score:0.118976, 中島敦

重要なのは、これが「平均値」である、という点です。

個々の作品では0.1を超える作品を書いているユーザーさんもいるのですが「全作品の平均」を取ると、だいたいみんなスコアが低くなります。

上に挙げた文豪さんたちは、一般的にアマチュアさんよりも多い数の小説を出しているので、平均をとったら不利になりそうなものですが、実際はより作品数の少ないアマチュアさんよりもスコアが高くなっています。

以上の結果から、どうやらアマとプロで、少なからぬ差がありそうな雰囲気は確認できました。

というわけで、あとはこうした差を埋めるためのツールを作るだけ…ということで開発したのが、表題の型付きの小説記述用言語「TypeNovel」です。

型付き小説記述用言語「TypeNovel」とは

まず小説における「型」とはなんでしょうか?

自分は「制約(constraint)」と「注釈(annotation)」の組み合わせである、と定義しました。

小説の各シーンに「制約」を与え、作者はその制約を本文で「注釈」する。しなければ型エラー。そういう塩梅です。

見てもらったほうが早いかもしれません。

@scene({
  season:"春",
  time:"午前"
}){
  $time("朝の通勤時間")、電車の窓から、隅田川の$season("桜")が見えた。
}

上の例では、シーンが満たす「制約」として

  1. 季節(season)は「春」である。
  2. 時刻(time)は「午前」である。

と設定しています。

なので本文では、この制約を明示的に満たすように注釈(annotation)することが求められます(しないとコンパイル時にエラー)。

例えば$time("朝の通学時間")という箇所は、time制約の「午前」を「朝の通学時間」と描写することで満たそうとしています。

同様に$season("桜")も、season制約の「春」を「」と描写することで満たそうとしています。

こういう風に「各シーンに課した制約を注釈しないとコンパイルエラー」とすることで、書き手は自分が何を書かなければならないのかについて、常に自覚的になることを強制されます。

ちなみに制約は別に時間に限らず、何を設定するのも作者の自由です。例えばplace:"学校", person:"花子"とか。シーンの設計図を事前に自覚的に書く、というのが重要なところです。

先の時間描写が不足している件ですが、おそらくアマチュアの作者は、自分の頭の中では「午前か午後か」「平日か休日か」「春か夏か」などを、ちゃんと思い浮かべていることが多いのではないかと思います。

でもそれは、書かなければ第三者には伝わりません。

だから各シーンに明示的に「客観的な事実」を宣言するというお作法を強要し、かつそれを説明しないとコンパイルが通らない、というアプローチをとり、作者の客観的な自覚を促そうとしているわけです。

プログラマもよく、色々なチョンボをやらかして、コンパイラに叱られます。煩わしいけど、それによって正確なプログラムが書けるようになるわけです。

しかし小説って、基本的には誰の力も借りずに黙々と書くものじゃないですか(違ったらすみません)。

だとしたら、こういう方法でコンピューターを「機械編集者」のように利用するというのは、プログラムの質を上げるのと同じように、作品の品質を上げ得るのではないでしょうか。

作者とプラットフォームのWinWin

実はこの仕組みを導入することは、作者とプラットフォーム運営者(出版社や投稿サイト)の双方に利益があります。

作者はこの言語を使うことで、作品の質を上げることができますが、同時にプラットフォーム側も「注釈付きのHTML」を入稿してもらうことで「自然言語処理」がしやすい原稿データを取得できるのです。

例えば次のようなマークアップ

@scene({season:"春"}){
  $season("桜")が満開だ。
}

次のようなHTMLを出力します(ちなみに出力HTMLのタグや属性についてはtnconfig.jsonという設定ファイルで設定できます)。

<scene data-season="春">
  <season></season>が満開だ。
</scene>

これは自然言語処理にとっては有利です。

例えば生のテキストで「桜が満開だ」という情報から「春」という情報を機械的に取得するのは、なかなか面倒なことです。

我々がよく知る小説原稿のテキストは、あまりに形式として素朴すぎるため、自然言語処理と相性がよくないのです。

しかし出力結果が上記のようなマークアップなら、最初から作者がさまざまな情報を注釈してくれているのですから、格段に仕事が簡単になります。なにより、質の良い機械学習用のデータとしても利活用できそうではありませんか。

世の中の検索エンジンは、HTMLに意味情報を含ませることで(セマンティック・ウェブ)、検索精度を向上させてきました。

なので、小説のように「素朴な形式」で書かれる媒体も、時代とともに意味情報を含んだ形式(セマンティック・ノベル)に進化していくのではないかなあと思っています。

後述しますが、この「セマンティック・ノベル」については、個人的に仕様を模索している最中です。

インストール(2019/10/14日追加)

npmからインストールできます。

npm install -g typenovel

コンパイラの使い方

色々なオプションがありますが、基本的には次のような感じで大丈夫でしょう。

[foo@localhost] tnc mynovel.tn

上のようにすると標準出力にHTMLが出力されます。

細かいオプションについては、

[foo@localhost] tnc --help

で確認できます。

出力HTMLのタグを変えるには

詳しくはドキュメントを読んでもらうこととして、簡単に言うとtnconfig.jsonというファイルを編集することで、出力するHTMLタグを自由に編集することができます。

tnconfig.jsonというのは、もちろんTypescriptのtsconfig.jsonを真似したものです。

最初はそんなファイルはないので、コマンドラインから次のコマンドで作成します。

[foo@localhost] tnc --init

こうすると、現在のディレクトリに初期設定で書かれたtnconfig.jsonが出力されます。

このファイルのmarkupMapという欄を編集すると、色々とタグを変えることができます。

例えば@sceneというタグを<scene>じゃなくて、<div class='scene'>にしたかったら、次のようにします。

{
  "markupMap": {
    "@scene": {
      "tagName": "div",
      "className": "<name>"
    }
  }
}

上記で<name>とありますが、これはプレースホルダーといって、@sceneタグに使うと、sceneという文字列に変更されます。その他にも<arg1>, <arg2> ... とか、<nth>, <nthOfType>, <index>, <indexOfType>とか色々なプレースホルダーがあります。

詳しくはCheatsheetのページで確認してください。

開発言語:F#

開発言語なのですが、今回は実行ファイル(コンパイラ)を配布するのがメインなので、Win/Mac/LinuxにクロスコンパイルできるF#を採用しました。

F#はちょっとマイナーな言語?なのかもしれませんが、OCamlとよく似て非常に言語処理系が書きやすい言語です。

久しぶりに触ってみたら、色々な周辺ツールがよく整備されていて驚きました。

.NETの資産も活用できるので、生産性も高いし、パッケージマネージャーNugetも使いやすくていい感じです。

(2019/10/14 Update)

TypeNovelをTypeScriptで書き直しました

今後の展望

TypeNovelと並行して、SemanticNovelという仕様と、そのビューアーについて構想中です。

SemanticNovelのビューアーは、例えば

  1. シーンの切り替わるタイミングで改ページが入る。
  2. シーンの時刻に応じてビューアーのテーマが変わる(夜ならダークモードとか)。
  3. 各セリフが誰によるものなのかを明示する補助UI

などなど、作品の「文脈情報」を生かし、読者に親切な読書環境を提供することを目指して開発しています。

ただしその前に、SemanticNovel の仕様をきっちり作っておかねばなりません。ちょっと時間がかかるかもです。

で、いずれ縦書き文庫にも導入できたらなあと妄想しています。

追記(2019/07/27)

追記2(2019/11/09)

https://raw.githubusercontent.com/tategakibunko/vscode-typenovel/master/images/capture.gif

最後に

TypeNovelリポジトリオープンソースで公開されています。興味のある方はお気軽にご参加ください。

https://github.com/tategakibunko/TypeNovel

SemanticNovelについても、一応Githubに場所を用意しておきました。ぼちぼちと更新していく予定です。

https://github.com/tategakibunko/SemanticNovel