openpilot/system/ui/widgets/html_render.py
mouxangithub 6f2bd6efa3 feat(ui): 将字体权重统一调整为 CHINA 以支持中文字体渲染
将多个 UI 组件中的字体权重从原有的 `MEDIUM`、`NORMAL`、`BOLD` 等值统一修改为 `CHINA`,
以确保界面文本能够正确使用中文字体进行显示。同时调整了部分字号和标签样式,提升中文环境下的展示效果。
2025-11-20 11:43:26 +08:00

291 lines
10 KiB
Python

import re
import pyray as rl
from dataclasses import dataclass
from enum import Enum
from typing import Any
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.lib.text_measure import measure_text_cached
LIST_INDENT_PX = 40
class ElementType(Enum):
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
H5 = "h5"
H6 = "h6"
P = "p"
B = "b"
UL = "ul"
LI = "li"
BR = "br"
TAG_NAMES = '|'.join([t.value for t in ElementType])
START_TAG_RE = re.compile(f'<({TAG_NAMES})>')
END_TAG_RE = re.compile(f'</({TAG_NAMES})>')
COMMENT_RE = re.compile(r'<!--.*?-->', flags=re.DOTALL)
DOCTYPE_RE = re.compile(r'<!DOCTYPE[^>]*>')
HTML_BODY_TAGS_RE = re.compile(r'</?(?:html|head|body)[^>]*>')
TOKEN_RE = re.compile(r'</[^>]+>|<[^>]+>|[^<\s]+')
def is_tag(token: str) -> tuple[bool, bool, ElementType | None]:
supported_tag = bool(START_TAG_RE.fullmatch(token))
supported_end_tag = bool(END_TAG_RE.fullmatch(token))
tag = ElementType(token[1:-1].strip('/')) if supported_tag or supported_end_tag else None
return supported_tag, supported_end_tag, tag
@dataclass
class HtmlElement:
type: ElementType
content: str
font_size: int
font_weight: FontWeight
margin_top: int
margin_bottom: int
line_height: float = 1.3 # matches Qt visually, unsure why not default 1.2
indent_level: int = 0
class HtmlRenderer(Widget):
def __init__(self, file_path: str | None = None, text: str | None = None,
text_size: dict | None = None, text_color: rl.Color = rl.WHITE, center_text: bool = False):
super().__init__()
self._text_color = text_color
self._center_text = center_text
self._normal_font = gui_app.font(FontWeight.CHINA)
self._bold_font = gui_app.font(FontWeight.CHINA)
self._indent_level = 0
if text_size is None:
text_size = {}
self._cached_height: float | None = None
self._cached_width: int = -1
# Base paragraph size (Qt stylesheet default is 48px in offroad alerts)
base_p_size = int(text_size.get(ElementType.P, 48))
# Untagged text defaults to <p>
self.styles: dict[ElementType, dict[str, Any]] = {
ElementType.H1: {"size": round(base_p_size * 2), "weight": FontWeight.CHINA, "margin_top": 20, "margin_bottom": 16},
ElementType.H2: {"size": round(base_p_size * 1.50), "weight": FontWeight.CHINA, "margin_top": 24, "margin_bottom": 12},
ElementType.H3: {"size": round(base_p_size * 1.17), "weight": FontWeight.CHINA, "margin_top": 20, "margin_bottom": 10},
ElementType.H4: {"size": round(base_p_size * 1.00), "weight": FontWeight.CHINA, "margin_top": 16, "margin_bottom": 8},
ElementType.H5: {"size": round(base_p_size * 0.83), "weight": FontWeight.CHINA, "margin_top": 12, "margin_bottom": 6},
ElementType.H6: {"size": round(base_p_size * 0.67), "weight": FontWeight.CHINA, "margin_top": 10, "margin_bottom": 4},
ElementType.P: {"size": base_p_size, "weight": FontWeight.CHINA, "margin_top": 8, "margin_bottom": 12},
ElementType.B: {"size": base_p_size, "weight": FontWeight.CHINA, "margin_top": 8, "margin_bottom": 12},
ElementType.LI: {"size": base_p_size, "weight": FontWeight.CHINA, "color": rl.Color(40, 40, 40, 255), "margin_top": 6, "margin_bottom": 6},
ElementType.BR: {"size": 0, "weight": FontWeight.CHINA, "margin_top": 0, "margin_bottom": 12},
}
self.elements: list[HtmlElement] = []
if file_path is not None:
self.parse_html_file(file_path)
elif text is not None:
self.parse_html_content(text)
else:
raise ValueError("Either file_path or text must be provided")
def parse_html_file(self, file_path: str) -> None:
with open(file_path, encoding='utf-8') as file:
content = file.read()
self.parse_html_content(content)
def parse_html_content(self, html_content: str) -> None:
self.elements.clear()
self._cached_height = None
self._cached_width = -1
# Remove HTML comments
html_content = COMMENT_RE.sub('', html_content)
# Remove DOCTYPE, html, head, body tags but keep their content
html_content = DOCTYPE_RE.sub('', html_content)
html_content = HTML_BODY_TAGS_RE.sub('', html_content)
# Parse HTML
tokens = TOKEN_RE.findall(html_content)
def close_tag():
nonlocal current_content
nonlocal current_tag
# If no tag is set, default to paragraph so we don't lose text
if current_tag is None:
current_tag = ElementType.P
text = ' '.join(current_content).strip()
current_content = []
if text:
if current_tag == ElementType.LI:
text = '' + text
self._add_element(current_tag, text)
current_content: list[str] = []
current_tag: ElementType | None = None
for token in tokens:
is_start_tag, is_end_tag, tag = is_tag(token)
if tag is not None:
if tag == ElementType.BR:
# Close current tag and add a line break
close_tag()
self._add_element(ElementType.BR, "")
elif is_start_tag or is_end_tag:
# Always add content regardless of opening or closing tag
close_tag()
if is_start_tag:
current_tag = tag
else:
current_tag = None
# increment after we add the content for the current tag
if tag == ElementType.UL:
self._indent_level = self._indent_level + 1 if is_start_tag else max(0, self._indent_level - 1)
else:
current_content.append(token)
if current_content:
close_tag()
def _add_element(self, element_type: ElementType, content: str) -> None:
style = self.styles[element_type]
element = HtmlElement(
type=element_type,
content=content,
font_size=style["size"],
font_weight=style["weight"],
margin_top=style["margin_top"],
margin_bottom=style["margin_bottom"],
indent_level=self._indent_level,
)
self.elements.append(element)
def _render(self, rect: rl.Rectangle):
# TODO: speed up by removing duplicate calculations across renders
current_y = rect.y
padding = 20
content_width = rect.width - (padding * 2)
for element in self.elements:
if element.type == ElementType.BR:
current_y += element.margin_bottom
continue
current_y += element.margin_top
if current_y > rect.y + rect.height:
break
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(content_width))
for line in wrapped_lines:
# Use FONT_SCALE from wrapped raylib text functions to match what is drawn
if current_y < rect.y - element.font_size * FONT_SCALE:
current_y += element.font_size * FONT_SCALE * element.line_height
continue
if current_y > rect.y + rect.height:
break
if self._center_text:
text_width = measure_text_cached(font, line, element.font_size).x
text_x = rect.x + (rect.width - text_width) / 2
else: # left align
text_x = rect.x + (max(element.indent_level - 1, 0) * LIST_INDENT_PX)
rl.draw_text_ex(font, line, rl.Vector2(text_x + padding, current_y), element.font_size, 0, self._text_color)
current_y += element.font_size * FONT_SCALE * element.line_height
# Apply bottom margin
current_y += element.margin_bottom
return current_y - rect.y
def get_total_height(self, content_width: int) -> float:
if self._cached_height is not None and self._cached_width == content_width:
return self._cached_height
total_height = 0.0
padding = 20
usable_width = content_width - (padding * 2)
for element in self.elements:
if element.type == ElementType.BR:
total_height += element.margin_bottom
continue
total_height += element.margin_top
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(usable_width))
for _ in wrapped_lines:
total_height += element.font_size * FONT_SCALE * element.line_height
total_height += element.margin_bottom
# Store result in cache
self._cached_height = total_height
self._cached_width = content_width
return total_height
def _get_font(self, weight: FontWeight):
if weight == FontWeight.CHINA:
return self._bold_font
return self._normal_font
class HtmlModal(Widget):
def __init__(self, file_path: str | None = None, text: str | None = None):
super().__init__()
self._content = HtmlRenderer(file_path=file_path, text=text)
self._scroll_panel = GuiScrollPanel()
self._ok_button = Button(tr("OK"), click_callback=lambda: gui_app.set_modal_overlay(None), button_style=ButtonStyle.PRIMARY)
def _render(self, rect: rl.Rectangle):
margin = 50
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - (margin * 2), rect.height - (margin * 2))
button_height = 160
button_spacing = 20
scrollable_height = content_rect.height - button_height - button_spacing
scrollable_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, scrollable_height)
total_height = self._content.get_total_height(int(scrollable_rect.width))
scroll_content_rect = rl.Rectangle(scrollable_rect.x, scrollable_rect.y, scrollable_rect.width, total_height)
scroll_offset = self._scroll_panel.update(scrollable_rect, scroll_content_rect)
scroll_content_rect.y += scroll_offset
rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height))
self._content.render(scroll_content_rect)
rl.end_scissor_mode()
button_width = (rect.width - 3 * 50) // 3
button_x = content_rect.x + content_rect.width - button_width
button_y = content_rect.y + content_rect.height - button_height
button_rect = rl.Rectangle(button_x, button_y, button_width, button_height)
self._ok_button.render(button_rect)
return -1