2023-09-08

el-easydrawで縦書きは表示できるか

style属性に writing-mode:vertical-rl; と指定すれば縦書きを表示できます。

edrawでテキストを縦書きにしてみた様子
図1: edrawでテキストを縦書きにしてみた様子

ただしEmacs 27.1世代の古いlibrsvgではウンともスンとも言わず全く効果はありませんでした。

私が今使っているWindows上の(MSYS2でビルドされた)Emacs 29.1においても、上スクリーンショットのように正しい位置に描画されません。他にも一部の文字の位置がずれる問題も発生しています。

text-anchorをmiddleにすることで縦方向の中心に揃えることが出来るはずですが、これも正しく機能しているとは言い難い状況です(先頭文字のmiddleになっている?)。

また、edraw自身も境界矩形を正しく認識していません。これを修正するにはstyleの値を解析する必要がありそうです。(2023-09-10追記: writing-modeプロパティを追加して境界矩形はそれを考慮するようにしました。styleの方は依然考慮していません)

実際にこのブログに描いてエクスポートすると次のようになります(埋め込みのSVGなので文字の範囲選択や検索が可能です)。

バスタ新宿中央道日野新島々駅安曇野支所前さわんどBT中の湯大正池帝国ホテル前上高地BT
図2: 実際にエクスポートされたSVG
2023-09-08

el-easydrawでCSS filterは使えるのか

使えるみたいです。

各種図形のstyleプロパティにfilterを指定した様子
図1: 各種図形のstyleプロパティにfilterを指定した様子

style属性に filter:drop-shadow(3px 3px 5px black); などと指定してやると、ドロップダウンシャドウが表示されます。

(その他の書き方: filter - CSS: カスケーディングスタイルシート | MDN)

実際にエクスポートしたのが次。表示されるかはブラウザ次第です。

TEXTAAABBBCCCHEARTSPADEDIA
図2: edrawで描いてエクスポートしたSVG

ちゃんとやるならSVG本来のフィルタに対応すべきなのでしょうが、UIを作るのが面倒くさいんですよねぇ。

ちなみに、手元のEmacs 27.1バイナリで試したところEmacs内の表示には効果がありませんでした。おそらくここ数年でlibrsvgが対応したのだと思います。

filterを使うと作図エディタとしての動作がかなり重くなります。EmacsでSVGを使って色々やる場合に一番ネックになる点は、実はlibrsvgがリアルタイム向きではないという事です。Emacs Lispを使ってSVGを組み立ててそれをイベントのたびに更新するという仕組みはそれ自体かなり無駄が多いのですが、そこは今日のPCではそこまで大きな問題ではありません。一番のネックはなんといっても画像の1ピクセル毎にかかる処理時間なのです。グリッド線にブレンドモードを指定しただけでかなり重くなったりもします。この辺りは元々用途が違うものを無理矢理使っているのですから仕方が無いところかもしれません。

2023-08-26

Emacsの中で動く作図ツール 最近の変更点(2023年1月~8月)

Emacsの中で動く作図ツールですが、前回(2022年末)からの変更点をまとめました。

misohena/el-easydraw: Embedded drawing tool for Emacs

ちなみにインストールはpackage-vcやらstraightやらで出来るらしいです。私は普通にGitのサブモジュールとしてcloneしてload-pathを通しているだけです。

接着機能

図形(の中の点)を他の図形にくっつける(移動に追従させる)機能を追加しました。図形の間を線で結んだり(パスツールでCtrlを押しながら図形をクリック)、テキストを矩形などの中心に配置し(テキストツールでCtrlを押しながら図形をクリック)、移動してもその状態を維持し続けられます。

接着機能の使用例(注:矢印設定は現在は複数選択で一括で出来ます)
図1: 接着機能の使用例(注:矢印設定は現在は複数選択で一括で出来ます)

仕様はかなり悩みました。今のところ条件は限定されており、また、残っている問題も多いです。内部的には点接続という図形内のアンカーポイントなどの点を別の図形内の点に関連付ける仕組みで実現しています。接続関係を表すデータは、SVG内にdata属性として記録しています。コンテキストメニュー内にも接着関連のコマンドをいくつか追加しました。

変形機能の改善
  • transform属性を図形の座標に適用
  • グループの変形に対応
  • 変形方式設定を追加
  • グループ化解除時にtransform属性を子に適用
  • GUIで変形(C-t)

最低限の変形機能は出来たと思いますがまだまだ沢山の問題が残っています。

変形と一口に言っても何をどのように変えるのかはある程度選択の幅があります。図形の種類(SVG要素の種類)によっては出来ないこともあるので、そういうときにどうするかが難しいです。

変形コマンドの使用例
図2: 変形コマンドの使用例
別フレーム対応

プロパティエディタとシェイプピッカーは別フレームで表示できるようになりました。編集中にフレームにしたりウィンドウに戻したり柔軟に変更できます。メニューボタンやコンテキストメニューから操作できます。だだ、使ってみるとそれほど便利では無いなと思いました。それほど大きな図を描くわけではありませんし、フレームが開くのに少し時間がかかるというのもあるかもしれません。そもそも私はEmacsでフレームを使うことに慣れていません。

フレーム表示例
図3: フレーム表示例
カスタムシェイプにラベルを追加

ほとんど例としてでてすが、二種類ほど追加しておきました。すでにカスタムシェイプリストを編集してしまっているとリセットしないと反映されないと思います。

シェイプピッカーも色々直したいところがあります。とりあえず折りたたみ状態は保持したいところ。

手書きツールの改善
生成する点の数を大幅に削減しました。
複製機能

選択図形を複製するコマンドを追加しました。単にコピーしてペーストしてもいいのですが、Dキー一つでできます。

また、選択ツールにおいて、M-ドラッグで複製しつつ平行移動します。

M-矢印キーまたはM-S-矢印キーで複製しつつ平行移動します。S付きは10px単位で移動します。C-u 数値のプレフィックスを付けると数値のピクセル数だけ移動します。

S-ドラッグによる移動方向制限
シフトキーを押しながらドラッグする操作に対応しました。ツールによって意味は異なりますが、45度単位で移動することが多いです。
切り抜き機能
マウスで範囲を指定してドキュメント全体を切り抜く機能を追加しました。全体を平行移動してドキュメントのサイズを変更します。範囲外を自動的に削除したりは しません 。図を描いたら端が余ってしまったということが良くあるのでそういうときに使います。実は小さくするときだけでなく大きくしたいときにも使えます。
日本語化

current-language-environmentが "Japanese" の時は日本語で表示されます。 edraw-msg-file 変数で無効化できます。環境によっては文字化け等正しく表示されない場合もあります。その場合は環境のフォント設定等を見直すか、諦めて無効化してください。

日本語でメニューが表示されている様子
図4: 日本語でメニューが表示されている様子

元々全てのメッセージをedraw-msg関数で囲っていたのはこのためでした。Emacsはメッセージが多言語化されているところがほぼありません。見つかったのはチュートリアルくらいです。それがcurrent-language-environmentによって切り替わっているので、それにならいました。そもそもEmacsのdocstringには多言語に対応する仕組みが無いのは大いに不満ですね!(最低限言語を指定するメタデータがあって、ボタン一発で翻訳するとかどうだろう)

マーカーの改善

マーカー(パスに付ける矢印や丸印)の形状をある程度カスタマイズできるようにしました。プロパティエディタから変更したり、メインメニューや変数edraw-default-marker-propertiesでデフォルトを変更できます。

将来的には形状自体を増やせるようにしたいです。そのための下ごしらえも少ししました。

パスツールのM-クリック・ドラッグ操作
Alt+クリック・ドラッグでハンドルを確実にコントロールできます。ハンドルをM-ドラッグするとそのハンドルのみを移動します(反対側のハンドルは動きません)。アンカーをM-ドラッグするとそのアンカーの二つのハンドルを再作成します(スムーズ点になります)。アンカーをM-クリックするとそのアンカーの二つのハンドルを削除します(コーナー点になります)。いわゆる切り替えツールとほぼ同じです。
点の座標指定移動
パスのアンカーやハンドルのコンテキストメニューに「座標による移動コマンド」を追加しました。精密な操作が必要な場合に有用です。
座標の表示
色々な場面で座標やサイズをメッセージ出力するようにしました。
図形に対するコンテキストメニューの「設定」によく使うプロパティの変更を追加
image要素に対するhrefとtext要素に対するfont-sizeを追加しました。
テキストツールの既存テキストクリック操作

これまでクリックは単に新しくテキストを追加するだけでしたが、既存のテキストをクリックした場合はそのテキストの文字列を(ミニバッファで)入力するようにしました。これまでプロパティエディタを開いて変更していたのですが、やっぱりその方が便利かなと思いまして。既存テキストの近くに新しいテキストを追加したい場合はC-uプレフィックスを入力してからクリックしてください。強制的に追加になります。

テキストまわりはもっと沢山の設定項目が必要です。

ツールヘルプの表示
ツールの操作は気が付きにくいものが多いので、ツール切り替え時に簡単なガイドを表示するようにしました。
選択ツールのC-ドラッグ操作
Ctrl+ドラッグで範囲と重なる図形を選択、選択解除します。
複数選択図形一括操作
複数の選択図形をプロパティエディタで変更できるようにしました。また、コンテキストメニューの「設定」でも一度にプロパティ値を変更できるようにしました。
ビューサイズの改善
表示領域自動拡大の改善
ズーム時の表示領域自動拡大の上限をウィンドウサイズに対する比率で指定出来ようにしました(edraw-editor-auto-view-enlargement-max-size変数参照)。デフォルトは幅約94%、高さ約63%に設定されています。あまり大きくすると環境によっては不安定になりそうで心配しています。
edraw-set-view-size-specの単純化
ビューのサイズ指定は、単純に高さと幅を数値で指定するだけになりました。これにより誤入力を減らせます。指定の解除はビューのリセットコマンドを使用してください。

困ったら 0 や v 0 を押してください。元に戻ります。

本当はドラッグでビューサイズを変えられると良いんですけどね。右下につまみを表示しなければなりません。

キー割り当ての追加
  • 選択図形に対する操作
    • g : グループ化
    • G : グループ化解除
    • D : 複製
    • p f : fillを変更
    • p s : strokeを変更
    • p p または M-RET: プロパティエディタを開く
    • M-矢印キー または M-S-矢印キー または C-u N M-矢印キー : 複製後移動
    • TAB : 次の図形(M-]と同じ)
    • S-TAB : 前の図形(M-[と同じ)
    • C-t : インタラクティブ変形
  • i : 画像ツール
  • dvb : svg要素のviewBox属性を変更
カスタマイズ変数の追加
edraw-editor-image-scaling-factor
大きさの微調整をします。ノートPCで少し小さいと感じたので追加しました。Emacs自体が(create-image関数が)自動スケーリング機能を持っているのですがギリギリそれが働かない解像度だったようで、それとは別に用意しました。
edraw-editor-default-tool
起動したときに選択するツールです。これまでは矩形ツールだったのですが、選択ツールの方がいいかなと思ったので変更できるようにしました。
edraw-org-link-image-max-size variable
org-mode上でインライン画像表示するときの最大サイズを設定できるようにしました。ウィンドウからはみ出すのを防止できます。
SVGファイル・データ内のコメントを可能な限り維持
SVGファイルやデータ内にあるコメントを可能な限り維持するようにしました。 <!-- -*- mode: edraw -*- --> というヘッダーコメントや任意のフッターコメントを入れることを想定しています。svg要素内にあるコメントは図形の前後関係など、動作に支障を来す可能性があります。
カラーピッカーの改善
  • 最近使った色の保存
  • 最近使った色をキーで選択(C-1, C-2, ..., C-0)
  • ドラッグ時にピッカーの外に出たときの挙動を改善(境界線上の色を指定しやすくする)
  • 色テキストを追加・置換するコマンド(カラーピッカー単独での利用)の改善
    • 子フレーム対応
    • set-transient-mapによる安定したキー操作
    • edraw-color-picker-replace-or-insert-color-at-pointコマンドを追加
    • css-modeやmhtml-modeでの設定例を追加

カラーピッカーのedraw-editor以外からの利用(応用)まではなかなか手が回らずいくつか放置されていた問題がありましたが、修正したので大分使いやすくなったと思います。私は先日書いたような設定でcss等の色が書いてある部分をいつでもカラーピッカーで変更できるようになりました。他の色形式に対応したり、元の書き方を出来るだけ変えないようにする等まだ改善点は残っています。それと子フレームは環境によっては正しく動かない可能性があるので心配しています。一応子フレームを使わないようにするカスタマイズ変数もあります。

カラーピッカー自体には、後は固定のパレットの追加と他の表色系への対応を考えています。

web-modeでカラーピッカーを使っている様子
図5: web-modeでカラーピッカーを使っている様子
edrawコマンド
edraw-mode.elにedrawコマンドを追加しました。M-x edrawで新しいバッファを作成してedraw-modeを立ち上げるだけのシンプルなものです。素早く新しい図を作成したい場合に有用です。名前を付けて保存するにはC-x C-sやC-x C-wでOKですが、その時の拡張子やauto-mode-alistの設定等によってはedraw-modeが解除されてしまうのでその際は再度M-x edraw-modeしてください。
edraw-modeの改善

単体の.edraw.svgファイル(edraw-modeバッファ)を編集する際に支障がある問題を改善しました。

  • バッファが空の場合に表示されないバグを修正
  • ファイル保存時にUTF-8を強制
  • テキストを編集できてしまうバグを修正
  • (テキスト)カーソルの非表示
  • 他のモード(xml-mode等)との確実な切り替えを確認
  • メインメニューの「保存」を単にsave-bufferコマンド(C-x C-s)へ変更
  • メインメニューにxml-modeへの切り替えを追加(単にxml-modeを呼び出すだけ)

org-modeからの利用ばかり考えていて、長らくこちらはおろそかになっていました。ちょっと試す分にはedraw-modeの方が速い気がしたので手を付けました。

README
  • org-modeの非同期エクスポートを有効にしている場合の設定方法を追加
  • org-modeの通常のファイルリンクで使う方法を追加
  • SVGファイル内のコメントやmagic-mode-alistでedraw-modeを使う方法を追加(.edraw.svgという長い拡張子を使わない方法)
バグ修正
一部の環境でエディタが表示されない
librsvgのバージョンによってセーブして再び開くとエディタが表示されない不具合がありました。svg要素のxmlnsが消えてしまうのが原因でした。新しめのlibrsvgはxmlnsを厳格に解釈するようなので、その影響かもしれません。それに伴いimage要素ではhrefではなくxlink:href属性を使用するようにしました。
画像ツールのファイル名入力を改善

Macで画像ファイル名の拡張子が入力されない不具合を修正しました。

また、画像は参照元のファイルがあるディレクトリかそのサブディレクトリにあるものしか表示できません。これはlibrsvgがセキュリティ上課している制限です。

マウスカーソルのちらつきを改善
マウスカーソルが画面が更新されるたびに一瞬だけ別の形状に変わるのを出来るだけ抑制しました。

その他沢山の細かい修正があります。

2023-08-25

Emacsのcss-modeやcustomize-face等でカラーピッカーを使う設定

以前(と言ってももうずいぶん前になりますが)Emacsで動くSVG実装のカラーピッカーを作りましたが、少し整えてからcss-modeやカスタマイズ機構で使うための設定を用意しました。作図エディタ本体の改良で忙しくカラーピッカー単体での利用の方はおろそかになっていました。ようやく手を付けられたので色々いじっているところです。

2023-08-25-color-picker.png

まず次のelispを導入します。

misohena/el-easydraw: Embedded drawing tool for Emacs

私は自分のemacs設定ディレクトリのサブモジュールになっていますが、package-vcやらstraightやらでも入れられるそうです。その辺り詳しいことは知りません。別にload-pathの通ったところに全部置いておけば済む話です。

それで、css-modeやmhtml-modeやらで使うには例えば次のようにします。

;; この辺りはパッケージ管理システムを使っていると自動的に作られているかも。
;; 一応;;;###autoload指定は入れてあるので。
(autoload 'edraw-color-picker-replace-color-at "edraw-color-picker" nil t)
(autoload 'edraw-color-picker-replace-or-insert-color-at-point "edraw-color-picker" nil t)

;; お好みでキー割り当て。
(defun my-edraw-color-picker-add-keys (map)
  ;; マウスのクリックでそこにある色名を置換。
  (define-key map [mouse-1] #'edraw-color-picker-replace-color-at)
  ;; C-c C-oでそこに色名があれば置換、無ければ挿入。
  (define-key map (kbd "C-c C-o")
              #'edraw-color-picker-replace-or-insert-color-at-point))

;; local-mapにキーを設定する関数。
(defun my-edraw-color-picker-enable ()
  (my-edraw-color-picker-add-keys (or (current-local-map)
                                      (let ((map (make-sparse-keymap)))
                                        (use-local-map map)
                                        map))))

;; お好みのモードにキー設定を追加。
(add-hook 'css-mode-hook 'my-edraw-color-picker-enable)
(add-hook 'mhtml-mode-hook 'my-edraw-color-picker-enable)
(add-hook 'web-mode-hook 'my-edraw-color-picker-enable)

EmacsのCustomize用のバッファ(customize-faceとか)で使うには次のようにします。

;; Customize用のバッファではEmacsの色名を使う。
(defun my-edraw-color-picker-enable-for-custom-mode ()
  (setq-local edraw-color-picker-insert-default-color-scheme 'emacs))
(add-hook 'Custom-mode-hook 'my-edraw-color-picker-enable-for-custom-mode)

;; フィールドのキーマップにキー設定を追加。
;; local-mapに設定するとフィールド上では効かないので。
(with-eval-after-load "cus-edit"
  (my-edraw-color-picker-add-keys custom-field-keymap))

カラーピッカーは現在デフォルトでは子フレームで表示されるようになっていますが、環境によっては正しく表示されないかもしれません。その場合は (setq edraw-color-picker-use-frame-p nil) にするとオーバーレイを使った表示になります。それはそれで無理矢理行と行の間に差し込むのでレイアウトが崩れることもあるかもしれませんが。

カラーピッカーの大きさは edraw-color-picker-near-point-scale で変えられます。デフォルトは0.75です。

無理にポイントの近くに表示しなくても (edraw-color-picker-read-color) でミニバッファから入力した方が使いやすいような気もしますがどうでしょうね……。

今後の改良点としては:

  • 他の表色系に対応
  • 置換前の表記に出来るだけ合わせる
  • 現在値の表示

辺りでしょうか。

その前に作図エディタ側からの必要性で固定パレットが入ると思います。

基本作図エディタ本体が優先なのでいつになるかは分かりませんが、そのうち。

2023-08-18

before-stringに別のオーバーレイのfaceが適用されない

before-stringプロパティを持つオーバーレイ(ov1)があったとします。そのオーバーレイ(ov1)を囲むように別のオーバーレイ(ov2)もあったとします。その別のオーバーレイ(ov2)がfaceプロパティを持っていた場合、そのfaceはov1のbefore-stringに影響するでしょうか。

[OV1BEFORE][OV1TEXT] OV2TEXT][OV2TEXT ov1の範囲ov2の範囲ov1のbefore-stringによって生成された部分青くハイライトするfaceが設定されている
図1: 二つのオーバーレイが重なる様子

色々試してみたのですが、なかなか影響させる方法が見つかりませんでした。

ov1のbefore-stringはov1が囲んでいるテキストの最初の文字のfaceテキストプロパティのみに影響を受けるようです(もちろんbefore-string自体にテキストプロパティが付いている場合は別です)。

これはtransient-mark-modeやhl-line-modeを使ってbefore-stringを持つオーバーレイを囲ってみればよく分かります。before-stringの部分だけハイライトされません。

Emacs Lispで再現するコードは次のようになります。

(let ((beg (point))
      (_ (insert "[OV2TEXT [OV1TEXT] OV2TEXT]"))
      (end (point)))
  (let ((ov1 (make-overlay (+ beg 9) (- end 9))))
    (overlay-put ov1 'evaporate t)
    (overlay-put ov1 'before-string "[OV1BEFORE]"))
  (let ((ov2 (make-overlay beg end)))
    (overlay-put ov2 'evaporate t)
    (overlay-put ov2 'face '(:background "#4080c0"))))

結果は次のようになります。

before-stringに他のオーバーレイのfaceプロパティが適用されない様子
図2: before-stringに他のオーバーレイのfaceプロパティが適用されない様子

色々変えて試してみました。

  • priorityプロパティを色々指定してみても変わりません。
  • ov2の範囲を色々変えても変わりません。
  • after-stringもbefore-stringと同様に影響を受けません。
  • [OV1TEXT]の先頭文字([)にfaceテキストプロパティ(実際にはfont-lock-face)を付けると、before-stringにはそのfaceが適用されます。つまり (put-text-property (+ beg 9) (+ beg 10) 'font-lock-face '(:background "red")) のように。これは回避策には利用できそうですがov2のfaceが適用されているわけではありません。ちなみにafter-stringは[OV1TEXT]の最後の文字……ではなく、その次の文字に設定したfaceが適用されます。
  • displayプロパティで表示した文字列([OV1TEXT]を置き換える)は影響を受けます。

displayプロパティは影響を受ける。その事実を知ったとき、私にはあるアイデアが浮かびました。before-stringにdisplayテキストプロパティを付けたらどうなるんだろう。つまり次のようにするわけです。

(let ((beg (point))
      (_ (insert "[OV2TEXT [OV1TEXT] OV2TEXT]"))
      (end (point)))
  (let ((ov1 (make-overlay (+ beg 9) (- end 9))))
    (overlay-put ov1 'evaporate t)
    (overlay-put ov1 'before-string
                 ;; ↓★displayテキストプロパティを設定する。
                 (propertize "_" 'display "[OV1BEFORE-DISPLAY]")))
  (let ((ov2 (make-overlay beg end)))
    (overlay-put ov2 'evaporate t)
    (overlay-put ov2 'face '(:background "#4080c0"))))

結果は何と……

before-stringに他のオーバーレイのfaceプロパティが適用されている様子
図3: before-stringに他のオーバーレイのfaceプロパティが適用されている様子

ちゃんと適用されました!

これらは一体どう解釈すれば良いのでしょうか。

まずbefore-stringに他のオーバーレイのfaceが適用されないのはバグでしょうか、意図した仕様でしょうか、それとも単に未定義動作(どうなっても文句は言えない)なだけでしょうか。前述したとおりtransient-mark-modeで範囲選択すればすぐに分かるので誰も気が付かないと言うことは無いと思うんですよね。さりとてこの挙動に何かメリットがあるのかと問われればあまり思いつきません。

一方before-stringのdisplayには効くというのはどうなのでしょうか。この挙動に頼って良いものなのでしょうか。

GNUのサイトに行ってバグトラッカーとメールのアーカイブを何度か行ったり来たりした後、嫌になって探すのを諦めました。

個人的にはどちらにも適用されるのが自然な挙動のように感じます。

今回の問題が気になったきっかけは、dired-details-rでhl-line-modeが正しく機能しなかったことです。dired-details-rでは、行末の"\n"部分にオーバーレイをかけてbefore-stringでファイルの詳細情報を表示しています1。なので、ファイルの詳細情報の部分は一切ハイライトされません。これでは現在の行をハイライトする意味がありません。

dired-details-rでhl-line-modeが正しく機能しない様子
図4: dired-details-rでhl-line-modeが正しく機能しない様子

すでに色々回避策を適用してしまったのですが、もしbefore-stringのdisplayに頼って良いのならもっとシンプルで安定したコードに出来そうです。

いやはや、Emacsのテキスト&オーバーレイプロパティまわりは何とも複雑ですね。

(追記:dired-details-rでhl-line-modeが正しく機能しない件は解決しました! ちなみにこのテクニックはall-the-icons-dired(私は色々独自に手を入れて使っています)でも有効です。あれはafter-stringでアイコンを挿入するので、そのままではhl-line-modeでアイコン部分がハイライトされません)

hl-line-modeが完全に機能するようになった様子
図5: hl-line-modeが完全に機能するようになった様子

脚注:

1

その辺りの経緯については以前に書いたと思います。たぶんEmacsでdisplayプロパティを使って改行を置き換えると非常に遅くなる件のあたり

2023-08-17

image-diredにサムネイル画像関連フックを追加する

もっと……もっとフックを……、フックが欲しいのじゃ!

私はdired-details-rを使ってファイルの詳細情報をファイル名の右側に表示していますが、image-diredでファイル名の前にサムネイルを追加すると表示がずれることがあります。

少し前の改良でファイル名の前にあるアイコンやらサムネイルやらの幅を考慮することは出来たのですが、それはdiredがディレクトリを読み込んだ直後の話。その後でサムネイルの状態に何か変化があると、やはりずれてしまいます。

典型的なのは、サムネイルの表示・非表示を切り替える操作をした場合。 image-dired-dired-toggle-marked-thumbs コマンド(C-t C-tで選択した画像のサムネイルをトグル)や私の改造だと my-image-dired-dired-show-all-thumbs コマンド(C-t C-aで全画像のサムネイルを表示)あたりの操作です。表示すれば詳細が右にずれますし、整えてから消せば左にずれます。

もう一つはサムネイル画像を生成し終わったとき。新しくサムネイルを生成するときは、ひとまず無効な画像(30ピクセル四方の正方形です)を表示させておいて、生成が終わったら更新するようにimage-diredは出来ています。問題はこれが非同期であるという点です(もちろん長時間待たされないという意味では良い点です)。サムネイルを生成するための外部プロセス(ImageMagickやらGraphicsMagickやら)が終了したらclear-image-cacheを実行することで画像を表示するオーバーレイが更新されます。その時に詳細情報が右にずれます。非同期なのでそのタイミングは予測できません。

というわけで次図のような画面が出来上がるわけです。

詳細情報が右にずれたdiredバッファ
図1: 詳細情報が右にずれたdiredバッファ

もちろんgを押せばすぐに揃うのですが、面倒くさいせいかなぜか押さないことも多いです。するとずれた部分が時々目の中にチラチラ入ってきて何だか気分が悪いです。

それに何だかみっともないじゃないですか。こうやってずれたスクリーンショットを気が付かずに出してしまうこともあります(実際に最近の記事の中にあります!)。こんなに好き勝手に改造して自己満足に浸っているくせにズレてるのかよ! みたいな。

というわけで直すことにしたのでした。

これを直すには、diredバッファへサムネイルのオーバーレイを挿入した後とサムネイル画像の生成が終わった後に、見た目を更新する処理を挟む必要があります。しかしそのようなタイミングで呼び出してくれる便利なフックはもちろん存在しません。フック……もっとフックを! と思うことはEmacsではいつものことです。というわけで、そのフック(それらのタイミングで任意の関数を呼び出す仕組み)をimage-diredに追加してみます。

まずはサムネイルを表示・消去した直後に呼び出すフック。diredバッファにサムネイルを表示するオーバーレイを挿入するのはimage-dired-dired.el内のimage-dired-dired-toggle-marked-thumbsコマンドです(Emacs 29.1時点)。その中身は次の通り。

;; Emacs 29.1付属のimage-dired-dired.elより
;;;###autoload
(defun image-dired-dired-toggle-marked-thumbs (&optional arg)
  "Toggle thumbnails in front of marked file names in the Dired buffer.
If no file is marked, toggle display of thumbnail on the current file's line.
ARG, if non-nil (interactively, the prefix argument), specifies the files
whose thumbnail display to toggle instead of the marked files: if ARG is an
integer, use the next ARG (or previous -ARG, if ARG<0) files; any other
value of ARG means toggle thumbnail display of the current line's file."
  (interactive "P" dired-mode)
  (setq image-dired--generate-thumbs-start  (current-time))
  (dired-map-over-marks
   (let ((image-pos  (dired-move-to-filename))
         (image-file (dired-get-filename nil t))
         thumb-file
         overlay)
     (when (and image-file
                (string-match-p (image-dired--file-name-regexp) image-file))
       (setq thumb-file (create-image
                         (image-dired--get-create-thumbnail-file image-file)))
       ;; If image is not already added, then add it.
       (let ((thumb-ov (cl-loop for ov in (overlays-in (point) (1+ (point)))
                                if (overlay-get ov 'thumb-file) return ov)))
         (if thumb-ov
             (delete-overlay thumb-ov)
           (put-image thumb-file image-pos)
           (setq overlay
                 (cl-loop for ov in (overlays-in (point) (1+ (point)))
                          if (overlay-get ov 'put-image) return ov))
           (overlay-put overlay 'image-file image-file)
           (overlay-put overlay 'thumb-file thumb-file))
         ;; ★ここに追加したい
         )))
   ;; Show or hide thumbnail on ARG next files.
   arg)
  (add-hook 'dired-after-readin-hook
            'image-dired-dired-after-readin-hook nil t))

この関数の (if thumb-ov (overlay-put overlay 'thumb-file thumb-file)) の後くらいに処理を挟みたいわけです。この関数はトグル動作なので、表示するときと消去するときの両方で通るところに挟むのが良いでしょう。しかしそこに処理を挟むのは困難です。

それ以前に、私はこの前の改造でこの関数をバラしてサムネイルの前後の余白を調整したところでした。

なので、その改造後の関数に手を入れてフック機能を追加してしまいましょう。以下★印の所三箇所を追加しました。

(defun my-image-dired-dired-toggle-marked-thumbs (&optional arg)
  ;; Derived from `image-dired-dired-toggle-marked-thumbs'
  "Toggle thumbnails in front of file names in the Dired buffer.
If no marked file could be found, insert or hide thumbnails on the
current line.  ARG, if non-nil, specifies the files to use instead
of the marked files.  If ARG is an integer, use the next ARG (or
previous -ARG, if ARG<0) files."
  (interactive "P")
  (dired-map-over-marks
   (my-image-dired-dired-set-thumb-visibility 'toggle) ;;ファイル毎の処理を分離
   arg             ; Show or hide image on ARG next files.
   'show-progress) ; Update dired display after each image is updated.
  (add-hook 'dired-after-readin-hook
            'image-dired-dired-after-readin-hook nil t))

;; ★2023-08-17追加
(defvar my-image-dired-dired-change-thumb-hook nil
  "diredバッファ内のサムネイルの表示状態が変化したら呼び出されるフッ
クです。

呼ばれるときの引数は(THUMBNAIL-OVERLAY ORIGINAL-IMAGE-FILENAME
IMAGE-POSITION)です。現在のバッファは変化したdiredバッファです。")

(defun my-image-dired-dired-set-thumb-visibility (visibility)
  ;; Derived from `image-dired-dired-toggle-marked-thumbs'
  (let ((image-pos  (dired-move-to-filename))
        (image-file (dired-get-filename nil t)))
    (when (and image-file
               (string-match-p (image-file-name-regexp) image-file))
      (let* ((thumb-file
              ;; Emacs 28まで
              ;;(image-dired-get-thumbnail-image image-file)
              ;; Emacs 29から
              (create-image
               (image-dired--get-create-thumbnail-file image-file)))
             (thumb-ov (cl-loop for ov in (overlays-in (point) (1+ (point)))
                                if (overlay-get ov 'thumb-file) return ov)))
        ;; 他から使うためにトグル以外もできるようにした
        (if thumb-ov
            (when (memq visibility '(nil toggle))
              (delete-overlay thumb-ov)
              ;; ★2023-08-17追加 : 表示→非表示
              (run-hook-with-args 'my-image-dired-dired-change-thumb-hook
                                  nil image-file image-pos))
          (when (memq visibility '(t toggle))
            ;; ★2023-08-17修正 : 非表示→表示
            (let ((new-thumb-ov
                   ;; 独自の関数を呼ぶ
                   (my-image-dired-dired-create-thumbnail-overlay
                    image-pos image-file thumb-file)))
              (run-hook-with-args 'my-image-dired-dired-change-thumb-hook
                                  new-thumb-ov image-file image-pos))))))))

こうしておくことで、一つのサムネイルが表示・非表示されるたびに任意の処理を挟むことが出来ます。そこでレイアウトを整えてやろうというわけです。

次にサムネイル画像の生成が終わったとき。画像の生成はimage-dired-external.el内のimage-dired-create-thumb-1関数で行っています。内容を見てみましょう。

;; Emacs 29.1付属のimage-dired-external.elより

(defun image-dired-create-thumb-1 (original-file thumbnail-file)
  "For ORIGINAL-FILE, create thumbnail image named THUMBNAIL-FILE."
  (image-dired--check-executable-exists
   'image-dired-cmd-create-thumbnail-program)
  (let* ((size (number-to-string (image-dired--thumb-size)))
         (modif-time (format-time-string
                      "%s" (file-attribute-modification-time
                            (file-attributes original-file))))
         (thumbnail-nq8-file (replace-regexp-in-string ".png\\'" "-nq8.png"
                                                       thumbnail-file))
         (spec `((?s . ,size) (?w . ,size) (?h . ,size)
                 (?m . ,modif-time)
                 (?f . ,original-file)
                 (?q . ,thumbnail-nq8-file)
                 (?t . ,thumbnail-file)))
         (thumbnail-dir (file-name-directory thumbnail-file))
         process)
    (when (not (file-exists-p thumbnail-dir))
      (with-file-modes #o700
        (make-directory thumbnail-dir t))
      (message "Thumbnail directory created: %s" thumbnail-dir))

    ;; Thumbnail file creation processes begin here and are marshaled
    ;; in a queue by `image-dired-create-thumb'.
    (let ((cmd image-dired-cmd-create-thumbnail-program)
          (args (mapcar
                 (lambda (arg) (format-spec arg spec))
                 (if (memq image-dired-thumbnail-storage
                           image-dired--thumbnail-standard-sizes)
                     image-dired-cmd-create-standard-thumbnail-options
                   image-dired-cmd-create-thumbnail-options))))
      (image-dired-debug "Running %s %s" cmd (string-join args " "))
      (setq process
            (apply #'start-process "image-dired-create-thumbnail" nil
                   cmd args)))

    (setf (process-sentinel process)
          (lambda (process status)
            ;; Trigger next in queue once a thumbnail has been created
            (cl-decf image-dired-queue-active-jobs)
            (image-dired-thumb-queue-run)
            (when (= image-dired-queue-active-jobs 0)
              (image-dired-debug
               (format-time-string
                "Generated thumbnails in %s.%3N seconds"
                (time-subtract nil
                               image-dired--generate-thumbs-start))))
            (if (not (and (eq (process-status process) 'exit)
                          (zerop (process-exit-status process))))
                (message "Thumb could not be created for %s: %s"
                         (abbreviate-file-name original-file)
                         (string-replace "\n" "" status))
              (set-file-modes thumbnail-file #o600)
              (clear-image-cache thumbnail-file)
              ;; PNG thumbnail has been created since we are
              ;; following the XDG thumbnail spec, so try to optimize
              (when (memq image-dired-thumbnail-storage
                          image-dired--thumbnail-standard-sizes)
                (cond
                 ((and image-dired-cmd-pngnq-program
                       (executable-find image-dired-cmd-pngnq-program))
                  (image-dired-pngnq-thumb spec))
                 ((and image-dired-cmd-pngcrush-program
                       (executable-find image-dired-cmd-pngcrush-program))
                  (image-dired-pngcrush-thumb spec))
                 ((and image-dired-cmd-optipng-program
                       (executable-find image-dired-cmd-optipng-program))
                  (image-dired-optipng-thumb spec)))))))
    process))

この関数は引数にoriginal-fileとthumbnail-fileを取ります。分かりやすいですね。

start-processで外部プロセスを起動しています。

その直後にプロセスオブジェクトのsentinelとしてlambda関数を設定することでプロセスの終了を検出しています。sentinelはプロセスの状態が変わったときに呼び出されます。

問題なのはこれがlambda、つまり匿名の関数だということ。この関数にadviceを追加して処理を追加するようなマネはできません。

しかしその後を見ると、このimage-dired-create-thumb-1はプロセスオブジェクトを返却しています。やった! ラッキー!! プロセスオブジェクトが得られるなら、そのsentinelを書き替えることが出来ます。次のように。

(defvar my-image-dired-create-thumb-hook nil)

(defun my-image-dired-create-thumb-1-around-for-call-hook
    (orig-fun original-file thumbnail-file &rest args)
  "`image-dired-create-thumb-1'に対するaround adviceです。"
  (let* (;; 元のimage-dired-create-thumb-1を呼び出す。
         (process (apply orig-fun original-file thumbnail-file args))
         ;; 返ってきたprocessオブジェクトの元のsentinelをとっておく。
         (orig-sentinel (process-sentinel process)))
    ;; sentinelを横取りする。
    (set-process-sentinel
     process
     (lambda (process status)
       (prog1
           ;; 元のsentinelを呼ぶ。
           (funcall orig-sentinel process status)
         ;; 成功だったら、フックを呼び出す。
         (when (and (eq (process-status process) 'exit)
                    (zerop (process-exit-status process)))
           (run-hook-with-args 'my-image-dired-create-thumb-hook
                               original-file thumbnail-file)))))
    process))

(advice-add 'image-dired-create-thumb-1 :around
            #'my-image-dired-create-thumb-1-around-for-call-hook)

つまり、image-dired-create-thumb-1が設定した元のsentinelをとっておいて、独自の関数にsentinelを書き替えてしまい、その独自の関数は元のsentinelを呼び出してから追加の処理をするわけです。

こういう横取りの仕方を見ると私はよく割り込みベクタのフックを思い出します。MS-DOSで常駐プログラム書いたり、システムコールをフックして処理を挟んでみたり。やってることは昔と変わらないなー。

というわけでこれで処理を挟む準備は整いました。後は次のようにすればdired-details-rがレイアウトを整えてくれます。

;; サムネイル画像をdiredバッファに挿入した後、または削除した後の処理。
;; 呼び出されるときのcurrent-bufferはdiredバッファです。

(defun my-image-dired-dired-update-on-change-thumb (_ov _original-file pos)
  (save-excursion
    (goto-char pos)
    (when (fboundp 'dired-details-r-update-current-line)
      (dired-details-r-update-current-line))))

(add-hook 'my-image-dired-dired-change-thumb-hook
          #'my-image-dired-dired-update-on-change-thumb)

;; サムネイル画像を生成し終わったときの処理。
;; 呼び出されるときのcurrent-bufferがどこかは分からないので注意すること。

(defun my-image-dired-dired-update-on-create-thumb (original-path thumb-path)
  ;; 念のため確実にフルパスにする。
  (setq original-path (expand-file-name original-path))
  (setq thumb-path (expand-file-name thumb-path))

  (let ((dired-buffers
         ;; 元画像があるディレクトリを表示するdiredバッファを列挙する。
         ;; サブディレクトリを挿入していると複数あり得る。
         (let ((original-dir (file-name-directory original-path)))
           (cl-loop for buf in (buffer-list)
                    when (and
                          (eq (buffer-local-value 'major-mode buf) 'dired-mode)
                          (assoc original-dir
                                 (buffer-local-value 'dired-subdir-alist buf)))
                    collect buf))))

    ;; 各diredバッファについて:
    (dolist (buf dired-buffers)
      (with-current-buffer buf
        ;; @todo サムネイル左右の余白も更新したい。
        ;; dired-details-rでoriginal-pathの行だけ更新する。
        (when (fboundp 'dired-details-r-update-file)
          (dired-details-r-update-file original-path))))))

(add-hook 'my-image-dired-create-thumb-hook
          'my-image-dired-dired-update-on-create-thumb)

画像生成後の方は、いつ呼び出されるのか予測が出来ないので少し込み入っています。

2023-08-17

image-diredで不要になったサムネイルを削除する

前回の続き。image-diredが生成するサムネイルを元の画像の場所が分かるような形式にしたのでした(image-dired-thumbnail-storage'image-dired-dir のときの話)。

image-diredのサムネイルを元の画像の場所が分かる形式にする(2023-08-17修正あり)

今回はこの仕組みを使って、不要になったサムネイルを削除するコマンドを作ります。

まずサムネイルのパスから元の画像の場所を求める関数を作成します。

;;;; サムネイルファイル名のデコード

(defun my-image-dired--decode-thumb-name (str)
  "STR内の%xx表記を元の文字に戻します。"
  (replace-regexp-in-string
   "%[0-9A-Fa-f][0-9A-Fa-f]"
   (lambda (entity) (char-to-string (string-to-number (substring entity 1) 16)))
   str t t))

(defun my-image-dired-thumb-original-file-name (thumb-path)
  "サムネイル画像THUMB-PATHの元画像ファイルのファイル名(ディレクトリ
部分を含まない)を返します。"
  (my-image-dired--decode-thumb-name (file-name-sans-extension ;;jpgを取り除く
                                      (file-name-nondirectory thumb-path))))

(defun my-image-dired-thumb-original-dir (thumb-path)
  "サムネイル画像THUMB-PATHの元画像ファイルがあるディレクトリを返します。"
  (setq thumb-path (expand-file-name thumb-path))
  (unless (string-prefix-p (expand-file-name (image-dired-dir))
                           thumb-path)
    (error "THUMB-PATH is not in the `image-dired-dir'"))
  (my-image-dired--decode-thumb-name
   (file-name-nondirectory
    (directory-file-name
     (file-name-directory thumb-path)))))

(defun my-image-dired-thumb-original-path (thumb-path)
  "サムネイル画像THUMB-PATHの元画像ファイルへのパスを返します。
元画像ファイルは存在しているとは限りません。"
  (file-name-concat (my-image-dired-thumb-original-dir thumb-path)
                    (my-image-dired-thumb-original-file-name thumb-path)))

そしてこれらを使って元の画像が既に無くなっているサムネイルを削除するコマンドを作りました。

;;;; サムネイルの掃除
(defvar my-image-dired-clean-verbose nil)

(defun my-image-dired-clean-thumbs-dir (thumbs-dir &optional forced-delete-p)
  "サムネイルが格納されているディレクトリTHUMBS-DIR内を掃除します。

THUMBS-DIRは`my-image-dired--thumb-dir'で作成されたような、元のディ
レクトリが%エンコードされているパスです。その下にあるファイルは
`my-image-dired--thumb-file-name'でエンコードされている必要があり
ます。

FORCED-DELETE-Pがnon-nilの時は、元画像がまだあるサムネイルも含め
て全てのサムネイルが削除されます。"
  (let ((original-dir (my-image-dired-thumb-original-dir
                       (file-name-as-directory thumbs-dir)))
        (valid-count 0)
        (deleted-count 0)
        (error-count 0))
    (dolist (thumb-path (directory-files thumbs-dir t "\\.jpg\\'"))
      (let* ((original-filename (my-image-dired-thumb-original-file-name
                                 thumb-path))
             (original-path (file-name-concat original-dir original-filename)))
        (if (and (not forced-delete-p)
                 (file-exists-p original-path))
            ;; 有効なサムネイルが存在した。
            (cl-incf valid-count)
          ;; 無効なサムネイルを消す。
          (when my-image-dired-clean-verbose
            (message "Delete thumb %s (Original:%s)" thumb-path original-path))
          (condition-case err
              (progn
                (delete-file thumb-path)
                (cl-incf deleted-count))
            (error
             (when my-image-dired-clean-verbose
               (message "Failed to delete thumb %s (Original:%s) err=%s" thumb-path original-path err))
             (cl-incf error-count))))))

    ;; 有効なサムネイルが存在しないならディレクトリの削除を試みる。
    (when (= valid-count 0)
      (when my-image-dired-clean-verbose
        (message "Delete thumbs dir %s" thumbs-dir))
      (ignore-errors (delete-directory thumbs-dir)))

    ;; 結果を返す。
    (my-image-dired-clean-count-make deleted-count error-count valid-count)))

;; 処理結果の意味

(defun my-image-dired-clean-count-make (&optional deleted error valid)
  (list (or deleted 0) (or error 0) (or valid 0)))
(defun my-image-dired-clean-count-inc (dst src)
  (cl-loop for dst-cell on dst
           for src-value in src
           do (setcar dst-cell (+ (car dst-cell) src-value))))
(defun my-image-dired-clean-count-deleted (count) (nth 0 count))
(defun my-image-dired-clean-count-error (count) (nth 1 count))
(defun my-image-dired-clean-count-valid (count) (nth 2 count))

(defun my-image-dired-clean-report (count show-msg)
  (when show-msg
    (message "Deleted %s, Error %s, Valid %s"
             (my-image-dired-clean-count-deleted count)
             (my-image-dired-clean-count-error count)
             (my-image-dired-clean-count-valid count)))
  count)

(defun my-image-dired-clean-thumbs (&optional forced-delete-p)
  "既に存在しない画像に対するサムネイルを削除します。"
  (interactive)
  ;; `image-dired-dir'下の全てのディレクトリについて
  ;; `my-image-dired-clean-thumbs-dir'を呼び出す。
  (let ((thumbs-root-dir (image-dired-dir))
        (count (my-image-dired-clean-count-make)))
    (dolist (file (directory-files thumbs-root-dir))
      (when (not (member file '("." "..")))
        (let ((thumbs-dir (expand-file-name file thumbs-root-dir)))
          (when (file-directory-p thumbs-dir)
            (my-image-dired-clean-count-inc
             count
             (my-image-dired-clean-thumbs-dir thumbs-dir forced-delete-p))))))
    (my-image-dired-clean-report count (called-interactively-p 'interactive))
    count))

ディレクトリを限定して、お掃除をするコマンドも追加します。

(defun my-image-dired-clean-thumbs-under (original-dir &optional subdirs-p
                                                       forced-delete-p)
  "ORIGINAL-DIR下の画像に対するサムネイルのうち不要になったものを削除します。

SUBDIRS-Pがnon-nilの場合、ORIGINAL-DIR以下にあった子孫ディレクト
リも処理に含めます。

インタラクティブに実行した場合は、現在のディレクトリが
original-dirになり、コマンドのプレフィックス引数がsubdirs-pになり
ます。"
  (interactive
   (list
    (if (derived-mode-p 'dired-mode 'wdired-mode)
        (dired-current-directory) ;;複数のディレクトリを表示している場合に備えて
      default-directory)
    current-prefix-arg))

  (let ((thumbs-dir (my-image-dired--thumb-dir
                     (file-name-as-directory original-dir)))
        (count (my-image-dired-clean-count-make)))
    (if subdirs-p
        ;; 子孫ディレクトリを含める。
        ;; エンコードしたディレクトリパスと先頭がマッチする
        ;; サムネイルディレクトリを全て対象にする。
        ;; 既に削除されているディレクトリも列挙できる。
        (dolist (thumbs-dir (directory-files
                             (image-dired-dir)
                             t
                             (concat "\\`"
                                     (regexp-quote
                                      (file-name-nondirectory thumbs-dir)))))
          (my-image-dired-clean-count-inc
           count
           (my-image-dired-clean-thumbs-dir thumbs-dir forced-delete-p)))
      ;; 指定ディレクトリ下のみ。
      (when (file-directory-p thumbs-dir)
        (setq count
              (my-image-dired-clean-thumbs-dir thumbs-dir forced-delete-p))))
    (my-image-dired-clean-report count (called-interactively-p 'interactive))
    count))

ついでに無条件でサムネイルを削除するコマンドも追加しておきます。

(defun my-image-dired-delete-thumbs ()
  "全てのサムネイルを削除します。"
  (interactive
   (unless (yes-or-no-p "Delete all thumbnails? ")
     (keyboard-quit)))
  (my-image-dired-clean-report
   (my-image-dired-clean-thumbs t)
   (called-interactively-p 'interactive)))


(defun my-image-dired-delete-thumbs-under (original-dir &optional subdirs-p)
  "ORIGINAL-DIR下の画像に対するサムネイルを全て削除します。

SUBDIRS-Pがnon-nilの場合、ORIGINAL-DIR以下にあった子孫ディレクト
リも処理に含めます。

インタラクティブに実行した場合は、現在のディレクトリが
original-dirになり、コマンドのプレフィックス引数がsubdirs-pになり
ます。"
  (interactive
   (list
    (if (derived-mode-p 'dired-mode 'wdired-mode)
        (dired-current-directory) ;;複数のディレクトリを表示している場合に備えて
      default-directory)
    current-prefix-arg))

  (my-image-dired-clean-report
   (my-image-dired-clean-thumbs-under original-dir subdirs-p t)
   (called-interactively-p 'interactive)))

これで次の4つのコマンドが出来ました。

  • my-image-dired-clean-thumbs : 元の画像が無くなったサムネイルを削除
  • my-image-dired-clean-thumbs-under : 同ディレクトリ指定バージョン
  • my-image-dired-delete-thumbs : 無条件でサムネイルを削除
  • my-image-dired-delete-thumbs-under : 同ディレクトリ指定バージョン

無条件削除はdiredで~/.emacs.d/image-diredを開いて手動で削除すればいい気もするんですけどね。一応。

cleanやclean-underをいつ呼び出すかは残された問題です。一括サムネイル表示コマンド(image-dired-show-all-from-dirや私の改造版だとmy-image-dired-dired-show-all-thumbsとか)に仕込んでおくくらいでしょうか? いや、ファイル数が多いディレクトリではあまり良くない? 最悪手動で呼び出すしかないですね。そのあたりは好き好きで。

後は許容容量に応じて古くなったサムネイルを削除する機能とか。「古くなった」と一口に言ってもそれはどういう意味なのか。最終アクセス日時情報が取れないと難しいところはあります。まぁ、その辺はもういいかなーという気もします。結局気が向いたときにdiredで~/.emacs.d/image-diredを開いて消せば良いのでしょう。MD5ハッシュだとそれすら簡単には出来ないので前回・今回の改造は意義があることでしょう。

それにしても本家はこの辺りどうするつもりなんでしょうね。タグやコメントでは~/.emacs.d/image-dired/.image-dired_dbというファイルを作って記録するので、結局その延長で対応表ファイルを作るくらいなのかなーと予想しますが。

2023-08-12

image-diredのサムネイルを元の画像の場所が分かる形式にする

image-diredのサムネイルはデフォルトでは ~/.emacs.d/image-dired に配置されます。(image-dired-dir変数やimage-dired-thumbnail-storage変数によって変更できます)

実際にそのディレクトリを見ると次のようになっています。

~/.emacs.d/image-diredの下
図1: ~/.emacs.d/image-diredの下

サムネイルのファイル名には、オリジナル画像のフルパス名をSHA-1で変換した文字列が用いられています。

これは見た目がスマートで格好よさげですが、実用上は元のファイル名に戻せないという問題があります。どこか別の場所に対応関係を保存しているのだろうと思いきや、そのようなものは見当たりません。となると、この沢山のサムネイルの中で元の画像がすでに削除されてしまったものを探すのは困難です。やるとすれば、ストレージの中にある全ての画像ファイルに対してSHA-1を適用して合致しなかったものを列挙するくらいでしょうか。過去に特定のディレクトリ下にあった(画像に対する)サムネイルを列挙するのもほぼ不可能でしょう。

サムネイルファイルに何かメタ情報を埋め込むという手もあります。例えば image-dired-thumbnail-storage が 'standard 等のときは、pngファイルの "Thumb::URI" 属性として "file://%f" が埋め込まれるようなので、これを元に元の画像を割り出すことも可能でしょう。とは言え特定のディレクトリ下にある画像のサムネイルを列挙するのに全てのサムネイルをスキャンしなければならないのは非効率です。

どこかに対応関係を示すファイルなりデータベースなりを作るというのも手ですが、(特にファイルであれば)サムネイル生成時のパフォーマンスや不整合の問題も出てくるでしょう。sqliteでも使う?

あくまでファイル名、ディレクトリ名レベルで何とかするのがお手軽ではないでしょうか。

フルパス名をURLエンコードのように%を付けてエスケープするのはどうでしょう。

例えば次のフルパスを

C:/home/hoge/project1/資料/room1.png

次のようにします。

C%3a%2fhome%2fhoge%2fproject1%2f資料%2froom1%2epng

これでもいいのですが、一応ディレクトリとファイル名を分けて最終的に次の場所にサムネイルを配置するようにしてみました。

~/.emacs.d/image-dired/C%3a%2fhome%2fhoge%2fproject1%2f資料%2f/room1%2epng.jpg

特定のディレクトリにある画像を列挙するのも簡単です(サブディレクトリを全て列挙するには多少ディレクトリを検索しなければなりませんが)。

長いファイル名問題はありますがそこは無視する方向で。

以下それを実現するコード。(すべてEmacs 29.1に対する追加です)

;;;; サムネイルファイル名のエンコード

(defun my-image-dired--encode-thumb-name (path)
  "PATHをファイルのベース名として使える文字列へエンコードします。"
  ;; url-hexify-stringは少し問題があるので使えない。
  (mapconcat (lambda (ch)
               ;; 変換元がファイル名なのでパス区切り文字以外は不要だと思
               ;; うが念のため色々エスケープしておく。
               ;; 拡張子を示す.もエスケープして完全にベース名として認識
               ;; されるようにする。
               (if (or (<= ch 32)
                       (memq ch '(?< ?> ?: ?\" ?/ ?\\ ?| ?? ?* ?. ?%))) ;;2023-08-13追記:?%が抜けていたのを修正
                   (format "%%%02x" ch)
                 (char-to-string ch)))
             path))

(defun my-image-dired--thumb-dir (original-file) ;;2023-08-17追記:追加
  "ORIGINAL-FILEに対するサムネイルを格納する場所を返します。"
  (expand-file-name
   ;; %エンコードする
   (my-image-dired--encode-thumb-name
    ;; 最後のスラッシュは含めてしまっていいかな。c:/とかあるし。
    (file-name-directory (expand-file-name original-file)))
   (image-dired-dir)))

(defun my-image-dired--thumb-file-name (original-file) ;;2023-08-17追記:追加
  "ORIGINAL-FILEに対応するサムネイルファイル名を返します。
ディレクトリは含まれません。"
  (concat
   ;; %エンコードする(ファイル名部分のみ・拡張子込み)
   (my-image-dired--encode-thumb-name
    (file-name-nondirectory original-file))
   ;; 拡張子を付ける
   ".jpg"))

(defun my-image-dired--thumb-file-path (original-file) ;;2023-08-17追記:追加
  "ORIGINAL-FILEに対応するサムネイルファイル名のフルパスを返します。"
  (file-name-concat (my-image-dired--thumb-dir original-file)
                    (my-image-dired--thumb-file-name original-file)))

;;;; サムネイルのファイル名を元の画像が分かるようなものにする

;; image-diredが生成するサムネイルのファイル名を、元画像の場所が分か
;; るような形式にする。

(defun my-image-dired-thumb-name (file)
  "`image-dired-thumb-name'を置き換えるための関数です。
画像ファイルFILEを格納するためのサムネイルファイルのパスを返します。
格納できるようにするために必要なディレクトリを作成する場合があります。"
  ;;2023-08-17追記:my-image-dired--thumb-dirとmy-image-dired--thumb-file-nameを使うようにしました。
  (let* ((thumb-dir (my-image-dired--thumb-dir file))
         (thumb-filename (my-image-dired--thumb-file-name file)))
    ;; ここでディレクトリを作ってしまうのはあまり良くないけど……
    (unless (file-directory-p thumb-dir)
      (with-file-modes #o700
        (make-directory thumb-dir t)))
    (file-name-concat thumb-dir thumb-filename)))

(defun my-image-dired-thumb-name-around (old-func file)
  "`image-dired-thumb-name'に対する:aroundアドバイスです。"
  (if (eq 'image-dired image-dired-thumbnail-storage)
      ;; image-dired-thumbnail-storageが'image-diredの時だけ
      ;; 独自のファイル名を生成する。
      (my-image-dired-thumb-name file)
    ;; その他は本来の関数を呼び出す。
    (funcall old-func file)))

(advice-add 'image-dired-thumb-name :around 'my-image-dired-thumb-name-around)

これを適用すると ~/.emacs.d/image-dired は次のようになります。

変更を適用した後の~/.emacs.d/image-diredの下
図2: 変更を適用した後の~/.emacs.d/image-diredの下

これならどこのディレクトリに対するサムネイルか一目瞭然ですし、diredで確認して不要だと思ったものだけを簡単に削除できます。別にこれで良くないですか?

もちろん自動的に指定したディレクトリのサムネイルを一括で削除するコマンドを作ったり、既に存在しない画像に対するサムネイルを掃除するコマンドを作るのも良さそうです。

この方法が嫌なのであれば、次善の策はサムイル画像へのメタ情報の埋め込み、sqlite等でサムネイルデータベースを構築、そこまでせず管理用のlispオブジェクトをファイルに読み書きする、といったくらいでしょうか。どれを取っても正直今ひとつといった感じはしますが。

なんかいじればいじるほどimage-diredは改善点が出てきてしまいますね。

2023-08-12

image-converterで動画ファイルを開いたときに固まるのを避ける

先日の設定でEmacsから動画系のファイルも画像として扱えるようになったのですが、実際にimage-modeで開こうとすると膨大な時間がかかることが分かりました。

気が付いたきっかけは@yoyaさんのこのツイート。

実際にimage-modeでビデオファイルを表示させようとしたところ確かにEmacsが固まりました。巨大なビデオファイルの場合はそもそもEmacsがファイルを読み込む段階で止まっているようでしたが、小さめなビデオファイルの場合は裏でffmpegが何やらテンポラリディレクトリにmagick-で始まるファイルを生成していてそれにかなり時間がかかっているようでした。小さめなものであればそのうち終わります。

しかしimage-diredでサムネイルを生成する場合にはそれほど時間がかかりません。巨大なビデオファイルでも大丈夫です。基本的にdiredからビデオファイルを開くときは外部のプレイヤーを起動するようになっていて、あくまでサムネイルを生成するために動画系のファイルに対応しただけなので気が付きませんでした。image-modeで表示しようとするとダメでサムネイル生成は大丈夫。両者は何が違うのでしょうか。

実際に実行されるコマンドを元に調査したところ、入力ファイル名の後に [0] があるかどうかで変わることが分かりました。

magick convert video.mp4 jpg:image.jpg
magick convert video.mp4[0] jpg:image.jpg

おそらく入力を指定する段階で最初の1枚目だけと限定されているので問題を回避できるのでしょう。ImageMagickは沢山の形式に対応していますから、おそらく入力は形式毎にモジュール化されていて、入力モジュールには必要なフレームに関する情報が引き渡されず、かといって読み込みを必要になるまで遅延するような仕組みも無いため全て読み込むしかない、といったところではないでしょうか。

image-diredの方はこの問題に気が付いたのかちゃんと対策をしてくれていますが、image-converterの方は対策されていません。まぁ、動画なんて開くなよ、ということなのかもしれません。

とは言え、一応次のようなコードで無理矢理入力ファイルの末尾に [0] を付けるようにしたところ問題は解消しました。

;; image-converterがImageMagickで動画ファイルを変換するときに長時間
;; 固まるのを避ける。
;; 全フレーム読み込もうとしてしまうのだとか!
(defun my-image-converter--convert-magick (old-fun type source image-format)
  ;; ファイル名の後に[0]をつける。data形式の場合は未対応。
  (unless image-format
    (setq source (concat source "[0]")))
  (funcall old-fun type source image-format))

(advice-add 'image-converter--convert-magick :around
            'my-image-converter--convert-magick)
2023-08-10

image-diredでmp3カバー画像を表示する

前回ImageMagickがサポートしている画像形式をできるだけ登録したので、いろんな画像ファイルを眺めては悦に入っていたのですが、音楽ディレクトリを見たときにmp3ファイルにサムネイルが表示されないことに気が付きました。mp3ファイルにはアルバムのカバー画像(アルバムアート?)が埋め込まれているケースが多いので、表示しようと思えば出来ないことはないはずです。少し調べてみたらffmpegを使って取り出せることが分かったので試してみました。

まずはimage-converter-add-handler関数を使ってmp3やm4aのときにffmpegを使うようにしてみました。

;; image-mode等で表示する方法

(defun my-image-convert-ffmpeg (source format)
  (image-converter--convert 'ffmpeg source format))

(image-converter-add-handler "mp3" #'my-image-convert-ffmpeg)
(image-converter-add-handler "m4a" #'my-image-convert-ffmpeg)

これだけで、mp3ファイルをEmacsで開いてからimage-modeを立ち上げるとカバー画像が表示されます。

しかしこれだけではimage-diredでサムネイルが表示されません。image-diredはimage-converterとはまた別の仕組みでサムネイルを生成していたのでした。こちらはImageMagickとGraphicsMagickのみ考慮されていてffmpegは対応していません。また、画像形式によってプログラムやオプションを変える仕組みもありません。なので、advice-addを使ってサムネイル生成関数の挙動を無理矢理変更することで実現しました。

;; image-diredでサムネイルを表示する方法

(defun my-image-dired-create-thumb-1-around (orig-func
                                             original-file thumbnail-file)
  (if (member (file-name-extension original-file) '("mp3" "m4a"))

      ;; ffmpegを使うように一時的に変数を書き替えてから実行
      (let ((image-dired-cmd-create-thumbnail-program "ffmpeg")
            (image-dired-cmd-create-thumbnail-options
             '("-i" "%f"
               "-vf" "scale=%w:%h:force_original_aspect_ratio=decrease"
               "%t")))
        (funcall orig-func original-file thumbnail-file))

    ;; 通常通りに実行
    (funcall orig-func original-file thumbnail-file)))

(advice-add 'image-dired-create-thumb-1 :around #'my-image-dired-create-thumb-1-around)

するとこんな感じでdired内にカバー画像が表示できました。

mp3やm4aファイルのカバー画像をDired内に表示した例
図1: mp3やm4aファイルのカバー画像をDired内に表示した例