ui: simple HTML parser for regulatory Views (#35525)
* simple HTML parser for regulatory Views * format --------- Co-authored-by: Shane Smiskol <shane@smiskol.com>pull/35536/head
parent
f3d0a9ea13
commit
723a52626d
3 changed files with 209 additions and 7 deletions
@ -0,0 +1,194 @@ |
||||
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 |
||||
from openpilot.system.ui.lib.wrap_text import wrap_text |
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel |
||||
from openpilot.system.ui.lib.widget import Widget, DialogResult |
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle |
||||
|
||||
|
||||
class ElementType(Enum): |
||||
H1 = "h1" |
||||
H2 = "h2" |
||||
H3 = "h3" |
||||
H4 = "h4" |
||||
H5 = "h5" |
||||
H6 = "h6" |
||||
P = "p" |
||||
BR = "br" |
||||
|
||||
|
||||
@dataclass |
||||
class HtmlElement: |
||||
type: ElementType |
||||
content: str |
||||
font_size: int |
||||
font_weight: FontWeight |
||||
color: rl.Color |
||||
margin_top: int |
||||
margin_bottom: int |
||||
line_height: float = 1.2 |
||||
|
||||
|
||||
class HtmlRenderer(Widget): |
||||
def __init__(self, file_path: str): |
||||
self.elements: list[HtmlElement] = [] |
||||
self._normal_font = gui_app.font(FontWeight.NORMAL) |
||||
self._bold_font = gui_app.font(FontWeight.BOLD) |
||||
self._scroll_panel = GuiScrollPanel() |
||||
|
||||
self.styles: dict[ElementType, dict[str, Any]] = { |
||||
ElementType.H1: {"size": 68, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 16}, |
||||
ElementType.H2: {"size": 60, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 24, "margin_bottom": 12}, |
||||
ElementType.H3: {"size": 52, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 10}, |
||||
ElementType.H4: {"size": 48, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 16, "margin_bottom": 8}, |
||||
ElementType.H5: {"size": 44, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 12, "margin_bottom": 6}, |
||||
ElementType.H6: {"size": 40, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 10, "margin_bottom": 4}, |
||||
ElementType.P: {"size": 38, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 8, "margin_bottom": 12}, |
||||
ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "color": rl.BLACK, "margin_top": 0, "margin_bottom": 12}, |
||||
} |
||||
|
||||
self.parse_html_file(file_path) |
||||
|
||||
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() |
||||
|
||||
# Remove HTML comments |
||||
html_content = re.sub(r'<!--.*?-->', '', html_content, flags=re.DOTALL) |
||||
|
||||
# Remove DOCTYPE, html, head, body tags but keep their content |
||||
html_content = re.sub(r'<!DOCTYPE[^>]*>', '', html_content) |
||||
html_content = re.sub(r'</?(?:html|head|body)[^>]*>', '', html_content) |
||||
|
||||
# Find all HTML elements |
||||
pattern = r'<(h[1-6]|p)(?:[^>]*)>(.*?)</\1>|<br\s*/?>' |
||||
matches = re.finditer(pattern, html_content, re.DOTALL | re.IGNORECASE) |
||||
|
||||
for match in matches: |
||||
if match.group(0).lower().startswith('<br'): |
||||
# Handle <br> tags |
||||
self._add_element(ElementType.BR, "") |
||||
else: |
||||
tag = match.group(1).lower() |
||||
content = match.group(2).strip() |
||||
|
||||
# Clean up content - remove extra whitespace |
||||
content = re.sub(r'\s+', ' ', content) |
||||
content = content.strip() |
||||
|
||||
if content: # Only add non-empty elements |
||||
element_type = ElementType(tag) |
||||
self._add_element(element_type, content) |
||||
|
||||
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"], |
||||
color=style["color"], |
||||
margin_top=style["margin_top"], |
||||
margin_bottom=style["margin_bottom"], |
||||
) |
||||
|
||||
self.elements.append(element) |
||||
|
||||
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.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.handle_scroll(scrollable_rect, scroll_content_rect) |
||||
|
||||
rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height)) |
||||
self._render_content(scrollable_rect, scroll_offset.y) |
||||
rl.end_scissor_mode() |
||||
|
||||
button_width = (rect.width - 3 * 50) // 3 |
||||
button_x = content_rect.x + (content_rect.width - button_width) / 2 |
||||
button_y = content_rect.y + content_rect.height - button_height |
||||
button_rect = rl.Rectangle(button_x, button_y, button_width, button_height) |
||||
if gui_button(button_rect, "OK", button_style=ButtonStyle.PRIMARY) == 1: |
||||
return DialogResult.CONFIRM |
||||
|
||||
return DialogResult.NO_ACTION |
||||
|
||||
def _render_content(self, rect: rl.Rectangle, scroll_offset: float = 0) -> float: |
||||
current_y = rect.y + scroll_offset |
||||
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: |
||||
if current_y < rect.y - element.font_size: |
||||
current_y += element.font_size * element.line_height |
||||
continue |
||||
|
||||
if current_y > rect.y + rect.height: |
||||
break |
||||
|
||||
rl.draw_text_ex(font, line, rl.Vector2(rect.x + padding, current_y), element.font_size, 0, rl.WHITE) |
||||
|
||||
current_y += element.font_size * element.line_height |
||||
|
||||
# Apply bottom margin |
||||
current_y += element.margin_bottom |
||||
|
||||
return current_y - rect.y - scroll_offset # Return total content height |
||||
|
||||
def get_total_height(self, content_width: int) -> float: |
||||
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 * element.line_height |
||||
|
||||
total_height += element.margin_bottom |
||||
|
||||
return total_height |
||||
|
||||
def _get_font(self, weight: FontWeight): |
||||
if weight == FontWeight.BOLD: |
||||
return self._bold_font |
||||
return self._normal_font |
Loading…
Reference in new issue