- Add new subcommands: convert, preflight, server, templates, webpage - Migrate config from bin/config.json to ~/.config/mytoolkit - Fix expand_bookmarks to modify writer objects instead of reader - Improve md_to_pdf with CJK bookmark support - Update README and project metadata
276 lines
8.3 KiB
Python
276 lines
8.3 KiB
Python
"""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
|
|
|
|
# Typst callouts exported by templates/md-to-pdf/{textbook,manual}/template.typ.
|
|
# Keep this tuple and _extra_imports (below) in sync when adding or removing `#let` callouts.
|
|
# Pandoc escapes leading `#`, so these names are wrapped in a long-enough `{=typst}` fence
|
|
# (length chosen so inner ``` from Pandoc does not terminate the fence early).
|
|
_TY_CALL_NAMES: tuple[str, ...] = (
|
|
"info-box",
|
|
"tip-box",
|
|
"warn-box",
|
|
"danger-box",
|
|
"theorem",
|
|
"lemma",
|
|
"proof",
|
|
"example",
|
|
"exercise",
|
|
)
|
|
_TY_CALL_ALT = "|".join(
|
|
re.escape(n) for n in sorted(_TY_CALL_NAMES, key=len, reverse=True)
|
|
)
|
|
_TY_CALL_START = re.compile(
|
|
rf"^(\s{{0,3}})(#(?:{_TY_CALL_ALT}))(?![A-Za-z0-9-])",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
def _odd_fenced_code_before(md: str, pos: int) -> bool:
|
|
"""True if ``pos`` lies inside an odd number of ``` fenced regions (heuristic)."""
|
|
in_fence = False
|
|
for line in md[:pos].split("\n"):
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("```"):
|
|
in_fence = not in_fence
|
|
return in_fence
|
|
|
|
|
|
def _skip_quoted(md: str, i: int) -> int:
|
|
quote = md[i]
|
|
i += 1
|
|
n = len(md)
|
|
while i < n:
|
|
ch = md[i]
|
|
if ch == "\\" and i + 1 < n:
|
|
i += 2
|
|
continue
|
|
if ch == quote:
|
|
return i + 1
|
|
i += 1
|
|
return i
|
|
|
|
|
|
def _consume_balanced(md: str, i: int, open_c: str, close_c: str) -> int | None:
|
|
"""If ``md[i]`` is ``open_c``, return index one past matching ``close_c``."""
|
|
if i >= len(md) or md[i] != open_c:
|
|
return None
|
|
depth = 0
|
|
pos = i
|
|
n = len(md)
|
|
while pos < n:
|
|
ch = md[pos]
|
|
if ch in ('"', "'"):
|
|
pos = _skip_quoted(md, pos)
|
|
continue
|
|
if ch == open_c:
|
|
depth += 1
|
|
elif ch == close_c:
|
|
depth -= 1
|
|
if depth == 0:
|
|
return pos + 1
|
|
pos += 1
|
|
return None
|
|
|
|
|
|
def _md_fragment_to_typst(fragment: str) -> str:
|
|
fragment = fragment.strip("\n")
|
|
if not fragment.strip():
|
|
return ""
|
|
result = run_command(
|
|
["pandoc", "-f", "markdown-citations", "-t", "typst", "-"],
|
|
input=fragment,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.stdout.rstrip()
|
|
|
|
|
|
def _longest_backtick_run(s: str) -> int:
|
|
longest = 0
|
|
i = 0
|
|
n = len(s)
|
|
while i < n:
|
|
if s[i] == "`":
|
|
j = i
|
|
while j < n and s[j] == "`":
|
|
j += 1
|
|
longest = max(longest, j - i)
|
|
i = j
|
|
else:
|
|
i += 1
|
|
return longest
|
|
|
|
|
|
def _typst_raw_fence(inner: str) -> str:
|
|
"""Fence long enough to contain Pandoc typst that may include ``` code blocks."""
|
|
run = _longest_backtick_run(inner)
|
|
return "`" * max(3, run + 1)
|
|
|
|
|
|
def preprocess_typst_callouts(md: str) -> str:
|
|
"""Wrap template Typst callouts in Pandoc raw Typst blocks so they compile.
|
|
|
|
Bodies are converted with Pandoc separately so Markdown and math still work.
|
|
Markdown links that use ``[...](...)`` inside a callout can break bracket matching.
|
|
"""
|
|
out: list[str] = []
|
|
last = 0
|
|
for m in _TY_CALL_START.finditer(md):
|
|
if _odd_fenced_code_before(md, m.start()):
|
|
continue
|
|
idx = m.start(2)
|
|
pos = m.end(2)
|
|
while pos < len(md) and md[pos] in " \t":
|
|
pos += 1
|
|
if pos < len(md) and md[pos] == "(":
|
|
end_paren = _consume_balanced(md, pos, "(", ")")
|
|
if end_paren is None:
|
|
continue
|
|
pos = end_paren
|
|
while pos < len(md) and md[pos] in " \t":
|
|
pos += 1
|
|
if pos >= len(md) or md[pos] != "[":
|
|
continue
|
|
bracket_open = pos
|
|
after_body = _consume_balanced(md, bracket_open, "[", "]")
|
|
if after_body is None:
|
|
continue
|
|
body_inner = md[bracket_open + 1 : after_body - 1]
|
|
prefix = md[idx : bracket_open + 1]
|
|
inner_typ = _md_fragment_to_typst(body_inner)
|
|
fence = _typst_raw_fence(prefix + "\n" + inner_typ)
|
|
replacement = f"{fence}{{=typst}}\n{prefix}\n{inner_typ}\n]\n{fence}\n"
|
|
out.append(md[last : m.start()])
|
|
out.append(replacement)
|
|
last = after_body
|
|
out.append(md[last:])
|
|
return "".join(out)
|
|
|
|
|
|
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"
|
|
md_content = preprocess_typst_callouts(md_content)
|
|
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; match template rule-blue
|
|
typ = typ.replace(
|
|
"#horizontalrule",
|
|
'#line(length: 100%, stroke: 0.65pt + rgb("#3467A8").lighten(46%))',
|
|
)
|
|
# 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 = writer._root_object.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"
|
|
|
|
# Pandoc raw Typst callouts (#theorem, #info-box, …) live in the driver file; they must
|
|
# resolve to symbols exported from the template module (not only inside #show: …).
|
|
_extra_imports = {
|
|
"textbook": "theorem, lemma, proof, example, exercise",
|
|
"manual": "info-box, tip-box, warn-box, danger-box",
|
|
}
|
|
extra = _extra_imports.get(template, "")
|
|
import_list = f"{template}, three-line-table"
|
|
if extra:
|
|
import_list += f", {extra}"
|
|
|
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
f.write(f'#import "{rel_template}": {import_list}\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)
|