COURSE 章節目錄 · 課程首頁 · 成品 App

模組 6 — canvas 文字合成:AI 畫背景、canvas 畫字

學完能做什麼

先想清楚:文字讓 AI 畫,還是程式畫?

開始之前,先做一個五分鐘的對照實驗。打開任何一個生圖工具(ChatGPT、Gemini 都行),貼這段:

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

先說句公道話:「AI 寫不好中文字」這件事,在新一代的強繪圖模型(gpt-image 新版、Gemini 的新繪圖模型)上已經進步很多——九格大字常常一次就寫對。但在許多免費管道、較舊或較小的模型上,你仍會看到三種典型災難:偽漢字(筆畫黏成字典裡沒有的字)、缺字漏字、簡繁混雜;而且同一個 prompt 重抽三次,錯的地方都不一樣。

所以真正的決策點不是「AI 會不會寫中文」,而是——字畫進圖裡之後,你要怎麼改?

  1. 改字成本:文字烤進圖裡,改一個字 = 整張重生。一次重生是幾十秒到幾分鐘加一筆生圖費,而且構圖會跟著變,「只改那個字、其他都不動」做不到。canvas 疊字改文字是即時、免費、其他像素一動不動。
  2. 驗收成本:模型再強也是機率性輸出,9 格要逐格人工檢查,81 格小字更要;程式畫的字是確定性的,100% 一字不差,不用驗
  3. 版型保證:自動縮字、斷行、絕不重疊、八色對應——這些是排版引擎的工作,canvas 給你保證,模型只能給你機率。

一句話總結這個架構決定:文字是「資料」,背景是「藝術」。資料要可程式化地修改與驗收,藝術才交給模型發揮。這也是為什麼「之後要改文字比較方便」是 canvas 合成的第一理由,模型寫不寫得好中文只是次要的歷史包袱。

這整段其實是甲方思維推到系統設計層:做驗收的成本,反過來決定架構——能 100% 程式化驗收的(文字)自己掌握,只能機率性交付、驗收又貴的(氛圍)才發包給 AI。步驟 3「可讀性不能賭 AI 聽話」也是同一句話:驗收這一關不外包。

核心觀念:分工——AI 畫氛圍,程式畫文字

正路是把工作拆成兩層:

背景是一張圖,文字是一層程式畫的圖,兩層合成後輸出 PNG。這個「解耦」還有一個下一章才兌現的紅利:改文字不必重新生圖,迭代零成本。

步驟

以下程式碼取自成品 app/js/wallpaper.js(有簡化,邏輯一致)。

1. 開一張「輸出尺寸」的 canvas

桌布要的是實際畫素,不是網頁排版尺寸:

const CANVAS = { portrait: { w: 1170, h: 2532 }, landscape: { w: 1920, h: 1080 } };

const { w: W, h: H } = CANVAS[orientation];
canvas.width = W;   // 這是輸出畫素,不是 CSS 樣式
canvas.height = H;
const ctx = canvas.getContext('2d');

直式 1170×2532 是 iPhone 等級的桌布解析度;預覽時用 CSS 把 canvas 縮小顯示即可,輸出尺寸不受影響。

2. 背景 cover 縮放鋪滿

AI 生的背景是 1024×1536(或 1536×1024),跟畫布比例不完全相同。處理方式跟 CSS 的 background-size: cover 同一個邏輯:放大到剛好蓋滿、超出的部分置中裁掉。

// 算出來源圖要裁切的區域 {sx, sy, sw, sh}
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);

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

3. 暗化遮罩:字的可讀性不能賭 AI 聽話

生圖 prompt 雖然要求「中央區域保持低對比留白」,但模型不一定每次都聽話。可讀性要靠自己保證,雙保險:

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

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

這樣不管背景多花,字底下永遠是深色,白字、淺色字一定讀得到。

4. fitText:二分搜尋找出塞得下的最大字級

九宮格每格的文字長度差異很大(「英文」兩個字 vs「睡前聽英文 podcast」九個字),固定字級必爆版。做法是用二分搜尋找「換行後仍塞得進格子的最大字級」:

function fitText(measureFactory, text, opts) {
  const { maxWidth, maxHeight, maxFontSize, minFontSize = 14, lineHeight = 1.32 } = opts;
  const tryFit = (fs) => {
    const measure = measureFactory(fs);          // 該字級下的量寬函式
    const lines = wrapCJK(text, maxWidth, measure); // 先斷行(見下一步)
    const heightOk = lines.length * fs * lineHeight <= maxHeight;
    const widthOk = lines.every((l) => measure(l) <= maxWidth + 0.01);
    return heightOk && widthOk ? lines : null;
  };
  let lo = minFontSize;
  let hi = Math.max(minFontSize, Math.floor(maxFontSize));
  let 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; }
  }
  return best || { fontSize: minFontSize, lines: wrapCJK(text, maxWidth, measureFactory(minFontSize)) };
}

量字寬用 canvas 自己的 measureText,但量之前一定要先把 ctx.font 設成要量的字級,不然量出來的是上一次的字寬:

const factory = (fs) => {
  ctx.font = `500 ${fs}px 'Noto Sans TC', 'Microsoft JhengHei', sans-serif`;
  return (s) => ctx.measureText(s).width;
};
const { fontSize, lines } = fitText(factory, '睡前聽英文 podcast', {
  maxWidth: cellW - pad * 2, maxHeight: cellH - pad * 2, maxFontSize: cellW * 0.15,
});

5. 中文斷行:逐字可斷,英數段不拆

中文沒有空格,canvas 也沒有自動換行,得自己斷。原則是:中文字逐字都可以斷,但英數連續段(160kmTOEICpodcast)是一個不可拆的單位,放不下就整段移到下一行——拆成 TOE / IC 就不是人話了。

function wrapCJK(text, maxWidth, measure) {
  const lines = [];
  let line = '';
  const push = (unit) => {
    const next = line + unit;
    if (line && measure(next) > maxWidth) { lines.push(line); line = unit; }
    else { line = next; }
  };
  // 英數連續段視為不可拆單位;其餘逐字
  const units = String(text).match(/[A-Za-z0-9]+|[\s\S]/gu) || [];
  for (const unit of units) {
    if (unit === '\n') { lines.push(line); line = ''; continue; }
    if (unit.length > 1 && measure(unit) > maxWidth) {
      for (const ch of unit) push(ch); // 整段比格子還寬才退回逐字硬拆
    } else {
      push(unit);
    }
  }
  lines.push(line);
  return lines.length ? lines : [''];
}

關鍵在那行正規表達式:/[A-Za-z0-9]+|[\s\S]/gu 把文字切成「英數段」與「單一字元」兩種單位,後面就用同一套邏輯排。

6. 配色慣例:中央格最強、八色各自呼應

網路上流傳的大谷翔平目標表轉載圖有一套大家看慣的配色語言,我們沿用它:

// 顏色來自風格預設(styles-presets.js):每個風格帶 accent + sub[8]
const palette = presetById(styleId).palette;

// 位置 i(0-8,跳過中央 4)對應第幾個子目標
const subIndexOf = (pos) => (pos < 4 ? pos : pos - 1);

// hex 轉帶透明度的 rgba
function hexA(hex, alpha) {
  const n = parseInt(hex.slice(1), 16);
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}

// 中央格:fill = hexA(palette.accent, 0.85),邊框 palette.accent 3px,白字 700
// 子目標格 i:fill = CELL_BG,左邊條 palette.sub[subIndexOf(i)] 4px,米白字 500

7. 輸出 PNG

合成完直接從 canvas 匯出檔案,全程不經過任何伺服器:

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');

給 AI 的 prompt 範本

貼給 ChatGPT 或 Claude,可重現本章成品(單檔、雙擊能開):

做一個單檔 HTML(CSS/JS 全內嵌)的「九宮格桌布合成器」:

【輸入】九個文字框(3×3 排列,中央是核心目標),加一個「上傳背景圖」
檔案選擇器(沒上傳就用深藍到金色的 linearGradient 漸層當背景)。

【畫布】直式 1170×2532 / 橫式 1920×1080,一組切換鈕。canvas 的
width/height 設成輸出畫素,預覽用 CSS 縮小顯示(max-width: 100%)。

【合成順序】
1. 背景圖 cover 縮放鋪滿(模仿 background-size: cover:取
   max(dstW/srcW, dstH/srcH) 的縮放比,置中裁切),用 drawImage 的
   九參數形式畫。
2. 疊一層 rgba(8,10,16,0.3) 全幅暗化遮罩。
3. 畫 3×3 九宮格:格子總寬 = min(0.86*畫布寬, 0.52*畫布高),格間距 =
   格寬*0.06,圓角 = 格寬*0.08,直式置中於 44% 高、橫式置中。
4. 底部置中一行半透明小字「目標九宮格」。

【格子配色】中央格:金色 #c9a44e 85% 透明度當底 + 3px 金色邊框 + 白色
粗體字;其餘八格:rgba(12,16,24,0.58) 格底 + 各自一條 4px 彩色左邊條
(八格八色,在深色底上要可讀)。

【文字排版】每格文字要自動縮放字級:用二分搜尋找「斷行後寬高都塞得進
格子」的最大字級,最小 14px,行高 1.32。斷行規則:中文逐字可斷,
連續英數段(如 160km、TOEIC)視為不可拆單位整段換行。量字寬用
ctx.measureText,量之前先設好 ctx.font。字型 'Noto Sans TC' 加系統
fallback。

【輸出】「下載 PNG」按鈕用 canvas.toBlob 下載。

【驗收】
1. 某格填「睡前聽英文 podcast 三十分鐘」:不溢出格子、podcast 沒被拆半。
2. 上傳一張直的照片配橫式畫布:鋪滿、置中裁切、不變形。
3. 上傳一張很亮的圖:每格文字仍清楚可讀。
4. 下載的 PNG 實際尺寸是 1170×2532(或 1920×1080)。

常見坑

  1. 還是想叫 AI 畫字:短英文偶爾成功,會讓人誤以為中文也行。記住判準:錯的位置每次不一樣 = 無法驗收 = 不能上線。
  2. 用 CSS 思維設 canvas 尺寸:canvas.width 是輸出畫素、CSS 的 width 只是顯示大小,兩者各管各的。只設 CSS 會得到一張 300×150 的糊圖。
  3. 忘了遮罩,字看不清:別依賴生圖 prompt 的「中央留白」約束,模型不一定聽話。全幅遮罩 + 格底是自己掌握的保證。
  4. 英文單字被攔腰折斷:純逐字斷行會把 podcast 拆成 pod / cast。英數連續段要當一個單位處理。
  5. measureText 量錯寬:量之前沒先設 ctx.font,量到的是上一個字級的寬度,版面就會時好時壞。
  6. 跨網域圖片污染 canvas:直接 drawImage 一張外站圖,沒過 CORS 的話 toBlob 會丟 SecurityError。成品的做法是把生成圖先 fetch 回來變 Blob、存進 IndexedDB,再 createImageBitmap 來畫——順便解掉生成圖網址七天過期的問題。
  7. toBlob 是非同步的:結果在 callback 裡,別寫成同步取值。

對照成品