LoginSignup
0
2

More than 1 year has passed since last update.

PythonのPyMuPDFでPDFのContentを編集する

Posted at

はじめに

とあるフリーソフトで図形を作成し、PDF出力したらWatermarkががっつり入っていて、そのままでは使い物になりませんでした。
とはいえ、PDFの内容がvector形式で記述されていれば、Inkscape(Illustratorの互換ソフト)で個別に編集することで、きれいに取り除くことができます。

しかし、何か不備があって複数の図形を作り直した際、すべてのPDFを個別にInkscapeで編集するのが億劫だったので、PythonによるPDF編集を試みました。

install PyMuPDF

ライブラリとしては、PyMuPDFを利用します。importの際はimport fitzとします。

pip install PyMuPDF

他にもPDF関係のライブラリはいくつかあるのですが、特に有名なPyPDF2は日本語がMetadataを含めて入っているとエラーを出して読み込めないのでなしです。
PyMuPDFは中華系ライブラリのおかげか、マルチバイト言語にも優しい設計ですね。

PDFの構造

今回、PythonでPDFを取り扱うにあたって大変苦労したのですが、その最大の要因はPDFの構造をきちんと把握していなかったことでした(恥ずかしい)。

参照: 見て作って学ぶ、PDFファイルの基本構造 - TechRacho -PDFファイル解析にあたって1 PDFの仕様をまとめる #コンテンツストリームオブジェクト - Qiita -

ざっくりいえば、PDFの構造の内、本体部分はObjectで構成されています。
このObjectにはフォント名や色情報など、描画内容に付け加えられる情報が含まれています。
そして、一部のObjectにはContent Streamというbytes文字列が付随しており、ここに描画内容や画像データなどが詰め込まれています。
描画内容はPostScriptをベースにした言語で書かれています。

PDFの構造を理解する必要がないためか、「Python PDF」で検索してもテキストや画像抽出の解説ばかり出てくるの、本当に邪魔でした。

PDFのContent Streamを取り出す

まずはPDFをdocという変数に開きます。

import fitz

file_name = "tmp.pdf"
doc = fitz.open(file_name)

PDF本体に含まれるObjectにはIndexが割り振られており、xrefという変数で扱います。
Content StreamのIndexが分かっていれば、stream_bytes=doc.xref_stream(xref)で取り出せます。
PDFのContent Streamは暗号化や圧縮をされている場合がありますが、PyMuPDFはそれらをいい感じに処理してくれるので、
stream_str=stream_bytes.decode("utf8")とすればPostScript(ベースの)描画命令文字列を取得できます。

なお、Content Stream Indexの取得は以下で可能です。

xrefs_stream=sum([page_tmp.get_contents() for page_tmp in doc.pages], [])

以下は、描画命令文字列とその描画内容の例です。ただし、Objectの構成や描画命令文字列の形式は、PDF出力するソフトに依存します。同じ描画内容でも、必ずしも同じ描画命令になるわけではないことに注意してください。

1 0 0 -1 0 4.589843 cm
q
Q q
q
1 0 0 1 0 0 cm
/a0 gs /x6 Do
Q
0.760784 0.760784 0.760784 rg /a1 gs
2.422 2.426 m 1.898 2.953 l 1.824 3.027 1.707 3.027 1.633 2.953 c 1.559
 2.879 1.559 2.762 1.633 2.691 c 2.16 2.164 l 2.234 2.09 2.352 2.09 2.422
 2.164 c 2.496 2.234 2.496 2.355 2.422 2.426 c h
2.422 2.426 m f*
0.592157 0.592157 0.592157 rg 1.766 1.77 m 1.238 2.293 l 1.164 2.367 1.047 2.367 0.977 2.293 c 0.902 
2.223 0.902 2.105 0.977 2.031 c 1.5 1.504 l 1.938 1.066 2.645 1.066 3.082
 1.504 c 3.52 1.941 3.52 2.648 3.082 3.086 c 2.555 3.613 l 2.484 3.684 2.363
 3.684 2.293 3.613 c 2.219 3.539 2.219 3.422 2.293 3.348 c 2.82 2.82 l 3.109
 2.531 3.109 2.059 2.82 1.77 c 2.527 1.477 2.055 1.477 1.766 1.77 c h
1.766 1.77 m f*
Q

Content Streamに目的の編集を行う

上記でContent Streamが得られます。
さて、私自身がPostScript素人のせいかもしれませんが、PostScriptとして真面目に解釈するのは相当骨が折れそうな気がしました。

そこで、Watermark削除に成功した手法を記します。
まず、同様の図形であっても各座標はPDFファイル全体に対する絶対座標で表記されるので、PDFファイルのサイズやWatermarkの位置が違えば描画命令は一致しません。
しかし、今回のPDFファイルでは描画命令が「本来の内容=>Watermark」の順になっていることが判明したので、描画内容の区切りとなっているQ\nq\nQ\nqを目印にして、Watermark部分のみ切り離すことに成功しました。

stream_orig=doc.xref_stream(xref).decode("utf8")
stream_new=stream_orig.split("Q\nq\nQ\nq")[0:2]

Watermark部分の判別の判別方法などは、ケースバイケースだと思うので、実際にいくつかのファイルの描画命令を比較してください。

編集が完了したら、Content Streamの内容を反映します。

doc.update_stream(xref, stream_new)

PDFに保存する

PDFに保存します。

new_file_name=f"new_{file_name}"
doc.save(new_file_name)

Appendix

Objectの値を操作する

# get
for key in doc.xref_get_keys(doc.page_xref(0)):
	print(key, "=", doc.xref_get_key(doc.page_xref(0), key))

"""
Type = ('name', '/Page')
Annots = ('array', '[10 0 R]')
Parent = ('xref', '4 0 R')
Rotate = ('int', '0')
Contents = ('xref', '11 0 R')
MediaBox = ('array', '[0 0 595.32 841.92]')
Resources = ('dict', '<</Font<</R8 12 0 R/R10 13 0 R/R12 14 0 R/R14 15 0 R/R17 16 0 R/R20 17 0 R/R23 18 0 R/R27 19 0 R>>/ProcSet[/PDF/Text]/ExtGState<</R7 20 0 R>>>>')
"""

get_keyではtuppleが得られますが、1番目が型、2番目が値になっています。
set_keyで入力する場合、2番目の値に相当する内容を文字列として入力します。その際、(文字列の)"null"を入力すれば、key自体が削除されます。

# set
doc.set_key(xref, key, new_val)

参考

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2