HOME BLOG

org-mode 导出宏:org-macro

本文算是我在阅读 org manual 的 13.9 节时的意外收获,原本我只是想学习一下 org 到 HTML 的导出顺便记个笔记,但在读到 13.5 章时发现这 macro 居然意外的好玩,遂记下了一些折腾过程。

本文使用的环境如下:

1. org-mode 的导出过程

在 org-mode 文档中对宏行为的描述是 Org expands macros at the very beginning of the export process. 拿 C 语言类比一下可能是在宏展开阶段。要准确的了解导出过程还是得阅读源代码,或者是阅读文档。不过这是一份教你如何为 org-mode 写导出后端的文档,我在这里找到了对 org-mode 导出过程的说明:

  1. 运行 org-export-before-processing-hook
  2. 展开 #INCLUDE:
  3. 展开 {{{macro}}}
  4. 执行 babel 代码块
  5. 运行 org-export-before-parsing-hook
  6. 调用导出后端

为了确定这个描述是否正确,我们可以阅读一下 ox.elorg-export-as 函数,它是整个导出过程的核心函数。在 emacs 28.2 中它位于 ox.el 的 2907 行至 3065 行,我们只关注重点部分:

  • 2942 行 ~ 2948 行,处理 narrow 和 region

    ;; Narrow buffer to an appropriate region or subtree for
    ;; parsing.  If parsing subtree, be sure to remove main
    ;; headline, planning data and property drawer.
    (cond ((org-region-active-p)
           (narrow-to-region (region-beginning) (region-end)))
          (subtreep
           (org-narrow-to-subtree)
           (goto-char (point-min))
           (org-end-of-meta-data)
           (narrow-to-region (point) (point-max))))
    
  • 2967 行 ~ 2968 行,执行 org-export-before-processing-hook

    ;; Run first hook with current back-end's name as argument.
    (run-hook-with-args 'org-export-before-processing-hook
                        (org-export-backend-name backend))
    
  • 2969 行,展开所有的 include 关键字

    (org-export-expand-include-keyword)
    
  • 2971 行 ~ 2972 行,宏初始化和宏替换

    (org-macro-initialize-templates org-export-global-macros)
    (org-macro-replace-all org-macro-templates parsed-keywords)
    
  • 2981 行 ~ 2985 行,执行 babel 代码块

    ;;  Possibly execute Babel code.  Re-run a macro expansion
    ;;  specifically for {{{results}}} since inline source blocks
    ;;  may have generated some more.  Refresh buffer properties
    ;;  and radio targets another time.
    (when org-export-use-babel
      (org-babel-exp-process-buffer)
      (org-macro-replace-all '(("results" . "$1")) parsed-keywords)
      (org-set-regexps-and-options)
      (org-update-radio-target-regexp))
    
  • 2990 行 ~ 2992 行,执行 org-export-before-parsing-hook

    (save-excursion
      (run-hook-with-args 'org-export-before-parsing-hook
                          (org-export-backend-name backend)))
    
  • 3023 行,进行 parse

    ;; Parse buffer.
    (setq tree (org-element-parse-buffer nil visible-only))
    
  • 3043 ~ 3065 行,执行从 tree 到输出 output 的过程

    ;; Eventually transcode TREE.  Wrap the resulting string into
    ;; a template.
    (let* ((body (org-element-normalize-string
                  (or (org-export-data tree info) "")))
           ...
    

可以看到整个过程和上面列出的基本一致,大致是 预处理->宏展开->解析->输出 这样的一个过程。

2. 导出宏的实现

从上面给出的代码片段可以看到在整个 export 过程中有关宏展开的代码只有两行:

(org-macro-initialize-templates org-export-global-macros)
(org-macro-replace-all org-macro-templates parsed-keywords)

从这两个函数的名字我们就能看出处理宏的步骤,首先初始化模板,然后对所有的宏进行展开。下面我们从模板和展开两个方面来介绍具体实现。

2.1. 由宏定义生成函数

org-macro 中的宏使用正则替换或 elisp 函数实现,org 宏可看作接受字符串参数并返回字符串的函数。在内部 org-macro 会通过 org-macro--makeargs 根据宏内容生成参数表, org-macro--set-templates 根据宏内容生成字符串或函数。我们可以简单体验一下这两个函数的功能:

(org-macro--makeargs "$1")
=> (&optional $1 &rest _)
(org-macro--makeargs "$1, $2")
=> (&optional $1 $2 &rest _)
(org-macro--makeargs "$1, $2, $4")
=> (&optional $1 $2 $3 $4 &rest _)
(org-macro--makeargs "$9")
=> (&optional $1 $2 $3 $4 $5 $6 $7 $8 $9 &rest _)

在导出宏的模板中,参数名由 $ 加上数字组成。 $1 是宏调用的第一个参数, $2 是第二个,以此类推。匹配参数的正则是 "\\$\\([0-9]+\\)" ,虽然它能够匹配 $0 ,但实现代码不允许它出现在参数表中:

;; org-macro.el line 92
(while (> max 0)
  (push (intern (format "$%d" max)) args)
  (setq max (1- max)))

;; use $0
(org-macro--makeargs "$0")
=> (&rest _)
(org-macro--makeargs "$0, $5")
=> (&optional $1 $2 $3 $4 $5 &rest _)

org-macro--set-templates 接受由 (name . template) 组成的列表,返回经过处理的模板列表。对列表中名字相同的模板,它会使用最后出现的模板。对于以 (eval 开头 template ,它会将模板字符串解析为 lisp 代码,对于普通 template ,它不做处理,直接返回原字符串:

(org-macro--set-templates '(("hello" . "$1 $2 world")))
=> (("hello" . "$1 $2 world"))

(org-macro--set-templates '(("hello" . "$1 world")
                            ("hello" . "$2 world")))
=> (("hello" . "$2 world"))

(org-macro--set-templates '(("h" . "(eval (concat $1 $2))")))
=> (("h" closure (t) (&optional $1 $2 &rest _) (concat $1 $2)))

参考官方文档,定义宏有两种方法,分别是:

#+MACRO: name template
#+MACRO: name (eval template)

前者用于定义普通的文本替换宏,后者用于函数宏。宏的使用方法是 {{{name(arg1, arg2, ...)}}} ,如果没有参数就是 {{{name}}} 。根据上面得到的参数表容易知道宏对参数的使用非常宽容,超出或少于宏定义中的参数个数都没有问题。

2.2. 从文件中收集宏定义

上面我们提到了可以使用 #+MACRO: 关键字来定义宏,那么 org-macro 是以何种方式和何种顺序来找到文件中的宏定义的呢? org-macro--collect-macros 通过调用 org-collect-keywords 来收集使用 MACRO 关键字的名字和值。这个函数比较有意思的一点是它会将通过 SETUPFILE 引入的文件中的宏定义也包含进去:

;; org.el line 4519
(let* ((keywords (cons "SETUPFILE" (mapcar #'upcase keywords)))

这也就是说如果你通过 #+SETUPFILE: 引入了一些 settings ,那么 setupfile 中的宏定义也会起作用。 org-collect-keywords 中的注释没有说明它的扫描顺序,不过根据位于 org.el 4536 行的 re-search-forward 可以猜测应该是从上往下的顺序。这也就是说宏在文件中出现位置越靠后,它在 org-collect-keywords 的返回表中的位置就越靠后。

org-macro--collect-macros 添加了 4 个预定义的宏,分别是 authoremailtitledate ,它们通过文件或 SETUPFILE 中的 #+AUTHOR#+EMAIL#+TITLE#+DATE 获取:

;; org-macro line 124
(defun org-macro--collect-macros ()
  "Collect macro definitions in current buffer and setup files.
Return an alist containing all macro templates found."
  (let ((templates
         `(("author" . ,(org-macro--find-keyword-value "AUTHOR" t))
           ("email" . ,(org-macro--find-keyword-value "EMAIL"))
           ("title" . ,(org-macro--find-keyword-value "TITLE" t))
           ("date" . ,(org-macro--find-date)))))
    (pcase (org-collect-keywords '("MACRO"))
      (`(("MACRO" . ,values))
       (dolist (value values)
         (when (string-match "^\\(\\S-+\\)[ \t]*" value)
           (let ((name (match-string 1 value))
                 (definition (substring value (match-end 0))))
             (push (cons name definition) templates))))))
    templates))

根据该函数的中使用的 push ,我们可知在它的返回表中 4 个预定义的宏位于最后,表头是文件中最后出现的宏,第二元素是倒数第二个出现的宏,以此类推。如果说 org-collect-keywords 得到的是 M1 M2 M3 的话,那么它的返回值是 M3 M2 M1 author email title date

org-macro-initialize-templates ,也就是宏初始化函数中,使用 org-macro--collect-macros 收集到的宏会被 org-macro--set-templates 进行一次转换得到可用的模板,注意这里的 default 在前面,这也就是说文件中定义的宏具有更高的优先级,如果 default 中有宏与文件宏同名,那么它会被替换掉:

;; org-macro.el line 158
;; org-macro-initialize-templates

;; Install user-defined macros.  Local macros have higher
;; precedence than global ones.
(org-macro--set-templates (append default (org-macro--collect-macros)))

随后,该函数将 input-filemodification-time 这两个宏插入表中,它们分别是输入文件名和修改时间:

;; org-macro.el line 164
`("input-file" . ,(file-name-nondirectory visited-file))
`("modification-time" . ...)

在最后添加 keywordnpropertytime 这四个宏,可见它们就是函数:

;; org-macro line 174

;; Install generic macros.
'(("keyword" . (lambda (arg1 &rest _)
                 (org-macro--find-keyword-value arg1 t)))
  ("n" . (lambda (&optional arg1 arg2 &rest _)
           (org-macro--counter-increment arg1 arg2)))
  ("property" . (lambda (arg1 &optional arg2 &rest _)
                  (org-macro--get-property arg1 arg2)))
  ("time" . (lambda (arg1 &rest _)
              (format-time-string arg1))))

最后让我们回到 ox.el 中调用 org-macro-initialize-templates 的位置:

(org-macro-initialize-templates org-export-global-macros)

注意函数的参数 org-export-global-macros ,我们可以使用它来定义全局宏,它将作为 default 参加参与 org-macro-initialize-templates 的调用。

以上就是对收集宏阶段的过程分析,现在让我们举个例子,假设 f1 中有定义 M1M2f2 中有定义 M3M4org-export-global-macros 中有宏 M5M6 (它们都是按顺序出现)。那么,对于以下 org 文件:

#+INCLUDE: f1.org
#+MACRO: M7 $1
#+SETUPFILE: f2.org
#+MACRO: M8 Hello world

我们最终由 org-macro-initialize-templates 得到的宏表就是: (--set-templates M5 M6 M8 M4 M3 M7 M2 M1 author email title date) + input-file, modification-time, keyword, n, property, time 。在 + 号前的宏定义会被 org-macro--set-templates 整理为模板。

上一节忘了说了,在处理过程中 org-macro--set-templates 会将列表反序,上一节的例子不足以说明这一点,这里补充一下:

(org-macro--set-templates '(("hello" . "world")
                            ("world" . "hello")))
=> (("world" . "hello") ("hello" . "world"))

所以我们最终得到的顺序应该是 date title email author M1 M2 M7 M3 M4 M8 M6 M5 input-file modification-time keyword n property time

我在本地试了以下,先将 org-macro-global-macros 设置为 (("M5" . "foooo") ("M6" . "barrrr")) ,然后在 org-macro.elorg-macro-initialize-templates 的末尾插入 (print org-macro-templates) ,添加需要的文件 f1f2 后,我通过 C-c C-e h H 调用导出到 html 的 buffer,最后在 *message* buffer 中看到了如下输出:

1.png

实际上这样的输出出现了几次,原因不明…如果你也像我一样修改了 org-macro.el 中的代码,记得测试之后改回来。

2.png

根据这样的处理方法会出现一个比较鬼畜的现象,那就是文件中定义的宏如果出现重名,那么最先定义的宏生效,这和我们一般想的最后定义生效不一致,如果你使用 ox-org (记得 (require 'ox-org) )导出以下文件,那么最后得到的结果是 1 而不是 2:

#+MACRO: A 1
#+MACRO: A 2

{{{A}}}
3.png

得到的宏列表如下,可见没有 A 2

4.png

宏,很神奇吧(笑)。

整个导出过程还是有点复杂的,不过总结一下也就以下几条规则:

  • 宏表顺序为 date, title, email, author 加上 文件中按顺序出现的宏 加上 全局宏表倒序 加上剩下的六个宏
  • 尽量不要使用 authoremailtitledateinput-filemodification-timekeywordnpropertytime 作为宏名,它们算是保留关键字
  • 可以使用 org-macro-templates 定义全局宏,但如果与文件宏同名,它们会被覆盖
  • 对于同名 文件 宏,以 最先 出现的宏定义为准,其他同名定义会被覆盖
  • 对于同名 全局 宏,以 最后 出现的宏定义为准,其他同名定义会被覆盖

文档中对宏的顺序没有做任何说明,可见作者也不认为宏应该被非常复杂地使用。

2.3. 宏的展开

org-export-as 通过调用 org-macro-replace-all 来展开文件中出现的宏调用。它的表达式为:

(org-macro-replace-all org-macro-templates parsed-keywords)

其中的参数我们随后解释,首先让我们看看 org-macro-replace-all 干了什么。

首先该函数会进入一个匹配当前 buffer 中所有宏调用的循环,对 buffer 中出现的宏调用逐个处理:

(while (re-search-forward "{{{[-A-Za-z0-9_]" nil t)
  ...

根据这个匹配规则我们可以知道宏的命名规则,即可以使用大小写字母加上 0~9 数字加上 -_ 。在找到宏调用后会判断当前位置的 headline 是否被注释,若是则跳过:

(unless (save-match-data (org-in-commented-heading-p))

随后该函数调用 org-element-context 获取宏调用所在环境,对此我们无序太过关心,不过感兴趣的话可以把光标放到一个宏调用 {{{A}}} 上,然后再 M-: (print (org-element-context))

5.png

可见我们得到了一个 plist,我们最关心的宏位于表头。 :key"a":value"{{{A}}}" 。不过这只是最简单的情况,如果宏调用位于 keyword 或 property 环境中那么 org-element-context 的返回值会有所不同。 org-macro-replace-all 对它们进行了处理:

;; org-macro.el line 228

(macro
 (cond
  ((eq type 'macro) datum)
  ;; In parsed keywords and associated node
  ;; properties, force macro recognition.
  ((or (and (eq type 'keyword)
            (member (org-element-property :key datum) keywords))
       (and (eq type 'node-property)
            (string-match-p properties-regexp
                            (org-element-property :key datum))))
   (save-excursion
     (goto-char (match-beginning 0))
     (org-element-macro-parser))))))

如果从匹配处获取的是一个宏,那么接下来会从由 org-element-context 获取的 datum 中获取一些信息,比如 key 和 value 值,宏调用起始位置等等。这里比较有意思的是它通过记录当前宏调用的签名来判断是否会出现宏的无限展开:

;; org-mcaro.el line: 241
(when macro
  (let* ((key (org-element-property :key macro))
         (value (org-macro-expand macro templates))
         (begin (org-element-property :begin macro))
         (signature (list begin
                          macro
                          (org-element-property :args macro))))
    ;; Avoid circular dependencies by checking if the same
    ;; macro with the same arguments is expanded at the
    ;; same position twice.
    (cond ((member signature record)
           (error "Circular macro expansion: %s" key))
          (value
           (push signature record)
           ...

真正的展开过程发生在 value 那一行,即 (value (org-macro-expand macro templates)) 。这里的 macro 就是 datumtemplates 就是 (org-macro-replace-all org-macro-templates parsed-keywords) 调用的第一参数,也就是收集阶段得到的模板。 org-macro-expand 定义如下:

(defun org-macro-expand (macro templates)
  (let ((template
         ;; Macro names are case-insensitive.
         (cdr (assoc-string (org-element-property :key macro) templates t))))
    (when template
      (let* ((value
              (if (functionp template)
                  (apply template (org-element-property :args macro))
                (replace-regexp-in-string
                 "\\$[0-9]+"
                 (lambda (m)
                   (or (nth (1- (string-to-number (substring m 1)))
                            (org-element-property :args macro))
                       ;; No argument: remove place-holder.
                       ""))
                 template nil 'literal))))
        ;; Force return value to be a string.
        (format "%s" (or value ""))))))

首先它会使用 accos-stringtemplates 里面按顺序寻找宏定义,这里还强调了一句宏名大小写不敏感。在找到模板后,如果模板是函数,那就使用宏调用的参数对该函数进行调用;如果模板是字符串(也就是一般的文本宏),那就使用正则替换,如果某个变量没有作为参数传递,那么该变量在输出字符串中就是零长字符串。

如果出现宏嵌套,org-macro 的处理方式是展开到不能再展开为止。根据实现,嵌套宏的展开顺序应该是从外到内。

文档中说明了可以使用宏的位置:段落,标题,VERSE 块,表格,列表,关键字,以及一些后端特定的规定。参考实现内容我们可知也能在属性中使用宏。

到了这里我们就完成了对 org-macro 基本实现的介绍,org-macro 中还定义了一些辅助函数帮助我们编写函数宏,这里就不一一介绍了,我可能会在下面的例子中使用它们。

3. 导出宏的使用

整个导出过程并不只有宏展开,如果我们只想知道宏展开结果的话可以从 org-export-as 中拆除一小块代码片段出来。不过这里我们采用更好的方法,首先 (require 'ox-org) ,然后在每次想要导出时使用 C-c C-e O O 得到导出的 org buffer。 ox-org 的作用是规范化作为源的 org 文件。

最简单的宏应该是无参宏,类似于 C 语言的 #DEFINE A 1 。我们在上面也演示了它的用法。

#+MACRO: hello world

{{{hellO}}}
=>
world

稍微复杂的是带参文本宏,比如这些:

#+MACRO: h how $1 are you?

{{{h}}}
{{{h()}}}
{{{h(old)}}}
=>
how  are you?
how  are you?
how old are you?

说到带参宏我想到了 C 语言里的 ## ,这里我们可以借助单位宏来实现:

#+MACRO: hel $1b
#+MACRO: id $1
#+MACRO: hel2 {{{id($1)}}}1

{{{hel(1)}}}
{{{hel2(1)}}}
=>
1b
11

在文本宏中似乎不太能表达空格:

#+MACRO: abc a$1b$2c$3d

{{{abc}}}
{{{abc(1)}}}
{{{abc(1 )}}}
{{{abc( 1 )}}}
{{{abc(  1  )}}}
{{{abc( 1  ,)}}}
{{{abc(1,2)}}}
{{{abc(1, 2)}}}
{{{abc(1, 2 )}}}
{{{abc(1, 2 ,)}}}
{{{abc(1, 2 ,3)}}}
{{{abc(1, 2 , 3)}}}
{{{abc(1, 2 , 3 )}}}
{{{abc(1, 2 , 3 ,)}}}
{{{abc(1,     2    , 3     ,)}}}
=>
abcd
a1bcd
a1bcd
a1bcd
a1bcd
a1 bcd
a1b2cd
a1b 2cd
a1b 2cd
a1b 2 cd
a1b 2 c3d
a1b 2 c 3d
a1b 2 c 3d
a1b 2 c 3 d
a1b 2 c 3 d

可见想要表达空格需要 , 分割,而且多个空格会被合并。这样以来我们也就不能在第一个参数的前面加空格了。不过这些问题都可以通过函数宏在一定程度上解决,比如去掉参数中的空格等等。

我们可以使用 babel 来完成一些魔法操作:

# https://stackoverflow.com/questions/22132603/define-org-mode-macro-with-line-breaks

#+MACRO: newline   src_emacs-lisp[:results raw]{"\n"}

不过这属实是有点难顶…而且宏展开时还需要保证 {{{ 前面没有非空白字符。我们可以很轻松的使用函数宏做到换行和空白:

#+MACRO: newline (eval "\n")

a{{{newline}}}b
=>
a
b

这是一个组合宏的例子:

#+MACRO: newline (eval "\n")
#+MACRO: x a$1b

{{{x({{{newline}}})}}}
=>
a
b

关于宏的使用暂时就介绍这么多,我目前还没有什么使用经验,我试着能不能用它表示一些 org HTML 不导出的 tag。这样就不用写 @@html:...@@ 了。


3.1. 默认宏

最后我们再介绍一下 org-macro 自带的 10 个宏吧。

title, author, email, date 是无参宏,表示标题,作者,邮箱和日期:

#+TITLE: hello
#+DATE: [2023-01-17 Tue 11:11]
#+AUTHOR: include-yy
#+EMAIL: [email protected]

{{{author}}}
{{{title}}}
{{{email}}}
{{{date}}}
=>
include-yy
hello
[email protected]
[2023-01-17 Tue 11:11]

keyword 的作用是收集当前文件中某一关键字的所有值,上面的四个宏都是使用它定义的:

#+TITLE: hello
#+DATE: [2023-01-17 Tue 11:11]
#+AUTHOR: include-yy
#+EMAIL: [email protected]

{{{keyword(AUTHOR)}}}
=>
include-yy

timemodification-time 分别是文件的导出时间和修改时间。它们都可以接受 format 参数。

input-file 展开为被导出文件的名字:

{{{input-file}}}
=>
1.org

property 接受属性名和搜索选项作为参数,它返回当前实体下的属性值,如果搜索选项指向远处的实体,则在远处实体下搜索属性选项。

最后一个宏是 n ,它实现了一个计数器,可以展开得到计数器值,从 1 开始。无参调用返回 n 调用的次数,有参调用则创建名为参数的计数器。它的第二参数可以设定计数器的值,如果是 - 的话,则不增加:

{{{n}}}{{{n}}}{{{n}}}{{{n}}}
{{{n(add)}}}{{{n(add,-)}}}{{{n(add)}}}{{{n(add)}}}{{{n(add)}}}
=>
1234
11234
10

以上。