gpdf で透過 PNG を埋め込む方法
PNG のバイト列を c.Image にそのまま渡す。gpdf がアルファチャンネルを PDF の SMask に変換し、透過部分はそのまま描画される。
質問を言い換えると
ロゴや印影を 背景透過の PNG で持っている。Photoshop や Figma が書き出す RGBA の PNG だ。これを gpdf で PDF に埋め込んだとき、透過部分はそのまま透過になるのか? それとも周りに白い四角が出てしまうのか?
即答
PNG のバイト列を c.Image に渡すだけ。それ以上の指定はいらない。gpdf がアルファチャンネルをデコードして、PDF の SMask (ソフトマスク) オブジェクトを画像と一緒に書き出す。透明画素はそのまま透明として描画される。
logo, _ := os.ReadFile("logo.png")
c.Image(logo, template.FitWidth(document.Mm(40)))
これで全部。白背景にフラット化する必要も、RGBA を RGB に変換する必要も、「透過を有効化する」フラグも要らない。 PNG は PNG のまま PDF に届く。
動くサンプルコード
透過を実際に確認するには、PNG の下に何かが見えていないと意味がない。本文の上に透かしを重ねるパターンが定番で、page.Absolute でロゴを座標固定し、通常フローのコンテンツがその下に流れる構図にする。
package main
import (
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
stamp, err := os.ReadFile("draft-stamp.png")
if err != nil {
log.Fatal(err)
}
doc := gpdf.NewDocument(
gpdf.WithPageSize(gpdf.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("四半期レポート — 2026 Q1", template.FontSize(20), template.Bold())
c.Text("第 1 四半期の売上は前年同期比 38% 増。エンタープライズ顧客の更新と、金融機関 3 社の新規獲得が牽引した。インフラ投資が頭打ちとなったことで営業利益率は 24% に拡大。")
c.Text("期末の従業員数は 142 名。前期末の 128 名から 14 名増加し、うち 9 名がエンジニアリング部門の採用。")
})
})
page.Absolute(document.Mm(60), document.Mm(120), func(c *template.ColBuilder) {
c.Image(stamp, template.FitWidth(document.Mm(80)))
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("report-draft.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
draft-stamp.png は赤の太字で「DRAFT」と書かれた、背景完全透過の RGBA PNG を想定している。本文の上に乗ると、透明な画素を通して下の段落が透けて見える。draft-stamp.png をロゴでも印影でも署名画像でも置き換えていい — 同じコードパス、同じ SMask 処理が走る。
gpdf が PNG に対して何をしているのか
面白いのは Writer 側の処理。PDF には「RGBA 画像」という単一オブジェクトは存在しない。RGB 画像オブジェクトと、それに対応するグレースケールの SMask (ソフトマスク) 画像がペアになり、SMask の各画素値がメイン画像の各画素のアルファ値として扱われる。レンダリング時に PDF リーダーが両者を合成する。
PNG を渡すと、レンダラ (document/render/pdftarget.go) は画素を 1 周走査する:
- 24 bit の RGB がメイン画像ストリームに格納され、FlateDecode で圧縮される
- 8 bit のアルファが別の SMask ストリームに格納され、同じく FlateDecode される
- 画像辞書に
/SMask <ref>が追加され、アルファストリームを参照する
すべてのアルファサンプルが 0xFF (完全不透明) だった場合、gpdf はアルファバッファを破棄して SMask の書き出しもスキップする。JPEG 相当の不透明 PNG なら追加コストはゼロ。コストが発生するのはアルファが実際に意味を持つ場合だけ。
このパス全体が pure Go で動く — 標準ライブラリの image/png がデコードを担当し、compress/flate が圧縮する。CGO も libpng 依存もない。 macOS から linux/arm64 (Lambda 等) へのクロスコンパイルも、依然として 1 個のスタティックバイナリにまとまる。
JPEG の罠
「透過 PNG のはずがいつの間にか JPEG だった」というケースは要注意。JPEG はアルファチャンネルを持てないので、書き出した時点で既に透過は失われている。書き出しツールは適当な背景色 (たいていは白) にアルファをフラット化する。
c.Image(jpegBytes) 自体は普通に動くが、本来透明だった部分には不透明な白 (黒、ピンクのこともある) の四角形が残る。修正は上流側 — PNG として再書き出しするしかない。gpdf 側に JPEG から透過を復元するフラグはない。
「PNG-8」のパレット透過は別件で、gpdf は標準の image/png を使うのでパレット PNG も正しく扱える。問題は資産管理のパイプラインのどこかで JPEG 化されるケース — 一度落ちたデータは戻らない。
サイズ調整と透かし
実用上カバーすべき拡張は 2 つ。
ロゴのスケーリング: template.FitWidth(document.Mm(40)) または template.FitHeight(document.Mm(20)) を渡す。PNG はフル解像度のままデコードされ、レンダリング時に PDF の座標変換で縮小される — アルファ側もリサンプリングなし。エッジは鮮鋭なまま。
斜めの「DRAFT」透かし: 透かしを薄いアルファ (25〜40% 程度) の PNG として書き出し、上のサンプルと同じ要領で page.Absolute で配置する。アルファは画素単位なので、透かし内で不透明度を変化させるのも自由 — グラデーションのフェード、ロゴの実線部分の周りだけ半透明、など。PDF リーダーが下のテキストとちゃんと合成してくれる。
ピクセル単位で 30% 透過を保証したいなら、画像エディタ側でアルファをベイクする判断が必要。gpdf は受け取ったアルファ値をそのまま再現するだけで、Builder API には画像単位の不透明度乗算オプションは用意していない。
ファイルサイズの目安
アルファ付き PNG → RGB ストリーム + グレースケール SMask ストリームになるので、アルファなしと比べておよそ 33% 大きくなる。100 KB の不透明 PNG が 133 KB 程度になる、というイメージ。ロゴ 1 個ならまったく気にならない。50 ページのレポートに毎ページ透かしを入れた場合も、SMask は 1 度登録されて各ページから参照されるだけなので、データの重複はない。
画像 1 枚で何 MB にも膨れる場合、原因は gpdf のエンコーディングではなく元 PNG のほう。pngquant や oxipng を通してから埋め込むといい。アルファチャンネルはどちらの最適化でも壊れない。
関連レシピ
- gpdf で日本語フォントを埋め込む方法 — 「バイト列をそのまま渡す」の TrueType 版
- Go で 50 行以内に請求書 PDF を作る — 透過のある会社ロゴが実際の文書でどこに収まるか
- なぜ gpdf は他の Go PDF ライブラリより 10〜30 倍速いのか — pure Go のデコードパスが何マイクロ秒を消費し、何を節約しているか
gpdf を使ってみる
gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、PNG と TrueType を pure Go で処理する。
go get github.com/gpdf-dev/gpdf