対象実装: 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 のフォントを実際にダウンロード・処理する ようになり、その完了を待って描画が始まらない。
実サイトの CSS は font-display: swap
を指定している
(fonts.googleapis.com/css2?...&display=swap
および取得した @font-face に
font-display: swap が含まれる)。 swap
の意図は「フォント読み込みを待たずに代替フォントで描画し、
読み込み完了後に差し替える」ことである。 →
読み込み完了まで描画を止める現在の実装は、この意図に反する。
5.3MB のフォントを同期的に待つのは、この OS の用途では非現実的。 ダウンロードだけでなく解析・グリフ生成にも時間がかかる。
spec/resource_loading.md
R-2 と統一)。font-display の各値(auto /
block / swap / fallback /
optional)の 厳密な block period / swap period
のタイミングは未実装。
すべて「代替で即描画し、取得できたら差し替える」(=swap
相当)として扱う近似。
規格文言を確認でき次第、値ごとの挙動を分けること。spec/TODO.md の再レイアウト項目参照)。.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
と同じく純粋関数+単体試験で判定を固定すること。
前節で「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 を取得するようになった結果、 時間内に終わらなくなった。
image_loader_thread,
font_queue)で非同期に行われ、メイン描画スレッドを一切ブロックしない。font_budget
/ image_budget /
FETCH_BYTE_CAP)はすべて撤回・廃止され、全リソースを最後まで取得して順次反映する。@font-face の取り込みが CSS
キャッシュミス時にしか走らなかった
fetch_and_register_font_faces
は「ネットワーク往復を伴うから」という理由で CSS
キャッシュがミスしたときだけ呼ばれていた(2026-07-22 の制限)。
その後フォント取得が非同期+キャッシュ優先になり前提が崩れていた:
Pending(登録できない)→ 取得できているのに永久に登録されない。実測 8
本取得・registered= 0 件。
毎レイアウト呼ぶよう変更(登録済みは has_custom_font
で即スキップ、 未取得は font_queue の seen
で重複投入されない)。 実測でキャッシュ命中が 0 → 1
件になった。
連続失敗による打ち切りが残っていた
MAX_CONSECUTIVE_FAILURES = 2
で以降のフォントを全部スキップしていた。
打ち切りは本物のブラウザの挙動ではない(R-2)。撤去した結果、 Font
Awesome の 4 本も取得キューへ載るようになった。
register_custom_font が失敗を 4
箇所すべて無言で返していた
WOFF2 展開失敗・未知の形式・パース失敗・set_primary
失敗。
どこで落ちたか分からなかった。すべてログを追加(spec/logging_policy.md
L-1)。
ログを足したことで停止位置が確定した:
[FONT] 登録開始 family=FontAwesome bytes=4132
(以降 [FONT] 解析開始 が出ない)
この 2 つのログの間にあるのは
decompress_woff2(Brotli 展開)だけ。
fa-v4compatibility.woff2(4132 バイト)の展開で
レイアウトスレッドが停止する。描画スレッドは動き続けている
(present_frame
が増え続ける)ため、フリーズには見えない。
待機時間 300 / 420 / 540 秒すべてで同じ地点で止まったので、 処理が遅いのではなく進まなくなっている。
decompress_brotli は BrotliDecompressStream
の単発呼び出しで
ループは無い。したがって停止しているのは
BrotliDecompressStream の内部のどちらか。まだ特定していないので断定しない。 次はこの 2 点の間にログを入れて切り分ける。
なお GLOBAL_VECTOR_FONT の差し替えは
try_lock へ変更した。 描画スレッドが保持したまま譲ると spin
ロックが永久に回るため (CUSTOM_FONTS
への登録は済んでいるので描画には反映される)。
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
のヒープがあり、プールサイズの見積り自体が不要になる。
「大きいフォントで枯渇して落ちる/止まる」失敗モードごと消えた。
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
でこのフォントが使える」と記録する
だけであり、既定フォントを変える操作ではない。差し替えを撤去した。
描画側 draw_string_ja_size_ext_bg_ls_ws は
GLOBAL_VECTOR_FONT しか見ず、 font-family
を受け取らない。そのため登録しただけでは字形に反映されない。
font-family から
CUSTOM_FONTS を引いて描く経路これが Web フォント再現の残りの本体。
RenderElement に
font_family(カンマ区切りのまま保持)を追加。 従来は
::before 用の before_font_family しか無く、
本文の family がどこにも渡っていなかった。Screen::draw_string_family(..., family_list) を追加。
family リストを先頭から見て CUSTOM_FONTS
に登録済みのものがあれば
それで描き、無ければ従来の既定フォントへフォールバックする。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 の登録が間に合わない理由)を追う。
フォント 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 を通す。
最後の 2 つの穴を塞いで、取得から描画まで通った。
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 を落としていた)。
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 の字形に変わったことを確認した。
NoHeadTableset_primary で既定フォントを乗っ取っていた →
全文字がアイコン化@font-face 取り込みが CSS キャッシュミス時限定 →
永久に登録されないparse_and_layout の内側 →
循環して発火しないfont_family が 12 生成箇所のうち 2 箇所にしか無いfont-family が継承プロパティ一覧に無い8 つのうち 7
つは「失敗が無言だった」ために見えていなかった
(spec/logging_policy.md)。
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
は単独で実行し、ビルドと同じコマンド行に並べない。
https://www.sugi-lab.net/
のファーストビューは実物とほぼ一致:
IMG_6530.jpg 3.7MB)が表示されるbackdrop-filter: blur() 込み)SCREENSHOT_SCROLL
でスクロールして撮ろうとしたが、scroll_y=0 のまま
動かなかった。browser.scroll()
を送るキー入力がブラウザに吸われて
ターミナルへ届いていないため(既知のキーコード配送の問題)。
レイアウト自体は全ページ分できている(elements=369)ので、
描画されていないわけではない。スクロールして確認する手段が無いだけ。
harness 側でスクロールを起こす別経路(例: ブラウザのビルトインを 起動時引数から呼ぶ)が要る。
ブラウザが処理していたキーは 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)でのスクロールを実装するのが筋。
#id リンクos.run("browser", "https://www.sugi-lab.net/#research")
は ページ読込前に端末へ打つので確実に届く(現に URL
は届いている)screenshot_site.ps1 を Python の文字列 index
で切り貼りしたところ、 別の箇所と重なってスクリプト構造を破壊した
(ParserError: MissingCatchOrFinally)。
git diff で自分の変更が 40 行の追加のみと確認したうえで
git checkout して復旧し、Edit
ツールで厳密一致の置換をやり直した。
スクリプトの編集は index 切り貼りではなく厳密一致の置換で行うこと。
#id)の対応フラグメントを HTTP 要求に載せていた
resolve_url_parts / load_page_with_method
が # を含むパスを
そのままサーバへ送っていた。フラグメントはサーバへ送るものではない。
分離して保持するようにした。
初回ロード時のフラグメントが無視されていた
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 の位置(「主な研究テーマ」)で:
ファーストビューは実物とほぼ一致しているので、 下部セクション固有の問題。次はここを切り分ける。
アンカースクロールで #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="詳細"
カードの中身はレイアウトされている。見出し・本文・「詳細」まで すべて要素として存在し、座標も表示範囲内にある。
不透明度も 255。CSS アニメーションの初期値
opacity:0 で
隠れているわけではない(IntersectionObserver
も実装済みだった)。
それでも画面には出ていない。
画面上の位置は
1509 - 1269 + 40 = 280。実際のスクリーンショットの
その位置は空白。
#95 までは描かれ、#96 以降が出ていない (#92 / #94 の半透明白ボックスは実際に画面に見えている)。
「レイアウトされていない」でも「透明で見えない」でもなく、 ある要素を境に描画されなくなっている。
考えられる先:
要素ループ内のクリップ(set_clip)が解除されずに
以降の描画を捨てている、あるいは scroll_regions の判定で
可視外と誤判定している。まだ確認していないので断定しない。
#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 の白い全面ボックスが 何を意図したものか
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 を確認。
影の数を直してもカードは画面に出ない。
[CULL] #96 描画対象 el_y=280 h=319 —
除外はされていないop=255、bg=Some(0xFFFFFFFF)、shadows=1
と値は正しい#100 fs=24 "スマートホーム・HEMS")も出ていない白地に白だから見えない、では説明がつかない (文字は濃色、影もある)。 要素ループの可視判定を通った後、実際の塗りに至っていない。
次に見る場所: 可視判定と塗りの間にある分岐 (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(見えない)
window_mgr.rs の
set_clip(app_x, app_y, w, h) は
ウィンドウバッファ内座標として正しい(引数の取り違えではない)clip_path は空、mix-blend-mode
も空、opacity は 255、 transform の
rotate/scale も既定値[CULL])は通過している「画面下側だけ出ない」という形なので、 垂直方向のクリップかオフセットの取り違えが残っている可能性が高い。 ただし特定できていないので断定しない。
次の一手: 塗り/文字描画の直前で screen
の現在のクリップ矩形を 1
回出す。要素側ではなく描画先の状態を見る。
ここまで「この要素は画面に出るはずなのに出ない」を、 ビルド(約 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 回だけ記録して切り分ける。
ただしそこも純粋関数へ切り出せるなら先に切り出す。
「カード内テキストが出ない」を追って、静的に候補を潰していった:
| 候補 | 判定 | 根拠 |
|---|---|---|
| 可視判定 | 除外 | 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 箇所ある。両方を計装した:
[CULL] #96 描画対象
が出る(=除外されていない)overflow:hidden のクリップ → [CULL2]
が一度も出ない(=除外されていない)要素は正しい座標・色・不透明度で描画処理に到達している。 それでも画面に出ない。
描いた後に上書きされている。
ダンプに z_index と paint_depth
を入れていなかった。 これが最後の未確認点。両方とも生成 12
箇所すべてで設定されており inherited_paint_z
も再帰前後で保存・復元されているが、
実際の値は見ていない。
次: [VIS] に z と depth
を追加し、 カード(#96)とその文字(#100)の描画順を確認する。
[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)を覆うものは無い:
#50 は y=529..1269(画面 -700..40)で領域外#92 / #94 は画面 y 120..206 で領域外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
win_y + el_y = 31 + 280 =
311、高さ 319 → 311..630sub_scroll=0 なので入れ子スクロールの影響も無い除外判定・クリップ・座標のすべてが「描かれるはず」を示している。 それでも画面には出ない。
| 条件 | 画面 y≈444 の要素 | 結果 |
|---|---|---|
scroll_y=0(ヒーロー) |
ボタン | 描かれる |
scroll_y=1269(#research) |
カード | 描かれない |
同じ画面座標でもスクロール後だけ描かれない。 要素の属性ではなく、スクロール状態そのものが条件。
ここまでで要素側の要因は静的にすべて潰した。 次に疑うべきはコンポジタ側:
(0,0 722x580) のことがある
(ウィンドウは x=1024 まであるのに幅 722)[WM][DIAG] compositor try_lock FAILED (using stale frame)
が出る =古いフレームを表示することがあるスクロール後の再描画がコンポジタまで届いているかを、 ウィンドウバッファと画面のどちらの問題かを分けて確認する。
描画後にウィンドウバッファを読み返した:
[READBACK] (210,382) = 0xfff8f9fa 期待=0xff3498db(アイコン円)
(210,382) は #97 のアイコン円(不透明な青
0xFF3498DB、60×60)の中心。 読めた値はページ背景色
#f8f9fa。
つまり カードも青い円も一切描かれていない
(描かれていれば白 0xFFFFFFFF か青になる)。
「描けているが画面に届かない」ではなく、バッファにも描かれていない。
[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 について、以下がすべて「描かれるはず」を示している:
[CULL] が出て
[CULL2] は出ない)(100,31,1024,600) の内側(描画位置
y=311..630)sub_scroll=0、op=255、色も正常、transform
既定値それでもバッファに 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)の到達可否。
塗り関数への到達と引数を記録した:
[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)の切り替えにある。
boxfill 内で実際に書き込みループへ入っているか
(x0/y0/x1/y1 を u32
へ変換した後の値、クリップ判定の結果)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
書き込みが実行されているのに読み返すと背景色、という矛盾が残る。
[BOXFILL] と [READBACK]
が別フレームの可能性 (どちらも 20 回に 1
回の間引き出力で、カウンタが独立している)resolve_stride() が書き込み時と読み返し時で異なる間引きをやめて同一フレームで突き合わせる。
[BOXFILL] と [READBACK]
を同じフレーム番号でタグ付けし、
「このフレームで塗った直後に読むと何が入っているか」を見る。
塗った直後に読んで白なら「後で上書きされている」、 塗った直後に読んで背景色なら「書き込みが効いていない」。 この 1 回で分岐が確定する。
塗った直後と、フレーム末尾で同じ画素を読み比べた:
[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 と併せれば、
どの要素が最後に上書きしているかが確定する。
draw_pixel
だけ見て 「誰も書いていない」と結論しかけた。 boxfill
には一括経路があることを見落としていた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)で誰が上書きしているかを確定させる。
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
に純粋関数として切り出し、 単体試験で固定する。
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 に固定しないboxfill
は不透明時に draw_pixel を
通らない。片方だけ見て「誰も書いていない」と結論しかけたscroll_y を併記して
初めて最終状態の上書きだと確定できたCURRENT_EI を
ループ後に戻さず、ループ外の塗りが直前の要素番号を名乗っていた「実サイトに無い角丸枠が重なっている」と書いたが、誤りだった。 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
で確認する。純粋なレイアウト計算なので単体試験にできる。
.section-header h2 { background: rgba(255,255,255,.9); border-radius: 10px; display: inline-block }
について:
background → 効いている(実測
0xE5FFFFFF)border-radius → 効いている(実測
r=(10,10,10,10))display: inline-block →
効いていない(幅が全幅 844)同じルールの宣言なのに一部だけ効かない。
.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 もネットワークも使わない:
.section-header h2 の background
が当たること.section-header h2 の border-radius: 10px
が当たること.section-header h2 の display
が後勝ちで flex になること.section-header p の display が
inline-block のままであることカスケードの後勝ちを固定する回帰試験になった。
これ以降、同種の疑いは make test 30 秒で判定できる。
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 再構築か、ウェイト別の登録かに絞れる。
カスケード側はもう疑わなくてよい(試験で固定した)。
登録直後に符号位置を引いて記録した:
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 ← ここが鍵
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
のフォントマッチング)。
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 系プロパティを単体試験で確認する。
カスケード側は既に試験基盤があるので追加は容易。
「アイコンのグリフが出ない」と「☐ が描かれる」は別問題 として切り分ける。
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 バイトで別ファミリ名の方に入っていた。
@font-face の font-weight
をパースする(FontFaceDef::weight。未指定 400)CUSTOM_FONTS のキーを family|weight
にする。 family
名だけのキーも用意するが、既にあれば上書きしない900 → 700、通常なら 400 の順に引き、
見つからなければ family 名だけのキーへ落とすVectorFont は Clone ではないので、 family
名キー用に同じバイト列からもう 1 つ構築している。
アイコン位置の ☐ は消えていない。 boxfill /
draw_individual_borders /
draw_rounded_rect_border の
いずれも透明判定を通すようにしたので、さらに別の経路から出ている。
次: <i> 要素そのものの描画経路を特定する
(要素ダンプで bd / border_*_width /
outline_* を突き合わせる)。
tests/src/test_sugi_lab_cascade.rs に追加:
.fas に Font Awesome の family
が当たる → OK::before の content がある → OK.fas に 枠線が当たっていない → OK(☐
は枠線ではない)カスケードは完全に正しい。☐ はフォントから描かれた字形そのもの。
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 = 変換なし」と決め打ちしているため:
glyf が変換されているのに transformLength
を読まず、
元長で切り出す(ストリーム上の位置がずれる)glyf として書いている)その結果、head / cmap
は無事なのでフォントはパースに成功し、 glyph_index()
も引ける。しかし輪郭データが壊れているので
箱のような字形が描かれる。観測と完全に一致する。
WOFF2 仕様 §5.1 の glyf 変換の復元:
nContourStream / nPointsStream /
flagStream / glyphStream /
compositeStream / bboxStream /
instructionStream の 7 本を読み出すglyf を組み立て直すloca は復元した glyf
のオフセットから作る純粋な変換処理なので単体試験で固定できる (入力バイト列 → 出力バイト列)。実行環境は不要。
transform_version の判定自体は先に直すべき。 現状は
glyf の切り出し長が誤っており、
それ以降のテーブルのストリーム位置も全部ずれている可能性がある。
| 候補 | 結果 |
|---|---|
| カスケード(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)は 一般的な記号なので残してよい。
原因ではなかったが、実装は正しく必要なもの:
glyf/loca
は 0 が「変換あり」)glyf 変換の復元
kernel/woff2_glyf.rs(単体試験 11 件)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
と選ばれたキーを出す)。
::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 のアイコンが正しく描画され、☐ が消えた。
glyf/loca は 0
が「変換あり」)glyf
変換の復元が未実装(kernel/woff2_glyf.rs を新規実装)has_custom_font が family 名だけを見ていた)::before
の書体が要素自身の指定を無視(本命)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 が要る)も引き続き正しく描かれる。
フォント読み込みの途中の状態で、ヒーローの説明文が化けた。
実際: 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
が届き切る前の途中の状態でしか
露出しないため。待ち時間を短くした回でたまたま見えた。
十分に待った状態しか見ていないと、読み込み途中の不具合は見つからない。 待ち時間を変えて撮ることも確認手段のうち。