diff --git a/.github/actions/spell-check/patterns.txt b/.github/actions/spell-check/patterns.txt index 181d728e84..024cef81a1 100644 --- a/.github/actions/spell-check/patterns.txt +++ b/.github/actions/spell-check/patterns.txt @@ -273,3 +273,6 @@ St&yle # Usernames with numbers # 0x6f677548 is user name but user folder causes a flag \bx6f677548\b + +# Microsoft Store URLs and product IDs +ms-windows-store://\S+ diff --git a/src/modules/powerrename/lib/Helpers.cpp b/src/modules/powerrename/lib/Helpers.cpp index 03977a3732..a1ae8c9073 100644 --- a/src/modules/powerrename/lib/Helpers.cpp +++ b/src/modules/powerrename/lib/Helpers.cpp @@ -347,14 +347,19 @@ bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataTyp // According to the metadata support table, only these formats support metadata extraction: // - JPEG (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding - // - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding + // - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding // - PNG (text chunks) + // - HEIF/HEIC (IFD, Exif, XMP, GPS) - requires HEIF Image Extensions from Microsoft Store + // - AVIF (IFD, Exif, XMP, GPS) - requires AV1 Video Extension from Microsoft Store static const std::unordered_set supportedExtensions = { L".jpg", L".jpeg", L".png", L".tif", - L".tiff" + L".tiff", + L".heic", + L".heif", + L".avif" }; // If file type doesn't support metadata, no need to check patterns diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.cpp b/src/modules/powerrename/lib/WICMetadataExtractor.cpp index bd2f9c08dc..eb66679aad 100644 --- a/src/modules/powerrename/lib/WICMetadataExtractor.cpp +++ b/src/modules/powerrename/lib/WICMetadataExtractor.cpp @@ -20,7 +20,7 @@ namespace // WIC metadata property paths const std::wstring EXIF_DATE_TAKEN = L"/app1/ifd/exif/{ushort=36867}"; // DateTimeOriginal - const std::wstring EXIF_DATE_DIGITIZED = L"/app1/ifd/exif/{ushort=36868}"; // DateTimeDigitized + const std::wstring EXIF_DATE_DIGITIZED = L"/app1/ifd/exif/{ushort=36868}"; // DateTimeDigitized const std::wstring EXIF_DATE_MODIFIED = L"/app1/ifd/{ushort=306}"; // DateTime const std::wstring EXIF_CAMERA_MAKE = L"/app1/ifd/{ushort=271}"; // Make const std::wstring EXIF_CAMERA_MODEL = L"/app1/ifd/{ushort=272}"; // Model @@ -37,14 +37,43 @@ namespace const std::wstring EXIF_HEIGHT = L"/app1/ifd/exif/{ushort=40963}"; // PixelYDimension - actual image height const std::wstring EXIF_ARTIST = L"/app1/ifd/{ushort=315}"; // Artist const std::wstring EXIF_COPYRIGHT = L"/app1/ifd/{ushort=33432}"; // Copyright - - // GPS paths + + // GPS paths for JPEG format const std::wstring GPS_LATITUDE = L"/app1/ifd/gps/{ushort=2}"; // GPSLatitude const std::wstring GPS_LATITUDE_REF = L"/app1/ifd/gps/{ushort=1}"; // GPSLatitudeRef const std::wstring GPS_LONGITUDE = L"/app1/ifd/gps/{ushort=4}"; // GPSLongitude const std::wstring GPS_LONGITUDE_REF = L"/app1/ifd/gps/{ushort=3}"; // GPSLongitudeRef const std::wstring GPS_ALTITUDE = L"/app1/ifd/gps/{ushort=6}"; // GPSAltitude const std::wstring GPS_ALTITUDE_REF = L"/app1/ifd/gps/{ushort=5}"; // GPSAltitudeRef + + // WIC metadata property paths for TIFF/HEIF format (uses /ifd prefix directly) + // HEIF/HEIC images use TIFF-style metadata paths + const std::wstring HEIF_DATE_TAKEN = L"/ifd/exif/{ushort=36867}"; // DateTimeOriginal + const std::wstring HEIF_DATE_DIGITIZED = L"/ifd/exif/{ushort=36868}"; // DateTimeDigitized + const std::wstring HEIF_DATE_MODIFIED = L"/ifd/{ushort=306}"; // DateTime + const std::wstring HEIF_CAMERA_MAKE = L"/ifd/{ushort=271}"; // Make + const std::wstring HEIF_CAMERA_MODEL = L"/ifd/{ushort=272}"; // Model + const std::wstring HEIF_LENS_MODEL = L"/ifd/exif/{ushort=42036}"; // LensModel + const std::wstring HEIF_ISO = L"/ifd/exif/{ushort=34855}"; // ISOSpeedRatings + const std::wstring HEIF_APERTURE = L"/ifd/exif/{ushort=33437}"; // FNumber + const std::wstring HEIF_SHUTTER_SPEED = L"/ifd/exif/{ushort=33434}"; // ExposureTime + const std::wstring HEIF_FOCAL_LENGTH = L"/ifd/exif/{ushort=37386}"; // FocalLength + const std::wstring HEIF_EXPOSURE_BIAS = L"/ifd/exif/{ushort=37380}"; // ExposureBiasValue + const std::wstring HEIF_FLASH = L"/ifd/exif/{ushort=37385}"; // Flash + const std::wstring HEIF_ORIENTATION = L"/ifd/{ushort=274}"; // Orientation + const std::wstring HEIF_COLOR_SPACE = L"/ifd/exif/{ushort=40961}"; // ColorSpace + const std::wstring HEIF_WIDTH = L"/ifd/exif/{ushort=40962}"; // PixelXDimension + const std::wstring HEIF_HEIGHT = L"/ifd/exif/{ushort=40963}"; // PixelYDimension + const std::wstring HEIF_ARTIST = L"/ifd/{ushort=315}"; // Artist + const std::wstring HEIF_COPYRIGHT = L"/ifd/{ushort=33432}"; // Copyright + + // GPS paths for TIFF/HEIF format + const std::wstring HEIF_GPS_LATITUDE = L"/ifd/gps/{ushort=2}"; // GPSLatitude + const std::wstring HEIF_GPS_LATITUDE_REF = L"/ifd/gps/{ushort=1}"; // GPSLatitudeRef + const std::wstring HEIF_GPS_LONGITUDE = L"/ifd/gps/{ushort=4}"; // GPSLongitude + const std::wstring HEIF_GPS_LONGITUDE_REF = L"/ifd/gps/{ushort=3}"; // GPSLongitudeRef + const std::wstring HEIF_GPS_ALTITUDE = L"/ifd/gps/{ushort=6}"; // GPSAltitude + const std::wstring HEIF_GPS_ALTITUDE_REF = L"/ifd/gps/{ushort=5}"; // GPSAltitudeRef // Documentation: https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/ @@ -465,8 +494,11 @@ bool WICMetadataExtractor::LoadEXIFMetadata( return false; } - ExtractAllEXIFFields(reader, outMetadata); - ExtractGPSData(reader, outMetadata); + // Detect container format to determine correct metadata paths + MetadataPathFormat pathFormat = GetMetadataPathFormatFromDecoder(decoder); + + ExtractAllEXIFFields(reader, outMetadata, pathFormat); + ExtractGPSData(reader, outMetadata, pathFormat); return true; } @@ -507,64 +539,126 @@ CComPtr WICMetadataExtractor::GetMetadataReader(IWICBit { return nullptr; } - + CComPtr frame; if (FAILED(decoder->GetFrame(0, &frame))) { return nullptr; } - + CComPtr reader; frame->GetMetadataQueryReader(&reader); - + return reader; } -void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) +MetadataPathFormat WICMetadataExtractor::GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder) +{ + if (!decoder) + { + return MetadataPathFormat::JPEG; + } + + GUID containerFormat; + if (SUCCEEDED(decoder->GetContainerFormat(&containerFormat))) + { + // HEIF and TIFF use /ifd/... paths directly + if (containerFormat == GUID_ContainerFormatHeif || + containerFormat == GUID_ContainerFormatTiff) + { + return MetadataPathFormat::IFD; + } + } + + // JPEG and other formats use /app1/ifd/... paths + return MetadataPathFormat::JPEG; +} + +void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat) { if (!reader) return; - + + // Select the correct paths based on container format + const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD); + + // Date/time paths + const auto& dateTakenPath = useIfdPaths ? HEIF_DATE_TAKEN : EXIF_DATE_TAKEN; + const auto& dateDigitizedPath = useIfdPaths ? HEIF_DATE_DIGITIZED : EXIF_DATE_DIGITIZED; + const auto& dateModifiedPath = useIfdPaths ? HEIF_DATE_MODIFIED : EXIF_DATE_MODIFIED; + + // Camera info paths + const auto& cameraMakePath = useIfdPaths ? HEIF_CAMERA_MAKE : EXIF_CAMERA_MAKE; + const auto& cameraModelPath = useIfdPaths ? HEIF_CAMERA_MODEL : EXIF_CAMERA_MODEL; + const auto& lensModelPath = useIfdPaths ? HEIF_LENS_MODEL : EXIF_LENS_MODEL; + + // Shooting parameter paths + const auto& isoPath = useIfdPaths ? HEIF_ISO : EXIF_ISO; + const auto& aperturePath = useIfdPaths ? HEIF_APERTURE : EXIF_APERTURE; + const auto& shutterSpeedPath = useIfdPaths ? HEIF_SHUTTER_SPEED : EXIF_SHUTTER_SPEED; + const auto& focalLengthPath = useIfdPaths ? HEIF_FOCAL_LENGTH : EXIF_FOCAL_LENGTH; + const auto& exposureBiasPath = useIfdPaths ? HEIF_EXPOSURE_BIAS : EXIF_EXPOSURE_BIAS; + const auto& flashPath = useIfdPaths ? HEIF_FLASH : EXIF_FLASH; + + // Image property paths + const auto& widthPath = useIfdPaths ? HEIF_WIDTH : EXIF_WIDTH; + const auto& heightPath = useIfdPaths ? HEIF_HEIGHT : EXIF_HEIGHT; + const auto& orientationPath = useIfdPaths ? HEIF_ORIENTATION : EXIF_ORIENTATION; + const auto& colorSpacePath = useIfdPaths ? HEIF_COLOR_SPACE : EXIF_COLOR_SPACE; + + // Author info paths + const auto& artistPath = useIfdPaths ? HEIF_ARTIST : EXIF_ARTIST; + const auto& copyrightPath = useIfdPaths ? HEIF_COPYRIGHT : EXIF_COPYRIGHT; + // Extract date/time fields - metadata.dateTaken = ReadDateTime(reader, EXIF_DATE_TAKEN); - metadata.dateDigitized = ReadDateTime(reader, EXIF_DATE_DIGITIZED); - metadata.dateModified = ReadDateTime(reader, EXIF_DATE_MODIFIED); - + metadata.dateTaken = ReadDateTime(reader, dateTakenPath); + metadata.dateDigitized = ReadDateTime(reader, dateDigitizedPath); + metadata.dateModified = ReadDateTime(reader, dateModifiedPath); + // Extract camera information - metadata.cameraMake = ReadString(reader, EXIF_CAMERA_MAKE); - metadata.cameraModel = ReadString(reader, EXIF_CAMERA_MODEL); - metadata.lensModel = ReadString(reader, EXIF_LENS_MODEL); - + metadata.cameraMake = ReadString(reader, cameraMakePath); + metadata.cameraModel = ReadString(reader, cameraModelPath); + metadata.lensModel = ReadString(reader, lensModelPath); + // Extract shooting parameters - metadata.iso = ReadInteger(reader, EXIF_ISO); - metadata.aperture = ReadDouble(reader, EXIF_APERTURE); - metadata.shutterSpeed = ReadDouble(reader, EXIF_SHUTTER_SPEED); - metadata.focalLength = ReadDouble(reader, EXIF_FOCAL_LENGTH); - metadata.exposureBias = ReadDouble(reader, EXIF_EXPOSURE_BIAS); - metadata.flash = ReadInteger(reader, EXIF_FLASH); - + metadata.iso = ReadInteger(reader, isoPath); + metadata.aperture = ReadDouble(reader, aperturePath); + metadata.shutterSpeed = ReadDouble(reader, shutterSpeedPath); + metadata.focalLength = ReadDouble(reader, focalLengthPath); + metadata.exposureBias = ReadDouble(reader, exposureBiasPath); + metadata.flash = ReadInteger(reader, flashPath); + // Extract image properties - metadata.width = ReadInteger(reader, EXIF_WIDTH); - metadata.height = ReadInteger(reader, EXIF_HEIGHT); - metadata.orientation = ReadInteger(reader, EXIF_ORIENTATION); - metadata.colorSpace = ReadInteger(reader, EXIF_COLOR_SPACE); - + metadata.width = ReadInteger(reader, widthPath); + metadata.height = ReadInteger(reader, heightPath); + metadata.orientation = ReadInteger(reader, orientationPath); + metadata.colorSpace = ReadInteger(reader, colorSpacePath); + // Extract author information - metadata.author = ReadString(reader, EXIF_ARTIST); - metadata.copyright = ReadString(reader, EXIF_COPYRIGHT); + metadata.author = ReadString(reader, artistPath); + metadata.copyright = ReadString(reader, copyrightPath); } -void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) +void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat) { if (!reader) { return; } - auto lat = ReadMetadata(reader, GPS_LATITUDE); - auto lon = ReadMetadata(reader, GPS_LONGITUDE); - auto latRef = ReadMetadata(reader, GPS_LATITUDE_REF); - auto lonRef = ReadMetadata(reader, GPS_LONGITUDE_REF); + // Select the correct paths based on container format + const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD); + + const auto& latitudePath = useIfdPaths ? HEIF_GPS_LATITUDE : GPS_LATITUDE; + const auto& longitudePath = useIfdPaths ? HEIF_GPS_LONGITUDE : GPS_LONGITUDE; + const auto& latitudeRefPath = useIfdPaths ? HEIF_GPS_LATITUDE_REF : GPS_LATITUDE_REF; + const auto& longitudeRefPath = useIfdPaths ? HEIF_GPS_LONGITUDE_REF : GPS_LONGITUDE_REF; + const auto& altitudePath = useIfdPaths ? HEIF_GPS_ALTITUDE : GPS_ALTITUDE; + + auto lat = ReadMetadata(reader, latitudePath); + auto lon = ReadMetadata(reader, longitudePath); + auto latRef = ReadMetadata(reader, latitudeRefPath); + auto lonRef = ReadMetadata(reader, longitudeRefPath); if (lat && lon) { @@ -584,7 +678,7 @@ void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFM metadata.longitude = coords.second; } - auto alt = ReadMetadata(reader, GPS_ALTITUDE); + auto alt = ReadMetadata(reader, altitudePath); if (alt) { metadata.altitude = MetadataFormatHelper::ParseGPSRational(alt->Get()); diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.h b/src/modules/powerrename/lib/WICMetadataExtractor.h index 868d18aa7c..f2149a40f1 100644 --- a/src/modules/powerrename/lib/WICMetadataExtractor.h +++ b/src/modules/powerrename/lib/WICMetadataExtractor.h @@ -9,14 +9,32 @@ #include #include +// Forward declarations for unit test friend classes +namespace WICMetadataExtractorTests +{ + class ExtractAVIFMetadataTests; +} + namespace PowerRenameLib { + /// + /// Metadata path format based on container type + /// + enum class MetadataPathFormat + { + JPEG, // Uses /app1/ifd/... paths (JPEG) + IFD // Uses /ifd/... paths (HEIF, TIFF, etc.) + }; + /// /// Windows Imaging Component (WIC) implementation for metadata extraction /// Provides efficient batch extraction of all metadata types with built-in caching /// class WICMetadataExtractor { + // Friend declarations for unit testing + friend class WICMetadataExtractorTests::ExtractAVIFMetadataTests; + public: WICMetadataExtractor(); ~WICMetadataExtractor(); @@ -45,10 +63,13 @@ namespace PowerRenameLib bool LoadXMPMetadata(const std::wstring& filePath, XMPMetadata& outMetadata); // Batch extraction methods - void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); - void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); + void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat); + void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat); void ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata); + // Internal container format detection + MetadataPathFormat GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder); + // Field reading helpers std::optional ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path); std::optional ReadString(IWICMetadataQueryReader* reader, const std::wstring& path); diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj index 3a3c5663aa..8e58bb7956 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj @@ -89,6 +89,12 @@ true + + true + + + true + true diff --git a/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp index c6d1d9b16c..265abfa7b7 100644 --- a/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp +++ b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp @@ -227,18 +227,350 @@ namespace WICMetadataExtractorTests XMPMetadata metadata; std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg"; - + bool result1 = extractor.ExtractXMPMetadata(testFile, metadata); Assert::IsTrue(result1); - + extractor.ClearCache(); - + XMPMetadata metadata2; bool result2 = extractor.ExtractXMPMetadata(testFile, metadata2); Assert::IsTrue(result2); - + // Both calls should succeed Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str()); } }; + + TEST_CLASS(ExtractHEIFMetadataTests) + { + public: + TEST_METHOD(ExtractHEIF_EXIF_CameraInfo) + { + // Test HEIF EXIF extraction - camera information + // This test requires HEIF Image Extensions to be installed from Microsoft Store + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + // Check if file exists first + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + // If HEIF extension is not installed, extraction may fail + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify camera information from iPhone + Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present"); + Assert::AreEqual(L"Apple", metadata.cameraMake.value().c_str(), L"Camera make should be Apple"); + + Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present"); + // Model should contain "iPhone" + Assert::IsTrue(metadata.cameraModel.value().find(L"iPhone") != std::wstring::npos, + L"Camera model should contain iPhone"); + } + + TEST_METHOD(ExtractHEIF_EXIF_DateTaken) + { + // Test HEIF EXIF extraction - date taken + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify date taken is present + Assert::IsTrue(metadata.dateTaken.has_value(), L"Date taken should be present"); + + // Verify the date is a reasonable year (2020-2030 range) + SYSTEMTIME dt = metadata.dateTaken.value(); + Assert::IsTrue(dt.wYear >= 2020 && dt.wYear <= 2030, L"Date taken year should be reasonable"); + } + + TEST_METHOD(ExtractHEIF_EXIF_ShootingParameters) + { + // Test HEIF EXIF extraction - shooting parameters + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify shooting parameters are present + Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present"); + Assert::IsTrue(metadata.iso.value() > 0, L"ISO should be positive"); + + Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present"); + Assert::IsTrue(metadata.aperture.value() > 0, L"Aperture should be positive"); + + Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present"); + Assert::IsTrue(metadata.shutterSpeed.value() > 0, L"Shutter speed should be positive"); + + Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present"); + Assert::IsTrue(metadata.focalLength.value() > 0, L"Focal length should be positive"); + } + + TEST_METHOD(ExtractHEIF_EXIF_GPS) + { + // Test HEIF EXIF extraction - GPS coordinates + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify GPS coordinates are present (if the test file has GPS data) + if (metadata.latitude.has_value() && metadata.longitude.has_value()) + { + // Latitude should be between -90 and 90 + Assert::IsTrue(metadata.latitude.value() >= -90.0 && metadata.latitude.value() <= 90.0, + L"Latitude should be valid"); + + // Longitude should be between -180 and 180 + Assert::IsTrue(metadata.longitude.value() >= -180.0 && metadata.longitude.value() <= 180.0, + L"Longitude should be valid"); + } + else + { + Logger::WriteMessage(L"GPS data not present in test file"); + } + } + + TEST_METHOD(ExtractHEIF_EXIF_ImageDimensions) + { + // Test HEIF EXIF extraction - image dimensions + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify image dimensions are present + Assert::IsTrue(metadata.width.has_value(), L"Width should be present"); + Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive"); + + Assert::IsTrue(metadata.height.has_value(), L"Height should be present"); + Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive"); + } + + TEST_METHOD(ExtractHEIF_EXIF_LensModel) + { + // Test HEIF EXIF extraction - lens model + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify lens model is present (iPhone photos typically have this) + if (metadata.lensModel.has_value()) + { + Assert::IsFalse(metadata.lensModel.value().empty(), L"Lens model should not be empty"); + } + else + { + Logger::WriteMessage(L"Lens model not present in test file"); + } + } + }; + + TEST_CLASS(ExtractAVIFMetadataTests) + { + public: + TEST_METHOD(ExtractAVIF_EXIF_CameraInfo) + { + // Test AVIF EXIF extraction - camera information + // This test requires AV1 Video Extension to be installed from Microsoft Store + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"AVIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed"); + return; + } + + Assert::IsTrue(result, L"AVIF EXIF extraction should succeed"); + + // Verify camera information + if (metadata.cameraMake.has_value()) + { + Assert::IsFalse(metadata.cameraMake.value().empty(), L"Camera make should not be empty"); + } + + if (metadata.cameraModel.has_value()) + { + Assert::IsFalse(metadata.cameraModel.value().empty(), L"Camera model should not be empty"); + } + } + + TEST_METHOD(ExtractAVIF_EXIF_DateTaken) + { + // Test AVIF EXIF extraction - date taken + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"AVIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed"); + return; + } + + Assert::IsTrue(result, L"AVIF EXIF extraction should succeed"); + + // Verify date taken is present + if (metadata.dateTaken.has_value()) + { + SYSTEMTIME dt = metadata.dateTaken.value(); + Assert::IsTrue(dt.wYear >= 2000 && dt.wYear <= 2100, L"Date taken year should be reasonable"); + } + else + { + Logger::WriteMessage(L"Date taken not present in AVIF test file"); + } + } + + TEST_METHOD(ExtractAVIF_EXIF_ImageDimensions) + { + // Test AVIF EXIF extraction - image dimensions + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"AVIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed"); + return; + } + + Assert::IsTrue(result, L"AVIF EXIF extraction should succeed"); + + // Verify image dimensions are present + if (metadata.width.has_value()) + { + Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive"); + } + + if (metadata.height.has_value()) + { + Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive"); + } + } + }; } diff --git a/src/modules/powerrename/unittests/testdata/avif_test.avif b/src/modules/powerrename/unittests/testdata/avif_test.avif new file mode 100644 index 0000000000..49fad74288 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/avif_test.avif differ diff --git a/src/modules/powerrename/unittests/testdata/heif_test.heic b/src/modules/powerrename/unittests/testdata/heif_test.heic new file mode 100644 index 0000000000..824e011b9e Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/heif_test.heic differ diff --git a/src/settings-ui/Settings.UI/Helpers/StoreExtensionHelper.cs b/src/settings-ui/Settings.UI/Helpers/StoreExtensionHelper.cs new file mode 100644 index 0000000000..5dd82c2ce1 --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/StoreExtensionHelper.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using ManagedCommon; +using Windows.Management.Deployment; +using Windows.System; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + /// + /// Helper class to manage installation status and installation command for a Microsoft Store extension. + /// + public class StoreExtensionHelper : INotifyPropertyChanged + { + private readonly string _packageFamilyName; + private readonly string _storeUri; + private readonly string _extensionName; + private bool? _isInstalled; + + public event PropertyChangedEventHandler PropertyChanged; + + public StoreExtensionHelper(string packageFamilyName, string storeUri, string extensionName) + { + _packageFamilyName = packageFamilyName ?? throw new ArgumentNullException(nameof(packageFamilyName)); + _storeUri = storeUri ?? throw new ArgumentNullException(nameof(storeUri)); + _extensionName = extensionName ?? throw new ArgumentNullException(nameof(extensionName)); + InstallCommand = new AsyncCommand(InstallExtensionAsync); + } + + /// + /// Gets a value indicating whether the extension is installed. + /// + public bool IsInstalled + { + get + { + if (!_isInstalled.HasValue) + { + _isInstalled = CheckExtensionInstalled(); + } + + return _isInstalled.Value; + } + } + + /// + /// Gets the command to install the extension. + /// + public ICommand InstallCommand { get; } + + /// + /// Refreshes the installation status of the extension. + /// + public void RefreshStatus() + { + _isInstalled = null; + OnPropertyChanged(nameof(IsInstalled)); + } + + private bool CheckExtensionInstalled() + { + try + { + var packageManager = new PackageManager(); + var packages = packageManager.FindPackagesForUser(string.Empty, _packageFamilyName); + return packages.Any(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to check extension installation status: {_packageFamilyName}", ex); + return false; + } + } + + private async Task InstallExtensionAsync() + { + try + { + await Launcher.LaunchUriAsync(new Uri(_storeUri)); + } + catch (Exception ex) + { + Logger.LogError($"Failed to open {_extensionName} extension store page", ex); + } + } + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml index 81cfe44f8e..c53b06c7c4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml @@ -7,10 +7,18 @@ xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> + + + + + + + + + +