LINEオープンチャットでTeXを扱いたかった話


動機

数学系のオープンチャットにいると、ちょっと複雑な数式を送りたいときがどうしてもあります。

x = {-b+-√(b^2 -4ac)} / 2a

とか

∑[n=1~∞]1/n^2 = π^2/6

とか...
あまりに複雑長大なものは紙に書いたりTeXなどの組版処理ソフトを使ったりして画像にして送るわけですが、上に挙げた程度の (1行〜数行で書ける) 式でいちいちそんなことをするのは面倒なので (生のTeXを送る人もいますが、読めない人もいるので微妙です) こんな感じでなんとか伝わるように書いて送ることが多いです。

これはPCならLINEとソフトのウィンドウを横に並べればいい話なので問題になりにくいでしょうから、以下では主にスマホユーザーを対象として話を進めることにします。

構想

LINEでTeXとか使えたら便利だよね〜という話は以前からたまに出ていて、実際、オープンチャット以外でなら

https://qiita.com/plageoj/items/3df087999d550338e2b8
https://qiita.com/yudukikun5120/items/ec2fc172d180c099f24d
といったMessaging APIを用いる前例が存在します。
なぜオープンチャットでは例がないかというと、オープンチャットではMessaging APIが使えないため
TeXを送る -> botが処理して自動で画像を送る

という流れを実現できないからです。

これはLINEの外の我々がどうにかできるものではないので、代替手段として

LINE上でTeXを書いて画像をダウンロードする -> 手動で画像を送る

という流れがまず浮かびます。これはLIFFを使えば実現できそうに見えます。正式対応していないと書かれてはいますが、今回はプロフィール情報や友達情報などは一切必要としていないので問題なさそうです。実際に書いてみたコードが以下になります (Next.js) 。

index.ts
import { ChangeEvent, useEffect, useState } from 'react'
import Head from 'next/head'
import styles from '../styles/main.modules.css'
import katex from 'katex'
import html2canvas from 'html2canvas'


const downloadKatexImage = (katexArea: HTMLElement) => {
    html2canvas(katexArea)
        .then(canvas => {
            const downloadLink.href = document.getElementByTagName("a")[0]
            downloadLink.href = canvas.toDataURL()
            downloadLink.click()
        })
}
const renderKatex = (
    katexArea: HTMLElement,
    inputText: string
  ) => {
    katex.render(String.raw`${inputText}`, katexArea, {
        throwOnError: false,
        displayMode: true
    })
}
const getProperKatexFontSize = (
    katexArea: HTMLElement,
    currentFontSize: number
  ): number => {
    const katexBox = document.getElementsByClassName('katex')[0]
    // katexによって生成される、レンダリングされるKaTeXをすっぽり囲うspan要素

    const wRate = katexArea.clientWidth / katexBox.clientWidth
    const hRate = katexArea.clientHeight / katexBox.clientWidth

    return Min(
        Min(
            currentFontSize * wRate,
            defaultKatexFontSize
        ),
        Min(
            currentFontSize * hRate,
            defaultKatexFontSize
        )
    )
}

const deaultKatexFontSize = 3
const Min = (x:number, y:number) => {
    if (x <= y) return x
    return y
}


const Home = () => {
    const LiffID = process.env.LIFF_ID || ''
    useEffect(() => {
        import('@line/liff').then(liffFile => {
            const liff = liffFile.default
            liff.init({
                liffId: LiffID,
                withLoginOnEnternalBrowser: true
            })
        })
    }, [])

    const [katexFontsize, setKatexFontSize] = useState(defaultKatexFontSize)
    const [katexArea, setKatexArea] = useState<HTMLElement>()
    useEffect(() => {
        setKatexarea(
            document.getById('katex-area')!
        )
    }, [])

    const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
        renderKatex(katexArea!, e.target.value)
        const newKatexFontSize = getProperKatexFontSize(katexArea!, katexFontSize)
        setKatexFontsize(newKatexFontSize)
        document.documentElement.style.setProperty(
            '--katex-font-size', `${newKatexFontSize}em`
        )
    }
    const handleClick = () => {
        downloadKatexImage(katexArea!)
    }

    return (
        <Head>
          <title>LIFF-TEX</title>
          <link rel='icon' href='/favicon.ico'/>
        </Head>
        <main className={styles.mainStyle}>
          <div 
            className={styles.katexAreaStyle}
            id='katex-area'
          />
          <textarea
            className={styles.inputStyle}
            onChange={(e) => handleChange(e)}
          />
          <button className={styles.buttonStyle} onClick={handleClick}>
            download
          </button>
          <a download='KaTeX.png'></a>
        </main>
    )
}

export default Home
_document.ts
import { Html, Head, Main, NextScript } from "next/document";

const Document = () => {
  return (
    <Html>
      <Head>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css" integrity="sha384-KiWOvVjnN8qwAZbuQyWDIbfCLFhLXNETzBQjA/92pIowpC0d2O3nppDGQVgwd2nB" crossOrigin="anonymous"/>
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

export default Document

CSSなどは省略しますが、ここに実際に動くものを置いておくので、よければ遊んであげてください。

https://liff-tex-kana-rus.vercel.app

さて、結論から言うと、これでは目的を果たすことはできません。TeXをレンダリングするところまでは問題ないのですが、画像をダウンロードすることができないのです。調べてみたところ、これはLINEブラウザ / LIFFブラウザの仕様に起因するものであり、画像だけでなく、一般にファイルのダウンロード自体できないようになっているようです。

LIFFブラウザは、iOSではWKWebView (opens new window)、AndroidではAndroid WebView (opens new window)を利用しています。そのため、LIFFブラウザの仕様および動作についてもこれらの仕組みに準拠するものとなります。
https://developers.line.biz/ja/docs/liff/overview/#liff-browser

https://pisuke-code.com/android-webview-download-file/
https://zenn.dev/bricklife/articles/how-to-download-blob-on-wkwebview

つまり、LINE側で対応する実装を施してくれない限りダウンロードは実現しないのであり、これもWebアプリ側ではどうしようもありません。

再構想

ではどうするかですが、大きく分けて以下の2パターンの方針を考えました。

  1. 外部に別のAPIを用意して無理矢理迂回する
例: 画像のDataURLを取得
 -> 外部APIにPOST, 外部ブラウザで開かせる
 -> APIはアクセスされた瞬間POSTされた画像をダウンロードさせる
 -> 手動で1画面戻ってもらう (と自動で元のオープンチャットに遷移)
  1. 最初から外部ブラウザで開かせる
例: そもそもWebアプリを外部ブラウザで開かせる
 -> 画像のDataURL取得 & ダウンロードさせる
 -> 手動で1画面戻ってもらう (と自動で元のオープンチャットに遷移)

問題は外部ブラウザで開かせると言う部分で、普通にLINE上でURLを開いても必ずLINEブラウザで開かれてしまい、ダウンロードができません。

こんなものを見つけました。

LINEアプリから以下のクエリパラメータを付与した任意のURLにアクセスすると、LINE内ブラウザの代わりに外部ブラウザでURLを開きます。...
https://developers.line.biz/ja/docs/messaging-api/using-line-url-scheme/

これを使えば強制的に外部ブラウザで開かせることができますが、

このLINE URLスキームは、LINEアプリからアクセスするすべてのURLで機能します。ただし、LIFFアプリのみ例外的にサポートされないため、たとえばLIFF URLにクエリパラメータを付与しても機能しません。

とのことなので、1.の案は不可能です。つまり、アプリのURL https://liff-tex-kana-rus.vercel.app にパラメータ openExternalBrowser=1 をつけた
https://liff-tex-kana-rus.vercel.app?openExternalBrowser=1
をオープンチャットのアナウンスにでも置いておいて、必要な時にクリックしてアプリに飛べばいい訳です。

正直、最初に想定していたのとはかなり違った形になり、事前調査の不足を痛感しています。こうなると普通にネイティブアプリを使うのと大差なくなっている気もしますが、

ネイティブ:
   手動でLINEを抜けてアプリを開く
-> 書いてダウンロード
-> 手動でアプリを抜けてLINEに移動する
Web:
   (アナウンスしておけば) LINE上に表示されているURLでアプリに飛ぶ
-> 書いてダウンロード
-> 1画面戻る (と自動で元のオープンチャットに遷移)

という手間の差が生じるので、Webアプリを作った意味はあったと思いたいです。

最後まで読んでいただきありがとうございます。