// 真实 Electron 下验证 nativeImage 的实际行为 (一次性校验脚本, 不是自测的一部分) // // 为什么要单独验: 自测里 electron 是**桩**, nativeImage 是假的。 // 这里用真的 Electron 跑, 确认: // 1. nativeImage 能解 JPEG / PNG // 2. resize 后 toPNG/toJPEG 的体积落在预期范围 // 3. **真照片** PNG 编码远大于 JPEG -> 所以缩略图优先 PNG、超限才退 JPEG // 4. **透明 PNG 转 JPEG 会丢透明** (alpha 全变 255, 透明区变黑) // -> 所以透明截图必须走 PNG, 这是"优先 PNG"那条规则的原因 // 5. clipboard.writeImage 真能写进系统剪贴板, 且写的是原图 // 6. 不存在/非图片的文件 -> 空图, 不抛异常 // // 用法: npm run selftest:image const { app, nativeImage, clipboard } = require('electron') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const zlib = require('node:zlib') // 生产代码本体 (esbuild 打的包, --external:electron -> 这里拿到的是**真** electron) const imageMod = require('./.out/image-native.cjs') const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lnm-native-')) let failed = false function check(name, cond, detail) { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`) if (!cond) failed = true } // ---- 手写 PNG 编码器 (够用即可: 8bit RGB / RGBA, filter=none) ---- let crcTable = null function crc32(buf) { if (!crcTable) { crcTable = new Int32Array(256) for (let n = 0; n < 256; n++) { let c = n for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 crcTable[n] = c } } let c = -1 for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8) return c ^ -1 } /** * @param {number} w * @param {number} h * @param {(x:number,y:number)=>[number,number,number,number]} pixel -> r,g,b,a */ function writePng(file, w, h, pixel) { // 逐行扫一遍, 看有没有半透明/全透明像素 —— 有就写 RGBA, 否则写 RGB let hasAlpha = false for (let y = 0; y < h && !hasAlpha; y++) { for (let x = 0; x < w; x++) { if (pixel(x, y)[3] < 255) { hasAlpha = true; break } } } const bpp = hasAlpha ? 4 : 3 const raw = Buffer.alloc((w * bpp + 1) * h) for (let y = 0; y < h; y++) { const rowStart = y * (w * bpp + 1) raw[rowStart] = 0 // filter: none for (let x = 0; x < w; x++) { const [r, g, b, a] = pixel(x, y) const o = rowStart + 1 + x * bpp raw[o] = r; raw[o + 1] = g; raw[o + 2] = b if (hasAlpha) raw[o + 3] = a } } const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length) const td = Buffer.concat([Buffer.from(type, 'ascii'), data]) const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td) >>> 0) return Buffer.concat([len, td, crc]) } const ihdr = Buffer.alloc(13) ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4) ihdr[8] = 8; ihdr[9] = hasAlpha ? 6 : 2 const png = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 0 })), chunk('IEND', Buffer.alloc(0)), ]) fs.writeFileSync(file, png) return png } app.whenReady().then(() => { // ---------- 1) 照片样图 (有噪声): PNG 编码远大于 JPEG ---------- const photoPath = path.join(dir, 'photo.png') let seed = 12345 const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff } const photoBytes = writePng(photoPath, 1200, 900, () => [ Math.floor(rnd() * 256), Math.floor(rnd() * 256), Math.floor(rnd() * 256), 255, ]) // 透明图 (下半区全透明) —— 用来验 "PNG 优先, 不转 JPEG" const alphaPath = path.join(dir, 'alpha.png') writePng(alphaPath, 400, 300, (x, y) => (y < 150 ? [255, 0, 0, 255] : [0, 255, 0, 0])) const img = nativeImage.createFromPath(photoPath) check('nativeImage 能解 PNG', !img.isEmpty()) const size = img.getSize() check('尺寸读得对', size.width === 1200 && size.height === 900, `${size.width}x${size.height}`) const small = img.resize({ width: 480, quality: 'good' }) const ss = small.getSize() check('resize 到 480 宽 (高按比例)', ss.width === 480 && ss.height === 360, `${ss.width}x${ss.height}`) const pngOut = small.toPNG() const jpegOut = small.toJPEG(80) console.log(` 照片缩略图体积: PNG=${pngOut.length}B JPEG=${jpegOut.length}B 源PNG=${photoBytes.length}B`) check('照片场景 JPEG 明显小于 PNG', jpegOut.length < pngOut.length / 2, `JPEG=${jpegOut.length}B PNG=${pngOut.length}B`) // ---------- 1b) 走**生产代码**的缩略图生成 (质量阶梯) ---------- // 纯噪声是最坏情况: q80 会超 60KB, 阶梯应该降质量/缩尺寸把它压回来 const thumb = imageMod.makeThumbFromFile(photoPath) check('生产代码能出缩略图', !!thumb && thumb.startsWith('data:image/'), (thumb || '').slice(0, 30)) if (thumb) { const bytes = Buffer.from(thumb.split(',')[1], 'base64').length console.log(` 生产缩略图: ${bytes}B (${thumb.slice(0, 22)})`) check('生产缩略图压到 60KB 内', bytes <= 60_000, `${bytes}B`) } // 透明 PNG: 生产代码必须走 PNG (保住透明), 不能转 JPEG const alphaThumb = imageMod.makeThumbFromFile(alphaPath) check('透明图走 PNG (保透明)', !!alphaThumb && alphaThumb.startsWith('data:image/png;base64,'), (alphaThumb || '').slice(0, 24)) // ---------- 2) 透明 PNG: 转 JPEG 会丢透明 (这就是"优先 PNG"的原因) ---------- const alphaImg = nativeImage.createFromPath(alphaPath) check('透明 PNG 能解', !alphaImg.isEmpty()) // toBitmap 恒为 BGRA (4 字节/像素), 不管原格式 const bmp = alphaImg.toBitmap() const bpp = 4 const px = (b, x, y) => { const o = (y * alphaImg.getSize().width + x) * bpp return { b: b[o], g: b[o + 1], r: b[o + 2], a: b[o + 3] } } check('PNG 位图里下半区是全透明 (alpha=0)', px(bmp, 200, 250).a === 0, `alpha=${px(bmp, 200, 250).a}`) check('PNG 位图里上半区不透明', px(bmp, 200, 50).a === 255, `alpha=${px(bmp, 200, 50).a}`) const alphaJpeg = alphaImg.toJPEG(80) const alphaBmp = alphaImg.toBitmap() // 再取一次 (原图) check('JPEG 编码后长度合理', alphaJpeg.length > 0) // 把 JPEG 解回来看 alpha 变成什么 const roundTrip = nativeImage.createFromBuffer(alphaJpeg) const rtBmp = roundTrip.toBitmap() const rtPx = px(rtBmp, 200, 250) check('透明区转 JPEG 后 alpha 变成不透明 (透明丢了)', rtPx.a === 255, `alpha=${rtPx.a}`) void alphaBmp // ---------- 3) data URL ---------- const durl = small.toDataURL() check('toDataURL 产出 data:image/png', durl.startsWith('data:image/png;base64,'), durl.slice(0, 30)) // base64 长度 = ceil(len/3)*4, 再加 "data:image/png;base64," 前缀 const expectLen = Math.ceil(pngOut.length / 3) * 4 + 'data:image/png;base64,'.length check('data URL 长度与 PNG 字节数吻合', durl.length === expectLen, `${durl.length} vs ${expectLen}`) // ---------- 4) 剪贴板: 写进去再读回来 ---------- clipboard.writeImage(img) const back = clipboard.readImage() check('剪贴板写入后可读回', !back.isEmpty()) const bs = back.getSize() check('剪贴板里是原图尺寸 (不是缩略图)', bs.width === 1200 && bs.height === 900, `${bs.width}x${bs.height}`) // ---------- 5) 坏输入: 空图而不是抛异常 ---------- const missing = nativeImage.createFromPath(path.join(dir, 'nope.png')) check('不存在的文件 -> 空图而不是抛异常', missing.isEmpty()) const txt = path.join(dir, 'a.txt') fs.writeFileSync(txt, 'hello') check('非图片 -> 空图', nativeImage.createFromPath(txt).isEmpty()) check('空 buffer -> 空图', nativeImage.createFromBuffer(Buffer.alloc(0)).isEmpty()) console.log(failed ? '\n有失败项' : '\n全部通过') app.exit(failed ? 1 : 0) })