Coverage for lobster/htmldoc/htmldoc.py: 12%
166 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-05-12 15:02 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-05-12 15:02 +0000
1#!/usr/bin/env python3
2#
3# lobster_html_report - Visualise LOBSTER report in HTML
4# Copyright (C) 2022-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
5#
6# This program is free software: you can redistribute it and/or modify
7# it under the terms of the GNU Affero General Public License as
8# published by the Free Software Foundation, either version 3 of the
9# License, or (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful, but
12# WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14# Affero General Public License for more details.
15#
16# You should have received a copy of the GNU Affero General Public
17# License along with this program. If not, see
18# <https://www.gnu.org/licenses/>.
20import html
21from abc import abstractmethod, ABC
22from typing import List, Optional
24from lobster.htmldoc import assets
26NAVBAR_STICKY_SCRIPT = """
27window.onscroll = function() {stickyNavbar()};
29var navbar = document.getElementById("navbar");
30var sticky = navbar.offsetTop;
32function stickyNavbar() {
33 if (window.pageYOffset >= sticky) {
34 navbar.classList.add("sticky")
35 } else {
36 navbar.classList.remove("sticky");
37 }
38}
39"""
42class Menu_Item(ABC):
43 def __init__(self, name):
44 self.name = name
46 @abstractmethod
47 def generate(self, doc) -> List[str]:
48 pass
51class Menu_Link(Menu_Item):
52 def __init__(self, name, target):
53 super().__init__(name)
55 self.target = target
57 def generate(self, doc) -> List[str]:
58 rv = (
59 f'<a href="{self.target}" '
60 f'id="menu-item-{html.escape(self.name).replace(" ", "-").lower()}">'
61 )
62 if self.target.startswith("http"):
63 rv += '<svg class="icon"><use href="#svg-external-link"></use></svg>' + " "
64 rv += html.escape(self.name)
65 rv += "</a>"
66 return [rv]
69class Dropdown_Menu(Menu_Item):
70 def __init__(self, name):
71 super().__init__(name)
72 self.items = []
74 def add_link(self, name, target):
75 self.items.append(Menu_Link(name, target))
77 def generate(self, doc) -> List[str]:
79 doc.style["#navbar .dropdown"] = {
80 "float" : "left",
81 "overflow" : "hidden",
82 }
83 doc.style[".navbar-right .dropdown"] = {
84 "float" : "right",
85 "overflow" : "hidden",
86 }
87 doc.style[".dropdown .dropbtn"] = {
88 "font-size" : "inherit",
89 "border" : "none",
90 "outline" : "none",
91 "padding" : "14px 16px",
92 "background-color" : "inherit",
93 "color" : "white",
94 "font-family" : "inherit",
95 "margin" : "0",
96 }
97 doc.style[".dropdown:hover .dropbtn"] = {
98 "background-color" : "white",
99 "color" : doc.primary_color,
100 }
101 doc.style[".dropdown-content"] = {
102 "display" : "none",
103 "position" : "absolute",
104 "background-color" : doc.primary_color,
105 "box-shadow" : "0px 8px 16px 0px rgba(0,0,0,0.2)",
106 "z-index" : "1",
107 }
108 doc.style[".navbar-right .dropdown-content"] = {
109 "right" : "0",
110 }
111 doc.style[".dropdown-content a"] = {
112 "float" : "none",
113 "color" : "white",
114 "padding" : "12px 16px",
115 "text-decoration" : "none",
116 "display" : "block",
117 "text-align" : "left",
118 }
119 doc.style[".dropdown-content a:hover"] = {
120 "color" : doc.primary_color,
121 "background-color" : "white",
122 }
123 doc.style[".dropdown:hover .dropdown-content"] = {
124 "display" : "flex",
125 "flex-direction" : "column",
126 }
127 doc.style[".sticky .dropdown-content"] = {
128 "position" : "fixed",
129 }
131 rv = ['<div class="dropdown">']
132 rv.append(
133 f'<button class="dropbtn">{html.escape(self.name)}'
134 '<svg class="icon"><use href="#svg-chevron-down"></use></svg>'
135 '</button>'
136 )
137 rv.append('<div class="dropdown-content">')
138 for item in self.items:
139 rv += item.generate(doc)
140 rv.append("</div>")
141 rv.append("</div>")
142 return rv
145class Navigation_Bar:
146 def __init__(self):
147 self.left_items: List[Menu_Item] = []
148 self.right_items: List[Menu_Item] = []
150 def add_link(self, name, target, alignment="left"):
151 assert alignment in ("left", "right")
153 item = Menu_Link(name, target)
154 if alignment == "left":
155 self.left_items.append(item)
156 else:
157 self.right_items.append(item)
159 def add_dropdown(self, name, alignment="left"):
160 assert alignment in ("left", "right")
162 menu = Dropdown_Menu(name)
163 if alignment == "left":
164 self.left_items.append(menu)
165 else:
166 self.right_items.append(menu)
168 return menu
170 def generate(self, doc):
171 doc.style["#navbar"] = {
172 "overflow" : "hidden",
173 "background-color" : doc.primary_color,
174 }
175 if self.right_items:
176 doc.style[".navbar-right"] = {
177 "float" : "right",
178 }
179 doc.style["#navbar a"] = {
180 "float" : "left",
181 "display" : "block",
182 "color" : "white",
183 "padding" : "14px",
184 "text-decoration" : "none",
185 }
186 doc.style["#navbar a:hover"] = {
187 "background-color" : "white",
188 "color" : doc.primary_color,
189 }
190 doc.style[".sticky"] = {
191 "position" : "fixed",
192 "top" : "0",
193 "width" : "100%",
194 }
195 doc.style[".sticky + .htmlbody"] = {
196 "padding-top" : "60px",
197 }
199 doc.scripts.append(NAVBAR_STICKY_SCRIPT)
201 rv = []
203 rv.append('<div id="navbar">')
204 for item in self.left_items:
205 rv += item.generate(doc)
206 if self.right_items:
207 rv.append('<div class="navbar-right">')
208 for item in self.right_items:
209 rv += item.generate(doc)
210 rv.append('</div>')
211 rv.append('</div>')
213 return rv
216class Document:
217 def __init__(self, title, subtitle):
218 self.title = title
219 self.subtitle = subtitle
221 self.primary_color = "#009ada"
223 self.navbar = Navigation_Bar()
224 self.style = {
225 "html" : {"scroll-padding-top": "5em"},
226 "body" : {"margin": "0"},
227 ".title" : {
228 "background-color" : self.primary_color,
229 "color" : "white",
230 "padding" : "0.5em",
231 "margin" : "0"
232 },
233 "h1" : {
234 "padding" : "0",
235 "margin" : "0"
236 },
237 "h2" : {
238 "padding" : "0.5em",
239 "margin" : "0",
240 "border-bottom" : f"0.25em solid {self.primary_color}",
241 "text-align" : "right",
242 },
243 ".content" : {
244 "padding" : "0.5em",
245 },
246 ".icon" : {
247 "width": "24px",
248 "height": "24px",
249 "vertical-align": "middle",
250 }
251 }
252 self.scripts = []
253 self.body = []
254 self.css = []
256 def add_line(self, line):
257 if len(self.body) == 0:
258 self.body.append('<div class="content">')
259 self.body.append(line)
261 def add_heading(
262 self,
263 level: int,
264 text: str,
265 anchor: Optional[str] = None,
266 html_identifier: bool = False,
267 ):
268 assert 2 <= level <= 7
270 if level == 2 and self.body:
271 self.body.append("</div>")
273 heading_identifier = f'heading-{text.replace(" ", "-").lower()}'
274 if anchor is None:
275 if html_identifier:
276 self.body.append(
277 f'<h{level} id="{heading_identifier}">'
278 f'{text}</h{level}>'
279 )
280 else:
281 self.body.append(f'<h{level}>{text}</h{level}>')
282 else:
283 if html_identifier:
284 self.body.append(
285 f'<h{level} id="sec-{anchor}" '
286 f'class="{heading_identifier}">'
287 f'{text}</h{level}>'
288 )
289 else:
290 self.body.append(f'<h{level} id="sec-{anchor}">{text}</h{level}>')
292 if level == 2:
293 self.body.append('<div class="content">')
295 def render(self):
296 navbar_content = self.navbar.generate(self)
298 rv = [
299 "<!DOCTYPE html>",
300 "<html>",
301 "<head>",
302 "<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>",
303 f"<title>{html.escape(self.title)}</title>",
304 "<style>"
305 ]
306 for elem, style in self.style.items():
307 rv.append("%s {" % elem)
308 for attr, value in style.items():
309 rv.append(f" {attr}: {value};")
310 rv.append("}")
312 # add css files that are appended to self.files
313 for css_file in self.css:
314 rv.append(css_file)
315 rv.append("</style>")
316 rv.append("</head>")
317 rv.append("<body>")
319 rv.append('<div class="title">')
320 rv.append(f"<h1>{html.escape(self.title)}</h1>")
321 rv.append(f'<div class="subtitle">{html.escape(self.subtitle)}</div>')
322 rv.append('</div>')
324 rv += navbar_content
326 rv.append('<div class="htmlbody">')
327 rv.append('<svg style="display: none;">')
328 rv.append('<defs>')
329 rv.append('<symbol id="svg-check-square" viewBox="0 0 24 24">')
330 rv.append(assets.SVG_CHECK_SQUARE)
331 rv.append('</symbol>')
332 rv.append('</defs>')
333 rv.append('</svg>')
335 rv.append('<svg style="display: none;">')
336 rv.append('<defs>')
337 rv.append('<symbol id="svg-alert-triangle" viewBox="0 0 24 24">')
338 rv.append(assets.SVG_ALERT_TRIANGLE)
339 rv.append('</symbol>')
340 rv.append('</defs>')
341 rv.append('</svg>')
343 rv.append('<svg style="display: none;">')
344 rv.append('<defs>')
345 rv.append('<symbol id="svg-external-link" viewBox="0 0 24 24">')
346 rv.append(assets.SVG_EXTERNAL_LINK)
347 rv.append('</symbol>')
348 rv.append('</defs>')
349 rv.append('</svg>')
351 rv.append('<svg style="display: none;">')
352 rv.append('<defs>')
353 rv.append('<symbol id="svg-chevron-down" viewBox="0 0 24 24">')
354 rv.append(assets.SVG_CHEVRON_DOWN)
355 rv.append('</symbol>')
356 rv.append('</defs>')
357 rv.append('</svg>')
359 rv += self.body
360 rv.append('</div>')
361 rv.append('</div>')
363 for script in self.scripts:
364 rv.append("<script>")
365 rv += script.splitlines()
366 rv.append("</script>")
368 rv.append("</body>")
369 rv.append("</html>")
371 return "\n".join(rv)