anti scroll

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

先頭ページに「登場人物の一覧」が表示されるようになりました

作品に登場人物を登録した場合、先頭ページに「登場人物の一覧」が表示されるようになりました。

f:id:convertical:20200705102918p:plain
登場人物の一覧

市販の書籍でも、だいたいこんな感じで冒頭に表示されてますよね。

登場人物の挿絵については、小説本文の下側に「登場人物」のタブメニューがあって、そこをクリックすると挿絵付きで一覧が表示されます。

f:id:convertical:20200705102945p:plain
挿絵付きの登場人物一覧

人物をクリックすると、こんな感じで詳細情報が表示されます。

f:id:convertical:20200705103223p:plain
人物の詳細

jingoo 1.4.0 をリリースしました

jingoo 1.4.0をリリースしました。

github.com

新しくなった点

  • Jg_templateモジュールを部分的に改良したJg_template2というモジュールが追加されました。

サンプル

これまでJg_templateモジュールは、次のようにmodels連想リストを与えていました。

open Jingoo
open Jg_types

let result = Jg_template.from_string "{{ msg }}" ~models:[
 ("msg", Tstr "hello, world!");
]

Jg_template2モジュールからは、models名前を与えたら値を返す関数を渡します。

open Jingoo
open Jg_types

let alist = [("msg", Tstr "hello, world!")]
let result = Jg_template2.from_string "{{ msg }}" ~models:(fun key ->
  try List.assoc key alist with Not_found -> Tnull
)

これの何が良いのかというと、string -> tvalueという型の関数にさえなっていれば、データソースは何でも良くなることです。

例えばデータソースを(連想配列より高速な)Hashtblにすることもできます。

open Jingoo
open Jg_types

let htbl = Hashtbl.create 1 in
let () = Hashtbl.add htbl "msg" (Tstr "hello, world!") in
let result = Jg_template2.from_string "{{ msg }}" ~models:(fun key ->
  try Hashtbl.find htbl key with Not_found -> Tnull
)

データソースなしで、単なる関数にしてしまうと、さらに高速です。

open Jingoo
open Jg_types

let result = Jg_template2.from_string "{{ msg }}" ~models:(function
 | "msg" -> Tstr "hello, world!"
 | _ -> Tnull
)

関数にすることで、乱数値みたいなものを扱うこともできます。

open Jingoo
open Jg_types

let result = Jg_template2.from_string "{{ msg }}, {{ rand }}" ~models:(function
 | "msg" -> Tstr "hello, world!"
 | "rand" -> Tint (Random.int 100) (* 0 ~ 100 の値をランダムに返す *)
 | _ -> Tnull
)

将来的にはこっちをメインのAPIとして使用したいのですが、旧版のAPIで既に広く使用されてしまっているので、今回はモジュールを別名で分けることで対応しました。

参考

In Jingoo.Jg_template, provide a function for the getting variables · Issue #112 · tategakibunko/jingoo · GitHub

シリーズ作品において続きの作品へのリンクが本文の最後に表示されるようになりました

Togetterの次の記事に「探す作業が嫌だ」みたいなことが書かれていて「そりゃごもっとも…」と思ったので、表題の件を実装しました。

少年ジャンプ+副編集長が大学1年生から「漫画アプリのUIについて物申したい!」というDMが来て実際に会って考えが整理されて意義深い時間になったという話 - Togetter

これまでは、シリーズを設定している作品について、続きを読む際に、いったんシリーズ作品のリンクへ飛んでから、自分で続きを探さないといけませんでした。

これからは続きがある場合は、本文の最後にリンクが表示されます。

f:id:convertical:20200615125638p:plain
続きがある場合は、リンクを表示する

当たり前にそうあるべきことが、これまで出来ていなかったことが、ちょっと恥ずかしいです。

nehan7をリリース

nehan6からnehan7にバージョンアップしました。

インストール

npm install --save nehan

変更点

  • 組版速度が約20%向上しました。
  • コードサイズが約10%削減されました。
  • nehan.cssが不要になりました。
  • 行内に置換要素、画像、ルビ、圏点傍点、複数サイズの文字などを同時に含むような場合でも、ベースラインが正確に計算されるようになりました。
  • テーブルセルに対してvertical-align: middleが効くようになりました。
  • いくつかのパターンにおける文字詰めの表示崩れを修正しました。
  • 行末揃えの処理を指定しても組版速度が落ちなくなりました。
  • PageReaderは廃止されました。今後はPagedHtmlDocumentを使用してください(後述)。

縦書き文庫のビューアーの刷新

縦書き文庫のビューアーも、新バージョンのエンジンに差し替えました。

nehanのコードサイズが減ったぶん、ページの読み込みも(少しだけ)速くなったと思います。

500KByteほど削減されているので、モバイル環境なんかでは、そこそこ効果が大きいのではないでしょうか。

UIについても、ちょっとだけ刷新しました。

これまでは「本文」と「目次・登場人物」の2列で表示していたのですが、今後は本文を一列(ワンカラム)で表示するようにして、「目次・登場人物」は本文下のメニューに移動させました。

これにより、本文は表示領域が広くなり見やすくなった一方、目次や登場人物に対する操作性は悪くなってしまったので、これに関しては今後の課題としておきます。

(本文の横に目次やら人物やらのタブを表示したらいいのかなあ、と考えています)

nehan7のざっくりとした使い方(開発者向け)

だいたいこんな感じで使います。

import { PagedHtmlDocument, CssStyleSheet } from 'nehan';

const src = "<h1>hello, nehan!</h1>";
const doc = new PagedHtmlDocument(src, {
  styleSheets: [
    new CssStyleSheet({
      "body": {
        "writing-mode": "horizontal-tb", // or "vertical-rl"
        // インライン方向のサイズ(横書きならwidth、縦書きならheightに相当)
        "measure": "450px", 
        // ブロック方向のサイズ(横書きならheight、縦書きならwidthに相当)
        "extent": "500px",
        "font-size": "16px",
        "padding": "1em"
      }
    })
  ]
});
const $dst = document.querySelector("#dst"); // 結果を格納するDOM

// 描画を開始(非同期処理)
doc.render({
  onPage: (ctx){
    const page = ctx.caller.getPage(ctx.page.index); // ページを評価
    $dst.appendChild(page.dom); // 評価したページのDOMを注入
    $dst.appendChild(document.createElement("hr")); // 一応、分割線
  },
  onComplete: (ctx){
    console.log(`終わりました(${ctx.time}msec)`);
  }
});

注意事項1(論理プロパティについて)

nehanは論理組版エンジンなので、領域サイズの指定にwidthとかheightなどは使えません。

代わりにmeasureとかextentという論理プロパティで指定します。

measureは横書き(writing-mode:"horizontal-tb")ではwidthのことです。

縦書き(writing-mode:"vertical-rl")ならheightになります。

extentは横書き(writing-mode:"horizontal-tb")ではheightのことです。

縦書き(writing-mode:"vertical-rl")ならwidthになります。

同じく、top, right, bottom, leftといった名前は、nehanではそれぞれbefore, end, after, startと指定します。

例えば横書きのmargin-leftは、nehanではmargin-startです。

before/afterはブロック方向の前後で、start/endはインライン方向の前後、と覚えてください。

注意事項2(段組とページ送りについて)

上のサンプルでは、appendChildを使って、組版結果をどんどん追記する「段組み方式」で表示していますが、ページ送りで表示したいときは、自前で現在表示するページやページ番号などを管理する必要があります。

その場合、起動時には先頭ページだけを表示し、それ以降のページはページ送りをしたタイミングでdoc.getPageを使って動的に対象ページを取得し、画面上のDOMを差し替える、という実装になるでしょう。

ページ送りの場合の実装については、サンプルのbook.tsなどが参考になるかもしれません。

Angular(<=8)でnehanを使っている方への注意

結論だけ先に述べておきます。

  • Angular8以下では、nehan(<=6.0.38)までしか使えません。
  • ただしTypescript3.6.3以降を使える環境では、なんの問題もなくnehan(>=6.0.40)を使用できます。

以下に理由を書きますが、非常にしょうもないことです。

package.jsonのミス

nehan(<=6.0.38)においては、package.jsontypesという属性の値が./dist/indx.d.tsとなっていました。

しかし実はこれ正しくは./dist/index.d.tsと書かねばならなかったのです(eの字が抜けていた)。

- "types": "./dist/indx.d.ts",  // version 6.0.38
+ "types": "./dist/index.d.ts", // version 6.0.40

先に「Angular8以下では、nehan6.0.38を使ってください」と書きましたが、つまりそれは、このバグったpackage.jsonを使ったversion6.0.38を使ってください、ということです。なぜか?

理由は「そのバグがあるために、Typescript3.5以下でもエラーが出ないから」です。つまりバグのある古いバージョンを使うことで、Typescript3.5以下しか使えないAngular(<=8)でバグがでない、ということです。

実はnehanは各所にgetterと言われる機能を使っているのですが、実はこれ、Typescript3.6.3以降じゃないと次のようなエラーが出て、正しく使えなかった機能だったのです。

error TS1086: An accessor cannot be declared in an ambient context.

でもnehan6.0.38は正しいindex.d.tsが読み込めていない状態だったので、幸運にも? Typescript(<=3.5)でもこのエラーが出なかったのです!

というわけで、Typescript3.5.3までしか使えないAngular8では、nehan(>=6.0.40)を使用できません…

ちなみにAngular9になると、Typescript3.6系列がサポートされるらしいので、そこからはnehanを6.0.40にアップデートできます。

TypeNovelのVSCode拡張を公開しました

VSCodeのマーケットプレースにvscode-typenovelというTypeNovel用のVisual Studio Code拡張を公開しました。

marketplace.visualstudio.com

インストール後は、*.tnファイルを編集するときに有効になります。

実際に動かすと、こんな感じです。

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

主な機能

  • ブロック(@で始まる)と注釈($で始まる)のインテリセンス
  • 組み込みマークアップの引数説明を表示($rubyとか@notesとか)
  • 文法のハイライトや、括弧を自動で閉じる設定
  • セーブ時にエラーをチェック(注釈されていない制約、未定義の制約、重複した制約など)

ちなみに$で起動するインテリセンスは、もちろん組み込みタグだけではなく、ブロックで定義した制約の一覧も表示します。

連絡先

バグや機能要望はGithubにて受け付けています。

github.com

余談(開発でハマったところ)

1 .vscodeignoreファイルの挙動

vsce packageコマンドで拡張機能をパッケージ化するときに、.vscodeignoreファイルでパッケージに含めないファイルを定義できるのですが、このファイルに.gitignoreを入れると、.gitignoreの中身を勝手に見て、その中に記述されたファイルもパッケージの対象外にしてしまうみたいです。

つまり.gitignoreの中に「gitの管理は不要だが、vs拡張のパッケージには必要」なファイルを記述してしまうと、それらがvs拡張にパッケージされず、拡張機能が動かないことがあります。

これを回避するのは簡単で、ようするに.vscodeignore.gitignoreを記述しなければよいのです(幸い.gitignoreは、.vscodeignoreに記述しなくてもパッケージ対象にはならないので)。

ちなみにパッケージされるファイルを事前に確認したい場合は、vsce packageをする前にvsce lsとすると、パッケージされるファイル一覧を事前に確認することができます。

2 ローカルの.vsixファイルをVSCodeのUIから直インストール・アンインストールした際の挙動

ローカルでインストールした.vsixファイルを、VSCodeのUIからアンインストールした場合、.vscode/extensions/<拡張機能のフォルダ>が削除されないことがあります。

で、わかりにくいことに、この状態でVSCodeのUIから新しい.vsixファイルを読み込ませて上書きインストールさせようとすると、インストールは成功と表示されるのですが、実際には何も新しいファイルが展開されず、古いディレクトリだけが残っている状態になってしまいます。

つまりバグを修正して再インストールしても、修正されていない古い拡張が残り続けるので、まったく修正されていない状態になっているわけです。

この状態について、最初は「修正が正しくない」と判断して、ハマってしまいました。そこから「実は再インストール自体がなされておらず、古い拡張が残り続けているだけ」と気付くのに小一時間ぐらいかかりました。

ようするに「ローカルでインストール・アンインストールのテストするときは、UIを介さずに手動で拡張機能ディレクトリを削除する必要がある」ということになります。

jingoo v1.3.1をリリース

久しぶりになりますが、jingooのv1.3.1をリリースしました。

Release v1.3.1 · tategakibunko/jingoo · GitHub

変更点

  • 演算子として、新たに+=, -=,*=, /=,%= がサポートされました。
  • 匿名関数がサポートされました。
  • 条件分岐の構文にて、elseif だけではなく、else ifを使うこともできるようになりました。
  • macro ~ endmacro構文にて、endmacroの後に、macro名を付け足すことができるようになりました。

匿名関数はこんなふうに使います。

{# 2と表示される #}
{{ ((i) => i + 1)(1) }}

最後のmacro~endmacroについては、こんな風に使います。

{% macro li (content) %}<li>{{content}}</li>{% endmacro %}

{# v1.3.1からは、こういうふうにも書ける #}
{% macro li (content) %}<li>{{content}}</li>{% endmacro li %}

マクロの中身が長くなったときに「あれ、これってなんのマクロの宣言だっけ?」とかならないように、endmacroの部分で、メモ代わりみたいな感覚でマクロ名を付け足すこともできるようになった、ということです。

ただし次のように、宣言したマクロ名と違う名前を付け足すと、構文エラーになります。

{# エラー(マクロ名はliであって、fooではない) #}
{% macro li (content) %}<li>{{content}}</li>{% endmacro foo %}

アプリケーションにTypeNovelのコンパイラを組み込む

この記事は、TypeNovelコンパイラをアプリケーションから利用したい開発者向けのものです。

導入

npm install --save typenovel

コンパイラの呼び出し

ソーステキストからコンパイルするときは、Tnc.fromStringです。

import { Tnc } from 'typenovel';

const result = Tnc.fromString('@scene(){ foo }', {
  format: 'html', // もしくは 'text'
  minify: false // trueだと圧縮表示
});

console.error(result.errors); // エラー
console.log(result.output); // コンパイル結果

ソースファイルからコンパイルするときは、Tnc.fromFileです。

import { Tnc } from 'typenovel';

const result = Tnc.fromFile('sample.tn', {
  format: 'html',
  minify: false
});

console.error(result.errors); // エラー
console.log(result.output); // コンパイル結果

コンパイラを拡張する

Compileクラスを使うと、コンパイルの各段階におけるVisitorを自前のものに置き換えることができます。

例えば、TypeNovelのノードツリー(TnNode)から出力文字列に変換するVisitor(NodeFormatter)を自前のものにするときは、こんな風にします。

import { Compile } from 'typenovel';

const myFormatter = new MyNodeFormatter(); // 自前で実装したFormatter
const result = Compile.fromString('@scene(){ foo }', {
  nodeFormatter: myFormatter // 自前のFormatterを設定
});

console.log(result.output); // コンパイル結果

ここで、MyNodeFormatterは、NodeFormatter interfaceを実装したクラスです。

NodeFormatter interfaceはnode-formatter.tsにて、次のように定義されています。

export interface NodeFormatter {
  visitTextNode: (args: {
    content: string;
    isWhiteSpacePre: boolean;
    isFirstChild: boolean;
    isLastChild: boolean;
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }) => string;

  visitAnnotNode: (args: {
    name: string;
    tagName: string;
    id: string;
    className: string;
    attrs: any;
    content: string;
    selfClosing: boolean;
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }) => string;

  visitBlockNode: (args: {
    name: string;
    tagName: string;
    id: string;
    className: string;
    attrs: any;
    content?: string;
    children: TnNode[];
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }) => string;
}

それぞれの関数の引数に、各ノードの情報が入っています。

ようするに、これらの情報を元にして、テキストノード(TextNode)、注釈タグ(AnnotNode)、制約ブロック(BlockNode)のそれぞれを、なんらかのテキストに変換して返せばよいわけです。

例えば注釈タグではクラス属性やid属性など無視して、タグ名だけあれば十分!などと思うなら、次のようにvisitAnnotNodeを実装すればいいわけです。

class MyNodeFormatter implements NodeFormatter {

  (省略)

  visitAnnotNode: (args: {
    name: string;
    tagName: string;
    id: string;
    className: string;
    attrs: any;
    content: string;
    selfClosing: boolean;
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }){
    return `<${args.tagName}>${args.content}</${args.tagName}>`;
  }
}

拡張できるinterfaceはノードから出力テキストの変換処理だけではありません。

次の部分をユーザーが自由に拡張できます。

TypeNovelParser(String -> Ast)

TypeNovelParserは、TypeNovelのソースからAstを作成するインターフェイスです。

AstMapper(Ast -> Ast')

AstMapperは、Astを別のAstに入れ替えるインターフェイスです。

AstConverter(Ast -> TnNode)

AstConverterは、AstTnNodeに変換するインターフェイスです。

NodeMapper(TnNode -> TnNode')

NodeMapperは、TnNodeを別のTnNodeに入れ替えるインターフェイスです。

NodeValidator(TnNode -> ValidationError[])

NodeValidatorは、TnNodeを検査してValidationErrorを出力するインターフェイスです。

NodeFormatter(TnNode -> String)

NodeFormatterは、TnNodeから出力テキストを生成するインターフェイスです。

拡張例

例えばTypeNovelにおいて、別のTypeNovelソースを展開する構文である

$include('別ソース')

などは、Ast -> Ast'な処理なので、IncludeExpanderクラスとしてAstMapperインターフェイスで実装されています。

ast-mapper.ts

あるいは、ノードから無駄なホワイトスペースを削除するNodeWhitespaceCleanerクラスは、TnNode -> TnNode' な処理なので、NodeMapperインターフェイスで実装されています。

node-mapper.ts

こうやって、コンパイラの各段階を自分好みのものに置き換えることで、各自でコンパイラが拡張できるような作りになっています。