読者です 読者をやめる 読者になる 読者になる

nehan.jsのセレクターマッチング処理を高速化

nehan.jsのセレクタのマッチング処理を高速化しました。

先日サポートした行末揃えは、有効にすると20%ほど遅くなりましたが、今回の修正によって15%ほど速くできたので、少し戻すことができました。

前々から、機会があったらやっておこうと思っていた処理でもあったし、ちゃんと効果があって良かったです。

最適化の内容

やっていることは単純で、セレクタのマッチング結果を(キャッシュできるものに限り)キャッシュしただけです。

ただしマッチ結果には、キャッシュしやすいものとしにくいものがあるので、その辺のことについてちょっと説明します。

属性セレクタのマッチング結果はキャッシュが困難

属性セレクタは、マークアップや親が同じでも、マークアップが現れる相対的な文脈によってマッチしたりしなかったりするので、常に同じ値が返ってくるとは限りません。

これに対応するためには、属性クラスの情報をキャッシュキーに盛り込む必要があります。

しかし例えば次のようなマークアップを考えてください。

<div>
  <p class="foo">text0</p>
  <p class="foo">text1</p>
  <p class="foo">text2</p>
</div>

<div>
  <p class="foo">text3</p>
  <p class="foo">text4</p>
  <p class="foo">text5</p>
  <p class="foo">text6</p>
</div>

ここでtext2text6を内容に含むpタグをそれぞれp2p6と名付けることにします。

さて、p2p6のキャッシュキーはどうなるでしょうか。

両者の特徴をフルに含んだキャッシュキーを設計するとなれば(擬似的な記述ですが)こんなかんじになりそうです。

// p2のキャッシュキー
div>p.foo[nth-child=2, first-of-type=false, last-child=true, last-of-type=true, ...]

// p6のキャッシュキー
div>p.foo[nth-child=3, first-of-type=false, last-child=true, last-of-type=true, ...]

殆ど同じキャッシュキーですが、nth-childの値だけが異なります。

しかし両者は木構造の位置として、性質的には殆ど同じものです(divの直下にあるpタグの最後の要素)。

なので、p2p6のどちらも、次のセレクタにマッチします。

div>p.foo:last-child{ font-size:2em }

しかし、nth-childの値が違うだけで、違うキャッシュキーとなるわけです。

これを同じキャッシュキーにするわけにはいきません。なぜなら属性クラスはnth-childを使っている「かも」しれないからです。 属性クラスをキャッシュして(他の場所で)再利用するなら、確実に属性クラスが表現しうる全ての特徴が一致している、という保証が必要なのです。

というわけで、仮にp2のマッチ結果をキャッシュしたところで、同じ結果がp6のマッチング時に再利用されることはありません。

キャッシュが効果的なのは、どれだけ再利用されるかにかかっているのですが、こうやってキャッシュが分散すると、ヒット率が落ちる上に容量も食うわけで、かえって性能を損ねるおそれが出てきます。

というわけで、キャッシュキーが分散しがちな属性セレクタについては、都度マッチする方針で統一しました。

ただし、属性クラスを含むセレクタは別の領域に保存しておくなどして、前もって検索範囲を狭めておく、というようなことはしています。

キャッシュできる属性クラスのセレクタを書く方法

しかし、上記のようなp2p6で、同じマッチ結果がキャッシュされるようなセレクタが書きたい! と思いませんか?

nehan.jsならこれができます。

nehan.jsでは、cssプロパティに関数値を設定できるので、先ほどと同じ場所(div>p.foo:last-child)を、属性クラスを使わずに指定することがでるのです。

例えば以下のように、属性クラスの判定を遅延評価の関数に追い出すことで、セレクタの詳細度が減った「キャッシュヒット率の高い」セレクタを宣言することができます。

"div>p.foo":{
  onload:function(ctx){
    if(ctx.isLastChild()){ // is last-child?
      return {fontSize:"2em"};
    }
    return null;
  }
}

大まかなものでマッチさせておくことでセレクタのマッチング結果をキャッシュさせ、last-childのような詳細で決まるスタイルについては、遅延評価で取得させるわけです。

一見すると、スタイル値の取得に際し関数呼び出しのオーバーヘッドが発生するように見えますが、これは属性クラスの条件判定をセレクタ検索時に計算するか、検索後に計算するかの差でしか無いので、性能差には結びつきません。

属性クラスバージョンと遅延評価バージョンの性能差

とはいえ、セレクタ数が少ないケースでは、どちらの書き方をしても、たいして性能の差はありませんでした。

字数が40万字、セレクタ数が5000個ぐらいのラインを超えると、さすがにキャッシュの効く遅延評価バージョンの方が22%ぐらい速かったですが…

マージ後のスタイル値はキャッシュ出来ないのか

マッチしたセレクタは、specificityの順にソートし、最終的なスタイル値へとマージされます。

この最終的なスタイル値をキャッシュできると更に良いのは言うまでもないことですが、nehan.jsではcssの値に関数を指定できる仕様がある関係上、これが難しくなっています。

関数値で指定された値は実行時に値が決まるサンクなので、ある特定のタイミングで呼ばれて返ってきた結果を、永続的な最終値としてキャッシュすることはできないからです。

そもそも仮にキャッシュしたとしても、スタイル構築で時間がかかるのは圧倒的にセレクタ検索の部分なので、大した効果は期待できないと思われます。

text-align:justify に対応

nehan.jsが、text-align:justifyに対応しました。

縦書き文庫では既に、ビューアーのbodyに対してjustifyが設定されています。

これを設定すると、例えば行末に赤い部分のような微妙なスペースがあっても…

f:id:convertical:20150720170302p:plain

揃えた後はこんな風に、行末が揃います。

f:id:convertical:20150720170313p:plain

あらゆるケースで完璧に揃うわけではありませんが…まあ大抵は揃うようになっています。

ちなみにjustifyを指定すると、少し変換が遅くなります。

自分の環境では20%ほど遅くなりました。

それだけのコストを払った結果、たまに行末がちょろっと揃うだけという状況をどう考えるか…というところなのですけど。

実のところ、自分は今回の実装をするまでずっとjustifyのことを、禁則処理って意味だと勘違いしていたぐらい意識が低いので、割りと「どっちでもいいかなあ」派です。

そもそも横文字が入らない小説だと、なにもしなくても大抵は揃っちゃうんですよね。

ちなみに禁則のことは、英語で(japanese) hyphenationというのだそうです。

余談

均等揃えとか行末揃えについては、日本語組版処理の要件を、ちょろっと斜め読みした程度ですが、ざっくりいうと

  1. まずベタ組を保つことが最優先
  2. その上で、まず削れるところを削り、次に空ける

みたいなことらしいです。

ただ自分の感覚では、カッコや句点読点の前後にあるスペースが狭くなるのは好みじゃなかったので、そのケースでは削らないようにしました。

nehan.jsが複数ページに渡る論理的な回りこみ処理(論理フロート)の解除に対応しました。

リポジトリ上のnehan.jsで、論理的な回りこみ処理(論理フロート)の解除を実装しました。

回り込みを解除するスタイル設定ですが、通常のcssでは、

.clear-left{ clear:left }
.clear-right{ clear:right }
.clear-both{ clear:both }

などと書くところですが、nehan.jsでは

.clear-start{ clear:start }
.clear-end{ clear:end }
.clear-both{ clear:both }

のように、縦書き横書きに依存しない論理方向でクリアを指定します。

leftstartに、rightendに対応している点に注意して下さい。

サンプル

例えば「あいうえお…」から始まる文字列で、回りこみを解除することを考えます。

何も解除しないとこうなるものとします。

f:id:convertical:20150718172002p:plain

ここから「あいうえお…」の文字列をclear:endすると、こうなります。

f:id:convertical:20150718172055p:plain

ここから「あいうえお…」の文字列をclear:startもしくはclear:bothすると、こうなります。

f:id:convertical:20150718172113p:plain

start方向の回り込み要素のほうが大きいので、こっちを解除すると結果的にbothと同じ結果になります。

縦書き横書きの自動リサイズに対応したページ送りのビューアー「responsivook」

縦書き横書きの自動リサイズに対応したページ送りのビューアーを公開しました。

github.com

ブログやホームページに簡単に埋め込むことができます(たぶん)。

デモ

デモページを用意しました。

» デモページを見る

上のページを開いて、ページをリサイズしてみて下さい。

ビューアーの表示領域が自動で伸縮するのがわかると思います。

スマホページなどでも使いやすいのではないかと。

インストール

必要なソースをheadなどにインクルードします。

nehan.js および nehan.css が必要なので、先にダウンロードしておいて下さい。

nehan.jsdistフォルダ、nehan.csscssフォルダに入ってます。

<!-- stylesheet -->
<link rel="stylesheet" href="/path/to/nehan.css">
<link rel="stylesheet" href="/path/to/responsivook.css">

<!-- scripts -->
<script src="/path/to/nehan.js"></script>
<script src="/path/to/responsivook.js"></script>

使ってみる

例えばページ内の<div class='post-content'> 〜 </div> を縦書きページ送りのビューアーにしたいなら、</body>の直前あたりで、次のように呼び出します。

<script>
  document.addEventListener("DOMContentLoaded", function(event){
    Responsivook.start(".post-content", {
      flow:"tb-rl" // 横書きなら"lr-tb", 左縦書きなら"tb-lr"
    });
  });
</script>

カスタマイズ

以下、色々とカスタマイズのサンプルを紹介します。

ページサイズを固定に

レスポンシブの意味がなくなりますが、どうしても固定サイズが欲しいなら、次のようにします。

Responsivook.start(".post-content", {
  flow:"tb-rl", // 縦書き
  fontSize:16,
  width:500,
  height:400
});

ただし、スマホタブレットを含む色々なデバイスで読まれることを想定するなら、ページサイズや文字サイズは何も設定しない(自動で計算させる)ほうが良いと思います。

ボタンの色とかラベルとか

たぶんこれが最も使われるオプションじゃないかなと思います。

ボタン色に指定できるのは、sea, dark-blue, red, rouge, dark, orange, sunflower, concrete です。

色の値を指定するのではなくて、用意されたテーマ名の中から選択する形になります。

Responsivook.start(".post-content", {
  flow:"tb-rl",
  
  // 左右のボタン色の設定
  // 有効な値:'sea', 'dark-blue', 'red', 'rouge', 'dark', 'orange', 'sunflower', 'concrete'
  leftColor:"darkblue",
  rightColor:"red",

  // 左右のボタンのラベル
  leftLabel:"ひだり!",
  rightLabel:"ミギー"
});

文字色、文字サイズ、フォントファミリ、背景色

文字色、背景色などは別途スタイルシートを使って設定しても構いませんが、文字サイズやフォントファミリは、ページ組版の計算に直結する大事な値なので、スタイルシートで設定することはできません。

Responsivook.start(".post-content", {
  flow:"tb-rl",
  fontSize:16,
  fontFamily"Meiryo",
  color:"#666", // 文字色
  backgroundColor:"#ccc"
});

上級者向けのカスタマイズ

以下は少し上級者向けのカスタマイズです。

ページ内のスタイル

オプションのstylesを使うと、セレクタ(下の場合.post-content)に紐付いたビューアーのページ内スタイルを、nehan.js形式で記述できます。

nehan.js形式のスタイルについては、縦書き文庫のヘルプでユーザースタイルの項などが参考になると思います。

Responsivook.start(".post-content", {
  flow:"tb-rl",
  // ページ内スタイル
  styles:{
    "h1.foo":{
      fontSize:"2em",
      margin:{before:"1em", after:"1em"}
    },
    ".important":{
      fontWeight:"bold",
      color:"red"
    }
  }
});

全ビューアーに共通のページ内スタイルを設定する

Responsivook.setStylesを使うと、画面上に存在する全ビューアーに共通するページ内スタイルを設定できます。

一つ前のやつは、マッチしたセレクタ(上の場合だと.post-content)にだけ有効なスタイルになりますが、こちらで指定すると、別のセレクタで起動したビューアーでも有効になります。

全てのビューアーで共通のスタイルを使うなら、こちらを使ったほうが効率的でしょう。

ただし、この関数は、Responsivook.start前に呼ぶ必要があることに注意して下さい。

// 先にセットしておく
Responsivook.setStyles({
  ".paragraph":{
    fontSize:"0.9em",
    margin:{after:"1.5em"}
  }
});

Responsivook.start(".post-content", {
  flow:"tb-rl"
});

表示完了後のコールバックとか

スマホなどでは、HTMLのBUTTONタグに対する反応が、300msほど遅くなることはよく知られていることです。

これはタッチとプッシュを区別するための意図的な仕様なのですが、ビューアーに関する限り、触ったらすぐに次ページへと進みたいものです。

というわけで、これを素早くさせるためには、表示完了後に呼ばれるonCompleteをフックして、クリックイベントをタッチイベントにする処理を埋め込むと良いでしょう。

下の処理では、fastclick.jsを利用しています。

Responsivook.start(".post-content", {
  flow:"tb-rl",
  onComplete:function(){
    FastClick.attach(document.body);
  }
});

圏点・傍点用の自前タグを定義

傍点タグをspanで打つのが面倒な人は、自前のタグを登録してしまいましょう。

以下のようにしておくと、sesameとか、dotという傍点のタグを定義できます。

Responsivook.setStyles({
  dot:{
    display:"inline",
    textEmphasisStyle:"filled dot"
  },
  sesame:{
    display:"inline",
    textEmphasisStyle:"filled sesame"
  }
});

Responsivook.start(".post-content", {
  flow:"tb-rl"
});

こうしておけば

ここに<sesame>ゴマ点</sesame>、ここに<dot>ドット</dot>

マークアップできます。

総括

responsivook.jsは、僅か200行ちょっとの軽量なスクリプトです。

依存ライブラリもnehan.jsだけなので、スマホサイトでも扱いやすいのではないでしょうか。

Chrome Extensionのmanifest.json内version値を更新するスクリプト

手作業でやるのが面倒になってきたので書きました。

事前にnpm install minimistが必要です。

// update-manifest-version.js

var fs = require("fs");
var args = require("minimist")(process.argv.slice(2));

var update_version = function(version, target){
  var parts = version.split(".");
  var map = {major:0, minor:1, build:2, revision:3};
  var index = map[target] || 2;
  parts[index] = parseInt(parts[index]) + 1;
  return parts.join(".");
};

var file = args.f || "./manifest.json";
var target = args.t || "build";
var encoding = args.e || "utf8";
var json = JSON.parse(fs.readFileSync(file, encoding));

json.version = update_version(json.version, target);

fs.writeFileSync(file, JSON.stringify(json, null, 2), "utf8");

使い方

例えば今のバージョンが1.2.5(major=1, minor=2, build=5)で、buildバージョンだけ1つ上げたい場合

node update-manifest-version.js -f /path/to/manifest.json -t build -e utf8

とやります。

textNodeの出力オプションを追加

2chからのアクセスに、珍しくリファラが付いてたので、元スレを覗いてみたのですが、縦書き文庫に投稿された作品へのリンクに「踏まないほうがいいよね?」ってレスが付いていて、地味に傷ついたのでした。

capturePageTextオプション

それはさておき、表題の件です。

まだリリースはされていませんが、nehan.jsのリポジトリ上では、 Nehan.PageStreamsetContentや、 Nehan.PageStreamasyncGetという関数のオプションに、capturePageTextという新しいオプションが追加されました。

これを有効にすると、出力されるページに、DOMElementで言うところのtextNode的な文字列が入ってきます。

var pe = Nehan.createPagedElement().setContent("<p>foo</p>", {
  capturePageText:true, // textNodeを出力する
  onProgress:function(tree, ctx){
    console.log(tree.text); // "foo"
  }
});

この機能を使えば、例えばビューアーに検索機能を付けたい場合、filter関数を使って、

var search_pages = function(pe, keyword){
  return pe.filter(function(page){
    return page.text.indexOf(keyword) >= 0;
  });
};

var cool_pages = search_pages(pe, "cool!");

とかやれば、キーワードにマッチするページ配列が取得できるようになります。

Nehan.BoxとNehan.Pageの違い

ちょっとした捕捉ですが、filterが返すのは、Nehan.Pageの配列ではなく、Nehan.Boxの配列であることに注意して下さい。

つまり、上のプログラムにおけるcool_pagesのそれぞれには、elementメンバが存在しません。

console.log(typeof cool_pages[0]); // "Box"
console.log(typeof cool_pages[0].element); // "undefined"

これはなぜかというと、nehan.jsが計算するページは、実際にそのページが画面に表示されるまでは論理レイアウトNehan.Box)として保存されているからです。

nehan.jsでは、画面に表示されるときになって初めて、論理レイアウト(Nehan.Box)がon the flyで実レイアウト(Nehan.Page)に変換されます(これはたかだか1ページぶんの変換なので、一瞬で終わります。もちろん評価が済んだページはキャッシュされるので、再計算も発生しません)。こうやって生DOMの生成を遅延することで、全体のパーススピードを上げているわけです。

まだ表示されていないページを検索結果に含めるために、filter関数の戻り値はNehan.Boxの配列となっています。

余談

最後にnehan.jsの検索性について、ちょっと余談です。

よくnehan.jsのbrタグについて「CTRL+Fの検索が使えない」と言う人がいますが、段組みで表示する場合はその通りとして、ページ送りの組版にとってはどのみちCTRL+Fはあまり意味がないことに留意していただきたく。

例えば、検索ワードが最終ページにヒットするケースを考えて下さい。

そういう場合、いま見ているページをすっ飛ばして、いきなり最終ページにジャンプするわけにはいかないじゃないですか。

だから、ページ送りで表示するドキュメントの場合、どのみち検索機能は別のUIを用意する必要があるわけです。

だからbrはたいして問題じゃないように思うわけですが、どうなんでしょうね?

段落の先頭に自動で空白を入れる

サッカーのページを見ていたら、サイドメニューあった「ストレスと友達になる方法」というリンクが「スアレスと友達になる方法」に見えてしまいました。

ストレスともスアレスとも友達になれそうにない自分ですが、せめて段落の先頭に自動で空白を入れるぐらいのことはできるのではないか…

ということで、以下は「Pタグで表現された段落なら、自動で空白を埋め込む」スタイル設定です。

サンプル1

まずはシンプルに文字を注入する方法です。

{
  p:{
    onload:function(ctx){
      var markup = ctx.getMarkup();
      var content = markup.getContent();
      var head_char = new Nehan.Char(content.substring(0,1));

      // 先頭が全角スペース、カッコ開始、その他の特殊スペースじゃない
      if(!head_c1.isIdeographicSpace() && 
         !head_char.isKakkoStart() && 
         !head_char.isSpace()){
          // 先頭に全角スペースを注入
          markup.setContent(" " + content);
      }
    }
  }
}

よりちゃんと動かすためには「先頭がタグ開始文字だったらスキップして、本当の先頭文字を取りに行く」という処理が必要ですが、ほとんどの場合において、正しく動くでしょう。

サンプル2

ついでに、別のやり方も紹介しておきます。

以下は、pseudo-elementfirst-letterを使う方法です。

{
  "p::first-letter":{
    display:"inline",
    padding:function(ctx){
       var letter = ctx.getMarkupContent().substring(0,1);
       var chr = new Nehan.Char(letter);
       if(!chr.isIdeographicSpace() && !chr.isKakkoStart() && !chr.isSpace()){
         return {start:"1em"};
       }
       return null;
     }
  }
}

こういう自動処理は、サービス側で用意してもいいのですが、ユーザーに制御する自由があると、より小回りがきくと思います。

スタイル編集機能で、プログラマブルな小説投稿

新たにスタイル編集ページを追加しました。

詳しくは縦書き文庫のヘルプから「スタイル編集」のページを確認して下さい。

注意事項

スタイルはcssではなく、JSON形式で宣言します。

{
   ".foo":{
       margin:{
          after:"1em"
       }
    },
   "span.fuga":{
       "font-size":"2em" // fontSizeでもOK
    }
}

afterってのが見慣れないかもしれませんが、これは論理方向です。詳しくはヘルプの解説を見て下さい。

あと、bodyタグの編集はできません

bodyのスタイルは「表示設定」ボタンでのみ変更できるようにしたいからです。

紳士の嗜みとして、ここは一つ「bodyタッチはNG」ということで、ご理解いただければと思います。

ちなみに、このスタイル編集の面白い点は、なんといってもプロパティとして関数が書ける点です。

なので、その辺りのサンプルを2つほど紹介したいと思います。

サンプル1

例えば、縦書きや横書きで文字列を替えたい場合があると思います。

数字を横書きでは半角で、縦書きでは漢数字で、とか。

そういうことをするタグを、例えばこんな風に定義できます。

{
  // 使いかた
  // <replace-if-vert text="一九八四">1984</replace-if-vert>年
  "replace-if-vert":{
    display:"inline",
    onload:function(ctx){
      if(ctx.isTextVertical()){
        var markup = ctx.getMarkup();
        markup.setContent(markup.getAttr("text"));
      }
    }
  }
}

こうやって定義した後に

<replace-if-vert text="一九八四">1984</replace-if-vert>

と書くと、横書きなら「1984年」、縦書きなら漢数字で「一九八四年」と表示されます。

サンプル2

スタイル編集の中で変数を使うにはどうしたらいいでしょうか。

スタイルは、最終的に1つのJSONを返せばいいわけなので、次のように関数に囲ってJSONを返す処理にしてしまえば、変数を使うこともできます。

(function(){
  var colors = ["red", "green", "blue"];
  return {
    ".rgb-list li":{
      color:function(ctx){
        var index = ctx.getChildIndex();
        return colors[index % colors.length];
      }
    }
  };
})()

こうやって定義した後に、

<ul class="rbg-list">
  <li>これは赤</li>
  <li>これは緑</li>
  <li>これは青</li>
</ul>

と書けば、各リストアイテムが「赤」「緑」「青」の順番で色が変わります。

というわけで、使いこなすことができたら、色々と面白いことができるのではないでしょうか。