Obsidian の Canvas json を Python で扱う方法を考えてみた

こんにちは。突然ですが、Obsidian というメモアプリはご存知でしょうか?
obsidian.md

Markdown のメモの他、Canvas という機能で簡単な図式を書くこともできます。下記は適当に落書きしてみたものです。

Canvas は .canvas というファイルで保存されますが、今回はこのファイルを Python で取り扱う方法について紹介します。

.canvas ファイルの正体 = json

Canvas 機能で図を作成すると、Obsidian Vault のフォルダ内に ***.canvas というファイルが生成されます。このファイルの拡張子を .json に変えて VSCode などのエディタで開いてみると、図に対応するデータが書かれたファイルになっていることが分かります。下記は冒頭の落書きのファイルを .json に書き換えて出てきたものです。

{
	"nodes": [
		{
			"id": "c2a6cd9bac5f3f55",
			"x": -600,
			"y": -153,
			"width": 250,
			"height": 60,
			"color": "1",
			"type": "text",
			"text": "ポテチを食べる"
		},
		{
			"id": "52239ae6b4f15e84",
			"x": 215,
			"y": -126,
			"width": 221,
			"height": 67,
			"color": "1",
			"type": "text",
			"text": "アイスを食べる"
		},
		{
			"id": "2e8cca1b99c9b22e",
			"x": -242,
			"y": -340,
			"width": 302,
			"height": 80,
			"color": "4",
			"type": "text",
			"text": "甘いものが食べたくなる"
		},
		{
			"id": "7488195feb97e72c",
			"x": -242,
			"y": 60,
			"width": 312,
			"height": 80,
			"color": "4",
			"type": "text",
			"text": "しょっぱいものが食べたくなる"
		}
	],
	"edges": [
		{
			"id": "6e26c77fac2ea559",
			"fromNode": "c2a6cd9bac5f3f55",
			"fromSide": "top",
			"toNode": "2e8cca1b99c9b22e",
			"toSide": "left"
		},
		{
			"id": "52c6c125ab12ce78",
			"fromNode": "2e8cca1b99c9b22e",
			"fromSide": "right",
			"toNode": "52239ae6b4f15e84",
			"toSide": "top"
		},
		{
			"id": "474cd8063e26d8fa",
			"fromNode": "52239ae6b4f15e84",
			"fromSide": "bottom",
			"toNode": "7488195feb97e72c",
			"toSide": "right"
		},
		{
			"id": "e1703f60094f0881",
			"fromNode": "7488195feb97e72c",
			"fromSide": "left",
			"toNode": "c2a6cd9bac5f3f55",
			"toSide": "bottom"
		}
	]
}

似たような記述の繰り返しになっているので、すこし丁寧に構造を見てみましょう。
まず、全体は dict の形になっています。key は "nodes" と "edges" の二つです。それぞれグラフ理論の用語で頂点と辺を表すものです。下図でいうと〇がノード、線分がエッジです。

では "nodes" と "edges" には何が格納されているのか見てみましょう。それぞれ list の形になっていて、いくつかの dict が格納されているようです。便宜的に nodes の要素を node、edges の要素を edge と呼ぶことにします。

node は以下のような形をしています。各 node は各カードの情報を記述しているようです。

{
	"id": "c2a6cd9bac5f3f55",  # 各カード(文字が書かれた枠のこと)に対応する ID
	"x": -600,  # カードの左上の頂点の x 座標。単位はピクセル。右方向が正
	"y": -153,  # カードの左上の頂点の y 座標。単位はピクセル。下方向が正
	"width": 250,  # カードの横幅。単位はピクセル。
	"height": 60,  # カードの縦幅。単位はピクセル。
	"color": "1",  # カードの色。"1" は赤、"4" は緑。
	"type": "text",  # カードの中に記述するデータのタイプ
	"text": "ポテチを食べる"  # カードの中に記述するデータ
}

edge は以下のような形をしています。各 edge は各矢印の情報を記述しているようです。

{
	"id": "6e26c77fac2ea559",  # 矢印に対応する ID
	"fromNode": "c2a6cd9bac5f3f55",  # 矢印の根本のカードの ID
	"fromSide": "top",  # カードのどこから矢印が生えるか。top/bottom/left/right のいずれか
	"toNode": "2e8cca1b99c9b22e",  # 矢印の先端のカードの ID
	"toSide": "left"  # カードのどこへ矢印が刺さるか。top/bottom/left/right のいずれか
}

Canvas で書かれた図式は、カードが node(頂点)にあたり、カード同士を結ぶ矢印を edge(辺)とみなしている、という訳ですね。そして各 node と edge の内容には、それらを既定するのに必要な情報がシッカリと書かれているようです。

Pythonjson を扱いたい

ところで Python には json ファイルを取り扱うための機能が標準ライブラリにあります。名前は json と言います。ドストレートですね。
docs.python.org

例えば図式のファイルが "fat_cycle.canvas" だったら、こんな感じ↓で読み込むことができます。

import json

with open("fat_cycle.canvas", "r") as f:
    canvas_data = json.load(f)

print(canvas_data)  # 上記の json が表示される

json.load という関数がファイルの中身を自動で読み取って、Python で扱いやすい dict と list の入れ子に自動変換してくれるというスグレモノです。逆に dict と list の入れ子json ファイルとして保存することももちろん簡単です。そのときは json.dump という関数を使います。

import json

data = {
    "stomach": "empty", 
    "food": "Pudding", 
    "tasks": ["Bath", "Homework"],
}

with open("status.json", "w") as f:
    json.dump(data, f)  # json ファイルとして保存

ここでは保存先の拡張子を .json としましたが、この部分を .canvas とすることももちろん可能です。

Python で .canvas を扱いたい

ここまでの内容から、Python を使えば .canvas ファイルを自在に操ることが可能であることが分かったと思います。しかし、dict と list の入れ子のデータを直接いじいじするのって結構大変そうです。そこで、クラスを使ってもう少しだけプログラムしやすいように整理してみましょう。

.canvas ファイルを構成する要素は node と edge の二つでした。それぞれをクラスで表現することを考えてみます。
まず node に対応するクラス Node を定義します。

from typing import NamedTuple

class Node(NamedTuple):
    id: str
    x: int
    y: int
    width: int
    height: int
    color: str
    text: str

    def to_json(self) -> dict:
        return {
            "id": self.id,
            "x": self.x,
            "y": self.y,
            "width": self.width,
            "height": self.height,
            "color": self.color,
            "type": "text",  # 今回は text の場合のみを扱うので type はハードコード
            "text": self.text,
        }

一対一に構造を対応させるのであれば type も Node クラスのデータに含めるべきですが、今回は type="text" の場合のみを取り扱うと心に決めて、あえて type は外しています。今着目したい目的に合わせて、必要以上の一般化を行わない、というのも時には必要な判断です。

同様に edge に対応するクラス Edge を定義します。

from enum import Enum, auto
from typing import NamedTuple

class Node(NamedTuple):
    ... # (略)


class NodeSide(Enum):
    TOP = auto()
    BOTTOM = auto()
    LEFT = auto()
    RIGHT = auto()


class Edge(NamedTuple):
    id: str
    from_node: Node
    from_side: NodeSide
    to_node: Node
    to_side: NodeSide

    def to_json(self) -> dict:
        return {
            "id": self.id,
            "fromNode": self.from_node.id,
            "fromSide": self.from_side.name.lower(),
            "toNode": self.to_node.id,
            "toSide": self.to_side.name.lower(),
        }

いくつかポイントがあります。

  • from_node, to_node の型は str ではなく Node にしています。最終的には str が渡されるわけですが、プログラムで扱う場合は直接 Node 型のインスタンスが入っていた方が直観的な上、id を取り違えるといったミスも防げます。
  • from_side, to_side は str ではなく NodeSide という enum にしました。入るデータの種類があらかじめわかっていて数えるほどしかない場合は、Enum として定義したほうがプログラムしやすいです。書き間違いも防げます。*1
  • json の key は lowerCamelCase ですが、Python のプロパティは snake_case を採用しています。同じように lowerCamelCase を使ってもよいのですが、一応 PEP8 的な流儀に従ってみました。ここでのポイントは、プログラムを書くときに扱いやすいように決める、ということです。
  • 上記の工夫に伴って、to_json の中では str を作るためのルールが記述されるようになりました。

node と edge をクラスで表現できたので、.canvas のデータ全体に対応するクラスも作っておきます。

from enum import Enum, auto
from typing import NamedTuple

class Node(NamedTuple):
    ... # (略)


class NodeSide(Enum):
    ... # (略)


class Edge(NamedTuple):
    ... # (略)


class Canvas:
    def __init__(self):
        self.nodes: [Node] = []
        self.edges: [Edge] = []
    
    def to_json(self) -> dict:
        return {
            "nodes": [node.to_json() for node in self.nodes],
            "edges": [edge.to_json() for edge in self.edges],
        }

これで .canvas ファイルは Canvas クラスのインスタンスとして表現できるようになりました!*2

Python で .canvas を作る

ここまでのクラスを使って、冒頭の図式っぽいものを Python で作ってみましょう。説明するよりコードを見た方が早いと思うので、いきなりコードを書きます。

# 前略。各クラスを定義しておく


def main():
    import json

    canvas = Canvas()
    canvas = Canvas()
    node_potechi = Node("Potechi", -300, 0, 300, 50, "1", "ポテチを食べる")
    node_want_sweet = Node("WantSweet", 0, -100, 300, 50, "4", "甘いものが食べたくなる")
    node_icecream = Node("Icecream", 300, 0, 300, 50, "1", "アイスを食べる")
    node_want_salty = Node("WantSalty", 0, 100, 300, 50, "4", "しょっぱいものが食べたくなる")
    edge_p2sw = Edge("P2SW", node_potechi, NodeSide.TOP, node_want_sweet, NodeSide.LEFT)
    edge_s2i = Edge("S2I", node_want_sweet, NodeSide.RIGHT, node_icecream, NodeSide.TOP)
    edge_i2sl = Edge("I2SL", node_icecream, NodeSide.BOTTOM, node_want_salty, NodeSide.RIGHT)
    edge_s2p = Edge("S2P", node_want_salty, NodeSide.LEFT, node_potechi, NodeSide.BOTTOM)

    canvas.nodes = [node_potechi, node_want_sweet, node_icecream, node_want_salty]
    canvas.edges = [edge_p2sw, edge_s2i, edge_i2sl, edge_s2p]

    with open("fat_cycle.canvas", "w") as f:
        json.dump(canvas.to_json(), f)


if __name__ == '__main__':
    main()

上記プログラムで生成される fat_cycle.canvas を Obsidian Vault の下において Obsidian で読み込むと、以下のような画像が表示されます。いい感じですね。

プログラムを使って図式を書くことに成功したので、もっと変態的な図式を書くことも可能です。例えば100個のカードが規則的に並んだ複雑な図式なんかも書けそうですね。

Python で .canvas を読み込む

疲れてきたので実装の詳細は省きますが、一応既存の .canvas ファイルを読み込む方法についても述べておきます。
前のセクションでは Node や Edge のインスタンスたちを手で作った上で .canvas ファイルを生成しましたが、ファイルを読み込む場合はこのインスタンスを作る工程をファイル準拠で行うことになります。これを達成するには、以下のように実装すると見通しが良いと思います。

  • Canvas クラスに from_json というクラスメソッドを定義します。実装は「引数に与えられた json のデータの "nodes" に入っているものたちから Node のインスタンス群を作り、そのあと "edges" に入っているものたちから Edge のインスタンス群をつくる」という風に与えます。
  • 上記の Node や Edge を作る部分は、Node と Edge の中で実装を与えるようにします。つまり Node, Edge にも from_json というクラスメソッドを定義するという具合です。こちらも引数で受け取った dict からインスタンスを作れるようにします。

読み込む処理を実装する場合の注意点としては、Node や Edge のクラスの構造としては、読み込む対象に見合うだけのものを準備する必要があることが挙げられます。読み込むファイルが冒頭の落書きのファイルだけなら、ここまでのクラスに新たにメソッドを追加するだけで済むと思いますが、もっと多種多様な機能を使った .canvas ファイル(例えば両方向向きの矢印を使っているとか、画像ファイルを使っているとか)を相手にしたい場合は、より一般的なケースに対応できるようなクラスに拡張することが必要となります。もし何かしら便利ツールを作りたい場合はご注意ください。

まとめ

Python を使って Obsidian の Canvas のファイルを扱う方法について紹介してきました。ぜひ自分で動かしてみて、変態的な図式をたくさん作ってみてください。

なお .canvas の中身である json の構造の仕様については、公式ドキュメントでかなり丁寧に説明されています。この記事で紹介した機能はこのうちのほんの一部なので、もしもっとたくさん機能を使って書きたい場合は参照しながら拡張してみてください。
公式ドキュメント↓
jsoncanvas.org

ではまた。

*1:ちなみに Node の color も Enum で管理するという手もあります。実際公式ドキュメントによれば "1"~"6" それぞれに対応する色があるようです。ただこっちは "1"~"6" の他にも "#FF0000" のようなカラーコードでもよいらしいので、どうすべきか悩みどころです。color に与えられるデータの詳細については、次のページの Color の部分を参照してください。JSON Canvas — JSON Canvas Spec

*2:この記事で与えているものは、Canvas json の仕様を網羅したものではありません。これらを利用する場合は、必要に応じて仕様を追加したり削ったりしてください。Canvas json 自体の仕様は次の公式ドキュメントを参照してください。JSON Canvas — JSON Canvas Spec