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

166 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-05-12 15:02 +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( 

133 f'<button class="dropbtn">{html.escape(self.name)}' 

134 '<svg class="icon"><use href="#svg-chevron-down"></use></svg>' 

135 '</button>' 

136 ) 

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: List[Menu_Item] = [] 

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

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 doc.style["#navbar"] = { 

172 "overflow" : "hidden", 

173 "background-color" : doc.primary_color, 

174 } 

175 if self.right_items: 

176 doc.style[".navbar-right"] = { 

177 "float" : "right", 

178 } 

179 doc.style["#navbar a"] = { 

180 "float" : "left", 

181 "display" : "block", 

182 "color" : "white", 

183 "padding" : "14px", 

184 "text-decoration" : "none", 

185 } 

186 doc.style["#navbar a:hover"] = { 

187 "background-color" : "white", 

188 "color" : doc.primary_color, 

189 } 

190 doc.style[".sticky"] = { 

191 "position" : "fixed", 

192 "top" : "0", 

193 "width" : "100%", 

194 } 

195 doc.style[".sticky + .htmlbody"] = { 

196 "padding-top" : "60px", 

197 } 

198 

199 doc.scripts.append(NAVBAR_STICKY_SCRIPT) 

200 

201 rv = [] 

202 

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

204 for item in self.left_items: 

205 rv += item.generate(doc) 

206 if self.right_items: 

207 rv.append('<div class="navbar-right">') 

208 for item in self.right_items: 

209 rv += item.generate(doc) 

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

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

212 

213 return rv 

214 

215 

216class Document: 

217 def __init__(self, title, subtitle): 

218 self.title = title 

219 self.subtitle = subtitle 

220 

221 self.primary_color = "#009ada" 

222 

223 self.navbar = Navigation_Bar() 

224 self.style = { 

225 "html" : {"scroll-padding-top": "5em"}, 

226 "body" : {"margin": "0"}, 

227 ".title" : { 

228 "background-color" : self.primary_color, 

229 "color" : "white", 

230 "padding" : "0.5em", 

231 "margin" : "0" 

232 }, 

233 "h1" : { 

234 "padding" : "0", 

235 "margin" : "0" 

236 }, 

237 "h2" : { 

238 "padding" : "0.5em", 

239 "margin" : "0", 

240 "border-bottom" : f"0.25em solid {self.primary_color}", 

241 "text-align" : "right", 

242 }, 

243 ".content" : { 

244 "padding" : "0.5em", 

245 }, 

246 ".icon" : { 

247 "width": "24px", 

248 "height": "24px", 

249 "vertical-align": "middle", 

250 } 

251 } 

252 self.scripts = [] 

253 self.body = [] 

254 self.css = [] 

255 

256 def add_line(self, line): 

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

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

259 self.body.append(line) 

260 

261 def add_heading( 

262 self, 

263 level: int, 

264 text: str, 

265 anchor: Optional[str] = None, 

266 html_identifier: bool = False, 

267 ): 

268 assert 2 <= level <= 7 

269 

270 if level == 2 and self.body: 

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

272 

273 heading_identifier = f'heading-{text.replace(" ", "-").lower()}' 

274 if anchor is None: 

275 if html_identifier: 

276 self.body.append( 

277 f'<h{level} id="{heading_identifier}">' 

278 f'{text}</h{level}>' 

279 ) 

280 else: 

281 self.body.append(f'<h{level}>{text}</h{level}>') 

282 else: 

283 if html_identifier: 

284 self.body.append( 

285 f'<h{level} id="sec-{anchor}" ' 

286 f'class="{heading_identifier}">' 

287 f'{text}</h{level}>' 

288 ) 

289 else: 

290 self.body.append(f'<h{level} id="sec-{anchor}">{text}</h{level}>') 

291 

292 if level == 2: 

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

294 

295 def render(self): 

296 navbar_content = self.navbar.generate(self) 

297 

298 rv = [ 

299 "<!DOCTYPE html>", 

300 "<html>", 

301 "<head>", 

302 "<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>", 

303 f"<title>{html.escape(self.title)}</title>", 

304 "<style>" 

305 ] 

306 for elem, style in self.style.items(): 

307 rv.append("%s {" % elem) 

308 for attr, value in style.items(): 

309 rv.append(f" {attr}: {value};") 

310 rv.append("}") 

311 

312 # add css files that are appended to self.files 

313 for css_file in self.css: 

314 rv.append(css_file) 

315 rv.append("</style>") 

316 rv.append("</head>") 

317 rv.append("<body>") 

318 

319 rv.append('<div class="title">') 

320 rv.append(f"<h1>{html.escape(self.title)}</h1>") 

321 rv.append(f'<div class="subtitle">{html.escape(self.subtitle)}</div>') 

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

323 

324 rv += navbar_content 

325 

326 rv.append('<div class="htmlbody">') 

327 rv.append('<svg style="display: none;">') 

328 rv.append('<defs>') 

329 rv.append('<symbol id="svg-check-square" viewBox="0 0 24 24">') 

330 rv.append(assets.SVG_CHECK_SQUARE) 

331 rv.append('</symbol>') 

332 rv.append('</defs>') 

333 rv.append('</svg>') 

334 

335 rv.append('<svg style="display: none;">') 

336 rv.append('<defs>') 

337 rv.append('<symbol id="svg-alert-triangle" viewBox="0 0 24 24">') 

338 rv.append(assets.SVG_ALERT_TRIANGLE) 

339 rv.append('</symbol>') 

340 rv.append('</defs>') 

341 rv.append('</svg>') 

342 

343 rv.append('<svg style="display: none;">') 

344 rv.append('<defs>') 

345 rv.append('<symbol id="svg-external-link" viewBox="0 0 24 24">') 

346 rv.append(assets.SVG_EXTERNAL_LINK) 

347 rv.append('</symbol>') 

348 rv.append('</defs>') 

349 rv.append('</svg>') 

350 

351 rv.append('<svg style="display: none;">') 

352 rv.append('<defs>') 

353 rv.append('<symbol id="svg-chevron-down" viewBox="0 0 24 24">') 

354 rv.append(assets.SVG_CHEVRON_DOWN) 

355 rv.append('</symbol>') 

356 rv.append('</defs>') 

357 rv.append('</svg>') 

358 

359 rv += self.body 

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

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

362 

363 for script in self.scripts: 

364 rv.append("<script>") 

365 rv += script.splitlines() 

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

367 

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

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

370 

371 return "\n".join(rv)