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/.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 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. 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..dad26d6 --- /dev/null +++ b/Simple QR Code Maker.Windows/Contracts/Services/IHistoryStores.cs @@ -0,0 +1,35 @@ +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 ClearAndSaveAsync(ObservableCollection items); + + Task RemoveAndSaveAsync(ObservableCollection items, DecodingHistoryItem item); + + 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..8f2439f --- /dev/null +++ b/Simple QR Code Maker.Windows/Services/DecodingHistoryStore.cs @@ -0,0 +1,190 @@ +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 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(); + 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..2bdf94e 100644 --- a/Simple QR Code Maker.slnx +++ b/Simple QR Code Maker.slnx @@ -3,10 +3,24 @@ + + + + + + + + + + + + + + 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..27e1556 100644 --- a/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs +++ b/Simple QR Code Maker/Helpers/DecodingHistoryStorageHelper.cs @@ -1,133 +1,29 @@ -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); - } + public static Task SaveImageCopyAsync(string sourcePath) => + App.GetService().SaveImageCopyAsync(sourcePath); - /// - /// 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; + public static Task> LoadHistoryAsync() => + App.GetService().LoadAsync(); - DecodingHistoryItem latest = items[0]; + public static Task SaveHistoryAsync(ObservableCollection items) => + App.GetService().SaveAsync(items); - // 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 AddAndSaveAsync(ObservableCollection items, DecodingHistoryItem newItem) => + App.GetService().AddAndSaveAsync(items, newItem); - string? savedImagePath = await SaveImageCopyAsync(newImageSourcePath); + public static Task ClearAndSaveAsync(ObservableCollection items) => + App.GetService().ClearAndSaveAsync(items); - latest.SavedImagePath = savedImagePath ?? string.Empty; - latest.DecodedTexts = newDecodedTexts; + public static Task RemoveAndSaveAsync(ObservableCollection items, DecodingHistoryItem item) => + App.GetService().RemoveAndSaveAsync(items, item); - 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..153c8de 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); } @@ -1738,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)); } @@ -1749,14 +1724,7 @@ 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); + await DecodingHistoryStorageHelper.RemoveAndSaveAsync(DecodingHistoryItems, item); OnPropertyChanged(nameof(HasDecodingHistory)); } @@ -1892,4 +1860,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 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..f51052f --- /dev/null +++ b/tests/Simple QR Code Maker.Windows.Tests/Persistence/DecodingHistoryStoreTests.cs @@ -0,0 +1,289 @@ +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 RemovalPreservesSharedImagesAndClearDeletesRemainingImages() + { + DecodingHistoryStore store = CreateStore(); + ObservableCollection items = + [ + new() { SavedImagePath = "shared.png" }, + new() { SavedImagePath = "SHARED.PNG" }, + new() { SavedImagePath = "removed.png" } + ]; + 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); + await store.ClearAndSaveAsync(items); + Assert.IsEmpty(items); + Assert.IsEmpty(await store.LoadAsync()); + 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 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() + { + _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 } +}