md2pdf-tool 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ include LICENSE
2
+ include README.md
3
+ include pyproject.toml
4
+ recursive-include src/md2pdf *.css
5
+ recursive-include tests *.py
6
+ include gen_pdf.sh
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: md2pdf-tool
3
+ Version: 0.1.0
4
+ Summary: Convert Markdown to a styled, Chinese-friendly A4 PDF with QA previews.
5
+ Author-email: Ethan <zhangyuxin85@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ethanzhrepo/md2pdf
8
+ Project-URL: Repository, https://github.com/ethanzhrepo/md2pdf
9
+ Keywords: markdown,pdf,pandoc,playwright,chinese,cjk
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
13
+ Classifier: Topic :: Printing
14
+ Classifier: Environment :: Console
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: playwright>=1.40
19
+ Requires-Dist: PyMuPDF>=1.24
20
+ Requires-Dist: Pillow>=10.0
21
+ Dynamic: license-file
22
+
23
+ # md2pdf
24
+
25
+ Markdown 转 PDF 工具,专为中文文档优化:
26
+
27
+ 1. Pandoc 把 Markdown 转成带目录的 HTML。
28
+ 2. `md2pdf/style.css` 负责中文字体、封面、目录、标题、引用块、表格和分页样式。
29
+ 3. Playwright / headless Chrome 把 HTML 打印成 A4 PDF。
30
+ 4. PyMuPDF + Pillow 渲染 QA 图片和 `summary.txt`,用于检查页数、页面尺寸、乱码和抽检页面。
31
+
32
+ ## 安装
33
+
34
+ 需要先准备两个系统依赖(pip 无法安装):
35
+
36
+ - **pandoc**:`brew install pandoc`(macOS)/ `apt install pandoc`(Debian/Ubuntu)。
37
+ - **浏览器**:装好 Python 包后运行 `playwright install chromium`,让 Playwright 下载自带的 Chromium;macOS 上若已装 Google Chrome 也可直接复用。
38
+
39
+ 然后安装本工具。发布名是 `md2pdf-tool`(PyPI 上 `md2pdf` 已被占用),命令仍是 `md2pdf`。
40
+
41
+ **已发布后**,最终用户二选一:
42
+
43
+ ```bash
44
+ # Homebrew(个人 tap)
45
+ brew install <用户名>/md2pdf/md2pdf
46
+ playwright install chromium
47
+
48
+ # 或 pipx
49
+ pipx install md2pdf-tool
50
+ playwright install chromium
51
+ ```
52
+
53
+ **从源码安装 / 开发**:
54
+
55
+ ```bash
56
+ pipx install . # 从仓库目录安装,得到全局 md2pdf 命令
57
+ # 或可编辑安装:
58
+ python -m venv .venv && source .venv/bin/activate
59
+ pip install -e .
60
+ playwright install chromium
61
+ ```
62
+
63
+ 发布到 PyPI 和搭建 Homebrew tap 的完整步骤见 [PUBLISHING.md](PUBLISHING.md)。
64
+
65
+ ## 使用
66
+
67
+ 安装后直接用 `md2pdf` 命令:
68
+
69
+ ```bash
70
+ md2pdf docs/requirements.md
71
+ ```
72
+
73
+ 不传输出路径时,默认输出到同目录同名 `.pdf`;不传 `--title` 时,默认用文件名。
74
+
75
+ 也可以用 `gen_pdf.sh` 包装脚本——它会自动读取 Markdown 第一个一级标题作为封面标题:
76
+
77
+ ```bash
78
+ ./gen_pdf.sh docs/requirements.md docs/requirements.pdf
79
+ ./gen_pdf.sh --dry-run docs/requirements.md # 只打印将要执行的命令
80
+ ```
81
+
82
+ 需要手动指定标题、副标题、日期或必检文本时,传对应参数:
83
+
84
+ ```bash
85
+ md2pdf docs/requirements.md \
86
+ --output docs/requirements.pdf \
87
+ --title "4D交互式电子手册平台需求说明" \
88
+ --subtitle "面向大型企业私有化部署的创作、发布、播放与反馈闭环系统" \
89
+ --date "2026-06-03" \
90
+ --required-text "十八、最终一句话目标"
91
+ ```
92
+
93
+ 未安装时,也可以从仓库根目录用模块方式运行(需 `pip install -e .` 或设置 `PYTHONPATH=src`):
94
+
95
+ ```bash
96
+ python -m md2pdf.build_pdf path/to/input.md --output path/to/output.pdf
97
+ ```
98
+
99
+ ## 输出
100
+
101
+ 生成后会输出(以 `requirements` 为例):
102
+
103
+ - PDF:`docs/requirements.pdf`
104
+ - 中间 HTML:`<build-dir>/requirements.html`
105
+ - QA 摘要:`<build-dir>/qa/summary.txt`
106
+ - 页面抽检图和缩略图:`<build-dir>/qa/*.png`
107
+
108
+ `<build-dir>` 默认放在**输出 PDF 旁边**的 `.build/<stem>/`(用户可写,不会写进只读的安装目录)。需要指定别处时用 `--build-dir`,例如 `--build-dir ./.md2pdf-build`。
109
+
110
+ ## 依赖
111
+
112
+ - `pandoc`
113
+ - Python 包:`playwright`、`PyMuPDF`(`fitz`)、`Pillow`(随 `pip install` 自动安装)
114
+ - Google Chrome 或 Playwright Chromium
115
+
116
+ Pandoc 可能提示 `Could not load translations for zh-CN`,这不影响中文内容生成和 PDF 文本提取。
117
+
118
+ ## 测试
119
+
120
+ ```bash
121
+ pip install -e .
122
+ python -m unittest discover -s tests -v
123
+ ```
@@ -0,0 +1,101 @@
1
+ # md2pdf
2
+
3
+ Markdown 转 PDF 工具,专为中文文档优化:
4
+
5
+ 1. Pandoc 把 Markdown 转成带目录的 HTML。
6
+ 2. `md2pdf/style.css` 负责中文字体、封面、目录、标题、引用块、表格和分页样式。
7
+ 3. Playwright / headless Chrome 把 HTML 打印成 A4 PDF。
8
+ 4. PyMuPDF + Pillow 渲染 QA 图片和 `summary.txt`,用于检查页数、页面尺寸、乱码和抽检页面。
9
+
10
+ ## 安装
11
+
12
+ 需要先准备两个系统依赖(pip 无法安装):
13
+
14
+ - **pandoc**:`brew install pandoc`(macOS)/ `apt install pandoc`(Debian/Ubuntu)。
15
+ - **浏览器**:装好 Python 包后运行 `playwright install chromium`,让 Playwright 下载自带的 Chromium;macOS 上若已装 Google Chrome 也可直接复用。
16
+
17
+ 然后安装本工具。发布名是 `md2pdf-tool`(PyPI 上 `md2pdf` 已被占用),命令仍是 `md2pdf`。
18
+
19
+ **已发布后**,最终用户二选一:
20
+
21
+ ```bash
22
+ # Homebrew(个人 tap)
23
+ brew install <用户名>/md2pdf/md2pdf
24
+ playwright install chromium
25
+
26
+ # 或 pipx
27
+ pipx install md2pdf-tool
28
+ playwright install chromium
29
+ ```
30
+
31
+ **从源码安装 / 开发**:
32
+
33
+ ```bash
34
+ pipx install . # 从仓库目录安装,得到全局 md2pdf 命令
35
+ # 或可编辑安装:
36
+ python -m venv .venv && source .venv/bin/activate
37
+ pip install -e .
38
+ playwright install chromium
39
+ ```
40
+
41
+ 发布到 PyPI 和搭建 Homebrew tap 的完整步骤见 [PUBLISHING.md](PUBLISHING.md)。
42
+
43
+ ## 使用
44
+
45
+ 安装后直接用 `md2pdf` 命令:
46
+
47
+ ```bash
48
+ md2pdf docs/requirements.md
49
+ ```
50
+
51
+ 不传输出路径时,默认输出到同目录同名 `.pdf`;不传 `--title` 时,默认用文件名。
52
+
53
+ 也可以用 `gen_pdf.sh` 包装脚本——它会自动读取 Markdown 第一个一级标题作为封面标题:
54
+
55
+ ```bash
56
+ ./gen_pdf.sh docs/requirements.md docs/requirements.pdf
57
+ ./gen_pdf.sh --dry-run docs/requirements.md # 只打印将要执行的命令
58
+ ```
59
+
60
+ 需要手动指定标题、副标题、日期或必检文本时,传对应参数:
61
+
62
+ ```bash
63
+ md2pdf docs/requirements.md \
64
+ --output docs/requirements.pdf \
65
+ --title "4D交互式电子手册平台需求说明" \
66
+ --subtitle "面向大型企业私有化部署的创作、发布、播放与反馈闭环系统" \
67
+ --date "2026-06-03" \
68
+ --required-text "十八、最终一句话目标"
69
+ ```
70
+
71
+ 未安装时,也可以从仓库根目录用模块方式运行(需 `pip install -e .` 或设置 `PYTHONPATH=src`):
72
+
73
+ ```bash
74
+ python -m md2pdf.build_pdf path/to/input.md --output path/to/output.pdf
75
+ ```
76
+
77
+ ## 输出
78
+
79
+ 生成后会输出(以 `requirements` 为例):
80
+
81
+ - PDF:`docs/requirements.pdf`
82
+ - 中间 HTML:`<build-dir>/requirements.html`
83
+ - QA 摘要:`<build-dir>/qa/summary.txt`
84
+ - 页面抽检图和缩略图:`<build-dir>/qa/*.png`
85
+
86
+ `<build-dir>` 默认放在**输出 PDF 旁边**的 `.build/<stem>/`(用户可写,不会写进只读的安装目录)。需要指定别处时用 `--build-dir`,例如 `--build-dir ./.md2pdf-build`。
87
+
88
+ ## 依赖
89
+
90
+ - `pandoc`
91
+ - Python 包:`playwright`、`PyMuPDF`(`fitz`)、`Pillow`(随 `pip install` 自动安装)
92
+ - Google Chrome 或 Playwright Chromium
93
+
94
+ Pandoc 可能提示 `Could not load translations for zh-CN`,这不影响中文内容生成和 PDF 文本提取。
95
+
96
+ ## 测试
97
+
98
+ ```bash
99
+ pip install -e .
100
+ python -m unittest discover -s tests -v
101
+ ```
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Convenience wrapper around `python -m md2pdf.build_pdf`.
4
+ # Reads the first level-1 Markdown heading as the PDF cover title.
5
+ # When OUTPUT is omitted, writes <input>.pdf next to the source.
6
+ set -euo pipefail
7
+
8
+ usage() {
9
+ cat <<'EOF'
10
+ Usage: ./gen_pdf.sh INPUT.md [OUTPUT.pdf]
11
+
12
+ Convert a Markdown file to a styled, Chinese-friendly A4 PDF.
13
+ The first "# heading" in INPUT.md becomes the cover title.
14
+
15
+ Options:
16
+ --dry-run Print the build command without running it.
17
+ -h, --help Show this help and exit.
18
+ EOF
19
+ }
20
+
21
+ dry_run=0
22
+ args=()
23
+ for arg in "$@"; do
24
+ case "$arg" in
25
+ -h|--help) usage; exit 0 ;;
26
+ --dry-run) dry_run=1 ;;
27
+ *) args+=("$arg") ;;
28
+ esac
29
+ done
30
+
31
+ if [ "${#args[@]}" -lt 1 ]; then
32
+ usage >&2
33
+ exit 1
34
+ fi
35
+
36
+ input="${args[0]}"
37
+ output="${args[1]:-${input%.md}.pdf}"
38
+
39
+ # First level-1 heading ("# Title"), stripped of the leading marker.
40
+ title="$(grep -m1 '^# ' "$input" | sed 's/^# *//' || true)"
41
+
42
+ if [ "$dry_run" -eq 1 ]; then
43
+ echo "python -m md2pdf.build_pdf \"$input\" --output \"$output\" --title \"$title\""
44
+ exit 0
45
+ fi
46
+
47
+ exec python -m md2pdf.build_pdf "$input" --output "$output" --title "$title"
@@ -0,0 +1,50 @@
1
+ # Packaging for the md2pdf CLI.
2
+ #
3
+ # Distribution (PyPI) name is "md2pdf-tool" (the plain "md2pdf" name is
4
+ # already taken on PyPI). The *import* package stays `md2pdf`, so `import md2pdf`
5
+ # and the `md2pdf` console command are unaffected by the distribution name.
6
+ #
7
+ # After publishing, fill url + sha256 in packaging/homebrew/md2pdf.rb.
8
+
9
+ [build-system]
10
+ requires = ["setuptools>=77"]
11
+ build-backend = "setuptools.build_meta"
12
+
13
+ [project]
14
+ name = "md2pdf-tool"
15
+ version = "0.1.0"
16
+ description = "Convert Markdown to a styled, Chinese-friendly A4 PDF with QA previews."
17
+ readme = "README.md"
18
+ requires-python = ">=3.10"
19
+ license = "MIT"
20
+ license-files = ["LICENSE"]
21
+ authors = [{ name = "Ethan", email = "zhangyuxin85@gmail.com" }]
22
+ keywords = ["markdown", "pdf", "pandoc", "playwright", "chinese", "cjk"]
23
+ classifiers = [
24
+ "Programming Language :: Python :: 3",
25
+ "Operating System :: OS Independent",
26
+ "Topic :: Text Processing :: Markup :: Markdown",
27
+ "Topic :: Printing",
28
+ "Environment :: Console",
29
+ ]
30
+ dependencies = [
31
+ "playwright>=1.40",
32
+ "PyMuPDF>=1.24",
33
+ "Pillow>=10.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/ethanzhrepo/md2pdf"
38
+ Repository = "https://github.com/ethanzhrepo/md2pdf"
39
+
40
+ # Installs a global `md2pdf` command that calls build_pdf.main().
41
+ [project.scripts]
42
+ md2pdf = "md2pdf.build_pdf:main"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ # Ship the print stylesheet inside the package so the installed CLI can find it
48
+ # at <site-packages>/md2pdf/style.css (build_pdf.py resolves it next to itself).
49
+ [tool.setuptools.package-data]
50
+ md2pdf = ["style.css"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Project-local Markdown to PDF tooling."""
@@ -0,0 +1,326 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import html
5
+ import math
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from datetime import date as local_date
11
+ from pathlib import Path
12
+
13
+
14
+ TOOL_DIR = Path(__file__).resolve().parent
15
+ DEFAULT_CSS = TOOL_DIR / "style.css"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class BuildConfig:
20
+ source: Path
21
+ output: Path
22
+ title: str
23
+ subtitle: str
24
+ date: str
25
+ css: Path
26
+ build_dir: Path
27
+ footer_title: str
28
+ toc: bool
29
+ toc_depth: int
30
+ qa: bool
31
+ required_text: tuple[str, ...]
32
+
33
+
34
+ def default_output_path(source: Path) -> Path:
35
+ return source.with_suffix(".pdf")
36
+
37
+
38
+ def build_metadata(title: str, subtitle: str = "", date: str = "", lang: str = "zh-CN") -> str:
39
+ lines = ["---", f"title: {title}"]
40
+ if subtitle:
41
+ lines.append(f"subtitle: {subtitle}")
42
+ if date:
43
+ lines.append(f"date: {date}")
44
+ lines.extend([f"lang: {lang}", "---", ""])
45
+ return "\n".join(lines)
46
+
47
+
48
+ def selected_qa_pages(page_count: int) -> list[int]:
49
+ if page_count < 1:
50
+ raise ValueError("PDF has no pages")
51
+ candidates = {0, page_count // 2, page_count - 1}
52
+ if page_count > 1:
53
+ candidates.add(1)
54
+ if page_count > 2:
55
+ candidates.add(2)
56
+ return sorted(candidates)
57
+
58
+
59
+ def parse_args(argv: list[str] | None = None) -> BuildConfig:
60
+ parser = argparse.ArgumentParser(
61
+ description="Convert Markdown to a styled Chinese-friendly PDF and render QA previews.",
62
+ )
63
+ parser.add_argument("source", type=Path, help="Markdown source file")
64
+ parser.add_argument("--output", type=Path, help="PDF output path. Defaults to SOURCE with .pdf suffix.")
65
+ parser.add_argument("--title", help="Cover/title metadata. Defaults to the source filename stem.")
66
+ parser.add_argument("--subtitle", default="", help="Optional subtitle shown on the cover.")
67
+ parser.add_argument("--date", default=local_date.today().isoformat(), help="Cover date. Defaults to today.")
68
+ parser.add_argument("--css", type=Path, default=DEFAULT_CSS, help="Print CSS file.")
69
+ parser.add_argument("--build-dir", type=Path, help="Intermediate HTML, metadata, and QA output directory.")
70
+ parser.add_argument("--footer-title", help="Footer document title. Defaults to --title.")
71
+ parser.add_argument("--toc-depth", type=int, default=2, help="Pandoc table-of-contents depth.")
72
+ parser.add_argument("--no-toc", action="store_true", help="Disable generated table of contents.")
73
+ parser.add_argument("--no-qa", action="store_true", help="Skip PyMuPDF/Pillow QA rendering.")
74
+ parser.add_argument(
75
+ "--required-text",
76
+ action="append",
77
+ default=[],
78
+ help="Text that must be extractable from the final PDF. Can be passed multiple times.",
79
+ )
80
+ args = parser.parse_args(argv)
81
+
82
+ source = args.source.resolve()
83
+ output = (args.output or default_output_path(source)).resolve()
84
+ title = args.title or source.stem
85
+ # Default build dir lives next to the OUTPUT, not inside the package install
86
+ # location (which may be read-only, e.g. a Homebrew cellar or system
87
+ # site-packages). The output dir is user-owned and writable.
88
+ build_dir = (
89
+ args.build_dir.resolve()
90
+ if args.build_dir
91
+ else (output.parent / ".build" / source.stem)
92
+ )
93
+
94
+ return BuildConfig(
95
+ source=source,
96
+ output=output,
97
+ title=title,
98
+ subtitle=args.subtitle,
99
+ date=args.date,
100
+ css=args.css.resolve(),
101
+ build_dir=build_dir,
102
+ footer_title=args.footer_title or title,
103
+ toc=not args.no_toc,
104
+ toc_depth=args.toc_depth,
105
+ qa=not args.no_qa,
106
+ required_text=tuple(args.required_text),
107
+ )
108
+
109
+
110
+ def require_tool(name: str) -> None:
111
+ if shutil.which(name) is None:
112
+ raise RuntimeError(f"Required command not found: {name}")
113
+
114
+
115
+ def run_pandoc(config: BuildConfig) -> Path:
116
+ require_tool("pandoc")
117
+ config.build_dir.mkdir(parents=True, exist_ok=True)
118
+ metadata = config.build_dir / "metadata.yaml"
119
+ html_path = config.build_dir / f"{config.source.stem}.html"
120
+ metadata.write_text(
121
+ build_metadata(config.title, config.subtitle, config.date),
122
+ encoding="utf-8",
123
+ )
124
+
125
+ command = [
126
+ "pandoc",
127
+ str(config.source),
128
+ "--from=markdown+smart",
129
+ "--to=html5",
130
+ "--standalone",
131
+ "--metadata-file",
132
+ str(metadata),
133
+ "--css",
134
+ str(config.css),
135
+ "--output",
136
+ str(html_path),
137
+ ]
138
+ if config.toc:
139
+ command.extend(["--toc", "--toc-depth", str(config.toc_depth)])
140
+
141
+ subprocess.run(command, check=True, cwd=config.source.parent)
142
+ return html_path
143
+
144
+
145
+ def footer_template(title: str) -> str:
146
+ escaped_title = html.escape(title)
147
+ return f"""
148
+ <style>
149
+ .pdf-footer {{
150
+ width: 100%;
151
+ padding: 0 17mm;
152
+ color: #697386;
153
+ font-family: "Hiragino Sans GB", "STHeiti", "PingFang SC", Arial, sans-serif;
154
+ font-size: 8px;
155
+ display: flex;
156
+ justify-content: space-between;
157
+ }}
158
+ </style>
159
+ <div class="pdf-footer">
160
+ <span>{escaped_title}</span>
161
+ <span>第 <span class="pageNumber"></span> / <span class="totalPages"></span> 页</span>
162
+ </div>
163
+ """
164
+
165
+
166
+ def launch_chromium(playwright):
167
+ launch_errors: list[str] = []
168
+ launchers = [
169
+ lambda: playwright.chromium.launch(channel="chrome", headless=True),
170
+ lambda: playwright.chromium.launch(
171
+ executable_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
172
+ headless=True,
173
+ ),
174
+ lambda: playwright.chromium.launch(headless=True),
175
+ ]
176
+ for launcher in launchers:
177
+ try:
178
+ return launcher()
179
+ except Exception as exc: # noqa: BLE001 - preserve diagnostics for all fallbacks
180
+ launch_errors.append(str(exc))
181
+ raise RuntimeError("\n\n".join(launch_errors))
182
+
183
+
184
+ def print_pdf(config: BuildConfig, html_path: Path) -> None:
185
+ from playwright.sync_api import sync_playwright
186
+
187
+ config.output.parent.mkdir(parents=True, exist_ok=True)
188
+ with sync_playwright() as p:
189
+ browser = launch_chromium(p)
190
+ page = browser.new_page(viewport={"width": 1240, "height": 1754})
191
+ page.goto(html_path.as_uri(), wait_until="networkidle")
192
+ page.emulate_media(media="print")
193
+ page.pdf(
194
+ path=str(config.output),
195
+ format="A4",
196
+ print_background=True,
197
+ display_header_footer=True,
198
+ header_template="<div></div>",
199
+ footer_template=footer_template(config.footer_title),
200
+ prefer_css_page_size=True,
201
+ margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
202
+ )
203
+ browser.close()
204
+
205
+
206
+ def render_page(doc, page_index: int, scale: float, path: Path) -> None:
207
+ import fitz
208
+
209
+ page = doc.load_page(page_index)
210
+ pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
211
+ pix.save(str(path))
212
+
213
+
214
+ def make_contact_sheets(doc, qa_dir: Path) -> None:
215
+ import fitz
216
+ from PIL import Image, ImageDraw, ImageFont
217
+
218
+ per_sheet = 12
219
+ cols = 3
220
+ rows = 4
221
+ thumb_scale = 0.27
222
+ pad = 22
223
+ label_h = 24
224
+ font = ImageFont.load_default()
225
+
226
+ for sheet_index in range(math.ceil(doc.page_count / per_sheet)):
227
+ start = sheet_index * per_sheet
228
+ end = min(start + per_sheet, doc.page_count)
229
+ thumbs: list[Image.Image] = []
230
+ for page_index in range(start, end):
231
+ pix = doc.load_page(page_index).get_pixmap(
232
+ matrix=fitz.Matrix(thumb_scale, thumb_scale),
233
+ alpha=False,
234
+ )
235
+ img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
236
+ canvas = Image.new("RGB", (img.width, img.height + label_h), "white")
237
+ canvas.paste(img, (0, 0))
238
+ draw = ImageDraw.Draw(canvas)
239
+ draw.rectangle((0, 0, img.width - 1, img.height - 1), outline="#c7ced8", width=1)
240
+ draw.text((8, img.height + 5), f"Page {page_index + 1}", fill="#334155", font=font)
241
+ thumbs.append(canvas)
242
+
243
+ cell_w = max(t.width for t in thumbs)
244
+ cell_h = max(t.height for t in thumbs)
245
+ sheet = Image.new(
246
+ "RGB",
247
+ (cols * cell_w + (cols + 1) * pad, rows * cell_h + (rows + 1) * pad),
248
+ "#f2f5f9",
249
+ )
250
+ for i, thumb in enumerate(thumbs):
251
+ x = pad + (i % cols) * (cell_w + pad)
252
+ y = pad + (i // cols) * (cell_h + pad)
253
+ sheet.paste(thumb, (x, y))
254
+
255
+ sheet.save(qa_dir / f"contact_sheet_{sheet_index + 1:02d}.png")
256
+
257
+
258
+ def qa_render(config: BuildConfig, html_path: Path) -> Path:
259
+ import fitz
260
+
261
+ qa_dir = config.build_dir / "qa"
262
+ if qa_dir.exists():
263
+ shutil.rmtree(qa_dir)
264
+ qa_dir.mkdir(parents=True, exist_ok=True)
265
+
266
+ doc = fitz.open(config.output)
267
+ for page_index in selected_qa_pages(doc.page_count):
268
+ render_page(doc, page_index, 1.45, qa_dir / f"page_{page_index + 1:03d}.png")
269
+ make_contact_sheets(doc, qa_dir)
270
+
271
+ extracted = "\n".join(page.get_text() for page in doc)
272
+ required_results = {
273
+ item: item in extracted
274
+ for item in (config.title, *config.required_text)
275
+ if item
276
+ }
277
+ summary = {
278
+ "pdf": str(config.output),
279
+ "html": str(html_path),
280
+ "pages": doc.page_count,
281
+ "page_size_points": [round(doc[0].rect.width, 2), round(doc[0].rect.height, 2)],
282
+ "replacement_character_count": extracted.count("\ufffd"),
283
+ "required_text": required_results,
284
+ "very_low_text_pages": [
285
+ (i + 1, len(page.get_text().strip()))
286
+ for i, page in enumerate(doc)
287
+ if len(page.get_text().strip()) < 40
288
+ ],
289
+ }
290
+ summary_path = qa_dir / "summary.txt"
291
+ summary_path.write_text(
292
+ "\n".join(f"{key}: {value}" for key, value in summary.items()) + "\n",
293
+ encoding="utf-8",
294
+ )
295
+ doc.close()
296
+ return summary_path
297
+
298
+
299
+ def build(config: BuildConfig) -> tuple[Path, Path | None]:
300
+ if not config.source.exists():
301
+ raise FileNotFoundError(config.source)
302
+ if not config.css.exists():
303
+ raise FileNotFoundError(config.css)
304
+
305
+ html_path = run_pandoc(config)
306
+ print_pdf(config, html_path)
307
+ summary_path = qa_render(config, html_path) if config.qa else None
308
+ return config.output, summary_path
309
+
310
+
311
+ def main(argv: list[str] | None = None) -> int:
312
+ try:
313
+ config = parse_args(argv)
314
+ pdf_path, summary_path = build(config)
315
+ except Exception as exc: # noqa: BLE001 - CLI should report concise failure
316
+ print(f"md2pdf failed: {exc}", file=sys.stderr)
317
+ return 1
318
+
319
+ print(f"PDF: {pdf_path}")
320
+ if summary_path:
321
+ print(f"QA: {summary_path}")
322
+ return 0
323
+
324
+
325
+ if __name__ == "__main__":
326
+ raise SystemExit(main())
@@ -0,0 +1,386 @@
1
+ @charset "UTF-8";
2
+
3
+ :root {
4
+ --ink: #172033;
5
+ --muted: #5c667a;
6
+ --line: #d9e0ea;
7
+ --accent: #185b9d;
8
+ --accent-deep: #123f6d;
9
+ --accent-soft: #e8f1fb;
10
+ --panel: #f6f9fc;
11
+ --panel-line: #d7e2ee;
12
+ }
13
+
14
+ @page {
15
+ size: A4;
16
+ margin: 18mm 17mm 20mm 17mm;
17
+ }
18
+
19
+ * {
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ html {
24
+ color: var(--ink);
25
+ font-family: "Hiragino Sans GB", "STHeiti", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", Arial, sans-serif;
26
+ font-size: 10.7pt;
27
+ line-height: 1.72;
28
+ text-rendering: optimizeLegibility;
29
+ }
30
+
31
+ body {
32
+ margin: 0;
33
+ padding: 0;
34
+ background: #fff;
35
+ }
36
+
37
+ a {
38
+ color: var(--accent);
39
+ text-decoration: none;
40
+ }
41
+
42
+ p {
43
+ margin: 0 0 0.72em 0;
44
+ }
45
+
46
+ strong {
47
+ font-weight: 700;
48
+ }
49
+
50
+ #title-block-header {
51
+ min-height: 190mm;
52
+ position: relative;
53
+ display: block;
54
+ padding: 24mm 18mm 18mm 23mm;
55
+ break-after: page;
56
+ overflow: hidden;
57
+ border: 0.9pt solid var(--panel-line);
58
+ border-left: 8mm solid var(--accent);
59
+ border-radius: 6px;
60
+ background:
61
+ linear-gradient(180deg, #f8fbfe 0%, #ffffff 40%, #ffffff 100%);
62
+ }
63
+
64
+ #title-block-header::before {
65
+ content: "需求说明书";
66
+ position: relative;
67
+ z-index: 1;
68
+ display: inline-block;
69
+ width: max-content;
70
+ margin: 0 0 22mm 0;
71
+ padding: 2.3mm 4.4mm;
72
+ border-radius: 999px;
73
+ background: var(--accent-soft);
74
+ color: var(--accent-deep);
75
+ font-size: 9.5pt;
76
+ font-weight: 700;
77
+ letter-spacing: 0.08em;
78
+ }
79
+
80
+ #title-block-header::after {
81
+ content: "PRIVATE DEPLOYMENT · 4D IETM · OPERATION WORKFLOW";
82
+ position: absolute;
83
+ right: 18mm;
84
+ bottom: 18mm;
85
+ left: 23mm;
86
+ z-index: 0;
87
+ padding-top: 5mm;
88
+ border-top: 0.8pt solid var(--line);
89
+ color: #7a8798;
90
+ font-size: 8.4pt;
91
+ font-weight: 700;
92
+ letter-spacing: 0.09em;
93
+ }
94
+
95
+ h1.title {
96
+ margin: 0;
97
+ max-width: 165mm;
98
+ color: #10213d;
99
+ font-size: 28pt;
100
+ line-height: 1.18;
101
+ font-weight: 800;
102
+ letter-spacing: 0;
103
+ }
104
+
105
+ p.subtitle {
106
+ max-width: 160mm;
107
+ margin: 8mm 0 0 0;
108
+ color: var(--muted);
109
+ font-size: 13pt;
110
+ line-height: 1.56;
111
+ }
112
+
113
+ p.date {
114
+ margin: 16mm 0 0 0;
115
+ color: var(--muted);
116
+ font-size: 10.5pt;
117
+ }
118
+
119
+ #TOC {
120
+ break-after: page;
121
+ padding-top: 1mm;
122
+ }
123
+
124
+ #TOC::before {
125
+ content: "目录";
126
+ display: block;
127
+ margin: 0 0 8mm 0;
128
+ color: #10213d;
129
+ font-size: 21pt;
130
+ line-height: 1.2;
131
+ font-weight: 800;
132
+ border-bottom: 1.4pt solid var(--line);
133
+ padding-bottom: 4mm;
134
+ }
135
+
136
+ #TOC ul {
137
+ list-style: none;
138
+ margin: 0;
139
+ padding: 0;
140
+ }
141
+
142
+ #TOC > ul > li {
143
+ margin: 0 0 3.2mm 0;
144
+ padding: 0 0 3.2mm 0;
145
+ border-bottom: 0.5pt solid #edf1f5;
146
+ }
147
+
148
+ #TOC > ul > li > a {
149
+ color: var(--ink);
150
+ font-weight: 700;
151
+ }
152
+
153
+ #TOC ul ul {
154
+ margin-top: 1.4mm;
155
+ column-count: 2;
156
+ column-gap: 8mm;
157
+ }
158
+
159
+ #TOC ul ul li {
160
+ margin: 0 0 1.2mm 0;
161
+ break-inside: avoid;
162
+ }
163
+
164
+ #TOC ul ul a {
165
+ color: var(--muted);
166
+ font-size: 9.4pt;
167
+ }
168
+
169
+ h1,
170
+ h2,
171
+ h3 {
172
+ color: #10213d;
173
+ letter-spacing: 0;
174
+ }
175
+
176
+ h1 {
177
+ margin: 0 0 7mm 0;
178
+ padding-bottom: 3.2mm;
179
+ border-bottom: 1.4pt solid var(--line);
180
+ font-size: 20pt;
181
+ line-height: 1.28;
182
+ font-weight: 800;
183
+ break-before: page;
184
+ break-after: avoid;
185
+ }
186
+
187
+ #TOC + h1 {
188
+ break-before: auto;
189
+ }
190
+
191
+ h2 {
192
+ margin: 8.2mm 0 3.4mm 0;
193
+ font-size: 14.2pt;
194
+ line-height: 1.36;
195
+ font-weight: 780;
196
+ break-after: avoid;
197
+ }
198
+
199
+ h3 {
200
+ margin: 6mm 0 2.2mm 0;
201
+ color: var(--accent);
202
+ font-size: 11.8pt;
203
+ line-height: 1.36;
204
+ font-weight: 760;
205
+ break-after: avoid;
206
+ }
207
+
208
+ h4 {
209
+ margin: 4.5mm 0 1.8mm 0;
210
+ color: var(--muted);
211
+ font-size: 10.8pt;
212
+ font-weight: 740;
213
+ break-after: avoid;
214
+ }
215
+
216
+ #title-block-header h1.title {
217
+ margin: 0;
218
+ position: relative;
219
+ z-index: 1;
220
+ max-width: 142mm;
221
+ padding: 0;
222
+ border: none;
223
+ color: #10213d;
224
+ font-size: 30pt;
225
+ line-height: 1.16;
226
+ font-weight: 800;
227
+ break-before: auto;
228
+ break-after: auto;
229
+ }
230
+
231
+ #title-block-header p.subtitle {
232
+ position: relative;
233
+ z-index: 1;
234
+ max-width: 132mm;
235
+ margin: 9mm 0 0 0;
236
+ padding: 4mm 5mm;
237
+ border: 0.7pt solid var(--panel-line);
238
+ border-left: 3pt solid var(--accent);
239
+ border-radius: 4px;
240
+ background: var(--panel);
241
+ color: #415066;
242
+ font-size: 12.4pt;
243
+ line-height: 1.58;
244
+ }
245
+
246
+ #title-block-header p.date {
247
+ position: relative;
248
+ z-index: 1;
249
+ display: inline-flex;
250
+ align-items: center;
251
+ gap: 3mm;
252
+ margin: 13mm 0 0 0;
253
+ padding: 2.4mm 4mm;
254
+ border: 0.7pt solid var(--line);
255
+ border-radius: 4px;
256
+ background: #ffffff;
257
+ color: #536176;
258
+ font-size: 9.8pt;
259
+ font-weight: 650;
260
+ }
261
+
262
+ #title-block-header p.date::before {
263
+ content: "DATE";
264
+ color: var(--accent);
265
+ font-size: 7.8pt;
266
+ font-weight: 800;
267
+ letter-spacing: 0.08em;
268
+ }
269
+
270
+ ul,
271
+ ol {
272
+ margin: 0 0 3.2mm 0;
273
+ padding-left: 7mm;
274
+ }
275
+
276
+ li {
277
+ margin: 0.7mm 0;
278
+ padding-left: 0.8mm;
279
+ }
280
+
281
+ li::marker {
282
+ color: var(--accent);
283
+ }
284
+
285
+ blockquote {
286
+ margin: 4.5mm 0 5mm 0;
287
+ padding: 3.4mm 4.2mm 3.2mm 4.8mm;
288
+ border-left: 4pt solid var(--accent);
289
+ background: var(--accent-soft);
290
+ color: #16304f;
291
+ break-inside: avoid;
292
+ }
293
+
294
+ blockquote p {
295
+ margin: 0;
296
+ }
297
+
298
+ pre,
299
+ code {
300
+ font-family: Menlo, Monaco, "Courier New", monospace;
301
+ }
302
+
303
+ code {
304
+ padding: 0.1em 0.28em;
305
+ border-radius: 3px;
306
+ background: #f3f5f8;
307
+ color: #1f2a44;
308
+ font-size: 0.93em;
309
+ }
310
+
311
+ pre {
312
+ margin: 4mm 0 5mm 0;
313
+ padding: 4mm 5mm;
314
+ border: 0.7pt solid var(--line);
315
+ border-radius: 4px;
316
+ background: #f7f9fb;
317
+ color: #213047;
318
+ line-height: 1.55;
319
+ white-space: pre-wrap;
320
+ break-inside: avoid;
321
+ }
322
+
323
+ pre code {
324
+ padding: 0;
325
+ background: transparent;
326
+ font-size: 9.4pt;
327
+ }
328
+
329
+ table {
330
+ width: 100%;
331
+ margin: 5mm 0 6mm 0;
332
+ border-collapse: collapse;
333
+ border: 0.8pt solid var(--line);
334
+ font-size: 9.8pt;
335
+ break-inside: avoid;
336
+ }
337
+
338
+ thead {
339
+ display: table-header-group;
340
+ }
341
+
342
+ tr {
343
+ break-inside: avoid;
344
+ }
345
+
346
+ th,
347
+ td {
348
+ padding: 2.7mm 3.2mm;
349
+ border: 0.55pt solid var(--line);
350
+ vertical-align: middle;
351
+ line-height: 1.56;
352
+ }
353
+
354
+ th {
355
+ background: #edf3f8;
356
+ color: #10213d;
357
+ font-weight: 760;
358
+ }
359
+
360
+ tbody tr:nth-child(even) td {
361
+ background: #fafbfd;
362
+ }
363
+
364
+ hr {
365
+ display: none;
366
+ }
367
+
368
+ figure {
369
+ margin: 0;
370
+ }
371
+
372
+ .footnotes,
373
+ section.footnotes {
374
+ margin-top: 8mm;
375
+ padding-top: 4mm;
376
+ border-top: 1pt solid var(--line);
377
+ color: var(--muted);
378
+ font-size: 9pt;
379
+ }
380
+
381
+ @media print {
382
+ body {
383
+ orphans: 3;
384
+ widows: 3;
385
+ }
386
+ }
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: md2pdf-tool
3
+ Version: 0.1.0
4
+ Summary: Convert Markdown to a styled, Chinese-friendly A4 PDF with QA previews.
5
+ Author-email: Ethan <zhangyuxin85@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ethanzhrepo/md2pdf
8
+ Project-URL: Repository, https://github.com/ethanzhrepo/md2pdf
9
+ Keywords: markdown,pdf,pandoc,playwright,chinese,cjk
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
13
+ Classifier: Topic :: Printing
14
+ Classifier: Environment :: Console
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: playwright>=1.40
19
+ Requires-Dist: PyMuPDF>=1.24
20
+ Requires-Dist: Pillow>=10.0
21
+ Dynamic: license-file
22
+
23
+ # md2pdf
24
+
25
+ Markdown 转 PDF 工具,专为中文文档优化:
26
+
27
+ 1. Pandoc 把 Markdown 转成带目录的 HTML。
28
+ 2. `md2pdf/style.css` 负责中文字体、封面、目录、标题、引用块、表格和分页样式。
29
+ 3. Playwright / headless Chrome 把 HTML 打印成 A4 PDF。
30
+ 4. PyMuPDF + Pillow 渲染 QA 图片和 `summary.txt`,用于检查页数、页面尺寸、乱码和抽检页面。
31
+
32
+ ## 安装
33
+
34
+ 需要先准备两个系统依赖(pip 无法安装):
35
+
36
+ - **pandoc**:`brew install pandoc`(macOS)/ `apt install pandoc`(Debian/Ubuntu)。
37
+ - **浏览器**:装好 Python 包后运行 `playwright install chromium`,让 Playwright 下载自带的 Chromium;macOS 上若已装 Google Chrome 也可直接复用。
38
+
39
+ 然后安装本工具。发布名是 `md2pdf-tool`(PyPI 上 `md2pdf` 已被占用),命令仍是 `md2pdf`。
40
+
41
+ **已发布后**,最终用户二选一:
42
+
43
+ ```bash
44
+ # Homebrew(个人 tap)
45
+ brew install <用户名>/md2pdf/md2pdf
46
+ playwright install chromium
47
+
48
+ # 或 pipx
49
+ pipx install md2pdf-tool
50
+ playwright install chromium
51
+ ```
52
+
53
+ **从源码安装 / 开发**:
54
+
55
+ ```bash
56
+ pipx install . # 从仓库目录安装,得到全局 md2pdf 命令
57
+ # 或可编辑安装:
58
+ python -m venv .venv && source .venv/bin/activate
59
+ pip install -e .
60
+ playwright install chromium
61
+ ```
62
+
63
+ 发布到 PyPI 和搭建 Homebrew tap 的完整步骤见 [PUBLISHING.md](PUBLISHING.md)。
64
+
65
+ ## 使用
66
+
67
+ 安装后直接用 `md2pdf` 命令:
68
+
69
+ ```bash
70
+ md2pdf docs/requirements.md
71
+ ```
72
+
73
+ 不传输出路径时,默认输出到同目录同名 `.pdf`;不传 `--title` 时,默认用文件名。
74
+
75
+ 也可以用 `gen_pdf.sh` 包装脚本——它会自动读取 Markdown 第一个一级标题作为封面标题:
76
+
77
+ ```bash
78
+ ./gen_pdf.sh docs/requirements.md docs/requirements.pdf
79
+ ./gen_pdf.sh --dry-run docs/requirements.md # 只打印将要执行的命令
80
+ ```
81
+
82
+ 需要手动指定标题、副标题、日期或必检文本时,传对应参数:
83
+
84
+ ```bash
85
+ md2pdf docs/requirements.md \
86
+ --output docs/requirements.pdf \
87
+ --title "4D交互式电子手册平台需求说明" \
88
+ --subtitle "面向大型企业私有化部署的创作、发布、播放与反馈闭环系统" \
89
+ --date "2026-06-03" \
90
+ --required-text "十八、最终一句话目标"
91
+ ```
92
+
93
+ 未安装时,也可以从仓库根目录用模块方式运行(需 `pip install -e .` 或设置 `PYTHONPATH=src`):
94
+
95
+ ```bash
96
+ python -m md2pdf.build_pdf path/to/input.md --output path/to/output.pdf
97
+ ```
98
+
99
+ ## 输出
100
+
101
+ 生成后会输出(以 `requirements` 为例):
102
+
103
+ - PDF:`docs/requirements.pdf`
104
+ - 中间 HTML:`<build-dir>/requirements.html`
105
+ - QA 摘要:`<build-dir>/qa/summary.txt`
106
+ - 页面抽检图和缩略图:`<build-dir>/qa/*.png`
107
+
108
+ `<build-dir>` 默认放在**输出 PDF 旁边**的 `.build/<stem>/`(用户可写,不会写进只读的安装目录)。需要指定别处时用 `--build-dir`,例如 `--build-dir ./.md2pdf-build`。
109
+
110
+ ## 依赖
111
+
112
+ - `pandoc`
113
+ - Python 包:`playwright`、`PyMuPDF`(`fitz`)、`Pillow`(随 `pip install` 自动安装)
114
+ - Google Chrome 或 Playwright Chromium
115
+
116
+ Pandoc 可能提示 `Could not load translations for zh-CN`,这不影响中文内容生成和 PDF 文本提取。
117
+
118
+ ## 测试
119
+
120
+ ```bash
121
+ pip install -e .
122
+ python -m unittest discover -s tests -v
123
+ ```
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ gen_pdf.sh
5
+ pyproject.toml
6
+ src/md2pdf/__init__.py
7
+ src/md2pdf/build_pdf.py
8
+ src/md2pdf/style.css
9
+ src/md2pdf_tool.egg-info/PKG-INFO
10
+ src/md2pdf_tool.egg-info/SOURCES.txt
11
+ src/md2pdf_tool.egg-info/dependency_links.txt
12
+ src/md2pdf_tool.egg-info/entry_points.txt
13
+ src/md2pdf_tool.egg-info/requires.txt
14
+ src/md2pdf_tool.egg-info/top_level.txt
15
+ tests/test_build_pdf.py
16
+ tests/test_gen_pdf_shell.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ md2pdf = md2pdf.build_pdf:main
@@ -0,0 +1,3 @@
1
+ playwright>=1.40
2
+ PyMuPDF>=1.24
3
+ Pillow>=10.0
@@ -0,0 +1,33 @@
1
+ import unittest
2
+ from pathlib import Path
3
+
4
+ from md2pdf.build_pdf import build_metadata, default_output_path, selected_qa_pages
5
+
6
+
7
+ class Md2PdfUtilityTests(unittest.TestCase):
8
+ def test_default_output_path_reuses_source_stem(self):
9
+ self.assertEqual(
10
+ default_output_path(Path("docs/requirements.md")),
11
+ Path("docs/requirements.pdf"),
12
+ )
13
+
14
+ def test_build_metadata_preserves_chinese_fields(self):
15
+ metadata = build_metadata(
16
+ title="中文标题",
17
+ subtitle="中文副标题",
18
+ date="2026-06-03",
19
+ )
20
+
21
+ self.assertIn("title: 中文标题", metadata)
22
+ self.assertIn("subtitle: 中文副标题", metadata)
23
+ self.assertIn("date: 2026-06-03", metadata)
24
+ self.assertIn("lang: zh-CN", metadata)
25
+
26
+ def test_selected_qa_pages_deduplicates_small_documents(self):
27
+ self.assertEqual(selected_qa_pages(1), [0])
28
+ self.assertEqual(selected_qa_pages(2), [0, 1])
29
+ self.assertEqual(selected_qa_pages(70), [0, 1, 2, 35, 69])
30
+
31
+
32
+ if __name__ == "__main__":
33
+ unittest.main()
@@ -0,0 +1,43 @@
1
+ import subprocess
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ SCRIPT = ROOT / "gen_pdf.sh"
9
+
10
+
11
+ class GenPdfShellTests(unittest.TestCase):
12
+ def test_help_describes_input_and_optional_output(self):
13
+ result = subprocess.run(
14
+ ["bash", str(SCRIPT), "--help"],
15
+ cwd=ROOT,
16
+ text=True,
17
+ capture_output=True,
18
+ )
19
+
20
+ self.assertEqual(result.returncode, 0, result.stderr)
21
+ self.assertIn("Usage: ./gen_pdf.sh INPUT.md [OUTPUT.pdf]", result.stdout)
22
+
23
+ def test_dry_run_uses_first_heading_and_default_pdf_path(self):
24
+ with tempfile.TemporaryDirectory() as tmp:
25
+ source = Path(tmp) / "sample.md"
26
+ source.write_text("# 中文标题\n\n正文。\n", encoding="utf-8")
27
+
28
+ result = subprocess.run(
29
+ ["bash", str(SCRIPT), "--dry-run", str(source)],
30
+ cwd=ROOT,
31
+ text=True,
32
+ capture_output=True,
33
+ )
34
+
35
+ self.assertEqual(result.returncode, 0, result.stderr)
36
+ self.assertIn("python -m md2pdf.build_pdf", result.stdout)
37
+ self.assertIn(str(source.with_suffix(".pdf")), result.stdout)
38
+ self.assertIn("--title", result.stdout)
39
+ self.assertIn("中文标题", result.stdout)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ unittest.main()