跳到正文
DEV Community· Muhaymin Bin Mehmood·· 3 小時前AI 評分54

如何在不改動任何像素的情況下將圖片設為 300 DPI

Changing an Image to 300 DPI Without Touching a Single Pixel

AI 導讀

文章説明如何只修改 PNG、JPEG 檔案標頭中的 DPI 資料,將圖片設為 300 DPI,而不重新取樣或重新編碼像素。PNG 可能同時在 pHYs 和 eXIf 記錄解析度,JPEG 則可能在 JFIF 和 EXIF 中各自記錄,因此應更新已有欄位,並用獨立讀取器核對。

正文

"Please upload the image at 300 DPI." Print shops, journals and some government forms say this. Most people then open an editor, find a resize dialog, and accidentally resample the photo, making it bigger, blurrier or both.

Here's the thing: DPI is metadata. It's a number in the file header that tells a printer how many pixels to put in one inch. A 3000×2000 photo at 300 DPI prints at 10×6.67 inches. The same photo at 72 DPI prints at about 41.7×27.8 inches. The pixels are identical in both cases.

So changing DPI should change a few header bytes and nothing else. No decode, no re-encode, no quality loss, and EXIF untouched. Here's where those bytes live.

PNG: one place, in metres

PNG stores density in the pHYs chunk: 4 bytes X, 4 bytes Y, 1 byte unit. The unit 1 means pixels per metre, so 300 DPI is stored as 11811.

Reading it means walking the chunks until you find it:

function pngDpi(b) {
  const view = new DataView(b.buffer, b.byteOffset, b.byteLength)
  let p = 8 // skip the 8-byte signature
  while (p + 8 <= b.length) {
    const len = view.getUint32(p)
    const type = String.fromCharCode(...b.subarray(p + 4, p + 8))
    if (type === "pHYs") {
      const ppmX = view.getUint32(p + 8)
      const unit = b[p + 16] // 1 = metre
      return unit === 1 ? Math.round(ppmX * 0.0254) : null
    }
    if (type === "IDAT") break // density must come before the image data
    p += 12 + len // length + type + data + CRC
  }
  return null
}

Writing it means building a new 21-byte chunk (length, type, 9 data bytes, CRC-32) and inserting it before the first IDAT, or replacing the old one. Don't forget the CRC, or strict readers will reject the file.

JPEG: two places, and they can disagree

JPEG has two homes for density:

  • The JFIF APP0 segment: units, X density, Y density.
  • The EXIF APP1 segment: XResolution, YResolution, ResolutionUnit. This is what Photoshop and Windows Explorer show.

You'd think every JPEG has a JFIF segment. I listed the segments of a 300 DPI JPEG written by sharp:

let p = 2
while (b[p] === 0xff && b[p + 1] !== 0xda) {
  const marker = b[p + 1].toString(16)
  const len = (b[p + 2] << 8) | b[p + 3]
  console.log("FF" + marker, len, String.fromCharCode(...b.subarray(p + 4, p + 10)))
  p += 2 + len
}
FFe1 188 Exif..
FFe2 496 ICC_PR
FFdb 67 ...

No APP0 at all. The 300 DPI lives only in EXIF. A tool that only edits JFIF would "change the DPI" of this file and change nothing anyone can see.

The reverse happens too: files with JFIF saying 72 and EXIF saying 300. Different programs then show different answers for the same file. The safe rule: write every place that already exists, with the same value. Add a JFIF segment if there's none, and update EXIF if it's present.

The bug my own tests couldn't see

My first version passed 29 tests. They wrote a DPI, read it back with my own parser, and compared. Of course they passed: the writer and the reader shared the same assumptions.

Then I checked the output with sharp, which uses libvips, a completely independent reader. Some PNGs came back with the old DPI.

The cause: PNGs written by sharp or Photoshop can carry an eXIf chunk, a full EXIF block inside the PNG, with its own resolution tags. libvips reads eXIf in preference to pHYs. My code updated pHYs correctly and left the stale eXIf value in place, so to libvips the DPI never changed.

Two lessons:

  1. When a PNG has eXIf, update its resolution tags too.
  2. Verify header writes with a reader you didn't write. Round-tripping through your own parser only proves your writer and reader agree with each other.

What DPI can't do

Changing DPI never adds detail. If a print shop needs a 10-inch-wide print at 300 DPI, they need 3000 pixels across. A 1200-pixel photo set to 300 DPI just prints at 4 inches. A good tool should show that print size next to the DPI field, so nobody is surprised at the counter.

The tool

This header-only approach is how the free DPI converter on BatchSet works: pick 72, 150, 300 or 600 DPI for a JPG or PNG, and only the header bytes change. It runs in the browser, so the file isn't uploaded. WebP is rejected on purpose, because it has no standard density field, and silently returning an unchanged file would be worse than an error.

This article was drafted with an AI assistant from the code of a production tool. The snippets were run against real files before publishing.

來源:DEV Community · dev.to