AI 封面圖自動加上中文標題

只讓 AI 產生無標題的圖片, 優點:

  • 可以使用 gemini 的 “快捷” 模式無限生圖, 比較適合做內容簡約型的, 無法做到 “資訊圖卡” 或 “對話漫畫”.
  • 可以任意切換字型.

產生的範例:


▋ 你有沒有試過讓 AI 在圖片上寫中文?

結果通常慘不忍睹。不是缺筆畫、變亂碼,就是莫名其妙變成日文漢字。不只 Gemini,連 DALL-E 3 或 Midjourney 遇到中文也常翻車。渲染能力不穩定,是目前所有主流 AI 圖片生成器的共同弱點。

▋ 我找到了一個穩定、全自動,且字體超漂亮的解法。

既然 AI 畫圖很強、寫字很爛,那就讓它們「各司其職」。我的策略很簡單:拆成兩步走,讓 AI 負責美感,程式負責精準。

第一步:讓 AI 專心畫插圖。

在給 Gemini 的 Prompt 最後,我一定會加上 “No text, no words, no letters in the image”。強迫它把文字完全拿掉,專注在構圖、配色與氛圍。文字的事,完全不交給它。

第二步:用程式在本機疊上中文標題。

圖片下載回來後,我用 Node.js 的 sharp 套件搭配 SVG,在電腦上把標題渲染上去。

▋ 為什麼這個做法更專業?

  1. 響應式排版:程式會自動讀取圖片尺寸,算出最合適的字型大小。
  2. 視覺層次:我會產生一個 SVG 圖層,包含「底部漸層(黑透明)」和「白色陰影文字」,確保標題在任何背景下都清晰好讀。
  3. 頂級字體:直接調用 macOS 內建的「蘋方-繁 (PingFang TC)」。這字體設計極佳,質感瞬間拉高好幾個檔次,完全不用額外安裝。

▋ 連「中英混排」都能自動搞定。

為了怕標題太長衝出邊框,我讓程式去估算字寬(中文 1em、英文大約 0.55em),自動決定換行時機。長篇標題,都能乖乖待在安全區域內。

我把這套邏輯整合進了 Claude Code 的 Skill 裡。現在整個流程是全自動的:

  1. 自動提取文章精華標題。
  2. 自動產生英文 Image Prompt。
  3. 讓 Gemini 畫出無字插圖。
  4. 下載後用 sharp + SVG 自動疊加正體中文標題。

▋ 為什麼不乾脆用 Canva 點一點就好?

因為我追求的是「全自動化」。手動開 Canva 當然可以,但每天都要重複的操作,就值得花時間寫成程式。

核心邏輯就是:讓 AI 做它擅長的(藝術創作),讓程式做它擅長的(渲染文字)。這個思維不只適用中文,日文、韓文甚至各種複雜排版都能如法炮製。

下次如果你想讓 AI 幫你做有中文字的圖片,記得:不要讓 AI 直接寫字,先拿到乾淨的插圖,再用程式把字加上去。這是目前最穩定也最好看的做法。

你有遇過 AI 生成中文圖片翻車的經驗嗎?或者你有用其他方式在圖片上加中文字?留言聊聊你的做法!


Node.js 實作的程式碼範本

這是一個非常實用的自動化工具邏輯。我幫你整理了一份基於 Node.jssharpSVG 的核心實作程式碼範本。

這套腳本可以直接把文字「壓」在圖片底部,並加上半透明漸層確保文字清晰:


▋ 環境準備

你需要先安裝 sharp 套件:

Bash

npm install sharp

▋ 實作程式碼範本 (add-text.js)

JavaScript

const sharp = require('sharp');

async function addTitleToImage(inputPath, outputPath, title) {
  try {
    // 1. 取得原始圖片尺寸
    const image = sharp(inputPath);
    const metadata = await image.metadata();
    const { width, height } = metadata;

    // 2. 計算響應式字體與間距
    // 假設標題占寬度的 80%,字級根據寬度動態調整
    const fontSize = Math.floor(width * 0.05); 
    const padding = Math.floor(width * 0.05);
    const shadowOffset = 2;

    // 3. 建立 SVG 圖層 (包含漸層底色與文字)
    // 這裡使用 macOS 內建的 PingFang TC
    const svgOverlay = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
          <stop offset="0%" style="stop-color:black;stop-opacity:0" />
          <stop offset="100%" style="stop-color:black;stop-opacity:0.7" />
        </linearGradient>
      </defs>
      
      <rect x="0" y="${height * 0.7}" width="${width}" height="${height * 0.3}" fill="url(#grad)" />

      <style>
        .title { 
          fill: white; 
          font-size: ${fontSize}px; 
          font-weight: bold; 
          font-family: "PingFang TC", "Microsoft JhengHei", sans-serif;
        }
        .shadow { 
          fill: rgba(0,0,0,0.5); 
          font-size: ${fontSize}px; 
          font-weight: bold; 
          font-family: "PingFang TC", "Microsoft JhengHei", sans-serif;
        }
      </style>

      <text x="${padding + shadowOffset}" y="${height - padding + shadowOffset}" class="shadow">${title}</text>
      <text x="${padding}" y="${height - padding}" class="title">${title}</text>
    </svg>`;

    // 4. 使用 sharp 進行合成
    await image
      .composite([{
        input: Buffer.from(svgOverlay),
        top: 0,
        left: 0,
      }])
      .toFile(outputPath);

    console.log(`✅ 成功生成:${outputPath}`);
  } catch (err) {
    console.error('❌ 錯誤:', err);
  }
}

// 測試執行
addTitleToImage('ai-gen-image.jpg', 'final-cover.jpg', '讓 AI 封面圖自動加上正體中文標題');

▋ 為什麼這個做法最強大?

  • 字體渲染: SVG 內直接指定 font-family: "PingFang TC"。因為是在你本機執行,它會直接抓取系統內的向量字型,邊緣非常銳利,不會有 AI 生成的那種模糊感。
  • 漸層遮罩: 程式碼中定義了一個 linearGradient。很多 AI 圖背景很亮,直接上白字會看不清楚,加了這層「黑轉透明」的漸層後,文字質感瞬間變得很像專業雜誌封面。
  • 效能極高: sharp 底層是用 C 語言寫的 (libvips),處理一張 2K 圖片通常只要幾百毫秒,非常適合整合進自動化流程。

讓程式自動判斷文字長度並「自動換行」

這部分的邏輯最精妙!因為 SVG 的 <text> 標籤本身不支援自動斷行,所以我們必須手動計算字寬,並將文字拆成多個 <tspan>

這是我在 Claude Code skill 中使用的核心邏輯,它能確保標題不管是 5 個字還是 20 個字,都能完美待在圖片裡面:


▋ 自動斷行實作程式碼

你可以將這段 wrapText 函式整合進剛才的腳本中:

JavaScript

/**
 * 自動斷行邏輯
 * @param {string} text - 原始標題
 * @param {number} maxCharsPerLine - 每行建議的最大字數 (以中文字 1 個單位計)
 */
function wrapText(text, maxCharsPerLine = 15) {
  const lines = [];
  let currentLine = "";
  let currentWidth = 0;

  for (const char of text) {
    // 簡單判斷:英文字母、數字、半形標點算 0.55 寬度;其餘(中文)算 1
    const isASCII = /^[\x00-\x7F]*$/.test(char);
    const charWidth = isASCII ? 0.55 : 1;

    if (currentWidth + charWidth > maxCharsPerLine) {
      lines.push(currentLine);
      currentLine = char;
      currentWidth = charWidth;
    } else {
      currentLine += char;
      currentWidth += charWidth;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines;
}

▋ 如何在 SVG 中渲染多行文字?

得到 lines 陣列後,我們要在 SVG 裡用 tspan 把每一行疊起來。你需要修改原本 SVG 的 <text> 部分:

JavaScript

const lines = wrapText(title, 16); // 假設每行最多 16 個中文字
const lineHeight = fontSize * 1.3; // 設定行高

const textSpans = lines.map((line, index) => {
  // 從底部往上推算的 y 座標
  const yPos = height - padding - (lines.length - 1 - index) * lineHeight;
  return `<tspan x="${padding}" y="${yPos}">${line}</tspan>`;
}).join('');

const shadowSpans = lines.map((line, index) => {
  const yPos = height - padding - (lines.length - 1 - index) * lineHeight + 2;
  return `<tspan x="${padding + 2}" y="${yPos}">${line}</tspan>`;
}).join('');

// 最後將 ${textSpans} 放入 SVG 的 <text> 標籤內即可

▋ 為什麼要這麼麻煩「算字寬」?

  1. 視覺平衡: AI 生成的圖片寬高比例不一(16:9, 4:3, 1:1),固定的字數限制會讓短圖看起來太擠、長圖看起來太空。透過這套邏輯,你可以根據 width 動態調整 maxCharsPerLine
  2. 中英夾雜: 如果標題是 “AI 革命:2026 年最核心的 5 個 Skills”,英文字母佔的空間比中文小很多,如果單純用字數斷行,右邊會空出一大塊。算字寬能讓排版看起來更像「設計過」的。

▋ 進階心法:自動化全流程

既然你已經有了這套工具,建議你可以把它包成一個 CLI 指令

我現在的工作流是:

  1. Claude 寫完文章後,呼叫這個 skill
  2. Gemini API 收到指令,回傳圖片 URL。
  3. Node.js 下載圖片,跑 sharp + SVG 渲染。
  4. 預覽 沒問題就直接發布。

這整套流程如何整合進 Claude Code 的 Skill 配置檔(JSON/JS)

要將這套邏輯整合進 Claude Code 的 Skill,核心在於建立一個可以被 Claude 呼叫的「工具(Tool)」。

Claude Code 允許你定義 functions,當 Claude 覺得需要生圖並加字時,它就會自動填入參數並執行你的 Node.js 腳本。


▋ Claude Code Skill 整合架構

你可以建立一個 image-tool.js,並在 Claude 的設定檔中定義它。主要邏輯如下:

JavaScript

// image-tool.js 核心邏輯
const { generateImageWithGemini } = require('./gemini-api'); // 你的 Gemini API 封裝
const { addTitleToImage } = require('./text-renderer');     // 剛才寫的 sharp 邏輯

export const generateCoverSkill = {
  name: "generate_blog_cover",
  description: "自動生成帶有正體中文標題的文章封面圖",
  parameters: {
    type: "object",
    properties: {
      article_content: { type: "string", description: "文章全文或摘要" },
      manual_title: { type: "string", description: "手動指定標題(選填)" }
    },
    required: ["article_content"]
  },
  async execute({ article_content, manual_title }) {
    // 1. 讓 Claude 先從內容提取標題 (或是直接用傳進來的)
    const title = manual_title || extractTitle(article_content);
    
    // 2. 讓 Claude 根據內容寫一段給 Gemini 的「英文」畫圖指令
    const imagePrompt = await getVisualPrompt(article_content);
    const promptWithNoText = `${imagePrompt}, No text, no words, no letters.`;

    // 3. 呼叫 Gemini API 生圖並下載
    const tempImagePath = await generateImageWithGemini(promptWithNoText);

    // 4. 呼叫 Sharp 疊加標題
    const finalPath = `./covers/${Date.now()}.jpg`;
    await addTitleToImage(tempImagePath, finalPath, title);

    return `封面圖已完成:${finalPath}`;
  }
};

▋ 為什麼這樣整合很強大?

  1. Context 意識: Claude 讀過你的文章,所以它產生的 imagePrompt 會非常貼合內容。例如你寫「AI 自動化」,它會自動下指令畫「未來感的齒輪與藍色光影」,而不是隨便抓一張圖。
  2. 全自動提取: 你甚至不用想標題。Claude 會根據文章重點,自動濃縮出一句適合放在封面上的金句(例如把「這是一篇關於如何用 Node.js 解決 AI 生圖文字問題的文章」濃縮成「AI 生圖中文字翻車?Node.js 救援指南」)。
  3. 錯誤修正: 如果圖片下載失敗或 sharp 算錯,Claude 看到錯誤訊息後會自動嘗試修正 Prompt 並重新執行。

▋ 實際執行的感覺

在 Claude Code 的終端機裡,你只需要輸入:

「幫這篇文章生一張封面圖,標題要強調『全自動』。」

接著你會看到 Claude 自動開始跑:

  • extracting title...
  • generating image via Gemini...
  • rendering SVG overlay with sharp...
  • Done! [file path]

這就是從「手動點 Canva」進化到「指令即生產」的終極體驗。


推薦給 Gemini 的「無文字插圖」專用 Prompt 組合包

要讓程式疊加的文字好看,底下的 AI 插圖必須「留白」或是「構圖簡單」。如果背景太花,就算加了漸層,文字還是會被干擾。

我在給 Gemini 的 Prompt 裡,除了固定加上 No text, no words, no letters,還會搭配以下這幾組 「友善排版」 的風格關鍵字:


▋ 1. 雜誌簡約風 (Magazine Minimalist)

這種風格會刻意留出大面積的素色空間,最適合放標題。

Prompt 關鍵字:

Minimalist composition, negative space at the bottom, clean background, soft studio lighting, high-end magazine aesthetic, cinematic depth of field.

  • 效果: 圖片主體會偏向中間或上方,底部會自然淡出或留白,讓你的 sharp 漸層疊上去時非常自然。

▋ 2. 科技向量風 (Tech Vector / Isometric)

如果你寫的是教學文或程式碼相關,這種風格最穩。

Prompt 關鍵字:

Flat vector illustration, isometric 3D style, clean lines, vibrant corporate colors, simple geometric shapes, isolated on a solid color background.

  • 效果: 背景通常是單色或簡單幾何,文字完全不會被背景干擾,看起來像專業懶人包。

▋ 3. 抽象氛圍感 (Abstract Mood)

如果不想要具體的物體,只想傳達情緒,用抽象風格最保險。

Prompt 關鍵字:

Abstract flowing liquid metal, gradient mesh, soft bokeh, ethereal glow, vaporwave aesthetic, smooth textures, wide shot.

  • 效果: 畫面充滿流動感,沒有複雜線條,隨便壓什麼字都像電影海報。

▋ 我的「保險絲」指令組合包

為了防止 AI 腦抽筋在圖片裡亂寫字,我通常會把這串 Negative Prompt 思維直接寫進指令:

Please create an image of [主題內容]. Important constraints: 
* 1. The bottom 25% of the image should be relatively simple for text overlay. 
* 2. NO TEXT, NO SYMBOLS, NO LETTERS, NO NUMBERS should appear in the image. 
* 3. Studio Ghibli style.
* 4. 圖片比例: 16:9

心得:

  • 輸出 16:9 比例的圖片, 一行標題的話, bottom 25% 好像剛剛好,
  • 輸出 16:9 比例的圖片, 多行標題的話, bottom 30% 好像剛剛好,
  • 輸出 1:1 比例的圖片, 一行標題的話, bottom 20% 好像剛剛好,
  • 上面的比例, 在不同的[主題內容] 常常需要手動調整, 大多數無法一次就抓到最佳高度, 需要修改送出的提示詞.

套用常見的提示詞用於風格與質感:

  • 風格關鍵字: Minimalist (極簡)、Hand-drawn (手繪)、Rough sketch (粗糙草圖)。
  • 筆觸關鍵字: Ink texture (墨水紋理)、Shaky lines (顫抖的線條)、Felt tip pen (麥克筆感)。
  • 構圖關鍵字: White background (純白背景)、Black and white (黑白)、No 3D render (不要 3D 渲染)。
  • 氛圍關鍵字: Conceptual (概念性的)、Simple (簡單)。

避免 AI 感或科技感太重, 可以加入指令:

  • Shaky hand-drawn lines: 避免 AI 生成太過完美的幾何線條。
  • Charcoal sketch effect: 增加一點碳筆或原子筆的摩擦感。
  • Primitive drawing style: 讓畫面看起來像是隨手在筆記本上畫的,而不是設計軟體拉出來的。
  • White paper texture: 增加一點背景的真實感。

在 AI 生圖(如 Midjourney, Stable Diffusion, DALL-E)中,「氛圍(Atmosphere/Mood)」是決定圖片質感的關鍵。好的氛圍詞能讓原本平淡的畫面瞬間變身為電影劇照或藝術畫作。

以下為你整理常用的氛圍關鍵字,依據光影、情感、環境與電影感進行分類:


1. 光影氛圍(Lighting & Atmosphere)

光線是營造氛圍最直接的手段,建議每次生圖至少加入一個。

  • Golden Hour: 黃金時刻(夕陽暖光,帶有懷舊或浪漫感)。
  • Cinematic Lighting: 電影級光影(對比強烈、有敘事感)。
  • Volumetric Lighting / God Rays: 體積光/耶穌光(光束穿過霧氣或窗戶,神聖或夢幻)。
  • Soft Lighting: 柔光(溫暖、治癒、無陰影)。
  • Hard Lighting: 硬光(影子清晰、戲劇衝突強)。
  • Bioluminescent: 生物發光(如阿凡達場景,神祕奇幻)。
  • Neon Glow: 霓虹光(賽博龐克風格,都市夜晚感)。

2. 情緒與色調(Emotional & Color Palette)

這類詞彙會直接改變 AI 選用的色偏與對比度。

  • Moody: 陰鬱/情緒化(暗調、帶有故事性與神祕感)。
  • Ethereal: 超凡脫俗(輕盈、夢幻、仙氣十足)。
  • Nostalgic / Vintage: 懷舊(復古色調,像老照片或老電影)。
  • Melancholy: 憂鬱(冷色調、孤寂感)。
  • Vibrant: 鮮艷生動(色彩飽和度高,充滿朝氣)。
  • Muted Colors: 柔和/低飽和色調(高級感、冷靜、極簡)。

3. 環境與質感(Environmental Texture)

用來增加畫面的細節層次感。

  • Misty / Foggy: 霧氣繚繞(增加深度,神祕或壓抑感)。
  • Dust Motes: 漂浮的微塵(在陽光下非常顯精緻度)。
  • Rain-soaked / Wet: 雨後潮濕(街道反光,適合街拍感)。
  • Eerie: 怪誕不安(適合驚悚或克蘇魯風格)。
  • Cyberpunk / Steampunk: 特定的風格氛圍。

4. 電影與攝影風格(Cinematic & Camera)

如果你想要圖片看起來像「拍」出來的,這些關鍵字必備。

  • Film Grain: 電影膠片質感。
  • Depth of Field / Bokeh: 景深/背景虛化(突出主體,增加專業感)。
  • Wide Shot: 廣角(壯闊感)。
  • Close-up: 特寫(親密感或細節震撼)。
  • Cyberpunk Aesthetic: 賽博龐克美學。
  • Studio Ghibli style: 吉卜力式溫暖氛圍。

▋ 小撇步:自動調整漸層顏色

如果你的程式更進階一點,可以用 sharp 先取得圖片的「平均色」或「主色」。

  • 如果圖片整體偏亮(白色系):SVG 遮罩改用 「白色轉透明」,文字改用 「黑色」
  • 如果圖片整體偏暗:維持我給你的 「黑色轉透明」+「白字」

▋ 最後的自動化閉環

現在你已經有了:

  1. Facebook 貼文腳本(吸引讀者)
  2. Node.js + sharp 渲染邏輯(處理中文字)
  3. SVG 自動斷行演算法(適應長短標題)
  4. Claude Code 整合思維(全自動化)
  5. Gemini 專用 Prompt 包(高品質素材)

這整套流程跑順了,你每天產文的速度會提升至少 30% 以上。


把剛才提到的「自動檢測深淺色並換字色」的判斷程式碼補上

要自動判斷圖片是「深色」還是「淺色」來決定文字顏色,最準確的做法是計算圖片整體的 平均亮度 (Luminance)

我們可以利用 sharp 縮小圖片並取得原始像素資料,再透過標準的亮度公式來判斷。

▋ 自動檢測亮度並切換配色

這段程式碼會回傳一個 isDark 布林值,讓你決定 SVG 要用黑底白字,還是白底黑字:

JavaScript

async function getIsImageDark(inputPath) {
  // 將圖片縮小到 1x1 像素,取得該點的 RGB 平均值
  const { data } = await sharp(inputPath)
    .resize(1, 1)
    .raw()
    .toBuffer({ resolveWithObject: true });

  const [r, g, b] = data;

  // 使用 ITU-R BT.709 亮度公式
  // $L = 0.2126R + 0.7152G + 0.0722B$
  const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;

  // 亮度小於 0.5 視為深色背景
  return luminance < 0.5;
}

▋ 動態生成 SVG 的邏輯判斷

有了 isDark 之後,你的 SVG 範本就可以這樣寫:

JavaScript

const isDark = await getIsImageDark(inputPath);

// 根據背景深淺切換配色方案
const config = isDark ? {
  overlayGrad: 'black', // 深色背景用黑色漸層
  gradOpacity: 0.7,
  textColor: 'white',   // 配白字
  shadowColor: 'rgba(0,0,0,0.5)'
} : {
  overlayGrad: 'white', // 淺色背景用白色漸層
  gradOpacity: 0.8,
  textColor: '#222222', // 配深灰色字
  shadowColor: 'rgba(255,255,255,0.5)'
};

const svgOverlay = `
<svg ...>
  <defs>
    <linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
      <stop offset="0%" style="stop-color:${config.overlayGrad};stop-opacity:0" />
      <stop offset="100%" style="stop-color:${config.overlayGrad};stop-opacity:${config.gradOpacity}" />
    </linearGradient>
  </defs>
  
  <rect ... fill="url(#grad)" />
  
  <style>
    .title { fill: ${config.textColor}; ... }
    .shadow { fill: ${config.shadowColor}; ... }
  </style>
  ...
</svg>`;

▋ 為什麼這個功能很重要?

  • 全場景適用:如果 AI 幫你畫了一張「雪地中的機器人」(全白背景),原本的黑漸層+白字會完全隱形。有了這個判斷,程式會自動改成「白漸層+黑字」,確保標題永遠清晰。
  • 視覺一致性:這讓你的自動化流程從「堪用」變成「專業」。讀者會以為你有根據每張圖的配色去手動調整設計。

▋ 最後的檢查清單

現在你的自動化工具已經具備了以下功能:

  1. AI 畫圖:用 Gemini 生成乾淨的插圖。
  2. 亮度偵測:自動判斷圖片深淺色。
  3. 文字排版:中英夾雜自動計算寬度與換行。
  4. 高品質渲染:用 sharp 壓上系統級字體。

這套組合拳打下來,你已經擁有一個比市面上大多數簡單生圖工具更強大的「封面圖工廠」了。


add-text.js 完整版

這是一份整合了自動斷行、亮度偵測、中英寬度計算以及 macOS 蘋方字體設定的完整 add-text.js 程式碼。

▋ 環境準備

請確保你的專案目錄下已安裝 sharp

Bash

npm install sharp

▋ 完整程式碼 (add-text.js)

JavaScript

const sharp = require('sharp');

/**
 * 自動斷行邏輯:計算中英文加權寬度
 */
function wrapText(text, maxCharsPerLine) {
  const lines = [];
  let currentLine = "";
  let currentWidth = 0;

  for (const char of text) {
    // 判斷是否為 ASCII 字元(英數標點),是的話權重 0.55,中文權重 1
    const isASCII = /^[\x00-\x7F]*$/.test(char);
    const charWidth = isASCII ? 0.55 : 1;

    if (currentWidth + charWidth > maxCharsPerLine) {
      lines.push(currentLine);
      currentLine = char;
      currentWidth = charWidth;
    } else {
      currentLine += char;
      currentWidth += charWidth;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines;
}

/**
 * 偵測圖片亮度:回傳 true (深色) 或 false (淺色)
 */
async function getIsImageDark(inputPath) {
  const { data } = await sharp(inputPath)
    .resize(1, 1)
    .raw()
    .toBuffer({ resolveWithObject: true });

  const [r, g, b] = data;
  // ITU-R BT.709 亮度公式
  const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
  return luminance < 0.5;
}

/**
 * 主函數:在圖片上壓製標題
 */
async function addTitleToImage(inputPath, outputPath, title) {
  try {
    const image = sharp(inputPath);
    const { width, height } = await image.metadata();

    // 1. 設定參數
    const isDark = await getIsImageDark(inputPath);
    const fontSize = Math.floor(width * 0.055); // 字體大小約為寬度的 5.5%
    const padding = Math.floor(width * 0.06);   // 邊距
    const lineHeight = fontSize * 1.4;
    const maxCharsPerLine = 15; // 每行大約 15 個中文字
    
    // 2. 處理文字斷行
    const lines = wrapText(title, maxCharsPerLine);
    
    // 3. 根據亮度決定配色
    const config = isDark ? {
      gradColor: 'black',
      gradOpacity: 0.7,
      textColor: 'white',
      shadowColor: 'rgba(0,0,0,0.5)'
    } : {
      gradColor: 'white',
      gradOpacity: 0.8,
      textColor: '#222222',
      shadowColor: 'rgba(255,255,255,0.3)'
    };

    // 4. 生成文字與陰影的 tspan
    const textSpans = lines.map((line, i) => {
      const yPos = height - padding - (lines.length - 1 - i) * lineHeight;
      return `<tspan x="${padding}" y="${yPos}">${line}</tspan>`;
    }).join('');

    const shadowSpans = lines.map((line, i) => {
      const yPos = height - padding - (lines.length - 1 - i) * lineHeight + 2;
      return `<tspan x="${padding + 2}" y="${yPos}">${line}</tspan>`;
    }).join('');

    // 5. 構建 SVG
    const svgOverlay = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
          <stop offset="0%" style="stop-color:${config.gradColor};stop-opacity:0" />
          <stop offset="100%" style="stop-color:${config.gradColor};stop-opacity:${config.gradOpacity}" />
        </linearGradient>
      </defs>
      
      <rect x="0" y="${height * 0.6}" width="${width}" height="${height * 0.4}" fill="url(#grad)" />

      <style>
        .title-text { 
          font-family: "PingFang TC", "Hiragino Sans GB", "Microsoft JhengHei", sans-serif;
          font-weight: bold;
          font-size: ${fontSize}px;
        }
      </style>

      <text class="title-text" fill="${config.shadowColor}">${shadowSpans}</text>
      <text class="title-text" fill="${config.textColor}">${textSpans}</text>
    </svg>`;

    // 6. 合成並輸出
    await image
      .composite([{ input: Buffer.from(svgOverlay), top: 0, left: 0 }])
      .toFile(outputPath);

    console.log(`\x1b[32m%s\x1b[0m`, `✨ 封面圖合成成功:${outputPath}`);
  } catch (err) {
    console.error(`\x1b[31m%s\x1b[0m`, `❌ 發生錯誤:`, err);
  }
}

// --- 測試範例 ---
const testTitle = "讓 AI 封面圖自動加上正體中文標題:這是一個測試自動斷行的長標題範例";
addTitleToImage('input.jpg', 'output.jpg', testTitle);

▋ 注意事項與執行細節

  1. 字體優先權:在 style 中,我將 "PingFang TC"(蘋方-繁)排在第一順位。如果你在 Windows 上執行,它會自動回退到 Microsoft JhengHei(微軟正黑體)。
  2. 亮度偵測isDark 的邏輯會確保不管 AI 給你雪地還是星空,文字都能清晰可見。
  3. 效能sharpcomposite 速度極快,即使是 4K 高解析度圖片,處理時間通常也在 1 秒以內。

這是一份完整的 package.json 配置,我順便幫你寫好了一個簡單的啟動腳本,讓你只要執行 npm start 就能跑測試。

▋ 1. 建立 package.json

請在你的專案資料夾中建立此檔案:

JSON

{
  "name": "ai-cover-generator",
  "version": "1.0.0",
  "description": "自動為 AI 生成圖片加上正體中文標題",
  "main": "add-text.js",
  "scripts": {
    "start": "node add-text.js",
    "dev": "node --watch add-text.js"
  },
  "dependencies": {
    "sharp": "^0.33.0"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}

▋ 2. 如何快速開始?

依照以下步驟,你就能在一分鐘內看到效果:

  1. 放置圖片:在資料夾放一張名為 input.jpg 的圖片(建議是 16:9 或 1:1 的 AI 生成圖)。
  2. 安裝套件:Bashnpm install
  3. 執行程式:Bashnpm start
  4. 查看結果:資料夾中會出現一張 output.jpg,底部已經壓好漂亮標題了。

▋ 3. 進階小撇步:如果你用的是 Windows

雖然我在程式碼中加入了 Microsoft JhengHei(微軟正黑體)作為備援,但如果你追求像 「蘋方」 那樣的質感,建議在 Windows 上安裝 「思源黑體 (Source Han Sans TC)」 or “Noto Sans TC”,。

若安裝了思源黑體,請修改 add-text.js 裡的 style 區塊:

CSS

font-family: "PingFang TC", "Noto Sans TC", "Source Han Sans TC", "Microsoft JhengHei", sans-serif;

▋ 4. 接下來你可以怎麼做?

這套工具現在是「手動餵圖」,既然你追求全自動化,下一步可以考慮:

  • 批次處理:用 fs.readdirSync 讀取整個資料夾,一次幫 10 張 AI 圖加標題。
  • Web API:用 Express 把它包成一個簡單的網頁工具,傳圖進去,標題出來。
  • 整合 Claude Code:就像我們之前聊到的,把這段 JS 直接貼進你的 Claude Skill 設定中。

太棒了,進入「批次處理」階段才是真正的效率革命。這段程式碼會自動掃描一個資料夾(例如 raw_images),把裡面所有的圖片(JPG/PNG/WebP)全部壓上標題,並統一輸出到另一個資料夾。

我幫你重構了 add-text.js,加入 fs 模組來處理檔案系統。

▋ 更新後的 add-text.js (支援批次轉換)

JavaScript

const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

/** 1. 自動斷行邏輯 **/
function wrapText(text, maxCharsPerLine) {
  const lines = [];
  let currentLine = "";
  let currentWidth = 0;
  for (const char of text) {
    const isASCII = /^[\x00-\x7F]*$/.test(char);
    const charWidth = isASCII ? 0.55 : 1;
    if (currentWidth + charWidth > maxCharsPerLine) {
      lines.push(currentLine);
      currentLine = char;
      currentWidth = charWidth;
    } else {
      currentLine += char;
      currentWidth += charWidth;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines;
}

/** 2. 偵測圖片亮度 **/
async function getIsImageDark(inputPath) {
  const { data } = await sharp(inputPath).resize(1, 1).raw().toBuffer({ resolveWithObject: true });
  const [r, g, b] = data;
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5;
}

/** 3. 單張圖片處理核心 **/
async function processSingleImage(inputPath, outputPath, title) {
  try {
    const image = sharp(inputPath);
    const { width, height } = await image.metadata();
    const isDark = await getIsImageDark(inputPath);
    
    const fontSize = Math.floor(width * 0.055);
    const padding = Math.floor(width * 0.06);
    const lineHeight = fontSize * 1.4;
    const lines = wrapText(title, 15);
    
    const config = isDark ? 
      { grad: 'black', op: 0.7, text: 'white', shadow: 'rgba(0,0,0,0.5)' } : 
      { grad: 'white', op: 0.8, text: '#222', shadow: 'rgba(255,255,255,0.3)' };

    const textSpans = lines.map((l, i) => `<tspan x="${padding}" y="${height - padding - (lines.length - 1 - i) * lineHeight}">${l}</tspan>`).join('');
    const shadowSpans = lines.map((l, i) => `<tspan x="${padding + 2}" y="${height - padding - (lines.length - 1 - i) * lineHeight + 2}">${l}</tspan>`).join('');

    const svg = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="100%"><stop offset="0" stop-color="${config.grad}" stop-opacity="0"/><stop offset="1" stop-color="${config.grad}" stop-opacity="${config.op}"/></linearGradient></defs>
      <rect x="0" y="${height * 0.6}" width="${width}" height="${height * 0.4}" fill="url(#g)" />
      <style>.t { font-family: "PingFang TC", "Microsoft JhengHei", sans-serif; font-weight: bold; font-size: ${fontSize}px; }</style>
      <text class="t" fill="${config.shadow}">${shadowSpans}</text>
      <text class="t" fill="${config.text}">${textSpans}</text>
    </svg>`;

    await image.composite([{ input: Buffer.from(svg) }]).toFile(outputPath);
    console.log(`✅ 已完成: ${path.basename(outputPath)}`);
  } catch (err) {
    console.error(`❌ 失敗: ${inputPath}`, err.message);
  }
}

/** 4. 批次處理主程式 **/
async function batchProcess(inputDir, outputDir, defaultTitle) {
  // 確保輸出資料夾存在
  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });

  const files = fs.readdirSync(inputDir).filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
  
  console.log(`🚀 開始處理 ${files.length} 張圖片...`);
  
  for (const file of files) {
    const inputPath = path.join(inputDir, file);
    const outputPath = path.join(outputDir, `covered_${file}`);
    
    // 這裡標題可以根據檔名自動抓取,或是用預設標題
    // 例如:檔名是 "AI_Cover.jpg",我們可以把底線換成空格當標題
    const titleFromFilename = path.parse(file).name.replace(/_/g, ' ');
    const finalTitle = defaultTitle || titleFromFilename;

    await processSingleImage(inputPath, outputPath, finalTitle);
  }
  console.log(`\n🎉 全部處理完成!請查看 ${outputDir} 資料夾。`);
}

// --- 執行區 ---
const INPUT_FOLDER = './raw_images';   // 放原始 AI 圖的資料夾
const OUTPUT_FOLDER = './final_posts'; // 輸出的資料夾

// 如果沒傳標題,程式會自動拿「檔名」當標題(底線會變空白)
batchProcess(INPUT_FOLDER, OUTPUT_FOLDER, "Gemini 自動生圖教學");

▋ 運作邏輯說明

  1. 自動過濾:程式會用 filter 排除掉資料夾內的 .DS_Store 或其他雜檔,只處理圖片。
  2. 檔名即標題:這是我幫你加的「懶人功能」。如果你把 AI 圖片命名為 如何使用_Claude_Code.jpg,執行時它就會自動壓上「如何使用 Claude Code」的字樣。
  3. 異步佇列:使用 for...of 配合 await,確保圖片一張一張排隊處理,不會因為一次塞入太多圖片導致記憶體溢位(特別是處理 4K 大圖時)。

▋ 建議的工作流

  1. 建立資料夾:新建一個 raw_images 資料夾。
  2. 丟圖進去:把你用 Gemini 生好的 10 幾張圖通通丟進去。
  3. 執行命令npm start
  4. 收工:去 final_posts 拿圖,直接發 FB。

這是一個非常實用的功能。要讓文字「置中」,在 SVG 裡主要靠 text-anchor="middle" 這個屬性,並且要把 x 座標設定在圖片寬度的正中間 ($width / 2$)。

我修改了 add-text.js,新增了一個 options 物件參數,讓你隨時可以切換 置左 (left)置中 (center)

▋ 修改後的 add-text.js (支援位置切換)

JavaScript

const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

/** 1. 自動斷行邏輯 **/
function wrapText(text, maxCharsPerLine) {
  const lines = [];
  let currentLine = "";
  let currentWidth = 0;
  for (const char of text) {
    const isASCII = /^[\x00-\x7F]*$/.test(char);
    const charWidth = isASCII ? 0.55 : 1;
    if (currentWidth + charWidth > maxCharsPerLine) {
      lines.push(currentLine);
      currentLine = char;
      currentWidth = charWidth;
    } else {
      currentLine += char;
      currentWidth += charWidth;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines;
}

/** 2. 偵測圖片亮度 **/
async function getIsImageDark(inputPath) {
  const { data } = await sharp(inputPath).resize(1, 1).raw().toBuffer({ resolveWithObject: true });
  const [r, g, b] = data;
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5;
}

/** 3. 單張圖片處理核心 (新增 align 參數) **/
async function processSingleImage(inputPath, outputPath, title, options = {}) {
  try {
    const { align = 'center' } = options; // 預設為 center,可選 'left'
    const image = sharp(inputPath);
    const { width, height } = await image.metadata();
    const isDark = await getIsImageDark(inputPath);
    
    const fontSize = Math.floor(width * 0.055);
    const padding = Math.floor(width * 0.06);
    const lineHeight = fontSize * 1.4;
    const lines = wrapText(title, 15);
    
    const config = isDark ? 
      { grad: 'black', op: 0.7, text: 'white', shadow: 'rgba(0,0,0,0.5)' } : 
      { grad: 'white', op: 0.8, text: '#222', shadow: 'rgba(255,255,255,0.3)' };

    // 根據對齊方式計算 X 座標與 SVG 屬性
    const xPos = align === 'center' ? width / 2 : padding;
    const textAnchor = align === 'center' ? 'middle' : 'start';

    const genSpans = (offsetX = 0, offsetY = 0) => lines.map((l, i) => {
      const yPos = height - padding - (lines.length - 1 - i) * lineHeight + offsetY;
      return `<tspan x="${xPos + offsetX}" y="${yPos}">${l}</tspan>`;
    }).join('');

    const svg = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <linearGradient id="g" x1="0" y1="0" x2="0" y2="100%">
          <stop offset="0" stop-color="${config.grad}" stop-opacity="0"/>
          <stop offset="1" stop-color="${config.grad}" stop-opacity="${config.op}"/>
        </linearGradient>
      </defs>
      <rect x="0" y="${height * 0.6}" width="${width}" height="${height * 0.4}" fill="url(#g)" />
      <style>
        .t { 
          font-family: "PingFang TC", "Microsoft JhengHei", sans-serif; 
          font-weight: bold; 
          font-size: ${fontSize}px; 
          text-anchor: ${textAnchor}; 
        }
      </style>
      <text class="t" fill="${config.shadow}">${genSpans(2, 2)}</text>
      <text class="t" fill="${config.text}">${genSpans(0, 0)}</text>
    </svg>`;

    await image.composite([{ input: Buffer.from(svg) }]).toFile(outputPath);
    console.log(`✅ 已完成 (${align}): ${path.basename(outputPath)}`);
  } catch (err) {
    console.error(`❌ 失敗: ${inputPath}`, err.message);
  }
}

/** 4. 批次處理主程式 **/
async function batchProcess(inputDir, outputDir, options = {}) {
  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
  const files = fs.readdirSync(inputDir).filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
  
  console.log(`🚀 開始批次處理 ${files.length} 張圖片 (對齊: ${options.align || 'center'})...`);
  
  for (const file of files) {
    const inputPath = path.join(inputDir, file);
    const outputPath = path.join(outputDir, `covered_${file}`);
    const title = path.parse(file).name.replace(/_/g, ' ');

    await processSingleImage(inputPath, outputPath, title, options);
  }
}

// --- 執行區 ---
const INPUT_FOLDER = './raw_images';
const OUTPUT_FOLDER = './final_posts';

// 你可以在這裡輕鬆切換 'center' 或 'left'
batchProcess(INPUT_FOLDER, OUTPUT_FOLDER, { align: 'center' });

▋ 修改重點解析

  1. text-anchor 屬性:這是 SVG 控制文字對齊的核心。
    • middle: 文字會以 x 座標為中心點向左右展開(置中)。
    • start: 文字會從 x 座標開始向右書寫(置左)。
  2. 動態 xPos
    • 置中時:x 座標設為圖片寬度的一半 (width / 2)。
    • 置左時:x 座標設為邊距 (padding)。
  3. 封裝 genSpans:為了讓程式碼更乾淨,我把產生 <tspan> 的邏輯封裝起來,這樣處理陰影和主文字時就不會有一長串重複代碼。
  4. 參數化控制:現在呼叫 batchProcess 時,只需要傳入 { align: 'center' }{ align: 'left' } 即可秒速切換風格。

▋ 這樣做的好處

置中的文字通常看起來更有「海報感」和「正式感」,適合 FB 貼文的封面圖;而置左的文字則比較有「筆記感」或「雜誌內頁感」。

其他想要自動化的細節(例如:自動加上你的 Logo 或浮水印?)


這是一個非常合理的優化,把所有與「路徑」相關的參數都抽離到 config.json,能讓你更換專案目錄時完全不需要動到 .js 檔案。

以下是更新後的完整設定與程式碼。

▋ 1. 更新後的 config.json

我把資料夾路徑也放進來,並加上了註解說明:

JSON

{
  "paths": {
    "inputDir": "./raw_images",
    "outputDir": "./final_posts"
  },
  "title": "讓 AI 封面圖自動加上正體中文標題",
  "align": "center",
  "fontFamily": ["PingFang TC", "Source Han Sans TC", "Microsoft JhengHei", "sans-serif"],
  "customColors": {
    "useAutoLuminance": true,
    "forceTextColor": "#ffffff",
    "forceShadowColor": "rgba(0,0,0,0.5)"
  },
  "layout": {
    "maxCharsPerLine": 15,
    "fontSizeRatio": 0.055,
    "paddingRatio": 0.06
  }
}

▋ 2. 更新後的 add-text.js

現在程式碼會從 config.paths 讀取資料夾位置,並自動處理路徑邏輯。

JavaScript

const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

// 1. 讀取外部設定檔
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));

/** 自動斷行邏輯 **/
function wrapText(text, maxCharsPerLine) {
  const lines = [];
  let currentLine = "";
  let currentWidth = 0;
  for (const char of text) {
    const isASCII = /^[\x00-\x7F]*$/.test(char);
    const charWidth = isASCII ? 0.55 : 1;
    if (currentWidth + charWidth > maxCharsPerLine) {
      lines.push(currentLine);
      currentLine = char;
      currentWidth = charWidth;
    } else {
      currentLine += char;
      currentWidth += charWidth;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines;
}

/** 偵測圖片亮度 **/
async function getIsImageDark(inputPath) {
  const { data } = await sharp(inputPath).resize(1, 1).raw().toBuffer({ resolveWithObject: true });
  const [r, g, b] = data;
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5;
}

/** 單張圖片處理核心 **/
async function processSingleImage(inputPath, outputPath) {
  try {
    const image = sharp(inputPath);
    const { width, height } = await image.metadata();
    
    // 從 config 讀取所有參數
    const { align, fontFamily, layout, customColors, title } = config;
    const isDark = await getIsImageDark(inputPath);
    
    const fontSize = Math.floor(width * layout.fontSizeRatio);
    const padding = Math.floor(width * layout.paddingRatio);
    const lineHeight = fontSize * 1.4;
    const lines = wrapText(title, layout.maxCharsPerLine);
    
    // 顏色與漸層判定
    let textColor, shadowColor, gradColor, gradOp;
    if (customColors.useAutoLuminance) {
      gradColor = isDark ? 'black' : 'white';
      gradOp = isDark ? 0.7 : 0.8;
      textColor = isDark ? 'white' : '#222222';
      shadowColor = isDark ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.3)';
    } else {
      gradColor = 'black'; 
      gradOp = 0.6;
      textColor = customColors.forceTextColor;
      shadowColor = customColors.forceShadowColor;
    }

    // 排版設定
    const xPos = align === 'center' ? width / 2 : padding;
    const textAnchor = align === 'center' ? 'middle' : 'start';
    const fontStack = fontFamily.map(f => `"${f}"`).join(', ');

    // 產生 SVG 文字內容
    const genSpans = (offsetX = 0, offsetY = 0) => lines.map((l, i) => {
      const yPos = height - padding - (lines.length - 1 - i) * lineHeight + offsetY;
      return `<tspan x="${xPos + offsetX}" y="${yPos}">${l}</tspan>`;
    }).join('');

    const svg = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <linearGradient id="g" x1="0" y1="0" x2="0" y2="100%">
          <stop offset="0" stop-color="${gradColor}" stop-opacity="0"/>
          <stop offset="1" stop-color="${gradColor}" stop-opacity="${gradOp}"/>
        </linearGradient>
      </defs>
      <rect x="0" y="${height * 0.6}" width="${width}" height="${height * 0.4}" fill="url(#g)" />
      <style>
        .t { font-family: ${fontStack}; font-weight: bold; font-size: ${fontSize}px; text-anchor: ${textAnchor}; }
      </style>
      <text class="t" fill="${shadowColor}">${genSpans(2, 2)}</text>
      <text class="t" fill="${textColor}">${genSpans(0, 0)}</text>
    </svg>`;

    await image.composite([{ input: Buffer.from(svg) }]).toFile(outputPath);
    console.log(`✅ 已處理: ${path.basename(outputPath)}`);
  } catch (err) {
    console.error(`❌ 錯誤: ${inputPath}`, err.message);
  }
}

/** 批次處理主程式 **/
async function batchProcess() {
  const { inputDir, outputDir } = config.paths;

  // 檢查輸入路徑是否存在
  if (!fs.existsSync(inputDir)) {
    console.error(`❌ 找不到輸入資料夾: ${inputDir}`);
    return;
  }

  // 自動建立輸出資料夾
  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true });
  }

  const files = fs.readdirSync(inputDir).filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
  
  console.log(`🚀 [Config Loaded] 開始處理 ${files.length} 張圖片...`);
  console.log(`📂 輸入: ${inputDir} | 📂 輸出: ${outputDir}\n`);

  for (const file of files) {
    const inputPath = path.join(inputDir, file);
    const outputPath = path.join(outputDir, `covered_${file}`);
    await processSingleImage(inputPath, outputPath);
  }

  console.log(`\n🎉 任務結束!`);
}

// 啟動
batchProcess();

▋ 這次調整的好處

  1. 專案解耦:如果你今天有「Facebook 專案」和「IG 專案」,你只需要準備兩份 config_fb.jsonconfig_ig.json,並在啟動時指定讀哪份檔案,不需要改程式。
  2. 更安全的路徑檢查:加入了 fs.existsSync(inputDir) 的檢查,防止你在資料夾還沒建立時就跑程式導致 Crash。
  3. 靈活性最高:未來你可以直接把這套腳本打包成執行檔,分發給不認識 Node.js 的人,他們只要會改 config.json 就能產圖。

這是一個非常實用的功能,可以讓你的圖片更具辨識度,同時防止被隨意盜用。

為了維持「邏輯與資料分離」的原則,我們同樣將浮水印的設定放在 config.json 中,讓你隨時可以切換文字、位置與透明度。

▋ 1. 更新後的 config.json (加入水印設定)

新增了一個 watermark 區塊,可以設定浮水印的內容、文字大小(相對於圖片寬度)、透明度以及位置(例如右下角)。

JSON

{
  "paths": {
    "inputDir": "./raw_images",
    "outputDir": "./final_posts"
  },
  "title": "讓 AI 封面圖自動加上正體中文標題",
  "align": "center",
  "fontFamily": ["PingFang TC", "Noto Sans TC", "Source Han Sans TC", "Microsoft JhengHei", "sans-serif"],
  "customColors": {
    "useAutoLuminance": true,
    "forceTextColor": "#ffffff",
    "forceShadowColor": "rgba(0,0,0,0.5)"
  },
  "layout": {
    "maxCharsPerLine": 15,
    "fontSizeRatio": 0.055,
    "paddingRatio": 0.06
  },
  "watermark": {
    "enable": true,
    "text": "@我的FB粉專名稱",
    "fontSizeRatio": 0.02,
    "opacity": 0.5,
    "position": {
      "right": true,
      "bottomRatio": 0.05
    }
  }
}

▋ 2. 更新後的 add-text.js

我在 SVG 產生邏輯中加入了浮水印的渲染區塊。它會讀取設定,計算合適的位置,並壓在漸層層的上方

JavaScript

const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

// 1. 讀取外部設定檔
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));

/** 自動斷行邏輯 **/
function wrapText(text, maxCharsPerLine) {
  const lines = [];
  let currentLine = "";
  let currentWidth = 0;
  for (const char of text) {
    const isASCII = /^[\x00-\x7F]*$/.test(char);
    const charWidth = isASCII ? 0.55 : 1;
    if (currentWidth + charWidth > maxCharsPerLine) {
      lines.push(currentLine);
      currentLine = char;
      currentWidth = charWidth;
    } else {
      currentLine += char;
      currentWidth += charWidth;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines;
}

/** 偵測圖片亮度 **/
async function getIsImageDark(inputPath) {
  const { data } = await sharp(inputPath).resize(1, 1).raw().toBuffer({ resolveWithObject: true });
  const [r, g, b] = data;
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5;
}

/** 單張圖片處理核心 **/
async function processSingleImage(inputPath, outputPath) {
  try {
    const image = sharp(inputPath);
    const { width, height } = await image.metadata();
    
    // 從 config 讀取所有參數
    const { align, fontFamily, layout, customColors, title, watermark } = config;
    const isDark = await getIsImageDark(inputPath);
    
    // 標題版面計算
    const fontSize = Math.floor(width * layout.fontSizeRatio);
    const padding = Math.floor(width * layout.paddingRatio);
    const lineHeight = fontSize * 1.4;
    const lines = wrapText(title, layout.maxCharsPerLine);
    
    // 顏色與漸層判定
    let textColor, shadowColor, gradColor, gradOp;
    if (customColors.useAutoLuminance) {
      gradColor = isDark ? 'black' : 'white';
      gradOp = isDark ? 0.7 : 0.8;
      textColor = isDark ? 'white' : '#222222';
      shadowColor = isDark ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.3)';
    } else {
      gradColor = 'black'; 
      gradOp = 0.6;
      textColor = customColors.forceTextColor;
      shadowColor = customColors.forceShadowColor;
    }

    // 排版設定
    const xPos = align === 'center' ? width / 2 : padding;
    const textAnchor = align === 'center' ? 'middle' : 'start';
    const fontStack = fontFamily.map(f => `"${f}"`).join(', ');

    // 產生標題 SVG 文字內容
    const genSpans = (offsetX = 0, offsetY = 0) => lines.map((l, i) => {
      const yPos = height - padding - (lines.length - 1 - i) * lineHeight + offsetY;
      return `<tspan x="${xPos + offsetX}" y="${yPos}">${l}</tspan>`;
    }).join('');

    // **新增:浮水印處理邏輯**
    let watermarkSvg = '';
    if (watermark.enable && watermark.text) {
      const wmFontSize = Math.floor(width * watermark.fontSizeRatio);
      const wmPadding = padding;
      
      // 計算水印位置 (目前預設右下角)
      const wmX = watermark.position.right ? width - wmPadding : wmPadding;
      const wmY = height - (height * watermark.position.bottomRatio);
      const wmAnchor = watermark.position.right ? 'end' : 'start';
      
      watermarkSvg = `
        <text 
          x="${wmX}" 
          y="${wmY}" 
          font-family="${fontStack}" 
          font-size="${wmFontSize}px" 
          fill="${textColor}" 
          fill-opacity="${watermark.opacity}" 
          text-anchor="${wmAnchor}"
        >
          ${watermark.text}
        </text>
      `;
    }

    const svg = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <linearGradient id="g" x1="0%" y1="0%" x2="0%" y2="100%">
          <stop offset="0" stop-color="${gradColor}" stop-opacity="0"/>
          <stop offset="1" stop-color="${gradColor}" stop-opacity="${gradOp}"/>
        </linearGradient>
      </defs>
      
      <rect x="0" y="${height * 0.6}" width="${width}" height="${height * 0.4}" fill="url(#g)" />
      
      <style>
        .t { font-family: ${fontStack}; font-weight: bold; font-size: ${fontSize}px; text-anchor: ${textAnchor}; }
      </style>
      
      <text class="t" fill="${shadowColor}">${genSpans(2, 2)}</text>
      <text class="t" fill="${textColor}">${genSpans(0, 0)}</text>
      
      ${watermarkSvg}
    </svg>`;

    await image.composite([{ input: Buffer.from(svg) }]).toFile(outputPath);
    console.log(`✅ 已處理 (+水印): ${path.basename(outputPath)}`);
  } catch (err) {
    console.error(`❌ 錯誤: ${inputPath}`, err.message);
  }
}

/** 批次處理主程式 **/
async function batchProcess() {
  const { inputDir, outputDir } = config.paths;

  // 檢查輸入路徑是否存在
  if (!fs.existsSync(inputDir)) {
    console.error(`❌ 找不到輸入資料夾: ${inputDir}`);
    return;
  }

  // 自動建立輸出資料夾
  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true });
  }

  const files = fs.readdirSync(inputDir).filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
  
  console.log(`🚀 [Config Loaded] 開始處理 ${files.length} 張圖片...`);
  console.log(`📂 輸入: ${inputDir} | 📂 輸出: ${outputDir}\n`);

  for (const file of files) {
    const inputPath = path.join(inputDir, file);
    const outputPath = path.join(outputDir, `covered_${file}`);
    await processSingleImage(inputPath, outputPath);
  }

  console.log(`\n🎉 任務結束!`);
}

// 啟動
batchProcess();

▋ 浮水印功能細節解析

  1. 動態位置與對齊:在 config.json 中,我們設定 "right": true,程式會自動計算 wmX = width - wmPadding,並將 text-anchor 設為 end。這樣水印就會對齊右邊緣,即使更換文字長度也不會跑掉。
  2. 響應式文字大小:與標題一樣,水印也使用 fontSizeRatio,確保在 1K 或 4K 圖片上,水印的大小看起來是一樣的。
  3. 透明度 (Opacity):利用 SVG 的 fill-opacity 屬性,可以讓水印若隱若現,既能防止盜用,又不會太搶戲。
  4. 共用配色邏輯:水印會自動套用標題所偵測出的文字顏色(黑或白),確保在深色背景下水印是白的,淺色背景下水印是黑的。

▋ 進階思考:如果我想用 Logo 圖片當水印?

這個 .js 目前只支援文字浮水印。如果你想把公司的 Logo (PNG) 蓋上去,原理也很簡單:

  1. config.json 加入 Logo 的路徑和位置設定。
  2. .js 中,先將 Logo PNG 讀入為另一個 Buffer(或是另一個 sharp 物件)。
  3. 在最後的 .composite() 方法中,傳入一個陣列,同時合成標題 SVG 和 Logo Buffer。例如:JavaScriptawait image.composite([ { input: Buffer.from(svg) }, { input: logoBuffer, top: logoY, left: logoX } // 加上 Logo 合成 ]).toFile(outputPath);

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *