Skip to content

实现 图片 DPI 与打印尺寸 工具

分类:general | 标签:DPI、打印、尺寸 查看与修改图片 DPI 及打印尺寸

2.1 功能说明

查看与修改图片 DPI 及打印尺寸

2.1.1 使用指南

功能说明

读取并修改图片的 DPI(每英寸点数)元数据,并据此换算实际打印出来的物理尺寸。 支持 JPEG 与 PNG 两种格式,全部在浏览器本地按字节解析和改写,图片不会上传服务器。

DPI 到底是什么

图片文件本身只存像素,DPI 只是一个「打印时每英寸放多少个像素」的建议值,写在文件的元数据里。 改 DPI 不会改变画质,也不会改变像素数量,只是告诉打印机或排版软件(Word、InDesign、PS)该按多大尺寸输出。 换算公式很简单:物理尺寸(英寸) = 像素数 ÷ DPI,厘米数再乘以 2.54。

举例:一张 1200 × 1800 像素的图,标 300 DPI 时打印出来是 4 × 6 英寸(约 10.2 × 15.2 厘米); 标 72 DPI 时同一张图会被打印成 16.7 × 25 英寸,尺寸变大但每英寸只有 72 个点,看起来就糊了。

两种导出模式的区别

仅改写元数据(推荐,无损):直接在原始文件的字节流上修改 DPI 字段, 不解码、不重新编码,画质与文件体积几乎完全不变(JPEG 不会二次压缩)。适合「图片已经够大,只是 DPI 标错了」的场景,比如投稿要求 300 DPI。

按目标打印尺寸重采样:先把像素真正缩放到「目标厘米数 × 目标 DPI」对应的像素量,再写入 DPI。 这会重新编码,JPEG 会有一次有损压缩。放大像素并不能凭空增加细节,所以只建议往小了缩,不建议大幅放大。

元数据写入的技术细节与可靠性

JPEG:DPI 存在文件开头的 APP0 JFIF 段里,依次是单位(1 表示 dots/inch,2 表示 dots/cm)、 X 密度和 Y 密度。本工具会定位该段并把单位改为 1、写入你设置的数值;如果原文件没有 JFIF 段,会在 SOI 标记后插入一个标准的 18 字节 APP0 段。 另外,如果图片带有 EXIF(APP1)信息且其中含有 XResolution / YResolution,本工具会一并同步修改, 因为 Photoshop 等软件优先读 EXIF,只改 JFIF 会出现两处数值打架、显示仍是旧值的情况。

PNG:PNG 没有 DPI 概念,只有 pHYs 数据块记录「每米多少像素」, 换算关系是 每米像素数 = DPI ÷ 0.0254。本工具会替换已有的 pHYs 块(并重算 CRC32 校验), 没有则在第一个 IDAT 数据块之前插入一个新的 pHYs 块。

需要注意:不同软件对 DPI 的读取优先级不一致,个别看图软件(尤其是 Windows 资源管理器的属性面板) 会缓存旧值或干脆只读 EXIF。导出后如果显示不对,建议用 Photoshop、GIMP 或重新用本工具加载一次来验证。 另外重采样模式下 JPEG 由浏览器重新编码,浏览器编码器会丢弃原文件的 EXIF、ICC 色彩配置等信息,只保留本工具重新写入的 DPI。

常用照片规格

下方速查表按你当前设置的 DPI 实时换算所需像素数,方便核对证件照、冲印尺寸是否达标。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • getCrcTable()
  • crc32()
  • isPng()
  • pngChunks()
  • readPngInfo()
  • setPngDpi()
  • isJpeg()
  • scanJpegSegments()
  • findJfif()
  • findExifTiff()
  • locateExifResolution()
  • readJpegInfo()
  • setJpegDpi()
  • pickFile()
  • onFileChange()
  • onDrop()
  • loadFile()
  • setDpi()
  • downloadBlob()
  • baseName()
  • patchDpi()
  • finishExport()
  • exportImage()
  • verifyExported()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const fileInput = ref(null)
const dragOver = ref(false)
const error = ref('')
const notice = ref('')
const loaded = ref(false)
const exporting = ref(false)
const verifyText = ref('')

const fileName = ref('')
const fileSize = ref(0)
const format = ref('')
const mimeType = ref('')
const imgW = ref(0)
const imgH = ref(0)
const srcDpiX = ref(0)
const srcDpiY = ref(0)
const dpiSource = ref('')
const rawMetaLines = ref([])
const previewUrl = ref('')

const calcMode = ref('byDpi')
const targetDpi = ref(300)
const targetWidthCm = ref(10)
const exportMode = ref('meta')
const jpegQuality = ref(92)

const quickDpi = [72, 96, 150, 200, 300, 600]

let rawBytes = null
let objectUrl = ''
const lastExportBytes = ref(null)

/* ---------------- 通用工具 ---------------- */

let crcTable = null
function getCrcTable() {
  if (crcTable) return crcTable
  const t = new Uint32Array(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
    }
    t[n] = c >>> 0
  }
  crcTable = t
  return t
}

function crc32(buf, start, end) {
  const t = getCrcTable()
  let c = 0xffffffff
  for (let i = start; i < end; i++) {
    c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
  }
  return (c ^ 0xffffffff) >>> 0
}

/* ---------------- PNG ---------------- */

function isPng(bytes) {
  return (
    bytes.length > 8 &&
    bytes[0] === 0x89 &&
    bytes[1] === 0x50 &&
    bytes[2] === 0x4e &&
    bytes[3] === 0x47
  )
}

function pngChunks(bytes) {
  const list = []
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
  let off = 8
  while (off + 8 <= bytes.length) {
    const len = dv.getUint32(off)
    const type = String.fromCharCode(bytes[off + 4], bytes[off + 5], bytes[off + 6], bytes[off + 7])
    list.push({ off, len, type, dataOff: off + 8 })
    if (type === 'IEND') break
    off += 12 + len
    if (len < 0 || off <= 0) break
  }
  return list
}

function readPngInfo(bytes) {
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
  const chunks = pngChunks(bytes)
  const info = { width: 0, height: 0, dpiX: 0, dpiY: 0, source: '', lines: [] }

  const ihdr = chunks.find((c) => c.type === 'IHDR')
  if (ihdr) {
    info.width = dv.getUint32(ihdr.dataOff)
    info.height = dv.getUint32(ihdr.dataOff + 4)
    info.lines.push('IHDR: 宽 ' + info.width + ', 高 ' + info.height + ', 位深 ' + bytes[ihdr.dataOff + 8])
  }

  const phys = chunks.find((c) => c.type === 'pHYs')
  if (phys && phys.len === 9) {
    const ppuX = dv.getUint32(phys.dataOff)
    const ppuY = dv.getUint32(phys.dataOff + 4)
    const unit = bytes[phys.dataOff + 8]
    info.lines.push('pHYs: X ' + ppuX + ', Y ' + ppuY + ' 像素/单位, 单位标志 ' + unit + (unit === 1 ? '(米)' : '(未知)'))
    if (unit === 1) {
      info.dpiX = Math.round(ppuX * 0.0254)
      info.dpiY = Math.round(ppuY * 0.0254)
      info.source = 'PNG pHYs 数据块'
    }
  } else {
    info.lines.push('pHYs: 不存在(PNG 未记录物理分辨率)')
  }

  info.lines.push('数据块序列: ' + chunks.map((c) => c.type).join(' -> '))
  return info
}

function setPngDpi(bytes, dpiX, dpiY) {
  const ppuX = Math.round(dpiX / 0.0254)
  const ppuY = Math.round(dpiY / 0.0254)
  const chunks = pngChunks(bytes)
  const phys = chunks.find((c) => c.type === 'pHYs')

  if (phys && phys.len === 9) {
    const out = new Uint8Array(bytes)
    const dv = new DataView(out.buffer)
    dv.setUint32(phys.dataOff, ppuX)
    dv.setUint32(phys.dataOff + 4, ppuY)
    out[phys.dataOff + 8] = 1
    dv.setUint32(phys.dataOff + 9, crc32(out, phys.off + 4, phys.dataOff + 9))
    return out
  }

  const idat = chunks.find((c) => c.type === 'IDAT')
  const iend = chunks.find((c) => c.type === 'IEND')
  const insertAt = idat ? idat.off : iend ? iend.off : bytes.length

  const chunk = new Uint8Array(21)
  const cdv = new DataView(chunk.buffer)
  cdv.setUint32(0, 9)
  chunk[4] = 0x70 // p
  chunk[5] = 0x48 // H
  chunk[6] = 0x59 // Y
  chunk[7] = 0x73 // s
  cdv.setUint32(8, ppuX)
  cdv.setUint32(12, ppuY)
  chunk[16] = 1
  cdv.setUint32(17, crc32(chunk, 4, 17))

  const out = new Uint8Array(bytes.length + 21)
  out.set(bytes.subarray(0, insertAt), 0)
  out.set(chunk, insertAt)
  out.set(bytes.subarray(insertAt), insertAt + 21)
  return out
}

/* ---------------- JPEG ---------------- */

function isJpeg(bytes) {
  return bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8
}

function scanJpegSegments(bytes) {
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
  const segs = []
  let off = 2
  while (off < bytes.length - 1) {
    if (bytes[off] !== 0xff) {
      off++
      continue
    }
    const marker = bytes[off + 1]
    if (marker === 0xff) {
      off++
      continue
    }
    if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
      off += 2
      continue
    }
    if (marker === 0xd9 || marker === 0xda) break
    if (off + 4 > bytes.length) break
    const len = dv.getUint16(off + 2)
    segs.push({ marker, off, len, dataOff: off + 4 })
    off += 2 + len
  }
  return segs
}

function findJfif(bytes, segs) {
  for (const s of segs) {
    if (s.marker !== 0xe0) continue
    const d = s.dataOff
    if (
      bytes[d] === 0x4a &&
      bytes[d + 1] === 0x46 &&
      bytes[d + 2] === 0x49 &&
      bytes[d + 3] === 0x46 &&
      bytes[d + 4] === 0x00
    ) {
      return d
    }
  }
  return -1
}

function findExifTiff(bytes, segs) {
  for (const s of segs) {
    if (s.marker !== 0xe1) continue
    const d = s.dataOff
    if (
      bytes[d] === 0x45 &&
      bytes[d + 1] === 0x78 &&
      bytes[d + 2] === 0x69 &&
      bytes[d + 3] === 0x66 &&
      bytes[d + 4] === 0x00 &&
      bytes[d + 5] === 0x00
    ) {
      return d + 6
    }
  }
  return -1
}

// 定位 EXIF IFD0 中分辨率相关标签的字节偏移
function locateExifResolution(bytes, tiff) {
  try {
    const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
    const le = bytes[tiff] === 0x49
    const ifd0 = dv.getUint32(tiff + 4, le)
    const base = tiff + ifd0
    if (base + 2 > bytes.length) return null
    const count = dv.getUint16(base, le)
    const res = { le, xOff: -1, yOff: -1, unitOff: -1, x: 0, y: 0, unit: 0 }
    for (let i = 0; i < count; i++) {
      const e = base + 2 + i * 12
      if (e + 12 > bytes.length) break
      const tag = dv.getUint16(e, le)
      const type = dv.getUint16(e + 2, le)
      if ((tag === 0x011a || tag === 0x011b) && type === 5) {
        const p = tiff + dv.getUint32(e + 8, le)
        if (p + 8 > bytes.length) continue
        const num = dv.getUint32(p, le)
        const den = dv.getUint32(p + 4, le)
        const val = den ? num / den : 0
        if (tag === 0x011a) {
          res.xOff = p
          res.x = val
        } else {
          res.yOff = p
          res.y = val
        }
      } else if (tag === 0x0128) {
        res.unitOff = e + 8
        res.unit = dv.getUint16(e + 8, le)
      }
    }
    return res.xOff >= 0 || res.unitOff >= 0 ? res : null
  } catch (e) {
    return null
  }
}

function readJpegInfo(bytes) {
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
  const info = { width: 0, height: 0, dpiX: 0, dpiY: 0, source: '', lines: [] }
  const segs = scanJpegSegments(bytes)

  // SOF 取尺寸
  for (const s of segs) {
    const m = s.marker
    const isSof =
      (m >= 0xc0 && m <= 0xc3) || (m >= 0xc5 && m <= 0xc7) || (m >= 0xc9 && m <= 0xcb) || (m >= 0xcd && m <= 0xcf)
    if (isSof) {
      info.height = dv.getUint16(s.dataOff + 1)
      info.width = dv.getUint16(s.dataOff + 3)
      break
    }
  }

  const jfif = findJfif(bytes, segs)
  if (jfif >= 0) {
    const unit = bytes[jfif + 7]
    const xd = dv.getUint16(jfif + 8)
    const yd = dv.getUint16(jfif + 10)
    const unitName = unit === 1 ? 'dots/inch' : unit === 2 ? 'dots/cm' : '无单位(仅比例)'
    info.lines.push(
      'APP0 JFIF: 单位 ' + unit + '(' + unitName + '), Xdensity ' + xd + ', Ydensity ' + yd + ',字段偏移 ' + (jfif + 7)
    )
    if (unit === 1) {
      info.dpiX = xd
      info.dpiY = yd
      info.source = 'JPEG JFIF (APP0)'
    } else if (unit === 2) {
      info.dpiX = Math.round(xd * 2.54)
      info.dpiY = Math.round(yd * 2.54)
      info.source = 'JPEG JFIF (APP0, dots/cm 换算)'
    }
  } else {
    info.lines.push('APP0 JFIF: 不存在')
  }

  const tiff = findExifTiff(bytes, segs)
  if (tiff >= 0) {
    const r = locateExifResolution(bytes, tiff)
    if (r && r.x) {
      const unitName = r.unit === 3 ? '厘米' : '英寸'
      info.lines.push('APP1 EXIF: XResolution ' + r.x + ', YResolution ' + r.y + ', 单位 ' + r.unit + '(' + unitName + ')')
      const fx = r.unit === 3 ? r.x * 2.54 : r.x
      const fy = r.unit === 3 ? (r.y || r.x) * 2.54 : r.y || r.x
      // EXIF 优先级更高
      info.dpiX = Math.round(fx)
      info.dpiY = Math.round(fy)
      info.source = info.source ? 'JPEG EXIF(同时存在 JFIF)' : 'JPEG EXIF (APP1)'
    } else {
      info.lines.push('APP1 EXIF: 存在,但未包含分辨率标签')
    }
  } else {
    info.lines.push('APP1 EXIF: 不存在')
  }

  info.lines.push(
    '段序列: ' +
      segs
        .map((s) => 'FF' + s.marker.toString(16).toUpperCase().padStart(2, '0'))
        .join(' ')
  )
  return info
}

function setJpegDpi(bytes, dpiX, dpiY) {
  let out
  let segs = scanJpegSegments(bytes)
  let jfif = findJfif(bytes, segs)

  if (jfif >= 0) {
    out = new Uint8Array(bytes)
  } else {
    // 在 SOI 之后插入标准 APP0 JFIF 段(长度 16 + 标记 2 = 18 字节)
    const seg = new Uint8Array(18)
    const sdv = new DataView(seg.buffer)
    seg[0] = 0xff
    seg[1] = 0xe0
    sdv.setUint16(2, 16)
    seg[4] = 0x4a // J
    seg[5] = 0x46 // F
    seg[6] = 0x49 // I
    seg[7] = 0x46 // F
    seg[8] = 0x00
    seg[9] = 1 // 版本 1.2
    seg[10] = 2
    seg[11] = 1 // 单位 dots/inch
    sdv.setUint16(12, dpiX)
    sdv.setUint16(14, dpiY)
    seg[16] = 0 // 缩略图宽
    seg[17] = 0 // 缩略图高

    out = new Uint8Array(bytes.length + 18)
    out.set(bytes.subarray(0, 2), 0)
    out.set(seg, 2)
    out.set(bytes.subarray(2), 20)
    segs = scanJpegSegments(out)
    jfif = findJfif(out, segs)
  }

  if (jfif >= 0) {
    const dv = new DataView(out.buffer, out.byteOffset, out.byteLength)
    out[jfif + 7] = 1
    dv.setUint16(jfif + 8, dpiX)
    dv.setUint16(jfif + 10, dpiY)
  }

  // 同步 EXIF,避免两处数值冲突
  const tiff = findExifTiff(out, segs)
  if (tiff >= 0) {
    const r = locateExifResolution(out, tiff)
    if (r) {
      const dv = new DataView(out.buffer, out.byteOffset, out.byteLength)
      if (r.xOff >= 0) {
        dv.setUint32(r.xOff, dpiX, r.le)
        dv.setUint32(r.xOff + 4, 1, r.le)
      }
      if (r.yOff >= 0) {
        dv.setUint32(r.yOff, dpiY, r.le)
        dv.setUint32(r.yOff + 4, 1, r.le)
      }
      if (r.unitOff >= 0) {
        dv.setUint16(r.unitOff, 2, r.le) // 2 = 英寸
      }
    }
  }

  return out
}

/* ---------------- 交互 ---------------- */

function pickFile() {
  if (fileInput.value) fileInput.value.click()
}

function onFileChange(e) {
  const f = e.target.files && e.target.files[0]
  if (f) loadFile(f)
  e.target.value = ''
}

function onDrop(e) {
  dragOver.value = false
  const f = e.dataTransfer.files && e.dataTransfer.files[0]
  if (f) loadFile(f)
}

function loadFile(file) {
  error.value = ''
  notice.value = ''
  verifyText.value = ''
  lastExportBytes.value = null

  const reader = new FileReader()
  reader.onload = () => {
    const bytes = new Uint8Array(reader.result)
    let info = null
    if (isJpeg(bytes)) {
      format.value = 'JPEG'
      mimeType.value = 'image/jpeg'
      info = readJpegInfo(bytes)
    } else if (isPng(bytes)) {
      format.value = 'PNG'
      mimeType.value = 'image/png'
      info = readPngInfo(bytes)
    } else {
      error.value = '仅支持 JPEG 与 PNG 格式,当前文件无法识别'
      loaded.value = false
      return
    }

    rawBytes = bytes
    fileName.value = file.name
    fileSize.value = file.size
    imgW.value = info.width
    imgH.value = info.height
    srcDpiX.value = info.dpiX
    srcDpiY.value = info.dpiY
    dpiSource.value = info.source
    rawMetaLines.value = info.lines

    if (!info.width || !info.height) {
      notice.value = '未能从文件头解析出像素尺寸,将改用图片解码结果'
    }

    if (objectUrl) URL.revokeObjectURL(objectUrl)
    objectUrl = URL.createObjectURL(file)
    previewUrl.value = objectUrl

    const img = new Image()
    img.onload = () => {
      if (!imgW.value || !imgH.value) {
        imgW.value = img.naturalWidth
        imgH.value = img.naturalHeight
        notice.value = ''
      }
      targetDpi.value = info.dpiX || 300
      targetWidthCm.value = Number(((imgW.value / (info.dpiX || 300)) * 2.54).toFixed(2))
      loaded.value = true
    }
    img.onerror = () => {
      error.value = '图片解码失败,文件可能已损坏'
      loaded.value = false
    }
    img.src = objectUrl
  }
  reader.onerror = () => {
    error.value = '文件读取失败'
  }
  reader.readAsArrayBuffer(file)
}

function setDpi(d) {
  if (calcMode.value === 'bySize') return
  targetDpi.value = d
}

const sizeText = computed(() => {
  const s = fileSize.value
  if (s < 1024) return s + ' B'
  if (s < 1024 * 1024) return (s / 1024).toFixed(1) + ' KB'
  return (s / 1024 / 1024).toFixed(2) + ' MB'
})

const megaPixel = computed(() => ((imgW.value * imgH.value) / 1000000).toFixed(2))

const srcPrintText = computed(() => {
  const d = srcDpiX.value || 72
  const dy = srcDpiY.value || d
  const w = ((imgW.value / d) * 2.54).toFixed(2)
  const h = ((imgH.value / dy) * 2.54).toFixed(2)
  return w + ' × ' + h + ' cm'
})

const effectiveDpi = computed(() => {
  if (calcMode.value === 'byDpi') {
    return Math.max(1, Math.round(targetDpi.value || 1))
  }
  const cm = targetWidthCm.value || 1
  return Math.max(1, Math.round(imgW.value / (cm / 2.54)))
})

const printWidthCm = computed(() => (imgW.value / effectiveDpi.value) * 2.54)
const printHeightCm = computed(() => (imgH.value / effectiveDpi.value) * 2.54)
const lockedHeightCm = computed(() => Number(printHeightCm.value.toFixed(2)))

const resampleW = computed(() => {
  if (calcMode.value === 'bySize') {
    return Math.max(1, Math.round((targetWidthCm.value / 2.54) * effectiveDpi.value))
  }
  return imgW.value
})

const resampleH = computed(() => {
  if (calcMode.value === 'bySize') {
    const ratio = imgH.value / imgW.value
    return Math.max(1, Math.round(resampleW.value * ratio))
  }
  return imgH.value
})

const qualityLevel = computed(() => {
  const d = effectiveDpi.value
  if (d >= 300) return { type: 'success', text: '当前 DPI 达到 300 及以上,满足商业印刷与证件照冲印的清晰度要求。' }
  if (d >= 200) return { type: 'success', text: '当前 DPI 在 200~300 之间,日常照片冲印与文档打印足够清晰。' }
  if (d >= 150) return { type: 'warning', text: '当前 DPI 在 150~200 之间,普通激光打印可以接受,近距离细看会略显粗糙。' }
  if (d >= 96) return { type: 'warning', text: '当前 DPI 偏低,适合屏幕显示,打印成品会有明显颗粒感。' }
  return { type: 'error', text: '当前 DPI 过低,打印会严重模糊,建议提高 DPI 或缩小打印尺寸。' }
})

const specs = [
  { name: '一寸照', wmm: 25, hmm: 35 },
  { name: '小一寸照', wmm: 22, hmm: 32 },
  { name: '大一寸照', wmm: 33, hmm: 48 },
  { name: '二寸照', wmm: 35, hmm: 49 },
  { name: '小二寸(护照)', wmm: 33, hmm: 48 },
  { name: '大二寸照', wmm: 35, hmm: 53 },
  { name: '驾驶证照', wmm: 22, hmm: 32 },
  { name: '结婚证照', wmm: 53, hmm: 35 },
  { name: '5 寸照片', wmm: 89, hmm: 127 },
  { name: '6 寸照片', wmm: 102, hmm: 152 },
  { name: '7 寸照片', wmm: 127, hmm: 178 },
  { name: 'A4 纸', wmm: 210, hmm: 297 },
  { name: 'A3 纸', wmm: 297, hmm: 420 }
]

const specRows = computed(() => {
  const d = effectiveDpi.value
  return specs.map((s) => {
    const pw = Math.round((s.wmm / 25.4) * d)
    const ph = Math.round((s.hmm / 25.4) * d)
    // 允许横竖版任一方向满足
    const ok =
      (imgW.value >= pw && imgH.value >= ph) || (imgW.value >= ph && imgH.value >= pw)
    return {
      name: s.name,
      mm: s.wmm + ' × ' + s.hmm + ' mm',
      px: pw + ' × ' + ph + ' px',
      ok
    }
  })
})

watch(calcMode, () => {
  if (calcMode.value === 'bySize') {
    targetWidthCm.value = Number(((imgW.value / (targetDpi.value || 300)) * 2.54).toFixed(2))
  } else {
    targetDpi.value = effectiveDpi.value
  }
})

/* ---------------- 导出 ---------------- */

function downloadBlob(blob, name) {
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = name
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  setTimeout(() => URL.revokeObjectURL(url), 1000)
}

function baseName() {
  const n = fileName.value || 'image'
  const dot = n.lastIndexOf('.')
  return dot > 0 ? n.slice(0, dot) : n
}

function patchDpi(bytes) {
  const d = effectiveDpi.value
  return format.value === 'PNG' ? setPngDpi(bytes, d, d) : setJpegDpi(bytes, d, d)
}

function finishExport(bytes) {
  lastExportBytes.value = bytes
  const ext = format.value === 'PNG' ? '.png' : '.jpg'
  const blob = new Blob([bytes], { type: mimeType.value })
  downloadBlob(blob, baseName() + '-' + effectiveDpi.value + 'dpi' + ext)
  exporting.value = false
}

function exportImage() {
  if (!rawBytes) return
  error.value = ''
  verifyText.value = ''
  exporting.value = true

  try {
    if (exportMode.value === 'meta') {
      finishExport(patchDpi(rawBytes))
      return
    }

    // 重采样:先解码绘制到目标尺寸,再把 DPI 注入重编码后的字节
    const img = new Image()
    img.onload = () => {
      try {
        const canvas = document.createElement('canvas')
        canvas.width = resampleW.value
        canvas.height = resampleH.value
        const ctx = canvas.getContext('2d')
        ctx.imageSmoothingEnabled = true
        ctx.imageSmoothingQuality = 'high'
        if (format.value === 'JPEG') {
          ctx.fillStyle = '#ffffff'
          ctx.fillRect(0, 0, canvas.width, canvas.height)
        }
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height)

        const type = format.value === 'PNG' ? 'image/png' : 'image/jpeg'
        const q = format.value === 'PNG' ? undefined : jpegQuality.value / 100
        canvas.toBlob(
          (blob) => {
            if (!blob) {
              exporting.value = false
              error.value = '重采样导出失败'
              return
            }
            blob
              .arrayBuffer()
              .then((buf) => {
                finishExport(patchDpi(new Uint8Array(buf)))
              })
              .catch(() => {
                exporting.value = false
                error.value = '读取重编码结果失败'
              })
          },
          type,
          q
        )
      } catch (err) {
        exporting.value = false
        error.value = '重采样失败:' + (err && err.message ? err.message : '未知错误')
      }
    }
    img.onerror = () => {
      exporting.value = false
      error.value = '图片解码失败'
    }
    img.src = objectUrl
  } catch (err) {
    exporting.value = false
    error.value = '导出失败:' + (err && err.message ? err.message : '未知错误')
  }
}

function verifyExported() {
  const bytes = lastExportBytes.value
  if (!bytes) return
  const info = format.value === 'PNG' ? readPngInfo(bytes) : readJpegInfo(bytes)
  if (info.dpiX) {
    verifyText.value =
      '校验通过:导出文件中读到 DPI ' + info.dpiX + ' × ' + info.dpiY + ',来源 ' + info.source
  } else {
    verifyText.value = '校验结果:未在导出文件中读到有效 DPI 字段,请反馈该图片格式'
  }
}

onBeforeUnmount(() => {
  if (objectUrl) URL.revokeObjectURL(objectUrl)
})

2.3 效果截图

图片 DPI 与打印尺寸 效果截图

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