2025-10-19 22:58:10 +03:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Convert Markdown into 80-column DOS-compatible plain text.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
2025-10-20 14:14:07 +03:00
|
|
|
import json
|
2025-10-19 22:58:10 +03:00
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
2025-10-20 14:14:07 +03:00
|
|
|
from typing import Iterable, List, Optional, Set, Tuple
|
2025-10-19 22:58:10 +03:00
|
|
|
|
2025-10-20 11:40:17 +03:00
|
|
|
from md_types import BlockStyle, FrontMatter
|
|
|
|
|
from markdown_parser import MarkdownParser
|
|
|
|
|
from text_renderer import TextRenderer
|
2025-10-19 22:58:10 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
FRONTMATTER_PATTERN = re.compile(r"^---\s*$")
|
2025-10-20 14:14:07 +03:00
|
|
|
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:"
|
2025-10-19 22:58:10 +03:00
|
|
|
|
|
|
|
|
|
2025-10-19 23:22:15 +03:00
|
|
|
def _parse_int(value: Optional[str], default: int = 0) -> int:
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
match = re.search(r"-?\d+", value)
|
|
|
|
|
if not match:
|
|
|
|
|
return default
|
|
|
|
|
try:
|
|
|
|
|
return int(match.group())
|
|
|
|
|
except ValueError:
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_bool(value: Optional[str], default: bool = False) -> bool:
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
lowered = value.strip().lower()
|
|
|
|
|
if lowered in {"true", "yes", "1", "on"}:
|
|
|
|
|
return True
|
|
|
|
|
if lowered in {"false", "no", "0", "off"}:
|
|
|
|
|
return False
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
2025-10-20 11:40:17 +03:00
|
|
|
def convert_markdown(
|
|
|
|
|
lines: Iterable[str],
|
|
|
|
|
*,
|
|
|
|
|
width: int,
|
|
|
|
|
frontmatter: FrontMatter,
|
2025-10-20 14:14:07 +03:00
|
|
|
base_path: Optional[Path] = None,
|
2025-10-20 11:40:17 +03:00
|
|
|
) -> List[str]:
|
2025-10-20 14:14:07 +03:00
|
|
|
base_dir = (base_path or Path.cwd()).resolve()
|
|
|
|
|
expanded_lines = _expand_includes(list(lines), base_dir, set())
|
2025-10-20 11:40:17 +03:00
|
|
|
base_style = BlockStyle(
|
|
|
|
|
align="left",
|
|
|
|
|
margin_left=max(0, frontmatter.margin_left),
|
|
|
|
|
margin_right=max(0, frontmatter.margin_right),
|
|
|
|
|
)
|
|
|
|
|
parser = MarkdownParser(base_style)
|
|
|
|
|
renderer = TextRenderer(width=width, frontmatter=frontmatter)
|
2025-10-20 14:14:07 +03:00
|
|
|
for event in parser.parse(expanded_lines):
|
2025-10-20 11:40:17 +03:00
|
|
|
renderer.handle_event(event)
|
|
|
|
|
return renderer.finalize()
|
2025-10-19 22:58:10 +03:00
|
|
|
|
|
|
|
|
|
2025-10-20 14:14:07 +03:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 22:58:10 +03:00
|
|
|
def parse_frontmatter(lines: List[str]) -> Tuple[FrontMatter, List[str]]:
|
|
|
|
|
if not lines or not FRONTMATTER_PATTERN.match(lines[0]):
|
|
|
|
|
return FrontMatter(), lines
|
2025-10-20 11:40:17 +03:00
|
|
|
frontmatter: dict[str, str] = {}
|
2025-10-19 22:58:10 +03:00
|
|
|
idx = 1
|
|
|
|
|
while idx < len(lines):
|
|
|
|
|
if FRONTMATTER_PATTERN.match(lines[idx]):
|
|
|
|
|
break
|
|
|
|
|
if ":" in lines[idx]:
|
|
|
|
|
key, value = lines[idx].split(":", 1)
|
|
|
|
|
frontmatter[key.strip()] = value.strip()
|
|
|
|
|
idx += 1
|
|
|
|
|
if idx >= len(lines):
|
|
|
|
|
return FrontMatter(), lines
|
|
|
|
|
remaining = lines[idx + 1 :] if idx + 1 < len(lines) else []
|
2025-10-19 23:22:15 +03:00
|
|
|
paragraph_spacing_value = frontmatter.get("paragraph_spacing")
|
|
|
|
|
if paragraph_spacing_value is None:
|
|
|
|
|
paragraph_spacing_value = frontmatter.get("lines_between_paragraphs")
|
|
|
|
|
if paragraph_spacing_value is None:
|
|
|
|
|
paragraph_spacing_value = frontmatter.get("paragraph_lines")
|
2025-10-20 12:09:25 +03:00
|
|
|
default_wrap_indent = 2
|
|
|
|
|
wrap_code_blocks = _parse_bool(frontmatter.get("wrap_code_blocks"), False)
|
|
|
|
|
code_block_wrap_indent = default_wrap_indent if wrap_code_blocks else 0
|
|
|
|
|
code_block_wrap_value = frontmatter.get("code_block_wrap")
|
|
|
|
|
if code_block_wrap_value is not None:
|
|
|
|
|
normalized_wrap = code_block_wrap_value.strip()
|
|
|
|
|
if normalized_wrap:
|
|
|
|
|
if re.fullmatch(r"-?\d+", normalized_wrap):
|
|
|
|
|
wrap_code_blocks = True
|
|
|
|
|
code_block_wrap_indent = max(0, _parse_int(normalized_wrap, default_wrap_indent))
|
|
|
|
|
else:
|
|
|
|
|
wrap_flag = _parse_bool(normalized_wrap, wrap_code_blocks)
|
|
|
|
|
wrap_code_blocks = wrap_flag
|
|
|
|
|
code_block_wrap_indent = default_wrap_indent if wrap_flag else 0
|
|
|
|
|
code_block_line_numbers = _parse_bool(frontmatter.get("code_block_line_numbers"), True)
|
|
|
|
|
blockquote_bars = _parse_bool(frontmatter.get("blockquote_bars"), True)
|
2025-10-20 12:16:20 +03:00
|
|
|
list_marker_indent = max(0, _parse_int(frontmatter.get("list_marker_indent"), 0))
|
|
|
|
|
list_text_spacing = max(0, _parse_int(frontmatter.get("list_text_spacing"), 1))
|
2025-10-19 22:58:10 +03:00
|
|
|
fm = FrontMatter(
|
2025-10-19 23:22:15 +03:00
|
|
|
h1_font=frontmatter.get("h1_font", "standard").strip() or "standard",
|
|
|
|
|
h2_font=frontmatter.get("h2_font", "standard").strip() or "standard",
|
|
|
|
|
h3_font=frontmatter.get("h3_font", "standard").strip() or "standard",
|
|
|
|
|
margin_left=_parse_int(frontmatter.get("margin_left"), 0),
|
|
|
|
|
margin_right=_parse_int(frontmatter.get("margin_right"), 0),
|
|
|
|
|
paragraph_spacing=max(0, _parse_int(paragraph_spacing_value, 0)),
|
|
|
|
|
hyphenate=_parse_bool(frontmatter.get("hyphenate"), False),
|
|
|
|
|
hyphen_lang=(frontmatter.get("hyphen_lang") or "en_US").strip() or "en_US",
|
2025-10-20 01:11:29 +03:00
|
|
|
figlet_fallback=_parse_bool(frontmatter.get("figlet_fallback"), False),
|
2025-10-20 01:18:33 +03:00
|
|
|
header_spacing=max(0, _parse_int(frontmatter.get("header_spacing"), 2)),
|
2025-10-20 12:09:25 +03:00
|
|
|
wrap_code_blocks=wrap_code_blocks,
|
|
|
|
|
code_block_wrap_indent=code_block_wrap_indent,
|
|
|
|
|
code_block_line_numbers=code_block_line_numbers,
|
|
|
|
|
blockquote_bars=blockquote_bars,
|
2025-10-20 12:16:20 +03:00
|
|
|
list_marker_indent=list_marker_indent,
|
|
|
|
|
list_text_spacing=list_text_spacing,
|
2025-10-19 22:58:10 +03:00
|
|
|
)
|
|
|
|
|
return fm, remaining
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_lines(path: Path) -> List[str]:
|
|
|
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
|
|
|
return handle.readlines()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_output(path: Optional[Path], lines: List[str]) -> None:
|
|
|
|
|
content = "\r\n".join(lines)
|
|
|
|
|
if path is None:
|
|
|
|
|
sys.stdout.write(content + "\r\n")
|
|
|
|
|
return
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
path.write_text(content + "\r\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
|
|
|
parser = argparse.ArgumentParser(description="Convert Markdown files to 80-column DOS-compatible text.")
|
|
|
|
|
parser.add_argument("input_path", type=Path, help="Path to the Markdown input file.")
|
|
|
|
|
parser.add_argument("-o", "--output", type=Path, help="Optional path to write the resulting text file.")
|
|
|
|
|
parser.add_argument("--width", type=int, default=80, help="Maximum column width (default: 80).")
|
|
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
|
|
|
parser = build_parser()
|
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
lines = read_lines(args.input_path)
|
|
|
|
|
frontmatter, content = parse_frontmatter(lines)
|
2025-10-20 14:14:07 +03:00
|
|
|
converted_lines = convert_markdown(
|
|
|
|
|
content,
|
|
|
|
|
width=args.width,
|
|
|
|
|
frontmatter=frontmatter,
|
|
|
|
|
base_path=args.input_path.parent,
|
|
|
|
|
)
|
2025-10-19 22:58:10 +03:00
|
|
|
write_output(args.output, converted_lines)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|