diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 5132f8d0..7cd85d4b 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -24,6 +24,8 @@ env: PROJECT: 'Text-Grab' PROJECT_PATH: 'Text-Grab/Text-Grab.csproj' TEST_PATH: 'Tests/Tests.csproj' + TEST_CORE_PATH: 'Tests.Core/Tests.Core.csproj' + TEST_CORE_WINDOWS_PATH: 'Tests.Core.Windows/Tests.Core.Windows.csproj' BUILD_X64: 'bld/x64' BUILD_X64_SC: 'bld/x64/Text-Grab-Self-Contained' BUILD_ARM64: 'bld/arm64' @@ -50,6 +52,14 @@ jobs: - name: Install dependencies run: dotnet restore ${{ env.PROJECT_PATH }} + # The pure tier runs first: it needs no display and finishes in about a second, so a + # logic regression fails the job before the slow WPF/STA suite starts. + - name: Run Core tests + run: dotnet test --project ${{ env.TEST_CORE_PATH }} + + - name: Run Core.Windows tests + run: dotnet test --project ${{ env.TEST_CORE_WINDOWS_PATH }} -r win-x64 + - name: Run tests run: dotnet test --project ${{ env.TEST_PATH }} -r win-x64 diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildDev.yml index fee1c509..192dec9d 100644 --- a/.github/workflows/buildDev.yml +++ b/.github/workflows/buildDev.yml @@ -12,6 +12,8 @@ concurrency: env: PROJECT_PATH: "Text-Grab/Text-Grab.csproj" TEST_PATH: "Tests/Tests.csproj" + TEST_CORE_PATH: "Tests.Core/Tests.Core.csproj" + TEST_CORE_WINDOWS_PATH: "Tests.Core.Windows/Tests.Core.Windows.csproj" # Unlock token for the Windows AI language model (a Limited Access Feature). Absent secrets # leave the properties empty, which just builds without on-device text AI rather than failing. LAF_TOKEN: ${{ secrets.LAF_TOKEN }} @@ -30,6 +32,12 @@ jobs: run: dotnet restore ${{ env.PROJECT_PATH }} - name: Build run: dotnet build ${{ env.PROJECT_PATH }} -p:EnableMsixTooling=true + # The pure tier runs first: it needs no display and finishes in about a second, so a + # logic regression fails the job before the slow WPF/STA suite starts. + - name: Test Core + run: dotnet test --project ${{ env.TEST_CORE_PATH }} + - name: Test Core.Windows + run: dotnet test --project ${{ env.TEST_CORE_WINDOWS_PATH }} -r win-x64 - name: Test run: dotnet test --project ${{ env.TEST_PATH }} -r win-x64 diff --git a/Tests/BarcodeUtilitiesTests.cs b/Tests.Core.Windows/BarcodeUtilitiesTests.cs similarity index 78% rename from Tests/BarcodeUtilitiesTests.cs rename to Tests.Core.Windows/BarcodeUtilitiesTests.cs index d4856aff..3fe5f20d 100644 --- a/Tests/BarcodeUtilitiesTests.cs +++ b/Tests.Core.Windows/BarcodeUtilitiesTests.cs @@ -5,12 +5,14 @@ using Text_Grab; using Text_Grab.Models; using Text_Grab.Utilities; -using UnitsNet; using Windows.Storage.Streams; -using static System.Net.Mime.MediaTypeNames; -namespace Tests; +namespace Text_Grab.Tests.Core.Windows; +// Headless half of the original Tests/BarcodeUtilitiesTests.cs (batch 7a). ReadTestSingleQRCode +// stayed behind as Tests/BarcodeUtilitiesImageTests.cs: it is [WpfFact]-tagged, and Xunit.StaFact +// cannot be referenced here (it pulls in WindowsBase, which TierBoundaryTests bans). This half has +// 3 methods against that one's 1, so it kept the original name. public class BarcodeUtilitiesTests { [Fact] @@ -47,20 +49,6 @@ public void TryToReadBarcodes_WithTwoQrCodes_ReturnsTwoResults() Assert.Contains(results, r => r.RawOutput == "https://example.org"); } - [WpfFact] - public void ReadTestSingleQRCode() - { - string expectedOutput = "This is a test of the QR Code system"; - string testFilePath = FileUtilities.GetPathToLocalFile(@".\Images\QrCodeTestImage.png"); - - Bitmap testBmp = new(testFilePath); - - List result = BarcodeUtilities.TryToReadBarcodes(testBmp); - - Assert.Single(result); - Assert.Equal(expectedOutput, result[0].RawOutput); - } - [Fact] public async Task GetBitmapFromIRandomAccessStream_ReturnsBitmapIndependentOfSourceStream() { @@ -73,7 +61,7 @@ public async Task GetBitmapFromIRandomAccessStream_ReturnsBitmapIndependentOfSou using InMemoryRandomAccessStream randomAccessStream = new(); _ = await randomAccessStream.WriteAsync(memoryStream.ToArray().AsBuffer()); - Bitmap clonedBitmap = ImageMethods.GetBitmapFromIRandomAccessStream(randomAccessStream); + Bitmap clonedBitmap = BitmapUtilities.GetBitmapFromIRandomAccessStream(randomAccessStream); Assert.Equal(8, clonedBitmap.Width); Assert.Equal(8, clonedBitmap.Height); diff --git a/Tests.Core.Windows/FakeTextGrabSettings.cs b/Tests.Core.Windows/FakeTextGrabSettings.cs new file mode 100644 index 00000000..6e4f8a81 --- /dev/null +++ b/Tests.Core.Windows/FakeTextGrabSettings.cs @@ -0,0 +1,49 @@ +using System.Runtime.CompilerServices; +using Text_Grab.Interfaces; +using Text_Grab.Services; + +namespace Text_Grab.Tests.Core.Windows; + +/// +/// Registers a resolver for this test host. Tests.Core.Windows has +/// no app assembly to supply one via a [ModuleInitializer] the way Tests does (see +/// SettingsAccess.Current's remarks), so any moved test whose production code path reads settings +/// - here, a handful of OcrTests methods that call into OcrUtilities.BuildTextFromOcrLines, which +/// reads ParagraphDetection/RemoveFurigana/CorrectErrors/CorrectToLatin internally - would throw +/// InvalidOperationException without one installed. +/// +/// Every default below is copied from Text-Grab/Properties/Settings.settings's "(Default)" +/// profile, so a moved test that depends on a default value (e.g. RemoveFurigana=true dropping +/// furigana words) sees exactly what it saw running inside the app-hosted Tests project. +/// +internal static class TestSettingsInitializer +{ + [ModuleInitializer] + internal static void Register() => SettingsAccess.SetResolver(() => new FakeTextGrabSettings()); +} + +/// Minimal ITextGrabSettings double seeded with Settings.settings's shipped defaults. +internal sealed class FakeTextGrabSettings : ITextGrabSettings +{ + public bool CorrectErrors { get; set; } = true; + public bool CorrectToLatin { get; set; } = true; + public bool OverrideAiArchCheck { get; set; } + public bool ParagraphDetection { get; set; } = true; + public bool RemoveFurigana { get; set; } = true; + public bool TryToReadBarcodes { get; set; } = true; + public bool HdrCaptureCorrection { get; set; } + public bool HdrBorderlessGranted { get; set; } + public bool UiAutomationEnabled { get; set; } + public bool WindowsAiDescriptionEnabled { get; set; } + public bool UiAutomationFallbackToOcr { get; set; } = true; + public bool UseTesseract { get; set; } + public string TesseractPath { get; set; } = string.Empty; + public string LastUsedLang { get; set; } = string.Empty; + public int TtsSpeakWordLimit { get; set; } = 100; + public string TtsVoiceName { get; set; } = string.Empty; + public double TtsSpeakingRate { get; set; } = 1; + public string AudioTranscriptionModel { get; set; } = "BaseMultilingual"; + public bool EnableFileBackedManagedSettings { get; set; } + + public void Save() { } +} diff --git a/Tests.Core.Windows/FileUtilitiesTests.cs b/Tests.Core.Windows/FileUtilitiesTests.cs new file mode 100644 index 00000000..8dc1dbee --- /dev/null +++ b/Tests.Core.Windows/FileUtilitiesTests.cs @@ -0,0 +1,36 @@ +using Text_Grab.Utilities; + +namespace Text_Grab.Tests.Core.Windows; + +// Pure half of the original Tests/FilesIoTests.cs (batch 7a): FileUtilities.GetVisualDocumentFilter +// is Core.Windows-only and needs no app type. The rest of that file needed WPF or app-side members +// and kept the FilesIoTests name in Tests; the IoUtilities-only tests moved separately to +// Tests.Core/IoUtilitiesTests.cs. +public class FileUtilitiesTests +{ + [Fact] + public void GetVisualDocumentFilter_IncludesPdfSupport() + { + string filter = FileUtilities.GetVisualDocumentFilter(); + + Assert.Contains("Image and PDF files|", filter); + Assert.Contains("PDF files|*.pdf", filter); + Assert.Contains("Image files|", filter); + } + + // Joined FileUtilities in 7b once GrabFrameFileUtilities followed HistoryInfo to + // Core.Windows and GetOpenDocumentFilter() no longer needed the app-side + // OpenDocumentFilterUtilities split. + [Fact] + public void GetOpenDocumentFilter_IncludesVisualAndTextOptions() + { + string filter = FileUtilities.GetOpenDocumentFilter(); + + Assert.Contains("Supported documents|", filter); + Assert.Contains("Image and PDF files|", filter); + Assert.Contains("Spreadsheet documents|*.csv;*.tsv;*.tab", filter); + Assert.Contains("Markdown documents|*.md;*.markdown", filter); + Assert.Contains("Text documents (*.txt)|*.txt", filter); + Assert.Contains("All files (*.*)|*.*", filter); + } +} diff --git a/Tests/GrabFrameFileTests.cs b/Tests.Core.Windows/GrabFrameFileTests.cs similarity index 91% rename from Tests/GrabFrameFileTests.cs rename to Tests.Core.Windows/GrabFrameFileTests.cs index 0ec2647b..4b51cfc2 100644 --- a/Tests/GrabFrameFileTests.cs +++ b/Tests.Core.Windows/GrabFrameFileTests.cs @@ -2,13 +2,16 @@ using System.IO; using System.IO.Compression; using System.Text.Json; -using System.Windows; using Text_Grab; using Text_Grab.Models; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core.Windows; +// Moved wholesale in 7b: GrabFrameFileUtilities followed HistoryInfo to Core.Windows once the +// HistoryInfo blocker cleared (a8591aa), and every assertion here is against that headless pair +// (GrabFrameFileUtilities, HistoryInfo/WordBorderInfo) with no WPF type in sight - the file had +// an unused `using System.Windows;` from before that move, since dropped. public class GrabFrameFileTests { [Fact] @@ -21,13 +24,13 @@ public async Task SaveAndLoad_RoundTripsMetadataWordBordersAndImage() new() { Word = "Hello", - BorderRect = new Rect(1, 2, 30, 12), + BorderRect = new RectangleF(1, 2, 30, 12), LineNumber = 0, }, new() { Word = "World", - BorderRect = new Rect(35, 2, 32, 12), + BorderRect = new RectangleF(35, 2, 32, 12), LineNumber = 0, }, ]; @@ -40,7 +43,7 @@ public async Task SaveAndLoad_RoundTripsMetadataWordBordersAndImage() IsTable = true, LanguageTag = "en-US", LanguageKind = LanguageKind.Global, - PositionRect = new Rect(100, 120, 400, 300), + PositionRect = new RectangleF(100, 120, 400, 300), WordBorderInfoJson = JsonSerializer.Serialize(wordBorders), ImageContent = new Bitmap(64, 48), }; @@ -60,7 +63,7 @@ public async Task SaveAndLoad_RoundTripsMetadataWordBordersAndImage() Assert.True(loaded.IsTable); Assert.Equal("en-US", loaded.LanguageTag); Assert.Equal(LanguageKind.Global, loaded.LanguageKind); - Assert.Equal(new Rect(100, 120, 400, 300), loaded.PositionRect); + Assert.Equal(new RectangleF(100, 120, 400, 300), loaded.PositionRect); Assert.NotNull(loaded.ImageContent); Assert.Equal(64, loaded.ImageContent!.Width); @@ -91,7 +94,7 @@ public async Task SaveGrabFrameFileAsync_DoesNotMutateSuppliedInfo() string originalWordBordersJson = JsonSerializer.Serialize(new List { - new() { Word = "Hello", BorderRect = new Rect(1, 2, 30, 12), LineNumber = 0 }, + new() { Word = "Hello", BorderRect = new RectangleF(1, 2, 30, 12), LineNumber = 0 }, }); Bitmap originalImage = new(64, 48); diff --git a/Tests/HdrScreenCaptureTests.cs b/Tests.Core.Windows/HdrScreenCaptureTests.cs similarity index 97% rename from Tests/HdrScreenCaptureTests.cs rename to Tests.Core.Windows/HdrScreenCaptureTests.cs index 6b8e2a7e..92d843b7 100644 --- a/Tests/HdrScreenCaptureTests.cs +++ b/Tests.Core.Windows/HdrScreenCaptureTests.cs @@ -1,7 +1,7 @@ using System.Drawing; using Text_Grab.Utilities.Hdr; -namespace Tests; +namespace Text_Grab.Tests.Core.Windows; public class HdrScreenCaptureTests { diff --git a/Tests/ImageChangeDetectorTests.cs b/Tests.Core.Windows/ImageChangeDetectorTests.cs similarity index 98% rename from Tests/ImageChangeDetectorTests.cs rename to Tests.Core.Windows/ImageChangeDetectorTests.cs index 0750dac5..493abd03 100644 --- a/Tests/ImageChangeDetectorTests.cs +++ b/Tests.Core.Windows/ImageChangeDetectorTests.cs @@ -1,7 +1,7 @@ using System.Drawing; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core.Windows; public class ImageChangeDetectorTests { diff --git a/Tests/LanguageTests.cs b/Tests.Core.Windows/LanguageTests.cs similarity index 98% rename from Tests/LanguageTests.cs rename to Tests.Core.Windows/LanguageTests.cs index 56a53359..609992f1 100644 --- a/Tests/LanguageTests.cs +++ b/Tests.Core.Windows/LanguageTests.cs @@ -2,7 +2,7 @@ using Text_Grab; using Text_Grab.Models; -namespace Tests; +namespace Text_Grab.Tests.Core.Windows; public class LanguageTests { diff --git a/Tests.Core.Windows/OcrTests.cs b/Tests.Core.Windows/OcrTests.cs new file mode 100644 index 00000000..9cf7acd2 --- /dev/null +++ b/Tests.Core.Windows/OcrTests.cs @@ -0,0 +1,544 @@ +// This is the headless split of the original Tests/OcrTests.cs (batch 7a). The other half - +// live OCR-engine calls through OcrSourceUtilities, anything touching BitmapImage, and anything +// reading AppUtilities.TextGrabSettings directly - stayed behind as Tests/OcrSourceTests.cs. +// This half outnumbers that one (27 methods vs 15), so it kept the original name. Four methods +// (OcrComplexTableTestImage, GetTessLanguages, GetTesseractStrongLanguages, +// GetTesseractGitHubLanguage) were tagged [WpfFact] in the original file but never touch a WPF +// type - Xunit.StaFact cannot be referenced here (it pulls in WindowsBase, which +// TierBoundaryTests bans), so they moved as plain [Fact]/[Fact(Skip=...)] with no behavior +// change. +using System.Drawing; +using System.IO; +using System.Text; +using System.Text.Json; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Text_Grab.Utilities; +using Windows.Foundation; + +namespace Text_Grab.Tests.Core.Windows; + +public class OcrTests +{ + private const string ComplexWordBorders = @".\TextFiles\Table-Complex-WordBorders.json"; + private const string ComplexTableResult = @"DESCRIPTION YEAR TO DATE ACTUAL ANNUAL BUDGET BALANCE % BUDGET REMAINING +CORPORATE INCOME (1) $138,553 $358,100 $219,547 61 % +FOUNDATION INCOME 432,275 824,700 392,425 48% +GOVERNMENT INCOME 375,375 833,825 458,450 55% +PUBLICATIONS INCOME 1,341 3,000 1,659 55% +INTEREST INCOME (2) 26,767 39,000 12,233 31% +INVESTMENT GAIN (3) 50,472 0 N/A N/A +MISCELLANEOUS INCOME 1,650 6,995 5,345 76% +TOTAL REVENUE 1,026,433 2,065,620 1,089,659 53% +SALARIES & WAGES 355,633 603,840 248,207 41% +FRINGE BENEFITS 63,182 120,120 56,938 47% +OFFICE RENT 83,131 132,000 48,869 37% +EQUIPMENT RENTAL & MAINTENANCE 15,364 19,900 4,536 23% +SUPPLIES 8,051 10,200 2,149 21% +TELEPHONE AND POSTAGE 15,088 24,100 9,012 37% +INSURANCE 6,149 5,500 (649) (12)% +REGISTRATION & LICENSES 415 760 345 45% +DEPRECIATION 8,482 17,000 8,518 50% +BANK CHARGES 344 670 326 49% +AUDIT FEES 19,000 19,000 0 0% +BOARD MEETINGS 12,541 20,000 7,459 37% +TRAVEL 6,910 20,000 13,090 65% +LODGING & PERDIEM 15,623 20,000 4,377 22% +SEMINARS & MEETINGS 3,442 8,700 5,258 60% +PROFESSIONAL FESS 5,050 16,000 10,950 68% +PRINTING & PUBLICATIONS 25,576 25,000 (576) (2) % +MATERIALS,SUBS,DUES & TRAININGS 4,445 6,800 2,355 35% +LOCAL STAFF DEVELOPMENT 0 7,500 7,500 100% +STIPENDS 8,250 9,750 1,500 15% +SUBTOTAL 656,675 1,086,840 430,165 40% +TRANSFER PAYMENTS TO SUBRECIPIENTS 360,009 978,780 618,771 63% +TOTAL EXPENDITURES 1,016,684 2,065,620 1,048,936 51% +REVENUES OVERY(UNDER) EXPENDITURES $9,749 $0 $9,749 N/A"; + + [Theory] + [InlineData(10, 10, 25, 10, true)] // bounding-box gap = 5 + [InlineData(10, 10, 26, 10, false)] // threshold boundary: gap = 6 + [InlineData(10, 10, 27, 10, false)] // bounding-box gap = 7 + [InlineData(10, 10, 10, 10, false)] // same visual row + [InlineData(10, 10, 14, 10, false)] // insufficient vertical advance + [InlineData(10, 10, 18, 10, true)] // distinct rows with slight overlap + [InlineData(10, 10, 16, 30, false)] // height ratio = 3 + [InlineData(10, 0, 13, 10, false)] // zero height + public void IsWrappedParagraph_ReturnsExpected( + double currentTop, double currentHeight, + double nextTop, double nextHeight, + bool expected) + { + bool result = OcrUtilities.IsWrappedParagraph(currentTop, currentHeight, nextTop, nextHeight); + Assert.Equal(expected, result); + } + [Fact] + public void GroupWrappedParagraphLines_CombinesWrappedLinesIntoParagraphBlocks() + { + List lines = + [ + new(0, "Static cling is the tendency", new Rect(0, 0, 100, 10)), + new(1, "for light objects to stick.", new Rect(0, 14, 100, 10)), + new(2, "New paragraph.", new Rect(0, 32, 120, 12)), + ]; + + List groups = OcrUtilities.GroupWrappedParagraphLines(lines); + + Assert.Equal(2, groups.Count); + Assert.Equal(0, groups[0].StartingLineNumber); + Assert.Equal("Static cling is the tendency for light objects to stick.", groups[0].SingleLineText); + Assert.Equal($"Static cling is the tendency{Environment.NewLine}for light objects to stick.", groups[0].DisplayText); + Assert.Equal(0, groups[0].BoundingBox.Y); + Assert.Equal(24, groups[0].BoundingBox.Height); + Assert.Equal("New paragraph.", groups[1].SingleLineText); + } + [Fact] + public void GroupWrappedParagraphLines_DoesNotMergeEntriesOnTheSameVisualRow() + { + List lines = + [ + new(0, "Left entry", new Rect(0, 10, 50, 10)), + new(1, "Right entry", new Rect(60, 10, 50, 10)), + ]; + + List groups = OcrUtilities.GroupWrappedParagraphLines(lines); + + Assert.Equal(2, groups.Count); + Assert.All(groups, group => Assert.DoesNotContain(Environment.NewLine, group.DisplayText)); + Assert.All(groups, group => Assert.Equal(10, group.BoundingBox.Height)); + } + [Fact] + public void GroupWrappedParagraphLines_RemovesEmbeddedLineBreaksFromIndividualOcrLines() + { + List lines = + [ + new(0, $"First{Environment.NewLine}line", new Rect(0, 0, 100, 10)), + ]; + + OcrUtilities.GroupedOcrLines group = Assert.Single(OcrUtilities.GroupWrappedParagraphLines(lines)); + + Assert.Equal("First line", group.DisplayText); + Assert.Equal("First line", group.SingleLineText); + } + + [Fact] + public async Task OcrComplexTableTestImage() + { + // Given + string resultWordBorders = ComplexWordBorders; + string expectedResult = ComplexTableResult; + string wordBordersJson = await File.ReadAllTextAsync( + FileUtilities.GetPathToLocalFile(resultWordBorders), + TestContext.Current.CancellationToken); + + List wbInfoList = JsonSerializer.Deserialize>(wordBordersJson ?? "[]") + ?? throw new Exception("Failed to deserialize WordBorderInfo list"); + + // When + // 1514 x 1243 image size + Rectangle rectCanvasSize = new() + { + Width = 1514, + Height = 1243, + X = 0, + Y = 0 + }; + + ResultTable resultTable = new(); + resultTable.AnalyzeAsTable(wbInfoList, rectCanvasSize); + StringBuilder stringBuilder = new(); + + ResultTable.GetTextFromTabledWordBorders(stringBuilder, wbInfoList, true); + + // Then + Assert.Equal(expectedResult, stringBuilder.ToString()); + } + + [Fact(Skip = "fails GitHub actions")] + public async Task GetTessLanguages() + { + List expected = ["eng", "spa"]; + List actualStrings = await TesseractHelper.TesseractLanguagesAsStrings(); + + if (actualStrings.Count == 0) + return; + + foreach (string tag in expected) + { + Assert.Contains(tag, actualStrings); + } + } + + [Fact(Skip = "fails GitHub actions")] + public async Task GetTesseractStrongLanguages() + { + List expectedList = + [ + new TessLang("eng"), + new TessLang("spa"), + ]; + + List actualList = await TesseractHelper.TesseractLanguages(); + + if (actualList.Count == 0) + return; + + foreach (ILanguage tag in expectedList) + { + Assert.Contains(tag.AbbreviatedName, actualList.Select(x => x.AbbreviatedName).ToList()); + } + } + + [Fact(Skip = "fails GitHub actions")] + public async Task GetTesseractGitHubLanguage() + { + TesseractGitHubFileDownloader fileDownloader = new(); + + int length = TesseractGitHubFileDownloader.tesseractTrainedDataFileNames.Length; + string languageFileDataName = TesseractGitHubFileDownloader.tesseractTrainedDataFileNames[new Random().Next(length)]; + string tempFilePath = Path.Combine(Path.GetTempPath(), languageFileDataName); + + await fileDownloader.DownloadFileAsync(languageFileDataName, tempFilePath); + + Assert.True(File.Exists(tempFilePath)); + Assert.True(new FileInfo(tempFilePath).Length > 0); + + File.Delete(tempFilePath); + } + [Fact] + public void BuildTextFromOcrLines_FiltersFuriganaForJapanese() + { + // Given a Japanese line where the kanji 黒 is annotated with the small + // furigana くろ rendered directly above it. + FakeOcrLine line = new("くろ黒ごま", new Rect(0, 0, 60, 30)) + { + Words = + [ + // Furigana: short and sitting above the kanji it annotates. + new FakeOcrWord("くろ", new Rect(0, 0, 16, 8)), + // Main text: full-height single characters. + new FakeOcrWord("黒", new Rect(0, 10, 20, 20)), + new FakeOcrWord("ご", new Rect(20, 10, 20, 20)), + new FakeOcrWord("ま", new Rect(40, 10, 20, 20)), + ] + }; + + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + // When + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("ja"), ocrResult); + + // Then the furigana is dropped, leaving only the main text. + Assert.Equal("黒ごま", text); + } + [Fact] + public void FilterFurigana_EmptyList_ReturnsEmpty() + { + List result = OcrUtilities.FilterFurigana([]); + + Assert.Empty(result); + } + [Fact] + public void FilterFurigana_SingleWord_IsKept() + { + List words = [Word("黒", 0, 0, 20, 20)]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒"], result.Select(w => w.Text)); + } + [Fact] + public void FilterFurigana_UniformHeights_KeepsAllInOrder() + { + // No word is small relative to the median, so nothing is furigana. + List words = + [ + Word("黒", 0, 0, 20, 20), + Word("ご", 20, 0, 20, 20), + Word("ま", 40, 0, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒", "ご", "ま"], result.Select(w => w.Text)); + } + [Fact] + public void FilterFurigana_RemovesSmallWordAboveOverlappingKanji() + { + List words = + [ + Word("くろ", 0, 0, 16, 8), // furigana: short, sitting above + Word("黒", 0, 10, 20, 20), // kanji: taller, below, overlapping + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒"], result.Select(w => w.Text)); + } + [Fact] + public void FilterFurigana_KeepsSmallWordWhenNotHorizontallyOverlapping() + { + // Small, but nowhere near a kanji horizontally, so it is real text. + List words = + [ + Word("くろ", 100, 0, 16, 8), + Word("黒", 0, 10, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["くろ", "黒"], result.Select(w => w.Text)); + } + [Fact] + public void FilterFurigana_KeepsSmallWordBelowMainText() + { + // Furigana sits above its kanji; a small word BELOW a larger word is + // not furigana and must be kept. + List words = + [ + Word("黒", 0, 0, 20, 20), + Word("くろ", 0, 22, 16, 8), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒", "くろ"], result.Select(w => w.Text)); + } + [Fact] + public void FilterFurigana_KeepsSmallWordWhenWordBelowIsNotLarger() + { + // A small word directly above another small word is not furigana: + // furigana requires a larger word (the kanji) beneath it. The two tall + // words only exist to raise the median height. + List words = + [ + Word("く", 0, 0, 8, 8), + Word("ろ", 0, 10, 8, 8), // below + overlapping, but also small + Word("本", 50, 0, 20, 20), + Word("語", 80, 0, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["く", "ろ", "本", "語"], result.Select(w => w.Text)); + } + [Theory] + [InlineData("く", true)] // 1-char ruby is removed + [InlineData("くろ", true)] // 2-char ruby is removed + [InlineData("くろが", false)] // 3+ chars is treated as real text and kept + public void FilterFurigana_OnlyRemovesShortWords(string rubyText, bool removed) + { + List words = + [ + Word(rubyText, 0, 0, 16, 8), + Word("黒", 0, 10, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + string[] expected = removed ? ["黒"] : [rubyText, "黒"]; + Assert.Equal(expected, result.Select(w => w.Text)); + } + [Fact] + public void FilterFurigana_RemovesMultipleFuriganaKeepingMainText() + { + List words = + [ + Word("くろ", 0, 0, 16, 8), + Word("黒", 0, 10, 20, 20), + Word("ごま", 20, 0, 16, 8), + Word("米", 20, 10, 20, 20), + ]; + + List result = OcrUtilities.FilterFurigana(words); + + Assert.Equal(["黒", "米"], result.Select(w => w.Text)); + } + [Fact] + public void BuildTextFromOcrLines_JapaneseWithoutFurigana_IsUnchanged() + { + FakeOcrLine line = new("黒ごま", new Rect(0, 0, 60, 20)) + { + Words = + [ + Word("黒", 0, 0, 20, 20), + Word("ご", 20, 0, 20, 20), + Word("ま", 40, 0, 20, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("ja"), ocrResult); + + Assert.Equal("黒ごま", text); + } + [Fact] + public void BuildTextFromOcrLines_ChineseText_JoinsWithoutSpaces() + { + FakeOcrLine line = new("中文", new Rect(0, 0, 40, 20)) + { + Words = + [ + Word("中", 0, 0, 20, 20), + Word("文", 20, 0, 20, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("zh-Hans"), ocrResult); + + Assert.Equal("中文", text); + } + [Fact] + public void BuildTextFromOcrLines_FiltersRubyTextForChinese() + { + // The same small-ruby heuristic also runs for Chinese, another + // non-space-joining language (e.g. bopomofo above a character). + FakeOcrLine line = new("ㄓ中文", new Rect(0, 0, 40, 30)) + { + Words = + [ + Word("ㄓ", 0, 0, 8, 8), + Word("中", 0, 10, 20, 20), + Word("文", 20, 10, 20, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("zh-Hans"), ocrResult); + + Assert.Equal("中文", text); + } + [Fact] + public void OrderLinesForReadingFlow_SortsRowsTopToBottomAndLeftToRight() + { + // Mimics the Windows OCR engine returning furigana ruby lines and a + // trailing fragment out of reading order (as seen with Ja-Lang-Image.png). + // Row 1 (y~0): furigana くろ + main-line reading, emitted out of x-order. + // Row 2 (y~30): the main text line. + FakeOcrLine furiganaRight = new("しつ", new Rect(200, 0, 20, 8)); + FakeOcrLine furiganaLeft = new("くろ", new Rect(0, 0, 20, 8)); + FakeOcrLine mainLine = new("黒ごま質", new Rect(0, 30, 240, 20)); + + // Engine order is scrambled: right furigana, main line, then left furigana. + FakeOcrLinesWords ocrResult = new() + { + Lines = [furiganaRight, mainLine, furiganaLeft] + }; + + IReadOnlyList ordered = OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines); + + Assert.Equal(["くろ", "しつ", "黒ごま質"], ordered.Select(l => l.Text)); + } + [Fact] + public void OrderLinesForReadingFlow_KeepsSeparateRowsInVerticalOrder() + { + // Two furigana rows and two main-text rows interleaved and shuffled must + // come back strictly top-to-bottom. + FakeOcrLine ruby2 = new("かみ", new Rect(0, 100, 20, 8)); + FakeOcrLine main2 = new("髪", new Rect(0, 130, 40, 20)); + FakeOcrLine ruby1 = new("くろ", new Rect(0, 0, 20, 8)); + FakeOcrLine main1 = new("黒", new Rect(0, 30, 40, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [main2, ruby1, main1, ruby2] }; + + IReadOnlyList ordered = OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines); + + Assert.Equal(["くろ", "黒", "かみ", "髪"], ordered.Select(l => l.Text)); + } + [Fact] + public void FilterFuriganaLines_RemovesShortLineAboveTallerOverlappingLine() + { + // A short furigana line sitting just above a taller kanji line that it + // overlaps horizontally is dropped. + FakeOcrLine furigana = new("くろ", new Rect(0, 0, 40, 8)); + FakeOcrLine mainLine = new("黒ごま", new Rect(0, 10, 120, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [furigana, mainLine] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["黒ごま"], result.Select(l => l.Text)); + } + [Fact] + public void FilterFuriganaLines_KeepsTwoBodyLinesOfSimilarHeight() + { + // Two normal body lines stacked vertically: neither is much shorter than + // the other, so nothing is treated as furigana. + FakeOcrLine top = new("黒ごまは体に", new Rect(0, 0, 200, 20)); + FakeOcrLine bottom = new("たくさんあります", new Rect(0, 26, 200, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [top, bottom] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["黒ごまは体に", "たくさんあります"], result.Select(l => l.Text)); + } + [Fact] + public void FilterFuriganaLines_KeepsShortLineNotHorizontallyOverlappingAnyKanji() + { + // A short line off to the side (no taller line beneath it) is real text. + FakeOcrLine shortSide = new("注", new Rect(300, 0, 20, 8)); + FakeOcrLine mainLine = new("黒ごま", new Rect(0, 10, 120, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [shortSide, mainLine] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["注", "黒ごま"], result.Select(l => l.Text)); + } + [Fact] + public void FilterFuriganaLines_KeepsShortLineWhenGapIsTooLarge() + { + // Short line far above a taller line is a separate heading/body line, not + // a hugging ruby annotation, so it is kept. + FakeOcrLine shortHeading = new("メモ", new Rect(0, 0, 40, 8)); + FakeOcrLine mainLine = new("黒ごま", new Rect(0, 60, 120, 20)); + + FakeOcrLinesWords ocrResult = new() { Lines = [shortHeading, mainLine] }; + + IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); + + Assert.Equal(["メモ", "黒ごま"], result.Select(l => l.Text)); + } + + private static FakeOcrWord Word(string text, double x, double y, double width, double height) + => new(text, new Rect(x, y, width, height)); + + private sealed class FakeOcrLinesWords : IOcrLinesWords + { + public string Text { get; set; } = string.Empty; + + public IOcrLine[] Lines { get; set; } = []; + + public float Angle { get; set; } + } + + private sealed class FakeOcrLine : IOcrLine + { + public FakeOcrLine(string text, Rect boundingBox) + { + Text = text; + BoundingBox = boundingBox; + } + + public string Text { get; set; } + + public IOcrWord[] Words { get; set; } = []; + + public Rect BoundingBox { get; set; } + } + + private sealed class FakeOcrWord : IOcrWord + { + public FakeOcrWord(string text, Rect boundingBox) + { + Text = text; + BoundingBox = boundingBox; + } + + public string Text { get; set; } + + public Rect BoundingBox { get; set; } + } +} diff --git a/Tests/QrCodeTests.cs b/Tests.Core.Windows/QrCodeTests.cs similarity index 89% rename from Tests/QrCodeTests.cs rename to Tests.Core.Windows/QrCodeTests.cs index fe6f78e0..e627603d 100644 --- a/Tests/QrCodeTests.cs +++ b/Tests.Core.Windows/QrCodeTests.cs @@ -1,7 +1,7 @@ using Text_Grab.Utilities; using ZXing.QrCode.Internal; -namespace Tests; +namespace Text_Grab.Tests.Core.Windows; public class QrCodeTests { diff --git a/Tests.Core.Windows/Tests.Core.Windows.csproj b/Tests.Core.Windows/Tests.Core.Windows.csproj new file mode 100644 index 00000000..aab51b73 --- /dev/null +++ b/Tests.Core.Windows/Tests.Core.Windows.csproj @@ -0,0 +1,50 @@ + + + + net10.0-windows10.0.22621.0 + 10.0.22621.48 + Exe + Text_Grab.Tests.Core.Windows + enable + enable + false + + x64;x86;ARM64 + win-x86;win-x64;win-arm64 + + false + false + + + + + + + + + + + + + + PreserveNewest + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/Tests/TextFiles/Table-Complex-WordBorders.json b/Tests.Core.Windows/TextFiles/Table-Complex-WordBorders.json similarity index 100% rename from Tests/TextFiles/Table-Complex-WordBorders.json rename to Tests.Core.Windows/TextFiles/Table-Complex-WordBorders.json diff --git a/Tests.Core.Windows/TierBoundaryTests.cs b/Tests.Core.Windows/TierBoundaryTests.cs new file mode 100644 index 00000000..3af93948 --- /dev/null +++ b/Tests.Core.Windows/TierBoundaryTests.cs @@ -0,0 +1,63 @@ +using System.Linq; +using System.Reflection; +using Text_Grab.Models; + +namespace Text_Grab.Tests.Core.Windows; + +/// +/// Structural guards on the Core tier. These are cheap and they fail loudly the moment a move +/// smuggles a WPF dependency into a library that is supposed to be headless - which otherwise +/// only shows up much later, as an unexplained UseWPF flip in a csproj diff. +/// +public class TierBoundaryTests +{ + private static readonly Assembly CoreAssembly = typeof(RectangleFExtensions).Assembly; + private static readonly Assembly CoreWindowsAssembly = typeof(IOcrLinesWords).Assembly; + + [Fact] + public void TextGrabCore_ReferencesNoWpfOrWinRtAssemblies() + { + string[] offenders = ReferencedAssemblyNames(CoreAssembly) + .Where(IsUiOrWindowsAssembly) + .ToArray(); + + Assert.True( + offenders.Length == 0, + $"Text-Grab.Core must stay platform-neutral but references: {string.Join(", ", offenders)}"); + } + + [Fact] + public void TextGrabCoreWindows_ReferencesNoWpfAssemblies() + { + // Windows APIs are expected here; WPF is not. Core.Windows keeps UseWPF=false so that the + // OCR, capture and interop code stays usable from a headless host. + string[] offenders = ReferencedAssemblyNames(CoreWindowsAssembly) + .Where(IsWpfAssembly) + .ToArray(); + + Assert.True( + offenders.Length == 0, + $"Text-Grab.Core.Windows must not use WPF but references: {string.Join(", ", offenders)}"); + } + + [Fact] + public void TextGrabCore_DoesNotReferenceTextGrabCoreWindows() + { + // Dependencies point one way: app -> Core.Windows -> Core. + Assert.DoesNotContain( + "Text-Grab.Core.Windows", + ReferencedAssemblyNames(CoreAssembly)); + } + + private static string[] ReferencedAssemblyNames(Assembly assembly) + => [.. assembly.GetReferencedAssemblies().Select(static name => name.Name ?? string.Empty)]; + + private static bool IsWpfAssembly(string name) + => name is "PresentationCore" or "PresentationFramework" or "WindowsBase" or "System.Xaml"; + + private static bool IsUiOrWindowsAssembly(string name) + => IsWpfAssembly(name) + || name is "System.Windows.Forms" or "System.Drawing.Common" + || name.StartsWith("Microsoft.Windows.", System.StringComparison.Ordinal) + || name.StartsWith("Microsoft.WindowsAppSDK", System.StringComparison.Ordinal); +} diff --git a/Tests.Core.Windows/Usings.cs b/Tests.Core.Windows/Usings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/Tests.Core.Windows/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Tests/WindowsAiUtilitiesTests.cs b/Tests.Core.Windows/WindowsAiUtilitiesTests.cs similarity index 99% rename from Tests/WindowsAiUtilitiesTests.cs rename to Tests.Core.Windows/WindowsAiUtilitiesTests.cs index 368b5a39..18f3a30c 100644 --- a/Tests/WindowsAiUtilitiesTests.cs +++ b/Tests.Core.Windows/WindowsAiUtilitiesTests.cs @@ -1,6 +1,6 @@ using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core.Windows; /// /// Tests for the WindowsAiUtilities.CleanRegexResult method. diff --git a/Tests/CalculatorTests.cs b/Tests.Core/CalculatorTests.cs similarity index 99% rename from Tests/CalculatorTests.cs rename to Tests.Core/CalculatorTests.cs index 5201a76d..622368ad 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests.Core/CalculatorTests.cs @@ -3,7 +3,7 @@ using System.Globalization; using Text_Grab.Services; -namespace Tests; +namespace Text_Grab.Tests.Core; public class CalculatorTests { diff --git a/Tests/ColumnSplitUtilitiesTests.cs b/Tests.Core/ColumnSplitUtilitiesTests.cs similarity index 99% rename from Tests/ColumnSplitUtilitiesTests.cs rename to Tests.Core/ColumnSplitUtilitiesTests.cs index c6d1071e..81ca4cb8 100644 --- a/Tests/ColumnSplitUtilitiesTests.cs +++ b/Tests.Core/ColumnSplitUtilitiesTests.cs @@ -1,7 +1,7 @@ using Text_Grab.Models; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core; public class ColumnSplitUtilitiesTests { diff --git a/Tests/EditTextTableDocumentTests.cs b/Tests.Core/EditTextTableDocumentTests.cs similarity index 99% rename from Tests/EditTextTableDocumentTests.cs rename to Tests.Core/EditTextTableDocumentTests.cs index d81f228a..bd0223cf 100644 --- a/Tests/EditTextTableDocumentTests.cs +++ b/Tests.Core/EditTextTableDocumentTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Text_Grab.Models; -namespace Tests; +namespace Text_Grab.Tests.Core; public class EditTextTableDocumentTests { diff --git a/Tests/ExtractedPatternTests.cs b/Tests.Core/ExtractedPatternTests.cs similarity index 99% rename from Tests/ExtractedPatternTests.cs rename to Tests.Core/ExtractedPatternTests.cs index b0e97d48..c585027f 100644 --- a/Tests/ExtractedPatternTests.cs +++ b/Tests.Core/ExtractedPatternTests.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; using Text_Grab.Models; -namespace Tests; +namespace Text_Grab.Tests.Core; public class ExtractedPatternTests { diff --git a/Tests/GrabFrameTableEditStateTests.cs b/Tests.Core/GrabFrameTableEditStateTests.cs similarity index 96% rename from Tests/GrabFrameTableEditStateTests.cs rename to Tests.Core/GrabFrameTableEditStateTests.cs index cd0ffe2e..2a131958 100644 --- a/Tests/GrabFrameTableEditStateTests.cs +++ b/Tests.Core/GrabFrameTableEditStateTests.cs @@ -1,6 +1,6 @@ using Text_Grab.Models; -namespace Tests; +namespace Text_Grab.Tests.Core; public class GrabFrameTableEditStateTests { diff --git a/Tests.Core/IoUtilitiesTests.cs b/Tests.Core/IoUtilitiesTests.cs new file mode 100644 index 00000000..86d7fadf --- /dev/null +++ b/Tests.Core/IoUtilitiesTests.cs @@ -0,0 +1,46 @@ +using Text_Grab; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Text_Grab.Tests.Core; + +// Pure half of the original Tests/FilesIoTests.cs (batch 7a): these three methods only touch +// Text_Grab.Utilities.IoUtilities, which is plain Core. The rest of that file needed WPF +// ([WpfFact]/[WpfTheory]) or app-side FileUtilities/OpenDocumentFilterUtilities/App members and +// kept the FilesIoTests name in Tests. FileUtilities.GetVisualDocumentFilter, the other pure +// method in that file, is Core.Windows-only and moved separately to +// Tests.Core.Windows/FileUtilitiesTests.cs. +public class IoUtilitiesTests +{ + [Theory] + [InlineData(@"C:\Temp\sheet.csv", EtwEditorMode.Spreadsheet)] + [InlineData(@"C:\Temp\sheet.TSV", EtwEditorMode.Spreadsheet)] + [InlineData(@"C:\Temp\sheet.tab", EtwEditorMode.Spreadsheet)] + [InlineData(@"C:\Temp\notes.md", EtwEditorMode.Markdown)] + [InlineData(@"C:\Temp\notes.markdown", EtwEditorMode.Markdown)] + [InlineData(@"C:\Temp\notes.txt", EtwEditorMode.Text)] + [InlineData(@"C:\Temp\data.json", EtwEditorMode.Text)] + public void GetEditorModeForPath_UsesFileExtension(string path, EtwEditorMode expectedMode) + { + Assert.Equal(expectedMode, IoUtilities.GetEditorModeForPath(path)); + } + + [Theory] + [InlineData(@"C:\Temp\scan.png", OpenContentKind.Image)] + [InlineData(@"C:\Temp\scan.PDF", OpenContentKind.PdfDocument)] + [InlineData(@"C:\Temp\notes.txt", OpenContentKind.TextFile)] + public void GetOpenContentKindForPath_ClassifiesVisualDocumentsAndText(string path, OpenContentKind expectedKind) + { + Assert.Equal(expectedKind, IoUtilities.GetOpenContentKindForPath(path)); + } + + [Theory] + [InlineData(".png", true)] + [InlineData(".PDF", true)] + [InlineData(".txt", false)] + [InlineData("", false)] + public void IsVisualDocumentFileExtension_RecognizesImagesAndPdf(string extension, bool expected) + { + Assert.Equal(expected, IoUtilities.IsVisualDocumentFileExtension(extension)); + } +} diff --git a/Tests.Core/MarkdownParsingTests.cs b/Tests.Core/MarkdownParsingTests.cs new file mode 100644 index 00000000..07161832 --- /dev/null +++ b/Tests.Core/MarkdownParsingTests.cs @@ -0,0 +1,69 @@ +using Text_Grab.Utilities; + +namespace Text_Grab.Tests.Core; + +public class MarkdownParsingTests +{ + [Theory] + [InlineData("#")] + [InlineData("##")] + [InlineData(">")] + [InlineData(" >")] + [InlineData("-")] + [InlineData("1.")] + public void LiveBlockTriggerMarkers_AreRecognized(string marker) + { + Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(marker)); + } + + [Theory] + [InlineData("text")] + [InlineData("hello # world")] + [InlineData("1.2")] + public void NonTriggerText_DoesNotPromoteLiveBlock(string text) + { + Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(text)); + } + + [Theory] + [InlineData("**bold**")] + [InlineData("`code`")] + [InlineData("[link](https://example.com)")] + [InlineData("[ ] task")] + [InlineData("[x] done")] + public void CompletedMarkdownSyntax_PromotesLiveParsing(string text) + { + Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + } + + [Theory] + [InlineData("*")] + [InlineData("[link]")] + [InlineData("plain text")] + [InlineData("2026.04 release notes")] + public void IncompleteMarkdownSyntax_DoesNotPromoteLiveParsing(string text) + { + Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + } + + [Theory] + [InlineData("# Heading")] + [InlineData("> quote")] + [InlineData("- item")] + [InlineData("1. item")] + [InlineData("[link](https://example.com)")] + [InlineData("```csharp\nConsole.WriteLine(\"hi\");\n```")] + public void MarkdownLikeText_IsDetectedForPasteParsing(string text) + { + Assert.True(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + } + + [Theory] + [InlineData("Just a normal sentence.")] + [InlineData("2026.04 release notes")] + [InlineData("email me at joe@example.com")] + public void PlainText_IsNotDetectedAsMarkdown(string text) + { + Assert.False(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + } +} diff --git a/Tests/PatternExecutorTests.cs b/Tests.Core/PatternExecutorTests.cs similarity index 71% rename from Tests/PatternExecutorTests.cs rename to Tests.Core/PatternExecutorTests.cs index 23152e0a..2b13a7b9 100644 --- a/Tests/PatternExecutorTests.cs +++ b/Tests.Core/PatternExecutorTests.cs @@ -1,8 +1,13 @@ using Text_Grab.Models; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core; +// Pure half of the original Tests/PatternExecutorTests.cs (batch 7a): PatternExecutor and +// StoredRegex-backed PatternItem construction are Core-only. The three PatternItemCatalog +// tests stayed behind as Tests/PatternItemCatalogTests.cs - PatternItemCatalog.GetAll()/ +// GetByName() are app-side (Text-Grab/Models/PatternItemCatalog.cs, per e677b54). This half +// has 10 methods against that one's 3, so it kept the original name. public class PatternExecutorTests { // A deterministic saved-regex item that does not depend on the machine's saved patterns. @@ -12,45 +17,6 @@ private static PatternItem SavedEmail() => private static PatternItem RecognizerByName(string name) => new(BuiltInRecognizer.GetByName(name) ?? throw new InvalidOperationException($"missing recognizer {name}")); - // ── PatternItem catalog ─────────────────────────────────────────────────── - - [Fact] - public void GetAll_ListsSavedRegexesBeforeRecognizers() - { - IReadOnlyList all = PatternItem.GetAll(); - - int firstRecognizer = -1; - int lastSaved = -1; - for (int i = 0; i < all.Count; i++) - { - if (all[i].Kind == PatternKind.Recognizer && firstRecognizer < 0) - firstRecognizer = i; - if (all[i].Kind == PatternKind.SavedRegex) - lastSaved = i; - } - - Assert.True(firstRecognizer >= 0, "expected at least one recognizer item"); - Assert.True(lastSaved < firstRecognizer, "all saved regexes should precede recognizers"); - } - - [Fact] - public void GetAll_IncludesEveryRecognizerWithSmartGroup() - { - List recognizers = [.. PatternItem.GetAll().Where(p => p.Kind == PatternKind.Recognizer)]; - - Assert.Equal(BuiltInRecognizer.GetAll().Count, recognizers.Count); - Assert.All(recognizers, p => Assert.Equal(PatternItem.SmartGroup, p.GroupLabel)); - } - - [Fact] - public void GetByName_FindsRecognizer_CaseInsensitive() - { - PatternItem? email = PatternItem.GetByName("EMAIL"); - - Assert.NotNull(email); - Assert.Equal(PatternKind.Recognizer, email!.Kind); - } - // ── PatternExecutor – recognizer-backed ─────────────────────────────────── [Fact] diff --git a/Tests/ProtocolUtilitiesTests.cs b/Tests.Core/ProtocolUtilitiesTests.cs similarity index 58% rename from Tests/ProtocolUtilitiesTests.cs rename to Tests.Core/ProtocolUtilitiesTests.cs index 9bd0ce64..52a6ce9f 100644 --- a/Tests/ProtocolUtilitiesTests.cs +++ b/Tests.Core/ProtocolUtilitiesTests.cs @@ -1,9 +1,13 @@ using System; -using System.IO; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core; +// Pure half of the original Tests/ProtocolUtilitiesTests.cs (batch 7a): IsProtocolUri and +// TryParseProtocolUri live on Text-Grab.Core's ProtocolUtilities. The +// TryGetSafeProtocolFilePath tests stayed behind as Tests/ProtocolHandlerUtilitiesTests.cs - +// ProtocolHandlerUtilities is app-side (Text-Grab/Utilities/ProtocolHandlerUtilities.cs). This +// half has 9 methods against that one's 6, so it kept the original name. public class ProtocolUtilitiesTests { [Theory] @@ -116,81 +120,4 @@ public void TryParseProtocolUri_IgnoresMalformedQueryPairs() Assert.Single(parameters); Assert.Equal(@"C:\a.png", parameters["path"]); } - - // ── TryGetSafeProtocolFilePath ──────────────────────────────────────────── - - [Fact] - public void TryGetSafeProtocolFilePath_AcceptsImageInTempFolder() - { - string tempImage = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.png"); - File.WriteAllBytes(tempImage, [0]); - try - { - bool safe = ProtocolUtilities.TryGetSafeProtocolFilePath(tempImage, out string fullPath); - - Assert.True(safe); - Assert.Equal(Path.GetFullPath(tempImage), fullPath); - } - finally - { - File.Delete(tempImage); - } - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData(@"\\server\share\image.png")] // UNC: would trigger an SMB credential leak - [InlineData("//server/share/image.png")] // forward-slash UNC - [InlineData(@"\\?\C:\Windows\image.png")] // extended-length device path - [InlineData(@"\\.\PhysicalDrive0")] // device namespace - public void TryGetSafeProtocolFilePath_RejectsUncDeviceAndEmptyPaths(string? path) - { - Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(path, out _)); - } - - [Fact] - public void TryGetSafeProtocolFilePath_RejectsPathOutsideAllowedRoots() - { - // The Windows folder is never an allowed root; rejection happens before any - // existence check, so the file need not exist. - string outside = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Windows), - $"text-grab-{Guid.NewGuid():N}.png"); - - Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(outside, out _)); - } - - [Fact] - public void TryGetSafeProtocolFilePath_RejectsTraversalEscapingAllowedRoot() - { - // Starts inside Temp but climbs out to the Windows folder. - string traversal = Path.Combine(Path.GetTempPath(), "..", "..", "..", "Windows", "image.png"); - - Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(traversal, out _)); - } - - [Fact] - public void TryGetSafeProtocolFilePath_RejectsNonImageExtensionInAllowedRoot() - { - string tempText = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.txt"); - File.WriteAllText(tempText, "hello"); - try - { - Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(tempText, out _)); - } - finally - { - File.Delete(tempText); - } - } - - [Fact] - public void TryGetSafeProtocolFilePath_RejectsNonexistentImageInAllowedRoot() - { - string missing = Path.Combine(Path.GetTempPath(), $"text-grab-missing-{Guid.NewGuid():N}.png"); - - Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(missing, out _)); - } } diff --git a/Tests/RecognizerExecutorTests.cs b/Tests.Core/RecognizerExecutorTests.cs similarity index 73% rename from Tests/RecognizerExecutorTests.cs rename to Tests.Core/RecognizerExecutorTests.cs index 3de3c575..5672d68b 100644 --- a/Tests/RecognizerExecutorTests.cs +++ b/Tests.Core/RecognizerExecutorTests.cs @@ -2,8 +2,13 @@ using Text_Grab.Models; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core; +// Pure half of the original Tests/RecognizerExecutorTests.cs (batch 7a): RecognizerExecutor and +// BuiltInRecognizer are Core-only. The GrabTemplateExecutor-backed tests (recognizer placeholders +// and parsing) moved into the existing Tests/GrabTemplateExecutorTests.cs instead of a new class - +// GrabTemplateExecutor needs System.Windows.Rect and stays app-side, and that file already covers +// it comprehensively. public class RecognizerExecutorTests { private static BuiltInRecognizer Get(string id) => @@ -130,65 +135,6 @@ public void ApplyRecognizer_MatchedText_KeepsOriginalSpan() Assert.Equal("$5", result); } - // ── GrabTemplateExecutor – recognizer placeholders ──────────────────────── - - [Fact] - public void ApplyRecognizerPlaceholders_AllMatches_Substitutes() - { - string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("Found {r:Number:all}", "1 2 3"); - Assert.Equal("Found 1, 2, 3", result); - } - - [Fact] - public void ApplyRecognizerPlaceholders_TextOutput_UsesMatchedText() - { - string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("{r:Currency:first:text}", "it costs $5"); - Assert.Equal("$5", result); - } - - [Fact] - public void ApplyRecognizerPlaceholders_UnknownRecognizer_LeavesPlaceholder() - { - string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("{r:Nope:first}", "anything 5"); - Assert.Equal("{r:Nope:first}", result); - } - - [Fact] - public void ApplyRecognizerPlaceholders_LeavesPatternPlaceholdersUntouched() - { - // Recognizer pass must only resolve {r:...}, never {p:...} - string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders( - "{p:Email:first} {r:Number:first}", "value 5"); - Assert.Equal("{p:Email:first} 5", result); - } - - // ── GrabTemplateExecutor – parsing ──────────────────────────────────────── - - [Fact] - public void ParseRecognizerMatches_ExtractsModeAndOutputKind() - { - List matches = - GrabTemplateExecutor.ParseRecognizerMatchesFromOutputTemplate("{r:Number:all:text}"); - - TemplateRecognizerMatch match = Assert.Single(matches); - Assert.Equal("Number", match.RecognizerName); - Assert.Equal("all", match.MatchMode); - Assert.Equal(RecognizerOutputKind.MatchedText, match.OutputKind); - Assert.Equal(Get("number").Id, match.RecognizerId); - } - - [Fact] - public void ParseRecognizerMatches_WithSeparator_ParsesValueOutputAndSeparator() - { - List matches = - GrabTemplateExecutor.ParseRecognizerMatchesFromOutputTemplate("{r:Number:all:value:; }"); - - TemplateRecognizerMatch match = Assert.Single(matches); - Assert.Equal("all", match.MatchMode); - Assert.Equal("; ", match.Separator); - Assert.Equal(RecognizerOutputKind.ResolvedValue, match.OutputKind); - } - // ── FormatResolvedValue – resolution shapes (guards library coupling) ───── [Fact] @@ -275,18 +221,4 @@ public void GetMatches_DateTime_ResolvesDateRange() Assert.Equal("2026-01-01 → 2026-01-05", match.ResolvedValue); } - - // ── ApplyTextOnlyTemplate – recognizer-only ─────────────────────────────── - - [Fact] - public void ApplyTextOnlyTemplate_RecognizerPlaceholder_Resolves() - { - GrabTemplate template = new("Numbers") - { - OutputTemplate = "Numbers: {r:Number:all}" - }; - - string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "got 1 and 2"); - Assert.Equal("Numbers: 1, 2", result); - } } diff --git a/Tests.Core/RectangleFExtensionsTests.cs b/Tests.Core/RectangleFExtensionsTests.cs new file mode 100644 index 00000000..f5e154db --- /dev/null +++ b/Tests.Core/RectangleFExtensionsTests.cs @@ -0,0 +1,66 @@ +using System.Drawing; + +namespace Text_Grab.Tests.Core; + +public class RectangleFExtensionsTests +{ + [Theory] + [InlineData(0, 0, 10, 10, true)] + [InlineData(-5, -5, 1, 1, true)] + [InlineData(0, 0, 0, 10, false)] // zero width + [InlineData(0, 0, 10, 0, false)] // zero height + [InlineData(float.NaN, 0, 10, 10, false)] + [InlineData(0, float.NaN, 10, 10, false)] + [InlineData(float.PositiveInfinity, 0, 10, 10, false)] + [InlineData(0, 0, float.NegativeInfinity, 10, false)] + public void IsGood_RejectsDegenerateAndNonFiniteRects(float x, float y, float w, float h, bool expected) + { + RectangleF rect = new(x, y, w, h); + + Assert.Equal(expected, rect.IsGood()); + } + + [Fact] + public void CenterPoint_ReturnsMidpoint() + { + RectangleF rect = new(10, 20, 30, 40); + + PointF center = rect.CenterPoint(); + + Assert.Equal(25f, center.X); + Assert.Equal(40f, center.Y); + } + + [Fact] + public void GetScaledUpByFraction_ScalesPositionAndSize() + { + RectangleF scaled = new RectangleF(10, 20, 30, 40).GetScaledUpByFraction(2.0); + + Assert.Equal(new RectangleF(20, 40, 60, 80), scaled); + } + + [Fact] + public void GetScaleSizeByFraction_LeavesOriginInPlace() + { + RectangleF scaled = new RectangleF(10, 20, 30, 40).GetScaleSizeByFraction(0.5); + + Assert.Equal(new RectangleF(10, 20, 15, 20), scaled); + } + + [Fact] + public void Union_CombinesBothRects() + { + RectangleF union = new RectangleF(0, 0, 10, 10).Union(new RectangleF(20, 20, 10, 10)); + + Assert.Equal(new RectangleF(0, 0, 30, 30), union); + } + + [Fact] + public void Union_IgnoresEmptyOperandsRatherThanPullingToOrigin() + { + RectangleF populated = new(50, 50, 10, 10); + + Assert.Equal(populated, populated.Union(RectangleF.Empty)); + Assert.Equal(populated, RectangleF.Empty.Union(populated)); + } +} diff --git a/Tests/SpreadsheetUndoHistoryTests.cs b/Tests.Core/SpreadsheetUndoHistoryTests.cs similarity index 98% rename from Tests/SpreadsheetUndoHistoryTests.cs rename to Tests.Core/SpreadsheetUndoHistoryTests.cs index 4dd9b6a9..3e57c359 100644 --- a/Tests/SpreadsheetUndoHistoryTests.cs +++ b/Tests.Core/SpreadsheetUndoHistoryTests.cs @@ -1,6 +1,6 @@ using Text_Grab.Models; -namespace Tests; +namespace Text_Grab.Tests.Core; public class SpreadsheetUndoHistoryTests { diff --git a/Tests/StringMethodTests.cs b/Tests.Core/StringMethodTests.cs similarity index 99% rename from Tests/StringMethodTests.cs rename to Tests.Core/StringMethodTests.cs index da6a0b87..4ccbf2e2 100644 --- a/Tests/StringMethodTests.cs +++ b/Tests.Core/StringMethodTests.cs @@ -4,7 +4,7 @@ using Text_Grab; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core; public class StringMethodTests { diff --git a/Tests.Core/Tests.Core.csproj b/Tests.Core/Tests.Core.csproj new file mode 100644 index 00000000..672e068a --- /dev/null +++ b/Tests.Core/Tests.Core.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + Exe + Text_Grab.Tests.Core + enable + enable + false + + + + + + + + + + + diff --git a/Tests/TextSearchUtilitiesTests.cs b/Tests.Core/TextSearchUtilitiesTests.cs similarity index 98% rename from Tests/TextSearchUtilitiesTests.cs rename to Tests.Core/TextSearchUtilitiesTests.cs index 70abc954..8dec67e2 100644 --- a/Tests/TextSearchUtilitiesTests.cs +++ b/Tests.Core/TextSearchUtilitiesTests.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core; public class TextSearchUtilitiesTests { diff --git a/Tests/ThirdPartyNoticeUtilitiesTests.cs b/Tests.Core/ThirdPartyNoticeUtilitiesTests.cs similarity index 98% rename from Tests/ThirdPartyNoticeUtilitiesTests.cs rename to Tests.Core/ThirdPartyNoticeUtilitiesTests.cs index 84b896f3..f7cf3ff2 100644 --- a/Tests/ThirdPartyNoticeUtilitiesTests.cs +++ b/Tests.Core/ThirdPartyNoticeUtilitiesTests.cs @@ -1,7 +1,7 @@ using System.Linq; using Text_Grab.Utilities; -namespace Tests; +namespace Text_Grab.Tests.Core; public class ThirdPartyNoticeUtilitiesTests { diff --git a/Tests/UnitConversionTests.cs b/Tests.Core/UnitConversionTests.cs similarity index 99% rename from Tests/UnitConversionTests.cs rename to Tests.Core/UnitConversionTests.cs index 1170dd15..05ec9c28 100644 --- a/Tests/UnitConversionTests.cs +++ b/Tests.Core/UnitConversionTests.cs @@ -1,6 +1,6 @@ using Text_Grab.Services; -namespace Tests; +namespace Text_Grab.Tests.Core; public class UnitConversionTests { diff --git a/Tests.Core/Usings.cs b/Tests.Core/Usings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/Tests.Core/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Tests/BarcodeUtilitiesImageTests.cs b/Tests/BarcodeUtilitiesImageTests.cs new file mode 100644 index 00000000..65bd45f7 --- /dev/null +++ b/Tests/BarcodeUtilitiesImageTests.cs @@ -0,0 +1,26 @@ +using System.Drawing; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +// WPF half of the original Tests/BarcodeUtilitiesTests.cs (batch 7a). [WpfFact] needs +// Xunit.StaFact, which cannot be referenced from Tests.Core.Windows (it pulls in WindowsBase, +// which TierBoundaryTests bans), so this one test stayed behind while the rest moved to +// Tests.Core.Windows/BarcodeUtilitiesTests.cs, which kept the original name. +public class BarcodeUtilitiesImageTests +{ + [WpfFact] + public void ReadTestSingleQRCode() + { + string expectedOutput = "This is a test of the QR Code system"; + string testFilePath = FileUtilities.GetPathToLocalFile(@".\Images\QrCodeTestImage.png"); + + Bitmap testBmp = new(testFilePath); + + List result = BarcodeUtilities.TryToReadBarcodes(testBmp); + + Assert.Single(result); + Assert.Equal(expectedOutput, result[0].RawOutput); + } +} diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs index 22d2c092..38b93e15 100644 --- a/Tests/ClipboardUtilitiesTests.cs +++ b/Tests/ClipboardUtilitiesTests.cs @@ -36,7 +36,7 @@ public class ClipboardUtilitiesTests [Fact] public void ConvertHtmlToTabSeparated_ParsesBasicTable() { - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(SampleCfHtml); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(SampleCfHtml); string[] lines = result.Split('\n'); Assert.Equal(3, lines.Length); @@ -54,7 +54,7 @@ public void ConvertHtmlToTabSeparated_HandlesBrTag() """; - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); Assert.Equal("4 A\tSpring", result); } @@ -63,7 +63,7 @@ public void ConvertHtmlToTabSeparated_HandlesBrTag() public void ConvertHtmlToTabSeparated_ReturnsEmptyWhenNoTable() { string html = "

No table here

"; - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); Assert.Empty(result); } @@ -76,7 +76,7 @@ public void ConvertHtmlToTabSeparated_DecodesHtmlEntities() """; - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); Assert.Equal("A & B\t", result); } @@ -91,7 +91,7 @@ public void ConvertHtmlToTabSeparated_HandlesThElements() """; - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); string[] lines = result.Split('\n'); Assert.Equal(2, lines.Length); @@ -109,7 +109,7 @@ public void ConvertHtmlToTabSeparated_HandlesColspan() """; - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); string[] lines = result.Split('\n'); Assert.Equal(2, lines.Length); @@ -127,7 +127,7 @@ public void ConvertHtmlToTabSeparated_HandlesRowspan() """; - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); string[] lines = result.Split('\n'); Assert.Equal(2, lines.Length); @@ -145,7 +145,7 @@ public void ConvertHtmlToTabSeparated_DoesNotOverwriteRowspanWithColspan() """; - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); string[] lines = result.Split('\n'); Assert.Equal(2, lines.Length); @@ -173,7 +173,7 @@ public void ConvertHtmlToTabSeparated_DoesNotOverwriteRowspanWithColspan() [Fact] public void ConvertHtmlToTabSeparated_ParsesBrowserExtensionRegionTable() { - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(ExtensionRegionTableCfHtml); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(ExtensionRegionTableCfHtml); string[] lines = result.Split('\n'); Assert.Equal(3, lines.Length); @@ -186,13 +186,13 @@ public void ConvertHtmlToTabSeparated_ParsesBrowserExtensionRegionTable() [Fact] public void BuildCfHtmlTable_RoundTripsThroughConvertHtmlToTabSeparated() { - string cfHtml = ClipboardUtilities.BuildCfHtmlTable( + string cfHtml = CfHtmlTableUtilities.BuildCfHtmlTable( [ ["Month", "Int", "Season"], ["January", "1", "Winter"], ]); - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(cfHtml); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(cfHtml); string[] lines = result.Split('\n'); Assert.Equal(2, lines.Length); @@ -203,7 +203,7 @@ public void BuildCfHtmlTable_RoundTripsThroughConvertHtmlToTabSeparated() [Fact] public void BuildCfHtmlTable_HeaderOffsetsPointAtFragmentBoundaries() { - string cfHtml = ClipboardUtilities.BuildCfHtmlTable([["a", "b"]]); + string cfHtml = CfHtmlTableUtilities.BuildCfHtmlTable([["a", "b"]]); int startHtml = int.Parse(cfHtml.Substring(cfHtml.IndexOf("StartHTML:") + "StartHTML:".Length, 10)); int endHtml = int.Parse(cfHtml.Substring(cfHtml.IndexOf("EndHTML:") + "EndHTML:".Length, 10)); @@ -224,9 +224,9 @@ public void BuildCfHtmlTable_HeaderOffsetsPointAtFragmentBoundaries() [Fact] public void BuildCfHtmlTable_EscapesHtmlAndConvertsNewlinesToBreaks() { - string cfHtml = ClipboardUtilities.BuildCfHtmlTable([["A & B", "line1\r\nline2"]]); + string cfHtml = CfHtmlTableUtilities.BuildCfHtmlTable([["A & B", "line1\r\nline2"]]); - string result = ClipboardUtilities.ConvertHtmlToTabSeparated(cfHtml); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(cfHtml); Assert.Equal("A & B\tline1 line2", result); } @@ -234,6 +234,6 @@ public void BuildCfHtmlTable_EscapesHtmlAndConvertsNewlinesToBreaks() [Fact] public void BuildCfHtmlTable_ReturnsEmptyForNoRows() { - Assert.Equal(string.Empty, ClipboardUtilities.BuildCfHtmlTable([])); + Assert.Equal(string.Empty, CfHtmlTableUtilities.BuildCfHtmlTable([])); } } diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs index 75d54c06..6cb80f7c 100644 --- a/Tests/EditTextWindowSpreadsheetTests.cs +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -139,7 +139,7 @@ public void BuildSpreadsheetSelectionHtml_IncludesOnlySelectedCellsAsTableRows() (5, 5) ]); - string tabSeparated = Text_Grab.Utilities.ClipboardUtilities.ConvertHtmlToTabSeparated(html); + string tabSeparated = Text_Grab.Utilities.CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); Assert.Equal("a1\tc1" + Environment.NewLine + "a2\tc2", tabSeparated.Replace("\n", Environment.NewLine)); } diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index 6560b7ca..3442ac41 100644 --- a/Tests/FilesIoTests.cs +++ b/Tests/FilesIoTests.cs @@ -7,6 +7,12 @@ namespace Tests; +// App-coupled remainder of the original file (batch 7a). Its IoUtilities-only tests moved to +// Tests.Core/IoUtilitiesTests.cs and its FileUtilities.GetVisualDocumentFilter test moved to +// Tests.Core.Windows/FileUtilitiesTests.cs. GetOpenDocumentFilter_IncludesVisualAndTextOptions +// followed it there in 7b, once GrabFrameFileUtilities stopped needing the app to build that +// filter. What is left here is blocked on WPF ([WpfFact]/[WpfTheory] needing Xunit.StaFact, +// which cannot be referenced outside Tests) or on the app-side members. public class FilesIoTests { private const string fontSamplePath = @"Images\font_sample.png"; @@ -97,61 +103,6 @@ public async Task ReadNotExistingImageFileEmpty(FileStorageKind storageKind) Assert.Null(emptyReturn); } - [Theory] - [InlineData(@"C:\Temp\sheet.csv", EtwEditorMode.Spreadsheet)] - [InlineData(@"C:\Temp\sheet.TSV", EtwEditorMode.Spreadsheet)] - [InlineData(@"C:\Temp\sheet.tab", EtwEditorMode.Spreadsheet)] - [InlineData(@"C:\Temp\notes.md", EtwEditorMode.Markdown)] - [InlineData(@"C:\Temp\notes.markdown", EtwEditorMode.Markdown)] - [InlineData(@"C:\Temp\notes.txt", EtwEditorMode.Text)] - [InlineData(@"C:\Temp\data.json", EtwEditorMode.Text)] - public void GetEditorModeForPath_UsesFileExtension(string path, EtwEditorMode expectedMode) - { - Assert.Equal(expectedMode, IoUtilities.GetEditorModeForPath(path)); - } - - [Theory] - [InlineData(@"C:\Temp\scan.png", OpenContentKind.Image)] - [InlineData(@"C:\Temp\scan.PDF", OpenContentKind.PdfDocument)] - [InlineData(@"C:\Temp\notes.txt", OpenContentKind.TextFile)] - public void GetOpenContentKindForPath_ClassifiesVisualDocumentsAndText(string path, OpenContentKind expectedKind) - { - Assert.Equal(expectedKind, IoUtilities.GetOpenContentKindForPath(path)); - } - - [Theory] - [InlineData(".png", true)] - [InlineData(".PDF", true)] - [InlineData(".txt", false)] - [InlineData("", false)] - public void IsVisualDocumentFileExtension_RecognizesImagesAndPdf(string extension, bool expected) - { - Assert.Equal(expected, IoUtilities.IsVisualDocumentFileExtension(extension)); - } - - [Fact] - public void GetVisualDocumentFilter_IncludesPdfSupport() - { - string filter = FileUtilities.GetVisualDocumentFilter(); - - Assert.Contains("Image and PDF files|", filter); - Assert.Contains("PDF files|*.pdf", filter); - Assert.Contains("Image files|", filter); - } - - [Fact] - public void GetOpenDocumentFilter_IncludesVisualAndTextOptions() - { - string filter = FileUtilities.GetOpenDocumentFilter(); - - Assert.Contains("Supported documents|", filter); - Assert.Contains("Image and PDF files|", filter); - Assert.Contains("Spreadsheet documents|*.csv;*.tsv;*.tab", filter); - Assert.Contains("Markdown documents|*.md;*.markdown", filter); - Assert.Contains("Text documents (*.txt)|*.txt", filter); - Assert.Contains("All files (*.*)|*.*", filter); - } - [WpfFact] public void GetDroppedFilePaths_ReturnsExistingFilesOnly() { diff --git a/Tests/FreeformCaptureUtilitiesTests.cs b/Tests/FreeformCaptureUtilitiesTests.cs index 3cf860ff..91bc624c 100644 --- a/Tests/FreeformCaptureUtilitiesTests.cs +++ b/Tests/FreeformCaptureUtilitiesTests.cs @@ -48,13 +48,13 @@ public void CreateMaskedBitmap_WhitensPixelsOutsideThePolygon() using Graphics graphics = Graphics.FromImage(sourceBitmap); graphics.Clear(System.Drawing.Color.Black); - using Bitmap maskedBitmap = FreeformCaptureUtilities.CreateMaskedBitmap( + using Bitmap maskedBitmap = BitmapMaskUtilities.CreateMaskedBitmap( sourceBitmap, [ - new Point(2, 2), - new Point(7, 2), - new Point(7, 7), - new Point(2, 7) + new PointF(2, 2), + new PointF(7, 2), + new PointF(7, 7), + new PointF(2, 7) ]); Assert.Equal(System.Drawing.Color.Gray.ToArgb(), maskedBitmap.GetPixel(0, 0).ToArgb()); diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs index 0ee8f830..94f1306a 100644 --- a/Tests/GrabTemplateExecutorTests.cs +++ b/Tests/GrabTemplateExecutorTests.cs @@ -545,4 +545,76 @@ public void ValidateOutputTemplate_InvalidMatchMode_ReturnsIssue() Assert.NotEmpty(issues); Assert.Contains(issues, i => i.Contains("invalid_mode")); } + + // ── Recognizer placeholders ──────────────────────────────────────────────── + // Moved from Tests/RecognizerExecutorTests.cs in batch 7a: GrabTemplateExecutor needs + // System.Windows.Rect (Text-Grab/Utilities/GrabTemplateExecutor.cs), so it stays app-side and + // these tests could not follow the rest of RecognizerExecutorTests to Tests.Core. + + [Fact] + public void ApplyRecognizerPlaceholders_AllMatches_Substitutes() + { + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("Found {r:Number:all}", "1 2 3"); + Assert.Equal("Found 1, 2, 3", result); + } + + [Fact] + public void ApplyRecognizerPlaceholders_TextOutput_UsesMatchedText() + { + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("{r:Currency:first:text}", "it costs $5"); + Assert.Equal("$5", result); + } + + [Fact] + public void ApplyRecognizerPlaceholders_UnknownRecognizer_LeavesPlaceholder() + { + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders("{r:Nope:first}", "anything 5"); + Assert.Equal("{r:Nope:first}", result); + } + + [Fact] + public void ApplyRecognizerPlaceholders_LeavesPatternPlaceholdersUntouched() + { + // Recognizer pass must only resolve {r:...}, never {p:...} + string result = GrabTemplateExecutor.ApplyRecognizerPlaceholders( + "{p:Email:first} {r:Number:first}", "value 5"); + Assert.Equal("{p:Email:first} 5", result); + } + + [Fact] + public void ParseRecognizerMatches_ExtractsModeAndOutputKind() + { + List matches = + GrabTemplateExecutor.ParseRecognizerMatchesFromOutputTemplate("{r:Number:all:text}"); + + TemplateRecognizerMatch match = Assert.Single(matches); + Assert.Equal("Number", match.RecognizerName); + Assert.Equal("all", match.MatchMode); + Assert.Equal(RecognizerOutputKind.MatchedText, match.OutputKind); + Assert.Equal(BuiltInRecognizer.GetById("number")!.Id, match.RecognizerId); + } + + [Fact] + public void ParseRecognizerMatches_WithSeparator_ParsesValueOutputAndSeparator() + { + List matches = + GrabTemplateExecutor.ParseRecognizerMatchesFromOutputTemplate("{r:Number:all:value:; }"); + + TemplateRecognizerMatch match = Assert.Single(matches); + Assert.Equal("all", match.MatchMode); + Assert.Equal("; ", match.Separator); + Assert.Equal(RecognizerOutputKind.ResolvedValue, match.OutputKind); + } + + [Fact] + public void ApplyTextOnlyTemplate_RecognizerPlaceholder_Resolves() + { + GrabTemplate template = new("Numbers") + { + OutputTemplate = "Numbers: {r:Number:all}" + }; + + string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "got 1 and 2"); + Assert.Equal("Numbers: 1, 2", result); + } } diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs index a2153a53..16a6343f 100644 --- a/Tests/HistoryServiceTests.cs +++ b/Tests/HistoryServiceTests.cs @@ -1,3 +1,4 @@ +using System.Drawing; using System.Text.Json; using System.Text.Json.Serialization; using System.Windows; @@ -122,7 +123,7 @@ public void ImageHistory_SeparatesPdfDocumentsFromRecentGrabs() Assert.Same(newerPdf, Assert.Single(historyService.GetRecentPdfDocuments())); Assert.Equal(4, newerPdf.SourcePageIndex); Assert.True(historyService.HasAnyRecentGrabs()); - Assert.Same(olderGrab, HistoryService.GetMostRecentGrab([olderGrab, newerPdf])); + Assert.Same(olderGrab, HistoryFileUtilities.GetMostRecentGrab([olderGrab, newerPdf])); } [Fact] @@ -134,7 +135,7 @@ public void GetMostRecentGrab_ReturnsNull_WhenHistoryOnlyContainsPdfs() SourceContentKind = OpenContentKind.PdfDocument, }; - Assert.Null(HistoryService.GetMostRecentGrab([pdf])); + Assert.Null(HistoryFileUtilities.GetMostRecentGrab([pdf])); } [Fact] @@ -157,7 +158,7 @@ public void VisualHistoryRetention_LimitsGrabsAndPdfsIndependently() }); } - List itemsToRemove = HistoryService.GetExcessVisualHistoryItems(historyItems); + List itemsToRemove = HistoryFileUtilities.GetExcessVisualHistoryItems(historyItems); Assert.Equal(4, itemsToRemove.Count); Assert.Contains(itemsToRemove, history => history.ID == "grab-0"); @@ -176,7 +177,7 @@ public async Task ImageHistory_KeepsInlineWordBorderJsonWhileMirroringSidecarSto { Word = "hello", DisplayText = $"hello{Environment.NewLine}world", - BorderRect = new Rect(1, 2, 30, 40), + BorderRect = new RectangleF(1, 2, 30, 40), DisplayLineHeight = 18, KeepSingleLineOutput = true, LineNumber = 1, diff --git a/Tests/Images/Table-Complex.png b/Tests/Images/Table-Complex.png deleted file mode 100644 index 116e02b6..00000000 Binary files a/Tests/Images/Table-Complex.png and /dev/null differ diff --git a/Tests/MarkdownDocumentUtilitiesTests.cs b/Tests/MarkdownDocumentUtilitiesTests.cs index 48929356..404d6d8e 100644 --- a/Tests/MarkdownDocumentUtilitiesTests.cs +++ b/Tests/MarkdownDocumentUtilitiesTests.cs @@ -24,9 +24,9 @@ public void Markdown_RoundTrips_CommonFormatting() ``` """; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + string serialized = MarkdownFlowDocumentUtilities.SerializeToMarkdown(document); Assert.Contains("# Heading", serialized); Assert.Contains("**bold**", serialized); @@ -47,9 +47,9 @@ public void Markdown_Tables_RoundTrip_ToPipeTable() | Beta | 99 | """; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + string serialized = MarkdownFlowDocumentUtilities.SerializeToMarkdown(document); Assert.Contains("| Name | Value |", serialized); Assert.Contains("| Alpha | 42 |", serialized); @@ -64,9 +64,9 @@ public void Markdown_TaskLists_RoundTrip_ToCheckboxMarkers() - [x] done item """; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + string serialized = MarkdownFlowDocumentUtilities.SerializeToMarkdown(document); Assert.Contains("- [ ] open item", serialized); Assert.Contains("- [x] done item", serialized); @@ -80,14 +80,14 @@ 5. fifth 6. sixth """; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument( + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument( markdown, new FontFamily("Segoe UI"), 16); System.Windows.Documents.List list = Assert.IsType(Assert.Single(document.Blocks)); - string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + string serialized = MarkdownFlowDocumentUtilities.SerializeToMarkdown(document); Assert.Equal(5, list.StartIndex); Assert.Equal($"5. fifth{Environment.NewLine}6. sixth", serialized); @@ -99,7 +99,7 @@ public void PlainText_WithMarkdownCharacters_IsEscapedDuringSerialization() FlowDocument document = new(); document.Blocks.Add(new Paragraph(new Run("*literal* [value]"))); - string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + string serialized = MarkdownFlowDocumentUtilities.SerializeToMarkdown(document); Assert.Equal(@"\*literal\* \[value\]", serialized); } @@ -110,83 +110,20 @@ public void PreserveLiteralMarkdown_KeepsTypedMarkdownSyntax() FlowDocument document = new(); document.Blocks.Add(new Paragraph(new Run("**bold** [link](https://example.com)"))); - string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document, preserveLiteralMarkdown: true); + string serialized = MarkdownFlowDocumentUtilities.SerializeToMarkdown(document, preserveLiteralMarkdown: true); Assert.Equal("**bold** [link](https://example.com)", serialized); } - [Theory] - [InlineData("#")] - [InlineData("##")] - [InlineData(">")] - [InlineData(" >")] - [InlineData("-")] - [InlineData("1.")] - public void LiveBlockTriggerMarkers_AreRecognized(string marker) - { - Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(marker)); - } - - [Theory] - [InlineData("text")] - [InlineData("hello # world")] - [InlineData("1.2")] - public void NonTriggerText_DoesNotPromoteLiveBlock(string text) - { - Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(text)); - } - - [Theory] - [InlineData("**bold**")] - [InlineData("`code`")] - [InlineData("[link](https://example.com)")] - [InlineData("[ ] task")] - [InlineData("[x] done")] - public void CompletedMarkdownSyntax_PromotesLiveParsing(string text) - { - Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); - } - - [Theory] - [InlineData("*")] - [InlineData("[link]")] - [InlineData("plain text")] - [InlineData("2026.04 release notes")] - public void IncompleteMarkdownSyntax_DoesNotPromoteLiveParsing(string text) - { - Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); - } - - [Theory] - [InlineData("# Heading")] - [InlineData("> quote")] - [InlineData("- item")] - [InlineData("1. item")] - [InlineData("[link](https://example.com)")] - [InlineData("```csharp\nConsole.WriteLine(\"hi\");\n```")] - public void MarkdownLikeText_IsDetectedForPasteParsing(string text) - { - Assert.True(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); - } - - [Theory] - [InlineData("Just a normal sentence.")] - [InlineData("2026.04 release notes")] - [InlineData("email me at joe@example.com")] - public void PlainText_IsNotDetectedAsMarkdown(string text) - { - Assert.False(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); - } - /// /// Mirrors exactly what EditTextWindow.SelectInEditor does with a Find & Replace match: /// map the raw start and raw start+length offsets to positions independently, then read the /// rendered text between them. /// - private static string MapAndSlice(FlowDocument document, MarkdownDocumentUtilities.MarkdownOffsetMap map, int rawStart, int length) + private static string MapAndSlice(FlowDocument document, MarkdownFlowDocumentUtilities.MarkdownOffsetMap map, int rawStart, int length) { - TextPointer start = MarkdownDocumentUtilities.MapRawOffsetToPosition(document, map, rawStart); - TextPointer end = MarkdownDocumentUtilities.MapRawOffsetToPosition(document, map, rawStart + length); + TextPointer start = MarkdownFlowDocumentUtilities.MapRawOffsetToPosition(document, map, rawStart); + TextPointer end = MarkdownFlowDocumentUtilities.MapRawOffsetToPosition(document, map, rawStart + length); return new TextRange(start, end).Text; } @@ -194,8 +131,8 @@ private static string MapAndSlice(FlowDocument document, MarkdownDocumentUtiliti public void MapRawOffsetToPosition_SkipsStrippedBoldMarkers() { const string markdown = "Plain **bold** text with a [link](https://example.com)."; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("bold", StringComparison.Ordinal); @@ -206,8 +143,8 @@ public void MapRawOffsetToPosition_SkipsStrippedBoldMarkers() public void MapRawOffsetToPosition_SkipsLinkBracketsAndUrl() { const string markdown = "Plain **bold** text with a [link](https://example.com)."; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("link", StringComparison.Ordinal); @@ -218,8 +155,8 @@ public void MapRawOffsetToPosition_SkipsLinkBracketsAndUrl() public void MapRawOffsetToPosition_SkipsHeadingHashPrefix() { const string markdown = "# My Heading Title"; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("Heading", StringComparison.Ordinal); @@ -230,8 +167,8 @@ public void MapRawOffsetToPosition_SkipsHeadingHashPrefix() public void MapRawOffsetToPosition_SkipsListMarkers() { const string markdown = "- first item\n- second item\n- third item"; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("third", StringComparison.Ordinal); @@ -254,8 +191,8 @@ Second paragraph also has some words in it. Third paragraph has the target word right here. """; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("target", StringComparison.Ordinal); @@ -266,8 +203,8 @@ Third paragraph has the target word right here. public void MapRawOffsetToPosition_HandlesCodeSpanBackticks() { const string markdown = "Run `dotnet build` to compile the project."; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("dotnet", StringComparison.Ordinal); @@ -278,8 +215,8 @@ public void MapRawOffsetToPosition_HandlesCodeSpanBackticks() public void MapRawOffsetToPosition_HandlesBoldNestedInsideLinkText() { const string markdown = "See the [**important** notes](https://example.com) page."; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("important", StringComparison.Ordinal); @@ -298,8 +235,8 @@ public void MapRawOffsetToPosition_HandlesTextInsideTableCell() | --- | --- | | Alpha | fortytwo | """; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("fortytwo", StringComparison.Ordinal); @@ -314,8 +251,8 @@ public void MapRawOffsetToPosition_HandlesTextInEarlierTableCellOnSameRow() | --- | --- | | Alpha | fortytwo | """; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("Alpha", StringComparison.Ordinal); @@ -326,8 +263,8 @@ public void MapRawOffsetToPosition_HandlesTextInEarlierTableCellOnSameRow() public void MapRawOffsetToPosition_MapsBothEndsOfAMatchToTheExactRenderedSubstring() { const string markdown = "Plain text with a target word right here."; - FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); - MarkdownDocumentUtilities.MarkdownOffsetMap map = MarkdownDocumentUtilities.BuildOffsetMap(document); + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); int rawIndex = markdown.IndexOf("target word", StringComparison.Ordinal); diff --git a/Tests/OcrSourceTests.cs b/Tests/OcrSourceTests.cs new file mode 100644 index 00000000..b48eaf5f --- /dev/null +++ b/Tests/OcrSourceTests.cs @@ -0,0 +1,545 @@ +// This is the app-coupled split of the original Tests/OcrTests.cs (batch 7a): live OCR-engine +// calls through OcrSourceUtilities, anything constructing a WPF BitmapImage, and anything +// reading AppUtilities.TextGrabSettings directly. The headless majority (27 methods against +// pure Text_Grab.Utilities.OcrUtilities logic) kept the original OcrTests name and moved to +// Tests.Core.Windows/OcrTests.cs; this file has the remaining 15. +using System.Drawing; +using System.IO; +using System.Text; +using System.Windows.Media.Imaging; +using Text_Grab; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Text_Grab.Properties; +using Text_Grab.Utilities; +using Windows.Globalization; + +namespace Tests; + +public class OcrSourceTests +{ + private const string fontSamplePath = @".\Images\font_sample.png"; + private const string fontSampleResult = @"Times-Roman +Helvetica +Courier +Palatino-Roman +Helvetica-Narrow +Bookman-Demi"; + + private const string fontSampleResultForTesseract = @"Times-Roman +Helvetica +Courier +Palatino-Roman +Helvetica-Narrow + +Bookman-Demi +"; + + private const string fontTestPath = @".\Images\FontTest.png"; + + private const string fontTestResult = @"Arial +Times New Roman +Georgia +Segoe +Rockwell Condensed +Couier New"; + + private const string tableTestPath = @".\Images\Table-Test.png"; + private const string tableTestResult = @"Month Int Season +January 1 Winter +February 2 Winter +March 3 Spring +April 4 Spring +May 5 Spring +June 6 Summer +July 7 Summer +August 8 Summer +September 9 Fall +October 10 Fall +November 11 Fall +December 12 Winter"; + + private const string jaTestPath = @".\Images\Ja-Lang-Image.png"; + + // The reading-order-corrected OCR output for Ja-Lang-Image.png. Furigana ruby + // lines are still present inline (they are kept per the current line-ordering + // fix), but every line now appears in top-to-bottom / left-to-right reading + // order instead of the scrambled order the Windows OCR engine returns. + // + // JaTestExpectedResult (above) is the aspirational, fully-corrected target: + // furigana grouped per row with full-width spaces AND engine misreads fixed + // (からだ vs からた, こうか vs カ, ...). Reaching it needs more than ordering: + // furigana row grouping plus OCR error correction that recovers dakuten and + // small-kana the engine drops. This constant captures what is achievable today. + private const string JaReadingOrderResult = + "くろからたしつ黒ごまは体にいいです。タンバク質やカルシウムがかみ彡ろカたくさんあります。髪を黒くする効果もあります。くろあぶらはだかみりようり黒ごま油は肌や髪に使います。料理にも使います。かゆたからだお粥やデサ ー トに入れます。でも、食べすき、ると体たによくないです。少しすっ食べましよう。"; + + // With furigana removal enabled, the ruby-reading lines are dropped and only + // the main body text remains (still subject to the engine's own misreads and + // one stray mis-detected fragment "み彡" the geometry heuristic cannot catch). + private const string JaFuriganaRemovedResult = + "黒ごまは体にいいです。タンバク質やカルシウムがみ彡たくさんあります。髪を黒くする効果もあります。黒ごま油は肌や髪に使います。料理にも使います。お粥やデサ ー トに入れます。でも、食べすき、ると体によくないです。少しすっ食べましよう。"; + + [Theory] + [InlineData("en-US", "H3llO")] + [InlineData("ru-RU", "HЭllΘ")] + public void CleanOutput_CorrectsOnlyLatinCaptureLanguages(string languageTag, string expected) + { + Settings settings = AppUtilities.TextGrabSettings; + bool originalCorrectToLatin = settings.CorrectToLatin; + bool originalCorrectErrors = settings.CorrectErrors; + settings.CorrectToLatin = true; + settings.CorrectErrors = false; + + try + { + OcrOutput output = new() + { + Kind = OcrOutputKind.Paragraph, + Language = new GlobalLang(languageTag), + RawOutput = "HЭllΘ" + }; + + output.CleanOutput(); + + Assert.Equal(expected, output.CleanedOutput); + } + finally + { + settings.CorrectToLatin = originalCorrectToLatin; + settings.CorrectErrors = originalCorrectErrors; + } + } + [WpfFact] + public async Task OcrFontSampleImage() + { + // Given + string testImagePath = fontSamplePath; + + // When + string ocrTextResult = await OcrSourceUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); + + // Then + Assert.Equal(fontSampleResult, ocrTextResult); + } + [WpfFact] + public async Task OcrFontTestImage() + { + // Given + string testImagePath = fontTestPath; + string expectedResult = fontTestResult; + + Uri uri = new(testImagePath, UriKind.Relative); + // When + string ocrTextResult = await OcrSourceUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); + + // Then + Assert.Equal(expectedResult, ocrTextResult); + } + [WpfFact] + public async Task AnalyzeTable() + { + string testImagePath = tableTestPath; + string expectedResult = tableTestResult; + + + Uri uri = new(testImagePath, UriKind.Relative); + Language EnglishLanguage = new("en-US"); + GlobalLang globalLang = new(EnglishLanguage); + Bitmap testBitmap = new(FileUtilities.GetPathToLocalFile(testImagePath)); + // When + IOcrLinesWords ocrResult = await OcrSourceUtilities.GetOcrResultFromImageAsync(testBitmap, globalLang); + + Rectangle rectCanvasSize = new() + { + Width = 1132, + Height = 1158, + X = 0, + Y = 0 + }; + + List wordBorders = OcrUtilities.ParseOcrResultIntoWordBorderInfos(ocrResult); + + ResultTable resultTable = new(); + resultTable.AnalyzeAsTable(wordBorders, rectCanvasSize); + + StringBuilder stringBuilder = new(); + + ResultTable.GetTextFromTabledWordBorders(stringBuilder, wordBorders, true); + + // Then + Assert.Equal(expectedResult, stringBuilder.ToString()); + + } + [WpfFact] + public async Task ParagraphWrapDetection() + { + // Given + string testImagePath = @".\Images\paragraph-test-image.png"; + bool originalParagraphDetection = AppUtilities.TextGrabSettings.ParagraphDetection; + AppUtilities.TextGrabSettings.ParagraphDetection = true; + string expectedResult = "Static cling\r\nStatic cling is the tendency for light objects to stick (cling) to other objects owing to static electricity. Common everyday examples include dust and pet fur clinging to clothing, socks sticking together after being removed from a clothes dryer, or a rubber balloon attracting water after being rubbed against hair.\r\nWhile often considered a minor household annoyance, static cling represents a fundamental demonstration of electrostatics and has significant implications in manufacturing, electronics cooling, and material handling.\r\nhttps://en.wikipedia.org/wiki/Static_cling"; + + try + { + // When + string ocrTextResult = await OcrSourceUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); + + // Then + Assert.Equal(expectedResult, ocrTextResult); + } + finally + { + AppUtilities.TextGrabSettings.ParagraphDetection = originalParagraphDetection; + } + } + [Fact] + public void BuildTextFromOcrLines_UsesParagraphDetectionForWinAi() + { + bool originalParagraphDetection = AppUtilities.TextGrabSettings.ParagraphDetection; + AppUtilities.TextGrabSettings.ParagraphDetection = true; + + try + { + FakeOcrLinesWords ocrResult = new() + { + Lines = + [ + new FakeOcrLine("Static cling is the tendency", new Windows.Foundation.Rect(0, 0, 100, 10)), + new FakeOcrLine("for light objects to stick.", new Windows.Foundation.Rect(0, 14, 100, 10)), + new FakeOcrLine("New paragraph.", new Windows.Foundation.Rect(0, 32, 100, 10)), + ] + }; + + string text = OcrUtilities.BuildTextFromOcrLines(new WindowsAiLang(), ocrResult); + + Assert.Equal("Static cling is the tendency for light objects to stick.\r\nNew paragraph.", text); + } + finally + { + AppUtilities.TextGrabSettings.ParagraphDetection = originalParagraphDetection; + } + } + [Theory] + [InlineData(true, true, false, true)] + [InlineData(true, true, true, false)] + [InlineData(true, false, false, false)] + [InlineData(false, true, false, false)] + public void ShouldUseParagraphDetection_RespectsTableMode( + bool paragraphDetectionEnabled, + bool isSpaceJoiningLanguage, + bool isTableMode, + bool expected) + { + bool originalParagraphDetection = AppUtilities.TextGrabSettings.ParagraphDetection; + AppUtilities.TextGrabSettings.ParagraphDetection = paragraphDetectionEnabled; + + try + { + bool result = OcrUtilities.ShouldUseParagraphDetection(isSpaceJoiningLanguage, isTableMode); + Assert.Equal(expected, result); + } + finally + { + AppUtilities.TextGrabSettings.ParagraphDetection = originalParagraphDetection; + } + } + [WpfFact] + public async Task OcrJapaneseImage_ReadingOrder_KeepsFuriganaWhenDisabled() + { + // Given + GlobalLang japanese = new("ja"); + + // Skip if the Japanese OCR language pack is not installed on this machine. + if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) + return; + + Settings settings = AppUtilities.TextGrabSettings; + bool originalRemoveFurigana = settings.RemoveFurigana; + settings.RemoveFurigana = false; + + try + { + // When + string ocrTextResult = await OcrSourceUtilities.OcrAbsoluteFilePathAsync( + FileUtilities.GetPathToLocalFile(jaTestPath), japanese); + + // Then furigana are kept, but every line is in natural reading order + // (top-to-bottom, left-to-right). + Assert.Equal(JaReadingOrderResult, ocrTextResult); + } + finally + { + settings.RemoveFurigana = originalRemoveFurigana; + } + } + [WpfFact] + public async Task OcrJapaneseImage_RemovesFuriganaWhenEnabled() + { + // Given + GlobalLang japanese = new("ja"); + + if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) + return; + + Settings settings = AppUtilities.TextGrabSettings; + bool originalRemoveFurigana = settings.RemoveFurigana; + settings.RemoveFurigana = true; + + try + { + // When + string ocrTextResult = await OcrSourceUtilities.OcrAbsoluteFilePathAsync( + FileUtilities.GetPathToLocalFile(jaTestPath), japanese); + + // Then the furigana ruby lines are dropped, leaving the main text. + Assert.Equal(JaFuriganaRemovedResult, ocrTextResult); + } + finally + { + settings.RemoveFurigana = originalRemoveFurigana; + } + } + [WpfFact] + public async Task InspectJapaneseOcrOutput() + { + // Exploration harness: dumps the raw OCR lines/words with their bounding + // boxes so we can see exactly what the Windows OCR engine returns for a + // furigana-heavy Japanese image, and how the current pipeline processes it. + GlobalLang japanese = new("ja"); + + if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) + return; + + Bitmap testBitmap = new(FileUtilities.GetPathToLocalFile(jaTestPath)); + double scale = await OcrSourceUtilities.GetIdealScaleFactorForOcrAsync(testBitmap, japanese); + Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(testBitmap, scale); + IOcrLinesWords ocrResult = await OcrSourceUtilities.GetOcrResultFromImageAsync(scaledBitmap, japanese); + + StringBuilder report = new(); + report.AppendLine($"scale factor: {scale:0.###}"); + report.AppendLine($"line count: {ocrResult.Lines.Length}"); + report.AppendLine(); + + for (int i = 0; i < ocrResult.Lines.Length; i++) + { + IOcrLine line = ocrResult.Lines[i]; + Windows.Foundation.Rect lb = line.BoundingBox; + report.AppendLine( + $"LINE {i,2} Y={lb.Y,7:0.0} H={lb.Height,6:0.0} X={lb.X,7:0.0} W={lb.Width,7:0.0} \"{line.Text}\""); + foreach (IOcrWord w in line.Words) + { + Windows.Foundation.Rect wb = w.BoundingBox; + report.AppendLine( + $" word Y={wb.Y,7:0.0} H={wb.Height,6:0.0} X={wb.X,7:0.0} W={wb.Width,7:0.0} \"{w.Text}\""); + } + } + + report.AppendLine(); + report.AppendLine("=== reading-flow ordered lines ==="); + foreach (IOcrLine line in OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines)) + report.AppendLine($" Y={line.BoundingBox.Y,7:0.0} X={line.BoundingBox.X,7:0.0} \"{line.Text}\""); + + report.AppendLine(); + report.AppendLine("=== BuildTextFromOcrLines (current pipeline output) ==="); + report.AppendLine(OcrUtilities.BuildTextFromOcrLines(japanese, ocrResult)); + + string outPath = Path.Combine(Path.GetTempPath(), "ja-ocr-report.txt"); + await File.WriteAllTextAsync(outPath, report.ToString(), new UTF8Encoding(true), TestContext.Current.CancellationToken); + System.Diagnostics.Debug.WriteLine(report.ToString()); + System.Diagnostics.Debug.WriteLine($"Report written to {outPath}"); + } + [WpfFact] + public async Task ReadQrCode() + { + string expectedResult = "This is a test of the QR Code system"; + + string testImagePath = @".\Images\QrCodeTestImage.png"; + Uri uri = new(testImagePath, UriKind.Relative); + // When + string ocrTextResult = await OcrSourceUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); + + // Then + Assert.Equal(expectedResult, ocrTextResult); + } + [WpfFact] + public async Task AnalyzeTable2() + { + string expectedResult = @"Test Text +12 The Quick Brown Fox +13 Jumped over the +14 Lazy +15 +20 +200 +300 Brown +400 Dog"; + + string testImagePath = @".\Images\Table-Test-2.png"; + Uri uri = new(testImagePath, UriKind.Relative); + Language EnglishLanguage = new("en-US"); + GlobalLang globalLang = new(EnglishLanguage); + Bitmap testBitmap = new(FileUtilities.GetPathToLocalFile(testImagePath)); + // When + IOcrLinesWords ocrResult = await OcrSourceUtilities.GetOcrResultFromImageAsync(testBitmap, globalLang); + + Rectangle rectCanvasSize = new() + { + Width = 1152, + Height = 1132, + X = 0, + Y = 0 + }; + + List wordBorders = OcrUtilities.ParseOcrResultIntoWordBorderInfos(ocrResult); + + ResultTable resultTable = new(); + resultTable.AnalyzeAsTable(wordBorders, rectCanvasSize); + + StringBuilder stringBuilder = new(); + + ResultTable.GetTextFromTabledWordBorders(stringBuilder, wordBorders, true); + + // Then + Assert.Equal(expectedResult, stringBuilder.ToString()); + } + [WpfFact(Skip = "since the hocr is not being used from Tesseract it will not be tested for now")] + public async Task TesseractHocr() + { + int initialLinesToSkip = 12; + + // Given + string hocrFilePath = FileUtilities.GetPathToLocalFile(@"TextFiles\font_sample.hocr"); + string[] hocrFileContentsArray = await File.ReadAllLinesAsync(hocrFilePath); + + // combine string array into one string + StringBuilder sb = new(); + foreach (string line in hocrFileContentsArray.Skip(initialLinesToSkip).ToArray()) + sb.AppendLine(line); + + string hocrFileContents = sb.ToString(); + + string testImagePath = fontSamplePath; + // need to scale to get the test to match the output + // Bitmap scaledBMP = ImageMethods + Uri fileURI = new(FileUtilities.GetPathToLocalFile(testImagePath), UriKind.Absolute); + BitmapImage bmpImg = new(fileURI); + bmpImg.Freeze(); + Bitmap bmp = ImageMethods.BitmapImageToBitmap(bmpImg); + ILanguage language = LanguageUtilities.GetOCRLanguage(); + double idealScaleFactor = await OcrSourceUtilities.GetIdealScaleFactorForOcrAsync(bmp, language); + Bitmap scaledBMP = ImageMethods.ScaleBitmapUniform(bmp, idealScaleFactor); + + // When + TessLang EnglishLanguage = new("eng"); + OcrOutput tesseractOutput = await TesseractHelper.GetOcrOutputFromBitmap(scaledBMP, EnglishLanguage); + + string[] tesseractOutputArray = tesseractOutput.RawOutput.Split(Environment.NewLine); + StringBuilder sb2 = new(); + foreach (string line in tesseractOutputArray.Skip(initialLinesToSkip).ToArray()) + sb2.AppendLine(line); + + tesseractOutput.RawOutput = sb2.ToString(); + + // Then + Assert.Equal(hocrFileContents, tesseractOutput.RawOutput); + } + [WpfFact] + public async Task TesseractFontSample() + { + string testImagePath = fontSamplePath; + // need to scale to get the test to match the output + // Bitmap scaledBMP = ImageMethods + Uri fileURI = new(FileUtilities.GetPathToLocalFile(testImagePath), UriKind.Absolute); + BitmapImage bmpImg = new(fileURI); + bmpImg.Freeze(); + Bitmap bmp = ImageMethods.BitmapImageToBitmap(bmpImg); + ILanguage language = LanguageUtilities.GetOCRLanguage(); + double idealScaleFactor = await OcrSourceUtilities.GetIdealScaleFactorForOcrAsync(bmp, language); + Bitmap scaledBMP = ImageMethods.ScaleBitmapUniform(bmp, idealScaleFactor); + + // When + TessLang EnglishLanguage = new("eng"); + OcrOutput tesseractOutput = await TesseractHelper.GetOcrOutputFromBitmap(scaledBMP, EnglishLanguage); + + if (tesseractOutput.RawOutput == "Cannot find tesseract.exe") + return; + + // Then + Assert.Equal(fontSampleResultForTesseract, tesseractOutput.RawOutput); + } + [Fact] + public void BuildTextFromOcrLines_SpaceJoiningLanguage_DoesNotFilterFurigana() + { + // For space-joining languages the whole line text is used verbatim, so + // the furigana heuristic never runs, even with a tiny word present. + Settings settings = AppUtilities.TextGrabSettings; + bool originalParagraphDetection = settings.ParagraphDetection; + bool originalCorrectErrors = settings.CorrectErrors; + settings.ParagraphDetection = false; + settings.CorrectErrors = false; + + try + { + FakeOcrLine line = new("Hello World", new Windows.Foundation.Rect(0, 0, 100, 30)) + { + Words = + [ + Word("x", 0, 0, 4, 4), // tiny word that would be furigana in CJK + Word("Hello", 0, 10, 50, 20), + Word("World", 55, 10, 50, 20), + ] + }; + FakeOcrLinesWords ocrResult = new() { Lines = [line] }; + + string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("en-US"), ocrResult); + + Assert.Equal("Hello World" + System.Environment.NewLine, text); + } + finally + { + settings.ParagraphDetection = originalParagraphDetection; + settings.CorrectErrors = originalCorrectErrors; + } + } + + private static FakeOcrWord Word(string text, double x, double y, double width, double height) + => new(text, new Windows.Foundation.Rect(x, y, width, height)); + + private sealed class FakeOcrLinesWords : IOcrLinesWords + { + public string Text { get; set; } = string.Empty; + + public IOcrLine[] Lines { get; set; } = []; + + public float Angle { get; set; } + } + + private sealed class FakeOcrLine : IOcrLine + { + public FakeOcrLine(string text, Windows.Foundation.Rect boundingBox) + { + Text = text; + BoundingBox = boundingBox; + } + + public string Text { get; set; } + + public IOcrWord[] Words { get; set; } = []; + + public Windows.Foundation.Rect BoundingBox { get; set; } + } + + private sealed class FakeOcrWord : IOcrWord + { + public FakeOcrWord(string text, Windows.Foundation.Rect boundingBox) + { + Text = text; + BoundingBox = boundingBox; + } + + public string Text { get; set; } + + public Windows.Foundation.Rect BoundingBox { get; set; } + } +} diff --git a/Tests/OcrTests.cs b/Tests/OcrTests.cs deleted file mode 100644 index a8e44328..00000000 --- a/Tests/OcrTests.cs +++ /dev/null @@ -1,1080 +0,0 @@ -using System.Drawing; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Windows; -using System.Windows.Media.Imaging; -using Text_Grab; -using Text_Grab.Interfaces; -using Text_Grab.Models; -using Text_Grab.Properties; -using Text_Grab.Utilities; -using Windows.Globalization; - -namespace Tests; - -public class OcrTests -{ - private const string fontSamplePath = @".\Images\font_sample.png"; - private const string fontSampleResult = @"Times-Roman -Helvetica -Courier -Palatino-Roman -Helvetica-Narrow -Bookman-Demi"; - - private const string fontSampleResultForTesseract = @"Times-Roman -Helvetica -Courier -Palatino-Roman -Helvetica-Narrow - -Bookman-Demi -"; - - private const string fontTestPath = @".\Images\FontTest.png"; - - private const string fontTestResult = @"Arial -Times New Roman -Georgia -Segoe -Rockwell Condensed -Couier New"; - - private const string tableTestPath = @".\Images\Table-Test.png"; - private const string tableTestResult = @"Month Int Season -January 1 Winter -February 2 Winter -March 3 Spring -April 4 Spring -May 5 Spring -June 6 Summer -July 7 Summer -August 8 Summer -September 9 Fall -October 10 Fall -November 11 Fall -December 12 Winter"; - - private const string ComplexTablePath = @".\Images\Table-Complex.png"; - private const string ComplexWordBorders = @".\TextFiles\Table-Complex-WordBorders.json"; - private const string ComplexTableResult = @"DESCRIPTION YEAR TO DATE ACTUAL ANNUAL BUDGET BALANCE % BUDGET REMAINING -CORPORATE INCOME (1) $138,553 $358,100 $219,547 61 % -FOUNDATION INCOME 432,275 824,700 392,425 48% -GOVERNMENT INCOME 375,375 833,825 458,450 55% -PUBLICATIONS INCOME 1,341 3,000 1,659 55% -INTEREST INCOME (2) 26,767 39,000 12,233 31% -INVESTMENT GAIN (3) 50,472 0 N/A N/A -MISCELLANEOUS INCOME 1,650 6,995 5,345 76% -TOTAL REVENUE 1,026,433 2,065,620 1,089,659 53% -SALARIES & WAGES 355,633 603,840 248,207 41% -FRINGE BENEFITS 63,182 120,120 56,938 47% -OFFICE RENT 83,131 132,000 48,869 37% -EQUIPMENT RENTAL & MAINTENANCE 15,364 19,900 4,536 23% -SUPPLIES 8,051 10,200 2,149 21% -TELEPHONE AND POSTAGE 15,088 24,100 9,012 37% -INSURANCE 6,149 5,500 (649) (12)% -REGISTRATION & LICENSES 415 760 345 45% -DEPRECIATION 8,482 17,000 8,518 50% -BANK CHARGES 344 670 326 49% -AUDIT FEES 19,000 19,000 0 0% -BOARD MEETINGS 12,541 20,000 7,459 37% -TRAVEL 6,910 20,000 13,090 65% -LODGING & PERDIEM 15,623 20,000 4,377 22% -SEMINARS & MEETINGS 3,442 8,700 5,258 60% -PROFESSIONAL FESS 5,050 16,000 10,950 68% -PRINTING & PUBLICATIONS 25,576 25,000 (576) (2) % -MATERIALS,SUBS,DUES & TRAININGS 4,445 6,800 2,355 35% -LOCAL STAFF DEVELOPMENT 0 7,500 7,500 100% -STIPENDS 8,250 9,750 1,500 15% -SUBTOTAL 656,675 1,086,840 430,165 40% -TRANSFER PAYMENTS TO SUBRECIPIENTS 360,009 978,780 618,771 63% -TOTAL EXPENDITURES 1,016,684 2,065,620 1,048,936 51% -REVENUES OVERY(UNDER) EXPENDITURES $9,749 $0 $9,749 N/A"; - - private const string JaTestExpectedResult = @"""くろ からだ しつ -黒ごまは体にいいです。タンバク質やカルシウムが -かみ くろ こうか -たくさんあります。髪を黒くする効果もあります。 -くろ あぶら はだ かみ りようり -黒ごま油は肌や髪に使います。料理にも使います。 -かゆ た からだ -お粥やデサートに入れます。でも、食べすきると体 -た -によくないです。少しすつ食べましよう。 -"""; - - [Theory] - [InlineData("en-US", "H3llO")] - [InlineData("ru-RU", "HЭllΘ")] - public void CleanOutput_CorrectsOnlyLatinCaptureLanguages(string languageTag, string expected) - { - Settings settings = AppUtilities.TextGrabSettings; - bool originalCorrectToLatin = settings.CorrectToLatin; - bool originalCorrectErrors = settings.CorrectErrors; - settings.CorrectToLatin = true; - settings.CorrectErrors = false; - - try - { - OcrOutput output = new() - { - Kind = OcrOutputKind.Paragraph, - Language = new GlobalLang(languageTag), - RawOutput = "HЭllΘ" - }; - - output.CleanOutput(); - - Assert.Equal(expected, output.CleanedOutput); - } - finally - { - settings.CorrectToLatin = originalCorrectToLatin; - settings.CorrectErrors = originalCorrectErrors; - } - } - - [WpfFact] - public async Task OcrFontSampleImage() - { - // Given - string testImagePath = fontSamplePath; - - // When - string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); - - // Then - Assert.Equal(fontSampleResult, ocrTextResult); - } - - [WpfFact] - public async Task OcrFontTestImage() - { - // Given - string testImagePath = fontTestPath; - string expectedResult = fontTestResult; - - Uri uri = new(testImagePath, UriKind.Relative); - // When - string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); - - // Then - Assert.Equal(expectedResult, ocrTextResult); - } - - [WpfFact] - public async Task AnalyzeTable() - { - string testImagePath = tableTestPath; - string expectedResult = tableTestResult; - - - Uri uri = new(testImagePath, UriKind.Relative); - Language EnglishLanguage = new("en-US"); - GlobalLang globalLang = new(EnglishLanguage); - Bitmap testBitmap = new(FileUtilities.GetPathToLocalFile(testImagePath)); - // When - IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(testBitmap, globalLang); - - DpiScale dpi = new(1, 1); - Rectangle rectCanvasSize = new() - { - Width = 1132, - Height = 1158, - X = 0, - Y = 0 - }; - - List wordBorders = ResultTable.ParseOcrResultIntoWordBorderInfos(ocrResult, dpi); - - ResultTable resultTable = new(); - resultTable.AnalyzeAsTable(wordBorders, rectCanvasSize); - - StringBuilder stringBuilder = new(); - - ResultTable.GetTextFromTabledWordBorders(stringBuilder, wordBorders, true); - - // Then - Assert.Equal(expectedResult, stringBuilder.ToString()); - - } - - [WpfFact] - public async Task ParagraphWrapDetection() - { - // Given - string testImagePath = @".\Images\paragraph-test-image.png"; - bool originalParagraphDetection = AppUtilities.TextGrabSettings.ParagraphDetection; - AppUtilities.TextGrabSettings.ParagraphDetection = true; - string expectedResult = "Static cling\r\nStatic cling is the tendency for light objects to stick (cling) to other objects owing to static electricity. Common everyday examples include dust and pet fur clinging to clothing, socks sticking together after being removed from a clothes dryer, or a rubber balloon attracting water after being rubbed against hair.\r\nWhile often considered a minor household annoyance, static cling represents a fundamental demonstration of electrostatics and has significant implications in manufacturing, electronics cooling, and material handling.\r\nhttps://en.wikipedia.org/wiki/Static_cling"; - - try - { - // When - string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); - - // Then - Assert.Equal(expectedResult, ocrTextResult); - } - finally - { - AppUtilities.TextGrabSettings.ParagraphDetection = originalParagraphDetection; - } - } - - [Theory] - [InlineData(10, 10, 25, 10, true)] // bounding-box gap = 5 - [InlineData(10, 10, 26, 10, false)] // threshold boundary: gap = 6 - [InlineData(10, 10, 27, 10, false)] // bounding-box gap = 7 - [InlineData(10, 10, 10, 10, false)] // same visual row - [InlineData(10, 10, 14, 10, false)] // insufficient vertical advance - [InlineData(10, 10, 18, 10, true)] // distinct rows with slight overlap - [InlineData(10, 10, 16, 30, false)] // height ratio = 3 - [InlineData(10, 0, 13, 10, false)] // zero height - public void IsWrappedParagraph_ReturnsExpected( - double currentTop, double currentHeight, - double nextTop, double nextHeight, - bool expected) - { - bool result = OcrUtilities.IsWrappedParagraph(currentTop, currentHeight, nextTop, nextHeight); - Assert.Equal(expected, result); - } - - [Fact] - public void BuildTextFromOcrLines_UsesParagraphDetectionForWinAi() - { - bool originalParagraphDetection = AppUtilities.TextGrabSettings.ParagraphDetection; - AppUtilities.TextGrabSettings.ParagraphDetection = true; - - try - { - FakeOcrLinesWords ocrResult = new() - { - Lines = - [ - new FakeOcrLine("Static cling is the tendency", new Windows.Foundation.Rect(0, 0, 100, 10)), - new FakeOcrLine("for light objects to stick.", new Windows.Foundation.Rect(0, 14, 100, 10)), - new FakeOcrLine("New paragraph.", new Windows.Foundation.Rect(0, 32, 100, 10)), - ] - }; - - string text = OcrUtilities.BuildTextFromOcrLines(new WindowsAiLang(), ocrResult); - - Assert.Equal("Static cling is the tendency for light objects to stick.\r\nNew paragraph.", text); - } - finally - { - AppUtilities.TextGrabSettings.ParagraphDetection = originalParagraphDetection; - } - } - - [Theory] - [InlineData(true, true, false, true)] - [InlineData(true, true, true, false)] - [InlineData(true, false, false, false)] - [InlineData(false, true, false, false)] - public void ShouldUseParagraphDetection_RespectsTableMode( - bool paragraphDetectionEnabled, - bool isSpaceJoiningLanguage, - bool isTableMode, - bool expected) - { - bool originalParagraphDetection = AppUtilities.TextGrabSettings.ParagraphDetection; - AppUtilities.TextGrabSettings.ParagraphDetection = paragraphDetectionEnabled; - - try - { - bool result = OcrUtilities.ShouldUseParagraphDetection(isSpaceJoiningLanguage, isTableMode); - Assert.Equal(expected, result); - } - finally - { - AppUtilities.TextGrabSettings.ParagraphDetection = originalParagraphDetection; - } - } - - [Fact] - public void GroupWrappedParagraphLines_CombinesWrappedLinesIntoParagraphBlocks() - { - List lines = - [ - new(0, "Static cling is the tendency", new Windows.Foundation.Rect(0, 0, 100, 10)), - new(1, "for light objects to stick.", new Windows.Foundation.Rect(0, 14, 100, 10)), - new(2, "New paragraph.", new Windows.Foundation.Rect(0, 32, 120, 12)), - ]; - - List groups = OcrUtilities.GroupWrappedParagraphLines(lines); - - Assert.Equal(2, groups.Count); - Assert.Equal(0, groups[0].StartingLineNumber); - Assert.Equal("Static cling is the tendency for light objects to stick.", groups[0].SingleLineText); - Assert.Equal($"Static cling is the tendency{Environment.NewLine}for light objects to stick.", groups[0].DisplayText); - Assert.Equal(0, groups[0].BoundingBox.Y); - Assert.Equal(24, groups[0].BoundingBox.Height); - Assert.Equal("New paragraph.", groups[1].SingleLineText); - } - - [Fact] - public void GroupWrappedParagraphLines_DoesNotMergeEntriesOnTheSameVisualRow() - { - List lines = - [ - new(0, "Left entry", new Windows.Foundation.Rect(0, 10, 50, 10)), - new(1, "Right entry", new Windows.Foundation.Rect(60, 10, 50, 10)), - ]; - - List groups = OcrUtilities.GroupWrappedParagraphLines(lines); - - Assert.Equal(2, groups.Count); - Assert.All(groups, group => Assert.DoesNotContain(Environment.NewLine, group.DisplayText)); - Assert.All(groups, group => Assert.Equal(10, group.BoundingBox.Height)); - } - - [Fact] - public void GroupWrappedParagraphLines_RemovesEmbeddedLineBreaksFromIndividualOcrLines() - { - List lines = - [ - new(0, $"First{Environment.NewLine}line", new Windows.Foundation.Rect(0, 0, 100, 10)), - ]; - - OcrUtilities.GroupedOcrLines group = Assert.Single(OcrUtilities.GroupWrappedParagraphLines(lines)); - - Assert.Equal("First line", group.DisplayText); - Assert.Equal("First line", group.SingleLineText); - } - - private const string jaTestPath = @".\Images\Ja-Lang-Image.png"; - - // The reading-order-corrected OCR output for Ja-Lang-Image.png. Furigana ruby - // lines are still present inline (they are kept per the current line-ordering - // fix), but every line now appears in top-to-bottom / left-to-right reading - // order instead of the scrambled order the Windows OCR engine returns. - // - // JaTestExpectedResult (above) is the aspirational, fully-corrected target: - // furigana grouped per row with full-width spaces AND engine misreads fixed - // (からだ vs からた, こうか vs カ, ...). Reaching it needs more than ordering: - // furigana row grouping plus OCR error correction that recovers dakuten and - // small-kana the engine drops. This constant captures what is achievable today. - private const string JaReadingOrderResult = - "くろからたしつ黒ごまは体にいいです。タンバク質やカルシウムがかみ彡ろカたくさんあります。髪を黒くする効果もあります。くろあぶらはだかみりようり黒ごま油は肌や髪に使います。料理にも使います。かゆたからだお粥やデサ ー トに入れます。でも、食べすき、ると体たによくないです。少しすっ食べましよう。"; - - // With furigana removal enabled, the ruby-reading lines are dropped and only - // the main body text remains (still subject to the engine's own misreads and - // one stray mis-detected fragment "み彡" the geometry heuristic cannot catch). - private const string JaFuriganaRemovedResult = - "黒ごまは体にいいです。タンバク質やカルシウムがみ彡たくさんあります。髪を黒くする効果もあります。黒ごま油は肌や髪に使います。料理にも使います。お粥やデサ ー トに入れます。でも、食べすき、ると体によくないです。少しすっ食べましよう。"; - - [WpfFact] - public async Task OcrJapaneseImage_ReadingOrder_KeepsFuriganaWhenDisabled() - { - // Given - GlobalLang japanese = new("ja"); - - // Skip if the Japanese OCR language pack is not installed on this machine. - if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) - return; - - Settings settings = AppUtilities.TextGrabSettings; - bool originalRemoveFurigana = settings.RemoveFurigana; - settings.RemoveFurigana = false; - - try - { - // When - string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync( - FileUtilities.GetPathToLocalFile(jaTestPath), japanese); - - // Then furigana are kept, but every line is in natural reading order - // (top-to-bottom, left-to-right). - Assert.Equal(JaReadingOrderResult, ocrTextResult); - } - finally - { - settings.RemoveFurigana = originalRemoveFurigana; - } - } - - [WpfFact] - public async Task OcrJapaneseImage_RemovesFuriganaWhenEnabled() - { - // Given - GlobalLang japanese = new("ja"); - - if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) - return; - - Settings settings = AppUtilities.TextGrabSettings; - bool originalRemoveFurigana = settings.RemoveFurigana; - settings.RemoveFurigana = true; - - try - { - // When - string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync( - FileUtilities.GetPathToLocalFile(jaTestPath), japanese); - - // Then the furigana ruby lines are dropped, leaving the main text. - Assert.Equal(JaFuriganaRemovedResult, ocrTextResult); - } - finally - { - settings.RemoveFurigana = originalRemoveFurigana; - } - } - - [WpfFact] - public async Task InspectJapaneseOcrOutput() - { - // Exploration harness: dumps the raw OCR lines/words with their bounding - // boxes so we can see exactly what the Windows OCR engine returns for a - // furigana-heavy Japanese image, and how the current pipeline processes it. - GlobalLang japanese = new("ja"); - - if (!Windows.Media.Ocr.OcrEngine.IsLanguageSupported(japanese.OriginalLanguage)) - return; - - Bitmap testBitmap = new(FileUtilities.GetPathToLocalFile(jaTestPath)); - double scale = await OcrUtilities.GetIdealScaleFactorForOcrAsync(testBitmap, japanese); - Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(testBitmap, scale); - IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(scaledBitmap, japanese); - - StringBuilder report = new(); - report.AppendLine($"scale factor: {scale:0.###}"); - report.AppendLine($"line count: {ocrResult.Lines.Length}"); - report.AppendLine(); - - for (int i = 0; i < ocrResult.Lines.Length; i++) - { - IOcrLine line = ocrResult.Lines[i]; - Windows.Foundation.Rect lb = line.BoundingBox; - report.AppendLine( - $"LINE {i,2} Y={lb.Y,7:0.0} H={lb.Height,6:0.0} X={lb.X,7:0.0} W={lb.Width,7:0.0} \"{line.Text}\""); - foreach (IOcrWord w in line.Words) - { - Windows.Foundation.Rect wb = w.BoundingBox; - report.AppendLine( - $" word Y={wb.Y,7:0.0} H={wb.Height,6:0.0} X={wb.X,7:0.0} W={wb.Width,7:0.0} \"{w.Text}\""); - } - } - - report.AppendLine(); - report.AppendLine("=== reading-flow ordered lines ==="); - foreach (IOcrLine line in OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines)) - report.AppendLine($" Y={line.BoundingBox.Y,7:0.0} X={line.BoundingBox.X,7:0.0} \"{line.Text}\""); - - report.AppendLine(); - report.AppendLine("=== BuildTextFromOcrLines (current pipeline output) ==="); - report.AppendLine(OcrUtilities.BuildTextFromOcrLines(japanese, ocrResult)); - - string outPath = Path.Combine(Path.GetTempPath(), "ja-ocr-report.txt"); - await File.WriteAllTextAsync(outPath, report.ToString(), new UTF8Encoding(true), TestContext.Current.CancellationToken); - System.Diagnostics.Debug.WriteLine(report.ToString()); - System.Diagnostics.Debug.WriteLine($"Report written to {outPath}"); - } - - [WpfFact] - public async Task ReadQrCode() - { - string expectedResult = "This is a test of the QR Code system"; - - string testImagePath = @".\Images\QrCodeTestImage.png"; - Uri uri = new(testImagePath, UriKind.Relative); - // When - string ocrTextResult = await OcrUtilities.OcrAbsoluteFilePathAsync(FileUtilities.GetPathToLocalFile(testImagePath)); - - // Then - Assert.Equal(expectedResult, ocrTextResult); - } - - [WpfFact] - public async Task AnalyzeTable2() - { - string expectedResult = @"Test Text -12 The Quick Brown Fox -13 Jumped over the -14 Lazy -15 -20 -200 -300 Brown -400 Dog"; - - string testImagePath = @".\Images\Table-Test-2.png"; - Uri uri = new(testImagePath, UriKind.Relative); - Language EnglishLanguage = new("en-US"); - GlobalLang globalLang = new(EnglishLanguage); - Bitmap testBitmap = new(FileUtilities.GetPathToLocalFile(testImagePath)); - // When - IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(testBitmap, globalLang); - - DpiScale dpi = new(1, 1); - Rectangle rectCanvasSize = new() - { - Width = 1152, - Height = 1132, - X = 0, - Y = 0 - }; - - List wordBorders = ResultTable.ParseOcrResultIntoWordBorderInfos(ocrResult, dpi); - - ResultTable resultTable = new(); - resultTable.AnalyzeAsTable(wordBorders, rectCanvasSize); - - StringBuilder stringBuilder = new(); - - ResultTable.GetTextFromTabledWordBorders(stringBuilder, wordBorders, true); - - // Then - Assert.Equal(expectedResult, stringBuilder.ToString()); - } - - [WpfFact] - public async Task OcrComplexTableTestImage() - { - // Given - string resultWordBorders = ComplexWordBorders; - string expectedResult = ComplexTableResult; - string wordBordersJson = await File.ReadAllTextAsync( - FileUtilities.GetPathToLocalFile(resultWordBorders), - TestContext.Current.CancellationToken); - - List wbInfoList = JsonSerializer.Deserialize>(wordBordersJson ?? "[]") - ?? throw new Exception("Failed to deserialize WordBorderInfo list"); - - // When - // 1514 x 1243 image size - Rectangle rectCanvasSize = new() - { - Width = 1514, - Height = 1243, - X = 0, - Y = 0 - }; - - ResultTable resultTable = new(); - resultTable.AnalyzeAsTable(wbInfoList, rectCanvasSize); - StringBuilder stringBuilder = new(); - - ResultTable.GetTextFromTabledWordBorders(stringBuilder, wbInfoList, true); - - // Then - Assert.Equal(expectedResult, stringBuilder.ToString()); - } - - - [WpfFact(Skip = "since the hocr is not being used from Tesseract it will not be tested for now")] - public async Task TesseractHocr() - { - int initialLinesToSkip = 12; - - // Given - string hocrFilePath = FileUtilities.GetPathToLocalFile(@"TextFiles\font_sample.hocr"); - string[] hocrFileContentsArray = await File.ReadAllLinesAsync(hocrFilePath); - - // combine string array into one string - StringBuilder sb = new(); - foreach (string line in hocrFileContentsArray.Skip(initialLinesToSkip).ToArray()) - sb.AppendLine(line); - - string hocrFileContents = sb.ToString(); - - string testImagePath = fontSamplePath; - // need to scale to get the test to match the output - // Bitmap scaledBMP = ImageMethods - Uri fileURI = new(FileUtilities.GetPathToLocalFile(testImagePath), UriKind.Absolute); - BitmapImage bmpImg = new(fileURI); - bmpImg.Freeze(); - Bitmap bmp = ImageMethods.BitmapImageToBitmap(bmpImg); - ILanguage language = LanguageUtilities.GetOCRLanguage(); - double idealScaleFactor = await OcrUtilities.GetIdealScaleFactorForOcrAsync(bmp, language); - Bitmap scaledBMP = ImageMethods.ScaleBitmapUniform(bmp, idealScaleFactor); - - // When - TessLang EnglishLanguage = new("eng"); - OcrOutput tesseractOutput = await TesseractHelper.GetOcrOutputFromBitmap(scaledBMP, EnglishLanguage); - - string[] tesseractOutputArray = tesseractOutput.RawOutput.Split(Environment.NewLine); - StringBuilder sb2 = new(); - foreach (string line in tesseractOutputArray.Skip(initialLinesToSkip).ToArray()) - sb2.AppendLine(line); - - tesseractOutput.RawOutput = sb2.ToString(); - - // Then - Assert.Equal(hocrFileContents, tesseractOutput.RawOutput); - } - - [WpfFact] - public async Task TesseractFontSample() - { - string testImagePath = fontSamplePath; - // need to scale to get the test to match the output - // Bitmap scaledBMP = ImageMethods - Uri fileURI = new(FileUtilities.GetPathToLocalFile(testImagePath), UriKind.Absolute); - BitmapImage bmpImg = new(fileURI); - bmpImg.Freeze(); - Bitmap bmp = ImageMethods.BitmapImageToBitmap(bmpImg); - ILanguage language = LanguageUtilities.GetOCRLanguage(); - double idealScaleFactor = await OcrUtilities.GetIdealScaleFactorForOcrAsync(bmp, language); - Bitmap scaledBMP = ImageMethods.ScaleBitmapUniform(bmp, idealScaleFactor); - - // When - TessLang EnglishLanguage = new("eng"); - OcrOutput tesseractOutput = await TesseractHelper.GetOcrOutputFromBitmap(scaledBMP, EnglishLanguage); - - if (tesseractOutput.RawOutput == "Cannot find tesseract.exe") - return; - - // Then - Assert.Equal(fontSampleResultForTesseract, tesseractOutput.RawOutput); - } - - [WpfFact(Skip = "fails GitHub actions")] - public async Task GetTessLanguages() - { - List expected = ["eng", "spa"]; - List actualStrings = await TesseractHelper.TesseractLanguagesAsStrings(); - - if (actualStrings.Count == 0) - return; - - foreach (string tag in expected) - { - Assert.Contains(tag, actualStrings); - } - } - - [WpfFact(Skip = "fails GitHub actions")] - public async Task GetTesseractStrongLanguages() - { - List expectedList = - [ - new TessLang("eng"), - new TessLang("spa"), - ]; - - List actualList = await TesseractHelper.TesseractLanguages(); - - if (actualList.Count == 0) - return; - - foreach (ILanguage tag in expectedList) - { - Assert.Contains(tag.AbbreviatedName, actualList.Select(x => x.AbbreviatedName).ToList()); - } - } - - [WpfFact(Skip = "fails GitHub actions")] - public async Task GetTesseractGitHubLanguage() - { - TesseractGitHubFileDownloader fileDownloader = new(); - - int length = TesseractGitHubFileDownloader.tesseractTrainedDataFileNames.Length; - string languageFileDataName = TesseractGitHubFileDownloader.tesseractTrainedDataFileNames[new Random().Next(length)]; - string tempFilePath = Path.Combine(Path.GetTempPath(), languageFileDataName); - - await fileDownloader.DownloadFileAsync(languageFileDataName, tempFilePath); - - Assert.True(File.Exists(tempFilePath)); - Assert.True(new FileInfo(tempFilePath).Length > 0); - - File.Delete(tempFilePath); - } - - [Fact] - public void BuildTextFromOcrLines_FiltersFuriganaForJapanese() - { - // Given a Japanese line where the kanji 黒 is annotated with the small - // furigana くろ rendered directly above it. - FakeOcrLine line = new("くろ黒ごま", new Windows.Foundation.Rect(0, 0, 60, 30)) - { - Words = - [ - // Furigana: short and sitting above the kanji it annotates. - new FakeOcrWord("くろ", new Windows.Foundation.Rect(0, 0, 16, 8)), - // Main text: full-height single characters. - new FakeOcrWord("黒", new Windows.Foundation.Rect(0, 10, 20, 20)), - new FakeOcrWord("ご", new Windows.Foundation.Rect(20, 10, 20, 20)), - new FakeOcrWord("ま", new Windows.Foundation.Rect(40, 10, 20, 20)), - ] - }; - - FakeOcrLinesWords ocrResult = new() { Lines = [line] }; - - // When - string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("ja"), ocrResult); - - // Then the furigana is dropped, leaving only the main text. - Assert.Equal("黒ごま", text); - } - - // ----- FilterFurigana unit tests (the geometry heuristic) ----- - - [Fact] - public void FilterFurigana_EmptyList_ReturnsEmpty() - { - List result = OcrUtilities.FilterFurigana([]); - - Assert.Empty(result); - } - - [Fact] - public void FilterFurigana_SingleWord_IsKept() - { - List words = [Word("黒", 0, 0, 20, 20)]; - - List result = OcrUtilities.FilterFurigana(words); - - Assert.Equal(["黒"], result.Select(w => w.Text)); - } - - [Fact] - public void FilterFurigana_UniformHeights_KeepsAllInOrder() - { - // No word is small relative to the median, so nothing is furigana. - List words = - [ - Word("黒", 0, 0, 20, 20), - Word("ご", 20, 0, 20, 20), - Word("ま", 40, 0, 20, 20), - ]; - - List result = OcrUtilities.FilterFurigana(words); - - Assert.Equal(["黒", "ご", "ま"], result.Select(w => w.Text)); - } - - [Fact] - public void FilterFurigana_RemovesSmallWordAboveOverlappingKanji() - { - List words = - [ - Word("くろ", 0, 0, 16, 8), // furigana: short, sitting above - Word("黒", 0, 10, 20, 20), // kanji: taller, below, overlapping - ]; - - List result = OcrUtilities.FilterFurigana(words); - - Assert.Equal(["黒"], result.Select(w => w.Text)); - } - - [Fact] - public void FilterFurigana_KeepsSmallWordWhenNotHorizontallyOverlapping() - { - // Small, but nowhere near a kanji horizontally, so it is real text. - List words = - [ - Word("くろ", 100, 0, 16, 8), - Word("黒", 0, 10, 20, 20), - ]; - - List result = OcrUtilities.FilterFurigana(words); - - Assert.Equal(["くろ", "黒"], result.Select(w => w.Text)); - } - - [Fact] - public void FilterFurigana_KeepsSmallWordBelowMainText() - { - // Furigana sits above its kanji; a small word BELOW a larger word is - // not furigana and must be kept. - List words = - [ - Word("黒", 0, 0, 20, 20), - Word("くろ", 0, 22, 16, 8), - ]; - - List result = OcrUtilities.FilterFurigana(words); - - Assert.Equal(["黒", "くろ"], result.Select(w => w.Text)); - } - - [Fact] - public void FilterFurigana_KeepsSmallWordWhenWordBelowIsNotLarger() - { - // A small word directly above another small word is not furigana: - // furigana requires a larger word (the kanji) beneath it. The two tall - // words only exist to raise the median height. - List words = - [ - Word("く", 0, 0, 8, 8), - Word("ろ", 0, 10, 8, 8), // below + overlapping, but also small - Word("本", 50, 0, 20, 20), - Word("語", 80, 0, 20, 20), - ]; - - List result = OcrUtilities.FilterFurigana(words); - - Assert.Equal(["く", "ろ", "本", "語"], result.Select(w => w.Text)); - } - - [Theory] - [InlineData("く", true)] // 1-char ruby is removed - [InlineData("くろ", true)] // 2-char ruby is removed - [InlineData("くろが", false)] // 3+ chars is treated as real text and kept - public void FilterFurigana_OnlyRemovesShortWords(string rubyText, bool removed) - { - List words = - [ - Word(rubyText, 0, 0, 16, 8), - Word("黒", 0, 10, 20, 20), - ]; - - List result = OcrUtilities.FilterFurigana(words); - - string[] expected = removed ? ["黒"] : [rubyText, "黒"]; - Assert.Equal(expected, result.Select(w => w.Text)); - } - - [Fact] - public void FilterFurigana_RemovesMultipleFuriganaKeepingMainText() - { - List words = - [ - Word("くろ", 0, 0, 16, 8), - Word("黒", 0, 10, 20, 20), - Word("ごま", 20, 0, 16, 8), - Word("米", 20, 10, 20, 20), - ]; - - List result = OcrUtilities.FilterFurigana(words); - - Assert.Equal(["黒", "米"], result.Select(w => w.Text)); - } - - // ----- BuildTextFromOcrLines integration (language gating) ----- - - [Fact] - public void BuildTextFromOcrLines_JapaneseWithoutFurigana_IsUnchanged() - { - FakeOcrLine line = new("黒ごま", new Windows.Foundation.Rect(0, 0, 60, 20)) - { - Words = - [ - Word("黒", 0, 0, 20, 20), - Word("ご", 20, 0, 20, 20), - Word("ま", 40, 0, 20, 20), - ] - }; - FakeOcrLinesWords ocrResult = new() { Lines = [line] }; - - string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("ja"), ocrResult); - - Assert.Equal("黒ごま", text); - } - - [Fact] - public void BuildTextFromOcrLines_ChineseText_JoinsWithoutSpaces() - { - FakeOcrLine line = new("中文", new Windows.Foundation.Rect(0, 0, 40, 20)) - { - Words = - [ - Word("中", 0, 0, 20, 20), - Word("文", 20, 0, 20, 20), - ] - }; - FakeOcrLinesWords ocrResult = new() { Lines = [line] }; - - string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("zh-Hans"), ocrResult); - - Assert.Equal("中文", text); - } - - [Fact] - public void BuildTextFromOcrLines_FiltersRubyTextForChinese() - { - // The same small-ruby heuristic also runs for Chinese, another - // non-space-joining language (e.g. bopomofo above a character). - FakeOcrLine line = new("ㄓ中文", new Windows.Foundation.Rect(0, 0, 40, 30)) - { - Words = - [ - Word("ㄓ", 0, 0, 8, 8), - Word("中", 0, 10, 20, 20), - Word("文", 20, 10, 20, 20), - ] - }; - FakeOcrLinesWords ocrResult = new() { Lines = [line] }; - - string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("zh-Hans"), ocrResult); - - Assert.Equal("中文", text); - } - - [Fact] - public void BuildTextFromOcrLines_SpaceJoiningLanguage_DoesNotFilterFurigana() - { - // For space-joining languages the whole line text is used verbatim, so - // the furigana heuristic never runs, even with a tiny word present. - Settings settings = AppUtilities.TextGrabSettings; - bool originalParagraphDetection = settings.ParagraphDetection; - bool originalCorrectErrors = settings.CorrectErrors; - settings.ParagraphDetection = false; - settings.CorrectErrors = false; - - try - { - FakeOcrLine line = new("Hello World", new Windows.Foundation.Rect(0, 0, 100, 30)) - { - Words = - [ - Word("x", 0, 0, 4, 4), // tiny word that would be furigana in CJK - Word("Hello", 0, 10, 50, 20), - Word("World", 55, 10, 50, 20), - ] - }; - FakeOcrLinesWords ocrResult = new() { Lines = [line] }; - - string text = OcrUtilities.BuildTextFromOcrLines(new GlobalLang("en-US"), ocrResult); - - Assert.Equal("Hello World" + System.Environment.NewLine, text); - } - finally - { - settings.ParagraphDetection = originalParagraphDetection; - settings.CorrectErrors = originalCorrectErrors; - } - } - - [Fact] - public void OrderLinesForReadingFlow_SortsRowsTopToBottomAndLeftToRight() - { - // Mimics the Windows OCR engine returning furigana ruby lines and a - // trailing fragment out of reading order (as seen with Ja-Lang-Image.png). - // Row 1 (y~0): furigana くろ + main-line reading, emitted out of x-order. - // Row 2 (y~30): the main text line. - FakeOcrLine furiganaRight = new("しつ", new Windows.Foundation.Rect(200, 0, 20, 8)); - FakeOcrLine furiganaLeft = new("くろ", new Windows.Foundation.Rect(0, 0, 20, 8)); - FakeOcrLine mainLine = new("黒ごま質", new Windows.Foundation.Rect(0, 30, 240, 20)); - - // Engine order is scrambled: right furigana, main line, then left furigana. - FakeOcrLinesWords ocrResult = new() - { - Lines = [furiganaRight, mainLine, furiganaLeft] - }; - - IReadOnlyList ordered = OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines); - - Assert.Equal(["くろ", "しつ", "黒ごま質"], ordered.Select(l => l.Text)); - } - - [Fact] - public void OrderLinesForReadingFlow_KeepsSeparateRowsInVerticalOrder() - { - // Two furigana rows and two main-text rows interleaved and shuffled must - // come back strictly top-to-bottom. - FakeOcrLine ruby2 = new("かみ", new Windows.Foundation.Rect(0, 100, 20, 8)); - FakeOcrLine main2 = new("髪", new Windows.Foundation.Rect(0, 130, 40, 20)); - FakeOcrLine ruby1 = new("くろ", new Windows.Foundation.Rect(0, 0, 20, 8)); - FakeOcrLine main1 = new("黒", new Windows.Foundation.Rect(0, 30, 40, 20)); - - FakeOcrLinesWords ocrResult = new() { Lines = [main2, ruby1, main1, ruby2] }; - - IReadOnlyList ordered = OcrUtilities.OrderLinesForReadingFlow(ocrResult.Lines); - - Assert.Equal(["くろ", "黒", "かみ", "髪"], ordered.Select(l => l.Text)); - } - - [Fact] - public void FilterFuriganaLines_RemovesShortLineAboveTallerOverlappingLine() - { - // A short furigana line sitting just above a taller kanji line that it - // overlaps horizontally is dropped. - FakeOcrLine furigana = new("くろ", new Windows.Foundation.Rect(0, 0, 40, 8)); - FakeOcrLine mainLine = new("黒ごま", new Windows.Foundation.Rect(0, 10, 120, 20)); - - FakeOcrLinesWords ocrResult = new() { Lines = [furigana, mainLine] }; - - IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); - - Assert.Equal(["黒ごま"], result.Select(l => l.Text)); - } - - [Fact] - public void FilterFuriganaLines_KeepsTwoBodyLinesOfSimilarHeight() - { - // Two normal body lines stacked vertically: neither is much shorter than - // the other, so nothing is treated as furigana. - FakeOcrLine top = new("黒ごまは体に", new Windows.Foundation.Rect(0, 0, 200, 20)); - FakeOcrLine bottom = new("たくさんあります", new Windows.Foundation.Rect(0, 26, 200, 20)); - - FakeOcrLinesWords ocrResult = new() { Lines = [top, bottom] }; - - IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); - - Assert.Equal(["黒ごまは体に", "たくさんあります"], result.Select(l => l.Text)); - } - - [Fact] - public void FilterFuriganaLines_KeepsShortLineNotHorizontallyOverlappingAnyKanji() - { - // A short line off to the side (no taller line beneath it) is real text. - FakeOcrLine shortSide = new("注", new Windows.Foundation.Rect(300, 0, 20, 8)); - FakeOcrLine mainLine = new("黒ごま", new Windows.Foundation.Rect(0, 10, 120, 20)); - - FakeOcrLinesWords ocrResult = new() { Lines = [shortSide, mainLine] }; - - IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); - - Assert.Equal(["注", "黒ごま"], result.Select(l => l.Text)); - } - - [Fact] - public void FilterFuriganaLines_KeepsShortLineWhenGapIsTooLarge() - { - // Short line far above a taller line is a separate heading/body line, not - // a hugging ruby annotation, so it is kept. - FakeOcrLine shortHeading = new("メモ", new Windows.Foundation.Rect(0, 0, 40, 8)); - FakeOcrLine mainLine = new("黒ごま", new Windows.Foundation.Rect(0, 60, 120, 20)); - - FakeOcrLinesWords ocrResult = new() { Lines = [shortHeading, mainLine] }; - - IReadOnlyList result = OcrUtilities.FilterFuriganaLines(ocrResult.Lines); - - Assert.Equal(["メモ", "黒ごま"], result.Select(l => l.Text)); - } - - private static FakeOcrWord Word(string text, double x, double y, double width, double height) - => new(text, new Windows.Foundation.Rect(x, y, width, height)); - - private sealed class FakeOcrLinesWords : IOcrLinesWords - { - public string Text { get; set; } = string.Empty; - - public IOcrLine[] Lines { get; set; } = []; - - public float Angle { get; set; } - } - - private sealed class FakeOcrLine : IOcrLine - { - public FakeOcrLine(string text, Windows.Foundation.Rect boundingBox) - { - Text = text; - BoundingBox = boundingBox; - } - - public string Text { get; set; } - - public IOcrWord[] Words { get; set; } = []; - - public Windows.Foundation.Rect BoundingBox { get; set; } - } - - private sealed class FakeOcrWord : IOcrWord - { - public FakeOcrWord(string text, Windows.Foundation.Rect boundingBox) - { - Text = text; - BoundingBox = boundingBox; - } - - public string Text { get; set; } - - public Windows.Foundation.Rect BoundingBox { get; set; } - } -} diff --git a/Tests/PatternItemCatalogTests.cs b/Tests/PatternItemCatalogTests.cs new file mode 100644 index 00000000..d7cbe8df --- /dev/null +++ b/Tests/PatternItemCatalogTests.cs @@ -0,0 +1,47 @@ +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +// App-coupled half of the original Tests/PatternExecutorTests.cs (batch 7a): PatternItemCatalog +// (Text-Grab/Models/PatternItemCatalog.cs) reads settings and stays app-side per e677b54, so +// these three tests could not follow PatternExecutorTests to Tests.Core. +public class PatternItemCatalogTests +{ + [Fact] + public void GetAll_ListsSavedRegexesBeforeRecognizers() + { + IReadOnlyList all = PatternItemCatalog.GetAll(); + + int firstRecognizer = -1; + int lastSaved = -1; + for (int i = 0; i < all.Count; i++) + { + if (all[i].Kind == PatternKind.Recognizer && firstRecognizer < 0) + firstRecognizer = i; + if (all[i].Kind == PatternKind.SavedRegex) + lastSaved = i; + } + + Assert.True(firstRecognizer >= 0, "expected at least one recognizer item"); + Assert.True(lastSaved < firstRecognizer, "all saved regexes should precede recognizers"); + } + + [Fact] + public void GetAll_IncludesEveryRecognizerWithSmartGroup() + { + List recognizers = [.. PatternItemCatalog.GetAll().Where(p => p.Kind == PatternKind.Recognizer)]; + + Assert.Equal(BuiltInRecognizer.GetAll().Count, recognizers.Count); + Assert.All(recognizers, p => Assert.Equal(PatternItem.SmartGroup, p.GroupLabel)); + } + + [Fact] + public void GetByName_FindsRecognizer_CaseInsensitive() + { + PatternItem? email = PatternItemCatalog.GetByName("EMAIL"); + + Assert.NotNull(email); + Assert.Equal(PatternKind.Recognizer, email!.Kind); + } +} diff --git a/Tests/ProtocolHandlerUtilitiesTests.cs b/Tests/ProtocolHandlerUtilitiesTests.cs new file mode 100644 index 00000000..3bd75039 --- /dev/null +++ b/Tests/ProtocolHandlerUtilitiesTests.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using Text_Grab.Utilities; + +namespace Tests; + +// App-side half of the original Tests/ProtocolUtilitiesTests.cs (batch 7a): ProtocolHandlerUtilities +// (Text-Grab/Utilities/ProtocolHandlerUtilities.cs) is internal and app-side by design - it +// validates a companion app's path= parameter against the filesystem. The pure IsProtocolUri/ +// TryParseProtocolUri tests moved to Tests.Core/ProtocolUtilitiesTests.cs, which kept the name. +public class ProtocolHandlerUtilitiesTests +{ + [Fact] + public void TryGetSafeProtocolFilePath_AcceptsImageInTempFolder() + { + string tempImage = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.png"); + File.WriteAllBytes(tempImage, [0]); + try + { + bool safe = ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(tempImage, out string fullPath); + + Assert.True(safe); + Assert.Equal(Path.GetFullPath(tempImage), fullPath); + } + finally + { + File.Delete(tempImage); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(@"\\server\share\image.png")] // UNC: would trigger an SMB credential leak + [InlineData("//server/share/image.png")] // forward-slash UNC + [InlineData(@"\\?\C:\Windows\image.png")] // extended-length device path + [InlineData(@"\\.\PhysicalDrive0")] // device namespace + public void TryGetSafeProtocolFilePath_RejectsUncDeviceAndEmptyPaths(string? path) + { + Assert.False(ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(path, out _)); + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsPathOutsideAllowedRoots() + { + // The Windows folder is never an allowed root; rejection happens before any + // existence check, so the file need not exist. + string outside = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + $"text-grab-{Guid.NewGuid():N}.png"); + + Assert.False(ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(outside, out _)); + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsTraversalEscapingAllowedRoot() + { + // Starts inside Temp but climbs out to the Windows folder. + string traversal = Path.Combine(Path.GetTempPath(), "..", "..", "..", "Windows", "image.png"); + + Assert.False(ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(traversal, out _)); + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsNonImageExtensionInAllowedRoot() + { + string tempText = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.txt"); + File.WriteAllText(tempText, "hello"); + try + { + Assert.False(ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(tempText, out _)); + } + finally + { + File.Delete(tempText); + } + } + + [Fact] + public void TryGetSafeProtocolFilePath_RejectsNonexistentImageInAllowedRoot() + { + string missing = Path.Combine(Path.GetTempPath(), $"text-grab-missing-{Guid.NewGuid():N}.png"); + + Assert.False(ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(missing, out _)); + } +} diff --git a/Tests/ResultTableBenchmarks.cs b/Tests/ResultTableBenchmarks.cs index d12e0e80..e2d8c12d 100644 --- a/Tests/ResultTableBenchmarks.cs +++ b/Tests/ResultTableBenchmarks.cs @@ -2,7 +2,6 @@ using System.Drawing; using System.Text; using Text_Grab.Models; -using Rect = System.Windows.Rect; namespace Tests.Benchmarks; @@ -50,7 +49,7 @@ public void Setup() WordBorderInfo w = new() { Word = token, - BorderRect = new Rect(curLeft, top, Math.Max(12, token.Length * 7), rowH) + BorderRect = new RectangleF((float)curLeft, (float)top, Math.Max(12, token.Length * 7), (float)rowH) }; _syntheticBorders.Add(w); curLeft += w.BorderRect.Width + gapX; @@ -62,7 +61,7 @@ public void Setup() // Warm-up analysis so we can benchmark text build in isolation too _resultTable = new ResultTable(); - _resultTable.AnalyzeAsTable(_syntheticBorders, _canvas, drawTable: false); + _resultTable.AnalyzeAsTable(_syntheticBorders, _canvas); } [Benchmark] @@ -80,7 +79,7 @@ public int AnalyzeAsTable_Baseline() } ResultTable rt = new(); - rt.AnalyzeAsTable(copy, _canvas, drawTable: false); + rt.AnalyzeAsTable(copy, _canvas); return rt.Rows.Count + rt.Columns.Count; } diff --git a/Tests/ResultTableManualSeparatorTests.cs b/Tests/ResultTableManualSeparatorTests.cs index de777577..0cb5dd6f 100644 --- a/Tests/ResultTableManualSeparatorTests.cs +++ b/Tests/ResultTableManualSeparatorTests.cs @@ -1,6 +1,5 @@ using System.Drawing; using System.Text; -using System.Windows; using Text_Grab.Models; namespace Tests; @@ -17,7 +16,7 @@ public void AnalyzeAsTable_ManualRowSeparatorSplitsMergedRowOutput() ]; ResultTable automaticTable = new(); - automaticTable.AnalyzeAsTable(automaticInfos, new Rectangle(0, 0, 200, 200), drawTable: false); + automaticTable.AnalyzeAsTable(automaticInfos, new Rectangle(0, 0, 200, 200)); StringBuilder automaticText = new(); ResultTable.GetTextFromTabledWordBorders(automaticText, automaticInfos, true); @@ -34,8 +33,7 @@ public void AnalyzeAsTable_ManualRowSeparatorSplitsMergedRowOutput() manualInfos, new Rectangle(0, 0, 200, 200), manualRowSeparators: [18d], - manualColumnSeparators: null, - drawTable: false); + manualColumnSeparators: null); StringBuilder manualText = new(); ResultTable.GetTextFromTabledWordBorders(manualText, manualInfos, true); @@ -56,7 +54,7 @@ public void AnalyzeAsTable_ManualColumnSeparatorSplitsMergedColumnOutput() ]; ResultTable automaticTable = new(); - automaticTable.AnalyzeAsTable(automaticInfos, new Rectangle(0, 0, 200, 200), drawTable: false); + automaticTable.AnalyzeAsTable(automaticInfos, new Rectangle(0, 0, 200, 200)); StringBuilder automaticText = new(); ResultTable.GetTextFromTabledWordBorders(automaticText, automaticInfos, true); @@ -75,8 +73,7 @@ public void AnalyzeAsTable_ManualColumnSeparatorSplitsMergedColumnOutput() manualInfos, new Rectangle(0, 0, 200, 200), manualRowSeparators: null, - manualColumnSeparators: [25d], - drawTable: false); + manualColumnSeparators: [25d]); StringBuilder manualText = new(); ResultTable.GetTextFromTabledWordBorders(manualText, manualInfos, true); @@ -90,7 +87,7 @@ private static WordBorderInfo CreateWord(string word, double left, double top, d return new WordBorderInfo { Word = word, - BorderRect = new Rect(left, top, width, height) + BorderRect = new RectangleF((float)left, (float)top, (float)width, (float)height) }; } } diff --git a/Tests/SettingsAccessTests.cs b/Tests/SettingsAccessTests.cs new file mode 100644 index 00000000..79a34657 --- /dev/null +++ b/Tests/SettingsAccessTests.cs @@ -0,0 +1,86 @@ +using Text_Grab.Interfaces; +using Text_Grab.Properties; +using Text_Grab.Services; + +namespace Tests; + +/// +/// Guards the seam that lets Text-Grab.Core read settings without referencing the app. +/// If these break, portable code either cannot reach settings or is silently reading the wrong +/// instance - both of which surface far away from the actual cause. +/// +[Collection("Settings isolation")] +public class SettingsAccessTests +{ + [Fact] + public void ModuleInitializer_RegistersAResolverWithoutAnyStartupCall() + { + // The test host never raises App's WPF Startup event, so this passing is the proof that + // wiring lives in a module initializer and not in appStartup. + Assert.True(SettingsAccess.IsConfigured); + } + + [Fact] + public void Current_ResolvesToTheAppSettingsObject() + { + ITextGrabSettings settings = SettingsAccess.Current; + + Assert.IsType(settings); + } + + [Fact] + public void GeneratedSettingsPropertiesSatisfyTheInterfaceWithoutForwarding() + { + // Settings.Designer.cs is regenerated by SettingsSingleFileGenerator. Reading and writing + // through the interface here is what catches a generated property being renamed or having + // its type changed out from under ITextGrabSettings. + ITextGrabSettings settings = new Settings(); + + settings.CorrectToLatin = true; + settings.ParagraphDetection = false; + settings.TesseractPath = @"C:\tesseract\tesseract.exe"; + settings.LastUsedLang = "ja-JP"; + + Assert.True(settings.CorrectToLatin); + Assert.False(settings.ParagraphDetection); + Assert.Equal(@"C:\tesseract\tesseract.exe", settings.TesseractPath); + Assert.Equal("ja-JP", settings.LastUsedLang); + } + + [Fact] + public void SetResolver_SubstitutesAFakeAndCanBeRestored() + { + Settings substitute = new() { CorrectErrors = false, RemoveFurigana = true }; + + try + { + SettingsAccess.SetResolver(() => substitute); + + Assert.Same(substitute, SettingsAccess.Current); + Assert.False(SettingsAccess.Current.CorrectErrors); + Assert.True(SettingsAccess.Current.RemoveFurigana); + } + finally + { + SettingsAccess.SetResolver(static () => Text_Grab.Utilities.AppUtilities.TextGrabSettings); + } + + Assert.NotSame(substitute, SettingsAccess.Current); + } + + [Fact] + public void Current_ThrowsAClearErrorWhenNoResolverIsRegistered() + { + try + { + SettingsAccess.ClearResolver(); + + InvalidOperationException ex = Assert.Throws(() => SettingsAccess.Current); + Assert.Contains(nameof(SettingsAccess.SetResolver), ex.Message); + } + finally + { + SettingsAccess.SetResolver(static () => Text_Grab.Utilities.AppUtilities.TextGrabSettings); + } + } +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 95827918..d651ba05 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -55,16 +55,5 @@ - - - PreserveNewest - - - - - - PreserveNewest - - diff --git a/Text-Grab.Core.Windows/AssemblyInfo.cs b/Text-Grab.Core.Windows/AssemblyInfo.cs new file mode 100644 index 00000000..2d361181 --- /dev/null +++ b/Text-Grab.Core.Windows/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Text-Grab")] +[assembly: InternalsVisibleTo("Tests")] +[assembly: InternalsVisibleTo("Tests.Core.Windows")] diff --git a/Text-Grab/DesktopNotificationManagerCompat.cs b/Text-Grab.Core.Windows/DesktopNotificationManagerCompat.cs similarity index 100% rename from Text-Grab/DesktopNotificationManagerCompat.cs rename to Text-Grab.Core.Windows/DesktopNotificationManagerCompat.cs diff --git a/Text-Grab/Extensions/ImageExtensions.cs b/Text-Grab.Core.Windows/Extensions/ImageExtensions.cs similarity index 78% rename from Text-Grab/Extensions/ImageExtensions.cs rename to Text-Grab.Core.Windows/Extensions/ImageExtensions.cs index be8c0720..0aa44755 100644 --- a/Text-Grab/Extensions/ImageExtensions.cs +++ b/Text-Grab.Core.Windows/Extensions/ImageExtensions.cs @@ -9,16 +9,6 @@ internal static class ImageExtensions { private const int exifOrientationID = 0x112; //274 - internal static void ExifRotate(this Image img) - { - RotateFlipType rot = img.GetRotateFlipType(); - if (rot != RotateFlipType.RotateNoneFlipNone) - { - img.RotateFlip(rot); - img.RemovePropertyItem(exifOrientationID); - } - } - internal static RotateFlipType GetRotateFlipType(this Image img) { if (!img.PropertyIdList.Contains(exifOrientationID) diff --git a/Text-Grab/Extensions/LanguageExtensions.cs b/Text-Grab.Core.Windows/Extensions/LanguageExtensions.cs similarity index 57% rename from Text-Grab/Extensions/LanguageExtensions.cs rename to Text-Grab.Core.Windows/Extensions/LanguageExtensions.cs index 0024813e..1b50eb3b 100644 --- a/Text-Grab/Extensions/LanguageExtensions.cs +++ b/Text-Grab.Core.Windows/Extensions/LanguageExtensions.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Globalization; -using System.Windows.Markup; using Text_Grab.Interfaces; using Text_Grab.Models; using Windows.Globalization; @@ -18,13 +17,6 @@ public static bool IsSpaceJoining(this Language selectedLanguage) return true; } - public static bool IsRightToLeft(this Language language) - { - XmlLanguage lang = XmlLanguage.GetLanguage(language.LanguageTag); - CultureInfo culture = lang.GetEquivalentCulture(); - return culture.TextInfo.IsRightToLeft; - } - public static bool IsSpaceJoining(this ILanguage selectedLanguage) { if (selectedLanguage.LanguageTag.StartsWith("zh", StringComparison.InvariantCultureIgnoreCase)) @@ -34,15 +26,6 @@ public static bool IsSpaceJoining(this ILanguage selectedLanguage) return true; } - public static bool IsRightToLeft(this ILanguage selectedLanguage) - { - if (selectedLanguage is GlobalLang language) - return language.OriginalLanguage.IsRightToLeft(); - - // For other language types, use the LayoutDirection property - return selectedLanguage.LayoutDirection == LanguageLayoutDirection.Rtl; - } - public static bool IsLatinBased(this ILanguage selectedLanguage) { return string.Equals(selectedLanguage.Script, "Latn", StringComparison.OrdinalIgnoreCase); @@ -71,4 +54,40 @@ public static bool IsLatinBased(this ILanguage selectedLanguage) return null; return new GlobalLang(language); } + + /// + /// Whether text in this language reads right-to-left. + /// + /// The GlobalLang branch used to delegate to an overload taking + /// Windows.Globalization.Language, which resolved the tag through + /// XmlLanguage.GetLanguage(tag).GetEquivalentCulture(). XmlLanguage comes from + /// PresentationCore, which is why batch 3d had to leave both overloads in the app. Batch 4c + /// needed this one in Core.Windows for BuildTextFromOcrLines, so the tag is now resolved with + /// CultureInfo directly. The two were probed against 24 tags - ar, ar-EG, ar-SA, he, he-IL, + /// ur, ur-PK, fa, fa-IR, ckb, ps-AF, sd-Arab-PK, yi, he-Hebr-IL, ar-XX, en, en-US, ja, + /// zh-Hans, de-DE, and the unresolvable xx, xx-YY, und and "" - and agreed on every one. + /// + public static bool IsRightToLeft(this ILanguage selectedLanguage) + { + if (selectedLanguage is GlobalLang language) + return IsRightToLeftTag(language.OriginalLanguage.LanguageTag); + + // For other language types, use the LayoutDirection property + return selectedLanguage.LayoutDirection == LanguageLayoutDirection.Rtl; + } + + private static bool IsRightToLeftTag(string languageTag) + { + try + { + return CultureInfo.GetCultureInfo(languageTag).TextInfo.IsRightToLeft; + } + catch (CultureNotFoundException) + { + // XmlLanguage fell back to the invariant culture, which is left-to-right, for tags + // it could not resolve. Keep that behaviour rather than throwing at a call site that + // only wanted to know which way to order words. + return false; + } + } } diff --git a/Text-Grab/Extensions/SettingsStorageExtensions.cs b/Text-Grab.Core.Windows/Extensions/SettingsStorageExtensions.cs similarity index 100% rename from Text-Grab/Extensions/SettingsStorageExtensions.cs rename to Text-Grab.Core.Windows/Extensions/SettingsStorageExtensions.cs diff --git a/Text-Grab/Extensions/SoftwareBitmapExtensions.cs b/Text-Grab.Core.Windows/Extensions/SoftwareBitmapExtensions.cs similarity index 84% rename from Text-Grab/Extensions/SoftwareBitmapExtensions.cs rename to Text-Grab.Core.Windows/Extensions/SoftwareBitmapExtensions.cs index ceea81ac..1d5eb70e 100644 --- a/Text-Grab/Extensions/SoftwareBitmapExtensions.cs +++ b/Text-Grab.Core.Windows/Extensions/SoftwareBitmapExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.UI.Xaml.Media.Imaging; using System; using System.Drawing.Imaging; using System.IO; @@ -14,23 +13,6 @@ namespace Text_Grab.Extensions; public static class SoftwareBitmapExtensions { - public static async Task ToSourceAsync(this SoftwareBitmap softwareBitmap) - { - SoftwareBitmapSource source = new(); - - if (softwareBitmap.BitmapPixelFormat != BitmapPixelFormat.Bgra8 || softwareBitmap.BitmapAlphaMode != BitmapAlphaMode.Premultiplied) - { - SoftwareBitmap convertedBitmap = SoftwareBitmap.Convert(softwareBitmap, BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied); - await source.SetBitmapAsync(convertedBitmap); - } - else - { - await source.SetBitmapAsync(softwareBitmap); - } - - return source; - } - public static async Task FilePathToSoftwareBitmapAsync(this string filePath) { using IRandomAccessStream stream = await StorageFileExtensions.CreateStreamAsync(filePath); diff --git a/Text-Grab/Extensions/StorageFileExtensions.cs b/Text-Grab.Core.Windows/Extensions/StorageFileExtensions.cs similarity index 100% rename from Text-Grab/Extensions/StorageFileExtensions.cs rename to Text-Grab.Core.Windows/Extensions/StorageFileExtensions.cs diff --git a/Text-Grab/Interfaces/ILanguage.cs b/Text-Grab.Core.Windows/Interfaces/ILanguage.cs similarity index 100% rename from Text-Grab/Interfaces/ILanguage.cs rename to Text-Grab.Core.Windows/Interfaces/ILanguage.cs diff --git a/Text-Grab/Models/DragDataObject.cs b/Text-Grab.Core.Windows/Models/DragDataObject.cs similarity index 78% rename from Text-Grab/Models/DragDataObject.cs rename to Text-Grab.Core.Windows/Models/DragDataObject.cs index 55d861df..113cbeab 100644 --- a/Text-Grab/Models/DragDataObject.cs +++ b/Text-Grab.Core.Windows/Models/DragDataObject.cs @@ -2,8 +2,6 @@ using System.Drawing; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; -using DrawingImaging = System.Drawing.Imaging; -using MediaImaging = System.Windows.Media.Imaging; namespace Text_Grab.Models; @@ -72,21 +70,4 @@ private interface IDragSourceHelper // more methods available, but we don't need them } - - // https://stackoverflow.com/a/2897325 - public static Bitmap? BitmapSourceToBitmap(MediaImaging.BitmapSource source) - { - if (source == null) - { - return null; - } - - Bitmap bitmap = new(source.PixelWidth, source.PixelHeight, DrawingImaging.PixelFormat.Format32bppArgb); - DrawingImaging.BitmapData bitmapData = bitmap.LockBits(new Rectangle(Point.Empty, bitmap.Size), DrawingImaging.ImageLockMode.WriteOnly, DrawingImaging.PixelFormat.Format32bppArgb); - - source.CopyPixels(System.Windows.Int32Rect.Empty, bitmapData.Scan0, bitmapData.Height * bitmapData.Stride, bitmapData.Stride); - bitmap.UnlockBits(bitmapData); - - return bitmap; - } } \ No newline at end of file diff --git a/Text-Grab/Models/GeneratedOcrLinesWords.cs b/Text-Grab.Core.Windows/Models/GeneratedOcrLinesWords.cs similarity index 100% rename from Text-Grab/Models/GeneratedOcrLinesWords.cs rename to Text-Grab.Core.Windows/Models/GeneratedOcrLinesWords.cs diff --git a/Text-Grab/Models/GlobalLang.cs b/Text-Grab.Core.Windows/Models/GlobalLang.cs similarity index 100% rename from Text-Grab/Models/GlobalLang.cs rename to Text-Grab.Core.Windows/Models/GlobalLang.cs diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab.Core.Windows/Models/HistoryInfo.cs similarity index 70% rename from Text-Grab/Models/HistoryInfo.cs rename to Text-Grab.Core.Windows/Models/HistoryInfo.cs index d541eed8..61bfc3d9 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab.Core.Windows/Models/HistoryInfo.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Globalization; using System.Text.Json.Serialization; -using System.Windows; using Text_Grab.Interfaces; using Text_Grab.Utilities; using Windows.Globalization; @@ -11,6 +11,8 @@ namespace Text_Grab.Models; public class HistoryInfo : IEquatable { + private static readonly NumberFormatInfo CommaDecimalFormat = new() { NumberDecimalSeparator = "," }; + #region Constructors public HistoryInfo() @@ -91,21 +93,22 @@ public ILanguage OcrLanguage } } + /// + /// A projection over the persisted , not a stored field. + /// + /// + /// The on-disk format is the one System.Windows.Rect wrote before B2 of the Core split + /// moved this model off WPF geometry: "x,y,width,height", or the literal "Empty". + /// It is written with the invariant culture so a history file stays readable on any machine, + /// and read back tolerating the ';' separator and comma decimals that + /// Rect.ToString() produced under cultures whose decimal separator is ',' - + /// strings the old invariant-only Rect.Parse threw on. + /// [JsonIgnore] - public Rect PositionRect + public RectangleF PositionRect { - get - { - if (string.IsNullOrWhiteSpace(RectAsString)) - return Rect.Empty; - - return Rect.Parse(RectAsString); - } - - set - { - RectAsString = value.ToString(); - } + get => ParsePositionRect(RectAsString); + set => RectAsString = FormatPositionRect(value); } public TextGrabMode SourceMode { get; set; } @@ -177,5 +180,40 @@ public override int GetHashCode() return HashCode.Combine(ID); } + private static RectangleF ParsePositionRect(string source) + { + if (string.IsNullOrWhiteSpace(source)) + return RectangleF.Empty; + + string trimmed = source.Trim(); + + if (trimmed.Equals("Empty", StringComparison.OrdinalIgnoreCase)) + return RectangleF.Empty; + + // A ';' separator means the writing culture used ',' as its decimal separator. + bool commaDecimals = trimmed.Contains(';'); + string[] parts = trimmed.Split(commaDecimals ? ';' : ','); + + if (parts.Length != 4) + return RectangleF.Empty; + + IFormatProvider format = commaDecimals ? CommaDecimalFormat : CultureInfo.InvariantCulture; + float[] values = new float[4]; + + for (int i = 0; i < 4; i++) + if (!float.TryParse(parts[i].Trim(), NumberStyles.Float, format, out values[i])) + return RectangleF.Empty; + + return new RectangleF(values[0], values[1], values[2], values[3]); + } + + private static string FormatPositionRect(RectangleF rect) + { + if (rect == RectangleF.Empty) + return string.Empty; + + return string.Create(CultureInfo.InvariantCulture, $"{rect.X},{rect.Y},{rect.Width},{rect.Height}"); + } + #endregion Public Methods } diff --git a/Text-Grab/Models/OcrLinesWords.cs b/Text-Grab.Core.Windows/Models/OcrLinesWords.cs similarity index 100% rename from Text-Grab/Models/OcrLinesWords.cs rename to Text-Grab.Core.Windows/Models/OcrLinesWords.cs diff --git a/Text-Grab/Models/OcrOutput.cs b/Text-Grab.Core.Windows/Models/OcrOutput.cs similarity index 77% rename from Text-Grab/Models/OcrOutput.cs rename to Text-Grab.Core.Windows/Models/OcrOutput.cs index eaf900cf..4587709f 100644 --- a/Text-Grab/Models/OcrOutput.cs +++ b/Text-Grab.Core.Windows/Models/OcrOutput.cs @@ -1,6 +1,6 @@ using System.Drawing; using Text_Grab.Interfaces; -using Text_Grab.Properties; +using Text_Grab.Services; using Text_Grab.Utilities; using Windows.Graphics.Imaging; @@ -18,16 +18,15 @@ public record OcrOutput public void CleanOutput() { - if (AppUtilities.TextGrabSettings is not Settings userSettings - || Kind == OcrOutputKind.Barcode) + if (Kind == OcrOutputKind.Barcode) return; string correctingString = RawOutput; - if (userSettings.CorrectToLatin && Language?.IsLatinBased() == true) + if (SettingsAccess.Current.CorrectToLatin && Language?.IsLatinBased() == true) correctingString = correctingString.ReplaceGreekOrCyrillicWithLatin(); - if (userSettings.CorrectErrors) + if (SettingsAccess.Current.CorrectErrors) correctingString = correctingString.TryFixEveryWordLetterNumberErrors(); CleanedOutput = correctingString; diff --git a/Text-Grab/Models/TessLang.cs b/Text-Grab.Core.Windows/Models/TessLang.cs similarity index 100% rename from Text-Grab/Models/TessLang.cs rename to Text-Grab.Core.Windows/Models/TessLang.cs diff --git a/Text-Grab/Models/UiAutomationLang.cs b/Text-Grab.Core.Windows/Models/UiAutomationLang.cs similarity index 100% rename from Text-Grab/Models/UiAutomationLang.cs rename to Text-Grab.Core.Windows/Models/UiAutomationLang.cs diff --git a/Text-Grab/Models/WinAiOcrLinesWords.cs b/Text-Grab.Core.Windows/Models/WinAiOcrLinesWords.cs similarity index 100% rename from Text-Grab/Models/WinAiOcrLinesWords.cs rename to Text-Grab.Core.Windows/Models/WinAiOcrLinesWords.cs diff --git a/Text-Grab/Models/WinRtOcrLinesWords.cs b/Text-Grab.Core.Windows/Models/WinRtOcrLinesWords.cs similarity index 74% rename from Text-Grab/Models/WinRtOcrLinesWords.cs rename to Text-Grab.Core.Windows/Models/WinRtOcrLinesWords.cs index b4351d4b..39aa1cdb 100644 --- a/Text-Grab/Models/WinRtOcrLinesWords.cs +++ b/Text-Grab.Core.Windows/Models/WinRtOcrLinesWords.cs @@ -1,5 +1,4 @@ -using Text_Grab.Utilities; -using Windows.Foundation; +using Windows.Foundation; using Windows.Media.Ocr; namespace Text_Grab.Models; @@ -41,9 +40,7 @@ public WinRtOcrLine(OcrLine ocrLine) Words[i] = new WinRtOcrWord(word); } - System.Windows.Rect bRect = ocrLine.GetBoundingRect(); - - BoundingBox = new Rect(bRect.Left, bRect.Top, bRect.Width, bRect.Height); + BoundingBox = GetBoundingRect(ocrLine); } public OcrLine OriginalLine { get; set; } @@ -51,6 +48,16 @@ public WinRtOcrLine(OcrLine ocrLine) public string Text { get; set; } public IOcrWord[] Words { get; set; } public Rect BoundingBox { get; set; } + + private static Rect GetBoundingRect(OcrLine ocrLine) + { + double top = ocrLine.Words.Select(w => w.BoundingRect.Top).Min(); + double bottom = ocrLine.Words.Select(w => w.BoundingRect.Bottom).Max(); + double left = ocrLine.Words.Select(w => w.BoundingRect.Left).Min(); + double right = ocrLine.Words.Select(w => w.BoundingRect.Right).Max(); + + return new Rect(left, top, Math.Abs(right - left), Math.Abs(bottom - top)); + } } public class WinRtOcrWord : IOcrWord diff --git a/Text-Grab/Models/WindowsAiDescriptionLang.cs b/Text-Grab.Core.Windows/Models/WindowsAiDescriptionLang.cs similarity index 100% rename from Text-Grab/Models/WindowsAiDescriptionLang.cs rename to Text-Grab.Core.Windows/Models/WindowsAiDescriptionLang.cs diff --git a/Text-Grab/Models/WindowsAiLang.cs b/Text-Grab.Core.Windows/Models/WindowsAiLang.cs similarity index 100% rename from Text-Grab/Models/WindowsAiLang.cs rename to Text-Grab.Core.Windows/Models/WindowsAiLang.cs diff --git a/Text-Grab/NativeMethods.cs b/Text-Grab.Core.Windows/NativeMethods.cs similarity index 100% rename from Text-Grab/NativeMethods.cs rename to Text-Grab.Core.Windows/NativeMethods.cs diff --git a/Text-Grab/OSInterop.cs b/Text-Grab.Core.Windows/OSInterop.cs similarity index 99% rename from Text-Grab/OSInterop.cs rename to Text-Grab.Core.Windows/OSInterop.cs index 578aa536..e4b990d7 100644 --- a/Text-Grab/OSInterop.cs +++ b/Text-Grab.Core.Windows/OSInterop.cs @@ -121,9 +121,6 @@ public class MONITORINFOEX public delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); - [LibraryImport("user32.dll")] - public static partial short GetAsyncKeyState(System.Windows.Forms.Keys vKey); - [LibraryImport("user32.dll")] public static partial uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); diff --git a/Text-Grab/Services/LanguageService.cs b/Text-Grab.Core.Windows/Services/LanguageService.cs similarity index 95% rename from Text-Grab/Services/LanguageService.cs rename to Text-Grab.Core.Windows/Services/LanguageService.cs index ae0a40e0..c60364ec 100644 --- a/Text-Grab/Services/LanguageService.cs +++ b/Text-Grab.Core.Windows/Services/LanguageService.cs @@ -3,8 +3,8 @@ using System.Diagnostics; using System.Globalization; using System.Linq; -using System.Windows.Input; using Text_Grab.Interfaces; +using Text_Grab.Services; using Text_Grab.Models; using Text_Grab.Utilities; using Windows.Globalization; @@ -76,7 +76,7 @@ public IList GetAllLanguages() List languages = []; - if (AppUtilities.TextGrabSettings.UiAutomationEnabled) + if (SettingsAccess.Current.UiAutomationEnabled) languages.Add(_uiAutomationLangInstance); if (WindowsAiUtilities.CanDeviceUseWinAI()) @@ -85,7 +85,7 @@ public IList GetAllLanguages() languages.Add(_windowsAiLangInstance); } - if (AppUtilities.TextGrabSettings.WindowsAiDescriptionEnabled + if (SettingsAccess.Current.WindowsAiDescriptionEnabled && WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) { languages.Add(_windowsAiDescriptionLangInstance); @@ -167,7 +167,7 @@ public static (string LanguageTag, LanguageKind LanguageKind, bool UsedUiAutomat ///
public ILanguage GetOCRLanguage() { - string lastUsedLang = AppUtilities.TextGrabSettings.LastUsedLang; + string lastUsedLang = SettingsAccess.Current.LastUsedLang; lock (_cacheLock) { @@ -194,7 +194,7 @@ public ILanguage GetOCRLanguage() } else if (lastUsedLang == _windowsAiDescriptionLangTag) { - if (AppUtilities.TextGrabSettings.WindowsAiDescriptionEnabled + if (SettingsAccess.Current.WindowsAiDescriptionEnabled && WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) { _cachedOcrLanguage = _windowsAiDescriptionLangInstance; @@ -203,7 +203,7 @@ public ILanguage GetOCRLanguage() selectedLanguage = GetCurrentInputLanguage(); } - else if (lastUsedLang == _uiAutomationLangTag && AppUtilities.TextGrabSettings.UiAutomationEnabled) + else if (lastUsedLang == _uiAutomationLangTag && SettingsAccess.Current.UiAutomationEnabled) { _cachedOcrLanguage = _uiAutomationLangInstance; return _cachedOcrLanguage; @@ -370,15 +370,10 @@ public void InvalidateAllCaches() private static string GetCurrentInputLanguageTag() { - string? currentInputLangTag = null; - try - { - currentInputLangTag = InputLanguageManager.Current?.CurrentInputLanguage?.Name; - } - catch (NullReferenceException) - { - currentInputLangTag = null; - } + // Was InputLanguageManager.Current?.CurrentInputLanguage?.Name before this file moved out + // of the app. InputLanguageManager is PresentationCore; the app registers the read (and + // owns the NullReferenceException its internals can throw) via InputLanguageAccess. + string? currentInputLangTag = InputLanguageAccess.CurrentTag; if (!string.IsNullOrWhiteSpace(currentInputLangTag)) return currentInputLangTag; diff --git a/Text-Grab/Services/WindowsSpeechEngine.cs b/Text-Grab.Core.Windows/Services/WindowsSpeechEngine.cs similarity index 91% rename from Text-Grab/Services/WindowsSpeechEngine.cs rename to Text-Grab.Core.Windows/Services/WindowsSpeechEngine.cs index 0e5b4bc7..a5ffa8ac 100644 --- a/Text-Grab/Services/WindowsSpeechEngine.cs +++ b/Text-Grab.Core.Windows/Services/WindowsSpeechEngine.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Tasks; using Text_Grab.Interfaces; -using Text_Grab.Properties; using Windows.Media.Core; using Windows.Media.Playback; using Windows.Media.SpeechSynthesis; @@ -16,7 +15,7 @@ public async Task SpeakAsync(string text, CancellationToken ct) { using SpeechSynthesizer synthesizer = new(); - string voiceName = Settings.Default.TtsVoiceName; + string voiceName = SettingsAccess.Current.TtsVoiceName; if (!string.IsNullOrEmpty(voiceName)) { VoiceInformation? voice = SpeechSynthesizer.AllVoices @@ -25,7 +24,7 @@ public async Task SpeakAsync(string text, CancellationToken ct) synthesizer.Voice = voice; } - double speakingRate = Settings.Default.TtsSpeakingRate; + double speakingRate = SettingsAccess.Current.TtsSpeakingRate; if (speakingRate >= 0.5 && speakingRate <= 6.0) synthesizer.Options.SpeakingRate = speakingRate; diff --git a/Text-Grab.Core.Windows/Text-Grab.Core.Windows.csproj b/Text-Grab.Core.Windows/Text-Grab.Core.Windows.csproj new file mode 100644 index 00000000..36b29636 --- /dev/null +++ b/Text-Grab.Core.Windows/Text-Grab.Core.Windows.csproj @@ -0,0 +1,70 @@ + + + + net10.0-windows10.0.22621.0 + 10.0.22621.48 + Text_Grab + enable + enable + true + false + false + false + win-x86;win-x64;win-arm64 + + + + + + + + + $(LAF_TOKEN) + $(LAF_PUBLISHER_ID) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Utilities/AudioTranscriptionUtilities.cs b/Text-Grab.Core.Windows/Utilities/AudioTranscriptionUtilities.cs similarity index 99% rename from Text-Grab/Utilities/AudioTranscriptionUtilities.cs rename to Text-Grab.Core.Windows/Utilities/AudioTranscriptionUtilities.cs index 676f20f6..eed1ffdb 100644 --- a/Text-Grab/Utilities/AudioTranscriptionUtilities.cs +++ b/Text-Grab.Core.Windows/Utilities/AudioTranscriptionUtilities.cs @@ -10,6 +10,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Text_Grab.Services; using Whisper.net; using Whisper.net.Ggml; @@ -178,7 +179,7 @@ public static class AudioTranscriptionUtilities "Text-Grab", "WhisperModels"); /// The transcription model currently selected in settings (defaults to multilingual base). - public static WhisperModelChoice CurrentModelChoice => WhisperModelInfo.Parse(AppUtilities.TextGrabSettings.AudioTranscriptionModel); + public static WhisperModelChoice CurrentModelChoice => WhisperModelInfo.Parse(SettingsAccess.Current.AudioTranscriptionModel); private static string ModelPathFor(WhisperModelChoice choice) { diff --git a/Text-Grab/Utilities/BarcodeUtilities.cs b/Text-Grab.Core.Windows/Utilities/BarcodeUtilities.cs similarity index 100% rename from Text-Grab/Utilities/BarcodeUtilities.cs rename to Text-Grab.Core.Windows/Utilities/BarcodeUtilities.cs diff --git a/Text-Grab.Core.Windows/Utilities/BitmapMaskUtilities.cs b/Text-Grab.Core.Windows/Utilities/BitmapMaskUtilities.cs new file mode 100644 index 00000000..a690cc97 --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/BitmapMaskUtilities.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace Text_Grab.Utilities; + +/// +/// Split out of FreeformCaptureUtilities: the one member of that file with no WPF rendering +/// type in its signature. GetBounds and BuildGeometry return System.Windows.Rect/PathGeometry +/// and stay in the app. +/// +public static class BitmapMaskUtilities +{ + public static Bitmap CreateMaskedBitmap(Bitmap sourceBitmap, IReadOnlyList pointsRelativeToBounds) + { + ArgumentNullException.ThrowIfNull(sourceBitmap); + + if (pointsRelativeToBounds is null || pointsRelativeToBounds.Count < 3) + return new Bitmap(sourceBitmap); + + Bitmap maskedBitmap = new(sourceBitmap.Width, sourceBitmap.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using Graphics graphics = Graphics.FromImage(maskedBitmap); + using GraphicsPath graphicsPath = new(); + + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.Clear(System.Drawing.Color.Gray); + + graphicsPath.AddPolygon([.. pointsRelativeToBounds]); + graphics.SetClip(graphicsPath); + graphics.DrawImage(sourceBitmap, new Rectangle(0, 0, sourceBitmap.Width, sourceBitmap.Height)); + + return maskedBitmap; + } +} diff --git a/Text-Grab.Core.Windows/Utilities/BitmapUtilities.cs b/Text-Grab.Core.Windows/Utilities/BitmapUtilities.cs new file mode 100644 index 00000000..eabd7a58 --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/BitmapUtilities.cs @@ -0,0 +1,71 @@ +using System; +using System.Drawing; +using System.IO; +using Text_Grab.Extensions; +using Text_Grab.Services; +using Text_Grab.Utilities.Hdr; +using Windows.Storage.Streams; + +namespace Text_Grab; + +public static class BitmapUtilities +{ + public static Bitmap PadImage(Bitmap image, int minW = 64, int minH = 64) + { + if (image.Height >= minH && image.Width >= minW) + return image; + + int width = Math.Max(image.Width + 16, minW + 16); + int height = Math.Max(image.Height + 16, minH + 16); + + // Create a compatible bitmap + Bitmap destination = new(width, height, image.PixelFormat); + using Graphics gd = Graphics.FromImage(destination); + + gd.Clear(image.GetPixel(0, 0)); + gd.DrawImageUnscaled(image, 8, 8); + + return destination; + } + + public static Bitmap GetBitmapFromIRandomAccessStream(IRandomAccessStream stream) + { + Stream managedStream = stream.AsStream(); + if (managedStream.CanSeek) + managedStream.Position = 0; + + using Bitmap bitmap = new(managedStream); + return new Bitmap(bitmap); + } + + internal static RotateFlipType GetRotateFlipType(string path) + { + using Image img = Image.FromFile(path); + RotateFlipType rotateFlipType = img.GetRotateFlipType(); + return rotateFlipType; + } + + /// + /// Grabs a virtual-desktop region as a bitmap, preferring the HDR-aware capture path when the + /// user has enabled it. Internal rather than public: its only callers are ImageMethods' + /// GetRegionOfScreenAsBitmap and GetWindowsBoundsBitmap, both of which stay in the app - + /// GetRegionOfScreenAsBitmap because it writes to HistoryService, GetWindowsBoundsBitmap + /// because it pattern-matches on the GrabFrame view. It was private to ImageMethods before + /// batch 5b unblocked it by moving HdrScreenCapture into this assembly. + /// + internal static Bitmap CaptureScreenRegion(Rectangle region) + { + if (SettingsAccess.Current.HdrCaptureCorrection) + { + Bitmap? hdrBitmap = HdrScreenCapture.TryCaptureRegion(region); + if (hdrBitmap is not null) + return hdrBitmap; + } + + Bitmap bmp = new(region.Width, region.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using Graphics g = Graphics.FromImage(bmp); + + g.CopyFromScreen(region.Left, region.Top, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); + return bmp; + } +} diff --git a/Text-Grab/Utilities/CaptureLanguageUtilities.cs b/Text-Grab.Core.Windows/Utilities/CaptureLanguageUtilities.cs similarity index 95% rename from Text-Grab/Utilities/CaptureLanguageUtilities.cs rename to Text-Grab.Core.Windows/Utilities/CaptureLanguageUtilities.cs index bed65194..862aec51 100644 --- a/Text-Grab/Utilities/CaptureLanguageUtilities.cs +++ b/Text-Grab.Core.Windows/Utilities/CaptureLanguageUtilities.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Text_Grab.Interfaces; using Text_Grab.Models; +using Text_Grab.Services; namespace Text_Grab.Utilities; @@ -21,7 +22,7 @@ public static async Task> GetCaptureLanguagesAsync(bool includeT List languages = [.. LanguageUtilities.GetAllLanguages()]; if (includeTesseract - && AppUtilities.TextGrabSettings.UseTesseract + && SettingsAccess.Current.UseTesseract && TesseractHelper.CanLocateTesseractExe()) { List tesseractLanguages = await TesseractHelper.TesseractLanguages(); @@ -64,8 +65,8 @@ public static int FindPreferredLanguageIndex(IReadOnlyList languages, public static void PersistSelectedLanguage(ILanguage language) { - AppUtilities.TextGrabSettings.LastUsedLang = language.LanguageTag; - AppUtilities.TextGrabSettings.Save(); + SettingsAccess.Current.LastUsedLang = language.LanguageTag; + SettingsAccess.Current.Save(); LanguageUtilities.InvalidateOcrLanguageCache(); } diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab.Core.Windows/Utilities/ContextMenuUtilities.cs similarity index 100% rename from Text-Grab/Utilities/ContextMenuUtilities.cs rename to Text-Grab.Core.Windows/Utilities/ContextMenuUtilities.cs diff --git a/Text-Grab/Utilities/FileAssociationUtilities.cs b/Text-Grab.Core.Windows/Utilities/FileAssociationUtilities.cs similarity index 98% rename from Text-Grab/Utilities/FileAssociationUtilities.cs rename to Text-Grab.Core.Windows/Utilities/FileAssociationUtilities.cs index 9a501cd8..0e74e25e 100644 --- a/Text-Grab/Utilities/FileAssociationUtilities.cs +++ b/Text-Grab.Core.Windows/Utilities/FileAssociationUtilities.cs @@ -24,7 +24,7 @@ internal static class FileAssociationUtilities /// internal static void EnsureGrabFrameFileAssociation() { - if (AppUtilities.IsPackaged()) + if (PackageIdentity.IsPackaged()) return; string executablePath = FileUtilities.GetExePath(); diff --git a/Text-Grab/Utilities/FileUtilities.cs b/Text-Grab.Core.Windows/Utilities/FileUtilities.cs similarity index 94% rename from Text-Grab/Utilities/FileUtilities.cs rename to Text-Grab.Core.Windows/Utilities/FileUtilities.cs index f9958b9c..28329f16 100644 --- a/Text-Grab/Utilities/FileUtilities.cs +++ b/Text-Grab.Core.Windows/Utilities/FileUtilities.cs @@ -18,7 +18,7 @@ public class FileUtilities if (AutomationProfile.Current is not null) return GetImageFileUnpackaged(fileName, storageKind); - if (AppUtilities.IsPackaged() && AutomationProfile.Current is null) + if (PackageIdentity.IsPackaged() && AutomationProfile.Current is null) return GetImageFilePackaged(fileName, storageKind); return GetImageFileUnpackaged(fileName, storageKind); @@ -53,6 +53,13 @@ public static string GetVisualDocumentFilter() }); } + /// + /// The FileDialog filter string for the app's general "Open" dialog: images, PDFs, Grab + /// Frame files, spreadsheets, markdown and plain text. Folded back in here once + /// followed to + /// Core.Windows - before that, this lived app-side as + /// OpenDocumentFilterUtilities.GetOpenDocumentFilter() for exactly that reason. + /// public static string GetOpenDocumentFilter() { string spreadsheetExtensions = GetExtensionsFilterPattern(IoUtilities.SpreadsheetExtensions); @@ -94,7 +101,7 @@ public static async Task GetPathToHistory() if (AutomationProfile.Current is AutomationProfile profile) return profile.HistoryDirectory; - if (AppUtilities.IsPackaged()) + if (PackageIdentity.IsPackaged()) { StorageFolder historyFolder = await GetStorageFolderPackaged("", FileStorageKind.WithHistory); return historyFolder.Path; @@ -108,7 +115,7 @@ public static Task GetTextFileAsync(string fileName, FileStorageKind sto if (AutomationProfile.Current is not null) return GetTextFileUnpackaged(fileName, storageKind); - if (AppUtilities.IsPackaged()) + if (PackageIdentity.IsPackaged()) return GetTextFilePackaged(fileName, storageKind); return GetTextFileUnpackaged(fileName, storageKind); @@ -119,7 +126,7 @@ public static Task SaveImageFile(Bitmap image, string filename, FileStorag if (AutomationProfile.Current is not null) return SaveImageFileUnpackaged(image, filename, storageKind); - if (AppUtilities.IsPackaged()) + if (PackageIdentity.IsPackaged()) return SaveImagePackaged(image, filename, storageKind); return SaveImageFileUnpackaged(image, filename, storageKind); @@ -130,7 +137,7 @@ public static Task SaveTextFile(string textContent, string filename, FileS if (AutomationProfile.Current is not null) return SaveTextFileUnpackaged(textContent, filename, storageKind); - if (AppUtilities.IsPackaged()) + if (PackageIdentity.IsPackaged()) return SaveTextFilePackaged(textContent, filename, storageKind); return SaveTextFileUnpackaged(textContent, filename, storageKind); @@ -369,7 +376,7 @@ private static async Task SaveTextFileUnpackaged(string textContent, strin public static async void TryDeleteHistoryDirectory() { FileStorageKind historyFolderKind = FileStorageKind.WithHistory; - if (AppUtilities.IsPackaged() && AutomationProfile.Current is null) + if (PackageIdentity.IsPackaged() && AutomationProfile.Current is null) { StorageFolder historyFolder = await GetStorageFolderPackaged("", historyFolderKind); diff --git a/Text-Grab/Utilities/GrabFrameFileUtilities.cs b/Text-Grab.Core.Windows/Utilities/GrabFrameFileUtilities.cs similarity index 100% rename from Text-Grab/Utilities/GrabFrameFileUtilities.cs rename to Text-Grab.Core.Windows/Utilities/GrabFrameFileUtilities.cs diff --git a/Text-Grab/Utilities/Hdr/DisplayHdrInfo.cs b/Text-Grab.Core.Windows/Utilities/Hdr/DisplayHdrInfo.cs similarity index 100% rename from Text-Grab/Utilities/Hdr/DisplayHdrInfo.cs rename to Text-Grab.Core.Windows/Utilities/Hdr/DisplayHdrInfo.cs diff --git a/Text-Grab/Utilities/Hdr/HdrScreenCapture.cs b/Text-Grab.Core.Windows/Utilities/Hdr/HdrScreenCapture.cs similarity index 97% rename from Text-Grab/Utilities/Hdr/HdrScreenCapture.cs rename to Text-Grab.Core.Windows/Utilities/Hdr/HdrScreenCapture.cs index bb5c14f6..96588196 100644 --- a/Text-Grab/Utilities/Hdr/HdrScreenCapture.cs +++ b/Text-Grab.Core.Windows/Utilities/Hdr/HdrScreenCapture.cs @@ -4,6 +4,7 @@ using System.Drawing.Imaging; using System.Linq; using System.Runtime.InteropServices; +using Text_Grab.Services; using System.Threading; using Vortice.Direct3D; using Vortice.Direct3D11; @@ -463,17 +464,17 @@ private static void EnsureBorderlessRequestedOnce() // Only silently re-activate for users who already granted access in a past session, so a // consent prompt never appears unexpectedly during a grab. First-time consent is explicit, // via the "Check permissions" button in settings. - if (!AppUtilities.TextGrabSettings.HdrBorderlessGranted) + if (!SettingsAccess.Current.HdrBorderlessGranted) return; if (System.Threading.Interlocked.Exchange(ref _borderlessRequestStarted, 1) != 0) return; - System.Windows.Threading.Dispatcher? dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher is null) - return; - - _ = dispatcher.InvokeAsync(async () => await RequestBorderlessAccessAsync()); + // Was Application.Current?.Dispatcher.InvokeAsync before this file moved out of the app. + // TryPost returning false is the old "dispatcher is null" branch: nothing to post to, so + // nothing happens. The flag above is still set either way, exactly as before - a process + // with no UI thread does not retry the request on every capture. + _ = UiThreadAccess.TryPost(static () => _ = RequestBorderlessAccessAsync()); } #region WinRT / D3D interop diff --git a/Text-Grab.Core.Windows/Utilities/HistoryFileUtilities.cs b/Text-Grab.Core.Windows/Utilities/HistoryFileUtilities.cs new file mode 100644 index 00000000..c0be1a45 --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/HistoryFileUtilities.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Text_Grab.Models; +using Text_Grab.Services; + +namespace Text_Grab.Utilities; + +/// +/// The on-disk half of the grab history: reading and writing the two history JSON files, the +/// word-border sidecar files beside them, the normalization passes that keep older files +/// loadable, and the retention rules that decide what gets dropped. +/// +/// The headless half of what used to be HistoryService (batch 6e of the Core split). Everything +/// here is static and owns no state past the serializer options - the in-memory history lists, +/// the DispatcherTimer-driven write debounce and cache-release cycle, the cached fullscreen +/// bitmap, the recent-grabs MenuItem building and the GrabFrame / EditTextWindow construction all +/// stayed behind in Text_Grab.Services.HistoryService, which still owns the state these +/// functions are handed. +/// +/// Retention lives here rather than with the service because the caps and the selection rule are +/// one idea: picks what to drop and +/// and its siblings cap what gets written. +/// +public static class HistoryFileUtilities +{ + #region Fields + + /// How many text-only history entries survive a write. + internal const int MaxHistoryTextOnly = 100; + + /// How many image-backed (non-PDF) history entries survive a write. + internal const int MaxHistoryWithImages = 10; + + /// How many PDF-sourced history entries survive a write. + internal const int MaxHistoryPdfDocuments = 10; + + private const string WordBorderInfoFileSuffix = ".wordborders.json"; + + private static readonly AsyncLocal HistoryLanguageKindFallbackUsed = new(); + + private static readonly JsonSerializerOptions HistoryJsonOptions = new() + { + AllowTrailingCommas = true, + WriteIndented = true, + Converters = + { + new HistoryLanguageKindJsonConverter(), + new JsonStringEnumConverter() + } + }; + + #endregion Fields + + #region Loading and writing + + internal static async Task<(List HistoryItems, bool NeedsRewrite)> LoadHistoryAsync(string fileName) + { + string rawText = await FileUtilities.GetTextFileAsync($"{fileName}.json", FileStorageKind.WithHistory); + + if (string.IsNullOrWhiteSpace(rawText)) + return ([], false); + + try + { + HistoryLanguageKindFallbackUsed.Value = false; + List? tempHistory = JsonSerializer.Deserialize>(rawText, HistoryJsonOptions); + + if (tempHistory is List jsonList && jsonList.Count > 0) + return (tempHistory, HistoryLanguageKindFallbackUsed.Value); + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to deserialize history file '{fileName}.json' as a list. Attempting item-by-item recovery. {ex}"); + return LoadHistoryWithRecovery(rawText, fileName); + } + finally + { + HistoryLanguageKindFallbackUsed.Value = false; + } + + return ([], false); + } + + internal static (List HistoryItems, bool NeedsRewrite) LoadHistoryBlocking(string fileName) + { + return Task.Run(() => LoadHistoryAsync(fileName)).GetAwaiter().GetResult(); + } + + private static (List HistoryItems, bool NeedsRewrite) LoadHistoryWithRecovery(string rawText, string fileName) + { + try + { + using JsonDocument document = JsonDocument.Parse(rawText); + + if (document.RootElement.ValueKind != JsonValueKind.Array) + return ([], true); + + List recoveredHistory = []; + bool needsRewrite = true; + int index = 0; + + foreach (JsonElement element in document.RootElement.EnumerateArray()) + { + try + { + HistoryLanguageKindFallbackUsed.Value = false; + HistoryInfo? historyItem = element.Deserialize(HistoryJsonOptions); + if (historyItem is not null) + { + recoveredHistory.Add(historyItem); + if (HistoryLanguageKindFallbackUsed.Value) + needsRewrite = true; + } + } + catch (JsonException ex) + { + Debug.WriteLine($"Skipped invalid history item at index {index} from '{fileName}.json'. {ex}"); + } + finally + { + HistoryLanguageKindFallbackUsed.Value = false; + } + + index++; + } + + return (recoveredHistory, needsRewrite); + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to parse history file '{fileName}.json' during recovery. {ex}"); + return ([], true); + } + } + + internal static void WriteHistoryFiles(List history, string fileName, int maxNumberToSave) + { + string historyAsJson = JsonSerializer + .Serialize(history + .OrderBy(x => x.CaptureDateTime) + .TakeLast(maxNumberToSave), + HistoryJsonOptions); + + try + { + SaveHistoryTextFileBlocking(historyAsJson, $"{fileName}.json"); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to save history json file. {ex.Message}"); + } + } + + #endregion Loading and writing + + #region Normalization + + internal static bool NormalizeHistoryIds(List historyItems) + { + HashSet seenIds = []; + bool updatedAnyIds = false; + + foreach (HistoryInfo historyItem in historyItems) + { + if (!string.IsNullOrWhiteSpace(historyItem.ID) && seenIds.Add(historyItem.ID)) + continue; + + string nextId; + do + { + nextId = Guid.NewGuid().ToString(); + } + while (!seenIds.Add(nextId)); + + historyItem.ID = nextId; + updatedAnyIds = true; + } + + return updatedAnyIds; + } + + internal static bool NormalizeHistoryCompatibilityData(IEnumerable historyItems) + { + bool normalizedAnyHistoryItems = false; + + foreach (HistoryInfo historyItem in historyItems) + { + if (NormalizeHistoryCompatibilityData(historyItem)) + normalizedAnyHistoryItems = true; + } + + return normalizedAnyHistoryItems; + } + + internal static bool NormalizeHistoryCompatibilityData(HistoryInfo historyItem) + { + (string normalizedLanguageTag, LanguageKind normalizedLanguageKind, bool usedUiAutomation) = + LanguageUtilities.NormalizePersistedLanguageIdentity( + historyItem.LanguageKind, + historyItem.LanguageTag, + historyItem.UsedUiAutomation); + + if (string.Equals(historyItem.LanguageTag, normalizedLanguageTag, StringComparison.Ordinal) + && historyItem.LanguageKind == normalizedLanguageKind + && historyItem.UsedUiAutomation == usedUiAutomation) + { + return false; + } + + historyItem.LanguageTag = normalizedLanguageTag; + historyItem.LanguageKind = normalizedLanguageKind; + historyItem.UsedUiAutomation = usedUiAutomation; + return true; + } + + #endregion Normalization + + #region Word border sidecar files + + internal static bool EnsureWordBorderSidecarFiles(IEnumerable historyItems) + { + bool migratedAnyWordBorderData = false; + + foreach (HistoryInfo historyItem in historyItems) + { + if (PersistWordBorderData(historyItem)) + migratedAnyWordBorderData = true; + } + + return migratedAnyWordBorderData; + } + + internal static void PersistWordBorderData(IEnumerable historyItems) + { + foreach (HistoryInfo historyItem in historyItems) + PersistWordBorderData(historyItem); + } + + internal static bool PersistWordBorderData(HistoryInfo historyItem) + { + if (string.IsNullOrWhiteSpace(historyItem.WordBorderInfoJson)) + return false; + + if (string.IsNullOrWhiteSpace(historyItem.ID)) + historyItem.ID = Guid.NewGuid().ToString(); + + string wordBorderInfoFileName = GetWordBorderInfoFileName(historyItem.ID); + bool couldSaveWordBorderInfo = SaveHistoryTextFileBlocking(historyItem.WordBorderInfoJson, wordBorderInfoFileName); + + if (!couldSaveWordBorderInfo) + { + historyItem.WordBorderInfoFileName = null; + return false; + } + + historyItem.WordBorderInfoFileName = wordBorderInfoFileName; + + // When file-backed settings are enabled, the sidecar file is the authority + // for word border data, so drop the inline JSON to reduce memory/disk usage. + if (SettingsAccess.Current.EnableFileBackedManagedSettings) + historyItem.ClearTransientWordBorderData(); + + return true; + } + + internal static async Task> GetWordBorderInfosAsync(HistoryInfo history) + { + if (!string.IsNullOrWhiteSpace(history.WordBorderInfoFileName)) + { + // Sanitize the persisted file name to prevent path traversal outside the history directory + string sanitizedFileName = Path.GetFileName(history.WordBorderInfoFileName); + + if (!string.IsNullOrWhiteSpace(sanitizedFileName) + && string.Equals(Path.GetExtension(sanitizedFileName), ".json", StringComparison.OrdinalIgnoreCase)) + { + try + { + string historyBasePath = await FileUtilities.GetPathToHistory(); + string wordBorderInfoPath = Path.Combine(historyBasePath, sanitizedFileName); + + if (File.Exists(wordBorderInfoPath)) + { + await using FileStream wordBorderInfoStream = File.OpenRead(wordBorderInfoPath); + List? wordBorderInfos = + await JsonSerializer.DeserializeAsync>(wordBorderInfoStream, HistoryJsonOptions); + + if (wordBorderInfos is not null) + return wordBorderInfos; + } + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read word border info file for history item '{history.ID}': {ex}"); + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to deserialize word border info file for history item '{history.ID}': {ex}"); + } + } + } + + if (string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) + return []; + + try + { + List? inlineWordBorderInfos = + JsonSerializer.Deserialize>(history.WordBorderInfoJson, HistoryJsonOptions); + + return inlineWordBorderInfos ?? []; + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to deserialize inline word border info for history item '{history.ID}': {ex}"); + return []; + } + } + + #endregion Word border sidecar files + + #region Retention + + internal static HistoryInfo? GetMostRecentGrab(IEnumerable historyItems) + { + return historyItems + .Where(history => !history.IsPdfDocument) + .MaxBy(history => history.CaptureDateTime); + } + + internal static List GetExcessVisualHistoryItems(IEnumerable historyItems) + { + return + [ + .. historyItems + .Where(history => !history.IsPdfDocument) + .OrderBy(history => history.CaptureDateTime) + .SkipLast(MaxHistoryWithImages), + .. historyItems + .Where(history => history.IsPdfDocument) + .OrderBy(history => history.CaptureDateTime) + .SkipLast(MaxHistoryPdfDocuments), + ]; + } + + internal static void ClearTransientHistoryPayloads(IEnumerable historyItems) + { + foreach (HistoryInfo historyItem in historyItems) + { + historyItem.ClearTransientImage(); + historyItem.ClearTransientWordBorderData(); + } + } + + #endregion Retention + + #region Deleting artifacts + + internal static void DeleteHistoryArtifacts(HistoryInfo historyItem) + { + DeleteHistoryFile(historyItem.ImagePath); + DeleteHistoryFile(historyItem.WordBorderInfoFileName); + } + + internal static void DeleteUnusedWordBorderFiles(IEnumerable historyItems) + { + string historyBasePath = GetHistoryPathBlocking(); + + if (!Directory.Exists(historyBasePath)) + return; + + HashSet expectedFileNames = [.. historyItems + .Select(historyItem => historyItem.WordBorderInfoFileName) + .Where(fileName => !string.IsNullOrWhiteSpace(fileName)) + .Select(fileName => Path.GetFileName(fileName!))]; + + string[] wordBorderInfoFiles = Directory.GetFiles(historyBasePath, $"*{WordBorderInfoFileSuffix}"); + + foreach (string wordBorderInfoFile in wordBorderInfoFiles) + { + string fileName = Path.GetFileName(wordBorderInfoFile); + + if (!expectedFileNames.Contains(fileName)) + { + try + { + File.Delete(wordBorderInfoFile); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to delete word border info file '{wordBorderInfoFile}': {ex}"); + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Access denied when deleting word border info file '{wordBorderInfoFile}': {ex}"); + } + } + } + } + + private static void DeleteHistoryFile(string? historyFileName) + { + if (string.IsNullOrWhiteSpace(historyFileName)) + return; + + string historyBasePath = GetHistoryPathBlocking(); + string filePath = Path.Combine(historyBasePath, Path.GetFileName(historyFileName)); + + if (!File.Exists(filePath)) + return; + + try + { + File.Delete(filePath); + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to delete history file '{filePath}': {ex}"); + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Access denied when deleting history file '{filePath}': {ex}"); + } + } + + #endregion Deleting artifacts + + #region Path and file helpers + + private static string GetHistoryPathBlocking() + { + return Task.Run(async () => await FileUtilities.GetPathToHistory()).GetAwaiter().GetResult(); + } + + private static string GetWordBorderInfoFileName(string historyId) + { + return $"{historyId}{WordBorderInfoFileSuffix}"; + } + + private static bool SaveHistoryTextFileBlocking(string textContent, string fileName) + { + return Task.Run(async () => await FileUtilities.SaveTextFile(textContent, fileName, FileStorageKind.WithHistory)) + .GetAwaiter() + .GetResult(); + } + + #endregion Path and file helpers + + #region Json converter + + private sealed class HistoryLanguageKindJsonConverter : JsonConverter + { + public override LanguageKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + string? value = reader.GetString(); + + if (!string.IsNullOrWhiteSpace(value) + && Enum.TryParse(value, true, out LanguageKind parsedValue) + && Enum.IsDefined(typeof(LanguageKind), parsedValue)) + { + return parsedValue; + } + + HistoryLanguageKindFallbackUsed.Value = true; + Debug.WriteLine($"Unknown history LanguageKind '{value}'. Falling back to {LanguageKind.Global}."); + return LanguageKind.Global; + } + + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int numericValue)) + { + if (Enum.IsDefined(typeof(LanguageKind), numericValue)) + return (LanguageKind)numericValue; + + HistoryLanguageKindFallbackUsed.Value = true; + Debug.WriteLine($"Unknown history LanguageKind numeric value '{numericValue}'. Falling back to {LanguageKind.Global}."); + return LanguageKind.Global; + } + + if (reader.TokenType == JsonTokenType.Null) + { + HistoryLanguageKindFallbackUsed.Value = true; + return LanguageKind.Global; + } + + HistoryLanguageKindFallbackUsed.Value = true; + Debug.WriteLine($"Unexpected token '{reader.TokenType}' for history LanguageKind. Falling back to {LanguageKind.Global}."); + return LanguageKind.Global; + } + + public override void Write(Utf8JsonWriter writer, LanguageKind value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + } + + #endregion Json converter +} diff --git a/Text-Grab/Utilities/ImageChangeDetector.cs b/Text-Grab.Core.Windows/Utilities/ImageChangeDetector.cs similarity index 100% rename from Text-Grab/Utilities/ImageChangeDetector.cs rename to Text-Grab.Core.Windows/Utilities/ImageChangeDetector.cs diff --git a/Text-Grab/Utilities/LanguageUtilities.cs b/Text-Grab.Core.Windows/Utilities/LanguageUtilities.cs similarity index 100% rename from Text-Grab/Utilities/LanguageUtilities.cs rename to Text-Grab.Core.Windows/Utilities/LanguageUtilities.cs diff --git a/Text-Grab/Utilities/LimitedAccessFeatureUtilities.cs b/Text-Grab.Core.Windows/Utilities/LimitedAccessFeatureUtilities.cs similarity index 99% rename from Text-Grab/Utilities/LimitedAccessFeatureUtilities.cs rename to Text-Grab.Core.Windows/Utilities/LimitedAccessFeatureUtilities.cs index a1336980..fe74e25d 100644 --- a/Text-Grab/Utilities/LimitedAccessFeatureUtilities.cs +++ b/Text-Grab.Core.Windows/Utilities/LimitedAccessFeatureUtilities.cs @@ -17,7 +17,7 @@ namespace Text_Grab.Utilities; /// Tokens are requested from Microsoft at https://aka.ms/laffeatures and must not be committed to /// source control, so the token and publisher ID are read at runtime from (in order): /// 1. AssemblyMetadata baked in at build time — set the MSBuild properties -/// LafToken and LafPublisherId (see Text-Grab.csproj). +/// LafToken and LafPublisherId (see Text-Grab.Core.Windows.csproj). /// 2. The LAF_TOKEN and LAF_PUBLISHER_ID environment variables, for local development. /// /// This mirrors how microsoft/ai-dev-gallery handles the same feature. diff --git a/Text-Grab.Core.Windows/Utilities/OcrUtilities.cs b/Text-Grab.Core.Windows/Utilities/OcrUtilities.cs new file mode 100644 index 00000000..4a54945d --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/OcrUtilities.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Text_Grab.Services; + +namespace Text_Grab.Utilities; + +/// +/// Turning an OCR result into text: word- and line-level assembly, the furigana and reading-flow +/// heuristics, and the paragraph-wrap grouping. +/// +/// The portable half of what used to be Text-Grab/Utilities/OcrUtilities.cs (batch 4c of the Core +/// split). It keeps the original type name because that is what nearly every call site wants - +/// Tests/OcrTests.cs alone accounts for 43 of the old file's ~80 references, all against these +/// members. The other half - screen and window capture, engine dispatch, file and BitmapSource +/// sources - stays in the app as OcrSourceUtilities: it needs WPF, and also WindowsAiUtilities +/// and LanguageUtilities, neither of which has moved yet. +/// +public static partial class OcrUtilities +{ + // Cache the SpaceJoiningWordRegex to avoid creating it on every method call + private static readonly Regex _cachedSpaceJoiningWordRegex = SpaceJoiningWordRegex(); + + public static List ParseOcrResultIntoWordBorderInfos( + IOcrLinesWords ocrResult, + bool shouldCorrectToLatin = true) + { + List infos = []; + + foreach (IOcrLine ocrLine in ocrResult.Lines) + { + double top = ocrLine.Words.Select(x => x.BoundingBox.Top).Min(); + double bottom = ocrLine.Words.Select(x => x.BoundingBox.Bottom).Max(); + double left = ocrLine.Words.Select(x => x.BoundingBox.Left).Min(); + double right = ocrLine.Words.Select(x => x.BoundingBox.Right).Max(); + + RectangleF lineRect = new( + (float)left, + (float)top, + (float)Math.Abs(right - left), + (float)Math.Abs(bottom - top)); + + StringBuilder lineText = new(); + ocrLine.GetTextFromOcrLine(true, lineText, shouldCorrectToLatin); + + WordBorderInfo info = new() + { + BorderRect = lineRect, + Word = lineText.ToString().Trim(), + ResultRowID = 0, + ResultColumnID = 0 + }; + + infos.Add(info); + } + + return infos; + } + + public static void GetTextFromOcrLine( + this IOcrLine ocrLine, + bool isSpaceJoiningOCRLang, + StringBuilder text, + bool shouldCorrectToLatin = true) + { + // (when OCR language is zh or ja) + // matches words in a space-joining language, which contains: + // - one letter that is not in "other letters" (CJK characters are "other letters") + // - one number digit + // - any words longer than one character + // Chinese and Japanese characters are single-character words + // when a word is one punctuation/symbol, join it without spaces + + if (isSpaceJoiningOCRLang) + { + text.AppendLine(ocrLine.Text); + + if (SettingsAccess.Current.CorrectErrors) + text.TryFixEveryWordLetterNumberErrors(); + } + else + { + // For CJK languages, filter out likely furigana (small ruby-text + // characters above the main text) before merging the words. This is + // opt-in via the RemoveFurigana setting. + IEnumerable words = SettingsAccess.Current.RemoveFurigana + ? FilterFurigana([.. ocrLine.Words]) + : ocrLine.Words; + + bool isFirstWord = true; + bool isPrevWordSpaceJoining = false; + + foreach (IOcrWord ocrWord in words) + { + string wordString = ocrWord.Text; + + bool isThisWordSpaceJoining = _cachedSpaceJoiningWordRegex.IsMatch(wordString); + + if (SettingsAccess.Current.CorrectErrors) + wordString = wordString.TryFixNumberLetterErrors(); + + if (isFirstWord || (!isThisWordSpaceJoining && !isPrevWordSpaceJoining)) + _ = text.Append(wordString); + else + _ = text.Append(' ').Append(wordString); + + isFirstWord = false; + isPrevWordSpaceJoining = isThisWordSpaceJoining; + } + } + + if (SettingsAccess.Current.CorrectToLatin && shouldCorrectToLatin) + text.ReplaceGreekOrCyrillicWithLatin(); + } + + /// + /// Removes words that are likely furigana: small ruby-text characters + /// rendered above the main text in Japanese. A word is treated as furigana + /// when it is noticeably shorter than the line's median word height and sits + /// directly above a larger word that overlaps it horizontally. + /// + internal static List FilterFurigana(List words) + { + if (words.Count == 0) + return words; + + // Furigana is typically around half the height of the main text. + List heights = [.. words.Select(w => w.BoundingBox.Height).OrderBy(h => h)]; + double medianHeight = heights[heights.Count / 2]; + double furiganaThreshold = medianHeight * 0.6; + + List filteredWords = []; + + for (int i = 0; i < words.Count; i++) + { + IOcrWord word = words[i]; + bool isProbablyFurigana = false; + + if (word.BoundingBox.Height < furiganaThreshold) + { + // Only treat it as furigana when a larger word sits below it and + // overlaps horizontally (i.e. the kanji it annotates). + for (int j = 0; j < words.Count; j++) + { + if (i == j) + continue; + + IOcrWord otherWord = words[j]; + + bool isBelow = otherWord.BoundingBox.Top > word.BoundingBox.Bottom; + bool overlapsHorizontally = !(otherWord.BoundingBox.Right < word.BoundingBox.Left + || otherWord.BoundingBox.Left > word.BoundingBox.Right); + bool isLarger = otherWord.BoundingBox.Height > furiganaThreshold; + + if (isBelow && overlapsHorizontally && isLarger) + { + isProbablyFurigana = word.Text.Length <= 2; + break; + } + } + } + + if (!isProbablyFurigana) + filteredWords.Add(word); + } + + // If everything was filtered, fall back to the original words to avoid + // dropping the whole line. + return filteredWords.Count > 0 ? filteredWords : words; + } + + internal readonly record struct PositionedOcrLine(int LineNumber, string Text, Windows.Foundation.Rect BoundingBox); + + internal sealed class GroupedOcrLines(IReadOnlyList lines, Windows.Foundation.Rect boundingBox) + { + public Windows.Foundation.Rect BoundingBox { get; } = boundingBox; + + public IReadOnlyList Lines { get; } = lines; + + public int StartingLineNumber => Lines.Count == 0 ? 0 : Lines[0].LineNumber; + + public string DisplayText => string.Join(Environment.NewLine, Lines.Select(static line => line.Text.MakeStringSingleLine())); + + public string SingleLineText => string.Join(" ", Lines.Select(static line => line.Text.MakeStringSingleLine()).Where(static text => !string.IsNullOrWhiteSpace(text))); + } + + internal static string BuildTextFromOcrLines(ILanguage language, IOcrLinesWords ocrResult) + { + StringBuilder text = new(); + + bool isSpaceJoiningOCRLang = language.IsSpaceJoining(); + IOcrLine[] lines = ocrResult.Lines; + + if (ShouldUseParagraphDetection(isSpaceJoiningOCRLang) && lines.Length > 0) + { + List groupedLines = + [ + .. GroupWrappedParagraphLines( + [.. lines.Select((line, index) => new PositionedOcrLine(index, line.Text, line.BoundingBox))]) + ]; + + for (int i = 0; i < groupedLines.Count; i++) + { + if (i > 0) + text.AppendLine(); + + text.Append(groupedLines[i].SingleLineText); + } + } + else + { + // Windows OCR returns CJK lines - especially furigana ruby lines and + // stray fragments - in an order that does not follow the page's reading + // flow, so re-sort by geometry (top-to-bottom, then left-to-right) + // before joining. Space-joining languages keep the engine order because + // paragraph detection above already handles their layout. + IReadOnlyList orderedLines = isSpaceJoiningOCRLang + ? lines + : OrderLinesForReadingFlow(lines); + + // Windows OCR emits furigana (Japanese ruby readings) as their own + // short lines sitting directly above the kanji they annotate, so the + // word-level filter above never catches them. Drop those whole lines + // when furigana removal is enabled. + if (!isSpaceJoiningOCRLang && SettingsAccess.Current.RemoveFurigana) + orderedLines = FilterFuriganaLines(orderedLines); + + foreach (IOcrLine ocrLine in orderedLines) + ocrLine.GetTextFromOcrLine(isSpaceJoiningOCRLang, text, language.IsLatinBased()); + } + + if (language.IsRightToLeft()) + text.ReverseWordsForRightToLeft(); + + return text.ToString(); + } + + /// + /// Re-orders OCR lines into natural reading flow: groups lines that share a + /// horizontal row (their vertical extents overlap), orders rows top-to-bottom, + /// and orders the lines within each row left-to-right. Windows OCR frequently + /// returns CJK lines out of order (furigana above kanji, trailing fragments), + /// which scrambles the concatenated text without this pass. + /// + internal static IReadOnlyList OrderLinesForReadingFlow(IReadOnlyList lines) + { + if (lines.Count <= 1) + return lines; + + // Stable sort by the top edge so rows are discovered top-to-bottom. + List byTop = [.. lines.OrderBy(line => line.BoundingBox.Top)]; + + List> rows = []; + double currentRowTop = 0; + double currentRowBottom = 0; + + foreach (IOcrLine line in byTop) + { + Windows.Foundation.Rect box = line.BoundingBox; + + if (rows.Count > 0) + { + double overlap = Math.Min(currentRowBottom, box.Bottom) - Math.Max(currentRowTop, box.Top); + double minHeight = Math.Min(currentRowBottom - currentRowTop, box.Height); + + // A line joins the current row when it overlaps the row's vertical + // band by more than half of the shorter of the two heights. + if (minHeight > 0 && overlap > minHeight * 0.5) + { + rows[^1].Add(line); + currentRowTop = Math.Min(currentRowTop, box.Top); + currentRowBottom = Math.Max(currentRowBottom, box.Bottom); + continue; + } + } + + rows.Add([line]); + currentRowTop = box.Top; + currentRowBottom = box.Bottom; + } + + List ordered = []; + foreach (List row in rows) + ordered.AddRange(row.OrderBy(line => line.BoundingBox.Left)); + + return ordered; + } + + /// + /// Removes whole OCR lines that are likely furigana: short ruby-reading lines + /// that sit directly above a substantially taller line overlapping them + /// horizontally (the kanji they annotate). Windows OCR returns furigana as + /// their own lines, so this complements the word-level . + /// The heuristic is intentionally conservative and geometry-only; it can miss + /// mis-detected readings and is offered as an opt-in, experimental setting. + /// + internal static IReadOnlyList FilterFuriganaLines(IReadOnlyList lines) + { + if (lines.Count < 2) + return lines; + + List kept = []; + + for (int i = 0; i < lines.Count; i++) + { + Windows.Foundation.Rect box = lines[i].BoundingBox; + bool isFurigana = false; + + for (int j = 0; j < lines.Count; j++) + { + if (i == j) + continue; + + Windows.Foundation.Rect other = lines[j].BoundingBox; + + bool isBelow = other.Top >= box.Bottom; + bool overlapsHorizontally = !(other.Right < box.Left || other.Left > box.Right); + // The annotated kanji is markedly taller than its reading. + bool isSubstantiallyTaller = other.Height > box.Height * 1.4; + // Ruby text hugs the top of its character; a large vertical gap + // means these are separate lines of body text, not a reading. + bool isCloseAbove = other.Top - box.Bottom < box.Height; + + if (isBelow && overlapsHorizontally && isSubstantiallyTaller && isCloseAbove) + { + isFurigana = true; + break; + } + } + + if (!isFurigana) + kept.Add(lines[i]); + } + + // Never drop everything - fall back to the input if the heuristic would + // erase the whole result. + return kept.Count > 0 ? kept : lines; + } + + internal static bool ShouldUseParagraphDetection(bool isSpaceJoiningLanguage, bool isTableMode = false) + { + return SettingsAccess.Current.ParagraphDetection && isSpaceJoiningLanguage && !isTableMode; + } + + internal static List GroupWrappedParagraphLines(IReadOnlyList lines) + { + List groupedLines = []; + + if (lines.Count == 0) + return groupedLines; + + List currentGroup = [lines[0]]; + Windows.Foundation.Rect currentBounds = lines[0].BoundingBox; + + for (int i = 1; i < lines.Count; i++) + { + PositionedOcrLine previousLine = currentGroup[^1]; + PositionedOcrLine currentLine = lines[i]; + + if (IsWrappedParagraph( + previousLine.BoundingBox.Y, + previousLine.BoundingBox.Height, + currentLine.BoundingBox.Y, + currentLine.BoundingBox.Height)) + { + currentGroup.Add(currentLine); + currentBounds = UnionRectangles(currentBounds, currentLine.BoundingBox); + continue; + } + + groupedLines.Add(new GroupedOcrLines([.. currentGroup], currentBounds)); + currentGroup = [currentLine]; + currentBounds = currentLine.BoundingBox; + } + + groupedLines.Add(new GroupedOcrLines([.. currentGroup], currentBounds)); + return groupedLines; + } + + private static Windows.Foundation.Rect UnionRectangles(Windows.Foundation.Rect current, Windows.Foundation.Rect next) + { + if (current.IsEmpty) + return next; + + if (next.IsEmpty) + return current; + + double left = Math.Min(current.X, next.X); + double top = Math.Min(current.Y, next.Y); + double right = Math.Max(current.X + current.Width, next.X + next.Width); + double bottom = Math.Max(current.Y + current.Height, next.Y + next.Height); + return new Windows.Foundation.Rect(left, top, right - left, bottom - top); + } + + /// + /// Determines whether two consecutive lines belong to the same wrapped paragraph + /// by comparing the vertical gap between them relative to the average line height. + /// Returns true if the lines should be joined with a space (same paragraph, wrapped), + /// false if they should be separated by a newline (different paragraphs). + /// + internal static bool IsWrappedLine(IOcrLine currentLine, IOcrLine nextLine) + { + if (currentLine.BoundingBox.IsEmpty || nextLine.BoundingBox.IsEmpty) + return false; + + return IsWrappedParagraph( + currentLine.BoundingBox.Y, + currentLine.BoundingBox.Height, + nextLine.BoundingBox.Y, + nextLine.BoundingBox.Height); + } + + /// + /// Core paragraph-wrap heuristic: returns true when the vertical gap between two + /// lines is small enough (less than 60 % of the average line height) that they + /// belong to the same wrapped paragraph, and their heights are similar (ratio ≤ 1.5). + /// Works for any coordinate space — ratios are scale-invariant. + /// + internal static bool IsWrappedParagraph( + double currentTop, double currentHeight, + double nextTop, double nextHeight) + { + if (currentHeight <= 0 || nextHeight <= 0) + return false; + + // Lines with significantly different heights are likely different content blocks + double minHeight = Math.Min(currentHeight, nextHeight); + double maxHeight = Math.Max(currentHeight, nextHeight); + if (maxHeight / minHeight > 1.5) + return false; + + // Consecutive OCR entries must advance to a distinct visual row. Without + // this guard, duplicate or horizontally split entries on the same row have + // a negative gap and are incorrectly merged into a one-line-tall paragraph. + if (nextTop - currentTop < minHeight * 0.5) + return false; + + // If the vertical gap between line bounding boxes is less than 0.6× the average line + // height, the lines are part of the same paragraph (normal line spacing); otherwise + // the extra whitespace signals a paragraph break. + double gap = nextTop - (currentTop + currentHeight); + double avgLineHeight = (currentHeight + nextHeight) / 2.0; + return gap < avgLineHeight * 0.6; + } + + public static string GetStringFromOcrOutputs(List outputs) + { + StringBuilder text = new(); + + foreach (OcrOutput output in outputs) + { + output.CleanOutput(); + + if (!string.IsNullOrWhiteSpace(output.CleanedOutput)) + text.Append(output.CleanedOutput); + else if (!string.IsNullOrWhiteSpace(output.RawOutput)) + text.Append(output.RawOutput); + } + + return text.ToString(); + } + + [GeneratedRegex(@"(^[\p{L}-[\p{Lo}]]|\p{Nd}$)|.{2,}")] + private static partial Regex SpaceJoiningWordRegex(); +} diff --git a/Text-Grab.Core.Windows/Utilities/PackageIdentity.cs b/Text-Grab.Core.Windows/Utilities/PackageIdentity.cs new file mode 100644 index 00000000..938b281c --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/PackageIdentity.cs @@ -0,0 +1,44 @@ +using Windows.ApplicationModel; + +namespace Text_Grab.Utilities; + +/// +/// Packaging identity checks that need only . Split out of +/// Text-Grab/Utilities/AppUtilities.cs so this piece can live in Core.Windows while +/// TextGrabSettings/TextGrabSettingsService stay in the app; AppUtilities.IsPackaged() +/// and GetAppVersion() forward here. +/// +public static class PackageIdentity +{ + public static bool IsPackaged() + { + try + { + // If we have a package ID then we are running in a packaged context + PackageId dummy = Package.Current.Id; + return true; + } + catch + { + return false; + } + } + + public static string GetAppVersion() + { + if (IsPackaged()) + { + PackageVersion version = Package.Current.Id.Version; + return $"{version.Major}.{version.Minor}.{version.Build}" ?? "unknown error reading package version"; + } + + // Deliberately the ENTRY assembly, not the executing one. This method used to live in + // Text-Grab/Utilities/AppUtilities.cs, where "executing" meant Text-Grab.exe and its + // property. From here, "executing" would mean Text-Grab.Core.Windows, which + // carries no version and would report 1.0.0.0 to the settings page and diagnostics. + System.Reflection.Assembly versionSource = + System.Reflection.Assembly.GetEntryAssembly() ?? System.Reflection.Assembly.GetExecutingAssembly(); + + return versionSource.GetName().Version?.ToString() ?? "unknown error reading assembly version"; + } +} diff --git a/Text-Grab/Utilities/RegistryMonitor.cs b/Text-Grab.Core.Windows/Utilities/RegistryMonitor.cs similarity index 100% rename from Text-Grab/Utilities/RegistryMonitor.cs rename to Text-Grab.Core.Windows/Utilities/RegistryMonitor.cs diff --git a/Text-Grab.Core.Windows/Utilities/TesseractHelper.cs b/Text-Grab.Core.Windows/Utilities/TesseractHelper.cs new file mode 100644 index 00000000..f54a131e --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/TesseractHelper.cs @@ -0,0 +1,230 @@ +using CliWrap; +using CliWrap.Buffered; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using Text_Grab.Services; + +namespace Text_Grab.Utilities; + +// Install Tesseract for Windows from UB-Mannheim +// https://github.com/UB-Mannheim/tesseract/wiki + +// Docs about command line usage +// https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html + +// This was developed using Tesseract v5 in 2022 + +public static class TesseractHelper +{ + private const string rawPath = @"%LOCALAPPDATA%\Tesseract-OCR\tesseract.exe"; + private const string rawProgramsPath = @"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"; + private const string basicPath = @"C:\Program Files\Tesseract-OCR\tesseract.exe"; + + public static bool CanLocateTesseractExe() + { + string tesseractPath = string.Empty; + try + { + tesseractPath = GetTesseractPath(); + } + catch (Exception) + { + tesseractPath = string.Empty; +#if DEBUG + throw; +#endif + } + return !string.IsNullOrEmpty(tesseractPath); + } + + private static string GetTesseractPath() + { + ITextGrabSettings defaultSettings = SettingsAccess.Current; + + if (!string.IsNullOrWhiteSpace(defaultSettings.TesseractPath) + && File.Exists(defaultSettings.TesseractPath)) + return defaultSettings.TesseractPath; + + string tesExePath = Environment.ExpandEnvironmentVariables(rawPath); + string programsPath = Environment.ExpandEnvironmentVariables(rawProgramsPath); + + if (File.Exists(tesExePath)) + { + defaultSettings.TesseractPath = tesExePath; + defaultSettings.Save(); + return tesExePath; + } + + if (File.Exists(programsPath)) + { + defaultSettings.TesseractPath = programsPath; + defaultSettings.Save(); + return programsPath; + } + + if (File.Exists(basicPath)) + { + defaultSettings.TesseractPath = basicPath; + defaultSettings.Save(); + return basicPath; + } + + return string.Empty; + } + + public static async Task GetTextFromImagePathAsync(string imagePath, string tessTag) + { + string tesseractPath = GetTesseractPath(); + + if (string.IsNullOrWhiteSpace(tesseractPath)) + return "Cannot find tesseract.exe"; + + // probably not needed, but if the Windows languages get passed it, it should still work + string languageString = tessTag; + + BufferedCommandResult result = await Cli.Wrap(tesseractPath) + .WithValidation(CommandResultValidation.None) + .WithArguments(args => args + .Add(imagePath) + .Add("-") + .Add("-l") + .Add(languageString) + ) + .ExecuteBufferedAsync(Encoding.UTF8); + + return result.StandardOutput; + } + + public static async Task GetOcrOutputFromBitmap(Bitmap bmp, TessLang language) + { + bmp.Save(TesseractHelper.TempImagePath(), ImageFormat.Png); + + OcrOutput ocrOutput = new() + { + Engine = OcrEngineKind.Tesseract, + Kind = OcrOutputKind.Paragraph, + Language = language, + SourceBitmap = bmp, + RawOutput = await TesseractHelper.GetTextFromImagePathAsync(TempImagePath(), language.RawTag) + }; + ocrOutput.CleanOutput(); + + return ocrOutput; + } + + public static async Task GetTextFromImagePath(string pathToFile, bool outputHocr) + { + string tesExePath = GetTesseractPath(); + + if (string.IsNullOrEmpty(tesExePath)) + return "Cannot find tesseract.exe"; + + string argumentsString = $"\"{pathToFile}\" - -l eng"; + + if (outputHocr) + argumentsString += " hocr"; + + ProcessStartInfo psi = new() + { + FileName = tesExePath, + Arguments = argumentsString, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardInput = true, + }; + + Process? process = Process.Start(psi); + + if (process is null) + return string.Empty; + + StreamReader sr = process.StandardOutput; + StreamReader errorReader = process.StandardError; + + process.WaitForExit(1000); + + if (process.HasExited) + { + string returningResult = await sr.ReadToEndAsync(); + + if (!string.IsNullOrWhiteSpace(returningResult)) + return returningResult; + + returningResult = await errorReader.ReadToEndAsync(); + + return returningResult; + } + else + return string.Empty; + } + + public static string TempImagePath() + { + if (AutomationProfile.Current is not null) + return Path.Combine(AutomationProfile.GetTemporaryDirectory(), "tempImage.png"); + + string? exePath = Path.GetDirectoryName(System.AppContext.BaseDirectory); + if (exePath is null) + { + string rawPath = @"%LOCALAPPDATA%\Text_Grab"; + exePath = Environment.ExpandEnvironmentVariables(rawPath); + } + + return $"{exePath}\\tempImage.png"; + } + + public static async Task> TesseractLanguagesAsStrings() + { + List languageStrings = new(); + + string tesseractPath = GetTesseractPath(); + + if (string.IsNullOrWhiteSpace(tesseractPath)) + { + languageStrings.Add("eng"); + return languageStrings; + } + + BufferedCommandResult result = await Cli.Wrap(tesseractPath) + .WithValidation(CommandResultValidation.None) + .WithArguments(args => args + .Add("--list-langs") + ).ExecuteBufferedAsync(); + + if (string.IsNullOrWhiteSpace(result.StandardOutput)) + { + languageStrings.Add("eng"); + return languageStrings; + } + + string[] tempList = result.StandardOutput.Split(Environment.NewLine); + + foreach (string item in tempList) + if (item.Length < 30 && !string.IsNullOrWhiteSpace(item) && item != "osd") + languageStrings.Add(item); + + return languageStrings; + } + + public static async Task> TesseractLanguages() + { + List languageStrings = await TesseractLanguagesAsStrings(); + List tesseractLanguages = new(); + + foreach (string language in languageStrings) + tesseractLanguages.Add(new TessLang(language)); + + return tesseractLanguages; + } +} diff --git a/Text-Grab/Utilities/WinAiLanguageModel.cs b/Text-Grab.Core.Windows/Utilities/WinAiLanguageModel.cs similarity index 99% rename from Text-Grab/Utilities/WinAiLanguageModel.cs rename to Text-Grab.Core.Windows/Utilities/WinAiLanguageModel.cs index fe7118f5..0d67b84f 100644 --- a/Text-Grab/Utilities/WinAiLanguageModel.cs +++ b/Text-Grab.Core.Windows/Utilities/WinAiLanguageModel.cs @@ -90,7 +90,7 @@ internal static class WinAiLanguageModel /// internal static (bool Available, string? Reason) CheckAvailability() { - if (!AppUtilities.IsPackaged()) + if (!PackageIdentity.IsPackaged()) return (false, "Windows AI is only available when Text-Grab runs as an installed (packaged) app."); if (OSInterop.IsWindows10()) diff --git a/Text-Grab/Utilities/WinAiMeetingNotes.cs b/Text-Grab.Core.Windows/Utilities/WinAiMeetingNotes.cs similarity index 100% rename from Text-Grab/Utilities/WinAiMeetingNotes.cs rename to Text-Grab.Core.Windows/Utilities/WinAiMeetingNotes.cs diff --git a/Text-Grab/Utilities/WinAiTranslator.cs b/Text-Grab.Core.Windows/Utilities/WinAiTranslator.cs similarity index 100% rename from Text-Grab/Utilities/WinAiTranslator.cs rename to Text-Grab.Core.Windows/Utilities/WinAiTranslator.cs diff --git a/Text-Grab/Utilities/WindowsAiUtilities.cs b/Text-Grab.Core.Windows/Utilities/WindowsAiUtilities.cs similarity index 98% rename from Text-Grab/Utilities/WindowsAiUtilities.cs rename to Text-Grab.Core.Windows/Utilities/WindowsAiUtilities.cs index 9ae51088..f56b0a77 100644 --- a/Text-Grab/Utilities/WindowsAiUtilities.cs +++ b/Text-Grab.Core.Windows/Utilities/WindowsAiUtilities.cs @@ -14,7 +14,7 @@ using System.Threading.Tasks; using Text_Grab.Extensions; using Text_Grab.Models; -using Text_Grab.Properties; +using Text_Grab.Services; using Windows.Graphics.Imaging; namespace Text_Grab.Utilities; @@ -53,12 +53,12 @@ private static bool CanDeviceUseWinAiFeature(Func getReadyS private static bool MeetsWindowsAiPrerequisites() { // Check if the app is packaged and if the AI feature is supported - if (!AppUtilities.IsPackaged() || OSInterop.IsWindows10()) + if (!PackageIdentity.IsPackaged() || OSInterop.IsWindows10()) return false; // Today, Windows AI features are only supported on ARM64 unless overridden for debugging. Architecture arch = RuntimeInformation.ProcessArchitecture; - if (arch != Architecture.Arm64 && !Settings.Default.OverrideAiArchCheck) + if (arch != Architecture.Arm64 && !SettingsAccess.Current.OverrideAiArchCheck) return false; return true; diff --git a/Text-Grab.Core/AssemblyInfo.cs b/Text-Grab.Core/AssemblyInfo.cs new file mode 100644 index 00000000..01353f25 --- /dev/null +++ b/Text-Grab.Core/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Text-Grab")] +[assembly: InternalsVisibleTo("Text-Grab.Core.Windows")] +[assembly: InternalsVisibleTo("Tests")] +[assembly: InternalsVisibleTo("Tests.Core")] +[assembly: InternalsVisibleTo("Tests.Core.Windows")] diff --git a/Text-Grab/Enums.cs b/Text-Grab.Core/Enums.cs similarity index 98% rename from Text-Grab/Enums.cs rename to Text-Grab.Core/Enums.cs index d88a3648..490b2c5e 100644 --- a/Text-Grab/Enums.cs +++ b/Text-Grab.Core/Enums.cs @@ -1,4 +1,18 @@ -namespace Text_Grab; +namespace Text_Grab; + +public enum CurrentCase +{ + Lower = 0, + Camel = 1, + Upper = 2, + Unknown = 3 +} + +public enum SpotInLine +{ + Beginning = 0, + End = 1, +} public enum AddRemove { @@ -19,14 +33,6 @@ public enum TrayIconStyle Monochrome = 1, } -public enum CurrentCase -{ - Lower = 0, - Camel = 1, - Upper = 2, - Unknown = 3 -} - public enum FileStorageKind { Absolute = 0, @@ -65,12 +71,6 @@ public enum Side Bottom = 4 } -public enum SpotInLine -{ - Beginning = 0, - End = 1, -} - public enum TextGrabMode { Fullscreen = 0, diff --git a/Text-Grab/Extensions/NumberExtensions.cs b/Text-Grab.Core/Extensions/NumberExtensions.cs similarity index 100% rename from Text-Grab/Extensions/NumberExtensions.cs rename to Text-Grab.Core/Extensions/NumberExtensions.cs diff --git a/Text-Grab/Extensions/StringBuilderExtensions.cs b/Text-Grab.Core/Extensions/StringBuilderExtensions.cs similarity index 100% rename from Text-Grab/Extensions/StringBuilderExtensions.cs rename to Text-Grab.Core/Extensions/StringBuilderExtensions.cs diff --git a/Text-Grab.Core/Interfaces/ITextGrabSettings.cs b/Text-Grab.Core/Interfaces/ITextGrabSettings.cs new file mode 100644 index 00000000..70346e1b --- /dev/null +++ b/Text-Grab.Core/Interfaces/ITextGrabSettings.cs @@ -0,0 +1,82 @@ +namespace Text_Grab.Interfaces; + +/// +/// The slice of Text-Grab's user settings that portable (Core / Core.Windows) code is allowed +/// to read. +/// +/// The app's real settings object is Text_Grab.Properties.Settings - an internal, sealed, +/// generated ApplicationSettingsBase with 104 properties, reachable only through +/// AppUtilities.TextGrabSettings. That accessor is the single largest thing tying logic to +/// the app assembly. This interface breaks that tie without moving the settings machinery: the +/// generated properties already match these names and types, so the hand-written partial in +/// Text-Grab/Properties/Settings.cs satisfies the whole interface just by declaring it. +/// +/// Keep this deliberately small. Add a property only when a file being moved actually reads it. +/// If a single move would need more than a handful of new members, that file probably wants the +/// facade split instead - move the pure logic to Core and leave a thin settings-reading wrapper +/// in the app, the way PatternItem / PatternItemCatalog was handled in e677b54. +/// +/// Resolved through . +/// +public interface ITextGrabSettings +{ + /// Apply the OCR error-correction pass to recognized text. + bool CorrectErrors { get; set; } + + /// Map look-alike Greek and Cyrillic characters to Latin. + bool CorrectToLatin { get; set; } + + /// Bypass the arm64 gate on the Windows AI feature checks. + bool OverrideAiArchCheck { get; set; } + + /// Join OCR lines into paragraphs instead of preserving line breaks. + bool ParagraphDetection { get; set; } + + /// Strip furigana ruby text from recognized Japanese. + bool RemoveFurigana { get; set; } + + /// Scan captured images for barcodes and QR codes. + bool TryToReadBarcodes { get; set; } + + /// Use the HDR-aware capture path for screen regions. + bool HdrCaptureCorrection { get; set; } + + /// Whether the user has already granted borderless screen-capture access. + bool HdrBorderlessGranted { get; set; } + + /// Offer UI Automation as a text source alongside the OCR engines. + bool UiAutomationEnabled { get; set; } + + /// Offer the Windows AI image-description pseudo-language. + bool WindowsAiDescriptionEnabled { get; set; } + + /// Fall back to OCR when UI Automation returns no text. + bool UiAutomationFallbackToOcr { get; set; } + + /// Route OCR through Tesseract instead of the Windows engines. + bool UseTesseract { get; set; } + + /// Cached path to the Tesseract executable; written back once discovered. + string TesseractPath { get; set; } + + /// BCP-47 tag of the language used for the last capture. + string LastUsedLang { get; set; } + + /// Trim spoken text to this many words; zero or negative disables the limit. + int TtsSpeakWordLimit { get; set; } + + /// Display name of the preferred text-to-speech voice; empty selects the default. + string TtsVoiceName { get; set; } + + /// Speaking rate passed to the TTS engine; only values in [0.5, 6.0] are applied. + double TtsSpeakingRate { get; set; } + + /// Which local Whisper model to use for on-device audio transcription. + string AudioTranscriptionModel { get; set; } + + /// Store managed settings and history word borders in files beside the app data. + bool EnableFileBackedManagedSettings { get; set; } + + /// Persist pending changes. Backed by ApplicationSettingsBase.Save(). + void Save(); +} diff --git a/Text-Grab/Interfaces/ITtsEngine.cs b/Text-Grab.Core/Interfaces/ITtsEngine.cs similarity index 100% rename from Text-Grab/Interfaces/ITtsEngine.cs rename to Text-Grab.Core/Interfaces/ITtsEngine.cs diff --git a/Text-Grab/Models/AsyncOcrFileResult.cs b/Text-Grab.Core/Models/AsyncOcrFileResult.cs similarity index 100% rename from Text-Grab/Models/AsyncOcrFileResult.cs rename to Text-Grab.Core/Models/AsyncOcrFileResult.cs diff --git a/Text-Grab/Models/BuiltInRecognizer.cs b/Text-Grab.Core/Models/BuiltInRecognizer.cs similarity index 100% rename from Text-Grab/Models/BuiltInRecognizer.cs rename to Text-Grab.Core/Models/BuiltInRecognizer.cs diff --git a/Text-Grab/Models/EditTextTableDocument.cs b/Text-Grab.Core/Models/EditTextTableDocument.cs similarity index 100% rename from Text-Grab/Models/EditTextTableDocument.cs rename to Text-Grab.Core/Models/EditTextTableDocument.cs diff --git a/Text-Grab/Models/ExtractedPattern.cs b/Text-Grab.Core/Models/ExtractedPattern.cs similarity index 100% rename from Text-Grab/Models/ExtractedPattern.cs rename to Text-Grab.Core/Models/ExtractedPattern.cs diff --git a/Text-Grab/Models/FindResult.cs b/Text-Grab.Core/Models/FindResult.cs similarity index 100% rename from Text-Grab/Models/FindResult.cs rename to Text-Grab.Core/Models/FindResult.cs diff --git a/Text-Grab/Models/GrabFrameTableEditState.cs b/Text-Grab.Core/Models/GrabFrameTableEditState.cs similarity index 100% rename from Text-Grab/Models/GrabFrameTableEditState.cs rename to Text-Grab.Core/Models/GrabFrameTableEditState.cs diff --git a/Text-Grab/Models/GrabFrameWordGroupingMode.cs b/Text-Grab.Core/Models/GrabFrameWordGroupingMode.cs similarity index 100% rename from Text-Grab/Models/GrabFrameWordGroupingMode.cs rename to Text-Grab.Core/Models/GrabFrameWordGroupingMode.cs diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab.Core/Models/GrabTemplate.cs similarity index 100% rename from Text-Grab/Models/GrabTemplate.cs rename to Text-Grab.Core/Models/GrabTemplate.cs diff --git a/Text-Grab/Models/NullAsyncResult.cs b/Text-Grab.Core/Models/NullAsyncResult.cs similarity index 100% rename from Text-Grab/Models/NullAsyncResult.cs rename to Text-Grab.Core/Models/NullAsyncResult.cs diff --git a/Text-Grab/Models/OcrDirectoryOptions.cs b/Text-Grab.Core/Models/OcrDirectoryOptions.cs similarity index 100% rename from Text-Grab/Models/OcrDirectoryOptions.cs rename to Text-Grab.Core/Models/OcrDirectoryOptions.cs diff --git a/Text-Grab/Models/PatternItem.cs b/Text-Grab.Core/Models/PatternItem.cs similarity index 68% rename from Text-Grab/Models/PatternItem.cs rename to Text-Grab.Core/Models/PatternItem.cs index 0b395929..1099601f 100644 --- a/Text-Grab/Models/PatternItem.cs +++ b/Text-Grab.Core/Models/PatternItem.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Text_Grab.Utilities; - namespace Text_Grab.Models; /// Whether a is backed by a user regex or a built-in recognizer. @@ -83,38 +78,4 @@ internal PatternItem(BuiltInRecognizer recognizer, bool isHidden = false) Recognizer = recognizer; IsHidden = isHidden; } - - /// - /// Returns the combined catalog: the user's saved regexes first (falling back to the - /// built-in defaults when none are saved), then the built-in recognizers. Recognizers the - /// user has hidden are excluded unless is true — the - /// Patterns Manager passes true so it can offer an "unhide" action. - /// - public static IReadOnlyList GetAll(bool includeHidden = false) - { - StoredRegex[] saved = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); - if (saved.Length == 0) - saved = StoredRegex.GetDefaultPatterns(); - - HashSet hiddenIds = [.. AppUtilities.TextGrabSettingsService.LoadHiddenSmartPatternIds()]; - - IEnumerable recognizers = BuiltInRecognizer.GetAll() - .Select(r => new PatternItem(r, isHidden: hiddenIds.Contains(r.Id))); - - if (!includeHidden) - recognizers = recognizers.Where(r => !r.IsHidden); - - return - [ - .. saved.Select(s => new PatternItem(s)), - .. recognizers, - ]; - } - - /// - /// Finds a pattern by display name (case-insensitive), preferring a saved regex over a - /// recognizer when both share a name. Null when no pattern matches. - /// - public static PatternItem? GetByName(string name) - => GetAll().FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } diff --git a/Text-Grab/Models/ResultTable.cs b/Text-Grab.Core/Models/ResultTable.cs similarity index 72% rename from Text-Grab/Models/ResultTable.cs rename to Text-Grab.Core/Models/ResultTable.cs index 60d48ff2..846c981c 100644 --- a/Text-Grab/Models/ResultTable.cs +++ b/Text-Grab.Core/Models/ResultTable.cs @@ -3,12 +3,6 @@ using System.Drawing; using System.Linq; using System.Text; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using Text_Grab.Utilities; -using Windows.Media.Ocr; -using Rect = System.Windows.Rect; namespace Text_Grab.Models; @@ -18,9 +12,7 @@ public class ResultTable public List Rows { get; set; } = []; - private OcrResult? OcrResult { get; set; } - - public Rect BoundingRect { get; set; } = new(); + public RectangleF BoundingRect { get; set; } = new(); public List ColumnLines { get; set; } = []; @@ -30,41 +22,11 @@ public class ResultTable public List RowLines { get; set; } = []; - public Canvas? TableLines { get; set; } = null; - public ResultTable() { } - // New: accept pure model objects - public ResultTable(ref List wordBorders, DpiScale dpiScale) - { - int borderBuffer = 3; - - Rectangle bordersBorder = new(); - if (wordBorders.Count > 0) - { - double leftsMin = wordBorders.Select(x => x.BorderRect.Left).Min(); - double topsMin = wordBorders.Select(x => x.BorderRect.Top).Min(); - double rightsMax = wordBorders.Select(x => x.BorderRect.Right).Max(); - double bottomsMax = wordBorders.Select(x => x.BorderRect.Bottom).Max(); - - bordersBorder = new() - { - X = (int)leftsMin - borderBuffer, - Y = (int)topsMin - borderBuffer, - Width = (int)(rightsMax + borderBuffer), - Height = (int)(bottomsMax + borderBuffer) - }; - } - - bordersBorder.Width = (int)(bordersBorder.Width * dpiScale.DpiScaleX); - bordersBorder.Height = (int)(bordersBorder.Height * dpiScale.DpiScaleY); - - AnalyzeAsTable(wordBorders, bordersBorder); - } - private void ParseRowAndColumnLines() { // Draw Bounding Rect @@ -113,81 +75,17 @@ private void ParseRowAndColumnLines() } } - private List ParseOcrResultWordsIntoRects() - { - List allBoundingRects = []; - - if (OcrResult is null) - return allBoundingRects; - - foreach (OcrLine ocrLine in OcrResult.Lines) - { - foreach (OcrWord ocrWord in ocrLine.Words) - { - Rect ocrWordRect = new( - ocrWord.BoundingRect.X, - ocrWord.BoundingRect.Y, - ocrWord.BoundingRect.Width, - ocrWord.BoundingRect.Height); - - allBoundingRects.Add(ocrWordRect); - } - } - - return allBoundingRects; - } - - public static List ParseOcrResultIntoWordBorderInfos( - IOcrLinesWords ocrResult, - DpiScale dpi, - bool shouldCorrectToLatin = true) - { - List infos = []; - - foreach (IOcrLine ocrLine in ocrResult.Lines) - { - double top = ocrLine.Words.Select(x => x.BoundingBox.Top).Min(); - double bottom = ocrLine.Words.Select(x => x.BoundingBox.Bottom).Max(); - double left = ocrLine.Words.Select(x => x.BoundingBox.Left).Min(); - double right = ocrLine.Words.Select(x => x.BoundingBox.Right).Max(); - - Rect lineRect = new() - { - X = left, - Y = top, - Width = Math.Abs(right - left), - Height = Math.Abs(bottom - top) - }; - - StringBuilder lineText = new(); - ocrLine.GetTextFromOcrLine(true, lineText, shouldCorrectToLatin); - - WordBorderInfo info = new() - { - BorderRect = new Rect(lineRect.X, lineRect.Y, lineRect.Width, lineRect.Height), - Word = lineText.ToString().Trim(), - ResultRowID = 0, - ResultColumnID = 0 - }; - - infos.Add(info); - } - - return infos; - } - // New core analyzer that operates on WordBorderInfo (pure model) - public void AnalyzeAsTable(ICollection wordBorders, Rectangle rectCanvasSize, bool drawTable = true) + public void AnalyzeAsTable(ICollection wordBorders, Rectangle rectCanvasSize) { - AnalyzeAsTable(wordBorders, rectCanvasSize, null, null, drawTable); + AnalyzeAsTable(wordBorders, rectCanvasSize, null, null); } public void AnalyzeAsTable( ICollection wordBorders, Rectangle rectCanvasSize, IReadOnlyCollection? manualRowSeparators, - IReadOnlyCollection? manualColumnSeparators, - bool drawTable = true) + IReadOnlyCollection? manualColumnSeparators) { if (wordBorders == null || wordBorders.Count == 0) { @@ -206,11 +104,11 @@ public void AnalyzeAsTable( wb.Word = s.Trim(); } - double medianHeight = Median(wordBorders.Select(w => w.BorderRect.Height).Where(h => h > 0)); + double medianHeight = Median(wordBorders.Select(w => (double)w.BorderRect.Height).Where(h => h > 0)); if (double.IsNaN(medianHeight) || medianHeight <= 0) medianHeight = 20; double rowCenterThreshold = Math.Max(4, medianHeight * 0.75); - double medianWidth = Median(wordBorders.Select(w => w.BorderRect.Width).Where(w => w > 0)); + double medianWidth = Median(wordBorders.Select(w => (double)w.BorderRect.Width).Where(w => w > 0)); if (double.IsNaN(medianWidth) || medianWidth <= 0) medianWidth = 40; double columnCenterThreshold = Math.Max(24, medianWidth * 0.9); @@ -224,8 +122,6 @@ public void AnalyzeAsTable( ParseRowAndColumnLines(); ApplyManualSeparators(manualRowSeparators, manualColumnSeparators); AssignWordBordersToFinalGrid(wordBorders); - if (drawTable) - DrawTable(); } private static List BuildRowsByCenterClustering(ICollection wordBorders, double centerThreshold, double medianHeight) @@ -403,95 +299,6 @@ private static double Median(IEnumerable source) return list[mid]; } - private static List CalculateResultRows(int hitGridSpacing, List rowAreas) - { - List resultRows = []; - int rowTop = 0; - int rowCount = 0; - for (int i = 0; i < rowAreas.Count; i++) - { - int thisLine = rowAreas[i]; - - // check if should set this as top - if (i == 0) - rowTop = thisLine; - else - { - int prevRow = rowAreas[i - 1]; - if (thisLine - prevRow != hitGridSpacing) - { - rowTop = thisLine; - } - } - - // check to see if at bottom of row - if (i == rowAreas.Count - 1) - { - resultRows.Add(new ResultRow { Top = rowTop, Bottom = thisLine, ID = rowCount }); - rowCount++; - } - else if (i + 1 < rowAreas.Count) - { - int nextRow = rowAreas[i + 1]; - if (nextRow - thisLine != hitGridSpacing) - { - resultRows.Add(new ResultRow { Top = rowTop, Bottom = thisLine, ID = rowCount }); - rowCount++; - } - } - } - - return resultRows; - } - - private void DrawTable() - { - // Draw the lines and bounds of the table - SolidColorBrush tableColor = new(System.Windows.Media.Color.FromArgb(255, 40, 118, 126)); - - TableLines = new Canvas() - { - Tag = "TableLines" - }; - - Border tableOutline = new() - { - Width = this.BoundingRect.Width, - Height = this.BoundingRect.Height, - BorderThickness = new Thickness(3), - BorderBrush = tableColor - }; - TableLines.Children.Add(tableOutline); - Canvas.SetTop(tableOutline, this.BoundingRect.Y); - Canvas.SetLeft(tableOutline, this.BoundingRect.X); - - foreach (double columnLine in this.ColumnLines) - { - Border vertLine = new() - { - Width = 2, - Height = this.BoundingRect.Height, - Background = tableColor - }; - TableLines.Children.Add(vertLine); - Canvas.SetTop(vertLine, this.BoundingRect.Y); - Canvas.SetLeft(vertLine, columnLine); - } - - foreach (double rowLine in this.RowLines) - { - Border horzLine = new() - { - Height = 2, - Width = this.BoundingRect.Width, - Background = tableColor - }; - TableLines.Children.Add(horzLine); - Canvas.SetTop(horzLine, rowLine); - Canvas.SetLeft(horzLine, this.BoundingRect.X); - } - } - // Build text from model-only borders public static void GetTextFromTabledWordBorders(StringBuilder stringBuilder, List wordBorders, bool isSpaceJoining) { @@ -762,70 +569,6 @@ private static bool LooksLikePlainNumber(string token) return t.All(ch => char.IsDigit(ch)); } - private static void MergeTheseRowIDs(List resultRows, List outlierRowIDs) - { - // Merge sparse rows into adjacent rows to reduce fragmentation - for (int i = 0; i < outlierRowIDs.Count; i++) - { - for (int j = 0; j < resultRows.Count; j++) - { - ResultRow jthRow = resultRows[j]; - if (jthRow.ID == outlierRowIDs[i]) - { - if (resultRows.Count == 1) - { - // nothing to merge - continue; - } - - if (j == 0) - { - // merge with next row if possible - if (j + 1 < resultRows.Count) - { - ResultRow nextRow = resultRows[j + 1]; - nextRow.Top = Math.Min(nextRow.Top, jthRow.Top); - } - } - else if (j == resultRows.Count - 1) - { - // merge with previous row - if (j - 1 >= 0) - { - ResultRow prevRow = resultRows[j - 1]; - prevRow.Bottom = Math.Max(prevRow.Bottom, jthRow.Bottom); - } - } - else - { - // merge with the closest neighbor by gap distance - ResultRow prevRow = resultRows[j - 1]; - ResultRow nextRow = resultRows[j + 1]; - int distToPrev = (int)(jthRow.Top - prevRow.Bottom); - int distToNext = (int)(nextRow.Top - jthRow.Bottom); - - if (distToNext < distToPrev) - { - // merge with next row - nextRow.Top = Math.Min(nextRow.Top, jthRow.Top); - } - else - { - // merge with prev row - prevRow.Bottom = Math.Max(prevRow.Bottom, jthRow.Bottom); - } - } - - resultRows.RemoveAt(j); - // reindex remaining IDs to keep them sequential - for (int k = 0; k < resultRows.Count; k++) - resultRows[k].ID = k; - break; - } - } - } - } - // Overload for WordBorderInfo private void AssignWordBordersToFinalGrid(ICollection wordBorders) { diff --git a/Text-Grab/Models/SpreadsheetUndoHistory.cs b/Text-Grab.Core/Models/SpreadsheetUndoHistory.cs similarity index 100% rename from Text-Grab/Models/SpreadsheetUndoHistory.cs rename to Text-Grab.Core/Models/SpreadsheetUndoHistory.cs diff --git a/Text-Grab/Models/StoredRegex.cs b/Text-Grab.Core/Models/StoredRegex.cs similarity index 100% rename from Text-Grab/Models/StoredRegex.cs rename to Text-Grab.Core/Models/StoredRegex.cs diff --git a/Text-Grab/Models/TemplatePatternMatch.cs b/Text-Grab.Core/Models/TemplatePatternMatch.cs similarity index 100% rename from Text-Grab/Models/TemplatePatternMatch.cs rename to Text-Grab.Core/Models/TemplatePatternMatch.cs diff --git a/Text-Grab/Models/TemplateRecognizerMatch.cs b/Text-Grab.Core/Models/TemplateRecognizerMatch.cs similarity index 100% rename from Text-Grab/Models/TemplateRecognizerMatch.cs rename to Text-Grab.Core/Models/TemplateRecognizerMatch.cs diff --git a/Text-Grab/Models/TemplateRegion.cs b/Text-Grab.Core/Models/TemplateRegion.cs similarity index 75% rename from Text-Grab/Models/TemplateRegion.cs rename to Text-Grab.Core/Models/TemplateRegion.cs index f2a16117..b7a91cad 100644 --- a/Text-Grab/Models/TemplateRegion.cs +++ b/Text-Grab.Core/Models/TemplateRegion.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Drawing; namespace Text_Grab.Models; @@ -37,21 +37,21 @@ public class TemplateRegion public TemplateRegion() { } /// - /// Returns the absolute pixel Rect for this region given the canvas/image dimensions. + /// Returns the absolute pixel rect for this region given the canvas/image dimensions. /// - public Rect ToAbsoluteRect(double imageWidth, double imageHeight) + public RectangleF ToAbsoluteRect(double imageWidth, double imageHeight) { - return new Rect( - x: RatioLeft * imageWidth, - y: RatioTop * imageHeight, - width: RatioWidth * imageWidth, - height: RatioHeight * imageHeight); + return new RectangleF( + x: (float)(RatioLeft * imageWidth), + y: (float)(RatioTop * imageHeight), + width: (float)(RatioWidth * imageWidth), + height: (float)(RatioHeight * imageHeight)); } /// - /// Sets ratio values from an absolute Rect and canvas dimensions. + /// Sets ratio values from an absolute rect and canvas dimensions. /// - public static TemplateRegion FromAbsoluteRect(Rect rect, double imageWidth, double imageHeight, int regionNumber, string label = "") + public static TemplateRegion FromAbsoluteRect(RectangleF rect, double imageWidth, double imageHeight, int regionNumber, string label = "") { return new TemplateRegion { diff --git a/Text-Grab/Models/ThirdPartyPackageInfo.cs b/Text-Grab.Core/Models/ThirdPartyPackageInfo.cs similarity index 100% rename from Text-Grab/Models/ThirdPartyPackageInfo.cs rename to Text-Grab.Core/Models/ThirdPartyPackageInfo.cs diff --git a/Text-Grab.Core/Models/WebSearchUrlModel.cs b/Text-Grab.Core/Models/WebSearchUrlModel.cs new file mode 100644 index 00000000..05fb379d --- /dev/null +++ b/Text-Grab.Core/Models/WebSearchUrlModel.cs @@ -0,0 +1,17 @@ +namespace Text_Grab.Models; + +/// +/// A single named web-search endpoint (e.g. "Google" -> "https://www.google.com/search?q="). +/// +/// Settings-backed catalog loading/saving and default-searcher tracking depend on +/// AppUtilities.TextGrabSettings/TextGrabSettingsService, which only exist in the +/// app, so they live in the app-side instead - +/// the same split shape as PatternItem/PatternItemCatalog in e677b54. +/// +public record WebSearchUrlModel +{ + public string Name { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + + public override string ToString() => Name; +} diff --git a/Text-Grab.Core/Models/WordBorderInfo.cs b/Text-Grab.Core/Models/WordBorderInfo.cs new file mode 100644 index 00000000..d24ff56b --- /dev/null +++ b/Text-Grab.Core/Models/WordBorderInfo.cs @@ -0,0 +1,22 @@ +using System.Drawing; + +namespace Text_Grab.Models; + +public class WordBorderInfo +{ + public string Word { get; set; } = string.Empty; + public string DisplayText { get; set; } = string.Empty; + public RectangleF BorderRect { get; set; } = RectangleF.Empty; + public double DisplayLineHeight { get; set; } = 0; + public bool KeepSingleLineOutput { get; set; } = false; + public int LineNumber { get; set; } = 0; + public int ResultColumnID { get; set; } = 0; + public int ResultRowID { get; set; } = 0; + public string MatchingBackground { get; set; } = "Transparent"; + public bool IsBarcode { get; set; } = false; + + public WordBorderInfo() + { + + } +} diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab.Core/Services/CalculationService.DateTimeMath.cs similarity index 100% rename from Text-Grab/Services/CalculationService.DateTimeMath.cs rename to Text-Grab.Core/Services/CalculationService.DateTimeMath.cs diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab.Core/Services/CalculationService.UnitMath.cs similarity index 100% rename from Text-Grab/Services/CalculationService.UnitMath.cs rename to Text-Grab.Core/Services/CalculationService.UnitMath.cs diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab.Core/Services/CalculationService.cs similarity index 100% rename from Text-Grab/Services/CalculationService.cs rename to Text-Grab.Core/Services/CalculationService.cs diff --git a/Text-Grab.Core/Services/InputLanguageAccess.cs b/Text-Grab.Core/Services/InputLanguageAccess.cs new file mode 100644 index 00000000..148a4228 --- /dev/null +++ b/Text-Grab.Core/Services/InputLanguageAccess.cs @@ -0,0 +1,40 @@ +using System; + +namespace Text_Grab.Services; + +/// +/// How portable code reads the user's current keyboard input language. +/// +/// The same delegate-resolver shape as and +/// . WPF's System.Windows.Input.InputLanguageManager is the +/// only source for this and lives in PresentationCore, which Core cannot see; it also throws +/// from its own internals in some hosts, so the app's +/// resolver owns that catch and simply returns null. +/// +/// Null - whether because nothing is registered or because the host has no input language - is a +/// normal answer, not an error. LanguageService falls back to +/// CultureInfo.CurrentUICulture and then to en-US, exactly as it did when it read +/// InputLanguageManager directly. +/// +public static class InputLanguageAccess +{ + private static Func? _resolver; + + /// + /// Registers the source of the current input-language tag. The app calls this from a module + /// initializer. Tests may call it again to substitute a fake. + /// + public static void SetResolver(Func resolver) + => _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + + /// Drops the registered resolver. For tests that need to restore a clean slate. + public static void ClearResolver() => _resolver = null; + + /// Whether a resolver has been registered. + public static bool IsConfigured => _resolver is not null; + + /// + /// The current input-language tag, or null when there is no resolver or no input language. + /// + public static string? CurrentTag => _resolver?.Invoke(); +} diff --git a/Text-Grab.Core/Services/SettingsAccess.cs b/Text-Grab.Core/Services/SettingsAccess.cs new file mode 100644 index 00000000..99811676 --- /dev/null +++ b/Text-Grab.Core/Services/SettingsAccess.cs @@ -0,0 +1,50 @@ +using System; +using Text_Grab.Interfaces; + +namespace Text_Grab.Services; + +/// +/// How portable code reaches user settings. +/// +/// Core cannot call AppUtilities.TextGrabSettings - that lives in the app assembly and +/// returns an internal type. Instead the app registers a resolver at module load and Core reads +/// through . +/// +/// The resolver is a delegate rather than a stored instance on purpose: the app's settings object +/// hangs off Singleton<SettingsService>.Instance, which is lazy and does real work on +/// first touch (reads user.config, seeds automation profiles, loads JSON sidecars). Registering a +/// delegate keeps module initialization free of that; the settings object is still built on first +/// read, exactly as it is today. +/// +public static class SettingsAccess +{ + private static Func? _resolver; + + /// + /// Registers the source of settings. The app calls this from a module initializer, so it is + /// in place for any entry point into the app assembly - including the test host, which never + /// runs App.appStartup. Tests may call it again to substitute a fake. + /// + public static void SetResolver(Func resolver) + => _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + + /// Drops the registered resolver. For tests that need to restore a clean slate. + public static void ClearResolver() => _resolver = null; + + /// Whether a resolver has been registered. + public static bool IsConfigured => _resolver is not null; + + /// + /// The active settings. + /// + /// + /// No resolver was registered. In the app this cannot happen - the module initializer covers + /// it. It means Core code is running with neither the app assembly loaded nor a test fake + /// installed, so the caller has to supply one. + /// + public static ITextGrabSettings Current + => _resolver?.Invoke() + ?? throw new InvalidOperationException( + $"No settings resolver registered. Call {nameof(SettingsAccess)}.{nameof(SetResolver)} " + + "before reading settings from Core code."); +} diff --git a/Text-Grab.Core/Services/TtsEngineAccess.cs b/Text-Grab.Core/Services/TtsEngineAccess.cs new file mode 100644 index 00000000..41fc100a --- /dev/null +++ b/Text-Grab.Core/Services/TtsEngineAccess.cs @@ -0,0 +1,50 @@ +using System; +using Text_Grab.Interfaces; + +namespace Text_Grab.Services; + +/// +/// How gets its default speech engine. +/// +/// The same delegate-resolver shape as , +/// and . TtsService used to construct its default engine +/// with a field initializer - private ITtsEngine _engine = new WindowsSpeechEngine(); - +/// but WindowsSpeechEngine is WinRT-only and belongs in Core.Windows, which Core cannot +/// name. The app registers a factory at module load and calls +/// from its own constructor, so the engine is still built at the same +/// moment it always was: when a TtsService is constructed, not lazily on first +/// Speak. +/// +public static class TtsEngineAccess +{ + private static Func? _resolver; + + /// + /// Registers the factory for the default TTS engine. The app calls this from a module + /// initializer, so it is in place for any entry point into the app assembly - including the + /// test host. Tests may call it again to substitute a fake. + /// + public static void SetResolver(Func resolver) + => _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + + /// Drops the registered resolver. For tests that need to restore a clean slate. + public static void ClearResolver() => _resolver = null; + + /// Whether a resolver has been registered. + public static bool IsConfigured => _resolver is not null; + + /// + /// Builds a new default engine. + /// + /// + /// No resolver was registered. In the app this cannot happen - the module initializer covers + /// it. It means Core code is constructing a with neither the app + /// assembly loaded nor a test fake installed, so the caller has to supply one. + /// + public static ITtsEngine CreateDefault() + => _resolver is not null + ? _resolver() + : throw new InvalidOperationException( + $"No TTS engine resolver registered. Call {nameof(TtsEngineAccess)}.{nameof(SetResolver)} " + + "before constructing TtsService from Core code."); +} diff --git a/Text-Grab/Services/TtsService.cs b/Text-Grab.Core/Services/TtsService.cs similarity index 96% rename from Text-Grab/Services/TtsService.cs rename to Text-Grab.Core/Services/TtsService.cs index 2b970b88..b8c1bbb4 100644 --- a/Text-Grab/Services/TtsService.cs +++ b/Text-Grab.Core/Services/TtsService.cs @@ -3,13 +3,12 @@ using System.Threading.Channels; using System.Threading.Tasks; using Text_Grab.Interfaces; -using Text_Grab.Properties; namespace Text_Grab.Services; public class TtsService { - private ITtsEngine _engine = new WindowsSpeechEngine(); + private ITtsEngine _engine; private readonly Channel _queue = Channel.CreateUnbounded(); private readonly CancellationTokenSource _cts = new(); private CancellationTokenSource _speechCts = new(); @@ -37,6 +36,7 @@ public ITtsEngine Engine public TtsService() { + _engine = TtsEngineAccess.CreateDefault(); _ = Task.Run(DrainLoopAsync); } @@ -110,7 +110,7 @@ void handler() private static string ApplyWordLimit(string text) { - int wordLimit = Settings.Default.TtsSpeakWordLimit; + int wordLimit = SettingsAccess.Current.TtsSpeakWordLimit; if (wordLimit <= 0) return text; diff --git a/Text-Grab.Core/Services/UiThreadAccess.cs b/Text-Grab.Core/Services/UiThreadAccess.cs new file mode 100644 index 00000000..af5790cc --- /dev/null +++ b/Text-Grab.Core/Services/UiThreadAccess.cs @@ -0,0 +1,50 @@ +using System; + +namespace Text_Grab.Services; + +/// +/// How portable code gets work onto the app's UI thread. +/// +/// The same shape as , and for the same reason: Core cannot see +/// System.Windows.Application.Current.Dispatcher, which lives in WindowsBase. The app +/// registers a poster at module load and Core code calls . +/// +/// A delegate rather than a stored dispatcher because Application.Current is null at +/// module-initializer time and only becomes non-null once WPF starts. Resolving it inside the +/// registered delegate keeps the original late-bound behaviour: code that runs with no WPF +/// application - the test host, most obviously - simply finds nothing to post to. +/// +public static class UiThreadAccess +{ + private static Action? _poster; + + /// + /// Registers how to run an action on the UI thread. The app calls this from a module + /// initializer. Tests may call it again to substitute a synchronous fake. + /// + public static void SetPoster(Action poster) + => _poster = poster ?? throw new ArgumentNullException(nameof(poster)); + + /// Drops the registered poster. For tests that need to restore a clean slate. + public static void ClearPoster() => _poster = null; + + /// Whether a poster has been registered. + public static bool IsConfigured => _poster is not null; + + /// + /// Queues to run on the UI thread and returns true, or returns + /// false when there is no UI thread to post to. Callers are expected to treat false as + /// "nothing to do" rather than an error - it is the ordinary case in a headless process. + /// + public static bool TryPost(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + Action? poster = _poster; + if (poster is null) + return false; + + poster(action); + return true; + } +} diff --git a/Text-Grab.Core/Text-Grab.Core.csproj b/Text-Grab.Core/Text-Grab.Core.csproj new file mode 100644 index 00000000..5ef1b989 --- /dev/null +++ b/Text-Grab.Core/Text-Grab.Core.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + Text_Grab + enable + enable + false + + win-x86;win-x64;win-arm64 + + + + + + + + + + + + + none + + + + + + + diff --git a/Text-Grab/Utilities/AutomationProfile.cs b/Text-Grab.Core/Utilities/AutomationProfile.cs similarity index 88% rename from Text-Grab/Utilities/AutomationProfile.cs rename to Text-Grab.Core/Utilities/AutomationProfile.cs index 42c34ae4..ddf23dc6 100644 --- a/Text-Grab/Utilities/AutomationProfile.cs +++ b/Text-Grab.Core/Utilities/AutomationProfile.cs @@ -164,20 +164,27 @@ internal static string GetTemporaryFilePath(string extension = ".tmp") return Path.Combine(GetTemporaryDirectory(), $"{Guid.NewGuid():N}{normalizedExtension}"); } - internal void ApplySeed(Properties.Settings settings) + // Widened from Properties.Settings (the app's concrete, internal ApplicationSettingsBase + // subclass) so this can move to Core, which cannot reference the app assembly. Every + // property below is written through the SettingsBase indexer instead of a generated typed + // property. This is behavior-preserving, not just type-erasure: each generated property in + // Settings.Designer.cs (e.g. `FirstRun`) is a thin wrapper whose setter is exactly + // `this["FirstRun"] = value;` - the indexer assignment below is the same call the typed + // property would have made. + internal void ApplySeed(ApplicationSettingsBase settings) { - settings.FirstRun = false; - settings.RunInTheBackground = false; - settings.StartupOnLogin = false; - settings.GlobalHotkeysEnabled = false; - settings.ShowToast = false; - settings.DefaultLaunch = TextGrabMode.EditText.ToString(); - settings.LastUsedLang = "en-US"; - settings.UseTesseract = false; - settings.UiAutomationEnabled = false; - settings.WindowsAiDescriptionEnabled = false; - settings.EnableFileBackedManagedSettings = true; - settings.LookupFileLocation = LookupFilePath; + settings["FirstRun"] = false; + settings["RunInTheBackground"] = false; + settings["StartupOnLogin"] = false; + settings["GlobalHotkeysEnabled"] = false; + settings["ShowToast"] = false; + settings["DefaultLaunch"] = TextGrabMode.EditText.ToString(); + settings["LastUsedLang"] = "en-US"; + settings["UseTesseract"] = false; + settings["UiAutomationEnabled"] = false; + settings["WindowsAiDescriptionEnabled"] = false; + settings["EnableFileBackedManagedSettings"] = true; + settings["LookupFileLocation"] = LookupFilePath; foreach ((string propertyName, JsonElement value) in _seedValues) { diff --git a/Text-Grab/Utilities/AutomationSettingsProvider.cs b/Text-Grab.Core/Utilities/AutomationSettingsProvider.cs similarity index 100% rename from Text-Grab/Utilities/AutomationSettingsProvider.cs rename to Text-Grab.Core/Utilities/AutomationSettingsProvider.cs diff --git a/Text-Grab.Core/Utilities/CfHtmlTableUtilities.cs b/Text-Grab.Core/Utilities/CfHtmlTableUtilities.cs new file mode 100644 index 00000000..2185dc72 --- /dev/null +++ b/Text-Grab.Core/Utilities/CfHtmlTableUtilities.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; + +namespace Text_Grab.Utilities; + +/// +/// Pure CF_HTML table parsing and serialization - no clipboard, WPF, WinRT or GDI+ dependency. +/// Builds the CF_HTML fragment Windows' clipboard format expects and parses one back into a +/// tab-separated grid. Split out of ClipboardUtilities, whose remaining clipboard-touching +/// methods call into this for the actual table encoding/decoding. +/// +public static class CfHtmlTableUtilities +{ + private const int MaxHtmlTableSpan = 16_384; + + public static string BuildCfHtmlTable(IReadOnlyList> rows) + { + if (rows is null || rows.Count == 0) + return string.Empty; + + StringBuilder table = new(); + table.Append(""); + + foreach (IReadOnlyList row in rows) + { + table.Append(""); + foreach (string cell in row) + { + table.Append(""); + } + table.Append(""); + } + + table.Append("
"); + table.Append(WebUtility.HtmlEncode(cell ?? string.Empty).Replace("\r\n", "
").Replace("\n", "
")); + table.Append("
"); + + return WrapHtmlFragmentAsCfHtml(table.ToString()); + } + + internal static string WrapHtmlFragmentAsCfHtml(string htmlFragment) + { + const string htmlPrefix = "\r\n\r\n"; + const string htmlSuffix = "\r\n\r\n\r\n"; + + // CF_HTML header fields are 10-digit, zero-padded byte offsets into the UTF-8 + // encoded clipboard payload. See https://learn.microsoft.com/windows/win32/dataxchg/html-clipboard-format + static string BuildHeader(int startHtml, int endHtml, int startFragment, int endFragment) => + "Version:0.9\r\n" + + $"StartHTML:{startHtml:D10}\r\n" + + $"EndHTML:{endHtml:D10}\r\n" + + $"StartFragment:{startFragment:D10}\r\n" + + $"EndFragment:{endFragment:D10}\r\n"; + + int headerByteLength = Encoding.UTF8.GetByteCount(BuildHeader(0, 0, 0, 0)); + int startHtmlOffset = headerByteLength; + int startFragmentOffset = startHtmlOffset + Encoding.UTF8.GetByteCount(htmlPrefix); + int endFragmentOffset = startFragmentOffset + Encoding.UTF8.GetByteCount(htmlFragment); + int endHtmlOffset = endFragmentOffset + Encoding.UTF8.GetByteCount(htmlSuffix); + + return BuildHeader(startHtmlOffset, endHtmlOffset, startFragmentOffset, endFragmentOffset) + + htmlPrefix + htmlFragment + htmlSuffix; + } + + internal static string ConvertHtmlToTabSeparated(string cfHtml) + { + string fragment = ExtractHtmlFragment(cfHtml); + List> table = ParseHtmlTableToGrid(fragment); + if (table.Count == 0) + return string.Empty; + + StringBuilder sb = new(); + for (int r = 0; r < table.Count; r++) + { + if (r > 0) sb.Append('\n'); + sb.Append(string.Join("\t", table[r])); + } + return sb.ToString(); + } + + private static string ExtractHtmlFragment(string cfHtml) + { + int startPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); + if (startPos < 0) + startPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); + + int endPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); + if (endPos < 0) + endPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); + + if (startPos >= 0 && endPos > startPos) + { + int fragmentStart = cfHtml.IndexOf("-->", startPos) + 3; + return cfHtml[fragmentStart..endPos]; + } + + // Fall back to byte-offset headers (StartFragment:/EndFragment:) + const string startKey = "StartFragment:"; + const string endKey = "EndFragment:"; + int sfIdx = cfHtml.IndexOf(startKey, StringComparison.OrdinalIgnoreCase); + int efIdx = cfHtml.IndexOf(endKey, StringComparison.OrdinalIgnoreCase); + + if (sfIdx >= 0 && efIdx >= 0) + { + int sfNumStart = sfIdx + startKey.Length; + int sfLineEnd = cfHtml.IndexOf('\n', sfNumStart); + int efNumStart = efIdx + endKey.Length; + int efLineEnd = cfHtml.IndexOf('\n', efNumStart); + + if (sfLineEnd > sfNumStart && efLineEnd > efNumStart + && int.TryParse(cfHtml[sfNumStart..sfLineEnd].Trim(), out int sfOff) + && int.TryParse(cfHtml[efNumStart..efLineEnd].Trim(), out int efOff) + && sfOff >= 0 && efOff > sfOff && efOff <= cfHtml.Length) + { + return cfHtml[sfOff..efOff]; + } + } + + return cfHtml; + } + + private static List> ParseHtmlTableToGrid(string html) + { + List> result = []; + int tableStart = html.IndexOf("", StringComparison.OrdinalIgnoreCase); + tableEnd = tableEnd >= 0 ? tableEnd + 8 : html.Length; + + string tableHtml = html[tableStart..tableEnd]; + + // Tracks cells that span into future rows: col -> (remaining rows to fill, cell content) + Dictionary rowspanMap = []; + + int pos = 0; + while (pos < tableHtml.Length) + { + int rowStart = tableHtml.IndexOf("", rowStart, StringComparison.OrdinalIgnoreCase); + rowEnd = rowEnd >= 0 ? rowEnd + 5 : tableHtml.Length; + + List<(string Text, int ColSpan, int RowSpan)> parsedCells = + ParseHtmlRowCells(tableHtml[rowStart..rowEnd]); + + if (parsedCells.Count > 0 || rowspanMap.Count > 0) + { + // Build a sparse column map for this row + Dictionary rowData = []; + + // Apply rowspan carry-overs from previous rows first + foreach (int col in rowspanMap.Keys.OrderBy(k => k).ToList()) + { + (int rem, string content) = rowspanMap[col]; + rowData[col] = content; + if (rem > 1) + rowspanMap[col] = (rem - 1, content); + else + rowspanMap.Remove(col); + } + + // Place each parsed cell in the next free column(s) + int nextFreeCol = 0; + foreach ((string text, int colspan, int rowspan) in parsedCells) + { + nextFreeCol = FindNextFreeColumnRange(rowData, nextFreeCol, colspan); + + for (int cs = 0; cs < colspan; cs++) + rowData[nextFreeCol + cs] = text; + + if (rowspan > 1) + for (int cs = 0; cs < colspan; cs++) + rowspanMap[nextFreeCol + cs] = (rowspan - 1, text); + + nextFreeCol += colspan; + } + + if (rowData.Count > 0) + { + int colCount = rowData.Keys.Max() + 1; + List row = []; + for (int c = 0; c < colCount; c++) + row.Add(rowData.TryGetValue(c, out string? cell) ? cell : string.Empty); + result.Add(row); + } + } + + pos = rowEnd; + } + + return result; + } + + private static int FindNextFreeColumnRange( + IReadOnlyDictionary rowData, + int startColumn, + int columnCount) + { + int candidate = Math.Max(0, startColumn); + + while (true) + { + bool foundOccupiedColumn = false; + for (int offset = 0; offset < columnCount; offset++) + { + if (!rowData.ContainsKey(candidate + offset)) + continue; + + candidate += offset + 1; + foundOccupiedColumn = true; + break; + } + + if (!foundOccupiedColumn) + return candidate; + } + } + + private static List<(string Text, int ColSpan, int RowSpan)> ParseHtmlRowCells(string rowHtml) + { + List<(string, int, int)> cells = []; + int pos = 0; + + while (pos < rowHtml.Length) + { + int tdPos = rowHtml.IndexOf("= 0 && (thPos < 0 || tdPos <= thPos)) + { + cellStart = tdPos; + endTag = ""; + } + else + { + cellStart = thPos; + endTag = ""; + } + + int openEnd = rowHtml.IndexOf('>', cellStart); + if (openEnd < 0) break; + + string tagAttributes = rowHtml[(cellStart + 3)..openEnd]; + int colspan = ParseSpanAttribute(tagAttributes, "colspan"); + int rowspan = ParseSpanAttribute(tagAttributes, "rowspan"); + + int contentStart = openEnd + 1; + int contentEnd = rowHtml.IndexOf(endTag, contentStart, StringComparison.OrdinalIgnoreCase); + contentEnd = contentEnd >= 0 ? contentEnd : rowHtml.Length; + + cells.Add((CleanHtmlCellContent(rowHtml[contentStart..contentEnd]), colspan, rowspan)); + pos = contentEnd + endTag.Length; + } + + return cells; + } + + private static int ParseSpanAttribute(string tagAttributes, string attributeName) + { + int attrPos = tagAttributes.IndexOf(attributeName, StringComparison.OrdinalIgnoreCase); + if (attrPos < 0) return 1; + + int eqPos = tagAttributes.IndexOf('=', attrPos + attributeName.Length); + if (eqPos < 0) return 1; + + int valueStart = eqPos + 1; + while (valueStart < tagAttributes.Length && tagAttributes[valueStart] is ' ' or '"' or '\'') + valueStart++; + + int valueEnd = valueStart; + while (valueEnd < tagAttributes.Length && char.IsDigit(tagAttributes[valueEnd])) + valueEnd++; + + if (valueEnd == valueStart) return 1; + + return int.TryParse(tagAttributes[valueStart..valueEnd], out int span) && span >= 1 + ? Math.Min(span, MaxHtmlTableSpan) + : 1; + } + + private static string CleanHtmlCellContent(string html) + { + if (string.IsNullOrEmpty(html)) + return string.Empty; + + html = Regex.Replace(html, @"", " ", RegexOptions.IgnoreCase); + html = Regex.Replace(html, @"<[^>]*>", string.Empty); + html = WebUtility.HtmlDecode(html); + + return html.Trim(); + } +} diff --git a/Text-Grab/Utilities/CharacterUtilities.cs b/Text-Grab.Core/Utilities/CharacterUtilities.cs similarity index 100% rename from Text-Grab/Utilities/CharacterUtilities.cs rename to Text-Grab.Core/Utilities/CharacterUtilities.cs diff --git a/Text-Grab/Utilities/ColumnSplitUtilities.cs b/Text-Grab.Core/Utilities/ColumnSplitUtilities.cs similarity index 100% rename from Text-Grab/Utilities/ColumnSplitUtilities.cs rename to Text-Grab.Core/Utilities/ColumnSplitUtilities.cs diff --git a/Text-Grab/Utilities/Hdr/HdrToneMapper.cs b/Text-Grab.Core/Utilities/Hdr/HdrToneMapper.cs similarity index 100% rename from Text-Grab/Utilities/Hdr/HdrToneMapper.cs rename to Text-Grab.Core/Utilities/Hdr/HdrToneMapper.cs diff --git a/Text-Grab.Core/Utilities/HocrReader.cs b/Text-Grab.Core/Utilities/HocrReader.cs new file mode 100644 index 00000000..7d9b0251 --- /dev/null +++ b/Text-Grab.Core/Utilities/HocrReader.cs @@ -0,0 +1,57 @@ +using System.Text.RegularExpressions; + +namespace Text_Grab.Utilities; + +public class TessOcrLine +{ + public int Height { get; set; } + public string Text { get; set; } = string.Empty; + public int Width { get; set; } + public int X { get; set; } + public int Y { get; set; } +} + +public static class HocrReader +{ + private static readonly string[] separator = [""]; + + public static List ReadLines(string hocrText) + { + // Create a list to hold the OcrLine objects + List lines = new(); + + // Split the hOCR text into lines + string[] hocrLines = hocrText.Split(separator, StringSplitOptions.RemoveEmptyEntries); + + // Iterate through the lines + foreach (string hocrLineText in hocrLines) + { + // Extract the line information + TessOcrLine line = ReadLine(hocrLineText); + + // Add the line to the list + lines.Add(line); + } + + return lines; + } + + private static TessOcrLine ReadLine(string hocrLineText) + { + // Create a new OcrLine object + TessOcrLine line = new(); + + // Extract the text of the line from the hOCR text + Match textMatch = Regex.Match(hocrLineText, "]*>(.*?)"); + line.Text = textMatch.Groups[1].Value; + + // Extract the bounding box coordinates from the hOCR text + Match bboxMatch = Regex.Match(hocrLineText, "bbox (\\d+) (\\d+) (\\d+) (\\d+)"); + line.X = int.Parse(bboxMatch.Groups[1].Value); + line.Y = int.Parse(bboxMatch.Groups[2].Value); + line.Width = int.Parse(bboxMatch.Groups[3].Value); + line.Height = int.Parse(bboxMatch.Groups[4].Value); + + return line; + } +} diff --git a/Text-Grab/Utilities/IoUtilities.cs b/Text-Grab.Core/Utilities/IoUtilities.cs similarity index 65% rename from Text-Grab/Utilities/IoUtilities.cs rename to Text-Grab.Core/Utilities/IoUtilities.cs index 698bf16b..3763a565 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab.Core/Utilities/IoUtilities.cs @@ -1,9 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text; -using System.Threading.Tasks; -using Text_Grab.Interfaces; using Text_Grab.Models; namespace Text_Grab.Utilities; @@ -102,62 +100,6 @@ public static OpenContentKind GetOpenContentKindForPath(string? path) return OpenContentKind.TextFile; } - public static async Task<(string TextContent, OpenContentKind SourceKindOfContent)> GetContentFromPath(string pathOfFileToOpen, bool isMultipleFiles = false, ILanguage? language = null) - { - StringBuilder stringBuilder = new(); - OpenContentKind openContentKind = GetOpenContentKindForPath(pathOfFileToOpen); - - if (isMultipleFiles) - stringBuilder.AppendLine(pathOfFileToOpen); - - if (openContentKind is OpenContentKind.Image or OpenContentKind.PdfDocument) - { - try - { - stringBuilder.Append(await OcrUtilities.OcrAbsoluteFilePathAsync(pathOfFileToOpen, language)); - } - catch (Exception) - { - await new Wpf.Ui.Controls.MessageBox - { - Title = "Error", - Content = $"Failed to read {pathOfFileToOpen}", - CloseButtonText = "OK" - }.ShowDialogAsync(); - } - } - else - { - // Continue with along trying to open a text file. - openContentKind = OpenContentKind.TextFile; - await TryToOpenTextFile(pathOfFileToOpen, isMultipleFiles, stringBuilder); - } - - if (isMultipleFiles) - { - stringBuilder.Append(Environment.NewLine); - stringBuilder.Append(Environment.NewLine); - } - - return (stringBuilder.ToString(), openContentKind); - } - - public static async Task TryToOpenTextFile(string pathOfFileToOpen, bool isMultipleFiles, StringBuilder stringBuilder) - { - try - { - using StreamReader sr = File.OpenText(pathOfFileToOpen); - - string s = await sr.ReadToEndAsync(); - - stringBuilder.Append(s); - } - catch (System.Exception ex) - { - System.Windows.Forms.MessageBox.Show($"Failed to open file. {ex.Message}"); - } - } - public static string ListFilesFoldersInDirectory(string chosenFolderPath) { IEnumerable files = Directory.EnumerateFiles(chosenFolderPath); diff --git a/Text-Grab/Utilities/Json.cs b/Text-Grab.Core/Utilities/Json.cs similarity index 100% rename from Text-Grab/Utilities/Json.cs rename to Text-Grab.Core/Utilities/Json.cs diff --git a/Text-Grab/Utilities/LanguageHeuristics.cs b/Text-Grab.Core/Utilities/LanguageHeuristics.cs similarity index 100% rename from Text-Grab/Utilities/LanguageHeuristics.cs rename to Text-Grab.Core/Utilities/LanguageHeuristics.cs diff --git a/Text-Grab.Core/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab.Core/Utilities/MarkdownDocumentUtilities.cs new file mode 100644 index 00000000..179f9e58 --- /dev/null +++ b/Text-Grab.Core/Utilities/MarkdownDocumentUtilities.cs @@ -0,0 +1,176 @@ +using Markdig; +using Markdig.Extensions.AutoIdentifiers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using System; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Text_Grab.Utilities; + +public static partial class MarkdownDocumentUtilities +{ + private static readonly Regex LiveBlockTriggerRegex = LiveBlockTrigger(); + private static readonly Regex LiveInlinePromotionRegex = LiveInlinePromotion(); + private static readonly Regex MarkdownPatternRegex = MarkdownPattern(); + + internal static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .UseAutoIdentifiers(AutoIdentifierOptions.GitHub) // Must be BEFORE UseAdvancedExtensions to override default + .UseAdvancedExtensions() + .UseYamlFrontMatter() + .UseEmojiAndSmiley(enableSmileys: false) + .Build(); + + public static bool ShouldPromoteLiveBlock(string? lineTextBeforeSpace) + { + if (string.IsNullOrWhiteSpace(lineTextBeforeSpace)) + return false; + + return LiveBlockTriggerRegex.IsMatch(lineTextBeforeSpace); + } + + public static bool LooksLikeMarkdown(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + return MarkdownPatternRegex.IsMatch(text); + } + + public static bool ShouldPromoteLiveMarkdown(string? paragraphText) + { + if (string.IsNullOrWhiteSpace(paragraphText)) + return false; + + return LiveInlinePromotionRegex.IsMatch(NormalizeDocumentText(paragraphText)); + } + + internal static int GetOrderedListStart(ListBlock listBlock) + { + return listBlock.IsOrdered + && int.TryParse(listBlock.OrderedStart, out int startIndex) + && startIndex > 0 + ? startIndex + : 1; + } + + internal static string GetCodeBlockText(LeafBlock block) + { + return NormalizeDocumentText(block.Lines.ToString()); + } + + internal static string EscapeMarkdownText(string? text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + string escapedText = text + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("`", "\\`", StringComparison.Ordinal) + .Replace("*", "\\*", StringComparison.Ordinal) + .Replace("_", "\\_", StringComparison.Ordinal) + .Replace("[", "\\[", StringComparison.Ordinal) + .Replace("]", "\\]", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); + + escapedText = Regex.Replace(escapedText, @"^(#{1,6}\s)", @"\$1", RegexOptions.Multiline); + escapedText = Regex.Replace(escapedText, @"^(\s*>+)", @"\$1", RegexOptions.Multiline); + escapedText = Regex.Replace(escapedText, @"^(\s*[-+]\s)", @"\$1", RegexOptions.Multiline); + escapedText = Regex.Replace(escapedText, @"^(\s*\d+\.\s)", @"\$1", RegexOptions.Multiline); + return escapedText; + } + + internal static string EscapeLinkDestination(string destination) + { + return destination.Replace(")", "\\)", StringComparison.Ordinal); + } + + internal static string ApplyQuotePrefix(string text, string quotePrefix) + { + if (string.IsNullOrEmpty(quotePrefix)) + return text; + + return string.Join( + Environment.NewLine, + NormalizeNewlines(text).Split('\n').Select(line => string.IsNullOrEmpty(line) + ? quotePrefix.TrimEnd() + : $"{quotePrefix}{line}")); + } + + internal static string GetQuotePrefix(int quoteDepth) + { + if (quoteDepth <= 0) + return string.Empty; + + StringBuilder builder = new(); + for (int i = 0; i < quoteDepth; i++) + builder.Append("> "); + + return builder.ToString(); + } + + internal static string NormalizeDocumentText(string? text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + return NormalizeNewlines(text).TrimEnd('\n'); + } + + internal static string NormalizeNewlines(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + + /// + /// A code span's covers the whole backtick-delimited run (e.g. + /// `dotnet build`), but is just the inner text. Assumes + /// a symmetric fence (equal backtick count on both sides), which covers the vast majority of + /// real-world code spans; degrades to the fenced span if that assumption doesn't hold. + /// + internal static int GetCodeSpanContentRawStart(CodeInline codeInline) + { + int totalLength = codeInline.Span.End - codeInline.Span.Start + 1; + int contentLength = codeInline.Content.Length; + int fenceLength = Math.Max(0, (totalLength - contentLength) / 2); + return codeInline.Span.Start + fenceLength; + } + + /// + /// A 's Span is not always tight to its own Content — + /// e.g. inside a pipe table cell, Markdig's reported span includes the cell's padding + /// whitespace ("| Alpha |"'s content is "Alpha" but the span covers " Alpha "), + /// while ordinary paragraph text elsewhere has no such padding and the span is already exact. + /// Searches the reported span's own window for the literal content and returns its tight bounds; + /// falls back to the untrimmed span if the content can't be found there (should not normally happen). + /// + internal static (int Start, int End) ResolveContentSpan(string source, string content, int spanStart, int spanEndExclusive) + { + if (string.IsNullOrEmpty(content) || spanStart < 0 || spanEndExclusive > source.Length || spanEndExclusive <= spanStart) + return (spanStart, spanEndExclusive); + + int windowLength = spanEndExclusive - spanStart; + if (content.Length > windowLength) + return (spanStart, spanEndExclusive); + + int found = source.IndexOf(content, spanStart, windowLength, StringComparison.Ordinal); + return found < 0 ? (spanStart, spanEndExclusive) : (found, found + content.Length); + } + + internal static string GetSourceSlice(string source, MarkdownObject markdownObject) + { + if (markdownObject.Span.Start < 0 + || markdownObject.Span.End < markdownObject.Span.Start + || markdownObject.Span.End >= source.Length) + return string.Empty; + + return source.Substring(markdownObject.Span.Start, markdownObject.Span.End - markdownObject.Span.Start + 1); + } + + [GeneratedRegex(@"^\s{0,3}(#{1,6}|>+|[-+*]|\d+[.)])$", RegexOptions.Compiled)] + private static partial Regex LiveBlockTrigger(); + + [GeneratedRegex(@"(^|\s)\[( |x|X)\](\s|$)|(\*\*|__)(?=\S).+?\4|(?+\s|[-+*]\s|\d+[.)]\s|```|~~~|---\s*$|___\s*$|\*\*\*\s*$)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|\n)\|.+\|\s*$", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex MarkdownPattern(); +} diff --git a/Text-Grab.Core/Utilities/MatchModeSelector.cs b/Text-Grab.Core/Utilities/MatchModeSelector.cs new file mode 100644 index 00000000..e0cc8a99 --- /dev/null +++ b/Text-Grab.Core/Utilities/MatchModeSelector.cs @@ -0,0 +1,39 @@ +namespace Text_Grab.Utilities; + +/// +/// Selects values from an ordered match list according to a mode string +/// ("first", "last", "all", or 1-based indices like "2" / "1,3,5"). Shared by +/// , , and +/// 's placeholder resolution. +/// +public static class MatchModeSelector +{ + public static string ExtractMatchesByMode(IReadOnlyList allValues, string mode, string separator) + { + if (allValues.Count == 0) + return string.Empty; + + return mode.ToLowerInvariant() switch + { + "first" => allValues[0], + "last" => allValues[^1], + "all" => string.Join(separator, allValues), + _ => ExtractByIndices(allValues, mode, separator) + }; + } + + private static string ExtractByIndices(IReadOnlyList values, string mode, string separator) + { + // mode is either a single index like "2" or comma-separated like "1,3,5" + string[] parts = mode.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + List selected = []; + + foreach (string part in parts) + { + if (int.TryParse(part, out int index) && index >= 1 && index <= values.Count) + selected.Add(values[index - 1]); // convert 1-based to 0-based + } + + return string.Join(separator, selected); + } +} diff --git a/Text-Grab/Utilities/NumericUtilities.cs b/Text-Grab.Core/Utilities/NumericUtilities.cs similarity index 100% rename from Text-Grab/Utilities/NumericUtilities.cs rename to Text-Grab.Core/Utilities/NumericUtilities.cs diff --git a/Text-Grab/Utilities/PatternExecutor.cs b/Text-Grab.Core/Utilities/PatternExecutor.cs similarity index 97% rename from Text-Grab/Utilities/PatternExecutor.cs rename to Text-Grab.Core/Utilities/PatternExecutor.cs index 483ad407..527f11ed 100644 --- a/Text-Grab/Utilities/PatternExecutor.cs +++ b/Text-Grab.Core/Utilities/PatternExecutor.cs @@ -68,7 +68,7 @@ public static string Apply( return string.Empty; List values = [.. matches.Select(m => m.Text)]; - return GrabTemplateExecutor.ExtractMatchesByMode(values, mode, separator); + return MatchModeSelector.ExtractMatchesByMode(values, mode, separator); } private static IReadOnlyList GetRegexMatches(string pattern, string text) diff --git a/Text-Grab.Core/Utilities/ProtocolUtilities.cs b/Text-Grab.Core/Utilities/ProtocolUtilities.cs new file mode 100644 index 00000000..e3886792 --- /dev/null +++ b/Text-Grab.Core/Utilities/ProtocolUtilities.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; + +namespace Text_Grab.Utilities; + +/// +/// Pure parsing half of the text-grab:// protocol used by companion apps such as +/// the Text Grab browser extension. The URI is only a command channel; any +/// data payload (like a copied table) travels via the clipboard. +/// Supported URIs: +/// text-grab://paste-spreadsheet Edit Text window in spreadsheet mode, paste clipboard +/// text-grab://edit-text Edit Text window with clipboard text +/// text-grab://grab-frame[?path=...] Grab Frame, optionally opening a local image/PDF +/// text-grab://grab-text?path=... OCR a local image/PDF straight to the clipboard (no window) +/// text-grab://fullscreen Fullscreen grab +/// text-grab://quick-lookup Quick Simple Lookup +/// text-grab://settings Settings window +/// +/// Validating a companion app's path= parameter and registering the protocol with the +/// OS need Registry access, AutomationProfile and FileUtilities, so those methods +/// stay behind in Text-Grab/Utilities/ProtocolHandlerUtilities.cs. +/// +internal static class ProtocolUtilities +{ + internal const string Scheme = "text-grab"; + + /// + /// Returns true when a startup argument looks like a text-grab:// URI. + /// + internal static bool IsProtocolUri(string? argument) + { + return argument is not null + && argument.StartsWith($"{Scheme}:", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Parses a text-grab:// URI into a lowercase command and its query parameters. + /// Accepts both text-grab://command?key=value and text-grab:command forms. + /// + internal static bool TryParseProtocolUri( + string uriString, + out string command, + out Dictionary parameters) + { + command = string.Empty; + parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (!Uri.TryCreate(uriString, UriKind.Absolute, out Uri? uri) + || !string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase)) + return false; + + // text-grab://paste-spreadsheet puts the command in Host; + // text-grab:paste-spreadsheet puts it in AbsolutePath. + string rawCommand = !string.IsNullOrEmpty(uri.Host) ? uri.Host : uri.AbsolutePath; + command = rawCommand.Trim('/').ToLowerInvariant(); + if (string.IsNullOrEmpty(command)) + return false; + + string query = uri.Query.TrimStart('?'); + foreach (string pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + int separatorIndex = pair.IndexOf('='); + if (separatorIndex <= 0) + continue; + string key = Uri.UnescapeDataString(pair[..separatorIndex]); + string value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]); + parameters[key] = value; + } + + return true; + } +} diff --git a/Text-Grab/Utilities/RecognizerExecutor.cs b/Text-Grab.Core/Utilities/RecognizerExecutor.cs similarity index 98% rename from Text-Grab/Utilities/RecognizerExecutor.cs rename to Text-Grab.Core/Utilities/RecognizerExecutor.cs index df1de2f8..dfee10c3 100644 --- a/Text-Grab/Utilities/RecognizerExecutor.cs +++ b/Text-Grab.Core/Utilities/RecognizerExecutor.cs @@ -103,7 +103,7 @@ public static string ApplyRecognizer( List values = [.. matches.Select(m => output == RecognizerOutputKind.MatchedText ? m.Text : m.ResolvedValue)]; - return GrabTemplateExecutor.ExtractMatchesByMode(values, matchMode, separator); + return MatchModeSelector.ExtractMatchesByMode(values, matchMode, separator); } // ── Resolution formatting ─────────────────────────────────────────────────── diff --git a/Text-Grab.Core/Utilities/RectangleFExtensions.cs b/Text-Grab.Core/Utilities/RectangleFExtensions.cs new file mode 100644 index 00000000..6f504a92 --- /dev/null +++ b/Text-Grab.Core/Utilities/RectangleFExtensions.cs @@ -0,0 +1,75 @@ +using System.Drawing; + +namespace Text_Grab; + +/// +/// Portable geometry helpers for the Core tier. +/// +/// Text-Grab's UI code works in System.Windows.Rect, which lives in WindowsBase.dll and +/// is therefore only available with UseWPF=true. Text-Grab.Core targets plain net10.0 and +/// Text-Grab.Core.Windows deliberately keeps UseWPF=false, so neither can use it. +/// +/// is the substitute: it lives in System.Drawing.Primitives, which is +/// part of the shared framework and genuinely cross-platform - unlike System.Drawing.Common's +/// Bitmap/Graphics, which are Windows-only and belong in Text-Grab.Core.Windows. +/// +/// These mirror the WPF-typed helpers in Text-Grab/Extensions/ShapeExtensions.cs, which also +/// carries the conversions across the boundary (AsRect / AsRectangleF). +/// +public static class RectangleFExtensions +{ + /// + /// Whether the rectangle is usable for layout or hit-testing: finite on every axis and + /// non-degenerate. Mirrors ShapeExtensions.IsGood(Rect). + /// + public static bool IsGood(this RectangleF rect) + { + if (float.IsNaN(rect.X) || float.IsInfinity(rect.X)) + return false; + + if (float.IsNaN(rect.Y) || float.IsInfinity(rect.Y)) + return false; + + if (float.IsNaN(rect.Height) || rect.Height == 0 || float.IsInfinity(rect.Height)) + return false; + + if (float.IsNaN(rect.Width) || rect.Width == 0 || float.IsInfinity(rect.Width)) + return false; + + return true; + } + + public static PointF CenterPoint(this RectangleF rect) + => new(rect.Left + (rect.Width / 2), rect.Top + (rect.Height / 2)); + + /// Scales position and size together, keeping the rectangle in the same relative spot. + public static RectangleF GetScaledUpByFraction(this RectangleF rect, double scaleFactor) + => new( + (float)(rect.X * scaleFactor), + (float)(rect.Y * scaleFactor), + (float)(rect.Width * scaleFactor), + (float)(rect.Height * scaleFactor)); + + /// Scales size only, leaving the top-left corner where it is. + public static RectangleF GetScaleSizeByFraction(this RectangleF rect, double scaleFactor) + => new( + rect.X, + rect.Y, + (float)(rect.Width * scaleFactor), + (float)(rect.Height * scaleFactor)); + + /// + /// The smallest rectangle containing both inputs. An empty input is ignored rather than + /// dragging the union back to the origin, so this can be folded over a sequence. + /// + public static RectangleF Union(this RectangleF rect, RectangleF other) + { + if (rect.IsEmpty) + return other; + + if (other.IsEmpty) + return rect; + + return RectangleF.Union(rect, other); + } +} diff --git a/Text-Grab/Utilities/Singleton.cs b/Text-Grab.Core/Utilities/Singleton.cs similarity index 100% rename from Text-Grab/Utilities/Singleton.cs rename to Text-Grab.Core/Utilities/Singleton.cs diff --git a/Text-Grab/Utilities/StreamWrapper.cs b/Text-Grab.Core/Utilities/StreamWrapper.cs similarity index 100% rename from Text-Grab/Utilities/StreamWrapper.cs rename to Text-Grab.Core/Utilities/StreamWrapper.cs diff --git a/Text-Grab/Utilities/StringMethods.cs b/Text-Grab.Core/Utilities/StringMethods.cs similarity index 100% rename from Text-Grab/Utilities/StringMethods.cs rename to Text-Grab.Core/Utilities/StringMethods.cs diff --git a/Text-Grab.Core/Utilities/TesseractGitHubFileDownloader.cs b/Text-Grab.Core/Utilities/TesseractGitHubFileDownloader.cs new file mode 100644 index 00000000..b53ac6bf --- /dev/null +++ b/Text-Grab.Core/Utilities/TesseractGitHubFileDownloader.cs @@ -0,0 +1,175 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Text_Grab.Utilities; + +public class TesseractGitHubFileDownloader +{ + private readonly HttpClient _client; + + public TesseractGitHubFileDownloader() + { + _client = new HttpClient(); + // It's a good practice to set a user-agent when making requests + _client.DefaultRequestHeaders.Add("User-Agent", "Text Grab settings language downloader"); + } + + public async Task DownloadFileAsync(string filenameToDownload, string localDestination) + { + // Construct the URL to the raw content of the file in the GitHub repository + // https://github.com/tesseract-ocr/tessdata + string fileUrl = $"https://raw.githubusercontent.com/tesseract-ocr/tessdata/main/{filenameToDownload}"; + + try + { + // Send a GET request to the specified URL + HttpResponseMessage response = await _client.GetAsync(fileUrl); + response.EnsureSuccessStatusCode(); + + // Read the response content + byte[] fileContents = await response.Content.ReadAsByteArrayAsync(); + + // Write the content to a file on the local file system + await File.WriteAllBytesAsync(localDestination, fileContents); + Console.WriteLine("File downloaded successfully."); + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred: {ex.Message}"); + } + } + + public static readonly string[] tesseractTrainedDataFileNames = [ + "afr.traineddata", + "amh.traineddata", + "ara.traineddata", + "asm.traineddata", + "aze.traineddata", + "aze_cyrl.traineddata", + "bel.traineddata", + "ben.traineddata", + "bod.traineddata", + "bos.traineddata", + "bre.traineddata", + "bul.traineddata", + "cat.traineddata", + "ceb.traineddata", + "ces.traineddata", + "chi_sim.traineddata", + "chi_sim_vert.traineddata", + "chi_tra.traineddata", + "chi_tra_vert.traineddata", + "chr.traineddata", + "cos.traineddata", + "cym.traineddata", + "dan.traineddata", + "dan_frak.traineddata", + "deu.traineddata", + "deu_frak.traineddata", + "div.traineddata", + "dzo.traineddata", + "ell.traineddata", + "eng.traineddata", + "enm.traineddata", + "epo.traineddata", + "equ.traineddata", + "est.traineddata", + "eus.traineddata", + "fao.traineddata", + "fas.traineddata", + "fil.traineddata", + "fin.traineddata", + "fra.traineddata", + "frk.traineddata", + "frm.traineddata", + "fry.traineddata", + "gla.traineddata", + "gle.traineddata", + "glg.traineddata", + "grc.traineddata", + "guj.traineddata", + "hat.traineddata", + "heb.traineddata", + "hin.traineddata", + "hrv.traineddata", + "hun.traineddata", + "hye.traineddata", + "iku.traineddata", + "ind.traineddata", + "isl.traineddata", + "ita.traineddata", + "ita_old.traineddata", + "jav.traineddata", + "jpn.traineddata", + "jpn_vert.traineddata", + "kan.traineddata", + "kat.traineddata", + "kat_old.traineddata", + "kaz.traineddata", + "khm.traineddata", + "kir.traineddata", + "kmr.traineddata", + "kor.traineddata", + "kor_vert.traineddata", + "lao.traineddata", + "lat.traineddata", + "lav.traineddata", + "lit.traineddata", + "ltz.traineddata", + "mal.traineddata", + "mar.traineddata", + "mkd.traineddata", + "mlt.traineddata", + "mon.traineddata", + "mri.traineddata", + "msa.traineddata", + "mya.traineddata", + "nep.traineddata", + "nld.traineddata", + "nor.traineddata", + "oci.traineddata", + "ori.traineddata", + "osd.traineddata", + "pan.traineddata", + "pol.traineddata", + "por.traineddata", + "pus.traineddata", + "que.traineddata", + "ron.traineddata", + "rus.traineddata", + "san.traineddata", + "sin.traineddata", + "slk.traineddata", + "slk_frak.traineddata", + "slv.traineddata", + "snd.traineddata", + "spa.traineddata", + "spa_old.traineddata", + "sqi.traineddata", + "srp.traineddata", + "srp_latn.traineddata", + "sun.traineddata", + "swa.traineddata", + "swe.traineddata", + "syr.traineddata", + "tam.traineddata", + "tat.traineddata", + "tel.traineddata", + "tgk.traineddata", + "tgl.traineddata", + "tha.traineddata", + "tir.traineddata", + "ton.traineddata", + "tur.traineddata", + "uig.traineddata", + "ukr.traineddata", + "urd.traineddata", + "uzb.traineddata", + "uzb_cyrl.traineddata", + "vie.traineddata", + "yid.traineddata", + "yor.traineddata", + ]; +} diff --git a/Text-Grab/Utilities/TextSearchUtilities.cs b/Text-Grab.Core/Utilities/TextSearchUtilities.cs similarity index 76% rename from Text-Grab/Utilities/TextSearchUtilities.cs rename to Text-Grab.Core/Utilities/TextSearchUtilities.cs index 6cb6e2d7..2ca02477 100644 --- a/Text-Grab/Utilities/TextSearchUtilities.cs +++ b/Text-Grab.Core/Utilities/TextSearchUtilities.cs @@ -5,13 +5,13 @@ namespace Text_Grab.Utilities; -internal static class TextSearchUtilities +public static class TextSearchUtilities { private static readonly TimeSpan DefaultRegexTimeout = TimeSpan.FromSeconds(5); - internal static bool HasSearchText(string? searchText) => !string.IsNullOrEmpty(searchText); + public static bool HasSearchText(string? searchText) => !string.IsNullOrEmpty(searchText); - internal static string FormatMatchTextForDisplay(string matchText) + public static string FormatMatchTextForDisplay(string matchText) { if (!matchText.All(char.IsWhiteSpace)) return matchText.MakeStringSingleLine(); @@ -40,7 +40,7 @@ internal static string FormatMatchTextForDisplay(string matchText) return displayText.ToString(); } - internal static Regex CreateFindAndReplaceSearchRegex(string pattern, bool usePatternMode, bool exactMatch) + public static Regex CreateFindAndReplaceSearchRegex(string pattern, bool usePatternMode, bool exactMatch) { RegexOptions options = RegexOptions.Multiline; @@ -50,13 +50,13 @@ internal static Regex CreateFindAndReplaceSearchRegex(string pattern, bool usePa return new Regex(pattern, options, DefaultRegexTimeout); } - internal static Regex CreateReplacementRegex(string pattern, bool exactMatch) + public static Regex CreateReplacementRegex(string pattern, bool exactMatch) { RegexOptions options = exactMatch ? RegexOptions.None : RegexOptions.IgnoreCase; return new Regex(pattern, options, DefaultRegexTimeout); } - internal static Regex CreateGrabFrameSearchRegex(string pattern, bool exactMatch) + public static Regex CreateGrabFrameSearchRegex(string pattern, bool exactMatch) { RegexOptions options = exactMatch ? RegexOptions.Multiline : RegexOptions.Multiline | RegexOptions.IgnoreCase; return new Regex(pattern, options, DefaultRegexTimeout); diff --git a/Text-Grab/Utilities/ThirdPartyNoticeUtilities.cs b/Text-Grab.Core/Utilities/ThirdPartyNoticeUtilities.cs similarity index 77% rename from Text-Grab/Utilities/ThirdPartyNoticeUtilities.cs rename to Text-Grab.Core/Utilities/ThirdPartyNoticeUtilities.cs index 800fd217..3e76b6a3 100644 --- a/Text-Grab/Utilities/ThirdPartyNoticeUtilities.cs +++ b/Text-Grab.Core/Utilities/ThirdPartyNoticeUtilities.cs @@ -1,11 +1,13 @@ -using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using Text_Grab.Models; namespace Text_Grab.Utilities; +/// +/// Pure package catalog. Resolving notice/license file paths against the running +/// executable's location and opening them needs FileUtilities.GetExePath(), so those +/// methods stay behind in Text-Grab/Utilities/ThirdPartyNoticeLauncher.cs. +/// public static class ThirdPartyNoticeUtilities { public const string BuiltWithFileName = "BUILT-WITH.md"; @@ -44,47 +46,4 @@ public static class ThirdPartyNoticeUtilities new("Xunit.StaFact", "3.0.13", "Tests", "MS-PL", "https://github.com/AArnott/Xunit.StaFact", "https://github.com/AArnott/Xunit.StaFact/blob/main/LICENSE", false, "Test-only dependency."), new("xunit.v3", "3.2.2", "Tests", "Apache-2.0", "https://github.com/xunit/xunit", "https://github.com/xunit/xunit/blob/main/LICENSE", false, "Test-only dependency."), ]; - - public static string? GetBuiltWithFilePath() - { - string? executableDirectory = Path.GetDirectoryName(FileUtilities.GetExePath()); - return string.IsNullOrWhiteSpace(executableDirectory) - ? null - : Path.Combine(executableDirectory, BuiltWithFileName); - } - - public static string? GetNoticesDirectoryPath() - { - string? executableDirectory = Path.GetDirectoryName(FileUtilities.GetExePath()); - return string.IsNullOrWhiteSpace(executableDirectory) - ? null - : Path.Combine(executableDirectory, NoticesDirectoryName); - } - - public static string? GetNoticeTarget(ThirdPartyPackageInfo package) - { - if (!package.NoticeIsLocal) - return package.NoticeTarget; - - string? executableDirectory = Path.GetDirectoryName(FileUtilities.GetExePath()); - return string.IsNullOrWhiteSpace(executableDirectory) - ? null - : Path.Combine(executableDirectory, package.NoticeTarget); - } - - public static void OpenBuiltWithFile() => OpenTarget(GetBuiltWithFilePath()); - - public static void OpenNoticesDirectory() => OpenTarget(GetNoticesDirectoryPath()); - - public static void OpenNoticeFile(ThirdPartyPackageInfo package) => OpenTarget(GetNoticeTarget(package)); - - public static void OpenProjectUrl(ThirdPartyPackageInfo package) => OpenTarget(package.ProjectUrl); - - private static void OpenTarget(string? target) - { - if (string.IsNullOrWhiteSpace(target)) - return; - - Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); - } } diff --git a/Text-Grab.sln b/Text-Grab.sln index 4c6992d1..79297ea4 100644 --- a/Text-Grab.sln +++ b/Text-Grab.sln @@ -16,14 +16,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TextGrab.AutomationHost", "UiTests\TextGrab.AutomationHost\TextGrab.AutomationHost.csproj", "{51D9D3FA-2722-4203-AE21-AEA1B11C761D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Text-Grab.Core", "Text-Grab.Core\Text-Grab.Core.csproj", "{DFFADAEC-2E5A-4F14-B649-2E006558ACA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Text-Grab.Core.Windows", "Text-Grab.Core.Windows\Text-Grab.Core.Windows.csproj", "{F84E41ED-2A83-4717-99F8-734C7A34A690}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Core", "Tests.Core\Tests.Core.csproj", "{F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Core.Windows", "Tests.Core.Windows\Tests.Core.Windows.csproj", "{5F8E212A-3C21-46EF-97EF-7B890C112873}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 + Debug|Any CPU = Debug|Any CPU Release|ARM64 = Release|ARM64 Release|x64 = Release|x64 Release|x86 = Release|x86 + Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Debug|ARM64.ActiveCfg = Debug|ARM64 @@ -32,12 +42,16 @@ Global {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Debug|x64.Build.0 = Debug|x64 {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Debug|x86.ActiveCfg = Debug|x86 {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Debug|x86.Build.0 = Debug|x86 + {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Debug|Any CPU.Build.0 = Debug|Any CPU {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|ARM64.ActiveCfg = Release|ARM64 {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|ARM64.Build.0 = Release|ARM64 {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|x64.ActiveCfg = Release|x64 {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|x64.Build.0 = Release|x64 {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|x86.ActiveCfg = Release|x86 {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|x86.Build.0 = Release|x86 + {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE1A1C35-BF6A-4141-BD91-260E0D2794BA}.Release|Any CPU.Build.0 = Release|Any CPU {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|ARM64.ActiveCfg = Debug|ARM64 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|ARM64.Build.0 = Debug|ARM64 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|ARM64.Deploy.0 = Debug|ARM64 @@ -47,6 +61,8 @@ Global {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|x86.ActiveCfg = Debug|x86 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|x86.Build.0 = Debug|x86 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|x86.Deploy.0 = Debug|x86 + {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE37F469-F629-4B49-84BA-37A1DA192C60}.Debug|Any CPU.Build.0 = Debug|Any CPU {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|ARM64.ActiveCfg = Release|ARM64 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|ARM64.Build.0 = Release|ARM64 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|ARM64.Deploy.0 = Release|ARM64 @@ -56,30 +72,104 @@ Global {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|x86.ActiveCfg = Release|x86 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|x86.Build.0 = Release|x86 {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|x86.Deploy.0 = Release|x86 + {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE37F469-F629-4B49-84BA-37A1DA192C60}.Release|Any CPU.Build.0 = Release|Any CPU {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|ARM64.ActiveCfg = Debug|ARM64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|ARM64.Build.0 = Debug|ARM64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|x64.ActiveCfg = Debug|x64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|x64.Build.0 = Debug|x64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|x86.ActiveCfg = Debug|x86 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|x86.Build.0 = Debug|x86 + {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|ARM64.ActiveCfg = Release|ARM64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|ARM64.Build.0 = Release|ARM64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|x64.ActiveCfg = Release|x64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|x64.Build.0 = Release|x64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|x86.ActiveCfg = Release|x86 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|x86.Build.0 = Release|x86 + {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|Any CPU.Build.0 = Release|Any CPU {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|ARM64.ActiveCfg = Debug|ARM64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|ARM64.Build.0 = Debug|ARM64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x64.ActiveCfg = Debug|x64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x64.Build.0 = Debug|x64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x86.ActiveCfg = Debug|x86 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x86.Build.0 = Debug|x86 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|Any CPU.Build.0 = Debug|Any CPU {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|ARM64.ActiveCfg = Release|ARM64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|ARM64.Build.0 = Release|ARM64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x64.ActiveCfg = Release|x64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x64.Build.0 = Release|x64 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x86.ActiveCfg = Release|x86 {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x86.Build.0 = Release|x86 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|Any CPU.Build.0 = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|ARM64.Build.0 = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|x64.Build.0 = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|x86.Build.0 = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|ARM64.ActiveCfg = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|ARM64.Build.0 = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|x64.ActiveCfg = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|x64.Build.0 = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|x86.ActiveCfg = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|x86.Build.0 = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFFADAEC-2E5A-4F14-B649-2E006558ACA2}.Release|Any CPU.Build.0 = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|ARM64.Build.0 = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|x64.ActiveCfg = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|x64.Build.0 = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|x86.ActiveCfg = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|x86.Build.0 = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|ARM64.ActiveCfg = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|ARM64.Build.0 = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|x64.ActiveCfg = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|x64.Build.0 = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|x86.ActiveCfg = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|x86.Build.0 = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F84E41ED-2A83-4717-99F8-734C7A34A690}.Release|Any CPU.Build.0 = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|ARM64.Build.0 = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|x64.ActiveCfg = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|x64.Build.0 = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|x86.ActiveCfg = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|x86.Build.0 = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|ARM64.ActiveCfg = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|ARM64.Build.0 = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|x64.ActiveCfg = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|x64.Build.0 = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|x86.ActiveCfg = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|x86.Build.0 = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F96646AB-DAD1-4A0E-AE95-3AA24A8754D5}.Release|Any CPU.Build.0 = Release|Any CPU + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|ARM64.Build.0 = Debug|ARM64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|x64.ActiveCfg = Debug|x64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|x64.Build.0 = Debug|x64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|x86.ActiveCfg = Debug|x86 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|x86.Build.0 = Debug|x86 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|ARM64.ActiveCfg = Release|ARM64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|ARM64.Build.0 = Release|ARM64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|x64.ActiveCfg = Release|x64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|x64.Build.0 = Release|x64 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|x86.ActiveCfg = Release|x86 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|x86.Build.0 = Release|x86 + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F8E212A-3C21-46EF-97EF-7B890C112873}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 2c50f2d1..9af38c47 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -413,7 +413,7 @@ internal static bool HandleProtocolUri(string uriString) // open the file; an unsafe path falls back to an empty Grab Frame. if (parameters.TryGetValue("path", out string? path)) { - if (ProtocolUtilities.TryGetSafeProtocolFilePath(path, out string safePath)) + if (ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(path, out string safePath)) { GrabFrame gfWithFile = new(safePath); gfWithFile.Show(); @@ -434,7 +434,7 @@ internal static bool HandleProtocolUri(string uriString) // OCR a local image/PDF straight to the clipboard, no window. The path is // untrusted; only proceed for a validated, allowed local file. if (parameters.TryGetValue("path", out string? path) - && ProtocolUtilities.TryGetSafeProtocolFilePath(path, out string safePath)) + && ProtocolHandlerUtilities.TryGetSafeProtocolFilePath(path, out string safePath)) { _ = GrabTextFromFileAsync(safePath); return true; @@ -469,7 +469,7 @@ private static async Task GrabTextFromFileAsync(string path) { try { - string ocrText = await OcrUtilities.OcrAbsoluteFilePathAsync( + string ocrText = await OcrSourceUtilities.OcrAbsoluteFilePathAsync( path, LanguageUtilities.GetOCRLanguage()); OutputUtilities.HandleTextFromOcr(ocrText, isSingleLine: false, isTable: false); } @@ -522,7 +522,7 @@ public static async Task TryToOpenFilePathAsync(string possiblePath, bool if (isQuiet) { - (string pathContent, _) = await IoUtilities.GetContentFromPath(possiblePath); + (string pathContent, _) = await FileOpenUtilities.GetContentFromPath(possiblePath); OutputUtilities.HandleTextFromOcr( pathContent, false, @@ -600,7 +600,7 @@ private async void appStartup(object sender, StartupEventArgs e) // (packaged installs register these via the MSIX manifest). if (_automationProfile is null || _automationProfile.AllowsPersistentRegistration) { - ProtocolUtilities.EnsureProtocolRegistration(); + ProtocolHandlerUtilities.EnsureProtocolRegistration(); FileAssociationUtilities.EnsureGrabFrameFileAssociation(); } diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index 85d9294f..d5341046 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -131,7 +131,7 @@ private void FullscreenGrabMenuItem_Click(object sender, RoutedEventArgs e) private async void PreviousRegionMenuItem_Click(object sender, RoutedEventArgs e) { - await OcrUtilities.GetTextFromPreviousFullscreenRegion(); + await OcrSourceUtilities.GetTextFromPreviousFullscreenRegion(); } private void LookupMenuItem_Click(object sender, RoutedEventArgs e) diff --git a/Text-Grab/Controls/SearchBar.xaml.cs b/Text-Grab/Controls/SearchBar.xaml.cs index 020d4b9a..54980fa4 100644 --- a/Text-Grab/Controls/SearchBar.xaml.cs +++ b/Text-Grab/Controls/SearchBar.xaml.cs @@ -221,7 +221,7 @@ private void PatternMenu_Opened(object sender, RoutedEventArgs e) PatternMenu.Items.Clear(); string? currentGroup = null; - foreach (PatternItem pattern in PatternItem.GetAll()) + foreach (PatternItem pattern in PatternItemCatalog.GetAll()) { if (pattern.GroupLabel != currentGroup) { diff --git a/Text-Grab/Controls/SplitColumnWindow.xaml.cs b/Text-Grab/Controls/SplitColumnWindow.xaml.cs index c02a0439..b38ebc4e 100644 --- a/Text-Grab/Controls/SplitColumnWindow.xaml.cs +++ b/Text-Grab/Controls/SplitColumnWindow.xaml.cs @@ -58,7 +58,7 @@ private void LoadPatternPicker() { // Feed the inline picker the same unified catalog the Grab Template editor uses: // saved regexes (inserted as {p:Name}) and built-in smart patterns ({r:Name}). - allPatternItems = PatternItem.GetAll(); + allPatternItems = PatternItemCatalog.GetAll(); PatternPickerBox.ItemsSource = [ .. allPatternItems.Select(p => new InlinePickerItem(p.Name, TokenFor(p), p.GroupLabel) diff --git a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs index a9accfce..609f1a99 100644 --- a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs +++ b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs @@ -46,7 +46,7 @@ private void LoadPatternItems() { OutputTemplateBox.ItemsSource = [ - .. PatternItem.GetAll().Select(InlinePickerItemFor), + .. PatternItemCatalog.GetAll().Select(InlinePickerItemFor), ]; } diff --git a/Text-Grab/Extensions/ShapeExtensions.cs b/Text-Grab/Extensions/ShapeExtensions.cs index d666960b..caab4d66 100644 --- a/Text-Grab/Extensions/ShapeExtensions.cs +++ b/Text-Grab/Extensions/ShapeExtensions.cs @@ -16,6 +16,40 @@ public static Rectangle AsRectangle(this Rect rect) return new Rectangle((int)rect.X, (int)rect.Y, (int)rect.Width, (int)rect.Height); } + // Conversions across the Core boundary. Text-Grab.Core and Text-Grab.Core.Windows cannot use + // System.Windows.Rect (WindowsBase, WPF-only), so they speak RectangleF/PointF/SizeF from + // System.Drawing.Primitives instead. View code converts here, at the edge. + + public static Rect AsRect(this RectangleF rectangle) + { + return new Rect(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); + } + + public static RectangleF AsRectangleF(this Rect rect) + { + return new RectangleF((float)rect.X, (float)rect.Y, (float)rect.Width, (float)rect.Height); + } + + public static System.Windows.Point AsPoint(this PointF point) + { + return new System.Windows.Point(point.X, point.Y); + } + + public static PointF AsPointF(this System.Windows.Point point) + { + return new PointF((float)point.X, (float)point.Y); + } + + public static System.Windows.Size AsSize(this SizeF size) + { + return new System.Windows.Size(size.Width, size.Height); + } + + public static SizeF AsSizeF(this System.Windows.Size size) + { + return new SizeF((float)size.Width, (float)size.Height); + } + public static Rect GetScaledDownByDpi(this Rect rect, DpiScale dpi) { return new Rect(rect.X / dpi.DpiScaleX, diff --git a/Text-Grab/Models/PatternItemCatalog.cs b/Text-Grab/Models/PatternItemCatalog.cs new file mode 100644 index 00000000..c92b70cc --- /dev/null +++ b/Text-Grab/Models/PatternItemCatalog.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Text_Grab.Utilities; + +namespace Text_Grab.Models; + +/// +/// Loads the combined, user-facing "Patterns" catalog (saved regexes + built-in recognizers) +/// from settings. Split out from because it depends on +/// , which only exists in the app. +/// +public static class PatternItemCatalog +{ + /// + /// Returns the combined catalog: the user's saved regexes first (falling back to the + /// built-in defaults when none are saved), then the built-in recognizers. Recognizers the + /// user has hidden are excluded unless is true — the + /// Patterns Manager passes true so it can offer an "unhide" action. + /// + public static IReadOnlyList GetAll(bool includeHidden = false) + { + StoredRegex[] saved = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (saved.Length == 0) + saved = StoredRegex.GetDefaultPatterns(); + + HashSet hiddenIds = [.. AppUtilities.TextGrabSettingsService.LoadHiddenSmartPatternIds()]; + + IEnumerable recognizers = BuiltInRecognizer.GetAll() + .Select(r => new PatternItem(r, isHidden: hiddenIds.Contains(r.Id))); + + if (!includeHidden) + recognizers = recognizers.Where(r => !r.IsHidden); + + return + [ + .. saved.Select(s => new PatternItem(s)), + .. recognizers, + ]; + } + + /// + /// Finds a pattern by display name (case-insensitive), preferring a saved regex over a + /// recognizer when both share a name. Null when no pattern matches. + /// + public static PatternItem? GetByName(string name) + => GetAll().FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Text-Grab/Models/WebSearchUrlModel.cs b/Text-Grab/Models/WebSearchUrlCatalog.cs similarity index 80% rename from Text-Grab/Models/WebSearchUrlModel.cs rename to Text-Grab/Models/WebSearchUrlCatalog.cs index e70caaa8..04e52dda 100644 --- a/Text-Grab/Models/WebSearchUrlModel.cs +++ b/Text-Grab/Models/WebSearchUrlCatalog.cs @@ -4,11 +4,17 @@ namespace Text_Grab.Models; -public record WebSearchUrlModel +/// +/// Settings-backed catalog of web-search endpoints, plus which +/// one is the default. Split out from because it depends on +/// /, +/// which only exist in the app. Accessed through +/// Singleton<WebSearchUrlCatalog>.Instance so the cached list and default selection +/// persist across the call sites within a session, exactly as they did on the old +/// WebSearchUrlModel singleton instance. +/// +public class WebSearchUrlCatalog { - public string Name { get; set; } = string.Empty; - public string Url { get; set; } = string.Empty; - private WebSearchUrlModel? defaultSearcher; public WebSearchUrlModel DefaultSearcher @@ -25,8 +31,6 @@ public WebSearchUrlModel DefaultSearcher } } - public override string ToString() => Name; - private List webSearchers = []; public List WebSearchers diff --git a/Text-Grab/Models/WordBorderInfo.cs b/Text-Grab/Models/WordBorderInfo.cs deleted file mode 100644 index 25e6934f..00000000 --- a/Text-Grab/Models/WordBorderInfo.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Windows; -using Text_Grab.Controls; - -namespace Text_Grab.Models; - -public class WordBorderInfo -{ - public string Word { get; set; } = string.Empty; - public string DisplayText { get; set; } = string.Empty; - public Rect BorderRect { get; set; } = Rect.Empty; - public double DisplayLineHeight { get; set; } = 0; - public bool KeepSingleLineOutput { get; set; } = false; - public int LineNumber { get; set; } = 0; - public int ResultColumnID { get; set; } = 0; - public int ResultRowID { get; set; } = 0; - public string MatchingBackground { get; set; } = "Transparent"; - public bool IsBarcode { get; set; } = false; - - public WordBorderInfo() - { - - } - - public WordBorderInfo(WordBorder wordBorder) - { - Word = wordBorder.Word; - DisplayText = wordBorder.KeepSingleLineOutput || !string.Equals(wordBorder.DisplayText, wordBorder.Word, StringComparison.Ordinal) - ? wordBorder.DisplayText - : string.Empty; - DisplayLineHeight = wordBorder.DisplayLineHeight; - KeepSingleLineOutput = wordBorder.KeepSingleLineOutput; - LineNumber = wordBorder.LineNumber; - ResultColumnID = wordBorder.ResultColumnID; - ResultRowID = wordBorder.ResultRowID; - MatchingBackground = wordBorder.MatchingBackground.ToString(); - IsBarcode = wordBorder.IsBarcode; - BorderRect = new() - { - X = wordBorder.Left, - Y = wordBorder.Top, - Width = wordBorder.Width, - Height = wordBorder.Height - }; - } -} diff --git a/Text-Grab/Models/WordBorderInfoFactory.cs b/Text-Grab/Models/WordBorderInfoFactory.cs new file mode 100644 index 00000000..762e4396 --- /dev/null +++ b/Text-Grab/Models/WordBorderInfoFactory.cs @@ -0,0 +1,37 @@ +using System; +using Text_Grab.Controls; + +namespace Text_Grab.Models; + +/// +/// Builds a pure projection from the WPF +/// control. Split out of so that class could move to +/// Text-Grab.Core — is a WPF control and can never follow it there. +/// +public static class WordBorderInfoFactory +{ + public static WordBorderInfo Create(WordBorder wordBorder) + { + return new WordBorderInfo + { + Word = wordBorder.Word, + DisplayText = wordBorder.KeepSingleLineOutput || !string.Equals(wordBorder.DisplayText, wordBorder.Word, StringComparison.Ordinal) + ? wordBorder.DisplayText + : string.Empty, + DisplayLineHeight = wordBorder.DisplayLineHeight, + KeepSingleLineOutput = wordBorder.KeepSingleLineOutput, + LineNumber = wordBorder.LineNumber, + ResultColumnID = wordBorder.ResultColumnID, + ResultRowID = wordBorder.ResultRowID, + MatchingBackground = wordBorder.MatchingBackground.ToString(), + IsBarcode = wordBorder.IsBarcode, + BorderRect = new() + { + X = (float)wordBorder.Left, + Y = (float)wordBorder.Top, + Width = (float)wordBorder.Width, + Height = (float)wordBorder.Height + } + }; + } +} diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 2a721e74..d1d89f48 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -135,13 +135,13 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) StartupOnLoginCheckBox.IsChecked = DefaultSettings.StartupOnLogin; } - List searcherSettings = Singleton.Instance.WebSearchers; + List searcherSettings = Singleton.Instance.WebSearchers; WebSearchersComboBox.Items.Clear(); foreach (WebSearchUrlModel searcher in searcherSettings) WebSearchersComboBox.Items.Add(searcher); - WebSearchersComboBox.SelectedItem = Singleton.Instance.DefaultSearcher; + WebSearchersComboBox.SelectedItem = Singleton.Instance.DefaultSearcher; ShowToastCheckBox.IsChecked = DefaultSettings.ShowToast; @@ -490,7 +490,7 @@ private void WebSearchersComboBox_SelectionChanged(object sender, SelectionChang || comboBox.SelectedItem is not WebSearchUrlModel newDefault) return; - Singleton.Instance.DefaultSearcher = newDefault; + Singleton.Instance.DefaultSearcher = newDefault; } private async void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) diff --git a/Text-Grab/Properties/Settings.cs b/Text-Grab/Properties/Settings.cs index a7de5c03..f6d9a3c8 100644 --- a/Text-Grab/Properties/Settings.cs +++ b/Text-Grab/Properties/Settings.cs @@ -1,4 +1,5 @@ using System.Configuration; +using Text_Grab.Interfaces; using Text_Grab.Utilities; namespace Text_Grab.Properties; @@ -10,7 +11,13 @@ namespace Text_Grab.Properties; // is active the provider transparently defers to its LocalFileSettingsProvider base, // so normal runs are unaffected. This lives in a hand-written partial so it survives // SettingsSingleFileGenerator regenerating Settings.Designer.cs. +// +// This partial also declares ITextGrabSettings, which is how Text-Grab.Core reads settings +// without depending on the app. Every member of that interface is already implemented by the +// generated properties (and by ApplicationSettingsBase.Save), so there is nothing to write here - +// declaring the interface is the whole implementation. If a build breaks on a newly added +// interface member, the fix belongs in Settings.settings, not in a forwarding property here. [SettingsProvider(typeof(AutomationSettingsProvider))] -internal sealed partial class Settings +internal sealed partial class Settings : ITextGrabSettings { } diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index a3e08be0..1317a985 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -1,13 +1,9 @@ using Humanizer; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; using System.IO; using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -21,27 +17,23 @@ namespace Text_Grab.Services; +/// +/// The live, app-bound half of the grab history: the in-memory lists, the DispatcherTimer that +/// debounces writes and the one that releases the cache when it goes idle, the cached fullscreen +/// bitmap, the recent-grabs menu, and opening a history entry back into a GrabFrame. +/// +/// Everything that only touches the disk - loading, writing, normalization, the word-border +/// sidecar files and the retention rules - moved to +/// in batch 6e of the Core split. What +/// holds the rest here is state plus WPF: DispatcherTimer and MenuItem are WindowsBase and +/// PresentationFramework, and SaveToHistory takes a GrabFrame and an EditTextWindow. +/// public partial class HistoryService : IDisposable { #region Fields - private static readonly int maxHistoryTextOnly = 100; - private static readonly int maxHistoryWithImages = 10; - private static readonly int maxHistoryPdfDocuments = 10; - private const string WordBorderInfoFileSuffix = ".wordborders.json"; private static readonly TimeSpan historyCacheCheckInterval = TimeSpan.FromMinutes(1); private static readonly TimeSpan historyCacheIdleLifetime = TimeSpan.FromMinutes(2); - private static readonly AsyncLocal HistoryLanguageKindFallbackUsed = new(); - private static readonly JsonSerializerOptions HistoryJsonOptions = new() - { - AllowTrailingCommas = true, - WriteIndented = true, - Converters = - { - new HistoryLanguageKindJsonConverter(), - new JsonStringEnumConverter() - } - }; private List HistoryTextOnly = []; private List HistoryWithImage = []; private readonly DispatcherTimer saveTimer = new(); @@ -123,7 +115,7 @@ public bool GetLastHistoryAsGrabFrame() { EnsureImageHistoryLoaded(); TouchHistoryCache(); - HistoryInfo? lastHistoryItem = GetMostRecentGrab(HistoryWithImage); + HistoryInfo? lastHistoryItem = HistoryFileUtilities.GetMostRecentGrab(HistoryWithImage); if (lastHistoryItem is not HistoryInfo historyInfo) return false; @@ -135,13 +127,6 @@ public bool GetLastHistoryAsGrabFrame() return true; } - internal static HistoryInfo? GetMostRecentGrab(IEnumerable historyItems) - { - return historyItems - .Where(history => !history.IsPdfDocument) - .MaxBy(history => history.CaptureDateTime); - } - public string GetLastTextHistory() { EnsureTextHistoryLoaded(); @@ -182,19 +167,27 @@ public async Task LoadHistories() _hasPendingWrite = false; ReleaseLoadedHistoriesCore(); - (HistoryTextOnly, bool textHistoryNeedsRewrite) = await LoadHistoryAsync(nameof(HistoryTextOnly)); + (HistoryTextOnly, bool textHistoryNeedsRewrite) = + await HistoryFileUtilities.LoadHistoryAsync(nameof(HistoryTextOnly)); _textHistoryLoaded = true; - NormalizeHistoryIds(HistoryTextOnly); - if (textHistoryNeedsRewrite || NormalizeHistoryCompatibilityData(HistoryTextOnly)) + // Both normalizers mutate, so neither may be short-circuited away by the other. + bool normalizedTextIds = HistoryFileUtilities.NormalizeHistoryIds(HistoryTextOnly); + bool normalizedTextCompatibilityData = HistoryFileUtilities.NormalizeHistoryCompatibilityData(HistoryTextOnly); + + if (normalizedTextIds || textHistoryNeedsRewrite || normalizedTextCompatibilityData) MarkHistoryDirty(); - (HistoryWithImage, bool imageHistoryNeedsRewrite) = await LoadHistoryAsync(nameof(HistoryWithImage)); + (HistoryWithImage, bool imageHistoryNeedsRewrite) = + await HistoryFileUtilities.LoadHistoryAsync(nameof(HistoryWithImage)); _imageHistoryLoaded = true; - NormalizeHistoryIds(HistoryWithImage); - if (imageHistoryNeedsRewrite || NormalizeHistoryCompatibilityData(HistoryWithImage)) + // Both normalizers mutate, so neither may be short-circuited away by the other. + bool normalizedImageIds = HistoryFileUtilities.NormalizeHistoryIds(HistoryWithImage); + bool normalizedImageCompatibilityData = HistoryFileUtilities.NormalizeHistoryCompatibilityData(HistoryWithImage); + + if (normalizedImageIds || imageHistoryNeedsRewrite || normalizedImageCompatibilityData) MarkHistoryDirty(); - if (EnsureWordBorderSidecarFiles(HistoryWithImage)) + if (HistoryFileUtilities.EnsureWordBorderSidecarFiles(HistoryWithImage)) MarkHistoryDirty(); TouchHistoryCache(); @@ -319,8 +312,8 @@ public void SaveToHistory(GrabFrame grabFrameToSave) if (string.IsNullOrEmpty(historyInfo.ID)) historyInfo.ID = Guid.NewGuid().ToString(); - NormalizeHistoryCompatibilityData(historyInfo); - PersistWordBorderData(historyInfo); + HistoryFileUtilities.NormalizeHistoryCompatibilityData(historyInfo); + HistoryFileUtilities.PersistWordBorderData(historyInfo); if (historyInfo.ImageContent is not null && !string.IsNullOrWhiteSpace(historyInfo.ImagePath)) FileUtilities.SaveImageFile(historyInfo.ImageContent, historyInfo.ImagePath, FileStorageKind.WithHistory); @@ -348,8 +341,8 @@ public void SaveToHistory(HistoryInfo infoFromFullscreenGrab) infoFromFullscreenGrab.ImagePath = $"{imgRandomName}.bmp"; - NormalizeHistoryCompatibilityData(infoFromFullscreenGrab); - PersistWordBorderData(infoFromFullscreenGrab); + HistoryFileUtilities.NormalizeHistoryCompatibilityData(infoFromFullscreenGrab); + HistoryFileUtilities.PersistWordBorderData(infoFromFullscreenGrab); infoFromFullscreenGrab.ClearTransientImage(); HistoryWithImage.Add(infoFromFullscreenGrab); @@ -366,7 +359,7 @@ public void SaveToHistory(EditTextWindow etwToSave) EnsureTextHistoryLoaded(); TouchHistoryCache(); HistoryInfo historyInfo = etwToSave.AsHistoryItem(); - NormalizeHistoryCompatibilityData(historyInfo); + HistoryFileUtilities.NormalizeHistoryCompatibilityData(historyInfo); foreach (HistoryInfo inHistoryItem in HistoryTextOnly) { @@ -393,20 +386,23 @@ public void WriteHistory() if (_textHistoryLoaded) { - NormalizeHistoryCompatibilityData(HistoryTextOnly); - WriteHistoryFiles(HistoryTextOnly, nameof(HistoryTextOnly), maxHistoryTextOnly); + HistoryFileUtilities.NormalizeHistoryCompatibilityData(HistoryTextOnly); + HistoryFileUtilities.WriteHistoryFiles( + HistoryTextOnly, + nameof(HistoryTextOnly), + HistoryFileUtilities.MaxHistoryTextOnly); } if (_imageHistoryLoaded) { ClearOldImages(); - NormalizeHistoryCompatibilityData(HistoryWithImage); - PersistWordBorderData(HistoryWithImage); - WriteHistoryFiles( + HistoryFileUtilities.NormalizeHistoryCompatibilityData(HistoryWithImage); + HistoryFileUtilities.PersistWordBorderData(HistoryWithImage); + HistoryFileUtilities.WriteHistoryFiles( HistoryWithImage, nameof(HistoryWithImage), - maxHistoryWithImages + maxHistoryPdfDocuments); - DeleteUnusedWordBorderFiles(HistoryWithImage); + HistoryFileUtilities.MaxHistoryWithImages + HistoryFileUtilities.MaxHistoryPdfDocuments); + HistoryFileUtilities.DeleteUnusedWordBorderFiles(HistoryWithImage); } _hasPendingWrite = false; @@ -428,7 +424,7 @@ public void RemoveImageHistoryItem(HistoryInfo historyItem) HistoryWithImage.Remove(historyItem); historyItem.ClearTransientImage(); historyItem.ClearTransientWordBorderData(); - DeleteHistoryArtifacts(historyItem); + HistoryFileUtilities.DeleteHistoryArtifacts(historyItem); MarkHistoryDirty(); } @@ -453,59 +449,10 @@ public void RemoveImageHistoryItem(HistoryInfo historyItem) return HistoryTextOnly.FirstOrDefault(history => history.ID == historyId); } - public async Task> GetWordBorderInfosAsync(HistoryInfo history) + public Task> GetWordBorderInfosAsync(HistoryInfo history) { TouchHistoryCache(); - - if (!string.IsNullOrWhiteSpace(history.WordBorderInfoFileName)) - { - // Sanitize the persisted file name to prevent path traversal outside the history directory - string sanitizedFileName = Path.GetFileName(history.WordBorderInfoFileName); - - if (!string.IsNullOrWhiteSpace(sanitizedFileName) - && string.Equals(Path.GetExtension(sanitizedFileName), ".json", StringComparison.OrdinalIgnoreCase)) - { - try - { - string historyBasePath = await FileUtilities.GetPathToHistory(); - string wordBorderInfoPath = Path.Combine(historyBasePath, sanitizedFileName); - - if (File.Exists(wordBorderInfoPath)) - { - await using FileStream wordBorderInfoStream = File.OpenRead(wordBorderInfoPath); - List? wordBorderInfos = - await JsonSerializer.DeserializeAsync>(wordBorderInfoStream, HistoryJsonOptions); - - if (wordBorderInfos is not null) - return wordBorderInfos; - } - } - catch (IOException ex) - { - Debug.WriteLine($"Failed to read word border info file for history item '{history.ID}': {ex}"); - } - catch (JsonException ex) - { - Debug.WriteLine($"Failed to deserialize word border info file for history item '{history.ID}': {ex}"); - } - } - } - - if (string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) - return []; - - try - { - List? inlineWordBorderInfos = - JsonSerializer.Deserialize>(history.WordBorderInfoJson, HistoryJsonOptions); - - return inlineWordBorderInfos ?? []; - } - catch (JsonException ex) - { - Debug.WriteLine($"Failed to deserialize inline word border info for history item '{history.ID}': {ex}"); - return []; - } + return HistoryFileUtilities.GetWordBorderInfosAsync(history); } public void ReleaseLoadedHistories() @@ -542,101 +489,9 @@ public void Dispose() #region Private Methods - private static async Task<(List HistoryItems, bool NeedsRewrite)> LoadHistoryAsync(string fileName) - { - string rawText = await FileUtilities.GetTextFileAsync($"{fileName}.json", FileStorageKind.WithHistory); - - if (string.IsNullOrWhiteSpace(rawText)) - return ([], false); - - try - { - HistoryLanguageKindFallbackUsed.Value = false; - List? tempHistory = JsonSerializer.Deserialize>(rawText, HistoryJsonOptions); - - if (tempHistory is List jsonList && jsonList.Count > 0) - return (tempHistory, HistoryLanguageKindFallbackUsed.Value); - } - catch (JsonException ex) - { - Debug.WriteLine($"Failed to deserialize history file '{fileName}.json' as a list. Attempting item-by-item recovery. {ex}"); - return LoadHistoryWithRecovery(rawText, fileName); - } - finally - { - HistoryLanguageKindFallbackUsed.Value = false; - } - - return ([], false); - } - - private static (List HistoryItems, bool NeedsRewrite) LoadHistoryWithRecovery(string rawText, string fileName) - { - try - { - using JsonDocument document = JsonDocument.Parse(rawText); - - if (document.RootElement.ValueKind != JsonValueKind.Array) - return ([], true); - - List recoveredHistory = []; - bool needsRewrite = true; - int index = 0; - - foreach (JsonElement element in document.RootElement.EnumerateArray()) - { - try - { - HistoryLanguageKindFallbackUsed.Value = false; - HistoryInfo? historyItem = element.Deserialize(HistoryJsonOptions); - if (historyItem is not null) - { - recoveredHistory.Add(historyItem); - if (HistoryLanguageKindFallbackUsed.Value) - needsRewrite = true; - } - } - catch (JsonException ex) - { - Debug.WriteLine($"Skipped invalid history item at index {index} from '{fileName}.json'. {ex}"); - } - finally - { - HistoryLanguageKindFallbackUsed.Value = false; - } - - index++; - } - - return (recoveredHistory, needsRewrite); - } - catch (JsonException ex) - { - Debug.WriteLine($"Failed to parse history file '{fileName}.json' during recovery. {ex}"); - return ([], true); - } - } - - private static void WriteHistoryFiles(List history, string fileName, int maxNumberToSave) - { - string historyAsJson = JsonSerializer - .Serialize(history - .OrderBy(x => x.CaptureDateTime) - .TakeLast(maxNumberToSave), - HistoryJsonOptions); - - try - { - SaveHistoryTextFileBlocking(historyAsJson, $"{fileName}.json"); - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to save history json file. {ex.Message}"); - } - } private void ClearOldImages() { - List imagesToRemove = GetExcessVisualHistoryItems(HistoryWithImage); + List imagesToRemove = HistoryFileUtilities.GetExcessVisualHistoryItems(HistoryWithImage); if (imagesToRemove.Count == 0) return; @@ -645,24 +500,9 @@ private void ClearOldImages() HistoryWithImage.Remove(historyItem); foreach (HistoryInfo infoItem in imagesToRemove) - DeleteHistoryArtifacts(infoItem); - - ClearTransientHistoryPayloads(imagesToRemove); - } + HistoryFileUtilities.DeleteHistoryArtifacts(infoItem); - internal static List GetExcessVisualHistoryItems(IEnumerable historyItems) - { - return - [ - .. historyItems - .Where(history => !history.IsPdfDocument) - .OrderBy(history => history.CaptureDateTime) - .SkipLast(maxHistoryWithImages), - .. historyItems - .Where(history => history.IsPdfDocument) - .OrderBy(history => history.CaptureDateTime) - .SkipLast(maxHistoryPdfDocuments), - ]; + HistoryFileUtilities.ClearTransientHistoryPayloads(imagesToRemove); } private void DisposeCachedBitmap() @@ -677,27 +517,22 @@ private void DisposeCachedBitmap() CachedBitmap = null; } - private static void ClearTransientHistoryPayloads(IEnumerable historyItems) - { - foreach (HistoryInfo historyItem in historyItems) - { - historyItem.ClearTransientImage(); - historyItem.ClearTransientWordBorderData(); - } - } - private void EnsureImageHistoryLoaded() { if (_imageHistoryLoaded) return; - (HistoryWithImage, bool imageHistoryNeedsRewrite) = LoadHistoryBlocking(nameof(HistoryWithImage)); + (HistoryWithImage, bool imageHistoryNeedsRewrite) = + HistoryFileUtilities.LoadHistoryBlocking(nameof(HistoryWithImage)); _imageHistoryLoaded = true; - NormalizeHistoryIds(HistoryWithImage); - if (imageHistoryNeedsRewrite || NormalizeHistoryCompatibilityData(HistoryWithImage)) + // Both normalizers mutate, so neither may be short-circuited away by the other. + bool normalizedIds = HistoryFileUtilities.NormalizeHistoryIds(HistoryWithImage); + bool normalizedCompatibilityData = HistoryFileUtilities.NormalizeHistoryCompatibilityData(HistoryWithImage); + + if (normalizedIds || imageHistoryNeedsRewrite || normalizedCompatibilityData) MarkHistoryDirty(); - if (EnsureWordBorderSidecarFiles(HistoryWithImage)) + if (HistoryFileUtilities.EnsureWordBorderSidecarFiles(HistoryWithImage)) MarkHistoryDirty(); } @@ -706,10 +541,14 @@ private void EnsureTextHistoryLoaded() if (_textHistoryLoaded) return; - (HistoryTextOnly, bool textHistoryNeedsRewrite) = LoadHistoryBlocking(nameof(HistoryTextOnly)); + (HistoryTextOnly, bool textHistoryNeedsRewrite) = + HistoryFileUtilities.LoadHistoryBlocking(nameof(HistoryTextOnly)); _textHistoryLoaded = true; - NormalizeHistoryIds(HistoryTextOnly); - if (textHistoryNeedsRewrite || NormalizeHistoryCompatibilityData(HistoryTextOnly)) + // Both normalizers mutate, so neither may be short-circuited away by the other. + bool normalizedIds = HistoryFileUtilities.NormalizeHistoryIds(HistoryTextOnly); + bool normalizedCompatibilityData = HistoryFileUtilities.NormalizeHistoryCompatibilityData(HistoryTextOnly); + + if (normalizedIds || textHistoryNeedsRewrite || normalizedCompatibilityData) MarkHistoryDirty(); } @@ -727,95 +566,6 @@ private void HistoryCacheReleaseTimer_Tick(object? sender, EventArgs e) ReleaseLoadedHistoriesCore(); } - private static (List HistoryItems, bool NeedsRewrite) LoadHistoryBlocking(string fileName) - { - return Task.Run(() => LoadHistoryAsync(fileName)).GetAwaiter().GetResult(); - } - - private static string GetHistoryPathBlocking() - { - return Task.Run(async () => await FileUtilities.GetPathToHistory()).GetAwaiter().GetResult(); - } - - private static string GetWordBorderInfoFileName(string historyId) - { - return $"{historyId}{WordBorderInfoFileSuffix}"; - } - - private static bool SaveHistoryTextFileBlocking(string textContent, string fileName) - { - return Task.Run(async () => await FileUtilities.SaveTextFile(textContent, fileName, FileStorageKind.WithHistory)) - .GetAwaiter() - .GetResult(); - } - - private void DeleteHistoryArtifacts(HistoryInfo historyItem) - { - DeleteHistoryFile(historyItem.ImagePath); - DeleteHistoryFile(historyItem.WordBorderInfoFileName); - } - - private static void DeleteHistoryFile(string? historyFileName) - { - if (string.IsNullOrWhiteSpace(historyFileName)) - return; - - string historyBasePath = GetHistoryPathBlocking(); - string filePath = Path.Combine(historyBasePath, Path.GetFileName(historyFileName)); - - if (!File.Exists(filePath)) - return; - - try - { - File.Delete(filePath); - } - catch (IOException ex) - { - Debug.WriteLine($"Failed to delete history file '{filePath}': {ex}"); - } - catch (UnauthorizedAccessException ex) - { - Debug.WriteLine($"Access denied when deleting history file '{filePath}': {ex}"); - } - } - - private void DeleteUnusedWordBorderFiles(IEnumerable historyItems) - { - string historyBasePath = GetHistoryPathBlocking(); - - if (!Directory.Exists(historyBasePath)) - return; - - HashSet expectedFileNames = [.. historyItems - .Select(historyItem => historyItem.WordBorderInfoFileName) - .Where(fileName => !string.IsNullOrWhiteSpace(fileName)) - .Select(fileName => Path.GetFileName(fileName!))]; - - string[] wordBorderInfoFiles = Directory.GetFiles(historyBasePath, $"*{WordBorderInfoFileSuffix}"); - - foreach (string wordBorderInfoFile in wordBorderInfoFiles) - { - string fileName = Path.GetFileName(wordBorderInfoFile); - - if (!expectedFileNames.Contains(fileName)) - { - try - { - File.Delete(wordBorderInfoFile); - } - catch (IOException ex) - { - Debug.WriteLine($"Failed to delete word border info file '{wordBorderInfoFile}': {ex}"); - } - catch (UnauthorizedAccessException ex) - { - Debug.WriteLine($"Access denied when deleting word border info file '{wordBorderInfoFile}': {ex}"); - } - } - } - } - private void MarkHistoryDirty() { _hasPendingWrite = true; @@ -824,114 +574,9 @@ private void MarkHistoryDirty() saveTimer.Start(); } - private bool EnsureWordBorderSidecarFiles(IEnumerable historyItems) - { - bool migratedAnyWordBorderData = false; - - foreach (HistoryInfo historyItem in historyItems) - { - if (PersistWordBorderData(historyItem)) - migratedAnyWordBorderData = true; - } - - return migratedAnyWordBorderData; - } - - private static bool NormalizeHistoryCompatibilityData(IEnumerable historyItems) - { - bool normalizedAnyHistoryItems = false; - - foreach (HistoryInfo historyItem in historyItems) - { - if (NormalizeHistoryCompatibilityData(historyItem)) - normalizedAnyHistoryItems = true; - } - - return normalizedAnyHistoryItems; - } - - private static bool NormalizeHistoryCompatibilityData(HistoryInfo historyItem) - { - (string normalizedLanguageTag, LanguageKind normalizedLanguageKind, bool usedUiAutomation) = - LanguageUtilities.NormalizePersistedLanguageIdentity( - historyItem.LanguageKind, - historyItem.LanguageTag, - historyItem.UsedUiAutomation); - - if (string.Equals(historyItem.LanguageTag, normalizedLanguageTag, StringComparison.Ordinal) - && historyItem.LanguageKind == normalizedLanguageKind - && historyItem.UsedUiAutomation == usedUiAutomation) - { - return false; - } - - historyItem.LanguageTag = normalizedLanguageTag; - historyItem.LanguageKind = normalizedLanguageKind; - historyItem.UsedUiAutomation = usedUiAutomation; - return true; - } - - private void PersistWordBorderData(IEnumerable historyItems) - { - foreach (HistoryInfo historyItem in historyItems) - PersistWordBorderData(historyItem); - } - - private bool PersistWordBorderData(HistoryInfo historyItem) - { - if (string.IsNullOrWhiteSpace(historyItem.WordBorderInfoJson)) - return false; - - if (string.IsNullOrWhiteSpace(historyItem.ID)) - historyItem.ID = Guid.NewGuid().ToString(); - - string wordBorderInfoFileName = GetWordBorderInfoFileName(historyItem.ID); - bool couldSaveWordBorderInfo = SaveHistoryTextFileBlocking(historyItem.WordBorderInfoJson, wordBorderInfoFileName); - - if (!couldSaveWordBorderInfo) - { - historyItem.WordBorderInfoFileName = null; - return false; - } - - historyItem.WordBorderInfoFileName = wordBorderInfoFileName; - - // When file-backed settings are enabled, the sidecar file is the authority - // for word border data, so drop the inline JSON to reduce memory/disk usage. - if (DefaultSettings.EnableFileBackedManagedSettings) - historyItem.ClearTransientWordBorderData(); - - return true; - } - - private void NormalizeHistoryIds(List historyItems) - { - HashSet seenIds = []; - bool updatedAnyIds = false; - - foreach (HistoryInfo historyItem in historyItems) - { - if (!string.IsNullOrWhiteSpace(historyItem.ID) && seenIds.Add(historyItem.ID)) - continue; - - string nextId; - do - { - nextId = Guid.NewGuid().ToString(); - } - while (!seenIds.Add(nextId)); - - historyItem.ID = nextId; - updatedAnyIds = true; - } - - if (updatedAnyIds) - MarkHistoryDirty(); - } - private void ReleaseLoadedHistoriesCore() { - ClearTransientHistoryPayloads(HistoryWithImage); + HistoryFileUtilities.ClearTransientHistoryPayloads(HistoryWithImage); HistoryWithImage.Clear(); HistoryTextOnly.Clear(); _imageHistoryLoaded = false; @@ -955,50 +600,5 @@ private void TouchHistoryCache() historyCacheReleaseTimer.Start(); } - private sealed class HistoryLanguageKindJsonConverter : JsonConverter - { - public override LanguageKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String) - { - string? value = reader.GetString(); - - if (!string.IsNullOrWhiteSpace(value) - && Enum.TryParse(value, true, out LanguageKind parsedValue) - && Enum.IsDefined(typeof(LanguageKind), parsedValue)) - { - return parsedValue; - } - - HistoryLanguageKindFallbackUsed.Value = true; - Debug.WriteLine($"Unknown history LanguageKind '{value}'. Falling back to {LanguageKind.Global}."); - return LanguageKind.Global; - } - - if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int numericValue)) - { - if (Enum.IsDefined(typeof(LanguageKind), numericValue)) - return (LanguageKind)numericValue; - - HistoryLanguageKindFallbackUsed.Value = true; - Debug.WriteLine($"Unknown history LanguageKind numeric value '{numericValue}'. Falling back to {LanguageKind.Global}."); - return LanguageKind.Global; - } - - if (reader.TokenType == JsonTokenType.Null) - { - HistoryLanguageKindFallbackUsed.Value = true; - return LanguageKind.Global; - } - - HistoryLanguageKindFallbackUsed.Value = true; - Debug.WriteLine($"Unexpected token '{reader.TokenType}' for history LanguageKind. Falling back to {LanguageKind.Global}."); - return LanguageKind.Global; - } - - public override void Write(Utf8JsonWriter writer, LanguageKind value, JsonSerializerOptions options) - => writer.WriteStringValue(value.ToString()); - } - #endregion Private Methods } diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index c50823fb..4581d7c2 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -34,24 +34,13 @@ before translate / summarize / rewrite / text-to-table will run; request one at https://aka.ms/laffeatures. - Never commit a token. Pass it in at build time instead: - dotnet build -p:LafToken="..." -p:LafPublisherId="..." - or set the LAF_TOKEN / LAF_PUBLISHER_ID environment variables for local development, which - the properties below pick up so Visual Studio builds get the token too. See - docs/Configuring-LAF-Environment-Variables.md. + LimitedAccessFeatureUtilities reads this back via its own assembly's AssemblyMetadata, and it + lives in Text-Grab.Core.Windows, so the LafToken/LafPublisherId wiring is defined there (see + Text-Grab.Core.Windows.csproj) rather than here. Global MSBuild properties passed to this + project's build (dotnet build/publish -p:LafToken=... -p:LafPublisherId=...) flow through the + ProjectReference below to that project's build unchanged, so nothing else has to change here. + See docs/Configuring-LAF-Environment-Variables.md. --> - - $(LAF_TOKEN) - $(LAF_PUBLISHER_ID) - - - - - - - - - @@ -91,6 +80,11 @@ + + + + + @@ -99,11 +93,6 @@ - - - - - @@ -111,23 +100,12 @@ - - - - - none - diff --git a/Text-Grab/Utilities/AppUtilities.cs b/Text-Grab/Utilities/AppUtilities.cs index 2a64ce55..80d7bcf1 100644 --- a/Text-Grab/Utilities/AppUtilities.cs +++ b/Text-Grab/Utilities/AppUtilities.cs @@ -1,23 +1,10 @@ using Text_Grab.Properties; using Text_Grab.Services; -using Windows.ApplicationModel; namespace Text_Grab.Utilities; internal class AppUtilities { - internal static bool IsPackaged() - { - try - { - // If we have a package ID then we are running in a packaged context - PackageId dummy = Package.Current.Id; - return true; - } - catch - { - return false; - } - } + internal static bool IsPackaged() => PackageIdentity.IsPackaged(); internal static SettingsService TextGrabSettingsService => Singleton.Instance; @@ -33,15 +20,5 @@ internal static bool ShouldCorrectToLatin() && settings.CorrectToLatin && LanguageUtilities.IsCurrentLanguageLatinBased(); - internal static string GetAppVersion() - { - if (IsPackaged()) - { - PackageVersion version = Package.Current.Id.Version; - return $"{version.Major}.{version.Minor}.{version.Build}" ?? "unknown error reading package version"; - } - - - return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown error reading assembly version"; - } + internal static string GetAppVersion() => PackageIdentity.GetAppVersion(); } diff --git a/Text-Grab/Utilities/ClipboardUtilities.cs b/Text-Grab/Utilities/ClipboardUtilities.cs index f3e2d1d9..3b588588 100644 --- a/Text-Grab/Utilities/ClipboardUtilities.cs +++ b/Text-Grab/Utilities/ClipboardUtilities.cs @@ -15,8 +15,6 @@ namespace Text_Grab.Utilities; public class ClipboardUtilities { - private const int MaxHtmlTableSpan = 16_384; - public static async Task<(bool, string)> TryGetClipboardText() { DataPackageView? dataPackageView = null; @@ -61,7 +59,7 @@ public static (bool, ImageSource?) TryGetImageFromClipboard() { IDataObject? clipboardData = System.Windows.Clipboard.GetDataObject(); if (clipboardData is null - || !clipboardData.GetDataPresent(System.Windows.Forms.DataFormats.Bitmap)) + || !clipboardData.GetDataPresent(System.Windows.DataFormats.Bitmap)) return (false, null); imageSource = System.Windows.Clipboard.GetImage(); @@ -133,55 +131,6 @@ private static string CleanTeamsBase64Image(string dirtyTeamsString) return sb.ToString(); } - public static string BuildCfHtmlTable(IReadOnlyList> rows) - { - if (rows is null || rows.Count == 0) - return string.Empty; - - StringBuilder table = new(); - table.Append(""); - - foreach (IReadOnlyList row in rows) - { - table.Append(""); - foreach (string cell in row) - { - table.Append(""); - } - table.Append(""); - } - - table.Append("
"); - table.Append(WebUtility.HtmlEncode(cell ?? string.Empty).Replace("\r\n", "
").Replace("\n", "
")); - table.Append("
"); - - return WrapHtmlFragmentAsCfHtml(table.ToString()); - } - - internal static string WrapHtmlFragmentAsCfHtml(string htmlFragment) - { - const string htmlPrefix = "\r\n\r\n"; - const string htmlSuffix = "\r\n\r\n\r\n"; - - // CF_HTML header fields are 10-digit, zero-padded byte offsets into the UTF-8 - // encoded clipboard payload. See https://learn.microsoft.com/windows/win32/dataxchg/html-clipboard-format - static string BuildHeader(int startHtml, int endHtml, int startFragment, int endFragment) => - "Version:0.9\r\n" + - $"StartHTML:{startHtml:D10}\r\n" + - $"EndHTML:{endHtml:D10}\r\n" + - $"StartFragment:{startFragment:D10}\r\n" + - $"EndFragment:{endFragment:D10}\r\n"; - - int headerByteLength = Encoding.UTF8.GetByteCount(BuildHeader(0, 0, 0, 0)); - int startHtmlOffset = headerByteLength; - int startFragmentOffset = startHtmlOffset + Encoding.UTF8.GetByteCount(htmlPrefix); - int endFragmentOffset = startFragmentOffset + Encoding.UTF8.GetByteCount(htmlFragment); - int endHtmlOffset = endFragmentOffset + Encoding.UTF8.GetByteCount(htmlSuffix); - - return BuildHeader(startHtmlOffset, endHtmlOffset, startFragmentOffset, endFragmentOffset) - + htmlPrefix + htmlFragment + htmlSuffix; - } - public static bool TryGetHtmlTableAsTabSeparated(out string tabSeparated) { tabSeparated = string.Empty; @@ -194,7 +143,7 @@ public static bool TryGetHtmlTableAsTabSeparated(out string tabSeparated) if (string.IsNullOrEmpty(htmlData)) return false; - string result = ConvertHtmlToTabSeparated(htmlData); + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(htmlData); if (string.IsNullOrEmpty(result)) return false; @@ -207,240 +156,6 @@ public static bool TryGetHtmlTableAsTabSeparated(out string tabSeparated) } } - internal static string ConvertHtmlToTabSeparated(string cfHtml) - { - string fragment = ExtractHtmlFragment(cfHtml); - List> table = ParseHtmlTableToGrid(fragment); - if (table.Count == 0) - return string.Empty; - - StringBuilder sb = new(); - for (int r = 0; r < table.Count; r++) - { - if (r > 0) sb.Append('\n'); - sb.Append(string.Join("\t", table[r])); - } - return sb.ToString(); - } - - private static string ExtractHtmlFragment(string cfHtml) - { - int startPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); - if (startPos < 0) - startPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); - - int endPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); - if (endPos < 0) - endPos = cfHtml.IndexOf("", StringComparison.OrdinalIgnoreCase); - - if (startPos >= 0 && endPos > startPos) - { - int fragmentStart = cfHtml.IndexOf("-->", startPos) + 3; - return cfHtml[fragmentStart..endPos]; - } - - // Fall back to byte-offset headers (StartFragment:/EndFragment:) - const string startKey = "StartFragment:"; - const string endKey = "EndFragment:"; - int sfIdx = cfHtml.IndexOf(startKey, StringComparison.OrdinalIgnoreCase); - int efIdx = cfHtml.IndexOf(endKey, StringComparison.OrdinalIgnoreCase); - - if (sfIdx >= 0 && efIdx >= 0) - { - int sfNumStart = sfIdx + startKey.Length; - int sfLineEnd = cfHtml.IndexOf('\n', sfNumStart); - int efNumStart = efIdx + endKey.Length; - int efLineEnd = cfHtml.IndexOf('\n', efNumStart); - - if (sfLineEnd > sfNumStart && efLineEnd > efNumStart - && int.TryParse(cfHtml[sfNumStart..sfLineEnd].Trim(), out int sfOff) - && int.TryParse(cfHtml[efNumStart..efLineEnd].Trim(), out int efOff) - && sfOff >= 0 && efOff > sfOff && efOff <= cfHtml.Length) - { - return cfHtml[sfOff..efOff]; - } - } - - return cfHtml; - } - - private static List> ParseHtmlTableToGrid(string html) - { - List> result = []; - int tableStart = html.IndexOf("", StringComparison.OrdinalIgnoreCase); - tableEnd = tableEnd >= 0 ? tableEnd + 8 : html.Length; - - string tableHtml = html[tableStart..tableEnd]; - - // Tracks cells that span into future rows: col -> (remaining rows to fill, cell content) - Dictionary rowspanMap = []; - - int pos = 0; - while (pos < tableHtml.Length) - { - int rowStart = tableHtml.IndexOf("", rowStart, StringComparison.OrdinalIgnoreCase); - rowEnd = rowEnd >= 0 ? rowEnd + 5 : tableHtml.Length; - - List<(string Text, int ColSpan, int RowSpan)> parsedCells = - ParseHtmlRowCells(tableHtml[rowStart..rowEnd]); - - if (parsedCells.Count > 0 || rowspanMap.Count > 0) - { - // Build a sparse column map for this row - Dictionary rowData = []; - - // Apply rowspan carry-overs from previous rows first - foreach (int col in rowspanMap.Keys.OrderBy(k => k).ToList()) - { - (int rem, string content) = rowspanMap[col]; - rowData[col] = content; - if (rem > 1) - rowspanMap[col] = (rem - 1, content); - else - rowspanMap.Remove(col); - } - - // Place each parsed cell in the next free column(s) - int nextFreeCol = 0; - foreach ((string text, int colspan, int rowspan) in parsedCells) - { - nextFreeCol = FindNextFreeColumnRange(rowData, nextFreeCol, colspan); - - for (int cs = 0; cs < colspan; cs++) - rowData[nextFreeCol + cs] = text; - - if (rowspan > 1) - for (int cs = 0; cs < colspan; cs++) - rowspanMap[nextFreeCol + cs] = (rowspan - 1, text); - - nextFreeCol += colspan; - } - - if (rowData.Count > 0) - { - int colCount = rowData.Keys.Max() + 1; - List row = []; - for (int c = 0; c < colCount; c++) - row.Add(rowData.TryGetValue(c, out string? cell) ? cell : string.Empty); - result.Add(row); - } - } - - pos = rowEnd; - } - - return result; - } - - private static int FindNextFreeColumnRange( - IReadOnlyDictionary rowData, - int startColumn, - int columnCount) - { - int candidate = Math.Max(0, startColumn); - - while (true) - { - bool foundOccupiedColumn = false; - for (int offset = 0; offset < columnCount; offset++) - { - if (!rowData.ContainsKey(candidate + offset)) - continue; - - candidate += offset + 1; - foundOccupiedColumn = true; - break; - } - - if (!foundOccupiedColumn) - return candidate; - } - } - - private static List<(string Text, int ColSpan, int RowSpan)> ParseHtmlRowCells(string rowHtml) - { - List<(string, int, int)> cells = []; - int pos = 0; - - while (pos < rowHtml.Length) - { - int tdPos = rowHtml.IndexOf("= 0 && (thPos < 0 || tdPos <= thPos)) - { - cellStart = tdPos; - endTag = ""; - } - else - { - cellStart = thPos; - endTag = ""; - } - - int openEnd = rowHtml.IndexOf('>', cellStart); - if (openEnd < 0) break; - - string tagAttributes = rowHtml[(cellStart + 3)..openEnd]; - int colspan = ParseSpanAttribute(tagAttributes, "colspan"); - int rowspan = ParseSpanAttribute(tagAttributes, "rowspan"); - - int contentStart = openEnd + 1; - int contentEnd = rowHtml.IndexOf(endTag, contentStart, StringComparison.OrdinalIgnoreCase); - contentEnd = contentEnd >= 0 ? contentEnd : rowHtml.Length; - - cells.Add((CleanHtmlCellContent(rowHtml[contentStart..contentEnd]), colspan, rowspan)); - pos = contentEnd + endTag.Length; - } - - return cells; - } - - private static int ParseSpanAttribute(string tagAttributes, string attributeName) - { - int attrPos = tagAttributes.IndexOf(attributeName, StringComparison.OrdinalIgnoreCase); - if (attrPos < 0) return 1; - - int eqPos = tagAttributes.IndexOf('=', attrPos + attributeName.Length); - if (eqPos < 0) return 1; - - int valueStart = eqPos + 1; - while (valueStart < tagAttributes.Length && tagAttributes[valueStart] is ' ' or '"' or '\'') - valueStart++; - - int valueEnd = valueStart; - while (valueEnd < tagAttributes.Length && char.IsDigit(tagAttributes[valueEnd])) - valueEnd++; - - if (valueEnd == valueStart) return 1; - - return int.TryParse(tagAttributes[valueStart..valueEnd], out int span) && span >= 1 - ? Math.Min(span, MaxHtmlTableSpan) - : 1; - } - - private static string CleanHtmlCellContent(string html) - { - if (string.IsNullOrEmpty(html)) - return string.Empty; - - html = Regex.Replace(html, @"", " ", RegexOptions.IgnoreCase); - html = Regex.Replace(html, @"<[^>]*>", string.Empty); - html = WebUtility.HtmlDecode(html); - - return html.Trim(); - } - private static string base64ImageExtension(ref string base64String) { // Copied this portion of the code from https://github.com/veler/DevToys diff --git a/Text-Grab/Utilities/FileOpenUtilities.cs b/Text-Grab/Utilities/FileOpenUtilities.cs new file mode 100644 index 00000000..cbca7948 --- /dev/null +++ b/Text-Grab/Utilities/FileOpenUtilities.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Text_Grab.Interfaces; + +namespace Text_Grab.Utilities; + +public class FileOpenUtilities +{ + public static async Task<(string TextContent, OpenContentKind SourceKindOfContent)> GetContentFromPath(string pathOfFileToOpen, bool isMultipleFiles = false, ILanguage? language = null) + { + StringBuilder stringBuilder = new(); + OpenContentKind openContentKind = IoUtilities.GetOpenContentKindForPath(pathOfFileToOpen); + + if (isMultipleFiles) + stringBuilder.AppendLine(pathOfFileToOpen); + + if (openContentKind is OpenContentKind.Image or OpenContentKind.PdfDocument) + { + try + { + stringBuilder.Append(await OcrSourceUtilities.OcrAbsoluteFilePathAsync(pathOfFileToOpen, language)); + } + catch (Exception) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Error", + Content = $"Failed to read {pathOfFileToOpen}", + CloseButtonText = "OK" + }.ShowDialogAsync(); + } + } + else + { + // Continue with along trying to open a text file. + openContentKind = OpenContentKind.TextFile; + await TryToOpenTextFile(pathOfFileToOpen, isMultipleFiles, stringBuilder); + } + + if (isMultipleFiles) + { + stringBuilder.Append(Environment.NewLine); + stringBuilder.Append(Environment.NewLine); + } + + return (stringBuilder.ToString(), openContentKind); + } + + public static async Task TryToOpenTextFile(string pathOfFileToOpen, bool isMultipleFiles, StringBuilder stringBuilder) + { + try + { + using StreamReader sr = File.OpenText(pathOfFileToOpen); + + string s = await sr.ReadToEndAsync(); + + stringBuilder.Append(s); + } + catch (System.Exception ex) + { + System.Windows.Forms.MessageBox.Show($"Failed to open file. {ex.Message}"); + } + } +} diff --git a/Text-Grab/Utilities/FreeformCaptureUtilities.cs b/Text-Grab/Utilities/FreeformCaptureUtilities.cs index 02383864..81aae920 100644 --- a/Text-Grab/Utilities/FreeformCaptureUtilities.cs +++ b/Text-Grab/Utilities/FreeformCaptureUtilities.cs @@ -46,25 +46,4 @@ public static PathGeometry BuildGeometry(IReadOnlyList points) geometry.Freeze(); return geometry; } - - public static Bitmap CreateMaskedBitmap(Bitmap sourceBitmap, IReadOnlyList pointsRelativeToBounds) - { - ArgumentNullException.ThrowIfNull(sourceBitmap); - - if (pointsRelativeToBounds is null || pointsRelativeToBounds.Count < 3) - return new Bitmap(sourceBitmap); - - Bitmap maskedBitmap = new(sourceBitmap.Width, sourceBitmap.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); - using Graphics graphics = Graphics.FromImage(maskedBitmap); - using GraphicsPath graphicsPath = new(); - - graphics.SmoothingMode = SmoothingMode.AntiAlias; - graphics.Clear(System.Drawing.Color.Gray); - - graphicsPath.AddPolygon([.. pointsRelativeToBounds.Select(static point => new PointF((float)point.X, (float)point.Y))]); - graphics.SetClip(graphicsPath); - graphics.DrawImage(sourceBitmap, new Rectangle(0, 0, sourceBitmap.Width, sourceBitmap.Height)); - - return maskedBitmap; - } } diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs index 03eece92..6733ce36 100644 --- a/Text-Grab/Utilities/GrabTemplateExecutor.cs +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -98,7 +98,7 @@ public static async Task ExecuteTemplateAsync( { try { - fullAreaText = await OcrUtilities.GetTextFromAbsoluteRectAsync(captureRegion, resolvedLanguage); + fullAreaText = await OcrSourceUtilities.GetTextFromAbsoluteRectAsync(captureRegion, resolvedLanguage); } catch (Exception) { @@ -166,7 +166,7 @@ public static async Task ExecuteTemplateOnBitmapAsync( using Bitmap regionBitmap = bitmap.Clone( new Rectangle(x, y, width, height), bitmap.PixelFormat); string regionText = OcrUtilities.GetStringFromOcrOutputs( - await OcrUtilities.GetTextFromImageAsync(regionBitmap, resolvedLanguage)); + await OcrSourceUtilities.GetTextFromImageAsync(regionBitmap, resolvedLanguage)); regionResults[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) ? region.DefaultValue : regionText.Trim(); @@ -194,7 +194,7 @@ public static async Task ExecuteTemplateOnBitmapAsync( try { fullAreaText = OcrUtilities.GetStringFromOcrOutputs( - await OcrUtilities.GetTextFromImageAsync(bitmap, resolvedLanguage)); + await OcrSourceUtilities.GetTextFromImageAsync(bitmap, resolvedLanguage)); } catch (Exception) { @@ -358,41 +358,7 @@ public static string ApplyPatternPlaceholders( /// Extracts match values based on the mode string. /// internal static string ExtractMatchesByMode(MatchCollection matches, string mode, string separator) - => ExtractMatchesByMode([.. matches.Select(m => m.Value)], mode, separator); - - /// - /// Selects values from an ordered list according to the mode string - /// ("first", "last", "all", or 1-based indices like "2" / "1,3,5"). - /// Shared with recognizer placeholder/application logic. - /// - internal static string ExtractMatchesByMode(IReadOnlyList allValues, string mode, string separator) - { - if (allValues.Count == 0) - return string.Empty; - - return mode.ToLowerInvariant() switch - { - "first" => allValues[0], - "last" => allValues[^1], - "all" => string.Join(separator, allValues), - _ => ExtractByIndices(allValues, mode, separator) - }; - } - - private static string ExtractByIndices(IReadOnlyList values, string mode, string separator) - { - // mode is either a single index like "2" or comma-separated like "1,3,5" - string[] parts = mode.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - List selected = []; - - foreach (string part in parts) - { - if (int.TryParse(part, out int index) && index >= 1 && index <= values.Count) - selected.Add(values[index - 1]); // convert 1-based to 0-based - } - - return string.Join(separator, selected); - } + => MatchModeSelector.ExtractMatchesByMode([.. matches.Select(m => m.Value)], mode, separator); /// /// Resolves entries to their actual regex strings @@ -574,7 +540,7 @@ private static async Task> OcrAllRegionsAsync( try { // GetTextFromAbsoluteRectAsync uses absolute screen coordinates - string regionText = await OcrUtilities.GetTextFromAbsoluteRectAsync(absoluteRegionRect, language); + string regionText = await OcrSourceUtilities.GetTextFromAbsoluteRectAsync(absoluteRegionRect, language); // Use default value when OCR returns nothing results[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) ? region.DefaultValue diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs index 5c22e241..c71e7d15 100644 --- a/Text-Grab/Utilities/ImageMethods.cs +++ b/Text-Grab/Utilities/ImageMethods.cs @@ -19,24 +19,6 @@ namespace Text_Grab; public static class ImageMethods { - public static Bitmap PadImage(Bitmap image, int minW = 64, int minH = 64) - { - if (image.Height >= minH && image.Width >= minW) - return image; - - int width = Math.Max(image.Width + 16, minW + 16); - int height = Math.Max(image.Height + 16, minH + 16); - - // Create a compatible bitmap - Bitmap destination = new(width, height, image.PixelFormat); - using Graphics gd = Graphics.FromImage(destination); - - gd.Clear(image.GetPixel(0, 0)); - gd.DrawImageUnscaled(image, 8, 8); - - return destination; - } - public static Bitmap BitmapImageToBitmap(BitmapImage bitmapImage) { using MemoryStream outStream = new(); @@ -97,26 +79,10 @@ public static BitmapImage CachedBitmapToBitmapImage(System.Windows.Media.Imaging /// full precision and tone-map it back to SDR so the result isn't washed out (issue #111). /// Falls back to a plain GDI screen copy otherwise or if HDR capture fails. /// - private static Bitmap CaptureScreenRegion(Rectangle region) - { - if (AppUtilities.TextGrabSettings.HdrCaptureCorrection) - { - Bitmap? hdrBitmap = HdrScreenCapture.TryCaptureRegion(region); - if (hdrBitmap is not null) - return hdrBitmap; - } - - Bitmap bmp = new(region.Width, region.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); - using Graphics g = Graphics.FromImage(bmp); - - g.CopyFromScreen(region.Left, region.Top, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); - return bmp; - } - public static Bitmap GetRegionOfScreenAsBitmap(Rectangle region, bool cacheResult = true) { - Bitmap bmp = CaptureScreenRegion(region); - bmp = PadImage(bmp); + Bitmap bmp = BitmapUtilities.CaptureScreenRegion(region); + bmp = BitmapUtilities.PadImage(bmp); if (cacheResult) Singleton.Instance.CacheLastBitmap(bmp); @@ -162,7 +128,7 @@ public static Bitmap GetWindowsBoundsBitmap(Window passedWindow) } Rectangle windowRegion = new(thisCorrectedLeft, thisCorrectedTop, windowWidth, windowHeight); - return CaptureScreenRegion(windowRegion); + return BitmapUtilities.CaptureScreenRegion(windowRegion); } public static ImageSource GetWindowBoundsImage(Window passedWindow) @@ -246,16 +212,6 @@ public static Bitmap BitmapSourceToBitmap(BitmapSource source) }; } - public static Bitmap GetBitmapFromIRandomAccessStream(IRandomAccessStream stream) - { - Stream managedStream = stream.AsStream(); - if (managedStream.CanSeek) - managedStream.Position = 0; - - using Bitmap bitmap = new(managedStream); - return new Bitmap(bitmap); - } - public static BitmapImage GetBitmapImageFromIRandomAccessStream(IRandomAccessStream stream) { BitmapImage bmp = new(); @@ -270,13 +226,6 @@ public static BitmapImage GetBitmapImageFromIRandomAccessStream(IRandomAccessStr return bmp; } - internal static RotateFlipType GetRotateFlipType(string path) - { - using Image img = Image.FromFile(path); - RotateFlipType rotateFlipType = img.GetRotateFlipType(); - return rotateFlipType; - } - internal static void RotateImage(BitmapImage droppedImage, RotateFlipType rotateFlipType) { // Only consider basic rotation for now diff --git a/Text-Grab/Utilities/InputLanguageAccessInitializer.cs b/Text-Grab/Utilities/InputLanguageAccessInitializer.cs new file mode 100644 index 00000000..fc3612b8 --- /dev/null +++ b/Text-Grab/Utilities/InputLanguageAccessInitializer.cs @@ -0,0 +1,34 @@ +using System; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using Text_Grab.Services; + +namespace Text_Grab.Utilities; + +/// +/// Points Text-Grab.Core's at WPF's InputLanguageManager. +/// +internal static class InputLanguageAccessInitializer +{ + /// + /// Same reasoning as : a module initializer rather + /// than a call in App.appStartup, so the Tests host is covered too. + /// + /// The NullReferenceException catch came with the code from LanguageService - the manager + /// throws it from its own internals in some hosts - and stays on this side of the seam, + /// because this is the only side that knows InputLanguageManager exists. + /// + [ModuleInitializer] + internal static void Initialize() + => InputLanguageAccess.SetResolver(static () => + { + try + { + return InputLanguageManager.Current?.CurrentInputLanguage?.Name; + } + catch (NullReferenceException) + { + return null; + } + }); +} diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownFlowDocumentUtilities.cs similarity index 79% rename from Text-Grab/Utilities/MarkdownDocumentUtilities.cs rename to Text-Grab/Utilities/MarkdownFlowDocumentUtilities.cs index 2db413b1..994ca491 100644 --- a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs +++ b/Text-Grab/Utilities/MarkdownFlowDocumentUtilities.cs @@ -1,5 +1,3 @@ -using Markdig; -using Markdig.Extensions.AutoIdentifiers; using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -7,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using System.Text.RegularExpressions; using System.Windows; using System.Windows.Documents; using System.Windows.Media; @@ -25,19 +22,17 @@ namespace Text_Grab.Utilities; -public static partial class MarkdownDocumentUtilities +/// +/// The FlowDocument-bound half of the markdown editor's document model. Split out of +/// (now in Text-Grab.Core) because everything here touches +/// / types, which cannot move to the +/// portable tier. The pure AST-walking/regex/string helpers this class calls +/// (, +/// , etc.) stayed on the original type name +/// and are exposed internal for this class to reach. +/// +public static class MarkdownFlowDocumentUtilities { - private static readonly Regex LiveBlockTriggerRegex = LiveBlockTrigger(); - private static readonly Regex LiveInlinePromotionRegex = LiveInlinePromotion(); - private static readonly Regex MarkdownPatternRegex = MarkdownPattern(); - - private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() - .UseAutoIdentifiers(AutoIdentifierOptions.GitHub) // Must be BEFORE UseAdvancedExtensions to override default - .UseAdvancedExtensions() - .UseYamlFrontMatter() - .UseEmojiAndSmiley(enableSmileys: false) - .Build(); - private enum MarkdownBlockRole { None, @@ -74,7 +69,7 @@ public static FlowDocument CreateFlowDocument(string? markdownText, FontFamily f PagePadding = new Thickness(0) }; - MarkdownDocument markdownDocument = Markdown.Parse(safeMarkdown, MarkdownPipeline); + MarkdownDocument markdownDocument = Markdig.Markdown.Parse(safeMarkdown, MarkdownDocumentUtilities.MarkdownPipeline); foreach (MarkdigBlock block in markdownDocument) AppendBlock(document.Blocks, block, safeMarkdown, quoteDepth: 0); @@ -105,7 +100,7 @@ public static string SerializeToMarkdown(FlowDocument document, bool preserveLit public static string GetDocumentPlainText(FlowDocument document) { ArgumentNullException.ThrowIfNull(document); - return NormalizeDocumentText(new TextRange(document.ContentStart, document.ContentEnd).Text); + return MarkdownDocumentUtilities.NormalizeDocumentText(new TextRange(document.ContentStart, document.ContentEnd).Text); } /// @@ -242,10 +237,6 @@ private static TextPointer MapWithinBlock(IReadOnlyList r /// insertion positions scoped to that one paragraph. Bounded by the paragraph's own size rather /// than the whole document, and — unlike a document-wide walk — correct even inside a table cell. /// - /// - /// Resolves a plain-text offset local to to an actual - /// by walking insertion positions scoped to that one paragraph. - /// /// /// A list item's non-navigable bullet/number marker is a known, narrow exception: WPF's /// counts it as part of the paragraph's rendered text (so the @@ -335,30 +326,6 @@ private static void CollectOffsetMappings(WpfInline inline, Paragraph owningPara } } - public static bool ShouldPromoteLiveBlock(string? lineTextBeforeSpace) - { - if (string.IsNullOrWhiteSpace(lineTextBeforeSpace)) - return false; - - return LiveBlockTriggerRegex.IsMatch(lineTextBeforeSpace); - } - - public static bool LooksLikeMarkdown(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - return false; - - return MarkdownPatternRegex.IsMatch(text); - } - - public static bool ShouldPromoteLiveMarkdown(string? paragraphText) - { - if (string.IsNullOrWhiteSpace(paragraphText)) - return false; - - return LiveInlinePromotionRegex.IsMatch(NormalizeDocumentText(paragraphText)); - } - public static void ApplyTheme(FlowDocument document, FrameworkElement resourceHost, bool isLightTheme) { ArgumentNullException.ThrowIfNull(document); @@ -412,7 +379,7 @@ private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, stri { MarkerStyle = listBlock.IsOrdered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc, Margin = new Thickness(0, 4, 0, 4), - StartIndex = GetOrderedListStart(listBlock), + StartIndex = MarkdownDocumentUtilities.GetOrderedListStart(listBlock), }; SetQuoteDepth(list, quoteDepth); @@ -432,11 +399,11 @@ private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, stri break; case FencedCodeBlock fencedCodeBlock: - blocks.Add(CreateCodeParagraph(GetCodeBlockText(fencedCodeBlock), fencedCodeBlock.Info, quoteDepth, fencedCodeBlock.Span.Start, fencedCodeBlock.Span.End + 1)); + blocks.Add(CreateCodeParagraph(MarkdownDocumentUtilities.GetCodeBlockText(fencedCodeBlock), fencedCodeBlock.Info, quoteDepth, fencedCodeBlock.Span.Start, fencedCodeBlock.Span.End + 1)); break; case CodeBlock codeBlock: - blocks.Add(CreateCodeParagraph(GetCodeBlockText(codeBlock), info: null, quoteDepth, codeBlock.Span.Start, codeBlock.Span.End + 1)); + blocks.Add(CreateCodeParagraph(MarkdownDocumentUtilities.GetCodeBlockText(codeBlock), info: null, quoteDepth, codeBlock.Span.Start, codeBlock.Span.End + 1)); break; case ThematicBreakBlock thematicBreakBlock: @@ -458,7 +425,7 @@ private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, stri break; default: - blocks.Add(CreateLiteralParagraph(GetSourceSlice(source, block), quoteDepth, block.Span.Start, block.Span.End + 1)); + blocks.Add(CreateLiteralParagraph(MarkdownDocumentUtilities.GetSourceSlice(source, block), quoteDepth, block.Span.Start, block.Span.End + 1)); break; } } @@ -555,7 +522,7 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, case LiteralInline literalInline: string literalContent = literalInline.Content.ToString(); Run contentRun = new(literalContent); - (int literalRawStart, int literalRawEnd) = ResolveContentSpan( + (int literalRawStart, int literalRawEnd) = MarkdownDocumentUtilities.ResolveContentSpan( source, literalContent, literalInline.Span.Start, literalInline.Span.End + 1); SetRawSpan(contentRun, literalRawStart, literalRawEnd); inlines.Add(contentRun); @@ -574,7 +541,7 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, // codeInline.Span covers the backtick fence too (e.g. "`dotnet build`"), but // Content is just the inner text ("dotnet build") — tag the content's own raw // range, not the fenced span, so this maps 1:1 instead of proportionally. - int codeContentRawStart = GetCodeSpanContentRawStart(codeInline); + int codeContentRawStart = MarkdownDocumentUtilities.GetCodeSpanContentRawStart(codeInline); SetRawSpan(codeRun, codeContentRawStart, codeContentRawStart + codeInline.Content.Length); inlines.Add(codeRun); break; @@ -620,7 +587,7 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, break; case LinkInline linkInline: - Run literalImageRun = new(GetSourceSlice(source, linkInline)); + Run literalImageRun = new(MarkdownDocumentUtilities.GetSourceSlice(source, linkInline)); SetInlineRole(literalImageRun, MarkdownInlineRole.LiteralMarkdown); SetRawSpan(literalImageRun, linkInline.Span.Start, linkInline.Span.End + 1); inlines.Add(literalImageRun); @@ -640,7 +607,7 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, break; default: - Run literalRun = new(GetSourceSlice(source, inline)); + Run literalRun = new(MarkdownDocumentUtilities.GetSourceSlice(source, inline)); SetInlineRole(literalRun, MarkdownInlineRole.LiteralMarkdown); SetRawSpan(literalRun, inline.Span.Start, inline.Span.End + 1); inlines.Add(literalRun); @@ -672,22 +639,22 @@ private static void WriteBlock(StringBuilder builder, WpfBlock block, int listDe private static void WriteParagraph(StringBuilder builder, Paragraph paragraph, bool preserveLiteralMarkdown) { - string quotePrefix = GetQuotePrefix(GetQuoteDepth(paragraph)); + string quotePrefix = MarkdownDocumentUtilities.GetQuotePrefix(GetQuoteDepth(paragraph)); if (GetBlockRole(paragraph) == MarkdownBlockRole.ThematicBreak) { - builder.Append(ApplyQuotePrefix("---", quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix("---", quotePrefix)); return; } if (GetBlockRole(paragraph) == MarkdownBlockRole.CodeBlock) { string codeInfo = GetCodeFenceInfo(paragraph); - string codeText = NormalizeDocumentText(new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + string codeText = MarkdownDocumentUtilities.NormalizeDocumentText(new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); string fencedBlock = string.IsNullOrWhiteSpace(codeInfo) ? $"```{Environment.NewLine}{codeText}{Environment.NewLine}```" : $"```{codeInfo}{Environment.NewLine}{codeText}{Environment.NewLine}```"; - builder.Append(ApplyQuotePrefix(fencedBlock, quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix(fencedBlock, quotePrefix)); return; } @@ -696,12 +663,12 @@ private static void WriteParagraph(StringBuilder builder, Paragraph paragraph, b if (headingLevel > 0) content = $"{new string('#', headingLevel)} {content}"; - builder.Append(ApplyQuotePrefix(content, quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix(content, quotePrefix)); } private static void WriteList(StringBuilder builder, WpfList list, int listDepth, bool preserveLiteralMarkdown) { - string quotePrefix = GetQuotePrefix(GetQuoteDepth(list)); + string quotePrefix = MarkdownDocumentUtilities.GetQuotePrefix(GetQuoteDepth(list)); bool isOrdered = list.MarkerStyle == TextMarkerStyle.Decimal; int itemIndex = isOrdered ? Math.Max(1, list.StartIndex) : 1; bool isFirstItem = true; @@ -723,34 +690,25 @@ private static void WriteList(StringBuilder builder, WpfList list, int listDepth wroteItemBlock = true; } - string[] itemLines = NormalizeNewlines(itemBuilder.ToString()).Split('\n'); + string[] itemLines = MarkdownDocumentUtilities.NormalizeNewlines(itemBuilder.ToString()).Split('\n'); string indent = new(' ', listDepth * 2); string marker = isOrdered ? $"{itemIndex}. " : "- "; - builder.Append(ApplyQuotePrefix($"{indent}{marker}{itemLines[0]}", quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix($"{indent}{marker}{itemLines[0]}", quotePrefix)); string continuationIndent = $"{indent}{new string(' ', marker.Length)}"; for (int lineIndex = 1; lineIndex < itemLines.Length; lineIndex++) { builder.AppendLine(); - builder.Append(ApplyQuotePrefix($"{continuationIndent}{itemLines[lineIndex]}", quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix($"{continuationIndent}{itemLines[lineIndex]}", quotePrefix)); } itemIndex++; } } - private static int GetOrderedListStart(ListBlock listBlock) - { - return listBlock.IsOrdered - && int.TryParse(listBlock.OrderedStart, out int startIndex) - && startIndex > 0 - ? startIndex - : 1; - } - private static void WriteTable(StringBuilder builder, WpfTable table) { - string quotePrefix = GetQuotePrefix(GetQuoteDepth(table)); + string quotePrefix = MarkdownDocumentUtilities.GetQuotePrefix(GetQuoteDepth(table)); TableRowGroup? firstGroup = table.RowGroups.FirstOrDefault(); if (firstGroup is null || firstGroup.Rows.Count == 0) return; @@ -758,9 +716,9 @@ private static void WriteTable(StringBuilder builder, WpfTable table) List rows = [.. firstGroup.Rows.Cast()]; List headerCells = [.. rows[0].Cells.Cast().Select(SerializeTableCell)]; - builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", headerCells)} |", quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix($"| {string.Join(" | ", headerCells)} |", quotePrefix)); builder.AppendLine(); - builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", Enumerable.Repeat("---", Math.Max(1, headerCells.Count)))} |", quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix($"| {string.Join(" | ", Enumerable.Repeat("---", Math.Max(1, headerCells.Count)))} |", quotePrefix)); IEnumerable dataRows = rows.Count > 1 && rows[0].Cells.Cast().Any(GetIsTableHeader) ? rows.Skip(1) @@ -770,13 +728,13 @@ private static void WriteTable(StringBuilder builder, WpfTable table) { builder.AppendLine(); List rowCells = [.. row.Cells.Cast().Select(SerializeTableCell)]; - builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", rowCells)} |", quotePrefix)); + builder.Append(MarkdownDocumentUtilities.ApplyQuotePrefix($"| {string.Join(" | ", rowCells)} |", quotePrefix)); } } private static string SerializeTableCell(WpfTableCell cell) { - string rawText = NormalizeDocumentText(new TextRange(cell.ContentStart, cell.ContentEnd).Text); + string rawText = MarkdownDocumentUtilities.NormalizeDocumentText(new TextRange(cell.ContentStart, cell.ContentEnd).Text); return rawText .Replace("|", "\\|", StringComparison.Ordinal) .Replace("\n", "
", StringComparison.Ordinal); @@ -803,17 +761,17 @@ private static void WriteInline(StringBuilder builder, WpfInline inline, bool pr builder.Append(GetInlineRole(run) switch { MarkdownInlineRole.TaskListMarker => GetTaskListMarkerChecked(run) ? "[x]" : "[ ]", - MarkdownInlineRole.CodeSpan => $"`{NormalizeDocumentText(run.Text)}`", + MarkdownInlineRole.CodeSpan => $"`{MarkdownDocumentUtilities.NormalizeDocumentText(run.Text)}`", MarkdownInlineRole.LiteralMarkdown => run.Text, _ when preserveLiteralMarkdown => run.Text, - _ => EscapeMarkdownText(run.Text) + _ => MarkdownDocumentUtilities.EscapeMarkdownText(run.Text) }); break; case Hyperlink hyperlink: string linkText = SerializeInlines(hyperlink.Inlines, preserveLiteralMarkdown); string linkTarget = hyperlink.NavigateUri?.OriginalString ?? linkText; - builder.Append($"[{linkText}]({EscapeLinkDestination(linkTarget)})"); + builder.Append($"[{linkText}]({MarkdownDocumentUtilities.EscapeLinkDestination(linkTarget)})"); break; case Bold bold: @@ -830,7 +788,7 @@ private static void WriteInline(StringBuilder builder, WpfInline inline, bool pr case Span span when GetInlineRole(span) == MarkdownInlineRole.CodeSpan: builder.Append('`'); - builder.Append(NormalizeDocumentText(new TextRange(span.ContentStart, span.ContentEnd).Text)); + builder.Append(MarkdownDocumentUtilities.NormalizeDocumentText(new TextRange(span.ContentStart, span.ContentEnd).Text)); builder.Append('`'); break; @@ -981,148 +939,38 @@ private static Brush FindBrush(FrameworkElement resourceHost, string resourceKey }; } - private static string GetCodeBlockText(LeafBlock block) - { - return NormalizeDocumentText(block.Lines.ToString()); - } - private static string SerializeLiteralText(TextElement element, bool preserveLiteralMarkdown) { - string text = NormalizeDocumentText(new TextRange(element.ContentStart, element.ContentEnd).Text); - return preserveLiteralMarkdown ? text : EscapeMarkdownText(text); - } - - private static string EscapeMarkdownText(string? text) - { - if (string.IsNullOrEmpty(text)) - return string.Empty; - - string escapedText = text - .Replace("\\", "\\\\", StringComparison.Ordinal) - .Replace("`", "\\`", StringComparison.Ordinal) - .Replace("*", "\\*", StringComparison.Ordinal) - .Replace("_", "\\_", StringComparison.Ordinal) - .Replace("[", "\\[", StringComparison.Ordinal) - .Replace("]", "\\]", StringComparison.Ordinal) - .Replace("|", "\\|", StringComparison.Ordinal); - - escapedText = Regex.Replace(escapedText, @"^(#{1,6}\s)", @"\$1", RegexOptions.Multiline); - escapedText = Regex.Replace(escapedText, @"^(\s*>+)", @"\$1", RegexOptions.Multiline); - escapedText = Regex.Replace(escapedText, @"^(\s*[-+]\s)", @"\$1", RegexOptions.Multiline); - escapedText = Regex.Replace(escapedText, @"^(\s*\d+\.\s)", @"\$1", RegexOptions.Multiline); - return escapedText; - } - - private static string EscapeLinkDestination(string destination) - { - return destination.Replace(")", "\\)", StringComparison.Ordinal); - } - - private static string ApplyQuotePrefix(string text, string quotePrefix) - { - if (string.IsNullOrEmpty(quotePrefix)) - return text; - - return string.Join( - Environment.NewLine, - NormalizeNewlines(text).Split('\n').Select(line => string.IsNullOrEmpty(line) - ? quotePrefix.TrimEnd() - : $"{quotePrefix}{line}")); - } - - private static string GetQuotePrefix(int quoteDepth) - { - if (quoteDepth <= 0) - return string.Empty; - - StringBuilder builder = new(); - for (int i = 0; i < quoteDepth; i++) - builder.Append("> "); - - return builder.ToString(); - } - - private static string NormalizeDocumentText(string? text) - { - if (string.IsNullOrEmpty(text)) - return string.Empty; - - return NormalizeNewlines(text).TrimEnd('\n'); - } - - private static string NormalizeNewlines(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); - - /// - /// A code span's covers the whole backtick-delimited run (e.g. - /// `dotnet build`), but is just the inner text. Assumes - /// a symmetric fence (equal backtick count on both sides), which covers the vast majority of - /// real-world code spans; degrades to the fenced span if that assumption doesn't hold. - /// - private static int GetCodeSpanContentRawStart(CodeInline codeInline) - { - int totalLength = codeInline.Span.End - codeInline.Span.Start + 1; - int contentLength = codeInline.Content.Length; - int fenceLength = Math.Max(0, (totalLength - contentLength) / 2); - return codeInline.Span.Start + fenceLength; - } - - /// - /// A 's Span is not always tight to its own Content — - /// e.g. inside a pipe table cell, Markdig's reported span includes the cell's padding - /// whitespace ("| Alpha |"'s content is "Alpha" but the span covers " Alpha "), - /// while ordinary paragraph text elsewhere has no such padding and the span is already exact. - /// Searches the reported span's own window for the literal content and returns its tight bounds; - /// falls back to the untrimmed span if the content can't be found there (should not normally happen). - /// - private static (int Start, int End) ResolveContentSpan(string source, string content, int spanStart, int spanEndExclusive) - { - if (string.IsNullOrEmpty(content) || spanStart < 0 || spanEndExclusive > source.Length || spanEndExclusive <= spanStart) - return (spanStart, spanEndExclusive); - - int windowLength = spanEndExclusive - spanStart; - if (content.Length > windowLength) - return (spanStart, spanEndExclusive); - - int found = source.IndexOf(content, spanStart, windowLength, StringComparison.Ordinal); - return found < 0 ? (spanStart, spanEndExclusive) : (found, found + content.Length); - } - - private static string GetSourceSlice(string source, MarkdownObject markdownObject) - { - if (markdownObject.Span.Start < 0 - || markdownObject.Span.End < markdownObject.Span.Start - || markdownObject.Span.End >= source.Length) - return string.Empty; - - return source.Substring(markdownObject.Span.Start, markdownObject.Span.End - markdownObject.Span.Start + 1); + string text = MarkdownDocumentUtilities.NormalizeDocumentText(new TextRange(element.ContentStart, element.ContentEnd).Text); + return preserveLiteralMarkdown ? text : MarkdownDocumentUtilities.EscapeMarkdownText(text); } private static readonly DependencyProperty QuoteDepthProperty = - DependencyProperty.RegisterAttached("QuoteDepth", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(0)); + DependencyProperty.RegisterAttached("QuoteDepth", typeof(int), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(0)); private static readonly DependencyProperty HeadingLevelProperty = - DependencyProperty.RegisterAttached("HeadingLevel", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(0)); + DependencyProperty.RegisterAttached("HeadingLevel", typeof(int), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(0)); private static readonly DependencyProperty BlockRoleProperty = - DependencyProperty.RegisterAttached("BlockRole", typeof(MarkdownBlockRole), typeof(MarkdownDocumentUtilities), new PropertyMetadata(MarkdownBlockRole.None)); + DependencyProperty.RegisterAttached("BlockRole", typeof(MarkdownBlockRole), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(MarkdownBlockRole.None)); private static readonly DependencyProperty InlineRoleProperty = - DependencyProperty.RegisterAttached("InlineRole", typeof(MarkdownInlineRole), typeof(MarkdownDocumentUtilities), new PropertyMetadata(MarkdownInlineRole.None)); + DependencyProperty.RegisterAttached("InlineRole", typeof(MarkdownInlineRole), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(MarkdownInlineRole.None)); private static readonly DependencyProperty TaskListMarkerCheckedProperty = - DependencyProperty.RegisterAttached("TaskListMarkerChecked", typeof(bool), typeof(MarkdownDocumentUtilities), new PropertyMetadata(false)); + DependencyProperty.RegisterAttached("TaskListMarkerChecked", typeof(bool), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(false)); private static readonly DependencyProperty CodeFenceInfoProperty = - DependencyProperty.RegisterAttached("CodeFenceInfo", typeof(string), typeof(MarkdownDocumentUtilities), new PropertyMetadata(string.Empty)); + DependencyProperty.RegisterAttached("CodeFenceInfo", typeof(string), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(string.Empty)); private static readonly DependencyProperty IsTableHeaderProperty = - DependencyProperty.RegisterAttached("IsTableHeader", typeof(bool), typeof(MarkdownDocumentUtilities), new PropertyMetadata(false)); + DependencyProperty.RegisterAttached("IsTableHeader", typeof(bool), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(false)); private static readonly DependencyProperty RawSpanStartProperty = - DependencyProperty.RegisterAttached("RawSpanStart", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(-1)); + DependencyProperty.RegisterAttached("RawSpanStart", typeof(int), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(-1)); private static readonly DependencyProperty RawSpanEndProperty = - DependencyProperty.RegisterAttached("RawSpanEnd", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(-1)); + DependencyProperty.RegisterAttached("RawSpanEnd", typeof(int), typeof(MarkdownFlowDocumentUtilities), new PropertyMetadata(-1)); /// /// Records the [start, end) range in the raw markdown source that a Run's rendered text was @@ -1155,14 +1003,4 @@ private static void SetRawSpan(DependencyObject element, int start, int endExclu private static string GetCodeFenceInfo(DependencyObject element) => (string)element.GetValue(CodeFenceInfoProperty); private static void SetIsTableHeader(DependencyObject element, bool value) => element.SetValue(IsTableHeaderProperty, value); private static bool GetIsTableHeader(DependencyObject element) => (bool)element.GetValue(IsTableHeaderProperty); - - - [GeneratedRegex(@"^\s{0,3}(#{1,6}|>+|[-+*]|\d+[.)])$", RegexOptions.Compiled)] - private static partial Regex LiveBlockTrigger(); - - [GeneratedRegex(@"(^|\s)\[( |x|X)\](\s|$)|(\*\*|__)(?=\S).+?\4|(?+\s|[-+*]\s|\d+[.)]\s|```|~~~|---\s*$|___\s*$|\*\*\*\s*$)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|\n)\|.+\|\s*$", RegexOptions.Multiline | RegexOptions.Compiled)] - private static partial Regex MarkdownPattern(); } diff --git a/Text-Grab/Utilities/NotifyIconUtilities.cs b/Text-Grab/Utilities/NotifyIconUtilities.cs index eadf9cac..5bd371b8 100644 --- a/Text-Grab/Utilities/NotifyIconUtilities.cs +++ b/Text-Grab/Utilities/NotifyIconUtilities.cs @@ -132,7 +132,7 @@ private static void HotKeyManager_HotKeyPressed(object? sender, HotKeyEventArgs case ShortcutKeyActions.PreviousRegionGrab: System.Windows.Application.Current.Dispatcher.Invoke(new Action(() => { - OcrUtilities.GetCopyTextFromPreviousRegion(); + OcrSourceUtilities.GetCopyTextFromPreviousRegion(); })); break; case ShortcutKeyActions.PreviousEditWindow: diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrSourceUtilities.cs similarity index 56% rename from Text-Grab/Utilities/OcrUtilities.cs rename to Text-Grab/Utilities/OcrSourceUtilities.cs index a6621545..58ebf887 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrSourceUtilities.cs @@ -6,7 +6,6 @@ using System.IO; using System.Linq; using System.Text; -using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -26,13 +25,25 @@ namespace Text_Grab.Utilities; -public static partial class OcrUtilities +/// +/// OCR entry points bound to app-side image sources: screen regions, windows, BitmapSource, +/// files and streams - plus engine dispatch. +/// +/// The app-coupled half of what used to be OcrUtilities (batch 4c of the Core split). The text +/// assembly it feeds - line and word joining, furigana filtering, paragraph grouping - moved to +/// Text-Grab.Core.Windows keeping the OcrUtilities name, so the call sites there resolve +/// unchanged; this half took the new name instead. +/// +/// What holds it here: System.Windows.Window and BitmapSource throughout, and engine dispatch +/// via WindowsAiUtilities and LanguageUtilities, neither of which has moved (WindowsAiUtilities +/// is blocked on SoftwareBitmapExtensions in wave 5a). LoadBitmapFromFile stays deferred on its +/// own account - it builds a WPF BitmapImage to apply EXIF rotation, and decoupling it means a +/// GDI+/WIC rewrite, which takes OcrAbsoluteFilePathAsync and OcrFile with it. +/// +public static class OcrSourceUtilities { private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; - // Cache the SpaceJoiningWordRegex to avoid creating it on every method call - private static readonly Regex _cachedSpaceJoiningWordRegex = SpaceJoiningWordRegex(); - private static bool IsUiAutomationLanguage(ILanguage language) => language is UiAutomationLang; private static bool IsWindowsAiDescriptionLanguage(ILanguage language) => language is WindowsAiDescriptionLang; @@ -50,118 +61,6 @@ private static ILanguage GetCompatibleOcrLanguage(ILanguage language) return handle == IntPtr.Zero ? null : [handle]; } - public static void GetTextFromOcrLine( - this IOcrLine ocrLine, - bool isSpaceJoiningOCRLang, - StringBuilder text, - bool shouldCorrectToLatin = true) - { - // (when OCR language is zh or ja) - // matches words in a space-joining language, which contains: - // - one letter that is not in "other letters" (CJK characters are "other letters") - // - one number digit - // - any words longer than one character - // Chinese and Japanese characters are single-character words - // when a word is one punctuation/symbol, join it without spaces - - if (isSpaceJoiningOCRLang) - { - text.AppendLine(ocrLine.Text); - - if (DefaultSettings.CorrectErrors) - text.TryFixEveryWordLetterNumberErrors(); - } - else - { - // For CJK languages, filter out likely furigana (small ruby-text - // characters above the main text) before merging the words. This is - // opt-in via the RemoveFurigana setting. - IEnumerable words = DefaultSettings.RemoveFurigana - ? FilterFurigana([.. ocrLine.Words]) - : ocrLine.Words; - - bool isFirstWord = true; - bool isPrevWordSpaceJoining = false; - - foreach (IOcrWord ocrWord in words) - { - string wordString = ocrWord.Text; - - bool isThisWordSpaceJoining = _cachedSpaceJoiningWordRegex.IsMatch(wordString); - - if (DefaultSettings.CorrectErrors) - wordString = wordString.TryFixNumberLetterErrors(); - - if (isFirstWord || (!isThisWordSpaceJoining && !isPrevWordSpaceJoining)) - _ = text.Append(wordString); - else - _ = text.Append(' ').Append(wordString); - - isFirstWord = false; - isPrevWordSpaceJoining = isThisWordSpaceJoining; - } - } - - if (DefaultSettings.CorrectToLatin && shouldCorrectToLatin) - text.ReplaceGreekOrCyrillicWithLatin(); - } - - /// - /// Removes words that are likely furigana: small ruby-text characters - /// rendered above the main text in Japanese. A word is treated as furigana - /// when it is noticeably shorter than the line's median word height and sits - /// directly above a larger word that overlaps it horizontally. - /// - internal static List FilterFurigana(List words) - { - if (words.Count == 0) - return words; - - // Furigana is typically around half the height of the main text. - List heights = [.. words.Select(w => w.BoundingBox.Height).OrderBy(h => h)]; - double medianHeight = heights[heights.Count / 2]; - double furiganaThreshold = medianHeight * 0.6; - - List filteredWords = []; - - for (int i = 0; i < words.Count; i++) - { - IOcrWord word = words[i]; - bool isProbablyFurigana = false; - - if (word.BoundingBox.Height < furiganaThreshold) - { - // Only treat it as furigana when a larger word sits below it and - // overlaps horizontally (i.e. the kanji it annotates). - for (int j = 0; j < words.Count; j++) - { - if (i == j) - continue; - - IOcrWord otherWord = words[j]; - - bool isBelow = otherWord.BoundingBox.Top > word.BoundingBox.Bottom; - bool overlapsHorizontally = !(otherWord.BoundingBox.Right < word.BoundingBox.Left - || otherWord.BoundingBox.Left > word.BoundingBox.Right); - bool isLarger = otherWord.BoundingBox.Height > furiganaThreshold; - - if (isBelow && overlapsHorizontally && isLarger) - { - isProbablyFurigana = word.Text.Length <= 2; - break; - } - } - } - - if (!isProbablyFurigana) - filteredWords.Add(word); - } - - // If everything was filtered, fall back to the original words to avoid - // dropping the whole line. - return filteredWords.Count > 0 ? filteredWords : words; - } - public static async Task GetTextFromAbsoluteRectAsync( Rect rect, ILanguage language, @@ -179,7 +78,7 @@ public static async Task GetTextFromAbsoluteRectAsync( Bitmap bmp = preCapturedBitmap ?? ImageMethods.GetRegionOfScreenAsBitmap(rect.AsRectangle()); - return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); + return OcrUtilities.GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); } public static async Task GetRegionsTextAsync(Window passedWindow, Rectangle selectedRegion, ILanguage language) @@ -205,13 +104,11 @@ public static async Task GetRegionsTextAsTableAsync(Window passedWindow, using Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(correctedRegion); double scale = await GetIdealScaleFactorForOcrAsync(bmp, compatibleLanguage); using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bmp, scale); - DpiScale dpiScale = VisualTreeHelper.GetDpi(passedWindow); IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, compatibleLanguage); // New model-only flow - List wordBorderInfos = ResultTable.ParseOcrResultIntoWordBorderInfos( + List wordBorderInfos = OcrUtilities.ParseOcrResultIntoWordBorderInfos( ocrResult, - dpiScale, compatibleLanguage.IsLatinBased()); Rectangle rectCanvasSize = new() @@ -240,7 +137,7 @@ public static async Task GetTextFromBitmapAsync(Bitmap bitmap, ILanguage language = GetCompatibleOcrLanguage(language); } - return GetStringFromOcrOutputs(await GetTextFromImageAsync(bitmap, language)); + return OcrUtilities.GetStringFromOcrOutputs(await GetTextFromImageAsync(bitmap, language)); } public static async Task GetTextFromBitmapSourceAsync(BitmapSource bitmapSource, ILanguage language) @@ -255,11 +152,9 @@ public static async Task GetTextFromBitmapAsTableAsync(Bitmap bitmap, IL double scale = await GetIdealScaleFactorForOcrAsync(bitmap, compatibleLanguage); using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bitmap, scale); IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, compatibleLanguage); - DpiScale bitmapDpiScale = new(1.0, 1.0); - List wordBorderInfos = ResultTable.ParseOcrResultIntoWordBorderInfos( + List wordBorderInfos = OcrUtilities.ParseOcrResultIntoWordBorderInfos( ocrResult, - bitmapDpiScale, compatibleLanguage.IsLatinBased()); Rectangle rectCanvasSize = new() @@ -382,14 +277,14 @@ public static async void GetCopyTextFromPreviousRegion() if (!await CanReplayPreviousFullscreenSelection(lastFsg)) return; - Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); + Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor).AsRect(); ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); // Capture the region before showing the loading indicator so the overlay itself // isn't baked into the region's screenshot (issue #662). Bitmap preCapturedBitmap = ImageMethods.GetRegionOfScreenAsBitmap(scaledRect.AsRectangle()); - PreviousGrabWindow previousGrab = new(lastFsg.PositionRect, PreviousGrabIndicator.Loading); + PreviousGrabWindow previousGrab = new(lastFsg.PositionRect.AsRect(), PreviousGrabIndicator.Loading); previousGrab.Show(); try @@ -434,14 +329,14 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio if (!await CanReplayPreviousFullscreenSelection(lastFsg)) return; - Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); + Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor).AsRect(); ILanguage language = lastFsg.OcrLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); // Capture the region before showing the loading indicator so the overlay itself // isn't baked into the region's screenshot (issue #662). Bitmap preCapturedBitmap = ImageMethods.GetRegionOfScreenAsBitmap(scaledRect.AsRectangle()); - PreviousGrabWindow previousGrab = new(lastFsg.PositionRect, PreviousGrabIndicator.Loading); + PreviousGrabWindow previousGrab = new(lastFsg.PositionRect.AsRect(), PreviousGrabIndicator.Loading); previousGrab.Show(); try @@ -478,14 +373,14 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio public static async Task> GetTextFromRandomAccessStream(IRandomAccessStream randomAccessStream, ILanguage language) { - Bitmap bitmap = ImageMethods.GetBitmapFromIRandomAccessStream(randomAccessStream); + Bitmap bitmap = BitmapUtilities.GetBitmapFromIRandomAccessStream(randomAccessStream); List outputs = await GetTextFromImageAsync(bitmap, language); return outputs; } public static async Task> GetTextFromWinAiAsync(Bitmap bitmap, WindowsAiLang language) { - if (ShouldUseParagraphDetection(language.IsSpaceJoining())) + if (OcrUtilities.ShouldUseParagraphDetection(language.IsSpaceJoining())) { WinAiOcrLinesWords? ocrResult = await WindowsAiUtilities.GetOcrResultAsync(bitmap); if (ocrResult is not null) @@ -556,7 +451,7 @@ public static async Task> GetTextFromImageAsync(Bitmap bitmap, I GlobalLang ocrLanguageFromILang = language as GlobalLang ?? new GlobalLang("en-US"); double scale = await GetIdealScaleFactorForOcrAsync(bitmap, ocrLanguageFromILang); using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bitmap, scale); - IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(scaledBitmap, ocrLanguageFromILang); + IOcrLinesWords ocrResult = await OcrSourceUtilities.GetOcrResultFromImageAsync(scaledBitmap, ocrLanguageFromILang); OcrOutput paragraphsOutput = GetTextFromOcrResult(ocrLanguageFromILang, new Bitmap(scaledBitmap), ocrResult); outputs.Add(paragraphsOutput); } @@ -572,304 +467,13 @@ private static OcrOutput GetTextFromOcrResult(ILanguage language, Bitmap? scaled OcrOutput paragraphsOutput = new() { Kind = OcrOutputKind.Paragraph, - RawOutput = BuildTextFromOcrLines(language, ocrResult), + RawOutput = OcrUtilities.BuildTextFromOcrLines(language, ocrResult), Language = language, SourceBitmap = scaledBitmap, }; return paragraphsOutput; } - internal readonly record struct PositionedOcrLine(int LineNumber, string Text, Windows.Foundation.Rect BoundingBox); - - internal sealed class GroupedOcrLines(IReadOnlyList lines, Windows.Foundation.Rect boundingBox) - { - public Windows.Foundation.Rect BoundingBox { get; } = boundingBox; - - public IReadOnlyList Lines { get; } = lines; - - public int StartingLineNumber => Lines.Count == 0 ? 0 : Lines[0].LineNumber; - - public string DisplayText => string.Join(Environment.NewLine, Lines.Select(static line => line.Text.MakeStringSingleLine())); - - public string SingleLineText => string.Join(" ", Lines.Select(static line => line.Text.MakeStringSingleLine()).Where(static text => !string.IsNullOrWhiteSpace(text))); - } - - internal static string BuildTextFromOcrLines(ILanguage language, IOcrLinesWords ocrResult) - { - StringBuilder text = new(); - - bool isSpaceJoiningOCRLang = language.IsSpaceJoining(); - IOcrLine[] lines = ocrResult.Lines; - - if (ShouldUseParagraphDetection(isSpaceJoiningOCRLang) && lines.Length > 0) - { - List groupedLines = - [ - .. GroupWrappedParagraphLines( - [.. lines.Select((line, index) => new PositionedOcrLine(index, line.Text, line.BoundingBox))]) - ]; - - for (int i = 0; i < groupedLines.Count; i++) - { - if (i > 0) - text.AppendLine(); - - text.Append(groupedLines[i].SingleLineText); - } - } - else - { - // Windows OCR returns CJK lines - especially furigana ruby lines and - // stray fragments - in an order that does not follow the page's reading - // flow, so re-sort by geometry (top-to-bottom, then left-to-right) - // before joining. Space-joining languages keep the engine order because - // paragraph detection above already handles their layout. - IReadOnlyList orderedLines = isSpaceJoiningOCRLang - ? lines - : OrderLinesForReadingFlow(lines); - - // Windows OCR emits furigana (Japanese ruby readings) as their own - // short lines sitting directly above the kanji they annotate, so the - // word-level filter above never catches them. Drop those whole lines - // when furigana removal is enabled. - if (!isSpaceJoiningOCRLang && DefaultSettings.RemoveFurigana) - orderedLines = FilterFuriganaLines(orderedLines); - - foreach (IOcrLine ocrLine in orderedLines) - ocrLine.GetTextFromOcrLine(isSpaceJoiningOCRLang, text, language.IsLatinBased()); - } - - if (language.IsRightToLeft()) - text.ReverseWordsForRightToLeft(); - - return text.ToString(); - } - - /// - /// Re-orders OCR lines into natural reading flow: groups lines that share a - /// horizontal row (their vertical extents overlap), orders rows top-to-bottom, - /// and orders the lines within each row left-to-right. Windows OCR frequently - /// returns CJK lines out of order (furigana above kanji, trailing fragments), - /// which scrambles the concatenated text without this pass. - /// - internal static IReadOnlyList OrderLinesForReadingFlow(IReadOnlyList lines) - { - if (lines.Count <= 1) - return lines; - - // Stable sort by the top edge so rows are discovered top-to-bottom. - List byTop = [.. lines.OrderBy(line => line.BoundingBox.Top)]; - - List> rows = []; - double currentRowTop = 0; - double currentRowBottom = 0; - - foreach (IOcrLine line in byTop) - { - Windows.Foundation.Rect box = line.BoundingBox; - - if (rows.Count > 0) - { - double overlap = Math.Min(currentRowBottom, box.Bottom) - Math.Max(currentRowTop, box.Top); - double minHeight = Math.Min(currentRowBottom - currentRowTop, box.Height); - - // A line joins the current row when it overlaps the row's vertical - // band by more than half of the shorter of the two heights. - if (minHeight > 0 && overlap > minHeight * 0.5) - { - rows[^1].Add(line); - currentRowTop = Math.Min(currentRowTop, box.Top); - currentRowBottom = Math.Max(currentRowBottom, box.Bottom); - continue; - } - } - - rows.Add([line]); - currentRowTop = box.Top; - currentRowBottom = box.Bottom; - } - - List ordered = []; - foreach (List row in rows) - ordered.AddRange(row.OrderBy(line => line.BoundingBox.Left)); - - return ordered; - } - - /// - /// Removes whole OCR lines that are likely furigana: short ruby-reading lines - /// that sit directly above a substantially taller line overlapping them - /// horizontally (the kanji they annotate). Windows OCR returns furigana as - /// their own lines, so this complements the word-level . - /// The heuristic is intentionally conservative and geometry-only; it can miss - /// mis-detected readings and is offered as an opt-in, experimental setting. - /// - internal static IReadOnlyList FilterFuriganaLines(IReadOnlyList lines) - { - if (lines.Count < 2) - return lines; - - List kept = []; - - for (int i = 0; i < lines.Count; i++) - { - Windows.Foundation.Rect box = lines[i].BoundingBox; - bool isFurigana = false; - - for (int j = 0; j < lines.Count; j++) - { - if (i == j) - continue; - - Windows.Foundation.Rect other = lines[j].BoundingBox; - - bool isBelow = other.Top >= box.Bottom; - bool overlapsHorizontally = !(other.Right < box.Left || other.Left > box.Right); - // The annotated kanji is markedly taller than its reading. - bool isSubstantiallyTaller = other.Height > box.Height * 1.4; - // Ruby text hugs the top of its character; a large vertical gap - // means these are separate lines of body text, not a reading. - bool isCloseAbove = other.Top - box.Bottom < box.Height; - - if (isBelow && overlapsHorizontally && isSubstantiallyTaller && isCloseAbove) - { - isFurigana = true; - break; - } - } - - if (!isFurigana) - kept.Add(lines[i]); - } - - // Never drop everything - fall back to the input if the heuristic would - // erase the whole result. - return kept.Count > 0 ? kept : lines; - } - - internal static bool ShouldUseParagraphDetection(bool isSpaceJoiningLanguage, bool isTableMode = false) - { - return DefaultSettings.ParagraphDetection && isSpaceJoiningLanguage && !isTableMode; - } - - internal static List GroupWrappedParagraphLines(IReadOnlyList lines) - { - List groupedLines = []; - - if (lines.Count == 0) - return groupedLines; - - List currentGroup = [lines[0]]; - Windows.Foundation.Rect currentBounds = lines[0].BoundingBox; - - for (int i = 1; i < lines.Count; i++) - { - PositionedOcrLine previousLine = currentGroup[^1]; - PositionedOcrLine currentLine = lines[i]; - - if (IsWrappedParagraph( - previousLine.BoundingBox.Y, - previousLine.BoundingBox.Height, - currentLine.BoundingBox.Y, - currentLine.BoundingBox.Height)) - { - currentGroup.Add(currentLine); - currentBounds = UnionRectangles(currentBounds, currentLine.BoundingBox); - continue; - } - - groupedLines.Add(new GroupedOcrLines([.. currentGroup], currentBounds)); - currentGroup = [currentLine]; - currentBounds = currentLine.BoundingBox; - } - - groupedLines.Add(new GroupedOcrLines([.. currentGroup], currentBounds)); - return groupedLines; - } - - private static Windows.Foundation.Rect UnionRectangles(Windows.Foundation.Rect current, Windows.Foundation.Rect next) - { - if (current.IsEmpty) - return next; - - if (next.IsEmpty) - return current; - - double left = Math.Min(current.X, next.X); - double top = Math.Min(current.Y, next.Y); - double right = Math.Max(current.X + current.Width, next.X + next.Width); - double bottom = Math.Max(current.Y + current.Height, next.Y + next.Height); - return new Windows.Foundation.Rect(left, top, right - left, bottom - top); - } - - /// - /// Determines whether two consecutive lines belong to the same wrapped paragraph - /// by comparing the vertical gap between them relative to the average line height. - /// Returns true if the lines should be joined with a space (same paragraph, wrapped), - /// false if they should be separated by a newline (different paragraphs). - /// - internal static bool IsWrappedLine(IOcrLine currentLine, IOcrLine nextLine) - { - if (currentLine.BoundingBox.IsEmpty || nextLine.BoundingBox.IsEmpty) - return false; - - return IsWrappedParagraph( - currentLine.BoundingBox.Y, - currentLine.BoundingBox.Height, - nextLine.BoundingBox.Y, - nextLine.BoundingBox.Height); - } - - /// - /// Core paragraph-wrap heuristic: returns true when the vertical gap between two - /// lines is small enough (less than 60 % of the average line height) that they - /// belong to the same wrapped paragraph, and their heights are similar (ratio ≤ 1.5). - /// Works for any coordinate space — ratios are scale-invariant. - /// - internal static bool IsWrappedParagraph( - double currentTop, double currentHeight, - double nextTop, double nextHeight) - { - if (currentHeight <= 0 || nextHeight <= 0) - return false; - - // Lines with significantly different heights are likely different content blocks - double minHeight = Math.Min(currentHeight, nextHeight); - double maxHeight = Math.Max(currentHeight, nextHeight); - if (maxHeight / minHeight > 1.5) - return false; - - // Consecutive OCR entries must advance to a distinct visual row. Without - // this guard, duplicate or horizontally split entries on the same row have - // a negative gap and are incorrectly merged into a one-line-tall paragraph. - if (nextTop - currentTop < minHeight * 0.5) - return false; - - // If the vertical gap between line bounding boxes is less than 0.6× the average line - // height, the lines are part of the same paragraph (normal line spacing); otherwise - // the extra whitespace signals a paragraph break. - double gap = nextTop - (currentTop + currentHeight); - double avgLineHeight = (currentHeight + nextHeight) / 2.0; - return gap < avgLineHeight * 0.6; - } - - public static string GetStringFromOcrOutputs(List outputs) - { - StringBuilder text = new(); - - foreach (OcrOutput output in outputs) - { - output.CleanOutput(); - - if (!string.IsNullOrWhiteSpace(output.CleanedOutput)) - text.Append(output.CleanedOutput); - else if (!string.IsNullOrWhiteSpace(output.RawOutput)) - text.Append(output.RawOutput); - } - - return text.ToString(); - } - public static async Task OcrAbsoluteFilePathAsync(string absolutePath, ILanguage? language = null) { language ??= LanguageUtilities.GetCurrentInputLanguage(); @@ -881,13 +485,13 @@ public static async Task OcrAbsoluteFilePathAsync(string absolutePath, I } using Bitmap bmp = LoadBitmapFromFile(absolutePath); - return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); + return OcrUtilities.GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); } private static Bitmap LoadBitmapFromFile(string absolutePath) { Uri fileURI = new(absolutePath, UriKind.Absolute); - RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(absolutePath); + RotateFlipType rotateFlipType = BitmapUtilities.GetRotateFlipType(absolutePath); BitmapImage droppedImage = new(); droppedImage.BeginInit(); droppedImage.UriSource = fileURI; @@ -918,7 +522,7 @@ public static async Task GetClickedWordAsync(Window passedWindow, Point private static async Task GetTextFromClickedWordAsync(Point singlePoint, Bitmap bitmap, ILanguage language) { - return GetTextFromClickedWord(singlePoint, await OcrUtilities.GetOcrResultFromImageAsync(bitmap, language)); + return GetTextFromClickedWord(singlePoint, await OcrSourceUtilities.GetOcrResultFromImageAsync(bitmap, language)); } private static string GetTextFromClickedWord(Point singlePoint, IOcrLinesWords ocrResult) @@ -939,7 +543,7 @@ public static async Task GetIdealScaleFactorForOcrAsync(Bitmap bitmap, I return 1.0; selectedLanguage = GetCompatibleOcrLanguage(selectedLanguage); - IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(bitmap, selectedLanguage); + IOcrLinesWords ocrResult = await OcrSourceUtilities.GetOcrResultFromImageAsync(bitmap, selectedLanguage); return GetIdealScaleFactorForOcrResult(ocrResult, bitmap.Height, bitmap.Width); } @@ -980,22 +584,6 @@ private static double GetIdealScaleFactorForOcrResult(IOcrLinesWords ocrResult, return scaleFactor; } - public static Rect GetBoundingRect(this OcrLine ocrLine) - { - double top = ocrLine.Words.Select(x => x.BoundingRect.Top).Min(); - double bottom = ocrLine.Words.Select(x => x.BoundingRect.Bottom).Max(); - double left = ocrLine.Words.Select(x => x.BoundingRect.Left).Min(); - double right = ocrLine.Words.Select(x => x.BoundingRect.Right).Max(); - - return new() - { - X = left, - Y = top, - Width = Math.Abs(right - left), - Height = Math.Abs(bottom - top) - }; - } - public static async Task OcrFile(string path, ILanguage? selectedLanguage, OcrDirectoryOptions options) { StringBuilder returnString = new(); @@ -1042,9 +630,6 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag return returnString.ToString(); } - [GeneratedRegex(@"(^[\p{L}-[\p{Lo}]]|\p{Nd}$)|.{2,}")] - private static partial Regex SpaceJoiningWordRegex(); - private static async Task CanReplayPreviousFullscreenSelection(HistoryInfo history) { if (history.SelectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter) diff --git a/Text-Grab/Utilities/PdfDocumentRenderer.cs b/Text-Grab/Utilities/PdfDocumentRenderer.cs index 3a60d4fa..d067963e 100644 --- a/Text-Grab/Utilities/PdfDocumentRenderer.cs +++ b/Text-Grab/Utilities/PdfDocumentRenderer.cs @@ -417,7 +417,7 @@ private async Task> GetOcrLinesAsync( Func? sourceRectPredicate = null) { using Bitmap bitmap = ImageMethods.BitmapSourceToBitmap(renderedPage); - (IOcrLinesWords? ocrResult, double scale) = await OcrUtilities.GetOcrResultFromBitmapAsync(bitmap, language); + (IOcrLinesWords? ocrResult, double scale) = await OcrSourceUtilities.GetOcrResultFromBitmapAsync(bitmap, language); if (ocrResult is null || ocrResult.Lines.Length == 0) return []; @@ -472,7 +472,7 @@ private static async Task RenderPageBitmapAsync(WinPdfPage page) await page.RenderToStreamAsync(renderedStream, renderOptions); renderedStream.Seek(0); - using Bitmap renderedBitmap = ImageMethods.GetBitmapFromIRandomAccessStream(renderedStream); + using Bitmap renderedBitmap = BitmapUtilities.GetBitmapFromIRandomAccessStream(renderedStream); return ImageMethods.BitmapToImageSource(renderedBitmap); } diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs index bbf6451f..45cf09a9 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -209,7 +209,7 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, PostGr case "WebSearch_Click": string searchStringUrlSafe = WebUtility.UrlEncode(text); - WebSearchUrlModel searcher = Singleton.Instance.DefaultSearcher; + WebSearchUrlModel searcher = Singleton.Instance.DefaultSearcher; Uri searchUri = new($"{searcher.Url}{searchStringUrlSafe}"); _ = await Windows.System.Launcher.LaunchUriAsync(searchUri); // Don't modify the text for web search diff --git a/Text-Grab/Utilities/ProtocolUtilities.cs b/Text-Grab/Utilities/ProtocolHandlerUtilities.cs similarity index 69% rename from Text-Grab/Utilities/ProtocolUtilities.cs rename to Text-Grab/Utilities/ProtocolHandlerUtilities.cs index 27500114..8be68b25 100644 --- a/Text-Grab/Utilities/ProtocolUtilities.cs +++ b/Text-Grab/Utilities/ProtocolHandlerUtilities.cs @@ -7,69 +7,15 @@ namespace Text_Grab.Utilities; /// -/// Utility class for the text-grab:// protocol used by companion apps such as -/// the Text Grab browser extension. The URI is only a command channel; any -/// data payload (like a copied table) travels via the clipboard. -/// Supported URIs: -/// text-grab://paste-spreadsheet Edit Text window in spreadsheet mode, paste clipboard -/// text-grab://edit-text Edit Text window with clipboard text -/// text-grab://grab-frame[?path=...] Grab Frame, optionally opening a local image/PDF -/// text-grab://grab-text?path=... OCR a local image/PDF straight to the clipboard (no window) -/// text-grab://fullscreen Fullscreen grab -/// text-grab://quick-lookup Quick Simple Lookup -/// text-grab://settings Settings window +/// Impure half of the text-grab:// protocol handling that stayed split out of Core in batch 2c: +/// validating a companion app's path= parameter against the filesystem/AutomationProfile, +/// and registering the protocol with the OS. and +/// (the pure URI parsing) live in +/// Text-Grab.Core's under the original name. /// -internal static class ProtocolUtilities +internal static class ProtocolHandlerUtilities { - internal const string Scheme = "text-grab"; - - private const string ProtocolKeyPath = @"Software\Classes\" + Scheme; - - /// - /// Returns true when a startup argument looks like a text-grab:// URI. - /// - internal static bool IsProtocolUri(string? argument) - { - return argument is not null - && argument.StartsWith($"{Scheme}:", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Parses a text-grab:// URI into a lowercase command and its query parameters. - /// Accepts both text-grab://command?key=value and text-grab:command forms. - /// - internal static bool TryParseProtocolUri( - string uriString, - out string command, - out Dictionary parameters) - { - command = string.Empty; - parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (!Uri.TryCreate(uriString, UriKind.Absolute, out Uri? uri) - || !string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase)) - return false; - - // text-grab://paste-spreadsheet puts the command in Host; - // text-grab:paste-spreadsheet puts it in AbsolutePath. - string rawCommand = !string.IsNullOrEmpty(uri.Host) ? uri.Host : uri.AbsolutePath; - command = rawCommand.Trim('/').ToLowerInvariant(); - if (string.IsNullOrEmpty(command)) - return false; - - string query = uri.Query.TrimStart('?'); - foreach (string pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) - { - int separatorIndex = pair.IndexOf('='); - if (separatorIndex <= 0) - continue; - string key = Uri.UnescapeDataString(pair[..separatorIndex]); - string value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]); - parameters[key] = value; - } - - return true; - } + private const string ProtocolKeyPath = @"Software\Classes\" + ProtocolUtilities.Scheme; /// /// Validates a path= parameter supplied via the text-grab:// protocol and, diff --git a/Text-Grab/Utilities/ResultTableRenderer.cs b/Text-Grab/Utilities/ResultTableRenderer.cs new file mode 100644 index 00000000..d8df3e22 --- /dev/null +++ b/Text-Grab/Utilities/ResultTableRenderer.cs @@ -0,0 +1,69 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Text_Grab.Extensions; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +/// +/// Draws the visual grid lines for a onto a WPF . +/// +/// Split out of when that class moved to Text-Grab.Core (batch 4d of +/// the Core split) - the pure clustering algorithm and table state are portable, but this +/// rendering step needs Canvas/Border/SolidColorBrush, which are not. +/// +public static class ResultTableRenderer +{ + public static Canvas BuildTableLines(ResultTable table) + { + Rect boundingRect = table.BoundingRect.AsRect(); + + // Draw the lines and bounds of the table + SolidColorBrush tableColor = new(Color.FromArgb(255, 40, 118, 126)); + + Canvas tableLines = new() + { + Tag = "TableLines" + }; + + Border tableOutline = new() + { + Width = boundingRect.Width, + Height = boundingRect.Height, + BorderThickness = new Thickness(3), + BorderBrush = tableColor + }; + tableLines.Children.Add(tableOutline); + Canvas.SetTop(tableOutline, boundingRect.Y); + Canvas.SetLeft(tableOutline, boundingRect.X); + + foreach (double columnLine in table.ColumnLines) + { + Border vertLine = new() + { + Width = 2, + Height = boundingRect.Height, + Background = tableColor + }; + tableLines.Children.Add(vertLine); + Canvas.SetTop(vertLine, boundingRect.Y); + Canvas.SetLeft(vertLine, columnLine); + } + + foreach (double rowLine in table.RowLines) + { + Border horzLine = new() + { + Height = 2, + Width = boundingRect.Width, + Background = tableColor + }; + tableLines.Children.Add(horzLine); + Canvas.SetTop(horzLine, rowLine); + Canvas.SetLeft(horzLine, boundingRect.X); + } + + return tableLines; + } +} diff --git a/Text-Grab/Utilities/SettingsAccessInitializer.cs b/Text-Grab/Utilities/SettingsAccessInitializer.cs new file mode 100644 index 00000000..18ca92ad --- /dev/null +++ b/Text-Grab/Utilities/SettingsAccessInitializer.cs @@ -0,0 +1,23 @@ +using System.Runtime.CompilerServices; +using Text_Grab.Services; + +namespace Text_Grab.Utilities; + +/// +/// Points Text-Grab.Core's at the app's real settings object. +/// +internal static class SettingsAccessInitializer +{ + /// + /// Runs when the Text-Grab assembly loads, before any of its code executes. A module + /// initializer rather than a call in App.appStartup because the Tests host loads this + /// assembly and exercises its code without ever raising the WPF Startup event - wiring it + /// here means both paths are covered by construction. + /// + /// This only stores the delegate. AppUtilities.TextGrabSettings still resolves lazily on + /// first read, so nothing forces SettingsService to be built at load time. + /// + [ModuleInitializer] + internal static void Initialize() + => SettingsAccess.SetResolver(static () => AppUtilities.TextGrabSettings); +} diff --git a/Text-Grab/Utilities/TesseractHelper.cs b/Text-Grab/Utilities/TesseractHelper.cs deleted file mode 100644 index 9dfdccb6..00000000 --- a/Text-Grab/Utilities/TesseractHelper.cs +++ /dev/null @@ -1,455 +0,0 @@ -using CliWrap; -using CliWrap.Buffered; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Drawing; -using System.Drawing.Imaging; -using System.IO; -using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Text_Grab.Interfaces; -using Text_Grab.Models; -using Text_Grab.Properties; - -namespace Text_Grab.Utilities; - -// Install Tesseract for Windows from UB-Mannheim -// https://github.com/UB-Mannheim/tesseract/wiki - -// Docs about command line usage -// https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html - -// This was developed using Tesseract v5 in 2022 - -public static class TesseractHelper -{ - private const string rawPath = @"%LOCALAPPDATA%\Tesseract-OCR\tesseract.exe"; - private const string rawProgramsPath = @"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"; - private const string basicPath = @"C:\Program Files\Tesseract-OCR\tesseract.exe"; - - private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; - - - public static bool CanLocateTesseractExe() - { - string tesseractPath = string.Empty; - try - { - tesseractPath = GetTesseractPath(); - } - catch (Exception) - { - tesseractPath = string.Empty; -#if DEBUG - throw; -#endif - } - return !string.IsNullOrEmpty(tesseractPath); - } - - private static string GetTesseractPath() - { - if (!string.IsNullOrWhiteSpace(DefaultSettings.TesseractPath) - && File.Exists(DefaultSettings.TesseractPath)) - return DefaultSettings.TesseractPath; - - string tesExePath = Environment.ExpandEnvironmentVariables(rawPath); - string programsPath = Environment.ExpandEnvironmentVariables(rawProgramsPath); - - if (File.Exists(tesExePath)) - { - DefaultSettings.TesseractPath = tesExePath; - DefaultSettings.Save(); - return tesExePath; - } - - if (File.Exists(programsPath)) - { - DefaultSettings.TesseractPath = programsPath; - DefaultSettings.Save(); - return programsPath; - } - - if (File.Exists(basicPath)) - { - DefaultSettings.TesseractPath = basicPath; - DefaultSettings.Save(); - return basicPath; - } - - return string.Empty; - } - - public static async Task GetTextFromImagePathAsync(string imagePath, string tessTag) - { - string tesseractPath = GetTesseractPath(); - - if (string.IsNullOrWhiteSpace(tesseractPath)) - return "Cannot find tesseract.exe"; - - // probably not needed, but if the Windows languages get passed it, it should still work - string languageString = tessTag; - - BufferedCommandResult result = await Cli.Wrap(tesseractPath) - .WithValidation(CommandResultValidation.None) - .WithArguments(args => args - .Add(imagePath) - .Add("-") - .Add("-l") - .Add(languageString) - ) - .ExecuteBufferedAsync(Encoding.UTF8); - - return result.StandardOutput; - } - - public static async Task GetOcrOutputFromBitmap(Bitmap bmp, TessLang language) - { - bmp.Save(TesseractHelper.TempImagePath(), ImageFormat.Png); - - OcrOutput ocrOutput = new() - { - Engine = OcrEngineKind.Tesseract, - Kind = OcrOutputKind.Paragraph, - Language = language, - SourceBitmap = bmp, - RawOutput = await TesseractHelper.GetTextFromImagePathAsync(TempImagePath(), language.RawTag) - }; - ocrOutput.CleanOutput(); - - return ocrOutput; - } - - public static async Task GetTextFromImagePath(string pathToFile, bool outputHocr) - { - string tesExePath = GetTesseractPath(); - - if (string.IsNullOrEmpty(tesExePath)) - return "Cannot find tesseract.exe"; - - string argumentsString = $"\"{pathToFile}\" - -l eng"; - - if (outputHocr) - argumentsString += " hocr"; - - ProcessStartInfo psi = new() - { - FileName = tesExePath, - Arguments = argumentsString, - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardInput = true, - }; - - Process? process = Process.Start(psi); - - if (process is null) - return string.Empty; - - StreamReader sr = process.StandardOutput; - StreamReader errorReader = process.StandardError; - - process.WaitForExit(1000); - - if (process.HasExited) - { - string returningResult = await sr.ReadToEndAsync(); - - if (!string.IsNullOrWhiteSpace(returningResult)) - return returningResult; - - returningResult = await errorReader.ReadToEndAsync(); - - return returningResult; - } - else - return string.Empty; - } - - public static string TempImagePath() - { - if (AutomationProfile.Current is not null) - return Path.Combine(AutomationProfile.GetTemporaryDirectory(), "tempImage.png"); - - string? exePath = Path.GetDirectoryName(System.AppContext.BaseDirectory); - if (exePath is null) - { - string rawPath = @"%LOCALAPPDATA%\Text_Grab"; - exePath = Environment.ExpandEnvironmentVariables(rawPath); - } - - return $"{exePath}\\tempImage.png"; - } - - public static async Task> TesseractLanguagesAsStrings() - { - List languageStrings = new(); - - string tesseractPath = GetTesseractPath(); - - if (string.IsNullOrWhiteSpace(tesseractPath)) - { - languageStrings.Add("eng"); - return languageStrings; - } - - BufferedCommandResult result = await Cli.Wrap(tesseractPath) - .WithValidation(CommandResultValidation.None) - .WithArguments(args => args - .Add("--list-langs") - ).ExecuteBufferedAsync(); - - if (string.IsNullOrWhiteSpace(result.StandardOutput)) - { - languageStrings.Add("eng"); - return languageStrings; - } - - string[] tempList = result.StandardOutput.Split(Environment.NewLine); - - foreach (string item in tempList) - if (item.Length < 30 && !string.IsNullOrWhiteSpace(item) && item != "osd") - languageStrings.Add(item); - - return languageStrings; - } - - public static async Task> TesseractLanguages() - { - List languageStrings = await TesseractLanguagesAsStrings(); - List tesseractLanguages = new(); - - foreach (string language in languageStrings) - tesseractLanguages.Add(new TessLang(language)); - - return tesseractLanguages; - } -} - -public class TesseractGitHubFileDownloader -{ - private readonly HttpClient _client; - - public TesseractGitHubFileDownloader() - { - _client = new HttpClient(); - // It's a good practice to set a user-agent when making requests - _client.DefaultRequestHeaders.Add("User-Agent", "Text Grab settings language downloader"); - } - - public async Task DownloadFileAsync(string filenameToDownload, string localDestination) - { - // Construct the URL to the raw content of the file in the GitHub repository - // https://github.com/tesseract-ocr/tessdata - string fileUrl = $"https://raw.githubusercontent.com/tesseract-ocr/tessdata/main/{filenameToDownload}"; - - try - { - // Send a GET request to the specified URL - HttpResponseMessage response = await _client.GetAsync(fileUrl); - response.EnsureSuccessStatusCode(); - - // Read the response content - byte[] fileContents = await response.Content.ReadAsByteArrayAsync(); - - // Write the content to a file on the local file system - await File.WriteAllBytesAsync(localDestination, fileContents); - Console.WriteLine("File downloaded successfully."); - } - catch (Exception ex) - { - Console.WriteLine($"An error occurred: {ex.Message}"); - } - } - - public static readonly string[] tesseractTrainedDataFileNames = [ - "afr.traineddata", - "amh.traineddata", - "ara.traineddata", - "asm.traineddata", - "aze.traineddata", - "aze_cyrl.traineddata", - "bel.traineddata", - "ben.traineddata", - "bod.traineddata", - "bos.traineddata", - "bre.traineddata", - "bul.traineddata", - "cat.traineddata", - "ceb.traineddata", - "ces.traineddata", - "chi_sim.traineddata", - "chi_sim_vert.traineddata", - "chi_tra.traineddata", - "chi_tra_vert.traineddata", - "chr.traineddata", - "cos.traineddata", - "cym.traineddata", - "dan.traineddata", - "dan_frak.traineddata", - "deu.traineddata", - "deu_frak.traineddata", - "div.traineddata", - "dzo.traineddata", - "ell.traineddata", - "eng.traineddata", - "enm.traineddata", - "epo.traineddata", - "equ.traineddata", - "est.traineddata", - "eus.traineddata", - "fao.traineddata", - "fas.traineddata", - "fil.traineddata", - "fin.traineddata", - "fra.traineddata", - "frk.traineddata", - "frm.traineddata", - "fry.traineddata", - "gla.traineddata", - "gle.traineddata", - "glg.traineddata", - "grc.traineddata", - "guj.traineddata", - "hat.traineddata", - "heb.traineddata", - "hin.traineddata", - "hrv.traineddata", - "hun.traineddata", - "hye.traineddata", - "iku.traineddata", - "ind.traineddata", - "isl.traineddata", - "ita.traineddata", - "ita_old.traineddata", - "jav.traineddata", - "jpn.traineddata", - "jpn_vert.traineddata", - "kan.traineddata", - "kat.traineddata", - "kat_old.traineddata", - "kaz.traineddata", - "khm.traineddata", - "kir.traineddata", - "kmr.traineddata", - "kor.traineddata", - "kor_vert.traineddata", - "lao.traineddata", - "lat.traineddata", - "lav.traineddata", - "lit.traineddata", - "ltz.traineddata", - "mal.traineddata", - "mar.traineddata", - "mkd.traineddata", - "mlt.traineddata", - "mon.traineddata", - "mri.traineddata", - "msa.traineddata", - "mya.traineddata", - "nep.traineddata", - "nld.traineddata", - "nor.traineddata", - "oci.traineddata", - "ori.traineddata", - "osd.traineddata", - "pan.traineddata", - "pol.traineddata", - "por.traineddata", - "pus.traineddata", - "que.traineddata", - "ron.traineddata", - "rus.traineddata", - "san.traineddata", - "sin.traineddata", - "slk.traineddata", - "slk_frak.traineddata", - "slv.traineddata", - "snd.traineddata", - "spa.traineddata", - "spa_old.traineddata", - "sqi.traineddata", - "srp.traineddata", - "srp_latn.traineddata", - "sun.traineddata", - "swa.traineddata", - "swe.traineddata", - "syr.traineddata", - "tam.traineddata", - "tat.traineddata", - "tel.traineddata", - "tgk.traineddata", - "tgl.traineddata", - "tha.traineddata", - "tir.traineddata", - "ton.traineddata", - "tur.traineddata", - "uig.traineddata", - "ukr.traineddata", - "urd.traineddata", - "uzb.traineddata", - "uzb_cyrl.traineddata", - "vie.traineddata", - "yid.traineddata", - "yor.traineddata", - ]; -} - -public class TessOcrLine -{ - public int Height { get; set; } - public string Text { get; set; } = string.Empty; - public int Width { get; set; } - public int X { get; set; } - public int Y { get; set; } -} - -public static class HocrReader -{ - private static readonly string[] separator = [""]; - - public static List ReadLines(string hocrText) - { - // Create a list to hold the OcrLine objects - List lines = new(); - - // Split the hOCR text into lines - string[] hocrLines = hocrText.Split(separator, StringSplitOptions.RemoveEmptyEntries); - - // Iterate through the lines - foreach (string hocrLineText in hocrLines) - { - // Extract the line information - TessOcrLine line = ReadLine(hocrLineText); - - // Add the line to the list - lines.Add(line); - } - - return lines; - } - - private static TessOcrLine ReadLine(string hocrLineText) - { - // Create a new OcrLine object - TessOcrLine line = new(); - - // Extract the text of the line from the hOCR text - Match textMatch = Regex.Match(hocrLineText, "]*>(.*?)"); - line.Text = textMatch.Groups[1].Value; - - // Extract the bounding box coordinates from the hOCR text - Match bboxMatch = Regex.Match(hocrLineText, "bbox (\\d+) (\\d+) (\\d+) (\\d+)"); - line.X = int.Parse(bboxMatch.Groups[1].Value); - line.Y = int.Parse(bboxMatch.Groups[2].Value); - line.Width = int.Parse(bboxMatch.Groups[3].Value); - line.Height = int.Parse(bboxMatch.Groups[4].Value); - - return line; - } -} diff --git a/Text-Grab/Utilities/ThirdPartyNoticeLauncher.cs b/Text-Grab/Utilities/ThirdPartyNoticeLauncher.cs new file mode 100644 index 00000000..d526ff06 --- /dev/null +++ b/Text-Grab/Utilities/ThirdPartyNoticeLauncher.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using System.IO; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +/// +/// Impure half of the third-party notice utilities that stayed split out of Core in batch 2c: +/// resolving notice/license file paths against the running executable's location +/// () and opening them. The pure package catalog +/// () lives in Text-Grab.Core under the +/// original name. +/// +public static class ThirdPartyNoticeLauncher +{ + public static string? GetBuiltWithFilePath() + { + string? executableDirectory = Path.GetDirectoryName(FileUtilities.GetExePath()); + return string.IsNullOrWhiteSpace(executableDirectory) + ? null + : Path.Combine(executableDirectory, ThirdPartyNoticeUtilities.BuiltWithFileName); + } + + public static string? GetNoticesDirectoryPath() + { + string? executableDirectory = Path.GetDirectoryName(FileUtilities.GetExePath()); + return string.IsNullOrWhiteSpace(executableDirectory) + ? null + : Path.Combine(executableDirectory, ThirdPartyNoticeUtilities.NoticesDirectoryName); + } + + public static string? GetNoticeTarget(ThirdPartyPackageInfo package) + { + if (!package.NoticeIsLocal) + return package.NoticeTarget; + + string? executableDirectory = Path.GetDirectoryName(FileUtilities.GetExePath()); + return string.IsNullOrWhiteSpace(executableDirectory) + ? null + : Path.Combine(executableDirectory, package.NoticeTarget); + } + + public static void OpenBuiltWithFile() => OpenTarget(GetBuiltWithFilePath()); + + public static void OpenNoticesDirectory() => OpenTarget(GetNoticesDirectoryPath()); + + public static void OpenNoticeFile(ThirdPartyPackageInfo package) => OpenTarget(GetNoticeTarget(package)); + + public static void OpenProjectUrl(ThirdPartyPackageInfo package) => OpenTarget(package.ProjectUrl); + + private static void OpenTarget(string? target) + { + if (string.IsNullOrWhiteSpace(target)) + return; + + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + } +} diff --git a/Text-Grab/Utilities/TtsEngineAccessInitializer.cs b/Text-Grab/Utilities/TtsEngineAccessInitializer.cs new file mode 100644 index 00000000..5b9dac39 --- /dev/null +++ b/Text-Grab/Utilities/TtsEngineAccessInitializer.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; +using Text_Grab.Services; + +namespace Text_Grab.Utilities; + +/// +/// Points Text-Grab.Core's at the app's WinRT speech engine. +/// +internal static class TtsEngineAccessInitializer +{ + /// + /// Same reasoning as : a module initializer rather + /// than a call in App.appStartup, so the Tests host is covered too. + /// + [ModuleInitializer] + internal static void Initialize() + => TtsEngineAccess.SetResolver(static () => new WindowsSpeechEngine()); +} diff --git a/Text-Grab/Utilities/UiThreadAccessInitializer.cs b/Text-Grab/Utilities/UiThreadAccessInitializer.cs new file mode 100644 index 00000000..c37289a1 --- /dev/null +++ b/Text-Grab/Utilities/UiThreadAccessInitializer.cs @@ -0,0 +1,26 @@ +using System.Runtime.CompilerServices; +using Text_Grab.Services; + +namespace Text_Grab.Utilities; + +/// +/// Points Text-Grab.Core's at the WPF dispatcher. +/// +internal static class UiThreadAccessInitializer +{ + /// + /// Same reasoning as : a module initializer rather + /// than a call in App.appStartup, so the Tests host is covered too. + /// + /// Application.Current is read inside the delegate, not here - at module-load time it + /// is still null. When it is null at call time the post is simply dropped, which is what the + /// code this replaced did with its dispatcher is null check. + /// + [ModuleInitializer] + internal static void Initialize() + => UiThreadAccess.SetPoster(static action => + { + System.Windows.Threading.Dispatcher? dispatcher = System.Windows.Application.Current?.Dispatcher; + _ = dispatcher?.InvokeAsync(action); + }); +} diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index cf4ffefe..d5e24812 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -124,7 +124,7 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow private bool isSyncingTextFromMarkdown = false; private bool isApplyingSpreadsheetLayout = false; private bool isApplyingMarkdownDocument = false; - private MarkdownDocumentUtilities.MarkdownOffsetMap markdownOffsetMap = new([], []); + private MarkdownFlowDocumentUtilities.MarkdownOffsetMap markdownOffsetMap = new([], []); private string? markdownOffsetMapSourceText; private bool isLoadingOpenedFile = false; private bool hasPendingFileEdits = false; @@ -194,7 +194,7 @@ public EditTextWindow(HistoryInfo historyInfo) historyId = historyInfo.ID; - if (historyInfo.PositionRect != Rect.Empty) + if (historyInfo.PositionRect != System.Drawing.RectangleF.Empty) { this.Left = historyInfo.PositionRect.X; this.Top = historyInfo.PositionRect.Y; @@ -1005,11 +1005,11 @@ private void RefreshMarkdownFromText() private void LoadMarkdownDocumentFromText(string? markdownText) { isApplyingMarkdownDocument = true; - MarkdownEditorControl.Document = MarkdownDocumentUtilities.CreateFlowDocument( + MarkdownEditorControl.Document = MarkdownFlowDocumentUtilities.CreateFlowDocument( markdownText, MarkdownEditorControl.FontFamily, MarkdownEditorControl.FontSize); - markdownOffsetMap = MarkdownDocumentUtilities.BuildOffsetMap(MarkdownEditorControl.Document); + markdownOffsetMap = MarkdownFlowDocumentUtilities.BuildOffsetMap(MarkdownEditorControl.Document); markdownOffsetMapSourceText = markdownText ?? string.Empty; ApplyMarkdownTheme(); ApplyMarkdownWrapSetting(); @@ -1023,7 +1023,7 @@ private void SyncMarkdownTextFromDocument() return; isSyncingTextFromMarkdown = true; - PassedTextControl.Text = MarkdownDocumentUtilities.SerializeToMarkdown( + PassedTextControl.Text = MarkdownFlowDocumentUtilities.SerializeToMarkdown( MarkdownEditorControl.Document, preserveLiteralMarkdown: true); isSyncingTextFromMarkdown = false; @@ -1034,7 +1034,7 @@ private void ApplyMarkdownTheme() if (MarkdownEditorControl.Document is null) return; - MarkdownDocumentUtilities.ApplyTheme( + MarkdownFlowDocumentUtilities.ApplyTheme( MarkdownEditorControl.Document, this, SystemThemeUtility.IsLightTheme()); @@ -1544,7 +1544,7 @@ internal static string BuildSpreadsheetSelectionHtml( .Select(cell => dataTable.Rows[cell.RowIndex][cell.ColumnIndex]?.ToString() ?? string.Empty) .ToList())]; - return ClipboardUtilities.BuildCfHtmlTable(rows); + return CfHtmlTableUtilities.BuildCfHtmlTable(rows); } internal static string BuildSpreadsheetSelectionMarkdown( @@ -2522,7 +2522,7 @@ internal async void OpenPath(string pathOfFileToOpen, bool isMultipleFiles = fal } ResetSpreadsheetUndoHistory(); - (string TextContent, OpenContentKind KindOpened) = await IoUtilities.GetContentFromPath(pathOfFileToOpen, isMultipleFiles, selectedILanguage); + (string TextContent, OpenContentKind KindOpened) = await FileOpenUtilities.GetContentFromPath(pathOfFileToOpen, isMultipleFiles, selectedILanguage); bool shouldTrackOpenedFile = KindOpened == OpenContentKind.TextFile && !isMultipleFiles; if (KindOpened == OpenContentKind.TextFile) @@ -3292,7 +3292,7 @@ internal IEnumerable GetSelectedOrAllTextSegmentsForPreview() /// markdown / are measured against — bold /// markers, heading #s, list bullets, link brackets, etc. are stripped on render. The /// index is translated through the offset map built alongside the rendered document - /// () rather than applied directly. + /// () rather than applied directly. /// public void SelectInEditor(int index, int length) { @@ -3304,8 +3304,8 @@ public void SelectInEditor(int index, int length) if (MarkdownEditorControl.Document is not null) { - TextPointer start = MarkdownDocumentUtilities.MapRawOffsetToPosition(MarkdownEditorControl.Document, markdownOffsetMap, index); - TextPointer end = MarkdownDocumentUtilities.MapRawOffsetToPosition(MarkdownEditorControl.Document, markdownOffsetMap, index + length); + TextPointer start = MarkdownFlowDocumentUtilities.MapRawOffsetToPosition(MarkdownEditorControl.Document, markdownOffsetMap, index); + TextPointer end = MarkdownFlowDocumentUtilities.MapRawOffsetToPosition(MarkdownEditorControl.Document, markdownOffsetMap, index + length); MarkdownEditorControl.Selection.Select(start, end); MarkdownEditorControl.Focus(); start.Paragraph?.BringIntoView(); @@ -3493,7 +3493,7 @@ private void EditMenuItem_SubmenuOpened(object sender, RoutedEventArgs e) PopulateTemplateMenu(ApplyGrabTemplateMenuItem, textOnlyTemplates, ApplyGrabTemplateItem_Click); PopulateTemplateMenu(ApplyGrabTemplatePerLineMenuItem, textOnlyTemplates, ApplyGrabTemplatePerLineItem_Click); - List patterns = [.. PatternItem.GetAll()]; + List patterns = [.. PatternItemCatalog.GetAll()]; PopulatePatternMenu( ApplyPatternMenuItem, patterns.Where(pattern => pattern.Kind == PatternKind.SavedRegex), @@ -3872,7 +3872,7 @@ private async void DefaultWebSearchExecuted(object sender, ExecutedRoutedEventAr string possibleSearch = PassedTextControl.SelectedText; string searchStringUrlSafe = WebUtility.UrlEncode(possibleSearch); - WebSearchUrlModel searcher = Singleton.Instance.DefaultSearcher; + WebSearchUrlModel searcher = Singleton.Instance.DefaultSearcher; Uri searchUri = new($"{searcher.Url}{searchStringUrlSafe}"); _ = await Windows.System.Launcher.LaunchUriAsync(searchUri); @@ -4404,7 +4404,7 @@ await Parallel.ForEachAsync(ocrFileResults, parallelOptions, async (ocrFile, ct) { ct.ThrowIfCancellationRequested(); - ocrFile.OcrResult = await OcrUtilities.OcrFile(ocrFile.FilePath, selectedLanguage, options); + ocrFile.OcrResult = await OcrSourceUtilities.OcrFile(ocrFile.FilePath, selectedLanguage, options); // to get the TextBox to update whenever OCR Finishes: if (!options.WriteTxtFiles) @@ -4715,8 +4715,8 @@ private void MarkdownEditorControl_Pasting(object sender, DataObjectPastingEvent bool shouldParseAsMarkdown = MarkdownDocumentUtilities.LooksLikeMarkdown(pastedText); int selectionStartOffset = GetMarkdownPlainTextOffset(MarkdownEditorControl.Selection.Start); int renderedPasteLength = shouldParseAsMarkdown - ? MarkdownDocumentUtilities.GetDocumentPlainText( - MarkdownDocumentUtilities.CreateFlowDocument( + ? MarkdownFlowDocumentUtilities.GetDocumentPlainText( + MarkdownFlowDocumentUtilities.CreateFlowDocument( pastedText, MarkdownEditorControl.FontFamily, MarkdownEditorControl.FontSize)).Length @@ -4867,7 +4867,7 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul { RandomAccessStreamReference streamReference = await dataPackageView.GetBitmapAsync(); using IRandomAccessStream stream = await streamReference.OpenReadAsync(); - List outputs = await OcrUtilities.GetTextFromRandomAccessStream(stream, LanguageUtilities.GetOCRLanguage()); + List outputs = await OcrSourceUtilities.GetTextFromRandomAccessStream(stream, LanguageUtilities.GetOCRLanguage()); string text = OcrUtilities.GetStringFromOcrOutputs(outputs); System.Windows.Application.Current.Dispatcher.Invoke(new Action(() => { AddCopiedTextToTextBox(text); })); @@ -4891,7 +4891,7 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul continue; using IRandomAccessStream stream = await storageFile.OpenAsync(FileAccessMode.Read); - List outputs = await OcrUtilities.GetTextFromRandomAccessStream(stream, LanguageUtilities.GetOCRLanguage()); + List outputs = await OcrSourceUtilities.GetTextFromRandomAccessStream(stream, LanguageUtilities.GetOCRLanguage()); string text = OcrUtilities.GetStringFromOcrOutputs(outputs); System.Windows.Application.Current.Dispatcher.Invoke(new Action(() => { AddCopiedTextToTextBox(text); })); @@ -4913,7 +4913,7 @@ private async void PreviousRegion_Click(object sender, RoutedEventArgs e) HistoryService hs = Singleton.Instance; if (hs.HasAnyFullscreenHistory()) - await OcrUtilities.GetTextFromPreviousFullscreenRegion(PassedTextControl); + await OcrSourceUtilities.GetTextFromPreviousFullscreenRegion(PassedTextControl); } private async void RateAndReview_Click(object sender, RoutedEventArgs e) @@ -5509,7 +5509,7 @@ private void SetupRoutedCommands() _ = newWindowWithSelectionCommand.InputGestures.Add(new KeyGesture(Key.N, ModifierKeys.Control)); _ = CommandBindings.Add(new CommandBinding(newWindowWithSelectionCommand, NewWindowWithText_Clicked)); - List searchers = Singleton.Instance.WebSearchers; + List searchers = Singleton.Instance.WebSearchers; foreach (WebSearchUrlModel searcher in searchers) { @@ -5740,7 +5740,7 @@ private void UpdateLineAndColumnText() string plainText = MarkdownEditorControl.Document is null ? string.Empty - : MarkdownDocumentUtilities.GetDocumentPlainText(MarkdownEditorControl.Document); + : MarkdownFlowDocumentUtilities.GetDocumentPlainText(MarkdownEditorControl.Document); string selectedText = MarkdownEditorControl.Selection.Text.TrimEnd('\r', '\n'); BottomBarText.Text = string.IsNullOrEmpty(selectedText) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index 2e0af306..602948fa 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -847,7 +847,8 @@ private FullscreenCaptureResult CreateRectangleSelectionResult(FsgSelectionStyle List relativePoints = [.. devicePoints.Select(point => new Point(point.X - deviceBounds.X, point.Y - deviceBounds.Y))]; using Bitmap rawBitmap = ImageMethods.GetRegionOfScreenAsBitmap(absoluteCaptureRect.AsRectangle(), cacheResult: false); - Bitmap maskedBitmap = FreeformCaptureUtilities.CreateMaskedBitmap(rawBitmap, relativePoints); + List relativePointsF = [.. relativePoints.Select(point => point.AsPointF())]; + Bitmap maskedBitmap = BitmapMaskUtilities.CreateMaskedBitmap(rawBitmap, relativePointsF); Singleton.Instance.CacheLastBitmap(maskedBitmap); BitmapSource captureImage = ImageMethods.BitmapToImageSource(maskedBitmap); @@ -1265,28 +1266,28 @@ private async Task CommitSelectionCoreAsync(FullscreenCaptureResult select Math.Round(clickedPointForOcr.X), Math.Round(clickedPointForOcr.Y)); - TextFromOCR = await OcrUtilities.GetClickedWordAsync(this, clickedPointForOcr, selectedOcrLang); + TextFromOCR = await OcrSourceUtilities.GetClickedWordAsync(this, clickedPointForOcr, selectedOcrLang); } else if (selectedOcrLang is UiAutomationLang) { - TextFromOCR = await OcrUtilities.GetTextFromAbsoluteRectAsync(selection.CaptureRegion, selectedOcrLang, excludedHandles); + TextFromOCR = await OcrSourceUtilities.GetTextFromAbsoluteRectAsync(selection.CaptureRegion, selectedOcrLang, excludedHandles); } else if (selection.CapturedImage is not null) { TextFromOCR = isTable - ? await OcrUtilities.GetTextFromBitmapSourceAsTableAsync(selection.CapturedImage, selectedOcrLang) - : await OcrUtilities.GetTextFromBitmapSourceAsync(selection.CapturedImage, selectedOcrLang); + ? await OcrSourceUtilities.GetTextFromBitmapSourceAsTableAsync(selection.CapturedImage, selectedOcrLang) + : await OcrSourceUtilities.GetTextFromBitmapSourceAsync(selection.CapturedImage, selectedOcrLang); } else if (isTable) { // TODO: Look into why this happens and find a better way to dispose the bitmap // DO NOT add a using statement to this selected bitmap, it crashes the app Bitmap selectionBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle()); - TextFromOCR = await OcrUtilities.GetTextFromBitmapAsTableAsync(selectionBitmap, selectedOcrLang); + TextFromOCR = await OcrSourceUtilities.GetTextFromBitmapAsTableAsync(selectionBitmap, selectedOcrLang); } else { - TextFromOCR = await OcrUtilities.GetTextFromAbsoluteRectAsync(selection.CaptureRegion, selectedOcrLang, excludedHandles); + TextFromOCR = await OcrSourceUtilities.GetTextFromAbsoluteRectAsync(selection.CaptureRegion, selectedOcrLang, excludedHandles); } } catch (Exception ex) @@ -1327,7 +1328,7 @@ private async Task FinishCommitWithTextAsync(FullscreenCaptureResult selec LanguageKind = languageKind, UsedUiAutomation = usedUiAutomation, CaptureDateTime = DateTimeOffset.Now, - PositionRect = GetHistoryPositionRect(selection), + PositionRect = GetHistoryPositionRect(selection).AsRectangleF(), IsTable = TableToggleButton.IsChecked!.Value, TextContent = TextFromOCR ?? string.Empty, ImageContent = historyBitmap, diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 104e623f..11ef299a 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -280,7 +280,7 @@ private async Task LoadTemplateForEditing(GrabTemplate template) foreach (TemplateRegion region in template.Regions.OrderBy(r => r.RegionNumber)) { - Rect abs = region.ToAbsoluteRect(cw, ch); + Rect abs = region.ToAbsoluteRect(cw, ch).AsRect(); WordBorder wb = new() { @@ -370,7 +370,7 @@ private async Task LoadContentFromHistory(HistoryInfo history) if (wbInfoList.Count < 1) NotifyIfUiAutomationNeedsLiveSource(currentLanguage); - if (history.PositionRect != Rect.Empty) + if (history.PositionRect != System.Drawing.RectangleF.Empty) { Left = history.PositionRect.Left; Top = history.PositionRect.Top; @@ -481,12 +481,12 @@ private void ScaleHistoryWordBordersToCanvas(HistoryInfo history, List 0) info.DisplayLineHeight *= scaleY; @@ -507,8 +507,8 @@ private Size GetSavedHistoryContentSize(HistoryInfo history) return new Size(imageContentBitmap.Width, imageContentBitmap.Height); } - Rect positionRect = history.PositionRect; - if (positionRect == Rect.Empty || positionRect.Width <= 0 || positionRect.Height <= 0) + System.Drawing.RectangleF positionRect = history.PositionRect; + if (positionRect == System.Drawing.RectangleF.Empty || positionRect.Width <= 0 || positionRect.Height <= 0) return new Size(0, 0); if (history.SourceMode == TextGrabMode.Fullscreen) @@ -740,7 +740,7 @@ private bool TryGetTablePlacementBounds(out Rect tableBounds) if (AnalyzedResultTable is null) _ = TryToPlaceTable(); - tableBounds = AnalyzedResultTable?.BoundingRect ?? Rect.Empty; + tableBounds = AnalyzedResultTable?.BoundingRect.AsRect() ?? Rect.Empty; return tableBounds != Rect.Empty && tableBounds.Width > 0 && tableBounds.Height > 0; @@ -1217,7 +1217,7 @@ public HistoryInfo AsHistoryItem() List wbInfoList = []; foreach (WordBorder wb in wordBorders) - wbInfoList.Add(new WordBorderInfo(wb)); + wbInfoList.Add(WordBorderInfoFactory.Create(wb)); string? wbInfoJson = null; if (wbInfoList.Count > 0) @@ -1262,7 +1262,7 @@ public HistoryInfo AsHistoryItem() WordBorderInfoJson = wbInfoJson, WordBorderInfoFileName = wbInfoJson is null ? null : historyItem?.WordBorderInfoFileName, ImageContent = bitmap, - PositionRect = sizePosRect, + PositionRect = sizePosRect.AsRectangleF(), IsTable = TableToggleButton.IsChecked!.Value, ManualTableColumnSeparators = tableEditState.ManualColumnSeparators.Count > 0 ? [.. tableEditState.ManualColumnSeparators] : null, ManualTableRowSeparators = tableEditState.ManualRowSeparators.Count > 0 ? [.. tableEditState.ManualRowSeparators] : null, @@ -1540,7 +1540,7 @@ public void MergeSelectedWordBorders() DpiScale dpi = VisualTreeHelper.GetDpi(this); // Build merged content via model-only ResultTable - List selInfos = [.. selectedWordBorders.Select(wb => new WordBorderInfo(wb))]; + List selInfos = [.. selectedWordBorders.Select(wb => WordBorderInfoFactory.Create(wb))]; ResultTable tmp = new(); tmp.AnalyzeAsTable(selInfos, new System.Drawing.Rectangle(0, 0, (int)ActualWidth, (int)ActualHeight)); StringBuilder sb = new(); @@ -1714,7 +1714,7 @@ private async void AddNewWordBorder(Border selectBorder) rect = new(rect.X + 4, rect.Y, (rect.Width * dpi.DpiScaleX) + 10, rect.Height * dpi.DpiScaleY); // Language language = CurrentLanguage.AsLanguage() ?? LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US"); ILanguage language = CurrentLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); - string ocrText = await OcrUtilities.GetTextFromAbsoluteRectAsync( + string ocrText = await OcrSourceUtilities.GetTextFromAbsoluteRectAsync( rect.GetScaleSizeByFraction(viewBoxZoomFactor), language, GetUiAutomationExcludedHandles()); @@ -2345,11 +2345,11 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") if (frameContentImageSource is BitmapSource frozenBmp) { using System.Drawing.Bitmap bmpForOcr = ImageMethods.BitmapSourceToBitmap(frozenBmp); - (ocrResultOfWindow, windowFrameImageScale) = await OcrUtilities.GetOcrResultFromBitmapAsync(bmpForOcr, CurrentLanguage); + (ocrResultOfWindow, windowFrameImageScale) = await OcrSourceUtilities.GetOcrResultFromBitmapAsync(bmpForOcr, CurrentLanguage); } else { - (ocrResultOfWindow, windowFrameImageScale) = await OcrUtilities.GetOcrResultFromRegionAsync(rectCanvasSize, CurrentLanguage); + (ocrResultOfWindow, windowFrameImageScale) = await OcrSourceUtilities.GetOcrResultFromRegionAsync(rectCanvasSize, CurrentLanguage); } } @@ -4775,7 +4775,7 @@ private void UpdateTemplatePickerItems() // Pattern items — saved regexes and built-in recognizers as one "Patterns" concept, // split into "Saved Patterns" / "Smart Patterns" subsections. - items.AddRange(PatternItem.GetAll().Select(TextOnlyTemplateDialog.InlinePickerItemFor)); + items.AddRange(PatternItemCatalog.GetAll().Select(TextOnlyTemplateDialog.InlinePickerItemFor)); TemplateOutputBox.ItemsSource = items; @@ -4870,7 +4870,7 @@ private async Task TryLoadImageFromPath(string path) droppedImage.BeginInit(); droppedImage.UriSource = fileURI; droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle - System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path); + System.Drawing.RotateFlipType rotateFlipType = BitmapUtilities.GetRotateFlipType(path); ImageMethods.RotateImage(droppedImage, rotateFlipType); droppedImage.EndInit(); frameContentImageSource = droppedImage; @@ -4968,7 +4968,7 @@ private List TryToPlaceTable() { RemoveTableLines(); - List wbInfos = [.. wordBorders.Select(wb => new WordBorderInfo(wb))]; + List wbInfos = [.. wordBorders.Select(wb => WordBorderInfoFactory.Create(wb))]; if (wbInfos.Count == 0) { AnalyzedResultTable = null; @@ -4997,8 +4997,7 @@ private List TryToPlaceTable() tableEditState.SetManualSeparators( AnalyzedResultTable.ManualRowSeparators, AnalyzedResultTable.ManualColumnSeparators); - if (AnalyzedResultTable.TableLines is not null) - RectanglesCanvas.Children.Add(AnalyzedResultTable.TableLines); + RectanglesCanvas.Children.Add(ResultTableRenderer.BuildTableLines(AnalyzedResultTable)); } catch (Exception ex) { diff --git a/Text-Grab/Views/LicensesWindow.xaml.cs b/Text-Grab/Views/LicensesWindow.xaml.cs index deb099d8..4903de95 100644 --- a/Text-Grab/Views/LicensesWindow.xaml.cs +++ b/Text-Grab/Views/LicensesWindow.xaml.cs @@ -19,23 +19,23 @@ public LicensesWindow() private void BuiltWithButton_Click(object sender, RoutedEventArgs e) { - ThirdPartyNoticeUtilities.OpenBuiltWithFile(); + ThirdPartyNoticeLauncher.OpenBuiltWithFile(); } private void NoticesFolderButton_Click(object sender, RoutedEventArgs e) { - ThirdPartyNoticeUtilities.OpenNoticesDirectory(); + ThirdPartyNoticeLauncher.OpenNoticesDirectory(); } private void NoticeButton_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement { DataContext: ThirdPartyPackageInfo package }) - ThirdPartyNoticeUtilities.OpenNoticeFile(package); + ThirdPartyNoticeLauncher.OpenNoticeFile(package); } private void ProjectButton_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement { DataContext: ThirdPartyPackageInfo package }) - ThirdPartyNoticeUtilities.OpenProjectUrl(package); + ThirdPartyNoticeLauncher.OpenProjectUrl(package); } } diff --git a/docs/Configuring-LAF-Environment-Variables.md b/docs/Configuring-LAF-Environment-Variables.md index 41608fc6..04d84c78 100644 --- a/docs/Configuring-LAF-Environment-Variables.md +++ b/docs/Configuring-LAF-Environment-Variables.md @@ -42,9 +42,11 @@ setx LAF_TOKEN "" setx LAF_PUBLISHER_ID "" ``` -`Text-Grab.csproj` defaults the `LafToken` / `LafPublisherId` MSBuild properties from these -environment variables, so ordinary `dotnet build` and Visual Studio builds bake the token in without -any extra flags. +`Text-Grab.Core.Windows.csproj` defaults the `LafToken` / `LafPublisherId` MSBuild properties from +these environment variables, so ordinary `dotnet build` and Visual Studio builds bake the token in +without any extra flags. Building `Text-Grab.csproj` (or the wapproj) still works the same way: its +`ProjectReference` to `Text-Grab.Core.Windows.csproj` carries the same global MSBuild properties +into that project's build. ## Explicit build-time injection diff --git a/docs/Core-Split-Plan.md b/docs/Core-Split-Plan.md new file mode 100644 index 00000000..6fe9a4de --- /dev/null +++ b/docs/Core-Split-Plan.md @@ -0,0 +1,1111 @@ +# Core Split Plan + +Reorganizing Text-Grab from one 178-file WPF app into a layered set of projects: + +``` +Text-Grab.Core net10.0 pure logic, no UI, no Windows + ^ +Text-Grab.Core.Windows net10.0-windows10.0.22621.0 WinRT / GDI+ / P-Invoke, UseWPF=false + ^ +Text-Grab net10.0-windows... WPF app Views, Controls, Pages, app wiring +``` + +Test projects mirror the same tiers: `Tests.Core` (net10.0, fast), `Tests.Core.Windows` +(net10.0-windows, headless), `Tests` (net10.0-windows, WPF/STA, references the app). + +Phase 0 (scaffolding), the first six move commits, and Wave 0 (foundations) are done — see +`git log --oneline` from `288f6d1` forward. This document is the plan for the rest and +the standing contract for every agent that works on it. + +**Section 4's file lists were rebuilt from five parallel reconnaissance passes** that read every +candidate file end-to-end. They supersede the original lists, which were derived from grepping +`using` directives and were wrong in roughly a dozen places (§4.0). + +--- + +## 1. Invariants — every agent must follow these + +1. **All three projects share `RootNamespace = Text_Grab`.** A moved file keeps its + `namespace` line unchanged. A move is `git mv` plus fixing only what actually breaks. + Never "tidy" namespaces during a move. +2. **`Text-Grab.Core.Windows` keeps `UseWPF=false` and `UseWindowsForms=false`.** If a file + needs `System.Windows.*` or `System.Windows.Forms.*`, it either stays in the app or gets + split. Do not flip these flags to make a move work. +3. **Dependencies point one way only:** app → Core.Windows → Core. Core never references + Core.Windows; neither library ever references the app. +4. **One batch = one commit.** Do not start the next batch until the current one builds. +5. **Defer, don't redesign.** If a file in your list turns out to be blocked by something + outside your list, leave it, record it in §7 (Deferred ledger) with the specific blocker, + and move on. Do not expand scope to unblock it. +6. **Never run two edit-capable agents against this working tree at once.** Moves touch + shared files (`.csproj`, call sites, `Enums.cs`); concurrent edits corrupt each other. + Reconnaissance agents (read-only) may run in parallel; movers run serially. +7. Commit messages follow the established style: what moved, what had to be fixed and why, + what was deferred and its specific blocker. See `edefeaa` and `e677b54` for the pattern. +8. **Never classify a file by its `using` directives.** Check which *types* it actually uses. + The original wave lists in this document were built by grepping usings and were wrong about + a dozen files in both directions — see §4.0. The four traps, all of which bit that pass: + - A file with no `System.Windows` using can still be WPF-bound: `using Text_Grab.Controls;` + reaches `WordBorder`, which *is* a WPF `Control`. + - A fully-qualified type never appears in a using at all + (`Wpf.Ui.Controls.SymbolRegular` in `LookupItem`). + - `System.Drawing` is two different things. Primitives (`RectangleF`, `PointF`, `SizeF`, + `Color`) are portable and fine in **Core**; GDI+ (`Bitmap`, `Graphics`, `Icon`) is + Windows-only and belongs in **Core.Windows**. + - `Rect` is two different things. `Windows.Foundation.Rect` is WinRT and fine in + Core.Windows; `System.Windows.Rect` is WindowsBase and is not. `edefeaa` already hit + this once. + +## 2. Verification gates — exact commands + +```bash +# Primary gate. Builds Core -> Core.Windows -> app -> Tests. ~13s incremental. +dotnet build Tests/Tests.csproj -c Debug -p:Platform=x64 + +# Fast library gate, run first while iterating. +dotnet build Tests.Core/Tests.Core.csproj -c Debug + +# Wave boundaries. Run the two fast ones first - they need no display and finish in ~1s each. +dotnet test --project Tests.Core/Tests.Core.csproj +dotnet test --project Tests.Core.Windows/Tests.Core.Windows.csproj -p:Platform=x64 +dotnet test --project Tests/Tests.csproj -r win-x64 # slow, spawns STA/WPF tests +``` + +`Tests.Core.Windows` needs `-p:Platform=x64` (or another named platform). Without it the +Windows App SDK targets, pulled in transitively via `Text-Grab.Core.Windows`, fail with +`WindowsAppSDKSelfContained requires a supported Windows architecture`. + +**Do not run `dotnet build Text-Grab.sln`.** The MSIX `Text-Grab-Package.wapproj` fails +under the dotnet CLI with `MSB4019: Microsoft.DesktopBridge.props was not found` — it needs +full MSBuild / Visual Studio. That error is pre-existing and unrelated to this work; the +per-project builds above are the real gate. + +Also note: `Text-Grab.Core` and `Text-Grab.Core.Windows` must keep +`win-x86;win-x64;win-arm64`. The wapproj restores +project references per-RID and drops `NETSDK1047` without them. + +## 3. Wave 0 — the three foundation decisions + +These gate almost everything else and are design work, not mechanical moves. **Do these +yourself (Opus), serially, before dispatching any Sonnet mover.** + +### B1 — Settings access + +48 app files reach settings through `AppUtilities.TextGrabSettings`, which returns +`Text_Grab.Properties.Settings` — an `internal sealed partial class : ApplicationSettingsBase` +with 104 generated properties. Only **28 distinct properties** are actually read through that +accessor, so the real coupling surface is small. + +**Done in `8398af2`:** `Text-Grab.Core/Interfaces/ITextGrabSettings.cs` declares the slice +portable code may read; `Text-Grab.Core/Services/SettingsAccess.cs` resolves it. Core code reads +`SettingsAccess.Current.CorrectToLatin` instead of `AppUtilities.TextGrabSettings.CorrectToLatin`. + +The app implements the interface by *declaring* it — the generated properties already match on +name and type, and `Save()` comes from `ApplicationSettingsBase`, so there is no forwarding code: + +```csharp +// Text-Grab/Properties/Settings.cs — existing hand-written partial +[SettingsProvider(typeof(AutomationSettingsProvider))] +internal sealed partial class Settings : ITextGrabSettings { } +``` + +`SettingsAccess` holds a `Func` rather than an instance, because the app's +settings object hangs off `Singleton.Instance`, which is lazy and does real work +on first touch. The app registers it from a `[ModuleInitializer]`, not from `App.appStartup`: the +`Tests` host loads the app assembly and runs its code without ever raising the WPF Startup event. + +**Seeded properties** (from the actual near-term consumers, not guessed): `CorrectErrors`, +`CorrectToLatin`, `ParagraphDetection`, `RemoveFurigana`, `TryToReadBarcodes`, +`UiAutomationFallbackToOcr`, `UseTesseract`, `TesseractPath`, `LastUsedLang`, `Save()`. + +**Adding a property:** add it to the interface. If the build then fails, the missing property +belongs in `Settings.settings` — do not write a forwarding property in the partial. If one move +would need more than a handful of new members, use the façade split instead. + +**Prefer the cheaper alternative for leaf cases:** when a file has exactly one settings +touchpoint, split the pure part into Core and leave a thin settings-reading façade in the app. +That is what `e677b54` did with `PatternItem` / `PatternItemCatalog`, and it stays the right +move for one-off cases. Use the interface when a file has many touchpoints or the façade +would be larger than the thing it wraps. + +### B2 — Geometry currency + +`System.Windows.Rect` / `Point` / `Size` live in `WindowsBase.dll`, which only comes with +`UseWPF=true`. They appear in ~30 otherwise-portable files and are the single most common +blocker after settings. + +**Done in `30af90f`:** `System.Drawing.RectangleF` / `PointF` / `SizeF` are the geometry type in +Core and Core.Windows. These are in **System.Drawing.Primitives**, part of the shared framework — +genuinely cross-platform, needs no Windows TFM and no package reference. +`Text-Grab.Core/Utilities/RectangleFExtensions.cs` carries the portable helpers (`IsGood`, +`CenterPoint`, `GetScaledUpByFraction`, `GetScaleSizeByFraction`, `Union`); the app's existing +`Extensions/ShapeExtensions.cs` gained the boundary conversions (`AsRect` / `AsRectangleF`, +`AsPoint` / `AsPointF`, `AsSize` / `AsSizeF`) alongside the `Rectangle` ↔ `Rect` pair it already +had. View code converts at the edge. + +> Important distinction agents keep getting wrong: `System.Drawing.Primitives` (Rectangle, +> RectangleF, Point, PointF, Size, SizeF, Color) is portable and fine in **Core**. +> `System.Drawing.Common` (Bitmap, Graphics, Icon, BitmapData) is Windows-only and belongs in +> **Core.Windows**. A `using System.Drawing;` alone tells you nothing — check which types. + +### B3 — Imaging currency + +Movable code juggles four bitmap representations. The rule: + +| Type | Assembly | Allowed in | +|---|---|---| +| `System.Drawing.Bitmap` | System.Drawing.Common | Core.Windows, app | +| `Windows.Graphics.Imaging.SoftwareBitmap` | WinRT | Core.Windows, app | +| `ImageMagick.MagickImage` | Magick.NET | Core.Windows, app | +| `System.Windows.Media.Imaging.BitmapSource` | WPF | **app only** | +| `byte[]` / `Stream` | — | Core | + +`BitmapSource` never crosses out of the app. Core (pure) traffics in `byte[]`/`Stream`. + +Note that `Magick.NET` splits the same way its consumers do: `Magick.NET.SystemDrawing` is a +plain net8.0 library and is Core.Windows-eligible, while `Magick.NET.SystemWindowsMedia` is +WPF-only and must stay in the app. + +### B4 — UI Automation: the `FrameworkReference` loophole is closed + +`System.Windows.Automation` (`UIAutomationClient.dll`) lives in the WindowsDesktop shared +framework. It can be resolved with `` +*without* setting `UseWPF=true`, which is a real gap in the wording of invariant 2. + +**Decision: do not take it.** That reference also drags in `WindowsBase`, which puts +`System.Windows.Rect` back within reach of Core.Windows and quietly defeats B2. The one file this +affects, `UIAutomationUtilities.cs`, stays in the app — its type surface is four never-move models +deep, which is not worth weakening the tier boundary to move one file. + +`Tests.Core.Windows/TierBoundaryTests.cs` already enforces this: it checks referenced assembly +*names*, and `WindowsBase` is on its list, so the loophole fails the build rather than passing +review. + +### Wave 0 batches — **done** + +| Batch | Work | Commit | +|---|---|---| +| 0a | B2 geometry currency | `30af90f` | +| 0b | B1 settings seam | `8398af2` | +| 0c | Test scaffolding, tier guards, CI | this commit | + +Three things came out differently than planned above; the text has been corrected to match +what was actually built: + +- **0a extended `ShapeExtensions` instead of adding `WpfGeometryExtensions`.** That file already + carried `Rectangle` ↔ `Rect`, so the conversions belonged next to it rather than in a parallel + API. Core got `RectangleFExtensions` mirroring the portable helpers. +- **0b registers the resolver from a `[ModuleInitializer]`, not `App.appStartup`.** The `Tests` + host loads the app assembly and exercises its code without ever raising the WPF Startup event, + so wiring it at startup would have left every app-referencing test unable to read settings. +- **0c added `Tests.Core.Windows/TierBoundaryTests.cs`**, which was not in the original plan. + It asserts by reflection that Core references no WPF/WinRT assembly, that Core.Windows + references no WPF assembly, and that Core does not reference Core.Windows. This is the + automated enforcement of invariants 2 and 3 — cheaper than catching a `UseWPF` flip in review. + +## 4. Wave plan + +### 4.0 What reconnaissance changed + +Five read-only passes read every candidate file end-to-end. The corrections that matter: + +**Moved *out* of the wave lists (now never-move):** + +| File | Why the original list was wrong | +|---|---| +| `UndoRedoOperations/UndoRedo.cs`, `ChangeWord.cs`, `ResizeWordBorder.cs` | Hold `WordBorder` fields and construct WPF operation classes. No `System.Windows` using — they reach WPF through `using Text_Grab.Controls;`. | +| `Models/LookupItem.cs` | `Wpf.Ui.Controls.SymbolRegular UiSymbol`, fully qualified, so no using revealed it. | +| `Utilities/ImplementAppOptions.cs` | Filed under Windows AI; is actually app-lifecycle plumbing that casts to the WPF `App` and calls the never-move `NotifyIconUtilities`. | +| `Utilities/MagickHelpers.cs` | Every public signature is `ImageSource`→`ImageSource`; its `Magick.NET.SystemWindowsMedia` package is WPF-only. | +| `Utilities/CameraCaptureUtilities.cs` | Both failure paths show `Wpf.Ui.Controls.MessageBox`; entry point needs a WPF `Window` for its `hwnd`. | +| `Utilities/SettingsImportExportUtilities.cs` | Orchestration glue over three other services; reflects over the *entire* settings surface by design. | +| `Utilities/DiagnosticsUtilities.cs` | Reads ~70 settings properties — 14× the façade threshold — and aggregates every deferred subsystem. | + +**Moved *into* the wave lists (the never-move list was written before B2 landed):** + +| File | Why it can move now | +|---|---| +| `Models/WordBorderInfo.cs` | Already the portable projection of the `WordBorder` control — flattening it to data is the class's whole job. Its only WPF tie is `Rect BorderRect` → `RectangleF`. The `WordBorderInfo(WordBorder)` constructor stays in the app as a factory. **This unblocks `ResultTable`'s clustering algorithm.** | +| `Models/TemplateRegion.cs` | Same shape: WPF-bound only through `ToAbsoluteRect`/`FromAbsoluteRect` returning `System.Windows.Rect`. **This unblocks `GrabTemplate` and `OcrDirectoryOptions`.** | + +**Re-scoped:** +- `Utilities/PdfDocumentRenderer.cs` moves from Wave 4 to Wave 5. Its blocker is the + `BitmapSource` currency (`RenderPageAsync` *returns* one) — identical to `ImageMethods`, not + OCR-specific. +- `Utilities/AutomationProfile.cs` and `AutomationSettingsProvider.cs` go to **plain Core**, not + Core.Windows. Verified empirically: with a `System.Configuration.ConfigurationManager` package + reference, `ApplicationSettingsBase` / `LocalFileSettingsProvider` resolve *and run* on plain + net10.0. `AutomationProfile` needs one mechanical change — widen + `ApplySeed(Properties.Settings)` to `ApplySeed(ApplicationSettingsBase)`; every member it + touches is on the base class. `AutomationSettingsProvider` needs no changes at all. +- `Utilities/Hdr/HdrToneMapper.cs` goes to **plain Core** — it uses nothing but `System`. + +**Diagnoses corrected:** +- `ResultTable`'s `OcrResult` coupling is *dead code*, not a blocker. Its live path already + consumes the portable `IOcrLinesWords`. The real blocker was `WordBorderInfo` — now resolved. +- `TesseractHelper`'s settings write-back needs no redesign. `ITextGrabSettings.Save()` already + covers it. Its actual blocker is `AutomationProfile`, via one method (`TempImagePath`). +- `Utilities/Hdr/*` was recorded as "mostly clean already". It is not: `HdrScreenCapture.cs:472` + reaches `System.Windows.Application.Current?.Dispatcher` to pump a consent dialog. + +### 4.1 Wave 1 — shared leaves (do this first) + +**This batch did not exist in the original plan and is now the highest-leverage work in it.** +Reconnaissance found the same handful of tiny files blocking Waves 3, 5 and 6 independently. +Until they land, every later batch hits the same wall: Core.Windows cannot reach back into the +app, so a leaf left behind blocks everything above it. + +**1a — shared leaves → Core** (all verified dependency-free): +`Utilities/Singleton.cs`, `Utilities/StreamWrapper.cs` (class `WrappingStream`), +`Models/NullAsyncResult.cs` (`StreamWrapper` constructs it — same commit), +`Utilities/Json.cs` (namespace is `Text_Grab.Helpers`, not `.Utilities` — keep it), +`Utilities/IoUtilities.cs` *(pure string-list half only; the class also has WinForms and +Wpf.Ui `MessageBox` calls that stay behind)*. + +**1b — packaging identity → Core.Windows.** Extract `AppUtilities.IsPackaged()` and +`GetAppVersion()` into `Text-Grab.Core.Windows/Utilities/PackageIdentity.cs`. Both need only +`Windows.ApplicationModel.Package`. They are unreachable today purely because they share a class +with `TextGrabSettings`/`TextGrabSettingsService`, which must stay in the app. Leave `AppUtilities` +forwarding so the ~dozens of existing call sites need not all change at once. + +`StorageFileExtensions.cs` → Core.Windows also belongs here rather than in Wave 3 — it is +dependency-free and `SoftwareBitmapExtensions` needs it. + +**Why this ordering matters:** 1a+1b unblocks, at minimum, `SettingsStorageExtensions` (3b), +`SoftwareBitmapExtensions` (5), `FileAssociationUtilities` (3a), `WinAiLanguageModel` (3c), +`FileUtilities` (5), and `CameraCaptureUtilities`'s `IsPackaged` call. + +### 4.2 Wave 2 — pure leaves → Core + +**2a — Enums.** Merge all 17 enums from `Text-Grab/Enums.cs` into `Text-Grab.Core/Enums.cs`. +Verified: all 17 are plain int/short-backed with no attributes, and there are **zero** name +collisions with Core's existing two. Do this early — it is a shared file every later batch may +otherwise touch. + +**2b — Models (11 files, verified clean):** `AsyncOcrFileResult`, `EditTextTableDocument`, +`ExtractedPattern`, `FindResult` (calls a static on `EditTextTableDocument` — same commit, that +order), `GrabFrameTableEditState`, `GrabFrameWordGroupingMode`, `SpreadsheetUndoHistory`, +`TemplatePatternMatch`, `TemplateRecognizerMatch`, `ThirdPartyPackageInfo`. + +**2c — Utilities / Extensions / Interfaces (9 files):** `PatternExecutor` then +`ColumnSplitUtilities` (which calls it — same commit, that order); `NumericUtilities`, +`LanguageHeuristics`, `Extensions/NumberExtensions`, `Extensions/StringBuilderExtensions`, +`Interfaces/ITtsEngine`. + +Two files here need a **split**, not a move: +- `ProtocolUtilities` — only `IsProtocolUri` and `TryParseProtocolUri` are pure. The rest needs + Registry + `AutomationProfile` + `FileUtilities`. +- `ThirdPartyNoticeUtilities` — only `Packages` and the constants are pure. The `Get*Path`/`Open*` + methods need `FileUtilities.GetExePath()`. All three of its existing tests touch only + `Packages`, so the test moves with the pure half. + +**2d — geometry conversions.** Convert `WordBorderInfo.BorderRect` and +`TemplateRegion.ToAbsoluteRect`/`FromAbsoluteRect` to `RectangleF`, move both to Core, leave the +`WordBorderInfo(WordBorder)` factory in the app. Then `GrabTemplate` and `OcrDirectoryOptions` +follow. This is the batch that pays off B2. + +**2e — CalculationService.** All three files, ~2000 lines, fully pure (one call into +`NumericUtilities`). **Move** the `NCalcAsync` and `UnitsNet` package references to +`Text-Grab.Core.csproj` — verified they are used nowhere else in the app. Leave `Tests.csproj`'s +own `NCalcAsync` reference alone. Move `CalculatorTests` and `UnitConversionTests` to `Tests.Core` +in the same commit. + +**2f — Markdown split.** ~150 pure lines out of 1168. Pure half (Markdig AST + regex + string): +`LooksLikeMarkdown`, `ShouldPromoteLiveBlock`, `ShouldPromoteLiveMarkdown`, `NormalizeDocumentText`, +`NormalizeNewlines`, `EscapeMarkdownText`, `EscapeLinkDestination`, `ApplyQuotePrefix`, +`GetQuotePrefix`, `GetOrderedListStart`, `ResolveContentSpan`, `GetSourceSlice`, +`GetCodeSpanContentRawStart`, `GetCodeBlockText`, the `MarkdownPipeline` field and the three +`[GeneratedRegex]` methods. Everything touching `FlowDocument`/`System.Windows.Documents` stays. +The shared string helpers must become `internal`/`public` for the app half to call them. **Markdig +stays referenced by both halves** — the app side pattern-matches on Markdig types directly; verify +transitivity before removing the app's reference. Extract the 6 pure test methods into +`Tests.Core/MarkdownParsingTests.cs`. + +### 4.3 Wave 3 — Windows leaves → Core.Windows + +**3a — eight files, zero blockers, one commit.** `NativeMethods.cs`, `RegistryMonitor.cs` +(namespace is `RegistryUtils`, vendored — keep it), `OSInterop.cs`, +`DesktopNotificationManagerCompat.cs`, `Models/GeneratedOcrLinesWords.cs` (uses +`Windows.Foundation.Rect` — WinRT, not a B2 blocker), `Models/UiAutomationLang.cs`, +`Models/WindowsAiLang.cs`, `Models/WindowsAiDescriptionLang.cs`. + +> `OSInterop.cs` is 1292 lines and was recorded as probably app-bound. `System.Windows.Forms` +> appears **once** in the whole file — in a `GetAsyncKeyState(Keys)` overload with zero callers. +> The only live caller uses the `int` overload. **Delete the dead overload and the file moves.** + +**3b — after Wave 1.** `Extensions/SettingsStorageExtensions.cs` (namespace is `Text_Grab.Helpers` +— keep it; needs `Json.cs` from 1a). `Utilities/LimitedAccessFeatureUtilities.cs` (zero deps — +move first in the WinAI chain). + +**3c — Windows AI chain, in this order:** `WinAiLanguageModel.cs` (needs `PackageIdentity` from 1b, +`OSInterop` from 3a, `LimitedAccessFeatureUtilities` from 3b), then `WinAiTranslator.cs` and +`WinAiMeetingNotes.cs` (both need only `WinAiLanguageModel`). + +**3d — `Extensions/LanguageExtensions.cs` split.** `XmlLanguage` comes from `PresentationCore` +and the path is reachable in production from `GrabFrame`, `EditTextWindow` and `OcrUtilities` — +not a dead using. Move `IsSpaceJoining` (both overloads), `IsLatinBased`, `AsLanguage`, +`AsILanguage`; leave `IsRightToLeft(this Language)` and the `GlobalLang` branch of the `ILanguage` +overload in a thin app-side façade. + +### 4.4 Wave 4 — OCR pipeline + +**4a — `OcrOutput` → `BarcodeUtilities`, a move-now pair.** `OcrOutput.CleanOutput()` needs a +two-line swap to `SettingsAccess.Current` (`CorrectToLatin`, `CorrectErrors` — both already on the +interface) and the `is not Settings userSettings` cast dropped. `BarcodeUtilities` follows +immediately; add `ZXing.Net.Bindings.Windows.Compatibility` to Core.Windows. + +**4b — `TesseractHelper`.** Blocked on `AutomationProfile` (via `TempImagePath` only) — so this +follows Wave 6a. Add `CliWrap` to Core.Windows. `TesseractGitHubFileDownloader` in the same file is +fully portable and could go to plain Core. + +**4c — the `OcrUtilities` split. Opus owns this; do it as its own commit.** +`DefaultSettings` reads exactly six properties, all already on `ITextGrabSettings` — verified, no +seventh. The hidden WPF dependency is `LoadBitmapFromFile` (lines 887–899), which builds a +`BitmapImage` to apply EXIF rotation; decoupling it means a real rewrite against GDI+/WIC, so +**defer that method and its two callers** (`OcrAbsoluteFilePathAsync`, `OcrFile`) rather than +attempt it inside 4c. + +**Ordering constraint the original plan missed: 4c cannot precede Wave 3.** `OcrEngine.cs` will +not compile in Core.Windows until `WindowsAiLang`, `WindowsAiDescriptionLang`, `UiAutomationLang` +(3a), `WinAiLanguageModel` (3c) and `AutomationProfile` (6a) have landed. If those are not ready, +move only the strictly portable subset — furigana filtering, paragraph-wrap heuristics, +`BuildTextFromOcrLines`, `GetStringFromOcrOutputs`, `GetTextFromOcrLine` — and leave engine +dispatch behind. + +**Blocker created by batch 3d - RESOLVED in 4c.** `BuildTextFromOcrLines` calls +`language.IsRightToLeft()`, and 3d had left both `IsRightToLeft` overloads in the app as +`LanguageRtlExtensions` because `XmlLanguage` comes from PresentationCore. + +Option 1 was taken, after settling the behaviour question empirically rather than by reasoning: a +throwaway WPF probe compared `XmlLanguage.GetLanguage(tag).GetEquivalentCulture().TextInfo +.IsRightToLeft` against `CultureInfo.GetCultureInfo(tag).TextInfo.IsRightToLeft` across 24 tags - +`ar`, `ar-EG`, `ar-SA`, `he`, `he-IL`, `ur`, `ur-PK`, `fa`, `fa-IR`, `ckb`, `ps-AF`, `sd-Arab-PK`, +`yi`, `he-Hebr-IL`, `ar-XX`, `en`, `en-US`, `ja`, `zh-Hans`, `de-DE`, and the unresolvable `xx`, +`xx-YY`, `und` and `""`. They agreed on every one, including the tags with subtags and the ones +neither can resolve. + +So the `ILanguage` overload moved into Core.Windows `LanguageExtensions` with a `CultureInfo` +lookup in its `GlobalLang` branch, guarded by a `CultureNotFoundException` catch returning false +(XmlLanguage fell back to the invariant culture, which is LTR). That left the `Language` overload +with **zero** call sites - all five live `IsRightToLeft` calls are on `ILanguage` - so +`Extensions/LanguageRtlExtensions.cs` was deleted outright. 3d's facade is gone. + +**4c as executed.** The split went the direction the call-site census pointed: the portable text +assembly - `GetTextFromOcrLine`, `FilterFurigana`, `FilterFuriganaLines`, `OrderLinesForReadingFlow`, +`BuildTextFromOcrLines`, `ShouldUseParagraphDetection`, `GroupWrappedParagraphLines`, `IsWrappedLine`, +`IsWrappedParagraph`, `GetStringFromOcrOutputs`, `ParseOcrResultIntoWordBorderInfos`, and the nested +`PositionedOcrLine`/`GroupedOcrLines` - moved to Core.Windows **keeping the `OcrUtilities` name**, +because `Tests/OcrTests.cs` alone held 43 of the file's ~80 references and every one of them is +against that subset. The app-coupled half - capture, engine dispatch, file and `BitmapSource` +sources - took the new name `OcrSourceUtilities`. `GetBoundingRect(this OcrLine)` was deleted as +section 8 dead code. Engine dispatch stayed behind as the ordering note above predicted: +`WindowsAiUtilities` and `LanguageUtilities` have not moved. + +`Tests/OcrTests.cs` is the heaviest consumer of the headless surface and becomes a +`Tests.Core.Windows` candidate in Wave 7. It references the nested `PositionedOcrLine` / +`GroupedOcrLines` types by name; both halves keep `Text_Grab.Utilities`, so it stays green. + +**4d — `ResultTable`.** Unblocked by 2d. Delete the dead code first (see §9), then move the +clustering algorithm. + +**4e — language chain, strictly ordered, and hard-blocked at the end.** +`CaptureLanguageUtilities` → `LanguageUtilities` → `LanguageService`. The first two are pure +forwarders and move for free once the third does. `LanguageService` has a genuine blocker: +`System.Windows.Input.InputLanguageManager`, with no portable substitute. It also needs +`UiAutomationEnabled` and `WindowsAiDescriptionEnabled` added to `ITextGrabSettings`. **Route to +Opus** — the workable split is to extract the pure `switch`-expression helpers (`GetLanguageTag`, +`GetLanguageKind`, `GetPersistedLanguageIdentity`, `NormalizePersistedLanguageIdentity`) and leave +the input-language reader in the app. + +**Ordering constraint found while preparing 4c: 4e cannot precede 5a.** Reading `LanguageService` +in full, `InputLanguageManager` appears in exactly one place - the private +`GetCurrentInputLanguageTag()` - and everything else in the class is WinRT, which is legal in +Core.Windows. That makes the better split the whole class moving under its own name with the +input-language read behind a resolver the app registers (the `SettingsAccess` shape), defaulting +to `CultureInfo.CurrentUICulture.Name` when none is registered. But `GetAllLanguages()` and +`GetOCRLanguage()` both call `WindowsAiUtilities.CanDeviceUseWinAI()`, and `WindowsAiUtilities` is +still app-side, deferred on `SoftwareBitmapExtensions` - which is 5a. Run 4e after wave 5. + +**4e as executed, after wave 5.** `WindowsAiUtilities` moved first - its three blockers were all +gone (`AutomationProfile` in 6a, `SoftwareBitmapExtensions` in 5a, and `OverrideAiArchCheck` added +to `ITextGrabSettings` here). Its one remaining app call, `AppUtilities.IsPackaged()`, is a plain +forwarder to `PackageIdentity.IsPackaged()`, which has been in Core.Windows since 1b. + +That cleared the way for the whole language chain to move to Core.Windows **unsplit** - +`LanguageService`, `LanguageUtilities`, `CaptureLanguageUtilities`, all keeping their names, so +none of their 155 call sites needed an edit. `UiAutomationEnabled` and `WindowsAiDescriptionEnabled` +joined `ITextGrabSettings` as the table predicted. `Singleton` was already in Core. + +The `InputLanguageManager` blocker became `Text-Grab.Core/Services/InputLanguageAccess.cs`, the +third instance of the delegate-resolver shape after `SettingsAccess` and `UiThreadAccess`. The +`NullReferenceException` catch that guarded the read stayed on the app side of the seam, inside the +registered resolver, since that is the only side that knows InputLanguageManager exists. A null tag +- no resolver, or no input language - still falls through to `CultureInfo.CurrentUICulture` and then +to en-US, exactly as before. Extracting the switch helpers, which the paragraph above proposed, +turned out to be unnecessary. + +### 4.5 Wave 5 — capture and imaging → Core.Windows + +**5a — move-now (after Wave 1):** +- `Utilities/Hdr/HdrToneMapper.cs` → **plain Core** (nothing but `System`). +- `Utilities/Hdr/DisplayHdrInfo.cs` → Core.Windows; add `Vortice.Direct3D11`, `Vortice.DXGI`. +- `Extensions/ImageExtensions.cs` → Core.Windows (pure GDI+; `ExifRotate` is dead — see §9). +- `Utilities/ImageChangeDetector.cs` → Core.Windows; add `Magick.NET-Q16-AnyCPU`, + `Magick.NET.SystemDrawing`. +- `Models/DragDataObject.cs` → Core.Windows, after deleting its dead `BitmapSourceToBitmap` (§9). +- `Extensions/SoftwareBitmapExtensions.cs` → Core.Windows (needs `StorageFileExtensions` and + `WrappingStream` from Wave 1). + +**5b — `HdrScreenCapture.cs`.** The D3D11/DXGI/WinRT pipeline is clean. Two blockers: add +`HdrBorderlessGranted` to `ITextGrabSettings`, and extract the `Application.Current.Dispatcher` +hop at line 472 behind a settable hook the app wires up at startup — it exists to pump a one-time +OS consent dialog and is load-bearing. + +**5b as executed.** Both blockers cleared. `HdrBorderlessGranted` and `HdrCaptureCorrection` were +added to `ITextGrabSettings`; both already existed in `Settings.settings`, so neither needed a +`.settings` edit. The `Application.Current.Dispatcher` hop became +`Text-Grab.Core/Services/UiThreadAccess.cs` - the same delegate-resolver shape as `SettingsAccess`, +registered from an app-side `[ModuleInitializer]` so the Tests host is covered without an +`App.appStartup` call. `TryPost` returning false is exactly the old `dispatcher is null` branch, +and `_borderlessRequestStarted` is still set before the post either way, so a process with no UI +thread does not re-request on every capture. + +With `HdrScreenCapture` in Core.Windows, 5c's deferred `CaptureScreenRegion` moved as well - into +`BitmapUtilities` as `internal`, since its only two callers (`GetRegionOfScreenAsBitmap`, +`GetWindowsBoundsBitmap`) stay in the app and Core.Windows already grants `InternalsVisibleTo` +to it. That row is out of section 7. + +**5c — `ImageMethods.cs` split.** Headless half (→ Core.Windows): `PadImage`, +`CaptureScreenRegion`, `GetBitmapFromIRandomAccessStream`, `GetRotateFlipType(string)`. Everything +touching `BitmapImage`/`BitmapSource`/`CachedBitmap`/`InteropBitmap`/`Window`/`ImageSource` stays. +Add `HdrCaptureCorrection` to `ITextGrabSettings`. **`GetRegionOfScreenAsBitmap` stays behind for +now** — it calls `Singleton.Instance.CacheLastBitmap`, and inverting that call is a +redesign (invariant 5). `GetWindowsBoundsBitmap` is permanently app-bound; it pattern-matches on +the `GrabFrame` *View*. + +**5d — `ClipboardUtilities.cs` split.** Larger than expected in the right direction: ~330 of 464 +lines are a pure CF_HTML table parser with no clipboard, WPF, WinRT or GDI+ dependency → +**plain Core** as `Utilities/CfHtmlTableUtilities.cs`. The clipboard-touching methods stay. +Separately, line 64's `System.Windows.Forms.DataFormats.Bitmap` is the file's only WinForms use +and is the identical string constant to WPF's `System.Windows.DataFormats.Bitmap` — swap it in the +same commit regardless of whether the split happens. + +**5e — `FreeformCaptureUtilities.cs`.** Only `CreateMaskedBitmap` moves, after changing its +parameter from `IReadOnlyList` to `IReadOnlyList`; the single call site in +`FullscreenGrab.SelectionStyles.cs` converts via `AsPointF`. `GetBounds` and `BuildGeometry` +return WPF rendering types (`PathGeometry`) and stay. + +**5f — `PdfDocumentRenderer.cs`** (re-scoped here from Wave 4). Blocked on the same `BitmapSource` +currency as `ImageMethods`: `RenderPageAsync` returns one, and changing that is a public API shape +change affecting multiple views. Its internal geometry and line-grouping logic is already portable +(`Windows.Foundation.Rect`) if partial credit is wanted. + +### 4.6 Wave 6 — services and settings + +**6a — settings providers → plain Core.** `AutomationSettingsProvider.cs` (no changes) and +`AutomationProfile.cs` (widen `ApplySeed` to `ApplicationSettingsBase`). Add +`System.Configuration.ConfigurationManager` to `Text-Grab.Core.csproj`. Highest-confidence batch in +the wave, and it unblocks `TesseractHelper` (4b), `ContextMenuUtilities` and +`FileAssociationUtilities` (3a-deferred), and `FileUtilities` (5). + +**6b — speech.** `Services/WindowsSpeechEngine.cs` → Core.Windows. `Services/TtsService.cs` → +plain Core, after resolving its `private ITtsEngine _engine = new WindowsSpeechEngine();` field +initializer — the app should register the default engine at composition, same shape as +`SettingsAccess`. Add `TtsSpeakWordLimit`, `TtsVoiceName`, `TtsSpeakingRate`. + +**6b as executed.** Both files moved unsplit, keeping their names. The field initializer became +`Text-Grab.Core/Services/TtsEngineAccess.cs` — the fourth delegate-resolver, after +`SettingsAccess`, `UiThreadAccess` and `InputLanguageAccess`. It holds a `Func` +factory rather than a stored instance, and `TtsService`'s constructor calls +`TtsEngineAccess.CreateDefault()` as its first statement, so the engine is still built at the same +moment it always was — when a `TtsService` is constructed, not lazily on first `Speak`. The app +registers `static () => new WindowsSpeechEngine()` from `Text-Grab/Utilities/ +TtsEngineAccessInitializer.cs`, a `[ModuleInitializer]` covering the Tests host the same way +`SettingsAccessInitializer` does. An unregistered resolver throws `InvalidOperationException`, +matching `SettingsAccess` — in production the module initializer always covers it, so this is +unreachable outside a Core-only host with no fake installed. `WindowsSpeechEngine`'s two settings +reads (`TtsVoiceName`, `TtsSpeakingRate`) moved from `Properties.Settings.Default` to +`SettingsAccess.Current`; `TtsService`'s `TtsSpeakWordLimit` read did the same. + +**6c — `AudioTranscriptionUtilities.cs` → Core.Windows, wholesale.** 1115 lines, fully headless +(NAudio + Whisper.net, zero WPF, zero WinRT), with exactly **one** settings touchpoint: +`AudioTranscriptionModel`. Move `NAudio`, `Whisper.net`, `Whisper.net.Runtime` to Core.Windows. +(`IncludeTimecodesInTranscription` and `NotifyOnTranscriptionComplete` are consumed only in the +views, not in this file.) The cleanest single file in the whole reorganization — use it as the +anchor that proves Core.Windows can host NAudio/Whisper. + +**6c as executed.** Moved unsplit, keeping its name and its `Text_Grab.Utilities` namespace. The +one settings touchpoint (`CurrentModelChoice`, reading `AudioTranscriptionModel`) switched from +`AppUtilities.TextGrabSettings` to `SettingsAccess.Current`; `AudioTranscriptionModel` joined +`ITextGrabSettings` and already existed in `Settings.settings`, so no `.settings` edit was needed. +A repo-wide grep confirmed no other app file uses any NAudio or Whisper.net type directly (unlike +the ZXing/CliWrap/Magick.NET precedent, where the app kept the package because app code still +calls those types), so `NAudio`, `Whisper.net` and `Whisper.net.Runtime` moved to +`Text-Grab.Core.Windows.csproj` outright rather than being duplicated in `Text-Grab.csproj`. The +two consumers (`EditTextWindow.xaml.cs`, `OpenMediaWindow.xaml.cs`) only call +`AudioTranscriptionUtilities`/`LiveAudioTranscriber` members, never NAudio/Whisper.net types +directly, so nothing there needed a change. + +**6d — `WebSearchUrlModel` split**, exactly `PatternItem`/`PatternItemCatalog`-shaped: pure record +→ Core, static accessors stay in the app. No interface changes needed. + +**6d as executed.** The impure half turned out to be larger than "static accessors": the settings +coupling was on *instance* members (`DefaultSearcher`, `WebSearchers` and their private backing +fields), used through `Singleton.Instance` at every call site, with the three +static helpers (`GetWebSearchUrls`, `SaveWebSearchUrls`, `GetDefaultWebSearchUrls`) only ever +called internally to back those properties - zero external call sites of their own. So the whole +settings-touching unit, instance members and statics together, moved into a new app-side +`Text-Grab/Models/WebSearchUrlCatalog.cs`, keeping every member and its behaviour unchanged. +`WebSearchUrlModel` in Core kept only `Name`, `Url` and `ToString()`. Call-site census: 12 +references use `WebSearchUrlModel` purely as a data type (`List`, `foreach`, +pattern matches, construction) and needed no edit since the namespace didn't change; 6 references +were `Singleton.Instance.{DefaultSearcher,WebSearchers}` across +`GeneralSettings.xaml.cs`, `PostGrabActionManager.cs` and `EditTextWindow.xaml.cs`, updated to +`Singleton`. The data half's majority confirms the original name stayed with +it, matching the `PatternItem`/`PatternItemCatalog` precedent. + +**Deferred-ledger sweep, run alongside 6b-6d.** Three §7 rows named blockers that had since +landed: `Utilities/FileUtilities.cs` (blocked on `AutomationProfile.Current`, resolved in 6a), +`Utilities/FileAssociationUtilities.cs` (blocked on `FileUtilities.GetExePath()`), and +`Utilities/ContextMenuUtilities.cs` (blocked on `AutomationProfile.Current`, +`FileUtilities.GetExePath()`, and the `IoUtilities` split, all resolved). Two moved cleanly: +`ContextMenuUtilities.cs` moved unsplit. `FileUtilities.cs` needed one split: its +`GetOpenDocumentFilter()` also calls `GrabFrameFileUtilities` (`.GrabFrameFileExtension`, +`.GetGrabFrameFileFilter()`), which stays app-side - blocked on `HistoryInfo`, per its own §7 row, +untouched here since `Services/HistoryService.cs` is out of scope for this sweep. Everything else +in `FileUtilities` (12 other members, a dozen-plus call sites across the app) moved to +Core.Windows keeping the name; `GetOpenDocumentFilter()` alone (3 call sites: `App.xaml.cs`, +`EditTextWindow.xaml.cs`, one test) moved into a new app-side `OpenDocumentFilterUtilities.cs`, +calling back into two of `FileUtilities`'s helpers (`GetVisualDocumentFilterPattern`, +`GetExtensionsFilterPattern`) widened from `private` to `internal` for exactly that caller. +`AppUtilities.IsPackaged()` calls in `FileUtilities` became `PackageIdentity.IsPackaged()` (the +established 4e substitution - `AppUtilities.IsPackaged()` is a one-line forwarder to it). +`FileAssociationUtilities.cs` did **not** move: its `GrabFrameExtensionKeyPath` constant +references `GrabFrameFileUtilities.GrabFrameFileExtension` directly, the same `HistoryInfo` +blocker one level removed - left in place with its §7 row rewritten. `Utilities/TesseractHelper.cs` +was a stale §7 row - it moved in batch 4b and was simply never removed - deleted. + +**6e — `HistoryService.cs`. Opus owns this; it is a second `OcrUtilities`.** A genuinely headless +JSON pipeline (`LoadHistoryAsync`, `LoadHistoryWithRecovery`, `WriteHistoryFiles`, the +`Normalize*` methods, `HistoryLanguageKindJsonConverter`) is interleaved with WPF menu building, +`GrabFrame`/`EditTextWindow` construction and a GDI+ `CachedBitmap`, sharing private state across +both halves. Blocked on `HistoryInfo`'s own `System.Windows.Rect PositionRect` — which B2 and 2d +now give a path to. + +**`HistoryInfo` first, in `a8591aa`.** `PositionRect` was never a stored field — it is a +projection over the persisted `RectAsString`, and only `RectAsString` is serialized — so B2's +currency change costs nothing on disk. It is now a `System.Drawing.RectangleF` with hand-rolled +parse/format helpers that keep the `"x,y,width,height"` text `Rect.ToString()` wrote, plus the +literal `"Empty"`. Writing is invariant-culture; reading additionally tolerates the `';'` +separator and comma decimals `Rect.ToString()` emitted under cultures whose decimal separator is +`','` — strings the old invariant-only `Rect.Parse` threw on rather than read. The eleven call +sites convert at the edge through `ShapeExtensions`. `HistoryInfo.cs` then moved to +`Text-Grab.Core.Windows/Models/` with no other edit, which also clears the root blocker under the +`GrabFrameFileUtilities` and `FileAssociationUtilities` §7 rows. + +**6e as executed, in `212234a`.** `Text-Grab.Core.Windows/Utilities/HistoryFileUtilities.cs` takes +the whole persistence pipeline: `LoadHistoryAsync` / `LoadHistoryBlocking` / +`LoadHistoryWithRecovery` / `WriteHistoryFiles`, `HistoryLanguageKindJsonConverter` and the +`AsyncLocal` rewrite flag it sets, `NormalizeHistoryIds` and both +`NormalizeHistoryCompatibilityData` overloads, the word-border sidecar chain +(`EnsureWordBorderSidecarFiles`, both `PersistWordBorderData` overloads, +`GetWordBorderInfosAsync`), retention (`GetMostRecentGrab`, `GetExcessVisualHistoryItems`, the +three `Max*` caps, `ClearTransientHistoryPayloads`) and artifact deletion. All static, no state +past the serializer options. + +`HistoryService` kept its name — every call site is `Singleton.Instance` — and +kept what actually held it in the app: the two `List` fields, the two +`DispatcherTimer`s that debounce writes and release the idle cache, the cached fullscreen +`Bitmap` and its HBITMAP, the recent-grabs `MenuItem` building, and the `SaveToHistory` overloads +taking `GrabFrame` and `EditTextWindow`. + +Two behaviour-preserving details. `NormalizeHistoryIds` used to call `MarkHistoryDirty` itself; it +returns a `bool` now so it can be static, and its four callers evaluate it and +`NormalizeHistoryCompatibilityData` into locals before testing them — both normalizers mutate, so +neither may be short-circuited away by the other, which the obvious `||` chain would have done. +`GetWordBorderInfosAsync` stays on the service as a two-line wrapper, because the +`TouchHistoryCache()` it opens with is cache bookkeeping, not file work. +`PersistWordBorderData` reads `EnableFileBackedManagedSettings`, so that joined +`ITextGrabSettings` (member 20). + +### 4.7 Wave 7 — tests and closeout + +**7a — test migration.** To `Tests.Core`: `StringMethodTests`, `TextSearchUtilitiesTests`, +`RecognizerExecutorTests`, `PatternExecutorTests`, `CalculatorTests`, `UnitConversionTests`, +`ExtractedPatternTests`, `ColumnSplitUtilitiesTests`, `SpreadsheetUndoHistoryTests`, +`EditTextTableDocumentTests`, `GrabFrameTableEditStateTests`, `ThirdPartyNoticeUtilitiesTests`, +plus the pure halves of `ProtocolUtilitiesTests` and `MarkdownDocumentUtilitiesTests`. To +`Tests.Core.Windows`: `OcrTests` and the other headless-Windows suites. Delete +`Tests.Core/ScaffoldingSmokeTests.cs`. + +**7a as executed.** 12 suites moved unsplit and 6 more split across the boundary, landing in +`a3643e6`, `873ea58`, `5585199`, `fe4fd59`. What it deliberately left in `Tests`, and why, is the +standing record of test placement - re-verify against these reasons before moving any of them, +rather than assuming they are just unfinished: + +| Suite | Stays in `Tests` because | +|---|---| +| `LanguageServiceTests`, `CaptureLanguageUtilitiesTests` | Direct `Settings.Default` reads plus `[Collection("Settings isolation")]` | +| `HistoryServiceTests` | `HistoryService` is app-side by design after 6e | +| `TtsServiceTests` | Would need both a `TtsEngineAccess` fake and a `SettingsAccess` fake; judged non-trivial | +| `SettingsAccessTests` | `[Collection("Settings isolation")]` fixture | +| `WordBorderTests`, `ImageMethodsTests`, `FreeformCaptureUtilitiesTests` | WPF types, or entirely `[WpfFact]` | +| `PdfDocumentRendererTests` | The production file has not moved - blocked on 5f | +| `FullscreenCaptureResultTests` | Exercises `FullscreenCaptureResult`, a genuine never-move type (`BitmapSource`, B3) | +| `GrabFrameViewScaleUtilitiesTests`, `WindowSelectionUtilitiesTests` | Exercised types 7a judged never-move; 7b's re-derivation found neither production file has a real blocker beyond B2 currency (see §7) - moving the tests is still deferred pending that conversion, but the reason is no longer "never," it is "not yet" | + +7a also introduced `Tests.Core.Windows/FakeTextGrabSettings.cs`: a POCO `ITextGrabSettings` +(all 20 members, `Save()` a no-op) seeded from `Settings.settings`'s shipped defaults +(`RemoveFurigana = true` matters concretely - one OCR test's expected output depends on furigana +actually being filtered) and registered by a `[ModuleInitializer]`, the same mechanism the app +uses for `SettingsAccessInitializer`. It exists because `OcrUtilities.BuildTextFromOcrLines` reads +`SettingsAccess.Current` unconditionally, and `Tests.Core.Windows` has no app assembly to supply a +resolver the way `Tests` does. This is the template for any future Core-tier test suite that needs +settings and cannot reach the app: a local `ITextGrabSettings` double plus a `[ModuleInitializer]` +registration, not a dependency on the app assembly. + +**7b — closeout.** Remove dead app-side shims; confirm the MSIX package still builds in Visual +Studio (the one thing the CLI gate cannot check); re-derive the never-move list one final time +against B2; update this document with the final layer map. + +**7b as executed.** Shim removal: the dead `Table-Complex.png` content item and file (`6497dd3`, +confirmed dead by 7a's own commit message, not just by absence of new references), and +`GrabFrameFileUtilities.cs`/`FileAssociationUtilities.cs` moving to Core.Windows once audited and +found to have no second blocker, which in turn retired the app-side `OpenDocumentFilterUtilities.cs` +façade (`3f4b222`). The never-move re-derivation moved `WindowSelectionUtilities`, +`WindowSelectionCandidate` and `GrabFrameViewScaleUtilities` into §7 - all three were carried on the +list from an earlier wave without ever being checked against B2, and turned out to have no blocker +beyond it. Everything else on the list was re-verified and kept its existing reason (some reasons +were tightened with the actual type each file is blocked on, rather than left as a bare filename). +The MSIX package build could not be verified from this environment - see the note at the end of +§9. Final layer map: §10. + +### Never moves — verified + +Re-derived against B2 in 7b: every entry below was re-read against the current tree, not carried +forward from whenever it was first listed. `HistoryInfo` moving to Core.Windows in `a8591aa` is +exactly the kind of event that can quietly invalidate an old reason, so each file was checked for +that specifically as well as for the B2 (`Rect`/`Point`/`Size`) currency question. Two Utilities +entries and one Models entry did not survive the re-check and moved to §7 below; +everything else here still has the blocker it was originally listed for. + +`Views/`, `Controls/`, `Pages/`, `Styles/`, `Themes/`, `App.xaml.cs`, `AssemblyInfo.cs`, +`WPFExtensionMethods.cs`, `Properties/Settings.Designer.cs`, `TextGrabNotificationActivator.cs`. + +**Extensions:** `ControlExtensions`, `DapploExtensions`, `KeyboardExtensions`, `ShapeExtensions`. + +**Utilities:** `ColorHelper` (`System.Windows.Media.Color`/`SolidColorBrush`, not a B2 type), +`CursorClipper` (`FrameworkElement`), `WindowResizer` (`Window`), `WindowUtilities` (`Window`, +`Application.Current.Windows`), `NotificationUtilities` (`Application.Current.Windows`, +`EditTextWindow`), `HotKeyManager` (`System.Windows.Forms.Keys` plus an `HwndSource`-style +message loop), `AutomationDiagnostics` (`Window`, `FrameworkElement`, `EventManager`), +`NotifyIconUtilities` (`Application`, `BitmapImage`, `Views`), `OutputUtilities` (`TextBox`, +`Clipboard`), `ShareTargetUtilities` (`Views`, WinRT share-target activation), +`ImplementAppOptions` (casts to the WPF `App`, calls `NotifyIconUtilities`), `MagickHelpers` +(`ImageSource`-typed signatures via `Magick.NET.SystemWindowsMedia`), `CameraCaptureUtilities` +(`Wpf.Ui.Controls.MessageBox`, needs a WPF `Window` for its `hwnd`), `SettingsImportExportUtilities` +(reflects over the entire settings surface by design), `DiagnosticsUtilities` (reads ~70 settings +properties, aggregates every deferred subsystem), `PostGrabActionManager` (`Wpf.Ui.Controls`, +`Wpf.Ui.Controls.MessageBox`), `CustomBottomBarUtilities` (`Text_Grab.Controls.CollapsibleButton`), +`ShortcutKeysUtilities` (`System.Windows.Input.Key`), `UIAutomationUtilities` +(`System.Windows.Automation` / `UIAutomationClient.dll`, see B4). + +`GrabFrameViewScaleUtilities` and `WindowSelectionUtilities` came off this list in 7b — see §7. +Both turned out to be pure `Rect`/`Point`/`Size` math with no other WPF coupling, which B2 already +has a conversion path for; they were never independently blocked, they were just never audited. + +**Models:** `ButtonInfo` (~90 static entries each assigning `Wpf.Ui.Controls.SymbolRegular` — +whole-class, not splittable), `ShortcutKeySet` (`System.Windows.Input.Key`), `PostGrabContext`, +`FullscreenCaptureResult` (both carry a `System.Windows.Media.Imaging.BitmapSource` — a B3 +blocker, independent of and unaffected by B2), `LookupItem` (`Wpf.Ui.Controls.SymbolRegular`, +fully qualified, plus a `HistoryInfo` constructor parameter that is incidental to its real +blocker). + +`UiAutomationOptions`, `UiAutomationOverlayItem`, `UiAutomationOverlaySnapshot` — re-checked +against B2 in 7b and found to be pure `Rect`/`Point` data records, the same shape B2 already +converted for `WordBorderInfo`/`TemplateRegion`. They stay here anyway: their only consumers are +`UIAutomationUtilities` (blocked on `System.Windows.Automation`, B4 already declined to chase +this) and the views (`FullscreenGrab.SelectionStyles.cs`, `GrabFrame.xaml.cs`) directly. Moving +three data models would not free anything real, so B4's "not worth weakening the tier boundary +to move one file" verdict extends to these models too - it was really always about them. +`WindowSelectionCandidate` used to sit in this same bucket by association +(`UiAutomationOverlaySnapshot.TargetWindow` is one), but 7b found it and its own consumer, +`WindowSelectionUtilities`, have no B4-style second blocker between them - see §7. + +**UndoRedoOperations:** all of them — `Operation`, `AddWordBorder`, `RemoveWordBorder`, +`ChangedImage`, `UndoRedo`, `ChangeWord`, `ResizeWordBorder`. Every one is typed on `WordBorder`, +`Canvas` or `ImageSource`. + +### Consolidated `ITextGrabSettings` additions + +Nine members across the whole plan, taking the interface from 10 to 19. Add each one only when its +batch runs. + +| Property | Type | Needed by | Batch | +|---|---|---|---| +| `OverrideAiArchCheck` | `bool` | `WindowsAiUtilities` | 3 (deferred) | +| `UiAutomationEnabled` | `bool` | `LanguageService` | 4e | +| `WindowsAiDescriptionEnabled` | `bool` | `LanguageService` | 4e | +| `HdrCaptureCorrection` | `bool` | `ImageMethods` | 5c | +| `HdrBorderlessGranted` | `bool` | `HdrScreenCapture` | 5b | +| `AudioTranscriptionModel` | `string` | `AudioTranscriptionUtilities` | 6c | +| `TtsSpeakWordLimit` | `int` | `TtsService` | 6b | +| `TtsVoiceName` | `string` | `WindowsSpeechEngine` | 6b | +| `TtsSpeakingRate` | `double` | `WindowsSpeechEngine` | 6b | + +All nine already exist in `Settings.settings`, so each is a one-line interface addition with no +`.settings` edit. Declined: the three `UiAutomation*` traversal properties, per B4. + +**The `Load*`/`Save*` families are not candidates.** `LoadStoredRegexes`, `LoadBottomBarButtons`, +`LoadWebSearchUrls` and friends are `SettingsService` *methods*, not scalar properties. They do not +fit this interface's shape, and the façade pattern (`PatternItemCatalog`) handles them with no +interface change at all. +## 5. Sub-agent orchestration + +### Roles + +| Role | Model | Isolation | Parallel? | +|---|---|---|---| +| **Cartographer** — read-only dependency mapping of one area | Sonnet, `Explore` | none (read-only) | **yes**, 4–6 at once | +| **Mover** — executes one batch, commits it | Sonnet, `general-purpose` | none (main tree) | **no**, strictly serial | +| **Architect** — Wave 0, batch 4a, any split that changes a public shape | Opus (you) | none | n/a | +| **Verifier** — build + test at wave boundaries | Sonnet | none | no | + +### Why movers are serial + +Every batch touches shared state: `.csproj` `PackageReference` lists, `Enums.cs`, and call +sites in `EditTextWindow.xaml.cs` (7799 lines) and `GrabFrame.xaml.cs` (6442 lines) that +almost every batch edits. Worktree isolation would just relocate the conflict to a merge that +is harder to resolve than the original edit. Serial movers with a 13-second build gate between +them is the faster path in wall-clock terms. + +The parallelism worth having is in reconnaissance: dispatch cartographers for Waves 3–6 +simultaneously while you do Wave 0, so every mover starts with an accurate file list. + +### Mover prompt template + +``` +You are executing batch of the Text-Grab Core split. + +Read D:\source\TheJoeFin\Text-Grab\docs\Core-Split-Plan.md first — sections 1 (especially +invariant 8), 2, and your batch in section 4 are binding. Then read the two most recent +move commits (git show edefeaa, git show e677b54) to match the established style. + +Your file list is exactly: + +Target project: + +Procedure, per file: + 1. Read it in full. Confirm which TYPES it uses — invariant 8 lists the four traps, and + the original wave lists were wrong about a dozen files for exactly these reasons. + 2. If it moves cleanly: `git mv` it, keep the namespace, fix call sites the compiler flags. + 3. If it needs a split: pure part moves, the coupled façade stays in the app under a new + name. See PatternItem/PatternItemCatalog in e677b54 for the shape. + 4. If it is blocked by something outside your list: LEAVE IT. Do not expand scope. + 5. If section 8 lists dead code in a file you are moving, re-verify it has no call sites + (beware target-typed `new()`), then delete it as part of the move. + +After each file, run: dotnet build Tests/Tests.csproj -c Debug -p:Platform=x64 +Never run `dotnet build Text-Grab.sln` — the wapproj fails under the dotnet CLI by design. + +When the whole list is done and the build is clean: + - Append every deferred file to section 7 of Core-Split-Plan.md with its specific blocker. + - Commit everything as one commit in the established style. + +Report back: files moved, files split (and how), files deferred (and why), final build status. +Do not report success unless the build actually succeeded — paste the failure if it did not. +``` + +### Cartographer prompt template + +``` +Read-only reconnaissance for the Text-Grab Core split. Make no edits. + +Area: + +For each file report: + - Its real dependency set: WPF types, WinForms types, WinRT namespaces, System.Drawing + primitives vs GDI+, P/Invoke, settings access, and which other Text-Grab types it needs. + - Verdict: moves clean to Core / moves clean to Core.Windows / needs a split (say where the + seam is) / stays in the app (say why). + - Which OTHER files a move would drag in. +Rank the area into tiers: move-now, move-after-B1-settings, move-after-B2-geometry, never. +Be specific about blockers — "uses settings" is useless; "GetTesseractPath writes +TesseractPath back to settings as a side effect" is what I need. +``` + +### Cadence + +Do not fire-and-forget the whole chain. Run **one wave at a time**, and between waves: +`git log --oneline -8`, run the wave-boundary test commands, and skim the batch diffs. The +prior six commits were all human-reviewed; that ratio should hold — a mover that silently +"fixes" a call site incorrectly compiles fine and breaks at runtime. + +### What the reconnaissance pass actually bought + +Worth recording, because it justifies doing this again before Waves 5 and 6 execute. Five +read-only agents ran in parallel against one working tree — safe because none of them could +write. Between them they: + +- found `OSInterop.cs` (1292 lines) blocked by a single dead line; +- found the `AppUtilities.IsPackaged()` shared blocker that no single wave owned, which is now + Wave 1; +- **disproved four entries** on the original wave lists and **rescued two** from the never-move + list; +- settled the `System.Configuration`-on-net10.0 question by building and running a probe rather + than reasoning about it; +- surfaced the `FrameworkReference` loophole in invariant 2 (§B4). + +The cost was five agents reading ~60 files. The alternative was movers discovering each of these +mid-batch, with a half-applied commit in the tree. + +**Verify before acting on a report.** Every load-bearing claim above was independently checked +before it entered this document, and one was wrong in a way that mattered: an agent reported +`GrabFrame` constructing a `ResultTable`, and a naive `grep "new ResultTable"` appeared to refute +it — the call is target-typed `new()`. Trusting the grep over the agent would have deleted a live +constructor. + +## 6. Risk register + +| Risk | Mitigation | +|---|---| +| MSIX packaging breaks (CLI gate can't see it) | Open the solution in VS and build the wapproj at each wave boundary. | +| A mover flips `UseWPF=true` on Core.Windows to unblock itself | `Tests.Core.Windows/TierBoundaryTests.cs` fails the build. Also check the csproj diff in review. | +| Silent behavior change from a "cleanup" during a move | Movers are told to change only what the compiler flags. Review diffs for unrequested edits. | +| `RuntimeIdentifiers` dropped from a library csproj | Causes NETSDK1047 in the wapproj restore only — invisible to the CLI gate. Grep the csprojs at wave boundaries. | +| Settings interface sprawls to all 104 properties | Add properties only when a move demands one; if a batch needs more than ~5 new ones, that file probably wants a façade split instead. | +| `EditTextWindow.xaml.cs` / `GrabFrame.xaml.cs` churn | They are touched by most batches. Serial movers make this safe; parallel ones would not. | +| A mover classifies by `using` lines and moves a WPF-bound file | Invariant 8. `TierBoundaryTests` catches it at the assembly level if it slips through. | +| A batch stalls because a one-line leaf it needs is still app-side | Wave 1 exists precisely for this. Do not start Waves 3–6 before it lands. | +| Deleting "dead" code that is actually reachable | §8 rows were each verified by full-repo grep. Re-verify before deleting; target-typed `new()` and same-named members on other types defeat a naive grep — that is how `GrabFrame`'s `ResultTable` construction was nearly missed. | + +## 7. Deferred ledger + +Files with a real blocker, and the specific thing that clears it. **Movers append here.** +Every row below was verified by reading the file, not inferred. 7b re-read every row against the +current tree (see 7b's own commits for what that turned up) rather than trusting the wording +inherited from whichever batch wrote it. + +**`FileAssociationUtilities.cs` and `GrabFrameFileUtilities.cs` resolved in `3f4b222`.** Both rows +said "unblocked by `a8591aa`, never audited past that" — this batch did that audit and found no +second blocker in either file. Both moved to Core.Windows unsplit; `FileAssociationUtilities.cs` +needed one substitution (`AppUtilities.IsPackaged()` → `PackageIdentity.IsPackaged()`, the +established 4e/6a pattern), `GrabFrameFileUtilities.cs` needed none. That move also retired +`OpenDocumentFilterUtilities.cs`, the app-side façade that existed only because +`GrabFrameFileUtilities` had to stay app-side — its one method folded back into +`FileUtilities.GetOpenDocumentFilter()` in Core.Windows in the same commit. + +| File | Blocker (specific) | Unblocked by | +|---|---|---| +| `Utilities/OcrSourceUtilities.cs` | Post-4c remainder. `LoadBitmapFromFile` builds a WPF `BitmapImage` to apply EXIF rotation; decoupling means a GDI+/WIC rewrite, and it takes `OcrAbsoluteFilePathAsync` and `OcrFile` with it. Engine dispatch additionally needs `WindowsAiUtilities` (5a) and `LanguageUtilities` (4e); the rest is `Window`/`BitmapSource` capture and stays | a GDI+/WIC rewrite, then 5a + 4e | +| `Services/SettingsService.cs` | Clones `ButtonInfo` and `ShortcutKeySet` field-by-field (both never-move); `Windows.Storage.ApplicationDataContainer` caps it at Core.Windows regardless | needs a `ButtonInfo` redesign — likely never | +| `Utilities/GrabTemplateManager.cs` | `SaveTemplateReferenceImage` (BitmapSource) and `CreateButtonInfoForTemplate` (Wpf.Ui) must stay; `IsFileBackedManagedSettingsEnabled` is a service property, not a scalar. `GrabTemplate`/`TemplateRegion` moved to Core in 2d, so the remaining blocker is a plain split | a split | +| `Utilities/GrabTemplateExecutor.cs` | `LoadStoredRegexes()` needs a non-scalar seam. `GrabTemplate`/`TemplateRegion` moved to Core in 2d and 4c has landed, so the remaining blocker is the settings façade plus its calls into `OcrSourceUtilities` | a façade + `OcrSourceUtilities` | +| `Utilities/PdfDocumentRenderer.cs` | `RenderPageAsync` returns `BitmapSource` — a public API shape change affecting several views | 5f | +| `Utilities/WindowSelectionUtilities.cs`, `Models/WindowSelectionCandidate.cs` | Found in 7b's never-move re-derivation, not by a mover: neither has any blocker beyond B2. `WindowSelectionUtilities` is `OSInterop` P/Invoke (Core.Windows-legal since 3a) plus `System.Windows.Rect`/`Point` math with no WPF UI type in sight; `WindowSelectionCandidate` is a `Rect`+`Point` data record, the same shape B2 already resolved for `WordBorderInfo`/`TemplateRegion`. Not attempted here — invariant 5, and the conversion surface (both fields on `FullscreenGrab.SelectionStyles.cs`, one field on `UiAutomationOverlaySnapshot`) is real work, not a leaf | `Rect`/`Point` → `RectangleF`/`PointF`, plus the view-side conversions at both call sites | +| `Utilities/GrabFrameViewScaleUtilities.cs` | Same 7b discovery as the row above: pure `Rect`/`Size` math (`GetMinimumWindowRect`, `StepScale`, `CoerceScale`), already calling the app's `ShapeExtensions.IsGood(this Rect)` — Core already has the `RectangleF` equivalent (`RectangleFExtensions.IsGood`) from B2. One call site (`GrabFrame.xaml.cs:1151`) converts at the edge | `Rect`/`Size` → `RectangleF`/`SizeF` | + +## 8. Verified dead code — free, zero-risk prep + +Each of these was confirmed to have **zero call sites** across the repo. Deleting them is safe +and independent of any wave; two of them unblock real moves. + +| Dead code | Why it matters | +|---|---| +| `OSInterop.GetAsyncKeyState(System.Windows.Forms.Keys)` (line 125) | The **only** `System.Windows.Forms` reference in all 1292 lines. Deleting it moves the whole file. | +| `Models/DragDataObject.BitmapSourceToBitmap` (line 77) | The file's only WPF touchpoint, and a duplicate of `ImageMethods.BitmapSourceToBitmap`. Deleting it moves the file. | +| `Models/ResultTable`: `OcrResult` property, `ParseOcrResultWordsIntoRects()`, the `ResultTable(ref List, DpiScale)` ctor, `CalculateResultRows`, `MergeTheseRowIDs` | Leftovers from a superseded grid-line algorithm. The `OcrResult` property is why `ResultTable` looked WinRT-coupled; the live path already uses `IOcrLinesWords`. | +| ~~`Utilities/OcrUtilities.GetBoundingRect(this OcrLine)`~~ | Deleted in 4c. `edefeaa` had left it saying "other app code may still use it"; nothing did. | +| `Extensions/ImageExtensions.ExifRotate` (line 12) | Unused. | + +Not dead, but a one-line dependency removal in the same spirit: +`ClipboardUtilities.cs:64` uses `System.Windows.Forms.DataFormats.Bitmap` — the identical string +constant to WPF's `System.Windows.DataFormats.Bitmap`, and the file's only WinForms use. + +## 9. Definition of done + +- `Text-Grab.Core` holds the text, pattern, table, calculation, and template logic, with no + `System.Windows`, no `Windows.*`, no P/Invoke. **Done.** +- `Text-Grab.Core.Windows` holds OCR engines, capture, imaging, Windows AI, and Win32 interop, + with `UseWPF=false` still set. **Done** - `TierBoundaryTests` enforces the flag and the + reference direction on every test run. +- `Text-Grab` holds Views, Controls, Pages, app wiring, and thin adapters — nothing else. + **Mostly true, honestly assessed in §10's residue list** - a handful of files stay app-side for + reasons stronger than "not yet gotten to it" (settings orchestration, `ButtonInfo`/`Wpf.Ui` + coupling, `System.Windows.Automation`), and §7 lists three more that are B2-only and simply + never scheduled. +- `Tests.Core` runs in ~2s with no display and covers the pure tier; `Tests` keeps only the + WPF/STA tests (plus the handful of suites in §4.7's 7a table that could not split further). + **Done.** +- CI runs all three test projects. **Done for the three CLI-buildable projects.** The MSIX + package build is the one item this whole plan cannot verify from a CLI-only environment - see + the callout at the end of this section. +- Section 7 is empty, or every remaining row has a written reason it stays. **True as of 7b** - + seven rows remain, each re-verified against the current tree rather than carried forward from + whichever batch first wrote it (see §7's own note). + +**The MSIX wapproj build remains unverified.** Every gate command in §2 targets a project that +builds under the plain `dotnet` CLI; `Text-Grab-Package.wapproj` does not; and no agent that has +worked on this plan, across any wave, has had access to a full Visual Studio install to build it. +That means the packaging reference graph - three projects deep, each carrying its own +`RuntimeIdentifiers` and `PackageReference` list - has been exercised only by the risk-register +mitigation in §6 ("grep the csprojs at wave boundaries"), never by an actual wapproj build. Open +the solution in Visual Studio and build the `Text-Grab-Package` project before shipping a release +off this branch; that is the one remaining step this document cannot close out for you. + +## 10. Final layer map + +Written for whoever opens this repo next with no memory of any of the above. The short version: +three library tiers, dependencies point one way, four small seams carry the app-only behavior +that pure/headless code still needs to call, and a reflection-based test enforces the boundary so +a future PR cannot quietly undo it. + +### The tiers + +``` +Text-Grab.Core net10.0 pure logic, no UI, no Windows + ^ +Text-Grab.Core.Windows net10.0-windows10.0.22621.0 WinRT / GDI+ / P-Invoke, UseWPF=false + ^ +Text-Grab net10.0-windows... WPF app Views, Controls, Pages, app wiring +``` + +Dependencies point one way only: app -> Core.Windows -> Core. Core never references Core.Windows +and neither library references the app - this is invariant 3, and it is the one a move is most +likely to violate by accident (a call site left behind, a `using` that should not resolve). It is +enforced mechanically, not just by convention: see "The tier guard" below. + +**`Text-Grab.Core`** (57 files) holds everything that needs nothing but the BCL: text and pattern +matching (`PatternExecutor`, `ColumnSplitUtilities`, `RecognizerExecutor`, regex/number/markdown +utilities), the table and calculation engines (`ResultTable`'s clustering algorithm, +`CalculationService` and its ~2000 lines built on NCalcAsync/UnitsNet), the geometry currency +(`RectangleFExtensions`), the settings-provider chain (`AutomationProfile`, +`AutomationSettingsProvider`, riding on `System.Configuration.ConfigurationManager` - verified to +resolve and run on plain net10.0 in Wave 0), the portable models (`WordBorderInfo`, +`TemplateRegion`, `GrabTemplate`, the CF_HTML table parser), `Enums.cs` (all 17, merged in 2a), +and the four delegate-resolver seams described below. Package references: `Markdig`, the +`Microsoft.Recognizers.Text.*` family, `NCalcAsync`, `UnitsNet`, +`System.Configuration.ConfigurationManager`. + +**`Text-Grab.Core.Windows`** (47 files) holds everything that needs Windows but not WPF: OCR +(`OcrUtilities`, the Windows AI / language chain, `TesseractHelper`), capture and imaging +(`HdrScreenCapture`, the headless half of `ImageMethods`, `SoftwareBitmapExtensions`, +`ImageChangeDetector`), the file and history pipelines (`FileUtilities`, +`GrabFrameFileUtilities`, `FileAssociationUtilities`, `HistoryFileUtilities`, +`HistoryInfo`), the audio transcription chain (`AudioTranscriptionUtilities`, NAudio + Whisper.net, +moved wholesale in 6c), text-to-speech (`WindowsSpeechEngine`), Win32/WinRT interop +(`OSInterop`, `RegistryMonitor`, `NativeMethods`, `DesktopNotificationManagerCompat`), and the +HDR/D3D11 pipeline. `UseWPF=false` is load-bearing here, not decorative - it is what keeps this +tier usable from a headless host, and B4 declined a real `FrameworkReference` loophole +specifically to protect it. Package references: `Microsoft.WindowsAppSDK.AI`, +`ZXing.Net.Bindings.Windows.Compatibility`, `CliWrap`, `Vortice.Direct3D11`/`Vortice.DXGI`, +`Magick.NET-Q16-AnyCPU`/`Magick.NET.SystemDrawing`, `NAudio`, `Whisper.net`/`Whisper.net.Runtime`. + +**`Text-Grab`** (115 files) holds Views, Controls, Pages, app wiring, and the residue described +below - WPF-bound code, settings orchestration, and the handful of files that were never worth +splitting for what they would free. See the residue list at the end of this section for the +honest accounting of what is here and why, rather than a claim that it is all just "not done yet." + +### The four delegate-resolver seams + +Four places in `Text-Grab.Core/Services/` let portable code call something only the app can +provide, without Core referencing the app. Same shape every time: a static class holding a +delegate field, a `SetResolver`/`SetPoster` setter, and a getter that throws +`InvalidOperationException` if nothing has registered yet. Each is registered from a +`[ModuleInitializer]` in `Text-Grab/Utilities/`, not from `App.appStartup` - the `Tests` project +loads the `Text-Grab` assembly and exercises its code without ever raising the WPF Startup event, +so a module initializer is the only registration point that covers both the running app and the +test host. `Tests.Core.Windows` has no app assembly at all, so tests that exercise a seam from +there register their own fake (see `FakeTextGrabSettings` below). + +| Seam | Registered by | Points at | +|---|---|---| +| `SettingsAccess` (`Func`) | `SettingsAccessInitializer` | `AppUtilities.TextGrabSettings` (which resolves `Singleton.Instance.ClassicSettings` lazily) | +| `UiThreadAccess` (a poster, not a resolver) | `UiThreadAccessInitializer` | `Application.Current?.Dispatcher.InvokeAsync(...)`, silently dropping the post if there is no dispatcher yet | +| `InputLanguageAccess` (`Func`) | `InputLanguageAccessInitializer` | `InputLanguageManager.Current?.CurrentInputLanguage?.Name`, catching the `NullReferenceException` the manager can throw internally | +| `TtsEngineAccess` (`Func`, a factory not a stored instance) | `TtsEngineAccessInitializer` | `new WindowsSpeechEngine()` | + +They arrived in this order as each wave hit the blocker they solve: `SettingsAccess` in B1 (Wave +0), `UiThreadAccess` in 5b (`HdrScreenCapture`'s one-time consent-dialog pump), +`InputLanguageAccess` in 4e (`LanguageService`'s only non-WinRT read), `TtsEngineAccess` in 6b +(`TtsService`'s engine field initializer). If a future move hits the same shape - portable code +needs one small thing only the app can answer - this is the pattern to reach for before reaching +for a bigger redesign. + +### `ITextGrabSettings` + +`Text-Grab.Core/Interfaces/ITextGrabSettings.cs` is the slice of the app's 104-property +`Settings` class that portable/headless code is allowed to read - 19 properties plus `Save()`, +20 members, up from the 10 (`CorrectErrors`/`CorrectToLatin`/... plus `Save()`) it started with in +B1. The app implements it by declaring it on the existing hand-written `Settings` partial +(`internal sealed partial class Settings : ITextGrabSettings`) - the generated properties already +match on name and type, so there is no forwarding code on that side. Every addition after B1 came +from a real move that needed it (tracked in the consolidated-additions table in §4.6) and every +one already existed in `Settings.settings`, so none needed a `.settings` edit. The rule that kept +it from sprawling: add a property only when a move demands one; a file needing more than a +handful of new members wants a façade split instead (`PatternItemCatalog`, +`WebSearchUrlCatalog`), not an interface that tries to cover it. + +A Core-tier or Core.Windows-tier test that needs settings and has no app assembly to fall back on +cannot use `SettingsAccessInitializer` - `Tests.Core.Windows/FakeTextGrabSettings.cs` is the +template for that case: a minimal `internal sealed class` implementing `ITextGrabSettings` with +every default copied from `Settings.settings`'s shipped profile, registered by its own +`[ModuleInitializer]`. It exists because `OcrUtilities.BuildTextFromOcrLines` reads +`SettingsAccess.Current` unconditionally and a handful of moved `OcrTests` methods exercise that +path headlessly. + +### The tier guard + +`Tests.Core.Windows/TierBoundaryTests.cs` enforces invariants 2 and 3 by reflection, not by +convention: it loads the `Text-Grab.Core` and `Text-Grab.Core.Windows` assemblies and asserts +`GetReferencedAssemblies()` contains none of `PresentationCore`, `PresentationFramework`, +`WindowsBase`, `System.Xaml` (checked on both), plus `System.Windows.Forms`, +`System.Drawing.Common`, and anything prefixed `Microsoft.Windows.`/`Microsoft.WindowsAppSDK` +(checked on Core only, since Core.Windows legitimately needs Windows APIs - just not WPF ones); +and that Core does not reference Core.Windows at all. This is what makes a `UseWPF` flip or a +stray `WindowsBase` pull-in (the B4 `FrameworkReference` loophole) a test failure instead of +something that only surfaces later as an unexplained csproj diff in review. + +### Test tiers + +`Tests.Core` (17 files, net10.0, no display, ~2s) mirrors `Text-Grab.Core`. `Tests.Core.Windows` +(12 files, net10.0-windows, headless - no `Xunit.StaFact`, since that package pulls in +`WindowsBase`, which the tier guard bans) mirrors `Text-Grab.Core.Windows`. `Tests` (54 files, +WPF/STA, references the app) keeps what genuinely needs a WPF host, plus the suites listed in +§4.7's 7a table that could not follow their production code for a specific, still-true reason. + +### The honest residue - what stayed in the app, and why + +Not everything left in `Text-Grab` is a WPF view. Grouping the real reasons, so the answer to +"why is this still here" is always one of these, not a shrug: + +- **Actual WPF/WinForms UI types.** `Window`, `FrameworkElement`, `TextBox`, `Wpf.Ui.Controls.*`, + `System.Windows.Automation` (`UIAutomationClient.dll`, B4), `System.Windows.Forms.Keys`. This is + most of the never-move list in §4.0 and holds no surprises. +- **Settings orchestration that is deliberately whole-surface.** `SettingsImportExportUtilities` + and `DiagnosticsUtilities` each touch dozens of settings properties by design (reflection over + the entire surface, or a diagnostics dump of most of it) - the kind of file + `ITextGrabSettings`'s "add one property per real need" rule is specifically meant to keep out of + Core. +- **`BitmapSource` currency (B3).** `PostGrabContext`, `FullscreenCaptureResult`, + `PdfDocumentRenderer.RenderPageAsync`, and the WPF half of `ImageMethods`/`OcrSourceUtilities` + all carry `System.Windows.Media.Imaging.BitmapSource`, which B3 fixed as app-only currency; + `OcrSourceUtilities.LoadBitmapFromFile` specifically needs a GDI+/WIC rewrite to lose it, not + just a move. +- **`ButtonInfo`/`ShortcutKeySet` coupling.** `SettingsService` clones both field-by-field; + `ButtonInfo` itself is ~90 static entries each assigning a `Wpf.Ui.Controls.SymbolRegular`, not + a splittable class. `GrabTemplateManager`'s `CreateButtonInfoForTemplate` inherits the same + blocker. +- **B2-only, simply never scheduled.** Three files (`WindowSelectionUtilities`, + `WindowSelectionCandidate`, `GrabFrameViewScaleUtilities`) turned out in 7b to have no blocker + beyond the `Rect`/`Point`/`Size` currency B2 already solved for `WordBorderInfo`/`TemplateRegion` + - they were carried on the never-move list without ever being checked against it. They are the + most likely next wave if this plan is picked back up; see their §7 rows for the exact + conversion surface. +- **A façade with no interface change.** `PatternItemCatalog`, `WebSearchUrlCatalog` - the + settings-touching half of a file whose pure half already moved, kept deliberately thin rather + than folded into `ITextGrabSettings`. +- **Genuinely unresolved, per §7.** `GrabTemplateManager`/`GrabTemplateExecutor` (need a split + plus a non-scalar settings seam for `Load*`/`Save*`-shaped methods, which `ITextGrabSettings` + deliberately does not cover) and `SettingsService` itself (judged likely-never, see above). \ No newline at end of file