← AI開発 資料アーカイブ
ビルド/生成スクリプト

変換スクリプト: 2つのMarkdownをPDF化(markdown+reportlab)

元ファイル: システム要件定義の分析と汎用化方法/convert_to_pdf.py

要約

汎用的に2つのMarkdownファイルをPDFへ変換するPythonスクリプト。markdownライブラリとreportlabを組み合わせ、タイトル/H1/H2などのカスタムスタイルを定義してA4 PDFを生成する。TTFontによる日本語フォント埋め込みにも対応する構成。

要点

PythonreportlabPDF生成Markdown変換

#!/usr/bin/env python3
"""
2つのMarkdownファイルをPDFに変換するスクリプト
reportlab + markdown を使用
"""
import markdown
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
from reportlab.lib.enums import TA_LEFT, TA_CENTER
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import re
import os

def md_to_pdf(input_path, output_path):
    """MarkdownファイルをPDFに変換する"""
    print(f"Converting {input_path} -> {output_path}")
    
    with open(input_path, 'r', encoding='utf-8') as f:
        content = f.read()
    
    # スタイル設定
    styles = getSampleStyleSheet()
    
    # カスタムスタイル
    title_style = ParagraphStyle(
        'CustomTitle',
        parent=styles['Heading1'],
        fontSize=20,
        spaceAfter=12,
        textColor=colors.HexColor('#1a1a2e'),
        fontName='Helvetica-Bold',
    )
    h1_style = ParagraphStyle(
        'CustomH1',
        parent=styles['Heading1'],
        fontSize=16,
        spaceAfter=8,
        spaceBefore=16,
        textColor=colors.HexColor('#0d3b66'),
        fontName='Helvetica-Bold',
    )
    h2_style = ParagraphStyle(
        'CustomH2',
        parent=styles['Heading2'],
        fontSize=13,
        spaceAfter=6,
        spaceBefore=12,
        textColor=colors.HexColor('#1b4f72'),
        fontName='Helvetica-Bold',
    )
    h3_style = ParagraphStyle(
        'CustomH3',
        parent=styles['Heading3'],
        fontSize=11,
        spaceAfter=4,
        spaceBefore=8,
        textColor=colors.HexColor('#2e86c1'),
        fontName='Helvetica-Bold',
    )
    body_style = ParagraphStyle(
        'CustomBody',
        parent=styles['Normal'],
        fontSize=9,
        spaceAfter=4,
        leading=14,
        fontName='Helvetica',
    )
    code_style = ParagraphStyle(
        'CustomCode',
        parent=styles['Code'],
        fontSize=7.5,
        spaceAfter=4,
        spaceBefore=4,
        leading=11,
        fontName='Courier',
        backColor=colors.HexColor('#f5f5f5'),
        borderPadding=(4, 4, 4, 4),
    )
    bullet_style = ParagraphStyle(
        'CustomBullet',
        parent=styles['Normal'],
        fontSize=9,
        spaceAfter=2,
        leading=13,
        leftIndent=12,
        fontName='Helvetica',
        bulletIndent=4,
    )
    
    # ドキュメント作成
    doc = SimpleDocTemplate(
        output_path,
        pagesize=A4,
        rightMargin=20*mm,
        leftMargin=20*mm,
        topMargin=20*mm,
        bottomMargin=20*mm,
    )
    
    story = []
    lines = content.split('\n')
    in_code_block = False
    code_lines = []
    
    i = 0
    while i < len(lines):
        line = lines[i]
        
        # コードブロック開始/終了
        if line.strip().startswith('```'):
            if in_code_block:
                # コードブロック終了
                code_text = '\n'.join(code_lines)
                # 長すぎる行を折り返す
                wrapped_lines = []
                for cl in code_lines:
                    if len(cl) > 90:
                        cl = cl[:87] + '...'
                    wrapped_lines.append(cl.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;'))
                code_text_safe = '\n'.join(wrapped_lines)
                if code_text_safe.strip():
                    try:
                        story.append(Paragraph(f'<font name="Courier" size="7.5"><pre>{code_text_safe}</pre></font>', code_style))
                    except:
                        story.append(Spacer(1, 2*mm))
                code_lines = []
                in_code_block = False
            else:
                in_code_block = True
            i += 1
            continue
        
        if in_code_block:
            code_lines.append(line)
            i += 1
            continue
        
        # 空行
        if not line.strip():
            story.append(Spacer(1, 2*mm))
            i += 1
            continue
        
        # 水平線
        if line.strip().startswith('---') and len(line.strip()) >= 3:
            story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cccccc'), spaceAfter=4, spaceBefore=4))
            i += 1
            continue
        
        # 見出し
        if line.startswith('# '):
            text = line[2:].strip()
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            try:
                story.append(Paragraph(text_safe, title_style))
            except:
                pass
        elif line.startswith('## '):
            text = line[3:].strip()
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            try:
                story.append(Paragraph(text_safe, h1_style))
            except:
                pass
        elif line.startswith('### '):
            text = line[4:].strip()
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            try:
                story.append(Paragraph(text_safe, h2_style))
            except:
                pass
        elif line.startswith('#### '):
            text = line[5:].strip()
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            try:
                story.append(Paragraph(text_safe, h3_style))
            except:
                pass
        # 箇条書き
        elif line.strip().startswith('- ') or line.strip().startswith('* '):
            text = re.sub(r'^[\s\-\*]+', '', line).strip()
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            # **bold** 処理
            text_safe = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text_safe)
            text_safe = re.sub(r'`(.+?)`', r'<font name="Courier">\1</font>', text_safe)
            indent = len(line) - len(line.lstrip())
            left = 12 + (indent // 2) * 8
            bs = ParagraphStyle('BulletIndent', parent=bullet_style, leftIndent=left)
            try:
                story.append(Paragraph(f'• {text_safe}', bs))
            except:
                pass
        # 番号付きリスト
        elif re.match(r'^\d+\. ', line.strip()):
            text = re.sub(r'^\d+\. ', '', line.strip())
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            text_safe = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text_safe)
            try:
                story.append(Paragraph(text_safe, bullet_style))
            except:
                pass
        # 引用
        elif line.strip().startswith('> '):
            text = line.strip()[2:]
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            qs = ParagraphStyle('Quote', parent=body_style, leftIndent=12, textColor=colors.HexColor('#555555'), borderPadding=(2, 2, 2, 8))
            try:
                story.append(Paragraph(f'<i>{text_safe}</i>', qs))
            except:
                pass
        # テーブル行はスキップ(複雑なため)
        elif line.strip().startswith('|'):
            text_safe = line.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            try:
                story.append(Paragraph(f'<font name="Courier" size="7">{text_safe}</font>', body_style))
            except:
                pass
        # 通常テキスト
        else:
            text = line.strip()
            if not text:
                i += 1
                continue
            text_safe = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
            text_safe = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text_safe)
            text_safe = re.sub(r'`(.+?)`', r'<font name="Courier">\1</font>', text_safe)
            text_safe = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text_safe)
            try:
                story.append(Paragraph(text_safe, body_style))
            except:
                pass
        
        i += 1
    
    # PDF生成
    doc.build(story)
    print(f"  -> Done: {output_path}")

if __name__ == '__main__':
    md_to_pdf('/home/ubuntu/MASTER_CLAUDE.md', '/home/ubuntu/MASTER_CLAUDE.pdf')
    md_to_pdf('/home/ubuntu/START_PROMPT.md', '/home/ubuntu/START_PROMPT.pdf')
    print("All PDFs generated successfully!")

↑ トップへ戻る