pdfprep 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdfprep/__init__.py +15 -0
- pdfprep/metadata.py +64 -0
- pdfprep/ocr.py +196 -0
- pdfprep/parsing.py +254 -0
- pdfprep/table.py +289 -0
- pdfprep-0.1.0.dist-info/METADATA +249 -0
- pdfprep-0.1.0.dist-info/RECORD +10 -0
- pdfprep-0.1.0.dist-info/WHEEL +5 -0
- pdfprep-0.1.0.dist-info/entry_points.txt +5 -0
- pdfprep-0.1.0.dist-info/top_level.txt +1 -0
pdfprep/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pdfprep — PDF 전처리 도구 모음
|
|
3
|
+
|
|
4
|
+
서브모듈:
|
|
5
|
+
metadata : PDF 메타데이터 추출
|
|
6
|
+
parsing : 3종 라이브러리(pypdf / pdfplumber / pymupdf)로 텍스트 파싱
|
|
7
|
+
ocr : 2종 OCR 엔진(tesseract / paddleocr)으로 이미지 텍스트 추출
|
|
8
|
+
table : 3종 라이브러리(camelot / tabula / layoutparser)로 표 추출
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from . import metadata, parsing, ocr, table
|
|
14
|
+
|
|
15
|
+
__all__ = ["metadata", "parsing", "ocr", "table", "__version__"]
|
pdfprep/metadata.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pypdf import PdfReader
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_pdf_date(date_str):
|
|
8
|
+
if not date_str:
|
|
9
|
+
return None
|
|
10
|
+
s = str(date_str)
|
|
11
|
+
if s.startswith("D:"):
|
|
12
|
+
s = s[2:]
|
|
13
|
+
try:
|
|
14
|
+
return datetime.strptime(s[:14], "%Y%m%d%H%M%S").strftime("%Y-%m-%d %H:%M:%S")
|
|
15
|
+
except ValueError:
|
|
16
|
+
return str(date_str)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def show_pdf_metadata(pdf_path):
|
|
20
|
+
path = Path(pdf_path)
|
|
21
|
+
if not path.exists():
|
|
22
|
+
print(f"파일을 찾을 수 없습니다: {pdf_path}")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
reader = PdfReader(str(path))
|
|
26
|
+
meta = reader.metadata or {}
|
|
27
|
+
|
|
28
|
+
print(f"=== PDF 메타데이터: {path.name} ===")
|
|
29
|
+
print(f"파일 크기 : {path.stat().st_size:,} bytes")
|
|
30
|
+
print(f"페이지 수 : {len(reader.pages)}")
|
|
31
|
+
print(f"PDF 버전 : {reader.pdf_header}")
|
|
32
|
+
print(f"암호화 여부 : {reader.is_encrypted}")
|
|
33
|
+
print()
|
|
34
|
+
print("--- Document Info ---")
|
|
35
|
+
print(f"제목(Title) : {meta.get('/Title', '')}")
|
|
36
|
+
print(f"작성자(Author) : {meta.get('/Author', '')}")
|
|
37
|
+
print(f"주제(Subject) : {meta.get('/Subject', '')}")
|
|
38
|
+
print(f"키워드(Keywords): {meta.get('/Keywords', '')}")
|
|
39
|
+
print(f"생성 도구 : {meta.get('/Creator', '')}")
|
|
40
|
+
print(f"생성기(Producer): {meta.get('/Producer', '')}")
|
|
41
|
+
print(f"생성일 : {parse_pdf_date(meta.get('/CreationDate'))}")
|
|
42
|
+
print(f"수정일 : {parse_pdf_date(meta.get('/ModDate'))}")
|
|
43
|
+
|
|
44
|
+
extras = {k: v for k, v in meta.items() if k not in {
|
|
45
|
+
"/Title", "/Author", "/Subject", "/Keywords",
|
|
46
|
+
"/Creator", "/Producer", "/CreationDate", "/ModDate",
|
|
47
|
+
}}
|
|
48
|
+
if extras:
|
|
49
|
+
print()
|
|
50
|
+
print("--- 기타 항목 ---")
|
|
51
|
+
for k, v in extras.items():
|
|
52
|
+
print(f"{k}: {v}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
if len(sys.argv) < 2:
|
|
57
|
+
print("사용법: python3 pdf_metadata.py <PDF 파일 경로> [추가 파일 ...]")
|
|
58
|
+
print("예시 : python3 pdf_metadata.py pdf_sample.pdf")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
for i, pdf_path in enumerate(sys.argv[1:]):
|
|
62
|
+
if i > 0:
|
|
63
|
+
print()
|
|
64
|
+
show_pdf_metadata(pdf_path)
|
pdfprep/ocr.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import io
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
OCR_ENGINES = ("tesseract", "paddleocr")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def pdf_to_images(pdf_path, dpi=200):
|
|
9
|
+
"""
|
|
10
|
+
pymupdf로 PDF 각 페이지를 PIL Image로 렌더링.
|
|
11
|
+
"""
|
|
12
|
+
import fitz
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
images = []
|
|
16
|
+
with fitz.open(str(pdf_path)) as doc:
|
|
17
|
+
zoom = dpi / 72
|
|
18
|
+
mat = fitz.Matrix(zoom, zoom)
|
|
19
|
+
for page in doc:
|
|
20
|
+
pix = page.get_pixmap(matrix=mat, alpha=False)
|
|
21
|
+
img = Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB")
|
|
22
|
+
images.append(img)
|
|
23
|
+
return images
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ocr_with_tesseract(pdf_path, lang="kor+eng", dpi=200):
|
|
27
|
+
"""
|
|
28
|
+
pytesseract — Google Tesseract 래퍼.
|
|
29
|
+
빠르고 가벼우며, 한국어/영어 traineddata 설치 필요.
|
|
30
|
+
"""
|
|
31
|
+
import pytesseract
|
|
32
|
+
|
|
33
|
+
images = pdf_to_images(pdf_path, dpi=dpi)
|
|
34
|
+
pages = []
|
|
35
|
+
confidences = []
|
|
36
|
+
word_count = 0
|
|
37
|
+
for img in images:
|
|
38
|
+
text = pytesseract.image_to_string(img, lang=lang)
|
|
39
|
+
pages.append(text)
|
|
40
|
+
|
|
41
|
+
data = pytesseract.image_to_data(img, lang=lang, output_type=pytesseract.Output.DICT)
|
|
42
|
+
valid_words = [
|
|
43
|
+
(w, int(c))
|
|
44
|
+
for w, c in zip(data.get("text", []), data.get("conf", []))
|
|
45
|
+
if w.strip() and str(c) not in ("-1", "")
|
|
46
|
+
]
|
|
47
|
+
word_count += len(valid_words)
|
|
48
|
+
confidences.extend(c for _, c in valid_words)
|
|
49
|
+
|
|
50
|
+
avg_conf = sum(confidences) / len(confidences) if confidences else 0
|
|
51
|
+
features = {
|
|
52
|
+
"사용 언어": lang,
|
|
53
|
+
"렌더링 DPI": dpi,
|
|
54
|
+
"인식 단어 수": word_count,
|
|
55
|
+
"평균 신뢰도(%)": round(avg_conf, 2),
|
|
56
|
+
"최저 신뢰도(%)": min(confidences) if confidences else None,
|
|
57
|
+
"최고 신뢰도(%)": max(confidences) if confidences else None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"engine": "tesseract",
|
|
62
|
+
"page_count": len(pages),
|
|
63
|
+
"pages": pages,
|
|
64
|
+
"text": "\n\n".join(pages),
|
|
65
|
+
"features": features,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ocr_with_paddleocr(pdf_path, lang="korean", dpi=200):
|
|
70
|
+
"""
|
|
71
|
+
PaddleOCR — Baidu의 딥러닝 기반 OCR.
|
|
72
|
+
검출(detection) + 인식(recognition) 단계를 거쳐 박스 + 텍스트 + 신뢰도 반환.
|
|
73
|
+
"""
|
|
74
|
+
import numpy as np
|
|
75
|
+
from paddleocr import PaddleOCR
|
|
76
|
+
|
|
77
|
+
ocr = PaddleOCR(use_angle_cls=True, lang=lang, show_log=False)
|
|
78
|
+
|
|
79
|
+
images = pdf_to_images(pdf_path, dpi=dpi)
|
|
80
|
+
pages = []
|
|
81
|
+
box_count = 0
|
|
82
|
+
confidences = []
|
|
83
|
+
word_count = 0
|
|
84
|
+
for img in images:
|
|
85
|
+
arr = np.array(img)
|
|
86
|
+
results = ocr.ocr(arr, cls=True)
|
|
87
|
+
page_lines = []
|
|
88
|
+
for page_res in results:
|
|
89
|
+
if not page_res:
|
|
90
|
+
continue
|
|
91
|
+
for item in page_res:
|
|
92
|
+
# item = [box, (text, score)]
|
|
93
|
+
if not item or len(item) < 2:
|
|
94
|
+
continue
|
|
95
|
+
text_info = item[1]
|
|
96
|
+
if not text_info:
|
|
97
|
+
continue
|
|
98
|
+
t, s = text_info[0], text_info[1]
|
|
99
|
+
page_lines.append(t)
|
|
100
|
+
confidences.append(float(s))
|
|
101
|
+
word_count += len(t.split())
|
|
102
|
+
box_count += 1
|
|
103
|
+
pages.append("\n".join(page_lines))
|
|
104
|
+
|
|
105
|
+
avg_conf = sum(confidences) / len(confidences) if confidences else 0
|
|
106
|
+
features = {
|
|
107
|
+
"사용 언어": lang,
|
|
108
|
+
"렌더링 DPI": dpi,
|
|
109
|
+
"검출된 텍스트 박스 수": box_count,
|
|
110
|
+
"인식 단어 수": word_count,
|
|
111
|
+
"평균 신뢰도": round(avg_conf, 4),
|
|
112
|
+
"최저 신뢰도": round(min(confidences), 4) if confidences else None,
|
|
113
|
+
"최고 신뢰도": round(max(confidences), 4) if confidences else None,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"engine": "paddleocr",
|
|
118
|
+
"page_count": len(pages),
|
|
119
|
+
"pages": pages,
|
|
120
|
+
"text": "\n\n".join(pages),
|
|
121
|
+
"features": features,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
ENGINE_MAP = {
|
|
126
|
+
"tesseract": ocr_with_tesseract,
|
|
127
|
+
"paddleocr": ocr_with_paddleocr,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
HIGHLIGHT_KEYS = {
|
|
131
|
+
"tesseract": ("인식 단어 수", "평균 신뢰도(%)", "사용 언어"),
|
|
132
|
+
"paddleocr": ("검출된 텍스트 박스 수", "평균 신뢰도", "사용 언어"),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ocr_pdf(pdf_path, engine="tesseract", **kwargs):
|
|
137
|
+
if engine not in ENGINE_MAP:
|
|
138
|
+
raise ValueError(f"지원하지 않는 엔진: {engine}. 사용 가능: {list(ENGINE_MAP)}")
|
|
139
|
+
path = Path(pdf_path)
|
|
140
|
+
if not path.exists():
|
|
141
|
+
raise FileNotFoundError(f"파일을 찾을 수 없습니다: {pdf_path}")
|
|
142
|
+
return ENGINE_MAP[engine](path, **kwargs)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _format_value(value):
|
|
146
|
+
if isinstance(value, list):
|
|
147
|
+
if not value:
|
|
148
|
+
return "[]"
|
|
149
|
+
preview = ", ".join(str(v) for v in value[:5])
|
|
150
|
+
suffix = f", ... (+{len(value) - 5})" if len(value) > 5 else ""
|
|
151
|
+
return f"[{preview}{suffix}]"
|
|
152
|
+
return str(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def print_result(result, preview_chars=300):
|
|
156
|
+
print(f"=== OCR 엔진: {result['engine']} ===")
|
|
157
|
+
print(f"페이지 수 : {result.get('page_count', '-')}")
|
|
158
|
+
text = result.get("text", "")
|
|
159
|
+
print(f"전체 텍스트 길이: {len(text)}자")
|
|
160
|
+
|
|
161
|
+
features = result.get("features", {})
|
|
162
|
+
if features:
|
|
163
|
+
print()
|
|
164
|
+
print(f"--- {result['engine']} 특화 지표 ---")
|
|
165
|
+
for k, v in features.items():
|
|
166
|
+
print(f" {k:<22}: {_format_value(v)}")
|
|
167
|
+
|
|
168
|
+
print()
|
|
169
|
+
print(f"--- OCR 결과 미리보기 (앞 {preview_chars}자) ---")
|
|
170
|
+
print(text[:preview_chars] + ("..." if len(text) > preview_chars else ""))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main():
|
|
174
|
+
if len(sys.argv) < 2:
|
|
175
|
+
print("사용법: python3 ocr_pdf.py <PDF 파일> [엔진]")
|
|
176
|
+
print(f" 엔진(생략 시 전체): {', '.join(OCR_ENGINES)}")
|
|
177
|
+
print("예시 : python3 ocr_pdf.py data/pdf_sample.pdf tesseract")
|
|
178
|
+
print(" python3 ocr_pdf.py data/pdf_sample.pdf")
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
pdf_path = sys.argv[1]
|
|
182
|
+
engines = [sys.argv[2]] if len(sys.argv) >= 3 else list(OCR_ENGINES)
|
|
183
|
+
|
|
184
|
+
for i, engine in enumerate(engines):
|
|
185
|
+
if i > 0:
|
|
186
|
+
print()
|
|
187
|
+
try:
|
|
188
|
+
result = ocr_pdf(pdf_path, engine)
|
|
189
|
+
print_result(result)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
print(f"=== OCR 엔진: {engine} ===")
|
|
192
|
+
print(f"오류: {type(e).__name__}: {e}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
main()
|
pdfprep/parsing.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
ENGINES = ("pypdf", "pdfplumber", "pymupdf")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _count_outline(items):
|
|
8
|
+
if not items:
|
|
9
|
+
return 0
|
|
10
|
+
total = 0
|
|
11
|
+
for item in items:
|
|
12
|
+
if isinstance(item, list):
|
|
13
|
+
total += _count_outline(item)
|
|
14
|
+
else:
|
|
15
|
+
total += 1
|
|
16
|
+
return total
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_with_pypdf(pdf_path):
|
|
20
|
+
"""
|
|
21
|
+
pypdf 강점: 문서 구조 메타정보 (목차, 폼 필드, 주석, 암호화)
|
|
22
|
+
"""
|
|
23
|
+
from pypdf import PdfReader
|
|
24
|
+
reader = PdfReader(str(pdf_path))
|
|
25
|
+
|
|
26
|
+
pages_text = []
|
|
27
|
+
annotation_count = 0
|
|
28
|
+
image_count = 0
|
|
29
|
+
rotations = []
|
|
30
|
+
for page in reader.pages:
|
|
31
|
+
pages_text.append(page.extract_text() or "")
|
|
32
|
+
annots = page.get("/Annots")
|
|
33
|
+
if annots:
|
|
34
|
+
try:
|
|
35
|
+
annotation_count += len(annots)
|
|
36
|
+
except TypeError:
|
|
37
|
+
pass
|
|
38
|
+
try:
|
|
39
|
+
image_count += len(page.images)
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
rotations.append(page.get("/Rotate", 0) or 0)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
form_fields = reader.get_form_text_fields() or {}
|
|
46
|
+
except Exception:
|
|
47
|
+
form_fields = {}
|
|
48
|
+
|
|
49
|
+
features = {
|
|
50
|
+
"목차(outline) 항목 수": _count_outline(reader.outline),
|
|
51
|
+
"폼 필드 수": len(form_fields),
|
|
52
|
+
"주석 수": annotation_count,
|
|
53
|
+
"이미지 수": image_count,
|
|
54
|
+
"암호화 여부": reader.is_encrypted,
|
|
55
|
+
"PDF 버전": reader.pdf_header,
|
|
56
|
+
"페이지 회전각": rotations,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"engine": "pypdf",
|
|
61
|
+
"page_count": len(pages_text),
|
|
62
|
+
"pages": pages_text,
|
|
63
|
+
"text": "\n\n".join(pages_text),
|
|
64
|
+
"features": features,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_with_pdfplumber(pdf_path):
|
|
69
|
+
"""
|
|
70
|
+
pdfplumber 강점: 표 추출, 문자/단어 단위 위치 정보, 도형(선/사각형)
|
|
71
|
+
"""
|
|
72
|
+
import pdfplumber
|
|
73
|
+
|
|
74
|
+
pages_text = []
|
|
75
|
+
tables_per_page = []
|
|
76
|
+
char_count = 0
|
|
77
|
+
word_count = 0
|
|
78
|
+
line_count = 0
|
|
79
|
+
rect_count = 0
|
|
80
|
+
fontnames = set()
|
|
81
|
+
|
|
82
|
+
with pdfplumber.open(str(pdf_path)) as pdf:
|
|
83
|
+
for page in pdf.pages:
|
|
84
|
+
pages_text.append(page.extract_text() or "")
|
|
85
|
+
page_tables = page.extract_tables() or []
|
|
86
|
+
if page_tables:
|
|
87
|
+
tables_per_page.append({"page": page.page_number, "tables": page_tables})
|
|
88
|
+
char_count += len(page.chars or [])
|
|
89
|
+
word_count += len(page.extract_words() or [])
|
|
90
|
+
line_count += len(page.lines or [])
|
|
91
|
+
rect_count += len(page.rects or [])
|
|
92
|
+
for ch in page.chars or []:
|
|
93
|
+
fn = ch.get("fontname")
|
|
94
|
+
if fn:
|
|
95
|
+
fontnames.add(fn)
|
|
96
|
+
|
|
97
|
+
total_tables = sum(len(entry["tables"]) for entry in tables_per_page)
|
|
98
|
+
features = {
|
|
99
|
+
"표 개수": total_tables,
|
|
100
|
+
"표가 있는 페이지": [entry["page"] for entry in tables_per_page],
|
|
101
|
+
"문자(char) 수": char_count,
|
|
102
|
+
"단어 수": word_count,
|
|
103
|
+
"선(line) 수": line_count,
|
|
104
|
+
"사각형(rect) 수": rect_count,
|
|
105
|
+
"사용 폰트 수": len(fontnames),
|
|
106
|
+
"폰트 목록": sorted(fontnames),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"engine": "pdfplumber",
|
|
111
|
+
"page_count": len(pages_text),
|
|
112
|
+
"pages": pages_text,
|
|
113
|
+
"text": "\n\n".join(pages_text),
|
|
114
|
+
"tables": tables_per_page,
|
|
115
|
+
"features": features,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_with_pymupdf(pdf_path):
|
|
120
|
+
"""
|
|
121
|
+
pymupdf 강점: 블록 구조, 이미지/링크 메타, 폰트 색·크기, 렌더링
|
|
122
|
+
"""
|
|
123
|
+
import fitz
|
|
124
|
+
|
|
125
|
+
pages_text = []
|
|
126
|
+
block_count = 0
|
|
127
|
+
image_count = 0
|
|
128
|
+
link_count = 0
|
|
129
|
+
drawing_count = 0
|
|
130
|
+
font_set = set()
|
|
131
|
+
size_set = set()
|
|
132
|
+
color_set = set()
|
|
133
|
+
|
|
134
|
+
with fitz.open(str(pdf_path)) as doc:
|
|
135
|
+
toc = doc.get_toc() or []
|
|
136
|
+
metadata = doc.metadata or {}
|
|
137
|
+
|
|
138
|
+
for page in doc:
|
|
139
|
+
pages_text.append(page.get_text())
|
|
140
|
+
|
|
141
|
+
d = page.get_text("dict")
|
|
142
|
+
for block in d.get("blocks", []):
|
|
143
|
+
block_count += 1
|
|
144
|
+
for line in block.get("lines", []):
|
|
145
|
+
for span in line.get("spans", []):
|
|
146
|
+
font_set.add(span.get("font", ""))
|
|
147
|
+
size_set.add(round(span.get("size", 0), 1))
|
|
148
|
+
color_set.add(span.get("color", 0))
|
|
149
|
+
|
|
150
|
+
image_count += len(page.get_images() or [])
|
|
151
|
+
link_count += len(page.get_links() or [])
|
|
152
|
+
try:
|
|
153
|
+
drawing_count += len(page.get_drawings() or [])
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
features = {
|
|
158
|
+
"블록(block) 수": block_count,
|
|
159
|
+
"이미지 수": image_count,
|
|
160
|
+
"링크 수": link_count,
|
|
161
|
+
"드로잉(도형) 수": drawing_count,
|
|
162
|
+
"사용 폰트 수": len(font_set),
|
|
163
|
+
"폰트 목록": sorted(f for f in font_set if f),
|
|
164
|
+
"사용 글자 크기": sorted(size_set),
|
|
165
|
+
"사용 색상 수": len(color_set),
|
|
166
|
+
"목차(TOC) 항목 수": len(toc),
|
|
167
|
+
"PDF Producer": metadata.get("producer", ""),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"engine": "pymupdf",
|
|
172
|
+
"page_count": len(pages_text),
|
|
173
|
+
"pages": pages_text,
|
|
174
|
+
"text": "\n\n".join(pages_text),
|
|
175
|
+
"features": features,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
ENGINE_MAP = {
|
|
180
|
+
"pypdf": parse_with_pypdf,
|
|
181
|
+
"pdfplumber": parse_with_pdfplumber,
|
|
182
|
+
"pymupdf": parse_with_pymupdf,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# 요약 테이블에 강조해서 보여줄 "엔진별 시그니처 지표"
|
|
187
|
+
HIGHLIGHT_KEYS = {
|
|
188
|
+
"pypdf": ("목차(outline) 항목 수", "폼 필드 수", "주석 수"),
|
|
189
|
+
"pdfplumber": ("표 개수", "단어 수", "사용 폰트 수"),
|
|
190
|
+
"pymupdf": ("블록(block) 수", "이미지 수", "사용 폰트 수"),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_pdf(pdf_path, engine="pypdf"):
|
|
195
|
+
if engine not in ENGINE_MAP:
|
|
196
|
+
raise ValueError(f"지원하지 않는 엔진: {engine}. 사용 가능: {list(ENGINE_MAP)}")
|
|
197
|
+
path = Path(pdf_path)
|
|
198
|
+
if not path.exists():
|
|
199
|
+
raise FileNotFoundError(f"파일을 찾을 수 없습니다: {pdf_path}")
|
|
200
|
+
return ENGINE_MAP[engine](path)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _format_value(value):
|
|
204
|
+
if isinstance(value, list):
|
|
205
|
+
if not value:
|
|
206
|
+
return "[]"
|
|
207
|
+
preview = ", ".join(str(v) for v in value[:5])
|
|
208
|
+
suffix = f", ... (+{len(value) - 5})" if len(value) > 5 else ""
|
|
209
|
+
return f"[{preview}{suffix}]"
|
|
210
|
+
return str(value)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def print_result(result, preview_chars=300):
|
|
214
|
+
print(f"=== 엔진: {result['engine']} ===")
|
|
215
|
+
print(f"페이지 수 : {result.get('page_count', '-')}")
|
|
216
|
+
text = result.get("text", "")
|
|
217
|
+
print(f"전체 텍스트 길이: {len(text)}자")
|
|
218
|
+
|
|
219
|
+
features = result.get("features", {})
|
|
220
|
+
if features:
|
|
221
|
+
print()
|
|
222
|
+
print(f"--- {result['engine']} 특화 지표 ---")
|
|
223
|
+
for k, v in features.items():
|
|
224
|
+
print(f" {k:<22}: {_format_value(v)}")
|
|
225
|
+
|
|
226
|
+
print()
|
|
227
|
+
print(f"--- 텍스트 미리보기 (앞 {preview_chars}자) ---")
|
|
228
|
+
print(text[:preview_chars] + ("..." if len(text) > preview_chars else ""))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def main():
|
|
232
|
+
if len(sys.argv) < 2:
|
|
233
|
+
print("사용법: python3 parsing_pdf.py <PDF 파일> [엔진]")
|
|
234
|
+
print(f" 엔진(생략 시 전체): {', '.join(ENGINES)}")
|
|
235
|
+
print("예시 : python3 parsing_pdf.py data/pdf_sample.pdf pypdf")
|
|
236
|
+
print(" python3 parsing_pdf.py data/pdf_sample.pdf")
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
|
|
239
|
+
pdf_path = sys.argv[1]
|
|
240
|
+
engines = [sys.argv[2]] if len(sys.argv) >= 3 else list(ENGINES)
|
|
241
|
+
|
|
242
|
+
for i, engine in enumerate(engines):
|
|
243
|
+
if i > 0:
|
|
244
|
+
print()
|
|
245
|
+
try:
|
|
246
|
+
result = parse_pdf(pdf_path, engine)
|
|
247
|
+
print_result(result)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
print(f"=== 엔진: {engine} ===")
|
|
250
|
+
print(f"오류: {type(e).__name__}: {e}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
main()
|
pdfprep/table.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import io
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
ENGINES = ("camelot", "tabula", "layoutparser")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _df_shape(df):
|
|
9
|
+
try:
|
|
10
|
+
return f"{df.shape[0]}행 × {df.shape[1]}열"
|
|
11
|
+
except Exception:
|
|
12
|
+
return "?"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _df_preview(df, max_rows=3, max_cols=4):
|
|
16
|
+
try:
|
|
17
|
+
sub = df.iloc[:max_rows, :max_cols]
|
|
18
|
+
return sub.to_string(index=False)
|
|
19
|
+
except Exception:
|
|
20
|
+
return str(df)[:200]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_with_camelot(pdf_path, flavor="lattice", pages="all"):
|
|
24
|
+
"""
|
|
25
|
+
Camelot — PDF 표 추출 전용.
|
|
26
|
+
flavor="lattice" → 선이 명확한 표 (ghostscript 필요)
|
|
27
|
+
flavor="stream" → 선 없는 표 (좌표 기반)
|
|
28
|
+
"""
|
|
29
|
+
import camelot
|
|
30
|
+
|
|
31
|
+
tables = camelot.read_pdf(str(pdf_path), flavor=flavor, pages=pages)
|
|
32
|
+
items = []
|
|
33
|
+
accuracies = []
|
|
34
|
+
whitespaces = []
|
|
35
|
+
total_rows = 0
|
|
36
|
+
total_cols = 0
|
|
37
|
+
for t in tables:
|
|
38
|
+
df = t.df
|
|
39
|
+
rows, cols = df.shape
|
|
40
|
+
total_rows += rows
|
|
41
|
+
total_cols = max(total_cols, cols)
|
|
42
|
+
report = getattr(t, "parsing_report", {}) or {}
|
|
43
|
+
if "accuracy" in report:
|
|
44
|
+
accuracies.append(float(report["accuracy"]))
|
|
45
|
+
if "whitespace" in report:
|
|
46
|
+
whitespaces.append(float(report["whitespace"]))
|
|
47
|
+
items.append({
|
|
48
|
+
"page": report.get("page"),
|
|
49
|
+
"shape": (rows, cols),
|
|
50
|
+
"accuracy": report.get("accuracy"),
|
|
51
|
+
"whitespace": report.get("whitespace"),
|
|
52
|
+
"df": df,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
features = {
|
|
56
|
+
"표 개수": len(tables),
|
|
57
|
+
"flavor": flavor,
|
|
58
|
+
"각 표 모양": [t["shape"] for t in items],
|
|
59
|
+
"각 표 페이지": [t["page"] for t in items],
|
|
60
|
+
"평균 정확도(accuracy)": round(sum(accuracies) / len(accuracies), 2) if accuracies else None,
|
|
61
|
+
"평균 여백률(whitespace)": round(sum(whitespaces) / len(whitespaces), 2) if whitespaces else None,
|
|
62
|
+
"최대 열 수": total_cols,
|
|
63
|
+
"총 행 수(합)": total_rows,
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
"engine": "camelot",
|
|
67
|
+
"table_count": len(tables),
|
|
68
|
+
"tables": items,
|
|
69
|
+
"features": features,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_with_tabula(pdf_path, pages="all"):
|
|
74
|
+
"""
|
|
75
|
+
Tabula — Java 기반 tabula-java 래퍼.
|
|
76
|
+
pandas.DataFrame 리스트 반환.
|
|
77
|
+
"""
|
|
78
|
+
import tabula
|
|
79
|
+
|
|
80
|
+
dfs = tabula.read_pdf(str(pdf_path), pages=pages, multiple_tables=True, lattice=True)
|
|
81
|
+
items = []
|
|
82
|
+
total_rows = 0
|
|
83
|
+
total_cols = 0
|
|
84
|
+
for i, df in enumerate(dfs, start=1):
|
|
85
|
+
rows, cols = df.shape
|
|
86
|
+
total_rows += rows
|
|
87
|
+
total_cols = max(total_cols, cols)
|
|
88
|
+
items.append({
|
|
89
|
+
"index": i,
|
|
90
|
+
"shape": (rows, cols),
|
|
91
|
+
"columns": list(df.columns),
|
|
92
|
+
"df": df,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
features = {
|
|
96
|
+
"표 개수": len(dfs),
|
|
97
|
+
"각 표 모양": [t["shape"] for t in items],
|
|
98
|
+
"각 표 헤더(첫 표)": items[0]["columns"][:6] if items else [],
|
|
99
|
+
"최대 열 수": total_cols,
|
|
100
|
+
"총 행 수(합)": total_rows,
|
|
101
|
+
"추출 모드": "lattice (선 기반)",
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
"engine": "tabula",
|
|
105
|
+
"table_count": len(dfs),
|
|
106
|
+
"tables": items,
|
|
107
|
+
"features": features,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_with_layoutparser(pdf_path, dpi=200, model_name=None):
|
|
112
|
+
"""
|
|
113
|
+
LayoutParser — 페이지 이미지에서 'Table' 영역의 bbox를 탐지.
|
|
114
|
+
셀 내용 추출이 아니라 '어디에 표가 있는가'를 찾는 용도.
|
|
115
|
+
Detectron2 / PaddleDetection / EfficientDet 등 백엔드 모델 필요.
|
|
116
|
+
"""
|
|
117
|
+
import fitz
|
|
118
|
+
from PIL import Image
|
|
119
|
+
import numpy as np
|
|
120
|
+
|
|
121
|
+
# Pillow 10+ 에서 PIL.Image.LINEAR 가 제거됨 → Detectron2 0.6 호환을 위해 사전 패치
|
|
122
|
+
if not hasattr(Image, "LINEAR"):
|
|
123
|
+
Image.LINEAR = Image.BILINEAR
|
|
124
|
+
if not hasattr(Image, "CUBIC"):
|
|
125
|
+
Image.CUBIC = Image.BICUBIC
|
|
126
|
+
|
|
127
|
+
import layoutparser as lp
|
|
128
|
+
|
|
129
|
+
# PaddleDetection 백엔드 + TableBank 모델 (표 전용 학습)
|
|
130
|
+
try:
|
|
131
|
+
model = lp.PaddleDetectionLayoutModel(
|
|
132
|
+
"lp://TableBank/ppyolov2_r50vd_dcn_365e/config",
|
|
133
|
+
label_map={0: "Table"},
|
|
134
|
+
enforce_cpu=True,
|
|
135
|
+
)
|
|
136
|
+
backend = "PaddleDetection (TableBank)"
|
|
137
|
+
except Exception as e_pd:
|
|
138
|
+
try:
|
|
139
|
+
model = lp.Detectron2LayoutModel(
|
|
140
|
+
"lp://PubLayNet/mask_rcnn_X_101_32x8d_FPN_3x/config",
|
|
141
|
+
extra_config=["MODEL.ROI_HEADS.SCORE_THRESH_TEST", 0.6],
|
|
142
|
+
label_map={0: "Text", 1: "Title", 2: "List", 3: "Table", 4: "Figure"},
|
|
143
|
+
)
|
|
144
|
+
backend = "Detectron2"
|
|
145
|
+
except Exception as e_d2:
|
|
146
|
+
raise ImportError(
|
|
147
|
+
"layoutparser 모델 백엔드를 사용할 수 없습니다.\n"
|
|
148
|
+
" - PaddleDetection (paddlepaddle 필요): "
|
|
149
|
+
f"{e_pd.__class__.__name__}: {str(e_pd)[:100]}\n"
|
|
150
|
+
" - Detectron2 (모델 다운로드 URL 일부 비활성화 상태): "
|
|
151
|
+
f"{e_d2.__class__.__name__}: {str(e_d2)[:100]}"
|
|
152
|
+
) from e_pd
|
|
153
|
+
|
|
154
|
+
table_regions = []
|
|
155
|
+
page_results = []
|
|
156
|
+
with fitz.open(str(pdf_path)) as doc:
|
|
157
|
+
zoom = dpi / 72
|
|
158
|
+
mat = fitz.Matrix(zoom, zoom)
|
|
159
|
+
for page_idx, page in enumerate(doc, start=1):
|
|
160
|
+
pix = page.get_pixmap(matrix=mat, alpha=False)
|
|
161
|
+
img = np.array(Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB"))
|
|
162
|
+
layout = model.detect(img)
|
|
163
|
+
tables_on_page = [b for b in layout if b.type == "Table"]
|
|
164
|
+
for tb in tables_on_page:
|
|
165
|
+
box = tb.block
|
|
166
|
+
# 픽셀 좌표 → PDF 좌표 (PDF 포인트, 72dpi 기준)
|
|
167
|
+
pdf_rect = fitz.Rect(
|
|
168
|
+
box.x_1 / zoom, box.y_1 / zoom,
|
|
169
|
+
box.x_2 / zoom, box.y_2 / zoom,
|
|
170
|
+
)
|
|
171
|
+
# bbox 영역 내부 텍스트 추출 (디지털 PDF 한정 — 스캔 PDF면 빈 문자열)
|
|
172
|
+
region_text = page.get_text(clip=pdf_rect).strip()
|
|
173
|
+
table_regions.append({
|
|
174
|
+
"page": page_idx,
|
|
175
|
+
"score": round(float(tb.score), 4) if tb.score is not None else None,
|
|
176
|
+
"bbox": (round(box.x_1, 1), round(box.y_1, 1),
|
|
177
|
+
round(box.x_2, 1), round(box.y_2, 1)),
|
|
178
|
+
"text": region_text,
|
|
179
|
+
})
|
|
180
|
+
page_results.append({
|
|
181
|
+
"page": page_idx,
|
|
182
|
+
"table_regions": tables_on_page,
|
|
183
|
+
"all_layout_count": len(layout),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
features = {
|
|
187
|
+
"사용 백엔드": backend,
|
|
188
|
+
"렌더링 DPI": dpi,
|
|
189
|
+
"검출된 표 영역 수": len(table_regions),
|
|
190
|
+
"표 영역 상세": [
|
|
191
|
+
f"p{r['page']} {r['bbox']} (score={r['score']})"
|
|
192
|
+
for r in table_regions
|
|
193
|
+
],
|
|
194
|
+
"페이지별 전체 레이아웃 요소 수": [
|
|
195
|
+
p["all_layout_count"] for p in page_results
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
"engine": "layoutparser",
|
|
200
|
+
"table_count": len(table_regions),
|
|
201
|
+
"tables": table_regions,
|
|
202
|
+
"features": features,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
ENGINE_MAP = {
|
|
207
|
+
"camelot": extract_with_camelot,
|
|
208
|
+
"tabula": extract_with_tabula,
|
|
209
|
+
"layoutparser": extract_with_layoutparser,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
HIGHLIGHT_KEYS = {
|
|
213
|
+
"camelot": ("표 개수", "평균 정확도(accuracy)", "각 표 모양"),
|
|
214
|
+
"tabula": ("표 개수", "각 표 모양", "각 표 헤더(첫 표)"),
|
|
215
|
+
"layoutparser": ("검출된 표 영역 수", "사용 백엔드", "표 영역 상세"),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def extract_tables(pdf_path, engine="camelot", **kwargs):
|
|
220
|
+
if engine not in ENGINE_MAP:
|
|
221
|
+
raise ValueError(f"지원하지 않는 엔진: {engine}. 사용 가능: {list(ENGINE_MAP)}")
|
|
222
|
+
path = Path(pdf_path)
|
|
223
|
+
if not path.exists():
|
|
224
|
+
raise FileNotFoundError(f"파일을 찾을 수 없습니다: {pdf_path}")
|
|
225
|
+
return ENGINE_MAP[engine](path, **kwargs)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _format_value(value):
|
|
229
|
+
if isinstance(value, list):
|
|
230
|
+
if not value:
|
|
231
|
+
return "[]"
|
|
232
|
+
preview = ", ".join(str(v) for v in value[:5])
|
|
233
|
+
suffix = f", ... (+{len(value) - 5})" if len(value) > 5 else ""
|
|
234
|
+
return f"[{preview}{suffix}]"
|
|
235
|
+
return str(value)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def print_result(result, preview_rows=3):
|
|
239
|
+
print(f"=== 표 추출 엔진: {result['engine']} ===")
|
|
240
|
+
print(f"검출된 표 수 : {result.get('table_count', 0)}")
|
|
241
|
+
|
|
242
|
+
features = result.get("features", {})
|
|
243
|
+
if features:
|
|
244
|
+
print()
|
|
245
|
+
print(f"--- {result['engine']} 특화 지표 ---")
|
|
246
|
+
for k, v in features.items():
|
|
247
|
+
print(f" {k:<22}: {_format_value(v)}")
|
|
248
|
+
|
|
249
|
+
tables = result.get("tables", [])
|
|
250
|
+
if not tables:
|
|
251
|
+
return
|
|
252
|
+
print()
|
|
253
|
+
print(f"--- 표 미리보기 (앞 {preview_rows}행) ---")
|
|
254
|
+
for i, t in enumerate(tables[:5], start=1):
|
|
255
|
+
df = t.get("df")
|
|
256
|
+
if df is not None:
|
|
257
|
+
print(f"\n[표 {i}] {_df_shape(df)}")
|
|
258
|
+
print(_df_preview(df, max_rows=preview_rows))
|
|
259
|
+
else:
|
|
260
|
+
page = t.get("page", "?")
|
|
261
|
+
bbox = t.get("bbox", "?")
|
|
262
|
+
score = t.get("score", "?")
|
|
263
|
+
print(f" [표 {i}] page={page}, bbox={bbox}, score={score}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main():
|
|
267
|
+
if len(sys.argv) < 2:
|
|
268
|
+
print("사용법: python3 table_pdf.py <PDF 파일> [엔진]")
|
|
269
|
+
print(f" 엔진(생략 시 전체): {', '.join(ENGINES)}")
|
|
270
|
+
print("예시 : python3 table_pdf.py data/table_sample.pdf camelot")
|
|
271
|
+
print(" python3 table_pdf.py data/table_sample.pdf")
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
|
|
274
|
+
pdf_path = sys.argv[1]
|
|
275
|
+
engines = [sys.argv[2]] if len(sys.argv) >= 3 else list(ENGINES)
|
|
276
|
+
|
|
277
|
+
for i, engine in enumerate(engines):
|
|
278
|
+
if i > 0:
|
|
279
|
+
print()
|
|
280
|
+
try:
|
|
281
|
+
result = extract_tables(pdf_path, engine)
|
|
282
|
+
print_result(result)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f"=== 표 추출 엔진: {engine} ===")
|
|
285
|
+
print(f"오류: {type(e).__name__}: {e}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdfprep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PDF 전처리 통합 도구 — 메타데이터, 텍스트 파싱, OCR, 표 추출
|
|
5
|
+
Author-email: uwpark <uwpark@simplatform.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/pdfprep/
|
|
8
|
+
Keywords: pdf,ocr,table-extraction,preprocessing,parsing,metadata
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Topic :: Text Processing
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
15
|
+
Classifier: Natural Language :: Korean
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: pypdf<6.0,>=4.0
|
|
19
|
+
Requires-Dist: pdfplumber>=0.11.0
|
|
20
|
+
Requires-Dist: pymupdf>=1.27.0
|
|
21
|
+
Requires-Dist: pillow>=10.0
|
|
22
|
+
Requires-Dist: numpy<2
|
|
23
|
+
Requires-Dist: setuptools>=65
|
|
24
|
+
Provides-Extra: ocr
|
|
25
|
+
Requires-Dist: pytesseract>=0.3.13; extra == "ocr"
|
|
26
|
+
Requires-Dist: paddleocr==2.7.3; extra == "ocr"
|
|
27
|
+
Requires-Dist: paddlepaddle==2.6.2; extra == "ocr"
|
|
28
|
+
Provides-Extra: table
|
|
29
|
+
Requires-Dist: camelot-py[base]==1.0.9; extra == "table"
|
|
30
|
+
Requires-Dist: tabula-py>=2.10; extra == "table"
|
|
31
|
+
Requires-Dist: jpype1>=1.5; extra == "table"
|
|
32
|
+
Requires-Dist: layoutparser>=0.3.4; extra == "table"
|
|
33
|
+
Provides-Extra: all
|
|
34
|
+
Requires-Dist: pytesseract>=0.3.13; extra == "all"
|
|
35
|
+
Requires-Dist: paddleocr==2.7.3; extra == "all"
|
|
36
|
+
Requires-Dist: paddlepaddle==2.6.2; extra == "all"
|
|
37
|
+
Requires-Dist: camelot-py[base]==1.0.9; extra == "all"
|
|
38
|
+
Requires-Dist: tabula-py>=2.10; extra == "all"
|
|
39
|
+
Requires-Dist: jpype1>=1.5; extra == "all"
|
|
40
|
+
Requires-Dist: layoutparser>=0.3.4; extra == "all"
|
|
41
|
+
|
|
42
|
+
# PDF 메타데이터 / 파싱 / OCR / 표 추출 도구
|
|
43
|
+
|
|
44
|
+
PDF 파일의 메타데이터를 확인하고, 3가지 라이브러리(`pypdf` / `pdfplumber` / `pymupdf`)로 텍스트를 파싱하며, 2가지 OCR 엔진(`tesseract` / `paddleocr`)으로 이미지 기반 텍스트를 추출하고, 3가지 표 추출 라이브러리(`camelot` / `tabula` / `layoutparser`)로 표를 검출·추출하는 Python 스크립트 모음입니다.
|
|
45
|
+
|
|
46
|
+
## 구성
|
|
47
|
+
|
|
48
|
+
| 파일 | 설명 |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `pdf_metadata.py` | PDF 메타데이터 추출/출력 모듈. CLI로 직접 실행 가능 |
|
|
51
|
+
| `test_pdf_metadata.py` | `pdf_metadata`를 불러와 동작을 검증하는 테스트 스크립트 |
|
|
52
|
+
| `parsing_pdf.py` | 3종 라이브러리로 PDF 텍스트를 파싱하는 통합 모듈 |
|
|
53
|
+
| `test_parsing_pdf.py` | 각 파싱 엔진별 결과를 비교·검증하는 테스트 스크립트 |
|
|
54
|
+
| `ocr_pdf.py` | 2종 OCR 엔진(tesseract / paddleocr)으로 PDF를 OCR하는 통합 모듈 |
|
|
55
|
+
| `test_ocr_pdf.py` | OCR 엔진별 결과(텍스트, 신뢰도, 박스 수 등)를 비교 검증 |
|
|
56
|
+
| `table_pdf.py` | 3종 라이브러리(camelot / tabula / layoutparser)로 표 추출하는 통합 모듈 |
|
|
57
|
+
| `test_table_pdf.py` | 표 추출 엔진별 결과(표 개수, 행/열, 정확도 등)를 비교 검증 |
|
|
58
|
+
| `requirements.txt` | 의존성 목록 |
|
|
59
|
+
| `data/` | 테스트용 PDF 파일을 두는 폴더 |
|
|
60
|
+
|
|
61
|
+
## 설치
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install -r requirements.txt
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
PEP 668 환경(Ubuntu 등 시스템 Python)에서는 가상환경 사용을 권장합니다.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python3 -m venv .venv
|
|
71
|
+
source .venv/bin/activate
|
|
72
|
+
pip install -r requirements.txt
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Tesseract 시스템 패키지
|
|
76
|
+
|
|
77
|
+
`pytesseract`는 시스템에 설치된 `tesseract` 바이너리를 호출하므로 별도 설치가 필요합니다.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Ubuntu/Debian — 한국어 + 영어 traineddata 포함
|
|
81
|
+
sudo apt install tesseract-ocr tesseract-ocr-kor tesseract-ocr-eng
|
|
82
|
+
|
|
83
|
+
# 설치 확인
|
|
84
|
+
tesseract --version
|
|
85
|
+
tesseract --list-langs # kor, eng 출력되어야 함
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### PaddleOCR 버전 주의
|
|
89
|
+
|
|
90
|
+
`paddleocr 2.7.3` + `paddlepaddle 2.6.2` + `numpy<2` 조합으로 고정되어 있습니다. PaddleOCR 3.x / paddlepaddle 3.x 조합에서는 CPU 환경에서 PIR + oneDNN 호환 문제가 발생할 수 있어 LTS 조합을 사용합니다. 최초 실행 시 모델 파일이 자동으로 다운로드됩니다(~수십 MB).
|
|
91
|
+
|
|
92
|
+
> **Python 3.12+ 사용자**: `paddlepaddle 2.6.2`는 표준 라이브러리에서 제거된 `distutils`를 호출하므로 `setuptools`가 반드시 설치돼 있어야 합니다(requirements.txt에 포함). 누락 시 `No module named 'setuptools'`가 발생하면 `pip install setuptools`로 해결하세요.
|
|
93
|
+
|
|
94
|
+
### 표 추출 시스템 패키지
|
|
95
|
+
|
|
96
|
+
- **camelot** → `ghostscript` 필요 (`sudo apt install ghostscript`)
|
|
97
|
+
- **tabula** → JRE 필요 (`sudo apt install default-jre`)
|
|
98
|
+
- **layoutparser** → PaddleDetection 백엔드 + **TableBank** 모델 사용 (표 전용 학습)
|
|
99
|
+
- OCR 섹션에서 설치한 `paddlepaddle` 만으로 동작
|
|
100
|
+
- 최초 실행 시 모델(약 221MB) 자동 다운로드 (Baidu CDN)
|
|
101
|
+
- 별도 `paddledet` 또는 `detectron2` 설치 불필요
|
|
102
|
+
|
|
103
|
+
## 사용법
|
|
104
|
+
|
|
105
|
+
### 1. PDF 메타데이터 출력 (`pdf_metadata.py`)
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python3 pdf_metadata.py data/pdf_sample.pdf
|
|
109
|
+
python3 pdf_metadata.py a.pdf b.pdf c.pdf
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
출력 항목
|
|
113
|
+
- 파일 크기, 페이지 수, PDF 버전, 암호화 여부
|
|
114
|
+
- Document Info: Title, Author, Subject, Keywords, Creator, Producer
|
|
115
|
+
- 생성일/수정일 (`D:YYYYMMDDHHMMSS` → `YYYY-MM-DD HH:MM:SS` 변환)
|
|
116
|
+
- 기타 메타데이터 키(`/Trapped` 등)
|
|
117
|
+
|
|
118
|
+
### 2. 메타데이터 테스트 (`test_pdf_metadata.py`)
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python3 test_pdf_metadata.py # 기본 data/ 폴더
|
|
122
|
+
python3 test_pdf_metadata.py data/pdf_sample.pdf # 단일 파일
|
|
123
|
+
python3 test_pdf_metadata.py a.pdf b.pdf data/ # 혼합 지정
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3. PDF 텍스트 파싱 (`parsing_pdf.py`)
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# 전체 엔진으로 한 번에 비교
|
|
130
|
+
python3 parsing_pdf.py data/parsing_sample.pdf
|
|
131
|
+
|
|
132
|
+
# 특정 엔진만 사용
|
|
133
|
+
python3 parsing_pdf.py data/parsing_sample.pdf pypdf
|
|
134
|
+
python3 parsing_pdf.py data/parsing_sample.pdf pdfplumber
|
|
135
|
+
python3 parsing_pdf.py data/parsing_sample.pdf pymupdf
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
엔진별 특징
|
|
139
|
+
- **pypdf** — 가벼움. 목차/폼/주석 등 문서 구조 메타에 강점
|
|
140
|
+
- **pdfplumber** — 표(테이블) 추출, 단어/문자 단위 bbox에 강점
|
|
141
|
+
- **pymupdf** (`fitz`) — 빠르고 강력. 블록·이미지·폰트·색상 등 풍부한 메타
|
|
142
|
+
|
|
143
|
+
### 4. 파싱 테스트 (`test_parsing_pdf.py`)
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python3 test_parsing_pdf.py # 기본 data/ 폴더
|
|
147
|
+
python3 test_parsing_pdf.py data/pdf_sample.pdf # 단일 파일
|
|
148
|
+
python3 test_parsing_pdf.py /다른/경로/ # 다른 폴더
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
출력
|
|
152
|
+
- 엔진 × 파일 시간/페이지/텍스트길이 매트릭스
|
|
153
|
+
- 엔진별 특화 지표 (예: pdfplumber → 표 개수, pymupdf → 블록·이미지·폰트 수)
|
|
154
|
+
|
|
155
|
+
### 5. OCR 실행 (`ocr_pdf.py`)
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# 전체 엔진으로 비교 (tesseract → paddleocr 순)
|
|
159
|
+
python3 ocr_pdf.py data/pdf_sample.pdf
|
|
160
|
+
|
|
161
|
+
# 특정 엔진만 사용
|
|
162
|
+
python3 ocr_pdf.py data/pdf_sample.pdf tesseract
|
|
163
|
+
python3 ocr_pdf.py data/pdf_sample.pdf paddleocr
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
처리 흐름
|
|
167
|
+
1. `pymupdf`로 PDF 각 페이지를 PNG로 렌더링(기본 200 DPI)
|
|
168
|
+
2. 이미지를 OCR 엔진에 전달
|
|
169
|
+
3. 텍스트 + 신뢰도 + 박스/단어 통계 반환
|
|
170
|
+
|
|
171
|
+
엔진별 특징
|
|
172
|
+
- **tesseract** — 가벼움/빠름. `kor+eng` 등 다국어 동시 인식. 단어 단위 신뢰도 제공
|
|
173
|
+
- **paddleocr** — 딥러닝 기반. 검출(detection) + 인식(recognition) 2단계로 라인별 박스 + 신뢰도 반환
|
|
174
|
+
|
|
175
|
+
### 6. OCR 테스트 (`test_ocr_pdf.py`)
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
python3 test_ocr_pdf.py # 기본 파일 + 전체 엔진
|
|
179
|
+
python3 test_ocr_pdf.py data/pdf_sample.pdf # 지정 파일 + 전체 엔진
|
|
180
|
+
python3 test_ocr_pdf.py /다른/경로/ # 지정 폴더 + 전체 엔진
|
|
181
|
+
|
|
182
|
+
# 엔진 단독 실행 (마지막 인자가 엔진 이름이면 그것만 실행)
|
|
183
|
+
python3 test_ocr_pdf.py data/pdf_sample.pdf tesseract
|
|
184
|
+
python3 test_ocr_pdf.py data/pdf_sample.pdf paddleocr
|
|
185
|
+
python3 test_ocr_pdf.py paddleocr # 엔진만 지정 → 기본 파일 사용
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
출력
|
|
189
|
+
- 엔진 × 파일 시간/페이지/텍스트길이 매트릭스
|
|
190
|
+
- 엔진별 특화 지표
|
|
191
|
+
- **tesseract**: 인식 단어 수, 평균 신뢰도(%), 사용 언어
|
|
192
|
+
- **paddleocr**: 검출 텍스트 박스 수, 평균 신뢰도, 사용 언어
|
|
193
|
+
|
|
194
|
+
라이브러리 미설치 시 해당 엔진은 자동으로 `SKIP` 처리됩니다.
|
|
195
|
+
|
|
196
|
+
### 7. 표 추출 (`table_pdf.py`)
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# 전체 엔진으로 비교
|
|
200
|
+
python3 table_pdf.py data/table_sample.pdf
|
|
201
|
+
|
|
202
|
+
# 특정 엔진만 사용
|
|
203
|
+
python3 table_pdf.py data/table_sample.pdf camelot
|
|
204
|
+
python3 table_pdf.py data/table_sample.pdf tabula
|
|
205
|
+
python3 table_pdf.py data/table_sample.pdf layoutparser
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
엔진별 특징
|
|
209
|
+
- **camelot** — PDF 표 추출 전용. `lattice`(선 기반) / `stream`(좌표 기반) 두 모드. 정확도(accuracy)·여백률(whitespace) 등 품질 지표 제공. Ghostscript 필요
|
|
210
|
+
- **tabula** — `tabula-java` 래퍼. pandas DataFrame 리스트 반환. JRE 필요
|
|
211
|
+
- **layoutparser** — 페이지를 이미지로 렌더링 후 'Table' 영역의 bbox를 탐지하는 레이아웃 검출기. 셀 내용 추출이 아닌 '표 위치' 검출용. PaddleDetection 백엔드 + TableBank 모델 (영문 PubLayNet 대신 표 전용 학습)
|
|
212
|
+
|
|
213
|
+
### 8. 표 추출 테스트 (`test_table_pdf.py`)
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
python3 test_table_pdf.py # 기본 파일 + 전체 엔진
|
|
217
|
+
python3 test_table_pdf.py data/table_sample.pdf # 지정 파일 + 전체 엔진
|
|
218
|
+
python3 test_table_pdf.py /다른/경로/ # 지정 폴더 + 전체 엔진
|
|
219
|
+
|
|
220
|
+
# 엔진 단독 실행
|
|
221
|
+
python3 test_table_pdf.py data/table_sample.pdf camelot
|
|
222
|
+
python3 test_table_pdf.py data/table_sample.pdf tabula
|
|
223
|
+
python3 test_table_pdf.py data/table_sample.pdf layoutparser
|
|
224
|
+
python3 test_table_pdf.py camelot # 엔진만 지정 → 기본 파일 사용
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
기본 파일은 `data/table_sample.pdf`입니다. 출력에는 표 개수, 각 표의 행/열 크기, 첫 표 미리보기, 엔진별 특화 지표(camelot 정확도, tabula 헤더, layoutparser bbox)가 포함됩니다.
|
|
228
|
+
|
|
229
|
+
## 폴더 구조
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
code/
|
|
233
|
+
├── README.md
|
|
234
|
+
├── requirements.txt
|
|
235
|
+
├── pdf_metadata.py
|
|
236
|
+
├── test_pdf_metadata.py
|
|
237
|
+
├── parsing_pdf.py
|
|
238
|
+
├── test_parsing_pdf.py
|
|
239
|
+
├── ocr_pdf.py
|
|
240
|
+
├── test_ocr_pdf.py
|
|
241
|
+
├── table_pdf.py
|
|
242
|
+
├── test_table_pdf.py
|
|
243
|
+
└── data/
|
|
244
|
+
├── pdf_sample.pdf
|
|
245
|
+
├── parsing_sample.pdf
|
|
246
|
+
└── table_sample.pdf
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`data/`에 PDF를 추가하면 인자 없이 테스트를 실행해도 자동으로 같이 검증됩니다.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pdfprep/__init__.py,sha256=zV8ch6JxnS0t0uUOcOKHe2Dau2jFtcRV8OzNhZLQKFc,499
|
|
2
|
+
pdfprep/metadata.py,sha256=f6fwBzMj-0tCoksF9MZmv8j5wn-PGKfBbA7-4Ku0eks,2141
|
|
3
|
+
pdfprep/ocr.py,sha256=PP18FqWuplEmTPF-qOCvUeTn9Mzh3qNNq-vqwsOLIfk,6146
|
|
4
|
+
pdfprep/parsing.py,sha256=3-YFbluMobtg_Arzb7kmC-6M5sOiDjvWW_GKOcdNb4w,7670
|
|
5
|
+
pdfprep/table.py,sha256=2nye4eitoGgVgNho_GJI1PyWlGLAflKx_1VfX0WFLZk,9856
|
|
6
|
+
pdfprep-0.1.0.dist-info/METADATA,sha256=FLkeDZ8AzoGS1qHzLpESS6zjcOJRBYczqebdXen_t7k,10430
|
|
7
|
+
pdfprep-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
pdfprep-0.1.0.dist-info/entry_points.txt,sha256=5q-s_E2hA_XNLIUJJX96iH9bNbAHlrrZvPcU2DxyBqQ,162
|
|
9
|
+
pdfprep-0.1.0.dist-info/top_level.txt,sha256=dGL0EYAoTAQmGS_WIGJrhPRhAFVKs0fMwLQKEgM4-sk,8
|
|
10
|
+
pdfprep-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pdfprep
|