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.
- md2pdf_tool-0.1.0/LICENSE +21 -0
- md2pdf_tool-0.1.0/MANIFEST.in +6 -0
- md2pdf_tool-0.1.0/PKG-INFO +123 -0
- md2pdf_tool-0.1.0/README.md +101 -0
- md2pdf_tool-0.1.0/gen_pdf.sh +47 -0
- md2pdf_tool-0.1.0/pyproject.toml +50 -0
- md2pdf_tool-0.1.0/setup.cfg +4 -0
- md2pdf_tool-0.1.0/src/md2pdf/__init__.py +1 -0
- md2pdf_tool-0.1.0/src/md2pdf/build_pdf.py +326 -0
- md2pdf_tool-0.1.0/src/md2pdf/style.css +386 -0
- md2pdf_tool-0.1.0/src/md2pdf_tool.egg-info/PKG-INFO +123 -0
- md2pdf_tool-0.1.0/src/md2pdf_tool.egg-info/SOURCES.txt +16 -0
- md2pdf_tool-0.1.0/src/md2pdf_tool.egg-info/dependency_links.txt +1 -0
- md2pdf_tool-0.1.0/src/md2pdf_tool.egg-info/entry_points.txt +2 -0
- md2pdf_tool-0.1.0/src/md2pdf_tool.egg-info/requires.txt +3 -0
- md2pdf_tool-0.1.0/src/md2pdf_tool.egg-info/top_level.txt +1 -0
- md2pdf_tool-0.1.0/tests/test_build_pdf.py +33 -0
- md2pdf_tool-0.1.0/tests/test_gen_pdf_shell.py +43 -0
|
@@ -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,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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
md2pdf
|
|
@@ -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()
|