前三天分別解決了「找得到元件」、「畫面內容穩定」與「時機和範圍都正確」,到這邊已經可以穩定拿到一張乾淨的截圖了。今天要在這張截圖上動手:畫框、標號,讓讀者一眼就知道該看畫面上的哪個位置。
使用手冊的靈魂,某種程度上就是「圖上標了 ①②③」。一張沒有標註的截圖,讀者要自己猜「說的是哪個按鈕」;一張標註清楚的截圖,配合旁邊的文字說明,讀者幾乎不用思考就知道該點哪裡。這件事做得好不好,某種程度上就是一份手冊看起來專不專業的分水嶺。

要在截圖上畫框、標號,大致有三條路可以走:
影像處理庫 (sharp、canvas 之類)
直接對截圖的像素資料動手,用程式庫畫矩形、畫文字。問題是中文字型的載入與渲染要自己處理、文字寬度要自己算才不會溢出框外,而且多了一層原生相依 (native binding),在不同作業系統、CI 環境上的安裝與相容性都要額外花力氣。
後製工具
用 Photoshop 一類的工具人工後製。這條路直接出局,它不可自動化,跟這整條產線「改完 UI 跑一個指令」的目標完全相反。
注入 DOM 疊層再截圖 (推薦)
不直接對圖片的像素動手,而是在按下快門之前,先在頁面上疊一層用 HTML / CSS 畫出來的框與圓標,截圖時連同這層一起拍下去。沒有原生相依、樣式用 CSS 調整比程式化畫圖直觀得多,而且中文字型直接吃瀏覽器本身的渲染引擎,完全不用管字型載入。
三條路裡,DOM 疊層是最務實的選擇。它把「畫框」這件事,從「影像處理問題」轉換成「網頁排版問題」,而網頁排版正好是瀏覽器最擅長的事。
整個流程是:先用 boundingBox() 算出目標元件的位置與尺寸 → 注入一層 position: fixed 加上 pointer-events: none 的 overlay 容器 → 在這層 overlay 裡畫出框與圓標 → 截圖 → 截完之後把疊層移除,還原畫面。
座標在 Node 這邊算好再丟進去,page.evaluate() 裡面就只剩下「照著座標畫方框」這件事:
async function annotate(page: Page, targets: Target[]) {
// 先在 Node 這邊把每個目標解析成畫面座標
const items = await resolve(page, targets)
await page.evaluate((items) => {
const overlay = document.createElement('div')
overlay.id = '__manual-overlay'
overlay.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:999999;'
for (const t of items) {
const pad = 4
const box = document.createElement('div')
box.style.cssText = `
position:absolute;
left:${t.x - pad}px; top:${t.y - pad}px;
width:${t.width + pad * 2}px; height:${t.height + pad * 2}px;
border:3px solid #ff3b30; border-radius:6px;
box-shadow:0 0 0 2px rgba(255,255,255,0.9);
`
const badge = document.createElement('div')
badge.textContent = t.label
badge.style.cssText = `
position:absolute; left:-14px; top:-14px;
width:28px; height:28px; border-radius:50%;
background:#ff3b30; color:#fff;
display:flex; align-items:center; justify-content:center;
font:bold 14px/1 system-ui, sans-serif;
box-shadow:0 0 0 2px rgba(255,255,255,0.9);
`
box.appendChild(badge)
overlay.appendChild(box)
}
document.body.appendChild(overlay)
}, items)
}
截完之後記得把疊層清掉,不然下一張截圖會連同上一張的框一起拍進去:
await page.screenshot({ path: 'output/day11/steps.png' })
await page.evaluate(() => document.getElementById('__manual-overlay')?.remove())
對著範例 App 跑一次,標上「①搜尋攝影機 → ②雙擊清單裡的 Lobby-01 → ③畫面填入左上角的格子」,產出大概是這樣:
await annotate(page, [
{ selectors: ['[data-testid="camera-search"]'], label: 1 },
{ selectors: ['[data-testid="camera-row_lobby-01"]'], label: 2 },
{ selectors: ['[data-testid="grid-cell_1"]'], label: 3 },
])

這幾個數字看起來很瑣碎,但它們直接決定產出看起來像不像一回事:
box-shadow:0 0 0 2px rgba(255,255,255,0.9)),兩種背景下都看得出來。deviceScaleFactor 一起看。pad),讓框「圈住」元件而不是「貼著」元件。annotate 的輸入設計成三種形式,對應三種實際會遇到的情境:
單一 selector:標註單一元件,最常用的情況。
await page.getByTestId('camera-edit_gate-a').click()
await page.getByTestId('camera-dialog').waitFor({ state: 'visible' })
await annotate(page, [
{ selectors: ['[data-testid="camera-dialog-name"]'], label: 1 },
{ selectors: ['[data-testid="camera-dialog-enabled"]'], label: 2 },
{ selectors: ['[data-testid="camera-dialog-confirm"]'], label: 3 },
])

selector 陣列:把多個目標合併成一個涵蓋全部的大框,適合「這一整排都是同一件事」的情境。範例 App 的版面切換是 grid-layout_1x1 到 _4x4 四顆獨立的按鈕,但在手冊裡它們就是「版面切換」這一個功能,沒必要拆成四個標號:
await annotate(page, [
{
selectors: [
'[data-testid="grid-layout_1x1"]',
'[data-testid="grid-layout_2x2"]',
'[data-testid="grid-layout_3x3"]',
'[data-testid="grid-layout_4x4"]',
],
label: 1,
},
])
實作上就是取所有 boundingBox() 的聯集 (最小的 x、最小的 y、最大的右界與下界)。

手動座標:當目標不是一個明確的 DOM 元件時使用,例如要標註 canvas 上自己畫的一塊區域。這種情況下就只能直接給 { x, y, width, height }。
另外,建議在產線裡順手保留一份未標註的版本。除錯的時候 (確認框有沒有畫歪)、審稿的時候 (確認標號有沒有對錯元件),兩張圖並排看會省下很多時間。
App 自己的 modal backdrop、Toast 容器這類浮層,通常也會用偏高的 z-index。如果疊層的 z-index 沒設得夠高,畫出來的框反而會被 App 的浮層蓋住,截圖裡完全看不到。
範例 App 的對話框遮罩是 z-index: 100,第二層的確認框是 110,Toast 更高,是 200。把疊層的 z-index 設成 10 再截同一張對話框,結果是這樣:

程式沒有報錯、annotate 也確實執行了,但產出看起來就跟沒標註完全一樣,這種問題如果沒有人工審稿很容易一路混進成品裡。解法很單純:把疊層的 z-index 設成一個刻意誇張的值 (例如前面的 999999),確保它一定在最上層。
pointer-events: none 不能漏position: fixed 加上 inset: 0 的疊層,等於用一張透明的布蓋住整個畫面。如果忘了加 pointer-events: none,後續每一個操作都會點在這塊布上,而不是真正的元件。
有些 App 的全域樣式表 (尤其是寫得比較激進的 CSS reset) 會連帶影響到疊層元素,讓框線、圓標的呈現跟預期不一樣。
上面的寫法全部用內聯樣式,就是為了把特異性 (specificity) 拉高,減少被全域樣式蓋掉的機會。如果連內聯樣式都擋不住 (例如對方用了 !important),就要改用 Shadow DOM,把整個疊層隔離在 App 的樣式作用域之外。
看一下前面對話框那張圖,③ 這個圓標其實壓到了旁邊的「取消」按鈕。目前的擺放邏輯是死的,一律放在框的左上角外側,所以只要目標旁邊剛好有東西,圓標就會蓋住它。
而這正好是明天的主題,會討論圓標擺放與碰撞避讓。