というわけで、el-xmpを使えばDiredの中にファイルのメタデータを表示できるようになりました。
もちろんソートやフィルタ、マークも可能です。
ようやくWindowsのエクスプローラでも出来るようなことがEmacsでもできるようになりました。良かった良かった。
とは言えサポートしているファイル形式がまだ限られているのでもう少し増やしたいところですね。
とりあえずID3に対応したので、MP3のアーティスト名やアルバム名なんかも表示できるようになりました。
というわけで、el-xmpを使えばDiredの中にファイルのメタデータを表示できるようになりました。
もちろんソートやフィルタ、マークも可能です。
ようやくWindowsのエクスプローラでも出来るようなことがEmacsでもできるようになりました。良かった良かった。
とは言えサポートしているファイル形式がまだ限られているのでもう少し増やしたいところですね。
とりあえずID3に対応したので、MP3のアーティスト名やアルバム名なんかも表示できるようになりました。
昨日の続き。
前回はファイルのスコアが書かれているCSVファイルを元にDired内のファイルをソートしてみましたが、これだけだとパッと見何の順番で並んでいるのかよく分かりませんよね。やはりファイルの一覧の中にスコアの数字そのものも表示されていないとピンときません。なので今回はDiredのファイル行の中に、ファイルに関連した任意の情報を追加する方法について考えてみようと思います。
前回も書いたとおりDiredは単なるテキストバッファですから、勝手に書き替えてしまえば済む話です。本当に? 結論としては、たぶん、概ね問題ないと思います。
Diredはファイルに関する情報を変数の形では保持しておらず、全てバッファ内のテキストとして保持しています。なのでファイル名を始め全ての情報は何かコマンドを実行するたびにバッファ内のテキストから毎回抽出しなければなりません。デフォルトの -al
オプションでファイルリストを作成すると、ファイル行には左から順にファイルの種類(d)、モードビット、ハードリンク数、所有者名、グループ名、サイズ、タイムスタンプ、そしてファイル名が並びます(シンボリックリンクの場合はその後に ->
で始まる部分が追加されます)。この中で一番重要なのは当然一番右のファイル名ですが、その位置を正確に割り出すのは案外面倒な問題が伴います。そんな中で、ファイル名の左側に好きな情報を勝手に追加してしまっても大丈夫なのでしょうか。
大丈夫なのです。ファイル名の左にどんなテキストを追加してもDiredは正確にファイル名の位置を割り出すことが出来ます。
その秘密はinsert-directory
という関数にあります。この関数はlsの出力をバッファに挿入する関数です(lsが無い環境ではls-lisp.elでエミュレートします)。lsと同じようにファイルパスとスイッチを引数に取ります。今回注目すべきは、スイッチとして --dired
を指定したときの挙動です。GNUのlsには --dired
(または-D)というオプションがあり、これが指定されているとlsは出力の最後にファイル名部分の範囲を先頭からの文字数で列挙してくれます。例えば次のように。
> ls -al --dired total 684 drwxr-xr-x 1 user user 0 11月 27 18:36 ./ drwxr-xr-x 1 user user 0 11月 30 15:31 ../ -rw-r--r-- 1 user user 1948 9月 25 16:55 compface.el -rw-r--r-- 1 user user 906 10月 26 18:46 compface.elc -rw-r--r-- 1 user user 12701 9月 25 16:55 exif.el -rw-r--r-- 1 user user 7640 10月 26 18:46 exif.elc -rw-r--r-- 1 user user 13135 9月 25 16:55 gravatar.el -rw-r--r-- 1 user user 9698 10月 26 18:46 gravatar.elc -rw-r--r-- 1 user user 14269 9月 25 16:55 image-converter.el -rw-r--r-- 1 user user 10276 10月 26 18:46 image-converter.elc -rw-r--r-- 1 user user 17854 9月 25 16:55 image-crop.el -rw-r--r-- 1 user user 13474 10月 26 18:46 image-crop.elc -rw-r--r-- 1 user user 84378 10月 16 04:24 image-dired.el -rw-r--r-- 1 user user 83477 10月 26 18:46 image-dired.elc -rw-r--r-- 1 user user 17044 9月 25 16:55 image-dired-dired.el -rw-r--r-- 1 user user 18328 10月 26 18:46 image-dired-dired.elc -rw-r--r-- 1 user user 29509 9月 25 16:55 image-dired-external.el -rw-r--r-- 1 user user 21633 10月 26 18:46 image-dired-external.elc -rw-r--r-- 1 user user 13209 9月 25 16:55 image-dired-tags.el -rw-r--r-- 1 user user 9952 10月 26 18:46 image-dired-tags.elc -rw-r--r-- 1 user user 8924 9月 25 16:55 image-dired-util.el -rw-r--r-- 1 user user 7195 10月 26 18:46 image-dired-util.elc -rw-r--r-- 1 user user 23171 9月 25 16:55 wallpaper.el -rw-r--r-- 1 user user 24016 10月 26 18:46 wallpaper.elc //DIRED// 59 60 109 111 160 171 219 231 279 286 334 342 390 401 449 461 509 527 575 594 642 655 703 717 765 779 827 842 890 910 958 979 1027 1050 1098 1122 1170 1189 1237 1257 1305 1324 1372 1392 1440 1452 1500 1513 //DIRED-OPTIONS// --quoting-style=literal
末尾の //DIRED//
で始まる行の後に並ぶ数字はファイル名部分の範囲を示しています。最初は59ですが、これは最初のファイル名の先頭が出力の先頭から数えて59文字目にあることを意味しています。次の60はファイル名の末尾の位置を同様に表します。つまり、一番最初の .
を指しています。以下同様に続きます。
そして重要なのは、insert-directory
はこの情報を読み取ってバッファ内のファイル名が書かれている範囲にテキストプロパティ dired-filename=t を設定するということです。
なので行の中にあるファイル名を表す範囲を特定するにはテキストプロパティが設定されている範囲を探すだけで良く、その前にどんなテキストがあってもそのテキストプロパティが存在する限り(少なくともファイル名を特定するには)問題ありません。そのテキストプロパティが設定されている場所を探す関数がdired-move-to-filename
とdired-move-to-end-of-filename
です。その中を見れば行末までの間にある dired-filename
という名前のテキストプロパティを探していることが分かります。(注:これらの関数を見るとテキストプロパティが検出できなかったときの処理も書かれていて、正規表現検索によってファイル位置の範囲を特定しようとしています。なので100%問題ないとは言い切れない部分がありますが、今回はその辺りは無視することにします)
前回書いたコードの中から流用できるものを引っ張ってきましょう。
サンプルのCSV。
muscat.html,230 melon.html,140 grape.html,185 cherry.html,210 strawberry.html,153
これを読む関数。
(defun my-dired-sort-read-file-score (dir) (ignore-errors (with-temp-buffer (insert-file-contents (file-name-concat dir ".file-score.csv")) (goto-char (point-min)) (cl-loop until (eobp) for (file score) = (split-string (buffer-substring (point) (line-end-position)) ",") collect (cons (expand-file-name file dir) (string-to-number score)) do (forward-line)))))
一つのディレクトリの中のファイルリストの範囲を特定する関数。
(defun my-dired-next-dir-files-region () ;; ファイル名が無いところをスキップ (while (and (null (dired-move-to-filename)) (= (forward-line) 0))) (unless (eobp) (let ((beg (line-beginning-position))) ;; ファイル名があるところをスキップ (while (and (dired-move-to-filename) (= (forward-line) 0))) (cons beg (line-beginning-position)))))
それでファイルリストの中にスコアの数字を挿入する関数を作成します。
(defun my-dired-add-file-score-to-region (beg end) (when-let ((file-score-alist (my-dired-sort-read-file-score (dired-current-directory)))) (goto-char beg) (while (< (point) end) ;; 各行について (when (dired-move-to-filename) ;; ファイル名の先頭へ移動 ;; ファイル名とスコアを取得 (let* ((file (dired-get-filename nil t)) (score (alist-get file file-score-alist nil nil #'string=))) ;; 1文字左へ移動 (backward-char) ;; スコアを挿入 (insert (if score (format " %3d" score) " ")))) ;; 次の行へ (forward-line))))
バッファ内にある全ディレクトリに対してこれを適用する関数を作成します。前回作った my-dired-sort-by-file-score
関数とほとんど同じですが、今回は挿入すると末尾の位置がずれていくためマーカーを使用しています。そして my-dired-sort-region-by-file-score
の代わりに今作った my-dired-add-file-score-to-region
を呼び出すようにすればOKです。
(defun my-dired-add-file-score () (interactive nil dired-mode) (widen) (goto-char (point-min)) (let ((inhibit-read-only t)) (while-let ((files-region (my-dired-next-dir-files-region))) (let ((beg (car files-region)) (end (copy-marker (cdr files-region)))) (my-dired-add-file-score-to-region beg end) (goto-char end) (set-marker end nil)))))
前回作ったソートコマンドと一緒に使ってみるとDiredバッファは次のような見た目になりました。
c:/home/user/tmp/my-dired: drwxr-xr-x 1 user user 0 11月 30 23:14 . drwxr-xr-x 1 user user 0 11月 30 23:13 .. -rw-r--r-- 1 user user 87 11月 30 23:14 .file-score.csv -rw-r--r-- 1 user user 7 11月 30 23:13 140 melon.html -rw-r--r-- 1 user user 12 11月 30 23:14 153 strawberry.html -rw-r--r-- 1 user user 7 11月 30 23:13 185 grape.html -rw-r--r-- 1 user user 8 11月 30 23:13 210 cherry.html -rw-r--r-- 1 user user 8 11月 30 23:13 230 muscat.html
スコアが表示され、その順番でファイルが並んでいることが分かります。
Web検索すると s
キーや C-u s
でソート順を変更できるという情報が見つかりますがそういった話ではありません。悪しからず。
Diredではlsやls-lispが認識するオプションによってファイルをソートできますが、ソートのキーに出来るのはあくまでファイルシステムが認識できるもののみです。ファイルシステムの外にある何らかの情報に基づいてファイルをソートするにはどうしたら良いでしょうか。
基本的にDiredというのは(良くも悪くも)ファイル情報を並べただけのテキストバッファに過ぎないので、read-onlyを一時的に解除して好きなように並べ替えてしまえば良いのです。ただし、次のことに注意する必要があります。
C-x C-q
(dired-toggle-read-only
)はwdiredを起動してしまうので使えません。 M-x read-only-mode
するか、Emacs Lispから (let ((inhibit-read-only t)) ...書き替え... )
しましょう。dired-subdir-alist
変数とマーカーで管理されているので、それが壊れないようにしましょう。 試しに M-x read-only-mode
の後にファイルの範囲を正確にリージョンで囲って、 M-x sort-lines
してみればソートされることが確認できると思います。
例えば何かファイルに対するスコアを列挙したファイル(.file-score.csv)があるとしましょう。
muscat.html,230 melon.html,140 grape.html,185 cherry.html,210 strawberry.html,153
これを読み込むには次のようなEmacs Lispの関数を作れば良いでしょう。
(defun my-dired-sort-read-file-score (dir) (ignore-errors (with-temp-buffer (insert-file-contents (file-name-concat dir ".file-score.csv")) (goto-char (point-min)) (cl-loop until (eobp) for (file score) = (split-string (buffer-substring (point) (line-end-position)) ",") collect (cons (expand-file-name file dir) (string-to-number score)) do (forward-line)))))
試しに実行してみると次のようになります。
(my-dired-sort-read-file-score "/products/")
(("/products/muscat.html" . 230) ("/products/melon.html" . 140) ("/products/grape.html" . 185) ("/products/cherry.html" . 210) ("/products/strawberry.html" . 153))
これを元にDired上でファイルをソートするには、sort-subr
関数を使用すると次のように書けます。
(defun my-dired-sort-region-by-file-score (beg end) (when-let ((file-score-alist (my-dired-sort-read-file-score (dired-current-directory)))) (save-restriction (narrow-to-region beg end) (goto-char beg) (sort-subr nil ; REVERSE #'forward-line ; NEXTRECFUN #'end-of-line ; ENDRECFUN ;; STARTKEYFUN (注意:決してnilを返さないこと) (lambda () (alist-get (dired-get-filename nil t) file-score-alist 0 nil #'string=)) nil ; ENDKEYFUN #'< ; PREDICATE ))))
sort-subr
はバッファ内のテキストをソートする汎用的な関数です(Sorting (GNU Emacs Lisp Reference Manual))。引数STARTKEYFUNには通常キーとなるテキストの先頭へ移動する関数を指定しますが、単にキーとなる値を返しても構いません。ここではファイル名に対応するスコアの数値を返しています。キーを数値にしたのでPREDICATEには #'<
を指定していますが、デフォルトのままでも大丈夫かもしれません。
試しにテスト用のファイルを用意して、Diredバッファ内でファイルをリージョンで囲い、 M-: (my-dired-sort-region-by-file-score (region-beginning) (region-end))
などとしてみるとちゃんとソートされることが分かります。
ここまでソートする範囲を手動で指定していましたが、ディレクトリの先頭のファイルと最後のファイルの位置を自動的に求めるにはどうしたら良いでしょうか。
ここで注意する必要があるのが、Diredは一つのバッファ内に複数のディレクトリを表示できるという点です(dired-insert-subdir
等)。なのでその一つ一つのディレクトリ毎にファイルリストの範囲を求める必要があります。
最も単純なのはバッファ内の全行をスキャンしてファイルがある所とファイルが無いところを判別していくことです。
(defun my-dired-next-dir-files-region () ;; ファイル名が無いところをスキップ (while (and (null (dired-move-to-filename)) (= (forward-line) 0))) (unless (eobp) (let ((beg (line-beginning-position))) ;; ファイル名があるところをスキップ (while (and (dired-move-to-filename) (= (forward-line) 0))) (cons beg (line-beginning-position)))))
行にファイル情報があるかどうかはdired-move-to-filename
が非nilを返すかどうかで判別できます。この方法はdired.el内で広く使われています。
そしてファイル情報が連続している範囲を見つけて返すのが上のコードになります。
全行をスキャンするのが嫌ならばdired-subdir-alist
を利用することもできます。
(defun my-dired-next-dir-files-region () ;; ファイル名が無いところをスキップ (while (and (null (dired-move-to-filename)) (= (forward-line) 0))) (unless (eobp) (let ((beg (line-beginning-position))) ;; 次のディレクトリの先頭またはEOBへジャンプ (goto-char (dired-subdir-max)) ;; ファイルがある所まで戻る (while (and (= (forward-line -1) 0) (not (dired-move-to-filename)))) ;; その次の行の先頭 (forward-line) (cons beg (point)))))
dired-subdir-max
関数は現在のポイントが指し示すディレクトリのバッファ内での末尾位置を返します。dired-subdir-alist
には各ディレクトリの末尾の位置がマーカーとして保存されているので、全行をスキャンせずとも高速に割り出すことが出来ます。
ただ、その位置は正確に最後のファイルを指し示していないので、若干の後戻りスキャンが必要になります。
以上を組み合わせると次のようなコマンドを仕立てることが出来ます。
(defun my-dired-sort-by-file-score () (interactive nil dired-mode) (widen) (goto-char (point-min)) (let ((inhibit-read-only t)) (while-let ((files-region (my-dired-next-dir-files-region))) (my-dired-sort-region-by-file-score (car files-region) (cdr files-region)) (goto-char (cdr files-region)))))
手動でコマンドを実行したときだけソートされれば良いのであればこれで十分です。しかし何かのコマンドを実行したら常にソートされていて欲しい場合はどうしたら良いでしょうか。
当然Diredバッファが更新されるたびにどこかで自動的にソートする必要があります。
Diredのバッファ更新処理は大ざっぱに言って次のような流れを辿ります。
dired-noselect等 → dired-revert → dired-readin (あるいはdired-insert-old-subdirs) → dired-readin-insert → dired-insert-directory → insert-directory → file-name-handler(insert-directory) or ls-lisp--insert-directory or call-process(sh -c ls) → run-hook 'dired-after-readin-hook
最も無難なのがdired-after-readin-hookのタイミングです。このタイミングではバッファの更新が一通り終わって、上のコードで使用している全てのdired関数(dired-move-to-filename
やdired-subdir-max
、dired-get-filename
等)が正常に機能します。ただし、他の拡張もこのタイミングで様々な処理を行うことが多いため、競合には注意した方が良いでしょう。
他のタイミングとしては、一つのディレクトリを挿入するdired-insert-directory
やinsert-directory
の直後が考えられるでしょう。ただし、このタイミングでソートをするにはいくつか注意が必要です。
dired-insert-directory
の直後ではdired-subdir-alist
が構築されていないため、ディレクトリのファイルリストの範囲は全行スキャンしなければなりません。dired-move-to-filename
はおそらく大丈夫ですが、dired-get-filename
は問題がありそうです。dired-get-filename
はその辺りの処理をちゃんと行ってくれます。 ls-lisp
の中でソートするのは魅力的なアイデアでしょう。ls-lisp
はlsコマンドを使用せずEmacsの機能だけでlsをエミュレートするライブラリです。Windowsや最近ではAndroidでもデフォルトではls-lisp
が使われるようになっています。ls-lisp
は内部でdirectory-files-and-attributes
関数を呼び出してファイルリストを取得しています。この関数は結果をLispのリストで返すため、うまく介入すればテキストに挿入される前にリストのレベルでソート処理を行えます。具体的にはls-lisp-handle-switches
関数の直前はどうでしょうか。file-alistを独自にソートした上でswitchesにUオプションを追加してしまえばls-lisp
はそれ以上ソートを行いません。欠点はls-lisp
は使われない場合があるという点でしょう。UNIX環境でlsを使っている場合は当然として、trampなどでリモートディレクトリを参照している場合もls-lisp
が使われない可能性があります。
というわけで、先日から作っているel-xmpでは、任意のXMPプロパティに基づいてdiredやimage-dired内でファイルやサムネイルをソートできるようになりました。
misohena/el-xmp: Emacs XMP (Extensible Metadata Platform) Library
xmp-setup.elを使用すれば ' S p
でプロパティ名を指定してソート、 ' S -
で解除できます。私は exif:DateTimeOriginal (撮影日)でソートしたいがためにこの機能を追加しました。
dired上でのソートは概ね上で説明したようなやり口で対処しています。
image-dired上では一つの画像がテキストプロパティが付いた1文字に対応するので、sort-subr
を使用して1文字単位でソートしています。
あ、そうそう、結局キャッシュのためにSQLiteでデータベースを作成しました。ついでにサイドカーファイル無しでもプロパティを保存できるようにもしました(設定次第)。ただし、サイドカーファイルが無い分データベースのメンテナンスのために様々なコマンドを駆使する必要が出てきてしまっています。プロパティを付けた後にその対象ファイルを移動したり削除したりした場合、データベース内に記録されたプロパティは対象ファイルが存在しないいわば「はぐれ」状態になってしまいます。それを修正するためのコマンドを色々追加してあります。サイドカーファイルでもはぐれファイルになることはありますが比較的気がつきやすいですし対処もしやすいです。
Windows SearchのデータベースはWindows11からSQLite3を使ったものに変更されているそうです。そんなことが色々検索をしていたときにふと目に入ったので実際にどうなっているのか調べてみました。
データベースは C:\ProgramData\Microsoft\Search\Data\Applications\Windows\
にあります。Windows10だとここには Windows.edb
というファイルを中心としたファイル群があるのですが、Windows11だと Windows.db
、 Windows-gather.db
、 Windows-usn.db
というファイルがあります。
早速見て行きましょう……と言いたいところですが、私のメインPCいまだにWindows10なので、Windows11のノートPCからコピーしてきました。
sqlite3コマンドを使ってデータベースの中身を調べてみました。
Windows.db
CatalogStorageManager SystemIndex_1 SystemIndex_1_DATA_14 SystemIndex_1_OCC_14 SystemIndex_1_DATA_35 SystemIndex_1_OCC_35 SystemIndex_1_DATA_53 SystemIndex_1_OCC_53 SystemIndex_1_DATA_74 SystemIndex_1_OCC_74 SystemIndex_1_DATA_91 SystemIndex_1_OCC_91 SystemIndex_1_DATA_112 SystemIndex_1_OCC_112 SystemIndex_1_DATA_113 SystemIndex_1_OCC_113 SystemIndex_1_DATA_114 SystemIndex_1_OCC_114 SystemIndex_1_DATA_115 SystemIndex_1_OCC_115 SystemIndex_1_DATA_116 SystemIndex_1_OCC_116 SystemIndex_1_DATA_117 SystemIndex_1_OCC_117 SystemIndex_1_DATA_118 SystemIndex_1_OCC_118 SystemIndex_1_DATA_119 SystemIndex_1_OCC_119 SystemIndex_1_DATA_120 SystemIndex_1_OCC_120 SystemIndex_1_DATA_121 SystemIndex_1_OCC_121 SystemIndex_1_DATA_122 SystemIndex_1_OCC_122 SystemIndex_1_DATA_123 SystemIndex_1_OCC_123 SystemIndex_1_DATA_124 SystemIndex_1_OCC_124 SystemIndex_1_DATA_125 SystemIndex_1_OCC_125 SystemIndex_1_DATA_126 SystemIndex_1_OCC_126 SystemIndex_1_DATA_127 SystemIndex_1_OCC_127 SystemIndex_1_DATA_128 SystemIndex_1_OCC_128 SystemIndex_1_DATA_129 SystemIndex_1_OCC_129 SystemIndex_1_Properties SystemIndex_1_PropertyStore SystemIndex_1_PropertyStore_Metadata
Windows-gather.db
SystemIndex_Gthr SystemIndex_GthrAppOwner SystemIndex_GthrPth
Windows-usn.db
ChangeTracking
どれが何だか分かっていませんが、とりあえず全部載せておきます。
Windows.db
CREATE TABLE CatalogStorageManager ( CatalogName TEXT_TEXT(127) DEFAULT NULL, CatalogID INTEGER PRIMARY KEY, IsDeleted LONG_INTEGER DEFAULT 0, UNIQUE (CatalogName COLLATE "UNICODE_en-US" ASC, CatalogID ASC) ); CREATE TABLE SystemIndex_1_Properties ( IndexID LONG_INTEGER DEFAULT NULL, PropertyID LONG_INTEGER DEFAULT NULL, Property LONGBINARY_BLOB(2147483647) DEFAULT NULL, PRIMARY KEY (IndexID ASC, PropertyID ASC) ) WITHOUT ROWID; CREATE TABLE SystemIndex_1_PropertyStore_Metadata ( Id INTEGER PRIMARY KEY, UniqueKey TEXT NOT NULL UNIQUE, Name TEXT NOT NULL, PropertyId INTEGER NOT NULL, VariantType INTEGER NOT NULL, StorageType INTEGER NOT NULL, MaxSize INTEGER, Flags INTEGER NOT NULL ); CREATE TABLE SystemIndex_1_PropertyStore ( WorkId INTEGER NOT NULL, ColumnId INTEGER NOT NULL, Value BLOB NOT NULL, PRIMARY KEY (WorkId, ColumnId) ) WITHOUT ROWID; CREATE TABLE SystemIndex_1 ( IndexID INTEGER PRIMARY KEY, IndexDescription LONGBINARY_BLOB(5104) DEFAULT NULL ); CREATE TABLE SystemIndex_1_DATA_14 ( Partition UNSIGNEDLONG_INTEGER DEFAULT NULL, szKey LONGBINARY_BLOB(1664) DEFAULT NULL, PID UNSIGNEDSHORT_INTEGER DEFAULT NULL, WidStart UNSIGNEDLONG_INTEGER DEFAULT NULL, Data LONGBINARY_BLOB(32686) DEFAULT NULL, PRIMARY KEY (Partition ASC, szKey ASC, PID ASC, WidStart ASC) ) WITHOUT ROWID; CREATE TABLE SystemIndex_1_OCC_14 (OccID UNSIGNEDLONG_INTEGER DEFAULT NULL, OccPage LONGBINARY_BLOB(32650) DEFAULT NULL, PRIMARY KEY (OccID ASC) ) WITHOUT ROWID; CREATE TABLE SystemIndex_1_DATA_35 ( Partition UNSIGNEDLONG_INTEGER DEFAULT NULL, szKey LONGBINARY_BLOB(1664) DEFAULT NULL, PID UNSIGNEDSHORT_INTEGER DEFAULT NULL, WidStart UNSIGNEDLONG_INTEGER DEFAULT NULL, Data LONGBINARY_BLOB(32686) DEFAULT NULL, PRIMARY KEY (Partition ASC, szKey ASC, PID ASC, WidStart ASC)) WITHOUT ROWID; CREATE TABLE SystemIndex_1_OCC_35 ( OccID UNSIGNEDLONG_INTEGER DEFAULT NULL, OccPage LONGBINARY_BLOB(32650) DEFAULT NULL, PRIMARY KEY (OccID ASC)) WITHOUT ROWID; /* 以下 35の部分が53,74,91,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129 と同様に続く */
Windows-gather.db
CREATE TABLE SystemIndex_GthrAppOwner (OwnerId UNSIGNEDLONG_INTEGER NOT NULL, UserSid TEXT_TEXT(64) NOT NULL, AppUserModelId LONGTEXT_TEXT(130) NOT NULL, PRIMARY KEY (OwnerId ASC)) WITHOUT ROWID; CREATE TABLE SystemIndex_Gthr (ScopeID UNSIGNEDLONG_INTEGER NOT NULL, FileName LONGTEXT_TEXT(512) DEFAULT NULL, DocumentID UNSIGNEDLONG_INTEGER NOT NULL, SDID UNSIGNEDLONG_INTEGER DEFAULT NULL, UserData LONGBINARY_BLOB(26214400) DEFAULT NULL, AppOwnerId UNSIGNEDLONG_INTEGER DEFAULT NULL, LastModified BINARY_BLOB(8) DEFAULT NULL, RequiredSIDs BINARY_MULTI_BLOB(68) DEFAULT NULL, DeletedCount LONG_INTEGER DEFAULT NULL, TransactionFlags LONG_INTEGER DEFAULT NULL, TransactionExtendedFlags LONG_INTEGER DEFAULT NULL, CrawlNumberCrawled LONG_INTEGER DEFAULT NULL, StartAddressIdentifier UNSIGNEDSHORT_INTEGER DEFAULT NULL, Priority UNSIGNEDBYTE_INTEGER DEFAULT NULL, RunTime UNSIGNEDLONG_INTEGER DEFAULT NULL, FailureUpdateAttempts UNSIGNEDBYTE_INTEGER DEFAULT NULL, ClientID UNSIGNEDLONG_INTEGER DEFAULT NULL, LastRequestedRunTime UNSIGNEDLONG_INTEGER DEFAULT NULL, StorageProviderId LONGTEXT_TEXT(130) DEFAULT NULL, CalculatedPropertyFlags UNSIGNEDLONG_INTEGER DEFAULT NULL, PRIMARY KEY (ScopeID ASC, FileName COLLATE "UNICODE_en-US_LINGUISTIC_IGNORECASE" ASC)) WITHOUT ROWID; CREATE TABLE SystemIndex_GthrPth (Scope UNSIGNEDLONG_INTEGER DEFAULT NULL, Parent UNSIGNEDLONG_INTEGER DEFAULT NULL, Name LONGTEXT_TEXT(1000) DEFAULT NULL, PRIMARY KEY (Scope ASC)) WITHOUT ROWID;
Windows-usn.db
CREATE TABLE ChangeTracking (Client UNSIGNEDSHORT_INTEGER NOT NULL, Batch UNSIGNEDBYTE_INTEGER NOT NULL, Path LONGTEXT_TEXT(1000) DEFAULT NULL, CaseMap LONGTEXT_TEXT(1000) DEFAULT NULL, CurrentEntry BINARY_BLOB(56) DEFAULT NULL, MoveSource LONGTEXT_TEXT(1000) DEFAULT NULL, OldEntry BINARY_BLOB(56) DEFAULT NULL, MoveDestination LONGTEXT_TEXT(1000) DEFAULT NULL, RangeInformation LONGBINARY_BLOB(2147483647) DEFAULT NULL, PRIMARY KEY (Client ASC, Batch ASC, Path ASC)) WITHOUT ROWID;
全部くまなく解析するつもりも無いので、とりあえずパッと見一番分かりやすいファイルのプロパティに関する部分を見てみます。
テーブル SystemIndex_1_PropertyStore_Metadata
の定義は次のようになっています。
CREATE TABLE SystemIndex_1_PropertyStore_Metadata ( Id INTEGER PRIMARY KEY, UniqueKey TEXT NOT NULL UNIQUE, Name TEXT NOT NULL, PropertyId INTEGER NOT NULL, VariantType INTEGER NOT NULL, StorageType INTEGER NOT NULL, MaxSize INTEGER, Flags INTEGER NOT NULL );
実際の中身を見てみると次のようになっています。
select * from SystemIndex_1_PropertyStore_Metadata limit 40
Id | UniqueKey | Name | PropertyId | VariantType | StorageType | MaxSize | Flags |
1 | 4703-System_Null | System.Null | 4703 | 1 | 13 | 512 | 22 |
2 | 4583-System_Photo_GainControlText | System.Photo.GainControlText | 4583 | 31 | 11 | 512 | 55 |
3 | 4224-System_Contact_BusinessHomePage | System.Contact.BusinessHomePage | 4224 | 31 | 11 | 512 | 55 |
4 | 4435-System_IsIncomplete | System.IsIncomplete | 4435 | 11 | 8 | 54 | |
5 | 4449-System_ItemSubType | System.ItemSubType | 4449 | 31 | 11 | 512 | 55 |
6 | 4412-System_History_SelectionCount | System.History.SelectionCount | 4412 | 19 | 4 | 22 | |
7 | 4413-System_History_TargetUrlHostName | System.History.TargetUrlHostName | 4413 | 31 | 11 | 512 | 55 |
8 | 4622-System_SDID | System.SDID | 4622 | 19 | 4 | 22 | |
9 | 4359-System_DRM_IsProtected | System.DRM.IsProtected | 4359 | 11 | 8 | 54 | |
10 | 4345-System_Contact_SpouseName | System.Contact.SpouseName | 4345 | 31 | 11 | 256 | 55 |
11 | 4358-System_DRM_IsDisabled | System.DRM.IsDisabled | 4358 | 11 | 8 | 54 | |
12 | 4361-System_DateAcquired | System.DateAcquired | 4361 | 64 | 12 | 8 | 54 |
13 | 4434-System_IsFolder | System.IsFolder | 4434 | 11 | 8 | 54 | |
14 | 4513-System_Message_BccAddress | System.Message.BccAddress | 4513 | 65 | 13 | 5120 | 119 |
15 | 4472-System_MIMEType | System.MIMEType | 4472 | 31 | 11 | 512 | 55 |
16 | 4430-System_IsDeleted | System.IsDeleted | 4430 | 11 | 8 | 54 | |
17 | 4397-System_FilePlaceholderStatus | System.FilePlaceholderStatus | 4397 | 19 | 4 | 22 | |
18 | 4660-System_StorageProviderFileIdentifier | System.StorageProviderFileIdentifier | 4660 | 31 | 11 | 512 | 54 |
19 | 4514-System_Message_BccName | System.Message.BccName | 4514 | 65 | 13 | 5120 | 119 |
20 | 4661-System_StorageProviderFileVersion | System.StorageProviderFileVersion | 4661 | 31 | 11 | 512 | 54 |
21 | 27F-System_Search_Rank | System.Search.Rank | 27 | 3 | 3 | 10 | |
22 | 4515-System_Message_CcAddress | System.Message.CcAddress | 4515 | 65 | 13 | 5120 | 119 |
23 | 4407-System_GPS_LatitudeRef | System.GPS.LatitudeRef | 4407 | 31 | 11 | 512 | 54 |
24 | 4370-System_Document_Contributor | System.Document.Contributor | 4370 | 65 | 13 | 10240 | 119 |
25 | 28-System_Search_HitCount | System.Search.HitCount | 28 | 3 | 3 | 6 | |
26 | 4630F-System_Search_GatherTime | System.Search.GatherTime | 4630 | 64 | 12 | 8 | 58 |
27 | 4516-System_Message_CcName | System.Message.CcName | 4516 | 65 | 13 | 5120 | 119 |
28 | 4256-System_Contact_HomeAddress1Street | System.Contact.HomeAddress1Street | 4256 | 31 | 11 | 2048 | 55 |
29 | 4624-System_Search_AccessCount | System.Search.AccessCount | 4624 | 19 | 4 | 22 | |
30 | 4440-System_ItemFolderPathDisplay | System.ItemFolderPathDisplay | 4440 | 31 | 11 | 65536 | 51 |
31 | 4253-System_Contact_HomeAddress1Locality | System.Contact.HomeAddress1Locality | 4253 | 31 | 11 | 2048 | 55 |
32 | 4241-System_Contact_EmailAddress2 | System.Contact.EmailAddress2 | 4241 | 31 | 11 | 256 | 55 |
33 | 4447-System_ItemPathDisplay | System.ItemPathDisplay | 4447 | 31 | 11 | 65536 | 51 |
34 | 4255-System_Contact_HomeAddress1Region | System.Contact.HomeAddress1Region | 4255 | 31 | 11 | 2048 | 55 |
35 | 4632-System_Search_LastIndexedTotalTime | System.Search.LastIndexedTotalTime | 4632 | 5 | 7 | 22 | |
36 | 4252-System_Contact_HomeAddress1Country | System.Contact.HomeAddress1Country | 4252 | 31 | 11 | 2048 | 55 |
37 | 4254-System_Contact_HomeAddress1PostalCode | System.Contact.HomeAddress1PostalCode | 4254 | 31 | 11 | 2048 | 55 |
38 | 4174-System_Communication_AccountName | System.Communication.AccountName | 4174 | 31 | 11 | 512 | 55 |
39 | 33-System_ItemUrl | System.ItemUrl | 33 | 31 | 11 | 65536 | 51 |
40 | 4436-System_IsRead | System.IsRead | 4436 | 11 | 8 | 54 |
select count(*) from SystemIndex_1_PropertyStore_Metadata
605
全部で605行ありました。(Windows Searchは拡張可能な仕組みなので環境によって異なると思います)
これらはプロパティの種類に関する情報でしょう。プロパティのID、名前、型といった情報が見て取れます。
Nameに書かれている文字列には見覚えがあります。Windows Search SQLで検索したときにはこのプロパティ名を列名として指定しました(Windows Search SQL 構文の概要 - Win32 apps | Microsoft Learn)。
VariantTypeはWindows開発ではおなじみのやつですね。Windows SDKのWTypes.hから抜粋。
enum VARENUM { VT_EMPTY = 0, VT_NULL = 1, VT_I2 = 2, VT_I4 = 3, VT_R4 = 4, VT_R8 = 5, VT_CY = 6, VT_DATE = 7, VT_BSTR = 8, VT_DISPATCH = 9, VT_ERROR = 10, VT_BOOL = 11, VT_VARIANT = 12, VT_UNKNOWN = 13, VT_DECIMAL = 14, VT_I1 = 16, VT_UI1 = 17, VT_UI2 = 18, VT_UI4 = 19, VT_I8 = 20, VT_UI8 = 21, VT_INT = 22, VT_UINT = 23, VT_VOID = 24, VT_HRESULT = 25, VT_PTR = 26, VT_SAFEARRAY = 27, VT_CARRAY = 28, VT_USERDEFINED = 29, VT_LPSTR = 30, VT_LPWSTR = 31, VT_RECORD = 36, VT_INT_PTR = 37, VT_UINT_PTR = 38, VT_FILETIME = 64, VT_BLOB = 65, VT_STREAM = 66, VT_STORAGE = 67, VT_STREAMED_OBJECT = 68, VT_STORED_OBJECT = 69, VT_BLOB_OBJECT = 70, VT_CF = 71, VT_CLSID = 72, VT_VERSIONED_STREAM = 73, VT_BSTR_BLOB = 0xfff, VT_VECTOR = 0x1000, VT_ARRAY = 0x2000, VT_BYREF = 0x4000, VT_RESERVED = 0x8000, VT_ILLEGAL = 0xffff, VT_ILLEGALMASKED = 0xfff, VT_TYPEMASK = 0xfff } ;
こいつをEmacs Lispから引けるようにしておくと便利でしょう。
(defvar my-vt-alist '((0 . EMPTY) (1 . NULL) (2 . I2) (3 . I4) (4 . R4) (5 . R8) (6 . CY) (7 . DATE) (8 . BSTR) (9 . DISPATCH) (10 . ERROR) (11 . BOOL) (12 . VARIANT) (13 . UNKNOWN) (14 . DECIMAL) (16 . I1) (17 . UI1) (18 . UI2) (19 . UI4) (20 . I8) (21 . UI8) (22 . INT) (23 . UINT) (24 . VOID) (25 . HRESULT) (26 . PTR) (27 . SAFEARRAY) (28 . CARRAY) (29 . USERDEFINED) (30 . LPSTR) (31 . LPWSTR) (36 . RECORD) (37 . INT_PTR) (38 . UINT_PTR) (64 . FILETIME) (65 . BLOB) (66 . STREAM) (67 . STORAGE) (68 . STREAMED_OBJECT) (69 . STORED_OBJECT) (70 . BLOB_OBJECT) (71 . CF) (72 . CLSID) (73 . VERSIONED_STREAM) (#xfff . BSTR_BLOB) (#x1000 . VECTOR) (#x2000 . ARRAY) (#x4000 . BYREF) (#x8000 . RESERVED) )) (defun my-vt-name (vt-id) (alist-get vt-id my-vt-alist)) ;; TEST: (my-vt-name 31) => LPWSTR
これをorg-modeの表にcolumn formulaとして設定すれば次のような表も簡単に生成できます。
| Id | Name | VariantType | (VT Name)| StorageType | MaxSize | Flags | |----+---------------------------------------+-------------+----------+-------------+---------+-------| | 25 | 'System.Search.HitCount' | 3 | I4 | 3 | NULL | 6 | | 26 | 'System.Search.GatherTime' | 64 | FILETIME | 12 | 8 | 58 | | 27 | 'System.Message.CcName' | 65 | BLOB | 13 | 5120 | 119 | | 28 | 'System.Contact.HomeAddress1Street' | 31 | LPWSTR | 11 | 2048 | 55 | | 29 | 'System.Search.AccessCount' | 19 | UI4 | 4 | NULL | 22 | | 30 | 'System.ItemFolderPathDisplay' | 31 | LPWSTR | 11 | 65536 | 51 | | 31 | 'System.Contact.HomeAddress1Locality' | 31 | LPWSTR | 11 | 2048 | 55 |
さらにEmacs 29で加わった組み込みSQLite機能を使ってプロパティの Id
から型情報を割り出せるようにしておきましょう。
(defvar my-wds-property-alist (let ((db (sqlite-open "Windows.db"))) (unwind-protect (sqlite-select db "select * from SystemIndex_1_PropertyStore_Metadata") (sqlite-close db)))) (defun my-wds-property-name (id) (nth 2 (assq id my-wds-property-alist))) (defun my-wds-property-vt (id) (nth 4 (assq id my-wds-property-alist))) (defun my-wds-property-vt-name (id) (my-vt-name (my-wds-property-vt id))) ;; TEST: (cons (my-wds-property-name 30) (my-wds-property-vt-name 30)) => ("System.ItemFolderPathDisplay" . LPWSTR)
後はMaxSizeは良いとして、StorageTypeやFlagsは私ではすぐには思い当たらないです。
肝心要の実際のプロパティ値はどこにあるのかというと SystemIndex_1_PropertyStore
というテーブルにあります。
定義は次の通り。
CREATE TABLE SystemIndex_1_PropertyStore ( WorkId INTEGER NOT NULL, ColumnId INTEGER NOT NULL, Value BLOB NOT NULL, PRIMARY KEY (WorkId, ColumnId) ) WITHOUT ROWID;
実際の中身ですが、こちらはBLOBが入っているせいで文字化けしたので .dump
した結果から抜粋しました。
WorkId | ColumnId | Value |
---|---|---|
1 | 13 | 1 |
1 | 17 | 6 |
1 | 26 | X'2925c18d31b6d901' |
1 | 29 | 0 |
1 | 30 | 'C:\ProgramData\Microsoft\Windows' |
1 | 33 | 'C:\ProgramData\Microsoft\Windows\スタート メニュー' |
1 | 35 | 0.0 |
1 | 39 | 'file:C:/ProgramData/Microsoft/Windows/Start Menu' |
1 | 188 | X'00ea2bc7d261d801' |
1 | 295 | 0 |
1 | 306 | 0 |
1 | 308 | X'00ea2bc7d261d801' |
1 | 342 | X'66006f006c00640065007200' |
1 | 349 | X'5600f5bf02b2cf41' |
1 | 376 | 'file' |
1 | 414 | 'Windows' |
1 | 420 | 'ファイル フォルダー' |
1 | 432 | 'スタート メニュー' |
1 | 438 | 8209 |
1 | 441 | X'3868c8f55df9d801' |
1 | 444 | X'00ea2bc7d261d801' |
1 | 445 | X'00b352c6d261d801' |
1 | 448 | X'3868c8f55df9d801' |
1 | 449 | X'e3efa68d31b6d901' |
1 | 462 | 'スタート メニュー' |
1 | 463 | 'フォルダー' |
1 | 464 | 'Windows (C:\ProgramData\Microsoft)' |
1 | 466 | 'スタート メニュー' |
1 | 470 | 'スタート メニュー' |
1 | 564 | 'スタート メニュー (C:\ProgramData\Microsoft\Windows)' |
1 | 567 | 'Directory' |
1 | 581 | 'Start Menu' |
1 | 585 | 'Start Menu' |
1 | 587 | 1887437183 |
1 | 596 | X'28126512' |
1 | 597 | X'ba0a9bc7980a1de2357c65c73ae167b6' |
2 | 13 | 1 |
2 | 17 | 6 |
2 | 26 | X'b7a34f8331b6d901' |
2 | 29 | 0 |
2 | 30 | 'C:\' |
2 | 33 | 'C:\ユーザー' |
2 | 35 | 0.109375 |
2 | 39 | 'file:C:/Users' |
以降もこの調子で1行に1つのプロパティが大量に並んでいます。
WorkId
というのがオブジェクトの識別番号のようです。
ColumnId
は先ほど見たテーブル SystemIndex_1_PropertyStore_Metadata
の Id
列に対応しています。どうも旧形式のデータベースでは一つの行が一つのファイルで、プロパティは一つ一つの列だったのだとか。SQLiteに移行したときに設計を変えたようです。プロパティは存在しないものが多いのでプロパティを列にするとnullだらけになってしまいます。それでもインタフェースとしてはそちらの方が直感的ですよね。
Value
はプロパティの値です。テーブルの定義上ではBLOB型(Value BLOB NOT NULL)となっているのですが、実際に格納されている型は様々なようで、 SystemIndex_1_PropertyStore_Metadata
の VariantType
列に書かれている形式で格納されているみたいです。
これも先ほどやったようにorg-modeの表でプロパティ名と型名を補ってみると次のようになります。
| WorkId | ColumnId | (PropertyName) | (VTName) | Value | |--------+----------+------------------------------------+----------+------------------------------------------------------| | 1 | 13 | System.IsFolder | BOOL | 1 | | 1 | 17 | System.FilePlaceholderStatus | UI4 | 6 | | 1 | 26 | System.Search.GatherTime | FILETIME | X'2925c18d31b6d901' | | 1 | 29 | System.Search.AccessCount | UI4 | 0 | | 1 | 30 | System.ItemFolderPathDisplay | LPWSTR | 'C:\ProgramData\Microsoft\Windows' | | 1 | 33 | System.ItemPathDisplay | LPWSTR | 'C:\ProgramData\Microsoft\Windows\スタート メニュー' | | 1 | 35 | System.Search.LastIndexedTotalTime | R8 | 0.0 | | 1 | 39 | System.ItemUrl | LPWSTR | 'file:C:/ProgramData/Microsoft/Windows/Start Menu' | | 1 | 188 | System.DateImported | FILETIME | X'00ea2bc7d261d801' | | 1 | 295 | System.IsAttachment | BOOL | 0 | | 1 | 306 | System.IsEncrypted | BOOL | 0 | | 1 | 308 | System.ItemDate | FILETIME | X'00ea2bc7d261d801' | | 1 | 342 | System.Kind | BLOB | X'66006f006c00640065007200' | | 1 | 349 | System.ThumbnailCacheId | UI8 | X'5600f5bf02b2cf41' | | 1 | 376 | System.Search.Store | LPWSTR | 'file' | | 1 | 414 | System.ItemFolderNameDisplay | LPWSTR | 'Windows' | | 1 | 420 | System.ItemTypeText | LPWSTR | 'ファイル フォルダー' | | 1 | 432 | System.ItemNameDisplay | LPWSTR | 'スタート メニュー' |
System.ItemUrl
が完全なパスを表しています。 System.ItemPathDisplay
は Display と付いているように一部のファイル名がローカライズされてしまうので使いづらいんですよね。以前EmacsからWindows Searchするものを作ったときに困った記憶があります。
WorkId=1はスタートメニューフォルダを表しているようですが、あまりピンとこないので何か写真のプロパティを取得してみましょう。
まずはファイルを検索。といってもこのデータベースはノートPCのものなのでインデックスの範囲は極力絞ってありますし、その範囲に写真は入っていません。とりあえずテスト画像を作ってPicturesフォルダに入れて認識させ、再度データベースをメインPCにコピー。そして次のSQLで検索。
select * from SystemIndex_1_PropertyStore where ColumnId=39 and Value LIKE 'file:C:/Users/USERNAME/Pictures/%.jpg';
WorkId | ColumnId | Value |
3338 | 39 | file:C:/Users/USERNAME/Pictures/PXL_20241025_040712150.jpg |
新しい写真の WorkId
は 3338
になったようです。これをキーに全プロパティを取得してみましょう。
select * from SystemIndex_1_PropertyStore where WorkId=3338;
……と、実際やってみましたが、これもBLOBが文字化けしてしまったのでダンプしたデータから抜粋します(ついでにプロパティ名と型名も補っておきます)。
WorkId | ColumnId | (PropertyName) | (VTName) | Value |
---|---|---|---|---|
3338 | 13 | System.IsFolder | BOOL | 0 |
3338 | 15 | System.MIMEType | LPWSTR | 'image/jpeg' |
3338 | 17 | System.FilePlaceholderStatus | UI4 | 6 |
3338 | 23 | System.GPS.LatitudeRef | LPWSTR | 'N' |
3338 | 26 | System.Search.GatherTime | FILETIME | X'b183b6f95232db01' |
3338 | 29 | System.Search.AccessCount | UI4 | 0 |
3338 | 30 | System.ItemFolderPathDisplay | LPWSTR | 'C:\ユーザー\USERNAME\ピクチャ' |
3338 | 33 | System.ItemPathDisplay | LPWSTR | 'C:\ユーザー\USERNAME\ピクチャ\PXL_20241025_040712150.jpg' |
3338 | 35 | System.Search.LastIndexedTotalTime | R8 | 0.0 |
3338 | 39 | System.ItemUrl | LPWSTR | 'file:C:/Users/USERNAME/Pictures/PXL_20241025_040712150.jpg' |
3338 | 47 | System.ItemParticipants | BLOB | X'5400610072006f000000480061006e0061006b006f00' |
3338 | 60 | System.Photo.FlashText | LPWSTR | 'フラッシュなし (強制)' |
3338 | 75 | System.Photo.ExposureTime | R8 | 0.00665899999999999998 |
3338 | 85 | System.Photo.FNumber | R8 | 2.20000000000000017 |
3338 | 93 | System.FileOwner | LPWSTR | 'MYCOMPUTER\USERNAME' |
3338 | 110 | System.Image.HorizontalSize | UI4 | 4080 |
3338 | 114 | System.Photo.MeteringModeText | LPWSTR | '中央重点測光' |
3338 | 116 | System.Image.VerticalSize | UI4 | 3072 |
3338 | 122 | System.Image.HorizontalResolution | R8 | 96.0 |
3338 | 126 | System.Image.VerticalResolution | R8 | 96.0 |
3338 | 130 | System.Image.BitDepth | UI4 | 24 |
3338 | 143 | System.Photo.WhiteBalance | UI4 | 0 |
3338 | 153 | System.Image.Dimensions | LPWSTR | '4080 x 3072' |
3338 | 172 | System.GPS.Date | FILETIME | X'0020d86ad526db01' |
3338 | 185 | System.GPS.LongitudeRef | LPWSTR | 'E' |
3338 | 188 | System.DateImported | FILETIME | X'00a2f2dd5032db01' |
3338 | 204 | System.Media.ClassPrimaryID | LPWSTR | '{6FB2E74A-B8CB-40BB-93F3-FAC5F00FA203}' |
3338 | 270 | System.Media.DlnaProfileID | BLOB | X'4a005000450047005f004c0052004700' |
3338 | 279 | System.NotUserContent | BOOL | 0 |
3338 | 287 | System.Photo.FocalLengthInFilm | UI2 | 16 |
3338 | 290 | System.Photo.OrientationText | LPWSTR | '標準' |
3338 | 294 | System.ItemAuthors | BLOB | X'5400610072006f000000480061006e0061006b006f00' |
3338 | 295 | System.IsAttachment | BOOL | 0 |
3338 | 306 | System.IsEncrypted | BOOL | 0 |
3338 | 308 | System.ItemDate | FILETIME | X'60bb495e9326db01' |
3338 | 313 | System.Photo.CameraManufacturer | LPWSTR | 'Google' |
3338 | 316 | System.Photo.CameraModel | LPWSTR | 'Pixel 7' |
3338 | 322 | System.Photo.Orientation | UI2 | 1 |
3338 | 342 | System.Kind | BLOB | X'7000690063007400750072006500' |
3338 | 349 | System.ThumbnailCacheId | UI8 | X'e7698c11b08a5156' |
3338 | 356 | System.Photo.ShutterSpeed | R8 | 7.23000000000000042 |
3338 | 360 | System.VolumeId | CLSID | X'89cae2bf542043d99ecc540d185f11f5' |
3338 | 361 | System.Photo.Aperture | R8 | 2.2799999999999998 |
3338 | 366 | System.Photo.ExposureBias | R8 | 0.0 |
3338 | 370 | System.GPS.LatitudeDecimal | R8 | 35.6262194166666645 |
3338 | 374 | System.Photo.SubjectDistance | R8 | 4294967295.0 |
3338 | 376 | System.Search.Store | LPWSTR | 'file' |
3338 | 377 | System.Photo.MeteringMode | UI2 | 2 |
3338 | 384 | System.Photo.Flash | UI1 | 16 |
3338 | 386 | System.Photo.FocalLength | R8 | 2.35000000000000008 |
3338 | 387 | System.Photo.ExposureProgram | UI4 | 2 |
3338 | 396 | System.Photo.ISOSpeed | UI2 | 470 |
3338 | 407 | System.ContentType | LPWSTR | 'image/jpeg' |
3338 | 414 | System.ItemFolderNameDisplay | LPWSTR | 'ピクチャ' |
3338 | 424 | System.Title | LPWSTR | 'Senburi near Icchodaira' |
3338 | 429 | System.Author | BLOB | X'5400610072006f000000480061006e0061006b006f00' |
3338 | 430 | System.Keywords | BLOB | X'530065006e0062007500720069000000540061006b0061006f00' |
3338 | 432 | System.ItemNameDisplay | LPWSTR | 'PXL_20241025_040712150.jpg' |
3338 | 434 | System.FileExtension | LPWSTR | '.jpg' |
3338 | 436 | System.Size | UI8 | X'bfdb4d0000000000' |
3338 | 438 | System.FileAttributes | UI4 | 32 |
3338 | 441 | System.DateModified | FILETIME | X'00e4a1b35232db01' |
3338 | 444 | System.Document.DateCreated | FILETIME | X'00a2f2dd5032db01' |
3338 | 445 | System.DateCreated | FILETIME | X'ce669cdd5032db01' |
3338 | 448 | System.Document.DateSaved | FILETIME | X'00e4a1b35232db01' |
3338 | 449 | System.DateAccessed | FILETIME | X'919582f95232db01' |
3338 | 459 | System.Photo.ProgramModeText | LPWSTR | '標準のプログラム' |
3338 | 461 | System.ApplicationName | LPWSTR | 'HDR+ 1.0.641377693zd' |
3338 | 462 | System.ItemName | LPWSTR | 'PXL_20241025_040712150.jpg' |
3338 | 463 | System.KindText | LPWSTR | 'ピクチャ' |
3338 | 464 | System.ItemFolderPathDisplayNarrow | LPWSTR | 'ピクチャ (C:\ユーザー\USERNAME)' |
3338 | 466 | System.ItemNameDisplayWithoutExtension | LPWSTR | 'PXL_20241025_040712150' |
3338 | 469 | System.GPS.LongitudeDecimal | R8 | 139.230677777777771 |
3338 | 477 | System.GPS.Latitude | BLOB | X'000000000080414000000000008042400cb81e85eb314140' |
3338 | 478 | System.Photo.MaxAperture | R8 | 2.2799999999999998 |
3338 | 482 | System.GPS.Longitude | BLOB | X'00000000006061400000000000002a40f91f85eb51384940' |
3338 | 483 | System.Photo.TagViewAggregate | BLOB | X'530065006e0062007500720069000000540061006b0061006f00' |
3338 | 491 | System.Photo.ExposureProgramText | LPWSTR | '標準' |
3338 | 493 | System.Photo.SaturationText | LPWSTR | '標準' |
3338 | 520 | System.Photo.WhiteBalanceText | LPWSTR | '自動' |
3338 | 557 | System.ComputerName | LPWSTR | 'MYCOMPUTER' |
3338 | 564 | System.ItemPathDisplayNarrow | LPWSTR | 'PXL_20241025_040712150 (C:\ユーザー\USERNAME\ピクチャ)' |
3338 | 567 | System.ItemType | LPWSTR | '.jpg' |
3338 | 572 | System.Photo.SharpnessText | LPWSTR | '標準' |
3338 | 579 | System.Photo.DateTaken | FILETIME | X'60bb495e9326db01' |
3338 | 580 | System.Photo.ContrastText | LPWSTR | '標準' |
3338 | 581 | System.FileName | LPWSTR | 'PXL_20241025_040712150.jpg' |
3338 | 584 | System.Photo.DigitalZoom | R8 | 0.0 |
3338 | 585 | System.ParsingName | LPWSTR | 'PXL_20241025_040712150.jpg' |
3338 | 587 | System.SFGAOFlags | UI4 | 1077936503 |
3338 | 596 | InvertedOnlyPids | BLOB | X'28126512' |
3338 | 597 | InvertedOnlyMD5 | BLOB | X'0ace870441760f5a3225b96131c5f0ba' |
様々なメタデータが格納されていることが分かります。
例えば System.Title
を見ると 'Senburi near Icchodaira'
となっています。この写真は先日高尾山でセンブリの花を撮ったものだったので、あらかじめexiftoolでテスト的にタイトルを付けておいたのでした。データベースの中でどうなっているのか知りたかったので。x-default(英語)、英語、日本語と三つの言語でタイトルを付けておいたのですが、x-default(英語)のみが収集されたみたいです。
System.Author
を見るとBLOB型で X'5400610072006f000000480061006e0061006b006f00' となっています。BLOBと言っていますがどう見てもWSTR(UTF-16LE)ですよね。次のようなEmacs Lispを使えばデコード出来ます。
(defun my-decode-wstr (hexstr) (decode-coding-string (apply 'unibyte-string (cl-loop for i below (length hexstr) by 2 collect (string-to-number (substring hexstr i (+ i 2)) 16))) 'utf-16le))
実際にデコードすると次のようになります。
(my-decode-wstr "5400610072006f000000480061006e0061006b006f00")
"Taro\0Hanako"
あらかじめexiftoolで作者(dc:creator)をTaroとHanakoの二名にしておいたのですが、NUL文字区切りで記録されていました。なるほどだからBLOBにしているのかもしれませんね。(追記:SQLiteは文字列にNUL文字を含めることができるそうです。一部の文字列関数が最初のNUL文字までしか認識しないといった制限はあるみたいですが。なので必ずしもNULが入っているからといってBLOBにする必要は無かったかもしれません。参考:NUL Characters In Strings - sqlite.org)
System.Keywords
も同様にデコードしてみましょう。
(my-decode-wstr "530065006e0062007500720069000000540061006b0061006f00")
"Senburi\0Takao"
こちらもSenburiとTakaoの二つをdc:subjectに設定しておいたのですが、このようにNUL文字区切りで記録されていました。
GPS情報なんかも格納されているので是非場所を割り出してみてください。何かプレゼントでも隠しておけたら面白かったのですが(笑) って、Decimalバージョンもあるのね。
というわけで、最低限プロパティの部分がどのように格納されているかは分かったと思います。
ちなみに今回の記事はorg-modeのSQLiteソースコードブロックを使用して書きました。詳しい書き方については次のページをご覧下さい。
SQLite Source Code Blocks in Org Mode
上の方では実際に次のように書いていました。
#+begin_src sqlite :db Windows.db :header :exports both :eval no-export select * from SystemIndex_1_PropertyStore_Metadata limit 40 #+end_src | Id | UniqueKey | Name | PropertyId | VariantType | StorageType | MaxSize | Flags | | 1 | 4703-System_Null | System.Null | 4703 | 1 | 13 | 512 | 22 | ...略...
:header
を付けると見出しが付きます(デフォルトでは付きません)。
デフォルトはコードのみのエクスポートだったので、 :exports both
を指定しました。
エクスポート時に勝手に評価しないように :eval no-export
を指定しました。
また上の方でも少し使いましたが、Emacs 29から組み込みのsqlite関数が入っています。
Database (GNU Emacs Lisp Reference Manual)
使用例:
(let ((db (sqlite-open "~/test-sqlite.db"))) (unwind-protect (progn ;; ファイル情報テーブルを作る (sqlite-execute db "create table if not exists files ( id integer primary key, path text not null unique, mod_time real not null, size integer not null)") ;; カレントディレクトリにある全ファイルのフルパス、更新日時、サイズを挿入する ;; (本当は一文でやった方が速いみたい values (?,?,?),(?,?,?)...) (cl-loop for (file . attrs) in (directory-files-and-attributes "." t) do (sqlite-execute db "insert or replace into files (path,mod_time,size) values (?,?,?)" (list file (float-time (file-attribute-modification-time attrs)) (file-attribute-size attrs)))) ;; 更新日時降順に ((パス 更新日時)...) を取得する (sqlite-execute db "select path,datetime(mod_time,'unixepoch','localtime') from files where mod_time >= ? order by mod_time desc" ;; 過去30日分取得 (list (- (float-time) (* 30 24 60 60))))) ;; エラーが出てもちゃんと閉じるよ(しなくてもGCのタイミングで閉じるけど) (sqlite-close db)))
……というわけで色々やってみましたが、終わってみるとこれはEmacsのSQLite関連機能を学ぶためのちょうど良いチュートリアルでした。
xmp-editor.elを追加してプロパティを一括で編集できるようになりました。
Diredからなら xmp-dired-do-edit-properties
コマンド(' e p
)、その他のファイル名が検出できるところからなら xmp-edit-file-properties
コマンドで次のような編集バッファを表示できます。
image-diredには image-dired-dired-edit-comment-and-tags
というコマンドがあってこれと似たようなことができますが「コメント」と「タグ」のみの編集となっています。こちらは好きなだけ編集項目を増やせるのが良い所です(ユーザーが独自のプロパティを定義することも可能です)。そもそも対象は画像ファイルに限定されません。サムネイルは画像ファイルの時だけimage-diredの仕組みを利用して表示しています。
デフォルトではカスタマイズ変数で指定されているプロパティのみが表示されますが、プレフィックス引数(C-u 0 ' e p
)を使うかまたは末尾が-allとなっているコマンド(' e a
)を使えばel-xmpが認識できる全てのプロパティを表示させることもできます。
上のスクリーンショットは認識できる全プロパティが表示されていますが、そこには元々EXIFで記録されているカメラの情報も入っています。
今回xmp-tiff.elとxmp-exif.elを追加したことによってEXIFを読み取ることも可能になりました。JPEGスキャナはAPP1セグメントのうちEXIFとXMPパケットの両方を読み取ります。読み取ったEXIFはXMPに変換されて同一ファイル内にあるXMPパケットとマージされ、元々一つのXMPパケットだったかのように扱われます。EXIFの読み取りについてはEmacs内にすでにexif.elがありますが以前調べたように多少問題があることが分かっていた(特にエンディアンバグはまだ残っているようです)ので独自に実装しました。
ついでにTIFF画像にも対応し、こちらもJPEGと同様EXIFとXMPパケットの両方を読み取ってマージします。いくつかのカメラのRAW画像形式はTIFFベースだったので全く同じコードで対応できました。とりあえずソニーのARW、キヤノンのCR2(CR3はISO BMFFベースらしいです)、ニコンのNEFが読み込めたので、それらの拡張子ではTIFFスキャナが使われるようにしました。
PDFはファイルの中に複数のXMPパケットが混在していることが多いので汎用的なパケットスキャンではかなり高い確率で間違ったXMPパケットを読み取ってしまいます。そこでxmp-pdf.elを追加してPDFもちゃんとスキャンするように頑張ってみたのですが圧縮や暗号に対応できず読めないファイルが多く残る結果となってしまいました。その代わりpdfinfoコマンドによるXMPの抽出に対応したので、必要であればインストールしてください。pdf-toolsを入れていれば多分入っているんじゃないかと思います。無理にEmacs Lispで実装するよりもこっちの方が良いでしょう。
EXIFが読めるようになったので撮影日時が取得できるようになりました。Emacs Lispのコードレベルでは (xmp-pvalue-as-text (xmp-get-file-property "example.jpg" (xmp-xml-ename xmp-exif: "DateTimeOriginal")))
で取得できます。
ちなみにEXIFが持つ基本的な日時には DateTime
、 DateTimeOriginal
、 DateTimeDigitized
があり、 DateTime
は更新日時(xmp:ModifyDateにマップされる)、 DateTimeOriginal
は元々の撮影日時、 DateTimeDigitized
はデジタル化(要するにフィルムであればスキャンした日時。デジカメであれば撮影日時と同じ)となっているようです。以前のEXIF Metadata規格では DateTimeDigitized
が xmp:CreateDate
にマップされていたようですが、現在は exif:DateTimeDigitized
にマップされています。「作成日」というのはやや曖昧な概念と言えるでしょう。
撮影日時が読めるのであればファイルを撮影日時順にソートしたくなります。しかしDiredを開くたびに全ファイルからメタデータを読み取っていては時間がかかりすぎますから、断然キャッシュが重要になってきます。現在はEmacs起動中のみ有効なキャッシュ機構がありますが、Emacsを終了させたら消えてしまいます。やはり永続的なキャッシュ機構が必要です。
また、メタデータを編集した際の保存先も現在はサイドカーファイルのみですが、 ~/.emacs.d/ の中に隠しておく選択肢もやはり欲しい所です。ディレクトリやファイルの種類によって保存先を変えられるのが理想でしょう。
となるとやはり何らかのデータベースのような仕組みが必要になります。
image-diredのように一つのファイル( ~/.emacs.d/image-dired/.image-dired_db )に全ファイル分のメタデータをテキストで保存するのは嫌でしょう。一つ編集するたびに全部書き直さなければなりません。対象ファイルが増えたら増えた分だけ全ての場所で重くなっていきます。
サムネイルと同じようにメタデータも ファイル毎 に ~/.emacs.d/ 内に保存してしまうと言う手もあるでしょう。しかしいくら一つ一つのメタデータファイルを単純化してもディレクトリを開くときに多数のファイルにアクセスするのは効率が悪いです。
ディレクトリ毎 にメタデータファイルを作成して ~/.emacs.d/ の下に保存しておくというのは比較的バランスが取れた方法かもしれません。ファイル名はディレクトリのフルパスを何らかの規則でエンコードしたものにすれば良いでしょう。
後はちゃんとしたデータベースを使うことですね。今のEmacsにはSQLiteが組み込まれているので(コンパイルオプション次第ですがWindows版公式ビルドでちゃんと使えます)、それを使うのも良さそうです。Android OSが似たようなことをすでにしていますね。あれは端末の中にあるメディアファイルのメタデータをスキャンしてSQLiteのデータベースに保持していつでも素早く情報を取得できるようにしています。それと同じような感じにすれば良さそうです。とは言えデータベースの設計は悩み所が多そうな気がします。何を保存するか、何で検索できるようにするか。例えばdc:subject内の一つ一つの主題で高速に検索できるようになっているべきか、等。一つのプロパティが単純な一つのText型ならまだ良いのですが、Array(Seq,Seq,Alt)やStruct等の構造を持っているととたんに面倒になります。単純にデータの保存先と割り切るなら最悪S式をそのまま突っ込んでしまえば良いのでしょうけど……。まぁ、自分の必要性に応じてできるだけ簡単な方法を使いましょう。
xmp-image-dired.elを追加し、image-dired内で画像ファイルのメタデータを操作できるようにしました。具体的には次のことが可能です。
現在対応しているプロパティは次の通りです。
image-diredにはタグやコメントを付ける機能が既にありますが、それでは物足りないという人のためのものです(標準機能のタグやコメントは.emacs.d内に独自形式で保存されるので他のアプリと連携できないという問題もあります)。
参考までに設定例としてxmp-setup.elを追加したので、それを使えば簡単にこれらの機能をdiredとimage-diredの両方から利用できるようになります(フィルタはimage-diredのみ。diredではマーク操作が可能)。次のキーが使えるようになります。
' s r
: Set Rating' s l
: Set Label' s s
: Set Subjects' a s
: Add Subjects' r s
: Remove Subjects' s t
: Set Title' s d
: Set Description' s c
: Set Creators' g a
: Get All properties' g r
: Get Rating' g l
: Get Label' g s
: Get Subjects' g t
: Get Title' g d
: Get Description' g c
: Get Creators' m r
: Mark by Rating' m l
: Mark by Label' m s
: Mark by Subjects' m t
: Mark by Title' m d
: Mark by Description' m c
: Mark by Creator' f r
: Filter by Rating' f l
: Filter by Label' f s
: Filter by Subjects' f -
: Clear Filter 例えば ' s r
でレーティングを設定して、 ' f r
で特定のレーティングのみを表示する、といった使い方ができます。 ' f -
でフィルタを解除します。HydraやらTransientやらで使いやすくした方が良いと思いますが、とりあえず設定例ということで。
ここで言うLabel(xmp:Labelプロパティ)は、ファイルに対して一つだけ設定できる短い文字列です。定義については https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/ を参照してください。Adobe Lightroom等ではカラーラベルを設定するとこのプロパティにRedやBlueといった色名が設定されるらしいです。
Subject(dc:subject)は、ファイルの主題を表すキーワード等のリストです。定義については https://developer.adobe.com/xmp/docs/XMPNamespaces/dc/ や https://www.dublincore.org/specifications/dublin-core/dcmi-terms/terms/subject/ を参照してください。実質的にはある種のタグとして使用してしまって良いのではないかと思います。主題なので、ファイルが何を表現しているのかといった観点から付けた方が良いのだと思います。何か作業の状態等を付けるのは憚られる感じかと思います。
フィルタ表示機能は元々image-diredが対応していないものを無理矢理実現しているので、Emacsのバージョンアップに伴って機能しなくなる可能性があります。気がついたら直すつもりです。
XMP(Extensible Metadata Platform)というメタデータを記述するための仕様があります。
https://developer.adobe.com/xmp/docs/
https://developer.adobe.com/xmp/docs/XMPSpecifications/
メタデータというと例えばタイトル、作成者、作成日、内容説明、ラベルといったものが代表的ですが、それに限らずあらゆる関連情報を表現することが出来る拡張可能な枠組みがXMPです。
例えば XMPSpecificationPart1.pdf というPDFファイル(バイナリ)の最後の方には次のようなXMLテキストが含まれています。
...バイナリ略... <?xpacket begin="(略)" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 4.0-c316 44.253921, Sun Oct 01 2006 17:14:39"> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"> <dc:format>application/pdf</dc:format> <dc:title> <rdf:Alt> <rdf:li xml:lang="x-default">XMP Specification Part 1: Data Model, Serialization, and Core Properties</rdf:li> </rdf:Alt> </dc:title> <dc:creator> <rdf:Seq> <rdf:li>Adobe Systems Incorporated</rdf:li> </rdf:Seq> </dc:creator> </rdf:Description> <rdf:Description rdf:about="" xmlns:xap="http://ns.adobe.com/xap/1.0/"> <xap:CreateDate>2012-03-21T08:55:04Z</xap:CreateDate> <xap:CreatorTool>FrameMaker 8.0</xap:CreatorTool> <xap:ModifyDate>2012-03-21T08:57:17-07:00</xap:ModifyDate> <xap:MetadataDate>2012-03-21T08:57:17-07:00</xap:MetadataDate> </rdf:Description> <rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/"> <pdf:Producer>Acrobat Distiller 8.1.0 (Windows)</pdf:Producer> <pdf:Copyright>Copyright 2012, Adobe Systems Incorporated. All rights reserved.</pdf:Copyright> </rdf:Description> <rdf:Description rdf:about="" xmlns:pdfx="http://ns.adobe.com/pdfx/1.3/"> <pdfx:Copyright>Copyright 2012, Adobe Systems Incorporated. All rights reserved.</pdfx:Copyright> </rdf:Description> <rdf:Description rdf:about="" xmlns:xapMM="http://ns.adobe.com/xap/1.0/mm/"> <xapMM:DocumentID>uuid:3806c052-1c0d-4599-b3ff-38c0441bbb3a</xapMM:DocumentID> <xapMM:InstanceID>uuid:abc82103-c360-4b74-8812-ff4c81eceaea</xapMM:InstanceID> </rdf:Description> </rdf:RDF> </x:xmpmeta> ...空白文字略... <?xpacket end="w"?> ...バイナリ略...
これによって、このPDFのタイトルが「XMP Specification Part 1: Data Model, Serialization, and Core Properties」、作成者が「Adobe Systems Incorporated」、作成日時が「2012-03-21T08:55:04Z」であることを示しているわけです。
XMPはJPEGファイルの中にも埋め込まれていることがあり、様々な撮影情報が記録されていることがあります。
写真のメタデータと言えばExifが代表的ですが、私の使っているカメラではカメラ内で設定したレーティング(写真の評価)だけがXMPとして記録されるようになっていました(それ以外の撮影情報は全てExifで記録されていました)。
また、写真編集ソフトや各種ビューアもこのXMPを利用するものが多いです。沢山ある写真を評価したり分類したりしたときに、その情報をサイドカーファイルと呼ばれるXMLファイル(IMG0001.JPG.xmpのようなファイル名)に保存するようになっているソフトが多くあります。darktableにいたっては現像のための全てのパラメータをサイドカーファイルに保存します。サイドカーファイルは少々煩わしい存在ですが、メタデータをネットワークストレージを介して共有したり他のソフトと交換したりするのには最適でしょう。
というわけで、EmacsからもこのXMPで書かれたメタデータを読み書きするためのライブラリを作成しました。
misohena/el-xmp: Emacs XMP (Extensible Metadata Platform) Library
今のところ基本的な情報を読み書きするためのコマンドやDired上でメタデータを元にマークするコマンドくらいしかありません。
サイドカーファイル(拡張子が.xmpのXMLテキスト)からの読み取りはもちろん、JPEG内のAPP1セグメント内にあるXMPパケットも読み取れるようになっています。その他の場合は汎用的なパケットスキャン(<?xpacket
を探す)が使われますが、確実に読み取れるとは限りません。
保存は全てサイドカーファイルに対して行われます。一応パケット内に書き戻すことも可能なのですが、危ないのでデフォルトでは無効になっています。
Diredでマークするときなど、数千ファイルくらいあると読み取りにかなりの時間がかかります。一応Emacsのセッション中だけ有効なキャッシュ機構があるので、二回目以降は速くなるはずです。
永続的なファイルメタデータキャッシュ機構もそのうち作りたいと考えています。
「emacs lisp parse xml」などでWeb検索すると真っ先に出てくるのはEmacs Lispのマニュアルでしょう。
Parsing HTML/XML (GNU Emacs Lisp Reference Manual)
そこではlibxml-parse-xml-region
という関数で解析が出来るというようなことが書かれています。早速試してみましょう。
まずは次のXMLファイルを用意します。これは今適当にでっち上げたXMLで特に意味はありません。これをexample.xmlというファイル名で保存します。
<?xml version="1.0" encoding="UTF-8"?> <my-nanikano-data xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> <my-file-list> <file name="a0001.wav"> <text><p xmlns="http://www.w3.org/1999/xhtml">これはテストですてすとてすと<strong>0001</strong>です。</p></text> <rdf:Description rdf:about="" xmp:Rating="5" xmlns:xmp="http://ns.adobe.com/xap/1.0/"> <dc:creator>太郎</dc:creator> </rdf:Description> </file> <file name="b0001.wav"> <text><p xmlns="http://www.w3.org/1999/xhtml">こんにちは<strong>0002</strong>です!</p></text> <rdf:Description rdf:about=""> <dc:creator>花子</dc:creator> </rdf:Description> </file> </my-file-list> </my-nanikano-data>
そしてlibxml-parse-xml-region
を使って解析してみます。
(with-temp-buffer (insert-file-contents "example.xml") (libxml-parse-xml-region))
結果は次のようになりました。
(my-nanikano-data nil (my-file-list nil (file ((name . "a0001.wav")) (text nil (p nil "これはテストですてすとてすと" (strong nil "0001") "です。")) (Description ((about . "") (Rating . "5")) (creator nil "太郎"))) (file ((name . "b0001.wav")) (text nil (p nil "こんにちは" (strong nil "0002") "です!")) (Description ((about . "")) (creator nil "花子")))))
名前空間が消えてしまっています。これはいけません。これをベースに内容を修正して書き戻したらmy-nanikano-dataを読み込むアプリが正しく動かなくなってしまいます(まぁ、そんなものはありませんが)。
「emacs libxml namespace」でWeb検索するとこの問題に対するバグ報告と思わしきものがヒットしますが、libxml2のバグでEmacsのバグじゃ無いよ、ということでクローズされているみたいです(#59537 - `libxml-parse-xml-region` strips out the namespace information, and namespace prefix in the DOM representation - GNU bug report logs)。そんなわけないでしょう(笑)。libxml2はノードの解析結果をxmlNode構造体で返すようですが、libxml-parse-xml-region
の実装はその中のnsを一切触っていませんからね。
まぁ、バグを見つける→バグ報告を見つける→よく分からない理由で却下されている、という流れはEmacsでは良くあることです。Emacsは「あるがまま」の状態で提供されているわけで、自分で直して使えない人にはお勧めできません。
とは言えここはC言語で書かれている部分なので修正のハードルは高いですね。Emacs Lispレベルで何とかならないでしょうか?
探してみるとxml-parse-file
というものが見つかりました(xml.el内)。早速使ってみましょう。
(xml-parse-file "example.xml")
((my-nanikano-data ((xmlns:rdf . "http://www.w3.org/1999/02/22-rdf-syntax-ns#") (xmlns:dc . "http://purl.org/dc/elements/1.1/")) "\n " (my-file-list nil "\n " (file ((name . "a0001.wav")) "\n " (text nil (p ((xmlns . "http://www.w3.org/1999/xhtml")) "これはテストですてすとてすと" (strong nil "0001") "です。")) "\n " (rdf:Description ((rdf:about . "") (xmp:Rating . "5") (xmlns:xmp . "http://ns.adobe.com/xap/1.0/")) "\n " (dc:creator nil "太郎") "\n ") "\n ") "\n " (file ((name . "b0001.wav")) "\n " (text nil (p ((xmlns . "http://www.w3.org/1999/xhtml")) "こんにちは" (strong nil "0002") "です!")) "\n " (rdf:Description ((rdf:about . "")) "\n " (dc:creator nil "花子") "\n ") "\n ") "\n ") "\n"))
パッと見良さそうに見えます。名前空間の宣言や接頭辞の情報が失われず残っています。空白文字が全部残っているのは気になりますが、まぁ、何とでもなります。
問題は要素名や属性名が接頭辞を含む一つのシンボルで表現されていることです。その接頭辞がどの名前空間名(URI)を表しているか(あるいは省略されている場合にデフォルト名前空間が何か)は自分で調べなければならないでしょう。接頭辞が慣用的に決まっていてそれしか対応する必要が無いならこれでも良いのでしょうが、私はちょっと気になります。
改めてxml-parse-file
の関数ドキュメントを見ると、parse-ns引数を非nilにするとQNAMES(修飾名)を展開できると書かれています。早速やってみましょう。
(xml-parse-file "example.xml" nil t)
((("" . "my-nanikano-data") ((("http://www.w3.org/2000/xmlns/" . "rdf") . "http://www.w3.org/1999/02/22-rdf-syntax-ns#") (("http://www.w3.org/2000/xmlns/" . "dc") . "http://purl.org/dc/elements/1.1/")) "\n " (("" . "my-file-list") nil "\n " (("" . "file") ((("" . "name") . "a0001.wav")) "\n " (("" . "text") nil (("http://www.w3.org/1999/xhtml" . "p") ((("http://www.w3.org/2000/xmlns/" . "") . "http://www.w3.org/1999/xhtml")) "これはテストですてすとてすと" (("http://www.w3.org/1999/xhtml" . "strong") nil "0001") "です。")) "\n " (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Description") ((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "about") . "") (("" . "Rating") . "5") (("http://www.w3.org/2000/xmlns/" . "xmp") . "http://ns.adobe.com/xap/1.0/")) "\n " (("http://purl.org/dc/elements/1.1/" . "creator") nil "太郎") "\n ") "\n ") "\n " (("" . "file") ((("" . "name") . "b0001.wav")) "\n " (("" . "text") nil (("http://www.w3.org/1999/xhtml" . "p") ((("http://www.w3.org/2000/xmlns/" . "") . "http://www.w3.org/1999/xhtml")) "こんにちは" (("http://www.w3.org/1999/xhtml" . "strong") nil "0002") "です!")) "\n " (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Description") ((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "about") . "")) "\n " (("http://purl.org/dc/elements/1.1/" . "creator") nil "花子") "\n ") "\n ") "\n ") "\n"))
おお、これまで要素名や属性名が入っていた部分がconsセルになっています。 (名前空間名 . ローカル名)
という形式になっており、これはXML名前空間仕様における展開名(expanded name)と同じです。これなら接頭辞がどの名前空間名に対応するかを自分で調べなくても良さそうですね。
……と思ったのですが、よく見るとおかしな所があります。
それは (("" . "Rating") . "5")
と書かれている部分。これは (("http://ns.adobe.com/xap/1.0/" . "Rating") . "5")
でないといけないはずです。
少し追試をしてみましょう。試しに次のようなXMLを作ってみました。
<?xml version="1.0" encoding="UTF-8"?> <rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xmp="http://ns.adobe.com/xap/1.0/" rdf:about="" xmp:Rating="3" xmp:CreateDate="2024-09-23"> <xmp:Thumbnails> <rdf:Alt> <rdf:li rdf:parseType="Resource"> .... </rdf:li> </rdf:Alt> </xmp:Thumbnails> </rdf:Description>
これを同様に解析してみます。
(xml-parse-file "example-2.xml" nil t)
結果は次の通り。
((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Description") ((("http://www.w3.org/2000/xmlns/" . "rdf") . "http://www.w3.org/1999/02/22-rdf-syntax-ns#") (("http://www.w3.org/2000/xmlns/" . "xmp") . "http://ns.adobe.com/xap/1.0/") (("" . "about") . "") (("" . "Rating") . "3") (("" . "CreateDate") . "2024-09-23")) "\n " (("http://ns.adobe.com/xap/1.0/" . "Thumbnails") nil "\n " (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Alt") nil "\n " (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "li") ((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "parseType") . "Resource")) "\n ....\n ") "\n ") "\n ") "\n"))
属性名である rdf:about
や xmp:Rating
、 xmp:CreateDate
の名前空間名が ""
になってしまっています。しかし前にある要素名 rdf:Description
や、その後にある要素名(xmp:Thumbnails
)や属性名(rdf:parseType
)には正しい名前空間名が展開されています。つまり名前空間を宣言した要素に指定されている属性名だけが正しく展開されていないように見えます。
そこでxml.el内にあるxml-parse-file
の実装を読んだところ、原因はxml-parse-tag-1
関数内にあることが分かりました。 ;; opening tag
と書いてあるあたり、開始タグの解析を行っているあたりです。xml-nsという変数が接頭辞と名前空間名との対応表(alist)になっているようですが、その更新と使用の順序に問題があります。全属性の解析と属性名の展開、名前空間宣言(xmlns:??=)の反映、要素名の展開の順に行われているため、属性名を展開する段階ではまだその要素で行われている名前空間宣言が反映されていません。従って、名前空間宣言をした要素にある属性名は正しく展開されないというわけです。せっかく便利な機能があるのにこれでは使えません。
他にもEmacs内にXMLを解析している部分は無いかと探したところ、nxml-parse-file
という関数を見つけました(nxml-parse.el内)。
早速使ってましょう。
(nxml-parse-file "example.xml")
("my-nanikano-data" (((:http://www.w3.org/2000/xmlns/ . "rdf") . "http://www.w3.org/1999/02/22-rdf-syntax-ns#") ((:http://www.w3.org/2000/xmlns/ . "dc") . "http://purl.org/dc/elements/1.1/")) "\n " ("my-file-list" nil "\n " ("file" (("name" . "a0001.wav")) "\n " ("text" nil ((:http://www.w3.org/1999/xhtml . "p") (((:http://www.w3.org/2000/xmlns/ . "xmlns") . "http://www.w3.org/1999/xhtml")) "これはテストですてすとてすと" ((:http://www.w3.org/1999/xhtml . "strong") nil "0001") "です。")) "\n " ((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "Description") (((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "about") . "") ((:http://ns.adobe.com/xap/1.0/ . "Rating") . "5") ((:http://www.w3.org/2000/xmlns/ . "xmp") . "http://ns.adobe.com/xap/1.0/")) "\n " ((:http://purl.org/dc/elements/1.1/ . "creator") nil "太郎") "\n ") "\n ") "\n " ("file" (("name" . "b0001.wav")) "\n " ("text" nil ((:http://www.w3.org/1999/xhtml . "p") (((:http://www.w3.org/2000/xmlns/ . "xmlns") . "http://www.w3.org/1999/xhtml")) "こんにちは" ((:http://www.w3.org/1999/xhtml . "strong") nil "0002") "です!")) "\n " ((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "Description") (((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "about") . "")) "\n " ((:http://purl.org/dc/elements/1.1/ . "creator") nil "花子") "\n ") "\n ") "\n ") "\n")
問題は特に見当たらないと思います。
特徴:
:http://www.w3.org/2000/xmlns/
等)。(<名前空間名キーワード> . <ローカル名文字列>)
で表現される。ただし、名前空間無しの場合はローカル名単体の文字列となる。xmlns=<名前空間名>
)の解析結果は ((:http://www.w3.org/2000/xmlns/ . "xmlns") . "<名前空間名>")
の形で表現される。 xmlns
の部分は :http://www.w3.org/2000/xmlns/
と展開されているのだからローカル名部分は空文字列にでもなりそうなものだが、なぜか "xmlns"
が入っている。これだと xmlns:
という接頭辞の名前空間を宣言する(xmlns:xmlns=<名前空間名>
)のと同じ形になってしまうので、注意が必要。 xml-parse-file(parse-nsが非nilの場合)やnxml-parse-fileを使った場合、要素名がconsセルで表現されるようになるわけですが、そうするとdom.elが使えなくなるという問題が発生します。dom.elの各関数には処理対象のノードを指定する引数(nodeやdom)があるわけですが、それらはなぜか引数がノードのリストである可能性を考慮しています。引数nodeがノードのリストであった場合、そのリスト内の最初のノードを処理対象にするコードが各関数に入っています。引数nodeがノードのリストかどうかをチェックするコードは (consp (car node))
という形になっています。nxml-parse-file等を使用して生成されたノードは要素名部分、つまりリストの最初の要素がconsセルになりますから、このチェックにひっかかってしまいます。そしてノードのリストと勘違いされて、その先頭要素が取り出され、構造が破壊されてしまいます。
この問題に対する対策ですが、dom.elは大したことはやっていませんし少々使いづらい所もあるので、自分で独自のノード操作関数を作成するのが一番だと思います。要素の内部表現はいずれで解析した場合でも、 (<要素名> . (<属性alist> . <子供リスト>))
の形になっています。要素名や属性名(属性alistのキー部分)の部分が文字列だったりconsセル (<名前空間名> . <ローカル名>)
だったりするだけです。やっていることは単純なので簡単に作れるでしょう。el-easydrawなんかでも最初のうちはdom.elやsvg.elを使用していましたが、今は使わなくなってしまいました。
別の方法としては、解析した後に名前部分を補正してしまう手があります。つまり、ツリーを巡回して名前部分を一つのシンボルなり文字列なりに結合してしまうわけです(:http://www.w3.org/1999/02/22-rdf-syntax-ns\#::Description
のように)。そうすればdom.elは引き続き使えます。
名前空間をちゃんと扱いたいならnxml-parse-fileが一番良さそうです。この場合、解析結果そのままだとdom.elが使えないので何らかの対処が必要になります。
接頭辞が決め打ちでも構わないような用途なら(parse-ns引数を使わない)xml-parse-fileも使えそうです。parse-ns引数は不具合があるので止めましょう。
名前空間を使わない、もしくは消えても構わないような用途ならlibxml-parse-xml-regionが使えます。
Emacsの中をくまなく探したわけでもありませんし紹介した関数の中もくまなく調べたわけでもありませんので、何か気がついていない問題やより良い方法があるかもしれません。
と言うわけで昨日の続きなのですが、captionから図番を消す方法について。
昨日紹介した "#+CUSTOM_TRANSLATION: "Figure %d:" ""
という指定で図番を消すのは、指定方法が間接的すぎてイマイチだなという話でした。もっと直接的に「図番を消せ」と指定したい所です。
なので今度は #+OPTIONS: fignum:nil
という指定で消せるようにしてみます。
何かの写真 [[file:example-1.jpg]] これは別の写真です。 別の写真 [[file:example-2.jpg]]これは何かの写真です。
といってもやることは結局翻訳を書き替えるだけです。
そもそも図番を消すのになぜ翻訳を書き替えているのかというと、ox-html.elでこの図番の文字列を生成している部分が非常に入り組んだ場所にあって、advice等で動作を修正しづらいからという理由があります。
;; ox-html.elより (defun org-html-paragraph (paragraph contents info) "Transcode a PARAGRAPH element from Org to HTML. CONTENTS is the contents of the paragraph, as a string. INFO is the plist used as a communication channel." (let* ((parent (org-element-parent paragraph)) (parent-type (org-element-type parent)) (style '((footnote-definition " class=\"footpara\"") (org-data " class=\"footpara\""))) (attributes (org-html--make-attribute-string (org-export-read-attribute :attr_html paragraph))) (extra (or (cadr (assq parent-type style)) ""))) (cond ((and (eq parent-type 'item) (not (org-export-get-previous-element paragraph info)) (let ((followers (org-export-get-next-element paragraph info 2))) (and (not (cdr followers)) (org-element-type-p (car followers) '(nil plain-list))))) ;; First paragraph in an item has no tag if it is alone or ;; followed, at most, by a sub-list. contents) ((org-html-standalone-image-p paragraph info) ;; Standalone image. (let ((caption (let ((raw (org-export-data (org-export-get-caption paragraph) info)) (org-html-standalone-image-predicate #'org-html--has-caption-p)) (if (not (org-string-nw-p raw)) raw (concat "<span class=\"figure-number\">" (format (org-html--translate "Figure %d:" info) ;;★★ここ!!★★ (org-html--translateはorg-export-translateを呼んでるだけです) (org-export-get-ordinal (org-element-map paragraph 'link #'identity info t) info nil #'org-html-standalone-image-p)) " </span>" raw)))) (label (org-html--reference paragraph info))) (org-html--wrap-image contents info caption label))) ;; Regular paragraph. (t (format "<p%s%s>\n%s</p>" (if (org-string-nw-p attributes) (concat " " attributes) "") extra contents)))))
paragraphのtranscodeを行う関数の奥深くに埋め込まれてしまっているんですね。もっと関数を細かく分けようよ……などと言っても仕方がありません(Emacs Lispでは良くあることです)。なのでそこから呼び出されているorg-html--translate(org-export-translate)の動作を変えることを考えたわけです。
最初は次のようにしてうまく行くことを確かめました。
(defun my-org-html--translate:no-figure-number (s info) (when (and (stringp s) (string= s "Figure %d:")) "")) (advice-add 'org-html--translate :before-until ;;←nilを返したら元の関数を呼ぶ指定 'my-org-html--translate:no-figure-number)
これだと常に図番が消えてしまうので、何か切り替える方法が必要です。必要かどうかは文書によって変わるのでバッファ内オプションで指定出来るのが望ましいです。
それならいっそのこと翻訳全般をバッファ内オプションでカスタマイズ出来るようにしてはどうか? と思い作成したのが昨日のコードでした。まぁ、ちょっとやり過ぎだったみたいです。
なので昨日のコードをベースにして、オプションの指定方法とその反映部分を修正してみましょう。
まずはエクスポートオプション fignum
を追加します。全てのバックエンドに共通するオプションはorg-export-options-alist
変数に格納されています。その定義は次のようになっています。
;; ox.elより (defconst org-export-options-alist '((:title "TITLE" nil nil parse) (:date "DATE" nil nil parse) (:author "AUTHOR" nil user-full-name parse) ... (:creator "CREATOR" nil org-export-creator-string) (:headline-levels nil "H" org-export-headline-levels) (:preserve-breaks nil "\\n" org-export-preserve-breaks) (:section-numbers nil "num" org-export-with-section-numbers) ...
ここに登録しておくと、エクスポート時に自動的にplistの形で全オプションの値を集めてくれます。alistのキー(:title等)は後からplistのキーとして使うキーワードです。2番目の文字列は #+TITLE:
のような形のオプションで使います。3番目の文字列は #+OPTIONS: H:5
のような形のオプションで使います。4番目はデフォルト値、5番目は複数のオプションが指定されたときにどうするかを指定します。
今回は #+OPTIONS: fignum:nil
のように指定させたいので (:figure-number nil "fignum" t)
のような要素をorg-export-options-alistに追加すれば良いでしょう。
(setf (alist-get :figure-number org-export-options-alist) '(nil "fignum" t))
こういう時私はいつもsetfとalist-getを使用しています。なんでalist-setみたいなものが無いんでしょうね?
後は翻訳辞書を書き替えるだけです。
今回私はマニュアルのエクスポートプロセスを見てから少し実験した上で、org-export-filter-options-functionsに登録する関数でその書き替え処理を行うことにしました。理由は、この段階になるとオプションが収集し終わっていること、そしてトランスコードが始まる前であることです。加えて、その後一貫してカレントバッファが一時コピーであることも確認しました(ソースコードを追った上での確認はしていないので、そうならないケースがあったらスミマセン)。
(add-to-list 'org-export-filter-options-functions 'org-figure-number-filter-options) (defun org-figure-number-filter-options (options _backend &rest _rest) (unless (plist-get options :figure-number) (org-figure-number-override-dictionary options)) options)
翻訳辞書(org-export-dictionary)の一時的な変更はローカル変数化することで実現しています。少し実験した限り、エクスポート処理中は元のorg-modeバッファをコピーした一時バッファが常にカレントバッファになっているようだったので、それで十分かなと思いました(間違っていたらスミマセン)。
(defun org-figure-number-override-dictionary (options) (setq-local org-export-dictionary (nconc (org-figure-number-make-dictionary ;; #+LANGUAGE:の指定がある場合にも対応。 (or (plist-get options :language) org-export-default-language)) org-export-dictionary))) (defun org-figure-number-make-dictionary (lang) (list (list "Figure %d:" (list lang :default ""))))
というわけで最終的には次のようになります。
;;; org-figure-number.el --- Remove figure numbers -*- lexical-binding: t; -*- ;; init.el: ;; (with-eval-after-load "ox" (require 'org-figure-number)) (require 'cl-lib) (require 'ox) (defun org-figure-number-make-dictionary (lang) (list (list "Figure %d:" (list lang :default "")))) (defun org-figure-number-override-dictionary (options) (setq-local org-export-dictionary (nconc (org-figure-number-make-dictionary (or (plist-get options :language) org-export-default-language)) org-export-dictionary))) (defun org-figure-number-filter-options (options _backend &rest _rest) (unless (plist-get options :figure-number) (org-figure-number-override-dictionary options)) options) (defun org-figure-number-setup () (setf (alist-get :figure-number org-export-options-alist) '(nil "fignum" t)) (add-to-list 'org-export-filter-options-functions 'org-figure-number-filter-options)) (org-figure-number-setup) (provide 'org-figure-number)
と、ここまで書いてふと気がついたのですが、他のバックエンドではどうなっているのかな? と。……ox-latex.elなんかだと図番はまた別の方法で生成されているみたいですね。そういえばTeXって処理系が連番振るものでしたね……。
というわけで、以上はHTMLでエクスポートする際の話でした。
「エクスポート時の翻訳ってなんじゃ?」とお思いの方もいると思いますが、org-modeには人間が読むための短い文字列を英語以外の言語へ翻訳するための仕組みがあります。詳しくはorg-export-dictionary
変数を見るのが手っ取り早いと思います。 "Author"
が "著者"
、 "Date"
が "日付"
、 "Figure %d:"
が "図%d: "
、 "Listing"
が "ソースコード"
等々、色々定義されています。そしてそういった訳がイマイチしっくりこないという事はありませんか? それも文書によって適したものは変わってくることもあります。そこで今回はこの翻訳をバッファ内のオプションでカスタマイズする方法を用意してみました。
前準備:
(with-eval-after-load "ox" (require 'org-custom-translation))
使い方:
単純に置き換える例。
#+TITLE: むかしのはなし #+AUTHOR: おおむかしのかたりべ むかしむかしあるところにおじいさんとおばあさんがいました。 めでたしめでたし。
図番を消す例。次のようにすれば「図1:」のような番号を消すことも出来ます。
何かの写真 [[file:example-1.jpg]] これは別の写真です。 別の写真 [[file:example-2.jpg]]これは何かの写真です。
いや、本当はこの図番を消すために作ったのですが、いざ作り終えてみると正直この方法で消すのはイマイチかなぁ、と。もしorg-modeの更新で "Figure %d:"
の部分が変わってしまったら効果が無くなってしまいますからね。例えば ":"
の部分は別途付け加えるようになるとか。
なので後でもうちょっと違うやり方を考えてみようと思いますが、せっかく作ったのでここに残しておきます。
;;; org-custom-translation.el --- Customize export translations -*- lexical-binding: t; -*- ;; Copyright (C) 2024 AKIYAMA Kouhei ;; Author: AKIYAMA Kouhei <misohena@gmail.com> ;; Keywords: ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see <https://www.gnu.org/licenses/>. ;;; Commentary: ;; Change the translation dictionary used during export from the ;; in-buffer options. ;; Add the export option "#+CUSTOM_TRANSLATION:" to temporarily change ;; org-export-dictionary during export. ;; * Preparation ;; Put the following in your init.el. ;; (with-eval-after-load "ox" ;; (require 'org-custom-translation)) ;; * Option Syntax ;; #+CUSTOM_TRANSLATION: [<language>] <src> <dst> ;; * Examples ;; Translate to Japanese romanization. ;; #+CUSTOM_TRANSLATION: ja Author Sakusha ;; #+CUSTOM_TRANSLATION: ja Date Hizuke ;; Remove figure number. ;; #+CUSTOM_TRANSLATION: "Figure %d:" "" ;;; Code: (require 'cl-lib) (require 'ox) (defun org-custom-translation-split-option-value (str) (cl-loop with index = 0 while (progn (string-match " *\\(\\(\"\\([^\"]*\\)\"\\)\\|\\([^ \t\"]+\\)\\)\\|" str index) (match-beginning 1)) collect (or (match-string 3 str) (match-string 4 str)) do (setq index (match-end 0)))) (defun org-custom-translation-make-dictionary (lines current-language) (cl-loop for line in lines for values = (org-custom-translation-split-option-value line) when (>= (length values) 2) collect (let ((lang (if (>= (length values) 3) (pop values) current-language)) (src (car values)) (dst (cadr values))) (list src (list lang :default dst))))) (defun org-custom-translation-override-dictionary (options) (setq-local org-export-dictionary (append (org-custom-translation-make-dictionary ;;(cdar (org-collect-keywords '("CUSTOM_TRANSLATION"))) (when-let ((lines-str (plist-get options :custom-translation))) (split-string lines-str "\n")) (or (plist-get options :language) org-export-default-language)) org-export-dictionary))) (defun org-custom-translation-filter-options (options _backend &rest _rest) (org-custom-translation-override-dictionary options) options) (defun org-custom-translation-add-export-option () (setf (alist-get :custom-translation org-export-options-alist) '("CUSTOM_TRANSLATION" nil nil newline))) (defun org-custom-translation-setup () (org-custom-translation-add-export-option) (add-to-list 'org-export-filter-options-functions 'org-custom-translation-filter-options)) (org-custom-translation-setup) (provide 'org-custom-translation) ;;; org-custom-translation.el ends here
基本的な処理は全て org-export-filter-options-functions を経由して呼び出される org-custom-translation-filter-options で行っています。
org-export-filter-options-functions は本来オプションをフィルタするためのものですが、ここで CUSTOM_TRANSLATION オプションの値に応じて org-export-dictionary を変更してしまいます。ここが呼ばれる時のカレントバッファは元のorg-modeバッファをコピーした一時的なバッファのようなので、ローカル変数として設定してしまいます。後は自然に新しい訳が使われるようになります。