fix(md_to_pdf): fix TOC links and bookmarks for CJK content

Three fixes:

1. Use pandoc -f markdown-citations to avoid @label citation parsing
   errors in Typst output.

2. Remove Pandoc auto-generated heading labels (<...>) from typst body.
   Without this, Typst creates named-destination links for outline
   entries. Named destinations with non-ASCII characters break in
   macOS Preview and other PDF readers.

3. Fix expand_bookmarks() to clone the full document (preserving all
   /Dest attributes) instead of rebuilding outline from scratch, which
   destroyed the bookmark hierarchy.

4. Add --root / to typst compile for consistent absolute path resolution.
This commit is contained in:
Zhengshou Lai
2026-05-04 13:07:29 +08:00
parent 7547053247
commit 38328c28d0
+115
View File
@@ -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)