import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { deflateSync, inflateSync } from "node:zlib"; const designDir = path.resolve("docs/design/images2"); const implementationDir = path.resolve("docs/design/implementation-screenshots"); const outputDir = path.resolve("docs/design/visual-diff"); const threshold = 24; const gridColumns = 12; const gridRows = 8; const pageNames = [ "dispatcher-01-center-dashboard.png", "dispatcher-02-issue-pool.png", "dispatcher-03-department-queue.png", "dispatcher-04-escalation-center.png", "dispatcher-05-knowledge-review.png", "dispatcher-06-rule-config.png", "dispatcher-07-analytics-board.png", "client-01-workbench.png", "client-02-submit-issue.png", "client-03-issue-detail-handler.png", "client-04-submitter-tracking.png", "client-05-mobile-submit.png" ]; function main() { mkdirSync(outputDir, { recursive: true }); const availableNames = pageNames.filter((fileName) => existsSync(path.join(designDir, fileName)) && existsSync(path.join(implementationDir, fileName))); const missingNames = pageNames.filter((fileName) => !availableNames.includes(fileName)); const results = availableNames.map((fileName) => comparePage(fileName)); writeReport(results, missingNames); const failed = results.filter((result) => result.status === "needs-polish").length; const review = results.filter((result) => result.status === "review").length; console.log(`Visual diff complete. Compared ${results.length} pages. ${failed} need polish, ${review} need review.`); console.log(`Report: ${path.relative(process.cwd(), path.join(outputDir, "report.md"))}`); } function comparePage(fileName) { const designPath = path.join(designDir, fileName); const implementationPath = path.join(implementationDir, fileName); const expected = readPng(designPath); let actual = readPng(implementationPath); const resized = expected.width !== actual.width || expected.height !== actual.height; if (resized) { actual = resizeImage(actual, expected.width, expected.height); } const diff = Buffer.alloc(expected.width * expected.height * 4); const grid = createGrid(); let changedPixels = 0; let deltaTotal = 0; let deltaSquaredTotal = 0; let maxDelta = 0; for (let y = 0; y < expected.height; y += 1) { for (let x = 0; x < expected.width; x += 1) { const offset = (y * expected.width + x) * 4; const dr = expected.data[offset] - actual.data[offset]; const dg = expected.data[offset + 1] - actual.data[offset + 1]; const db = expected.data[offset + 2] - actual.data[offset + 2]; const delta = Math.sqrt((dr * dr + dg * dg + db * db) / 3); const changed = delta > threshold; if (changed) changedPixels += 1; deltaTotal += delta; deltaSquaredTotal += delta * delta; if (delta > maxDelta) maxDelta = delta; const cell = gridCellFor(x, y, expected.width, expected.height); grid[cell].deltaTotal += delta; grid[cell].changedPixels += changed ? 1 : 0; grid[cell].pixels += 1; writeDiffPixel(diff, offset, actual.data, delta, changed); } } const pixelCount = expected.width * expected.height; const meanDelta = deltaTotal / pixelCount; const rmsDelta = Math.sqrt(deltaSquaredTotal / pixelCount); const changedRatio = changedPixels / pixelCount; const diffFileName = fileName.replace(/\.png$/, "-diff.png"); writePng(path.join(outputDir, diffFileName), { width: expected.width, height: expected.height, data: diff }); return { fileName, width: expected.width, height: expected.height, resized, meanDelta, rmsDelta, changedRatio, maxDelta, status: classify(meanDelta, changedRatio), diffFileName, hotspots: topHotspots(grid) }; } function readPng(filePath) { const bytes = readFileSync(filePath); const signature = "89504e470d0a1a0a"; if (bytes.subarray(0, 8).toString("hex") !== signature) { throw new Error(`Not a PNG file: ${filePath}`); } let offset = 8; let header; const idatChunks = []; while (offset < bytes.length) { const length = bytes.readUInt32BE(offset); const type = bytes.subarray(offset + 4, offset + 8).toString("ascii"); const data = bytes.subarray(offset + 8, offset + 8 + length); offset += 12 + length; if (type === "IHDR") { header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4), bitDepth: data[8], colorType: data[9], compression: data[10], filter: data[11], interlace: data[12] }; } else if (type === "IDAT") { idatChunks.push(data); } else if (type === "IEND") { break; } } if (!header) throw new Error(`PNG is missing IHDR: ${filePath}`); if (header.bitDepth !== 8 || (header.colorType !== 2 && header.colorType !== 6)) { throw new Error(`Unsupported PNG format in ${filePath}. Expected 8-bit RGB/RGBA, got bit=${header.bitDepth}, color=${header.colorType}.`); } if (header.compression !== 0 || header.filter !== 0 || header.interlace !== 0) { throw new Error(`Unsupported PNG encoding in ${filePath}. Interlaced images are not supported.`); } const channels = header.colorType === 6 ? 4 : 3; const stride = header.width * channels; const raw = inflateSync(Buffer.concat(idatChunks)); const pixels = Buffer.alloc(header.width * header.height * 4); let inputOffset = 0; let previous = Buffer.alloc(stride); for (let y = 0; y < header.height; y += 1) { const filterType = raw[inputOffset]; inputOffset += 1; const encoded = raw.subarray(inputOffset, inputOffset + stride); inputOffset += stride; const decoded = unfilterScanline(encoded, previous, filterType, channels); for (let x = 0; x < header.width; x += 1) { const source = x * channels; const target = (y * header.width + x) * 4; pixels[target] = decoded[source]; pixels[target + 1] = decoded[source + 1]; pixels[target + 2] = decoded[source + 2]; pixels[target + 3] = channels === 4 ? decoded[source + 3] : 255; } previous = decoded; } return { width: header.width, height: header.height, data: pixels }; } function unfilterScanline(encoded, previous, filterType, bytesPerPixel) { const decoded = Buffer.alloc(encoded.length); for (let index = 0; index < encoded.length; index += 1) { const left = index >= bytesPerPixel ? decoded[index - bytesPerPixel] : 0; const up = previous[index] || 0; const upLeft = index >= bytesPerPixel ? previous[index - bytesPerPixel] || 0 : 0; let value; switch (filterType) { case 0: value = encoded[index]; break; case 1: value = encoded[index] + left; break; case 2: value = encoded[index] + up; break; case 3: value = encoded[index] + Math.floor((left + up) / 2); break; case 4: value = encoded[index] + paethPredictor(left, up, upLeft); break; default: throw new Error(`Unsupported PNG filter type: ${filterType}`); } decoded[index] = value & 255; } return decoded; } function paethPredictor(left, up, upLeft) { const estimate = left + up - upLeft; const leftDistance = Math.abs(estimate - left); const upDistance = Math.abs(estimate - up); const upLeftDistance = Math.abs(estimate - upLeft); if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left; if (upDistance <= upLeftDistance) return up; return upLeft; } function writePng(filePath, image) { const rowLength = image.width * 4; const raw = Buffer.alloc((rowLength + 1) * image.height); let rawOffset = 0; for (let y = 0; y < image.height; y += 1) { raw[rawOffset] = 0; rawOffset += 1; image.data.copy(raw, rawOffset, y * rowLength, y * rowLength + rowLength); rawOffset += rowLength; } const chunks = [ pngChunk("IHDR", createIhdr(image.width, image.height)), pngChunk("IDAT", deflateSync(raw)), pngChunk("IEND", Buffer.alloc(0)) ]; writeFileSync(filePath, Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), ...chunks])); } function createIhdr(width, height) { const data = Buffer.alloc(13); data.writeUInt32BE(width, 0); data.writeUInt32BE(height, 4); data[8] = 8; data[9] = 6; data[10] = 0; data[11] = 0; data[12] = 0; return data; } function pngChunk(type, data) { const typeBytes = Buffer.from(type, "ascii"); const length = Buffer.alloc(4); length.writeUInt32BE(data.length, 0); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 0); return Buffer.concat([length, typeBytes, data, crc]); } const crcTable = Array.from({ length: 256 }, (_, index) => { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } return value >>> 0; }); function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) { crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } function resizeImage(image, targetWidth, targetHeight) { const output = Buffer.alloc(targetWidth * targetHeight * 4); const xScale = image.width / targetWidth; const yScale = image.height / targetHeight; for (let y = 0; y < targetHeight; y += 1) { const sourceY = (y + 0.5) * yScale - 0.5; const y0 = clamp(Math.floor(sourceY), 0, image.height - 1); const y1 = clamp(y0 + 1, 0, image.height - 1); const yMix = sourceY - y0; for (let x = 0; x < targetWidth; x += 1) { const sourceX = (x + 0.5) * xScale - 0.5; const x0 = clamp(Math.floor(sourceX), 0, image.width - 1); const x1 = clamp(x0 + 1, 0, image.width - 1); const xMix = sourceX - x0; const target = (y * targetWidth + x) * 4; for (let channel = 0; channel < 4; channel += 1) { const a = image.data[(y0 * image.width + x0) * 4 + channel]; const b = image.data[(y0 * image.width + x1) * 4 + channel]; const c = image.data[(y1 * image.width + x0) * 4 + channel]; const d = image.data[(y1 * image.width + x1) * 4 + channel]; const top = a + (b - a) * xMix; const bottom = c + (d - c) * xMix; output[target + channel] = clamp(Math.round(top + (bottom - top) * yMix), 0, 255); } } } return { width: targetWidth, height: targetHeight, data: output }; } function writeDiffPixel(diff, offset, actualData, delta, changed) { if (changed) { diff[offset] = 255; diff[offset + 1] = clamp(Math.round(220 - delta), 20, 190); diff[offset + 2] = clamp(Math.round(80 + delta), 120, 255); diff[offset + 3] = 255; return; } const gray = Math.round(actualData[offset] * 0.299 + actualData[offset + 1] * 0.587 + actualData[offset + 2] * 0.114); diff[offset] = gray; diff[offset + 1] = gray; diff[offset + 2] = gray; diff[offset + 3] = 255; } function createGrid() { return Array.from({ length: gridColumns * gridRows }, () => ({ deltaTotal: 0, changedPixels: 0, pixels: 0 })); } function gridCellFor(x, y, width, height) { const column = Math.min(gridColumns - 1, Math.floor((x / width) * gridColumns)); const row = Math.min(gridRows - 1, Math.floor((y / height) * gridRows)); return row * gridColumns + column; } function topHotspots(grid) { return grid .map((cell, index) => ({ index, row: Math.floor(index / gridColumns) + 1, column: (index % gridColumns) + 1, meanDelta: cell.deltaTotal / Math.max(1, cell.pixels), changedRatio: cell.changedPixels / Math.max(1, cell.pixels) })) .sort((a, b) => b.meanDelta - a.meanDelta) .slice(0, 3) .map((cell) => `${zoneLabel(cell.column, cell.row)} ${formatNumber(cell.meanDelta)} / ${formatPercent(cell.changedRatio)}`); } function zoneLabel(column, row) { const horizontal = column <= 4 ? "left" : column <= 8 ? "center" : "right"; const vertical = row <= 3 ? "top" : row <= 5 ? "middle" : "bottom"; return `${vertical}-${horizontal}`; } function classify(meanDelta, changedRatio) { if (meanDelta <= 8 && changedRatio <= 0.1) return "close"; if (meanDelta <= 18 && changedRatio <= 0.25) return "review"; return "needs-polish"; } function writeReport(results, missingNames) { const lines = [ "# Issue Hub AI 视觉差异报告", "", `生成时间:${new Date().toISOString()}`, "", "## 方法", "", `- 对比对象:\`docs/design/images2/\` 高保真设计图 vs \`docs/design/implementation-screenshots/\` 当前实现截图。`, `- 差异阈值:单像素 RGB 均方差大于 ${threshold} 记为变更像素。`, "- 输出:每页一张高亮差异图,灰色代表低差异区域,粉色代表明显差异区域。", "- 用途:这是视觉打磨诊断工具,不替代人工设计评审;AI 生成稿中的文案、图标细节和截图动态数据会天然造成差异。", "", "## 汇总", "", `- 对比页面:${results.length} 张。`, `- 接近设计稿:${results.filter((item) => item.status === "close").length} 张。`, `- 需要人工复核:${results.filter((item) => item.status === "review").length} 张。`, `- 需要继续视觉打磨:${results.filter((item) => item.status === "needs-polish").length} 张。`, "", "| 页面 | 尺寸 | 状态 | 平均差异 | RMS | 变更像素 | 最大差异 | 差异图 | 热点区域 |", "| --- | --- | --- | ---: | ---: | ---: | ---: | --- | --- |" ]; for (const result of results) { lines.push( `| ${result.fileName} | ${result.width}x${result.height}${result.resized ? " resized" : ""} | ${statusLabel(result.status)} | ${formatNumber(result.meanDelta)} | ${formatNumber(result.rmsDelta)} | ${formatPercent(result.changedRatio)} | ${formatNumber(result.maxDelta)} | [diff](${result.diffFileName}) | ${result.hotspots.join("
")} |` ); } if (missingNames.length > 0) { lines.push("", "## 缺失文件", ""); for (const fileName of missingNames) { lines.push(`- ${fileName}`); } } lines.push( "", "## 判读建议", "", "- 优先处理 `needs-polish` 页面中热点集中在 top-left/top-center 的差异,这通常对应侧栏、顶部工具区和首屏结构。", "- 如果热点集中在 right-middle/right-bottom,优先核对右侧检查器、AI 建议面板、操作区密度和滚动状态。", "- 如果热点集中在 center-middle,优先核对表格密度、卡片边界、图表形态和主要工作区信息层级。", "- 当平均差异下降但变更像素仍高,通常说明整体布局接近但文字、图标、间距和局部状态还需要人工修正。" ); writeFileSync(path.join(outputDir, "report.md"), `${lines.join("\n")}\n`); } function statusLabel(status) { if (status === "close") return "接近"; if (status === "review") return "复核"; return "需打磨"; } function formatNumber(value) { return value.toFixed(2); } function formatPercent(value) { return `${(value * 100).toFixed(1)}%`; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } main();