Appearance
实现 图片 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-input、el-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 效果截图
