プログラミング

【Inkscape対応/無料ですぐ使える】latex数式SVG抽出保存ツールとその作り方をはじめから

latex数式をSVGファイルに保存してInkscapeなどで読み込むことができるツールを公開しました。また,プログラミング初心者向けに詳しいコードの解説をしているので,参考にしてください。

https://mmsankosho.com/mjsvgoutput/

ad

特徴

日ごろ数学関係のブログを書いているとき,Inkscapeで作成したグラフに数式を入れることが多いのですが,なかなか手軽に使えるツールが無いので自分で作りました。数式の描画にはMathjaxを使用しています。

いろいろ余計な要素を排除して,純粋にInkscapeに取り込むことができるSVGファイルを作るのが目的です。ダウンロードされたSVGファイルはスケール調整されているので,Inkscapeで取り込んだときに適度な大きさで取り込まれます(スケール調整しないと用紙を大幅にはみ出る大きなオブジェクトとして取り込まれる)。

さらに,表示したコードをクリップボードにコピーする機能も付けました。この場合,スケール調整は行われません。

この機能の使い方の一つは,コピーしたコードをHTMLに直接貼り付けることです。こうすることで,Mathjaxをインストールせずに手軽にホームページやブログに数式を挿入することができます。

数式を頻繁に使用するわけではないが,いちいち数式の画像を作るのも面倒だし,SVG画像ならフォントの縁もきれいに表示されるので,コードのコピペで対応できた方が楽で良い,という場合には便利です。

WordPressの場合は「ブロックの追加」→「カスタムHTML」でコピーしたコードを書きこむと,ブログの記事に数式を入れることができます。

このブログもWordpressで作成されています。上は表示サンプルです。ブラウザがよほど古いものでない限り,問題なく表示されていると思います。この数式はMathjaxをインストールするのではなく,HTMLに直接SVGのコードを書きこむことによって表示しています。実際自分で試してみたところ,画像ファイルをアップロードする手間が省けてコピペ一発でいけるので,非常に楽でした。

ad

未解決の問題

今のところ数式を画面右端で自動で折り返すことはできません。今回はMathjax ver3を使って作成していますが,なぜかver2でできたはずの折り返し機能がver3では未対応です(そのうち対応するらしい)。ver3はまだネット上の情報も少なく,この問題の解決には時間が必要です。

ad

コードの解説

備忘録として,コードの動作について書いておきます。

<script>
MathJax = {
  loader: {load: ["input/tex", "output/svg"]}
};
MathJax = {
  svg: {
    scale: 1,                      // global scaling factor for all expressions
    minScale: .001,                  // smallest scaling factor to use
    mtextInheritFont: false,       // true to make mtext elements use surrounding font
    merrorInheritFont: true,       // true to make merror text use surrounding font
    mathmlSpacing: false,          // true for MathML spacing rules, false for TeX rules
    skipAttributes: {},            // RFDa and other attributes NOT to copy to the output
    exFactor: .5,                  // default size of ex in em units
    displayAlign: 'left',        // default for indentalign when set to 'auto'
    displayIndent: '0',            // default for indentshift when set to 'auto'
    fontCache: 'local',            // or 'global' or 'none'
    localID: null,                 // ID to use for local font cache (for single equation processing)
    internalSpeechTitles: true,    // insert <title> tags with speech content
    titleID: 0                     // initial id number to use for aria-labeledby titles
  }
};
</script>

ヘッダーに初期設定を書きます。デフォルトの設定をあえていじる部分はほとんどないので,細かい説明は省きます。scaleの値を変更すると表示される数式の大きさを変えることができます。

<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-svg.js"></script>

mathjaxをCDNで読み込みます。mathjaxにはいくつか種類があるのですが,今回はSVG形式が必要なので,tex-mml-svg.jsを読み込みます。

<script
  src="https://code.jquery.com/jquery-3.5.1.min.js"
  integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
  crossorigin="anonymous"></script>
<link rel="stylesheet" href="mjsvgoutput.css"  type="text/css">

コードではjqueryを使っているので読み込みます。また,サイトのcssファイルを設定しておきます。

    <p class="index">latex数式</p>
    <textarea id="equation" name="equation" rows="10" cols="30">\int_0^1 x^2\enspace dx</textarea>
    <p class="index">数式</p>
    <div id="latex_equation">$$\int_0^1 x^2\enspace dx$$</div>
    <div class="button_wrapper">
      <button type="button" id="svg_save" class="button" name="svg_save">SVGファイルをダウンロード</button>
      <button type="button" id="clipboard_copy" class="button" name="clipboard_copy">クリップボードにコピー</button>
    </div>
    <div id="message"></div>
    <textarea id="svg_code" name="equation" rows="10" cols="80">ここにSVGのコードが出力されます。</textarea>

bodyの中身です。<textarea>で数式の入力欄とコードの出力欄を設置します。また,<button>でダウンロードとクリップボードにコピーするボタンを設置します。

入力欄-文字が入力されたら数式を再描画する

$('#equation').on('keyup', function() {

数式入力欄のイベントリスナーを設置します。ここはjqueryによる記述になります。$(対象となるセレクタ).on()とすることで,対象となるセレクタでマウスがクリックされる,キーが押されるなどのイベントが発生したときの処理を記述することができます。'keyup'はキーボードのキーが押され,離れたときにfunction()以下の処理を実行します。<textarea>にイベントリスナーを設置するときには,'keypress'ではなく'keyup'を用います。'keypress'を用いると<textarea>内の文字列が書き換えられる前に処理が実行されてしまいます。入力欄の内容が書き換えられてから処理を実行する場合には'keyup'を用いる必要があります。

  let input = $('#equation').val();

入力欄の内容を.val()で取り出し,文字列inputに格納します。

  $('#latex_equation').html('$$'+input+'$$');

文字列inputの前後に$$を加えてブロック要素latex_equationに書き込みます。この段階ではまだ数式として描画されません。

Mathjaxはページ全体のロードが終わった時点で,body内の$$$$で囲まれた文字列をSVG画像に書き換えて表示します。この動作はページを読み込んだときに一度だけ実行されます。

  MathJax.typesetPromise();

MathJax.typesetPromise()は数式を動的に描画する関数です。今回のようにいったんページを表示したあとで内容が変更され,再び数式を描画したいときに用います。関数には色々設定方法があるのですが,単にMathJax.typesetPromise()と記述した場合にはページ内のすべての数式が再描画されます。

再描画によってブロック要素の中身がSVGのコードに置き換わります。<div id="latex_equation">$$latex数式$$</div>の部分が,<div id="latex_equation"><svg>SVGのコード</svg><div>のように変わります。

SVGコードを抽出する

  let svg_code = $('#latex_equation').html();

ブロック要素からSVGのコードを抽出し,文字列svg_codeに格納します。

今回のコードのポイントになる部分です。Mathjaxはlatex数式文字列からSVGコードを生成してHTMLの中に書き込むので,そのコードを表示後に奪って他に転用しようというわけです。

実際には<svg>タグはさらに他のタグで囲まれています。

svg_code = 

'<mjx-container class="MathJax CtxtMenu_Attached_0" jax="SVG" display="true" justify="left" role="presentation" tabindex="0" ctxtmenu_counter="2" style="position: relative;"><svg ~> ・・・・・ </svg><mjx-assistive-mml role="presentation" unselectable="on" display="block"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><msubsup><mo data-mjx-texclass="OP">∫</mo><mn>0</mn><mn>1</mn></msubsup><msup><mi>x</mi><mn>2</mn></msup><mstyle scriptlevel="0"><mspace width=".5em"></mspace></mstyle><mi>d</mi><mi>x</mi></math></mjx-assistive-mml></mjx-container>'

<svg></svg>の外側にあるタグを残しておくとInkscakeで読み込むことができなくなるので,不要な部分を消去します。

  svg_code = svg_code.match(/<svg(.*)svg>/g);

.match()は文字列から正規表現に一致する部分を抽出します。正規表現についての説明は省略しますが,ここでは<svg></svg>の部分を抽出して再びsvg_codeに格納しています。

  $('#svg_code').text(svg_code);

コード出力欄に抽出したコードを表示します。入力欄の部分は以上です。

ダウンロード

$('#svg_save').on('click', function() {

次にダウンロードボタンがクリックされたときの処理に移ります。上と同様にイベントリスナーが設置され,ボタンがクリックされたときに処理が実行されます。

  let svg_code = $('#latex_equation').html();
  svg_code = svg_code.match(/<svg(.*)svg>/g);

先ほど同様,SVGコードを抽出します。

  svg_code = svg_code[0].replace(/<svg.*width="\d+(\.\d+)?ex" height="\d+(\.\d+)?ex"/g, '<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="297mm"');
  svg_code = svg_code.replace(/<svg.*?>/g, '$&<g transform="scale(0.1)">');
  svg_code = svg_code.replace(/<\/svg>/g, '</g>$&');

次に,コードにスケールの設定を継ぎ足しきます。.replace()は正規表現を使うことができ,正規表現に一致する文字列を指定した文字列に置き換えます。

まず,<svg ~>widthheight の数字を強制的に 210mm297mm に書き換えます。この大きさは,要するにA4用紙の大きさで,特別な意味はないのですが,このように書き換えておかないと何故か読み込みに失敗します。

また,<svg ~>の部分を<svg ~><g transform="scale(0.1)">に置き換え,スケールの設定を追加しています。スケールは0.1としていますが,この値を変えることでInkscapeで取り込んだときの大きさを調節することができます。

また,svg_code[0]としているのは,先に実行した.match()が正規表現のオブジェクトを返しているからです。細かいことは気にせず,とりあえず[0]を付けておけば良いと解釈してください。

  svg_code = svg_code.replace(/<\/svg>/g, '</g>$&');

さらに,</svg>でタグを閉じている部分を,</g></svg>として上でスケールを設定した<g>タグを閉じます。

  let blob = new Blob([svg_code],{type:"image/svg+xml"});

ファイルとしてダウンロードするためにBlobオブジェクトを生成します。Blobオブジェクトはバイナリデータを扱うためのオブジェクトで,ファイルとしてダウンロードするためには文字列をバイナリデータの形に変換しておく必要があります。

上で作ったSVGコードの文字列svg_codeBlobに与える場合は,[svg_code]と記述します。

また,type:はデータの形式を指定するもので,type: "image/svg+xml"とすることで,このデータがSVG形式のものであることを示しておきます。

こうして作られたオブジェクトをblobに格納します。

プログラミング初心者の方(私もですが)にとって,オブジェクトとは何かを理解するのは非常に困難です。ちなみに,blobの中身を覗いてみると以下のような状態になっています。

blobの中にはsizetypeなどさまざまなキーと値のセットが存在しています。javascriptではblob.size=4382のようにして,オブジェクトが持つキーの値を取り出すことができます。

実際にはこのオブジェクトの中身は非常に複雑な構造をしていることが分かりますが,細かいことは分からなくても大丈夫です。ここでは,オブジェクトblobとは様々な設定が放り込まれた箱であることがイメージできれば十分です。

次にダウンロードの作業に移ります。

一般的にホームページからファイルをダウンロードさせるには<a href=~>のようなタグを記述して,その部分をクリックしたらファイルがダウンロードされることになります。

これから説明する手順も考え方は同じです。

  let link = document.createElement('a');

createElement<a>タグを生成します。この段階では,画面のどこかにリンクが表示されるわけではありません。画面上に表示する場合は.appendChild()を用いますが,今回は画面に表示する必要はないので使用しません。画面上に存在しなくてもちゃんとダウンロードできます。

  link.href = URL.createObjectURL(blob);

createObjectURL()を用いてリンクのURLを含むオブジェクトを生成します。link.href<a>タグのhrefに指定するリンク先のURLです。

かなり抽象的な操作ですが,SVGコードをバイナリデータに変換したblobからリンク先のURLを作り,それを<a>タグのリンク先として指定しています。

  let date_obj = new Date();
  let milliseconds = date_obj.getTime();

ダウンロードするファイル名に時間を表す数値を入れることで連続してダウンロードしたときにファイル名が重複しないようにしたいと思います。実際に操作してみると分かると思いますが,ダウンロードすると,svg1609657877327.svgのように多くの数字が並んだファイル名としてダウンロードされるのが確認できるでしょう。

この数字は現時刻が1970年1月1日から何ミリ秒経過しているかを示すものです。このようにしているのは,単に連続してダウンロードしたファイルを区別したいからであって深い意味はありません。

ここで再びオブジェクトが登場します。Date()は日付や時刻の情報を含むオブジェクトを生成します。これをdate_objに格納します。さらに.getTime()によって上記で説明した経過時間を取得し,millisecondsに格納します。

  link.download = 'svg'+milliseconds+'.svg';

話を<a>タグに戻します。<a>タグを格納したオブジェクトlinkにダウンロードのファイル名を指定します。上で作った経過時間を表すmillisecondsが使用されています。

  link.click();

このように書くことで,最終的に<a>タグをクリックします。click()はマウスでのクリックをシミュレートする関数で,リンクをクリックした場合と同じ動作を自動的に行います。

  $('#message').text('SVGファイルをダウンロードしました。').show();
  let message_hide = function() {
    $('#message').text('');
  }
  setTimeout(message_hide, 2000);

ダウンロードされたことを示すメッセージを表示します。

クリップボードにコピーする

クリップボードにコピーするボタンを押したときの処理に移ります。

$('#clipboard_copy').on('click', function() {

イベントリスナーを設置します。

  $('#svg_code').select();

画面下に表示されているSVGコードの文字列全体を選択された状態にします。

  document.execCommand('copy');

execCommand('copy')はキーボードでCtrl+Cを押したときと同じ動作をします。つまり,選択された文字列がクリップボードにコピーされます。

まとめ

今回は,latex数式をホームページ上に表示できるMathjaxを用い,抽出したSVGコードを利用する方法を学びました。これはMathjaxの本来の使用法ではありませんが,作成した数式をInkscapeや最新のwordに取り込んだり,ブログなどで数式を表示するなど幅広く応用することができます。

最後に,コード全体を示します。本コードはオープンソースであり著作権者の許諾なく,自由に改変・再配布できます。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>【Inkscape対応】latex数式SVG抽出保存ツール</title>
<script>
MathJax = {
  loader: {load: ["input/tex", "output/svg"]}
};
MathJax = {
  svg: {
    scale: 1,                      // global scaling factor for all expressions
    minScale: .001,                  // smallest scaling factor to use
    mtextInheritFont: false,       // true to make mtext elements use surrounding font
    merrorInheritFont: true,       // true to make merror text use surrounding font
    mathmlSpacing: false,          // true for MathML spacing rules, false for TeX rules
    skipAttributes: {},            // RFDa and other attributes NOT to copy to the output
    exFactor: .5,                  // default size of ex in em units
    displayAlign: 'left',        // default for indentalign when set to 'auto'
    displayIndent: '0',            // default for indentshift when set to 'auto'
    fontCache: 'local',            // or 'global' or 'none'
    localID: null,                 // ID to use for local font cache (for single equation processing)
    internalSpeechTitles: true,    // insert <title> tags with speech content
    titleID: 0                     // initial id number to use for aria-labeledby titles
  }
};
</script>
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-svg.js"></script>
<script
  src="https://code.jquery.com/jquery-3.5.1.min.js"
  integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
  crossorigin="anonymous"></script>
<link rel="stylesheet" href="mjsvgoutput.css"  type="text/css">
</head>
<body>
<div class="container">
  <header>【Inkscape対応】latex数式SVG抽出保存ツール</header>
  <article>
    <p class="index">latex数式</p>
    <textarea id="equation" name="equation" rows="10" cols="30">\int_0^1 x^2\enspace dx</textarea>
    <p class="index">数式</p>
    <div id="latex_equation">$$\int_0^1 x^2\enspace dx$$</div>
    <div class="button_wrapper">
      <button type="button" id="svg_save" class="button" name="svg_save">SVGファイルをダウンロード</button>
      <button type="button" id="clipboard_copy" class="button" name="clipboard_copy">クリップボードにコピー</button>
    </div>
    <div id="message"></div>
    <textarea id="svg_code" name="equation" rows="10" cols="80">ここにSVGのコードが出力されます。</textarea>
  </article>
  <footer>2021 Powered by <a href="https://www.mmsankosho.com">mm参考書</a></footer>
</div>
<script>
$('#equation').on('keyup', function() {
  let input = $('#equation').val();
  $('#latex_equation').html('$$'+input+'$$');
  MathJax.typesetPromise();
  let svg_code = $('#latex_equation').html();
  svg_code = svg_code.match(/<svg(.*)svg>/g);
  $('#svg_code').text(svg_code);
});
$('#svg_save').on('click', function() {
  let svg_code = $('#latex_equation').html();
  svg_code = svg_code.match(/<svg(.*)svg>/g);
  svg_code = svg_code[0].replace(/<svg.*width="\d+(\.\d+)?ex" height="\d+(\.\d+)?ex"/g, '<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="297mm"');
  svg_code = svg_code.replace(/<svg.*?>/g, '$&<g transform="scale(0.1)">');
  svg_code = svg_code.replace(/<\/svg>/g, '</g>$&');
  let blob = new Blob([svg_code],{type:"image/svg+xml"});
  let link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  let date_obj = new Date();
  let milliseconds = date_obj.getTime();
  link.download = 'svg'+milliseconds+'.svg';
  link.click();
  $('#message').text('SVGファイルをダウンロードしました。').show();
  let message_hide = function() {
    $('#message').text('');
  }
  setTimeout(message_hide, 2000);
});
$('#clipboard_copy').on('click', function() {
  $('#svg_code').select();
  document.execCommand('copy');
  $('#message').text('クリップボードにSVGコードがコピーされました。').show();
  let message_hide = function() {
    $('#message').text('');
  }
  setTimeout(message_hide, 2000);
});
</script>
</body>
</html>
body {
    display: flex;
    justify-content: center;
    font-family: YuGothic, 'Yu Gothic', sans-serif;
    font-size: 18px;
    font-weight: 500;
}
p {
    margin: 0 0 0 1rem;
}
.index {
    font-weight: 700;
}
.container {
    width: 680px;
    background-color: #f2f2f2;
    padding: 0.5rem;
}
#equation {
    width: 94%;
    margin: 0 0 0 1rem;
}
#latex_equation {
    display: block;
    width: 100%;
    height: 100px;
    margin: 0 1rem 0 1rem;
}
.button_wrapper {
    display: flex;
    justify-content: space-around;
    margin: 0.5rem;
}
#svg_save {
    flex-basis: 45%;
}
#clipboard_copy {
    flex-basis: 45%;
}
.button {
    display: inline-block;
    padding: 0.5em 1em;
    text-decoration: none;
    background: #668ad8;/*ボタン色*/
    color: #FFF;
    border-bottom: solid 4px #627295;
    border-radius: 3px;
    font-weight: 700;
}
#message {
    display: block;
    height: 1.5rem;
    font-size: 0.75rem;
    padding-left: 1rem;
}
#svg_code {
    width: 94%;
    margin: 0 0 0 1rem;
}
footer {
    text-align: right;
}
タイトルとURLをコピーしました