323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""
|
|
PDF 처리 모듈
|
|
PDF 파일을 이미지로 변환하고 base64로 인코딩하는 기능을 제공합니다.
|
|
"""
|
|
|
|
import base64
|
|
import io
|
|
import fitz # PyMuPDF
|
|
from PIL import Image
|
|
from typing import List, Optional, Tuple, Dict, Any
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
# 로깅 설정
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class PDFProcessor:
|
|
"""PDF 파일 처리 클래스"""
|
|
|
|
def __init__(self):
|
|
self.supported_formats = ['pdf']
|
|
|
|
def validate_pdf_file(self, file_path: str) -> bool:
|
|
"""PDF 파일 유효성 검사"""
|
|
try:
|
|
path = Path(file_path)
|
|
|
|
# 파일 존재 여부 확인
|
|
if not path.exists():
|
|
logger.error(f"파일이 존재하지 않습니다: {file_path}")
|
|
return False
|
|
|
|
# 파일 확장자 확인
|
|
if path.suffix.lower() != '.pdf':
|
|
logger.error(f"지원하지 않는 파일 형식입니다: {path.suffix}")
|
|
return False
|
|
|
|
# PDF 파일 열기 테스트
|
|
doc = fitz.open(file_path)
|
|
page_count = len(doc)
|
|
doc.close()
|
|
|
|
if page_count == 0:
|
|
logger.error("PDF 파일에 페이지가 없습니다.")
|
|
return False
|
|
|
|
logger.info(f"PDF 검증 완료: {page_count}페이지")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"PDF 파일 검증 중 오류 발생: {e}")
|
|
return False
|
|
|
|
def get_pdf_info(self, file_path: str) -> Optional[dict]:
|
|
"""PDF 파일 정보 조회"""
|
|
try:
|
|
doc = fitz.open(file_path)
|
|
info = {
|
|
'page_count': len(doc),
|
|
'metadata': doc.metadata,
|
|
'file_size': Path(file_path).stat().st_size,
|
|
'filename': Path(file_path).name
|
|
}
|
|
doc.close()
|
|
return info
|
|
|
|
except Exception as e:
|
|
logger.error(f"PDF 정보 조회 중 오류 발생: {e}")
|
|
return None
|
|
|
|
def convert_pdf_page_to_image(
|
|
self,
|
|
file_path: str,
|
|
page_number: int = 0,
|
|
zoom: float = 2.0,
|
|
image_format: str = "PNG"
|
|
) -> Optional[Image.Image]:
|
|
"""PDF 페이지를 PIL Image로 변환"""
|
|
try:
|
|
doc = fitz.open(file_path)
|
|
|
|
if page_number >= len(doc):
|
|
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
|
|
doc.close()
|
|
return None
|
|
|
|
# 페이지 로드
|
|
page = doc.load_page(page_number)
|
|
|
|
# 이미지 변환을 위한 매트릭스 설정 (확대/축소)
|
|
mat = fitz.Matrix(zoom, zoom)
|
|
pix = page.get_pixmap(matrix=mat)
|
|
|
|
# PIL Image로 변환
|
|
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
|
|
|
doc.close()
|
|
logger.info(f"페이지 {page_number + 1} 이미지 변환 완료: {img.size}")
|
|
return img
|
|
|
|
except Exception as e:
|
|
logger.error(f"PDF 페이지 이미지 변환 중 오류 발생: {e}")
|
|
return None
|
|
|
|
def convert_pdf_to_images(
|
|
self,
|
|
file_path: str,
|
|
max_pages: Optional[int] = None,
|
|
zoom: float = 2.0
|
|
) -> List[Image.Image]:
|
|
"""PDF의 모든 페이지를 이미지로 변환"""
|
|
images = []
|
|
|
|
try:
|
|
doc = fitz.open(file_path)
|
|
total_pages = len(doc)
|
|
|
|
# 최대 페이지 수 제한
|
|
if max_pages:
|
|
total_pages = min(total_pages, max_pages)
|
|
|
|
for page_num in range(total_pages):
|
|
img = self.convert_pdf_page_to_image(file_path, page_num, zoom)
|
|
if img:
|
|
images.append(img)
|
|
|
|
doc.close()
|
|
logger.info(f"총 {len(images)}개 페이지 이미지 변환 완료")
|
|
|
|
except Exception as e:
|
|
logger.error(f"PDF 전체 페이지 변환 중 오류 발생: {e}")
|
|
|
|
return images
|
|
|
|
def image_to_base64(
|
|
self,
|
|
image: Image.Image,
|
|
format: str = "PNG",
|
|
quality: int = 95
|
|
) -> Optional[str]:
|
|
"""PIL Image를 base64 문자열로 변환"""
|
|
try:
|
|
buffer = io.BytesIO()
|
|
|
|
# JPEG 형식인 경우 품질 설정
|
|
if format.upper() == "JPEG":
|
|
image.save(buffer, format=format, quality=quality)
|
|
else:
|
|
image.save(buffer, format=format)
|
|
|
|
buffer.seek(0)
|
|
base64_string = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
|
|
logger.info(f"이미지를 base64로 변환 완료 (크기: {len(base64_string)} 문자)")
|
|
return base64_string
|
|
|
|
except Exception as e:
|
|
logger.error(f"이미지 base64 변환 중 오류 발생: {e}")
|
|
return None
|
|
|
|
def pdf_page_to_base64(
|
|
self,
|
|
file_path: str,
|
|
page_number: int = 0,
|
|
zoom: float = 2.0,
|
|
format: str = "PNG"
|
|
) -> Optional[str]:
|
|
"""PDF 페이지를 직접 base64로 변환"""
|
|
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
|
|
if img:
|
|
return self.image_to_base64(img, format)
|
|
return None
|
|
|
|
def pdf_page_to_image_bytes(
|
|
self,
|
|
file_path: str,
|
|
page_number: int = 0,
|
|
zoom: float = 2.0,
|
|
format: str = "PNG"
|
|
) -> Optional[bytes]:
|
|
"""PDF 페이지를 이미지 바이트로 변환 (Flet 이미지 표시용)"""
|
|
try:
|
|
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
|
|
if img:
|
|
buffer = io.BytesIO()
|
|
img.save(buffer, format=format)
|
|
buffer.seek(0)
|
|
image_bytes = buffer.getvalue()
|
|
|
|
logger.info(f"페이지 {page_number + 1} 이미지 바이트 변환 완료 (크기: {len(image_bytes)} 바이트)")
|
|
return image_bytes
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"PDF 페이지 이미지 바이트 변환 중 오류 발생: {e}")
|
|
return None
|
|
|
|
def get_optimal_zoom_for_size(self, target_size: Tuple[int, int]) -> float:
|
|
"""목표 크기에 맞는 최적 줌 비율 계산"""
|
|
# 기본 PDF 페이지 크기 (A4: 595x842 points)
|
|
default_width, default_height = 595, 842
|
|
target_width, target_height = target_size
|
|
|
|
# 비율 계산
|
|
width_ratio = target_width / default_width
|
|
height_ratio = target_height / default_height
|
|
|
|
# 작은 비율을 선택하여 전체 페이지가 들어가도록 함
|
|
zoom = min(width_ratio, height_ratio)
|
|
|
|
logger.info(f"최적 줌 비율 계산: {zoom:.2f}")
|
|
return zoom
|
|
|
|
def extract_text_with_coordinates(self, file_path: str, page_number: int = 0) -> List[Dict[str, Any]]:
|
|
"""PDF 페이지에서 텍스트와 좌표를 추출합니다."""
|
|
text_blocks = []
|
|
try:
|
|
doc = fitz.open(file_path)
|
|
if page_number >= len(doc):
|
|
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
|
|
doc.close()
|
|
return []
|
|
|
|
page = doc.load_page(page_number)
|
|
# 'dict' 옵션은 블록, 라인, 스팬에 대한 상세 정보를 제공합니다.
|
|
blocks = page.get_text("dict")["blocks"]
|
|
for b in blocks: # 블록 반복
|
|
if b['type'] == 0: # 텍스트 블록
|
|
for l in b["lines"]: # 라인 반복
|
|
for s in l["spans"]: # 스팬(텍스트 조각) 반복
|
|
text_blocks.append({
|
|
"text": s["text"],
|
|
"bbox": s["bbox"], # (x0, y0, x1, y1)
|
|
"font": s["font"],
|
|
"size": s["size"]
|
|
})
|
|
|
|
doc.close()
|
|
logger.info(f"페이지 {page_number + 1}에서 {len(text_blocks)}개의 텍스트 블록 추출 완료")
|
|
return text_blocks
|
|
|
|
except Exception as e:
|
|
logger.error(f"PDF 텍스트 및 좌표 추출 중 오류 발생: {e}")
|
|
return []
|
|
|
|
def convert_to_images(
|
|
self,
|
|
file_path: str,
|
|
zoom: float = 2.0,
|
|
max_pages: int = 10
|
|
) -> List[Image.Image]:
|
|
"""PDF의 모든 페이지(또는 지정된 수까지)를 PIL Image 리스트로 변환"""
|
|
images = []
|
|
try:
|
|
doc = fitz.open(file_path)
|
|
page_count = min(len(doc), max_pages) # 최대 페이지 수 제한
|
|
|
|
logger.info(f"PDF 변환 시작: {page_count}페이지")
|
|
|
|
for page_num in range(page_count):
|
|
page = doc.load_page(page_num)
|
|
|
|
# 이미지 변환을 위한 매트릭스 설정
|
|
mat = fitz.Matrix(zoom, zoom)
|
|
pix = page.get_pixmap(matrix=mat)
|
|
|
|
# PIL Image로 변환
|
|
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
|
images.append(img)
|
|
|
|
logger.info(f"페이지 {page_num + 1}/{page_count} 변환 완료: {img.size}")
|
|
|
|
doc.close()
|
|
logger.info(f"PDF 전체 변환 완료: {len(images)}개 이미지")
|
|
return images
|
|
|
|
except Exception as e:
|
|
logger.error(f"PDF 다중 페이지 변환 중 오류 발생: {e}")
|
|
return []
|
|
|
|
def image_to_bytes(self, image: Image.Image, format: str = 'PNG') -> bytes:
|
|
"""
|
|
PIL Image를 바이트 데이터로 변환합니다.
|
|
|
|
Args:
|
|
image: PIL Image 객체
|
|
format: 이미지 포맷 ('PNG', 'JPEG' 등)
|
|
|
|
Returns:
|
|
이미지 바이트 데이터
|
|
"""
|
|
try:
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format=format)
|
|
image_bytes = buffer.getvalue()
|
|
buffer.close()
|
|
|
|
logger.info(f"이미지를 {format} 바이트로 변환: {len(image_bytes)} bytes")
|
|
return image_bytes
|
|
|
|
except Exception as e:
|
|
logger.error(f"이미지 바이트 변환 중 오류 발생: {e}")
|
|
return b''
|
|
|
|
# 사용 예시
|
|
if __name__ == "__main__":
|
|
processor = PDFProcessor()
|
|
|
|
# 테스트용 코드 (실제 PDF 파일 경로로 변경 필요)
|
|
test_pdf = "test.pdf"
|
|
|
|
if processor.validate_pdf_file(test_pdf):
|
|
info = processor.get_pdf_info(test_pdf)
|
|
print(f"PDF 정보: {info}")
|
|
|
|
# 첫 번째 페이지를 base64로 변환
|
|
base64_data = processor.pdf_page_to_base64(test_pdf, 0)
|
|
if base64_data:
|
|
print(f"Base64 변환 성공: {len(base64_data)} 문자")
|
|
else:
|
|
print("PDF 파일 검증 실패")
|