notebook

都内でWEB系エンジニアやってます。

DenoでPowerline風OGP生成ツールを作った

この記事はDenoのカレンダーの2日目の記事です

qiita.com

Deno+CanvasでPowerline風のOGP画像を動的に設定できるアプリケーションを作ったのでその紹介です

Powerline風のOGP生成ツール

イメージ

主にOGP用途で、こんな画像を返すサーバを実装した

alt

画像Generator

terminal-image.deno.dev/generator

https://terminal-image.deno.dev/generatorterminal-image.deno.dev

GitHub

swfz/deno-terminal-image

背景

本ブログ以外にも自前でブログを持っていてそちらのOGP画像が欲しいと思っていた

ブログのOGPなので文字列だけ動的に入れ込めるようにしたい

どうせならタグとかも載せたい

Powerlineが好きなのでPowerline風にしたい

動的な処理が必要なのでサーバが必要だがDenoであればDeno Deployでサクッとデプロイできる

これは簡単に作れそうでは?

という感じで作ってみた

使い方

GeneratorでURLを生成できるのでそれをmetaタグのOGP設定に指定するだけ

タイトルやTagなど動的に変更したい箇所は適宜パラメータを調整する

カスタマイズ

書き始めたときはpng画像を返すサーバの実装のみだったがコード書いていくうちにあれこれパラメータとして渡したい欲求にかられ結局ある程度自由にパラメータを渡せるようにした

そこまで作り込んだら次はGeneratorを作ってカスタマイズできるようにしようということでこちらも作った

独自の色味を作れる

いくつか馴染みのあるテーマ風のテンプレートを用意したので以下に例を載せる

alt

alt

alt

実装

ここからは実装内容からいくつか絞って取り上げる

大したことはしていない…

Canvas

DenoでCanvasを扱うためのモジュールがあるのでそれを使う

canvas@v1.4.1 | Deno

https://deno.land/x/canvas@v1.4.1deno.land

  • Deno.serveでCanvasから画像レスポンスを返すサンプルコード
import { createCanvas } from "https://deno.land/x/canvas@v1.4.1/mod.ts";

const port = 8080;

const handler = async (request: Request): Promise<Response> => {
  const [width, height] = [1200, 630];

  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext("2d");

  ctx.fillStyle = "#0000FF";
  ctx.fillRect(0, 30, 1200, 80);

  const headers = new Headers();
  headers.set("content-type", "image/png");

  const response = new Response(canvas.toBuffer(), {
    headers: headers,
    status: 200,
  });

  return response;
};

Deno.serve({ port }, handler);

createCanvasを使用してCanvasのコンテキストを生成

ctx.fillStylectx.fillRectなど普通にCanvasをいじるときと同様の操作をする

サンプルでは適当な線を引かせてみた

canvas.toBuffer()の結果をResponseに含めて画像のレスポンスを返す

  • deno runでローカルサーバを立ててアクセスしてみる

こんな感じ

日本語対応フォント

Canvas上で日本語を表示させるための処理が必要

特にフォント指定しない場合

このように日本語は表示されない

NotoSansCJKのフォントを取得してcanvas.loadFontでフォントを読ませるようにした

このloadFontはDenoのモジュールが提供しているもののよう

familyで指定した文字列をctx.fontで指定する

  • NotoSansCJKフォントを読み込んでCanvasで表示させるためのサンプルコード
import { createCanvas } from "https://deno.land/x/canvas@v1.4.1/mod.ts";

const port = 8080;

const handler = async (request: Request): Promise<Response> => {
  const [width, height] = [1200, 630];

  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext("2d");

  const font = await Deno.readFile("./NotoSansCJK-Regular.ttc");
  canvas.loadFont(font, {
    family: "notosans",
  });
  ctx.font = "50pt notosans";

  ctx.fillStyle = "#00DDDD";
  ctx.fillText("日本語のタイトル", 50, 300)

  const headers = new Headers();
  headers.set("content-type", "image/png");

  const response = new Response(canvas.toBuffer(), {
    headers: headers,
    status: 200,
  });

  return response;
};

Deno.serve({ port }, handler);

いい感じ

分かち書き

長いタイトルの場合、変な箇所で区切られてしまうのは困る

単語区切りのちょうどよいところで改行させたい

分かち書きといえばMeCabなどが有名だが、実装時はtiny-segmenterというライブラリを使った

leungwensen/tiny-segmenter: Mirror of TinySegmenter, the super compact Japanese tokenizer in JavaScript.

  • 辞書なし
  • わずか25kb
  • 文字種情報と組み合わせを特徴量として学習、分類

と言った特徴がある

作ったときは知らなかったが、今実装するならbudouxにすると思う

budoux/javascript at main · google/budoux

確か同じようなコンセプトだったはず、どこかで差し替えたい

tiny-segmenterはdenoには公開されていないがnpmとしては公開されているのでesm.shで読み込める

  • tiny_segmenter_sample.ts
import TinySegmenter from "https://esm.sh/tiny-segmenter@0.2.0";

const title = "Slack APIで特定チャンネルの会話履歴を取得する";

const segmenter = new TinySegmenter();
const segments = segmenter.segment(title);
console.log(title);
  • 実行結果
$ deno run tiny_segmenter_sample.ts
[
  "Slack", " ",
  "API",   "で",
  "特定",  "チャンネル",
  "の",    "会話",
  "履歴",  "を",
  "取得",  "する"
]

うまく分割できた

マルチバイトの文字列幅

改行位置を特定するということはタイトルの文字列の長さを把握して描画領域の幅と比較し、タイトルが上回る場合は改行させるという処理が必要

テキストの長さの情報が必要となる

Canvasで文字列幅を取得するにはctx.measureText("文字列")で可能だが、モジュールのREADMEに「asciiのみ対応」と書いてあり、実際に試すとaでもでも同じ文字列幅が返ってきた…

なのでワークアラウンド感はあるが、ブラウザ側でCanvasを用意し実際にいくつか測ってみて、ブラウザ側(Generator)ではそのままmeasureTextの値を使用、サーバ側では特定の比率分だけ調整するというような処理をいれた

  • measureTextが機能するかチェックする関数
const measuredCorrectly = (ctx: CanvasRenderingContext2D) => {
  return ctx.measureText("あ").width !== ctx.measureText("a").width;
}

これで判断することにした

Generator

この画面の話

パラメータを弄ってサーバにアクセスして確認して…というフローが面倒だなと感じ始め、プレビューできる画面も欲しくなったので作った

簡単なhtmlを用意してserveしているだけ

  • index.htmlファイルをserveするサンプル(一部抜粋)
import { serveFile } from "https://deno.land/std@0.208.0/http/file_server.ts";

  const url = new URL(request.url);
  if (url.pathname.startsWith("/generator")) {
    return await serveFile(request, `${Deno.cwd()}/static/index.html`);
  }

パラメータを弄ったらリアルタイムにプレビューが変わるが、サーバ側のロジックをフロントでも流用している

deno_emitbundleで複数ファイルをトランスパイル、一部加工して一つのapp.jsにまとめる処理を書いて、htmlからはapp.jsを読む

デプロイ時にはapp.jsなどの成果物も含めてデプロイすることで静的に配信している

denoland/deno_emit: Transpile and bundle JavaScript and TypeScript under Deno and Deno Deploy

github.com

ワークアラウンドもりもりなので一応リンクだけ…

deno-terminal-image/bundle.ts at main · swfz/deno-terminal-image

まとめ

Deno+CanvasでPowerline風OGP生成ツールを作った

  • Canvasで画像レスポンスを生成
  • 日本語対応フォントを読ませ日本語に対応
  • 分かち書きを用いて適切な改行位置を特定

感想としては

  • Deno deployが手軽で良い
    • サクッとサーバが必要なものを試せるのがすごく楽
  • npmの資産も活かせる
  • Canvasの操作は結局たいへん

何かサクッと試したいなと思ったときに選択肢の1つとして良いなと感じた

この記事書いてたら、ここまでやるならFreshで書き直したほうがよい?プレビューはサーバ側に直接リクエストしてもよい?と思えてきたのでどこかでやりたい

最後に、もう一度Generatorのリンクを置いておくので良ければ使ってみてください

terminal-image.deno.dev/generator

https://terminal-image.deno.dev/generatorterminal-image.deno.dev