md2txt/ama_renderer.py
2025-10-20 16:59:52 +03:00

239 lines
8.9 KiB
Python

from __future__ import annotations
import re
from functools import partial
from pathlib import Path
from typing import Any, Callable, List
from md_types import (
AsciiArtPayload,
BlockQuotePayload,
BlockStyle,
CodeBlockPayload,
FrontMatter,
HeadingPayload,
ListItemPayload,
ParagraphPayload,
StyleSpec,
)
from plugins import register_renderer
from text_renderer import (
BOLD_RE,
CODE_STASH_RE,
IMAGE_RE,
ITALIC_RE,
LINK_RE,
STRIKETHROUGH_RE,
TextRenderer,
UNDERLINE_EM_RE,
UNDERLINE_STRONG_RE,
)
LOCAL_SUFFIXES = {".ama"}
class AmaRenderer(TextRenderer):
def __init__(self, frontmatter: FrontMatter, *, width: int = 78, **_: Any) -> None:
super().__init__(width=min(width, 78), frontmatter=frontmatter)
self.links.clear()
self.link_indices.clear()
# Block rendering -----------------------------------------------------
def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None:
processed = self._process_inline(payload.text)
def render_fn(target_style: BlockStyle) -> List[str]:
return self._wrap_and_format(processed, target_style)
self._emit_block(render_fn(style), stylable=True, render_fn=render_fn, style=style)
if self.paragraph_spacing > 0:
self.output.extend([""] * self.paragraph_spacing)
def _render_heading(self, payload: HeadingPayload, style: BlockStyle) -> None:
self._ensure_header_spacing()
figlet = self._render_figlet_heading(payload.level, payload.text, style)
if figlet and all(len(line.rstrip()) <= self.width for line in figlet):
self._emit_block(figlet, stylable=False)
return
heading = self._process_inline(payload.text)
self._emit_block([f"%h {heading}", ""], stylable=False)
def _render_code_block(self, payload: CodeBlockPayload, style: BlockStyle) -> None:
margin_left, _, _ = self._margins(style)
base_indent = " " * margin_left + " "
body: List[str] = [base_indent + "`="]
content_width = max(1, self.width - len(base_indent))
for raw_line in payload.lines:
line = raw_line.rstrip("\n")
segments = self._wrap_code_line_segments(line, content_width) if line else []
if not segments:
body.append(base_indent)
continue
body.append(base_indent + segments[0])
for segment in segments[1:]:
body.append(base_indent + segment)
body.append(base_indent + "`=")
self._emit_block(body, stylable=False)
def _render_blockquote(self, payload: BlockQuotePayload, style: BlockStyle) -> None:
processed = self._process_inline(payload.text)
indent = " " * (3 * max(1, payload.depth))
def render_fn(target_style: BlockStyle) -> List[str]:
return self._wrap_and_format(processed, target_style, initial_indent=indent, subsequent_indent=indent)
self._emit_block(render_fn(style), stylable=True, render_fn=render_fn, style=style)
def _render_list_item(self, payload: ListItemPayload, style: BlockStyle) -> None:
base_indent = payload.indent.replace("\t", " ")
marker_indent = " " * self.list_marker_indent
marker = payload.marker
spacing = " " * self.list_text_spacing
initial = f"{base_indent}{marker_indent}{marker}{spacing}"
subsequent = f"{base_indent}{marker_indent}{' ' * len(marker)}{spacing}"
processed = self._process_inline(payload.text)
def render_fn(target_style: BlockStyle) -> List[str]:
return self._wrap_and_format(
processed,
target_style,
initial_indent=initial,
subsequent_indent=subsequent,
)
self._emit_block(render_fn(style), stylable=True, render_fn=render_fn, style=style)
def _render_horizontal_rule(self, _payload: object, style: BlockStyle) -> None:
margin_left, _, available = self._margins(style)
self._emit_block([" " * margin_left + "-" * available], stylable=False)
def _render_blank_line(self, *_: object) -> None:
self.output.append("")
def _render_custom_block(self, payload: AsciiArtPayload, style: BlockStyle) -> None:
render = partial(self._layout_ascii_pieces, payload.pieces)
def formatter(target_style: BlockStyle) -> List[str]:
return render(target_style)
self._emit_block(formatter(style), stylable=True, render_fn=formatter, style=style)
# Inline --------------------------------------------------------------
def _process_inline(self, text: str) -> str:
code_segments: List[str] = []
def stash_code(match):
segment = match.group(0)[1:-1]
code_segments.append(segment)
return f"\u0000CODE{len(code_segments) - 1}\u0000"
text = CODE_STASH_RE.sub(stash_code, text)
text = text.replace("%", "%%")
text = STRIKETHROUGH_RE.sub(lambda m: m.group(1), text)
text = BOLD_RE.sub(self._emphasis_handler("%!", str.upper), text)
text = ITALIC_RE.sub(self._emphasis_handler("%!", lambda s: s), text)
text = UNDERLINE_STRONG_RE.sub(self._emphasis_handler("%b", str.upper), text)
text = UNDERLINE_EM_RE.sub(self._emphasis_handler("%b", lambda s: s), text)
text = LINK_RE.sub(self._replace_link, text)
text = IMAGE_RE.sub(self._replace_image, text)
for idx, code in enumerate(code_segments):
text = text.replace(f"\u0000CODE{idx}\u0000", f"`={code}`=")
return text
@staticmethod
def _replace_link(match) -> str:
label = match.group(1).strip()
target = match.group(2).strip()
suffix = Path(target).suffix.lower()
if suffix in LOCAL_SUFFIXES:
return f"%l{Path(target).name}:{label or target}%t"
formatted_target = _format_external_url(target)
if label and label != target:
return f"{label} ({formatted_target})"
return formatted_target
@staticmethod
def _replace_image(match) -> str:
alt = match.group(1).strip()
url = match.group(2).strip()
suffix = Path(url).suffix.lower()
if suffix in LOCAL_SUFFIXES:
return f"%l{Path(url).name}:{alt or url}%t"
formatted_url = _format_external_url(url)
if alt and alt != url:
return f"{alt} ({formatted_url})"
return formatted_url
def _emphasis_handler(self, prefix: str, transform: Callable[[str], str]) -> Callable[[Any], str]:
def handler(match) -> str:
content = transform(match.group(1))
return self._apply_emphasis_spacing(match.string, match.start(), match.end(), f"{prefix}{content}%t")
return handler
def _wrap_and_format(
self,
text: str,
style: BlockStyle,
*,
initial_indent: str = "",
subsequent_indent: str | None = None,
) -> List[str]:
lines = self._wrap_text(
text,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent if subsequent_indent is not None else initial_indent,
style=style,
hyphenate=self.hyphenate,
)
return self._propagate_modes(lines)
def _propagate_modes(self, lines: List[str]) -> List[str]:
mode = "%t"
result: List[str] = []
for line in lines:
current_line = line
if mode in {"%!", "%b"}:
stripped = current_line.lstrip()
leading_ws = current_line[: len(current_line) - len(stripped)]
while stripped.startswith(("%t", "%!", "%b")):
stripped = stripped[2:]
spacer = " " if stripped and not stripped.startswith(" ") else ""
current_line = f"{leading_ws}{mode}{spacer}{stripped}" if stripped else f"{leading_ws}{mode}"
end_mode = self._line_end_mode(current_line)
result.append(current_line)
mode = end_mode if end_mode in {"%!", "%b"} else "%t"
return result
@staticmethod
def _line_end_mode(line: str) -> str:
mode = "%t"
idx = 0
while True:
pos = line.find("%", idx)
if pos == -1 or pos + 1 >= len(line):
break
code = line[pos:pos + 2]
if code in {"%!", "%b", "%t"}:
mode = code
elif code == "%%":
pass
idx = pos + 2
return mode
def _ama_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> AmaRenderer:
width = int(options.get("width", 78))
return AmaRenderer(frontmatter, width=width)
try:
register_renderer("ama", _ama_renderer_factory)
except ValueError:
pass
def _format_external_url(url: str) -> str:
stripped = url.strip()
if stripped.startswith("<") and stripped.endswith(">"):
return stripped
return f"<{stripped}>"