こんにちは、型と複雑GUIが大好きな @f_subal です。 普段は pixivFACTORY というサービスでフロントエンドをやっています。
さて、早速スクショでお見せしていますが、 今年の3月に pixivFACTORY のグッズ編集画面はリニューアルしました。
すでにこちらの記事でも紹介がありましたが、 新しい画面では、画像のみならず文字を使ったデザインができるようになっています。 フォントワークス および MonoType の書体が 100 スタイル分利用可能です。 文字だけでの制作も可能ですので是非ご利用ください。
https://factory.pixiv.net/item_groups/new
今回は、このテキスト入力機能のフロントエンド実装、 特に React と SVG でいかに文字レイヤーを表現するかについてご紹介したいと思います。
SVG にとって文字レイヤーとは何か?
pixivFACTORY では原稿の表現に SVG を用いています。
以前は HTML で <div>
をドラッガブルにしていました。
SVG に移行した理由としては図形の表現力ももちろんあるのですが、 最も大きかったのは単位系の表現が容易だったことです。
そもそもグッズとは物理的な存在です。物理的に印刷されるものをブラウザで編集するには、ブラウザ上で px 単位ではない長さ( ここでは mm の長さ )を表現できる仕組みが必要になります。SVG の場合、viewBox を上手く活用することで、異なる単位系の橋渡しがスッキリ書けます1。
このあたりは以前 pixiv TECH SALON で発表したこちらのスライドをご覧ください。
改行を表現する
さて、SVG で文字を表現するのに用いる要素は <text>
または <tspan>
です。
このとき念頭に置かないといけないのは、SVG の文字においては「改行」という概念がないことです。SVG に <br>
タグはありませんからね。
また、「折り返し」「行間」という概念もビルトインでは存在しません。
HTML のように、たとえば親である <div>
要素の右端に行ったら折れるという挙動はありません。<text>
そのものには一行あたりの固定した幅というものがないからです2。
右端で勝手に折れないのはまぁ良いとして、改行そのものは無いと困りますね。 <tspan>
には y
座標が指定できるので、行数やフォントサイズから y
座標に換算できればとりあえず良さそうです。
<text>
の中に各行が <tspan>
で書かれるとします。このとき n 行目の <tspan>
の y
座標は (n - 1) × フォントサイズ × 行送り(linefeed) で計算できます。
気をつけるべきなのは、何も考えず <tspan>
の y
座標を 0
にすると、行の上端ではなくベースラインが基準に来てしまうということです。これは SVG の dominant-baseline を設定するか、フォントサイズ1個分余計に y
を足すかで調整できます。
React で簡単に書くと次のようになるでしょう(簡単のため、 fontSize
は mm
単位で表現されるものとしますが、実際には pt
→ mm
換算が挟まります )。
文字レイヤーの変形
さて、これで文字を描画することができました。 しかし我々が作りたいのはデザインツールですから、これをマウスなどを用いて変形できなければなりません。
画像レイヤーの変形については先程のスライドでも触れているので参照してください。 これだけでもなかなかに面倒なのですが、文字レイヤーにはさらに画像レイヤーにない固有の難しさがあります。
それはバウンディングボックスの大きさを確定させることです。
画像レイヤーの場合、上のようなバウンディングボックスの大きさは、そのまま画像の大きさに一致します。
画像の縦横のサイズは、ファイルを見ればわかりますし、DB に width
, height
カラムを格納していれば何の苦労もなく利用できるでしょう。
しかし、文字レイヤーはそうは行きません。 たとえば ヒラギノ角ゴシック W6 で 18pt( 行間 1.5 )で入力された「こんにちは\n世界」という文字が 何px × 何px の矩形で囲めるか、その場で測る以外の方法で分かるでしょうか?
にも関わらず、バウンディングボックスの大きさは文字レイヤーをレンダリングする前に確定したいということになり、鶏が先か卵が先かの問題になります。
当初、この問題がある都合上、文字レイヤーの場合はバウンディングボックスによる変形を諦める( 別の方法で大きさを変える )ことも検討しかけたのですが、なんとか大きさを測る仕組みを導入し、文字でも画像と同じ方向で変形できるようにしました3。
具体的には次のような方針を取りました。 実際に画面に見える文字レイヤーとは別に、画面外に同じ大きさの文字レイヤーを描画し、その大きさを測って渡すというものです。
React では次のように表現します。 まず、すべての文字レイヤーを画面外に描画するコンポーネントを用意します。
そしてその子要素として、自分の大きさを測る機能を持った見えない文字レイヤーのコンポーネントを作ります。
文字レイヤーになんらかの更新がある度に、描画済みの文字レイヤーは自身の大きさを測り、親の state ないし Redux の store に格納します。
さて、実際に画面に表示される個別の文字レイヤーは、この TextSizeProvider
から Context API を通じて自身の大きさを受け取ります。
これにより、各文字レイヤーはバウンディングボックスの大きさを確定することができます( 測るところと描画するところで、同じ <TextLayer>
コンポーネントを使っているのがポイントです )。
ただあとで知ったのですが、世の中には opentype.js というライブラリがあって、フォントファイルを直接ブラウザで読み、大きさを測る方法もあるようです。
こちらの検証はしてないですが、上手くいきそうならこっちに乗り換えるかもしれません。
Web フォントのローディング戦略
ブラウザ上で有料フォントを用いるためには、Web フォントを扱う仕組みが必要です。通常なら CSS に @font-face
を書いて終わりですが、今回はそうは行きません。
pixivFACTORY のテキスト入力機能では 100種類の Web フォント( うち 80 種類が日本語 )を扱います。日本語フォントは数千グリフを収録する分サイズが重いですし、それらを何十ファイルも予めダウンロードするというのは、あまり考えたくない話です。
今回の要件では、利用するフォントだけを、必要な文字数分だけオンデマンドに取得する必要があります。フォントを必要な文字数分サブセット配信する仕組みはこちらを見ていただくとして、今回はそれをフロントエンドで扱う仕組みをお話します。
フォント配信を行うマイクロサービスに、フォント名と使用文字を指定してサブセットを得るエンドポイントがあるとします( URL はイメージです )。
ユーザーが文字を入力する度にこのエンドポイントを叩き、降ってきた Web フォントの URL をグローバルな <style>
タグに反映する、というのがフロントエンドで必要な仕事になります。
React はもともと、要素の style=
属性を更新するのは簡単にできます。一方、グローバルな <style>
タグの中身をリアクティブに更新する仕組みは標準ではついてきません。もちろん <style dangerouslySetInnerHtml={...} />
という方法はありますが、できれば避けたいところです。
今回そこで目をつけたのが styled-components v4 でした。
styled-components には createGlobalStyle
というヘルパー関数があります( https://www.styled-components.com/docs/api#helpers )。これは文字通りグローバルな <style>
タグを、通常の React コンポーネントと同じように書ける仕組みを提供するものです。
すごい。
実は pixivFACTORY は styled-components は使っておらず、コンポーネントのスタイルには Dart Sass + CSS Modules でしていますが、この機能のために styled-components を導入しました。おかげで @font-face
の更新処理はすぐに実装できました4
まとめ
これだけ大掛かりな UI は自分の人生でも初めてだったのですが、形にできてよかったです。
React と SVG の組み合わせは実際強力で、 <div>
をこねくり回していた頃はできなかったものができます。皆さんも巨大 GUI に挑戦する際は是非ともやってみてください!
最後に、ピクシブ株式会社はブラウザの限界を超える創作体験をつくれるフロントエンドエンジニアを募集しています!
-
SVG は属性に単位が書けるのに
width="40px"
viewBox="0 0 100mm 100mm"
と書かないのはなぜか、気になった人もいるかも知れません。MDN によると、ブラウザでは基本的に px ⇔ mm が 90dpi と仮定されるようです( https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Positions )。この値が特に都合の良いものではなかったので、ここは無次元で mm の値を書いたほうが考えることが減るだろうと判断しました。 ↩ -
SVG 2 からは
inline-size
プロパティを利用して一行あたりの固定した幅を表現することが出来ますが、まだほとんどのブラウザに実装されていません。https://www.w3.org/TR/SVG2/text.html#InlineSize ↩ -
実際、文字レイヤーの大きさが測れなくて困るのは単にバウンディングボックスの見た目の問題だけではありません。回転を実装する際に、「この点を中心に回転する」というのを確定するのにも、矩形の大きさが結局必要になってきます。なのでどの道諦める訳には行かなかったのです。 ↩
-
とは言ってもこの後 Web フォント読み込みの CORS 設定やブラウザ間差異など他のところで苦しむんですが、ここを自前でやってたらもっと酷いことになっていたと思います。 ↩