2026-01-06 ,

org-modeの表(テーブル)で集計する方法(tblfn.el)

以前orgtbl-aggregateを使ってorg-modeの表(テーブル)を集計する方法について書きました。

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

それ以前はEmacs LispのソースコードブロックでEmacsに標準で入っている関数やマクロを駆使して集計するコードを細々と書いていたので、それに比べれば格段にマシになりました。

しかしこのorgtbl-aggregateは使っているうちに色々と不満を感じるようにもなってきました。

一番の問題はなんと言っても書き方が覚えられないということでしょう。以前書いたものをコピーしてくれば楽ですが。最近はウィザードも追加されたみたいですが、それを使ってみてもやっぱり意味が分からず結局以前のものをコピーした方が早いです。hlineの追加や、最後に合計行を付け加える処理などはとても分かりにくいです。

機能も集約だけにフォーカスしているので基本的にそれ以外のことはできません。兄弟プロジェクトとしてorgtbl-joinというものもあるのでそれを使えばもっといろんな事ができるみたいです。とは言え私は使ったことが無いのでよく分かりません。

決定的だったのが構成比率のパーセンテージを付ける方法が無いらしいこと。いや、ひょっとしたらあるのかもしれませんが、私には見つけられませんでした。

集計作業にはorgtbl-aggregateの他にはR言語もたまに使っていました。R言語のソースコードブロックからorg-modeのテーブルを参照して集計を取って結果をテーブルとして表示するわけです。

これはある程度はうまく行くのですが、テーブルの最後に水平線を入れて合計欄を作ることだけがどうしてもやり方が分かりませんでした。org-modeのテーブルに水平線を入れるには hline というシンボルを入れなければならないのだと思いますが、何か手があるのでしょうか。

そして最大の問題はその書き方です。なんでこんなにまどろっこしい書き方をしなければならないのでしょうか。無駄に記号も多いし。勉強してもしばらく経ってまたやろうとしたらすっかり書き方を忘れていました。自慢じゃありませんが私はそこまで頻繁にこういった集計作業をしないんですよ。

私がやりたいことは、テーブルからカテゴリー毎の値を合計するだけです。後はソートして、その横にパーセンテージを付けたり、一番下に合計値を付けたりするくらいです。エクセルなら数クリックで終わることじゃないですか。なんでそのためにプログラムみたいなものを書かなきゃいけないんですか?

私が一つ上の段落で書いたことを素直に書けるようにならないものでしょうか。

というわけで、作ったのがこちらです。

misohena/el-tblfn: Simple data table functions for Emacs

それを使えば単純な集計は次で事足ります。

#+begin_src elisp :var table=my-test-table :colnames no :hlines yes
(require 'tblfn)
(tblfn-aggregate table "Category" "Total")
#+end_src

上で書いたような、集計、ソート、パーセンテージ列追加、合計行追加まで含めると次のようになります。

#+begin_src elisp :var table=my-test-table :colnames no :hlines yes
(thread-first
  (tblfn-aggregate table "Category" "Total")
  (tblfn-sort "Total" t)
  (tblfn-add-percentage-column "Total" "Percentage")
  (tblfn-add-footer-sum "Total" "Percentage"))
#+end_src

やって欲しいことをただ単純に素直に書くだけ。これですよ! こうでなきゃいけません!

もう少し詳しく見てみましょう。

例えば次のようなテーブルがあったとします。(この表はClaudeに頼んだら作ってくれました)

#+NAME: inventory
| Product    | Category  | Quantity | Price |  Total | Supplier      |
|------------+-----------+----------+-------+--------+---------------|
| Apple      | Fruit     |       50 |   150 |   7500 | Tokyo Foods   |
| Banana     | Fruit     |       80 |    80 |   6400 | Manila Export |
| Orange     | Fruit     |      100 |   100 |  10000 | US Produce    |
| Strawberry | Fruit     |       30 |   400 |  12000 | Tokyo Foods   |
| Cabbage    | Vegetable |       40 |   120 |   4800 | Beijing Trade |
| Carrot     | Vegetable |       60 |    90 |   5400 | Tokyo Foods   |
| Tomato     | Vegetable |       45 |   200 |   9000 | Mexico Fresh  |
| Lettuce    | Vegetable |       35 |   130 |   4550 | US Produce    |
| Potato     | Vegetable |       70 |    80 |   5600 | Dutch Farms   |
| Milk       | Dairy     |       25 |   220 |   5500 | Tokyo Foods   |
| Cheese     | Dairy     |       15 |   450 |   6750 | Paris Dairy   |
| Yogurt     | Dairy     |       40 |   180 |   7200 | Berlin Dairy  |
| Chicken    | Meat      |       20 |   350 |   7000 | Brazil Meat   |
| Pork       | Meat      |       18 |   400 |   7200 | Danish Farms  |
| Beef       | Meat      |       10 |   800 |   8000 | Aussie Beef   |
|------------+-----------+----------+-------+--------+---------------|
|            |           |          |       | 106900 |               |
#+TBLFM: @>$5=vsum(@I..@II)

inventoryという名前のテーブルで、商品(食品)とカテゴリー、個数、単価、合計額、供給会社の表です。

一番下に合計値が入っているのがポイントですね。これがあるとorgtbl-aggregateだと結構邪魔なんですよ。

それでさっきと同じソースコードブロックを書きます。

#+begin_src elisp :var inventory=inventory :colnames no :hlines yes
(require 'tblfn)
(thread-first
  (tblfn-aggregate inventory "Category" "Total")
  (tblfn-sort "Total" t)
  (tblfn-add-percentage-column "Total" "Percentage")
  (tblfn-add-footer-sum "Total" "Percentage"))
#+end_src

実行結果はorg-modeバッファ上で次のようになります。

#+RESULTS:
| Category  |  Total | Percentage |
|-----------+--------+------------|
| Fruit     |  35900 |      33.58 |
| Vegetable |  29350 |      27.46 |
| Meat      |  22200 |      20.77 |
| Dairy     |  19450 |      18.19 |
|-----------+--------+------------|
|           | 106900 |       100. |

ちゃんと最後に水平線で区切られた合計行が入っていますね。

次に別のテーブルと結合してみましょう。例えば次の表。

#+NAME: suppliers
| Supplier      | Country     | Rating |
|---------------+-------------+--------|
| Tokyo Foods   | Japan       |      5 |
| Manila Export | Philippines |      4 |
| US Produce    | USA         |      4 |
| Beijing Trade | China       |      3 |
| Mexico Fresh  | Mexico      |      5 |
| Dutch Farms   | Netherlands |      4 |
| Paris Dairy   | France      |      5 |
| Berlin Dairy  | Germany     |      4 |
| Brazil Meat   | Brazil      |      4 |
| Danish Farms  | Denmark     |      5 |
| Aussie Beef   | Australia   |      4 |

suppliersという名前で、供給会社の国籍と評価を記した表です。(これもClaudeが適当に作ってくれました)

とりあえずinventoryとsuppliersをSupplier列を元に結合してみましょうか。

#+begin_src elisp :var inventory=inventory :var suppliers=suppliers :colnames no :hlines yes
(tblfn-join inventory suppliers "Supplier")
#+end_src
#+RESULTS:
| Product    | Category  | Quantity | Price | Total | Supplier      | Country     | Rating |
|------------+-----------+----------+-------+-------+---------------+-------------+--------|
| Apple      | Fruit     |       50 |   150 |  7500 | Tokyo Foods   | Japan       |      5 |
| Banana     | Fruit     |       80 |    80 |  6400 | Manila Export | Philippines |      4 |
| Orange     | Fruit     |      100 |   100 | 10000 | US Produce    | USA         |      4 |
| Strawberry | Fruit     |       30 |   400 | 12000 | Tokyo Foods   | Japan       |      5 |
| Cabbage    | Vegetable |       40 |   120 |  4800 | Beijing Trade | China       |      3 |
| Carrot     | Vegetable |       60 |    90 |  5400 | Tokyo Foods   | Japan       |      5 |
| Tomato     | Vegetable |       45 |   200 |  9000 | Mexico Fresh  | Mexico      |      5 |
| Lettuce    | Vegetable |       35 |   130 |  4550 | US Produce    | USA         |      4 |
| Potato     | Vegetable |       70 |    80 |  5600 | Dutch Farms   | Netherlands |      4 |
| Milk       | Dairy     |       25 |   220 |  5500 | Tokyo Foods   | Japan       |      5 |
| Cheese     | Dairy     |       15 |   450 |  6750 | Paris Dairy   | France      |      5 |
| Yogurt     | Dairy     |       40 |   180 |  7200 | Berlin Dairy  | Germany     |      4 |
| Chicken    | Meat      |       20 |   350 |  7000 | Brazil Meat   | Brazil      |      4 |
| Pork       | Meat      |       18 |   400 |  7200 | Danish Farms  | Denmark     |      5 |
| Beef       | Meat      |       10 |   800 |  8000 | Aussie Beef   | Australia   |      4 |

左の表の各行に右の表のマッチする行が連結された形です。簡単ですね!

ここから供給国毎の合計金額と構成比率を出してみましょうか。

#+begin_src elisp :var inventory=inventory :var suppliers=suppliers :colnames no :hlines yes
(thread-first
  inventory
  (tblfn-join suppliers "Supplier")
  (tblfn-aggregate "Country" "Total")
  (tblfn-sort "Total" t)
  (tblfn-add-percentage-column "Total" "Percentage")
  (tblfn-add-footer-sum "Total"))
#+end_src

#+RESULTS:
| Country     |  Total | Percentage |
|-------------+--------+------------|
| Japan       |  30400 |      28.44 |
| USA         |  14550 |      13.61 |
| Mexico      |   9000 |       8.42 |
| Australia   |   8000 |       7.48 |
| Germany     |   7200 |       6.74 |
| Denmark     |   7200 |       6.74 |
| Brazil      |   7000 |       6.55 |
| France      |   6750 |       6.31 |
| Philippines |   6400 |       5.99 |
| Netherlands |   5600 |       5.24 |
| China       |   4800 |       4.49 |
|-------------+--------+------------|
|             | 106900 |            |

日本が多いのは日本人の私が生成を頼んだからでしょうか? Claudeは最初"Potato"のCountryに"Hokkaido"なんて入ったデータを生成してきたので「ちょっとふざけすぎだろう」とツッコんでしまいましたよ。

もしこれがお野菜だけだったらどうなるでしょうか。

まずはお野菜だけ抜き出す方法。なんのひねりも無くたったのこれだけです。

#+begin_src elisp :var inventory=inventory :colnames no :hlines yes
(tblfn-filter inventory "Category" "Vegetable")
#+end_src

#+RESULTS:
| Product | Category  | Quantity | Price | Total | Supplier      |
|---------+-----------+----------+-------+-------+---------------|
| Cabbage | Vegetable |       40 |   120 |  4800 | Beijing Trade |
| Carrot  | Vegetable |       60 |    90 |  5400 | Tokyo Foods   |
| Tomato  | Vegetable |       45 |   200 |  9000 | Mexico Fresh  |
| Lettuce | Vegetable |       35 |   130 |  4550 | US Produce    |
| Potato  | Vegetable |       70 |    80 |  5600 | Dutch Farms   |

国毎の構成比率は次のようになります。

#+begin_src elisp :var inventory=inventory :var suppliers=suppliers  :colnames no :hlines yes
(thread-first
  (tblfn-filter inventory "Category" "Vegetable")
  (tblfn-join suppliers "Supplier")
  (tblfn-aggregate "Country" "Total")
  (tblfn-sort "Total" t)
  (tblfn-add-percentage-column "Total" "Percentage")
  (tblfn-add-footer-sum "Total"))
#+end_src

#+RESULTS:
| Country     | Total | Percentage |
|-------------+-------+------------|
| Mexico      |  9000 |      30.66 |
| Netherlands |  5600 |      19.08 |
| Japan       |  5400 |      18.40 |
| China       |  4800 |      16.35 |
| USA         |  4550 |      15.50 |
|-------------+-------+------------|
|             | 29350 |            |

メキシコが一番ですね。何を買っているのでしょうか。

#+begin_src elisp :var inventory=inventory :var suppliers=suppliers :colnames no :hlines yes
(tblfn-filter (tblfn-join inventory suppliers "Supplier") "Country" "Mexico")
#+end_src

トマトを沢山買っているみたいですね。

#+RESULTS:
| Product | Category  | Quantity | Price | Total | Supplier     | Country | Rating |
|---------+-----------+----------+-------+-------+--------------+---------+--------|
| Tomato  | Vegetable |       45 |   200 |  9000 | Mexico Fresh | Mexico  |      5 |

逆にお野菜以外はどうでしょう。お野菜以外を抜き出すには次のようにします。

#+begin_src elisp :var inventory=inventory :var suppliers=suppliers :colnames no :hlines yes
(tblfn-filter inventory '(not (equal Category "Vegetable")))
#+end_src

#+RESULTS:
| Product    | Category | Quantity | Price | Total | Supplier      |
|------------+----------+----------+-------+-------+---------------|
| Apple      | Fruit    |       50 |   150 |  7500 | Tokyo Foods   |
| Banana     | Fruit    |       80 |    80 |  6400 | Manila Export |
| Orange     | Fruit    |      100 |   100 | 10000 | US Produce    |
| Strawberry | Fruit    |       30 |   400 | 12000 | Tokyo Foods   |
| Milk       | Dairy    |       25 |   220 |  5500 | Tokyo Foods   |
| Cheese     | Dairy    |       15 |   450 |  6750 | Paris Dairy   |
| Yogurt     | Dairy    |       40 |   180 |  7200 | Berlin Dairy  |
| Chicken    | Meat     |       20 |   350 |  7000 | Brazil Meat   |
| Pork       | Meat     |       18 |   400 |  7200 | Danish Farms  |
| Beef       | Meat     |       10 |   800 |  8000 | Aussie Beef   |

tblfn-filter の条件部分には「列名」「列の値」「項を二つ取る関数(デフォルトは equal)」という形式の他にも、S式や(行を引数に取る)関数を指定できます。

そしてS式を指定した場合は、列名と同じ名前の変数で列の値を参照できます(列名に空白が入っている場合は \ でエスケープしてください)。他にも row-index で行番号が取得できたり、 row で行そのもの(フィールドのリスト)を参照できたりもします。

おっと、ここで重大なニュースが。乳製品の価格が一気に2倍に跳ね上がったようです。どうしましょう。

データの一部を更新する方法は色々と用意してありますが、最も機能が多いのは tblfn-update でしょうか。

tblfn-update を使って乳製品の値段を2倍にしてみましょう。

#+begin_src elisp :var inventory=inventory :colnames no :hlines yes
(tblfn-update inventory '(equal Category "Dairy") "Price" '(* 2 Price))
#+end_src

#+RESULTS:
| Product    | Category  | Quantity | Price | Total | Supplier      |
|------------+-----------+----------+-------+-------+---------------|
| Apple      | Fruit     |       50 |   150 |  7500 | Tokyo Foods   |
| Banana     | Fruit     |       80 |    80 |  6400 | Manila Export |
| Orange     | Fruit     |      100 |   100 | 10000 | US Produce    |
| Strawberry | Fruit     |       30 |   400 | 12000 | Tokyo Foods   |
| Cabbage    | Vegetable |       40 |   120 |  4800 | Beijing Trade |
| Carrot     | Vegetable |       60 |    90 |  5400 | Tokyo Foods   |
| Tomato     | Vegetable |       45 |   200 |  9000 | Mexico Fresh  |
| Lettuce    | Vegetable |       35 |   130 |  4550 | US Produce    |
| Potato     | Vegetable |       70 |    80 |  5600 | Dutch Farms   |
| Milk       | Dairy     |       25 |   440 |  5500 | Tokyo Foods   |
| Cheese     | Dairy     |       15 |   900 |  6750 | Paris Dairy   |
| Yogurt     | Dairy     |       40 |   360 |  7200 | Berlin Dairy  |
| Chicken    | Meat      |       20 |   350 |  7000 | Brazil Meat   |
| Pork       | Meat      |       18 |   400 |  7200 | Danish Farms  |
| Beef       | Meat      |       10 |   800 |  8000 | Aussie Beef   |

乳製品(Dairy)というとMilk、Cheese、Yogurtですが、元の単価は220、450、180だったところが今は440、900、360になっています。

ちなみに '(* 2 Price) の部分は列の値が文字列の場合に備えて '(* 2 (tblfn-to-number Price)) と書くのも良いです。フィールドの値の型がstringなのかnumberなのかは曖昧なことが多いので。

tblfn-update は他にも行全体を置き換えたり色々なことができるようになっています。詳しくはexample.orgあたりやソースコードの中を見てください。

それにしても元のテーブルとは別に新しく単価を2倍にしたテーブルを作ってどうするんでしょうね? よく分かりません。

ああそうだ、これまでorg-modeのテーブルを加工することばかりやってきましたが、実はCSVファイルを読み書きする関数も入っているのでした。

つまり次のようにすればCSVを読み込んで価格を2倍にしてCSVファイルに書き込むといったことができます。

(thread-first
  (tblfn-read-csv-file "inventory.csv") ;; 基本的に全てのフィールドは文字列型になります。
  (tblfn-update '(equal Category "Dairy") "Price" '(* 2 (tblfn-to-number Price)))
  (tblfn-write-csv-file "new-inventory.csv"))

ヘッダー行(列名が入った行)が無かったり、余計なフッターがあったりする場合は別途それらを追加したり削除したりする必要があるでしょう。そのための関数もちゃんと用意しています。

ここで紹介した以外にも沢山の関数があります。README.orgに関数リストを載せておきました。とは言え私が思いつく限りのものでしかないので、おそらく足りないものはいくらでもあるでしょう。私が今後増やそうと思っているのはCalcを使った計算部分です。日付とかも対応したいんですよね。

README.orgには他にもいくつかの 蘊蓄 作っている間に気になったことを書いておいたので、ご利用の際はご一読ください。

それにしてもこんな簡単なことがなぜ最初からできるようになっていないのか未だによく分かりません。きっと私が知らないだけでみんなもっと簡単な方法ですでに解決しているんじゃないかと疑っています。そういうのはブログに書いて公開しましょう。秘密にしてちゃダメですよ!