« ^ »

EmacsでPDFを扱うために右往左往する

所要時間: 約 14分

TL;DR

  • pdf-toolsを使ってEmacsでのPDFの操作性を向上する。
  • PDF上のテキストをコピーしやすくする機能を追加した。
  • pdf-toolsでは注釈へ返信できないことが分かった。
  • Poppler、epdfinfo、pdf-toolsを拡張し、注釈への返信を実現した。


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
Popplerのインストール
brew install automake
Automakeのインストール

依存ツールをインストールしたら、pdf-toolsをインストールする。

M-x package-install pdf-tools
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

https://res.cloudinary.com/symdon/image/upload/v1672276250/blog.symdon.info/1672220542/Untitled_sxfmxl.gif

pdf-view-popup-text という関数を新たに実装し、それをキーマップ pdf-view-mode-mapt にバインドして、上記のような挙動をさせることにした。デフォルトではキーバインドは設定しないので、直接 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-listtabulated-mode で構成されているため、isearchなど既存の機能が使える。そこで pdf-annot-list-annotationscontents の値も表示するように、カスタマイズする。どの値を列を表示するかという情報は pdf-annot-list-format によって管理されているため、その値を上書きする8


(setq pdf-annot-list-format
      '((page . 3)
	(type . 10)
	(date . 24)
	(label . 10)
	(contents . 100)
	))
pdf-annot-list-formatを上書きすることで注釈一覧の表示を変更する

注釈にはスレッドのような概念があるようでAdbe Acrobat Readerなどでは、注釈に対する返信としてコメントを挿入できる。 pdf-info.el を使って確認すると、subjectには ノート注釈 という文字列が設定されていた。またIDは親の注釈が annot-1-0 だとすると annot-1-1annot-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を使っている9poppler_document_new_from_fdpoppler_document_get_pagepoppler_page_get_annot_mappingpoppler_annot_get_annot_typepoppler_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を指定し、注釈をページに挿入する例

このオブジェクトでは /Annots 8 0 R と指定している。 8 0 R はオブジェクト番号8、世代番号0のオブジェクトを参照するようにを指定している。では次にオブジェクト番号8、世代番号0のオブジェクトを確認する。

8 0 obj
  [
    9 0 R
    10 0 R
  ]
endobj
オブジェクト番号8、世代番号0のオブジェクト

オブジェクト番号8、世代番号0のオブジェクトは配列であり、 9 0 R10 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/Annot.cc

ただしこの属性を出力するようなコードを見付けられなかった。poppler自体が対応していないかもしれない。

注釈に返信する機能のサポート

ここまでの調査で、既に提供されている機能だけで注釈に返信する事を諦めることにした。PDFの仕様は分かっているため、Popper、epdfinfo、pdf-toolsの各プログラムを拡張することで、注釈に返信する機能を実現することにした。

Poppler

PDFライブラリPopplerを調査したところPopplerのglibフロントエンドを使って注釈に返信するための方法を見付けられなかった。残された道はPoppler自体を拡張することだった。そこで、Popplerを独自に拡張することにしたのだが、この道のりは余りに長くなったため別の記事「PDFの返信用注釈機能をPopplerに追加する」に記述した。

その旅路の果てに poppler_annot_text_reply_new() 関数を追加した。この関数は注釈に対する返信用の注釈を追加する。これは独自に追加した関数であり、公式のPopplerには存在しない。この関数を使いたい場合は、公式ではなく独自拡張であるhttps://github.com/TakesxiSximada/poppler-symdom-customを使用する必要がある。必要に応じてインストールして欲しい。macOSでHomebrewを使用している場合、この独自拡張版を使うためのフォーミュラを用意した。「PDFの返信用注釈機能をPopplerに追加する」でインストール方法について説明しているが、説明を掻い摘むと以下の方法でインストールできる。

brew install TakesxiSximada/homebrew-tap/poppler
独自拡張版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/server/epdfinfo.c

修正の全体を知りたい場合、以下のコミットを確認して欲しい。

https://github.com/TakesxiSximada/pdf-tools/commit/6d1ae3612e1a18d4df0aac821469933eb631e788#diff-884eccc1055d93e00cfb2f27e127516ff17cc045a4bb59d98fc69779dbe5eb65

pdf-tools

ここまでの作業でPopplerを拡張し、epdfinfoを拡張し、注釈に対する返信の注釈を追加できるようになった。残る作業は、その機能をEmacsから呼び出す事だ。返信の文章を記述する方法も提供した方が操作性が向上すると考え、注釈の一覧表示である pdf-annot-list-modei を入力すると、現在位置の注釈に対して返信を記述するための新たなバッファを作成することにした。

;;;###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)
注釈への返信を記述するバッファを作成する

修正の全体を知りたい場合、以下のコミットを確認して欲しい。

https://github.com/TakesxiSximada/pdf-tools/commit/6d1ae3612e1a18d4df0aac821469933eb631e788#diff-9b685024630d7d53948c8205da177458b45240a49250d3d6264638f9ecc7d0a5

EmacsでPDFの注釈に返信する

長い旅だったけれど、ようやく拡張できた。pdf-annot-list-mode-mapの i に返信用コマンド14を割り当てているため、 i を押すことで返信用のバッファを開く。返信内容を書き、 C-c C-c している。 C-c C-c には注釈への返信の登録コマンド15を割り当てている。labelの値を設定していないため、編集者の値が設定されていない。ここまでの道のりが長過ぎたため、今回は対応しない。今後の課題とする。

https://res.cloudinary.com/symdon/image/upload/v1673303671/blog.symdon.info/1672220542/Untitled_mnprlk.gif