只讓 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,在電腦上把標題渲染上去。
▋ 為什麼這個做法更專業?
- 響應式排版:程式會自動讀取圖片尺寸,算出最合適的字型大小。
- 視覺層次:我會產生一個 SVG 圖層,包含「底部漸層(黑透明)」和「白色陰影文字」,確保標題在任何背景下都清晰好讀。
- 頂級字體:直接調用 macOS 內建的「蘋方-繁 (PingFang TC)」。這字體設計極佳,質感瞬間拉高好幾個檔次,完全不用額外安裝。
▋ 連「中英混排」都能自動搞定。
為了怕標題太長衝出邊框,我讓程式去估算字寬(中文 1em、英文大約 0.55em),自動決定換行時機。長篇標題,都能乖乖待在安全區域內。
我把這套邏輯整合進了 Claude Code 的 Skill 裡。現在整個流程是全自動的:
- 自動提取文章精華標題。
- 自動產生英文 Image Prompt。
- 讓 Gemini 畫出無字插圖。
- 下載後用 sharp + SVG 自動疊加正體中文標題。
▋ 為什麼不乾脆用 Canva 點一點就好?
因為我追求的是「全自動化」。手動開 Canva 當然可以,但每天都要重複的操作,就值得花時間寫成程式。
核心邏輯就是:讓 AI 做它擅長的(藝術創作),讓程式做它擅長的(渲染文字)。這個思維不只適用中文,日文、韓文甚至各種複雜排版都能如法炮製。
下次如果你想讓 AI 幫你做有中文字的圖片,記得:不要讓 AI 直接寫字,先拿到乾淨的插圖,再用程式把字加上去。這是目前最穩定也最好看的做法。
你有遇過 AI 生成中文圖片翻車的經驗嗎?或者你有用其他方式在圖片上加中文字?留言聊聊你的做法!
Node.js 實作的程式碼範本
這是一個非常實用的自動化工具邏輯。我幫你整理了一份基於 Node.js、sharp 與 SVG 的核心實作程式碼範本。
這套腳本可以直接把文字「壓」在圖片底部,並加上半透明漸層確保文字清晰:
▋ 環境準備
你需要先安裝 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> 標籤內即可
▋ 為什麼要這麼麻煩「算字寬」?
- 視覺平衡: AI 生成的圖片寬高比例不一(16:9, 4:3, 1:1),固定的字數限制會讓短圖看起來太擠、長圖看起來太空。透過這套邏輯,你可以根據
width動態調整maxCharsPerLine。 - 中英夾雜: 如果標題是 “AI 革命:2026 年最核心的 5 個 Skills”,英文字母佔的空間比中文小很多,如果單純用字數斷行,右邊會空出一大塊。算字寬能讓排版看起來更像「設計過」的。
▋ 進階心法:自動化全流程
既然你已經有了這套工具,建議你可以把它包成一個 CLI 指令。
我現在的工作流是:
- Claude 寫完文章後,呼叫這個
skill。 - Gemini API 收到指令,回傳圖片 URL。
- Node.js 下載圖片,跑
sharp+SVG渲染。 - 預覽 沒問題就直接發布。
這整套流程如何整合進 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}`;
}
};
▋ 為什麼這樣整合很強大?
- Context 意識: Claude 讀過你的文章,所以它產生的
imagePrompt會非常貼合內容。例如你寫「AI 自動化」,它會自動下指令畫「未來感的齒輪與藍色光影」,而不是隨便抓一張圖。 - 全自動提取: 你甚至不用想標題。Claude 會根據文章重點,自動濃縮出一句適合放在封面上的金句(例如把「這是一篇關於如何用 Node.js 解決 AI 生圖文字問題的文章」濃縮成「AI 生圖中文字翻車?Node.js 救援指南」)。
- 錯誤修正: 如果圖片下載失敗或 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 遮罩改用 「白色轉透明」,文字改用 「黑色」。
- 如果圖片整體偏暗:維持我給你的 「黑色轉透明」+「白字」。
▋ 最後的自動化閉環
現在你已經有了:
- Facebook 貼文腳本(吸引讀者)
- Node.js + sharp 渲染邏輯(處理中文字)
- SVG 自動斷行演算法(適應長短標題)
- Claude Code 整合思維(全自動化)
- 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 幫你畫了一張「雪地中的機器人」(全白背景),原本的黑漸層+白字會完全隱形。有了這個判斷,程式會自動改成「白漸層+黑字」,確保標題永遠清晰。
- 視覺一致性:這讓你的自動化流程從「堪用」變成「專業」。讀者會以為你有根據每張圖的配色去手動調整設計。
▋ 最後的檢查清單
現在你的自動化工具已經具備了以下功能:
- AI 畫圖:用 Gemini 生成乾淨的插圖。
- 亮度偵測:自動判斷圖片深淺色。
- 文字排版:中英夾雜自動計算寬度與換行。
- 高品質渲染:用
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);
▋ 注意事項與執行細節
- 字體優先權:在
style中,我將"PingFang TC"(蘋方-繁)排在第一順位。如果你在 Windows 上執行,它會自動回退到Microsoft JhengHei(微軟正黑體)。 - 亮度偵測:
isDark的邏輯會確保不管 AI 給你雪地還是星空,文字都能清晰可見。 - 效能:
sharp的composite速度極快,即使是 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. 如何快速開始?
依照以下步驟,你就能在一分鐘內看到效果:
- 放置圖片:在資料夾放一張名為
input.jpg的圖片(建議是 16:9 或 1:1 的 AI 生成圖)。 - 安裝套件:Bash
npm install - 執行程式:Bash
npm start - 查看結果:資料夾中會出現一張
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 自動生圖教學");
▋ 運作邏輯說明
- 自動過濾:程式會用
filter排除掉資料夾內的.DS_Store或其他雜檔,只處理圖片。 - 檔名即標題:這是我幫你加的「懶人功能」。如果你把 AI 圖片命名為
如何使用_Claude_Code.jpg,執行時它就會自動壓上「如何使用 Claude Code」的字樣。 - 異步佇列:使用
for...of配合await,確保圖片一張一張排隊處理,不會因為一次塞入太多圖片導致記憶體溢位(特別是處理 4K 大圖時)。
▋ 建議的工作流
- 建立資料夾:新建一個
raw_images資料夾。 - 丟圖進去:把你用 Gemini 生好的 10 幾張圖通通丟進去。
- 執行命令:
npm start - 收工:去
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' });
▋ 修改重點解析
text-anchor屬性:這是 SVG 控制文字對齊的核心。middle: 文字會以x座標為中心點向左右展開(置中)。start: 文字會從x座標開始向右書寫(置左)。
- 動態
xPos:- 置中時:
x座標設為圖片寬度的一半 (width / 2)。 - 置左時:
x座標設為邊距 (padding)。
- 置中時:
- 封裝
genSpans:為了讓程式碼更乾淨,我把產生<tspan>的邏輯封裝起來,這樣處理陰影和主文字時就不會有一長串重複代碼。 - 參數化控制:現在呼叫
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();
▋ 這次調整的好處
- 專案解耦:如果你今天有「Facebook 專案」和「IG 專案」,你只需要準備兩份
config_fb.json和config_ig.json,並在啟動時指定讀哪份檔案,不需要改程式。 - 更安全的路徑檢查:加入了
fs.existsSync(inputDir)的檢查,防止你在資料夾還沒建立時就跑程式導致 Crash。 - 靈活性最高:未來你可以直接把這套腳本打包成執行檔,分發給不認識 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();
▋ 浮水印功能細節解析
- 動態位置與對齊:在
config.json中,我們設定"right": true,程式會自動計算wmX = width - wmPadding,並將text-anchor設為end。這樣水印就會對齊右邊緣,即使更換文字長度也不會跑掉。 - 響應式文字大小:與標題一樣,水印也使用
fontSizeRatio,確保在 1K 或 4K 圖片上,水印的大小看起來是一樣的。 - 透明度 (Opacity):利用 SVG 的
fill-opacity屬性,可以讓水印若隱若現,既能防止盜用,又不會太搶戲。 - 共用配色邏輯:水印會自動套用標題所偵測出的文字顏色(黑或白),確保在深色背景下水印是白的,淺色背景下水印是黑的。
▋ 進階思考:如果我想用 Logo 圖片當水印?
這個 .js 目前只支援文字浮水印。如果你想把公司的 Logo (PNG) 蓋上去,原理也很簡單:
- 在
config.json加入 Logo 的路徑和位置設定。 - 在
.js中,先將 Logo PNG 讀入為另一個 Buffer(或是另一個sharp物件)。 - 在最後的
.composite()方法中,傳入一個陣列,同時合成標題 SVG 和 Logo Buffer。例如:JavaScriptawait image.composite([ { input: Buffer.from(svg) }, { input: logoBuffer, top: logoY, left: logoX } // 加上 Logo 合成 ]).toFile(outputPath);
