BudouXとSatoriを使ってタイトルが分かち書きされたOGP画像を出力する。

Google Developers Japanのブログを読んでいたら、BudouXという分かち書き器が紹介されていました。

ところで、このブログではOGP画像をSatoriで生成しています。SNSでブログへのリンクが共有されることはほぼないだろうと踏んでかなりいい加減に作っていたのですが、思いの外色々なところで共有されており、度々OGP画像を目にする機会がありました。ありがたいことです。

ただ、目にしたOGP画像のなかで「うわぁ脳が嫌がる」となったものが2枚ありました。これです。

  • ブログをGridsomeからAstroに移行しました。
    ブログをGridsomeからAstroに移行しました。
  • シェルスクリプト(Bash)でwhile readが最初の行だけ読んでbreakするので対処する。
    シェルスクリプト(Bash)でwhile readが最初の行だけ読んでbreakするので対処する。

最後の行が文節で終わっていないのはかなり気になります。ブログの動作に影響を与える部分ではないため、気になりつつも放置していたのですが、 BudouXならこの問題を解決できそうなので導入してみました。

導入

このブログではAstroのintegrationからOGP画像を生成しているため、pnpm install budouxでインストールを行いました。公式手順通りインポートします。

import { loadDefaultJapaneseParser } from "budoux";
const parser = loadDefaultJapaneseParser();

分かち書きしてみる

試しに、修正したいOGP画像のタイトル「ブログをGridsomeからAstroに移行しました。」と「シェルスクリプト(Bash)でwhile readが最初の行だけ読んでbreakするので対処する。」をBudouXで分かち書きしてみます。

// input
import { loadDefaultJapaneseParser } from "budoux";
const parser = loadDefaultJapaneseParser();
console.log(parser.parse("ブログをGridsomeからAstroに移行しました。"));
console.log(parser.parse("シェルスクリプト(Bash)でwhile readが最初の行だけ読んでbreakするので対処する。"));

// output
[ 'ブログを', 'Gridsomeから', 'Astroに', '移行しました。' ]
[
  'シェルスクリプト(Bash)で',
  'while readが',
  '最初の',
  '行だけ',
  '読んで',
  'breakするので',
  '対処する。'
]

良さそうです。処理時間を計測するまでもなく高速で結果が返ってきました。

SatoriでOGP画像を生成する

分かち書きした結果をSatoriに渡してOGP画像を生成してみます。使用したSatoriのバージョンは0.10.8です。

だめだった方法

<wbr>, word-break: keep-all, overflow-wrap: anywhereで分かち書きした部分から折り返してくれるはずです。 なお、画像生成は以下のサイトを参考にしています。

import satori from "satori";
import { loadDefaultJapaneseParser } from "budoux";

const parser = loadDefaultJapaneseParser();

const generate = async (
  title: string,
  {
    background,
    font,
  }: {
    background: string;
    font: Buffer;
  },
): Promise<Buffer> => {
  const words = parser.parse(title);
  const svg = await satori(
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        textAlign: "center",
        width: 1200,
        height: 630,
        backgroundImage: `url(${background})`,
        backgroundSize: "1200px 630px",
      }}
    >
      <div
        style={{
          paddingBottom: "80px",
          paddingLeft: "30px",
          paddingRight: "30px",
          display: "flex",
          justifyContent: "center",
          width: 1040,
          height: 390,
          fontSize: "50px",
          color: "#fff",
          textOverflow: "ellipsis",
          alignItems: "center",

          // 分かち書き用の設定
          wordBreak: "keep-all",
          overflowWrap: "anywhere",
        }}
      >
        {words.map((word, i) => {
          return (
            <>
              {word}
              { // 最後の要素以外は<wbr>タグを付与する
                words.length - 1 !== i && <wbr />
              }
            </>
          );
        })}
      </div>
    </div>,
    {
      debug: true, // デバッグ用にボーダーを出力する
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "NotoSansJP",
          data: font,
          weight: 900,
          style: "normal",
        },
      ],
    },
  );
  const png = await sharp(Buffer.from(svg)).png().toBuffer();
  return png;
};

だめだった結果

失敗しました。分かち書きされた配列の中身がそれぞれ1つの要素となって一列に並んでしまい、その結果テキストが意図しない箇所で折り返されています。

なんか折り返されなくてぐちゃぐちゃになったやつ。
なんか折り返されなくてぐちゃぐちゃになったやつ。

divの中身は単なるテキストとして扱ってほしかったのですが、どうやらSatoriでは子要素はblock要素として扱うようです。

うまくいった方法

flex-wrap: wrapでchildrenのblock要素をそのまま折り返しました。

const generate = async (
  title: string,
  {
    background,
    font,
  }: {
    background: string;
    font: Buffer;
  },
): Promise<Buffer> => {
  const words = parser.parse(title);
  const svg = await satori(
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        textAlign: "center",
        width: 1200,
        height: 630,
        backgroundImage: `url(${background})`,
        backgroundSize: "1200px 630px",
      }}
    >
      <div
        style={{
          paddingBottom: "80px",
          paddingLeft: "30px",
          paddingRight: "30px",
          display: "flex",
          justifyContent: "center",
          width: 1040,
          height: 390,
          fontSize: "50px",
          color: "#fff",
          flexWrap: "wrap", // 親要素に収まらない子要素を折り返す
          textOverflow: "ellipsis",
          alignContent: "center",
          alignItems: "center",
        }}
      >
        {words.map((word) => {
          // satoriではinline-blockは使用できないため、明示的にblockを指定する
          return <span style={{ display: "block" }}>{word}</span>;
        })}
      </div>
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "NotoSansJP",
          data: font,
          weight: 900,
          style: "normal",
        },
      ],
    },
  );
  const png = await sharp(Buffer.from(svg)).png().toBuffer();
  return png;
};

うまくいった結果

うまくいきました。良かったですね。

期待通りの結果になった画像。
期待通りの結果になった画像。

ちなみに、「シェルスクリプト(Bash)でwhile readが最初の行だけ読んでbreakするので対処する。」の結果はこれです。こちらも期待通りの結果になりました。

補足

この方法だと、親要素よりも長くなる文字列がある場合に、次の文字が回り込まず改行が不自然になるという問題があります。以下の記事がわかりやすいです。

これを回避するには各チャンクをできるだけ細かく分割し、親の要素の横幅を超えないように調整しなければなりません。

調整のためにカスタムモデルの学習をするほどでもないので、力業でどうにかしました。

所感

BudouX、びっくりするくらい軽量で高速なのと、使い方がとてもシンプルで悩むところが一切なかったので、導入してよかったです。
最初字面だけ見てBURIKI ONEの続編だと信じて疑わなかったのですが、一切何も関係なかったですし、ゲームですらありませんでした。

おまけ

補足部分で「親要素よりも長くなる文字列がある場合に、次の文字が回り込まず改行が不自然になる」と書いたのですが、ふと、U+200B ZERO WIDTH SPACEとU+2060 WORD JOINERを使えば解決できるのではないか、と思い試してみました。

だめでした。
だめでした。

だめでした。フォントにNoto(no tofu)SansJPを使っているのに豆腐を見ることになるとは。