diff --git a/README.md b/README.md index cfbfa85..679e552 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This repository contains command line helpers for transforming Markdown into for ## Tools - `md2amb.py` – converts Markdown into Amber-screen formatted text (see script for details). -- `md2txt.py` – converts Markdown into 80-column, DOS-compatible plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers an optional `micron` renderer for Micron-formatted output, and exposes the core pipeline so you can add your own parser or renderer modules: +- `md2txt.py` – converts Markdown into 80-column, DOS-compatible plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron` and `amb` renderers for Micron/Ancient Machine Book output, and exposes the core pipeline so you can add your own parser or renderer modules: - FIGlet-rendered headings (H1–H3) driven by optional YAML frontmatter (`h1_font`, `h2_font`, `h3_font`). - H4+ headings rendered in uppercase with dashed underlines. - Emphasis styles converted to spaced or delimited characters, e.g. `**bold**` → `B O L D`, `__strong__` → `_s_t_r_o_n_g_`, `~~strike~~` → `~s~t~r~i~k~e~`. @@ -30,6 +30,7 @@ python md2txt.py input.md -o output.txt # convert to DOS python md2txt.py input.md # write result to stdout python md2txt.py input.md --width 72 # override column width python md2txt.py input.md --parser markdown --renderer micron # emit Micron-formatted output +python md2txt.py input.md --renderer amb # emit AMB/AMA markup python md2txt.py input.md --renderer-option width=68 # pass KEY=VALUE to a renderer ``` diff --git a/ama_renderer.py b/ama_renderer.py new file mode 100644 index 0000000..741eba7 --- /dev/null +++ b/ama_renderer.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +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() + self._base_style = BlockStyle(align="left", margin_left=0, margin_right=0) + + # Block rendering ----------------------------------------------------- + def _render_paragraph(self, payload: ParagraphPayload, style: BlockStyle) -> None: + processed = self._process_inline(payload.text) + self._emit_render( + lambda target_style: self._wrap_text( + processed, + initial_indent="", + subsequent_indent="", + style=target_style, + hyphenate=self.hyphenate, + ), + style, + stylable=True, + ) + 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)) + self._emit_render( + lambda target_style: self._wrap_text( + processed, + initial_indent=indent, + subsequent_indent=indent, + style=target_style, + hyphenate=self.hyphenate, + ), + style, + stylable=True, + ) + + 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) + self._emit_render( + lambda target_style: self._wrap_text( + processed, + initial_indent=initial, + subsequent_indent=subsequent, + style=target_style, + hyphenate=self.hyphenate, + ), + style, + stylable=True, + ) + + 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 _combine_styles(self, base: BlockStyle, spec: StyleSpec | None) -> BlockStyle: + combined = super()._combine_styles(base, spec) + return BlockStyle(align=combined.align, margin_left=0, margin_right=0) + + 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 _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}>" diff --git a/md2txt.py b/md2txt.py index 8eb8c50..b8a1a1d 100644 --- a/md2txt.py +++ b/md2txt.py @@ -23,6 +23,7 @@ from plugins import ( from text_renderer import TextRenderer import micron_renderer # noqa: F401 # register micron renderer plugin +import ama_renderer # noqa: F401 # register AMA renderer plugin def _split_option(token: str) -> Tuple[str, str]: