0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

八戸高専Advent Calendar 2023

Day 10

TeX文字列を画像ファイルにするdiscord botを作る

Posted at

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};
}

mathjaxsharpの使い方はそれぞれこちらこちらにあるリファンレスを参考にします。

注意点として、sharpのtoFileで画像ファイルを作成してしまうと、二回目以降に同じ名前のファイルを作成しようとしても上書きされません。おそらくGCに回収されるまで一回目のファイルは開かれた状態になっているからです。
https://www.ultra-noob.com/blog/2020/80/#google_vignette

discord botのコード例

TeX文字列から画像ファイルを作成するdiscord botのコード例です。

index.js

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("トークンをここに");

worker.js

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記法、表示は綺麗なんですけど書くの大変なんですよね。行列式書いたときはタヒぬかと思いました。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?