728x90
목표 & 요구사항 정의
- 프라이버시: 서버 업로드 없음, 추적/로그 없음, 모든 처리는 로컬 브라우저/내 PC에서만.
- 오픈소스: 재현 가능한 빌드, 라이선스 명확, 의존성 투명.
- 기능 범위(15+개)
- 변환: JPEG/PNG/TXT → PDF, PDF → JPEG/PNG/TXT(텍스트 추출)
- 관리: 압축, 합치기, 분할, 페이지 추출/삭제/정렬/회전, 플랫(Flatten)
- 보안/프라이버시: 메타데이터 삭제, 비밀번호 해제(정당한 소유/암호 보유 시)
- 사용성: 드래그&드롭, 일괄 처리, 용량/건수 제한 없음.
- 확장성: 흔한 PDF 변칙(잘못된 xref, junk 헤더 등)에 대한 복구 경로(salvage path) 내장.
아키텍처 개요(브라우저 100% 클라이언트 사이드)
- 코어 엔진(WASM/JS)
- PDF 렌더/파서: 브라우저에서 PDF 로드·페이지 렌더·텍스트 추출·주석/양식 접근.
- PDF 조작 라이브러리: 페이지 합치기/분할/회전, 메타데이터/임베디드 제거, 폼 플랫 등.
- 이미지 코덱: JPEG/PNG/WebP/SVG 디코드·리인코드, 다운샘플/압축.
- OCR(옵션): 스캔/이미지 기반 PDF의 텍스트화(Tesseract.js 등).
- 런타임 분리
- Web Worker: 무거운 연산 분리(렌더/압축/텍스트 추출).
- OffscreenCanvas: 페이지 렌더·비주얼 비교 성능 최적화.
- 스토리지 & 파일 I/O
- File System Access API(지원 브라우저) 또는 in-memory Blob·IndexedDB.
- PWA(오프라인) + 캐시/서비스워커(단, 외부 통신 차단).
- 보안 하드닝
- CSP:
default-src 'self'
, 외부 스크립트/추적기 금지, WASM 무결성(SRI) 핀. - 네트워크 금지: 도구 실행 시 네트워킹 호출 자체를 막는 옵션(토글) 제공.
- CSP:
※ “특정 제품”을 쓰라는 뜻이 아닙니다. WASM 컴파일된 PDF 엔진/조작 라이브러리/이미지 코덱을 조합해 설계하는 방법론입니다.
프라이버시·보안 설계(위협모델)
- 데이터 흐름: 파일 → 브라우저 메모리 → 결과 파일. (네트워크 외부 유출 경로 0)
- 공격면 축소
- /JavaScript, /OpenAction, /Launch, /URI, /EmbeddedFiles 등 위험 요소 로드/실행 금지.
- 해제/압축 시 자원 한도(페이지 수, 객체 수, 압축 해제 최대 바이트, 시간 제한).
- 무결성
- WASM/JS 잠금(SRI, 고정 해시), 자동 업데이트 시 서명 검증.
- 사전 스캔(선택): PDF 구조 간단 검사(위험 키 탐지) → 차단/경고 후 진행.
- 정책: 사내 배포 시 “PDF는 기본 메타 제거 + 필요 시 암호화”를 의무화.
PDF “이상 vs 현실” & 복구 경로(salvage path)
- 이상적 절차:
%PDF-1.x
→startxref
→xref
→trailer
(/Root
) → 탐색. - 현실 문제
- 헤더 앞 junk로 모든 오프셋이 밀림.
startxref
오탈자/근처/중간 지점 가리킴,/Prev
체인 깨짐.- xref stream / object stream(압축) 존재, 텍스트 리가처(ff).
- 복구 핵심
- 헤더 위치 보정(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모드)
- 비주얼 비교: 두 PDF 페이지를 각각 캔버스로 렌더 → 픽셀/SSIM 차이 맵 생성(하이라이트).
- 텍스트 비교: 각 페이지 텍스트 추출→ 정규화(리가처/공백) → 라인/토큰 diff.
성능·UX 베스트프랙티스
- Worker 풀로 파일 단위 동시 처리(예: 3~4개).
- 해상도 가변: 미리보기는 저해상도, 내보내기는 고해상도.
- 일괄 처리 파이프라인: 드롭 → 큐 → 진행률 표시 → 실패 건 재시도.
- 대용량: 스트리밍 파서(가능 시), 페이지 단위 저장, 메모리 상한 수립.
- 오프라인 PWA: 초기 리소스 캐시 후 네트워킹 차단 스위치 제공.
보안 점검표(내부 가이드)
- 도구 자체
- 네트워크 호출 없음(옵션 토글) / CSP ‘self’ / SRI 적용
- 위험 엔트리(/JS,/OpenAction,/EmbeddedFiles 등) 기본 차단
- 자원 한도(페이지/객체/압축해제바이트/시간) 설정
- 업무 절차
- 외부 발송 전 메타데이터 제거 의무화
- 필요 시 AES-256 암호화 후 송부(조직 표준 암호 정책)
- 스캔/OCR 결과는 검증 룰(필수 필드·합계) 적용
- 교육/홍보
- 상용 업로드형 PDF 웹앱 사용 금지/차단 사유 안내
- 도구 사용법 카드뉴스/매뉴얼 배포
- 감사 대응
- 실행 로그(로컬만) 옵션 / 버전·해시 기록 / 릴리즈 노트 관리
오프라인 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
구현·테스트 전략
- 테스트 코퍼스: 정상/손상/xref stream/오브젝트 스트림/접근성/스캔형 PDF 샘플셋 1만+.
- 에러 주도 개발: “포인터 근처/중간/오탈자/헤더Junk/Prev깨짐” 유형별 회귀 테스트.
- 품질 지표: 변환 성공률, 텍스트 정확도(단어·필드), 압축율, 처리 시간, 실패율.
- 릴리즈 가드: 새 의존성 빌드 시 SRI/서명·CVE 체크 자동화.
사용 시나리오(예)
- 이미지 스캔 묶음을 PDF로
- 드롭(이미지들) → 순서 정렬 → 페이지 크기 맞춤 → PDF 저장 → 메타 제거 → 암호화.
- 긴 문서 분할/추출
- 썸네일에서 범위 표시 → 추출/삭제 → 재정렬 → 회전 보정 → 저장.
- 스캔 PDF를 텍스트로
- 텍스트 추출 실패 시 OCR 백업 → 리가처/공백 정규화 → CSV/JSON 내보내기.
- 외부 공유
- 메타 제거 → 필요 시 Flatten → 암호화(PW 정책) → 발송 로그(로컬) 기록.
대안 포맷 병행(조직 정책 권고)
- PDF + JSON/CSV/XML/Markdown 동시 제출(기계우선).
- 표 데이터는 CSV 동봉, 본문은 Markdown/HTML, 최종 배포판만 PDF.
- Tagged PDF(PDF/UA), PDF/A-2u 권장(접근성·검색성·호환성).
728x90
그리드형(광고전용)
댓글