ここ数日、自作ブラウザのOmoikaneでHTMLとCSS周りの実装をまとめて進めていました。

最初はFirefoxと同じHTMLを表示して、どこに描画差が残っているかを調べていました。

比較を始めると、単純な色の違いだけではなく、inline要素の矩形が取れていない、WebフォントをJavaScriptから扱えない、HTML fragmentをcontextに合わせてparseできない、といった基盤側の不足も出てきます。

見つかったものを順番に直していたら、描画、レイアウト、CSSOM、DOM、HTML parserまで広がりました。

今回は、その中で閉じた15件のIssueをまとめておきます。

最初はFirefoxとの描画比較

きっかけは、同じfixtureをOmoikaneとFirefoxで開き、DOMの矩形とスクリーンショットを比較したことでした。

この比較で最初に見つかったのが、次の問題です。

  • textareaの複数行表示と改行位置が合わない
  • inline spanの背景が描かれず、getClientRects()も空になる
  • Arabicテキストが連結字形ではなく欠落グリフになる

PR #680で、#673#674#675をまとめて修正しました。

inline要素については、行ごとの要素境界を保持し、背景描画とgetClientRects()getBoundingClientRect()で同じ矩形を使うようにしました。

textareaは改行コードをLFへ正規化し、単語とgrapheme単位の折り返し、行高、padding端でのclip、UTF-16位置でのcaretとselectionを処理します。

Arabicについてはfont fallbackを増やしただけではなく、data URLの読み込み漏れや、URL内の数字を変換してBase64を壊していた処理、font shorthandからfamilyが消える問題も直しています。

修正前後とFirefoxで25条件を撮影し、同じブラウザ内では再撮影した画像が一致することも確認しました。

このPRでは、macOSでテスト用HTTPサーバーがWouldBlockになり、CSPテストの後片付け中に二重panicする#703も一緒に直しました。

描画比較をする前にテスト基盤が不安定だと、結果が実装差なのかテストの失敗なのか分からなくなるので、このあたりも必要でした。

inline-blockを一つの箱として扱う

Webフォントのfixtureを比較していると、文字列の横幅は合っているのに、inline-blockの高さだけが10pxずれていました。

#measure {
  display: inline-block;
  font: 40px/50px DynamicFace, monospace;
  border: 2px solid;
  padding: 8px;
}

Firefoxでは、border boxの高さが70pxになります。

line-height 50px
+ padding 8px × 2
+ border 2px × 2
= 70px

Omoikaneでは60pxになっていました。

原因は、inline-blockを通常のinline要素と同じように断片へ展開し、内部に独立した整形文脈を作っていなかったことです。そのため、明示した50pxのline boxではなく、40px相当のglyph metricsが高さに使われていました。

Issue #709では、inline-blockを親の行に置かれるatomic inline-level boxとして扱い、子要素は独立したLayoutBoxでlayoutするように変更しました。

これによって、内部line box、block child、shrink-to-fit、baseline、vertical-align、overflow、transform、hit testを要素単位で処理できます。

修正後は、FirefoxとOmoikaneのDOMRectがどちらも(24, 24, 300, 70)になりました。

WebフォントをJavaScriptから扱えるようにする

Omoikaneは以前からCSSの@font-faceを描画に使えましたが、JavaScriptからフォントを読み込むAPIがありませんでした。

ページ側から見ると、次のような処理ができません。

const face = new FontFace("Example", "url(example.woff2)");
await face.load();
document.fonts.add(face);
await document.fonts.ready;

Issue #687では、FontFaceFontFaceSetdocument.fontsと、フォント読み込み用のtask sourceを実装しました。

URLとBufferSourceからの読み込み、load()loadedstatus、登録と削除、check()ready、loading関連eventまで含みます。

ここで別の問題も見つかりました。

既存のCSSOMは、約1 MBあるフォントのdata URLをJavaScriptで1文字ずつ走査していました。そのためloop上限に到達し、@font-faceを列挙できません。

宣言の分割も単純な;区切りだったため、data URLや引用文字列の中にあるセミコロンまで宣言の区切りとして扱っていました。

Issue #708で、sourceを保持するCSS rule scannerをRust側へ移し、CSSFontFaceRuleとdescriptorのlive更新を実装しました。

CSSFontFaceRule.styleからdescriptorを変えたときは元のstylesheetとFontFaceへ反映し、srcの変更や規則の削除時には古いfaceを切り離します。

この2件はPR #712でまとめて入りました。

固定したWPTは75件すべて実行でき、Firefoxとの比較でも要素のgeometryは完全に一致しました。残った差は文字の輪郭だけだったので、これはあとで別に直します。

CSSのカスケードにlayerを入れる

@layerは構文としてparseできていましたが、style resolverは中のruleを通常のruleとして扱っていました。

例えば、前のlayerにあるspecificityの高いselectorと、後ろのlayerにあるspecificityの低いselectorが競合すると、本来は後ろのlayerが優先されます。

ところがlayer順を見ていないので、Omoikaneではspecificityだけで前のruleが勝つ場合がありました。

Issue #706では、named、anonymous、nested、import、unlayeredのruleを宣言順に登録し、カスケードへlayer順を追加しました。

通常宣言は後ろのlayerが強く、!importantでは順番が逆になります。revert-layer、layer付き@import、CSSOMからのlayer rule操作も含めています。

PR #718では、追加したWPTを含む81件でregression 0、Firefoxとの固定fixtureはgeometryと全画素が一致しました。

無効なフォームを送信しない

フォーム側ではConstraint Validation APIが未実装でした。

そのため、requiredなinputが空でもrequestSubmit()がsubmit eventを発火し、そのままnavigationへ進んでいました。

Issue #691では、input、textarea、selectなどへ次のAPIを追加しました。

  • validity
  • validationMessage
  • willValidate
  • checkValidity()
  • reportValidity()
  • setCustomValidity()

required、type、pattern、min、max、step、文字数、custom errorを評価し、disabled、readonly、fieldset、datalistなど検証対象外の条件も扱います。

無効なcontrolではcancelableかつnon-bubblingなinvalid eventを発火し、未処理なら最初のcontrolへfocusします。novalidateformnovalidatesubmit()requestSubmit()の違いも実装しました。

PR #719では、追加したWPT 15件がすべて通り、全体でもregressionはありませんでした。

contextに合わせてHTML fragmentをparseする

innerHTMLの実装は、渡されたHTMLを常に仮のbodyで包み、document parserへ渡していました。

通常の要素ならそれらしい結果になりますが、tabletbodytrselectの中では開始するtree construction modeが違います。SVGやMathMLではnamespaceも変わります。

さらに、Range.createContextualFragment()自体が未実装でした。

Issue #690#720では、context elementから開始するHTML fragment parserを追加し、innerHTMLRange.createContextualFragment()で共有するようにしました。

HTMLだけではなく、XML document用のstrictなfragment parserも追加しています。

ここまで実装すると、fragment内のscriptをいつ実行するかという問題が出てきます。

innerHTMLで作られたscriptはinertのままですが、document.createElement("script")Range.createContextualFragment()で作ったinline scriptは、文書へ接続されたときに実行する必要があります。

Issue #721では、APIから作られたscriptの状態をnative node単位で追跡し、対象Documentのrealmで一度だけ実行するようにしました。

移動や再挿入で二重実行せず、type、CSP、sandbox、iframe realm、例外記録も既存のscript経路へ揃えています。

3件をまとめたPR #722では、HTML tree builderのテスト28件、固定WPT 97件、全体2482件が通りました。

text-overflowで省略記号を描く

text-overflowはpropertyとしてparseされず、幅からはみ出した文字列をclipするだけでした。

Issue #697では、1値のclipellipsisを追加しました。

省略記号は単純に文字列の末尾へ足せばいいわけではありません。省略記号の幅を先に確保し、残った幅に収まる位置で文字列を切る必要があります。

このときgrapheme clusterの途中で切らず、複数のinline fragmentをまたいで処理し、LTRとRTLでは省略記号を置く側も変えます。

layout、hit test、scroll geometryは元のfragmentを維持し、paint時だけoverflow用の表示列を組み立てるようにしました。

PR #723では、Firefoxと比較した6条件でborder boxが一致し、LTRとRTLの両方で正しい側に省略記号が出ることを確認しています。

同じフォントなのに文字が小さい

ここまで直しても、同じTTF、同じfont-size、同じline-heightを使ったFirefoxとOmoikaneで、文字の輪郭とbaselineに差が残りました。

20pxの固定フォントを使うと、Omoikaneの文字のinkは高さ17px、line box内の上端から10pxの位置にありました。Firefoxでは高さ19px、上端から6pxです。

要素の矩形や行位置は合っているので、layoutではなくglyph rasterize側の問題です。

Omoikaneは文字のshaping時に、CSSのfont sizeをフォントのunitsPerEmへ対応させていました。

一方、rasterizeに使っているab_glyphへは、CSSのpx値をそのままscaleとして渡していました。ab_glyphのscaleはascent - descentを基準にしているため、em squareを基準にするshapingとは倍率が一致しません。

Issue #677では、CSSのem sizeをab_glyphのfull-height scaleへ変換し、shapingとrasterizeの倍率を揃えました。

さらに、選択したfont faceのascentとdescentをtext fragmentへ持たせ、half-leadingを使ってbaselineを決めます。

修正後は文字のinkが高さ18px、上端から7pxになり、Firefoxの19px、6pxへ近づきました。

変更画素は8439から7332、正規化した平均絶対誤差は約0.0070から約0.0035になっています。

残る1px程度の差は、ab_glyphとFirefoxが使うrasterizer自体の違いです。広い許容誤差で同じ扱いにはせず、どこまでが実装不具合で、どこからがrasterizer差なのかを分けて記録しました。

box-shadowの作り方が違っていた

最後は、box-shadowと角丸境界です。

Omoikaneのouter shadowは、border boxの内側を最初にくり抜き、その輪郭をblurしていました。

CSSのouter shadowは、不透明なborder box全体をblurしてから、完成した影をborder edgeの内側でclipします。順番が逆です。

また、box-shadowのblur値を3回のbox blurそれぞれの半径として使っていたため、仕様上のsigma = blur / 2より影が広くなっていました。

この2つが重なり、要素のすぐ外側は影が薄く、外へは広がりすぎた状態になっていました。

Issue #676では、不透明な形状全体を先にblurし、最後に角丸の内側をcoverage付きで除外する順へ変更しました。

小さいblurには直接Gaussian、大きいblurには3-passのbox近似を使い、後者は画素数に対して線形の処理を維持します。

角丸もpixel中心の内外だけで二値判定していたため、境界が階段状になっていました。solid、gradient、image、border、shadowで同じsubpixel coverageを使うようにしています。

元の8px shadowでは、Firefoxとの差が次のように変わりました。

指標修正前修正後
差のある画素5,1684,024
最大チャンネル差74/2555/255
正規化MAE0.02185530.00110876

差のある画素数はまだ多く見えますが、最大差と平均誤差はかなり小さくなりました。残っているのは主に角丸境界のanti-aliasing差です。

blur 0、1、2、4、8、16px、正負のspread、offset、角丸shadowを含む12条件で比較し、要素のgeometryはすべてFirefoxと一致しました。

15件をまとめて閉じた

今回閉じたIssueは次の15件です。

一つひとつは独立した機能ですが、実際にはかなりつながっています。

Webフォントの比較からinline-blockの高さが見つかり、geometryを揃えると文字のrasterize差が見えるようになりました。inlineの矩形を直すと、text-overflowでどのfragmentを残すかも正しく扱えるようになります。

HTML fragment parserを実装すると、fragment内のscriptをいつ実行するかというDOM側の問題が出てきます。

表面に見える一つの差を直すと、その下にある別の差が見える、という感じでした。

まとめ

今回は、OmoikaneのHTML・CSS実装をまとめて進めました。

追加したのは、Font Loading API、Constraint Validation API、Range.createContextualFragment()@layertext-overflowなどです。

同時に、inline要素、textarea、Arabic、inline-block、文字のrasterize、box-shadow、角丸境界をFirefoxと比較して修正しました。

APIが存在するだけではなく、実際のWPTを動かし、DOMの数値と描画結果をFirefoxと並べて確認しています。

まだFirefoxと全画素が一致するわけではありません。文字のanti-aliasingなど、別のrasterizerを使う以上残る差もあります。

それでも、単にHTMLをparseしてそれらしい画面を出すところから、Web APIの状態変化、CSSOMのlive更新、contextに依存するHTML parser、描画のsubpixel coverageまで扱うところへ進みました。

しばらくはこの調子で、差が見つかったところから一つずつ直していきます。