md2pdf.py ダウンロード/コピー
md2pdf.py
md2pdf.py
1"""
2MarkdownファイルをPDFに変換するユーティリティ。
3
4概要:
5 MarkdownファイルをPDFに変換するユーティリティモジュールです。
6詳細説明:
7 MarkdownテキストをHTMLに変換し、さらにFPDFライブラリを使用してPDFドキュメントとして
8 レンダリングします。日本語フォントの自動検出や、コードブロック、箇条書き、見出しなどの
9 基本的なMarkdown要素のサポートを含みます。
10関連リンク:
11 md2pdf_usage
12"""
13import os
14import re
15from pathlib import Path
16
17import markdown
18from fpdf import FPDF, XPos, YPos
19
20
21def insert_soft_breaks(s: str, hard_chunk=60) -> str:
22 """
23 概要:
24 改行できない長大トークンにゼロ幅スペースを挿入して折り返しを許可します。
25 詳細説明:
26 URLやファイルパスなどの連続した文字の塊、または指定されたhard_chunkよりも
27 長いトークンに対して、ゼロ幅スペース(U+200B)を挿入することで、PDFレンダリング時に
28 テキストが適切に折り返されるようにします。これにより、レイアウトの崩れを防ぎます。
29 引数:
30 :param s: 処理対象の文字列。
31 :type s: str
32 :param hard_chunk: ゼロ幅スペースを挿入する最小の文字塊の長さ。
33 :type hard_chunk: int
34 戻り値:
35 :returns: ゼロ幅スペースが挿入された文字列。
36 :rtype: str
37 """
38 s = re.sub(r'([/_\-.&?=#:])', r'\1\u200b', s)
39
40 def break_long_token(m):
41 token = m.group(0)
42 out = []
43 for i in range(0, len(token), hard_chunk):
44 out.append(token[i:i + hard_chunk])
45 return '\u200b'.join(out)
46
47 s = re.sub(r'[^\s\u200b]{' + str(hard_chunk * 2) + r',}', break_long_token, s)
48 return s
49
50
51def md_to_html(md_text: str) -> str:
52 """
53 概要:
54 MarkdownテキストをHTMLに変換します。
55 詳細説明:
56 Python-Markdownライブラリを使用し、追加の拡張機能
57 (extra, fenced_code, tables)を有効にしてMarkdownテキストをHTML文字列に変換します。
58 引数:
59 :param md_text: 変換するMarkdown形式の文字列。
60 :type md_text: str
61 戻り値:
62 :returns: 変換されたHTML形式の文字列。
63 :rtype: str
64 """
65 return markdown.markdown(md_text, extensions=["extra", "fenced_code", "tables"])
66
67
68def extract_code_blocks(html: str):
69 """
70 概要:
71 HTML文字列からpreとcodeタグ形式のコードブロックを抽出しプレースホルダに置換します。
72 詳細説明:
73 FPDFがHTMLのコードブロックを直接処理できないため、この関数はコードブロックのコンテンツを
74 一時的なリストに保存し、元のHTML内では@@@CODEBLOCK_N@@@という形式のプレースホルダに置き換えます。
75 これにより、HTMLの残りの部分が通常の段落として処理された後、コードブロックを個別にレンダリングできます。
76 引数:
77 :param html: 処理対象のHTML文字列。
78 :type html: str
79 戻り値:
80 :returns: プレースホルダに置換されたHTML文字列と、抽出されたコードブロックのリストのタプル。
81 :rtype: tuple
82 """
83 code_list = []
84
85 def repl(m):
86 code = m.group(1)
87 code_list.append(code)
88 return f"@@@CODEBLOCK_{len(code_list) - 1}@@@"
89
90 html2 = re.sub(
91 r"<pre><code>(.*?)</code></pre>",
92 repl,
93 html,
94 flags=re.IGNORECASE | re.DOTALL,
95 )
96 return html2, code_list
97
98
99def find_font_path(font_path=None):
100 """
101 概要:
102 システム上の日本語フォントのパスを検索します。
103 詳細説明:
104 指定されたフォントパスを最優先で確認し、その後、一般的なWindows、macOS、Linuxの
105 日本語フォントパスの候補を探索します。最初に見つかった既存のファイルのパスを返します。
106 これにより、異なるOS環境でも日本語が正しく表示されるようにします。
107 引数:
108 :param font_path: ユーザーが指定したフォントファイルのパス。Noneの場合はデフォルトを検索します。
109 :type font_path: str or None
110 戻り値:
111 :returns: 見つかったフォントファイルの絶対パス文字列、または見つからなかった場合はNone。
112 :rtype: str or None
113 """
114 candidates = []
115 if font_path:
116 candidates.append(Path(font_path))
117
118 candidates.append(Path(r"C:\Windows\Fonts\msgothic.ttc"))
119 candidates += [
120 Path("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc"),
121 Path("/Library/Fonts/Arial Unicode.ttf"),
122 Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
123 Path("/usr/share/fonts/truetype/noto/NotoSansCJKjp-Regular.otf"),
124 ]
125
126 for p in candidates:
127 if p.is_file():
128 return str(p)
129 return None
130
131
132class PDF(FPDF):
133 """
134 概要:
135 MarkdownコンテンツをPDFとしてレンダリングするためのFPDF拡張クラス。
136 詳細説明:
137 FPDFの基本機能に加え、H1、H2、段落、箇条書き、コードブロックといった
138 Markdownの要素をPDFに適切に描画するためのカスタムメソッドを提供します。
139 ページのヘッダーとフッターは表示されません。
140 """
141 def header(self):
142 """
143 概要:
144 ページのヘッダーを生成しないようにオーバーライドします。
145 """
146 pass
147
148 def footer(self):
149 """
150 概要:
151 ページのフッターを生成しないようにオーバーライドします。
152 """
153 pass
154
155 def _body_width(self) -> float:
156 """
157 概要:
158 PDFドキュメントのボディ部分の幅を計算します。
159 詳細説明:
160 左右のマージンを除いた幅を計算します。
161 戻り値:
162 :returns: ボディ部分の幅のミリメートル単位の数値。
163 :rtype: float
164 """
165 return self.w - self.l_margin - self.r_margin
166
167 def h1(self, text: str):
168 """
169 概要:
170 H1見出しをPDFに書き込みます。
171 詳細説明:
172 指定されたテキストを大きなフォントサイズで描画し、前後に余白を追加します。
173 引数:
174 :param text: H1見出しのテキスト。
175 :type text: str
176 """
177 self.set_font("DOCFONT", "", 18)
178 self.ln(2)
179 self.multi_cell(self._body_width(), 10, text, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
180 self.ln(1)
181
182 def h2(self, text: str):
183 """
184 概要:
185 H2見出しをPDFに書き込みます。
186 詳細説明:
187 指定されたテキストをH1よりわずかに小さいフォントサイズで描画し、前後に余白を追加します。
188 引数:
189 :param text: H2見出しのテキスト。
190 :type text: str
191 """
192 self.set_font("DOCFONT", "", 16)
193 self.ln(1)
194 self.multi_cell(self._body_width(), 9, text, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
195
196 def para(self, text: str):
197 """
198 概要:
199 標準の段落テキストをPDFに書き込みます。
200 詳細説明:
201 指定されたテキストを通常のフォントサイズで描画します。
202 テキストが長すぎてFPDFのmulti_cellで処理できない場合、
203 insert_soft_breaks関数を使用してゼロ幅スペースを挿入し、折り返しを試みます。
204 引数:
205 :param text: 段落のテキスト。
206 :type text: str
207 """
208 self.set_font("DOCFONT", "", 12)
209 try:
210 self.multi_cell(self._body_width(), 7, text)
211 except Exception:
212 self.multi_cell(self._body_width(), 7, insert_soft_breaks(text))
213 self.ln(0.5)
214
215 def bullet(self, text: str):
216 """
217 概要:
218 箇条書き項目をPDFに書き込みます。
219 詳細説明:
220 ビュレット記号を先頭に付加し、残りのテキストを右にインデントして描画します。
221 テキストが長すぎてFPDFのmulti_cellで処理できない場合、
222 insert_soft_breaks関数を使用してゼロ幅スペースを挿入し、折り返しを試みます。
223 引数:
224 :param text: 箇条書き項目のテキスト。
225 :type text: str
226 """
227 self.set_font("DOCFONT", "", 12)
228 bullet_w = 6
229 remain = self._body_width() - bullet_w
230 if remain < 20:
231 return self.para(f"• {text}")
232 self.cell(bullet_w, 7, "• ")
233 try:
234 self.multi_cell(remain, 7, text)
235 except Exception:
236 self.multi_cell(remain, 7, insert_soft_breaks(text))
237
238 def code_block(self, code: str):
239 """
240 概要:
241 コードブロックをPDFに書き込みます。
242 詳細説明:
243 コードブロックは背景色がグレーのボックスで囲まれ、コードがその中に表示されます。
244 コードの行が長い場合、insert_soft_breaks関数を使用してゼロ幅スペースを挿入し、
245 適切に折り返されるようにします。
246 引数:
247 :param code: コードブロック内のテキスト。
248 :type code: str
249 """
250 self.set_font("DOCFONT", "", 11)
251 lines = code.replace("\t", " ").splitlines() or [""]
252 h = max(7 * len(lines) + 6, 14)
253 x0, y0 = self.get_x(), self.get_y()
254 w = self._body_width()
255
256 self.set_fill_color(245, 245, 245)
257 self.rect(x0, y0, w, h, style="F")
258 self.set_draw_color(200, 200, 200)
259 self.rect(x0, y0, w, h)
260
261 self.set_xy(x0 + 2, y0 + 3)
262 for ln in lines:
263 ln2 = insert_soft_breaks(ln, hard_chunk=80)
264 self.multi_cell(w - 4, 6, ln2)
265 self.ln(3)
266
267
268def html_to_pdf(html: str, output_filename: str, font_path=None):
269 """
270 概要:
271 HTML文字列をPDFファイルに変換します。
272 詳細説明:
273 HTMLを解析し、PDFクラスのメソッドを使用してコンテンツをレンダリングします。
274 事前にextract_code_blocksで抽出されたコードブロックは、この関数内で
275 PDFのコードブロックとして特別に処理されます。また、日本語フォントの検出と設定も行われます。
276 引数:
277 :param html: 変換するHTML形式の文字列。
278 :type html: str
279 :param output_filename: 出力するPDFファイルのパス。
280 :type output_filename: str
281 :param font_path: 使用する日本語フォントファイルのパス。Noneの場合はデフォルトを検索します。
282 :type font_path: str or None
283 例外:
284 :raises FileNotFoundError: 日本語フォントが見つからない場合。
285 """
286 html, code_list = extract_code_blocks(html)
287
288 pdf = PDF(format="A4")
289 pdf.set_auto_page_break(auto=True, margin=15)
290 pdf.add_page()
291
292 chosen_font = find_font_path(font_path)
293 if not chosen_font:
294 raise FileNotFoundError(
295 "Japanese font not found. Specify a TTF/OTF/TTC font path."
296 )
297
298 pdf.add_font("DOCFONT", "", chosen_font)
299
300 lines = html.splitlines()
301
302 for raw in lines:
303 s = raw.strip()
304 if not s:
305 continue
306
307 m = re.match(r"^@@@CODEBLOCK_(\d+)@@@$", s)
308 if m:
309 idx = int(m.group(1))
310 code = code_list[idx]
311 pdf.code_block(code)
312 continue
313
314 m = re.match(r"^<h1[^>]*>(.*?)</h1>\s*$", s, re.IGNORECASE)
315 if m:
316 pdf.h1(re.sub(r"<[^>]+>", "", m.group(1)))
317 continue
318
319 m = re.match(r"^<h2[^>]*>(.*?)</h2>\s*$", s, re.IGNORECASE)
320 if m:
321 pdf.h2(re.sub(r"<[^>]+>", "", m.group(1)))
322 continue
323
324 m = re.match(r"^<li[^>]*>(.*?)</li>\s*$", s, re.IGNORECASE)
325 if m:
326 text = re.sub(r"<[^>]+>", "", m.group(1))
327 pdf.bullet(text)
328 continue
329
330 m = re.match(r"^<p[^>]*>(.*?)</p>\s*$", s, re.IGNORECASE)
331 if m:
332 text = re.sub(r"<[^>]+>", "", m.group(1))
333 pdf.para(text)
334 continue
335
336 # タグが一致しない場合は、タグを除去して通常の段落として処理
337 pdf.para(re.sub(r"<[^>]+>", "", s))
338
339 pdf.output(output_filename)
340
341
342def md_to_pdf(input_path, output_path=None, font_path=None):
343 """
344 概要:
345 MarkdownファイルをPDFに変換します。
346 詳細説明:
347 指定されたMarkdownファイルを読み込み、md_to_htmlでHTMLに変換後、
348 html_to_pdfで最終的なPDFファイルを作成します。
349 入力ファイルの存在チェック、空ファイルチェック、エラーハンドリングを行います。
350 出力パスが指定されない場合は、入力ファイル名と同じ名前で拡張子をpdfに変更して出力します。
351 引数:
352 :param input_path: 入力となるMarkdownファイルのパス。
353 :type input_path: str
354 :param output_path: 出力するPDFファイルのパス。Noneの場合は入力ファイルパスから自動生成します。
355 :type output_path: str or None
356 :param font_path: 使用する日本語フォントファイルのパス。Noneの場合はデフォルトを検索します。
357 :type font_path: str or None
358 戻り値:
359 :returns: 変換に成功した場合は出力PDFファイルの絶対パス、失敗した場合はNone。
360 :rtype: str or None
361 """
362 input_path = os.path.abspath(input_path)
363 if output_path is None:
364 output_path = os.path.splitext(input_path)[0] + ".pdf"
365 else:
366 output_path = os.path.abspath(output_path)
367
368 print(f" Converting '{os.path.basename(input_path)}' to PDF...")
369
370 try:
371 if not os.path.exists(input_path):
372 print(f" Error converting '{input_path}' to PDF: file does not exist")
373 return None
374
375 if os.path.getsize(input_path) == 0:
376 print(f" Error converting '{input_path}' to PDF: file is empty")
377 return None
378
379 md_text = Path(input_path).read_text(encoding="utf-8")
380 html = md_to_html(md_text)
381 html_to_pdf(html, output_path, font_path=font_path)
382
383 print(f" Successfully converted to PDF: '{output_path}'")
384 return output_path
385
386 except Exception as e:
387 print(f" Error converting '{input_path}' to PDF: {e}")
388 return None
389
390
391if __name__ == "__main__":
392 import argparse
393
394 parser = argparse.ArgumentParser(
395 description="Convert Markdown to PDF (simple, Japanese-capable)."
396 )
397 parser.add_argument("input_path", help="input markdown file path")
398 parser.add_argument("output_path", nargs="?", default=None, help="output pdf file path")
399 parser.add_argument("--font", default=None, help="path to a TTF/OTF/TTC font for Japanese")
400 parser.add_argument("--no-pause", action="store_true", help="do not wait for ENTER before exit")
401 args = parser.parse_args()
402
403 rc = 0 if md_to_pdf(args.input_path, args.output_path, font_path=args.font) else 1
404 print("\nProgram execution completed.")
405 if not args.no_pause:
406 input("\nPress ENTER to terminate>>\n")
407 raise SystemExit(rc)