From 617ebde1109f406c1ed22fe6e31c7de3c48b3758 Mon Sep 17 00:00:00 2001 From: Matias Simon Date: Sun, 27 Sep 2026 21:30:14 +0200 Subject: [PATCH 1/6] Pin how EXIF-oriented JPEGs are thumbnailed and resized None of the EXIF handling had a single test. Add fixtures for all eight orientations, each with a big- and a little-endian TIFF header, that all display as the same 64x32 image with one colour per quadrant. They are generated by test/fixtures/exif/generate.py and checked there with ImageMagick's -auto-orient, so they stand apart from both the browser and Dropzone's own EXIF code. The tests record what the browser produces today, for every fixture: an upright thumbnail, `file.width` and `file.height` in displayed axes, resizing and cropping along the displayed axes, a custom `resize`'s source rectangle taken from the displayed image, and images from the server and PNGs left to the browser. The next changes must not move any of that. They also record the bug: the resized image carries the original orientation, although its pixels are already upright. --- packages/dropzone/test/fixtures/exif/1-be.jpg | Bin 0 -> 426 bytes packages/dropzone/test/fixtures/exif/1-le.jpg | Bin 0 -> 426 bytes packages/dropzone/test/fixtures/exif/2-be.jpg | Bin 0 -> 427 bytes packages/dropzone/test/fixtures/exif/2-le.jpg | Bin 0 -> 427 bytes packages/dropzone/test/fixtures/exif/3-be.jpg | Bin 0 -> 426 bytes packages/dropzone/test/fixtures/exif/3-le.jpg | Bin 0 -> 426 bytes packages/dropzone/test/fixtures/exif/4-be.jpg | Bin 0 -> 427 bytes packages/dropzone/test/fixtures/exif/4-le.jpg | Bin 0 -> 427 bytes packages/dropzone/test/fixtures/exif/5-be.jpg | Bin 0 -> 456 bytes packages/dropzone/test/fixtures/exif/5-le.jpg | Bin 0 -> 456 bytes packages/dropzone/test/fixtures/exif/6-be.jpg | Bin 0 -> 457 bytes packages/dropzone/test/fixtures/exif/6-le.jpg | Bin 0 -> 457 bytes packages/dropzone/test/fixtures/exif/7-be.jpg | Bin 0 -> 458 bytes packages/dropzone/test/fixtures/exif/7-le.jpg | Bin 0 -> 458 bytes packages/dropzone/test/fixtures/exif/8-be.jpg | Bin 0 -> 457 bytes packages/dropzone/test/fixtures/exif/8-le.jpg | Bin 0 -> 457 bytes .../dropzone/test/fixtures/exif/generate.py | 95 ++++++++ packages/dropzone/test/unit-tests/exif.js | 206 ++++++++++++++++++ 18 files changed, 301 insertions(+) create mode 100644 packages/dropzone/test/fixtures/exif/1-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/1-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/2-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/2-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/3-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/3-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/4-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/4-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/5-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/5-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/6-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/6-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/7-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/7-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/8-be.jpg create mode 100644 packages/dropzone/test/fixtures/exif/8-le.jpg create mode 100644 packages/dropzone/test/fixtures/exif/generate.py create mode 100644 packages/dropzone/test/unit-tests/exif.js diff --git a/packages/dropzone/test/fixtures/exif/1-be.jpg b/packages/dropzone/test/fixtures/exif/1-be.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d5e299ff128d247995d5441a02192ab99d2e8172 GIT binary patch literal 426 zcmb7d@IuH*v6_cTnL#!NEyfS}1~*;z98PxH!AIIZD4m zhw9L+gJ5;3yC8mo_;M~5C;#ESy!X#93D3Q_Ig^4flp!aOUMR_X>p1VGxKw zhEkTHA}v*Hq)$ literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/1-le.jpg b/packages/dropzone/test/fixtures/exif/1-le.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4d96d96305271c82b14f58d8525d000b30ae4612 GIT binary patch literal 426 zcmb7>ze)o^5XQgVz1x2|@m2x`Vv*WTJFzgJcW{EBU|}UT2?W7N@F4gAHrBRwmXcRU zp%!))g3%`Sg7^gM?AaKs{FZN+o%!u9!}=FLMY^3AIT!;NE%4Jz5+TM>BqAA0 zSy>q^l~Gdanl?6wvZQ8{unCv^9ObbPu~kx4$iIRg!)dh87R-S=CLHs3Sft^@_`jsB zp+-HOtr9@hMxbYTt5wCjzx*o_!RRgFuz}0T&gT@kS-Nc;fghLIHDLE1pDuv4!_mVB X(5$zc`#@5TzOKfQ_o;lQ5Ac(3Xe>J7 literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/2-be.jpg b/packages/dropzone/test/fixtures/exif/2-be.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1cf2d62f9b063ef0d67e400222675232bd81d179 GIT binary patch literal 427 zcmb79Jxc>Y6r6W^x3{-BF)M<021_fowNTKUY&{M>UGQkP!Kc@ zkE@8eND~lzlXGYx_s6(jVq&e==^k|sny5BvNu|{u9QJ!n*f*@gBL}CRgD;#0zAtx&kcHk;5>DV>yli#OaAVi=;8Rn6HiOpaXDp(O@;29Ez>;tBFdAI%_ zXeD_tSuVO9V0W6cqBsa6tz6y|ZI^#V%wf+6gJq1KzHdH37mwF_ClKlE?R*BgpPpR; ey0N)^p26?meSLI6yQg%21+4EJ54V6B`|<-D+da+z literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/2-le.jpg b/packages/dropzone/test/fixtures/exif/2-le.jpg new file mode 100644 index 0000000000000000000000000000000000000000..686d10803a60d7d7388ae5ad83eaf44e3dfe4fe5 GIT binary patch literal 427 zcmb7mW63B*0S0m^xrr62-`7u-8Gs=a0d$g(*Y021RQ z5OKH2o*?Nl2=)T!W4tf1z1BLd_EGDgj%qDSD~)FFu-mD_y{w85sDfAg%Z}`A&uhTv495+!rtY?*0dvMb4HQ_$6gMb9WfgohzC=5AN zky1tI*eD%qrA*1hiNI%RDM_V}M}9|xh&f8M(uL(;KqC1eVy6d%}WWUe^Bu zlf=F;U!GRILG0q5ZY0`>-A=1w&yh0uuh?P}U)yK-HSm1ouy_ZoRnONCfV6-AJ)VGv ZF?TP4tNi8#h^ph)3t;K-_F)8a??0*=J!Sv^ literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/3-le.jpg b/packages/dropzone/test/fixtures/exif/3-le.jpg new file mode 100644 index 0000000000000000000000000000000000000000..51bb67510d9a453ff0664d722f1d3e5c10e155f0 GIT binary patch literal 426 zcmb7ZN*l!HlEg;XAVF3_k zRe+eQRgO7Dg0Ty%us_5528U?3*Xf>ij+ljmz_gz0b6jt6V6Wf1xays^k+$kttFxE=vmVc3(!Wz`DL&8E@K~}gYEb;Pw{Xd9J z#EpeYQDpr+q5(tWsU?5nf^a1QFCD`aQ zB!$*C78)DH2O00Ijazz#bLY;v-wcd@^Pi~gXL$z307eV`3(a)c?Erbc1_MB#$~Y`_ z8{~N=0tQK5r+kj~EppIazjtxfJ8vUx19wa~=AW=cpAX;vqu5v) zEY!*=0hHE>73G08alyNC9QYX$g1Hk8+xWhnTt5J>0Z6EZg6pVzx?hJ!72Fx=Rh5ysO%>4}|xu-m-wn zIcwpp52BB?-fA#RxV0i}58DbOwuc7N9rIsnF!Nnulsb^7RKI2ck97b`Hsg-Yf14T% R-Z}`bVSA+h>lDNPn*hx$PdWeq literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/5-le.jpg b/packages/dropzone/test/fixtures/exif/5-le.jpg new file mode 100644 index 0000000000000000000000000000000000000000..68be8f163665cc1a694e5db6ce99ef9fd586587d GIT binary patch literal 456 zcmex=59-L^6Q@ z3p0phWnp1vVPj=u17mhh4mNg9b~ZK+ZVpZ^5CF1xxVd;h9FQRptxT-U%&c7OZ0ukq zBoY4)FbHxmI4~$MGYT>=2{JMZGX6ipAO`e2$VW(kgA*t&B7i0e44fkj;vj#ZN^o*< zfRqcOiUN)Me~W>KnGt9cvmk>#L-(C!`x{iuR!OdBj5AtysezMsRa^Ff@SfFM7BD$y zEu8g1^s&}k4TcG~R;2A=TVcfZ&_KFl{%Z|pzAKDU2lABa*DT<%4q(Y<+|l`OQ-i@< Q2f;OLkJNviV)%a(0KyDUG5`Po literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/6-be.jpg b/packages/dropzone/test/fixtures/exif/6-be.jpg new file mode 100644 index 0000000000000000000000000000000000000000..023f98a8605373d5329d31b905ad1b24073acff0 GIT binary patch literal 457 zcmex=| z;qX=lw++%A^IvN)pS4=0>%hsosxA9Kc+cuB3z(d<7S8%0`dI6&2E&9~E7JC`R-D@H O#h_xgN^(8p|C<0ckx(oE literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/6-le.jpg b/packages/dropzone/test/fixtures/exif/6-le.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4e75fd6aaa0c1231013dd32958b70ebd250b8dd8 GIT binary patch literal 457 zcmex=59-L^6Q@ z3p0phWnp1vVPj=u17mhh4mNg9b~ZK+ZVpZ^5CF1xxVd;h9FQRptxT-U%&c7OZ0ukq zBoY4)FbHxmI4~$MGYT>=2{JMZGX6ipAOZ9|5J0>DAvrkNxPUSu0w|&&IiRQ*x@ran zPOxf0Ox3p-c$gW1Rxt}Q*fTu1{8#@#o>Kjq1w7UPEZK}ZI{$5IFnH@AxQ6YK`ma+A zhqp4gZIJGm|5}6jtko)A2TtBqZP^FHdsc5*z~r2@aMlOW$69YS7$)3Wk+z4m;?!<0 O1{JealIt1&-vj_EYEUEq literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/7-be.jpg b/packages/dropzone/test/fixtures/exif/7-be.jpg new file mode 100644 index 0000000000000000000000000000000000000000..92ad53ecb98406152cc08db09b6b511661e790c2 GIT binary patch literal 458 zcmb7AyGjE=6g_ulcOIJ!?udwXDMc%7EeymgL|Cx2v`HWeMuQ8B|6y-w>mOL8@CSrM z8~p(5SUKKV8y^tx4EM~rhch$W@l*eT>>%&uAOeUN{3{N-)o=h51vUW~NLqqmWw~}} z8IpH`X4HaZqie_SpS$Ulv|JQ^W$c9U2fpvy_s}1Hn zDT%=_*V%9LzR48qj7O&zqmw>*2W8P24D0i1+(&rAHX;&8jRdihn#8e+b)t1cc1&l>AJds~?f(7kuw7qPE4Vwl-m7QH GF#QC%%S_z> literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/8-be.jpg b/packages/dropzone/test/fixtures/exif/8-be.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d3c90ac56ca739a970fb1c67c2a051018fc7e97e GIT binary patch literal 457 zcmb79Jxc>Y5PfsIx3_mWH5-+*DJ7keR4F2yIfxLr!on)pTp$FE28Cc{WohZZh}hVt zu&@>sKbnYG+S|ywdp4d{zGdFdym_3bH;@$ zL>8Veh39)h$b$3(3zLO~Ahd;j!>hNa1=2``Wj`kyj}kX zd>NR=QZcU@gSA_CzTpR+wX3{p?N109nXi~a6K8{ux1IWH^?e05yIX5r)~#LKo`k1& iQGNDDo5mZ#Ab;>)sX-Y2|~@Btkxex~>>>c=l=U`a{< literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/8-le.jpg b/packages/dropzone/test/fixtures/exif/8-le.jpg new file mode 100644 index 0000000000000000000000000000000000000000..313d364a6fc8c44a5e5d24d50b46f3d7fc040855 GIT binary patch literal 457 zcmb7v8We(+m8GTsg~7%? zg@v`C_|Zhf(%weaowM<@@+~vWzWL2AV;;;5<;^&W!5F~ofq6mN8TWfYlGNb?;8~jF(AiGnTCYDoJQ`&%&xqhK5@m-ZIAvD|E~G1E z9-i{0r#vbBlCJ`it-?|e(uE%R9(7#K-N2Jxj{GZ`DYTDu)Hu^{G~=3?YgFy>wEhn% zKd_CZa?!N_;Y1f3D)0!`MVIg=gq+#0xJDaigO9hJ=4<_Z1vk4}YhB)LOm0uY)4QlS g`(w|aS1y*P(G}v$s&3z>qLqpPJ6Qb8@Y^(xUtK#%LI3~& literal 0 HcmV?d00001 diff --git a/packages/dropzone/test/fixtures/exif/generate.py b/packages/dropzone/test/fixtures/exif/generate.py new file mode 100644 index 000000000..a3cc91045 --- /dev/null +++ b/packages/dropzone/test/fixtures/exif/generate.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Generates the EXIF orientation fixtures. Needs Python 3 and ImageMagick 7. + +Every fixture *displays* as the same 64x32 image, one colour per quadrant: + + red green + blue yellow + +What differs is how it gets there. The pixels are stored transformed by the +inverse of one of the eight EXIF orientations, and the file carries that +orientation, so a viewer that honours it undoes the transform. Each exists +twice, with a big-endian ("MM") and a little-endian ("II") TIFF header, since +cameras write both. + +The EXIF block holds Make, Orientation and Software, in that order, so a +reader has to look past the first entry and a writer has to leave its +neighbours alone. + +ImageMagick checks the result: every file must auto-orient back to the image +above. That keeps the fixtures independent of both the browser and Dropzone's +own EXIF code. + +Run from this directory: python3 generate.py +""" +import struct +import subprocess +import tempfile +from pathlib import Path + +HERE = Path(__file__).parent + +# The operation that turns the displayed image into the stored one, i.e. the +# inverse of what the orientation asks a viewer to do. +STORE = { + 1: [], + 2: ["-flop"], + 3: ["-rotate", "180"], + 4: ["-flip"], + 5: ["-transpose"], + 6: ["-rotate", "-90"], + 7: ["-transverse"], + 8: ["-rotate", "90"], +} + + +def magick(*args): + return subprocess.run(["magick", *args], capture_output=True, check=True).stdout + + +def exif_segment(orientation, big_endian): + bo = ">" if big_endian else "<" + make, software = b"Dropzone\0", b"EXIF fixture\0" + entries = 3 + data = 8 + 2 + 12 * entries + 4 # where values too big for an entry start + ifd = struct.pack(bo + "H", entries) + ifd += struct.pack(bo + "HHII", 0x010F, 2, len(make), data) + ifd += struct.pack(bo + "HHIHH", 0x0112, 3, 1, orientation, 0) + ifd += struct.pack(bo + "HHII", 0x0131, 2, len(software), data + len(make)) + ifd += struct.pack(bo + "I", 0) + tiff = (b"MM" if big_endian else b"II") + struct.pack(bo + "HI", 42, 8) + ifd + make + software + payload = b"Exif\0\0" + tiff + return b"\xff\xe1" + struct.pack(">H", len(payload) + 2) + payload + + +def quadrants(path): + colours = [] + for x, y in ((16, 8), (48, 8), (16, 24), (48, 24)): + r, g, b = (int(v) for v in magick(path, "-format", + f"%[fx:int(255*p{{{x},{y}}}.r)] %[fx:int(255*p{{{x},{y}}}.g)] %[fx:int(255*p{{{x},{y}}}.b)]", + "info:").split()) + colours.append("Y" if r > 150 and g > 150 and b < 100 else "R" if r > 150 + else "G" if g > 100 else "B" if b > 150 else "?") + return "".join(colours) + + +with tempfile.TemporaryDirectory() as tmp: + display = Path(tmp) / "display.png" + magick("-size", "32x16", "xc:#ff0000", "-size", "32x16", "xc:#00c000", "+append", + "(", "-size", "32x16", "xc:#0000ff", "-size", "32x16", "xc:#ffff00", "+append", ")", + "-append", str(display)) + + for orientation, store in STORE.items(): + for big_endian in (True, False): + jpeg = magick(str(display), *store, "-quality", "95", "jpg:-") + assert jpeg[:4] == b"\xff\xd8\xff\xe0", "expected SOI followed by APP0" + app0_end = 4 + struct.unpack(">H", jpeg[4:6])[0] + name = HERE / f"{orientation}-{'be' if big_endian else 'le'}.jpg" + name.write_bytes(jpeg[:app0_end] + exif_segment(orientation, big_endian) + jpeg[app0_end:]) + + upright = Path(tmp) / "upright.png" + magick(str(name), "-auto-orient", str(upright)) + size = magick("identify", "-format", "%wx%h", str(upright)).decode() + seen = quadrants(str(upright)) + assert (size, seen) == ("64x32", "RGBY"), f"{name.name}: {size} {seen}" + print(f"{name.name}: orientation {orientation}, displays as {size} {seen}") diff --git a/packages/dropzone/test/unit-tests/exif.js b/packages/dropzone/test/unit-tests/exif.js new file mode 100644 index 000000000..7409467ce --- /dev/null +++ b/packages/dropzone/test/unit-tests/exif.js @@ -0,0 +1,206 @@ +import { Dropzone } from "../../src/dropzone"; + +// Every fixture displays as the same 64x32 image, one colour per quadrant -- +// red, green / blue, yellow -- but stores its pixels transformed by the inverse +// of an EXIF orientation. See ../fixtures/exif/generate.py. +const fixtures = Object.entries( + import.meta.glob("../fixtures/exif/*.jpg", { query: "?inline", import: "default", eager: true }), +).map(([path, url]) => { + let [, orientation, byteOrder] = path.match(/(\d)-(be|le)\.jpg$/); + return { name: `${orientation}-${byteOrder}`, orientation: Number(orientation), url }; +}); + +let bytesOf = (url) => + Uint8Array.from(atob(url.slice(url.indexOf(",") + 1)), (char) => char.charCodeAt(0)); + +function jpegUrlOf(bytes) { + let binary = ""; + for (let byte of bytes) binary += String.fromCharCode(byte); + return `data:image/jpeg;base64,${btoa(binary)}`; +} + +let fileOf = ({ name, url }) => new File([bytesOf(url)], `${name}.jpg`, { type: "image/jpeg" }); + +let dataUrlOf = (blob) => + new Promise((resolve) => { + let reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + +// What an image looks like once the browser has decoded it: its size, and the +// colour at the centre of each quadrant. +async function looks(src) { + let img = await new Promise((resolve, reject) => { + let img = document.createElement("img"); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + let { naturalWidth: width, naturalHeight: height } = img; + let canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + let colours = [ + [0.25, 0.25], + [0.75, 0.25], + [0.25, 0.75], + [0.75, 0.75], + ].map(([fx, fy]) => { + let [r, g, b] = ctx.getImageData(Math.floor(width * fx), Math.floor(height * fy), 1, 1).data; + if (r > 150 && g > 150 && b < 100) return "Y"; + if (r > 150) return "R"; + if (g > 100) return "G"; + if (b > 150) return "B"; + return "?"; + }); + return `${width}x${height} ${colours.join("")}`; +} + +// A viewer that ignores EXIF shows the stored pixels as they are. Dropping the +// APP1 segments gets the browser to do the same. +function withoutExif(url) { + let bytes = bytesOf(url); + let kept = [bytes.subarray(0, 2)]; + let p = 2; + while (p + 4 <= bytes.length && bytes[p] === 0xff && bytes[p + 1] !== 0xda) { + let end = p + 2 + ((bytes[p + 2] << 8) | bytes[p + 3]); + if (bytes[p + 1] !== 0xe1) kept.push(bytes.subarray(p, end)); + p = end; + } + kept.push(bytes.subarray(p)); + + let joined = new Uint8Array(kept.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (let part of kept) { + joined.set(part, offset); + offset += part.length; + } + return jpegUrlOf(joined); +} + +// Orientation and Make, read without going through Dropzone's own EXIF code. +// It only has to cope with the fixtures and what a canvas writes. +function exifOf(url) { + let b = bytesOf(url); + let p = 2; + while (p + 4 <= b.length && b[p] === 0xff && b[p + 1] !== 0xda) { + let isExif = String.fromCharCode(...b.subarray(p + 4, p + 10)) === "Exif\0\0"; + if (b[p + 1] === 0xe1 && isExif) { + let tiff = p + 10; + let little = b[tiff] === 0x49; + let u16 = (o) => (little ? b[o] | (b[o + 1] << 8) : (b[o] << 8) | b[o + 1]); + let u32 = (o) => (little ? u16(o) + u16(o + 2) * 65536 : u16(o) * 65536 + u16(o + 2)); + let ifd = tiff + u32(tiff + 4); + let tags = {}; + for (let i = 0; i < u16(ifd); i++) { + let entry = ifd + 2 + 12 * i; + let count = u32(entry + 4); + if (u16(entry) === 0x0112) tags.orientation = u16(entry + 8); + if (u16(entry) === 0x010f) { + let at = count > 4 ? tiff + u32(entry + 8) : entry + 8; + tags.make = String.fromCharCode(...b.subarray(at, at + count - 1)); + } + } + return tags; + } + p += 2 + ((b[p + 2] << 8) | b[p + 3]); + } + return null; +} + +describe("EXIF orientation", function () { + let dropzone = null; + beforeEach(() => (dropzone = new Dropzone(document.createElement("div"), { url: "/" }))); + afterEach(() => dropzone.destroy()); + + let thumbnail = (file, width, height, method) => + new Promise((resolve) => dropzone.createThumbnail(file, width, height, method, true, resolve)); + + let resize = (file, width, height, method) => + new Promise((resolve) => dropzone.resizeImage(file, width, height, method, resolve)).then( + dataUrlOf, + ); + + describe.each(fixtures)("orientation $orientation ($name)", (fixture) => { + it("should draw the thumbnail upright", async function () { + let file = fileOf(fixture); + + expect(await looks(await thumbnail(file, 32, 32, "contain"))).toBe("32x16 RGBY"); + // The dimensions are the displayed ones, which is what `resize` and + // anyone reading them in a `thumbnail` handler work with. + expect([file.width, file.height]).toEqual([64, 32]); + }); + + it("should resize along the displayed axes", async function () { + let resized = await resize(fileOf(fixture), 32, null, "contain"); + + // `resizeWidth` limits the width the image is shown at, whichever way + // round its pixels are stored. + expect(await looks(withoutExif(resized))).toBe("32x16 RGBY"); + }); + + it("should crop along the displayed axes", async function () { + let resized = await resize(fileOf(fixture), 32, 32, "crop"); + + expect(await looks(withoutExif(resized))).toBe("32x32 RGBY"); + }); + + it("should take a custom resize's source rectangle from the displayed image", async function () { + // Off-centre on purpose: the default `resize` only ever crops around the + // middle, which looks the same however the image is flipped. + dropzone.options.resize = () => ({ + srcX: 32, + srcY: 0, + srcWidth: 32, + srcHeight: 16, + trgWidth: 32, + trgHeight: 16, + }); + + expect(await looks(await thumbnail(fileOf(fixture), null, null, "contain"))).toBe( + "32x16 GGGG", + ); + }); + + it("should carry the original EXIF over to the resized image", async function () { + let resized = await resize(fileOf(fixture), 32, null, "contain"); + + expect(exifOf(resized)).toEqual({ orientation: fixture.orientation, make: "Dropzone" }); + }); + }); + + it("should leave an image from the server for the browser to orient", async function () { + let { url } = fixtures.find((fixture) => fixture.name === "6-be"); + + let shown = await new Promise((resolve) => { + dropzone.on("thumbnail", (file, dataUrl) => resolve(dataUrl)); + dropzone.displayExistingFile({ name: "photo.jpg", size: 1 }, url, null, null, true); + }); + + expect(await looks(shown)).toBe("64x32 RGBY"); + }); + + it("should leave a PNG alone", async function () { + let canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 32; + let ctx = canvas.getContext("2d"); + for (let [colour, x, y] of [ + ["#ff0000", 0, 0], + ["#00c000", 32, 0], + ["#0000ff", 0, 16], + ["#ffff00", 32, 16], + ]) { + ctx.fillStyle = colour; + ctx.fillRect(x, y, 32, 16); + } + let blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + let png = new File([blob], "upright.png", { type: "image/png" }); + + expect(await looks(await thumbnail(png, 32, 32, "contain"))).toBe("32x16 RGBY"); + expect(await looks(await resize(png, 32, null, "contain"))).toBe("32x16 RGBY"); + }); +}); From 487ae9f4dec608f72925aed8e151795a748c2a5c Mon Sep 17 00:00:00 2001 From: Matias Simon Date: Sun, 27 Sep 2026 21:33:08 +0200 Subject: [PATCH 2/6] Replace ExifRestore with a small EXIF module ExifRestore carried a hand-written base64 codec that decoded the whole original image in JavaScript, one character at a time, just to find a segment near its start. src/exif.ts does the same job with atob and btoa, and decodes only the head of the original -- 192 KiB, falling back to the whole file when EXIF sits further in. The faster codec is @kaymes' idea from #2001. It picks the same segment as before -- the first APP1 -- and was checked byte for byte against ExifRestore on every fixture, over five canvas encodings each and with originals that have no EXIF at all: 160 comparisons, all identical. Where to insert it is now read from the segment structure rather than taken as the first 0xFF after byte 3. That is the same place for a JPEG that opens with a JFIF header, which is what canvases write; without one, the old rule landed inside the next segment. restoreExif never throws. It runs inside `transformFile`, where an exception would leave the upload waiting forever; it hands the resized image back without the metadata instead. --- packages/dropzone/src/dropzone.ts | 161 +--------------------- packages/dropzone/src/exif.ts | 126 +++++++++++++++++ packages/dropzone/test/unit-tests/exif.js | 59 ++++++++ 3 files changed, 187 insertions(+), 159 deletions(-) create mode 100644 packages/dropzone/src/exif.ts diff --git a/packages/dropzone/src/dropzone.ts b/packages/dropzone/src/dropzone.ts index 0cc626bf6..07943e674 100644 --- a/packages/dropzone/src/dropzone.ts +++ b/packages/dropzone/src/dropzone.ts @@ -2,6 +2,7 @@ import { version } from "../package.json"; import extend from "./extend"; import Emitter from "./emitter"; +import { restoreExif } from "./exif"; import defaultOptions from "./options"; import type { DropzoneOptions, ResolvedDropzoneOptions } from "./options"; @@ -1073,7 +1074,7 @@ export default class Dropzone extends Emitter { let resizedDataURL = canvas.toDataURL(resizeMimeType, this.options.resizeQuality); if (resizeMimeType === "image/jpeg" || resizeMimeType === "image/jpg") { // Now add the original EXIF information - resizedDataURL = ExifRestore.restore(file.dataURL!, resizedDataURL); + resizedDataURL = restoreExif(file.dataURL!, resizedDataURL); } return callback(Dropzone.dataURItoBlob(resizedDataURL)); } @@ -2283,164 +2284,6 @@ var drawImageIOSFix = function ( return ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh / vertSquashRatio); }; -// Based on MinifyJpeg -// Source: http://www.perry.cz/files/ExifRestorer.js -// http://elicon.blog57.fc2.com/blog-entry-206.html -class ExifRestore { - static KEY_STR: string; - - static initClass() { - this.KEY_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; - } - - static encode64(input: any) { - let output = ""; - let chr1: any = undefined; - let chr2: any = undefined; - let chr3: any = ""; - let enc1: any = undefined; - let enc2: any = undefined; - let enc3: any = undefined; - let enc4: any = ""; - let i = 0; - while (true) { - chr1 = input[i++]; - chr2 = input[i++]; - chr3 = input[i++]; - enc1 = chr1 >> 2; - enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); - enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); - enc4 = chr3 & 63; - if (isNaN(chr2)) { - enc3 = enc4 = 64; - } else if (isNaN(chr3)) { - enc4 = 64; - } - output = - output + - this.KEY_STR.charAt(enc1) + - this.KEY_STR.charAt(enc2) + - this.KEY_STR.charAt(enc3) + - this.KEY_STR.charAt(enc4); - chr1 = chr2 = chr3 = ""; - enc1 = enc2 = enc3 = enc4 = ""; - if (!(i < input.length)) { - break; - } - } - return output; - } - - static restore(origFileBase64: string, resizedFileBase64: string) { - if (!origFileBase64.match("data:image/jpeg;base64,")) { - return resizedFileBase64; - } - let rawImage = this.decode64(origFileBase64.replace("data:image/jpeg;base64,", "")); - let segments = this.slice2Segments(rawImage); - let image = this.exifManipulation(resizedFileBase64, segments); - return `data:image/jpeg;base64,${this.encode64(image)}`; - } - - static exifManipulation(resizedFileBase64: string, segments: any[]) { - let exifArray = this.getExifArray(segments); - let newImageArray = this.insertExif(resizedFileBase64, exifArray); - let aBuffer = new Uint8Array(newImageArray); - return aBuffer; - } - - static getExifArray(segments: any[]) { - let seg = undefined; - let x = 0; - while (x < segments.length) { - seg = segments[x]; - if (seg[0] === 255 && seg[1] === 225) { - return seg; - } - x++; - } - return []; - } - - static insertExif(resizedFileBase64: string, exifArray: any[]) { - let imageData = resizedFileBase64.replace("data:image/jpeg;base64,", ""); - let buf = this.decode64(imageData); - let separatePoint = buf.indexOf(255, 3); - let mae = buf.slice(0, separatePoint); - let ato = buf.slice(separatePoint); - let array = mae; - array = array.concat(exifArray); - array = array.concat(ato); - return array; - } - - static slice2Segments(rawImageArray: any[]) { - let head = 0; - let segments = []; - while (true) { - var length; - if (rawImageArray[head] === 255 && rawImageArray[head + 1] === 218) { - break; - } - if (rawImageArray[head] === 255 && rawImageArray[head + 1] === 216) { - head += 2; - } else { - length = rawImageArray[head + 2] * 256 + rawImageArray[head + 3]; - let endPoint = head + length + 2; - let seg = rawImageArray.slice(head, endPoint); - segments.push(seg); - head = endPoint; - } - if (head > rawImageArray.length) { - break; - } - } - return segments; - } - - static decode64(input: any) { - let chr1: any = undefined; - let chr2: any = undefined; - let chr3: any = ""; - let enc1: any = undefined; - let enc2: any = undefined; - let enc3: any = undefined; - let enc4: any = ""; - let i = 0; - let buf: number[] = []; - // remove all characters that are not A-Z, a-z, 0-9, +, /, or = - let base64test = /[^A-Za-z0-9+/=]/g; - if (base64test.exec(input)) { - console.warn( - "There were invalid base64 characters in the input text.\nValid base64 characters are A-Z, a-z, 0-9, '+', '/',and '='\nExpect errors in decoding.", - ); - } - input = input.replace(/[^A-Za-z0-9+/=]/g, ""); - while (true) { - enc1 = this.KEY_STR.indexOf(input.charAt(i++)); - enc2 = this.KEY_STR.indexOf(input.charAt(i++)); - enc3 = this.KEY_STR.indexOf(input.charAt(i++)); - enc4 = this.KEY_STR.indexOf(input.charAt(i++)); - chr1 = (enc1 << 2) | (enc2 >> 4); - chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); - chr3 = ((enc3 & 3) << 6) | enc4; - buf.push(chr1); - if (enc3 !== 64) { - buf.push(chr2); - } - if (enc4 !== 64) { - buf.push(chr3); - } - chr1 = chr2 = chr3 = ""; - enc1 = enc2 = enc3 = enc4 = ""; - if (!(i < input.length)) { - break; - } - } - return buf; - } -} -ExifRestore.initClass(); - function __guard__(value: any, transform: any) { return typeof value !== "undefined" && value !== null ? transform(value) : undefined; } diff --git a/packages/dropzone/src/exif.ts b/packages/dropzone/src/exif.ts new file mode 100644 index 000000000..6cff91d25 --- /dev/null +++ b/packages/dropzone/src/exif.ts @@ -0,0 +1,126 @@ +// Carrying a JPEG's EXIF over to a resized copy of it. +// +// A canvas cannot write metadata, so when `resizeImage` re-encodes a photo, +// the camera's EXIF -- date taken, make and model, location -- would be lost. +// This copies the block across from the original. Both images are data URLs +// at that point, so it works on their base64 directly. + +const SOI = 0xd8; +const SOS = 0xda; +const APP0 = 0xe0; +const APP1 = 0xe1; + +// How much of a JPEG is decoded to look at its segments, in bytes. EXIF sits +// at the front and is at most 64 KiB, so this reaches past it in all but +// unusual files; anything else falls back to decoding the whole thing. A +// multiple of three, so the base64 it came from ends on a whole group. +const HEAD_BYTES = 3 * 65536; + +type Segment = { marker: number; start: number; end: number }; + +function decode(base64: string): Uint8Array { + let binary = atob(base64); + let bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function encode(bytes: Uint8Array): string { + // In slices, because `fromCharCode` takes the bytes as arguments, and an + // image has more of those than an engine will pass in one call. + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +} + +// The base64 of a data URL of the given type, or null for anything else. +function base64Of(dataURL: string, type: string): string | null { + let prefix = `data:${type};base64,`; + return dataURL.startsWith(prefix) ? dataURL.slice(prefix.length) : null; +} + +// The segments ahead of the image data, or null if the bytes run out before +// it starts -- which for a header decoded on its own means it was cut too +// short. Anything that is not a marker where one should be ends the search. +function segmentsOf(bytes: Uint8Array): Segment[] | null { + if (bytes[0] !== 0xff || bytes[1] !== SOI) { + return []; + } + let segments: Segment[] = []; + let p = 2; + while (p + 4 <= bytes.length) { + if (bytes[p] !== 0xff || bytes[p + 1] === SOS) { + return segments; + } + let end = p + 2 + ((bytes[p + 2] << 8) | bytes[p + 3]); + if (end > bytes.length) { + return null; + } + segments.push({ marker: bytes[p + 1], start: p, end }); + p = end; + } + return null; +} + +// A JPEG's segments, decoding no more of its base64 than it takes to reach +// them. `chars` is how much of the base64 that was. +function readHead(base64: string) { + let chars = Math.min(base64.length, (HEAD_BYTES / 3) * 4); + let bytes = decode(base64.slice(0, chars)); + let segments = segmentsOf(bytes); + if (segments == null && chars < base64.length) { + chars = base64.length; + bytes = decode(base64); + segments = segmentsOf(bytes); + } + return { bytes, segments: segments ?? [], chars }; +} + +// `resized` with the first APP1 segment of `original` added, which is where a +// JPEG keeps its EXIF. Returned unchanged if there is none, or if either one +// is not a JPEG. +// +// It never throws. This runs inside `transformFile`, and an exception there +// would leave the upload waiting for a `done` that never comes; losing the +// metadata is the better outcome. +export function restoreExif(original: string, resized: string): string { + try { + return insertExif(original, resized); + } catch { + return resized; + } +} + +function insertExif(original: string, resized: string): string { + let originalBase64 = base64Of(original, "image/jpeg"); + let resizedBase64 = base64Of(resized, "image/jpeg"); + if (originalBase64 == null || resizedBase64 == null) { + return resized; + } + + let source = readHead(originalBase64); + let exif = source.segments.find((segment) => segment.marker === APP1); + if (exif == null) { + return resized; + } + let block = source.bytes.subarray(exif.start, exif.end); + + let target = decode(resizedBase64); + let first = segmentsOf(target)?.[0]; + if (target[0] !== 0xff || target[1] !== SOI) { + return resized; + } + // After the JFIF header when there is one, since that has to come first, + // and straight after the start-of-image marker otherwise. + let at = first?.marker === APP0 ? first.end : 2; + + let joined = new Uint8Array(target.length + block.length); + joined.set(target.subarray(0, at)); + joined.set(block, at); + joined.set(target.subarray(at), at + block.length); + return `data:image/jpeg;base64,${encode(joined)}`; +} diff --git a/packages/dropzone/test/unit-tests/exif.js b/packages/dropzone/test/unit-tests/exif.js index 7409467ce..631939fd3 100644 --- a/packages/dropzone/test/unit-tests/exif.js +++ b/packages/dropzone/test/unit-tests/exif.js @@ -1,4 +1,5 @@ import { Dropzone } from "../../src/dropzone"; +import { restoreExif } from "../../src/exif"; // Every fixture displays as the same 64x32 image, one colour per quadrant -- // red, green / blue, yellow -- but stores its pixels transformed by the inverse @@ -204,3 +205,61 @@ describe("EXIF orientation", function () { expect(await looks(await resize(png, 32, null, "contain"))).toBe("32x16 RGBY"); }); }); + +// Just enough of a JPEG for the segment handling: markers and lengths are +// real, the contents are not. +let segment = (marker, payload) => { + let length = payload.length + 2; + return [0xff, marker, length >> 8, length & 0xff, ...payload]; +}; +let text = (string) => Array.from(string, (char) => char.charCodeAt(0)); +let jpeg = (...segments) => + jpegUrlOf(Uint8Array.from([0xff, 0xd8, ...segments.flat(), 0xff, 0xda, 0, 2, 0x12, 0xff, 0xd9])); + +const JFIF = segment(0xe0, text("JFIF\0\x01\x01\0\0\x01\0\x01\0\0")); +const EXIF = segment(0xe1, text("Exif\0\0MM\0*\0\0\0\x08\0\0")); +const XMP = segment(0xe1, text("http://ns.adobe.com/xap/1.0/\0")); +// A quantisation table full of 0xFF, which is not a marker in there. +const TABLE = segment(0xdb, [0, ...Array(64).fill(0xff)]); + +describe("restoreExif()", function () { + it("should put the EXIF segment after the JFIF header", function () { + expect(restoreExif(jpeg(JFIF, EXIF, TABLE), jpeg(JFIF, TABLE))).toBe(jpeg(JFIF, EXIF, TABLE)); + }); + + it("should put it straight after the start of image when there is no JFIF header", function () { + expect(restoreExif(jpeg(JFIF, EXIF, TABLE), jpeg(TABLE))).toBe(jpeg(EXIF, TABLE)); + }); + + it("should take the first APP1 segment, whatever it holds", function () { + expect(restoreExif(jpeg(JFIF, XMP, EXIF), jpeg(JFIF, TABLE))).toBe(jpeg(JFIF, XMP, TABLE)); + }); + + it("should find EXIF past the part of the file it decodes first", function () { + // Four segments of the largest size there is push it past 192 KiB. + let large = segment(0xe2, Array(65533).fill(0)); + let original = jpeg(JFIF, large, large, large, large, EXIF, TABLE); + + expect(restoreExif(original, jpeg(JFIF, TABLE))).toBe(jpeg(JFIF, EXIF, TABLE)); + }); + + it("should hand the resized image back as it is when the original has no EXIF", function () { + let resized = jpeg(JFIF, TABLE); + + expect(restoreExif(jpeg(JFIF, TABLE), resized)).toBe(resized); + }); + + it("should hand the resized image back as it is if the original cannot be decoded", function () { + let resized = jpeg(JFIF, TABLE); + + expect(restoreExif("data:image/jpeg;base64,not*base64", resized)).toBe(resized); + }); + + it("should hand the resized image back as it is unless both are JPEGs", function () { + let png = "data:image/png;base64,iVBORw0KGgo="; + let resized = jpeg(JFIF, TABLE); + + expect(restoreExif(png, resized)).toBe(resized); + expect(restoreExif(jpeg(JFIF, EXIF, TABLE), png)).toBe(png); + }); +}); From 009c2d57aeaae0f18ec98203d1354cf45aabbbd8 Mon Sep 17 00:00:00 2001 From: Matias Simon Date: Sun, 27 Sep 2026 21:38:06 +0200 Subject: [PATCH 3/6] Turn JPEGs upright in Dropzone rather than the browser Whether a JPEG's EXIF orientation got applied depended on the browser. Current ones do it while decoding; older ones do not, and there Dropzone only rotated the image if the page had loaded exif.js. To know what the pixels look like after drawing -- which the resized image's EXIF has to match -- you would have to know which of those happened. So Dropzone applies the orientation itself. extractOrientation reads it from the data URL and hands the browser a copy whose value is set to 1, changing those two bytes and re-encoding only the head of the file. The drawing then turns the pixels upright on the canvas. Every browser runs the same code and produces the same image, and exif.js is no longer consulted. The rotation code that was already there could not simply be switched on: it measured the image as stored, so `resize` got its dimensions in the wrong axes. For a portrait photo, `resizeWidth` would have limited the displayed height and a crop would have come out the wrong shape -- the same thing that makes #2001's strip-and-restore approach a behaviour change. Everything is now worked out in displayed axes, as the browser did it: `file.width` and `file.height` are the displayed size, `resize` sees that, and its source rectangle is mapped back into the stored pixels before drawing. Every test from the first commit passes unchanged. They were checked against four deliberate mistakes -- resizing in stored axes, a wrong rotation, an unmapped source rectangle, the browser also rotating -- and each one fails between 6 and 56 of them. Images passed to `displayExistingFile` are still left to the browser: they need not be data URLs, and nothing is resized from them. --- packages/dropzone/src/dropzone.ts | 146 ++++++++---------- packages/dropzone/src/exif.ts | 174 ++++++++++++++++++++-- packages/dropzone/src/types.d.ts | 6 +- packages/dropzone/test/unit-tests/exif.js | 144 +++++++++++++++++- 4 files changed, 369 insertions(+), 101 deletions(-) diff --git a/packages/dropzone/src/dropzone.ts b/packages/dropzone/src/dropzone.ts index 07943e674..d3d1d4c6c 100644 --- a/packages/dropzone/src/dropzone.ts +++ b/packages/dropzone/src/dropzone.ts @@ -2,7 +2,7 @@ import { version } from "../package.json"; import extend from "./extend"; import Emitter from "./emitter"; -import { restoreExif } from "./exif"; +import { extractOrientation, orientationTransform, restoreExif, storedRect } from "./exif"; import defaultOptions from "./options"; import type { DropzoneOptions, ResolvedDropzoneOptions } from "./options"; @@ -1184,95 +1184,69 @@ export default class Dropzone extends Emitter { img.crossOrigin = crossOrigin; } - // fixOrientation is not needed anymore with browsers handling imageOrientation - fixOrientation = - getComputedStyle(document.body)["imageOrientation"] == "from-image" ? false : fixOrientation; + // With `fixOrientation`, Dropzone turns the image upright itself: the + // browser gets a copy whose EXIF says it is upright already, and the real + // orientation is applied while drawing. That way the result is the same in + // every browser, whether or not it would have done this on its own. + // Without it -- for images from the server, which need not be data URLs -- + // it is left to the browser. + let { url, orientation } = fixOrientation + ? extractOrientation(file.dataURL!) + : { url: file.dataURL!, orientation: 1 }; img.onload = () => { - let loadExif = (callback: (orientation: number) => void) => callback(1); - if (typeof EXIF !== "undefined" && EXIF !== null && fixOrientation) { - loadExif = (callback: (orientation: number) => void) => - EXIF.getData(img, function (this: any) { - return callback(EXIF.getTag(this as any, "Orientation")); - }); - } - - return loadExif((orientation: number) => { - file.width = img.width; - file.height = img.height; - - let resizeInfo = this.options.resize.call(this, file, width, height, resizeMethod); - - let canvas = document.createElement("canvas"); - let ctx = canvas.getContext("2d")!; - - canvas.width = resizeInfo.trgWidth; - canvas.height = resizeInfo.trgHeight; - - if (orientation > 4) { - canvas.width = resizeInfo.trgHeight; - canvas.height = resizeInfo.trgWidth; - } - - switch (orientation) { - case 2: - // horizontal flip - ctx.translate(canvas.width, 0); - ctx.scale(-1, 1); - break; - case 3: - // 180° rotate left - ctx.translate(canvas.width, canvas.height); - ctx.rotate(Math.PI); - break; - case 4: - // vertical flip - ctx.translate(0, canvas.height); - ctx.scale(1, -1); - break; - case 5: - // vertical flip + 90 rotate right - ctx.rotate(0.5 * Math.PI); - ctx.scale(1, -1); - break; - case 6: - // 90° rotate right - ctx.rotate(0.5 * Math.PI); - ctx.translate(0, -canvas.width); - break; - case 7: - // horizontal flip + 90 rotate right - ctx.rotate(0.5 * Math.PI); - ctx.translate(canvas.height, -canvas.width); - ctx.scale(-1, 1); - break; - case 8: - // 90° rotate left - ctx.rotate(-0.5 * Math.PI); - ctx.translate(-canvas.height, 0); - break; - } + // Orientations 5 to 8 put the image on its side, so what is stored as + // its width is displayed as its height. From here on everything is in + // displayed axes, which is what `resize` and anyone reading `file.width` + // work with. + let sideways = orientation > 4; + file.width = sideways ? img.height : img.width; + file.height = sideways ? img.width : img.height; + + let resizeInfo = this.options.resize.call(this, file, width, height, resizeMethod); + + let canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d")!; + + canvas.width = resizeInfo.trgWidth; + canvas.height = resizeInfo.trgHeight; + + // `resize` picks what to show from the displayed image; take it from + // where those pixels are actually stored, and turn them upright on the + // way into the target rectangle. + let source = storedRect(orientation, file.width, file.height, { + x: resizeInfo.srcX != null ? resizeInfo.srcX : 0, + y: resizeInfo.srcY != null ? resizeInfo.srcY : 0, + width: resizeInfo.srcWidth, + height: resizeInfo.srcHeight, + }); + ctx.translate( + resizeInfo.trgX != null ? resizeInfo.trgX : 0, + resizeInfo.trgY != null ? resizeInfo.trgY : 0, + ); + ctx.transform( + ...orientationTransform(orientation, resizeInfo.trgWidth, resizeInfo.trgHeight), + ); - // This is a bugfix for iOS' scaling bug. - drawImageIOSFix( - ctx, - img, - resizeInfo.srcX != null ? resizeInfo.srcX : 0, - resizeInfo.srcY != null ? resizeInfo.srcY : 0, - resizeInfo.srcWidth, - resizeInfo.srcHeight, - resizeInfo.trgX != null ? resizeInfo.trgX : 0, - resizeInfo.trgY != null ? resizeInfo.trgY : 0, - resizeInfo.trgWidth, - resizeInfo.trgHeight, - ); + // This is a bugfix for iOS' scaling bug. + drawImageIOSFix( + ctx, + img, + source.x, + source.y, + source.width, + source.height, + 0, + 0, + sideways ? resizeInfo.trgHeight : resizeInfo.trgWidth, + sideways ? resizeInfo.trgWidth : resizeInfo.trgHeight, + ); - let thumbnail = canvas.toDataURL("image/png"); + let thumbnail = canvas.toDataURL("image/png"); - if (callback != null) { - return callback(thumbnail, canvas); - } - }); + if (callback != null) { + return callback(thumbnail, canvas); + } }; if (callback != null) { @@ -1281,7 +1255,7 @@ export default class Dropzone extends Emitter { img.onerror = (e) => callback(e); } - return (img.src = file.dataURL!); + return (img.src = url); } // Goes through the queue and processes files if there aren't too many already. diff --git a/packages/dropzone/src/exif.ts b/packages/dropzone/src/exif.ts index 6cff91d25..0ea23c494 100644 --- a/packages/dropzone/src/exif.ts +++ b/packages/dropzone/src/exif.ts @@ -1,9 +1,16 @@ -// Carrying a JPEG's EXIF over to a resized copy of it. +// The two things Dropzone needs from a JPEG's EXIF. // -// A canvas cannot write metadata, so when `resizeImage` re-encodes a photo, -// the camera's EXIF -- date taken, make and model, location -- would be lost. -// This copies the block across from the original. Both images are data URLs -// at that point, so it works on their base64 directly. +// Its orientation. Cameras store pixels the way the sensor read them and +// record in EXIF how to turn them upright. Dropzone applies that itself when +// drawing, rather than leaving it to the browser, so that the result does not +// depend on which browser it is -- older ones do not do it at all. +// +// And the rest of it. A canvas cannot write metadata, so when `resizeImage` +// re-encodes a photo, the camera's EXIF -- date taken, make and model, +// location -- would be lost. That gets copied across from the original. +// +// Both work on the base64 of the data URLs directly, since that is what the +// images are at that point. const SOI = 0xd8; const SOS = 0xda; @@ -67,7 +74,8 @@ function segmentsOf(bytes: Uint8Array): Segment[] | null { } // A JPEG's segments, decoding no more of its base64 than it takes to reach -// them. `chars` is how much of the base64 that was. +// them. `chars` is how much of the base64 that was: all of it, or a whole +// number of groups whose bytes can be re-encoded and put back in its place. function readHead(base64: string) { let chars = Math.min(base64.length, (HEAD_BYTES / 3) * 4); let bytes = decode(base64.slice(0, chars)); @@ -77,7 +85,153 @@ function readHead(base64: string) { bytes = decode(base64); segments = segmentsOf(bytes); } - return { bytes, segments: segments ?? [], chars }; + return { bytes, segments: segments || [], chars }; +} + +// Whether a segment holds EXIF: an APP1 that starts "Exif\0\0". XMP uses APP1 +// as well. +function isExif(bytes: Uint8Array, segment: Segment): boolean { + let signature = "Exif\0\0"; + for (let i = 0; i < signature.length; i++) { + if (bytes[segment.start + 4 + i] !== signature.charCodeAt(i)) { + return false; + } + } + return segment.marker === APP1; +} + +// Where the Orientation value of an EXIF segment sits, and what it says, or +// null if the segment is not EXIF or has none. Everything read is checked +// against the segment's end first, since the file is whatever the user +// dropped. +function orientationOf(bytes: Uint8Array, segment: Segment) { + let end = segment.end; + let tiff = segment.start + 10; // after the marker, the length and "Exif\0\0" + if (!isExif(bytes, segment) || tiff + 8 > end) { + return null; + } + + // The TIFF header says which byte order everything after it is in. + let little = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + if (!little && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) { + return null; + } + let u16 = (at: number) => + little ? bytes[at] | (bytes[at + 1] << 8) : (bytes[at] << 8) | bytes[at + 1]; + let u32 = (at: number) => + little ? u16(at) + u16(at + 2) * 0x10000 : u16(at) * 0x10000 + u16(at + 2); + if (u16(tiff + 2) !== 42) { + return null; + } + + let ifd = tiff + u32(tiff + 4); + if (ifd + 2 > end) { + return null; + } + for (let i = 0, count = u16(ifd); i < count; i++) { + let entry = ifd + 2 + 12 * i; + if (entry + 12 > end) { + return null; + } + // Orientation is a single SHORT, so its value sits in the entry itself. + if (u16(entry) === 0x0112) { + return u16(entry + 2) === 3 ? { at: entry + 8, little, value: u16(entry + 8) } : null; + } + } + return null; +} + +// A data URL's orientation, and the data URL with its orientation set to 1, +// so that the browser draws the pixels as they are stored instead of turning +// them itself. Only the two bytes of the value change. +// +// Anything that is not a JPEG, or does not ask to be turned, comes back as it +// is with orientation 1 -- the same string, so no copy of the image is made. +// It never throws; the worst case is that the browser decides after all. +export function extractOrientation(dataURL: string): { url: string; orientation: number } { + let unchanged = { url: dataURL, orientation: 1 }; + try { + let comma = dataURL.indexOf(","); + let prefix = dataURL.slice(0, comma + 1); + if (!prefix.startsWith("data:") || !prefix.endsWith(";base64,")) { + return unchanged; + } + let base64 = dataURL.slice(comma + 1); + + let head = readHead(base64); + // Browsers read it from the first EXIF segment and look no further. + let exif = head.segments.find((segment) => isExif(head.bytes, segment)); + let found = exif ? orientationOf(head.bytes, exif) : null; + // Values outside 1 to 8 mean nothing, and browsers treat them as 1. + if (found == null || found.value < 2 || found.value > 8) { + return unchanged; + } + + let bytes = head.bytes.slice(); + bytes[found.at] = found.little ? 1 : 0; + bytes[found.at + 1] = found.little ? 0 : 1; + return { url: prefix + encode(bytes) + base64.slice(head.chars), orientation: found.value }; + } catch (error) { + return unchanged; + } +} + +type Rect = { x: number; y: number; width: number; height: number }; + +// A rectangle of an image as it is displayed, found in its pixels as they are +// stored. `width` and `height` are the displayed image's. +export function storedRect(orientation: number, width: number, height: number, rect: Rect): Rect { + let { x, y, width: w, height: h } = rect; + // How far the rectangle is from the displayed image's right and bottom. + let right = width - x - w; + let bottom = height - y - h; + // Orientations 5 to 8 put the image on its side, which swaps the axes. + switch (orientation) { + case 2: + return { x: right, y, width: w, height: h }; + case 3: + return { x: right, y: bottom, width: w, height: h }; + case 4: + return { x, y: bottom, width: w, height: h }; + case 5: + return { x: y, y: x, width: h, height: w }; + case 6: + return { x: y, y: right, width: h, height: w }; + case 7: + return { x: bottom, y: right, width: h, height: w }; + case 8: + return { x: bottom, y: x, width: h, height: w }; + default: + return rect; + } +} + +// The canvas transform that makes stored pixels, drawn from the origin, fill +// a `width` by `height` rectangle the right way up. For 5 to 8 they have to +// be drawn `height` wide and `width` high, since they are on their side. +export function orientationTransform( + orientation: number, + width: number, + height: number, +): [number, number, number, number, number, number] { + switch (orientation) { + case 2: // mirrored left to right + return [-1, 0, 0, 1, width, 0]; + case 3: // upside down + return [-1, 0, 0, -1, width, height]; + case 4: // mirrored top to bottom + return [1, 0, 0, -1, 0, height]; + case 5: // mirrored along the diagonal from top left + return [0, 1, 1, 0, 0, 0]; + case 6: // turned a quarter to the left, so turn it right + return [0, 1, -1, 0, width, 0]; + case 7: // mirrored along the other diagonal + return [0, -1, -1, 0, width, height]; + case 8: // turned a quarter to the right, so turn it left + return [0, -1, 1, 0, 0, height]; + default: + return [1, 0, 0, 1, 0, 0]; + } } // `resized` with the first APP1 segment of `original` added, which is where a @@ -90,7 +244,7 @@ function readHead(base64: string) { export function restoreExif(original: string, resized: string): string { try { return insertExif(original, resized); - } catch { + } catch (error) { return resized; } } @@ -110,13 +264,13 @@ function insertExif(original: string, resized: string): string { let block = source.bytes.subarray(exif.start, exif.end); let target = decode(resizedBase64); - let first = segmentsOf(target)?.[0]; if (target[0] !== 0xff || target[1] !== SOI) { return resized; } // After the JFIF header when there is one, since that has to come first, // and straight after the start-of-image marker otherwise. - let at = first?.marker === APP0 ? first.end : 2; + let segments = segmentsOf(target); + let at = segments && segments.length && segments[0].marker === APP0 ? segments[0].end : 2; let joined = new Uint8Array(target.length + block.length); joined.set(target.subarray(0, at)); diff --git a/packages/dropzone/src/types.d.ts b/packages/dropzone/src/types.d.ts index c0caffa27..86a30dead 100644 --- a/packages/dropzone/src/types.d.ts +++ b/packages/dropzone/src/types.d.ts @@ -4,8 +4,6 @@ declare module "*.html?raw" { export default content; } -// Optional globals. Neither is a dependency: Dropzone uses EXIF only when the -// page already loaded exif.js, and registers a jQuery plugin only when jQuery -// is present. -declare const EXIF: any; +// An optional global, not a dependency: Dropzone registers a jQuery plugin +// only when jQuery is present. declare const jQuery: any; diff --git a/packages/dropzone/test/unit-tests/exif.js b/packages/dropzone/test/unit-tests/exif.js index 631939fd3..bf0472dca 100644 --- a/packages/dropzone/test/unit-tests/exif.js +++ b/packages/dropzone/test/unit-tests/exif.js @@ -1,5 +1,5 @@ import { Dropzone } from "../../src/dropzone"; -import { restoreExif } from "../../src/exif"; +import { extractOrientation, restoreExif } from "../../src/exif"; // Every fixture displays as the same 64x32 image, one colour per quadrant -- // red, green / blue, yellow -- but stores its pixels transformed by the inverse @@ -263,3 +263,145 @@ describe("restoreExif()", function () { expect(restoreExif(jpeg(JFIF, EXIF, TABLE), png)).toBe(png); }); }); + +// An EXIF segment holding nothing but an orientation, written without going +// through Dropzone. `null` leaves the tag out; `type` other than 3 (SHORT) +// makes it malformed. +function exifSegment(orientation, { little = false, type = 3, entries = null } = {}) { + let u16 = (v) => (little ? [v & 0xff, v >> 8] : [v >> 8, v & 0xff]); + let u32 = (v) => + little ? [...u16(v & 0xffff), ...u16(v >>> 16)] : [...u16(v >>> 16), ...u16(v & 0xffff)]; + let tags = + orientation == null + ? [] + : [[...u16(0x0112), ...u16(type), ...u32(1), ...u16(orientation), 0, 0]]; + let ifd = [...u16(entries ?? tags.length), ...tags.flat(), ...u32(0)]; + return segment(0xe1, [ + ...text("Exif\0\0"), + ...text(little ? "II" : "MM"), + ...u16(42), + ...u32(8), + ...ifd, + ]); +} + +// Which bytes differ between two data URLs of the same length. +function changedBytes(before, after) { + let [a, b] = [bytesOf(before), bytesOf(after)]; + expect(b.length).toBe(a.length); + return a.reduce((changed, byte, i) => (byte === b[i] ? changed : [...changed, i]), []); +} + +describe("extractOrientation()", function () { + let fixture = (name) => fixtures.find((f) => f.name === name).url; + + it.each(["2-be", "3-le", "5-be", "6-be", "6-le", "7-le", "8-be"])( + "should report %s's orientation and set it to 1, changing nothing else", + function (name) { + let { url, orientation } = extractOrientation(fixture(name)); + + expect(orientation).toBe(Number(name[0])); + expect(exifOf(url)).toEqual({ orientation: 1, make: "Dropzone" }); + // Only the low byte of the value, whichever end of the SHORT that is. + expect(changedBytes(fixture(name), url)).toHaveLength(1); + }, + ); + + it("should hand back the very same string when there is nothing to turn", function () { + let upright = fixture("1-be"); + + expect(extractOrientation(upright)).toEqual({ url: upright, orientation: 1 }); + }); + + it("should leave a JPEG without an orientation alone", function () { + for (let url of [ + jpeg(JFIF, TABLE), + jpeg(JFIF, exifSegment(null), TABLE), + jpeg(JFIF, XMP, TABLE), + ]) { + expect(extractOrientation(url)).toEqual({ url, orientation: 1 }); + } + }); + + it("should only look at the first EXIF segment, as browsers do", function () { + let url = jpeg(exifSegment(null), exifSegment(6), TABLE); + + expect(extractOrientation(url)).toEqual({ url, orientation: 1 }); + }); + + it("should ignore values that mean nothing, as browsers do", function () { + for (let url of [ + jpeg(exifSegment(0), TABLE), + jpeg(exifSegment(9), TABLE), + jpeg(exifSegment(6, { type: 4 }), TABLE), + ]) { + expect(extractOrientation(url)).toEqual({ url, orientation: 1 }); + } + }); + + it("should leave anything that is not a base64 JPEG alone", function () { + for (let url of [ + "data:image/png;base64,iVBORw0KGgo=", + "data:image/jpeg,not-base64", + "https://example.com/photo.jpg", + ]) { + expect(extractOrientation(url)).toEqual({ url, orientation: 1 }); + } + }); + + it("should give up quietly on a broken file", function () { + let brokenOrder = exifSegment(6); + brokenOrder.splice(10, 2, 0x58, 0x58); // "XX" instead of "MM" + + for (let url of [ + // Claims five entries, holds none: reading them would run off the end + // of the segment and into the table after it. + jpeg(exifSegment(null, { entries: 5 }), TABLE), + jpeg(brokenOrder, TABLE), + // A segment longer than the file. + jpegUrlOf(Uint8Array.from([0xff, 0xd8, 0xff, 0xe1, 0x10, 0x00, 0x45])), + "data:image/jpeg;base64,not*base64", + ]) { + expect(extractOrientation(url)).toEqual({ url, orientation: 1 }); + } + }); + + it("should find the orientation past the part of the file it decodes first", function () { + // Four segments of the largest size there is push it past 192 KiB. + let large = segment(0xe2, Array(65533).fill(0)); + let original = jpeg(JFIF, large, large, large, large, exifSegment(6), TABLE); + + let { url, orientation } = extractOrientation(original); + + expect(orientation).toBe(6); + expect(changedBytes(original, url)).toHaveLength(1); + }); + + it("should re-encode only the head of a large file and leave the rest as it was", function () { + // Large enough that only its head gets decoded; the base64 of the rest + // must be carried over as it is and still line up. + let scan = Uint8Array.from({ length: 400000 }, (_, i) => (i * 7) % 255); + let head = [ + 0xff, + 0xd8, + ...JFIF, + ...exifSegment(6, { little: true }), + ...TABLE, + 0xff, + 0xda, + 0, + 2, + ]; + let bytes = new Uint8Array(head.length + scan.length + 2); + bytes.set(head); + bytes.set(scan, head.length); + bytes.set([0xff, 0xd9], head.length + scan.length); + let original = jpegUrlOf(bytes); + + let { url, orientation } = extractOrientation(original); + + expect(orientation).toBe(6); + expect(changedBytes(original, url)).toHaveLength(1); + expect(url.slice(-1000)).toBe(original.slice(-1000)); + }); +}); From fea2df1cb1ade8156bd3134f9412f643bf97f071 Mon Sep 17 00:00:00 2001 From: Matias Simon Date: Sun, 27 Sep 2026 21:39:08 +0200 Subject: [PATCH 4/6] Write the orientation back as 1 on resized JPEGs The resized image is drawn upright, but it got the original's EXIF copied onto it as it was, orientation included. Anything that honours EXIF -- image viewers, and servers that auto-orient with sharp, Pillow or ImageMagick -- then turned the already upright pixels a second time: portrait phone photos came out on their side, and upside-down ones the other way up. restoreExif now sets the orientation to 1 in the segment it copies, and leaves the rest of the EXIF alone. Viewers that honour it and viewers that do not now show the same, correct, image. The fixture tests assert exactly that for all eight orientations in both byte orders; without this change the 14 that are not already upright fail. Fixes #1967, #2081 and #2194. --- .changeset/exif-orientation.md | 5 +++++ packages/dropzone/src/exif.ts | 12 +++++++++++- packages/dropzone/test/unit-tests/exif.js | 16 ++++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .changeset/exif-orientation.md diff --git a/.changeset/exif-orientation.md b/.changeset/exif-orientation.md new file mode 100644 index 000000000..455b64d30 --- /dev/null +++ b/.changeset/exif-orientation.md @@ -0,0 +1,5 @@ +--- +"dropzone": patch +--- + +Fix resized JPEGs coming out rotated: the original EXIF orientation was copied onto pixels that were already upright, so viewers that honour it turned them a second time. It is now written back as `1`, and Dropzone applies the orientation itself in every browser rather than relying on the browser or exif.js. If you were correcting for this on your server, remove that. diff --git a/packages/dropzone/src/exif.ts b/packages/dropzone/src/exif.ts index 0ea23c494..cc5b63149 100644 --- a/packages/dropzone/src/exif.ts +++ b/packages/dropzone/src/exif.ts @@ -238,6 +238,10 @@ export function orientationTransform( // JPEG keeps its EXIF. Returned unchanged if there is none, or if either one // is not a JPEG. // +// `resized` is expected to be upright, which is how `resizeImage` draws it, so +// the orientation is set to 1 on the way. Copied as it was, it would make +// anything that honours it turn the image a second time. +// // It never throws. This runs inside `transformFile`, and an exception there // would leave the upload waiting for a `done` that never comes; losing the // metadata is the better outcome. @@ -261,7 +265,13 @@ function insertExif(original: string, resized: string): string { if (exif == null) { return resized; } - let block = source.bytes.subarray(exif.start, exif.end); + let block = source.bytes.slice(exif.start, exif.end); + let orientation = orientationOf(source.bytes, exif); + if (orientation != null) { + let at = orientation.at - exif.start; + block[at] = orientation.little ? 1 : 0; + block[at + 1] = orientation.little ? 0 : 1; + } let target = decode(resizedBase64); if (target[0] !== 0xff || target[1] !== SOI) { diff --git a/packages/dropzone/test/unit-tests/exif.js b/packages/dropzone/test/unit-tests/exif.js index bf0472dca..a291dbb7e 100644 --- a/packages/dropzone/test/unit-tests/exif.js +++ b/packages/dropzone/test/unit-tests/exif.js @@ -166,10 +166,13 @@ describe("EXIF orientation", function () { ); }); - it("should carry the original EXIF over to the resized image", async function () { + it("should carry the EXIF over, saying the image is upright now", async function () { let resized = await resize(fileOf(fixture), 32, null, "contain"); - expect(exifOf(resized)).toEqual({ orientation: fixture.orientation, make: "Dropzone" }); + expect(exifOf(resized)).toEqual({ orientation: 1, make: "Dropzone" }); + // So a viewer that honours EXIF shows it the same way as one that does + // not, instead of turning it a second time. See #1967, #2081 and #2194. + expect(await looks(resized)).toBe("32x16 RGBY"); }); }); @@ -231,6 +234,15 @@ describe("restoreExif()", function () { expect(restoreExif(jpeg(JFIF, EXIF, TABLE), jpeg(TABLE))).toBe(jpeg(EXIF, TABLE)); }); + it.each([false, true])( + "should set the orientation to 1, since the resized image is upright (little endian: %s)", + function (little) { + let restored = restoreExif(jpeg(JFIF, exifSegment(6, { little }), TABLE), jpeg(JFIF, TABLE)); + + expect(restored).toBe(jpeg(JFIF, exifSegment(1, { little }), TABLE)); + }, + ); + it("should take the first APP1 segment, whatever it holds", function () { expect(restoreExif(jpeg(JFIF, XMP, EXIF), jpeg(JFIF, TABLE))).toBe(jpeg(JFIF, XMP, TABLE)); }); From aabc23426c0036d7d717a92bdd61a18fc972f59d Mon Sep 17 00:00:00 2001 From: Matias Simon Date: Sun, 27 Sep 2026 21:49:19 +0200 Subject: [PATCH 5/6] Take the EXIF fix off the roadmap The orientation fix it described is done, as a patch rather than in 7.0, and the EXIF code it listed among the things to split out of dropzone.ts has already moved to src/exif.ts. --- ROADMAP.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6e94db35e..a874d61de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,21 +106,9 @@ changelog note telling people to delete their manual push. ### Restructure `src/dropzone.js` -2,192 lines in one file holding the class, the browser detection, the EXIF -restorer and the canvas helpers. Split along those seams. - -### Fix EXIF orientation on resize - -**#2001** by @kaymes strips EXIF before resizing so the browser stops -auto-correcting, then restores it after, and replaces `ExifRestorer` with faster -`atob`/`btoa` versions. It fixes rotated phone photos, which is a common and -visible complaint. - -It is also the highest-risk PR in the backlog: +109/−158, conflicting with main, -and the author says plainly they could not test it because they never got the -build working. It needs a rebase and real fixtures at all eight EXIF -orientations before it can be trusted. Give it its own release rather than -burying it in a batch. +2,192 lines in one file holding the class, the browser detection and the canvas +helpers. Split along those seams. The EXIF handling has already moved out, to +`src/exif.ts`. --- From beeb891c9b930d20345ae71a5c5dff1cfe08ef97 Mon Sep 17 00:00:00 2001 From: Matias Simon Date: Sun, 27 Sep 2026 21:53:04 +0200 Subject: [PATCH 6/6] Say what happens to EXIF when images are resized Nothing told users that a resized JPEG keeps the camera's EXIF, which includes the location it was taken at, or that other formats drop it -- and it is the natural place to say that the result is upright. The row in the options table now runs past the width the table is padded to. Re-padding every other row to match would be a diff of pure whitespace, so it is left as it is. --- apps/docs/docs/configuration/basics/configuration-options.md | 2 +- packages/dropzone/src/options.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/docs/docs/configuration/basics/configuration-options.md b/apps/docs/docs/configuration/basics/configuration-options.md index bcd964a9d..782e2d777 100644 --- a/apps/docs/docs/configuration/basics/configuration-options.md +++ b/apps/docs/docs/configuration/basics/configuration-options.md @@ -23,7 +23,7 @@ Here is a list of all available options for Dropzone. In case any of this inform | `thumbnailWidth` | `120` | If `null`, the ratio of the image will be used to calculate it. | | `thumbnailHeight` | `120` | The same as `thumbnailWidth`. If both are null, images will not be resized. | | `thumbnailMethod` | `"crop"` | How the images should be scaled down in case both, `thumbnailWidth` and `thumbnailHeight` are provided. Can be either `contain` or `crop`. | -| `resizeWidth` | `null` |

If set, images will be resized to these dimensions before being **uploaded**. If only one, resizeWidth **or** resizeHeight is provided, the original aspect ratio of the file will be preserved.
The options.transformFile function uses these options, so if the transformFile function is overridden, these options don't do anything.

| +| `resizeWidth` | `null` |

If set, images will be resized to these dimensions before being **uploaded**. If only one, resizeWidth **or** resizeHeight is provided, the original aspect ratio of the file will be preserved.
Resized JPEGs keep the original's EXIF, including date, camera and location; other formats keep none. The EXIF orientation is applied before resizing, so the result is upright.
The options.transformFile function uses these options, so if the transformFile function is overridden, these options don't do anything.

| | `resizeHeight` | `null` | See `resizeWidth`. | | `resizeMimeType` | `null` | The mime type of the resized image (before it gets uploaded to the server). If `null` the original mime type will be used. To force jpeg, for example, use `image/jpeg`. See `resizeWidth` for more information. | | `resizeQuality` | `0.8` | The quality of the resized images. See `resizeWidth`. | diff --git a/packages/dropzone/src/options.ts b/packages/dropzone/src/options.ts index a6e714b04..a2b02fd1e 100644 --- a/packages/dropzone/src/options.ts +++ b/packages/dropzone/src/options.ts @@ -124,6 +124,10 @@ let defaultOptions = { * If only one, `resizeWidth` **or** `resizeHeight` is provided, the original aspect * ratio of the file will be preserved. * + * Resized JPEGs keep the original's EXIF, including date, camera and location; + * other formats keep none. The EXIF orientation is applied before resizing, so + * the result is upright. + * * The `options.transformFile` function uses these options, so if the `transformFile` function * is overridden, these options don't do anything. */