Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/exif-orientation.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 3 additions & 15 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` | <p>If set, images will be resized to these dimensions before being **uploaded**. If only one, <code>resizeWidth</code> **or** <code>resizeHeight</code> is provided, the original aspect ratio of the file will be preserved.<br />The <code>options.transformFile</code> function uses these options, so if the <code>transformFile</code> function is overridden, these options don't do anything.</p> |
| `resizeWidth` | `null` | <p>If set, images will be resized to these dimensions before being **uploaded**. If only one, <code>resizeWidth</code> **or** <code>resizeHeight</code> is provided, the original aspect ratio of the file will be preserved.<br />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.<br />The <code>options.transformFile</code> function uses these options, so if the <code>transformFile</code> function is overridden, these options don't do anything.</p> |
| `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`. |
Expand Down
305 changes: 61 additions & 244 deletions packages/dropzone/src/dropzone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { version } from "../package.json";
import extend from "./extend";
import Emitter from "./emitter";
import { extractOrientation, orientationTransform, restoreExif, storedRect } from "./exif";
import defaultOptions from "./options";
import type { DropzoneOptions, ResolvedDropzoneOptions } from "./options";

Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -1183,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) {
Expand All @@ -1280,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.
Expand Down Expand Up @@ -2283,164 +2258,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;
}
Expand Down
Loading
Loading