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.の方向でちゃんと解決しようとしている感じです。