MathJaxによる描画を細かく制御する

MathJax というのはLaTeXなどで書いた数式を美しく描画するための処理系です。 なおこのエントリは MathJax v2.7.0 を対象にしています。

www.mathjax.org

まず前提として、ほとんどのケースではMathJaxをconfigパラメータを与えてロードするだけで自動的にtypeset *1 が行われます。

この自動typesetは、HTML中のすべての要素に対してただ一度だけ行われるので、content editableでウェブエディタを実装しているケースやリアルタイムプレビューとの相性が非常に悪いのです。つまりcontent editable中のLaTeX記法は置き換えるべきではないのに置き換えてしまうし、プレビューが更新されてもtypesetされないのでこちらはLaTeX記法が処理されません。

そこで細かくMathJaxを制御する必要があるのですが、これがなかなか苦労したので、ハマったことや考えたことをここにメモしておきます。MathJaxは奥が深すぎて同じことをするのに何通りもの方法があり、ここに書いたものが唯一の方法ではありません。実際に実装するときは最新のドキュメントを参照することをおすすめします。

APIリファレンス

リファレンスはこちら。 ぐぐるとよく過去のバージョンのものが出てくることが多いです。何かを探すときは最新版をサイト内検索するのがいいでしょう。

http://docs.mathjax.org/en/latest/api/

SSRかCSRか

MathJax-node があるので、SSR: Server Side Renderingもできるようです。

MathJaxの描画処理はかなり時間がかかり、またpreviewとして仮描画したものを出したりするため、数式の描画を表示するときにかなりガクガクします。

SSRするとこのガクガクがおそらくなくなるので、可能であればSSRしたいところです。しかしそのためには専用のnodejsサーバを立てなければならず、ほかの(たとえばReact SSR)レンダリングサーバに相乗りできるならいいですが、そうではなくMathJaxのためだけに新規にnodejsサーバを立てるのであれば大抵の場合オーバースペックでしょう。

今回はCSR: Client Side Renderingにしました。

MathJaxのロード

MathJaxパッケージはnpmにもありますが、それをwebpackで固めるとextensionのロードが404になるという現象になり、結局解決できませんでした。

なのでcdnjsのようなpublic CDNを使うか、MathJax配布キットのとおりの構造でデプロイするかしないといけないようです。

MathJax 3.0でwebpack対応予定 ということなので、webpackに組み込みたいときは3.0を待つのがいいようです。

自動typesetを無効にする

configに skipStartupTypeset: true を渡します。

MathJax.Hub を設定するためのドキュメントはこちら:

http://docs.mathjax.org/en/latest/options/hub.html

MathJaxの初期化終了コールバック

MathJaxは非同期でコンポーネントをロードするので、 MathJax オブジェクトが存在するからといって使用可能とは限りません。

MathJax.isReady は一つの指標としては使えますが、初期化の終了イベントをフックしたいところです。

初期化シーケンスは Startup Sequence にあり、それぞれのタイイングでコールバックを呼ぶのでイベントリスナを登録すればよさそうです。

このシーケンスの詳細は省きますが、End が初期終了時に呼ばれるイベントです。

MathJax.Hub.Register.StartupHook('End', () => {
  // MathJax APIがすべて利用可能になった!
});

MathJax Typeset API

MathJax.Hub.Typeset(target, callback) を使うと、target elementを指定してtypesetを起動できます。

target は HTML element またはその配列、あるいは null で、 nullの場合は document.body 全体が処理の対象になります。

MathJaxは処理をシーケンシャルにするために MathJax.Callback.Queue というjob queueを持っているので、typesetの処理は実際にはこのようになります *2

MathJax.Callback.Queue(() => {
  MathJax.Hub.Typeset(elements, () => {
    console.log(`typeset is done for ${elements.length} elements.`);
  });
});

エラーハンドリング

プロダクション環境ではエラーがあってもサービス提供者としては何もでないことのほうが多いでしょうが、開発中はエラーの詳細を知りたいというケースでは “Math Processing Error” イベントをフックすることでコンソールにエラーの詳細を出力できます。

MathJax.Hub.Register.MessageHook("Math Processing Error", (message) => {
  console.error(message);
});

MathJax Preview

LaTeXをちゃんとレンダリングするのはかなり時間がかかるようで、デフォルトだと本描画のまえに数回プレビュー描画が入ります。

このpreviewは様々なレイヤでやっているらしく制御の仕方がはっきりしないのですが、 MathJax_Preview CSS classが指定されているので、これに対してスタイルを当てると見た目はそれなりに制御できます。

全体像

というわけで全体像です。実際に運用しているコードそのものではありませんが、だいたいこんな感じです。 requestTypeset() を呼ぶと (1) MathJaxがロードされていなければロードする (2) MathJaxが使えるようになるまで待ってtypesetを起動する (3) requestTypset() は呼ぶたびにtypesetを起動する、ということを行うコードです。

const CONFIG = 'TeX-AMS-MML_HTMLorMML'; // 適当なconfig

const DEV = (process.env.NODE_ENV != 'production');

let isLoading = false;

function loadMathJax(onComplete) {
  const script = document.createElement('script');
  // from https://cdnjs.com/libraries/mathjax
  script.src = `https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=${CONFIG}`;
  script.async = true;
  script.onload = () => {
    const MathJax = window.MathJax;
    console.log(`loaded MathJax/${MathJax.version} (${CONFIG})`);

    MathJax.Hub.Config({
      messageStyle: DEV ? "normal" : "none", // 左下に出るメッセージボックスは開発時のみ有効にする
      skipStartupTypeset: true,
      tex2jax: {
        inlineMath: [
          ['$','$'],
        ],
        displayMath: [
          ['$$', '$$'],
        ],
        processEscapes: true,
      },
    });
    MathJax.Hub.Register.MessageHook("Math Processing Error", (message) => {
      console.error(message);
    });
    MathJax.Hub.Register.StartupHook('End', onComplete);
  };
  document.head.appendChild(script);
}

export function triggerTypeset() {
  const elements = [...document.querySelectorAll('.content')];

  const MathJax = window.MathJax;
  console.assert(MathJax && MathJax.isReady);

  MathJax.Callback.Queue(() => {
    MathJax.Hub.Typeset(elements, () => {
      console.log(`typeset is done for ${elements.length} elements.`);
    });
  });
}

export function requestTypeset() {
  if (!document.querySelector('.content')) {
    return;
  }

  if (window.MathJax && window.MathJax.isReady) {
    triggerTypeset();
  } else {
    if (isLoading) {
      return;
    }
    isLoading = true;

    loadMathJax(() => {
      triggerTypeset();
    });
  }
}

*1:MathJaxの描画処理のことで、原義は「活字を組む」という意味です。MathJaxでこの用語を使うのはTeXに由来すると思われます。

*2:queueを使う必要はもしかしたらないのかもしれませんが、MathJaxは内部で様々なイベントが飛び交う複雑な非同期システムなのでこのようにするのが無難でしょう。

yarnpkgのdependenciesにgithubのリポジトリを直接書こうとしてハマった

追記:(2017/04/14): "foo" : "gfx/foo"$github_user/$repository)というフォーマットでうまくいきました。ε-(´∀`*)ホッ


追記(2017/04/13): 結局解決できてませんでした。つらい。

fooというnpm moduleにパッチを当てた gfx/foo という版をどうしてもいま使いたい、というときに、package.jsonにはこんな感じにURLを直接書けるはずということになっているのですが

{
  "dependencies": {
    "foo": "git+https://github.com/gfx/foo#branch",
  }
}

これがどうもCircleCI上のyarnpkgだと刺さってしまって動きませんでした。yarnpkgのアップグレードなどでも解決せず。結局以下のように .git suffixを付けると解決できました。

{
  "dependencies": {
    "foo": "git+https://github.com/gfx/foo.git#branch",
  }
}

CommonMarkの現状 2017年4月版

CommonMarkというのはMarkdownの標準化を目指して2014年に立ち上がったプロジェクトです。

当時のニュース:

Standard Markdown Becomes Common Markdown then CommonMark (日本語版)

公式サイト:

http://commonmark.org/

あれから3年、とうとうGitHubがCommonMarkを採用したというアナウンスがありました。つまり、長らくMarkdownデファクトと考えられてきたGFM: GitHub Flavored Markdownは、いまやCommonMarkのスーパーセットなのです。

A formal spec for GitHub Flavored Markdown | GitHub Engineering (2017/03/14)

To ensure that the rendered Markdown in our website is fully compliant with the CommonMark spec, the new backend implementation for GFM parsing on GitHub is based on cmark,

実際には、このエントリにあるように、GitHubはオリジナルの参照実装であるcmarkをforkしたもの(cmark-gfm)を使っています。これは、GFMのいくつかの拡張について仕様が定まっていないためで、それらの議論が終わったらオリジナルのcmarkにマージするつもりだとのこと。GitHubはCommonMarkのコアメンバーなので、このあたりについては心配はいらなそうです。

https://github.com/github/cmark

拡張: https://github.com/github/cmark/tree/master/extensions

Ruby gemの CommonMarker も、実際に参照しているのは github/cmark で、いまはGitHub社のkivikakk氏がcommonmarkerとgithub/cmark両方をメンテしているようですね。

このGitHubのアナウンスをうけて、国内外のMarkdown採用サービスが CommonMark (あるいはその拡張のGFM)に移行してゆくと考えられます。

Kibela的にはCommonMarkの拡張構文への関心が高いところです。すでにPlantUMLとLaTeXという二種類の記法を描画するためにMarkdown構文を拡張しているからです。

blog.kibe.la

blog.kibe.la

CommonMarkの仕様や拡張については talk.commonmark.org というフォーラムがあるので、CommonMarkの将来についてはこのあたりを眺めるとよさそうですね。

https://talk.commonmark.org/

JavaScript SSRのためのレンダリングサーバがほしい

いまReactのSSRのためにHypernova を使っていて、これはこれでちゃんと動くんですがいくつか不満があります。

  • Unix Domain Socketをサポートしていない
  • アプリケーションサーバ(express)の設定をほとんど変えられない
    • worker数とか
    • bodyの圧縮にzstdを使うとか

さらに、JSでレンダリングしたいコンテンツは他にもあって、たとえば

  • MathJax
  • Markdown
    • CommonMarkだとC実装とJS実装があるのでめちゃくちゃ頑張らなくても同じようなものを作ることはできるが…

なのでさしあたってこの三種類のコンテンツをレンダリングするためのNodeJSサーバがあるといいなと。

ここまでくるとNodeJSでアプリケーションサーバを書いたほうがいい気もしますが、まあそこは既存の資産があるのでステートレスなレンダリングだけでもまずはNodeJSでやれればなあと思いました。

というか、こうなるとHTTPサーバである必要もなさそう。

<meta name="robots" content="noindex"/> でも検索エンジンにインデックスされるケースがある

noindex tagがあるにも関わらずインデックスされているケースを見かけたので調べてみたところ、googlebotに関してはどうやらそういう仕様があるようです。 noindex tag は常にインデックスの削除を行うのだと思っていたのでちょっと驚きました。

メタタグを使用して検索インデックス登録をブロックする - Search Console ヘルプ

重要: noindex メタタグを有効にするには、robots.txt ファイルでページをブロックしないでください。ページが robots.txt ファイルでブロックされると、クローラは noindex タグを認識しません。そのため、たとえば他のページからリンクされていると、ページは検索結果に引き続き表示される可能性があります。

Block search indexing with meta tags - Search Console Help (同ページ英語版)

Important! For the noindex meta tag to be effective, the page must not be blocked by a robots.txt file. If the page is blocked by a robots.txt file, the crawler will never see the noindex tag, and the page can still appear in search results, for example if other pages link to it.

ちょっとこの説明だけだとよくわかりませんが、 noindex を指定していても検索エンジンにインデックスされるケースがあるようです。

googlebotが「noindex tagをみるとそのページをインデックスしない、既にインデックス済みの場合はインデックスを消す」というロジックだと仮定すると、たとえば次のようなケースではインデックスされるのかもしれません。

  1. noindex tagを指定しないままページを公開する
  2. noindex tagの指定とrobots.txtによる該当ページのブロックを 同時に 行う
  3. googlebotは該当ページを見に行かないので、「noindex tagをみてインデックスを削除する」という処理を行わない

これが正しいのだとすると、このgooglebotの注意書きのとおり、 robots.txt で該当ページのブロックを外せばインデックスが削除されるはずですね。goolebotがrobots.txt でブロックしているのを見つけたら、ブロックされているURLですでにあるインデックスをすべて削除すればいいような気もしますが、そうはなっていないようです。

ただ上記のgooglebotの注意書きの「たとえば他のページからリンクされていると、ページは検索結果に引き続き表示される可能性があります」はよくわかりません。最初からnoindex tagとrobots.txtによるブロックしていて、かつ他のページからリンクされているときもインデックスされるかのように読めますが、この解釈が正しいのかどうかはわかりません。

Ormaに関する雑談&質問用にgitterのchat roomをつくりました

en: https://gitter.im/Android-Orma/Lobby ja: https://gitter.im/Android-Orma/Ja

背景

GitLab、Slackライクなサービス「Gitter」を買収。有償プラン廃止で何人でも無料で利用可能に。オープンソース化も約束 - Publickey でひさびさにgitterを知って、悪くないんじゃないかと思って設置してみました。

OSSコミュニティの母体としてSlackをつかうことが増えてるようですが、gitterだとpublic channelであれば招待されなくても入れるし、コミュニティとしてはそのほうがいいですよね。

正直チャットサービスとしてのクオリティはSlackのほうが高いです。gitterはリアクションとかできないし、モバイルアプリもGitHubのパスワードをアプリ内ブラウザで入力しないといけないので使えないし。しかしライブラリやフレームワークごとにslackチャンネルに入るのも大変面倒なので、それよりはgitterのほうがよさそうだなと思っています。

カンファレンススタッフ用アプリがあるとよさそう

DroidKaigi 2017お疲れさまでした。スタッフとしては3回目、スピーカーとしては初めての参加でした。

スタッフとしては、受付の誘導、司会、副司会、会場の片付けなどをしました。特に司会はわりと臨機応変に行動しなければならず、部屋ごとにけっこうバラバラだったのではないかなあと思います。

カンファレンスが始まってからも次々と様々な仕様変更がきて、「意識を高くもって気をつける」だけだとカバーしきれないところがあります。そういう点をカバーできるようにカンファレンススタッフ用アプリがあるといいなーと思って記憶が鮮明なうちにメモしておきます。もちろん関係者でのKTPは別途しますが、それは公開されないと思うので。

実際に起きたこと

  • スタッフのシフト表の変更
    • 頻繁におきてた印象、というのもいくつか不整合がみつかるので
    • シフト表にいれるスタッフは40人くらいいるので、不整合なく保つのは至難の業
  • セッションのTimetableの変更
    • やむを得ない事情によりキャンセルになった枠に当日の判断でFireside Chatをいれることになった
    • 決定があった時点でアナウンスしたけど館内放送があるわけではないので全員に周知できたかどうかは不明
  • 司会用チートシートがどんどん増える問題
    • 部屋 x 時間帯(ランチ前・一日目終了後・二日目終了後)などで微妙に言うことが違うのもなかなか大変
  • 適切な人に適切なタイミングで情報をpush通知する必要性
    • インカム(トランシーバ)を多用していたけど、インカムから情報を聞き取るのはかなり集中しないといけない
    • インカム議事録係がいたけど大変そうだった
  • 司会から発表者への残り時間の通知を適切にするためのツールが乏しく意識だけでやってる、ゆえにときどきミスる

こういうアプリがほしい

  • 1秒で起動して「いま自分が何をすべきか」を知りたい(チートシート)
  • チートシートに差分があればわかりやすく表示してほしい
  • 司会の場合、発表開始5分前、開始時、発表終了10分前、5分前、終了時に通知がほしい
  • スタッフ(あるいはスタッフグループごと)へ情報をpushできるようにする
    • 重要な知らせは強いpush通知(5分くらいバイブレーションが続くとか)があってもいいかもしれない

DroidKaigi 2017で「ORMの選び方」という発表をしました

Ormaの開発の際に他のORMはどういう設計思想なのかを調べたときの知見をもとに、DroidKaigi 2017用に仕上げた発表です。

これ契機にORMについて一家言ある人が増えるといいなと思いながら発表しました。

SQLiteDatabase (SQLiteOpenHelper) を直接つかうかどうかでいうと圧倒的にORMをつかったほうが早く品質のよいアプリを開発できると思っていて、それはやはりORMのマッピング、クエリビルダ、アソシエーション、pub-sub、マイグレーションといった機能が便利だからなんですよね。ただ便利といういだけのみならず、型安全だったり自動化してくれたりと信頼性を高める工夫を書くORMがしているわけで、それを使わないのはもったいないです。

その上で、まあ私としてはOrmaが私の感じる問題を解決してくれる唯一のORMなのでOrmaを使いますが、機能や将来性などを考えるとフルコミッターを大勢抱えるRealmはよさそうだなとは思います。Ormaはすばらしいライブラリですが、開発者がSPOFという点は否めませんからね。ただなぜ私がRealmを好かないのか、Realmがある世界に敢えてもうさらにORMを追加したのかというのも資料で触れてます。

というのはすべて差し置いて、ORMの開発はめちゃくちゃ楽しかったのでよかったらあなただけのオリジナルORMを作ってみてください。

Bit Journeyに転職してKibelaをリリースしました

半年くらいまえにBit Journeyに転職してKibelaを作ってました。AndroidエンジニアからRails + Reactエンジニアへの転向ということになります。

Kibelaはこちら。ようやく本日リリースできました。といっても開発面でいうとこれからが正念場ではあります。

Kibela - 個人の発信を組織の力にする情報共有ツール

“個人の発信を組織の力にする情報共有ツール” と銘打っているとおり、これは 個人が組織内で自由に情報を発信すると組織が活性化する という仮説に基づいて設計されている、会社などの組織向けのサービスです。もちろんそれだけでなく、仕様書の整理につかったり議事録をとりあえず突っ込んでおくみたいなのもありです。

さてKibelaでできることはBlogとWikiを書くことです。これはつまり 個人が発信する情報 とそれ以外を分けるということです。このあたりの思想やベストプラクティスは追々公式ブログで語っていきたいです。

今回の転職は、このKibelaというサービス自体に興味があったというのが主な理由でした。Kibelaはクックパッドの社内向け内製ツールGroupadにヒントを得て設計されています。実際のところ、 個人が組織内で自由に情報を発信すると組織が活性化する という仮説は、私やBit Journey創業メンバーがいたクックパッド社での経験がもとになっています。Groupadが見せてくれた組織における情報共有のありかたには在職中ずっと興味をもっていて、クックパッド社の活力の源のひとつだと考えていたのです。そういう思いがあったので、Bit Journey代表の井原からの「一緒にKibelaを作らないか」という誘いに乗ることにしたのでした。

Kibelaは技術的にみると普通のRailsアプリケーションといえますたサービスの性質上そこまでスケーラビリティやパフォーマンスへのプレッシャーはありません。しかし半年経験してみると、技術的に深掘りできるポイントもたくさんあり、いまのところ楽しんで開発してます。

というわけで、Kibelaをどうぞよろしく!

Herokuのreview appsでRailsのLetter Opener WebをつかうHack

github.com

これのREADMEにもありますが、Letter Opener (-Webも含む)をHerokuで使うにはちょっと注意が必要です。つまり、

  • Letter Openerはデータを #{Rails.root}/tmp/letter_opener に保存する
  • Herokuはdyno (node) 間でデータのやりとりは出来ない設計
  • ActionMailerの deliver_later! はActiveJobで実行するので、web dynoではなくworker dyno のファイルシステムにLetter Openerのデータを保存する

という状況なので、 #deliver_now! で送信したものはLetter Openerで見えますが、 #deliver_later! で送信したものは見えない、ということになります。

じゃあHeroku review appのときだけ #deliver_later!#deliver_now! に置き換えればいいんですねということでApplicationMailerにこんな感じのhackを入れました。

ActionMailerの内部実装に強く依存しているのでRailsのアップデートなどで動かなくなる可能性はありますが、 Rails 4.2.x, 5.1.x あたりはこれで大丈夫そうです。

# rubocop:disable Style/MethodMissing

class ApplicationMailer < ActionMailer::Base
  # Heroku web apps do not access worker's filesystem
  # https://github.com/fgrehm/letter_opener_web#usage-on-heroku

  # override
  def self.method_missing(method_name, *args)
    if action_methods.include?(method_name.to_s)
      MyMessageDelivery.new(self, method_name, *args)
    else
      super
    end
  end

  class MyMessageDelivery < ActionMailer::MessageDelivery
    # override
    def deliver_later!
      if Rails.env.heroku_review_app?
        deliver_now!
      else
        super
      end
    end

    # override
    def deliver_later
      if Rails.env.heroku_review_app?
        deliver_now
      else
        super
      end
    end
  end
end