From f2a040bf226bf5955c0291ac8fe8a2b84f31abc5 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 25 Oct 2026 13:43:29 -0500 Subject: [PATCH 1/5] Extract testable Core and Windows production libraries Move portable builders, parsers, models, and workflow policies into Core. Isolate rendering, image processing, persistence, and backup/export logic in a headless Windows library, with WinUI adapters and dependency injection wiring in the app. Harden URI/CSV handling, Unicode truncation, QR alignment and layout calculations, image lifetime, and atomic persistence while preserving legacy storage compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/Services/IFileService.cs | 4 +- .../Enums.cs | 2 +- .../Helpers/AsyncBatchProcessor.cs | 43 + .../Helpers/CsvParser.cs | 31 +- .../Helpers/DelimitedSpreadsheetHelper.cs | 43 + .../Helpers/EmailBuilderHelper.cs | 5 +- .../Helpers/GeneratedIdGenerator.cs | 0 Simple QR Code Maker.Core/Helpers/Json.cs | 6 +- .../Helpers/PrintLayoutHelper.cs | 26 +- .../Helpers/QrAlignmentPatternHelper.cs | 30 +- .../Helpers/QrDocumentHelper.cs | 52 + .../Helpers/QrFrameTextResolver.cs | 3 + .../Helpers/RedirectorWarningHelper.cs | 0 .../Helpers/SpreadsheetImportPolicy.cs | 85 ++ .../Helpers/StringHelpers.cs | 25 +- .../Helpers/UrlBuilderHelper.cs | 12 +- .../Helpers/VCardBuilderHelper.cs | 36 +- .../Helpers/WifiBuilderHelper.cs | 0 .../Models/DecodingSourceKind.cs | 0 .../Models/EmailBuilderState.cs | 0 .../Models/GeneratedIdOptions.cs | 0 .../Models/PrintJobSettings.cs | 1 + .../Models/PrintPageLayout.cs | 0 .../Models/PrintPageType.cs | 0 .../Models/PrintPageTypeHelper.cs | 6 +- .../Models/QrFramePreset.cs | 0 .../Models/QrFramePresetCatalog.cs | 0 .../Models/QrFramePresetOption.cs | 0 .../Models/QrFrameTextSource.cs | 0 .../Models/QrImageLayoutMetrics.cs | 10 +- .../Models/RedirectorUrlClassification.cs | 0 .../Models/SpreadsheetGeneratedIdFormat.cs | 0 .../SpreadsheetGeneratedIdFormatOption.cs | 0 .../Models/SpreadsheetIdWriteRequest.cs | 0 .../Models/SpreadsheetIdWriteRow.cs | 0 .../Models/SpreadsheetReadResult.cs | 0 .../Models/SpreadsheetSourceRow.cs | 0 .../Models/UrlBuilderLine.cs | 0 .../Models/UrlBuilderQueryParameter.cs | 0 .../Models/VCardBuilderState.cs | 0 .../Models/VCardOptionalFieldKind.cs | 0 .../Models/WifiBuilderState.cs | 0 .../Services/FileService.cs | 4 +- .../Simple QR Code Maker.Core.csproj | 7 + .../Contracts/Services/IBrandService.cs | 0 .../Contracts/Services/IHistoryService.cs | 2 + .../Contracts/Services/IHistoryStores.cs | 31 + .../Services/ILocalSettingsService.cs | 0 .../Contracts/Services/IPersistenceStorage.cs | 42 + .../Helpers/BackupArchiveHelper.cs | 170 +++ .../Helpers/ColorHelpers.cs | 0 .../Helpers/ImageProcessingHelper.cs | 22 +- .../Helpers/QrCodeDesignStateMapper.cs | 0 .../Helpers/QrCodeRenderer.cs | 1344 +++++++++++++++++ .../Helpers/QrExportWriter.cs | 94 ++ .../RedirectorWarningSettingsHelper.cs | 0 .../Helpers/RuntimeHelper.cs | 8 + .../Helpers/SettingsStorageExtensions.cs | 44 +- .../Helpers/SettingsStoragePaths.cs | 14 + .../Models/BrandCreationOptions.cs | 0 .../Models/BrandItem.cs | 4 +- .../Models/BrandJsonContext.cs | 0 .../Models/BrandJsonSerializerOptions.cs | 0 .../Models/ColorJsonConverter.cs | 0 .../Models/CommonBrandCatalog.cs | 0 .../Models/CommonBrandPreset.cs | 0 .../Models/DecodingHistoryItem.cs | 0 .../Models/DecodingHistoryJsonContext.cs | 0 .../DecodingHistoryJsonSerializerOptions.cs | 0 .../Models/EmojiLogoStyle.cs | 0 .../Models/ErrorCorrectionOptions.cs | 0 .../Models/HistoryItem.cs | 4 +- .../Models/HistoryJsonContext.cs | 0 .../Models/HistoryJsonSerializerOptions.cs | 0 .../Models/LocalSettingsJsonContext.cs | 0 .../Models/LocalSettingsOptions.cs | 0 .../Models/QrCodeDesignState.cs | 0 .../Models/QrCodeSizeRecommendation.cs | 0 .../Models/QrRenderSettingsSnapshot.cs | 2 +- .../Models/RequestedQrCodeItem.cs | 2 +- .../Services/AtomicJsonFileStore.cs | 71 + .../Services/BrandService.cs | 13 +- .../Services/BrandStore.cs | 47 + .../Services/DecodingHistoryStore.cs | 160 ++ .../Services/HistoryImageStore.cs | 37 + .../Services/HistoryService.cs | 66 + .../Services/HistoryStore.cs | 185 +++ .../Services/LocalSettingsService.cs | 31 + .../Services/QrPdfRenderer.cs | 360 +++++ .../Services/SettingsStores.cs | 98 ++ .../Simple QR Code Maker.Windows.csproj | 28 + Simple QR Code Maker.slnx | 4 + Simple QR Code Maker/App.xaml.cs | 44 +- .../Helpers/BarcodeHelpers.cs | 1341 +--------------- .../Helpers/BrandStorageHelper.cs | 69 +- .../Helpers/DecodingHistoryStorageHelper.cs | 136 +- .../Helpers/ExcelSpreadsheetHelper.cs | 81 +- .../Helpers/HistoryStorageHelper.cs | 517 +------ .../Services/HistoryService.cs | 47 - .../Services/LocalSettingsService.cs | 87 -- Simple QR Code Maker/Services/PrintService.cs | 467 +----- .../Services/QrExportService.cs | 146 +- .../Simple QR Code Maker.csproj | 7 +- .../ViewModels/DecodingViewModel.cs | 46 +- .../ViewModels/MainViewModel.cs | 82 +- .../ViewModels/SettingsViewModel.cs | 251 +-- .../ViewModels/SpreadsheetImportViewModel.cs | 95 +- 107 files changed, 3398 insertions(+), 3332 deletions(-) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Enums.cs (93%) create mode 100644 Simple QR Code Maker.Core/Helpers/AsyncBatchProcessor.cs rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/CsvParser.cs (78%) create mode 100644 Simple QR Code Maker.Core/Helpers/DelimitedSpreadsheetHelper.cs rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/EmailBuilderHelper.cs (94%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/GeneratedIdGenerator.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/PrintLayoutHelper.cs (88%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/QrAlignmentPatternHelper.cs (80%) create mode 100644 Simple QR Code Maker.Core/Helpers/QrDocumentHelper.cs rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/QrFrameTextResolver.cs (98%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/RedirectorWarningHelper.cs (100%) create mode 100644 Simple QR Code Maker.Core/Helpers/SpreadsheetImportPolicy.cs rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/StringHelpers.cs (65%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/UrlBuilderHelper.cs (96%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/VCardBuilderHelper.cs (91%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Helpers/WifiBuilderHelper.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/DecodingSourceKind.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/EmailBuilderState.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/GeneratedIdOptions.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/PrintJobSettings.cs (96%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/PrintPageLayout.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/PrintPageType.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/PrintPageTypeHelper.cs (87%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/QrFramePreset.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/QrFramePresetCatalog.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/QrFramePresetOption.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/QrFrameTextSource.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/QrImageLayoutMetrics.cs (54%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/RedirectorUrlClassification.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/SpreadsheetGeneratedIdFormat.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/SpreadsheetGeneratedIdFormatOption.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/SpreadsheetIdWriteRequest.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/SpreadsheetIdWriteRow.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/SpreadsheetReadResult.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/SpreadsheetSourceRow.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/UrlBuilderLine.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/UrlBuilderQueryParameter.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/VCardBuilderState.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/VCardOptionalFieldKind.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Core}/Models/WifiBuilderState.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Contracts/Services/IBrandService.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Contracts/Services/IHistoryService.cs (92%) create mode 100644 Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Contracts/Services/ILocalSettingsService.cs (100%) create mode 100644 Simple QR Code Maker.Windows/Contracts/Services/IPersistenceStorage.cs create mode 100644 Simple QR Code Maker.Windows/Helpers/BackupArchiveHelper.cs rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Helpers/ColorHelpers.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Helpers/ImageProcessingHelper.cs (94%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Helpers/QrCodeDesignStateMapper.cs (100%) create mode 100644 Simple QR Code Maker.Windows/Helpers/QrCodeRenderer.cs create mode 100644 Simple QR Code Maker.Windows/Helpers/QrExportWriter.cs rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Helpers/RedirectorWarningSettingsHelper.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Helpers/RuntimeHelper.cs (64%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Helpers/SettingsStorageExtensions.cs (70%) create mode 100644 Simple QR Code Maker.Windows/Helpers/SettingsStoragePaths.cs rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/BrandCreationOptions.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/BrandItem.cs (91%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/BrandJsonContext.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/BrandJsonSerializerOptions.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/ColorJsonConverter.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/CommonBrandCatalog.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/CommonBrandPreset.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/DecodingHistoryItem.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/DecodingHistoryJsonContext.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/DecodingHistoryJsonSerializerOptions.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/EmojiLogoStyle.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/ErrorCorrectionOptions.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/HistoryItem.cs (93%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/HistoryJsonContext.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/HistoryJsonSerializerOptions.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/LocalSettingsJsonContext.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/LocalSettingsOptions.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/QrCodeDesignState.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/QrCodeSizeRecommendation.cs (100%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/QrRenderSettingsSnapshot.cs (98%) rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Models/RequestedQrCodeItem.cs (70%) create mode 100644 Simple QR Code Maker.Windows/Services/AtomicJsonFileStore.cs rename {Simple QR Code Maker => Simple QR Code Maker.Windows}/Services/BrandService.cs (82%) create mode 100644 Simple QR Code Maker.Windows/Services/BrandStore.cs create mode 100644 Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs create mode 100644 Simple QR Code Maker.Windows/Services/HistoryImageStore.cs create mode 100644 Simple QR Code Maker.Windows/Services/HistoryService.cs create mode 100644 Simple QR Code Maker.Windows/Services/HistoryStore.cs create mode 100644 Simple QR Code Maker.Windows/Services/LocalSettingsService.cs create mode 100644 Simple QR Code Maker.Windows/Services/QrPdfRenderer.cs create mode 100644 Simple QR Code Maker.Windows/Services/SettingsStores.cs create mode 100644 Simple QR Code Maker.Windows/Simple QR Code Maker.Windows.csproj delete mode 100644 Simple QR Code Maker/Services/HistoryService.cs delete mode 100644 Simple QR Code Maker/Services/LocalSettingsService.cs diff --git a/Simple QR Code Maker.Core/Contracts/Services/IFileService.cs b/Simple QR Code Maker.Core/Contracts/Services/IFileService.cs index 82ce7c6..3e35da0 100644 --- a/Simple QR Code Maker.Core/Contracts/Services/IFileService.cs +++ b/Simple QR Code Maker.Core/Contracts/Services/IFileService.cs @@ -2,9 +2,9 @@ public interface IFileService { - T Read(string folderPath, string fileName); + T? Read(string folderPath, string fileName); void Save(string folderPath, string fileName, T content); - void Delete(string folderPath, string fileName); + void Delete(string folderPath, string? fileName); } diff --git a/Simple QR Code Maker/Enums.cs b/Simple QR Code Maker.Core/Enums.cs similarity index 93% rename from Simple QR Code Maker/Enums.cs rename to Simple QR Code Maker.Core/Enums.cs index b29a713..5608af6 100644 --- a/Simple QR Code Maker/Enums.cs +++ b/Simple QR Code Maker.Core/Enums.cs @@ -1,4 +1,4 @@ -namespace Simple_QR_Code_Maker; +namespace Simple_QR_Code_Maker; public enum FileKind { diff --git a/Simple QR Code Maker.Core/Helpers/AsyncBatchProcessor.cs b/Simple QR Code Maker.Core/Helpers/AsyncBatchProcessor.cs new file mode 100644 index 0000000..f63c116 --- /dev/null +++ b/Simple QR Code Maker.Core/Helpers/AsyncBatchProcessor.cs @@ -0,0 +1,43 @@ +namespace Simple_QR_Code_Maker.Helpers; + +public static class AsyncBatchProcessor +{ + public static async Task RunAsync( + IReadOnlyList items, + int maxConcurrency, + Func process, + Action? updateProgress = null, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxConcurrency); + ArgumentNullException.ThrowIfNull(process); + using SemaphoreSlim semaphore = new(maxConcurrency, maxConcurrency); + int completed = 0; + await Task.WhenAll(items.Select(ProcessAsync)); + + async Task ProcessAsync(T item) + { + await semaphore.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + await process(item); + } + finally + { + try + { + if (updateProgress is not null) + { + lock (semaphore) + updateProgress(++completed); + } + } + finally + { + semaphore.Release(); + } + } + } + } +} diff --git a/Simple QR Code Maker/Helpers/CsvParser.cs b/Simple QR Code Maker.Core/Helpers/CsvParser.cs similarity index 78% rename from Simple QR Code Maker/Helpers/CsvParser.cs rename to Simple QR Code Maker.Core/Helpers/CsvParser.cs index 1448097..4df944a 100644 --- a/Simple QR Code Maker/Helpers/CsvParser.cs +++ b/Simple QR Code Maker.Core/Helpers/CsvParser.cs @@ -13,16 +13,15 @@ public static class CsvParser /// public static List> Parse(string csv, char delimiter = ',') { + ValidateDelimiter(delimiter); List> rows = []; if (string.IsNullOrEmpty(csv)) return rows; - // Normalise line endings to LF only so we can treat \n as the row separator. - csv = csv.Replace("\r\n", "\n").Replace('\r', '\n'); - List currentRow = []; StringBuilder currentField = new(); bool inQuotes = false; + bool fieldStarted = false; int i = 0; while (i < csv.Length) @@ -57,6 +56,7 @@ public static List> Parse(string csv, char delimiter = ',') if (c == '"') { inQuotes = true; + fieldStarted = true; i++; } else if (c == delimiter) @@ -65,13 +65,16 @@ public static List> Parse(string csv, char delimiter = ',') currentField.Clear(); i++; } - else if (c == '\n') + else if (c is '\r' or '\n') { currentRow.Add(currentField.ToString()); currentField.Clear(); rows.Add(currentRow); currentRow = []; + fieldStarted = false; i++; + if (c == '\r' && i < csv.Length && csv[i] == '\n') + i++; } else { @@ -82,17 +85,19 @@ public static List> Parse(string csv, char delimiter = ',') } // Flush the last field and row (file may not end with a newline). - currentRow.Add(currentField.ToString()); - // Only add the last row if it is non-empty (avoids a phantom blank row for - // files that do end with a newline). - if (currentRow.Count > 1 || (currentRow.Count == 1 && currentRow[0].Length > 0)) + if (currentRow.Count > 0 || currentField.Length > 0 || fieldStarted) + { + currentRow.Add(currentField.ToString()); rows.Add(currentRow); + } return rows; } public static string Serialize(IEnumerable> rows, char delimiter = ',') { + ArgumentNullException.ThrowIfNull(rows); + ValidateDelimiter(delimiter); StringBuilder builder = new(); bool isFirstRow = true; @@ -106,7 +111,9 @@ public static string Serialize(IEnumerable> rows, char del if (index > 0) builder.Append(delimiter); - builder.Append(EscapeField(row[index], delimiter)); + builder.Append(row.Count == 1 && row[index].Length == 0 + ? "\"\"" + : EscapeField(row[index], delimiter)); } isFirstRow = false; @@ -115,6 +122,12 @@ public static string Serialize(IEnumerable> rows, char del return builder.ToString(); } + private static void ValidateDelimiter(char delimiter) + { + if (delimiter is '"' or '\r' or '\n') + throw new ArgumentOutOfRangeException(nameof(delimiter), "A delimiter cannot be a quote or newline."); + } + private static string EscapeField(string field, char delimiter) { bool requiresQuotes = field.Contains(delimiter) diff --git a/Simple QR Code Maker.Core/Helpers/DelimitedSpreadsheetHelper.cs b/Simple QR Code Maker.Core/Helpers/DelimitedSpreadsheetHelper.cs new file mode 100644 index 0000000..54d2ae0 --- /dev/null +++ b/Simple QR Code Maker.Core/Helpers/DelimitedSpreadsheetHelper.cs @@ -0,0 +1,43 @@ +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Helpers; + +public static class DelimitedSpreadsheetHelper +{ + public static async Task ReadAsync(string filePath, char delimiter) + { + string contents = await File.ReadAllTextAsync(filePath); + List> rows = CsvParser.Parse(contents, delimiter); + return new SpreadsheetReadResult + { + Rows = [.. rows.Select((row, index) => new SpreadsheetSourceRow + { + SourceRowIndex = index, + Cells = [.. row], + })], + }; + } + + public static async Task WriteGeneratedIdsAsync(string filePath, char delimiter, SpreadsheetIdWriteRequest request) + { + string contents = await File.ReadAllTextAsync(filePath); + List> rows = CsvParser.Parse(contents, delimiter); + SpreadsheetImportPolicy.ApplyGeneratedIds(rows, request); + string updatedContents = CsvParser.Serialize(rows, delimiter); + string fullPath = Path.GetFullPath(filePath); + string directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("Unable to determine the spreadsheet file directory."); + string tempFilePath = Path.Combine(directory, $"{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllTextAsync(tempFilePath, updatedContents); + File.Replace(tempFilePath, fullPath, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + finally + { + if (File.Exists(tempFilePath)) + File.Delete(tempFilePath); + } + } +} diff --git a/Simple QR Code Maker/Helpers/EmailBuilderHelper.cs b/Simple QR Code Maker.Core/Helpers/EmailBuilderHelper.cs similarity index 94% rename from Simple QR Code Maker/Helpers/EmailBuilderHelper.cs rename to Simple QR Code Maker.Core/Helpers/EmailBuilderHelper.cs index 02835c4..66924df 100644 --- a/Simple QR Code Maker/Helpers/EmailBuilderHelper.cs +++ b/Simple QR Code Maker.Core/Helpers/EmailBuilderHelper.cs @@ -56,7 +56,10 @@ public static string Serialize(EmailBuilderState state) StringBuilder builder = new(); builder.Append(MailtoPrefix); - builder.Append(state.To.Trim()); + builder.Append(Uri.EscapeDataString(state.To.Trim()) + .Replace("%40", "@") + .Replace("%2C", ",") + .Replace("%3B", ";")); List queryParts = []; diff --git a/Simple QR Code Maker/Helpers/GeneratedIdGenerator.cs b/Simple QR Code Maker.Core/Helpers/GeneratedIdGenerator.cs similarity index 100% rename from Simple QR Code Maker/Helpers/GeneratedIdGenerator.cs rename to Simple QR Code Maker.Core/Helpers/GeneratedIdGenerator.cs diff --git a/Simple QR Code Maker.Core/Helpers/Json.cs b/Simple QR Code Maker.Core/Helpers/Json.cs index b86464c..265b06c 100644 --- a/Simple QR Code Maker.Core/Helpers/Json.cs +++ b/Simple QR Code Maker.Core/Helpers/Json.cs @@ -10,15 +10,15 @@ public static class Json TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; - public static async Task ToObjectAsync(string value) + public static async Task ToObjectAsync(string value) { - return await Task.Run(() => + return await Task.Run(() => { return JsonSerializer.Deserialize(value, _options); }); } - public static async Task StringifyAsync(object value) + public static async Task StringifyAsync(object? value) { return await Task.Run(() => { diff --git a/Simple QR Code Maker/Helpers/PrintLayoutHelper.cs b/Simple QR Code Maker.Core/Helpers/PrintLayoutHelper.cs similarity index 88% rename from Simple QR Code Maker/Helpers/PrintLayoutHelper.cs rename to Simple QR Code Maker.Core/Helpers/PrintLayoutHelper.cs index dbc61be..3dadc32 100644 --- a/Simple QR Code Maker/Helpers/PrintLayoutHelper.cs +++ b/Simple QR Code Maker.Core/Helpers/PrintLayoutHelper.cs @@ -51,6 +51,11 @@ internal static PrintLayoutMetrics CreateMetrics(double pageWidthPoints, double internal static PrintLayoutMetrics CreateMetrics(double pageWidthPoints, double pageHeightPoints, PrintJobSettings settings, QrImageLayoutMetrics imageLayoutMetrics) { + if (!double.IsFinite(pageWidthPoints) || pageWidthPoints <= 0) + throw new ArgumentOutOfRangeException(nameof(pageWidthPoints)); + if (!double.IsFinite(pageHeightPoints) || pageHeightPoints <= 0) + throw new ArgumentOutOfRangeException(nameof(pageHeightPoints)); + PrintJobSettings normalizedSettings = settings.Normalize(); double marginPoints = MillimetersToPoints(normalizedSettings.MarginMm); double spacingPoints = MillimetersToPoints(normalizedSettings.SpacingMm); @@ -94,22 +99,27 @@ internal static double CalculateActualCodeSizePoints(double cellWidth, double ce internal static PrintCodePlacement CalculateCodePlacement(double cellWidth, double cellHeight, PrintJobSettings settings, QrImageLayoutMetrics imageLayoutMetrics) { + if (!double.IsFinite(cellWidth)) + throw new ArgumentOutOfRangeException(nameof(cellWidth)); + if (!double.IsFinite(cellHeight)) + throw new ArgumentOutOfRangeException(nameof(cellHeight)); + PrintJobSettings normalizedSettings = settings.Normalize(); double reservedLabelHeight = normalizedSettings.ShowLabels ? LabelHeightPoints + LabelSpacingPoints : 0; double paddedWidth = Math.Max(cellWidth - (CellPaddingPoints * 2), 1); double paddedHeight = Math.Max(cellHeight - (CellPaddingPoints * 2), 1); double imageAreaHeight = Math.Max(paddedHeight - reservedLabelHeight, 1); double requestedCodeSizePoints = MillimetersToPoints(normalizedSettings.CodeSizeMm); - double requestedImageWidth = requestedCodeSizePoints * imageLayoutMetrics.WidthPerQrSize; - double requestedImageHeight = requestedCodeSizePoints * imageLayoutMetrics.HeightPerQrSize; - double widthScale = requestedImageWidth <= 0 ? 1 : paddedWidth / requestedImageWidth; - double heightScale = requestedImageHeight <= 0 ? 1 : imageAreaHeight / requestedImageHeight; - double appliedScale = Math.Max(Math.Min(Math.Min(widthScale, heightScale), 1), 0); + double actualCodeSizePoints = Math.Min( + requestedCodeSizePoints, + Math.Min( + paddedWidth / imageLayoutMetrics.WidthPerQrSize, + imageAreaHeight / imageLayoutMetrics.HeightPerQrSize)); return new PrintCodePlacement( - Math.Max(requestedImageWidth * appliedScale, 1), - Math.Max(requestedImageHeight * appliedScale, 1), - Math.Max(requestedCodeSizePoints * appliedScale, 1)); + Math.Max(actualCodeSizePoints * imageLayoutMetrics.WidthPerQrSize, 1), + Math.Max(actualCodeSizePoints * imageLayoutMetrics.HeightPerQrSize, 1), + Math.Max(actualCodeSizePoints, 1)); } internal static double MillimetersToPoints(double millimeters) => millimeters * PointsPerInch / MillimetersPerInch; diff --git a/Simple QR Code Maker/Helpers/QrAlignmentPatternHelper.cs b/Simple QR Code Maker.Core/Helpers/QrAlignmentPatternHelper.cs similarity index 80% rename from Simple QR Code Maker/Helpers/QrAlignmentPatternHelper.cs rename to Simple QR Code Maker.Core/Helpers/QrAlignmentPatternHelper.cs index 77c742f..4121dde 100644 --- a/Simple QR Code Maker/Helpers/QrAlignmentPatternHelper.cs +++ b/Simple QR Code Maker.Core/Helpers/QrAlignmentPatternHelper.cs @@ -42,14 +42,19 @@ public static class QrAlignmentPatternHelper [6, 30, 58, 86, 114, 142], // v33 [6, 34, 62, 90, 118, 146], // v34 [6, 30, 54, 78, 102, 126, 150], // v35 - [6, 26, 52, 78, 104, 130, 156], // v36 - [6, 30, 56, 82, 108, 134, 160], // v37 - [6, 34, 60, 86, 112, 138, 164], // v38 - [6, 30, 58, 86, 114, 142, 168], // v39 - [6, 32, 62, 92, 122, 152, 182], // v40 + [6, 24, 50, 76, 102, 128, 154], // v36 + [6, 28, 54, 80, 106, 132, 158], // v37 + [6, 32, 58, 84, 110, 136, 162], // v38 + [6, 26, 54, 82, 110, 138, 166], // v39 + [6, 30, 58, 86, 114, 142, 170], // v40 ]; - public static int GetDimension(int version) => version * 4 + 17; + public static int GetDimension(int version) + { + ArgumentOutOfRangeException.ThrowIfLessThan(version, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(version, 40); + return version * 4 + 17; + } // Returns all alignment pattern centers (moduleRow, moduleCol) for the given version, // excluding positions that overlap the three finder pattern + separator zones. @@ -63,7 +68,7 @@ public static class QrAlignmentPatternHelper return []; int dim = GetDimension(version); - var centers = new List<(int Row, int Col)>(); + List<(int Row, int Col)> centers = []; foreach (int r in positions) { @@ -85,6 +90,8 @@ public static class QrAlignmentPatternHelper public static (double X, double Y) GetIdealPixelCoord(int moduleRow, int moduleCol, int version, int outputSize) { int dim = GetDimension(version); + ValidateModulePosition(moduleRow, moduleCol, dim); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputSize); double x = (moduleCol + 0.5) * outputSize / dim; double y = (moduleRow + 0.5) * outputSize / dim; return (x, y); @@ -98,6 +105,7 @@ public static PointF EstimateImagePosition( int moduleRow, int moduleCol, int version) { int dim = GetDimension(version); + ValidateModulePosition(moduleRow, moduleCol, dim); // Normalized (u, v): u=0 is the left edge, u=1 is the right edge; v=0 top, v=1 bottom. // A module CENTER at (row, col) sits at (col+0.5)/dim of the total width/height. @@ -118,4 +126,12 @@ public static PointF EstimateImagePosition( return new PointF(x, y); } + + private static void ValidateModulePosition(int moduleRow, int moduleCol, int dimension) + { + ArgumentOutOfRangeException.ThrowIfNegative(moduleRow); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(moduleRow, dimension); + ArgumentOutOfRangeException.ThrowIfNegative(moduleCol); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(moduleCol, dimension); + } } diff --git a/Simple QR Code Maker.Core/Helpers/QrDocumentHelper.cs b/Simple QR Code Maker.Core/Helpers/QrDocumentHelper.cs new file mode 100644 index 0000000..2d3a6bb --- /dev/null +++ b/Simple QR Code Maker.Core/Helpers/QrDocumentHelper.cs @@ -0,0 +1,52 @@ +namespace Simple_QR_Code_Maker.Helpers; + +public static class QrDocumentHelper +{ + public static IEnumerable EnumerateTexts(string text, MultiLineCodeMode mode) + { + if (mode != MultiLineCodeMode.OneLineOneCode) + { + if (!string.IsNullOrWhiteSpace(text)) + yield return text.Trim(); + yield break; + } + + foreach (string line in text.Split(["\r\n", "\r", "\n"], StringSplitOptions.None)) + { + if (!string.IsNullOrWhiteSpace(line)) + yield return line.Trim(); + } + } + + public static QrContentKind ClassifyContent(string? text) + => VCardBuilderHelper.IsVCard(text) ? QrContentKind.VCard + : WifiBuilderHelper.IsWifi(text) ? QrContentKind.WiFi + : EmailBuilderHelper.IsEmail(text) ? QrContentKind.Email + : QrContentKind.PlainText; + + public static bool MatchesBrandUrl(string content, string brandUrl) + { + string normalizedContent = NormalizeBrandUrl(content); + string pattern = NormalizeBrandUrl(brandUrl).TrimEnd('/'); + int authorityEnd = normalizedContent.IndexOfAny(['/', '?', '#']); + ReadOnlySpan authority = authorityEnd >= 0 ? normalizedContent.AsSpan(0, authorityEnd) : normalizedContent.AsSpan(); + if (authority.Contains('@')) + return false; + if (pattern.Length == 0 || !normalizedContent.StartsWith(pattern, StringComparison.Ordinal)) + return false; + return normalizedContent.Length == pattern.Length + || normalizedContent[pattern.Length] is '/' or '?' or '#' or ':'; + } + + private static string NormalizeBrandUrl(string url) + { + string normalized = url.Trim().ToLowerInvariant(); + if (normalized.StartsWith("https://", StringComparison.Ordinal)) + normalized = normalized[8..]; + else if (normalized.StartsWith("http://", StringComparison.Ordinal)) + normalized = normalized[7..]; + if (normalized.StartsWith("www.", StringComparison.Ordinal)) + normalized = normalized[4..]; + return normalized; + } +} diff --git a/Simple QR Code Maker/Helpers/QrFrameTextResolver.cs b/Simple QR Code Maker.Core/Helpers/QrFrameTextResolver.cs similarity index 98% rename from Simple QR Code Maker/Helpers/QrFrameTextResolver.cs rename to Simple QR Code Maker.Core/Helpers/QrFrameTextResolver.cs index 4613276..2577628 100644 --- a/Simple QR Code Maker/Helpers/QrFrameTextResolver.cs +++ b/Simple QR Code Maker.Core/Helpers/QrFrameTextResolver.cs @@ -155,6 +155,9 @@ private static string Truncate(string value, int maxLength) return value; int trimmedLength = Math.Max(maxLength - Ellipsis.Length, 1); + if (char.IsHighSurrogate(value[trimmedLength - 1])) + trimmedLength--; + return value[..trimmedLength].TrimEnd() + Ellipsis; } diff --git a/Simple QR Code Maker/Helpers/RedirectorWarningHelper.cs b/Simple QR Code Maker.Core/Helpers/RedirectorWarningHelper.cs similarity index 100% rename from Simple QR Code Maker/Helpers/RedirectorWarningHelper.cs rename to Simple QR Code Maker.Core/Helpers/RedirectorWarningHelper.cs diff --git a/Simple QR Code Maker.Core/Helpers/SpreadsheetImportPolicy.cs b/Simple QR Code Maker.Core/Helpers/SpreadsheetImportPolicy.cs new file mode 100644 index 0000000..4ee78e6 --- /dev/null +++ b/Simple QR Code Maker.Core/Helpers/SpreadsheetImportPolicy.cs @@ -0,0 +1,85 @@ +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Helpers; + +public static class SpreadsheetImportPolicy +{ + public static List CreateHeaders(IReadOnlyList rows, bool firstRowIsHeader) + { + int columnCount = rows.Count == 0 ? 0 : rows.Max(row => row.Cells.Count); + List headers = new(columnCount); + for (int index = 0; index < columnCount; index++) + { + headers.Add(firstRowIsHeader && index < rows[0].Cells.Count && !string.IsNullOrWhiteSpace(rows[0].Cells[index]) + ? rows[0].Cells[index] + : $"Column {index + 1}"); + } + + return headers; + } + + public static List SelectValues( + IEnumerable> rows, int columnIndex, string prefix, string suffix, bool removeDuplicates) + { + if (columnIndex < 0) + return []; + + IEnumerable values = rows + .Select(row => columnIndex < row.Count ? row[columnIndex] : string.Empty) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => $"{prefix}{value}{suffix}"); + + return [.. removeDuplicates ? values.Distinct(StringComparer.Ordinal) : values]; + } + + public static List NormalizeRow(IReadOnlyList cells, int columnCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(columnCount); + return [.. Enumerable.Range(0, columnCount).Select(index => GetCellValue(cells, index))]; + } + + public static string GetCellValue(IReadOnlyList cells, int index) + => index >= 0 && index < cells.Count ? cells[index] : string.Empty; + + public static bool ShouldGenerateId(IReadOnlyList cells, int idColumnIndex) + => cells.Where((value, index) => index != idColumnIndex).Any(value => !string.IsNullOrWhiteSpace(value)); + + public static int FindIdColumnIndex(IReadOnlyList headers) + { + for (int index = 0; index < headers.Count; index++) + { + if (string.Equals(headers[index].Trim(), "id", StringComparison.OrdinalIgnoreCase)) + return index; + } + + return -1; + } + + public static void ApplyGeneratedIds(List> rows, SpreadsheetIdWriteRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentOutOfRangeException.ThrowIfNegative(request.IdColumnIndex); + foreach (SpreadsheetIdWriteRow row in request.Rows) + ArgumentOutOfRangeException.ThrowIfNegative(row.SourceRowIndex); + + if (request.CreateIdColumn && request.FirstRowIsHeader) + { + EnsureCell(rows, 0, request.IdColumnIndex); + rows[0][request.IdColumnIndex] = "id"; + } + + foreach (SpreadsheetIdWriteRow row in request.Rows) + { + EnsureCell(rows, row.SourceRowIndex, request.IdColumnIndex); + rows[row.SourceRowIndex][request.IdColumnIndex] = row.Value; + } + } + + private static void EnsureCell(List> rows, int rowIndex, int columnIndex) + { + while (rows.Count <= rowIndex) + rows.Add([]); + while (rows[rowIndex].Count <= columnIndex) + rows[rowIndex].Add(string.Empty); + } +} diff --git a/Simple QR Code Maker/Helpers/StringHelpers.cs b/Simple QR Code Maker.Core/Helpers/StringHelpers.cs similarity index 65% rename from Simple QR Code Maker/Helpers/StringHelpers.cs rename to Simple QR Code Maker.Core/Helpers/StringHelpers.cs index 9ac6462..b143a58 100644 --- a/Simple QR Code Maker/Helpers/StringHelpers.cs +++ b/Simple QR Code Maker.Core/Helpers/StringHelpers.cs @@ -1,4 +1,4 @@ -using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Models; using System.Text; using System.Text.RegularExpressions; @@ -23,6 +23,10 @@ public static string ReplaceReservedCharacters(this string stringToClean) public static string ToSafeFileName(this string fullString) { + ArgumentNullException.ThrowIfNull(fullString); + if (fullString.Length == 0) + return "QR-Code"; + if (VCardBuilderHelper.TryParse(fullString, out VCardBuilderState state)) fullString = $"{VCardBuilderHelper.GetDisplayName(state)}-vCard"; else if (WifiBuilderHelper.TryParse(fullString, out WifiBuilderState wifiState)) @@ -33,15 +37,30 @@ public static string ToSafeFileName(this string fullString) bool userStringEndsInDash = fullString[^1] == '-'; string fileNameContent = fullString.ReplaceReservedCharacters(); if (fileNameContent.Length > 60) - fileNameContent = $"{fileNameContent[..28]}...{fileNameContent[^28..]}"; + { + int prefixLength = char.IsHighSurrogate(fileNameContent[27]) ? 27 : 28; + int suffixStart = fileNameContent.Length - 28; + if (char.IsLowSurrogate(fileNameContent[suffixStart])) + suffixStart++; + + fileNameContent = $"{fileNameContent[..prefixLength]}...{fileNameContent[suffixStart..]}"; + } // trim off a dash if it is the last char because of ReplaceReservedCharacters() if (!userStringEndsInDash && fileNameContent[^1] == '-') fileNameContent = fileNameContent[..^1]; - return fileNameContent; + if (fileNameContent.Length == 0) + return "QR-Code"; + + return WindowsDeviceNameRegex().IsMatch(fileNameContent) + ? fileNameContent + "-QR" + : fileNameContent; } [GeneratedRegex(@"-+")] private static partial Regex TrimMultiSpaceRegex(); + + [GeneratedRegex(@"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex WindowsDeviceNameRegex(); } diff --git a/Simple QR Code Maker/Helpers/UrlBuilderHelper.cs b/Simple QR Code Maker.Core/Helpers/UrlBuilderHelper.cs similarity index 96% rename from Simple QR Code Maker/Helpers/UrlBuilderHelper.cs rename to Simple QR Code Maker.Core/Helpers/UrlBuilderHelper.cs index 01f6b29..1acebf9 100644 --- a/Simple QR Code Maker/Helpers/UrlBuilderHelper.cs +++ b/Simple QR Code Maker.Core/Helpers/UrlBuilderHelper.cs @@ -67,11 +67,11 @@ public static string SerializeLine(UrlBuilderLine line) } bool hasAuthority = authority.Length > 0; + if ((hasAuthority && scheme.Length > 0) || line.UsesAuthorityDelimiter) + builder.Append("//"); + if (hasAuthority) { - if (scheme.Length > 0 || line.UsesAuthorityDelimiter) - builder.Append("//"); - builder.Append(authority); if (path.Length > 0) @@ -138,12 +138,6 @@ private static UrlBuilderLine ParseBaseParts(string basePart) return builderLine; } - if (builderLine.Scheme.Length > 0 && working.Contains('/')) - { - PopulateAuthorityAndPath(builderLine, working); - return builderLine; - } - if (builderLine.Scheme.Length == 0 && LooksLikeAuthority(working)) { PopulateAuthorityAndPath(builderLine, working); diff --git a/Simple QR Code Maker/Helpers/VCardBuilderHelper.cs b/Simple QR Code Maker.Core/Helpers/VCardBuilderHelper.cs similarity index 91% rename from Simple QR Code Maker/Helpers/VCardBuilderHelper.cs rename to Simple QR Code Maker.Core/Helpers/VCardBuilderHelper.cs index 8946c58..b335d3c 100644 --- a/Simple QR Code Maker/Helpers/VCardBuilderHelper.cs +++ b/Simple QR Code Maker.Core/Helpers/VCardBuilderHelper.cs @@ -22,8 +22,8 @@ public static bool TryParse(string? text, out VCardBuilderState state) if (unfoldedLines.Count < 3) return false; - if (!unfoldedLines.Any(static line => line.Equals(BeginLine, StringComparison.OrdinalIgnoreCase)) - || !unfoldedLines.Any(static line => line.Equals(EndLine, StringComparison.OrdinalIgnoreCase))) + if (!unfoldedLines[0].Equals(BeginLine, StringComparison.OrdinalIgnoreCase) + || !unfoldedLines[^1].Equals(EndLine, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -52,7 +52,7 @@ public static bool TryParse(string? text, out VCardBuilderState state) state.FormattedName = propertyValue; break; case "N": - ApplyStructuredName(state, propertyValue); + ApplyStructuredName(state, valuePart); break; case "ORG": state.Organization = propertyValue; @@ -76,7 +76,7 @@ public static bool TryParse(string? text, out VCardBuilderState state) state.Birthday = propertyValue; break; case "ADR": - ApplyAddress(state, propertyValue); + ApplyAddress(state, valuePart); break; case "NOTE": state.Note = propertyValue; @@ -251,7 +251,7 @@ private static string GetPropertyName(string propertyPart) private static void ApplyStructuredName(VCardBuilderState state, string value) { - string[] parts = value.Split(';', StringSplitOptions.None); + IReadOnlyList parts = SplitStructuredValue(value); state.LastName = GetPart(parts, 0); state.FirstName = GetPart(parts, 1); state.MiddleName = GetPart(parts, 2); @@ -291,7 +291,7 @@ private static void ApplyPhoneValue(VCardBuilderState state, string propertyPart private static void ApplyAddress(VCardBuilderState state, string value) { - string[] parts = value.Split(';', StringSplitOptions.None); + IReadOnlyList parts = SplitStructuredValue(value); state.StreetAddress = GetPart(parts, 2); state.Locality = GetPart(parts, 3); state.Region = GetPart(parts, 4); @@ -301,6 +301,7 @@ private static void ApplyAddress(VCardBuilderState state, string value) private static string EscapeValue(string value) { + value = value.Replace("\r\n", "\n").Replace('\r', '\n'); StringBuilder builder = new(value.Length); foreach (char character in value) { @@ -315,8 +316,6 @@ private static string EscapeValue(string value) case ',': builder.Append(@"\,"); break; - case '\r': - break; case '\n': builder.Append(@"\n"); break; @@ -365,4 +364,25 @@ private static string GetPart(IReadOnlyList parts, int index) { return index < parts.Count ? UnescapeValue(parts[index]) : string.Empty; } + + private static IReadOnlyList SplitStructuredValue(string value) + { + List parts = []; + int start = 0; + for (int index = 0; index < value.Length; index++) + { + if (value[index] == '\\' && index + 1 < value.Length) + { + index++; + } + else if (value[index] == ';') + { + parts.Add(value[start..index]); + start = index + 1; + } + } + + parts.Add(value[start..]); + return parts; + } } diff --git a/Simple QR Code Maker/Helpers/WifiBuilderHelper.cs b/Simple QR Code Maker.Core/Helpers/WifiBuilderHelper.cs similarity index 100% rename from Simple QR Code Maker/Helpers/WifiBuilderHelper.cs rename to Simple QR Code Maker.Core/Helpers/WifiBuilderHelper.cs diff --git a/Simple QR Code Maker/Models/DecodingSourceKind.cs b/Simple QR Code Maker.Core/Models/DecodingSourceKind.cs similarity index 100% rename from Simple QR Code Maker/Models/DecodingSourceKind.cs rename to Simple QR Code Maker.Core/Models/DecodingSourceKind.cs diff --git a/Simple QR Code Maker/Models/EmailBuilderState.cs b/Simple QR Code Maker.Core/Models/EmailBuilderState.cs similarity index 100% rename from Simple QR Code Maker/Models/EmailBuilderState.cs rename to Simple QR Code Maker.Core/Models/EmailBuilderState.cs diff --git a/Simple QR Code Maker/Models/GeneratedIdOptions.cs b/Simple QR Code Maker.Core/Models/GeneratedIdOptions.cs similarity index 100% rename from Simple QR Code Maker/Models/GeneratedIdOptions.cs rename to Simple QR Code Maker.Core/Models/GeneratedIdOptions.cs diff --git a/Simple QR Code Maker/Models/PrintJobSettings.cs b/Simple QR Code Maker.Core/Models/PrintJobSettings.cs similarity index 96% rename from Simple QR Code Maker/Models/PrintJobSettings.cs rename to Simple QR Code Maker.Core/Models/PrintJobSettings.cs index cf7609d..7f1d770 100644 --- a/Simple QR Code Maker/Models/PrintJobSettings.cs +++ b/Simple QR Code Maker.Core/Models/PrintJobSettings.cs @@ -33,6 +33,7 @@ public PrintJobSettings Normalize() { CodesPerPage = NormalizeCodesPerPage(CodesPerPage), PageType = PrintPageTypeHelper.Resolve(PageType), + PageLayout = Enum.IsDefined(PageLayout) ? PageLayout : DefaultPageLayout, MarginMm = NormalizeMillimeters(MarginMm, DefaultMarginMm, MinimumMarginMm, MaximumMarginMm), CodeSizeMm = NormalizeMillimeters(CodeSizeMm, DefaultCodeSizeMm, MinimumCodeSizeMm, MaximumCodeSizeMm), SpacingMm = NormalizeMillimeters(SpacingMm, DefaultSpacingMm, MinimumSpacingMm, MaximumSpacingMm), diff --git a/Simple QR Code Maker/Models/PrintPageLayout.cs b/Simple QR Code Maker.Core/Models/PrintPageLayout.cs similarity index 100% rename from Simple QR Code Maker/Models/PrintPageLayout.cs rename to Simple QR Code Maker.Core/Models/PrintPageLayout.cs diff --git a/Simple QR Code Maker/Models/PrintPageType.cs b/Simple QR Code Maker.Core/Models/PrintPageType.cs similarity index 100% rename from Simple QR Code Maker/Models/PrintPageType.cs rename to Simple QR Code Maker.Core/Models/PrintPageType.cs diff --git a/Simple QR Code Maker/Models/PrintPageTypeHelper.cs b/Simple QR Code Maker.Core/Models/PrintPageTypeHelper.cs similarity index 87% rename from Simple QR Code Maker/Models/PrintPageTypeHelper.cs rename to Simple QR Code Maker.Core/Models/PrintPageTypeHelper.cs index bad8e8c..2353ee9 100644 --- a/Simple QR Code Maker/Models/PrintPageTypeHelper.cs +++ b/Simple QR Code Maker.Core/Models/PrintPageTypeHelper.cs @@ -21,9 +21,9 @@ public static class PrintPageTypeHelper public static PrintPageType Resolve(PrintPageType pageType) { - return pageType == PrintPageType.Auto - ? GetRegionalDefault() - : pageType; + return pageType != PrintPageType.Auto && Enum.IsDefined(pageType) + ? pageType + : GetRegionalDefault(); } public static PrintPageType GetRegionalDefault() diff --git a/Simple QR Code Maker/Models/QrFramePreset.cs b/Simple QR Code Maker.Core/Models/QrFramePreset.cs similarity index 100% rename from Simple QR Code Maker/Models/QrFramePreset.cs rename to Simple QR Code Maker.Core/Models/QrFramePreset.cs diff --git a/Simple QR Code Maker/Models/QrFramePresetCatalog.cs b/Simple QR Code Maker.Core/Models/QrFramePresetCatalog.cs similarity index 100% rename from Simple QR Code Maker/Models/QrFramePresetCatalog.cs rename to Simple QR Code Maker.Core/Models/QrFramePresetCatalog.cs diff --git a/Simple QR Code Maker/Models/QrFramePresetOption.cs b/Simple QR Code Maker.Core/Models/QrFramePresetOption.cs similarity index 100% rename from Simple QR Code Maker/Models/QrFramePresetOption.cs rename to Simple QR Code Maker.Core/Models/QrFramePresetOption.cs diff --git a/Simple QR Code Maker/Models/QrFrameTextSource.cs b/Simple QR Code Maker.Core/Models/QrFrameTextSource.cs similarity index 100% rename from Simple QR Code Maker/Models/QrFrameTextSource.cs rename to Simple QR Code Maker.Core/Models/QrFrameTextSource.cs diff --git a/Simple QR Code Maker/Models/QrImageLayoutMetrics.cs b/Simple QR Code Maker.Core/Models/QrImageLayoutMetrics.cs similarity index 54% rename from Simple QR Code Maker/Models/QrImageLayoutMetrics.cs rename to Simple QR Code Maker.Core/Models/QrImageLayoutMetrics.cs index 4e882dc..3b80c44 100644 --- a/Simple QR Code Maker/Models/QrImageLayoutMetrics.cs +++ b/Simple QR Code Maker.Core/Models/QrImageLayoutMetrics.cs @@ -8,16 +8,18 @@ internal readonly record struct QrImageLayoutMetrics( { public static QrImageLayoutMetrics Square { get; } = new(1, 1, 1, 1); - public double WidthPerQrSize => CanvasWidth / Math.Max(QrWidth, 1); + public double WidthPerQrSize => NormalizeScale(CanvasWidth / Math.Max(QrWidth, 1)); - public double HeightPerQrSize => CanvasHeight / Math.Max(QrHeight, 1); + public double HeightPerQrSize => NormalizeScale(CanvasHeight / Math.Max(QrHeight, 1)); public static QrImageLayoutMetrics FromScaleProfile(double widthPerQrSize, double heightPerQrSize) { return new QrImageLayoutMetrics( - Math.Max(widthPerQrSize, 1), - Math.Max(heightPerQrSize, 1), + NormalizeScale(widthPerQrSize), + NormalizeScale(heightPerQrSize), 1, 1); } + + private static double NormalizeScale(double value) => double.IsFinite(value) ? Math.Max(value, 1) : 1; } diff --git a/Simple QR Code Maker/Models/RedirectorUrlClassification.cs b/Simple QR Code Maker.Core/Models/RedirectorUrlClassification.cs similarity index 100% rename from Simple QR Code Maker/Models/RedirectorUrlClassification.cs rename to Simple QR Code Maker.Core/Models/RedirectorUrlClassification.cs diff --git a/Simple QR Code Maker/Models/SpreadsheetGeneratedIdFormat.cs b/Simple QR Code Maker.Core/Models/SpreadsheetGeneratedIdFormat.cs similarity index 100% rename from Simple QR Code Maker/Models/SpreadsheetGeneratedIdFormat.cs rename to Simple QR Code Maker.Core/Models/SpreadsheetGeneratedIdFormat.cs diff --git a/Simple QR Code Maker/Models/SpreadsheetGeneratedIdFormatOption.cs b/Simple QR Code Maker.Core/Models/SpreadsheetGeneratedIdFormatOption.cs similarity index 100% rename from Simple QR Code Maker/Models/SpreadsheetGeneratedIdFormatOption.cs rename to Simple QR Code Maker.Core/Models/SpreadsheetGeneratedIdFormatOption.cs diff --git a/Simple QR Code Maker/Models/SpreadsheetIdWriteRequest.cs b/Simple QR Code Maker.Core/Models/SpreadsheetIdWriteRequest.cs similarity index 100% rename from Simple QR Code Maker/Models/SpreadsheetIdWriteRequest.cs rename to Simple QR Code Maker.Core/Models/SpreadsheetIdWriteRequest.cs diff --git a/Simple QR Code Maker/Models/SpreadsheetIdWriteRow.cs b/Simple QR Code Maker.Core/Models/SpreadsheetIdWriteRow.cs similarity index 100% rename from Simple QR Code Maker/Models/SpreadsheetIdWriteRow.cs rename to Simple QR Code Maker.Core/Models/SpreadsheetIdWriteRow.cs diff --git a/Simple QR Code Maker/Models/SpreadsheetReadResult.cs b/Simple QR Code Maker.Core/Models/SpreadsheetReadResult.cs similarity index 100% rename from Simple QR Code Maker/Models/SpreadsheetReadResult.cs rename to Simple QR Code Maker.Core/Models/SpreadsheetReadResult.cs diff --git a/Simple QR Code Maker/Models/SpreadsheetSourceRow.cs b/Simple QR Code Maker.Core/Models/SpreadsheetSourceRow.cs similarity index 100% rename from Simple QR Code Maker/Models/SpreadsheetSourceRow.cs rename to Simple QR Code Maker.Core/Models/SpreadsheetSourceRow.cs diff --git a/Simple QR Code Maker/Models/UrlBuilderLine.cs b/Simple QR Code Maker.Core/Models/UrlBuilderLine.cs similarity index 100% rename from Simple QR Code Maker/Models/UrlBuilderLine.cs rename to Simple QR Code Maker.Core/Models/UrlBuilderLine.cs diff --git a/Simple QR Code Maker/Models/UrlBuilderQueryParameter.cs b/Simple QR Code Maker.Core/Models/UrlBuilderQueryParameter.cs similarity index 100% rename from Simple QR Code Maker/Models/UrlBuilderQueryParameter.cs rename to Simple QR Code Maker.Core/Models/UrlBuilderQueryParameter.cs diff --git a/Simple QR Code Maker/Models/VCardBuilderState.cs b/Simple QR Code Maker.Core/Models/VCardBuilderState.cs similarity index 100% rename from Simple QR Code Maker/Models/VCardBuilderState.cs rename to Simple QR Code Maker.Core/Models/VCardBuilderState.cs diff --git a/Simple QR Code Maker/Models/VCardOptionalFieldKind.cs b/Simple QR Code Maker.Core/Models/VCardOptionalFieldKind.cs similarity index 100% rename from Simple QR Code Maker/Models/VCardOptionalFieldKind.cs rename to Simple QR Code Maker.Core/Models/VCardOptionalFieldKind.cs diff --git a/Simple QR Code Maker/Models/WifiBuilderState.cs b/Simple QR Code Maker.Core/Models/WifiBuilderState.cs similarity index 100% rename from Simple QR Code Maker/Models/WifiBuilderState.cs rename to Simple QR Code Maker.Core/Models/WifiBuilderState.cs diff --git a/Simple QR Code Maker.Core/Services/FileService.cs b/Simple QR Code Maker.Core/Services/FileService.cs index b11ad4a..5db05d4 100644 --- a/Simple QR Code Maker.Core/Services/FileService.cs +++ b/Simple QR Code Maker.Core/Services/FileService.cs @@ -6,7 +6,7 @@ namespace Simple_QR_Code_Maker.Core.Services; public class FileService : IFileService { - public T Read(string folderPath, string fileName) + public T? Read(string folderPath, string fileName) { string path = Path.Combine(folderPath, fileName); if (File.Exists(path)) @@ -29,7 +29,7 @@ public void Save(string folderPath, string fileName, T content) File.WriteAllText(Path.Combine(folderPath, fileName), fileContent, Encoding.UTF8); } - public void Delete(string folderPath, string fileName) + public void Delete(string folderPath, string? fileName) { if (fileName != null && File.Exists(Path.Combine(folderPath, fileName))) { diff --git a/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj b/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj index 1146907..ad97311 100644 --- a/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj +++ b/Simple QR Code Maker.Core/Simple QR Code Maker.Core.csproj @@ -4,9 +4,16 @@ Simple_QR_Code_Maker.Core x64;arm64 enable + enable + preview + + + + + diff --git a/Simple QR Code Maker/Contracts/Services/IBrandService.cs b/Simple QR Code Maker.Windows/Contracts/Services/IBrandService.cs similarity index 100% rename from Simple QR Code Maker/Contracts/Services/IBrandService.cs rename to Simple QR Code Maker.Windows/Contracts/Services/IBrandService.cs diff --git a/Simple QR Code Maker/Contracts/Services/IHistoryService.cs b/Simple QR Code Maker.Windows/Contracts/Services/IHistoryService.cs similarity index 92% rename from Simple QR Code Maker/Contracts/Services/IHistoryService.cs rename to Simple QR Code Maker.Windows/Contracts/Services/IHistoryService.cs index 65c2150..3fecc2c 100644 --- a/Simple QR Code Maker/Contracts/Services/IHistoryService.cs +++ b/Simple QR Code Maker.Windows/Contracts/Services/IHistoryService.cs @@ -5,6 +5,8 @@ namespace Simple_QR_Code_Maker.Contracts.Services; public interface IHistoryService { + Task PendingShutdownSave { get; } + Task LoadAsync(ObservableCollection historyItems); Task SaveAsync(ObservableCollection historyItems); diff --git a/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs b/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs new file mode 100644 index 0000000..15e5146 --- /dev/null +++ b/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Contracts.Services; + +public interface IBrandStore +{ + Task> LoadAsync(); + + Task SaveAsync(ObservableCollection items); +} + +public interface IHistoryStore +{ + Task> LoadAsync(); + + Task SaveAsync(ObservableCollection items); +} + +public interface IDecodingHistoryStore +{ + Task> LoadAsync(); + + Task SaveAsync(ObservableCollection items); + + Task SaveImageCopyAsync(string sourcePath); + + Task AddAndSaveAsync(ObservableCollection items, DecodingHistoryItem newItem); + + Task UpdateLatestAndSaveAsync(ObservableCollection items, string sourcePath, List decodedTexts); +} diff --git a/Simple QR Code Maker/Contracts/Services/ILocalSettingsService.cs b/Simple QR Code Maker.Windows/Contracts/Services/ILocalSettingsService.cs similarity index 100% rename from Simple QR Code Maker/Contracts/Services/ILocalSettingsService.cs rename to Simple QR Code Maker.Windows/Contracts/Services/ILocalSettingsService.cs diff --git a/Simple QR Code Maker.Windows/Contracts/Services/IPersistenceStorage.cs b/Simple QR Code Maker.Windows/Contracts/Services/IPersistenceStorage.cs new file mode 100644 index 0000000..7ebcd30 --- /dev/null +++ b/Simple QR Code Maker.Windows/Contracts/Services/IPersistenceStorage.cs @@ -0,0 +1,42 @@ +namespace Simple_QR_Code_Maker.Contracts.Services; + +public interface IJsonFileStore +{ + Task ReadAsync(string fileName); + + Task WriteAsync(string fileName, string json); +} + +public interface IAtomicFileOperations +{ + Task ReadAsync(string path); + + Task WriteAsync(string path, string content); + + void Replace(string sourcePath, string destinationPath); + + void Delete(string path); +} + +public interface ISettingsStore +{ + Task ReadAsync(string key); + + Task WriteAsync(string key, string value); + + Task RemoveAsync(string key); +} + +public interface IRuntimeEnvironment +{ + bool IsPackaged { get; } + + string LocalApplicationDataPath { get; } +} + +public interface IHistoryImageStore +{ + Task SaveCopyAsync(string sourcePath); + + Task DeleteAsync(string path); +} diff --git a/Simple QR Code Maker.Windows/Helpers/BackupArchiveHelper.cs b/Simple QR Code Maker.Windows/Helpers/BackupArchiveHelper.cs new file mode 100644 index 0000000..3c2e305 --- /dev/null +++ b/Simple QR Code Maker.Windows/Helpers/BackupArchiveHelper.cs @@ -0,0 +1,170 @@ +using Simple_QR_Code_Maker.Models; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO.Compression; +using System.Text; + +namespace Simple_QR_Code_Maker.Helpers; + +internal static class BackupArchiveHelper +{ + internal static async Task AddImageToArchiveAsync( + ZipArchive archive, + string? originalPath, + Dictionary imagePathMap, + string category, + string nameHint, + DateTime timestamp) + { + if (string.IsNullOrEmpty(originalPath)) + return null; + if (imagePathMap.TryGetValue(originalPath, out string? existingZipPath)) + return existingZipPath; + if (!File.Exists(originalPath)) + return null; + + string zipPath = CreateArchiveImagePath(category, nameHint, originalPath, timestamp, imagePathMap.Count + 1); + ZipArchiveEntry entry = archive.CreateEntry(zipPath, CompressionLevel.NoCompression); + using (Stream output = entry.Open()) + using (FileStream input = File.OpenRead(originalPath)) + await input.CopyToAsync(output); + + imagePathMap[originalPath] = zipPath; + return zipPath; + } + + internal static async Task> ExtractImagesAsync(ZipArchive archive, string localFolderPath) + { + Dictionary importedPaths = []; + string directory = Path.Combine(localFolderPath, "ImportedImages", $"import_{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (!entry.FullName.StartsWith("images/", StringComparison.Ordinal) || string.IsNullOrEmpty(entry.Name)) + continue; + + string localPath = CreateUniqueImportedImagePath(directory, entry.Name); + using (Stream input = entry.Open()) + using (FileStream output = new(localPath, FileMode.CreateNew, FileAccess.Write)) + await input.CopyToAsync(output); + + importedPaths[entry.FullName] = localPath; + } + + return importedPaths; + } + + internal static string? ResolveImportedImagePath(string? logoPath, IReadOnlyDictionary importedPaths) + { + if (string.IsNullOrEmpty(logoPath)) + return null; + return logoPath.StartsWith("images/", StringComparison.Ordinal) + ? importedPaths.GetValueOrDefault(logoPath) + : logoPath; + } + + internal static ObservableCollection MergeHistory( + IEnumerable existing, IReadOnlyList imported) + { + ObservableCollection merged = new(existing); + for (int index = imported.Count - 1; index >= 0; index--) + { + merged.Remove(imported[index]); + merged.Insert(0, imported[index]); + } + + return merged; + } + + internal static ObservableCollection MergeBrands( + IEnumerable existing, IReadOnlyList imported) + { + ObservableCollection merged = new(existing); + BrandItem? preferredDefault = null; + for (int index = imported.Count - 1; index >= 0; index--) + { + BrandItem brand = imported[index]; + if (brand.IsDefault) + preferredDefault = brand; + merged.Remove(brand); + merged.Insert(0, brand); + } + + preferredDefault ??= merged.FirstOrDefault(brand => brand.IsDefault); + bool assigned = false; + foreach (BrandItem brand in merged) + { + brand.IsDefault = !assigned && preferredDefault is not null && brand.Equals(preferredDefault); + assigned |= brand.IsDefault; + } + + return merged; + } + + internal static string CreateArchiveImagePath( + string category, string nameHint, string originalPath, DateTime timestamp, int imageNumber, TimeProvider? clock = null) + { + string extension = Path.GetExtension(originalPath); + if (string.IsNullOrWhiteSpace(extension)) + extension = ".img"; + + string categorySegment = SanitizeFileNameSegment(category, 16); + string timestampSegment = (timestamp == default ? (clock ?? TimeProvider.System).GetLocalNow().DateTime : timestamp) + .ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); + string hintSegment = SanitizeFileNameSegment(nameHint, 36); + string originalSegment = SanitizeFileNameSegment(Path.GetFileNameWithoutExtension(originalPath), 24); + List parts = [string.IsNullOrWhiteSpace(categorySegment) ? "image" : categorySegment, timestampSegment]; + if (!string.IsNullOrWhiteSpace(hintSegment)) + parts.Add(hintSegment); + if (!string.IsNullOrWhiteSpace(originalSegment) && !string.Equals(originalSegment, hintSegment, StringComparison.OrdinalIgnoreCase)) + parts.Add(originalSegment); + parts.Add($"logo_{imageNumber:D3}"); + return $"images/{string.Join("_", parts)}{extension}"; + } + + private static string CreateUniqueImportedImagePath(string directory, string entryName) + { + string name = SanitizeFileNameSegment(Path.GetFileNameWithoutExtension(entryName), 96); + if (string.IsNullOrWhiteSpace(name)) + name = "imported_logo"; + string extension = Path.GetExtension(entryName); + if (string.IsNullOrWhiteSpace(extension)) + extension = ".img"; + string path = Path.Combine(directory, $"{name}{extension}"); + int duplicateIndex = 1; + while (File.Exists(path)) + { + path = Path.Combine(directory, $"{name}_{duplicateIndex:D2}{extension}"); + duplicateIndex++; + } + + return path; + } + + internal static string SanitizeFileNameSegment(string? value, int maxLength) + { + ArgumentOutOfRangeException.ThrowIfNegative(maxLength); + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + StringBuilder builder = new(maxLength); + bool lastWasSeparator = false; + foreach (char character in value.Trim()) + { + if (char.IsLetterOrDigit(character)) + { + if (builder.Length >= maxLength) + break; + builder.Append(char.ToLowerInvariant(character)); + lastWasSeparator = false; + } + else if (builder.Length > 0 && !lastWasSeparator && builder.Length < maxLength) + { + builder.Append('_'); + lastWasSeparator = true; + } + } + + return builder.ToString().Trim('_'); + } +} diff --git a/Simple QR Code Maker/Helpers/ColorHelpers.cs b/Simple QR Code Maker.Windows/Helpers/ColorHelpers.cs similarity index 100% rename from Simple QR Code Maker/Helpers/ColorHelpers.cs rename to Simple QR Code Maker.Windows/Helpers/ColorHelpers.cs diff --git a/Simple QR Code Maker/Helpers/ImageProcessingHelper.cs b/Simple QR Code Maker.Windows/Helpers/ImageProcessingHelper.cs similarity index 94% rename from Simple QR Code Maker/Helpers/ImageProcessingHelper.cs rename to Simple QR Code Maker.Windows/Helpers/ImageProcessingHelper.cs index a646e63..6a4320b 100644 --- a/Simple QR Code Maker/Helpers/ImageProcessingHelper.cs +++ b/Simple QR Code Maker.Windows/Helpers/ImageProcessingHelper.cs @@ -48,7 +48,7 @@ public static MagickImage SetBlackPoint(MagickImage image, MagickColor blackPoin { var result = (MagickImage)image.Clone(); - double threshold = (blackPointColor.R + blackPointColor.G + blackPointColor.B) / (3.0 * 255.0); + double threshold = (blackPointColor.R + blackPointColor.G + blackPointColor.B) / (3.0 * Quantum.Max); result.Level(new Percentage(threshold * 100), new Percentage(100), 1.0); return result; @@ -58,7 +58,7 @@ public static MagickImage SetWhitePoint(MagickImage image, MagickColor whitePoin { var result = (MagickImage)image.Clone(); - double threshold = (whitePointColor.R + whitePointColor.G + whitePointColor.B) / (3.0 * 255.0); + double threshold = (whitePointColor.R + whitePointColor.G + whitePointColor.B) / (3.0 * Quantum.Max); result.Level(new Percentage(0), new Percentage(threshold * 100), 1.0); return result; @@ -248,9 +248,12 @@ private static bool IsValidQuadrilateral(Point p1, Point p2, Point p3, Point p4) return false; } - // Check for self-intersecting quadrilateral - // A valid quadrilateral should have diagonals that intersect inside - return true; + // Ordered corners of a convex quadrilateral must turn in the same direction. + // Non-collinearity alone also accepts crossed (bow-tie) selections. + long firstTurn = CrossProduct(p1, p2, p3); + return Math.Sign(firstTurn) == Math.Sign(CrossProduct(p2, p3, p4)) + && Math.Sign(firstTurn) == Math.Sign(CrossProduct(p3, p4, p1)) + && Math.Sign(firstTurn) == Math.Sign(CrossProduct(p4, p1, p2)); } private static bool IsPointInBounds(Point point, uint imageWidth, uint imageHeight) @@ -262,13 +265,15 @@ private static bool AreCollinear(Point p1, Point p2, Point p3) { // Calculate the cross product to check collinearity // If cross product is close to 0, points are collinear - long crossProduct = (long)(p2.X - p1.X) * (p3.Y - p1.Y) - - (long)(p2.Y - p1.Y) * (p3.X - p1.X); + long crossProduct = CrossProduct(p1, p2, p3); // Allow small tolerance for floating point errors (within 1 pixel area) return Math.Abs(crossProduct) < 1; } + private static long CrossProduct(Point p1, Point p2, Point p3) + => (long)(p2.X - p1.X) * (p3.Y - p1.Y) - (long)(p2.Y - p1.Y) * (p3.X - p1.X); + public static async Task LoadImageFromStorageFile(StorageFile file) { using var stream = await file.OpenStreamForReadAsync(); @@ -321,7 +326,8 @@ public static Bitmap ConvertToBitmap(MagickImage image) using var memoryStream = new MemoryStream(); image.Write(memoryStream, MagickFormat.Png); memoryStream.Position = 0; - return new Bitmap(memoryStream); + using Bitmap decoded = new(memoryStream); + return new Bitmap(decoded); } public static async Task SaveToTemporaryFile(MagickImage image) diff --git a/Simple QR Code Maker/Helpers/QrCodeDesignStateMapper.cs b/Simple QR Code Maker.Windows/Helpers/QrCodeDesignStateMapper.cs similarity index 100% rename from Simple QR Code Maker/Helpers/QrCodeDesignStateMapper.cs rename to Simple QR Code Maker.Windows/Helpers/QrCodeDesignStateMapper.cs diff --git a/Simple QR Code Maker.Windows/Helpers/QrCodeRenderer.cs b/Simple QR Code Maker.Windows/Helpers/QrCodeRenderer.cs new file mode 100644 index 0000000..36eda97 --- /dev/null +++ b/Simple QR Code Maker.Windows/Helpers/QrCodeRenderer.cs @@ -0,0 +1,1344 @@ +using ImageMagick; +using Simple_QR_Code_Maker.Models; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Globalization; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Windows.Storage; +using ZXing; +using ZXing.Common; +using ZXing.QrCode.Internal; +using ZXing.Rendering; +using ZXing.Windows.Compatibility; +using static ZXing.Rendering.SvgRenderer; + +namespace Simple_QR_Code_Maker.Helpers; + +public static partial class QrCodeRenderer +{ + private const int MaxQrRenderSize = 1024; + + private const string Utf8CharacterSet = "UTF-8"; + + private static string NormalizeVCardLineEndings(string text) + { + // WinUI TextBox uses CR-only newlines, but vCard readers expect CRLF. + return VCardBuilderHelper.IsVCard(text) + ? text.ReplaceLineEndings("\r\n") + : text; + } + + /// + /// ZXing encodes byte-mode content as ISO-8859-1 unless told otherwise, which silently replaces + /// every character outside Latin-1 (Cyrillic, Greek, CJK, emoji, ...) with '?'. Text that fits in + /// ISO-8859-1 keeps the default so those codes stay byte-for-byte what they have always been. + /// + private static bool NeedsUtf8Encoding(string text) + { + foreach (char character in text) + { + // 0x00FF is the last code point ISO-8859-1 can represent + if (character > 'ÿ') + return true; + } + + return false; + } + + /// + /// Adds the UTF-8 character set hint when the text requires it. ZXing also writes an ECI header + /// declaring the character set so readers know how to interpret the bytes. + /// + private static void ApplyCharacterSetHint(EncodingOptions encodingOptions, string text) + { + if (NeedsUtf8Encoding(text)) + encodingOptions.Hints[EncodeHintType.CHARACTER_SET] = Utf8CharacterSet; + } + + /// + /// Encodes the text the same way the barcode writers do, so version and module counts derived from + /// this match the rendered code. + /// + internal static QRCode EncodeQrCode(string text, ErrorCorrectionLevel correctionLevel) + { + text = NormalizeVCardLineEndings(text); + + if (!NeedsUtf8Encoding(text)) + return ZXing.QrCode.Internal.Encoder.encode(text, correctionLevel); + + Dictionary hints = new() + { + { EncodeHintType.CHARACTER_SET, Utf8CharacterSet } + }; + + return ZXing.QrCode.Internal.Encoder.encode(text, correctionLevel, hints); + } + + /// + /// Calculate the maximum safe logo size percentage based on QR code error correction level and version + /// + /// The text to encode in the QR code + /// The error correction level + /// Maximum safe logo size as a percentage (0-100) + // public static int GetMaxLogoSizePercentage(string text, ErrorCorrectionLevel correctionLevel) + public static int GetMaxLogoSizePercentage(ErrorCorrectionLevel correctionLevel) + { + // Error correction capacity by level (percentage of code that can be damaged and still readable) + // These are the theoretical maximum recovery percentages + return correctionLevel.ToString() switch + { + "L" => 20, // Low: ~7% + "M" => 23, // Medium: ~15% + "Q" => 35, // Quartile: ~25% + "H" => 40, // High: ~30% + _ => 20 + }; + } + + /// + /// Rasterizes SVG content to a System.Drawing.Bitmap at the specified dimensions. + /// Uses Magick.NET; the SVG is scaled to fit preserving aspect ratio. + /// + public static Bitmap RasterizeSvgToBitmap(string svgContent, int width, int height) + { + MagickReadSettings settings = new() + { + Format = MagickFormat.Svg, + Width = (uint)width, + Height = (uint)height, + BackgroundColor = MagickColors.Transparent, + }; + byte[] svgBytes = System.Text.Encoding.UTF8.GetBytes(svgContent); + using MagickImage image = new(svgBytes, settings); + image.Format = MagickFormat.Png32; + using MemoryStream ms = new(); + image.Write(ms); + ms.Position = 0; + using Bitmap decoded = new(ms); + return new Bitmap(decoded); + } + + public static Bitmap GetQrCodeBitmapFromText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) + { + return CreateQrCodeBitmap( + text, + correctionLevel, + foreground, + background, + logoImage, + logoSizePercentage, + logoPaddingPixels, + qrPaddingModules, + framePreset, + frameText, + out _); + } + + public static void SaveQrCodePngToStream(Stream outputStream, string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) + { + using Bitmap bitmap = CreateQrCodeBitmap( + text, + correctionLevel, + foreground, + background, + logoImage, + logoSizePercentage, + logoPaddingPixels, + qrPaddingModules, + framePreset, + frameText, + out _); + bitmap.Save(outputStream, ImageFormat.Png); + } + + internal static void SaveQrCodePngToStream(Stream outputStream, string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage, double logoSizePercentage, double logoPaddingPixels, double qrPaddingModules, QrFramePreset framePreset, string? frameText, out QrImageLayoutMetrics imageLayout) + { + using Bitmap bitmap = CreateQrCodeBitmap( + text, + correctionLevel, + foreground, + background, + logoImage, + logoSizePercentage, + logoPaddingPixels, + qrPaddingModules, + framePreset, + frameText, + out int qrRenderSize); + imageLayout = GetQrImageLayoutMetrics(qrRenderSize, framePreset); + bitmap.Save(outputStream, ImageFormat.Png); + } + + internal static QrImageLayoutMetrics GetQrImageLayoutMetrics( + string text, + ErrorCorrectionLevel correctionLevel, + double qrPaddingModules = 2.0, + QrFramePreset framePreset = QrFramePreset.None) + { + int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); + QRCode qrCode = EncodeQrCode(text, correctionLevel); + int moduleCount = qrCode.Version.DimensionForVersion; + int renderSize = GetQrRenderSize(moduleCount, normalizedQrPaddingModules); + return GetQrImageLayoutMetrics(renderSize, framePreset); + } + + private static QrImageLayoutMetrics GetQrImageLayoutMetrics(int qrRenderSize, QrFramePreset framePreset) + { + switch (framePreset) + { + case QrFramePreset.BottomLabel: + { + int outerPadding = ScaleMetric(qrRenderSize, 0.05, 36); + int labelHeight = ScaleMetric(qrRenderSize, 0.16, 84); + int labelSpacing = ScaleMetric(qrRenderSize, 0.035, 20); + int canvasWidth = qrRenderSize + (outerPadding * 2); + int canvasHeight = qrRenderSize + (outerPadding * 2) + labelSpacing + labelHeight; + return new QrImageLayoutMetrics(canvasWidth, canvasHeight, qrRenderSize, qrRenderSize); + } + case QrFramePreset.RoundedFrame: + { + int outerPadding = ScaleMetric(qrRenderSize, 0.1, 56); + int labelHeight = ScaleMetric(qrRenderSize, 0.14, 80); + int labelSpacing = ScaleMetric(qrRenderSize, 0.03, 18); + int canvasWidth = qrRenderSize + (outerPadding * 2); + int canvasHeight = qrRenderSize + (outerPadding * 2) + labelSpacing + labelHeight; + return new QrImageLayoutMetrics(canvasWidth, canvasHeight, qrRenderSize, qrRenderSize); + } + case QrFramePreset.CornerCallout: + { + int outerPadding = ScaleMetric(qrRenderSize, 0.11, 64); + int labelHeight = ScaleMetric(qrRenderSize, 0.13, 72); + int labelSpacing = ScaleMetric(qrRenderSize, 0.04, 24); + int canvasWidth = qrRenderSize + (outerPadding * 2); + int canvasHeight = qrRenderSize + (outerPadding * 2) + labelSpacing + labelHeight; + return new QrImageLayoutMetrics(canvasWidth, canvasHeight, qrRenderSize, qrRenderSize); + } + default: + return new QrImageLayoutMetrics(qrRenderSize, qrRenderSize, qrRenderSize, qrRenderSize); + } + } + + /// + /// Converts a ZXing-generated bitmap (Format32bppRgb, no alpha) to a Format32bppArgb + /// bitmap with the correct alpha channel applied to foreground and background pixels. + /// + private static Bitmap ApplyAlphaToQrBitmap(Bitmap source, System.Drawing.Color foreground, System.Drawing.Color background) + { + // Clone to Format32bppArgb first: Format32bppRgb stores the alpha byte as 0 in memory, + // so SetRemapTable would fail to match OldColor.A=255. The clone conversion sets alpha=255 + // for every pixel (all Format32bppRgb pixels are fully opaque by definition). + using Bitmap argbSource = source.Clone( + new System.Drawing.Rectangle(0, 0, source.Width, source.Height), + PixelFormat.Format32bppArgb); + + Bitmap result = new(source.Width, source.Height, PixelFormat.Format32bppArgb); + using Graphics g = Graphics.FromImage(result); + g.Clear(System.Drawing.Color.Transparent); + + // Remap the opaque source colors to the user's alpha-aware colors + ColorMap[] colorMaps = + [ + new ColorMap + { + OldColor = System.Drawing.Color.FromArgb(255, background.R, background.G, background.B), + NewColor = background + }, + new ColorMap + { + OldColor = System.Drawing.Color.FromArgb(255, foreground.R, foreground.G, foreground.B), + NewColor = foreground + } + ]; + + using ImageAttributes attributes = new(); + attributes.SetRemapTable(colorMaps); + + g.DrawImage(argbSource, + new System.Drawing.Rectangle(0, 0, result.Width, result.Height), + 0, 0, argbSource.Width, argbSource.Height, + GraphicsUnit.Pixel, attributes); + + return result; + } + + private static Bitmap CreateQrCodeBitmap(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage, double logoSizePercentage, double logoPaddingPixels, double qrPaddingModules, QrFramePreset framePreset, string? frameText, out int qrRenderSize) + { + text = NormalizeVCardLineEndings(text); + + // Always pass fully opaque colors to ZXing — if the background color has A=0, ZXing fills + // nothing (transparent brush = SourceOver no-op) and the bitmap initializes to black, making + // foreground and background pixels indistinguishable. ApplyAlphaToQrBitmap handles alpha. + int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); + QRCode qrCode = EncodeQrCode(text, correctionLevel); + int moduleCount = qrCode.Version.DimensionForVersion; + qrRenderSize = GetQrRenderSize(moduleCount, normalizedQrPaddingModules); + + BitmapRenderer bitmapRenderer = new() + { + Foreground = System.Drawing.Color.FromArgb(255, foreground.R, foreground.G, foreground.B), + Background = System.Drawing.Color.FromArgb(255, background.R, background.G, background.B) + }; + + BarcodeWriter barcodeWriter = new() + { + Format = BarcodeFormat.QR_CODE, + Renderer = bitmapRenderer, + }; + + EncodingOptions encodingOptions = new() + { + Width = qrRenderSize, + Height = qrRenderSize, + Margin = normalizedQrPaddingModules, + }; + encodingOptions.Hints.Add(EncodeHintType.ERROR_CORRECTION, correctionLevel); + ApplyCharacterSetHint(encodingOptions, text); + barcodeWriter.Options = encodingOptions; + + Bitmap rawBitmap = barcodeWriter.Write(text); + bool needsAlpha = foreground.A < 255 || background.A < 255; + Bitmap bitmap = rawBitmap; + + if (needsAlpha) + { + bitmap = ApplyAlphaToQrBitmap(rawBitmap, foreground, background); + rawBitmap.Dispose(); + } + + try + { + if (logoImage != null) + { + OverlayLogoOnQrCode(bitmap, logoImage, logoSizePercentage, moduleCount, encodingOptions.Margin, logoPaddingPixels, background); + } + + Bitmap framedBitmap = AddFrameToBitmap(bitmap, foreground, background, framePreset, frameText); + if (!ReferenceEquals(framedBitmap, bitmap)) + { + bitmap.Dispose(); + bitmap = framedBitmap; + } + + return bitmap; + } + catch + { + bitmap.Dispose(); + throw; + } + } + + private static Bitmap AddFrameToBitmap(Bitmap qrBitmap, System.Drawing.Color foreground, System.Drawing.Color background, QrFramePreset framePreset, string? frameText) + { + string? resolvedFrameText = QrFramePresetCatalog.ResolveText(framePreset, frameText); + if (string.IsNullOrWhiteSpace(resolvedFrameText)) + return qrBitmap; + + return framePreset switch + { + QrFramePreset.BottomLabel => AddBottomLabelFrameToBitmap(qrBitmap, resolvedFrameText, foreground, background), + QrFramePreset.RoundedFrame => AddRoundedFrameToBitmap(qrBitmap, resolvedFrameText, foreground, background), + QrFramePreset.CornerCallout => AddCornerCalloutFrameToBitmap(qrBitmap, resolvedFrameText, foreground, background), + _ => qrBitmap, + }; + } + + private static Bitmap AddBottomLabelFrameToBitmap(Bitmap qrBitmap, string frameText, System.Drawing.Color foreground, System.Drawing.Color background) + { + int outerPadding = ScaleMetric(qrBitmap.Width, 0.05, 36); + int labelHeight = ScaleMetric(qrBitmap.Width, 0.16, 84); + int labelSpacing = ScaleMetric(qrBitmap.Width, 0.035, 20); + int labelWidth = qrBitmap.Width + ScaleMetric(qrBitmap.Width, 0.08, 40); + int outlineThickness = ScaleMetric(qrBitmap.Width, 0.014, 8); + int accentHeight = ScaleMetric(qrBitmap.Width, 0.014, 8); + int qrX = outerPadding; + int qrY = outerPadding; + int canvasWidth = qrBitmap.Width + (outerPadding * 2); + int canvasHeight = qrY + qrBitmap.Height + labelSpacing + labelHeight + outerPadding; + int labelX = (canvasWidth - labelWidth) / 2; + int labelY = qrY + qrBitmap.Height + labelSpacing; + int accentWidth = Math.Max(labelWidth / 4, ScaleMetric(qrBitmap.Width, 0.18, 120)); + int accentX = (canvasWidth - accentWidth) / 2; + int accentY = labelY - (labelSpacing / 2); + Bitmap canvas = new(canvasWidth, canvasHeight, PixelFormat.Format32bppArgb); + + using Graphics g = Graphics.FromImage(canvas); + PrepareGraphics(g); + g.Clear(System.Drawing.Color.Transparent); + g.DrawImage(qrBitmap, qrX, qrY, qrBitmap.Width, qrBitmap.Height); + + using GraphicsPath labelPath = CreateRoundedRectanglePath(new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), labelHeight / 2f); + using GraphicsPath accentPath = CreateRoundedRectanglePath(new System.Drawing.RectangleF(accentX, accentY, accentWidth, accentHeight), accentHeight / 2f); + using SolidBrush backgroundBrush = new(background); + using SolidBrush foregroundBrush = new(foreground); + using Pen outlinePen = new(foreground, outlineThickness); + + g.FillPath(backgroundBrush, labelPath); + g.DrawPath(outlinePen, labelPath); + g.FillPath(foregroundBrush, accentPath); + DrawFrameText(g, frameText, foreground, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness); + + return canvas; + } + + private static Bitmap AddRoundedFrameToBitmap(Bitmap qrBitmap, string frameText, System.Drawing.Color foreground, System.Drawing.Color background) + { + int outerPadding = ScaleMetric(qrBitmap.Width, 0.1, 56); + int borderInset = ScaleMetric(qrBitmap.Width, 0.032, 18); + int labelHeight = ScaleMetric(qrBitmap.Width, 0.14, 80); + int labelSpacing = ScaleMetric(qrBitmap.Width, 0.03, 18); + int outlineThickness = ScaleMetric(qrBitmap.Width, 0.022, 14); + int qrX = outerPadding; + int qrY = outerPadding; + int canvasWidth = qrBitmap.Width + (outerPadding * 2); + int canvasHeight = qrY + qrBitmap.Height + outerPadding + labelSpacing + labelHeight; + float borderRadius = ScaleMetric(qrBitmap.Width, 0.085, 42); + System.Drawing.RectangleF borderRect = new( + qrX - borderInset, + qrY - borderInset, + qrBitmap.Width + (borderInset * 2), + qrBitmap.Height + (borderInset * 2)); + int labelWidth = (int)Math.Round(borderRect.Width * 0.72, MidpointRounding.AwayFromZero); + int labelX = (canvasWidth - labelWidth) / 2; + int labelY = (int)Math.Round(borderRect.Bottom + labelSpacing, MidpointRounding.AwayFromZero); + Bitmap canvas = new(canvasWidth, canvasHeight, PixelFormat.Format32bppArgb); + + using Graphics g = Graphics.FromImage(canvas); + PrepareGraphics(g); + g.Clear(System.Drawing.Color.Transparent); + + using GraphicsPath borderPath = CreateRoundedRectanglePath(borderRect, borderRadius); + using SolidBrush backgroundBrush = new(background); + using Pen outlinePen = new(foreground, outlineThickness); + + g.FillPath(backgroundBrush, borderPath); + g.DrawPath(outlinePen, borderPath); + g.DrawImage(qrBitmap, qrX, qrY, qrBitmap.Width, qrBitmap.Height); + DrawLabelPill(g, frameText, foreground, background, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness); + + return canvas; + } + + private static Bitmap AddCornerCalloutFrameToBitmap(Bitmap qrBitmap, string frameText, System.Drawing.Color foreground, System.Drawing.Color background) + { + int outerPadding = ScaleMetric(qrBitmap.Width, 0.11, 64); + int bracketInset = ScaleMetric(qrBitmap.Width, 0.03, 16); + int bracketLength = ScaleMetric(qrBitmap.Width, 0.14, 72); + int outlineThickness = ScaleMetric(qrBitmap.Width, 0.024, 15); + int labelHeight = ScaleMetric(qrBitmap.Width, 0.13, 72); + int labelSpacing = ScaleMetric(qrBitmap.Width, 0.04, 24); + int labelWidth = ScaleMetric(qrBitmap.Width, 0.72, 520); + int canvasWidth = qrBitmap.Width + (outerPadding * 2); + int canvasHeight = labelHeight + labelSpacing + qrBitmap.Height + (outerPadding * 2); + int labelX = (canvasWidth - labelWidth) / 2; + int labelY = outerPadding / 3; + int qrX = outerPadding; + int qrY = labelY + labelHeight + labelSpacing; + System.Drawing.RectangleF bracketRect = new( + qrX - bracketInset, + qrY - bracketInset, + qrBitmap.Width + (bracketInset * 2), + qrBitmap.Height + (bracketInset * 2)); + Bitmap canvas = new(canvasWidth, canvasHeight, PixelFormat.Format32bppArgb); + + using Graphics g = Graphics.FromImage(canvas); + PrepareGraphics(g); + g.Clear(System.Drawing.Color.Transparent); + g.DrawImage(qrBitmap, qrX, qrY, qrBitmap.Width, qrBitmap.Height); + DrawLabelPill(g, frameText, foreground, background, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness); + DrawCornerCalloutBrackets(g, foreground, bracketRect, bracketLength, outlineThickness, labelX + (labelWidth / 2f), labelY + labelHeight); + + return canvas; + } + + private static void PrepareGraphics(Graphics g) + { + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; + g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + } + + private static void DrawLabelPill(Graphics g, string frameText, System.Drawing.Color foreground, System.Drawing.Color background, System.Drawing.RectangleF rect, float outlineThickness) + { + using GraphicsPath labelPath = CreateRoundedRectanglePath(rect, rect.Height / 2f); + using SolidBrush backgroundBrush = new(background); + using Pen outlinePen = new(foreground, outlineThickness); + + g.FillPath(backgroundBrush, labelPath); + g.DrawPath(outlinePen, labelPath); + DrawFrameText(g, frameText, foreground, rect, outlineThickness); + } + + private static void DrawFrameText(Graphics g, string frameText, System.Drawing.Color foreground, System.Drawing.RectangleF rect, float outlineThickness) + { + System.Drawing.RectangleF textRect = GetFrameTextRect(rect, outlineThickness); + float fontSize = GetFittedFrameFontSize(frameText, textRect.Size); + using Font font = new("Segoe UI", fontSize, FontStyle.Bold, GraphicsUnit.Pixel); + using SolidBrush textBrush = new(foreground); + using StringFormat format = new() + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + Trimming = StringTrimming.EllipsisCharacter, + FormatFlags = StringFormatFlags.NoWrap, + }; + + g.DrawString(frameText, font, textBrush, textRect, format); + } + + private static System.Drawing.RectangleF GetFrameTextRect(System.Drawing.RectangleF rect, float outlineThickness) + { + float horizontalPadding = Math.Max(outlineThickness * 2.5f, rect.Width * 0.08f); + float verticalPadding = Math.Max(outlineThickness * 1.5f, rect.Height * 0.14f); + float width = Math.Max(1f, rect.Width - (horizontalPadding * 2f)); + float height = Math.Max(1f, rect.Height - (verticalPadding * 2f)); + + return new System.Drawing.RectangleF( + rect.X + horizontalPadding, + rect.Y + verticalPadding, + width, + height); + } + + private static float GetFittedFrameFontSize(string frameText, System.Drawing.SizeF availableSize) + { + const float measurementFontSize = 100f; + + using Bitmap measurementBitmap = new(1, 1); + using Graphics measurementGraphics = Graphics.FromImage(measurementBitmap); + PrepareGraphics(measurementGraphics); + using Font measurementFont = new("Segoe UI", measurementFontSize, FontStyle.Bold, GraphicsUnit.Pixel); + using StringFormat format = new() + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + Trimming = StringTrimming.EllipsisCharacter, + FormatFlags = StringFormatFlags.NoWrap, + }; + + System.Drawing.SizeF measuredSize = measurementGraphics.MeasureString(frameText, measurementFont, PointF.Empty, format); + if (measuredSize.Width <= 0 || measuredSize.Height <= 0) + return Math.Max(1f, availableSize.Height); + + float scaleX = availableSize.Width / measuredSize.Width; + float scaleY = availableSize.Height / measuredSize.Height; + float scale = Math.Min(scaleX, scaleY) * 0.98f; + + return Math.Max(1f, measurementFontSize * scale); + } + + private static void DrawCornerCalloutBrackets(Graphics g, System.Drawing.Color foreground, System.Drawing.RectangleF bracketRect, float bracketLength, float outlineThickness, float labelCenterX, float labelBottomY) + { + using Pen outlinePen = new(foreground, outlineThickness) + { + LineJoin = System.Drawing.Drawing2D.LineJoin.Round, + StartCap = System.Drawing.Drawing2D.LineCap.Round, + EndCap = System.Drawing.Drawing2D.LineCap.Round, + }; + + float left = bracketRect.Left; + float top = bracketRect.Top; + float right = bracketRect.Right; + float bottom = bracketRect.Bottom; + + g.DrawLines(outlinePen, + [ + new System.Drawing.PointF(left + bracketLength, top), + new System.Drawing.PointF(left, top), + new System.Drawing.PointF(left, top + bracketLength), + ]); + g.DrawLines(outlinePen, + [ + new System.Drawing.PointF(right - bracketLength, top), + new System.Drawing.PointF(right, top), + new System.Drawing.PointF(right, top + bracketLength), + ]); + g.DrawLines(outlinePen, + [ + new System.Drawing.PointF(left + bracketLength, bottom), + new System.Drawing.PointF(left, bottom), + new System.Drawing.PointF(left, bottom - bracketLength), + ]); + g.DrawLines(outlinePen, + [ + new System.Drawing.PointF(right - bracketLength, bottom), + new System.Drawing.PointF(right, bottom), + new System.Drawing.PointF(right, bottom - bracketLength), + ]); + + float connectorBottom = top - (outlineThickness * 1.2f); + if (connectorBottom > labelBottomY) + { + g.DrawLine(outlinePen, labelCenterX, labelBottomY, labelCenterX, connectorBottom); + } + } + + private static GraphicsPath CreateRoundedRectanglePath(System.Drawing.RectangleF rect, float radius) + { + float diameter = Math.Min(radius * 2f, Math.Min(rect.Width, rect.Height)); + GraphicsPath path = new(); + + if (diameter <= 0) + { + path.AddRectangle(rect); + return path; + } + + path.AddArc(rect.X, rect.Y, diameter, diameter, 180, 90); + path.AddArc(rect.Right - diameter, rect.Y, diameter, diameter, 270, 90); + path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(rect.X, rect.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } + + private static int ScaleMetric(int baseSize, double ratio, int minimum) + { + int scaledValue = (int)Math.Round(baseSize * ratio, MidpointRounding.AwayFromZero); + return Math.Max(scaledValue, minimum); + } + + private static void OverlayLogoOnQrCode(Bitmap qrCodeBitmap, Bitmap logo, double sizePercentage, int moduleCount, int margin, double logoPaddingPixels, System.Drawing.Color backgroundColor) + { + // Calculate the pixel size of each QR code module + // The total size includes the margin on both sides + int totalModules = moduleCount + (margin * 2); + double modulePixelSize = (double)qrCodeBitmap.Width / totalModules; + + // Calculate the punchout space size based on the size percentage + // This is the area that will be covered (blocking QR code modules) + int punchoutSizePixels = (int)(Math.Min(qrCodeBitmap.Width, qrCodeBitmap.Height) * (sizePercentage / 100.0)); + + // Round the punchout size to the nearest module boundary + int punchoutSizeModules = (int)Math.Round(punchoutSizePixels / modulePixelSize); + // Ensure it's at least 1 module and odd number for better centering + if (punchoutSizeModules < 1) punchoutSizeModules = 1; + if (punchoutSizeModules % 2 == 0) punchoutSizeModules++; // Make it odd for symmetry + + // Convert back to pixels, aligned to module boundaries + int punchoutSize = (int)(punchoutSizeModules * modulePixelSize); + + // Calculate the position to center the punchout area + int punchoutX = (qrCodeBitmap.Width - punchoutSize) / 2; + int punchoutY = (qrCodeBitmap.Height - punchoutSize) / 2; + + // Convert padding to actual pixels + // Positive padding = logo smaller than punchout (adds white space) + // Negative padding = logo larger than punchout (logo extends beyond white background) + int paddingPixels = (int)Math.Round(logoPaddingPixels); + + // Calculate the actual logo display size + // Logo fills the punchout area minus the padding on all sides + int logoDisplayWidth = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + int logoDisplayHeight = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + + // If padding is negative, logo is larger than punchout + if (paddingPixels < 0) + { + logoDisplayWidth = punchoutSize + (Math.Abs(paddingPixels) * 2); + logoDisplayHeight = punchoutSize + (Math.Abs(paddingPixels) * 2); + } + + // Calculate logo dimensions preserving aspect ratio + float aspectRatio = (float)logo.Width / logo.Height; + int logoWidth, logoHeight; + + if (aspectRatio > 1) // Wider than tall + { + logoWidth = logoDisplayWidth; + logoHeight = (int)(logoDisplayWidth / aspectRatio); + } + else // Taller than wide or square + { + logoHeight = logoDisplayHeight; + logoWidth = (int)(logoDisplayHeight * aspectRatio); + } + + // Center the logo within the punchout area (or offset if larger) + int logoX = punchoutX + (punchoutSize - logoWidth) / 2; + int logoY = punchoutY + (punchoutSize - logoHeight) / 2; + + using Graphics g = Graphics.FromImage(qrCodeBitmap); + // Set high quality rendering + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; + g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; + g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; + + // Draw the punchout background with the same color as the QR code background. + // Use SourceCopy so a transparent background color actually clears those pixels + // rather than being ignored by the default SourceOver compositing. + g.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceCopy; + using SolidBrush backgroundBrush = new(backgroundColor); + g.FillRectangle(backgroundBrush, punchoutX, punchoutY, punchoutSize, punchoutSize); + + // Reset to SourceOver so the logo composites correctly over the punchout + g.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceOver; + + // Draw the logo scaled to fit within or extend beyond the punchout + g.DrawImage(logo, logoX, logoY, logoWidth, logoHeight); + } + + /// + /// Calculate the smallest side of a QR code based on the distance between the camera and the QR code + /// + /// Distance of camera from QR Code (in) + /// Number of blocks in the QR Code (Version) + /// The smallest size (in) of a QR Code scanning at the given distance + public static bool IsSizeRecommendationAvailableForPadding(double qrPaddingModules) + { + int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); + return normalizedQrPaddingModules is >= 1 and <= 4; + } + + public static double SmallestCodeSide(double distance, int numberOfBlocks, double qrPaddingModules = 2.0) + { + if (!double.IsFinite(distance) || distance <= 0 || numberOfBlocks <= 0) + return 0; + + int padding = NormalizeQrPaddingModules(qrPaddingModules) * 2; + + double blockSize = (distance + 2.721) / 1759.1; + + // check if the block size is too small for normal printer + if (blockSize < 0.007) + return 0; + + double codeSize = blockSize * (numberOfBlocks + padding); + return codeSize; + } + + public static double ContrastRatioLossFrac(double contrastRatio) + { + double x1 = 21; + double y1 = 1; + double x2 = 2.5; + double y2 = 0.8; + + double slope = (y2 - y1) / (x2 - x1); + double yIntercept = y1 - (slope * x1); + + return (slope * contrastRatio) + yIntercept; + } + + public static bool TryGetMinimumRecommendedSideMillimeters( + double distance, + int numberOfBlocks, + Windows.UI.Color foreground, + Windows.UI.Color background, + out double minimumSideMillimeters, + double qrPaddingModules = 2.0) + { + minimumSideMillimeters = 0; + + if (foreground.A < 255 || background.A < 255) + { + return false; + } + + if (!IsSizeRecommendationAvailableForPadding(qrPaddingModules)) + { + return false; + } + + double smallestSideInches = SmallestCodeSide(distance, numberOfBlocks, qrPaddingModules); + if (smallestSideInches == 0) + { + return false; + } + + double contrastRatio = ColorHelpers.GetContrastRatio(foreground, background); + if (contrastRatio < 2.5) + { + return false; + } + + double fractionalLoss = ContrastRatioLossFrac(contrastRatio); + minimumSideMillimeters = (smallestSideInches / fractionalLoss) * 25.4; + return true; + } + + public static QrCodeSizeRecommendation GetSizeRecommendation(double distance, int numberOfBlocks, Windows.UI.Color foreground, Windows.UI.Color background, double qrPaddingModules = 2.0) + { + if (foreground.A < 255 || background.A < 255) + { + return new QrCodeSizeRecommendation( + QrCodeSizeRecommendationKind.TransparencyDependent, + "Exact minimum size depends on the final print surface when transparency is used."); + } + + if (!IsSizeRecommendationAvailableForPadding(qrPaddingModules)) + { + int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); + return new QrCodeSizeRecommendation( + QrCodeSizeRecommendationKind.PaddingDependent, + $"Padding {normalizedQrPaddingModules} is outside the predictable 1-4 module range. Borderless and heavy-border QR Codes scan too variably for reliable print sizing."); + } + + bool isMetric = RegionInfo.CurrentRegion.IsMetric; + double smallestSide = SmallestCodeSide(distance, numberOfBlocks, qrPaddingModules); + + if (smallestSide == 0) + { + return new QrCodeSizeRecommendation( + QrCodeSizeRecommendationKind.Error, + "Error at selected max distance."); + } + + double contrastRatio = ColorHelpers.GetContrastRatio(foreground, background); + + if (contrastRatio < 2.5) + { + return new QrCodeSizeRecommendation( + QrCodeSizeRecommendationKind.LowContrast, + "Color contrast too low"); + } + + if (!TryGetMinimumRecommendedSideMillimeters(distance, numberOfBlocks, foreground, background, out double minimumSideMillimeters, qrPaddingModules)) + { + return new QrCodeSizeRecommendation( + QrCodeSizeRecommendationKind.Error, + "Error at selected max distance."); + } + + if (!isMetric) + { + double smallestSideInches = minimumSideMillimeters / 25.4; + return new QrCodeSizeRecommendation( + QrCodeSizeRecommendationKind.Exact, + $"{smallestSideInches:F2} x {smallestSideInches:F2} in"); + } + + double smallestSideCm = minimumSideMillimeters / 10.0; + return new QrCodeSizeRecommendation( + QrCodeSizeRecommendationKind.Exact, + $"{smallestSideCm:F2} x {smallestSideCm:F2} cm"); + } + + public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, string? logoSvgContent = null, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) + { + text = NormalizeVCardLineEndings(text); + + int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); + QRCode qrCode = EncodeQrCode(text, correctionLevel); + int moduleCount = qrCode.Version.DimensionForVersion; + int renderSize = GetQrRenderSize(moduleCount, normalizedQrPaddingModules); + + SvgRenderer svgRenderer = new() + { + Foreground = new SvgRenderer.Color(foreground.A, foreground.R, foreground.G, foreground.B), + Background = new SvgRenderer.Color(background.A, background.R, background.G, background.B), + }; + + BarcodeWriterSvg barcodeWriter = new() + { + Format = BarcodeFormat.QR_CODE, + Renderer = svgRenderer + }; + + EncodingOptions encodingOptions = new() + { + Width = renderSize, + Height = renderSize, + Margin = normalizedQrPaddingModules, + }; + encodingOptions.Hints.Add(EncodeHintType.ERROR_CORRECTION, correctionLevel); + ApplyCharacterSetHint(encodingOptions, text); + barcodeWriter.Options = encodingOptions; + + SvgImage svg = barcodeWriter.Write(text); + + // If a logo is provided, embed it in the SVG + if (logoImage != null || logoSvgContent != null) + { + svg = EmbedLogoInSvg(svg, logoImage, logoSizePercentage, moduleCount, encodingOptions.Margin, renderSize, logoPaddingPixels, background, logoSvgContent); + } + + svg = ApplyFrameToSvg(svg, renderSize, foreground, background, framePreset, frameText); + + return svg; + } + + private static SvgImage ApplyFrameToSvg(SvgImage svg, int qrRenderSize, System.Drawing.Color foreground, System.Drawing.Color background, QrFramePreset framePreset, string? frameText) + { + string? resolvedFrameText = QrFramePresetCatalog.ResolveText(framePreset, frameText); + if (string.IsNullOrWhiteSpace(resolvedFrameText)) + return svg; + + string svgBody = ExtractSvgInnerContent(svg.Content); + if (string.IsNullOrWhiteSpace(svgBody)) + return svg; + + string qrGroup; + string frameMarkup; + string canvasWidth; + string canvasHeight; + + switch (framePreset) + { + case QrFramePreset.BottomLabel: + { + int outerPadding = ScaleMetric(qrRenderSize, 0.05, 36); + int labelHeight = ScaleMetric(qrRenderSize, 0.16, 84); + int labelSpacing = ScaleMetric(qrRenderSize, 0.035, 20); + int labelWidth = qrRenderSize + ScaleMetric(qrRenderSize, 0.08, 40); + int outlineThickness = ScaleMetric(qrRenderSize, 0.014, 8); + int accentHeight = ScaleMetric(qrRenderSize, 0.014, 8); + int qrX = outerPadding; + int qrY = outerPadding; + int totalWidth = qrRenderSize + (outerPadding * 2); + int totalHeight = qrY + qrRenderSize + labelSpacing + labelHeight + outerPadding; + int labelX = (totalWidth - labelWidth) / 2; + int labelY = qrY + qrRenderSize + labelSpacing; + int accentWidth = Math.Max(labelWidth / 4, ScaleMetric(qrRenderSize, 0.18, 120)); + int accentX = (totalWidth - accentWidth) / 2; + int accentY = labelY - (labelSpacing / 2); + + qrGroup = CreateTranslatedSvgGroup(svgBody, qrX, qrY); + frameMarkup = $""" + + + {CreateSvgTextElement(resolvedFrameText, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness, foreground)} +"""; + canvasWidth = SvgValue(totalWidth); + canvasHeight = SvgValue(totalHeight); + break; + } + case QrFramePreset.RoundedFrame: + { + int outerPadding = ScaleMetric(qrRenderSize, 0.1, 56); + int borderInset = ScaleMetric(qrRenderSize, 0.032, 18); + int labelHeight = ScaleMetric(qrRenderSize, 0.14, 80); + int labelSpacing = ScaleMetric(qrRenderSize, 0.03, 18); + int outlineThickness = ScaleMetric(qrRenderSize, 0.022, 14); + int qrX = outerPadding; + int qrY = outerPadding; + int totalWidth = qrRenderSize + (outerPadding * 2); + int totalHeight = qrY + qrRenderSize + outerPadding + labelSpacing + labelHeight; + int borderX = qrX - borderInset; + int borderY = qrY - borderInset; + int borderWidth = qrRenderSize + (borderInset * 2); + int borderHeight = qrRenderSize + (borderInset * 2); + int borderRadius = ScaleMetric(qrRenderSize, 0.085, 42); + int labelWidth = (int)Math.Round(borderWidth * 0.72, MidpointRounding.AwayFromZero); + int labelX = (totalWidth - labelWidth) / 2; + int labelY = borderY + borderHeight + labelSpacing; + + qrGroup = CreateTranslatedSvgGroup(svgBody, qrX, qrY); + frameMarkup = $""" + + + {CreateSvgTextElement(resolvedFrameText, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness, foreground)} +"""; + canvasWidth = SvgValue(totalWidth); + canvasHeight = SvgValue(totalHeight); + break; + } + case QrFramePreset.CornerCallout: + { + int outerPadding = ScaleMetric(qrRenderSize, 0.11, 64); + int bracketInset = ScaleMetric(qrRenderSize, 0.03, 16); + int bracketLength = ScaleMetric(qrRenderSize, 0.14, 72); + int outlineThickness = ScaleMetric(qrRenderSize, 0.024, 15); + int labelHeight = ScaleMetric(qrRenderSize, 0.13, 72); + int labelSpacing = ScaleMetric(qrRenderSize, 0.04, 24); + int labelWidth = ScaleMetric(qrRenderSize, 0.72, 520); + int totalWidth = qrRenderSize + (outerPadding * 2); + int totalHeight = labelHeight + labelSpacing + qrRenderSize + (outerPadding * 2); + int labelX = (totalWidth - labelWidth) / 2; + int labelY = outerPadding / 3; + int qrX = outerPadding; + int qrY = labelY + labelHeight + labelSpacing; + int bracketLeft = qrX - bracketInset; + int bracketTop = qrY - bracketInset; + int bracketRight = qrX + qrRenderSize + bracketInset; + int bracketBottom = qrY + qrRenderSize + bracketInset; + + qrGroup = CreateTranslatedSvgGroup(svgBody, qrX, qrY); + frameMarkup = $""" + + {CreateSvgTextElement(resolvedFrameText, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness, foreground)} + + +"""; + canvasWidth = SvgValue(totalWidth); + canvasHeight = SvgValue(totalHeight); + break; + } + default: + return svg; + } + + string content = $""" + +{frameMarkup} +{qrGroup} + +"""; + return new SvgImage(content); + } + + private static string CreateTranslatedSvgGroup(string svgBody, double offsetX, double offsetY) + { + return $" {Environment.NewLine}{svgBody}{Environment.NewLine} "; + } + + private static string CreateSvgTextElement(string value, System.Drawing.RectangleF rect, float outlineThickness, System.Drawing.Color foreground) + { + System.Drawing.RectangleF textRect = GetFrameTextRect(rect, outlineThickness); + float fontSize = GetFittedFrameFontSize(value, textRect.Size); + string escapedText = System.Security.SecurityElement.Escape(value) ?? string.Empty; + float centerX = textRect.X + (textRect.Width / 2f); + float centerY = textRect.Y + (textRect.Height / 2f); + return $"{escapedText}"; + } + + private static string ToSvgColor(System.Drawing.Color color) + { + return color.A < 255 + ? $"rgba({color.R},{color.G},{color.B},{(color.A / 255.0).ToString("0.###", CultureInfo.InvariantCulture)})" + : $"rgb({color.R},{color.G},{color.B})"; + } + + private static string SvgValue(double value) + { + return value.ToString("0.###", CultureInfo.InvariantCulture); + } + + private static SvgImage EmbedLogoInSvg(SvgImage svg, Bitmap? logo, double sizePercentage, int moduleCount, int margin, int svgSize, double logoPaddingPixels, System.Drawing.Color backgroundColor, string? logoSvgContent = null) + { + // Calculate the pixel size of each QR code module + int totalModules = moduleCount + (margin * 2); + double modulePixelSize = (double)svgSize / totalModules; + + // Calculate the punchout space size based on the size percentage + // This is the area that will be covered (blocking QR code modules) + int punchoutSizePixels = (int)(svgSize * (sizePercentage / 100.0)); + + // Round the punchout size to the nearest module boundary + int punchoutSizeModules = (int)Math.Round(punchoutSizePixels / modulePixelSize); + if (punchoutSizeModules < 1) punchoutSizeModules = 1; + if (punchoutSizeModules % 2 == 0) punchoutSizeModules++; + + int punchoutSize = (int)(punchoutSizeModules * modulePixelSize); + + // Calculate the position to center the punchout area + int punchoutX = (svgSize - punchoutSize) / 2; + int punchoutY = (svgSize - punchoutSize) / 2; + + // Convert padding to actual pixels + // Positive padding = logo smaller than punchout (adds white space) + // Negative padding = logo larger than punchout (logo extends beyond white background) + int paddingPixels = (int)Math.Round(logoPaddingPixels); + + // Calculate the actual logo display size + // Logo fills the punchout area minus the padding on all sides + int logoDisplayWidth = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + int logoDisplayHeight = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + + // If padding is negative, logo is larger than punchout + if (paddingPixels < 0) + { + logoDisplayWidth = punchoutSize + (Math.Abs(paddingPixels) * 2); + logoDisplayHeight = punchoutSize + (Math.Abs(paddingPixels) * 2); + } + + // Build the SVG logo element with punchout background and logo + // Use rgba() when the background has any transparency so the punchout is correctly transparent + string backgroundColorFill = ToSvgColor(backgroundColor); + + string logoSvgElement; + + if (logoSvgContent != null) + { + // SVG logo: inline as a nested element so all viewers render it correctly + int logoWidth = logoDisplayWidth; + int logoHeight = logoDisplayHeight; + int logoX = punchoutX + (punchoutSize - logoWidth) / 2; + int logoY = punchoutY + (punchoutSize - logoHeight) / 2; + + XElement logoElement = XElement.Parse(logoSvgContent, LoadOptions.PreserveWhitespace); + if (logoElement.Name.LocalName != "svg") + throw new InvalidDataException("The logo must have an SVG root element."); + + string viewBox = ExtractSvgViewBox(logoSvgContent); + if (!string.IsNullOrEmpty(viewBox)) + logoElement.SetAttributeValue("viewBox", viewBox); + logoElement.SetAttributeValue("x", logoX); + logoElement.SetAttributeValue("y", logoY); + logoElement.SetAttributeValue("width", logoWidth); + logoElement.SetAttributeValue("height", logoHeight); + logoElement.SetAttributeValue("preserveAspectRatio", "xMidYMid meet"); + logoElement.SetAttributeValue("overflow", "visible"); + + logoSvgElement = $@" + + + + {logoElement.ToString(SaveOptions.DisableFormatting)}"; + } + else + { + // Raster logo: calculate AR and embed as base64 PNG + float aspectRatio = (float)logo!.Width / logo.Height; + int logoWidth, logoHeight; + + if (aspectRatio > 1) // Wider than tall + { + logoWidth = logoDisplayWidth; + logoHeight = (int)(logoDisplayWidth / aspectRatio); + } + else // Taller than wide or square + { + logoHeight = logoDisplayHeight; + logoWidth = (int)(logoDisplayHeight * aspectRatio); + } + + // Center the logo within the punchout area (or offset if larger) + int logoX = punchoutX + (punchoutSize - logoWidth) / 2; + int logoY = punchoutY + (punchoutSize - logoHeight) / 2; + + // Resize the logo to the display size before encoding to reduce SVG file size + Bitmap resizedLogo = new(logoWidth, logoHeight); + using (Graphics g = Graphics.FromImage(resizedLogo)) + { + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; + g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; + g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; + g.DrawImage(logo, 0, 0, logoWidth, logoHeight); + } + + string base64Logo; + using (MemoryStream ms = new()) + { + resizedLogo.Save(ms, ImageFormat.Png); + byte[] imageBytes = ms.ToArray(); + base64Logo = Convert.ToBase64String(imageBytes); + } + resizedLogo.Dispose(); + + logoSvgElement = $@" + + + + "; + } + + // Build an SVG mask that subtracts the logo area from the QR code content. + // A plain painted on top of SVG paths does nothing — + // the mask approach is the only reliable way to clear QR modules in the logo zone. + string maskId = "logo-mask"; + string maskDefs = $""" + + + + + + +"""; + + // Find the end of the opening tag so we can inject and wrap the QR + // content in a masked group. Start search at the or declarations. + // Use a regex that skips quoted attribute values so a stray '>' inside an attribute + // (e.g. style="fill:>") does not split the tag prematurely. + string svgContent = svg.Content; + Match openTagMatch = SvgTagRegex().Match(svgContent); + int openTagEnd = openTagMatch.Index + openTagMatch.Length - 1; + + // Wrap only the outermost — use LastIndexOf so any nested elements + // (e.g. from an inlined SVG logo) are not accidentally closed early. + string svgBody = svgContent[(openTagEnd + 1)..]; + int lastClose = svgBody.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + + string modifiedContent = svgContent[..(openTagEnd + 1)] + + "\n" + maskDefs + + $"\n" + + (lastClose >= 0 + ? svgBody[..lastClose] + $"\n{logoSvgElement}\n" + : svgBody + $"\n{logoSvgElement}\n"); // malformed but degrade gracefully + + return new SvgImage(modifiedContent); + } + + public static IEnumerable<(string, Result)> GetStringsFromImageFile(StorageFile storageFile) + { + try + { + using Bitmap bitmap = new(storageFile.Path); + return GetStringsFromBitmap(bitmap); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not load image file '{storageFile.Name}': {ex.Message}"); + return []; + } + } + + public static IEnumerable<(string, Result)> GetStringsFromBitmap(Bitmap bitmap) + { + BarcodeReader barcodeReader = new() + { + AutoRotate = true, + Options = new DecodingOptions + { + TryHarder = true, + TryInverted = true, + PossibleFormats = [BarcodeFormat.QR_CODE, BarcodeFormat.DATA_MATRIX, BarcodeFormat.AZTEC, BarcodeFormat.PDF_417], + PureBarcode = false, + } + }; + + foreach (int decodePadding in GetDecodePaddings(bitmap)) + { + Bitmap? paddedBitmap = decodePadding > 0 + ? AddOuterDecodePadding(bitmap, decodePadding) + : null; + Bitmap decodeBitmap = paddedBitmap ?? bitmap; + + Result[] results = barcodeReader.DecodeMultiple(decodeBitmap); + List<(string, Result)> strings = ConvertResultsToStrings(results, decodePadding); + paddedBitmap?.Dispose(); + + if (strings.Count > 0) + return strings; + } + + return []; + } + + private static List<(string, Result)> ConvertResultsToStrings(Result[]? results, int decodePadding) + { + List<(string, Result)> strings = []; + + if (results == null || results.Length == 0) + return strings; + + foreach (Result result in results) + { + if (string.IsNullOrWhiteSpace(result.Text)) + continue; + + if (decodePadding > 0) + ShiftResultPointsToOriginalImage(result, decodePadding); + + strings.Add((result.Text, result)); + } + + return strings; + } + + private static void ShiftResultPointsToOriginalImage(Result result, int decodePadding) + { + ResultPoint[]? points = result.ResultPoints; + if (points == null) + return; + + for (int i = 0; i < points.Length; i++) + { + ResultPoint p = points[i]; + if (p != null) + points[i] = new ResultPoint(p.X - decodePadding, p.Y - decodePadding); + } + } + + private static IEnumerable GetDecodePaddings(Bitmap bitmap) + { + int minDimension = Math.Min(bitmap.Width, bitmap.Height); + int mediumPadding = Math.Clamp((int)Math.Round(minDimension * 0.03, MidpointRounding.AwayFromZero), 8, 24); + int largePadding = Math.Clamp((int)Math.Round(minDimension * 0.08, MidpointRounding.AwayFromZero), 16, 64); + + int[] paddings = [0, mediumPadding, largePadding]; + return paddings.Distinct().Order(); + } + + private static Bitmap AddOuterDecodePadding(Bitmap source, int padding) + { + Bitmap padded = new(source.Width + (padding * 2), source.Height + (padding * 2), PixelFormat.Format32bppArgb); + using Graphics g = Graphics.FromImage(padded); + g.Clear(System.Drawing.Color.White); + g.DrawImageUnscaled(source, padding, padding); + return padded; + } + + /// + /// Extracts the viewBox value from an SVG string (e.g. "0 0 600 530"). + /// Falls back to constructing one from width/height attributes if no viewBox is present. + /// + private static string ExtractSvgViewBox(string svgContent) + { + // Prefer an explicit viewBox attribute + Match viewBoxMatch = + SvgViewboxRegex().Match(svgContent); + if (viewBoxMatch.Success) + return viewBoxMatch.Groups[1].Value; + + // Fall back to synthesising viewBox from width/height on the element + Match widthMatch = + SvgTagWidthRegex().Match(svgContent); + Match heightMatch = + SvgTagHeightRegex().Match(svgContent); + + if (widthMatch.Success && heightMatch.Success) + return $"0 0 {widthMatch.Groups[1].Value} {heightMatch.Groups[1].Value}"; + + return string.Empty; + } + + /// + /// Extracts the inner content of an SVG string — everything between the + /// end of the opening <svg> tag and the last </svg>. + /// Correctly skips any leading XML declaration or DOCTYPE preamble. + /// + private static string ExtractSvgInnerContent(string svgContent) + { + // Locate the and other preamble) + int svgTagStart = svgContent.IndexOf(" of the opening tag + int openTagEnd = svgContent.IndexOf('>', svgTagStart); + if (openTagEnd < 0) + return string.Empty; + + // Find the last (the one that closes the root element) + int closeTagStart = svgContent.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + if (closeTagStart < 0 || closeTagStart <= openTagEnd) + return string.Empty; + + return svgContent[(openTagEnd + 1)..closeTagStart]; + } + + public static int NormalizeQrPaddingModules(double qrPaddingModules) + { + return Math.Clamp((int)Math.Round(qrPaddingModules, MidpointRounding.AwayFromZero), 0, 10); + } + + private static int GetQrRenderSize(int moduleCount, int margin) + { + int totalModules = moduleCount + (margin * 2); + int pixelsPerModule = Math.Max(1, MaxQrRenderSize / totalModules); + return totalModules * pixelsPerModule; + } + + [GeneratedRegex(@"""']|""[^""]*""|'[^']*')*>", RegexOptions.IgnoreCase | RegexOptions.Singleline, "en-US")] + private static partial Regex SvgTagRegex(); + + [GeneratedRegex(@"viewBox\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex SvgViewboxRegex(); + + [GeneratedRegex(@"]*\swidth\s*=\s*[""']([0-9.]+)[""']", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex SvgTagWidthRegex(); + + [GeneratedRegex(@"]*\sheight\s*=\s*[""']([0-9.]+)[""']", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex SvgTagHeightRegex(); +} \ No newline at end of file diff --git a/Simple QR Code Maker.Windows/Helpers/QrExportWriter.cs b/Simple QR Code Maker.Windows/Helpers/QrExportWriter.cs new file mode 100644 index 0000000..047f5bc --- /dev/null +++ b/Simple QR Code Maker.Windows/Helpers/QrExportWriter.cs @@ -0,0 +1,94 @@ +using Simple_QR_Code_Maker.Models; +using System.IO.Compression; +using System.Text; + +namespace Simple_QR_Code_Maker.Helpers; + +internal static class QrExportWriter +{ + internal static Task RenderPngAsync(RequestedQrCodeItem code, QrRenderSettingsSnapshot settings) + => Task.Run(() => + { + using QrRenderSettingsSnapshot? commonSettings = settings.CreateCommonBrandOverride(code); + QrRenderSettingsSnapshot effectiveSettings = commonSettings ?? settings; + using MemoryStream output = new(); + QrCodeRenderer.SaveQrCodePngToStream( + output, code.CodeAsText, effectiveSettings.ErrorCorrectionLevel, + effectiveSettings.ForegroundColor, effectiveSettings.BackgroundColor, + effectiveSettings.LogoImage, effectiveSettings.LogoSizePercentage, + effectiveSettings.LogoPaddingPixels, effectiveSettings.QrPaddingModules, + effectiveSettings.FramePreset, ResolveFrameText(code, effectiveSettings)); + return output.ToArray(); + }); + + internal static Task RenderSvgAsync(RequestedQrCodeItem code, QrRenderSettingsSnapshot settings) + => Task.Run(() => + { + using QrRenderSettingsSnapshot? commonSettings = settings.CreateCommonBrandOverride(code); + QrRenderSettingsSnapshot effectiveSettings = commonSettings ?? settings; + return QrCodeRenderer.GetSvgQrCodeForText( + code.CodeAsText, effectiveSettings.ErrorCorrectionLevel, + effectiveSettings.ForegroundColor, effectiveSettings.BackgroundColor, + effectiveSettings.LogoImage, effectiveSettings.LogoSizePercentage, + effectiveSettings.LogoPaddingPixels, effectiveSettings.LogoSvgContent, + effectiveSettings.QrPaddingModules, effectiveSettings.FramePreset, + ResolveFrameText(code, effectiveSettings)).Content; + }); + + internal static async Task WriteAsync( + RequestedQrCodeItem code, Stream output, FileKind kind, QrRenderSettingsSnapshot settings) + { + switch (kind) + { + case FileKind.None: + return; + case FileKind.PNG: + await output.WriteAsync(await RenderPngAsync(code, settings)); + break; + case FileKind.SVG: + using (StreamWriter writer = new(output, new UTF8Encoding(false), 1024, leaveOpen: true)) + { + await writer.WriteAsync(await RenderSvgAsync(code, settings)); + await writer.FlushAsync(); + } + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported QR export format."); + } + } + + internal static async Task WriteZipAsync( + Stream output, + IReadOnlyList codes, + QrRenderSettingsSnapshot settings, + Action? updateProgress, + params FileKind[] kinds) + { + using (ZipArchive archive = new(output, ZipArchiveMode.Create, leaveOpen: true)) + { + for (int index = 0; index < codes.Count; index++) + { + RequestedQrCodeItem code = codes[index]; + if (!string.IsNullOrWhiteSpace(code.SafeFileNameBase)) + { + foreach (FileKind kind in kinds) + { + ZipArchiveEntry entry = archive.CreateEntry(code.SafeFileNameBase + GetFileExtension(kind), CompressionLevel.Optimal); + using Stream entryStream = entry.Open(); + await WriteAsync(code, entryStream, kind, settings); + } + } + updateProgress?.Invoke(index + 1); + } + } + + await output.FlushAsync(); + } + + internal static string GetFileExtension(FileKind kind) => $".{kind.ToString().ToLowerInvariant()}"; + + private static string? ResolveFrameText(RequestedQrCodeItem code, QrRenderSettingsSnapshot settings) + => QrFrameTextResolver.Resolve( + settings.FramePreset, settings.FrameTextSource, settings.FrameText, + code.CodeAsText, code.ContentKind, code.MultiLineCodeModeOverride); +} diff --git a/Simple QR Code Maker/Helpers/RedirectorWarningSettingsHelper.cs b/Simple QR Code Maker.Windows/Helpers/RedirectorWarningSettingsHelper.cs similarity index 100% rename from Simple QR Code Maker/Helpers/RedirectorWarningSettingsHelper.cs rename to Simple QR Code Maker.Windows/Helpers/RedirectorWarningSettingsHelper.cs diff --git a/Simple QR Code Maker/Helpers/RuntimeHelper.cs b/Simple QR Code Maker.Windows/Helpers/RuntimeHelper.cs similarity index 64% rename from Simple QR Code Maker/Helpers/RuntimeHelper.cs rename to Simple QR Code Maker.Windows/Helpers/RuntimeHelper.cs index 8803082..650497d 100644 --- a/Simple QR Code Maker/Helpers/RuntimeHelper.cs +++ b/Simple QR Code Maker.Windows/Helpers/RuntimeHelper.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using System.Text; +using Simple_QR_Code_Maker.Contracts.Services; namespace Simple_QR_Code_Maker.Helpers; @@ -18,3 +19,10 @@ public static bool IsMSIX } } } + +public sealed class RuntimeEnvironment : IRuntimeEnvironment +{ + public bool IsPackaged => RuntimeHelper.IsMSIX; + + public string LocalApplicationDataPath => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); +} diff --git a/Simple QR Code Maker/Helpers/SettingsStorageExtensions.cs b/Simple QR Code Maker.Windows/Helpers/SettingsStorageExtensions.cs similarity index 70% rename from Simple QR Code Maker/Helpers/SettingsStorageExtensions.cs rename to Simple QR Code Maker.Windows/Helpers/SettingsStorageExtensions.cs index 7424d19..8a55704 100644 --- a/Simple QR Code Maker/Helpers/SettingsStorageExtensions.cs +++ b/Simple QR Code Maker.Windows/Helpers/SettingsStorageExtensions.cs @@ -1,5 +1,6 @@ using Simple_QR_Code_Maker.Core.Helpers; - +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Services; using Windows.Storage; using Windows.Storage.Streams; @@ -18,28 +19,29 @@ public static bool IsRoamingStorageAvailable(this ApplicationData appData) public static async Task SaveAsync(this StorageFolder folder, string name, T content) { - StorageFile file = await folder.CreateFileAsync(GetFileName(name), CreationCollisionOption.ReplaceExisting); - string fileContent = await Json.StringifyAsync(content); - - await FileIO.WriteTextAsync(file, fileContent); + await new AtomicJsonFileStore(folder.Path).SaveAsync(name, content); } public static async Task ReadAsync(this StorageFolder folder, string name) { - if (!File.Exists(Path.Combine(folder.Path, GetFileName(name)))) - { - return default; - } + return await new AtomicJsonFileStore(folder.Path).ReadAsync(name); + } - StorageFile file = await folder.GetFileAsync($"{name}.json"); - string fileContent = await FileIO.ReadTextAsync(file); + public static async Task SaveAsync(this IJsonFileStore files, string name, T content) + { + string json = await Json.StringifyAsync(content); + await files.WriteAsync(GetFileName(name), json); + } - return await Json.ToObjectAsync(fileContent); + public static async Task ReadAsync(this IJsonFileStore files, string name) + { + string? json = await files.ReadAsync(GetFileName(name)); + return json is null ? default : await Json.ToObjectAsync(json); } public static async Task SaveAsync(this ApplicationDataContainer settings, string key, T value) { - settings.SaveString(key, await Json.StringifyAsync(value)); + await new ContainerSettingsStore(settings).SaveAsync(key, value); } public static void SaveString(this ApplicationDataContainer settings, string key, string value) @@ -49,14 +51,18 @@ public static void SaveString(this ApplicationDataContainer settings, string key public static async Task ReadAsync(this ApplicationDataContainer settings, string key) { - object? obj; + return await new ContainerSettingsStore(settings).ReadAsync(key); + } - if (settings.Values.TryGetValue(key, out obj)) - { - return await Json.ToObjectAsync((string)obj); - } + public static async Task SaveAsync(this ISettingsStore settings, string key, T value) + { + await settings.WriteAsync(key, await Json.StringifyAsync(value)); + } - return default; + public static async Task ReadAsync(this ISettingsStore settings, string key) + { + string? json = await settings.ReadAsync(key); + return json is null ? default : await Json.ToObjectAsync(json); } public static async Task SaveFileAsync(this StorageFolder folder, byte[] content, string fileName, CreationCollisionOption options = CreationCollisionOption.ReplaceExisting) diff --git a/Simple QR Code Maker.Windows/Helpers/SettingsStoragePaths.cs b/Simple QR Code Maker.Windows/Helpers/SettingsStoragePaths.cs new file mode 100644 index 0000000..8e95352 --- /dev/null +++ b/Simple QR Code Maker.Windows/Helpers/SettingsStoragePaths.cs @@ -0,0 +1,14 @@ +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Helpers; + +public static class SettingsStoragePaths +{ + public const string DefaultFileName = "LocalSettings.json"; + + public static string GetFolder(string localApplicationData, LocalSettingsOptions options) => + Path.Combine(localApplicationData, options.ApplicationDataFolder ?? Path.Combine("Simple QR Code Maker", "ApplicationData")); + + public static string GetLegacyHistoryFolder(string localApplicationData) => + Path.Combine(localApplicationData, "Simple QR Code Maker", "ApplicationData"); +} diff --git a/Simple QR Code Maker/Models/BrandCreationOptions.cs b/Simple QR Code Maker.Windows/Models/BrandCreationOptions.cs similarity index 100% rename from Simple QR Code Maker/Models/BrandCreationOptions.cs rename to Simple QR Code Maker.Windows/Models/BrandCreationOptions.cs diff --git a/Simple QR Code Maker/Models/BrandItem.cs b/Simple QR Code Maker.Windows/Models/BrandItem.cs similarity index 91% rename from Simple QR Code Maker/Models/BrandItem.cs rename to Simple QR Code Maker.Windows/Models/BrandItem.cs index 4d6a479..cb4fb55 100644 --- a/Simple QR Code Maker/Models/BrandItem.cs +++ b/Simple QR Code Maker.Windows/Models/BrandItem.cs @@ -62,7 +62,7 @@ public bool Equals(BrandItem? other) if (other is null) return false; - return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase); + return string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); } public override bool Equals(object? obj) @@ -70,5 +70,5 @@ public override bool Equals(object? obj) return Equals(obj as BrandItem); } - public override int GetHashCode() => HashCode.Combine(Name.ToUpperInvariant()); + public override int GetHashCode() => Name is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Name); } diff --git a/Simple QR Code Maker/Models/BrandJsonContext.cs b/Simple QR Code Maker.Windows/Models/BrandJsonContext.cs similarity index 100% rename from Simple QR Code Maker/Models/BrandJsonContext.cs rename to Simple QR Code Maker.Windows/Models/BrandJsonContext.cs diff --git a/Simple QR Code Maker/Models/BrandJsonSerializerOptions.cs b/Simple QR Code Maker.Windows/Models/BrandJsonSerializerOptions.cs similarity index 100% rename from Simple QR Code Maker/Models/BrandJsonSerializerOptions.cs rename to Simple QR Code Maker.Windows/Models/BrandJsonSerializerOptions.cs diff --git a/Simple QR Code Maker/Models/ColorJsonConverter.cs b/Simple QR Code Maker.Windows/Models/ColorJsonConverter.cs similarity index 100% rename from Simple QR Code Maker/Models/ColorJsonConverter.cs rename to Simple QR Code Maker.Windows/Models/ColorJsonConverter.cs diff --git a/Simple QR Code Maker/Models/CommonBrandCatalog.cs b/Simple QR Code Maker.Windows/Models/CommonBrandCatalog.cs similarity index 100% rename from Simple QR Code Maker/Models/CommonBrandCatalog.cs rename to Simple QR Code Maker.Windows/Models/CommonBrandCatalog.cs diff --git a/Simple QR Code Maker/Models/CommonBrandPreset.cs b/Simple QR Code Maker.Windows/Models/CommonBrandPreset.cs similarity index 100% rename from Simple QR Code Maker/Models/CommonBrandPreset.cs rename to Simple QR Code Maker.Windows/Models/CommonBrandPreset.cs diff --git a/Simple QR Code Maker/Models/DecodingHistoryItem.cs b/Simple QR Code Maker.Windows/Models/DecodingHistoryItem.cs similarity index 100% rename from Simple QR Code Maker/Models/DecodingHistoryItem.cs rename to Simple QR Code Maker.Windows/Models/DecodingHistoryItem.cs diff --git a/Simple QR Code Maker/Models/DecodingHistoryJsonContext.cs b/Simple QR Code Maker.Windows/Models/DecodingHistoryJsonContext.cs similarity index 100% rename from Simple QR Code Maker/Models/DecodingHistoryJsonContext.cs rename to Simple QR Code Maker.Windows/Models/DecodingHistoryJsonContext.cs diff --git a/Simple QR Code Maker/Models/DecodingHistoryJsonSerializerOptions.cs b/Simple QR Code Maker.Windows/Models/DecodingHistoryJsonSerializerOptions.cs similarity index 100% rename from Simple QR Code Maker/Models/DecodingHistoryJsonSerializerOptions.cs rename to Simple QR Code Maker.Windows/Models/DecodingHistoryJsonSerializerOptions.cs diff --git a/Simple QR Code Maker/Models/EmojiLogoStyle.cs b/Simple QR Code Maker.Windows/Models/EmojiLogoStyle.cs similarity index 100% rename from Simple QR Code Maker/Models/EmojiLogoStyle.cs rename to Simple QR Code Maker.Windows/Models/EmojiLogoStyle.cs diff --git a/Simple QR Code Maker/Models/ErrorCorrectionOptions.cs b/Simple QR Code Maker.Windows/Models/ErrorCorrectionOptions.cs similarity index 100% rename from Simple QR Code Maker/Models/ErrorCorrectionOptions.cs rename to Simple QR Code Maker.Windows/Models/ErrorCorrectionOptions.cs diff --git a/Simple QR Code Maker/Models/HistoryItem.cs b/Simple QR Code Maker.Windows/Models/HistoryItem.cs similarity index 93% rename from Simple QR Code Maker/Models/HistoryItem.cs rename to Simple QR Code Maker.Windows/Models/HistoryItem.cs index a387491..1fb9f22 100644 --- a/Simple QR Code Maker/Models/HistoryItem.cs +++ b/Simple QR Code Maker.Windows/Models/HistoryItem.cs @@ -99,7 +99,7 @@ public bool Equals(HistoryItem? other) if (other is null) return false; - return CodesContent.Equals(other.CodesContent, StringComparison.InvariantCulture); + return string.Equals(CodesContent, other.CodesContent, StringComparison.InvariantCulture); } public override bool Equals(object? obj) @@ -107,5 +107,5 @@ public override bool Equals(object? obj) return Equals(obj as HistoryItem); } - public override int GetHashCode() => HashCode.Combine(CodesContent); + public override int GetHashCode() => CodesContent is null ? 0 : StringComparer.InvariantCulture.GetHashCode(CodesContent); } \ No newline at end of file diff --git a/Simple QR Code Maker/Models/HistoryJsonContext.cs b/Simple QR Code Maker.Windows/Models/HistoryJsonContext.cs similarity index 100% rename from Simple QR Code Maker/Models/HistoryJsonContext.cs rename to Simple QR Code Maker.Windows/Models/HistoryJsonContext.cs diff --git a/Simple QR Code Maker/Models/HistoryJsonSerializerOptions.cs b/Simple QR Code Maker.Windows/Models/HistoryJsonSerializerOptions.cs similarity index 100% rename from Simple QR Code Maker/Models/HistoryJsonSerializerOptions.cs rename to Simple QR Code Maker.Windows/Models/HistoryJsonSerializerOptions.cs diff --git a/Simple QR Code Maker/Models/LocalSettingsJsonContext.cs b/Simple QR Code Maker.Windows/Models/LocalSettingsJsonContext.cs similarity index 100% rename from Simple QR Code Maker/Models/LocalSettingsJsonContext.cs rename to Simple QR Code Maker.Windows/Models/LocalSettingsJsonContext.cs diff --git a/Simple QR Code Maker/Models/LocalSettingsOptions.cs b/Simple QR Code Maker.Windows/Models/LocalSettingsOptions.cs similarity index 100% rename from Simple QR Code Maker/Models/LocalSettingsOptions.cs rename to Simple QR Code Maker.Windows/Models/LocalSettingsOptions.cs diff --git a/Simple QR Code Maker/Models/QrCodeDesignState.cs b/Simple QR Code Maker.Windows/Models/QrCodeDesignState.cs similarity index 100% rename from Simple QR Code Maker/Models/QrCodeDesignState.cs rename to Simple QR Code Maker.Windows/Models/QrCodeDesignState.cs diff --git a/Simple QR Code Maker/Models/QrCodeSizeRecommendation.cs b/Simple QR Code Maker.Windows/Models/QrCodeSizeRecommendation.cs similarity index 100% rename from Simple QR Code Maker/Models/QrCodeSizeRecommendation.cs rename to Simple QR Code Maker.Windows/Models/QrCodeSizeRecommendation.cs diff --git a/Simple QR Code Maker/Models/QrRenderSettingsSnapshot.cs b/Simple QR Code Maker.Windows/Models/QrRenderSettingsSnapshot.cs similarity index 98% rename from Simple QR Code Maker/Models/QrRenderSettingsSnapshot.cs rename to Simple QR Code Maker.Windows/Models/QrRenderSettingsSnapshot.cs index b37fa7f..b45cfad 100644 --- a/Simple QR Code Maker/Models/QrRenderSettingsSnapshot.cs +++ b/Simple QR Code Maker.Windows/Models/QrRenderSettingsSnapshot.cs @@ -93,7 +93,7 @@ public static QrRenderSettingsSnapshot Create( ErrorCorrectionLevel.H, brand.Foreground, System.Drawing.Color.White, - BarcodeHelpers.RasterizeSvgToBitmap(svg, 512, 512), + QrCodeRenderer.RasterizeSvgToBitmap(svg, 512, 512), 15, 4, svg, diff --git a/Simple QR Code Maker/Models/RequestedQrCodeItem.cs b/Simple QR Code Maker.Windows/Models/RequestedQrCodeItem.cs similarity index 70% rename from Simple QR Code Maker/Models/RequestedQrCodeItem.cs rename to Simple QR Code Maker.Windows/Models/RequestedQrCodeItem.cs index 8f9c8da..1de70aa 100644 --- a/Simple QR Code Maker/Models/RequestedQrCodeItem.cs +++ b/Simple QR Code Maker.Windows/Models/RequestedQrCodeItem.cs @@ -8,5 +8,5 @@ public sealed record RequestedQrCodeItem( MultiLineCodeMode? MultiLineCodeModeOverride = null, bool UseCommonBrand = false) { - public string SafeFileNameBase => CodeAsText.ToSafeFileName(); + public string SafeFileNameBase => string.IsNullOrWhiteSpace(CodeAsText) ? string.Empty : CodeAsText.ToSafeFileName(); } \ No newline at end of file diff --git a/Simple QR Code Maker.Windows/Services/AtomicJsonFileStore.cs b/Simple QR Code Maker.Windows/Services/AtomicJsonFileStore.cs new file mode 100644 index 0000000..6406bdd --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/AtomicJsonFileStore.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using Simple_QR_Code_Maker.Contracts.Services; + +namespace Simple_QR_Code_Maker.Services; + +public sealed class AtomicJsonFileStore(string folderPath, IAtomicFileOperations files) : IJsonFileStore +{ + private static readonly ConcurrentDictionary Gates = new(StringComparer.OrdinalIgnoreCase); + + public AtomicJsonFileStore(string folderPath) : this(folderPath, new AtomicFileOperations()) + { + } + + public Task ReadAsync(string fileName) => files.ReadAsync(Path.Combine(folderPath, fileName)); + + public async Task WriteAsync(string fileName, string json) + { + string destination = Path.GetFullPath(Path.Combine(folderPath, fileName)); + SemaphoreSlim gate = Gates.GetOrAdd(destination, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync().ConfigureAwait(false); + string pending = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await files.WriteAsync(pending, json).ConfigureAwait(false); + files.Replace(pending, destination); + } + finally + { + try + { + files.Delete(pending); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not remove pending persistence file: {ex.Message}"); + } + gate.Release(); + } + } +} + +public sealed class AtomicFileOperations : IAtomicFileOperations +{ + public async Task ReadAsync(string path) + { + try + { + return await File.ReadAllTextAsync(path).ConfigureAwait(false); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } + + public Task WriteAsync(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + return File.WriteAllTextAsync(path, content, new UTF8Encoding(false)); + } + + public void Replace(string sourcePath, string destinationPath) => File.Move(sourcePath, destinationPath, overwrite: true); + + public void Delete(string path) => File.Delete(path); +} diff --git a/Simple QR Code Maker/Services/BrandService.cs b/Simple QR Code Maker.Windows/Services/BrandService.cs similarity index 82% rename from Simple QR Code Maker/Services/BrandService.cs rename to Simple QR Code Maker.Windows/Services/BrandService.cs index 7bf812b..6e17946 100644 --- a/Simple QR Code Maker/Services/BrandService.cs +++ b/Simple QR Code Maker.Windows/Services/BrandService.cs @@ -1,15 +1,14 @@ using Simple_QR_Code_Maker.Contracts.Services; -using Simple_QR_Code_Maker.Helpers; using Simple_QR_Code_Maker.Models; using System.Collections.ObjectModel; namespace Simple_QR_Code_Maker.Services; -public class BrandService : IBrandService +public class BrandService(IBrandStore store) : IBrandService { public async Task LoadAsync(ObservableCollection brandItems) { - ObservableCollection loadedBrands = await BrandStorageHelper.LoadBrandsAsync(); + ObservableCollection loadedBrands = await store.LoadAsync(); brandItems.Clear(); foreach (BrandItem item in loadedBrands) @@ -22,13 +21,13 @@ public async Task AddOrReplaceAndSaveAsync(ObservableCollection brand { brandItems.Remove(brand); brandItems.Insert(0, brand); - await BrandStorageHelper.SaveBrandsAsync(brandItems); + await store.SaveAsync(brandItems); } public async Task DeleteAndSaveAsync(ObservableCollection brandItems, BrandItem brand) { brandItems.Remove(brand); - await BrandStorageHelper.SaveBrandsAsync(brandItems); + await store.SaveAsync(brandItems); } public async Task SetDefaultAndSaveAsync(ObservableCollection brandItems, BrandItem brand) @@ -43,7 +42,7 @@ public async Task SetDefaultAndSaveAsync(ObservableCollection brandIt RefreshBrandItemInList(brandItems, previousDefault); RefreshBrandItemInList(brandItems, brand); - await BrandStorageHelper.SaveBrandsAsync(brandItems); + await store.SaveAsync(brandItems); } public async Task ReplaceAndSaveAsync(ObservableCollection brandItems, BrandItem existingBrand, BrandItem replacementBrand) @@ -54,7 +53,7 @@ public async Task ReplaceAndSaveAsync(ObservableCollection bran brandItems.RemoveAt(index); brandItems.Insert(index, replacementBrand); - await BrandStorageHelper.SaveBrandsAsync(brandItems); + await store.SaveAsync(brandItems); return true; } diff --git a/Simple QR Code Maker.Windows/Services/BrandStore.cs b/Simple QR Code Maker.Windows/Services/BrandStore.cs new file mode 100644 index 0000000..8cfb7e0 --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/BrandStore.cs @@ -0,0 +1,47 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Text.Json; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Services; + +public sealed class BrandStore(IJsonFileStore files) : IBrandStore +{ + public async Task> LoadAsync() + { + try + { + string? json = await files.ReadAsync("Brands.json"); + if (string.IsNullOrWhiteSpace(json)) + { + Debug.WriteLine("Brands.json is missing or empty - starting with empty brands."); + return []; + } + + ObservableCollection brands = JsonSerializer.Deserialize(json, BrandJsonContext.Default.ObservableCollectionBrandItem) ?? []; + Debug.WriteLine($"Loaded {brands.Count} brands from Brands.json"); + return brands; + } + catch (Exception ex) + { + Debug.WriteLine($"Error loading Brands.json: {ex.GetType().Name}: {ex.Message}"); + return []; + } + } + + public async Task SaveAsync(ObservableCollection items) + { + string json = JsonSerializer.Serialize(items, BrandJsonContext.Default.ObservableCollectionBrandItem); + try + { + await files.WriteAsync("Brands.json", json).ConfigureAwait(false); + Debug.WriteLine($"Saved {items.Count} brands to Brands.json ({json.Length} bytes)"); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to save brands: {ex.Message}"); + throw; + } + } +} diff --git a/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs b/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs new file mode 100644 index 0000000..b41d006 --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs @@ -0,0 +1,160 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Text.Json; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Services; + +public sealed class DecodingHistoryStore(IJsonFileStore files, IHistoryImageStore images) : IDecodingHistoryStore +{ + private const string FileName = "DecodingHistory.json"; + private const int MaximumItems = 50; + private readonly SemaphoreSlim _gate = new(1, 1); + + public async Task SaveImageCopyAsync(string sourcePath) + { + try + { + return await images.SaveCopyAsync(sourcePath); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to save image copy for decoding history: {ex.Message}"); + return null; + } + } + + public async Task> LoadAsync() + { + try + { + return await ReadAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to load decoding history: {ex.Message}"); + return []; + } + } + + public async Task SaveAsync(ObservableCollection items) + { + string json = Serialize(items); + await _gate.WaitAsync(); + try + { + await SaveCoreAsync(json); + } + finally + { + _gate.Release(); + } + } + + public async Task AddAndSaveAsync(ObservableCollection items, DecodingHistoryItem newItem) + { + await _gate.WaitAsync(); + try + { + ObservableCollection candidate = new(items.Prepend(newItem).Take(MaximumItems)); + await SaveCoreAsync(Serialize(candidate)); + items.Clear(); + foreach (DecodingHistoryItem item in candidate) + items.Add(item); + } + finally + { + _gate.Release(); + } + } + + public async Task UpdateLatestAndSaveAsync(ObservableCollection items, string sourcePath, List decodedTexts) + { + await _gate.WaitAsync(); + try + { + if (items.Count == 0) + return; + + DecodingHistoryItem latest = items[0]; + string? replacementPath = await images.SaveCopyAsync(sourcePath); + if (replacementPath is null) + throw new IOException("The replacement decoding history image could not be copied."); + + ObservableCollection candidate = JsonSerializer.Deserialize( + Serialize(items), DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem)!; + candidate[0].SavedImagePath = replacementPath; + candidate[0].DecodedTexts = [.. decodedTexts]; + string oldImagePath = latest.SavedImagePath; + try + { + await SaveCoreAsync(Serialize(candidate), oldImagePath); + } + catch + { + await DeleteImageAsync(replacementPath); + throw; + } + latest.SavedImagePath = replacementPath; + latest.DecodedTexts = [.. decodedTexts]; + if (!string.IsNullOrEmpty(oldImagePath) && !items.Any(item => string.Equals(item.SavedImagePath, oldImagePath, StringComparison.OrdinalIgnoreCase))) + await DeleteImageAsync(oldImagePath); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to update decoding history: {ex.Message}"); + throw; + } + finally + { + _gate.Release(); + } + } + + private async Task> ReadAsync() + { + string? json = await files.ReadAsync(FileName); + return string.IsNullOrWhiteSpace(json) + ? [] + : JsonSerializer.Deserialize(json, DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem) ?? []; + } + + private async Task SaveCoreAsync(string json, string? replacedImagePath = null) + { + ObservableCollection previous = await LoadAsync(); + try + { + await files.WriteAsync(FileName, json); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to save decoding history: {ex.Message}"); + throw; + } + ObservableCollection current = JsonSerializer.Deserialize( + json, DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem)!; + HashSet retainedImages = new(current.Select(item => item.SavedImagePath), StringComparer.OrdinalIgnoreCase); + foreach (string path in previous.Select(item => item.SavedImagePath).Where(path => !string.IsNullOrEmpty(path)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!retainedImages.Contains(path) && !string.Equals(path, replacedImagePath, StringComparison.OrdinalIgnoreCase)) + await DeleteImageAsync(path); + } + Debug.WriteLine($"Saved {current.Count} decoding history items."); + } + + private async Task DeleteImageAsync(string path) + { + try + { + await images.DeleteAsync(path); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not delete decoding history image: {ex.Message}"); + } + } + + private static string Serialize(ObservableCollection items) => + JsonSerializer.Serialize(items, DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem); +} diff --git a/Simple QR Code Maker.Windows/Services/HistoryImageStore.cs b/Simple QR Code Maker.Windows/Services/HistoryImageStore.cs new file mode 100644 index 0000000..2a8a132 --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/HistoryImageStore.cs @@ -0,0 +1,37 @@ +using Simple_QR_Code_Maker.Contracts.Services; + +namespace Simple_QR_Code_Maker.Services; + +public sealed class HistoryImageStore(string folderPath, TimeProvider clock) : IHistoryImageStore +{ + public async Task SaveCopyAsync(string sourcePath) + { + if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + return null; + + Directory.CreateDirectory(folderPath); + string path = Path.Combine(folderPath, $"{clock.GetLocalNow().Ticks}_{Guid.NewGuid():N}_decoded.png"); + try + { + using FileStream source = File.OpenRead(sourcePath); + using FileStream destination = new(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await source.CopyToAsync(destination).ConfigureAwait(false); + return path; + } + catch + { + File.Delete(path); + throw; + } + } + + public Task DeleteAsync(string path) + { + // History can contain user paths from imported data. Never delete outside our owned folder. + string root = Path.GetFullPath(folderPath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + string candidate = Path.GetFullPath(path); + if (candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + File.Delete(candidate); + return Task.CompletedTask; + } +} diff --git a/Simple QR Code Maker.Windows/Services/HistoryService.cs b/Simple QR Code Maker.Windows/Services/HistoryService.cs new file mode 100644 index 0000000..ec45895 --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/HistoryService.cs @@ -0,0 +1,66 @@ +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Text.Json; + +namespace Simple_QR_Code_Maker.Services; + +public class HistoryService(IHistoryStore store) : IHistoryService +{ + public Task PendingShutdownSave { get; private set; } = Task.CompletedTask; + + public async Task LoadAsync(ObservableCollection historyItems) + { + ObservableCollection loadedHistory = await store.LoadAsync(); + + historyItems.Clear(); + foreach (HistoryItem item in loadedHistory) + { + historyItems.Add(item); + } + } + + public async Task SaveAsync(ObservableCollection historyItems) + { + await store.SaveAsync(historyItems); + } + + public async Task AddOrReplaceAndSaveAsync(ObservableCollection historyItems, HistoryItem historyItem) + { + historyItems.Remove(historyItem); + historyItems.Insert(0, historyItem); + await store.SaveAsync(historyItems); + } + + public void SaveSnapshotOnShutdown(IEnumerable historyItems, HistoryItem historyItem) + { + try + { + ObservableCollection snapshot = new(historyItems.Where(item => !item.Equals(historyItem))); + snapshot.Insert(0, historyItem); + // Freeze mutable items and arrays before any asynchronous work can yield. + string json = JsonSerializer.Serialize(snapshot, HistoryJsonContext.Default.ObservableCollectionHistoryItem); + snapshot = JsonSerializer.Deserialize(json, HistoryJsonContext.Default.ObservableCollectionHistoryItem)!; + PendingShutdownSave = Task.WhenAll(PendingShutdownSave, SaveShutdownSnapshotAsync(snapshot)); + } + catch (Exception ex) + { + Debug.WriteLine($"SaveHistoryOnShutdown error: {ex}"); + PendingShutdownSave = Task.WhenAll(PendingShutdownSave, Task.FromException(ex)); + } + } + + private async Task SaveShutdownSnapshotAsync(ObservableCollection snapshot) + { + try + { + await store.SaveAsync(snapshot).ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"SaveHistoryOnShutdown error: {ex}"); + throw; + } + } +} diff --git a/Simple QR Code Maker.Windows/Services/HistoryStore.cs b/Simple QR Code Maker.Windows/Services/HistoryStore.cs new file mode 100644 index 0000000..d34a4c7 --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/HistoryStore.cs @@ -0,0 +1,185 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; +using Windows.UI; + +namespace Simple_QR_Code_Maker.Services; + +public sealed class HistoryStore(IJsonFileStore files, ISettingsStore legacySettings, TimeProvider clock) : IHistoryStore +{ + private const string HistoryFileName = "History.json"; + private const string LegacyKey = "HistoryItems"; + private readonly SemaphoreSlim _loadGate = new(1, 1); + + public async Task> LoadAsync() + { + await _loadGate.WaitAsync(); + try + { + ObservableCollection? current = await LoadFileAsync(); + if (current is { Count: > 0 }) + return current; + + ObservableCollection? migrated = await MigrateAsync(); + if (migrated is not { Count: > 0 }) + return []; + + // Do not remove the only durable copy until the replacement has been committed. + await SaveAsync(migrated); + try + { + await legacySettings.RemoveAsync(LegacyKey); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not clear old history settings: {ex.Message}"); + } + return migrated; + } + finally + { + _loadGate.Release(); + } + } + + public async Task SaveAsync(ObservableCollection items) + { + string json = JsonSerializer.Serialize(items, HistoryJsonContext.Default.ObservableCollectionHistoryItem); + try + { + await files.WriteAsync(HistoryFileName, json).ConfigureAwait(false); + Debug.WriteLine($"Saved {items.Count} items to {HistoryFileName} ({json.Length} bytes)"); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to save history: {ex.Message}"); + throw; + } + } + + private async Task?> LoadFileAsync() + { + try + { + string? json = await files.ReadAsync(HistoryFileName); + if (string.IsNullOrWhiteSpace(json)) + { + Debug.WriteLine("History.json is missing or empty - attempting migration from old settings."); + return null; + } + + ObservableCollection? items = JsonSerializer.Deserialize(json, HistoryJsonContext.Default.ObservableCollectionHistoryItem); + if (items is not null) + FixColors(items); + return items; + } + catch (Exception ex) + { + Debug.WriteLine($"Error loading {HistoryFileName}: {ex.GetType().Name}: {ex.Message}"); + return null; + } + } + + private async Task?> MigrateAsync() + { + try + { + string? json = await legacySettings.ReadAsync(LegacyKey); + if (string.IsNullOrWhiteSpace(json)) + return null; + + using JsonDocument document = JsonDocument.Parse(json); + ObservableCollection items = []; + foreach (JsonElement element in document.RootElement.EnumerateArray()) + { + try + { + JsonObject itemJson = JsonNode.Parse(element.GetRawText()) as JsonObject + ?? throw new JsonException("A history item must be an object."); + itemJson["Foreground"] = ParseColor(element, "Foreground", false); + itemJson["Background"] = ParseColor(element, "Background", true); + if (!element.TryGetProperty("SavedDateTime", out _)) + itemJson["SavedDateTime"] = clock.GetLocalNow().DateTime; + + if (element.TryGetProperty("LogoEmojiStyle", out JsonElement emojiStyle) + && (emojiStyle.ValueKind != JsonValueKind.String + || !Enum.TryParse(emojiStyle.GetString(), true, out EmojiLogoStyle _))) + { + itemJson["LogoEmojiStyle"] = null; + } + + HistoryItem item = JsonSerializer.Deserialize(itemJson.ToJsonString(), HistoryJsonContext.Default.HistoryItem) + ?? throw new JsonException("A history item cannot be null."); + items.Add(item); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not migrate history item: {ex.Message}"); + } + } + FixColors(items); + return items; + } + catch (Exception ex) + { + Debug.WriteLine($"Error migrating old history settings: {ex.GetType().Name}: {ex.Message}"); + return null; + } + } + + private static string ParseColor(JsonElement item, string propertyName, bool background) + { + Color fallback = background ? Color.FromArgb(255, 255, 255, 255) : Color.FromArgb(255, 0, 0, 0); + try + { + if (!item.TryGetProperty(propertyName, out JsonElement value)) + return ToHex(fallback); + + if (value.ValueKind == JsonValueKind.String) + { + string? hex = value.GetString(); + if (hex is { Length: 9 } && hex[0] == '#') + { + _ = Convert.ToUInt32(hex[1..], 16); + return hex; + } + } + else if (value.ValueKind == JsonValueKind.Object) + { + byte a = value.TryGetProperty("A", out JsonElement alpha) ? alpha.GetByte() : (byte)255; + byte r = value.TryGetProperty("R", out JsonElement red) ? red.GetByte() : (byte)0; + byte g = value.TryGetProperty("G", out JsonElement green) ? green.GetByte() : (byte)0; + byte b = value.TryGetProperty("B", out JsonElement blue) ? blue.GetByte() : (byte)0; + return ToHex(Color.FromArgb(a, r, g, b)); + } + Debug.WriteLine($"Invalid legacy {propertyName} color; using the existing default."); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not parse legacy {propertyName} color: {ex.Message}"); + } + return ToHex(fallback); + } + + private static string ToHex(Color color) => $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}"; + + private static void FixColors(IEnumerable items) + { + foreach (HistoryItem item in items) + { + if (item.Foreground == default) + { + Debug.WriteLine("Fixed transparent history foreground."); + item.Foreground = Color.FromArgb(255, 0, 0, 0); + } + if (item.Background == default) + { + Debug.WriteLine("Fixed transparent history background."); + item.Background = Color.FromArgb(255, 255, 255, 255); + } + } + } +} diff --git a/Simple QR Code Maker.Windows/Services/LocalSettingsService.cs b/Simple QR Code Maker.Windows/Services/LocalSettingsService.cs new file mode 100644 index 0000000..5b2e1c1 --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/LocalSettingsService.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Services; + +public class LocalSettingsService : ILocalSettingsService +{ + private readonly ISettingsStore _settings; + + public LocalSettingsService(IRuntimeEnvironment runtime, ISettingsStore packagedSettings, ISettingsStore fileSettings) + { + _settings = runtime.IsPackaged ? packagedSettings : fileSettings; + } + + public async Task ReadSettingAsync(string key) + { + string? json = await _settings.ReadAsync(key).ConfigureAwait(false); + if (json is null) + return default; + + object? value = JsonSerializer.Deserialize(json, typeof(T), LocalSettingsJsonContext.Default); + return value is T result ? result : default; + } + + public Task SaveSettingAsync(string key, T value) + { + string json = JsonSerializer.Serialize(value, typeof(T), LocalSettingsJsonContext.Default); + return _settings.WriteAsync(key, json); + } +} diff --git a/Simple QR Code Maker.Windows/Services/QrPdfRenderer.cs b/Simple QR Code Maker.Windows/Services/QrPdfRenderer.cs new file mode 100644 index 0000000..6deedcd --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/QrPdfRenderer.cs @@ -0,0 +1,360 @@ +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.Core; +using UglyToad.PdfPig.Writer; +using Windows.Foundation; + +namespace Simple_QR_Code_Maker.Services; + +public static class QrPdfRenderer +{ + private readonly record struct PrintableQrCodeAsset(string Label, byte[] PngBytes, QrImageLayoutMetrics ImageLayout); + + public static Task GenerateQrPdfBytesAsync( + IReadOnlyList codes, + QrRenderSettingsSnapshot renderSettings, + PrintJobSettings printSettings, + CancellationToken cancellationToken = default) + { + PrintJobSettings normalizedSettings = printSettings.Normalize(); + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + List printableCodes = codes.Select(code => + { + cancellationToken.ThrowIfCancellationRequested(); + using QrRenderSettingsSnapshot? commonSettings = renderSettings.CreateCommonBrandOverride(code); + QrRenderSettingsSnapshot effectiveSettings = commonSettings ?? renderSettings; + string? resolvedFrameText = QrFrameTextResolver.Resolve( + effectiveSettings.FramePreset, + effectiveSettings.FrameTextSource, + effectiveSettings.FrameText, + code.CodeAsText, + code.ContentKind, + code.MultiLineCodeModeOverride); + using MemoryStream ms = new(); + QrCodeRenderer.SaveQrCodePngToStream( + ms, + code.CodeAsText, + effectiveSettings.ErrorCorrectionLevel, + effectiveSettings.ForegroundColor, + effectiveSettings.BackgroundColor, + effectiveSettings.LogoImage, + effectiveSettings.LogoSizePercentage, + effectiveSettings.LogoPaddingPixels, + effectiveSettings.QrPaddingModules, + effectiveSettings.FramePreset, + resolvedFrameText, + out QrImageLayoutMetrics imageLayout); + string label = code.ContentKind switch + { + QrContentKind.VCard => VCardBuilderHelper.GetDisplayName(code.CodeAsText), + QrContentKind.WiFi => WifiBuilderHelper.GetDisplayName(code.CodeAsText), + QrContentKind.Email => EmailBuilderHelper.GetDisplayName(code.CodeAsText), + _ => code.CodeAsText, + }; + return new PrintableQrCodeAsset(label, ms.ToArray(), imageLayout); + }).ToList(); + + cancellationToken.ThrowIfCancellationRequested(); + + using PdfDocumentBuilder document = new(); + document.DocumentInformation.Title = "QR Codes"; + document.DocumentInformation.Creator = "Simple QR Code Maker"; + PdfLabelFont? labelFont = normalizedSettings.ShowLabels ? new PdfLabelFont(document) : null; + + QrImageLayoutMetrics maximumImageLayout = GetMaximumImageLayout(printableCodes); + PrintLayoutMetrics pageLayout = PrintLayoutHelper.CreateMetrics(normalizedSettings, maximumImageLayout); + int pageCount = (int)Math.Ceiling((double)printableCodes.Count / pageLayout.CodesPerPage); + + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + + PdfPageBuilder page = document.AddPage(pageLayout.PageWidthPoints, pageLayout.PageHeightPoints); + DrawPage(page, pageIndex, printableCodes, normalizedSettings, maximumImageLayout, labelFont); + } + + cancellationToken.ThrowIfCancellationRequested(); + return document.Build(); + }, cancellationToken); + } + + private static void DrawPage( + PdfPageBuilder page, + int pageIndex, + IReadOnlyList printableCodes, + PrintJobSettings settings, + QrImageLayoutMetrics maximumImageLayout, + PdfLabelFont? labelFont) + { + PrintLayoutMetrics pageLayout = PrintLayoutHelper.CreateMetrics(page.PageSize.Width, page.PageSize.Height, settings, maximumImageLayout); + page.SetStrokeColor(255, 255, 255); + page.SetTextAndFillColor(255, 255, 255); + page.DrawRectangle(new PdfPoint(0, 0), page.PageSize.Width, page.PageSize.Height, 0, fill: true); + page.ResetColor(); + + int start = pageIndex * pageLayout.CodesPerPage; + int end = Math.Min(start + pageLayout.CodesPerPage, printableCodes.Count); + + for (int i = start; i < end; i++) + { + int local = i - start; + Rect cellRect = new( + pageLayout.MarginPoints + ((local % pageLayout.Columns) * (pageLayout.CellWidth + pageLayout.SpacingPoints)), + pageLayout.MarginPoints + ((local / pageLayout.Columns) * (pageLayout.CellHeight + pageLayout.SpacingPoints)), + pageLayout.CellWidth, + pageLayout.CellHeight); + + DrawCell(page, cellRect, printableCodes[i], settings, labelFont); + } + } + + private static void DrawCell(PdfPageBuilder page, Rect cellRect, PrintableQrCodeAsset printableCode, PrintJobSettings settings, PdfLabelFont? labelFont) + { + Rect paddedRect = new( + cellRect.X + PrintLayoutHelper.CellPaddingPoints, + cellRect.Y + PrintLayoutHelper.CellPaddingPoints, + Math.Max(cellRect.Width - (PrintLayoutHelper.CellPaddingPoints * 2), 1), + Math.Max(cellRect.Height - (PrintLayoutHelper.CellPaddingPoints * 2), 1)); + + double reservedLabelHeight = settings.ShowLabels ? PrintLayoutHelper.LabelHeightPoints + PrintLayoutHelper.LabelSpacingPoints : 0; + double imageAreaHeight = Math.Max(paddedRect.Height - reservedLabelHeight, 1); + PrintCodePlacement placement = PrintLayoutHelper.CalculateCodePlacement( + cellRect.Width, + cellRect.Height, + settings, + printableCode.ImageLayout); + double imageX = paddedRect.X + ((paddedRect.Width - placement.ImageWidthPoints) / 2); + double imageY = paddedRect.Y + ((imageAreaHeight - placement.ImageHeightPoints) / 2); + + // Layout uses top-left coordinates; PDF coordinates start at the bottom-left. + double imageBottom = page.PageSize.Height - imageY - placement.ImageHeightPoints; + page.AddPng(printableCode.PngBytes, new PdfRectangle( + imageX, + imageBottom, + imageX + placement.ImageWidthPoints, + imageBottom + placement.ImageHeightPoints)); + + if (labelFont is null) + return; + + Rect labelRect = new( + paddedRect.X, + paddedRect.Bottom - PrintLayoutHelper.LabelHeightPoints, + paddedRect.Width, + PrintLayoutHelper.LabelHeightPoints); + + DrawWrappedLabel(page, printableCode.Label, labelRect, labelFont); + } + + private static void DrawWrappedLabel(PdfPageBuilder page, string value, Rect labelRect, PdfLabelFont labelFont) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + double fontSize = GetLabelFontSize(page, value, labelFont, labelRect.Width); + List lines = WrapLabelLines( + page, + value, + labelFont, + fontSize, + labelRect.Width, + PrintLayoutHelper.LabelMaxLineCount, + out _); + double lineHeight = fontSize * PrintLayoutHelper.LabelLineHeightMultiplier; + double totalTextHeight = lines.Count * lineHeight; + double top = labelRect.Y + Math.Max((labelRect.Height - totalTextHeight) / 2, 0); + + foreach (string line in lines) + { + IReadOnlyList letters = labelFont.MeasureText(page, line, fontSize); + double width = GetTextWidth(letters); + double glyphCenter = (letters.Max(letter => letter.BoundingBox.Top) + letters.Min(letter => letter.BoundingBox.Bottom)) / 2; + PdfPoint baseline = new( + labelRect.X + ((labelRect.Width - width) / 2), + page.PageSize.Height - top - (lineHeight / 2) - glyphCenter); + labelFont.DrawText(page, line, fontSize, baseline); + top += lineHeight; + } + } + + private static double GetLabelFontSize(PdfPageBuilder page, string value, PdfLabelFont font, double availableWidth) + { + for (double fontSize = PrintLayoutHelper.LabelFontSizePoints; + fontSize >= PrintLayoutHelper.MinimumLabelFontSizePoints; + fontSize -= 0.5) + { + _ = WrapLabelLines( + page, + value, + font, + fontSize, + availableWidth, + PrintLayoutHelper.LabelMaxLineCount, + out bool fitsCompletely); + + if (fitsCompletely) + return fontSize; + } + + return PrintLayoutHelper.MinimumLabelFontSizePoints; + } + + private static List WrapLabelLines( + PdfPageBuilder page, + string value, + PdfLabelFont font, + double fontSize, + double availableWidth, + int maxLineCount, + out bool fitsCompletely) + { + string normalized = value + .Replace("\r\n", " ", StringComparison.Ordinal) + .Replace('\r', ' ') + .Replace('\n', ' ') + .Trim(); + List lines = []; + + if (string.IsNullOrWhiteSpace(normalized)) + { + fitsCompletely = true; + return lines; + } + + int start = 0; + while (start < normalized.Length && lines.Count < maxLineCount) + { + while (start < normalized.Length && char.IsWhiteSpace(normalized[start])) + start++; + + if (start >= normalized.Length) + break; + + int nextBreak = FindWrapBreakIndex(page, normalized, start, font, fontSize, availableWidth); + string line = normalized[start..nextBreak].Trim(); + if (line.Length == 0) + { + nextBreak = Math.Min(start + 1, normalized.Length); + line = normalized[start..nextBreak]; + } + + lines.Add(line); + start = nextBreak; + } + + while (start < normalized.Length && char.IsWhiteSpace(normalized[start])) + start++; + + fitsCompletely = start >= normalized.Length; + return lines; + } + + private static int FindWrapBreakIndex( + PdfPageBuilder page, + string value, + int start, + PdfLabelFont font, + double fontSize, + double availableWidth) + { + int lastPreferredBreak = -1; + + for (int index = start + 1; index <= value.Length; index++) + { + string candidate = value[start..index].TrimEnd(); + if (candidate.Length == 0) + continue; + + if (GetTextWidth(font.MeasureText(page, candidate, fontSize)) <= availableWidth) + { + if (index < value.Length && IsPreferredWrapBoundary(value[index - 1])) + lastPreferredBreak = index; + + continue; + } + + if (lastPreferredBreak > start) + return lastPreferredBreak; + + return Math.Max(start + 1, index - 1); + } + + return value.Length; + } + + private static bool IsPreferredWrapBoundary(char character) + { + return char.IsWhiteSpace(character) + || character is '-' or '_' or '/' or '\\' or '.' or '?' or '&' or '='; + } + + + private static double GetTextWidth(IReadOnlyList letters) + { + return letters.Count == 0 ? 0 : letters[^1].EndBaseLine.X; + } + + private sealed class PdfLabelFont + { + private readonly PdfDocumentBuilder _document; + private readonly byte[] _fontBytes; + private readonly HashSet _characters = []; + private PdfDocumentBuilder.AddedFont _font; + + internal PdfLabelFont(PdfDocumentBuilder document) + { + _document = document; + _fontBytes = File.ReadAllBytes(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf")); + _font = document.AddTrueTypeFont(_fontBytes); + } + + internal IReadOnlyList MeasureText(PdfPageBuilder page, string text, double fontSize) + { + return page.MeasureText(text, fontSize, new PdfPoint(0, 0), _font); + } + + internal void DrawText(PdfPageBuilder page, string text, double fontSize, PdfPoint baseline) + { + int start = 0; + for (int index = 0; index < text.Length; index++) + { + if (_characters.Contains(text[index])) + continue; + + // PdfPig's simple TrueType fonts can encode only 255 distinct characters. + if (_characters.Count == byte.MaxValue) + { + string run = text[start..index]; + page.AddText(run, fontSize, baseline, _font); + baseline = new PdfPoint(baseline.X + GetTextWidth(MeasureText(page, run, fontSize)), baseline.Y); + _font = _document.AddTrueTypeFont(_fontBytes); + _characters.Clear(); + start = index; + } + + _characters.Add(text[index]); + } + + page.AddText(text[start..], fontSize, baseline, _font); + } + } + + + private static QrImageLayoutMetrics GetMaximumImageLayout(IReadOnlyList printableCodes) + { + if (printableCodes.Count == 0) + { + return QrImageLayoutMetrics.Square; + } + + double maxWidthPerQrSize = printableCodes.Max(static code => code.ImageLayout.WidthPerQrSize); + double maxHeightPerQrSize = printableCodes.Max(static code => code.ImageLayout.HeightPerQrSize); + return QrImageLayoutMetrics.FromScaleProfile(maxWidthPerQrSize, maxHeightPerQrSize); + } + +} \ No newline at end of file diff --git a/Simple QR Code Maker.Windows/Services/SettingsStores.cs b/Simple QR Code Maker.Windows/Services/SettingsStores.cs new file mode 100644 index 0000000..e6becbd --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/SettingsStores.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; +using Windows.Storage; + +namespace Simple_QR_Code_Maker.Services; + +public sealed class PackagedSettingsStore : ISettingsStore +{ + private static ContainerSettingsStore Current => new(ApplicationData.Current.LocalSettings); + + public Task ReadAsync(string key) => Current.ReadAsync(key); + + public Task WriteAsync(string key, string value) => Current.WriteAsync(key, value); + + public Task RemoveAsync(string key) => Current.RemoveAsync(key); +} + +public sealed class ContainerSettingsStore(ApplicationDataContainer settings) : ISettingsStore +{ + public Task ReadAsync(string key) + { + if (!settings.Values.TryGetValue(key, out object? value)) + return Task.FromResult(null); + + return Task.FromResult((string?)value); + } + + public Task WriteAsync(string key, string value) + { + settings.Values[key] = value; + return Task.CompletedTask; + } + + public Task RemoveAsync(string key) + { + settings.Values.Remove(key); + return Task.CompletedTask; + } +} + +public sealed class FileSettingsStore(IJsonFileStore files, string fileName = "LocalSettings.json") : ISettingsStore +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private Dictionary? _settings; + + public async Task ReadAsync(string key) + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + Dictionary settings = await LoadAsync().ConfigureAwait(false); + return settings.GetValueOrDefault(key); + } + finally + { + _gate.Release(); + } + } + + public Task WriteAsync(string key, string value) => ChangeAsync(key, value); + + public Task RemoveAsync(string key) => ChangeAsync(key, null); + + private async Task ChangeAsync(string key, string? value) + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + Dictionary candidate = new(await LoadAsync().ConfigureAwait(false)); + if (value is null) + candidate.Remove(key); + else + candidate[key] = value; + + string json = JsonSerializer.Serialize(candidate, HistoryJsonContext.Default.DictionaryStringString); + await files.WriteAsync(fileName, json).ConfigureAwait(false); + _settings = candidate; + } + finally + { + _gate.Release(); + } + } + + private async Task> LoadAsync() + { + if (_settings is not null) + return _settings; + + string? json = await files.ReadAsync(fileName).ConfigureAwait(false); + _settings = json is null + ? [] + : JsonSerializer.Deserialize(json, HistoryJsonContext.Default.DictionaryStringString) + ?? throw new JsonException("The local settings file must contain a settings dictionary."); + return _settings; + } +} diff --git a/Simple QR Code Maker.Windows/Simple QR Code Maker.Windows.csproj b/Simple QR Code Maker.Windows/Simple QR Code Maker.Windows.csproj new file mode 100644 index 0000000..a21c055 --- /dev/null +++ b/Simple QR Code Maker.Windows/Simple QR Code Maker.Windows.csproj @@ -0,0 +1,28 @@ + + + net10.0-windows10.0.26100.0 + 10.0.19041.0 + Simple_QR_Code_Maker + x64;ARM64 + enable + enable + preview + true + + + + + + + + + + + + + + + CommonBrands.%(Filename)%(Extension) + + + diff --git a/Simple QR Code Maker.slnx b/Simple QR Code Maker.slnx index bd028cc..4577211 100644 --- a/Simple QR Code Maker.slnx +++ b/Simple QR Code Maker.slnx @@ -7,6 +7,10 @@ + + + + diff --git a/Simple QR Code Maker/App.xaml.cs b/Simple QR Code Maker/App.xaml.cs index 28b5d47..0c01f58 100644 --- a/Simple QR Code Maker/App.xaml.cs +++ b/Simple QR Code Maker/App.xaml.cs @@ -1,16 +1,19 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; using Simple_QR_Code_Maker.Activation; using Simple_QR_Code_Maker.Contracts.Services; using Simple_QR_Code_Maker.Core.Contracts.Services; using Simple_QR_Code_Maker.Core.Services; +using Simple_QR_Code_Maker.Helpers; using Simple_QR_Code_Maker.Models; using Simple_QR_Code_Maker.Services; using Simple_QR_Code_Maker.ViewModels; using Simple_QR_Code_Maker.Views; using System.Diagnostics; +using Windows.Storage; namespace Simple_QR_Code_Maker; @@ -57,7 +60,44 @@ public App() // Services services.AddTransient(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + { + LocalSettingsOptions options = provider.GetRequiredService>().Value; + string folder = SettingsStoragePaths.GetFolder(provider.GetRequiredService().LocalApplicationDataPath, options); + return new FileSettingsStore(new AtomicJsonFileStore(folder), options.LocalSettingsFile ?? SettingsStoragePaths.DefaultFileName); + }); + services.AddSingleton(provider => new LocalSettingsService( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService())); + services.AddSingleton(_ => new AtomicJsonFileStore(ApplicationData.Current.LocalFolder.Path)); + services.AddSingleton(); + services.AddSingleton(provider => + { + IRuntimeEnvironment runtime = provider.GetRequiredService(); + ISettingsStore legacySettings; + if (runtime.IsPackaged) + { + legacySettings = provider.GetRequiredService(); + } + else + { + LocalSettingsOptions options = provider.GetRequiredService>().Value; + string legacyFolder = SettingsStoragePaths.GetLegacyHistoryFolder(runtime.LocalApplicationDataPath); + string settingsFolder = SettingsStoragePaths.GetFolder(runtime.LocalApplicationDataPath, options); + bool isSameSettingsFile = string.Equals(Path.GetFullPath(settingsFolder), Path.GetFullPath(legacyFolder), StringComparison.OrdinalIgnoreCase) + && string.Equals(options.LocalSettingsFile ?? SettingsStoragePaths.DefaultFileName, SettingsStoragePaths.DefaultFileName, StringComparison.OrdinalIgnoreCase); + legacySettings = isSameSettingsFile + ? provider.GetRequiredService() + : new FileSettingsStore(new AtomicJsonFileStore(legacyFolder)); + } + return new HistoryStore(provider.GetRequiredService(), legacySettings, TimeProvider.System); + }); + services.AddSingleton(_ => new HistoryImageStore( + Path.Combine(ApplicationData.Current.LocalFolder.Path, "DecodingHistoryImages"), TimeProvider.System)); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -148,4 +188,4 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) await App.GetService().ActivateAsync(args); } } -} +} \ No newline at end of file diff --git a/Simple QR Code Maker/Helpers/BarcodeHelpers.cs b/Simple QR Code Maker/Helpers/BarcodeHelpers.cs index fa378d7..4b09f66 100644 --- a/Simple QR Code Maker/Helpers/BarcodeHelpers.cs +++ b/Simple QR Code Maker/Helpers/BarcodeHelpers.cs @@ -1,1351 +1,70 @@ -using ImageMagick; using Microsoft.UI.Xaml.Media.Imaging; using Simple_QR_Code_Maker.Models; using System.Drawing; -using System.Drawing.Drawing2D; using System.Drawing.Imaging; -using System.Globalization; -using System.Text.RegularExpressions; -using System.Xml.Linq; using Windows.Storage; using ZXing; -using ZXing.Common; using ZXing.QrCode.Internal; -using ZXing.Rendering; -using ZXing.Windows.Compatibility; -using static ZXing.Rendering.SvgRenderer; +using SvgImage = ZXing.Rendering.SvgRenderer.SvgImage; namespace Simple_QR_Code_Maker.Helpers; -public static partial class BarcodeHelpers +/// WinUI adapter for the headless, tested QR renderer. +public static class BarcodeHelpers { - private const int MaxQrRenderSize = 1024; - - private const string Utf8CharacterSet = "UTF-8"; - - private static string NormalizeVCardLineEndings(string text) - { - // WinUI TextBox uses CR-only newlines, but vCard readers expect CRLF. - return VCardBuilderHelper.IsVCard(text) - ? text.ReplaceLineEndings("\r\n") - : text; - } - - /// - /// ZXing encodes byte-mode content as ISO-8859-1 unless told otherwise, which silently replaces - /// every character outside Latin-1 (Cyrillic, Greek, CJK, emoji, ...) with '?'. Text that fits in - /// ISO-8859-1 keeps the default so those codes stay byte-for-byte what they have always been. - /// - private static bool NeedsUtf8Encoding(string text) - { - foreach (char character in text) - { - // 0x00FF is the last code point ISO-8859-1 can represent - if (character > 'ÿ') - return true; - } - - return false; - } - - /// - /// Adds the UTF-8 character set hint when the text requires it. ZXing also writes an ECI header - /// declaring the character set so readers know how to interpret the bytes. - /// - private static void ApplyCharacterSetHint(EncodingOptions encodingOptions, string text) - { - if (NeedsUtf8Encoding(text)) - encodingOptions.Hints[EncodeHintType.CHARACTER_SET] = Utf8CharacterSet; - } - - /// - /// Encodes the text the same way the barcode writers do, so version and module counts derived from - /// this match the rendered code. - /// internal static QRCode EncodeQrCode(string text, ErrorCorrectionLevel correctionLevel) - { - text = NormalizeVCardLineEndings(text); - - if (!NeedsUtf8Encoding(text)) - return ZXing.QrCode.Internal.Encoder.encode(text, correctionLevel); - - Dictionary hints = new() - { - { EncodeHintType.CHARACTER_SET, Utf8CharacterSet } - }; - - return ZXing.QrCode.Internal.Encoder.encode(text, correctionLevel, hints); - } + => QrCodeRenderer.EncodeQrCode(text, correctionLevel); - /// - /// Calculate the maximum safe logo size percentage based on QR code error correction level and version - /// - /// The text to encode in the QR code - /// The error correction level - /// Maximum safe logo size as a percentage (0-100) - // public static int GetMaxLogoSizePercentage(string text, ErrorCorrectionLevel correctionLevel) public static int GetMaxLogoSizePercentage(ErrorCorrectionLevel correctionLevel) - { - // Error correction capacity by level (percentage of code that can be damaged and still readable) - // These are the theoretical maximum recovery percentages - return correctionLevel.ToString() switch - { - "L" => 20, // Low: ~7% - "M" => 23, // Medium: ~15% - "Q" => 35, // Quartile: ~25% - "H" => 40, // High: ~30% - _ => 20 - }; - } + => QrCodeRenderer.GetMaxLogoSizePercentage(correctionLevel); - /// - /// Rasterizes SVG content to a System.Drawing.Bitmap at the specified dimensions. - /// Uses Magick.NET; the SVG is scaled to fit preserving aspect ratio. - /// public static Bitmap RasterizeSvgToBitmap(string svgContent, int width, int height) - { - MagickReadSettings settings = new() - { - Format = MagickFormat.Svg, - Width = (uint)width, - Height = (uint)height, - BackgroundColor = MagickColors.Transparent, - }; - byte[] svgBytes = System.Text.Encoding.UTF8.GetBytes(svgContent); - using MagickImage image = new(svgBytes, settings); - image.Format = MagickFormat.Png32; - using MemoryStream ms = new(); - image.Write(ms); - ms.Position = 0; - return new Bitmap(ms); - } - - public static WriteableBitmap GetQrCodeBitmapFromText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) - { - using Bitmap bitmap = CreateQrCodeBitmap( - text, - correctionLevel, - foreground, - background, - logoImage, - logoSizePercentage, - logoPaddingPixels, - qrPaddingModules, - framePreset, - frameText, - out _); - using MemoryStream ms = new(); - bitmap.Save(ms, ImageFormat.Png); - - WriteableBitmap bitmapImage = new(bitmap.Width, bitmap.Height); - ms.Position = 0; - bitmapImage.SetSource(ms.AsRandomAccessStream()); - - return bitmapImage; - } - - public static void SaveQrCodePngToStream(Stream outputStream, string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) - { - using Bitmap bitmap = CreateQrCodeBitmap( - text, - correctionLevel, - foreground, - background, - logoImage, - logoSizePercentage, - logoPaddingPixels, - qrPaddingModules, - framePreset, - frameText, - out _); - bitmap.Save(outputStream, ImageFormat.Png); - } - - internal static void SaveQrCodePngToStream(Stream outputStream, string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage, double logoSizePercentage, double logoPaddingPixels, double qrPaddingModules, QrFramePreset framePreset, string? frameText, out QrImageLayoutMetrics imageLayout) - { - using Bitmap bitmap = CreateQrCodeBitmap( - text, - correctionLevel, - foreground, - background, - logoImage, - logoSizePercentage, - logoPaddingPixels, - qrPaddingModules, - framePreset, - frameText, - out int qrRenderSize); - imageLayout = GetQrImageLayoutMetrics(qrRenderSize, framePreset); - bitmap.Save(outputStream, ImageFormat.Png); - } - - internal static QrImageLayoutMetrics GetQrImageLayoutMetrics( - string text, - ErrorCorrectionLevel correctionLevel, - double qrPaddingModules = 2.0, - QrFramePreset framePreset = QrFramePreset.None) - { - int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); - QRCode qrCode = EncodeQrCode(text, correctionLevel); - int moduleCount = qrCode.Version.DimensionForVersion; - int renderSize = GetQrRenderSize(moduleCount, normalizedQrPaddingModules); - return GetQrImageLayoutMetrics(renderSize, framePreset); - } - - private static QrImageLayoutMetrics GetQrImageLayoutMetrics(int qrRenderSize, QrFramePreset framePreset) - { - switch (framePreset) - { - case QrFramePreset.BottomLabel: - { - int outerPadding = ScaleMetric(qrRenderSize, 0.05, 36); - int labelHeight = ScaleMetric(qrRenderSize, 0.16, 84); - int labelSpacing = ScaleMetric(qrRenderSize, 0.035, 20); - int canvasWidth = qrRenderSize + (outerPadding * 2); - int canvasHeight = qrRenderSize + (outerPadding * 2) + labelSpacing + labelHeight; - return new QrImageLayoutMetrics(canvasWidth, canvasHeight, qrRenderSize, qrRenderSize); - } - case QrFramePreset.RoundedFrame: - { - int outerPadding = ScaleMetric(qrRenderSize, 0.1, 56); - int labelHeight = ScaleMetric(qrRenderSize, 0.14, 80); - int labelSpacing = ScaleMetric(qrRenderSize, 0.03, 18); - int canvasWidth = qrRenderSize + (outerPadding * 2); - int canvasHeight = qrRenderSize + (outerPadding * 2) + labelSpacing + labelHeight; - return new QrImageLayoutMetrics(canvasWidth, canvasHeight, qrRenderSize, qrRenderSize); - } - case QrFramePreset.CornerCallout: - { - int outerPadding = ScaleMetric(qrRenderSize, 0.11, 64); - int labelHeight = ScaleMetric(qrRenderSize, 0.13, 72); - int labelSpacing = ScaleMetric(qrRenderSize, 0.04, 24); - int canvasWidth = qrRenderSize + (outerPadding * 2); - int canvasHeight = qrRenderSize + (outerPadding * 2) + labelSpacing + labelHeight; - return new QrImageLayoutMetrics(canvasWidth, canvasHeight, qrRenderSize, qrRenderSize); - } - default: - return new QrImageLayoutMetrics(qrRenderSize, qrRenderSize, qrRenderSize, qrRenderSize); - } - } + => QrCodeRenderer.RasterizeSvgToBitmap(svgContent, width, height); - /// - /// Converts a ZXing-generated bitmap (Format32bppRgb, no alpha) to a Format32bppArgb - /// bitmap with the correct alpha channel applied to foreground and background pixels. - /// - private static Bitmap ApplyAlphaToQrBitmap(Bitmap source, System.Drawing.Color foreground, System.Drawing.Color background) + public static WriteableBitmap GetQrCodeBitmapFromText(string text, ErrorCorrectionLevel correctionLevel, Color foreground, Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) { - // Clone to Format32bppArgb first: Format32bppRgb stores the alpha byte as 0 in memory, - // so SetRemapTable would fail to match OldColor.A=255. The clone conversion sets alpha=255 - // for every pixel (all Format32bppRgb pixels are fully opaque by definition). - using Bitmap argbSource = source.Clone( - new System.Drawing.Rectangle(0, 0, source.Width, source.Height), - PixelFormat.Format32bppArgb); - - Bitmap result = new(source.Width, source.Height, PixelFormat.Format32bppArgb); - using Graphics g = Graphics.FromImage(result); - g.Clear(System.Drawing.Color.Transparent); - - // Remap the opaque source colors to the user's alpha-aware colors - ColorMap[] colorMaps = - [ - new ColorMap - { - OldColor = System.Drawing.Color.FromArgb(255, background.R, background.G, background.B), - NewColor = background - }, - new ColorMap - { - OldColor = System.Drawing.Color.FromArgb(255, foreground.R, foreground.G, foreground.B), - NewColor = foreground - } - ]; - - using ImageAttributes attributes = new(); - attributes.SetRemapTable(colorMaps); - - g.DrawImage(argbSource, - new System.Drawing.Rectangle(0, 0, result.Width, result.Height), - 0, 0, argbSource.Width, argbSource.Height, - GraphicsUnit.Pixel, attributes); - + using Bitmap bitmap = QrCodeRenderer.GetQrCodeBitmapFromText(text, correctionLevel, foreground, background, logoImage, logoSizePercentage, logoPaddingPixels, qrPaddingModules, framePreset, frameText); + using MemoryStream stream = new(); + bitmap.Save(stream, ImageFormat.Png); + stream.Position = 0; + WriteableBitmap result = new(bitmap.Width, bitmap.Height); + result.SetSource(stream.AsRandomAccessStream()); return result; } - private static Bitmap CreateQrCodeBitmap(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage, double logoSizePercentage, double logoPaddingPixels, double qrPaddingModules, QrFramePreset framePreset, string? frameText, out int qrRenderSize) - { - text = NormalizeVCardLineEndings(text); - - // Always pass fully opaque colors to ZXing — if the background color has A=0, ZXing fills - // nothing (transparent brush = SourceOver no-op) and the bitmap initializes to black, making - // foreground and background pixels indistinguishable. ApplyAlphaToQrBitmap handles alpha. - int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); - QRCode qrCode = EncodeQrCode(text, correctionLevel); - int moduleCount = qrCode.Version.DimensionForVersion; - qrRenderSize = GetQrRenderSize(moduleCount, normalizedQrPaddingModules); - - BitmapRenderer bitmapRenderer = new() - { - Foreground = System.Drawing.Color.FromArgb(255, foreground.R, foreground.G, foreground.B), - Background = System.Drawing.Color.FromArgb(255, background.R, background.G, background.B) - }; - - BarcodeWriter barcodeWriter = new() - { - Format = BarcodeFormat.QR_CODE, - Renderer = bitmapRenderer, - }; - - EncodingOptions encodingOptions = new() - { - Width = qrRenderSize, - Height = qrRenderSize, - Margin = normalizedQrPaddingModules, - }; - encodingOptions.Hints.Add(EncodeHintType.ERROR_CORRECTION, correctionLevel); - ApplyCharacterSetHint(encodingOptions, text); - barcodeWriter.Options = encodingOptions; - - Bitmap rawBitmap = barcodeWriter.Write(text); - bool needsAlpha = foreground.A < 255 || background.A < 255; - Bitmap bitmap = rawBitmap; - - if (needsAlpha) - { - bitmap = ApplyAlphaToQrBitmap(rawBitmap, foreground, background); - rawBitmap.Dispose(); - } + public static void SaveQrCodePngToStream(Stream outputStream, string text, ErrorCorrectionLevel correctionLevel, Color foreground, Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) + => QrCodeRenderer.SaveQrCodePngToStream(outputStream, text, correctionLevel, foreground, background, logoImage, logoSizePercentage, logoPaddingPixels, qrPaddingModules, framePreset, frameText); - try - { - if (logoImage != null) - { - OverlayLogoOnQrCode(bitmap, logoImage, logoSizePercentage, moduleCount, encodingOptions.Margin, logoPaddingPixels, background); - } - - Bitmap framedBitmap = AddFrameToBitmap(bitmap, foreground, background, framePreset, frameText); - if (!ReferenceEquals(framedBitmap, bitmap)) - { - bitmap.Dispose(); - bitmap = framedBitmap; - } - - return bitmap; - } - catch - { - bitmap.Dispose(); - throw; - } - } - - private static Bitmap AddFrameToBitmap(Bitmap qrBitmap, System.Drawing.Color foreground, System.Drawing.Color background, QrFramePreset framePreset, string? frameText) - { - string? resolvedFrameText = QrFramePresetCatalog.ResolveText(framePreset, frameText); - if (string.IsNullOrWhiteSpace(resolvedFrameText)) - return qrBitmap; - - return framePreset switch - { - QrFramePreset.BottomLabel => AddBottomLabelFrameToBitmap(qrBitmap, resolvedFrameText, foreground, background), - QrFramePreset.RoundedFrame => AddRoundedFrameToBitmap(qrBitmap, resolvedFrameText, foreground, background), - QrFramePreset.CornerCallout => AddCornerCalloutFrameToBitmap(qrBitmap, resolvedFrameText, foreground, background), - _ => qrBitmap, - }; - } - - private static Bitmap AddBottomLabelFrameToBitmap(Bitmap qrBitmap, string frameText, System.Drawing.Color foreground, System.Drawing.Color background) - { - int outerPadding = ScaleMetric(qrBitmap.Width, 0.05, 36); - int labelHeight = ScaleMetric(qrBitmap.Width, 0.16, 84); - int labelSpacing = ScaleMetric(qrBitmap.Width, 0.035, 20); - int labelWidth = qrBitmap.Width + ScaleMetric(qrBitmap.Width, 0.08, 40); - int outlineThickness = ScaleMetric(qrBitmap.Width, 0.014, 8); - int accentHeight = ScaleMetric(qrBitmap.Width, 0.014, 8); - int qrX = outerPadding; - int qrY = outerPadding; - int canvasWidth = qrBitmap.Width + (outerPadding * 2); - int canvasHeight = qrY + qrBitmap.Height + labelSpacing + labelHeight + outerPadding; - int labelX = (canvasWidth - labelWidth) / 2; - int labelY = qrY + qrBitmap.Height + labelSpacing; - int accentWidth = Math.Max(labelWidth / 4, ScaleMetric(qrBitmap.Width, 0.18, 120)); - int accentX = (canvasWidth - accentWidth) / 2; - int accentY = labelY - (labelSpacing / 2); - Bitmap canvas = new(canvasWidth, canvasHeight, PixelFormat.Format32bppArgb); - - using Graphics g = Graphics.FromImage(canvas); - PrepareGraphics(g); - g.Clear(System.Drawing.Color.Transparent); - g.DrawImage(qrBitmap, qrX, qrY, qrBitmap.Width, qrBitmap.Height); - - using GraphicsPath labelPath = CreateRoundedRectanglePath(new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), labelHeight / 2f); - using GraphicsPath accentPath = CreateRoundedRectanglePath(new System.Drawing.RectangleF(accentX, accentY, accentWidth, accentHeight), accentHeight / 2f); - using SolidBrush backgroundBrush = new(background); - using SolidBrush foregroundBrush = new(foreground); - using Pen outlinePen = new(foreground, outlineThickness); - - g.FillPath(backgroundBrush, labelPath); - g.DrawPath(outlinePen, labelPath); - g.FillPath(foregroundBrush, accentPath); - DrawFrameText(g, frameText, foreground, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness); - - return canvas; - } - - private static Bitmap AddRoundedFrameToBitmap(Bitmap qrBitmap, string frameText, System.Drawing.Color foreground, System.Drawing.Color background) - { - int outerPadding = ScaleMetric(qrBitmap.Width, 0.1, 56); - int borderInset = ScaleMetric(qrBitmap.Width, 0.032, 18); - int labelHeight = ScaleMetric(qrBitmap.Width, 0.14, 80); - int labelSpacing = ScaleMetric(qrBitmap.Width, 0.03, 18); - int outlineThickness = ScaleMetric(qrBitmap.Width, 0.022, 14); - int qrX = outerPadding; - int qrY = outerPadding; - int canvasWidth = qrBitmap.Width + (outerPadding * 2); - int canvasHeight = qrY + qrBitmap.Height + outerPadding + labelSpacing + labelHeight; - float borderRadius = ScaleMetric(qrBitmap.Width, 0.085, 42); - System.Drawing.RectangleF borderRect = new( - qrX - borderInset, - qrY - borderInset, - qrBitmap.Width + (borderInset * 2), - qrBitmap.Height + (borderInset * 2)); - int labelWidth = (int)Math.Round(borderRect.Width * 0.72, MidpointRounding.AwayFromZero); - int labelX = (canvasWidth - labelWidth) / 2; - int labelY = (int)Math.Round(borderRect.Bottom + labelSpacing, MidpointRounding.AwayFromZero); - Bitmap canvas = new(canvasWidth, canvasHeight, PixelFormat.Format32bppArgb); + internal static void SaveQrCodePngToStream(Stream outputStream, string text, ErrorCorrectionLevel correctionLevel, Color foreground, Color background, Bitmap? logoImage, double logoSizePercentage, double logoPaddingPixels, double qrPaddingModules, QrFramePreset framePreset, string? frameText, out QrImageLayoutMetrics imageLayout) + => QrCodeRenderer.SaveQrCodePngToStream(outputStream, text, correctionLevel, foreground, background, logoImage, logoSizePercentage, logoPaddingPixels, qrPaddingModules, framePreset, frameText, out imageLayout); - using Graphics g = Graphics.FromImage(canvas); - PrepareGraphics(g); - g.Clear(System.Drawing.Color.Transparent); + internal static QrImageLayoutMetrics GetQrImageLayoutMetrics(string text, ErrorCorrectionLevel correctionLevel, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None) + => QrCodeRenderer.GetQrImageLayoutMetrics(text, correctionLevel, qrPaddingModules, framePreset); - using GraphicsPath borderPath = CreateRoundedRectanglePath(borderRect, borderRadius); - using SolidBrush backgroundBrush = new(background); - using Pen outlinePen = new(foreground, outlineThickness); - - g.FillPath(backgroundBrush, borderPath); - g.DrawPath(outlinePen, borderPath); - g.DrawImage(qrBitmap, qrX, qrY, qrBitmap.Width, qrBitmap.Height); - DrawLabelPill(g, frameText, foreground, background, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness); - - return canvas; - } - - private static Bitmap AddCornerCalloutFrameToBitmap(Bitmap qrBitmap, string frameText, System.Drawing.Color foreground, System.Drawing.Color background) - { - int outerPadding = ScaleMetric(qrBitmap.Width, 0.11, 64); - int bracketInset = ScaleMetric(qrBitmap.Width, 0.03, 16); - int bracketLength = ScaleMetric(qrBitmap.Width, 0.14, 72); - int outlineThickness = ScaleMetric(qrBitmap.Width, 0.024, 15); - int labelHeight = ScaleMetric(qrBitmap.Width, 0.13, 72); - int labelSpacing = ScaleMetric(qrBitmap.Width, 0.04, 24); - int labelWidth = ScaleMetric(qrBitmap.Width, 0.72, 520); - int canvasWidth = qrBitmap.Width + (outerPadding * 2); - int canvasHeight = labelHeight + labelSpacing + qrBitmap.Height + (outerPadding * 2); - int labelX = (canvasWidth - labelWidth) / 2; - int labelY = outerPadding / 3; - int qrX = outerPadding; - int qrY = labelY + labelHeight + labelSpacing; - System.Drawing.RectangleF bracketRect = new( - qrX - bracketInset, - qrY - bracketInset, - qrBitmap.Width + (bracketInset * 2), - qrBitmap.Height + (bracketInset * 2)); - Bitmap canvas = new(canvasWidth, canvasHeight, PixelFormat.Format32bppArgb); - - using Graphics g = Graphics.FromImage(canvas); - PrepareGraphics(g); - g.Clear(System.Drawing.Color.Transparent); - g.DrawImage(qrBitmap, qrX, qrY, qrBitmap.Width, qrBitmap.Height); - DrawLabelPill(g, frameText, foreground, background, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness); - DrawCornerCalloutBrackets(g, foreground, bracketRect, bracketLength, outlineThickness, labelX + (labelWidth / 2f), labelY + labelHeight); - - return canvas; - } - - private static void PrepareGraphics(Graphics g) - { - g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; - g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; - g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; - g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; - g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; - } - - private static void DrawLabelPill(Graphics g, string frameText, System.Drawing.Color foreground, System.Drawing.Color background, System.Drawing.RectangleF rect, float outlineThickness) - { - using GraphicsPath labelPath = CreateRoundedRectanglePath(rect, rect.Height / 2f); - using SolidBrush backgroundBrush = new(background); - using Pen outlinePen = new(foreground, outlineThickness); - - g.FillPath(backgroundBrush, labelPath); - g.DrawPath(outlinePen, labelPath); - DrawFrameText(g, frameText, foreground, rect, outlineThickness); - } - - private static void DrawFrameText(Graphics g, string frameText, System.Drawing.Color foreground, System.Drawing.RectangleF rect, float outlineThickness) - { - System.Drawing.RectangleF textRect = GetFrameTextRect(rect, outlineThickness); - float fontSize = GetFittedFrameFontSize(frameText, textRect.Size); - using Font font = new("Segoe UI", fontSize, FontStyle.Bold, GraphicsUnit.Pixel); - using SolidBrush textBrush = new(foreground); - using StringFormat format = new() - { - Alignment = StringAlignment.Center, - LineAlignment = StringAlignment.Center, - Trimming = StringTrimming.EllipsisCharacter, - FormatFlags = StringFormatFlags.NoWrap, - }; - - g.DrawString(frameText, font, textBrush, textRect, format); - } - - private static System.Drawing.RectangleF GetFrameTextRect(System.Drawing.RectangleF rect, float outlineThickness) - { - float horizontalPadding = Math.Max(outlineThickness * 2.5f, rect.Width * 0.08f); - float verticalPadding = Math.Max(outlineThickness * 1.5f, rect.Height * 0.14f); - float width = Math.Max(1f, rect.Width - (horizontalPadding * 2f)); - float height = Math.Max(1f, rect.Height - (verticalPadding * 2f)); - - return new System.Drawing.RectangleF( - rect.X + horizontalPadding, - rect.Y + verticalPadding, - width, - height); - } - - private static float GetFittedFrameFontSize(string frameText, System.Drawing.SizeF availableSize) - { - const float measurementFontSize = 100f; - - using Bitmap measurementBitmap = new(1, 1); - using Graphics measurementGraphics = Graphics.FromImage(measurementBitmap); - PrepareGraphics(measurementGraphics); - using Font measurementFont = new("Segoe UI", measurementFontSize, FontStyle.Bold, GraphicsUnit.Pixel); - using StringFormat format = new() - { - Alignment = StringAlignment.Center, - LineAlignment = StringAlignment.Center, - Trimming = StringTrimming.EllipsisCharacter, - FormatFlags = StringFormatFlags.NoWrap, - }; - - System.Drawing.SizeF measuredSize = measurementGraphics.MeasureString(frameText, measurementFont, PointF.Empty, format); - if (measuredSize.Width <= 0 || measuredSize.Height <= 0) - return Math.Max(1f, availableSize.Height); - - float scaleX = availableSize.Width / measuredSize.Width; - float scaleY = availableSize.Height / measuredSize.Height; - float scale = Math.Min(scaleX, scaleY) * 0.98f; - - return Math.Max(1f, measurementFontSize * scale); - } - - private static void DrawCornerCalloutBrackets(Graphics g, System.Drawing.Color foreground, System.Drawing.RectangleF bracketRect, float bracketLength, float outlineThickness, float labelCenterX, float labelBottomY) - { - using Pen outlinePen = new(foreground, outlineThickness) - { - LineJoin = System.Drawing.Drawing2D.LineJoin.Round, - StartCap = System.Drawing.Drawing2D.LineCap.Round, - EndCap = System.Drawing.Drawing2D.LineCap.Round, - }; - - float left = bracketRect.Left; - float top = bracketRect.Top; - float right = bracketRect.Right; - float bottom = bracketRect.Bottom; - - g.DrawLines(outlinePen, - [ - new System.Drawing.PointF(left + bracketLength, top), - new System.Drawing.PointF(left, top), - new System.Drawing.PointF(left, top + bracketLength), - ]); - g.DrawLines(outlinePen, - [ - new System.Drawing.PointF(right - bracketLength, top), - new System.Drawing.PointF(right, top), - new System.Drawing.PointF(right, top + bracketLength), - ]); - g.DrawLines(outlinePen, - [ - new System.Drawing.PointF(left + bracketLength, bottom), - new System.Drawing.PointF(left, bottom), - new System.Drawing.PointF(left, bottom - bracketLength), - ]); - g.DrawLines(outlinePen, - [ - new System.Drawing.PointF(right - bracketLength, bottom), - new System.Drawing.PointF(right, bottom), - new System.Drawing.PointF(right, bottom - bracketLength), - ]); - - float connectorBottom = top - (outlineThickness * 1.2f); - if (connectorBottom > labelBottomY) - { - g.DrawLine(outlinePen, labelCenterX, labelBottomY, labelCenterX, connectorBottom); - } - } - - private static GraphicsPath CreateRoundedRectanglePath(System.Drawing.RectangleF rect, float radius) - { - float diameter = Math.Min(radius * 2f, Math.Min(rect.Width, rect.Height)); - GraphicsPath path = new(); - - if (diameter <= 0) - { - path.AddRectangle(rect); - return path; - } - - path.AddArc(rect.X, rect.Y, diameter, diameter, 180, 90); - path.AddArc(rect.Right - diameter, rect.Y, diameter, diameter, 270, 90); - path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter, diameter, 0, 90); - path.AddArc(rect.X, rect.Bottom - diameter, diameter, diameter, 90, 90); - path.CloseFigure(); - return path; - } - - private static int ScaleMetric(int baseSize, double ratio, int minimum) - { - int scaledValue = (int)Math.Round(baseSize * ratio, MidpointRounding.AwayFromZero); - return Math.Max(scaledValue, minimum); - } - - private static void OverlayLogoOnQrCode(Bitmap qrCodeBitmap, Bitmap logo, double sizePercentage, int moduleCount, int margin, double logoPaddingPixels, System.Drawing.Color backgroundColor) - { - // Calculate the pixel size of each QR code module - // The total size includes the margin on both sides - int totalModules = moduleCount + (margin * 2); - double modulePixelSize = (double)qrCodeBitmap.Width / totalModules; - - // Calculate the punchout space size based on the size percentage - // This is the area that will be covered (blocking QR code modules) - int punchoutSizePixels = (int)(Math.Min(qrCodeBitmap.Width, qrCodeBitmap.Height) * (sizePercentage / 100.0)); - - // Round the punchout size to the nearest module boundary - int punchoutSizeModules = (int)Math.Round(punchoutSizePixels / modulePixelSize); - // Ensure it's at least 1 module and odd number for better centering - if (punchoutSizeModules < 1) punchoutSizeModules = 1; - if (punchoutSizeModules % 2 == 0) punchoutSizeModules++; // Make it odd for symmetry - - // Convert back to pixels, aligned to module boundaries - int punchoutSize = (int)(punchoutSizeModules * modulePixelSize); - - // Calculate the position to center the punchout area - int punchoutX = (qrCodeBitmap.Width - punchoutSize) / 2; - int punchoutY = (qrCodeBitmap.Height - punchoutSize) / 2; - - // Convert padding to actual pixels - // Positive padding = logo smaller than punchout (adds white space) - // Negative padding = logo larger than punchout (logo extends beyond white background) - int paddingPixels = (int)Math.Round(logoPaddingPixels); - - // Calculate the actual logo display size - // Logo fills the punchout area minus the padding on all sides - int logoDisplayWidth = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); - int logoDisplayHeight = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); - - // If padding is negative, logo is larger than punchout - if (paddingPixels < 0) - { - logoDisplayWidth = punchoutSize + (Math.Abs(paddingPixels) * 2); - logoDisplayHeight = punchoutSize + (Math.Abs(paddingPixels) * 2); - } - - // Calculate logo dimensions preserving aspect ratio - float aspectRatio = (float)logo.Width / logo.Height; - int logoWidth, logoHeight; - - if (aspectRatio > 1) // Wider than tall - { - logoWidth = logoDisplayWidth; - logoHeight = (int)(logoDisplayWidth / aspectRatio); - } - else // Taller than wide or square - { - logoHeight = logoDisplayHeight; - logoWidth = (int)(logoDisplayHeight * aspectRatio); - } - - // Center the logo within the punchout area (or offset if larger) - int logoX = punchoutX + (punchoutSize - logoWidth) / 2; - int logoY = punchoutY + (punchoutSize - logoHeight) / 2; - - using Graphics g = Graphics.FromImage(qrCodeBitmap); - // Set high quality rendering - g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; - g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; - g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; - g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; - - // Draw the punchout background with the same color as the QR code background. - // Use SourceCopy so a transparent background color actually clears those pixels - // rather than being ignored by the default SourceOver compositing. - g.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceCopy; - using SolidBrush backgroundBrush = new(backgroundColor); - g.FillRectangle(backgroundBrush, punchoutX, punchoutY, punchoutSize, punchoutSize); - - // Reset to SourceOver so the logo composites correctly over the punchout - g.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceOver; - - // Draw the logo scaled to fit within or extend beyond the punchout - g.DrawImage(logo, logoX, logoY, logoWidth, logoHeight); - } - - /// - /// Calculate the smallest side of a QR code based on the distance between the camera and the QR code - /// - /// Distance of camera from QR Code (in) - /// Number of blocks in the QR Code (Version) - /// The smallest size (in) of a QR Code scanning at the given distance public static bool IsSizeRecommendationAvailableForPadding(double qrPaddingModules) - { - int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); - return normalizedQrPaddingModules is >= 1 and <= 4; - } + => QrCodeRenderer.IsSizeRecommendationAvailableForPadding(qrPaddingModules); public static double SmallestCodeSide(double distance, int numberOfBlocks, double qrPaddingModules = 2.0) - { - int padding = NormalizeQrPaddingModules(qrPaddingModules) * 2; - - double blockSize = (distance + 2.721) / 1759.1; - - // check if the block size is too small for normal printer - if (blockSize < 0.007) - return 0; - - double codeSize = blockSize * (numberOfBlocks + padding); - return codeSize; - } + => QrCodeRenderer.SmallestCodeSide(distance, numberOfBlocks, qrPaddingModules); public static double ContrastRatioLossFrac(double contrastRatio) - { - double x1 = 21; - double y1 = 1; - double x2 = 2.5; - double y2 = 0.8; + => QrCodeRenderer.ContrastRatioLossFrac(contrastRatio); - double slope = (y2 - y1) / (x2 - x1); - double yIntercept = y1 - (slope * x1); - - return (slope * contrastRatio) + yIntercept; - } - - public static bool TryGetMinimumRecommendedSideMillimeters( - double distance, - int numberOfBlocks, - Windows.UI.Color foreground, - Windows.UI.Color background, - out double minimumSideMillimeters, - double qrPaddingModules = 2.0) - { - minimumSideMillimeters = 0; - - if (foreground.A < 255 || background.A < 255) - { - return false; - } - - if (!IsSizeRecommendationAvailableForPadding(qrPaddingModules)) - { - return false; - } - - double smallestSideInches = SmallestCodeSide(distance, numberOfBlocks, qrPaddingModules); - if (smallestSideInches == 0) - { - return false; - } - - double contrastRatio = ColorHelpers.GetContrastRatio(foreground, background); - if (contrastRatio < 2.5) - { - return false; - } - - double fractionalLoss = ContrastRatioLossFrac(contrastRatio); - minimumSideMillimeters = (smallestSideInches / fractionalLoss) * 25.4; - return true; - } + public static bool TryGetMinimumRecommendedSideMillimeters(double distance, int numberOfBlocks, Windows.UI.Color foreground, Windows.UI.Color background, out double minimumSideMillimeters, double qrPaddingModules = 2.0) + => QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(distance, numberOfBlocks, foreground, background, out minimumSideMillimeters, qrPaddingModules); public static QrCodeSizeRecommendation GetSizeRecommendation(double distance, int numberOfBlocks, Windows.UI.Color foreground, Windows.UI.Color background, double qrPaddingModules = 2.0) - { - if (foreground.A < 255 || background.A < 255) - { - return new QrCodeSizeRecommendation( - QrCodeSizeRecommendationKind.TransparencyDependent, - "Exact minimum size depends on the final print surface when transparency is used."); - } - - if (!IsSizeRecommendationAvailableForPadding(qrPaddingModules)) - { - int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); - return new QrCodeSizeRecommendation( - QrCodeSizeRecommendationKind.PaddingDependent, - $"Padding {normalizedQrPaddingModules} is outside the predictable 1-4 module range. Borderless and heavy-border QR Codes scan too variably for reliable print sizing."); - } - - bool isMetric = RegionInfo.CurrentRegion.IsMetric; - double smallestSide = SmallestCodeSide(distance, numberOfBlocks, qrPaddingModules); - - if (smallestSide == 0) - { - return new QrCodeSizeRecommendation( - QrCodeSizeRecommendationKind.Error, - "Error at selected max distance."); - } - - double contrastRatio = ColorHelpers.GetContrastRatio(foreground, background); - - if (contrastRatio < 2.5) - { - return new QrCodeSizeRecommendation( - QrCodeSizeRecommendationKind.LowContrast, - "Color contrast too low"); - } - - if (!TryGetMinimumRecommendedSideMillimeters(distance, numberOfBlocks, foreground, background, out double minimumSideMillimeters, qrPaddingModules)) - { - return new QrCodeSizeRecommendation( - QrCodeSizeRecommendationKind.Error, - "Error at selected max distance."); - } - - if (!isMetric) - { - double smallestSideInches = minimumSideMillimeters / 25.4; - return new QrCodeSizeRecommendation( - QrCodeSizeRecommendationKind.Exact, - $"{smallestSideInches:F2} x {smallestSideInches:F2} in"); - } - - double smallestSideCm = minimumSideMillimeters / 10.0; - return new QrCodeSizeRecommendation( - QrCodeSizeRecommendationKind.Exact, - $"{smallestSideCm:F2} x {smallestSideCm:F2} cm"); - } - - public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel correctionLevel, System.Drawing.Color foreground, System.Drawing.Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, string? logoSvgContent = null, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) - { - text = NormalizeVCardLineEndings(text); - - int normalizedQrPaddingModules = NormalizeQrPaddingModules(qrPaddingModules); - QRCode qrCode = EncodeQrCode(text, correctionLevel); - int moduleCount = qrCode.Version.DimensionForVersion; - int renderSize = GetQrRenderSize(moduleCount, normalizedQrPaddingModules); - - SvgRenderer svgRenderer = new() - { - Foreground = new SvgRenderer.Color(foreground.A, foreground.R, foreground.G, foreground.B), - Background = new SvgRenderer.Color(background.A, background.R, background.G, background.B), - }; - - BarcodeWriterSvg barcodeWriter = new() - { - Format = BarcodeFormat.QR_CODE, - Renderer = svgRenderer - }; - - EncodingOptions encodingOptions = new() - { - Width = renderSize, - Height = renderSize, - Margin = normalizedQrPaddingModules, - }; - encodingOptions.Hints.Add(EncodeHintType.ERROR_CORRECTION, correctionLevel); - ApplyCharacterSetHint(encodingOptions, text); - barcodeWriter.Options = encodingOptions; - - SvgImage svg = barcodeWriter.Write(text); - - // If a logo is provided, embed it in the SVG - if (logoImage != null || logoSvgContent != null) - { - svg = EmbedLogoInSvg(svg, logoImage, logoSizePercentage, moduleCount, encodingOptions.Margin, renderSize, logoPaddingPixels, background, logoSvgContent); - } - - svg = ApplyFrameToSvg(svg, renderSize, foreground, background, framePreset, frameText); - - return svg; - } - - private static SvgImage ApplyFrameToSvg(SvgImage svg, int qrRenderSize, System.Drawing.Color foreground, System.Drawing.Color background, QrFramePreset framePreset, string? frameText) - { - string? resolvedFrameText = QrFramePresetCatalog.ResolveText(framePreset, frameText); - if (string.IsNullOrWhiteSpace(resolvedFrameText)) - return svg; - - string svgBody = ExtractSvgInnerContent(svg.Content); - if (string.IsNullOrWhiteSpace(svgBody)) - return svg; - - string qrGroup; - string frameMarkup; - string canvasWidth; - string canvasHeight; - - switch (framePreset) - { - case QrFramePreset.BottomLabel: - { - int outerPadding = ScaleMetric(qrRenderSize, 0.05, 36); - int labelHeight = ScaleMetric(qrRenderSize, 0.16, 84); - int labelSpacing = ScaleMetric(qrRenderSize, 0.035, 20); - int labelWidth = qrRenderSize + ScaleMetric(qrRenderSize, 0.08, 40); - int outlineThickness = ScaleMetric(qrRenderSize, 0.014, 8); - int accentHeight = ScaleMetric(qrRenderSize, 0.014, 8); - int qrX = outerPadding; - int qrY = outerPadding; - int totalWidth = qrRenderSize + (outerPadding * 2); - int totalHeight = qrY + qrRenderSize + labelSpacing + labelHeight + outerPadding; - int labelX = (totalWidth - labelWidth) / 2; - int labelY = qrY + qrRenderSize + labelSpacing; - int accentWidth = Math.Max(labelWidth / 4, ScaleMetric(qrRenderSize, 0.18, 120)); - int accentX = (totalWidth - accentWidth) / 2; - int accentY = labelY - (labelSpacing / 2); - - qrGroup = CreateTranslatedSvgGroup(svgBody, qrX, qrY); - frameMarkup = $""" - - - {CreateSvgTextElement(resolvedFrameText, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness, foreground)} -"""; - canvasWidth = SvgValue(totalWidth); - canvasHeight = SvgValue(totalHeight); - break; - } - case QrFramePreset.RoundedFrame: - { - int outerPadding = ScaleMetric(qrRenderSize, 0.1, 56); - int borderInset = ScaleMetric(qrRenderSize, 0.032, 18); - int labelHeight = ScaleMetric(qrRenderSize, 0.14, 80); - int labelSpacing = ScaleMetric(qrRenderSize, 0.03, 18); - int outlineThickness = ScaleMetric(qrRenderSize, 0.022, 14); - int qrX = outerPadding; - int qrY = outerPadding; - int totalWidth = qrRenderSize + (outerPadding * 2); - int totalHeight = qrY + qrRenderSize + outerPadding + labelSpacing + labelHeight; - int borderX = qrX - borderInset; - int borderY = qrY - borderInset; - int borderWidth = qrRenderSize + (borderInset * 2); - int borderHeight = qrRenderSize + (borderInset * 2); - int borderRadius = ScaleMetric(qrRenderSize, 0.085, 42); - int labelWidth = (int)Math.Round(borderWidth * 0.72, MidpointRounding.AwayFromZero); - int labelX = (totalWidth - labelWidth) / 2; - int labelY = borderY + borderHeight + labelSpacing; - - qrGroup = CreateTranslatedSvgGroup(svgBody, qrX, qrY); - frameMarkup = $""" - - - {CreateSvgTextElement(resolvedFrameText, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness, foreground)} -"""; - canvasWidth = SvgValue(totalWidth); - canvasHeight = SvgValue(totalHeight); - break; - } - case QrFramePreset.CornerCallout: - { - int outerPadding = ScaleMetric(qrRenderSize, 0.11, 64); - int bracketInset = ScaleMetric(qrRenderSize, 0.03, 16); - int bracketLength = ScaleMetric(qrRenderSize, 0.14, 72); - int outlineThickness = ScaleMetric(qrRenderSize, 0.024, 15); - int labelHeight = ScaleMetric(qrRenderSize, 0.13, 72); - int labelSpacing = ScaleMetric(qrRenderSize, 0.04, 24); - int labelWidth = ScaleMetric(qrRenderSize, 0.72, 520); - int totalWidth = qrRenderSize + (outerPadding * 2); - int totalHeight = labelHeight + labelSpacing + qrRenderSize + (outerPadding * 2); - int labelX = (totalWidth - labelWidth) / 2; - int labelY = outerPadding / 3; - int qrX = outerPadding; - int qrY = labelY + labelHeight + labelSpacing; - int bracketLeft = qrX - bracketInset; - int bracketTop = qrY - bracketInset; - int bracketRight = qrX + qrRenderSize + bracketInset; - int bracketBottom = qrY + qrRenderSize + bracketInset; - - qrGroup = CreateTranslatedSvgGroup(svgBody, qrX, qrY); - frameMarkup = $""" - - {CreateSvgTextElement(resolvedFrameText, new System.Drawing.RectangleF(labelX, labelY, labelWidth, labelHeight), outlineThickness, foreground)} - - -"""; - canvasWidth = SvgValue(totalWidth); - canvasHeight = SvgValue(totalHeight); - break; - } - default: - return svg; - } - - string content = $""" - -{frameMarkup} -{qrGroup} - -"""; - return new SvgImage(content); - } - - private static string CreateTranslatedSvgGroup(string svgBody, double offsetX, double offsetY) - { - return $" {Environment.NewLine}{svgBody}{Environment.NewLine} "; - } - - private static string CreateSvgTextElement(string value, System.Drawing.RectangleF rect, float outlineThickness, System.Drawing.Color foreground) - { - System.Drawing.RectangleF textRect = GetFrameTextRect(rect, outlineThickness); - float fontSize = GetFittedFrameFontSize(value, textRect.Size); - string escapedText = System.Security.SecurityElement.Escape(value) ?? string.Empty; - float centerX = textRect.X + (textRect.Width / 2f); - float centerY = textRect.Y + (textRect.Height / 2f); - return $"{escapedText}"; - } - - private static string ToSvgColor(System.Drawing.Color color) - { - return color.A < 255 - ? $"rgba({color.R},{color.G},{color.B},{(color.A / 255.0).ToString("0.###", CultureInfo.InvariantCulture)})" - : $"rgb({color.R},{color.G},{color.B})"; - } - - private static string SvgValue(double value) - { - return value.ToString("0.###", CultureInfo.InvariantCulture); - } - - private static SvgImage EmbedLogoInSvg(SvgImage svg, Bitmap? logo, double sizePercentage, int moduleCount, int margin, int svgSize, double logoPaddingPixels, System.Drawing.Color backgroundColor, string? logoSvgContent = null) - { - // Calculate the pixel size of each QR code module - int totalModules = moduleCount + (margin * 2); - double modulePixelSize = (double)svgSize / totalModules; - - // Calculate the punchout space size based on the size percentage - // This is the area that will be covered (blocking QR code modules) - int punchoutSizePixels = (int)(svgSize * (sizePercentage / 100.0)); - - // Round the punchout size to the nearest module boundary - int punchoutSizeModules = (int)Math.Round(punchoutSizePixels / modulePixelSize); - if (punchoutSizeModules < 1) punchoutSizeModules = 1; - if (punchoutSizeModules % 2 == 0) punchoutSizeModules++; - - int punchoutSize = (int)(punchoutSizeModules * modulePixelSize); - - // Calculate the position to center the punchout area - int punchoutX = (svgSize - punchoutSize) / 2; - int punchoutY = (svgSize - punchoutSize) / 2; - - // Convert padding to actual pixels - // Positive padding = logo smaller than punchout (adds white space) - // Negative padding = logo larger than punchout (logo extends beyond white background) - int paddingPixels = (int)Math.Round(logoPaddingPixels); - - // Calculate the actual logo display size - // Logo fills the punchout area minus the padding on all sides - int logoDisplayWidth = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); - int logoDisplayHeight = Math.Max(1, punchoutSize - (Math.Abs(paddingPixels) * 2)); + => QrCodeRenderer.GetSizeRecommendation(distance, numberOfBlocks, foreground, background, qrPaddingModules); - // If padding is negative, logo is larger than punchout - if (paddingPixels < 0) - { - logoDisplayWidth = punchoutSize + (Math.Abs(paddingPixels) * 2); - logoDisplayHeight = punchoutSize + (Math.Abs(paddingPixels) * 2); - } - - // Build the SVG logo element with punchout background and logo - // Use rgba() when the background has any transparency so the punchout is correctly transparent - string backgroundColorFill = backgroundColor.A < 255 - ? $"rgba({backgroundColor.R},{backgroundColor.G},{backgroundColor.B},{backgroundColor.A / 255.0:F3})" - : $"rgb({backgroundColor.R},{backgroundColor.G},{backgroundColor.B})"; - - string logoSvgElement; - - if (logoSvgContent != null) - { - // SVG logo: inline as a nested element so all viewers render it correctly - int logoWidth = logoDisplayWidth; - int logoHeight = logoDisplayHeight; - int logoX = punchoutX + (punchoutSize - logoWidth) / 2; - int logoY = punchoutY + (punchoutSize - logoHeight) / 2; - - XElement logoElement = XElement.Parse(logoSvgContent, LoadOptions.PreserveWhitespace); - if (logoElement.Name.LocalName != "svg") - throw new InvalidDataException("The logo must have an SVG root element."); - - string viewBox = ExtractSvgViewBox(logoSvgContent); - if (!string.IsNullOrEmpty(viewBox)) - logoElement.SetAttributeValue("viewBox", viewBox); - logoElement.SetAttributeValue("x", logoX); - logoElement.SetAttributeValue("y", logoY); - logoElement.SetAttributeValue("width", logoWidth); - logoElement.SetAttributeValue("height", logoHeight); - logoElement.SetAttributeValue("preserveAspectRatio", "xMidYMid meet"); - logoElement.SetAttributeValue("overflow", "visible"); - - logoSvgElement = $@" - - - - {logoElement.ToString(SaveOptions.DisableFormatting)}"; - } - else - { - // Raster logo: calculate AR and embed as base64 PNG - float aspectRatio = (float)logo!.Width / logo.Height; - int logoWidth, logoHeight; - - if (aspectRatio > 1) // Wider than tall - { - logoWidth = logoDisplayWidth; - logoHeight = (int)(logoDisplayWidth / aspectRatio); - } - else // Taller than wide or square - { - logoHeight = logoDisplayHeight; - logoWidth = (int)(logoDisplayHeight * aspectRatio); - } - - // Center the logo within the punchout area (or offset if larger) - int logoX = punchoutX + (punchoutSize - logoWidth) / 2; - int logoY = punchoutY + (punchoutSize - logoHeight) / 2; - - // Resize the logo to the display size before encoding to reduce SVG file size - Bitmap resizedLogo = new(logoWidth, logoHeight); - using (Graphics g = Graphics.FromImage(resizedLogo)) - { - g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; - g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; - g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; - g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; - g.DrawImage(logo, 0, 0, logoWidth, logoHeight); - } - - string base64Logo; - using (MemoryStream ms = new()) - { - resizedLogo.Save(ms, ImageFormat.Png); - byte[] imageBytes = ms.ToArray(); - base64Logo = Convert.ToBase64String(imageBytes); - } - resizedLogo.Dispose(); - - logoSvgElement = $@" - - - - "; - } - - // Build an SVG mask that subtracts the logo area from the QR code content. - // A plain painted on top of SVG paths does nothing — - // the mask approach is the only reliable way to clear QR modules in the logo zone. - string maskId = "logo-mask"; - string maskDefs = $""" - - - - - - -"""; - - // Find the end of the opening tag so we can inject and wrap the QR - // content in a masked group. Start search at the or declarations. - // Use a regex that skips quoted attribute values so a stray '>' inside an attribute - // (e.g. style="fill:>") does not split the tag prematurely. - string svgContent = svg.Content; - Match openTagMatch = SvgTagRegex().Match(svgContent); - int openTagEnd = openTagMatch.Index + openTagMatch.Length - 1; - - // Wrap only the outermost — use LastIndexOf so any nested elements - // (e.g. from an inlined SVG logo) are not accidentally closed early. - string svgBody = svgContent[(openTagEnd + 1)..]; - int lastClose = svgBody.LastIndexOf("", StringComparison.OrdinalIgnoreCase); - - string modifiedContent = svgContent[..(openTagEnd + 1)] - + "\n" + maskDefs - + $"\n" - + (lastClose >= 0 - ? svgBody[..lastClose] + $"\n{logoSvgElement}\n" - : svgBody + $"\n{logoSvgElement}\n"); // malformed but degrade gracefully - - return new SvgImage(modifiedContent); - } + public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel correctionLevel, Color foreground, Color background, Bitmap? logoImage = null, double logoSizePercentage = 20.0, double logoPaddingPixels = 8.0, string? logoSvgContent = null, double qrPaddingModules = 2.0, QrFramePreset framePreset = QrFramePreset.None, string? frameText = null) + => QrCodeRenderer.GetSvgQrCodeForText(text, correctionLevel, foreground, background, logoImage, logoSizePercentage, logoPaddingPixels, logoSvgContent, qrPaddingModules, framePreset, frameText); public static IEnumerable<(string, Result)> GetStringsFromImageFile(StorageFile storageFile) - { - try - { - Bitmap bitmap = new(storageFile.Path); - return GetStringsFromBitmap(bitmap); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Could not load image file '{storageFile.Name}': {ex.Message}"); - return []; - } - } + => QrCodeRenderer.GetStringsFromImageFile(storageFile); public static IEnumerable<(string, Result)> GetStringsFromBitmap(Bitmap bitmap) - { - BarcodeReader barcodeReader = new() - { - AutoRotate = true, - Options = new DecodingOptions - { - TryHarder = true, - TryInverted = true, - PossibleFormats = [BarcodeFormat.QR_CODE, BarcodeFormat.DATA_MATRIX, BarcodeFormat.AZTEC, BarcodeFormat.PDF_417], - PureBarcode = false, - } - }; - - foreach (int decodePadding in GetDecodePaddings(bitmap)) - { - Bitmap? paddedBitmap = decodePadding > 0 - ? AddOuterDecodePadding(bitmap, decodePadding) - : null; - Bitmap decodeBitmap = paddedBitmap ?? bitmap; - - Result[] results = barcodeReader.DecodeMultiple(decodeBitmap); - List<(string, Result)> strings = ConvertResultsToStrings(results, decodePadding); - paddedBitmap?.Dispose(); - - if (strings.Count > 0) - return strings; - } - - return []; - } - - private static List<(string, Result)> ConvertResultsToStrings(Result[]? results, int decodePadding) - { - List<(string, Result)> strings = []; - - if (results == null || results.Length == 0) - return strings; - - foreach (Result result in results) - { - if (string.IsNullOrWhiteSpace(result.Text)) - continue; - - if (decodePadding > 0) - ShiftResultPointsToOriginalImage(result, decodePadding); - - strings.Add((result.Text, result)); - } - - return strings; - } - - private static void ShiftResultPointsToOriginalImage(Result result, int decodePadding) - { - ResultPoint[]? points = result.ResultPoints; - if (points == null) - return; - - for (int i = 0; i < points.Length; i++) - { - ResultPoint p = points[i]; - if (p != null) - points[i] = new ResultPoint(p.X - decodePadding, p.Y - decodePadding); - } - } - - private static IEnumerable GetDecodePaddings(Bitmap bitmap) - { - int minDimension = Math.Min(bitmap.Width, bitmap.Height); - int mediumPadding = Math.Clamp((int)Math.Round(minDimension * 0.03, MidpointRounding.AwayFromZero), 8, 24); - int largePadding = Math.Clamp((int)Math.Round(minDimension * 0.08, MidpointRounding.AwayFromZero), 16, 64); - - int[] paddings = [0, mediumPadding, largePadding]; - return paddings.Distinct().Order(); - } - - private static Bitmap AddOuterDecodePadding(Bitmap source, int padding) - { - Bitmap padded = new(source.Width + (padding * 2), source.Height + (padding * 2), PixelFormat.Format32bppArgb); - using Graphics g = Graphics.FromImage(padded); - g.Clear(System.Drawing.Color.White); - g.DrawImageUnscaled(source, padding, padding); - return padded; - } - - /// - /// Extracts the viewBox value from an SVG string (e.g. "0 0 600 530"). - /// Falls back to constructing one from width/height attributes if no viewBox is present. - /// - private static string ExtractSvgViewBox(string svgContent) - { - // Prefer an explicit viewBox attribute - Match viewBoxMatch = - SvgViewboxRegex().Match(svgContent); - if (viewBoxMatch.Success) - return viewBoxMatch.Groups[1].Value; - - // Fall back to synthesising viewBox from width/height on the element - Match widthMatch = - SvgTagWidthRegex().Match(svgContent); - Match heightMatch = - SvgTagHeightRegex().Match(svgContent); - - if (widthMatch.Success && heightMatch.Success) - return $"0 0 {widthMatch.Groups[1].Value} {heightMatch.Groups[1].Value}"; - - return string.Empty; - } - - /// - /// Extracts the inner content of an SVG string — everything between the - /// end of the opening <svg> tag and the last </svg>. - /// Correctly skips any leading XML declaration or DOCTYPE preamble. - /// - private static string ExtractSvgInnerContent(string svgContent) - { - // Locate the and other preamble) - int svgTagStart = svgContent.IndexOf(" of the opening tag - int openTagEnd = svgContent.IndexOf('>', svgTagStart); - if (openTagEnd < 0) - return string.Empty; - - // Find the last (the one that closes the root element) - int closeTagStart = svgContent.LastIndexOf("", StringComparison.OrdinalIgnoreCase); - if (closeTagStart < 0 || closeTagStart <= openTagEnd) - return string.Empty; - - return svgContent[(openTagEnd + 1)..closeTagStart]; - } + => QrCodeRenderer.GetStringsFromBitmap(bitmap); public static int NormalizeQrPaddingModules(double qrPaddingModules) - { - return Math.Clamp((int)Math.Round(qrPaddingModules, MidpointRounding.AwayFromZero), 0, 10); - } - - private static int GetQrRenderSize(int moduleCount, int margin) - { - int totalModules = moduleCount + (margin * 2); - int pixelsPerModule = Math.Max(1, MaxQrRenderSize / totalModules); - return totalModules * pixelsPerModule; - } - - [GeneratedRegex(@"""']|""[^""]*""|'[^']*')*>", RegexOptions.IgnoreCase | RegexOptions.Singleline, "en-US")] - private static partial Regex SvgTagRegex(); - - [GeneratedRegex(@"viewBox\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase, "en-US")] - private static partial Regex SvgViewboxRegex(); - - [GeneratedRegex(@"]*\swidth\s*=\s*[""']([0-9.]+)[""']", RegexOptions.IgnoreCase, "en-US")] - private static partial Regex SvgTagWidthRegex(); - - [GeneratedRegex(@"]*\sheight\s*=\s*[""']([0-9.]+)[""']", RegexOptions.IgnoreCase, "en-US")] - private static partial Regex SvgTagHeightRegex(); + => QrCodeRenderer.NormalizeQrPaddingModules(qrPaddingModules); } \ No newline at end of file diff --git a/Simple QR Code Maker/Helpers/BrandStorageHelper.cs b/Simple QR Code Maker/Helpers/BrandStorageHelper.cs index ff229f0..60489db 100644 --- a/Simple QR Code Maker/Helpers/BrandStorageHelper.cs +++ b/Simple QR Code Maker/Helpers/BrandStorageHelper.cs @@ -1,69 +1,14 @@ -using Simple_QR_Code_Maker.Models; using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Text.Json; -using Windows.Storage; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; namespace Simple_QR_Code_Maker.Helpers; public static class BrandStorageHelper { - private const string BrandsFileName = "Brands.json"; - - public static async Task> LoadBrandsAsync() - { - try - { - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - StorageFile brandsFile = await localFolder.GetFileAsync(BrandsFileName); - string json = await FileIO.ReadTextAsync(brandsFile); - - if (string.IsNullOrWhiteSpace(json)) - { - Debug.WriteLine($"{BrandsFileName} exists but is empty"); - return []; - } - - ObservableCollection? brands = JsonSerializer.Deserialize(json, BrandJsonContext.Default.ObservableCollectionBrandItem); - - Debug.WriteLine($"Loaded {brands?.Count ?? 0} brands from {BrandsFileName}"); - return brands ?? []; - } - catch (FileNotFoundException) - { - Debug.WriteLine($"{BrandsFileName} not found - starting with empty brands"); - return []; - } - catch (Exception ex) - { - Debug.WriteLine($"Error loading {BrandsFileName}: {ex.GetType().Name}: {ex.Message}"); - return []; - } - } - - public static async Task SaveBrandsAsync(ObservableCollection brandItems) - { - try - { - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - - string json = JsonSerializer.Serialize(brandItems, BrandJsonContext.Default.ObservableCollectionBrandItem); - - string tempFileName = BrandsFileName + ".tmp"; - StorageFile tempFile = await localFolder.CreateFileAsync( - tempFileName, - CreationCollisionOption.ReplaceExisting); - - await FileIO.WriteTextAsync(tempFile, json); - - await tempFile.RenameAsync(BrandsFileName, NameCollisionOption.ReplaceExisting); + public static Task> LoadBrandsAsync() => + App.GetService().LoadAsync(); - Debug.WriteLine($"Saved {brandItems.Count} brands to {BrandsFileName} ({json.Length} bytes)"); - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to save brands: {ex.Message}"); - throw; - } - } -} + public static Task SaveBrandsAsync(ObservableCollection brandItems) => + App.GetService().SaveAsync(brandItems); +} \ No newline at end of file diff --git a/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs b/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs index 6f0c394..7c44464 100644 --- a/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs +++ b/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs @@ -1,133 +1,23 @@ -using Simple_QR_Code_Maker.Models; using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Text.Json; -using Windows.Storage; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; namespace Simple_QR_Code_Maker.Helpers; public static class DecodingHistoryStorageHelper { - private const string HistoryFileName = "DecodingHistory.json"; - private const string HistoryImagesFolderName = "DecodingHistoryImages"; - private const int MaxHistoryItems = 50; - - public static async Task SaveImageCopyAsync(string sourcePath) - { - try - { - if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) - return null; - - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - StorageFolder imagesFolder = await localFolder.CreateFolderAsync( - HistoryImagesFolderName, - CreationCollisionOption.OpenIfExists); - - string fileName = $"{DateTimeOffset.Now.Ticks}_decoded.png"; - StorageFile destFile = await imagesFolder.CreateFileAsync( - fileName, - CreationCollisionOption.ReplaceExisting); - - await Task.Run(() => File.Copy(sourcePath, destFile.Path, overwrite: true)); - - return destFile.Path; - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to save image copy for decoding history: {ex.Message}"); - return null; - } - } - - public static async Task> LoadHistoryAsync() - { - try - { - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - IStorageItem? item = await localFolder.TryGetItemAsync(HistoryFileName); - - if (item is not StorageFile historyFile) - return []; - - string json = await FileIO.ReadTextAsync(historyFile); - if (string.IsNullOrWhiteSpace(json)) - return []; - - ObservableCollection? history = - JsonSerializer.Deserialize(json, DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem); - - return history ?? []; - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to load decoding history: {ex.Message}"); - return []; - } - } - - public static async Task SaveHistoryAsync(ObservableCollection items) - { - try - { - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - string json = JsonSerializer.Serialize(items, DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem); - - string tempFileName = HistoryFileName + ".tmp"; - StorageFile tempFile = await localFolder.CreateFileAsync( - tempFileName, - CreationCollisionOption.ReplaceExisting); - - await FileIO.WriteTextAsync(tempFile, json); - await tempFile.RenameAsync(HistoryFileName, NameCollisionOption.ReplaceExisting); - - Debug.WriteLine($"Saved {items.Count} decoding history items."); - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to save decoding history: {ex.Message}"); - } - } - - public static async Task AddAndSaveAsync( - ObservableCollection items, - DecodingHistoryItem newItem) - { - items.Insert(0, newItem); - - while (items.Count > MaxHistoryItems) - items.RemoveAt(items.Count - 1); - - await SaveHistoryAsync(items); - } - - /// - /// Replaces the image and decoded texts of the most-recent history item in place. - /// Used when the user applies advanced tool changes to an already-saved image so that - /// a second history entry is not created. - /// - public static async Task UpdateLatestAndSaveAsync( - ObservableCollection items, - string newImageSourcePath, - List newDecodedTexts) - { - if (items.Count == 0) - return; - - DecodingHistoryItem latest = items[0]; + public static Task SaveImageCopyAsync(string sourcePath) => + App.GetService().SaveImageCopyAsync(sourcePath); - // Delete the old image file so it doesn't become orphaned - if (!string.IsNullOrEmpty(latest.SavedImagePath) && File.Exists(latest.SavedImagePath)) - { - try { File.Delete(latest.SavedImagePath); } - catch (Exception ex) { Debug.WriteLine($"Could not delete old history image: {ex.Message}"); } - } + public static Task> LoadHistoryAsync() => + App.GetService().LoadAsync(); - string? savedImagePath = await SaveImageCopyAsync(newImageSourcePath); + public static Task SaveHistoryAsync(ObservableCollection items) => + App.GetService().SaveAsync(items); - latest.SavedImagePath = savedImagePath ?? string.Empty; - latest.DecodedTexts = newDecodedTexts; + public static Task AddAndSaveAsync(ObservableCollection items, DecodingHistoryItem newItem) => + App.GetService().AddAndSaveAsync(items, newItem); - await SaveHistoryAsync(items); - } -} + public static Task UpdateLatestAndSaveAsync(ObservableCollection items, string newImageSourcePath, List newDecodedTexts) => + App.GetService().UpdateLatestAndSaveAsync(items, newImageSourcePath, newDecodedTexts); +} \ No newline at end of file diff --git a/Simple QR Code Maker/Helpers/ExcelSpreadsheetHelper.cs b/Simple QR Code Maker/Helpers/ExcelSpreadsheetHelper.cs index df47e15..776d9c3 100644 --- a/Simple QR Code Maker/Helpers/ExcelSpreadsheetHelper.cs +++ b/Simple QR Code Maker/Helpers/ExcelSpreadsheetHelper.cs @@ -52,8 +52,8 @@ public static async Task ReadAsync(string filePath) return extension.ToLowerInvariant() switch { - ".csv" => await ReadDelimitedRowsAsync(filePath, ','), - ".tsv" => await ReadDelimitedRowsAsync(filePath, '\t'), + ".csv" => await DelimitedSpreadsheetHelper.ReadAsync(filePath, ','), + ".tsv" => await DelimitedSpreadsheetHelper.ReadAsync(filePath, '\t'), ".xlsx" or ".xls" => await ReadWorkbookRowsAsync(filePath), _ => throw new NotSupportedException($"Unsupported spreadsheet file type: {extension}"), }; @@ -71,10 +71,10 @@ public static async Task WriteGeneratedIdsAsync(string filePath, SpreadsheetIdWr switch (extension.ToLowerInvariant()) { case ".csv": - await WriteDelimitedGeneratedIdsAsync(filePath, ',', request); + await DelimitedSpreadsheetHelper.WriteGeneratedIdsAsync(filePath, ',', request); break; case ".tsv": - await WriteDelimitedGeneratedIdsAsync(filePath, '\t', request); + await DelimitedSpreadsheetHelper.WriteGeneratedIdsAsync(filePath, '\t', request); break; case ".xlsx": case ".xls": @@ -85,23 +85,6 @@ public static async Task WriteGeneratedIdsAsync(string filePath, SpreadsheetIdWr } } - private static async Task ReadDelimitedRowsAsync(string filePath, char delimiter) - { - string contents = await File.ReadAllTextAsync(filePath); - List> rows = CsvParser.Parse(contents, delimiter); - List sourceRows = [.. rows - .Select((row, index) => new SpreadsheetSourceRow - { - SourceRowIndex = index, - Cells = [.. row], - })]; - - return new SpreadsheetReadResult - { - Rows = sourceRows, - }; - } - private static async Task ReadWorkbookRowsAsync(string filePath) { if (!await CheckIsAvailableAsync()) @@ -110,16 +93,6 @@ private static async Task ReadWorkbookRowsAsync(string fi return await RunStaAsync(() => ReadWorkbookRowsInternal(filePath)); } - private static async Task WriteDelimitedGeneratedIdsAsync(string filePath, char delimiter, SpreadsheetIdWriteRequest request) - { - string contents = await File.ReadAllTextAsync(filePath); - List> rows = CsvParser.Parse(contents, delimiter); - ApplyGeneratedIds(rows, request); - - string updatedContents = CsvParser.Serialize(rows, delimiter); - await WriteTextFileAtomicallyAsync(filePath, updatedContents); - } - private static async Task WriteWorkbookGeneratedIdsAsync(string filePath, SpreadsheetIdWriteRequest request) { if (!await CheckIsAvailableAsync()) @@ -431,50 +404,6 @@ private static void SetWorksheetCellText(object? cellsCollection, int row, int c } } - private static void ApplyGeneratedIds(List> rows, SpreadsheetIdWriteRequest request) - { - if (request.CreateIdColumn && request.FirstRowIsHeader) - { - EnsureRowAndColumn(rows, 0, request.IdColumnIndex); - rows[0][request.IdColumnIndex] = "id"; - } - - foreach (SpreadsheetIdWriteRow row in request.Rows) - { - EnsureRowAndColumn(rows, row.SourceRowIndex, request.IdColumnIndex); - rows[row.SourceRowIndex][request.IdColumnIndex] = row.Value; - } - } - - private static void EnsureRowAndColumn(List> rows, int rowIndex, int columnIndex) - { - while (rows.Count <= rowIndex) - rows.Add([]); - - List row = rows[rowIndex]; - while (row.Count <= columnIndex) - row.Add(string.Empty); - } - - private static async Task WriteTextFileAtomicallyAsync(string filePath, string contents) - { - string directory = Path.GetDirectoryName(filePath) - ?? throw new InvalidOperationException("Unable to determine the spreadsheet file directory."); - string tempFilePath = Path.Combine(directory, $"{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp"); - - await File.WriteAllTextAsync(tempFilePath, contents); - - try - { - File.Replace(tempFilePath, filePath, destinationBackupFileName: null, ignoreMetadataErrors: true); - } - finally - { - if (File.Exists(tempFilePath)) - File.Delete(tempFilePath); - } - } - private static object? GetProperty(object? target, string propertyName) { ArgumentNullException.ThrowIfNull(target); @@ -562,4 +491,4 @@ private static Task RunStaAsync(Func func) return completionSource.Task; } -} +} \ No newline at end of file diff --git a/Simple QR Code Maker/Helpers/HistoryStorageHelper.cs b/Simple QR Code Maker/Helpers/HistoryStorageHelper.cs index 4b9110e..225b068 100644 --- a/Simple QR Code Maker/Helpers/HistoryStorageHelper.cs +++ b/Simple QR Code Maker/Helpers/HistoryStorageHelper.cs @@ -1,517 +1,14 @@ -using Simple_QR_Code_Maker.Models; using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using Windows.Storage; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; namespace Simple_QR_Code_Maker.Helpers; -/// -/// Helper class for managing QR code history storage and migration. -/// Handles both new JSON file format and legacy settings-based storage. -/// public static class HistoryStorageHelper { - private const string HistoryFileName = "History.json"; - private const string HistoryItemsKey = "HistoryItems"; - - /// - /// Loads history from storage, attempting migration from old settings if needed. - /// - /// Collection of history items, or empty collection if no history exists. - public static async Task> LoadHistoryAsync() - { - ObservableCollection historyItems = []; - - // Try to load from new file location first - ObservableCollection? historyFromFile = await LoadHistoryFromFileAsync(); - - if (historyFromFile != null && historyFromFile.Count > 0) - { - foreach (HistoryItem item in historyFromFile) - { - ValidateAndFixColors(item); - historyItems.Add(item); - } - return historyItems; - } - - // If no file exists, attempt migration from old settings - ObservableCollection? migratedHistory = await MigrateFromOldSettingsAsync(); - - if (migratedHistory != null && migratedHistory.Count > 0) - { - foreach (HistoryItem item in migratedHistory) - { - ValidateAndFixColors(item); - historyItems.Add(item); - } - - // Save migrated history to new file location - await SaveHistoryAsync(historyItems); - - // Clean up old settings - await ClearOldSettingsAsync(); - } - - return historyItems; - } - - /// - /// Saves history to the JSON file. - /// - public static async Task SaveHistoryAsync(ObservableCollection historyItems) - { - try - { - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - - string json = JsonSerializer.Serialize(historyItems, HistoryJsonContext.Default.ObservableCollectionHistoryItem); - - // Write to a temp file first, then rename over the real file. - // This prevents an empty History.json when the app exits before - // the write completes (fire-and-forget in SaveHistoryOnShutdown). - string tempFileName = HistoryFileName + ".tmp"; - StorageFile tempFile = await localFolder.CreateFileAsync( - tempFileName, - CreationCollisionOption.ReplaceExisting); - - await FileIO.WriteTextAsync(tempFile, json); - - // Atomic rename – replaces the destination if it already exists. - await tempFile.RenameAsync(HistoryFileName, NameCollisionOption.ReplaceExisting); - - Debug.WriteLine($"?? Saved {historyItems.Count} items to {HistoryFileName} ({json.Length} bytes)"); - } - catch (Exception ex) - { - Debug.WriteLine($"? Failed to save history: {ex.Message}"); - throw; - } - } - - /// - /// Validates and fixes transparent or invalid colors in a history item. - /// - private static void ValidateAndFixColors(HistoryItem item) - { - // Fix transparent foreground color - if (item.Foreground.A == 0 && item.Foreground.R == 0 && - item.Foreground.G == 0 && item.Foreground.B == 0) - { - Debug.WriteLine($"?? Fixed transparent foreground for: {item.CodesContent}"); - item.Foreground = Windows.UI.Color.FromArgb(255, 0, 0, 0); - } - - // Fix transparent background color - if (item.Background.A == 0 && item.Background.R == 0 && - item.Background.G == 0 && item.Background.B == 0) - { - Debug.WriteLine($"?? Fixed transparent background for: {item.CodesContent}"); - item.Background = Windows.UI.Color.FromArgb(255, 255, 255, 255); - } - } - - /// - /// Loads history from the JSON file. - /// - private static async Task?> LoadHistoryFromFileAsync() - { - try - { - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - StorageFile historyFile = await localFolder.GetFileAsync(HistoryFileName); - string json = await FileIO.ReadTextAsync(historyFile); - - if (string.IsNullOrWhiteSpace(json)) - { - Debug.WriteLine($"?? {HistoryFileName} exists but is empty - treating as missing"); - return null; - } - - ObservableCollection? history = JsonSerializer.Deserialize(json, HistoryJsonContext.Default.ObservableCollectionHistoryItem); - - Debug.WriteLine($"? Loaded {history?.Count ?? 0} items from {HistoryFileName}"); - return history; - } - catch (FileNotFoundException) - { - Debug.WriteLine($"?? {HistoryFileName} not found - attempting migration from old settings"); - return null; - } - catch (Exception ex) - { - Debug.WriteLine($"? Error loading {HistoryFileName}: {ex.GetType().Name}: {ex.Message}"); - return null; - } - } - - /// - /// Migrates history from old settings storage to new format. - /// - private static async Task?> MigrateFromOldSettingsAsync() - { - Debug.WriteLine("?? Starting history migration from old settings..."); - - try - { - string? historyJson = await ReadOldSettingsJsonAsync(); - - if (string.IsNullOrEmpty(historyJson)) - { - Debug.WriteLine("?? No history found in old settings - user may be new or data was cleared"); - return null; - } - - Debug.WriteLine($"?? Parsing JSON (first 300 chars):\n{historyJson.Substring(0, Math.Min(300, historyJson.Length))}..."); - - // ATTEMPT 1: Try deserializing with proper options (handles new hex format) - ObservableCollection? migratedHistory = await TryDeserializeWithOptionsAsync(historyJson); - if (migratedHistory != null) - { - return migratedHistory; - } - - // ATTEMPT 2: Manual JSON parsing (handles old object format) - migratedHistory = ParseHistoryManually(historyJson); - return migratedHistory; - } - catch (Exception ex) - { - Debug.WriteLine($"?? FATAL ERROR in migration: {ex.GetType().Name}: {ex.Message}"); - Debug.WriteLine($" Stack: {ex.StackTrace}"); - return null; - } - } - - /// - /// Reads the old history JSON from settings storage. - /// - private static async Task ReadOldSettingsJsonAsync() - { - if (RuntimeHelper.IsMSIX) - { - return ReadFromMsixSettings(); - } - else - { - return await ReadFromFileBasedSettingsAsync(); - } - } - - /// - /// Reads history from MSIX ApplicationData settings. - /// - private static string? ReadFromMsixSettings() - { - Debug.WriteLine("?? MSIX: Reading from ApplicationData.Current.LocalSettings"); - - if (ApplicationData.Current.LocalSettings.Values.TryGetValue(HistoryItemsKey, out object? obj)) - { - string? historyJson = obj as string; - Debug.WriteLine($" Found {historyJson?.Length ?? 0} characters"); - return historyJson; - } - - Debug.WriteLine($" Key '{HistoryItemsKey}' not found in LocalSettings"); - return null; - } - - /// - /// Reads history from file-based settings (non-MSIX). - /// - private static async Task ReadFromFileBasedSettingsAsync() - { - Debug.WriteLine("?? Non-MSIX: Reading from LocalSettings.json file"); - - try - { - string appDataPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Simple QR Code Maker/ApplicationData", - "LocalSettings.json"); - - Debug.WriteLine($" Looking for: {appDataPath}"); - - if (!File.Exists(appDataPath)) - { - Debug.WriteLine(" File does not exist"); - return null; - } - - string settingsJson = File.ReadAllText(appDataPath); - Debug.WriteLine($" File exists, size: {settingsJson.Length} bytes"); - - Dictionary? settingsDict = JsonSerializer.Deserialize(settingsJson, HistoryJsonContext.Default.DictionaryStringString); - - if (settingsDict != null && settingsDict.TryGetValue(HistoryItemsKey, out string? histVal)) - { - Debug.WriteLine($" Found {HistoryItemsKey}: {histVal.Length} characters"); - return histVal; - } - - Debug.WriteLine($" {HistoryItemsKey} key not found in settings dictionary"); - return null; - } - catch (Exception fileEx) - { - Debug.WriteLine($"? Failed to read settings file: {fileEx.Message}"); - return null; - } - } - - /// - /// Attempts to deserialize history using proper serializer options. - /// - private static async Task?> TryDeserializeWithOptionsAsync(string historyJson) - { - try - { - ObservableCollection? history = JsonSerializer.Deserialize(historyJson, HistoryJsonContext.Default.ObservableCollectionHistoryItem); - - if (history != null && history.Count > 0) - { - Debug.WriteLine($"? Method 1 SUCCESS: Migrated {history.Count} items with proper format"); - return history; - } - } - catch (JsonException ex) - { - Debug.WriteLine($"?? Method 1 FAILED: {ex.Message}"); - Debug.WriteLine($" Path: {ex.Path}, Line: {ex.LineNumber}"); - } - - return null; - } - - /// - /// Manually parses history JSON to handle old color format. - /// - private static ObservableCollection? ParseHistoryManually(string historyJson) - { - Debug.WriteLine("?? Trying Method 2: Manual JSON parsing with old format support"); - - try - { - using JsonDocument document = JsonDocument.Parse(historyJson); - ObservableCollection migratedHistory = []; - - int itemCount = 0; - foreach (JsonElement element in document.RootElement.EnumerateArray()) - { - itemCount++; - try - { - HistoryItem item = ParseHistoryItem(element, itemCount); - migratedHistory.Add(item); - - Debug.WriteLine($" ? Item {itemCount} migrated: {item.CodesContent.Substring(0, Math.Min(50, item.CodesContent.Length))}"); - } - catch (Exception itemEx) - { - Debug.WriteLine($" ? Item {itemCount} FAILED: {itemEx.Message}"); - } - } - - if (migratedHistory.Count > 0) - { - Debug.WriteLine($"? Method 2 SUCCESS: Migrated {migratedHistory.Count} of {itemCount} items"); - return migratedHistory; - } - - Debug.WriteLine($"? Method 2 FAILED: Parsed {itemCount} items but 0 were valid"); - return null; - } - catch (Exception fallbackEx) - { - Debug.WriteLine($"? Method 2 FAILED: {fallbackEx.GetType().Name}: {fallbackEx.Message}"); - Debug.WriteLine($" Stack: {fallbackEx.StackTrace}"); - return null; - } - } - - /// - /// Parses a single history item from JSON element. - /// - private static HistoryItem ParseHistoryItem(JsonElement element, int itemIndex) - { - HistoryItem item = new() - { - CodesContent = element.TryGetProperty("CodesContent", out JsonElement contentElem) - ? (contentElem.GetString() ?? string.Empty) - : string.Empty, - - SavedDateTime = element.TryGetProperty("SavedDateTime", out JsonElement dateElem) - ? dateElem.GetDateTime() - : DateTime.Now, - - ErrorCorrectionLevelAsString = element.TryGetProperty("ErrorCorrectionLevelAsString", out JsonElement errElem) - ? (errElem.GetString() ?? "M") - : "M", - - LogoSizePercentage = element.TryGetProperty("LogoSizePercentage", out JsonElement sizeElem) - ? sizeElem.GetDouble() - : 15, - - LogoPaddingPixels = element.TryGetProperty("LogoPaddingPixels", out JsonElement padElem) - ? padElem.GetDouble() - : 4.0, - - LogoImagePath = element.TryGetProperty("LogoImagePath", out JsonElement logoElem) - ? logoElem.GetString() - : null, - - LogoEmoji = element.TryGetProperty("LogoEmoji", out JsonElement emojiElem) - ? emojiElem.GetString() - : null, - - LogoEmojiStyle = element.TryGetProperty("LogoEmojiStyle", out JsonElement emojiStyleElem) - && Enum.TryParse(emojiStyleElem.GetString(), ignoreCase: true, out EmojiLogoStyle parsedEmojiStyle) - ? parsedEmojiStyle - : null - }; - - // Parse colors - if (element.TryGetProperty("Foreground", out JsonElement fgElem)) - { - item.Foreground = ParseColorFromJson(fgElem, "Foreground", itemIndex); - } - else - { - Debug.WriteLine($" Item {itemIndex}: No Foreground property, using default black"); - item.Foreground = Windows.UI.Color.FromArgb(255, 0, 0, 0); - } - - if (element.TryGetProperty("Background", out JsonElement bgElem)) - { - item.Background = ParseColorFromJson(bgElem, "Background", itemIndex); - } - else - { - Debug.WriteLine($" Item {itemIndex}: No Background property, using default white"); - item.Background = Windows.UI.Color.FromArgb(255, 255, 255, 255); - } - - return item; - } - - /// - /// Parses a color from JSON element, handling both hex and object formats. - /// - private static Windows.UI.Color ParseColorFromJson(JsonElement colorElement, string colorName, int itemIndex) - { - try - { - // Handle new format: "#FF000000" - if (colorElement.ValueKind == JsonValueKind.String) - { - string? hex = colorElement.GetString(); - if (!string.IsNullOrEmpty(hex) && hex.Length == 9 && hex.StartsWith("#")) - { - byte a = Convert.ToByte(hex.Substring(1, 2), 16); - byte r = Convert.ToByte(hex.Substring(3, 2), 16); - byte g = Convert.ToByte(hex.Substring(5, 2), 16); - byte b = Convert.ToByte(hex.Substring(7, 2), 16); - - Debug.WriteLine($" Item {itemIndex} {colorName}: Parsed HEX {hex} ? ARGB({a},{r},{g},{b})"); - return Windows.UI.Color.FromArgb(a, r, g, b); - } - Debug.WriteLine($" Item {itemIndex} {colorName}: Invalid HEX format '{hex}'"); - } - // Handle old format: {"A":255,"R":0,"G":0,"B":0} - else if (colorElement.ValueKind == JsonValueKind.Object) - { - byte a = colorElement.TryGetProperty("A", out JsonElement aElem) ? aElem.GetByte() : (byte)255; - byte r = colorElement.TryGetProperty("R", out JsonElement rElem) ? rElem.GetByte() : (byte)0; - byte g = colorElement.TryGetProperty("G", out JsonElement gElem) ? gElem.GetByte() : (byte)0; - byte b = colorElement.TryGetProperty("B", out JsonElement bElem) ? bElem.GetByte() : (byte)0; - - Debug.WriteLine($" Item {itemIndex} {colorName}: Parsed OBJ ? ARGB({a},{r},{g},{b})"); - return Windows.UI.Color.FromArgb(a, r, g, b); - } - - Debug.WriteLine($" Item {itemIndex} {colorName}: Unexpected type {colorElement.ValueKind}"); - } - catch (Exception ex) - { - Debug.WriteLine($" Item {itemIndex} {colorName}: Parse error - {ex.Message}"); - } - - // Return appropriate default color - return GetDefaultColor(colorName, itemIndex); - } - - /// - /// Gets the default color for a given color type. - /// - private static Windows.UI.Color GetDefaultColor(string colorName, int itemIndex) - { - bool isBackground = colorName.Equals("Background", StringComparison.OrdinalIgnoreCase); - byte defaultR = isBackground ? (byte)255 : (byte)0; - byte defaultG = isBackground ? (byte)255 : (byte)0; - byte defaultB = isBackground ? (byte)255 : (byte)0; - - Debug.WriteLine($" Item {itemIndex} {colorName}: Using default ARGB(255,{defaultR},{defaultG},{defaultB})"); - return Windows.UI.Color.FromArgb(255, defaultR, defaultG, defaultB); - } - - /// - /// Clears the old settings storage after successful migration. - /// - private static async Task ClearOldSettingsAsync() - { - try - { - if (RuntimeHelper.IsMSIX) - { - ClearMsixSettings(); - } - else - { - await ClearFileBasedSettingsAsync(); - } - - Debug.WriteLine("?? Migration complete!"); - } - catch (Exception ex) - { - Debug.WriteLine($"?? Could not clear old settings: {ex.Message}"); - } - } - - /// - /// Clears history from MSIX settings. - /// - private static void ClearMsixSettings() - { - ApplicationData.Current.LocalSettings.Values.Remove(HistoryItemsKey); - Debug.WriteLine("??? Cleared old MSIX settings"); - } - - /// - /// Clears history from file-based settings. - /// - private static async Task ClearFileBasedSettingsAsync() - { - string appDataPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Simple QR Code Maker/ApplicationData"); - - string settingsPath = Path.Combine(appDataPath, "LocalSettings.json"); - - if (!File.Exists(settingsPath)) - { - return; - } - - string settingsJson = File.ReadAllText(settingsPath); - Dictionary settingsDict = JsonSerializer.Deserialize(settingsJson, HistoryJsonContext.Default.DictionaryStringString) ?? []; - settingsDict.Remove(HistoryItemsKey); - - string updatedJson = JsonSerializer.Serialize(settingsDict, HistoryJsonContext.Default.DictionaryStringString); - File.WriteAllText(settingsPath, updatedJson); + public static Task> LoadHistoryAsync() => + App.GetService().LoadAsync(); - Debug.WriteLine("??? Cleared old file-based settings"); - } -} + public static Task SaveHistoryAsync(ObservableCollection historyItems) => + App.GetService().SaveAsync(historyItems); +} \ No newline at end of file diff --git a/Simple QR Code Maker/Services/HistoryService.cs b/Simple QR Code Maker/Services/HistoryService.cs deleted file mode 100644 index d955bb4..0000000 --- a/Simple QR Code Maker/Services/HistoryService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Simple_QR_Code_Maker.Contracts.Services; -using Simple_QR_Code_Maker.Helpers; -using Simple_QR_Code_Maker.Models; -using System.Collections.ObjectModel; -using System.Diagnostics; - -namespace Simple_QR_Code_Maker.Services; - -public class HistoryService : IHistoryService -{ - public async Task LoadAsync(ObservableCollection historyItems) - { - ObservableCollection loadedHistory = await HistoryStorageHelper.LoadHistoryAsync(); - - historyItems.Clear(); - foreach (HistoryItem item in loadedHistory) - { - historyItems.Add(item); - } - } - - public async Task SaveAsync(ObservableCollection historyItems) - { - await HistoryStorageHelper.SaveHistoryAsync(historyItems); - } - - public async Task AddOrReplaceAndSaveAsync(ObservableCollection historyItems, HistoryItem historyItem) - { - historyItems.Remove(historyItem); - historyItems.Insert(0, historyItem); - await HistoryStorageHelper.SaveHistoryAsync(historyItems); - } - - public void SaveSnapshotOnShutdown(IEnumerable historyItems, HistoryItem historyItem) - { - try - { - ObservableCollection snapshot = new(historyItems.Where(item => !item.Equals(historyItem))); - snapshot.Insert(0, historyItem); - _ = HistoryStorageHelper.SaveHistoryAsync(snapshot); - } - catch (Exception ex) - { - Debug.WriteLine($"❌ SaveHistoryOnShutdown error: {ex}"); - } - } -} diff --git a/Simple QR Code Maker/Services/LocalSettingsService.cs b/Simple QR Code Maker/Services/LocalSettingsService.cs deleted file mode 100644 index 00e3e9b..0000000 --- a/Simple QR Code Maker/Services/LocalSettingsService.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.Extensions.Options; -using Simple_QR_Code_Maker.Contracts.Services; -using Simple_QR_Code_Maker.Core.Contracts.Services; -using Simple_QR_Code_Maker.Core.Helpers; -using Simple_QR_Code_Maker.Helpers; -using Simple_QR_Code_Maker.Models; -using System.Text.Json; -using Windows.Storage; - -namespace Simple_QR_Code_Maker.Services; - -public class LocalSettingsService : ILocalSettingsService -{ - private const string _defaultApplicationDataFolder = "Simple QR Code Maker/ApplicationData"; - private const string _defaultLocalSettingsFile = "LocalSettings.json"; - - private readonly IFileService _fileService; - private readonly LocalSettingsOptions _options; - - private readonly string _localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - private readonly string _applicationDataFolder; - private readonly string _localSettingsFile; - - private IDictionary _settings; - - private bool _isInitialized; - - public LocalSettingsService(IFileService fileService, IOptions options) - { - _fileService = fileService; - _options = options.Value; - - _applicationDataFolder = Path.Combine(_localApplicationData, _options.ApplicationDataFolder ?? _defaultApplicationDataFolder); - _localSettingsFile = _options.LocalSettingsFile ?? _defaultLocalSettingsFile; - - _settings = new Dictionary(); - } - - private async Task InitializeAsync() - { - if (!_isInitialized) - { - _settings = await Task.Run(() => _fileService.Read>(_applicationDataFolder, _localSettingsFile)) ?? new Dictionary(); - - _isInitialized = true; - } - } - - public async Task ReadSettingAsync(string key) - { - if (RuntimeHelper.IsMSIX) - { - if (ApplicationData.Current.LocalSettings.Values.TryGetValue(key, out object? obj)) - { - object? deserialized = JsonSerializer.Deserialize((string)obj, typeof(T), LocalSettingsJsonContext.Default); - return deserialized is T result ? result : default; - } - } - else - { - await InitializeAsync(); - - if (_settings != null && _settings.TryGetValue(key, out object? obj)) - { - return await Json.ToObjectAsync((string)obj); - } - } - - return default; - } - - public async Task SaveSettingAsync(string key, T value) - { - if (RuntimeHelper.IsMSIX) - { - ApplicationData.Current.LocalSettings.Values[key] = JsonSerializer.Serialize(value, typeof(T), LocalSettingsJsonContext.Default); - } - else - { - await InitializeAsync(); - - _settings[key] = await Json.StringifyAsync(value); - - await Task.Run(() => _fileService.Save(_applicationDataFolder, _localSettingsFile, _settings)); - } - } -} diff --git a/Simple QR Code Maker/Services/PrintService.cs b/Simple QR Code Maker/Services/PrintService.cs index 03d83e2..f59d18b 100644 --- a/Simple QR Code Maker/Services/PrintService.cs +++ b/Simple QR Code Maker/Services/PrintService.cs @@ -4,13 +4,8 @@ using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Printing; using Simple_QR_Code_Maker.Contracts.Services; -using Simple_QR_Code_Maker.Helpers; using Simple_QR_Code_Maker.Models; using System.Diagnostics; -using UglyToad.PdfPig.Content; -using UglyToad.PdfPig.Core; -using UglyToad.PdfPig.Writer; -using Windows.Foundation; using Windows.Graphics.Printing; using Windows.Storage; using Windows.Storage.Streams; @@ -24,7 +19,6 @@ namespace Simple_QR_Code_Maker.Services; public class PrintService : IPrintService { private const uint PrintRenderLongEdgePixels = 2400; - private readonly record struct PrintableQrCodeAsset(string Label, byte[] PngBytes, QrImageLayoutMetrics ImageLayout); private readonly SemaphoreSlim printStateGate = new(1, 1); private PrintDocument? activePrintDocument; private IPrintDocumentSource? activePrintDocumentSource; @@ -33,116 +27,45 @@ public class PrintService : IPrintService private List activePrintPages = []; private string activePrintTitle = "QR Codes"; - public Task GenerateQrPdfAsync( + public async Task GenerateQrPdfAsync( IReadOnlyList codes, QrRenderSettingsSnapshot renderSettings, PrintJobSettings printSettings, CancellationToken cancellationToken = default) { - PrintJobSettings normalizedSettings = printSettings.Normalize(); string pdfPath = CreatePreviewPdfPath(); - - return Task.Run(() => + try { + byte[] bytes = await QrPdfRenderer.GenerateQrPdfBytesAsync(codes, renderSettings, printSettings, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - - List printableCodes = codes.Select(code => - { - using QrRenderSettingsSnapshot? commonSettings = renderSettings.CreateCommonBrandOverride(code); - QrRenderSettingsSnapshot effectiveSettings = commonSettings ?? renderSettings; - string? resolvedFrameText = QrFrameTextResolver.Resolve( - effectiveSettings.FramePreset, - effectiveSettings.FrameTextSource, - effectiveSettings.FrameText, - code.CodeAsText, - code.ContentKind, - code.MultiLineCodeModeOverride); - using MemoryStream ms = new(); - BarcodeHelpers.SaveQrCodePngToStream( - ms, - code.CodeAsText, - effectiveSettings.ErrorCorrectionLevel, - effectiveSettings.ForegroundColor, - effectiveSettings.BackgroundColor, - effectiveSettings.LogoImage, - effectiveSettings.LogoSizePercentage, - effectiveSettings.LogoPaddingPixels, - effectiveSettings.QrPaddingModules, - effectiveSettings.FramePreset, - resolvedFrameText, - out QrImageLayoutMetrics imageLayout); - string label = code.ContentKind switch - { - QrContentKind.VCard => VCardBuilderHelper.GetDisplayName(code.CodeAsText), - QrContentKind.WiFi => WifiBuilderHelper.GetDisplayName(code.CodeAsText), - QrContentKind.Email => EmailBuilderHelper.GetDisplayName(code.CodeAsText), - _ => code.CodeAsText, - }; - return new PrintableQrCodeAsset(label, ms.ToArray(), imageLayout); - }).ToList(); - - cancellationToken.ThrowIfCancellationRequested(); - - try - { - using PdfDocumentBuilder document = new(); - document.DocumentInformation.Title = "QR Codes"; - document.DocumentInformation.Creator = "Simple QR Code Maker"; - PdfLabelFont? labelFont = normalizedSettings.ShowLabels ? new PdfLabelFont(document) : null; - - QrImageLayoutMetrics maximumImageLayout = GetMaximumImageLayout(printableCodes); - PrintLayoutMetrics pageLayout = PrintLayoutHelper.CreateMetrics(normalizedSettings, maximumImageLayout); - int pageCount = (int)Math.Ceiling((double)printableCodes.Count / pageLayout.CodesPerPage); - - for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) - { - cancellationToken.ThrowIfCancellationRequested(); - - PdfPageBuilder page = document.AddPage(pageLayout.PageWidthPoints, pageLayout.PageHeightPoints); - DrawPage(page, pageIndex, printableCodes, normalizedSettings, maximumImageLayout, labelFont); - } - - File.WriteAllBytes(pdfPath, document.Build()); - return pdfPath; - } - catch - { - TryDeleteFile(pdfPath); - throw; - } - }, cancellationToken); + await File.WriteAllBytesAsync(pdfPath, bytes, cancellationToken).ConfigureAwait(false); + return pdfPath; + } + catch + { + TryDeleteFile(pdfPath); + throw; + } } - public async Task PrintPdfAsync( - string pdfPath, - CancellationToken cancellationToken = default) + public async Task PrintPdfAsync(string pdfPath, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(pdfPath); - if (!File.Exists(pdfPath)) - { throw new FileNotFoundException("The generated PDF could not be found for printing.", pdfPath); - } - if (!PrintManager.IsSupported()) - { throw new InvalidOperationException("Printing isn't supported on this device."); - } IntPtr windowHandle = WindowNative.GetWindowHandle(App.MainWindow); - await printStateGate.WaitAsync(cancellationToken); try { if (activePrintDocument is not null) - { throw new InvalidOperationException("Another print job is already in progress."); - } activePrintTitle = Path.GetFileNameWithoutExtension(pdfPath); activePrintBitmaps = await LoadPrintableBitmapsAsync(pdfPath, cancellationToken); RegisterForPrinting(windowHandle); - try { await PrintManagerInterop.ShowPrintUIForWindowAsync(windowHandle); @@ -159,221 +82,10 @@ public async Task PrintPdfAsync( } } - private static void DrawPage( - PdfPageBuilder page, - int pageIndex, - IReadOnlyList printableCodes, - PrintJobSettings settings, - QrImageLayoutMetrics maximumImageLayout, - PdfLabelFont? labelFont) - { - PrintLayoutMetrics pageLayout = PrintLayoutHelper.CreateMetrics(page.PageSize.Width, page.PageSize.Height, settings, maximumImageLayout); - page.SetStrokeColor(255, 255, 255); - page.SetTextAndFillColor(255, 255, 255); - page.DrawRectangle(new PdfPoint(0, 0), page.PageSize.Width, page.PageSize.Height, 0, fill: true); - page.ResetColor(); - - int start = pageIndex * pageLayout.CodesPerPage; - int end = Math.Min(start + pageLayout.CodesPerPage, printableCodes.Count); - - for (int i = start; i < end; i++) - { - int local = i - start; - Rect cellRect = new( - pageLayout.MarginPoints + ((local % pageLayout.Columns) * (pageLayout.CellWidth + pageLayout.SpacingPoints)), - pageLayout.MarginPoints + ((local / pageLayout.Columns) * (pageLayout.CellHeight + pageLayout.SpacingPoints)), - pageLayout.CellWidth, - pageLayout.CellHeight); - - DrawCell(page, cellRect, printableCodes[i], settings, labelFont); - } - } - - private static void DrawCell(PdfPageBuilder page, Rect cellRect, PrintableQrCodeAsset printableCode, PrintJobSettings settings, PdfLabelFont? labelFont) - { - Rect paddedRect = new( - cellRect.X + PrintLayoutHelper.CellPaddingPoints, - cellRect.Y + PrintLayoutHelper.CellPaddingPoints, - Math.Max(cellRect.Width - (PrintLayoutHelper.CellPaddingPoints * 2), 1), - Math.Max(cellRect.Height - (PrintLayoutHelper.CellPaddingPoints * 2), 1)); - - double reservedLabelHeight = settings.ShowLabels ? PrintLayoutHelper.LabelHeightPoints + PrintLayoutHelper.LabelSpacingPoints : 0; - double imageAreaHeight = Math.Max(paddedRect.Height - reservedLabelHeight, 1); - PrintCodePlacement placement = PrintLayoutHelper.CalculateCodePlacement( - cellRect.Width, - cellRect.Height, - settings, - printableCode.ImageLayout); - double imageX = paddedRect.X + ((paddedRect.Width - placement.ImageWidthPoints) / 2); - double imageY = paddedRect.Y + ((imageAreaHeight - placement.ImageHeightPoints) / 2); - - // Layout uses top-left coordinates; PDF coordinates start at the bottom-left. - double imageBottom = page.PageSize.Height - imageY - placement.ImageHeightPoints; - page.AddPng(printableCode.PngBytes, new PdfRectangle( - imageX, - imageBottom, - imageX + placement.ImageWidthPoints, - imageBottom + placement.ImageHeightPoints)); - - if (labelFont is null) - return; - - Rect labelRect = new( - paddedRect.X, - paddedRect.Bottom - PrintLayoutHelper.LabelHeightPoints, - paddedRect.Width, - PrintLayoutHelper.LabelHeightPoints); - - DrawWrappedLabel(page, printableCode.Label, labelRect, labelFont); - } - - private static void DrawWrappedLabel(PdfPageBuilder page, string value, Rect labelRect, PdfLabelFont labelFont) - { - if (string.IsNullOrWhiteSpace(value)) - return; - - double fontSize = GetLabelFontSize(page, value, labelFont, labelRect.Width); - List lines = WrapLabelLines( - page, - value, - labelFont, - fontSize, - labelRect.Width, - PrintLayoutHelper.LabelMaxLineCount, - out _); - double lineHeight = fontSize * PrintLayoutHelper.LabelLineHeightMultiplier; - double totalTextHeight = lines.Count * lineHeight; - double top = labelRect.Y + Math.Max((labelRect.Height - totalTextHeight) / 2, 0); - - foreach (string line in lines) - { - IReadOnlyList letters = labelFont.MeasureText(page, line, fontSize); - double width = GetTextWidth(letters); - double glyphCenter = (letters.Max(letter => letter.BoundingBox.Top) + letters.Min(letter => letter.BoundingBox.Bottom)) / 2; - PdfPoint baseline = new( - labelRect.X + ((labelRect.Width - width) / 2), - page.PageSize.Height - top - (lineHeight / 2) - glyphCenter); - labelFont.DrawText(page, line, fontSize, baseline); - top += lineHeight; - } - } - - private static double GetLabelFontSize(PdfPageBuilder page, string value, PdfLabelFont font, double availableWidth) - { - for (double fontSize = PrintLayoutHelper.LabelFontSizePoints; - fontSize >= PrintLayoutHelper.MinimumLabelFontSizePoints; - fontSize -= 0.5) - { - _ = WrapLabelLines( - page, - value, - font, - fontSize, - availableWidth, - PrintLayoutHelper.LabelMaxLineCount, - out bool fitsCompletely); - - if (fitsCompletely) - return fontSize; - } - - return PrintLayoutHelper.MinimumLabelFontSizePoints; - } - - private static List WrapLabelLines( - PdfPageBuilder page, - string value, - PdfLabelFont font, - double fontSize, - double availableWidth, - int maxLineCount, - out bool fitsCompletely) - { - string normalized = value - .Replace("\r\n", " ", StringComparison.Ordinal) - .Replace('\r', ' ') - .Replace('\n', ' ') - .Trim(); - List lines = []; - - if (string.IsNullOrWhiteSpace(normalized)) - { - fitsCompletely = true; - return lines; - } - - int start = 0; - while (start < normalized.Length && lines.Count < maxLineCount) - { - while (start < normalized.Length && char.IsWhiteSpace(normalized[start])) - start++; - - if (start >= normalized.Length) - break; - - int nextBreak = FindWrapBreakIndex(page, normalized, start, font, fontSize, availableWidth); - string line = normalized[start..nextBreak].Trim(); - if (line.Length == 0) - { - nextBreak = Math.Min(start + 1, normalized.Length); - line = normalized[start..nextBreak]; - } - - lines.Add(line); - start = nextBreak; - } - - while (start < normalized.Length && char.IsWhiteSpace(normalized[start])) - start++; - - fitsCompletely = start >= normalized.Length; - return lines; - } - - private static int FindWrapBreakIndex( - PdfPageBuilder page, - string value, - int start, - PdfLabelFont font, - double fontSize, - double availableWidth) - { - int lastPreferredBreak = -1; - - for (int index = start + 1; index <= value.Length; index++) - { - string candidate = value[start..index].TrimEnd(); - if (candidate.Length == 0) - continue; - - if (GetTextWidth(font.MeasureText(page, candidate, fontSize)) <= availableWidth) - { - if (index < value.Length && IsPreferredWrapBoundary(value[index - 1])) - lastPreferredBreak = index; - - continue; - } - - if (lastPreferredBreak > start) - return lastPreferredBreak; - - return Math.Max(start + 1, index - 1); - } - - return value.Length; - } - - private static bool IsPreferredWrapBoundary(char character) - { - return char.IsWhiteSpace(character) - || character is '-' or '_' or '/' or '\\' or '.' or '?' or '&' or '='; - } - private void RegisterForPrinting(IntPtr windowHandle) { activePrintManager = PrintManagerInterop.GetForWindow(windowHandle); activePrintManager.PrintTaskRequested += OnPrintTaskRequested; - activePrintDocument = new PrintDocument(); activePrintDocumentSource = activePrintDocument.DocumentSource; activePrintDocument.Paginate += OnPrintDocumentPaginate; @@ -390,13 +102,11 @@ private void UnregisterForPrinting() activePrintDocument.AddPages -= OnPrintDocumentAddPages; activePrintDocument = null; } - if (activePrintManager is not null) { activePrintManager.PrintTaskRequested -= OnPrintTaskRequested; activePrintManager = null; } - activePrintDocumentSource = null; activePrintBitmaps.Clear(); activePrintPages.Clear(); @@ -412,10 +122,7 @@ private void OnPrintTaskRequested(PrintManager sender, PrintTaskRequestedEventAr private void OnPrintTaskSourceRequested(PrintTaskSourceRequestedArgs args) { if (activePrintDocumentSource is null) - { throw new InvalidOperationException("The print document source is unavailable."); - } - args.SetSource(activePrintDocumentSource); } @@ -423,115 +130,43 @@ private void OnPrintTaskCompleted(PrintTask sender, PrintTaskCompletedEventArgs { App.MainWindow.DispatcherQueue.TryEnqueue(() => { - try - { - UnregisterForPrinting(); - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to clean up the print job: {ex.Message}"); - } + try { UnregisterForPrinting(); } + catch (Exception ex) { Debug.WriteLine($"Failed to clean up the print job: {ex.Message}"); } }); } private void OnPrintDocumentPaginate(object sender, PaginateEventArgs e) { if (activePrintDocument is null) - { return; - } - - PrintPageDescription pageDescription = e.PrintTaskOptions.GetPageDescription(0); - activePrintPages = CreatePrintPages(activePrintBitmaps, pageDescription); + PrintPageDescription description = e.PrintTaskOptions.GetPageDescription(0); + activePrintPages = CreatePrintPages(activePrintBitmaps, description); activePrintDocument.SetPreviewPageCount(activePrintPages.Count, PreviewPageCountType.Final); } private void OnPrintDocumentGetPreviewPage(object sender, GetPreviewPageEventArgs e) { if (activePrintDocument is null) - { return; - } - - int pageIndex = e.PageNumber - 1; - if (pageIndex < 0 || pageIndex >= activePrintPages.Count) - { - return; - } - - activePrintDocument.SetPreviewPage(e.PageNumber, activePrintPages[pageIndex]); + int index = e.PageNumber - 1; + if (index >= 0 && index < activePrintPages.Count) + activePrintDocument.SetPreviewPage(e.PageNumber, activePrintPages[index]); } private void OnPrintDocumentAddPages(object sender, AddPagesEventArgs e) { if (activePrintDocument is null) - { return; - } - foreach (UIElement page in activePrintPages) - { activePrintDocument.AddPage(page); - } - activePrintDocument.AddPagesComplete(); } private static string CreatePreviewPdfPath() { - string previewDirectory = Path.Combine(Path.GetTempPath(), "Simple_QR_Code_Maker", "PrintPreview"); - Directory.CreateDirectory(previewDirectory); - return Path.Combine(previewDirectory, $"qr-print-{DateTimeOffset.UtcNow.Ticks}.pdf"); - } - - private static double GetTextWidth(IReadOnlyList letters) - { - return letters.Count == 0 ? 0 : letters[^1].EndBaseLine.X; - } - - private sealed class PdfLabelFont - { - private readonly PdfDocumentBuilder document; - private readonly byte[] fontBytes; - private readonly HashSet characters = []; - private PdfDocumentBuilder.AddedFont font; - - internal PdfLabelFont(PdfDocumentBuilder document) - { - this.document = document; - fontBytes = File.ReadAllBytes(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf")); - font = document.AddTrueTypeFont(fontBytes); - } - - internal IReadOnlyList MeasureText(PdfPageBuilder page, string text, double fontSize) - { - return page.MeasureText(text, fontSize, new PdfPoint(0, 0), font); - } - - internal void DrawText(PdfPageBuilder page, string text, double fontSize, PdfPoint baseline) - { - int start = 0; - for (int index = 0; index < text.Length; index++) - { - if (characters.Contains(text[index])) - continue; - - // PdfPig's simple TrueType fonts can encode only 255 distinct characters. - if (characters.Count == byte.MaxValue) - { - string run = text[start..index]; - page.AddText(run, fontSize, baseline, font); - baseline = new PdfPoint(baseline.X + GetTextWidth(MeasureText(page, run, fontSize)), baseline.Y); - font = document.AddTrueTypeFont(fontBytes); - characters.Clear(); - start = index; - } - - characters.Add(text[index]); - } - - page.AddText(text[start..], fontSize, baseline, font); - } + string directory = Path.Combine(Path.GetTempPath(), "Simple_QR_Code_Maker", "PrintPreview"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, $"qr-print-{DateTimeOffset.UtcNow.Ticks}.pdf"); } private static async Task> LoadPrintableBitmapsAsync(string pdfPath, CancellationToken cancellationToken) @@ -539,58 +174,48 @@ private static async Task> LoadPrintableBitmapsAsync(string pd StorageFile pdfFile = await StorageFile.GetFileFromPathAsync(pdfPath); WinPdfDocument pdfDocument = await WinPdfDocument.LoadFromFileAsync(pdfFile); List bitmaps = []; - for (uint index = 0; index < pdfDocument.PageCount; index++) { cancellationToken.ThrowIfCancellationRequested(); - using WinPdfPage page = pdfDocument.GetPage(index); using InMemoryRandomAccessStream stream = new(); - - (uint destinationWidth, uint destinationHeight) = GetPrintRenderSize(page); - Windows.Data.Pdf.PdfPageRenderOptions renderOptions = new() + (uint width, uint height) = GetPrintRenderSize(page); + Windows.Data.Pdf.PdfPageRenderOptions options = new() { - DestinationWidth = destinationWidth, - DestinationHeight = destinationHeight, + DestinationWidth = width, + DestinationHeight = height, BackgroundColor = new Color { A = 255, R = 255, G = 255, B = 255 }, }; - - await page.RenderToStreamAsync(stream, renderOptions).AsTask(cancellationToken); + await page.RenderToStreamAsync(stream, options).AsTask(cancellationToken); stream.Seek(0); - BitmapImage bitmap = new(); await bitmap.SetSourceAsync(stream); bitmaps.Add(bitmap); } - return bitmaps; } - private static List CreatePrintPages(IReadOnlyList pageBitmaps, PrintPageDescription pageDescription) + private static List CreatePrintPages(IReadOnlyList pageBitmaps, PrintPageDescription description) { List pages = new(pageBitmaps.Count); - foreach (BitmapImage bitmap in pageBitmaps) { - Grid pageRoot = new() + Grid root = new() { - Width = pageDescription.PageSize.Width, - Height = pageDescription.PageSize.Height, + Width = description.PageSize.Width, + Height = description.PageSize.Height, Background = new SolidColorBrush(Microsoft.UI.Colors.White), }; - - Image pageImage = new() + Image image = new() { Source = bitmap, Stretch = Stretch.Uniform, HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch, }; - - pageRoot.Children.Add(pageImage); - pages.Add(pageRoot); + root.Children.Add(image); + pages.Add(root); } - return pages; } @@ -598,27 +223,9 @@ private static (uint Width, uint Height) GetPrintRenderSize(WinPdfPage page) { double width = Math.Max(page.Size.Width, 1); double height = Math.Max(page.Size.Height, 1); - if (width >= height) - { - uint renderedHeight = (uint)Math.Max(1, Math.Round(PrintRenderLongEdgePixels * (height / width))); - return (PrintRenderLongEdgePixels, renderedHeight); - } - - uint renderedWidth = (uint)Math.Max(1, Math.Round(PrintRenderLongEdgePixels * (width / height))); - return (renderedWidth, PrintRenderLongEdgePixels); - } - - private static QrImageLayoutMetrics GetMaximumImageLayout(IReadOnlyList printableCodes) - { - if (printableCodes.Count == 0) - { - return QrImageLayoutMetrics.Square; - } - - double maxWidthPerQrSize = printableCodes.Max(static code => code.ImageLayout.WidthPerQrSize); - double maxHeightPerQrSize = printableCodes.Max(static code => code.ImageLayout.HeightPerQrSize); - return QrImageLayoutMetrics.FromScaleProfile(maxWidthPerQrSize, maxHeightPerQrSize); + return (PrintRenderLongEdgePixels, (uint)Math.Max(1, Math.Round(PrintRenderLongEdgePixels * height / width))); + return ((uint)Math.Max(1, Math.Round(PrintRenderLongEdgePixels * width / height)), PrintRenderLongEdgePixels); } private static void TryDeleteFile(string path) @@ -626,9 +233,7 @@ private static void TryDeleteFile(string path) try { if (File.Exists(path)) - { File.Delete(path); - } } catch (Exception ex) { diff --git a/Simple QR Code Maker/Services/QrExportService.cs b/Simple QR Code Maker/Services/QrExportService.cs index 13c2fb4..a3a72e5 100644 --- a/Simple QR Code Maker/Services/QrExportService.cs +++ b/Simple QR Code Maker/Services/QrExportService.cs @@ -1,7 +1,6 @@ using Simple_QR_Code_Maker.Contracts.Services; using Simple_QR_Code_Maker.Helpers; using Simple_QR_Code_Maker.Models; -using System.IO.Compression; using Windows.Storage; using Windows.Storage.Pickers; using Windows.Storage.Streams; @@ -67,39 +66,20 @@ public async Task> RenderSvgTextsAsync( if (folder is null) return null; - int completedItemCount = 0; int maxConcurrency = renderSettings.LogoImage is null ? MaxBulkSaveConcurrency : 1; - using SemaphoreSlim semaphore = new(maxConcurrency, maxConcurrency); - List tasks = []; - - foreach (RequestedQrCodeItem requestedCode in requestedCodes) + await AsyncBatchProcessor.RunAsync(requestedCodes, maxConcurrency, async requestedCode => { - await semaphore.WaitAsync(); - tasks.Add(Task.Run(async () => + if (!string.IsNullOrWhiteSpace(requestedCode.SafeFileNameBase)) { - try - { - if (!string.IsNullOrWhiteSpace(requestedCode.SafeFileNameBase)) - { - foreach (FileKind kindOfFile in fileKinds) - { - StorageFile file = await folder.CreateFileAsync( - requestedCode.SafeFileNameBase + GetFileExtension(kindOfFile), - CreationCollisionOption.ReplaceExisting); - await WriteRequestedCodeToFileAsync(requestedCode, file, kindOfFile, renderSettings); - } - } - } - finally + foreach (FileKind kindOfFile in fileKinds) { - int completed = Interlocked.Increment(ref completedItemCount); - updateProgress?.Invoke(completed); - semaphore.Release(); + StorageFile file = await folder.CreateFileAsync( + requestedCode.SafeFileNameBase + GetFileExtension(kindOfFile), + CreationCollisionOption.ReplaceExisting); + await WriteRequestedCodeToFileAsync(requestedCode, file, kindOfFile, renderSettings); } - })); - } - - await Task.WhenAll(tasks); + } + }, updateProgress); return folder.Path; } @@ -116,87 +96,15 @@ public async Task SaveFilesAsZipAsync( using IRandomAccessStream outputStream = await zipFile.OpenAsync(FileAccessMode.ReadWrite); outputStream.Size = 0; using Stream output = outputStream.AsStreamForWrite(); - using ZipArchive archive = new(output, ZipArchiveMode.Create, leaveOpen: true); - - for (int index = 0; index < requestedCodes.Count; index++) - { - RequestedQrCodeItem requestedCode = requestedCodes[index]; - - if (!string.IsNullOrWhiteSpace(requestedCode.SafeFileNameBase)) - { - foreach (FileKind kindOfFile in fileKinds) - { - ZipArchiveEntry entry = archive.CreateEntry( - requestedCode.SafeFileNameBase + GetFileExtension(kindOfFile), - CompressionLevel.Optimal); - using Stream entryStream = entry.Open(); - await WriteRequestedCodeToZipEntryAsync(requestedCode, entryStream, kindOfFile, renderSettings); - } - } - - updateProgress?.Invoke(index + 1); - } - - await output.FlushAsync(); + await QrExportWriter.WriteZipAsync(output, requestedCodes, renderSettings, updateProgress, fileKinds); return true; } private static async Task RenderRequestedCodePngBytesAsync(RequestedQrCodeItem requestedCode, QrRenderSettingsSnapshot renderSettings) - { - return await Task.Run(() => - { - using QrRenderSettingsSnapshot? commonSettings = renderSettings.CreateCommonBrandOverride(requestedCode); - QrRenderSettingsSnapshot effectiveSettings = commonSettings ?? renderSettings; - string? resolvedFrameText = ResolveFrameText(requestedCode, effectiveSettings); - using MemoryStream ms = new(); - BarcodeHelpers.SaveQrCodePngToStream( - ms, - requestedCode.CodeAsText, - effectiveSettings.ErrorCorrectionLevel, - effectiveSettings.ForegroundColor, - effectiveSettings.BackgroundColor, - effectiveSettings.LogoImage, - effectiveSettings.LogoSizePercentage, - effectiveSettings.LogoPaddingPixels, - effectiveSettings.QrPaddingModules, - effectiveSettings.FramePreset, - resolvedFrameText); - return ms.ToArray(); - }); - } + => await QrExportWriter.RenderPngAsync(requestedCode, renderSettings); private static async Task GetRequestedCodeAsSvgTextAsync(RequestedQrCodeItem requestedCode, QrRenderSettingsSnapshot renderSettings) - { - return await Task.Run(() => - { - using QrRenderSettingsSnapshot? commonSettings = renderSettings.CreateCommonBrandOverride(requestedCode); - QrRenderSettingsSnapshot effectiveSettings = commonSettings ?? renderSettings; - string? resolvedFrameText = ResolveFrameText(requestedCode, effectiveSettings); - return BarcodeHelpers.GetSvgQrCodeForText( - requestedCode.CodeAsText, - effectiveSettings.ErrorCorrectionLevel, - effectiveSettings.ForegroundColor, - effectiveSettings.BackgroundColor, - effectiveSettings.LogoImage, - effectiveSettings.LogoSizePercentage, - effectiveSettings.LogoPaddingPixels, - effectiveSettings.LogoSvgContent, - effectiveSettings.QrPaddingModules, - effectiveSettings.FramePreset, - resolvedFrameText).Content; - }); - } - - private static string? ResolveFrameText(RequestedQrCodeItem requestedCode, QrRenderSettingsSnapshot renderSettings) - { - return QrFrameTextResolver.Resolve( - renderSettings.FramePreset, - renderSettings.FrameTextSource, - renderSettings.FrameText, - requestedCode.CodeAsText, - requestedCode.ContentKind, - requestedCode.MultiLineCodeModeOverride); - } + => await QrExportWriter.RenderSvgAsync(requestedCode, renderSettings); private static async Task WriteRequestedCodeToFileAsync(RequestedQrCodeItem requestedCode, StorageFile file, FileKind kindOfFile, QrRenderSettingsSnapshot renderSettings) { @@ -230,32 +138,6 @@ private static async Task WriteRequestedCodeToFileAsync(RequestedQrCodeItem requ } } - private static async Task WriteRequestedCodeToZipEntryAsync(RequestedQrCodeItem requestedCode, Stream entryStream, FileKind kindOfFile, QrRenderSettingsSnapshot renderSettings) - { - switch (kindOfFile) - { - case FileKind.None: - return; - case FileKind.PNG: - { - byte[] pngBytes = await RenderRequestedCodePngBytesAsync(requestedCode, renderSettings); - await entryStream.WriteAsync(pngBytes); - await entryStream.FlushAsync(); - break; - } - case FileKind.SVG: - { - string svgText = await GetRequestedCodeAsSvgTextAsync(requestedCode, renderSettings); - using StreamWriter writer = new(entryStream, new System.Text.UTF8Encoding(false), 1024, leaveOpen: true); - await writer.WriteAsync(svgText); - await writer.FlushAsync(); - break; - } - default: - return; - } - } - private static async Task PickSaveFolderAsync(string? overrideFolderPath, string quickSaveLocation) { if (overrideFolderPath is not null) @@ -287,7 +169,5 @@ private static async Task WriteRequestedCodeToZipEntryAsync(RequestedQrCodeItem } private static string GetFileExtension(FileKind kindOfFile) - { - return $".{kindOfFile.ToString().ToLowerInvariant()}"; - } + => QrExportWriter.GetFileExtension(kindOfFile); } \ No newline at end of file diff --git a/Simple QR Code Maker/Simple QR Code Maker.csproj b/Simple QR Code Maker/Simple QR Code Maker.csproj index a1346b8..ceccabe 100644 --- a/Simple QR Code Maker/Simple QR Code Maker.csproj +++ b/Simple QR Code Maker/Simple QR Code Maker.csproj @@ -58,6 +58,7 @@ + @@ -69,6 +70,7 @@ + @@ -77,11 +79,6 @@ PreserveNewest - - - - CommonBrands.%(Filename)%(Extension) - diff --git a/Simple QR Code Maker/ViewModels/DecodingViewModel.cs b/Simple QR Code Maker/ViewModels/DecodingViewModel.cs index 78f5d91..cc1ce6f 100644 --- a/Simple QR Code Maker/ViewModels/DecodingViewModel.cs +++ b/Simple QR Code Maker/ViewModels/DecodingViewModel.cs @@ -1464,42 +1464,18 @@ private void EditQrCode(object parameter) if (string.IsNullOrWhiteSpace(InfoBarMessage)) return; - bool isVCard = VCardBuilderHelper.IsVCard(InfoBarMessage); - bool isWifi = WifiBuilderHelper.IsWifi(InfoBarMessage); - bool isEmail = EmailBuilderHelper.IsEmail(InfoBarMessage); - QrContentKind contentKind = isVCard - ? QrContentKind.VCard - : isWifi - ? QrContentKind.WiFi - : isEmail - ? QrContentKind.Email - : QrContentKind.PlainText; + QrContentKind contentKind = QrDocumentHelper.ClassifyContent(InfoBarMessage); MultiLineCodeMode? overrideMode = contentKind == QrContentKind.PlainText ? null : MultiLineCodeMode.MultilineOneCode; // Create a new HistoryItem with the decoded text, preserving other state if available - HistoryItem editHistoryItem = navigationHistoryItem != null - ? new HistoryItem - { - CodesContent = InfoBarMessage, - ContentKind = contentKind, - MultiLineCodeModeOverride = overrideMode, - Foreground = navigationHistoryItem.Foreground, - Background = navigationHistoryItem.Background, - ErrorCorrection = navigationHistoryItem.ErrorCorrection, - LogoImagePath = navigationHistoryItem.LogoImagePath, - LogoEmoji = navigationHistoryItem.LogoEmoji, - LogoEmojiStyle = navigationHistoryItem.LogoEmojiStyle, - LogoSizePercentage = navigationHistoryItem.LogoSizePercentage, - LogoPaddingPixels = navigationHistoryItem.LogoPaddingPixels, - } - : new HistoryItem - { - CodesContent = InfoBarMessage, - ContentKind = contentKind, - MultiLineCodeModeOverride = overrideMode, - }; + HistoryItem editHistoryItem = navigationHistoryItem is not null + ? QrCodeDesignStateMapper.ToHistoryItem(QrCodeDesignStateMapper.FromHistoryItem(navigationHistoryItem)) + : new HistoryItem(); + editHistoryItem.CodesContent = InfoBarMessage; + editHistoryItem.ContentKind = contentKind; + editHistoryItem.MultiLineCodeModeOverride = overrideMode; NavigationService.NavigateTo(typeof(MainViewModel).FullName!, editHistoryItem); } @@ -1749,12 +1725,6 @@ private async Task RemoveDecodingHistoryItem(DecodingHistoryItem item) if (item is null) return; - if (!string.IsNullOrEmpty(item.SavedImagePath) && File.Exists(item.SavedImagePath)) - { - try { File.Delete(item.SavedImagePath); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Could not delete decoding history image: {ex.Message}"); } - } - DecodingHistoryItems.Remove(item); await DecodingHistoryStorageHelper.SaveHistoryAsync(DecodingHistoryItems); OnPropertyChanged(nameof(HasDecodingHistory)); @@ -1892,4 +1862,4 @@ public void OnNavigatedFrom() WeakReferenceMessenger.Default.UnregisterAll(this); ResetViewState(); } -} +} \ No newline at end of file diff --git a/Simple QR Code Maker/ViewModels/MainViewModel.cs b/Simple QR Code Maker/ViewModels/MainViewModel.cs index 4a8f9e7..42d72e1 100644 --- a/Simple QR Code Maker/ViewModels/MainViewModel.cs +++ b/Simple QR Code Maker/ViewModels/MainViewModel.cs @@ -1248,23 +1248,7 @@ private void ShowKnownRedirectorWarning(string host, int unsafeRedirectorCount) } private IEnumerable EnumerateRequestedCodeTexts() - { - if (GetEffectiveMultiLineCodeMode() != MultiLineCodeMode.OneLineOneCode) - { - string singleCode = UrlText.Trim(); - if (!string.IsNullOrWhiteSpace(singleCode)) - yield return singleCode; - - yield break; - } - - foreach (string line in UrlText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None)) - { - string textToUse = line.Trim(); - if (!string.IsNullOrWhiteSpace(textToUse)) - yield return textToUse; - } - } + => QrDocumentHelper.EnumerateTexts(UrlText, GetEffectiveMultiLineCodeMode()); private async Task MaterializePreviewBatchAsync(int additionalCount) { @@ -1299,51 +1283,8 @@ private async Task MaterializePreviewBatchAsync(int additionalCount) } private BrandItem? FindMatchingBrand(string content) - { - string normalizedContent = NormalizeUrlForBrandMatching(content); - if (string.IsNullOrEmpty(normalizedContent)) - return null; - - foreach (BrandItem brand in BrandItems) - { - if (string.IsNullOrWhiteSpace(brand.UrlContent)) - continue; - - // Strip trailing slashes so "example.com/" and "example.com" are equivalent patterns - string brandPattern = NormalizeUrlForBrandMatching(brand.UrlContent).TrimEnd('/'); - if (string.IsNullOrEmpty(brandPattern)) - continue; - - if (!normalizedContent.StartsWith(brandPattern, StringComparison.Ordinal)) - continue; - - // Require a URL boundary after the pattern so "pexample.com" cannot match brand "example.com", - // and "example.com.evil.com" cannot match "example.com" (next char would be '.'). - if (normalizedContent.Length == brandPattern.Length) - return brand; - - char next = normalizedContent[brandPattern.Length]; - if (next is '/' or '?' or '#' or ':' or '@') - return brand; - } - - return null; - } - - private static string NormalizeUrlForBrandMatching(string url) - { - string s = url.Trim().ToLowerInvariant(); - - if (s.StartsWith("https://", StringComparison.Ordinal)) - s = s[8..]; - else if (s.StartsWith("http://", StringComparison.Ordinal)) - s = s[7..]; - - if (s.StartsWith("www.", StringComparison.Ordinal)) - s = s[4..]; - - return s; - } + => BrandItems.FirstOrDefault(brand => !string.IsNullOrWhiteSpace(brand.UrlContent) + && QrDocumentHelper.MatchesBrandUrl(content, brand.UrlContent)); private static bool IsUrlLike(string line) => line.StartsWith("http://", StringComparison.OrdinalIgnoreCase) @@ -1351,22 +1292,7 @@ private static bool IsUrlLike(string line) || line.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase); private bool LineMatchesBrandUrl(string line, string brandUrl) - { - string normalizedLine = NormalizeUrlForBrandMatching(line); - string normalizedBrand = NormalizeUrlForBrandMatching(brandUrl).TrimEnd('/'); - - if (string.IsNullOrEmpty(normalizedBrand)) - return false; - - if (!normalizedLine.StartsWith(normalizedBrand, StringComparison.Ordinal)) - return false; - - if (normalizedLine.Length == normalizedBrand.Length) - return true; - - char next = normalizedLine[normalizedBrand.Length]; - return next is '/' or '?' or '#' or ':' or '@'; - } + => QrDocumentHelper.MatchesBrandUrl(line, brandUrl); private bool ApplyBrandUrlToExistingContent(BrandItem brand) { diff --git a/Simple QR Code Maker/ViewModels/SettingsViewModel.cs b/Simple QR Code Maker/ViewModels/SettingsViewModel.cs index 0e1f7fc..0b5a420 100644 --- a/Simple QR Code Maker/ViewModels/SettingsViewModel.cs +++ b/Simple QR Code Maker/ViewModels/SettingsViewModel.cs @@ -614,7 +614,7 @@ private async Task ExportSettings() ObservableCollection historyItems = await HistoryStorageHelper.LoadHistoryAsync(); foreach (HistoryItem item in historyItems) { - item.LogoImagePath = await AddImageToArchiveAsync( + item.LogoImagePath = await BackupArchiveHelper.AddImageToArchiveAsync( archive, item.LogoImagePath, imagePathMap, @@ -634,7 +634,7 @@ private async Task ExportSettings() ObservableCollection brandItems = await BrandStorageHelper.LoadBrandsAsync(); foreach (BrandItem brand in brandItems) { - brand.LogoImagePath = await AddImageToArchiveAsync( + brand.LogoImagePath = await BackupArchiveHelper.AddImageToArchiveAsync( archive, brand.LogoImagePath, imagePathMap, @@ -707,7 +707,7 @@ private async Task ImportSettings() // Extract images first so paths can be resolved when processing JSON. // zipRelPath (e.g. "images/history_20260413_170000_example_logo_001.png") // maps to a unique absolute local path for this import session. - Dictionary importedImagePaths = await ExtractImagesToLocalFolderAsync(archive, localFolder); + Dictionary importedImagePaths = await BackupArchiveHelper.ExtractImagesAsync(archive, localFolder.Path); ZipArchiveEntry? historyEntry = archive.GetEntry("History.json"); if (historyEntry is not null) @@ -720,10 +720,10 @@ private async Task ImportSettings() if (historyItems is not null) { foreach (HistoryItem item in historyItems) - item.LogoImagePath = ResolveImportedImagePath(item.LogoImagePath, importedImagePaths); + item.LogoImagePath = BackupArchiveHelper.ResolveImportedImagePath(item.LogoImagePath, importedImagePaths); ObservableCollection existingHistory = await HistoryStorageHelper.LoadHistoryAsync(); - ObservableCollection mergedHistory = MergeImportedHistory(existingHistory, historyItems); + ObservableCollection mergedHistory = BackupArchiveHelper.MergeHistory(existingHistory, historyItems); await HistoryStorageHelper.SaveHistoryAsync(mergedHistory); } } @@ -739,10 +739,10 @@ private async Task ImportSettings() if (brandItems is not null) { foreach (BrandItem brand in brandItems) - brand.LogoImagePath = ResolveImportedImagePath(brand.LogoImagePath, importedImagePaths); + brand.LogoImagePath = BackupArchiveHelper.ResolveImportedImagePath(brand.LogoImagePath, importedImagePaths); ObservableCollection existingBrands = await BrandStorageHelper.LoadBrandsAsync(); - ObservableCollection mergedBrands = MergeImportedBrands(existingBrands, brandItems); + ObservableCollection mergedBrands = BackupArchiveHelper.MergeBrands(existingBrands, brandItems); await BrandStorageHelper.SaveBrandsAsync(mergedBrands); } } @@ -775,241 +775,6 @@ private async Task ImportSettings() } } - /// - /// Adds an image file to the ZIP archive (deduplicating by original path). - /// Returns the relative ZIP path, or null if the image path is null/missing. - /// - private static async Task AddImageToArchiveAsync( - ZipArchive archive, - string? originalPath, - Dictionary imagePathMap, - string category, - string nameHint, - DateTime timestamp) - { - if (string.IsNullOrEmpty(originalPath)) - return null; - - if (imagePathMap.TryGetValue(originalPath, out string? existingZipPath)) - return existingZipPath; - - if (!File.Exists(originalPath)) - return null; - - string zipRelPath = CreateArchiveImagePath( - category, - nameHint, - originalPath, - timestamp, - imagePathMap.Count + 1); - - imagePathMap[originalPath] = zipRelPath; - - ZipArchiveEntry imgEntry = archive.CreateEntry(zipRelPath, CompressionLevel.NoCompression); - using Stream imgEntryStream = imgEntry.Open(); - using FileStream imgFileStream = File.OpenRead(originalPath); - await imgFileStream.CopyToAsync(imgEntryStream); - - return zipRelPath; - } - - /// - /// Extracts all images/* entries from the archive to LocalFolder/ImportedImages/. - /// Returns a mapping from ZIP relative path to absolute local path. - /// - private static async Task> ExtractImagesToLocalFolderAsync( - ZipArchive archive, StorageFolder localFolder) - { - Dictionary importedPaths = []; - string importedImagesDir = Path.Combine(localFolder.Path, "ImportedImages", CreateImportSessionFolderName()); - Directory.CreateDirectory(importedImagesDir); - - foreach (ZipArchiveEntry entry in archive.Entries) - { - if (!entry.FullName.StartsWith("images/", StringComparison.Ordinal) || string.IsNullOrEmpty(entry.Name)) - continue; - - string localPath = CreateUniqueImportedImagePath(importedImagesDir, entry.Name); - using Stream entryStream = entry.Open(); - using FileStream fileStream = new(localPath, FileMode.Create, FileAccess.Write); - await entryStream.CopyToAsync(fileStream); - - importedPaths[entry.FullName] = localPath; - } - - return importedPaths; - } - - /// - /// Maps a ZIP-relative image path to its extracted absolute local path. - /// Returns null if the path was a ZIP reference that wasn't found in the import. - /// Leaves non-relative paths (i.e. paths from old backups without images) as-is. - /// - private static string? ResolveImportedImagePath(string? logoPath, Dictionary importedImagePaths) - { - if (string.IsNullOrEmpty(logoPath)) - return null; - - if (logoPath.StartsWith("images/", StringComparison.Ordinal)) - return importedImagePaths.TryGetValue(logoPath, out string? local) ? local : null; - - return logoPath; - } - - private static ObservableCollection MergeImportedHistory( - ObservableCollection existingHistory, - ObservableCollection importedHistory) - { - ObservableCollection mergedHistory = new(existingHistory); - - for (int index = importedHistory.Count - 1; index >= 0; index--) - { - HistoryItem importedItem = importedHistory[index]; - mergedHistory.Remove(importedItem); - mergedHistory.Insert(0, importedItem); - } - - return mergedHistory; - } - - private static ObservableCollection MergeImportedBrands( - ObservableCollection existingBrands, - ObservableCollection importedBrands) - { - ObservableCollection mergedBrands = new(existingBrands); - BrandItem? preferredDefault = null; - - for (int index = importedBrands.Count - 1; index >= 0; index--) - { - BrandItem importedBrand = importedBrands[index]; - if (importedBrand.IsDefault) - preferredDefault = importedBrand; - - mergedBrands.Remove(importedBrand); - mergedBrands.Insert(0, importedBrand); - } - - NormalizeDefaultBrand(mergedBrands, preferredDefault); - return mergedBrands; - } - - private static void NormalizeDefaultBrand(ObservableCollection brands, BrandItem? preferredDefault) - { - BrandItem? resolvedDefault = preferredDefault; - if (resolvedDefault is null) - { - foreach (BrandItem brand in brands) - { - if (brand.IsDefault) - { - resolvedDefault = brand; - break; - } - } - } - - bool defaultAssigned = false; - foreach (BrandItem brand in brands) - { - bool shouldBeDefault = resolvedDefault is not null && !defaultAssigned && brand.Equals(resolvedDefault); - brand.IsDefault = shouldBeDefault; - defaultAssigned |= shouldBeDefault; - } - } - - private static string CreateArchiveImagePath( - string category, - string nameHint, - string originalPath, - DateTime timestamp, - int imageNumber) - { - string extension = Path.GetExtension(originalPath); - if (string.IsNullOrWhiteSpace(extension)) - extension = ".img"; - - string categorySegment = SanitizeFileNameSegment(category, 16); - string timestampSegment = (timestamp == default ? DateTime.Now : timestamp).ToString("yyyyMMdd_HHmmss"); - string hintSegment = SanitizeFileNameSegment(nameHint, 36); - string originalNameSegment = SanitizeFileNameSegment(Path.GetFileNameWithoutExtension(originalPath), 24); - - List parts = - [ - string.IsNullOrWhiteSpace(categorySegment) ? "image" : categorySegment, - timestampSegment - ]; - - if (!string.IsNullOrWhiteSpace(hintSegment)) - parts.Add(hintSegment); - - if (!string.IsNullOrWhiteSpace(originalNameSegment) && - !string.Equals(originalNameSegment, hintSegment, StringComparison.OrdinalIgnoreCase)) - { - parts.Add(originalNameSegment); - } - - parts.Add($"logo_{imageNumber:D3}"); - - return $"images/{string.Join("_", parts)}{extension}"; - } - - private static string CreateImportSessionFolderName() - { - return $"import_{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N")[..8]}"; - } - - private static string CreateUniqueImportedImagePath(string importedImagesDir, string entryName) - { - string baseName = SanitizeFileNameSegment(Path.GetFileNameWithoutExtension(entryName), 96); - if (string.IsNullOrWhiteSpace(baseName)) - baseName = "imported_logo"; - - string extension = Path.GetExtension(entryName); - if (string.IsNullOrWhiteSpace(extension)) - extension = ".img"; - - string localPath = Path.Combine(importedImagesDir, $"{baseName}{extension}"); - int duplicateIndex = 1; - - while (File.Exists(localPath)) - { - localPath = Path.Combine(importedImagesDir, $"{baseName}_{duplicateIndex:D2}{extension}"); - duplicateIndex++; - } - - return localPath; - } - - private static string SanitizeFileNameSegment(string? value, int maxLength) - { - if (string.IsNullOrWhiteSpace(value)) - return string.Empty; - - StringBuilder builder = new(maxLength); - bool lastWasSeparator = false; - - foreach (char ch in value.Trim()) - { - if (char.IsLetterOrDigit(ch)) - { - if (builder.Length >= maxLength) - break; - - builder.Append(char.ToLowerInvariant(ch)); - lastWasSeparator = false; - continue; - } - - if (builder.Length == 0 || lastWasSeparator || builder.Length >= maxLength) - continue; - - builder.Append('_'); - lastWasSeparator = true; - } - - return builder.ToString().Trim('_'); - } - private void ShowStatus(string message, InfoBarSeverity severity) { importExportStatusTimer.Stop(); @@ -1172,4 +937,4 @@ public async void OnNavigatedFrom() await SaveAllSettingsAsync(); } } -} +} \ No newline at end of file diff --git a/Simple QR Code Maker/ViewModels/SpreadsheetImportViewModel.cs b/Simple QR Code Maker/ViewModels/SpreadsheetImportViewModel.cs index 1cb9835..ec0dc2b 100644 --- a/Simple QR Code Maker/ViewModels/SpreadsheetImportViewModel.cs +++ b/Simple QR Code Maker/ViewModels/SpreadsheetImportViewModel.cs @@ -312,10 +312,9 @@ private void Refresh(bool firstRowIsHeader) return; } - int columnCount = _allRows.Max(row => row.Cells.Count); _dataRows = firstRowIsHeader ? [.. _allRows.Skip(1)] : [.. _allRows]; - List headers = CreateBaseHeaders(columnCount, firstRowIsHeader); + List headers = SpreadsheetImportPolicy.CreateHeaders(_allRows, firstRowIsHeader); int existingIdColumnIndex = firstRowIsHeader ? FindIdColumnIndex(headers) : -1; _idColumnIndex = -1; _willCreateIdColumn = false; @@ -383,38 +382,8 @@ private void UpdateImportCount() private List GetSelectedValues() { - int columnIndex = SelectedColumnIndex; - if (columnIndex < 0) - return []; - - IEnumerable values = _displayRows - .Select(row => columnIndex < row.Count ? row[columnIndex] : string.Empty) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => $"{TextBeforeImportedLine}{value}{TextAfterImportedLine}"); - - if (RemoveDuplicates) - values = values.Distinct(StringComparer.Ordinal); - - return [.. values]; - } - - private List CreateBaseHeaders(int columnCount, bool firstRowIsHeader) - { - if (firstRowIsHeader && _allRows.Count > 0) - { - SpreadsheetSourceRow headerRow = _allRows[0]; - List headers = new(columnCount); - for (int index = 0; index < columnCount; index++) - { - headers.Add(index < headerRow.Cells.Count && !string.IsNullOrWhiteSpace(headerRow.Cells[index]) - ? headerRow.Cells[index] - : $"Column {index + 1}"); - } - - return headers; - } - - return [.. Enumerable.Range(1, columnCount).Select(index => $"Column {index}")]; + return SpreadsheetImportPolicy.SelectValues( + _displayRows, SelectedColumnIndex, TextBeforeImportedLine, TextAfterImportedLine, RemoveDuplicates); } private void BuildDisplayRows() @@ -425,7 +394,7 @@ private void BuildDisplayRows() foreach (SpreadsheetSourceRow row in _dataRows) { - List currentRow = CreateNormalizedRow(row.Cells, _headers.Length); + List currentRow = SpreadsheetImportPolicy.NormalizeRow(row.Cells, _headers.Length); if (GenerateIdsToSpreadsheet && _idColumnIndex >= 0) { string existingId = GetCellValue(row.Cells, _idColumnIndex); @@ -469,15 +438,6 @@ private static void ReplaceItems(ObservableCollection collection, IEnumera collection.Add(item); } - private static List CreateNormalizedRow(IReadOnlyList values, int columnCount) - { - List row = new(columnCount); - for (int index = 0; index < columnCount; index++) - row.Add(index < values.Count ? values[index] : string.Empty); - - return row; - } - private string BuildIdGenerationDescription(int missingIdCount, string? generatedIdValidationMessage) { if (!GenerateIdsToSpreadsheet) @@ -570,35 +530,13 @@ private bool TryBuildGeneratedIdOptions(out GeneratedIdOptions options, out stri } private bool ShouldGenerateIdForRow(SpreadsheetSourceRow row) - { - for (int index = 0; index < row.Cells.Count; index++) - { - if (index == _idColumnIndex) - continue; - if (!string.IsNullOrWhiteSpace(row.Cells[index])) - return true; - } - - return false; - } + => SpreadsheetImportPolicy.ShouldGenerateId(row.Cells, _idColumnIndex); private static string GetCellValue(IReadOnlyList values, int index) - { - return index >= 0 && index < values.Count - ? values[index] - : string.Empty; - } + => SpreadsheetImportPolicy.GetCellValue(values, index); private static int FindIdColumnIndex(IReadOnlyList headers) - { - for (int index = 0; index < headers.Count; index++) - { - if (string.Equals(headers[index].Trim(), IdColumnName, StringComparison.OrdinalIgnoreCase)) - return index; - } - - return -1; - } + => SpreadsheetImportPolicy.FindIdColumnIndex(headers); private void ShowStatus(string message, string title, InfoBarSeverity severity) { @@ -616,20 +554,5 @@ private void ClearStatus() } private static HistoryItem CloneHistoryItem(HistoryItem source) - { - return new HistoryItem - { - CodesContent = source.CodesContent, - ContentKind = source.ContentKind, - MultiLineCodeModeOverride = source.MultiLineCodeModeOverride, - Foreground = source.Foreground, - Background = source.Background, - ErrorCorrection = source.ErrorCorrection, - LogoImagePath = source.LogoImagePath, - LogoEmoji = source.LogoEmoji, - LogoEmojiStyle = source.LogoEmojiStyle, - LogoSizePercentage = source.LogoSizePercentage, - LogoPaddingPixels = source.LogoPaddingPixels, - }; - } -} + => QrCodeDesignStateMapper.ToHistoryItem(QrCodeDesignStateMapper.FromHistoryItem(source)); +} \ No newline at end of file From ed7d2268279f41eefdbad350bc483f15b6d7e63f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 25 Oct 2026 13:43:38 -0500 Subject: [PATCH 2/5] Add headless regression suites and coverage gates Exercise the production Core and Windows libraries with MSTest and Microsoft.Testing.Platform, including isolated filesystem integration tests and structural rendering checks. Add platform-aware test scripts, TRX/Cobertura reporting, and ratcheting line and branch coverage thresholds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Simple QR Code Maker.slnx | 10 + global.json | 5 + scripts/Assert-Coverage.ps1 | 34 +++ scripts/Test.ps1 | 63 ++++ tests/Directory.Build.props | 11 + .../Helpers/CsvParserTests.cs | 93 ++++++ .../Helpers/EmailBuilderHelperTests.cs | 74 +++++ .../Helpers/GeneratedIdGeneratorTests.cs | 90 ++++++ .../Helpers/PrintLayoutHelperTests.cs | 186 ++++++++++++ .../Helpers/QrAlignmentPatternHelperTests.cs | 93 ++++++ .../Helpers/QrFrameTextResolverTests.cs | 70 +++++ .../Helpers/RedirectorWarningHelperTests.cs | 80 +++++ .../Helpers/StringHelpersTests.cs | 66 +++++ .../Helpers/UrlBuilderHelperTests.cs | 75 +++++ .../Helpers/VCardBuilderHelperTests.cs | 121 ++++++++ .../Helpers/WifiBuilderHelperTests.cs | 69 +++++ .../Infrastructure/FileServiceTests.cs | 76 +++++ .../Infrastructure/JsonTests.cs | 33 +++ .../Infrastructure/TemporaryDirectory.cs | 13 + .../Models/BuilderModelsTests.cs | 90 ++++++ .../Models/PrintModelsTests.cs | 112 +++++++ .../Models/QrFramePresetCatalogTests.cs | 35 +++ .../Simple QR Code Maker.Core.Tests.csproj | 9 + .../TestAssembly.cs | 3 + .../Workflows/AsyncBatchProcessorTests.cs | 88 ++++++ .../Workflows/DelimitedSpreadsheetTests.cs | 60 ++++ .../Workflows/QrDocumentHelperTests.cs | 49 +++ .../Workflows/SpreadsheetImportPolicyTests.cs | 99 +++++++ .../Imaging/ImageProcessingTests.cs | 152 ++++++++++ .../Models/DesignAndModelTests.cs | 234 +++++++++++++++ .../Persistence/AtomicStorageTests.cs | 78 +++++ .../Persistence/BrandStoreTests.cs | 75 +++++ .../Persistence/DecodingHistoryStoreTests.cs | 215 ++++++++++++++ .../DiskStorageIntegrationTests.cs | 117 ++++++++ .../Persistence/HistoryStoreTests.cs | 189 ++++++++++++ .../Persistence/PersistenceFakes.cs | 99 +++++++ .../Persistence/SettingsStoreTests.cs | 128 ++++++++ .../Rendering/QrCodeRendererTests.cs | 279 ++++++++++++++++++ .../Rendering/QrPdfRendererTests.cs | 225 ++++++++++++++ .../Rendering/SizeRecommendationTests.cs | 91 ++++++ .../Serialization/ModelSerializationTests.cs | 211 +++++++++++++ .../Services/BrandServiceTests.cs | 101 +++++++ .../Services/HistoryServiceTests.cs | 102 +++++++ .../Services/LocalSettingsServiceTests.cs | 95 ++++++ .../RedirectorWarningSettingsTests.cs | 47 +++ .../Simple QR Code Maker.Windows.Tests.csproj | 10 + .../TestAssembly.cs | 3 + .../Workflows/BackupArchiveTests.cs | 109 +++++++ .../Workflows/QrExportWriterTests.cs | 76 +++++ tests/coverage.config | 21 ++ tests/thresholds.json | 4 + 51 files changed, 4468 insertions(+) create mode 100644 global.json create mode 100644 scripts/Assert-Coverage.ps1 create mode 100644 scripts/Test.ps1 create mode 100644 tests/Directory.Build.props create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/CsvParserTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/EmailBuilderHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/GeneratedIdGeneratorTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/PrintLayoutHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/QrAlignmentPatternHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/QrFrameTextResolverTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/RedirectorWarningHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/StringHelpersTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/UrlBuilderHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/VCardBuilderHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Helpers/WifiBuilderHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Infrastructure/FileServiceTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Infrastructure/JsonTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Infrastructure/TemporaryDirectory.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Models/BuilderModelsTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Models/PrintModelsTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Models/QrFramePresetCatalogTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Simple QR Code Maker.Core.Tests.csproj create mode 100644 tests/Simple QR Code Maker.Core.Tests/TestAssembly.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Workflows/AsyncBatchProcessorTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Workflows/DelimitedSpreadsheetTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Workflows/QrDocumentHelperTests.cs create mode 100644 tests/Simple QR Code Maker.Core.Tests/Workflows/SpreadsheetImportPolicyTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Imaging/ImageProcessingTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Models/DesignAndModelTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Persistence/AtomicStorageTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Persistence/BrandStoreTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Persistence/DiskStorageIntegrationTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Persistence/HistoryStoreTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Persistence/PersistenceFakes.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Persistence/SettingsStoreTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Rendering/QrCodeRendererTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Rendering/QrPdfRendererTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Rendering/SizeRecommendationTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Serialization/ModelSerializationTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Services/BrandServiceTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Services/HistoryServiceTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Services/LocalSettingsServiceTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Services/RedirectorWarningSettingsTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Simple QR Code Maker.Windows.Tests.csproj create mode 100644 tests/Simple QR Code Maker.Windows.Tests/TestAssembly.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Workflows/BackupArchiveTests.cs create mode 100644 tests/Simple QR Code Maker.Windows.Tests/Workflows/QrExportWriterTests.cs create mode 100644 tests/coverage.config create mode 100644 tests/thresholds.json diff --git a/Simple QR Code Maker.slnx b/Simple QR Code Maker.slnx index 4577211..2bdf94e 100644 --- a/Simple QR Code Maker.slnx +++ b/Simple QR Code Maker.slnx @@ -3,6 +3,16 @@ + + + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/scripts/Assert-Coverage.ps1 b/scripts/Assert-Coverage.ps1 new file mode 100644 index 0000000..a2dba31 --- /dev/null +++ b/scripts/Assert-Coverage.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $ReportPath, + [Parameter(Mandatory)] + [ValidateSet('Core', 'Windows')] + [string] $Component +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = Split-Path $PSScriptRoot -Parent +$thresholds = Get-Content -Raw (Join-Path $root 'tests\thresholds.json') | ConvertFrom-Json +$minimum = $thresholds.$Component +[xml] $report = Get-Content -Raw $ReportPath +$packages = @($report.coverage.packages.package | Where-Object { $_.name -eq "Simple QR Code Maker.$Component" }) +if ($packages.Count -ne 1) { + throw "Expected exactly one production coverage package for $Component in $ReportPath." +} + +$failed = $false +foreach ($metric in @('line', 'branch')) { + $percentage = 100 * [double]::Parse($packages[0].GetAttribute("$metric-rate"), [cultureinfo]::InvariantCulture) + if (-not [double]::IsFinite($percentage) -or $percentage -lt 0 -or $percentage -gt 100) { + throw "Invalid $metric coverage in $ReportPath." + } + '{0} {1} coverage: {2:F2}% (minimum {3}%)' -f $Component, $metric, $percentage, $minimum.$metric + if ($percentage -lt $minimum.$metric) { + $failed = $true + } +} +if ($failed) { + throw "$Component coverage is below the committed thresholds in tests\thresholds.json." +} diff --git a/scripts/Test.ps1 b/scripts/Test.ps1 new file mode 100644 index 0000000..4adc8c4 --- /dev/null +++ b/scripts/Test.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [ValidateSet('x64', 'ARM64')] + [string] $Platform = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString(), + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release', + [ValidateSet('All', 'Core', 'Windows')] + [string] $Suite = 'All', + [switch] $Coverage +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = Split-Path $PSScriptRoot -Parent +$suites = if ($Suite -eq 'All') { + if ($IsWindows) { @('Core', 'Windows') } else { @('Core') } +} else { + @($Suite) +} +if ('Windows' -in $suites -and -not $IsWindows) { + throw 'The Windows suite requires Windows. Use -Suite Core on this platform.' +} + +$failed = $false +foreach ($name in $suites) { + $project = Join-Path $root "tests\Simple QR Code Maker.$name.Tests\Simple QR Code Maker.$name.Tests.csproj" + $results = Join-Path $root "TestResults\$name" + if (Test-Path $results) { + Get-ChildItem $results -File | + Where-Object { $_.Name -in @('results.trx', 'coverage.cobertura.xml') } | + Remove-Item + } + $arguments = @( + 'test', '--project', $project, + '--configuration', $Configuration, "-p:Platform=$Platform", + '--minimum-expected-tests', '1', + '--results-directory', $results, + '--report-trx', '--report-trx-filename', 'results.trx' + ) + if ($Coverage) { + $arguments += @( + '--coverage', '--coverage-output-format', 'cobertura', + '--coverage-output', (Join-Path $results 'coverage.cobertura.xml'), + '--coverage-settings', (Join-Path $root 'tests\coverage.config') + ) + } + + & dotnet @arguments + if ($LASTEXITCODE -ne 0) { + $failed = $true + } elseif ($Coverage) { + try { + & (Join-Path $PSScriptRoot 'Assert-Coverage.ps1') -Component $name -ReportPath (Join-Path $results 'coverage.cobertura.xml') + } catch { + Write-Error -Message $_.Exception.Message -ErrorAction Continue + $failed = $true + } + } +} + +if ($failed) { + throw 'One or more test suites or coverage gates failed. See TestResults for details.' +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..270991a --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,11 @@ + + + enable + enable + preview + x64;ARM64 + false + Default + true + + diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/CsvParserTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/CsvParserTests.cs new file mode 100644 index 0000000..413e7ed --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/CsvParserTests.cs @@ -0,0 +1,93 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class CsvParserTests +{ + [TestMethod] + [DataRow("")] + [DataRow(null)] + public void EmptyInputHasNoRows(string? text) + { + Assert.AreEqual(0, CsvParser.Parse(text!).Count); + } + + [TestMethod] + [DataRow("\r\n")] + [DataRow("\n")] + [DataRow("\r")] + public void ParsesRowSeparatorsWithoutPhantomTrailingRow(string newline) + { + List> rows = CsvParser.Parse("a,b" + newline + "c,d" + newline); + Assert.AreEqual(2, rows.Count); + CollectionAssert.AreEqual(new[] { "a", "b" }, rows[0]); + CollectionAssert.AreEqual(new[] { "c", "d" }, rows[1]); + } + + [TestMethod] + public void ParsesIndependentQuotedExample() + { + List> rows = CsvParser.Parse("name,note,empty\r\n\"雪,😊\",\"say \"\"hi\"\"\r\nnext\","); + Assert.AreEqual(2, rows.Count); + CollectionAssert.AreEqual(new[] { "name", "note", "empty" }, rows[0]); + CollectionAssert.AreEqual(new[] { "雪,😊", "say \"hi\"\r\nnext", "" }, rows[1]); + } + + [TestMethod] + public void SerializesIndependentEscapingExample() + { + IReadOnlyList[] rows = [new[] { "雪", "a,b", "a\"b", "a\rb\nc" }, new[] { "" }]; + Assert.AreEqual("雪,\"a,b\",\"a\"\"b\",\"a\rb\nc\"\r\n\"\"", CsvParser.Serialize(rows)); + } + + [TestMethod] + [DataRow(',')] + [DataRow(';')] + [DataRow('\t')] + public void RoundtripPreservesEmptyFieldsUnicodeQuotesAndEmbeddedNewlines(char delimiter) + { + IReadOnlyList[] expected = + [ + new[] { "", "日本語😊", "comma,semi;tab\t", "\"quoted\"", "CR\rLF\nCRLF\r\n" }, + new[] { "" }, + new[] { "", "" }, + ]; + List> actual = CsvParser.Parse(CsvParser.Serialize(expected, delimiter), delimiter); + Assert.AreEqual(expected.Length, actual.Count); + for (int index = 0; index < expected.Length; index++) + CollectionAssert.AreEqual(expected[index].ToArray(), actual[index]); + } + + [TestMethod] + public void PreservesQuotedEmptyRowAndIntentionalBlankRows() + { + Assert.AreEqual(1, CsvParser.Parse("\"\"").Count); + Assert.AreEqual(2, CsvParser.Parse("\r\n\r\n").Count); + CollectionAssert.AreEqual(new[] { "", "", "" }, CsvParser.Parse(",,")[0]); + Assert.AreEqual(string.Empty, CsvParser.Serialize([])); + } + + [TestMethod] + public void UnterminatedQuotedFieldReturnsAvailableData() + { + CollectionAssert.AreEqual(new[] { "a", "unfinished\ntext" }, CsvParser.Parse("a,\"unfinished\ntext")[0]); + } + + [TestMethod] + [DataRow('"')] + [DataRow('\r')] + [DataRow('\n')] + public void RejectsAmbiguousDelimiters(char delimiter) + { + Assert.ThrowsExactly(() => CsvParser.Parse("a", delimiter)); + Assert.ThrowsExactly(() => CsvParser.Serialize([], delimiter)); + } + + [TestMethod] + public void RejectsNullRows() + { + Assert.ThrowsExactly(() => CsvParser.Serialize(null!)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/EmailBuilderHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/EmailBuilderHelperTests.cs new file mode 100644 index 0000000..ba9488c --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/EmailBuilderHelperTests.cs @@ -0,0 +1,74 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class EmailBuilderHelperTests +{ + [TestMethod] + public void SerializesIndependentMailtoExample() + { + EmailBuilderState state = new() + { + To = " ada@example.com ", + Cc = "cc@example.com", + Bcc = "hidden@example.com", + Subject = "Hello & 雪", + Body = "line 1\r\nline 2?=😊 ", + }; + Assert.AreEqual("mailto:ada@example.com?cc=cc%40example.com&bcc=hidden%40example.com&subject=Hello%20%26%20%E9%9B%AA&body=line%201%0D%0Aline%202%3F%3D%F0%9F%98%8A%20", EmailBuilderHelper.Serialize(state)); + } + + [TestMethod] + public void ParsesIndependentCaseInsensitiveEncodedHeaders() + { + Assert.IsTrue(EmailBuilderHelper.TryParse(" MAILTO:ada%40example.com?SUBJECT=A%2BB&body=first%0Asecond&CC=c%40test.com&ignored=x ", out EmailBuilderState state)); + Assert.AreEqual("ada@example.com", state.To); + Assert.AreEqual("A+B", state.Subject); + Assert.AreEqual("first\nsecond", state.Body); + Assert.AreEqual("c@test.com", state.Cc); + } + + [TestMethod] + public void RoundtripEscapesRecipientQueryDelimitersAndPreservesBodyWhitespace() + { + EmailBuilderState expected = new() + { + To = "tag?x#雪@example.com,other@example.com", + Subject = "雪?&=+", + Body = " \r\nline\n ", + }; + string canonical = EmailBuilderHelper.Serialize(expected); + Assert.StartsWith("mailto:tag%3Fx%23%E9%9B%AA@example.com,other@example.com?", canonical); + Assert.IsTrue(EmailBuilderHelper.TryParse(canonical, out EmailBuilderState actual)); + Assert.AreEqual(expected.To, actual.To); + Assert.AreEqual(expected.Subject, actual.Subject); + Assert.AreEqual(expected.Body, actual.Body); + Assert.AreEqual(canonical, EmailBuilderHelper.Serialize(actual)); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("ada@example.com")] + [DataRow("mailto:")] + [DataRow("mailto:?subject=hello")] + public void RejectsMissingMailtoRecipient(string? text) + { + Assert.IsFalse(EmailBuilderHelper.IsEmail(text)); + Assert.AreEqual("Email", EmailBuilderHelper.GetDisplayName(text)); + } + + [TestMethod] + public void HandlesMalformedQueryAndDisplayNameFallbacks() + { + Assert.IsTrue(EmailBuilderHelper.TryParse("mailto:a@test.com?bad&=ignored&subject=%zz&body=a=b", out EmailBuilderState state)); + Assert.AreEqual("%zz", state.Subject); + Assert.AreEqual("a=b", state.Body); + Assert.AreEqual("%zz", EmailBuilderHelper.GetDisplayName(state)); + Assert.AreEqual("a@test.com", EmailBuilderHelper.GetDisplayName(new EmailBuilderState { To = " a@test.com " })); + Assert.AreEqual(string.Empty, EmailBuilderHelper.Serialize(new EmailBuilderState())); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/GeneratedIdGeneratorTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/GeneratedIdGeneratorTests.cs new file mode 100644 index 0000000..a113aa0 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/GeneratedIdGeneratorTests.cs @@ -0,0 +1,90 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class GeneratedIdGeneratorTests +{ + [TestMethod] + [DataRow(SpreadsheetGeneratedIdFormat.Guid, "D", 36)] + [DataRow(SpreadsheetGeneratedIdFormat.GuidWithoutDashes, "N", 32)] + public void CreatesGuidWithRequestedRepresentationAndAffixes(SpreadsheetGeneratedIdFormat format, string specifier, int length) + { + string value = GeneratedIdGenerator.Create(new GeneratedIdOptions { Format = format, Prefix = "雪[", Suffix = "]😊" }); + Assert.StartsWith("雪[", value); + Assert.EndsWith("]😊", value); + string core = value[2..^3]; + Assert.AreEqual(length, core.Length); + Assert.IsTrue(Guid.TryParseExact(core, specifier, out Guid parsed)); + Assert.AreNotEqual(Guid.Empty, parsed); + } + + [TestMethod] + [DataRow(1)] + [DataRow(12)] + [DataRow(128)] + public void NanoIdUsesRequestedLengthAndDocumentedAlphabet(int length) + { + string value = GeneratedIdGenerator.Create(new GeneratedIdOptions { Format = SpreadsheetGeneratedIdFormat.NanoId, NanoIdLength = length }); + Assert.AreEqual(length, value.Length); + Assert.IsTrue(value.All(static character => character is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z')); + } + + [TestMethod] + public void CreatesRequestedCountWithoutDependingOnRandomValues() + { + IReadOnlyList values = GeneratedIdGenerator.CreateMany(3, new GeneratedIdOptions { Prefix = "id-" }); + Assert.AreEqual(3, values.Count); + Assert.IsTrue(values.All(static value => value.Length == 39 && Guid.TryParseExact(value[3..], "D", out _))); + } + + [TestMethod] + [DataRow(0)] + [DataRow(-1)] + public void RejectsNonpositiveCountAndNanoIdLength(int value) + { + Assert.ThrowsExactly(() => GeneratedIdGenerator.CreateMany(value, new GeneratedIdOptions())); + Assert.ThrowsExactly(() => GeneratedIdGenerator.Create(new GeneratedIdOptions + { + Format = SpreadsheetGeneratedIdFormat.NanoId, + NanoIdLength = value, + })); + } + + [TestMethod] + public void RejectsMissingOptionsAndUnknownFormat() + { + Assert.ThrowsExactly(() => GeneratedIdGenerator.Create(null!)); + Assert.ThrowsExactly(() => GeneratedIdGenerator.CreateMany(1, null!)); + Assert.ThrowsExactly(() => GeneratedIdGenerator.Create(new GeneratedIdOptions + { + Format = (SpreadsheetGeneratedIdFormat)999, + })); + } + + [TestMethod] + [DataRow(1.0, 1)] + [DataRow(123.0, 123)] + [DataRow(2147483647.0, int.MaxValue)] + public void AcceptsRepresentablePositiveWholeNumbers(double value, int expected) + { + Assert.IsTrue(GeneratedIdGenerator.TryGetWholeNumber(value, out int actual)); + Assert.AreEqual(expected, actual); + } + + [TestMethod] + [DataRow(0.0)] + [DataRow(-1.0)] + [DataRow(1.5)] + [DataRow(2147483648.0)] + [DataRow(double.NaN)] + [DataRow(double.PositiveInfinity)] + [DataRow(double.NegativeInfinity)] + public void RejectsUnrepresentableWholeNumbersAndClearsResult(double value) + { + Assert.IsFalse(GeneratedIdGenerator.TryGetWholeNumber(value, out int actual)); + Assert.AreEqual(0, actual); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/PrintLayoutHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/PrintLayoutHelperTests.cs new file mode 100644 index 0000000..a393c38 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/PrintLayoutHelperTests.cs @@ -0,0 +1,186 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class PrintLayoutHelperTests +{ + [TestMethod] + [DataRow(0.0)] + [DataRow(1.0)] + [DataRow(25.4)] + [DataRow(250.0)] + public void UnitConversionPreservesPrecision(double millimeters) + { + Assert.AreEqual(millimeters * 72 / 25.4, PrintLayoutHelper.MillimetersToPoints(millimeters), 1e-10); + Assert.AreEqual(millimeters, PrintLayoutHelper.PointsToMillimeters(PrintLayoutHelper.MillimetersToPoints(millimeters)), 1e-10); + } + + [TestMethod] + [DataRow(PrintPageType.Letter, 612.0, 792.0)] + [DataRow(PrintPageType.Legal, 612.0, 1008.0)] + [DataRow(PrintPageType.Statement, 396.0, 612.0)] + [DataRow(PrintPageType.Executive, 540.0, 720.0)] + [DataRow(PrintPageType.Tabloid, 792.0, 1224.0)] + [DataRow(PrintPageType.Ledger, 1224.0, 792.0)] + [DataRow(PrintPageType.A3, 842.0, 1191.0)] + [DataRow(PrintPageType.A4, 595.0, 842.0)] + [DataRow(PrintPageType.A5, 420.0, 595.0)] + [DataRow(PrintPageType.B4, 709.0, 1001.0)] + [DataRow(PrintPageType.B5, 499.0, 709.0)] + public void UsesKnownPageDimensionsAndSwapsForLandscape(PrintPageType type, double width, double height) + { + Assert.AreEqual((width, height), PrintLayoutHelper.GetPageDimensions(type, PrintPageLayout.Portrait)); + Assert.AreEqual((height, width), PrintLayoutHelper.GetPageDimensions(type, PrintPageLayout.Landscape)); + } + + [TestMethod] + [DataRow(1, 1, 1)] + [DataRow(2, 2, 1)] + [DataRow(4, 2, 2)] + [DataRow(6, 3, 2)] + [DataRow(9, 3, 3)] + [DataRow(12, 4, 3)] + [DataRow(16, 4, 4)] + public void FixedGridUsesRequestedCountAndOrientation(int count, int rows, int columns) + { + PrintJobSettings settings = new() { CodesPerPage = count, FitAsManyAsPossible = false, PageType = PrintPageType.Letter }; + PrintLayoutMetrics portrait = PrintLayoutHelper.CreateMetrics(settings); + PrintLayoutMetrics landscape = PrintLayoutHelper.CreateMetrics(settings with { PageLayout = PrintPageLayout.Landscape }); + Assert.AreEqual(rows, portrait.Rows); + Assert.AreEqual(columns, portrait.Columns); + Assert.AreEqual(count, portrait.CodesPerPage); + Assert.AreEqual(columns, landscape.Rows); + Assert.AreEqual(rows, landscape.Columns); + Assert.AreEqual(count, landscape.CodesPerPage); + } + + [TestMethod] + public void FixedGridSubtractsMarginsAndIntercellSpacingExactly() + { + PrintLayoutMetrics metrics = PrintLayoutHelper.CreateMetrics(600, 800, new PrintJobSettings + { + FitAsManyAsPossible = false, + CodesPerPage = 4, + MarginMm = 25.4, + SpacingMm = 12.7, + CodeSizeMm = 25.4, + ShowLabels = false, + }); + Assert.AreEqual(72, metrics.MarginPoints, 1e-10); + Assert.AreEqual(36, metrics.SpacingPoints, 1e-10); + Assert.AreEqual(210, metrics.CellWidth, 1e-10); + Assert.AreEqual(310, metrics.CellHeight, 1e-10); + Assert.AreEqual(72, metrics.ActualCodeSizePoints, 1e-10); + Assert.AreEqual(600, metrics.PageWidthPoints, 1e-10); + Assert.AreEqual(800, metrics.PageHeightPoints, 1e-10); + } + + [TestMethod] + public void AutoFitUsesRequestedImageSizePaddingAndLabelHeight() + { + PrintJobSettings settings = new() { MarginMm = 0, SpacingMm = 0, CodeSizeMm = 25.4, ShowLabels = false }; + PrintLayoutMetrics square = PrintLayoutHelper.CreateMetrics(612, 792, settings); + Assert.AreEqual(6, square.Columns); + Assert.AreEqual(8, square.Rows); + Assert.AreEqual(48, square.CodesPerPage); + Assert.AreEqual(102, square.CellWidth, 1e-10); + Assert.AreEqual(99, square.CellHeight, 1e-10); + Assert.AreEqual(72, square.ActualCodeSizePoints, 1e-10); + PrintLayoutMetrics labeled = PrintLayoutHelper.CreateMetrics(612, 792, settings with { ShowLabels = true }); + Assert.AreEqual(6, labeled.Columns); + Assert.AreEqual(6, labeled.Rows); + PrintLayoutMetrics framed = PrintLayoutHelper.CreateMetrics(612, 792, settings, QrImageLayoutMetrics.FromScaleProfile(2, 3)); + Assert.AreEqual(3, framed.Columns); + Assert.AreEqual(3, framed.Rows); + } + + [TestMethod] + public void PlacementPreservesFrameAspectRatioAndReservesLabels() + { + PrintJobSettings settings = new() { CodeSizeMm = 25.4, ShowLabels = false }; + QrImageLayoutMetrics image = QrImageLayoutMetrics.FromScaleProfile(2, 3); + PrintCodePlacement placement = PrintLayoutHelper.CalculateCodePlacement(200, 200, settings, image); + Assert.AreEqual(176, placement.ImageHeightPoints, 1e-10); + Assert.AreEqual(176.0 / 3, placement.ActualCodeSizePoints, 1e-10); + Assert.AreEqual(176.0 * 2 / 3, placement.ImageWidthPoints, 1e-10); + PrintCodePlacement labeled = PrintLayoutHelper.CalculateCodePlacement(200, 200, settings with { ShowLabels = true }, image); + Assert.AreEqual(146, labeled.ImageHeightPoints, 1e-10); + Assert.AreEqual(146.0 / 3, labeled.ActualCodeSizePoints, 1e-10); + Assert.AreEqual(72, PrintLayoutHelper.CalculateActualCodeSizePoints(1000, 1000, settings), 1e-10); + Assert.AreEqual(placement.ActualCodeSizePoints, PrintLayoutHelper.CalculateActualCodeSizePoints(200, 200, settings, image), 1e-10); + } + + [TestMethod] + public void TinyPagesAndInvalidProfilesStillHaveFinitePositiveGeometry() + { + PrintJobSettings settings = new() { MarginMm = 50, CodeSizeMm = 250, SpacingMm = 25 }; + PrintLayoutMetrics metrics = PrintLayoutHelper.CreateMetrics(1, 1, settings, new QrImageLayoutMetrics(double.NaN, double.PositiveInfinity, 0, 0)); + Assert.AreEqual(1, metrics.Rows); + Assert.AreEqual(1, metrics.Columns); + Assert.IsTrue(double.IsFinite(metrics.ActualCodeSizePoints) && metrics.ActualCodeSizePoints >= 1); + PrintCodePlacement placement = PrintLayoutHelper.CalculateCodePlacement(-10, 0, settings, QrImageLayoutMetrics.Square); + Assert.AreEqual(1, placement.ImageWidthPoints, 1e-10); + Assert.AreEqual(1, placement.ImageHeightPoints, 1e-10); + } + + [TestMethod] + public void ExtremeFiniteFrameRatioDoesNotOverflowIntoNanPlacement() + { + PrintCodePlacement placement = PrintLayoutHelper.CalculateCodePlacement(200, 200, + new PrintJobSettings { ShowLabels = false }, QrImageLayoutMetrics.FromScaleProfile(double.MaxValue, 1)); + Assert.AreEqual(176, placement.ImageWidthPoints, 1e-10); + Assert.AreEqual(1, placement.ImageHeightPoints, 1e-10); + Assert.AreEqual(1, placement.ActualCodeSizePoints, 1e-10); + } + + [TestMethod] + public void FixedGridGeometryReconstructsPageAndNeverUpscalesRequestedCode() + { + foreach (int count in PrintJobSettings.SupportedCodesPerPage) + { + foreach (PrintPageLayout orientation in Enum.GetValues()) + { + PrintJobSettings settings = new() + { + CodesPerPage = count, + FitAsManyAsPossible = false, + PageType = PrintPageType.A4, + PageLayout = orientation, + MarginMm = 10, + SpacingMm = 4, + }; + PrintLayoutMetrics metrics = PrintLayoutHelper.CreateMetrics(settings); + Assert.AreEqual(metrics.PageWidthPoints, metrics.CellWidth * metrics.Columns + metrics.SpacingPoints * (metrics.Columns - 1) + metrics.MarginPoints * 2, 1e-9); + Assert.AreEqual(metrics.PageHeightPoints, metrics.CellHeight * metrics.Rows + metrics.SpacingPoints * (metrics.Rows - 1) + metrics.MarginPoints * 2, 1e-9); + Assert.IsTrue(metrics.ActualCodeSizePoints > 0 && metrics.ActualCodeSizePoints <= metrics.RequestedCodeSizePoints); + Assert.IsTrue(metrics.ActualCodeSizePoints <= metrics.CellWidth - 24); + Assert.IsTrue(metrics.ActualCodeSizePoints <= metrics.CellHeight - 54); + } + } + } + + [TestMethod] + [DataRow(0.0)] + [DataRow(-1.0)] + [DataRow(double.NaN)] + [DataRow(double.PositiveInfinity)] + [DataRow(double.NegativeInfinity)] + public void RejectsNonphysicalPageDimensions(double value) + { + Assert.ThrowsExactly(() => PrintLayoutHelper.CreateMetrics(value, 792, new PrintJobSettings())); + Assert.ThrowsExactly(() => PrintLayoutHelper.CreateMetrics(612, value, new PrintJobSettings())); + } + + [TestMethod] + [DataRow(double.NaN)] + [DataRow(double.PositiveInfinity)] + [DataRow(double.NegativeInfinity)] + public void RejectsNonfiniteCellDimensions(double value) + { + Assert.ThrowsExactly(() => PrintLayoutHelper.CalculateActualCodeSizePoints(value, 200, new PrintJobSettings())); + Assert.ThrowsExactly(() => PrintLayoutHelper.CalculateActualCodeSizePoints(200, value, new PrintJobSettings())); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/QrAlignmentPatternHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/QrAlignmentPatternHelperTests.cs new file mode 100644 index 0000000..4f15d0c --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/QrAlignmentPatternHelperTests.cs @@ -0,0 +1,93 @@ +using System.Drawing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class QrAlignmentPatternHelperTests +{ + [TestMethod] + [DataRow(1, 21)] + [DataRow(7, 45)] + [DataRow(40, 177)] + public void VersionDeterminesModuleDimension(int version, int expected) + { + Assert.AreEqual(expected, QrAlignmentPatternHelper.GetDimension(version)); + } + + [TestMethod] + public void MatchesIndependentSmallVersionCenters() + { + Assert.AreEqual(0, QrAlignmentPatternHelper.GetAlignmentPatternCenters(1).Count); + CollectionAssert.AreEqual(new[] { (18, 18) }, QrAlignmentPatternHelper.GetAlignmentPatternCenters(2)); + CollectionAssert.AreEqual(new[] { (6, 22), (22, 6), (22, 22), (22, 38), (38, 22), (38, 38) }, + QrAlignmentPatternHelper.GetAlignmentPatternCenters(7)); + } + + [TestMethod] + [DataRow(36, "6,24,50,76,102,128,154")] + [DataRow(37, "6,28,54,80,106,132,158")] + [DataRow(38, "6,32,58,84,110,136,162")] + [DataRow(39, "6,26,54,82,110,138,166")] + [DataRow(40, "6,30,58,86,114,142,170")] + public void HighVersionCoordinatesMatchIsoAlignmentPositions(int version, string expectedPositions) + { + int[] expected = expectedPositions.Split(',').Select(int.Parse).ToArray(); + int[] actual = QrAlignmentPatternHelper.GetAlignmentPatternCenters(version).Select(static point => point.Row).Distinct().Order().ToArray(); + CollectionAssert.AreEqual(expected, actual); + } + + [TestMethod] + public void AllVersionsKeepCentersInBoundsAndOutsideFinderZones() + { + for (int version = 2; version <= 40; version++) + { + int dimension = QrAlignmentPatternHelper.GetDimension(version); + List<(int Row, int Col)> centers = QrAlignmentPatternHelper.GetAlignmentPatternCenters(version); + Assert.AreEqual(centers.Count, centers.Distinct().Count()); + foreach ((int row, int column) in centers) + { + Assert.IsTrue(row >= 2 && row < dimension - 2 && column >= 2 && column < dimension - 2, $"Version {version}: ({row}, {column})"); + Assert.IsFalse(row <= 8 && column <= 8); + Assert.IsFalse(row <= 8 && column >= dimension - 8); + Assert.IsFalse(row >= dimension - 8 && column <= 8); + } + } + } + + [TestMethod] + public void PixelCoordinatesUseModuleCentersAndCorrectAxes() + { + (double x, double y) = QrAlignmentPatternHelper.GetIdealPixelCoord(1, 2, 1, 210); + Assert.AreEqual(25, x, 1e-12); + Assert.AreEqual(15, y, 1e-12); + } + + [TestMethod] + public void BilinearInterpolationUsesAllFourCorners() + { + PointF center = QrAlignmentPatternHelper.EstimateImagePosition(new(0, 0), new(200, 0), new(0, 100), new(300, 200), 10, 10, 1); + Assert.AreEqual(125f, center.X, 1e-5f); + Assert.AreEqual(75f, center.Y, 1e-5f); + } + + [TestMethod] + [DataRow(0)] + [DataRow(-1)] + [DataRow(41)] + public void InvalidVersionsAreRejectedByGeometryAndHaveNoCenters(int version) + { + Assert.AreEqual(0, QrAlignmentPatternHelper.GetAlignmentPatternCenters(version).Count); + Assert.ThrowsExactly(() => QrAlignmentPatternHelper.GetDimension(version)); + } + + [TestMethod] + public void RejectsInvalidModulePositionsAndOutputSize() + { + Assert.ThrowsExactly(() => QrAlignmentPatternHelper.GetIdealPixelCoord(-1, 0, 1, 210)); + Assert.ThrowsExactly(() => QrAlignmentPatternHelper.GetIdealPixelCoord(0, 21, 1, 210)); + Assert.ThrowsExactly(() => QrAlignmentPatternHelper.GetIdealPixelCoord(0, 0, 1, 0)); + Assert.ThrowsExactly(() => QrAlignmentPatternHelper.EstimateImagePosition(default, default, default, default, 21, 0, 1)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/QrFrameTextResolverTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/QrFrameTextResolverTests.cs new file mode 100644 index 0000000..b832d40 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/QrFrameTextResolverTests.cs @@ -0,0 +1,70 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class QrFrameTextResolverTests +{ + [TestMethod] + [DataRow(QrFramePreset.None, QrFrameTextSource.ContentSummary)] + [DataRow(QrFramePreset.BottomLabel, QrFrameTextSource.Manual)] + public void ManualOrDisabledFrameReturnsManualTextUnmodified(QrFramePreset preset, QrFrameTextSource source) + { + Assert.AreEqual(" manual \n text ", QrFrameTextResolver.Resolve(preset, source, " manual \n text ", "summary", QrContentKind.PlainText)); + } + + [TestMethod] + [DataRow("https://www.example.com/a%20b?q=private#anchor", "example.com/a b")] + [DataRow("https://example.com:8443/a", "example.com:8443/a")] + [DataRow("https://example.com:443/", "example.com")] + [DataRow("https://example.com/this/path/is/too/long/to/fit/into/the/summary", "example.com")] + [DataRow(" first \t line \r\nsecond line ", "first line")] + [DataRow("\n\r\n雪 😊\rnext", "雪 😊")] + public void ResolvesIndependentUriAndPlainTextSummaries(string input, string expected) + { + Assert.AreEqual(expected, Resolve(input)); + } + + [TestMethod] + [DataRow(QrContentKind.VCard, "BEGIN:VCARD\nVERSION:3.0\nFN:Ada Lovelace\nEND:VCARD", "Ada Lovelace")] + [DataRow(QrContentKind.WiFi, "WIFI:S:Cafe;T:nopass;;", "Cafe")] + [DataRow(QrContentKind.Email, "mailto:a@test.com?subject=Hello%20World", "Hello World")] + [DataRow(QrContentKind.Email, "mailto:a@test.com", "a@test.com")] + public void UsesStructuredContentDisplayName(QrContentKind kind, string text, string expected) + { + Assert.AreEqual(expected, Resolve(text, kind)); + } + + [TestMethod] + [DataRow(QrContentKind.PlainText, null)] + [DataRow(QrContentKind.PlainText, " \r\n\t")] + [DataRow(QrContentKind.VCard, "broken")] + [DataRow(QrContentKind.WiFi, "broken")] + [DataRow(QrContentKind.Email, "broken")] + public void EmptyOrInvalidSummaryFallsBackToManual(QrContentKind kind, string? input) + { + Assert.AreEqual("fallback", Resolve(input, kind)); + } + + [TestMethod] + public void TruncatesAtFortyFourCharactersWithoutSplittingUnicode() + { + Assert.AreEqual(new string('a', 44), Resolve(new string('a', 44))); + Assert.AreEqual(new string('a', 41) + "...", Resolve(new string('a', 45))); + Assert.AreEqual(new string('a', 40) + "...", Resolve(new string('a', 40) + "😊" + new string('b', 10))); + } + + [TestMethod] + public void MultilineModeStillUsesFirstNonemptyLine() + { + Assert.AreEqual("first line", QrFrameTextResolver.Resolve(QrFramePreset.RoundedFrame, QrFrameTextSource.ContentSummary, + null, "\nfirst \t line\nsecond", QrContentKind.PlainText, MultiLineCodeMode.MultilineOneCode)); + } + + private static string? Resolve(string? text, QrContentKind kind = QrContentKind.PlainText) + { + return QrFrameTextResolver.Resolve(QrFramePreset.BottomLabel, QrFrameTextSource.ContentSummary, "fallback", text, kind); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/RedirectorWarningHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/RedirectorWarningHelperTests.cs new file mode 100644 index 0000000..e0b1dfe --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/RedirectorWarningHelperTests.cs @@ -0,0 +1,80 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class RedirectorWarningHelperTests +{ + [TestMethod] + [DataRow("https://bit.ly/code", "bit.ly")] + [DataRow("https://WWW.BIT.LY./code", "bit.ly")] + [DataRow("https://sub.qrco.de/code", "sub.qrco.de")] + [DataRow("https://tinyurl.com/a", "tinyurl.com")] + public void WarnsForKnownHostsAndTheirSubdomains(string text, string expectedHost) + { + RedirectorUrlClassification result = RedirectorWarningHelper.Classify(text); + Assert.IsTrue(result.IsAbsoluteUri); + Assert.IsTrue(result.IsKnownRedirector); + Assert.IsFalse(result.IsSafeDomain); + Assert.IsTrue(result.ShouldWarn); + Assert.AreEqual(expectedHost, result.Host); + } + + [TestMethod] + [DataRow("https://notbit.ly/code")] + [DataRow("https://bit.ly.evil.example/code")] + [DataRow("https://bit.ly@evil.example/code")] + [DataRow("https://example.com/?next=https://bit.ly/code")] + [DataRow("https://example.com/#bit.ly")] + [DataRow("https://tinyurl.com-evil.example/code")] + public void DeceptiveNamesAndEmbeddedUrlsAreNotKnownRedirectorHosts(string text) + { + RedirectorUrlClassification result = RedirectorWarningHelper.Classify(text, ["example.com"]); + Assert.IsTrue(result.IsAbsoluteUri); + Assert.IsFalse(result.IsKnownRedirector); + Assert.IsFalse(result.IsSafeDomain); + Assert.IsFalse(result.ShouldWarn); + } + + [TestMethod] + public void AllowlistMatchesOnlyWholeHostOrSubdomain() + { + Assert.IsFalse(RedirectorWarningHelper.Classify("https://sub.bit.ly/code", [" WWW.BIT.LY. "]).ShouldWarn); + Assert.IsTrue(RedirectorWarningHelper.Classify("https://bit.ly/code", ["it.ly"]).ShouldWarn); + Assert.IsTrue(RedirectorWarningHelper.Classify("https://bit.ly/code", ["bit.ly.evil.example"]).ShouldWarn); + Assert.IsTrue(RedirectorWarningHelper.MatchesHostOrSubdomain("child.example.com", ["example.com"])); + Assert.IsFalse(RedirectorWarningHelper.MatchesHostOrSubdomain("badexample.com", ["example.com"])); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" \t ")] + [DataRow("example.com/path")] + [DataRow("https://")] + public void InvalidOrRelativeInputHasNoClassification(string? text) + { + Assert.AreEqual(RedirectorUrlClassification.None, RedirectorWarningHelper.Classify(text)); + } + + [TestMethod] + public void HostlessAbsoluteUrisDoNotWarn() + { + RedirectorUrlClassification result = RedirectorWarningHelper.Classify("urn:example:resource"); + Assert.IsTrue(result.IsAbsoluteUri); + Assert.AreEqual(string.Empty, result.Host); + Assert.IsFalse(result.ShouldWarn); + } + + [TestMethod] + public void NormalizesDeduplicatesAndSortsHostNames() + { + CollectionAssert.AreEqual(new[] { "a.test", "b.test" }, RedirectorWarningHelper.NormalizeHosts( + [" WWW.B.Test. ", "b.test", "", "A.TEST", " "]).ToArray()); + Assert.AreEqual(0, RedirectorWarningHelper.NormalizeHosts(null).Count); + Assert.AreEqual(string.Empty, RedirectorWarningHelper.NormalizeHost(null)); + Assert.AreEqual("sub.example.com", RedirectorWarningHelper.NormalizeHost(" Sub.Example.Com... ")); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/StringHelpersTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/StringHelpersTests.cs new file mode 100644 index 0000000..5e8f538 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/StringHelpersTests.cs @@ -0,0 +1,66 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class StringHelpersTests +{ + [TestMethod] + public void ReplacesReservedAndControlCharactersAndCollapsesDashes() + { + Assert.AreEqual("a-b-c-雪😊", "a /:*?\"<>|\\+,.=[]!@;\tb\r\nc-雪😊".ReplaceReservedCharacters()); + Assert.AreEqual("-A-B-C", "\0A\tB\r\nC".ReplaceReservedCharacters()); + } + + [TestMethod] + [DataRow("", "QR-Code")] + [DataRow(" ", "QR-Code")] + [DataRow("\0\r\n", "QR-Code")] + [DataRow(":", "QR-Code")] + [DataRow("hello?", "hello")] + [DataRow("hello-", "hello-")] + [DataRow("-", "-")] + [DataRow("雪😊", "雪😊")] + [DataRow("CON", "CON-QR")] + [DataRow("nul", "nul-QR")] + [DataRow("COM1", "COM1-QR")] + [DataRow("LPT9", "LPT9-QR")] + [DataRow("COM10", "COM10")] + public void ProducesUsableWindowsFilename(string input, string expected) + { + Assert.AreEqual(expected, input.ToSafeFileName()); + } + + [TestMethod] + public void TruncatesOnlyAboveSixtyCharactersKeepingBothEnds() + { + string sixty = new('a', 60); + Assert.AreEqual(sixty, sixty.ToSafeFileName()); + string longName = new string('a', 30) + "middle" + new string('z', 30); + Assert.AreEqual(new string('a', 28) + "..." + new string('z', 28), longName.ToSafeFileName()); + Assert.AreEqual(59, longName.ToSafeFileName().Length); + } + + [TestMethod] + public void TruncationDoesNotSplitUnicodeSurrogatePairs() + { + string input = new string('a', 27) + "😊" + new string('b', 10) + "😊" + new string('z', 27); + Assert.AreEqual(new string('a', 27) + "..." + new string('z', 27), input.ToSafeFileName()); + } + + [TestMethod] + public void UsesContentSpecificDisplayNames() + { + Assert.AreEqual("Ada-vCard", VCardBuilderHelper.Serialize(new VCardBuilderState { FormattedName = "Ada" }).ToSafeFileName()); + Assert.AreEqual("Cafe-WiFi", "WIFI:S:Cafe;T:nopass;;".ToSafeFileName()); + Assert.AreEqual("Hello-Email", "mailto:a@test.com?subject=Hello".ToSafeFileName()); + } + + [TestMethod] + public void NullFilenameInputIsRejectedExplicitly() + { + Assert.ThrowsExactly(() => StringHelpers.ToSafeFileName(null!)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/UrlBuilderHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/UrlBuilderHelperTests.cs new file mode 100644 index 0000000..68dfaa3 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/UrlBuilderHelperTests.cs @@ -0,0 +1,75 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class UrlBuilderHelperTests +{ + [TestMethod] + public void ParsesIndependentUriExample() + { + UrlBuilderLine line = UrlBuilderHelper.ParseLine(" https://example.com/a%20b/%E9%9B%AA?x=one%26two&flag#hello%20world "); + Assert.AreEqual("https", line.Scheme); + Assert.AreEqual("example.com", line.Authority); + Assert.AreEqual("/a b/雪", line.Path); + Assert.AreEqual("hello world", line.Fragment); + Assert.IsTrue(line.UsesAuthorityDelimiter); + Assert.AreEqual(2, line.QueryParameters.Count); + Assert.AreEqual("x", line.QueryParameters[0].Name); + Assert.AreEqual("one&two", line.QueryParameters[0].Value); + Assert.AreEqual(string.Empty, line.QueryParameters[1].Value); + } + + [TestMethod] + public void SerializesIndependentModelWithReservedAndUnicodeCharacters() + { + UrlBuilderLine line = new() + { + Scheme = " https: ", + Authority = "example.com", + Path = "a b/雪", + Fragment = "a#😊", + QueryParameters = [new("a&b", "x=y+z"), new("empty", ""), new("", "value"), new("", "")], + }; + Assert.AreEqual("https://example.com/a%20b/%E9%9B%AA?a%26b=x%3Dy%2Bz&empty=&=value#a%23%F0%9F%98%8A", UrlBuilderHelper.SerializeLine(line)); + } + + [TestMethod] + [DataRow("https://example.com/path?x=1&x=2#top", "https://example.com/path?x=1&x=2#top")] + [DataRow("//example.com/a", "//example.com/a")] + [DataRow("file:///folder/a%20b", "file:///folder/a%20b")] + [DataRow("///relative/path", "///relative/path")] + [DataRow("example.com/a", "example.com/a")] + [DataRow("/relative/a%20b", "/relative/a%20b")] + [DataRow("urn:example:path/child", "urn:example%3Apath/child")] + [DataRow("mailto:me@example.com", "mailto:me%40example.com")] + [DataRow("https://example.com/?flag&x=a=b&plus=a+b", "https://example.com/?flag=&x=a%3Db&plus=a%2Bb")] + [DataRow("https://example.com/%zz?x=%zz", "https://example.com/%25zz?x=%25zz")] + public void ProducesStableCanonicalRoundtrip(string input, string expected) + { + string actual = UrlBuilderHelper.SerializeLine(UrlBuilderHelper.ParseLine(input)); + Assert.AreEqual(expected, actual); + Assert.AreEqual(expected, UrlBuilderHelper.SerializeLine(UrlBuilderHelper.ParseLine(actual))); + } + + [TestMethod] + [DataRow("")] + [DataRow(" \r\n\t")] + [DataRow(null)] + public void EmptyTextProvidesOneEditableBlankLine(string? text) + { + IReadOnlyList lines = UrlBuilderHelper.ParseText(text); + Assert.AreEqual(1, lines.Count); + Assert.AreEqual(string.Empty, UrlBuilderHelper.SerializeText(lines)); + } + + [TestMethod] + public void SplitsAllNewlineFormsAndOmitsBlankLines() + { + IReadOnlyList lines = UrlBuilderHelper.ParseText("https://a.test\r\n \rhttps://b.test\nhttps://c.test\n"); + Assert.AreEqual(3, lines.Count); + Assert.AreEqual("https://a.test\r\nhttps://b.test\r\nhttps://c.test", UrlBuilderHelper.SerializeText(lines)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/VCardBuilderHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/VCardBuilderHelperTests.cs new file mode 100644 index 0000000..ffc2ab2 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/VCardBuilderHelperTests.cs @@ -0,0 +1,121 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class VCardBuilderHelperTests +{ + [TestMethod] + public void SerializesIndependentContactExample() + { + VCardBuilderState state = new() + { + FirstName = "Ada", + LastName = "Lovelace", + Organization = "Analytical, Engines", + MobilePhone = "+44 123", + Email = "ada@example.com", + StreetAddress = "1; Main Street", + Locality = "London", + Note = "first\rsecond\r\nthird\n雪", + }; + Assert.AreEqual( + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\nORG:Analytical\\, Engines\r\nTEL;TYPE=CELL:+44 123\r\nEMAIL;TYPE=INTERNET:ada@example.com\r\nADR;TYPE=HOME:;;1\\; Main Street;London;;;\r\nNOTE:first\\nsecond\\nthird\\n雪\r\nEND:VCARD", + VCardBuilderHelper.Serialize(state)); + } + + [TestMethod] + public void ParsesIndependentFoldedCaseInsensitiveContact() + { + const string text = "begin:vcard\nVERSION:3.0\nFN:Renée 雪\nN:Last;First;Middle;Dr.;Jr.\nTEL;TYPE=WORK:111\nTEL;TYPE=HOME:222\nTEL;TYPE=CELL:333\nNOTE:Long\n continuation\n\tmore\\Nline\nend:vcard"; + Assert.IsTrue(VCardBuilderHelper.TryParse(text, out VCardBuilderState state)); + Assert.AreEqual("Renée 雪", state.FormattedName); + Assert.AreEqual("Last", state.LastName); + Assert.AreEqual("First", state.FirstName); + Assert.AreEqual("Middle", state.MiddleName); + Assert.AreEqual("Dr.", state.Prefix); + Assert.AreEqual("Jr.", state.Suffix); + Assert.AreEqual("111", state.WorkPhone); + Assert.AreEqual("222", state.HomePhone); + Assert.AreEqual("333", state.MobilePhone); + Assert.AreEqual("Longcontinuationmore\nline", state.Note); + } + + [TestMethod] + public void RoundtripPreservesEscapedStructuredSeparatorsAndLiteralBackslashes() + { + VCardBuilderState expected = new() + { + FirstName = @"First\name", + LastName = "Last;尾", + MiddleName = "Mid,😊", + Prefix = "Dr.", + Suffix = "Jr.", + StreetAddress = @"12; Path\name", + Locality = "Town;East", + Region = "Region,West", + PostalCode = "123", + Country = "日本", + Title = "Engineer", + Nickname = "A,B", + Birthday = "1815-12-10", + Url = "https://example.com", + Note = @"literal \n; comma, and slash\", + }; + string canonical = VCardBuilderHelper.Serialize(expected); + Assert.IsTrue(VCardBuilderHelper.TryParse(canonical, out VCardBuilderState actual)); + Assert.AreEqual(expected.FirstName, actual.FirstName); + Assert.AreEqual(expected.LastName, actual.LastName); + Assert.AreEqual(expected.MiddleName, actual.MiddleName); + Assert.AreEqual(expected.StreetAddress, actual.StreetAddress); + Assert.AreEqual(expected.Locality, actual.Locality); + Assert.AreEqual(expected.Region, actual.Region); + Assert.AreEqual(expected.Country, actual.Country); + Assert.AreEqual(expected.Note, actual.Note); + Assert.AreEqual(canonical, VCardBuilderHelper.Serialize(actual)); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("not a card")] + [DataRow("BEGIN:VCARD\nFN:Name")] + [DataRow("END:VCARD\nFN:Name\nBEGIN:VCARD")] + [DataRow("BEGIN:VCARD\nVERSION:3.0\nEND:VCARD")] + public void RejectsMissingBoundaryOrDisplayName(string? text) + { + Assert.IsFalse(VCardBuilderHelper.TryParse(text, out _)); + Assert.IsFalse(VCardBuilderHelper.IsVCard(text)); + Assert.AreEqual("vCard contact", VCardBuilderHelper.GetDisplayName(text)); + } + + [TestMethod] + public void DisplayNameUsesOrderedFallbacksAndEmptyContactDoesNotSerialize() + { + VCardBuilderState state = new(); + Assert.AreEqual(string.Empty, VCardBuilderHelper.Serialize(state)); + state.MobilePhone = " 123 "; + Assert.AreEqual("123", VCardBuilderHelper.GetDisplayName(state)); + state.Email = " a@test.com "; + Assert.AreEqual("a@test.com", VCardBuilderHelper.GetDisplayName(state)); + state.Organization = " Company "; + Assert.AreEqual("Company", VCardBuilderHelper.GetDisplayName(state)); + state.FirstName = " Ada "; + state.LastName = " Lovelace "; + Assert.AreEqual("Ada Lovelace", VCardBuilderHelper.GetDisplayName(state)); + state.FormattedName = " Preferred "; + Assert.AreEqual("Preferred", VCardBuilderHelper.GetDisplayName(state)); + } + + [TestMethod] + public void UntypedPhonesFillAvailableSlotsAndMalformedPropertiesAreIgnored() + { + const string text = "BEGIN:VCARD\rVERSION:3.0\rFN:Name\rbroken\rTEL:1\rTEL:2\rTEL:3\rTEL:4\rUNKNOWN:value\rEND:VCARD"; + Assert.IsTrue(VCardBuilderHelper.TryParse(text, out VCardBuilderState state)); + Assert.AreEqual("1", state.MobilePhone); + Assert.AreEqual("2", state.WorkPhone); + Assert.AreEqual("3", state.HomePhone); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Helpers/WifiBuilderHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Helpers/WifiBuilderHelperTests.cs new file mode 100644 index 0000000..77b1e9c --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Helpers/WifiBuilderHelperTests.cs @@ -0,0 +1,69 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Helpers; + +[TestClass] +public sealed class WifiBuilderHelperTests +{ + [TestMethod] + public void SerializesIndependentEscapingExample() + { + WifiBuilderState state = new() { Ssid = "Cafe;雪", Password = "p:a,s\"s\\", IsHiddenNetwork = true }; + Assert.AreEqual("WIFI:S:Cafe\\;雪;T:WPA;P:p\\:a\\,s\\\"s\\\\;H:true;;", WifiBuilderHelper.Serialize(state)); + } + + [TestMethod] + [DataRow("WIFI:S:Guest;T:nopass;;", WifiAuthenticationType.None)] + [DataRow("wifi:s:Guest;t:wep;p:key;h:TRUE;;", WifiAuthenticationType.WEP)] + [DataRow("WIFI:S:Guest;T:WPA2;P:key;;", WifiAuthenticationType.WPA)] + [DataRow("WIFI:S:Guest;T:WPA3;P:key;;", WifiAuthenticationType.WPA)] + [DataRow("WIFI:S:Guest;;", WifiAuthenticationType.None)] + public void ParsesKnownAuthenticationRepresentations(string text, WifiAuthenticationType expected) + { + Assert.IsTrue(WifiBuilderHelper.TryParse(text, out WifiBuilderState state)); + Assert.AreEqual("Guest", state.Ssid); + Assert.AreEqual(expected, state.AuthenticationType); + } + + [TestMethod] + public void RoundtripPreservesUnicodeEscapedSeparatorsAndBackslashes() + { + WifiBuilderState expected = new() { Ssid = "\\;:,\"雪😊\n", Password = "a\\;b:c,d\"e", IsHiddenNetwork = true }; + string canonical = WifiBuilderHelper.Serialize(expected); + Assert.IsTrue(WifiBuilderHelper.TryParse(canonical, out WifiBuilderState actual)); + Assert.AreEqual(expected.Ssid, actual.Ssid); + Assert.AreEqual(expected.Password, actual.Password); + Assert.IsTrue(actual.IsHiddenNetwork); + Assert.AreEqual(canonical, WifiBuilderHelper.Serialize(actual)); + } + + [TestMethod] + public void OpenNetworkOmitsPasswordAndEmptyNetworkDoesNotSerialize() + { + Assert.AreEqual(string.Empty, WifiBuilderHelper.Serialize(new WifiBuilderState())); + Assert.AreEqual("WIFI:S:Guest;T:nopass;;", WifiBuilderHelper.Serialize(new WifiBuilderState + { + Ssid = "Guest", + Password = "ignored", + AuthenticationType = WifiAuthenticationType.None, + })); + Assert.IsTrue(WifiBuilderHelper.TryParse("WIFI:S:Guest;H:not-bool;UNKNOWN:ignored;;", out WifiBuilderState state)); + Assert.IsFalse(state.IsHiddenNetwork); + Assert.AreEqual("Guest", WifiBuilderHelper.GetDisplayName(state)); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("SSID:Guest")] + [DataRow("WIFI:T:WPA;P:key;;")] + [DataRow("WIFI:S: ;T:WPA;;")] + [DataRow("WIFI:S:Guest;T:ROT13;;")] + public void RejectsInvalidPayloads(string? text) + { + Assert.IsFalse(WifiBuilderHelper.IsWifi(text)); + Assert.AreEqual("WiFi network", WifiBuilderHelper.GetDisplayName(text)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Infrastructure/FileServiceTests.cs b/tests/Simple QR Code Maker.Core.Tests/Infrastructure/FileServiceTests.cs new file mode 100644 index 0000000..4753dc9 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Infrastructure/FileServiceTests.cs @@ -0,0 +1,76 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Core.Services; +using System.Text.Json; + +namespace Simple_QR_Code_Maker.Core.Tests.Infrastructure; + +[TestClass] +[TestCategory("Integration")] +public sealed class FileServiceTests +{ + [TestMethod] + public void SaveCreatesDirectoryAndRoundTripsUnicode() + { + using TemporaryDirectory directory = new(); + string folder = Path.Combine(directory.Path, "nested", "settings"); + FileService service = new(); + Dictionary expected = new() { ["text"] = "\u03bb\u6f22\ud83d\ude00", ["multiline"] = "one\r\ntwo" }; + + service.Save(folder, "data.json", expected); + + Assert.IsTrue(Directory.Exists(folder)); + Dictionary? actual = service.Read>(folder, "data.json"); + Assert.IsNotNull(actual); + CollectionAssert.AreEquivalent(expected.ToArray(), actual.ToArray()); + } + + [TestMethod] + public void SaveReplacesPreviousLongerContent() + { + using TemporaryDirectory directory = new(); + FileService service = new(); + service.Save(directory.Path, "data.json", new string('x', 100)); + service.Save(directory.Path, "data.json", "short"); + Assert.AreEqual("short", service.Read(directory.Path, "data.json")); + } + + [TestMethod] + public void MissingFileReturnsDefaultWithoutCreatingDirectory() + { + using TemporaryDirectory directory = new(); + string missingFolder = Path.Combine(directory.Path, "missing"); + Assert.IsNull(new FileService().Read(missingFolder, "data.json")); + Assert.AreEqual(0, new FileService().Read(missingFolder, "data.json")); + Assert.IsFalse(Directory.Exists(missingFolder)); + } + + [TestMethod] + public void InvalidJsonIsNotSilentlyDiscarded() + { + using TemporaryDirectory directory = new(); + File.WriteAllText(Path.Combine(directory.Path, "data.json"), "{broken"); + Assert.ThrowsExactly(() => new FileService().Read>(directory.Path, "data.json")); + } + + [TestMethod] + public void DeleteIsIdempotentAndAcceptsNullName() + { + using TemporaryDirectory directory = new(); + FileService service = new(); + service.Save(directory.Path, "data.json", "value"); + service.Delete(directory.Path, "data.json"); + service.Delete(directory.Path, "data.json"); + service.Delete(directory.Path, null); + Assert.IsFalse(File.Exists(Path.Combine(directory.Path, "data.json"))); + } + + [TestMethod] + public void WriteFailurePropagates() + { + using TemporaryDirectory directory = new(); + string occupiedPath = Path.Combine(directory.Path, "file"); + File.WriteAllText(occupiedPath, "keep"); + Assert.ThrowsExactly(() => new FileService().Save(occupiedPath, "data.json", "value")); + Assert.AreEqual("keep", File.ReadAllText(occupiedPath)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Infrastructure/JsonTests.cs b/tests/Simple QR Code Maker.Core.Tests/Infrastructure/JsonTests.cs new file mode 100644 index 0000000..3ce2f8f --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Infrastructure/JsonTests.cs @@ -0,0 +1,33 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Core.Helpers; +using System.Text.Json; + +namespace Simple_QR_Code_Maker.Core.Tests.Infrastructure; + +[TestClass] +public sealed class JsonTests +{ + [TestMethod] + public async Task RoundTripPreservesValuesAndEscapedText() + { + Dictionary expected = new() { ["text"] = "a\"b\\c\r\n\u6f22" }; + string json = await Json.StringifyAsync(expected); + Dictionary? actual = await Json.ToObjectAsync>(json); + Assert.IsNotNull(actual); + Assert.AreEqual(expected["text"], actual["text"]); + } + + [TestMethod] + public async Task NullRoundTrips() + { + Assert.AreEqual("null", await Json.StringifyAsync(null)); + Assert.IsNull(await Json.ToObjectAsync("null")); + } + + [TestMethod] + public async Task InvalidJsonAndMismatchedTypesPropagate() + { + await Assert.ThrowsExactlyAsync(() => Json.ToObjectAsync("{")); + await Assert.ThrowsExactlyAsync(() => Json.ToObjectAsync("\"not an integer\"")); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Infrastructure/TemporaryDirectory.cs b/tests/Simple QR Code Maker.Core.Tests/Infrastructure/TemporaryDirectory.cs new file mode 100644 index 0000000..c45bc87 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Infrastructure/TemporaryDirectory.cs @@ -0,0 +1,13 @@ +namespace Simple_QR_Code_Maker.Core.Tests.Infrastructure; + +internal sealed class TemporaryDirectory : IDisposable +{ + internal string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"simple-qr-tests-{Guid.NewGuid():N}"); + + internal TemporaryDirectory() + { + Directory.CreateDirectory(Path); + } + + public void Dispose() => Directory.Delete(Path, recursive: true); +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Models/BuilderModelsTests.cs b/tests/Simple QR Code Maker.Core.Tests/Models/BuilderModelsTests.cs new file mode 100644 index 0000000..7e6c728 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Models/BuilderModelsTests.cs @@ -0,0 +1,90 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Models; + +[TestClass] +public sealed class BuilderModelsTests +{ + [TestMethod] + public void UrlCloneIsIndependentIncludingObservableQueryParameters() + { + UrlBuilderLine original = new() + { + Scheme = "https", + Authority = "example.com", + Path = "/雪", + Fragment = "top", + UsesAuthorityDelimiter = true, + QueryParameters = [new("key", "value")], + }; + UrlBuilderLine clone = original.Clone(); + Assert.AreNotSame(original, clone); + Assert.AreNotSame(original.QueryParameters, clone.QueryParameters); + Assert.AreNotSame(original.QueryParameters[0], clone.QueryParameters[0]); + Assert.AreEqual(original.Scheme, clone.Scheme); + Assert.AreEqual(original.Authority, clone.Authority); + Assert.AreEqual(original.Path, clone.Path); + Assert.AreEqual(original.Fragment, clone.Fragment); + Assert.AreEqual(original.UsesAuthorityDelimiter, clone.UsesAuthorityDelimiter); + clone.QueryParameters[0].Value = "changed"; + clone.QueryParameters.Add(new("new", "parameter")); + Assert.AreEqual("value", original.QueryParameters[0].Value); + Assert.AreEqual(1, original.QueryParameters.Count); + } + + [TestMethod] + public void QueryParameterNotifiesOnlyWhenValueChangesAndClonesIndependently() + { + UrlBuilderQueryParameter parameter = new("key", "value"); + List changes = []; + parameter.PropertyChanged += (_, args) => changes.Add(args.PropertyName); + parameter.Name = "key"; + parameter.Value = "new"; + CollectionAssert.AreEqual(new[] { "Value" }, changes); + UrlBuilderQueryParameter clone = parameter.Clone(); + clone.Name = "other"; + Assert.AreEqual("key", parameter.Name); + Assert.AreEqual("new", clone.Value); + } + + [TestMethod] + public void VCardCloneCopiesEveryFieldWithoutSharingMutableState() + { + VCardBuilderState original = new(); + foreach (System.Reflection.PropertyInfo property in typeof(VCardBuilderState).GetProperties()) + property.SetValue(original, property.Name + " 雪"); + VCardBuilderState clone = original.Clone(); + Assert.AreNotSame(original, clone); + foreach (System.Reflection.PropertyInfo property in typeof(VCardBuilderState).GetProperties()) + Assert.AreEqual(property.GetValue(original), property.GetValue(clone), property.Name); + clone.FormattedName = "changed"; + Assert.AreEqual("FormattedName 雪", original.FormattedName); + } + + [TestMethod] + public void WifiCloneCopiesAllFieldsIndependently() + { + WifiBuilderState original = new() { Ssid = "雪", Password = "secret", AuthenticationType = WifiAuthenticationType.WEP, IsHiddenNetwork = true }; + WifiBuilderState clone = original.Clone(); + Assert.AreNotSame(original, clone); + Assert.AreEqual(original.Ssid, clone.Ssid); + Assert.AreEqual(original.Password, clone.Password); + Assert.AreEqual(original.AuthenticationType, clone.AuthenticationType); + Assert.AreEqual(original.IsHiddenNetwork, clone.IsHiddenNetwork); + clone.Password = "changed"; + Assert.AreEqual("secret", original.Password); + } + + [TestMethod] + public void IdDefaultsAndCatalogCoverAllSupportedFormats() + { + GeneratedIdOptions options = new(); + Assert.AreEqual(SpreadsheetGeneratedIdFormat.Guid, options.Format); + Assert.AreEqual(12, options.NanoIdLength); + Assert.AreEqual(string.Empty, options.Prefix); + Assert.AreEqual(string.Empty, options.Suffix); + CollectionAssert.AreEquivalent(Enum.GetValues(), SpreadsheetGeneratedIdFormatOption.All.Select(static option => option.Format).ToArray()); + Assert.IsTrue(SpreadsheetGeneratedIdFormatOption.All.All(static option => !string.IsNullOrWhiteSpace(option.DisplayName))); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Models/PrintModelsTests.cs b/tests/Simple QR Code Maker.Core.Tests/Models/PrintModelsTests.cs new file mode 100644 index 0000000..6106835 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Models/PrintModelsTests.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Models; + +[TestClass] +public sealed class PrintModelsTests +{ + [TestMethod] + public void NormalizationClampsBoundariesWithoutMutatingOriginal() + { + PrintJobSettings original = new() + { + CodesPerPage = 3, + PageType = PrintPageType.A5, + PageLayout = (PrintPageLayout)99, + MarginMm = -1, + CodeSizeMm = 300, + SpacingMm = 30, + FitAsManyAsPossible = false, + ShowLabels = false, + }; + PrintJobSettings normalized = original.Normalize(); + Assert.AreEqual(PrintJobSettings.DefaultCodesPerPage, normalized.CodesPerPage); + Assert.AreEqual(PrintPageType.A5, normalized.PageType); + Assert.AreEqual(PrintPageLayout.Portrait, normalized.PageLayout); + Assert.AreEqual(0, normalized.MarginMm, 1e-10); + Assert.AreEqual(250, normalized.CodeSizeMm, 1e-10); + Assert.AreEqual(25, normalized.SpacingMm, 1e-10); + Assert.IsFalse(normalized.FitAsManyAsPossible); + Assert.IsFalse(normalized.ShowLabels); + Assert.AreEqual(-1, original.MarginMm, 1e-10); + Assert.AreEqual(normalized, normalized.Normalize()); + } + + [TestMethod] + [DataRow(double.NaN)] + [DataRow(double.PositiveInfinity)] + [DataRow(double.NegativeInfinity)] + public void NonfiniteMeasurementsUseDefaults(double value) + { + PrintJobSettings settings = new PrintJobSettings { MarginMm = value, CodeSizeMm = value, SpacingMm = value }.Normalize(); + Assert.AreEqual(10, settings.MarginMm, 1e-10); + Assert.AreEqual(35, settings.CodeSizeMm, 1e-10); + Assert.AreEqual(4, settings.SpacingMm, 1e-10); + } + + [TestMethod] + public void PreservesValidBoundsAndClampsOtherEnds() + { + PrintJobSettings settings = new PrintJobSettings { MarginMm = 100, CodeSizeMm = -1, SpacingMm = -1 }.Normalize(); + Assert.AreEqual(50, settings.MarginMm, 1e-10); + Assert.AreEqual(5, settings.CodeSizeMm, 1e-10); + Assert.AreEqual(0, settings.SpacingMm, 1e-10); + foreach (int count in PrintJobSettings.SupportedCodesPerPage) + Assert.AreEqual(count, new PrintJobSettings { CodesPerPage = count }.Normalize().CodesPerPage); + } + + [TestMethod] + [DataRow("en-US", PrintPageType.Letter)] + [DataRow("en-GB", PrintPageType.A4)] + [DataRow("de-DE", PrintPageType.A4)] + [DataRow("", PrintPageType.Letter)] + public void AutoAndUnknownPageTypesUseRegionalDefault(string cultureName, PrintPageType expected) + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + Assert.AreEqual(expected, PrintPageTypeHelper.GetRegionalDefault()); + Assert.AreEqual(expected, PrintPageTypeHelper.Resolve(PrintPageType.Auto)); + Assert.AreEqual(expected, PrintPageTypeHelper.Resolve((PrintPageType)999)); + Assert.AreEqual(PrintPageType.A3, PrintPageTypeHelper.Resolve(PrintPageType.A3)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [TestMethod] + public void SelectablePagesAreDefinedDistinctAndExcludeAuto() + { + Assert.AreEqual(11, PrintPageTypeHelper.SelectablePageTypes.Length); + Assert.AreEqual(11, PrintPageTypeHelper.SelectablePageTypes.Distinct().Count()); + Assert.IsTrue(PrintPageTypeHelper.SelectablePageTypes.All(static type => Enum.IsDefined(type) && type != PrintPageType.Auto)); + } + + [TestMethod] + [DataRow(0.0)] + [DataRow(-1.0)] + [DataRow(double.NaN)] + [DataRow(double.PositiveInfinity)] + [DataRow(double.NegativeInfinity)] + public void InvalidScaleProfilesFallBackToSquare(double value) + { + QrImageLayoutMetrics metrics = QrImageLayoutMetrics.FromScaleProfile(value, value); + Assert.AreEqual(1, metrics.WidthPerQrSize, 1e-10); + Assert.AreEqual(1, metrics.HeightPerQrSize, 1e-10); + } + + [TestMethod] + public void ImageMetricsCalculateRatiosAndClampToQrSize() + { + QrImageLayoutMetrics metrics = new(400, 300, 200, 100); + Assert.AreEqual(2, metrics.WidthPerQrSize, 1e-10); + Assert.AreEqual(3, metrics.HeightPerQrSize, 1e-10); + Assert.AreEqual(1, new QrImageLayoutMetrics(0, 0, 100, 100).WidthPerQrSize, 1e-10); + Assert.AreEqual(1, QrImageLayoutMetrics.Square.HeightPerQrSize, 1e-10); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Models/QrFramePresetCatalogTests.cs b/tests/Simple QR Code Maker.Core.Tests/Models/QrFramePresetCatalogTests.cs new file mode 100644 index 0000000..44d16ee --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Models/QrFramePresetCatalogTests.cs @@ -0,0 +1,35 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Models; + +[TestClass] +public sealed class QrFramePresetCatalogTests +{ + [TestMethod] + public void CatalogHasExactlyOneOptionPerPreset() + { + CollectionAssert.AreEquivalent(Enum.GetValues(), QrFramePresetCatalog.All.Select(static option => option.Preset).ToArray()); + Assert.IsTrue(QrFramePresetCatalog.All.All(static option => !string.IsNullOrWhiteSpace(option.Description) && !string.IsNullOrWhiteSpace(option.ShortDescription))); + } + + [TestMethod] + [DataRow(QrFramePreset.BottomLabel, "Scan Me")] + [DataRow(QrFramePreset.RoundedFrame, "Scan Here")] + [DataRow(QrFramePreset.CornerCallout, "Scan QR Code")] + public void DefaultsApplyOnlyWhenTextIsEmpty(QrFramePreset preset, string expected) + { + Assert.AreEqual(expected, QrFramePresetCatalog.ResolveText(preset, null)); + Assert.AreEqual(expected, QrFramePresetCatalog.ResolveText(preset, " \r\n ")); + Assert.AreEqual("雪 😊", QrFramePresetCatalog.ResolveText(preset, " 雪 😊 ")); + Assert.AreEqual(preset, QrFramePresetCatalog.GetOption(preset).Preset); + } + + [TestMethod] + public void DisabledAndUnknownPresetHaveSafeFallback() + { + Assert.IsNull(QrFramePresetCatalog.ResolveText(QrFramePreset.None, "ignored")); + Assert.AreEqual(QrFramePreset.None, QrFramePresetCatalog.GetOption((QrFramePreset)999).Preset); + Assert.AreEqual(string.Empty, QrFramePresetCatalog.ResolveText((QrFramePreset)999, null)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Simple QR Code Maker.Core.Tests.csproj b/tests/Simple QR Code Maker.Core.Tests/Simple QR Code Maker.Core.Tests.csproj new file mode 100644 index 0000000..c0ac9e4 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Simple QR Code Maker.Core.Tests.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Simple_QR_Code_Maker.Core.Tests + + + + + diff --git a/tests/Simple QR Code Maker.Core.Tests/TestAssembly.cs b/tests/Simple QR Code Maker.Core.Tests/TestAssembly.cs new file mode 100644 index 0000000..be2dce4 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize(Scope = ExecutionScope.ClassLevel)] diff --git a/tests/Simple QR Code Maker.Core.Tests/Workflows/AsyncBatchProcessorTests.cs b/tests/Simple QR Code Maker.Core.Tests/Workflows/AsyncBatchProcessorTests.cs new file mode 100644 index 0000000..edf2af7 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Workflows/AsyncBatchProcessorTests.cs @@ -0,0 +1,88 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; + +namespace Simple_QR_Code_Maker.Core.Tests.Workflows; + +[TestClass] +public sealed class AsyncBatchProcessorTests +{ + [TestMethod] + public async Task ConcurrencyIsBoundedAndProgressIsMonotonic() + { + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource full = new(TaskCreationOptions.RunContinuationsAsynchronously); + int active = 0; + int maximum = 0; + List progress = []; + Task batch = AsyncBatchProcessor.RunAsync(Enumerable.Range(0, 8).ToArray(), 2, async _ => + { + int current = Interlocked.Increment(ref active); + lock (progress) + maximum = Math.Max(maximum, current); + if (current == 2) + full.TrySetResult(); + try + { + await release.Task; + } + finally + { + Interlocked.Decrement(ref active); + } + }, count => progress.Add(count)); + + try + { + await full.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual(2, active); + } + finally + { + release.TrySetResult(); + await batch.WaitAsync(TimeSpan.FromSeconds(10)); + } + + Assert.AreEqual(2, maximum); + CollectionAssert.AreEqual(Enumerable.Range(1, 8).ToArray(), progress); + } + + [TestMethod] + public async Task WorkAndProgressFailuresReleaseSlotsAndPropagate() + { + List processed = []; + Task failure = AsyncBatchProcessor.RunAsync(new[] { 0, 1, 2 }, 1, value => + { + processed.Add(value); + return value == 0 ? Task.FromException(new IOException("synthetic failure")) : Task.CompletedTask; + }); + await Assert.ThrowsExactlyAsync(() => failure.WaitAsync(TimeSpan.FromSeconds(10))); + CollectionAssert.AreEqual(new[] { 0, 1, 2 }, processed); + + Task progressFailure = AsyncBatchProcessor.RunAsync(new[] { 0, 1, 2 }, 1, + _ => Task.CompletedTask, count => + { + if (count == 1) + throw new InvalidOperationException("synthetic progress failure"); + }); + await Assert.ThrowsExactlyAsync(() => progressFailure.WaitAsync(TimeSpan.FromSeconds(10))); + } + + [TestMethod] + public async Task PreCancellationDoesNotStartWork() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + int processed = 0; + await Assert.ThrowsAsync(() => AsyncBatchProcessor.RunAsync( + new[] { 1, 2 }, 2, _ => { processed++; return Task.CompletedTask; }, cancellationToken: cancellation.Token)); + Assert.AreEqual(0, processed); + } + + [TestMethod] + public async Task EmptyBatchAndInvalidConcurrencyAreExplicit() + { + await AsyncBatchProcessor.RunAsync(Array.Empty(), 1, _ => throw new AssertFailedException("Should not run.")); + await Assert.ThrowsExactlyAsync(() => AsyncBatchProcessor.RunAsync( + new[] { 1 }, 0, _ => Task.CompletedTask)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Workflows/DelimitedSpreadsheetTests.cs b/tests/Simple QR Code Maker.Core.Tests/Workflows/DelimitedSpreadsheetTests.cs new file mode 100644 index 0000000..d5a47ec --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Workflows/DelimitedSpreadsheetTests.cs @@ -0,0 +1,60 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Core.Tests.Infrastructure; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Workflows; + +[TestClass] +[TestCategory("Integration")] +public sealed class DelimitedSpreadsheetTests +{ + [TestMethod] + [DataRow(',')] + [DataRow('\t')] + public async Task WritebackPreservesQuotedAndUnicodeValues(char delimiter) + { + using TemporaryDirectory directory = new(); + string path = Path.Combine(directory.Path, "data.txt"); + await File.WriteAllTextAsync(path, CsvParser.Serialize([new[] { "name", "value" }, new[] { "a\"b\r\nc", "\u6f22" }], delimiter)); + await DelimitedSpreadsheetHelper.WriteGeneratedIdsAsync(path, delimiter, new SpreadsheetIdWriteRequest + { + FirstRowIsHeader = true, + CreateIdColumn = true, + IdColumnIndex = 2, + Rows = [new() { SourceRowIndex = 1, Value = "fixed-id" }], + }); + SpreadsheetReadResult actual = await DelimitedSpreadsheetHelper.ReadAsync(path, delimiter); + Assert.HasCount(2, actual.Rows); + Assert.AreEqual(1, actual.Rows[1].SourceRowIndex); + CollectionAssert.AreEqual(new[] { "a\"b\r\nc", "\u6f22", "fixed-id" }, actual.Rows[1].Cells.ToArray()); + Assert.IsEmpty(Directory.GetFiles(directory.Path, "*.tmp")); + } + + [TestMethod] + public async Task InvalidWritebackPreservesOriginalAndLeavesNoTempFiles() + { + using TemporaryDirectory directory = new(); + string path = Path.Combine(directory.Path, "data.csv"); + const string original = "name\r\nkeep"; + await File.WriteAllTextAsync(path, original); + await Assert.ThrowsExactlyAsync(() => DelimitedSpreadsheetHelper.WriteGeneratedIdsAsync(path, ',', new SpreadsheetIdWriteRequest + { + FirstRowIsHeader = true, + CreateIdColumn = true, + IdColumnIndex = -1, + Rows = [], + })); + Assert.AreEqual(original, await File.ReadAllTextAsync(path)); + Assert.IsEmpty(Directory.GetFiles(directory.Path, "*.tmp")); + } + + [TestMethod] + public async Task MissingSourceDoesNotCreateOutput() + { + using TemporaryDirectory directory = new(); + string path = Path.Combine(directory.Path, "missing.csv"); + await Assert.ThrowsExactlyAsync(() => DelimitedSpreadsheetHelper.ReadAsync(path, ',')); + Assert.IsEmpty(Directory.GetFiles(directory.Path)); + } +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Workflows/QrDocumentHelperTests.cs b/tests/Simple QR Code Maker.Core.Tests/Workflows/QrDocumentHelperTests.cs new file mode 100644 index 0000000..df92822 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Workflows/QrDocumentHelperTests.cs @@ -0,0 +1,49 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; + +namespace Simple_QR_Code_Maker.Core.Tests.Workflows; + +[TestClass] +public sealed class QrDocumentHelperTests +{ + [TestMethod] + public void PerLineModeRecognizesAllNewlinesAndRetainsDuplicates() + { + CollectionAssert.AreEqual(new[] { "one", "two", "three", "one" }, + QrDocumentHelper.EnumerateTexts(" one\r\ntwo\r \nthree\none ", MultiLineCodeMode.OneLineOneCode).ToArray()); + } + + [TestMethod] + public void SingleCodeModePreservesInteriorNewlines() + { + CollectionAssert.AreEqual(new[] { "one\r\ntwo\rthree" }, + QrDocumentHelper.EnumerateTexts(" one\r\ntwo\rthree ", MultiLineCodeMode.MultilineOneCode).ToArray()); + Assert.IsEmpty(QrDocumentHelper.EnumerateTexts(" \r\n ", MultiLineCodeMode.MultilineOneCode).ToArray()); + } + + [TestMethod] + [DataRow("WIFI:S:Network;T:nopass;;", QrContentKind.WiFi)] + [DataRow("mailto:user@example.com", QrContentKind.Email)] + [DataRow("BEGIN:VCARD\rVERSION:3.0\rFN:Contact\rEND:VCARD", QrContentKind.VCard)] + [DataRow("https://example.com", QrContentKind.PlainText)] + [DataRow("WIFI:malformed", QrContentKind.PlainText)] + [DataRow(null, QrContentKind.PlainText)] + public void DecodedContentClassificationUsesActualBuilderParsers(string? text, QrContentKind expected) + => Assert.AreEqual(expected, QrDocumentHelper.ClassifyContent(text)); + + [TestMethod] + [DataRow("https://www.Example.com/", "example.com", true)] + [DataRow("http://example.com/path", "https://example.com/", true)] + [DataRow("https://example.com:8080/path", "example.com", true)] + [DataRow("https://example.com?q=x", "example.com", true)] + [DataRow("https://example.com#fragment", "example.com", true)] + [DataRow("https://example.com.evil.test", "example.com", false)] + [DataRow("https://example.com@evil.test", "example.com", false)] + [DataRow("https://example.com:443@evil.test", "example.com", false)] + [DataRow("https://example.com/path@contact", "example.com", true)] + [DataRow("https://notexample.com", "example.com", false)] + [DataRow("https://example.com/pathology", "example.com/path", false)] + [DataRow("https://example.com", "", false)] + public void BrandMatchingRequiresRealPatternBoundary(string content, string pattern, bool expected) + => Assert.AreEqual(expected, QrDocumentHelper.MatchesBrandUrl(content, pattern)); +} diff --git a/tests/Simple QR Code Maker.Core.Tests/Workflows/SpreadsheetImportPolicyTests.cs b/tests/Simple QR Code Maker.Core.Tests/Workflows/SpreadsheetImportPolicyTests.cs new file mode 100644 index 0000000..6d79ba7 --- /dev/null +++ b/tests/Simple QR Code Maker.Core.Tests/Workflows/SpreadsheetImportPolicyTests.cs @@ -0,0 +1,99 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; + +namespace Simple_QR_Code_Maker.Core.Tests.Workflows; + +[TestClass] +public sealed class SpreadsheetImportPolicyTests +{ + [TestMethod] + public void HeadersUseWidestRowAndFillMissingNames() + { + SpreadsheetSourceRow[] rows = + [ + new() { SourceRowIndex = 0, Cells = ["Name", " "] }, + new() { SourceRowIndex = 1, Cells = ["value", "other", "extra"] }, + ]; + CollectionAssert.AreEqual(new[] { "Name", "Column 2", "Column 3" }, SpreadsheetImportPolicy.CreateHeaders(rows, true)); + CollectionAssert.AreEqual(new[] { "Column 1", "Column 2", "Column 3" }, SpreadsheetImportPolicy.CreateHeaders(rows, false)); + Assert.IsEmpty(SpreadsheetImportPolicy.CreateHeaders([], true)); + } + + [TestMethod] + public void SelectionFiltersBlanksAndPreservesOrdinalOrderAndContent() + { + IReadOnlyList[] rows = [["one"], [], [" "], ["one"], ["ONE"], [" two "]]; + CollectionAssert.AreEqual(new[] { "", "", "< two >" }, + SpreadsheetImportPolicy.SelectValues(rows, 0, "<", ">", true)); + Assert.HasCount(4, SpreadsheetImportPolicy.SelectValues(rows, 0, "", "", false)); + Assert.IsEmpty(SpreadsheetImportPolicy.SelectValues(rows, -1, "", "", true)); + Assert.IsEmpty(SpreadsheetImportPolicy.SelectValues(rows, 4, "", "", true)); + } + + [TestMethod] + public void NormalizationDoesNotMutateSource() + { + List source = ["one"]; + CollectionAssert.AreEqual(new[] { "one", "", "" }, SpreadsheetImportPolicy.NormalizeRow(source, 3)); + Assert.HasCount(1, source); + Assert.IsEmpty(SpreadsheetImportPolicy.NormalizeRow(source, 0)); + Assert.AreEqual("", SpreadsheetImportPolicy.GetCellValue(source, -1)); + Assert.ThrowsExactly(() => SpreadsheetImportPolicy.NormalizeRow(source, -1)); + } + + [TestMethod] + public void IdColumnRecognitionIgnoresCaseAndSurroundingWhitespace() + { + Assert.AreEqual(1, SpreadsheetImportPolicy.FindIdColumnIndex(["name", " ID ", "id"])); + Assert.AreEqual(-1, SpreadsheetImportPolicy.FindIdColumnIndex(["identifier"])); + Assert.IsFalse(SpreadsheetImportPolicy.ShouldGenerateId(["existing-id", " "], 0)); + Assert.IsTrue(SpreadsheetImportPolicy.ShouldGenerateId(["", "value"], 0)); + Assert.IsFalse(SpreadsheetImportPolicy.ShouldGenerateId([], 0)); + } + + [TestMethod] + public void WritebackOnlyChangesRequestedCellsAndAddsHeader() + { + List> rows = [["name"], ["first", "existing"], ["second"]]; + SpreadsheetImportPolicy.ApplyGeneratedIds(rows, new SpreadsheetIdWriteRequest + { + FirstRowIsHeader = true, + CreateIdColumn = true, + IdColumnIndex = 1, + Rows = [new() { SourceRowIndex = 2, Value = "new-id" }], + }); + CollectionAssert.AreEqual(new[] { "name", "id" }, rows[0]); + CollectionAssert.AreEqual(new[] { "first", "existing" }, rows[1]); + CollectionAssert.AreEqual(new[] { "second", "new-id" }, rows[2]); + } + + [TestMethod] + public void HeaderlessWritebackPadsRaggedAndMissingRows() + { + List> rows = [["first"]]; + SpreadsheetImportPolicy.ApplyGeneratedIds(rows, new SpreadsheetIdWriteRequest + { + FirstRowIsHeader = false, + CreateIdColumn = true, + IdColumnIndex = 2, + Rows = [new() { SourceRowIndex = 2, Value = "id" }], + }); + CollectionAssert.AreEqual(new[] { "first" }, rows[0]); + CollectionAssert.AreEqual(new[] { "", "", "id" }, rows[2]); + } + + [TestMethod] + public void InvalidWritebackDoesNotPartiallyMutateRows() + { + List> rows = [["name"]]; + Assert.ThrowsExactly(() => SpreadsheetImportPolicy.ApplyGeneratedIds(rows, new SpreadsheetIdWriteRequest + { + FirstRowIsHeader = true, + CreateIdColumn = true, + IdColumnIndex = 1, + Rows = [new() { SourceRowIndex = -1, Value = "invalid" }], + })); + CollectionAssert.AreEqual(new[] { "name" }, rows[0]); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Imaging/ImageProcessingTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Imaging/ImageProcessingTests.cs new file mode 100644 index 0000000..523a4d7 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Imaging/ImageProcessingTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using ImageMagick; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; + +namespace Simple_QR_Code_Maker.Windows.Tests.Imaging; + +[TestClass] +public sealed class ImageProcessingTests +{ + [TestMethod] + public void GrayscaleAndInvertReturnIndependentImagesWithExpectedChannels() + { + using Bitmap bitmap = new(8, 6); + using (Graphics graphics = Graphics.FromImage(bitmap)) + graphics.Clear(Color.FromArgb(255, 20, 80, 160)); + using MagickImage source = ImageProcessingHelper.LoadImageFromBitmap(bitmap); + using MagickImage grayscale = ImageProcessingHelper.ApplyGrayscale(source); + using MagickImage inverted = ImageProcessingHelper.InvertColors(source); + Color gray = Pixel(grayscale, 2, 2); + Assert.AreEqual(gray.R, gray.G); + Assert.AreEqual(gray.G, gray.B); + Color inverse = Pixel(inverted, 2, 2); + Assert.AreEqual(235, (int)inverse.R); + Assert.AreEqual(175, (int)inverse.G); + Assert.AreEqual(95, (int)inverse.B); + Assert.AreEqual(Color.FromArgb(255, 20, 80, 160).ToArgb(), Pixel(source, 2, 2).ToArgb()); + Assert.AreNotSame(source, grayscale); + Assert.AreNotSame(source, inverted); + } + + [TestMethod] + public void ContrastAndLevelsTransformLuminanceWithoutMutatingSource() + { + using MagickImage source = CreateGrayRamp(); + using MagickImage neutral = ImageProcessingHelper.AdjustContrast(source, 0); + Assert.AreEqual(Pixel(source, 1, 1).ToArgb(), Pixel(neutral, 1, 1).ToArgb()); + using MagickImage contrast = ImageProcessingHelper.AdjustContrast(source, 30); + Assert.IsTrue(Pixel(contrast, 1, 1).R < Pixel(source, 1, 1).R); + Assert.IsTrue(Pixel(contrast, 13, 1).R > Pixel(source, 13, 1).R); + using MagickImage levels = ImageProcessingHelper.AdjustLevels(source, 25, 75); + Assert.AreEqual(0, (int)Pixel(levels, 1, 1).R); + Assert.AreEqual(255, (int)Pixel(levels, 13, 1).R); + Assert.AreEqual(32, (int)Pixel(source, 1, 1).R); + Assert.AreEqual(224, (int)Pixel(source, 13, 1).R); + } + + [TestMethod] + public void BlackAndWhitePointsUseQ16QuantumRangeRatherThanByteRange() + { + using MagickImage source = CreateGrayRamp(); + using MagickImage black = ImageProcessingHelper.SetBlackPoint(source, new MagickColor("#404040")); + using MagickImage white = ImageProcessingHelper.SetWhitePoint(source, new MagickColor("#C0C0C0")); + Assert.AreEqual(0, (int)Pixel(black, 1, 1).R); + Assert.AreEqual(255, (int)Pixel(white, 13, 1).R); + Assert.IsTrue(Pixel(black, 9, 1).R is > 150 and < 200, "A 25% black point should retain mid/high range detail."); + Assert.IsTrue(Pixel(white, 1, 1).R is > 35 and < 50, "A 75% white point must not crush the full image to black."); + Assert.AreEqual(32, (int)Pixel(source, 1, 1).R); + } + + [TestMethod] + public void CropAndBorderHaveExactDimensionsAndDoNotChangeOriginal() + { + using Bitmap bitmap = new(20, 10); + using (Graphics graphics = Graphics.FromImage(bitmap)) + { + graphics.Clear(Color.Blue); + graphics.FillRectangle(Brushes.Red, 5, 2, 6, 4); + } + using MagickImage source = ImageProcessingHelper.LoadImageFromBitmap(bitmap); + using MagickImage crop = ImageProcessingHelper.CropRegion(source, 5, 2, 6, 4); + Assert.AreEqual(6u, crop.Width); + Assert.AreEqual(4u, crop.Height); + Assert.AreEqual(Color.Red.ToArgb(), Pixel(crop, 0, 0).ToArgb()); + Assert.AreEqual(0, crop.Page.X); + Assert.AreEqual(0, crop.Page.Y); + using MagickImage border = ImageProcessingHelper.AddWhiteBorder(source, 10); + Assert.AreEqual(24u, border.Width); + Assert.AreEqual(14u, border.Height); + Assert.AreEqual(Color.White.ToArgb(), Pixel(border, 0, 0).ToArgb()); + Assert.AreEqual(Color.Blue.ToArgb(), Pixel(border, 2, 2).ToArgb()); + Assert.AreEqual(Color.Red.ToArgb(), Pixel(border, 7, 4).ToArgb()); + Assert.AreEqual(20u, source.Width); + Assert.AreEqual(10u, source.Height); + } + + [TestMethod] + public void PerspectiveAndPolynomialTransformsUseRequestedGeometry() + { + using MagickImage source = new(MagickColors.Red, 32, 32); + using MagickImage corrected = ImageProcessingHelper.CorrectPerspectiveDistortion(source, new(5, 5), new(25, 5), new(25, 25), new(5, 25), 2); + Assert.AreEqual(24u, corrected.Width); + Assert.AreEqual(24u, corrected.Height); + Assert.AreEqual(Color.White.ToArgb(), Pixel(corrected, 0, 0).ToArgb()); + Assert.AreEqual(Color.Red.ToArgb(), Pixel(corrected, 12, 12).ToArgb()); + List<(Point src, PointF dst)> points = [(new(0, 0), new(0, 0)), (new(31, 0), new(31, 0)), (new(0, 31), new(0, 31))]; + using MagickImage unwarped = ImageProcessingHelper.ApplyPolynomialUnwarp(source, points, 20, 16, 2); + Assert.AreEqual(24u, unwarped.Width); + Assert.AreEqual(20u, unwarped.Height); + Assert.AreEqual(32u, source.Width); + } + + [TestMethod] + public void PerspectiveRejectsOutOfBoundsDegenerateCrossedAndTooSmallCorners() + { + using MagickImage source = new(MagickColors.White, 32, 32); + Assert.ThrowsExactly(() => ImageProcessingHelper.CorrectPerspectiveDistortion(source, new(-1, 0), new(20, 0), new(20, 20), new(0, 20))); + Assert.ThrowsExactly(() => ImageProcessingHelper.CorrectPerspectiveDistortion(source, new(1, 1), new(1, 1), new(1, 1), new(1, 1))); + Assert.ThrowsExactly(() => ImageProcessingHelper.CorrectPerspectiveDistortion(source, new(0, 0), new(10, 10), new(20, 20), new(30, 30))); + Assert.ThrowsExactly(() => ImageProcessingHelper.CorrectPerspectiveDistortion(source, new(0, 0), new(20, 20), new(20, 0), new(0, 20))); + Assert.ThrowsExactly(() => ImageProcessingHelper.CorrectPerspectiveDistortion(source, new(0, 0), new(6, 0), new(6, 6), new(0, 6))); + Assert.ThrowsExactly(() => ImageProcessingHelper.ApplyPolynomialUnwarp(source, [], 20, 20)); + } + + [TestMethod] + public void BitmapConversionRemainsUsableAfterSourceAndInternalStreamsAreDisposed() + { + Bitmap converted; + using (MagickImage image = new(MagickColors.Blue, 7, 5)) + converted = ImageProcessingHelper.ConvertToBitmap(image); + using (converted) + { + Assert.AreEqual(7, converted.Width); + Assert.AreEqual(5, converted.Height); + Assert.AreEqual(Color.Blue.ToArgb(), converted.GetPixel(3, 2).ToArgb()); + converted.SetPixel(3, 2, Color.Red); + using System.IO.MemoryStream stream = new(); + converted.Save(stream, System.Drawing.Imaging.ImageFormat.Png); + Assert.IsTrue(stream.Length > 0); + } + } + + private static MagickImage CreateGrayRamp() + { + using Bitmap bitmap = new(16, 4); + for (int y = 0; y < bitmap.Height; y++) + for (int x = 0; x < bitmap.Width; x++) + { + int value = x < 4 ? 32 : x < 8 ? 64 : x < 12 ? 192 : 224; + bitmap.SetPixel(x, y, Color.FromArgb(value, value, value)); + } + return ImageProcessingHelper.LoadImageFromBitmap(bitmap); + } + + private static Color Pixel(MagickImage image, int x, int y) + { + using Bitmap bitmap = ImageProcessingHelper.ConvertToBitmap(image); + return bitmap.GetPixel(x, y); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Models/DesignAndModelTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Models/DesignAndModelTests.cs new file mode 100644 index 0000000..13b1183 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Models/DesignAndModelTests.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using ZXing.QrCode.Internal; +using WinColor = Windows.UI.Color; + +namespace Simple_QR_Code_Maker.Windows.Tests.Models; + +[TestClass] +public sealed class DesignAndModelTests +{ + [TestMethod] + public void HistoryEqualityAndHashingAgreeForInvariantEquivalentPayloads() + { + HistoryItem first = new() { CodesContent = "café", Foreground = WinColor.FromArgb(255, 0, 0, 0) }; + HistoryItem same = new() { CodesContent = "cafe\u0301", Foreground = WinColor.FromArgb(255, 255, 0, 0) }; + Assert.IsTrue(first.Equals(same)); + Assert.IsTrue(first.Equals((object)same)); + Assert.AreEqual(first.GetHashCode(), same.GetHashCode(), "Invariant-culture equality must use the same hash comparer."); + Assert.HasCount(1, new HashSet { first, same }); + Assert.IsFalse(first.Equals(new HistoryItem { CodesContent = "CAFÉ" })); + Assert.IsFalse(first.Equals(null)); + Assert.IsFalse(first.Equals("café")); + HistoryItem nullPayload = new() { CodesContent = null! }; + Assert.IsTrue(nullPayload.Equals(new HistoryItem { CodesContent = null! })); + Assert.AreEqual(0, nullPayload.GetHashCode()); + } + + [TestMethod] + public void BrandEqualityIsOrdinalIgnoreCaseWithMatchingHash() + { + BrandItem first = new() { Name = "Example", IsDefault = true }; + BrandItem second = new() { Name = "EXAMPLE", IsDefault = false }; + Assert.IsTrue(first.Equals(second)); + Assert.AreEqual(first.GetHashCode(), second.GetHashCode()); + Assert.HasCount(1, new HashSet { first, second }); + Assert.IsFalse(first.Equals(new BrandItem { Name = " Example " })); + Assert.IsFalse(first.Equals(null)); + Assert.IsFalse(first.Equals("Example")); + BrandItem nullName = new() { Name = null! }; + Assert.IsTrue(nullName.Equals(new BrandItem { Name = null! })); + Assert.AreEqual(0, nullName.GetHashCode()); + } + + [TestMethod] + [DataRow("L", "L")] + [DataRow("M", "M")] + [DataRow("Q", "Q")] + [DataRow("H", "H")] + [DataRow("old unknown", "M")] + public void CorrectionPropertySupportsStoredStringsAndFallback(string stored, string expected) + { + HistoryItem history = new() { ErrorCorrectionLevelAsString = stored }; + Assert.AreEqual(expected, history.ErrorCorrection.ToString()); + history.ErrorCorrection = ErrorCorrectionLevel.Q; + Assert.AreEqual("Q", history.ErrorCorrectionLevelAsString); + } + + [TestMethod] + public void DesignHistoryRoundTripCopiesEveryFieldAndDoesNotAliasArrays() + { + QrCodeDesignState state = CreateState(); + HistoryItem history = QrCodeDesignStateMapper.ToHistoryItem(state); + QrCodeDesignState restored = QrCodeDesignStateMapper.FromHistoryItem(history); + Assert.AreEqual(state with { CommonBrandCodes = restored.CommonBrandCodes }, restored); + CollectionAssert.AreEqual(state.CommonBrandCodes, history.CommonBrandCodes); + Assert.AreNotSame(state.CommonBrandCodes, history.CommonBrandCodes); + Assert.AreNotSame(history.CommonBrandCodes, restored.CommonBrandCodes); + history.CommonBrandCodes[0] = "changed"; + Assert.AreEqual("https://example.test", state.CommonBrandCodes[0]); + Assert.AreEqual("https://example.test", restored.CommonBrandCodes[0]); + history.CommonBrandCodes = null!; + Assert.HasCount(0, QrCodeDesignStateMapper.FromHistoryItem(history).CommonBrandCodes); + } + + [TestMethod] + public void BrandMapperIncludesSelectedDesignAndTrimsNameAndCaption() + { + QrCodeDesignState state = CreateState(); + BrandItem brand = QrCodeDesignStateMapper.ToBrandItem(state, " Synthetic brand ", new() { IncludeUrl = true }); + Assert.AreEqual("Synthetic brand", brand.Name); + Assert.AreEqual(state.Foreground, brand.Foreground); + Assert.AreEqual(state.Background, brand.Background); + Assert.AreEqual(state.CodesContent, brand.UrlContent); + Assert.AreEqual(state.ContentKind, brand.ContentKind); + Assert.AreEqual(state.MultiLineCodeModeOverride, brand.MultiLineCodeModeOverride); + Assert.AreEqual("H", brand.ErrorCorrectionLevelAsString); + Assert.AreEqual(state.LogoImagePath, brand.LogoImagePath); + Assert.AreEqual(state.LogoEmoji, brand.LogoEmoji); + Assert.AreEqual(state.LogoEmojiStyle, brand.LogoEmojiStyle); + Assert.AreEqual(state.LogoSizePercentage, brand.LogoSizePercentage); + Assert.AreEqual(state.LogoPaddingPixels, brand.LogoPaddingPixels); + Assert.AreEqual(state.FramePreset, brand.FramePreset); + Assert.AreEqual(state.FrameTextSource, brand.FrameTextSource); + Assert.AreEqual("Scan me", brand.FrameText); + BrandItem withoutFrame = QrCodeDesignStateMapper.ToBrandItem(state with { FramePreset = QrFramePreset.None }, "name", new()); + Assert.IsNull(withoutFrame.FrameText); + } + + [TestMethod] + [DataRow("foreground")] + [DataRow("background")] + [DataRow("url")] + [DataRow("logo")] + [DataRow("correction")] + [DataRow("frame")] + public void EachBrandIncludeFlagIsIndependent(string excluded) + { + BrandCreationOptions options = new() + { + IncludeForeground = excluded != "foreground", + IncludeBackground = excluded != "background", + IncludeUrl = excluded != "url", + IncludeCenterImage = excluded != "logo", + IncludeErrorCorrection = excluded != "correction", + IncludeFrame = excluded != "frame", + }; + BrandItem brand = QrCodeDesignStateMapper.ToBrandItem(CreateState(), "brand", options); + Assert.AreEqual(excluded == "foreground", brand.Foreground is null); + Assert.AreEqual(excluded == "background", brand.Background is null); + Assert.AreEqual(excluded == "url", brand.UrlContent is null); + Assert.AreEqual(excluded == "correction", brand.ErrorCorrectionLevelAsString is null); + Assert.AreEqual(excluded == "logo", brand.LogoImagePath is null); + Assert.AreEqual(excluded == "logo", brand.LogoEmoji is null); + Assert.AreEqual(excluded == "logo", brand.LogoEmojiStyle is null); + Assert.AreEqual(excluded == "logo", brand.LogoSizePercentage is null); + Assert.AreEqual(excluded == "logo", brand.LogoPaddingPixels is null); + Assert.AreEqual(excluded == "frame", brand.FramePreset is null); + Assert.AreEqual(excluded == "frame", brand.FrameText is null); + } + + [TestMethod] + public void RenderSnapshotOwnsAnIndependentLogoAndPreservesSettings() + { + using Bitmap source = new(3, 2); + source.SetPixel(1, 1, Color.Red); + using QrRenderSettingsSnapshot snapshot = QrRenderSettingsSnapshot.Create(ErrorCorrectionLevel.Q, Color.Navy, Color.White, source, 12.5, -3, "", 4, QrFramePreset.BottomLabel, QrFrameTextSource.Manual, "caption"); + source.SetPixel(1, 1, Color.Blue); + Assert.AreNotSame(source, snapshot.LogoImage); + Assert.AreEqual(Color.Red.ToArgb(), snapshot.LogoImage!.GetPixel(1, 1).ToArgb()); + Assert.AreEqual(ErrorCorrectionLevel.Q, snapshot.ErrorCorrectionLevel); + Assert.AreEqual(Color.Navy, snapshot.ForegroundColor); + Assert.AreEqual(Color.White, snapshot.BackgroundColor); + Assert.AreEqual(12.5, snapshot.LogoSizePercentage); + Assert.AreEqual(-3, snapshot.LogoPaddingPixels); + Assert.AreEqual("", snapshot.LogoSvgContent); + Assert.AreEqual(4, snapshot.QrPaddingModules); + Assert.AreEqual(QrFramePreset.BottomLabel, snapshot.FramePreset); + Assert.AreEqual("caption", snapshot.FrameText); + Assert.IsNull(snapshot.CreateCommonBrandOverride(new("https://example.test"))); + Assert.ThrowsExactly(() => snapshot.CreateCommonBrandOverride(new("https://example.test", UseCommonBrand: true))); + snapshot.Dispose(); + Assert.AreEqual(Color.Blue.ToArgb(), source.GetPixel(1, 1).ToArgb(), "Snapshot disposal must not dispose the caller's bitmap."); + } + + [TestMethod] + [DataRow("HTTPS://WWW.YOUTUBE.COM/watch?v=123", "YouTube")] + [DataRow("youtu.be/test", "YouTube")] + [DataRow("https://google.com/maps/place/test", "Google Maps")] + [DataRow("https://github.com./test", "GitHub")] + [DataRow("https://github.com.evil.test/test", null)] + [DataRow("https://evilgithub.com/test", null)] + [DataRow("https://user@github.com/test", null)] + [DataRow("ftp://github.com/test", null)] + [DataRow("https://google.com/mapsfake", null)] + [DataRow("text github.com test", null)] + [DataRow("", null)] + public void CommonBrandMatchingRequiresRealHostAndPathBoundaries(string payload, string? expected) + => Assert.AreEqual(expected, CommonBrandCatalog.FindMatching(payload)?.Name); + + [TestMethod] + public void CommonBrandResourcesAndOverridesRemainAvailableFromExtractedAssembly() + { + foreach (CommonBrandPreset brand in CommonBrandCatalog.All) + Assert.AreEqual("svg", XElement.Parse(brand.LoadLogoSvg()).Name.LocalName); + + using QrRenderSettingsSnapshot source = QrRenderSettingsSnapshot.Create(ErrorCorrectionLevel.L, Color.Red, Color.Blue, null, 30, -2, null, 4, QrFramePreset.BottomLabel, QrFrameTextSource.ContentSummary, "existing"); + using QrRenderSettingsSnapshot common = source.CreateCommonBrandOverride(new("https://github.com/example", UseCommonBrand: true))!; + Assert.AreEqual(ErrorCorrectionLevel.H, common.ErrorCorrectionLevel); + Assert.AreEqual(CommonBrandCatalog.FindMatching("github.com")!.Foreground, common.ForegroundColor); + Assert.AreEqual(Color.White, common.BackgroundColor); + Assert.IsNotNull(common.LogoImage); + Assert.IsFalse(string.IsNullOrEmpty(common.LogoSvgContent)); + Assert.AreEqual(15, common.LogoSizePercentage); + Assert.AreEqual(4, common.LogoPaddingPixels); + Assert.AreEqual(source.QrPaddingModules, common.QrPaddingModules); + Assert.AreEqual(source.FramePreset, common.FramePreset); + Assert.AreEqual(source.FrameTextSource, common.FrameTextSource); + Assert.AreEqual(source.FrameText, common.FrameText); + } + + [TestMethod] + public void DecodingDisplayPropertiesHandleEmptySingleMultipleAndNonFileSources() + { + DecodingHistoryItem item = new(); + Assert.AreEqual("(no codes found)", item.FirstDecodedText); + Assert.AreEqual(string.Empty, item.DecodedCountSuffix); + Assert.AreEqual("Unknown source", item.SourceDisplayName); + Assert.IsFalse(item.HasSourceFile); + Assert.IsFalse(item.HasSavedImage); + item.DecodedTexts = ["first", "second", "third"]; + Assert.AreEqual("first", item.FirstDecodedText); + Assert.AreEqual(" (+2 more)", item.DecodedCountSuffix); + item.SourceKind = DecodingSourceKind.Camera; + Assert.AreEqual("Camera capture", item.SourceDisplayName); + item.SourceKind = DecodingSourceKind.SnippingTool; + Assert.AreEqual("Snipping Tool capture", item.SourceDisplayName); + item.SourceKind = DecodingSourceKind.Clipboard; + Assert.AreEqual("Clipboard image", item.SourceDisplayName); + } + + private static QrCodeDesignState CreateState() => new() + { + CodesContent = "https://example.test/content", + ContentKind = QrContentKind.PlainText, + MultiLineCodeModeOverride = MultiLineCodeMode.MultilineOneCode, + CommonBrandCodes = ["https://example.test"], + Foreground = WinColor.FromArgb(128, 1, 2, 3), + Background = WinColor.FromArgb(0, 4, 5, 6), + ErrorCorrection = ErrorCorrectionLevel.H, + LogoImagePath = "synthetic.png", + LogoEmoji = "😀", + LogoEmojiStyle = EmojiLogoStyle.Flat, + LogoSizePercentage = 15.5, + LogoPaddingPixels = -2.5, + FramePreset = QrFramePreset.BottomLabel, + FrameTextSource = QrFrameTextSource.Manual, + FrameText = " Scan me ", + }; +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/AtomicStorageTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/AtomicStorageTests.cs new file mode 100644 index 0000000..3183813 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/AtomicStorageTests.cs @@ -0,0 +1,78 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Services; + +namespace Simple_QR_Code_Maker.Windows.Tests.Persistence; + +[TestClass] +public sealed class AtomicStorageTests +{ + [TestMethod] + public async Task ConcurrentStoresSerializeSameDestinationAndCommitEachSnapshotInOrder() + { + BlockingFiles files = new(); + string directory = Path.Combine(Environment.CurrentDirectory, "TestResults", Guid.NewGuid().ToString("N")); + AtomicJsonFileStore firstStore = new(directory, files); + AtomicJsonFileStore secondStore = new(directory, files); + Task first = firstStore.WriteAsync("History.json", "first snapshot"); + await files.FirstWriteEntered.Task; + Task second = secondStore.WriteAsync("History.json", "second snapshot"); + Assert.AreEqual(1, files.Writes); + files.ReleaseFirstWrite.SetResult(); + await Task.WhenAll(first, second); + CollectionAssert.AreEqual(new[] { "first snapshot", "second snapshot" }, files.Commits); + Assert.AreEqual("second snapshot", await secondStore.ReadAsync("History.json")); + Assert.HasCount(1, files.Contents); + } + + [TestMethod] + public async Task FailedWriteReleasesSerializationGateAndDoesNotPublishPartialData() + { + BlockingFiles files = new() { FailFirstWrite = true }; + string directory = Path.Combine(Environment.CurrentDirectory, "TestResults", Guid.NewGuid().ToString("N")); + AtomicJsonFileStore store = new(directory, files); + Task first = store.WriteAsync("Brands.json", "failed"); + await files.FirstWriteEntered.Task; + Task second = store.WriteAsync("Brands.json", "successful"); + files.ReleaseFirstWrite.SetResult(); + await Assert.ThrowsExactlyAsync(() => first); + await second; + CollectionAssert.AreEqual(new[] { "successful" }, files.Commits); + Assert.AreEqual("successful", await store.ReadAsync("Brands.json")); + Assert.HasCount(1, files.Contents); + } + + private sealed class BlockingFiles : IAtomicFileOperations + { + public TaskCompletionSource FirstWriteEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseFirstWrite { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Dictionary Contents { get; } = []; + public List Commits { get; } = []; + public int Writes { get; private set; } + public bool FailFirstWrite { get; init; } + + public Task ReadAsync(string path) => Task.FromResult(Contents.GetValueOrDefault(Path.GetFullPath(path))); + + public async Task WriteAsync(string path, string content) + { + Writes++; + Contents[path] = content; + if (Writes == 1) + { + FirstWriteEntered.SetResult(); + await ReleaseFirstWrite.Task; + if (FailFirstWrite) + throw new IOException("First write failed."); + } + } + + public void Replace(string sourcePath, string destinationPath) + { + Contents[destinationPath] = Contents[sourcePath]; + Commits.Add(Contents[sourcePath]); + Contents.Remove(sourcePath); + } + + public void Delete(string path) => Contents.Remove(path); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/BrandStoreTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/BrandStoreTests.cs new file mode 100644 index 0000000..f77d8f4 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/BrandStoreTests.cs @@ -0,0 +1,75 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; +using Windows.UI; + +namespace Simple_QR_Code_Maker.Windows.Tests.Persistence; + +[TestClass] +public sealed class BrandStoreTests +{ + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("null")] + [DataRow("[]")] + [DataRow("malformed")] + public async Task MissingEmptyMalformedOrNullReturnsEmpty(string? json) + { + MemoryJsonFileStore files = new(); + if (json is not null) + files.Files["Brands.json"] = json; + Assert.IsEmpty(await new BrandStore(files).LoadAsync()); + Assert.IsEmpty(files.Writes); + } + + [TestMethod] + public async Task RoundTripPreservesOrderDefaultsNullableColorsAndDesign() + { + MemoryJsonFileStore files = new(); + BrandStore store = new(files); + await store.SaveAsync( + [ + new() { Name = "second", IsDefault = true, Foreground = Color.FromArgb(123, 4, 5, 6), FrameText = "brand label" }, + new() { Name = "first", Background = null, LogoEmoji = "🌵", UrlContent = "https://example.com" } + ]); + ObservableCollection loaded = await store.LoadAsync(); + CollectionAssert.AreEqual(new[] { "second", "first" }, loaded.Select(item => item.Name).ToArray()); + Assert.IsTrue(loaded[0].IsDefault); + Assert.AreEqual(Color.FromArgb(123, 4, 5, 6), loaded[0].Foreground); + Assert.AreEqual("brand label", loaded[0].FrameText); + Assert.IsNull(loaded[1].Background); + Assert.AreEqual("🌵", loaded[1].LogoEmoji); + Assert.AreEqual("https://example.com", loaded[1].UrlContent); + Assert.AreEqual("Brands.json", files.Writes[0].Name); + } + + [TestMethod] + public async Task FailurePropagatesWithoutReplacingExistingFile() + { + MemoryJsonFileStore files = new(); + BrandStore store = new(files); + await store.SaveAsync([new() { Name = "original" }]); + string previous = files.Files["Brands.json"]; + files.WriteFailure = new IOException("Rename failed."); + await Assert.ThrowsExactlyAsync(() => store.SaveAsync([new() { Name = "replacement" }])); + Assert.AreEqual(previous, files.Files["Brands.json"]); + } + + [TestMethod] + public async Task SaveFreezesItemsBeforeAsynchronousWrite() + { + MemoryJsonFileStore files = new() { WriteEntered = new(), ReleaseWrite = new() }; + BrandItem item = new() { Name = "original" }; + Task save = new BrandStore(files).SaveAsync([item]); + await files.WriteEntered.Task; + item.Name = "changed"; + files.ReleaseWrite.SetResult(); + await save; + BrandItem saved = JsonSerializer.Deserialize(files.Files["Brands.json"], BrandJsonContext.Default.ObservableCollectionBrandItem)![0]; + Assert.AreEqual("original", saved.Name); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs new file mode 100644 index 0000000..40e1552 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs @@ -0,0 +1,215 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; + +namespace Simple_QR_Code_Maker.Windows.Tests.Persistence; + +[TestClass] +public sealed class DecodingHistoryStoreTests +{ + private readonly MemoryJsonFileStore _files = new(); + private readonly FakeImages _images = new(); + + private DecodingHistoryStore CreateStore() => new(_files, _images); + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("null")] + [DataRow("[]")] + [DataRow("invalid")] + public async Task MissingEmptyMalformedOrNullReturnsEmpty(string? json) + { + if (json is not null) + _files.Files["DecodingHistory.json"] = json; + Assert.IsEmpty(await CreateStore().LoadAsync()); + Assert.IsEmpty(_files.Writes); + } + + [TestMethod] + public async Task AddLimitsHistoryToFiftyAndDeletesOnlyPrunedUnreferencedImages() + { + ObservableCollection items = new(Enumerable.Range(0, 50).Select(index => new DecodingHistoryItem + { + SavedImagePath = $"image-{index}.png", + DecodedTexts = [$"text-{index}"], + SavedDateTime = FixedTimeProvider.Now.DateTime + })); + DecodingHistoryStore store = CreateStore(); + await store.SaveAsync(items); + DecodingHistoryItem added = new() { SavedImagePath = "new.png", DecodedTexts = ["new"] }; + await store.AddAndSaveAsync(items, added); + Assert.HasCount(50, items); + Assert.AreSame(added, items[0]); + Assert.AreEqual("text-48", items[^1].DecodedTexts[0]); + CollectionAssert.AreEqual(new[] { "image-49.png" }, _images.DeletedPaths); + ObservableCollection loaded = await store.LoadAsync(); + Assert.HasCount(50, loaded); + Assert.AreEqual("new.png", loaded[0].SavedImagePath); + } + + [TestMethod] + public async Task UpdateLatestPreservesIdentityMetadataAndOrderAndCopiesTextList() + { + DecodingHistoryItem latest = new() + { + SavedImagePath = "old.png", + SourceFilePath = "source.png", + SourceKind = DecodingSourceKind.Clipboard, + SavedDateTime = FixedTimeProvider.Now.DateTime, + DecodedTexts = ["old"] + }; + DecodingHistoryItem older = new() { SavedImagePath = "older.png", DecodedTexts = ["older"] }; + ObservableCollection items = [latest, older]; + DecodingHistoryStore store = CreateStore(); + await store.SaveAsync(items); + List texts = ["new", "another"]; + await store.UpdateLatestAndSaveAsync(items, "edited.png", texts); + texts.Clear(); + Assert.HasCount(2, items); + Assert.AreSame(latest, items[0]); + Assert.AreSame(older, items[1]); + Assert.AreEqual("new-image.png", latest.SavedImagePath); + Assert.AreEqual(FixedTimeProvider.Now.DateTime, latest.SavedDateTime); + Assert.AreEqual(DecodingSourceKind.Clipboard, latest.SourceKind); + Assert.AreEqual("source.png", latest.SourceFilePath); + CollectionAssert.AreEqual(new[] { "new", "another" }, latest.DecodedTexts); + CollectionAssert.AreEqual(new[] { "old.png" }, _images.DeletedPaths); + CollectionAssert.AreEqual(new[] { "edited.png" }, _images.CopiedSources); + ObservableCollection loaded = await store.LoadAsync(); + CollectionAssert.AreEqual(new[] { "new", "another" }, loaded[0].DecodedTexts); + } + + [TestMethod] + public async Task UpdatingEmptyHistoryDoesNothing() + { + await CreateStore().UpdateLatestAndSaveAsync([], "source.png", ["text"]); + Assert.IsEmpty(_files.Writes); + Assert.IsEmpty(_images.CopiedSources); + } + + [TestMethod] + public async Task FailedReplacementCopyPreservesOldImageAndMetadata() + { + _images.CopyFailure = new IOException("Copy failed."); + ObservableCollection items = [new() { SavedImagePath = "old.png", DecodedTexts = ["old"] }]; + await Assert.ThrowsExactlyAsync(() => CreateStore().UpdateLatestAndSaveAsync(items, "new.png", ["new"])); + Assert.AreEqual("old.png", items[0].SavedImagePath); + CollectionAssert.AreEqual(new[] { "old" }, items[0].DecodedTexts); + Assert.IsEmpty(_files.Writes); + Assert.IsEmpty(_images.DeletedPaths); + } + + [TestMethod] + public async Task MissingReplacementImageIsObservableWithoutDestroyingOldImage() + { + _images.CopyResult = null; + ObservableCollection items = [new() { SavedImagePath = "old.png" }]; + await Assert.ThrowsExactlyAsync(() => CreateStore().UpdateLatestAndSaveAsync(items, "missing.png", ["new"])); + Assert.AreEqual("old.png", items[0].SavedImagePath); + Assert.IsEmpty(_images.DeletedPaths); + } + + [TestMethod] + public async Task FailedWritePreservesOldMetadataAndImageAndRemovesUncommittedCopy() + { + ObservableCollection items = [new() { SavedImagePath = "old.png", DecodedTexts = ["old"] }]; + DecodingHistoryStore store = CreateStore(); + await store.SaveAsync(items); + string previous = _files.Files["DecodingHistory.json"]; + _files.WriteFailure = new IOException("Rename failed."); + await Assert.ThrowsExactlyAsync(() => store.UpdateLatestAndSaveAsync(items, "new.png", ["new"])); + Assert.AreEqual(previous, _files.Files["DecodingHistory.json"]); + Assert.AreEqual("old.png", items[0].SavedImagePath); + CollectionAssert.AreEqual(new[] { "old" }, items[0].DecodedTexts); + CollectionAssert.AreEqual(new[] { "new-image.png" }, _images.DeletedPaths); + } + + [TestMethod] + public async Task FailedAddDoesNotTrimLiveCollectionOrDeleteImages() + { + ObservableCollection items = [new() { SavedImagePath = "old.png" }]; + _files.WriteFailure = new IOException("Write failed."); + await Assert.ThrowsExactlyAsync(() => CreateStore().AddAndSaveAsync(items, new() { SavedImagePath = "new.png" })); + Assert.HasCount(1, items); + Assert.AreEqual("old.png", items[0].SavedImagePath); + Assert.IsEmpty(_images.DeletedPaths); + } + + [TestMethod] + public async Task SaveRemovalPreservesSharedImagesAndClearDeletesRemainingImages() + { + DecodingHistoryStore store = CreateStore(); + ObservableCollection items = + [ + new() { SavedImagePath = "shared.png" }, + new() { SavedImagePath = "SHARED.PNG" }, + new() { SavedImagePath = "removed.png" } + ]; + await store.SaveAsync(items); + items.RemoveAt(0); + items.RemoveAt(1); + await store.SaveAsync(items); + CollectionAssert.AreEqual(new[] { "removed.png" }, _images.DeletedPaths); + items.Clear(); + await store.SaveAsync(items); + CollectionAssert.AreEqual(new[] { "removed.png", "SHARED.PNG" }, _images.DeletedPaths); + } + + [TestMethod] + public async Task ImageCleanupFailureDoesNotUndoConfirmedPersistence() + { + DecodingHistoryStore store = CreateStore(); + await store.SaveAsync([new() { SavedImagePath = "old.png" }]); + _images.DeleteFailure = new IOException("Delete failed."); + await store.SaveAsync([]); + Assert.IsEmpty(await store.LoadAsync()); + } + + [TestMethod] + public async Task CopyFacadeRetainsNullResultForUnavailableImages() + { + _images.CopyFailure = new IOException("Copy failed."); + Assert.IsNull(await CreateStore().SaveImageCopyAsync("image.png")); + _images.CopyFailure = null; + _images.CopyResult = null; + Assert.IsNull(await CreateStore().SaveImageCopyAsync("missing.png")); + } + + [TestMethod] + public async Task ConcurrentAddsSerializePolicyAndKeepBothEntries() + { + _files.WriteEntered = new(); + _files.ReleaseWrite = new(); + DecodingHistoryStore store = CreateStore(); + ObservableCollection items = []; + Task first = store.AddAndSaveAsync(items, new() { DecodedTexts = ["first"] }); + await _files.WriteEntered.Task; + Task second = store.AddAndSaveAsync(items, new() { DecodedTexts = ["second"] }); + _files.ReleaseWrite.SetResult(); + await Task.WhenAll(first, second); + CollectionAssert.AreEqual(new[] { "second", "first" }, items.Select(item => item.DecodedTexts[0]).ToArray()); + Assert.HasCount(2, await store.LoadAsync()); + } + + [TestMethod] + public async Task SaveSnapshotDoesNotObserveLaterCollectionOrTextMutations() + { + _files.WriteEntered = new(); + _files.ReleaseWrite = new(); + DecodingHistoryItem item = new() { SavedImagePath = "old.png", DecodedTexts = ["before"] }; + ObservableCollection items = [item]; + Task save = CreateStore().SaveAsync(items); + await _files.WriteEntered.Task; + item.DecodedTexts[0] = "after"; + items.Clear(); + _files.ReleaseWrite.SetResult(); + await save; + DecodingHistoryItem saved = JsonSerializer.Deserialize( + _files.Files["DecodingHistory.json"], DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem)![0]; + Assert.AreEqual("before", saved.DecodedTexts[0]); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/DiskStorageIntegrationTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/DiskStorageIntegrationTests.cs new file mode 100644 index 0000000..cd26c68 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/DiskStorageIntegrationTests.cs @@ -0,0 +1,117 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Services; + +namespace Simple_QR_Code_Maker.Windows.Tests.Persistence; + +[TestClass] +[TestCategory("Integration")] +public sealed class DiskStorageIntegrationTests +{ + private string _directory = null!; + + [TestInitialize] + public void CreatePrivateDirectory() + { + _directory = Path.Combine(Environment.CurrentDirectory, "TestResults", "Persistence", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + [TestCleanup] + public void RemovePrivateDirectory() => Directory.Delete(_directory, recursive: true); + + [TestMethod] + public async Task AtomicWritesCreateAndReplaceOnlyTheirOwnFile() + { + AtomicJsonFileStore store = new(_directory); + Assert.IsNull(await store.ReadAsync("missing.json")); + await store.WriteAsync("history.json", """{"original":true}"""); + await store.WriteAsync("history.json", """{"replacement":true}"""); + Assert.AreEqual("""{"replacement":true}""", await store.ReadAsync("history.json")); + Assert.HasCount(1, Directory.GetFiles(_directory)); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task FailedWriteOrRenameKeepsPreviousRealFileAndRemovesPendingFile(bool failRename) + { + AtomicJsonFileStore healthy = new(_directory); + await healthy.WriteAsync("history.json", "previous"); + AtomicJsonFileStore failing = new(_directory, new FailingDiskFiles(failRename)); + await Assert.ThrowsExactlyAsync(() => failing.WriteAsync("history.json", "replacement")); + Assert.AreEqual("previous", await healthy.ReadAsync("history.json")); + Assert.HasCount(1, Directory.GetFiles(_directory)); + await healthy.WriteAsync("history.json", "recovered"); + Assert.AreEqual("recovered", await healthy.ReadAsync("history.json")); + } + + [TestMethod] + public async Task FileSettingsPersistAcrossInstancesAndMigrationKeepsOtherKeys() + { + AtomicJsonFileStore files = new(_directory); + FileSettingsStore legacy = new(files); + await legacy.WriteAsync("HistoryItems", """[{"CodesContent":"migrated","Foreground":{"A":255,"R":1,"G":2,"B":3}}]"""); + await legacy.WriteAsync("Other", "42"); + HistoryStore history = new(files, legacy, new FixedTimeProvider()); + Assert.HasCount(1, await history.LoadAsync()); + FileSettingsStore reloaded = new(new AtomicJsonFileStore(_directory)); + Assert.IsNull(await reloaded.ReadAsync("HistoryItems")); + Assert.AreEqual("42", await reloaded.ReadAsync("Other")); + Assert.HasCount(1, await history.LoadAsync()); + } + + [TestMethod] + public async Task ImageCopiesAreIndependentUniqueWithFrozenClockAndOwnedDeletionOnly() + { + string source = Path.Combine(_directory, "source.png"); + byte[] bytes = [1, 2, 3, 4, 5]; + await File.WriteAllBytesAsync(source, bytes); + string imagesDirectory = Path.Combine(_directory, "images"); + HistoryImageStore images = new(imagesDirectory, new FixedTimeProvider()); + string? first = await images.SaveCopyAsync(source); + string? second = await images.SaveCopyAsync(source); + Assert.IsNotNull(first); + Assert.IsNotNull(second); + Assert.AreNotEqual(first, second); + CollectionAssert.AreEqual(bytes, await File.ReadAllBytesAsync(first)); + await File.WriteAllBytesAsync(source, [9]); + CollectionAssert.AreEqual(bytes, await File.ReadAllBytesAsync(first)); + await images.DeleteAsync(source); + Assert.IsTrue(File.Exists(source)); + await images.DeleteAsync(first); + Assert.IsFalse(File.Exists(first)); + Assert.IsTrue(File.Exists(second)); + await images.DeleteAsync(Path.Combine(imagesDirectory, "..", "source.png")); + Assert.IsTrue(File.Exists(source)); + } + + [TestMethod] + public async Task MissingImageDoesNotCreateStorageFolder() + { + string imagesDirectory = Path.Combine(_directory, "images"); + HistoryImageStore images = new(imagesDirectory, new FixedTimeProvider()); + Assert.IsNull(await images.SaveCopyAsync(string.Empty)); + Assert.IsNull(await images.SaveCopyAsync(Path.Combine(_directory, "missing.png"))); + Assert.IsFalse(Directory.Exists(imagesDirectory)); + } + + private sealed class FailingDiskFiles(bool failRename) : IAtomicFileOperations + { + private readonly AtomicFileOperations _inner = new(); + + public Task ReadAsync(string path) => _inner.ReadAsync(path); + + public async Task WriteAsync(string path, string content) + { + await _inner.WriteAsync(path, content); + if (!failRename) + throw new IOException("Failed after writing pending data."); + } + + public void Replace(string sourcePath, string destinationPath) => + throw new IOException("Failed before replacing destination."); + + public void Delete(string path) => _inner.Delete(path); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/HistoryStoreTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/HistoryStoreTests.cs new file mode 100644 index 0000000..fe8e395 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/HistoryStoreTests.cs @@ -0,0 +1,189 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; +using Windows.UI; + +namespace Simple_QR_Code_Maker.Windows.Tests.Persistence; + +[TestClass] +public sealed class HistoryStoreTests +{ + private readonly MemoryJsonFileStore _files = new(); + private readonly MemorySettingsStore _legacy = new(); + + private HistoryStore CreateStore() => new(_files, _legacy, new FixedTimeProvider()); + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("[]")] + [DataRow("null")] + [DataRow("{malformed")] + public async Task MissingEmptyOrMalformedWithoutLegacyReturnsEmpty(string? json) + { + if (json is not null) + _files.Files["History.json"] = json; + Assert.IsEmpty(await CreateStore().LoadAsync()); + Assert.AreEqual(0, _legacy.Removals); + Assert.IsEmpty(_files.Writes); + } + + [TestMethod] + public async Task ExistingFilePreservesOrderContentAndAllDesignFieldsWithoutReadingLegacy() + { + ObservableCollection original = + [ + new() { CodesContent = "second", SavedDateTime = FixedTimeProvider.Now.DateTime, CommonBrandCodes = ["x", "y"], ContentKind = QrContentKind.WiFi, FrameText = "label" }, + new() { CodesContent = "first", LogoEmoji = "🐱", ErrorCorrectionLevelAsString = "H" } + ]; + HistoryStore store = CreateStore(); + await store.SaveAsync(original); + ObservableCollection loaded = await store.LoadAsync(); + CollectionAssert.AreEqual(new[] { "second", "first" }, loaded.Select(item => item.CodesContent).ToArray()); + CollectionAssert.AreEqual(new[] { "x", "y" }, loaded[0].CommonBrandCodes); + Assert.AreEqual(QrContentKind.WiFi, loaded[0].ContentKind); + Assert.AreEqual("label", loaded[0].FrameText); + Assert.AreEqual("🐱", loaded[1].LogoEmoji); + Assert.AreEqual("H", loaded[1].ErrorCorrectionLevelAsString); + Assert.AreEqual(0, _legacy.Reads); + } + + [TestMethod] + public async Task LegacyHexAndObjectColorsMigrateUsingSourceGeneratedContract() + { + _legacy.Values["HistoryItems"] = """ + [ + {"CodesContent":"hex","Foreground":"#FF123456","Background":"#FFABCDEF","ContentKind":"WiFi","LogoPaddingPixels":9,"FrameText":"kept"}, + {"CodesContent":"object","Foreground":{"A":128,"R":12,"G":34,"B":56},"Background":{"R":255,"G":255,"B":255}}, + {"CodesContent":"defaults","Foreground":{"A":999},"Background":"bad","LogoEmojiStyle":"unknown"} + ] + """; + ObservableCollection loaded = await CreateStore().LoadAsync(); + Assert.HasCount(3, loaded); + Assert.AreEqual(Color.FromArgb(255, 0x12, 0x34, 0x56), loaded[0].Foreground); + Assert.AreEqual(Color.FromArgb(255, 0xAB, 0xCD, 0xEF), loaded[0].Background); + Assert.AreEqual(QrContentKind.WiFi, loaded[0].ContentKind); + Assert.AreEqual("kept", loaded[0].FrameText); + Assert.AreEqual(Color.FromArgb(128, 12, 34, 56), loaded[1].Foreground); + Assert.AreEqual(Color.FromArgb(255, 255, 255, 255), loaded[1].Background); + Assert.AreEqual(Color.FromArgb(255, 0, 0, 0), loaded[2].Foreground); + Assert.AreEqual(Color.FromArgb(255, 255, 255, 255), loaded[2].Background); + Assert.IsNull(loaded[2].LogoEmojiStyle); + Assert.AreEqual(FixedTimeProvider.Now.DateTime, loaded[2].SavedDateTime); + Assert.AreEqual(15, loaded[2].LogoSizePercentage); + Assert.AreEqual(4, loaded[2].LogoPaddingPixels); + Assert.AreEqual("M", loaded[2].ErrorCorrectionLevelAsString); + Assert.AreEqual(1, _legacy.Removals); + Assert.IsFalse(_legacy.Values.ContainsKey("HistoryItems")); + ObservableCollection saved = JsonSerializer.Deserialize( + _files.Files["History.json"], HistoryJsonContext.Default.ObservableCollectionHistoryItem)!; + Assert.HasCount(3, saved); + } + + [TestMethod] + public async Task InvalidScalarItemsAreSkippedButValidItemsStillMigrate() + { + _legacy.Values["HistoryItems"] = """ + [{"CodesContent":"valid"}, {"CodesContent":"invalid","LogoSizePercentage":"not-a-number"}, null] + """; + ObservableCollection loaded = await CreateStore().LoadAsync(); + Assert.HasCount(1, loaded); + Assert.AreEqual("valid", loaded[0].CodesContent); + Assert.AreEqual(1, _legacy.Removals); + } + + [TestMethod] + public async Task MissingContentAndInvalidColorsRetainLegacyDefaults() + { + _legacy.Values["HistoryItems"] = """[{"Foreground":"#GG000000","Background":42}]"""; + ObservableCollection loaded = await CreateStore().LoadAsync(); + Assert.HasCount(1, loaded); + Assert.AreEqual(string.Empty, loaded[0].CodesContent); + Assert.AreEqual(Color.FromArgb(255, 0, 0, 0), loaded[0].Foreground); + Assert.AreEqual(Color.FromArgb(255, 255, 255, 255), loaded[0].Background); + } + + [TestMethod] + public async Task TransparentBlackColorsAreRepairedOnLoad() + { + _files.Files["History.json"] = """[{"CodesContent":"transparent","Foreground":"#00000000","Background":"#00000000"}]"""; + HistoryItem item = (await CreateStore().LoadAsync())[0]; + Assert.AreEqual(Color.FromArgb(255, 0, 0, 0), item.Foreground); + Assert.AreEqual(Color.FromArgb(255, 255, 255, 255), item.Background); + } + + [TestMethod] + public async Task FailedMigrationWriteNeverClearsLegacy() + { + _legacy.Values["HistoryItems"] = """[{"CodesContent":"only copy"}]"""; + _files.WriteFailure = new IOException("Write failed."); + await Assert.ThrowsExactlyAsync(() => CreateStore().LoadAsync()); + Assert.AreEqual(0, _legacy.Removals); + Assert.IsTrue(_legacy.Values.ContainsKey("HistoryItems")); + Assert.IsFalse(_files.Files.ContainsKey("History.json")); + } + + [TestMethod] + [DataRow("not json")] + [DataRow("{}")] + [DataRow("[]")] + [DataRow("""[{"SavedDateTime":"invalid"}]""")] + public async Task UnusableLegacyIsNotClearedOrReplaced(string legacy) + { + _legacy.Values["HistoryItems"] = legacy; + Assert.IsEmpty(await CreateStore().LoadAsync()); + Assert.AreEqual(0, _legacy.Removals); + Assert.IsEmpty(_files.Writes); + } + + [TestMethod] + public async Task CleanupFailureKeepsConfirmedPersistedHistoryUsable() + { + _legacy.Values["HistoryItems"] = """[{"CodesContent":"persisted"}]"""; + _legacy.RemoveFailure = new IOException("Cleanup failed."); + Assert.HasCount(1, await CreateStore().LoadAsync()); + Assert.IsTrue(_files.Files.ContainsKey("History.json")); + Assert.IsTrue(_legacy.Values.ContainsKey("HistoryItems")); + } + + [TestMethod] + public async Task SaveCapturesStateBeforeYieldAndPropagatesWriteFailure() + { + HistoryItem item = new() { CodesContent = "before", CommonBrandCodes = ["before"] }; + ObservableCollection items = [item]; + _files.WriteEntered = new(); + _files.ReleaseWrite = new(); + Task save = CreateStore().SaveAsync(items); + await _files.WriteEntered.Task; + item.CodesContent = "after"; + item.CommonBrandCodes[0] = "after"; + items.Clear(); + _files.ReleaseWrite.SetResult(); + await save; + HistoryItem saved = JsonSerializer.Deserialize( + _files.Files["History.json"], HistoryJsonContext.Default.ObservableCollectionHistoryItem)![0]; + Assert.AreEqual("before", saved.CodesContent); + Assert.AreEqual("before", saved.CommonBrandCodes[0]); + _files.WriteFailure = new IOException("Write failed."); + await Assert.ThrowsExactlyAsync(() => CreateStore().SaveAsync(items)); + } + + [TestMethod] + public async Task ConcurrentLoadsMigrateOnlyOnce() + { + _legacy.Values["HistoryItems"] = """[{"CodesContent":"once"}]"""; + _files.WriteEntered = new(); + _files.ReleaseWrite = new(); + HistoryStore store = CreateStore(); + Task> first = store.LoadAsync(); + await _files.WriteEntered.Task; + Task> second = store.LoadAsync(); + _files.ReleaseWrite.SetResult(); + await Task.WhenAll(first, second); + Assert.HasCount(1, _files.Writes); + Assert.AreEqual(1, _legacy.Removals); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/PersistenceFakes.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/PersistenceFakes.cs new file mode 100644 index 0000000..51937c5 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/PersistenceFakes.cs @@ -0,0 +1,99 @@ +using Simple_QR_Code_Maker.Contracts.Services; + +namespace Simple_QR_Code_Maker.Windows.Tests.Persistence; + +internal sealed class MemoryJsonFileStore : IJsonFileStore +{ + public Dictionary Files { get; } = []; + public List<(string Name, string Json)> Writes { get; } = []; + public Exception? ReadFailure { get; set; } + public Exception? WriteFailure { get; set; } + public TaskCompletionSource? WriteEntered { get; set; } + public TaskCompletionSource? ReleaseWrite { get; set; } + + public Task ReadAsync(string fileName) + { + if (ReadFailure is not null) + throw ReadFailure; + return Task.FromResult(Files.GetValueOrDefault(fileName)); + } + + public async Task WriteAsync(string fileName, string json) + { + WriteEntered?.TrySetResult(); + if (ReleaseWrite is not null) + await ReleaseWrite.Task; + if (WriteFailure is not null) + throw WriteFailure; + Writes.Add((fileName, json)); + Files[fileName] = json; + } +} + +internal sealed class MemorySettingsStore : ISettingsStore +{ + public Dictionary Values { get; } = []; + public int Reads { get; private set; } + public int Removals { get; private set; } + public Exception? Failure { get; set; } + public Exception? RemoveFailure { get; set; } + + public Task ReadAsync(string key) + { + Reads++; + if (Failure is not null) + throw Failure; + return Task.FromResult(Values.GetValueOrDefault(key)); + } + + public Task WriteAsync(string key, string value) + { + if (Failure is not null) + throw Failure; + Values[key] = value; + return Task.CompletedTask; + } + + public Task RemoveAsync(string key) + { + if (RemoveFailure is not null) + throw RemoveFailure; + Removals++; + Values.Remove(key); + return Task.CompletedTask; + } +} + +internal sealed class FixedTimeProvider : TimeProvider +{ + public static readonly DateTimeOffset Now = new(2025, 2, 3, 4, 5, 6, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => Now; + + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; +} + +internal sealed class FakeImages : IHistoryImageStore +{ + public string? CopyResult { get; set; } = "new-image.png"; + public Exception? CopyFailure { get; set; } + public Exception? DeleteFailure { get; set; } + public List CopiedSources { get; } = []; + public List DeletedPaths { get; } = []; + + public Task SaveCopyAsync(string sourcePath) + { + CopiedSources.Add(sourcePath); + if (CopyFailure is not null) + throw CopyFailure; + return Task.FromResult(CopyResult); + } + + public Task DeleteAsync(string path) + { + if (DeleteFailure is not null) + throw DeleteFailure; + DeletedPaths.Add(path); + return Task.CompletedTask; + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/SettingsStoreTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/SettingsStoreTests.cs new file mode 100644 index 0000000..1159afd --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/SettingsStoreTests.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; + +namespace Simple_QR_Code_Maker.Windows.Tests.Persistence; + +[TestClass] +public sealed class SettingsStoreTests +{ + [TestMethod] + public void ConfiguredSettingsAndHistoricalMigrationPathsRemainDistinct() + { + string root = Path.Combine(Environment.CurrentDirectory, "fake-local-data"); + Assert.AreEqual(Path.Combine(root, "Simple QR Code Maker", "ApplicationData"), + SettingsStoragePaths.GetFolder(root, new LocalSettingsOptions())); + Assert.AreEqual(Path.Combine(root, "Simple_QR_Code_Maker", "ApplicationData"), + SettingsStoragePaths.GetFolder(root, new LocalSettingsOptions { ApplicationDataFolder = Path.Combine("Simple_QR_Code_Maker", "ApplicationData") })); + Assert.AreEqual(Path.Combine(root, "Simple QR Code Maker", "ApplicationData"), + SettingsStoragePaths.GetLegacyHistoryFolder(root)); + } + + [TestMethod] + public async Task MissingFileHasNoValueAndFirstWriteCreatesStringDictionary() + { + MemoryJsonFileStore files = new(); + FileSettingsStore store = new(files); + Assert.IsNull(await store.ReadAsync("missing")); + await store.WriteAsync("enabled", "true"); + using JsonDocument json = JsonDocument.Parse(files.Files["LocalSettings.json"]); + Assert.AreEqual("true", json.RootElement.GetProperty("enabled").GetString()); + Assert.AreEqual("true", await store.ReadAsync("enabled")); + } + + [TestMethod] + public async Task ExistingNestedJsonStringsAndUnrelatedKeysSurviveUpdatesAndRemoval() + { + MemoryJsonFileStore files = new(); + files.Files["custom.json"] = """{"HistoryItems":"[{\"CodesContent\":\"old\"}]","Other":"42"}"""; + FileSettingsStore store = new(files, "custom.json"); + Assert.AreEqual("""[{"CodesContent":"old"}]""", await store.ReadAsync("HistoryItems")); + await store.WriteAsync("Added", "\"value\""); + await store.RemoveAsync("HistoryItems"); + using JsonDocument json = JsonDocument.Parse(files.Files["custom.json"]); + Assert.IsFalse(json.RootElement.TryGetProperty("HistoryItems", out _)); + Assert.AreEqual("42", json.RootElement.GetProperty("Other").GetString()); + Assert.AreEqual("\"value\"", json.RootElement.GetProperty("Added").GetString()); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow("invalid")] + [DataRow("null")] + [DataRow("[]")] + [DataRow("""{"bad":42}""")] + public async Task InvalidSettingsFileDoesNotSilentlyReset(string content) + { + MemoryJsonFileStore files = new(); + files.Files["LocalSettings.json"] = content; + FileSettingsStore store = new(files); + await Assert.ThrowsExactlyAsync(() => store.ReadAsync("key")); + Assert.IsEmpty(files.Writes); + Assert.AreEqual(content, files.Files["LocalSettings.json"]); + } + + [TestMethod] + public async Task FailedWriteDoesNotPublishUncommittedCache() + { + MemoryJsonFileStore files = new(); + FileSettingsStore store = new(files); + await store.WriteAsync("key", "old"); + files.WriteFailure = new IOException("Failed replace."); + await Assert.ThrowsExactlyAsync(() => store.WriteAsync("key", "new")); + Assert.AreEqual("old", await store.ReadAsync("key")); + files.WriteFailure = null; + await store.WriteAsync("other", "value"); + using JsonDocument json = JsonDocument.Parse(files.Files["LocalSettings.json"]); + Assert.AreEqual("old", json.RootElement.GetProperty("key").GetString()); + } + + [TestMethod] + public async Task ConcurrentWritesAndLegacyRemovalAreSerializedWithoutLostKeys() + { + MemoryJsonFileStore files = new() { WriteEntered = new(), ReleaseWrite = new() }; + files.Files["LocalSettings.json"] = """{"HistoryItems":"legacy","preserve":"true"}"""; + FileSettingsStore store = new(files); + Task first = store.WriteAsync("first", "1"); + await files.WriteEntered.Task; + Task second = store.WriteAsync("second", "2"); + Task remove = store.RemoveAsync("HistoryItems"); + files.ReleaseWrite.SetResult(); + await Task.WhenAll(first, second, remove); + using JsonDocument json = JsonDocument.Parse(files.Files["LocalSettings.json"]); + Assert.AreEqual("1", json.RootElement.GetProperty("first").GetString()); + Assert.AreEqual("2", json.RootElement.GetProperty("second").GetString()); + Assert.AreEqual("true", json.RootElement.GetProperty("preserve").GetString()); + Assert.IsFalse(json.RootElement.TryGetProperty("HistoryItems", out _)); + } + + [TestMethod] + public async Task FolderExtensionUsesJsonSuffixAndPreservesTypedRoundTrip() + { + MemoryJsonFileStore files = new(); + Assert.IsNull(await files.ReadAsync("missing")); + await files.SaveAsync("preference", new[] { "one", "two" }); + Assert.IsTrue(files.Files.ContainsKey("preference.json")); + CollectionAssert.AreEqual(new[] { "one", "two" }, await files.ReadAsync("preference")); + files.Files["broken.json"] = "invalid"; + await Assert.ThrowsExactlyAsync(() => files.ReadAsync("broken")); + files.WriteFailure = new IOException("Save failed."); + await Assert.ThrowsExactlyAsync(() => files.SaveAsync("preference", false)); + } + + [TestMethod] + public async Task ContainerExtensionPolicyUsesTheProvidedStoreAndExposesInvalidData() + { + MemorySettingsStore settings = new(); + Assert.IsNull(await settings.ReadAsync("missing")); + await settings.SaveAsync("preference", new[] { "one", "two" }); + CollectionAssert.AreEqual(new[] { "one", "two" }, await settings.ReadAsync("preference")); + settings.Values["broken"] = "invalid"; + await Assert.ThrowsExactlyAsync(() => settings.ReadAsync("broken")); + settings.Failure = new IOException("Container unavailable."); + await Assert.ThrowsExactlyAsync(() => settings.SaveAsync("preference", false)); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Rendering/QrCodeRendererTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Rendering/QrCodeRendererTests.cs new file mode 100644 index 0000000..d29df6a --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Rendering/QrCodeRendererTests.cs @@ -0,0 +1,279 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using ZXing; +using ZXing.QrCode.Internal; + +namespace Simple_QR_Code_Maker.Windows.Tests.Rendering; + +[TestClass] +public sealed class QrCodeRendererTests +{ + [TestMethod] + [DataRow("Hello, QR!")] + [DataRow("café déjà vu ÿ")] + [DataRow("漢字かな中文")] + [DataRow("Привет, мир")] + [DataRow("QR 😀🚀🌈")] + public void PngAndSvgPreservePayload(string payload) + { + using MemoryStream stream = new(); + QrCodeRenderer.SaveQrCodePngToStream(stream, payload, ErrorCorrectionLevel.M, Color.Black, Color.White); + Assert.IsTrue(stream.CanWrite, "The renderer must not close the caller's stream."); + stream.Position = 0; + using Bitmap png = new(stream); + AssertPayload(png, payload); + + string svg = QrCodeRenderer.GetSvgQrCodeForText(payload, ErrorCorrectionLevel.M, Color.Black, Color.White).Content; + XElement root = XElement.Parse(svg); + Assert.AreEqual("svg", root.Name.LocalName); + Assert.IsTrue(root.Descendants().Any(e => e.Name.LocalName is "rect" or "path" or "polygon")); + using Bitmap rasterized = QrCodeRenderer.RasterizeSvgToBitmap(svg, png.Width, png.Height); + AssertPayload(rasterized, payload); + } + + [TestMethod] + [DataRow("L", 20)] + [DataRow("M", 23)] + [DataRow("Q", 35)] + [DataRow("H", 40)] + public void CorrectionLevelsAreUsedByEncoderAndRenderer(string levelName, int maxLogo) + { + ErrorCorrectionLevel level = Level(levelName); + const string payload = "correction-level payload 0123456789"; + QRCode qr = QrCodeRenderer.EncodeQrCode(payload, level); + Assert.AreEqual(level, qr.ECLevel); + Assert.AreEqual(maxLogo, QrCodeRenderer.GetMaxLogoSizePercentage(level)); + using Bitmap image = QrCodeRenderer.GetQrCodeBitmapFromText(payload, level, Color.Black, Color.White); + var result = QrCodeRenderer.GetStringsFromBitmap(image).Single().Item2; + Assert.AreEqual(payload, result.Text); + Assert.AreEqual(levelName, result.ResultMetadata[ResultMetadataType.ERROR_CORRECTION_LEVEL]); + } + + [TestMethod] + public void VersionOneCapacityMatchesKnownNumericAlphanumericAndLatin1Limits() + { + // Version 1-L capacities from the QR specification, independent of decoder behavior. + AssertVersion(new string('1', 41), 1); + AssertVersion(new string('1', 42), 2); + AssertVersion(new string('A', 25), 1); + AssertVersion(new string('A', 26), 2); + AssertVersion(new string('é', 17), 1); + AssertVersion(new string('é', 18), 2); + Assert.AreEqual(Mode.NUMERIC, QrCodeRenderer.EncodeQrCode("01234567", ErrorCorrectionLevel.L).Mode); + Assert.AreEqual(Mode.BYTE, QrCodeRenderer.EncodeQrCode("é", ErrorCorrectionLevel.L).Mode); + Assert.ThrowsExactly(() => QrCodeRenderer.EncodeQrCode(new string('é', 4000), ErrorCorrectionLevel.H)); + } + + [TestMethod] + [DataRow(0, 1008)] + [DataRow(2, 1000)] + [DataRow(4, 1015)] + [DataRow(10, 984)] + public void PixelsUseWholeModulesAndExactQuietZones(int padding, int expectedSide) + { + const string payload = "01234567"; + QRCode qr = QrCodeRenderer.EncodeQrCode(payload, ErrorCorrectionLevel.L); + Assert.AreEqual(21, qr.Version.DimensionForVersion); + using Bitmap image = QrCodeRenderer.GetQrCodeBitmapFromText(payload, ErrorCorrectionLevel.L, Color.Black, Color.White, qrPaddingModules: padding); + Assert.AreEqual(expectedSide, image.Width); + Assert.AreEqual(expectedSide, image.Height); + int scale = expectedSide / (21 + padding * 2); + for (int y = 0; y < 21 + padding * 2; y++) + { + for (int x = 0; x < 21 + padding * 2; x++) + { + bool isDark = x >= padding && x < 21 + padding && y >= padding && y < 21 + padding + && qr.Matrix[x - padding, y - padding] == 1; + Color expected = isDark ? Color.Black : Color.White; + Assert.AreEqual(expected.ToArgb(), image.GetPixel(x * scale + scale / 2, y * scale + scale / 2).ToArgb(), $"module ({x},{y})"); + } + } + + // Fixed structural expectations, not a renderer-vs-decoder round trip. + string[] finder = ["1111111", "1000001", "1011101", "1011101", "1011101", "1000001", "1111111"]; + for (int y = 0; y < 7; y++) + for (int x = 0; x < 7; x++) + Assert.AreEqual(finder[y][x] - '0', (int)qr.Matrix[x, y]); + for (int x = 8; x <= 12; x++) + Assert.AreEqual(x % 2 == 0 ? 1 : 0, (int)qr.Matrix[x, 6]); + AssertPayload(image, payload); + } + + [TestMethod] + [DataRow(-100.0, 0)] + [DataRow(0.49, 0)] + [DataRow(0.5, 1)] + [DataRow(2.49, 2)] + [DataRow(2.5, 3)] + [DataRow(9.5, 10)] + [DataRow(100.0, 10)] + public void PaddingIsRoundedAwayFromZeroAndClamped(double value, int expected) + => Assert.AreEqual(expected, QrCodeRenderer.NormalizeQrPaddingModules(value)); + + [TestMethod] + [DataRow("\r")] + [DataRow("\n")] + [DataRow("\r\n")] + public void VCardSizingPngAndSvgUseIdenticalCrLfPayload(string newline) + { + string[] lines = ["BEGIN:VCARD", "VERSION:3.0", "FN:Zoë 😀", "N:Example;Zoë;;;", "NOTE:First\\nSecond", "END:VCARD"]; + string input = string.Join(newline, lines); + string expected = string.Join("\r\n", lines); + QRCode normalized = QrCodeRenderer.EncodeQrCode(expected, ErrorCorrectionLevel.Q); + Assert.AreEqual(normalized.Version.VersionNumber, QrCodeRenderer.EncodeQrCode(input, ErrorCorrectionLevel.Q).Version.VersionNumber); + var metrics = QrCodeRenderer.GetQrImageLayoutMetrics(input, ErrorCorrectionLevel.Q); + using Bitmap png = QrCodeRenderer.GetQrCodeBitmapFromText(input, ErrorCorrectionLevel.Q, Color.Black, Color.White); + Assert.AreEqual(metrics.CanvasWidth, (double)png.Width); + AssertPayload(png, expected); + string svg = QrCodeRenderer.GetSvgQrCodeForText(input, ErrorCorrectionLevel.Q, Color.Black, Color.White).Content; + using Bitmap rasterized = QrCodeRenderer.RasterizeSvgToBitmap(svg, png.Width, png.Height); + AssertPayload(rasterized, expected); + using Bitmap ordinary = QrCodeRenderer.GetQrCodeBitmapFromText("ordinary\rtext", ErrorCorrectionLevel.M, Color.Black, Color.White); + AssertPayload(ordinary, "ordinary\rtext"); + } + + [TestMethod] + public void PngRetainsOpaqueColorsAndTransparentBackground() + { + Color foreground = Color.FromArgb(255, 24, 80, 120); + Color background = Color.FromArgb(0, 230, 240, 250); + using Bitmap image = QrCodeRenderer.GetQrCodeBitmapFromText("alpha", ErrorCorrectionLevel.L, foreground, background); + int scale = image.Width / 25; + Assert.AreEqual(0, (int)image.GetPixel(0, 0).A); + Assert.AreEqual(foreground.ToArgb(), image.GetPixel(2 * scale + scale / 2, 2 * scale + scale / 2).ToArgb()); + using Bitmap translucent = QrCodeRenderer.GetQrCodeBitmapFromText("alpha", ErrorCorrectionLevel.L, Color.FromArgb(128, 0, 0, 0), Color.White); + Assert.AreEqual(128, (int)translucent.GetPixel(2 * scale + scale / 2, 2 * scale + scale / 2).A); + } + + [TestMethod] + [DataRow(QrFramePreset.None)] + [DataRow(QrFramePreset.BottomLabel)] + [DataRow(QrFramePreset.RoundedFrame)] + [DataRow(QrFramePreset.CornerCallout)] + public void FrameGeometryMatchesPngSvgAndSizingWithoutFontSnapshots(QrFramePreset frame) + { + const string label = "Scan & \"that\""; + var expected = QrCodeRenderer.GetQrImageLayoutMetrics("frame", ErrorCorrectionLevel.M, 2, frame); + using MemoryStream stream = new(); + QrCodeRenderer.SaveQrCodePngToStream(stream, "frame", ErrorCorrectionLevel.M, Color.Black, Color.White, null, 20, 8, 2, frame, label, out var rendered); + Assert.AreEqual(expected, rendered); + stream.Position = 0; + using Bitmap bitmap = new(stream); + Assert.AreEqual(expected.CanvasWidth, (double)bitmap.Width); + Assert.AreEqual(expected.CanvasHeight, (double)bitmap.Height); + AssertPayload(bitmap, "frame"); + XElement svg = XElement.Parse(QrCodeRenderer.GetSvgQrCodeForText("frame", ErrorCorrectionLevel.M, Color.Black, Color.White, framePreset: frame, frameText: label).Content); + string[] viewBox = svg.Attribute("viewBox")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(expected.CanvasWidth, double.Parse(viewBox[2], CultureInfo.InvariantCulture)); + Assert.AreEqual(expected.CanvasHeight, double.Parse(viewBox[3], CultureInfo.InvariantCulture)); + if (frame != QrFramePreset.None) + { + Assert.IsTrue(bitmap.Height > rendered.QrHeight); + Assert.AreEqual(label, svg.Descendants().Single(e => e.Name.LocalName == "text").Value); + } + } + + [TestMethod] + public void SyntheticRasterLogoPreservesAspectRatioAndPayload() + { + using Bitmap logo = new(12, 6); + using (Graphics graphics = Graphics.FromImage(logo)) + graphics.Clear(Color.Red); + using Bitmap qr = QrCodeRenderer.GetQrCodeBitmapFromText("https://example.test/logo", ErrorCorrectionLevel.H, Color.Black, Color.White, logo, 15, 4); + Assert.AreEqual(Color.Red.ToArgb(), qr.GetPixel(qr.Width / 2, qr.Height / 2).ToArgb()); + AssertPayload(qr, "https://example.test/logo"); + XElement svg = XElement.Parse(QrCodeRenderer.GetSvgQrCodeForText("logo", ErrorCorrectionLevel.H, Color.Black, Color.Transparent, logo, 15, 4).Content); + XElement embedded = svg.Descendants().Single(e => e.Name.LocalName == "image"); + double width = double.Parse(embedded.Attribute("width")!.Value, CultureInfo.InvariantCulture); + double height = double.Parse(embedded.Attribute("height")!.Value, CultureInfo.InvariantCulture); + Assert.AreEqual(2, width / height, 0.05); + string href = embedded.Attribute("href")!.Value; + Assert.IsTrue(href.StartsWith("data:image/png;base64,", StringComparison.Ordinal)); + using MemoryStream bytes = new(Convert.FromBase64String(href.Split(',')[1])); + using Bitmap embeddedImage = new(bytes); + Assert.AreEqual((int)width, embeddedImage.Width); + Assert.AreEqual((int)height, embeddedImage.Height); + Assert.IsTrue(svg.Descendants().Any(e => e.Name.LocalName == "mask")); + Assert.IsTrue(svg.Descendants().Any(e => e.Attribute("mask")?.Value == "url(#logo-mask)")); + } + + [TestMethod] + public void InlineSvgLogoIsNestedAndOpacityIsCultureIndependent() + { + const string logo = """"""; + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + XElement root = XElement.Parse(QrCodeRenderer.GetSvgQrCodeForText("inline", ErrorCorrectionLevel.H, Color.Black, Color.FromArgb(128, 20, 30, 40), logoSvgContent: logo).Content); + XElement nested = root.Descendants().Single(e => e.Name.LocalName == "svg"); + Assert.AreEqual("0 0 12 6", nested.Attribute("viewBox")!.Value); + Assert.AreEqual("xMidYMid meet", nested.Attribute("preserveAspectRatio")!.Value); + Assert.IsFalse(root.Descendants().Attributes("fill").Any(a => a.Value.Contains("0,502", StringComparison.Ordinal)), "SVG decimal alpha must not use the current culture's comma."); + Assert.IsTrue(root.Descendants().Attributes("fill").Any(a => a.Value == "rgba(20,30,40,0.502)")); + } + finally + { + CultureInfo.CurrentCulture = original; + } + Assert.ThrowsExactly(() => QrCodeRenderer.GetSvgQrCodeForText("bad", ErrorCorrectionLevel.H, Color.Black, Color.White, logoSvgContent: "")); + } + + [TestMethod] + public void DecoderReadsIndependentlyAuthoredNumericFixtureAndRejectsBlank() + { + // Synthetic version 1-L / mask 0 fixture for "01234567", authored independently + // of ZXing: data 10 20 0C 56 61 80 + EC/11 pad, RS 53 55 97 67 10 05 84. + string[] modules = + [ + "111111100010101111111", "100000100000101000001", "101110101010001011101", + "101110100000101011101", "101110100101101011101", "100000100111001000001", + "111111101010101111111", "000000001010000000000", "111011111010111000100", + "001101001001010100010", "100000111111011101110", "110101011101110110010", + "101010111101011100001", "000000001000001000010", "111111101110100010001", + "100000101000001001011", "101110101110101011101", "101110100111010101110", + "101110101111011100101", "100000101001110111000", "111111101101011100101", + ]; + using Bitmap fixture = new(232, 232); + using (Graphics graphics = Graphics.FromImage(fixture)) + { + graphics.Clear(Color.White); + for (int y = 0; y < 21; y++) + for (int x = 0; x < 21; x++) + if (modules[y][x] == '1') + graphics.FillRectangle(Brushes.Black, (x + 4) * 8, (y + 4) * 8, 8, 8); + } + AssertPayload(fixture, "01234567"); + using Bitmap blank = new(64, 64); + using (Graphics graphics = Graphics.FromImage(blank)) + graphics.Clear(Color.White); + Assert.IsFalse(QrCodeRenderer.GetStringsFromBitmap(blank).Any()); + } + + private static void AssertVersion(string payload, int expected) + => Assert.AreEqual(expected, QrCodeRenderer.EncodeQrCode(payload, ErrorCorrectionLevel.L).Version.VersionNumber); + + private static void AssertPayload(Bitmap image, string expected) + { + var results = QrCodeRenderer.GetStringsFromBitmap(image).ToArray(); + Assert.HasCount(1, results); + Assert.AreEqual(expected, results[0].Item1); + Assert.AreEqual(BarcodeFormat.QR_CODE, results[0].Item2.BarcodeFormat); + } + + private static ErrorCorrectionLevel Level(string name) => name switch + { + "L" => ErrorCorrectionLevel.L, + "Q" => ErrorCorrectionLevel.Q, + "H" => ErrorCorrectionLevel.H, + _ => ErrorCorrectionLevel.M, + }; +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Rendering/QrPdfRendererTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Rendering/QrPdfRendererTests.cs new file mode 100644 index 0000000..157ae9e --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Rendering/QrPdfRendererTests.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; +using UglyToad.PdfPig; +using ZXing.QrCode.Internal; + +namespace Simple_QR_Code_Maker.Windows.Tests.Rendering; + +[TestClass] +public sealed class QrPdfRendererTests +{ + [TestMethod] + [DataRow(PrintPageType.Letter, PrintPageLayout.Portrait, 612.0, 792.0)] + [DataRow(PrintPageType.Letter, PrintPageLayout.Landscape, 792.0, 612.0)] + [DataRow(PrintPageType.A4, PrintPageLayout.Portrait, 595.0, 842.0)] + [DataRow(PrintPageType.A4, PrintPageLayout.Landscape, 842.0, 595.0)] + [DataRow(PrintPageType.Legal, PrintPageLayout.Portrait, 612.0, 1008.0)] + [DataRow(PrintPageType.A5, PrintPageLayout.Portrait, 420.0, 595.0)] + public async Task PdfPageCountsDimensionsImagesAndMetadataMatchPrintSettingsAsync(PrintPageType type, PrintPageLayout orientation, double width, double height) + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(); + RequestedQrCodeItem[] codes = Enumerable.Range(0, 5).Select(i => new RequestedQrCodeItem($"PDF item {i}")).ToArray(); + PrintJobSettings settings = new() + { + PageType = type, PageLayout = orientation, CodesPerPage = 2, FitAsManyAsPossible = false, + ShowLabels = false, CodeSizeMm = 30, MarginMm = 10, SpacingMm = 4, + }; + byte[] bytes = await QrPdfRenderer.GenerateQrPdfBytesAsync(codes, renderSettings, settings); + using PdfDocument document = PdfDocument.Open(bytes); + Assert.AreEqual(3, document.NumberOfPages); + Assert.AreEqual("QR Codes", document.Information.Title); + Assert.AreEqual("Simple QR Code Maker", document.Information.Creator); + int index = 0; + foreach (var page in document.GetPages()) + { + Assert.AreEqual(width, (double)page.Width, 0.01); + Assert.AreEqual(height, (double)page.Height, 0.01); + var images = page.GetImages().ToArray(); + Assert.HasCount(page.Number == 3 ? 1 : 2, images); + Assert.AreEqual(string.Empty, page.Text); + foreach (var image in images) + { + Assert.AreEqual(30 * 72 / 25.4, image.BoundingBox.Width, 0.02); + Assert.AreEqual(image.BoundingBox.Width, image.BoundingBox.Height, 0.02); + Assert.IsTrue(image.BoundingBox.Left >= 10 * 72 / 25.4); + Assert.IsTrue(image.BoundingBox.Right <= width - 10 * 72 / 25.4); + Assert.IsTrue(image.BoundingBox.Bottom >= 10 * 72 / 25.4); + Assert.IsTrue(image.BoundingBox.Top <= height - 10 * 72 / 25.4); + Assert.IsTrue(image.TryGetPng(out byte[]? png)); + using MemoryStream stream = new(png!); + using Bitmap bitmap = new(stream); + Assert.AreEqual(codes[index++].CodeAsText, QrCodeRenderer.GetStringsFromBitmap(bitmap).Single().Item1); + } + } + Assert.AreEqual(5, index, "Every requested QR must be embedded exactly once."); + } + + [TestMethod] + public async Task PdfImagesFollowTopLeftGridOrderAndStayInsideDistinctCellsAsync() + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(); + PrintJobSettings settings = new() + { + PageType = PrintPageType.Letter, PageLayout = PrintPageLayout.Portrait, + CodesPerPage = 4, FitAsManyAsPossible = false, ShowLabels = false, + MarginMm = 10, SpacingMm = 4, CodeSizeMm = 35, + }; + RequestedQrCodeItem[] codes = [new("northwest"), new("northeast"), new("southwest"), new("southeast")]; + using PdfDocument pdf = PdfDocument.Open(await QrPdfRenderer.GenerateQrPdfBytesAsync(codes, renderSettings, settings)); + var images = pdf.GetPage(1).GetImages().ToArray(); + Assert.HasCount(4, images); + Assert.IsTrue(images[0].BoundingBox.Right < images[1].BoundingBox.Left); + Assert.IsTrue(images[2].BoundingBox.Right < images[3].BoundingBox.Left); + Assert.IsTrue(images[0].BoundingBox.Bottom > images[2].BoundingBox.Top, "Top-left layout must be converted to bottom-left PDF coordinates."); + Assert.AreEqual(images[0].BoundingBox.Left, images[2].BoundingBox.Left, 0.01); + Assert.AreEqual(images[1].BoundingBox.Top, images[0].BoundingBox.Top, 0.01); + double margin = 10 * 72 / 25.4; + double spacing = 4 * 72 / 25.4; + double cellWidth = (612 - 2 * margin - spacing) / 2; + double cellHeight = (792 - 2 * margin - spacing) / 2; + double imageSide = 35 * 72 / 25.4; + Assert.AreEqual(margin + (cellWidth - imageSide) / 2, images[0].BoundingBox.Left, 0.02); + Assert.AreEqual(792 - margin - (cellHeight - imageSide) / 2, images[0].BoundingBox.Top, 0.02); + } + + [TestMethod] + public async Task PdfLabelsUseStructuredContentDisplayNamesAndExcludeRawPayloadAsync() + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(); + RequestedQrCodeItem[] codes = + [ + new("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zoë Example\r\nN:Example;Zoë;;;\r\nEND:VCARD", QrContentKind.VCard), + new("WIFI:T:WPA;S:Synthetic Network;P:secret-pass;;", QrContentKind.WiFi), + new("mailto:person@example.test?subject=Hello&body=Private", QrContentKind.Email), + new("Plain QR caption"), + ]; + PrintJobSettings settings = new() + { + PageType = PrintPageType.Letter, CodesPerPage = 4, FitAsManyAsPossible = false, + ShowLabels = true, CodeSizeMm = 30, + }; + using PdfDocument pdf = PdfDocument.Open(await QrPdfRenderer.GenerateQrPdfBytesAsync(codes, renderSettings, settings)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Zoë Example", text); + Assert.Contains("Synthetic Network", text); + Assert.Contains("Hello", text); // Email labels prefer the subject when one is present. + Assert.Contains("Plain QR caption", text); + Assert.IsFalse(text.Contains("BEGIN:VCARD", StringComparison.Ordinal)); + Assert.IsFalse(text.Contains("secret-pass", StringComparison.Ordinal)); + Assert.IsFalse(text.Contains("Private", StringComparison.Ordinal)); + Assert.IsTrue(pdf.GetPage(1).Letters.All(letter => letter.FontSize >= PrintLayoutHelper.MinimumLabelFontSizePoints - 0.01)); + } + + [TestMethod] + public async Task PdfWrapsAndShrinksLongLabelsWithoutDependingOnExactFontPixelsAsync() + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(); + string label = string.Join(" / ", Enumerable.Repeat("long synthetic label segment", 8)); + PrintJobSettings settings = new() + { + PageType = PrintPageType.A5, CodesPerPage = 16, FitAsManyAsPossible = false, + ShowLabels = true, CodeSizeMm = 20, + }; + using PdfDocument pdf = PdfDocument.Open(await QrPdfRenderer.GenerateQrPdfBytesAsync([new(label)], renderSettings, settings)); + var page = pdf.GetPage(1); + Assert.IsTrue(page.Letters.Count > 0); + Assert.IsTrue(page.Text.Length < label.Length); + Assert.IsTrue(page.Letters.All(letter => letter.FontSize >= 6 && letter.FontSize <= 10)); + Assert.IsTrue(page.Letters.Select(letter => Math.Round(letter.StartBaseLine.Y, 1)).Distinct().Count() <= 2); + Assert.IsTrue(page.Letters.All(letter => letter.BoundingBox.Left >= 0 && letter.BoundingBox.Right <= (double)page.Width)); + } + + [TestMethod] + [DataRow(QrFramePreset.BottomLabel)] + [DataRow(QrFramePreset.RoundedFrame)] + [DataRow(QrFramePreset.CornerCallout)] + public async Task PdfPreservesFramedImageAspectRatioAndQrPayloadAsync(QrFramePreset frame) + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(frame); + PrintJobSettings settings = new() { PageType = PrintPageType.Letter, CodesPerPage = 1, FitAsManyAsPossible = false, ShowLabels = false, CodeSizeMm = 35 }; + using PdfDocument pdf = PdfDocument.Open(await QrPdfRenderer.GenerateQrPdfBytesAsync([new("framed PDF")], renderSettings, settings)); + var image = pdf.GetPage(1).GetImages().Single(); + var metrics = QrCodeRenderer.GetQrImageLayoutMetrics("framed PDF", ErrorCorrectionLevel.H, 2, frame); + Assert.AreEqual(metrics.CanvasWidth / metrics.CanvasHeight, image.BoundingBox.Width / image.BoundingBox.Height, 0.001); + Assert.AreEqual(35 * 72 / 25.4 * metrics.WidthPerQrSize, image.BoundingBox.Width, 0.02); + Assert.IsTrue(image.TryGetPng(out byte[]? bytes)); + using MemoryStream stream = new(bytes!); + using Bitmap bitmap = new(stream); + Assert.AreEqual("framed PDF", QrCodeRenderer.GetStringsFromBitmap(bitmap).Single().Item1); + } + + [TestMethod] + public async Task PdfAutoFitUsesActualFrameProfileRatherThanSquareAssumptionAsync() + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(QrFramePreset.CornerCallout); + PrintJobSettings settings = new() + { + PageType = PrintPageType.A5, FitAsManyAsPossible = true, ShowLabels = true, + MarginMm = 10, CodeSizeMm = 40, SpacingMm = 4, + }; + RequestedQrCodeItem[] codes = Enumerable.Range(0, 9).Select(i => new RequestedQrCodeItem($"Auto-fit {i}")).ToArray(); + var metrics = QrCodeRenderer.GetQrImageLayoutMetrics(codes[0].CodeAsText, ErrorCorrectionLevel.H, 2, QrFramePreset.CornerCallout); + var layout = PrintLayoutHelper.CreateMetrics(settings, metrics); + using PdfDocument pdf = PdfDocument.Open(await QrPdfRenderer.GenerateQrPdfBytesAsync(codes, renderSettings, settings)); + Assert.AreEqual((int)Math.Ceiling(9.0 / layout.CodesPerPage), pdf.NumberOfPages); + Assert.AreEqual(9, pdf.GetPages().Sum(page => page.GetImages().Count())); + } + + [TestMethod] + public async Task PreCancelledPdfDoesNotEnumerateOrRenderCodesAsync() + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + await Assert.ThrowsExactlyAsync(() => QrPdfRenderer.GenerateQrPdfBytesAsync( + new CancellingCodes(cancellation, throwOnEnumeration: true), renderSettings, new(), cancellation.Token)); + } + + [TestMethod] + public async Task PdfHonorsCancellationBetweenRenderedItemsAsync() + { + using QrRenderSettingsSnapshot renderSettings = CreateRenderSettings(); + using CancellationTokenSource cancellation = new(); + Task operation = QrPdfRenderer.GenerateQrPdfBytesAsync(new CancellingCodes(cancellation), renderSettings, new() { ShowLabels = false }, cancellation.Token); + var exception = await Assert.ThrowsExactlyAsync(() => operation); + Assert.AreEqual(cancellation.Token, exception.CancellationToken); + Assert.IsTrue(operation.IsCanceled); + } + + [TestMethod] + public void RendererAssemblyDoesNotDependOnWinUi() + { + Assert.IsFalse(typeof(QrPdfRenderer).Assembly.GetReferencedAssemblies().Any(reference => + reference.Name is "Microsoft.WinUI" or "Microsoft.WindowsAppSDK" or "Microsoft.UI.Xaml")); + } + + private static QrRenderSettingsSnapshot CreateRenderSettings(QrFramePreset frame = QrFramePreset.None) + => QrRenderSettingsSnapshot.Create(ErrorCorrectionLevel.H, Color.Black, Color.White, null, 15, 4, null, 2, frame, QrFrameTextSource.Manual, "Scan me"); + + private sealed class CancellingCodes(CancellationTokenSource cancellation, bool throwOnEnumeration = false) : IReadOnlyList + { + public int Count => 2; + public RequestedQrCodeItem this[int index] => new($"cancel-{index}"); + + public IEnumerator GetEnumerator() + { + if (throwOnEnumeration) + throw new AssertFailedException("A pre-cancelled operation must not enumerate input."); + yield return this[0]; + cancellation.Cancel(); + yield return this[1]; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Rendering/SizeRecommendationTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Rendering/SizeRecommendationTests.cs new file mode 100644 index 0000000..ea3f1e4 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Rendering/SizeRecommendationTests.cs @@ -0,0 +1,91 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using Windows.UI; + +namespace Simple_QR_Code_Maker.Windows.Tests.Rendering; + +[TestClass] +public sealed class SizeRecommendationTests +{ + private static Color Black => Color.FromArgb(255, 0, 0, 0); + private static Color White => Color.FromArgb(255, 255, 255, 255); + + [TestMethod] + public void ContrastMatchesKnownWcagRatiosAndIsSymmetric() + { + Assert.AreEqual(21, ColorHelpers.GetContrastRatio(Black, White), 1e-10); + Assert.AreEqual(1, ColorHelpers.GetContrastRatio(Black, Black), 1e-10); + Color gray = Color.FromArgb(255, 128, 128, 128); + Assert.AreEqual(3.94943965, ColorHelpers.GetContrastRatio(gray, White), 1e-6); + Assert.AreEqual(ColorHelpers.GetContrastRatio(gray, White), ColorHelpers.GetContrastRatio(White, gray), 1e-10); + Assert.AreEqual(1, QrCodeRenderer.ContrastRatioLossFrac(21), 1e-10); + Assert.AreEqual(0.8, QrCodeRenderer.ContrastRatioLossFrac(2.5), 1e-10); + } + + [TestMethod] + [DataRow(0, false)] + [DataRow(1, true)] + [DataRow(2, true)] + [DataRow(3, true)] + [DataRow(4, true)] + [DataRow(5, false)] + [DataRow(10, false)] + public void OnlyPredictableQuietZonesHaveExactRecommendations(int padding, bool expected) + => Assert.AreEqual(expected, QrCodeRenderer.IsSizeRecommendationAvailableForPadding(padding)); + + [TestMethod] + public void MinimumSizeMatchesFormulaGrowsWithDistanceModulesPaddingAndContrastLoss() + { + const double distance = 64; + double expectedInches = (distance + 2.721) / 1759.1 * 25; + Assert.AreEqual(expectedInches, QrCodeRenderer.SmallestCodeSide(distance, 21), 1e-10); + Assert.AreEqual(0, QrCodeRenderer.SmallestCodeSide(1, 21)); + Assert.IsTrue(QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(distance, 21, Black, White, out double minimum)); + Assert.AreEqual(expectedInches * 25.4, minimum, 1e-10); + Assert.IsTrue(QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(128, 21, Black, White, out double farther)); + Assert.IsTrue(farther > minimum); + Assert.IsTrue(QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(distance, 41, Black, White, out double denser)); + Assert.IsTrue(denser > minimum); + Assert.IsTrue(QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(distance, 21, Black, White, out double padded, 4)); + Assert.IsTrue(padded > minimum); + Assert.IsTrue(QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(distance, 21, Color.FromArgb(255, 100, 100, 100), White, out double reducedContrast)); + Assert.IsTrue(reducedContrast > minimum); + Assert.IsTrue(QrCodeRenderer.GetSizeRecommendation(distance, 21, Black, White).IsExact); + } + + [TestMethod] + public void RecommendationClassifiesTransparencyPaddingLowContrastAndShortDistance() + { + AssertKind(QrCodeSizeRecommendationKind.TransparencyDependent, Color.FromArgb(254, 0, 0, 0), White, 2, 64); + AssertKind(QrCodeSizeRecommendationKind.TransparencyDependent, Black, Color.FromArgb(0, 255, 255, 255), 2, 64); + AssertKind(QrCodeSizeRecommendationKind.PaddingDependent, Black, White, 0, 64); + AssertKind(QrCodeSizeRecommendationKind.PaddingDependent, Black, White, 5, 64); + AssertKind(QrCodeSizeRecommendationKind.LowContrast, White, White, 2, 64); + AssertKind(QrCodeSizeRecommendationKind.Error, Black, White, 2, 1); + } + + [TestMethod] + [DataRow(double.NaN, 21)] + [DataRow(double.PositiveInfinity, 21)] + [DataRow(-1.0, 21)] + [DataRow(64.0, 0)] + [DataRow(64.0, -1)] + public void InvalidDistanceOrModuleCountsNeverProduceExactNanRecommendations(double distance, int modules) + { + Assert.AreEqual(0, QrCodeRenderer.SmallestCodeSide(distance, modules)); + Assert.IsFalse(QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(distance, modules, Black, White, out double side)); + Assert.AreEqual(0, side); + Assert.AreEqual(QrCodeSizeRecommendationKind.Error, QrCodeRenderer.GetSizeRecommendation(distance, modules, Black, White).Kind); + } + + private static void AssertKind(QrCodeSizeRecommendationKind expected, Color foreground, Color background, double padding, double distance) + { + var recommendation = QrCodeRenderer.GetSizeRecommendation(distance, 21, foreground, background, padding); + Assert.AreEqual(expected, recommendation.Kind); + Assert.IsFalse(recommendation.IsExact); + Assert.IsFalse(string.IsNullOrWhiteSpace(recommendation.Text)); + Assert.IsFalse(QrCodeRenderer.TryGetMinimumRecommendedSideMillimeters(distance, 21, foreground, background, out double size, padding)); + Assert.AreEqual(0, size); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Serialization/ModelSerializationTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Serialization/ModelSerializationTests.cs new file mode 100644 index 0000000..404e884 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Serialization/ModelSerializationTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.ObjectModel; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Models; +using Windows.UI; +using ZXing; + +namespace Simple_QR_Code_Maker.Windows.Tests.Serialization; + +[TestClass] +public sealed class ModelSerializationTests +{ + [TestMethod] + public void HistoryUsesRealGeneratedMetadataAndPreservesCompleteDesign() + { + HistoryItem original = new() + { + CodesContent = "BEGIN:VCARD\r\nFN:Zoë 😀\r\nEND:VCARD", + SavedDateTime = new DateTime(2024, 2, 29, 12, 34, 56, DateTimeKind.Utc), + ContentKind = QrContentKind.VCard, + MultiLineCodeModeOverride = MultiLineCodeMode.MultilineOneCode, + CommonBrandCodes = ["https://example.test/α", "two"], + Foreground = Color.FromArgb(128, 12, 34, 56), + Background = Color.FromArgb(0, 210, 220, 230), + ErrorCorrectionLevelAsString = "H", + Format = BarcodeFormat.QR_CODE, + LogoImagePath = @"C:\synthetic\logo.png", + LogoEmoji = "😀", + LogoEmojiStyle = EmojiLogoStyle.ThreeDimensional, + LogoSizePercentage = 17.5, + LogoPaddingPixels = -2, + FramePreset = QrFramePreset.CornerCallout, + FrameTextSource = QrFrameTextSource.Manual, + FrameText = "Scan ", + }; + string json = JsonSerializer.Serialize(original, HistoryJsonContext.Default.HistoryItem); + using JsonDocument document = JsonDocument.Parse(json); + Assert.AreEqual("#800C2238", document.RootElement.GetProperty("Foreground").GetString()); + Assert.AreEqual("#00D2DCE6", document.RootElement.GetProperty("Background").GetString()); + Assert.AreEqual("VCard", document.RootElement.GetProperty("ContentKind").GetString()); + Assert.AreEqual("CornerCallout", document.RootElement.GetProperty("FramePreset").GetString()); + Assert.IsFalse(document.RootElement.TryGetProperty("DisplayText", out _)); + Assert.IsFalse(document.RootElement.TryGetProperty("ErrorCorrection", out _)); + HistoryItem restored = JsonSerializer.Deserialize(json, HistoryJsonContext.Default.HistoryItem)!; + Assert.AreEqual(json, JsonSerializer.Serialize(restored, HistoryJsonContext.Default.HistoryItem)); + Assert.AreEqual(original.SavedDateTime, restored.SavedDateTime); + Assert.AreEqual(original.CodesContent, restored.CodesContent); + CollectionAssert.AreEqual(original.CommonBrandCodes, restored.CommonBrandCodes); + Assert.AreEqual(original.Foreground, restored.Foreground); + Assert.AreEqual(original.Background, restored.Background); + Assert.AreEqual(original.LogoEmojiStyle, restored.LogoEmojiStyle); + Assert.AreEqual(original.FrameText, restored.FrameText); + Assert.AreSame(HistoryJsonContext.Default, HistoryJsonSerializerOptions.Options.TypeInfoResolver); + Assert.AreEqual(original.CodesContent, JsonSerializer.Deserialize(json, HistoryJsonSerializerOptions.Options)!.CodesContent); + } + + [TestMethod] + public void HistoricalMissingAndUnknownFieldsKeepBackwardCompatibleDefaults() + { + const string json = """{"CodesContent":"old payload","Foreground":"#FF123456","UnknownFutureField":{"Enabled":true}}"""; + HistoryItem history = JsonSerializer.Deserialize(json, HistoryJsonContext.Default.HistoryItem)!; + Assert.AreEqual("old payload", history.CodesContent); + Assert.AreEqual(QrContentKind.PlainText, history.ContentKind); + Assert.IsNull(history.MultiLineCodeModeOverride); + Assert.HasCount(0, history.CommonBrandCodes); + Assert.AreEqual(Color.FromArgb(255, 255, 255, 255), history.Background); + Assert.AreEqual("M", history.ErrorCorrection.ToString()); + Assert.AreEqual(15, history.LogoSizePercentage); + Assert.AreEqual(4, history.LogoPaddingPixels); + Assert.IsNull(history.LogoEmojiStyle); + Assert.AreEqual(QrFramePreset.None, history.FramePreset); + Assert.AreEqual(QrFrameTextSource.Manual, history.FrameTextSource); + BrandItem brand = JsonSerializer.Deserialize("""{"Name":"Legacy","IsDefault":true}""", BrandJsonContext.Default.BrandItem)!; + Assert.AreEqual("Legacy", brand.Name); + Assert.IsTrue(brand.IsDefault); + Assert.IsNull(brand.Foreground); + Assert.IsNull(brand.Background); + Assert.IsNull(brand.FramePreset); + Assert.IsNull(brand.LogoPaddingPixels); + } + + [TestMethod] + public void BrandPreservesNullsAlphaEnumsAndOptionalValuesThroughGeneratedContext() + { + BrandItem original = new() + { + Name = "Synthetic", + CreatedDateTime = new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Foreground = Color.FromArgb(1, 2, 3, 4), + Background = null, + UrlContent = "https://example.test", + ContentKind = QrContentKind.Email, + MultiLineCodeModeOverride = MultiLineCodeMode.OneLineOneCode, + ErrorCorrectionLevelAsString = null, + LogoEmoji = "🌈", + LogoEmojiStyle = EmojiLogoStyle.Flat, + LogoSizePercentage = 12.25, + LogoPaddingPixels = 0, + FramePreset = QrFramePreset.None, + FrameText = null, + IsDefault = true, + }; + string json = JsonSerializer.Serialize(original, BrandJsonContext.Default.BrandItem); + using JsonDocument document = JsonDocument.Parse(json); + Assert.AreEqual("#01020304", document.RootElement.GetProperty("Foreground").GetString()); + Assert.AreEqual(JsonValueKind.Null, document.RootElement.GetProperty("Background").ValueKind); + Assert.AreEqual("Flat", document.RootElement.GetProperty("LogoEmojiStyle").GetString()); + Assert.AreEqual("None", document.RootElement.GetProperty("FramePreset").GetString()); + BrandItem copy = JsonSerializer.Deserialize(json, BrandJsonSerializerOptions.Options)!; + Assert.AreEqual(original.Foreground, copy.Foreground); + Assert.IsNull(copy.Background); + Assert.AreEqual(json, JsonSerializer.Serialize(copy, BrandJsonContext.Default.BrandItem)); + Assert.AreSame(BrandJsonContext.Default, BrandJsonSerializerOptions.Options.TypeInfoResolver); + } + + [TestMethod] + [DataRow("#00000000", 0, 0, 0, 0)] + [DataRow("#FFffffff", 255, 255, 255, 255)] + [DataRow("#80402010", 128, 64, 32, 16)] + [DataRow("short", 255, 0, 0, 0)] + [DataRow("", 255, 0, 0, 0)] + public void ColorConverterHandlesAlphaCaseAndLegacyInvalidLengths(string value, int a, int r, int g, int b) + { + string json = $$"""{"Foreground":"{{value}}"}"""; + HistoryItem history = JsonSerializer.Deserialize(json, HistoryJsonContext.Default.HistoryItem)!; + Assert.AreEqual(Color.FromArgb((byte)a, (byte)r, (byte)g, (byte)b), history.Foreground); + BrandItem brand = JsonSerializer.Deserialize(json, BrandJsonContext.Default.BrandItem)!; + Assert.AreEqual(history.Foreground, brand.Foreground); + } + + [TestMethod] + public void NullableColorNullRemainsNullAndCollectionsUseGeneratedMetadata() + { + BrandItem brand = JsonSerializer.Deserialize("""{"Foreground":null,"Background":null}""", BrandJsonContext.Default.BrandItem)!; + Assert.IsNull(brand.Foreground); + Assert.IsNull(brand.Background); + ObservableCollection brands = [brand, new() { Name = "second", Foreground = Color.FromArgb(255, 5, 6, 7) }]; + string json = JsonSerializer.Serialize(brands, BrandJsonContext.Default.ObservableCollectionBrandItem); + var brandCopy = JsonSerializer.Deserialize(json, BrandJsonContext.Default.ObservableCollectionBrandItem)!; + Assert.HasCount(2, brandCopy); + Assert.IsNull(brandCopy[0].Foreground); + ObservableCollection histories = [new() { CodesContent = "one" }, new() { CodesContent = "two" }]; + json = JsonSerializer.Serialize(histories, HistoryJsonContext.Default.ObservableCollectionHistoryItem); + Assert.AreEqual("two", JsonSerializer.Deserialize(json, HistoryJsonContext.Default.ObservableCollectionHistoryItem)![1].CodesContent); + } + + [TestMethod] + public void AllEnumValuesAndNullableVariantsRoundTripWithoutReflection() + { + foreach (QrContentKind kind in Enum.GetValues()) + { + HistoryItem value = new() { ContentKind = kind }; + string json = JsonSerializer.Serialize(value, HistoryJsonContext.Default.HistoryItem); + Assert.AreEqual(kind, JsonSerializer.Deserialize(json, HistoryJsonContext.Default.HistoryItem)!.ContentKind); + } + foreach (EmojiLogoStyle style in Enum.GetValues()) + { + BrandItem value = new() { LogoEmojiStyle = style }; + string json = JsonSerializer.Serialize(value, BrandJsonContext.Default.BrandItem); + Assert.AreEqual(style, JsonSerializer.Deserialize(json, BrandJsonContext.Default.BrandItem)!.LogoEmojiStyle); + } + foreach (QrFramePreset frame in Enum.GetValues()) + { + foreach (QrFrameTextSource source in Enum.GetValues()) + { + HistoryItem value = new() { FramePreset = frame, FrameTextSource = source }; + string json = JsonSerializer.Serialize(value, HistoryJsonContext.Default.HistoryItem); + HistoryItem copy = JsonSerializer.Deserialize(json, HistoryJsonContext.Default.HistoryItem)!; + Assert.AreEqual(frame, copy.FramePreset); + Assert.AreEqual(source, copy.FrameTextSource); + } + } + Assert.ThrowsExactly(() => JsonSerializer.Deserialize("""{"ContentKind":"NotAContentKind"}""", HistoryJsonContext.Default.HistoryItem)); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize("""{"LogoEmojiStyle":"NotAStyle"}""", BrandJsonContext.Default.BrandItem)); + } + + [TestMethod] + public void DecodingHistoryPersistsOnlyStoredDataAndSupportsOldDocuments() + { + foreach (DecodingSourceKind source in Enum.GetValues()) + { + DecodingHistoryItem item = new() + { + SourceKind = source, + SavedImagePath = "synthetic.png", + SourceFilePath = source == DecodingSourceKind.File ? @"C:\synthetic\source.png" : null, + DecodedTexts = ["one", "漢字 😀"], + SavedDateTime = new DateTime(2021, 10, 11, 12, 13, 14, DateTimeKind.Utc), + }; + string json = JsonSerializer.Serialize(item, DecodingHistoryJsonContext.Default.DecodingHistoryItem); + using JsonDocument document = JsonDocument.Parse(json); + Assert.AreEqual(source.ToString(), document.RootElement.GetProperty("SourceKind").GetString()); + Assert.IsFalse(document.RootElement.TryGetProperty("HasSourceFile", out _)); + Assert.IsFalse(document.RootElement.TryGetProperty("SaveDateAsString", out _)); + Assert.IsFalse(document.RootElement.TryGetProperty("FirstDecodedText", out _)); + DecodingHistoryItem copy = JsonSerializer.Deserialize(json, DecodingHistoryJsonSerializerOptions.Options)!; + CollectionAssert.AreEqual(item.DecodedTexts, copy.DecodedTexts); + Assert.AreEqual(source, copy.SourceKind); + Assert.AreEqual(item.SavedDateTime, copy.SavedDateTime); + } + DecodingHistoryItem legacy = JsonSerializer.Deserialize("{}", DecodingHistoryJsonContext.Default.DecodingHistoryItem)!; + Assert.AreEqual(DecodingSourceKind.File, legacy.SourceKind); + Assert.HasCount(0, legacy.DecodedTexts); + Assert.AreEqual(string.Empty, legacy.SavedImagePath); + Assert.IsNull(legacy.SourceFilePath); + ObservableCollection items = [legacy]; + string collectionJson = JsonSerializer.Serialize(items, DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem); + Assert.HasCount(1, JsonSerializer.Deserialize(collectionJson, DecodingHistoryJsonContext.Default.ObservableCollectionDecodingHistoryItem)!); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Services/BrandServiceTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Services/BrandServiceTests.cs new file mode 100644 index 0000000..6822d13 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Services/BrandServiceTests.cs @@ -0,0 +1,101 @@ +using System.Collections.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; + +namespace Simple_QR_Code_Maker.Windows.Tests.Services; + +[TestClass] +public sealed class BrandServiceTests +{ + [TestMethod] + public async Task LoadReplacesExistingItemsInPersistedOrder() + { + FakeBrandStore store = new() { Loaded = [new() { Name = "second" }, new() { Name = "first" }] }; + ObservableCollection items = [new() { Name = "stale" }]; + await new BrandService(store).LoadAsync(items); + CollectionAssert.AreEqual(new[] { "second", "first" }, items.Select(item => item.Name).ToArray()); + Assert.AreEqual(0, store.SaveCount); + } + + [TestMethod] + public async Task AddReplacesCaseInsensitiveIdentityAndMovesReplacementToFront() + { + FakeBrandStore store = new(); + BrandItem other = new() { Name = "other" }; + ObservableCollection items = [other, new() { Name = "EXAMPLE", UrlContent = "old" }]; + BrandItem replacement = new() { Name = "example", UrlContent = "new" }; + await new BrandService(store).AddOrReplaceAndSaveAsync(items, replacement); + Assert.HasCount(2, items); + Assert.AreSame(replacement, items[0]); + Assert.AreSame(other, items[1]); + Assert.AreSame(items, store.Saved); + Assert.AreEqual(1, store.SaveCount); + } + + [TestMethod] + public async Task DeleteUsesCaseInsensitiveIdentity() + { + FakeBrandStore store = new(); + ObservableCollection items = [new() { Name = "FIRST" }, new() { Name = "second" }]; + await new BrandService(store).DeleteAndSaveAsync(items, new() { Name = "first" }); + Assert.HasCount(1, items); + Assert.AreEqual("second", items[0].Name); + Assert.AreEqual(1, store.SaveCount); + } + + [TestMethod] + public async Task DefaultSelectionClearsOtherDefaultsAndSecondSelectionTogglesOff() + { + FakeBrandStore store = new(); + BrandItem first = new() { Name = "first", IsDefault = true }; + BrandItem second = new() { Name = "second" }; + ObservableCollection items = [first, second]; + BrandService service = new(store); + await service.SetDefaultAndSaveAsync(items, second); + Assert.IsFalse(first.IsDefault); + Assert.IsTrue(second.IsDefault); + CollectionAssert.AreEqual(new[] { first, second }, items.ToArray()); + await service.SetDefaultAndSaveAsync(items, second); + Assert.IsFalse(items.Any(item => item.IsDefault)); + Assert.AreEqual(2, store.SaveCount); + } + + [TestMethod] + public async Task ReplaceMaintainsPositionAndReturnsFalseWithoutSavingWhenMissing() + { + FakeBrandStore store = new(); + ObservableCollection items = [new() { Name = "first" }, new() { Name = "SECOND" }]; + BrandItem replacement = new() { Name = "renamed" }; + BrandService service = new(store); + Assert.IsTrue(await service.ReplaceAndSaveAsync(items, new() { Name = "second" }, replacement)); + Assert.AreSame(replacement, items[1]); + Assert.IsFalse(await service.ReplaceAndSaveAsync(items, new() { Name = "absent" }, new())); + Assert.AreEqual(1, store.SaveCount); + } + + [TestMethod] + public async Task PersistenceFailureRemainsObservable() + { + FakeBrandStore store = new() { Failure = new IOException("Write failed.") }; + await Assert.ThrowsExactlyAsync(() => new BrandService(store).AddOrReplaceAndSaveAsync([], new())); + } + + private sealed class FakeBrandStore : IBrandStore + { + public ObservableCollection Loaded { get; set; } = []; + public ObservableCollection? Saved { get; private set; } + public int SaveCount { get; private set; } + public Exception? Failure { get; init; } + + public Task> LoadAsync() => Task.FromResult(Loaded); + + public Task SaveAsync(ObservableCollection items) + { + SaveCount++; + Saved = items; + return Failure is null ? Task.CompletedTask : Task.FromException(Failure); + } + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Services/HistoryServiceTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Services/HistoryServiceTests.cs new file mode 100644 index 0000000..f308a14 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Services/HistoryServiceTests.cs @@ -0,0 +1,102 @@ +using System.Collections.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; + +namespace Simple_QR_Code_Maker.Windows.Tests.Services; + +[TestClass] +public sealed class HistoryServiceTests +{ + [TestMethod] + public async Task LoadReplacesCollectionWithoutReordering() + { + FakeHistoryStore store = new() { Loaded = [new() { CodesContent = "newest" }, new() { CodesContent = "oldest" }] }; + ObservableCollection items = [new() { CodesContent = "stale" }]; + await new HistoryService(store).LoadAsync(items); + CollectionAssert.AreEqual(new[] { "newest", "oldest" }, items.Select(item => item.CodesContent).ToArray()); + } + + [TestMethod] + public async Task ContentIdentityReplacesHistoryAndMovesUpdatedDesignToFront() + { + FakeHistoryStore store = new(); + HistoryItem other = new() { CodesContent = "other" }; + ObservableCollection items = [other, new() { CodesContent = "same", LogoSizePercentage = 1 }]; + HistoryItem replacement = new() { CodesContent = "same", LogoSizePercentage = 30 }; + await new HistoryService(store).AddOrReplaceAndSaveAsync(items, replacement); + Assert.HasCount(2, items); + Assert.AreSame(replacement, items[0]); + Assert.AreSame(other, items[1]); + Assert.AreSame(items, store.Saved); + } + + [TestMethod] + public async Task ContentIdentityRemainsCaseSensitive() + { + FakeHistoryStore store = new(); + ObservableCollection items = [new() { CodesContent = "CASE" }]; + await new HistoryService(store).AddOrReplaceAndSaveAsync(items, new() { CodesContent = "case" }); + Assert.HasCount(2, items); + } + + [TestMethod] + public async Task SavePropagatesPersistenceFailure() + { + FakeHistoryStore store = new() { Failure = new IOException("Write failed.") }; + await Assert.ThrowsExactlyAsync(() => new HistoryService(store).SaveAsync([])); + } + + [TestMethod] + public async Task ShutdownSnapshotIsIndependentOfCollectionItemsAndMutableArrays() + { + FakeHistoryStore store = new() { Release = new() }; + HistoryService service = new(store); + HistoryItem current = new() { CodesContent = "current", CommonBrandCodes = ["current original"], FrameText = "frame" }; + HistoryItem previous = new() { CodesContent = "previous", CommonBrandCodes = ["previous original"] }; + ObservableCollection items = [previous, new() { CodesContent = "current" }]; + service.SaveSnapshotOnShutdown(items, current); + Assert.IsFalse(service.PendingShutdownSave.IsCompleted); + current.CodesContent = "mutated"; + current.FrameText = "changed"; + current.CommonBrandCodes[0] = "changed"; + previous.CommonBrandCodes[0] = "changed"; + items.Clear(); + Assert.IsNotNull(store.Saved); + CollectionAssert.AreEqual(new[] { "current", "previous" }, store.Saved.Select(item => item.CodesContent).ToArray()); + Assert.AreEqual("frame", store.Saved[0].FrameText); + Assert.AreEqual("current original", store.Saved[0].CommonBrandCodes[0]); + Assert.AreEqual("previous original", store.Saved[1].CommonBrandCodes[0]); + Assert.AreNotSame(current, store.Saved[0]); + store.Release.SetResult(); + await service.PendingShutdownSave; + } + + [TestMethod] + public async Task ShutdownFailureIsExposedByRetainedTask() + { + HistoryService service = new(new FakeHistoryStore { Failure = new IOException("Shutdown write failed.") }); + service.SaveSnapshotOnShutdown([], new() { CodesContent = "current" }); + await Assert.ThrowsExactlyAsync(() => service.PendingShutdownSave); + } + + private sealed class FakeHistoryStore : IHistoryStore + { + public ObservableCollection Loaded { get; set; } = []; + public ObservableCollection? Saved { get; private set; } + public TaskCompletionSource? Release { get; init; } + public Exception? Failure { get; init; } + + public Task> LoadAsync() => Task.FromResult(Loaded); + + public async Task SaveAsync(ObservableCollection items) + { + Saved = items; + if (Release is not null) + await Release.Task; + if (Failure is not null) + throw Failure; + } + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Services/LocalSettingsServiceTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Services/LocalSettingsServiceTests.cs new file mode 100644 index 0000000..fd0eaaf --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Services/LocalSettingsServiceTests.cs @@ -0,0 +1,95 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Models; +using Simple_QR_Code_Maker.Services; +using Simple_QR_Code_Maker.Windows.Tests.Persistence; + +namespace Simple_QR_Code_Maker.Windows.Tests.Services; + +[TestClass] +public sealed class LocalSettingsServiceTests +{ + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task TypedSettingsRoundTripUsesOnlySelectedStoragePolicy(bool packaged) + { + MemorySettingsStore packagedStore = new(); + MemorySettingsStore fileStore = new(); + LocalSettingsService service = new(new FakeRuntime(packaged), packagedStore, fileStore); + await service.SaveSettingAsync("bool", true); + await service.SaveSettingAsync("nullable", (bool?)false); + await service.SaveSettingAsync("null", (int?)null); + await service.SaveSettingAsync("int", 42); + await service.SaveSettingAsync("double", 12.5); + await service.SaveSettingAsync("string", "é \"quoted\" \\ value"); + await service.SaveSettingAsync("array", new[] { "one", "two" }); + await service.SaveSettingAsync("enum", QrFramePreset.None); + Assert.IsTrue(await service.ReadSettingAsync("bool")); + Assert.IsFalse(await service.ReadSettingAsync("nullable")); + Assert.IsNull(await service.ReadSettingAsync("null")); + Assert.AreEqual(42, await service.ReadSettingAsync("int")); + Assert.AreEqual(12.5, await service.ReadSettingAsync("double")); + Assert.AreEqual("é \"quoted\" \\ value", await service.ReadSettingAsync("string")); + CollectionAssert.AreEqual(new[] { "one", "two" }, await service.ReadSettingAsync("array")); + Assert.AreEqual(QrFramePreset.None, await service.ReadSettingAsync("enum")); + MemorySettingsStore selected = packaged ? packagedStore : fileStore; + MemorySettingsStore unused = packaged ? fileStore : packagedStore; + Assert.HasCount(8, selected.Values); + Assert.IsEmpty(unused.Values); + Assert.AreEqual(0, unused.Reads); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task MissingSettingsReturnTypeDefaultsWithoutPersisting(bool packaged) + { + MemorySettingsStore store = new(); + LocalSettingsService service = new(new FakeRuntime(packaged), store, store); + Assert.IsNull(await service.ReadSettingAsync("missing")); + Assert.IsNull(await service.ReadSettingAsync("missing")); + Assert.AreEqual(0, await service.ReadSettingAsync("missing")); + Assert.IsEmpty(store.Values); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task MalformedTypedDataIsObservableAndDoesNotFallBackToOtherPolicy(bool packaged) + { + MemorySettingsStore selected = new(); + selected.Values["bad"] = "not JSON"; + MemorySettingsStore other = new(); + other.Values["bad"] = "true"; + LocalSettingsService service = packaged + ? new(new FakeRuntime(true), selected, other) + : new(new FakeRuntime(false), other, selected); + await Assert.ThrowsExactlyAsync(() => service.ReadSettingAsync("bad")); + Assert.AreEqual(0, other.Reads); + } + + [TestMethod] + public async Task UnsupportedTypesFailWithoutReflectionFallback() + { + MemorySettingsStore store = new(); + LocalSettingsService service = new(new FakeRuntime(true), store, store); + await Assert.ThrowsExactlyAsync(() => service.SaveSettingAsync("unsupported", DateTime.UnixEpoch)); + Assert.IsEmpty(store.Values); + } + + [TestMethod] + public async Task StorageFailuresRemainObservable() + { + MemorySettingsStore store = new() { Failure = new IOException("Settings unavailable.") }; + LocalSettingsService service = new(new FakeRuntime(false), store, store); + await Assert.ThrowsExactlyAsync(() => service.ReadSettingAsync("key")); + await Assert.ThrowsExactlyAsync(() => service.SaveSettingAsync("key", true)); + } + + private sealed class FakeRuntime(bool packaged) : IRuntimeEnvironment + { + public bool IsPackaged => packaged; + public string LocalApplicationDataPath => throw new AssertFailedException("A typed settings policy must not access live paths."); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Services/RedirectorWarningSettingsTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Services/RedirectorWarningSettingsTests.cs new file mode 100644 index 0000000..7e98254 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Services/RedirectorWarningSettingsTests.cs @@ -0,0 +1,47 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Contracts.Services; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Services; +using Simple_QR_Code_Maker.Windows.Tests.Persistence; + +namespace Simple_QR_Code_Maker.Windows.Tests.Services; + +[TestClass] +public sealed class RedirectorWarningSettingsTests +{ + private static LocalSettingsService CreateService() => + new(new FakeRuntime(), new MemorySettingsStore(), new MemorySettingsStore()); + + [TestMethod] + public async Task MissingSettingsKeepWarningEnabledAndSafeListEmpty() + { + LocalSettingsService service = CreateService(); + Assert.IsTrue(await RedirectorWarningSettingsHelper.ReadWarningEnabledAsync(service)); + Assert.IsEmpty(await RedirectorWarningSettingsHelper.ReadSafeDomainsAsync(service)); + } + + [TestMethod] + public async Task ExplicitWarningPreferenceRoundTrips() + { + LocalSettingsService service = CreateService(); + await RedirectorWarningSettingsHelper.SaveWarningEnabledAsync(service, false); + Assert.IsFalse(await RedirectorWarningSettingsHelper.ReadWarningEnabledAsync(service)); + await RedirectorWarningSettingsHelper.SaveWarningEnabledAsync(service, true); + Assert.IsTrue(await RedirectorWarningSettingsHelper.ReadWarningEnabledAsync(service)); + } + + [TestMethod] + public async Task SafeDomainsAreNormalizedDeduplicatedAndBlankValuesRemoved() + { + LocalSettingsService service = CreateService(); + await RedirectorWarningSettingsHelper.SaveSafeDomainsAsync(service, [" EXAMPLE.COM ", "example.com", "www.other.example.", "", " "]); + IReadOnlyList hosts = await RedirectorWarningSettingsHelper.ReadSafeDomainsAsync(service); + CollectionAssert.AreEquivalent(new[] { "example.com", "other.example" }, hosts.ToArray()); + } + + private sealed class FakeRuntime : IRuntimeEnvironment + { + public bool IsPackaged => false; + public string LocalApplicationDataPath => throw new AssertFailedException("Unexpected live path access."); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Simple QR Code Maker.Windows.Tests.csproj b/tests/Simple QR Code Maker.Windows.Tests/Simple QR Code Maker.Windows.Tests.csproj new file mode 100644 index 0000000..4a7d229 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Simple QR Code Maker.Windows.Tests.csproj @@ -0,0 +1,10 @@ + + + net10.0-windows10.0.26100.0 + 10.0.19041.0 + Simple_QR_Code_Maker.Windows.Tests + + + + + diff --git a/tests/Simple QR Code Maker.Windows.Tests/TestAssembly.cs b/tests/Simple QR Code Maker.Windows.Tests/TestAssembly.cs new file mode 100644 index 0000000..be2dce4 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize(Scope = ExecutionScope.ClassLevel)] diff --git a/tests/Simple QR Code Maker.Windows.Tests/Workflows/BackupArchiveTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Workflows/BackupArchiveTests.cs new file mode 100644 index 0000000..fcd550a --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Workflows/BackupArchiveTests.cs @@ -0,0 +1,109 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using System.Collections.ObjectModel; +using System.IO.Compression; +using System.Text; + +namespace Simple_QR_Code_Maker.Windows.Tests.Workflows; + +[TestClass] +public sealed class BackupArchiveTests +{ + [TestMethod] + public void HistoryMergePrefersImportedValuesAndPreservesOrder() + { + HistoryItem old = new() { CodesContent = "same", FrameText = "old" }; + HistoryItem replacement = new() { CodesContent = "same", FrameText = "new" }; + ObservableCollection merged = BackupArchiveHelper.MergeHistory( + [old, new() { CodesContent = "existing" }], [new() { CodesContent = "first" }, replacement]); + CollectionAssert.AreEqual(new[] { "first", "same", "existing" }, merged.Select(item => item.CodesContent).ToArray()); + Assert.AreSame(replacement, merged[1]); + } + + [TestMethod] + public void BrandMergeIsCaseInsensitiveAndChoosesFirstImportedDefault() + { + ObservableCollection merged = BackupArchiveHelper.MergeBrands( + [new() { Name = "same", IsDefault = true }, new() { Name = "keep" }], + [new() { Name = "SAME", IsDefault = true }, new() { Name = "other", IsDefault = true }]); + CollectionAssert.AreEqual(new[] { "SAME", "other", "keep" }, merged.Select(brand => brand.Name).ToArray()); + Assert.HasCount(1, merged.Where(brand => brand.IsDefault).ToArray()); + Assert.IsTrue(merged[0].IsDefault); + } + + [TestMethod] + public void ExistingDefaultSurvivesWhenImportHasNoDefault() + { + ObservableCollection merged = BackupArchiveHelper.MergeBrands( + [new() { Name = "keep", IsDefault = true }], [new() { Name = "new" }]); + Assert.IsTrue(merged.Single(brand => brand.Name == "keep").IsDefault); + } + + [TestMethod] + public void RelativeImageReferencesResolveOnlyWhenPresent() + { + Dictionary paths = new() { ["images/logo.png"] = @"C:\temporary\logo.png" }; + Assert.AreEqual(@"C:\temporary\logo.png", BackupArchiveHelper.ResolveImportedImagePath("images/logo.png", paths)); + Assert.IsNull(BackupArchiveHelper.ResolveImportedImagePath("images/missing.png", paths)); + Assert.IsNull(BackupArchiveHelper.ResolveImportedImagePath(null, paths)); + Assert.AreEqual(@"C:\old\logo.png", BackupArchiveHelper.ResolveImportedImagePath(@"C:\old\logo.png", paths)); + } + + [TestMethod] + public void ArchiveNamesArePredictableAndSegmentsAreBounded() + { + Assert.AreEqual("images/history_20261025_101112_example_logo_logo_003.png", + BackupArchiveHelper.CreateArchiveImagePath("History", "Example", "Logo.png", new DateTime(2026, 10, 25, 10, 11, 12), 3)); + Assert.AreEqual("one_two", BackupArchiveHelper.SanitizeFileNameSegment(" /ONE:* TWO/ ", 10)); + Assert.AreEqual("abcd", BackupArchiveHelper.SanitizeFileNameSegment("abcdefgh", 4)); + Assert.AreEqual("", BackupArchiveHelper.SanitizeFileNameSegment("text", 0)); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task ImageExportDeduplicatesAndImportUsesUniqueSafeLocalNames() + { + string directory = Path.Combine(Path.GetTempPath(), $"simple-qr-backup-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + string source = Path.Combine(directory, "logo.png"); + await File.WriteAllTextAsync(source, "synthetic image bytes"); + using MemoryStream stream = new(); + string? archivePath; + using (ZipArchive archive = new(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + Dictionary map = []; + archivePath = await BackupArchiveHelper.AddImageToArchiveAsync(archive, source, map, "history", "example", new DateTime(2026, 1, 1)); + Assert.AreEqual(archivePath, await BackupArchiveHelper.AddImageToArchiveAsync(archive, source, map, "brand", "duplicate", default)); + Assert.IsNull(await BackupArchiveHelper.AddImageToArchiveAsync(archive, source + ".missing", map, "history", "", default)); + WriteEntry(archive, "images/sub/duplicate.png", "first"); + WriteEntry(archive, "images/other/duplicate.png", "second"); + WriteEntry(archive, "images/../../outside.png", "contained"); + WriteEntry(archive, "ignored.txt", "ignored"); + } + + stream.Position = 0; + using ZipArchive input = new(stream, ZipArchiveMode.Read); + Dictionary extracted = await BackupArchiveHelper.ExtractImagesAsync(input, directory); + Assert.HasCount(4, extracted); + Assert.IsNotNull(archivePath); + Assert.AreEqual("synthetic image bytes", await File.ReadAllTextAsync(extracted[archivePath])); + Assert.AreNotEqual(extracted["images/sub/duplicate.png"], extracted["images/other/duplicate.png"]); + foreach (string path in extracted.Values) + Assert.StartsWith(Path.Combine(directory, "ImportedImages") + Path.DirectorySeparatorChar, Path.GetFullPath(path)); + Assert.IsFalse(File.Exists(Path.Combine(directory, "outside.png"))); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static void WriteEntry(ZipArchive archive, string name, string text) + { + using StreamWriter writer = new(archive.CreateEntry(name).Open(), Encoding.UTF8); + writer.Write(text); + } +} diff --git a/tests/Simple QR Code Maker.Windows.Tests/Workflows/QrExportWriterTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Workflows/QrExportWriterTests.cs new file mode 100644 index 0000000..7c1eb82 --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Workflows/QrExportWriterTests.cs @@ -0,0 +1,76 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Simple_QR_Code_Maker.Helpers; +using Simple_QR_Code_Maker.Models; +using System.Drawing; +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using ZXing.QrCode.Internal; + +namespace Simple_QR_Code_Maker.Windows.Tests.Workflows; + +[TestClass] +public sealed class QrExportWriterTests +{ + [TestMethod] + public async Task ZipContainsBothFormatsAndIsCompleteBeforeReturning() + { + using QrRenderSettingsSnapshot settings = CreateSettings(); + RequestedQrCodeItem[] codes = [new("one"), new("two"), new("")]; + List progress = []; + using MemoryStream output = new(); + await QrExportWriter.WriteZipAsync(output, codes, settings, count => progress.Add(count), FileKind.PNG, FileKind.SVG); + Assert.IsTrue(output.CanWrite); + output.Position = 0; + using ZipArchive archive = new(output, ZipArchiveMode.Read, leaveOpen: true); + CollectionAssert.AreEquivalent(new[] { "one.png", "one.svg", "two.png", "two.svg" }, + archive.Entries.Select(entry => entry.FullName).ToArray()); + CollectionAssert.AreEqual(new[] { 1, 2, 3 }, progress); + using Stream png = archive.GetEntry("one.png")!.Open(); + byte[] signature = new byte[8]; + await png.ReadExactlyAsync(signature); + CollectionAssert.AreEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }, signature); + using StreamReader svg = new(archive.GetEntry("two.svg")!.Open()); + Assert.AreEqual("svg", XDocument.Parse(await svg.ReadToEndAsync()).Root!.Name.LocalName); + } + + [TestMethod] + public async Task SvgOutputHasNoBomAndLeavesCallerStreamOpen() + { + using QrRenderSettingsSnapshot settings = CreateSettings(); + using MemoryStream output = new(); + await QrExportWriter.WriteAsync(new("unicode-\u6f22"), output, FileKind.SVG, settings); + Assert.IsTrue(output.CanWrite); + byte[] bytes = output.ToArray(); + Assert.IsFalse(bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })); + Assert.AreEqual("svg", XDocument.Parse(Encoding.UTF8.GetString(bytes)).Root!.Name.LocalName); + } + + [TestMethod] + public async Task InvalidFormatsAndStreamFailuresPropagate() + { + using QrRenderSettingsSnapshot settings = CreateSettings(); + using MemoryStream output = new(); + await Assert.ThrowsExactlyAsync(() => + QrExportWriter.WriteAsync(new("one"), output, (FileKind)999, settings)); + using MemoryStream readOnly = new([1, 2, 3], writable: false); + await Assert.ThrowsAsync(() => + QrExportWriter.WriteAsync(new("one"), readOnly, FileKind.PNG, settings)); + } + + [TestMethod] + public async Task EmptyZipIsValidAndMakesNoProgress() + { + using QrRenderSettingsSnapshot settings = CreateSettings(); + using MemoryStream output = new(); + await QrExportWriter.WriteZipAsync(output, [], settings, _ => Assert.Fail("No items completed."), FileKind.PNG); + output.Position = 0; + using ZipArchive archive = new(output, ZipArchiveMode.Read); + Assert.IsEmpty(archive.Entries); + } + + private static QrRenderSettingsSnapshot CreateSettings() + => QrRenderSettingsSnapshot.Create( + ErrorCorrectionLevel.M, Color.Black, Color.White, + null, 15, 4, null, 2, QrFramePreset.None, QrFrameTextSource.Manual, null); +} diff --git a/tests/coverage.config b/tests/coverage.config new file mode 100644 index 0000000..dcbe816 --- /dev/null +++ b/tests/coverage.config @@ -0,0 +1,21 @@ + + + + + + .*Simple QR Code Maker\.(Core|Windows)\.dll$ + + + + + .*[\\/]obj[\\/].* + + + + + ^System\.CodeDom\.Compiler\.GeneratedCodeAttribute$ + ^System\.Diagnostics\.CodeAnalysis\.ExcludeFromCodeCoverageAttribute$ + + + + diff --git a/tests/thresholds.json b/tests/thresholds.json new file mode 100644 index 0000000..152e596 --- /dev/null +++ b/tests/thresholds.json @@ -0,0 +1,4 @@ +{ + "Core": { "line": 95, "branch": 90 }, + "Windows": { "line": 85, "branch": 80 } +} From 98b2b62b4c6fc41d32dd93d8c804bb51c3cc9ba1 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 25 Oct 2026 13:43:38 -0500 Subject: [PATCH 3/5] Run cross-platform tests and packaged app builds in CI Validate portable tests on Linux and headless suites on Windows x64 and ARM64. Upload coverage artifacts and build the packaged app with the .NET 10 runtime and preview compiler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 65 ++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f495e8..1ee762b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,38 +1,65 @@ name: Build on: - # push: - # branches: - # - main - # - dev - # pull_request: - # branches: - # - main - # - dev + push: + branches: [main, dev] + pull_request: workflow_dispatch: +permissions: + contents: read + concurrency: - group: ${{ github.workflow }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + test: + name: Tests (${{ matrix.os }}, ${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: x64 + - os: windows-latest + platform: x64 + - os: windows-11-arm + platform: ARM64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + - name: Run headless tests with coverage + shell: pwsh + run: .\scripts\Test.ps1 -Platform ${{ matrix.platform }} -Coverage + - name: Upload results and coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }}-${{ matrix.platform }} + path: TestResults + if-no-files-found: warn + build: + name: Packaged app (x64) runs-on: windows-latest - steps: - name: Checkout code uses: actions/checkout@v6 - - - name: Setup .NET + - name: Setup .NET 10 runtime and SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' - + dotnet-version: '10.0.x' + # Existing app sources use preview collection-expression arguments. + - name: Setup preview compiler + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '11.0.x' + dotnet-quality: preview - name: Setup MSBuild uses: microsoft/setup-msbuild@v2 - - - name: Restore - run: msbuild "Simple QR Code Maker.sln" /t:Restore /p:Configuration=Release /p:Platform=x64 - - name: Build - run: msbuild "Simple QR Code Maker.sln" /p:Configuration=Release /p:Platform=x64 \ No newline at end of file + run: dotnet msbuild "Simple QR Code Maker.slnx" /restore /p:Configuration=Release /p:Platform=x64 /nologo \ No newline at end of file From 46dcac0108f4fe4ac7300067a8343dce0406cf59 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 25 Oct 2026 13:43:38 -0500 Subject: [PATCH 4/5] Document the library architecture and headless test workflow Update contributor guidance with build prerequisites, suite selection, coverage requirements, deterministic testing conventions, and the explicit approval requirement for UI automation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 6 ++++-- CLAUDE.md | 8 +++++--- README.md | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e115877..397c67e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,7 +16,9 @@ dotnet msbuild "Simple QR Code Maker.slnx" /restore /p:Configuration=Release /p: ``` - Visual Studio 2022 with the Windows App SDK workload and MSIX tooling is required; see `.vsconfig`. -- There is no automated test project in this repository, so there is no single-test command to run. +- Run headless tests with `.\scripts\Test.ps1 -Platform ARM64` (or `x64` on x64 hosts); add `-Coverage` for TRX and Cobertura reports. +- Run a single suite with `dotnet test --project "tests\Simple QR Code Maker.Core.Tests\Simple QR Code Maker.Core.Tests.csproj" -p:Platform=ARM64`. Use MTP filtering options shown by that project's `--help` to select tests. +- Core tests are portable; Windows tests require Windows and run without launching the app. Filesystem integration tests use isolated temporary directories. - There is no dedicated lint command configured in the repository. ## UI automation testing @@ -25,7 +27,7 @@ Always ask the user and wait for explicit approval before running UI automation ## High-level architecture -- The solution has two projects: `Simple QR Code Maker` is the WinUI 3 app, and `Simple QR Code Maker.Core` contains shared infrastructure such as `FileService`. +- The app references portable `Simple QR Code Maker.Core` (builders, parsing, workflow/print policies, infrastructure) and headless `Simple QR Code Maker.Windows` (rendering, persistence, Windows models). Each library has an MSTest/MTP project under `tests`. - `App.xaml.cs` is the composition root. It builds a `Microsoft.Extensions.Hosting` host, registers services/view models/views, and routes startup through `IActivationService`. - Activation is split between `DefaultActivationHandler` and `ShareTargetActivationHandler`. Shared images route into `DecodingViewModel`; shared text and URIs route into `MainViewModel`. - `ShellPage` hosts navigation. `PageService` maps view-model keys to pages, and `NavigationService` performs frame navigation and forwards `INavigationAware` lifecycle calls. diff --git a/CLAUDE.md b/CLAUDE.md index e3bba6e..acb6c0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,9 +26,9 @@ dotnet msbuild "Simple QR Code Maker.slnx" -p:Configuration=Release -p:Platform= Visual Studio 2022 with the Windows App SDK workload and MSIX packaging tools is required. See `.vsconfig` for the exact required components. -## No Automated Tests +## Automated Tests -There are no unit or integration test projects. Testing is done manually using MSIX packages in `AppPackages/`. +Run `.\scripts\Test.ps1 -Platform ARM64` (use `x64` on x64 hosts), adding `-Coverage` for TRX and Cobertura reports in `TestResults`. `-Suite Core` selects portable tests; `-Suite Windows` selects headless Windows tests. Tests use MSTest with Microsoft.Testing.Platform, not VSTest. No app activation, real app data, Excel installation, or desktop interaction is needed. ## UI Automation Testing @@ -41,7 +41,9 @@ The app follows **MVVM + Dependency Injection** using `CommunityToolkit.Mvvm` an ### Solution Structure - **`Simple QR Code Maker/`** — Main WinUI 3 app project -- **`Simple QR Code Maker.Core/`** — Shared library (currently just `FileService` for JSON I/O) +- **`Simple QR Code Maker.Core/`** — Portable builders, parsers, workflow/print policies, and JSON/file infrastructure +- **`Simple QR Code Maker.Windows/`** — Headless Windows rendering, persistence, and models +- **`tests/`** — MSTest/MTP projects exercising the actual production libraries ### App Entry & DI diff --git a/README.md b/README.md index 7d16baf..5ba167e 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,29 @@ On the reading side, v2 can decode codes from files, folders, drag-and-drop, the - Export settings, brands, and history to a ZIP backup. - Import settings, brands, and history from a ZIP backup. +## Development and testing + +The app targets .NET 10 and uses WinUI 3/MSIX. Build it with Visual Studio's Windows App SDK/MSIX tooling and `dotnet msbuild "Simple QR Code Maker.slnx" /restore /p:Configuration=Debug /p:Platform=ARM64 /nologo` (or `x64`). Existing app sources use preview collection-expression arguments, so the app build currently needs the .NET 11 preview SDK as well as the .NET 10 runtime. + +The production logic lives in two libraries: portable `Simple QR Code Maker.Core`, and headless Windows-specific `Simple QR Code Maker.Windows`. Both are referenced by the app and by MSTest/Microsoft.Testing.Platform test projects. Tests do not launch the app or touch its settings. + +```powershell +# Run both headless suites on Windows; Core only on other platforms. +.\scripts\Test.ps1 -Platform ARM64 + +# Run one suite, including coverage and TRX results in TestResults. +.\scripts\Test.ps1 -Platform ARM64 -Suite Core -Coverage + +# Direct runner invocation (use x64 on x64 hosts). +dotnet test --project "tests\Simple QR Code Maker.Windows.Tests\Simple QR Code Maker.Windows.Tests.csproj" -p:Platform=ARM64 +``` + +The test projects require .NET 10. Use `-Suite Core` on Linux/macOS. Real filesystem checks are marked `Integration`, operate only in unique temporary directories, and clean up after themselves. Native image processing is exercised on matching x64/ARM64 hosts. CI runs portable tests on Linux and both suites on Windows x64/ARM64, and publishes results and coverage even when a suite fails. + +Coverage runs enforce the measured production-library floors in `tests\thresholds.json`. Keep these ratcheting upward; generated code is excluded, but entire helper/service classes are not. Core requires 95% line and 90% branch coverage; Windows requires 85% line and 80% branch coverage. + +Keep tests deterministic: use synthetic data, fixed timestamps/cultures where relevant, controllable tasks instead of sleeps, and structural image/PDF assertions instead of whole-file snapshots. Regression tests should cover success, invalid input, boundaries, failures, and legacy compatibility. XAML bindings, actual pickers, clipboard, camera, Windows AI availability, Excel COM integration, and printing remain platform/UI checks; **running anything that launches or interacts with the app requires explicit user approval**. + --- Made with ❤️ in Milwaukee by Joe Finney. From a79e40d0f743d2fb776adcba082140e871e16d1e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 25 Oct 2026 21:10:17 -0500 Subject: [PATCH 5/5] Fix decoding history clear and removal races Serialize clear and remove operations with pending adds, preserving live history when persistence fails. Add blocked-write regression coverage for both races and failed saves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/Services/IHistoryStores.cs | 4 + .../Services/DecodingHistoryStore.cs | 30 +++++++ .../Helpers/DecodingHistoryStorageHelper.cs | 6 ++ .../ViewModels/DecodingViewModel.cs | 6 +- .../Persistence/DecodingHistoryStoreTests.cs | 86 +++++++++++++++++-- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs b/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs index 15e5146..dad26d6 100644 --- a/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs +++ b/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs @@ -27,5 +27,9 @@ public interface IDecodingHistoryStore Task AddAndSaveAsync(ObservableCollection items, DecodingHistoryItem newItem); + Task ClearAndSaveAsync(ObservableCollection items); + + Task RemoveAndSaveAsync(ObservableCollection items, DecodingHistoryItem item); + Task UpdateLatestAndSaveAsync(ObservableCollection items, string sourcePath, List decodedTexts); } diff --git a/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs b/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs index b41d006..8f2439f 100644 --- a/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs +++ b/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs @@ -69,6 +69,36 @@ public async Task AddAndSaveAsync(ObservableCollection item } } + public async Task ClearAndSaveAsync(ObservableCollection items) + { + await _gate.WaitAsync(); + try + { + await SaveCoreAsync(Serialize([])); + items.Clear(); + } + finally + { + _gate.Release(); + } + } + + public async Task RemoveAndSaveAsync(ObservableCollection items, DecodingHistoryItem item) + { + await _gate.WaitAsync(); + try + { + ObservableCollection candidate = new(items); + candidate.Remove(item); + await SaveCoreAsync(Serialize(candidate)); + items.Remove(item); + } + finally + { + _gate.Release(); + } + } + public async Task UpdateLatestAndSaveAsync(ObservableCollection items, string sourcePath, List decodedTexts) { await _gate.WaitAsync(); diff --git a/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs b/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs index 7c44464..27e1556 100644 --- a/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs +++ b/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs @@ -18,6 +18,12 @@ public static Task SaveHistoryAsync(ObservableCollection it public static Task AddAndSaveAsync(ObservableCollection items, DecodingHistoryItem newItem) => App.GetService().AddAndSaveAsync(items, newItem); + public static Task ClearAndSaveAsync(ObservableCollection items) => + App.GetService().ClearAndSaveAsync(items); + + public static Task RemoveAndSaveAsync(ObservableCollection items, DecodingHistoryItem item) => + App.GetService().RemoveAndSaveAsync(items, item); + public static Task UpdateLatestAndSaveAsync(ObservableCollection items, string newImageSourcePath, List newDecodedTexts) => App.GetService().UpdateLatestAndSaveAsync(items, newImageSourcePath, newDecodedTexts); } \ No newline at end of file diff --git a/Simple QR Code Maker/ViewModels/DecodingViewModel.cs b/Simple QR Code Maker/ViewModels/DecodingViewModel.cs index cc1ce6f..153c8de 100644 --- a/Simple QR Code Maker/ViewModels/DecodingViewModel.cs +++ b/Simple QR Code Maker/ViewModels/DecodingViewModel.cs @@ -1714,8 +1714,7 @@ private void TextBorder_TextBorder_Click(object sender, RoutedEventArgs e) [RelayCommand] private async Task ClearDecodingHistory() { - DecodingHistoryItems.Clear(); - await DecodingHistoryStorageHelper.SaveHistoryAsync(DecodingHistoryItems); + await DecodingHistoryStorageHelper.ClearAndSaveAsync(DecodingHistoryItems); OnPropertyChanged(nameof(HasDecodingHistory)); } @@ -1725,8 +1724,7 @@ private async Task RemoveDecodingHistoryItem(DecodingHistoryItem item) if (item is null) return; - DecodingHistoryItems.Remove(item); - await DecodingHistoryStorageHelper.SaveHistoryAsync(DecodingHistoryItems); + await DecodingHistoryStorageHelper.RemoveAndSaveAsync(DecodingHistoryItems, item); OnPropertyChanged(nameof(HasDecodingHistory)); } diff --git a/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs b/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs index 40e1552..f51052f 100644 --- a/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs @@ -140,7 +140,7 @@ public async Task FailedAddDoesNotTrimLiveCollectionOrDeleteImages() } [TestMethod] - public async Task SaveRemovalPreservesSharedImagesAndClearDeletesRemainingImages() + public async Task RemovalPreservesSharedImagesAndClearDeletesRemainingImages() { DecodingHistoryStore store = CreateStore(); ObservableCollection items = @@ -150,12 +150,14 @@ public async Task SaveRemovalPreservesSharedImagesAndClearDeletesRemainingImages new() { SavedImagePath = "removed.png" } ]; await store.SaveAsync(items); - items.RemoveAt(0); - items.RemoveAt(1); - await store.SaveAsync(items); + await store.RemoveAndSaveAsync(items, items[0]); + Assert.IsEmpty(_images.DeletedPaths); + await store.RemoveAndSaveAsync(items, items[1]); + Assert.HasCount(1, items); CollectionAssert.AreEqual(new[] { "removed.png" }, _images.DeletedPaths); - items.Clear(); - await store.SaveAsync(items); + await store.ClearAndSaveAsync(items); + Assert.IsEmpty(items); + Assert.IsEmpty(await store.LoadAsync()); CollectionAssert.AreEqual(new[] { "removed.png", "SHARED.PNG" }, _images.DeletedPaths); } @@ -195,6 +197,78 @@ public async Task ConcurrentAddsSerializePolicyAndKeepBothEntries() Assert.HasCount(2, await store.LoadAsync()); } + [TestMethod] + public async Task ClearDuringPendingAddKeepsLiveAndPersistedHistoryEmpty() + { + DecodingHistoryStore store = CreateStore(); + ObservableCollection items = [new() { SavedImagePath = "old.png" }]; + await store.SaveAsync(items); + _files.WriteEntered = new(); + _files.ReleaseWrite = new(); + + Task add = store.AddAndSaveAsync(items, new() { SavedImagePath = "new.png" }); + await _files.WriteEntered.Task; + Task clear = store.ClearAndSaveAsync(items); + _files.ReleaseWrite.SetResult(); + await Task.WhenAll(add, clear); + + Assert.IsEmpty(items); + Assert.IsEmpty(await store.LoadAsync()); + CollectionAssert.AreEquivalent(new[] { "old.png", "new.png" }, _images.DeletedPaths); + } + + [TestMethod] + public async Task RemoveDuringPendingAddPreservesNewAndUnremovedEntries() + { + DecodingHistoryStore store = CreateStore(); + DecodingHistoryItem removed = new() { SavedImagePath = "removed.png" }; + DecodingHistoryItem retained = new() { SavedImagePath = "retained.png" }; + DecodingHistoryItem added = new() { SavedImagePath = "new.png" }; + ObservableCollection items = [removed, retained]; + await store.SaveAsync(items); + _files.WriteEntered = new(); + _files.ReleaseWrite = new(); + + Task add = store.AddAndSaveAsync(items, added); + await _files.WriteEntered.Task; + Task remove = store.RemoveAndSaveAsync(items, removed); + _files.ReleaseWrite.SetResult(); + await Task.WhenAll(add, remove); + + CollectionAssert.AreEqual(new[] { added, retained }, items); + CollectionAssert.AreEqual(new[] { "new.png", "retained.png" }, + (await store.LoadAsync()).Select(item => item.SavedImagePath).ToArray()); + CollectionAssert.AreEqual(new[] { "removed.png" }, _images.DeletedPaths); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task FailedClearOrRemovalPreservesLiveAndPersistedHistoryAndImages(bool clear) + { + DecodingHistoryStore store = CreateStore(); + DecodingHistoryItem item = new() { SavedImagePath = "old.png" }; + ObservableCollection items = [item]; + await store.SaveAsync(items); + string previous = _files.Files["DecodingHistory.json"]; + _files.WriteFailure = new IOException("Write failed."); + + await Assert.ThrowsExactlyAsync(() => clear + ? store.ClearAndSaveAsync(items) + : store.RemoveAndSaveAsync(items, item)); + + Assert.HasCount(1, items); + Assert.AreSame(item, items[0]); + Assert.AreEqual(previous, _files.Files["DecodingHistory.json"]); + Assert.IsEmpty(_images.DeletedPaths); + + _files.WriteFailure = null; + await (clear ? store.ClearAndSaveAsync(items) : store.RemoveAndSaveAsync(items, item)); + Assert.IsEmpty(items); + Assert.IsEmpty(await store.LoadAsync()); + CollectionAssert.AreEqual(new[] { "old.png" }, _images.DeletedPaths); + } + [TestMethod] public async Task SaveSnapshotDoesNotObserveLaterCollectionOrTextMutations() {