fsTypeだけでPDFのフォントを検査したら、7本中3本を誤って止めた

fsTypeだけでPDFのフォントを検査したら、7本中3本を誤って止めた

pdffontsのemb列は、7本すべてyesでした。入っています。ただ、yesが教えてくれるのは「PDFの中にある」ところまでです。販売してよいフォントかどうかは、どこにも書かれていません。

先月のAIで作ったPDF商品の検査では、未埋め込みフォントが0であることを確かめて、そこで満足していました。今回はその続きで、埋め込まれたフォント側の権利を機械でどこまで見られるかを試しました。

試したのは合成見本です。週末の献立テンプレートを想定した1ページに、手元のRaspberry Piに入っているフォント7本をわざと混ぜました。AIにPDFを作らせたわけでも、販売して反応を測ったわけでもありません。HTMLでフォントを指定しなければ、何が埋め込まれるかは変換した環境のフォント次第です。AIに下書きさせたHTMLでも同じです。今回は指定を固定して、その状況を小さく再現しました。

fsTypeは「文書に入れてよいか」の札

フォントファイルのOS/2表には、fsTypeという16ビットの値があります。OpenTypeの仕様では、下位4ビットが埋め込みの許可範囲を表します。

  • 0: 自由に埋め込める
  • 2: 所有者の許可なしに埋め込み、改変、受け渡しをしてはいけない
  • 4: 閲覧と印刷のためなら埋め込める。文書は読み取り専用で開く
  • 8: 埋め込めて、受け取った側が編集もできる

ほかに、サブセット化を禁じるビット(0x0100)と、ビットマップだけ許可するビット(0x0200)があります。

もう1か所、name表にはライセンスの説明(ID 13)とURL(ID 14)を入れる欄があります。空のままのフォントも多い欄です。

[HTML] -- Chrome --> [PDF]
                       |
                  pdffonts: 使ったフォント名を取り出す
                       |
                  fc-list: 名前から手元のフォントファイルを探す
                       |
                  fontTools: fsTypeとname表を読む
                       |
                  判定: 0以外は止める?

最初はこの流れの最後を、単純に「0以外は止める」で書きました。

7本のPDFを作る

作業用のフォルダと、使った道具の版です。

mkdir hibanas-font-lab
cd hibanas-font-lab
python3 -m venv .venv
.venv/bin/pip install -q fonttools
python3 --version
.venv/bin/python -c "import fontTools; print('fontTools', fontTools.version)"
google-chrome --version
pdffonts -v 2>&1 | head -1
Python 3.13.5
fontTools 4.66.1
Google Chrome 151.0.7922.71
pdffonts version 25.03.0

sample.htmlには、7行それぞれに別のフォントを指定しました。日本語を使う3行には、日本語の字形を持つフォントを選んでいます。指定の部分だけ抜くと、こうです。

.a{font-family:"Noto Sans CJK JP"}
.d{font-family:"Quicksand"}
.g{font-family:"Nimbus Sans"}

残りの.b(DejaVu Sans)、.c(FreeSerif)、.e(Droid Sans Fallback)、.f(WenQuanYi Zen Hei)も同じ書き方です。各行のクラスを付けた<p>に、見出しのような短い文を1つずつ入れました。

ChromeでPDFにして、使われたフォントを名前だけ並べます。

google-chrome --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer --print-to-pdf=sample.pdf sample.html
pdffonts sample.pdf | awk 'NR>2 {print $1, "emb=" $(NF-4)}' | sed 's/^[A-Z]\{6\}+//' | sort -u
64651 bytes written to file sample.pdf
DejaVuSans emb=yes
DroidSansFallback emb=yes
FreeSerif emb=yes
NimbusSans-Regular emb=yes
NotoSansCJKjp-Regular emb=yes
Quicksand-Regular emb=yes
WenQuanYiZenHei emb=yes

sedで消しているのは、pdffontsが名前の先頭に付けるAAAAAA+のような6文字の印です。サブセット化したフォントにつく目印で、手元のフォントファイルと照らすときには邪魔になります。

最初の検査はfsTypeが0かどうかだけ

check_pdf_fonts_v1.pyとして保存しました。PDFのフォント名をfc-listでファイルに引き当て、fsTypeが0ならOK、それ以外はNGです。

import re, subprocess, sys
from fontTools.ttLib import TTFont

def pdf_font_names(pdf):
    out = subprocess.run(["pdffonts", pdf], capture_output=True, text=True, check=True).stdout
    names = []
    for line in out.splitlines()[2:]:
        name = line.split()[0]
        names.append(re.sub(r"^[A-Z]{6}\+", "", name))
    return sorted(set(names))

def find_file(ps_name):
    out = subprocess.run(["fc-list", ":postscriptname=" + ps_name, "file", "index"],
                         capture_output=True, text=True).stdout
    first = out.splitlines()[0]
    path, _, idx = first.partition(": :index=")
    return path, int(idx or 0)

bad = 0
for name in pdf_font_names(sys.argv[1]):
    path, idx = find_file(name)
    font = TTFont(path, fontNumber=idx, lazy=True)
    fs = font["OS/2"].fsType
    verdict = "OK" if fs == 0 else "NG"
    bad += verdict == "NG"
    print(f"{verdict}  {name}  fsType={fs}")
sys.exit(1 if bad else 0)
.venv/bin/python check_pdf_fonts_v1.py sample.pdf; echo "exit=$?"
OK  DejaVuSans  fsType=0
NG  DroidSansFallback  fsType=8
OK  FreeSerif  fsType=0
NG  NimbusSans-Regular  fsType=4
OK  NotoSansCJKjp-Regular  fsType=0
OK  Quicksand-Regular  fsType=0
NG  WenQuanYiZenHei  fsType=8
exit=1

結果は7本中3本の停止。なぜ止まったのか。仕様を読み直すと、4は「閲覧と印刷なら埋め込める」、8は「編集もできる」です。完成したPDFを配るだけなら、どちらも埋め込みを許しています。0以外をNGにした私の基準が、そもそも間違っていました。

止まった3本は、実際には問題なかった

では、通った4本は安全なのか。fsTypeの隣にあるライセンス欄を、7本分並べて読みます。list_fonts.pyは、PostScript名を渡すとfsTypeとname表ID 13の冒頭を出す小さなスクリプトです。

list_fonts.py の全文
import subprocess, sys
from fontTools.ttLib import TTFont

for ps in sys.argv[1:]:
    out = subprocess.run(["fc-list", ":postscriptname=" + ps, "file", "index"],
                         capture_output=True, text=True).stdout
    for line in out.splitlines():
        path, _, idx = line.partition(": :index=")
        if path.endswith((".ttf", ".otf", ".ttc")):
            break
    font = TTFont(path, fontNumber=int(idx or 0), lazy=True)
    rec = font["name"].getName(13, 3, 1, 0x409)
    text = rec.toUnicode().replace("\n", " ")[:46] if rec else "(空)"
    print(f"{ps:22} fsType={font['OS/2'].fsType}  name表ID13: {text}")
.venv/bin/python list_fonts.py NotoSansCJKjp-Regular DejaVuSans FreeSerif Quicksand-Regular DroidSansFallback WenQuanYiZenHei NimbusSans-Regular
NotoSansCJKjp-Regular  fsType=0  name表ID13: This Font Software is licensed under the SIL O
DejaVuSans             fsType=0  name表ID13: Fonts are (c) Bitstream (see below). DejaVu ch
FreeSerif              fsType=0  name表ID13: This computer font is part of GNU FreeFont.  I
Quicksand-Regular      fsType=0  name表ID13: (空)
DroidSansFallback      fsType=8  name表ID13: Licensed under the Apache License, Version 2.0
WenQuanYiZenHei        fsType=8  name表ID13: GPL2 with font embedding exception
NimbusSans-Regular     fsType=4  name表ID13: (空)

7本のうち3本は、Debianのパッケージ由来でした。dpkg -Sで調べると、Nimbus Sansはfonts-urw-base35、FreeSerifはfonts-freefont-ttf、Quicksandはfonts-quicksandに入っています。パッケージには、配布元がまとめたcopyrightファイルも付きます。フォント内の記載より、そちらのほうが詳しい例もありました。

止めた3本のうち、Droid Sans FallbackはApache License 2.0、WenQuanYi Zen Heiは「GPL2 with font embedding exception」と書いてあります。Nimbus Sansはname表が空でした。DebianのパッケージにあるcopyrightファイルでAGPL-3に「PostScriptまたはPDFに組み込むことを許可する」という例外が付いていると分かりました。3本とも、PDFに入れる用途では止める理由が見つかりません。

逆に、通したQuicksandはname表のID 13が空でした。URL欄にはOFLと書いてあり、DebianのcopyrightもOFL-1.1です。結論は合っていても、フォント単体では根拠を示せません。FreeSerifはGPL 3以降で、文書へ埋め込んだ場合の例外が本文に書かれていました。fsTypeが0だから通る、という話ではありません。

fsTypeは権利の判定器ではなく、「文書に埋め込んでよいか」を示す札でした。その札が0であっても、売ってよい根拠は別のところに書かれています。

正直に書くと、読む前は「fsTypeが0なら安心、それ以外は怪しい」くらいに思っていました。逆です。怪しいのは、札が0なのに説明が空のフォントでした。探す手間は、そのまま購入者への説明の手間です。

Chromeは、埋め込み禁止のフォントを断らなかった

もう1つ、fsTypeが2のフォントをChromeは断るのか。断ってくれるなら、検査側でその条件を書く必要はなくなります。DejaVu Sansを複製し、fsTypeだけ2に書き換え、名前をRestrictedLabに変えた試験用フォントを作ります。手元の試験にだけ使い、終わったら削除しました。

make_restricted_font.py の全文
import os
from fontTools.ttLib import TTFont

font = TTFont("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
font["OS/2"].fsType = 2
for rec in font["name"].names:
    if rec.nameID in (1, 4, 16):
        rec.string = "RestrictedLab"
    if rec.nameID == 6:
        rec.string = "RestrictedLab-Regular"
    if rec.nameID == 3:
        rec.string = "RestrictedLab-Regular-test"
os.makedirs(os.path.expanduser("~/.local/share/fonts/restrictedlab"), exist_ok=True)
font.save(os.path.expanduser("~/.local/share/fonts/restrictedlab/RestrictedLab.ttf"))

restricted.htmlは、試験用フォントとDejaVu Sansを1行ずつ並べただけのHTMLです。

.venv/bin/python make_restricted_font.py
fc-cache -f ~/.local/share/fonts/restrictedlab
google-chrome --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer --print-to-pdf=restricted.pdf restricted.html
pdffonts restricted.pdf
17552 bytes written to file restricted.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+RestrictedLab-Regular         Type 3            Custom           yes yes yes      4  0
BAAAAA+DejaVuSans                    CID TrueType      Identity-H       yes yes yes      5  0

embはyesのまま。断られませんでした。同じ元ファイルから作ったDejaVu SansがCID TrueTypeなのに対し、試験用だけがType 3になっています。Chromeは、埋め込み禁止のフォントを別の形に変えて入れたように見えます。ただし、Noto Sans CJKとNimbus SansもType 3でした。この2本はCFF形式のOpenTypeで、禁止とは関係ありません。uni列は3本ともyesなので、文字としての検索やコピーは残ります。Type 3かどうかは、権利の手がかりになりません。

この実験で言えるのは、使ったバージョン(Chrome 151)が、fsType 2のフォントでも黙ってPDFを作ったことまでです。他のPDF出力ツールや版で同じとは限りません。

つまり、変換した側の道具は守ってくれません。AIにHTMLを書かせて、そのままChromeでPDFにする流れだと、フォントを選んだ人が誰もいないまま、PDFだけが出来上がります。一番怖いのはここです。

確認済み一覧を足して作り直す

方針を変えました。fsTypeは「止める条件」としてだけ使います。fsTypeが2、サブセット化禁止なのにサブセットされている、ビットマップのみ、の3つです。

それとは別に、font-review.jsonを作りました。人が読んで、ライセンスと根拠を書いたフォントだけを登録します。PDFに現れたフォントがこの一覧に無ければ止めます。判断を機械に任せず、「人がまだ読んでいない」ことだけを機械に見つけさせる形です。ライセンスの善し悪しは私が読みます。読んでいない、という事実だけを機械が数えます。

{
  "NotoSansCJKjp-Regular": {"license": "OFL-1.1", "evidence": "フォント内のname表(ID13)", "reviewed": "2026-10-12"},
  "NimbusSans-Regular": {"license": "AGPL-3.0 + Font exception(PDFへの組み込みを許可)", "evidence": "Debianパッケージのcopyright(name表は空)", "reviewed": "2026-10-12"}
}

キーはPDFに出るPostScript名、値はライセンス名と、その根拠をどこで読んだかの記録です。上は2本分の抜粋で、残り5本も同じ形で書きました。

check_pdf_fonts.py の全文
import json, re, subprocess, sys
from fontTools.ttLib import TTFont

def pdf_fonts(pdf):
    out = subprocess.run(["pdffonts", pdf], capture_output=True, text=True, check=True).stdout
    fonts = {}
    for line in out.splitlines()[2:]:
        cols = line.split()
        name = re.sub(r"^[A-Z]{6}\+", "", cols[0])
        fonts[name] = {"emb": cols[-5], "sub": cols[-4]}
    return fonts

def find_file(ps_name):
    out = subprocess.run(["fc-list", ":postscriptname=" + ps_name, "file", "index"],
                         capture_output=True, text=True).stdout
    for line in out.splitlines():
        path, _, idx = line.partition(": :index=")
        if path.endswith((".ttf", ".otf", ".ttc")):
            return path, int(idx or 0)
    return None, 0

pdf, review_path = sys.argv[1], sys.argv[2]
review = json.load(open(review_path, encoding="utf-8"))
problems = 0
for name, info in pdf_fonts(pdf).items():
    msgs = []
    if info["emb"] != "yes":
        msgs.append("埋め込まれていない")
    path, idx = find_file(name)
    if path is None:
        msgs.append("手元のフォントファイルが見つからず、fsTypeを読めない")
    else:
        fs = TTFont(path, fontNumber=idx, lazy=True)["OS/2"].fsType
        if fs & 0x000F == 2:
            msgs.append(f"fsType={fs}: 埋め込み禁止(Restricted License)")
        if fs & 0x0100 and info["sub"] == "yes":
            msgs.append(f"fsType={fs}: サブセット化禁止なのにサブセットされている")
        if fs & 0x0200:
            msgs.append(f"fsType={fs}: ビットマップのみ許可")
    if name not in review:
        msgs.append("ライセンス未確認(font-review.jsonに記録なし)")
    print(("NG  " if msgs else "OK  ") + name + ("" if not msgs else "  -> " + " / ".join(msgs)))
    problems += bool(msgs)
print(f"未解決: {problems}件")
sys.exit(1 if problems else 0)

まず、7本とも登録した状態。

.venv/bin/python check_pdf_fonts.py sample.pdf font-review.json; echo "exit=$?"
OK  NotoSansCJKjp-Regular
OK  DejaVuSans
OK  FreeSerif
OK  Quicksand-Regular
OK  DroidSansFallback
OK  WenQuanYiZenHei
OK  NimbusSans-Regular
未解決: 0件
exit=0

次は、fsTypeが2の試験用フォントが入ったPDF。

.venv/bin/python check_pdf_fonts.py restricted.pdf font-review.json; echo "exit=$?"
NG  RestrictedLab-Regular  -> fsType=2: 埋め込み禁止(Restricted License) / ライセンス未確認(font-review.jsonに記録なし)
OK  DejaVuSans
未解決: 1件
exit=1

最後に、QuicksandとNimbus Sansの記録を消して、記録の無いフォントが止まるかを見ます。

font-review-short.jsonは、次の数行でfont-review.jsonから2本分を消して作りました。

.venv/bin/python - <<'PY'
import json
d = json.load(open("font-review.json", encoding="utf-8"))
for k in ("Quicksand-Regular", "NimbusSans-Regular"):
    d.pop(k)
json.dump(d, open("font-review-short.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
PY
.venv/bin/python check_pdf_fonts.py sample.pdf font-review-short.json; echo "exit=$?"
OK  NotoSansCJKjp-Regular
OK  DejaVuSans
OK  FreeSerif
NG  Quicksand-Regular  -> ライセンス未確認(font-review.jsonに記録なし)
OK  DroidSansFallback
OK  WenQuanYiZenHei
NG  NimbusSans-Regular  -> ライセンス未確認(font-review.jsonに記録なし)
未解決: 2件
exit=1

まだ納得していないこと

この検査の土台は、私が読んで書いた台帳です。reviewedの日付を入れたのは、name表とDebianのcopyrightを読んだ日で、法律の専門家に確かめた日ではありません。AGPLの例外条文が、PDFを有料で配る場面にどこまで及ぶのかは、条文の読み方を自分で固める必要があります。

もう1つ、今回見たのはPDFの中のフォントだけです。フォントファイルをZIPに同梱して売る場合は、再配布の条件がまるごと変わります。そちらは別の検査になります。

それでも、「入っているのに誰も読んでいないフォント」を毎回止められるようになりました。止まった回に台帳へ1行足す作業は、数分です。ライセンスの欄が空のフォントに当たったら、その場で別のフォントへ替える選択肢もあります。

次にPDF商品を作るときは、変換の前にフォントを指定します。指定しないと、何が入るかが環境任せになるからです。

出典

この記事をシェア