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 {