Upright on the phone, flat again once another device opens it
The photo I shot on a phone the year before last: one tap of rotate in the album and it looked upright. Handed it to a computer, dropped it into an old viewer, and it lay back down. I suspected the save had failed, spun it three times over, and the copy in the browser had been turned twice, straight upside down.
Only later did I see it: that rotate in the album never touched a pixel, it only rewrote a flag called Orientation, and one tap of "Turn right 90°" in Image Rotate Flip really does move the pixels. The two pay completely different bills.
Only the flag changes, not one pixel moves
While verifying I hand-wrote a set of JPEGs, writing tag 0x0112 in turn as 1 through 8 inside the APP1 segment (FFE1 plus Exif\0\0 plus a little-endian TIFF). The one declaring 6 is 1268 B; strip its metadata, decode again, and it is 80×120. The pixels lying in the file really do lie turned by 90°, and "upright" only lives in the flag; decode with the flag and naturalWidth times naturalHeight is 120×80.
Whatever reads the flag sees it upright, whatever does not has to lay out the stored pixels. That "rotation" changed the reading, not the file.
The browser hands the orientation back for the code
The template is 206 lines. Grepping exif, orientation and createImageBitmap gives 0 hits, so it parses no orientation flag at all. Decoding is only new Image() with URL.createObjectURL, and onload catches the two uprighted values (line 130).
Orientation 1 through 8 collapses into a single display direction: the 5, 6, 7, 8 group stored as 80×120 all come out 120×80 inside the tool, and the exported canvas contents match pairwise, 1–4 sharing hash 962d4215 and 5–8 sharing hash da1c444c. Sloppy writing trips you too: same IFD, same 6, and the PNG whose eXIf chunk carries the plain spec TIFF, at 727 B, is uprighted to 80×120, while the 733 B one that copies the JPEG-style prefix is ignored and stays 120×80, with the info line and the file name changing together.
Fig. 1 | stored width and height, what the browser hands back, and the two canvas hashes.
The two bills a real rotation pays
The 90° permutation itself is exact: the 90° canvas of det_q90 and the hand-reordered reference are both 160x240:eceb4a99:153600:24483724, with no interpolation. But export always passes through canvas.toBlob(..., 0.92) (lines 186–187), the quality is hard-coded and the interface cannot move it. det_q90.jpg at 28210 B still becomes 28003 B if it just sits at 0° and downloads, with 38373 pixels changed and a mean deviation of 13.814; the q100 one at 104764 B falls to 30510 B with all 38400 pixels different and a mean of 73.74. The only thing that stays byte for byte is PNG: mark.png at 581 B exports as 581 B. What is really worth saving is file size (image compression).
The second bill is metadata. All 23 export records report outAPP1=0 and outOri=null, PNG output keeps only IHDR/IDAT/IEND with no eXIf; for the phone photo that carries 5 IFD entries, the SAMSUNG, SM-G9910 and 2026:03:07 21:14:52 string fields are all gone afterwards. Vendor, model, capture time and GPS leave together, and what comes back is an exported JPEG whose SOF, after a 90° turn, is written straight as 80×120, so downstream, whether it reads the flag or not, has only this one direction left.
Four downloads hurt far more than four taps
Tapping "Turn right 90°" four times in one session folds the angle back to 0 (0→90→180→-90→0), the canvas hash returns to its start, and both exports are 28003 B, byte for byte identical.
The real habit is to download after every turn and look again: det_q90 starts at 28210 B and the four rounds come out as 28373, 26986, 26324 and 26167 B, and the final canvas differs from the original picture in 38396 pixels with a mean deviation of 24.549. Four 90° turns are not the same as no turn at all. Only PNG genuinely returns to the start: mark.png at 581 B walks through 591, 580, 594 and 581 B, the final hash equals the original with 0 pixels different, and orientationTags is null in every round.
Two switches that leave a trace
Switching off "expand the canvas to fit the whole picture" is not about a little whitespace, it really crops: the det_q90 canvas with it off is still 240×160 and keeps only 25600 opaque pixels, 90° drops 12800 (33.33%), 45° drops 8823 (22.60%), 30° drops 7211 (18.54%), and only 180° drops nothing at all.
Fig. 2 | pixels cropped away on det_q90 once canvas expansion is off.
"Keep the original format" only holds for image/jpeg (line 176): mark.webp at 692 B yields a PNG of 1327 B, mark.bmp at 28854 B becomes 581 B, shape.svg at 165 B becomes 2687 B, because an SVG is rasterized at 180×120 the moment it loads, and anim.gif at 597 B keeps only its first frame, so per-frame work needs GIF frame splitting first. A mismatched name is more dangerous: fake.webp is in fact a 1200×900 JPEG at 39587 B and exports as a PNG of 504277 B.
Filling transparency while the dropdown reads "JPG (opaque)" stays quiet too: line 185 switches the format to PNG, alpha.png at 1873 B comes back as alpha_旋转200x120.png at 1873 B, byte for byte the same as choosing "keep the original format", while the dropdown still sits on JPG; change the fill to white and a JPG of 5342 B is finally encoded.
How the two rotations divide the work
| Item | Rewrite Orientation only | Move the pixels then export |
|---|---|---|
| Pixels inside the file | 80×120, unmoved | Moved to 120×80, SOF follows |
| Bytes | All but unchanged | 28210 B becomes 28003 B |
| Metadata | Only the flag moves | outAPP1=0, outOri=null |
| Downstream that ignores the flag | Still lying flat | One direction left |
When every downstream reads the flag, rewriting the flag is enough; to send a picture into an old system that does not read it, you have to really move the pixels once and accept the re-encode. One pass also holds a single picture: the field reads only files[0] (line 88), and when a new image is loaded, resetAll() on line 134 clears angle=135 and the horizontal flip, so the settings of the previous picture never carry over.
Q: Save it upright on the computer once, and it never lies down again?
Only a real pixel move plus export does that. In such a file the flag goes back to zero and the pixels are already upright, so readers and non-readers of EXIF both end up with one direction. Tapping rotate inside an album leaves it flat for any downstream that skips the flag.
Q: Why are the model and the capture time gone after a turn?
Export redraws the canvas and then calls toBlob, so metadata cannot get into that path. Across the 23 export records, the SAMSUNG photo with 5 IFD entries ends with every string field empty, and the PNG carries no eXIf chunk either.
Q: Does turning a JPEG by 90° blur it?
The permutation does not blur, because the canvas hash 160x240:eceb4a99 matches the hand-permuted reference. The blur is in the re-encode: 0.92 is hard-coded on line 186, and det_q90 at 28210 B becomes 28003 B even parked at 0°, with 38373 pixels changed.