スポンサーリンク

【Cocoon】投稿画面から直接タイトル入りアイキャッチ画像を作れるツールを作ってみた! 

忘備録
この記事は約33分で読めます。
記事内に広告が含まれています。
スポンサーリンク

ちょっとした投稿(ニュース投稿など)では、アイキャッチ画像を毎回用意するのが面倒で、いつも同じ画像を使い回していました。そんな中、Cocoonにはタイトルから自動でアイキャッチ画像を生成してくれる便利な機能が実装されており、画像がないときにはとても助かっています。
ただ、背景画像を選べない点が少し物足りなく感じていました。
そこで今回は、投稿画面からそのままタイトル入りのアイキャッチ画像を作成できるツールを、ChatGPTの力を借りて実装してみました。
もし興味があれば、ぜひ参考にしてみてください!

スポンサーリンク

ツールの紹介

このツールは、WordPressの投稿編集画面に「タイトル入りアイキャッチ画像作成」パネルを追加し、アイキャッチ画像にタイトルをオーバレイさせた合成画像の作成、または画像がないときに背景色を選んでタイトルをオーバレイさせた画像を作成できるツールです。
編集はリアルタイムでプリビューで表示されるので保存前に確認することが出来ます。また、作成後はアイキャッチに設定されるようになっています。

主な機能一覧

機能内容
背景画像選択以下から選択可能
・ 既存のアイキャッチ画像を使用
・新しく画像をアップロード
・単色背景
(単色背景画像は 1200x630px 固定)
テキスト設定投稿タイトルが自動で入力され、以下のカスタマイズが可能
・タイトルではなく別のテキストに変更可能
・文字サイズ(10 〜 200px)
・文字色
・フォントスタイル(普通/太字/斜体/太字+斜体)
・影(有無、色、ぼかし)
・フォント(テーマのNotoSansJPを利用)
配置調整タイトルの位置を以下から選択可能
・横位置:%指定
・縦位置:%指定
背景オーバーレイ文字の背景に半透明の長方形を追加可能
・背景色
・透過度
リアルタイムプレビューCanvasで描画し、調整内容がすぐに画面に反映されるプレビュー付き
保存処理「保存」ボタンで画像を生成し、以下を自動で実行
・PNG形式でWordPressメディアライブラリに保存
・生成画像をその投稿のアイキャッチに設定
※ 保存後、投稿編集ページを更新するとことでアイキャッチ設定プリビューに表示されます
対応投稿タイプ全ての投稿タイプに対応
確認済み動作環境・ワードプレス Ver 6.8
・Cocoon Ver 2.8.5.3
・php Ver2.8.5.3php Ver 8.2.22
スポンサーリンク

インストールの方法

インストールについて今回は、function.phpとjavascript.jsに追記するのではなく、新たにファイルを作成し設置する方法で行っています。
function.phpのファイルが大きくなること、また整理しやすくするためにこのツール用のファイルを作成し、そこに設置することにしています。

手順としては
1.function.phpに作成したphpファイルを読み込むコードを追記
2.追加function用とjavascript.用のフォルダ作成を作成
3.ファイルのアップロード
となります。

function.phpの読みこみ等については下記Chuyaさんの記事を参考にしていますのでご確認ください。

1.function.phpに作成したphpファイルを読み込むコードを追記

Cocoonの子テーマにあるfunction.phpに下記コードを追記

/********************************
functionファイルの分割・読み込み
********************************/

// 子ディレクトリ取得
$dir = get_stylesheet_directory();

// functions下のファイル取得
$files = glob($dir . '/functions/*.php');

foreach($files as $file) {
  if (file_exists($file)) {
    include($file);
  }
}

追加function用とjavascript.用のフォルダを作成

Cocoonのcocoon-child-masterのフォルダに新規にfunctionとjsのフォルダを作成。

Eye_catch1

これで下準備は完了です。

ファイルのアップロード

下記コードを張り付けたphp、jsファイルを作成し、functionとjsフォルダにアップロードすれば完了です。

<?php  // eye_catch_with_title.php

<?php  // eye_catch_with_title.php

function add_custom_featured_image_metabox() {
     $post_types = get_post_types(['public' => true], 'names');
    foreach ($post_types as $post_type) {
        add_meta_box(
            'custom_featured_image',
            'カスタムアイキャッチ画像作成',
            'render_custom_featured_image_metabox',
            $post_type,
            'side'
        );
    }
}
add_action('add_meta_boxes', 'add_custom_featured_image_metabox');

function render_custom_featured_image_metabox($post) {
    $thumbnail_id = get_post_thumbnail_id($post->ID);
    $thumbnail_url = $thumbnail_id ? wp_get_attachment_url($thumbnail_id) : '';
    ?>

 <style>
        #custom-featured-image-tool {
            display: flex;
            flex-wrap: wrap;
            gap: 10px; /* パネル間のスペース */
        }

        .panel {
            flex: 1 1 calc(50% - 20px); /* 2列レイアウト */
            background: #f9f9f9; /* パネルの背景色 */
            border: 1px solid #ddd; /* 枠線 */
            padding: 15px; /* 内側の余白 */
            border-radius: 8px; /* 角を丸く */
            box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); /* 影 */
        }

        .panel p {
            margin: 0 0 10px; /* パラグラフの下の余白 */
        }

        button {
            width: 100%; /* ボタンをフル幅に */
        }
    </style>

    <div id="custom-featured-image-tool">
        <canvas id="preview-canvas" style="max-width:100%; border:1px solid #ccc;"></canvas>

        <!-- 背景モード選択 -->
       <div class="panel">
            <p><strong>背景の選択</strong></p>
            <label><input type="radio" name="bg-mode" value="upload" checked> 画像をアップロード</label><br>
            <label><input type="radio" name="bg-mode" value="color"> 背景色のみ</label>色指定:<input type="color" id="bg-color" value="#000000"><br>

            <div id="upload-wrapper" style="margin-top: 8px;">
                <div id="upload-wrapper" style="margin-top: 8px;">
    <label for="custom-bg-upload" class="upload-label">画像をアップロード</label>
    <input type="file" id="custom-bg-upload" accept="image/*" style="display: none;">
    <div id="file-name-display">ファイルが選択されていません</div>
</div>

<style>
  .upload-label {
    display: inline-block;
    padding: 0.5em 1em;
    background-color: #007cba;
    color: #fff;
    border-radius: 4px;
    cursor: pointer;
    margin-bottom: 0.1em;
  }

  .upload-label:hover {
    background-color: #006ba1;
  }

  #file-name-display {
    font-size: 12px;
    color: #333;
    margin-top: 0.1em;
  }
</style>

<script>
  document.addEventListener('DOMContentLoaded', function () {
    const fileInput = document.getElementById('custom-bg-upload');
    const fileDisplay = document.getElementById('file-name-display');

    fileInput.addEventListener('change', function () {
      if (fileInput.files.length > 0) {
        fileDisplay.textContent = fileInput.files[0].name;
      } else {
        fileDisplay.textContent = 'ファイルが選択されていません';
      }
    });
  });
</script>
<br>
                <img id="upload-preview" src="" style="max-width: 100%; margin-top: 8px; display:none;">
            </div>

            <label><input type="checkbox" id="overlay-enable" checked> タイトル背景色</label><br>
            オーバーレイの色: <input type="color" id="overlay-color" value="#000000"><br>
            透明度:<input type="range" id="overlay-opacity" min="0" max="1" step="0.05" value="0.4">
            </div>
      
        <!-- テキスト位置 -->
        <div class="panel">
<p><strong>テキスト位置</strong></p>
            横:<input type="number" id="pos-x" value="50" min="0" max="100" style="width: 30%;">%,
            縦:<input type="number" id="pos-y" value="50" min="0" max="100" style="width: 30%;">%
        </div>
<!-- 任意テキスト -->
<div class="panel">
    <p><strong>表示するテキスト</strong></p>
    <label><input type="checkbox" id="custom-text-enable"> タイトルの代わりに以下のテキストを使う</label><br>
    <textarea id="custom-text" style="width: 100%; height: 60px;" placeholder="ここに任意のテキストを入力..."></textarea>
</div>

        <!-- スタイル -->
    <div class="panel">
        <p><strong>スタイル</strong></p>
        フォントサイズ: <input type="number" id="font-size" value="24" min="10" max="200" style="width: 30%;"><br>
        <div style="display: flex; align-items: center; gap: 10px;">
  <label for="font-style">スタイル:</label>
  <select id="font-style" style="width: 40%;">
    <option value="normal">普通</option>
    <option value="bold">太字</option>
    <option value="italic">斜体</option>
    <option value="bold italic">太字+斜体</option>
  </select>
</div>
        </select>
            フォント種類:<br>
            <select id="font-family" style="width: 100%;">
                <option value="sans-serif">ゴシック体(sans-serif)</option>
                <option value="serif">明朝体(serif)</option>
                <option value="monospace">等幅(monospace)</option>
                <option value="cursive">手書き風(cursive)</option>
            </select>
    
        <!-- テキスト色 -->
             文字色: <input type="color" id="text-color" value="#ffffff">シャドウ: <input type="checkbox" id="text-shadow" checked>
       <!-- パディング -->
             背景余白<br>
            上下:<input type="number" id="padding-vertical" value="10" min="0" style="width: 30%;">
            左右:<input type="number" id="padding-horizontal" value="10" min="0" style="width: 30%;">
        </div>

        <div class="panel">
  <button type="button" id="generate-image" class="button button-primary" style="width: 100%;">画像を生成して保存</button>
  <div id="save-status" style="margin-top: 8px; font-weight: bold; color: #0073aa;"></div>
</div>

    <script>
        const postTitle = <?php echo json_encode(get_the_title($post)); ?>;
        const postID = <?php echo (int) $post->ID; ?>;
        const thumbnailURL = <?php echo json_encode($thumbnail_url); ?>;
    </script>
    <?php
    wp_enqueue_script('custom-featured-image-tool', get_stylesheet_directory_uri() . '/js/custom-featured-image-tool.js', [], null, true);
    wp_localize_script('custom-featured-image-tool', 'customImageAjax', [
        'ajax_url' => admin_url('admin-ajax.php'),
        'nonce'    => wp_create_nonce('custom_featured_image')
    ]);
}

add_action('wp_ajax_save_custom_featured_image', 'save_custom_featured_image');
function save_custom_featured_image() {
    if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'custom_featured_image')) {
        wp_send_json_error('認証に失敗しました');
    }

    $post_id = intval($_POST['post_id']);

    if (!isset($_FILES['image_file']) || $_FILES['image_file']['error'] !== UPLOAD_ERR_OK) {
        wp_send_json_error('画像ファイルのアップロードに失敗しました');
    }

    $file = $_FILES['image_file'];
    $upload_dir = wp_upload_dir();
    $filename = 'custom_featured_' . $post_id . '_' . time() . '.png';
    $filepath = $upload_dir['path'] . '/' . $filename;

    if (!move_uploaded_file($file['tmp_name'], $filepath)) {
        wp_send_json_error('画像の保存に失敗しました');
    }

    $filetype = wp_check_filetype($filename, null);
    $attachment = [
        'post_mime_type' => $filetype['type'],
        'post_title'     => sanitize_file_name($filename),
        'post_content'   => '',
        'post_status'    => 'inherit'
    ];

    $attach_id = wp_insert_attachment($attachment, $filepath, $post_id);
    require_once ABSPATH . 'wp-admin/includes/image.php';
    $attach_data = wp_generate_attachment_metadata($attach_id, $filepath);
    wp_update_attachment_metadata($attach_id, $attach_data);

    set_post_thumbnail($post_id, $attach_id);

    wp_send_json_success('保存成功');
}

今回はファイル名をeye_catch_with_title.phpにしてあります。
黄色ハイライトの部分は必要ないです。ファイル名を確認するために記述しているだけですので <?php だけ記述してあればいいです。

下記 JSファイルはファイル名 custom-featured-image-tool.jsにて作成しています。
jsファイル名、フォルダ名を変更する場合には上記 phpの赤色ハイライト部分のファイル名、フォルダ名を変更してください

下のjsコードで黄色ハイライトの部分は必要ないです。ファイル名を確認するために記述しているだけです

// public/js/custom-featured-image-tool.js

document.addEventListener('DOMContentLoaded', () => {
  const canvas = document.getElementById('preview-canvas');
  const ctx = canvas.getContext('2d');

  let customBGImage = null;
  let bgMode = 'upload';

  const uploadInput = document.getElementById('custom-bg-upload');
  const uploadPreview = document.getElementById('upload-preview');

  uploadInput.addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (file && file.type.startsWith('image/')) {
    const reader = new FileReader();
    reader.onload = function (evt) {
      const img = new Image();
      img.onload = function () {
        customBGImage = img;
        bgMode = 'upload'; // ← これを追加
        uploadPreview.src = evt.target.result;
        drawImage();
      };
      img.src = evt.target.result;
    };
    reader.readAsDataURL(file);
  }
});

document.getElementById('bg-color').addEventListener('input', () => {
  bgMode = 'color'; // ← 背景色変更時に切り替え
  drawImage();
});


  document.querySelectorAll('input[name="bg-mode"]').forEach(radio => {
    radio.addEventListener('change', (e) => {
      bgMode = e.target.value;
      document.getElementById('upload-wrapper').style.display = (bgMode === 'upload') ? 'block' : 'none';
      drawImage();
    });
  });

  function drawCanvasContents(img = null) {
    const fontSize = parseInt(document.getElementById('font-size').value, 10);
    const fontStyle = document.getElementById('font-style').value;
    const fontFamily = document.getElementById('font-family').value;
    const textColor = document.getElementById('text-color').value;
    const textShadow = document.getElementById('text-shadow').checked;
    const paddingV = parseInt(document.getElementById('padding-vertical').value, 10);
    const paddingH = parseInt(document.getElementById('padding-horizontal').value, 10);
    const posXPercent = parseInt(document.getElementById('pos-x').value, 10) / 100;
    const posYPercent = parseInt(document.getElementById('pos-y').value, 10) / 100;
    const overlay = document.getElementById('overlay-enable').checked;
    const overlayColor = document.getElementById('overlay-color').value || '#000000';
    const overlayOpacity = parseFloat(document.getElementById('overlay-opacity').value || '0.4');
const customTextEnabled = document.getElementById('custom-text-enable').checked;
const customText = document.getElementById('custom-text').value.trim();
const displayText = (customTextEnabled && customText) ? customText : postTitle;

    ctx.font = `${fontStyle} ${fontSize}px ${fontFamily}`;
    ctx.textBaseline = 'top';

    const words = displayText.split('');
    const lines = [];
    let line = '';
    for (let i = 0; i < words.length; i++) {
      const testLine = line + words[i];
      if (ctx.measureText(testLine).width > canvas.width * 0.8 && i > 0) {
        lines.push(line);
        line = words[i];
      } else {
        line = testLine;
      }
    }
    lines.push(line);

    const lineHeight = fontSize * 1.4;
    const totalHeight = lines.length * lineHeight;
    const x = canvas.width * posXPercent;
    const y = canvas.height * posYPercent - totalHeight / 2;

    lines.forEach((line, i) => {
      const textWidth = ctx.measureText(line).width;
      const tx = x - textWidth / 2;
      const ty = y + i * lineHeight;

      if (overlay) {
        const radius = 12;
        const rectX = tx - paddingH;
        const rectY = ty - paddingV / 2;
        const rectWidth = textWidth + paddingH * 2;
        const rectHeight = fontSize + paddingV;

        ctx.save();
        ctx.fillStyle = hexToRGBA(overlayColor, overlayOpacity);
        roundRect(ctx, rectX, rectY, rectWidth, rectHeight, radius);
        ctx.fill();
        ctx.restore();
      }

      if (textShadow) {
        ctx.shadowColor = 'rgba(0,0,0,0.6)';
        ctx.shadowOffsetX = 2;
        ctx.shadowOffsetY = 2;
        ctx.shadowBlur = 4;
      } else {
        ctx.shadowColor = 'transparent';
      }

      ctx.fillStyle = textColor;
      ctx.fillText(line, tx, ty);
    });
  }

  function drawImage() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  if (bgMode === 'upload' && customBGImage) {
    canvas.width = customBGImage.width;
    canvas.height = customBGImage.height;
    ctx.drawImage(customBGImage, 0, 0);
    drawCanvasContents();
  } else if (bgMode === 'color') {
    canvas.width = 1200;
    canvas.height = 630;
    const bgColor = document.getElementById('bg-color').value;
    ctx.fillStyle = bgColor;
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    drawCanvasContents();
  } else if (thumbnailURL) {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);
      drawCanvasContents();
    };
    img.src = thumbnailURL;
  } else {
    // 何も設定されていないときのデフォルト背景色
    canvas.width = 1200;
    canvas.height = 630;
    ctx.fillStyle = "#cccccc";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    drawCanvasContents();
  }
}

  document.querySelectorAll('#custom-featured-image-tool input, #custom-featured-image-tool select, #custom-text')
  .forEach(input => input.addEventListener('input', drawImage));


 document.getElementById('generate-image').addEventListener('click', () => {
  const saveButton = document.getElementById('generate-image');
  const statusEl = document.getElementById('save-status');

  // ボタンを無効化
  saveButton.disabled = true;
  saveButton.textContent = '保存中...';
  statusEl.textContent = '保存中...';
  statusEl.style.color = 'blue';

  canvas.toBlob(blob => {
    const formData = new FormData();
    formData.append('action', 'save_custom_featured_image');
    formData.append('post_id', postID);
    formData.append('nonce', customImageAjax.nonce);
    formData.append('image_file', blob, 'featured-image.png');

    fetch(customImageAjax.ajax_url, {
      method: 'POST',
      body: formData
    })
    .then(response => response.json())
    .then(data => {
      if (data.success) {
        statusEl.textContent = '保存とアイキャッチ画像の設定が出来ました!';
        statusEl.style.color = 'green';
      } else {
        statusEl.textContent = '保存失敗: ' + (data.data || '原因不明');
        statusEl.style.color = 'red';
      }
    })
    .catch(error => {
      statusEl.textContent = '通信エラー: ' + error.message;
      statusEl.style.color = 'red';
    })
    .finally(() => {
      // ボタンを再び有効化
      saveButton.disabled = false;
      saveButton.textContent = '画像を保存';
    });
  }, 'image/png');
});



  function hexToRGBA(hex, alpha) {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    return `rgba(${r},${g},${b},${alpha})`;
  }

  function roundRect(ctx, x, y, width, height, radius) {
    ctx.beginPath();
    ctx.moveTo(x + radius, y);
    ctx.arcTo(x + width, y, x + width, y + height, radius);
    ctx.arcTo(x + width, y + height, x, y + height, radius);
    ctx.arcTo(x, y + height, x, y, radius);
    ctx.arcTo(x, y, x + width, y, radius);
    ctx.closePath();
  }

  drawImage();
});

※ 文字化けが起きたら、ファイルを保存するときの文字コードを確認してみてください。(通常はUTF-8)

スポンサーリンク

使い方

投稿画面の右の下の方に表示される

Eye_catch1s

プリビュー画面が小さくて見にくい場合は、青まるで囲んだ Λ をクリックして上に移動していくと投稿編集画面の下に大きく表示されます。

Eye_catch2

各メニューについて

プリビュー画像の下にある各メニューについてです。
メニューは大きく分けて5つのセクションからなり、それぞれの機能を説明していきたいと思います。

Eye_catch_menu

背景の選択

項目補足説明
画像をアップロード初期設定で選択済みアイキャッチ画像を表示
背景色のみ色指定選択した背景色がプレビューに表示される
背景画像は1200 X 630pxで固定
選択した場合、”画像をアップロード”ボタンは非表示となる
画像をアップロードアップロードした画像は即時プレビューに表示される
”画像を生成して保存”を行わないとアップロードされた画像はメディア ライブラリーに保存されない
アップロードた場合、使用中のファアイル名がボタン下に表示される
タイトル背景色タイトルに背景色の設定を有効・無効の選択
オーバーレイの色タイトルの背景色の選択
透明度タイトル背景色の透明度の調整

テキスト位置

項目補足説明
テキスト位置テキストの位置調整
縦・横ともに0 〜 100%で調整可能
初期値は50%(中央)
※ 表示されたテキストの中心を基準にしているためプリビューを確認しながらの調整が必要

表示するテキスト

項目補足説明
表示するテキストタイトルの代わりに別の内容のテキストを使用したい場合に選択、入力した内容がプリビューに表示される

スタイル

項目補足説明
フォントサイズフォントサイズは10 〜 200pxで選択可能
※ 画像サイズによってプレビューでの見え方が変わります
プリビューに表示される画像の縮小率に合わせてテキストの表示も縮小表示されているため
スタイル4つの文字スタイルから選択可能
・普通
・太字
・斜体
・太字+斜体
フォント種類4つのフォントから選択可能
・ゴシック体(sans-serif)
・明朝体(serif)
・等幅(monospace)
・手書き風(cursive)
文字色文字の色の選択
シャドウ文字にシャドウを有無の選択
※ プレビューで確認しながらオン/オフを選択した方が良い
背景余白テキスト背景色の上下・左右の余白を設定
※ 背景の選択でタイトル背景色を選択していない場合は無効となります

⑤画像を生成して保存について

アイキャッチ画像の編集後、”画像を生成して保存”を行うとメディア ライブラリに画像が新しく保存されます。
アイキャッチの元の画像には上書きされず新しい画像として保存されます。
保存が問題なく行われると”保存とアイキャッチ画像の設定が出来ました!”のメッセージが表示されます。
また、保存がされた時点でアイキャッチ画像に設定がされるようになっていますが、右上のアイキャッチ画像のプリビュー表示は投稿画面のページを更新するまでは更新表示されまさせん。またページを更新する際には、記事内容が失われないように下書き保存(保存)を行ってからページ更新を行なって下さい。
※ 画像のサイズが大きい場合には、保存設定に時間がかかる場合があります。

スポンサーリンク

アイキャッチがうまく保存されない?

画像が大きすぎる場合には、ModSecurityに関連したWAF セキュリティー設定によっては、うまく保存ができない場合があるようです。

スポンサーリンク

さいごに

ワードプレステーマ Cocoonのでしか動作確認はしていません。
他のテーマに実装したい場合は、テーマに合わせ一部のコードを変更すれば動くと思います。
また、使用については自己責任となりますので、バックアップ等必要な準備をすることをお勧めします。
もしできればコメント等もいただければ幸いです。

コメント

タイトルとURLをコピーしました