WebKitの「折り返し行末にきた文字をタグで囲むと分離禁止される」バグの修正に挑戦してみた

前回に続いて、WebKitのバグ修正に挑戦してみました。今回修正したのは、縦書きテキストレイアウトに関する調査支援者の公募にある折り返し行末にきた文字をタグで囲むと分離禁止されるというやつです。

下記の図が問題が生じている場合の表示ですが、黄色の部分に<span>木</span>などのタグが入っており、それが行末にあると改行位置がおかしくなるという問題です。

f:id:mdgw:20130126104933p:plain

本来は、下記のように表示されることが期待されます。

f:id:mdgw:20130126104955p:plain

ということでこのバグの修正に挑戦してみたのですが、何となく原因は分かったものの、良い修正方法が分かりませんでした。以下は、そもそもなぜこんな現象が発生するのか、というのを調べた結果のメモです。

WebKitでの改行処理

バグの原因

ここでは簡単のため(というよりも詳細を理解できていないため)日本語の文字のみを前提としています。

改行に関する処理は主に Source/WebCore/rendering/RenderBlockLineLayout.cpp の中にある RenderBlock::LineBreak::nextSegmentBreak で行われています。(この辺りの部分は、最近になって色々と変更されているみたいなので、現行のWebブラウザで使われている処理とは異なる可能性があります。)

この中では、ブロックに含まれる文字の中から、次の1行に含まれる分の文字を探索して取り出しています。色々と条件があるようなのですが、基本的には文字の横幅の合計を計算していき、ブロックからはみ出す手前までを1行としています。

仮に下記のような横幅が2文字のブロックがあった場合、初回のnextSegmentBreakでは「月火」、次回のnextSegmentBreakでは「水木」が取り出されます。

<div style="width: 2em">月火水木金土</div>

ところが、下記のようにタグが入ってくると、初回のnextSegmentBreakでは「月火」、次回のnextSegmentBreakでは「水木金」までが取り出されてしまいます。その結果として、先の例のように改行位置がおかしな表示になります。

<div style="width: 2em">月火水<span></span>金土</div>

横幅がはみ出しているかどうかの判定は、1文字ずつ取り出しながら幅を足し合わせていき、ブロックの幅を超えたら「その手前の文字まで」を1行分としています。つまり「月火」が取り出される時は、「水」の文字を取り出した時に判定が行われています。同様に「水木金」が取り出される時は、「土」の文字を取り出した時に判定が行われています。

本来は「金」の文字を取り出した時に、既にブロック幅をはみ出しているので、そこで終了するべきなのですが、「金」の文字は分離(break)禁止と判断されてしまい、「金」の文字の時には横幅チェックなどの改行判定が行われません。これがバグの原因となっています。

内部的な挙動

分離禁止かどうかの判定を行っているが nextSegmentBreak の下記の部分です。

bool betweenWords = c == '\n' || (currWS != PRE && !atStart && isBreakable(renderTextInfo.m_lineBreakIterator, current.m_pos, current.m_nextBreakablePosition, breakNBSP, 0) && (style->hyphens() != HyphensNone || (current.previousInSameNode() != softHyphen)));

betweenWords が true であれば、改行判定が行われます。この条件のうち、atStartについては、行の最初の文字を取り出している時にのみ true となります。取り出している文字の「手前」までを1行として取り出すので、行の最初の文字を取り出している時に改行しても意味がない、ということだと思います。

ということでatStartを無視すると、実質的には isBreakable の結果が利用されると考えられます。日本語の文字のみを考えた場合、isBreakableは基本的に true となるのですが、例外として、文字列の先頭文字のときは false となってしまいます。(より正確には、isBreakableは現在の文字の手前部分で分離できるかどうか、という意味なので、先頭文字の手前で分離すると文字がなくなってしまうということだと思います。)

バグが発生するHTMLの場合、内部的には下記のように3つの文字列に分割されて処理が行われます。

<div style="width: 2em">月火水<span></span>金土</div>
 ↓
[string] 月火水
[string] 木
[string] 金土

この例では「月」「木」「金」が文字列の先頭文字となり、isBreakable が false となって break 禁止と判定されます。よってそれらの文字を取り出している時には、改行判定も行われません。

修正方法?

原因を考えると、次のような修正方法が考えられます。

  1. 文字列の先頭文字の際にも isBreakable を true にする
  2. 文字列の先頭文字の際にも改行判定を有効にする
  3. 3つに分割された文字列を1つの文字列として処理する

1.は isBreakable という意味を考えるとあまり良くない気がします。2.は悪くないと思うのですが、現状がそうなっていないことを考えると、何か副作用がありそうな気もします。3.が一番正しい気もするのですが、影響範囲が大きくてどのように修正して良いのかさっぱり分かりません。

ということで、2.の方法でお茶を濁してみました。

Index: Source/WebCore/rendering/RenderBlockLineLayout.cpp
===================================================================
--- Source/WebCore/rendering/RenderBlockLineLayout.cpp	(revision 140537)
+++ Source/WebCore/rendering/RenderBlockLineLayout.cpp	(working copy)
@@ -2824,7 +2824,7 @@
                     midWordBreak = width.committedWidth() + wrapW + charWidth > width.availableWidth();
                 }
 
-                bool betweenWords = c == '\n' || (currWS != PRE && !atStart && isBreakable(renderTextInfo.m_lineBreakIterator, current.m_pos, current.m_nextBreakablePosition, breakNBSP)
+                bool betweenWords = c == '\n' || (currWS != PRE && !atStart && (isBreakable(renderTextInfo.m_lineBreakIterator, current.m_pos, current.m_nextBreakablePosition, breakNBSP) || current.m_pos == 0)
                     && (style->hyphens() != HyphensNone || (current.previousInSameNode() != softHyphen)));
 
                 if (betweenWords || midWordBreak) {

取りあえずバグは修正されるのですが、とっても副作用がありそうな感じです・・・

追記1

改めて考えてみたのですが、上記の修正方法だと実際に副作用があります。日本語の場合は良いのですが、英語の場合だと二つ目のブロックが3行に分割されてしまいます。というわけで、上記の修正はボツでした・・・

<div style="width: 3em">unbreakable</div>
<div style="width: 3em">unbr<span>eak</span>able</div>

追記2

良く読んでなかったのですが、このバグからリンクされているBug 17427 - Summary: Line breaking opportunities at the end of a text node are missedが同じ現象のようで(上記の適当なパッチでこちらのテストケースも治る)、こちらには既にパッチや具体的な問題点が書かれていました。ざっくり見た限りでは、3.の方向でちゃんと解決しようとしている感じです。

WebKitの「下線がルビで分離される」バグを修正してみた

縦書きテキストレイアウトに関する調査支援者の公募にあるWebKitのバグを治すと10万円が貰える、というのに釣られてバグの修正に挑戦してみました。

挑戦してみたのは下線でルビが分離されるというやつです。コードのクォリティはアレな感じですが、とりあえず下記のような感じで動くようになりました。ちなみに下線以外の上線や取り消し線も同じ場所で表示してるので一緒に治ります。

Index: Source/WebCore/rendering/InlineTextBox.cpp
===================================================================
--- Source/WebCore/rendering/InlineTextBox.cpp	(revision 140537)
+++ Source/WebCore/rendering/InlineTextBox.cpp	(working copy)
@@ -1010,6 +1010,50 @@
         setClip = true;
     }
 
+    RenderBlock* containingBlock = renderer()->containingBlock();
+    if (containingBlock->isRubyBase() && containingBlock->parent() && containingBlock->parent()->isRubyRun()) {
+        RenderRubyRun* rubyRun = toRenderRubyRun(containingBlock->parent());
+
+        int leftOverhang = rubyRun->marginLeft();
+        int rightOverhang = rubyRun->marginRight();
+
+        RenderObject* prevObj = renderer();
+        while (prevObj != containingBlock) {
+            if (prevObj->previousSibling()) {
+                break;
+            }
+            prevObj = prevObj->parent();
+        }
+
+        RenderObject* nextObj = renderer();
+        while (nextObj != containingBlock) {
+            if (nextObj->nextSibling()) {
+                break;
+            }
+            nextObj = nextObj->parent();
+        }
+
+        bool hasNotLeft;
+        bool hasNotRight;
+        if (isLeftToRightDirection()) {
+            hasNotLeft = (prevObj == containingBlock);
+            hasNotRight = (nextObj == containingBlock);
+        } else {
+            hasNotLeft = (nextObj == containingBlock);
+            hasNotRight = (prevObj == containingBlock);
+        }
+
+        if (hasNotLeft && hasNotRight) {
+            width = rubyRun->logicalWidth() + leftOverhang + rightOverhang;
+            localOrigin.setX(boxOrigin.x() - left() - leftOverhang);
+        } else if (hasNotLeft) {
+            width += left() + leftOverhang;
+            localOrigin.setX(boxOrigin.x() - left() - leftOverhang);
+        } else if (hasNotRight) {
+            width = rubyRun->logicalWidth() - left() + rightOverhang;
+        }
+    }
+
     ColorSpace colorSpace = renderer()->style()->colorSpace();
     bool setShadow = false;

こんな感じでrubyの中でも途切れずに下線が表示されます。

f:id:mdgw:20130124181455p:plain

以下はこの修正の簡単な説明です。

HTMLで下記のようなタグがあった場合、

<ruby>
  漢字
  <rt>ふりがな</rt>
</ruby>

WebKitの内部では下記のような階層のデータ構造になるようです。

RenderRuby(<ruby>)
    RenderRubyRun
        RenderRubyText(<rt>)
            RenderText --> InlineTextBox(ふりがな)
            ...
        RenderRubyBase
            RenderText --> InlineTextBox(漢字)
            ...
    ...

問題となっている下線はInlineTextBox.cppのpaintDecorationメソッドで描画されているのですが、現状は文字幅の分だけ描画するようになっています。そこで、InlineTextBoxがRubyBase及びRubyRunの子どもであった場合には、RubyRunの横幅を利用して文字の外側の空白部分にも広げて描画するようにしています。

またルビ文字は、左右の文字に少しはみ出して表示されるのですが、そのはみ出した部分はoverhangと呼ばれるようです。このoverhangは、RubyRunにマイナスのmarginとして設定されるようなので、その値を利用して描画位置と幅を微調整しています。

それ以外で面倒なのが、下記のようなHTMLの場合です。

<ruby>
  <u><u>
  <strike></strike>
  <rt>ながいふりがな</rt>
</ruby>

f:id:mdgw:20130124181002p:plain

この場合InlineTextBoxが複数存在するため、単純にRubyRunの幅を利用することができません。現在は、単一・左端・中間・右端という4パターンに分けて対応しているのですが、この部分は冗長だったり座標の計算が怪しげだったりします。また上記のHTMLでは、取り消し線が「字」の両側に伸びておらず、文字の分割位置が偏っているのですが、修正が大変そうだったので手をつけていません。この辺り色々と改善の余地がある気がしています。

はてなブログのiPhoneアプリ

iPod touch 欲しい! id:hatenablog と書くとiPod touchが当たるというキャンペーン。

アプリをダウンロードしてiPod touchをもらおう! iPhoneアプリ「はてなブログ」をリリースしました

キャンペーンはともかく、アプリの方は予想に反して良く出来ていてビックリ。この勢いでWeb版の編集画面も改良してくれると良いのだけど…

Wicketで継承を利用したページのテンプレートを手軽(?)にプレビュー可能にしてみた

 Wicketのページテンプレートは、継承・パネル等を利用することにより、共通部分の再利用が簡単にできるのが便利です。ところが、継承を利用したページ(HTML)をプレビューする場合、継承元ページのCSS等は反映されないため、そのままでは表示結果が実際とは大きく異なるものとなります。この問題を解決するには、継承元ページと同じCSSを追加する、実際にアプリケーションサーバWicketを動かして確認する、という方法が考えられますが、あまり手軽ではないように思います。

 そこで、各ページにCSSを追加したり、Wicketを実際に動かさなくても、継承を利用したページ(HTML)をプレビュー出来るようにしてみました。具体的には、JavaScriptにより親ページと子ページを一つのHTML中に表示するスクリプトを作成しました。このスクリプトを各ページに埋め込むことで、HTML単体で開いても"ある程度"のプレビューができるようになります。

wicket-preview.js - https://github.com/madogiwa/wicket-preview/

基本的な使い方

1.各ページに wicket-preview.js を埋め込む

 親ページを含めた全てのページに wicket-preview.js を追加します。

<wicket:remove>
  <script src="./wicket-preview.js"></script>
</wicket:remove>

 Wicketアプリケーションサーバで実行する際には邪魔になりますので、実行時に削除されるよう <wicket:remove> で囲っておきます。

2.継承元ページを指定する

 <wicket:extend> の data-parent 属性に、継承元(親)ページへのパスを指定します。

<wicket:extend data-parent="ParentPage">
...
</wicket:extend>

これで準備は完了です。後はブラウザでページを開くと継承元のページを含めて表示されます。

サンプル で動作例を確認することができます。またGitHubの方にはサンプルのソースもあります。

その他の使い方

 継承を利用したページを表示する以外に、下記のようなことができます。

Panel

 Panelを埋め込みたい場所に data-panel 属性で Panel へのパスを指定します。

<div>
  <div wicket:id="panel" data-panel="SearchPanel"></div>
</div>

<wicket:fragment>

<wicket:fragment>の埋め込みも可能です。埋め込みたい場所に data-fragment 属性でidを指定します。

<div>
  <div wicket:id="" data-fragment="fragment1"></div>
</div>

<wicket:fragment wicket:id="fragment1">
...
</wicket:fragment>

<wicket:head>

 <wicket:head> で指定した内容はページに挿入されます。(単純に挿入するだけで、重複する要素の排除等はされません)

repeat

data-repeat 属性に反復回数を指定すると、指定された回数分だけ要素がコピーされます。

<ul>
  <li wicket:id="list" data-repeat="10">Item</li>
</ul>

おまけ(スクリプトの動作)

 スクリプトを埋め込んだページを開くと、下記のような処理が行われます。

  1. ページの読み込み時に <wicket:extend> の有無を調べる。
  2. ページ中に <wicket:extend> がある場合は、継承を利用したページと判断し、親ページにリダイレクトする。この際、親ページに対して child パラメータで子ページのURLを渡しておく。
  3. 1〜2を繰り返してルートの親ページまでたどり着いたら、child パラメータで指定された子ページを順番に埋め込む。

 1.と2.はどの子ページを表示すれば良いのか、という情報を親ページに渡しているだけです。そのため、実際には省略可能で、親ページで手動で child パラメータを指定しても同じ結果となります。

追記

IE7とIE8でも動くようにしてみました。jQuery経由ではネームスペース周りで問題が出るようなので、HTML文字列やDOMを直接操作する形で対応しています。

リポジトリをOracle LinuxからCentOSに変更するスクリプトを作成してみた

元ネタはここで公開されている centos2ol.sh です。これはCentOS(及びRHEL, Scientific Linux)からOracle Linuxに乗り換えるために、スクリプトでリポジトリ設定を変更してしまうというもので、その強引な手法が面白かったので、ネタとしてその逆を行うスクリプトを作成してみました。

作成したスクリプト ol2centos は、元ネタとは逆に Oracle Linux から CentOSリポジトリへの書き換えを行います。centos2ol.sh は Apache License でしたので、それを改造する形で実現しています。

使い方

rootユーザで下記のコマンドを実行して下さい。

curl -O https://raw.github.com/madogiwa/ol2centos/master/ol2centos.sh
sh ol2centos.sh

なお一応、仮想マシン上で、

CentOS 5.8(i386) ==centos2ol==> Oracle Linux ==ol2centos==> CentOS

の順番に適用して動作を確認しましたが、ネタとして作成したものですので、実用に用いることはお勧めできません。

スクリプトの変更点など

元のスクリプトでは、下記のことを行っています。

  1. インストールされているLinuxの種類とバージョンの確認
  2. リポジトリディレクトリ(yum.repos.d)の確認
  3. リポジトリディレクトリに Oracle Linux 用の設定を追加
  4. リポジトリディレクトリにある既存設定ファイルを無効化
  5. releaseパッケージ(/etc/issueなどが含まれるやつ)の入れ替え

このスクリプトを改造して実現したのですが、少しだけ面倒だったのが 3.のリポジトリ設定を追加するところです。

Oracle Linux では http://public-yum.oracle.com/ からリポジトリ用の設定ファイルを直接ダウンロード可能なのですが、CentOS では直接ダウンロード可能なものが見つからず、そのままのルーチンを流用することができませんでした。そのため、設定が含まれている rpm ファイル(centos-release)をダウンロードして、そこから設定ファイル(CentOS-Base.repoなど)を抜き出す、ということを行っています。ちなみに下記が該当部分です。

echo "Downloading and Extract CentOS yum repository file..."
if ! curl "${yum_url}/${release_pkg}" | rpm2cpio | cpio -id "*.repo"; then

それ以外については、概ねそれぞれ逆方向になるように変更するだけで実現できています。