Coverage for lobster/htmldoc/htmldoc.py: 97%

166 statements  

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

19 

20import html 

21from abc import abstractmethod, ABC 

22from typing import List, Optional 

23 

24from lobster.htmldoc import assets 

25 

26NAVBAR_STICKY_SCRIPT = """ 

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

28 

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

30var sticky = navbar.offsetTop; 

31 

32function stickyNavbar() { 

33 if (window.pageYOffset >= sticky) { 

34 navbar.classList.add("sticky") 

35 } else { 

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

37 } 

38} 

39""" 

40 

41 

42class Menu_Item(ABC): 

43 def __init__(self, name): 

44 self.name = name 

45 

46 @abstractmethod 

47 def generate(self, doc) -> List[str]: 

48 pass 

49 

50 

51class Menu_Link(Menu_Item): 

52 def __init__(self, name, target): 

53 super().__init__(name) 

54 

55 self.target = target 

56 

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] 

67 

68 

69class Dropdown_Menu(Menu_Item): 

70 def __init__(self, name): 

71 super().__init__(name) 

72 self.items = [] 

73 

74 def add_link(self, name, target): 

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

76 

77 def generate(self, doc) -> List[str]: 

78 

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 } 

130 

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 

141 

142 

143class Navigation_Bar: 

144 def __init__(self): 

145 self.left_items: List[Menu_Item] = [] 

146 self.right_items: List[Menu_Item] = [] 

147 

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

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

150 

151 item = Menu_Link(name, target) 

152 if alignment == "left": 152 ↛ 155line 152 didn't jump to line 155 because the condition on line 152 was always true

153 self.left_items.append(item) 

154 else: 

155 self.right_items.append(item) 

156 

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

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

159 

160 menu = Dropdown_Menu(name) 

161 if alignment == "left": 

162 self.left_items.append(menu) 

163 else: 

164 self.right_items.append(menu) 

165 

166 return menu 

167 

168 def generate(self, doc): 

169 doc.style["#navbar"] = { 

170 "overflow" : "hidden", 

171 "background-color" : doc.primary_color, 

172 } 

173 if self.right_items: 173 ↛ 177line 173 didn't jump to line 177 because the condition on line 173 was always true

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 } 

196 

197 doc.scripts.append(NAVBAR_STICKY_SCRIPT) 

198 

199 rv = [] 

200 

201 rv.append('<div id="navbar">') 

202 for item in self.left_items: 

203 rv += item.generate(doc) 

204 if self.right_items: 204 ↛ 209line 204 didn't jump to line 209 because the condition on line 204 was always true

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

210 

211 return rv 

212 

213 

214class Document: 

215 def __init__(self, title, subtitle): 

216 self.title = title 

217 self.subtitle = subtitle 

218 

219 self.primary_color = "#009ada" 

220 

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

253 

254 def add_line(self, line): 

255 if len(self.body) == 0: 

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

257 self.body.append(line) 

258 

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 

267 

268 if level == 2 and self.body: 

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

270 

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: 281 ↛ 288line 281 didn't jump to line 288 because the condition on line 281 was always true

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

289 

290 if level == 2: 

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

292 

293 def render(self): 

294 navbar_content = self.navbar.generate(self) 

295 

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

309 

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

316 

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

322 

323 rv += navbar_content 

324 

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

333 

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

341 

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

349 

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

357 

358 rv += self.body 

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

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

361 

362 for script in self.scripts: 

363 rv.append("<script>") 

364 rv += script.splitlines() 

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

366 

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

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

369 

370 return "\n".join(rv)