2021-12-14 ,

HTMLでエクスポートしたorg-modeのリンクを新しいタブで開くようにする

1. 目的

HTMLでリンク先を新しいタブで開くにはa要素の属性として target="_blank" を指定します。全ての文書で常に新しいタブで開くのはどうかと思いますが、特定の文書(ページ)内は一律そうして欲しいケースもあると思います。

org-modeのリンクをHTMLでエクスポートしたとき、デフォルトではもちろんそのような属性は付きません。リンクの前に #+attr_html: :target _blank のような指定を入れれば実現可能ですが沢山のリンクがあると面倒ですし入れ忘れも生じます。また、一つの段落に複数のリンクがある場合、全てのリンクに属性が反映されないという問題もあります。

#+attr_html: :target _blank :rel noopener
[​[http://example.com/]]は別タブで開きます。二つ目のリンク[​[http://example.org/]]は開きません。

というわけで今回はorg-modeのフィルタシステムを使用して自動的に全てのリンクに新しいタブで開くための属性を入れてみたいと思います。

2. フィルタ関数の作成

まずフィルタ関数を作成します。

(defun org-newtab-link-filter (s backend info)
  (if (and
       ;;@todo Support <a data-ex=">" href=...>
       (org-export-derived-backend-p backend 'html) ;; html only
       (not (string-match "\\`[^>]* target=\"" s)) ;; has no target=
       (not (string-match "\\`[^>]* rel=\"" s)) ;; has no rel=
       (string-match "\\`[^>]* href=\"[^#]" s) ;;not internal link
       (string-match "\\`<a " s)) ;; a tag
      (replace-match "<a target=\"_blank\" rel=\"noopener\" " t t s)
    s))

この関数はHTMLのa要素にtarget属性とrel属性を挿入します。ただし追加するのは次の条件が全て満たされているときのみです。

  • HTMLバックエンドかその派生のバックエンドでエクスポート中であること
  • target=rel= がまだ指定されていないこと
  • リンク先が # で始まるページ内リンクではないこと

3. フィルタ関数の登録

次にこのフィルタがエクスポート時に使われるようにするのですが、これには色々な方法があります。

3.1. 変数 org-export-filter-link-functions に設定する方法

org-export-filter-TYPE-functions という名前の変数にフィルタ関数を登録すると、構文要素 TYPE のエクスポート結果を再処理できます。リンク要素に対するフィルタは org-export-filter-link-functions という変数へ登録します。(参考: Advanced Export Configuration (The Org Manual))

この方法で個別のファイルにフィルターを設定する方法については以前書きました。

個別のorg-modeファイルにエクスポート時のフィルターを設定する

例えば次のようにソースブロックを使ってエクスポートするたびにバッファローカル変数を設定するようにします。

#+BEGIN_SRC emacs-lisp :exports results :results none
(setq-local org-export-filter-link-functions '(org-newtab-link-filter))
#+END_SRC

[​[https://example.com]]

#+BIND: を使っても良いのですが org-export-allow-bind-keywordst でないと動作しないので、それならソースブロックにして評価するかyes/no確認を入れた方が良いと思います。

3.2. 派生したバックエンドを作成する方法

次のコードはhtmlバックエンドから派生したnewtab-link-htmlバックエンドを作成するものです。(参考: Advanced Export Configuration (The Org Manual))

;; エクスポートオプションで有効/無効を切り替えるしくみ(後述)
(defvar org-newtab-link-enabled nil) ;;tでデフォルト有効

(defvar org-newtab-link-options-alist
  '((:newtab-link-enabled "HTML_LINK_NEWTAB" nil org-newtab-link-enabled)))

(defun org-newtab-link-filter-opt (s backend info)
  (if (not (member (plist-get info :newtab-link-enabled) '(nil "" "nil" "no")))
      (org-newtab-link-filter s backend info)
    s))

;; HTMLから派生したバックエンドを作る
(defun org-newtab-link-define-backend ()
  (require 'ox-html)
  (org-export-define-derived-backend
      'newtab-link-html 'html
    :filters-alist '((:filter-link . org-newtab-link-filter-opt))
    :options-alist org-newtab-link-options-alist

    :menu-entry
    '(?n "Export to HTML (Enable org-newtab-link)"
         ((?N "As HTML buffer" org-newtab-link-export-as-html)
          (?n "As HTML file" org-newtab-link-export-to-html)
          (?o "As HTML file and open"
              (lambda (a s v b)
                (if a (org-newtab-link-export-to-html t s v b)
                  (org-open-file (org-newtab-link-export-to-html nil s v b)))))))))

(defun org-newtab-link-export-as-html
    (&optional async subtreep visible-only body-only ext-plist)
  (interactive)
  (org-export-to-buffer 'newtab-link-html "*Org Go Game HTML Export*"
    async subtreep visible-only body-only ext-plist
    (lambda () (set-auto-mode t))))

(defun org-newtab-link-export-to-html
    (&optional async subtreep visible-only body-only ext-plist)
  (interactive)
  (let* ((extension (concat "." (or (plist-get ext-plist :html-extension)
                                    org-html-extension
                                    "html")))
         (file (org-export-output-file-name extension subtreep))
         (org-export-coding-system org-html-coding-system))
    (org-export-to-file 'newtab-link-html file
      async subtreep visible-only body-only ext-plist)))

(org-newtab-link-define-backend)

エクスポートメニューにもエントリーを追加するので、 C-c C-e n n といったキー操作でフィルタを有効にしたエクスポートが可能です。

しかしこの方法にはいくつか欠点があります。

  • HTMLから派生した他のバックエンドには適用されない。
  • メニューが煩雑になる。
  • エクスポート時に明示的にバックエンドを選ばなければならない。

こういった問題があることを考えるとバックエンドを派生させるというアイデアはそれほど良いものとは思えなくなってきます。

3.3. HTMLバックエンドを修正する方法

派生したバックエンドを作るよりも既存のHTMLバックエンドを修正した方が使い勝手は良いものになります。ただし、他にもバックエンドを修正するコードがあるかもしれないので干渉しないように注意しましょう。

(defun org-newtab-link-modify-html-backend ()
  (require 'ox-html)
  (let ((backend (org-export-get-backend 'html)))
    ;; org-newtab-link-options-alistで定義されているオプションをバックエンドへ追加
    (let ((backend-options (org-export-backend-options backend))
          (new-option-names (mapcar #'car org-newtab-link-options-alist)))
      (setf (org-export-backend-options backend)
            (nconc
             (seq-remove (lambda (elem) (memq (car elem) new-option-names))
                         backend-options)
             org-newtab-link-options-alist)))

    ;; フィルタ関数org-newtab-link-filter-optをバックエンドの:filter-linkへ追加
    (let ((filter-link (assq :filter-link
                                   (org-export-backend-filters backend))))
      ;; null => (:filter-link . ())
      (when (null filter-link)
        (push (setq filter-link (list :filter-link))
              (org-export-backend-filters backend)))
      ;; (:filter-link . function) => (:filter-link . (function))
      (when (functionp (cdr filter-link))
        (setcdr filter-link (list (cdr filter-link))))
      ;; Add my filter function
      (when (not (memq 'org-newtab-link-filter-opt (cdr filter-link)))
        (push 'org-newtab-link-filter-opt (cdr filter-link))))))

(org-newtab-link-modify-html-backend)

こうすると通常のHTMLバックエンドによるエクスポートでリンクをフィルタできるようになります。

4. フィルタの有効/無効を切り替える

リンクを常に新しいタブで開くのなら良いのですが、たいていの場合目的に応じて切り替える必要があるでしょう。プロジェクトやディレクトリ、ファイルによって柔軟に切り替えられるようにしておく必要があります。

4.1. ファイルローカル変数・ディレクトリローカル変数を使用する方法

一番簡単なのはファイルローカル変数という仕組みを使用して、ファイルやディレクトリ毎に org-export-filter-link-functions 変数を変更することです。

まずバッファローカル変数にフィルタを設定する org-newtab-link-enable という関数を作ります。

(defun org-newtab-link-enable ()
  (setq-local org-export-filter-link-functions
              (cons #'org-newtab-link-filter
                    org-export-filter-link-functions)))

そしてそれをファイルローカル変数として安全に評価できるように設定します。ファイルを開くたびに警告が出るのは鬱陶しいので。

(add-to-list 'safe-local-eval-forms '(org-newtab-link-enable))

その上で次のように .dir-locals.el を作成します(M-x add-dir-local-variableorg-modeeval(org-newtab-link-enable) と入力等)。

;;; Directory Local Variables
;;; For more information see (info "(emacs) Directory Variables")

((org-mode . ((eval . (org-newtab-link-enable)))))

するとそのディレクトリ下にあるorgファイルを開いたときに、自動的に変数 org-export-filter-link-functions にフィルタ関数がバッファローカルとして設定されます。

org-modeはエクスポート時にフィルタ変数の値を読み取ってバックエンドオブジェクトのプロパティにコピーするので、エクスポート用に一時的に作られるバッファ上でも設定したフィルタが正しく使われます。下手に新しく専用のバッファローカル変数を作成すると、その変数はエクスポート用のバッファにコピーされないので正しく動作しません。

4.2. エクスポートオプションを追加する方法

#+HTML_LINK_NEWTAB: t のような記述でファイル毎に制御できると便利です。

これを実現するにはバックエンドオブジェクトにオプション定義を追加する必要があります。そのための仕組みは既に上のバックエンドを定義する方法の所に書いてあります。以下一部を再掲します。

(defvar org-newtab-link-enabled nil) ;;tでデフォルト有効

(defvar org-newtab-link-options-alist
  '((:newtab-link-enabled "HTML_LINK_NEWTAB" nil org-newtab-link-enabled)))

(defun org-newtab-link-filter-opt (s backend info)
  (if (not (member (plist-get info :newtab-link-enabled) '(nil "" "nil" "no")))
      (org-newtab-link-filter s backend info)
    s))

;; これらをバックエンドに仕込む方法は派生させる場合と修正する場合で異なります。

この方法は変数 org-newtab-link-enabled でもフィルタの有効性を制御できるようにします。この変数をファイルローカル変数やディレクトリローカル変数にすることもできます。そうしたい場合、安全な変数に指定しておくと煩わしい警告が出なくて便利です。

(put 'org-newtab-link-enabled 'safe-local-variable #'booleanp)

この方法はバックエンドに何らかの方法(派生させるなり修正するなり)で手を入れる必要があります。もしバックエンドに関わりたくないのであれば、フィルタ関数の中でオプション記述を検索するという方法もあり得ます。

5. 使う

以下は実際の使用例です。

#+html_link_newtab: t
* Chapter1

[​[https://example.com/]]や[​[https://example.org/]]は新しいタブで開きます。

#+attr_html: :target _self
[​[https://example.com/]]のように明示的にtarget属性が指定されているようなリンクには適用されません。[​[https://example.org/]]のような段落中の二つ目のリンクは ~#+attr_html:~ の効果が及ばないので新しいタブで開きます。

[​[*Chapter1][Chapter1]]のような内部リンクには適用されません。

6. 他の方法

今回はorg-modeのフィルタという仕組みを使用しましたが他にも様々なやり方が考えられます。

7. 終わりに

org-modeのエクスポートシステムは複雑でカスタマイズできるポイントも限られているのでちょっとしたことでもいつもどう実現しようか悩んでしまいます。この記事が何か同じようなカスタマイズをする際の参考になれば幸いです。

8. まとめ

;; リンクのフィルタ関数
(defun org-newtab-link-filter (s backend info)
  (if (and
       ;;@todo Support <a data-ex=">" href=...>
       (org-export-derived-backend-p backend 'html) ;; html only
       (not (string-match "\\`[^>]* target=\"" s)) ;; has no target=
       (not (string-match "\\`[^>]* rel=\"" s)) ;; has no rel=
       (string-match "\\`[^>]* href=\"[^#]" s) ;;not internal link
       (string-match "\\`<a " s)) ;; a tag
      (replace-match "<a target=\"_blank\" rel=\"noopener\" " t t s)
    s))

;; エクスポートオプション
(defvar org-newtab-link-enabled nil) ;;tでデフォルト有効
(put 'org-newtab-link-enabled 'safe-local-variable #'booleanp)

(defvar org-newtab-link-options-alist
  '((:newtab-link-enabled "HTML_LINK_NEWTAB" nil org-newtab-link-enabled)))

(defun org-newtab-link-filter-opt (s backend info)
  (if (not (member (plist-get info :newtab-link-enabled) '(nil "" "nil" "no")))
      (org-newtab-link-filter s backend info)
    s))

;; HTMLバックエンドの修正
(defun org-newtab-link-modify-html-backend ()
  (require 'ox-html)
  (let ((backend (org-export-get-backend 'html)))
    ;; org-newtab-link-options-alistで定義されているオプションをバックエンドへ追加
    (let ((backend-options (org-export-backend-options backend))
          (new-option-names (mapcar #'car org-newtab-link-options-alist)))
      (setf (org-export-backend-options backend)
            (nconc
             (seq-remove (lambda (elem) (memq (car elem) new-option-names))
                         backend-options)
             org-newtab-link-options-alist)))
    ;; フィルタ関数org-newtab-link-filter-optをバックエンドの:filter-linkへ追加
    (let ((filter-link (assq :filter-link
                                   (org-export-backend-filters backend))))
      ;; null => (:filter-link . ())
      (when (null filter-link)
        (push (setq filter-link (list :filter-link))
              (org-export-backend-filters backend)))
      ;; (:filter-link . function) => (:filter-link . (function))
      (when (functionp (cdr filter-link))
        (setcdr filter-link (list (cdr filter-link))))
      ;; Add my filter function
      (when (not (memq 'org-newtab-link-filter-opt (cdr filter-link)))
        (push 'org-newtab-link-filter-opt (cdr filter-link))))))

(with-eval-after-load "ox-html"
  (org-newtab-link-modify-html-backend))

一応GitHubにも置いておきます。

misohena/org-newtab-link: Open org-mode links exported as HTML in a new tab.

2021-11-17 ,

緯度経度リンクタイプをorg-modeに追加する

皆さんは緯度経度で場所を指し示すことって沢山ありますか? 普通そんなに無いですよね。私もそんなには無いんですが、登山をやっていると少しはあるんです。ここに何があったよーとか、ここの分岐は一方が通行止めになっていたよーとか、この写真の場所はどこだよーとか、写真撮り忘れたけどこのあたりだよーとか。

そんなときマップサービスへのリンクを張るのでも良いのですが、沢山位置を指し示す必要があるといちいちURLを生成するのが面倒です。

というわけで、 [[geo:36.2893,137.64785]] のような記述で位置を指定できるようにしました。

misohena/org-geolink: Adds geo location link type to org-mode.

探せば既にありそうですけどね。

書き方はだいたい geo URI scheme (rfc5870, Wikipedia)に合わせてあります。座標系とか高度とか不確実性とかは全然考慮してません。

上をHTMLでエクスポートすると下のようになります。

<ul class="org-ul">
<li><a href="https://www.openstreetmap.org/#map=15/36.2893/137.64785" target="_blank" rel="noopener" data-geolink="36.2893,137.64785">36.2893,137.64785</a></li>
<li><a href="https://www.openstreetmap.org/#map=18/36.2893/137.64785" target="_blank" rel="noopener" data-geolink="36.2893,137.64785;z=18">36.2893,137.64785</a></li>
<li><a href="https://www.openstreetmap.org/#map=15/36.2893/137.64785" target="_blank" rel="noopener" data-geolink="36.2893,137.64785">奥穂高岳</a></li>
</ul>

実際にこのブログ(Org2Blog)で書くと↓のようになります。

リンク上で C-c C-o したときにブラウザで開くようにもなっています。

設定で使用する地図サービスや生成するURL・HTMLを細かくカスタマイズできるようになっています。カスタマイズ変数だけでなく #+GEOLINK_MAP: google のようにバッファ内のオプションでもカスタマイズできるようになっています。(例: https://raw.githubusercontent.com/misohena/org-geolink/master/example.org)

私は比較的登山道が載っていることが多い地理院地図へのリンクを生成するように設定して使っています。(例: 36.2893,137.64785)

設定によってはリンクを埋め込みの地図に置換することもできます。(設定例: https://github.com/misohena/org-geolink#embedded-map-example)

のように書くと↓になります。

2021-10-31

2021秋の新番組

最近は本当に何をするのも遅くってやになっちゃいますね。もう11月ですよ。

今期は前期よりは大分見られるものが多いですが、これと言ったものはなかなか難しいですね。

印象 Web 更新時刻 タイトル
FOD - 平家物語
ABEMA 月 07:00 がんばれ同期ちゃん
ABEMA 月 07:00 月曜日のたわわ2
Disney+ - スター・ウォーズ:ビジョンズ
Netflix - 範馬刃牙
dアニメ 金 21:54 キミとフィットボクシング -FIt Boxing Animation-
YouTube 金 22:30 メガトン級ムサシ
× dアニメ 金 22:30 SELECTION PROJECT
Netflix - 終末のワルキューレ
dアニメ 土 01:00 魔王イブロギアに身を捧げよ
Netflix - ブルーピリオド
ABEMA 火 02:00 異世界食堂2
dアニメ 土 12:00 でーじミーツガール
dアニメ - 結城友奈は勇者である -大満開の章-
YouTube 月木 17:00 リッチ警官 キャッシュ!
NHK+ - 舞妓さんちのまかないさん
- - 半妖の夜叉姫 弐の章
ABEMA 日 00:00 86―エイティシックス― 第2クール
× dアニメ (10/09) 土 10:00 ぐんまちゃん
dアニメ (10/10) 日 09:30 デジモンゴーストゲーム
dアニメ 月 17:00 ワッチャプリマジ!
Amazon (10/08) 金 22:00 さんかく窓の外側は夜
dアニメ 日 23:30 テスラノート
dアニメ 月 00:00 MUTEKING THE Dancing HERO
dアニメ 月 00:00 無職転生-異世界行ったら本気だす- 第2クール
dアニメ 月 00:30 見える子ちゃん
dアニメ 月 01:00 しょうたいむ!~歌のお姉さんだってしたい~
dアニメ 火 00:30 月とライカと吸血姫
dアニメ 土 02:25 やくならマグカップも 二番窯
dアニメ 火 00:00 吸血鬼すぐ死ぬ
dアニメ 火 00:30 カードファイト!! ヴァンガード overDress Season2
FOD - 海賊王女
YouTube 水 20:00 境界戦機
dアニメ 火 02:00 進化の実~知らないうちに勝ち組人生~
Amazon 水 17:20 チキップダンサーズ
Amazon 水 25:00 takt op.Destiny
dアニメ 水 23:00 プラオレ!~PRIDE OF ORANGE~
dアニメ 水 23:00 真の仲間じゃないと勇者のパーティーを追い出されたので、辺境でスローライフすることにしました
Netflix - 古見さんは、コミュ症です。
FOD - マブラヴ オルタネイティヴ
ABEMA 水 23:30 世界最高の暗殺者、異世界貴族に転生する
dアニメ 木 23:30 サクガン!!
Amazon 木 01:58 プラチナエンド
× ABEMA 土 00:00 ヴィジュアルプリズン
- - 終末のハーレム
ABEMA 土 02:53 大正オトメ御伽話
dアニメ 土 22:00 最果てのパラディン
ABEMA 日 00:30 ビルディバイド -#000000-(コードブラック) 第1期
dアニメ 日 01:30 ルパン三世 PART6
ABEMA 火 01:00 先輩がうざい後輩の話
- - ワールドトリガー 3rdシーズン
dアニメ 火 00:00 逆転世界ノ電池少女
- - BanG Dream! ガルパ☆ピコ ふぃーばー!
ABEMA 水 00:30 Deep Insanity THE LOST CHILD
× - - かぎなど
dアニメ 金 00:00 シキザクラ
dアニメ 月 12:00 闘神機ジーズフレーム
Amazon 金 01:55 王様ランキング
dアニメ 土 00:00 180秒で君の耳を幸せにできるか?
YouTube   ガンダムブレイカー バトローグ

この中で他と少し毛色が違うもので気になるのはさんかく窓の外側は夜でしょうか。なんかpetとかを連想しますね。

2021-09-21 , ,

Emacsの中で動く作図ツールを作る

先日も書きましたが最近はEmacsの中で動く作図ツールを作っています。

ソース: misohena/el-easydraw: Embedded drawing tool for Emacs (github.com)

org-modeの中で思いついた時に図を描きエクスポートするまでの様子
図1: org-modeの中で思いついた時に図を描きエクスポートするまでの様子

以前囲碁の棋譜編集ツールを作ってその時にも書きましたが、Emacsの中でこのくらいのことは出来ても罰は当たらないと思うんですよね(このくらい出来て当然だろ!の意)。

org-modeは素晴らしいツールでいろんな事が出来ますが、文書の中に別の要素を埋め込んで統一的に編集する機能はまだまだ改善の余地が沢山あると思います。(ソースコードブロックのようなテキストベースでプログラマーが誰でも喜ぶような物は充実していますけど) 特にGUI要素が全然足りません。例えば図を描くならditaaやPlantUMLなんかもありますが、やっぱりGUIで描きたくないですか? 20年以上前のWordに出来たようなことが現代の編集環境で出来ないというのはとても残念な事だと思います。(Xwidgetsが使えれば色々出来るのかもしれませんがWindowsなので未だに使ったことがありません。Cygwinで環境を整えれば使えるのかもしれませんが……)

ということでEmacsの中でシームレスに作図が出来るようにと作りました。まだまだ改善するところが沢山あって思っていた以上に難航していますが、日々テストと称していろんな図を作成して遊んでいます。

2021-09-21-edraw1.png
2021-09-21-itsumodori.png
2021-09-21-karasu1.png
2021-09-21-karasu2.png
2021-09-21-increase-issues.png
2021-09-21-diary.png
2021-09-21-edraw2.png
2021-09-21-edraw-svg-path-d-structure1.png
2021-09-21-edraw-svg-path-d-structure2.png
2021-09-21-edraw-self-dev.png
2021-09-21-edraw-propedit.png
2021-09-21-edraw-takao.png
2021-09-21-edraw-copy-paste-test.png

実装

画像表示(ビュー)とマウス操作(コントロール)はこれまで培ってきたEmacsでのSVGやオーバーレイ、マウスイベント処理の延長線上にあります。

その上で一番最初に手を付けたのは当たり判定でした。SVGで図形を表示するのは簡単ですがマウスでクリックした点と図形との当たり判定は自分で行わなければなりません。残念ながらEmacsはそこまで面倒を見てくれません。ベジェ曲線を含んだパスの判定をするにはそれなりに手間がかかりますが、これが出来なければ話になりません。幸いこの手の当たり判定やベジェ曲線については多少扱ったことがあったのですぐに実装出来ました(完全かはともかく)。それでもこういう当たり判定処理はちゃんと動くと嬉しいものですね。

編集対象となる図形データ(モデル)は、基本的にはSVGのDOMツリーです。edraw-bodyというidを持つg要素の下が編集エリアで、それ以外の所にUIに必要なもの(グリッドやアンカー点等)を配置します(もちろん出力時にはUI要素は削除します)。できるだけDOMツリーを尊重して編集対象となるデータは常にDOMツリーに持たせてそれを書き替える形にしようと思いました。しかし今となってはちょっと怪しくなっています。shapeクラスを作ってそれ経由でDOM要素を操作する形になっていますが、shapeオブジェクトが編集中のデータを一部持ってしまっています。毎回属性をparseして編集して文字列に戻すのも大変ですし(特にpathデータ)、ドラッグ移動中や選択中のポイントが必ずしも属性と一対一で対応するわけではないので困るということもありました。とはいえそういったものは一部の例外で、基本的にはshapeオブジェクトはDOMノードをラップする存在です。あ、ちなみに今回初めてeieioを使っています(これもまた色々暗中模索でした)。

そういった所を実装して割とすぐに簡単な図が描けるようになりましたが、その後の細々とした改良に沢山の時間を費やしています。

矢印テスト中の様子
図2: 矢印テスト中の様子

例えば矢印は手間がかかりました。SVGにはマーカーという機能があってあらかじめ定義しておいた図形(マーカー)をパスの頂点にくっつけることが出来るのですが、あらかじめ定義しておく必要がある時点で少し面倒ですし、色も含めて定義しておく必要があるので線の色が変わると定義も更新しなければなりません。重複する定義をまとめる仕組みや変更を検知して更新する仕組みが必要でした。なので中身は見た目よりもずっと面倒くさいことになっています。でもこの手のソフトを作るなら矢印は絶対に欲しいと思っていたので頑張りました。是非矢印を有効(パスを右クリックしてSet→End Marker→Arrow)にした上でstroke色を変えてみてください。矢印の色も一緒に変わるのは決して当たり前なことでは無いんです。それが証拠に線を半透明にするとボロが出ます(笑)。(SVG2ではfill="context-stroke"という指定が出来るようになって多少やりやすくなりますがlibrsvgでは最近対応したばかりでまだ手元のEmacsでは使えません。librsvgでの対応が待たれる事項は他にも沢山あります)

パスの操作はいちいち場合分けが大変で苦労しました。SVGのパスデータ(path要素のd=属性)にはやっかいなところがいくつかあって(本文末尾参照)、アンカーポイントの削除、追加、パスの分割、連結、ハンドルのあれやこれやを実装する際に悩みの種となりました。SVG仕様の細かいところまで対応する必要は無かったのかもしれませんが、将来的にどんなデータを扱うのか分かりませんので。例えば今のところ複数のサブパス(一つのd=の中に複数のパスが存在するケース)は扱えないのですが、将来的には対応したいところです。でないとドーナツ型が作れませんし(ここが抜けない)。いや、まぁ、やってやれないことも無いんですけどね……(←U字になってる)。

その後も先日紹介したカラーピッカー、プロパティエディタ、コピー&ペースト、複数選択、UNDO/REDO等々少しずつ実装していきました。前回の囲碁エディタのようにもう少し短期間で切りの良いところまでいけると思ったのですが、一つ一つの改良とテストに思っていたよりも時間がかかってしまいました。

プロパティエディタの改良でマウス移動イベントが文字単位でしか発生しない事を知る
図3: プロパティエディタの改良でマウス移動イベントが文字単位でしか発生しない事を知る

org-modeとの連携とリンク形式、インライン画像表示、編集、エクスポート

一通り作図が出来るエディタが出来たらorg-modeとの連携部分も作らねばなりません。前回の囲碁エディタは #+begin_igo#+end_igo というスペシャルブロックを使いましたが、今回は [​[edraw:]] という独自のリンクタイプを追加することにしました。ブロックだと行の中に図を挿入できないからですこんな風に(SVG)。もちろんorg-modeで画像を挿入する通常の方法がリンクなのでそれにならったというのもあります。

現在サポートしているリンクの形式は次の通りです。

[​[edraw:file=./example.edraw.svg]​]

[​[edraw:data=<base64data>​]

[​[*Example][edraw:file=./example.edraw.svg]​]

[​[*Example][edraw:data=<base64data>]​]

ファイルへ(.edraw.svg)へのリンクの他、base64によるデータ埋め込みにも対応しています。外部ファイルが必須となると途端にお手軽さが減ってしまいます。一つの文書ファイルで完結していた方が取り扱いが楽なのは間違いありません。幸いSVGはXMLなのでラスター画像よりは大きくありませんし、それをさらにgzip圧縮してからbase64エンコードしています。

外部ファイルの場合、拡張子は.edraw.svgを推奨しています。Emacs Easy Drawが扱えるのは独自のルールに従ったSVGのサブセットのみです。他のソフトが出力したSVGを編集できるわけではありませんが、Emacs Easy Drawが出力したSVGをブラウザなど他のソフトで表示することは可能です。

通常のリンク形式(file, http, https)を拡張することも考えたのですがData URI対応の経験から既存の処理と混ざると非常に面倒だと思ったのでひとまず完全に独自のリンクタイプとしました。gzipで圧縮したSVGはブラウザで直接表示できないので、どのみちエクスポート時には独自の変換処理が必要になります。将来的には.edarw.svgファイルへの通常リンク(例: [​[file:./example.edraw.svg]] )を直接編集できるようにすることも考えています。ただ、データをorgファイル内に埋め込みたいと思うならやはり独自形式の方が都合が良いと思います。

これらのリンクは edraw-org-link-image-mode というマイナーモードによってバッファ内にインライン表示できます。org-modeの org-toggle-inline-images に相当しますが、こちらはマッチする形式は即画像で表示します(私は通常の画像リンクも即更新するように修正して使っているのでそのやり方を踏襲しました)。

リンクまたはインライン表示された画像上で C-c C-o を押すとエディタが開きます。編集の後 C-c C-c (またはメニューからFinish Edit)で編集したSVGデータをバッファ内(ファイルリンクの場合は指しているファイル)に書き戻します。

もちろんEmacsの中で表示・編集できるだけでなく、HTMLエクスポートの際にはimgまたはsvgタグとして出力できます。以下は実際にEmacs Easy Drawで描いた図です。このブログはOrg2blogで書かれているので、手元のorg文書に埋め込まれている図がそのまま皆様の目の前に現れています。

MLLZLLZLLMx1,y1 Lx2,y2 Lx3,y3 Z Lx4,y4 Lx5,y5 Z Lx6,y6 Lx7,y7Subpath1Subpath2Subpath3Subpath1(Closed Path)Subpath2(Closed Path)Subpath3(Open Path)
図4: Emacs Easy Drawで描いた図をSVGとしてエクスポートした例(ブラウザで文字が選択出来る)

Pathツールの使い方

PhotoshopやIllustrator等でおなじみのPathツール(ペンツール)ですが、知らない人は最初戸惑うかもしれません。なので使い方を示すアニメーションを作ってみました。

パスツールの使用例
図5: パスツールの使用例

単にクリックするとその場所に点(アンカーポイント)を追加します。次々にクリックするとアンカーポイント間が直線で結ばれます。

マウスボタンを押し下げてからそのままドラッグすると曲線の制御点(ハンドル)を動かすモードになります。押した点から伸ばした方向に向かう滑らかな曲線が出来ます。

一つのアンカーポイントからは二つのハンドルが伸びています。それぞれ前の区間と後ろの区間に対する制御点なのですが、この二つがアンカーポイントを挟んで互いに180度反対側にあると(要するに三つの点が一つの直線上にあると)、そのアンカーポイントを通る線は尖った部分が無く完全に滑らかになります(その直線を接線とした曲線になります)。

アンカーポイントハンドル
図6: アンカーポイントとハンドル

もしアンカーポイントを選択してもハンドルが二つ現れない場合は、アンカーポイントを右クリックして「Make Smooth」を選んでください。ちょうど良さそうな点を計算してそこに二つのハンドルを置きます。

逆にハンドルを消したい場合は「Make Corner」を選んでください。滑らかさが消えて完全に尖った形(折れ線)になります。

片側のハンドルだけを単独で動かしたい場合は、一つのハンドルをクリックして選択状態にしてください。そのハンドルだけ単独で動くようになります。(PhotoshopやIllustratorの「切り替えツール」は今のところありません)

ちなみに選択中のハンドルやアンカー(もちろんシェイプも)は矢印キーで移動できます。S-矢印キーで10ピクセル単位、M-矢印キーで数値入力で移動します。細かい調整の際には重宝します。

現在のパスを終了して新しいパスを開始したいときは再度Pathツールを選択してください。ツールバーのボタンを押すかaキーを押すとPathツールが初期状態から始まり、次のクリックでは新しいパスシェイプと最初のアンカーポイントが作成されます。

このとき(Pathツールを選択した直後)、現在選択中のパスの端点(一番端っこのアンカーポイント)をクリックすると、そのアンカーポイントからパスを伸ばす(再開する)ことが出来ます。

パスを伸ばしているときに既存のパスの端点をクリックすると、現在のパスをその端点を持つパスと連結します。

クリックで既存のアンカーと繋げたくない場合は、 C-u + クリック で確実に新しいアンカーポイントを追加できます。

操作方法の問題点について

他にも操作には色々注意点がありますが、とりあえず運用でカバーしつつ実用的な図がかけるところまでは出来たのではないかと思います。

完全に私の好みに合わせて作っているので他の方には使いづらい所もあるかとは思いますが、その辺りはご了承ください。

うまく自分の好みに合わせられず使いづらいところもありますが、その辺りは自分の実力が足りないのが悪いのだと諦められるので納得しやすいところではあります(笑)。

細かいところの改善は限りが無く時間は有限なので手を付けていないところも沢山あります。

無限の改善点の狭間で

まだまだ改善点は尽きません。思いついた物はtodo.orgに書き留めています。

一時期は一つ直していくそばから沢山の修正点が見つかる状況でしたが、それも少し落ち着いてきました。

修正点修正済み修正した一つ取り出す修正点を見つける修正作業
図7: 一つ修正すると修正点が増えている図

この手のツールは作っていけば行くほど次第に労多くして功少なしな事項ばかりが残っていくのが常です。

本当に切りがないので、このあたりでひとまず開発のペースを緩めようと思います。元々UNDOを実装するところまでは一気に作ろうと思っていてそれが出来たので。それに秋山の紅葉が私を呼んでいますので。

SVGは本当に表現力があって色々出来るので皆さんもEmacsに足りない要素をどんどん追加していきましょう。

おまけ:SVGパスデータの構造についての図

以下はSVG path要素のd属性について説明するためにEmacs Easy Drawで描いた図です。

pathは <path d="M10,10L30,10C40,20 40,80 30,100" stroke="red" /> のような記述で自由に線を引くための要素ですが、そのd属性(パスデータ)の編集にはいくつか注意点があるのでそれについて描いたものです。

基本的な構造(M, L, Cコマンド):

MLC(previous anchor's)forward handlebackward handleanchoranchoranchorline segmentcurve segmentM commandL commandC command

Zコマンドでパスを閉じると始点と終点が切れ目のない繋がった形状になる(単に始点へ線を引くのとは幅の広い線において色々違いが出てくる)。最後の点と始点の間は直線で結ばれる。

MLLL commandL commandZ command(Line to previous M)* Z commands automatically create a closing segment but cannot represent a curve

曲線で閉じるにはMと同一点までCで閉じ線を引かないといけない(その上でZが必要)。その場合MとCの点は一つのアンカーポイントとして同一視して処理しなければならない(Mを動かしたらCも一緒に動かしたり、Mの前の点を求めるときはCをスキップする等)。

Same coordinatesMCCZClosing Segmentbackward handle of C and M!forward handle of M and C!Length=0

一つのパスデータには複数(0以上)のサブパスを含めることができる。

MLLMLLZClosed SubpathOpen SubpathMLOpen SubpathM L L Z M L L M L

直後にMコマンドが無いZコマンドはMの位置を開始点にした新しいサブパスを作る。

MLLZLM L L Z LSubpath1(Closed Path)Subpath2(Open Path)Subpath12

従って、一つのMコマンドが表す点は複数のサブパスで共有される場合がある。このMの点を削除したり分割したりする場合は注意が必要になる。

MLLZLLZLLMx1,y1 Lx2,y2 Lx3,y3 Z Lx4,y4 Lx5,y5 Z Lx6,y6 Lx7,y7Subpath1Subpath2Subpath3Subpath1(Closed Path)Subpath2(Closed Path)Subpath3(Open Path)

最後のアンカーポイントの前方ハンドルを記録するため、 -forward-handle-point という独自の拡張コマンドを追加している。当然これはSVG出力時には削除される。

C?M or L or CC command-forward-handle-point commandLast anchor point

(あー、矢印の色が……。複数の図を一つのHTMLに出力した(埋め込んだ)ときにマーカーIDが重複する問題に気がついてしまった……。修正するならエクスポート時にマーカーIDに図のIDを付け加えるとかかなぁ)