From 525bcec3c9538a8e9d31539f5d5a50ce1eb0c328 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 11 Jan 2026 17:10:55 -0500 Subject: [PATCH] Add compress, rotate, convert tools to CLI Port Web UI image tools to CLI for parity: - compress: JPEG compression with size reduction stats - rotate: Rotation and flip with jpegtran for JPEGs (DCT-safe) - convert: Format conversion between PNG, JPG, BMP, WebP Rotate tool supports flip-only operations without rotation. Co-Authored-By: Claude Opus 4.5 --- TODO-4.2.1.md | 6 +- src/stegasoo/cli.py | 197 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/TODO-4.2.1.md b/TODO-4.2.1.md index 36b0dbd..9d5948c 100644 --- a/TODO-4.2.1.md +++ b/TODO-4.2.1.md @@ -18,7 +18,11 @@ - Compress, Rotate, Strip, EXIF viewer all working - Rotate uses jpegtran for lossless JPEG rotation - Compact UI styling -- [ ] CLI tools - full shakedown and fixes +- [x] CLI tools - full shakedown and fixes + - Fixed encode to output JPEG when carrier is JPEG (was always PNG) + - Fixed jpegtran -trim flag destroying DCT stego data + - Added compress, rotate, convert tools (matching Web UI) + - Rotate uses jpegtran for JPEGs, supports flip-only operations ## AUR Packages - [ ] `stegasoo-cli` - standalone CLI package (no web dependencies) diff --git a/src/stegasoo/cli.py b/src/stegasoo/cli.py index 080cf9a..c3d96c1 100644 --- a/src/stegasoo/cli.py +++ b/src/stegasoo/cli.py @@ -1313,6 +1313,203 @@ def tools_exif(image, clear, set_fields, output, as_json): raise click.UsageError(str(e)) +@tools.command("compress") +@click.argument("image", type=click.Path(exists=True)) +@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)") +@click.option("-o", "--output", type=click.Path(), help="Output file (default: _q.jpg)") +def tools_compress(image, quality, output): + """Compress a JPEG image. + + DCT steganography survives JPEG compression! Use this to reduce file size + while preserving hidden data. + + Examples: + + stegasoo tools compress photo.jpg -q 60 + stegasoo tools compress photo.jpg -q 80 -o smaller.jpg + """ + from PIL import Image + import io + + if not 1 <= quality <= 100: + raise click.UsageError("Quality must be between 1 and 100") + + with open(image, "rb") as f: + image_data = f.read() + + img = Image.open(io.BytesIO(image_data)) + + # Convert to RGB if needed (JPEG doesn't support alpha) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + buffer = io.BytesIO() + img.save(buffer, format="JPEG", quality=quality) + compressed_data = buffer.getvalue() + + if not output: + stem = Path(image).stem + output = f"{stem}_q{quality}.jpg" + + with open(output, "wb") as f: + f.write(compressed_data) + + orig_size = len(image_data) + new_size = len(compressed_data) + reduction = (1 - new_size / orig_size) * 100 + + click.echo(f"Compressed to: {output}") + click.echo(f" Original: {orig_size:,} bytes") + click.echo(f" Compressed: {new_size:,} bytes ({reduction:.1f}% smaller)") + + +@tools.command("rotate") +@click.argument("image", type=click.Path(exists=True)) +@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise") +@click.option("--flip-h", is_flag=True, help="Flip horizontally") +@click.option("--flip-v", is_flag=True, help="Flip vertically") +@click.option("-o", "--output", type=click.Path(), help="Output file") +def tools_rotate(image, rotation, flip_h, flip_v, output): + """Rotate and/or flip an image. + + For JPEGs, uses lossless jpegtran rotation which preserves DCT steganography. + For other formats, uses PIL (re-encodes the image). + + Examples: + + stegasoo tools rotate photo.jpg -r 90 + stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg + """ + from PIL import Image + import io + import shutil + + with open(image, "rb") as f: + image_data = f.read() + + # Must have rotation or flip + if not rotation and not flip_h and not flip_v: + raise click.UsageError("Must specify at least one of -r/--rotation, --flip-h, or --flip-v") + + img = Image.open(io.BytesIO(image_data)) + is_jpeg = img.format == "JPEG" + img.close() + + rotation_deg = int(rotation) if rotation else 0 + + # For JPEGs, use lossless jpegtran + if is_jpeg and shutil.which("jpegtran"): + from .dct_steganography import _jpegtran_rotate + + result_data = image_data + + # Apply rotation + if rotation_deg in (90, 180, 270): + result_data = _jpegtran_rotate(result_data, rotation_deg) + + # Apply flips using jpegtran + if flip_h or flip_v: + import subprocess + import tempfile + import os + + for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []): + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: + f.write(result_data) + input_path = f.name + output_path = tempfile.mktemp(suffix=".jpg") + try: + subprocess.run( + ["jpegtran", "-flip", flip_type, "-copy", "all", + "-outfile", output_path, input_path], + capture_output=True, timeout=30, check=True + ) + with open(output_path, "rb") as f: + result_data = f.read() + finally: + for p in [input_path, output_path]: + try: + os.unlink(p) + except OSError: + pass + + ext = "jpg" + click.echo(" (Used lossless jpegtran - DCT stego preserved)") + else: + # Use PIL for non-JPEGs + img = Image.open(io.BytesIO(image_data)) + + # PIL rotation is counter-clockwise, we want clockwise + if rotation_deg: + pil_rotation = {90: 270, 180: 180, 270: 90}[rotation_deg] + img = img.rotate(pil_rotation, expand=True) + + if flip_h: + img = img.transpose(Image.FLIP_LEFT_RIGHT) + if flip_v: + img = img.transpose(Image.FLIP_TOP_BOTTOM) + + buffer = io.BytesIO() + img.save(buffer, format="PNG") + result_data = buffer.getvalue() + ext = "png" + + if not output: + stem = Path(image).stem + suffix = "rotated" if rotation_deg else "flipped" + output = f"{stem}_{suffix}.{ext}" + + with open(output, "wb") as f: + f.write(result_data) + + click.echo(f"Saved to: {output}") + + +@tools.command("convert") +@click.argument("image", type=click.Path(exists=True)) +@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format") +@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)") +@click.option("-o", "--output", type=click.Path(), help="Output file") +def tools_convert(image, fmt, quality, output): + """Convert image to a different format. + + Examples: + + stegasoo tools convert photo.png -f jpg + stegasoo tools convert photo.jpg -f png -o lossless.png + """ + from PIL import Image + import io + + with open(image, "rb") as f: + image_data = f.read() + + img = Image.open(io.BytesIO(image_data)) + + # Handle format-specific conversions + save_format = {"jpg": "JPEG", "png": "PNG", "bmp": "BMP", "webp": "WEBP"}[fmt] + + if save_format == "JPEG" and img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + buffer = io.BytesIO() + if save_format in ("JPEG", "WEBP"): + img.save(buffer, format=save_format, quality=quality) + else: + img.save(buffer, format=save_format) + + result_data = buffer.getvalue() + + if not output: + stem = Path(image).stem + output = f"{stem}.{fmt}" + + with open(output, "wb") as f: + f.write(result_data) + + click.echo(f"Converted to: {output}") + + # ============================================================================= # ADMIN COMMANDS (Web UI administration) # =============================================================================