第 06 章 — canvas 文字合成目錄  

AI 互動行銷頁實戰 — 目標九宮格 — 第 06 章

canvas 文字合成:
AI 畫背景,canvas 畫字

學完:想清楚文字該讓 AI 畫還是程式畫(關鍵是改字成本),然後用 canvas 把九宮格文字一字不差地疊在任何背景上,輸出可下載的 PNG 桌布。

對照實驗:叫 AI 連中文字一起畫

畫一張 3×3 的目標九宮格表格圖,深藍色底、金色框線。
中央格寫「完成全馬 42.195km」,
周圍八格依序寫:「每週跑 40km」「核心訓練」「睡滿 7 小時」
「補給策略」「配速練習」「比賽報名」「飲食管理」「跑姿調整」。
要求:每一格的繁體中文都清晰可讀、一字不差。

五分鐘的對照實驗:貼到 ChatGPT 或 Gemini 任一生圖工具。表格與配色多半有模有樣——湊近看文字。

新模型進步了,但決策點不在這

為什麼會這樣

生圖模型是畫畫的,不是排版的
英文 26 個字母,樣本夠多常常畫得對;常用中文字四、五千個,模型只能畫出「長得像中文的紋理」。
這是原理性限制,不是換個新模型就會好。

核心觀念:分工

AI 負責它擅長的

  • 氛圍、光線、風格、構圖
  • 生成一張沒有任何文字的背景
  • 生圖 prompt 鐵律:Do NOT render any text

程式負責機器擅長的

  • 精準、可驗收的文字排版
  • canvas 把九宮格一格一格畫上去
  • 兩層合成輸出 PNG

這個解耦還有一個下一章才兌現的紅利:改文字不必重新生圖,迭代零成本。

步驟一:開一張「輸出尺寸」的 canvas

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 的糊圖。

步驟二:背景 cover 縮放鋪滿

// 跟 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); // 九參數形式

這個函式不挑圖:任何比例丟進來都能鋪滿——下一章「匯入背景圖」靠的就是它。

步驟三:暗化遮罩,可讀性不賭 AI 聽話

// 保險一:全幅半透明暗化遮罩
ctx.fillStyle = 'rgba(8, 10, 16, 0.30)';
ctx.fillRect(0, 0, W, H);

// 保險二:每一格自己再墊一層更深的格底(畫格子時用)
const CELL_BG = 'rgba(12, 16, 24, 0.58)';

步驟四:fitText——二分搜尋塞得下的最大字級

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]。

步驟七:輸出 PNG

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 全圖版型 + 風格預設)