Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/turbohtml/_internal/_render.py: 92%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

149 statements  

1""" 

2Configuration objects for the tree's output renderers. 

3 

4``to_markdown``, ``to_text``/``to_annotated_text`` and ``serialize``/``encode`` each carried a long flat keyword list 

5that was hard to read, hard to document, and let mutually exclusive options (markdown's ``strip`` vs ``convert``) be 

6passed together. This module replaces those keyword walls with three immutable, thread-safe configuration objects -- 

7:class:`Markdown`, :class:`PlainText` and :class:`Html` -- mirroring the :class:`~turbohtml.Policy` sanitizer config and 

8the :class:`~turbohtml.Indent`/:class:`~turbohtml.Minify` layout objects already in the API. 

9 

10The markdown surface is large enough that a single flat config would just move the wall, so :class:`Markdown` groups its 

11knobs into themed sub-configs (``Markdown.Headings``, ``Markdown.Links``, ...). Each config validates itself on 

12construction and exposes a private ``_unpack`` that yields only the values differing from the renderer's built-in 

13defaults, so an empty config reproduces the no-argument rendering exactly and the C renderer keeps its existing 

14default handling. 

15""" 

16 

17from __future__ import annotations 

18 

19from dataclasses import dataclass, field, fields 

20from typing import TYPE_CHECKING, Final, Literal 

21 

22from turbohtml._html import Formatter, Indent, Minify, _register_render_configs 

23 

24if TYPE_CHECKING: 

25 from collections.abc import Callable, Iterable, Mapping 

26 

27 from turbohtml._html import Element 

28 

29 

30@dataclass(frozen=True) 

31class Markdown: 

32 """ 

33 How :meth:`Node.to_markdown` renders a subtree as Markdown. 

34 

35 Build one and reuse it across threads; every field has a GitHub-Flavored-Markdown default, so ``Markdown()`` 

36 reproduces the no-argument rendering. The many knobs are grouped into themed sub-configs to keep any one object 

37 small -- ``Markdown(links=Markdown.Links(style="reference"))`` rather than a flat ``link_style="reference"``. 

38 

39 :param headings: how headings are written (ATX, closed ATX, or setext). 

40 :param lists: the unordered-list markers. 

41 :param inline: inline emphasis, strike, sub/sup, and quote wrappers. 

42 :param code: fenced vs indented code blocks and their language. 

43 :param links: link style and which links are kept. 

44 :param images: how images render and their fallback alt text. 

45 :param tables: table rendering and header detection. 

46 :param escaping: which Markdown-significant characters are escaped. 

47 :param wrapping: word-wrapping of prose and constructs. 

48 :param document: line breaks, block spacing, trimming, and transliteration. 

49 :param google: reading inline-CSS styling the way a Google Docs export encodes it. 

50 :param strip: tags rendered as their text only, dropping the markup; mutually exclusive with ``convert``. 

51 :param convert: the only tags to render as Markdown, every other tag dropped to text; mutually exclusive with 

52 ``strip``. 

53 :param converters: per-tag overrides, each a callable receiving the element and its rendered child Markdown. 

54 :raises ValueError: if both ``strip`` and ``convert`` are given, since they are mutually exclusive. 

55 """ 

56 

57 @dataclass(frozen=True) 

58 class Headings: 

59 """ 

60 How headings are written. 

61 

62 :param style: ``atx`` (``# h``), ``atx_closed`` (``# h #``), or ``setext`` (underlined). 

63 """ 

64 

65 style: Literal["atx", "atx_closed", "setext"] = "atx" 

66 

67 @dataclass(frozen=True) 

68 class Lists: 

69 """ 

70 How unordered lists are marked. 

71 

72 :param bullets: unordered-list markers, cycled by nesting depth (e.g. ``-*+``). 

73 """ 

74 

75 bullets: str = "-" 

76 

77 @dataclass(frozen=True) 

78 class Inline: 

79 """ 

80 Inline text formatting: emphasis, sub/sup, and quote wrappers. 

81 

82 :param strong: the bold wrapper. 

83 :param emphasis: the italic wrapper. 

84 :param strikethrough: ``keep`` wraps struck text, ``hide`` drops it. 

85 :param ignore_emphasis: render bold/italic/strike content as plain text, no markup. 

86 :param sub: the ``<sub>`` wrapper (empty drops the markup). 

87 :param sup: the ``<sup>`` wrapper (empty drops the markup). 

88 :param hide_strikethrough: in ``google`` mode, drop text a CSS line-through struck. 

89 :param quote_open: the opening ``<q>`` wrapper. 

90 :param quote_close: the closing ``<q>`` wrapper. 

91 """ 

92 

93 strong: str = "**" 

94 emphasis: str = "*" 

95 strikethrough: Literal["keep", "hide"] = "keep" 

96 ignore_emphasis: bool = False 

97 sub: str = "" 

98 sup: str = "" 

99 hide_strikethrough: bool = False 

100 quote_open: str = '"' 

101 quote_close: str = '"' 

102 

103 @dataclass(frozen=True) 

104 class Code: 

105 """ 

106 How code blocks are rendered. 

107 

108 :param block_style: ``fenced`` (triple-backtick) or ``indented`` (four-space). 

109 :param language: default fence language when the element declares none. 

110 :param mark: wrap inline code in ``[code]``/``[/code]`` markers. 

111 """ 

112 

113 block_style: Literal["fenced", "indented"] = "fenced" 

114 language: str = "" 

115 mark: bool = False 

116 

117 @dataclass(frozen=True) 

118 class Links: 

119 """ 

120 Link style and which links are kept. 

121 

122 :param style: ``inline`` (``[t](url)``) or ``reference`` (collected at the end). 

123 :param autolink: emit ``<url>`` when the text equals an absolute href. 

124 :param title: use the href as the title when none is given. 

125 :param ignore: render link text only, no markup. 

126 :param skip_internal: drop ``href="#..."`` fragment links. 

127 :param base_url: prefix resolved onto a relative link or image href. 

128 """ 

129 

130 style: Literal["inline", "reference"] = "inline" 

131 autolink: bool = True 

132 title: bool = False 

133 ignore: bool = False 

134 skip_internal: bool = False 

135 base_url: str = "" 

136 

137 @dataclass(frozen=True) 

138 class Images: 

139 """ 

140 How images are rendered. 

141 

142 :param mode: ``markdown`` (``![alt](src)``), ``alt`` (alt text only), ``ignore``, or ``html`` (raw ``<img>``). 

143 :param default_alt: alt text used for an image that declares none. 

144 """ 

145 

146 mode: Literal["markdown", "alt", "ignore", "html"] = "markdown" 

147 default_alt: str = "" 

148 

149 @dataclass(frozen=True) 

150 class Tables: 

151 """ 

152 How tables are rendered. 

153 

154 :param mode: ``markdown`` (pipe table), ``strip`` (cell text only), or ``html`` (raw ``<table>``). 

155 :param header: which row is the header: ``first``, ``detect``, or ``none``. 

156 :param cell_blocks: what a table or list *inside* a cell becomes, since a pipe cell holds no block: 

157 ``html`` keeps its source markup (and a ``<br>`` for a line break), ``text`` flattens block layout while 

158 keeping compact list markers. 

159 :param pad: align columns to a common width. 

160 """ 

161 

162 mode: Literal["markdown", "strip", "html"] = "markdown" 

163 header: Literal["first", "detect", "none"] = "first" 

164 cell_blocks: Literal["html", "text"] = "html" 

165 pad: bool = False 

166 

167 @dataclass(frozen=True) 

168 class Escaping: 

169 """ 

170 Which Markdown-significant characters are escaped. 

171 

172 :param mode: ``minimal`` escapes only what a parser needs, ``all`` escapes every Markdown character, and 

173 ``none`` leaves prose as written apart from the ``asterisks`` and ``underscores`` choices and a ``|`` 

174 inside a table cell. 

175 :param asterisks: escape literal ``*`` so it is not read as emphasis. 

176 :param underscores: escape literal ``_`` so it is not read as emphasis. 

177 """ 

178 

179 mode: Literal["minimal", "all", "none"] = "minimal" 

180 asterisks: bool = True 

181 underscores: bool = True 

182 

183 @dataclass(frozen=True) 

184 class Wrapping: 

185 """ 

186 Word-wrapping of prose and constructs. 

187 

188 :param width: word-wrap column for prose, or 0 to leave lines unwrapped. 

189 :param list_items: extend word wrapping into list-item text. 

190 :param links: allow a link or image construct to break across a wrap. 

191 """ 

192 

193 width: int = 0 

194 list_items: bool = False 

195 links: bool = True 

196 

197 @dataclass(frozen=True) 

198 class Document: 

199 """ 

200 Document-level layout: line breaks, block spacing, trimming, and transliteration. 

201 

202 :param line_break: render a hard break as trailing ``spaces`` or a ``backslash``. 

203 :param block_spacing: a ``double`` blank line between blocks, or a ``single`` newline. 

204 :param trim: trim document edges: ``strip``, ``lstrip``, ``rstrip``, or ``none``. 

205 :param transliterate: fold common non-ASCII typography in prose to ASCII. 

206 """ 

207 

208 line_break: Literal["spaces", "backslash"] = "spaces" 

209 block_spacing: Literal["double", "single"] = "double" 

210 trim: Literal["strip", "lstrip", "rstrip", "none"] = "strip" 

211 transliterate: bool = False 

212 

213 @dataclass(frozen=True) 

214 class GoogleDoc: 

215 """ 

216 Reading inline-CSS styling the way a Google Docs export encodes it. 

217 

218 :param enabled: read inline-CSS styling the way a Google Docs HTML export encodes it. 

219 :param list_indent: px of ``margin-left`` per list-nesting level (at least 1). 

220 """ 

221 

222 enabled: bool = False 

223 list_indent: int = 36 

224 

225 headings: Markdown.Headings = field(default_factory=Headings) 

226 lists: Markdown.Lists = field(default_factory=Lists) 

227 inline: Markdown.Inline = field(default_factory=Inline) 

228 code: Markdown.Code = field(default_factory=Code) 

229 links: Markdown.Links = field(default_factory=Links) 

230 images: Markdown.Images = field(default_factory=Images) 

231 tables: Markdown.Tables = field(default_factory=Tables) 

232 escaping: Markdown.Escaping = field(default_factory=Escaping) 

233 wrapping: Markdown.Wrapping = field(default_factory=Wrapping) 

234 document: Markdown.Document = field(default_factory=Document) 

235 google: Markdown.GoogleDoc = field(default_factory=GoogleDoc) 

236 strip: Iterable[str] | None = None 

237 convert: Iterable[str] | None = None 

238 converters: Mapping[str, Callable[[Element, str], str]] | None = None 

239 

240 def __post_init__(self) -> None: 

241 """Reject the one mutually exclusive pair up front, so the renderer never has to.""" 

242 if self.strip is not None and self.convert is not None: 

243 msg = "strip and convert are mutually exclusive" 

244 raise ValueError(msg) 

245 

246 @classmethod 

247 def google_doc(cls) -> Markdown: 

248 """ 

249 Read a Google Docs HTML export: inline-CSS styling drives emphasis, and struck text is dropped. 

250 

251 :returns: a config tuned for Google Docs export markup. 

252 """ 

253 return cls( 

254 inline=cls.Inline(hide_strikethrough=True), 

255 google=cls.GoogleDoc(enabled=True), 

256 ) 

257 

258 def _unpack(self) -> dict[str, object]: 

259 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults.""" 

260 default = _MARKDOWN_DEFAULT 

261 flat = { 

262 "heading_style": (self.headings.style, default.headings.style), 

263 "bullets": (self.lists.bullets, default.lists.bullets), 

264 "strong": (self.inline.strong, default.inline.strong), 

265 "emphasis": (self.inline.emphasis, default.inline.emphasis), 

266 "strikethrough": (self.inline.strikethrough, default.inline.strikethrough), 

267 "ignore_emphasis": (self.inline.ignore_emphasis, default.inline.ignore_emphasis), 

268 "sub_symbol": (self.inline.sub, default.inline.sub), 

269 "sup_symbol": (self.inline.sup, default.inline.sup), 

270 "hide_strikethrough": (self.inline.hide_strikethrough, default.inline.hide_strikethrough), 

271 "quote_open": (self.inline.quote_open, default.inline.quote_open), 

272 "quote_close": (self.inline.quote_close, default.inline.quote_close), 

273 "code_block_style": (self.code.block_style, default.code.block_style), 

274 "code_language": (self.code.language, default.code.language), 

275 "mark_code": (self.code.mark, default.code.mark), 

276 "link_style": (self.links.style, default.links.style), 

277 "autolink": (self.links.autolink, default.links.autolink), 

278 "link_title": (self.links.title, default.links.title), 

279 "ignore_links": (self.links.ignore, default.links.ignore), 

280 "skip_internal_links": (self.links.skip_internal, default.links.skip_internal), 

281 "base_url": (self.links.base_url, default.links.base_url), 

282 "image_mode": (self.images.mode, default.images.mode), 

283 "default_image_alt": (self.images.default_alt, default.images.default_alt), 

284 "table_mode": (self.tables.mode, default.tables.mode), 

285 "table_header": (self.tables.header, default.tables.header), 

286 "cell_blocks": (self.tables.cell_blocks, default.tables.cell_blocks), 

287 "pad_tables": (self.tables.pad, default.tables.pad), 

288 "escape_mode": (self.escaping.mode, default.escaping.mode), 

289 "escape_asterisks": (self.escaping.asterisks, default.escaping.asterisks), 

290 "escape_underscores": (self.escaping.underscores, default.escaping.underscores), 

291 "wrap_width": (self.wrapping.width, default.wrapping.width), 

292 "wrap_list_items": (self.wrapping.list_items, default.wrapping.list_items), 

293 "wrap_links": (self.wrapping.links, default.wrapping.links), 

294 "line_break": (self.document.line_break, default.document.line_break), 

295 "block_spacing": (self.document.block_spacing, default.document.block_spacing), 

296 "document_strip": (self.document.trim, default.document.trim), 

297 "transliterate": (self.document.transliterate, default.document.transliterate), 

298 "google_doc": (self.google.enabled, default.google.enabled), 

299 "google_list_indent": (self.google.list_indent, default.google.list_indent), 

300 } 

301 unpacked: dict[str, object] = {name: value for name, (value, base) in flat.items() if value != base} 

302 # pass the tag filters through unchanged: the renderer rejects a bare str (so 

303 # ``strip="div"`` is an error, not "strip tags d, i, v") and accepts any other iterable 

304 if self.strip is not None: 

305 unpacked["strip"] = self.strip 

306 if self.convert is not None: 

307 unpacked["convert"] = self.convert 

308 if self.converters is not None: 

309 unpacked["converters"] = self.converters 

310 return unpacked 

311 

312 

313_MARKDOWN_DEFAULT: Final = Markdown() 

314 

315 

316@dataclass(frozen=True) 

317class PlainText: 

318 """ 

319 How :meth:`Node.to_text` and :meth:`Node.to_annotated_text` render a subtree as layout-aware plain text. 

320 

321 :param width: wrap column for prose, or 0 to leave lines unwrapped. 

322 :param links: how to render ``<a>`` targets: ``none`` drops them, ``inline`` shows them after the text, 

323 ``footnote`` collects them at the end. 

324 :param images: render image alt text instead of skipping images. 

325 :param layout: ``extended`` adds blank lines and indentation; ``strict`` keeps the output compact. 

326 :param default_image_alt: alt text used for an image that declares none. 

327 :param table_cell_separator: string placed between table cells on a row. 

328 :param bullet: marker prefixed to each list item. 

329 """ 

330 

331 width: int = 0 

332 links: Literal["none", "inline", "footnote"] = "none" 

333 images: bool = False 

334 layout: Literal["extended", "strict"] = "extended" 

335 default_image_alt: str = "" 

336 table_cell_separator: str = " " 

337 bullet: str = "* " 

338 

339 def _unpack(self) -> dict[str, object]: 

340 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults.""" 

341 default = _PLAINTEXT_DEFAULT 

342 return { 

343 field_.name: value 

344 for field_ in (_PLAINTEXT_FIELDS if type(self) is PlainText else fields(self)) 

345 if (value := getattr(self, field_.name)) != getattr(default, field_.name) 

346 } 

347 

348 

349_PLAINTEXT_DEFAULT: Final = PlainText() 

350_PLAINTEXT_FIELDS: Final = fields(PlainText) 

351 

352 

353@dataclass(frozen=True) 

354class Html: 

355 """ 

356 How :meth:`Node.serialize` and :meth:`Node.encode` render a subtree as HTML. 

357 

358 :param formatter: the escape policy for text and attribute values. 

359 :param layout: ``None`` emits the compact WHATWG form, an :class:`~turbohtml.Indent` pretty-prints, a 

360 :class:`~turbohtml.Minify` minifies. 

361 :param sort_attributes: emit each element's attributes in name order rather than source order. 

362 :param meta_charset: normalize (or inject) the ``<meta charset>`` declaration to the output encoding. 

363 :param xml: emit XML/XHTML syntax -- every empty element self-closes (``<br/>``), foreign SVG and MathML 

364 subtrees carry their namespace declarations, and text and attribute values follow the XML escaping rules. 

365 The HTML void-element and raw-text special casing (and the ``formatter``/``meta_charset`` options) do not 

366 apply; a :class:`~turbohtml.Minify` layout stays HTML. The serializer drops or spaces content XML 1.0 cannot 

367 hold -- a C0 control other than tab/newline/return, a surrogate, the noncharacters U+FFFE/U+FFFF, a ``--`` or 

368 trailing ``-`` in a comment, an attribute name carrying a forbidden character -- so the output re-parses with 

369 :func:`~turbohtml.parse_xml`. 

370 """ 

371 

372 formatter: Formatter = Formatter.WHATWG 

373 layout: Indent | Minify | None = None 

374 sort_attributes: bool = False 

375 meta_charset: bool = False 

376 xml: bool = False 

377 

378 def _unpack(self) -> dict[str, object]: 

379 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults.""" 

380 default = _HTML_DEFAULT 

381 return { 

382 field_.name: value 

383 for field_ in (_HTML_FIELDS if type(self) is Html else fields(self)) 

384 if (value := getattr(self, field_.name)) != getattr(default, field_.name) 

385 } 

386 

387 

388_HTML_DEFAULT: Final = Html() 

389_HTML_FIELDS: Final = fields(Html) 

390 

391 

392@dataclass(frozen=True) 

393class Canonical: 

394 """ 

395 How :meth:`Node.canonicalize` renders a subtree as Canonical XML (c14n). 

396 

397 Canonical XML is the byte-exact serialization an XML signature signs: attributes are reordered, redundant 

398 namespace declarations are dropped, empty elements are written as start-end pairs, and character references are 

399 normalized. For a subtree, version ``1.1`` excludes inherited ``xml:id`` and fixes up ``xml:base`` across omitted 

400 ancestors. 

401 

402 :param version: ``1.0`` (`Canonical XML 1.0 <https://www.w3.org/TR/xml-c14n>`_) or ``1.1`` 

403 (`Canonical XML 1.1 <https://www.w3.org/TR/xml-c14n11/>`_). 

404 :param exclusive: use `Exclusive XML Canonicalization <https://www.w3.org/TR/xml-exc-c14n>`_, which renders only 

405 the namespaces a subtree visibly uses rather than inheriting every in-scope ancestor declaration. 

406 :param with_comments: keep comment nodes, the with-comments variant of each algorithm. 

407 :param inclusive_ns_prefixes: in exclusive mode, namespace prefixes promoted to the apex even when unused, so a 

408 detached subtree keeps them; only meaningful with ``exclusive``. 

409 :raises ValueError: if ``exclusive`` is combined with ``version="1.1"`` (exclusive c14n builds on 1.0 only), or if 

410 ``inclusive_ns_prefixes`` is given without ``exclusive``. 

411 """ 

412 

413 version: Literal["1.0", "1.1"] = "1.0" 

414 exclusive: bool = False 

415 with_comments: bool = False 

416 inclusive_ns_prefixes: tuple[str, ...] = () 

417 

418 def __post_init__(self) -> None: 

419 """Reject the option pairs the c14n algorithms leave undefined, so the renderer never has to.""" 

420 if self.exclusive and self.version == "1.1": 

421 msg = "exclusive canonicalization is defined only over version 1.0" 

422 raise ValueError(msg) 

423 if self.inclusive_ns_prefixes and not self.exclusive: 

424 msg = "inclusive_ns_prefixes applies only in exclusive mode" 

425 raise ValueError(msg) 

426 

427 def _unpack(self) -> dict[str, object]: 

428 """Flatten to the renderer's keyword names, emitting only values that differ from its defaults.""" 

429 default = _CANONICAL_DEFAULT 

430 return { 

431 field_.name: value 

432 for field_ in (_CANONICAL_FIELDS if type(self) is Canonical else fields(self)) 

433 if (value := getattr(self, field_.name)) != getattr(default, field_.name) 

434 } 

435 

436 

437_CANONICAL_DEFAULT: Final = Canonical() 

438_CANONICAL_FIELDS: Final = fields(Canonical) 

439 

440_register_render_configs(Markdown, PlainText, Html, Canonical) 

441 

442 

443__all__ = [ 

444 "Canonical", 

445 "Html", 

446 "Markdown", 

447 "PlainText", 

448]