Emacsはデフォルトの状態でもPDFを表示できる。しかし機能には制限があり、表示がぼやけていたり、注釈を入れられなかったりする。PDFを扱うのであれば、最も操作性の良いものはAdbe Acrobat Readerかもしれないが、それだとEmacsから離れてしまうため、他の作業の効率が落ちる。だから、どうしてもPDFのある程度の操作をEmacs上で行いたい。そこで今回はEmacsでのPDFの操作性を向上するために足掻くことにする。
pdf-tools
EmacsでPDFの操作性を向上するためのツールとしてpdf-toolsがある。pdf-toolsはPDF専用のモードであるpdf-view-modeや、検索機能や注釈機能など多くのPDFの機能を提供している。ここからはpdf-toolsの環境整備をしていく1。
現在のソースコードは、Github上にホスティングされている2。新旧のリポジトリがあり、古い方はアーカイブされている3。MELPAにもpdf-toolsは登録されているため、package.elを使ってインストールできる。インストールする際にCで記述されたepdfinfoという内部コマンドをコンパイルする。その際にAutomakeを使用する。またepdfinfoはPopplerというPDFライブラリに依存している。そのためAutomakeやPopplerは事前にインストールしパスを通しておく。macOSの場合、Homebrewを使えばインストールや構成はそれほど難しくない。
brew install poppler
brew install automake
依存ツールをインストールしたら、pdf-toolsをインストールする。
M-x package-install pdf-tools
インストールするとepdfinfoをビルドするか確認してくる。 y
と答えるとビルドが始まる。もしビルドに失敗したら、失敗した理由を解消し、以下を評価することでもう一度ビルドしてくれる。
(pdf-tools-install)
PDF内の文字をコピーする操作性を改善する
EmacsでPDFを表示してると、PDF内の文字をコピーしたくなる。EmacsのPDF表示にはカーソルが存在しない。そのためカーソルでの文字位置を指定することはできない。マウスポインタを使って文字をドラッグする事はできるため、そこでリージョンを選択し文字列をコピーできる。しかし、それではキーボードから手を離さないといけない。これは少し不便だ。
実はavyを使いカーソルを実装した猛者がいるようだが4 5 6 、そのEmacs Lispを試したところ上手く動かすことができなかった。
pdf-view-mark-whole-page
を使うと、ページ全体のテキストをリージョン選択できる。これを元にリングバッファにテキストをコピーし、新しくバッファを作成し、先程コピーしたデータをリングバッファから取得することで、通常のテキスト操作ができる。今回はこれでお茶を濁すことにする7。
pdf-view-popup-text
という関数を新たに実装し、それをキーマップ pdf-view-mode-map
の t
にバインドして、上記のような挙動をさせることにした。デフォルトではキーバインドは設定しないので、直接 M-x
で呼び出している。EmacsでのPDFの操作性向上については、まだ他の観点で考えることができそうだが、それはまた別の機会に考えることにする。
もうひとつ発見があったのは pdf-view-mode
でPDFを表示させている状態で remove-overlays
を呼び出しオーバーレイを削除してみた所、PDFの表示が消えバイナリのようだデータが表示されるバッファが表示された。おそらく pdf-view-mode
はオーバーレイで実装されていたのだと思う。オーバーレイの力強さや柔軟性を再確認した。テキストをコピーしたバッファは本来オーバーレイにして、更に上に表示することで、操作性はもっと向上できると思う。
注釈機能と向き合う
PDFには注釈という機能があり、吹き出しマークや記号を挿入したり、また文字などにはマーカーを引くことができる。それらの注釈には文を挿入できるため、PDF内のメモとして活用できる。この注釈はEmacs Lispでも取得できるため、PDFの操作を工夫する余地がある。ここでは注釈の文章の一覧を取得してみることにする。
(require 'pdf-tools)
(pdf-tools-install-noverify)
(defun find-my-annotations-testing ()
"TESTINGとコメントが付いている注釈を探し出す"
(interactive)
(mapc (lambda (annot)
(if-let* ((content (cdr (assoc 'contents annot)))
(matching (string-match-p "TESTING" content)))
(print content)))
(pdf-info-getannots)))
注釈一覧自体は M-x pdf-annot-list-annotations
で提供されている。ただし、この一覧にコメント(contentsに設定されている値)は表示されない。これが表示できると pdf-annot-list
は tabulated-mode
で構成されているため、isearchなど既存の機能が使える。そこで pdf-annot-list-annotations
で contents
の値も表示するように、カスタマイズする。どの値を列を表示するかという情報は pdf-annot-list-format
によって管理されているため、その値を上書きする8。
(setq pdf-annot-list-format
'((page . 3)
(type . 10)
(date . 24)
(label . 10)
(contents . 100)
))
注釈にはスレッドのような概念があるようでAdbe Acrobat Readerなどでは、注釈に対する返信としてコメントを挿入できる。 pdf-info.el
を使って確認すると、subjectには ノート注釈
という文字列が設定されていた。またIDは親の注釈が annot-1-0
だとすると annot-1-1
や annot-1-2
のように最後の数字が連番で付番されていることが分かった。ただし注釈のID自体は、子供の注釈ではなく独立した注釈として付番しても同様に値が付いていた。おそらく annot-ページ番号-付番の追加順序に応じて採番
のような形式でIDを生成しているのかもしれない。これも、どこでそのロジックが動作しているのかまでは分からなかった。またsubjectの値は変更しようとするとepdfinfoがエラーを返すため変更できなかった。作成時にも特に指定できる訳ではない為、この値をどのようにすれば操作可能なのか分からない。
Acrbat Readerを使って注釈情報をFDFファイルへ出力し、その中身を眺めてみると注釈らしきデータの中に Parent 5 0 R
のようなデータが挿入されていた。確証はないけれど、このデータが注釈のスレッド構造を作っているように思える。現状では返信形式の注釈を追加することはできていない。もう少し調べたかったが、時間がなかったため調査できなかった。
Poppler
pdf-toolsでのPDF操作はepdfinfoというツールが付かわれている。そのプログラムはpdf-tools自体がソースコードまるごと内包していて、インストールと同時にビルドされる。pdf-toolsの一部としてCで実装されており、PDFの操作自体はPopplerを利用している。Popplerはfreedesktop.orgが管理しているPDFライブラリだ。epdfinfoのコードをざっと読んでみたが、返信用の注釈の考慮はされていないようだった。つまり、Popperを使って返信用の注釈を付けられるように、epdfinfoを改修する必要があるということになる。しかし残念ながらPopper自体を使用したことなく、全く使い方が分からない。そこで、Popplerを使って簡素なCのプログラムを実装し、どうすれば返信用の注釈を付けることができるのかを調べることにした。
Popperにはいくつかの方法でアクセスできるようにAPIが提供されている。epdfinfoはglib用のAPIを使っている9。 poppler_document_new_from_fd
、 poppler_document_get_page
、 poppler_page_get_annot_mapping
、 poppler_annot_get_annot_type
、 poppler_annot_get_contents
を使って、 sample.pdf
に保存された注釈の内容を標準出力に表示してみる。 sample.pdf
は適当に落してきたPDFで、そこにpdf-toolsを使用してあらかじめ注釈を追加しておいた。
/**
PDFを開き、注釈の内容を表示する
参考 https://poppler.freedesktop.org/api/glib/
*/
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <poppler.h>
int main () {
int fd;
PopplerDocument *doc;
PopplerPage *page;
GList *annots, *item;
// ファイルを開く
fd = open("./sample.pdf", O_RDONLY);
if (fd < 0) {
perror("Failed to open file\n");
return 1;
}
// ファイル記述子からPDFのデータを取得しPopplerで扱える形式にする
doc = poppler_document_new_from_fd(fd, NULL, NULL);
close(fd);
if (doc == NULL) {
perror("Failed to read PDF file\n");
return 2;
}
// ドキュメントからページを取得
page = poppler_document_get_page(doc, 0);
if (page == NULL) {
perror("Failed to get PDF page\n");
return 3;
}
// ページに含まれる注釈の一覧を取得
annots = poppler_page_get_annot_mapping(page);
if (annots == NULL) {
perror("No annotation\n");
return 4;
}
int annotation_type;
for (item = annots; item; item = item->next) {
// 注釈毎に操作するため型変換する
PopplerAnnotMapping *annot_map = (PopplerAnnotMapping *)item->data;
annotation_type = poppler_annot_get_annot_type(annot_map->annot);
if (POPPLER_ANNOT_TEXT != annotation_type) {
printf("Annotation Type: %d\n", annotation_type);
continue;
}
// 注釈の内容を取得
gchar *contents;
contents = poppler_annot_get_contents(annot_map->annot);
printf("Annotation: %s", contents);
g_free(contents);
}
poppler_page_free_annot_mapping(annots);
close(fd);
return 0;
}
all:
gcc \
-I /usr/local/Cellar/poppler/22.12.0/include/poppler/glib/ \
-I /usr/local/Cellar/glib/2.74.3/include/glib-2.0 \
-I /usr/local/Cellar/glib/2.74.3/lib/glib-2.0/include \
-I /usr/local/Cellar/cairo/1.16.0_5/include/cairo \
-lpoppler-glib \
-lglib-2.0 \
main.c
./a.out
addannon: addannon.c
gcc \
-I /usr/local/Cellar/poppler/22.12.0/include/poppler/glib/ \
-I /usr/local/Cellar/glib/2.74.3/include/glib-2.0 \
-I /usr/local/Cellar/glib/2.74.3/lib/glib-2.0/include \
-I /usr/local/Cellar/cairo/1.16.0_5/include/cairo \
-lpoppler-glib \
-lglib-2.0 \
addannon.c -o addannon
./addannon
anno: anno.cpp
g++ \
-I /usr/local/Cellar/poppler/22.12.0/include/poppler/glib/ \
-I /usr/local/Cellar/glib/2.74.3/include/glib-2.0 \
-I /usr/local/Cellar/glib/2.74.3/lib/glib-2.0/include \
-I /usr/local/Cellar/cairo/1.16.0_5/include/cairo \
-lpoppler-glib \
-lglib-2.0 \
anno.cpp -o anno
./anno
custom:
gcc \
-I /opt/ng/poppler/poppler \
-I /opt/ng/poppler/glib \
-I /opt/ng/poppler/build/glib \
-I /usr/local/Cellar/glib/2.74.3/include/glib-2.0 \
-I /usr/local/Cellar/glib/2.74.3/lib/glib-2.0/include \
-I /usr/local/Cellar/cairo/1.16.0_5/include/cairo \
-L /opt/ng/poppler/build \
-L /opt/ng/poppler/build/glib \
-l glib-2.0 \
-l libpoppler-glib.8.24.0 -v \
main.c
./a.out
何が必要なのかを明らかにするために、Makefileは手動で記述し、ライブラリパスなどは直接指定した。ビルドして実行する。
./a.out
Annotation: test
注釈の内容を表示できることを確認した。
次に返信注釈を追加する方法を調べていたのだが、popplerに返信注釈という概念があるのか、怪しくなってきた。一応 PopplerAnnotMarkupReplyType
というenum型はある。また poppler_annot_markup_get_reply_to
という関数もある。あるにはあるのだが、これらの使い方が全く分からない。フラグを設定する方法が提供されているかと、調べてみているが特にその様子もない。本当に分からない
PDFの仕様を確認する
PDFAが配布している仕様10 、オライリーのPDF構造解説11、EmacsのPDFView12とfundamental-modeを行ききして年末年始ずっとPDFを触っていた。まだ完全に理解できた訳ではないけれど、ようやく少しだけ分かってきた。
PDFの仕様には幾つかのバージョンが存在する。それぞれのバージョンによって指定方法がかなり異なっていた。今回はPDF-3.0の仕様を確認した。これは最新という訳ではなく、むしろかなり古い。注釈の機能の多くが導入された最初のバージョンらしく13、簡単な構造をしていたため、今回はこれを採用した。
注釈をページに設定するためには、ページオブジェクトに /Annots
を指定する必要がある。
4 0 obj << /Type /Page /Parent 3 0 R /MediaBox [0 0 612.0000 792.0000] /Contents 7 0 R /Annots 8 0 R /Resources << /ProcSet 5 0 R /Font << /F1 6 0 R >> >> >> endobj
このオブジェクトでは /Annots 8 0 R
と指定している。 8 0 R
はオブジェクト番号8、世代番号0のオブジェクトを参照するようにを指定している。では次にオブジェクト番号8、世代番号0のオブジェクトを確認する。
8 0 obj [ 9 0 R 10 0 R ] endobj
オブジェクト番号8、世代番号0のオブジェクトは配列であり、 9 0 R
、 10 0 R
で参照を指定している。つまり、これらのオブジェクトが注釈として使われる。配列の先頭に指定されているオブジェクト番号9、世代番号0のオブジェクトを確認する。
9 0 obj << /Type /Annot /C [0.9961089494 0 0] /Contents(this is a comment) /F 1 /M (D:20230101095655+09'00') /NM (bf00b033-f132-1b46-a56c-d153b2019226) /P 4 0 R /Popup true /Rect [191.68 647.445 209.68 667.445] /Subtype /Text /T(sximada) >> endobj
/Type /Annot
が指定されている。これは、このオブジェクトが注釈であることを示している。注釈はいくつかのサブタイプがあり、 /Subtype /Text
から、テキスト注釈であることが分かる。Acrobat Readerで表示されるものは/Tと/Contentsで、/Tはコメントした人の名前、/Contentsはコメントの内容だ。
注釈には返信機能があり、Acrobat Readerでもスレッドのような形式で表示される。オブジェクト番号10、世代番号0のオブジェクトは、この返信注釈だ。
10 0 obj << /Type /Annot /IRT 9 0 R /C [0.9961089494 0 0] /Contents(this is reply) /F 1 /M (D:20230101095655+09'00') /NM (bf00b033-f132-1b46-a56c-d153b2019226) /P 4 0 R /Popup true /Rect [191.68 647.445 209.68 667.445] /Subtype /Text /T(sximada) >> endobj
概ね通常の注釈と同じ形式ではるが、違いは /IRT 9 0 R
を指定していることだ。これはオブジェクト番号9、世代番号0のオブジェクトを参照しており、それは最初に確認した注釈のオブジェクトだ。つまり、このオブジェクトは、オブジェクト番号9、世代番号0に返信しているということになる。確証がないが、おそらくIRTは In Reply To の略なのではないかと思う。
PopplerでIRTの値を設定する
Emacsのpdf-toolsではepdfinfoを使ってPDFの操作を行っている。epdfinfoはCで実装されており、内部ではpopplerを使用している。返信用の注釈は /IRT
という属性に返信元のオブジェクトの参照を指定する。ここまで分かった。popplerを経由して注釈の /IRT
属性を指定できればよいことにはなるが、glib経由のpopplerでは、この値を更新する手段が提供されていない。この値を設定できるようにpopplerを改修し、ファイルへの出力時に /IRT
の属性をファイル内に書き出すようにする必要がある。
関連するパッチが既に提出されており、マージリクエスト自体も閉じられている。
https://gitlab.freedesktop.org/poppler/poppler/-/issues/363
コア側とglib側の修正が必要になっており、glib側の修正が入っているか不明瞭だ。調べる必要がある。
popplerのコードを調べると、IRT属性を解析するコードはある。
const Object &irtObj = dict->lookupNF("IRT");
if (irtObj.isRef()) {
inReplyTo = irtObj.getRef();
} else {
inReplyTo = Ref::INVALID();
}
ただしこの属性を出力するようなコードを見付けられなかった。poppler自体が対応していないかもしれない。
注釈に返信する機能のサポート
ここまでの調査で、既に提供されている機能だけで注釈に返信する事を諦めることにした。PDFの仕様は分かっているため、Popper、epdfinfo、pdf-toolsの各プログラムを拡張することで、注釈に返信する機能を実現することにした。
Poppler
PDFライブラリPopplerを調査したところPopplerのglibフロントエンドを使って注釈に返信するための方法を見付けられなかった。残された道はPoppler自体を拡張することだった。そこで、Popplerを独自に拡張することにしたのだが、この道のりは余りに長くなったため別の記事「Popplerで返信用注釈を追加できるようにする」に記述した。
その旅路の果てに poppler_annot_text_reply_new()
関数を追加した。この関数は注釈に対する返信用の注釈を追加する。これは独自に追加した関数であり、公式のPopplerには存在しない。この関数を使いたい場合は、公式ではなく独自拡張であるhttps://github.com/TakesxiSximada/poppler-symdom-customを使用する必要がある。必要に応じてインストールして欲しい。macOSでHomebrewを使用している場合、この独自拡張版を使うためのフォーミュラを用意した。「PDFの返信用注釈機能をPopplerに追加する」でインストール方法について説明しているが、説明を掻い摘むと以下の方法でインストールできる。
brew install TakesxiSximada/homebrew-tap/poppler
epdfinfo
ここからはepdfinfoに注釈への返信コマンドを追加する。この方法が本当に正しい方法かどうか、かなり自信がないが実装の正しさよりも、まず動く事が重要だ。殆んどの処理は pdf-tools/server/epdfinfo.c
に実装されている。ここに新たに注釈の返信を作成する cmd_replyannot()
関数を追加する。以下に一部の修正を示す。
static void
cmd_replyannot (const epdfinfo_t *ctx, const command_arg_t *args)
{
const document_t *doc = args->value.doc;
const gint pn = args[1].value.natnum;
const gchar *key = args[2].value.string;
const gchar *contents = args[3].value.string;
const annotation_t *a = annotation_get_by_key (doc, key);
GList *annotations;
perror_if_not (a, "No such annotation: %s", key);
PopplerPage *page;
page = poppler_document_get_page(doc->pdf, pn - 1);
perror_if_not (page, "No such page %d", pn);
const PopplerAnnot *pa = poppler_annot_text_reply_new(a->amap->annot, contents);
perror_if_not (pa, "Replying annotation failed: %s", key);
PopplerAnnotMapping *amap;
amap = poppler_annot_mapping_new ();
amap->area = a->amap->area;
amap->annot = pa;
annotations = annoation_get_for_page (doc, pn);
int i = g_list_length (annotations);
gchar *new_key = g_strdup_printf ("annot-%d-%d", pn, i);
while (g_hash_table_lookup (doc->annotations.keys, new_key))
{
g_free (new_key);
new_key = g_strdup_printf ("annot-%d-%d", pn, ++i);
}
annotation_t *new_amap = g_malloc (sizeof (annotation_t));
new_amap->amap = amap;
new_amap->key = new_key;
doc->annotations.pages[pn - 1] = g_list_prepend (annotations, new_amap);
g_hash_table_insert (doc->annotations.keys, new_key, new_amap);
poppler_page_add_annot (page, pa);
OK_BEGIN ();
annotation_print (new_amap, page);
OK_END ();
error:
if (page) g_object_unref (page);
}
修正の全体を知りたい場合、以下のコミットを確認して欲しい。
pdf-tools
ここまでの作業でPopplerを拡張し、epdfinfoを拡張し、注釈に対する返信の注釈を追加できるようになった。残る作業は、その機能をEmacsから呼び出す事だ。返信の文章を記述する方法も提供した方が操作性が向上すると考え、注釈の一覧表示である pdf-annot-list-mode
で i
を入力すると、現在位置の注釈に対して返信を記述するための新たなバッファを作成することにした。
;;;###autoload
(defun pdf-annot-reply-start ()
(interactive)
(let ((buf pdf-annot-list-document-buffer)
(page-num (string-to-number (seq-first (tabulated-list-get-entry))))
(annot-id (tabulated-list-get-id)))
(switch-to-buffer (get-buffer-create "*PDF ANNOT REPLY*"))
(pdf-annot-reply-mode)
(setq-local pdf-annot-list-document-buffer buf)
(setq-local pdf-annot-list-page-num page-num)
(setq-local pdf-annot-list-annot-id annot-id)
))
(define-key pdf-annot-list-mode-map (kbd "i") 'pdf-annot-reply-start)
返信用のバッファで C-c C-c
すると、注釈の返信としてバッファの内容を登録し新たな注釈を生成する。Emacsから epdfinfo
へは pdf-info-query
を使うことで命令を送信できる。それを使って命令を実行する。
;;;###autoload
(defun pdf-annot-reply-commit ()
(interactive)
(pdf-info-query 'replyannot
(pdf-info--normalize-file-or-buffer pdf-annot-list-document-buffer)
pdf-annot-list-page-num
pdf-annot-list-annot-id
(buffer-substring-no-properties (point-min) (point-max)))
(with-current-buffer pdf-annot-list-document-buffer
(set-buffer-modified-p t))
(kill-buffer (current-buffer)))
;;;###autoload
(define-derived-mode pdf-annot-reply-mode org-mode "Annot Reply" nil)
(define-key pdf-annot-reply-mode-map (kbd "C-c C-c") 'pdf-annot-reply-commit)
修正の全体を知りたい場合、以下のコミットを確認して欲しい。
EmacsでPDFの注釈に返信する
この対応をするにあたり次の記事を参考にした。 https://taipapamotohus.com/post/pdf-tools/#pdf-tools
pdf-annot-list-format
の値を使って描画をする時に、ページやタイプといった列は決め打ちされているかもしれない。値を変更して動作確認してみたところ、上手く変更できなかった。
PDFの各種注釈関連機能の導入されたバージョンをまとめられていたため、参考にした。 https://www.antenna.co.jp/pdf/reference/Annotation.html
pdf-annot-reply-start
pdf-annot-reply-commit