Coverage for lobster/htmldoc/htmldoc.py: 12%
166 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-03-04 12:54 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-03-04 12:54 +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('<button class="dropbtn">%s%s</button>' %
133 (html.escape(self.name),
134 '<svg class="icon"><use href="#svg-chevron-down"></use></svg>'))
135 rv.append('<div class="dropdown-content">')
136 for item in self.items:
137 rv += item.generate(doc)
138 rv.append("</div>")
139 rv.append("</div>")
140 return rv
143class Navigation_Bar:
144 def __init__(self):
145 self.left_items: List[Menu_Item] = []
146 self.right_items: List[Menu_Item] = []
148 def add_link(self, name, target, alignment="left"):
149 assert alignment in ("left", "right")
151 item = Menu_Link(name, target)
152 if alignment == "left":
153 self.left_items.append(item)
154 else:
155 self.right_items.append(item)
157 def add_dropdown(self, name, alignment="left"):
158 assert alignment in ("left", "right")
160 menu = Dropdown_Menu(name)
161 if alignment == "left":
162 self.left_items.append(menu)
163 else:
164 self.right_items.append(menu)
166 return menu
168 def generate(self, doc):
169 doc.style["#navbar"] = {
170 "overflow" : "hidden",
171 "background-color" : doc.primary_color,
172 }
173 if self.right_items:
174 doc.style[".navbar-right"] = {
175 "float" : "right",
176 }
177 doc.style["#navbar a"] = {
178 "float" : "left",
179 "display" : "block",
180 "color" : "white",
181 "padding" : "14px",
182 "text-decoration" : "none",
183 }
184 doc.style["#navbar a:hover"] = {
185 "background-color" : "white",
186 "color" : doc.primary_color,
187 }
188 doc.style[".sticky"] = {
189 "position" : "fixed",
190 "top" : "0",
191 "width" : "100%",
192 }
193 doc.style[".sticky + .htmlbody"] = {
194 "padding-top" : "60px",
195 }
197 doc.scripts.append(NAVBAR_STICKY_SCRIPT)
199 rv = []
201 rv.append('<div id="navbar">')
202 for item in self.left_items:
203 rv += item.generate(doc)
204 if self.right_items:
205 rv.append('<div class="navbar-right">')
206 for item in self.right_items:
207 rv += item.generate(doc)
208 rv.append('</div>')
209 rv.append('</div>')
211 return rv
214class Document:
215 def __init__(self, title, subtitle):
216 self.title = title
217 self.subtitle = subtitle
219 self.primary_color = "#009ada"
221 self.navbar = Navigation_Bar()
222 self.style = {
223 "html" : {"scroll-padding-top": "5em"},
224 "body" : {"margin": "0"},
225 ".title" : {
226 "background-color" : self.primary_color,
227 "color" : "white",
228 "padding" : "0.5em",
229 "margin" : "0"
230 },
231 "h1" : {
232 "padding" : "0",
233 "margin" : "0"
234 },
235 "h2" : {
236 "padding" : "0.5em",
237 "margin" : "0",
238 "border-bottom" : "0.25em solid %s" % self.primary_color,
239 "text-align" : "right",
240 },
241 ".content" : {
242 "padding" : "0.5em",
243 },
244 ".icon" : {
245 "width": "24px",
246 "height": "24px",
247 "vertical-align": "middle",
248 }
249 }
250 self.scripts = []
251 self.body = []
252 self.css = []
254 def add_line(self, line):
255 if len(self.body) == 0:
256 self.body.append('<div class="content">')
257 self.body.append(line)
259 def add_heading(
260 self,
261 level: int,
262 text: str,
263 anchor: Optional[str] = None,
264 html_identifier: bool = False,
265 ):
266 assert 2 <= level <= 7
268 if level == 2 and self.body:
269 self.body.append("</div>")
271 heading_identifier = f'heading-{text.replace(" ", "-").lower()}'
272 if anchor is None:
273 if html_identifier:
274 self.body.append(
275 f'<h{level} id="{heading_identifier}">'
276 f'{text}</h{level}>'
277 )
278 else:
279 self.body.append(f'<h{level}>{text}</h{level}>')
280 else:
281 if html_identifier:
282 self.body.append(
283 f'<h{level} id="sec-{anchor}" '
284 f'class="{heading_identifier}">'
285 f'{text}</h{level}>'
286 )
287 else:
288 self.body.append(f'<h{level} id="sec-{anchor}">{text}</h{level}>')
290 if level == 2:
291 self.body.append('<div class="content">')
293 def render(self):
294 navbar_content = self.navbar.generate(self)
296 rv = [
297 "<!DOCTYPE html>",
298 "<html>",
299 "<head>",
300 "<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>",
301 "<title>%s</title>" % html.escape(self.title),
302 "<style>"
303 ]
304 for elem, style in self.style.items():
305 rv.append("%s {" % elem)
306 for attr, value in style.items():
307 rv.append(" %s: %s;" % (attr, value))
308 rv.append("}")
310 # add css files that are appended to self.files
311 for css_file in self.css:
312 rv.append(css_file)
313 rv.append("</style>")
314 rv.append("</head>")
315 rv.append("<body>")
317 rv.append('<div class="title">')
318 rv.append("<h1>%s</h1>" % html.escape(self.title))
319 rv.append('<div class="subtitle">%s</div>' %
320 html.escape(self.subtitle))
321 rv.append('</div>')
323 rv += navbar_content
325 rv.append('<div class="htmlbody">')
326 rv.append('<svg style="display: none;">')
327 rv.append('<defs>')
328 rv.append('<symbol id="svg-check-square" viewBox="0 0 24 24">')
329 rv.append(assets.SVG_CHECK_SQUARE)
330 rv.append('</symbol>')
331 rv.append('</defs>')
332 rv.append('</svg>')
334 rv.append('<svg style="display: none;">')
335 rv.append('<defs>')
336 rv.append('<symbol id="svg-alert-triangle" viewBox="0 0 24 24">')
337 rv.append(assets.SVG_ALERT_TRIANGLE)
338 rv.append('</symbol>')
339 rv.append('</defs>')
340 rv.append('</svg>')
342 rv.append('<svg style="display: none;">')
343 rv.append('<defs>')
344 rv.append('<symbol id="svg-external-link" viewBox="0 0 24 24">')
345 rv.append(assets.SVG_EXTERNAL_LINK)
346 rv.append('</symbol>')
347 rv.append('</defs>')
348 rv.append('</svg>')
350 rv.append('<svg style="display: none;">')
351 rv.append('<defs>')
352 rv.append('<symbol id="svg-chevron-down" viewBox="0 0 24 24">')
353 rv.append(assets.SVG_CHEVRON_DOWN)
354 rv.append('</symbol>')
355 rv.append('</defs>')
356 rv.append('</svg>')
358 rv += self.body
359 rv.append('</div>')
360 rv.append('</div>')
362 for script in self.scripts:
363 rv.append("<script>")
364 rv += script.splitlines()
365 rv.append("</script>")
367 rv.append("</body>")
368 rv.append("</html>")
370 return "\n".join(rv)