// 皮肤与外观:读取页面 HTML、外部候选皮肤的 CSS 注入与内嵌资源、PrepareHtmlForWnds, // 以及候选窗、悬浮工具栏的布局 / 主题 / 字体等外观应用。 #include "webview2/windows_webview2_internal.h" #include "webview2/inline_protocol.h" #include "webview2/skin_css_policy.h" #include "config/ime_config.h" #include "defines/globals.h" #include "skin/candidate_skin_catalog.h" #include "utils/common_utils.h" #include "window/candidate_presenter.h" #include "window/candidate_skin_palette.h" #include "window/floating_toolbar_presenter.h" #include "fmt/xchar.h" #include #include #include #include #include #include #include #include #include #include #include namespace { std::optional activeExternalCandidateSkin; } // namespace double GetActiveCandidateSkinDecorationTopDip() { return activeExternalCandidateSkin ? activeExternalCandidateSkin->decorationTopDip : 0.0; } double GetActiveCandidateSkinDecorationWidthDip() { return activeExternalCandidateSkin ? activeExternalCandidateSkin->decorationWidthDip : 0.0; } std::wstring ReadHtmlFile(const std::wstring &filePath) { std::ifstream file(filePath, std::ios::binary); if (!file) return L""; std::ostringstream buffer; buffer << file.rdbuf(); return string_to_wstring(buffer.str()); } // An unreadable themed asset must never turn into an empty NavigateToString: // that renders a blank, title-less page which looks like a dead WebView. std::wstring ReadHtmlFileWithFallback(const std::wstring &primaryPath, const std::wstring &fallbackPath) { std::wstring content = ReadHtmlFile(primaryPath); if (content.empty() && primaryPath != fallbackPath) { content = ReadHtmlFile(fallbackPath); } return content; } namespace { std::wstring NormalizeSkinCssUrl(std::wstring url) { while (!url.empty() && iswspace(url.front())) { url.erase(url.begin()); } while (!url.empty() && iswspace(url.back())) { url.pop_back(); } while (url.size() >= 2 && url[0] == L'.' && url[1] == L'/') { url.erase(0, 2); } std::replace(url.begin(), url.end(), L'\\', L'/'); return url; } std::wstring EncodeBase64(const std::vector &bytes) { static constexpr wchar_t kTable[] = L"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::wstring out; out.reserve(((bytes.size() + 2) / 3) * 4); size_t i = 0; while (i + 2 < bytes.size()) { const unsigned int triple = (static_cast(bytes[i]) << 16) | (static_cast(bytes[i + 1]) << 8) | static_cast(bytes[i + 2]); out.push_back(kTable[(triple >> 18) & 63]); out.push_back(kTable[(triple >> 12) & 63]); out.push_back(kTable[(triple >> 6) & 63]); out.push_back(kTable[triple & 63]); i += 3; } if (i < bytes.size()) { unsigned int triple = static_cast(bytes[i]) << 16; if (i + 1 < bytes.size()) { triple |= static_cast(bytes[i + 1]) << 8; } out.push_back(kTable[(triple >> 18) & 63]); out.push_back(kTable[(triple >> 12) & 63]); out.push_back(i + 1 < bytes.size() ? kTable[(triple >> 6) & 63] : L'='); out.push_back(L'='); } return out; } std::wstring MimeForSkinAsset(const std::wstring &relativePath) { std::wstring lower = relativePath; for (wchar_t &ch : lower) { ch = static_cast(towlower(ch)); } if (lower.size() >= 4 && lower.compare(lower.size() - 4, 4, L".png") == 0) return L"image/png"; if (lower.size() >= 4 && lower.compare(lower.size() - 4, 4, L".jpg") == 0) return L"image/jpeg"; if (lower.size() >= 5 && lower.compare(lower.size() - 5, 5, L".jpeg") == 0) return L"image/jpeg"; if (lower.size() >= 5 && lower.compare(lower.size() - 5, 5, L".webp") == 0) return L"image/webp"; if (lower.size() >= 4 && lower.compare(lower.size() - 4, 4, L".gif") == 0) return L"image/gif"; if (lower.size() >= 4 && lower.compare(lower.size() - 4, 4, L".svg") == 0) return L"image/svg+xml"; return L"application/octet-stream"; } std::wstring EmbedSkinCssUrl(const std::wstring &skinsRoot, const std::string &skinId, const std::wstring &rawUrl) { const std::wstring relative = NormalizeSkinCssUrl(rawUrl); const std::filesystem::path filePath = std::filesystem::path(skinsRoot) / std::filesystem::u8path(skinId) / std::filesystem::u8path(wstring_to_string(relative)); std::error_code ec; constexpr std::uintmax_t kMaxEmbedBytes = 1500 * 1024; const auto fileSize = std::filesystem::file_size(filePath, ec); if (!ec && fileSize > 0 && fileSize <= kMaxEmbedBytes) { std::ifstream stream(filePath, std::ios::binary); std::vector bytes(static_cast(fileSize)); if (stream && stream.read(reinterpret_cast(bytes.data()), static_cast(fileSize))) { return L"url(\"data:" + MimeForSkinAsset(relative) + L";base64," + EncodeBase64(bytes) + L"\")"; } } return L"url(\"https://candidate-skins/" + string_to_wstring(skinId) + L"/" + relative + L"\")"; } void NeutralizeEmbeddedStyleClosers(std::wstring &css) { for (size_t i = 0; i + 7 < css.size(); ++i) { if (css[i] != L'<' || css[i + 1] != L'/') { continue; } std::wstring tag = css.substr(i + 2, 5); for (wchar_t &ch : tag) { ch = static_cast(towlower(ch)); } if (tag == L"style") { css.insert(i + 1, 1, L' '); i += 2; } } } void AppendExternalCandidateColorCss(std::wstring &css, const CandidateSkinCatalog::CandidateColors &colors) { auto add = [&](const std::string &value, const wchar_t *selector, const wchar_t *property) { if (value.empty()) { return; } css.append(selector); css.append(L" { "); css.append(property); css.append(L": "); css.append(string_to_wstring(value)); css.append(L"; }\n"); }; add(colors.accent, L".cursor, .first::before", L"background"); // 悬停在选中项上保持选中色;base 皮肤的 .cand.first:hover(如微信绿的 #07c160)会盖过低特异度的 .cand.first。 add(colors.selected, L".first, .cand.first, .hover-active .cand.first:hover", L"background-color"); add(colors.hover, L".hover-active .cand:not(.first):hover", L"background-color"); add(colors.surface, L".container", L"background"); add(colors.border, L".container", L"border-color"); // .text 画的是 var(--cand-text),只改 .container 的 color 够不着它。设置页的候选文字色写在 :root 的内联样式里, // 仍然压过这一条,与 D2D 的优先级一致。 add(colors.text, L":root", L"--cand-text"); add(colors.text, L".container", L"color"); add(colors.number, L".num, .cand-no", L"color"); // 翻译默认继承 .text 的颜色再叠 opacity .62;单独配色时取原值,不再叠透明度。 add(colors.translation, L".cand-translation", L"color"); if (!colors.translation.empty()) { css.append(L".cand-translation { opacity: 1; }\n"); } if (colors.showSelectedBar.has_value() && !*colors.showSelectedBar) { css.append(L".first::before { display: none; }\n"); } // 细分配色。候选与预编辑文字先读设置页的 --msime-user-text,与 D2D 里用户文字色压过皮肤一致。 // :where(.cand) 把特异度压到与基础 .text 相同,基础皮肤 `.first .text` 的选中行配色仍然生效。 if (!colors.candidateText.empty()) css.append(L":where(.cand) .text { color: var(--msime-user-text, " + string_to_wstring(colors.candidateText) + L"); }\n"); if (!colors.preeditText.empty()) css.append(L".pinyin .text { color: var(--msime-user-text, " + string_to_wstring(colors.preeditText) + L"); }\n"); add(colors.preeditCaret, L".cursor", L"background"); add(colors.selectedBar, L".first::before", L"background"); add(colors.selectedText, L".cand.first .text", L"color"); add(colors.selectedNumber, L".cand.first .num, .cand.first .cand-no", L"color"); add(colors.selectedTranslation, L".cand.first .cand-translation", L"color"); if (!colors.selectedTranslation.empty()) { css.append(L".cand.first .cand-translation { opacity: 1; }\n"); } add(colors.menuBackground, L".context-menu, .context-submenu", L"background"); add(colors.menuBorder, L".context-menu, .context-submenu", L"border-color"); add(colors.menuText, L".context-menu, .context-submenu, .context-menu-item", L"color"); add(colors.menuHover, L".context-menu-item:hover", L"background"); } std::wstring CssRgba(D2D1_COLOR_F color, double alpha) { return fmt::format(L"rgba({}, {}, {}, {:.3f})", static_cast(std::lround(color.r * 255.0f)), static_cast(std::lround(color.g * 255.0f)), static_cast(std::lround(color.b * 255.0f)), alpha); } // 预编辑底色带、分隔线、外框线宽、高亮圆角与卡片阴影。D2D 端在 CandidatePresenter::ApplySkin 里用同一组值。 void AppendExternalCandidateGeometryCss(std::wstring &css, const CandidateSkinCatalog::Package &skin, bool light) { const CandidateSkinCatalog::CandidateColors &colors = light ? skin.light : skin.dark; const double itemRadius = skin.itemCornerRadiusDip ? *skin.itemCornerRadiusDip : CandidateSkinBaseItemRadiusDip(skin.base); if (!colors.preeditBackground.empty()) { css.append(fmt::format(L".row.pinyin {{ background: {}; border-radius: {}px; }}\n", string_to_wstring(colors.preeditBackground), itemRadius)); } if (!colors.preeditDivider.empty()) { css.append(L".row.pinyin { border-bottom: 1px solid " + string_to_wstring(colors.preeditDivider) + L"; }\n"); } if (skin.borderWidthDip) { // 杨柳青、秋桂的基础 CSS 是 border: none,只改线宽会没有线型,所以连同线型和解析后的边框色一起写。 const D2D1_COLOR_F border = ResolveCandidateSkinPalette(skin.base, light, {}, &colors, skin.base).border; css.append(fmt::format(L".container:not(:empty) {{ border: {}px solid {}; }}\n", *skin.borderWidthDip, CssRgba(border, border.a))); } if (skin.itemCornerRadiusDip) { // 贴着外框四角的那几个角由更高特异度的贴角规则接管,仍跟外框圆角。 const std::wstring radius = fmt::format(L"{}px", *skin.itemCornerRadiusDip); css.append(L".container { --ao-item-radius: " + radius + L"; }\n"); css.append(L".container .cand, .container .cand.first { border-radius: " + radius + L"; }\n"); } if (!skin.shadow.empty()) { // 杨柳青的 .container 带 clip-path,阴影挂在 .containerParent 上;其余基础皮肤挂在 .container 上。 const wchar_t *selector = skin.base == "willow_green" ? L".containerParent:not(:empty)" : L".container:not(:empty)"; std::wstring value = L"none"; if (skin.shadow != "none") { const double scale = skin.shadow == "soft" ? 0.5 : 1.6; const D2D1_COLOR_F black = D2D1::ColorF(0, 1.0f); value = L"8px 10px 24px " + CssRgba(black, (std::min)((light ? 0.18 : 0.34) * scale, 1.0)) + L", 2px 3px 8px " + CssRgba(black, (std::min)((light ? 0.10 : 0.22) * scale, 1.0)); } css.append(std::wstring(selector) + L" { box-shadow: " + value + L"; }\n"); } } // Image paths come from the package's own skin.toml, so they get the same treatment as a URL // written inside the stylesheet rather than being trusted because they arrived through a // manifest field. std::wstring ManifestImageCssUrl(const std::wstring &skinsRoot, const std::string &skinId, const std::string &path) { const std::wstring raw = string_to_wstring(path); if (path.empty() || msime::skin_css::ClassifyUrl(raw) != msime::skin_css::UrlAction::Embed) { return L"none"; } return EmbedSkinCssUrl(skinsRoot, skinId, raw); } void AppendExternalCandidateCornerCss(std::wstring &css, const CandidateSkinCatalog::Package &skin) { if (!skin.cornerRadiusDip) { return; } const std::wstring radius = fmt::format(L"{}px", *skin.cornerRadiusDip); // 杨柳青、秋桂的外框与贴角高亮都读这两个变量,改变量即可;杨柳青的外框阴影挂在 .containerParent 上。 css.append(L"body, .container { border-radius: " + radius + L"; }\n"); css.append(L".container { --wg-radius: " + radius + L"; --ao-radius: " + radius + L"; }\n"); if (skin.base == "willow_green") { css.append(L".containerParent { border-radius: " + radius + L"; }\n"); } // Fluent 与微信横排把贴着外框四角的高亮角写死成外框圆角,这里用同一组选择器改成 R。 if ((skin.base == "fluent" || skin.base == "wechat") && GetConfiguredCandidateWindowLayout() == "horizontal") { css.append(L".container > .pinyin + .row-wrapper > .cand { border-bottom-left-radius: " + radius + L"; }\n" L".container.preedit-hidden > .pinyin + .row-wrapper > .cand { border-top-left-radius: " + radius + L"; }\n" L".container > .row-wrapper:is(:last-child, .last-visible) > .cand { " L"border-bottom-right-radius: " + radius + L"; }\n" L".container.preedit-hidden > .row-wrapper:is(:last-child, .last-visible) > .cand { " L"border-top-right-radius: " + radius + L"; }\n"); } } std::wstring BuildExternalCandidateSkinCss(const CandidateSkinCatalog::Package &skin, const std::wstring &skinsRoot) { // 卡片至少与装饰图同宽,装饰图因此总落在卡片宽度之内;D2D 端用同一规则撑开卡片。 std::wstring css = L".container:not(:empty) { min-width: max(7em, var(--msime-skin-min-width, 0px), " L"var(--msime-skin-decoration-width, 0px)); }\n"; if (skin.decorationTopDip > 0.0) { // 装饰盒底边贴卡片顶边、不与卡片重叠,按 align 贴卡片左/中/右,图片在盒内 contain 居中。 const wchar_t *horizontal = L"right: 0;"; if (skin.decorationAlign == "left") horizontal = L"left: 0;"; else if (skin.decorationAlign == "center") horizontal = L"left: 0; right: 0; margin-inline: auto;"; css.append(L".containerParent { padding-top: var(--msime-skin-decoration-top, 0px); " L"position: relative; box-sizing: border-box; }\n" L".containerParent:not(:empty)::before { content: \"\"; position: absolute; " L"z-index: 0; top: 0; "); css.append(horizontal); css.append(L" width: var(--msime-skin-decoration-width, 0px); " L"height: var(--msime-skin-decoration-top, 0px); background: "); css.append(ManifestImageCssUrl(skinsRoot, skin.id, skin.decorationImage)); css.append(L" center / contain no-repeat; pointer-events: none; }\n" L".container { position: relative; z-index: 1; }\n"); } AppendExternalCandidateCornerCss(css, skin); const bool light = ResolveConfiguredTheme(GetConfiguredThemeCand()) == "light"; const CandidateSkinCatalog::CandidateColors &colors = light ? skin.light : skin.dark; AppendExternalCandidateColorCss(css, colors); AppendExternalCandidateGeometryCss(css, skin, light); if (!skin.backgroundImage.empty()) { // 与 D2D 一致:背景图铺满整个卡片(含边框下方),边框画在它上面。不能用 ::before 垫图—— // 卡片是 overflow 滚动容器,子元素只能画到内边距盒,半透明边框下会露出一圈底色。 // 图层不能单独设 opacity,于是在图上再盖一层 (1 - opacity) 的底色,效果等同于把图按 opacity 叠在底色上。 const wchar_t *size = L"cover"; if (skin.backgroundFit == "contain") size = L"contain"; else if (skin.backgroundFit == "stretch") size = L"100% 100%"; const D2D1_COLOR_F surface = ResolveCandidateSkinPalette(skin.base, light, {}, &colors, skin.base).surface; const std::wstring veil = fmt::format(L"rgba({}, {}, {}, {:.3f})", static_cast(std::lround(surface.r * 255.0f)), static_cast(std::lround(surface.g * 255.0f)), static_cast(std::lround(surface.b * 255.0f)), surface.a * (1.0 - skin.backgroundOpacity)); css.append(L".container:not(:empty) { background-image: linear-gradient(" + veil + L", " + veil + L"), "); css.append(ManifestImageCssUrl(skinsRoot, skin.id, skin.backgroundImage)); css.append(fmt::format(L"; background-size: auto, {}; background-position: center; " L"background-repeat: no-repeat; background-origin: border-box; " L"background-clip: border-box; }}\n", size)); } return css; } bool InjectExternalCandidateSkin(std::wstring &html, const CandidateSkinCatalog::Package &skin, const std::wstring &skinsRoot) { if (html.empty() || skinsRoot.empty()) { return false; } const std::wstring vars = fmt::format(L"", skin.minWidthDip, skin.decorationTopDip, skin.decorationWidthDip); std::wstring css = BuildExternalCandidateSkinCss(skin, skinsRoot); NeutralizeEmbeddedStyleClosers(css); std::wstring generated; if (!css.empty()) { generated = L""; } const size_t headEnd = html.find(L""); if (headEnd == std::wstring::npos) { return false; } html.insert(headEnd, vars + generated); return true; } // 工具栏页面是按深浅主题各一张的,这里只生成当前主题的覆盖;D2D 端由 ApplyFloatingToolbarSkinOverrides 读同一份值。 std::wstring BuildExternalToolbarSkinCss(const CandidateSkinCatalog::Package &skin, bool light) { std::wstring css; auto add = [&](const std::string &value, const wchar_t *selector, const wchar_t *property) { if (value.empty()) { return; } css.append(selector); css.append(L" { "); css.append(property); css.append(L": "); css.append(string_to_wstring(value)); css.append(L"; }\n"); }; const CandidateSkinCatalog::ToolbarColors &colors = light ? skin.toolbarLight : skin.toolbarDark; add(colors.background, L".status-bar", L"background-color"); add(colors.border, L".status-bar", L"border-color"); add(colors.handle, L".drag-handle", L"background"); add(colors.divider, L".divider", L"background-color"); add(colors.icon, L".icon", L"color"); add(colors.hover, L".icon:hover", L"background-color"); if (skin.toolbarCornerRadiusDip) { // 部分 base 页面把圆角直接写成 calc(Npx * var(--ftb-scale)),所以覆盖 border-radius 本身而不是 --ftb-radius。 css.append(fmt::format(L".status-bar {{ border-radius: calc({}px * var(--ftb-scale)); }}\n", *skin.toolbarCornerRadiusDip)); } return css; } bool InjectExternalToolbarSkin(std::wstring &html, const CandidateSkinCatalog::Package &skin, bool light) { std::wstring css = BuildExternalToolbarSkinCss(skin, light); const size_t headEnd = html.find(L""); if (css.empty() || headEnd == std::wstring::npos) { return false; } NeutralizeEmbeddedStyleClosers(css); html.insert(headEnd, L""); return true; } void InjectCandidateDocumentSkin(std::wstring &html, const std::wstring &builtInCss, const std::string &skin, const std::string &base, const std::string &layout, const std::string &theme) { if (html.empty()) return; const size_t htmlTagEnd = html.find(L'>', html.find(L""); if (headEnd != std::wstring::npos && !builtInCss.empty()) html.insert(headEnd, L""); } } // namespace int PrepareHtmlForWnds() { // 用户数据目录,默认 %LOCALAPPDATA%\metasequoiaime,安装时可以改到别的盘。 std::wstring assetPath = CommonUtils::get_ime_data_path_w(); // // 候选窗口 // const bool isHorizontal = GetConfiguredCandidateWindowLayout() == "horizontal"; const bool candLight = ResolveConfiguredTheme(GetConfiguredThemeCand()) == "light"; const std::string candidateSkin = GetConfiguredCandidateSkin(); const std::string candidateLayout = isHorizontal ? "horizontal" : "vertical"; const std::string candidateTheme = candLight ? "light" : "dark"; activeExternalCandidateSkin.reset(); std::string baseCandidateSkin = candidateSkin; if (!CandidateSkinCatalog::IsBuiltIn(candidateSkin)) { const std::wstring skinsRoot = assetPath + L"\\skins"; activeExternalCandidateSkin = CandidateSkinCatalog::Load(skinsRoot, candidateSkin); if (activeExternalCandidateSkin && !CandidateSkinCatalog::Supports(*activeExternalCandidateSkin, candidateLayout, candidateTheme)) activeExternalCandidateSkin.reset(); baseCandidateSkin = activeExternalCandidateSkin ? activeExternalCandidateSkin->base : "fluent"; } std::wstring htmlCandWnd; std::wstring bodyHtmlCandWnd; std::wstring measureHtmlCandWnd; if (isHorizontal) { htmlCandWnd = L"/html/webview2/candwnd/horizontal_candidate_window.html"; // Body/measure fragments are theme-agnostic markup; keep the existing dark assets. bodyHtmlCandWnd = L"/html/webview2/candwnd/body/horizontal_candidate_window_dark.html"; measureHtmlCandWnd = L"/html/webview2/candwnd/body/horizontal_candidate_window_dark_measure.html"; } else { htmlCandWnd = L"/html/webview2/candwnd/vertical_candidate_window.html"; bodyHtmlCandWnd = L"/html/webview2/candwnd/body/vertical_candidate_window_dark.html"; measureHtmlCandWnd = L"/html/webview2/candwnd/body/vertical_candidate_window_dark_measure.html"; } std::wstring entireHtmlPathCandWnd = assetPath + htmlCandWnd; ::HTMLStringCandWnd = ReadHtmlFile(entireHtmlPathCandWnd); const std::wstring candidateStyleName = fmt::format(L"{}_{}.css", string_to_wstring(candidateLayout), string_to_wstring(candidateTheme)); const std::wstring candidateStylePath = fmt::format(L"/html/webview2/candwnd/skins/{}/{}", string_to_wstring(baseCandidateSkin), candidateStyleName); std::wstring builtInCandidateCss = ReadHtmlFile(assetPath + candidateStylePath); if (builtInCandidateCss.empty()) { builtInCandidateCss = ReadHtmlFile(assetPath + L"/html/webview2/candwnd/skins/fluent/" + candidateStyleName); } if (builtInCandidateCss.empty() && candLight) { builtInCandidateCss = ReadHtmlFile(assetPath + L"/html/webview2/candwnd/skins/fluent/" + string_to_wstring(candidateLayout) + L"_dark.css"); } InjectCandidateDocumentSkin(::HTMLStringCandWnd, builtInCandidateCss, candidateSkin, baseCandidateSkin, candidateLayout, candidateTheme); if (activeExternalCandidateSkin) { const std::wstring skinsRoot = assetPath + L"\\skins"; if (!InjectExternalCandidateSkin(::HTMLStringCandWnd, *activeExternalCandidateSkin, skinsRoot)) { // Without the stylesheet, native decoration insets would clip the card. activeExternalCandidateSkin.reset(); } } std::wstring bodyHtmlPathCandWnd = assetPath + bodyHtmlCandWnd; ::BodyStringCandWnd = ReadHtmlFile(bodyHtmlPathCandWnd); std::wstring measureHtmlPathCandWnd = assetPath + measureHtmlCandWnd; ::MeasureStringCandWnd = ReadHtmlFile(measureHtmlPathCandWnd); (void)0; // // 托盘语言区菜单窗口 // const bool menuLight = ResolveConfiguredTheme(GetConfiguredThemeMenu()) == "light"; std::wstring htmlMenuWnd = menuLight ? L"/html/webview2/menu/default_light.html" : L"/html/webview2/menu/default.html"; std::wstring entireHtmlPathMenuWnd = assetPath + htmlMenuWnd; ::HTMLStringMenuWnd = ReadHtmlFileWithFallback(entireHtmlPathMenuWnd, assetPath + L"/html/webview2/menu/default.html"); // // settings 窗口 // 这里暂时没有用到,因为 settings 窗口使用的是映射 url 导航 // /* std::wstring htmlSettingsWnd = L"/html/webview2/settings/default.html"; std::wstring entireHtmlPathSettingsWnd = assetPath + htmlSettingsWnd; ::HTMLStringSettingsWnd = ReadHtmlFile(entireHtmlPathSettingsWnd); */ // // floating toolbar 窗口 // const bool ftbLight = ResolveConfiguredTheme(GetConfiguredThemeFtb()) == "light"; std::wstring htmlFtbWnd; if (baseCandidateSkin == "wechat") { htmlFtbWnd = ftbLight ? L"/html/webview2/ftb/wechat_light.html" : L"/html/webview2/ftb/wechat.html"; } else if (baseCandidateSkin == "graphite") { htmlFtbWnd = ftbLight ? L"/html/webview2/ftb/graphite_light.html" : L"/html/webview2/ftb/graphite.html"; } else if (baseCandidateSkin == "willow_green") { htmlFtbWnd = ftbLight ? L"/html/webview2/ftb/willow_green_light.html" : L"/html/webview2/ftb/willow_green.html"; } else if (baseCandidateSkin == "autumn_osmanthus") { htmlFtbWnd = ftbLight ? L"/html/webview2/ftb/autumn_osmanthus_light.html" : L"/html/webview2/ftb/autumn_osmanthus.html"; } else { htmlFtbWnd = ftbLight ? L"/html/webview2/ftb/default_light.html" : L"/html/webview2/ftb/default.html"; } std::wstring entireHtmlPathFtbWnd = assetPath + htmlFtbWnd; ::HTMLStringFtbWnd = ReadHtmlFileWithFallback(entireHtmlPathFtbWnd, assetPath + L"/html/webview2/ftb/default.html"); if (activeExternalCandidateSkin && !::HTMLStringFtbWnd.empty()) { const size_t htmlTag = ::HTMLStringFtbWnd.find(L"', htmlTag); if (htmlTagEnd != std::wstring::npos) { ::HTMLStringFtbWnd.insert(htmlTagEnd, fmt::format(L" data-candidate-skin=\"{}\" data-candidate-base=\"{}\" " L"data-candidate-theme=\"{}\"", string_to_wstring(candidateSkin), string_to_wstring(baseCandidateSkin), ftbLight ? L"light" : L"dark")); } InjectExternalToolbarSkin(::HTMLStringFtbWnd, *activeExternalCandidateSkin, ftbLight); } // The small windows navigate from strings. Inline the pinned local runtime // before navigation instead of blocking first paint on two virtual-host loads. // Immutable for this process, like the executable's protocol contract. static const std::wstring protocolSchema = ReadHtmlFile(assetPath + L"/html/webview2/shared/schema.js"); static const std::wstring protocolRuntime = ReadHtmlFile(assetPath + L"/html/webview2/shared/runtime.js"); InlineWebViewProtocolScripts(::HTMLStringCandWnd, protocolSchema, protocolRuntime); InlineWebViewProtocolScripts(::HTMLStringMenuWnd, protocolSchema, protocolRuntime); InlineWebViewProtocolScripts(::HTMLStringFtbWnd, protocolSchema, protocolRuntime); preparedCandidateSkin = candidateSkin; return 0; } bool ApplyConfiguredCandidateWindowLayout() { PrepareHtmlForWnds(); if (CandidatePresenter::Instance().IsBound() && !webviewCandWnd) { return true; } if (!webviewCandWnd || HTMLStringCandWnd.empty()) { return false; } const bool ok = SUCCEEDED(webviewCandWnd->NavigateToString(HTMLStringCandWnd.c_str())); if (ok) { loadedCandidateSkin = preparedCandidateSkin; } return ok; } bool ApplyConfiguredUiThemes() { if (FloatingToolbarPresenter::Instance().IsBound()) { FloatingToolbarPresenter::Instance().ApplyTheme(); } const std::string candidateSkin = GetConfiguredCandidateSkin(); PrepareHtmlForWnds(); bool ok = true; if (webviewCandWnd && !HTMLStringCandWnd.empty()) { const bool candidateOk = SUCCEEDED(webviewCandWnd->NavigateToString(HTMLStringCandWnd.c_str())); if (candidateOk) { loadedCandidateSkin = candidateSkin; } ok = candidateOk && ok; } if (webviewFtbWnd && !HTMLStringFtbWnd.empty()) { ClearFloatingToolbarNavigationState(); const bool floatingToolbarOk = SUCCEEDED(webviewFtbWnd->NavigateToString(HTMLStringFtbWnd.c_str())); if (floatingToolbarOk) { loadedFloatingToolbarSkin = candidateSkin; } ok = floatingToolbarOk && ok; } if (webviewMenuWnd && !HTMLStringMenuWnd.empty()) { ok = SUCCEEDED(webviewMenuWnd->NavigateToString(HTMLStringMenuWnd.c_str())) && ok; } return ok; } bool ApplyConfiguredCandidateSkinIfChanged() { const std::string &candidateSkin = GetConfiguredCandidateSkin(); const bool candidateCurrent = !webviewCandWnd || loadedCandidateSkin == candidateSkin; const bool floatingToolbarCurrent = !webviewFtbWnd || loadedFloatingToolbarSkin == candidateSkin; if (candidateCurrent && floatingToolbarCurrent) { return true; } return ApplyConfiguredUiThemes(); } uint64_t GetCandidateSkinReloadRevision() { return candidateSkinReloadRevision; } bool ForceReloadConfiguredCandidateSkin() { ++candidateSkinReloadRevision; loadedCandidateSkin.clear(); loadedFloatingToolbarSkin.clear(); const bool cloakCandidate = ::is_global_wnd_cand_shown && ::global_hwnd && IsWindow(::global_hwnd) && !CandidatePresenter::Instance().IsBound(); if (cloakCandidate) SetCandidateHostCloaked(true); const bool ok = ApplyConfiguredUiThemes(); if (!ok && cloakCandidate) SetCandidateHostCloaked(false); return ok; } bool ApplyConfiguredCandidateAppearance() { if (CandidatePresenter::Instance().IsBound() && !webviewCandWnd) { return true; } if (!webviewCandWnd) { return false; } nlohmann::json cfg = {{"font", ResolveSystemFontFamilyForCss(GetConfiguredCandidateFont())}, {"english_font", ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont())}, {"fallback_fonts", GetConfiguredCandidateFallbackFontFamilies()}, {"font_size", GetConfiguredCandidateFontSize()}, {"preedit_font_size", GetConfiguredCandidateWindowPreeditFontSize()}, {"cand_text_color", GetConfiguredCandidateTextColor()}}; std::string family; auto appendFont = [&](const std::string &font) { // JSON quoting also escapes CSS quotes/backslashes; font names exclude control characters. if (!family.empty()) family += ", "; family += nlohmann::json(font).dump(-1, ' ', false); }; // 皮肤的 font_family 排在最前,用户配置的字体整体退为回退,与 D2D 端同序。 if (activeExternalCandidateSkin && !activeExternalCandidateSkin->fontFamily.empty()) appendFont(ResolveSystemFontFamilyForCss(activeExternalCandidateSkin->fontFamily)); appendFont(ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont())); for (const auto &font : GetConfiguredCandidateFallbackFontFamilies()) appendFont(font); cfg["font_family"] = family + ", sans-serif"; const std::wstring script = L"(function(c){" L"const root=document.documentElement;" L"const family=c.font_family;" L"root.style.setProperty('--cand-font-family', family);" L"root.style.setProperty('--cand-font-size', String(c.font_size||16)+'px');" L"root.style.setProperty('--preedit-font-size', String(c.preedit_font_size||c.font_size||16)+'px');" L"const color=(c.cand_text_color||'auto');" L"if(color&&color!=='auto'){" L"root.style.setProperty('--cand-text', color);" L"root.style.setProperty('--cand-num', color.length===7?color+'9d':color);" // 外部皮肤的 candidate_text / preedit_text 写成 var(--msime-user-text, 皮肤色),设置页的文字色照样压过它们。 L"root.style.setProperty('--msime-user-text', color);" L"}else{" L"root.style.removeProperty('--cand-text');" L"root.style.removeProperty('--cand-num');" L"root.style.removeProperty('--msime-user-text');" L"}" // Drop any stale nowrap fast-layout sheet so the skin's wrap-at-max-width // rules apply; forcing a single line makes the card overflow the cap and // get clipped by .container{overflow-x:hidden}. L"document.getElementById('msime-fast-layout')?.remove();" L"})(" + string_to_wstring(cfg.dump()) + L");"; return SUCCEEDED(webviewCandWnd->ExecuteScript(script.c_str(), nullptr)); } bool ApplyConfiguredFloatingToolbarAppearance() { return ApplyConfiguredFloatingToolbarAppearance(nullptr); } bool ApplyConfiguredFloatingToolbarAppearance(std::function onComplete) { if (FloatingToolbarPresenter::Instance().IsBound()) { FloatingToolbarPresenter::Instance().ApplyAppearance(); if (onComplete) { onComplete(); } return true; } if (!webviewFtbWnd) { if (onComplete) { onComplete(); } return false; } nlohmann::json cfg = {{"scale", GetConfiguredFloatingToolbarScale()}, {"font_size", GetConfiguredFloatingToolbarFontSize()}}; const std::wstring script = L"(function(c){" L"const root=document.documentElement;" L"const scale=(typeof c.scale==='number'&&c.scale>0)?c.scale:1;" L"const icon=(typeof c.font_size==='number'&&c.font_size>0)?c.font_size:24;" L"root.style.setProperty('--ftb-scale', String(scale));" L"root.style.setProperty('--ftb-icon-size', String(icon)+'px');" L"return true;" L"})(" + string_to_wstring(cfg.dump()) + L");"; if (!onComplete) { return SUCCEEDED(webviewFtbWnd->ExecuteScript(script.c_str(), nullptr)); } return SUCCEEDED(webviewFtbWnd->ExecuteScript( script.c_str(), Callback([onComplete](HRESULT, LPCWSTR) -> HRESULT { onComplete(); return S_OK; }).Get())); }