From b85530483abe56c4472eb9199786b609517258ea Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 20 Oct 2025 14:14:07 +0300 Subject: [PATCH] ascii art blocks --- examples/loremipsum.md | 15 +--- markdown_parser.py | 88 +++++++++++++++++++++- md2txt.py | 155 ++++++++++++++++++++++++++++++++++++++- md_types.py | 15 ++++ text_renderer.py | 161 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 417 insertions(+), 17 deletions(-) diff --git a/examples/loremipsum.md b/examples/loremipsum.md index 9d82d30..48af7f4 100644 --- a/examples/loremipsum.md +++ b/examples/loremipsum.md @@ -41,20 +41,11 @@ populifer adeunt quicquam cum **ales** Ixion angulus placet reddere. ## Tractaque serpens arva quoque {:.center} -### Unordered +# Include -+ Create a list by starting a line with `+`, `-`, or `*` -- Marker character change forces new list start: -* Ac tristique libero volutpat at jesrh hjsrh sejr hhsjehv sjtrhg jrdth -+ Facilisis in pretium nisl aliquet -- Nulla volutpat aliquam velit -+ Very easy +#[d1 :left](dragon.txt) #[d1 :center :mirror](dragon.txt) #[d1 :right](dragon.txt) -### Ordered - -1. Lorem ipsum dolor sit amet -2. Consectetur adipiscing elit -3. Integer molestie lorem at massa +![[markdown.md]] Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit `gate_truncate_wave`. [Geratur de](#salientia-iungit-contra-et), insolida diff --git a/markdown_parser.py b/markdown_parser.py index 6ed08be..a49086f 100644 --- a/markdown_parser.py +++ b/markdown_parser.py @@ -1,9 +1,13 @@ from __future__ import annotations +import json import re -from typing import Iterable, Iterator, List, Optional, Union +from pathlib import Path +from typing import Dict, Iterable, Iterator, List, Optional, Union from md_types import ( + AsciiArtPayload, + AsciiArtPiece, BlockEvent, BlockKind, BlockQuotePayload, @@ -27,6 +31,7 @@ PARA_OPEN_RE = re.compile(r"^\s*]*)>\s*$", re.IGNORECASE) PARA_CLOSE_RE = re.compile(r"^\s*

\s*$", re.IGNORECASE) MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$") MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$") +ASCII_SENTINEL_PREFIX = "\u0000ASCII:" class MarkdownParser: @@ -36,6 +41,7 @@ class MarkdownParser: self._paragraph_style_spec: Optional[StyleSpec] = None self._pending_block_style_spec: Optional[StyleSpec] = None self._last_stylable_block: bool = False + self._ascii_cache: Dict[str, List[str]] = {} def parse(self, lines: Iterable[str]) -> Iterator[Union[BlockEvent, StyleUpdateEvent]]: self._reset_state() @@ -48,6 +54,23 @@ class MarkdownParser: for raw_line in iterator: line = raw_line.rstrip("\n") + if line.startswith(ASCII_SENTINEL_PREFIX): + event = self._flush_paragraph(current_paragraph) + if event is not None: + yield event + current_paragraph = [] + payload = self._build_ascii_payload(line) + style = self._combine_styles(self._current_style(), self._pending_block_style_spec) + self._pending_block_style_spec = None + self._last_stylable_block = True + yield BlockEvent( + kind=BlockKind.CUSTOM_BLOCK, + payload=payload, + style=style, + stylable=True, + ) + continue + if in_code_block: if line.strip().startswith("```"): event = self._flush_code_block(code_lines) @@ -297,6 +320,69 @@ class MarkdownParser: stylable=False, ) + def _build_ascii_payload(self, sentinel_line: str) -> AsciiArtPayload: + entries = self._decode_ascii_sentinel(sentinel_line) + pieces: List[AsciiArtPiece] = [] + for entry in entries: + lines = self._load_ascii_art_lines(entry["path"]) + pieces.append( + AsciiArtPiece( + block_type=entry["type"], + name=entry["name"], + path=entry["path"], + lines=lines, + align=entry["align"], + ) + ) + return AsciiArtPayload(pieces=pieces) + + def _decode_ascii_sentinel(self, line: str) -> List[Dict[str, str]]: + payload = line[len(ASCII_SENTINEL_PREFIX) :] + try: + data = json.loads(payload) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid ASCII sentinel payload: {payload}") from exc + if "pieces" in data and isinstance(data["pieces"], list): + raw_pieces = data["pieces"] + else: + raw_pieces = [data] + pieces: List[Dict[str, str]] = [] + for entry in raw_pieces: + for key in ("type", "name", "path"): + if key not in entry: + raise ValueError(f"ASCII sentinel missing '{key}' field: {payload}") + align = entry.get("align") + align_str = str(align) if align is not None else None + if align_str is not None: + align_lower = align_str.strip().lower() + if align_lower == "centre": + align_lower = "center" + if align_lower not in {"left", "center", "right"}: + align_lower = None + else: + align_lower = None + pieces.append( + { + "type": str(entry["type"]), + "name": str(entry["name"]), + "path": str(entry["path"]), + "align": align_lower, + } + ) + return pieces + + def _load_ascii_art_lines(self, path_str: str) -> List[str]: + cached = self._ascii_cache.get(path_str) + if cached is not None: + return list(cached) + path = Path(path_str) + if not path.exists(): + raise FileNotFoundError(f"ASCII art file '{path}' was not found.") + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + self._ascii_cache[path_str] = lines + return list(lines) + def _make_base_style(self) -> BlockStyle: return BlockStyle( align=self._base_style.align, diff --git a/md2txt.py b/md2txt.py index 010170e..e115f91 100644 --- a/md2txt.py +++ b/md2txt.py @@ -5,10 +5,11 @@ Convert Markdown into 80-column DOS-compatible plain text. from __future__ import annotations import argparse +import json import re import sys from pathlib import Path -from typing import Iterable, List, Optional, Tuple +from typing import Iterable, List, Optional, Set, Tuple from md_types import BlockStyle, FrontMatter from markdown_parser import MarkdownParser @@ -16,6 +17,14 @@ from text_renderer import TextRenderer FRONTMATTER_PATTERN = re.compile(r"^---\s*$") +INCLUDE_WIKILINK_PATTERN = re.compile(r"^\s*!\[\[(.+?)\]\]\s*$") +INCLUDE_DIRECTIVE_PATTERN = re.compile(r"^\s*\{\s*\.include\s+(.+?)\s*\}\s*$") +ASCII_BLOCK_PATTERN = re.compile( + r"^\s*#\[(?P