188 lines
8.3 KiB
HTML
188 lines
8.3 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>海报素材像素审计(canvas 非透明像素 + 原始/下载逐像素对比)</title>
|
||
<style>
|
||
body{font-family:monospace;font-size:12px;margin:8px;background:#111;color:#eee}
|
||
.src{margin:6px 0;padding:6px;border:1px solid #333;border-radius:4px}
|
||
.src b{color:#7ee787}.src .bad{color:#ff7b72}.src .ok{color:#7ee787}
|
||
h1{font-size:16px}h2{font-size:14px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>海报素材像素审计</h1>
|
||
<p>支持参数:<code>?only=源1,源2</code> 定向审计;<code>?max=N</code> 每源最多素材数;<code>?size=N</code> 渲染尺寸;<code>?compare=1</code> 开启「原始 vs 下载」逐像素对比。</p>
|
||
<div id="out"><p>加载中…</p></div>
|
||
|
||
<script src="js/data/poster-sources.js?v=20260823e"></script>
|
||
<script src="js/data/poster-builtin.js?v=20260823e"></script>
|
||
<script src="js/core/posterlib.js?v=20260823e"></script>
|
||
|
||
<script>
|
||
/** 渲染 svg 字符串 → 像素统计 {blank, alphaSum, data, width, height} */
|
||
function svgToCanvas(svgStr, viewBox, size) {
|
||
return new Promise(resolve => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const c = document.createElement('canvas');
|
||
c.width = size; c.height = size;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = 'rgba(0,0,0,0)';
|
||
ctx.fillRect(0,0,size,size);
|
||
ctx.drawImage(img, 0, 0, size, size);
|
||
let data;
|
||
try { data = ctx.getImageData(0,0,size,size).data; }
|
||
catch(e){ resolve({blank:false, err:'getImageData受限'}); return; }
|
||
let alphaSum = 0;
|
||
for (let i = 3; i < data.length; i += 4) alphaSum += data[i];
|
||
resolve({ blank: alphaSum === 0, alphaSum, data });
|
||
};
|
||
img.onerror = () => resolve({ blank: false, err: 'SVG加载失败' });
|
||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);
|
||
});
|
||
}
|
||
|
||
/** 位图(PNG/JPG)→ 像素统计 */
|
||
function imgToCanvas(src, size) {
|
||
return new Promise(resolve => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => {
|
||
const c = document.createElement('canvas');
|
||
c.width = size; c.height = size;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = 'rgba(0,0,0,0)';
|
||
ctx.fillRect(0,0,size,size);
|
||
ctx.drawImage(img, 0, 0, size, size);
|
||
let data;
|
||
try { data = ctx.getImageData(0,0,size,size).data; }
|
||
catch(e){ resolve({blank:false, err:'getImageData受限', width:img.naturalWidth, height:img.naturalHeight}); return; }
|
||
let alphaSum = 0;
|
||
for (let i = 3; i < data.length; i += 4) alphaSum += data[i];
|
||
resolve({ blank: alphaSum === 0, alphaSum, data, width: img.naturalWidth, height: img.naturalHeight });
|
||
};
|
||
img.onerror = () => resolve({ blank: false, err: '位图加载失败' });
|
||
img.src = src;
|
||
});
|
||
}
|
||
|
||
/** 两张像素数据逐像素对比,返回差异率(0~1) */
|
||
function pixelDiff(a, b) {
|
||
if (!a || !b || a.length !== b.length) return 1;
|
||
let diff = 0, total = a.length / 4;
|
||
for (let i = 0; i < a.length; i += 4) {
|
||
if (Math.abs(a[i]-b[i]) + Math.abs(a[i+1]-b[i+1]) + Math.abs(a[i+2]-b[i+2]) + Math.abs(a[i+3]-b[i+3]) > 8) diff++;
|
||
}
|
||
return total ? diff / total : 0;
|
||
}
|
||
|
||
/** 由源配置重建原始 CDN URL(用于「原始 vs 下载」对比) */
|
||
function originalUrl(src, item) {
|
||
const name = item.name || String(item.id).split(':')[1];
|
||
const ext = (item.mime === 'image/jpeg') ? '.jpg' : (item.mime === 'image/png' ? '.png' : '.svg');
|
||
if (src.gh) {
|
||
const repo = src.gh + '@' + (src.ghBranch || 'master');
|
||
const dir = (src.dirs || [])[0] || '';
|
||
return `https://cdn.jsdelivr.net/gh/${repo}/${dir}/${name}${ext}`;
|
||
}
|
||
if (src.npm) {
|
||
const dir = (src.dirs || [])[0] || '';
|
||
return `https://unpkg.com/${src.npm}/${dir}/${name}${ext}`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function wrapSvg(body, viewBox) {
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox || '0 0 24 24'}">${body}</svg>`;
|
||
}
|
||
|
||
async function run() {
|
||
const out = document.getElementById('out');
|
||
const qs = new URLSearchParams(location.search);
|
||
const only = qs.get('only') ? qs.get('only').split(',').map(s => s.trim()) : null;
|
||
const max = parseInt(qs.get('max') || '300', 10);
|
||
const size = parseInt(qs.get('size') || '64', 10);
|
||
const compare = qs.get('compare') === '1';
|
||
|
||
const srcMap = {};
|
||
for (const s of (window.HGB_PosterSources || [])) srcMap[s.id] = s;
|
||
|
||
const builtin = window.HGB_PosterBuiltin || { sources: {} };
|
||
const ids = Object.keys(builtin.sources || {});
|
||
if (!ids.length) { out.innerHTML = '<p>⚠️ 暂无海报素材源(poster-builtin.js 为空),请先运行 node scripts/build-posters.js。</p>'; return; }
|
||
|
||
let totalItems = 0, totalBlank = 0, totalErr = 0, totalDiff = 0;
|
||
const rows = [];
|
||
|
||
for (const sid of ids) {
|
||
if (only && !only.includes(sid)) continue;
|
||
let data = null;
|
||
try {
|
||
data = await HGB_PosterLib.loadSource(sid);
|
||
} catch (e) { totalErr++; rows.push({ sid, error: '加载失败 ' + e.message }); continue; }
|
||
if (!data || !Array.isArray(data.items)) { totalErr++; rows.push({ sid, error: '无 items 数组' }); continue; }
|
||
|
||
const items = data.items.slice(0, max);
|
||
const src = srcMap[sid] || {};
|
||
let blank = 0, diffBad = 0;
|
||
const samples = [];
|
||
for (const it of items) {
|
||
totalItems++;
|
||
// 字体无像素可审(文件完整性已由构建端 sha256/bytes 校验),跳过渲染
|
||
if (src.type === 'font') continue;
|
||
const viewBox = it.viewBox || (it.width && it.height ? `0 0 ${it.width} ${it.height}` : '0 0 24 24');
|
||
let r;
|
||
if (it.svg) {
|
||
r = await svgToCanvas(wrapSvg(it.svg, viewBox), viewBox, size);
|
||
} else if (it.file) {
|
||
r = await imgToCanvas('js/data/' + it.file, size);
|
||
// 分辨率参数校验:渲染出的原始尺寸需与构建记录一致(防止下载损坏/错位)
|
||
if (!r.err && it.width && (r.width !== it.width || r.height !== it.height)) {
|
||
r.resMismatch = true;
|
||
}
|
||
} else {
|
||
// 配色 JSON / 字体:无像素,仅结构完整性校验
|
||
continue;
|
||
}
|
||
|
||
if (r.blank) { blank++; totalBlank++; if (samples.length < 12) samples.push(it.id + (r.err ? '[' + r.err + ']' : '')); }
|
||
if (r.resMismatch) { if (samples.length < 12) samples.push(it.id + '[分辨率不符 ' + r.width + 'x' + r.height + '≠' + it.width + 'x' + it.height + ']'); }
|
||
|
||
// 「原始 vs 下载」逐像素对比:重新拉取 CDN 原始文件渲染,与下载数据比差异率
|
||
// 注:transform 源(如 heropatterns JS 模板 → 静态 SVG)为有意格式转换,无 1:1 原始文件可对比,跳过
|
||
if (compare && it.svg && !src.transform) {
|
||
const orig = originalUrl(src, it);
|
||
if (orig) {
|
||
try {
|
||
const raw = await (await fetch(orig)).text();
|
||
if (!/<svg[\s>]/i.test(raw)) continue; // 404/错误页等非 SVG 响应,跳过
|
||
const body = raw.replace(/[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>[\s\S]*/, '');
|
||
const ro = await svgToCanvas(wrapSvg(body, viewBox), viewBox, size);
|
||
if (ro.data && r.data) {
|
||
const d = pixelDiff(r.data, ro.data);
|
||
if (d > 0.01) { diffBad++; totalDiff++; if (samples.length < 12) samples.push(it.id + '[差异率 ' + (d * 100).toFixed(2) + '%]'); }
|
||
}
|
||
} catch (e) { /* 原始拉取失败忽略(网络/CDN) */ }
|
||
}
|
||
}
|
||
}
|
||
rows.push({ sid, total: items.length, blank, diffBad, samples });
|
||
out.innerHTML = '<p>进度 ' + ids.indexOf(sid) + 1 + '/' + ids.length + ' …</p>';
|
||
}
|
||
|
||
let html = '<h2>扫描 ' + totalItems.toLocaleString() + ' 个,空白 ' + totalBlank.toLocaleString() + ',失败 ' + totalErr + (compare ? ',像素差异异常 ' + totalDiff : '') + '</h2>';
|
||
rows.sort((a, b) => b.blank - a.blank).forEach(r => {
|
||
if (r.error) html += '<div class="src">✗ ' + r.sid + ':' + r.error + '</div>';
|
||
else html += '<div class="src"><b>' + r.sid + '</b>:空白 <span class="' + (r.blank ? 'bad' : 'ok') + '">' + r.blank + '/' + r.total + '</span>' +
|
||
(r.diffBad ? ' · 像素差异异常 <span class="bad">' + r.diffBad + '</span>' : '') +
|
||
(r.samples.length ? '<br>' + r.samples.join('<br>') : '') + '</div>';
|
||
});
|
||
out.innerHTML = html;
|
||
document.title = 'POSTER-AUDIT-DONE ' + totalItems + '/' + totalBlank + '/' + totalErr;
|
||
}
|
||
run();
|
||
</script>
|
||
</body>
|
||
</html>
|