From 352032bd7143ba81bd2f51aec52dbbf2ab2fb91e Mon Sep 17 00:00:00 2001 From: LEI Zongmin Date: Mon, 12 Oct 2026 01:12:44 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(font):=20=E8=A1=A5=E5=85=A8=E6=B3=9B?= =?UTF-8?q?=E6=97=8F=20:700=20=E5=88=AB=E5=90=8D=E4=B8=8E=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=97=8F=E8=B0=B1=E6=88=90=E5=AF=B9=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=BF=AE=20w700=20=E8=90=BD=20regular?= =?UTF-8?q?=E3=80=81w400=20=E5=91=BD=E4=B8=AD=20Bold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P9-D2(R5061):build_font_resolver 仅为 sans-serif/serif 注册 :700 别名, system-ui/cursive/fantasy 缺 :700 键时 resolve_font_face 的 [":700",""] 查找 直接落 regular face(probe2 B1:system-ui w700 渲染细体);monospace:700 按 CSS Fonts 4 §5.2 族内语义回退族内唯一 face,不套用 sans 链跨族兜底。平台 角色加载补成对:bold 角色此前只载 DejaVuSans-Bold,"DejaVu Sans" 族单 face, w400 亦命中粗体(probe2 A3);primary 角色只载 NotoSans-Regular,GitHub 栈 含 "Noto Sans"(先于 sans-serif)时 w700 停在栈中途——分别补同族 Regular/Bold companion(Linux 清单;macOS/Windows 族谱成对加载留 FIXME)。 测试资产:新增内联 reftest css-fonts/system-ui-bold-zw-001(system-ui w700 ≍ sans-serif w700;本地孪生 tests/wpt-runner/local-reftests/css/css-fonts/, make import-wpt 登记账本与 manifest);loader/system 各增 p9d2 断言。 验证:probe2 真机对照 B1 细体→粗体、A3 误粗体→regular、B4 mono w700 族内 回退保持 regular,Chrome 双端一致;make reftest 709/709(新案通过、无像素 回归);make test 20495/0;cargo clippy --workspace --all-targets -D warnings(默认 v8)绿;cargo fmt --check 干净。 Co-Authored-By: Claude Code --- crates/render-foundation/src/font/loader.rs | 68 ++++++++++ crates/render-foundation/src/font/system.rs | 124 ++++++++++++++++++ tests/wpt-runner/imported-tests.txt | 1 + .../css-fonts/system-ui-bold-zw-001-ref.html | 18 +++ .../css/css-fonts/system-ui-bold-zw-001.html | 26 ++++ .../wpt-runner/src/reftest_data/css_fonts.rs | 14 ++ 6 files changed, 251 insertions(+) create mode 100644 tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001-ref.html create mode 100644 tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001.html diff --git a/crates/render-foundation/src/font/loader.rs b/crates/render-foundation/src/font/loader.rs index 9455d5b067..55dad75fe6 100644 --- a/crates/render-foundation/src/font/loader.rs +++ b/crates/render-foundation/src/font/loader.rs @@ -351,6 +351,13 @@ impl FontLoader { // monospace let mono_id = self.resolve_generic_family(&mono_names).unwrap_or(default_id); resolver.insert("monospace".to_string(), mono_id); + // P9-D2(R5072,CSS Fonts 4 §5.2 weight 匹配在族内 face 集上进行):mono 族 + // 常仅一个 face(Noto Sans Mono CJK SC),w700 须族内回退到唯一 face——不套 + // 用 R3993 sans 链的跨族兜底(跨族会把 sans bold 塞给 monospace)。 + resolver.insert( + "monospace:700".to_string(), + self.bold_face_of_same_family(mono_id).unwrap_or(mono_id), + ); // cursive / fantasy / system-ui → 暂映射到 sans-serif if std::env::var("ZW_RESOLVER_TRACE").is_ok() { @@ -367,6 +374,12 @@ impl FontLoader { resolver.insert("cursive".to_string(), sans_id); resolver.insert("fantasy".to_string(), sans_id); resolver.insert("system-ui".to_string(), sans_id); + // P9-D2(R5072):三族与 sans-serif 同族,w700 共用 sans-serif:700 值(R3993 + // 链)——缺 :700 键时 resolve_font_face 的 [":700",""] 查找直接落 regular face + //(probe2 B1:system-ui w700 渲染 regular;GitHub system-ui 栈粗体标题细体)。 + resolver.insert("cursive:700".to_string(), sans_bold_id); + resolver.insert("fantasy:700".to_string(), sans_bold_id); + resolver.insert("system-ui:700".to_string(), sans_bold_id); resolver } @@ -1223,6 +1236,61 @@ mod tests { assert!(resolver.contains_key("serif:700") && serif != u32::MAX); } + /// P9-D2(R5072):system-ui/cursive/fantasy 与 sans-serif 共用 :700 face; + /// monospace:700 族内回退(无同族 Bold 时 = 自身,不得跨族取 sans bold)。 + /// 手工加载 Liberation R+B 构造确定性环境(无字体时跳过,同 R3993 口径)。 + #[test] + fn p9d2_generic_and_system_ui_bold_aliases_registered() { + let Some(data) = std::fs::read("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf").ok() else { + eprintln!("p9d2: Liberation Sans not found, skip"); + return; + }; + let Some(bold) = std::fs::read("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf").ok() else { + eprintln!("p9d2: Liberation Sans Bold not found, skip"); + return; + }; + let mut loader = FontLoader::new(); + loader.load_font(&data).expect("load Liberation Sans"); + loader.load_font(&bold).expect("load Liberation Sans Bold"); + let resolver = loader.build_font_resolver(); + + let sans_bold = resolver + .get("sans-serif:700") + .copied() + .expect("sans-serif:700 registered"); + for family in ["system-ui", "cursive", "fantasy"] { + let key = format!("{family}:700"); + assert_eq!( + resolver.get(&key), + Some(&sans_bold), + "P9-D2: {key} must share the sans-serif:700 face (same family), got {:?}", + resolver.get(&key) + ); + } + + // 单 face mono 族(DejaVu Sans Mono,不载其 Bold):monospace:700 = + // monospace 自身——族内回退,禁止跨族取「任意族第二 face」(sans bold)。 + // 注:mono 关键字解析不到任何清单族时落 default face,此时「解析族」的 + // bold face 即正确答案(族语义跟随解析结果,不跟随关键字)。 + let Some(mono_data) = std::fs::read("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf").ok() else { + eprintln!("p9d2: DejaVu Sans Mono not found, skip mono assertion"); + return; + }; + loader.load_font(&mono_data).expect("load DejaVu Sans Mono"); + let resolver = loader.build_font_resolver(); + let mono = resolver.get("DejaVu Sans Mono").copied().expect("mono face registered"); + assert_eq!( + resolver.get("monospace"), + Some(&mono), + "P9-D2: monospace must resolve to the loaded mono face" + ); + assert_eq!( + resolver.get("monospace:700"), + Some(&mono), + "P9-D2: monospace:700 must stay within the mono family when no mono Bold face exists" + ); + } + #[test] fn test_font_loader_empty() { let loader = FontLoader::new(); diff --git a/crates/render-foundation/src/font/system.rs b/crates/render-foundation/src/font/system.rs index c0770644b4..56e2a7759e 100644 --- a/crates/render-foundation/src/font/system.rs +++ b/crates/render-foundation/src/font/system.rs @@ -24,6 +24,17 @@ pub fn load_platform_fonts() -> PlatformFonts { }; }; + // P9-D2(R5072):primary 族同样须 [Regular, Bold] 成对——GitHub 栈含 + // "Noto Sans"(先于 sans-serif),只载 NotoSans-Regular 时该族单 face,w700 + // 标题命中 regular 停在栈中途(真机复验实证:补齐 sans-serif:700 后首页标题 + // 仍细体)。 + let _ = load_first(&mut loader, primary_bold_font_paths(), 0, "primary-bold"); + // P9-D2(R5072):bold 角色须与同族 Regular 成对注册——family_map 约定首 face + // Regular、第二 face Bold(build_font_resolver 据此注册 {family} 与 {family}:700, + // CSS Fonts 4 §5.2)。只载 Bold 时该族单 face:任意字重(含 w400)都命中 Bold, + // 且族内无 :700 键(probe2 A3:"DejaVu Sans" w400 渲染粗体)。两清单同族序一致, + // 保证 Regular 先于 Bold 进 family_map。 + let _ = load_first(&mut loader, bold_regular_font_paths(), 0, "bold-regular"); let _ = load_first(&mut loader, bold_font_paths(), 0, "bold"); // R4377:generic 族 face 与 reftest harness 对齐(R1259 serif / R1263 sans / R4373 mono @@ -175,6 +186,53 @@ fn bold_font_paths() -> &'static [&'static str] { } } +/// P9-D2(R5072):bold 角色的同族 Regular face,与 `bold_font_paths` 同族序配对, +/// 先于 Bold 加载。仅覆盖 Linux(本切片证据环境);macOS/Windows 的 bold 角色同样 +/// 只载 Bold——macOS 的 Arial Regular 由 `generic_family_font_paths` 在其后补载, +/// family_map 序为 [Bold, Regular],resolver["Arial"] 仍取 Bold。FIXME: 各平台 +/// 族谱成对加载待清单核对后统一(对齐 harness reftest_fonts 的成对加载口径)。 +fn bold_regular_font_paths() -> &'static [&'static str] { + #[cfg(target_os = "macos")] + { + &[] as &[&str] + } + #[cfg(target_os = "windows")] + { + &[] as &[&str] + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + &[ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + ] + } +} + +/// P9-D2(R5072):primary 族(`primary_font_paths` 前两项 NotoSans-Regular)的 +/// 同族 Bold face,紧随 primary 加载,保证 family_map 序 [Regular, Bold]。仅覆盖 +/// Linux(本切片证据环境);清单首项须与 primary 实际命中的族对应。macOS/Windows +/// 同病 FIXME:各平台族谱成对加载待清单核对后统一(对齐 harness reftest_fonts +/// 的成对加载口径)。 +fn primary_bold_font_paths() -> &'static [&'static str] { + #[cfg(target_os = "macos")] + { + &[] as &[&str] + } + #[cfg(target_os = "windows")] + { + &[] as &[&str] + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + &[ + "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/opentype/noto/NotoSans-Bold.ttf", + ] + } +} + fn platform_fallback_font_paths() -> &'static [&'static str] { #[cfg(target_os = "macos")] { @@ -306,4 +364,70 @@ mod tests { ); } } + + /// P9-D2(R5072):bold 角色族须有完整 [Regular, Bold] 谱——w400 命中 Regular、 + /// w700 命中 Bold。此前只载 DejaVuSans-Bold:family_map 单 face,"DejaVu Sans" + /// 任意字重都命中 Bold(probe2 A3)。system-ui/cursive/fantasy 的 :700 与 + /// sans-serif:700 同值;monospace:700 族内回退(生产清单下 mono 族单 face)。 + /// DejaVu Regular 缺失(极简环境)时跳过族谱断言。 + #[test] + fn p9d2_bold_role_family_pairs_regular_with_bold() { + let platform = load_platform_fonts(); + let resolver = platform.loader.build_font_resolver(); + + if std::path::Path::new("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf").exists() { + let regular = resolver + .get("DejaVu Sans") + .copied() + .expect("DejaVu Sans registered when its Regular face is loaded"); + let bold = resolver + .get("DejaVu Sans:700") + .copied() + .expect("DejaVu Sans:700 registered (family_map second face)"); + assert_ne!( + regular, bold, + "P9-D2: 'DejaVu Sans' w400 must resolve to the Regular face, not the Bold face" + ); + } else { + eprintln!("p9d2: DejaVu Sans Regular not found, skip family-pair assertion"); + } + + // primary 族(Noto Sans):GitHub 栈含 "Noto Sans"(先于 sans-serif), + // w700 须能命中 Bold face(R5072 首页标题细体残口的回归锚)。 + if std::path::Path::new("/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf").exists() { + let regular = resolver + .get("Noto Sans") + .copied() + .expect("Noto Sans registered (primary role)"); + let bold = resolver + .get("Noto Sans:700") + .copied() + .expect("Noto Sans:700 registered (primary-bold companion)"); + assert_ne!( + regular, bold, + "P9-D2: 'Noto Sans' w700 must reach the Bold face, not stop at Regular" + ); + } + + assert_eq!( + resolver.get("system-ui:700"), + resolver.get("sans-serif:700"), + "P9-D2: system-ui:700 must share the sans-serif:700 face (same family)" + ); + assert_eq!( + resolver.get("cursive:700"), + resolver.get("sans-serif:700"), + "P9-D2: cursive:700 must share the sans-serif:700 face (same family)" + ); + assert_eq!( + resolver.get("fantasy:700"), + resolver.get("sans-serif:700"), + "P9-D2: fantasy:700 must share the sans-serif:700 face (same family)" + ); + assert_eq!( + resolver.get("monospace:700"), + resolver.get("monospace"), + "P9-D2: monospace:700 must stay within the mono family (single-face family falls back to itself)" + ); + } } diff --git a/tests/wpt-runner/imported-tests.txt b/tests/wpt-runner/imported-tests.txt index df031e625a..c1e3029c9d 100644 --- a/tests/wpt-runner/imported-tests.txt +++ b/tests/wpt-runner/imported-tests.txt @@ -531,3 +531,4 @@ css/css-pseudo/selection-background-color-001.html css/reference/ref-filled-gree css/css-sizing/aspect-ratio/replaced-element-012.html css/reference/ref-filled-green-100px-square.xht == 2026-10-11 R5056 修复:picture/source 回退提取落地即坏(R4162)——find_picture_source_srcset 的 quote 切片截到 tag 尾(缺端界),find(quote) 在值区间永不命中 → 回退恒 None → 图源缺失零渲染(aspect-ratio 族 012 自引入恒红;engine 侧 R4162 DOM 提取本就正确,harness ImageCache 单侧缺 URL)。quote 改单字符切片(≡ R3995 data=/src= 同款端界) css/css-pseudo/first-letter-insert-text-node-dynamic.html css/css-pseudo/first-letter-insert-text-node-dynamic-ref.html == 2026-10-11 R5058 修复:insertBefore(textNode, textRef) 变异静默丢弃——sel 父 + 文本子 ref(childNodes 文本代理无 selector/handle)时 shim wire 分支全不命中,仅 CreateTextNode 在册、插入文本未落地(::first-letter/::first-line 仍样式旧首字母);补 sel 父 text-ref wire 分支(ref===firstChild → InsertAdjacentText afterbegin 精确,否则 beforeend 兜底);first-line-insert-text-node-dynamic 同路径顺带翻绿 css/css-sizing/aspect-ratio/block-aspect-ratio-024.html css/reference/ref-filled-green-100px-square.xht == 2026-10-11 R5060 修复:block 子 aspect-ratio + min-height 传递 inline 最小值(css-sizing-4 §5.2 + csswg#5257)——min-height 可解析(real 或百分比 × definite 父块高)时固有宽贡献 ≥ min-height × ratio(width:min-content 父由子贡献取值);门 = plain block/flow-root + w/h 均 auto + ratio>0 + min-height 非 auto;max-height 传递 cap 挂账 +css/css-fonts/system-ui-bold-zw-001.html css/css-fonts/system-ui-bold-zw-001-ref.html == 2026-10-12 p9s1 修复(R5072):generic/system 族 :700 resolver 别名缺失 + platform 族谱不成对(CSS Fonts 4 §5.2 weight 匹配在族内 face 集上进行)——build_font_resolver 补 system-ui/cursive/fantasy:700=sans_bold_id(≡R3993 链)+ monospace:700=bold_face_of_same_family(mono_id).unwrap_or(mono_id)(族内回退禁跨族);platform primary/bold 角色补同族配对加载(NotoSans-Bold、DejaVuSans-Regular 先于各 Bold),修 DejaVu Sans w400 命中 Bold、system-ui/monospace w700 落 regular(probe2 B1/A3/B4 真机实证);GitHub system-ui 栈粗体按钮等受益(本地自建案,主仓源 tests/wpt-runner/local-reftests/,wpt-data 重拉后按其恢复;内联孪生 reftest_data/css_fonts.rs id css-fonts/system-ui-bold-zw-001;缺陷态判别 = regular vs bold 字形差远超 1% 阈值) diff --git a/tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001-ref.html b/tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001-ref.html new file mode 100644 index 0000000000..d385cdb428 --- /dev/null +++ b/tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001-ref.html @@ -0,0 +1,18 @@ + + + + +system-ui w700 resolves to the family bold face (ref) + + + + +
Bold heading text
+ + diff --git a/tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001.html b/tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001.html new file mode 100644 index 0000000000..8e3cc77b07 --- /dev/null +++ b/tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001.html @@ -0,0 +1,26 @@ + + + + + +system-ui w700 resolves to the family bold face + + + + +
Bold heading text
+ + diff --git a/tests/wpt-runner/src/reftest_data/css_fonts.rs b/tests/wpt-runner/src/reftest_data/css_fonts.rs index 8464a673c5..0c09ca1970 100644 --- a/tests/wpt-runner/src/reftest_data/css_fonts.rs +++ b/tests/wpt-runner/src/reftest_data/css_fonts.rs @@ -420,6 +420,20 @@ const REFTESTS: &[InlineReftestDef] = &[ ref_html: "
Overridden to 10px
", is_match: true, }, + // ── P9-D2(R5072):system-ui 族 w700 须命中 bold face(CSS Fonts 4 §5.2)── + // 回归形态:build_font_resolver 缺 system-ui:700 键,resolve_font_face 的 + // [":700",""] 查找直接落 regular face——GitHub system-ui 栈粗体标题渲染细体。 + // ref 页用 sans-serif:700(R3993 键恒 bold);修复态 system-ui:700 与 + // sans-serif:700 同 face → 两页逐像素相等。缺陷态 regular vs bold 字形差 + // 远超 1% 阈值(判别)。文件孪生: + // tests/wpt-runner/local-reftests/css/css-fonts/system-ui-bold-zw-001*.html。 + InlineReftestDef { + id: "css-fonts/system-ui-bold-zw-001", + category: ReftestCategory::Text, + test_html: "
Bold heading text
", + ref_html: "
Bold heading text
", + is_match: true, + }, ]; pub fn reftests() -> &'static [InlineReftestDef] { From c7a3c128cf2f6dc3280765fde4bb06013ee83152 Mon Sep 17 00:00:00 2001 From: LEI Zongmin Date: Mon, 12 Oct 2026 02:17:30 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(font):=20=E5=B9=B3=E5=8F=B0=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E8=A7=92=E8=89=B2=E5=8A=A0=E8=BD=BD=E6=8C=89=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=8E=BB=E9=87=8D=E2=80=94=E2=80=94=E4=BF=AE=E6=9E=81?= =?UTF-8?q?=E7=AE=80=E7=8E=AF=E5=A2=83=E6=97=8F=E8=B0=B1=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=20face=20=E4=BB=A4=20:700=20=E8=90=BD=20regular=EF=BC=88?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=20DF-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双审查缺陷角色 DF-1(major 非阻断):仅装 fonts-dejavu-core 的环境 primary 与 bold-regular 命中同一 DejaVuSans.ttf,load_first 无去重致 family_map= [Reg,RegDup,Bold]、自动注册 {family}:700=ids.get(1)=RegDup——w700 渲染细体, 对 base(该环境 [Reg,Bold] 正确)回归。修复:load_first 与 generic 族循环共享 loaded_paths 集合按路径去重,同路径只载一次;新增 DF-1 去重单测。 另更正 DF-3 注释矛盾:monospace:700 的 :700 语义跟随解析结果族(mono 命中 专属族取族内 Bold/自身;generic 全落空时取 default 族谱系),此前「禁跨族」 表述与 default 臂实际行为不符——禁的是 R3993「任意族第二 face」兜底。 验证:cargo test -p zero-render-foundation --lib font 168/0(含 DF-1 测试); fmt 干净;clippy workspace -D warnings 绿(默认 v8)。DF-2(清单错位无运行时 校验)挂账与平台族谱 FIXME 一并处理。 Co-Authored-By: Claude Code --- crates/render-foundation/src/font/loader.rs | 7 +- crates/render-foundation/src/font/system.rs | 75 +++++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/crates/render-foundation/src/font/loader.rs b/crates/render-foundation/src/font/loader.rs index 55dad75fe6..b541e92ff3 100644 --- a/crates/render-foundation/src/font/loader.rs +++ b/crates/render-foundation/src/font/loader.rs @@ -352,8 +352,11 @@ impl FontLoader { let mono_id = self.resolve_generic_family(&mono_names).unwrap_or(default_id); resolver.insert("monospace".to_string(), mono_id); // P9-D2(R5072,CSS Fonts 4 §5.2 weight 匹配在族内 face 集上进行):mono 族 - // 常仅一个 face(Noto Sans Mono CJK SC),w700 须族内回退到唯一 face——不套 - // 用 R3993 sans 链的跨族兜底(跨族会把 sans bold 塞给 monospace)。 + // 常仅一个 face(Noto Sans Mono CJK SC),w700 须族内回退到唯一 face。:700 + // 语义跟随解析结果族:mono_id 命中专属 mono 族时取该族 Bold(无 Bold 则自 + // 身);generic 关键字全落空(mono_id = default)时取 default 族自身谱系的 + // Bold/自身——不套用 R3993 sans 链的「任意族第二 face」兜底(那才会把不相干 + // 的 sans bold 塞给 monospace)。 resolver.insert( "monospace:700".to_string(), self.bold_face_of_same_family(mono_id).unwrap_or(mono_id), diff --git a/crates/render-foundation/src/font/system.rs b/crates/render-foundation/src/font/system.rs index 56e2a7759e..4af3d1968b 100644 --- a/crates/render-foundation/src/font/system.rs +++ b/crates/render-foundation/src/font/system.rs @@ -15,7 +15,13 @@ pub struct PlatformFonts { /// 按跨进程稳定顺序加载 primary、bold 与 fallback 字体。 pub fn load_platform_fonts() -> PlatformFonts { let mut loader = FontLoader::new(); - let primary = load_first(&mut loader, primary_font_paths(), 0, "primary"); + // P9-D2 DF-1(R5072 返修):跨角色共享已载路径集合——极简环境(如仅 + // fonts-dejavu-core)primary 与 bold-regular 会命中同一 DejaVuSans.ttf,二次 + // 载入生成重复 face 使 family_map=[Reg,RegDup,Bold]、自动注册的 {family}:700 + // 指向 RegDup(w700 落 regular,对 base 回归)。同路径只载一次,族谱由真实 + // Bold face 补齐。 + let mut loaded_paths = std::collections::HashSet::new(); + let primary = load_first(&mut loader, primary_font_paths(), 0, "primary", &mut loaded_paths); let Some(primary_id) = primary else { tracing::warn!("No platform primary font found; text rendering will be limited"); return PlatformFonts { @@ -28,14 +34,26 @@ pub fn load_platform_fonts() -> PlatformFonts { // "Noto Sans"(先于 sans-serif),只载 NotoSans-Regular 时该族单 face,w700 // 标题命中 regular 停在栈中途(真机复验实证:补齐 sans-serif:700 后首页标题 // 仍细体)。 - let _ = load_first(&mut loader, primary_bold_font_paths(), 0, "primary-bold"); + let _ = load_first( + &mut loader, + primary_bold_font_paths(), + 0, + "primary-bold", + &mut loaded_paths, + ); // P9-D2(R5072):bold 角色须与同族 Regular 成对注册——family_map 约定首 face // Regular、第二 face Bold(build_font_resolver 据此注册 {family} 与 {family}:700, // CSS Fonts 4 §5.2)。只载 Bold 时该族单 face:任意字重(含 w400)都命中 Bold, // 且族内无 :700 键(probe2 A3:"DejaVu Sans" w400 渲染粗体)。两清单同族序一致, // 保证 Regular 先于 Bold 进 family_map。 - let _ = load_first(&mut loader, bold_regular_font_paths(), 0, "bold-regular"); - let _ = load_first(&mut loader, bold_font_paths(), 0, "bold"); + let _ = load_first( + &mut loader, + bold_regular_font_paths(), + 0, + "bold-regular", + &mut loaded_paths, + ); + let _ = load_first(&mut loader, bold_font_paths(), 0, "bold", &mut loaded_paths); // R4377:generic 族 face 与 reftest harness 对齐(R1259 serif / R1263 sans / R4373 mono // 谱系——chromium fontconfig 默认:serif/initial = Times New Roman → Liberation Serif、 @@ -45,10 +63,17 @@ pub fn load_platform_fonts() -> PlatformFonts { // 仅加载为 family 成员(family_map 供名匹配),**不进 fallback chain**(缺字回退行为 // 零变化);加载顺序在 primary/bold 之后、CJK chain 之前(各进程同序 → font_id 一致)。 for path in generic_family_font_paths() { + // P9-D2 DF-1:角色清单已载的同路径(如 bold-regular 兜底命中 + // LiberationSans-Regular)不再二次载入——family_map 已注册该 face。 + if loaded_paths.contains(*path) { + continue; + } match std::fs::read(path) { Ok(data) => { if let Err(error) = loader.load_font(&data) { tracing::debug!(path, %error, "Failed to load generic family font"); + } else { + loaded_paths.insert((*path).to_string()); } } Err(error) => tracing::debug!(path, %error, "Generic family font not found"), @@ -93,10 +118,22 @@ pub fn load_platform_fonts() -> PlatformFonts { } } -fn load_first(loader: &mut FontLoader, paths: &[&str], face_index: u32, role: &str) -> Option { +fn load_first( + loader: &mut FontLoader, + paths: &[&str], + face_index: u32, + role: &str, + loaded_paths: &mut std::collections::HashSet, +) -> Option { paths.iter().find_map(|path| { + // P9-D2 DF-1:同路径已被更早角色载入时跳过(重复 face 会占据 family_map + // 第二位,使自动注册的 {family}:700 指向重复 Regular 而非 Bold)。 + if loaded_paths.contains(*path) { + return None; + } let data = std::fs::read(path).ok()?; let id = loader.load_font_at_index(&data, face_index).ok()?; + loaded_paths.insert((*path).to_string()); tracing::info!(path, font_id = id, "Loaded platform {role} font"); Some(id) }) @@ -430,4 +467,32 @@ mod tests { "P9-D2: monospace:700 must stay within the mono family (single-face family falls back to itself)" ); } + + /// P9-D2 DF-1(R5072 返修):load_first 跨角色路径去重——同路径二次调用被跳过 + /// (返回 None 且不载入)。缺陷形态:极简环境 primary 与 bold-regular 命中同一 + /// DejaVuSans.ttf,重复 face 占据 family_map 第二位,自动注册的 {family}:700 + /// 指向重复 Regular(w700 落细体)。本机无对应字体时跳过。 + #[test] + fn p9d2_df1_load_first_dedups_already_loaded_path() { + let candidate = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + ] + .into_iter() + .find(|p| std::path::Path::new(p).exists()); + let Some(path) = candidate else { + eprintln!("p9d2 DF-1: no DejaVu/Liberation regular font on this machine, skip dedup assertion"); + return; + }; + let paths = [path]; + let mut loader = FontLoader::new(); + let mut seen = std::collections::HashSet::new(); + let first = load_first(&mut loader, &paths, 0, "dedup-probe-a", &mut seen); + assert!(first.is_some(), "first load of an existing font must succeed"); + let second = load_first(&mut loader, &paths, 0, "dedup-probe-b", &mut seen); + assert!( + second.is_none(), + "second load of the same path must be skipped (DF-1 dedup)" + ); + } } From 254e51e3547a6b903625dcf5896bedab4c6afdb0 Mon Sep 17 00:00:00 2001 From: LEI Zongmin Date: Mon, 12 Oct 2026 09:08:36 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(webview):=20execute=5Fscript=20?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E6=8E=92=E6=B0=B4=E8=A1=A5=E6=AF=8F=E8=BD=AE?= =?UTF-8?q?=20deadline=20=E6=A3=80=E6=9F=A5=E2=80=94=E2=80=94=E4=BF=AE?= =?UTF-8?q?=E5=A4=9A=E9=93=BE=E8=87=AA=E7=BB=AD=E4=B8=8B=E7=9A=84=20livelo?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drain_next_async_callback_if_pending 原先仅在 try_recv 空档检查 20ms 预算; 被排水的回调以 setTimeout(fn, 0) 自续时(service worker 轮询链每跳含宿主 调用,解析耗时大于定时器线程 spawn),多链并行供给使通道永无空档,Ok 分支 无限消耗——make test 全量并行下测试线程在单次 execute_script 内 100% 单核 空转(实测 101.6s 不返回),serial 锁被占、后续测试全部泊住,二进制挂到 test-guard 900s 墙钟(leg 上下文 3/3 复现)。 排水循环顶部补每轮 deadline 检查:等待语义不变(recv_timeout 仍受剩余预算 约束),仅将消耗总量限制在预算内,恢复 execute_script 同步调用必须返回的 契约。新增 async_drain_bound 回归测试(4 条慢跳永续链):修复前 call 0 耗时 101.6s 断言失败,修复后全套 1.32s 通过;workspace make test 20531P/0F、 60s 警告 0,reftest 709/709。 Co-Authored-By: Claude Code --- crates/webview/src/tests/async_drain_bound.rs | 55 +++++++++++++++++++ crates/webview/src/tests/mod.rs | 1 + crates/webview/src/webview.rs | 7 +++ 3 files changed, 63 insertions(+) create mode 100644 crates/webview/src/tests/async_drain_bound.rs diff --git a/crates/webview/src/tests/async_drain_bound.rs b/crates/webview/src/tests/async_drain_bound.rs new file mode 100644 index 0000000000..240e5d882a --- /dev/null +++ b/crates/webview/src/tests/async_drain_bound.rs @@ -0,0 +1,55 @@ +//! execute_script 的异步回调排水预算上界回归测试。 +//! +//! 根因背景(service_worker_runtime 套件 leg 上下文 livelock):`webview` 的 +//! `drain_next_async_callback_if_pending` 原先仅在通道空档(`try_recv` 返回 +//! Empty)检查 20ms 预算。SW 轮询链(pollRegistration / pollClientMessages) +//! 以 `setTimeout(fn, 0)` 自续且每跳含宿主调用——解析耗时大于定时器线程 +//! spawn——多链并行时供给压倒排水,通道永无空档,排水循环永不返回:测试 +//! 线程在单次 `execute_script` 内 100% 单核空转(实测 101.6s),自身 60s +//! deadline 断言不可达,serial 锁被占,整个测试二进制挂死。本测试以 4 条 +//! 永续慢跳自续链复现该形态,要求每次 `execute_script` 在有界时间内返回; +//! 回归时断言快速失败,修复后确定性通过。 + +use std::time::{Duration, Instant}; + +use crate::{WebView, WebViewConfig}; + +#[test] +fn execute_script_returns_with_eternal_zero_timeout_chain() { + let mut wv = WebView::new(WebViewConfig::default()); + let setup = wv + .execute_script("typeof setTimeout") + .expect("setup execute_script should succeed"); + assert_eq!(setup, "function", "setTimeout shim must exist in plain execute_script"); + + // 4 条永续慢跳链:每跳 ~3ms 同步 JS 计算后 setTimeout(f, 0)。单条链在每次 + // 回调解析期间无新回调到达,通道必现空档、deadline 可兜住;多条慢链下, + // 任一回调解析期间其余链已积累新回调(spawn ~50µs ≪ 解析 ~3ms),队列 + // 永不空档——模拟 pollRegistration / pollClientMessages 每跳含宿主调用 + // (解析慢于线程 spawn)且多注册并行轮询的形态。 + wv.execute_script( + "globalThis.__drainProbeBusy = function () { + var s = 0; + for (var i = 0; i < 200000; i++) s += Math.sqrt(i); + return s; + }; + for (var i = 0; i < 4; i++) { + (function f() { globalThis.__drainProbeBusy(); setTimeout(f, 0); })(); + } undefined;", + ) + .expect("chain setup should succeed"); + + let overall = Instant::now() + Duration::from_secs(30); + for i in 0..50 { + let call_start = Instant::now(); + let result = wv.execute_script("1+1"); + assert!(result.is_ok(), "execute_script call {i} failed: {result:?}"); + let elapsed = call_start.elapsed(); + assert!( + elapsed < Duration::from_secs(2), + "execute_script call {i} took {elapsed:?} — async-callback drain exceeded its budget" + ); + assert!(Instant::now() < overall, "probe overall budget exceeded"); + std::thread::sleep(Duration::from_millis(5)); + } +} diff --git a/crates/webview/src/tests/mod.rs b/crates/webview/src/tests/mod.rs index dd79c2ee94..beb79cc221 100644 --- a/crates/webview/src/tests/mod.rs +++ b/crates/webview/src/tests/mod.rs @@ -1,6 +1,7 @@ //! WebView 测试模块。 mod advanced; +mod async_drain_bound; mod basic; mod builder_nav; mod cache_storage; diff --git a/crates/webview/src/webview.rs b/crates/webview/src/webview.rs index 1c44d0d8b4..497d5f9285 100644 --- a/crates/webview/src/webview.rs +++ b/crates/webview/src/webview.rs @@ -2650,6 +2650,13 @@ impl WebView { }; let deadline = std::time::Instant::now() + timeout; loop { + // 排水预算对每次迭代生效:回调可以 setTimeout(fn, 0) 自续(service + // worker 轮询链每跳含宿主调用,解析耗时大于定时器线程 spawn),多条 + // 并行链的供给会让通道永无空档——仅在 `try_recv` 空档检查 deadline + // 会让本循环在 `Ok` 分支无限消耗,execute_script 永不返回。 + if std::time::Instant::now() >= deadline { + return; + } let callback = match self.async_callback_rx.try_recv() { Ok(callback) => Some(callback), Err(mpsc::TryRecvError::Empty) From 9184053a9dedaf78d58cf7b4bac17d647fa39e98 Mon Sep 17 00:00:00 2001 From: LEI Zongmin Date: Mon, 12 Oct 2026 10:23:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(webview,render-foundation):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20main=20=E5=90=8C=E6=AD=A5=E5=B8=A6=E5=85=A5?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E5=A4=84=E5=9B=9E=E5=BD=92=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=20async=5Fload=20=E6=B8=B2=E6=9F=93=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=9B=E5=BB=BA=E3=80=81=E8=BF=98=E5=8E=9F=20image?= =?UTF-8?q?=5Fcache=20=E6=B5=8B=E8=AF=95=E6=8B=BC=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 归因(均存在于 main tip 247ad103e,非本分支改动引入;merge-tree 单侧变更不产生 冲突,故障内容随 main 带入): 1. async_load.rs:580 `expect("session")` panic——zero-webview lib 26 个测试失败 (async_load 家族 + redirect_tv1)及 zero-webdriver http_session 8 个 500, 同一根因:e776d6875/0620e8103「移除 T5DIAG 临时诊断」把生产行 `self.render_session = Some(BudgetedRenderSession::new(html, css_for_render))` 一并删除(30e0e7c0d 与 e776d6875^ 均在场;main tip 上 BudgetedRenderSession::new 仅剩 webview.rs 测试内的直接构造)。恢复该行,使 0620e8103 自述保留的 substitute_css_vars_text 生产路径重新接线(css_for_render 不再是死局部变量)。 2. image_cache.rs 测试块拼接损伤——zero-render-foundation lib test 编译失败 (duplicated attribute / dead_code / needless_borrow 三错):3ed4223cf 插入 decode_data_uri_svg_background 时替换了原测试的 fn 头,致原 decode_image_bytes_dispatches_by_magic 失去 #[test] 且被改名 test_decode_image_bytes_dispatches_by_magic,新测试带双 #[test]。按其本意还原: 原测试恢复原名与属性,SVG 测试独立置于其后(&src→src)。 两处在 main 上均未被 pr-precheck 覆盖(workspace check 不含 lib test 目标与 集成测试执行)。本提交使合并头恢复可测基线,供全量门禁与 PR #139 交付。 Co-Authored-By: Claude Code --- crates/render-foundation/src/image_cache.rs | 18 +++++++++--------- crates/webview/src/async_load.rs | 1 + 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/render-foundation/src/image_cache.rs b/crates/render-foundation/src/image_cache.rs index 164014c937..0daa19e046 100644 --- a/crates/render-foundation/src/image_cache.rs +++ b/crates/render-foundation/src/image_cache.rs @@ -2187,15 +2187,7 @@ mod decode_tests { /// 分发器:按魔数字节/内容正确路由 PNG / JPEG / WebP / SVG / 未知格式。 #[test] - #[test] - fn decode_data_uri_svg_background() { - // bing-t5(C4):data-URI SVG background-image 解码管线——URL-encoded SVG - let src = "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%2210%22%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22red%22%2F%3E%3C%2Fsvg%3E"; - let img = decode_data_uri(&src).expect("SVG data-URI should decode"); - assert!(img.width > 0 && img.height > 0, "SVG 应栅格化为位图"); - } - - fn test_decode_image_bytes_dispatches_by_magic() { + fn decode_image_bytes_dispatches_by_magic() { // PNG → 成功 let png = red_2x2_png(); let img = decode_image_bytes(&png).expect("PNG should dispatch and decode"); @@ -2232,6 +2224,14 @@ mod decode_tests { ); } + /// bing-t5(C4):data-URI SVG background-image 解码管线——URL-encoded SVG。 + #[test] + fn decode_data_uri_svg_background() { + let src = "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%2210%22%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22red%22%2F%3E%3C%2Fsvg%3E"; + let img = decode_data_uri(src).expect("SVG data-URI should decode"); + assert!(img.width > 0 && img.height > 0, "SVG 应栅格化为位图"); + } + /// R1705:`decode_data_uri` base64 PNG → ImageData(renderer + wpt-runner 共用入口)。 #[test] fn decode_data_uri_base64_png() { diff --git a/crates/webview/src/async_load.rs b/crates/webview/src/async_load.rs index a9e2cf839e..8972655d50 100644 --- a/crates/webview/src/async_load.rs +++ b/crates/webview/src/async_load.rs @@ -575,6 +575,7 @@ impl AsyncPageLoad { // bing-t5(C3+C4 根因):CSS Custom Properties var() 替换—— // 在进入渲染前做文本级替换(collect --name: value → substitute var(--name, fallback))。 let css_for_render = substitute_css_vars_text(&self.css); + self.render_session = Some(BudgetedRenderSession::new(html, css_for_render)); } let session = self.render_session.as_mut().expect("session");