From 4b58e21dfcf61624d58eb630cac95c009a1841f2 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 20 Oct 2025 16:59:52 +0300 Subject: [PATCH] md2amb tool --- README.md | 10 +- ama_renderer.py | 100 +++++++++++------ lorem.amb | Bin 0 -> 14840 bytes md2amb.py | 282 ++++++++++++++++++++++++++++++++++++++++++++++++ output.amb | Bin 0 -> 14840 bytes 5 files changed, 355 insertions(+), 37 deletions(-) create mode 100644 lorem.amb create mode 100644 output.amb diff --git a/README.md b/README.md index 679e552..48e1c3f 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 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: +- `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 `ama` 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,10 +30,16 @@ 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 ama # emit AMB/AMA markup python md2txt.py input.md --renderer-option width=68 # pass KEY=VALUE to a renderer ``` +- `md2amb.py` – package Markdown (and linked Markdown files) into a self-contained `.amb` archive composed of `.ama` articles that honour the 78-column/64 KiB AMA constraints. + +```bash +python md2amb.py --title "Your Manual" docs/index.md output/manual.amb +``` + `--parser` and `--renderer` select a plugin by name (defaults are `markdown` and `text`). Repeatable `--parser-option KEY=VALUE` and `--renderer-option KEY=VALUE` pairs are forwarded to the plugin factories as keyword arguments in addition to the defaults supplied by the CLI. Both scripts accept `--help` for the full option list. ## FIGlet Fonts via Frontmatter diff --git a/ama_renderer.py b/ama_renderer.py index 741eba7..e0e7172 100644 --- a/ama_renderer.py +++ b/ama_renderer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from functools import partial from pathlib import Path from typing import Any, Callable, List @@ -36,22 +37,14 @@ class AmaRenderer(TextRenderer): 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, - ) + 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) @@ -84,17 +77,10 @@ class AmaRenderer(TextRenderer): 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_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", " ") @@ -104,17 +90,15 @@ class AmaRenderer(TextRenderer): 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( + def render_fn(target_style: BlockStyle) -> List[str]: + return self._wrap_and_format( processed, + target_style, initial_indent=initial, subsequent_indent=subsequent, - style=target_style, - hyphenate=self.hyphenate, - ), - style, - stylable=True, - ) + ) + + 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) @@ -179,10 +163,6 @@ class AmaRenderer(TextRenderer): 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)) @@ -190,6 +170,56 @@ class AmaRenderer(TextRenderer): 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)) diff --git a/lorem.amb b/lorem.amb new file mode 100644 index 0000000000000000000000000000000000000000..464377749cef8b1e1ff4673551bcb18267b4ccdb GIT binary patch literal 14840 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 MarkdownParser: + return MarkdownParser(base_style) + + +try: + register_parser("markdown", _markdown_parser_factory) +except ValueError: + pass + + +MARKDOWN_LINK_RE = re.compile(r"(\[[^\]]*\]\()([^)]+)(\))") +LOCAL_LINK_RE = re.compile(r"^[A-Za-z0-9_.~/\\-]+$") +EXT_MD = {".md", ".markdown", ".mkd", ".mkdn"} +AMA_MAX_BYTES = 65_535 +AMB_MAGIC = b"AMB1" +LINK_CONTINUE_LABEL = "Continue" + + +@dataclass +class Article: + source: Path + ama_name: str + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Convert Markdown into an AMB archive.") + parser.add_argument("input", type=Path, help="Root Markdown file to convert.") + parser.add_argument("output", type=Path, help="Output AMB filename.") + parser.add_argument("--title", type=str, help="Optional book title.") + args = parser.parse_args(list(argv) if argv is not None else None) + + input_path = args.input.resolve() + if not input_path.exists(): + parser.error(f"Input file '{input_path}' does not exist.") + + amb_bytes = build_amb( + root_markdown=input_path, + title=args.title, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(amb_bytes) + print(str(args.output)) + return 0 + + +def build_amb(root_markdown: Path, title: str | None) -> bytes: + articles = collect_articles(root_markdown) + ama_contents = render_articles(articles) + files = assemble_files(ama_contents, title) + return pack_amb(files) + + +def collect_articles(root_markdown: Path) -> Dict[Path, Article]: + queue: deque[Path] = deque([root_markdown]) + visited: Dict[Path, Article] = {} + assigned_names: set[str] = set() + + while queue: + current = queue.popleft() + current = current.resolve() + if current in visited: + continue + if not current.exists(): + raise FileNotFoundError(f"Referenced file '{current}' was not found.") + if current == root_markdown: + ama_name = "INDEX.AMA" + else: + ama_name = assign_ama_name(current.stem, assigned_names) + assigned_names.add(ama_name) + visited[current] = Article(source=current, ama_name=ama_name) + + for linked in find_local_markdown_links(current): + queue.append(linked) + + return visited + + +def find_local_markdown_links(markdown_path: Path) -> List[Path]: + text = markdown_path.read_text(encoding="utf-8") + results: List[Path] = [] + + for _, target, _ in MARKDOWN_LINK_RE.findall(text): + cleaned = target.strip() + if not cleaned or cleaned.startswith("#"): + continue + if "://" in cleaned or cleaned.startswith(("mailto:", "ftp:", "gopher:", "tel:")): + continue + resolved = (markdown_path.parent / cleaned.split("#", 1)[0]).resolve() + if resolved.suffix.lower() in EXT_MD: + results.append(resolved) + return results + + +def assign_ama_name(stem: str, existing: set[str]) -> str: + base = "".join((c if c.isalnum() else "_") for c in stem.upper()) + if not base: + base = "ARTICLE" + if base[0].isdigit(): + base = f"_{base}" + base = base[:8] + + name = f"{base}.AMA" + counter = 1 + while name in existing: + suffix = f"{counter:02d}" + trimmed = base[: max(1, 8 - len(suffix))] + name = f"{trimmed}{suffix}.AMA" + counter += 1 + return name + + +def render_articles(articles: Dict[Path, Article]) -> Dict[str, List[str]]: + parser_factory = get_parser_factory("markdown") + renderer_factory = get_renderer_factory("ama") + rendered: Dict[str, List[str]] = {} + + for path, article in articles.items(): + content = path.read_text(encoding="utf-8") + rewritten = rewrite_links(content, path.parent, articles) + frontmatter, body_lines = parse_frontmatter(rewritten.splitlines(keepends=True)) + ama_lines = run_conversion( + body_lines, + frontmatter=frontmatter, + parser_factory=parser_factory, + renderer_factory=renderer_factory, + renderer_options={"width": 78}, + base_path=path.parent, + ) + split_articles = split_article(article.ama_name, ama_lines) + rendered.update(split_articles) + return rendered + + +def rewrite_links(markdown: str, base_dir: Path, articles: Dict[Path, Article]) -> str: + def replacer(match: re.Match[str]) -> str: + prefix, target, suffix = match.groups() + cleaned = target.strip() + candidate = (base_dir / cleaned.split("#", 1)[0]).resolve() + if candidate in articles: + mapped = articles[candidate].ama_name + return f"{prefix}{mapped}{suffix}" + return match.group(0) + + return MARKDOWN_LINK_RE.sub(replacer, markdown) + + +def split_article(filename: str, lines: List[str]) -> Dict[str, List[str]]: + encoded = "\n".join(lines).encode("utf-8") + if len(encoded) <= AMA_MAX_BYTES: + return {filename: lines} + + segments: List[List[str]] = [] + current: List[str] = [] + current_size = 0 + + def flush_segment() -> None: + nonlocal current, current_size + if current: + segments.append(current) + current = [] + current_size = 0 + + for line in lines: + candidate_size = current_size + len((line + "\n").encode("utf-8")) + if candidate_size > AMA_MAX_BYTES and current: + flush_segment() + current.append(line) + current_size += len((line + "\n").encode("utf-8")) + flush_segment() + + result: Dict[str, List[str]] = {} + stem = Path(filename).stem + generated_names = [filename] + + for idx in range(1, len(segments)): + suffix = f"{idx:02d}" + trimmed = stem[: max(1, 8 - len(suffix))] + new_name = f"{trimmed}{suffix}.AMA" + counter = 1 + while new_name in result or new_name in generated_names: + suffix = f"{idx:02d}{counter}" + trimmed = stem[: max(1, 8 - len(suffix))] + new_name = f"{trimmed}{suffix}.AMA" + counter += 1 + generated_names.append(new_name) + + for name, segment in zip(generated_names, segments, strict=False): + result[name] = segment[:] + + for idx, name in enumerate(generated_names[:-1]): + next_name = generated_names[idx + 1] + result[name].append("") + result[name].append(f"%l{next_name}:{LINK_CONTINUE_LABEL}%t") + return result + + +def assemble_files(ama_contents: Dict[str, List[str]], title: str | None) -> List[Tuple[str, bytes]]: + files: List[Tuple[str, bytes]] = [] + if title: + files.append(("TITLE", title.encode("ascii", "ignore")[:64])) + + index_bytes = encode_ama("INDEX.AMA", ama_contents.pop("INDEX.AMA")) + files.append(("INDEX.AMA", index_bytes)) + + for name, lines in sorted(ama_contents.items()): + files.append((name, encode_ama(name, lines))) + + return files + + +def encode_ama(name: str, lines: List[str]) -> bytes: + content = "\n".join(lines).rstrip("\n") + "\n" + data = content.encode("utf-8") + if len(data) > AMA_MAX_BYTES: + raise ValueError(f"Generated AMA article '{name}' exceeds {AMA_MAX_BYTES} bytes.") + if any("\t" in line for line in lines): + raise ValueError(f"Generated AMA article '{name}' contains tab characters.") + return data + + +def pack_amb(files: List[Tuple[str, bytes]]) -> bytes: + entries = [] + offset = 6 + 20 * len(files) + payloads = [] + + for filename, data in files: + canonical = filename.upper() + if len(canonical) > 12: + raise ValueError(f"Filename '{canonical}' does not fit 8.3 constraints.") + payloads.append(data) + checksum = bsd_checksum(data) + entries.append((canonical, offset, len(data), checksum)) + offset += len(data) + + output = bytearray() + output.extend(AMB_MAGIC) + output.extend(struct.pack(" int: + checksum = 0 + for byte in data: + checksum = (checksum >> 1) | ((checksum & 1) << 15) + checksum = (checksum + byte) & 0xFFFF + return checksum + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/output.amb b/output.amb new file mode 100644 index 0000000000000000000000000000000000000000..464377749cef8b1e1ff4673551bcb18267b4ccdb GIT binary patch literal 14840 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