스캔

오늘 회사에서 서류를 여러장 스캔하는데, 각각 페이지마다 다른 곳으로 메일을 발송해야하는 일이었다. 스캔을 하고 보니, 한개의 파일로 바인딩이 되어있어서, acrobat으로 작업을 할까 하다가, 그냥 chatgpt 시켜서 스크립트를 짰더니, 음… 뭐 나쁘지 않다.

pypdf

뭔들 없겠느냐만, python 패키지는 찾는것도 일이다. AI덕분에 이제는 그냥 생각만 해도 되니 꽤나 편하다. 당장 써야하는 관계로 설계랄 것 없이 바로 돌릴 수 있는 형태로 만들었다.

여러 페이지의 pdf를 각 페이지 기준으로 저장

내가 써먹을 스크립트이다. 뭐 향후 사용을 위해 생각을 하자만 한도 끝도 없지만, 일단 필요한 건 바로 쓰기 편한 형태로 간다.

from pypdf import PdfReader, PdfWriter
import os

input_pdf = "1.pdf"
output_dir = "split_pages"

os.makedirs(output_dir, exist_ok=True)

reader = PdfReader(input_pdf)

for i, page in enumerate(reader.pages, start=1):
    writer = PdfWriter()
    writer.add_page(page)

    output_file = os.path.join(
        output_dir,
        f"page_{i:03d}.pdf"
    )

    with open(output_file, "wb") as f:
        writer.write(f)

print(f"완료: 총 {len(reader.pages)} 페이지 분리")

선택한 페이지만 변환

선택한 페이지만 변환하는 옵션 정도는 있어야하겠다.

from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog

from pypdf import PdfReader, PdfWriter


def parse_pages(value: str, total_pages: int) -> list[int]:
    pages: list[int] = []
    for item in value.replace(" ", "").split(","):
        if not item:
            raise ValueError("페이지 번호를 입력해 주세요.")
        try:
            if "-" in item:
                parts = item.split("-")
                if len(parts) != 2:
                    raise ValueError
                start, end = map(int, parts)
                if start > end:
                    raise ValueError(f"범위의 시작이 끝보다 큽니다: {item}")
                selected = range(start, end + 1)
            else:
                selected = [int(item)]
        except ValueError as exc:
            if str(exc):
                raise
            raise ValueError(f"잘못된 페이지 형식입니다: {item}") from exc
        for page in selected:
            if not 1 <= page <= total_pages:
                raise ValueError(f"{page}페이지는 범위를 벗어납니다. 1~{total_pages} 사이로 입력하세요.")
            if page - 1 not in pages:
                pages.append(page - 1)
    return pages


def main() -> None:
    root = tk.Tk()
    root.withdraw()
    input_file = filedialog.askopenfilename(
        title="원본 PDF 선택", filetypes=[("PDF 파일", "*.pdf")]
    )
    if not input_file:
        root.destroy()
        return
    try:
        reader = PdfReader(input_file)
        if reader.is_encrypted and not reader.decrypt(""):
            raise ValueError("암호가 설정된 PDF는 처리할 수 없습니다.")
        total_pages = len(reader.pages)
        page_text = simpledialog.askstring(
            "페이지 선택",
            f"총 {total_pages}페이지입니다.\n추출할 페이지를 입력하세요.\n\n예: 1,3,5-8",
            parent=root,
        )
        if page_text is None:
            return
        selected_pages = parse_pages(page_text, total_pages)
        source = Path(input_file)
        output_file = filedialog.asksaveasfilename(
            title="선택한 페이지 저장",
            initialdir=str(source.parent),
            initialfile=f"{source.stem}_selected.pdf",
            defaultextension=".pdf",
            filetypes=[("PDF 파일", "*.pdf")],
        )
        if not output_file:
            return
        writer = PdfWriter()
        for page_index in selected_pages:
            writer.add_page(reader.pages[page_index])
        with open(output_file, "wb") as output:
            writer.write(output)
        messagebox.showinfo("완료", f"{len(selected_pages)}개 페이지를 저장했습니다.\n{output_file}", parent=root)
    except (ValueError, OSError) as exc:
        messagebox.showerror("오류", str(exc), parent=root)
    except Exception as exc:
        messagebox.showerror("오류", f"PDF 처리 중 오류가 발생했습니다.\n{exc}", parent=root)
    finally:
        root.destroy()


if __name__ == "__main__":
    main()

합치기

분리를 하다보면 또 붙이는게 아쉬우니, 하는김에 같이 해둔다.

from pypdf import PdfWriter

files = [
    "01_표지.pdf",
    "02_본문.pdf",
    "03_첨부.pdf",
]

writer = PdfWriter()

for file in files:
    writer.append(file)

with open("merged.pdf", "wb") as f:
    writer.write(f)

jpg를 pdf로 변환

그냥 pdf로 출력(print) 하면 되지만, 그마저도 귀찮다.

from PIL import Image

imgname = "계획"

image = Image.open(imgname + ".jpg")
image = image.convert("RGB")
image.save(imgname + ".pdf")

print("완료")

pdf를 jpg로 변환

반대로 pjf를 jpg 파일로 변환하는 기능 정도는 있어야겠지?

from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox

import pypdfium2 as pdfium


DPI = 300
JPEG_QUALITY = 100


def pdf_to_jpg(pdf_path: str, output_dir: str, dpi: int = DPI) -> list[Path]:
    """PDF의 모든 페이지를 JPG 파일로 변환하고 저장 경로 목록을 반환한다."""
    source = Path(pdf_path)
    destination = Path(output_dir)
    destination.mkdir(parents=True, exist_ok=True)

    document = pdfium.PdfDocument(str(source))
    saved_files: list[Path] = []
    scale = dpi / 72

    try:
        page_count = len(document)
        if page_count == 0:
            raise ValueError("페이지가 없는 PDF입니다.")

        digits = max(3, len(str(page_count)))
        for page_number in range(page_count):
            page = document[page_number]
            try:
                bitmap = page.render(scale=scale)
                image = bitmap.to_pil().convert("RGB")
                output_file = destination / (
                    f"{source.stem}_page_{page_number + 1:0{digits}d}.jpg"
                )
                image.save(output_file, "JPEG", quality=JPEG_QUALITY, optimize=True)
                image.close()
                bitmap.close()
                saved_files.append(output_file)
            finally:
                page.close()
    finally:
        document.close()

    return saved_files


def main() -> None:
    root = tk.Tk()
    root.withdraw()

    pdf_path = filedialog.askopenfilename(
        title="JPG로 변환할 PDF 선택",
        filetypes=[("PDF 파일", "*.pdf")],
    )
    if not pdf_path:
        root.destroy()
        return

    source = Path(pdf_path)
    output_dir = filedialog.askdirectory(
        title="JPG를 저장할 폴더 선택",
        initialdir=str(source.parent),
    )
    if not output_dir:
        root.destroy()
        return

    try:
        saved_files = pdf_to_jpg(pdf_path, output_dir)
        messagebox.showinfo(
            "변환 완료",
            f"총 {len(saved_files)}페이지를 JPG로 저장했습니다.\n\n{output_dir}",
            parent=root,
        )
    except Exception as exc:
        messagebox.showerror(
            "변환 실패",
            f"PDF를 JPG로 변환하는 중 오류가 발생했습니다.\n\n{exc}",
            parent=root,
        )
    finally:
        root.destroy()


if __name__ == "__main__":
    main()

결론

프론트 엔드를 붙여서 여러형태의 작업을 자유자재로 하는 것도 생각해봤으나, 그게 무슨 의미인가. 그냥 그때 그때 AI로 만드는게 더 빠를 수 있다. 이걸 활용해서 무엇을 할 수 있을지 생각해봐야한다.
그런데 왜 이 코드를 블로그에 적고 있는 거지?

끝.