// ==UserScript== // @name 图片裁剪压缩工具 // @namespace local.image.crop.compress // @version 1.0.0 // @description 处理本地图片:可查看当前宽高/文件大小,按指定像素裁剪或缩放(等比/居中裁剪/拉伸),按质量或目标文件大小压缩。支持 PNG/JPEG/JPG/ICO/GIF/WebP/BMP/AVIF,可拖入或粘贴截图。 // @author faymi // @license MIT // @match *://*/* // @match file:///* // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; // ---------------- 基础工具 ---------------- const PREFIX = 'qic'; const sleep = ms => new Promise(r => setTimeout(r, ms)); function fmtSize(bytes) { if (!bytes || bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0, v = Number(bytes); while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; } function extOf(name) { const m = /\.([a-zA-Z0-9]+)$/.exec(name || ''); return m ? m[1].toLowerCase() : ''; } function stemOf(name) { const i = (name || '').lastIndexOf('.'); return i > 0 ? name.slice(0, i) : (name || 'file'); } function safeName(name) { return String(name).replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').trim() || 'image'; } function fileLooksImage(file) { if (file && file.type && file.type.startsWith('image/')) return true; const exts = ['png', 'jpg', 'jpeg', 'ico', 'gif', 'webp', 'bmp', 'avif', 'svg']; return exts.includes(extOf(file && file.name)); } function toMime(fmt) { return { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' }[fmt] || null; } function mimeToExt(mime) { if (mime === 'image/jpeg') return 'jpg'; if (mime === 'image/webp') return 'webp'; return 'png'; } function canvasToBlob(canvas, type, quality) { return new Promise((resolve, reject) => { canvas.toBlob(b => (b ? resolve(b) : reject(new Error('浏览器编码失败,可能不支持该输出格式'))), type, quality); }); } function supportsCanvasType(type) { try { const c = document.createElement('canvas'); return c.toDataURL(type).indexOf(`data:${type}`) === 0; } catch (_) { return false; } } // ---------------- 状态 ---------------- const state = { items: [], // { fid, file, name, size, w, h, imgUrl, img, out, rowEls } seq: 0, panelOpen: false, }; let els = {}; let qualityTouched = false; // 用户是否主动调整过质量(默认 85 视为未设置) // ---------------- 图片解码 ---------------- async function ensureImage(item) { if (item.img && item.img.complete && item.img.naturalWidth) return item.img; if (!item.imgUrl) item.imgUrl = URL.createObjectURL(item.file); const img = new Image(); img.decoding = 'async'; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = () => reject(new Error('无法解码该图片(格式可能不受浏览器支持)')); img.src = item.imgUrl; }); item.img = img; return img; } async function measure(item) { const img = await ensureImage(item); item.w = img.naturalWidth; item.h = img.naturalHeight; return img; } // ---------------- 几何计算 ---------------- // mode: 'none' | 'fit' | 'cover' | 'stretch' // 返回 { ow, oh, sx, sy, sw, sh, changed },changed=false 表示无需改变像素尺寸 function computeGeom(mode, pos, sw, sh, rawW, rawH) { const tw = Math.max(0, parseInt(rawW, 10) || 0); const th = Math.max(0, parseInt(rawH, 10) || 0); if (!tw && !th) return { ow: sw, oh: sh, sx: 0, sy: 0, sw: sw, sh: sh, changed: false }; let w = tw, h = th; if (w && !h) h = Math.max(1, Math.round(sh * w / sw)); if (h && !w) w = Math.max(1, Math.round(sw * h / sh)); if (mode === 'none') return { ow: sw, oh: sh, sx: 0, sy: 0, sw: sw, sh: sh, changed: false }; if (mode === 'stretch') { return { ow: w, oh: h, sx: 0, sy: 0, sw: sw, sh: sh, changed: true }; } if (mode === 'fit') { const s = Math.min(w / sw, h / sh); const ow = Math.max(1, Math.round(sw * s)); const oh = Math.max(1, Math.round(sh * s)); const changed = ow !== sw || oh !== sh; return { ow, oh, sx: 0, sy: 0, sw, sh, changed }; } // cover:等比放大/缩小后居中(或按位置)裁剪,输出恰好 w×h let srw = sw, srh = sh; const targetRatio = w / h; const srcRatio = sw / sh; if (srcRatio > targetRatio) { srh = sh; srw = Math.round(sh * targetRatio); } else { srw = sw; srh = Math.round(sw / targetRatio); } const marginX = sw - srw; const marginY = sh - srh; let sx, sy; if (pos === 'left') { sx = 0; } else if (pos === 'right') { sx = marginX; } else if (pos === 'top') { sx = marginX / 2; } else if (pos === 'bottom') { sx = marginX / 2; } else { sx = marginX / 2; } if (pos === 'top') { sy = 0; } else if (pos === 'bottom') { sy = marginY; } else if (pos === 'left' || pos === 'right') { sy = marginY / 2; } else { sy = marginY / 2; } sx = Math.round(sx); sy = Math.round(sy); const changed = w !== sw || h !== sh; return { ow: w, oh: h, sx, sy, sw: srw, sh: srh, changed }; } // ---------------- 单张处理 ---------------- async function processItem(item, opts) { const img = await ensureImage(item); const sw = img.naturalWidth, sh = img.naturalHeight; const geom = computeGeom(opts.mode, opts.pos, sw, sh, opts.width, opts.height); const srcMime = item.file.type || ''; const srcExt = extOf(item.file.name); const fmt = opts.format; // 'source'|'png'|'jpeg'|'webp' const wantTranscode = fmt !== 'source'; const isGif = srcExt === 'gif' || srcMime === 'image/gif'; const isIco = srcExt === 'ico' || /icon/i.test(srcMime); const isBmp = srcExt === 'bmp' || srcMime === 'image/bmp'; const isLossySource = srcMime === 'image/jpeg' || srcMime === 'image/webp'; const passthruRaw = isGif || isIco || isBmp || srcMime === 'image/avif'; // 目标输出格式(浏览器 canvas 只能编码 png/jpeg/webp) let type; if (wantTranscode) { type = toMime(fmt); } else if (isLossySource) { type = srcMime; // JPEG/WebP:可按质量重编码 } else if (passthruRaw) { type = 'image/png'; // 需要变换时 GIF/ICO/BMP/AVIF 转静态 PNG } else { type = srcMime || 'image/png'; } if (!['image/png', 'image/jpeg', 'image/webp'].includes(type)) type = 'image/png'; if (type === 'image/webp' && !supportsCanvasType('image/webp')) type = 'image/png'; // 未做任何改动时:GIF 动图/ICO/BMP/AVIF/PNG 原样保留; // JPEG/WebP 仅在用户主动调低质量时重编码(即为“压缩”),否则也原样保留。 const untouched = !geom.changed && !opts.maxBytes && fmt === 'source'; if (untouched) { const canPassthru = passthruRaw || srcMime === 'image/png' || (isLossySource && (!opts.qualityTouched || opts.quality >= 100)); if (canPassthru) { let warn = ''; if (isGif && opts.qualityTouched && opts.quality < 100) { warn = 'GIF 动图已原样保留,无法按质量压缩;如需压缩请输出 JPEG/WebP(将转为静态图)'; } else if (srcMime === 'image/png' && opts.qualityTouched && opts.quality < 100) { warn = 'PNG 为无损格式,质量参数不生效;如需压缩请改尺寸或输出 JPEG/WebP'; } return { blob: item.file, w: sw, h: sh, type: srcMime || 'application/octet-stream', changed: false, warn, name: item.name, }; } } let canvasType = type; if (opts.maxBytes > 0 && !['image/jpeg', 'image/webp'].includes(canvasType)) { canvasType = supportsCanvasType('image/webp') ? 'image/webp' : 'image/jpeg'; } if (geom.ow * geom.oh > 100000000) { throw new Error(`输出尺寸过大(${geom.ow}×${geom.oh}),请减小目标宽高`); } const canvas = document.createElement('canvas'); canvas.width = geom.ow; canvas.height = geom.oh; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('无法创建画布'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; if (canvasType === 'image/jpeg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); } if (geom.changed) { ctx.drawImage(img, geom.sx, geom.sy, geom.sw, geom.sh, 0, 0, geom.ow, geom.oh); } else { ctx.drawImage(img, 0, 0); } const quality = (opts.quality != null ? opts.quality : 90) / 100; let blob; let warn = ''; if (opts.maxBytes > 0 && ['image/jpeg', 'image/webp'].includes(canvasType)) { let lo = 5, hi = 96, best = null; for (let i = 0; i < 9; i++) { const q = Math.max(5, Math.min(95, Math.round((lo + hi) / 2))); const b = await canvasToBlob(canvas, canvasType, q / 100); if (b.size <= opts.maxBytes) { best = b; lo = q + 1; } else hi = q - 1; } blob = best || await canvasToBlob(canvas, canvasType, 0.05); if (blob.size > opts.maxBytes) warn = `已尽力压缩,仍为 ${fmtSize(blob.size)}(>${Math.round(opts.maxBytes / 1024)}KB)`; if (canvasType !== type) warn = (warn ? warn + ';' : '') + `已自动转 ${mimeToExt(canvasType).toUpperCase()} 以满足大小`; } else { blob = await canvasToBlob(canvas, canvasType, quality); } const changed = geom.changed || wantTranscode || opts.maxBytes > 0 || (isLossySource && fmt === 'source' && opts.qualityTouched && opts.quality < 100); const outName = `${stemOf(item.file.name)}_${canvas.width}x${canvas.height}.${mimeToExt(canvasType)}`; return { blob, w: canvas.width, h: canvas.height, type: canvasType, changed, warn, name: outName }; } // ---------------- UI ---------------- function css() { const s = document.createElement('style'); s.id = `${PREFIX}-style`; s.textContent = ` #${PREFIX}-toggle{position:fixed;right:20px;bottom:24px;z-index:2147483647;width:46px;height:46px;border-radius:50%;background:#1677ff;color:#fff;font-size:22px;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.35);user-select:none;transition:transform .15s;font-family:Segoe UI,Microsoft YaHei,sans-serif;} #${PREFIX}-toggle:hover{transform:scale(1.08);} #${PREFIX}-panel{position:fixed;right:20px;bottom:82px;z-index:2147483646;width:470px;max-width:calc(100vw - 36px);max-height:82vh;background:#202124;border:1px solid #3c4043;border-radius:12px;color:#e8eaed;font-family:Segoe UI,Microsoft YaHei,sans-serif;font-size:13px;display:none;flex-direction:column;box-shadow:0 8px 28px rgba(0,0,0,.6);overflow:hidden;} .${PREFIX}-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#292b2f;border-bottom:1px solid #3c4043;} .${PREFIX}-close{cursor:pointer;color:#9aa0a6;font-size:16px;padding:0 6px;} .${PREFIX}-close:hover{color:#fff;} .${PREFIX}-toolbar{display:flex;gap:6px;padding:8px 10px;border-bottom:1px solid #3c4043;flex-wrap:wrap;align-items:center;} .${PREFIX}-btn{padding:4px 10px;border:1px solid #5f6368;border-radius:6px;background:#292b2f;color:#e8eaed;font-size:12px;cursor:pointer;white-space:nowrap;} .${PREFIX}-btn:hover{background:#3c4043;} .${PREFIX}-btn:disabled{opacity:.5;cursor:not-allowed;} .${PREFIX}-btn.primary{background:#1677ff;border-color:#1677ff;} .${PREFIX}-btn.primary:hover{background:#3c8cff;} .${PREFIX}-list{flex:1;overflow-y:auto;min-height:120px;max-height:36vh;padding:4px 6px;} .${PREFIX}-empty{color:#9aa0a6;text-align:center;padding:18px 10px;font-size:12px;line-height:1.8;border:1px dashed #3c4043;border-radius:8px;margin:8px 6px;} .${PREFIX}-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #33353a;border-radius:6px;} .${PREFIX}-row:hover{background:#2b2d31;} .${PREFIX}-row.selected{background:#1e2a3a;} .${PREFIX}-thumb{width:44px;height:44px;object-fit:contain;background:#17181a;border-radius:6px;flex:none;border:1px solid #3c4043;} .${PREFIX}-info{flex:1;min-width:0;} .${PREFIX}-name{font-size:12px;color:#e8eaed;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .${PREFIX}-meta{font-size:11px;color:#9aa0a6;word-break:break-all;line-height:1.5;} .${PREFIX}-meta .ok{color:#7bd88f;} .${PREFIX}-meta .err{color:#f28b82;} .${PREFIX}-meta .warn{color:#f5c26b;} .${PREFIX}-ops{display:flex;flex-direction:column;gap:4px;flex:none;} .${PREFIX}-mini{font-size:11px;padding:1px 6px;} .${PREFIX}-settings{padding:10px 12px;border-top:1px solid #3c4043;background:#1c1d1f;overflow-y:auto;max-height:34vh;} .${PREFIX}-field{display:flex;align-items:center;gap:6px;margin:5px 0;flex-wrap:wrap;} .${PREFIX}-field label{color:#c8ccd1;font-size:12px;min-width:72px;flex:none;} .${PREFIX}-input, .${PREFIX}-select{background:#292b2f;border:1px solid #5f6368;color:#e8eaed;border-radius:6px;padding:4px 7px;font-size:12px;outline:none;} .${PREFIX}-input:focus,.${PREFIX}-select:focus{border-color:#1677ff;} .${PREFIX}-hint{font-size:11px;color:#9aa0a6;margin:3px 0;} .${PREFIX}-current{color:#8ab4f8;font-size:12px;} .${PREFIX}-log{max-height:90px;overflow-y:auto;padding:6px 12px;border-top:1px solid #3c4043;background:#1b1c1e;font-size:11px;color:#9aa0a6;} .${PREFIX}-range{width:130px;} `; document.head.appendChild(s); } function buildUI() { css(); const toggle = document.createElement('div'); toggle.id = `${PREFIX}-toggle`; toggle.textContent = '🖼'; toggle.title = '图片裁剪压缩工具'; const panel = document.createElement('div'); panel.id = `${PREFIX}-panel`; panel.innerHTML = `