md2pdf-tool 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Project-local Markdown to PDF tooling."""
md2pdf/build_pdf.py ADDED
@@ -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())
md2pdf/style.css ADDED
@@ -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,9 @@
1
+ md2pdf/__init__.py,sha256=7to4ec_9vfA481_Qlwk59tw9N0pSH3Kq1ltZg7K1SS8,45
2
+ md2pdf/build_pdf.py,sha256=QoIeGe7aYxxe9PCqy3IufrBQ0I5Q7AeXRhn6eYTRNdk,10828
3
+ md2pdf/style.css,sha256=gaSwKmyE-dDPzU5InzW5zKHj0xMDOVkxRuOhF-iR_og,6075
4
+ md2pdf_tool-0.1.0.dist-info/licenses/LICENSE,sha256=U7osqnwcvwZ9lfrko8c6DX7EdYrZhR1vxTDojcUmxvI,1062
5
+ md2pdf_tool-0.1.0.dist-info/METADATA,sha256=GGVcERW2Fo1PC3MNLUt9MBujIBa_jG_zchYF6hrR8W0,4080
6
+ md2pdf_tool-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ md2pdf_tool-0.1.0.dist-info/entry_points.txt,sha256=ZirRxv-qFAMWM9zGihdWxJIW0h54VsHK3ZnfHvG1-ys,49
8
+ md2pdf_tool-0.1.0.dist-info/top_level.txt,sha256=M6w-SJJb4vYpGkCZOC_PLJ47_mldkUsf0HvtDmdhJJ0,7
9
+ md2pdf_tool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ md2pdf = md2pdf.build_pdf:main
@@ -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 @@
1
+ md2pdf