Frontend
What's actually inside an image's DPI tag (and why changing it is lossless)
Casey Marlin Dev.to (EN Zone)
2 views
A print shop rejected a 4000 × 2667 px photo last week because the file "was only 72 DPI". The photo had more than enough pixels for the 8 × 10 in print they were making — at 300 DPI that size only needs 2400 × 3000. Nothing about the image was wrong. One integer in the file header was.
This post is about where that integer lives, byte by byte, and why rewriting it doesn't touch a single pixel.
DPI is metadata, not resolution
"Resolution" gets used for two unrelated things:
Pixel dimensions — how many samples the image has. 4000 × 2667. This is the actual information content.
Density tag — a hint that says "when you put this on paper, space the pixels N to the inch". 72, 96, 300, whatever.
Screens ignore the density tag completely. Browsers map image pixels to CSS pixels and never read it. The tag only matters when something is about to lay ink on a physical surface — or when a form-validation script on an upload portal reads it and refuses your file.
Print size is always pixels ÷ DPI. A 3000 px wide image tagged 72 DPI "wants" to be 41.7 inches wide. The same file tagged 300 DPI "wants" to be 10 inches. Same pixels. Same sharpness at the same physical size. Different suggestion.
Where the tag lives: three formats, three places
JPEG — JFIF APP0 segment
Right after the SOI marker (FF D8), most JPEGs carry an APP0 segment with the JFIF header:
FF E0 APP0 marker
00 10 length (16)
4A 46 49 46 00 "JFIF\0"
01 02 version 1.02
01 density units: 0 = none, 1 = dots/inch, 2 = dots/cm
01 2C X density (0x012C = 300)
01 2C Y density
00 00 thumbnail w/h
That's it. Two big-endian uint16 values and a units byte. Rewriting "72 DPI" to "300 DPI" means changing 00 48 to 01 2C at a fixed offset and making sure the units byte is 01. The entropy-coded scan data that follows — the actual compressed pixels — is untouched.
JPEG — EXIF XResolution / YResolution
Cameras usually write an APP1 EXIF segment too, and it has its own resolution fields: tags 0x011A (XResolution) and 0x011B (YResolution) as RATIONAL values (two uint32s: numerator / denominator), plus 0x0128 ResolutionUnit (2 = inch, 3 = cm).
So a JPEG can carry the density twice, and they can disagree. Photoshop reads EXIF first. Windows Explorer reads... it depends on the version. A proper tool rewrites both so every reader agrees.
PNG — the pHYs chunk
PNG stores it in an optional pHYs chunk before IDAT:
00 00 00 09 length (9)
70 48 59 73 "pHYs"
00 00 2E 23 pixels per unit, X (0x2E23 = 11811)
00 00 2E 23 pixels per unit, Y
01 unit: 1 = metre
xx xx xx xx CRC32
Notice PNG doesn't store inches. It stores pixels per metre. 300 DPI = 300 / 0.0254 = 11811 px/m. That's why a PNG "set to 300 DPI" sometimes reads back as 299.99 — the metre value is an integer and 300 doesn't divide cleanly.
If the chunk is absent (most screenshots, most web-exported PNGs), there is no DPI. Any number a viewer shows you is that viewer's default, not a property of the file.
Why rewriting it is lossless
In all three cases the density fields are in a header segment or chunk that sits before and separate from the compressed image data. Changing them:
doesn't decode the pixels
doesn't re-encode the pixels
doesn't touch JPEG quantization tables or Huffman data
doesn't touch PNG IDAT chunks
The only thing you have to get right is the PNG CRC (recompute it over the chunk type + data) and, for JPEG, not shifting any offsets — the fields are fixed-width so an in-place overwrite is safe.
Contrast with what "Image → Resize → 300 DPI" does in most editors: it decodes, optionally resamples, and re-encodes. For JPEG that's a generation of quality loss you never needed to take.
Reading pHYs in ~15 lines of JavaScript
async function readPngDpi(file) {
const buf = new Uint8Array(await file.arrayBuffer());
const dv = new DataView(buf.buffer);
let off = 8; // skip PNG signature
while (off < buf.length) {
const len = dv.getUint32(off);
const type = String.fromCharCode(...buf.slice(off + 4, off + 8));
if (type === "pHYs") {
const ppuX = dv.getUint32(off + 8);
const unit = buf[off + 16];
return unit === 1 ? Math.round(ppuX * 0.0254) : null; // px/m → DPI
}
if (type === "IDAT") return null; // pHYs must precede IDAT
off += 12 + len; // length + type + data + CRC
}
return null;
}
Writing is the same walk plus a CRC32 and an ArrayBuffer splice. JFIF is even simpler — it's a fixed offset if the APP0 segment is where it usually is (and you should verify the JFIF\0 signature rather than assume).
The trap on the other side
Because the tag is so easy to change, "increase image DPI" tools are everywhere, and half of them quietly imply that raising the number makes a small image printable. It doesn't. An 800 × 600 px image tagged 300 DPI is a 2.67 × 2 in print. Tagging it 600 makes it a 1.33 × 1 in print. The pixels are the ceiling; the tag is just where you set the ruler.
The honest workflow: check pixel dimensions ÷ target DPI ≥ print size. If yes, rewrite the tag and stop. If no, you need more pixels — a larger original, a rescan, or an actual upscaler (which is a different, lossy operation).
I put this in a browser tool
I got tired of opening Photoshop to change one integer, so I built SetDPI — it does the JFIF / EXIF / pHYs rewrite described above entirely in the browser with a FileReader and a Blob. Nothing is uploaded; you can load the page and go offline. There's also a DPI checker that reads all three fields and tells you when they disagree or when the PNG simply has no pHYs chunk, instead of inventing 72.
If you're building anything that handles user-uploaded images, the takeaway is smaller than the tool: read the density fields, don't trust them, and never resample a file that only needed its label fixed.
Read original: https://dev.to/caseymarlin/whats-actually-inside-an-images-dpi-tag-and-why-changing-it-is-lossless-57pb
← Previous
HypeBESTIE: My Free Sincere Compliment Engine
Next →
The Fixpoint No Test Suite Can Check
Related
how I make my templates easy to reskin (probably overthought this)
Frontend
0
Dev.to (EN Zone)
StyleX won CSS-in-JS because AI agents can read it
Frontend
1
DEV Community
Why I Built a Lightweight Utility Styling Library for React Native (And How It Solves StyleSheet Fatigue)
Frontend
1
DEV Community
DOM in Angular: Understanding the Document Object Model with Practical Examples
Frontend
3
Dev.to (EN Zone)
Comments0
No comments yet — be the first