Pythonで既存PDFにSVGの透かしを追加する
svglib、ReportLab、pypdfで既存PDFにSVGの透かしを合成します。不透明度、ページの切り抜き、回転、残るテキスト、重ね順を確認します。
目次
Pythonで既存のPDFにSVGの透かしを追加するには、SVGをReportLabの描画オブジェクトに変換し、小さなPDFのオーバーレイとして描画してから、pypdfで元の各ページに合成します。各ページの見える切り抜き範囲に基づいてオーバーレイの大きさと位置を決め、合成前にページの回転を正規化します。
不透明なページ背景の上にマークを表示する必要がある場合は、over=True を使います。半透明のマークでも最前面に配置できます。over=False では同じデザインが既存の内容の下に入り、塗りのあるページの裏に隠れる場合があります。
以下の完全な例は、独自のSVGと3ページの元PDFを作ります。異なるページサイズ、ゼロ以外の原点を持つ切り抜き範囲、回転したページを含めます。結果は新しいPDFになります。元のファイルは別に保管してください。
最初に文書に対する操作を選ぶ
| 出発点 | 適した手順 |
|---|---|
| 既存PDFの各ページにSVGのエンブレムが必要 | 以下のようにSVGのオーバーレイを描画し、既存ページへ合成する |
| 新しいPDFを一から構成する | ReportLabでSVGを描画する手順を直接使う |
| 既存SVGに適切なデザインがすでにある | 元のベクターデータを保つ。トレースは不要 |
| ロゴがラスター画像にしかない | まず適したデータを準備し、PDF合成は別に検証する |
pypdfの透かしガイドは、ページの上に置く内容と下に置く内容を区別しています。ここでの「透かし」は見えるエンブレムを指し、実際の重なり順は over が決めます。セキュリティマーク、改ざん防止方法、署名済み文書の処理ではありません。
1. 小さな対応SVGから始める
このテストは、中央が開いた三角形の枠と3本のバーという2つの塗り付きパスを使います。どちらのパスにも 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 として保存します。入力のデザインをすべて含み、3つの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ページ目は、元の背景が不透明なので、上に重ねた出力ではエンブレムが見え、下に敷いた出力では隠れるはずです。他の2ページは同じ形を繰り返すのではなく、切り抜きと回転を試します。
自分の通常の未署名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ポイントの描画になります。CSSの1ピクセルは0.75ポイントです。コードは、SVGピクセルがPDFポイントと等しいと仮定せず、drawing.width と drawing.height を読みます。
この例はSVGのビューポートを各ページの可視幅の65%にし、縦横比を保って中央に置きます。中心を合わせるのは宣言した描画の長方形であり、エンブレムの視覚的な重心ではありません。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 だけ平行移動することで、切り抜き範囲がゼロ以外から始まる場合も、対象の可視領域内へ配置できます。expand=False は、合成によるページボックスの拡大を防ぎます。
4. SVGプレビューだけでなく保存したPDFを確認する

保存したPDFを開き直し、コンテンツストリーム、ページボックス、回転、抽出テキスト、画像リソースを調べてから、Poppler 26.02で可視切り抜き範囲を96dpiで描画しました。最終的なファイル検査は40項目を通過しました。ブラウザー比較でも、保存ファイルのハッシュ、読み込んだ描画、元解像度の12個のピクセル点を確認しました。
| このサンプルの確認 | 観察した結果 |
|---|---|
| ページ数と既知の元テキスト | 両方の出力に全3ページと予定した元テキストが残った |
| オーバーレイ形状 | 塗り付きパス2つ、調べた頂点18個、各ページの非ストロークのアルファ0.25 |
| 追加した画像リソース | 独自の元ページと出力ページに追加はなし |
| 配置とページボックス | 宣言した切り抜き範囲と保存した変換の計算に一致し、出力の回転はゼロ |
| 枠と開いた中央の確認点 | 上重ねの枠ピクセルは想定した合成色の±2 RGB値以内で、中央の穴はページ背景を保った |
| 不透明背景での下敷き | 1ページ目の元と下敷きの96dpi描画で、異なるピクセルはゼロ |
配置監査は当初、理想的な浮動小数点座標との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のグラフィックスシステムにおける描画オブジェクトとPDF描画。
- pypdf 6.10.0:PDFヘッダーバージョン — 出力で使う機能に対応するPDFバージョンの宣言。
元からベクターのデータがあれば、それを残します。適したラスターのロゴしかない場合は、PerfectVectorで編集可能なSVGを準備し、そのデータを調べてから、新しく合成したPDFを別に検証してください。


