DLsiteプロフィール帳を作りたい

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

# ニャン作さんをご存じですか？
https://www.youtube.com/@user-ws1sf2cn4t

DLsiteにはニャン作さんという同人創作活動についての情報発信をしてくれるキャラクターがいます。
ニャン作さんのTwitterを見ていたところ、DLsiterプロフィール帳なるものを配布されておりました。

https://x.com/dIsiter_NYANSAK/status/1668173795524489217

さっそく自分も書こうと思いましたが、画像ファイルなので書き込むのが大変:sweat_drops:
そこで、簡単にプロフィール帳を作れるWebアプリを作ってみました！

https://dlsite-profile-maker.web.app/

フォームの質問に回答するだけでプロフィール画像を作成することができます。
（どんな感じで作れるかは、実際に試してみてください:pray:）
![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/e16fb882-6990-0230-d9fc-7a215ddff370.png)


# 構成
難しいことは一切やっていません。
ちょっとしたことをやりたいときはFirebaseにホストするのが一番楽だと思っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/ffa88882-7c4b-6cd2-090d-543984099322.png)

# 技術要素
UIライブラリに[Bootstrap](https://getbootstrap.jp/)を使っていますが、ほぼ素のHTML、JavaScript、CSSです。（一部JQueryもアリ）
今回のキモはcanvas上に画像を表示して、さらにその上に文字や図形を配置する処理です。

### Canvasへの画像の読み込みと描画
画像リソースのロードが完了したタイミングでcanvasのサイズを設定するのがミソです。
```JavaScript
// 画像の描画処理
function draw(canvas, imagePath) {
  // canvasが画像読み込む時の処理
  // 画像を読み込みに時間がかかる場合があるので、onLoadのタイミングでcanvasのサイズを設定
  image.addEventListener("load", function () {
    canvas.width = image.naturalWidth;
    canvas.height = image.naturalHeight;
    ctx = canvas.getContext("2d", { storage: "discardable" });
    ctx.drawImage(image, 0, 0);
  });
  // 画像リソースのオリジンが違う場合は設定
  image.crossOrigin = "anonymous";
  image.src = imagePath;
}
```

### Canvasの初期化
初期化時に前回の描画内容が残ってしまうときがあり、色々試してこのやり方に落ち着きました。
```JavaScript
// canvasを初期化する
ctx.clearRect(0, 0, canvas.width, canvas.height);
delete ctx;
canvas.remove();
delete canvas;
```

### Canvasへの書き込み
意外とテキストに設定できる属性が多くて驚きました。
（もっと融通の利かないものだと勝手に思っていた）
```JavaScript
// 楕円を描く処理
function circle(ctx, x, y, xHalf, yHalf) {
    ctx.lineWidth = 12; // 線の幅
    ctx.strokeStyle = "deeppink"; // 線の色
    ctx.beginPath(); // 初期化
    // x：楕円の中心の X 軸 (水平) 座標
    // y：楕円の中心の Y 軸 (水平) 座標
    // xHalf：楕円の長辺の半径
    // yHalf：楕円の短辺の半径
    // 0：楕円の傾き（ラジアン）
    // 0：楕円が始まる角度で、正の X 軸から時計回りの角度（ラジアン）
    // 2 * Math.PI：楕円が終わる角度で、正の X 軸から時計回りの角度（ラジアン）
    ctx.ellipse(x, y, xHalf, yHalf, 0, 0, 2 * Math.PI);
    ctx.stroke(); // 描画
}
// テキストを書く処理
function text() {
    ctx.textAlign = "center"; // 文字の配置
    ctx.fillStyle = "deeppink"; // 文字の色
    ctx.font = 'bold 64px "M PLUS 1"'; // 文字のフォント
    ctx.fillText($("#name").val(), 964, 370); // 描画
}
```

### プロフィール画像作成の全体像
フォームに入力されたテキストなどを取得⇒描画という流れです。
```JavaScript
var canvas, ctx;
var image = new Image();
var imagePath =
    "下地になる画像のURL";

$(function () {
  $('<canvas>').attr({
    id: 'image-canvas',
  }).appendTo('.canvas-wrapper');

  canvas = document.getElementById("image-canvas");
  draw(canvas, imagePath);

  // 画像の描画処理
  function draw(canvas, imagePath) {
    // canvasが画像読み込む時の処理
    // 画像を読み込みに時間がかかる場合があるので、onLoadのタイミングでcanvasのサイズを設定
    image.addEventListener("load", function () {
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      ctx = canvas.getContext("2d", { storage: "discardable" });
      ctx.drawImage(image, 0, 0);
    });
    // 画像リソースのオリジンが違う場合は設定
    image.crossOrigin = "anonymous";
    image.src = imagePath;
  }

  // 楕円を描く処理
  function circle(ctx, x, y, xHalf, yHalf) {
    ctx.lineWidth = 12; // 線の幅
    ctx.strokeStyle = "deeppink"; // 線の色
    ctx.beginPath(); // 初期化
    // x：楕円の中心の X 軸 (水平) 座標
    // y：楕円の中心の Y 軸 (水平) 座標
    // xHalf：楕円の長辺の半径
    // yHalf：楕円の短辺の半径
    // 0：楕円の傾き（ラジアン）
    // 0：楕円が始まる角度で、正の X 軸から時計回りの角度（ラジアン）
    // 2 * Math.PI：楕円が終わる角度で、正の X 軸から時計回りの角度（ラジアン）
    ctx.ellipse(x, y, xHalf, yHalf, 0, 0, 2 * Math.PI);
    ctx.stroke(); // 描画
  }

  // プレビューボタンを押したときの処理
  function buildProfile() {
    // canvasを初期化する
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    delete ctx;
    canvas.remove();
    delete canvas;

    // 再度画像を読み込み
    $('<canvas>').attr({
      id: 'image-canvas',
    }).appendTo('.canvas-wrapper');
    canvas = document.getElementById("image-canvas");
    canvas.width = image.naturalWidth;
    canvas.height = image.naturalHeight;
    ctx = canvas.getContext("2d", { storage: "discardable" });

    ctx.drawImage(image, 0, 0);

    ctx.textAlign = "center"; // 文字の配置
    ctx.fillStyle = "deeppink"; // 文字の色
    ctx.font = 'bold 64px "M PLUS 1"'; // 文字のフォント

    // 以降描画処理
    ctx.fillText($("#name").val(), 964, 370); // 
    ctx.fillText($("#nickname").val(), 1480, 370);
    ctx.fillText($("#birth-month").val(), 364, 496);
    ctx.fillText($("#birth-day").val(), 610, 496);
    ctx.fillText($("#zodiac-sign").val().slice(0, -1), 1230, 496);
    ctx.fillText($("#history").val(), 813, 628);
    ctx.fillText($("#interesting").val(), 890, 750);
    ctx.fillText($("#buy-count").val(), 682, 878);
    if ($("#life-style").val() === "朝型") {
      circle(ctx, 506, 1370, 100, 60);
    } else {
      circle(ctx, 838, 1370, 100, 60);
    }
    if ($("#smartphone").val() === "iPhone") {
      circle(ctx, 480, 1610, 130, 60);
    } else {
      circle(ctx, 840, 1610, 140, 60);
    }
    if ($("#personality").val() === "几帳面") {
      circle(ctx, 462, 1854, 130, 60);
    } else {
      circle(ctx, 840, 1854, 168, 60);
    }
    if ($("#dlsite-secret").val() === "いる") {
      circle(ctx, 490, 2092, 100, 60);
    } else {
      circle(ctx, 822, 2092, 120, 60);
    }
    ctx.fillText($("#hobby").val(), 1834, 1306);
    ctx.fillText($("#anime").val(), 1834, 1510);
    ctx.fillText($("#youtuber").val(), 1834, 1720);
    ctx.fillText($("#music").val(), 1834, 1926);
    ctx.fillText($("#food").val(), 1834, 2130);

    ctx.textAlign = "left";
    const favorites = $("#favorite").val().split("\n");
    if (favorites[0]) ctx.fillText(favorites[0], 500, 2530);
    if (favorites[1]) ctx.fillText(favorites[1], 500, 2640);
    if (favorites[2]) ctx.fillText(favorites[2], 500, 2748);
    if (favorites[3]) ctx.fillText(favorites[3], 500, 2856);

    const circles = $("#circle").val().split("\n");
    if (circles[0]) ctx.fillText(circles[0], 1444, 2530);
    if (circles[1]) ctx.fillText(circles[1], 1444, 2640);
    if (circles[2]) ctx.fillText(circles[2], 1444, 2748);
    if (circles[3]) ctx.fillText(circles[3], 1444, 2856);

    const genres = $("#genres").val().split("\n");
    if (genres[0]) ctx.fillText(genres[0], 292, 3074);
    if (genres[1]) ctx.fillText(genres[1], 292, 3182);
    if (genres[2]) ctx.fillText(genres[2], 292, 3290);
    if (genres[3]) ctx.fillText(genres[3], 292, 3396);

    const products = $("#product").val().split("\n");
    if (products[0]) ctx.fillText(products[0], 1248, 3074);
    if (products[1]) ctx.fillText(products[1], 1248, 3182);
    if (products[2]) ctx.fillText(products[2], 1248, 3290);
    if (products[3]) ctx.fillText(products[3], 1248, 3396);
  };

  $("#make-profile").on("click", function () {
    buildProfile();
  });

  $(".download").on("click", () => {
    buildProfile();
    const link = document.createElement("a");
    link.href = canvas.toDataURL("image/png");
    link.download = "dlsite_user_profile.png";
    link.click();
  });
});

```
これまで、canvasはあまり触りたくないと思って敬遠していたのですが、文字の中央寄せやフォント設定ができたり、意外と自由度が高くて驚きました。
配置座標の指定が、試して直しての繰り返しだったのでこれが結構つらいですね……

# 最後に
もし使ってくれる人がいたらフォントサイズや色も調整できるようにしたいなと思っています。
また、プロフィール帳画像自体はニャン作さんのご厚意で配布されているものなので、良識の範囲内で使って遊んでくださいね:bulb:

ちなみに、私のプロフィールはネットの海に放流してます:ocean:

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。



（ニャン作もっと流行って…:blush:）