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

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/>. 

19 

20import html 

21 

22from lobster.html import assets 

23 

24NAVBAR_STICKY_SCRIPT = """ 

25window.onscroll = function() {stickyNavbar()}; 

26 

27var navbar = document.getElementById("navbar"); 

28var sticky = navbar.offsetTop; 

29 

30function stickyNavbar() { 

31 if (window.pageYOffset >= sticky) { 

32 navbar.classList.add("sticky") 

33 } else { 

34 navbar.classList.remove("sticky"); 

35 } 

36} 

37""" 

38 

39 

40class Menu_Item: 

41 def __init__(self, name): 

42 assert isinstance(name, str) 

43 self.name = name 

44 

45 def generate(self, doc): 

46 assert isinstance(doc, Document) 

47 assert False 

48 

49 

50class Menu_Link(Menu_Item): 

51 def __init__(self, name, target): 

52 assert isinstance(target, str) 

53 super().__init__(name) 

54 

55 self.target = target 

56 

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] 

68 

69 

70class Dropdown_Menu(Menu_Item): 

71 def __init__(self, name): 

72 super().__init__(name) 

73 self.items = [] 

74 

75 def add_link(self, name, target): 

76 self.items.append(Menu_Link(name, target)) 

77 

78 def generate(self, doc): 

79 assert isinstance(doc, Document) 

80 

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 } 

132 

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 

143 

144 

145class Navigation_Bar: 

146 def __init__(self): 

147 self.left_items = [] 

148 self.right_items = [] 

149 

150 def add_link(self, name, target, alignment="left"): 

151 assert alignment in ("left", "right") 

152 

153 item = Menu_Link(name, target) 

154 if alignment == "left": 

155 self.left_items.append(item) 

156 else: 

157 self.right_items.append(item) 

158 

159 def add_dropdown(self, name, alignment="left"): 

160 assert alignment in ("left", "right") 

161 

162 menu = Dropdown_Menu(name) 

163 if alignment == "left": 

164 self.left_items.append(menu) 

165 else: 

166 self.right_items.append(menu) 

167 

168 return menu 

169 

170 def generate(self, doc): 

171 assert isinstance(doc, Document) 

172 

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 } 

200 

201 doc.scripts.append(NAVBAR_STICKY_SCRIPT) 

202 

203 rv = [] 

204 

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>') 

214 

215 return rv 

216 

217 

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 

224 

225 self.primary_color = "#009ada" 

226 

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 = [] 

259 

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) 

265 

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) 

271 

272 if level == 2 and self.body: 

273 self.body.append("</div>") 

274 

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}>') 

293 

294 if level == 2: 

295 self.body.append('<div class="content">') 

296 

297 def render(self): 

298 navbar_content = self.navbar.generate(self) 

299 

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("}") 

313 

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>") 

320 

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>') 

326 

327 rv += navbar_content 

328 

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>') 

337 

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>') 

345 

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>') 

353 

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>') 

361 

362 rv += self.body 

363 rv.append('</div>') 

364 rv.append('</div>') 

365 

366 for script in self.scripts: 

367 rv.append("<script>") 

368 rv += script.splitlines() 

369 rv.append("</script>") 

370 

371 rv.append("</body>") 

372 rv.append("</html>") 

373 

374 return "\n".join(rv)