Coverage for lobster/html/htmldoc.py: 98%
172 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 14:55 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 14:55 +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 = '<a href="%s">' % self.target
60 if self.target.startswith("http"):
61 rv += '<svg class="icon"><use href="#svg-external-link"></use></svg>' + " "
62 rv += html.escape(self.name)
63 rv += "</a>"
64 return [rv]
67class Dropdown_Menu(Menu_Item):
68 def __init__(self, name):
69 super().__init__(name)
70 self.items = []
72 def add_link(self, name, target):
73 self.items.append(Menu_Link(name, target))
75 def generate(self, doc):
76 assert isinstance(doc, Document)
78 doc.style["#navbar .dropdown"] = {
79 "float" : "left",
80 "overflow" : "hidden",
81 }
82 doc.style[".navbar-right .dropdown"] = {
83 "float" : "right",
84 "overflow" : "hidden",
85 }
86 doc.style[".dropdown .dropbtn"] = {
87 "font-size" : "inherit",
88 "border" : "none",
89 "outline" : "none",
90 "padding" : "14px 16px",
91 "background-color" : "inherit",
92 "color" : "white",
93 "font-family" : "inherit",
94 "margin" : "0",
95 }
96 doc.style[".dropdown:hover .dropbtn"] = {
97 "background-color" : "white",
98 "color" : doc.primary_color,
99 }
100 doc.style[".dropdown-content"] = {
101 "display" : "none",
102 "position" : "absolute",
103 "background-color" : doc.primary_color,
104 "box-shadow" : "0px 8px 16px 0px rgba(0,0,0,0.2)",
105 "z-index" : "1",
106 }
107 doc.style[".navbar-right .dropdown-content"] = {
108 "right" : "0",
109 }
110 doc.style[".dropdown-content a"] = {
111 "float" : "none",
112 "color" : "white",
113 "padding" : "12px 16px",
114 "text-decoration" : "none",
115 "display" : "block",
116 "text-align" : "left",
117 }
118 doc.style[".dropdown-content a:hover"] = {
119 "color" : doc.primary_color,
120 "background-color" : "white",
121 }
122 doc.style[".dropdown:hover .dropdown-content"] = {
123 "display" : "flex",
124 "flex-direction" : "column",
125 }
126 doc.style[".sticky .dropdown-content"] = {
127 "position" : "fixed",
128 }
130 rv = ['<div class="dropdown">']
131 rv.append('<button class="dropbtn">%s%s</button>' %
132 (html.escape(self.name),
133 '<svg class="icon"><use href="#svg-chevron-down"></use></svg>'))
134 rv.append('<div class="dropdown-content">')
135 for item in self.items:
136 rv += item.generate(doc)
137 rv.append("</div>")
138 rv.append("</div>")
139 return rv
142class Navigation_Bar:
143 def __init__(self):
144 self.left_items = []
145 self.right_items = []
147 def add_link(self, name, target, alignment="left"):
148 assert alignment in ("left", "right")
150 item = Menu_Link(name, target)
151 if alignment == "left": 151 ↛ 154line 151 didn't jump to line 154 because the condition on line 151 was always true
152 self.left_items.append(item)
153 else:
154 self.right_items.append(item)
156 def add_dropdown(self, name, alignment="left"):
157 assert alignment in ("left", "right")
159 menu = Dropdown_Menu(name)
160 if alignment == "left":
161 self.left_items.append(menu)
162 else:
163 self.right_items.append(menu)
165 return menu
167 def generate(self, doc):
168 assert isinstance(doc, Document)
170 doc.style["#navbar"] = {
171 "overflow" : "hidden",
172 "background-color" : doc.primary_color,
173 }
174 if self.right_items: 174 ↛ 178line 174 didn't jump to line 178 because the condition on line 174 was always true
175 doc.style[".navbar-right"] = {
176 "float" : "right",
177 }
178 doc.style["#navbar a"] = {
179 "float" : "left",
180 "display" : "block",
181 "color" : "white",
182 "padding" : "14px",
183 "text-decoration" : "none",
184 }
185 doc.style["#navbar a:hover"] = {
186 "background-color" : "white",
187 "color" : doc.primary_color,
188 }
189 doc.style[".sticky"] = {
190 "position" : "fixed",
191 "top" : "0",
192 "width" : "100%",
193 }
194 doc.style[".sticky + .htmlbody"] = {
195 "padding-top" : "60px",
196 }
198 doc.scripts.append(NAVBAR_STICKY_SCRIPT)
200 rv = []
202 rv.append('<div id="navbar">')
203 for item in self.left_items:
204 rv += item.generate(doc)
205 if self.right_items: 205 ↛ 210line 205 didn't jump to line 210 because the condition on line 205 was always true
206 rv.append('<div class="navbar-right">')
207 for item in self.right_items:
208 rv += item.generate(doc)
209 rv.append('</div>')
210 rv.append('</div>')
212 return rv
215class Document:
216 def __init__(self, title, subtitle):
217 assert isinstance(title, str)
218 assert isinstance(subtitle, str)
219 self.title = title
220 self.subtitle = subtitle
222 self.primary_color = "#009ada"
224 self.navbar = Navigation_Bar()
225 self.style = {
226 "html" : {"scroll-padding-top": "5em"},
227 "body" : {"margin": "0"},
228 ".title" : {
229 "background-color" : self.primary_color,
230 "color" : "white",
231 "padding" : "0.5em",
232 "margin" : "0"
233 },
234 "h1" : {
235 "padding" : "0",
236 "margin" : "0"
237 },
238 "h2" : {
239 "padding" : "0.5em",
240 "margin" : "0",
241 "border-bottom" : "0.25em solid %s" % self.primary_color,
242 "text-align" : "right",
243 },
244 ".content" : {
245 "padding" : "0.5em",
246 },
247 ".icon" : {
248 "width": "24px",
249 "height": "24px",
250 "vertical-align": "middle",
251 }
252 }
253 self.scripts = []
254 self.body = []
255 self.css = []
257 def add_line(self, line):
258 assert isinstance(line, str)
259 if len(self.body) == 0:
260 self.body.append('<div class="content">')
261 self.body.append(line)
263 def add_heading(self, level, text, anchor=None):
264 assert isinstance(level, int)
265 assert isinstance(text, str)
266 assert 2 <= level <= 7
267 assert anchor is None or isinstance(anchor, str)
269 if level == 2 and self.body:
270 self.body.append("</div>")
272 if anchor is None:
273 self.body.append("<h%u>%s</h%u>" % (level,
274 text,
275 level))
276 else:
277 self.body.append('<h%u id="sec-%s">%s</h%u>' %
278 (level,
279 anchor,
280 text,
281 level))
283 if level == 2:
284 self.body.append('<div class="content">')
286 def render(self):
287 navbar_content = self.navbar.generate(self)
289 rv = [
290 "<!DOCTYPE html>",
291 "<html>",
292 "<head>",
293 "<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>",
294 "<title>%s</title>" % html.escape(self.title),
295 "<style>"
296 ]
297 for elem, style in self.style.items():
298 rv.append("%s {" % elem)
299 for attr, value in style.items():
300 rv.append(" %s: %s;" % (attr, value))
301 rv.append("}")
303 # add css files that are appended to self.files
304 for css_file in self.css:
305 rv.append(css_file)
306 rv.append("</style>")
307 rv.append("</head>")
308 rv.append("<body>")
310 rv.append('<div class="title">')
311 rv.append("<h1>%s</h1>" % html.escape(self.title))
312 rv.append('<div class="subtitle">%s</div>' %
313 html.escape(self.subtitle))
314 rv.append('</div>')
316 rv += navbar_content
318 rv.append('<div class="htmlbody">')
319 rv.append('<svg style="display: none;">')
320 rv.append('<defs>')
321 rv.append('<symbol id="svg-check-square" viewBox="0 0 24 24">')
322 rv.append(assets.SVG_CHECK_SQUARE)
323 rv.append('</symbol>')
324 rv.append('</defs>')
325 rv.append('</svg>')
327 rv.append('<svg style="display: none;">')
328 rv.append('<defs>')
329 rv.append('<symbol id="svg-alert-triangle" viewBox="0 0 24 24">')
330 rv.append(assets.SVG_ALERT_TRIANGLE)
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-external-link" viewBox="0 0 24 24">')
338 rv.append(assets.SVG_EXTERNAL_LINK)
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-chevron-down" viewBox="0 0 24 24">')
346 rv.append(assets.SVG_CHEVRON_DOWN)
347 rv.append('</symbol>')
348 rv.append('</defs>')
349 rv.append('</svg>')
351 rv += self.body
352 rv.append('</div>')
353 rv.append('</div>')
355 for script in self.scripts:
356 rv.append("<script>")
357 rv += script.splitlines()
358 rv.append("</script>")
360 rv.append("</body>")
361 rv.append("</html>")
363 return "\n".join(rv)