記事一覧

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 のほう。pngquantoxipng を通してから埋め込むといい。アルファチャンネルはどちらの最適化でも壊れない。

関連レシピ

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、PNG と TrueType を pure Go で処理する。

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · Read the docs