📝

【PDF圧縮】約40年続く業界標準Ghostscriptを、Rust 自前実装で抜いた話

に公開3

はじめに

皆さん如何お過ごしでしょうか?
最近はどの技術記事もAI一色で食傷気味の長嶋です。

さて本日は、個人開発しているデスクトップアプリ Karui に PDF 圧縮機能を載せてみたら意外と深い穴にハマったので、 その記録です。

https://karui.app/ja

ちなみにこのKaruiは Tauri v2で実装されており、Win/Mac両対応のデスクトップ画像圧縮アプリです。Tauriについては以前の記事で書いております。
https://zenn.dev/genshi_ai/articles/fa96e541c480d2

そして最初に結論。

8 種類のテスト PDF(公文書 / LaTeX 論文 / Word / Excel / PowerPoint / 学術ジャーナル等)の 全件で Ghostscript の /ebook プリセットを上回りました

「Ghostscript」 が分からない人もいると思うので、 そこから書きます。

そもそも Ghostscript(gs)って?

PDF 圧縮ツールといえば SmallPDF / iLovePDF / Adobe Acrobat / PDF24 あたりが有名どころ。
オンライン系サービスの内部実装は非公開ですが、 OSS / ローカル系で「画質を落として PDF を小さくする」 ジャンルでは Ghostscript が長年の定番です。
PDF24 は公式 changelog でインストーラに gs を同梱と明記、 OSS の minimalpdfcompress も内部で gs を呼んでいます。

Ghostscript は 1988 年に L. Peter Deutsch が公開した PostScript / PDF インタプリタで、 約 40 年続く老舗 OSS。 現在は 1993 年設立の Artifex Software が所有・メンテナンスしています。 PDF 関連の業務全般をこなす万能ツールです。

gs の圧縮プリセットは 4 段階:

プリセット 用途 圧縮の強さ
/screen 画面表示用 強(潰しすぎで実用に堪えない)
/ebook 電子書籍用 中強 ← 本記事の比較対象
/printer 印刷用
/prepress 商用印刷用 最弱

/ebook実用画質を保ったまま使える gs プリセットの中で最も強い設定。
これより圧縮率が高ければ「実用画質で gs 越え」と言えるラインです。
/screen はさらに潰しますが、 画像が 72 DPI まで荒く下げられて見栄えが悪くなるので、 一般用途には選びにくい設定です。

ライセンスは AGPLv3 + 別途商用ライセンス。 商用プロダクトに gs を組み込むなら別途有償契約が必要で、 これが結構な額。 商用組み込みのハードルが高いのが現状で、 ここを Rust 実装 + 軽量ライセンスで置き換えられないか、 というのが Karui PDF 圧縮の出発点です。

今回はPDF圧縮の定番であるGhostscriptをベンチマークとして、それを超える実装が出来ないかを試行錯誤しました。

結果サマリー: gs に 8 戦 8 勝

今回はジャンルの異なるPDF8種類を用意して比較しました。

# PDF ページ数 元サイズ 種類
1 政府通達文書(日本語、 タグ付き) 28 2.0 MB 公文書
2 業務報告書(日本語) 1 122 KB 小規模ビジネス文書
3 LaTeX 英論文(arxiv 形式) 5 195 KB 学術論文
4 学術ジャーナル(nutrients 誌) 27 1.3 MB 図表入り論文
5 カンファ論文 + 画像(WACV) 9 7.1 MB 画像多めの英論文
6 Excel 出力(罫線多め) 8 737 KB Office 出力
7 Word 出力(画像主体) 10 3.9 MB Office 出力
8 PowerPoint プレゼン 19 6.7 MB スライド資料

8 種類の PDF を gs と Karui で同条件圧縮した結果:
数値は削減率。「69% 削減」 = 元の 31% まで縮んだ意味。 太字 = Karui の勝ち

PDF gs/ebook Karui 差分
政府通達文書 51% 69% +18pt
業務報告書 48% 74% +26pt
LaTeX 英論文 49% 49% +1pt
学術ジャーナル 74% 74% +0.2pt
カンファ + 画像 93% 94% +1pt
Excel 出力 71% 72% +1pt
Word 画像主体 69% 95% +26pt
PowerPoint 54% 67% +13pt
戦績 8 戦 8 勝

業務文書系では大差勝ち、 学術論文 / Excel ではほぼ互角ですが負けはなし。
特に Word 画像主体は 3.9 MB → 0.2 MB(95% 削減)

そもそも PDF って中身は何が入ってるの?

PDF は 1 ファイル = ものを詰めた箱。 中にはこんなものが入っています:

┌─────────────────────────────────┐
│  PDF ファイル(箱)              │
│                                 │
│  ┌─ ページ 1                    │
│  │   "ここに『あ』を書け"        │
│  │   "ここに画像を貼れ"          │
│  ├─ ページ 2                    │
│  │   ...                        │
│  │                              │
│  ├─ 埋め込みフォント             │
│  │  ("MS 明朝" 丸ごと)         │
│  ├─ 埋め込み画像                 │
│  │  (JPEG / PNG 等)            │
│  ├─ 文書プロパティ               │
│  └─ アクセシビリティ情報         │
└─────────────────────────────────┘
  • ページ自体は薄い: ページの中身は「ここに『あ』を書け」「この画像をここに貼れ」という 指示書 のみ。
  • 重いのはフォントと画像: PDF はどの環境でも同じに見えるよう、 使ったフォントを 丸ごと埋め込みます
  • 無駄が多い: 重複データ・使っていないデータ・冗長な書き方が大量に紛れ込みがち。

つまり PDF を縮めるということは
「使ってないものを捨てる」
「同じものを 1 つにまとめる」
「画像とフォントを再エンコードする」
を地道に積み上げる作業です。

Karui がやっていること(全体像)

PDF ファイル

① 画像を再エンコード(解像度も適正化)

② フォントから「使ってない字」を削る

③ フォントを古い書き方 → 新しいコンパクトな書き方に翻訳

④ ページの描画コマンドを短く書き直す

⑤ 重複している設定をまとめる

⑥ 不要なメタ情報を消す

出力 PDF

ここから効いた順に解説します。

用語の準備

  • % 削減率: 元のサイズからどれだけ縮んだか。 「68.8% 削減」 = 元の 31.2% まで縮んだ意味。
  • pt(パーセントポイント): 比率同士の差分。 「Karui 68.8% vs gs 51.1% は +17.7pt」 のように使う。 単純な引き算で出る数字。
  • glyph(グリフ): 「字」 1 つ 1 つの絵のデータ。

施策 1: フォントから「使われていない字」を削る ← 単発最大の削減

身近な例え

日本語の PDF に 200 文字書いたとき、 中に入るフォントデータは「使った 200 字分」 ではありません。 明朝体フォント丸ごと数 MB がそのまま埋め込まれます。 漢字 5 万字分の字形データが、 200 字しか使わないのに全部入っている状態。

「1 通の手紙を書くために、 国語辞典を丸ごと封筒に入れて送っている」 ようなもの。
PDF 仕様上「フォントは全部入れていい」 ことになっているので、 ツールは律儀に丸ごと埋め込みます。

特にCJKフォント(日中韓などのマルチバイト文字を含む)は重くなりがちです。

Karui がしていること

ページの描画コマンドを走査して 実際に使われた字 ID(CID)だけを集め、 使われていない字をフォントから物理削除。 数万字 → 数百字に減るので、 フォントだけで 90%+ 縮みます。

結果

政府の通達文書(28 ページ、 元 2 MB)が 11.9% → 56.4% 削減(+44pt)。 これだけで gs/ebook(51.1%)を上回ります。

実装(気になる人向け)

typst/subsetter で字を選別し、 PDF 側のフォント参照テーブル(/CIDToGIDMap)も作り直します。

// karui-app/src-tauri/src/compression/pdf_font.rs (抜粋)

// 1. 実際に使われた字 ID を集める
let cids: Vec<u16> = used_cids.iter().copied().collect();
let remapper = GlyphRemapper::new_from_glyphs_sorted(&cids);

// 2. subset (使われた字だけ残す)
let new_font = subset(&original_bytes, 0, &remapper)
    .map_err(|e| format!("{e:?}"))?;
let new_compressed = flate_compress(&new_font);

// 3. 「元 CID → 新 GID」の対応表を再生成
let max_old_cid = used_cids.iter().last().copied().unwrap_or(0);
let mut cid_to_gid = vec![0u8; (max_old_cid as usize + 1) * 2];
for &old_cid in used_cids {
    if let Some(new_gid) = remapper.get(old_cid) {
        let idx = (old_cid as usize) * 2;
        cid_to_gid[idx]     = (new_gid >> 8) as u8;
        cid_to_gid[idx + 1] = (new_gid & 0xff) as u8;
    }
}

ページ側の描画コマンドは触らず、 フォント参照テーブルだけ差し替えるので副作用が少ないのがポイント。

施策 2: フォントを「古い書き方」から「新しい書き方」に翻訳

LaTeX 論文を gs より小さくするため、 1100 行のフォント format 変換コードを自前で用意しました

身近な例え

フォントの「書き方」 には新旧 2 種類あって、 中身(字の形)は同じなのに 古いほうが約 3 倍重い:

  • 古い書き方(Type1、 1984 年に Adobe が発表): 手書き原稿のように冗長
  • 新しい書き方(CFF、 後継): タイプ印刷のように整然・コンパクト

ちなみに Adobe 自身も 2023 年から Type1 のサポートを順次終了 していて、 Photoshop 23 / Illustrator 27.3 / InDesign 18.2 以降では Type1 フォントが開けません。 Adobe 公式の説明はこう:

CFF は Type 1 のコンパクトな表現であり、アドビが今後もサポートする形式です。
Adobe 公式「PostScript Type 1 フォントのサポート終了」

つまり Karui の変換は 「Adobe 公式が推奨する新フォーマットへの引っ越し」 をやっているとも言えます。

詳しくはこちらを読んでいただければ。
https://ja.wikipedia.org/wiki/PostScriptフォント

LaTeX で書いた英論文(arxiv)は ほとんどがこの古い書き方で埋め込まれています。 新しい書き方に「翻訳」 すれば、 論文 PDF のフォントが 1/3 に。

Rust にも Type1 のパーサ(connorskees/pdf, hayro-font)と CFF subsetter(typst/subsetter, allsorts, krilla)はあるんですが、 両者を繋いで「PDF に埋まった Type1 を CFF に書き直す」 までやる例は見当たらず。

Karui ではこれを Adobe Tech Note 5176 / 5177 を一次資料に、 約 1100 行で自前実装 しました(「いやそれ既にあるよ」 を知ってる方いたら教えてください…)。

翻訳が動いた直後、 LaTeX 論文が 49.4% 削減で gs(48.5%)を抜きました
やった! とレンダリングしたら 文字が全部読めない、 全 glyph 崩壊。
原因は CFF の callsubrサブルーチン番号から bias を引かずに格納していた こと。
修正は下記のように subr_bias()idx - bias を入れるだけ、 ここで半日溶かしました。

結果

arxiv(5 ページ、 元 195 KB)が 22.9% → 49.4% 削減(+26.5pt)
gs/ebook(48.5%)を僅差で抜けました。

実装(気になる人向け)

オフセット計算自体は本当に短い。

// karui-app/src-tauri/src/compression/pdf_type1_to_cff.rs

fn subr_bias(n_subrs: usize) -> i32 {
    if n_subrs < 1240 {
        107
    } else if n_subrs < 33900 {
        1131
    } else {
        32768
    }
}

charstring を変換するループの中で、 callsubr を見たら直前の番号を bias 補正で書き直します。

// callsubr (op 10) の処理 ─ 直前の番号を bias 補正で再 emit
(10, _) => {
    let idx = t1_stack.pop().unwrap_or(0);
    trim_last_operand(&mut t2_operands);
    t2_operands.extend_from_slice(&encode_t2_int(idx - subr_bias));
    out.extend_from_slice(&t2_operands);
    t2_operands.clear();
    out.push(10);
}

idx - subr_bias の 1 行を忘れて文字が全滅していた、 というオチ。

施策 3: PowerPoint の「二重圧縮の罠」を剥がす

身近な例え

PowerPoint で資料を作って PDF 書き出しすると、 中の写真が 「JPEG を更に zip で固めた状態」 で入っています。
普通の PDF 処理ソフトは「画像 = JPEG 1 段」 を前提にしているので、 zip がかかっていることに気づかず素通り。 結果、 PowerPoint 由来の PDF は多くの圧縮ツールで写真部分がそのまま残っています

Karui がしていること

PDF の filter chain を辿って zip(Flate)を剥がしてから、 残った JPEG を再エンコード。

結果

PowerPoint プレゼン(19 ページ、 元 6.7 MB)が 48.8% → 66.9% 削減(+18pt)。 gs/ebook(53.5%)を 13pt 抜きました。

実装(気になる人向け)

// karui-app/src-tauri/src/compression/pdf.rs

/// Filter chain を辿って、最終的な JPEG バイト列を取り出す。
/// - [DCT]            → そのまま返す
/// - [Flate, DCT]     → Flate を剥がして返す(PowerPoint パターン)
/// - [ASCII85, DCT]   → ASCII85 を剥がして返す
fn jpeg_bytes_from_dct_stream(stream: &Stream) -> Result<Vec<u8>, String> {
    let filters = stream.filters().map(...).unwrap_or_default();
    let mut current: Vec<u8> = stream.content.clone();
    for filter in filters {
        if filter == b"DCTDecode" {
            return Ok(current);  // ここまで届いたバイト列が JPEG 本体
        }
        if filter == b"FlateDecode" {
            let mut decoder = ZlibDecoder::new(current.as_slice());
            let mut buf = Vec::with_capacity(current.len() * 4);
            decoder.read_to_end(&mut buf).map_err(|e| e.to_string())?;
            current = buf;
            continue;
        }
        return Err(format!("unsupported pre-DCT filter"));
    }
    Err("no DCT filter in chain".to_string())
}

加えて「すでに高圧縮されている画像はスキップ」 という gate が、 zip ラップ JPEG にも誤発火していました(zip ラップ状態だと見かけのバイト数が極端に小さく見えるため)。 「zip + JPEG なら必ず処理する」 に変更。

施策 4: 「アイコン向け圧縮」で写真を潰さない

身近な例え

PDF 内の画像には 2 種類:

  • 半透明を含む画像: アイコン / チャート / ロゴ。 色数が少ない。
  • 写真: 風景 / 人物 / PowerPoint の背景写真。 色数が多い。

これまでの実装は、 半透明を含む画像を 全部「256 色のパレット PNG」 に変換していました。 アイコンには適していますが、 PowerPoint の 背景写真 までこれにかけると色が 256 色に減らされて階調が「縞模様」(バンディング)に。 86% のピクセルが違う色 という大崩壊。

Karui がしていること

画像のユニーク色数を数えて「自然写真」 と判定したら JPEG 経路、 「アイコン / イラスト」 と判定したらパレット PNG 経路に分岐。 写真はさらに JPEG / 通常 PNG / パレット PNG の 3 候補から最小サイズを選びます。

結果

ピクセル誤差 86% → 1%。 PowerPoint 系の +18pt 削減(施策 3 と合算)に大きく貢献。

施策 5: Excel / 罫線多めの文書で「描画コマンドを短く書き直す」

身近な例え

Excel を PDF 出力すると、 罫線 1 マスごとに「色を設定」「四角を描く」「閉じる」 を律儀に繰り返します。 1000 マスあれば 1000 セット。

ただし PDF にはもっと短い書き方もあって、 例えば灰色指定なら:

  • 冗長: 0.5 0.5 0.5 sc(15 文字)
  • 短縮: 0.5 g(5 文字)

中身は同じで 1/3 サイズ。 Office ソフトは冗長な方を機械的に出すので、 これを短い方に書き直すだけで Excel 系 PDF が大きく縮みます。

Karui がしていること

content stream を読み直して 4 種類の書き換え:

  1. R G B sc(R=G=B のとき)→ R g に短縮
  2. m l l l h(5 命令で矩形)→ re(1 命令)に短縮
  3. 隣接した描画ブロックの共通設定を外側に括り出し
  4. 浮動小数を 4 桁に丸め(PDF ライブラリは 17 桁出すので冗長)

結果

Excel(8 ページ、 元 737 KB)が 65.6% → 71.6% 削減(+6pt)。 gs/ebook(70.8%)越え。

実装(気になる人向け)

// karui-app/src-tauri/src/compression/pdf_content_dedup.rs

fn collapse_gray_rgb(ops: Vec<Operation>) -> Vec<Operation> {
    let mut out = Vec::with_capacity(ops.len());
    for op in ops {
        if (op.operator == "sc" || op.operator == "SC") && op.operands.len() == 3 {
            if let (Some(r), Some(g), Some(b)) = (
                as_f64(&op.operands[0]),
                as_f64(&op.operands[1]),
                as_f64(&op.operands[2]),
            ) {
                // R == G == B なら gray operator 1 値で表せる
                if (r - g).abs() < 1e-6 && (g - b).abs() < 1e-6 {
                    let gray_op = if op.operator == "sc" { "g" } else { "G" };
                    out.push(Operation::new(gray_op, vec![Object::Real(r as f32)]));
                    continue;
                }
            }
        }
        out.push(op);
    }
    out
}

0.5 0.5 0.5 sc(15 文字)→ 0.5 g(5 文字)。 1 セル 10 文字節約、 1000 セルなら 10 KB。

施策 6: 余分なメタ情報を削る

PDF にはページごと・画像ごと・フォントごとに XMP メタデータ(作成日時 / アプリ名 / 編集履歴 等)が貼り付いています。 LaTeX 論文だと 同じメタデータが 5 ページ分重複 していて、 合計 100 KB 以上食ってました。

Karui は全 dict を再帰的に walk して /Metadata 参照を片っ端から除去。 学術ジャーナル系で +3pt の追加削減。

詳細ベンチマーク(プリセット別 vs 全 gs プリセット)

Karui の UI プリセット(Standard / Strong)× gs プリセット 3 種での実測値。
数値は削減率、 太字 = Karui の中で良いほう。

ファイル Karui Standard Karui Strong gs/prepress gs/printer gs/ebook
政府通達 54.0% 68.8% 42.6% 45.6% 51.1%
LaTeX 英論文 48.5% 49.4% 47.2% 44.5% 48.5%
学術ジャーナル 58.8% 74.0% 44.8% 32.1% 73.8%
カンファ + 画像 80.9% 94.2% 47.3% 48.3% 92.9%
Excel 出力 71.6% 71.6% 70.1% 70.1% 70.8%
業務報告書 57.7% 73.9% 26.1% 29.7% 48.0%
PowerPoint 51.2% 66.9% -6.1% 45.5% 53.5%
Word 画像主体 69.8% 94.7% 0.5% 0.5% 69.1%

ちなみに-6.1%(gs/prepress の PowerPoint)は 元より膨張 した意味。
gs は preset を間違えると太らせることがありますが、 Karui は preset を変えても膨張しません(Standard でも最低 48% 削減)。

ライセンスについて

Karui アプリ本体は OSS ではなく、 配布バイナリだけ公開しています。
ただし PDF 圧縮エンジンの中核部分は 要望があれば MIT で別途切り出し公開する余地があります。

PDF 構造処理(フォント subset / Type1 → CFF / content stream 最適化 / メタデータ削除)は Rust で書いていて、 依存も標準的な crate(subsetter / allsorts / ttf-parser / flate2 / quantette / vendored lopdf)。
画像 (JPEG) 再エンコードだけは libjxl ベースの C++ ライブラリを呼ぶ構成です。

参考までに同種ツールのライセンス事情:

製品 ライセンス 商用利用
Ghostscript AGPLv3 ソース公開義務 / 別途有償
Adobe Acrobat Pro 商用 有償サブスク
Datalogics PDF Optimizer 商用 高額ライセンス
PyMuPDF AGPL / 商用 AGPL or 有償
presse(Rust) GPL-3.0 GPL 感染
Karui PDF エンジン(要望次第) MIT で切り出し可 制限なし

PDF 圧縮を商用プロダクトに埋め込もうとすると意外と詰む のが業界の実情で、 現状 Adobe や Datalogics の高額 SDK しか現実的な選択肢がないと思います。
(ちなみにもしあれば知りたいです。車輪の再発明説あるので)

「うちのプロダクトに組み込みたい」
「MIT で公開してくれたら使う」
みたいな声があれば教えてください。

おわりに

Rust 自前実装で gs/ebook 越えの PDF 圧縮、 ちゃんと作れます。

特に面白かったポイント:

  • 「1 通の手紙に国語辞典を同封している」 を直すと最大削減: 使われた字だけ残せば CJK PDF のフォントは 90%+ 縮む。 政府通達文書が +44pt。
  • 古いフォント形式 → 新しいフォント形式の翻訳器を 1100 行で自前実装: LaTeX 論文圧縮の本丸。
  • PowerPoint の二重圧縮の罠: 写真が zip + JPEG で二重ラップされていて多くの圧縮ツールが素通り。 ちゃんと剥がしたら +18pt。
  • アイコン向け圧縮で写真を潰してた: パレット PNG 経路に流れて 86% ピクセル崩壊していたのを、 写真とアイコンを自動判定する経路に分離。

PDF 圧縮を真面目に「画像 + フォント全部再エンコードして縮める」 ところまで作る民は少ない気がするので、 同志がいたら声かけてください。

Karuiはそもそも高効率な画像圧縮アプリでしたが、ユーザーのニーズでPDF圧縮も同梱するようになりました。
画像圧縮機能自体もめちゃくちゃ強いので(16MB → 153KB: 99%削減)ぜひ使ってみてね!

https://karui.app/ja

参考

Discussion

TalosTalos

PDF に埋まった Type1 を CFF に書き直す

お仕事で書いたことはあります。お仕事なので公開できる形ではないですが。
昔なので細かいことは忘れてるなー。

1
だいちだいち

ありがとうございます!おそらく仕事でこういったことをされてる方はいるんじゃないかなぁとは思いました!ちなみにRustで実装されましたか?他言語だとどうなるかあんまりわからないので気になります!

TalosTalos

PDF に埋まった Type1 を CFF に書き直す

お仕事で書いたことはあります。お仕事なので公開できる形ではないですが。
昔なので細かいことは忘れてるなー。