From 572b9df0bbba1b1758a056ddcea3b4f5d4512b3e Mon Sep 17 00:00:00 2001 From: ascpixi <44982772+ascpixi@users.noreply.github.com> Date: Sun, 24 May 2026 10:28:53 -0400 Subject: [PATCH 1/2] Preserve EXIF metadata when re-encoding images to JPEG --- app/javascript/application.js | 71 ++++++++++++++++++- app/views/active_storage/blobs/_blob.html.erb | 2 +- app/views/admin/check_ins/index.html.erb | 2 +- app/views/admin/check_ins/show.html.erb | 2 +- app/views/admin/submissions/_table.html.erb | 2 +- app/views/admin/submissions/index.html.erb | 4 +- app/views/admin/submissions/show.html.erb | 2 +- app/views/static_pages/home.html.erb | 2 +- 8 files changed, 77 insertions(+), 10 deletions(-) diff --git a/app/javascript/application.js b/app/javascript/application.js index 56bf7c6..3643f94 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -6,6 +6,58 @@ import { start } from "@rails/activestorage" start() +// Extract the raw APP1 (EXIF) segment bytes from a JPEG file, or null if absent. +function extractJpegExif(srcBytes) { + var i = 2; // skip SOI FF D8 + while (i + 3 < srcBytes.length) { + if (srcBytes[i] !== 0xFF) break; + var marker = srcBytes[i + 1]; + var segLen = (srcBytes[i + 2] << 8) | srcBytes[i + 3]; + if (marker === 0xE1) return srcBytes.slice(i, i + 2 + segLen); // APP1 + if (marker === 0xDA || marker === 0xD9) break; // SOS / EOI + i += 2 + segLen; + } + return null; +} + +// Patch the EXIF orientation tag in-place to `value` (1 = normal/upright). +// Canvas drawImage already applies visual rotation, so the tag must be reset +// to avoid double-rotation in viewers that honour EXIF orientation. +function patchExifOrientation(app1, value) { + if (app1.length < 14) return; + var hdr = String.fromCharCode(app1[4], app1[5], app1[6], app1[7]); + if (hdr !== "Exif") return; + var t = 10; // tiff header offset inside app1 + var le = app1[t] === 0x49; // "II" = little-endian + function r16(o) { return le ? (app1[o] | (app1[o+1] << 8)) : ((app1[o] << 8) | app1[o+1]); } + function r32(o) { return (le ? (app1[o] | (app1[o+1]<<8) | (app1[o+2]<<16) | (app1[o+3]<<24)) : ((app1[o]<<24) | (app1[o+1]<<16) | (app1[o+2]<<8) | app1[o+3])) >>> 0; } + var ifd = t + r32(t + 4); + var n = r16(ifd); + for (var j = 0; j < n; j++) { + var e = ifd + 2 + j * 12; + if (r16(e) === 0x0112) { // Orientation tag + if (le) { app1[e+8] = value & 0xFF; app1[e+9] = 0; } + else { app1[e+8] = 0; app1[e+9] = value & 0xFF; } + return; + } + } +} + +// Splice `exifSegment` into a canvas-produced JPEG blob, replacing any JFIF +// APP0 header so the result is a clean EXIF JPEG. +function spliceExifIntoJpeg(exifSegment, destBytes) { + // Skip any APP0 (JFIF) segments that canvas may have emitted. + var start = 2; + while (start + 3 < destBytes.length && destBytes[start] === 0xFF && destBytes[start+1] === 0xE0) { + start += 2 + ((destBytes[start+2] << 8) | destBytes[start+3]); + } + var out = new Uint8Array(2 + exifSegment.length + destBytes.length - start); + out.set(destBytes.slice(0, 2)); + out.set(exifSegment, 2); + out.set(destBytes.slice(start), 2 + exifSegment.length); + return new Blob([out], { type: "image/jpeg" }); +} + function compressToJpeg(file) { return new Promise(function (resolve) { var MAX = 1920, QUALITY = 0.82; @@ -22,9 +74,24 @@ function compressToJpeg(file) { var canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; canvas.getContext("2d").drawImage(img, 0, 0, w, h); - canvas.toBlob(function (blob) { if (!blob) { resolve(file); return; } + canvas.toBlob(function (blob) { + if (!blob) { resolve(file); return; } var name = file.name.replace(/\.[^.]+$/, ".jpg"); - resolve(new File([blob], name, { type: "image/jpeg", lastModified: Date.now() })); + // Preserve EXIF from the original file if it was a JPEG. + if (!file.type.startsWith("image/jpeg") && !file.type.startsWith("image/jpg")) { + resolve(new File([blob], name, { type: "image/jpeg", lastModified: Date.now() })); + return; + } + Promise.all([file.arrayBuffer(), blob.arrayBuffer()]).then(function(bufs) { + var exif = extractJpegExif(new Uint8Array(bufs[0])); + if (!exif) { + resolve(new File([blob], name, { type: "image/jpeg", lastModified: Date.now() })); + return; + } + patchExifOrientation(exif, 1); + var finalBlob = spliceExifIntoJpeg(exif, new Uint8Array(bufs[1])); + resolve(new File([finalBlob], name, { type: "image/jpeg", lastModified: Date.now() })); + }); }, "image/jpeg", QUALITY); }; img.onerror = function () { resolve(file); }; diff --git a/app/views/active_storage/blobs/_blob.html.erb b/app/views/active_storage/blobs/_blob.html.erb index 49ba357..2aba822 100644 --- a/app/views/active_storage/blobs/_blob.html.erb +++ b/app/views/active_storage/blobs/_blob.html.erb @@ -1,6 +1,6 @@
attachment--<%= blob.filename.extension %>"> <% if blob.representable? %> - <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> + <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ], saver: { strip: false }) %> <% end %>
diff --git a/app/views/admin/check_ins/index.html.erb b/app/views/admin/check_ins/index.html.erb index 546fab7..d07e84e 100644 --- a/app/views/admin/check_ins/index.html.erb +++ b/app/views/admin/check_ins/index.html.erb @@ -46,7 +46,7 @@
<% if ci.image.attached? %> - <%= image_tag ci.image.variant(resize_to_limit: [80, 80]), + <%= image_tag ci.image.variant(resize_to_limit: [80, 80], saver: { strip: false }), class: "w-20 h-20 object-cover border-2 border-black" %> <% else %>
diff --git a/app/views/admin/check_ins/show.html.erb b/app/views/admin/check_ins/show.html.erb index 382e8b1..485f5ee 100644 --- a/app/views/admin/check_ins/show.html.erb +++ b/app/views/admin/check_ins/show.html.erb @@ -43,7 +43,7 @@
<% if @check_in.image.attached? %> - <%= image_tag @check_in.image.variant(resize_to_limit: [800, 800]), + <%= image_tag @check_in.image.variant(resize_to_limit: [800, 800], saver: { strip: false }), class: "max-w-full border-2 border-black" %>
<%= link_to "Download original", url_for(@check_in.image), diff --git a/app/views/admin/submissions/_table.html.erb b/app/views/admin/submissions/_table.html.erb index 60775f5..f5524d4 100644 --- a/app/views/admin/submissions/_table.html.erb +++ b/app/views/admin/submissions/_table.html.erb @@ -77,7 +77,7 @@ <% blob = sub.media.blob %> <% if blob.content_type.start_with?("image/") %> <%= link_to url_for(sub.media), target: "_blank", rel: "noopener" do %> - <%= image_tag sub.media.variant(resize_to_limit: [64, 64]), + <%= image_tag sub.media.variant(resize_to_limit: [64, 64], saver: { strip: false }), class: "w-16 h-16 object-cover border-2 border-black" %> <% end %> <% elsif blob.content_type.start_with?("video/") %> diff --git a/app/views/admin/submissions/index.html.erb b/app/views/admin/submissions/index.html.erb index b348390..ee6484a 100644 --- a/app/views/admin/submissions/index.html.erb +++ b/app/views/admin/submissions/index.html.erb @@ -108,7 +108,7 @@ <% if blob.content_type.start_with?("image/") %>
- <%= image_tag submission.media.variant(resize_to_limit: [160, 160]), + <%= image_tag submission.media.variant(resize_to_limit: [160, 160], saver: { strip: false }), class: "w-40 h-40 object-cover border-2 border-black" %>