Coverage for lobster/html/htmldoc.py: 11%
177 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 09:51 +0000
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 09:51 +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
22from lobster.html import assets
24NAVBAR_STICKY_SCRIPT = """
25window.onscroll = function() {stickyNavbar()};
27var navbar = document.getElementById("navbar");
28var sticky = navbar.offsetTop;
30function stickyNavbar() {
31 if (window.pageYOffset >= sticky) {
32 navbar.classList.add("sticky")
33 } else {
34 navbar.classList.remove("sticky");
35 }
36}
37"""
40class Menu_Item:
41 def __init__(self, name):
42 assert isinstance(name, str)
43 self.name = name
45 def generate(self, doc):
46 assert isinstance(doc, Document)
47 assert False
50class Menu_Link(Menu_Item):
51 def __init__(self, name, target):
52 assert isinstance(target, str)
53 super().__init__(name)
55 self.target = target
57 def generate(self, doc):
58 assert isinstance(doc, Document)
59 rv = (
60 f'<a href="{self.target}" '
61 f'id="menu-item-{html.escape(self.name).replace(" ", "-").lower()}">'
62 )
63 if self.target.startswith("http"):
64 rv += '<svg class="icon"><use href="#svg-external-link"></use></svg>' + " "
65 rv += html.escape(self.name)
66 rv += "</a>"
67 return [rv]
70class Dropdown_Menu(Menu_Item):
71 def __init__(self, name):
72 super().__init__(name)
73 self.items = []
75 def add_link(self, name, target):
76 self.items.append(Menu_Link(name, target))
78 def generate(self, doc):
79 assert isinstance(doc, Document)
81 doc.style["#navbar .dropdown"] = {
82 "float" : "left",
83 "overflow" : "hidden",
84 }
85 doc.style[".navbar-right .dropdown"] = {
86 "float" : "right",
87 "overflow" : "hidden",
88 }
89 doc.style[".dropdown .dropbtn"] = {
90 "font-size" : "inherit",
91 "border" : "none",
92 "outline" : "none",
93 "padding" : "14px 16px",
94 "background-color" : "inherit",
95 "color" : "white",
96 "font-family" : "inherit",
97 "margin" : "0",
98 }
99 doc.style[".dropdown:hover .dropbtn"] = {
100 "background-color" : "white",
101 "color" : doc.primary_color,
102 }
103 doc.style[".dropdown-content"] = {
104 "display" : "none",
105 "position" : "absolute",
106 "background-color" : doc.primary_color,
107 "box-shadow" : "0px 8px 16px 0px rgba(0,0,0,0.2)",
108 "z-index" : "1",
109 }
110 doc.style[".navbar-right .dropdown-content"] = {
111 "right" : "0",
112 }
113 doc.style[".dropdown-content a"] = {
114 "float" : "none",
115 "color" : "white",
116 "padding" : "12px 16px",
117 "text-decoration" : "none",
118 "display" : "block",
119 "text-align" : "left",
120 }
121 doc.style[".dropdown-content a:hover"] = {
122 "color" : doc.primary_color,
123 "background-color" : "white",
124 }
125 doc.style[".dropdown:hover .dropdown-content"] = {
126 "display" : "flex",
127 "flex-direction" : "column",
128 }
129 doc.style[".sticky .dropdown-content"] = {
130 "position" : "fixed",
131 }
133 rv = ['<div class="dropdown">']
134 rv.append('<button class="dropbtn">%s%s</button>' %
135 (html.escape(self.name),
136 '<svg class="icon"><use href="#svg-chevron-down"></use></svg>'))
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 = []
148 self.right_items = []
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 assert isinstance(doc, Document)
173 doc.style["#navbar"] = {
174 "overflow" : "hidden",
175 "background-color" : doc.primary_color,
176 }
177 if self.right_items:
178 doc.style[".navbar-right"] = {
179 "float" : "right",
180 }
181 doc.style["#navbar a"] = {
182 "float" : "left",
183 "display" : "block",
184 "color" : "white",
185 "padding" : "14px",
186 "text-decoration" : "none",
187 }
188 doc.style["#navbar a:hover"] = {
189 "background-color" : "white",
190 "color" : doc.primary_color,
191 }
192 doc.style[".sticky"] = {
193 "position" : "fixed",
194 "top" : "0",
195 "width" : "100%",
196 }
197 doc.style[".sticky + .htmlbody"] = {
198 "padding-top" : "60px",
199 }
201 doc.scripts.append(NAVBAR_STICKY_SCRIPT)
203 rv = []
205 rv.append('<div id="navbar">')
206 for item in self.left_items:
207 rv += item.generate(doc)
208 if self.right_items:
209 rv.append('<div class="navbar-right">')
210 for item in self.right_items:
211 rv += item.generate(doc)
212 rv.append('</div>')
213 rv.append('</div>')
215 return rv
218class Document:
219 def __init__(self, title, subtitle):
220 assert isinstance(title, str)
221 assert isinstance(subtitle, str)
222 self.title = title
223 self.subtitle = subtitle
225 self.primary_color = "#009ada"
227 self.navbar = Navigation_Bar()
228 self.style = {
229 "html" : {"scroll-padding-top": "5em"},
230 "body" : {"margin": "0"},
231 ".title" : {
232 "background-color" : self.primary_color,
233 "color" : "white",
234 "padding" : "0.5em",
235 "margin" : "0"
236 },
237 "h1" : {
238 "padding" : "0",
239 "margin" : "0"
240 },
241 "h2" : {
242 "padding" : "0.5em",
243 "margin" : "0",
244 "border-bottom" : "0.25em solid %s" % self.primary_color,
245 "text-align" : "right",
246 },
247 ".content" : {
248 "padding" : "0.5em",
249 },
250 ".icon" : {
251 "width": "24px",
252 "height": "24px",
253 "vertical-align": "middle",
254 }
255 }
256 self.scripts = []
257 self.body = []
258 self.css = []
260 def add_line(self, line):
261 assert isinstance(line, str)
262 if len(self.body) == 0:
263 self.body.append('<div class="content">')
264 self.body.append(line)
266 def add_heading(self, level, text, anchor=None, html_identifier=False):
267 assert isinstance(level, int)
268 assert isinstance(text, str)
269 assert 2 <= level <= 7
270 assert anchor is None or isinstance(anchor, str)
272 if level == 2 and self.body:
273 self.body.append("</div>")
275 heading_identifier = f'heading-{text.replace(" ", "-").lower()}'
276 if anchor is None:
277 if html_identifier:
278 self.body.append(
279 f'<h{level} id="{heading_identifier}">'
280 f'{text}</h{level}>'
281 )
282 else:
283 self.body.append(f'<h{level}>{text}</h{level}>')
284 else:
285 if html_identifier:
286 self.body.append(
287 f'<h{level} id="sec-{anchor}" '
288 f'class="{heading_identifier}">'
289 f'{text}</h{level}>'
290 )
291 else:
292 self.body.append(f'<h{level} id="sec-{anchor}">{text}</h{level}>')
294 if level == 2:
295 self.body.append('<div class="content">')
297 def render(self):
298 navbar_content = self.navbar.generate(self)
300 rv = [
301 "<!DOCTYPE html>",
302 "<html>",
303 "<head>",
304 "<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>",
305 "<title>%s</title>" % html.escape(self.title),
306 "<style>"
307 ]
308 for elem, style in self.style.items():
309 rv.append("%s {" % elem)
310 for attr, value in style.items():
311 rv.append(" %s: %s;" % (attr, value))
312 rv.append("}")
314 # add css files that are appended to self.files
315 for css_file in self.css:
316 rv.append(css_file)
317 rv.append("</style>")
318 rv.append("</head>")
319 rv.append("<body>")
321 rv.append('<div class="title">')
322 rv.append("<h1>%s</h1>" % html.escape(self.title))
323 rv.append('<div class="subtitle">%s</div>' %
324 html.escape(self.subtitle))
325 rv.append('</div>')
327 rv += navbar_content
329 rv.append('<div class="htmlbody">')
330 rv.append('<svg style="display: none;">')
331 rv.append('<defs>')
332 rv.append('<symbol id="svg-check-square" viewBox="0 0 24 24">')
333 rv.append(assets.SVG_CHECK_SQUARE)
334 rv.append('</symbol>')
335 rv.append('</defs>')
336 rv.append('</svg>')
338 rv.append('<svg style="display: none;">')
339 rv.append('<defs>')
340 rv.append('<symbol id="svg-alert-triangle" viewBox="0 0 24 24">')
341 rv.append(assets.SVG_ALERT_TRIANGLE)
342 rv.append('</symbol>')
343 rv.append('</defs>')
344 rv.append('</svg>')
346 rv.append('<svg style="display: none;">')
347 rv.append('<defs>')
348 rv.append('<symbol id="svg-external-link" viewBox="0 0 24 24">')
349 rv.append(assets.SVG_EXTERNAL_LINK)
350 rv.append('</symbol>')
351 rv.append('</defs>')
352 rv.append('</svg>')
354 rv.append('<svg style="display: none;">')
355 rv.append('<defs>')
356 rv.append('<symbol id="svg-chevron-down" viewBox="0 0 24 24">')
357 rv.append(assets.SVG_CHEVRON_DOWN)
358 rv.append('</symbol>')
359 rv.append('</defs>')
360 rv.append('</svg>')
362 rv += self.body
363 rv.append('</div>')
364 rv.append('</div>')
366 for script in self.scripts:
367 rv.append("<script>")
368 rv += script.splitlines()
369 rv.append("</script>")
371 rv.append("</body>")
372 rv.append("</html>")
374 return "\n".join(rv)