Author Archives: AKIYAMA

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 (and w32-quote-process-args (nthcdr args-pos fun-args)) ;; (2025-03-06追記:引数が無いときにエラーになるのを修正した)
    (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)が領域を表示/非表示にする流れについて理解が深まりました。

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