ascii art blocks

This commit is contained in:
randogoth 2025-10-20 14:14:07 +03:00
parent ad70e8dcd8
commit b85530483a
5 changed files with 417 additions and 17 deletions

View file

@ -41,20 +41,11 @@ populifer adeunt quicquam cum **ales** Ixion angulus placet reddere.
## Tractaque serpens arva quoque ## Tractaque serpens arva quoque
{:.center} {:.center}
### Unordered # Include
+ Create a list by starting a line with `+`, `-`, or `*` #[d1 :left](dragon.txt) #[d1 :center :mirror](dragon.txt) #[d1 :right](dragon.txt)
- 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
### Ordered ![[markdown.md]]
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. Integer molestie lorem at massa
Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit Inprudens dum memor alma, casses dedi Sinuessa iam quid adicit
`gate_truncate_wave`. [Geratur de](#salientia-iungit-contra-et), insolida `gate_truncate_wave`. [Geratur de](#salientia-iungit-contra-et), insolida

View file

@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json
import re 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 ( from md_types import (
AsciiArtPayload,
AsciiArtPiece,
BlockEvent, BlockEvent,
BlockKind, BlockKind,
BlockQuotePayload, BlockQuotePayload,
@ -27,6 +31,7 @@ PARA_OPEN_RE = re.compile(r"^\s*<p\b([^>]*)>\s*$", re.IGNORECASE)
PARA_CLOSE_RE = re.compile(r"^\s*</p>\s*$", re.IGNORECASE) PARA_CLOSE_RE = re.compile(r"^\s*</p>\s*$", re.IGNORECASE)
MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$") MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$")
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$") MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
ASCII_SENTINEL_PREFIX = "\u0000ASCII:"
class MarkdownParser: class MarkdownParser:
@ -36,6 +41,7 @@ class MarkdownParser:
self._paragraph_style_spec: Optional[StyleSpec] = None self._paragraph_style_spec: Optional[StyleSpec] = None
self._pending_block_style_spec: Optional[StyleSpec] = None self._pending_block_style_spec: Optional[StyleSpec] = None
self._last_stylable_block: bool = False self._last_stylable_block: bool = False
self._ascii_cache: Dict[str, List[str]] = {}
def parse(self, lines: Iterable[str]) -> Iterator[Union[BlockEvent, StyleUpdateEvent]]: def parse(self, lines: Iterable[str]) -> Iterator[Union[BlockEvent, StyleUpdateEvent]]:
self._reset_state() self._reset_state()
@ -48,6 +54,23 @@ class MarkdownParser:
for raw_line in iterator: for raw_line in iterator:
line = raw_line.rstrip("\n") 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 in_code_block:
if line.strip().startswith("```"): if line.strip().startswith("```"):
event = self._flush_code_block(code_lines) event = self._flush_code_block(code_lines)
@ -297,6 +320,69 @@ class MarkdownParser:
stylable=False, 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: def _make_base_style(self) -> BlockStyle:
return BlockStyle( return BlockStyle(
align=self._base_style.align, align=self._base_style.align,

155
md2txt.py
View file

@ -5,10 +5,11 @@ Convert Markdown into 80-column DOS-compatible plain text.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import re import re
import sys import sys
from pathlib import Path 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 md_types import BlockStyle, FrontMatter
from markdown_parser import MarkdownParser from markdown_parser import MarkdownParser
@ -16,6 +17,14 @@ from text_renderer import TextRenderer
FRONTMATTER_PATTERN = re.compile(r"^---\s*$") 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<label>[^\]]+)\]\((?P<target>[^)]+)\)\s*(?P<attr>\{\s*:[^}]+\s*\})?\s*$"
)
ASCII_INLINE_PATTERN = re.compile(r"#\[(?P<label>[^\]]+)\]\((?P<target>[^)]+)\)")
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
ASCII_SENTINEL_PREFIX = "\u0000ASCII:"
def _parse_int(value: Optional[str], default: int = 0) -> int: def _parse_int(value: Optional[str], default: int = 0) -> int:
@ -46,7 +55,10 @@ def convert_markdown(
*, *,
width: int, width: int,
frontmatter: FrontMatter, frontmatter: FrontMatter,
base_path: Optional[Path] = None,
) -> List[str]: ) -> List[str]:
base_dir = (base_path or Path.cwd()).resolve()
expanded_lines = _expand_includes(list(lines), base_dir, set())
base_style = BlockStyle( base_style = BlockStyle(
align="left", align="left",
margin_left=max(0, frontmatter.margin_left), margin_left=max(0, frontmatter.margin_left),
@ -54,11 +66,143 @@ def convert_markdown(
) )
parser = MarkdownParser(base_style) parser = MarkdownParser(base_style)
renderer = TextRenderer(width=width, frontmatter=frontmatter) renderer = TextRenderer(width=width, frontmatter=frontmatter)
for event in parser.parse(lines): for event in parser.parse(expanded_lines):
renderer.handle_event(event) renderer.handle_event(event)
return renderer.finalize() return renderer.finalize()
def _expand_includes(lines: List[str], base_dir: Path, include_stack: Set[Path]) -> List[str]:
expanded: List[str] = []
for line in lines:
ascii_segments = _extract_ascii_segments(line, base_dir)
if ascii_segments is not None:
for sentinel_line, attr_line in ascii_segments:
expanded.append(sentinel_line)
if attr_line is not None:
expanded.append(attr_line)
continue
target = _extract_include_target(line)
if target is None:
expanded.append(line)
continue
target_path = (base_dir / target).resolve()
if target_path in include_stack:
raise RuntimeError(f"Circular include detected for '{target_path}'.")
if not target_path.exists():
raise FileNotFoundError(f"Included file '{target_path}' was not found.")
include_stack.add(target_path)
included_lines = read_lines(target_path)
_, include_body = parse_frontmatter(included_lines)
included_content = _expand_includes(include_body, target_path.parent, include_stack)
expanded.extend(included_content)
include_stack.remove(target_path)
return expanded
def _extract_include_target(line: str) -> Optional[str]:
stripped = line.rstrip("\n")
match = INCLUDE_WIKILINK_PATTERN.match(stripped)
if match:
return _normalize_include_target(match.group(1))
match = INCLUDE_DIRECTIVE_PATTERN.match(stripped)
if match:
return _normalize_include_target(match.group(1))
return None
def _normalize_include_target(value: str) -> str:
trimmed = value.strip()
if len(trimmed) >= 2 and ((trimmed[0] == trimmed[-1]) and trimmed[0] in {"'", '"'}):
trimmed = trimmed[1:-1].strip()
return trimmed
def _extract_ascii_segments(line: str, base_dir: Path) -> Optional[List[Tuple[str, Optional[str]]]]:
stripped_line = line.rstrip("\n")
block_match = ASCII_BLOCK_PATTERN.match(stripped_line)
if block_match:
label = block_match.group("label")
target = block_match.group("target")
attr_text = block_match.group("attr")
sentinel = _make_ascii_sentinel(label, target, base_dir)
attr_line = f"{attr_text}\n" if attr_text else None
return [(sentinel, attr_line)]
matches = list(ASCII_INLINE_PATTERN.finditer(stripped_line))
if not matches:
return None
pieces: List[dict] = []
last_end = 0
for match in matches:
prefix = stripped_line[last_end : match.start()]
if prefix.strip():
return None
label = match.group("label")
target = match.group("target")
block_type, block_name, align = _parse_ascii_label(label)
normalized_target = _normalize_include_target(target)
target_path = (base_dir / normalized_target).resolve()
if not target_path.exists():
raise FileNotFoundError(f"ASCII art file '{target_path}' was not found.")
pieces.append(
{
"type": block_type,
"name": block_name,
"path": str(target_path),
"align": align,
}
)
last_end = match.end()
suffix = stripped_line[last_end:]
if suffix.strip():
return None
if not pieces:
return None
sentinel = f"{ASCII_SENTINEL_PREFIX}{json.dumps({'pieces': pieces})}\n"
return [(sentinel, None)]
def _make_ascii_sentinel(label: str, target: str, base_dir: Path) -> str:
block_type, block_name, align = _parse_ascii_label(label)
normalized_target = _normalize_include_target(target)
target_path = (base_dir / normalized_target).resolve()
if not target_path.exists():
raise FileNotFoundError(f"ASCII art file '{target_path}' was not found.")
payload = {
"pieces": [
{
"type": block_type,
"name": block_name,
"path": str(target_path),
"align": align,
}
]
}
return f"{ASCII_SENTINEL_PREFIX}{json.dumps(payload)}\n"
def _parse_ascii_label(label: str) -> Tuple[str, str, Optional[str]]:
tokens = label.strip().split()
non_colon: List[str] = []
align: Optional[str] = None
for token in tokens:
if token.startswith(":"):
tag = token[1:].strip().lower()
if tag in {"left", "right", "center", "centre"}:
align = "center" if tag in {"center", "centre"} else tag
# ignore other colon tags for now
else:
non_colon.append(token)
block_type = non_colon[0] if non_colon else "custom"
block_name = " ".join(non_colon[1:]) if len(non_colon) > 1 else ""
return block_type, block_name, align
def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]: def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
if not lines or not FRONTMATTER_PATTERN.match(lines[0]): if not lines or not FRONTMATTER_PATTERN.match(lines[0]):
return FrontMatter(), lines return FrontMatter(), lines
@ -145,7 +289,12 @@ def main(argv: Optional[List[str]] = None) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
lines = read_lines(args.input_path) lines = read_lines(args.input_path)
frontmatter, content = parse_frontmatter(lines) frontmatter, content = parse_frontmatter(lines)
converted_lines = convert_markdown(content, width=args.width, frontmatter=frontmatter) converted_lines = convert_markdown(
content,
width=args.width,
frontmatter=frontmatter,
base_path=args.input_path.parent,
)
write_output(args.output, converted_lines) write_output(args.output, converted_lines)
return 0 return 0

View file

@ -13,6 +13,7 @@ class BlockKind(Enum):
LIST_ITEM = "list_item" LIST_ITEM = "list_item"
HORIZONTAL_RULE = "horizontal_rule" HORIZONTAL_RULE = "horizontal_rule"
BLANK_LINE = "blank_line" BLANK_LINE = "blank_line"
CUSTOM_BLOCK = "custom_block"
@dataclass @dataclass
@ -80,6 +81,20 @@ class ListItemPayload:
ordered: bool ordered: bool
@dataclass
class AsciiArtPiece:
block_type: str
name: str
path: str
lines: List[str]
align: Optional[str] = None
@dataclass
class AsciiArtPayload:
pieces: List[AsciiArtPiece]
@dataclass @dataclass
class BlockEvent: class BlockEvent:
kind: BlockKind kind: BlockKind

View file

@ -4,9 +4,11 @@ import re
import string import string
import textwrap import textwrap
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Dict, List, Optional from typing import Callable, Dict, List, Optional, Tuple
from md_types import ( from md_types import (
AsciiArtPayload,
AsciiArtPiece,
BlockEvent, BlockEvent,
BlockKind, BlockKind,
BlockQuotePayload, BlockQuotePayload,
@ -136,6 +138,8 @@ class TextRenderer:
self._render_horizontal_rule(event.style) self._render_horizontal_rule(event.style)
elif event.kind is BlockKind.BLANK_LINE: elif event.kind is BlockKind.BLANK_LINE:
self._render_blank_line() self._render_blank_line()
elif event.kind is BlockKind.CUSTOM_BLOCK:
self._render_custom_block(event.payload, event.style)
else: # pragma: no cover - defensive default else: # pragma: no cover - defensive default
self._last_stylable_block = None self._last_stylable_block = None
@ -201,6 +205,161 @@ class TextRenderer:
if self.paragraph_spacing == 0: if self.paragraph_spacing == 0:
self.output.append("") self.output.append("")
def _render_custom_block(self, payload: AsciiArtPayload, style: BlockStyle) -> None:
def render(target_style: BlockStyle) -> List[str]:
return self._layout_ascii_pieces(payload.pieces, target_style)
lines = render(style)
self._emit_block(lines, stylable=True, render_fn=render, style=style)
def _layout_ascii_pieces(self, pieces: List[AsciiArtPiece], style: BlockStyle) -> List[str]:
if not pieces:
return []
if len(pieces) == 1:
piece = pieces[0]
return self._align_preformatted_lines(piece.lines, style, piece.align)
margin_left = min(max(style.margin_left, 0), self.width - 1)
margin_right = max(style.margin_right, 0)
available_width = max(1, self.width - margin_left - margin_right)
heights = [len(piece.lines) for piece in pieces]
max_height = max(heights) if heights else 0
widths = [self._ascii_piece_width(piece.lines) for piece in pieces]
positions = self._compute_ascii_positions(pieces, widths, available_width)
if positions is None:
# fall back to vertical stacking by aligning individually
result: List[str] = []
for piece in pieces:
result.extend(self._align_preformatted_lines(piece.lines, style, piece.align))
return result
canvas = [list(" " * available_width) for _ in range(max_height)]
for piece, pos, width in positions:
lines = piece.lines
for row_index in range(max_height):
if row_index >= len(lines):
continue
line = lines[row_index]
for col_index, char in enumerate(line):
target_index = pos + col_index
if target_index >= available_width:
break
if char == " ":
continue
canvas[row_index][target_index] = char
prefix = " " * margin_left
result_lines: List[str] = []
for row in canvas:
content = "".join(row).rstrip()
result_lines.append(prefix + content)
return result_lines
def _compute_ascii_positions(
self,
pieces: List[AsciiArtPiece],
widths: List[int],
available_width: int,
) -> Optional[List[Tuple[AsciiArtPiece, int, int]]]:
if available_width <= 0:
return None
gap = 4
left_cursor = 0
right_cursor = available_width
placement_map: Dict[int, Tuple[int, int]] = {}
# Categorise pieces by alignment preference
left_indices: List[int] = []
center_indices: List[int] = []
right_indices: List[int] = []
for index, piece in enumerate(pieces):
align = (piece.align or "left").lower()
if align == "right":
right_indices.append(index)
elif align == "center":
center_indices.append(index)
else:
left_indices.append(index)
for index in left_indices:
width = min(widths[index], available_width)
pos = left_cursor
left_cursor = min(available_width, pos + width + gap)
placement_map[index] = (pos, width)
for index in reversed(right_indices):
width = min(widths[index], available_width)
pos = max(0, right_cursor - width)
right_cursor = max(0, pos - gap)
placement_map[index] = (pos, width)
center_used = False
for index in center_indices:
width = min(widths[index], available_width)
pos = max(0, (available_width - width) // 2)
if center_used:
# shift to current left cursor if already used center
pos = left_cursor
left_cursor = min(available_width, pos + width + gap)
else:
center_used = True
placement_map[index] = (pos, width)
# Detect overlap; if found, abort to fallback
for i in range(len(pieces)):
if i not in placement_map:
continue
pos_i, width_i = placement_map[i]
end_i = pos_i + width_i
for j in range(i + 1, len(pieces)):
if j not in placement_map:
continue
pos_j, width_j = placement_map[j]
if pos_i <= pos_j < end_i or pos_j <= pos_i < pos_j + width_j:
return None
ordered_positions: List[Tuple[AsciiArtPiece, int, int]] = []
for index, piece in enumerate(pieces):
if index not in placement_map:
continue
pos, width = placement_map[index]
ordered_positions.append((piece, pos, width))
return ordered_positions
def _ascii_piece_width(self, lines: List[str]) -> int:
return max((len(line.rstrip("\n")) for line in lines), default=0)
def _align_preformatted_lines(
self,
lines: List[str],
style: BlockStyle,
explicit_align: Optional[str] = None,
) -> List[str]:
if not lines:
return []
margin_left = min(max(style.margin_left, 0), self.width - 1)
margin_right = max(style.margin_right, 0)
available_width = max(1, self.width - margin_left - margin_right)
processed = [line.rstrip("\n") for line in lines]
block_width = max((len(line) for line in processed), default=0)
extra_space = max(0, available_width - block_width)
align = (explicit_align or style.align or "left").lower()
if align == "center" or align == "centre":
align_offset = extra_space // 2
elif align == "right":
align_offset = extra_space
else:
align_offset = 0
max_indent = max(0, self.width - block_width)
indent = min(margin_left + align_offset, max_indent)
indent_str = " " * indent
return [indent_str + line for line in processed]
def _emit_block( def _emit_block(
self, self,
lines: List[str], lines: List[str],