refactor: restructure mytoolkit with new subcommands and md-to-pdf improvements

- 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
This commit is contained in:
Zhengshou Lai
2026-05-04 17:19:32 +08:00
parent 38328c28d0
commit 3e4cf618a8
20 changed files with 1846 additions and 214 deletions
+164 -4
View File
@@ -9,6 +9,151 @@ 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."""
@@ -16,6 +161,7 @@ def md_to_typ(inputs: list[Path]) -> str:
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,
@@ -23,8 +169,11 @@ def md_to_typ(inputs: list[Path]) -> str:
text=True,
)
typ = result.stdout
# Pandoc outputs #horizontalrule which Typst does not define
typ = typ.replace("#horizontalrule", "#line(length: 100%)")
# 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)
@@ -43,7 +192,7 @@ def expand_bookmarks(pdf_path: Path, max_level: int) -> None:
writer = PdfWriter()
writer.clone_document_from_reader(reader)
outline_root = reader.trailer["/Root"].get("/Outlines")
outline_root = writer._root_object.get("/Outlines")
if not outline_root:
writer.write(str(pdf_path))
return
@@ -95,8 +244,19 @@ def build_pdf(
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}": {template}, three-line-table\n\n')
f.write(f'#import "{rel_template}": {import_list}\n\n')
f.write(f"#show: {template}.with()\n\n")
f.write(typ_body)