自作ブラウザの Omoikane を触っていると、大きなWeb APIを一つ実装するより、既存ブラウザでは当たり前に見える細かい差分の方が厄介なことがある。

今回入れたのは、bodyの既定余白、Declarative Shadow DOM、縦書き時の文字の向き。どれも画面だけ見ると小さな変更だが、CSSのカスケード、HTMLのパース、文字描画のそれぞれに前提がある。

bodyの8pxはただの余白ではない

スタイルを指定していないHTMLをFirefoxなどで開くと、bodyの内容は画面端から少し内側に置かれる。ブラウザのUA stylesheetが持つ既定のmargin: 8pxによるもの。

Omoikaneにはこれがなかったので、何も指定していないページが画面の端から始まっていた。見た目の差だけなら、レイアウトの最後に8pxを足すような修正でも一見直りそうに見える。

ただし、この余白はauthor stylesheetより弱いUA originの宣言として存在する。

<style>
  body { margin: 0; }
</style>

このようなページではauthor側のmargin: 0が勝つ。またauthor側でrevertを使えば、UA側の8pxへ戻る。documentにあるauthor stylesheetの数やCSSOMへ、存在しないUA stylesheetが混ざるのも正しくない。

そのため、今回の修正ではbodyに対する4方向の8pxを、style resolverの候補へUserAgent originとして追加した。author CSSより先にカスケードへ入れる形にしている。

UA origin:     body の margin 8px
author origin: body { margin: 0 }
computed style は 0px

既定値を直接computed styleへ書き込むのではなく、カスケードに参加させたことで、author CSS、revert、CSSOMをまとめて自然な扱いにできる。

この変更にはFirefoxとの比較fixtureも追加した。既定状態では内容とリストマーカーが(8, 8)から始まり、author CSSで0にすると(0, 0)になることを、500x160のキャプチャで画素単位まで比較している。8pxでも、ページ全体がずれるので差分画像ではかなり目立つ。

Declarative Shadow DOMはHTMLを読んだ瞬間だけ特別

次はDeclarative Shadow DOM。HTMLの中で、次のようにshadow rootを宣言できる仕組み。

<div id="host">
  <template shadowrootmode="open">
    <span id="inside">shadow tree</span>
  </template>
</div>

通常の文書をパースするとき、このtemplateはhostのshadow rootになり、template要素自体はlight DOMに残らない。shadowrootmode="closed"なら内部のrootは作られるが、JavaScriptからhost.shadowRootとしては取得できない。

ここで気をつける必要があったのは、同じ文字列をinnerHTMLへ渡した場合。

host.innerHTML = '<template shadowrootmode="open"><span>...</span></template>';

今回の実装では、このfragment parserの経路でDeclarative Shadow DOMを有効にしていない。この場合はshadow rootを作らず、通常のtemplateとして残す。

HTMLの最初の読み込みと、DOM操作用のfragment parsingを同じように処理すると、あとからinnerHTMLへ入れた文字列で勝手にshadow rootが作られる。また、templateをhostの子として残すとshadow treeの内容がlight DOMから見えてしまう。

パーサー側では、通常のDocumentを読むときだけ宣言を許可し、hostがshadow rootを持っていないことも確認する。無効なhost、二つ目の宣言、fragment parserでは、templateをそのまま扱う。構文を読めるようにするだけでは足りず、どのパース文脈かまで状態に持つ必要があった。

縦書きの列方向と文字の向きは別

縦書きの修正も、最初は単純に見えた。

.vertical {
  writing-mode: vertical-rl;
}

vertical-rlvertical-lrは列がどちらへ進むかを表す。一方で、英数字を回転させる向きや、日本語の文字を立てたままにするかは別の問題になる。

以前は、block directionから文字の回転方向を決めていた。そのため、vertical-lrではラテン文字まで逆向きに扱う経路があった。列の進行方向と、文字そのものを物理的にどちらへ回すかを同じ値で扱っていた。

修正では、vertical-rlvertical-lrをmixed orientationとして扱い、文字ごとの向きはUnicode Vertical Orientationのデータで決めるようにした。

縦書き mixed orientation
    -> ラテン文字: 回転して配置
    -> 漢字・かな・ハングル: 立てたまま配置
    -> 絵文字・日本語の読点: 立てたまま配置

結合文字は単独で回転方向を決めず、直前の基底文字を引き継ぐ。sideways-rlsideways-lrは別に扱い、こちらでは指定された方向へ文字全体を回す。

縦書きの組版としては、まだ使える縦書き用字形への置換まで入ったわけではない。例えば長音記号は、現状では回転するfallbackになる。とはいえ、列方向だけからすべての字を回すよりは、Unicode上の文字方向を参照する方が実際の縦書きに近づく。

互換性の修正は描画だけでは終わらない

今回の変更は、見た目だけに見える。

  • 8pxの余白はCSSのoriginとrevert
  • shadow rootはHTML parserの文脈とDOM treeの分離
  • 縦書きはlogicalな書字方向と物理的なglyph rotation

という形で、それぞれ別の層まで入っていく。

同じ更新では、使われていないときのDOM removal hookを省く処理や、CDPのアクセシビリティ確認で不要なlayoutを避ける修正も入った。互換性用の処理は増やし続けるだけだと、機能を使っていないページの実行時間にも影響する。仕様どおりに動くことと、その確認や通常のDOM操作を重くしないことは、分けて見ていく必要がある。

ブラウザを使っていると8pxの余白も、縦書きの句読点も普段は意識しない。ただ、その意識しなくてよい状態を作るには、既定値をどのoriginへ置くか、HTMLをどの文脈で読んでいるか、文字の向きが何で決まるかを一つずつ合わせることになる。