Skip to content
1 change: 1 addition & 0 deletions images/images.qrc
Original file line number Diff line number Diff line change
Expand Up @@ -203,5 +203,6 @@
<file>themes/qfield/nodpi/ic_collapse_all_24dp.svg</file>
<file>themes/qfield/nodpi/ic_expand_all_24dp.svg</file>
<file>themes/qfield/nodpi/ic_3d_24dp.svg</file>
<file>themes/qfield/nodpi/ic_phone_rotate_black_24dp.svg</file>
</qresource>
</RCC>
1 change: 1 addition & 0 deletions images/themes/qfield/nodpi/ic_phone_rotate_black_24dp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
187 changes: 183 additions & 4 deletions src/core/utils/fileutils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@
#include <QFileInfo>
#include <QImage>
#include <QImageReader>
#include <QImageWriter>
#include <QMimeDatabase>
#include <QPainter>
#include <QPainterPath>
#include <QSaveFile>
#include <QStandardPaths>
#include <QTransform>
#include <qgis.h>
#include <qgsapplication.h>
#include <qgsexiftools.h>
Expand Down Expand Up @@ -216,15 +219,19 @@ bool FileUtils::copyRecursively( const QString &sourceFolder, const QString &des

QFileInfo destInfo( destName );
if ( QFileInfo( srcName ).isDir() )
{
continue;
}

QDir destDir( destInfo.absoluteDir() );
if ( !destDir.exists() )
{
destDir.mkpath( destDir.path() );
}
if ( QFile::exists( destName ) )
{
QFile::remove( destName );
}

bool success = QFile::copy( srcName, destName );
if ( !success )
Expand All @@ -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;
}
Expand All @@ -249,7 +258,9 @@ int FileUtils::copyRecursivelyPrepare( const QString &sourceFolder, const QStrin
QDir sourceDir( sourceFolder );

if ( !sourceDir.exists() )
{
return 0;
}

int count = 0;

Expand All @@ -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 );
Expand All @@ -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();
}
Expand Down Expand Up @@ -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] );
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -489,24 +511,177 @@ 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] );
}
}
}

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();
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/core/utils/fileutils.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

/**
Expand Down
Loading
Loading