Yearly Archives: 2022

2022-11-04 ,

org-modernでタグを正確に右寄せする

org-modernはタグを右寄せせず見出しのすぐ右に並べる(org-tags-column=0)ことを想定していますが、私は右寄せ(org-tags-column=-74)のまま使っています。

そうすると何が困るかというと、タグの位置が不揃いになることです。

修正前:タグの位置が不揃い
図1: 修正前:タグの位置が不揃い

org-modernはタグを少し小さめのフォント(:height 0.9)で表示しますし、コロン(:)の前後に空白を入れるのでどうしても幅が変わってしまいます。

文字を小さくしているので文字数単位での調整では修正できません。ピクセル単位での調整が必要です。

というわけで次のように直しました。(org-modern/org-modern.el at 59b2e3c94756b4e37b2cf7b9f81028c6d4758672 · minad/org-modern より修正)

;; org-modern.el (2022-11-04版より修正)

;;;~略~

(defun org-modern--tag ()
  "Prettify headline tags."
;;;~略~
          (setq colon-beg cbeg colon-end cend)))

      ;; 以下を追加。タグの位置を揃える。
      (my-org-modern-align-tags-right beg end)
)))

(require 'shr)

(defvar my-org-modern-tags-right (* (default-font-width) (- 80 3))) ;;揃えるピクセル位置

(defun my-org-modern-align-tags-right (beg end)
  (let* (;;(beg-px (car (window-text-pixel-size nil (line-beginning-position) beg)))
         (end-px (car (window-text-pixel-size nil (line-beginning-position) end)))
         ;;(width-px (if (<= beg-px end-px) (- end-px beg-px) (+ (- (window-text-width nil t) beg-px) end-px)))
         ;; shr-string-pixel-widthを使う。
         ;; つまり別バッファに移してからwindow-text-pixel-sizeで幅の計測を行う。
         ;; 同じバッファでやるとタグが非表示部分の中にあると幅が0になってしまう。
         (width-px (shr-string-pixel-width (buffer-substring beg end)))
         (tags-right my-org-modern-tags-right))
    (when (and (< end-px tags-right) (> width-px 0))
      ;;(put-text-property (1- beg) beg 'display (list 'space :width (list (if (< end-px tags-right) (- tags-right end-px) 0))))
      ;; align-toを使う。widthだと後からorg-indentでずれてしまう。
      (put-text-property (1- beg) beg 'display (list 'space :align-to (list (- tags-right width-px))))
      )))

つまり、org-modernがタグのfontifyを行った直後にタグのピクセル幅を計算し、タグの直前にある空白文字をdisplayプロパティによる空白に置き換えます。そのdisplayプロパティには (space :align-to 揃える位置 - タグ幅) を指定します。

結果は次の通りピッタリ右端が揃いました。

修正後:タグの位置がピッタリ揃っている
図2: 修正後:タグの位置がピッタリ揃っている

リージョンで囲っている部分はdisplayプロパティが適用された空白です。

キモは shr-string-pixel-width 関数です。テキストのピクセルサイズを求めるには window-text-pixel-size 関数を使わなければなりませんが、この関数はバッファとウィンドウを要求します。 shr-string-pixel-width は一時的なバッファを一時的に現在のウィンドウに関連付けて window-text-pixel-size を呼び出してくれます。そんなことをして即時に正しく計算できるのか(レイアウト処理を遅延していないのか)、パフォーマンスは大丈夫なのか心配になりましたが、今のところ問題は見つかっていません。window-text-pixel-sizeのソースコードを読んでおいた方が良いのかもしれません。

検索するとEmacs 25.1で問題があったのを修正した記録があります。

bug#24950: 25.1; shr-string-pixel-width cannot be called from a dedicate

今回の修正は強制的にタグを右端に揃えるのでorg-tags-columnがなんであろうとお構いなしです。環境によって最適なウィンドウ幅は変わります。タグを何桁目で揃えるのか悩み所でしたが、org-tags-column=0にして右寄せは完全にビューの役割にするのも良いかもしれません。

2022-11-03 ,

Org言語のソースコードブロックをエクスポートしたときにorg-modernが影響してしまう問題

久しぶりにOrgの書き方を説明する記事を書いた時に気がついたのですが、エクスポート結果がorg-modernが適用された状態になっていました。

例えば次のOrg文書をエクスポートしたときに……

org-modeでは色々なものを次のように書きます。

#+begin_src org
,* table

| 項目 | 金額 |
|------+------|
| あれ | 1000 |
| それ | 2000 |

,* list

- [ ] item1
- [ ] item2
- [ ] item3

,* link

こんにちは。 [[https://www.google.com/][Google]] 

↑これは以前直した所。
素のorg-modeでも単に下線でGoogleとだけ表示されてしまい
リンクの書き方が分からない。

#+end_src

次のようになっていました。

org-modernの影響を受けたHTMLエクスポート結果
図1: org-modernの影響を受けたHTMLエクスポート結果

これじゃOrgの書き方の説明になりません。もちろん正しくは次のようになっていなければなりません。

通常のHTMLエクスポート結果
図2: 通常のHTMLエクスポート結果

これは全てのバッファでorg-modernを有効にするために、次のように設定したからです。

(add-hook 'org-mode-hook 'org-modern-mode)

org-modeがHTMLでエクスポートするとき、ソースコードブロックの中身に色づけを行います。その処理はorg-html-fontify-code関数にあるのですが、その仕組みは、一時バッファを作成して言語用のモードを立ち上げ、コードを挿入し、font-lock-ensureでバッファの色づけを行い、htmlizeを使ってテキストプロパティを元にHTMLに変換します。

なので、Emacs上で編集しているときの見た目をHTML上で再現してしまうわけです。なんてこった!

同種の現象は以前リンクの書き方でもありました。

org言語のソースブロックをエクスポートしたときにリンクをそのまま出力する

なので今回も同じようなアプローチを取ります。

次の部分はリンクの問題と共用です。

(defvar-local my-org-in-html-fontify-code nil)

(defun my-org-html-fontify-code-advice (old-func &rest rest)
  (cl-letf (((default-value 'org-link-descriptive) nil) ;;リンクを展開表示する
            ((default-value 'my-org-in-html-fontify-code) t)) ;;org-html-fontify-codeの中にいることを示す
    (apply old-func rest)))

(advice-add 'org-html-fontify-code :around 'my-org-html-fontify-code-advice)

その上で org-modern-mode を起動する部分を次のようにします。

(defun my-org-modern-enable ()
  ;; エクスポート中(ソースコードブロックのfontify中)はorg-modern-modeを起動しない。
  (unless my-org-in-html-fontify-code
    (org-modern-mode)))

(add-hook 'org-mode-hook #'my-org-modern-enable)

つまり、色づけ処理に入るときにフラグを立てて、モードを立ち上げるときにそれを確認するわけです。

はい、いつも通り無理矢理ですね。

そもそも org-modern を常用している人って世界中にどれくらいいるんでしょうね。重くなるし編集しづらくなるし色々問題も起きるのでハッキリ言っておすすめはしません(笑)。

2022-11-03 ,

org-modeの表(テーブル)で集計する方法(orgtbl-aggregateの使い方)

org-modeのスプレッドシート(表計算)機能は便利ですが、データをグループ化して集計する機能が欠けています。

Excelであればピボットテーブルや小計機能を使えば超簡単ですが、org-modeでやろうとするとどうしたものか困ってしまいます。ソースコードブロック(babel)で何とかする方法もありますが、ここでは orgtbl-aggregate という外部パッケージを使う方法を説明します。

orgtbl-aggregateの入手先

orgtbl-aggregateは次の場所で開発されているようです。

tbanel/orgaggregate: Aggregates tables in Org mode

MELPA等でも配布されているようです。

一点だけ注意。日本語(全角文字)を使用すると表が乱れる不具合がありました(2022-11-03現在)。例によって文字列に対してstring-width関数を使用していないことが原因です。 (length cellnp)(length cell) と書かれている部分を (string-width cellnp)(string-width cell) に変更したら解消しました。

基本的な使い方

適当な表

適当な表を用意しました。

#+NAME: 20221103-payments
| 大分類 | 小分類 | 支払先       | 目的           |  金額 |
|--------+--------+--------------+----------------+-------|
| 交通費 | 鉄道   | JR東日本     | 立川-甲府      |  1690 |
| 交通費 | バス   | 山梨交通     | 甲府駅-広河原  |  1990 |
| 交通費 | バス   | 山梨交通     | 利用者協力金   |   300 |
| 宿泊費 | 幕営   | 白根御池小屋 | テント1人1泊   |  1000 |
| 食費   | 飲料   | 白根御池小屋 | 麦茶           |   500 |
| 宿泊費 | 幕営   | 北岳肩の小屋 | テント1人1泊   |  1000 |
| 食費   | 飲料   | 北岳肩の小屋 | なっちゃん     |   600 |
| 食費   | 飲料   | 北岳肩の小屋 | 水1L           |   200 |
| 食費   | 菓子   | 白根御池小屋 | 信玄アイス     |   800 |
| 交通費 | バス   | 山梨交通     | 広河原-甲府駅  |  1990 |
| 交通費 | バス   | 山梨交通     | 利用者協力金   |   300 |
| 交通費 | 鉄道   | JR東日本     | 甲府-立川      |  1690 |
|--------+--------+--------------+----------------+-------|
|        |        |              |                | 12060 |
#+TBLFM: @>$5=vsum(@I..@II)

20221103-paymentsという名前が付いたorg-modeの表です。これは6月末に北岳へ行った時に支払ったお金の一覧です。

右下に合計金額を書き加えてあります。これが少々面倒を引き起こします。

基本

大まかにどんなものにどのくらいの費用がかかったのでしょうか。

何も考えずに最低限の指定をしてみます。 #+BEGIN:#+END:C-c C-x x で挿入できます。aggregate、テーブル名、集計する列などを入力します。その後で C-c C-c を押すと集計を実行します。

#+BEGIN: aggregate :table "20221103-payments" :cols "大分類 vsum(金額)"
| 大分類 | vsum(金額) |
|--------+------------|
| 交通費 |       7960 |
| 宿泊費 |       2000 |
| 食費   |       2100 |
|        |      12060 |
#+END:

概ね良いのですが、合計の部分(12060)が一緒くたになっているのが気になります。元の表から合計の行を消しても良いのですが、それは残したいものとしましょう。計算を水平線(hline)で囲まれた範囲に限定できないものでしょうか。

隠れた列「hline」

実は hline という列が指定できます。元の表には無い列ですが、何個目の水平線の後かを表します(一つ目の水平線は見出しとの区切りのため数えません)。

#+BEGIN: aggregate :table "20221103-payments" :cols "hline 大分類 vsum(金額)"
| hline | 大分類 | vsum(金額) |
|-------+--------+------------|
|     0 | 交通費 |       7960 |
|     0 | 宿泊費 |       2000 |
|     0 | 食費   |       2100 |
|     1 |        |      12060 |
#+END:

条件抽出

hlineが0の行だけを抽出すれば合計部分を排除できます。それには :cond (equal hline "0") を指定します。 "0" は文字列です。

#+BEGIN: aggregate :table "20221103-payments" :cols "大分類 vsum(金額)" :cond (equal hline "0")
| 大分類 | vsum(金額) |
|--------+------------|
| 交通費 |       7960 |
| 宿泊費 |       2000 |
| 食費   |       2100 |
#+END:

列名変更

列名の vsum(金額) というのはちょっと式っぽいので、単純に にしてみます。 ;'計' を指定します。列名が変わります。

#+BEGIN: aggregate :table "20221103-payments" :cols "大分類 vsum(金額);'計'" :cond (equal hline "0")
| 大分類 |   計 |
|--------+------|
| 交通費 | 7960 |
| 宿泊費 | 2000 |
| 食費   | 2100 |
#+END:

ソート

金額が大きい順にソートしてみます。 ;^N を指定すると数値降順にソートされます(小文字で昇順。a/Aアルファベット順、n/N数値順、t/T時間順)。

#+BEGIN: aggregate :table "20221103-payments" :cols "大分類 vsum(金額);'計';^N" :cond (equal hline "0")
| 大分類 |   計 |
|--------+------|
| 交通費 | 7960 |
| 食費   | 2100 |
| 宿泊費 | 2000 |
#+END:

本記事の趣旨とは関係ない余談ですが、やはり交通費が一番かかります。食費には自宅からの持ち込み分は含まれていません。宿泊費が安いのはひとえにテント泊だからですが、装備を用意するのに多額の費用がかかります(山小屋に泊まるのと比べれば十分元は取れます)。

追加処理

最後に合計を付け加えてみます。ちょっと面倒です。

#+BEGIN: aggregate :table "20221103-payments" :cols "大分類 vsum(金額);'計';^N" :cond (equal hline "0") :post (lambda (table) `(,@table hline ("合計" ,(cl-loop for line in table sum (if (eq line 'hline) 0 (string-to-number (nth 1 line)))))))
| 大分類 |    計 |
|--------+-------|
| 交通費 |  7960 |
| 食費   |  2100 |
| 宿泊費 |  2000 |
|--------+-------|
| 合計   | 12060 |
#+END:

:post の後の部分は整形すると次のようになっています。

(lambda (table)
  `(,@table
    hline
    ("合計" ,(cl-loop for line in table
                      sum (if (eq line 'hline)
                              0
                            (string-to-number (nth 1 line)))))))

tableは行のリストで、その要素は列のリストかシンボルhlineです。

このコードは末尾にhlineと合計の行を付け加えます。

単純に上2行(見出しとhline)を無視するだけであればcl-loop部分は次のようにしても良かったかもしれません。

(cl-loop for line in (cddr table)
         sum (string-to-number (nth 1 line)))

結果の埋め込み

元の表もそうですが、合計が入るとデータとしては少々扱いづらい表になってしまいます。合計は表の中ではなく別の場所に記載するというのも手でしょう。

インラインソースコードブロックを使えば文書中に結果を埋め込むことも出来ます。

合計: src_elisp[:var tbl=20221103-payments[0:-2,-1] :colnames yes :hlines no :eval no-export ]{(apply #'+ tbl)} {{{results(=12060=)}}}

わざわざ計算しなくても表の右下から持ってきたいだけなら次のようにしても大丈夫です。(集計するブロックに名前を付けてそれを参照しても大丈夫です)

合計: src_elisp[:var tbl=20221103-payments[-1,-1] :eval no-export]{tbl} {{{results(=12060=)}}}

ちなみに[-1,-1]の部分は参照先の一部を取り出すための表記です。詳しくは次の辺りをご覧下さい。

別の観点

もちろん集計するキーはどの列でも構いません。

支払先毎の金額を出してみます。

#+BEGIN: aggregate :table "20221103-payments" :cols "支払先 vsum(金額);^N" :cond (equal hline "0")
| 支払先       | vsum(金額) |
|--------------+------------|
| 山梨交通     |       4580 |
| JR東日本     |       3380 |
| 白根御池小屋 |       2300 |
| 北岳肩の小屋 |       1800 |
#+END:

hlineの出力

合計の前に水平線を出すには、 :hline を指定するという手もあります。

#+BEGIN: aggregate :table "20221103-payments" :cols "hline;^n 大分類 vsum(金額);^N" :hline 1
| hline | 大分類 | vsum(金額) |
|-------+--------+------------|
|     0 | 交通費 |       7960 |
|     0 | 食費   |       2100 |
|     0 | 宿泊費 |       2000 |
|-------+--------+------------|
|     1 |        |      12060 |
#+END:

単純に金額だけで(降順)ソートすると合計の12060が先頭に来てしまいます。「hline」列を金額より前にソートすることで合計が下に居続けます。

その上で :hline 1 を指定すると、1列目(hline)の変わり目に水平線が入ります。

hlineの列は邪魔なので ;<> を指定することで非表示にできます。

#+BEGIN: aggregate :table "20221103-payments" :cols "hline;<>;^n 大分類 vsum(金額);^N" :hline 1
| 大分類 | vsum(金額) |
|--------+------------|
| 交通費 |       7960 |
| 食費   |       2100 |
| 宿泊費 |       2000 |
|--------+------------|
|        |      12060 |
#+END:

:hlineの後の数字は列数です。左から指定された列数の列が同じ行をグループ化し、その間に水平線を挿入します。値がソートされていないと正しく挿入できない、と思います。

#+BEGIN: aggregate :table "20221103-payments" :cols "大分類;^a 支払先;^a 小分類;^a vsum(金額);^N" :cond (equal hline "0") :hline 1
| 大分類 | 支払先       | 小分類 | vsum(金額) |
|--------+--------------+--------+------------|
| 交通費 | JR東日本     | 鉄道   |       3380 |
| 交通費 | 山梨交通     | バス   |       4580 |
|--------+--------------+--------+------------|
| 宿泊費 | 北岳肩の小屋 | 幕営   |       1000 |
| 宿泊費 | 白根御池小屋 | 幕営   |       1000 |
|--------+--------------+--------+------------|
| 食費   | 北岳肩の小屋 | 飲料   |        800 |
| 食費   | 白根御池小屋 | 菓子   |        800 |
| 食費   | 白根御池小屋 | 飲料   |        500 |
#+END:
#+BEGIN: aggregate :table "20221103-payments" :cols "大分類;^a 支払先;^a 小分類;^a vsum(金額);^N" :cond (equal hline "0") :hline 2
| 大分類 | 支払先       | 小分類 | vsum(金額) |
|--------+--------------+--------+------------|
| 交通費 | JR東日本     | 鉄道   |       3380 |
|--------+--------------+--------+------------|
| 交通費 | 山梨交通     | バス   |       4580 |
|--------+--------------+--------+------------|
| 宿泊費 | 北岳肩の小屋 | 幕営   |       1000 |
|--------+--------------+--------+------------|
| 宿泊費 | 白根御池小屋 | 幕営   |       1000 |
|--------+--------------+--------+------------|
| 食費   | 北岳肩の小屋 | 飲料   |        800 |
|--------+--------------+--------+------------|
| 食費   | 白根御池小屋 | 菓子   |        800 |
| 食費   | 白根御池小屋 | 飲料   |        500 |
#+END:

orgtbl-aggregateを使わない方法(ソースコードブロックで集計する)

orgtbl-aggregate を使わないのであればソースコードブロックを使うのが普通だと思います。環境に依存せずに使えるのはEmacs Lispだけです。

#+begin_src elisp :var tbl=20221103-payments :colnames no :hlines yes
(let ((key-col 0)
      (value-col 4)
      (start-row 2)
      body)
  ;; 集計
  (cl-loop for line in (nthcdr start-row tbl)
           until (eq line 'hline)
           for key = (nth key-col line)
           for value = (nth value-col line)
           do (cl-incf (alist-get key body 0 nil #'equal) value))
  ;; ドット対をリストに変換
  (setq body (mapcar (lambda (x) (list (car x) (cdr x))) body))
  ;; ソート
  (setq body (sort body (lambda (a b) (> (cadr a) (cadr b) ))))

  `(;; 見出し
    (,(nth key-col (car tbl)) ,(nth value-col (car tbl)))
    ;; 水平線
    hline
    ;; データ本体
    ,@body
    ;; 水平線
    hline
    ;; 合計
    ("合計" ,(cl-loop for (key value) in body sum value))
  ))
#+end_src

#+RESULTS:
| 大分類 |  金額 |
|--------+-------|
| 交通費 |  7960 |
| 食費   |  2100 |
| 宿泊費 |  2000 |
|--------+-------|
| 合計   | 12060 |

あらかじめ関数にでもしておかない限り面倒くさくて仕方がありません。Rなど他の言語を使えば多少はマシかもしれませんが。

自分用の集計用関数を定義しておけば良いのでしょうが汎用性を持たせるのには手間がかかります。それなら orgtbl-aggregate のダイナミックブロックで必要なことが十分手軽に出来ます。

2022-10-30 ,

Org Agendaの長い見出しにインデントを適用する

org-modeでAgenda Viewを見ていたときに、長い見出しが折り返されているのが見づらいことに気がついた。(私は普段org-startup-truncatedをnilにして使っているのでそうなるのだと思う。表の部分にはphscrollを使用している)

Org Agendaで長い見出しが折り返されている様子
図1: Org Agendaで長い見出しが折り返されている様子

折り返し後のテキストを字下げする機能はEmacsに既にあって、wrap-prefixテキストプロパティを使えば良い。

org-agenda.elのorg-agenda-format-item関数を次のように修正する。

        ;; Evaluate the compiled format
-       (setq rtn (concat (eval formatter t) txt))
+       (let ((prefix (eval formatter t)))
+         (setq rtn (concat prefix txt))
  
        ;; And finally add the text properties
-       (remove-text-properties 0 (length rtn) '(line-prefix t wrap-prefix t) rtn)
+         (remove-text-properties 0 (length rtn) '(line-prefix t wrap-prefix t) rtn)
+         (put-text-property 0 (length rtn) 'wrap-prefix (make-string (length prefix) ? ) rtn))
        (org-add-props rtn nil
          'org-category category
            'tags tags
            'org-priority-highest org-priority-highest

すると次のようにスッキリした見た目になった。

Org Agendaの長い見出しにインデントを適用した様子
図2: Org Agendaの長い見出しにインデントを適用した様子

関数を直接修正するとorg-modeのバージョンアップによって壊れる可能性が高いので、せめてadvice-addを使うなどして動作を変えたいところ。なので次のようにしてみた。

(defvar my-org-agenda-format-item-prefix "") ;;formatterが返した値を取っておくための変数。

(defun my-org-agenda-format-item (orig-fun &rest args)
  ;; 元のorg-agenda-format-itemを呼び出す前に
  ;; org-prefix-format-compiledを一時的に書き替える。
  (let* ((org-prefix-format-compiled
          (list
           (car org-prefix-format-compiled)
           ;; formatterを書き替えてしまう。
           ;; 結果を my-org-agenda-format-item-prefix に書き込むように。
           (list 'setq 'my-org-agenda-format-item-prefix (cadr org-prefix-format-compiled))))
         ;; 元のorg-agenda-format-itemを呼び出す。
         (rv (apply orig-fun args)))
    ;; 戻り値にwrap-prefixテキストプロパティを追加する。
    ;; インデントの深さはformatterが返した文字列(prefix)の長さとする。
    (put-text-property 0 (length rv) 'wrap-prefix (make-string (length my-org-agenda-format-item-prefix) ? ) rv)
    rv))

(advice-add #'org-agenda-format-item :around #'my-org-agenda-format-item)

もちろんOrg Agendaバッファのtruncate-linesをtにするという手もある。はみ出した見出しはスクロールしなければ見えなくなるが。

2022-09-08

Emacsが子プロセスを起動するときのコマンドライン引数を直す(Windows版)

Windows版のEmacsで子プロセスを起動するときに正しいコマンドライン引数が子プロセスに伝わらない(場合がある)不具合について調査、改善してみました。

この不具合は様々な現象を引き起こします。例えばgrepに指定した文字列が検索されないといった形で問題が現れます。

原因

調べたところ三つの原因が見つかりました。

  • CreateProcessA(ANSI版、非UNICODE版)を使っている
  • コマンドライン引数の文字エンコーディングが間違っている場合がある
  • Emacsの引数のエスケープ処理に問題がある

一つ目は、Emacsが子プロセスを起動するのにWin32APIのCreateProcessAを使っていることです。CreateProcessAはANSI文字列版(MBCS版)であり、CreateProcessWというWide文字列版(UNICODE版)ではありません。なのでどう逆立ちしても(CP65001でも使わない限り)UNICODEは使用できません。現在のコードページに沿った文字エンコーディング(日本の場合はCP932、ほぼShift_JIS)で表現できる範囲の文字しか引数として使用できません。

二つ目は、Emacsの設定によってはCreateProcessAにUTF-8等の間違った文字エンコーディングの文字列が渡されてしまうことです。ただ、私は普段CP932をデフォルトにしているのでこの原因で問題が起きたことはありません。

三つ目は、Emacsのコマンドライン引数処理に複数の問題があることです。マルチバイト文字を考慮していないため、2バイト目が5Cである文字(いわゆるダメ文字)に反応して余計な\(バックスラッシュ)を挿入してしまいます。また、Cygwin向けのエスケープ処理にも不十分なところがあります。

この辺りの処理はEmacsのソースコードの emacs/src/w32proc.c にあります。

emacs/w32proc.c at 5a223c7f2ef4c31abbd46367b6ea83cd19d30aa7 · emacs-mirror/emacs

修正方法

一つ目の問題を直そうと思ったらEmacsのソースコードを修正してCreateProcessWを使うようにすべきでしょう。自分でビルドせず配布されているバイナリを使いたいのであれば諦めるよりありません。無理矢理何とかする方法も思いつかなくはないですが(他のプログラム、DLLを経由するとか、@でファイルの中身を引数に挿入する仕組みを使うとか)、止めておきます。

二つ目の問題は、事前に文字列をCP932に変換することで回避できます。

三つ目の問題は、 w32-quote-process-args をnilにしてEmacsの問題のある引数処理を抑制しつつ自分で引数を処理することで回避できます。

一つ目はともかく、二つ目と三つ目はEmacs Lispのレベルで何とかできそうです。

Webを探したところ、次のようなページが見つかりました。

UTF-8 をベースとして利用するための設定 - NTEmacs @ ウィキ - atwiki(アットウィキ)

「UTF-8をベースとして」ということですが、CP932のままで使用しようとした場合も結局は同じ問題が発生します(特にダメ文字問題)。

というわけで、そのページの下の方で紹介されているコードを試したところ、概ね問題が解消しました。

「概ね」と書いたのは完全には解消していなかったからです。

そのコードを詳しく見てみると、肝心のコマンドライン引数処理の部分が非常にシンプルな正規表現置換でした。「はて、本当にそれで良いのかな?」と思って色々調べたところ少しだけ改善の余地がありました。

その辺りとともに全体的に自分なりに整理して沢山コメントを入れたのが次のコードです。

;;;; 子プロセスに渡すコマンドライン引数を修正する(NTEmacs用 28.1で確認)

;; Windows版のEmacsで子プロセスを起動するときのコマンドライン引数に関する
;; 二つの問題を修正する。
;;
;; - 文字エンコーディングの問題
;; - エスケープ処理(quoting)の問題

;; Emacsが何をしているのかは emacs/src/w32proc.c の sys_spawnve 関数を
;; 見ること。

;;;;; Cygwinプログラム判定

;; 引数の変換処理方式はCygwinのプログラムかどうかで変える必要がある。
;; Cygwinのプログラムかどうかはexeがcygwin1.dllを必要としているかで判別する。

(defun my-procargfix-cygwin-program-p-no-cache (filename)
  "FILENAMEがCygwinのプログラムならtを返します。(キャッシュ不使用)"
  ;; cygwin1.dllを使っているかで判定する。
  ;; emacs/src/w32proc.c の w32_executable_type も似たような事をしている。
  (with-temp-buffer
    (let ((w32-quote-process-args nil)) ;;lddのCygwin判定(再帰)を抑制する。
      (when (eq (call-process "ldd" nil t nil (concat "\"" filename "\""))
                0)
        (goto-char (point-min))
        (number-or-marker-p
         (re-search-forward "cygwin[0-9]+\.dll" nil t))))))

(defvar my-procargfix-fullpath-cache nil) ;;2022-11-18追加
(defvar my-procargfix-ldd-cache nil)

(defun my-procargfix-cygwin-program-p (filename)
  "FILENAMEがCygwinのプログラムならtを返します。(キャッシュ使用)"
  (let ((abs-fname (and (stringp filename)
                        ;;(executable-find filename) ;;9ms! Very Slow!! ;;2022-11-18削除
                        (or (cdr (assoc filename my-procargfix-fullpath-cache))
                            (cdar (push (cons filename (executable-find filename)) my-procargfix-fullpath-cache))) ;;2022-11-18追加
                        )))
    (when abs-fname
      (or
       (cdr (assoc abs-fname my-procargfix-ldd-cache))
       (let ((cyg-p (my-procargfix-cygwin-program-p-no-cache abs-fname)))
         (push (cons abs-fname cyg-p) my-procargfix-ldd-cache)
         cyg-p)))))

;;2022-11-18追記: executable-findは滅茶苦茶遅いのでキャッシュするようにした。しかしファイルを移動したときはキャッシュのクリアが必要になってしまった。
(defun my-procargfix-clear-cache ()
  (interactive)
  (setq my-procargfix-fullpath-cache nil)
  (setq my-procargfix-ldd-cache nil)) ;;2022-11-18追加


;;;;; 引数の変換処理

;; CreateProcessにコマンドライン引数を引き渡すとき、全ての引数を一つの
;; 文字列に結合しなければならない。
;; そのために各引数を二重引用符で囲み範囲を明確にする必要がある。
;; 特殊な意味に用いられる文字はエスケープしなければならない。
;; また、CreateProcessAを使用しているので確実にCP932(日本であれば)
;; でエンコードする必要がある。

(defun my-procargfix-quote-for-cygwin (arg)
  "Cygwinプログラムへの引数ARGを二重引用符で囲みます。"

  ;; Cygwinが渡された引数をどのように展開するかは次のソースを参照すること。
  ;; https://github.com/mirror/newlib-cygwin/blob/master/winsup/cygwin/dcrt0.cc

  ;; 基本的には \ と " をエスケープする。
  ;;  \ => \\
  ;;  " => \"
  (cond
   ;; ただし変換後(囲った後)が "\\server\..." の形式になると
   ;; Cygwin側でエスケープシーケンスの処理が抑制されてしまう。
   ;; 例えばgrepで \sXXX\s と書くと変換後の "\\sXXX\\s" は
   ;; この形式にマッチしてしまう。
   ;; こうなるとCygwin側に渡したときに元の文字列に戻らない。
   ;; なので、変換後の先頭が\二文字になるのを避ける。
   ;; 変換前の先頭に\が一文字ならば、それはエスケープしないようにする。
   ;; 変換後が "\sXXX\\s" ならば \sXXX\s に戻る。
   ((and (>= (length arg) 2)
         (= (elt arg 0) ?\\)
         (/= (elt arg 1) ?\\)
         (/= (elt arg 1) ?\")) ;;is_dos_pathの後半部分に相当
    (concat "\""
            (substring arg 0 2)
            ;; 最初の2文字より後は通常通りエスケープする。
            (replace-regexp-in-string "[\\\\\"]" "\\\\\\&" arg t nil nil 2)
            "\""))
   ;; また、同様に "C:..." の形式でも
   ;; Cygwin側でエスケープシーケンスの処理が抑制されてしまう。
   ;; 例えばgrepで A:\sXXX\s と書くと変換後の "A:\\sXXX\\s" は
   ;; この形式にマッチしてしまう。
   ;; 先頭のアルファベットを二重引用符の外に出すことで回避する。
   ;; 変換後が A":\\sXXX\\s" ならば A:\sXXX\s に戻る。
   ((and (>= (length arg) 2)
         (or (<= ?A (elt arg 0) ?Z)
             (<= ?a (elt arg 0) ?z))
         (= (elt arg 1) ?:)) ;;isdriveに相当
    (concat (substring arg 0 1)
            "\""
            ;; 最初の1文字より後は通常通りエスケープする。
            (replace-regexp-in-string "[\\\\\"]" "\\\\\\&" arg t nil nil 1)
            "\""))
   ;; 通常のケース。
   ;;  \ => \\
   ;;  " => \"
   (t
    (concat "\""
            (replace-regexp-in-string "[\\\\\"]" "\\\\\\&" arg t)
            "\"")))
  ;; 注意: CYGWIN=noglobの場合はサポートしない。
  ;; noglobが指定されると、単に二重引用符の間がそのまま出力されるようになる。
  ;; その時二重引用符は強制的に消される(quoted関数、winshell=0時の動作)。
  ;; 二重引用符の中に""や\"を書いても特別扱いされない。
  ;; "ABC""DEF" => ABCDEF
  ;; "ABC\"DEF" => ABC\DEF
  ;; こうなると二重引用符を再現するのはどうやっても不可能となる。
  )

(defun my-procargfix-quote-for-windows (arg)
  "通常のWindowsプログラムへの引数ARGを二重引用符で囲みます。"
  ;; 通常のWindowsプログラムがコマンドライン引数をどのように展開するかは
  ;; 次のページを参照すること。
  ;; https://docs.microsoft.com/ja-jp/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170

  ;; 二重引用符で囲った上で、
  ;; 0個以上の\の後に"が来るケースだけ処理すれば十分なはず。
  ;; \{0..n個}" => \{n*2個}\"
  (concat "\""
          (replace-regexp-in-string "\\(\\\\*\\)\"" "\\1\\1\\\\\"" arg)
          "\""))

(defun my-procargfix-convert-prog-args (prog-name prog-args)
  "コマンドPROG-NAMEに引き渡す引数リストPROG-ARGSを変換します。"
  ;; 独自のエスケープ処理をする。
  (setq prog-args
        ;;この中からcall-processが呼ばれる場合があることに注意
        (mapcar (if (my-procargfix-cygwin-program-p prog-name)
                    #'my-procargfix-quote-for-cygwin
                  #'my-procargfix-quote-for-windows)
                prog-args))

  ;; 確実にCP932にする。
  ;; CreateProcessA(ASCII版)に引き渡すコマンドライン引数のエンコーディングは
  ;; 現在のコードページのものでなければならない。
  ;; 最終的にUTF-16に変換されてサブプロセスに渡されるので、
  ;; どのみち自由なバイト列は指定できない。
  (setq prog-args
        (mapcar (lambda (arg)
                  (if (multibyte-string-p arg)
                      (encode-coding-string arg 'cp932)
                    arg))
                prog-args))
  prog-args)

;;;;; 子プロセス起動関数の書き替え

;; call-process、call-process-region、start-processにadviceを仕掛ける。
;; コマンドライン引数部分を事前に変換する。

(defun my-procargfix-apply (orig-fun fun-args prog-pos args-pos)
  "子プロセスを呼び出す関数をコマンド引数部分を修正して呼び出します。

呼び出す関数はORIG-FUN、その関数に引き渡す引数はFUN-ARGS、プログ
ラム名はFUN-ARGSのPROG-POS番目、コマンド引数部分はFUN-ARGSの
ARGS-POS番目以降です。"
  ;; 関数引数リスト(fun-args)のコマンド引数部分(args-pos以降)を加工する。
  ;; すでにw32-quote-process-argsがnilのときは余計なことはしない。
  (when w32-quote-process-args
    (setf (nthcdr args-pos fun-args)
          (my-procargfix-convert-prog-args
           (nth prog-pos fun-args)
           (nthcdr args-pos fun-args))))

  ;; (2023-05-08追記: 空白を含む実行ファイル(Program Files)を起動したときに引数列がおかしくなる問題に対処した)
  ;; 実行ファイルパスを加工する。
  ;; w32-quote-process-argsをnilにすると実行ファイルパスが二重引用符で
  ;; 囲まれなくなる。そうすると空白を含む実行ファイルパスが正しく呼び
  ;; 出し先のargv[0]に設定されなくなる。
  ;; w32-quote-process-argsをnilにする以上どうしようも無いので、
  ;; 空白文字を含む場合はショートファイル名にして回避する。
  ;; ただし、exeやbatが省略された場合はうまく行かないかもしれない。
  (when w32-quote-process-args
    (let ((prog-name (nth prog-pos fun-args)))
      (when (seq-contains-p prog-name ?  #'eq)
        (setf (nth prog-pos fun-args)
              (or (w32-short-file-name prog-name) prog-name)))))

  ;; Emacsのエスケープ処理を抑制して元の関数を呼び出す。
  ;;(message "args=%s" fun-args)
  (let ((w32-quote-process-args nil))
    (apply orig-fun fun-args)))

(defmacro my-procargfix-add-advice (target-func prog-pos args-pos)
  (let ((ad-func
         (intern (format "my-procargfix-advice--%s" target-func))))
    `(progn
       (defun ,ad-func (orig-fun &rest fun-args)
         (my-procargfix-apply orig-fun fun-args ,prog-pos ,args-pos))
       (advice-add (quote ,target-func)
                   :around
                   (quote ,ad-func)
                   '((depth . 99))))))

(my-procargfix-add-advice call-process        0 4)
(my-procargfix-add-advice call-process-region 2 6)
(my-procargfix-add-advice start-process       2 3)
;;@todo make-processには対応していない。

テスト:

// コマンドライン引数をそのまま表示するだけのプログラム。
// echoargs-cyg.exe : Cygwinのg++でビルドした物。
// echoargs-vc.exe : VC++でビルドした物。
#include <iostream>

int main(int argc, char *argv[])
{
    for(int i = 0; i < argc; ++i){
        std::cout << i << ":" << argv[i] << std::endl;
    }
}
(defun my-procargfix-test-exec (program arg)
  "PROGRAMをコマンドライン引数としてARGを与えて起動する。表示された引数文字列を回収してリストで返す。"
  (let (;;(coding-system-for-read 'utf-8-dos)
        (coding-system-for-read 'cp932-dos)
        (buffer (get-buffer-create "*Output*")))
    (with-current-buffer buffer
      (delete-region (point-min) (point-max))
      (call-process (expand-file-name program) nil buffer t arg)
      (goto-char (point-min))
      (cl-loop while (re-search-forward "^[0-9]+:\\(.*\\)$" nil t)
               collect (match-string 1)))))

(defun my-procargfix-test-exec-exam (program arg)
  (equal (cdr (my-procargfix-test-exec program arg))
         (list arg)))

;; 実行して元に戻るかを確認する。
(let ((test-cases
       '("abc"
         "Program Files"
         "abc\"\" \\\\\\\""
         "abc\"def\"\"ghi\"\"\"jkl\"\"\"\"mno"
         "abc\\def\\\\ghi\\\\\\jkl\\\\\\\\mno"
         "abc'def''ghi'''jkl''''mno"
         ;; 先頭がネットワークパスになりかねないケース
         "\\sABC\\s"
         "\\sABC"
         "\\\"sABC"
         "\\\\sABC"
         ;; ドライブレターで始まるケース
         "c:\\sABC\\s\"DEF\""
         ;; 
         ""
         ;; ダメ文字
         "表示"
         "\\表\\示"
         ;; GLOBっぽい文字列
         "*.el"
         "~/"
         ))
      (ok 0)
      (ng 0))
  (dolist (program '("echoargs-vc.exe" "echoargs-cyg.exe"))
    (dolist (arg test-cases)
      (if (my-exec-test program arg)
          (cl-incf ok)
        (cl-incf ng)
        (message "Fail %s" arg))))
  (message "OK:%s, NG:%s" ok ng))

最後に

Emacsからgrepを使ったときに問題が起きることがあるのは昔から気がついていました。ただ、色々と工夫するとたいていの場合問題を回避できてしまうのであまり深く追求することはありませんでした。

そもそも素のgrepはCP932に対応していないので普段あまり使っていません。日本で使われている複数の文字エンコーディングに対応したgrepとして昔はlv(をlgrepにリネームした物。Emacsのlgrepコマンドと紛らわしいので以下単にlv)を使用していました。しかしlvはGNU grepよりも機能が劣るため、grepの設定(grep-command等)を流用するパッケージでたまに問題が起きました(grepにあるオプションが存在しない等)。そこで最近はlvをエンコーディング変換にのみ使用してgrepとパイプで繋げるスクリプト(lvgrep)を作成して使っていました。そうこうするうちにCounselでripgrepを使うようになりました。なので外部のgrepコマンドはripgrepで統一してしまおうとgrep-commandにrgを指定したのですが様々な問題が発生しました。そのうちの一つが今回のダメ文字によって引数が壊れるというものでした。

正直ここまで面倒くさいことになるとは思いませんでした。たかがgrepを完全に動作させたいだけなのに。Emacsは(Windowsでは)grepもまともに使えないエディタなんです。

だいたいM-x grepのインタフェースは原始的すぎますよね。他にもlgrepとかrgrepとかzgrepとかzrgrep(rzgrep)とかgrep-find(find-grep)とかfind-grep-diredとかgrepと名の付くものはいったいEmacsに幾つ入っているのか調べてしまいましたよ。Emacsの機能だけを使ったgrepも悪くないと思うんですけどね。moccur-grepとか。外部のコマンドに依存しないのでWindowsでも安定して動きます。まぁ、速度は多少遅いかもしれませんが。diredからA(dired-do-find-regexp)とかQ(dired-do-find-regexp-and-replace)とかも結構便利だったりします。最近はconsult-ripgrepを使ってみています。Embarkで検索結果を普通のgrepのように別バッファに移したりも出来ます。grep一つとってもEmacsはとても難しくてなかなか人にお勧めできないエディタだなとつくづく思う次第です。

そういえば今回の問題に関連すると思われるメーリングリストの投稿を見ました。

https://lists.gnu.org/archive/html/bug-gnu-emacs/2013-01/msg01211.html

2013年だからもうずいぶん前ですよね。私は英語がとても苦手で中学時代にずっと2を取り続けたくらいなので正確な意味は分かりませんが、コマンドライン引数のエンコーディングに制限を課すべきでは無いと言っているのでしょうか。どうなんでしょうね。まずエスケープ処理をしている段階で既に特定のエンコーディングに制限されてしまっています。文字が\か"か判別する方法はエンコーディングによって変わりますからね。Cygwinですら今やマルチバイト文字を考慮してargvを組み立ててますよ。第一CreateProcessに渡すコマンドライン引数はA版なら現在のコードページ、W版ならUTF-16と決まっています。A版には何でも好きなバイト列を放り込めるわけでは無く、それはUTF-16に変換されて子プロセスに引き渡されます。spellerというのが何か知りませんが、そんな使い方をする人が実際にいるのでしょうか。少なくともWindowsではいないでしょう(出来ないので)。制限を課したくないと言いながら文字列をぶっ壊して特定の文字列を使えなくしてOSにはないEmacs独自の制限を新たに付け加えているのですから何を言っているんだろうという感じです。

誰かこの部分CreateProcessWでUNICODE化してくれませんかね。

既にWSL2上に移行した人も居るようですし今更感もある話題ですが、私はまだまだWindowsのネイティブ版を使い続けるつもりなので今後もおかしな挙動に悩まされ続けることでしょう。

2022-08-29 ,

折りたたみ状態によって見出しのマークを切り替える(org-mode)

org-modernを入れたのでこれまで使っていたorg-bulletsをお払い箱にして見出しの表示設定を調整しました。

これまで私が使っていた全角■●▲(←全角で表示されていますか?)等はどうにも野暮ったかったので、半角で表示される右三角にしてみたところ結構いい感じになりました。しかし右三角を使うと、開いたときに下向き三角になって欲しい気がしてしまいます。というわけでやってみました。

結果:

TABキーによって見出しのマークが切り替わる様子
図1: TABキーによって見出しのマークが切り替わる様子

コード:

;; org-modern.el (2022-12-22) に対する変更

;; まずは深さ毎の見出しマーク文字列(展開時、折りたたみ時の両方)をあらかじめ組み立てます。
;; org-modernではorg-modern-modeを起動したときにできるだけpropertizeした文字列を
;; 変数にキャッシュしておくようになっているので、それに倣いました。

(defvar-local org-modern--open-star-cache nil)
(defvar-local org-modern--folded-star-cache nil)

(defun my-org-modern--cache-star ()
  ;; 状態によって次の記号を使う。
  ;;  open(unfolded): BLACK RIGHT POINTING TRIANGLE (U+25B6)
  ;;  folded: BLACK DOWN POINTING SMALL TRIANGLE (U+25BE)
  ;; (SMALLを使ったのは手元の環境できっちり半角で表示される下向き黒三角がこれだけだったので)
  ;; (2022-12-22削除:深さに応じて先頭に空白を入れる。)
  ;; (2022-12-22追加:深さに応じて先頭に空白を入れるには org-modern-hide-stars に空白文字を指定すること。org-modern 0.6以降の機能)
  ;; この辺は好みで。
  (setq
   org-modern--open-star-cache
   (vconcat (cl-loop for level from 1 to 10
                     ;; (2022-12-22修正:本家でpropertizeを使うコードがorg-modern--symbolに変わったので追従。また、levelに応じて空白を入れるのを止めた)
                     collect (org-modern--symbol "▾")))
   org-modern--folded-star-cache
   (vconcat (cl-loop for level from 1 to 10
                     ;; (2022-12-22修正:本家でpropertizeを使うコードがorg-modern--symbolに変わったので追従。また、levelに応じて空白を入れるのを止めた)
                     collect (org-modern--symbol "▶")))))
(advice-add #'org-modern-mode :before #'my-org-modern--cache-star)

;; 次に折りたたみ状態に(開閉状態)によってfontify時に使うキャッシュを切り替えます。
;; 折りたたみ状態は見出し行の直後が不可視状態になっているかで判断しています。

(defun my-org-modern--star-around (original-fun &rest args)
  "Prettify headline stars."
  ;; 開閉状況によって org-modern--star-cache を切り替える。
  (let* ((folded (invisible-p (line-end-position)))
         (org-modern--star-cache (if folded
                                     org-modern--folded-star-cache
                                   org-modern--open-star-cache)))
    (apply original-fun args)))
(advice-add #'org-modern--star :around #'my-org-modern--star-around)

;; 最後に折りたたみ状態が切り替わったときに見出し行をfontifyし直します。
;; org-modeがセクションを表示したり非表示にしたりするとき、必ず
;; org-flag-regionやoutline-flag-regionが呼ばれます。
;; 表示/非表示する範囲の一行前くらいから見出し行を抽出してfont-lock-flushで
;; 再fontifyを促します。

(defun my-org-modern-flush-headings (from to flag)
  (save-match-data
    (save-excursion
      (goto-char from)

      ;; 1行前から更新する。更新すべき見出しが先行しているかもしれないので。
      (forward-line -1)

      ;; 閉じるときは一行前からFROMまでを処理すれば十分。
      ;; FROM以降は隠されて見えないし、開くときはflag=nilでここが呼ばれる。
      (when flag ;;hide region FROM..TO
        (setq to from))

      (while (re-search-forward (concat "^" org-outline-regexp) to t)
        (font-lock-flush (line-beginning-position)
                         (min (1+ (line-end-position)) (point-max)))))))

(defun my-org-modern-flag-region-advice (original-fun from to flag &rest args)
  (apply original-fun from to flag args)
  ;; org-modeやoutline-modeでFROMからTOまでを表示したり隠したりしたときに、
  ;; その中にある見出し行をfont-lockし直す。
  ;; font-lock側では現在の開閉状況によって見出し行を変化させる。
  (my-org-modern-flush-headings from to flag))

(advice-add #'outline-flag-region :around #'my-org-modern-flag-region-advice)

;; (2022-12-22修正:Org 9.6からorg-flag-regionはobsoleteになってorg-fold-core-regionが使われるようになったので修正。)
(if (version<= "9.6" (org-version))
    (when (fboundp 'org-fold-core-region)
      (advice-add #'org-fold-core-region :around #'my-org-modern-flag-region-advice))
  (when (fboundp 'org-flag-region)
    (advice-add #'org-flag-region :around #'my-org-modern-flag-region-advice)))

今回のことでorg-mode(やoutline-mode)が領域を表示/非表示にする流れについて理解が深まりました。

以前、折りたたみ状態によって見出し行の大きさや行間スペースを変えたいと思ったこともあるので、今回の応用でそういったことも可能になるかもしれません。

2022-08-27 ,

org-modernとorg-indentを併用したときの表の乱れを直す

org-modern

先日org-modernを試してみました。org-modeの各部の見た目を綺麗に(モダンに)してくれます。

そんな中で私が最も気に入ったのは、表(テーブル)の線を綺麗にしてくれるという点です。

org-modernで見た目を改善したorg-modeの表
図1: org-modernで見た目を改善したorg-modeの表

もはやorg-modeと言われなければ分からないと思います。

いくつか問題もあって、 org-table-toggle-coordinate-overlays による座標表示は機能しなくなります。まぁ、仕方ないですね。(2022-08-27追記: org-table-toggle-column-width あたりも問題がありますね。これはちょっと気になるかなぁ)

その辺りは許容するとして、私が使っていて最も気になったのはorg-indentと併用した際の問題です。org-indentは階層に応じて左にインデントをつけてくれるモードです。org-modernはこのorg-indentと相性が悪いです。例えばブロック(#+begin_???から#+end_???までの間)の左に装飾を入れる機能があるのですが、これはorg-indentが有効になっているときは機能しません。

org-indentの併用で表に発生する問題

表の線についてはorg-indent使用時でも一見正しく機能するように見えましたが、よく見ると二つほど問題がありました。

  • 横線の下に大きく空白が空く
  • ウィンドウの先頭にある行がインデントされない
修正前
図2: 修正前

原因

横線の下に大きく空白が空く原因は、org-indentが挿入する空白文字が高さを持ってしまっているところにあります。高さを持っている以上、その行がそれ以上縮まることはありません。

ウィンドウの先頭がインデントされない原因は、先日書いたEmacsのバグにあります。

原因
図3: 原因

修正

org-indentで挿入する空白の高さを1ピクセルにする

org-indentが挿入する文字列が高さを持ってしまっていることが原因なので、それを無くせば良いのです。文字列では無くdisplayプロパティのspace指定を使うことで高さ1ピクセルの空白を作ります。

;; org-indentを使っていると表の水平線の高さが狭まらない問題を修正する。
;; インデントの空白文字列の高さよりも小さくならないのが原因。
;; インデントの空白文字列をdisplayプロパティで高さ1pxのspaceに置き換える。
;; @todo wrap-indentも修正すべき?
(defun my-org-indent--compute-prefixes-after ()
  ;; org-indent--text-line-prefixesはレベル毎のline-prefix。
  ;; org-indent--compute-prefixesがそれを計算した後ここが呼ばれる。
  (let ((prefixes org-indent--text-line-prefixes))
    ;; 各レベルのprefixを修正する。
    (dotimes (i (length prefixes))
      (let* ((space-str (aref prefixes i))
             (space-length (length space-str)))
        (when (> space-length 0)
          (aset prefixes i
                ;; テキストプロパティ
                ;; display (space :width i :height (1))
                ;; を追加する。
                ;; つまり、line-prefixとして空白文字ではなく高さ1pxのspace
                ;; が表示されるようになる。
                ;; これによって、(prefix以外の)行の高さが正しく反映される
                ;; ようになる。これまでは行が小さくなってもprefixの空白文
                ;; 字の高さより小さくならなかった。
                (org-add-props
                    space-str
                    nil
                  'display (cons 'space
                                 (list :width space-length
                                              ;; (list (* space-length
                                              ;;          (frame-char-width)))
                                       :height '(1))))))))))
(advice-add #'org-indent--compute-prefixes :after
            #'my-org-indent--compute-prefixes-after)

元々このorg-indentの空白文字を入れるという挙動は、org-modernに限らず他でも同様の問題を引き起こす可能性があるはずです。例えばfaceをカスタマイズして特定の行の文字サイズを小さくしたとき、文字は小さくなったのに行が一緒に小さくならなず無駄に空白が空くという問題が生じる可能性があります。

表の縦線部分をdisplay (space …)ではなくdisplay " "にする

先日書いた通りこの問題はdisplayプロパティにspaceではなく文字列を指定すれば発生しません。文字列にすると幅と高さを自由に指定できなくなってしまいますが、face属性に:height 0.1を設定することで極小文字にして回避します。

;; org-modern--tableを差し替える。
(defun org-modern--table ()
  "Prettify vertical table lines."
  (save-excursion
    (let* ((beg (match-beginning 0))
           (end (match-end 0))
           (tbeg (match-beginning 1))
           (tend (match-end 1))
           ;; Unique objects
           (sp1 (list 'space :width 1))
           (sp2 (list 'space :width 1))
           (color (face-attribute 'org-table :foreground nil t))
           (inner (progn
                    (goto-char beg)
                    (forward-line)
                    (re-search-forward "^[ \t]*|" (line-end-position) t)))
           (separator (progn
                        (goto-char beg)
                        (re-search-forward "^[ \t]*|-" end 'noerror))))

      ;; 横線を引く
      (goto-char beg)
      (when separator
        ;; overlineを引いて高さを縮める
        (when (numberp org-modern-table-horizontal)
          (add-face-text-property tbeg tend `(:overline ,color) 'append)
          (add-face-text-property beg (1+ end) `(:height ,org-modern-table-horizontal) 'append))
        ;; 横幅を1文字分確保する(縦線部分以外)
        (while (re-search-forward "[^|+]+" tend 'noerror)
          (let ((a (match-beginning 0))
                (b (match-end 0)))
            ;; TODO Text scaling breaks the table formatting since the space is not scaled accordingly
            (cl-loop for i from a below b do
                     (put-text-property i (1+ i) 'display
                                        (if (= 0 (mod i 2)) sp1 sp2))))))

      ;; 縦線を引く
      (goto-char beg)
      (while (re-search-forward
              "-+\\(?1:+\\)-\\|\\(?:^\\|[- ]\\)\\(?1:|\\)\\(?:$\\|[- ]\\)"
              end 'noerror)
        (let ((a (match-beginning 1))
              (b (match-end 1)))
          (cond
           ((and org-modern-table-vertical (or (not separator) inner))
            (add-text-properties
             a b
             `(;; vertical lineにspaceを使うとウィンドウ先頭でline-prefixが効かなくなる。
               ;;display (space :width (,org-modern-table-vertical))
               display
               " "
               face
               (:inherit org-table :inverse-video t)
               ))
            (add-face-text-property a b`(:height 0.1) 'append) ;;0.1の部分は,org-modern-table-verticalとしたいところだけどピクセル数で指定されるので無理。
            )
           ((and org-modern-table-horizontal separator)
            (put-text-property
             a b
             ;; vertical lineにspaceを使うとウィンドウ先頭でline-prefixが効かなくなる。
             ;;'display `(space :width (,org-modern-table-vertical))
             'display " "))
           (t (put-text-property a b 'face 'org-hide)))))
      )))

結果

修正後
図4: 修正後
2022-08-18

display spaceを併用するとウィンドウの先頭でline-prefixが効かなくなる件(Emacs Bug?)

org-modernを試したときに表の先頭が(org-indentによって)インデントされていないことに気がついた。(Emacs 28.1で確認)

ウィンドウの先頭部分が崩れている表
図1: ウィンドウの先頭部分が崩れている表

原因を調べたところ、org-modernに限らず次の条件で問題が起きることが分かった。

  • 行の先頭に対してline-prefixテキストプロパティとdisplayテキストプロパティの両方が指定されている
  • displayプロパティに(space …)を指定している
  • その行がウィンドウの先頭にある

この条件を満たすとき、なぜかline-prefixの効果が消えてしまう。

再現するコードは次の通り。

(progn
  ;; ウィンドウの先頭へ移動
  (goto-char (window-start))
  ;; line-prefixとdisplayの両方のテキストプロパティを持つテキストを挿入
  (insert (propertize "TEXT" ;;←この文字列はdisplayプロパティで置換される
                      'line-prefix "[PREFIX-STR]" ;;←行の前にこれが表示されるはず
                      'display '(space :width 1) ;;NG
                      ;;'display (svg-image (svg-create 10 10)) ;;OK
                      ;;'display "[DISPLAY-STR]" ;;OK
                      )))

displayテキストプロパティの値が(image …)の場合や単なる文字列の場合この現象は起きない。例えば単なる文字列の場合は[PREFIX-STR][DISPLAY-STR]と表示されるが、(space …)の場合は[PREFIX-STR]が表示されず1文字分の空白が表示されるだけとなる。

回避方法はちょっと思いつかない。いっそ表の部分はインデントを全部無効化するとか?

最初phscrollのせいかと思ったがそういうわけでは無さそうだ。

以前にもline-prefixでマウス入力の座標がずれる問題に遭遇したことがある。Emacsのソースコードを確認していないが、この辺りの処理には何らかの構造的な問題があるのかもしれない。私の経験的にもレイアウト処理というのはちゃんと設計しないと複雑怪奇なものになりがちだ。

それにしてもorg-modernの罫線の引き方、displayテキストプロパティの(space :width (1)) で幅1pxの空白を作って、faceに:inverse-video tを指定することで実現してるんだ! だからline-spacingがあっても隙間無く線が表示される。そんな方法考えもしなかった。

(insert
 ;; 1行目 赤い縦線にABC 行間は10
 (propertize "X"
             'display '(space :width (1))
             'font-lock-face '(:inverse-video t :foreground "red"))
 (propertize "ABC\n" 'line-spacing 10)
 ;; 2行目 赤い縦線にDEF
 (propertize "X"
             'display '(space :width (1))
             'font-lock-face '(:inverse-video t :foreground "red"))
 "DEF")

あー、透明な画像で1pxの空白を作れば回避可能かもしれない。でも全行に画像を挿入しまくるのもなぁ……。

(2022-08-27追記: org-modernでorg-indentを使っていると表のウィンドウ先頭部分がインデントされない問題は、displayテキストプロパティを" "にしてfaceの:heightを0.1にすることで回避した。org-modern–tableを色々といじると直せる。ちなみにこれとは別の話だが、org-indentを使っていると表の水平線の下に空白が空いてしまう問題は、org-indentが挿入する空白文字列の高さを小さくすることで回避できる。詳しくはorg-modernとorg-indentを併用したときの表の乱れを直すに書いた)

2022-08-16 ,

org-modeの起動時間を短縮する(org-babel-load-languages編)

org-modeの読み込みは非常に遅い。

原因は色々あるが、その一つが org-babel-load-languages の読み込みである。 org-babel-do-load-languages 関数によって、 org-babel-load-languages で指定されている全ての言語バックエンド(ob-???.el)を起動時に読み込んでしまい、その結果数秒も待たされることがある。

解決策はいくつか考えられる。一つはorg-mode起動後にタイマーによって少しずつ読み込んでいく方法。もう一つは必要になってから必要な言語だけ読み込む方法。

今回は後者を実現する。

ただし、この方法はorg-modeのバージョンアップによって機能しなくなる可能性が前者の方法より高い。org-modeは起動時に全ての言語バックエンドが読み込まれていることが前提で書かれており、言語バックエンドが必要なところで必ず呼び出す関数などは存在しない。強いて言えば、 (intern (concat "org-babel-???:" lang)) のようなコードによって org-babel-???:??? のようなシンボルを生成している箇所があちこちに存在する(grep '"org-babel-.*:'等で検索すると良い)。今回はそのような場所を詳しく調査することで半ば場当たり的に言語バックエンドが必要な箇所に処理を挟んで遅延読み込み処理を追加した。従ってorg-modeのバージョンアップに弱くなっている。

しかしながら調査した結果、大半は一つのパターンで対処が可能であることが分かった。ほとんどの場合、言語バックエンドを必要とする処理の前にはソースブロックの言語名を取得する処理が入っており、それは (org-element-property :language element) のような形になっている。このコードはorg-elementで解析した構文要素オブジェクトから言語名プロパティを取得するものだ。このコードで返すのは#+begin_srcの後の言語名なので、 org-element-property にadviceをかけて戻り値の言語名を元に言語バックエンドを読み込んでしまえば良い。 org-element-property は頻繁に呼ばれる関数なのであまり気は進まないが、少なくとも改変の影響はorg-modeの範囲に留まる(intern等に引っかけるよりはマシである)。

org-babel-get-src-block-infoorg-babel-lob-get-info のようなソースブロックの情報を返す関数も、結局はorg-elementで解析を行い要素の:languageプロパティを取得している。

まれにそれ以外の方法で言語名を生成している場合がある。例えばob-table.elには"emacs-lisp"のように言語名をハードコードしている箇所がある。また、 org-babel-enter-header-arg-w-completion 関数に対して (match-string) の値を言語名として引き渡している箇所もある。このようなケースには個別に対処する必要がある。

;; 使い方:
;; 1. org-babel-load-languagesの値はCustomizeの方ではnilにしておくこと。
;; 2. (with-eval-after-load "org"
;;      (load "このコードを含むファイル")) などとする。

;; 使用する言語名とそれを提供するelファイル名の一覧。
(defvar my-org-babel-languages
  ;;(言語名 . ob-ファイル名.el)
  '((elisp . emacs-lisp)
    (emacs-lisp . emacs-lisp)
    (makefile . makefile)
    (ditaa . ditaa)
    (dot . dot)
    (plantuml . plantuml)
    (perl . perl)
    (cpp . C)
    (C++ . C)
    (D . C)
    (C . C)
    (js . js)
    (java . java)
    (org . org)
    (R . R)
    (python . python)
    (shell . shell)
    (sh . shell)
    (bash . shell)
    (zsh . shell)
    (fish . shell)
    (csh . shell)
    (ash . shell)
    (dash . shell)
    (ksh . shell)
    (mksh . shell)
    (posh . shell)))

(defun my-org-babel-language-files ()
  "重複しない全ての言語バックエンドファイル名を返す。"
  (seq-uniq (mapcar #'cdr my-org-babel-languages)))

;; my-org-babel-languagesからorg-babel-load-languagesを設定する。
;; org-lintやorg-pcompleteにorg-babel-load-languagesを使った処理がある
;; ようなので。
;; このときcustom-set-variablesを使わないようにすること。
;; org-babel-do-load-languagesが呼ばれて全部読み込まれてしまうので。
(setq org-babel-load-languages
      (mapcar (lambda (lang) (cons lang t)) ;;(emacs-lisp . t)のような形式
              (my-org-babel-language-files)))

(defun my-org-require-lang-file (lang-file-name)
  "ob-LANG-FILE-NAME.elを読み込む。"
  (when lang-file-name
    (require (intern (format "ob-%s" lang-file-name)) nil t)))

(defun my-org-require-lang (lang)
  "LANGを読み込む。"
  (my-org-require-lang-file
   (alist-get
    (if (stringp lang) (intern lang) lang)
    my-org-babel-languages)))

(defun my-org-require-lang-all ()
  "全ての言語を読み込む。"
  (mapc #'my-org-require-lang-file
        (my-org-babel-language-files)))

;; org-elementで言語名を返す時、その言語をロードする。
(advice-add #'org-element-property :around #'my-org-element-property)
(defun my-org-element-property (original-fun property element)
  (let ((value (funcall original-fun property element)))
    (when (eq property :language)
      (my-org-require-lang value))
    value))

;; ob-table.elに(org-babel-execute-src-block nil (list "emacs-lisp" "results" params))のような呼び出し方をする所があるので。
(advice-add #'org-babel-execute-src-block :around
            #'my-org-babel-execute-src-block)
(defun my-org-babel-execute-src-block (original-fun
                                       &optional arg info params)
  (my-org-require-lang (nth 0 info))
  (funcall original-fun arg info params))

;; (match-string)の値を直接langとして渡しているので。
(advice-add #'org-babel-enter-header-arg-w-completion :around
            #'my-org-babel-enter-header-arg-w-completion)
(defun my-org-babel-enter-header-arg-w-completion (original-fun
                                                   lang)
  (my-org-require-lang lang)
  (funcall original-fun lang))

;; org-lint(org-lint-wrong-header-argument, org-lint-wrong-header-value)内で参照しているので。
;; 面倒なので全部読み込んでしまう。
(advice-add #'org-lint :around #'my-org-lint)
(defun my-org-lint (original-fun &rest args)
  (my-org-require-lang-all)
  (apply original-fun args))

;; 他にもinfoやlangを引数に取るような関数がある。
;; my-org-element-propertyやorg-babel-get-src-block-info等を使ってlangや
;; infoを取得していれば問題ないが、予期していない方法でlangやinfoを取得し
;; ている場合は対処する必要がある。

と、書いた後にorg-modeのメーリングリストに次のような投稿を見つけた。

Load Org Babel Languages on Demand

見たところ org-src--get-lang-modeorg-babel-confirm-evaluate にadviceを追加して似たようなことをしている。 org-src--get-lang-mode 関数は現在見当たらないが何か変更があったのだろうか( org-src-get-lang-mode はあるので改名された?)。

2020年の投稿だがその後どうなったのかは不明。

ちゃんと修正するのであれば、まずはあちこちに散らばっている org-babel-*:* シンボルを組み立てる処理を関数にまとめるのが良さそう。そしてその関数から言語名に対応するob-ファイルを読み込むようにすれば良い。有効言語の設定変数をどうするかは迷い所だが org-babel-load-languages の形式を拡張できるかもしれないし、あるいは別に変数を用意しても良さそうだ。

2022-08-15

Ivy/CounselからVertico/Consultへ移行~補完候補以外を選びづらい問題

通常バッファ内での補完をcompanyからcorfuに変えたので、ミニバッファ補完もivyからverticoへ変えてみた。

設定:

(vertico-mode)
(setq vertico-cycle t) ;;最初と最後の候補を行き来できるようにする
(setq completion-styles '(basic substring partial-completion flex)) ;;適当

;; 大文字小文字の区別をしない
(setq read-file-name-completion-ignore-case t
      read-buffer-completion-ignore-case t
      completion-ignore-case t)

;; 候補更新時に最初の候補を選択しない (2023-03-19追記: verticoにカスタマイズ変数が追加された! https://github.com/minad/vertico/commit/bedd146c3ffc236d746d088a94c3858eca0618d9 (Add vertico-preselect option (Fix #306) · minad/vertico@bedd146))
(setq vertico-preselect 'prompt)

;; (2023-04-18追記)
;; ただし、require-matchがt(やそれに類するもの)で入力が空ではなくマッ
;; チする候補がある場合は、その候補の先頭を選択する。
(defun my-vertico--recompute (orig-fun pt content &rest args)
  (let ((result (apply orig-fun pt content args)))
    (if (and (not (equal content "")) ;;入力が空の時は(require-matchであっても)defaultまたはnilを返すことになっている。
             (> (alist-get 'vertico--total result) 0)
             ;; completing-readの説明によれば
             ;; nil,confirm,confirm-after-completion以外はtのように
             ;; 振る舞うべき。
             (not (memq minibuffer--require-match
                        '(nil confirm confirm-after-completion))))
        (setf (alist-get 'vertico--index result) 0))
    result))
(advice-add #'vertico--recompute :around #'my-vertico--recompute)

;; 以下は過去のもの
;; ;; 候補更新時に最初の候補を選択しない (2022-12-01追記: vertico--allow-prompt-p関数が無くなって代わりに戻り値にvertico--allow-promptが増えたので修正)
;; (defun my-vertico--recompute (original-fun &rest args)
;;   ;; vertico--update-candidatesの最後の処理を置き換える。
;;   (let ((result (apply original-fun args)))
;;     (when result
;;       (let ((lock         (alist-get 'vertico--lock-candidate result))
;;             (allow-prompt (alist-get 'vertico--allow-prompt result))
;;             (index        (alist-get 'vertico--index result)))
;;         (when (and (not lock)
;;                    allow-prompt)
;;           ;; lockされておらず, require-matchじゃない場合は現在入力中の文字列を選択する。
;;           (setf (alist-get 'vertico--index result) -1))))
;;     result))
;; (advice-add #'vertico--recompute :around #'my-vertico--recompute)

;; ;; 候補更新時に最初の候補を選択しない (2022-10-24追記: 関数名や戻り値が変わったので修正)
;; (defun my-vertico--recompute (original-fun &rest args)
;;   ;; vertico--update-candidatesの最後の処理を置き換える。
;;   (let ((result (apply original-fun args)))
;;     (when result
;;       (let ((lock        (alist-get 'vertico--lock-candidate result))
;;             (def-missing (alist-get 'vertico--default-missing result))
;;             (index       (alist-get 'vertico--index result)))
;;         (when (and (not lock)
;;                    (let ((vertico--default-missing def-missing)) (vertico--allow-prompt-p)))
;;           ;; lockされておらず, require-matchじゃない場合は現在入力中の文字列を選択する。
;;           (setf (alist-get 'vertico--index result) -1))))
;;     result))
;; (advice-add #'vertico--recompute :around #'my-vertico--recompute)

;; ;; 候補更新時に最初の候補を選択しない (旧バージョン用)
;; (defun my-vertico--recompute-candidates (original-fun &rest args)
;;   ;; vertico--update-candidatesの最後の処理を置き換える
;;   (let ((result (apply original-fun args)))
;;     (when result
;;       (unless (nth 3 result) ;;3=index
;;         (setq vertico--lock-candidate nil)
;;         (setf (nth 3 result) ;;3=index
;;               (if (vertico--allow-prompt-selection-p)
;;                   ;; require-matchじゃない場合は現在入力中の文字列を選択する
;;                   -1
;;                 ;; require-matchの場合は最初の候補を選択する
;;                 0))))
;;     result))
;; (advice-add #'vertico--recompute-candidates :around #'my-vertico--recompute-candidates)

;; より簡単な方法としてRETをvertico-exit-inputにするという方法もある。
;; (define-key vertico-map [remap exit-minibuffer] #'vertico-exit-input)
;; しかし補完候補がハイライトされるので気持ち悪い。
;; プロンプトがハイライトされているべき。

移行して一番気になったのが、何かを入力するとそれにマッチする候補の中から最初の物を選択してしまうという挙動だ。これはhelmでもivyでもどうしてもなじめなかった。

(2023-03-19追記: 最初の物を選択しないようにできるカスタマイズ変数 vertico-preselect が追加されたので設定でこの挙動は抑制できるようになった)

よく起きる問題としては、find-fileで新規ファイルを作るときに間違って既存のファイルを開いてしまうというものだ。ファイル名を入力してRETを押したとき、その入力した文字列と部分的にマッチする候補が開いてしまうのだ。

helmにせよivyにせよverticoにせよそれに対する標準の解決策は一応あって、何か特殊なキーで確定させるというものだ。ivyではC-M-j、verticoではM-RETで現在の入力をそのまま確定できる。helmは覚えていないが、何かしらあったと思う。

私はこの挙動にとても強い違和感を覚える。

find-fileで出てくるミニバッファ入力は、「任意の」ファイル名を入力するためのものだ。決して限られた候補の中から選ぶというものでは無い。Emacsの標準的な動作でも、任意の文字列を入力して明示的にTABを押したときだけ補完されるというものだ。どんなOSのファイル選択ダイアログだって、ファイル名を入力してEnterを押したら別のファイル名が入力されるなんてバカなことは起きない。それをverticoでは新規のファイルを作る時だけM-RETを押せと言うのである。Emacsを使う人たちは学習能力が高いからどんな特殊な操作でもすぐに慣れてしまうのだろうけど私はそんなものには慣れたくない。そんなことだから初心者に逃げられるのでは無いか。

Emacs Lispにおいて補完入力を行う関数はcompleting-readだ。Verticoはここにも作用する。

(completing-read "商品: " '("ringo-ame" "ringo-cake" "ringo-juice"))

上のような式を評価したとして、ringo RETと入力したらringoと入力されるべきでringo-ame等が入力されるのは納得いかない。ringo-ameやらは例えば開発者が気を利かせて用意しただけの何か優先度の低い候補に過ぎないかもしれない。ringoよりringo-ameを優先すべき理由はない。

(completing-read "商品: " '("ringo-ame" "ringo-cake" "ringo-juice") nil t)

completing-readの第4引数(REQUIRE-MATCH)がtならば分かる。この場合、任意の文字列は入力できず、必ず候補の中から選ばなければならないからだ(ただし何も入力せずRETを押したときは第7引数のデフォルト値またはnilとなる)。

というわけで、この辺りを修正したのが上の設定だ。文字列を入力すると候補は絞り込まれるが選択はされない。C-n、C-pで明示的に選択したときだけそれが使われる。ただし、REQUIRE-MATCHがtのときだけは最初の候補を自動的に選択する。

このやり方の欠点は大半は候補の中から選べば済むときでも必ず明示的に選択操作をしなければならない点だ。例えばfind-fileは既存のファイルを選択することの方が多いはずだ。また、switch-to-bufferも既存のバッファを選択することの方が多い(任意のバッファ名を入力して新しく作れるということを覚えていない人もいるのではないか)。こういったときに、一つに絞り込んだのにいちいち選択操作をしなければならないのは面倒に感じることもあるかもしれない。

しかしそれはfind-fileやswitch-to-bufferといった用途毎に特有な事情であって、ミニバッファ補完付き入力全般に適用出来る問題ではないはずだ。用途毎にどちらが多いかは異なるのだから。後は好みで、find-fileやswitch-to-bufferに設定があればよい。個人的にはswitch-to-bufferで新しいバッファを作る機能は使っていないので、REQUIRE-MATCH=t、つまり既存のバッファのみ選択出来るようになっていて構わない。find-fileはいちいち選択するのに苦は感じない。どちらにせよ一つに絞り込んだらTABを押してRETすれば素のEmacsと同じなのだから分かりやすい。

ミニバッファ補完を活用する応用コマンドについては、ivyに依存するcounselではなくより幅広いミニバッファ補完に対応したconsultへ移行した。比較してみるとcounselの方が若干使いやすいと感じることもあるが、両者そう大きくは違わない。それほど使い込んでいるわけではないというのもあるけれど。Embarkと組み合わせると絞り込んだ候補に対して色々アクションを適用出来たりするようだ。拙作のel-winsearchにはconsult版を追加した。