FlutterでMFM(Markup Language For Misskey)を扱う
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として公開しました。
テストコードの最後をご覧いただけばわかりますが、このように継承関係のあるノードを返します。
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を描画しています。
ピュアなDart&Flutterだけでかなり再現性の高い描画ができることが分かり、私自身もFlutterの実力を見て驚きました。
MFMのレンダラの部分についてもpub.devに公開したいですね。(まだ公開できる品質ではないので…)
Discussion