AI 互動行銷頁實戰 — 目標九宮格 — 第 06 章
學完:想清楚文字該讓 AI 畫還是程式畫(關鍵是改字成本),然後用 canvas 把九宮格文字一字不差地疊在任何背景上,輸出可下載的 PNG 桌布。
畫一張 3×3 的目標九宮格表格圖,深藍色底、金色框線。 中央格寫「完成全馬 42.195km」, 周圍八格依序寫:「每週跑 40km」「核心訓練」「睡滿 7 小時」 「補給策略」「配速練習」「比賽報名」「飲食管理」「跑姿調整」。 要求:每一格的繁體中文都清晰可讀、一字不差。
五分鐘的對照實驗:貼到 ChatGPT 或 Gemini 任一生圖工具。表格與配色多半有模有樣——湊近看文字。
為什麼會這樣
生圖模型是畫畫的,不是排版的。
英文 26 個字母,樣本夠多常常畫得對;常用中文字四、五千個,模型只能畫出「長得像中文的紋理」。
這是原理性限制,不是換個新模型就會好。
這個解耦還有一個下一章才兌現的紅利:改文字不必重新生圖,迭代零成本。
const CANVAS = { portrait: { w: 1170, h: 2532 }, // iPhone 等級桌布解析度
landscape: { w: 1920, h: 1080 } };
const { w: W, h: H } = CANVAS[orientation];
canvas.width = W; // 這是輸出畫素,不是 CSS 樣式
canvas.height = H;
const ctx = canvas.getContext('2d');
canvas.width 是輸出畫素、CSS 的 width 只是顯示大小,兩者各管各的。預覽用 CSS 縮小顯示即可,輸出尺寸不受影響——只設 CSS 會得到一張 300×150 的糊圖。
// 跟 CSS 的 background-size: cover 同一個邏輯:蓋滿、超出置中裁掉
function coverRect(srcW, srcH, dstW, dstH) {
const scale = Math.max(dstW / srcW, dstH / srcH);
const sw = dstW / scale;
const sh = dstH / scale;
return { sx: (srcW - sw) / 2, sy: (srcH - sh) / 2, sw, sh };
}
const { sx, sy, sw, sh } = coverRect(bgBitmap.width, bgBitmap.height, W, H);
ctx.drawImage(bgBitmap, sx, sy, sw, sh, 0, 0, W, H); // 九參數形式
這個函式不挑圖:任何比例丟進來都能鋪滿——下一章「匯入背景圖」靠的就是它。
// 保險一:全幅半透明暗化遮罩 ctx.fillStyle = 'rgba(8, 10, 16, 0.30)'; ctx.fillRect(0, 0, W, H); // 保險二:每一格自己再墊一層更深的格底(畫格子時用) const CELL_BG = 'rgba(12, 16, 24, 0.58)';
const tryFit = (fs) => {
const measure = measureFactory(fs); // 該字級下的量寬函式
const lines = wrapCJK(text, maxWidth, measure); // 先斷行(下一頁)
const ok = lines.length * fs * lineHeight <= maxHeight
&& lines.every((l) => measure(l) <= maxWidth + 0.01);
return ok ? lines : null;
};
let lo = minFontSize, hi = maxFontSize, best = null;
while (lo <= hi) { // 二分搜尋
const mid = (lo + hi) >> 1;
const lines = tryFit(mid);
if (lines) { best = { fontSize: mid, lines }; lo = mid + 1; }
else { hi = mid - 1; }
}
每格文字長度差很大(「英文」vs「睡前聽英文 podcast」),固定字級必爆版。量字寬用 ctx.measureText——量之前一定要先把 ctx.font 設成要量的字級,不然量到的是上一次的字寬。
function wrapCJK(text, maxWidth, measure) {
const lines = []; let line = '';
const push = (unit) => {
if (line && measure(line + unit) > maxWidth) { lines.push(line); line = unit; }
else { line += unit; }
};
// 切成「英數段」與「單一字元」兩種單位
const units = String(text).match(/[A-Za-z0-9]+|[\s\S]/gu) || [];
for (const unit of units) {
if (unit.length > 1 && measure(unit) > maxWidth) {
for (const ch of unit) push(ch); // 整段比格子還寬才退回逐字硬拆
} else { push(unit); }
}
lines.push(line);
return lines;
}
中文沒有空格、canvas 沒有自動換行。160km、TOEIC、podcast 是不可拆單位,放不下整段移到下一行——拆成 TOE / IC 就不是人話了。
| 區域 | 畫法 |
|---|---|
| 中央格(核心目標) | 風格主色 85% 不透明度當底 + 3px 同色邊框 + 白色粗體字 整張圖最強的顏色 |
| 八個子目標格 | 深色格底 + 各自一條 4px 彩色左邊條,八格八色 |
| 9×9 模式外圍區塊 | 區塊中心格用對應子目標的同色(填色 32% 透明 + 同色邊框)一眼看出在展開哪個子目標 |
沿用大谷翔平目標表轉載圖那套大家看慣的配色語言;顏色來自風格預設(styles-presets.js),每個風格帶 accent + sub[8]。
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '目標九宮格.png';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}, 'image/png');
合成完直接從 canvas 匯出,全程不經過任何伺服器。注意 toBlob 是非同步的,結果在 callback 裡。
下一章:AI 編輯迴圈——「整體亮一點」怎麼改背景?以及當生圖服務掛掉時,prompt 如何變成可攜帶的資產讓漏斗走得完。
對照成品:demos/03-wallpaper/ · app/js/wallpaper.js(9×9 全圖版型 + 風格預設)← → 翻頁