Appearance
实现 ImageMetaExtractor 工具
分类:developer | 标签:EXIF、元数据、GPS、PNG、隐私、工具 在浏览器本地读取与写入图片的隐藏信息(EXIF/IPTC/XMP 元数据、GPS 地理位置、PNG 文本块),支持高德地图查看与选点修改 GPS,全程本地处理不上传服务器
2.1 功能说明
在浏览器本地读取与写入图片的隐藏信息(EXIF/IPTC/XMP 元数据、GPS 地理位置、PNG 文本块),支持高德地图查看与选点修改 GPS,全程本地处理不上传服务器
核心能力
- 元信息解析(只读)
- 元数据
- GPS 位置
- 纬度
- 经度
- 海拔
- 坐标系
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
本工具目录 ImageMetaExtractor/ 下除 index.vue 外,还包含以下源文件:
exifr-lite.js(2 行)metaParser.js(479 行)metaStripper.js(97 行)
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
goBack()clearAll()pickFile()onFileChange()onDrop()onPaste()decodeFile()copy()done()fallbackCopy()copyAll()exportJson()openMap()exportClean()handleWindowPaste()escapeHtml()syntaxHighlight()pngDisplay()defaultForm()humanSize()readAsDataUrl()dataUrlToBlob()saveBlob()isJpegFile()cleanName()pickBatch()onBatchChange()onBatchDrop()removeBatch()clearBatch()addBatchFiles()cleanBatch()pickSingle()onSingleChange()dmsToDeg()val()prefillForm()resetForm()formatTagValue()toggleAllTags()selectGpsOnly()cloneExif()normalizeExif()insertAndDownload()eraseSelected()eraseAllExif()writeExif()qCellStyle()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
UploadFilled,
Loading,
DocumentCopy,
Download,
Location,
Delete,
MagicStick
} from '@element-plus/icons-vue'
import { analyzeImage, isGpsKey } from './metaParser.js'
import { stripJpegMetadata, stripPngMetadata } from './metaStripper.js'
import piexif from '@/vendor/piexif/index.js'
import JSZip from '@/vendor/jszip/index.js'
const router = useRouter()
const pageTab = ref('read')
const fileInput = ref(null)
const dragover = ref(false)
const loading = ref(false)
const errorMsg = ref('')
const result = ref(null)
const activeTab = ref('meta')
const dimensions = ref(null)
const previewUrl = ref('')
const fileSize = ref(0)
const currentBuf = ref(null)
const fileTypeLabels = {
jpeg: 'JPEG',
png: 'PNG',
gif: 'GIF',
webp: 'WEBP',
bmp: 'BMP',
unknown: '未知格式'
}
const fileTypeLabel = computed(() => fileTypeLabels[result.value?.fileType] || '未知格式')
const metaEntries = computed(() =>
(result.value?.exifEntries || []).filter((e) => !isGpsKey(e.key))
)
const hasMeta = computed(() => metaEntries.value.length > 0)
const fileSizeLabel = computed(() => {
const s = fileSize.value
if (!s) return '—'
if (s < 1024) return s + ' B'
if (s < 1024 * 1024) return (s / 1024).toFixed(1) + ' KB'
return (s / 1024 / 1024).toFixed(2) + ' MB'
})
function goBack() {
router.back()
}
function clearAll() {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
result.value = null
dimensions.value = null
errorMsg.value = ''
fileSize.value = 0
currentBuf.value = null
activeTab.value = 'meta'
loading.value = false
}
function pickFile() {
fileInput.value?.click()
}
function onFileChange(e) {
const file = e.target.files && e.target.files[0]
if (file) decodeFile(file)
e.target.value = ''
}
function onDrop(e) {
dragover.value = false
const file = e.dataTransfer?.files?.[0]
if (file) decodeFile(file)
}
function onPaste(e) {
const items = e.clipboardData?.items
if (!items) return
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const file = it.getAsFile()
if (file) decodeFile(file)
return
}
}
}
async function decodeFile(file) {
errorMsg.value = ''
result.value = null
dimensions.value = null
previewUrl.value = ''
fileSize.value = file.size || 0
loading.value = true
const reader = new FileReader()
reader.onload = async () => {
try {
const buf = reader.result
currentBuf.value = buf
const res = await analyzeImage(buf, file.name)
const mime = file.type || 'image/*'
const blob = new Blob([buf], { type: mime })
// 回收上一张预览的 object URL(避免内存泄漏),但当前这张要保留给模板中的 <img> 持续使用
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
const url = URL.createObjectURL(blob)
previewUrl.value = url
// 仅用于读取尺寸,不要再 revoke——否则模板里的 <img> 会加载失败
const img = new Image()
img.onload = () => {
dimensions.value = { w: img.naturalWidth, h: img.naturalHeight }
}
img.src = url
result.value = res
loading.value = false
if (!res.exifEntries.length && !res.gps && !res.pngText.length) {
ElMessage.info('未在该图片中发现可提取的隐藏元信息')
}
} catch (err) {
console.error('图片元信息解析异常:', err)
errorMsg.value = '图片解析失败,请确认文件为有效的图片格式。'
loading.value = false
}
}
reader.onerror = () => {
errorMsg.value = '文件读取失败,请重试。'
loading.value = false
}
reader.readAsArrayBuffer(file)
}
function copy(text) {
if (!text) return
const done = () => ElMessage.success('已复制到剪贴板')
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(String(text)).then(done).catch(fallbackCopy)
} else {
fallbackCopy()
}
function fallbackCopy() {
const ta = document.createElement('textarea')
ta.value = String(text)
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
done()
}
}
function copyAll() {
if (!result.value) return
const lines = []
for (const e of metaEntries.value) lines.push(`${e.label}:${e.value}`)
if (result.value.gps) {
lines.push(`纬度:${result.value.gps.lat.toFixed(6)}`)
lines.push(`经度:${result.value.gps.lng.toFixed(6)}`)
}
for (const t of result.value.pngText) {
lines.push(`[PNG:${t.type}] ${t.keyword}:${t.text}`)
}
copy(lines.join('\n'))
}
function exportJson() {
if (!result.value) return
const data = {
fileName: result.value.fileName,
fileType: result.value.fileType,
dimensions: dimensions.value,
exif: result.value.exif,
gps: result.value.gps,
pngText: result.value.pngText,
warnings: result.value.warnings
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = (result.value.fileName || 'metadata') + '.meta.json'
a.click()
URL.revokeObjectURL(a.href)
}
function openMap(url) {
if (!url) return
window.open(url, '_blank', 'noopener')
}
// 清除元数据并导出:剥离 EXIF/GPS(或 PNG 文本块) 后下载干净副本,并就地刷新预览
async function exportClean() {
if (!currentBuf.value || !result.value) return
const ft = result.value.fileType
let blob
try {
if (ft === 'jpeg') blob = stripJpegMetadata(currentBuf.value)
else if (ft === 'png') blob = stripPngMetadata(currentBuf.value)
else {
ElMessage.warning('当前格式暂不支持清除元数据(仅支持 JPG / PNG)')
return
}
} catch (e) {
ElMessage.error('清除失败:' + e.message)
return
}
// 下载干净副本
const a = document.createElement('a')
const objUrl = URL.createObjectURL(blob)
const base = (result.value.fileName || 'image').replace(/\.[^.]+$/, '')
a.href = objUrl
a.download = base + '-clean.' + (ft === 'jpeg' ? 'jpg' : 'png')
a.click()
URL.revokeObjectURL(objUrl)
// 就地刷新预览与解析结果,直观证明元数据已清除
const cleanedBuf = await blob.arrayBuffer()
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
const url = URL.createObjectURL(blob)
previewUrl.value = url
const img = new Image()
img.onload = () => {
dimensions.value = { w: img.naturalWidth, h: img.naturalHeight }
}
img.src = url
const res = await analyzeImage(cleanedBuf, result.value.fileName)
currentBuf.value = cleanedBuf
result.value = res
activeTab.value = 'meta'
ElMessage.success('已清除元数据,预览已更新,并导出文件')
}
function handleWindowPaste(e) {
onPaste(e)
}
onMounted(() => window.addEventListener('paste', handleWindowPaste))
onBeforeUnmount(() => {
window.removeEventListener('paste', handleWindowPaste)
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
// ---------- PNG 文本块:JSON 格式化 + 语法高亮 ----------
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
}
function syntaxHighlight(jsonStr) {
// jsonStr 已是合法且格式化后的 JSON 文本
const escaped = escapeHtml(jsonStr)
return escaped.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match) => {
let cls = 'json-num'
if (/^"/.test(match)) {
cls = /:$/.test(match) ? 'json-key' : 'json-str'
} else if (/true|false/.test(match)) {
cls = 'json-bool'
} else if (/null/.test(match)) {
cls = 'json-null'
}
return `<span class="${cls}">${match}</span>`
}
)
}
// 返回 { isJson, html }:JSON 则格式化并高亮,否则原样转义
function pngDisplay(t) {
const text = t.text || ''
const trimmed = text.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
const formatted = JSON.stringify(parsed, null, 2)
return { isJson: true, html: syntaxHighlight(formatted) }
} catch (e) {
// 形如 JSON 但解析失败,按普通文本处理
}
}
return { isJson: false, html: escapeHtml(text) }
}
/* ==================== EXIF 写入 / 批量清除 ==================== */
const IFD_KEYS = ['0th', 'Exif', 'GPS', '1st']
const ifdLabels = {
'0th': '主图信息(0th IFD)',
Exif: '拍摄参数(Exif IFD)',
GPS: 'GPS 定位(GPS IFD)',
'1st': '缩略图信息(1st IFD)'
}
const batchInput = ref(null)
const batchDragover = ref(false)
const batchFiles = ref([])
const batchRunning = ref(false)
const batchProgress = ref(0)
const singleInput = ref(null)
const singleFile = ref(null)
const singleDataUrl = ref('')
const singleExif = ref(null)
const singleError = ref('')
const editTab = ref('erase')
const selectedTags = ref([])
const defaultForm = () => ({
copyright: '',
artist: '',
description: '',
software: '',
make: '',
model: '',
dateTimeOriginal: '',
gpsOn: false,
lat: 0,
lng: 0,
altitude: 0,
mode: 'merge'
})
const form = ref(defaultForm())
function humanSize(s) {
if (!s) return '0 B'
if (s < 1024) return s + ' B'
if (s < 1024 * 1024) return (s / 1024).toFixed(1) + ' KB'
return (s / 1024 / 1024).toFixed(2) + ' MB'
}
function readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = () => reject(new Error('文件读取失败'))
r.readAsDataURL(file)
})
}
function dataUrlToBlob(dataUrl) {
const comma = dataUrl.indexOf(',')
const head = dataUrl.slice(0, comma)
const body = dataUrl.slice(comma + 1)
const mimeMatch = /:(.*?);/.exec(head)
const mime = mimeMatch ? mimeMatch[1] : 'image/jpeg'
const bin = atob(body)
const u8 = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i)
return new Blob([u8], { type: mime })
}
function saveBlob(blob, name) {
const objUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objUrl
a.download = name
a.click()
URL.revokeObjectURL(objUrl)
}
function isJpegFile(f) {
return /^image\/jpe?g$/i.test(f.type) || /\.jpe?g$/i.test(f.name)
}
function cleanName(name, suffix, ext) {
return name.replace(/\.[^.]+$/, '') + suffix + (ext || '.jpg')
}
/* ---------- 批量清除 ---------- */
function pickBatch() {
batchInput.value?.click()
}
function onBatchChange(e) {
addBatchFiles(Array.from(e.target.files || []))
e.target.value = ''
}
function onBatchDrop(e) {
batchDragover.value = false
addBatchFiles(Array.from(e.dataTransfer?.files || []))
}
function removeBatch(i) {
batchFiles.value.splice(i, 1)
}
function clearBatch() {
batchFiles.value = []
batchProgress.value = 0
}
async function addBatchFiles(list) {
const jpegs = list.filter(isJpegFile)
const skipped = list.length - jpegs.length
if (skipped > 0) ElMessage.warning(`已忽略 ${skipped} 个非 JPEG 文件`)
if (!jpegs.length) return
for (const f of jpegs) {
const row = { file: f, name: f.name, size: f.size, status: 'scanning', tagCount: 0, hasGps: false, error: '' }
batchFiles.value.push(row)
// 预检测:统计该文件的 EXIF 字段数与是否含 GPS
try {
const dataUrl = await readAsDataUrl(f)
const obj = piexif.load(dataUrl)
let n = 0
for (const k of IFD_KEYS) n += obj[k] ? Object.keys(obj[k]).length : 0
row.tagCount = n
row.hasGps = !!(obj.GPS && Object.keys(obj.GPS).length)
} catch (err) {
row.error = '非标准 JPEG'
}
row.status = 'done'
}
}
async function cleanBatch(asZip) {
const rows = batchFiles.value.filter((r) => !r.error)
if (!rows.length) {
ElMessage.warning('没有可处理的 JPEG 文件')
return
}
batchRunning.value = true
batchProgress.value = 0
try {
const zip = asZip ? new JSZip() : null
for (let i = 0; i < rows.length; i++) {
const r = rows[i]
const dataUrl = await readAsDataUrl(r.file)
const cleaned = piexif.remove(dataUrl)
const blob = dataUrlToBlob(cleaned)
const outName = cleanName(r.name, '-clean')
if (zip) {
zip.file(outName, blob)
} else {
saveBlob(blob, outName)
await new Promise((res) => setTimeout(res, 120))
}
batchProgress.value = Math.round(((i + 1) / rows.length) * 100)
}
if (zip) {
const out = await zip.generateAsync({ type: 'blob' })
saveBlob(out, `exif-cleaned-${rows.length}files.zip`)
}
ElMessage.success(`已清除 ${rows.length} 张图片的 EXIF`)
} catch (e) {
ElMessage.error('批量清除失败:' + (e && e.message ? e.message : String(e)))
} finally {
batchRunning.value = false
}
}
/* ---------- 单张:读取 ---------- */
function pickSingle() {
singleInput.value?.click()
}
async function onSingleChange(e) {
const f = (e.target.files || [])[0]
e.target.value = ''
if (!f) return
singleError.value = ''
selectedTags.value = []
if (!isJpegFile(f)) {
singleFile.value = null
singleError.value = '仅支持 JPEG 格式(.jpg / .jpeg)'
return
}
try {
const dataUrl = await readAsDataUrl(f)
const obj = piexif.load(dataUrl)
singleFile.value = f
singleDataUrl.value = dataUrl
singleExif.value = obj
prefillForm(obj)
} catch (err) {
singleFile.value = null
singleExif.value = null
singleError.value = '读取 EXIF 失败:' + (err && err.message ? err.message : String(err))
}
}
function dmsToDeg(dms, ref) {
if (!Array.isArray(dms) || dms.length < 3) return 0
const val = (p) => (Array.isArray(p) && p[1] ? p[0] / p[1] : 0)
const deg = val(dms[0]) + val(dms[1]) / 60 + val(dms[2]) / 3600
return ref === 'S' || ref === 'W' ? -deg : deg
}
// 载入图片后,用已有 EXIF 预填写入表单,方便在原值上修改
function prefillForm(obj) {
const f = defaultForm()
const zeroth = obj['0th'] || {}
const exif = obj.Exif || {}
const gps = obj.GPS || {}
f.copyright = zeroth[piexif.ImageIFD.Copyright] || ''
f.artist = zeroth[piexif.ImageIFD.Artist] || ''
f.description = zeroth[piexif.ImageIFD.ImageDescription] || ''
f.software = zeroth[piexif.ImageIFD.Software] || ''
f.make = zeroth[piexif.ImageIFD.Make] || ''
f.model = zeroth[piexif.ImageIFD.Model] || ''
f.dateTimeOriginal = exif[piexif.ExifIFD.DateTimeOriginal] || ''
if (gps[piexif.GPSIFD.GPSLatitude] && gps[piexif.GPSIFD.GPSLongitude]) {
f.gpsOn = true
f.lat = Number(dmsToDeg(gps[piexif.GPSIFD.GPSLatitude], gps[piexif.GPSIFD.GPSLatitudeRef]).toFixed(6))
f.lng = Number(dmsToDeg(gps[piexif.GPSIFD.GPSLongitude], gps[piexif.GPSIFD.GPSLongitudeRef]).toFixed(6))
const alt = gps[piexif.GPSIFD.GPSAltitude]
if (Array.isArray(alt) && alt[1]) {
f.altitude = Number((alt[0] / alt[1]).toFixed(1)) * (gps[piexif.GPSIFD.GPSAltitudeRef] === 1 ? -1 : 1)
}
}
form.value = f
}
function resetForm() {
if (singleExif.value) prefillForm(singleExif.value)
else form.value = defaultForm()
}
/* ---------- 单张:选择性擦除 ---------- */
function formatTagValue(v) {
if (v == null) return ''
if (typeof v === 'string') return v.length > 60 ? v.slice(0, 60) + '…' : v
if (Array.isArray(v)) {
const s = JSON.stringify(v)
return s.length > 60 ? s.slice(0, 60) + '…' : s
}
return String(v)
}
const editableTags = computed(() => {
const obj = singleExif.value
if (!obj) return []
const out = []
for (const ifd of IFD_KEYS) {
const bag = obj[ifd]
if (!bag) continue
for (const id of Object.keys(bag)) {
const def = piexif.TAGS[ifd] && piexif.TAGS[ifd][id]
out.push({
key: ifd + ':' + id,
ifd,
id: Number(id),
name: def ? def.name : '未知标签 #' + id,
display: formatTagValue(bag[id])
})
}
}
return out
})
const groupedTags = computed(() => {
const map = new Map()
for (const t of editableTags.value) {
if (!map.has(t.ifd)) map.set(t.ifd, [])
map.get(t.ifd).push(t)
}
return IFD_KEYS.filter((k) => map.has(k)).map((k) => ({ ifd: k, items: map.get(k) }))
})
const allTagsChecked = computed(
() => editableTags.value.length > 0 && selectedTags.value.length === editableTags.value.length
)
const someTagsChecked = computed(
() => selectedTags.value.length > 0 && selectedTags.value.length < editableTags.value.length
)
function toggleAllTags(checked) {
selectedTags.value = checked ? editableTags.value.map((t) => t.key) : []
}
function selectGpsOnly() {
selectedTags.value = editableTags.value.filter((t) => t.ifd === 'GPS').map((t) => t.key)
}
function cloneExif(obj) {
const out = {}
for (const ifd of IFD_KEYS) out[ifd] = Object.assign({}, obj[ifd] || {})
out.thumbnail = obj.thumbnail || null
return out
}
// piexif.dump 要求:thumbnail 存在时 1st IFD 必须有内容,否则会报错
function normalizeExif(obj) {
if (obj.thumbnail && (!obj['1st'] || !Object.keys(obj['1st']).length)) obj.thumbnail = null
if (!obj.thumbnail) obj['1st'] = {}
return obj
}
function insertAndDownload(exifObj, suffix) {
const bytes = piexif.dump(normalizeExif(exifObj))
const out = piexif.insert(bytes, singleDataUrl.value)
saveBlob(dataUrlToBlob(out), cleanName(singleFile.value.name, suffix))
return out
}
function eraseSelected() {
if (!singleExif.value || !selectedTags.value.length) return
try {
const next = cloneExif(singleExif.value)
for (const key of selectedTags.value) {
const idx = key.indexOf(':')
const ifd = key.slice(0, idx)
const id = key.slice(idx + 1)
if (next[ifd]) delete next[ifd][id]
}
const out = insertAndDownload(next, '-edited')
// 同步内部状态,界面立即反映擦除结果
singleDataUrl.value = out
singleExif.value = piexif.load(out)
selectedTags.value = []
ElMessage.success('已擦除选中字段并下载')
} catch (e) {
ElMessage.error('擦除失败:' + (e && e.message ? e.message : String(e)))
}
}
function eraseAllExif() {
if (!singleDataUrl.value) return
try {
const out = piexif.remove(singleDataUrl.value)
saveBlob(dataUrlToBlob(out), cleanName(singleFile.value.name, '-clean'))
singleDataUrl.value = out
singleExif.value = piexif.load(out)
selectedTags.value = []
ElMessage.success('已清除全部 EXIF 并下载')
} catch (e) {
ElMessage.error('清除失败:' + (e && e.message ? e.message : String(e)))
}
}
/* ---------- 单张:写入自定义 EXIF ---------- */
function writeExif() {
if (!singleDataUrl.value) return
const f = form.value
try {
const base = f.mode === 'replace' ? { '0th': {}, Exif: {}, GPS: {}, '1st': {}, thumbnail: null } : cloneExif(singleExif.value)
const zeroth = base['0th']
const exif = base.Exif
const gps = base.GPS
if (f.copyright) zeroth[piexif.ImageIFD.Copyright] = f.copyright
if (f.artist) zeroth[piexif.ImageIFD.Artist] = f.artist
if (f.description) zeroth[piexif.ImageIFD.ImageDescription] = f.description
if (f.software) zeroth[piexif.ImageIFD.Software] = f.software
if (f.make) zeroth[piexif.ImageIFD.Make] = f.make
if (f.model) zeroth[piexif.ImageIFD.Model] = f.model
if (f.dateTimeOriginal) {
zeroth[piexif.ImageIFD.DateTime] = f.dateTimeOriginal
exif[piexif.ExifIFD.DateTimeOriginal] = f.dateTimeOriginal
exif[piexif.ExifIFD.DateTimeDigitized] = f.dateTimeOriginal
}
if (f.gpsOn) {
const lat = Number(f.lat) || 0
const lng = Number(f.lng) || 0
gps[piexif.GPSIFD.GPSVersionID] = [2, 3, 0, 0]
gps[piexif.GPSIFD.GPSLatitudeRef] = lat < 0 ? 'S' : 'N'
gps[piexif.GPSIFD.GPSLatitude] = piexif.GPSHelper.degToDmsRational(Math.abs(lat))
gps[piexif.GPSIFD.GPSLongitudeRef] = lng < 0 ? 'W' : 'E'
gps[piexif.GPSIFD.GPSLongitude] = piexif.GPSHelper.degToDmsRational(Math.abs(lng))
const alt = Number(f.altitude) || 0
gps[piexif.GPSIFD.GPSAltitudeRef] = alt < 0 ? 1 : 0
gps[piexif.GPSIFD.GPSAltitude] = [Math.round(Math.abs(alt) * 100), 100]
}
const hasAny =
Object.keys(zeroth).length || Object.keys(exif).length || Object.keys(gps).length
if (!hasAny) {
ElMessage.warning('请至少填写一个字段')
return
}
const out = insertAndDownload(base, '-exif')
singleDataUrl.value = out
singleExif.value = piexif.load(out)
ElMessage.success('已写入 EXIF 并下载')
} catch (e) {
ElMessage.error('写入失败:' + (e && e.message ? e.message : String(e)))
}
}
// JPEG 量化表热力图着色:量化值越大 → 越偏红(高频压缩更强)
function qCellStyle(v) {
const val = Math.min(255, Math.max(1, v == null ? 1 : v))
const ratio = val / 255
const r = Math.round(255 * ratio)
const g = Math.round(255 * (1 - ratio) * 0.85)
const b = Math.round(140 * (1 - ratio))
return {
background: `rgb(${r}, ${g}, ${b})`,
color: ratio > 0.5 ? '#fff' : '#303133'
}
}2.3 效果截图
