diff --git a/.forgejo/workflows/build-catalog-lite.yml b/.forgejo/workflows/build-catalog-lite.yml index 8bf031a..c916fe9 100644 --- a/.forgejo/workflows/build-catalog-lite.yml +++ b/.forgejo/workflows/build-catalog-lite.yml @@ -8,6 +8,11 @@ on: required: true default: '2026-12-31' type: string + publish_release: + description: Publish Forgejo release when running on a tag ref + required: true + default: false + type: boolean env: DOTNET_VERSION: 10.0.x @@ -23,10 +28,8 @@ jobs: include: - runtime: win-x64 artifact_name: catalog-lite-win-x64 - executable_name: CatalogLite.exe - runtime: linux-x64 artifact_name: catalog-lite-linux-x64 - executable_name: CatalogLite steps: - name: Checkout @@ -59,15 +62,12 @@ jobs: dotnet publish "${{ env.PROJECT_PATH }}" \ -c Release \ -r "${{ matrix.runtime }}" \ - --self-contained false \ + --self-contained true \ --no-restore \ -p:CatalogLiteExpirationDate="${CATALOG_LITE_EXPIRATION_DATE}" \ -p:PublishSingleFile=true \ - -p:SelfContained=false \ - -p:IncludeNativeLibrariesForSelfExtract=true \ -p:PublishTrimmed=false \ -p:PublishReadyToRun=false \ - -p:DebugType=embedded \ -o "${PUBLISH_DIR}" - name: Validate published files @@ -75,23 +75,14 @@ jobs: PUBLISH_DIR: artifacts/publish/${{ matrix.runtime }} run: | set -eu - executable="${PUBLISH_DIR}/${{ matrix.executable_name }}" - if [ ! -f "${executable}" ]; then - echo "Catalog Lite executable was not produced: ${executable}" - exit 1 + if [ "${{ matrix.runtime }}" = "win-x64" ]; then + file_count="$(find "${PUBLISH_DIR}" -maxdepth 1 -type f -iname '*.exe' | wc -l | tr -d ' ')" + else + file_count="$(find "${PUBLISH_DIR}" -maxdepth 1 -type f -name 'CatalogLite' | wc -l | tr -d ' ')" fi - loose_library_count="$(find "${PUBLISH_DIR}" -maxdepth 1 -type f \( -iname '*.dll' -o -name '*.so' -o -name '*.dylib' \) | wc -l | tr -d ' ')" - if [ "${loose_library_count}" -ne 0 ]; then - echo "Catalog Lite publish must not contain loose native or managed libraries:" - find "${PUBLISH_DIR}" -maxdepth 1 -type f \( -iname '*.dll' -o -name '*.so' -o -name '*.dylib' \) -print - exit 1 - fi - - extra_file_count="$(find "${PUBLISH_DIR}" -maxdepth 1 -type f ! -name "${{ matrix.executable_name }}" | wc -l | tr -d ' ')" - if [ "${extra_file_count}" -ne 0 ]; then - echo "Catalog Lite publish artifact must contain only ${{ matrix.executable_name }}:" - find "${PUBLISH_DIR}" -maxdepth 1 -type f ! -name "${{ matrix.executable_name }}" -print + if [ "${file_count}" -eq 0 ]; then + echo "No Catalog Lite executable produced in ${PUBLISH_DIR}" exit 1 fi @@ -99,10 +90,11 @@ jobs: uses: actions/upload-artifact@v3 with: name: ${{ matrix.artifact_name }} - path: artifacts/publish/${{ matrix.runtime }}/${{ matrix.executable_name }} + path: artifacts/publish/${{ matrix.runtime }} if-no-files-found: error release: + if: inputs.publish_release && startsWith(forgejo.ref, 'refs/tags/') needs: build runs-on: docker env: @@ -115,6 +107,12 @@ jobs: name: catalog-lite-win-x64 path: artifacts/release/win-x64 + - name: Download Linux artifact + uses: actions/download-artifact@v3 + with: + name: catalog-lite-linux-x64 + path: artifacts/release/linux-x64 + - name: Validate release token run: | set -eu @@ -127,10 +125,8 @@ jobs: run: | set -eu api_base="${FORGEJO_SERVER_URL%/}/api/v1/repos/${FORGEJO_REPOSITORY}" - tag="catalog-lite-${CATALOG_LITE_EXPIRATION_DATE}" - name="Catalog Lite ${CATALOG_LITE_EXPIRATION_DATE}" - commit="${FORGEJO_SHA:-${GITHUB_SHA:-}}" - create_payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"Catalog Lite\\n\\nScadenza build: %s","draft":false,"prerelease":false}' "${tag}" "${commit}" "${name}" "${CATALOG_LITE_EXPIRATION_DATE}")" + tag="${FORGEJO_REF_NAME}" + create_payload="$(printf '{"tag_name":"%s","name":"%s","body":"Catalog Lite\\n\\nScadenza build: %s","draft":false,"prerelease":false}' "${tag}" "${tag}" "${CATALOG_LITE_EXPIRATION_DATE}")" update_payload="$(printf '{"body":"Catalog Lite\\n\\nScadenza build: %s"}' "${CATALOG_LITE_EXPIRATION_DATE}")" http_code="$(curl -sS -o release.json -w '%{http_code}' \ @@ -173,25 +169,23 @@ jobs: run: | set -eu api_base="${FORGEJO_SERVER_URL%/}/api/v1/repos/${FORGEJO_REPOSITORY}" + short_sha="$(printf '%s' "${FORGEJO_SHA}" | cut -c1-12)" windows_exe="$(find artifacts/release/win-x64 -maxdepth 1 -type f -iname '*.exe' | head -n1)" + linux_exe="$(find artifacts/release/linux-x64 -maxdepth 1 -type f -name 'CatalogLite' | head -n1)" if [ -z "${windows_exe}" ]; then echo "No Windows executable found in downloaded artifact" exit 1 fi - curl -fsS \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - "${api_base}/releases/${RELEASE_ID}/assets" \ - -o assets.json + if [ -z "${linux_exe}" ]; then + echo "No Linux executable found in downloaded artifact" + exit 1 + fi - for asset_id in $(tr '{' '\n' < assets.json | sed -n 's/.*"id":\([0-9][0-9]*\).*"name":"[^"]*".*/\1/p'); do - curl -fsS \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -X DELETE \ - "${api_base}/releases/${RELEASE_ID}/assets/${asset_id}" - done + linux_archive="CatalogLite-linux-x64-${FORGEJO_REF_NAME}-${short_sha}.tar.gz" + tar -czf "${linux_archive}" -C "$(dirname "${linux_exe}")" "$(basename "${linux_exe}")" upload_asset() { asset_path="$1" @@ -203,4 +197,5 @@ jobs: "${api_base}/releases/${RELEASE_ID}/assets?name=${asset_name}" } - upload_asset "${windows_exe}" "CatalogLite-${CATALOG_LITE_EXPIRATION_DATE}.exe" + upload_asset "${windows_exe}" "CatalogLite-win-x64-${FORGEJO_REF_NAME}-${short_sha}.exe" + upload_asset "${linux_archive}" "${linux_archive}" diff --git a/.forgejo/workflows/build-windows-avalonia.yml b/.forgejo/workflows/build-windows-avalonia.yml index c418d92..9fb522e 100644 --- a/.forgejo/workflows/build-windows-avalonia.yml +++ b/.forgejo/workflows/build-windows-avalonia.yml @@ -91,13 +91,6 @@ jobs: exit 1 fi - legacy_renderer_count="$(find "${{ env.PUBLISH_DIR }}" -maxdepth 1 -type f \( -iname 'Microsoft.Windows.Compatibility.dll' -o -iname 'System.Private.Windows.GdiPlus.dll' \) | wc -l | tr -d ' ')" - if [ "${legacy_renderer_count}" -ne 0 ]; then - echo "Legacy GDI compatibility assemblies must not be published:" - find "${{ env.PUBLISH_DIR }}" -maxdepth 1 -type f \( -iname 'Microsoft.Windows.Compatibility.dll' -o -iname 'System.Private.Windows.GdiPlus.dll' \) -print - exit 1 - fi - - name: Upload publish artifact uses: actions/upload-artifact@v3 with: diff --git a/.forgejo/workflows/release-windows-avalonia.yml b/.forgejo/workflows/release-windows-avalonia.yml index ca7c521..32dfd1f 100644 --- a/.forgejo/workflows/release-windows-avalonia.yml +++ b/.forgejo/workflows/release-windows-avalonia.yml @@ -82,13 +82,6 @@ jobs: exit 1 fi - legacy_renderer_count="$(find "${{ env.PUBLISH_DIR }}" -maxdepth 1 -type f \( -iname 'Microsoft.Windows.Compatibility.dll' -o -iname 'System.Private.Windows.GdiPlus.dll' \) | wc -l | tr -d ' ')" - if [ "${legacy_renderer_count}" -ne 0 ]; then - echo "Legacy GDI compatibility assemblies must not be published:" - find "${{ env.PUBLISH_DIR }}" -maxdepth 1 -type f \( -iname 'Microsoft.Windows.Compatibility.dll' -o -iname 'System.Private.Windows.GdiPlus.dll' \) -print - exit 1 - fi - - name: Upload publish artifact uses: actions/upload-artifact@v3 with: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8431a6c..3ce4bbe 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -42,17 +42,21 @@ The main app launches Avalonia directly. Dialog events (`SelectSourceFolderReque 1. User configures paths/settings in the UI (`DataModel.cs` — MVVM ViewModel) 2. `ProcessImagesCommand` triggers `ImageCreationService` 3. `ImageCreationService` processes files in parallel chunks, with configurable concurrency and batch size (GC flush between chunks) -4. Each file is handled by the ImageSharp `IImageCreator` implementation +4. Each file is handled by an `IImageCreator` implementation (GDI+ or ImageSharp) 5. Output: resized/watermarked/overlaid images written to a destination folder hierarchy ### Key Abstractions (MaddoShared) -- **`IImageCreator`** — single async method to process one image; implemented by `ImageCreatorImageSharp` (SixLabors.ImageSharp) +- **`IImageCreator`** — single async method to process one image; two implementations: `ImageCreatorGDI` (System.Drawing) and `ImageCreatorSharp` (SixLabors.ImageSharp) - **`ImageCreationService`** — parallel orchestrator; uses `AsyncEnumerator` with chunking; loads logo once, clones per thread for thread safety - **`ImageState`** — per-file processing context (input path, EXIF orientation, thumbnail sizes, overlays, logo, rotation) -- **`PicSettings`** — 50+ property configuration model (dimensions, fonts, colors, JPEG quality, watermark, logo positioning) +- **`PicSettings`** — 50+ property configuration model (dimensions, fonts, colors, JPEG quality, watermark, logo positioning, `ImageCreatorProvider` selector) - **`FileHelperSharp`** — recursive file enumeration with folder-per-N-files mapping and counter formatting +### Implementation Selection + +`PicSettings.ImageCreatorProvider` switches between `"Sharp"` (SixLabors.ImageSharp) and `"Alternate"` (GDI+) at runtime. + ## Conventions ### C# Style diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 04a76b7..050a0aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -88,13 +88,6 @@ build_windows: # Produce a single-file, ready-to-run publish so downstream jobs only need the EXE. try { & $dotnetExe publish "imagecatalog\ImageCatalog 2.csproj" -c $env:BUILD_CONFIG -r win-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=false -p:PublishReadyToRun=true -o "imagecatalog\bin\$env:BUILD_CONFIG\net10.0-windows\publish" -v minimal - $publishDir = "imagecatalog\bin\$env:BUILD_CONFIG\net10.0-windows\publish" - $legacyRendererFiles = Get-ChildItem $publishDir -File | Where-Object { $_.Name -in @('Microsoft.Windows.Compatibility.dll', 'System.Private.Windows.GdiPlus.dll') } - if ($legacyRendererFiles) { - Write-Host 'Legacy GDI compatibility assemblies must not be published:' - $legacyRendererFiles | ForEach-Object { Write-Host $_.FullName } - exit 1 - } } catch { Write-Host "dotnet publish failed: $_" throw diff --git a/CatalogLite/App.axaml b/CatalogLite/App.axaml index 7b4ec8a..c01ea2f 100644 --- a/CatalogLite/App.axaml +++ b/CatalogLite/App.axaml @@ -36,6 +36,6 @@ - + \ No newline at end of file diff --git a/CatalogLite/CatalogConfigurationLoader.cs b/CatalogLite/CatalogConfigurationLoader.cs index f7652c9..966e1dc 100644 --- a/CatalogLite/CatalogConfigurationLoader.cs +++ b/CatalogLite/CatalogConfigurationLoader.cs @@ -1,8 +1,7 @@ +using System.Drawing; using System.Globalization; using System.Xml.Linq; using MaddoShared; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; namespace CatalogLite; @@ -90,7 +89,7 @@ public sealed class CatalogConfigurationLoader settings.Margine = values.GetInt("TestoMargine", 8); settings.LogoAltezza = values.GetInt("MarchioAltezza", 430); settings.LogoLarghezza = values.GetInt("MarchioLarghezza", 430); - settings.FontColoreRGB = ParseColor(values.GetString("ColoreTestoRGB", "Yellow"), new Rgba32(255, 255, 0, 255)); + settings.FontColoreRGB = ParseColor(values.GetString("ColoreTestoRGB", "Yellow"), Color.Yellow); settings.LogoAggiungi = values.GetBool("MarchioAggiungi"); settings.LogoNomeFile = values.GetString("MarchioFile"); settings.LogoTrasparenza = values.GetInt("MarchioTrasparenza", 100).ToString(CultureInfo.InvariantCulture); @@ -119,6 +118,7 @@ public sealed class CatalogConfigurationLoader settings.FotoRuotaASinistra = false; settings.TempMinText = string.Empty; settings.OverwriteFiles = values.GetBool("GeneraleSovrascriviFile"); + settings.ImageCreatorProvider = "ImageSharp"; } private static ImageCreationService.Options BuildOptions(ConfigurationValues values, string sourcePath, string destinationPath) @@ -150,7 +150,7 @@ public sealed class CatalogConfigurationLoader return string.Equals(values.GetString("MiniatureModalita"), mode, StringComparison.OrdinalIgnoreCase); } - private static Rgba32 ParseColor(string value, Rgba32 fallback) + private static Color ParseColor(string value, Color fallback) { if (string.IsNullOrWhiteSpace(value)) { @@ -162,19 +162,14 @@ public sealed class CatalogConfigurationLoader { if (normalized.StartsWith('#') && normalized.Length == 7) { - return new Rgba32( - Convert.ToByte(normalized[1..3], 16), - Convert.ToByte(normalized[3..5], 16), - Convert.ToByte(normalized[5..7], 16), - 255); + return Color.FromArgb( + Convert.ToInt32(normalized[1..3], 16), + Convert.ToInt32(normalized[3..5], 16), + Convert.ToInt32(normalized[5..7], 16)); } - if (normalized.Length == 6 && normalized.All(Uri.IsHexDigit)) - { - normalized = "#" + normalized; - } - - return Color.Parse(normalized).ToPixel(); + var named = Color.FromName(normalized); + return named.IsKnownColor || named.IsNamedColor ? named : fallback; } catch { diff --git a/CatalogLite/CatalogLite.csproj b/CatalogLite/CatalogLite.csproj index 54121b7..97280a9 100644 --- a/CatalogLite/CatalogLite.csproj +++ b/CatalogLite/CatalogLite.csproj @@ -8,13 +8,6 @@ CatalogLite false 2026-12-31 - true - false - true - true - false - false - embedded @@ -33,7 +26,7 @@ - + @@ -52,25 +45,4 @@ Overwrite="true" Lines="using System.Reflection%3B [assembly: AssemblyMetadata("CatalogLiteGeneratedExpirationDate", "$(CatalogLiteExpirationDate)")] [assembly: AssemblyMetadata("CatalogLiteExpirationDate", "$(CatalogLiteExpirationDate)")] namespace CatalogLite%3B internal static class BuildExpiration { public const string ExpirationDate = "$(CatalogLiteExpirationDate)"%3B }" /> - - - - <_CatalogLiteForbiddenReference Include="@(ReferencePath)" Condition="$([System.String]::Copy('%(FileName)').StartsWith('AIFotoONLUS')) Or '%(FileName)' == 'Catalog.Communication' Or '%(FileName)' == 'Microsoft.Windows.Compatibility' Or '%(FileName)' == 'System.Drawing.Common' Or '%(FileName)' == 'System.Private.Windows.GdiPlus' Or '%(FileName)' == 'System.Windows.Extensions'" /> - - - - - - - <_CatalogLiteSidecarFile Include="$(PublishDir)**\*.dll.config;$(PublishDir)**\*.pdb" /> - - - - - - - <_CatalogLiteLooseNativeFile Include="$(PublishDir)**\*.dll;$(PublishDir)**\*.so;$(PublishDir)**\*.dylib" /> - - - \ No newline at end of file diff --git a/CatalogLite/LiteCatalogViewModel.cs b/CatalogLite/LiteCatalogViewModel.cs index 0a4c8c6..56a87be 100644 --- a/CatalogLite/LiteCatalogViewModel.cs +++ b/CatalogLite/LiteCatalogViewModel.cs @@ -250,6 +250,7 @@ public sealed class LiteCatalogViewModel : ViewModelBase _picSettings.DirectorySorgente = SourcePath; _picSettings.DirectoryDestinazione = DestinationPath; _picSettings.DestDir = new DirectoryInfo(DestinationPath); + _picSettings.ImageCreatorProvider = "ImageSharp"; IsProcessing = true; ResetProgress("Analisi immagini..."); diff --git a/MaddoShared.Benchmarks/ChunkSizeBenchmarks.cs b/MaddoShared.Benchmarks/ChunkSizeBenchmarks.cs index 390799d..710b5e6 100644 --- a/MaddoShared.Benchmarks/ChunkSizeBenchmarks.cs +++ b/MaddoShared.Benchmarks/ChunkSizeBenchmarks.cs @@ -50,7 +50,7 @@ public class ChunkSizeBenchmarks }); var logger = loggerFactory.CreateLogger(); - var imageCreatorLogger = loggerFactory.CreateLogger(); + var imageCreatorLogger = loggerFactory.CreateLogger(); _picSettings = new PicSettings { @@ -75,7 +75,7 @@ public class ChunkSizeBenchmarks Trasparenza = 100 }; - var imageCreatorService = new ImageCreatorImageSharp(_picSettings, imageCreatorLogger); + var imageCreatorService = new ImageCreatorGDI(_picSettings, imageCreatorLogger); _imageCreationStuff = new ImageCreationService(logger, _picSettings, imageCreatorService); } diff --git a/MaddoShared.Benchmarks/Helpers/TestImageGenerator.cs b/MaddoShared.Benchmarks/Helpers/TestImageGenerator.cs index 5630655..34f44b5 100644 --- a/MaddoShared.Benchmarks/Helpers/TestImageGenerator.cs +++ b/MaddoShared.Benchmarks/Helpers/TestImageGenerator.cs @@ -1,32 +1,40 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Drawing.Imaging; using System.IO; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Jpeg; -using SixLabors.ImageSharp.PixelFormats; namespace MaddoShared.Benchmarks.Helpers; /// -/// Helper class to generate test images for benchmarking. +/// Helper class to generate test images for benchmarking /// +[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] public static class TestImageGenerator { + /// + /// Generates a set of test JPEG images in the specified directory + /// + /// Directory where images will be created + /// Number of images to generate + /// Width of each image + /// Height of each image + /// Whether to create images in subfolders public static void GenerateTestImages( - string outputDirectory, - int imageCount, - int width = 4000, + string outputDirectory, + int imageCount, + int width = 4000, int height = 3000, bool includeSubfolders = false) { Directory.CreateDirectory(outputDirectory); - var random = new Random(42); - var encoder = new JpegEncoder { Quality = 85 }; + var random = new Random(42); // Fixed seed for reproducibility - for (var i = 0; i < imageCount; i++) + for (int i = 0; i < imageCount; i++) { var targetDir = outputDirectory; - + if (includeSubfolders && i % 10 == 0) { targetDir = Path.Combine(outputDirectory, $"Subfolder_{i / 10}"); @@ -34,17 +42,48 @@ public static class TestImageGenerator } var filePath = Path.Combine(targetDir, $"test_image_{i:D5}.jpg"); + + // Skip if already exists if (File.Exists(filePath)) - { continue; + + using var bitmap = new Bitmap(width, height); + using var graphics = Graphics.FromImage(bitmap); + + // Fill with a random color background + var bgColor = Color.FromArgb(random.Next(256), random.Next(256), random.Next(256)); + graphics.Clear(bgColor); + + // Draw some random shapes to make it more realistic + for (int j = 0; j < 20; j++) + { + var color = Color.FromArgb(random.Next(256), random.Next(256), random.Next(256)); + var brush = new SolidBrush(color); + var x = random.Next(width); + var y = random.Next(height); + var w = random.Next(200, 800); + var h = random.Next(200, 800); + graphics.FillEllipse(brush, x, y, w, h); } - using var image = new Image(width, height, RandomColor(random)); - AddBenchmarkTexture(image, random); - image.Save(filePath, encoder); + // Add some text + using var font = new Font("Arial", 48, FontStyle.Bold); + var text = $"Test Image {i}"; + var textBrush = new SolidBrush(Color.White); + graphics.DrawString(text, font, textBrush, new PointF(100, 100)); + + // Save as JPEG with standard quality + var encoder = GetEncoder(ImageFormat.Jpeg); + var encoderParameters = new EncoderParameters(1); + encoderParameters.Param[0] = new EncoderParameter(Encoder.Quality, 85L); + + bitmap.Save(filePath, encoder, encoderParameters); } } + /// + /// Cleans up generated test images + /// public static void CleanupTestImages(string directory) { if (Directory.Exists(directory)) @@ -53,38 +92,16 @@ public static class TestImageGenerator } } - private static void AddBenchmarkTexture(Image image, Random random) + private static ImageCodecInfo GetEncoder(ImageFormat format) { - image.ProcessPixelRows(accessor => + var codecs = ImageCodecInfo.GetImageEncoders(); + foreach (var codec in codecs) { - for (var shape = 0; shape < 20; shape++) + if (codec.FormatID == format.Guid) { - var color = RandomColor(random); - var startX = random.Next(image.Width); - var startY = random.Next(image.Height); - var width = random.Next(200, Math.Min(800, image.Width) + 1); - var height = random.Next(200, Math.Min(800, image.Height) + 1); - var endX = Math.Min(accessor.Width, startX + width); - var endY = Math.Min(accessor.Height, startY + height); - - for (var y = startY; y < endY; y++) - { - var row = accessor.GetRowSpan(y); - for (var x = startX; x < endX; x++) - { - row[x] = color; - } - } + return codec; } - }); - } - - private static Rgba32 RandomColor(Random random) - { - return new Rgba32( - (byte)random.Next(256), - (byte)random.Next(256), - (byte)random.Next(256), - 255); + } + return null; } } diff --git a/MaddoShared.Benchmarks/ImageProcessingBenchmarks.cs b/MaddoShared.Benchmarks/ImageProcessingBenchmarks.cs index 49f86d0..572c436 100644 --- a/MaddoShared.Benchmarks/ImageProcessingBenchmarks.cs +++ b/MaddoShared.Benchmarks/ImageProcessingBenchmarks.cs @@ -25,7 +25,7 @@ public class ImageProcessingBenchmarks private ImageCreationService _imageCreationStuff; private PicSettings _picSettings; private ILogger _logger; - private ILogger _imageCreatorLogger; + private ILogger _imageCreatorLogger; [Params(10, 50, 100)] public int ImageCount { get; set; } @@ -55,7 +55,7 @@ public class ImageProcessingBenchmarks }); _logger = loggerFactory.CreateLogger(); - _imageCreatorLogger = loggerFactory.CreateLogger(); + _imageCreatorLogger = loggerFactory.CreateLogger(); // Setup PicSettings with default values _picSettings = new PicSettings @@ -81,7 +81,7 @@ public class ImageProcessingBenchmarks Trasparenza = 100 }; - var imageCreatorService = new ImageCreatorImageSharp(_picSettings, _imageCreatorLogger); + var imageCreatorService = new ImageCreatorGDI(_picSettings, _imageCreatorLogger); _imageCreationStuff = new ImageCreationService(_logger, _picSettings, imageCreatorService); } diff --git a/MaddoShared.Benchmarks/ImageSizeBenchmarks.cs b/MaddoShared.Benchmarks/ImageSizeBenchmarks.cs index b190f8d..4d71e9f 100644 --- a/MaddoShared.Benchmarks/ImageSizeBenchmarks.cs +++ b/MaddoShared.Benchmarks/ImageSizeBenchmarks.cs @@ -59,7 +59,7 @@ public class ImageSizeBenchmarks }); var logger = loggerFactory.CreateLogger(); - var imageCreatorLogger = loggerFactory.CreateLogger(); + var imageCreatorLogger = loggerFactory.CreateLogger(); _picSettings = new PicSettings { @@ -84,7 +84,7 @@ public class ImageSizeBenchmarks Trasparenza = 100 }; - var imageCreatorService = new ImageCreatorImageSharp(_picSettings, imageCreatorLogger); + var imageCreatorService = new ImageCreatorGDI(_picSettings, imageCreatorLogger); _imageCreationStuff = new ImageCreationService(logger, _picSettings, imageCreatorService); } diff --git a/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj b/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj index f68e319..56b8684 100644 --- a/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj +++ b/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj @@ -2,8 +2,10 @@ Exe - net10.0 + net10.0-windows x64 + true + true diff --git a/MaddoShared.Benchmarks/StressTestBenchmark.cs b/MaddoShared.Benchmarks/StressTestBenchmark.cs index 32d1723..207b434 100644 --- a/MaddoShared.Benchmarks/StressTestBenchmark.cs +++ b/MaddoShared.Benchmarks/StressTestBenchmark.cs @@ -40,7 +40,7 @@ public class StressTestBenchmark Console.WriteLine($"[STRESS TEST] Generating {ImageCount} test images..."); Console.WriteLine("This may take several minutes depending on your hardware."); - + // Use smaller images for stress test to save space and time TestImageGenerator.GenerateTestImages(_sourceDirectory, ImageCount, width: 1920, height: 1080); @@ -50,7 +50,7 @@ public class StressTestBenchmark }); var logger = loggerFactory.CreateLogger(); - var imageCreatorLogger = loggerFactory.CreateLogger(); + var imageCreatorLogger = loggerFactory.CreateLogger(); _picSettings = new PicSettings { @@ -75,7 +75,7 @@ public class StressTestBenchmark Trasparenza = 100 }; - var imageCreatorService = new ImageCreatorImageSharp(_picSettings, imageCreatorLogger); + var imageCreatorService = new ImageCreatorGDI(_picSettings, imageCreatorLogger); _imageCreationStuff = new ImageCreationService(logger, _picSettings, imageCreatorService); Console.WriteLine($"[STRESS TEST] Setup complete. Ready to process {ImageCount} images."); @@ -130,12 +130,12 @@ public class StressTestBenchmark var results = new ConcurrentBag(); var startTime = DateTime.Now; - + await _imageCreationStuff.ProcessImagesParallel(options, results, null, CancellationToken.None); - + var duration = DateTime.Now - startTime; var throughput = ImageCount / duration.TotalSeconds; - + Console.WriteLine($"[STRESS TEST] Processed {results.Count}/{ImageCount} images in {duration.TotalSeconds:F2}s"); Console.WriteLine($"[STRESS TEST] Throughput: {throughput:F2} images/second"); } @@ -160,12 +160,12 @@ public class StressTestBenchmark var results = new ConcurrentBag(); var startTime = DateTime.Now; - + await _imageCreationStuff.ProcessImagesParallel(options, results, null, CancellationToken.None); - + var duration = DateTime.Now - startTime; var throughput = ImageCount / duration.TotalSeconds; - + Console.WriteLine($"[STRESS TEST] Processed {results.Count}/{ImageCount} images in {duration.TotalSeconds:F2}s"); Console.WriteLine($"[STRESS TEST] Throughput: {throughput:F2} images/second"); } diff --git a/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj b/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj index 9d7d59a..6a7b6fe 100644 --- a/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj +++ b/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj @@ -15,9 +15,9 @@ - - - + + + diff --git a/MaddoShared.Tests/ImageCreatorSharpTests.cs b/MaddoShared.Tests/ImageCreatorSharpTests.cs index 546a7de..ae10edc 100644 --- a/MaddoShared.Tests/ImageCreatorSharpTests.cs +++ b/MaddoShared.Tests/ImageCreatorSharpTests.cs @@ -1,63 +1,237 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; using System.Reflection; -using MaddoShared; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Extensions.Logging; +using NSubstitute; using Shouldly; -using SixLabors.ImageSharp; +using MaddoShared; -namespace MaddoShared.Tests; - -[TestClass] -public class ImageCreatorSharpTests +namespace MaddoShared.Tests { - [TestMethod] - public void CalculateThumbnailSize_Larghezza_UsesWidthScaling() + [TestClass] + public class ImageCreatorSharpTests { - var size = CalculateThumbnailSize(400, 200, 200, "Larghezza"); - - size.Width.ShouldBe(200); - size.Height.ShouldBe(100); - } - - [TestMethod] - public void CalculateThumbnailSize_Altezza_UsesHeightScaling() - { - var size = CalculateThumbnailSize(200, 400, 200, "Altezza"); - - size.Width.ShouldBe(100); - size.Height.ShouldBe(200); - } - - [TestMethod] - public void FindBestFontSize_ConstrainsTextToBounds() - { - const string text = "A very long text that will not fit at the requested size"; - var method = typeof(ImageCreatorImageSharp).GetMethod( - "FindBestFontSize", - BindingFlags.NonPublic | BindingFlags.Static); - - method.ShouldNotBeNull(); - - var size = (float)method.Invoke(null, new object[] + private ImageCreatorGDI CreateService(Action customize = null) { - text, - "Arial", - 40, - 50f, - 20f, - 6 - })!; + var settings = new PicSettings + { + DimStandard = 20, + DimStandardMiniatura = 10, + LarghezzaSmall = 100, + AltezzaSmall = 100, + LarghezzaBig = 800, + AltezzaBig = 600, + Trasparenza = 50, + IlFont = "Arial", + Grassetto = false, + Posizione = "CENTRO", + Allineamento = "CENTRO", + Margine = 10, + MargVert = 10, + TestoMin = false, + AggNumTempMin = false + }; - size.ShouldBeInRange(6f, 40f); - (size * text.Length * 0.6f <= 50f || size <= 6f).ShouldBeTrue(); - } + customize?.Invoke(settings); - private static Size CalculateThumbnailSize(int width, int height, int maxPixel, string sizeMode) - { - var method = typeof(ImageCreatorImageSharp).GetMethod( - "CalculateThumbnailSize", - BindingFlags.NonPublic | BindingFlags.Static); + var logger = Substitute.For>(); + return new ImageCreatorGDI(settings, logger); + } - method.ShouldNotBeNull(); - return (Size)method.Invoke(null, new object[] { width, height, maxPixel, sizeMode })!; + [TestMethod] + public void CalculateThumbnailSize_Larghezza_UsesWidthScaling() + { + var svc = CreateService(); + var mi = svc.GetType().GetMethod("CalculateThumbnailSize", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + var size = (Size)mi.Invoke(svc, new object[] { 400, 200, 200, "Larghezza" }); + + size.Width.ShouldBe(200); + size.Height.ShouldBe(100); + } + + [TestMethod] + public void CalculateThumbnailSize_Altezza_UsesHeightScaling() + { + var svc = CreateService(); + var mi = svc.GetType().GetMethod("CalculateThumbnailSize", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + var size = (Size)mi.Invoke(svc, new object[] { 200, 400, 200, "Altezza" }); + + size.Width.ShouldBe(100); + size.Height.ShouldBe(200); + } + + [TestMethod] + public void IsSameDirectory_IsCaseInsensitive() + { + var svc = CreateService(); + var mi = svc.GetType().GetMethod("IsSameDirectory", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + bool same = (bool)mi.Invoke(svc, new object[] { @"C:\Temp", @"c:\temp" }); + same.ShouldBeTrue(); + + bool notSame = (bool)mi.Invoke(svc, new object[] { @"C:\TempA", @"c:\temp" }); + notSame.ShouldBeFalse(); + } + + [TestMethod] + public void UpdateFilenameWithCode_InsertsCodeBeforeExtension() + { + var svc = CreateService(s => s.Codice = "_X"); + var mi = svc.GetType().GetMethod("UpdateFilenameWithCode", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + var state = new ImageState { NomeFileSmall = "photo123.jpg" }; + mi.Invoke(svc, new object[] { state }); + + state.NomeFileSmall.ShouldBe("photo123_X.jpg"); + } + + [DataTestMethod] + [DataRow("SINISTRA")] + [DataRow("CENTRO")] + [DataRow("DESTRA")] + public void CalculateHorizontalAlignment_RespectsAlignment(string alignment) + { + var svc = CreateService(s => { s.Allineamento = alignment; s.Margine = 20; }); + + var mi = svc.GetType().GetMethod("CalculateHorizontalAlignment", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + var center = (float)mi.Invoke(svc, new object[] { 800, 100f }); + + if (alignment == "SINISTRA") + center.ShouldBeInRange(0f, 400f); + if (alignment == "DESTRA") + center.ShouldBeInRange(400f, 800f); + if (alignment == "CENTRO") + center.ShouldBe(800 / 2f, 0.0001f); + } + + [TestMethod] + public void SetVerticalPosition_AltoAndBasso_SetExpectedValues() + { + var svc = CreateService(s => s.Posizione = "ALTO"); + var mi = svc.GetType().GetMethod("SetVerticalPosition", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + var state = new ImageState(); + + // ALTO + mi.Invoke(svc, new object[] { 500, 20f, state }); + state.YPosFromBottom1.ShouldBe(10f); + state.YPosFromBottom4.ShouldBe(10f); + + // BASSO + state = new ImageState(); + svc = CreateService(s => { s.Posizione = "BASSO"; s.Margine = 10; s.MargVert = 5; }); + mi = svc.GetType().GetMethod("SetVerticalPosition", BindingFlags.NonPublic | BindingFlags.Instance); + mi.Invoke(svc, new object[] { 200, 20f, state }); + + var expected1 = (float)(200 - 20 - (200 * 10 / 100.0)); + var expected4 = (float)(200 - 20 - (200 * 5 / 100.0)); + state.YPosFromBottom1.ShouldBe(expected1, 0.001f); + state.YPosFromBottom4.ShouldBe(expected4, 0.001f); + } + + [TestMethod] + public void FormatTimeText_WithAndWithoutFileName_ProducesExpectedStrings() + { + var svc = CreateService(); + var mi = svc.GetType().GetMethod("FormatTimeText", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + var state = new ImageState + { + NomeFileBig = "file.jpg", + TestoOrario = "T:", + DataPartenzaI = new DateTime(2024, 01, 01, 12, 0, 0), + DataFoto = new DateTime(2024, 01, 01, 11, 59, 0) + }; + var withoutName = (string)mi.Invoke(svc, new object[] { state, false }); + withoutName.ShouldStartWith(Environment.NewLine); + withoutName.ShouldContain("T:"); + + var withName = (string)mi.Invoke(svc, new object[] { state, true }); + withName.ShouldContain("file.jpg"); + withName.ShouldContain("T:"); + withName.ShouldContain(Environment.NewLine); + } + + [TestMethod] + public void PrepareSignatureText_SetsSmallSignature_AccordingFlags() + { + var svc = CreateService(); + var miPrep = svc.GetType().GetMethod("PrepareSignatureText", BindingFlags.NonPublic | BindingFlags.Instance); + miPrep.ShouldNotBeNull(); + + var state = new ImageState { NomeFileBig = "bigname.jpg" }; + + svc = CreateService(s => s.TestoMin = true); + miPrep.Invoke(svc, new object[] { state }); + state.TestoFirmaPiccola.ShouldBe("bigname.jpg"); + + state.TestoFirmaPiccola = ""; + svc = CreateService(s => { s.TestoMin = false; s.AggNumTempMin = true; }); + miPrep.Invoke(svc, new object[] { state }); + state.TestoFirmaPiccola.ShouldBe("bigname.jpg "); + } + + [TestMethod] + public void ShouldRenderText_ReturnsCorrectFlag() + { + var svc = CreateService(s => { s.UsaOrarioMiniatura = false; s.TestoMin = false; s.AggTempoGaraMin = false; s.AggNumTempMin = false; }); + var mi = svc.GetType().GetMethod("ShouldRenderText", BindingFlags.NonPublic | BindingFlags.Instance); + mi.ShouldNotBeNull(); + + var res = (bool)mi.Invoke(svc, Array.Empty()); + res.ShouldBeFalse(); + + svc = CreateService(s => s.TestoMin = true); + mi = svc.GetType().GetMethod("ShouldRenderText", BindingFlags.NonPublic | BindingFlags.Instance); + res = (bool)mi.Invoke(svc, Array.Empty()); + res.ShouldBeTrue(); + } + + [TestMethod] + public void FindBestFontSize_And_AdjustFontToFitWidth_ModifySizes() + { + var svc = CreateService(s => { s.IlFont = "Arial"; s.DimStandardMiniatura = 30; }); + + using var bmp = new Bitmap(400, 100); + using var g = Graphics.FromImage(bmp); + + var miFind = svc.GetType().GetMethod("FindBestFontSize", BindingFlags.NonPublic | BindingFlags.Instance); + miFind.ShouldNotBeNull(); + + int best = (int)miFind.Invoke(svc, new object[] { g, "A very long text that won't fit", "Arial", 40, false, 50, 5 }); + best.ShouldBeInRange(5, 40); + + // The helper AdjustFontToFitWidth was in an earlier refactor; replicate its logic here + var imageState = new ImageState { DimensioneStandardMiniatura = 30, TestoFirmaPiccola = "A very long test string" }; + var initialFont = new Font("Arial", imageState.DimensioneStandardMiniatura); + var textSize = g.MeasureString(imageState.TestoFirmaPiccola, initialFont); + + int tempFontSize = imageState.DimensioneStandardMiniatura; + while ((textSize.Width > 50) && tempFontSize > 5) + { + tempFontSize = (tempFontSize > 20) ? tempFontSize - 5 : tempFontSize - 1; + using var tempFont = new Font("Arial", tempFontSize); + textSize = g.MeasureString(imageState.TestoFirmaPiccola, tempFont); + } + + var updatedSize = textSize; + imageState.DimensioneStandardMiniatura = tempFontSize; + + imageState.DimensioneStandardMiniatura.ShouldBeLessThanOrEqualTo(30); + (updatedSize.Width <= 50 || imageState.DimensioneStandardMiniatura <= 5).ShouldBeTrue(); + } } } diff --git a/MaddoShared.Tests/MaddoShared.Tests.csproj b/MaddoShared.Tests/MaddoShared.Tests.csproj index 0060244..2c8d370 100644 --- a/MaddoShared.Tests/MaddoShared.Tests.csproj +++ b/MaddoShared.Tests/MaddoShared.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/MaddoShared/ImageCreatorGDI.cs b/MaddoShared/ImageCreatorGDI.cs new file mode 100644 index 0000000..15fd6f6 --- /dev/null +++ b/MaddoShared/ImageCreatorGDI.cs @@ -0,0 +1,866 @@ +#if WINDOWS +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; + +// Imports System.Threading + +namespace MaddoShared; + +[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] +public class ImageCreatorGDI(PicSettings picSettings, ILogger logger) : IImageCreator +{ + public async Task CreateImageAsync(ImageState imgState, byte[]? logoData) + { + try + { + await Task.Run(() => + { + logger.LogInformation("File: {FileInfo} Dest: {DirectoryInfo}", imgState.WorkFile, imgState.DestDir); + PrepareVariables(imgState); + ExtractExif(imgState); + + using var g = Image.FromFile(imgState.WorkFile.FullName); + + // Set extra text + SetExtraText(g, imgState); + + // Rotate image according to EXIF + ApplyRotation(g, imgState); + + // Force jpeg if option selected + var thisFormat = g.RawFormat; + if (picSettings.UsaForzaJpg) + thisFormat = ImageFormat.Jpeg; + + PrepareThumbnailSize(g, imgState); + + using var imgOutputBig = new Bitmap(g, imgState.ThumbSizeBig.Width, imgState.ThumbSizeBig.Height); + + imgOutputBig.SetResolution(g.HorizontalResolution, g.VerticalResolution); + + // Create thumbnails + CreateThumbnails(g, imgState, imgOutputBig, thisFormat); + + AddText(g, imgState, imgOutputBig); + + AddLogo(imgOutputBig, logoData); + + SavePhoto(imgOutputBig, imgState, thisFormat); + }).ConfigureAwait(false); + } + + catch (Exception ex) + { + var e = ex.Demystify(); + logger.LogError(e, "Error in processing photo {WorkFileName}", imgState.WorkFile.Name); + } + } + + private void ExtractExif(ImageState imgState) + { + using var img = SixLabors.ImageSharp.Image.Load(imgState.WorkFile.FullName); + imgState.Orientation = Orientations.TopLeft; + + IExifValue rotation = null; + + var exifProfile = img.Metadata?.ExifProfile; + var found = exifProfile != null && exifProfile.TryGetValue(ExifTag.Orientation, out rotation); + + if (found) + { + var intOrientation = rotation.Value.ToInt32(); + imgState.Orientation = (Orientations)intOrientation; + } + + IExifValue date = null; + var creationFound = exifProfile != null && exifProfile.TryGetValue(ExifTag.DateTimeOriginal, out date); + if (creationFound) + { + var succ = DateTime.TryParseExact(date.Value, "yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var crDate); + if (succ) + { + imgState.CreationDate = crDate; + } + else + { + imgState.CreationDate = null; + } + } + else + { + imgState.CreationDate = null; + } + } + + private void ApplyRotation(Image g, ImageState imgState) + { + imgState.FotoRuotaADestra = false; + imgState.FotoRuotaASinistra = false; + + if (picSettings.UsaRotazioneAutomatica && g.PropertyIdList.Length > 0) + { + switch (imgState.Orientation) + { + case Orientations.BottomLeft: + case Orientations.BottomRight: + case Orientations.LeftTop: + case Orientations.LftBottom: + imgState.FotoRuotaASinistra = true; + break; + case Orientations.RightBottom: + case Orientations.RightTop: + case Orientations.TopLeft: + case Orientations.TopRight: + break; + } + } + + if (imgState.FotoRuotaASinistra) + g.RotateFlip(RotateFlipType.Rotate270FlipNone); + if (imgState.FotoRuotaADestra) + g.RotateFlip(RotateFlipType.Rotate90FlipNone); + } + + /// + /// ''' Aggiunge Orario, tempo gara e altri + /// ''' + /// ''' Image + /// + /// ''' + private void SetExtraText(Image g, ImageState imgState) + { + if (picSettings.UsaOrarioTestoApplicare || picSettings.UsaTempoGaraTestoApplicare || + picSettings.UsaOrarioMiniatura || picSettings.TestoMin || picSettings.AggTempoGaraMin || + picSettings.AggNumTempMin) + { + if (g.PropertyIdList.Length <= 0) return; + imgState.DataFoto = imgState.CreationDate ?? DateTime.Now; + imgState.TestoFirma = picSettings.TestoFirmaStart; + imgState.TestoFirmaV = picSettings.TestoFirmaStartV; + + if (imgState.DataFoto.Year == 1) return; + imgState.TestoFirmaPiccola = imgState.DataFoto.ToShortTimeString(); + if (picSettings.UsaOrarioTestoApplicare) + { + imgState.TestoFirma += + $" {imgState.DataFoto.ToShortDateString()} {imgState.DataFoto.ToLongTimeString()}"; + imgState.TestoFirmaV += + $" {imgState.DataFoto.ToShortDateString()} {imgState.DataFoto.ToLongTimeString()}"; + } + + if (!picSettings.UsaTempoGaraTestoApplicare) return; + var diff = imgState.DataFoto - imgState.DataPartenzaI; + imgState.TestoFirma += $" {imgState.TestoOrario}{diff.Hours:00}:{diff.Minutes:00}:{diff.Seconds:00}"; + imgState.TestoFirmaV += $" {imgState.TestoOrario}{diff.Hours:00}:{diff.Minutes:00}:{diff.Seconds:00}"; + } + else + { + imgState.TestoFirma = picSettings.TestoFirmaStart; + imgState.TestoFirmaV = picSettings.TestoFirmaStartV; + } + } + + /// + /// ''' Prepara diverse variabili azzerandole, elaborandole e prendendole dalle impostazioni + /// ''' + /// ''' + private void PrepareVariables(ImageState imgState) + { + imgState.AlphaScelta = System.Convert.ToInt32((255 * (100 - picSettings.Trasparenza) / (double)100)); + imgState.TestoFirma = ""; + imgState.TestoFirmaV = ""; + imgState.DataPartenzaI = picSettings.DataPartenza; + imgState.TestoOrario = picSettings.TestoOrario; + if (imgState.TestoOrario.Length > 0) + imgState.TestoOrario += " "; + imgState.TestoFirmaPiccola = ""; + imgState.ThumbSizeSmall = new Size(); + imgState.ThumbSizeBig = new Size(); + imgState.NomeFileSmall = ""; + imgState.NomeFileBig2 = ""; + imgState.NomeFileBig = ""; + imgState.DimensioneStandard = picSettings.DimStandard; + imgState.DimensioneStandardMiniatura = picSettings.DimStandardMiniatura; + // nomeFileSmall = Suffisso & NomeFileChild + // nomeFileBig = NomeFileChild + imgState.NomeFileSmall = picSettings.Suffisso + imgState.WorkFile.Name; + imgState.NomeFileBig = imgState.WorkFile.Name; + // Sanitize file names to avoid invalid characters causing IO errors + imgState.NomeFileSmall = SanitizeFileName(imgState.NomeFileSmall); + imgState.NomeFileBig = SanitizeFileName(imgState.NomeFileBig); + } + + private static string SanitizeFileName(string fileName) + { + if (string.IsNullOrEmpty(fileName)) return fileName; + var invalid = Path.GetInvalidFileNameChars(); + var sb = new System.Text.StringBuilder(fileName.Length); + foreach (var ch in fileName) + { + sb.Append(Array.IndexOf(invalid, ch) >= 0 ? '_' : ch); + } + return sb.ToString(); + } + + private void PrepareThumbnailSize(Image g, ImageState imgState) + { + if (g.Width > g.Height) + { + imgState.ThumbSizeSmall = CalculateThumbnailSize(g.Width, g.Height, picSettings.LarghezzaSmall, "Larghezza"); + var sizeOrig = new Size(g.Width, g.Height); + imgState.ThumbSizeBig = sizeOrig; + } + else + { + imgState.ThumbSizeSmall = CalculateThumbnailSize(g.Width, g.Height, picSettings.AltezzaSmall, "Altezza"); + var sizeOrig = new Size(g.Width, g.Height); + imgState.ThumbSizeBig = sizeOrig; + } + } + + private void CreateThumbnails(Image sourceImage, ImageState imgState, Bitmap imgOutputBig, ImageFormat format) + { + // Only skip thumbnail generation when the global "create thumbnails" flag is false. + // Whether thumbnails include text is handled by ShouldRenderText/CreateThumbnailWithText + if (!picSettings.CreaMiniature) + return; + + PrepareSignatureText(imgState); + + if (IsSameDirectory(picSettings.DirectorySorgente, picSettings.DirectoryDestinazione)) + UpdateFilenameWithCode(imgState); + + if (ShouldRenderText()) + CreateThumbnailWithText(sourceImage, imgState, imgOutputBig, format); + else + CreateSimpleThumbnail(sourceImage, imgState, format); + } + + private void PrepareSignatureText(ImageState imgState) + { + if (picSettings.TestoMin) + imgState.TestoFirmaPiccola = imgState.NomeFileBig; + else if (picSettings.AggNumTempMin) + imgState.TestoFirmaPiccola = imgState.NomeFileBig + " "; + } + + private bool IsSameDirectory(string dir1, string dir2) => + string.Equals(dir1, dir2, StringComparison.OrdinalIgnoreCase); + + private void UpdateFilenameWithCode(ImageState imgState) + { + var name = imgState.NomeFileSmall; + imgState.NomeFileSmall = name[..^4] + picSettings.Codice + name[^4..]; + } + + private bool ShouldRenderText() => + picSettings.UsaOrarioMiniatura || picSettings.TestoMin || picSettings.AggTempoGaraMin || + picSettings.AggNumTempMin; + + private void CreateSimpleThumbnail(Image image, ImageState imgState, ImageFormat format) + { + using var thumbnail = new Bitmap(image, imgState.ThumbSizeSmall.Width, imgState.ThumbSizeSmall.Height); + thumbnail.Save(Path.Combine(imgState.DestDir.FullName, imgState.NomeFileSmall), format); + } + + private void CreateThumbnailWithText(Image image, ImageState imgState, Bitmap sourceBitmap, ImageFormat format) + { + if (imgState.TestoFirmaPiccola.Length == 0) + { + CreateSimpleThumbnail(image, imgState, format); + return; + } + + using var imgOutputSmall = (Bitmap)sourceBitmap.Clone(); + using var graphics = Graphics.FromImage(imgOutputSmall); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + // Use the user's configured font size directly + using var font1 = CreateFont(picSettings.IlFont, imgState.DimensioneStandardMiniatura, picSettings.Grassetto); + var textSize = graphics.MeasureString(imgState.TestoFirmaPiccola, font1); + + // Adjust font if it's too large for the image dimensions + // Keep text height under 15% of image height to ensure proper spacing when resized + // This leaves room for margins and prevents clipping + int tempFontSize = imgState.DimensioneStandardMiniatura; + float maxTextHeight = image.Height * 0.15f; + + while ((textSize.Width > image.Width * 0.95f || textSize.Height > maxTextHeight) && tempFontSize > 5) + { + tempFontSize = (tempFontSize > 20) ? tempFontSize - 5 : tempFontSize - 1; + using var tempFont = CreateFont(picSettings.IlFont, tempFontSize, picSettings.Grassetto); + textSize = graphics.MeasureString(imgState.TestoFirmaPiccola, tempFont); + } + + // Re-measure text with the final font size for accurate positioning + using var finalFont = CreateFont(picSettings.IlFont, tempFontSize, picSettings.Grassetto); + var finalTextSize = graphics.MeasureString(imgState.TestoFirmaPiccola, finalFont); + + SetVerticalPosition(image.Height, finalTextSize.Height, imgState); + + float xCenter = CalculateHorizontalAlignment(image.Width, finalTextSize.Width); + using var stringFormat = new StringFormat(); + stringFormat.Alignment = StringAlignment.Center; + + using var shadowBrush = new SolidBrush(Color.FromArgb(imgState.AlphaScelta, 0, 0, 0)); + using var textBrush = new SolidBrush(Color.FromArgb(imgState.AlphaScelta, picSettings.FontColoreRGB)); + + DrawText(graphics, imgState, xCenter, stringFormat, shadowBrush, textBrush, finalFont); + + using var finalThumb = + new Bitmap(imgOutputSmall, imgState.ThumbSizeSmall.Width, imgState.ThumbSizeSmall.Height); + finalThumb.Save(Path.Combine(imgState.DestDir.FullName, imgState.NomeFileSmall), format); + } + + private Font CreateFont(string fontName, int size, bool bold) => + new Font(fontName, size, bold ? FontStyle.Bold : FontStyle.Regular); + + private int FindBestFontSize(Graphics g, string text, string fontName, int maxSize, bool bold, int maxWidth, int minSize = 5) + { + if (maxSize <= minSize) return Math.Max(minSize, maxSize); + + int low = minSize; + int high = Math.Max(minSize, maxSize); + int best = minSize; + + while (low <= high) + { + int mid = (low + high) / 2; + using var testFont = CreateFont(fontName, mid, bold); + var measured = g.MeasureString(text, testFont); + if (measured.Width <= maxWidth) + { + best = mid; + low = mid + 1; // try larger + } + else + { + high = mid - 1; // too big + } + } + + return best; + } + + + private void AddText(Image g, ImageState imgState, Bitmap imgOutputBig) + { + using var grPhoto = Graphics.FromImage(imgOutputBig); + grPhoto.SmoothingMode = SmoothingMode.AntiAlias; + + // Determine best base font size using a binary search (faster than decremental loop) + int availableWidth = (int)g.Width; + int targetBaseSize = imgState.DimensioneStandard > 0 ? imgState.DimensioneStandard : picSettings.DimStandard; + int bestBaseSize = FindBestFontSize(grPhoto, imgState.TestoFirma ?? string.Empty, picSettings.IlFont, targetBaseSize, picSettings.Grassetto, availableWidth); + imgState.DimensioneStandard = bestBaseSize; + + // Decide final drawing size (use DimVert if rotated) + int drawSize = (imgState.FotoRuotaADestra || imgState.FotoRuotaASinistra) ? picSettings.DimVert : imgState.DimensioneStandard; + + using var drawFont = CreateFont(picSettings.IlFont, drawSize, picSettings.Grassetto); + var crSize = grPhoto.MeasureString(imgState.TestoFirma ?? string.Empty, drawFont); + var larghezzaStandard = Convert.ToInt32(crSize.Width); + + // Vertical positions + switch (picSettings.Posizione.ToUpper()) + { + case "ALTO": + { + imgState.YPosFromBottom = picSettings.Margine; + imgState.YPosFromBottom3 = picSettings.MargVert; + break; + } + + case "BASSO": + { + imgState.YPosFromBottom = + Convert.ToSingle((g.Height - crSize.Height - (g.Height * picSettings.Margine / 100.0))); + imgState.YPosFromBottom3 = + Convert.ToSingle((g.Height - crSize.Height - (g.Height * picSettings.MargVert / 100.0))); + break; + } + } + + float xCenterOfImg = 0; + using var strFormat = new StringFormat(); + switch (picSettings.Allineamento.ToUpper()) + { + case "SINISTRA": + { + xCenterOfImg = Convert.ToSingle((picSettings.Margine + (larghezzaStandard / (double)2))); + if ((larghezzaStandard / (double)2) > (g.Width / (double)2) - picSettings.Margine) + xCenterOfImg = Convert.ToSingle((g.Width / (double)2)); + break; + } + + case "CENTRO": + { + xCenterOfImg = Convert.ToSingle((g.Width / (double)2)); + break; + } + + case "DESTRA": + { + xCenterOfImg = + Convert.ToSingle((g.Width - picSettings.Margine - (larghezzaStandard / (double)2))); + if ((larghezzaStandard / (double)2) > (g.Width / (double)2) - picSettings.Margine) + xCenterOfImg = Convert.ToSingle((g.Width / (double)2)); + break; + } + } + + strFormat.Alignment = StringAlignment.Center; + + using var semiTransBrush2 = new SolidBrush(Color.FromArgb(imgState.AlphaScelta, 0, 0, 0)); + using var semiTransBrush = new SolidBrush(Color.FromArgb(imgState.AlphaScelta, picSettings.FontColoreRGB)); + + // write text (NomeFileBig) + if (picSettings.TestoNome) + { + if (picSettings.NomeData && g.PropertyIdList.Length > 0) + { + imgState.DataFoto = imgState.CreationDate ?? DateTime.Now; + + grPhoto.DrawString((imgState.NomeFileBig + " " + imgState.DataFoto.ToShortDateString()), drawFont, + semiTransBrush2, new PointF(xCenterOfImg + 1, imgState.YPosFromBottom + 1), strFormat); + grPhoto.DrawString((imgState.NomeFileBig + " " + imgState.DataFoto.ToShortDateString()), drawFont, + semiTransBrush, new PointF(xCenterOfImg, imgState.YPosFromBottom), strFormat); + } + else + { + grPhoto.DrawString(imgState.NomeFileBig, drawFont, semiTransBrush2, + new PointF(xCenterOfImg + 1, imgState.YPosFromBottom + 1), strFormat); + grPhoto.DrawString(imgState.NomeFileBig, drawFont, semiTransBrush, + new PointF(xCenterOfImg, imgState.YPosFromBottom), strFormat); + } + } + else + { + if (imgState.FotoRuotaADestra || imgState.FotoRuotaASinistra) + { + if (!picSettings.TestoMin) + { + grPhoto.DrawString(imgState.TestoFirmaV, drawFont, semiTransBrush2, + new PointF(xCenterOfImg + 1, imgState.YPosFromBottom3 + 1), strFormat); + grPhoto.DrawString(imgState.TestoFirmaV, drawFont, semiTransBrush, + new PointF(xCenterOfImg, imgState.YPosFromBottom3), strFormat); + } + + if (picSettings.TestoMin) + { + grPhoto.DrawString(imgState.TestoFirmaV, drawFont, semiTransBrush2, + new PointF(xCenterOfImg + 1, imgState.YPosFromBottom4 + 1), strFormat); + grPhoto.DrawString(imgState.TestoFirmaV, drawFont, semiTransBrush, + new PointF(xCenterOfImg, imgState.YPosFromBottom4), strFormat); + } + } + else + { + grPhoto.DrawString(imgState.TestoFirma, drawFont, semiTransBrush2, + new PointF(xCenterOfImg + 1, imgState.YPosFromBottom + 1), strFormat); + grPhoto.DrawString(imgState.TestoFirma, drawFont, semiTransBrush, + new PointF(xCenterOfImg, imgState.YPosFromBottom), strFormat); + } + } + + if (string.Equals(picSettings.DirectorySorgente, picSettings.DirectoryDestinazione, + StringComparison.OrdinalIgnoreCase)) + { + imgState.NomeFileBig2 = imgState.NomeFileBig; + imgState.NomeFileBig = $"{imgState.NomeFileBig[..^4]}{picSettings.Codice}{imgState.NomeFileBig[^4..]}"; + } + } + + private void AddLogo(Bitmap imgOutputBig, byte[]? logoData) + { + // Skip if no logo bytes provided + if (logoData is null) return; + + if (!picSettings.LogoAggiungi) return; + + using var logo = Image.FromStream(new System.IO.MemoryStream(logoData)); + + // Decide whether to apply a color-key transparency remap or rely on existing image alpha. + // If UseTransparentColor is true, parse the configured TransparentColor and remap it to fully transparent. + using var grWatermark = Graphics.FromImage(imgOutputBig); + using ImageAttributes imageAttributes = new ImageAttributes(); + + if (picSettings.UseTransparentColor) + { + Color keyColor = Color.White; + try + { + if (!string.IsNullOrWhiteSpace(picSettings.TransparentColor)) + { + // ColorTranslator accepts both "#RRGGBB" and "RRGGBB" + keyColor = ColorTranslator.FromHtml(picSettings.TransparentColor); + } + } + catch + { + keyColor = Color.White; + } + + var colorMap = new ColorMap + { + // background: the color we search for and replace with transparency + OldColor = keyColor, + NewColor = Color.FromArgb(0, 0, 0, 0) + }; + + var remapTable = new[] { colorMap }; + imageAttributes.SetRemapTable(remapTable, ColorAdjustType.Bitmap); + } + + // * The second color manipulation is used to change the opacity by setting the 3rd row and 3rd column to 0.3f + // Parse transparency safely (default to 100 if parsing fails) + if (!int.TryParse(picSettings.LogoTrasparenza, out var logoTransparencyValue)) + { + logoTransparencyValue = 100; + } + + var colorMatrixElements = new[] + { + new[] { 1.0F, 0.0F, 0.0F, 0.0F, 0.0F }, new[] { 0.0F, 1.0F, 0.0F, 0.0F, 0.0F }, + new[] { 0.0F, 0.0F, 1.0F, 0.0F, 0.0F }, + new[] { 0.0F, 0.0F, 0.0F, System.Convert.ToSingle(logoTransparencyValue) / 100F, 0.0F }, + new[] { 0.0F, 0.0F, 0.0F, 0.0F, 1.0F } + }; + var wmColorMatrix = new ColorMatrix(colorMatrixElements); + imageAttributes.SetColorMatrix(wmColorMatrix, ColorMatrixFlag.Default, ColorAdjustType.Bitmap); + + var fotoLogoH = picSettings.LogoAltezza; + var fotoLogoW = picSettings.LogoLarghezza; + var fattoreAlt = logo.Height / (double)fotoLogoH; + var fattoreLarg = logo.Width / (double)fotoLogoW; + var nuovaSize = fattoreLarg > fattoreAlt + ? CalculateThumbnailSize(logo.Width, logo.Height, fotoLogoW, "Larghezza") + : CalculateThumbnailSize(logo.Width, logo.Height, fotoLogoH, "Altezza"); + + // Guard against null/empty LogoMargine and percentage parsing + var logoMargineStr = picSettings.LogoMargine ?? string.Empty; + var inPercentualeL = logoMargineStr.EndsWith('%'); + var margineL = 0; + if (inPercentualeL) + { + var trimmed = logoMargineStr.TrimEnd('%'); + if (!int.TryParse(trimmed, out margineL)) margineL = 0; + } + else + { + if (!int.TryParse(logoMargineStr, out margineL)) margineL = 0; + } + var margineUsato = + inPercentualeL ? System.Convert.ToInt32(imgOutputBig.Height * margineL / (double)100) : margineL; + + int xPosOfWm = 0; + int yPosOfWm = 0; + var logoH = (picSettings.LogoPosizioneH ?? "NESSUNA").ToUpperInvariant(); + var logoV = (picSettings.LogoPosizioneV ?? "NESSUNA").ToUpperInvariant(); + switch (logoH) + { + case "SINISTRA": + case "NESSUNA": + { + xPosOfWm = margineUsato; + break; + } + + case "CENTRO": + { + xPosOfWm = System.Convert.ToInt32((imgOutputBig.Width - nuovaSize.Width) / (double)2); + break; + } + + case "DESTRA": + { + xPosOfWm = ((imgOutputBig.Width - nuovaSize.Width) - margineUsato); + break; + } + } + + switch (logoV) + { + case "ALTO": + case "NESSUNA": + { + yPosOfWm = margineUsato; + break; + } + + case "CENTRO": + { + yPosOfWm = System.Convert.ToInt32((imgOutputBig.Height - nuovaSize.Height) / (double)2); + break; + } + + case "BASSO": + { + yPosOfWm = ((imgOutputBig.Height - nuovaSize.Height) - margineUsato); + break; + } + } + + grWatermark.DrawImage(logo, new Rectangle(xPosOfWm, yPosOfWm, nuovaSize.Width, nuovaSize.Height), 0, 0, + logo.Width, logo.Height, GraphicsUnit.Pixel, imageAttributes); + //grWatermark.Dispose(); + } + + + private void SavePhoto(Bitmap imgOutputBig, ImageState imgState, ImageFormat thisFormat) + { + var fileName = Path.Combine(imgState.DestDir.FullName, imgState.NomeFileBig); + + using var image1Stream = new MemoryStream(); + if (picSettings.FotoGrandeDimOrigina == false) + { + // attenzione non controlla se è png + // imgOutputBig.Save(Path.Combine(_DestDir.FullName, "Temp_" & NomeFileBig), thisFormat) + if (thisFormat.Equals(ImageFormat.Jpeg)) + { + MakeImageCustomQuality(imgOutputBig, image1Stream, picSettings.JpegQuality); + } + //SalvaImmagineCustomQuality(imgOutputBig, Path.Combine(DestDir.FullName, "Temp_" + NomeFileBig), _picSettings.jpegQuality); + else + { + imgOutputBig.Save(image1Stream, thisFormat); + } + + //imgOutputBig.Save(Path.Combine(DestDir.FullName, "Temp_" + NomeFileBig), thisFormat); + image1Stream.Seek(0, SeekOrigin.Begin); + using var g2 = Image.FromStream(image1Stream); + imgState.ThumbSizeBig = g2.Width > g2.Height + ? CalculateThumbnailSize(g2.Width, g2.Height, picSettings.LarghezzaBig, "Larghezza") + : CalculateThumbnailSize(g2.Width, g2.Height, picSettings.AltezzaBig, "Altezza"); + using var imgOutputBig2 = new Bitmap(g2, imgState.ThumbSizeBig.Width, imgState.ThumbSizeBig.Height); + + if (!picSettings.OverwriteFiles && File.Exists(fileName)) + { + logger.LogInformation("Saltata foto {FileName}, esiste", fileName); + } + else + { + if (thisFormat.Equals(ImageFormat.Jpeg)) + SaveImageCustomQuality(imgOutputBig2, fileName, picSettings.JpegQuality); + else + imgOutputBig2.Save(fileName, thisFormat); + } + } + else + { + if (!picSettings.OverwriteFiles && File.Exists(fileName)) + { + logger.LogInformation("Saltata foto {FileName}, esiste", fileName); + } + else + { + if (thisFormat.Equals(ImageFormat.Jpeg)) + SaveImageCustomQuality(imgOutputBig, fileName, picSettings.JpegQuality); + else + imgOutputBig.Save(fileName, thisFormat); + } + } + + image1Stream.Seek(0, SeekOrigin.Begin); + + if (!picSettings.CreaMiniature) return; + if (!picSettings.AggiungiScritteMiniature) return; + + using var g1 = picSettings.FotoGrandeDimOrigina ? (Image)imgOutputBig.Clone() : Image.FromStream(image1Stream); + + using var imgOutputSmall = new Bitmap(g1, imgState.ThumbSizeSmall.Width, imgState.ThumbSizeSmall.Height); + + if (string.Equals(picSettings.DirectorySorgente, picSettings.DirectoryDestinazione, + StringComparison.OrdinalIgnoreCase)) + imgState.NomeFileSmall = imgState.NomeFileSmall.Substring(0, imgState.NomeFileSmall.Length - 4) + + picSettings.Codice + + imgState.NomeFileSmall.Substring(imgState.NomeFileSmall.Length - 4); + + var tnFileName = Path.Combine(imgState.DestDir.FullName, imgState.NomeFileSmall); + + if (!picSettings.OverwriteFiles && File.Exists(tnFileName)) + { + logger.LogInformation("Saltata miniatura foto {TnFileName}, esiste", tnFileName); + } + else + { + if (thisFormat.Equals(ImageFormat.Jpeg)) + SaveImageCustomQuality(imgOutputSmall, tnFileName, picSettings.JpegQualityMin); + else + imgOutputSmall.Save(tnFileName, thisFormat); + } + } + + private void SaveImageCustomQuality(Bitmap imageToSave, string nomeFileFinale, long quality) + { + var jgpEncoder = GetEncoder(ImageFormat.Jpeg); + var myEncoder = System.Drawing.Imaging.Encoder.Quality; + + using var myEncoderParameters = new EncoderParameters(1); + + var myEncoderParameter = new EncoderParameter(myEncoder, quality); + myEncoderParameters.Param[0] = myEncoderParameter; + imageToSave.Save(nomeFileFinale, jgpEncoder, myEncoderParameters); + //imageToSave.Dispose(); + } + + private void MakeImageCustomQuality(Bitmap imageToSave, Stream destinationStream, long quality) + { + var jgpEncoder = GetEncoder(ImageFormat.Jpeg); + var myEncoder = System.Drawing.Imaging.Encoder.Quality; + + using var myEncoderParameters = new EncoderParameters(1); + + var myEncoderParameter = new EncoderParameter(myEncoder, quality); + myEncoderParameters.Param[0] = myEncoderParameter; + destinationStream.Seek(0, SeekOrigin.Begin); + imageToSave.Save(destinationStream, jgpEncoder, myEncoderParameters); + //imageToSave.Dispose(); + } + + private ImageCodecInfo GetEncoder(ImageFormat format) + { + var codecs = ImageCodecInfo.GetImageDecoders(); + + foreach (var codec in codecs) + { + if (codec.FormatID == format.Guid) + return codec; + } + + return null /* TODO Change to default(_) if this is not a reference type */; + } + + /// + /// ''' Calculate the Size of the New image + /// ''' + /// ''' Larghezza + /// ''' Altezza + /// ''' + /// ''' + /// ''' + /// ''' + private Size CalculateThumbnailSize(int currentwidth, int currentheight, int maxPixel, string tipoSize) + { + // e + // *** Larghezza, Altezza, Auto + + double tempMultiplier; + + if (tipoSize.ToUpper() == "Larghezza".ToUpper()) + tempMultiplier = maxPixel / (double)currentwidth; + else if (tipoSize.ToUpper() == "Altezza".ToUpper()) + tempMultiplier = maxPixel / (double)currentheight; + else if (currentheight > currentwidth) + tempMultiplier = maxPixel / (double)currentheight; + else + tempMultiplier = maxPixel / (double)currentwidth; + + var newSize = new Size(System.Convert.ToInt32(currentwidth * tempMultiplier), + System.Convert.ToInt32(currentheight * tempMultiplier)); + + return newSize; + } + + private void SetVerticalPosition(int imgHeight, float textHeight, ImageState imgState) + { + // Use 1% of image height as minimum margin, or 10px, whichever is larger + float minMargin = Math.Max(10f, imgHeight * 0.01f); + + switch (picSettings.Posizione.ToUpper()) + { + case "ALTO": + imgState.YPosFromBottom1 = Math.Max(minMargin, picSettings.Margine); + imgState.YPosFromBottom4 = Math.Max(minMargin, picSettings.MargVert); + break; + + case "BASSO": + var bottomMargin1 = (float)(imgHeight * picSettings.Margine / 100.0); + var bottomMargin4 = (float)(imgHeight * picSettings.MargVert / 100.0); + + // Position from bottom: bottom edge of text at desired margin from bottom + // Y = imageHeight - textHeight - bottomMargin + var desiredY1 = imgHeight - textHeight - bottomMargin1; + var desiredY4 = imgHeight - textHeight - bottomMargin4; + + // Ensure text stays completely within bounds: + // - Top edge must be >= minMargin (not clipped at top) + // - Bottom edge must be <= imgHeight - minMargin (not clipped at bottom) + var maxAllowedY1 = imgHeight - textHeight - minMargin; // Maximum Y to keep bottom margin + var maxAllowedY4 = imgHeight - textHeight - minMargin; + + imgState.YPosFromBottom1 = Math.Max(minMargin, Math.Min(desiredY1, maxAllowedY1)); + imgState.YPosFromBottom4 = Math.Max(minMargin, Math.Min(desiredY4, maxAllowedY4)); + break; + + case "CENTRO": + default: + // Center the text vertically + var centeredY = (imgHeight - textHeight) / 2f; + // Clamp to ensure margins are respected + imgState.YPosFromBottom1 = Math.Max(minMargin, Math.Min(centeredY, imgHeight - textHeight - minMargin)); + imgState.YPosFromBottom4 = imgState.YPosFromBottom1; + break; + } + } + + private float CalculateHorizontalAlignment(int imgWidth, float textWidth) + { + double halfWidth = textWidth / 2.0; + + return picSettings.Allineamento.ToUpper() switch + { + "SINISTRA" => (float)Math.Min(picSettings.Margine + halfWidth, imgWidth / 2.0), + "DESTRA" => (float)Math.Max(imgWidth - picSettings.Margine - halfWidth, imgWidth / 2.0), + _ => imgWidth / 2.0f, // CENTRO or default + }; + } + + private void DrawText(Graphics g, ImageState imgState, float x, StringFormat format, + Brush shadowBrush, Brush textBrush, Font font) + { + string content = imgState.TestoFirmaPiccola; + + if (picSettings.TestoMin) + { + content = imgState.NomeFileBig; + } + else if (picSettings.AggTempoGaraMin && picSettings.UsaTempoGaraTestoApplicare) + { + content = FormatTimeText(imgState, includeFileName: false); + } + else if (picSettings.AggNumTempMin) + { + content = FormatTimeText(imgState, includeFileName: true); + } + + var offset = new PointF(x + 1, imgState.YPosFromBottom1 + 1); + var actual = new PointF(x, imgState.YPosFromBottom1); + + g.DrawString(content, font, shadowBrush, offset, format); + g.DrawString(content, font, textBrush, actual, format); + } + + private string FormatTimeText(ImageState imgState, bool includeFileName) + { + var diff = imgState.DataPartenzaI - imgState.DataFoto; + var ticks = (long)(diff.TotalSeconds * 10000000); + var time = new TimeSpan(ticks); + + var formatted = $"{imgState.TestoOrario}{time:hh\\:mm\\:ss}"; + return includeFileName + ? $"{imgState.NomeFileBig}{Environment.NewLine}{formatted}" + : Environment.NewLine + formatted; + } +} +#endif // WINDOWS diff --git a/MaddoShared/ImageCreatorImageSharp.cs b/MaddoShared/ImageCreatorImageSharp.cs index 008380c..1a7d009 100644 --- a/MaddoShared/ImageCreatorImageSharp.cs +++ b/MaddoShared/ImageCreatorImageSharp.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +// System.Drawing not required for ImageSharp-based drawing in this class using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; @@ -16,8 +17,11 @@ using SixLabors.ImageSharp.Drawing; namespace MaddoShared; /// -/// Image creator implemented with SixLabors.ImageSharp for loading, EXIF orientation, -/// resizing, text/logo drawing, and saving. +/// Image creator implemented using SixLabors.ImageSharp for core image operations. +/// This implementation focuses on loading, EXIF-orientation, resizing and saving. +/// It intentionally implements a minimal subset of the original functionality to +/// provide a safe and testable replacement. Additional features (text/logo drawing) +/// can be added later using ImageSharp.Drawing.Common and SixLabors.Fonts. /// public class ImageCreatorImageSharp : IImageCreator { @@ -34,11 +38,12 @@ public class ImageCreatorImageSharp : IImageCreator { ArgumentNullException.ThrowIfNull(imgState); + // Minimal preparation of names and settings normally done by ImageCreatorSharp.PrepareVariables PrepareVariablesMinimal(imgState); try { - _logger.LogInformation("[ImageSharp] Processing {File} -> {Dest}", imgState.WorkFile?.FullName, imgState.DestDir?.FullName); + _logger.LogInformation("[Alternate] Processing {File} -> {Dest}", imgState.WorkFile?.FullName, imgState.DestDir?.FullName); using var fs = File.OpenRead(imgState.WorkFile.FullName); @@ -54,6 +59,9 @@ public class ImageCreatorImageSharp : IImageCreator // text to draw (horizontal vs vertical). ApplyExifOrientation(img, imgState); + // Determine output format + var forceJpg = _picSettings.UsaForzaJpg; + // Compute big size var bigSize = ComputeBigSize(img.Width, img.Height); @@ -63,10 +71,10 @@ public class ImageCreatorImageSharp : IImageCreator // Ensure destination exists imgState.DestDir?.Create(); - var fileNameBig = System.IO.Path.Combine(imgState.DestDir.FullName, imgState.NomeFileBig); + var fileNameBig = System.IO.Path.Combine(imgState.DestDir.FullName, imgState.NomeFileBig); // Draw overlays (text/logo) onto big image using ImageSharp and save - await DrawAndSaveAsync(imgBig, fileNameBig, imgState, logoData, _picSettings.JpegQuality, isThumbnail: false).ConfigureAwait(false); + await DrawAndSaveWithGdiAsync(imgBig, fileNameBig, imgState, logoData, _picSettings.JpegQuality, isThumbnail: false).ConfigureAwait(false); // Create thumbnail if requested if (_picSettings.CreaMiniature) @@ -77,16 +85,20 @@ public class ImageCreatorImageSharp : IImageCreator var fileNameSmall = System.IO.Path.Combine(imgState.DestDir.FullName, imgState.NomeFileSmall); // Draw overlays and save thumbnail via ImageSharp - await DrawAndSaveAsync(imgSmall, fileNameSmall, imgState, logoData, _picSettings.JpegQualityMin, isThumbnail: true).ConfigureAwait(false); + await DrawAndSaveWithGdiAsync(imgSmall, fileNameSmall, imgState, logoData, _picSettings.JpegQualityMin, isThumbnail: true).ConfigureAwait(false); } } catch (Exception ex) { - _logger.LogError(ex, "[ImageSharp] Error processing image {File}", imgState.WorkFile?.Name); + _logger.LogError(ex, "[Alternate] Error processing image {File}", imgState.WorkFile?.Name); throw; } } + + + // Thumbnail overlays are rendered by the GDI+ pass in DrawAndSaveWithGdiAsync to match original rendering. + private static SixLabors.ImageSharp.Formats.IImageEncoder GetEncoderForExtension(string ext, long quality) { quality = Math.Clamp(quality, 1, 100); @@ -98,7 +110,7 @@ public class ImageCreatorImageSharp : IImageCreator }; } - private async Task DrawAndSaveAsync(Image imgSharp, string outputPath, ImageState imgState, byte[]? logoData, long quality, bool isThumbnail) + private async Task DrawAndSaveWithGdiAsync(Image imgSharp, string outputPath, ImageState imgState, byte[]? logoData, long quality, bool isThumbnail) { // Use ImageSharp drawing APIs to render text and logos and save using ImageSharp encoders. // Clone editable image so we don't mutate the original reference unexpectedly. @@ -113,13 +125,13 @@ public class ImageCreatorImageSharp : IImageCreator } catch (Exception ex) { - _logger?.LogDebug(ex, "[ImageSharp] Failed to clear EXIF orientation on working image"); + _logger?.LogDebug(ex, "[Alternate] Failed to clear EXIF orientation on working image"); } // Ensure DataFoto is set (extracted earlier) so time-based text is available imgState.DataFoto = imgState.CreationDate ?? DateTime.Now; - // Ensure thumbnail text is prepared before drawing. + // Ensure thumbnail text is prepared similarly to ImageCreatorSharp logic if (isThumbnail) { if (string.IsNullOrEmpty(imgState.TestoFirmaPiccola)) @@ -273,7 +285,8 @@ public class ImageCreatorImageSharp : IImageCreator }); } - // Draw logos only on full-size images. + // Draw logo if provided. For compatibility with the original GDI implementation, + // do not draw the logo on thumbnails (ImageCreatorSharp only draws logos on big images). if (logoData != null && logoData.Length > 0 && _picSettings.LogoAggiungi && !isThumbnail) { try @@ -401,7 +414,7 @@ public class ImageCreatorImageSharp : IImageCreator } catch (Exception ex) { - _logger.LogError(ex, "[ImageSharp] Invalid transparent color setting {Color}", _picSettings.TransparentColor); + _logger.LogError(ex, "[Alternate] Invalid transparent color setting {Color}", _picSettings.TransparentColor); } } @@ -416,7 +429,7 @@ public class ImageCreatorImageSharp : IImageCreator } catch (Exception ex) { - _logger.LogError(ex, "[ImageSharp] Error drawing logo"); + _logger.LogError(ex, "[Alternate] Error drawing logo in ImageSharp pass"); } } @@ -431,6 +444,8 @@ public class ImageCreatorImageSharp : IImageCreator await working.SaveAsync(outStream, encoder).ConfigureAwait(false); } + // Removed GDI encoder helper; ImageSharp encoders are used instead. + private void PrepareVariablesMinimal(ImageState imgState) { imgState.NomeFileBig = imgState.WorkFile.Name; @@ -439,6 +454,7 @@ public class ImageCreatorImageSharp : IImageCreator imgState.DimensioneStandardMiniatura = _picSettings.DimStandardMiniatura; // basic text / transparency defaults used by drawing routines + // AlphaScelta mirrors ImageCreatorSharp behavior: compute from PicSettings.Trasparenza (0-100) imgState.AlphaScelta = Convert.ToInt32((255 * (100 - _picSettings.Trasparenza) / (double)100)); // Set minimal text fields so text drawing has fallback values @@ -501,7 +517,9 @@ public class ImageCreatorImageSharp : IImageCreator private void ApplyExifOrientation(Image img, ImageState imgState) { - // Set rotation flags on the state so other code can pick the correct text variant. + // Common EXIF orientations: 1=TopLeft, 3=BottomRight (rotate 180), 6=RightTop (rotate 90 CW), 8=LeftBottom (rotate 270 CW) + // Set rotation flags on the state so other code can pick the correct + // text variant (vertical vs horizontal). Mirror ImageCreatorSharp logic. imgState.FotoRuotaADestra = false; imgState.FotoRuotaASinistra = false; @@ -549,11 +567,11 @@ public class ImageCreatorImageSharp : IImageCreator catch (Exception ex) { // Non-fatal: log and continue - _logger?.LogDebug(ex, "[ImageSharp] Could not clear EXIF orientation tag"); + _logger?.LogDebug(ex, "[Alternate] Could not clear EXIF orientation tag"); } } - private Size ComputeBigSize(int width, int height) + private System.Drawing.Size ComputeBigSize(int width, int height) { // If original large size option requested, return original // otherwise compute based on width/height limits from settings @@ -562,14 +580,16 @@ public class ImageCreatorImageSharp : IImageCreator : CalculateThumbnailSize(width, height, _picSettings.AltezzaBig, "Altezza"); } - private Size ComputeSmallSize(int width, int height) + private System.Drawing.Size ComputeSmallSize(int width, int height) { return width > height ? CalculateThumbnailSize(width, height, _picSettings.LarghezzaSmall, "Larghezza") : CalculateThumbnailSize(width, height, _picSettings.AltezzaSmall, "Altezza"); } - private static Size CalculateThumbnailSize(int currentwidth, int currentheight, int maxPixel, string tipoSize) + // Helper to access PicSettings values via instance _picSettings + + private static System.Drawing.Size CalculateThumbnailSize(int currentwidth, int currentheight, int maxPixel, string tipoSize) { double tempMultiplier; if (string.Equals(tipoSize, "Larghezza", StringComparison.OrdinalIgnoreCase)) @@ -581,7 +601,7 @@ public class ImageCreatorImageSharp : IImageCreator else tempMultiplier = maxPixel / (double)currentwidth; - var newSize = new Size(Convert.ToInt32(currentwidth * tempMultiplier), Convert.ToInt32(currentheight * tempMultiplier)); + var newSize = new System.Drawing.Size(Convert.ToInt32(currentwidth * tempMultiplier), Convert.ToInt32(currentheight * tempMultiplier)); return newSize; } diff --git a/MaddoShared/ImageCreatorMapper.cs b/MaddoShared/ImageCreatorMapper.cs new file mode 100644 index 0000000..4772b7a --- /dev/null +++ b/MaddoShared/ImageCreatorMapper.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace MaddoShared; + +/// +/// Dynamically resolves the concrete IImageCreator implementation at call time +/// based on current PicSettings.ImageCreatorProvider. +/// On non-Windows platforms only ImageCreatorImageSharp is available. +/// +public class ImageCreatorMapper : IImageCreator +{ + private readonly IServiceProvider _sp; + private readonly PicSettings _settings; + private readonly ILogger _logger; + + public ImageCreatorMapper(IServiceProvider sp, PicSettings settings, ILogger logger) + { + _sp = sp ?? throw new ArgumentNullException(nameof(sp)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _logger = logger; + } + + public Task CreateImageAsync(ImageState imgState, byte[]? logoData) + { + var provider = (_settings.ImageCreatorProvider ?? "Sharp").Trim(); + _logger?.LogDebug("Resolving IImageCreator for provider '{Provider}'", provider); + +#if WINDOWS + return provider.Equals("ALTERNATE", StringComparison.OrdinalIgnoreCase) + ? ResolveAndCall(imgState, logoData) + : ResolveAndCall(imgState, logoData); +#else + // GDI is not available on non-Windows — always use ImageSharp + return ResolveAndCall(imgState, logoData); +#endif + } + + private Task ResolveAndCall(ImageState imgState, byte[]? logoData) where T : IImageCreator + { + var impl = (IImageCreator?)_sp.GetService(typeof(T)); + if (impl is null) + { + _logger?.LogWarning("Requested image creator {Type} is not registered. Falling back to ImageCreatorImageSharp.", typeof(T).Name); + impl = (IImageCreator?)_sp.GetService(typeof(ImageCreatorImageSharp)); + } + + if (impl is null) + throw new InvalidOperationException("No IImageCreator implementation is registered."); + + return impl.CreateImageAsync(imgState, logoData); + } +} diff --git a/MaddoShared/ImageState.cs b/MaddoShared/ImageState.cs index e80094c..0090ecb 100644 --- a/MaddoShared/ImageState.cs +++ b/MaddoShared/ImageState.cs @@ -1,4 +1,5 @@ using System; +using System.Drawing; using System.IO; namespace MaddoShared; @@ -27,16 +28,18 @@ public class ImageState public DateTime DataPartenzaI { get; set; } public string TestoOrario { get; set; } public string TestoFirmaPiccola { get; set; } - public string NomeFileSmall { get; set; } - public string NomeFileBig { get; set; } - public string NomeFileBig2 { get; set; } + public Size ThumbSizeSmall { get; set; } + public Size ThumbSizeBig { get; set; } + public string NomeFileSmall{ get; set; } + public string NomeFileBig{ get; set; } + public string NomeFileBig2{ get; set; } - public float YPosFromBottom { get; set; } - public float YPosFromBottom1 { get; set; } - public float YPosFromBottom2 { get; set; } - public float YPosFromBottom3 { get; set; } - public float YPosFromBottom4 { get; set; } + public float YPosFromBottom{ get; set; } + public float YPosFromBottom1{ get; set; } + public float YPosFromBottom2{ get; set; } + public float YPosFromBottom3{ get; set; } + public float YPosFromBottom4{ get; set; } - public Orientations Orientation { get; set; } - public DateTime? CreationDate { get; set; } + public Orientations Orientation{ get; set; } + public DateTime? CreationDate{ get; set; } } \ No newline at end of file diff --git a/MaddoShared/MaddoShared.csproj b/MaddoShared/MaddoShared.csproj index 6d0ee05..7e3998c 100644 --- a/MaddoShared/MaddoShared.csproj +++ b/MaddoShared/MaddoShared.csproj @@ -6,6 +6,8 @@ enable false x64 + + $(DefineConstants);WINDOWS @@ -29,5 +31,6 @@ all + \ No newline at end of file diff --git a/MaddoShared/PicSettings.cs b/MaddoShared/PicSettings.cs index 0623a7f..b633db3 100644 --- a/MaddoShared/PicSettings.cs +++ b/MaddoShared/PicSettings.cs @@ -1,6 +1,6 @@ using System; +using System.Drawing; using System.IO; -using SixLabors.ImageSharp.PixelFormats; namespace MaddoShared; @@ -35,7 +35,7 @@ public class PicSettings public int Margine { get; set; } public int LogoAltezza { get; set; } public int LogoLarghezza { get; set; } - public Rgba32 FontColoreRGB { get; set; } = new(255, 255, 0, 255); + public Color FontColoreRGB { get; set; } public bool LogoAggiungi { get; set; } // Initialize logo-related strings to safe defaults to avoid null reference issues public string LogoNomeFile { get; set; } = string.Empty; @@ -81,4 +81,6 @@ public class PicSettings public bool FotoRuotaASinistra { get; set; } = false; public string TempMinText { get; set; } = string.Empty; public bool OverwriteFiles { get; set; } = false; + // Which image creator to use: "Sharp" for current implementation, "Alternate" for alternate library + public string ImageCreatorProvider { get; set; } = "Sharp"; } \ No newline at end of file diff --git a/README.md b/README.md index dd8716f..1d4631a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Catalog 3 The build embeds an expiration date from the `CatalogLiteExpirationDate` MSBuild property: ```powershell -dotnet publish CatalogLite/CatalogLite.csproj -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:CatalogLiteExpirationDate=2026-12-31 +dotnet publish CatalogLite/CatalogLite.csproj -c Release -r win-x64 --self-contained true -p:CatalogLiteExpirationDate=2026-12-31 ``` The separate Forgejo workflow is `.forgejo/workflows/build-catalog-lite.yml`; run it manually and set `expiration_date` in `yyyy-MM-dd` format. diff --git a/docs/image-generation-tests-plan.md b/docs/image-generation-tests-plan.md index 2745450..dcddcd8 100644 --- a/docs/image-generation-tests-plan.md +++ b/docs/image-generation-tests-plan.md @@ -51,7 +51,7 @@ Verification approach Scope boundaries ---------------- - In scope: `ImageCreatorImageSharp` behavior: resize, EXIF rotation, text presence/position, logo position/opactiy, thumbnails. -- Out of scope: OCR verification of exact text glyphs, font-subpixel metrics, performance testing. +- Out of scope: `ImageCreatorGDI` (excluded), OCR verification of exact text glyphs, font-subpixel metrics, performance testing. Implementation notes -------------------- diff --git a/imagecatalog/AvaloniaViews/GeneralTabView.axaml b/imagecatalog/AvaloniaViews/GeneralTabView.axaml index 082d502..ed0dd98 100644 --- a/imagecatalog/AvaloniaViews/GeneralTabView.axaml +++ b/imagecatalog/AvaloniaViews/GeneralTabView.axaml @@ -38,6 +38,12 @@ + + + + + + diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index 4ed641a..d4a3dc4 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -6,8 +6,12 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +#if WINDOWS +using System.Drawing.Text; +#endif using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Globalization; using System.Threading; @@ -19,7 +23,6 @@ using AutoMapper; using MaddoShared; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; -using SixLabors.Fonts; namespace ImageCatalog_2 { @@ -558,19 +561,16 @@ namespace ImageCatalog_2 private List LoadAvailableFonts() { - try +#if WINDOWS + var fonts = new List(); + using (var installedFonts = new InstalledFontCollection()) { - return SystemFonts.Collection.Families - .Select(f => f.Name) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase) - .ToList(); - } - catch - { - return new List(); + fonts.AddRange(installedFonts.Families.Select(f => f.Name)); } + return fonts; +#else + return new List(); +#endif } private CancellationTokenSource? _mainToken; @@ -841,6 +841,56 @@ namespace ImageCatalog_2 set => _visual.LogoTransparency = value; } + // Image library selection (UI radio buttons bind to the boolean helpers) + private string _imageLibrary = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "System.Graphics" : "ImageSharp"; + + /// + /// Whether the application is running on Windows. Used by cross-platform UIs to show/hide Windows-only options. + /// + public bool IsRunningOnWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// + /// The selected image processing library. Possible values: "System.Graphics" or "ImageSharp". + /// This value is mirrored into PicSettings.ImageCreatorProvider so the runtime mapper picks the implementation. + /// + public string ImageLibrary + { + get => _imageLibrary; + set + { + if (_imageLibrary == value) return; + _imageLibrary = value; + // Reflect selection into PicSettings so mapper can resolve at runtime + _picSettings.ImageCreatorProvider = string.Equals(value, "ImageSharp", StringComparison.OrdinalIgnoreCase) + ? "ALTERNATE" + : "Sharp"; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(UseSystemGraphics)); + NotifyPropertyChanged(nameof(UseImageSharp)); + NotifyPropertyChanged(nameof(IsRunningOnWindows)); + } + } + + public bool UseSystemGraphics + { + get => string.Equals(ImageLibrary, "System.Graphics", StringComparison.OrdinalIgnoreCase); + set + { + if (value) ImageLibrary = "System.Graphics"; + NotifyPropertyChanged(); + } + } + + public bool UseImageSharp + { + get => string.Equals(ImageLibrary, "ImageSharp", StringComparison.OrdinalIgnoreCase); + set + { + if (value) ImageLibrary = "ImageSharp"; + NotifyPropertyChanged(); + } + } + // Folder division settings private int _filesPerFolder = 99; public int FilesPerFolder diff --git a/imagecatalog/ExifReader.cs b/imagecatalog/ExifReader.cs new file mode 100644 index 0000000..97005ca --- /dev/null +++ b/imagecatalog/ExifReader.cs @@ -0,0 +1,1190 @@ +/// ----------------------------------------------------------------------------- +/// +/// Utility class for reading EXIF data from images. Provides abstraction +/// for most common data and generic utilities for work with all other. +/// +/// +/// Copyright (c) Michal A. Valášek - Altair Communications, 2003 +/// Copmany: http://software.altaircom.net * support@altaircom.net +/// Private: http://www.rider.cz * developer@rider.cz +/// This is free software licensed under GNU Lesser General Public License +/// +/// +/// [altair] 10.9.2003 Created +/// +/// ----------------------------------------------------------------------------- +using System; +using System.Drawing; +using Microsoft.VisualBasic; +using Microsoft.VisualBasic.CompilerServices; + +namespace ImageCatalog +{ + public class ExifReader : IDisposable + { + private Bitmap Image; + + /// ----------------------------------------------------------------------------- + /// + /// Contains possible values of EXIF tag names (ID) + /// + /// See GdiPlusImaging.h + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public enum TagNames + { + ExifIFD = 0x8769, + GpsIFD = 0x8825, + NewSubfileType = 0xFE, + SubfileType = 0xFF, + ImageWidth = 0x100, + ImageHeight = 0x101, + BitsPerSample = 0x102, + Compression = 0x103, + PhotometricInterp = 0x106, + ThreshHolding = 0x107, + CellWidth = 0x108, + CellHeight = 0x109, + FillOrder = 0x10A, + DocumentName = 0x10D, + ImageDescription = 0x10E, + EquipMake = 0x10F, + EquipModel = 0x110, + StripOffsets = 0x111, + Orientation = 0x112, + SamplesPerPixel = 0x115, + RowsPerStrip = 0x116, + StripBytesCount = 0x117, + MinSampleValue = 0x118, + MaxSampleValue = 0x119, + XResolution = 0x11A, + YResolution = 0x11B, + PlanarConfig = 0x11C, + PageName = 0x11D, + XPosition = 0x11E, + YPosition = 0x11F, + FreeOffset = 0x120, + FreeByteCounts = 0x121, + GrayResponseUnit = 0x122, + GrayResponseCurve = 0x123, + T4Option = 0x124, + T6Option = 0x125, + ResolutionUnit = 0x128, + PageNumber = 0x129, + TransferFuncition = 0x12D, + SoftwareUsed = 0x131, + DateTime = 0x132, + Artist = 0x13B, + HostComputer = 0x13C, + Predictor = 0x13D, + WhitePoint = 0x13E, + PrimaryChromaticities = 0x13F, + ColorMap = 0x140, + HalftoneHints = 0x141, + TileWidth = 0x142, + TileLength = 0x143, + TileOffset = 0x144, + TileByteCounts = 0x145, + InkSet = 0x14C, + InkNames = 0x14D, + NumberOfInks = 0x14E, + DotRange = 0x150, + TargetPrinter = 0x151, + ExtraSamples = 0x152, + SampleFormat = 0x153, + SMinSampleValue = 0x154, + SMaxSampleValue = 0x155, + TransferRange = 0x156, + JPEGProc = 0x200, + JPEGInterFormat = 0x201, + JPEGInterLength = 0x202, + JPEGRestartInterval = 0x203, + JPEGLosslessPredictors = 0x205, + JPEGPointTransforms = 0x206, + JPEGQTables = 0x207, + JPEGDCTables = 0x208, + JPEGACTables = 0x209, + YCbCrCoefficients = 0x211, + YCbCrSubsampling = 0x212, + YCbCrPositioning = 0x213, + REFBlackWhite = 0x214, + ICCProfile = 0x8773, + Gamma = 0x301, + ICCProfileDescriptor = 0x302, + SRGBRenderingIntent = 0x303, + ImageTitle = 0x320, + Copyright = 0x8298, + ResolutionXUnit = 0x5001, + ResolutionYUnit = 0x5002, + ResolutionXLengthUnit = 0x5003, + ResolutionYLengthUnit = 0x5004, + PrintFlags = 0x5005, + PrintFlagsVersion = 0x5006, + PrintFlagsCrop = 0x5007, + PrintFlagsBleedWidth = 0x5008, + PrintFlagsBleedWidthScale = 0x5009, + HalftoneLPI = 0x500A, + HalftoneLPIUnit = 0x500B, + HalftoneDegree = 0x500C, + HalftoneShape = 0x500D, + HalftoneMisc = 0x500E, + HalftoneScreen = 0x500F, + JPEGQuality = 0x5010, + GridSize = 0x5011, + ThumbnailFormat = 0x5012, + ThumbnailWidth = 0x5013, + ThumbnailHeight = 0x5014, + ThumbnailColorDepth = 0x5015, + ThumbnailPlanes = 0x5016, + ThumbnailRawBytes = 0x5017, + ThumbnailSize = 0x5018, + ThumbnailCompressedSize = 0x5019, + ColorTransferFunction = 0x501A, + ThumbnailData = 0x501B, + ThumbnailImageWidth = 0x5020, + ThumbnailImageHeight = 0x502, + ThumbnailBitsPerSample = 0x5022, + ThumbnailCompression = 0x5023, + ThumbnailPhotometricInterp = 0x5024, + ThumbnailImageDescription = 0x5025, + ThumbnailEquipMake = 0x5026, + ThumbnailEquipModel = 0x5027, + ThumbnailStripOffsets = 0x5028, + ThumbnailOrientation = 0x5029, + ThumbnailSamplesPerPixel = 0x502A, + ThumbnailRowsPerStrip = 0x502B, + ThumbnailStripBytesCount = 0x502C, + ThumbnailResolutionX = 0x502D, + ThumbnailResolutionY = 0x502E, + ThumbnailPlanarConfig = 0x502F, + ThumbnailResolutionUnit = 0x5030, + ThumbnailTransferFunction = 0x5031, + ThumbnailSoftwareUsed = 0x5032, + ThumbnailDateTime = 0x5033, + ThumbnailArtist = 0x5034, + ThumbnailWhitePoint = 0x5035, + ThumbnailPrimaryChromaticities = 0x5036, + ThumbnailYCbCrCoefficients = 0x5037, + ThumbnailYCbCrSubsampling = 0x5038, + ThumbnailYCbCrPositioning = 0x5039, + ThumbnailRefBlackWhite = 0x503A, + ThumbnailCopyRight = 0x503B, + LuminanceTable = 0x5090, + ChrominanceTable = 0x5091, + FrameDelay = 0x5100, + LoopCount = 0x5101, + PixelUnit = 0x5110, + PixelPerUnitX = 0x5111, + PixelPerUnitY = 0x5112, + PaletteHistogram = 0x5113, + ExifExposureTime = 0x829A, + ExifFNumber = 0x829D, + ExifExposureProg = 0x8822, + ExifSpectralSense = 0x8824, + ExifISOSpeed = 0x8827, + ExifOECF = 0x8828, + ExifVer = 0x9000, + ExifDTOrig = 0x9003, + ExifDTDigitized = 0x9004, + ExifCompConfig = 0x9101, + ExifCompBPP = 0x9102, + ExifShutterSpeed = 0x9201, + ExifAperture = 0x9202, + ExifBrightness = 0x9203, + ExifExposureBias = 0x9204, + ExifMaxAperture = 0x9205, + ExifSubjectDist = 0x9206, + ExifMeteringMode = 0x9207, + ExifLightSource = 0x9208, + ExifFlash = 0x9209, + ExifFocalLength = 0x920A, + ExifMakerNote = 0x927C, + ExifUserComment = 0x9286, + ExifDTSubsec = 0x9290, + ExifDTOrigSS = 0x9291, + ExifDTDigSS = 0x9292, + ExifFPXVer = 0xA000, + ExifColorSpace = 0xA001, + ExifPixXDim = 0xA002, + ExifPixYDim = 0xA003, + ExifRelatedWav = 0xA004, + ExifInterop = 0xA005, + ExifFlashEnergy = 0xA20B, + ExifSpatialFR = 0xA20C, + ExifFocalXRes = 0xA20E, + ExifFocalYRes = 0xA20F, + ExifFocalResUnit = 0xA210, + ExifSubjectLoc = 0xA214, + ExifExposureIndex = 0xA215, + ExifSensingMethod = 0xA217, + ExifFileSource = 0xA300, + ExifSceneType = 0xA301, + ExifCfaPattern = 0xA302, + GpsVer = 0x0, + GpsLatitudeRef = 0x1, + GpsLatitude = 0x2, + GpsLongitudeRef = 0x3, + GpsLongitude = 0x4, + GpsAltitudeRef = 0x5, + GpsAltitude = 0x6, + GpsGpsTime = 0x7, + GpsGpsSatellites = 0x8, + GpsGpsStatus = 0x9, + GpsGpsMeasureMode = 0xA, + GpsGpsDop = 0xB, + GpsSpeedRef = 0xC, + GpsSpeed = 0xD, + GpsTrackRef = 0xE, + GpsTrack = 0xF, + GpsImgDirRef = 0x10, + GpsImgDir = 0x11, + GpsMapDatum = 0x12, + GpsDestLatRef = 0x13, + GpsDestLat = 0x14, + GpsDestLongRef = 0x15, + GpsDestLong = 0x16, + GpsDestBearRef = 0x17, + GpsDestBear = 0x18, + GpsDestDistRef = 0x19, + GpsDestDist = 0x1A + } + + /// ----------------------------------------------------------------------------- + /// + /// Real position of 0th row and column of picture + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public enum Orientations + { + TopLeft = 1, + TopRight = 2, + BottomRight = 3, + BottomLeft = 4, + LeftTop = 5, + RightTop = 6, + RightBottom = 7, + LftBottom = 8 + } + + /// ----------------------------------------------------------------------------- + /// + /// Exposure programs + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public enum ExposurePrograms + { + Manual = 1, + Normal = 2, + AperturePriority = 3, + ShutterPriority = 4, + Creative = 5, + Action = 6, + Portrait = 7, + Landscape = 8 + } + + /// ----------------------------------------------------------------------------- + /// + /// Exposure metering modes + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public enum ExposureMeteringModes + { + Unknown = 0, + Average = 1, + CenterWeightedAverage = 2, + Spot = 3, + MultiSpot = 4, + MultiSegment = 5, + Partial = 6, + Other = 255 + } + + /// ----------------------------------------------------------------------------- + /// + /// Flash activity modes + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public enum FlashModes + { + NotFired = 0, + Fired = 1, + FiredButNoStrobeReturned = 5, + FiredAndStrobeReturned = 7 + } + + /// ----------------------------------------------------------------------------- + /// + /// Possible light sources (white balance) + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public enum LightSources + { + Unknown = 0, + Daylight = 1, + Fluorescent = 2, + Tungsten = 3, + Flash = 10, + StandardLightA = 17, + StandardLightB = 18, + StandardLightC = 19, + D55 = 20, + D65 = 21, + D75 = 22, + Other = 255 + } + + /// ----------------------------------------------------------------------------- + /// + /// Represents rational which is type of some Exif properties + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public struct Rational + { + public int Numerator; + public int Denominator; + + /// ----------------------------------------------------------------------------- + /// + /// Converts rational to string representation + /// + /// Optional, default "/". String to be used as delimiter of components. + /// String representation of the rational. + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public new string ToString(string Delimiter = "/") + { + return Numerator + Delimiter + Denominator; + } + + /// ----------------------------------------------------------------------------- + /// + /// Converts rational to double precision real number + /// + /// The rational as double precision real number. + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public double ToDouble() + { + return Numerator / (double)Denominator; + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Initializes new instance of this class. + /// + /// Bitmap to read exif information from + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public ExifReader(Bitmap Bitmap) + { + if (Bitmap is null) + throw new ArgumentNullException("Bitmap"); + Image = Bitmap; + } + + /// ----------------------------------------------------------------------------- + /// + /// Returns all available data in formatted string form + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public override string ToString() + { + var SB = new System.Text.StringBuilder(); + SB.Append("Image:"); + SB.Append(@"\n\tDimensions: " + Width + " x " + Height + " px"); + SB.Append(@"\n\tResolution: " + ResolutionX + " x " + ResolutionY + " dpi"); + SB.Append(@"\n\tOrientation: " + Enum.GetName(typeof(Orientations), Orientation)); + SB.Append(@"\n\tTitle: " + Title); + SB.Append(@"\n\tDescription: " + Description); + SB.Append(@"\n\tCopyright: " + Copyright); + SB.Append(@"\nEquipment:"); + SB.Append(@"\n\tMaker: " + EquipmentMaker); + SB.Append(@"\n\tModel: " + EquipmentModel); + SB.Append(@"\n\tSoftware: " + Software); + SB.Append(@"\nDate and time:"); + SB.Append(@"\n\tGeneral: " + DateTimeLastModified.ToString()); + SB.Append(@"\n\tOriginal: " + DateTimeOriginal.ToString()); + SB.Append(@"\n\tDigitized: " + DateTimeDigitized.ToString()); + SB.Append(@"\nShooting conditions:"); + SB.Append(@"\n\tExposure time: " + ExposureTime.ToString("N4") + " s"); + SB.Append(@"\n\tExposure program: " + Enum.GetName(typeof(ExposurePrograms), ExposureProgram)); + SB.Append(@"\n\tExposure mode: " + Enum.GetName(typeof(ExposureMeteringModes), ExposureMeteringMode)); + SB.Append(@"\n\tAperture: F" + Aperture.ToString("N2")); + SB.Append(@"\n\tISO sensitivity: " + ISO); + SB.Append(@"\n\tSubject distance: " + SubjectDistance.ToString("N2") + " m"); + SB.Append(@"\n\tFocal length: " + FocalLength); + SB.Append(@"\n\tFlash: " + Enum.GetName(typeof(FlashModes), FlashMode)); + SB.Append(@"\n\tLight source (WB): " + Enum.GetName(typeof(LightSources), LightSource)); + SB.Append(@"\n\nCopyright (c) Michal A. Valasek - Altair Communications, 2003"); + SB.Append(@"\nhttp://software.altaircom.net * support@altaircom.net"); + SB.Replace(@"\n", Constants.vbCrLf); + SB.Replace(@"\t", Constants.vbTab); + return SB.ToString(); + } + + /// ----------------------------------------------------------------------------- + /// + /// Brand of equipment (EXIF EquipMake) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public string EquipmentMaker + { + get + { + return GetPropertyString((int)TagNames.EquipMake); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Model of equipment (EXIF EquipModel) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public string EquipmentModel + { + get + { + return GetPropertyString((int)TagNames.EquipModel); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Software used for processing (EXIF Software) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public string Software + { + get + { + return GetPropertyString((int)TagNames.SoftwareUsed); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Orientation of image (position of row 0, column 0) (EXIF Orientation) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public Orientations Orientation + { + get + { + int X = GetPropertyInt16((int)TagNames.Orientation); + if (!Enum.IsDefined(typeof(Orientations), X)) + { + return Orientations.TopLeft; + } + else + { + return (Orientations)Conversions.ToInteger(Enum.Parse(typeof(Orientations), Enum.GetName(typeof(Orientations), X))); + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Time when image was last modified (EXIF DateTime). + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public DateTime DateTimeLastModified + { + get + { + try + { + return DateTime.ParseExact(GetPropertyString((int)TagNames.DateTime), @"yyyy\:MM\:dd HH\:mm\:ss", null); + } + catch (Exception ex) + { + return DateTime.MinValue; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Time when image was taken (EXIF DateTimeOriginal). + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public DateTime DateTimeOriginal + { + get + { + try + { + return DateTime.ParseExact(GetPropertyString((int)TagNames.ExifDTOrig), @"yyyy\:MM\:dd HH\:mm\:ss", null); + } + catch (Exception ex) + { + return DateTime.MinValue; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Time when image was digitized (EXIF DateTimeDigitized). + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public DateTime DateTimeDigitized + { + get + { + try + { + return DateTime.ParseExact(GetPropertyString((int)TagNames.ExifDTDigitized), @"yyyy\:MM\:dd HH\:mm\:ss", null); + } + catch (Exception ex) + { + return DateTime.MinValue; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Image width + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public short Width + { + get + { + return GetPropertyInt16((int)TagNames.ImageWidth); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Image height + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public short Height + { + get + { + return GetPropertyInt16((int)TagNames.ImageHeight); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// X resolution in dpi (EXIF XResolution/ResolutionUnit) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public double ResolutionX + { + get + { + double R = GetPropertyRational((int)TagNames.XResolution).ToDouble(); + if (GetPropertyInt16((int)TagNames.ResolutionUnit) == 3) + { + // -- resolution is in points/cm + return R * 2.54d; + } + else + { + // -- resolution is in points/inch + return R; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Y resolution in dpi (EXIF YResolution/ResolutionUnit) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public double ResolutionY + { + get + { + double R = GetPropertyRational((int)TagNames.YResolution).ToDouble(); + if (GetPropertyInt16((int)TagNames.ResolutionUnit) == 3) + { + // -- resolution is in points/cm + return R * 2.54d; + } + else + { + // -- resolution is in points/inch + return R; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Image title (EXIF ImageTitle) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public string Title + { + get + { + return GetPropertyString((int)TagNames.ImageTitle); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Image description (EXIF ImageDescription) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public string Description + { + get + { + return GetPropertyString((int)TagNames.ImageDescription); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Image copyright (EXIF Copyright) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public string Copyright + { + get + { + return GetPropertyString((int)TagNames.Copyright); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Exposure time in seconds (EXIF ExifExposureTime/ExifShutterSpeed) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public double ExposureTime + { + get + { + if (IsPropertyDefined((int)TagNames.ExifExposureTime)) + { + // -- Exposure time is explicitly specified + return GetPropertyRational((int)TagNames.ExifExposureTime).ToDouble(); + } + else if (IsPropertyDefined((int)TagNames.ExifShutterSpeed)) + { + // -- Compute exposure time from shutter speed + return 1d / Math.Pow(2d, GetPropertyRational((int)TagNames.ExifShutterSpeed).ToDouble()); + } + else + { + // -- Can't figure out + return 0d; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Aperture value as F number (EXIF ExifFNumber/ExifApertureValue) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public double Aperture + { + get + { + if (IsPropertyDefined((int)TagNames.ExifFNumber)) + { + return GetPropertyRational((int)TagNames.ExifFNumber).ToDouble(); + } + else if (IsPropertyDefined((int)TagNames.ExifAperture)) + { + return Math.Pow(Math.Sqrt(2d), GetPropertyRational((int)TagNames.ExifAperture).ToDouble()); + } + else + { + return 0d; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Exposure program used (EXIF ExifExposureProg) + /// + /// + /// If not specified, returns Normal (2) + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public ExposurePrograms ExposureProgram + { + get + { + int X = GetPropertyInt16((int)TagNames.ExifExposureProg); + if (Enum.IsDefined(typeof(ExposurePrograms), X)) + { + return (ExposurePrograms)Conversions.ToInteger(Enum.Parse(typeof(ExposurePrograms), Enum.GetName(typeof(ExposurePrograms), X))); + } + else + { + return ExposurePrograms.Normal; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// ISO sensitivity + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public short ISO + { + get + { + return GetPropertyInt16((int)TagNames.ExifISOSpeed); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Subject distance in meters (EXIF SubjectDistance) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public double SubjectDistance + { + get + { + return GetPropertyRational((int)TagNames.ExifSubjectDist).ToDouble(); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Exposure method metering mode used (EXIF MeteringMode) + /// + /// + /// If not specified, returns Unknown (0) + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public ExposureMeteringModes ExposureMeteringMode + { + get + { + int X = GetPropertyInt16((int)TagNames.ExifMeteringMode); + if (Enum.IsDefined(typeof(ExposureMeteringModes), X)) + { + return (ExposureMeteringModes)Conversions.ToInteger(Enum.Parse(typeof(ExposureMeteringModes), Enum.GetName(typeof(ExposureMeteringModes), X))); + } + else + { + return ExposureMeteringModes.Unknown; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Focal length of lenses in mm (EXIF FocalLength) + /// + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public double FocalLength + { + get + { + return GetPropertyRational((int)TagNames.ExifFocalLength).ToDouble(); + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Flash mode (EXIF Flash) + /// + /// + /// If not present, value NotFired (0) is returned + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public FlashModes FlashMode + { + get + { + int X = GetPropertyInt16((int)TagNames.ExifFlash); + if (Enum.IsDefined(typeof(FlashModes), X)) + { + return (FlashModes)Conversions.ToInteger(Enum.Parse(typeof(FlashModes), Enum.GetName(typeof(FlashModes), X))); + } + else + { + return FlashModes.NotFired; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Light source / white balance (EXIF LightSource) + /// + /// + /// If not specified, returns Unknown (0). + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public LightSources LightSource + { + get + { + int X = GetPropertyInt16((int)TagNames.ExifLightSource); + if (Enum.IsDefined(typeof(LightSources), X)) + { + return (LightSources)Conversions.ToInteger(Enum.Parse(typeof(LightSources), Enum.GetName(typeof(LightSources), X))); + } + else + { + return LightSources.Unknown; + } + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Checks if current image has specified certain property + /// + /// + /// True if image has specified property, False otherwise. + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public bool IsPropertyDefined(int PID) + { + return Array.IndexOf(Image.PropertyIdList, PID) > -1; + } + + /// ----------------------------------------------------------------------------- + /// + /// Gets specified Int32 property + /// + /// Property ID + /// Optional, default 0. Default value returned if property is not present. + /// Value of property or DefaultValue if property is not present. + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public int GetPropertyInt32(int PID, int DefaultValue = 0) + { + if (IsPropertyDefined(PID)) + { + return GetInt32(Image.GetPropertyItem(PID).Value); + } + else + { + return DefaultValue; + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Gets specified Int16 property + /// + /// Property ID + /// Optional, default 0. Default value returned if property is not present. + /// Value of property or DefaultValue if property is not present. + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public short GetPropertyInt16(int PID, short DefaultValue = 0) + { + if (IsPropertyDefined(PID)) + { + return GetInt16(Image.GetPropertyItem(PID).Value); + } + else + { + return DefaultValue; + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Gets specified string property + /// + /// Property ID + /// Optional, default String.Empty. Default value returned if property is not present. + /// + /// Value of property or DefaultValue if property is not present. + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public string GetPropertyString(int PID, string DefaultValue = "") + { + if (IsPropertyDefined(PID)) + { + return GetString(Image.GetPropertyItem(PID).Value); + } + else + { + return DefaultValue; + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Gets specified rational property + /// + /// Property ID + /// + /// Value of property or 0/1 if not present. + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public Rational GetPropertyRational(int PID) + { + if (IsPropertyDefined(PID)) + { + return GetRational(Image.GetPropertyItem(PID).Value); + } + else + { + Rational R; + R.Numerator = 0; + R.Denominator = 1; + return R; + } + } + + /// ----------------------------------------------------------------------------- + /// + /// Reads Int32 from EXIF bytearray. + /// + /// EXIF bytearray to process + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public static int GetInt32(byte[] B) + { + if (B.Length < 4) + throw new ArgumentException("Data too short (4 bytes expected)", "B"); + return B[3] << 24 | B[2] << 16 | B[1] << 8 | B[0]; + } + + /// ----------------------------------------------------------------------------- + /// + /// Reads Int16 from EXIF bytearray. + /// + /// EXIF bytearray to process + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public static short GetInt16(byte[] B) + { + if (B.Length < 2) + throw new ArgumentException("Data too short (2 bytes expected)", "B"); + return (short)(B[1] << 8 | B[0]); + } + + /// ----------------------------------------------------------------------------- + /// + /// Reads string from EXIF bytearray. + /// + /// EXIF bytearray to process + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public static string GetString(byte[] B) + { + string R = System.Text.Encoding.ASCII.GetString(B); + if (R.EndsWith(Constants.vbNullChar)) + R = R.Substring(0, R.Length - 1); + return R; + } + + /// ----------------------------------------------------------------------------- + /// + /// Reads rational from EXIF bytearray. + /// + /// EXIF bytearray to process + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public static Rational GetRational(byte[] B) + { + var R = new Rational(); + byte[] N = new byte[4], D = new byte[4]; + Array.Copy(B, 0, N, 0, 4); + Array.Copy(B, 4, D, 0, 4); + R.Denominator = GetInt32(D); + R.Numerator = GetInt32(N); + return R; + } + + /// ----------------------------------------------------------------------------- + /// + /// Disposes unmanaged resources of this class + /// + /// + /// + /// [altair] 10.9.2003 Created + /// + /// ----------------------------------------------------------------------------- + public void Dispose() + { + Image.Dispose(); + } + } +} \ No newline at end of file diff --git a/imagecatalog/ImageCatalog 2.csproj b/imagecatalog/ImageCatalog 2.csproj index 15fbc3b..11eda12 100644 --- a/imagecatalog/ImageCatalog 2.csproj +++ b/imagecatalog/ImageCatalog 2.csproj @@ -72,7 +72,6 @@ - diff --git a/imagecatalog/Mappings/DataModelMappingProfile.cs b/imagecatalog/Mappings/DataModelMappingProfile.cs index 8ed1859..2706a88 100644 --- a/imagecatalog/Mappings/DataModelMappingProfile.cs +++ b/imagecatalog/Mappings/DataModelMappingProfile.cs @@ -1,7 +1,6 @@ -using AutoMapper; +using System.Drawing; +using AutoMapper; using MaddoShared; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; namespace ImageCatalog_2.Mappings; @@ -16,7 +15,7 @@ public class DataModelMappingProfile : Profile // Paths .ForMember(dest => dest.DirectorySorgente, opt => opt.MapFrom(src => src.SourcePath)) .ForMember(dest => dest.DirectoryDestinazione, opt => opt.MapFrom(src => src.DestinationPath)) - + // Font and text settings .ForMember(dest => dest.DimStandard, opt => opt.MapFrom(src => src.FontSize)) .ForMember(dest => dest.DimStandardMiniatura, opt => opt.MapFrom(src => src.FontSizeThumbnail)) @@ -26,8 +25,8 @@ public class DataModelMappingProfile : Profile .ForMember(dest => dest.Allineamento, opt => opt.MapFrom(src => src.HorizontalAlignment)) .ForMember(dest => dest.Trasparenza, opt => opt.MapFrom(src => src.TextTransparency)) .ForMember(dest => dest.Margine, opt => opt.MapFrom(src => src.TextMargin)) - .ForMember(dest => dest.FontColoreRGB, opt => opt.MapFrom(src => ParseColor(src.TextColorRGB))) - + .ForMember(dest => dest.FontColoreRGB, opt => opt.MapFrom(src => ColorTranslator.FromHtml(src.TextColorRGB))) + // Thumbnail settings .ForMember(dest => dest.AltezzaSmall, opt => opt.MapFrom(src => src.ThumbnailHeight)) .ForMember(dest => dest.LarghezzaSmall, opt => opt.MapFrom(src => src.ThumbnailWidth)) @@ -35,14 +34,14 @@ public class DataModelMappingProfile : Profile .ForMember(dest => dest.CreaMiniature, opt => opt.MapFrom(src => src.CreateThumbnails)) .ForMember(dest => dest.JpegQualityMin, opt => opt.MapFrom(src => src.JpegQualityThumbnail)) .ForMember(dest => dest.DimMin, opt => opt.MapFrom(src => src.FontSizeThumbnail)) - + // Big photo settings .ForMember(dest => dest.AltezzaBig, opt => opt.MapFrom(src => src.PhotoBigHeight)) .ForMember(dest => dest.LarghezzaBig, opt => opt.MapFrom(src => src.PhotoBigWidth)) .ForMember(dest => dest.FotoGrandeDimOrigina, opt => opt.MapFrom(src => src.KeepOriginalDimensions)) .ForMember(dest => dest.JpegQuality, opt => opt.MapFrom(src => src.JpegQuality)) .ForMember(dest => dest.Codice, opt => opt.MapFrom(src => src.BigPhotoSuffix)) - + // Logo settings .ForMember(dest => dest.LogoAggiungi, opt => opt.MapFrom(src => src.AddLogo)) .ForMember(dest => dest.LogoNomeFile, opt => opt.MapFrom(src => src.LogoFile)) @@ -52,15 +51,15 @@ public class DataModelMappingProfile : Profile .ForMember(dest => dest.LogoTrasparenza, opt => opt.MapFrom(src => src.LogoTransparency.ToString())) .ForMember(dest => dest.LogoPosizioneH, opt => opt.MapFrom(src => src.LogoHorizontalPosition)) .ForMember(dest => dest.LogoPosizioneV, opt => opt.MapFrom(src => src.LogoVerticalPosition)) - + // Text content .ForMember(dest => dest.TestoFirmaStart, opt => opt.MapFrom(src => src.HorizontalText)) .ForMember(dest => dest.TestoFirmaStartV, opt => opt.MapFrom(src => src.VerticalText)) - + // Vertical text settings .ForMember(dest => dest.DimVert, opt => opt.MapFrom(src => src.VerticalTextSize)) .ForMember(dest => dest.MargVert, opt => opt.MapFrom(src => src.VerticalTextMargin)) - + // Boolean flags .ForMember(dest => dest.UsaRotazioneAutomatica, opt => opt.MapFrom(src => src.AutomaticRotation)) .ForMember(dest => dest.UsaForzaJpg, opt => opt.MapFrom(src => src.ForceJpeg)) @@ -69,18 +68,18 @@ public class DataModelMappingProfile : Profile .ForMember(dest => dest.UsaOrarioTestoApplicare, opt => opt.MapFrom(src => src.AddTime)) .ForMember(dest => dest.UsaTempoGaraTestoApplicare, opt => opt.MapFrom(src => src.AddRaceTime)) .ForMember(dest => dest.OverwriteFiles, opt => opt.MapFrom(src => src.OverwriteImages)) - + // Additional settings .ForMember(dest => dest.UsaOrarioMiniatura, opt => opt.MapFrom(src => src.ThumbnailOption == ImageCatalog_2.DataModel.ThumbnailOptionEnum.Time)) .ForMember(dest => dest.DataPartenza, opt => opt.MapFrom(src => src.RaceStartDate)) .ForMember(dest => dest.TestoOrario, opt => opt.MapFrom(src => src.TimeLabel)) .ForMember(dest => dest.TestoMin, opt => opt.MapFrom(src => src.ThumbnailOption == ImageCatalog_2.DataModel.ThumbnailOptionEnum.FileName)) - + // Thumbnail text options .ForMember(dest => dest.AggiungiScritteMiniature, opt => opt.MapFrom(src => src.ThumbnailOption == ImageCatalog_2.DataModel.ThumbnailOptionEnum.Text)) .ForMember(dest => dest.AggTempoGaraMin, opt => opt.MapFrom(src => src.ThumbnailOption == ImageCatalog_2.DataModel.ThumbnailOptionEnum.RaceTime)) .ForMember(dest => dest.AggNumTempMin, opt => opt.MapFrom(src => src.ThumbnailOption == ImageCatalog_2.DataModel.ThumbnailOptionEnum.FileNameAndTime)) - + // Ignore unmapped properties .ForMember(dest => dest.DestDir, opt => opt.Ignore()) .ForMember(dest => dest.SecretDefault, opt => opt.Ignore()) @@ -92,27 +91,4 @@ public class DataModelMappingProfile : Profile .ForMember(dest => dest.FotoRuotaASinistra, opt => opt.Ignore()) .ForMember(dest => dest.TempMinText, opt => opt.Ignore()); } - - private static Rgba32 ParseColor(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return new Rgba32(255, 255, 0, 255); - } - - try - { - var normalized = value.Trim(); - if (normalized.Length == 6 && normalized.All(Uri.IsHexDigit)) - { - normalized = "#" + normalized; - } - - return Color.Parse(normalized).ToPixel(); - } - catch - { - return new Rgba32(255, 255, 0, 255); - } - } } diff --git a/imagecatalog/Models/SettingsDto.cs b/imagecatalog/Models/SettingsDto.cs index 41c7dd5..fd68763 100644 --- a/imagecatalog/Models/SettingsDto.cs +++ b/imagecatalog/Models/SettingsDto.cs @@ -192,6 +192,11 @@ namespace ImageCatalog_2.Models [XmlElement("UsaColoreTrasparente")] public bool UseTransparentColor { get; set; } = false; + // Selected image processing library (e.g., "System.Graphics" or "ImageSharp") + [JsonPropertyName("ImageLibrary")] + [XmlElement("ImageLibrary")] + public string ImageLibrary { get; set; } = "ImageSharp"; + // Options [JsonPropertyName("ForceJpeg")] [XmlElement("GeneraleForzaJpg")] diff --git a/imagecatalog/Program.cs b/imagecatalog/Program.cs index aa02e40..798a03d 100644 --- a/imagecatalog/Program.cs +++ b/imagecatalog/Program.cs @@ -143,7 +143,13 @@ static class Program services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); +#if WINDOWS + services.AddTransient(); +#endif + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(sp => sp.GetRequiredService()); var userPrefsPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ImageCatalog", "userprefs.xml");