import fs from 'fs'
import path from 'path'
import pixelmatch from 'pixelmatch'
import { PNG } from 'pngjs'
/**
* 逐檔比對兩個目錄內同名之PNG:位元組是否相同、解碼後RGBA是否相同、反鋸齒感知之像素差異數
*
* 用途:行為等價證明——把新管線之產製輸出導到暫存目錄(dirA),與現行標準圖目錄(dirB)逐檔比對,不動標準圖本身。
* 判準分層:bytesEqual(檔案位元組相同,受PNG編碼器與版本影響) ⊂ rgbaEqual(像素完全相同) ⊂ withinTolerance(pixelmatch差異數≤maxDiffPixels)。
* 等價層(2026-09-28):equivalent=rgbaEqual,或RGBA相異之像素全數落在opt.drift登錄之漂移點(座標+各通道差上限)內——
* 不以「pixelmatch計數0」為等價:threshold 0.1時整片均勻亮度差在約26級以內之像素一律不計,會掩蓋肉眼可見之全域變化。
* 各檔另報rgbaDiff(RGBA相異像素數)與rgbaDiffBox(其外接框),供歸因。
* 以dirA之檔案為準逐一比對;只存在於dirB者列於onlyInB(部分產製時屬正常,由呼叫端判斷)
*
* @param {String} dirA 輸入受檢目錄字串(例如產製輸出之暫存目錄)
* @param {String} dirB 輸入對照目錄字串(例如現行標準圖目錄)
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {RegExp} [opt.pattern=/\.png$/i] 輸入納入之檔名樣式,預設/\.png$/i
* @param {Number} [opt.threshold=0.1] 輸入pixelmatch之色差門檻,預設0.1
* @param {Number} [opt.maxDiffPixels=100] 輸入容許之真不同像素數,預設100
* @param {Array} [opt.drift=[]] 輸入登錄漂移點陣列[{x,y,maxDelta,files}],x,y為像素座標、maxDelta為各通道差上限、files為適用檔名之RegExp(省略則全部),預設[]
* @returns {Object} 回傳物件{files,onlyInB,summary},files為各檔結果陣列[{file,status,bytesEqual,rgbaEqual,rgbaDiff,rgbaDiffBox,equivalent,numDiff,withinTolerance}],status為'ok'、'missingInB'或'sizeMismatch'
* @example
*
* import compareImageDirs from 'w-package-tools-e2e/src/compareImageDirs.mjs'
*
* let r = compareImageDirs('./test/_tmp/regen-out', './test/pics/tokens')
* console.log(r.summary)
* // => { total: 50, bytesEqual: 50, rgbaEqual: 50, equivalent: 50, withinTolerance: 50, exceeded: 0, missingInB: 0, sizeMismatch: 0, onlyInB: 0 }
*
*/
function compareImageDirs(dirA, dirB, opt = {}) {
let { pattern = /\.png$/i, threshold = 0.1, maxDiffPixels = 100, drift = [] } = opt
let list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => pattern.test(f)).sort() : [])
let listA = list(dirA)
let setA = new Set(listA)
let onlyInB = list(dirB).filter((f) => !setA.has(f))
//RGBA 相異像素數、外接框, 以及是否全數落在登錄漂移點內
let rgbaDiffOf = (file, a, b) => {
let w = a.width
let pts = drift.filter((p) => !p.files || p.files.test(file))
let n = 0
let unexplained = 0
let x0 = Infinity
let y0 = Infinity
let x1 = -1
let y1 = -1
for (let i = 0; i < a.data.length; i += 4) {
if (a.data[i] === b.data[i] && a.data[i + 1] === b.data[i + 1] && a.data[i + 2] === b.data[i + 2] && a.data[i + 3] === b.data[i + 3]) {
continue
}
n++
let px = (i / 4) % w
let py = Math.floor(i / 4 / w)
x0 = Math.min(x0, px)
y0 = Math.min(y0, py)
x1 = Math.max(x1, px)
y1 = Math.max(y1, py)
let d = Math.max(Math.abs(a.data[i] - b.data[i]), Math.abs(a.data[i + 1] - b.data[i + 1]), Math.abs(a.data[i + 2] - b.data[i + 2]), Math.abs(a.data[i + 3] - b.data[i + 3]))
if (!pts.some((p) => p.x === px && p.y === py && d <= p.maxDelta)) {
unexplained++
}
}
return { rgbaDiff: n, rgbaDiffBox: n > 0 ? { x0, y0, x1, y1 } : null, onlyDrift: n > 0 && unexplained === 0 }
}
let files = listA.map((file) => {
let pb = path.join(dirB, file)
if (!fs.existsSync(pb)) {
return { file, status: 'missingInB', bytesEqual: false, rgbaEqual: false, rgbaDiff: null, rgbaDiffBox: null, equivalent: false, numDiff: null, withinTolerance: false }
}
let bufA = fs.readFileSync(path.join(dirA, file))
let bufB = fs.readFileSync(pb)
if (bufA.equals(bufB)) {
return { file, status: 'ok', bytesEqual: true, rgbaEqual: true, rgbaDiff: 0, rgbaDiffBox: null, equivalent: true, numDiff: 0, withinTolerance: true }
}
let pngA = PNG.sync.read(bufA)
let pngB = PNG.sync.read(bufB)
if (pngA.width !== pngB.width || pngA.height !== pngB.height) {
return { file, status: 'sizeMismatch', bytesEqual: false, rgbaEqual: false, rgbaDiff: null, rgbaDiffBox: null, equivalent: false, numDiff: null, withinTolerance: false }
}
let rgbaEqual = pngA.data.equals(pngB.data)
let rd = rgbaEqual ? { rgbaDiff: 0, rgbaDiffBox: null, onlyDrift: false } : rgbaDiffOf(file, pngA, pngB)
let numDiff = rgbaEqual ? 0 : pixelmatch(pngA.data, pngB.data, null, pngA.width, pngA.height, { threshold, includeAA: false })
return { file, status: 'ok', bytesEqual: false, rgbaEqual, rgbaDiff: rd.rgbaDiff, rgbaDiffBox: rd.rgbaDiffBox, equivalent: rgbaEqual || rd.onlyDrift, numDiff, withinTolerance: numDiff <= maxDiffPixels }
})
let count = (fn) => files.filter(fn).length
let summary = {
total: files.length,
bytesEqual: count((r) => r.bytesEqual),
rgbaEqual: count((r) => r.rgbaEqual),
equivalent: count((r) => r.equivalent),
withinTolerance: count((r) => r.withinTolerance),
exceeded: count((r) => r.status === 'ok' && !r.withinTolerance),
missingInB: count((r) => r.status === 'missingInB'),
sizeMismatch: count((r) => r.status === 'sizeMismatch'),
onlyInB: onlyInB.length,
}
return { files, onlyInB, summary }
}
export default compareImageDirs