Skip to content

实现 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-inputel-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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

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 效果截图

ImageMetaExtractor 效果截图

工具访问地址:https://www.i91tools.com/tools/image-meta-extractor