Web フォント読み込みが描画をブロックしない仕様

対象実装: arm/v1/src/os_lib/web_engine/font_budget.rs 単体試験: arm/v1/tests/src/test_font_budget.rs

典拠(2026-07-30 参照): - CSS Fonts Module Level 4 §4.9 font-display descriptor - https://www.w3.org/TR/css-fonts-4/#font-display-desc - https://drafts.csswg.org/css-fonts-4/#font-display-desc

注意: 上記 2 つの URL を参照したが、いずれも §4.9 の本文が 取得できず(目次までで切れる)、swap の block period / swap period の 正確な文言は未確認。したがって本書では規格文言を引用せず、 確実に言えることだけを根拠にする。原文を参照でき次第、更新すること。

背景(実測)

L1 の排他制御を直してネットワークが 5/5 成功するようになった結果、 実サイト表示が「Loading Secure Page…」のまま止まるようになった。

[NET] BROWSER: Load page SUCCESS. body_len=12597     ← HTML 取得済み
[WM][FRAME] present_frame=1216                        ← 描画ループは動作中
https_get_binary ordinal=4 SUCCESS body_bytes=5328547 ← 5.3MB の Noto Sans JP
https_get_binary ordinal=5 SUCCESS body_bytes=102065  ← Font Awesome webfont

退行ではない。 以前はフォント取得が失敗していたので先へ進めていた。 通信が健全になったことで 5.3MB のフォントを実際にダウンロード・処理する ようになり、その完了を待って描画が始まらない。

確実に言えること

  1. 実サイトの CSS は font-display: swap を指定している (fonts.googleapis.com/css2?...&display=swap および取得した @font-face に font-display: swap が含まれる)。 swap の意図は「フォント読み込みを待たずに代替フォントで描画し、 読み込み完了後に差し替える」ことである。 → 読み込み完了まで描画を止める現在の実装は、この意図に反する。

  2. 5.3MB のフォントを同期的に待つのは、この OS の用途では非現実的。 ダウンロードだけでなく解析・グリフ生成にも時間がかかる。

不変条件

本実装の範囲と近似

【2026-07-30】ヒーロー背景写真が出ない件の切り分け

.hero は background-image: linear-gradient(...), url('../img/IMG_6530.jpg')。 段階ごとに実測して、どこまで到達しているかを確定させた。

段階 結果
CSS から url() 抽出 成功 [BGIMG] first=Some("https://www.sugi-lab.net/img/IMG_6530.jpg")
可視判定を通過し収集対象になる 成功 [BGIMG][NEED] id="home"
ensure_image_cached のガード通過 成功 [BGIMG][ENSURE] passed guards
HTTPS 取得 成功 got=3724477 content_length=Some(3723837) complete=true
デコード ログ無し(成功も失敗も出ない)
描画 到達せず

→ 3.7MB の JPEG デコードで止まっている。 IMAGE_LOADER: decoded ...(成功)も IMAGE_LOADER: decode failed ...(失敗)も出力されない。

注意: 原因は未確定。候補は - デコードに極端に時間がかかっている(協調マルチタスクなので他が進まない) - デコード中に OOM でスレッドが落ちている(woff2 と同じ形) - MAX_JPEG_DIM(8192) 判定より手前で止まっている

次の手(推測で直さない): デコード開始・終了・所要時間をログして、 「時間がかかっている」のか「落ちている」のかを先に確定させる。 woff2 の OOM と同種なら、画素数の上限を設けて超過分は諦める (背景写真が出ないより、ページが表示される方が良い)。 font_budget と同じく純粋関数+単体試験で判定を固定すること。

【2026-07-30 訂正】「デコードで止まっている」は誤りだった

前節で「3.7MB の JPEG デコードで止まっている」と書いたが、誤り。 デコード計測ログ [JPEGDIAG] を仕込んで再測定したところ、 そこへ到達すらしていなかった。

実際に起きていたこと(2 段階):

(1) NIC 送信キューの溢れ

[NET][DIAG] nic tx queue FULL (size=8) dropped_total=2192

送信キューが 8 エントリしかなく、画像取得のような連続送信で溢れて 2192 パケットを捨てていた。捨てられた送信は上位から 「送ったのに応答が来ない」に見え、タイムアウトとして現れる。 → net/tx_queue.rs(純粋モジュール・単体試験 10 件)を追加し、 キュー長を 8 → 64 へ。溢れは 0 件になった。

(2) 単純に処理が終わっていない

[NET][STATS] total=8 ok=8 dns_fail=0 connect_fail=0 handshake_fail=0 truncated=0
[NET][DIAG] https_get_binary ordinal=9 host=www.sugi-lab.net path=/img/IMG_6530.jpg
[NET][DIAG] entering layout after css, tick=13245   ← ログ末尾

パニックも停止も無い。ネットワークは 8/8 成功。 3.7MB の画像取得を開始した状態でレイアウトに入り、 スクリーンショットのタイムアウト(120 秒)に達しただけ。

これまで表示できていたのは、画像取得が失敗して先へ進めていたため。 ネットワークが健全になり実際に 3.7MB を取得するようになった結果、 時間内に終わらなくなった。

したがって必要なのは

【2026-07-30 確定】画像・フォント取得の非同期化(サイズ上限の完全撤廃)

【2026-08-03】Web フォントが同一ブート内で登録されない件

塞いだ 3 つの穴

  1. @font-face の取り込みが CSS キャッシュミス時にしか走らなかった

    fetch_and_register_font_faces は「ネットワーク往復を伴うから」という理由で CSS キャッシュがミスしたときだけ呼ばれていた(2026-07-22 の制限)。 その後フォント取得が非同期+キャッシュ優先になり前提が崩れていた:

    1. 初回レイアウト: 未取得なので Pending(登録できない)
    2. 背景スレッドが取得完了してキャッシュへ保存
    3. 再レイアウト: CSS はキャッシュ命中するのでここが呼ばれない

    → 取得できているのに永久に登録されない。実測 8 本取得・registered= 0 件。

    毎レイアウト呼ぶよう変更(登録済みは has_custom_font で即スキップ、 未取得は font_queue の seen で重複投入されない)。 実測でキャッシュ命中が 0 → 1 件になった。

  2. 連続失敗による打ち切りが残っていた

    MAX_CONSECUTIVE_FAILURES = 2 で以降のフォントを全部スキップしていた。 打ち切りは本物のブラウザの挙動ではない(R-2)。撤去した結果、 Font Awesome の 4 本も取得キューへ載るようになった。

  3. register_custom_font が失敗を 4 箇所すべて無言で返していた

    WOFF2 展開失敗・未知の形式・パース失敗・set_primary 失敗。 どこで落ちたか分からなかった。すべてログを追加(spec/logging_policy.md L-1)。

残っている問題: WOFF2 の Brotli 展開で停止する

ログを足したことで停止位置が確定した:

[FONT] 登録開始 family=FontAwesome bytes=4132
(以降 [FONT] 解析開始 が出ない)

この 2 つのログの間にあるのは decompress_woff2(Brotli 展開)だけ。 fa-v4compatibility.woff2(4132 バイト)の展開で レイアウトスレッドが停止する。描画スレッドは動き続けている (present_frame が増え続ける)ため、フリーズには見えない。

待機時間 300 / 420 / 540 秒すべてで同じ地点で止まったので、 処理が遅いのではなく進まなくなっている。

decompress_brotli は BrotliDecompressStream の単発呼び出しで ループは無い。したがって停止しているのは

のどちらか。まだ特定していないので断定しない。 次はこの 2 点の間にログを入れて切り分ける。

なお GLOBAL_VECTOR_FONT の差し替えは try_lock へ変更した。 描画スレッドが保持したまま譲ると spin ロックが永久に回るため (CUSTOM_FONTS への登録は済んでいるので描画には反映される)。

【2026-08-03】WOFF2 が一度も使えていなかった 2 つのバグ

バグ 1: Brotli 展開でレイアウトスレッドが停止していた

decompress_brotli は alloc_no_stdlib::StackAllocator に 型ごとにスロットを 1 個しか渡していなかった([&mut [u8]; 1])。 Brotli は同じ型の領域を複数同時に要求するため、2 個目以降が取れず BrotliDecompressStream から戻ってこなくなる。

段ごとにログを入れて停止位置を確定させた:

[FONT][BR] u8 プール確保 完了
[FONT][BR] u32 プール確保 完了
[FONT][BR] HuffmanCode プール確保 完了
[FONT][BR] BrotliState 構築 完了
[FONT][BR] 展開開始 in=4060 out_cap=8750
(以降なし。待機 300/420/540 秒すべて同じ地点で停止)

描画スレッドは動き続けるためフリーズには見えず、 「フォントが当たらない」という形でしか表面化しなかった。

修正: 固定プールをやめ、Allocator トレイトを実装した ヒープ確保アロケータ(HeapAllocator)に置き換えた。 カーネルには 512MB のヒープがあり、プールサイズの見積り自体が不要になる。 「大きいフォントで枯渇して落ちる/止まる」失敗モードごと消えた。

バグ 2: 展開ストリームをタグ順に読んでいた

WOFF2 仕様では、テーブルデータはテーブルディレクトリに現れた順で 連結されている。実装はタグ順(sorted_indices)に読み進めていたため、 各テーブルへ別のテーブルのバイト列が割り当たっていた。

[FONT] フォントのパースに失敗 family=FontAwesome bytes=8916 err=NoHeadTable

修正: 「ストリームの切り出しはディレクトリ順」「ディレクトリの出力はタグ順」 と分けた。結果 registered=true になった。

[FONT] 登録完了 family=Font Awesome 7 Free bytes=314740
@font-face 'Font Awesome 7 Free' <- fa-solid-900.woff2 registered=true

そこで露見したリグレッション: 既定フォントの乗っ取り

登録が通った瞬間、Latin 文字がすべて Font Awesome の字形になった (OS のタイトルバー・タブまで)。register_custom_font が GLOBAL_VECTOR_FONT.set_primary() で文書全体の既定フォントを 差し替えていたため。

@font-face の登録は「この family でこのフォントが使える」と記録する だけであり、既定フォントを変える操作ではない。差し替えを撤去した。

残作業: family 指定で字形を選ぶ経路

描画側 draw_string_ja_size_ext_bg_ls_ws は GLOBAL_VECTOR_FONT しか見ず、 font-family を受け取らない。そのため登録しただけでは字形に反映されない。

これが Web フォント再現の残りの本体。

【2026-08-03】family 指定で字形を選ぶ経路を実装

実装

  1. RenderElement に font_family(カンマ区切りのまま保持)を追加。 従来は ::before 用の before_font_family しか無く、 本文の family がどこにも渡っていなかった。
  2. Screen::draw_string_family(..., family_list) を追加。 family リストを先頭から見て CUSTOM_FONTS に登録済みのものがあれば それで描き、無ければ従来の既定フォントへフォールバックする。
  3. グリフ描画の本体を draw_glyphs_with_font(font, ...) として切り出し、 フォントを呼び出し側が選べるようにした。

踏んだ罠: GLOBAL_VECTOR_FONT の二重ロック

切り出した本体の中に元の

let mut font_lock = GLOBAL_VECTOR_FONT.lock();
if let Some(font) = font_lock.as_mut() {

が残っており、呼び出し元が既にロックを保持したまま再度ロックして 再入不可の spin ロックで停止した。

[NET][DIAG] https_get ordinal=0 SUCCESS body_bytes=12597
[NET][DIAG] async_load: ASYNC_LOAD locked
(以降なし。2 回とも同じ地点で停止=ハーネスの不安定さではない)

同じ地点で再現したことで「自分の変更が原因」と切り分けられた。 引数のフォントを使うよう修正。

現状

理由: この実行で登録できたのは FontAwesome(8916 バイト)1 件だけ。 本文に使う Noto Sans JP(1 本 5.3MB、8 本取得済み)は、 取得は完了しているものの登録まで到達していない。

次はここ(大きい TTF の登録が間に合わない理由)を追う。

【2026-08-03】取得完了フラグの確認場所が循環していた

症状

フォント 8 本すべて取得成功、キャッシュ整合性も正常(保存直後に読める)、 それでもキャッシュ命中は 1 件だけで登録されない。

原因

ログの行番号を突き合わせると順序が見えた:

220行  @font-face 'Noto Sans JP' … 非同期取得へ投入   ← 最後のレイアウト
305行  [FONT] 背景取得完了 … bytes=5319680            ← 取得完了はこの後
407行  [FONT] 背景取得完了 … bytes=5327688

フォントが届いた後、レイアウトが一度も走っていなかった。

取得完了フラグ take_fonts_updated() を parse_and_layout の内側で 確認していたため、「レイアウトが動いていないと再レイアウトを起こせない」 という循環になっていた。

修正

確認を毎フレーム走る draw() の冒頭へ移した。

             再レイアウト  キャッシュ命中  登録
移設前        0 回          1 件            1 件
移設後        6 回          6 件            5 件(Noto Sans JP 含む)

[FONT] 登録完了 family=Noto Sans JP bytes=5319680 を確認。

残り: font_family が本文要素へ渡っていない

描画時にカスタムフォントが実際に使われたかを記録したところ:

[FONT] 描画に使用 family=font awesome 7 free

アイコンフォントだけ。noto sans jp は登録済みなのに使われていない。

原因: RenderElement の生成箇所は 12 箇所あるのに、 font_family を設定しているのは 2 箇所だけ。 本文テキストの要素は別の生成箇所で作られ、空のままだった。

(同種の見落としは過去にもあった。spec/paint_order.md で 「12 箇所のうち 5 箇所が ..Default::default() で z/depth を落としていた」 という形で記録されている。生成箇所が分散していること自体が罠。)

次はこの 12 箇所すべてに font_family を通す。

【2026-08-03】Web フォントが字形へ反映されるまで

最後の 2 つの穴を塞いで、取得から描画まで通った。

穴 1: font_family が本文要素へ渡っていなかった

RenderElement の生成箇所は 12 あるが、font_family を設定していたのは 2 箇所だけだった。テキストを持つ 5 箇所(display_text / fc_str / current_line×3)へ渡すよう修正。

生成箇所が 12 に分散していること自体が罠。同種の見落としは spec/paint_order.md にも記録がある(12 箇所のうち 5 箇所が ..Default::default() で z/depth を落としていた)。

穴 2: font-family が継承プロパティの一覧に無かった

cascade.rs の INHERITED_PROPS に font-family が入っていなかった。 CSS 2.1 §15.3 で font-family は継承プロパティである。

そのため body { font-family: 'Noto Sans JP', sans-serif } が子孫へ 伝わらず、テキスト要素の family が常に空だった。 取得・展開・再構築・登録がすべて成功しても、 描画で一度も選ばれなかったのはこれが原因。

実測

[FONT] 該当フォント無しで既定へ family="'Noto Sans JP',sans-serif"   ← 取得完了前
[FONT] 描画に使用 family=noto sans jp                                ← 取得完了後

スクリーンショットでも、ロゴ・ナビゲーション・見出しがすべて Noto Sans JP の字形に変わったことを確認した。

この一連で塞いだ穴(すべて実測で特定)

  1. Brotli 展開のアロケータスロット不足 → レイアウトスレッド停止
  2. WOFF2 のテーブルデータをタグ順に読んでいた → NoHeadTable
  3. set_primary で既定フォントを乗っ取っていた → 全文字がアイコン化
  4. @font-face 取り込みが CSS キャッシュミス時限定 → 永久に登録されない
  5. 連続失敗による打ち切り → Font Awesome が全スキップ
  6. 取得完了フラグの確認が parse_and_layout の内側 → 循環して発火しない
  7. font_family が 12 生成箇所のうち 2 箇所にしか無い
  8. font-family が継承プロパティ一覧に無い

8 つのうち 7 つは「失敗が無言だった」ために見えていなかった (spec/logging_policy.md)。

間欠的な試験失敗の正体(2026-08-04 特定)

test_net_tcp::tests::test_tcp_socket_table_exhaustion_and_reuse だった。

このテストは「ソケット表(32 件)が満杯のとき Err("tcp socket table full") を返す」ことを確かめるもので、 32 個の接続スレッドが同時にスロットを保持している状態を作る必要がある。 各スレッドは SYN タイムアウト(6 秒)で勝手にスロットを手放すため、 ホスト負荷が高いとスレッド起動が遅れ、全部揃う前に先行分が抜けて 満杯を観測できない。

テスト本体のコメントにも「フレーキー解消・その5」まで対策の履歴があり、 構造的に時間依存であることが分かる。

make test を cargo build と同じコマンド行に並べると ホスト負荷が上がって落ちやすくなる。単独で実行すること。

(対策案: 実接続を張らず、ソケット表の確保・解放だけを 純粋関数として切り出して試験する。時間依存が消える。)

間欠的な試験失敗について(追試の結果・当時)

make test が 6 回中 2 回 764 passed / 1 failed になったため追試した。

その後 13 回連続で 765 passed。失敗を再現できていない。

失敗した 2 回は、いずれも同じコマンド行で cargo build --release を 走らせた直後だった。ビルドと試験が同じ target/ を触る競合の可能性が高い。

したがって「テストが壊れている」証拠は無い。ただし 再現できていない以上「問題無し」とも言い切らない。 今後 make test は単独で実行し、ビルドと同じコマンド行に並べない。

現在の再現状況(2026-08-03 時点)

https://www.sugi-lab.net/ のファーストビューは実物とほぼ一致:

未確認: 下部セクション

SCREENSHOT_SCROLL でスクロールして撮ろうとしたが、scroll_y=0 のまま 動かなかった。browser.scroll() を送るキー入力がブラウザに吸われて ターミナルへ届いていないため(既知のキーコード配送の問題)。

レイアウト自体は全ページ分できている(elements=369)ので、 描画されていないわけではない。スクロールして確認する手段が無いだけ。

harness 側でスクロールを起こす別経路(例: ブラウザのビルトインを 起動時引数から呼ぶ)が要る。

【2026-08-03】キーボードスクロールを実装したが、検証はできていない

実装したもの(実機能の欠落を埋めた)

ブラウザが処理していたキーは Enter(0x1C) と Backspace(0x0E) の 2 つだけで、 矢印キーも PageUp/PageDown も Home/End も丸ごと未対応だった。 実ブラウザなら当然動く操作なので実装した (入力欄にフォーカスがあるときは文字入力を優先する)。

キー スキャンコード 動作
↑ / ↓ 0x48 / 0x50 60px
PageUp / PageDown 0x49 / 0x51 ビューポート 1 画面ぶん
Home / End 0x47 / 0x4F 先頭 / 末尾

検証できない理由: キーがアプリまで届かない

harness から QEMU モニタで pgdn を送っても scroll_y=0 のまま。 ブラウザの on_key に到達したキーコードを記録したところ、 1 件も出なかった([KEYDIAG] がゼロ)。

つまりブラウザ側の実装の問題ではなく、 QEMU モニタ→ゲスト→アプリのキー配送が成立していない。 シリアル経由(ESC + F1 + browser.scroll())も同様に届かない。

これは既知の「ブラウザ起動中に OS 側へキーコードが渡らない」問題と 同じ系統。入力経路そのものを直さないとキーによる検証はできない。

次にやるべきこと(キー入力に依存しない経路)

URL フラグメント(#id)でのスクロールを実装するのが筋。

harness を壊した件(反省)

screenshot_site.ps1 を Python の文字列 index で切り貼りしたところ、 別の箇所と重なってスクリプト構造を破壊した (ParserError: MissingCatchOrFinally)。 git diff で自分の変更が 40 行の追加のみと確認したうえで git checkout して復旧し、Edit ツールで厳密一致の置換をやり直した。

スクリプトの編集は index 切り貼りではなく厳密一致の置換で行うこと。

【2026-08-03】URL フラグメント(#id)の対応

直したもの

  1. フラグメントを HTTP 要求に載せていた

    resolve_url_parts / load_page_with_method が # を含むパスを そのままサーバへ送っていた。フラグメントはサーバへ送るものではない。 分離して保持するようにした。

  2. 初回ロード時のフラグメントが無視されていた

    navigate_to_fragment 自体は実装済みだったが、呼ばれるのは JS の location 変更経路だけで、URL に #id を付けて開いても 何も起きなかった。レイアウトで要素が出揃った時点で適用するようにした。

    対象 id がまだ現れていない場合は保持したままにして次のレイアウトで 再挑戦する(実測で 1 回目は要素 32 個のプレースホルダで見つからず、 2 回目に発火した)。

[NAV] フラグメントの対象がまだ見つからない #research elements=32
[NAV] フラグメントへスクロール #research

まだ画面に反映されていない

navigate_to_fragment は発火しているが、スクリーンショットは先頭のまま。 このサイトは scroll-behavior: smooth を使うため、 navigate_to_fragment は即座にジャンプせずアニメーション経路へ入る。 そこが完走していない可能性がある(未確認・断定しない)。

次はスムーススクロールの駆動を確認する。 scroll_anim_active と tick_scroll_anim が毎フレーム進んでいるか。

アンカースクロールが直後に戻されていた

navigate_to_fragment は発火しているのに画面は先頭のまま、という状態の原因。

navigate_to_fragment は :target 疑似クラスの再判定のため dom_needs_rebuild = true を立てる。ところが次のレイアウトで

let is_new_page = self.dom_needs_rebuild || self.last_html != html;
...
if is_new_page { self.scroll_y = 0; }

となり、アンカーへスクロールした直後に先頭へ引き戻されていた。

dom_needs_rebuild は「同じ文書の作り直し」(:target 変化・フォント到着・ JS 変更)でも立つ。スクロールを捨てて良いのは文書そのものが変わったとき だけなので、判定を self.last_html != html に分けた。

修正後、https://www.sugi-lab.net/#research で 「主な研究テーマ」まで実際にスクロールすることを確認した。

下部セクションの描画不具合(新規に見えた課題)

アンカースクロールで下部を確認できるようになった結果、 ファーストビュー以外に不具合があることが分かった。

#research の位置(「主な研究テーマ」)で:

ファーストビューは実物とほぼ一致しているので、 下部セクション固有の問題。次はここを切り分ける。

【2026-08-03】下部セクションの不具合を実測で絞り込んだ

アンカースクロールで #research を表示し、 その時点で画面に写っているはずの要素を全部出した([VIS])。

[VIS] scroll_y=1269 表示範囲 1269..1838 elements=369
[VIS] #92  y=1349 h=51  x=40  w=844 op=255 bg=Some(0xE5FFFFFF) r=(10,10,10,10)
[VIS] #93  y=1349 h=51  x=350 w=224 op=255 fs=32 t="主な研究テーマ"
[VIS] #94  y=1392 h=43  x=310 w=304 op=255 bg=Some(0xCCFFFFFF) r=(5,5,5,5)
[VIS] #95  y=1416 h=27  x=56  w=272 op=255 fs=17 t="最先端技術で未来の生活をデザイン"
[VIS] #96  y=1509 h=319 x=40  w=342 op=255 bg=Some(0xFFFFFFFF) r=(15,15,15,15)  ← カード
[VIS] #100 y=1627 h=38  x=91  w=240 op=255 fs=24 t="スマートホーム・HEMS"
[VIS] #102 y=1697 h=25  x=80  w=272 op=255 fs=16 t="IoT、情報家電やWeb連携によるスマー"
[VIS] #105 y=1763 h=25  x=80  w=32  op=255 fs=16 t="詳細"

分かったこと

  1. カードの中身はレイアウトされている。見出し・本文・「詳細」まで すべて要素として存在し、座標も表示範囲内にある。

  2. 不透明度も 255。CSS アニメーションの初期値 opacity:0 で 隠れているわけではない(IntersectionObserver も実装済みだった)。

  3. それでも画面には出ていない。

    画面上の位置は 1509 - 1269 + 40 = 280。実際のスクリーンショットの その位置は空白。

  4. #95 までは描かれ、#96 以降が出ていない (#92 / #94 の半透明白ボックスは実際に画面に見えている)。

つまり

「レイアウトされていない」でも「透明で見えない」でもなく、 ある要素を境に描画されなくなっている。

考えられる先: 要素ループ内のクリップ(set_clip)が解除されずに 以降の描画を捨てている、あるいは scroll_regions の判定で 可視外と誤判定している。まだ確認していないので断定しない。

もう 1 つの不具合

#92(w=844 h=51)と #94(w=304 h=43)の半透明白ボックスは 実サイトに無い。見出しと小見出しに重なって線のように見える。 ::before/::after の装飾(下線バー等)が 親のボックスサイズを継いでしまっている可能性がある。こちらも未確認。

カードが見えない理由: セクション背景が付いていない

除外理由を記録したところ、カードは描画対象に到達していた。

[CULL] #96 描画対象 el_y=280 h=319     ← カード。除外されていない
[CULL] #100 描画対象 el_y=398 h=38     ← 「スマートホーム・HEMS」

つまり「クリップで捨てられている」わけではない。

一方、要素の色を見ると:

[VIS] #89 y=1269 h=990 x=0 w=924 bg=None            ← 研究セクション。背景なし
[VIS] #96 y=1509 h=319 x=40 w=342 bg=Some(0xFFFFFFFF) r=(15,15,15,15)  ← 白いカード
[VIS] #2  y=0    h=6944         bg=Some(0xFFFFFFFF) op=0               ← body(不透明度 0)

実サイトではこのセクションに薄いグレーの背景があり、 その上に白いカードが乗るので輪郭が見える。 こちらはセクション背景が None のため、白いカードが白地に埋もれている。

次に調べること: - そのセクションに効くはずの background-color が なぜ None のままなのか(カスケードで拾えていないのか、 background ショートハンドの解釈か) - #2(body)の op=0 も要確認。不透明度 0 の白い全面ボックスが 何を意図したものか

box-shadow が rgba() の中のカンマで分割されていた(修正済み)

実サイトの CSS を確認した:

.research-card { background:#fff; padding:2.5rem; border-radius:15px; box-shadow: var(--shadow); }
:root { --shadow: 0 2px 10px rgba(0,0,0,0.1); }
section:nth-child(odd) { background: var(--bg-color); }  /* #ffffff */

つまり研究セクションの背景は本来も白。 「薄いグレーのはず」という私の推測は誤りだった(実物を見て訂正)。 白いカードを見せているのは box-shadow。

実測すると影の数がおかしかった:

[VIS] #96 ... shadows=4 bg=Some(0xFFFFFFFF) r=(15,15,15,15)

CSS の影は 1 個なのに 4 個。v.split(',') が rgba(0, 0, 0, 0.1) の中のカンマでも切っていた。 グラデーションで踏んだのと同じ罠。

修正: 括弧の深さを見る既存ヘルパ css::values::split_top_level_commas を使う。 shadows=4 → shadows=1 を確認。

まだ解決していない: カードが描画されない

影の数を直してもカードは画面に出ない。

白地に白だから見えない、では説明がつかない (文字は濃色、影もある)。 要素ループの可視判定を通った後、実際の塗りに至っていない。

次に見る場所: 可視判定と塗りの間にある分岐 (clip_s の生成、rotate/skew 経路、bg_images 経路など)。 #95 までは描かれ #96 以降が出ない、という境界の意味を調べる。

切り分けの結果: 塗りは正常、残るのは「カードの文字が出ない」

#92(画面に見える)と #96(見えない)の属性を突き合わせた:

#92 op=255 shadows=0 rot=0 sx=1 clip="" blend="" bg=0xE5FFFFFF r=(10,..)
#96 op=255 shadows=1 rot=0 sx=1 clip="" blend="" bg=0xFFFFFFFF r=(15,..)

関係する属性はすべて同一。違いは背景の不透明度と大きさだけで、 どちらも白。白地のページ上なので塗り自体は元々見えない。 画面に見えていた枠線は #92 の塗りではなく #91 の border だった。

([FILL] ログが #92 でも出なかったことから、 角丸付き背景は最終の boxfill ではなく 角丸専用の毎ピクセル経路を通っていると分かった。)

つまり「カードが描かれない」ではなく、 カード内のテキストが出ていないことが残る問題。

#100 fs=24 t="スマートホーム・HEMS" は要素として存在し、 op=255、座標も画面内(y=398)。それでも見えない。

次に確認すること: テキストの色。 [VIS] に color を含めていなかった。白文字になっていれば 白地に白で見えない説明がつく(ヒーローの白文字が 背景写真の上では見えていたのと同じ経路)。

テキスト色は正常だった(白文字説を棄却)

[VIS] に色を追加して確認:

#93  y=1349 fs=32 col=0x2c3e50 t="主な研究テーマ"          ← 画面に見える
#100 y=1627 fs=24 col=0x2c3e50 t="スマートホーム・HEMS"     ← 見えない
#102 y=1697 fs=16 col=0x2c3e50 t="IoT、情報家電や…"        ← 見えない
#105 y=1763 fs=16 col=0x3498db t="詳細"                    ← 見えない

同じ色(濃紺 0x2c3e50)で、片方は見え、片方は見えない。 白文字だから見えない、という説は棄却。

違いは画面上の Y 座標だけ: - #93 → 画面 y = 1349 - 1269 + 40 = 120(見える) - #100 → 画面 y = 1627 - 1269 + 40 = 398(見えない)

調べてまだ当たっていない箇所

「画面下側だけ出ない」という形なので、 垂直方向のクリップかオフセットの取り違えが残っている可能性が高い。 ただし特定できていないので断定しない。

次の一手: 塗り/文字描画の直前で screen の現在のクリップ矩形を 1 回出す。要素側ではなく描画先の状態を見る。

【2026-08-04】静的解析へ切り替えた

ここまで「この要素は画面に出るはずなのに出ない」を、 ビルド(約 10 分)+ QEMU 実行(約 6 分)を繰り返して調べていた。 座標変換も可視判定も純粋な計算で、実行して確かめる対象ではなかった。

web_engine/paint_cull.rs(純粋・単体試験 17 件)へ切り出し、 実測値をそのまま試験に入れた。これで以下をビルドせずに確定できた:

#93「主な研究テーマ」      y_offset=1349 → el_y=120 → draw_y=151(見える)
#100「スマートホーム・HEMS」y_offset=1627 → el_y=398 → draw_y=429(見えない)

どちらも is_in_viewport / is_in_clip が true。 可視判定でもクリップでもない。

静的に潰した候補

候補 判定 根拠
可視判定 除外 paint_cull の単体試験
クリップ矩形 除外 同上。set_clip(app_x, app_y, w, h) も座標系として正しい
描画順(背景が文字を上書き) 除外 paint_depth は生成 12 箇所すべてで設定済み
文字色(白地に白) 除外 実測 col=0x2c3e50。見える見出しと同色
不透明度 除外 op=255
transform / clip-path / mix-blend-mode 除外 すべて既定値・空

直したもの(別件だが実バグ)

VectorFont::new は fallbacks が空。 @font-face で登録したフォントには代替フォントが一つも無かった。 draw_string_family がそのフォントで全文字を描くため、 字形が無い文字(Font Awesome に日本語は無い)は何も出ない。 実ブラウザは字形ごとに次のフォントへ落とす。

内蔵フォントチェーン(DEFAULT_FONT_CHAIN)を代替として与えるよう修正。 ただしカード文字が出ない症状はこれでは直らなかった(別原因)。

残る問題

カード内テキストが出ない理由は未特定。 静的に潰せる候補は上表で尽きたので、次は 実際に文字を描く直前の状態(draw_string_family に渡る 座標・文字列・family)を 1 回だけ記録して切り分ける。 ただしそこも純粋関数へ切り出せるなら先に切り出す。

【2026-08-04】分析手法の誤り(自己訂正)

「カード内テキストが出ない」を追って、静的に候補を潰していった:

候補 判定 根拠
可視判定 除外 paint_cull::is_in_viewport の単体試験
クリップ 除外 paint_cull::is_in_clip の単体試験
座標変換 除外 viewport_y / buffer_y の単体試験
transform: scale で潰れる 除外 effective_size / is_degenerate
描画順(深さ) 除外 paint_depth は生成 12 箇所すべてで設定
描画順(z) 除外 z_index も 12 箇所すべてで設定。
inherited_paint_z は再帰の前後で保存・復元されている
文字色 除外 実測 col=0x2c3e50。見える見出しと同色
不透明度 除外 op=255

すべて除外された。そこで前提を疑った。

前提が壊れていた

[VIS] のダンプは「スクロール後の最初のフレーム」で 1 回だけ出す。 スクリーンショットはその数十秒後に撮っている。

その間にフォント到着による再レイアウトが複数回走っており、 要素の y_offset は変わっている。

つまり「y_offset=1627 の要素が画面に出ない」という前提は、 時刻の違う 2 つの証拠を突き合わせたもので、成り立っていない。

やり直しの方針

同一フレームの情報だけで判断すること。具体的には スクリーンショットを撮る直前のフレームで要素をダンプする。

ダンプの発火条件を「スクロール後の初回」ではなく 「撮影直前」に変える必要がある。 harness が撮影する時刻を OS 側へ伝える手段が無いので、 SCREENSHOT_WAIT 相当の時刻を過ぎたら 1 回出す、 あるいは一定間隔で出し続けて最後のものを読むのが簡単。

訂正: 位置は撮影時まで安定していた

「時刻の違う証拠を突き合わせた」と書いたが、その訂正自体が誤りだった。

ダンプを一定間隔で出し続け、撮影直前の最後のダンプを読んだところ:

[VIS] scroll_y=1269 表示範囲 1269..1838 elements=369
[VIS] #111 y=1627 h=38 x=545 fs=24 op=255 col=0x2c3e50 t="AI・データマイニング"
[VIS] #113 y=1697 h=25 x=537 fs=16 op=255 col=0x2c3e50 t="時系列データから知識を発見する人"

スクロール位置も要素座標も最初のダンプと同一。位置は変わっていない。

除外判定はすべて通過している

要素ループの除外は 2 箇所ある。両方を計装した:

要素は正しい座標・色・不透明度で描画処理に到達している。 それでも画面に出ない。

残る唯一の説明

描いた後に上書きされている。

ダンプに z_index と paint_depth を入れていなかった。 これが最後の未確認点。両方とも生成 12 箇所すべてで設定されており inherited_paint_z も再帰前後で保存・復元されているが、 実際の値は見ていない。

次: [VIS] に z と depth を追加し、 カード(#96)とその文字(#100)の描画順を確認する。

決定的なパターン: z=5 の要素が一つも描かれない

[VIS] に z_index / paint_depth / ソートキーを追加したところ、 きれいに分かれた:

要素 z depth 画面 y 結果
#92 見出し帯(半透明白) 10 6 120 見える
#94 小見出し帯(半透明白) 10 6 163 見える
#93「主な研究テーマ」 10 7 120 見える
#96 カード(白・角丸15) 5 6 280 見えない
#97 アイコン円(青 60×60) 5 7 320 見えない
#100「スマートホーム・HEMS」 5 8 398 見えない
#107 カード 2(白) 5 6 280 見えない

z=10 は描かれ、z=5 は一つも描かれていない。

青い円(bg=0xFF3498DB、不透明)まで消えているので、 テキスト固有でも白地に白でもない。

覆っている要素は無い

z=5 の後に塗られる z=10 の要素で、カード領域 (画面 y 280..599)を覆うものは無い:

つまり

z=5 の要素だけが塗られていない。 除外判定は 2 箇所とも通過している([CULL] / [CULL2] で確認済み)。

次に見る場所: ソート後のループで z の値によって分岐している箇所、 または z_index が描画側で別の意味に使われていないか (例: z_index を「スタッキングコンテキストの有無」として 解釈して別経路へ回している、など)。

実クリップ値まで確認しても矛盾が残る

[CULL] を周期出力にして撮影直前の状態を読んだ:

[CULL] #96 描画対象 el_y=280 h=319 sub_scroll=0
       clip=Some((100, 31, 1024, 600)) win_y=31 win_h=569

除外判定・クリップ・座標のすべてが「描かれるはず」を示している。 それでも画面には出ない。

決定的な対比: スクロールの有無

条件 画面 y≈444 の要素 結果
scroll_y=0(ヒーロー) ボタン 描かれる
scroll_y=1269(#research) カード 描かれない

同じ画面座標でもスクロール後だけ描かれない。 要素の属性ではなく、スクロール状態そのものが条件。

ここまでで要素側の要因は静的にすべて潰した。 次に疑うべきはコンポジタ側:

スクロール後の再描画がコンポジタまで届いているかを、 ウィンドウバッファと画面のどちらの問題かを分けて確認する。

ピクセル読み返しで「描かれていない」ことが確定

描画後にウィンドウバッファを読み返した:

[READBACK] (210,382) = 0xfff8f9fa  期待=0xff3498db(アイコン円)

(210,382) は #97 のアイコン円(不透明な青 0xFF3498DB、60×60)の中心。 読めた値はページ背景色 #f8f9fa。

つまり カードも青い円も一切描かれていない (描かれていれば白 0xFFFFFFFF か青になる)。 「描けているが画面に届かない」ではなく、バッファにも描かれていない。

#50 が覆っている説は外れ

[POS] #50 y_offset=529 el_y=-700 h=740 fixed=false sticky=false bound=2147483647

el_y=-700 で正しく画面外。sticky でも fixed でもない。 (el_y が負のため draw_y は u32 へ折り返すが、 boxfill は y0 > y1 で空ループになるだけ。paint_cull の試験で確認済み。)

状況の要約

要素 #96/#97 について、以下がすべて「描かれるはず」を示している:

それでもバッファに 1 ピクセルも書かれていない。

[CULL] のログは要素ループの中で出ているので、 ループには到達している。その後の塗り処理へ至る間に まだ計装していない分岐がある。

要素種別の分岐も通常経路だった

[CULL] #96 ... kind(hr=false img=false video=false input_kind=0
                    input=false textarea=false button=false)

is_hr / is_img / is_video / 各種 input / is_button のいずれでもない。 通常要素の描画経路に入っている。

背景色の決定も、hover/active/focus のいずれでも el.bg_color(= 白)へフォールバックする作りなので None にはならない。

残るのは actual_bg_color の最後に掛かる apply_css_filters_argb(&el.filter, c) のみ。 .research-card に filter は無いはずだが、未確認。

ここまでで確定していること(すべて実測または単体試験)

項目 状態
ビューポート除外 通過([CULL] が出る)
overflow:hidden 除外 通過([CULL2] が出ない)
クリップ矩形 内側((100,31,1024,600)、描画位置 311..630)
入れ子スクロール sub_scroll=0
不透明度 255
文字色 0x2c3e50(見える見出しと同色)
描画順 z=5。覆う要素は無い(#50 は el_y=-700 で画面外)
要素種別 すべて false(通常経路)
背景色 Some(0xFFFFFFFF)、hover 等でも bg_color へフォールバック
バッファの実際の値 ページ背景色のまま=1 ピクセルも書かれていない

次に見るのは apply_css_filters_argb と、 その後の実際の塗り分岐(角丸경路 / boxfill)の到達可否。

【2026-08-04】問題は描画プリミティブ層まで絞れた

塗り関数への到達と引数を記録した:

[RRFILL] #96 到達 rect=(140,311)-(482,630) r=(15,15,15,15)
         color=0xffffffff rotate=0

draw_rounded_rect_fill は正しい引数で呼ばれている。 その中で boxfill(140, 326, 482, 615) を白で実行するはず (top_max_r + bot_max_r = 30 <= h = 319 なので中央帯を塗る経路)。

読み返し点 (210,382) はその矩形の内側。クリップ (100,31,1024,600) の内側。

それでも読み返すとページ背景色のまま。

つまり

問題は boxfill / draw_pixel の内部か、描画先(render target)の切り替えにある。

次に確認すること

  1. boxfill 内で実際に書き込みループへ入っているか (x0/y0/x1/y1 を u32 へ変換した後の値、クリップ判定の結果)
  2. resolve_target() が返すバッファが、読み返し時と同じか (フレーム内で描画先が切り替わっていないか)

ここは純粋関数へ切り出しにくい(Cell と生ポインタを触る)が、 クリップ判定と座標変換は切り出せる。先に切り出して試験にする。

read_pixel と boxfill は同じ描画先を使う

pub fn read_pixel(&self, x, y) -> Color {
    let (target_ptr, w, h) = self.resolve_target();   // boxfill と同じ
    let stride = self.resolve_stride();               // boxfill と同じ

したがって「書いたのに読めない」は起こり得ない。 読めた 0xFFF8F9FA は、コンポジタがアプリ描画前に塗る theme.app_bg の可能性が高い。つまり その画素にブラウザが何も描いていない。

残る唯一の未確認点

boxfill の内部で書き込みループに入っているか。

let (target_ptr, w, h) = self.resolve_target();
if cx0 >= w || cy0 >= h { return; }     // ← ここで返っていないか

(140, 326) に対して w=1024, h=600 なら通過するはず。 resolve_target() が別の値を返していれば早期 return する。

次はここに 1 行だけ記録を入れて確定させる。 これが最後の 1 点(レイアウト → 要素判定 → 描画分岐 → 塗り関数の引数 → プリミティブ内部、と順に潰してきた)。

最終確認: boxfill は書き込みを実行している

[BOXFILL] rect=(140,326)-(482,615) target_w=1024 target_h=600 早期return=false

早期 return せず、描画先 1024×600 に対して y 326..600 / x 140..482 を白で塗る経路に入っている。

一方、同じ resolve_target() / resolve_stride() を使う read_pixel は:

[READBACK] (210,382) = 0xfff8f9fa

書き込みが実行されているのに読み返すと背景色、という矛盾が残る。

考えられる残りの説明(未検証・断定しない)

  1. [BOXFILL] と [READBACK] が別フレームの可能性 (どちらも 20 回に 1 回の間引き出力で、カウンタが独立している)
  2. 塗った後、同一フレーム内で別の要素が上書きしている (ソート順で #96 より後に来る要素を全部は追えていない)
  3. resolve_stride() が書き込み時と読み返し時で異なる

次にやるべきこと

間引きをやめて同一フレームで突き合わせる。 [BOXFILL] と [READBACK] を同じフレーム番号でタグ付けし、 「このフレームで塗った直後に読むと何が入っているか」を見る。

塗った直後に読んで白なら「後で上書きされている」、 塗った直後に読んで背景色なら「書き込みが効いていない」。 この 1 回で分岐が確定する。

【2026-08-04】決着: カードは描かれた後に上書きされていた

塗った直後と、フレーム末尾で同じ画素を読み比べた:

[AFTERFILL] (210,382) = 0xffffffff  ← 塗った直後(白=カードは描かれている)
[READBACK]  (210,382) = 0xfff8f9fa  ← フレーム末尾(背景色へ戻っている)

「描かれない」のではなく「描いた後に上書きされる」だった。

ここまで 「レイアウト → 要素判定 → 描画分岐 → 塗り関数の引数 → プリミティブ内部」 と順に潰してきたが、前提そのもの(描かれていない)が誤りだった。

上書きしている色

0xFFF8F9FA は CSS の --light-bg: #f8f9fa。 要素ダンプでこの色を持つのは #50(y=529 h=740 x=0 w=924 z=10 d=3)。

#50 は z=10 なので、カード(z=5)より後に塗られる。 描画順としては上書きし得る位置にいる。

ただし #50 の el_y は -700(画面外)で、 素直に計算すれば画面には出ないはず:

[POS] #50 y_offset=529 el_y=-700 h=740 fixed=false sticky=false

draw_y = win_y + el_y as u32 で u32 へ折り返すため、 y0_rect = -669、y1_rect = 71 となる。 - boxfill 経路なら cy0 が巨大な u32 で早期 return(塗らない) - 毎ピクセル経路(opacity<255 / clip-path / 角丸)なら for ry in 0..eff_h で y0_rect + ry を回すため、 py が 0..71 の範囲だけ実際に描かれる

いずれにせよ y=382 には届かないはずで、まだ説明がつかない。

次にやること

(210,382) にどの要素が書いているかを直接特定する。 draw_pixel でその座標に書く瞬間の色を記録すれば、 どの要素が上書きしているか一発で分かる。

教訓: 「描かれていない」と決めつけて描画経路ばかり追ったが、 塗った直後に読むという一手で最初に切り分けるべきだった。

上書きは boxfill の一括書き込み経路で起きている

draw_pixel に計装して (210,382) への書き込みを記録した:

[PIXWRITE] (210,382) <- 0xffaccbe0 by element #35
[PIXWRITE] (210,382) <- 0x33000000 by element #36
[PIXWRITE] (210,382) <- 0xff7f7f7f by element #96
[PIXWRITE] (210,382) <- 0xff2c3e50 by element #97

最終的に残る 0xfff8f9fa を書いた記録が無い。

理由: boxfill は不透明色(アルファ 0xFF)のとき draw_pixel を経由せず一括書き込み経路を使う。 半透明のときだけ 1 ピクセルずつ draw_pixel へ委譲する (spec/alpha_compositing.md)。

つまり上書きは一括書き込み経路で起きており、 draw_pixel の計装には写らない。

次にやること

boxfill の一括書き込み経路に、対象画素を含む矩形を塗るときだけ 記録を入れる。CURRENT_EI と併せれば、 どの要素が最後に上書きしているかが確定する。

教訓(この調査全体から)

【2026-08-04】上書き元を特定: 要素 #50 が全画面を塗り潰していた

boxfill の一括経路に計装して、(210,382) を含む塗りを全部記録した:

[BOXWRITE] rect=(140,326)-(482,615) color=0xffffffff element=#96  ← カードの白
[BOXWRITE] rect=(100,0)-(1024,740)  color=0xfff8f9fa element=#50  ← 直後に全面を塗る

要素 #50 が y=0..740 を #f8f9fa で塗り潰している。

#50 は

[VIS] #50 y=529 h=740 x=0 w=924 z=10 d=3 key=(10,3) bg=Some(0xFFF8F9FA)
[POS] #50 y_offset=529 el_y=-700 h=740 fixed=false sticky=false

で、スクロール後は el_y=-700(画面より上)。画面に出てはいけない。

にもかかわらず y0=0 から高さ 740 ぶん塗っている。 上端が 0 側へ落ちる一方で高さがそのままのため、 画面外の要素が可視領域を丸ごと覆う。

z=10 なのでカード(z=5)より後に塗られ、 見出し(z=10 で depth がより深い)はさらに後なので残る。 「z=5 が消えて z=10 が見える」という観測と完全に一致する。

重要な注意: この上書きは別フレームの可能性

矩形 (100,0)-(1024,740) から逆算すると:

y0 = draw_y = win_y + el_y as u32 = 0
  → el_y = -31
  → el_y = y_offset - scroll_y + top_offset = 529 - scroll_y + 40 = -31
  → scroll_y = 600

scroll_y=600 のフレームでの塗り。 [VIS] が示す最終状態は scroll_y=1269 なので、別フレーム。

このサイトは scroll-behavior: smooth を使うため、 アンカースクロールは 0 → 1269 へアニメーションする。 その途中(scroll_y=600 付近)のフレームを捉えた可能性が高い。

scroll_y=1269 の最終フレームでは el_y=-700 となり、 - 平坦な boxfill 経路 → cy0 が巨大な u32 で早期 return(塗らない) - 毎ピクセル経路 → py が 0..71 の範囲だけ描かれる

いずれにせよ y=382 には届かない。

したがって「#50 が最終フレームでカードを覆っている」とはまだ言えない。 [BOXWRITE] は 60 件で打ち切っており、 捉えたのがスクロール途中のフレームである可能性が高い。

次: [BOXWRITE] に scroll_y を併記し、 最終フレーム(scroll_y=1269)で誰が上書きしているかを確定させる。

なぜ y0=0 になるのか(scroll_y=600 のフレームでは説明がつく)

draw_y = win_y + el_y as u32 は el_y=-700 で巨大な u32 へ折り返すはずで、 その場合 boxfill は cy0 >= h で早期 return する(塗らない)。 実際には y0=0 が渡っているので、 どこかで上端が 0 側へ丸められている。

次: #50 がどの塗り経路を通っているかを特定し、 その経路の座標計算を確認する。 y1 = draw_y + el.height as u32 形式の呼び出しが複数あり、 そのいずれかが該当する可能性が高い。

修正方針: 画面外へはみ出す要素は、上端を丸めるなら 高さも同じだけ減らす。paint_cull に純粋関数として切り出し、 単体試験で固定する。

【2026-08-04】根本原因: transform-origin 補正の無条件クランプ

確定した証拠

boxfill の一括経路に scroll_y と要素番号を付けて記録し、 最終スクロール状態(scroll_y=1269)で読んだ:

[BOXWRITE] scroll_y=1269 rect=(140,326)-(482,615) color=0xffffffff element=#96
[BOXWRITE] scroll_y=1269 rect=(100,0)-(1024,740)  color=0xfff8f9fa element=#50

カード(#96)が白く塗られた直後に #50 が y=0..740 を塗って覆っていた。

原因

let draw_y = ((draw_y as i32)
    + ((el.height - eff_h) as f32 * el.transform_origin_y) as i32)
    .max(0) as u32;      // ← 無条件クランプ

draw_y は win_y + el_y as u32 で作られ、画面より上へスクロールアウト した要素では el_y が負なので u32 へ折り返している(as i32 は負)。 そこへ .max(0) を掛けると 0 に固定され、高さはそのままなので y=0..height を塗る。

#50 は y_offset=529 h=740、スクロール後 el_y=-700。 本来は画面外で何も描かないはずが、boxfill(100, 0, 1024, 740) を実行し 可視領域を丸ごと覆っていた。z=10 なので研究カード(z=5)より後に 塗られ、カードが消える。見出し(z=10 でより深い)は #50 より後なので残る。

「z=5 が消えて z=10 が見える」という観測と完全に一致する。

修正

補正が要るのは transform: scale() で寸法が変わったときだけ。 補正量が 0 なら元の値(折り返し済み u32)をそのまま使う。 折り返した値は boxfill の cy0 >= h で早期 return され、 画面外の要素は正しく描かれない。

純粋関数 paint_cull::apply_origin_adjust として切り出し、 単体試験 7 件で固定した(make test 800 件パス)。 うち 1 件はこの事象そのものを再現する:

let dy = draw_y_as_impl(31, -700);           // 折り返した巨大な u32
let out = apply_origin_adjust(dy, 740, 740, 0.5);
assert_eq!(out, dy);                          // 0 に固定しない

この調査の教訓

  1. 「描かれない」と決めつけない — 塗った直後に読む一手で 「上書きされている」と即座に分かった
  2. 計装は経路ごとに漏れる — boxfill は不透明時に draw_pixel を 通らない。片方だけ見て「誰も書いていない」と結論しかけた
  3. フレームを跨いだ証拠を突き合わせない — scroll_y を併記して 初めて最終状態の上書きだと確定できた
  4. 診断用のグローバルは必ず後始末する — CURRENT_EI を ループ後に戻さず、ループ外の塗りが直前の要素番号を名乗っていた

【2026-08-04】見出しの枠は仕様どおりだった(幅だけが誤り)

「実サイトに無い角丸枠が重なっている」と書いたが、誤りだった。 CSS を確認すると枠は仕様どおり存在する:

.section-header h2 {
    background: rgba(255,255,255,.9);   /* = 0xE5FFFFFF ✓ 一致 */
    padding: .5rem 1rem;
    border-radius: 10px;                 /* = r(10,10,10,10) ✓ 一致 */
    display: inline-block
}
.section-header p {
    background: rgba(255,255,255,.8);   /* = 0xCCFFFFFF ✓ 一致 */
    border-radius: 5px;                  /* = r(5,5,5,5) ✓ 一致 */
    display: inline-block
}

色も角丸も完全に一致している。間違っているのは幅だけ。

要素 期待(shrink-to-fit) 実測
h2 の箱 文字 224 + padding 32 = 256 844(全幅)
p の箱 文字 272 + padding 32 = 304 304 ✓

display: inline-block は内容幅へ縮むべきだが、 h2 だけ縮まず全幅になっている(p は正しく縮んでいる)。

同じ display: inline-block で片方だけ縮まないので、 h2 の UA 既定(display: block)との相互作用が疑わしい。 ただし背景と角丸は当たっているので、 ルール自体はマッチしている(幅計算だけの問題)。

次: display: inline-block の shrink-to-fit を h2 で確認する。純粋なレイアウト計算なので単体試験にできる。

未解決の矛盾: 同じルールから背景は効いて display は効かない

.section-header h2 { background: rgba(255,255,255,.9); border-radius: 10px; display: inline-block } について:

同じルールの宣言なのに一部だけ効かない。 .section-header p の方は display: inline-block が効いて 正しく縮んでいる(幅 304 = 文字 272 + padding 32)。

resolved_display は node.value("display") を見るだけで、 UA 既定(h2 { display: block })は著者ルールより先に specified_values へ入るので上書きされるはず。

次に確認すること: - カスケード後の h2 の display の実際の値 - 同じシートに h2 { display: block } 相当の後続ルールが無いか - p と h2 で扱いが分かれる箇所(UA 既定は両方 block を入れている)

純粋なカスケードの問題なので、単体試験で再現できるはず。 実行環境を回さずに切り分ける。

決着: エンジンは正しく、私の判断が誤りだった

display: inline-block が効いていないと考えたが、実サイトの CSS 自体に 後続ルールがあった:

.section-header h2 { ... display: inline-block }   /* 235 行目 */
.section-header h2 { ... display: flex ... }        /* 1429 行目・後勝ち */

正しい算出値は flex。flex はブロックレベルなので 箱が全幅になるのが正しい挙動。

p の方は後続ルールが color / font-size しか指定していないので inline-block が残り、縮む。

実測(h2 だけ全幅・p は縮む)と完全に一致していた。

「実サイトに無い枠が重なっている」という当初の判断は 2 度とも誤りだった(枠の存在も、幅の挙動も、CSS どおり)。

単体試験で確定させた

tests/src/test_sugi_lab_cascade.rs に 4 件追加した。 実サイトの本物の CSS/HTML を使い、QEMU もネットワークも使わない:

カスケードの後勝ちを固定する回帰試験になった。 これ以降、同種の疑いは make test 30 秒で判定できる。

【2026-08-05】Font Awesome アイコンが ☐ になる件

静的試験で 3 点を確定(QEMU 不要)

tests/src/test_sugi_lab_cascade.rs に追加:

確認項目 結果
.fas に Font Awesome の font-family が当たる OK
::before の content が存在する OK
content が私用領域の文字へ変換されている(\f02d → U+F02D) OK

カスケードは完全に正しい。

残る疑い

登録された FA フォントが、その符号位置の字形を持っていない。

考えられる原因: - WOFF2 のテーブル変換(glyf/loca transform)に未対応で、 再構築した SFNT の cmap / glyf が壊れている (フォント自体はパースに成功して登録されるが、 glyph_index() が None を返す) - 同名ファミリを複数回登録して後勝ちで別ウェイトに置き換わっている (.fas は font-weight:900(solid)だが、 fa-regular-400 が後から同じ family 名で登録されると solid の字形が失われる)

次にやること

register_custom_font の直後に、代表的な符号位置 (例 U+F02D = fa-book)の glyph_index() を引いて記録する。 None なら字形が無いと確定でき、 cmap 再構築か、ウェイト別の登録かに絞れる。

カスケード側はもう疑わなくてよい(試験で固定した)。

字形の実測で 2 つの問題が判明

登録直後に符号位置を引いて記録した:

family=Font Awesome 7 Free bytes=48104   字形F02D=true 字形F015=true
family=Font Awesome 5 Free bytes=314740  字形F02D=true 字形F015=true
family=FontAwesome         bytes=314740  字形F02D=true 字形F015=true
family=Noto Sans JP        bytes=5319680 字形F02D=true 字形F015=true   ← ここが鍵

問題 1: 同名ファミリの後勝ちでウェイトが入れ替わる

Font Awesome 7 Free に入っているのは 48104 バイト = regular(400)。 solid は 314740 バイトで、Font Awesome 5 Free の方に入っている。

CSS は同じ family 名でウェイト違いを複数の @font-face として定義する:

@font-face { font-family:"Font Awesome 7 Free"; font-weight:400; src:fa-regular-400.woff2 }
@font-face { font-family:"Font Awesome 7 Free"; font-weight:900; src:fa-solid-900.woff2 }

CUSTOM_FONTS は family 名だけをキーにしているので後勝ちで上書きされる。 .fas(font-weight:900)が要求する solid が失われる。

直し方: キーを (family, weight) にし、 描画時は要素の font-weight に最も近いものを選ぶ(CSS のフォントマッチング)。

問題 2: 内蔵フォールバックが私用領域に箱の字形を返す

Noto Sans JP でも 字形F02D=true になっている。 これは 2026-08-04 に追加した内蔵フォールバックチェーンが 私用領域の符号位置に箱(tofu)の字形を持っているため。

その結果、アイコンが見つからないときに 「何も描かない」ではなく ☐ を描く動作になっている。

直し方: 私用領域(U+E000..U+F8FF)は アイコンフォント専用の領域なので、 内蔵フォントへのフォールバックから除外する。 見つからなければ何も描かないのが正しい (実ブラウザもアイコンフォント未取得時は空白になる)。

私用領域のフォールバック除外は正しいが ☐ の原因ではなかった

内蔵フォントへのフォールバックから私用領域(U+E000..U+F8FF)を 除外したが、☐ は消えなかった。

修正自体は正しい(アイコンが無いときに箱を描くのは誤り)が、 観測している ☐ の原因ではない。

☐ はグリフではなく要素の枠線の可能性

以前の要素ダンプに該当しそうなものがある:

[VIS] #98 y=1569 h=20 x=100 w=20 bg=None bd=Some(0)

20×20 で枠線を持つ要素。アイコン用の <i> が 字形を描けないまま、要素の枠として見えている可能性が高い。

次: #apps の一覧行にある <i> 要素の border 系プロパティを単体試験で確認する。 カスケード側は既に試験基盤があるので追加は容易。

「アイコンのグリフが出ない」と「☐ が描かれる」は別問題 として切り分ける。

【2026-08-05】同名ファミリのウェイト別登録に対応

問題

CSS は同じ family 名でウェイト違いを複数定義する:

@font-face { font-family:"Font Awesome 7 Free"; font-weight:400; src:fa-regular-400.woff2 }
@font-face { font-family:"Font Awesome 7 Free"; font-weight:900; src:fa-solid-900.woff2 }

CUSTOM_FONTS は family 名だけをキーにしていたので後勝ちで上書きされ、 .fas(font-weight:900)が要求する solid が失われていた。

実測: family=Font Awesome 7 Free bytes=48104(= regular)。 solid は 314740 バイトで別ファミリ名の方に入っていた。

対応

  1. @font-face の font-weight をパースする(FontFaceDef::weight。未指定 400)
  2. CUSTOM_FONTS のキーを family|weight にする。 family 名だけのキーも用意するが、既にあれば上書きしない
  3. 描画時は要素の太字指定に応じて 太字なら 900 → 700、通常なら 400 の順に引き、 見つからなければ family 名だけのキーへ落とす

VectorFont は Clone ではないので、 family 名キー用に同じバイト列からもう 1 つ構築している。

残っている ☐

アイコン位置の ☐ は消えていない。 boxfill / draw_individual_borders / draw_rounded_rect_border の いずれも透明判定を通すようにしたので、さらに別の経路から出ている。

次: <i> 要素そのものの描画経路を特定する (要素ダンプで bd / border_*_width / outline_* を突き合わせる)。

【2026-08-05】☐ の根本原因: WOFF2 の glyf/loca 変換が未対応

静的試験で枠線説を棄却

tests/src/test_sugi_lab_cascade.rs に追加:

カスケードは完全に正しい。☐ はフォントから描かれた字形そのもの。

原因: 変換フラグの解釈が仕様と逆

let transform_version = (flags >> 6) & 0x03;
let transform_length = if transform_version != 0 { Some(tl) } else { None };

WOFF2 仕様では glyf と loca だけ意味が反転する:

テーブル version 0 version 3
glyf / loca 変換あり 変換なし
その他 変換なし 変換あり

現状は「0 = 変換なし」と決め打ちしているため:

  1. glyf が変換されているのに transformLength を読まず、 元長で切り出す(ストリーム上の位置がずれる)
  2. 変換の復元処理そのものが無い(変換済みバイト列を そのまま SFNT の glyf として書いている)

その結果、head / cmap は無事なのでフォントはパースに成功し、 glyph_index() も引ける。しかし輪郭データが壊れているので 箱のような字形が描かれる。観測と完全に一致する。

必要な作業

WOFF2 仕様 §5.1 の glyf 変換の復元:

純粋な変換処理なので単体試験で固定できる (入力バイト列 → 出力バイト列)。実行環境は不要。

補足

transform_version の判定自体は先に直すべき。 現状は glyf の切り出し長が誤っており、 それ以降のテーブルのストリーム位置も全部ずれている可能性がある。

【2026-08-05】☐ の正体は「代替文字が豆腐になっている」

静的に潰した候補

候補 結果
カスケード(family / content / エスケープ解除) 正常(試験で固定)
枠線 無し(試験で固定)
透明ボーダーが黒く描かれる 修正済み(見出しの黒枠は消えた)
WOFF2 の glyf 変換未対応 該当せず。FA は CFF ベース
ttf-parser の CFF 非対応 該当せず。0.20 は常時サポート

WOFF2 のテーブル構成を実測:

[FONT][WOFF2] テーブル構成(* = 変換済み): head hhea maxp OS/2 hmtx name cmap post CFF

glyf は無く(CFF ベース)、変換済みテーブルも無い。

原因

vector_font.rs の fallback_glyph_char が、 Font Awesome の符号位置を絵文字へ代替している:

'\u{f2db}' => '⚙',   // fa-microchip
'\u{f15c}' => '📄',  // fa-file-alt
'\u{f03a}' => '📋',  // fa-list
'\u{f3ed}' => '🛡',  // fa-shield-alt
'\u{f19d}' => '🎓',  // fa-graduation-cap
'\u{f5fc}' => '💻',  // fa-laptop-code

内蔵の日本語フォントはこれらの絵文字の字形を持たないが、 cmap には登録されていて豆腐(□)が返る。 その結果 ☐ が並ぶ。

直し方

代替先は「内蔵フォントが確実に持っている文字」に限る。 絵文字への代替は外す(アイコンが無いなら何も描かない方が、 豆腐が並ぶより実ブラウザの挙動に近い)。

矢印・チェック・山形(← → ↑ ↓ ✓ ✕ ‹ › ^ v)は 一般的な記号なので残してよい。

副産物: WOFF2 の変換対応を実装した

原因ではなかったが、実装は正しく必要なもの:

TTF ベースの WOFF2(Noto Sans JP など)で効く。

ウェイト別登録が有効になった(が ☐ は残る)

@font-face の取り込みループが has_custom_font(&ff.family) で ファミリ名だけを見てスキップしていたため、 同じ family の別ウェイトが一度も取り込まれていなかった。

(修正前)family=Font Awesome 7 Free bytes=48104    ← regular だけ
(修正後)family=Font Awesome 7 Free bytes=48104
          family=Font Awesome 7 Free bytes=314740   ← solid も入った

has_custom_font_weighted(family, weight) で判定するよう修正。

それでも ☐ が残る

ここまでで潰した候補(すべて実測または単体試験):

候補 結果
カスケード(family / content / エスケープ解除 / 枠線) 正常
透明ボーダーが黒く描かれる 修正済み(見出しの黒枠は消えた)
WOFF2 の glyf 変換未対応 該当せず(FA は CFF ベース)
ttf-parser の CFF 非対応 該当せず(0.20 は常時対応)
絵文字への代替が豆腐になる 50 件撤去したが ☐ は残る
同名ファミリの別ウェイトが登録されない 修正済み。solid も入った

残る仮説: <i> 要素の font_bold が期待どおりに立っておらず、 描画時に family|900(solid)ではなく family|400(regular)や family 名だけのキー(= regular)を引いている可能性。

次に確認すること: 描画時に実際どのキーが引かれているかを 1 回だけ記録する(draw_string_family に bold と選ばれたキーを出す)。

【2026-08-05】決着: ::before の書体が要素自身の指定を無視していた

決め手

描画時にどのフォントキーが選ばれたかを記録した:

[FONTPICK] font awesome 7 free|bold=false|hit=Some("font awesome 7 free|400")

.fas は CSS で font-weight: 900(単体試験で確認済み)なのに bold=false で regular(400) が選ばれ、 solid にしか無いアイコン字形が引けずに箱(☐)になっていた。

原因

要素側の RenderElement 生成が font_bold: false を決め打ちしていた。

border_style,
font_bold: false,      // ← ここ
font_italic: false,

::before / ::after の内容はこの要素自身の書体で描かれるのに、 太字・斜体を常に無効にしていた。

(通常のテキストは子のテキストノードとして別経路で描かれるため 影響が出ず、疑似要素だけが壊れていた。)

修正

styled から font-weight / font-style を読んで設定する。

結果: Font Awesome のアイコンが正しく描画され、☐ が消えた。

この一連で塞いだ穴(すべて実測または単体試験で確認)

  1. WOFF2 の変換フラグ判定が仕様と逆(glyf/loca は 0 が「変換あり」)
  2. glyf 変換の復元が未実装(kernel/woff2_glyf.rs を新規実装)
  3. 同名ファミリの別ウェイトが登録されない (has_custom_font が family 名だけを見ていた)
  4. 描画時にウェイトを見ずに引いていた
  5. 絵文字への代替が豆腐になる(50 件撤去)
  6. 私用領域が内蔵フォントへフォールバックして箱になる
  7. ::before の書体が要素自身の指定を無視(本命)

【2026-08-05】ウェイト選択は「そのフォントが描ける場合だけ」

踏んだリグレッション

font_bold を要素のカスケードから取るようにした直後、 見出しの文字が欠けた:

「開発アプリケーション」→「□ 発プリケーション」
「iOS用アプリ(iPhone/iPad対応)」→「i 用アプリ(i hon i a 対応 )」

原因

Google Fonts は unicode-range でファイルを分割して配信する。 Noto Sans JP の「ウェイト 700」が Latin だけのサブセットということがある。

太字だからと noto sans jp|700 を無条件に選ぶと、 そのサブセットに無い漢字・かなが欠ける。

修正

ウェイト付きキーを選ぶのは、そのフォント自身が 描こうとしている文字を持っている場合だけにした。

let probe = s.chars().find(|c| !c.is_whitespace());
// 選ぶ前に primary_has_glyph(probe) で確かめる

has_glyph ではなく primary_has_glyph を使う点が要点で、 代替チェーンまで見ると「持っている」と誤って判断してしまう。

このとき「判定は先頭の非空白文字 1 つで足りる(同じ文字体系が続くため)」 と書いたが、これは誤りだった(下記で訂正)。

結果: 見出しの文字欠けが解消し、 Font Awesome のアイコン(solid が要る)も引き続き正しく描かれる。

【2026-08-05】「先頭 1 文字で判定」は誤り — 全文字を見る

症状

フォント読み込みの途中の状態で、ヒーローの説明文が化けた。

実際:   IoT、情報家電、AI技術を駆使して
描画:   IoT、■報家電、AI■術を駆使 て
        ■税をサポート る革■■システムを■■開帔 ていま

原因

ウェイト付きフォントを選ぶかどうかを先頭の非空白文字 1 つで 判定していた。「同じ文字体系が続くはず」と前提を置いたためだが、 実サイトには 1 つの文字列で書体が混ざる例がふつうにある。

IoT、情報家電、… は先頭が I なので Latin サブセットが 「描ける」と判定され、後続の日本語が豆腐(■)になる。

対処

文字列に含まれる文字をすべて描けるフォントだけを選ぶ。 判定は純粋モジュール kernel/font_select.rs へ切り出した。

pub fn covers_all(s: &str, has_glyph: impl Fn(char) -> bool) -> bool {
    s.chars().filter(|c| !c.is_whitespace()).all(has_glyph)
}

空白は書体を問わないので除く(除かないと、空白を持たないフォントが 不当に落ちる)。計算量 O(L)、文字列は短いので実質無視できる。

tests/src/test_font_select.rs に 8 件。 とくに 末尾の 1 文字だけが欠ける場合(先頭 1 文字の判定では 見逃していた形)を回帰として残した。

教訓

F-1: 文字列を「均質なもの」として扱わない。 日本語のページでは Latin と CJK が 1 つの文字列に混ざるのが普通。

F-2: 「〜のはず」で範囲を狭めた最適化は、その前提を 試験に書き起こす。書けないなら前提が曖昧すぎる。

見つけ方についての注記

この不具合は SCREENSHOT_WAIT=360 では出ず、200 でだけ出た。 5.3MB の Noto Sans JP が届き切る前の途中の状態でしか 露出しないため。待ち時間を短くした回でたまたま見えた。

十分に待った状態しか見ていないと、読み込み途中の不具合は見つからない。 待ち時間を変えて撮ることも確認手段のうち。