TeX文字列を画像ファイルにするdiscord botを作る
この記事はアドカレに参加しています。
TeXってなに?
平方根や、行列式などを綺麗に表示したいときがあります。そんなときに役に立つのが、TeXという記法です。例えば、Qiitaで$a = \sqrt{10}$と表示するには以下のように書きます。
a = \sqrt{10}
TeXで表わせる数式記号はたくさんあります。
・Qiitaでの様々な数式の書き方 ~ TeX記法を使ったサンプルコード付き
・よく忘れるので数学のTeX記法をまとめ
・場合分けをきれいに書く
・27.7 数式の書き方(1)
discordには数式を綺麗に表示する機能がないので、このTeXで書かれた文字列をレンダリングされた画像ファイルにするbotがあると便利です。
TeX文字列を画像ファイルに変換する
TeX文字列からsvgに変換するのには、mathjaxを使います。svgから任意の画像ファイルに変換するには、sharpを使用します。
TeX文字列obj.str
から画像ファイルfile.png
を作成するコード例を以下に示します。
const fs = require("fs");
const sharp = require("sharp");
const mathjax = require("mathjax");
function option(flag) {
if(flag === true) return "source";
else return "over";
}
//obj = { name : "file.png", type : "png", option : false, str : "" };
async function make_file(obj) {
let error_msg = "";
let name = obj.name.substring(0, obj.name.indexOf("."));
await mathjax.init({
loader: {load: ["input/tex", "output/svg"]}
}).then(async (MathJax) => {
const svg = MathJax.tex2svg(obj.str, {display: true});
let str = MathJax.startup.adaptor.outerHTML(svg)
.replace(/<mjx-container class="MathJax" jax="SVG" display="true">/, "")
.replace(/<\/mjx-container>/, "");
if (obj.type === "png") name += ".png";
else if (obj.type === "jpeg") name += ".jpeg";
else if (obj.type === "webp") name += ".webp";
else name += ".svg";
if(obj.type === "svg")
fs.writeFileSync(name, str);
else{
let input = Buffer.from(str);
const metadata = await sharp(input).metadata().catch(err => { error_msg = "sharp error 0\n" + err; });
let { data, info } = await sharp({ create: { width: metadata.width, height: metadata.height,
channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 }}})
.composite([{ input: input, blend: option(obj.option) }])
.toFormat(obj.type)
.toBuffer({ resolveWithObject: true })
.catch(err => { error_msg = "sharp error 1\n" + err; });
fs.writeFileSync(name, data);
}
}).catch((err) => { error_msg = "TeX error\n" + err; } );
return {msg : error_msg, name : name};
}
mathjaxとsharpの使い方はそれぞれこちらとこちらにあるリファンレスを参考にします。
注意点として、sharpのtoFile
で画像ファイルを作成してしまうと、二回目以降に同じ名前のファイルを作成しようとしても上書きされません。おそらくGCに回収されるまで一回目のファイルは開かれた状態になっているからです。
https://www.ultra-noob.com/blog/2020/80/#google_vignette
discord botのコード例
TeX文字列から画像ファイルを作成するdiscord botのコード例です。
const channel_name = "bot_test";//channel_nameのチャンネルだけにbotは反応する
const { Client, GatewayIntentBits } = require("discord.js");
const client = new Client({
intents: Object.values(GatewayIntentBits).reduce((a, b) => a | b)
});
const {Worker} = require("worker_threads");
let worker = new Worker('./worker.js');
async function worker_on_msg(obj) {
if (obj.msg === "") {
client.channels.cache.find(ch => ch.name === channel_name)?.send({ files: ["./" + obj.name] });
}
else {
client.channels.cache.find(ch => ch.name === channel_name)?.send(obj.msg.toString());
}
}
worker.on("message", worker_on_msg);
client.on("ready", () => {
console.log(`${client.user.tag} でログインしています。`);
});
client.on("messageCreate", async msg => {
if(msg.channel.name !== channel_name) return;
if (msg.content === "!!ping") {
msg.reply("Pong!");
}
else if (msg.content === "!!destroy") {
worker.terminate();
client.destroy();
}
else if (msg.content === "!!help"){
client.channels.cache.find(ch => ch.name === channel_name)?.send(
"> 〇このbot is 何?\n" +
"> TeX記法で書かれた文字列から画像を生成するbotです。\n\n" +
"> 〇使用方法\n" +
"> botがオンライン時にコマンドを打ち込むと、コマンドに対応した動作をします。\n\n" +
'> 〇コマンド一覧\n> \n' +
"> ・!!ping\n> Pong!と返します。\n> \n" +
"> ・!!destroy\n> botがログオフします。\n> \n" +
"> ・!!help\n> コマンド一覧を表示します。\n> \n" +
"> ・!!TeX filename.type option \\`\\`\\`str\\`\\`\\`\n> TeXという記法で書かれた文字列strからflilename.typeの画像を生成します。typeはsvg,png,jpeg,webpのいずれかです。optionでは背景透過の有無を決めます。flilename.typeとoptionは省略できます。");
}
else {//!!TeX filetype option str
if (msg.content.length >= 5) {
let posObj = { name : "file.png", type : "png", option : false, str : "" };
let commands_list = [];
let ch = msg.content.indexOf("```")
if (ch === -1) return;
let i = 0;
while(ch > i) {
let j = msg.content.indexOf(" ", i);
if(j === -1) break;
commands_list.push(msg.content.substring(i, j));
i = j + 1;
}
commands_list.push("");
i = 0;
let len = commands_list.length - 1;
if(i > len) return;
if (commands_list[i] !== "!!TeX") return;
i++; if (i > len) msg.reply("command error");
if (commands_list[i].indexOf(".svg") !== -1) { posObj.name = commands_list[i]; posObj.type = "svg"; i++; }
else if (commands_list[i].indexOf(".png") !== -1) { posObj.name = commands_list[i]; posObj.type = "png"; i++; }
else if (commands_list[i].indexOf(".jpeg") !== -1) { posObj.name = commands_list[i]; posObj.type = "jpeg"; i++; }
else if (commands_list[i].indexOf(".webp") !== -1) { posObj.name = commands_list[i]; posObj.type = "webp"; i++; }
if (i > len) msg.reply("command error");
if (commands_list[i] === "true") { posObj.option = true; i++; }
else if (commands_list[i] === "false") { posObj.option = false; i++; }
if (posObj.type === "jpeg" && posObj.option === true) posObj.option = false;
len = msg.content.length;
ch += 3; i = msg.content.indexOf("```", ch);
if(i === -1) msg.reply("command error");
posObj.str = msg.content.substring(ch, i);
worker.postMessage(posObj);
}
}
});
//ログイン
client.login("トークンをここに");
const fs = require("fs");
const sharp = require("sharp");
const mathjax = require("mathjax");
const {parentPort} = require("worker_threads");
function option(flag) {
if(flag === true) return "source";
else return "over";
}
async function make_file(obj) {
let error_msg = "";
let name = obj.name.substring(0, obj.name.indexOf("."));
await mathjax.init({
loader: {load: ["input/tex", "output/svg"]}
}).then(async (MathJax) => {
const svg = MathJax.tex2svg(obj.str, {display: true});
let str = MathJax.startup.adaptor.outerHTML(svg)
.replace(/<mjx-container class="MathJax" jax="SVG" display="true">/, "")
.replace(/<\/mjx-container>/, "");
if (obj.type === "png") name += ".png";
else if (obj.type === "jpeg") name += ".jpeg";
else if (obj.type === "webp") name += ".webp";
else name += ".svg";
if(obj.type === "svg")
fs.writeFileSync(name, str);
else{
let input = Buffer.from(str);
const metadata = await sharp(input).metadata().catch(err => { error_msg = "sharp error 0\n" + err; });
let { data, info } = await sharp({ create: { width: metadata.width, height: metadata.height,
channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 }}})
.composite([{ input: input, blend: option(obj.option) }])
.toFormat(obj.type)
.toBuffer({ resolveWithObject: true })
.catch(err => { error_msg = "sharp error 1\n" + err; });
fs.writeFileSync(name, data);
}
}).catch((err) => { error_msg = "TeX error\n" + err; } );
parentPort.postMessage({msg : error_msg, name : name});
}
parentPort.on("message", async obj => {
make_file(obj);
});
discord botでは処理時間が長すぎるとbotが自動的にタイムアウトしてしまいます。そのため、メッセージイベントを受け取った後にメッセージを分析してから、重そうな処理をworkerに投げています。
参考文献
・mathjax
・sharp
・MathJaxをNode.jsで動かす
・美しい数式をコマンドラインから生み出したい!
・Node.jsでsvgからpngに変換したい場合はsharpを使用する
・【Node.js】 画像処理モジュールsharpの使い方を網羅してみました
・指定したチャンネルにメッセージを送信する方法まとめ
・ファイルを添付したメッセージを送信する
・Node.jsのworker_threadsで並列処理
・JavaScript/Node.jsでのバイナリデータ処理
・【Node.js】fsでファイルの存在確認をするコードが非推奨な理由
むすび
TeX記法、表示は綺麗なんですけど書くの大変なんですよね。行列式書いたときはタヒぬかと思いました。