Handle image orientation properly
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 23 Jul 2026 07:02:42 +0000 (10:02 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 23 Jul 2026 07:02:42 +0000 (10:02 +0300)
AGENTS.md [deleted file]
AGENTS.org [new file with mode: 0644]
src/main/java/eu/svjatoslav/meviz/htmlindexer/Constants.java
src/main/java/eu/svjatoslav/meviz/htmlindexer/Utils.java
src/main/java/eu/svjatoslav/meviz/htmlindexer/metadata/fileTypes/Picture.java

diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644 (file)
index 38c3050..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# MeWiz - Media Wizard
-
-A Java/Maven project - a Swiss army knife of various tools for audio, video, and photo editing/conversion.
-
-## Project Structure
-
-```
-src/main/java/eu/svjatoslav/meviz/
-├── Main.java              # Entry point, module dispatcher
-├── Module.java            # Interface for all modules
-├── encoder/               # Media encoding/conversion module
-├── htmlindexer/           # HTML gallery generator module
-├── renamer/               # Batch file rename module
-├── replace/               # Text replacement module
-└── bomremove/             # BOM stripping module
-```
-
-## Build Commands
-
-```bash
-# Clean build
-mvn clean package --settings .mvn/custom-settings.xml
-
-# Build without custom settings (may fail for some dependencies)
-mvn clean package
-
-# Run tests (if any)
-mvn test
-```
-
-## Running
-
-```bash
-# After building, run the JAR directly
-java -cp target/meviz-*-jar-with-dependencies.jar eu.svjatoslav.meviz.Main
-
-# Or use the convenience script (requires installation)
-meviz <command>
-
-# Available commands:
-# - index     : Generate/update HTML image gallery
-# - rename    : Batch file rename
-# - replace   : Text replacement
-# - encode    : Media encoding/conversion
-# - bomremove : Strip BOM from files
-# - help      : Show help
-```
-
-## Dependencies
-
-- Java 8+
-- Maven
-- External tools (for full functionality):
-  - `lame` - MP3 encoding
-  - `vorbis-tools` - OGG audio
-  - `imagemagick` - Image processing
-  - `ffmpeg` - Video/audio conversion
-
-## Code Style
-
-- Standard Java conventions
-- Package prefix: `eu.svjatoslav.meviz`
-- Each module implements the `Module` interface with methods:
-  - `getModuleCommand()` - CLI command name
-  - `getDescription()` - Brief description
-  - `run(String[] args)` - Execute module
-  - `showCommandlineHelp()` - Display help
-
-## Installation
-
-See `install` script for system-wide installation (requires sudo).
diff --git a/AGENTS.org b/AGENTS.org
new file mode 100644 (file)
index 0000000..17f075f
--- /dev/null
@@ -0,0 +1,101 @@
+:PROPERTIES:
+:ID:       17c10717-ba3c-4f13-a18b-6b134a0efa86
+:END:
+
+#+TITLE: AGENTS — MeWiz (Media Wizard)
+#+SUBTITLE: Guidelines for AI coding agents working in this repository
+#+LANGUAGE: en
+
+* Overview
+:PROPERTIES:
+:ID:       932415dc-bfcc-4bc7-8d1e-4b85916f35dd
+:END:
+
+A Java/Maven project — a Swiss army knife of various tools for audio,
+video, and photo editing/conversion. License: CC0 (see ~COPYING~).
+
+* Project Structure
+:PROPERTIES:
+:ID:       3b8b4982-c11d-464b-8a90-551e5982c9e8
+:END:
+
+#+begin_example
+src/main/java/eu/svjatoslav/meviz/
+├── Main.java              # Entry point, module dispatcher
+├── Module.java            # Interface for all modules
+├── encoder/               # Media encoding/conversion module
+├── htmlindexer/           # HTML gallery generator module
+├── renamer/               # Batch file rename module
+├── replace/               # Text replacement module
+└── bomremove/             # BOM stripping module
+#+end_example
+
+* Build Commands
+:PROPERTIES:
+:ID:       55eee295-0bfb-492f-8f80-0d6837c7b461
+:END:
+
+#+begin_src bash
+# Clean build
+mvn clean package --settings .mvn/custom-settings.xml
+
+# Build without custom settings (may fail for some dependencies)
+mvn clean package
+
+# Run tests (if any)
+mvn test
+#+end_src
+
+* Running
+:PROPERTIES:
+:ID:       05296108-b9b8-4310-8cca-439ef4589a90
+:END:
+
+#+begin_src bash
+# After building, run the JAR directly
+java -cp target/meviz-*-jar-with-dependencies.jar eu.svjatoslav.meviz.Main
+
+# Or use the convenience script (requires installation)
+meviz <command>
+
+# Available commands:
+# - index     : Generate/update HTML image gallery
+# - rename    : Batch file rename
+# - replace   : Text replacement
+# - encode    : Media encoding/conversion
+# - bomremove : Strip BOM from files
+# - help      : Show help
+#+end_src
+
+* Dependencies
+:PROPERTIES:
+:ID:       69373a95-4125-4d54-a6b5-4e4c370a8ad1
+:END:
+
+- Java 8+
+- Maven
+- External tools (for full functionality):
+  - ~lame~ — MP3 encoding
+  - ~vorbis-tools~ — OGG audio
+  - ~imagemagick~ — Image processing
+  - ~ffmpeg~ — Video/audio conversion
+
+* Code Style
+:PROPERTIES:
+:ID:       04108d3c-059f-4280-998e-28e470979ca1
+:END:
+
+- Standard Java conventions
+- Package prefix: ~eu.svjatoslav.meviz~
+- Each module implements the ~Module~ interface with methods:
+  - ~getModuleCommand()~ — CLI command name
+  - ~getDescription()~ — Brief description
+  - ~run(String[] args)~ — Execute module
+  - ~showCommandlineHelp()~ — Display help
+
+* Installation
+:PROPERTIES:
+:ID:       174b241c-1054-450a-91f0-1b18eae83266
+:END:
+
+See the ~install~ script for system-wide installation (requires sudo).
index 25f0826..ea32774 100755 (executable)
@@ -10,7 +10,7 @@ public class Constants {
 
     public static final String DEFAULT_GALLERY_TITLE = "Gallery";
 
-    public static final String METADATA_FILE_NAME = "metadata_6.dat";
+    public static final String METADATA_FILE_NAME = "metadata_7.dat";
 
     public static final String THUMBNAILS_DIRECTORY_NAME = ".thumbnails";
 
@@ -36,6 +36,6 @@ public class Constants {
     public static final String[] SUPPORTED_VIDEO_EXTENSIONS = {
             "avi", "mp4", "mpeg", "mpg", "mkv", "flv", "ogv"};
 
-    public static final String THUMBNAIL_VERSION = "2";
+    public static final String THUMBNAIL_VERSION = "3";
 
 }
index fabbce9..78ad63a 100755 (executable)
@@ -17,7 +17,9 @@ import javax.imageio.ImageIO;
 import javax.imageio.ImageReader;
 import javax.imageio.metadata.IIOMetadata;
 import javax.imageio.metadata.IIOMetadataNode;
+import javax.imageio.stream.ImageInputStream;
 import java.awt.*;
+import java.awt.geom.AffineTransform;
 import java.awt.image.BufferedImage;
 import java.io.BufferedReader;
 import java.io.File;
@@ -25,6 +27,7 @@ import java.io.FileReader;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.Iterator;
 import java.util.zip.CRC32;
 
 import static org.openimaj.image.ImageUtilities.createBufferedImage;
@@ -138,6 +141,195 @@ public class Utils {
         }
     }
 
+    /**
+     * Read EXIF orientation tag (0x0112) from a JPEG file using the JDK's
+     * built-in JPEG ImageIO metadata. Returns 1 when no usable orientation
+     * information is present.
+     *
+     * EXIF orientation values:
+     *   1 = normal, 2 = flip horizontal,
+     *   3 = 180°, 4 = flip vertical,
+     *   5 = transpose, 6 = 90° CW, 7 = transverse, 8 = 270° CW
+     */
+    public static int getExifOrientation(final File file) {
+        try (ImageInputStream stream = ImageIO.createImageInputStream(file)) {
+            if (stream == null)
+                return 1;
+
+            final Iterator<ImageReader> readers = ImageIO.getImageReaders(stream);
+            if (!readers.hasNext())
+                return 1;
+
+            final ImageReader reader = readers.next();
+            try {
+                reader.setInput(stream);
+                final IIOMetadata metadata = reader.getImageMetadata(0);
+                if (metadata == null)
+                    return 1;
+
+                final String format = metadata.getNativeMetadataFormatName();
+                if (format == null || !format.toLowerCase().contains("jpeg"))
+                    return 1;
+
+                final IIOMetadataNode root = (IIOMetadataNode) metadata
+                        .getAsTree(format);
+                // scan all marker segments for the EXIF one
+                final NodeList allMarkers = root
+                        .getElementsByTagName("markerSequence");
+                if (allMarkers.getLength() == 0)
+                    return 1;
+
+                final NodeList segments = ((IIOMetadataNode) allMarkers
+                        .item(0)).getElementsByTagName("unknown");
+                for (int i = 0; i < segments.getLength(); i++) {
+                    final IIOMetadataNode segment = (IIOMetadataNode) segments
+                            .item(i);
+                    final String markerTag = segment.getAttribute("MarkerTag");
+                    // EXIF is APP1 (marker tag 225 = 0xE1)
+                    if (!"225".equals(markerTag))
+                        continue;
+
+                    final Object userObject = segment.getUserObject();
+                    if (!(userObject instanceof byte[]))
+                        continue;
+
+                    final byte[] exif = (byte[]) userObject;
+                    final Integer orientation = parseExifOrientation(exif);
+                    if (orientation != null)
+                        return orientation;
+                }
+            } finally {
+                reader.dispose();
+            }
+        } catch (final Exception e) {
+            System.out.println("Could not read EXIF orientation from "
+                    + file + ": " + e.getMessage());
+        }
+        return 1;
+    }
+
+    /**
+     * Parse a raw EXIF APP1 segment and extract the orientation tag (0x0112).
+     * Handles both little-endian ("II") and big-endian ("MM") TIFF headers.
+     */
+    private static Integer parseExifOrientation(final byte[] exif) {
+        // Minimum: "Exif\0\0" (6) + byte order (2) + 42 (2) + IFD offset (4)
+        if (exif == null || exif.length < 14)
+            return null;
+
+        // Must start with "Exif\0\0"
+        if (exif[0] != 'E' || exif[1] != 'x' || exif[2] != 'i'
+                || exif[3] != 'f' || exif[4] != 0 || exif[5] != 0)
+            return null;
+
+        final int tiffStart = 6;
+        final boolean littleEndian;
+        if (exif[tiffStart] == 'I' && exif[tiffStart + 1] == 'I')
+            littleEndian = true;
+        else if (exif[tiffStart] == 'M' && exif[tiffStart + 1] == 'M')
+            littleEndian = false;
+        else
+            return null;
+
+        final int ifdOffset = readInt32(exif, tiffStart + 4, littleEndian);
+        final int ifdPos = tiffStart + ifdOffset;
+        if (ifdPos < 0 || ifdPos + 2 > exif.length)
+            return null;
+
+        final int entryCount = readUint16(exif, ifdPos, littleEndian);
+        for (int i = 0; i < entryCount; i++) {
+            final int entryPos = ifdPos + 2 + i * 12;
+            if (entryPos + 12 > exif.length)
+                break;
+
+            final int tag = readUint16(exif, entryPos, littleEndian);
+            if (tag == 0x0112)
+                return readUint16(exif, entryPos + 8, littleEndian);
+        }
+        return null;
+    }
+
+    private static int readUint16(final byte[] data, final int offset,
+                                  final boolean littleEndian) {
+        if (littleEndian)
+            return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8);
+        return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF);
+    }
+
+    private static int readInt32(final byte[] data, final int offset,
+                                 final boolean littleEndian) {
+        if (littleEndian)
+            return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8)
+                    | ((data[offset + 2] & 0xFF) << 16)
+                    | ((data[offset + 3] & 0xFF) << 24);
+        return ((data[offset] & 0xFF) << 24)
+                | ((data[offset + 1] & 0xFF) << 16)
+                | ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF);
+    }
+
+    /**
+     * Rotate and/or flip the image in memory according to EXIF orientation.
+     * Returns the original image unchanged for orientation 1 (or unknown).
+     * Never touches the file on disk.
+     */
+    public static BufferedImage applyExifOrientation(
+            final BufferedImage image, final int orientation) {
+        if (orientation < 2 || orientation > 8)
+            return image;
+
+        final int w = image.getWidth();
+        final int h = image.getHeight();
+        // orientations 5-8 swap width and height
+        final boolean swapDimensions = orientation >= 5;
+        final int newWidth = swapDimensions ? h : w;
+        final int newHeight = swapDimensions ? w : h;
+
+        final AffineTransform transform = new AffineTransform();
+        switch (orientation) {
+            case 2: // flip horizontal
+                transform.scale(-1, 1);
+                transform.translate(-w, 0);
+                break;
+            case 3: // 180°
+                transform.translate(w, h);
+                transform.rotate(Math.PI);
+                break;
+            case 4: // flip vertical
+                transform.scale(1, -1);
+                transform.translate(0, -h);
+                break;
+            case 5: // transpose (flip along top-left to bottom-right axis)
+                transform.rotate(Math.PI / 2);
+                transform.scale(1, -1);
+                break;
+            case 6: // 90° clockwise
+                transform.translate(h, 0);
+                transform.rotate(Math.PI / 2);
+                break;
+            case 7: // transverse (flip along top-right to bottom-left axis)
+                transform.translate(h, w);
+                transform.rotate(Math.PI / 2);
+                transform.scale(-1, 1);
+                break;
+            case 8: // 270° clockwise (90° counter-clockwise)
+                transform.translate(0, w);
+                transform.rotate(-Math.PI / 2);
+                break;
+            default:
+                return image;
+        }
+
+        final BufferedImage oriented = new BufferedImage(newWidth, newHeight,
+                image.getType() == 0 ? BufferedImage.TYPE_INT_RGB
+                        : image.getType());
+        final Graphics2D g = oriented.createGraphics();
+        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
+                RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+        g.drawImage(image, transform, null);
+        g.dispose();
+        return oriented;
+    }
+
     public static File getLayoutIndexFile(final Layout layout,
                                           final File directoryToIndex) {
 
index 9604211..cdedd92 100755 (executable)
@@ -125,10 +125,26 @@ public class Picture extends AbstractFile {
         }
     }
 
+    /**
+     * Load image with EXIF orientation applied in memory. The file on disk is
+     * never modified. GIFs skip rotation (handled separately, frames are
+     * rotated during thumbnail generation).
+     */
+    private static BufferedImage getOrientedBufferedImage(final File file)
+            throws ImageFormatError, IOException {
+        BufferedImage image = getBufferedImage(file);
+        if (!"gif".equalsIgnoreCase(
+                FilePathParser.getFileExtension(file.getName()))) {
+            final int orientation = Utils.getExifOrientation(file);
+            image = Utils.applyExifOrientation(image, orientation);
+        }
+        return image;
+    }
+
     private static void makeJpegThumbnail(File inputFile, File outputFile, java.awt.Dimension preferredTargetDimensions)
             throws IOException, ImageFormatError {
 
-        final BufferedImage inputImage = getBufferedImage(inputFile);
+        final BufferedImage inputImage = getOrientedBufferedImage(inputFile);
 
         final java.awt.Dimension sourceImageDimension = new java.awt.Dimension(
                 inputImage.getWidth(), inputImage.getHeight());
@@ -165,6 +181,9 @@ public class Picture extends AbstractFile {
 
     private static void makeGifThumbnail(
             File inputFile, File outputFile, java.awt.Dimension preferredTargetDimensions) throws IOException {
+
+        final int orientation = Utils.getExifOrientation(inputFile);
+
         ImageIcon imageIcon = new ImageIcon(IOHelper.getFileContents(inputFile));
 
         final java.awt.Dimension sourceImageDimension = new java.awt.Dimension(
@@ -183,7 +202,8 @@ public class Picture extends AbstractFile {
 
         Utils.ImageFrame[] frames = Utils.readGIF(reader);
         for (Utils.ImageFrame frame : frames) {
-            Image scaleImage = scaleImage(frame.image, targetDimensions.width, targetDimensions.height);
+            BufferedImage orientedImage = Utils.applyExifOrientation(frame.image, orientation);
+            Image scaleImage = scaleImage(orientedImage, targetDimensions.width, targetDimensions.height);
             BufferedImage bImage = new BufferedImage(
                     targetDimensions.width, targetDimensions.height, BufferedImage.TYPE_INT_ARGB);
             Graphics2D bGr = bImage.createGraphics();
@@ -253,7 +273,7 @@ public class Picture extends AbstractFile {
 
     @Override
     public void updateFileMetadata(final File parentDirectory) throws Exception {
-        final BufferedImage image = getBufferedImage(getFile(parentDirectory));
+        final BufferedImage image = getOrientedBufferedImage(getFile(parentDirectory));
         dimensions = new Dimension(image.getWidth(), image.getHeight());
     }