Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 

780 lignes
25 KiB

  1. from __future__ import annotations
  2. import sys
  3. from typing import ClassVar, Iterable, get_args
  4. from markdown_it import MarkdownIt
  5. from markdown_it.token import Token
  6. from rich.table import Table
  7. from . import box
  8. from ._loop import loop_first
  9. from ._stack import Stack
  10. from .console import Console, ConsoleOptions, JustifyMethod, RenderResult
  11. from .containers import Renderables
  12. from .jupyter import JupyterMixin
  13. from .panel import Panel
  14. from .rule import Rule
  15. from .segment import Segment
  16. from .style import Style, StyleStack
  17. from .syntax import Syntax
  18. from .text import Text, TextType
  19. class MarkdownElement:
  20. new_line: ClassVar[bool] = True
  21. @classmethod
  22. def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
  23. """Factory to create markdown element,
  24. Args:
  25. markdown (Markdown): The parent Markdown object.
  26. token (Token): A node from markdown-it.
  27. Returns:
  28. MarkdownElement: A new markdown element
  29. """
  30. return cls()
  31. def on_enter(self, context: MarkdownContext) -> None:
  32. """Called when the node is entered.
  33. Args:
  34. context (MarkdownContext): The markdown context.
  35. """
  36. def on_text(self, context: MarkdownContext, text: TextType) -> None:
  37. """Called when text is parsed.
  38. Args:
  39. context (MarkdownContext): The markdown context.
  40. """
  41. def on_leave(self, context: MarkdownContext) -> None:
  42. """Called when the parser leaves the element.
  43. Args:
  44. context (MarkdownContext): [description]
  45. """
  46. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  47. """Called when a child element is closed.
  48. This method allows a parent element to take over rendering of its children.
  49. Args:
  50. context (MarkdownContext): The markdown context.
  51. child (MarkdownElement): The child markdown element.
  52. Returns:
  53. bool: Return True to render the element, or False to not render the element.
  54. """
  55. return True
  56. def __rich_console__(
  57. self, console: Console, options: ConsoleOptions
  58. ) -> RenderResult:
  59. return ()
  60. class UnknownElement(MarkdownElement):
  61. """An unknown element.
  62. Hopefully there will be no unknown elements, and we will have a MarkdownElement for
  63. everything in the document.
  64. """
  65. class TextElement(MarkdownElement):
  66. """Base class for elements that render text."""
  67. style_name = "none"
  68. def on_enter(self, context: MarkdownContext) -> None:
  69. self.style = context.enter_style(self.style_name)
  70. self.text = Text(justify="left")
  71. def on_text(self, context: MarkdownContext, text: TextType) -> None:
  72. self.text.append(text, context.current_style if isinstance(text, str) else None)
  73. def on_leave(self, context: MarkdownContext) -> None:
  74. context.leave_style()
  75. class Paragraph(TextElement):
  76. """A Paragraph."""
  77. style_name = "markdown.paragraph"
  78. justify: JustifyMethod
  79. @classmethod
  80. def create(cls, markdown: Markdown, token: Token) -> Paragraph:
  81. return cls(justify=markdown.justify or "left")
  82. def __init__(self, justify: JustifyMethod) -> None:
  83. self.justify = justify
  84. def __rich_console__(
  85. self, console: Console, options: ConsoleOptions
  86. ) -> RenderResult:
  87. self.text.justify = self.justify
  88. yield self.text
  89. class Heading(TextElement):
  90. """A heading."""
  91. @classmethod
  92. def create(cls, markdown: Markdown, token: Token) -> Heading:
  93. return cls(token.tag)
  94. def on_enter(self, context: MarkdownContext) -> None:
  95. self.text = Text()
  96. context.enter_style(self.style_name)
  97. def __init__(self, tag: str) -> None:
  98. self.tag = tag
  99. self.style_name = f"markdown.{tag}"
  100. super().__init__()
  101. def __rich_console__(
  102. self, console: Console, options: ConsoleOptions
  103. ) -> RenderResult:
  104. text = self.text
  105. text.justify = "center"
  106. if self.tag == "h1":
  107. # Draw a border around h1s
  108. yield Panel(
  109. text,
  110. box=box.HEAVY,
  111. style="markdown.h1.border",
  112. )
  113. else:
  114. # Styled text for h2 and beyond
  115. if self.tag == "h2":
  116. yield Text("")
  117. yield text
  118. class CodeBlock(TextElement):
  119. """A code block with syntax highlighting."""
  120. style_name = "markdown.code_block"
  121. @classmethod
  122. def create(cls, markdown: Markdown, token: Token) -> CodeBlock:
  123. node_info = token.info or ""
  124. lexer_name = node_info.partition(" ")[0]
  125. return cls(lexer_name or "text", markdown.code_theme)
  126. def __init__(self, lexer_name: str, theme: str) -> None:
  127. self.lexer_name = lexer_name
  128. self.theme = theme
  129. def __rich_console__(
  130. self, console: Console, options: ConsoleOptions
  131. ) -> RenderResult:
  132. code = str(self.text).rstrip()
  133. syntax = Syntax(
  134. code, self.lexer_name, theme=self.theme, word_wrap=True, padding=1
  135. )
  136. yield syntax
  137. class BlockQuote(TextElement):
  138. """A block quote."""
  139. style_name = "markdown.block_quote"
  140. def __init__(self) -> None:
  141. self.elements: Renderables = Renderables()
  142. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  143. self.elements.append(child)
  144. return False
  145. def __rich_console__(
  146. self, console: Console, options: ConsoleOptions
  147. ) -> RenderResult:
  148. render_options = options.update(width=options.max_width - 4)
  149. lines = console.render_lines(self.elements, render_options, style=self.style)
  150. style = self.style
  151. new_line = Segment("\n")
  152. padding = Segment("▌ ", style)
  153. for line in lines:
  154. yield padding
  155. yield from line
  156. yield new_line
  157. class HorizontalRule(MarkdownElement):
  158. """A horizontal rule to divide sections."""
  159. new_line = False
  160. def __rich_console__(
  161. self, console: Console, options: ConsoleOptions
  162. ) -> RenderResult:
  163. style = console.get_style("markdown.hr", default="none")
  164. yield Rule(style=style)
  165. class TableElement(MarkdownElement):
  166. """MarkdownElement corresponding to `table_open`."""
  167. def __init__(self) -> None:
  168. self.header: TableHeaderElement | None = None
  169. self.body: TableBodyElement | None = None
  170. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  171. if isinstance(child, TableHeaderElement):
  172. self.header = child
  173. elif isinstance(child, TableBodyElement):
  174. self.body = child
  175. else:
  176. raise RuntimeError("Couldn't process markdown table.")
  177. return False
  178. def __rich_console__(
  179. self, console: Console, options: ConsoleOptions
  180. ) -> RenderResult:
  181. table = Table(box=box.SIMPLE_HEAVY)
  182. if self.header is not None and self.header.row is not None:
  183. for column in self.header.row.cells:
  184. table.add_column(column.content)
  185. if self.body is not None:
  186. for row in self.body.rows:
  187. row_content = [element.content for element in row.cells]
  188. table.add_row(*row_content)
  189. yield table
  190. class TableHeaderElement(MarkdownElement):
  191. """MarkdownElement corresponding to `thead_open` and `thead_close`."""
  192. def __init__(self) -> None:
  193. self.row: TableRowElement | None = None
  194. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  195. assert isinstance(child, TableRowElement)
  196. self.row = child
  197. return False
  198. class TableBodyElement(MarkdownElement):
  199. """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""
  200. def __init__(self) -> None:
  201. self.rows: list[TableRowElement] = []
  202. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  203. assert isinstance(child, TableRowElement)
  204. self.rows.append(child)
  205. return False
  206. class TableRowElement(MarkdownElement):
  207. """MarkdownElement corresponding to `tr_open` and `tr_close`."""
  208. def __init__(self) -> None:
  209. self.cells: list[TableDataElement] = []
  210. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  211. assert isinstance(child, TableDataElement)
  212. self.cells.append(child)
  213. return False
  214. class TableDataElement(MarkdownElement):
  215. """MarkdownElement corresponding to `td_open` and `td_close`
  216. and `th_open` and `th_close`."""
  217. @classmethod
  218. def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
  219. style = str(token.attrs.get("style")) or ""
  220. justify: JustifyMethod
  221. if "text-align:right" in style:
  222. justify = "right"
  223. elif "text-align:center" in style:
  224. justify = "center"
  225. elif "text-align:left" in style:
  226. justify = "left"
  227. else:
  228. justify = "default"
  229. assert justify in get_args(JustifyMethod)
  230. return cls(justify=justify)
  231. def __init__(self, justify: JustifyMethod) -> None:
  232. self.content: Text = Text("", justify=justify)
  233. self.justify = justify
  234. def on_text(self, context: MarkdownContext, text: TextType) -> None:
  235. text = Text(text) if isinstance(text, str) else text
  236. text.stylize(context.current_style)
  237. self.content.append_text(text)
  238. class ListElement(MarkdownElement):
  239. """A list element."""
  240. @classmethod
  241. def create(cls, markdown: Markdown, token: Token) -> ListElement:
  242. return cls(token.type, int(token.attrs.get("start", 1)))
  243. def __init__(self, list_type: str, list_start: int | None) -> None:
  244. self.items: list[ListItem] = []
  245. self.list_type = list_type
  246. self.list_start = list_start
  247. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  248. assert isinstance(child, ListItem)
  249. self.items.append(child)
  250. return False
  251. def __rich_console__(
  252. self, console: Console, options: ConsoleOptions
  253. ) -> RenderResult:
  254. if self.list_type == "bullet_list_open":
  255. for item in self.items:
  256. yield from item.render_bullet(console, options)
  257. else:
  258. number = 1 if self.list_start is None else self.list_start
  259. last_number = number + len(self.items)
  260. for index, item in enumerate(self.items):
  261. yield from item.render_number(
  262. console, options, number + index, last_number
  263. )
  264. class ListItem(TextElement):
  265. """An item in a list."""
  266. style_name = "markdown.item"
  267. def __init__(self) -> None:
  268. self.elements: Renderables = Renderables()
  269. def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
  270. self.elements.append(child)
  271. return False
  272. def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
  273. render_options = options.update(width=options.max_width - 3)
  274. lines = console.render_lines(self.elements, render_options, style=self.style)
  275. bullet_style = console.get_style("markdown.item.bullet", default="none")
  276. bullet = Segment(" • ", bullet_style)
  277. padding = Segment(" " * 3, bullet_style)
  278. new_line = Segment("\n")
  279. for first, line in loop_first(lines):
  280. yield bullet if first else padding
  281. yield from line
  282. yield new_line
  283. def render_number(
  284. self, console: Console, options: ConsoleOptions, number: int, last_number: int
  285. ) -> RenderResult:
  286. number_width = len(str(last_number)) + 2
  287. render_options = options.update(width=options.max_width - number_width)
  288. lines = console.render_lines(self.elements, render_options, style=self.style)
  289. number_style = console.get_style("markdown.item.number", default="none")
  290. new_line = Segment("\n")
  291. padding = Segment(" " * number_width, number_style)
  292. numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style)
  293. for first, line in loop_first(lines):
  294. yield numeral if first else padding
  295. yield from line
  296. yield new_line
  297. class Link(TextElement):
  298. @classmethod
  299. def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
  300. url = token.attrs.get("href", "#")
  301. return cls(token.content, str(url))
  302. def __init__(self, text: str, href: str):
  303. self.text = Text(text)
  304. self.href = href
  305. class ImageItem(TextElement):
  306. """Renders a placeholder for an image."""
  307. new_line = False
  308. @classmethod
  309. def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
  310. """Factory to create markdown element,
  311. Args:
  312. markdown (Markdown): The parent Markdown object.
  313. token (Any): A token from markdown-it.
  314. Returns:
  315. MarkdownElement: A new markdown element
  316. """
  317. return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)
  318. def __init__(self, destination: str, hyperlinks: bool) -> None:
  319. self.destination = destination
  320. self.hyperlinks = hyperlinks
  321. self.link: str | None = None
  322. super().__init__()
  323. def on_enter(self, context: MarkdownContext) -> None:
  324. self.link = context.current_style.link
  325. self.text = Text(justify="left")
  326. super().on_enter(context)
  327. def __rich_console__(
  328. self, console: Console, options: ConsoleOptions
  329. ) -> RenderResult:
  330. link_style = Style(link=self.link or self.destination or None)
  331. title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])
  332. if self.hyperlinks:
  333. title.stylize(link_style)
  334. text = Text.assemble("🌆 ", title, " ", end="")
  335. yield text
  336. class MarkdownContext:
  337. """Manages the console render state."""
  338. def __init__(
  339. self,
  340. console: Console,
  341. options: ConsoleOptions,
  342. style: Style,
  343. inline_code_lexer: str | None = None,
  344. inline_code_theme: str = "monokai",
  345. ) -> None:
  346. self.console = console
  347. self.options = options
  348. self.style_stack: StyleStack = StyleStack(style)
  349. self.stack: Stack[MarkdownElement] = Stack()
  350. self._syntax: Syntax | None = None
  351. if inline_code_lexer is not None:
  352. self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)
  353. @property
  354. def current_style(self) -> Style:
  355. """Current style which is the product of all styles on the stack."""
  356. return self.style_stack.current
  357. def on_text(self, text: str, node_type: str) -> None:
  358. """Called when the parser visits text."""
  359. if node_type in {"fence", "code_inline"} and self._syntax is not None:
  360. highlight_text = self._syntax.highlight(text)
  361. highlight_text.rstrip()
  362. self.stack.top.on_text(
  363. self, Text.assemble(highlight_text, style=self.style_stack.current)
  364. )
  365. else:
  366. self.stack.top.on_text(self, text)
  367. def enter_style(self, style_name: str | Style) -> Style:
  368. """Enter a style context."""
  369. style = self.console.get_style(style_name, default="none")
  370. self.style_stack.push(style)
  371. return self.current_style
  372. def leave_style(self) -> Style:
  373. """Leave a style context."""
  374. style = self.style_stack.pop()
  375. return style
  376. class Markdown(JupyterMixin):
  377. """A Markdown renderable.
  378. Args:
  379. markup (str): A string containing markdown.
  380. code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai". See https://pygments.org/styles/ for code themes.
  381. justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.
  382. style (Union[str, Style], optional): Optional style to apply to markdown.
  383. hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.
  384. inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is
  385. enabled. Defaults to None.
  386. inline_code_theme: (Optional[str], optional): Pygments theme for inline code
  387. highlighting, or None for no highlighting. Defaults to None.
  388. """
  389. elements: ClassVar[dict[str, type[MarkdownElement]]] = {
  390. "paragraph_open": Paragraph,
  391. "heading_open": Heading,
  392. "fence": CodeBlock,
  393. "code_block": CodeBlock,
  394. "blockquote_open": BlockQuote,
  395. "hr": HorizontalRule,
  396. "bullet_list_open": ListElement,
  397. "ordered_list_open": ListElement,
  398. "list_item_open": ListItem,
  399. "image": ImageItem,
  400. "table_open": TableElement,
  401. "tbody_open": TableBodyElement,
  402. "thead_open": TableHeaderElement,
  403. "tr_open": TableRowElement,
  404. "td_open": TableDataElement,
  405. "th_open": TableDataElement,
  406. }
  407. inlines = {"em", "strong", "code", "s"}
  408. def __init__(
  409. self,
  410. markup: str,
  411. code_theme: str = "monokai",
  412. justify: JustifyMethod | None = None,
  413. style: str | Style = "none",
  414. hyperlinks: bool = True,
  415. inline_code_lexer: str | None = None,
  416. inline_code_theme: str | None = None,
  417. ) -> None:
  418. parser = MarkdownIt().enable("strikethrough").enable("table")
  419. self.markup = markup
  420. self.parsed = parser.parse(markup)
  421. self.code_theme = code_theme
  422. self.justify: JustifyMethod | None = justify
  423. self.style = style
  424. self.hyperlinks = hyperlinks
  425. self.inline_code_lexer = inline_code_lexer
  426. self.inline_code_theme = inline_code_theme or code_theme
  427. def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
  428. """Flattens the token stream."""
  429. for token in tokens:
  430. is_fence = token.type == "fence"
  431. is_image = token.tag == "img"
  432. if token.children and not (is_image or is_fence):
  433. yield from self._flatten_tokens(token.children)
  434. else:
  435. yield token
  436. def __rich_console__(
  437. self, console: Console, options: ConsoleOptions
  438. ) -> RenderResult:
  439. """Render markdown to the console."""
  440. style = console.get_style(self.style, default="none")
  441. options = options.update(height=None)
  442. context = MarkdownContext(
  443. console,
  444. options,
  445. style,
  446. inline_code_lexer=self.inline_code_lexer,
  447. inline_code_theme=self.inline_code_theme,
  448. )
  449. tokens = self.parsed
  450. inline_style_tags = self.inlines
  451. new_line = False
  452. _new_line_segment = Segment.line()
  453. for token in self._flatten_tokens(tokens):
  454. node_type = token.type
  455. tag = token.tag
  456. entering = token.nesting == 1
  457. exiting = token.nesting == -1
  458. self_closing = token.nesting == 0
  459. if node_type == "text":
  460. context.on_text(token.content, node_type)
  461. elif node_type == "hardbreak":
  462. context.on_text("\n", node_type)
  463. elif node_type == "softbreak":
  464. context.on_text(" ", node_type)
  465. elif node_type == "link_open":
  466. href = str(token.attrs.get("href", ""))
  467. if self.hyperlinks:
  468. link_style = console.get_style("markdown.link_url", default="none")
  469. link_style += Style(link=href)
  470. context.enter_style(link_style)
  471. else:
  472. context.stack.push(Link.create(self, token))
  473. elif node_type == "link_close":
  474. if self.hyperlinks:
  475. context.leave_style()
  476. else:
  477. element = context.stack.pop()
  478. assert isinstance(element, Link)
  479. link_style = console.get_style("markdown.link", default="none")
  480. context.enter_style(link_style)
  481. context.on_text(element.text.plain, node_type)
  482. context.leave_style()
  483. context.on_text(" (", node_type)
  484. link_url_style = console.get_style(
  485. "markdown.link_url", default="none"
  486. )
  487. context.enter_style(link_url_style)
  488. context.on_text(element.href, node_type)
  489. context.leave_style()
  490. context.on_text(")", node_type)
  491. elif (
  492. tag in inline_style_tags
  493. and node_type != "fence"
  494. and node_type != "code_block"
  495. ):
  496. if entering:
  497. # If it's an opening inline token e.g. strong, em, etc.
  498. # Then we move into a style context i.e. push to stack.
  499. context.enter_style(f"markdown.{tag}")
  500. elif exiting:
  501. # If it's a closing inline style, then we pop the style
  502. # off of the stack, to move out of the context of it...
  503. context.leave_style()
  504. else:
  505. # If it's a self-closing inline style e.g. `code_inline`
  506. context.enter_style(f"markdown.{tag}")
  507. if token.content:
  508. context.on_text(token.content, node_type)
  509. context.leave_style()
  510. else:
  511. # Map the markdown tag -> MarkdownElement renderable
  512. element_class = self.elements.get(token.type) or UnknownElement
  513. element = element_class.create(self, token)
  514. if entering or self_closing:
  515. context.stack.push(element)
  516. element.on_enter(context)
  517. if exiting: # CLOSING tag
  518. element = context.stack.pop()
  519. should_render = not context.stack or (
  520. context.stack
  521. and context.stack.top.on_child_close(context, element)
  522. )
  523. if should_render:
  524. if new_line:
  525. yield _new_line_segment
  526. yield from console.render(element, context.options)
  527. elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)
  528. context.stack.pop()
  529. text = token.content
  530. if text is not None:
  531. element.on_text(context, text)
  532. should_render = (
  533. not context.stack
  534. or context.stack
  535. and context.stack.top.on_child_close(context, element)
  536. )
  537. if should_render:
  538. if new_line and node_type != "inline":
  539. yield _new_line_segment
  540. yield from console.render(element, context.options)
  541. if exiting or self_closing:
  542. element.on_leave(context)
  543. new_line = element.new_line
  544. if __name__ == "__main__": # pragma: no cover
  545. import argparse
  546. import sys
  547. parser = argparse.ArgumentParser(
  548. description="Render Markdown to the console with Rich"
  549. )
  550. parser.add_argument(
  551. "path",
  552. metavar="PATH",
  553. help="path to markdown file, or - for stdin",
  554. )
  555. parser.add_argument(
  556. "-c",
  557. "--force-color",
  558. dest="force_color",
  559. action="store_true",
  560. default=None,
  561. help="force color for non-terminals",
  562. )
  563. parser.add_argument(
  564. "-t",
  565. "--code-theme",
  566. dest="code_theme",
  567. default="monokai",
  568. help="pygments code theme",
  569. )
  570. parser.add_argument(
  571. "-i",
  572. "--inline-code-lexer",
  573. dest="inline_code_lexer",
  574. default=None,
  575. help="inline_code_lexer",
  576. )
  577. parser.add_argument(
  578. "-y",
  579. "--hyperlinks",
  580. dest="hyperlinks",
  581. action="store_true",
  582. help="enable hyperlinks",
  583. )
  584. parser.add_argument(
  585. "-w",
  586. "--width",
  587. type=int,
  588. dest="width",
  589. default=None,
  590. help="width of output (default will auto-detect)",
  591. )
  592. parser.add_argument(
  593. "-j",
  594. "--justify",
  595. dest="justify",
  596. action="store_true",
  597. help="enable full text justify",
  598. )
  599. parser.add_argument(
  600. "-p",
  601. "--page",
  602. dest="page",
  603. action="store_true",
  604. help="use pager to scroll output",
  605. )
  606. args = parser.parse_args()
  607. from rich.console import Console
  608. if args.path == "-":
  609. markdown_body = sys.stdin.read()
  610. else:
  611. with open(args.path, encoding="utf-8") as markdown_file:
  612. markdown_body = markdown_file.read()
  613. markdown = Markdown(
  614. markdown_body,
  615. justify="full" if args.justify else "left",
  616. code_theme=args.code_theme,
  617. hyperlinks=args.hyperlinks,
  618. inline_code_lexer=args.inline_code_lexer,
  619. )
  620. if args.page:
  621. import io
  622. import pydoc
  623. fileio = io.StringIO()
  624. console = Console(
  625. file=fileio, force_terminal=args.force_color, width=args.width
  626. )
  627. console.print(markdown)
  628. pydoc.pager(fileio.getvalue())
  629. else:
  630. console = Console(
  631. force_terminal=args.force_color, width=args.width, record=True
  632. )
  633. console.print(markdown)