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:
@@ -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)
|
||||
Reference in New Issue
Block a user