1from collections.abc import Mapping, Sequence 2from typing import cast, Optional, NamedTuple 3 4from html import escape 5from markdown_it.token import Token 6 7from .manual_structure import XrefTarget 8from .md import Renderer 9 10class UnresolvedXrefError(Exception): 11 pass 12 13class Heading(NamedTuple): 14 container_tag: str 15 level: int 16 html_tag: str 17 # special handling for part content: whether partinfo div was already closed from 18 # elsewhere or still needs closing. 19 partintro_closed: bool 20 # tocs are generated when the heading opens, but have to be emitted into the file 21 # after the heading titlepage (and maybe partinfo) has been closed. 22 toc_fragment: str 23 24_bullet_list_styles = [ 'disc', 'circle', 'square' ] 25_ordered_list_styles = [ '1', 'a', 'i', 'A', 'I' ] 26 27class HTMLRenderer(Renderer): 28 _xref_targets: Mapping[str, XrefTarget] 29 30 _headings: list[Heading] 31 _attrspans: list[str] 32 _hlevel_offset: int = 0 33 _bullet_list_nesting: int = 0 34 _ordered_list_nesting: int = 0 35 36 def __init__(self, manpage_urls: Mapping[str, str], xref_targets: Mapping[str, XrefTarget]): 37 super().__init__(manpage_urls) 38 self._headings = [] 39 self._attrspans = [] 40 self._xref_targets = xref_targets 41 42 def render(self, tokens: Sequence[Token]) -> str: 43 result = super().render(tokens) 44 result += self._close_headings(None) 45 return result 46 47 def _pull_image(self, path: str) -> str: 48 raise NotImplementedError() 49 50 def text(self, token: Token, tokens: Sequence[Token], i: int) -> str: 51 return escape(token.content) 52 def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 53 return "<p>" 54 def paragraph_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 55 return "</p>" 56 def hardbreak(self, token: Token, tokens: Sequence[Token], i: int) -> str: 57 return "<br />" 58 def softbreak(self, token: Token, tokens: Sequence[Token], i: int) -> str: 59 return "\n" 60 def code_inline(self, token: Token, tokens: Sequence[Token], i: int) -> str: 61 return f'<code class="literal">{escape(token.content)}</code>' 62 def code_block(self, token: Token, tokens: Sequence[Token], i: int) -> str: 63 return self.fence(token, tokens, i) 64 def link_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 65 href = escape(cast(str, token.attrs['href']), True) 66 tag, title, target, text = "link", "", 'target="_top"', "" 67 if href.startswith('#'): 68 if not (xref := self._xref_targets.get(href[1:])): 69 raise UnresolvedXrefError(f"bad local reference, id {href} not known") 70 if tokens[i + 1].type == 'link_close': 71 tag, text = "xref", xref.title_html 72 if xref.title: 73 # titles are not attribute-safe on their own, so we need to replace quotes. 74 title = 'title="{}"'.format(xref.title.replace('"', '&quot;')) 75 target, href = "", xref.href() 76 return f'<a class="{tag}" href="{href}" {title} {target}>{text}' 77 def link_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 78 return "</a>" 79 def list_item_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 80 return '<li class="listitem">' 81 def list_item_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 82 return "</li>" 83 def bullet_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 84 extra = 'compact' if token.meta.get('compact', False) else '' 85 style = _bullet_list_styles[self._bullet_list_nesting % len(_bullet_list_styles)] 86 self._bullet_list_nesting += 1 87 return f'<div class="itemizedlist"><ul class="itemizedlist {extra}" style="list-style-type: {style};">' 88 def bullet_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 89 self._bullet_list_nesting -= 1 90 return "</ul></div>" 91 def em_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 92 return '<span class="emphasis"><em>' 93 def em_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 94 return "</em></span>" 95 def strong_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 96 return '<span class="strong"><strong>' 97 def strong_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 98 return "</strong></span>" 99 def fence(self, token: Token, tokens: Sequence[Token], i: int) -> str: 100 # TODO use token.info. docbook doesn't so we can't yet. 101 return f'<pre class="programlisting">\n{escape(token.content)}</pre>' 102 def blockquote_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 103 return '<div class="blockquote"><blockquote class="blockquote">' 104 def blockquote_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 105 return "</blockquote></div>" 106 def note_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 107 return '<div class="note"><h3 class="title">Note</h3>' 108 def note_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 109 return "</div>" 110 def caution_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 111 return '<div class="caution"><h3 class="title">Caution</h3>' 112 def caution_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 113 return "</div>" 114 def important_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 115 return '<div class="important"><h3 class="title">Important</h3>' 116 def important_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 117 return "</div>" 118 def tip_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 119 return '<div class="tip"><h3 class="title">Tip</h3>' 120 def tip_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 121 return "</div>" 122 def warning_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 123 return '<div class="warning"><h3 class="title">Warning</h3>' 124 def warning_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 125 return "</div>" 126 def dl_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 127 return '<div class="variablelist"><dl class="variablelist">' 128 def dl_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 129 return "</dl></div>" 130 def dt_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 131 return '<dt><span class="term">' 132 def dt_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 133 return "</span></dt>" 134 def dd_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 135 return "<dd>" 136 def dd_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 137 return "</dd>" 138 def myst_role(self, token: Token, tokens: Sequence[Token], i: int) -> str: 139 if token.meta['name'] == 'command': 140 return f'<span class="command"><strong>{escape(token.content)}</strong></span>' 141 if token.meta['name'] == 'file': 142 return f'<code class="filename">{escape(token.content)}</code>' 143 if token.meta['name'] == 'var': 144 return f'<code class="varname">{escape(token.content)}</code>' 145 if token.meta['name'] == 'env': 146 return f'<code class="envar">{escape(token.content)}</code>' 147 if token.meta['name'] == 'option': 148 return f'<code class="option">{escape(token.content)}</code>' 149 if token.meta['name'] == 'manpage': 150 [page, section] = [ s.strip() for s in token.content.rsplit('(', 1) ] 151 section = section[:-1] 152 man = f"{page}({section})" 153 title = f'<span class="refentrytitle">{escape(page)}</span>' 154 vol = f"({escape(section)})" 155 ref = f'<span class="citerefentry">{title}{vol}</span>' 156 if man in self._manpage_urls: 157 return f'<a class="link" href="{escape(self._manpage_urls[man], True)}" target="_top">{ref}</a>' 158 else: 159 return ref 160 return super().myst_role(token, tokens, i) 161 def attr_span_begin(self, token: Token, tokens: Sequence[Token], i: int) -> str: 162 # we currently support *only* inline anchors and the special .keycap class to produce 163 # keycap-styled spans. 164 (id_part, class_part) = ("", "") 165 if s := token.attrs.get('id'): 166 id_part = f'<a id="{escape(cast(str, s), True)}" />' 167 if s := token.attrs.get('class'): 168 if s == 'keycap': 169 class_part = '<span class="keycap"><strong>' 170 self._attrspans.append("</strong></span>") 171 else: 172 return super().attr_span_begin(token, tokens, i) 173 else: 174 self._attrspans.append("") 175 return id_part + class_part 176 def attr_span_end(self, token: Token, tokens: Sequence[Token], i: int) -> str: 177 return self._attrspans.pop() 178 def heading_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 179 hlevel = int(token.tag[1:]) 180 htag, hstyle = self._make_hN(hlevel) 181 if hstyle: 182 hstyle = f'style="{escape(hstyle, True)}"' 183 if anchor := cast(str, token.attrs.get('id', '')): 184 anchor = f'<a id="{escape(anchor, True)}"></a>' 185 result = self._close_headings(hlevel) 186 tag = self._heading_tag(token, tokens, i) 187 toc_fragment = self._build_toc(tokens, i) 188 self._headings.append(Heading(tag, hlevel, htag, tag != 'part', toc_fragment)) 189 return ( 190 f'{result}' 191 f'<div class="{tag}">' 192 f' <div class="titlepage">' 193 f' <div>' 194 f' <div>' 195 f' <{htag} class="title" {hstyle}>' 196 f' {anchor}' 197 ) 198 def heading_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 199 heading = self._headings[-1] 200 result = ( 201 f' </{heading.html_tag}>' 202 f' </div>' 203 f' </div>' 204 f'</div>' 205 ) 206 if heading.container_tag == 'part': 207 result += '<div class="partintro">' 208 else: 209 result += heading.toc_fragment 210 return result 211 def ordered_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 212 extra = 'compact' if token.meta.get('compact', False) else '' 213 start = f'start="{token.attrs["start"]}"' if 'start' in token.attrs else "" 214 style = _ordered_list_styles[self._ordered_list_nesting % len(_ordered_list_styles)] 215 self._ordered_list_nesting += 1 216 return f'<div class="orderedlist"><ol class="orderedlist {extra}" {start} type="{style}">' 217 def ordered_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 218 self._ordered_list_nesting -= 1 219 return "</ol></div>" 220 def example_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 221 if id := cast(str, token.attrs.get('id', '')): 222 id = f'id="{escape(id, True)}"' if id else '' 223 return f'<div class="example"><a {id} />' 224 def example_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 225 return '</div></div><br class="example-break" />' 226 def example_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 227 return '<p class="title"><strong>' 228 def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 229 return '</strong></p><div class="example-contents">' 230 def image(self, token: Token, tokens: Sequence[Token], i: int) -> str: 231 src = self._pull_image(cast(str, token.attrs['src'])) 232 alt = f'alt="{escape(token.content, True)}"' if token.content else "" 233 if title := cast(str, token.attrs.get('title', '')): 234 title = f'title="{escape(title, True)}"' 235 return ( 236 '<div class="mediaobject">' 237 f'<img src="{escape(src, True)}" {alt} {title} />' 238 '</div>' 239 ) 240 def figure_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 241 if anchor := cast(str, token.attrs.get('id', '')): 242 anchor = f'<a id="{escape(anchor, True)}"></a>' 243 return f'<div class="figure">{anchor}' 244 def figure_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 245 return ( 246 ' </div>' 247 '</div><br class="figure-break" />' 248 ) 249 def figure_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 250 return ( 251 '<p class="title">' 252 ' <strong>' 253 ) 254 def figure_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 255 return ( 256 ' </strong>' 257 '</p>' 258 '<div class="figure-contents">' 259 ) 260 def table_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 261 return ( 262 '<div class="informaltable">' 263 '<table class="informaltable" border="1">' 264 ) 265 def table_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 266 return ( 267 '</table>' 268 '</div>' 269 ) 270 def thead_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 271 cols = [] 272 for j in range(i + 1, len(tokens)): 273 if tokens[j].type == 'thead_close': 274 break 275 elif tokens[j].type == 'th_open': 276 cols.append(cast(str, tokens[j].attrs.get('style', 'left')).removeprefix('text-align:')) 277 return "".join([ 278 "<colgroup>", 279 "".join([ f'<col align="{col}" />' for col in cols ]), 280 "</colgroup>", 281 "<thead>", 282 ]) 283 def thead_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 284 return "</thead>" 285 def tr_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 286 return "<tr>" 287 def tr_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 288 return "</tr>" 289 def th_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 290 return f'<th align="{cast(str, token.attrs.get("style", "left")).removeprefix("text-align:")}">' 291 def th_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 292 return "</th>" 293 def tbody_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 294 return "<tbody>" 295 def tbody_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 296 return "</tbody>" 297 def td_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 298 return f'<td align="{cast(str, token.attrs.get("style", "left")).removeprefix("text-align:")}">' 299 def td_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 300 return "</td>" 301 def footnote_ref(self, token: Token, tokens: Sequence[Token], i: int) -> str: 302 href = self._xref_targets[token.meta['target']].href() 303 id = escape(cast(str, token.attrs["id"]), True) 304 return ( 305 f'<a href="{href}" class="footnote" id="{id}">' 306 f'<sup class="footnote">[{token.meta["id"] + 1}]</sup>' 307 '</a>' 308 ) 309 def footnote_block_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 310 return ( 311 '<div class="footnotes">' 312 '<br />' 313 '<hr style="width:100; text-align:left;margin-left: 0" />' 314 ) 315 def footnote_block_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 316 return "</div>" 317 def footnote_open(self, token: Token, tokens: Sequence[Token], i: int) -> str: 318 # meta id,label 319 id = escape(self._xref_targets[token.meta["label"]].id, True) 320 return f'<div id="{id}" class="footnote">' 321 def footnote_close(self, token: Token, tokens: Sequence[Token], i: int) -> str: 322 return "</div>" 323 def footnote_anchor(self, token: Token, tokens: Sequence[Token], i: int) -> str: 324 href = self._xref_targets[token.meta['target']].href() 325 return ( 326 f'<a href="{href}" class="para">' 327 f'<sup class="para">[{token.meta["id"] + 1}]</sup>' 328 '</a>' 329 ) 330 331 def _make_hN(self, level: int) -> tuple[str, str]: 332 return f"h{min(6, max(1, level + self._hlevel_offset))}", "" 333 334 def _maybe_close_partintro(self) -> str: 335 if self._headings: 336 heading = self._headings[-1] 337 if heading.container_tag == 'part' and not heading.partintro_closed: 338 self._headings[-1] = heading._replace(partintro_closed=True) 339 return heading.toc_fragment + "</div>" 340 return "" 341 342 def _close_headings(self, level: Optional[int]) -> str: 343 result = [] 344 while len(self._headings) and (level is None or self._headings[-1].level >= level): 345 result.append(self._maybe_close_partintro()) 346 result.append("</div>") 347 self._headings.pop() 348 return "\n".join(result) 349 350 def _heading_tag(self, token: Token, tokens: Sequence[Token], i: int) -> str: 351 return "section" 352 def _build_toc(self, tokens: Sequence[Token], i: int) -> str: 353 return ""