Markdown Preview Enhancedを使いこなす
以前書いたCode Runnerを使いこなすがそこそこ需要あったので、お気に入りの拡張機能を深堀する記事第二弾として執筆しました。
Abstract
VScodeの拡張機能は様々なものが日夜開発されています。マークダウンのプレビューをする拡張機能もかなりの数存在していますが、圧倒的(主観)ダウンロード数を誇るのがMarkdown Preview Enhancedです。解説記事も世の中には多く存在していますが、例えばコードチャンクのオプションやmumeのparser.jsの細かなカスタマイズなどに触れている記事はあまり見つけられませんでした。そこで本記事ではMarkdown Preview Enhancedの機能(特にコードチャンク)を紹介しながら、主にそのカスタマイズに言及し、応用編ではマークダウンに挿入してあるPythonを仮想環境で実行させたり、生成されたグラフ画像を自動で保存させたりします。
注意:リンク先はVScode版のgitです。Atom版のgitはhttps://github.com/shd101wyy/markdown-preview-enhancedです。またここで語るのはVScodeの拡張機能としてであるため、atom版での動作は保証しません。(推測ですが、カスタマイズの中心は共通処理の部分mumeなので、流用は可能だと思われます。)
Markdown Preview Enhanced とは
略してMPEとも。
Markdown Preview Enhanced is a SUPER POWERFUL markdown extension for Atom and Visual Studio Code. The goal of this project is to bring you a wonderful markdown writing experience.
公式ドキュメントより
つまり最強に快適で拡張性の高いマークダウンのプレビュー拡張機能です。書かれている通り、Atomにも提供しているようですね。
他にも多数のマークダウンプレビュー拡張機能がありますが、単純にできることの多さ、ひいては拡張性の高さが良いと考えています。例えばマークダウンはHTMLにパースされて表示されているのですが、そのパース前・後に処理を独自に加えることができます。またJupyter Notebookのようにコードチャンク機能があり、ノートブックと同じ感覚で実行できます。他には画像をドラックアンドドロップで読み込んだりするImage Helper機能もあり、様々な便利機能の詰め合わせだと考えれば良いかと思います。
環境
本格的に機能の紹介に入っていく前に環境を明示しておきます。あまり関係ないと思いますが念のため。Pythonはコードチャンクの例などの際に用いています。3系であれば問題なく動くコードで示すつもりです。
- Windows 10
- VScode 1.60.1
- Python 3.7.9, 3.8.10
- pipenv version 2021.5.29
上記の通りWindowsなのでパス等はC:\hoge
で、ショートカットキーはctrl
で表記します。Macの方は適宜読み替えてください。
インストール
普通に拡張機能マーケットからインストールできます。
markdown previewで検索すると1番上に来ました(記事執筆時点)。
基本機能
勿論基本的な機能はプレビューです。
ctrl+K V
あるいは右クリックのコンテキストメニューからOpen preview to side
で隣のパネルにプレビューを表示します。
また応用編以降がメインのため、ここではコードチャンクを主役に据えて紹介をします。その他機能の多くは公式ドキュメント(日本語版)を読んでください。
ダイアグラム
この機能を普段使用していない筆者は、よくわかっていないことが多いので、公式ドキュメント読んでください。
網羅的な記事はVisual Studio Code+Markdownでチャート/グラフ/図を描画するには?がよさそう。
外部 File の Import
@ import "path/to/file" {options}
することで、様々なファイルを貼り付けることができる。超有能機能。
(公式ドキュメントより)
またコードブロックとして表示されるものについては、オプションでline_begin=2
等指定することで特定の行以降や特定の行までを表示することが可能になります。
外部 File の Import読みましょう。
コードチャンクと組み合わせるものについては以下でも触れます。
コードチャンク
個人的にMPEが最強たる所以かもしれない機能。
なんとマークダウンに埋め込んだコードブロックを実行します。
速度面ではおそらく最適化はされていないので、流石にJuptyer NoteBookの上位互換とまではいかないかも。とはいえ気軽に言語を問わずコードと実行と数式がすべてかけるのは非常に魅力的ですよね。残念な点は出力されたグラフの画像などを保存できないこと。しかしこれは応用編で解決策を提示します。
コードチャンクはデフォルトでは無効になっています!これはセキュリティ上の問題です。
自己責任において、setting.jsonで"markdown-preview-enhanced.enableScriptExecution": trueにしてください。
おすすめはワークスペースごとの設定にすることです。
コードブロックに言語後にオプションcmdを指定することで、その場で実行することが可能になります。
コードブロックの右上にallと▷ボタンがあります。allはすべてのコードブロックを実行するもので、▷はそのブロックのみ実行します。
デフォルトでは以下のように実行結果がすぐ下に生成されます。
Pythonの場合、もちろんpythonのパスが通っていないと実行されません。他言語でも同様です。
またcmdは任意のコマンドをとることができます。単に{cmd}とした場合は言語名がコマンドとして使われます。そのため言語名と実行コマンドが異なるような言語ではcmdを指定することが必須になります。
また、コマンドライン引数を指定することも可能です。
$input_fileはマクロで、実行時に入力ファイル名に置換されます。
※内部的には、コードブロックをファイルにまとめて、それを渡すという形をとっています。そのファイル名のことです。これは一時的なファイルなので処理が終了すると消えます。
また@ importでも対応します。例として適当に作ったhello.pyを同ディレクトリに保存しました。
print("Hello")
そしてマークダウンで@ importすると・・・
もう最強感ありますね。
行を表示したいときも簡単に実現します。適当なファイルtemp.pyを用意しました。
"""
@importされるファイル。
"""
import sys
import platform
def main():
print(sys.version)
print(platform.platform())
if __name__ == '__main__':
main()
そして・・・
この行数を表示するクラスは他の通常のコードブロックでも通用します。またこのクラスはhtmlにおけるクラスでもあるので、cssで指定することが可能になります。しゅごい・・・
よし、きっとmatplotlibもプレビュー上に!!
残念でした!別窓です!!!・・・・・これを回避するには、オプションにmatplotlib
を指定します。
(プレビューのスクリーンショット)
なるほど、これなら問題ない。しかしながら保存ができない・・・出力形式を指定するオプションでoutput="png"
とかしても、強制的に埋め込みのsvgになっています。ソースコードを見ると、matplotlibで指定した瞬間に出力はmarkdownになってしまうみたい。この問題の解決のヒントになりそうなことは後半に。
また、hideオプションを使うとコードを隠して実行結果のみ表示できます。
何に使えるのかというと、基本文章はマークダウンで書きたいけど、グラフを追加したいという時です。次の図のように、簡単なグラフの作成であればマークダウン上で編集して実行することができます。やばくね???????
さて、最も基本的な機能を上記では紹介しましたが、もちろん他には多くの機能が搭載されています。
公式ドキュメントのコードチャンクのページを読むのがよいでしょう。本記事はどちらかと言えば拡張編以降に力を入れている記事なのでここで基本的な機能の紹介は終わることにします。めんどくさいわけではないよ。
拡張編
MPEは様々な拡張が可能です。MPE側が想定しているであろう拡張の紹介を行います。
主に~/.mume
のフォルダ、つまりWindowsではC:\Users\takeMe1010\.mume
以下のファイルを変更すること可能になります。
このフォルダの指定は拡張機能の設定で変更可能で、markdown-preview-enhanced.configPath
で指定可能です。
style.less
lessを用いて、表示されるプレビューに装飾を加えることができます。
編集するファイルstyle.lessの場所は~/.mume
です。
ちなみにデフォルトだとmarkdown-preview-enhanced.previewTheme
で設定されたcssが使われます。setting.json
から選ぶか、プレビュー画面で右クリックして、Preview Themeから選択することで変更できる。すべて自分で指定したいときは、none.cssを指定しよう。
さて、デフォルトでは以下のようになっています。
/* Please visit the URL below for more information: */
/* https://shd101wyy.github.io/markdown-preview-enhanced/#/customize-css */
.markdown-preview.markdown-preview {
// modify your style here
// eg: background-color: blue;
}
プレビューで主に表示される部分が.markdown-preview.markdown-preview
となっているので、基本的にはこの部分を編集することになります。
以下は自分用のstyle.lessファイルです。参考にしてもらえれば。
私はたまにmarkdown-pdfを使うので、そちらでも使う都合上、bodyに対して同じcssをつけたいという希望から.markdown-preview.markdown-preview
の外側にも.main()
を展開しています。
ちなみにこの記事のプレビューのスクリーンショットはnone.cssを指定して、以下のstyle.lessを用いています。
// 線などの色
@line-color: #2f51b4;
// 表のヘッダーの色
@table-header-color: rgb(121, 190, 255);
/* 色装飾関係のCSS */
.colors() {
.red {
color: red;
}
.blue {
color: blue;
}
.green {
color: green;
}
.yellow {
color: yellow;
}
}
.hackgen {
font-family: 'HackGen', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.hackgenNerd {
font-family: 'HackGenNerd', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.applyCodeBlockFont(@font: "HackGenNerd") {
pre[data-role=codeBlock] {
font-family: @font, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
}
.basic() {
background-color: white;
color : black;
}
/**
* メイン部分
*/
.main() {
body {
.basic();
}
// この辺はmarkdown-pdfのstylesフォルダから抜粋。
h1,
h2,
h3 {
font-weight: normal;
}
h1 {
padding-bottom : 0.3em;
line-height : 1.2;
border-color : black;
border-bottom-width: 1px;
border-bottom-style: solid;
}
h2 {
position : relative;
padding-left : 18px;
padding-bottom: 0.3em;
line-height : 1.2;
&::before {
background: @line-color;
content : "";
height : 20px;
width : 5px;
left : 0px;
position : absolute;
top : 3px;
}
}
h3 {
display : inline-block;
border-bottom: solid 1px @line-color;
}
img {
max-width : 100%;
max-height: 100%;
}
hr {
border : 0;
height : 2px;
border-bottom: 2px solid;
}
.bold {
font-weight: bold;
}
.italic {
font-style: italic;
}
table {
width: 95%;
margin: auto;
border-collapse: collapse;
tr {
th {
background-color: @table-header-color;
}
th, td {
border: solid 1px black;
text-align: center
}
}
}
.applyCodeBlockFont();
.colors;
.right {
text-align: right
}
.center {
text-align: center
}
.left {
text-align: left
}
}
/* bodyとmarkdown-preview.markdown-previewに適用して、previewとpdfのCSSを共通にする */
.main;
.markdown-preview.markdown-preview {
.main;
.basic();
@media print {
p {
font-size: 12px;
}
}
}
表示はこんな感じ。
※lessの文法は解説しません。
私はHackGenを愛用しているため、フォントファミリーをそのように設定しています。インストールしていなければ反映されないので注意。
katex_config.js
Katexでは、マクロを定義して使うことができます。
ソースコードを見るとマクロ以外にもオプションを指定できそうであるけれどあまり使わなさそうだし割愛。使用できるオプションはおそらく公式ドキュメントにあるもの。
例えば以下のように書きます。\d
を\mathrm{d}
に置き換えるマクロです。例によって\
はエスケープが必要。
module.exports = {
macros: {
"\\d": "\\mathrm{d}"
}
}
MathJaxの方(mathjax_config.js)はよくわかりませんでした・・・
mermaid_config.js
mermaid.jsで図を描く時の設定ファイル。
ソースコードを見ると、生成されるhtmlのmermaidを読み込むscriptの冒頭に直接埋め込まれるみたいです。
設定できる値は公式ドキュメントのExample 2の形で使える値かな?
普段mermaidを使用しないので、これ以上は触れないでおきます。自分で使う時が来たら追記するかも。
parser.js
同様に~/.mume
に存在するはず。
このファイルに追加をすることで機能を勝手に追加したりすることが可能になります。
デフォルト設定は以下。コメントはこちらで加筆したものです。
module.exports = {
// 書いているmarkdownの文字列を受け取る。HTMLに変換される前にマークダウン文書に変換をかますことができる。
onWillParseMarkdown: function(markdown) {
return new Promise((resolve, reject)=> {
// ココでmarkdownの文字列を加工する
return resolve(markdown)
})
},
// マークダウンがHTMLに変換された後に、プレビューとして表示するHTMLへ渡される前に変換をかますことができる。
onDidParseMarkdown: function(html, {cheerio}) {
return new Promise((resolve, reject)=> {
// ココでHTMLの文字列を加工する
return resolve(html)
})
},
// ソースコードをみると、以下二つはtransformする前と後に呼ばれるものみたい。軽く見た感じでは上記二つで十分足りそうな感じ。
// pandocで出力する際には、`onWillParseMarkdown`が呼ばれた後に`onWillTransformMarkdown`及び`onDidTransformMarkdown`が呼び出される?(要検証)
onWillTransformMarkdown: function (markdown) {
return new Promise((resolve, reject) => {
return resolve(markdown);
});
},
onDidTransformMarkdown: function (markdown) {
return new Promise((resolve, reject) => {
return resolve(markdown);
});
}
}
公式ドキュメントの例では、全てのheader要素つまり#
の前に😀を加える仕様でparser.jsを改変しています。
module.exports = {
onWillParseMarkdown: function(markdown) {
return new Promise((resolve, reject) => {
markdown = markdown.replace(/#+\s+/gm, ($0) => $0 + "😀 ");
return resolve(markdown);
});
},
};
つまり各行に対して、先頭が#
或いは##...
と#が続く形の時に(正規表現の#+に対応)、その部分を取り出して後ろに顔文字を加えることで、実装していることになります。(replaceについてはJavaScriptのリファレンスのString.prototype.replace()を参照)
このparser.jsには可能性を感じますね!
続く応用編では色々試していきます。
応用編
以下では上記の仕様を利用したり、拡張機能のソースコードを書き換えたりして更に利便性を高めます。
とくに後者は間違えたりすると正常な動作をしなくなったりするので、自己責任で。まぁ失敗したら再インストールすればいいんですけど。
Pipenvと組み合わせる
私は仮想環境にPipenvを現在愛用しています。
解説記事はこちらがおススメ→Pipenvを使ったPython開発まとめ
今回仮想環境は3.8の環境でやってみます。
C:\hoge>pipenv --python 3.8
// グラフの動作確認に使用するので
C:\hoge>pipenv install matplotlib
仮想環境を作ったのち、適当に同ディレクトリに以下のようなtest_pipenv.md
を作成します。
Pipenvでは仮想環境で走らせるときにはpipenv run python -u hoge.py
とするからですね。
ctrl+shift+Enter
で全コードを実行すると
私はグローバルではpython 3.7なので、一つ目は3.7と出ています。二つ目は今回作成した仮想環境になっています!
とはいえ、毎回argsを指定するのも骨が折れます。そこで~/.mume/parser.js
に追記を行います。
/**
* コードチャンクの{pipenv}を実行用のpipenv run python -u $input_file に変換して返す
* @param {string} markdown
* @returns {string}
*/
function convertPipenv(markdown) {
const lines = markdown.split("\n");
for (let i = 0; i < lines.length; i++) {
lines[i] = lines[i].replace(
/```python\s+{pipenv([\W\w\s]*)}/g,
"```python {cmd=\"pipenv\" args=[\"run\", \"python\", \"-u\", \"\$input_file\"]$1}"
).replace(
/@import\s+("[\W\w]+")\s+{pipenv([\W\w\s]*)}\s*/g,
"@import $1 {cmd=\"pipenv\" args=[\"run\", \"python\", \"-u\", \"\$input_file\"]$2}"
);
}
return lines.join("\n");
}
module.exports = {
/**
* markdown-itに放り込まれる前の文字列を扱う部分
* @param {string} markdown マークダウンの文字列
* @returns resolve(マークダウンの文字列)
*/
onWillParseMarkdown: function(markdown) {
return new Promise((resolve, reject) => {
// この段階ではマークダウンの文字列。この返却値をmarkdown-itに放り込む。
markdown = convertPipenv(markdown);
return resolve(markdown);
});
}
}
(なお上記の実装は1行ずつ見ていく仕様になっています。正直大きなマークダウンファイルでは遅いと思われるので、何らかの改良が必須…)
これにより、オプションの先頭にpipenv
とつけるだけで、pipenvで実行するコードチャンクができるようになりました!
ちなみに@ importでも動作します。偉い。めでたしめでたし、と思っていたらmatplotlibがプレビューにうまく出力されない・・・
そこで原因を探るべくアマゾンの奥地・・・ではなく拡張機能のソースコードへ向かった。
どうやら原因は~/.vscode/extensions/shd101wyy.markdown-preview-enhanced-0.6.0\\node_modules\\@shd101wyy\\mume\\out\\src\\code-chunk.js
みたい。
この81行目を変更しました。
// 81行目 before
if (cmd.match(/python/) &&
(normalizedAttributes["matplotlib"] || normalizedAttributes["mpl"])) {
// 色々
}
// after
if ((cmd.match(/python/) || cmd.match(/pipenv/)) &&
(normalizedAttributes["matplotlib"] || normalizedAttributes["mpl"])) {
// 色々
}
同様に~/.vscode/extensions/shd101wyy.markdown-preview-enhanced-0.6.0\\node_modules\\@shd101wyy\\mume\\out\\src\\render-enhancers\fenced-code-chunks.js
の169行目も変更します。この変更は次のセクションの話の自動で保存のために必要みたいです。※ここも変更したらうまくいったレベルです。このソースがどこから呼ばれるのかすらわからなかったので・・・
//before
else if (cmd.match(/python/) &&
(normalizedAttributes["matplotlib"] || normalizedAttributes["mpl"])) {
outputFormat = "markdown";
}
// after
else if ((cmd.match(/python/) || cmd.match(/pipenv/)) &&
(normalizedAttributes["matplotlib"] || normalizedAttributes["mpl"])) {
outputFormat = "markdown";
}
これによってコマンドがpipenvでもオプション{matplotlib}が有効になり、グラフがプレビューに出力されるようになりました。めでたしめでたし。
matplotlibで出力したグラフを自動で保存する
Q. そもそも拡張機能はどうやってグラフをマークダウンに取り込んでるのだろうか?
A. plt.showを書き換えて、出力先を標準出力にsvgで書き出すようにして取得している。
その処理の部分は~\.vscode\extensions\shd101wyy.markdown-preview-enhanced-0.6.0\node_modules\@shd101wyy\mume\out\src\code-chunk.js
に記述されています(92行目以降から抜粋)。
def new_plt_show():
plt.savefig(sys.stdout, format="svg")
plt.show = new_plt_show
これをプログラムの実行時に、先に実行することでplt.show
を上書きしているみたい。
よってもっとも簡単そうなのはこの関数に追加で保存をすることです。しかし出力先は固定になっていしまうのに加え、ファイル名はランダムな名前にしないと上書き保存が多発しそう。なんとも使い勝手が悪そうです。
出力されたsvgを取得できるタイミング・・・ありますね。そうparser.jsのonDidParseMarkdown(html)
です。このhtmlにはsvgが含まれています。確認は簡単で、適当にmatplotlibのグラフを描画して表示させたhtmlをブラウザで開き(プレビューで右クリック:Opne in browser)、デベロッパーツール(Chromeだとctrl+shift+I)で見てみてください。この方法で表示されているプレビューのhtmlを見ることができるので、覚えておくとよいでしょう。
ちなみに出力されているHTMLは以下のようになっています。
例えば以下のようなマークダウンを書いたときは
概ね以下のようになっています。(...は省略の意味)
<html>
<head>
...
</head>
<body>
<div class="mume markdown-preview">
<h1 class="mume-header" id="matplotlib%E3%81%AE%E3%83%86%E3%82%B9%E3%83%88">matplotlibのテスト</h1>
<div class="code-chunk" data-id="cube" data-cmd="python">
<div class="input-div">
<pre data-role="codeBlock" data-info="python {code_chunk_offset=0, cmd matplotlib id="cube"}" class="language-python">...</pre>
</div>
<div class="output-div">
<svg ...>
...
</svg>
</div>
</div><!--div.code-chunk-->
</div>
</body>
</html>
したがって、目的のsvgは<div class="output-div">
の子孫に存在していることがわかりますね。またオプションで指定したidは<div class="code-chunk">
のデータ属性が保持していることが確認できます。またその他code-chunkのオプションは<div class="input-div">
の子孫の<pre>
のデータ属性が保持していることも確認できます。
以下ではnpmを用いるため、node.jsが必要です。ググって導入してください。
さて、html文字列の操作を簡単にするため、cheerioを導入します。
ちなみにcheerioだけなら、onDidParseMarkdown(html, {cheerio})
という感じで引数で受け取ることができますが、そのほかの拡張を考えるとnpm init
しておいて損はない気がする。
// もしnpm initをしていないならしてから。
C:\Users\takeMe\.mume>npm install cheerio
parser.jsを以下のようにしてみます。
const path = require("path");
const fs = require("fs");
const cheerio = require("cheerio");
/**
* 出力されたSVGを全て保存する。
* @param {cheerio.CheerioAPI} $
* @param {string} saveDir 保存フォルダ
*/
function saveAllSvg($, saveDir) {
$(".output-div").children("svg").each((i, elem) => {
const svg_str = $.html(elem);
/**@type {string} */
const code_chunk_id = $(elem.parent.parent).data("id");
let stem;
// デフォルトの名前の時
if (code_chunk_id.match(/code-chunk-id-\d+/)) {
stem = code_chunk_id + "-" + i.toString();
} else {
stem = code_chunk_id;
}
const filepath = path.resolve(saveDir, stem + ".svg");
fs.writeFileSync(filepath, svg_str);
});
}
module.exports = {
/**
* HTMLに変換された後の文字列を扱う部分。
* @param {string} html HTMLの文字列
* @param {string} projectDir プロジェクトのルートフォルダ
* @returns resolve(HTMLの文字列)
*/
onDidParseMarkdown: function(html, projectDir) {
// ここはHTMLの文字列。
return new Promise((resolve, reject) => {
const $ = cheerio.load(html);
// projectディレクトリのcode-outputというフォルダに保存するという意味。tempフォルダにしたかったらここを変更。
const saveDir = path.join(projectDir, "code-output");
saveAllSvg($, saveDir);
return resolve(html);
});
}
}
! 本来projectDir
という引数は存在しません。なので拡張機能のソースコードをいじって情報を持ってきます(力技)。…プルリクとかしたら喜ばれるのかな?
このparser.jsを呼び出している部分はmumeのmarkdown-engine.jsです。よって~\.vscode\extensions\shd101wyy.markdown-preview-enhanced-0.6.0\node_modules\@shd101wyy\mume\out\src\markdown-engine.js
の2053行目を変更します。markdown-engine.jsを頑張って開いてcrlt+G
で2053行目にジャンプするのがよいかと。
// before
html = yield utility.configs.parserConfig["onDidParseMarkdown"](html, {
cheerio,
});
// after
html = yield utility.configs.parserConfig["onDidParseMarkdown"](html, this.projectDirectoryPath, {
cheerio,
});
これで実行結果にsvgがあれば、そのコードチャンクのidを名前にして保存することができます。ただし上記の仕様だと、二個以上のグラフで上書きが発生することがあることに気が付きました。これは直したいですね。。。
現状svgになっていますが、png形式に変更するコードをnpm install
してなんとかすれば変換は容易なはず。
またhtmlにはコードチャンクのオプション等が保持されているので、色々頑張れば独自のオプションを設定して、保存ディレクトリをコード側で指定するみたいなこともできそうです。
トラブルシューティング
自分が直面したトラブルについてメモしておく場所でもあります。
chromeでPDF出力を選ぶと数式がレンダリングされない問題
レンダリングを行う部分の処理が、エクスポートを行う処理よりも遅いことで引き起こされる問題っぽい。
issueだと#638, #375がこの問題に対応しているっぽい。
対処はsettings.jsonで下記の設定を追加することになります。
{
...
"markdown-preview-enhanced.puppeteerWaitForTimeout": 3000, // 最大3000msまでレンダリングの処理を待つ
...
}
まとめ
本記事ではコードチャンクを中心に、parser.jsのカスタマイズについて紹介してきました。
Markdown-Preview-Enhancedは他にも素晴らしい機能を数多く搭載しているため、ぜひ公式ドキュメント(日本語版)を読んでみてください。
きっとあなたがしたいと思ったことは既に存在しているか、あるいはカスタマイズ可能なことでしょう。
皆様の快適なMPE生活の参考になれば幸いです。
take_me
Log
- 2021/09/21 投稿
- 同日 pipenvだと画像が自動保存できない問題を解決(?)
- (2022/12/01) トラブルシューティングを追加. 数式のレンダリングができないissueに言及