mambler/mambler.py
randogoth 6350a8a305 init
2025-10-20 20:54:19 +03:00

333 lines
11 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import struct
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
from md2txt import convert_markdown
from md2txt.conversion.core import parse_frontmatter
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"
CONTINUE_OVERHEAD = len("\n".encode("utf-8")) + len((f"%l{'ABCDEFGH.AMA'}:{LINK_CONTINUE_LABEL}%t\n").encode("utf-8"))
@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]]:
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 = convert_markdown(
body_lines,
width=78,
frontmatter=frontmatter,
base_path=path.parent,
renderer_name="ama",
)
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]]:
def encoded_size(candidate: List[str]) -> int:
return len(("\n".join(candidate).rstrip("\n") + "\n").encode("utf-8"))
if encoded_size(lines) <= AMA_MAX_BYTES:
return {filename: lines}
def line_size(value: str) -> int:
return len((value + "\n").encode("utf-8"))
segments: List[List[str]] = []
segment_sizes: List[int] = []
current: List[str] = []
current_size = 0
def flush_segment() -> None:
nonlocal current, current_size
if current:
segments.append(current)
segment_sizes.append(current_size)
current = []
current_size = 0
for line in lines:
size = line_size(line)
if size > AMA_MAX_BYTES:
raise ValueError(f"Generated AMA article '{filename}' contains a line exceeding {AMA_MAX_BYTES} bytes.")
if current_size + size > AMA_MAX_BYTES:
flush_segment()
if current_size + size > AMA_MAX_BYTES:
raise ValueError(f"Generated AMA article '{filename}' contains a line exceeding {AMA_MAX_BYTES} bytes.")
current.append(line)
current_size += size
flush_segment()
if not segments:
return {filename: lines}
soft_limit = AMA_MAX_BYTES - CONTINUE_OVERHEAD
idx = 0
while idx < len(segments) - 1:
if not segments[idx]:
segments.pop(idx)
segment_sizes.pop(idx)
if idx > 0:
idx -= 1
continue
if segment_sizes[idx] <= soft_limit:
idx += 1
continue
moved_line = segments[idx].pop()
moved_size = line_size(moved_line)
segment_sizes[idx] -= moved_size
segments[idx + 1].insert(0, moved_line)
segment_sizes[idx + 1] += moved_size
if not segments[idx]:
segments.pop(idx)
segment_sizes.pop(idx)
if idx > 0:
idx -= 1
continue
cascade = idx + 1
while cascade < len(segments) and segment_sizes[cascade] > AMA_MAX_BYTES:
overflow_line = segments[cascade].pop()
overflow_size = line_size(overflow_line)
if overflow_size > AMA_MAX_BYTES:
raise ValueError(f"Generated AMA article '{filename}' contains a line exceeding {AMA_MAX_BYTES} bytes.")
segment_sizes[cascade] -= overflow_size
if cascade + 1 < len(segments):
segments[cascade + 1].insert(0, overflow_line)
segment_sizes[cascade + 1] += overflow_size
else:
segments.append([overflow_line])
segment_sizes.append(overflow_size)
if not segments[cascade]:
segments.pop(cascade)
segment_sizes.pop(cascade)
break
# Recalculate segment sizes in case of structural changes
segment_sizes = [sum(line_size(line) for line in segment) for segment in segments]
if len(segments) == 1:
return {filename: segments[0][:]}
stem = Path(filename).stem
result: Dict[str, List[str]] = {}
generated_names: List[str] = []
existing_names: set[str] = set()
generated_names.append(filename)
existing_names.add(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 existing_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)
existing_names.add(new_name)
for idx, name in enumerate(generated_names):
segment_lines = segments[idx][:]
if idx < len(generated_names) - 1:
segment_lines.append("")
segment_lines.append(f"%l{generated_names[idx + 1]}:{LINK_CONTINUE_LABEL}%t")
if encoded_size(segment_lines) > AMA_MAX_BYTES:
raise ValueError(f"Unable to split AMA article '{name}' within size constraints.")
result[name] = segment_lines
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("<H", len(entries)))
for name, file_offset, length, checksum in entries:
padded = name.encode("ascii", "ignore")
padded = padded + b"\x00" * (12 - len(padded))
output.extend(padded)
output.extend(struct.pack("<I", file_offset))
output.extend(struct.pack("<H", length))
output.extend(struct.pack("<H", checksum))
for data in payloads:
output.extend(data)
return bytes(output)
def bsd_checksum(data: bytes) -> 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())