444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
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("<br>")} |`
|
|
);
|
|
}
|
|
|
|
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();
|