ReportLab을 활용한 PDF 문서 생성 가이드

1. ReportLab 개요

1.1. 설치

pip 패키지 관리자를 통해 설치할 수 있습니다.

pip install reportlab

1.2. 기본 PDF 생성

다음은 ReportLab을 사용하여 텍스트와 이미지가 포함된 간단한 PDF 파일을 생성하는 예제입니다.

from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase import pdfmetrics

# 사용할 폰트 등록
font_name = "malgun"
pdfmetrics.registerFont(TTFont(font_name, "malgun.ttf"))

# PDF 문서 생성 (pagesize를 생략하면 A4)
c = canvas.Canvas("sample.pdf", pagesize=letter)
c.setFont(font_name, 12)

# 텍스트 추가 (좌표계 기준점은 페이지 좌측 하단)
c.drawString(10, 10, "Hello ReportLab!")

# 이미지 추가 (크기 지정)
c.drawImage('./sample_image.jpg', 10, 60, width=400, height=300)

# 문서 저장
c.save()

1.3. 한글 폰트 관련 오류 해결

reportlab.pdfbase.ttfonts.TTFError 오류가 발생하면, 시스템에 설치된 한글 폰트를 직접 등록해야 합니다. 주로 사용하는 폰트는 '맑은 고딕' (malgun.ttf) 또는 '굴림' (gulim.ttc) 등이며, C:\Windows\Fonts (Windows) 또는 /usr/share/fonts/ (Linux)에서 확인할 수 있습니다.

방법 1: 폰트 파일을 ReportLab 폰트 디렉토리로 복사

폰트 파일을 Python 사이트 패키지 내 ReportLab의 fonts 폴더로 복사합니다. Python 설치 경로는 python -m site 명령어로 확인 가능합니다.

방법 2: 시스템 폰트 디렉토리 활용

# 시스템 폰트 디렉토리 확인
ls -l /usr/share/fonts/
# 폰트 파일 복사 (예: truetype 하위로)
sudo cp font.ttc /usr/share/fonts/truetype/
# 폰트 캐시 갱신
fc-cache -f -v

그 후, 코드에서 절대 경로나 상대 경로를 사용하여 폰트를 등록합니다.

# 절대 경로 사용 예시
pdfmetrics.registerFont(TTFont('MyFont', '/usr/share/fonts/truetype/malgun.ttf'))

1.4. PDF 메타데이터 설정

생성된 PDF의 속성에 제목, 작성자 등을 명시적으로 설정하여 익명 문서로 표시되는 것을 방지할 수 있습니다.

from reportlab.platypus import BaseDocTemplate

class CustomDocTemplate(BaseDocTemplate):
    def __init__(self, filename, **kw):
        kw["title"] = "중요 문서 제목"   # 제목 설정
        kw["author"] = "홍길동"          # 작성자 설정
        BaseDocTemplate.__init__(self, filename, **kw)

2. 문서 생성 심화

2.1. DocTemplate (문서 템플릿) 기초

ReportLab의 핵심 사용 방식은 Flowable(흐름 요소) 객체를 생성하고, 문서 템플릿(DocTemplate)을 사용하여 PDF를 조립하는 것입니다. 주요 Flowable로는 Paragraph(문단), Image(이미지), Table(표), VerticalBarChart(세로 막대 차트) 등이 있습니다.

from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import Paragraph, SimpleDocTemplate, Image, Table, Spacer
from reportlab.graphics.shapes import Drawing
from reportlab.graphics.charts.barcharts import VerticalBarChart
from reportlab.graphics.charts.legends import Legend
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm

# 헬퍼 함수 정의
def create_paragraph(style, text):
    return Paragraph(text, style)

def create_image(path):
    img = Image(path)
    img.drawWidth = 6 * cm
    img.drawHeight = 5 * cm
    return img

def create_table(data_list):
    col_width = 120
    style = [
        ('FONTNAME', (0, 0), (-1, -1), 'korean_font'),
        ('FONTSIZE', (0, 0), (-1, 0), 12),
        ('FONTSIZE', (0, 1), (-1, -1), 10),
        ('BACKGROUND', (0, 0), (-1, 0), '#d5dae6'),
        ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
        ('ALIGN', (0, 1), (-1, -1), 'LEFT'),
        ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
        ('TEXTCOLOR', (0, 0), (-1, -1), colors.darkslategray),
        ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
        ('SPAN', (0, 1), (2, 1)),
    ]
    return Table(data_list, colWidths=col_width, style=style)

def create_bar_chart(data_sets, categories, legend_items):
    drawing = Drawing(500, 200)
    chart = VerticalBarChart()
    chart.x = 45
    chart.y = 45
    chart.height = 150
    chart.width = 350
    chart.data = data_sets
    chart.strokeColor = colors.black
    chart.valueAxis.valueMin = 0
    chart.valueAxis.valueMax = 20
    chart.valueAxis.valueStep = 5
    chart.categoryAxis.labels.dx = 2
    chart.categoryAxis.labels.dy = -8
    chart.categoryAxis.labels.angle = 20
    chart.categoryAxis.labels.fontName = 'korean_font'
    chart.categoryAxis.categoryNames = categories

    legend = Legend()
    legend.fontName = 'korean_font'
    legend.alignment = 'right'
    legend.boxAnchor = 'ne'
    legend.x = 475
    legend.y = 140
    legend.dxTextSpace = 10
    legend.columnMaximum = 3
    legend.colorNamePairs = legend_items

    drawing.add(legend)
    drawing.add(chart)
    return drawing

# 메인 생성 함수
def generate_report(filename):
    # 폰트 등록
    pdfmetrics.registerFont(TTFont('korean_font', 'malgun.ttf'))

    # 스타일 설정
    styles = getSampleStyleSheet()
    title_style = styles['Heading1']
    title_style.fontName = 'korean_font'
    title_style.fontSize = 18
    title_style.leading = 30
    title_style.alignment = 1

    subtitle_style = styles['Heading2']
    subtitle_style.fontName = 'korean_font'
    subtitle_style.fontSize = 15
    subtitle_style.leading = 20
    subtitle_style.textColor = colors.red

    normal_style = styles['Normal']
    normal_style.fontName = 'korean_font'
    normal_style.fontSize = 12
    normal_style.wordWrap = 'CJK'
    normal_style.alignment = 0
    normal_style.firstLineIndent = 32
    normal_style.leading = 20

    content = []
    content.append(create_paragraph(title_style, '게임 분석 보고서'))
    content.append(create_image('./image.jpg'))
    content.append(Spacer(1, 1 * cm))
    content.append(create_paragraph(normal_style, '슈퍼 마리오 브라더스는 1985년에 발매된 닌텐도의 대표 작품으로, 횡스크롤 액션 게임의 표준을 제시했습니다. 전 세계적으로 3,300만 장 이상 판매되며 게임 역사상 가장 성공적인 타이틀 중 하나로 남아 있습니다.'))
    content.append(create_paragraph(subtitle_style, '주요 게임 목록'))

    table_data = [
        ('게임 타이틀', '출시 연도', '개발사'),
        ('TOP100',),
        ('슈퍼 마리오 브라더스', '1985년', '닌텐도'),
        ('젤다의 전설', '1986년', '닌텐도'),
        ('스트리트 파이터 II', '1991년', '캡콤'),
        ('파이널 판타지 VII', '1997년', '스퀘어'),
    ]
    content.append(create_table(table_data))

    content.append(create_paragraph(subtitle_style, '개발사별 게임 수'))
    chart_data = [(2, 4, 6, 12, 8, 16), (12, 14, 17, 9, 12, 7)]
    chart_categories = ['닌텐도', '캡콤', '코나미', '스퀘어', '세가', 'SNK']
    legend_pairs = [(colors.red, '아케이드'), (colors.green, '콘솔')]
    content.append(create_bar_chart(chart_data, chart_categories, legend_pairs))

    doc = SimpleDocTemplate(filename, pagesize=A4, topMargin=35)
    doc.build(content)

if __name__ == '__main__':
    generate_report(filename='game_report.pdf')

2.2. PageTemplate (페이지 템플릿)을 이용한 다단 레이아웃

복잡한 페이지 레이아웃(예: 다단 구성)이 필요할 경우 PageTemplateFrame을 활용합니다.

from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib import colors
from reportlab.platypus import BaseDocTemplate, Frame, Paragraph, NextPageTemplate, PageBreak, PageTemplate, Image

def create_multi_column_doc(filename):
    pdfmetrics.registerFont(TTFont('korean_font', "malgun.ttf"))
    styles = getSampleStyleSheet()
    normal_style = styles['Normal']
    normal_style.fontName = 'korean_font'
    normal_style.fontSize = 12
    normal_style.wordWrap = 'CJK'
    normal_style.leading = 15

    doc = BaseDocTemplate(filename, showBoundary=0, pagesize=A4)
    frame_full = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id='full')
    w = doc.width / 3
    h = w
    top_margin = doc.height - h
    frame_left_top = Frame(doc.leftMargin, top_margin, w, h, id='top_left')
    frame_right_top = Frame(doc.leftMargin + w, top_margin, doc.width - w, h, id='top_right')
    frame_bottom = Frame(doc.leftMargin, doc.bottomMargin, doc.width, top_margin - doc.topMargin, id='bottom')

    doc.addPageTemplates([
        PageTemplate(id='ThreeCol', frames=[frame_left_top, frame_right_top, frame_bottom]),
        PageTemplate(id='OneCol', frames=[frame_full]),
    ])

    elements = []
    elements.append(Image("./image.jpg"))
    elements.append(Paragraph('첫 번째 열 텍스트입니다. ' * 20, normal_style))
    elements.append(Paragraph('두 번째 열 텍스트입니다. ' * 20, normal_style))
    elements.append(Paragraph('하단 영역 텍스트입니다. ' * 30, normal_style))

    elements.append(NextPageTemplate('OneCol'))
    elements.append(PageBreak())
    elements.append(Paragraph('단일 컬럼 페이지입니다. ' * 40, normal_style))

    doc.build(elements)

if __name__ == '__main__':
    create_multi_column_doc('multi_column.pdf')

2.3. BaseDocTemplate 상속을 통한 목차 및 책갈피 생성

BaseDocTemplate을 상속받아 afterFlowable 메서드를 오버라이드하면 PDF 뷰어에서 사용할 수 있는 목차(책갈피)를 생성할 수 있습니다.

from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import PageBreak
from reportlab.platypus.paragraph import Paragraph
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus.frames import Frame
from reportlab.lib.units import cm

class TOCDocTemplate(BaseDocTemplate):
    def __init__(self, filename, **kw):
        self.allowSplitting = 0
        BaseDocTemplate.__init__(self, filename, **kw)
        frame = Frame(2.5*cm, 2.5*cm, 15*cm, 25*cm, id='main_frame')
        self.addPageTemplates(PageTemplate('normal', [frame]))
        self.chapter_num = 0
        self.section_num = 0

    def afterFlowable(self, flowable):
        if isinstance(flowable, Paragraph):
            text = flowable.getPlainText()
            style_name = flowable.style.name
            if style_name == 'Title':
                self.chapter_num += 1
                bookmark_key = f"chapter_{self.chapter_num}"
                self.canv.bookmarkPage(bookmark_key)
                self.canv.addOutlineEntry(text, bookmark_key, level=0)
            elif style_name == 'Heading1':
                self.section_num += 1
                bookmark_key = f"section_{self.section_num}"
                self.canv.bookmarkPage(bookmark_key)
                self.canv.addOutlineEntry(text, bookmark_key, level=1)

def create_doc_with_toc(filename):
    pdfmetrics.registerFont(TTFont('korean_font', "malgun.ttf"))

    title_style = ParagraphStyle(name='Title',
        fontName='korean_font', fontSize=18, leading=22, alignment=1, spaceAfter=20)
    heading_style = ParagraphStyle(name='Heading1',
        fontName='korean_font', fontSize=14, leading=18)

    story = []
    story.append(Paragraph('1장: 서론', title_style))
    story.append(Paragraph('1.1 배경', heading_style))
    story.append(Paragraph('여기는 1.1절의 내용입니다.'))
    story.append(PageBreak())
    story.append(Paragraph('1.2 목적', heading_style))
    story.append(Paragraph('여기는 1.2절의 내용입니다.'))
    story.append(PageBreak())
    story.append(Paragraph('2장: 본론', title_style))
    story.append(Paragraph('2.1 방법', heading_style))
    story.append(Paragraph('여기는 2.1절의 내용입니다.'))

    doc = TOCDocTemplate(filename)
    doc.build(story)

if __name__ == '__main__':
    create_doc_with_toc('document_with_toc.pdf')

2.4. SimpleDocTemplate을 이용한 머리글/바닥글

SimpleDocTemplateonFirstPageonLaterPages 콜백을 이용하여 머리글(header)과 바닥글(footer)을 손쉽게 추가할 수 있습니다.

from reportlab.platypus import SimpleDocTemplate, Paragraph, PageBreak
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont

def add_header_footer(canvas_obj, doc):
    canvas_obj.saveState()
    canvas_obj.setFont('korean_font', 10)
    # 머리글
    canvas_obj.setFillColorRGB(1, 0, 0)
    canvas_obj.drawCentredString(doc.width / 2, A4[1] - 9*mm, "주식회사 예시")

    # 구분선
    canvas_obj.setStrokeColorRGB(0.8, 0.8, 0.8)
    canvas_obj.line(0, A4[1] - 10*mm, doc.width, A4[1] - 10*mm)

    # 바닥글
    canvas_obj.line(0, 10*mm, doc.width, 10*mm)
    canvas_obj.setFillColorRGB(0, 0, 0)
    page_num_text = f"페이지 {doc.page}"
    canvas_obj.drawCentredString(doc.width / 2, 5*mm, page_num_text)
    canvas_obj.restoreState()

def create_report_with_hf(filename):
    pdfmetrics.registerFont(TTFont('korean_font', "malgun.ttf"))

    title_style = ParagraphStyle(name='Title',
        fontName='korean_font', fontSize=22, leading=26, alignment=1)
    doc = SimpleDocTemplate(filename, pagesize=A4, leftMargin=20, rightMargin=20)

    contents = []
    contents.append(Paragraph('보고서 제목', title_style))
    contents.append(PageBreak())
    contents.append(Paragraph('첫 번째 페이지 본문입니다.'))
    contents.append(PageBreak())
    contents.append(Paragraph('두 번째 페이지 본문입니다.'))

    # 첫 페이지와 이후 페이지에 동일한 머리글/바닥글 함수 적용
    doc.build(contents, onFirstPage=add_header_footer, onLaterPages=add_header_footer)

if __name__ == '__main__':
    create_report_with_hf('report_with_hf.pdf')

2.5. Canvas 상속을 통한 페이지 번호 제어

canvas.Canvas를 직접 상속받아 '현재 페이지 / 전체 페이지' 형태의 번호를 구현할 수 있습니다.

from reportlab.platypus import SimpleDocTemplate, Paragraph, PageBreak
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont

class PageNumberCanvas(canvas.Canvas):
    def __init__(self, *args, **kwargs):
        canvas.Canvas.__init__(self, *args, **kwargs)
        self._saved_states = []

    def showPage(self):
        self._saved_states.append(dict(self.__dict__))
        self._startPage()

    def save(self):
        total_pages = len(self._saved_states)
        for state in self._saved_states:
            self.__dict__.update(state)
            self._draw_page_number(total_pages)
            canvas.Canvas.showPage(self)
        canvas.Canvas.save(self)

    def _draw_page_number(self, total_count):
        self.setFont("Helvetica", 9)
        self.drawCentredString(A4[0] / 2, 15*mm, f"Page {self._pageNumber} of {total_count}")

def create_doc_with_page_num(filename):
    pdfmetrics.registerFont(TTFont('korean_font', "malgun.ttf"))
    title_style = ParagraphStyle(name='Title',
        fontName='korean_font', fontSize=22, leading=26, alignment=1)

    elements = [
        Paragraph('페이지 번호 예제', title_style),
        Paragraph("첫 페이지 내용"),
        PageBreak(),
        Paragraph("두 번째 페이지 내용"),
        PageBreak(),
        Paragraph("세 번째 페이지 내용"),
    ]
    doc = SimpleDocTemplate(filename)
    doc.build(elements, canvasmaker=PageNumberCanvas)

if __name__ == "__main__":
    create_doc_with_page_num('page_number_doc.pdf')

2.6. Canvas 직접 사용 (고급/복잡한 레이아웃)

매우 복잡하거나 정밀한 좌표 제어가 필요한 경우 canvas.Canvas를 직접 사용할 수 있습니다. Flowable 객체도 wrap()drawOn() 메서드를 통해 캔버스 위에 배치할 수 있습니다.

from reportlab.pdfgen import canvas
from reportlab.platypus import Image, Table
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.graphics.shapes import Drawing
from reportlab.graphics.charts.barcharts import VerticalBarChart
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import Paragraph

def create_complex_pdf(filename):
    pdfmetrics.registerFont(TTFont('korean_font', "malgun.ttf"))
    c = canvas.Canvas(filename, pagesize=A4)
    width, height = A4

    # 텍스트 출력
    c.setFont('korean_font', 12)
    c.drawString(20*mm, height - 20*mm, "정밀 제어가 필요한 PDF")

    # 이미지 출력
    img = Image('./image.jpg')
    img.drawWidth = 100
    img.drawHeight = 80
    img.drawOn(c, 20*mm, height - 100*mm)

    # Flowable (Paragraph) 배치
    styles = getSampleStyleSheet()
    para_style = styles['Normal']
    para_style.fontName = 'korean_font'
    para = Paragraph('이것은 Canvas 위에 배치된 문단입니다. ' * 10, para_style)
    para_w, para_h = para.wrap(width - 40*mm, height)
    para.drawOn(c, 20*mm, height - 150*mm - para_h)

    c.save()

if __name__ == '__main__':
    create_complex_pdf('complex_layout.pdf')

태그: ReportLab PDF 생성 python 문서 템플릿 페이지 레이아웃

6월 25일 18:11에 게시됨