系列: Hardware
python
774 行
· 更新於 2026-06-14
convert_md_to_pdf.py
Hardware/convert_md_to_pdf.py
"""Convert all Markdown files under 電子硬體完整教材/ to PDF.
支援:
- 中文字型(PingFang TC / Heiti TC / Noto Sans CJK 等)
- LaTeX 數學公式($...$ 與 $$...$$,透過 latex2mathml 轉成 MathML)
- 表格、程式碼區塊(Pygments 語法上色)、清單、引言、連結
- 內部 .md 連結自動改為對應 .pdf
- 自動分頁、頁首頁尾與頁碼
"""
from __future__ import annotations
import argparse
import base64
import html
import io
import logging
import re
import sys
import time
import warnings
from pathlib import Path
import markdown
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import mathtext
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
# WeasyPrint is chatty about font fallbacks and missing glyphs; matplotlib
# yells about CJK glyphs missing from its math fonts even when we route them
# through the HTML fallback. Both messages are noise during a batch run.
logging.getLogger("weasyprint").setLevel(logging.ERROR)
logging.getLogger("fontTools").setLevel(logging.ERROR)
warnings.filterwarnings(
"ignore",
message=r"Font 'rm' does not have a glyph for.*",
)
matplotlib.set_loglevel("error")
ROOT = Path(__file__).resolve().parent
DEFAULT_TARGET_DIR = ROOT / "電子硬體完整教材"
# ---------------------------------------------------------------------------
# Math pre-processing
# ---------------------------------------------------------------------------
# Markers chosen so that the markdown processor leaves them intact and they
# do not collide with normal text or code.
_BLOCK_MATH_OPEN = "@@MATHBLOCK_OPEN_{idx}@@"
_BLOCK_MATH_CLOSE = "@@MATHBLOCK_CLOSE_{idx}@@"
_INLINE_MATH_OPEN = "@@MATHINLINE_OPEN_{idx}@@"
_INLINE_MATH_CLOSE = "@@MATHINLINE_CLOSE_{idx}@@"
_FENCE_RE = re.compile(r"(^|\n)(```|~~~)[\s\S]*?\n\2(?=\n|$)", re.MULTILINE)
_INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
_BLOCK_MATH_RE = re.compile(r"\$\$\s*([\s\S]+?)\s*\$\$", re.MULTILINE)
# Inline math: $...$ where neither boundary is escaped or attached to a digit
# (avoid catching $5 prices). Allow letters/operators inside.
_INLINE_MATH_RE = re.compile(r"(?<![\\\w])\$([^\$\n]+?)\$(?![\w])")
_MATH_CACHE: dict[tuple[str, bool], str] = {}
# Lightweight LaTeX → matplotlib mathtext rewrites. Matplotlib mathtext does
# not understand a number of common LaTeX macros, so we normalise them.
_LATEX_FIXUPS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"\\quad\b"), r"\\;\\;\\;"),
(re.compile(r"\\qquad\b"), r"\\;\\;\\;\\;\\;\\;"),
(re.compile(r"\\!"), ""),
(re.compile(r"\\ "), r"\\;"),
(re.compile(r"\\,"), r"\\;"),
(re.compile(r"\\:"), r"\\;"),
(re.compile(r"\\textrm\{([^{}]*)\}"), r"\\mathrm{\1}"),
(re.compile(r"\\text\{([^{}]*)\}"), r"\\mathrm{\1}"),
(re.compile(r"\\dfrac"), r"\\frac"),
(re.compile(r"\\tfrac"), r"\\frac"),
(re.compile(r"\\left\\?\|"), r"|"),
(re.compile(r"\\right\\?\|"), r"|"),
(re.compile(r"\\left\."), r""),
(re.compile(r"\\right\."), r""),
(re.compile(r"\\left\\\\?\{"), r"\\{"),
(re.compile(r"\\right\\\\?\}"), r"\\}"),
(re.compile(r"\\left"), r""),
(re.compile(r"\\right"), r""),
(re.compile(r"\\operatorname\{([^{}]*)\}"), r"\\mathrm{\1}"),
]
def _normalise_latex(latex: str) -> str:
s = latex.strip()
for pat, repl in _LATEX_FIXUPS:
s = pat.sub(repl, s)
return s
def _render_math_svg(latex: str, display: bool) -> tuple[str, float, float, float] | None:
"""Render LaTeX with matplotlib mathtext.
Returns (svg_text, width_pt, height_pt, depth_pt) or None on failure.
Sizes are in PostScript points (matplotlib uses 72 dpi when saving SVG).
"""
expr = _normalise_latex(latex)
# Render at the same size as body text (~11pt) for inline formulas and
# slightly larger for display equations. Matplotlib mathtext outputs SVG
# at 72 dpi, so the resulting intrinsic size matches PDF points.
fontsize = 13 if display else 11
# Matplotlib mathtext requires the expression wrapped in $...$.
text = f"${expr}$"
try:
# Probe size first so the figure fits the formula tightly.
parser = mathtext.MathTextParser("path")
width, height, depth, _, _ = parser.parse(text, dpi=72, prop=None)
except Exception:
return None
# Convert pixels-at-72dpi → inches for the figure.
fig_w_in = max(0.05, width / 72.0)
fig_h_in = max(0.05, height / 72.0)
fig = plt.figure(figsize=(fig_w_in, fig_h_in), dpi=72)
try:
fig.patch.set_alpha(0.0)
# Place text so the bounding box matches the parsed metrics.
fig.text(0, depth / height if height else 0, text,
fontsize=fontsize, color="black")
buf = io.BytesIO()
fig.savefig(buf, format="svg", transparent=True,
bbox_inches="tight", pad_inches=0.01)
except Exception:
plt.close(fig)
return None
plt.close(fig)
svg_bytes = buf.getvalue()
return svg_bytes.decode("utf-8"), width, height, depth
_CJK_RE = re.compile(r"[\u3000-\u30ff\u3400-\u9fff\uff00-\uffef]")
_TEXT_MACRO_RE = re.compile(
r"\\(?:text|textrm|mathrm|mbox)\{([^{}]*)\}"
)
def _render_math_to_img(latex: str, display: bool) -> str | None:
"""Render a piece of LaTeX to a single <img>; None on failure."""
rendered = _render_math_svg(latex, display)
if rendered is None:
return None
svg_text, *_ = rendered
encoded = base64.b64encode(svg_text.encode("utf-8")).decode("ascii")
src = f"data:image/svg+xml;base64,{encoded}"
cls = "math-svg math-svg-block" if display else "math-svg math-svg-inline"
return f'<img class="{cls}" src="{src}" alt="{html.escape(latex)}"/>'
def _is_balanced_braces(s: str) -> bool:
depth = 0
for ch in s:
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth < 0:
return False
return depth == 0
def _latex_to_pretty_html(latex: str) -> str:
"""Best-effort translation of a small LaTeX subset to HTML.
Handles ``\\text{...}``, ``\\frac{a}{b}``, ``\\sqrt{a}``, simple
``_{...}`` / ``^{...}`` subscripts and superscripts, common Greek
macros, ``\\cdot``, ``\\times``, ``\\approx``, ``\\propto``,
``\\Rightarrow`` and ``\\quad``. Anything we cannot translate is
emitted as italic text — readable, even if not typographically
perfect.
"""
GREEK = {
"alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ",
"epsilon": "ε", "zeta": "ζ", "eta": "η", "theta": "θ",
"iota": "ι", "kappa": "κ", "lambda": "λ", "mu": "μ",
"nu": "ν", "xi": "ξ", "pi": "π", "rho": "ρ",
"sigma": "σ", "tau": "τ", "upsilon": "υ", "phi": "φ",
"chi": "χ", "psi": "ψ", "omega": "ω",
"Gamma": "Γ", "Delta": "Δ", "Theta": "Θ", "Lambda": "Λ",
"Xi": "Ξ", "Pi": "Π", "Sigma": "Σ", "Phi": "Φ",
"Psi": "Ψ", "Omega": "Ω",
}
SYMBOLS = {
"cdot": "·", "times": "×", "div": "÷", "pm": "±",
"approx": "≈", "neq": "≠", "leq": "≤", "geq": "≥",
"propto": "∝", "Rightarrow": "⇒", "rightarrow": "→",
"leftarrow": "←", "infty": "∞", "ldots": "…", "cdots": "⋯",
"angle": "∠", "circ": "°", "ll": "≪", "gg": "≫",
}
def _consume_braced(text: str, i: int) -> tuple[str, int]:
# text[i] should be '{'. Returns (inner, index after '}').
assert text[i] == "{"
depth = 1
j = i + 1
while j < len(text) and depth:
if text[j] == "{":
depth += 1
elif text[j] == "}":
depth -= 1
j += 1
return text[i + 1:j - 1], j
def _convert(text: str) -> str:
out: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch == "\\":
m = re.match(r"\\([A-Za-z]+)", text[i:])
if m:
name = m.group(1)
i += len(m.group(0))
if name in {"text", "textrm", "mathrm", "mbox"}:
if i < n and text[i] == "{":
inner, i = _consume_braced(text, i)
out.append(
f'<span class="math-cjk">'
f'{html.escape(inner)}</span>'
)
continue
if name == "frac":
# Expect two braced groups
if i < n and text[i] == "{":
num, i = _consume_braced(text, i)
else:
num = ""
if i < n and text[i] == "{":
den, i = _consume_braced(text, i)
else:
den = ""
out.append(
'<span class="frac">'
f'<span class="num">{_convert(num)}</span>'
f'<span class="den">{_convert(den)}</span>'
'</span>'
)
continue
if name == "sqrt":
if i < n and text[i] == "{":
inner, i = _consume_braced(text, i)
else:
inner = ""
out.append(f'√<span class="sqrt-arg">({_convert(inner)})</span>')
continue
if name in GREEK:
out.append(GREEK[name])
continue
if name in SYMBOLS:
out.append(SYMBOLS[name])
continue
if name in {"quad", "qquad"}:
out.append(" ")
continue
# Unknown macro: keep the name as italic text.
out.append(f'<i>{html.escape(name)}</i>')
continue
# Lone backslash
i += 1
continue
if ch == "_" and i + 1 < n:
i += 1
if text[i] == "{":
inner, i = _consume_braced(text, i)
else:
inner, i = text[i], i + 1
out.append(f'<sub>{_convert(inner)}</sub>')
continue
if ch == "^" and i + 1 < n:
i += 1
if text[i] == "{":
inner, i = _consume_braced(text, i)
else:
inner, i = text[i], i + 1
out.append(f'<sup>{_convert(inner)}</sup>')
continue
if ch in "{}":
i += 1
continue
out.append(html.escape(ch))
i += 1
return "".join(out)
return _convert(latex)
def _split_latex_around_cjk(latex: str) -> list[tuple[str, str]]:
"""Split LaTeX into (kind, text) chunks where kind is 'math' or 'cjk'.
Only `\\text{...}` / `\\textrm{...}` / `\\mathrm{...}` / `\\mbox{...}`
blocks that contain CJK are pulled out as plain text; everything else
(including ASCII-only macro contents) stays in the math chunk.
"""
chunks: list[tuple[str, str]] = []
pos = 0
for m in _TEXT_MACRO_RE.finditer(latex):
inner = m.group(1)
if not _CJK_RE.search(inner):
continue
before = latex[pos:m.start()]
if before.strip():
chunks.append(("math", before))
chunks.append(("cjk", inner))
pos = m.end()
tail = latex[pos:]
if tail.strip():
chunks.append(("math", tail))
return chunks
def _safe_latex_to_mathml(latex: str, display: bool) -> str:
"""Render a LaTeX snippet to one or more inline elements.
If the expression contains Chinese characters inside ``\\text{...}``
blocks, those are split out as plain HTML text and rendered between
SVG-rendered math fragments so we don't get ``[?]`` boxes.
"""
cache_key = (latex, display)
cached = _MATH_CACHE.get(cache_key)
if cached is not None:
return cached
if _CJK_RE.search(latex):
chunks = _split_latex_around_cjk(latex)
# If splitting produced math fragments with unbalanced braces (e.g.
# `\frac{` next to a Chinese-only block), matplotlib will choke on
# them. Fall back to a pretty pure-HTML rendering of the whole
# expression — slightly less elegant typography, but readable and
# preserves the Chinese annotations.
if any(
kind == "math" and not _is_balanced_braces(content)
for kind, content in chunks
):
html_expr = _latex_to_pretty_html(latex)
cls = "math-block math-html" if display else "math-inline math-html"
out = f'<{"div" if display else "span"} class="{cls}">{html_expr}</{"div" if display else "span"}>'
_MATH_CACHE[cache_key] = out
return out
else:
chunks = [("math", latex)]
pieces: list[str] = []
for kind, content in chunks:
if kind == "math":
cleaned = content.strip()
if not cleaned:
continue
img = _render_math_to_img(cleaned, display)
if img is None:
pieces.append(
f'<span class="math-fallback">{html.escape(cleaned)}</span>'
)
else:
pieces.append(img)
else:
pieces.append(
f'<span class="math-cjk">{html.escape(content)}</span>'
)
if not pieces:
cls = "math-block" if display else "math-inline"
out = f'<span class="{cls} math-fallback">{html.escape(latex)}</span>'
elif display:
out = '<div class="math-block">' + " ".join(pieces) + "</div>"
else:
out = '<span class="math-inline">' + "".join(pieces) + "</span>"
_MATH_CACHE[cache_key] = out
return out
def extract_math(text: str) -> tuple[str, list[str]]:
"""Replace math expressions with placeholders and return rendered MathML list.
The pre-processing is run *before* markdown but it must not touch math
that lives inside fenced or inline code regions, otherwise example LaTeX
appearing in code listings would be silently rewritten.
"""
rendered: list[str] = []
# 1) Carve out fenced and inline code so we don't disturb their contents.
code_segments: list[str] = []
def _stash_code(m: re.Match[str]) -> str:
code_segments.append(m.group(0))
return f"@@CODESEG_{len(code_segments) - 1}@@"
text = _FENCE_RE.sub(_stash_code, text)
text = _INLINE_CODE_RE.sub(_stash_code, text)
# 2) Block math first ($$...$$) so the inline regex never sees it.
def _block_sub(m: re.Match[str]) -> str:
rendered.append(_safe_latex_to_mathml(m.group(1), display=True))
idx = len(rendered) - 1
return _BLOCK_MATH_OPEN.format(idx=idx) + _BLOCK_MATH_CLOSE.format(idx=idx)
text = _BLOCK_MATH_RE.sub(_block_sub, text)
# 3) Inline math.
def _inline_sub(m: re.Match[str]) -> str:
rendered.append(_safe_latex_to_mathml(m.group(1), display=False))
idx = len(rendered) - 1
return _INLINE_MATH_OPEN.format(idx=idx) + _INLINE_MATH_CLOSE.format(idx=idx)
text = _INLINE_MATH_RE.sub(_inline_sub, text)
# 4) Restore code segments.
def _restore_code(m: re.Match[str]) -> str:
return code_segments[int(m.group(1))]
text = re.sub(r"@@CODESEG_(\d+)@@", _restore_code, text)
return text, rendered
def reinsert_math(html_text: str, rendered: list[str]) -> str:
"""Replace placeholders inserted by extract_math with rendered MathML."""
for i, snippet in enumerate(rendered):
open_token = _BLOCK_MATH_OPEN.format(idx=i)
close_token = _BLOCK_MATH_CLOSE.format(idx=i)
if open_token in html_text:
# Block math may have been wrapped inside a <p>...</p> by Markdown.
pattern = re.compile(
rf"(?:<p>\s*)?{re.escape(open_token)}.*?{re.escape(close_token)}(?:\s*</p>)?",
re.DOTALL,
)
html_text = pattern.sub(lambda _m, s=snippet: s, html_text, count=1)
continue
open_token = _INLINE_MATH_OPEN.format(idx=i)
close_token = _INLINE_MATH_CLOSE.format(idx=i)
pattern = re.compile(
rf"{re.escape(open_token)}.*?{re.escape(close_token)}", re.DOTALL
)
html_text = pattern.sub(lambda _m, s=snippet: s, html_text, count=1)
return html_text
# ---------------------------------------------------------------------------
# Markdown -> HTML
# ---------------------------------------------------------------------------
MD_EXTENSIONS = [
"extra", # tables, fenced_code, footnotes, abbr, attr_list, def_list, md_in_html
"sane_lists",
"smarty",
"toc",
"codehilite",
"admonition",
]
MD_EXT_CONFIGS = {
"codehilite": {
"guess_lang": False,
"css_class": "codehilite",
"noclasses": False,
},
"toc": {"permalink": False},
}
def _rewrite_md_links(html_text: str) -> str:
"""Rewrite href="...something.md" to href="...something.pdf"."""
def _sub(match: re.Match[str]) -> str:
prefix, target, suffix = match.group(1), match.group(2), match.group(3)
new_target = re.sub(r"\.md(#.*)?$", lambda m: ".pdf" + (m.group(1) or ""), target)
return f'{prefix}{new_target}{suffix}'
return re.sub(r'(href=")([^"]+)(")', _sub, html_text)
def render_markdown_to_html_body(md_text: str) -> str:
pre_processed, rendered_math = extract_math(md_text)
md = markdown.Markdown(extensions=MD_EXTENSIONS, extension_configs=MD_EXT_CONFIGS)
body = md.convert(pre_processed)
body = reinsert_math(body, rendered_math)
body = _rewrite_md_links(body)
return body
# ---------------------------------------------------------------------------
# HTML template + CSS
# ---------------------------------------------------------------------------
PYGMENTS_CSS = "" # populated lazily
def _pygments_css() -> str:
global PYGMENTS_CSS
if not PYGMENTS_CSS:
from pygments.formatters import HtmlFormatter
PYGMENTS_CSS = HtmlFormatter(style="friendly").get_style_defs(".codehilite")
return PYGMENTS_CSS
BASE_CSS = """
@page {
size: A4;
margin: 22mm 18mm 22mm 18mm;
@top-center {
content: string(doc-title);
font-family: "PingFang TC", "Heiti TC", "Noto Sans CJK TC", "Microsoft JhengHei", sans-serif;
font-size: 9pt;
color: #888;
}
@bottom-center {
content: counter(page) " / " counter(pages);
font-family: "PingFang TC", "Heiti TC", "Noto Sans CJK TC", sans-serif;
font-size: 9pt;
color: #888;
}
}
@page :first {
@top-center { content: ""; }
}
html { font-size: 11pt; }
body {
font-family: "PingFang TC", "Heiti TC", "Noto Sans CJK TC",
"Microsoft JhengHei", "Hiragino Sans GB",
"Helvetica Neue", Arial, sans-serif;
color: #1f1f1f;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: break-word;
}
h1, h2, h3, h4, h5, h6 {
color: #0b3a66;
font-weight: 700;
line-height: 1.3;
margin: 1.2em 0 0.6em;
page-break-after: avoid;
}
h1 {
string-set: doc-title content();
font-size: 22pt;
border-bottom: 3px solid #0b3a66;
padding-bottom: 0.25em;
margin-top: 0;
}
h2 {
font-size: 17pt;
border-bottom: 1px solid #c4d4e3;
padding-bottom: 0.2em;
}
h3 { font-size: 14pt; color: #1d5b94; }
h4 { font-size: 12.5pt; color: #2a6fae; }
h5 { font-size: 11.5pt; color: #3a7fbe; }
h6 { font-size: 11pt; color: #555; }
p { margin: 0.5em 0; text-align: justify; }
ul, ol { margin: 0.4em 0 0.6em 1.4em; padding: 0; }
li { margin: 0.15em 0; }
blockquote {
border-left: 4px solid #0b3a66;
background: #eef4fb;
padding: 0.6em 1em;
margin: 0.8em 0;
color: #2a3b4c;
}
a { color: #0b66c3; text-decoration: none; }
a:hover { text-decoration: underline; }
hr {
border: none;
border-top: 1px dashed #c4d4e3;
margin: 1.4em 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
font-size: 10pt;
page-break-inside: avoid;
}
th, td {
border: 1px solid #b8c8d8;
padding: 0.45em 0.6em;
text-align: left;
vertical-align: top;
}
th {
background: #0b3a66;
color: #fff;
font-weight: 600;
}
tr:nth-child(2n) td { background: #f5f9fd; }
code {
font-family: "JetBrains Mono", "Fira Code", "Menlo",
"Consolas", monospace;
font-size: 0.92em;
background: #f1efe9;
padding: 0.05em 0.3em;
border-radius: 3px;
color: #b03a2e;
}
pre {
background: #f6f3ec;
border: 1px solid #e4ddc8;
border-radius: 4px;
padding: 0.8em 1em;
overflow-x: auto;
font-size: 9.5pt;
line-height: 1.45;
page-break-inside: avoid;
white-space: pre-wrap;
word-break: break-all;
}
pre code {
background: transparent;
padding: 0;
color: inherit;
}
.codehilite { background: #f6f3ec; }
img { max-width: 100%; height: auto; }
/* Math (rendered as inline/block SVG by matplotlib).
* matplotlib emits SVGs at 72 dpi with explicit pt dimensions, so we let
* them render at intrinsic size and only baseline-align inline math. */
.math-svg-inline {
vertical-align: middle;
width: auto;
}
.math-block {
display: block;
text-align: center;
margin: 0.8em 0;
page-break-inside: avoid;
}
.math-svg-block {
max-width: 100%;
height: auto;
}
.math-fallback {
font-family: "Cambria Math", "Latin Modern Math", "Times New Roman", serif;
font-style: italic;
background: #fff5f5;
padding: 0 0.2em;
border-radius: 2px;
}
.math-cjk {
font-family: "PingFang TC", "Heiti TC", "Noto Sans CJK TC",
"Microsoft JhengHei", "Hiragino Sans GB", sans-serif;
padding: 0 0.15em;
vertical-align: middle;
}
/* Pure-HTML fallback for formulas that mix LaTeX layout with Chinese text. */
.math-html {
font-family: "Cambria Math", "Latin Modern Math", "Times New Roman", serif;
font-style: italic;
}
.math-html .math-cjk {
font-style: normal;
}
.math-html .frac {
display: inline-block;
text-align: center;
vertical-align: middle;
margin: 0 0.2em;
font-style: normal;
}
.math-html .frac .num {
display: block;
border-bottom: 1px solid currentColor;
padding: 0 0.3em 0.05em;
}
.math-html .frac .den {
display: block;
padding: 0.05em 0.3em 0;
}
.math-html sup, .math-html sub {
font-size: 0.78em;
line-height: 0;
}
/* Task lists */
ul li input[type="checkbox"] { margin-right: 0.3em; }
"""
def build_html_document(title: str, body_html: str) -> str:
return f"""<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<title>{html.escape(title)}</title>
</head>
<body>
{body_html}
</body>
</html>
"""
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
def convert_file(md_path: Path, out_path: Path | None = None,
font_config: FontConfiguration | None = None) -> Path:
md_text = md_path.read_text(encoding="utf-8")
title = md_path.stem
body_html = render_markdown_to_html_body(md_text)
document_html = build_html_document(title, body_html)
pdf_path = out_path or md_path.with_suffix(".pdf")
css = CSS(string=BASE_CSS + "\n" + _pygments_css(), font_config=font_config)
HTML(string=document_html, base_url=str(md_path.parent)).write_pdf(
pdf_path, stylesheets=[css], font_config=font_config
)
return pdf_path
def main() -> int:
parser = argparse.ArgumentParser(description="Convert markdown files to PDF.")
parser.add_argument(
"paths",
nargs="*",
help="特定 markdown 檔;若未提供則轉換 電子硬體完整教材/ 下所有 .md",
)
parser.add_argument("--root", type=Path, default=DEFAULT_TARGET_DIR)
args = parser.parse_args()
if args.paths:
files = [Path(p).resolve() for p in args.paths]
else:
files = sorted(args.root.rglob("*.md"))
if not files:
print("找不到任何 .md 檔案", file=sys.stderr)
return 1
font_config = FontConfiguration()
print(f"準備轉換 {len(files)} 個 markdown 檔 → PDF")
failures: list[tuple[Path, Exception]] = []
t0 = time.time()
for i, md in enumerate(files, 1):
try:
tic = time.time()
pdf = convert_file(md, font_config=font_config)
dur = time.time() - tic
rel = md.relative_to(ROOT) if md.is_relative_to(ROOT) else md
print(f" [{i:>2}/{len(files)}] ✓ {rel} → {pdf.name} ({dur:.2f}s)")
except Exception as e: # noqa: BLE001
failures.append((md, e))
print(f" [{i:>2}/{len(files)}] ✗ {md}: {e}", file=sys.stderr)
total = time.time() - t0
ok = len(files) - len(failures)
print(f"\n完成:成功 {ok} / 失敗 {len(failures)}(共 {total:.1f}s)")
if failures:
print("失敗檔案:", file=sys.stderr)
for f, e in failures:
print(f" - {f}: {e}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())