Add jpegtran lossless rotation and EXIF orientation handling

DCT steganography improvements:
- Add _apply_exif_orientation() to fix portrait photos encoding rotated
- Add _jpegtran_rotate() for lossless JPEG rotation preserving DCT data
- Add rotation fallback in extract_from_dct() - tries 0°, 90°, 180°, 270°
- Quick header validation to skip invalid rotations efficiently
- Fix: wrap debug.print in try/except to prevent extraction failures

Web UI rotate tool:
- Use jpegtran for JPEGs (lossless, preserves DCT steganography)
- Fall back to PIL for non-JPEGs
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats

This enables the workflow: encode → compress → rotate → decode
Rotated stego JPEGs can now be decoded by trying all orientations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-11 16:36:52 -05:00
parent 2ebc42f2cd
commit 4e3acfca20
4 changed files with 477 additions and 36 deletions

View File

@@ -2100,8 +2100,11 @@ def api_tools_exif_clear():
@app.route("/api/tools/rotate", methods=["POST"])
@login_required
def api_tools_rotate():
"""Rotate and/or flip an image."""
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
from PIL import Image
import shutil
import subprocess
import tempfile
image_file = request.files.get("image")
if not image_file:
@@ -2112,22 +2115,115 @@ def api_tools_rotate():
flip_v = request.form.get("flip_v", "false").lower() == "true"
try:
img = Image.open(io.BytesIO(image_file.read()))
image_data = image_file.read()
img = Image.open(io.BytesIO(image_data))
original_format = img.format # JPEG, PNG, etc.
img.close()
# Apply rotation (PIL rotates counter-clockwise, so negate)
if rotation:
img = img.rotate(-rotation, expand=True)
# For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
has_jpegtran = shutil.which("jpegtran") is not None
use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
# Apply flips
if flip_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
if use_jpegtran:
# Chain jpegtran operations for lossless transformation
current_data = image_data
# Output as PNG (lossless)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
# Apply rotation first
if rotation in (90, 180, 270):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all", "-trim",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply horizontal flip
if flip_h:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "horizontal", "-copy", "all", "-trim",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply vertical flip
if flip_v:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "vertical", "-copy", "all", "-trim",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
buffer = io.BytesIO(current_data)
mimetype = "image/jpeg"
ext = "jpg"
else:
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
img = Image.open(io.BytesIO(image_data))
# Apply rotation (PIL rotates counter-clockwise, so negate)
if rotation:
img = img.rotate(-rotation, expand=True)
# Apply flips
if flip_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
# Preserve original format
buffer = io.BytesIO()
if original_format == "JPEG":
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buffer, format="JPEG", quality=95)
mimetype = "image/jpeg"
ext = "jpg"
else:
img.save(buffer, format="PNG")
mimetype = "image/png"
ext = "png"
buffer.seek(0)
stem = (
image_file.filename.rsplit(".", 1)[0]
@@ -2136,9 +2232,9 @@ def api_tools_rotate():
)
return send_file(
buffer,
mimetype="image/png",
mimetype=mimetype,
as_attachment=True,
download_name=f"{stem}_transformed.png",
download_name=f"{stem}_transformed.{ext}",
)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -22,17 +22,17 @@
<div class="tools-ribbon-divider"></div>
<div class="tools-ribbon-group">
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
<i class="bi bi-eraser"></i>
<span>Strip</span>
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
<i class="bi bi-file-zip"></i>
<span>Compress</span>
</button>
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
<i class="bi bi-arrow-repeat"></i>
<span>Rotate</span>
</button>
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
<i class="bi bi-file-zip"></i>
<span>Compress</span>
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
<i class="bi bi-eraser"></i>
<span>Strip</span>
</button>
<button class="tool-icon-btn" data-tool="convert" title="Format Convert">
<i class="bi bi-arrow-left-right"></i>
@@ -368,6 +368,14 @@
<span class="tool-result-label">Flipped</span>
<span class="tool-result-value" id="rotateFlip">None</span>
</div>
<div class="alert alert-success small mt-3 mb-0" id="rotateJpegSafe" style="display: none;">
<i class="bi bi-check-circle me-1"></i>
<strong>DCT Safe:</strong> Uses jpegtran for lossless JPEG rotation. Your stego data will be preserved.
</div>
<div class="alert alert-warning small mt-3 mb-0" id="rotateNonJpegWarn" style="display: none;">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Note:</strong> Non-JPEG images are re-encoded during rotation.
</div>
</div>
</div>
<div class="tool-results-actions d-none" id="rotateActions">
@@ -634,6 +642,18 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
try {
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
// Check for auth redirect or non-JSON response
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.error('EXIF API returned non-JSON:', res.status, contentType);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Session expired - please refresh';
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
return;
}
const data = await res.json();
if (data.success) {
@@ -643,6 +663,7 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
if (entries.length === 0) {
tbody.innerHTML = '';
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-inbox d-block mb-2"></i>No metadata found';
} else {
document.getElementById('exifNoData').classList.add('d-none');
tbody.innerHTML = entries.map(([key, value]) => {
@@ -655,9 +676,20 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
document.getElementById('exifActions').classList.remove('d-none');
} else {
// API returned success: false
console.error('EXIF API error:', data.error);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = `<i class="bi bi-exclamation-triangle d-block mb-2"></i>${data.error || 'Error reading metadata'}`;
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
}
} catch (err) {
console.error(err);
console.error('EXIF fetch error:', err);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Error loading metadata';
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
}
});
@@ -796,6 +828,11 @@ setupDropZone('rotateZone', 'rotateFile', async (file) => {
document.getElementById('rotateData').classList.remove('d-none');
document.getElementById('rotateActions').classList.remove('d-none');
// Show appropriate DCT warning based on file type
const isJpeg = file.type === 'image/jpeg' || file.name.toLowerCase().match(/\.jpe?g$/);
document.getElementById('rotateJpegSafe').style.display = isJpeg ? 'block' : 'none';
document.getElementById('rotateNonJpegWarn').style.display = isJpeg ? 'none' : 'block';
// Load image to get dimensions, then show preview
const thumb = document.getElementById('rotateThumb');
const objectUrl = URL.createObjectURL(file);
@@ -889,6 +926,8 @@ function clearRotate() {
document.getElementById('rotateData').classList.add('d-none');
document.getElementById('rotateActions').classList.add('d-none');
document.getElementById('rotateFileInfo').classList.add('d-none');
document.getElementById('rotateJpegSafe').style.display = 'none';
document.getElementById('rotateNonJpegWarn').style.display = 'none';
const thumb = document.getElementById('rotateThumb');
thumb.style.transform = '';
thumb.style.width = '';
@@ -920,8 +959,7 @@ document.getElementById('rotateDownload')?.addEventListener('click', async funct
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const baseName = rotateCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'rotated';
a.download = `${baseName}_transformed.png`;
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'rotated.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);