diff --git a/bin/md_to_pdf.py b/bin/md_to_pdf.py new file mode 100644 index 0000000..9a0c913 --- /dev/null +++ b/bin/md_to_pdf.py @@ -0,0 +1,115 @@ +"""Markdown to PDF conversion core logic.""" + +import re +import uuid +from pathlib import Path + +import click + +from bin import templates_registry +from bin.utils import run_command + + +def md_to_typ(inputs: list[Path]) -> str: + """Convert Markdown files to Typst via pandoc.""" + md_content = "" + for inp in inputs: + with open(inp, "r", encoding="utf-8") as f: + md_content += f.read() + "\n\n" + result = run_command( + ["pandoc", "-f", "markdown-citations", "-t", "typst", "-"], + input=md_content, + capture_output=True, + text=True, + ) + typ = result.stdout + # Pandoc outputs #horizontalrule which Typst does not define + typ = typ.replace("#horizontalrule", "#line(length: 100%)") + # Remove Pandoc auto-generated heading labels to avoid named-destination + # links that break with non-ASCII characters in some PDF readers. + typ = re.sub(r"(^=+ .*?)\n<[^>\n]+>\n", r"\1\n", typ, flags=re.MULTILINE) + return typ + + +def expand_bookmarks(pdf_path: Path, max_level: int) -> None: + """Expand PDF outline nodes up to ``max_level`` without rebuilding.""" + if max_level <= 1: + return + + from pypdf import PdfReader, PdfWriter + from pypdf.generic import NumberObject + + reader = PdfReader(str(pdf_path)) + writer = PdfWriter() + writer.clone_document_from_reader(reader) + + outline_root = reader.trailer["/Root"].get("/Outlines") + if not outline_root: + writer.write(str(pdf_path)) + return + + def update_count(item_ref, depth=0): + item = item_ref.get_object() + count = item.get("/Count") + if count is not None and depth < max_level: + val = int(count) + if val < 0: + item.update({"/Count": NumberObject(-val)}) + first = item.get("/First") + if first: + child = first + while child: + update_count(child, depth=depth + 1) + child = child.get_object().get("/Next") + + first = outline_root.get("/First") + if first: + update_count(first, depth=0) + + writer.write(str(pdf_path)) + + +def build_pdf( + template: str, + inputs: list[Path], + output: Path, + bookmark_depth: int = 1, +) -> None: + """Build PDF from Markdown using Typst. + + ``bookmark_depth`` controls outline expansion: ``1`` keeps only top-level + bookmarks visible (default), ``2`` expands to second level, and so on. + """ + templates_dir = templates_registry.get_md_to_pdf_root() + template_file = templates_dir / template / "template.typ" + if not template_file.exists(): + click.secho(f"Template not found: {template_file}", fg="red", err=True) + raise SystemExit(1) + + typ_body = md_to_typ(inputs) + + # Convert pandoc tables to three-line tables + typ_body = typ_body.replace("#table(", "#three-line-table(") + + tmp_name = f".{uuid.uuid4().hex}.typ" + tmp_path = templates_dir / tmp_name + rel_template = f"{template}/template.typ" + + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(f'#import "{rel_template}": {template}, three-line-table\n\n') + f.write(f"#show: {template}.with()\n\n") + f.write(typ_body) + + output = output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + + try: + run_command( + ["typst", "compile", "--root", "/", tmp_name, str(output)], + cwd=str(templates_dir), + ) + finally: + tmp_path.unlink(missing_ok=True) + + if output.exists() and bookmark_depth > 1: + expand_bookmarks(output, bookmark_depth)