md2pdf.py ダウンロード/コピー

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)