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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user