实现了完整的markdown解析与渲染能力,包含IR中间节点定义、markdown解析器以及两种渲染风格(markdown格式与纯文本格式),支持常见markdown语法渲染
277 lines
9.1 KiB
Python
277 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Callable
|
|
|
|
from yuxi.channel.render.ir import (
|
|
BlockQuote,
|
|
Bold,
|
|
BoldItalic,
|
|
CodeBlock,
|
|
Document,
|
|
Heading,
|
|
Image,
|
|
InlineCode,
|
|
IrNode,
|
|
Italic,
|
|
LineBreak,
|
|
Link,
|
|
ListItem,
|
|
OrderedList,
|
|
Paragraph,
|
|
Strikethrough,
|
|
Table,
|
|
TableCell,
|
|
TableRow,
|
|
Text,
|
|
ThematicBreak,
|
|
UnorderedList,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_PIPE_ESCAPE_RE = str.maketrans({"|": "\\|"})
|
|
|
|
|
|
def render_md_table(
|
|
header_texts: list[str],
|
|
body_texts: list[list[str]],
|
|
aligns: list[str | None],
|
|
) -> str:
|
|
if not header_texts and not body_texts:
|
|
return ""
|
|
col_count = len(header_texts) or (len(body_texts[0]) if body_texts else 0)
|
|
if col_count == 0:
|
|
return ""
|
|
|
|
while len(header_texts) < col_count:
|
|
header_texts.append("")
|
|
while len(aligns) < col_count:
|
|
aligns.append(None)
|
|
|
|
align_map = {
|
|
"left": ":--",
|
|
"center": ":-:",
|
|
"right": "--:",
|
|
}
|
|
|
|
def _fmt_row(cells: list[str]) -> str:
|
|
parts = [cell.translate(_PIPE_ESCAPE_RE) for cell in cells[:col_count]]
|
|
while len(parts) < col_count:
|
|
parts.append("")
|
|
return "| " + " | ".join(parts) + " |"
|
|
|
|
def _fmt_sep() -> str:
|
|
parts = [align_map.get(a or "", "---") for a in aligns[:col_count]]
|
|
while len(parts) < col_count:
|
|
parts.append("---")
|
|
return "|" + "|".join(parts) + "|"
|
|
|
|
lines: list[str] = []
|
|
if header_texts:
|
|
lines.append(_fmt_row(header_texts))
|
|
lines.append(_fmt_sep())
|
|
for row in body_texts:
|
|
lines.append(_fmt_row(row))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _pad_cell(text: str, width: int, align: str | None) -> str:
|
|
if align == "right":
|
|
return text.rjust(width)
|
|
elif align == "center":
|
|
return text.center(width)
|
|
else:
|
|
return text.ljust(width)
|
|
|
|
|
|
def render_plain_table(
|
|
header_texts: list[str],
|
|
body_texts: list[list[str]],
|
|
aligns: list[str | None],
|
|
) -> str:
|
|
if not header_texts and not body_texts:
|
|
return ""
|
|
all_rows = ([header_texts] if header_texts else []) + body_texts
|
|
if not all_rows:
|
|
return ""
|
|
col_count = max(len(row) for row in all_rows)
|
|
|
|
col_widths = [0] * col_count
|
|
for row in all_rows:
|
|
for j, cell in enumerate(row[:col_count]):
|
|
col_widths[j] = max(col_widths[j], len(cell) + 2)
|
|
|
|
while len(aligns) < col_count:
|
|
aligns.append(None)
|
|
|
|
def _fmt_row(cells: list[str]) -> str:
|
|
padded = []
|
|
for j in range(col_count):
|
|
c = cells[j] if j < len(cells) else ""
|
|
padded.append(_pad_cell(c, col_widths[j], aligns[j]))
|
|
return "|" + "|".join(padded) + "|"
|
|
|
|
lines: list[str] = []
|
|
if header_texts:
|
|
lines.append(_fmt_row(header_texts))
|
|
sep_parts = ["-" * w for w in col_widths]
|
|
lines.append("|" + "|".join(sep_parts) + "|")
|
|
for row in body_texts:
|
|
lines.append(_fmt_row(row))
|
|
return "\n".join(lines)
|
|
|
|
|
|
@dataclass
|
|
class RenderStyleMap:
|
|
bold: Callable[[str], str]
|
|
italic: Callable[[str], str]
|
|
bold_italic: Callable[[str], str]
|
|
strikethrough: Callable[[str], str]
|
|
inline_code: Callable[[str], str]
|
|
code_block: Callable[[str, str | None], str]
|
|
link: Callable[[str, str], str]
|
|
image: Callable[[str, str], str]
|
|
heading: Callable[[int, str], str]
|
|
unordered_list_item: Callable[[str], str]
|
|
ordered_list_item: Callable[[str, int], str]
|
|
blockquote: Callable[[str], str]
|
|
table: Callable[[list[str], list[list[str]], list[str | None]], str] | None = None
|
|
thematic_break: str = ""
|
|
paragraph_separator: str = "\n\n"
|
|
|
|
@classmethod
|
|
def markdown(cls) -> RenderStyleMap:
|
|
return cls(
|
|
bold=lambda s: f"**{s}**",
|
|
italic=lambda s: f"*{s}*",
|
|
bold_italic=lambda s: f"***{s}***",
|
|
strikethrough=lambda s: f"~~{s}~~",
|
|
inline_code=lambda s: f"`{s}`",
|
|
code_block=lambda code, lang: f"```{lang or ''}\n{code}\n```",
|
|
link=lambda text, url: f"[{text}]({url})",
|
|
image=lambda alt, url: f"",
|
|
heading=lambda level, text: f"{'#' * level} {text}",
|
|
unordered_list_item=lambda text: f"- {text}",
|
|
ordered_list_item=lambda text, idx: f"{idx}. {text}",
|
|
blockquote=lambda s: "\n".join(f"> {line}" for line in s.split("\n")),
|
|
table=render_md_table,
|
|
thematic_break="---",
|
|
)
|
|
|
|
@classmethod
|
|
def plain_text(cls) -> RenderStyleMap:
|
|
return cls(
|
|
bold=lambda s: s,
|
|
italic=lambda s: s,
|
|
bold_italic=lambda s: s,
|
|
strikethrough=lambda s: s,
|
|
inline_code=lambda s: s,
|
|
code_block=lambda code, lang: code,
|
|
link=lambda text, url: f"{text} ({url})" if text else url,
|
|
image=lambda alt, url: f"[{alt or 'Image'}]({url})",
|
|
heading=lambda level, text: text,
|
|
unordered_list_item=lambda text: f"- {text}",
|
|
ordered_list_item=lambda text, idx: f"{idx}. {text}",
|
|
blockquote=lambda s: s,
|
|
table=render_plain_table,
|
|
thematic_break="---",
|
|
)
|
|
|
|
|
|
def _render_inline(nodes: list[IrNode], style: RenderStyleMap) -> str:
|
|
parts: list[str] = []
|
|
for node in nodes:
|
|
if isinstance(node, Text):
|
|
parts.append(node.content)
|
|
elif isinstance(node, Bold):
|
|
parts.append(style.bold(_render_inline(node.children, style)))
|
|
elif isinstance(node, Italic):
|
|
parts.append(style.italic(_render_inline(node.children, style)))
|
|
elif isinstance(node, BoldItalic):
|
|
parts.append(style.bold_italic(_render_inline(node.children, style)))
|
|
elif isinstance(node, Strikethrough):
|
|
parts.append(style.strikethrough(_render_inline(node.children, style)))
|
|
elif isinstance(node, InlineCode):
|
|
parts.append(style.inline_code(node.code))
|
|
elif isinstance(node, Link):
|
|
text = _render_inline(node.children, style)
|
|
parts.append(style.link(text, node.url))
|
|
elif isinstance(node, Image):
|
|
parts.append(style.image(node.alt, node.url))
|
|
elif isinstance(node, LineBreak):
|
|
parts.append("\n")
|
|
return "".join(parts)
|
|
|
|
|
|
def render(doc: Document, style: RenderStyleMap) -> str:
|
|
parts: list[str] = []
|
|
for i, node in enumerate(doc.children):
|
|
if i > 0:
|
|
parts.append(style.paragraph_separator)
|
|
parts.append(_render_block(node, style))
|
|
return "".join(parts)
|
|
|
|
|
|
def _render_block(node: IrNode, style: RenderStyleMap) -> str:
|
|
if isinstance(node, Paragraph):
|
|
return _render_inline(node.children, style)
|
|
elif isinstance(node, Heading):
|
|
text = _render_inline(node.children, style)
|
|
return style.heading(node.level, text)
|
|
elif isinstance(node, CodeBlock):
|
|
return style.code_block(node.code, node.language)
|
|
elif isinstance(node, BlockQuote):
|
|
inner = _render_blocks(node.children, style)
|
|
return style.blockquote(inner)
|
|
elif isinstance(node, UnorderedList):
|
|
lines = []
|
|
for item in node.items:
|
|
text = _render_list_item(item, style)
|
|
lines.append(style.unordered_list_item(text))
|
|
return "\n".join(lines)
|
|
elif isinstance(node, OrderedList):
|
|
lines = []
|
|
for idx, item in enumerate(node.items, start=node.start):
|
|
text = _render_list_item(item, style)
|
|
lines.append(style.ordered_list_item(text, idx))
|
|
return "\n".join(lines)
|
|
elif isinstance(node, ThematicBreak):
|
|
return style.thematic_break
|
|
elif isinstance(node, Table):
|
|
if style.table is not None:
|
|
header_texts = [_render_inline(cell.children, style) for cell in (node.header.cells if node.header else [])]
|
|
aligns = [
|
|
cell.align for cell in (node.header.cells if node.header else node.rows[0].cells if node.rows else [])
|
|
]
|
|
body_texts = [[_render_inline(cell.children, style) for cell in row.cells] for row in node.rows]
|
|
return style.table(header_texts, body_texts, aligns)
|
|
return ""
|
|
else:
|
|
logger.warning("Unknown IR node type during render: %s", type(node).__name__)
|
|
return ""
|
|
|
|
|
|
def _render_list_item(item: ListItem, style: RenderStyleMap) -> str:
|
|
inline_parts: list[str] = []
|
|
block_parts: list[str] = []
|
|
for child in item.children:
|
|
if isinstance(child, (UnorderedList, OrderedList)):
|
|
block_parts.append(_render_block(child, style))
|
|
elif isinstance(child, Paragraph):
|
|
block_parts.append(_render_block(child, style))
|
|
else:
|
|
inline_parts.append(_render_inline([child], style))
|
|
result = "".join(inline_parts)
|
|
if block_parts:
|
|
indent = " "
|
|
result += "\n" + "\n".join(indent + line for bp in block_parts for line in bp.split("\n"))
|
|
return result
|
|
|
|
|
|
def _render_blocks(nodes: list[IrNode], style: RenderStyleMap) -> str:
|
|
parts: list[str] = []
|
|
for node in nodes:
|
|
parts.append(_render_block(node, style))
|
|
return "\n".join(parts) |