前に作ったPDFの中身だけを差し替えたくなりました。レイアウトは気に入っているので、見た目はそのまま、文章だけ書き換えたいというやつです。
PythonのPyMuPDF(fitz)でやったところ、一応できたのですが、書き換えた行だけ明らかに細くなりました。並べると一目でわかるレベルです。原因が分かってみると単純だったので、同じことをやる人向けに書いておきます。
まず、元のPDFのフォントは再利用できないことがある
差し替えの流れはこうです。
- 元のPDFから、書き換えたい文字の位置とフォントサイズを取る
- その文字を消す
- 同じ位置に新しい文字を書く
問題は3番です。「元と同じフォントで書けばいい」と思うのですが、元のPDFに埋め込まれているフォントが Type3 だと、それは使えません。
Type3フォントは、ざっくり言うと「この文字はこう描く」という図形の指示が1文字ずつ入っているものです。つまり、そのPDFに出てくる文字ぶんしか中身がありません。新しい文章に別の漢字が出てきても、描きようがないわけです。
なので、見た目が近い別のフォントで書き直すことになります。
import fitz
doc = fitz.open("元.pdf")
page = doc[1]
for block in page.get_text("dict")["blocks"]:
if block["type"] != 0:
continue
for line in block["lines"]:
for span in line["spans"]:
print(span["font"], span["size"], repr(span["text"]))
# → Type3 (14 0 R) 11.26 'と' のように出たら Type3
今回の元PDFは日本語のゴシック体で、Windows同梱の Noto Sans JP とほぼ同じ見た目でした。C:\Windows\Fonts\NotoSansJP-VF.ttf があるので、これを指定しました。
page.insert_text(origin, "新しい文章",
fontname="notojp",
fontfile=r"C:\Windows\Fonts\NotoSansJP-VF.ttf",
fontsize=11.26)
これで出てきたのが、冒頭の「そこだけ細い」状態です。
原因:可変フォントの既定ウェイトが Thin だった
ファイル名の -VF は Variable Font(可変フォント) の意味です。可変フォントは、太さなどを1つのファイルの中で連続的に変えられるフォントで、wght(ウェイト)という軸を持っています。
問題は、その軸の「既定値」が何になっているかでした。確かめてみます。
from fontTools.ttLib import TTFont
f = TTFont(r"C:\Windows\Fonts\NotoSansJP-VF.ttf")
for a in f["fvar"].axes:
print(a.axisTag, a.minValue, a.defaultValue, a.maxValue)
# wght 100.0 100.0 900.0
100.0 100.0 900.0 は「最小100・既定100・最大900」という意味です。100は Thin、いわゆる極細です。
本文でよく使うRegularは400なので、何も指定しないと3段階以上細い字で描かれていたことになります。PyMuPDFは可変フォントの軸を指定して読む仕組みを持っていないので、ファイルをそのまま渡すと既定値、つまりThinがそのまま出ます。
「フォント名は合っているのに見た目が違う」ときは、太さを疑うと早いです。
解決:ウェイトを固定した実体ファイルを作ってから渡す
fontTools には、可変フォントを特定の値で固定して普通のフォントとして書き出す機能があります。
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont
f = TTFont(r"C:\Windows\Fonts\NotoSansJP-VF.ttf")
instantiateVariableFont(f, {"wght": 400}, inplace=True, updateFontNames=True)
f.save("NotoSansJP-Regular.ttf")
あとはこの NotoSansJP-Regular.ttf を fontfile に渡すだけです。これで元の行と見分けがつかなくなりました。
書き出したファイルは5MB以上あるので、リポジトリに置くのではなく、無ければその場で作るようにしておくと扱いが楽です。
from pathlib import Path
FONTFILE = Path(__file__).parent / "NotoSansJP-Regular.ttf"
if not FONTFILE.exists():
# 上の instantiateVariableFont を実行して保存する
...
ついでに踏んだ、もう2つの罠
罫線まで消える
文字を消すのに add_redact_annot() と apply_redactions() を使うのですが、何も指定しないと表の罫線も一緒に消えます。線は「文字」ではなく「図形」なので、図形の扱いを指定してやる必要があります。
page.apply_redactions(
images=fitz.PDF_REDACT_IMAGE_NONE,
graphics=fitz.PDF_REDACT_LINE_ART_NONE, # ← 線を消さない
text=fitz.PDF_REDACT_TEXT_REMOVE,
)
あと、add_redact_annot(rect) は既定でその範囲を白く塗りつぶします。背景が白なら気になりませんが、塗りたくないなら fill=None を渡します。
折り返しを字数で数えるとズレる
日本語だけなら「1行25文字」のように数えても合いますが、19:00〜22:00 のような半角数字が混ざると、半角のほうが幅が狭いので合わなくなります。実測の幅で折り返すのが確実です。
font = fitz.Font(fontfile=FONTFILE)
font.text_length("この行の幅は?", 11.26) # → pt単位の実測幅が返る
ついでに、時刻のような塊は途中で改行されると読みにくいので、そこだけ1文字として扱うようにしました。実際、最初は 木曜15:00〜19:00 が 木曜1 と 5:00〜19:00 に割れていました。
位置合わせは origin を使う
最後に細かい話ですが、書き込む位置は span["bbox"] ではなく span["origin"] を使うとズレません。origin はその文字のベースライン(文字が乗っている線)の座標なので、そのまま insert_text() に渡せます。bboxから計算しようとすると、フォントごとの上下の余白を自分で考えることになって面倒です。
まとめ
- 元PDFのフォントが Type3 だと再利用できない。似たフォントで書き直す
- ファイル名に
-VFが付いていたら可変フォント。既定ウェイトを確認する(Noto Sans JPは100= Thin) - 太さを固定した実体ファイルを
instantiateVariableFontで作ってから使う -
apply_redactions()は指定しないと罫線も消す - 折り返しは字数ではなく
text_length()の実測幅で
「フォントは合っているのに、なんか違う」と思ったら、まず太さを疑ってみてください。