Skip to content

PDF

PDF 문서에서 구조화된 텍스트와 테이블 추출하기

PDF 문서는 다양한 레이아웃과 서식을 가지며, 그 안에서 유의미한 정보를 자동으로 추출하는 것은 쉽지 않다. 특히 한국어 문서의 경우 문단 구조, 표, 목록 등의 다양한 요소가 뒤섞여 있어 정제된 텍스트를 얻기 위해서는 복잡한 후처리 과정이 필요하다. 본 글에서는 pdfminer.six 라이브러리를 활용하여 PDF 문서로부터 제목, 일반 문단, 표 형식의 데이터를 구분하여 통합적으로 추출하고, 사람이 읽기 쉬운 형태로 가공하는 전체 파이프라인을 설명한다.

핵심 목표

  • PDF 문서에서 텍스트, 제목, 목록, 표 등의 유형을 구분하여 추출

  • Y 좌표 및 X 좌표 기반으로 텍스트를 정렬 및 그룹화

  • 테이블 형태를 감지하여 행 단위로 구성

  • 결과를 마크다운 형태에 가까운 구조로 출력

  • 추가적으로 전체 텍스트 및 테이블을 별도로 백업

사용 라이브러리

  • pdfminer.six: PDF 문서의 레이아웃 분석 및 텍스트 추출을 담당

  • re: 정규표현식을 통한 텍스트 패턴 분석

  • pandas: 테이블 구조 저장 시 사용 가능

  • os, sys: 파일 입출력 및 경로 제어

주요 처리 단계

1. 텍스트 전처리 (clean_text)

텍스트를 추출한 이후에는 다음과 같은 정제를 수행한다:

  • 연속된 공백과 개행 제거

  • 선행/후행 공백 제거

text = re.sub(r'\s+', ' ', text)
text = re.sub(r'\n\s*\n\s*\n', '\n\n', text)
text = text.strip()

2. 텍스트 유형 분류 (classify_text_type)

문단을 다음 4가지로 분류한다:

  • 제목 (title)

  • 일반 텍스트 (text)

  • 목록 (list)

  • 표 (table)

정규표현식을 통해 제1조, 1. 개요, 가) 설명 등의 패턴을 판별한다. 표에 대해서는 날짜, 시간, 숫자 등 반복되는 숫자 패턴 및 키워드 기반으로 감지한다.

3. 페이지별 텍스트 및 테이블 통합 추출 (extract_unified_content)

PDF 페이지마다 모든 LTTextContainer를 순회하며 텍스트를 추출하고, 다음과 같은 방식으로 처리한다:

  • Y 좌표 기준 정렬 (위 → 아래)

  • 같은 줄에 있는 텍스트들을 X 좌표 기준으로 정렬

  • 여러 개의 요소가 한 줄에 존재하는 경우 | 기호로 묶어서 테이블 행으로 구성

이름 | 나이 | 직책
홍길동 | 34 | 매니저

4. 별도 테이블 구성 (extract_tables_separately)

추출된 텍스트 중에서 정확히 테이블일 가능성이 높은 행만을 별도로 추출한다. 다수의 열을 포함하면서 숫자 및 시간/근무 관련 키워드를 가진 행들만 필터링하며, 이를 rows 리스트로 구성한다.

5. 결과 포맷팅 (format_content_for_output)

다음과 같은 마크다운 유사 형식으로 구성된다:

  • 제목: ## 제목

  • 테이블: 📊 텍스트1 | 텍스트2

  • 목록: 가) 항목

  • 일반 문단: 그대로 출력

  • 페이지 헤더: === 페이지 N === 표시

6. 최종 결과 통합 및 저장 (create_comprehensive_result)

최종적으로 다음 섹션들을 포함하는 통합 분석 결과를 생성한다:

  • 문서 내용 (텍스트 + 테이블 순서 통합)

  • 구조화된 테이블 데이터 (행 기반 정리)

  • 전체 텍스트 (백업용, 길이 제한 있음)

출력은 result.txt 파일로 저장되며, 결과는 CLI에 일부 미리보기 형태로 출력된다.

실행 예시

python pdf_analyzer.py

출력 예

📁 처리할 파일: data/23. 플래티어_행복소통협의회 운영규정.pdf
💾 출력 파일: result.txt
✅ 종합 결과가 저장되었습니다: result.txt
📊 총 내용 길이: 12,345 문자
📄 통합 내용 항목: 84 개
📋 구조화된 테이블: 5 개

활용 예시

  • 사내 규정집, 회의록, 보고서 등 구조화된 문서를 분석하여 요약할 때 활용할 수 있다.

  • 정형 텍스트와 반정형 테이블을 함께 다루는 업무에서 정리 도구로 활용이 가능하다.

  • 추출된 테이블 데이터는 CSV, Excel 등으로 변환하여 추가 가공도 가능하다.

코드

from pdfminer.high_level import extract_text, extract_pages
from pdfminer.layout import LAParams, LTTextContainer
import os
import re

def clean_text(text):
    if not text:
        return ""
    text = re.sub(r'\s+', ' ', text)
    text = re.sub(r'\n\s*\n\s*\n', '\n\n', text)
    return text.strip()

def classify_text_type(text):
    if not text or len(text) < 2:
        return "ignore"
    title_patterns = [
        r'^[0-9]+\.\s*[가-힣]', r'^[가-힣]+\s*[0-9]*\.\s*[가-힣]',
        r'^[IVX]+\.\s*[가-힣]', r'^제\s*[0-9]+\s*[조항절]',
        r'^[가-힣]{2,}\s*[::]', r'^[가-힣]{1,10}\s*관리\s*지침',
    ]
    for pattern in title_patterns:
        if re.match(pattern, text): return "title"
    table_patterns = [
        r'\d+시간|\d+분', r'\d+월|\d+일|\d+년',
        r'\d+:\d+|\d+시\s*\d+분', r'[가-힣]+\s*\|\s*[가-힣]+',
        r'[가-힣]+\s+\d+\s+[가-힣]+', r'^\s*\d+\s+[가-힣]+\s+\d+',
    ]
    for pattern in table_patterns:
        if re.search(pattern, text): return "table"
    list_patterns = [
        r'^[가-힣]\)\s*', r'^[0-9]+\)\s*', r'^-\s*[가-힣]', r'^•\s*[가-힣]'
    ]
    for pattern in list_patterns:
        if re.match(pattern, text): return "list"
    return "text"

def extract_unified_content(pdf_path):
    laparams = LAParams(
        boxes_flow=0.4, word_margin=0.1,
        char_margin=1.5, line_margin=0.4, detect_vertical=True
    )
    all_content = []
    for page_num, layout in enumerate(extract_pages(pdf_path, laparams=laparams)):
        elements = []
        for el in layout:
            if isinstance(el, LTTextContainer):
                text = clean_text(el.get_text())
                if text and len(text) > 1:
                    x0, y0, x1, y1 = el.bbox
                    text_type = classify_text_type(text)
                    if text_type != "ignore":
                        elements.append({'text': text, 'type': text_type, 'x0': x0, 'y0': y0})
        elements.sort(key=lambda x: -x['y0'])
        grouped = []
        i = 0
        while i < len(elements):
            group = [elements[i]]
            y = elements[i]['y0']
            j = i + 1
            while j < len(elements):
                if abs(elements[j]['y0'] - y) <= 5:
                    group.append(elements.pop(j))
                else:
                    j += 1
            group.sort(key=lambda x: x['x0'])
            if len(group) > 1:
                table_row = ' | '.join([g['text'] for g in group])
                grouped.append({'text': table_row, 'type': 'table'})
            else:
                grouped.append(group[0])
            i += 1
        all_content.append({'text': f"\n=== 페이지 {page_num + 1} ===", 'type': 'page_header'})
        all_content.extend(grouped)
    return all_content

def extract_tables_separately(pdf_path):
    laparams = LAParams(
        boxes_flow=0.3, word_margin=0.05,
        char_margin=1.0, line_margin=0.3, detect_vertical=True
    )
    tables = []
    for page_num, layout in enumerate(extract_pages(pdf_path, laparams=laparams)):
        rows, elements = [], []
        for el in layout:
            if isinstance(el, LTTextContainer):
                text = clean_text(el.get_text())
                if text and len(text) > 1:
                    x0, y0, x1, y1 = el.bbox
                    elements.append({'text': text, 'x0': x0, 'y0': y0})
        elements.sort(key=lambda x: -x['y0'])
        i = 0
        while i < len(elements):
            row = [elements[i]]
            y = elements[i]['y0']
            j = i + 1
            while j < len(elements):
                if abs(elements[j]['y0'] - y) <= 5:
                    row.append(elements.pop(j))
                else:
                    j += 1
            if len(row) > 1:
                row.sort(key=lambda x: x['x0'])
                texts = [r['text'] for r in row]
                if any(re.search(r'\d', t) for t in texts):
                    rows.append(texts)
            i += 1
        if rows:
            tables.append({'page': page_num + 1, 'rows': rows})
    return tables

def format_content(content):
    lines = []
    for item in content:
        t = item['text']
        tp = item['type']
        if tp == 'page_header': lines.append(f"\n{t}\n" + "-"*50)
        elif tp == 'title': lines.append(f"\n## {t}")
        elif tp == 'table': lines.append(t)
        elif tp == 'list': lines.append(f" - {t}")
        else: lines.append(t)
    return '\n'.join(lines)

def create_result(pdf_path, output_path="result.txt"):
    try:
        unified = extract_unified_content(pdf_path)
        tables = extract_tables_separately(pdf_path)
        basic_text = clean_text(extract_text(pdf_path))

        sections = [
            "="*80,
            "PDF 문서 분석 결과",
            "="*80
        ]

        if unified:
            sections.append("\n\n[통합 텍스트 및 테이블]")
            sections.append("-"*60)
            sections.append(format_content(unified))

        if tables:
            sections.append("\n\n[구조화된 테이블]")
            sections.append("-"*60)
            for t in tables:
                sections.append(f"\n- 페이지 {t['page']}")
                for i, row in enumerate(t['rows']):
                    if i == 0:
                        sections.append("헤더: " + " | ".join(row))
                        sections.append("-"*40)
                    else:
                        sections.append(" | ".join(row))

        if basic_text and len(basic_text) > 100:
            sections.append("\n\n[전체 텍스트 (참고용)]")
            sections.append("-"*60)
            sections.append(basic_text[:3000] + "\n...(이하 생략)" if len(basic_text) > 3000 else basic_text)

        result = '\n'.join(sections)
        os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else '.', exist_ok=True)
        with open(output_path, 'w', encoding='utf-8') as f:
            f.write(result)

    except Exception as e:
        print(f"[오류] 결과 생성 실패: {e}")

if __name__ == "__main__":
    create_result("data/23. 플래티어_행복소통협의회 운영규정.pdf", "result.txt")

주요 변경 요약

항목 설명
로그 제거 print 문 대부분 제거, 필요한 경우 Exception 시 메시지만 출력
미리보기 제거 1500자 출력 등 모든 프리뷰 제거
불필요한 출력 제거 🎉, 📁, 📋 등 이모티콘 출력 삭제
출력 구조 간결화 출력 포맷을 최소한의 마크다운 형태로 유지
실행 구조 단순화 main()create_result() 호출만 수행

이제 이 코드는 조용히 PDF를 분석하고, 바로 result.txt에 결과만 저장하는 형태로 사용할 수 있다. 배치 처리나 서버 측 자동화에도 적합하다.