canvasInkRects.mjs

/**
 * 畫布(canvas)目標之實際墨跡範圍:逐矩形讀其所在 canvas 之像素,回傳矩形內實際有墨跡之最小外接矩形(視窗座標)
 *
 * 圖表程式庫(echarts/zrender 等)回報之文字、圖例項矩形常比畫出之字形寬數 px(字寬量測與實際繪字之差、項目群組含互動熱區);
 * 以之交 inkRect 外擴留白或取與鄰項間隙之中點,框線會偏向一側而貼到鄰項(2026-09-28 w-web-perm 統計圖例項殷鑑:
 * 框線距自身文字 7px、貼下一項圖示 0px)。先以本函數收斂到實際墨跡再交 inkRect。
 * 被捲動窗裁掉一部分之項目只回其可見墨跡、完全未繪製者回 null——同一列之鄰項應取「畫面上有墨跡者」,
 * 不可只取完整顯示者(否則緊鄰之半顯示項不算鄰項,框線照常外擴而貼上去;同一殷鑑)。
 * canvas 預設以各矩形中心點命中測試取得(該點最上層之 canvas),亦可由 opt.canvas 指定(選擇器字串或 Locator)。
 * 墨跡判準:未繪製處須為透明(圖表未設不透明背景色),以 alpha 大於門檻判定;背景不透明者以 opt.bg 給背景色,改以色差判定。
 *
 * @param {Object} page 輸入Playwright Page
 * @param {Array} rects 輸入視窗座標矩形陣列,每個為{x,y,width,height}
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String|Object} [opt.canvas=null] 輸入canvas之選擇器字串或Locator(取第一個),預設null代表逐矩形以中心點命中測試取得
 * @param {Number} [opt.alpha=40] 輸入墨跡判準之alpha門檻(0~255),預設40
 * @param {Array} [opt.bg=null] 輸入不透明背景色[r,g,b],給定時改以「與背景之最大通道差 > 24」判定墨跡,預設null
 * @param {Boolean} [opt.trimY=false] 輸入是否亦收斂上下,預設false(只收左右,上下沿用原矩形:同一行字之高度不因字形而異)
 * @returns {Promise<Array>} 回傳與rects同序之矩形陣列,矩形內無墨跡、找不到canvas或canvas非2d者為null
 * @example
 *
 * import canvasInkRects from 'w-package-tools-e2e/src/canvasInkRects.mjs'
 *
 * let inks = await canvasInkRects(page, legend.items.map((o) => o.whole))
 * let target = inks[0]
 * let buf = await captureStableWithBox(page, inkRect(target, { neighbors: inks.slice(1).filter(Boolean) }))
 *
 */
async function canvasInkRects(page, rects, opt = {}) {
    let { canvas = null, alpha = 40, bg = null, trimY = false } = opt
    let isRect = (r) => !!r && ['x', 'y', 'width', 'height'].every((k) => typeof r[k] === 'number')
    if (!Array.isArray(rects) || !rects.every(isRect)) {
        throw new Error('canvasInkRects: rects 須為 {x,y,width,height} 之數值矩形陣列')
    }
    if (typeof alpha !== 'number' || !(alpha >= 0 && alpha <= 255)) {
        throw new Error('canvasInkRects: alpha 須為 0~255 之數字')
    }
    if (bg !== null && !(Array.isArray(bg) && bg.length === 3 && bg.every((v) => typeof v === 'number'))) {
        throw new Error('canvasInkRects: bg 須為 [r,g,b] 或 null')
    }
    let handle = null
    if (canvas) {
        let loc = typeof canvas === 'string' ? page.locator(canvas).first() : (typeof canvas.first === 'function' ? canvas.first() : canvas)
        if (await loc.count() === 0) {
            throw new Error('canvasInkRects: 找不到指定之 canvas') //不交 elementHandle 等待逾時
        }
        handle = await loc.elementHandle()
    }
    try {
        return await page.evaluate(({ rects, handle, alpha, bg, trimY }) => {
            let out = []
            for (let r of rects) {
                let cvs = handle
                if (!cvs) {
                    let cx = r.x + r.width / 2
                    let cy = r.y + r.height / 2
                    cvs = document.elementsFromPoint(cx, cy).find((e) => e.tagName === 'CANVAS') || null
                }
                let ctx = cvs ? cvs.getContext('2d') : null //已為 webgl 之 canvas 回 null
                if (!ctx) {
                    out.push(null)
                    continue
                }
                let cr = cvs.getBoundingClientRect()
                let sx = cvs.width / cr.width
                let sy = cvs.height / cr.height
                let x0 = Math.max(0, Math.floor((r.x - cr.left) * sx))
                let x1 = Math.min(cvs.width, Math.ceil((r.x + r.width - cr.left) * sx))
                let y0 = Math.max(0, Math.floor((r.y - cr.top) * sy))
                let y1 = Math.min(cvs.height, Math.ceil((r.y + r.height - cr.top) * sy))
                let w = x1 - x0
                let h = y1 - y0
                if (w <= 0 || h <= 0) {
                    out.push(null)
                    continue
                }
                let d = ctx.getImageData(x0, y0, w, h).data
                let minX = w
                let maxX = -1
                let minY = h
                let maxY = -1
                for (let y = 0; y < h; y++) {
                    for (let x = 0; x < w; x++) {
                        let i = (y * w + x) * 4
                        let ink = d[i + 3] > alpha && (!bg || Math.max(Math.abs(d[i] - bg[0]), Math.abs(d[i + 1] - bg[1]), Math.abs(d[i + 2] - bg[2])) > 24)
                        if (ink) {
                            if (x < minX) minX = x
                            if (x > maxX) maxX = x
                            if (y < minY) minY = y
                            if (y > maxY) maxY = y
                        }
                    }
                }
                if (maxX < 0) {
                    out.push(null)
                    continue
                }
                out.push({
                    x: cr.left + (x0 + minX) / sx,
                    y: trimY ? cr.top + (y0 + minY) / sy : r.y,
                    width: (maxX - minX + 1) / sx,
                    height: trimY ? (maxY - minY + 1) / sy : r.height,
                })
            }
            return out
        }, { rects, handle, alpha, bg, trimY })
    }
    finally {
        if (handle) {
            await handle.dispose()
        }
    }
}


export default canvasInkRects