Python으로 기존 PDF에 SVG 워터마크 추가하기
svglib, ReportLab, pypdf로 기존 PDF에 SVG 워터마크를 병합합니다. 불투명도, 잘린 페이지, 회전, 텍스트 보존, 겹침 순서를 확인하세요.
목차
Python으로 기존 PDF에 SVG 워터마크를 추가하려면 SVG를 ReportLab drawing으로 변환하고 작은 PDF 오버레이에 렌더링한 다음, pypdf로 각 원본 페이지에 병합합니다. 각 페이지에서 보이는 크롭 박스를 기준으로 오버레이의 크기와 위치를 정하고 병합 전에 페이지 회전을 정규화하세요.
불투명한 페이지 배경 위에서도 표시가 보여야 한다면 over=True를 사용합니다. 반투명 표시도 위에 놓을 수 있습니다. over=False이면 같은 아트워크가 기존 콘텐츠 아래에 들어가 채워진 페이지 뒤로 사라질 수 있습니다.
아래 전체 예제는 자체 SVG와 3페이지 원본 PDF를 만듭니다. 서로 다른 페이지 크기, 원점이 0이 아닌 크롭 박스, 회전된 페이지가 포함됩니다. 결과는 새 PDF이며 원본 파일은 별도로 보관하세요.
문서에 필요한 작업부터 선택하기
| 출발점 | 유용한 작업 흐름 |
|---|---|
| 기존 PDF의 모든 페이지에 SVG 엠블럼이 필요함 | 아래처럼 SVG 오버레이를 렌더링하고 기존 페이지에 병합 |
| 새 PDF를 처음부터 구성함 | ReportLab SVG drawing 작업 흐름을 직접 사용 |
| 기존 SVG에 이미 올바른 아트워크가 있음 | 네이티브 벡터 원본을 유지하며 트레이싱은 불필요 |
| 로고가 래스터 이미지로만 남아 있음 | 적절한 아트워크부터 준비한 뒤 PDF 병합을 별도로 시험 |
pypdf의 워터마크 가이드는 페이지 위와 아래에 놓는 콘텐츠를 구분합니다. 여기서 “워터마크”는 보이는 엠블럼을 뜻하며, 실제 쌓임 순서는 over가 결정합니다. 보안 표시, 변조 방지 방법, 서명된 문서를 위한 작업 흐름은 아닙니다.
1. 지원되는 작은 SVG로 시작하기
이 시험은 채워진 경로 두 개를 사용합니다. 가운데가 뚫린 삼각형 프레임과 막대 세 개입니다. 두 경로 모두 fill-opacity="0.25"를 가집니다. 텍스트, 필터, 내장 이미지, 외부 리소스는 없습니다.
이처럼 입력 범위를 좁히는 것이 중요합니다. svglib는 유용한 SVG 하위 집합을 지원하지만, 문서화된 제한에는 제한적인 CSS 지원과 마스크·foreignObject 같은 미지원 기능이 포함됩니다. 브라우저에서 올바르게 보이는 SVG도 변환 후 확인해야 합니다. 파일에 래스터 콘텐츠가 있다면 결과를 전부 벡터로 취급하기 전에 SVG의 내장 이미지를 찾는 방법을 읽어 보세요.
이 시험 파일의 초기 버전에는 프레임과 함께 SVG 텍스트도 있었습니다. 시험한 도구 조합에서는 변환된 텍스트가 완전 불투명하게 칠해지고 경로는 0.25 불투명도를 유지했습니다. 그 텍스트를 직접 만든 경로 아트워크로 바꾸고 교체본을 시험했습니다. 최종 결과는 경로만 있는 이 사례를 확인하며, 모든 SVG 텍스트나 스타일 조합의 불투명도가 올바르다는 뜻은 아닙니다.
2. 전체 예제 실행하기
시험 환경은 Python 3.12.14, svglib 2.2.0, ReportLab 4.4.9, pypdf 6.10.0이었습니다. 현재 pypdf 문서는 더 최신 릴리스도 설명하므로 시험한 버전과 문서 버전을 구분하세요.
별도 환경에 해당 버전을 설치합니다.
python -m venv .venv
source .venv/bin/activate
python -m pip install svglib==2.2.0 reportlab==4.4.9 pypdf==6.10.0다음을 watermark_demo.py로 저장합니다. 모든 입력 아트워크를 포함하고 PDF 파일 세 개를 모두 생성하므로 별도로 다운로드할 예제 PDF나 SVG가 없습니다.
from io import BytesIO
from pathlib import Path
from pypdf import PdfReader, PdfWriter, Transformation
from pypdf.generic import RectangleObject
from reportlab.graphics import renderPDF
from reportlab.lib.colors import HexColor
from reportlab.pdfgen import canvas
from svglib.svglib import svg2rlg
SVG = '''<svg xmlns="http://www.w3.org/2000/svg" width="240" height="80"
viewBox="0 0 240 80">
<path fill="#315d6f" fill-opacity="0.25" fill-rule="nonzero"
d="M10 60 L45 8 L80 60 Z M31 48 L59 48 L45 27 Z"/>
<path fill="#315d6f" fill-opacity="0.25"
d="M95 23 H216 V31 H95 Z M95 39 H190 V47 H95 Z
M95 55 H205 V63 H95 Z"/>
</svg>'''
def make_demo_source(destination):
stream = BytesIO()
document = canvas.Canvas(stream, pagesize=(420, 300))
specifications = [
(420, 300, '#f5eddb', 'SOURCE PAGE ONE'),
(300, 420, '#ffffff', 'SOURCE PAGE TWO'),
(360, 260, '#e8eef0', 'SOURCE PAGE THREE')
]
for width, height, background, label in specifications:
document.setPageSize((width, height))
document.setFillColor(HexColor(background))
document.rect(0, 0, width, height, fill=1, stroke=0)
document.setFillColor(HexColor('#183a42'))
document.setFont('Helvetica-Bold', 16)
document.drawString(28, height - 40, label)
document.setFont('Helvetica', 10)
document.drawString(28, height - 62, 'Original text remains in the PDF content.')
document.drawString(28, 28, 'Original vector line and opaque page background')
document.setStrokeColor(HexColor('#c47b44'))
document.setLineWidth(3)
document.line(28, 48, width - 28, 48)
document.showPage()
document.save()
reader = PdfReader(stream)
reader.pages[1].cropbox = RectangleObject([10, 20, 290, 400])
reader.pages[2].rotate(90)
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
writer.write(destination)
def make_overlay(svg_path, width, height):
drawing = svg2rlg(str(svg_path))
if drawing is None or drawing.width <= 0 or drawing.height <= 0:
raise ValueError('The SVG did not produce a usable drawing')
scale = width * 0.65 / drawing.width
left = (width - drawing.width * scale) / 2
bottom = (height - drawing.height * scale) / 2
drawing.scale(scale, scale)
stream = BytesIO()
overlay = canvas.Canvas(stream, pagesize=(width, height))
renderPDF.draw(drawing, overlay, left, bottom)
overlay.save()
return PdfReader(stream).pages[0]
def add_artwork(source_path, svg_path, output_path, *, over):
reader = PdfReader(source_path)
if reader.is_encrypted:
raise ValueError('Encrypted inputs are outside this example')
if reader.get_fields():
raise ValueError('Form handling is outside this example')
writer = PdfWriter()
writer.clone_document_from_reader(reader)
# Cloning in pypdf 6.10.0 does not retain the source header version.
# This overlay uses transparency, introduced in PDF 1.4.
writer.pdf_header = max(reader.pdf_header, '%PDF-1.4')
for page in writer.pages:
if page.rotation:
page.transfer_rotation_to_content()
box = page.cropbox
width, height = float(box.width), float(box.height)
overlay = make_overlay(svg_path, width, height)
page.merge_transformed_page(
overlay,
Transformation().translate(float(box.left), float(box.bottom)),
over=over,
expand=False
)
writer.write(output_path)
if __name__ == '__main__':
output = Path('watermark-output')
output.mkdir(exist_ok=True)
artwork = output / 'emblem.svg'
artwork.write_text(SVG)
source = output / 'source.pdf'
make_demo_source(source)
add_artwork(source, artwork, output / 'overlay-top.pdf', over=True)
add_artwork(source, artwork, output / 'underlay.pdf', over=False)실행합니다.
python watermark_demo.pywatermark-output/source.pdf, overlay-top.pdf, underlay.pdf를 엽니다. 1페이지의 원래 배경이 불투명하므로 위쪽 오버레이 출력에는 엠블럼이 보이고 아래쪽 출력에서는 가려져야 합니다. 나머지 두 페이지는 같은 페이지 도형을 반복하는 대신 자르기와 회전을 시험합니다.
자신의 일반적인 미서명 PDF에 적용하려면 make_overlay()와 add_artwork()를 유지한 뒤 다음처럼 호출하세요.
add_artwork(
Path("original.pdf"),
Path("emblem.svg"),
Path("watermarked.pdf"),
over=True
)오버레이가 투명도를 사용하므로 작성기는 원본 헤더 버전을 유지하되 최소 PDF 1.4로 설정합니다. pypdf 6.10.0에서는 문서를 복제하기만 하면 원본이 더 최신 버전을 선언했어도 작성기 기본 PDF 1.3 헤더가 남을 수 있습니다. 명시적 할당은 이런 낮춤을 피하기 위한 것이며 적합성 검증은 아닙니다. 작성기 헤더 문서는 파일에 사용된 모든 기능을 지원하는 버전을 권장합니다.
방어 조건은 암호화된 입력과 감지된 양식 필드를 거부합니다. 완전한 호환성 또는 서명 감사는 아닙니다. 서명된 문서, 양식, 주석, 암호화, 문서별 요구 사항은 작업 중인 파일을 교체하기 전에 별도의 작업 흐름과 검증이 필요합니다.
3. 보이는 페이지 좌표에 아트워크 배치하기
PDF의 1포인트는 1/72인치입니다. 이 svglib 버전에서 SVG의 단위 없는 240 × 80 치수는 180 × 60포인트 drawing을 만듭니다. CSS 픽셀 1개가 0.75포인트에 대응합니다. 코드는 SVG 픽셀과 PDF 포인트가 이미 같다고 가정하지 않고 drawing.width와 drawing.height를 읽습니다.
예제는 SVG 뷰포트를 각 보이는 페이지 너비의 65%로 만들고 종횡비를 유지하면서 가운데 배치합니다. 엠블럼의 시각적 무게 중심이 아니라 선언된 drawing 직사각형을 가운데 정렬합니다. 65%는 예제의 배치 선택이며 워터마크 표준이 아닙니다. 너비만 기준으로 하는 이 배율은 여기의 가로로 긴 엠블럼에 적합합니다. 세로로 긴 교체 아트워크는 보이는 페이지 높이를 넘을 수 있습니다. 함수를 조정할 때는 사용 가능한 너비와 높이 모두로 배율을 제한하고 자신의 PDF에 적용하기 전에 모든 페이지를 검사하세요.
| 페이지 | 원본 조건 | 출력의 보이는 크롭 박스, 포인트 단위 | 명목 SVG 뷰포트 너비 |
|---|---|---|---|
| 1 | 420 × 300 가로 페이지 | 0, 0에서 420, 300까지 | 273pt |
| 2 | 300 × 420 페이지, 안쪽으로 자름 | 10, 20에서 290, 400까지 | 182pt |
| 3 | 90° 회전된 360 × 260 페이지 | 정규화 후 0, 0에서 260, 360까지 | 169pt |
transfer_rotation_to_content() 호출은 회전을 콘텐츠와 페이지 박스에 반영합니다. 보는 사람을 기준으로 원래 글자를 똑바로 돌리는 것은 아닙니다. 원래 3페이지 텍스트는 원본 표시 화면에서의 방향을 유지하고, 새 엠블럼은 정규화된 표시 좌표에 배치됩니다. pypdf 페이지 API는 페이지 병합 전에 회전을 옮기도록 권장합니다.
오버레이는 자체 원점에서 시작합니다. cropbox.left와 cropbox.bottom만큼 이동하면 크롭 박스가 0 이외의 위치에서 시작해도 대상의 보이는 영역 안에 배치됩니다. expand=False는 병합으로 페이지 박스가 커지는 것을 막습니다.
4. SVG 미리보기뿐 아니라 저장 PDF 확인하기

저장된 PDF를 다시 열어 콘텐츠 스트림, 페이지 박스, 회전, 추출 텍스트, 이미지 리소스를 검사한 뒤 Poppler 26.02로 보이는 크롭 박스를 96dpi로 렌더링했습니다. 최종 파일 검사는 40개 항목을 통과했습니다. 브라우저 비교로 저장 파일 해시, 불러온 렌더링, 원래 해상도의 픽셀 검사 12개도 확인했습니다.
| 이 시험 파일의 검사 | 관찰 결과 |
|---|---|
| 페이지 수와 알려진 원본 텍스트 | 두 출력 모두 3페이지와 예상 원본 텍스트 유지 |
| 오버레이 도형 | 각 페이지에 채워진 경로 2개, 검사한 꼭짓점 18개, 채우기 알파 0.25 |
| 추가 이미지 리소스 | 이 원본 및 출력 페이지에는 없음 |
| 배치와 페이지 박스 | 선언한 크롭 박스 및 직렬화된 변환 계산과 일치하며 출력 회전은 0 |
| 프레임과 열린 중심의 픽셀 검사 | 위쪽 오버레이 프레임 픽셀이 ±2 RGB 값 이내에서 예상 혼합과 일치하고 열린 중심은 페이지 배경 유지 |
| 불투명 배경의 아래쪽 표시 | 96dpi에서 1페이지 원본과 아래쪽 출력의 차이가 0픽셀 |
배치 감사는 처음에 이상적인 부동소수점 좌표와 0.00001pt 허용 오차로 엄격히 비교했을 때 실패했습니다. ReportLab은 변환 행렬을 유한 정밀도로 직렬화합니다. 그 실패 결과를 보존하고 예상 직렬화 행렬과 반올림 한계를 독립적으로 계산한 뒤 저장된 꼭짓점을 해당 값과 비교했습니다. 이상 좌표와의 최대 관찰 차이는 약 0.000054pt로, 도출한 한계 0.000086pt 이내였습니다. 이는 이 파일에서 시각적으로 가운데 놓인 배치를 확인하며 보편적인 정밀도 보장은 아닙니다.
Poppler가 설치되어 있다면 간단한 로컬 시각 검사는 다음과 같이 합니다.
pdftoppm -cropbox -r 96 -png watermark-output/overlay-top.pdf watermark-output/top
pdftoppm -cropbox -r 96 -png watermark-output/underlay.pdf watermark-output/below적절한 확대율에서 모든 페이지를 검사하세요. 예상 원본 텍스트를 선택하거나 추출하고, 페이지 치수를 확인하며, 워터마크가 경로로 남았는지 이미지를 추가했는지도 검사합니다. 선명한 스크린샷만으로는 이런 파일 속성을 확인할 수 없습니다. 이 시험은 인쇄, 뷰어 호환성, 파일 크기 절감, 워터마크 제거에 대한 저항성을 측정하지 않았습니다.
올바른 단계에서 실패 해결하기
| 증상 | 확인 또는 변경 사항 |
|---|---|
| 표시가 사라짐 | over=False와 over=True를 비교하고 불투명 원본 배경 확인 |
| 잘린 페이지에서 표시 위치가 어긋남 | 고정 페이지 크기 대신 크롭 박스의 크기와 원점 사용 |
| 회전된 페이지에서 표시 방향이 예상과 다름 | 보이는 치수를 계산하기 전에 회전을 정규화하고 결과 화면 검사 |
| 불투명도나 스타일이 바뀜 | SVG를 단순화하고 변환 PDF의 페인팅 상태를 검사한 뒤 바로 그 파일을 다시 시험 |
| 사진처럼 보이거나 픽셀화된 영역이 남음 | SVG에 내장 이미지가 있는지 검사합니다. SVG 포장으로 비트맵이 경로가 되지는 않습니다. |
| 텍스트, 양식, 문서 동작이 바뀜 | 병합 성공이 모든 것을 보존한다고 가정하지 말고 멈춰서 문서의 실제 기능 검증 |
원본 PDF는 페이지 콘텐츠로 유지되며 스크립트는 전체 페이지를 래스터화하지 않습니다. 그렇다고 모든 입력 PDF가 임의의 병합을 거쳐 그대로 유지된다는 증거는 아닙니다. 사본을 사용하고, 큰 묶음을 처리하기 전에 반드시 유지되어야 할 콘텐츠와 문서 기능을 정의하세요.
병합 전에 누락된 아트워크 준비하기
기존 로고가 이미 깨끗한 SVG라면 유지하세요. 적합한 평면 래스터 로고만 남아 있다면 PerfectVector의 로고 벡터화 도구로 이 Python 단계 전에 편집 가능한 아트워크를 준비할 수 있습니다. 복원된 실루엣, 구멍, 작은 디테일을 검사하고 SVG 변환과 저장 PDF 검사를 다시 실행하세요. 이 예제는 직접 만든 네이티브 경로를 사용했으며 PerfectVector 변환을 실행하지 않았습니다. 벡터화는 페이지 병합, 서명 검증, svglib 호환성 보장을 수행하지 않습니다.
자주 묻는 질문
pypdf가 SVG 워터마크를 직접 읽을 수 있나요? 이 예제는 먼저 svglib와 ReportLab으로 SVG를 PDF 오버레이로 변환합니다. 그 뒤 pypdf가 PDF 페이지 콘텐츠를 병합하며, 이 파이프라인에서 SVG 소스를 해석하지는 않습니다.
워터마크가 PDF 페이지 뒤로 사라지는 이유는 무엇인가요?
아래쪽에 놓은 표시는 기존 불투명 배경으로 가려질 수 있습니다. 보이는 위쪽 오버레이에는 over=True를 사용하고 아트워크 불투명도는 별도로 설정하세요. 모든 페이지의 실제 결과를 확인합니다.
벡터 아트워크와 원본 텍스트가 보존되나요? 시험한 경로 전용 엠블럼은 채워진 경로로 남았고, 3페이지 모두에서 예상 원본 텍스트를 추출할 수 있었습니다. 이 시험 파일 밖의 SVG 및 PDF 문서 기능은 별도로 검증해야 합니다.
PDF를 다시 쓰면 디지털 서명이 유지되나요? 이 예제를 서명 문서 작업 흐름으로 사용하지 마세요. 파일을 다시 쓰며 서명 보존이나 유효성을 시험하지 않습니다. 서명된 원본을 보관하고 해당 서명 요구 사항에 맞는 작업 흐름을 사용하세요.
출처
- pypdf: 스탬프 또는 워터마크 추가하기 — 위·아래 겹침 순서, 병합, 회전 안내입니다.
- pypdf: PageObject API — 페이지 박스, 회전 이동, 변환 병합 동작입니다.
- svglib 프로젝트 문서 — SVG에서 ReportLab으로의 변환, 단위, 지원 기능 제한입니다.
- ReportLab 그래픽 가이드 — ReportLab 그래픽 시스템의 drawing 객체와 PDF 렌더링입니다.
- pypdf 6.10.0: PDF 헤더 버전 — 출력에 사용한 기능을 지원하는 PDF 버전을 선언합니다.
네이티브 아트워크가 있다면 유지하세요. 적합한 래스터 로고만 남아 있다면 PerfectVector로 편집 가능한 SVG를 준비하고, 해당 아트워크를 검사한 뒤 새로 병합한 PDF를 별도로 검증합니다.


