👻

FlutterでMFM(Markup Language For Misskey)を扱う

2023/05/13に公開

MFMとは

MFMは、オープンソースのSNSプラットフォームである「Misskey」が持つ独自のMarkdown形式です。HTMLや一般によく知られたMarkdownと多少同じタグや構文などはありますが、基本的に異なるものです。

現状、「MFM」の実装は、本家のTypeScript実装以外に見つかりません。

HTML/CSS/JavaScript(TypeScript)以外の環境でもMFMを扱う需要がなかったのかもしれません。Misskeyのスマホアプリが少なく、またこのMFMにがっつり対応したものでWebViewのレンダリングでないものも無いようです。[1]

ここではFlutterでこのMFMを描画できるようにしていきましょう。Flutterは描画エンジンがSkiaですから、もしかしたらHTML/CSS/JavaScript以外のレンダリング環境ではじめての実装かもしれません。

なお、下記のスクリーンショットはFlutterで作成したサンプルアプリで、MFMの一例です。

MFM構文パーサを作る

なにはともあれまず構文パーサが必要です。幸いにもTypeScriptとDartはそれなりにパラダイムが近い言語ですので、気合でなんとかして私が移植しました。こちらです。

TypeScriptで実装されたテストも同様に移植し、同じ結果が得られることを確認できたのでバージョン1.0.0として公開しました。

https://pub.dev/packages/mfm_parser

テストコードの最後をご覧いただけばわかりますが、このように継承関係のあるノードを返します。

mfm_test.dart
test("composite", () {
	final input = r"""before
<center>
Hello $[tada everynyan! 🎉]

I'm @ai, A bot of misskey!

https://github.com/syuilo/ai
</center>
after""";
  final output = [
    MfmText("before"),
    MfmCenter(children: [
      MfmText("Hello "),
      MfmFn(name: "tada", args: {}, children: [
        MfmText("everynyan! "),
        MfmUnicodeEmoji("🎉"),
      ]),
      MfmText("\n\nI'm "),
      MfmMention("ai", null, "@ai"),
      MfmText(", A bot of misskey!\n\n"),
      MfmURL("https://github.com/syuilo/ai", false)
    ]),
    MfmText("after"),
  ];

  expect(parse(input), orderedEquals(output));
});

ノードをウィジェットへ置き換える

ここではこれらのノードをウィジェットに置き換えていきましょう。話をかんたんにするのと、私自身がそこまでまだ実装できていないこともあって、「動きのない」MFMに話を絞ります。

「動きのある」MFMについては、実装の目処が立って落ち着いたら解説の記事を書きたいと思います。

簡易な装飾

Flutterは皆様ご存知かと思いますがすべてウィジェットで表現されます。上のような継承関係のあるツリー構造はまさに、Flutterが持つウィジェットの構造とそのまま合わせることができます。

とはいえ、このノードをそのままウィジェットにするというのは少し難しい話です。単純にテキストや太字、センタリング、関数をそのままStatelessWidgetに置き換えるというのはあまり想像がつきません。

ではどうするかというと、Text.richを使用します。Text.richはリッチテキストのためのウィジェットですが、TextSpanとWidgetSpanを複数子に持つことができます。

そうするとText.richを返す、MFMElementWidgetを定義し、その中で子のノードに対してさらにMFMElementWidgetを再帰的に作っていくことで、リッチテキストの形でこれらの表現を再現していくことが可能になります。

これを図で示すとおおよそ下記のようになります。

概念図

さてこれで方針ができました。では太字や色をつける実装をしていくわけですが、このスタイルにも継承関係が発生します。いろいろな方法が考えられますが、ここではDefaultTextStyle.mergeを使っていきましょう。

DefaultTextStyleは、それより子にあるウィジェットにスタイルを強制するウィジェットです。DefaultTextStyleはただそれだけですが、DefaultTextStyle.mergeはそのウィジェットのコンテクストに対してマージをして伝播させていくことができます。

DefaultTextStyle.mergeで太字、斜体、取り消し線、色、サイズ、フォントの種類などをノードごとに反映して、子に伝播させていくことで、MFMの描画が可能になります。


else if (node is MfmBold)
  WidgetSpan(
    child: DefaultTextStyle.merge(
      style: TextStyle(fontWeight: FontWeight.bold),
      child: MfmElementWidget(nodes: node.children),
    ),
  )
else if (node is MfmSmall)
  WidgetSpan(
    alignment: PlaceholderAlignment.middle,
    child: DefaultTextStyle.merge(
      style: TextStyle(
        fontSize: (DefaultTextStyle.of(context).style.fontSize ?? 22) * 0.8),
	color: Theme.of(context).disabledColor,
      ),
      child: MfmElementWidget(nodes: node.children),
    ),
  )
else if (node is MfmItalic)
  WidgetSpan(
      child: DefaultTextStyle.merge(
        style: const TextStyle(fontStyle: FontStyle.italic),
        child: MfmElementWidget(nodes: node.children),
      ))
else if (node is MfmStrike)
  WidgetSpan(
      child: DefaultTextStyle.merge(
        style:
            const TextStyle(decoration: TextDecoration.lineThrough),
        child: MfmElementWidget(nodes: node.children),
      ))

ここで注目に値するのは、MfmSmallの(DefaultTextStyle.of(context).style.fontSize ?? 22) * 0.8)です。親から引き継がれた相対的なフォントサイズの倍率を指定することによって、下記のような記述をされたときにも相対的にbの部分をさらに小さくすることができます。

<small>a <small>b</small> c</small>

複雑な装飾

これだけならMarkdownでいいやとなるのですが、MFMがもつ秘めた可能性はこれで終わりません。
MFMを少しでも見たことがある人ならご存知かと思いますが、$[position $[scale $[rotate $[flip $[blur といった一般的なMarkdownでサポートされないような要素もサポートされます。

これらはCSSの強力な後ろ盾があって実現されているものですが、Flutterでも実現可能です。Transformウィジェットを使用します。

とりあえずposition, scale, rotateの3つについては、素直にTransformのファクトリコンストラクタを使用するだけで実装することができます。

functionにはMfmFnクラスの値が入っているものとして、下記のようなコードになります。

    if (function.name == "rotate") {
      final deg = double.tryParse(function.args["deg"] ?? "") ?? 90.0;
      return Transform.rotate(
          angle: deg * pi / 180,
          child: MfmElementWidget(nodes: function.children));
    }
    

    if (function.name == "scale") {
      final x = double.tryParse(function.args["x"] ?? "") ?? 1.0;
      final y = double.tryParse(function.args["y"] ?? "") ?? 1.0;

      // scale.x=0, scale.y=0は表示しない
      if (x == 0 || y == 0) {
        return Container();
      }

      return Transform.scale(
        scaleX: x,
        scaleY: y,
        child: MfmElementWidget(nodes: function.children),
      );
    }

    if (function.name == "position") {
      final x = double.tryParse(function.args["x"] ?? "") ?? 0;
      final y = double.tryParse(function.args["y"] ?? "") ?? 0;
      final double defaultFontSize =
          (DefaultTextStyle.of(context).style.fontSize ?? 22) *
              MediaQuery.of(context).textScaleFactor;

      return Transform.translate(
        offset: Offset(x * defaultFontSize, y * defaultFontSize),
        child: MfmElementWidget(nodes: function.children),
      );
    }    

ここでひとつ考慮しないといけないのは、positionについては「現在のtextScaleFactor」と「現在のフォントサイズ」の2つの要素によって移動量が変動するということです。

一番親のText.richにtextScaleFactorを指定しない場合は1固定ですが、ユーザビリティを考慮すると継承したほうがよいです。そうすると移動量は当然テキストの倍率で変わっていきますので、それを反映する必要があります。[2]

さて、$[flipというものもあります。これは反転させるものですが… これも比較的素直な実装で実現することができます。flipだけ指定されたかhのみならY方向に180°、vが指定されたらX方向に180°、両方が指定されたらZ方向に180°の回転をかけると、想定したような反転の挙動を示します。


    if (function.name == "flip") {
      final isVertical = function.args.containsKey("v");
      final isHorizontal = function.args.containsKey("h");

      if ((!isVertical && !isHorizontal) || (isHorizontal && !isVertical)) {
        return Transform(
          transform: Matrix4.rotationY(pi),
          alignment: Alignment.center,
          child: MfmElementWidget(nodes: function.children),
        );
      }

      if (isVertical && !isHorizontal) {
        return Transform(
          transform: Matrix4.rotationX(pi),
          alignment: Alignment.center,
          child: MfmElementWidget(nodes: function.children),
        );
      }

      return Transform(
        transform: Matrix4.rotationZ(pi),
        alignment: Alignment.center,
        child: MfmElementWidget(nodes: function.children),
      );
    }

まだありますね。$[blurです。これはTransformでは解決することができませんが…。Flutterには標準のImageFilteredを使用することで、子のウィジェットにぼかしを入れることができます。

また、Misskeyではタップするとぼかしが解除されます。この動作を再現するために、StatefulWidgetで状態を持つようにしてみましょう。[3]


class MfmFnBlur extends StatefulWidget {
  final Widget child;

  const MfmFnBlur({super.key, required this.child});
  
  State<StatefulWidget> createState() => MfmFnBlurState();
}

class MfmFnBlurState extends State<MfmFnBlur> {
  bool isBlur = true;

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() => isBlur = !isBlur);
      },
      child: ImageFiltered(
        imageFilter: ImageFilter.blur(
            sigmaX: isBlur ? 10.0 : 0, sigmaY: isBlur ? 10.0 : 0),
        child: widget.child,
      ),
    );
  }
}

これでひとまずの実装を行うことはできました。細かいことを述べると、WidgetSpanにalignment: PlaceholderAlignment.middle,がないとカスタム絵文字と文字が下揃えになってしまうなどいろいろありますが、それなりに再現できるようになったのではないでしょうか。

その他雑記など

ここで示したコードはmfm_rendererからの抜粋です。いま作成中のMisskeyクライアントアプリではここで示したような方法でMFMを描画しています。

https://github.com/shiosyakeyakini-info/mfm_renderer

ピュアなDart&Flutterだけでかなり再現性の高い描画ができることが分かり、私自身もFlutterの実力を見て驚きました。

MFMのレンダラの部分についてもpub.devに公開したいですね。(まだ公開できる品質ではないので…)

脚注
  1. MissRiricaはWebViewの描画のように見受けられますし、MilkteaMisscatは複雑なMFMを描画することはできませんでした。 ↩︎

  2. textScaleFactorをMFMElementWidgetに実装してはいけません。相対的にどんどん大きくなったり小さくなったりします。 ↩︎

  3. 実際には、一つのノートないでぼかしが解除されるのは1つだけです。あるぼかしの要素を解除した
    状態で別のぼかしをタップすると、もとのぼかしはもとに戻るので、この動きを再現しようとするとInheritedWidgetなどでウィジェットそのもののハッシュを持って管理するなどが必要かもしれません。 ↩︎

Discussion