본문 바로가기
프로그램 (PHP,Python)

PDF 파싱과 변환 및 편집, 프라이버시 중심의 오픈소스 툴 구축 전략

by 날으는물고기 2025. 9. 10.

PDF 파싱과 변환 및 편집, 프라이버시 중심의 오픈소스 툴 구축 전략

728x90

목표 & 요구사항 정의

  1. 프라이버시: 서버 업로드 없음, 추적/로그 없음, 모든 처리는 로컬 브라우저/내 PC에서만.
  2. 오픈소스: 재현 가능한 빌드, 라이선스 명확, 의존성 투명.
  3. 기능 범위(15+개)
    • 변환: JPEG/PNG/TXT → PDF, PDF → JPEG/PNG/TXT(텍스트 추출)
    • 관리: 압축, 합치기, 분할, 페이지 추출/삭제/정렬/회전, 플랫(Flatten)
    • 보안/프라이버시: 메타데이터 삭제, 비밀번호 해제(정당한 소유/암호 보유 시)
  4. 사용성: 드래그&드롭, 일괄 처리, 용량/건수 제한 없음.
  5. 확장성: 흔한 PDF 변칙(잘못된 xref, junk 헤더 등)에 대한 복구 경로(salvage path) 내장.

아키텍처 개요(브라우저 100% 클라이언트 사이드)

  1. 코어 엔진(WASM/JS)
    • PDF 렌더/파서: 브라우저에서 PDF 로드·페이지 렌더·텍스트 추출·주석/양식 접근.
    • PDF 조작 라이브러리: 페이지 합치기/분할/회전, 메타데이터/임베디드 제거, 폼 플랫 등.
    • 이미지 코덱: JPEG/PNG/WebP/SVG 디코드·리인코드, 다운샘플/압축.
    • OCR(옵션): 스캔/이미지 기반 PDF의 텍스트화(Tesseract.js 등).
  2. 런타임 분리
    • Web Worker: 무거운 연산 분리(렌더/압축/텍스트 추출).
    • OffscreenCanvas: 페이지 렌더·비주얼 비교 성능 최적화.
  3. 스토리지 & 파일 I/O
    • File System Access API(지원 브라우저) 또는 in-memory Blob·IndexedDB.
    • PWA(오프라인) + 캐시/서비스워커(단, 외부 통신 차단).
  4. 보안 하드닝
    • CSP: default-src 'self', 외부 스크립트/추적기 금지, WASM 무결성(SRI) 핀.
    • 네트워크 금지: 도구 실행 시 네트워킹 호출 자체를 막는 옵션(토글) 제공.

※ “특정 제품”을 쓰라는 뜻이 아닙니다. WASM 컴파일된 PDF 엔진/조작 라이브러리/이미지 코덱을 조합해 설계하는 방법론입니다.

프라이버시·보안 설계(위협모델)

  1. 데이터 흐름: 파일 → 브라우저 메모리 → 결과 파일. (네트워크 외부 유출 경로 0)
  2. 공격면 축소
    • /JavaScript, /OpenAction, /Launch, /URI, /EmbeddedFiles 등 위험 요소 로드/실행 금지.
    • 해제/압축 시 자원 한도(페이지 수, 객체 수, 압축 해제 최대 바이트, 시간 제한).
  3. 무결성
    • WASM/JS 잠금(SRI, 고정 해시), 자동 업데이트 시 서명 검증.
  4. 사전 스캔(선택): PDF 구조 간단 검사(위험 키 탐지) → 차단/경고 후 진행.
  5. 정책: 사내 배포 시 “PDF는 기본 메타 제거 + 필요 시 암호화”를 의무화.

PDF “이상 vs 현실” & 복구 경로(salvage path)

  1. 이상적 절차: %PDF-1.xstartxrefxreftrailer(/Root) → 탐색.
  2. 현실 문제
    • 헤더 앞 junk로 모든 오프셋이 밀림.
    • startxref 오탈자/근처/중간 지점 가리킴, /Prev 체인 깨짐.
    • xref stream / object stream(압축) 존재, 텍스트 리가처(ff).
  3. 복구 핵심
    • 헤더 위치 보정(content start offset) + 근처 바이트 검색(±n).
    • 실패 시 전파일 스캔: \n\d+\s+\d+\s+obj 패턴으로 객체 재색인.
    • /Prev는 불신, 각 세그먼트 독립 탐색 후 가장 최신 세그먼트 우선 병합.
    • 텍스트는 ToUnicode/접근성 트리 우선, 리가처 정규화.
300x250

기능별 설계 & 예시 코드(브라우저/JS 중심)

아래 코드는 개념 예시입니다. 실제에선 선택한 엔진 API에 맞춰 변환하세요.

1) JPEG/PNG/TXT → PDF

// 이미지 → PDF (pdf-lib 류의 API 컨셉)
import { PDFDocument, StandardFonts } from 'pdf-lib'

async function imagesToPdf(files) {
  const pdf = await PDFDocument.create()
  for (const f of files) {
    const bytes = await f.arrayBuffer()
    const img = f.type.includes('png')
      ? await pdf.embedPng(bytes)
      : await pdf.embedJpg(bytes)
    const page = pdf.addPage([img.width, img.height])
    page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height })
  }
  const out = await pdf.save()
  return new Blob([out], { type: 'application/pdf' })
}

// TXT → PDF(간단한 줄바꿈/여백)
async function textToPdf(text) {
  const pdf = await PDFDocument.create()
  const font = await pdf.embedFont(StandardFonts.Helvetica)
  const page = pdf.addPage([595, 842]) // A4
  const fontSize = 12, margin = 50
  const maxWidth = page.getWidth() - margin*2
  let y = page.getHeight() - margin
  for (const line of text.split('\n')) {
    const chunks = wrapText(line, font, fontSize, maxWidth) // 임의의 줄나눔 함수
    for (const t of chunks) {
      y -= fontSize * 1.4
      if (y < margin) { y = page.getHeight() - margin; pdf.addPage([595,842]) }
      page.drawText(t, { x: margin, y, size: fontSize, font })
    }
  }
  return new Blob([await pdf.save()], { type: 'application/pdf' })
}

2) PDF → JPEG/PNG (페이지 렌더)

// PDF.js 류 API 컨셉: 각 페이지를 Canvas로 렌더 후 Blob 추출
async function pdfToPngs(file) {
  const data = await file.arrayBuffer()
  const pdf = await pdfjsLib.getDocument({ data }).promise
  const images = []
  for (let i=1;i<=pdf.numPages;i++){
    const page = await pdf.getPage(i)
    const viewport = page.getViewport({ scale: 2.0 }) // 2x 렌더
    const canvas = new OffscreenCanvas(viewport.width, viewport.height)
    const ctx = canvas.getContext('2d')
    await page.render({ canvasContext: ctx, viewport }).promise
    const blob = await canvas.convertToBlob({ type: 'image/png' })
    images.push(blob)
  }
  return images
}

3) PDF → TXT(텍스트 추출 + 리가처 보정)

const LIG = { "\ufb00":"ff","\ufb01":"fi","\ufb02":"fl","\ufb03":"ffi","\ufb04":"ffl" }

function normalizeText(s){ return s.normalize('NFKC').replace(/\ufb0[0-4]/g, m=>LIG[m]||m) }

async function pdfToText(file) {
  const data = await file.arrayBuffer()
  const pdf = await pdfjsLib.getDocument({ data }).promise
  let out = ''
  for (let i=1;i<=pdf.numPages;i++){
    const page = await pdf.getPage(i)
    const textContent = await page.getTextContent()
    const items = textContent.items.map(x=>x.str).join(' ')
    out += normalizeText(items) + '\n'
  }
  return out
}

4) PDF 합치기/분할/페이지 조작

// 합치기: 여러 PDF를 하나로
async function mergePdfs(files) {
  const dst = await PDFDocument.create()
  for (const f of files) {
    const srcBytes = await f.arrayBuffer()
    const src = await PDFDocument.load(srcBytes)
    const copied = await dst.copyPages(src, src.getPageIndices())
    copied.forEach(p=>dst.addPage(p))
  }
  return new Blob([await dst.save()], { type: 'application/pdf' })
}

// 분할(페이지 범위)
async function splitPdf(file, ranges=[[1,3],[4,6]]) {
  const src = await PDFDocument.load(await file.arrayBuffer())
  const outs = []
  for (const [a,b] of ranges) {
    const dst = await PDFDocument.create()
    const pages = await dst.copyPages(src, Array.from({length:b-a+1},(_,i)=>a-1+i))
    pages.forEach(p=>dst.addPage(p))
    outs.push(new Blob([await dst.save()], { type: 'application/pdf' }))
  }
  return outs
}

// 추출/삭제/정렬/회전(예: 90도 회전)
function rotatePage(page, deg=90){ page.setRotation(page.getRotation()+deg*Math.PI/180) }

5) PDF 압축(이미지 다운샘플·재인코드)

// 개념: 각 페이지 렌더→이미지 재인코드(JPEG 품질 낮춤)→새 PDF에 삽입
async function compressPdfVisually(file, quality=0.7, scale=1.5) {
  const data = await file.arrayBuffer()
  const pdf = await pdfjsLib.getDocument({ data }).promise
  const dst = await PDFDocument.create()
  for (let i=1;i<=pdf.numPages;i++){
    const page = await pdf.getPage(i)
    const viewport = page.getViewport({ scale })
    const canvas = new OffscreenCanvas(viewport.width, viewport.height)
    await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
    const jpg = await canvas.convertToBlob({ type:'image/jpeg', quality })
    const bytes = await jpg.arrayBuffer()
    const img = await dst.embedJpg(bytes)
    const p = dst.addPage([img.width, img.height])
    p.drawImage(img, { x:0, y:0, width:img.width, height:img.height })
  }
  return new Blob([await dst.save()], { type:'application/pdf' })
}

6) 메타데이터 삭제(Info/XMP/임베디드/액션)

// 단순 Info/XMP 초기화 + 위험 엔트리 제거(개념)
async function stripMetadata(file) {
  const pdf = await PDFDocument.load(await file.arrayBuffer(), { updateMetadata: false })
  pdf.setTitle(''); pdf.setAuthor(''); pdf.setSubject(''); pdf.setKeywords([]); pdf.setProducer(''); pdf.setCreator(''); pdf.setCreationDate(undefined); pdf.setModificationDate(undefined)
  // 카탈로그에서 JS/임베디드/액션 제거(엔진별 API 차이 有 → 개념 예시)
  const cat = pdf.catalog
  ;['/Names','/OpenAction','/AA','/JavaScript','/URI','/EmbeddedFiles'].forEach(k=>{
    try{ cat.delete(k) }catch(e){}
  })
  return new Blob([await pdf.save()], { type:'application/pdf' })
}

7) 플랫(양식/주석을 페이지 내용에 고정)

// 폼을 플랫(편집 불가로 고정). 엔진에 따라 form.flatten() 제공.
async function flattenForm(file) {
  const pdf = await PDFDocument.load(await file.arrayBuffer())
  const form = pdf.getForm()
  form.flatten()
  return new Blob([await pdf.save()], { type:'application/pdf' })
}

8) 비밀번호 해제(암호 보유·정당 권리 前제)

본인이 권한을 보유한 문서에서, 암호를 아는 경우에 한해

  • 브라우저 엔진이 암호로 로드(복호화) → 2) 미암호화로 재저장.
    (구체 API는 선택 엔진에 따라 상이)

오프라인 CLI 대안(로컬 실행, 업로드 없음)

# 예시) 암호를 알고 있을 때 복호화 후 새 파일 저장
qpdf --password=<PASSWORD> --decrypt in.pdf out.pdf

반대로 암호를 모르는 문서의 보호 해제는 지원하지 않습니다.

PDF 비교(시각/텍스트 2모드)

  1. 비주얼 비교: 두 PDF 페이지를 각각 캔버스로 렌더 → 픽셀/SSIM 차이 맵 생성(하이라이트).
  2. 텍스트 비교: 각 페이지 텍스트 추출→ 정규화(리가처/공백) → 라인/토큰 diff.

성능·UX 베스트프랙티스

  • Worker 풀로 파일 단위 동시 처리(예: 3~4개).
  • 해상도 가변: 미리보기는 저해상도, 내보내기는 고해상도.
  • 일괄 처리 파이프라인: 드롭 → 큐 → 진행률 표시 → 실패 건 재시도.
  • 대용량: 스트리밍 파서(가능 시), 페이지 단위 저장, 메모리 상한 수립.
  • 오프라인 PWA: 초기 리소스 캐시 후 네트워킹 차단 스위치 제공.

보안 점검표(내부 가이드)

  1. 도구 자체
    • 네트워크 호출 없음(옵션 토글) / CSP ‘self’ / SRI 적용
    • 위험 엔트리(/JS,/OpenAction,/EmbeddedFiles 등) 기본 차단
    • 자원 한도(페이지/객체/압축해제바이트/시간) 설정
  2. 업무 절차
    • 외부 발송 전 메타데이터 제거 의무화
    • 필요 시 AES-256 암호화 후 송부(조직 표준 암호 정책)
    • 스캔/OCR 결과는 검증 룰(필수 필드·합계) 적용
  3. 교육/홍보
    • 상용 업로드형 PDF 웹앱 사용 금지/차단 사유 안내
    • 도구 사용법 카드뉴스/매뉴얼 배포
  4. 감사 대응
    • 실행 로그(로컬만) 옵션 / 버전·해시 기록 / 릴리즈 노트 관리

오프라인 CLI 레시피(로컬, 업로드 無)

브라우저 구현이 까다로운 경우 임시/보완책으로 안내(모두 로컬 실행).

# ① 합치기
pdfunite a.pdf b.pdf out.pdf          # (대안: qpdf --empty --pages a.pdf 1-z b.pdf 1-z -- out.pdf)

# ② 분할(1–3, 4–6)
qpdf in.pdf --pages in.pdf 1-3 -- out-1.pdf
qpdf in.pdf --pages in.pdf 4-6 -- out-2.pdf

# ③ 회전(모든 페이지 90도)
qpdf in.pdf --rotate=+90:1-z out.pdf

# ④ 압축(가시적 래스터라이즈 방식, 품질 85%)
gs -sDEVICE=pdfwrite -dPDFSETTINGS=/printer -dColorImageDownsampleType=/Average \
   -dColorImageResolution=150 -dJPEGQ=85 -o out.pdf in.pdf

# ⑤ 메타데이터 제거
exiftool -all= -overwrite_original in.pdf

# ⑥ 폼/주석 플랫(렌더 기반)
mutool clean -d -gg in.pdf out.pdf    # 주석/양식의 페이지 렌더 고정

# ⑦ 텍스트 추출
pdftotext in.pdf out.txt

# ⑧ 비밀번호 해제(암호 알고 있을 때)
qpdf --password=<PASSWORD> --decrypt in.pdf out.pdf

구현·테스트 전략

  1. 테스트 코퍼스: 정상/손상/xref stream/오브젝트 스트림/접근성/스캔형 PDF 샘플셋 1만+.
  2. 에러 주도 개발: “포인터 근처/중간/오탈자/헤더Junk/Prev깨짐” 유형별 회귀 테스트.
  3. 품질 지표: 변환 성공률, 텍스트 정확도(단어·필드), 압축율, 처리 시간, 실패율.
  4. 릴리즈 가드: 새 의존성 빌드 시 SRI/서명·CVE 체크 자동화.

사용 시나리오(예)

  1. 이미지 스캔 묶음을 PDF로
    • 드롭(이미지들) → 순서 정렬 → 페이지 크기 맞춤 → PDF 저장 → 메타 제거 → 암호화.
  2. 긴 문서 분할/추출
    • 썸네일에서 범위 표시 → 추출/삭제 → 재정렬 → 회전 보정 → 저장.
  3. 스캔 PDF를 텍스트로
    • 텍스트 추출 실패 시 OCR 백업 → 리가처/공백 정규화 → CSV/JSON 내보내기.
  4. 외부 공유
    • 메타 제거 → 필요 시 Flatten → 암호화(PW 정책) → 발송 로그(로컬) 기록.

대안 포맷 병행(조직 정책 권고)

  • PDF + JSON/CSV/XML/Markdown 동시 제출(기계우선).
  • 표 데이터는 CSV 동봉, 본문은 Markdown/HTML, 최종 배포판만 PDF.
  • Tagged PDF(PDF/UA), PDF/A-2u 권장(접근성·검색성·호환성).
728x90
그리드형(광고전용)

댓글