diff --git a/images/images.qrc b/images/images.qrc
index 2a386af179..2a2bb04287 100644
--- a/images/images.qrc
+++ b/images/images.qrc
@@ -203,5 +203,6 @@
themes/qfield/nodpi/ic_collapse_all_24dp.svg
themes/qfield/nodpi/ic_expand_all_24dp.svg
themes/qfield/nodpi/ic_3d_24dp.svg
+ themes/qfield/nodpi/ic_phone_rotate_black_24dp.svg
diff --git a/images/themes/qfield/nodpi/ic_phone_rotate_black_24dp.svg b/images/themes/qfield/nodpi/ic_phone_rotate_black_24dp.svg
new file mode 100644
index 0000000000..5350f0d03c
--- /dev/null
+++ b/images/themes/qfield/nodpi/ic_phone_rotate_black_24dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/core/utils/fileutils.cpp b/src/core/utils/fileutils.cpp
index 3df0f7372c..5c6b9fafe7 100644
--- a/src/core/utils/fileutils.cpp
+++ b/src/core/utils/fileutils.cpp
@@ -26,10 +26,13 @@
#include
#include
#include
+#include
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -216,7 +219,9 @@ bool FileUtils::copyRecursively( const QString &sourceFolder, const QString &des
QFileInfo destInfo( destName );
if ( QFileInfo( srcName ).isDir() )
+ {
continue;
+ }
QDir destDir( destInfo.absoluteDir() );
if ( !destDir.exists() )
@@ -224,7 +229,9 @@ bool FileUtils::copyRecursively( const QString &sourceFolder, const QString &des
destDir.mkpath( destDir.path() );
}
if ( QFile::exists( destName ) )
+ {
QFile::remove( destName );
+ }
bool success = QFile::copy( srcName, destName );
if ( !success )
@@ -236,7 +243,9 @@ bool FileUtils::copyRecursively( const QString &sourceFolder, const QString &des
QFile( destName ).setPermissions( QFileDevice::ReadOwner | QFileDevice::WriteOwner );
if ( feedback )
+ {
feedback->setProgress( 100 * current / fileCount );
+ }
++current;
}
@@ -249,7 +258,9 @@ int FileUtils::copyRecursivelyPrepare( const QString &sourceFolder, const QStrin
QDir sourceDir( sourceFolder );
if ( !sourceDir.exists() )
+ {
return 0;
+ }
int count = 0;
@@ -261,7 +272,9 @@ int FileUtils::copyRecursivelyPrepare( const QString &sourceFolder, const QStrin
QString filePath = dirIt.next();
const QString relPath = filePath.mid( sfLength );
if ( relPath.endsWith( QLatin1String( "/." ) ) || relPath.endsWith( QLatin1String( "/.." ) ) )
+ {
continue;
+ }
QString srcName = QDir::cleanPath( sourceFolder + QDir::separator() + relPath );
QString destName = QDir::cleanPath( destFolder + QDir::separator() + relPath );
@@ -279,12 +292,16 @@ QByteArray FileUtils::fileChecksum( const QString &fileName, const QCryptographi
QFile f( fileName );
if ( !f.open( QFile::ReadOnly ) )
+ {
return QByteArray();
+ }
QCryptographicHash hash( hashAlgorithm );
if ( hash.addData( &f ) )
+ {
return hash.result();
+ }
return QByteArray();
}
@@ -329,14 +346,17 @@ void FileUtils::restrictImageSize( const QString &imagePath, int maximumWidthHei
}
QVariantMap metadata = QgsExifTools::readTags( imagePath );
- QImage img( imagePath );
+ QImageReader reader( imagePath );
+ reader.setAutoTransform( true );
+ QImage img = reader.read();
if ( !img.isNull() && ( img.width() > maximumWidthHeight || img.height() > maximumWidthHeight ) )
{
QImage scaledImage = img.width() > img.height()
? img.scaledToWidth( maximumWidthHeight, Qt::SmoothTransformation )
: img.scaledToHeight( maximumWidthHeight, Qt::SmoothTransformation );
scaledImage.save( imagePath, nullptr, 90 );
-
+ metadata["Exif.Image.Orientation"] = 1;
+ metadata["Xmp.tiff.Orientation"] = 1;
for ( const QString &key : metadata.keys() )
{
QgsExifTools::tagImage( imagePath, key, metadata[key] );
@@ -399,7 +419,9 @@ void FileUtils::addImageStamp( const QString &imagePath, const QString &text, co
QgsReadWriteContext readWriteContent;
readWriteContent.setPathResolver( QgsProject::instance()->pathResolver() );
QVariantMap metadata = QgsExifTools::readTags( imagePath );
- QImage img( imagePath );
+ QImageReader reader( imagePath );
+ reader.setAutoTransform( true );
+ QImage img = reader.read();
if ( !img.isNull() )
{
QPainter painter( &img );
@@ -489,7 +511,8 @@ void FileUtils::addImageStamp( const QString &imagePath, const QString &text, co
QgsTextRenderer::drawText( QRectF( 10, img.height() - textHeight - 20, img.width() - 20, img.height() - 20 ), 0, horizontalAlignment, text.split( QStringLiteral( "\n" ) ), context, format, true, Qgis::TextVerticalAlignment::Top, Qgis::TextRendererFlag::WrapLines );
img.save( imagePath, nullptr, 90 );
-
+ metadata["Exif.Image.Orientation"] = 1;
+ metadata["Xmp.tiff.Orientation"] = 1;
for ( const QString &key : metadata.keys() )
{
QgsExifTools::tagImage( imagePath, key, metadata[key] );
@@ -497,16 +520,168 @@ void FileUtils::addImageStamp( const QString &imagePath, const QString &text, co
}
}
+bool FileUtils::normalizeImageOrientation( const QString &imagePath, int additionalRotation, bool expectLandscape )
+{
+ if ( !QFileInfo::exists( imagePath ) )
+ {
+ return false;
+ }
+
+ // Read EXIF tags before touching the image
+ QVariantMap metadata = QgsExifTools::readTags( imagePath );
+ const int exifOrientation = metadata.value( "Exif.Image.Orientation", 1 ).toInt();
+
+ // Read raw pixels without Qt auto-rotating them
+ QImageReader reader( imagePath );
+ reader.setAutoTransform( false );
+ QImage img = reader.read();
+
+ if ( img.isNull() )
+ {
+ return false;
+ }
+
+ const bool rawIsLandscape = img.width() > img.height();
+
+ // Map EXIF orientation tag to clockwise rotation degrees and horizontal mirror flag.
+ // EXIF spec: orientation values 1-8 encode all combinations of 90° rotation and mirror.
+ int exifRotation = 0;
+ bool needMirror = false;
+ switch ( exifOrientation )
+ {
+ case 1:
+ exifRotation = 0;
+ needMirror = false;
+ break;
+ case 2:
+ exifRotation = 0;
+ needMirror = true;
+ break;
+ case 3:
+ exifRotation = 180;
+ needMirror = false;
+ break;
+ case 4:
+ exifRotation = 180;
+ needMirror = true;
+ break;
+ case 5:
+ exifRotation = 90;
+ needMirror = true;
+ break;
+ case 6:
+ exifRotation = 90;
+ needMirror = false;
+ break;
+ case 7:
+ exifRotation = 270;
+ needMirror = true;
+ break;
+ case 8:
+ exifRotation = 270;
+ needMirror = false;
+ break;
+ default:
+ break;
+ }
+
+ // Determine the effective orientation after the EXIF rotation would be applied.
+ // A 90° or 270° rotation swaps width and height.
+ const bool afterExifLandscape = ( exifRotation == 90 || exifRotation == 270 ) ? !rawIsLandscape : rawIsLandscape;
+
+ // If the effective orientation doesn't match what we expect, add a 90° correction
+ // to bring the dimensions in line. This handles devices that save images sideways
+ const int dimCorrection = ( expectLandscape != afterExifLandscape ) ? 90 : 0;
+
+ // Normalize additional rotation to [0, 360)
+ int addRot = additionalRotation % 360;
+ if ( addRot < 0 )
+ {
+ addRot += 360;
+ }
+
+ // Combine EXIF-implied rotation, dimension correction, and user preference
+ const int totalRotation = ( exifRotation + dimCorrection + addRot ) % 360;
+
+ // Nothing to do: image is already upright with correct EXIF tag
+ if ( totalRotation == 0 && !needMirror && exifOrientation == 1 )
+ {
+ return true;
+ }
+
+ // Apply horizontal mirror before rotation (EXIF convention)
+ if ( needMirror )
+ {
+ img = img.flipped( Qt::Horizontal );
+ }
+
+ if ( totalRotation != 0 )
+ {
+ QTransform xform;
+ xform.rotate( totalRotation );
+ img = img.transformed( xform, Qt::SmoothTransformation );
+ }
+
+ // Determine output format from file extension
+ QByteArray fmt = QFileInfo( imagePath ).suffix().toLower().toLatin1();
+ if ( fmt == "jpeg" )
+ {
+ fmt = "jpg";
+ }
+ if ( fmt.isEmpty() )
+ {
+ fmt = "jpg";
+ }
+
+ // Write atomically to avoid a corrupt file on failure
+ QSaveFile out( imagePath );
+ if ( !out.open( QIODevice::WriteOnly ) )
+ {
+ return false;
+ }
+
+ QImageWriter writer( &out, fmt );
+ if ( fmt == "jpg" )
+ {
+ writer.setQuality( 90 );
+ }
+
+ if ( !writer.write( img ) )
+ {
+ out.cancelWriting();
+ return false;
+ }
+
+ if ( !out.commit() )
+ {
+ return false;
+ }
+
+ // Reset EXIF orientation to "normal / upright" since pixels are now physically correct
+ metadata["Exif.Image.Orientation"] = 1;
+ metadata["Xmp.tiff.Orientation"] = 1;
+ for ( auto it = metadata.constBegin(); it != metadata.constEnd(); ++it )
+ {
+ QgsExifTools::tagImage( imagePath, it.key(), it.value() );
+ }
+
+ return true;
+}
+
bool FileUtils::isWithinProjectDirectory( const QString &filePath )
{
// Get the project instance
QgsProject *project = QgsProject::instance();
if ( !project || project->fileName().isEmpty() )
+ {
return false;
+ }
QFileInfo projectFileInfo( project->fileName() );
if ( !projectFileInfo.exists() )
+ {
return false;
+ }
// Get the canonical path for the project directory
QString projectDirCanonical = QFileInfo( projectFileInfo.dir().absolutePath() ).canonicalFilePath();
@@ -515,7 +690,9 @@ bool FileUtils::isWithinProjectDirectory( const QString &filePath )
// Fallback to absolutePath() if canonicalFilePath() is empty
projectDirCanonical = QFileInfo( projectFileInfo.dir().absolutePath() ).absoluteFilePath();
if ( projectDirCanonical.isEmpty() )
+ {
return false;
+ }
}
// Get target file info and its canonical path
@@ -546,7 +723,9 @@ bool FileUtils::isWithinProjectDirectory( const QString &filePath )
{
existingCanonical = QFileInfo( dir.path() ).absoluteFilePath();
if ( existingCanonical.isEmpty() )
+ {
return false;
+ }
}
// Rebuild the target path from existing directories
diff --git a/src/core/utils/fileutils.h b/src/core/utils/fileutils.h
index 395cdf463d..95abea47b7 100644
--- a/src/core/utils/fileutils.h
+++ b/src/core/utils/fileutils.h
@@ -126,6 +126,18 @@ class QFIELD_CORE_EXPORT FileUtils : public QObject
*/
Q_INVOKABLE static void addImageStamp( const QString &imagePath, const QString &text, const QString &textFormat = QString(), Qgis::TextHorizontalAlignment horizontalAlignment = Qgis::TextHorizontalAlignment::Left, const QString &imageDecoration = QString() );
+ /**
+ * Normalizes an image orientation for camera captures.
+ * Handles EXIF orientation tags and dimension mismatches between expected
+ * capture orientation and actual saved image orientation across different devices.
+ * Physically bakes all rotation into pixels and resets EXIF orientation to 1.
+ * \param imagePath the image file path
+ * \param additionalRotation additional clockwise rotation in degrees (0, 90, 180, or 270)
+ * \param expectLandscape whether the capture was made in landscape orientation
+ * \returns TRUE on success, FALSE otherwise
+ */
+ Q_INVOKABLE static bool normalizeImageOrientation( const QString &imagePath, int additionalRotation, bool expectLandscape );
+
static bool copyRecursively( const QString &sourceFolder, const QString &destFolder, QgsFeedback *feedback = nullptr, bool wipeDestFolder = true );
/**
diff --git a/src/qml/QFieldCamera.qml b/src/qml/QFieldCamera.qml
index 8359a132ec..d9a236bd1d 100644
--- a/src/qml/QFieldCamera.qml
+++ b/src/qml/QFieldCamera.qml
@@ -113,6 +113,8 @@ Popup {
property string deviceId: ''
property size resolution: Qt.size(0, 0)
property int pixelFormat: 0
+ property int landscapeRotation: 0 // User-configurable: 0, 90, 180, or 270
+ property bool orientationHelpShown: false
}
ExpressionEvaluator {
@@ -164,12 +166,56 @@ Popup {
property alias camera: camera
property alias imageCapture: imageCapture
property alias recorder: recorder
- property alias videoOutput: videoOutput // expose it
+ property alias videoOutput: videoOutput
+ property alias previewRotation: cameraOrientation.rotation
- VideoOutput {
- id: videoOutput
- anchors.fill: parent
- visible: cameraItem.state == "PhotoCapture" || cameraItem.state == "VideoCapture"
+ QtObject {
+ id: cameraOrientation
+
+ property int rotation: {
+ // Desktop: never apply additional rotation, the OS handles it
+ if (Qt.platform.os !== "ios" && Qt.platform.os !== "android") {
+ return 0;
+ }
+ // Portrait: no additional rotation needed
+ if (cameraItem.isPortraitMode) {
+ return 0;
+ }
+ if (cameraItem.state === "VideoCapture") {
+ return 0;
+ }
+ // Landscape on mobile: use the user's saved preference
+ return cameraSettings.landscapeRotation;
+ }
+
+ // Show a one-time hint the first time the user enters landscape mode,
+ // so user know the rotation button exists if the image appears upside-down
+ onRotationChanged: {
+ if (!cameraSettings.orientationHelpShown && !cameraItem.isPortraitMode && (Qt.platform.os === "ios" || Qt.platform.os === "android")) {
+ cameraSettings.orientationHelpShown = true;
+ displayToast(qsTr("Wrong camera orientation ? Use the rotation button in settings menu."));
+ }
+ }
+ }
+
+ Item {
+ id: videoOutputContainer
+ anchors.centerIn: parent
+ // Pre-swap dimensions so after rotation the content fills the parent exactly
+ width: (cameraOrientation.rotation === 90 || cameraOrientation.rotation === 270) ? parent.height : parent.width
+ height: (cameraOrientation.rotation === 90 || cameraOrientation.rotation === 270) ? parent.width : parent.height
+
+ VideoOutput {
+ id: videoOutput
+ anchors.fill: parent
+ visible: cameraItem.state == "PhotoCapture" || cameraItem.state == "VideoCapture"
+
+ transform: Rotation {
+ origin.x: videoOutput.width / 2
+ origin.y: videoOutput.height / 2
+ angle: cameraOrientation.rotation
+ }
+ }
}
CaptureSession {
@@ -259,6 +305,7 @@ Popup {
if (device.id === cameraSettings.deviceId) {
item.camera.cameraDevice = device;
cameraPicked = true;
+ break;
}
}
}
@@ -366,16 +413,29 @@ Popup {
muted: true
}
- Image {
- id: photoPreview
-
+ Item {
+ id: photoPreviewContainer
+ anchors.centerIn: parent
visible: cameraItem.state == "PhotoPreview"
-
- anchors.fill: parent
- cache: false
- fillMode: Image.PreserveAspectFit
- smooth: true
- focus: visible
+ // Pre-swap dimensions so after rotation the content fills the parent exactly
+ property real rot: captureLoader.item ? captureLoader.item.previewRotation : 0
+ width: (rot === 90 || rot === 270) ? parent.height : parent.width
+ height: (rot === 90 || rot === 270) ? parent.width : parent.height
+
+ Image {
+ id: photoPreview
+ anchors.fill: parent
+ cache: false
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ focus: visible
+
+ transform: Rotation {
+ origin.x: photoPreview.width / 2
+ origin.y: photoPreview.height / 2
+ angle: captureLoader.item ? captureLoader.item.previewRotation : 0
+ }
+ }
}
PinchArea {
@@ -497,9 +557,16 @@ Popup {
currentPath = UrlUtils.toLocalFile(path);
}
} else if (cameraItem.state == "PhotoPreview" || cameraItem.state == "VideoPreview") {
- if (!currentPath || currentPath === "")
+ if (!currentPath || currentPath === "") {
return;
+ }
if (cameraItem.state == "PhotoPreview") {
+ // Bake the preview rotation and any EXIF transform into the saved pixels.
+ // In portrait mode no additional rotation is needed; in landscape we pass
+ // the user's chosen rotation preference.
+ const userRotation = cameraItem.isPortraitMode ? 0 : cameraSettings.landscapeRotation;
+ const expectLandscape = !cameraItem.isPortraitMode;
+ FileUtils.normalizeImageOrientation(currentPath, userRotation, expectLandscape);
if (cameraSettings.geoTagging && positionSource.active) {
FileUtils.addImageMetadata(currentPath, currentPosition);
}
@@ -747,6 +814,45 @@ Popup {
displayToast(cameraSettings.showGrid ? qsTr("Grid enabled") : qsTr("Grid disabled"));
}
}
+ Loader {
+ id: orientationButtonLoader
+
+ property bool show: !cameraItem.isPortraitMode && (Qt.platform.os === "ios" || Qt.platform.os === "android") && cameraItem.state !== "VideoCapture"
+
+ active: show
+ visible: show
+
+ // collapse footprint when hidden, just incase
+ width: show ? 40 : 0
+ height: show ? 40 : 0
+
+ sourceComponent: QfToolButton {
+ anchors.fill: parent
+ padding: 2
+
+ iconSource: Theme.getThemeVectorIcon("ic_phone_rotate_black_24dp")
+ iconColor: cameraSettings.landscapeRotation !== 0 ? Theme.mainColor : Theme.toolButtonColor
+ bgcolor: Theme.toolButtonBackgroundSemiOpaqueColor
+ round: true
+
+ onClicked: {
+ // cycle [ 0° - 180° - 90° - 270° - 0° ]
+ if (cameraSettings.landscapeRotation === 0) {
+ cameraSettings.landscapeRotation = 180;
+ displayToast(qsTr("Rotation: 180°"));
+ } else if (cameraSettings.landscapeRotation === 180) {
+ cameraSettings.landscapeRotation = 90;
+ displayToast(qsTr("Rotation: 90°"));
+ } else if (cameraSettings.landscapeRotation === 90) {
+ cameraSettings.landscapeRotation = 270;
+ displayToast(qsTr("Rotation: 270°"));
+ } else {
+ cameraSettings.landscapeRotation = 0;
+ displayToast(qsTr("Rotation: 0°"));
+ }
+ }
+ }
+ }
}
QfMenu {