anti scroll

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

Unicode Hyphen覚え書き

例えばHTMLで「F-15」のような「-」(ハイフン)を含むboxを90度回転して表示させようとすると、ハイフンの後で改行されてしまいます。

f:id:convertical:20151231091605p:plain

なぜこんなことが起きてしまうのでしょうか。

理由

フォントサイズが16と仮定して「F-15」という横書きのwordを囲むサイズが、32x16(横x縦)だとします。

これを縦書き用に90度回転させると、このwordを囲むサイズは、32x16から16x32になります。

このときFの後のハイフンが、回転前の横幅(32)ではなく、回転後の横幅(16)を前提にして解釈されるのが、改行が発生する原因です。

「F-」まで描画したところでインラインの描画位置が、回転後における横幅の限界(すなわち16)に届くのですが、この位置で発生したハイフンが、line-break-pointとブラウザに判断されるため、改行が発生してしまうわけです。

Unicodeの中でline-break-pointと解釈されるハイフンは他にも色々(*注1)ありますが、よく使われるのはHYPHEN MINUS(U+002D)や、HYPHEN(U+2010)です。

*注1 U+002D, U+2010, U+2012, U+2013とかもそう。調べたらもっとあるかも。

というわけで、nehan.js内では英数字を縦書き用に回転させるときだけ、U+002DU+2010(U+2012,U+2013なども)を、あらかじめnon-breaking-hyphen(U+2011)に変換して、この問題を回避しています。

white-space:preでも解決できるかもしれないです。

もちろんこれはベストな解決方法ではありません。

こうしたHyphenの動きをコントロールするため、CSSにはhyphensというプロパティが定義されているのですが、なぜかChromeではまだ未実装(その他のブラウザは殆ど実装されている)なので、仕方なくこういう回避策をとっています。

Nehan Reader version2.1リリース。自動で変換するサイトを指定できるようになりました。

Nehan Reader(version 2.1)をリリースしました。

chrome.google.com

オプションページにて、

  • 自動的にNehan Readerを起動するURL(パターン)
  • URL(パターン)毎の変換対象に指定する部分

などが設定できるようになりました。

ちなみに「小説家になろう」とか「青空文庫」のような、既に使っている人が多そうな対象は、デフォルトで設定しておきました。

なので、version2.1をインストールして「青空文庫」や「小説家になろう」の「作品ページ」にアクセスすると、作品本文を抜き出して縦書きページ送りにしたビューアーが自動的に起動しちゃいます。

捗りますね!

自動的に起動するURLを設定する

Nehan Readerのボタン上で右クリックして「オプション」へ進みます。

f:id:convertical:20151228164600p:plain

automatic convert URLsの欄に、自動変換させるサイトのURLパターンを記述します。

f:id:convertical:20151228164612p:plain

内容はSaveUrlListというボタンを押すと保存されます。

URLパターンの記述の仕方

URLパターンの記述方法は2つあります。

一つは、ダイレクトにサイトのURL(の一部)を記述する方法。

もう一つは、サイトのURLパターン正規表現で記述する方法(上級者向け)です。

以下はサンプルです(#で始まる行はコメント扱い)。

# URLをダイレクトに記述
headlines.yahoo.co.jp

# 正規表現で記述
r(www\.aozora\.gr\.jp/cards/\d+/files)

正規表現でパターンを指定する場合は、r(正規表現)の形式で記述しますが、メタ文字をエスケープする必要があることに注意して下さい。

変換する対象ノードを指定

サイトによっては、ページ全体ではなく、メインとなる記事のみを変換したいことがあります。

ヘッダーロゴの部分をスキップするのが面倒とか。

そういう場合は、オプションページのpath to convert targetの欄で、サイトごとの変換対象(へのjQueryPath)を設定することができます。

f:id:convertical:20151228164638p:plain

書式は

[URLパターン], [変換対象のjQueryPath]

の形式です。

[jQueryPath]の部分については、複数指定することもできます。

複数指定した場合は、最初に見つかったものが変換対象になります。

もし見つからなければ、ページ全体が変換されます。

以下はサンプルです。

# yahooニュースは、#mainを対象に変換する
news.yahoo.co.jp, #main

# URL部分は正規表現でもOK
r([a-z]\.yahoo\.co\.jp), #main

# www.example.comでは、まず.mainを探して、なければ.entryを探す
www.example.com, .main, .entry

記述したら、忘れずに「SavePathList」というボタンで保存して下さい。

最後に

自動変換するサイトを指定しておくと、いちいち変換ボタンを押さなくても勝手にビューアーが起動するので、ウェブ上の連載作品を読むのがとても楽になりました。

CSSとDOMの境界、あるいはリスト組版がテーブル組版の親戚である件について

http://tb.antiscroll.com/static/nehan-demo#list

このデモですけど、ブラウザの大きさを変えると、リフローすることに気付いた人はいるでしょうか。

こういうデモで、テーブル組版や回り込み処理を見せると「おお」となる人が多いんですけど、リスト組版を見せても「で?」という人が多いと思います。

しかし、一見すると単純に見えるリスト組版ですが、実はテーブルの組版とよく似た複雑な組版なのです。

並列組版

nehan.jsでは、list-style-position:outsideかつdisplay:list-itemな要素と、display:table-rowな要素は、どちらもParallelGenerator(並列組版)のサブクラスで実装されています。

並列組版とは、複数の組版要素を同時並行的に行う組版です。

特徴としては、複数の子要素が矛盾なく表示されるための「前計算」を必要とするケースが多い、というのがあります。

例えばテーブルなら、各セルのサイズをどう割り当てるのか、事前に何らかの計算が必要なのは、なんとなく想像がつくと思います。

でもリストはどうでしょうか?

「リストなんて、単にテキストの先頭にリストマークの添字を足すだけじゃないの?」と思う人もいるかもしれません。

この指摘は、list-style-position:insideなら、まあそのとおりなのです。

しかしlist-style-position:outsideのときは、様相が異なります。

まずはUL>LIが書かれたHTMLを、適当なブラウザで表示させみてください。

<ul style="list-style-position:outside">
  <li>日本国民は、正当に選挙された(以下略)</li>
</ul>

CSSとDOMの境界

さて、表示されたリストですが、各リスト・アイテムの「リストマーク」は、マウス等ではテキスト選択できません。

f:id:convertical:20151203101342p:plain

つまり、このリストマークは、ユーザーがDOM操作では触れない組版要素になっています。

これが「CSSとDOMの境界」です。

これだけじゃありません。

list-style-positon:outsideのときは、リストの要素が一行を溢れても、リストマークの横幅だけ常に内容がインデントされています。

f:id:convertical:20151203101357p:plain

つまり空白の一列が割り込んでいるわけです。

この空白も、liマークアップに対するmarginpaddingで実現されているわけではないので、CSSとDOMの境界と言えます。

正確にリストを組版するためには、これらの境界要素に関するサイズ配分を事前に計算する必要があるわけです。

で、こういう性質を知っていると、例えばブラウザにとってどんなリストが意地悪なのかが、なんとなく予想できます。

意地悪な組版

例えばOL>LI*1000マークアップで、それぞれのリストを1em, 2em, 3emの文字サイズで順に表示させてみましょう。

<style type="text/css">
ol{ list-style-position:outside; }
.hoge{ font-size:1em }
.hige{ font-size:2em }
.hage{ font-size:3em }
</style>

<ol>
  <li class="hoge">hoge</li>
  <li class="hige">hige</li>
  <li class="hage">hage</li>
  <!-- 以下1000行まで続く -->
</ol>

どうしてこれが意地悪なのかというと、このリストを正しく表示するためには、1000行の添字が使うであろう数字文字列の最大幅を事前に計算する必要があるからです。

じゃないと、適切なインデントを実現できませんよね。

しかしchrome/firefox/safariにこれを表示させると、いずれも桁数が大きくなったところで、以下のようにリストの添字部分が「画面の左側」に見切れてしまいます。

f:id:convertical:20151203101412p:plain

body{margin-left:3em}みたいな設定をしていたってダメです。左の限界を突破して表示が切れます。

もちろん普通に考えると、li要素にそれぞれ違う文字サイズを付与することなんて考えにくいことなので、実用上は問題ないのです。

ただ正確に扱おうとするなら、リスト要素もテーブル組版と同じく、先に必要領域を計算する必要のある重い組版に該当するわけです。

この1000行のリストが、現状のブラウザで瞬時に表示されるのは、単に各ブラウザが厳密な計算をサボっているだけで、組版処理そのものが単純だからではありません。

というわけで、リストの組版も(テーブル組版ほどではないですが)、なかなかに複雑な処理なのです、というお話しでした。

nehan.js version5.4.0のリリース

nehan.js 5.4.0をリリースしました。

github.com

このバージョンから新しく追加された機能や、5.3.x系から消えた機能などがいくつかあるので、ご注意下さい。

変更点

  • Nehan.Documentが新たに定義されました。
  • onPageコールバックがサポートされました。
  • Nehan.setup, Nehan.createEngine, Nehan.PageStreamが廃止されました。

Nehan.Documentについて

Nehan.Documentは、paged media用のdocument環境を抽象化したクラスです。

こういう風に使います。

var doc = new Nehan.Document();
var target = document.querySelector("#target"); // 表示先

// 内容をセット
doc.setContent("<h1>hello, nehan.js!</h1>");

// ページサイズやスタイルをセット
doc.setStyle("body", {
  flow:"tb-rl", // or "lr-tb"
  width:600,
  height:400,
  fontSize:16
});

// 組版スタート
doc.render({
  // 各ページが完了する度に呼ばれる
  onPage:function(page, ctx){
    console.log("page:%o, pageNo:%d, percent:%d", page, page.pageNo, page.percent);
    page.element.style.marginBottom = "1em"; // ちょっと下にスペースを足しておく
    target.appendChild(page.element); // 組版結果を表示先に追加していく
  }
});

onPageコールバックについて

onPageコールバックを定義すると、ページ計算が都度DOMに変換しながらのパースになります。

なので、ページを遅延評価するonProgressコールバックを使う場合と比べると、全体のパーススピードは落ちるのですが、代わりにコードは簡略化されます。

これまでonProgress内でページオブジェクトを取得する場合、以下のようにする必要がありました。

doc.render({
  onProgress:function(tree, ctx){
    // この中でページが欲しい場合は、tree -> page する必要があった
    var page = ctx.getPage(tree.pageNo);
  }
});

onProgressに渡されるのはページではなく、ツリー(まだDOM化されていない中間オブジェクト)だからです。

しかし段組表示がしたい場合などは、即座にDOM変換された結果が欲しいわけですから、onPageが便利でしょう。

一方、ページ送りするビューアーを作る場合は、現在表示されていないページを変換する必要はないので、onProgressを使って遅延しておくと、全体のパーススピードが上がります。

Styleについて

作成した各Documentは、それぞれに独立したCSS環境を持ちます。

だからそれぞれを縦書きにしたり横書きにしたりできるのですが、全ドキュメントに共通のスタイルを定義したいときもあります。

そういう場合は、Nehan.seStyleを使ってください。

// 先にグローバルスタイルをセットしておく
Nehan.setStyle(".header", {
  margin:function(ctx){
    var em = ctx.style.getFontSize();
    var rem = ctx.style.getRootFont().size;
    return {
      before:Math.floor(2 * rem - 0.14285 * em),
      after:rem
    };
  }
});

// 縦書き組版環境
var vert_doc = new Nehan.Document()
  .setStyle("body", {flow:"tb-rl"})
  .setContent("<h1 class='header'>vert doc</h1>");

// 横書き組版環境
var hori_doc = new Nehan.Document()
  .setStyle("body", {flow:"lr-tb"})
  .setContent("<h1 class='header'>hori doc</h1>");

グローバルスタイルについては、各ドキュメントを作る「前に」セットしておく必要があることに注意してください。

nehan.jsでSemantic UIのようなヘッダースタイルを設定する

人気のCSSフレームワークSemantic-UI」ですが、ヘッダーの設定はこんな感じになっています。

.ui.header{
  margin: calc(2rem -  0.14285em) 0em 1rem;
}

各ヘッダー内での文字サイズ(em)と、bodyの文字サイズ(rem)を使って計算しています。

同じことをnehan.jsでやる場合は、こんな感じで設定します。

Nehan.setStyle(".ui.header", {
  margin:function(ctx){
    var em = ctx.style.getFontSize();
    var rem = ctx.style.getRootFont().size;
    return {
      before:Math.floor(2 * rem - 0.14285 * em),
      after:rem
    };
  }
});

Nehan Readerでも上記の設定でヘッダーをスタイリングしていますが、見出しの余白のバランスが良くなるだけで、けっこう全体の印象が変わるから不思議なものです。

Firefoxでもversion41以降で縦書き字形が表示されるようになりました

昨日リリースされたFirefoxのversion41から縦書きの字形が出力できるようになりましたので、縦書き文庫nehan.jsもこれに対応しました。

これまでFirefoxのときだけ、一部の文字を画像で代替表示する処理をしていたのですが、これからはそれらがちゃんとした縦書き字形で表示されます。

またFirefoxが対応したことにより、ほぼ全ての主要ブラウザ(の最新版)で、こうした文字画像が不要になりそうです。

サーバーの転送量も大幅に減るでしょうし、嬉しい限りです。

とはいえmobile safari(<= version 4)とか、IE10以下では未だに文字画像で表示するわけですが…今やこうしたブラウザからのアクセスは微々たるものです。

良い時代になりました。

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%ほど遅くなりました。

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

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

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

余談

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

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

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

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