S SmartDocs
Série: Hardware python 774 lignes · Mis à jour 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("&nbsp;&nbsp;")
                        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())