diff --git a/.claude/brand-context.md b/.claude/brand-context.md new file mode 100644 index 00000000..fad75138 --- /dev/null +++ b/.claude/brand-context.md @@ -0,0 +1,53 @@ +# Brand context for Text Grab + +--- + +## Product + +What it is, in one sentence a stranger would understand: Text Grab is a Windows app to copy any text you can see or hear, then: edit, clean, save, recalled, and automate all of your text heavy tasks with clever functions and local AI! + +What it actually does (the mechanism, not the promise): Uses 100% local models to do Optical Character Recognition (OCR) and transcription. Many build in functions to automate tedious tasks, as well as using local LLMs for other text actions like translation, summarization, and more! + +What it does NOT do (this prevents overclaiming): Text Grab does not ship OCR models they are all built into Windows, or 3rd party. + +## Audience + +Who buys it: Power users, information workers, developers, designers, engineers, technical folks + +What they believe before they arrive: working with text is tedious and annoying, usually avoided as much as possible, manually transcribing text from an image or audio is annoying and there must be a better way. + +What they worry about at 2am: They are wasting their mind doing tedious data manipulation or looking up snippets, minor formatting, etc. and changing their workflows or relying on others more than they need to. + +What they'd use instead if you didn't exist: Doing the work manually, not being as confident, using tools like Excel, or Google Sheets and VS Code, maybe AI agents with some light scripting. + +## Positioning + +The one thing true about us that a competitor could not also say: Laser focused on making you more productive with text, from getting the text in to transformations. Native first; local first, always offline: built with WPF and only using local models. + +Category we compete in: + +Named competitors: Windows Snipping Tool, PowerToys Text Extractor, LightPDF, OCRSpace, FreeOCR, OnlineOCR, Simple OCR, Adobe Acrobat Pro DC, PDFelement, Easy Screen OCR, Boxoft Free OCR, ABBYY FineReader, Nanonets, Free OCR to Word, Nanonets, Filestack + +## Proof + +Numbers we can cite (with source and date): as of September 1st: over 160k downloads on GitHub, and nearly 5k stars, over 12k downloads from the Microsoft Store + +Named customers we're allowed to name: Windows Snipping Tool, PowerToys Text Extractor, ABBYY FineReader, Adobe Acrobat Pro DC + +Claims that need legal sign-off: none + +## Voice + +How we sound: Excited to save users time, to save them from tedium, to make them more productive. skeptical of cloud services that you never know what is going on with your data or when the app will change. Serious about the end to end text value pipeline. + +How we never sound: Like we are the best or first doing OCR. + +Words we always use: Productive, speed, faster, integrated, end-to-end + +Words we never use: Proprietary, — + +## Constraints + +Regulatory or legal limits: The OCR is not perfect and always needs review + +Anything off-limits: Guarantee support diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index b51c1e06..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' @@ -31,6 +33,10 @@ env: ARTIFACT_SIGNING_ENDPOINT: 'https://eus.codesigning.azure.net/' ARTIFACT_SIGNING_ACCOUNT_NAME: 'JoeFinAppsSigningCerts' ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: 'JoeFinApps' + # 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 }} + LAF_PUBLISHER_ID: ${{ secrets.LAF_PUBLISHER_ID }} jobs: build: @@ -46,8 +52,16 @@ 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 ${{ env.TEST_PATH }} -r win-x64 + run: dotnet test --project ${{ env.TEST_PATH }} -r win-x64 - name: Compute build version, archive paths, and release metadata id: compute @@ -89,6 +103,8 @@ jobs: -p:PublishReadyToRun=false -p:PublishSingleFile=true -p:CopyOutputSymbolsToPublishDirectory=false + -p:LafToken=$env:LAF_TOKEN + -p:LafPublisherId=$env:LAF_PUBLISHER_ID --nologo - name: Build x64 self-contained @@ -103,6 +119,8 @@ jobs: -p:PublishReadyToRun=true -p:PublishSingleFile=true -p:CopyOutputSymbolsToPublishDirectory=false + -p:LafToken=$env:LAF_TOKEN + -p:LafPublisherId=$env:LAF_PUBLISHER_ID --nologo - name: Build ARM64 framework-dependent @@ -116,6 +134,8 @@ jobs: -p:PublishSingleFile=true -p:EnableMsixTooling=true -p:CopyOutputSymbolsToPublishDirectory=false + -p:LafToken=$env:LAF_TOKEN + -p:LafPublisherId=$env:LAF_PUBLISHER_ID --nologo - name: Build ARM64 self-contained @@ -129,6 +149,8 @@ jobs: -p:PublishSingleFile=true -p:EnableMsixTooling=true -p:CopyOutputSymbolsToPublishDirectory=false + -p:LafToken=$env:LAF_TOKEN + -p:LafPublisherId=$env:LAF_PUBLISHER_ID --nologo - name: Rename ARM64 executables diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildDev.yml index a72cfec5..192dec9d 100644 --- a/.github/workflows/buildDev.yml +++ b/.github/workflows/buildDev.yml @@ -12,6 +12,12 @@ 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 }} + LAF_PUBLISHER_ID: ${{ secrets.LAF_PUBLISHER_ID }} jobs: build: @@ -26,11 +32,17 @@ 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 ${{ env.TEST_PATH }} -r win-x64 + run: dotnet test --project ${{ env.TEST_PATH }} -r win-x64 - name: Build for release and Publish - run: dotnet publish ${{ env.PROJECT_PATH }} -c Release --self-contained -r win-x64 -p:PublishSingleFile=true -p:EnableMsixTooling=true -o publish + run: dotnet publish ${{ env.PROJECT_PATH }} -c Release --self-contained -r win-x64 -p:PublishSingleFile=true -p:EnableMsixTooling=true -p:LafToken=$env:LAF_TOKEN -p:LafPublisherId=$env:LAF_PUBLISHER_ID -o publish - name: Upload artifact uses: actions/upload-artifact@v7 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..6633dc21 --- /dev/null +++ b/Tests.Core.Windows/FakeTextGrabSettings.cs @@ -0,0 +1,50 @@ +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 string LiveTranscriptionModel { 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..98ea3f74 --- /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.Core.Windows/WinAiLanguageModelLifetimeTests.cs b/Tests.Core.Windows/WinAiLanguageModelLifetimeTests.cs new file mode 100644 index 00000000..c2d7c312 --- /dev/null +++ b/Tests.Core.Windows/WinAiLanguageModelLifetimeTests.cs @@ -0,0 +1,202 @@ +using System.Reflection; +using Text_Grab.Utilities; + +namespace Text_Grab.Tests.Core.Windows; + +// These tests exercise the real lifetime gate without creating a native model or requiring a +// Copilot+ PC. The collection isolates the shared gate and the shutdown flag from other tests. +[Collection("Windows AI model lifetime")] +public sealed class WinAiLanguageModelLifetimeTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task ReleaseModelAsync_WaitsForActiveInference() + { + using CancellationTokenSource timeout = new(TestTimeout); + using IDisposable lease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + + Task release = WinAiLanguageModel.ReleaseModelAsync(timeout.Token); + try + { + Assert.False(release.IsCompleted); + } + finally + { + lease.Dispose(); + await release.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + } + + using IDisposable nextLease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + } + + [Fact] + public async Task ReleaseModelAsync_DoesNotResumeOnCallerSynchronizationContext() + { + using CancellationTokenSource timeout = new(TestTimeout); + using IDisposable lease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + RecordingSynchronizationContext context = new(); + SynchronizationContext? originalContext = SynchronizationContext.Current; + + Task release; + try + { + SynchronizationContext.SetSynchronizationContext(context); + release = WinAiLanguageModel.ReleaseModelAsync(timeout.Token); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + + lease.Dispose(); + await release.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + + Assert.Equal(0, context.PostCount); + } + + [Theory] + [InlineData("release")] + [InlineData("restart")] + [InlineData("warm-up")] + public async Task ExternalLifetimeOperation_CancellationDoesNotReleaseActiveInference(string operationName) + { + using CancellationTokenSource timeout = new(TestTimeout); + using CancellationTokenSource cancellation = new(); + using IDisposable lease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + + Task operation = operationName switch + { + "release" => WinAiLanguageModel.ReleaseModelAsync(cancellation.Token), + "restart" => WinAiLanguageModel.RestartModelAsync(cancellation.Token), + "warm-up" => WinAiLanguageModel.EnsureModelAsync(cancellation.Token), + _ => throw new ArgumentOutOfRangeException(nameof(operationName)), + }; + + try + { + Assert.False(operation.IsCompleted); + } + finally + { + cancellation.Cancel(); + } + + await Assert.ThrowsAnyAsync(() => operation.WaitAsync(TestTimeout, TestContext.Current.CancellationToken)); + + Task next = WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + try + { + Assert.False(next.IsCompleted); + } + finally + { + lease.Dispose(); + using IDisposable nextLease = await next.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task Recovery_DoesNotReacquireOrReleaseActiveInference() + { + using CancellationTokenSource timeout = new(TestTimeout); + using IDisposable lease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + + Action restart = GetRecoveryMethod(); + await Task.Run(() => restart(timeout.Token), timeout.Token).WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + + Task release = WinAiLanguageModel.ReleaseModelAsync(timeout.Token); + try + { + Assert.False(release.IsCompleted); + } + finally + { + lease.Dispose(); + await release.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task Recovery_CancellationIsPropagated() + { + using CancellationTokenSource timeout = new(TestTimeout); + using CancellationTokenSource cancellation = new(); + using IDisposable lease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + cancellation.Cancel(); + + Action restart = GetRecoveryMethod(); + OperationCanceledException exception = + Assert.Throws(() => restart(cancellation.Token)); + + Assert.Equal(cancellation.Token, exception.CancellationToken); + } + + [Fact] + public async Task Cleanup_ActiveLeaseCanFinishAndModelCannotBeRecreated() + { + FieldInfo disposed = typeof(WinAiLanguageModel).GetField("_disposed", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("The language model shutdown flag was not found."); + object? originalDisposed = disposed.GetValue(null); + + using CancellationTokenSource timeout = new(TestTimeout); + using IDisposable lease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + try + { + WinAiLanguageModel.Cleanup(); + lease.Dispose(); + + await WinAiLanguageModel.ReleaseModelAsync(timeout.Token).WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + (bool ready, string? error) = await WinAiLanguageModel.EnsureModelAsync(timeout.Token); + + Assert.False(ready); + Assert.NotNull(error); + Assert.Contains("shut down", error); + } + finally + { + disposed.SetValue(null, originalDisposed); + } + } + + [Fact] + public async Task InferenceLease_DisposeIsIdempotent() + { + using CancellationTokenSource timeout = new(TestTimeout); + IDisposable lease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + + lease.Dispose(); + lease.Dispose(); + + using IDisposable nextLease = await WinAiLanguageModel.AcquireInferenceAsync(timeout.Token); + } + + private static Action GetRecoveryMethod() + { + // Exercise the same private recovery path used by GenerateAsync and RunWithModelAsync + // without adding model factories or other production-only-for-testing abstractions. + MethodInfo method = typeof(WinAiLanguageModel).GetMethod( + "RestartModelUnderInferenceLease", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("The language model recovery method was not found."); + + return method.CreateDelegate>(); + } + + private sealed class RecordingSynchronizationContext : SynchronizationContext + { + private int _postCount; + + internal int PostCount => Volatile.Read(ref _postCount); + + public override void Post(SendOrPostCallback callback, object? state) + { + Interlocked.Increment(ref _postCount); + ThreadPool.QueueUserWorkItem(_ => callback(state)); + } + } +} + +[CollectionDefinition("Windows AI model lifetime", DisableParallelization = true)] +public sealed class WinAiLanguageModelLifetimeCollection +{ +} 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 74% rename from Tests/EditTextTableDocumentTests.cs rename to Tests.Core/EditTextTableDocumentTests.cs index d81f228a..30e31b56 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 { @@ -195,4 +195,73 @@ public void WrappedCells_MoveWithInsertedMovedAndDeletedRowsAndColumns() document.DeleteColumn(0); Assert.False(document.WrappedCells.Any()); } + + [Theory] + [InlineData("Alpha", 1, 1)] + [InlineData("Alpha\tBeta", 1, 2)] + [InlineData("Alpha\tBeta\r\n1\t2", 2, 2)] + [InlineData("Alpha\r\nBeta", 2, 1)] + public void ParseTabSeparatedRows_SplitsRowsAndColumns(string input, int expectedRowCount, int expectedColumnCount) + { + List rows = EditTextTableDocument.ParseTabSeparatedRows(input); + + Assert.Equal(expectedRowCount, rows.Count); + Assert.All(rows, row => Assert.Equal(expectedColumnCount, row.Length)); + } + + [Fact] + public void ParseTabSeparatedRows_TrimsTrailingNewlineArtifact() + { + List rows = EditTextTableDocument.ParseTabSeparatedRows("A\tB\r\n1\t2\r\n"); + + Assert.Equal(2, rows.Count); + Assert.Equal(["1", "2"], rows[1]); + } + + [Fact] + public void ParseTabSeparatedRows_EmptyInput_ReturnsNoRows() + { + Assert.Empty(EditTextTableDocument.ParseTabSeparatedRows(string.Empty)); + Assert.Empty(EditTextTableDocument.ParseTabSeparatedRows(null)); + } + + [Theory] + [InlineData("Alpha", true)] + [InlineData("Alpha\tBeta", false)] + [InlineData("Alpha\r\nBeta", false)] + [InlineData("Alpha\tBeta\r\n1\t2", false)] + public void IsSingleCellGrid_OnlyTrueForOneRowOneColumn(string input, bool expected) + { + List rows = EditTextTableDocument.ParseTabSeparatedRows(input); + + Assert.Equal(expected, EditTextTableDocument.IsSingleCellGrid(rows)); + } + + [Fact] + public void GetFirstFullyEmptyRowIndex_ReturnsIndexAfterLastPopulatedRow() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB\r\nC\tD"); + + Assert.Equal(2, document.GetFirstFullyEmptyRowIndex()); + } + + [Fact] + public void GetFirstFullyEmptyRowIndex_AllRowsEmpty_ReturnsZero() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText(string.Empty); + + Assert.Equal(0, document.GetFirstFullyEmptyRowIndex()); + } + + [Fact] + public void GetFirstFullyEmptyRowIndex_IgnoresBlankRowsBelowPopulatedData() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText( + "A\tB", + minimumRowCount: 10, + minimumColumnCount: 2); + document.Rows[5][0] = "populated"; + + Assert.Equal(6, document.GetFirstFullyEmptyRowIndex()); + } } 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/LocalAiResultUtilitiesTests.cs b/Tests.Core/LocalAiResultUtilitiesTests.cs new file mode 100644 index 00000000..e8141934 --- /dev/null +++ b/Tests.Core/LocalAiResultUtilitiesTests.cs @@ -0,0 +1,37 @@ +using Text_Grab.Utilities; + +namespace Text_Grab.Tests.Core; + +public class LocalAiResultUtilitiesTests +{ + [Fact] + public void IsUnchanged_IdenticalText_ReturnsTrue() + { + Assert.True(LocalAiResultUtilities.IsUnchanged("Hello world", "Hello world")); + } + + [Fact] + public void IsUnchanged_DifferentText_ReturnsFalse() + { + Assert.False(LocalAiResultUtilities.IsUnchanged("Hello world", "Hello, world!")); + } + + [Fact] + public void IsUnchanged_IgnoresLineEndingsAndSurroundingWhitespace() + { + Assert.True(LocalAiResultUtilities.IsUnchanged("line one\r\nline two", "line one\nline two\n")); + Assert.True(LocalAiResultUtilities.IsUnchanged(" padded ", "padded")); + } + + [Fact] + public void IsUnchanged_InteriorWhitespaceStillCounts() + { + Assert.False(LocalAiResultUtilities.IsUnchanged("one two", "one two")); + } + + [Fact] + public void IsUnchanged_CaseSensitive() + { + Assert.False(LocalAiResultUtilities.IsUnchanged("Hello", "hello")); + } +} 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 84% rename from Tests/StringMethodTests.cs rename to Tests.Core/StringMethodTests.cs index 41f6b535..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 { @@ -539,4 +539,94 @@ public void TestGuidCorrections(string input, string expected) { Assert.Equal(expected, input.CorrectCommonGuidErrors()); } + + [Fact] + public void CleanUpText_CollapsesSpacesTabsAndTrimsEachLine() + { + string messyText = "\tHello there \n general \t\t kenobi "; + string expected = $"Hello there{Environment.NewLine}general kenobi"; + + Assert.Equal(expected, messyText.CleanUpText()); + } + + [Fact] + public void CleanUpText_ReducesBlankLineRunsToOneBlankLine() + { + string messyText = "First paragraph.\n\n\n\n \n\nSecond paragraph."; + string expected = $"First paragraph.{Environment.NewLine}{Environment.NewLine}Second paragraph."; + + Assert.Equal(expected, messyText.CleanUpText()); + } + + [Fact] + public void CleanUpText_KeepsSingleNewlinesBetweenLines() + { + string messyText = "one\ntwo\nthree"; + string expected = $"one{Environment.NewLine}two{Environment.NewLine}three"; + + Assert.Equal(expected, messyText.CleanUpText()); + } + + [Fact] + public void CleanUpText_NormalizesMixedNewlineStyles() + { + string messyText = "one\r\ntwo\rthree\nfour"; + string expected = string.Join(Environment.NewLine, "one", "two", "three", "four"); + + Assert.Equal(expected, messyText.CleanUpText()); + } + + [Fact] + public void CleanUpText_RemovesNonBreakingAndZeroWidthCharacters() + { + // A non-breaking space, a zero-width space, and a BOM, all common in text copied + // from a web page and all invisible to the user. + string messyText = "web\u00A0 page\u200Btext\uFEFF here"; + string expected = "web pagetext here"; + + Assert.Equal(expected, messyText.CleanUpText()); + } + + [Fact] + public void CleanUpText_TrimsLeadingAndTrailingBlankLines() + { + string messyText = "\n\n \nkeep me\n \n\n"; + + Assert.Equal("keep me", messyText.CleanUpText()); + } + + [Fact] + public void CleanUpText_LeavesLatinLookalikesAloneByDefault() + { + string messyText = "Ωmega"; + + Assert.Equal("Ωmega", messyText.CleanUpText()); + Assert.Equal("Omega", messyText.CleanUpText(correctToLatin: true)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\n\n\n")] + public void CleanUpText_EmptyOrWhitespaceOnlyReturnsEmpty(string input) + { + Assert.Equal(string.Empty, input.CleanUpText()); + } + + [Fact] + public void TrimEachLine_TrimsLinesAndDropsBlankOnes() + { + string messyText = string.Join(Environment.NewLine, " first ", " ", "", "second\t"); + string expected = $"first{Environment.NewLine}second{Environment.NewLine}"; + + Assert.Equal(expected, messyText.TrimEachLine()); + } + + [Fact] + public void TrimEachLine_AllBlankReturnsEmpty() + { + string messyText = string.Join(Environment.NewLine, " ", "", "\t"); + + Assert.Equal(string.Empty, messyText.TrimEachLine()); + } } diff --git a/Tests.Core/Tests.Core.csproj b/Tests.Core/Tests.Core.csproj new file mode 100644 index 00000000..042c9978 --- /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 af36ff6f..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); @@ -182,4 +182,58 @@ public void ConvertHtmlToTabSeparated_ParsesBrowserExtensionRegionTable() //
collapses to a space; & decodes to &. Assert.Equal("Monitor arm\t5\t$130 & up", lines[2]); } + + [Fact] + public void BuildCfHtmlTable_RoundTripsThroughConvertHtmlToTabSeparated() + { + string cfHtml = CfHtmlTableUtilities.BuildCfHtmlTable( + [ + ["Month", "Int", "Season"], + ["January", "1", "Winter"], + ]); + + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(cfHtml); + + string[] lines = result.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Month\tInt\tSeason", lines[0]); + Assert.Equal("January\t1\tWinter", lines[1]); + } + + [Fact] + public void BuildCfHtmlTable_HeaderOffsetsPointAtFragmentBoundaries() + { + 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)); + int startFragment = int.Parse(cfHtml.Substring(cfHtml.IndexOf("StartFragment:") + "StartFragment:".Length, 10)); + int endFragment = int.Parse(cfHtml.Substring(cfHtml.IndexOf("EndFragment:") + "EndFragment:".Length, 10)); + + byte[] utf8Bytes = System.Text.Encoding.UTF8.GetBytes(cfHtml); + + Assert.True(startHtml < startFragment); + Assert.True(startFragment < endFragment); + Assert.True(endFragment <= endHtml); + Assert.True(endHtml <= utf8Bytes.Length); + + string fragment = System.Text.Encoding.UTF8.GetString(utf8Bytes, startFragment, endFragment - startFragment); + Assert.Equal("
ab
", fragment); + } + + [Fact] + public void BuildCfHtmlTable_EscapesHtmlAndConvertsNewlinesToBreaks() + { + string cfHtml = CfHtmlTableUtilities.BuildCfHtmlTable([["A & B", "line1\r\nline2"]]); + + string result = CfHtmlTableUtilities.ConvertHtmlToTabSeparated(cfHtml); + + Assert.Equal("A & B\tline1 line2", result); + } + + [Fact] + public void BuildCfHtmlTable_ReturnsEmptyForNoRows() + { + Assert.Equal(string.Empty, CfHtmlTableUtilities.BuildCfHtmlTable([])); + } } diff --git a/Tests/EditTextWindowAggregateResultTests.cs b/Tests/EditTextWindowAggregateResultTests.cs new file mode 100644 index 00000000..860e5258 --- /dev/null +++ b/Tests/EditTextWindowAggregateResultTests.cs @@ -0,0 +1,62 @@ +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextWindowAggregateResultTests +{ + [Theory] + [InlineData("One summary.", 0, 0)] + [InlineData("One summary.", 1, 2)] + [InlineData("# Meeting notes\r\n- First decision\r\n- Owner\tAction", 0, 0)] + [InlineData("# Meeting notes\r\n- First decision\r\n- Owner\tAction", 1, 2)] + public void WriteAggregateResultIntoSpreadsheetDocument_ReplacesOnlyAnchorCell( + string result, + int targetRow, + int targetColumn) + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\tc1\r\na2\tb2\tc2"); + string[][] original = [.. document.Rows.Select(row => row.ToArray())]; + + EditTextWindow.WriteAggregateResultIntoSpreadsheetDocument(document, result, targetRow, targetColumn); + + Assert.Equal(original.Length, document.Rows.Count); + for (int row = 0; row < original.Length; row++) + { + Assert.Equal(original[row].Length, document.Rows[row].Count); + for (int column = 0; column < original[row].Length; column++) + { + string expected = row == targetRow && column == targetColumn ? result : original[row][column]; + Assert.Equal(expected, document.Rows[row][column]); + } + } + + Assert.Single(document.Rows.SelectMany(row => row).Where(value => value == result)); + } + + [Fact] + public void WriteAggregateResultIntoSpreadsheetDocument_PreservesMultilineResultThroughDocumentPersistence() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("source\tkeep"); + const string result = "## Decisions\r\n- Keep\tthis together\r\n\r\n## Actions\r\n- Follow up"; + + EditTextWindow.WriteAggregateResultIntoSpreadsheetDocument(document, result, 0, 0); + EditTextTableDocument restored = Assert.IsType( + EditTextTableDocument.TryDeserialize(document.SerializeToJson())); + + Assert.Equal(result, restored.Rows[0][0]); + Assert.Equal("keep", restored.Rows[0][1]); + Assert.Single(restored.Rows.SelectMany(row => row).Where(value => value == result)); + } + + [Fact] + public void WriteAggregateResultIntoSpreadsheetDocument_CreatesOneResultInEmptySheet() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText(string.Empty); + + EditTextWindow.WriteAggregateResultIntoSpreadsheetDocument(document, "Summary", 0, 0); + + Assert.Equal("Summary", document.Rows[0][0]); + Assert.Single(document.Rows.SelectMany(row => row).Where(value => !string.IsNullOrEmpty(value))); + } +} diff --git a/Tests/EditTextWindowLiveTranscriptionTests.cs b/Tests/EditTextWindowLiveTranscriptionTests.cs new file mode 100644 index 00000000..acc4618f --- /dev/null +++ b/Tests/EditTextWindowLiveTranscriptionTests.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using System.Windows.Threading; +using Text_Grab; +using Text_Grab.Utilities; + +namespace Tests; + +public class EditTextWindowLiveTranscriptionTests +{ + [WpfFact] + public async Task StopAndDrainLiveTranscriptionAsync_KeepsFinalPhraseHandlerUntilStopFinishes() + { + using LiveAudioTranscriber transcriber = new(); + Dispatcher dispatcher = Dispatcher.CurrentDispatcher; + List delivered = []; + EventHandler handler = (_, phrase) => dispatcher.BeginInvoke(() => delivered.Add(phrase)); + transcriber.PhraseRecognized += handler; + + // Hold the real stop at its lifecycle gate, without opening an audio device or model. + SemaphoreSlim lifecycleGate = GetLifecycleGate(transcriber); + await lifecycleGate.WaitAsync(); + Task stop = EditTextWindow.StopAndDrainLiveTranscriptionAsync(transcriber, handler, dispatcher); + try + { + Assert.False(stop.IsCompleted); + await Task.Run(() => GetPhraseHandlers(transcriber)?.Invoke(transcriber, "final speech")); + Assert.NotNull(GetPhraseHandlers(transcriber)); + } + finally + { + lifecycleGate.Release(); + await stop; + } + + Assert.Equal(["final speech"], delivered); + Assert.Null(GetPhraseHandlers(transcriber)); + } + + [WpfFact] + public async Task StopAndDrainLiveTranscriptionAsync_DrainsQueuedPhrasesBeforeDetaching() + { + using LiveAudioTranscriber transcriber = new(); + Dispatcher dispatcher = Dispatcher.CurrentDispatcher; + List delivered = []; + bool subscribedDuringDelivery = false; + EventHandler handler = (_, phrase) => dispatcher.BeginInvoke(() => + { + subscribedDuringDelivery = GetPhraseHandlers(transcriber) is not null; + delivered.Add(phrase); + }); + transcriber.PhraseRecognized += handler; + + GetPhraseHandlers(transcriber)!.Invoke(transcriber, "last queued phrase"); + Task stop = EditTextWindow.StopAndDrainLiveTranscriptionAsync(transcriber, handler, dispatcher); + + Assert.False(stop.IsCompleted); + Assert.Empty(delivered); + Assert.NotNull(GetPhraseHandlers(transcriber)); + await stop; + + Assert.True(subscribedDuringDelivery); + Assert.Equal(["last queued phrase"], delivered); + Assert.Null(GetPhraseHandlers(transcriber)); + } + + [WpfFact] + public async Task StopAndDrainLiveTranscriptionAsync_AllowsResubscribingWithoutDuplicatingPhrases() + { + using LiveAudioTranscriber transcriber = new(); + Dispatcher dispatcher = Dispatcher.CurrentDispatcher; + List delivered = []; + EventHandler handler = (_, phrase) => dispatcher.BeginInvoke(() => delivered.Add(phrase)); + + transcriber.PhraseRecognized += handler; + GetPhraseHandlers(transcriber)!.Invoke(transcriber, "first session"); + await EditTextWindow.StopAndDrainLiveTranscriptionAsync(transcriber, handler, dispatcher); + + transcriber.PhraseRecognized += handler; + GetPhraseHandlers(transcriber)!.Invoke(transcriber, "second session"); + await EditTextWindow.StopAndDrainLiveTranscriptionAsync(transcriber, handler, dispatcher); + + Assert.Equal(["first session", "second session"], delivered); + Assert.Null(GetPhraseHandlers(transcriber)); + } + + private static SemaphoreSlim GetLifecycleGate(LiveAudioTranscriber transcriber) + { + FieldInfo field = typeof(LiveAudioTranscriber).GetField("_lifecycleLock", BindingFlags.Instance | BindingFlags.NonPublic)!; + return Assert.IsType(field.GetValue(transcriber)); + } + + private static EventHandler? GetPhraseHandlers(LiveAudioTranscriber transcriber) + { + FieldInfo field = typeof(LiveAudioTranscriber).GetField(nameof(LiveAudioTranscriber.PhraseRecognized), BindingFlags.Instance | BindingFlags.NonPublic)!; + return (EventHandler?)field.GetValue(transcriber); + } +} diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs index 4529e926..2cb5e160 100644 --- a/Tests/EditTextWindowSpreadsheetTests.cs +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -118,6 +118,49 @@ public void BuildSpreadsheetSelectionText_IncludesOnlySelectedCells() Assert.Equal("b1" + Environment.NewLine + "a2\tc2", selectionText); } + [Fact] + public void BuildSpreadsheetSelectionHtml_IncludesOnlySelectedCellsAsTableRows() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Columns.Add("C", typeof(string)); + dataTable.Rows.Add("a1", "b1", "c1"); + dataTable.Rows.Add("a2", "b2", "c2"); + + string html = EditTextWindow.BuildSpreadsheetSelectionHtml( + dataTable, + [ + (0, 0), + (0, 2), + (1, 0), + (1, 2), + (-1, 0), + (5, 5) + ]); + + string tabSeparated = Text_Grab.Utilities.CfHtmlTableUtilities.ConvertHtmlToTabSeparated(html); + + Assert.Equal("a1\tc1" + Environment.NewLine + "a2\tc2", tabSeparated.Replace("\n", Environment.NewLine)); + } + + [Fact] + public void BuildSpreadsheetSelectionHtml_ReturnsEmptyWhenNoValidCells() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Rows.Add("a1"); + + string html = EditTextWindow.BuildSpreadsheetSelectionHtml( + dataTable, + [ + (-1, 0), + (5, 5) + ]); + + Assert.Equal(string.Empty, html); + } + [Fact] public void BuildSpreadsheetSelectionMarkdown_BuildsTableFromSelectedCells() { @@ -451,4 +494,100 @@ public void GetSpreadsheetPersistedRowHeight_PersistsOnlyExplicitPositiveHeights { Assert.Equal(expectedHeight, EditTextWindow.GetSpreadsheetPersistedRowHeight(rowHeight)); } + + [Fact] + public void WriteGridIntoSpreadsheetDocument_WritesEachCellIntoItsOwnRowAndColumn() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText(string.Empty); + List grid = [["Name", "Age"], ["Joe", "42"]]; + + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, grid, startRow: 0, startCol: 0); + + Assert.Equal("Name", document.Rows[0][0]); + Assert.Equal("Age", document.Rows[0][1]); + Assert.Equal("Joe", document.Rows[1][0]); + Assert.Equal("42", document.Rows[1][1]); + } + + [Fact] + public void WriteGridIntoSpreadsheetDocument_AppendsAtRequestedBottomRow_WithoutDisturbingExistingRows() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("Existing\tRow"); + int appendRow = document.GetFirstFullyEmptyRowIndex(); + List grid = [["Name", "Age"], ["Joe", "42"]]; + + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, grid, startRow: appendRow, startCol: 0); + + Assert.Equal("Existing", document.Rows[0][0]); + Assert.Equal("Row", document.Rows[0][1]); + Assert.Equal("Name", document.Rows[appendRow][0]); + Assert.Equal("Age", document.Rows[appendRow][1]); + Assert.Equal("Joe", document.Rows[appendRow + 1][0]); + Assert.Equal("42", document.Rows[appendRow + 1][1]); + } + + [Fact] + public void WriteGridIntoSpreadsheetDocument_ExpandsColumnsForRaggedRows_WithoutSquishingIntoOneColumn() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText(string.Empty); + List grid = [["A", "B", "C"], ["1", "2"]]; + + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, grid, startRow: 0, startCol: 0); + + Assert.True(document.ColumnCount >= 3); + Assert.Equal("A", document.Rows[0][0]); + Assert.Equal("B", document.Rows[0][1]); + Assert.Equal("C", document.Rows[0][2]); + Assert.Equal("1", document.Rows[1][0]); + Assert.Equal("2", document.Rows[1][1]); + Assert.Equal(string.Empty, document.Rows[1][2]); + } + + [Fact] + public void WriteGridIntoSpreadsheetDocument_SequentialBottomAppends_LandOnSeparateRows() + { + // Regression for repeated table-mode FSG grabs sent to the same Spreadsheet-mode ETW: + // each grab must land on its own row instead of clobbering the previous one. + EditTextTableDocument document = EditTextTableDocument.CreateFromText(string.Empty); + + int firstAppendRow = document.GetFirstFullyEmptyRowIndex(); + Assert.Equal(0, firstAppendRow); + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, [["Name", "Age"]], firstAppendRow, 0); + + int secondAppendRow = document.GetFirstFullyEmptyRowIndex(); + Assert.Equal(1, secondAppendRow); + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, [["Joe", "42"]], secondAppendRow, 0); + + int thirdAppendRow = document.GetFirstFullyEmptyRowIndex(); + Assert.Equal(2, thirdAppendRow); + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, [["Jane", "30"]], thirdAppendRow, 0); + + Assert.Equal("Name", document.Rows[0][0]); + Assert.Equal("Age", document.Rows[0][1]); + Assert.Equal("Joe", document.Rows[1][0]); + Assert.Equal("42", document.Rows[1][1]); + Assert.Equal("Jane", document.Rows[2][0]); + Assert.Equal("30", document.Rows[2][1]); + } + + [Fact] + public void WriteGridIntoSpreadsheetDocument_SingleCellAtExplicitCurrentCell_DoesNotDisturbOtherRows() + { + // A single-cell grab result should land at the currently selected spreadsheet cell, + // not get spliced into whatever the underlying (hidden) text box's cursor last was. + EditTextTableDocument document = EditTextTableDocument.CreateFromText(string.Empty); + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, [["Name", "Age"]], 0, 0); + + List singleCellGrid = EditTextTableDocument.ParseTabSeparatedRows("Joe"); + Assert.True(EditTextTableDocument.IsSingleCellGrid(singleCellGrid)); + + int currentCellRow = 1; + int currentCellColumn = 0; + EditTextWindow.WriteGridIntoSpreadsheetDocument(document, singleCellGrid, currentCellRow, currentCellColumn); + + Assert.Equal("Name", document.Rows[0][0]); + Assert.Equal("Age", document.Rows[0][1]); + Assert.Equal("Joe", document.Rows[1][0]); + Assert.Equal(string.Empty, document.Rows[1][1]); + } } 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/GrabFrameEtwTests.cs b/Tests/GrabFrameEtwTests.cs index a133bc1c..d37999fc 100644 --- a/Tests/GrabFrameEtwTests.cs +++ b/Tests/GrabFrameEtwTests.cs @@ -49,4 +49,24 @@ public void ShouldUpdateLinkedDestinationText_PreservesSpreadsheetSelectionWhenC Assert.Equal(expected, shouldUpdate); } + + [Theory] + [InlineData(true, true, true, true)] + [InlineData(true, true, false, false)] + [InlineData(true, false, true, false)] + [InlineData(false, true, true, false)] + [InlineData(false, false, false, false)] + public void ShouldForceTableModeForNewGrab_OnlyWhenLinkedToSpreadsheetModeEtwAndTableModeAvailable( + bool hasDestinationTextBox, + bool isDestinationSpreadsheetMode, + bool isTableModeAvailable, + bool expected) + { + bool actual = WindowUtilities.ShouldForceTableModeForNewGrab( + hasDestinationTextBox, + isDestinationSpreadsheetMode, + isTableModeAvailable); + + Assert.Equal(expected, actual); + } } diff --git a/Tests/GrabFrameRedrawTests.cs b/Tests/GrabFrameRedrawTests.cs new file mode 100644 index 00000000..61435f8c --- /dev/null +++ b/Tests/GrabFrameRedrawTests.cs @@ -0,0 +1,163 @@ +using Text_Grab.Views; + +namespace Tests; + +public class GrabFrameRedrawTests +{ + [Fact] + public async Task RunAsync_NavigationDuringOcrDrawsOnlyLatestPageAndRejectsStaleResults() + { + GrabFrame.RedrawCoordinator redraws = new(); + TaskCompletionSource finishFirstOcr = new(TaskCreationOptions.RunContinuationsAsynchronously); + List startedPages = []; + List renderedPages = []; + int activeDraws = 0; + int maximumActiveDraws = 0; + + Task DrawPageAsync(int pageIndex) + { + return redraws.RunAsync(async version => + { + activeDraws++; + maximumActiveDraws = Math.Max(maximumActiveDraws, activeDraws); + startedPages.Add(pageIndex); + + if (pageIndex == 0) + await finishFirstOcr.Task; + + if (redraws.IsCurrent(version)) + renderedPages.Add(pageIndex); + + activeDraws--; + }); + } + + Task firstDraw = DrawPageAsync(0); + redraws.Invalidate(); + Task secondDraw = DrawPageAsync(1); + redraws.Invalidate(); + Task latestDraw = DrawPageAsync(2); + + Assert.Equal([0], startedPages); + Assert.False(secondDraw.IsCompleted); + Assert.False(latestDraw.IsCompleted); + + finishFirstOcr.SetResult(); + await Task.WhenAll(firstDraw, secondDraw, latestDraw) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal([0, 2], startedPages); + Assert.Equal([2], renderedPages); + Assert.Equal(1, maximumActiveDraws); + Assert.Equal(0, activeDraws); + } + + [Fact] + public async Task RunAsync_ResetWhileDrawsArePendingWaitsForNewPageRequest() + { + GrabFrame.RedrawCoordinator redraws = new(); + TaskCompletionSource finishOcr = new(TaskCreationOptions.RunContinuationsAsynchronously); + int startedDraws = 0; + int renderedDraws = 0; + + Task firstDraw = redraws.RunAsync(async version => + { + startedDraws++; + await finishOcr.Task; + if (redraws.IsCurrent(version)) + renderedDraws++; + }); + Task queuedDraw = redraws.RunAsync(_ => + { + startedDraws++; + renderedDraws++; + return Task.CompletedTask; + }); + + redraws.Invalidate(); + finishOcr.SetResult(); + await Task.WhenAll(firstDraw, queuedDraw) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(1, startedDraws); + Assert.Equal(0, renderedDraws); + + await redraws.RunAsync(version => + { + Assert.True(redraws.IsCurrent(version)); + startedDraws++; + renderedDraws++; + return Task.CompletedTask; + }); + + Assert.Equal(2, startedDraws); + Assert.Equal(1, renderedDraws); + } + + [Fact] + public async Task RunAsync_InvalidationWithoutPendingRequestDoesNotRedraw() + { + GrabFrame.RedrawCoordinator redraws = new(); + TaskCompletionSource finishOcr = new(TaskCreationOptions.RunContinuationsAsynchronously); + int drawCount = 0; + bool appliedResult = false; + + Task draw = redraws.RunAsync(async version => + { + drawCount++; + await finishOcr.Task; + appliedResult = redraws.IsCurrent(version); + }); + + redraws.Invalidate(); + finishOcr.SetResult(); + await draw.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(1, drawCount); + Assert.False(appliedResult); + } + + [Fact] + public async Task RunAsync_CompletedDrawIsNotRepeated() + { + GrabFrame.RedrawCoordinator redraws = new(); + int drawCount = 0; + + await redraws.RunAsync(version => + { + Assert.True(redraws.IsCurrent(version)); + drawCount++; + return Task.CompletedTask; + }); + + Assert.Equal(1, drawCount); + } + + [Fact] + public async Task RunAsync_FailedOcrReleasesPendingRedraw() + { + GrabFrame.RedrawCoordinator redraws = new(); + TaskCompletionSource finishOcr = new(TaskCreationOptions.RunContinuationsAsynchronously); + bool renderedLatestPage = false; + + Task failedDraw = redraws.RunAsync(async _ => + { + await finishOcr.Task; + throw new InvalidOperationException("OCR failed."); + }); + Task latestDraw = redraws.RunAsync(version => + { + renderedLatestPage = redraws.IsCurrent(version); + return Task.CompletedTask; + }); + + Assert.False(latestDraw.IsCompleted); + finishOcr.SetResult(); + + await Assert.ThrowsAsync( + () => failedDraw.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + await latestDraw.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(renderedLatestPage); + } +} diff --git a/Tests/GrabPageRangeDialogTests.cs b/Tests/GrabPageRangeDialogTests.cs new file mode 100644 index 00000000..83fd64a9 --- /dev/null +++ b/Tests/GrabPageRangeDialogTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using Text_Grab.Controls; + +namespace Tests; + +public class GrabPageRangeDialogTests +{ + [Fact] + public void BuildPageIndices_All_ReturnsEveryPageInRange() + { + List indices = GrabPageRangeDialog.BuildPageIndices(2, 5, GrabPageParity.All); + + Assert.Equal([2, 3, 4, 5], indices); + } + + [Fact] + public void BuildPageIndices_OddOnly_UsesPrintedPageNumbers() + { + // Indices 0..5 are pages 1..6; odd pages are 1, 3, 5 → indices 0, 2, 4. + List indices = GrabPageRangeDialog.BuildPageIndices(0, 5, GrabPageParity.OddOnly); + + Assert.Equal([0, 2, 4], indices); + } + + [Fact] + public void BuildPageIndices_EvenOnly_UsesPrintedPageNumbers() + { + List indices = GrabPageRangeDialog.BuildPageIndices(0, 5, GrabPageParity.EvenOnly); + + Assert.Equal([1, 3, 5], indices); + } + + [Fact] + public void BuildPageIndices_EmptyRange_ReturnsNothing() + { + Assert.Empty(GrabPageRangeDialog.BuildPageIndices(4, 3, GrabPageParity.All)); + } +} 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 e75717e9..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,71 +110,164 @@ 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) + /// + /// 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, MarkdownFlowDocumentUtilities.MarkdownOffsetMap map, int rawStart, int length) { - Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(marker)); + TextPointer start = MarkdownFlowDocumentUtilities.MapRawOffsetToPosition(document, map, rawStart); + TextPointer end = MarkdownFlowDocumentUtilities.MapRawOffsetToPosition(document, map, rawStart + length); + return new TextRange(start, end).Text; } - [Theory] - [InlineData("text")] - [InlineData("hello # world")] - [InlineData("1.2")] - public void NonTriggerText_DoesNotPromoteLiveBlock(string text) + [WpfFact] + public void MapRawOffsetToPosition_SkipsStrippedBoldMarkers() { - Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(text)); + const string markdown = "Plain **bold** text with a [link](https://example.com)."; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("bold", StringComparison.Ordinal); + + Assert.Equal("bold", MapAndSlice(document, map, rawIndex, 4)); } - [Theory] - [InlineData("**bold**")] - [InlineData("`code`")] - [InlineData("[link](https://example.com)")] - [InlineData("[ ] task")] - [InlineData("[x] done")] - public void CompletedMarkdownSyntax_PromotesLiveParsing(string text) + [WpfFact] + public void MapRawOffsetToPosition_SkipsLinkBracketsAndUrl() { - Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + const string markdown = "Plain **bold** text with a [link](https://example.com)."; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("link", StringComparison.Ordinal); + + Assert.Equal("link", MapAndSlice(document, map, rawIndex, 4)); } - [Theory] - [InlineData("*")] - [InlineData("[link]")] - [InlineData("plain text")] - [InlineData("2026.04 release notes")] - public void IncompleteMarkdownSyntax_DoesNotPromoteLiveParsing(string text) + [WpfFact] + public void MapRawOffsetToPosition_SkipsHeadingHashPrefix() + { + const string markdown = "# My Heading Title"; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("Heading", StringComparison.Ordinal); + + Assert.Equal("Heading", MapAndSlice(document, map, rawIndex, 7)); + } + + [WpfFact] + public void MapRawOffsetToPosition_SkipsListMarkers() { - Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + const string markdown = "- first item\n- second item\n- third item"; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("third", StringComparison.Ordinal); + + // A list item's bullet marker is a documented, narrow exception (see GetLocalTextPointer's + // remarks): no WPF insertion position exists that sits "just past the marker" without also + // having consumed the item's first real character, so a match starting at the very first + // character of a list item's content ends up selecting the marker glyph too. The word itself + // still resolves exactly — this is the one place a couple of extra, harmless characters + // (the bullet + tab) get selected alongside it. + Assert.EndsWith("third", MapAndSlice(document, map, rawIndex, 5)); } - [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) + [WpfFact] + public void MapRawOffsetToPosition_DoesNotDriftIntoWrongParagraph() { - Assert.True(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + const string markdown = """ + First paragraph has some words in it. + + Second paragraph also has some words in it. + + Third paragraph has the target word right here. + """; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("target", StringComparison.Ordinal); + + Assert.Equal("target", MapAndSlice(document, map, rawIndex, 6)); } - [Theory] - [InlineData("Just a normal sentence.")] - [InlineData("2026.04 release notes")] - [InlineData("email me at joe@example.com")] - public void PlainText_IsNotDetectedAsMarkdown(string text) + [WpfFact] + public void MapRawOffsetToPosition_HandlesCodeSpanBackticks() { - Assert.False(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + const string markdown = "Run `dotnet build` to compile the project."; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("dotnet", StringComparison.Ordinal); + + Assert.Equal("dotnet", MapAndSlice(document, map, rawIndex, 6)); + } + + [WpfFact] + public void MapRawOffsetToPosition_HandlesBoldNestedInsideLinkText() + { + const string markdown = "See the [**important** notes](https://example.com) page."; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("important", StringComparison.Ordinal); + + Assert.Equal("important", MapAndSlice(document, map, rawIndex, 9)); + } + + [WpfFact] + public void MapRawOffsetToPosition_HandlesTextInsideTableCell() + { + // Regression test: TextRange.Text does not reliably count characters when a range starts + // outside a Table and ends inside one of its cells — every position inside a given table + // row measured that way collapsed to the same offset (the row's end). Offsets here must be + // resolved relative to the containing cell's own paragraph, never the document. + const string markdown = """ + | Name | Value | + | --- | --- | + | Alpha | fortytwo | + """; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("fortytwo", StringComparison.Ordinal); + + Assert.Equal("fortytwo", MapAndSlice(document, map, rawIndex, 8)); + } + + [WpfFact] + public void MapRawOffsetToPosition_HandlesTextInEarlierTableCellOnSameRow() + { + const string markdown = """ + | Name | Value | + | --- | --- | + | Alpha | fortytwo | + """; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("Alpha", StringComparison.Ordinal); + + Assert.Equal("Alpha", MapAndSlice(document, map, rawIndex, 5)); + } + + [WpfFact] + public void MapRawOffsetToPosition_MapsBothEndsOfAMatchToTheExactRenderedSubstring() + { + const string markdown = "Plain text with a target word right here."; + FlowDocument document = MarkdownFlowDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + MarkdownFlowDocumentUtilities.MarkdownOffsetMap map = MarkdownFlowDocumentUtilities.BuildOffsetMap(document); + + int rawIndex = markdown.IndexOf("target word", StringComparison.Ordinal); + + Assert.Equal("target word", MapAndSlice(document, map, rawIndex, 11)); } } 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 0655a315..00000000 --- a/Tests/OcrTests.cs +++ /dev/null @@ -1,1078 +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)); - 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)); - - 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/PdfDocumentRendererTests.cs b/Tests/PdfDocumentRendererTests.cs index 530bf7c8..d3ad2461 100644 --- a/Tests/PdfDocumentRendererTests.cs +++ b/Tests/PdfDocumentRendererTests.cs @@ -1,3 +1,4 @@ +using Text_Grab.Models; using Text_Grab.Utilities; using UglyToad.PdfPig.Core; using Windows.Media.Ocr; @@ -6,6 +7,38 @@ namespace Tests; public class PdfDocumentRendererTests { + + const string expectedPDFText = """ + Text Grab Demo PDF + + Milwaukee, September 4th 2026 + + Text Grab Corp. + 123 N. Main St. + Anywhere, USA 12345 + + Dear Testing framework reading this, + + A hungry Fox saw some fine bunches of Grapes hanging from a vine that was trained along a high trellis, and did his best to reach them by jumping as high as he could into the air. But it was all in vain, for they were just out of reach: so he gave up trying, and walked away with an air of dignity and unconcern, remarking, "I thought those Grapes were ripe, but I see now they are quite sour." + + A Man and his Wife had the good fortune to possess a Goose which laid a Golden Egg every day. Lucky though they were, they soon began to think they were not getting rich fast enough, and, imagining the bird must be made of gold inside, they decided to kill it in order to secure the whole store of precious metal at once. But when they cut it open they found it was just like any other goose. Thus, they neither got rich all at once, as they had hoped, nor enjoyed any longer the daily addition to their wealth. + Much wants more and loses all. + + There was once a house that was overrun with Mice. A Cat heard of this, and said to herself, "That's the place for me," and off she went and took up her quarters in the house, and caught the Mice one by one and ate them. At last the Mice could stand it no longer, and they determined to take to their holes and stay there. "That's awkward," said the Cat to herself: "the only thing to do is to coax them out by a trick." So she considered a while, and then climbed up the wall and let herself hang down by her hind legs from a peg, and pretended to be dead. By and by a Mouse peeped out and saw the Cat hanging there. "Aha!" it cried, "you're very clever, madam, no doubt: but you may turn yourself into a bag of meal hanging there, if you like, yet you won't catch us coming anywhere near you." + If you are wise you won't be deceived by the innocent airs of those whom you have once found to be dangerous. + + There was once a Dog who used to snap at people and bite them without any provocation, and who was a great nuisance to every one who came to his master's house. So his master fastened a bell round his neck to warn people of his presence. The Dog was very proud of the bell, and strutted about tinkling it with immense satisfaction. But an old dog came up to him and said, "The fewer airs you give yourself the better, my friend. You don't think, do you, that your bell was given you as a reward of merit? On the contrary, it is a badge of disgrace." + Notoriety is often mistaken for fame. + + Thank you, + AESOP + + Contact + joe@joefinapps.com + 123-555-1234 + Milwaukee, WI + """; + [Fact] public void GetRenderDimensions_DoublesTypicalPdfPageSize() { @@ -80,6 +113,205 @@ public void GroupWordsIntoLines_GroupsNearbyWordsIntoSingleLine() secondLine => Assert.Equal("Again", secondLine.Text)); } + [WpfFact] + public async Task BuildTextFromLines_DetectsParagraphsInSamplePdf() + { + string pdfPath = FileUtilities.GetPathToLocalFile(@"TextFiles\Text-Grab-Test-PDF.pdf"); + + using PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(pdfPath); + PdfPageContent pageContent = await pdfDocument.GetPageContentAsync(pageIndex: 0); + string text = PdfDocumentRenderer.BuildTextFromLines(pageContent.NativeLines, useParagraphDetection: true); + + Assert.Contains( + "A hungry Fox saw some fine bunches of Grapes hanging from a vine that was trained along a high trellis, and did his best to reach them by jumping as high as he could into the air. But it was all in vain, for they were just out of reach: so he gave up trying, and walked away with an air of dignity and unconcern, remarking, \"I thought those Grapes were ripe, but I see now they are quite sour.\"", + text); + } + + [WpfFact] + public async Task GetSelectableWordsAsync_ReturnsMoreWordsThanLines() + { + string pdfPath = FileUtilities.GetPathToLocalFile(@"TextFiles\Text-Grab-Test-PDF.pdf"); + + using PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(pdfPath); + PdfPageContent pageContent = await pdfDocument.GetPageContentAsync(pageIndex: 0); + IReadOnlyList words = await pdfDocument.GetSelectableWordsAsync(pageIndex: 0); + + Assert.True(pageContent.HasNativeText); + Assert.True(words.Count > pageContent.NativeLines.Count); + Assert.All(pageContent.NativeWords, word => Assert.Contains(word, words)); + Assert.Contains(words, word => word.Text == "Milwaukee,"); + } + + [Fact] + public void CombineNativeAndOcrWords_PreservesScannedTableWithNativePageNumber() + { + PdfPageTextLine pageNumber = new(new Windows.Foundation.Rect(150, 280, 10, 10), "1", isNativeText: true); + GeneratedOcrLinesWords ocrResult = new() + { + Lines = + [ + new GeneratedOcrLine + { + Words = + [ + new GeneratedOcrWord { Text = "Quantity", BoundingBox = new(200, 40, 60, 12) }, + new GeneratedOcrWord { Text = "Item", BoundingBox = new(40, 40, 40, 12) } + ] + }, + new GeneratedOcrLine + { + Words = + [ + new GeneratedOcrWord { Text = "Apple", BoundingBox = new(40, 80, 40, 12) }, + new GeneratedOcrWord { Text = "2", BoundingBox = new(200, 80, 10, 12) } + ] + }, + GeneratedOcrLine.FromText("1", pageNumber.SourceRect) + ] + }; + + IReadOnlyList words = PdfDocumentRenderer.CombineNativeAndOcrWords( + [pageNumber], [new(0, 0, 300, 300)], ocrResult, scale: 1); + + Assert.Equal(["Item", "Quantity", "Apple", "2", "1"], words.Select(word => word.Text).ToArray()); + Assert.All(words.Take(4), word => Assert.False(word.IsNativeText)); + Assert.Same(pageNumber, words[^1]); + Assert.Equal(160, words[1].SourceRect.Left - words[0].SourceRect.Left); + } + + [Theory] + [InlineData(0.5)] + [InlineData(1)] + [InlineData(2.5)] + public void CombineNativeAndOcrWords_MapsScaledOcrToPageBeforeFiltering(double scale) + { + Windows.Foundation.Rect imageRegion = PdfDocumentRenderer.ConvertPdfRectToImageRect( + new PdfRectangle(40, 90, 160, 170), 200, 200, 400, 400); + Windows.Foundation.Rect imageWordRect = new(110, 90, 40, 12); + PdfPageTextLine nativeWord = new(new Windows.Foundation.Rect(180, 90, 50, 12), "Native", isNativeText: true); + GeneratedOcrLinesWords ocrResult = new() + { + Lines = + [ + GeneratedOcrLine.FromText("Image", new( + imageWordRect.X * scale, imageWordRect.Y * scale, + imageWordRect.Width * scale, imageWordRect.Height * scale)), + GeneratedOcrLine.FromText("Duplicate", new(180 * scale, 90 * scale, 50 * scale, 12 * scale)), + GeneratedOcrLine.FromText("Outside image", new(10 * scale, 10 * scale, 40 * scale, 12 * scale)) + ] + }; + + IReadOnlyList words = PdfDocumentRenderer.CombineNativeAndOcrWords( + [nativeWord], [imageRegion], ocrResult, scale); + + Assert.Collection( + words, + imageWord => + { + Assert.Equal("Image", imageWord.Text); + Assert.Equal(imageWordRect, imageWord.SourceRect); + Assert.False(imageWord.IsNativeText); + }, + word => Assert.Same(nativeWord, word)); + } + + [Fact] + public void CombineNativeAndOcrWords_SuppressesNativeOverlapsWithoutDroppingColumnGaps() + { + PdfPageTextLine leftWord = new(new Windows.Foundation.Rect(10, 40, 20, 12), "10", isNativeText: true); + PdfPageTextLine rightWord = new(new Windows.Foundation.Rect(190, 40, 20, 12), "30", isNativeText: true); + GeneratedOcrLinesWords ocrResult = new() + { + Lines = + [ + new GeneratedOcrLine + { + Words = + [ + new GeneratedOcrWord { Text = "1O", BoundingBox = new(12, 41, 20, 12) }, + new GeneratedOcrWord { Text = "10", BoundingBox = new(100, 40, 20, 12) }, + new GeneratedOcrWord { Text = "3O", BoundingBox = new(188, 39, 20, 12) } + ] + } + ] + }; + + IReadOnlyList words = PdfDocumentRenderer.CombineNativeAndOcrWords( + [leftWord, rightWord], [new(0, 0, 240, 100)], ocrResult, scale: 1); + + Assert.Collection( + words, + word => Assert.Same(leftWord, word), + imageWord => + { + Assert.Equal("10", imageWord.Text); + Assert.Equal(100, imageWord.SourceRect.X); + Assert.False(imageWord.IsNativeText); + }, + word => Assert.Same(rightWord, word)); + } + + [Fact] + public void CombineNativeAndOcrWords_OverlappingImagesDoNotDuplicateWords() + { + PdfPageTextLine nativeWord = new(new Windows.Foundation.Rect(0, 0, 20, 10), "Native", isNativeText: true); + GeneratedOcrLinesWords ocrResult = GeneratedOcrLinesWords.FromParagraph("Image", new(100, 100, 20, 10)); + + IReadOnlyList words = PdfDocumentRenderer.CombineNativeAndOcrWords( + [nativeWord], [new(90, 90, 50, 40), new(100, 100, 60, 50)], ocrResult, scale: 1); + + Assert.Equal(2, words.Count); + Assert.Single(words, word => word.Text == "Image"); + } + + [Fact] + public void CombineNativeAndOcrWords_FiltersEmptyWordsAndInsignificantImageOverlap() + { + PdfPageTextLine nativeWord = new(new Windows.Foundation.Rect(0, 0, 20, 10), "Native", isNativeText: true); + GeneratedOcrLinesWords ocrResult = new() + { + Lines = + [ + GeneratedOcrLine.FromText("Keep", new(105, 105, 10, 10)), + GeneratedOcrLine.FromText("Outside", new(108, 108, 10, 10)), + GeneratedOcrLine.FromText(" ", new(100, 100, 10, 10)), + GeneratedOcrLine.FromText("Empty bounds", new(100, 100, 0, 10)) + ] + }; + + IReadOnlyList words = PdfDocumentRenderer.CombineNativeAndOcrWords( + [nativeWord], [new(100, 100, 10, 10)], ocrResult, scale: 1); + + Assert.Equal(["Native", "Keep"], words.Select(word => word.Text).ToArray()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CombineNativeAndOcrWords_MissingOcrPreservesNativeWords(bool nullResult) + { + IReadOnlyList nativeWords = + [new(new Windows.Foundation.Rect(10, 10, 40, 12), "Native", isNativeText: true)]; + + IReadOnlyList words = PdfDocumentRenderer.CombineNativeAndOcrWords( + nativeWords, [new(0, 0, 100, 100)], nullResult ? null : new GeneratedOcrLinesWords(), scale: 1); + + Assert.Same(nativeWords, words); + } + + [Fact] + public void CombineNativeAndOcrWords_WithoutImagesPreservesNativeWords() + { + IReadOnlyList nativeWords = + [new(new Windows.Foundation.Rect(10, 10, 40, 12), "Native", isNativeText: true)]; + GeneratedOcrLinesWords ocrResult = GeneratedOcrLinesWords.FromParagraph("Ignore", new(100, 100, 20, 10)); + + IReadOnlyList words = PdfDocumentRenderer.CombineNativeAndOcrWords( + nativeWords, [], ocrResult, scale: 1); + + Assert.Same(nativeWords, words); + } + [Fact] public void ShouldIncludeOcrLine_OnlyReturnsTrueWhenImageOverlapIsMeaningful() { diff --git a/Tests/PostGrabActionManagerTests.cs b/Tests/PostGrabActionManagerTests.cs index 7a0a3b92..cf15dead 100644 --- a/Tests/PostGrabActionManagerTests.cs +++ b/Tests/PostGrabActionManagerTests.cs @@ -13,7 +13,7 @@ public void GetDefaultPostGrabActions_ReturnsExpectedCount() // Assert Assert.NotNull(actions); - Assert.Equal(6, actions.Count); + Assert.Equal(7, actions.Count); } [Fact] @@ -25,6 +25,7 @@ public void GetDefaultPostGrabActions_ContainsExpectedActions() // Assert Assert.Contains(actions, a => a.ButtonText == "Fix GUIDs"); Assert.Contains(actions, a => a.ButtonText == "Trim each line"); + Assert.Contains(actions, a => a.ButtonText == "Clean up text"); Assert.Contains(actions, a => a.ButtonText == "Remove duplicate lines"); Assert.Contains(actions, a => a.ButtonText == "Web Search"); Assert.Contains(actions, a => a.ButtonText == "Try to insert text"); @@ -104,6 +105,21 @@ public async System.Threading.Tasks.Task ExecutePostGrabAction_RemoveDuplicateLi Assert.Single(lines, l => l == "Line 1"); } + [Fact] + public async Task ExecutePostGrabAction_CleanUpText_NormalizesWhitespace() + { + // Arrange + ButtonInfo action = PostGrabActionManager.GetDefaultPostGrabActions() + .First(a => a.ClickEvent == "CleanUpText_Click"); + string input = " ragged line \n\n\n\n\tsecond\tline "; + + // Act + string result = await PostGrabActionManager.ExecutePostGrabAction(action, input); + + // Assert + Assert.Equal($"ragged line{Environment.NewLine}{Environment.NewLine}second line", result); + } + [Fact] public async Task ExecutePostGrabAction_SpeakText_ReturnsTextUnchanged() { 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/RepeatedPageElementDetectorTests.cs b/Tests/RepeatedPageElementDetectorTests.cs new file mode 100644 index 00000000..e747e476 --- /dev/null +++ b/Tests/RepeatedPageElementDetectorTests.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using System.Drawing; +using Text_Grab.Utilities; + +namespace Tests; + +public class RepeatedPageElementDetectorTests +{ + private static readonly SizeF LetterPage = new(850, 1100); + + [Fact] + public void SinglePage_NothingIsIgnored() + { + PageTextSnapshot page = Page( + Word("Acme", 60, 40), + Word("Corp", 110, 40), + Word("Body", 60, 200)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page]); + + Assert.Single(ignored); + Assert.Empty(ignored[0]); + } + + [Fact] + public void RunningHeaderAtSamePlaceOnEveryPage_IsIgnoredOnEveryPage() + { + PageTextSnapshot page1 = Page( + Word("Acme", 60, 40), + Word("Corp", 110, 40), + Word("Revenue", 60, 200), + Word("grew", 130, 200)); + PageTextSnapshot page2 = Page( + Word("Acme", 61, 40), + Word("Corp", 111, 41), + Word("Costs", 60, 200), + Word("fell", 130, 200)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2]); + + Assert.Equal(new HashSet { 0, 1 }, ignored[0]); + Assert.Equal(new HashSet { 0, 1 }, ignored[1]); + } + + [Fact] + public void IncrementingPageNumbersInFooter_AreIgnored() + { + PageTextSnapshot page1 = Page( + Word("Intro", 60, 300), + Word("Page", 380, 1050), + Word("1", 425, 1050), + Word("of", 445, 1050), + Word("12", 470, 1050)); + PageTextSnapshot page2 = Page( + Word("More", 60, 300), + Word("Page", 380, 1050), + Word("2", 425, 1050), + Word("of", 445, 1050), + Word("12", 470, 1050)); + PageTextSnapshot page3 = Page( + Word("End", 60, 300), + Word("Page", 380, 1050), + Word("10", 423, 1050), + Word("of", 445, 1050), + Word("12", 470, 1050)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2, page3]); + + foreach (HashSet pageIgnored in ignored) + Assert.Equal(new HashSet { 1, 2, 3, 4 }, pageIgnored); + } + + [Fact] + public void RepeatedTableColumnHeaderDirectlyAboveRows_IsKept() + { + // Column headers at the top of every page with data rows immediately below them — + // the same position and text as a running header, but attached to the body. + PageTextSnapshot page1 = Page( + Word("Name", 60, 100), + Word("Qty", 300, 100), + Word("Widget", 60, 118), + Word("4", 300, 118)); + PageTextSnapshot page2 = Page( + Word("Name", 60, 100), + Word("Qty", 300, 100), + Word("Gadget", 60, 118), + Word("7", 300, 118)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2]); + + Assert.Empty(ignored[0]); + Assert.Empty(ignored[1]); + } + + [Fact] + public void AllNumericFirstRowUnderRepeatedHeader_IsKeptViaTheRowBelow() + { + // Header row and an all-numeric first row both "repeat" (digits normalize to #); the + // second row has a differing cell, which anchors the row above it, then the header. + PageTextSnapshot page1 = Page( + Word("Name", 60, 100), Word("Qty", 300, 100), + Word("1", 60, 118), Word("40", 300, 118), + Word("Widget", 60, 136), Word("4", 300, 136)); + PageTextSnapshot page2 = Page( + Word("Name", 60, 100), Word("Qty", 300, 100), + Word("2", 60, 118), Word("75", 300, 118), + Word("Gadget", 60, 136), Word("7", 300, 136)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2]); + + Assert.Empty(ignored[0]); + Assert.Empty(ignored[1]); + } + + [Fact] + public void RepeatedTextInPageBody_IsNotTreatedAsHeader() + { + PageTextSnapshot page1 = Page(Word("Total", 60, 550), Word("Body", 60, 300)); + PageTextSnapshot page2 = Page(Word("Total", 60, 550), Word("Other", 60, 300)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2]); + + Assert.Empty(ignored[0]); + Assert.Empty(ignored[1]); + } + + [Fact] + public void DifferentHeaderTextAtSamePlace_IsKept() + { + PageTextSnapshot page1 = Page(Word("Chapter One", 60, 40, width: 120), Word("Body", 60, 300)); + PageTextSnapshot page2 = Page(Word("Appendix B", 60, 40, width: 120), Word("More", 60, 300)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2]); + + Assert.Empty(ignored[0]); + Assert.Empty(ignored[1]); + } + + [Fact] + public void SameHeaderTextAtDifferentPlace_IsKept() + { + PageTextSnapshot page1 = Page(Word("Acme", 60, 40), Word("Body", 60, 300)); + PageTextSnapshot page2 = Page(Word("Acme", 600, 40), Word("More", 60, 300)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2]); + + Assert.Empty(ignored[0]); + Assert.Empty(ignored[1]); + } + + [Fact] + public void OcrNoiseInRepeatedHeader_StillMatches() + { + PageTextSnapshot page1 = Page(Word("Confidential", 60, 40, width: 110), Word("Body", 60, 300)); + PageTextSnapshot page2 = Page(Word("Confidentia1", 60, 40, width: 110), Word("More", 60, 300)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements([page1, page2]); + + Assert.Equal(new HashSet { 0 }, ignored[0]); + Assert.Equal(new HashSet { 0 }, ignored[1]); + } + + [Fact] + public void HeaderMissingFromSomePages_IsStillIgnoredWhereItAppears() + { + // Five pages; the header is on pages 1, 2 and 4 only — each occurrence matches two of + // the other four pages, clearing the one-third threshold. + PageTextSnapshot withHeader1 = Page(Word("Draft", 60, 40), Word("A", 60, 300)); + PageTextSnapshot withHeader2 = Page(Word("Draft", 60, 40), Word("B", 60, 300)); + PageTextSnapshot without3 = Page(Word("C", 60, 300)); + PageTextSnapshot withHeader4 = Page(Word("Draft", 60, 40), Word("D", 60, 300)); + PageTextSnapshot without5 = Page(Word("E", 60, 300)); + + List> ignored = RepeatedPageElementDetector.FindRepeatedHeaderFooterElements( + [withHeader1, withHeader2, without3, withHeader4, without5]); + + Assert.Equal(new HashSet { 0 }, ignored[0]); + Assert.Equal(new HashSet { 0 }, ignored[1]); + Assert.Empty(ignored[2]); + Assert.Equal(new HashSet { 0 }, ignored[3]); + Assert.Empty(ignored[4]); + } + + [Theory] + [InlineData("Page 3 of 12", "PAGE # OF #")] + [InlineData(" Acme Corp ", "ACME CORP")] + [InlineData("2026-09-13", "#-#-#")] + [InlineData("", "")] + public void NormalizeText_CollapsesCaseWhitespaceAndDigits(string input, string expected) + { + Assert.Equal(expected, RepeatedPageElementDetector.NormalizeText(input)); + } + + private static PageTextSnapshot Page(params PageTextElement[] elements) => new(elements, LetterPage); + + private static PageTextElement Word(string text, float left, float top, float width = 40, float height = 12) + => new(text, new RectangleF(left, top, width, height)); +} 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/ResultTableBoundsFilterTests.cs b/Tests/ResultTableBoundsFilterTests.cs new file mode 100644 index 00000000..de2351ee --- /dev/null +++ b/Tests/ResultTableBoundsFilterTests.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Drawing; +using Text_Grab.Models; + +namespace Tests; + +public class ResultTableBoundsFilterTests +{ + [WpfFact] + public void FilterWordBordersWithinBounds_KeepsOnlyWordsCenteredInsideBounds() + { + WordBorderInfo inside = CreateWord("Inside", left: 20, top: 20, width: 30, height: 10); + WordBorderInfo outsideLeft = CreateWord("OutsideLeft", left: -50, top: 20, width: 30, height: 10); + WordBorderInfo outsideBelow = CreateWord("OutsideBelow", left: 20, top: 500, width: 30, height: 10); + + List all = [inside, outsideLeft, outsideBelow]; + RectangleF bounds = new(0, 0, 100, 100); + + List filtered = ResultTable.FilterWordBordersWithinBounds(all, bounds); + + Assert.Single(filtered); + Assert.Same(inside, filtered[0]); + } + + [WpfFact] + public void FilterWordBordersWithinBounds_WordStraddlingEdge_IsKeptOnlyWhenCenterIsInside() + { + // Center at x=95, inside a bounds right edge of 100 + WordBorderInfo straddlingInside = CreateWord("StraddlingInside", left: 80, top: 10, width: 30, height: 10); + // Center at x=115, outside the same bounds + WordBorderInfo straddlingOutside = CreateWord("StraddlingOutside", left: 100, top: 10, width: 30, height: 10); + + RectangleF bounds = new(0, 0, 100, 100); + + List filtered = ResultTable.FilterWordBordersWithinBounds( + [straddlingInside, straddlingOutside], + bounds); + + Assert.Single(filtered); + Assert.Same(straddlingInside, filtered[0]); + } + + private static WordBorderInfo CreateWord(string word, double left, double top, double width, double height) + { + return new WordBorderInfo + { + Word = word, + BorderRect = new RectangleF((float)left, (float)top, (float)width, (float)height) + }; + } +} diff --git a/Tests/ResultTableManualSeparatorTests.cs b/Tests/ResultTableManualSeparatorTests.cs index de777577..eb061358 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); @@ -85,12 +82,34 @@ public void AnalyzeAsTable_ManualColumnSeparatorSplitsMergedColumnOutput() Assert.Equal([25d], manualTable.ManualColumnSeparators); } + [WpfFact] + public void GetTextFromTabledWordBorders_SingleRowWithDistinctColumns_StillTabSeparates() + { + // Regression: capturing just one row of a table (e.g. grabbing rows one at a time into + // a spreadsheet) must not lose column structure just because that single grab only ever + // sees one row — previously a same-row, different-column pair got glued together with + // no separator at all ("NameAge") since tabs required 2+ rows to be detected first. + List infos = + [ + CreateWord("Name", left: 10, top: 10, width: 40, height: 10), + CreateWord("Age", left: 200, top: 10, width: 30, height: 10) + ]; + + ResultTable table = new(); + table.AnalyzeAsTable(infos, new Rectangle(0, 0, 400, 200)); + + StringBuilder text = new(); + ResultTable.GetTextFromTabledWordBorders(text, infos, true); + + Assert.Equal("Name\tAge", text.ToString()); + } + private static WordBorderInfo CreateWord(string word, double left, double top, double width, double height) { 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/SettingsImportExportTests.cs b/Tests/SettingsImportExportTests.cs index c2191879..5a349d26 100644 --- a/Tests/SettingsImportExportTests.cs +++ b/Tests/SettingsImportExportTests.cs @@ -41,7 +41,7 @@ public async Task ExportedZipContainsSettingsJson() string settingsJsonPath = Path.Combine(tempDir, "settings.json"); Assert.True(File.Exists(settingsJsonPath)); - string jsonContent = await File.ReadAllTextAsync(settingsJsonPath); + string jsonContent = await File.ReadAllTextAsync(settingsJsonPath, TestContext.Current.CancellationToken); Assert.False(string.IsNullOrEmpty(jsonContent)); // Check that JSON contains some setting keys (any of the common settings) bool containsSettings = jsonContent.Contains("ShowToast") || @@ -66,7 +66,7 @@ public async Task RoundTripSettingsExportImportPreservesAllValues() string originalTempDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Original_{Guid.NewGuid()}"); System.IO.Compression.ZipFile.ExtractToDirectory(originalZipPath, originalTempDir); string originalJsonPath = Path.Combine(originalTempDir, "settings.json"); - string originalJson = await File.ReadAllTextAsync(originalJsonPath); + string originalJson = await File.ReadAllTextAsync(originalJsonPath, TestContext.Current.CancellationToken); // Step 3: Deserialize to dictionary to get all key-value pairs Dictionary? originalSettings = JsonSerializer.Deserialize>(originalJson); @@ -100,7 +100,7 @@ public async Task RoundTripSettingsExportImportPreservesAllValues() string modifiedTempDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Modified_{Guid.NewGuid()}"); Directory.CreateDirectory(modifiedTempDir); string modifiedJsonPath = Path.Combine(modifiedTempDir, "settings.json"); - await File.WriteAllTextAsync(modifiedJsonPath, modifiedJson); + await File.WriteAllTextAsync(modifiedJsonPath, modifiedJson, TestContext.Current.CancellationToken); string modifiedZipPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Modified_{Guid.NewGuid()}.zip"); System.IO.Compression.ZipFile.CreateFromDirectory(modifiedTempDir, modifiedZipPath); @@ -115,7 +115,7 @@ public async Task RoundTripSettingsExportImportPreservesAllValues() string reimportedTempDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Reimported_{Guid.NewGuid()}"); System.IO.Compression.ZipFile.ExtractToDirectory(reimportedZipPath, reimportedTempDir); string reimportedJsonPath = Path.Combine(reimportedTempDir, "settings.json"); - string reimportedJson = await File.ReadAllTextAsync(reimportedJsonPath); + string reimportedJson = await File.ReadAllTextAsync(reimportedJsonPath, TestContext.Current.CancellationToken); Dictionary? reimportedSettings = JsonSerializer.Deserialize>(reimportedJson); Assert.NotNull(reimportedSettings); @@ -179,7 +179,9 @@ public async Task ManagedJsonSettingWithDataSurvivesRoundTrip() verifyDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Verify_{Guid.NewGuid()}"); System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, verifyDir); - string exportedJson = await File.ReadAllTextAsync(Path.Combine(verifyDir, "settings.json")); + string exportedJson = await File.ReadAllTextAsync( + Path.Combine(verifyDir, "settings.json"), + TestContext.Current.CancellationToken); Assert.Contains("export-roundtrip-1", exportedJson); // Clear the managed setting to simulate import on a clean machine @@ -215,7 +217,9 @@ public async Task ExportedSettingsJsonIncludesManagedSettingKeys() try { System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, tempDir); - string jsonContent = await File.ReadAllTextAsync(Path.Combine(tempDir, "settings.json")); + string jsonContent = await File.ReadAllTextAsync( + Path.Combine(tempDir, "settings.json"), + TestContext.Current.CancellationToken); // All six managed-JSON setting names must appear as keys in the export Assert.True(jsonContent.Contains("regexList", StringComparison.OrdinalIgnoreCase)); @@ -281,7 +285,7 @@ public async Task LegacyExportWithInlineManagedSettingsIsImportedToSidecarFiles( try { - await File.WriteAllTextAsync(Path.Combine(legacyDir, "settings.json"), legacyJson); + await File.WriteAllTextAsync(Path.Combine(legacyDir, "settings.json"), legacyJson, TestContext.Current.CancellationToken); System.IO.Compression.ZipFile.CreateFromDirectory(legacyDir, legacyZipPath); // Start from a clean state so the assertion is unambiguous diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index b59cb94e..678d273f 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -2,6 +2,7 @@ net10.0-windows10.0.22621 + Exe enable x64;x86;ARM64 win-x86;win-x64;win-arm64 @@ -13,18 +14,9 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - + + + @@ -61,18 +53,10 @@ PreserveNewest + + PreserveNewest + - - - PreserveNewest - - - - - - PreserveNewest - - diff --git a/Tests/TextFiles/Text-Grab-Test-PDF.pdf b/Tests/TextFiles/Text-Grab-Test-PDF.pdf new file mode 100644 index 00000000..ee932502 Binary files /dev/null and b/Tests/TextFiles/Text-Grab-Test-PDF.pdf differ diff --git a/Tests/TtsServiceTests.cs b/Tests/TtsServiceTests.cs index 58b391f0..2f04e2d8 100644 --- a/Tests/TtsServiceTests.cs +++ b/Tests/TtsServiceTests.cs @@ -22,16 +22,16 @@ public async Task DrainCallbackQueuingSpeech_DoesNotPublishIdleBetweenRequests() }; service.Speak("first"); - await engine.FirstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await engine.FirstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); service.RunWhenIdle(() => service.Speak("second")); engine.ReleaseFirst.TrySetResult(); - await engine.SecondStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await engine.SecondStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.Equal([true], busyEvents); engine.ReleaseSecond.TrySetResult(); - await idle.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await idle.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.Equal([true, false], busyEvents); } diff --git a/Tests/WinAiMeetingNotesTests.cs b/Tests/WinAiMeetingNotesTests.cs new file mode 100644 index 00000000..a5551b5e --- /dev/null +++ b/Tests/WinAiMeetingNotesTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Text_Grab.Utilities; + +namespace Tests; + +/// +/// Tests for the text splitting behind "Summarize as Meeting Notes". The model calls themselves need +/// a Copilot+ device, but the chunking that decides what gets sent to the model does not. +/// +public class WinAiMeetingNotesTests +{ + private const int Target = 100; + + /// Text that already fits is sent to the model in one piece. + [Fact] + public void SplitIntoParts_ShortText_ReturnsSinglePart() + { + string input = "Standup notes: shipped the OCR fix, starting on the settings page next."; + + List parts = WinAiMeetingNotes.SplitIntoParts(input, Target); + + Assert.Single(parts); + Assert.Equal(input, parts[0]); + } + + [Fact] + public void SplitIntoParts_TextExactlyAtTarget_ReturnsSinglePart() + { + string input = new('a', Target); + + List parts = WinAiMeetingNotes.SplitIntoParts(input, Target); + + Assert.Single(parts); + } + + [Fact] + public void SplitIntoParts_LongText_EveryPartWithinTarget() + { + string input = string.Join(" ", Enumerable.Repeat("discussed the roadmap and agreed on dates", 40)); + + List parts = WinAiMeetingNotes.SplitIntoParts(input, Target); + + Assert.True(parts.Count > 1); + Assert.All(parts, part => Assert.True(part.Length <= Target, $"Part was {part.Length} characters.")); + } + + /// Splitting must not lose or reorder any of the meeting text. + [Fact] + public void SplitIntoParts_LongText_PreservesAllWords() + { + string input = string.Join("\n", Enumerable.Range(0, 60).Select(index => $"Speaker {index}: point number {index}")); + + List parts = WinAiMeetingNotes.SplitIntoParts(input, Target); + + string[] originalWords = input.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + string[] splitWords = string.Join(" ", parts).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(originalWords, splitWords); + } + + /// A blank line is the most natural place to break a transcript. + [Fact] + public void SplitIntoParts_ParagraphBreaks_PrefersBlankLines() + { + string paragraph = new('a', 60); + string input = string.Join("\n\n", paragraph, paragraph, paragraph); + + List parts = WinAiMeetingNotes.SplitIntoParts(input, Target); + + Assert.All(parts, part => Assert.Equal(paragraph, part)); + } + + /// Text with nowhere good to break still terminates, splitting mid-word as a last resort. + [Fact] + public void SplitIntoParts_NoBreakCharacters_StillSplits() + { + string input = new('x', Target * 3); + + List parts = WinAiMeetingNotes.SplitIntoParts(input, Target); + + Assert.Equal(3, parts.Count); + Assert.All(parts, part => Assert.Equal(Target, part.Length)); + } + + [Fact] + public void SplitIntoParts_EmptyText_ReturnsSinglePart() + { + List parts = WinAiMeetingNotes.SplitIntoParts(string.Empty, Target); + + Assert.Single(parts); + Assert.Equal(string.Empty, parts[0]); + } +} diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index c6e38862..caab9fb1 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -14,7 +14,7 @@ + Version="4.16.0.0" /> Text Grab @@ -157,5 +157,7 @@ + + 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..74f41252 --- /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.Core.Windows/Utilities/AudioTranscriptionUtilities.cs b/Text-Grab.Core.Windows/Utilities/AudioTranscriptionUtilities.cs new file mode 100644 index 00000000..828b2bfe --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/AudioTranscriptionUtilities.cs @@ -0,0 +1,1279 @@ +using NAudio.MediaFoundation; +using NAudio.Utils; +using NAudio.Wave; +using System.Diagnostics; +using System.Text; +using Text_Grab.Services; +using Whisper.net; +using Whisper.net.Ggml; + +namespace Text_Grab.Utilities; + +/// +/// Lightweight, always-on file logger for the audio transcription path. Writes timestamped lines +/// (with current process working set) to a stable, easy-to-find location so a run can be diagnosed +/// after the fact. Also mirrors to . +/// +public static class AudioDebugLog +{ + private static readonly Lock _lock = new(); + + /// Rolled over into audio-debug.prev.log once the live file passes this size. + private const long MaxLogBytes = 1024 * 1024; + + private static long _writtenBytes = -1; // -1 until the size of an existing log is read once + + private static string LogDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Text-Grab", "Logs"); + + /// Stable per-user log path so a run can be found and collected after the fact. + public static string LogPath { get; } = Path.Combine(LogDirectory, "audio-debug.log"); + + /// The previous log, kept so a rollover mid-run doesn't lose the start of the session. + private static string PreviousLogPath { get; } = Path.Combine(LogDirectory, "audio-debug.prev.log"); + + public static void Write(string message) + { + // Environment.WorkingSet, not a Process object: a cached Process reports whatever it last + // refreshed, and a fresh Process.GetCurrentProcess() per logged line is not free. + long workingSetMb = 0; + try { workingSetMb = Environment.WorkingSet / (1024 * 1024); } catch { } + + string line = $"{DateTime.Now:HH:mm:ss.fff} [WS {workingSetMb,6} MB] {message}"; + Debug.WriteLine("[AudioTranscription] " + line); + try + { + lock (_lock) + { + if (_writtenBytes < 0) + { + Directory.CreateDirectory(LogDirectory); + _writtenBytes = File.Exists(LogPath) ? new FileInfo(LogPath).Length : 0; + } + + // Roll over instead of growing without bound: this log is always on. + if (_writtenBytes > MaxLogBytes) + { + File.Move(LogPath, PreviousLogPath, overwrite: true); + _writtenBytes = 0; + } + + string text = line + Environment.NewLine; + File.AppendAllText(LogPath, text); + _writtenBytes += Encoding.UTF8.GetByteCount(text); + } + } + catch { /* logging must never throw */ } + } +} + +/// +/// One loaded plus the number of leases still using it. Disposing a +/// factory frees the native model, so a factory that is superseded (the user picks another model) +/// while a transcription is still decoding against it is d instead: it is +/// disposed only once the last lease is returned. +/// +internal sealed class WhisperFactoryHandle +{ + private readonly Lock _lock = new(); + private int _users; + private bool _retired; + private bool _disposed; + + internal WhisperFactory Factory { get; } + internal WhisperModelChoice Choice { get; } + + internal WhisperFactoryHandle(WhisperFactory factory, WhisperModelChoice choice) + { + Factory = factory; + Choice = choice; + } + + internal WhisperFactoryLease Lease() + { + lock (_lock) + _users++; + + return new WhisperFactoryLease(this); + } + + /// Marks the factory superseded; it is disposed as soon as the last lease is returned. + internal void Retire() + { + lock (_lock) + { + _retired = true; + DisposeIfIdle(); + } + } + + internal void Return() + { + lock (_lock) + { + _users--; + DisposeIfIdle(); + } + } + + /// Caller must hold . + private void DisposeIfIdle() + { + if (_disposed || !_retired || _users > 0) + return; + + _disposed = true; + AudioDebugLog.Write($"WhisperFactoryHandle: disposing retired factory for {Choice}"); + try { Factory.Dispose(); } catch { } + } +} + +/// +/// A borrowed reference to a shared . The factory owns the native model +/// that every built from it decodes against, so it must outlive them: +/// hold the lease for as long as any such processor lives, and dispose it after the processor. +/// +internal sealed partial class WhisperFactoryLease : IDisposable +{ + private WhisperFactoryHandle? _handle; + + internal WhisperFactoryLease(WhisperFactoryHandle handle) => _handle = handle; + + internal WhisperFactory Factory => + (_handle ?? throw new ObjectDisposedException(nameof(WhisperFactoryLease))).Factory; + + public void Dispose() => Interlocked.Exchange(ref _handle, null)?.Return(); +} + +/// +/// On-device audio transcription backed by local Whisper (whisper.cpp) models via Whisper.net. +/// Runs entirely on the CPU, works packaged or unpackaged on x64 and arm64, and does not depend on +/// any experimental OS runtime. Arbitrary audio is decoded/resampled to the 16 kHz mono WAV that +/// Whisper requires using NAudio's Media Foundation reader/resampler. +/// +public static class AudioTranscriptionUtilities +{ + private static readonly HashSet AudioExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".oga", ".opus", ".wma", ".mp4", ".mov", + }; + + private static WhisperFactoryHandle? _factoryHandle; + private static readonly SemaphoreSlim _factoryLock = new(1, 1); + + // Silero VAD (voice activity detection) lets live transcription skip silence and cut on natural + // speech boundaries instead of fixed time windows. The factory is small and shared. + private static WhisperVadFactory? _vadFactory; + private static readonly SemaphoreSlim _vadFactoryLock = new(1, 1); + + /// Where downloaded Whisper (and VAD) models are stored on disk. + public static string ModelDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Text-Grab", "WhisperModels"); + + /// + /// The model currently selected for file/clip transcription (Open Audio/Video), where a progress + /// bar and cancel button make a slow, large model tolerable. Defaults to multilingual base. + /// + public static WhisperModelChoice CurrentModelChoice => WhisperModelInfo.Parse(SettingsAccess.Current.AudioTranscriptionModel); + + /// + /// The model currently selected for live (near-real-time) transcription — tracked separately from + /// so a large model picked for file transcription never gets + /// loaded into a live session, where it can't keep up with speech. Always one of + /// ; falls back to multilingual base otherwise + /// (e.g. a value persisted before a model was retired from live use). + /// + public static WhisperModelChoice CurrentLiveModelChoice + { + get + { + WhisperModelChoice choice = WhisperModelInfo.Parse(SettingsAccess.Current.LiveTranscriptionModel); + return Array.IndexOf(WhisperModelInfo.LiveEligibleModels, choice) >= 0 ? choice : WhisperModelChoice.BaseMultilingual; + } + } + + private static string ModelPathFor(WhisperModelChoice choice) + { + string typeName = WhisperModelInfo.GgmlTypeFor(choice).ToString().ToLowerInvariant(); + QuantizationType quantization = WhisperModelInfo.QuantizationFor(choice); + string suffix = quantization == QuantizationType.NoQuantization ? string.Empty : $"-{quantization.ToString().ToLowerInvariant()}"; + return Path.Combine(ModelDirectory, $"ggml-{typeName}{suffix}.bin"); + } + + private static string VadModelPath => Path.Combine(ModelDirectory, "ggml-silero-vad-v5.bin"); + + /// + /// Returns true when the given path points to a file with a recognized audio (or A/V) extension. + /// + public static bool IsAudioFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + return AudioExtensions.Contains(Path.GetExtension(path)); + } + + /// An -compatible filter string for the supported audio/A-V extensions. + public static string GetAudioFileFilter() + { + string extensions = string.Join(";", AudioExtensions.Select(ext => $"*{ext}")); + return $"Audio/video files|{extensions}|All files (*.*)|*.*"; + } + + /// Basic file info (name, size, duration) for the "what to expect" panel — no decoding. + internal readonly record struct AudioFileInfo(string FileName, long FileSizeBytes, TimeSpan Duration); + + /// + /// Reads the file size and duration of an audio/A-V file without decoding it, for upfront user + /// feedback before transcription starts. Throws if the file doesn't exist or can't be opened by + /// Media Foundation (e.g. an unsupported or corrupt format) — callers should show that inline. + /// + internal static AudioFileInfo GetAudioFileInfo(string audioFilePath) + { + if (!File.Exists(audioFilePath)) + throw new FileNotFoundException("Audio file not found.", audioFilePath); + + long fileSizeBytes = new FileInfo(audioFilePath).Length; + + MediaFoundationApi.Startup(); + using MediaFoundationReader reader = new(audioFilePath); + TimeSpan duration = reader.TotalTime; + + return new AudioFileInfo(Path.GetFileName(audioFilePath), fileSizeBytes, duration); + } + + /// + /// Whisper runs on the CPU on every supported Windows build (x64 / arm64, packaged or not), so + /// audio transcription is always available. The model is fetched on first use. + /// + public static bool IsAudioTranscriptionSupported() => true; + + /// True once the selected Whisper model has been downloaded and is available locally. + public static bool IsModelDownloaded() => IsModelDownloaded(CurrentModelChoice); + + /// True once the given Whisper model has been downloaded and is available locally. + public static bool IsModelDownloaded(WhisperModelChoice choice) => File.Exists(ModelPathFor(choice)); + + /// The on-disk size of an already-downloaded model, or null if it hasn't been downloaded yet. + public static long? DownloadedModelSizeBytes(WhisperModelChoice choice) + { + string path = ModelPathFor(choice); + return File.Exists(path) ? new FileInfo(path).Length : null; + } + + /// + /// Downloads a GGML model to LocalAppData if it isn't already present, returning its path. The + /// download is written to a temp file first, then moved into place so a cancelled or failed + /// download never leaves a corrupt model behind. + /// + private static async Task EnsureModelDownloadedAsync(WhisperModelChoice choice, IProgress? progress, CancellationToken cancellationToken) + { + string modelPath = ModelPathFor(choice); + if (File.Exists(modelPath)) + return modelPath; + + Directory.CreateDirectory(ModelDirectory); + GgmlType ggmlType = WhisperModelInfo.GgmlTypeFor(choice); + QuantizationType quantization = WhisperModelInfo.QuantizationFor(choice); + AudioDebugLog.Write($"EnsureModelDownloadedAsync: downloading Whisper '{ggmlType}' ({quantization}) model to {modelPath}"); + progress?.Report($"Downloading ({WhisperModelInfo.DisplayName(choice)}, first run)…"); + + string tempPath = modelPath + ".download"; + try + { + using (Stream modelStream = await WhisperGgmlDownloader.Default.GetGgmlModelAsync(ggmlType, quantization, cancellationToken).ConfigureAwait(false)) + using (FileStream fileWriter = File.Create(tempPath)) + await modelStream.CopyToAsync(fileWriter, cancellationToken).ConfigureAwait(false); + + if (File.Exists(modelPath)) + File.Delete(modelPath); + File.Move(tempPath, modelPath); + AudioDebugLog.Write("EnsureModelDownloadedAsync: download complete"); + return modelPath; + } + catch + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + throw; + } + } + + /// + /// Downloads the given Whisper model ahead of time (e.g. from the Models settings page) so it's + /// ready before the user starts a transcription. A no-op if it's already downloaded. + /// + public static Task DownloadModelAsync(WhisperModelChoice choice, IProgress? progress = null, CancellationToken cancellationToken = default) => + EnsureModelDownloadedAsync(choice, progress, cancellationToken); + + /// + /// Deletes a downloaded model's file to free disk space. If it's the model currently loaded in + /// memory, the shared factory is retired so the next transcription reloads (and, if needed, + /// re-downloads) it rather than continuing to serve the now-deleted file's in-memory copy. + /// Returns false if the model wasn't downloaded. + /// + public static bool DeleteModel(WhisperModelChoice choice) + { + string path = ModelPathFor(choice); + if (!File.Exists(path)) + return false; + + File.Delete(path); + AudioDebugLog.Write($"DeleteModel: deleted {choice} model at {path}"); + + if (_factoryHandle is not null && _factoryHandle.Choice == choice) + { + _factoryHandle.Retire(); + _factoryHandle = null; + } + + return true; + } + + /// + /// Borrows the shared for (or + /// — the file-transcription default — when omitted), downloading + /// the model if needed. The factory is shared rather than one-per-caller only so two overlapping + /// users of the same model (e.g. a live session running while a file transcription starts) reuse + /// one loaded copy instead of each loading their own; each caller is expected to call + /// once it's done so the model doesn't stay resident in RAM between + /// transcriptions. If the model choice changes, the old factory is retired and a new one is + /// loaded — see for why the caller must hold the lease for as + /// long as it uses processors built from the factory. + /// + internal static async Task AcquireFactoryAsync(IProgress? progress, CancellationToken cancellationToken, WhisperModelChoice? modelChoice = null) + { + WhisperModelChoice choice = modelChoice ?? CurrentModelChoice; + + await _factoryLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_factoryHandle is not null && _factoryHandle.Choice == choice) + return _factoryHandle.Lease(); + + if (_factoryHandle is not null) + { + AudioDebugLog.Write($"AcquireFactoryAsync: model changed {_factoryHandle.Choice} -> {choice}, reloading"); + _factoryHandle.Retire(); + _factoryHandle = null; + } + + string modelPath = await EnsureModelDownloadedAsync(choice, progress, cancellationToken).ConfigureAwait(false); + AudioDebugLog.Write($"AcquireFactoryAsync: loading WhisperFactory for {choice} ({WhisperModelInfo.GgmlTypeFor(choice)})"); + _factoryHandle = new WhisperFactoryHandle(WhisperFactory.FromPath(modelPath), choice); + AudioDebugLog.Write("AcquireFactoryAsync: WhisperFactory ready"); + return _factoryHandle.Lease(); + } + finally + { + _factoryLock.Release(); + } + } + + /// + /// Releases the shared Whisper factory once a transcription (live or file) is done with it, so the + /// loaded model — anywhere from tens of MB to ~1.6 GB — doesn't stay resident in RAM between uses. + /// The next transcription simply reloads it via . Safe to call even + /// while another lease on the same factory is still outstanding (e.g. a file transcription finishing + /// while a live session is using the same model, or vice versa): like a model change, + /// only disposes once every outstanding lease has been + /// returned, so it never frees a model a concurrent session is still decoding against. Callers must + /// return their own lease before calling this. + /// + internal static void ReleaseFactory() + { + _factoryLock.Wait(); + try + { + if (_factoryHandle is null) + return; + + AudioDebugLog.Write($"ReleaseFactory: releasing factory for {_factoryHandle.Choice}"); + _factoryHandle.Retire(); + _factoryHandle = null; + } + finally + { + _factoryLock.Release(); + } + } + + /// + /// Downloads the Silero VAD model to LocalAppData if needed (same temp-then-move pattern), then + /// returns the shared, cached . The VAD model is tiny (~a few MB). + /// + internal static async Task GetVadFactoryAsync(IProgress? progress, CancellationToken cancellationToken) + { + if (_vadFactory is not null) + return _vadFactory; + + await _vadFactoryLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_vadFactory is not null) + return _vadFactory; + + if (!File.Exists(VadModelPath)) + { + Directory.CreateDirectory(ModelDirectory); + AudioDebugLog.Write("GetVadFactoryAsync: downloading Silero VAD model"); + progress?.Report("Downloading voice-activity model…"); + + string tempPath = VadModelPath + ".download"; + try + { + using (Stream vadStream = await WhisperGgmlDownloader.Default.GetGgmlSileroVadModelAsync(SileroVadType.V5_1_2, cancellationToken).ConfigureAwait(false)) + using (FileStream fileWriter = File.Create(tempPath)) + await vadStream.CopyToAsync(fileWriter, cancellationToken).ConfigureAwait(false); + + if (File.Exists(VadModelPath)) + File.Delete(VadModelPath); + File.Move(tempPath, VadModelPath); + } + catch + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + throw; + } + } + + _vadFactory = WhisperVadFactory.FromPath(VadModelPath); + AudioDebugLog.Write("GetVadFactoryAsync: WhisperVadFactory ready"); + return _vadFactory; + } + finally + { + _vadFactoryLock.Release(); + } + } + + /// + /// Transcribes a complete audio file on-device with Whisper and returns the recognized text. + /// Whisper.net's ProcessAsync yields incrementally as whisper.cpp + /// finishes each segment of the audio, so receives each + /// segment's text as it becomes available (giving "text as it comes in" for long files). The full + /// transcript is still returned. Cancellation stops after the current segment; every segment + /// already surfaced via is preserved by the caller. + /// , if given, is passed to Whisper as an initial prompt so it's biased + /// toward names/jargon it might otherwise mishear; it applies only to this call, nothing persists. + /// When is true, each segment is prefixed with its start time + /// (e.g. [01:23]) and placed on its own line. + /// , if given, reports how far playback has reached through the + /// clip (0.0-1.0) after each segment, based on that segment's end time versus the clip's total + /// duration — lets callers show a real progress bar instead of an indeterminate spinner. + /// + public static async Task TranscribeAudioFileAsync(string audioFilePath, string? hotWords = null, IProgress? statusProgress = null, IProgress? segmentProgress = null, bool includeTimecodes = false, IProgress? clipProgress = null, CancellationToken cancellationToken = default) + { + AudioDebugLog.Write($"TranscribeAudioFileAsync: START path='{audioFilePath}'"); + + if (!File.Exists(audioFilePath)) + throw new FileNotFoundException("Audio file not found.", audioFilePath); + + long fileSizeKb = new FileInfo(audioFilePath).Length / 1024; + AudioDebugLog.Write($"TranscribeAudioFileAsync: file exists, size={fileSizeKb} KB, ext={Path.GetExtension(audioFilePath)}"); + + // Whisper + audio decoding are CPU-bound; run off the UI thread. + return await Task.Run(async () => + { + // The lease is held for the whole decode: a model change (or a live session starting) part + // way through must not free the native model this processor is still reading. Once this + // transcription is done with it — success, error, or cancellation — the finally below + // returns the lease and releases the shared factory so the model doesn't stay resident in + // RAM between transcriptions; the next call simply reloads it via AcquireFactoryAsync. + WhisperFactoryLease factoryLease = await AcquireFactoryAsync(statusProgress, cancellationToken).ConfigureAwait(false); + try + { + statusProgress?.Report("Transcribing audio…"); + AudioDebugLog.Write("TranscribeAudioFileAsync: decoding audio to 16 kHz mono WAV"); + using MemoryStream wavStream = DecodeToWav16kMono(audioFilePath); + AudioDebugLog.Write($"TranscribeAudioFileAsync: decoded WAV bytes={wavStream.Length}"); + + // 16 kHz mono 16-bit PCM, 44-byte WAV header: 32,000 bytes/second of audio. + double clipTotalSeconds = Math.Max(0, wavStream.Length - 44) / 32000.0; + + Stopwatch stopwatch = Stopwatch.StartNew(); + // Without this, whisper.cpp conditions each ~30s decode window on the text it just produced + // for the previous window. That's fine for continuity, but once a window decodes badly + // (applause, silence, cross-talk, a speaker handoff) the garbage becomes the prompt for the + // next window, and whisper.cpp is prone to spiraling into repeated garbage tokens once it's + // conditioned on its own bad output — corrupting the rest of a long file instead of just the + // one bad segment. WithNoContext() decodes each window independently so a bad patch stays + // contained to that patch. Matches the live path (see LiveAudioTranscriber.StartAsync). + WhisperProcessorBuilder processorBuilder = factoryLease.Factory.CreateBuilder() + .WithLanguage(WhisperModelInfo.LanguageFor(CurrentModelChoice)) + .WithThreads(Math.Max(1, Environment.ProcessorCount - 1)) + .WithNoContext(); + + // CarryInitialPrompt re-applies the hot words to every decode window (not just the first), + // so the bias holds across long files instead of fading out after the first segment. + if (!string.IsNullOrWhiteSpace(hotWords)) + processorBuilder = processorBuilder.WithPrompt(hotWords.Trim()).WithCarryInitialPrompt(true); + + await using WhisperProcessor processor = processorBuilder.Build(); + + StringBuilder builder = new(); + int segmentCount = 0; + await foreach (SegmentData segment in processor.ProcessAsync(wavStream, cancellationToken).ConfigureAwait(false)) + { + string segmentText = includeTimecodes + ? $"[{FormatTimecode(segment.Start)}]{segment.Text}{Environment.NewLine}" + : segment.Text; + + builder.Append(segmentText); + segmentProgress?.Report(segmentText); + segmentCount++; + + if (clipTotalSeconds > 0) + clipProgress?.Report(Math.Clamp(segment.End.TotalSeconds / clipTotalSeconds, 0.0, 1.0)); + } + + clipProgress?.Report(1.0); + + stopwatch.Stop(); + string text = CleanTranscript(builder.ToString()); + AudioDebugLog.Write($"TranscribeAudioFileAsync: DONE in {stopwatch.ElapsedMilliseconds} ms, {segmentCount} segments, result length={text.Length}"); + return text; + } + finally + { + factoryLease.Dispose(); + ReleaseFactory(); + } + }, cancellationToken).ConfigureAwait(false); + } + + /// Formats a segment's start time as mm:ss, or h:mm:ss once past an hour. + internal static string FormatTimecode(TimeSpan t) => + t.TotalHours >= 1 ? t.ToString(@"h\:mm\:ss") : t.ToString(@"mm\:ss"); + + /// Collapses whisper's leading spaces / stray whitespace into a tidy transcript. + internal static string CleanTranscript(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return string.Empty; + + return raw.Replace("\r\n", "\n").Trim(); + } + + /// + /// Decodes any Media Foundation-supported audio (wav, mp3, m4a, aac, wma, mp4, …) to a 16 kHz + /// mono 16-bit PCM WAV in memory — the format Whisper expects. + /// + internal static MemoryStream DecodeToWav16kMono(string audioFilePath) + { + MediaFoundationApi.Startup(); + + using MediaFoundationReader reader = new(audioFilePath); + WaveFormat targetFormat = new(16000, 16, 1); + + MemoryStream memoryStream = new(); + using (MediaFoundationResampler resampler = new(reader, targetFormat) { ResamplerQuality = 60 }) + { + // WriteWavFileToStream wraps the stream in an IgnoreDisposeStream, so memoryStream stays open. + WaveFileWriter.WriteWavFileToStream(memoryStream, resampler); + } + + memoryStream.Position = 0; + return memoryStream; + } + + /// Wraps raw 16 kHz mono 16-bit PCM bytes in an in-memory WAV stream for Whisper. + internal static MemoryStream PcmToWav16kMono(byte[] pcm, int count) + { + MemoryStream memoryStream = new(); + using (WaveFileWriter writer = new(new IgnoreDisposeStream(memoryStream), new WaveFormat(16000, 16, 1))) + writer.Write(pcm, 0, count); + + memoryStream.Position = 0; + return memoryStream; + } + + /// + /// Converts a raw captured buffer in an arbitrary (e.g. the + /// 32-bit float stereo mix from WASAPI loopback, or a mic's PCM) to a 16 kHz mono 16-bit WAV + /// stream for Whisper. Uses a fast path when the buffer is already in Whisper's format. + /// + internal static MemoryStream ConvertToWav16kMono(byte[] raw, int count, WaveFormat sourceFormat) + { + if (sourceFormat.Encoding == WaveFormatEncoding.Pcm + && sourceFormat.SampleRate == 16000 + && sourceFormat.Channels == 1 + && sourceFormat.BitsPerSample == 16) + { + return PcmToWav16kMono(raw, count); + } + + MediaFoundationApi.Startup(); + using RawSourceWaveStream rawStream = new(new MemoryStream(raw, 0, count), sourceFormat); + WaveFormat targetFormat = new(16000, 16, 1); + + MemoryStream memoryStream = new(); + using (MediaFoundationResampler resampler = new(rawStream, targetFormat) { ResamplerQuality = 60 }) + WaveFileWriter.WriteWavFileToStream(memoryStream, resampler); + + memoryStream.Position = 0; + return memoryStream; + } + + /// + /// Converts a raw captured buffer in an arbitrary to normalized + /// 16 kHz mono float samples in [-1, 1] — the shape both Silero VAD and Whisper consume directly, + /// avoiding a WAV round-trip. Uses a fast path when the buffer is already 16 kHz mono 16-bit PCM. + /// + internal static float[] ConvertToSamples16kMono(byte[] raw, int count, WaveFormat sourceFormat) + { + if (sourceFormat.Encoding == WaveFormatEncoding.Pcm + && sourceFormat.SampleRate == 16000 + && sourceFormat.Channels == 1 + && sourceFormat.BitsPerSample == 16) + { + int sampleCount = count / 2; + float[] fast = new float[sampleCount]; + for (int i = 0; i < sampleCount; i++) + { + short sample = (short)(raw[i * 2] | (raw[(i * 2) + 1] << 8)); + fast[i] = sample / 32768f; + } + return fast; + } + + MediaFoundationApi.Startup(); + using RawSourceWaveStream rawStream = new(new MemoryStream(raw, 0, count), sourceFormat); + WaveFormat targetFormat = new(16000, 16, 1); + using MediaFoundationResampler resampler = new(rawStream, targetFormat) { ResamplerQuality = 60 }; + + List samples = new(count / 4); + byte[] buffer = new byte[16000 * 2]; // ~1 second of 16-bit mono + int read; + while ((read = resampler.Read(buffer)) > 0) + { + for (int i = 0; i + 1 < read; i += 2) + { + short sample = (short)(buffer[i] | (buffer[i + 1] << 8)); + samples.Add(sample / 32768f); + } + } + return [.. samples]; + } +} + +/// The Whisper model a user can pick, trading speed for accuracy / language coverage. +public enum WhisperModelChoice +{ + /// tiny.en — fastest, English only. + TinyEnglish, + + /// base.en — fast, English only. + BaseEnglish, + + /// base — balanced, multilingual with auto language detection (default). + BaseMultilingual, + + /// small — noticeably slower than balanced, multilingual, more accurate. + SmallMultilingual, + + /// medium.en — slower still, English only, more accurate than small. + MediumEnglish, + + /// medium — slower still, multilingual, more accurate than small. + MediumMultilingual, + + /// large-v3-turbo — a distilled large model: nearly large-v3 accuracy, much faster. + LargeTurboMultilingual, + + /// large-v3 — the most accurate offered here, multilingual, slowest and largest download. + LargeMultilingual, +} + +/// Maps to its GGML model, language, and display name. +internal static class WhisperModelInfo +{ + public static WhisperModelChoice Parse(string? value) => value switch + { + "TinyEnglish" => WhisperModelChoice.TinyEnglish, + "BaseEnglish" => WhisperModelChoice.BaseEnglish, + "SmallMultilingual" => WhisperModelChoice.SmallMultilingual, + "MediumEnglish" => WhisperModelChoice.MediumEnglish, + "MediumMultilingual" => WhisperModelChoice.MediumMultilingual, + "LargeTurboMultilingual" => WhisperModelChoice.LargeTurboMultilingual, + "LargeMultilingual" => WhisperModelChoice.LargeMultilingual, + _ => WhisperModelChoice.BaseMultilingual, + }; + + public static GgmlType GgmlTypeFor(WhisperModelChoice choice) => choice switch + { + WhisperModelChoice.TinyEnglish => GgmlType.TinyEn, + WhisperModelChoice.BaseEnglish => GgmlType.BaseEn, + WhisperModelChoice.SmallMultilingual => GgmlType.Small, + WhisperModelChoice.MediumEnglish => GgmlType.MediumEn, + WhisperModelChoice.MediumMultilingual => GgmlType.Medium, + WhisperModelChoice.LargeTurboMultilingual => GgmlType.LargeV3Turbo, + WhisperModelChoice.LargeMultilingual => GgmlType.LargeV3, + _ => GgmlType.Base, + }; + + public static bool IsEnglishOnly(WhisperModelChoice choice) => + choice is WhisperModelChoice.TinyEnglish or WhisperModelChoice.BaseEnglish or WhisperModelChoice.MediumEnglish; + + // English-only models can't language-detect, so force English; multilingual models auto-detect. + public static string LanguageFor(WhisperModelChoice choice) => IsEnglishOnly(choice) ? "en" : "auto"; + + // Q5_0 keeps the English-only models fast and cheap to load with negligible WER impact. + // Multilingual models use the near-lossless Q8_0 instead, since quantization hurts accuracy more + // on the less-represented languages those models exist to cover. + public static QuantizationType QuantizationFor(WhisperModelChoice choice) => + IsEnglishOnly(choice) ? QuantizationType.Q5_0 : QuantizationType.Q8_0; + + /// + /// The models offered for live (near-real-time) transcription — see + /// . Deliberately just the small, + /// fast models: a live VAD-chunked session has to keep up with speech as it happens, so the medium + /// and large models are file-transcription only, where a progress bar and cancel button make the + /// wait tolerable. + /// + public static readonly WhisperModelChoice[] LiveEligibleModels = + [ + WhisperModelChoice.TinyEnglish, + WhisperModelChoice.BaseEnglish, + WhisperModelChoice.BaseMultilingual, + WhisperModelChoice.SmallMultilingual, + ]; + + public static string DisplayName(WhisperModelChoice choice) => choice switch + { + WhisperModelChoice.TinyEnglish => "Fastest — English", + WhisperModelChoice.BaseEnglish => "Fast — English", + WhisperModelChoice.SmallMultilingual => "Accurate — multilingual", + WhisperModelChoice.MediumEnglish => "Very accurate — English", + WhisperModelChoice.MediumMultilingual => "Very accurate — multilingual", + WhisperModelChoice.LargeTurboMultilingual => "Highly accurate, faster — multilingual", + WhisperModelChoice.LargeMultilingual => "Most accurate — multilingual", + _ => "Balanced — multilingual", + }; + + // Approximate download sizes for the specific GGML type + quantization combo each choice maps + // to (see GgmlTypeFor/QuantizationFor); actual sizes vary slightly by release but not enough to + // matter for picking between models. Shown alongside DisplayName since "more accurate" alone + // doesn't convey how much bigger a download the bigger models are. + public static string ApproxDownloadSize(WhisperModelChoice choice) => choice switch + { + WhisperModelChoice.TinyEnglish => "~31 MB", + WhisperModelChoice.BaseEnglish => "~57 MB", + WhisperModelChoice.SmallMultilingual => "~252 MB", + WhisperModelChoice.MediumEnglish => "~514 MB", + WhisperModelChoice.MediumMultilingual => "~785 MB", + WhisperModelChoice.LargeTurboMultilingual => "~834 MB", + WhisperModelChoice.LargeMultilingual => "~1.66 GB", + _ => "~78 MB", + }; + + /// Longer description of the speed/accuracy/language tradeoff, shown once a model is picked. + public static string Description(WhisperModelChoice choice) => choice switch + { + WhisperModelChoice.TinyEnglish => + "The smallest and fastest model here. English speech only, and the least accurate — best for quick drafts where speed matters more than getting every word right.", + WhisperModelChoice.BaseEnglish => + "Still fast, with noticeably better accuracy than the tiny model. English speech only.", + WhisperModelChoice.SmallMultilingual => + "Noticeably more accurate than the balanced model, and slower. Automatically detects the spoken language and covers dozens beyond English.", + WhisperModelChoice.MediumEnglish => + "Noticeably more accurate than the fast English models, but slower and a larger download. English speech only.", + WhisperModelChoice.MediumMultilingual => + "More accurate than the small model, but slower and a larger download. Automatically detects the spoken language and covers dozens beyond English.", + WhisperModelChoice.LargeTurboMultilingual => + "A distilled version of the large model: nearly the same accuracy, but noticeably faster. Automatically detects the spoken language and covers dozens beyond English.", + WhisperModelChoice.LargeMultilingual => + "The most accurate model here, but the slowest to process and the largest download. Automatically detects the spoken language and covers dozens beyond English.", + _ => + "A good default: balances speed and accuracy. Automatically detects the spoken language and covers dozens beyond English.", + }; + + /// Short label for the language coverage, shown alongside . + public static string LanguageSummary(WhisperModelChoice choice) => + IsEnglishOnly(choice) ? "English only" : "Multilingual — auto-detects language"; + + /// Terse language label for a table column, where is too long. + public static string ShortLanguageLabel(WhisperModelChoice choice) => + IsEnglishOnly(choice) ? "English only" : "Multilingual"; + + /// + /// with its approximate download size appended — used everywhere a model + /// is offered as a pick before it's necessarily downloaded (combo boxes, flyouts), so the size is + /// never more than a glance away. + /// + public static string DisplayNameWithSize(WhisperModelChoice choice) => + $"{DisplayName(choice)} ({ApproxDownloadSize(choice)})"; +} + +/// Where pulls audio from. +public enum LiveCaptureSource +{ + /// The default microphone / recording device. + Microphone, + + /// System output ("what you hear") via WASAPI loopback on the default render device. + SystemAudio, + + /// Both the microphone and system output, mixed into a single stream before transcription. + MicrophoneAndSystemAudio, +} + +/// +/// Near-live transcription with Whisper from the microphone, system output (WASAPI loopback), or +/// both at once (mixed into a single stream), gated by Silero voice-activity detection. Instead of +/// transcribing fixed time windows (which waste compute on silence and cut words mid-phrase), it +/// buffers audio, runs cheap VAD on a short cadence to find speech regions, and only sends a region +/// to Whisper once it's complete (trailing silence detected). Each completed utterance raises +/// . Events fire on background threads; subscribers must marshal to +/// their UI thread. +/// +public sealed partial class LiveAudioTranscriber : IDisposable +{ + private const int SampleRate = 16000; + private const int TimerIntervalMs = 200; + private const double MinAudioSeconds = 0.4; // don't bother running VAD on less than this + private const double CompletionSilenceSeconds = 0.25; // trailing silence that marks an utterance done + private const double MaxUtteranceSeconds = 20.0; // hard cap so a long monologue still flushes + + /// + /// A single capture device feeding this transcriber (microphone or system-audio loopback), with + /// its own raw-PCM buffer so concurrent sources never share captured bytes. + /// + private sealed class CaptureChannel + { + public WaveFormat SourceFormat { get; } + public MemoryStream PcmBuffer { get; } = new(); + public object BufferLock { get; } = new(); + private readonly IWaveIn? _waveIn; + private readonly WasapiRecorder? _wasapiRecorder; + private readonly EventHandler? _waveInDataAvailableHandler; + private readonly CaptureDataAvailableHandler? _wasapiDataAvailableHandler; + + public CaptureChannel(IWaveIn capture) + { + _waveIn = capture; + SourceFormat = capture.WaveFormat; + _waveInDataAvailableHandler = (_, e) => + { + lock (BufferLock) + PcmBuffer.Write(e.Buffer, 0, e.BytesRecorded); + }; + capture.DataAvailable += _waveInDataAvailableHandler; + } + + public CaptureChannel(WasapiRecorder capture) + { + _wasapiRecorder = capture; + SourceFormat = capture.WaveFormat; + _wasapiDataAvailableHandler = (buffer, _, _, _) => + { + lock (BufferLock) + PcmBuffer.Write(buffer); + }; + capture.DataAvailable += _wasapiDataAvailableHandler; + } + + public void StartRecording() + { + if (_waveIn is not null) + _waveIn.StartRecording(); + else + _wasapiRecorder!.StartRecording(); + } + + public void StopRecording() + { + if (_waveIn is not null) + _waveIn.StopRecording(); + else + _wasapiRecorder!.StopRecording(); + } + + public void Dispose() + { + if (_waveIn is not null) + { + _waveIn.DataAvailable -= _waveInDataAvailableHandler; + try { _waveIn.Dispose(); } catch { } + } + else if (_wasapiRecorder is not null) + { + _wasapiRecorder.DataAvailable -= _wasapiDataAvailableHandler; + try { _wasapiRecorder.Dispose(); } catch { } + } + } + } + + private readonly List _channels = []; + private WhisperFactoryLease? _factoryLease; + private WhisperProcessor? _processor; + private WhisperVadProcessor? _vadProcessor; + private readonly SemaphoreSlim _processingGate = new(1, 1); + + // Serializes StartAsync/StopAsync so a restart (e.g. changing the model or capture source while a + // session is live) can never overlap a start with an in-flight stop. Without this, a caller that + // fires Stop() (fire-and-forget) and then immediately awaits StartAsync() — as the source/model + // menu handlers used to — could race: the new session's capture channels get added to the same + // list the old session's Cleanup() is disposing/clearing on a background thread, corrupting shared + // state and crashing the app. With the lock, StartAsync simply waits for the prior StopAsync to + // finish flushing and cleaning up before building the new session. + private readonly SemaphoreSlim _lifecycleLock = new(1, 1); + private System.Timers.Timer? _chunkTimer; + private volatile bool _isRunning; + + // Timer.Stop() does not cancel Elapsed callbacks already queued to the thread pool, so one can + // still take the processing gate after StopAsync's flush releases it and run against state + // Cleanup() is tearing down. Teardown sets this, and a timed pass that sees it bails out. + private volatile bool _stopping; + private bool _disposed; + + /// Raised with recognized text for each completed (VAD-delimited) utterance. + public event EventHandler? PhraseRecognized; + + public bool IsRunning => _isRunning; + + /// The source the current (or most recent) session is capturing from. + public LiveCaptureSource Source { get; private set; } = LiveCaptureSource.Microphone; + + /// + /// Starts capturing from the requested source (microphone or system loopback) and transcribing + /// VAD-delimited utterances. Returns false when the device isn't available or startup otherwise + /// fails. The Whisper and VAD models are downloaded on first use, so the first call may take a while. + /// + public async Task StartAsync(LiveCaptureSource source = LiveCaptureSource.Microphone) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Waits out any in-flight StopAsync (e.g. a restart triggered by a model/source change) so a + // new session never starts while the previous one is still flushing/cleaning up. + await _lifecycleLock.WaitAsync().ConfigureAwait(false); + try + { + if (_isRunning) + return true; + + _stopping = false; + Source = source; + + bool wantsMic = source is LiveCaptureSource.Microphone or LiveCaptureSource.MicrophoneAndSystemAudio; + bool wantsSystem = source is LiveCaptureSource.SystemAudio or LiveCaptureSource.MicrophoneAndSystemAudio; + + if (wantsMic && WaveIn.DeviceCount <= 0) + { + AudioDebugLog.Write("LiveAudioTranscriber: no microphone capture device found"); + return false; + } + + // Live sessions only ever load a fast, live-eligible model (see CurrentLiveModelChoice) — + // never whatever large model might be selected for file transcription. + WhisperModelChoice choice = AudioTranscriptionUtilities.CurrentLiveModelChoice; + + // Held for the life of the session (released in Cleanup, after the processor): the shared + // factory owns the native model _processor decodes against. + _factoryLease = await AudioTranscriptionUtilities.AcquireFactoryAsync(null, CancellationToken.None, choice).ConfigureAwait(false); + _processor = _factoryLease.Factory.CreateBuilder() + .WithLanguage(WhisperModelInfo.LanguageFor(choice)) + .WithNoContext() // each utterance stands alone: faster and avoids cross-phrase drift + .WithThreads(Math.Max(1, Environment.ProcessorCount - 1)) + .Build(); + + WhisperVadFactory vadFactory = await AudioTranscriptionUtilities.GetVadFactoryAsync(null, CancellationToken.None).ConfigureAwait(false); + _vadProcessor = vadFactory.CreateBuilder() + .WithThreshold(0.5f) + .WithMinSpeechDuration(TimeSpan.FromMilliseconds(250)) + .WithMinSilenceDuration(TimeSpan.FromMilliseconds(300)) + .WithSpeechPadding(TimeSpan.FromMilliseconds(64)) + .WithThreads(Math.Max(1, Environment.ProcessorCount - 1)) + .Build(); + + // Microphone can be forced to 16 kHz mono (WinMM converts); loopback yields the render + // device's mix format (usually 32-bit float stereo) which we resample when processing. + // Each source gets its own channel/buffer; when both are requested they're captured + // independently and mixed down to one stream per processing pass (see MixChannels). + if (wantsMic) + _channels.Add(new CaptureChannel(new WaveIn { WaveFormat = new WaveFormat(16000, 16, 1), BufferMilliseconds = 100 })); + if (wantsSystem) + _channels.Add(new CaptureChannel(new WasapiRecorderBuilder() + .WithLoopbackCapture() + .WithBufferLength(100) + .Build())); + + foreach (CaptureChannel channel in _channels) + AudioDebugLog.Write($"LiveAudioTranscriber: source={source} model={choice} format={channel.SourceFormat.Encoding} {channel.SourceFormat.SampleRate}Hz {channel.SourceFormat.Channels}ch {channel.SourceFormat.BitsPerSample}bit"); + + foreach (CaptureChannel channel in _channels) + channel.StartRecording(); + + _chunkTimer = new System.Timers.Timer(TimerIntervalMs) { AutoReset = true }; + _chunkTimer.Elapsed += async (_, _) => await ProcessBufferedChunkAsync().ConfigureAwait(false); + _chunkTimer.Start(); + + _isRunning = true; + AudioDebugLog.Write("LiveAudioTranscriber: started"); + return true; + } + catch (Exception ex) + { + AudioDebugLog.Write($"LiveAudioTranscriber: failed to start ({source}): {ex.Message}"); + Cleanup(); + return false; + } + finally + { + _lifecycleLock.Release(); + } + } + + /// + /// Stops capturing, flushes and transcribes any remaining buffered speech, then releases + /// resources. Awaitable so a restart (source/model change) can wait for a clean teardown. Holds + /// the same lifecycle lock as , so a start already waiting on this stop + /// resumes only once the flush/cleanup below has fully finished. + /// + public async Task StopAsync() + { + await _lifecycleLock.WaitAsync().ConfigureAwait(false); + try + { + if (!_isRunning && _channels.Count == 0) + return; + + _isRunning = false; + _stopping = true; + _chunkTimer?.Stop(); + + foreach (CaptureChannel channel in _channels) + try { channel.StopRecording(); } catch { } + + // Flush whatever remains (this waits for any in-flight pass), then clean up. + try { await ProcessBufferedChunkAsync(flush: true).ConfigureAwait(false); } catch { } + + // Tear down under the same gate the timed passes take, so a callback queued before + // Stop() can never be inside ProcessBufferedChunkAsync while state is being disposed. + await _processingGate.WaitAsync().ConfigureAwait(false); + try + { + Cleanup(); + } + finally + { + _processingGate.Release(); + } + + AudioDebugLog.Write("LiveAudioTranscriber: stopped"); + } + finally + { + _lifecycleLock.Release(); + } + } + + /// Fire-and-forget stop for callers that can't await (see ). + public void Stop() => _ = StopAsync(); + + /// + /// Sums same-length-padded per-channel samples into one stream, clamping to [-1, 1] so two loud + /// sources can't clip beyond Whisper's expected range. A single channel is returned unchanged. + /// + private static float[] MixChannels(List perChannelSamples) + { + if (perChannelSamples.Count == 1) + return perChannelSamples[0]; + + int length = 0; + foreach (float[] samples in perChannelSamples) + length = Math.Max(length, samples.Length); + + float[] mixed = new float[length]; + foreach (float[] samples in perChannelSamples) + for (int i = 0; i < samples.Length; i++) + mixed[i] += samples[i]; + + for (int i = 0; i < mixed.Length; i++) + mixed[i] = Math.Clamp(mixed[i], -1f, 1f); + + return mixed; + } + + /// + /// Runs VAD over the buffered audio and transcribes any completed speech regions. Normal (timed) + /// passes skip if one is already running; a pass waits its turn and + /// forces transcription of whatever speech remains. + /// + private async Task ProcessBufferedChunkAsync(bool flush = false) + { + if (flush) + await _processingGate.WaitAsync().ConfigureAwait(false); + else if (!await _processingGate.WaitAsync(0).ConfigureAwait(false)) + return; + + try + { + // A pass queued before Stop() must not start once teardown has begun. + if (_stopping && !flush) + return; + + WhisperProcessor? processor = _processor; + WhisperVadProcessor? vad = _vadProcessor; + if (processor is null || vad is null || _channels.Count == 0) + return; + + // Snapshot without clearing — audio keeps arriving while we work; we trim precisely later. + List perChannelSamples = new(_channels.Count); + bool anyData = false; + foreach (CaptureChannel channel in _channels) + { + byte[] raw; + lock (channel.BufferLock) + { + if (channel.PcmBuffer.Length == 0) + { + perChannelSamples.Add([]); + continue; + } + raw = channel.PcmBuffer.ToArray(); + } + anyData = true; + perChannelSamples.Add(AudioTranscriptionUtilities.ConvertToSamples16kMono(raw, raw.Length, channel.SourceFormat)); + } + if (!anyData) + return; + + float[] samples = MixChannels(perChannelSamples); + double totalSeconds = samples.Length / (double)SampleRate; + if (!flush && totalSeconds < MinAudioSeconds) + return; + + IReadOnlyList speech = await vad.DetectSpeechAsync(samples).ConfigureAwait(false); + if (speech.Count == 0) + { + // Only silence so far: keep just the tail so the buffer doesn't grow during quiet. + TrimAllChannelsFront(totalSeconds - 0.5); + return; + } + + bool forced = flush || totalSeconds >= MaxUtteranceSeconds; + double cutSeconds = 0; + StringBuilder phrase = new(); + + for (int i = 0; i < speech.Count; i++) + { + VadSegmentData seg = speech[i]; + bool complete = forced || (totalSeconds - seg.End.TotalSeconds) >= CompletionSilenceSeconds; + if (!complete) + break; // speech still in progress: leave this and later regions buffered + + int start = Math.Max(0, (int)(seg.Start.TotalSeconds * SampleRate) - (SampleRate / 20)); + int end = Math.Min(samples.Length, (int)(seg.End.TotalSeconds * SampleRate) + (SampleRate / 20)); + cutSeconds = seg.End.TotalSeconds; + if (end <= start) + continue; + + ReadOnlyMemory slice = new(samples, start, end - start); + StringBuilder segmentText = new(); + await foreach (SegmentData s in processor.ProcessAsync(slice, CancellationToken.None).ConfigureAwait(false)) + segmentText.Append(s.Text); + + string cleaned = AudioTranscriptionUtilities.CleanTranscript(segmentText.ToString()); + if (cleaned.Length > 0) + { + if (phrase.Length > 0) + phrase.Append(' '); + phrase.Append(cleaned); + } + } + + if (cutSeconds > 0) + TrimAllChannelsFront(cutSeconds); + + if (phrase.Length > 0) + PhraseRecognized?.Invoke(this, phrase.ToString()); + } + catch (Exception ex) + { + AudioDebugLog.Write($"LiveAudioTranscriber: chunk processing error: {ex.Message}"); + } + finally + { + _processingGate.Release(); + } + } + + /// + /// Drops the first of buffered audio (rounded to a whole sample frame) + /// from every capture channel. Only the consumed prefix is removed, so audio captured during + /// processing is preserved. Each channel is trimmed using its own format, but by the same real-time + /// duration, so mixed channels stay in sync. + /// + private void TrimAllChannelsFront(double seconds) + { + if (seconds <= 0) + return; + + foreach (CaptureChannel channel in _channels) + { + int bytesToRemove = (int)(seconds * channel.SourceFormat.AverageBytesPerSecond); + int blockAlign = channel.SourceFormat.BlockAlign; + if (blockAlign > 0) + bytesToRemove -= bytesToRemove % blockAlign; + if (bytesToRemove <= 0) + continue; + + lock (channel.BufferLock) + { + byte[] current = channel.PcmBuffer.ToArray(); + int remove = Math.Min(bytesToRemove, current.Length); + channel.PcmBuffer.SetLength(0); + if (current.Length > remove) + channel.PcmBuffer.Write(current, remove, current.Length - remove); + } + } + } + + private void Cleanup() + { + if (_chunkTimer is not null) + { + _chunkTimer.Stop(); + _chunkTimer.Dispose(); + _chunkTimer = null; + } + + foreach (CaptureChannel channel in _channels) + channel.Dispose(); + _channels.Clear(); + + if (_processor is not null) + { + try { _processor.Dispose(); } catch { } + _processor = null; + } + + if (_vadProcessor is not null) + { + try { _vadProcessor.Dispose(); } catch { } + _vadProcessor = null; + } + + // Return the lease only after the processor built from it is disposed, then release the shared + // factory so the model doesn't stay resident in RAM once this session is done with it — the + // next session (or a restart on model/source change) simply reloads it. The VAD factory is + // shared and owned by AudioTranscriptionUtilities; nothing to release there. + if (_factoryLease is not null) + { + _factoryLease.Dispose(); + _factoryLease = null; + AudioTranscriptionUtilities.ReleaseFactory(); + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + Stop(); + } +} 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 67% rename from Text-Grab/Utilities/CaptureLanguageUtilities.cs rename to Text-Grab.Core.Windows/Utilities/CaptureLanguageUtilities.cs index c5e96756..862aec51 100644 --- a/Text-Grab/Utilities/CaptureLanguageUtilities.cs +++ b/Text-Grab.Core.Windows/Utilities/CaptureLanguageUtilities.cs @@ -4,37 +4,34 @@ using System.Threading.Tasks; using Text_Grab.Interfaces; using Text_Grab.Models; -using Windows.Media.Ocr; +using Text_Grab.Services; namespace Text_Grab.Utilities; internal static class CaptureLanguageUtilities { + /// + /// Builds the language list for capture menus. The UI-automation / Windows AI / plain-OCR + /// portion comes from , which caches those + /// checks (each of which can be a genuinely slow WinRT/WinAppSDK probe) instead of redoing + /// them on every call — this used to duplicate that work uncached, which is what made menus + /// like EditTextWindow's "Capture" menu slow to open, especially in new windows. + /// public static async Task> GetCaptureLanguagesAsync(bool includeTesseract) { - List languages = []; - - if (AppUtilities.TextGrabSettings.UiAutomationEnabled) - languages.Add(new UiAutomationLang()); - - if (WindowsAiUtilities.CanDeviceUseWinAI()) - languages.Add(new WindowsAiLang()); - - if (AppUtilities.TextGrabSettings.WindowsAiDescriptionEnabled - && WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) - { - languages.Add(new WindowsAiDescriptionLang()); - } + List languages = [.. LanguageUtilities.GetAllLanguages()]; if (includeTesseract - && AppUtilities.TextGrabSettings.UseTesseract + && SettingsAccess.Current.UseTesseract && TesseractHelper.CanLocateTesseractExe()) { - languages.AddRange(await TesseractHelper.TesseractLanguages()); - } + List tesseractLanguages = await TesseractHelper.TesseractLanguages(); - foreach (Windows.Globalization.Language language in OcrEngine.AvailableRecognizerLanguages) - languages.Add(new GlobalLang(language)); + // Insert before the plain OCR languages (GlobalLang), after the UiAutomation/WindowsAi + // pseudo-languages, to preserve the original ordering. + int insertIndex = languages.FindIndex(l => l is GlobalLang); + languages.InsertRange(insertIndex < 0 ? languages.Count : insertIndex, tesseractLanguages); + } return languages; } @@ -68,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.Core.Windows/Utilities/LimitedAccessFeatureUtilities.cs b/Text-Grab.Core.Windows/Utilities/LimitedAccessFeatureUtilities.cs new file mode 100644 index 00000000..fe74e25d --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/LimitedAccessFeatureUtilities.cs @@ -0,0 +1,156 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Reflection; +using Windows.ApplicationModel; + +namespace Text_Grab.Utilities; + +/// +/// Unlocks the Windows AI language model, which Microsoft ships as a Limited Access Feature. +/// +/// An app must call with a token issued for +/// its own publisher ID before Microsoft.Windows.AI.Text.LanguageModel will do anything; +/// without it every call fails with "Access is denied. Limited Access Feature is not available: +/// com.microsoft.windows.ai.languagemodel." +/// +/// 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.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. +/// +internal static class LimitedAccessFeatureUtilities +{ + internal const string LanguageModelFeatureId = "com.microsoft.windows.ai.languagemodel"; + + private const string TokenKey = "LAF_TOKEN"; + private const string PublisherIdKey = "LAF_PUBLISHER_ID"; + + /// The unlock is process-wide and only needs to happen once. + private static (bool Unlocked, string? Reason)? _languageModelUnlock; + + private static readonly Lock _unlockLock = new(); + + /// + /// Attempts to unlock the language model feature, returning why it is unavailable when it is. + /// The result is cached until forgets a failed one. + /// + internal static (bool Unlocked, string? Reason) TryUnlockLanguageModel() + { + lock (_unlockLock) + { + _languageModelUnlock ??= UnlockLanguageModel(); + return _languageModelUnlock.Value; + } + } + + /// + /// Forgets a cached unlock *failure* so the next request asks Windows again. Called when the + /// Windows AI runtime is restarted after a dropped connection: TryUnlockFeature talks to that + /// runtime too, so a failure cached while it was away would keep the feature off for the life + /// of the process. A successful unlock is kept, since it never needs redoing. + /// + internal static void ResetUnlockCache() + { + lock (_unlockLock) + { + if (_languageModelUnlock is { Unlocked: false }) + _languageModelUnlock = null; + } + } + + private static (bool Unlocked, string? Reason) UnlockLanguageModel() + { + string publisherId = GetSetting(PublisherIdKey); + + // The publisher ID is the hash half of the package family name. Falling back to it means a + // build with only a token configured still forms the correct usage string. + if (string.IsNullOrWhiteSpace(publisherId)) + publisherId = GetPublisherHash(); + + string token = GetSetting(TokenKey); + string usage = $"{publisherId} has registered their use of {LanguageModelFeatureId} with Microsoft and agrees to the terms of use."; + + try + { + LimitedAccessFeatureRequestResult result = + LimitedAccessFeatures.TryUnlockFeature(LanguageModelFeatureId, token, usage); + + if (result.Status is LimitedAccessFeatureStatus.Available or LimitedAccessFeatureStatus.AvailableWithoutToken) + { + Debug.WriteLine($"Windows AI language model unlocked: {result.Status}"); + return (true, null); + } + + Debug.WriteLine($"Windows AI language model not unlocked: {result.Status}"); + return (false, DescribeFailure(result.Status, token, publisherId)); + } + catch (Exception ex) + { + Debug.WriteLine($"TryUnlockFeature failed: {ex.Message}"); + return (false, $"Windows could not unlock the AI language model feature: {ex.Message}"); + } + } + + private static string DescribeFailure(LimitedAccessFeatureStatus status, string token, string publisherId) + { + string statusText = status switch + { + LimitedAccessFeatureStatus.Unavailable => "Windows reports the feature as unavailable", + LimitedAccessFeatureStatus.Unknown => "Windows does not recognize this app as registered for the feature", + _ => $"Windows returned {status}", + }; + + string tokenText = string.IsNullOrWhiteSpace(token) + ? "This build of Text-Grab has no unlock token configured." + : "The configured unlock token was rejected."; + + return $""" + Windows AI's language model is a Limited Access Feature and must be unlocked before it can be used. + + {tokenText} {statusText}. + + A token has to be requested from Microsoft at https://aka.ms/laffeatures for publisher ID '{publisherId}', then supplied at build time via the LafToken and LafPublisherId MSBuild properties (or the LAF_TOKEN and LAF_PUBLISHER_ID environment variables). + """; + } + + /// + /// Reads a value from build-time assembly metadata, falling back to an environment variable. + /// + private static string GetSetting(string key) + { + foreach (AssemblyMetadataAttribute attribute in typeof(LimitedAccessFeatureUtilities).Assembly + .GetCustomAttributes()) + { + if (string.Equals(attribute.Key, key, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(attribute.Value)) + return attribute.Value; + } + + return Environment.GetEnvironmentVariable(key) ?? string.Empty; + } + + /// + /// The publisher hash from the package family name ("Name_hash" -> "hash"), which is the + /// publisher ID a Limited Access Feature token is issued against. + /// + internal static string GetPublisherHash() + { + try + { + string familyName = Package.Current.Id.FamilyName; + if (string.IsNullOrWhiteSpace(familyName)) + return string.Empty; + + string[] parts = familyName.Split('_'); + return parts.Length >= 2 ? parts[1] : string.Empty; + } + catch (Exception ex) + { + Debug.WriteLine($"Could not read the package family name: {ex.Message}"); + return string.Empty; + } + } +} 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.Core.Windows/Utilities/WinAiLanguageModel.cs b/Text-Grab.Core.Windows/Utilities/WinAiLanguageModel.cs new file mode 100644 index 00000000..f5d0b869 --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/WinAiLanguageModel.cs @@ -0,0 +1,613 @@ +using Microsoft.Windows.AI; +using Microsoft.Windows.AI.ContentSafety; +using Microsoft.Windows.AI.Text; +using System.Diagnostics; +using Windows.Foundation; + +namespace Text_Grab.Utilities; + +/// Why a request to the Windows AI language model did not produce text. +internal enum WinAiFailure +{ + /// The model answered. + None, + + /// This device cannot run the Windows AI language model at all. + Unavailable, + + /// The model exists but could not be prepared or created. + ModelNotReady, + + /// + /// The connection to the out-of-process Windows AI runtime dropped, which surfaces as + /// "The RPC server is unavailable". Recoverable by restarting the model. + /// + Disconnected, + + /// The prompt did not fit the model's context. + PromptTooLong, + + /// Content moderation or policy rejected the prompt or the response. + Blocked, + + /// The model reported an error, or returned nothing usable. + ModelError, +} + +/// The result of one generation: either text, or a reason there is none. +internal readonly record struct WinAiGenerationResult(string? Text, WinAiFailure Failure, string? Message) +{ + internal bool Succeeded => Failure is WinAiFailure.None && Text is not null; + + internal static WinAiGenerationResult Ok(string text) => new(text, WinAiFailure.None, null); + + internal static WinAiGenerationResult Failed(WinAiFailure failure, string message) => + new(null, failure, message); +} + +/// +/// Shared access to the Windows AI Foundry (Phi Silica), following the +/// pattern used by microsoft/ai-dev-gallery's PhiSilicaClient. +/// +/// Everything in Text-Grab that prompts the language model — translation, meeting notes, regex +/// extraction — goes through here so they all get the same three things: +/// 1. The Limited Access Feature unlock, checked once and reported with a message a user can act +/// on rather than an "Access is denied" exception from deep inside the model call. +/// 2. One created and reused across features; only the lightweight +/// per-request is rebuilt, so turns never accumulate and +/// the second feature to run does not pay the model creation cost again. +/// 3. Prompting the model directly through GenerateResponseAsync with a purpose-built system +/// prompt, instead of bending a skill such as TextRewriter into the job (whose own system +/// prompt fights the instruction and leaves instruction echoes in the output). +/// 4. Recovery from a dropped connection to the Windows AI runtime: the model runs out of +/// process, so when that process is recycled, updated or crashes, every object this process +/// holds becomes a dead proxy and keeps failing with "The RPC server is unavailable" until +/// it is thrown away and remade. That is done here rather than by restarting Text-Grab. +/// +/// Every failure path returns a and a human-readable message so callers +/// can tell the user why nothing came back. +/// +internal static class WinAiLanguageModel +{ + private static LanguageModel? _languageModel; + + // Phi Silica serves one generation at a time; queueing here keeps concurrent callers from + // interleaving requests on the shared model. Creation, warm-up and external release take the + // same lease, so no window can dispose the model while another feature is using it. + private static readonly SemaphoreSlim _inferenceLock = new(1, 1); + private static volatile bool _disposed; + + #region availability + + /// + /// Whether this device can run the Windows AI language model, and why not when it cannot. + /// Unlike the OCR checks this asks directly rather + /// than assuming ARM64, so Intel/AMD Copilot+ PCs are included and unsupported hardware is + /// excluded properly. + /// + internal static (bool Available, string? Reason) CheckAvailability() + { + if (!PackageIdentity.IsPackaged()) + return (false, "Windows AI is only available when Text-Grab runs as an installed (packaged) app."); + + if (OSInterop.IsWindows10()) + return (false, "The on-device Windows AI language model requires Windows 11."); + + try + { + AIFeatureReadyState readyState = LanguageModel.GetReadyState(); + + if (readyState is AIFeatureReadyState.NotSupportedOnCurrentSystem) + return (false, "This device does not support the on-device Windows AI language model. It requires a Copilot+ PC."); + + if (readyState is AIFeatureReadyState.DisabledByUser) + return (false, "The Windows AI language model is turned off in Windows Settings."); + + // Microsoft ships the language model as a Limited Access Feature, so it must be + // unlocked before any call to it will succeed. Do it here, ahead of CreateAsync and + // CreateContext, so the failure is reported once and clearly. + (bool unlocked, string? unlockReason) = LimitedAccessFeatureUtilities.TryUnlockLanguageModel(); + + return unlocked ? (true, null) : (false, unlockReason); + } + catch (Exception ex) + { + Debug.WriteLine($"LanguageModel.GetReadyState failed: {ex.Message}"); + return (false, $"Windows AI could not be reached on this device: {ex.Message}"); + } + } + + /// True when this device can run the Windows AI language model. + internal static bool IsAvailable() => CheckAvailability().Available; + + /// Returns the shared model, creating (and preparing, if needed) it on first use. + /// + /// Private on purpose: a caller that held on to the returned model would keep using it after a + /// dropped connection forced a restart. Features call to check + /// the model can be started, then , which always uses the current one. + /// The caller must hold the inference lease, including when only warming up the model. + /// + private static async Task<(LanguageModel? Model, string? Error)> GetModelAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_disposed) + return (null, "The Windows AI language model has been shut down."); + + if (_languageModel is not null) + return (_languageModel, null); + + try + { + (bool available, string? reason) = CheckAvailability(); + if (!available) + return (null, reason); + + if (LanguageModel.GetReadyState() is AIFeatureReadyState.NotReady) + { + // First run may download the model; the token lets the user back out of the wait. + AIFeatureReadyResult readyResult = await LanguageModel.EnsureReadyAsync().AsTask(cancellationToken); + if (readyResult.Status != AIFeatureReadyResultState.Success) + { + string detail = readyResult.ExtendedError?.Message ?? readyResult.Status.ToString(); + return (null, $"The Windows AI language model could not be prepared ({detail}). " + + "It may still be downloading — try again in a few minutes."); + } + } + + _languageModel = await LanguageModel.CreateAsync().AsTask(cancellationToken); + return (_languageModel, null); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"LanguageModel creation failed: {ex.Message}"); + return (null, $"The Windows AI language model could not be started: {ex.Message}"); + } + } + + /// + /// Makes sure the shared model exists and is ready, without handing it out. Features call this + /// up front so "the model could not be started" is reported before any work begins. + /// + internal static async Task<(bool Ready, string? Error)> EnsureModelAsync(CancellationToken cancellationToken) + { + using IDisposable lease = await AcquireInferenceAsync(cancellationToken); + (LanguageModel? model, string? error) = await GetModelAsync(cancellationToken); + return (model is not null, error); + } + + /// + /// HRESULTs that mean this process is holding a proxy to a Windows AI runtime that is no longer + /// there. The model runs out of process, so a service restart, a model update or a crash leaves + /// every object created before it permanently dead: the next call comes back as "The RPC server + /// is unavailable" (0x800706BA) and every call after it does too, until new objects are made. + /// + private static readonly int[] _connectionLostHResults = + [ + unchecked((int)0x800706BA), // RPC_S_SERVER_UNAVAILABLE — "The RPC server is unavailable." + unchecked((int)0x800706BB), // RPC_S_SERVER_TOO_BUSY + unchecked((int)0x800706BE), // RPC_S_CALL_FAILED + unchecked((int)0x800706BF), // RPC_S_CALL_FAILED_DNE + unchecked((int)0x800706B5), // RPC_S_UNKNOWN_IF + unchecked((int)0x80010108), // RPC_E_DISCONNECTED — the object invoked has disconnected + unchecked((int)0x80010105), // RPC_E_SERVERFAULT + unchecked((int)0x800401FD), // CO_E_OBJNOTCONNECTED + ]; + + /// Whether means the Windows AI runtime went away. + internal static bool IsConnectionLost(Exception? exception) + { + for (Exception? ex = exception; ex is not null; ex = ex.InnerException) + if (Array.IndexOf(_connectionLostHResults, ex.HResult) >= 0) + return true; + + return false; + } + + /// + /// Throws away the shared model, and any cached reason it could not be unlocked, so the next + /// request builds a fresh connection to the Windows AI runtime. and + /// do this by themselves when a request finds the runtime gone; + /// call it directly to offer the user a manual "try again" without restarting Text-Grab. + /// + internal static async Task RestartModelAsync(CancellationToken cancellationToken = default) + { + if (_disposed) + return; + + using IDisposable lease = await AcquireInferenceAsync(cancellationToken) + .ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + RestartModelUnderInferenceLease(cancellationToken); + } + + private static void RestartModelUnderInferenceLease(CancellationToken cancellationToken) + { + if (_disposed) + return; + + cancellationToken.ThrowIfCancellationRequested(); + ReleaseModelUnderInferenceLease(); + + // A failure to unlock the Limited Access Feature is cached for the life of the process, so + // forget it too: a transient failure there would otherwise fail the retry before it starts. + LimitedAccessFeatureUtilities.ResetUnlockCache(); + } + + /// + /// Drops the cached language model to free the memory it holds. The next request recreates it, + /// so call this when a feature is switched off rather than between requests. + /// + /// + /// Waits for creation and every inference using the model, including a multi-request lease. + /// Recovery inside a request must use instead, + /// since it already holds the lease and acquiring it again would deadlock. + /// + internal static async Task ReleaseModelAsync(CancellationToken cancellationToken = default) + { + // Even an uncontended release must not dispose a native model on the caller's UI thread. + using IDisposable lease = await AcquireInferenceAsync(cancellationToken) + .ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + ReleaseModelUnderInferenceLease(); + } + + private static void ReleaseModelUnderInferenceLease() + { + try + { + _languageModel?.Dispose(); + } + catch (Exception ex) + { + // Disposing a dead proxy can throw; the reference is dropped either way. + Debug.WriteLine($"Disposing the language model failed: {ex.Message}"); + } + finally + { + _languageModel = null; + } + } + + /// + /// Fire-and-forget for callers that cannot await (window cleanup, + /// a toggle handler). The model is freed once any in-flight creation and inference finish. + /// + internal static void ReleaseModel() => _ = ReleaseModelAsync(); + + /// Releases the shared language model. Call once during application shutdown. + internal static void Cleanup() + { + if (_disposed) + return; + + _disposed = true; + + // Stop new creation immediately, but let an active request finish before disposing its + // native model. Shutdown must not block the UI thread waiting for that request. + ReleaseModel(); + + // Leave the semaphore alive so active leases can release it and queued callers can finish. + } + + #endregion availability + + #region generation + + /// + /// Runs a single prompt against the shared model, taking care of availability, model creation + /// and the inference queue. Callers issuing several related requests should take a lease from + /// and call themselves, so + /// their requests are not interleaved with another feature's. + /// + internal static async Task PromptAsync( + string systemPrompt, + string prompt, + float temperature = 0.2f, + Action? onPartial = null, + CancellationToken cancellationToken = default) + { + (bool available, string? reason) = CheckAvailability(); + if (!available) + return WinAiGenerationResult.Failed( + WinAiFailure.Unavailable, reason ?? "Windows AI is not available on this device."); + + try + { + using IDisposable lease = await AcquireInferenceAsync(cancellationToken); + return await GenerateAsync(systemPrompt, prompt, temperature, onPartial, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"Language model request failed: {ex.Message}"); + return WinAiGenerationResult.Failed(WinAiFailure.ModelError, $"The language model failed: {ex.Message}"); + } + } + + /// + /// Waits for exclusive use of the shared model. Dispose the returned lease to let the next + /// caller in. + /// + internal static async Task AcquireInferenceAsync(CancellationToken cancellationToken) + { + await _inferenceLock.WaitAsync(cancellationToken).ConfigureAwait(false); + return new InferenceLease(); + } + + private sealed class InferenceLease : IDisposable + { + private int _released; + + public void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + _inferenceLock.Release(); + } + } + + /// + /// Runs one generation against the shared model. The caller holds the inference lease. + /// + /// When the request fails because the connection to the Windows AI runtime dropped — the + /// service was restarted, updated or recycled while Text-Grab held a proxy to it, reported as + /// "The RPC server is unavailable" — the model is thrown away, remade, and the request is tried + /// once more. Without that, every later request fails on the same dead proxy and the feature + /// only comes back when Text-Grab is restarted. + /// + /// Receives generated text as it streams in, for live UI updates. + internal static async Task GenerateAsync( + string systemPrompt, + string prompt, + float temperature, + Action? onDelta, + CancellationToken cancellationToken) + { + (LanguageModel? model, string? error) = await GetModelAsync(cancellationToken); + if (model is null) + return WinAiGenerationResult.Failed( + WinAiFailure.ModelNotReady, error ?? "The Windows AI language model could not be started."); + + // Records whether the failed attempt already pushed text at the UI, so the retry does not + // stream a second copy of the response into it. + bool alreadyStreamed = false; + Action? firstDelta = onDelta is null + ? null + : delta => { alreadyStreamed = true; onDelta(delta); }; + + WinAiGenerationResult result = + await GenerateOnceAsync(model, systemPrompt, prompt, temperature, firstDelta, cancellationToken); + + if (result.Failure is not WinAiFailure.Disconnected) + return result; + + Debug.WriteLine($"Windows AI connection lost, restarting the language model: {result.Message}"); + RestartModelUnderInferenceLease(cancellationToken); + + (model, error) = await GetModelAsync(cancellationToken); + if (model is null) + return WinAiGenerationResult.Failed( + WinAiFailure.Disconnected, + $"Windows AI stopped responding and could not be restarted: {error ?? result.Message}"); + + WinAiGenerationResult retry = await GenerateOnceAsync( + model, systemPrompt, prompt, temperature, alreadyStreamed ? null : onDelta, cancellationToken); + + return retry.Failure is WinAiFailure.Disconnected + ? WinAiGenerationResult.Failed( + WinAiFailure.Disconnected, + "Windows AI stopped responding, and restarting the model did not bring it back. " + + "Try again in a moment, or restart Text-Grab.") + : retry; + } + + /// + /// Runs against the shared model, for the WinAppSDK text skills + /// (summarize, rewrite, text-to-table) which take a of their own + /// instead of going through . Takes the inference lease, and + /// restarts the model and tries once more when the Windows AI runtime connection has dropped. + /// + /// The skill's result, or null and a message saying why there is none. + internal static async Task<(T? Value, string? Error)> RunWithModelAsync( + Func> work, + CancellationToken cancellationToken = default) where T : class + { + (bool available, string? reason) = CheckAvailability(); + if (!available) + return (null, reason ?? "Windows AI is not available on this device."); + + try + { + using IDisposable lease = await AcquireInferenceAsync(cancellationToken); + + (LanguageModel? model, string? error) = await GetModelAsync(cancellationToken); + if (model is null) + return (null, error ?? "The Windows AI language model could not be started."); + + try + { + return (await work(model, cancellationToken), null); + } + catch (Exception ex) when (IsConnectionLost(ex)) + { + Debug.WriteLine($"Windows AI connection lost, restarting the language model: {ex.Message}"); + RestartModelUnderInferenceLease(cancellationToken); + + (model, error) = await GetModelAsync(cancellationToken); + if (model is null) + return (null, $"Windows AI stopped responding and could not be restarted: {error ?? ex.Message}"); + + return (await work(model, cancellationToken), null); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"Language model request failed: {ex.Message}"); + + return (null, IsConnectionLost(ex) + ? "Windows AI stopped responding, and restarting the model did not bring it back. " + + "Try again in a moment, or restart Text-Grab." + : ex.Message); + } + } + + /// One attempt at a generation, with no recovery of its own. + private static async Task GenerateOnceAsync( + LanguageModel model, + string systemPrompt, + string prompt, + float temperature, + Action? onDelta, + CancellationToken cancellationToken) + { + LanguageModelContext context; + try + { + // A fresh context per request keeps the system prompt in force without carrying previous + // turns forward, which is what kept growing the prompt (and the latency) before. + context = model.CreateContext(systemPrompt, new ContentFilterOptions()); + } + catch (Exception ex) + { + return WinAiGenerationResult.Failed( + Classify(ex), $"The language model could not accept the request: {ex.Message}"); + } + + // The context holds native resources for the life of one request; every return path below + // must go through this finally or each call leaks it (surfaced as ever-growing native + // memory even though the managed heap stays flat). + try + { + // Advisory pre-check only. If it cannot answer, send the prompt anyway and let the model + // report PromptLargerThanContext — treating an unknown length as "too long" would turn + // every request into a silent no-op. + try + { + ulong usableLength = model.GetUsablePromptLength(context, prompt); + if (usableLength > 0 && (ulong)prompt.Length > usableLength) + return WinAiGenerationResult.Failed( + WinAiFailure.PromptTooLong, "The text is longer than the language model's context."); + } + catch (Exception ex) + { + Debug.WriteLine($"GetUsablePromptLength failed, sending prompt anyway: {ex.Message}"); + } + + LanguageModelResponseResult result; + try + { + LanguageModelOptions options = new() + { + Temperature = temperature, + ContentFilterOptions = new ContentFilterOptions(), + }; + + IAsyncOperationWithProgress operation = + model.GenerateResponseAsync(context, prompt, options); + + if (onDelta is not null) + operation.Progress = (_, delta) => + { + if (!string.IsNullOrEmpty(delta)) + onDelta(delta); + }; + + result = await operation.AsTask(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return WinAiGenerationResult.Failed( + Classify(ex), $"The language model failed to respond: {ex.Message}"); + } + + switch (result.Status) + { + case LanguageModelResponseStatus.Complete: + break; + + case LanguageModelResponseStatus.PromptLargerThanContext: + return WinAiGenerationResult.Failed( + WinAiFailure.PromptTooLong, "The text is longer than the language model's context."); + + case LanguageModelResponseStatus.BlockedByPolicy: + case LanguageModelResponseStatus.PromptBlockedByContentModeration: + case LanguageModelResponseStatus.ResponseBlockedByContentModeration: + return WinAiGenerationResult.Failed( + WinAiFailure.Blocked, + $"Windows AI blocked this text ({result.Status}). Content moderation rejected the request."); + + default: + Exception? extendedError = result.ExtendedError; + string detail = extendedError?.Message ?? result.Status.ToString(); + return WinAiGenerationResult.Failed( + Classify(extendedError), $"The language model returned an error: {detail}"); + } + + string text = result.Text ?? string.Empty; + + return string.IsNullOrWhiteSpace(text) + ? WinAiGenerationResult.Failed(WinAiFailure.ModelError, "The language model returned an empty response.") + : WinAiGenerationResult.Ok(text); + } + finally + { + context.Dispose(); + } + } + + /// A lost connection is worth retrying after a restart; anything else is not. + private static WinAiFailure Classify(Exception? exception) => + IsConnectionLost(exception) ? WinAiFailure.Disconnected : WinAiFailure.ModelError; + + /// + /// Light tidy-up of a model response. Deliberately conservative: an earlier translation + /// implementation tried to detect and strip "instruction echoes" and would silently hand back + /// the untranslated input whenever the guess misfired. Prompting the model directly removes the + /// echoes at the source, so all that is left is trimming stray fences and wrapping quotes. + /// + internal static string CleanResponse(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + string cleaned = text.Trim(); + + if (cleaned.StartsWith("```", StringComparison.Ordinal)) + { + int firstNewline = cleaned.IndexOf('\n'); + if (firstNewline > 0) + cleaned = cleaned[(firstNewline + 1)..]; + + if (cleaned.EndsWith("```", StringComparison.Ordinal)) + cleaned = cleaned[..^3]; + + cleaned = cleaned.Trim(); + } + + // Models often wrap a short answer in quotes even when told not to. + if (cleaned.Length > 1 && + ((cleaned[0] == '"' && cleaned[^1] == '"') || + (cleaned[0] == '\'' && cleaned[^1] == '\'') || + (cleaned[0] == '“' && cleaned[^1] == '”'))) + { + cleaned = cleaned[1..^1].Trim(); + } + + return cleaned; + } + + #endregion generation +} diff --git a/Text-Grab.Core.Windows/Utilities/WinAiMeetingNotes.cs b/Text-Grab.Core.Windows/Utilities/WinAiMeetingNotes.cs new file mode 100644 index 00000000..3eb47b9a --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/WinAiMeetingNotes.cs @@ -0,0 +1,302 @@ +using Microsoft.Windows.AI.Text; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Text_Grab.Utilities; + +/// +/// Turns a long stretch of text — a transcript, a wall of captured chat, scratch notes — into +/// meeting notes: what was discussed, what was decided, and what happens next. +/// +/// Windows AI has no meeting-notes skill, so like translation and regex extraction this prompts the +/// shared directly with a purpose-built system prompt rather than +/// bending TextSummarizer or TextRewriter into the job. +/// +/// The wrinkle is length: meeting text routinely runs past Phi Silica's context, and summarizing +/// half a transcript twice does not make notes. So a long input is handled map-then-reduce — each +/// part is reduced to plain bullets, and those bullets are written up as one set of notes in a final +/// pass. Short input skips straight to that final pass, which is the common case. +/// +internal static class WinAiMeetingNotes +{ + /// Notes should follow the text, with just enough room to phrase an action item. + private const float Temperature = 0.3f; + + /// + /// Characters per part in the first pass. Deliberately well inside the model's context so the + /// common case is one request per part with no splitting. + /// + private const int PartChars = 2000; + + /// Never split a part more than this many times chasing a context that will not fit. + private const int MaxSplitDepth = 3; + + /// The shape of the finished notes, shared by the two prompts that produce them. + private const string NotesFormat = + "Write the notes in Markdown with exactly these sections, in this order:\n" + + "## Summary — two or three sentences on what the meeting was about.\n" + + "## Topics Discussed — one bullet per topic, each with the points made about it.\n" + + "## Decisions — one bullet per decision reached. Write 'None recorded' if there were none.\n" + + "## Next Steps — one bullet per action item, written as '- [ ] Owner — action (due date)', " + + "leaving out the owner or the date when the text does not give one. " + + "Write 'None recorded' if there were none.\n" + + "Use only what is in the text: never invent attendees, decisions, owners or dates. " + + "Keep names, numbers and dates exactly as they appear. " + + "Reply with the notes only: no preamble, no commentary, and never repeat these instructions."; + + private const string NotesSystemPrompt = + "You are a meeting notes writer. The user sends the transcript or raw notes from a meeting. " + + NotesFormat; + + private const string PartSystemPrompt = + "You are taking notes on one part of a longer meeting. The user sends that part of the " + + "transcript. List what was discussed, and anything that was decided or assigned, as short " + + "Markdown bullets — one point per bullet, keeping names, numbers and dates exactly as they " + + "appear. Do not add headings, do not summarize the meeting as a whole, and do not invent " + + "anything that is not in this part. Reply with the bullets only."; + + private const string MergeSystemPrompt = + "You are a meeting notes writer. The user sends rough bullets taken from consecutive parts " + + "of one meeting, in order. Combine them into a single set of notes, merging points that " + + "repeat. " + NotesFormat; + + /// + /// Writes up as meeting notes. The notes are in + /// ; on failure that is null and + /// says why. + /// + /// + /// Optional callback describing the stage in progress, for a loading label. It is raised on a + /// background thread; marshal to the UI thread before touching controls. + /// + internal static async Task SummarizeAsync( + string text, + Action? onProgress = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + return WinAiGenerationResult.Failed(WinAiFailure.ModelError, "There was no text to turn into meeting notes."); + + (bool available, string? reason) = WinAiLanguageModel.CheckAvailability(); + if (!available) + return WinAiGenerationResult.Failed( + WinAiFailure.Unavailable, reason ?? "Windows AI is not available on this device."); + + try + { + (bool ready, string? error) = await WinAiLanguageModel.EnsureModelAsync(cancellationToken); + if (!ready) + return WinAiGenerationResult.Failed( + WinAiFailure.ModelNotReady, error ?? "The Windows AI language model could not be started."); + + // One lease for the whole write-up: a set of notes is many requests, and letting another + // feature interleave with them on the single-threaded model would stall both. + using (await WinAiLanguageModel.AcquireInferenceAsync(cancellationToken)) + { + List parts = SplitIntoParts(text, PartChars); + + // Short enough to write up in one go, which is most captures. + if (parts.Count == 1) + { + WinAiGenerationResult single = await WinAiLanguageModel.GenerateAsync( + NotesSystemPrompt, parts[0], Temperature, null, cancellationToken); + + if (single.Text is not null) + return Finish(single.Text); + + if (single.Failure is not WinAiFailure.PromptTooLong) + return single; + + // The text fit the character budget but not the model's context, so go through + // the map-then-reduce path with smaller parts. + parts = SplitIntoParts(text, PartChars / 2); + if (parts.Count == 1) + return single; + } + + List bullets = []; + WinAiGenerationResult lastFailure = default; + + for (int index = 0; index < parts.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + onProgress?.Invoke($"Reading part {index + 1} of {parts.Count}..."); + + WinAiGenerationResult part = await SummarizePartAsync(parts[index], 0, cancellationToken); + + if (part.Text is null) + { + // One unreadable part should not lose the rest of the meeting. + Debug.WriteLine($"Meeting notes part {index + 1} failed ({part.Failure}): {part.Message}"); + lastFailure = part; + continue; + } + + bullets.Add(part.Text.Trim()); + } + + if (bullets.Count == 0) + return lastFailure.Message is not null + ? lastFailure + : WinAiGenerationResult.Failed(WinAiFailure.ModelError, "The language model returned no notes."); + + onProgress?.Invoke("Writing up the notes..."); + + WinAiGenerationResult merged = await MergeAsync([.. bullets], cancellationToken); + + return merged.Text is null ? merged : Finish(merged.Text); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"Meeting notes exception: {ex.Message}"); + return WinAiGenerationResult.Failed(WinAiFailure.ModelError, $"Meeting notes failed: {ex.Message}"); + } + } + + private static WinAiGenerationResult Finish(string text) + { + string cleaned = WinAiLanguageModel.CleanResponse(text); + + return string.IsNullOrWhiteSpace(cleaned) + ? WinAiGenerationResult.Failed(WinAiFailure.ModelError, "The notes came back empty.") + : WinAiGenerationResult.Ok(cleaned); + } + + /// + /// Reduces one part of the text to bullets, halving it and recursing when — and only when — the + /// model reports the prompt does not fit the context. + /// + private static async Task SummarizePartAsync( + string part, + int depth, + CancellationToken cancellationToken) + { + WinAiGenerationResult outcome = await WinAiLanguageModel.GenerateAsync( + PartSystemPrompt, part, Temperature, null, cancellationToken); + + if (outcome.Text is not null || outcome.Failure is not WinAiFailure.PromptTooLong || depth >= MaxSplitDepth) + return outcome; + + string[] halves = SplitInHalf(part); + if (halves.Length < 2) + return outcome; + + StringBuilder combined = new(); + foreach (string half in halves) + { + WinAiGenerationResult piece = await SummarizePartAsync(half, depth + 1, cancellationToken); + if (piece.Text is null) + return piece; + + combined.AppendLine(piece.Text.Trim()); + } + + return WinAiGenerationResult.Ok(combined.ToString()); + } + + /// + /// Writes the per-part bullets up as one set of notes. When they do not all fit at once, each + /// half is written up separately and the two write-ups are merged. + /// + private static async Task MergeAsync( + string[] sections, + CancellationToken cancellationToken) + { + string joined = string.Join("\n\n", sections); + + WinAiGenerationResult merged = await WinAiLanguageModel.GenerateAsync( + MergeSystemPrompt, joined, Temperature, null, cancellationToken); + + if (merged.Text is not null || merged.Failure is not WinAiFailure.PromptTooLong) + return merged; + + // Two sections that still will not fit together cannot be merged by splitting again, so + // hand back the sections themselves rather than nothing at all. + if (sections.Length < 3) + return WinAiGenerationResult.Ok(joined); + + int middle = sections.Length / 2; + + WinAiGenerationResult first = await MergeAsync(sections[..middle], cancellationToken); + if (first.Text is null) + return first; + + WinAiGenerationResult second = await MergeAsync(sections[middle..], cancellationToken); + if (second.Text is null) + return second; + + return await MergeAsync([first.Text, second.Text], cancellationToken); + } + + /// + /// Splits text into parts of roughly , breaking at a blank line + /// where possible and at a line break otherwise, so a part rarely stops mid-thought. + /// + internal static List SplitIntoParts(string text, int targetChars) + { + if (text.Length <= targetChars) + return [text]; + + List parts = []; + int start = 0; + + while (start < text.Length) + { + if (text.Length - start <= targetChars) + { + parts.Add(text[start..]); + break; + } + + int limit = start + targetChars; + + // Prefer a paragraph break, then any line break, then a space, and only cut mid-word + // when the text offers nothing else. + int breakAt = text.LastIndexOf("\n\n", limit, targetChars, StringComparison.Ordinal); + if (breakAt <= start) + breakAt = text.LastIndexOf('\n', limit, targetChars); + if (breakAt <= start) + breakAt = text.LastIndexOf(' ', limit, targetChars); + if (breakAt <= start) + breakAt = limit; + + parts.Add(text[start..breakAt]); + start = breakAt; + + while (start < text.Length && (text[start] == '\n' || text[start] == '\r' || text[start] == ' ')) + start++; + } + + return parts; + } + + private static string[] SplitInHalf(string text) + { + if (text.Length < 200) + return [text]; + + int middle = text.Length / 2; + int splitAt = text.LastIndexOf('\n', middle); + + if (splitAt <= 0) + splitAt = text.IndexOf('\n', middle); + + if (splitAt <= 0) + { + splitAt = text.LastIndexOf(' ', middle); + if (splitAt <= 0) + return [text]; + } + + return [text[..(splitAt + 1)], text[(splitAt + 1)..]]; + } +} diff --git a/Text-Grab.Core.Windows/Utilities/WinAiTranslator.cs b/Text-Grab.Core.Windows/Utilities/WinAiTranslator.cs new file mode 100644 index 00000000..37a626e1 --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/WinAiTranslator.cs @@ -0,0 +1,499 @@ +using Microsoft.Windows.AI.Text; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Text_Grab.Utilities; + +/// Why a translation did not produce new text. +internal enum TranslationFailure +{ + /// The text was translated. + None, + + /// This device cannot run the Windows AI language model at all. + Unavailable, + + /// The model exists but could not be prepared or created. + ModelNotReady, + + /// The text already looks like it is in the target language, so nothing was sent. + NotNeeded, + + /// The prompt did not fit the model's context, even after splitting. + PromptTooLong, + + /// Content moderation or policy rejected the prompt or the response. + Blocked, + + /// The model reported an error, or returned nothing usable. + ModelError, +} + +/// +/// The outcome of a translation. is always safe to use: on failure it is the +/// original, untranslated input. +/// +internal readonly record struct TranslationResult(string Text, TranslationFailure Failure, string? Message) +{ + internal bool Succeeded => Failure is TranslationFailure.None; + + internal static TranslationResult Success(string text) => new(text, TranslationFailure.None, null); +} + +/// +/// The outcome of a batched translation. always matches the requested list in +/// length and order; entries that could not be translated keep their original value. +/// +internal readonly record struct BatchTranslationResult( + IReadOnlyList Items, + int TranslatedCount, + TranslationFailure Failure, + string? Message) +{ + internal bool Succeeded => Failure is TranslationFailure.None; +} + +/// +/// On-device translation built on , the shared Windows AI Foundry +/// (Phi Silica), following the pattern used by microsoft/ai-dev-gallery's +/// PhiSilicaClient and Translate samples. +/// +/// The three things that make this fast compared to the previous implementation: +/// 1. The model is prompted directly through GenerateResponseAsync with a translation system +/// prompt, instead of being funneled through the TextRewriter skill (whose own "rewrite this +/// text" system prompt fought the translation instruction and produced instruction echoes in +/// the output). +/// 2. The is created once and reused across features; only the +/// lightweight per-request context is rebuilt so turns never accumulate. +/// 3. Many short strings are translated in one batched inference instead of one inference each, +/// and partial results stream back through the operation's Progress callback so the UI can +/// fill in while the model is still generating. +/// +/// Every failure path returns a and a human-readable message so +/// callers can tell the user why nothing changed, rather than silently handing back the input. +/// +internal static partial class WinAiTranslator +{ + /// Max items packed into a single batched request. + private const int MaxBatchItems = 40; + + /// Approximate character budget for the numbered list in a single batched request. + private const int MaxBatchChars = 1200; + + /// Translation wants the most likely wording, not a creative one. + private const float Temperature = 0.2f; + + [GeneratedRegex(@"^\s*(\d+)\s*[.):\]]\s*(.*)$")] + private static partial Regex NumberedItemRegex(); + + #region availability + + /// + /// Whether this device can run the Windows AI language model, and why not when it cannot. + /// + internal static (bool Available, string? Reason) CheckAvailability() => WinAiLanguageModel.CheckAvailability(); + + /// True when this device can run the Windows AI language model. + internal static bool IsAvailable() => WinAiLanguageModel.IsAvailable(); + + /// + /// Drops the cached language model to free the memory it holds. The next translation recreates + /// it, so call this when translation is switched off rather than between translations. + /// Release is queued behind any active AI request without blocking the caller. + /// + internal static void ReleaseModel() => WinAiLanguageModel.ReleaseModel(); + + /// Releases the shared language model. Call once during application shutdown. + internal static void Cleanup() => WinAiLanguageModel.Cleanup(); + + #endregion availability + + #region generation + + /// Maps a shared model failure onto the translation-facing reason for it. + private static TranslationFailure ToTranslationFailure(WinAiFailure failure) => failure switch + { + WinAiFailure.None => TranslationFailure.None, + WinAiFailure.Unavailable => TranslationFailure.Unavailable, + WinAiFailure.ModelNotReady => TranslationFailure.ModelNotReady, + WinAiFailure.PromptTooLong => TranslationFailure.PromptTooLong, + WinAiFailure.Blocked => TranslationFailure.Blocked, + _ => TranslationFailure.ModelError, + }; + + private static string SystemPromptFor(string targetLanguage) => + $"You are a translation engine. Translate everything the user sends into {targetLanguage}, " + + $"written in the native script and characters of {targetLanguage}. " + + "Preserve the original line breaks, numbers, punctuation and formatting. " + + "Reply with the translation only: no notes, no explanations, no quotation marks around it, " + + "and never repeat these instructions."; + + #endregion generation + + #region single text + + /// + /// Translates a block of text. The returned is the original + /// input whenever translation did not happen, and then + /// explains why so the caller can tell the user. + /// + /// + /// Optional callback receiving generated text as it arrives. It is raised on a background + /// thread; marshal to the UI thread before touching controls. + /// + internal static async Task TranslateAsync( + string textToTranslate, + string targetLanguage, + Action? onPartial = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(textToTranslate)) + return TranslationResult.Success(textToTranslate); + + if (string.IsNullOrWhiteSpace(targetLanguage)) + return new TranslationResult(textToTranslate, TranslationFailure.Unavailable, "No target language was set."); + + (bool available, string? reason) = CheckAvailability(); + if (!available) + return new TranslationResult(textToTranslate, TranslationFailure.Unavailable, reason); + + if (LanguageHeuristics.IsLikelyInTargetLanguage(textToTranslate, targetLanguage)) + return new TranslationResult( + textToTranslate, TranslationFailure.NotNeeded, $"The text already appears to be in {targetLanguage}."); + + try + { + (bool ready, string? error) = await WinAiLanguageModel.EnsureModelAsync(cancellationToken); + if (!ready) + return new TranslationResult(textToTranslate, TranslationFailure.ModelNotReady, error); + + string systemPrompt = SystemPromptFor(targetLanguage); + + // One lease for the whole translation so another feature's request cannot interleave + // with it on the single-threaded model. + using (await WinAiLanguageModel.AcquireInferenceAsync(cancellationToken)) + { + WinAiGenerationResult outcome = await TranslateBlockAsync( + systemPrompt, textToTranslate, onPartial, cancellationToken); + + if (outcome.Text is null) + return new TranslationResult( + textToTranslate, ToTranslationFailure(outcome.Failure), outcome.Message); + + string cleaned = CleanResult(outcome.Text); + + return string.IsNullOrWhiteSpace(cleaned) + ? new TranslationResult(textToTranslate, TranslationFailure.ModelError, "The translation came back empty.") + : TranslationResult.Success(cleaned); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"Translation exception: {ex.Message}"); + return new TranslationResult(textToTranslate, TranslationFailure.ModelError, $"Translation failed: {ex.Message}"); + } + } + + /// + /// Translates one block, splitting it on line boundaries and recursing when — and only when — + /// the model reports the prompt does not fit the context. Any other failure is passed straight + /// back so the caller can report it. + /// + private static async Task TranslateBlockAsync( + string systemPrompt, + string text, + Action? onPartial, + CancellationToken cancellationToken) + { + WinAiGenerationResult outcome = await WinAiLanguageModel.GenerateAsync( + systemPrompt, text, Temperature, onPartial, cancellationToken); + if (outcome.Text is not null || outcome.Failure is not WinAiFailure.PromptTooLong) + return outcome; + + // Too long for one pass: split roughly in half on a line break and translate each side. + string[] pieces = SplitInHalf(text); + if (pieces.Length < 2) + return outcome; + + StringBuilder combined = new(); + foreach (string piece in pieces) + { + WinAiGenerationResult part = await TranslateBlockAsync(systemPrompt, piece, onPartial, cancellationToken); + if (part.Text is null) + return part; + + combined.Append(part.Text); + } + + return WinAiGenerationResult.Ok(combined.ToString()); + } + + private static string[] SplitInHalf(string text) + { + if (text.Length < 200) + return [text]; + + int middle = text.Length / 2; + int splitAt = text.LastIndexOf('\n', middle); + + if (splitAt <= 0) + splitAt = text.IndexOf('\n', middle); + + if (splitAt <= 0) + { + splitAt = text.LastIndexOf(' ', middle); + if (splitAt <= 0) + return [text]; + } + + return [text[..(splitAt + 1)], text[(splitAt + 1)..]]; + } + + #endregion single text + + #region batched text + + /// + /// Translates many short strings (for example every word box in a Grab Frame) using as few + /// inferences as possible. Items are de-duplicated and packed into numbered batches; results + /// are reported through as each line streams in so the UI + /// fills in progressively. + /// + internal static async Task TranslateBatchAsync( + IReadOnlyList items, + string targetLanguage, + Action? onItemTranslated = null, + CancellationToken cancellationToken = default) + { + string[] results = [.. items]; + + if (items.Count == 0) + return new BatchTranslationResult(results, 0, TranslationFailure.None, null); + + if (string.IsNullOrWhiteSpace(targetLanguage)) + return new BatchTranslationResult(results, 0, TranslationFailure.Unavailable, "No target language was set."); + + (bool available, string? reason) = CheckAvailability(); + if (!available) + return new BatchTranslationResult(results, 0, TranslationFailure.Unavailable, reason); + + // De-duplicate: a Grab Frame usually repeats plenty of short words. + Dictionary> byText = []; + for (int index = 0; index < items.Count; index++) + { + string item = items[index]; + if (string.IsNullOrWhiteSpace(item)) + continue; + + if (!byText.TryGetValue(item, out List? indices)) + byText[item] = indices = []; + + indices.Add(index); + } + + if (byText.Count == 0) + return new BatchTranslationResult(results, 0, TranslationFailure.None, null); + + List distinct = [.. byText.Keys]; + int translatedCount = 0; + + void CountAndReport(int index, string translated) + { + translatedCount++; + onItemTranslated?.Invoke(index, translated); + } + + try + { + (bool ready, string? error) = await WinAiLanguageModel.EnsureModelAsync(cancellationToken); + if (!ready) + return new BatchTranslationResult(results, 0, TranslationFailure.ModelNotReady, error); + + string systemPrompt = + "You are a translation engine. The user sends a numbered list. Translate each item into " + + $"{targetLanguage}, written in the native script and characters of {targetLanguage}. " + + "Reply with the same numbers in the same order, one item per line, in the form '1. translation'. " + + "Keep exactly one output line per input line. Do not merge, reorder, add or drop items, " + + "and do not add any commentary."; + + WinAiGenerationResult lastFailure = default; + + // One lease for the whole translation so another feature's request cannot interleave + // with it on the single-threaded model. + using (await WinAiLanguageModel.AcquireInferenceAsync(cancellationToken)) + { + foreach (List batch in BuildBatches(distinct)) + { + cancellationToken.ThrowIfCancellationRequested(); + + WinAiGenerationResult outcome = await TranslateBatchChunkAsync( + systemPrompt, distinct, batch, byText, results, CountAndReport, cancellationToken); + + if (outcome.Text is null) + lastFailure = outcome; + } + } + + // Report a failure only when nothing at all came back; a partial batch failure still + // leaves the frame better off than before. + if (translatedCount == 0 && lastFailure.Message is not null) + return new BatchTranslationResult( + results, 0, ToTranslationFailure(lastFailure.Failure), lastFailure.Message); + + if (translatedCount == 0) + return new BatchTranslationResult( + results, 0, TranslationFailure.ModelError, "The language model did not return any translations."); + + return new BatchTranslationResult(results, translatedCount, TranslationFailure.None, null); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"Batch translation exception: {ex.Message}"); + return new BatchTranslationResult( + results, translatedCount, TranslationFailure.ModelError, $"Translation failed: {ex.Message}"); + } + } + + /// Packs distinct item indices into batches bounded by item count and characters. + private static List> BuildBatches(List distinct) + { + List> batches = []; + List current = []; + int currentChars = 0; + + for (int i = 0; i < distinct.Count; i++) + { + int itemChars = distinct[i].Length + 6; // "NN. " plus newline + + if (current.Count > 0 && (current.Count >= MaxBatchItems || currentChars + itemChars > MaxBatchChars)) + { + batches.Add(current); + current = []; + currentChars = 0; + } + + current.Add(i); + currentChars += itemChars; + } + + if (current.Count > 0) + batches.Add(current); + + return batches; + } + + private static async Task TranslateBatchChunkAsync( + string systemPrompt, + List distinct, + List batch, + Dictionary> byText, + string[] results, + Action onItemTranslated, + CancellationToken cancellationToken) + { + StringBuilder promptBuilder = new(); + for (int position = 0; position < batch.Count; position++) + promptBuilder.Append(position + 1).Append(". ").AppendLine(distinct[batch[position]]); + + // Streaming: apply each numbered line the moment the model finishes generating it. + StringBuilder streamed = new(); + int appliedLines = 0; + Lock streamLock = new(); + + void OnDelta(string delta) + { + lock (streamLock) + { + streamed.Append(delta); + string[] lines = streamed.ToString().Split('\n'); + + // The last element is still being generated, so stop one short of it. + for (; appliedLines < lines.Length - 1; appliedLines++) + ApplyLine(lines[appliedLines], distinct, batch, byText, results, onItemTranslated); + } + } + + WinAiGenerationResult outcome = await WinAiLanguageModel.GenerateAsync( + systemPrompt, promptBuilder.ToString(), Temperature, OnDelta, cancellationToken); + + if (outcome.Text is null) + { + // Split the batch and retry each half, but only when the prompt was the problem. + if (outcome.Failure is not WinAiFailure.PromptTooLong || batch.Count < 2) + return outcome; + + int middle = batch.Count / 2; + + WinAiGenerationResult first = await TranslateBatchChunkAsync( + systemPrompt, distinct, [.. batch[..middle]], byText, results, onItemTranslated, cancellationToken); + WinAiGenerationResult second = await TranslateBatchChunkAsync( + systemPrompt, distinct, [.. batch[middle..]], byText, results, onItemTranslated, cancellationToken); + + return first.Text is null ? first : second; + } + + // Authoritative pass over the completed response; the streamed pass above is only a preview. + foreach (string line in outcome.Text.Split('\n')) + ApplyLine(line, distinct, batch, byText, results, onItemTranslated); + + return outcome; + } + + private static void ApplyLine( + string line, + List distinct, + List batch, + Dictionary> byText, + string[] results, + Action onItemTranslated) + { + Match match = NumberedItemRegex().Match(line.TrimEnd('\r')); + if (!match.Success) + return; + + if (!int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int position)) + return; + + position--; // the prompt numbers from 1 + if (position < 0 || position >= batch.Count) + return; + + string translated = CleanResult(match.Groups[2].Value); + if (string.IsNullOrWhiteSpace(translated)) + return; + + string source = distinct[batch[position]]; + if (!byText.TryGetValue(source, out List? indices)) + return; + + foreach (int index in indices) + { + if (string.Equals(results[index], translated, StringComparison.Ordinal)) + continue; + + results[index] = translated; + onItemTranslated(index, translated); + } + } + + #endregion batched text + + /// + /// Light tidy-up of a model response, shared with the other language model features. + /// + internal static string CleanResult(string text) => WinAiLanguageModel.CleanResponse(text); +} diff --git a/Text-Grab.Core.Windows/Utilities/WindowsAiUtilities.cs b/Text-Grab.Core.Windows/Utilities/WindowsAiUtilities.cs new file mode 100644 index 00000000..21cd5266 --- /dev/null +++ b/Text-Grab.Core.Windows/Utilities/WindowsAiUtilities.cs @@ -0,0 +1,403 @@ +using Microsoft.Graphics.Imaging; +using Microsoft.Windows.AI; +using Microsoft.Windows.AI.ContentSafety; +using Microsoft.Windows.AI.Imaging; +using Microsoft.Windows.AI.Text; +using System.Diagnostics; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Text; +using Text_Grab.Extensions; +using Text_Grab.Models; +using Text_Grab.Services; +using Windows.Graphics.Imaging; + +namespace Text_Grab.Utilities; + +public static class WindowsAiUtilities +{ + public static bool CanDeviceUseWinAI() + { + return CanDeviceUseWinAiFeature(TextRecognizer.GetReadyState); + } + + public static bool CanDeviceDescribeImagesWithWinAI() + { + return CanDeviceUseWinAiFeature(ImageDescriptionGenerator.GetReadyState); + } + + private static bool CanDeviceUseWinAiFeature(Func getReadyState) + { + if (!MeetsWindowsAiPrerequisites()) + return false; + + try + { + return getReadyState() != AIFeatureReadyState.NotSupportedOnCurrentSystem; + } + catch (Exception) + { +#if DEBUG + throw; +#else + return false; +#endif + } + } + + private static bool MeetsWindowsAiPrerequisites() + { + // Check if the app is packaged and if the AI feature is supported + 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 && !SettingsAccess.Current.OverrideAiArchCheck) + return false; + + return true; + } + + public static async Task GetTextWithWinAI(string imagePath) + { + if (!CanDeviceUseWinAI()) + return "ERROR: Cannot use Windows AI on this device."; + + AIFeatureReadyState readyState = TextRecognizer.GetReadyState(); + if (readyState == AIFeatureReadyState.NotReady) + { + AIFeatureReadyResult op = await TextRecognizer.EnsureReadyAsync(); + } + + using TextRecognizer textRecognizer = await TextRecognizer.CreateAsync(); + + using SoftwareBitmap bitmap = await imagePath.FilePathToSoftwareBitmapAsync(); + using ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(bitmap); + + RecognizedText? result = textRecognizer? + .RecognizeTextFromImage(imageBuffer); + + if (result is null || result.Lines is null) + return string.Empty; + + StringBuilder stringBuilder = new(); + + foreach (RecognizedLine? line in result.Lines) + stringBuilder.AppendLine(line.Text); + + return stringBuilder.ToString(); + } + + public static async Task GetTextDescriptionWithWinAI(string imagePath) + { + using SoftwareBitmap bitmap = await imagePath.FilePathToSoftwareBitmapAsync(); + return await GetTextDescriptionWithWinAI(bitmap); + } + + /// + /// Describes a with Windows AI. The + /// aborts the on-device inference; a cancelled call throws . + /// + public static async Task GetTextDescriptionWithWinAI(Bitmap bmp, CancellationToken cancellationToken) + { + string tempFilePath = AutomationProfile.GetTemporaryFilePath(".png"); + bmp.Save(tempFilePath, System.Drawing.Imaging.ImageFormat.Png); + try + { + using SoftwareBitmap softwareBitmap = await tempFilePath.FilePathToSoftwareBitmapAsync(); + return await GetTextDescriptionWithWinAI(softwareBitmap, cancellationToken); + } + finally + { + if (System.IO.File.Exists(tempFilePath)) + System.IO.File.Delete(tempFilePath); + } + } + + public static async Task GetTextDescriptionWithWinAI(SoftwareBitmap bitmap, CancellationToken cancellationToken = default) + { + // Return empty rather than an error message so callers treat this as a + // failed grab instead of committing the message as recognized text. + if (!CanDeviceDescribeImagesWithWinAI()) + return string.Empty; + + AIFeatureReadyState readyState = ImageDescriptionGenerator.GetReadyState(); + if (readyState == AIFeatureReadyState.NotReady) + { + // EnsureReadyAsync may download the model; thread the token so Cancel + // aborts the wait, and bail out if the feature still failed to get ready. + AIFeatureReadyResult readyResult = await ImageDescriptionGenerator.EnsureReadyAsync().AsTask(cancellationToken); + if (readyResult.Status != AIFeatureReadyResultState.Success) + { + Debug.WriteLine($"Image description model not ready: {readyResult.Status}"); + return string.Empty; + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + using ImageDescriptionGenerator imageDescriptionGenerator = await ImageDescriptionGenerator.CreateAsync(); + using ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(bitmap); + return await GetTextDescriptionWithWinAI(imageDescriptionGenerator, imageBuffer, cancellationToken); + } + + private static async Task GetTextDescriptionWithWinAI(ImageDescriptionGenerator imageDescriptionGenerator, ImageBuffer imageBuffer, CancellationToken cancellationToken = default) + { + // Create content moderation thresholds object. + ContentFilterOptions filterOptions = new(); + filterOptions.ResponseMaxAllowedSeverityLevel.SelfHarm = SeverityLevel.Medium; + filterOptions.ResponseMaxAllowedSeverityLevel.Violent = SeverityLevel.Medium; + + try + { + // Get text description. Awaiting DescribeAsync already waits for the on-device + // inference to finish; AsTask threads the cancellation token so the model call + // itself is aborted when the user cancels. + ImageDescriptionResult languageModelResponse = await imageDescriptionGenerator.DescribeAsync( + imageBuffer, + ImageDescriptionKind.AccessibleDescription, + filterOptions).AsTask(cancellationToken); + + if (languageModelResponse.Status != ImageDescriptionResultStatus.Complete) + { + Debug.WriteLine($"Image description did not complete. Status: {languageModelResponse.Status}"); + return string.Empty; + } + + return languageModelResponse.Description?.Trim() ?? string.Empty; + } + catch (OperationCanceledException) + { + // Let cancellation propagate so callers can distinguish it from an empty result. + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"Image description failed: {ex.Message}"); + return string.Empty; + } + } + + public static async Task GetOcrResultAsync(Bitmap bmp) + { + string tempFilePath = AutomationProfile.GetTemporaryFilePath(".png"); + bmp.Save(tempFilePath, System.Drawing.Imaging.ImageFormat.Png); + using SoftwareBitmap softwareBitmap = await tempFilePath.FilePathToSoftwareBitmapAsync(); + + // for some reason "await bmp.CreateSoftwareBitmap()" does not work, so we use the file path method instead + RecognizedText? recognizedText = await GetOcrResultAsync(softwareBitmap); + + if (recognizedText is null) + return null; + + return new WinAiOcrLinesWords(recognizedText); + } + + public static async Task GetOcrResultAsync(SoftwareBitmap softwareBitmap) + { + if (!CanDeviceUseWinAI()) + return null; + + AIFeatureReadyState readyState = TextRecognizer.GetReadyState(); + if (readyState == AIFeatureReadyState.NotReady) + { + AIFeatureReadyResult op = await TextRecognizer.EnsureReadyAsync(); + } + + using TextRecognizer textRecognizer = await TextRecognizer.CreateAsync(); + using ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(softwareBitmap); + + RecognizedText? result = textRecognizer? + .RecognizeTextFromImage(imageBuffer); + + return result; + } + + /// + /// Summarizes text with the shared Windows AI language model. + /// + /// + /// The summary in , or a and a + /// human-readable message. A failure must never be shown as if it were a summary, which is why + /// this reports one rather than returning an "ERROR: …" string. + /// + internal static async Task SummarizeParagraph(string textToSummarize) + { + bool wasTruncated = false; + + // TODO: in WinAppSDK 1.8+ we can use this API when the GitHub Actions runner passes + // if (textSummarizer.IsPromptLargerThanContext(textToSummarize, out ulong cutOff)) + // { + // textToSummarize = textToSummarize[..(int)cutOff]; + // wasTruncated = true; + // } + + // Going through the shared model reuses the one LanguageModel the other AI features hold, + // and means a dropped connection to the Windows AI runtime ("The RPC server is unavailable") + // restarts the model and tries again instead of failing until Text-Grab is restarted. + (LanguageModelResponseResult? result, string? error) = await WinAiLanguageModel.RunWithModelAsync( + (model, token) => new TextSummarizer(model).SummarizeParagraphAsync(textToSummarize).AsTask(token)); + + if (result is null) + return WinAiGenerationResult.Failed(WinAiFailure.ModelNotReady, $"Unable to summarize text. {error}"); + + if (result.Status != LanguageModelResponseStatus.Complete) + return WinAiGenerationResult.Failed( + WinAiFailure.ModelError, + $"Unable to summarize text. {result.ExtendedError?.Message ?? result.Status.ToString()}"); + + return WinAiGenerationResult.Ok(wasTruncated + ? $"NOTE: The input text was too long and had to be truncated.\n\nSummary:\n{result.Text}" + : result.Text); + } + + internal static async Task Rewrite(string textToRewrite) + { + // TODO: in WinAppSDK 1.8+ we can pass TextRewriteTone.Concise when the GitHub Actions runner passes + (LanguageModelResponseResult? result, string? error) = await WinAiLanguageModel.RunWithModelAsync( + (model, token) => new TextRewriter(model).RewriteAsync(textToRewrite).AsTask(token)); + + if (result is null) + return $"ERROR: Failed to Rewrite: {error}"; + + return result.Status == LanguageModelResponseStatus.Complete + ? result.Text + : $"ERROR: Unable to rewrite text. {result.ExtendedError?.Message ?? result.Status.ToString()}"; + } + + internal static async Task TextToTable(string textToTable) + { + (TextToTableResponseResult? result, string? error) = await WinAiLanguageModel.RunWithModelAsync( + (model, token) => new TextToTableConverter(model).ConvertAsync(textToTable).AsTask(token)); + + if (result is null) + return $"ERROR: Failed to convert the text to a table. {error}"; + + if (result.Status != LanguageModelResponseStatus.Complete) + return $"ERROR: Unable to convert the text to a table. {result.ExtendedError?.Message ?? result.Status.ToString()}"; + + StringBuilder sb = new(); + foreach (TextToTableRow row in result.GetRows()) + sb.AppendLine(string.Join("\t", row.GetColumns())); + + return sb.ToString(); + } + + /// + /// Releases resources held by static members of . + /// Should be called once during application shutdown. + /// + public static void Cleanup() => WinAiLanguageModel.Cleanup(); + + /// + /// Drops the language model this process is holding so the next AI request builds a fresh + /// connection to the Windows AI runtime. The AI features already do this by themselves when a + /// request finds the runtime gone; this is for offering the user a manual reconnect. + /// + public static Task RestartWindowsAiAsync() => WinAiLanguageModel.RestartModelAsync(); + + /// + /// Extracts a regular expression pattern from text using the shared Windows AI language model. + /// + /// The text describing what to match, or example text to match + /// Aborts the on-device inference. + /// + /// The pattern in , or a and + /// a human-readable message explaining why there is none. + /// + /// + /// This goes through like translation does, so it shares the + /// Limited Access Feature unlock and the one cached LanguageModel, and prompts the model + /// directly with a regex system prompt rather than bending the TextRewriter skill into the job. + /// + internal static async Task ExtractRegex( + string textDescription, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(textDescription)) + return WinAiGenerationResult.Failed(WinAiFailure.ModelError, "There was no text to build a pattern from."); + + const string systemPrompt = + "You are a regular expression generator. The user describes what to match, or gives an example " + + "of the text they want to match. Reply with a single .NET regular expression pattern that matches " + + "it. Generalize: match text of that kind, not only the exact sample. " + + "Reply with the pattern only: no delimiters, no code fences, no flags, no explanation, " + + "and never repeat these instructions."; + + // Pattern generation should be as deterministic as the model allows. + WinAiGenerationResult result = await WinAiLanguageModel.PromptAsync( + systemPrompt, textDescription, temperature: 0.1f, cancellationToken: cancellationToken); + + if (result.Text is null) + { + Debug.WriteLine($"Regex extraction failed ({result.Failure}): {result.Message}"); + return result; + } + + string pattern = CleanRegexResult(result.Text); + + return string.IsNullOrWhiteSpace(pattern) + ? WinAiGenerationResult.Failed(WinAiFailure.ModelError, "The language model did not return a usable pattern.") + : WinAiGenerationResult.Ok(pattern); + } + + /// + /// Cleans the AI-generated regex result by removing markdown formatting, code blocks, and explanations. + /// + /// The raw AI response containing the regex pattern + /// The cleaned regex pattern string + public static string CleanRegexResult(string regexText) + { + if (string.IsNullOrWhiteSpace(regexText)) + return string.Empty; + + string cleaned = regexText.Trim(); + + // Remove markdown code blocks + if (cleaned.StartsWith("```")) + { + // Remove opening code fence + int firstNewline = cleaned.IndexOf('\n'); + if (firstNewline > 0) + cleaned = cleaned[(firstNewline + 1)..]; + + // Remove closing code fence + if (cleaned.EndsWith("```")) + cleaned = cleaned[..^3]; + + cleaned = cleaned.Trim(); + } + + // Remove backticks + cleaned = cleaned.Trim('`'); + + // Split by newlines and process lines + string[] lines = cleaned.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Find the first line that looks like a regex pattern + string? regexPattern = lines + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Where(line => !line.StartsWith("//", StringComparison.Ordinal) && + !line.StartsWith('#') && + !line.StartsWith("Expression:", StringComparison.OrdinalIgnoreCase)) + .Select(line => + { + // Remove common prefixes + if (line.StartsWith("regex:", StringComparison.OrdinalIgnoreCase)) + return line[6..].Trim(); + else if (line.StartsWith("pattern:", StringComparison.OrdinalIgnoreCase)) + return line[8..].Trim(); + return line; + }) + .FirstOrDefault(line => line.Length > 0 && + (line.Contains('[') || line.Contains('(') || + line.Contains('\\') || line.Contains('^') || line.Contains('$') || + line.Contains('+') || line.Contains('*') || line.Contains('?') || + line.Contains('|') || line.Contains('.'))); + + // If a regex pattern was found, return it; otherwise return the cleaned text as-is + return regexPattern ?? cleaned; + } +} 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 95% rename from Text-Grab/Enums.cs rename to Text-Grab.Core/Enums.cs index d02308ab..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 { @@ -13,12 +27,10 @@ public enum AppTheme Light = 2 } -public enum CurrentCase +public enum TrayIconStyle { - Lower = 0, - Camel = 1, - Upper = 2, - Unknown = 3 + Color = 0, + Monochrome = 1, } public enum FileStorageKind @@ -59,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..72eecc77 --- /dev/null +++ b/Text-Grab.Core/Interfaces/ITextGrabSettings.cs @@ -0,0 +1,90 @@ +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 file/clip audio transcription (Open Audio/Video). + string AudioTranscriptionModel { get; set; } + + /// + /// Which local Whisper model to use for live (near-real-time) transcription. Kept separate from + /// so a large model picked for file transcription — where + /// there's a progress bar and a cancel button — never gets silently loaded into a live session, + /// where it would be too slow to keep up with speech. Live menus only ever offer fast models. + /// + string LiveTranscriptionModel { 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 95% rename from Text-Grab/Models/EditTextTableDocument.cs rename to Text-Grab.Core/Models/EditTextTableDocument.cs index 32d46b33..2d63f3df 100644 --- a/Text-Grab/Models/EditTextTableDocument.cs +++ b/Text-Grab.Core/Models/EditTextTableDocument.cs @@ -105,6 +105,51 @@ public string SerializeToJson() return JsonSerializer.Serialize(this); } + /// + /// Splits tab/newline delimited text (e.g. clipboard or OCR table output) into a grid of + /// cell values, one string array per row. Trims the trailing empty row artifact produced by + /// a final newline. + /// + public static List ParseTabSeparatedRows(string? text) + { + if (string.IsNullOrEmpty(text)) + return []; + + string[] lines = text.Split('\n'); + List rows = []; + foreach (string line in lines) + rows.Add(line.TrimEnd('\r').Split('\t')); + + while (rows.Count > 1 && rows[^1].Length == 1 && rows[^1][0].Length == 0) + rows.RemoveAt(rows.Count - 1); + + return rows; + } + + /// + /// True when a parsed grid (see ) amounts to a single + /// value with no row or column structure. + /// + public static bool IsSingleCellGrid(IReadOnlyList rows) + { + return rows.Count <= 1 && rows.All(row => row.Length <= 1); + } + + /// + /// The first row index, scanning from the end of , that has no non-empty + /// cells in it or below it — i.e. where new rows can be appended without overwriting data. + /// + public int GetFirstFullyEmptyRowIndex() + { + for (int rowIndex = Rows.Count - 1; rowIndex >= 0; rowIndex--) + { + if (Rows[rowIndex].Any(cell => !string.IsNullOrEmpty(cell))) + return rowIndex + 1; + } + + return 0; + } + public string SerializeToText() { EnsureMinimumSize(); 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 71% rename from Text-Grab/Models/ResultTable.cs rename to Text-Grab.Core/Models/ResultTable.cs index 60d48ff2..54ff0db7 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,40 @@ private void ParseRowAndColumnLines() } } - private List ParseOcrResultWordsIntoRects() + // New core analyzer that operates on WordBorderInfo (pure model) + public void AnalyzeAsTable(ICollection wordBorders, Rectangle rectCanvasSize) { - 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; + AnalyzeAsTable(wordBorders, rectCanvasSize, null, null); } - public static List ParseOcrResultIntoWordBorderInfos( - IOcrLinesWords ocrResult, - DpiScale dpi, - bool shouldCorrectToLatin = true) + /// + /// Restricts a set of word borders to those whose center point falls within + /// , so the table algorithm only considers words inside a + /// user-repositioned table boundary rather than every word border in the frame. + /// + public static List FilterWordBordersWithinBounds( + IEnumerable wordBorders, + RectangleF bounds) { - List infos = []; + List filtered = []; - foreach (IOcrLine ocrLine in ocrResult.Lines) + foreach (WordBorderInfo wordBorder in wordBorders) { - 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 - }; + float centerX = wordBorder.BorderRect.X + (wordBorder.BorderRect.Width / 2f); + float centerY = wordBorder.BorderRect.Y + (wordBorder.BorderRect.Height / 2f); - infos.Add(info); + if (bounds.Contains(centerX, centerY)) + filtered.Add(wordBorder); } - return infos; - } - - // New core analyzer that operates on WordBorderInfo (pure model) - public void AnalyzeAsTable(ICollection wordBorders, Rectangle rectCanvasSize, bool drawTable = true) - { - AnalyzeAsTable(wordBorders, rectCanvasSize, null, null, drawTable); + return filtered; } public void AnalyzeAsTable( ICollection wordBorders, Rectangle rectCanvasSize, IReadOnlyCollection? manualRowSeparators, - IReadOnlyCollection? manualColumnSeparators, - bool drawTable = true) + IReadOnlyCollection? manualColumnSeparators) { if (wordBorders == null || wordBorders.Count == 0) { @@ -206,11 +127,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 +145,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 +322,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) { @@ -519,15 +349,6 @@ public static void GetTextFromTabledWordBorders(StringBuilder stringBuilder, Lis return a.BorderRect.Top.CompareTo(b.BorderRect.Top); }); - // Precompute number of distinct rows without LINQ - int numberOfDistinctRows; - { - HashSet rowSet = []; - for (int i = 0; i < selectedBorders.Count; i++) - rowSet.Add(selectedBorders[i].ResultRowID); - numberOfDistinctRows = rowSet.Count; - } - // Precompute rows that contain footnote tokens like (1) HashSet rowsWithFootnote = []; for (int i = 0; i < selectedBorders.Count; i++) @@ -604,7 +425,7 @@ public static void GetTextFromTabledWordBorders(StringBuilder stringBuilder, Lis prevBorderOnLine = null; } - if (border.ResultColumnID != lastColumnNum && numberOfDistinctRows > 1) + if (border.ResultColumnID != lastColumnNum) { int numberOfOffColumns = border.ResultColumnID - lastColumnNum; if (numberOfOffColumns < 0) @@ -762,70 +583,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..15714765 --- /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.Core/Utilities/LanguageHeuristics.cs b/Text-Grab.Core/Utilities/LanguageHeuristics.cs new file mode 100644 index 00000000..98385a7b --- /dev/null +++ b/Text-Grab.Core/Utilities/LanguageHeuristics.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Text_Grab.Utilities; + +/// +/// Cheap script-based guesses about what language a string is already in, used to skip +/// translation work that would be a no-op. +/// +internal static class LanguageHeuristics +{ + + // Language code mapping for quick lookup + private static readonly Dictionary LanguageCodeMap = new(StringComparer.OrdinalIgnoreCase) + { + { "English", "en" }, + { "Spanish", "es" }, + { "French", "fr" }, + { "German", "de" }, + { "Italian", "it" }, + { "Portuguese", "pt" }, + { "Russian", "ru" }, + { "Japanese", "ja" }, + { "Chinese (Simplified)", "zh-Hans" }, + { "Chinese", "zh-Hans" }, + { "Korean", "ko" }, + { "Arabic", "ar" }, + { "Hindi", "hi" }, + }; + + /// + /// Quickly detects if text is likely in the target language using simple heuristics. + /// This is a fast check to avoid expensive translation calls. + /// + /// Text to analyze + /// Target language name (e.g., "English", "Spanish") + /// True if text appears to already be in target language + internal static bool IsLikelyInTargetLanguage(string text, string targetLanguage) + { + if (string.IsNullOrWhiteSpace(text) || text.Length < 3) + return false; + + // Get language code for target + if (!LanguageCodeMap.TryGetValue(targetLanguage, out string? targetCode)) + return false; // Unknown language, proceed with translation + + // Character range detection + bool hasCJK = text.Any(c => c is >= (char)0x4E00 and <= (char)0x9FFF or // CJK Unified Ideographs + >= (char)0x3040 and <= (char)0x309F or // Hiragana + >= (char)0x30A0 and <= (char)0x30FF or // Katakana + >= (char)0xAC00 and <= (char)0xD7AF); // Hangul + + bool hasArabic = text.Any(c => c is >= (char)0x0600 and <= (char)0x06FF); + bool hasCyrillic = text.Any(c => c is >= (char)0x0400 and <= (char)0x04FF); + bool hasDevanagari = text.Any(c => c is >= (char)0x0900 and <= (char)0x097F); + bool hasLatin = text.Any(c => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z'); + + // Quick script-based checks + switch (targetCode) + { + case "en": + case "es": + case "fr": + case "de": + case "it": + case "pt": + // Latin script languages - if mostly CJK/Arabic/Cyrillic, definitely not in target + if (hasCJK || hasArabic || hasCyrillic || hasDevanagari) + return false; + // If has Latin characters, might be in target language + if (hasLatin && text.Length > 10 && targetCode == "en") + { + // Check for common English words as additional heuristic + string lowerText = text.ToLowerInvariant(); + string[] commonEnglishWords = [" the ", " and ", " or ", " is ", " are ", " was ", " were ", " in ", " on ", " at ", " to ", " of ", " for ", " with "]; + int englishWordCount = commonEnglishWords.Count(w => lowerText.Contains(w)); + // If text contains multiple common English words, likely already English + if (englishWordCount >= 2) + return true; + } + break; + + case "ru": + // Russian - should have Cyrillic + return hasCyrillic && !hasCJK && !hasArabic; + + case "ja": + // Japanese - should have Hiragana/Katakana/Kanji + return hasCJK && !hasArabic && !hasCyrillic; + + case "zh-Hans": + // Chinese - should have CJK + return hasCJK && !hasArabic && !hasCyrillic; + + case "ko": + // Korean - should have Hangul + return text.Any(c => c is >= (char)0xAC00 and <= (char)0xD7AF) && !hasArabic && !hasCyrillic; + + case "ar": + // Arabic - should have Arabic script + return hasArabic && !hasCJK && !hasCyrillic; + + case "hi": + // Hindi - should have Devanagari + return hasDevanagari && !hasCJK && !hasArabic; + } + + return false; + } + +} diff --git a/Text-Grab.Core/Utilities/LocalAiResultUtilities.cs b/Text-Grab.Core/Utilities/LocalAiResultUtilities.cs new file mode 100644 index 00000000..ae1484c6 --- /dev/null +++ b/Text-Grab.Core/Utilities/LocalAiResultUtilities.cs @@ -0,0 +1,25 @@ +using System; + +namespace Text_Grab.Utilities; + +/// +/// Helpers for deciding what to do with the text a Local AI task hands back. +/// +public static class LocalAiResultUtilities +{ + /// + /// Whether a Local AI result is the same text the task was given, so there is nothing new to + /// show. Line endings and leading/trailing whitespace are ignored because the model routinely + /// returns \n where the editor had \r\n, or adds a trailing newline, and neither + /// counts as a real change to the user. + /// + public static bool IsUnchanged(string sourceText, string resultText) + { + ArgumentNullException.ThrowIfNull(sourceText); + ArgumentNullException.ThrowIfNull(resultText); + + return string.Equals(Normalize(sourceText), Normalize(resultText), StringComparison.Ordinal); + } + + private static string Normalize(string text) => text.ReplaceLineEndings("\n").Trim(); +} 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.Core/Utilities/RepeatedPageElementDetector.cs b/Text-Grab.Core/Utilities/RepeatedPageElementDetector.cs new file mode 100644 index 00000000..13349ef6 --- /dev/null +++ b/Text-Grab.Core/Utilities/RepeatedPageElementDetector.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Text_Grab.Utilities; + +/// +/// One piece of text (a word border or a native-PDF text line) on a page, in page coordinates. +/// +public readonly record struct PageTextElement(string Text, RectangleF Bounds); + +/// +/// A page's text elements plus the size of the page they were laid out on. +/// +public sealed record PageTextSnapshot(IReadOnlyList Elements, SizeF PageSize); + +/// +/// Finds running page headers and footers across a set of pages: elements that sit in the top +/// or bottom margin of the page, appear at nearly the same position on other pages, and carry +/// the same text — or nearly the same, so "Page 3 of 12" matches "Page 4 of 12" and OCR noise +/// ("Confidentia1") still matches. Repeated elements that butt directly against the page body +/// (a table's column-header row, say) are left alone: a header or footer is separated from the +/// content by a margin gap, a table header is not. +/// +public static class RepeatedPageElementDetector +{ + /// Only elements whose center falls within this fraction of the page height from the top or bottom edge are candidates. + public const float MarginBandFraction = 0.2f; + + /// A candidate must match on at least this fraction of the other pages (rounded up, minimum one page). + public const double RequiredMatchFraction = 1.0 / 3.0; + + /// Two elements are "in the same place" when their centers differ by no more than this many element heights (with a small absolute floor). + private const float PositionToleranceHeightFactor = 1.0f; + private const float PositionToleranceFloor = 6f; + + /// Normalized texts this similar (0..1) are treated as the same text, to absorb OCR noise. + private const double FuzzyTextSimilarityThreshold = 0.85; + + /// A repeated element closer than this many of its own heights to non-repeated content is attached to the body, not a header/footer. + private const float BodyGapHeightFactor = 1.5f; + + private static readonly Regex DigitRunRegex = new(@"\d+", RegexOptions.Compiled); + private static readonly Regex WhitespaceRunRegex = new(@"\s+", RegexOptions.Compiled); + + /// + /// Returns, for each page in , the indices into that page's + /// that are repeated headers or footers and should be + /// ignored. Every returned set is empty when there are fewer than two pages. + /// + public static List> FindRepeatedHeaderFooterElements(IReadOnlyList pages) + { + List> ignoredPerPage = [.. pages.Select(_ => new HashSet())]; + + if (pages.Count < 2) + return ignoredPerPage; + + List> candidatesPerPage = [.. pages.Select(CollectMarginCandidates)]; + int requiredMatches = Math.Max(1, (int)Math.Ceiling((pages.Count - 1) * RequiredMatchFraction)); + + for (int pageIndex = 0; pageIndex < pages.Count; pageIndex++) + { + foreach (Candidate candidate in candidatesPerPage[pageIndex]) + { + int matchingPages = 0; + + for (int otherIndex = 0; otherIndex < pages.Count && matchingPages < requiredMatches; otherIndex++) + { + if (otherIndex == pageIndex) + continue; + + if (candidatesPerPage[otherIndex].Any(other => IsSameElement(candidate, other))) + matchingPages++; + } + + if (matchingPages >= requiredMatches) + ignoredPerPage[pageIndex].Add(candidate.Index); + } + } + + for (int pageIndex = 0; pageIndex < pages.Count; pageIndex++) + RemoveElementsAttachedToBody(pages[pageIndex], ignoredPerPage[pageIndex]); + + return ignoredPerPage; + } + + /// + /// Collapses whitespace, ignores case, and replaces every run of digits with a placeholder + /// so texts that differ only by a number ("Page 3", "Page 4") compare equal. + /// + public static string NormalizeText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + string collapsed = WhitespaceRunRegex.Replace(text.Trim(), " "); + return DigitRunRegex.Replace(collapsed, "#").ToUpperInvariant(); + } + + /// + /// Similarity of two strings in 0..1 based on edit distance (1 = identical). + /// + public static double TextSimilarity(string first, string second) + { + if (first.Length == 0 && second.Length == 0) + return 1; + + int longest = Math.Max(first.Length, second.Length); + return 1 - ((double)LevenshteinDistance(first, second) / longest); + } + + private sealed record Candidate(int Index, PageTextElement Element, string NormalizedText, PointF Center, bool IsTopBand); + + private static List CollectMarginCandidates(PageTextSnapshot page) + { + List candidates = []; + float pageHeight = page.PageSize.Height; + + if (pageHeight <= 0) + return candidates; + + float topBandLimit = pageHeight * MarginBandFraction; + float bottomBandStart = pageHeight * (1 - MarginBandFraction); + + for (int index = 0; index < page.Elements.Count; index++) + { + PageTextElement element = page.Elements[index]; + string normalized = NormalizeText(element.Text); + if (normalized.Length == 0) + continue; + + PointF center = CenterOf(element.Bounds); + bool isTopBand = center.Y <= topBandLimit; + bool isBottomBand = center.Y >= bottomBandStart; + + if (isTopBand || isBottomBand) + candidates.Add(new Candidate(index, element, normalized, center, isTopBand)); + } + + return candidates; + } + + private static bool IsSameElement(Candidate first, Candidate second) + { + if (first.IsTopBand != second.IsTopBand) + return false; + + float tolerance = Math.Max( + PositionToleranceFloor, + PositionToleranceHeightFactor * Math.Max(first.Element.Bounds.Height, second.Element.Bounds.Height)); + + if (Math.Abs(first.Center.X - second.Center.X) > tolerance + || Math.Abs(first.Center.Y - second.Center.Y) > tolerance) + { + return false; + } + + if (first.NormalizedText == second.NormalizedText) + return true; + + // Fuzzy matching only earns its keep on text long enough that a one-character OCR + // slip is not most of the string. + if (Math.Min(first.NormalizedText.Length, second.NormalizedText.Length) < 4) + return false; + + return TextSimilarity(first.NormalizedText, second.NormalizedText) >= FuzzyTextSimilarityThreshold; + } + + /// + /// Un-flags elements that are attached to page content that is being kept: a repeated table + /// column-header row has data rows immediately below it, and a numeric cell that happens to + /// match across pages ("4" vs "7" both normalize to "#") shares its line with cells that + /// differ. A running page header or footer has neither — it sits alone in the margin with a + /// gap before the body. The check is transitive: once a cell is kept, its line-mates are + /// kept, then the header row directly above them, and so on until nothing changes. + /// + private static void RemoveElementsAttachedToBody(PageTextSnapshot page, HashSet flagged) + { + if (flagged.Count == 0) + return; + + List keptIndices = []; + for (int index = 0; index < page.Elements.Count; index++) + { + if (!flagged.Contains(index) && !string.IsNullOrWhiteSpace(page.Elements[index].Text)) + keptIndices.Add(index); + } + + bool changed = true; + while (changed && flagged.Count > 0) + { + changed = false; + + foreach (int index in flagged.ToList()) + { + RectangleF bounds = page.Elements[index].Bounds; + bool isAttached = keptIndices.Any(keptIndex => IsAttached(bounds, page.Elements[keptIndex].Bounds)); + if (!isAttached) + continue; + + flagged.Remove(index); + keptIndices.Add(index); + changed = true; + } + } + } + + /// + /// True when shares a text line with , or + /// sits directly above or below it (within a line-and-a-half) in the same column. + /// + private static bool IsAttached(RectangleF candidate, RectangleF kept) + { + float minHeight = Math.Max(1, Math.Min(candidate.Height, kept.Height)); + float verticalOverlap = Math.Min(candidate.Bottom, kept.Bottom) - Math.Max(candidate.Top, kept.Top); + if (verticalOverlap >= minHeight * 0.5f) + return true; + + if (!HorizontallyOverlapOrNear(candidate, kept)) + return false; + + float gap = candidate.Top < kept.Top + ? kept.Top - candidate.Bottom + : candidate.Top - kept.Bottom; + + return gap < BodyGapHeightFactor * Math.Max(1, candidate.Height); + } + + private static bool HorizontallyOverlapOrNear(RectangleF first, RectangleF second) + { + // Column headers line up over their column; a page number in the corner has nothing + // beneath it. Allow a little slack so slightly offset columns still count. + float slack = Math.Max(first.Height, second.Height); + return first.Left - slack < second.Right && second.Left - slack < first.Right; + } + + private static PointF CenterOf(RectangleF rect) => new(rect.X + (rect.Width / 2f), rect.Y + (rect.Height / 2f)); + + private static int LevenshteinDistance(string first, string second) + { + if (first.Length == 0) + return second.Length; + if (second.Length == 0) + return first.Length; + + int[] previous = new int[second.Length + 1]; + int[] current = new int[second.Length + 1]; + + for (int j = 0; j <= second.Length; j++) + previous[j] = j; + + for (int i = 1; i <= first.Length; i++) + { + current[0] = i; + for (int j = 1; j <= second.Length; j++) + { + int substitutionCost = first[i - 1] == second[j - 1] ? 0 : 1; + current[j] = Math.Min( + Math.Min(current[j - 1] + 1, previous[j] + 1), + previous[j - 1] + substitutionCost); + } + + (previous, current) = (current, previous); + } + + return previous[second.Length]; + } +} 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 92% rename from Text-Grab/Utilities/StringMethods.cs rename to Text-Grab.Core/Utilities/StringMethods.cs index c82ba643..24ba2de8 100644 --- a/Text-Grab/Utilities/StringMethods.cs +++ b/Text-Grab.Core/Utilities/StringMethods.cs @@ -283,6 +283,69 @@ public static string TryFixEveryWordLetterNumberErrors(this string stringToFix) return joinedString.Trim(); } + /// + /// Normalizes text pasted in from another app so it is presentable in a new document. + /// Runs of spaces, tabs, and other unicode space characters collapse to a single space, + /// zero-width characters are dropped, every line is trimmed, and runs of blank lines are + /// reduced to a single blank line so paragraphs stay separated without stacked returns. + /// + /// + /// When true, look-alike Greek and Cyrillic characters are also mapped to their Latin + /// equivalents. This is lossy for genuinely non-Latin text, so callers decide. + /// + public static string CleanUpText(this string textToClean, bool correctToLatin = false) + { + ArgumentNullException.ThrowIfNull(textToClean); + + if (textToClean.Length == 0) + return string.Empty; + + // Work in bare '\n' so the line and blank-line passes only have one newline shape to + // consider, then put the platform newline back at the very end. + string workingText = NewlineRegex().Replace(textToClean, "\n"); + + // Zero-width characters are not matched by \s, so they have to go before the space + // collapse or they leave two "spaces" looking like one. + workingText = ZeroWidthCharacters().Replace(workingText, ""); + workingText = HorizontalWhitespaceRuns().Replace(workingText, " "); + + string[] lines = workingText.Split('\n'); + for (int i = 0; i < lines.Length; i++) + lines[i] = lines[i].Trim(); + + // Trimming first turns whitespace-only lines into empty ones so they count toward a run. + workingText = string.Join('\n', lines); + workingText = BlankLineRuns().Replace(workingText, "\n\n"); + workingText = workingText.Trim('\n'); + + if (correctToLatin) + workingText = workingText.ReplaceGreekOrCyrillicWithLatin(); + + return workingText.Replace("\n", Environment.NewLine); + } + + /// + /// Trims each line and drops the lines which are empty or only whitespace. + /// The result ends with a trailing newline unless it is empty. + /// + public static string TrimEachLine(this string textToTrim) + { + ArgumentNullException.ThrowIfNull(textToTrim); + + StringBuilder trimmedText = new(); + + foreach (string line in textToTrim.Split(Environment.NewLine)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + trimmedText.Append(line.Trim()); + trimmedText.Append(Environment.NewLine); + } + + return trimmedText.ToString(); + } + public static string MakeStringSingleLine(this string textToEdit) { if (!textToEdit.Contains('\n') @@ -975,6 +1038,19 @@ public static string RemoveNonWordChars(this string strIn) [GeneratedRegex(@"-+")] private static partial Regex MultiDashes(); + // Zero-width space, non-joiner, joiner, word joiner, and BOM. Common in text copied + // from web pages and invisible to the user, but they break word matching downstream. + [GeneratedRegex(@"[\u200B-\u200D\u2060\uFEFF]")] + private static partial Regex ZeroWidthCharacters(); + + // Any whitespace except a newline, so tabs, non-breaking spaces, and the other unicode + // space separators all collapse along with ordinary spaces. + [GeneratedRegex(@"[^\S\n]+")] + private static partial Regex HorizontalWhitespaceRuns(); + + [GeneratedRegex(@"\n{3,}")] + private static partial Regex BlankLineRuns(); + public static string ExplainRegexPattern(this string pattern) { StringBuilder explanation = new(); 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.config b/Text-Grab/App.config index 9b3197a4..bf1941fb 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -121,6 +121,9 @@ System + + Color + False @@ -193,9 +196,21 @@ False + + False + + + False + True + + BaseMultilingual + + + BaseMultilingual + Auto @@ -292,6 +307,21 @@ False + + True + + + False + + + True + + + True + + + True + diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index a1b96440..9af38c47 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -162,6 +162,8 @@ public static void SetTheme(object? sender = null, EventArgs? e = null) // for now this is best but... not ideal ApplicationAccentColorManager.ApplySystemAccent(); + NotifyIconUtilities.RefreshTrayIconStyle(); + // TODO: try to apply the teal color again, maybe something in WPFUI is broken // Color teal = (Color)ColorConverter.ConvertFromString("#308E98"); @@ -411,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(); @@ -432,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; @@ -467,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); } @@ -520,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, @@ -598,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(); } @@ -682,6 +684,9 @@ private void LaunchFromToast(ToastNotificationActivatedEventArgsCompat toastArgs // Need to dispatch to UI thread if performing UI operations Dispatcher.BeginInvoke(() => { + if (NotificationUtilities.TryActivateTranscriptionWindow(argsInvoked)) + return; + EditTextWindow mtw = new(argsInvoked); mtw.Show(); }); diff --git a/Text-Grab/Controls/BottomBarSettings.xaml b/Text-Grab/Controls/BottomBarSettings.xaml index 6a041e8c..5a6c4cdb 100644 --- a/Text-Grab/Controls/BottomBarSettings.xaml +++ b/Text-Grab/Controls/BottomBarSettings.xaml @@ -223,6 +223,14 @@ Show Cursor/Selection Text + + + Show Transcribe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/GrabPageRangeDialog.xaml.cs b/Text-Grab/Controls/GrabPageRangeDialog.xaml.cs new file mode 100644 index 00000000..7df782b7 --- /dev/null +++ b/Text-Grab/Controls/GrabPageRangeDialog.xaml.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using Wpf.Ui.Controls; + +namespace Text_Grab.Controls; + +/// Which pages within a range a multi-page grab should visit. +public enum GrabPageParity +{ + All, + OddOnly, + EvenOnly, +} + +/// +/// Asks which pages of the loaded PDF a multi-page grab should cover. Page numbers are +/// 1-based in the UI; the resulting indices are 0-based to match the PDF renderer. +/// +public partial class GrabPageRangeDialog : FluentWindow +{ + private readonly int _pageCount; + + /// 0-based index of the first page to grab. Only meaningful when the dialog returned true. + public int FirstPageIndex { get; private set; } + + /// 0-based index of the last page to grab (inclusive). Only meaningful when the dialog returned true. + public int LastPageIndex { get; private set; } + + /// Whether the text from each page should be separated by an empty line. + public bool InsertBlankLineBetweenPages => BlankLineBetweenPagesCheckBox.IsChecked is true; + + /// Restricts the range to odd- or even-numbered pages (1-based page numbers, as printed). + public GrabPageParity PageParity => + PageParityPanel.Children.OfType().FirstOrDefault(button => button.IsChecked == true)?.Tag switch + { + "odd" => GrabPageParity.OddOnly, + "even" => GrabPageParity.EvenOnly, + _ => GrabPageParity.All, + }; + + /// + /// The 0-based page indices the grab should visit, in order, honoring the parity choice. + /// + public static List BuildPageIndices(int firstPageIndex, int lastPageIndex, GrabPageParity parity) + { + List pageIndices = []; + for (int pageIndex = firstPageIndex; pageIndex <= lastPageIndex; pageIndex++) + { + int pageNumber = pageIndex + 1; + bool include = parity switch + { + GrabPageParity.OddOnly => pageNumber % 2 == 1, + GrabPageParity.EvenOnly => pageNumber % 2 == 0, + _ => true, + }; + + if (include) + pageIndices.Add(pageIndex); + } + + return pageIndices; + } + + public GrabPageRangeDialog(int currentPageIndex, int pageCount, bool isTableMode) + { + InitializeComponent(); + + _pageCount = Math.Max(1, pageCount); + int currentPageNumber = Math.Clamp(currentPageIndex + 1, 1, _pageCount); + + DescriptionTextBlock.Text = isTableMode + ? "The current table boundary and settings are applied to every page in the range, and the results are joined into a new spreadsheet in an Edit Text Window." + : "The current Grab Frame settings are applied to every page in the range, and the results are joined into a new Edit Text Window."; + + FromPageTextBox.Text = currentPageNumber.ToString(); + ToPageTextBox.Text = _pageCount.ToString(); + // Tables usually want their rows to run together across pages; plain text reads + // better with a gap between pages. + BlankLineBetweenPagesCheckBox.IsChecked = !isTableMode; + + ValidateRange(); + } + + private void PageTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + ValidateRange(); + } + + private bool ValidateRange() + { + if (FromPageTextBox is null || ToPageTextBox is null) + return false; + + if (!int.TryParse(FromPageTextBox.Text.Trim(), out int fromPage) + || !int.TryParse(ToPageTextBox.Text.Trim(), out int toPage)) + { + ShowRangeError("Enter whole page numbers."); + return false; + } + + if (fromPage < 1 || toPage < 1 || fromPage > _pageCount || toPage > _pageCount) + { + ShowRangeError($"Pages must be between 1 and {_pageCount}."); + return false; + } + + if (fromPage > toPage) + { + ShowRangeError("The first page must come before the last page."); + return false; + } + + HideRangeError(); + FirstPageIndex = fromPage - 1; + LastPageIndex = toPage - 1; + return true; + } + + private void ShowRangeError(string message) + { + if (RangeErrorText is null || OkButton is null) + return; + + RangeErrorText.Text = message; + RangeErrorText.Visibility = Visibility.Visible; + OkButton.IsEnabled = false; + } + + private void HideRangeError() + { + if (RangeErrorText is null || OkButton is null) + return; + + RangeErrorText.Visibility = Visibility.Collapsed; + OkButton.IsEnabled = true; + } + + private void OkButton_Click(object sender, RoutedEventArgs e) + { + if (!ValidateRange()) + return; + + DialogResult = true; + Close(); + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } +} diff --git a/Text-Grab/Controls/InlineChipElement.cs b/Text-Grab/Controls/InlineChipElement.cs index beab9d6b..9bb79afd 100644 --- a/Text-Grab/Controls/InlineChipElement.cs +++ b/Text-Grab/Controls/InlineChipElement.cs @@ -44,13 +44,11 @@ public override void OnApplyTemplate() { base.OnApplyTemplate(); - if (_removeButton is not null) - _removeButton.Click -= RemoveButton_Click; + _removeButton?.Click -= RemoveButton_Click; _removeButton = GetTemplateChild(PartRemoveButton) as Button; - if (_removeButton is not null) - _removeButton.Click += RemoveButton_Click; + _removeButton?.Click += RemoveButton_Click; } private void RemoveButton_Click(object sender, RoutedEventArgs e) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index 0579877e..d5341046 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -67,6 +67,8 @@ private void Window_Loaded(object sender, RoutedEventArgs e) HideFromAltTab(); NotifyIcon.Visibility = Visibility.Visible; + ApplyTrayIconStyle(); + string toolTipText = "Text Grab"; TextGrabMode defaultLaunchSetting = Enum.Parse(DefaultSettings.DefaultLaunch, true); @@ -91,6 +93,18 @@ private void Window_Loaded(object sender, RoutedEventArgs e) NotifyIcon.TooltipText = toolTipText; } + public void ApplyTrayIconStyle() + { + bool isMonochrome = Enum.TryParse(DefaultSettings.TrayIconStyle, true, out TrayIconStyle style) + && style == TrayIconStyle.Monochrome; + + string iconPath = isMonochrome + ? (SystemThemeUtility.IsLightTheme() ? "/Images/Select-Black.ico" : "/Images/Select-White.ico") + : "/Images/TealSelect40.png"; + + NotifyIcon.Icon = new BitmapImage(new Uri($"pack://application:,,,{iconPath}")); + } + private void EditWindowMenuItem_Click(object sender, RoutedEventArgs e) { EditTextWindow etw = new(); @@ -117,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..9aea7dc1 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), ]; } @@ -126,8 +126,7 @@ private void UpdateSaveButton() bool templateOk = !string.IsNullOrWhiteSpace(OutputTemplateBox.GetSerializedText()); SaveButton.IsEnabled = nameOk && templateOk; - if (ErrorText is not null) - ErrorText.Visibility = Visibility.Collapsed; + ErrorText?.Visibility = Visibility.Collapsed; } private void SaveButton_Click(object sender, RoutedEventArgs e) diff --git a/Text-Grab/Controls/WordBorder.xaml b/Text-Grab/Controls/WordBorder.xaml index 2fe3c1d3..8d812523 100644 --- a/Text-Grab/Controls/WordBorder.xaml +++ b/Text-Grab/Controls/WordBorder.xaml @@ -6,16 +6,14 @@ xmlns:local="clr-namespace:Text_Grab.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" Name="WordBorderControl" - AutomationProperties.Name="{Binding Path=DisplayText, Mode=OneWay}" d:DesignHeight="30" d:DesignWidth="80" + AutomationProperties.Name="{Binding Path=DisplayText, Mode=OneWay}" MouseDoubleClick="WordBorderControl_MouseDoubleClick" MouseEnter="WordBorder_MouseEnter" MouseLeave="WordBorder_MouseLeave" MouseMove="WordBorder_MouseEnter" - ToolTip="{Binding Path=Word, - Mode=OneWay, - UpdateSourceTrigger=PropertyChanged}" + ToolTip="{Binding Path=Word, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" Unloaded="WordBorderControl_Unloaded" mc:Ignorable="d"> @@ -90,24 +88,22 @@ @@ -120,15 +116,13 @@ Panel.ZIndex="10" Background="#CC1F6B78" CornerRadius="0,0,4,0" - Visibility="{Binding Path=TemplateBadgeVisibility, - ElementName=WordBorderControl}"> + Visibility="{Binding Path=TemplateBadgeVisibility, ElementName=WordBorderControl}"> + Text="{Binding Path=TemplateBadgeText, ElementName=WordBorderControl}" /> + /// Snapshot of taken when the edit textbox gains focus, so + /// can tell whether the user actually changed + /// anything and, if so, record it as an undoable edit. The live TwoWay binding on the + /// textbox updates / on every keystroke with no + /// other commit point, so without this the GrabFrame never learns an edit happened. + /// + private string? textEditStartValue; private double left = 0; private SolidColorBrush matchingBackground = new(Colors.Black); private double top = 0; @@ -491,7 +499,7 @@ private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventA translateSeparator = separator; } - if (WindowsAiUtilities.CanDeviceUseWinAI()) + if (WinAiTranslator.IsAvailable()) { if (translateMenuItem != null) { @@ -532,12 +540,29 @@ private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventA private void EditWordTextBox_GotFocus(object sender, RoutedEventArgs e) { Select(); + textEditStartValue = Word; // The user focusing a word's edit box is a strong signal they are about to correct // recognized text, so freeze the frame to keep it from resetting while they edit. OwnerGrabFrame?.FreezeFrameForWordEditing(); } + private void EditWordTextBox_LostFocus(object sender, RoutedEventArgs e) + { + // The textbox binds Text to DisplayText/Word live (UpdateSourceTrigger=PropertyChanged), + // so typing alone never tells the GrabFrame an edit happened. Losing focus is the only + // commit point, so diff against the value captured on focus and record it here. + if (textEditStartValue is not string oldWord) + return; + + textEditStartValue = null; + + if (oldWord == Word) + return; + + OwnerGrabFrame?.UndoableWordChange(this, oldWord, true); + } + private void EditWordTextBox_MouseDown(object sender, MouseButtonEventArgs e) { Select(); @@ -680,12 +705,13 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(Word)) return; - if (!WindowsAiUtilities.CanDeviceUseWinAI()) + (bool available, string? reason) = WinAiTranslator.CheckAvailability(); + if (!available) { await new Wpf.Ui.Controls.MessageBox { Title = "Translation Not Available", - Content = "Windows AI is not available on this device.", + Content = reason ?? "Windows AI is not available on this device.", CloseButtonText = "OK" }.ShowDialogAsync(); return; @@ -700,7 +726,20 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) string targetLanguage = GetSystemLanguageName(); // Translate the word - string translatedText = await WindowsAiUtilities.TranslateText(originalWord, targetLanguage); + TranslationResult result = await WinAiTranslator.TranslateAsync(originalWord, targetLanguage); + + if (!result.Succeeded) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = result.Failure is TranslationFailure.NotNeeded ? "Nothing to Translate" : "Translation Failed", + Content = result.Message ?? "The word could not be translated.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return; + } + + string translatedText = result.Text; // Update the word with translation if (!string.IsNullOrWhiteSpace(translatedText) && translatedText != originalWord) 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/Images/Select-Black.ico b/Text-Grab/Images/Select-Black.ico new file mode 100644 index 00000000..91007d4d Binary files /dev/null and b/Text-Grab/Images/Select-Black.ico differ diff --git a/Text-Grab/Images/Select-White.ico b/Text-Grab/Images/Select-White.ico new file mode 100644 index 00000000..008710ef Binary files /dev/null and b/Text-Grab/Images/Select-White.ico differ diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index 38eaf846..9b1a0e02 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -333,6 +333,14 @@ public static List AllButtons SymbolIcon = SymbolRegular.TextCollapse24 }, new() + { + OrderNumber = 3.15, + ButtonText = "Clean Up Text", + SymbolText = "", + ClickEvent = "CleanUpText_Click", + SymbolIcon = SymbolRegular.TextClearFormatting24 + }, + new() { OrderNumber = 3.2, ButtonText = "Try to make Numbers", @@ -794,6 +802,14 @@ public static List AllButtons RequiresCopilotPlus = true }, new() + { + OrderNumber = 8.15, + ButtonText = "Summarize as Meeting Notes", + ClickEvent = "MeetingNotesMenuItem_Click", + SymbolIcon = SymbolRegular.NotepadPerson20, + RequiresCopilotPlus = true + }, + new() { OrderNumber = 8.2, ButtonText = "Rewrite with Local AI", 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 b/Text-Grab/Pages/GeneralSettings.xaml index f470b199..b9f287ac 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -107,6 +107,26 @@ + + + + + + + (DefaultSettings.DefaultLaunch, true); switch (defaultLaunchSetting) { @@ -121,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; @@ -255,6 +269,26 @@ private void DarkThemeRdBtn_Checked(object sender, RoutedEventArgs e) App.SetTheme(); } + private void ColorTrayIconRdBtn_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.TrayIconStyle = TrayIconStyle.Color.ToString(); + DefaultSettings.Save(); + NotifyIconUtilities.RefreshTrayIconStyle(); + } + + private void MonochromeTrayIconRdBtn_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.TrayIconStyle = TrayIconStyle.Monochrome.ToString(); + DefaultSettings.Save(); + NotifyIconUtilities.RefreshTrayIconStyle(); + } + private void ReadBarcodesBarcode_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) @@ -456,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/Pages/ModelsSettings.xaml b/Text-Grab/Pages/ModelsSettings.xaml new file mode 100644 index 00000000..773e6854 --- /dev/null +++ b/Text-Grab/Pages/ModelsSettings.xaml @@ -0,0 +1,650 @@ + + + + + + + + + + + + Audio transcription runs entirely on this device using local Whisper models. Models are downloaded to disk the first time they're used; manage them here to pick a default, download one ahead of time, or delete one to free up space. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Pages/ModelsSettings.xaml.cs b/Text-Grab/Pages/ModelsSettings.xaml.cs new file mode 100644 index 00000000..cbced370 --- /dev/null +++ b/Text-Grab/Pages/ModelsSettings.xaml.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using Text_Grab.Utilities; + +namespace Text_Grab.Pages; + +/// +/// Interaction logic for ModelsSettings.xaml +/// +public partial class ModelsSettings : Page +{ + private readonly record struct ModelRow(Grid RowGrid, TextBlock NameText, TextBlock LanguageText, TextBlock StatusText, Button DownloadButton, Button DeleteButton); + + private Dictionary modelRows = []; + private bool _loaded; + + public ModelsSettings() + { + InitializeComponent(); + } + + private void Page_Loaded(object sender, RoutedEventArgs e) + { + modelRows = new() + { + [WhisperModelChoice.TinyEnglish] = new(TinyEnglishRowGrid, TinyEnglishNameText, TinyEnglishLanguageText, TinyEnglishStatusText, TinyEnglishDownloadButton, TinyEnglishDeleteButton), + [WhisperModelChoice.BaseEnglish] = new(BaseEnglishRowGrid, BaseEnglishNameText, BaseEnglishLanguageText, BaseEnglishStatusText, BaseEnglishDownloadButton, BaseEnglishDeleteButton), + [WhisperModelChoice.BaseMultilingual] = new(BaseMultilingualRowGrid, BaseMultilingualNameText, BaseMultilingualLanguageText, BaseMultilingualStatusText, BaseMultilingualDownloadButton, BaseMultilingualDeleteButton), + [WhisperModelChoice.SmallMultilingual] = new(SmallMultilingualRowGrid, SmallMultilingualNameText, SmallMultilingualLanguageText, SmallMultilingualStatusText, SmallMultilingualDownloadButton, SmallMultilingualDeleteButton), + [WhisperModelChoice.MediumEnglish] = new(MediumEnglishRowGrid, MediumEnglishNameText, MediumEnglishLanguageText, MediumEnglishStatusText, MediumEnglishDownloadButton, MediumEnglishDeleteButton), + [WhisperModelChoice.MediumMultilingual] = new(MediumMultilingualRowGrid, MediumMultilingualNameText, MediumMultilingualLanguageText, MediumMultilingualStatusText, MediumMultilingualDownloadButton, MediumMultilingualDeleteButton), + [WhisperModelChoice.LargeTurboMultilingual] = new(LargeTurboMultilingualRowGrid, LargeTurboMultilingualNameText, LargeTurboMultilingualLanguageText, LargeTurboMultilingualStatusText, LargeTurboMultilingualDownloadButton, LargeTurboMultilingualDeleteButton), + [WhisperModelChoice.LargeMultilingual] = new(LargeMultilingualRowGrid, LargeMultilingualNameText, LargeMultilingualLanguageText, LargeMultilingualStatusText, LargeMultilingualDownloadButton, LargeMultilingualDeleteButton), + }; + + // Every row and combo box entry's text is drawn from WhisperModelInfo — the single source of + // truth also used by the live-transcription flyouts and Open Media — so a model's name, size, + // and description can never drift out of sync between touch points. + foreach ((WhisperModelChoice choice, ModelRow row) in modelRows) + { + row.NameText.Text = WhisperModelInfo.DisplayName(choice); + row.LanguageText.Text = WhisperModelInfo.ShortLanguageLabel(choice); + row.RowGrid.ToolTip = WhisperModelInfo.Description(choice); + } + + WhisperModelChoice currentChoice = AudioTranscriptionUtilities.CurrentModelChoice; + foreach (ComboBoxItem item in DefaultModelComboBox.Items) + { + if (item.Tag is not string tag) + continue; + + item.Content = WhisperModelInfo.DisplayNameWithSize(WhisperModelInfo.Parse(tag)); + if (tag == currentChoice.ToString()) + DefaultModelComboBox.SelectedItem = item; + } + + WhisperModelChoice currentLiveChoice = AudioTranscriptionUtilities.CurrentLiveModelChoice; + foreach (ComboBoxItem item in LiveDefaultModelComboBox.Items) + { + if (item.Tag is not string tag) + continue; + + item.Content = WhisperModelInfo.DisplayNameWithSize(WhisperModelInfo.Parse(tag)); + if (tag == currentLiveChoice.ToString()) + LiveDefaultModelComboBox.SelectedItem = item; + } + + foreach (WhisperModelChoice choice in modelRows.Keys) + RefreshModelRow(choice); + + _loaded = true; + } + + /// Updates the download status text and which of Download/Delete is shown for a model. + private void RefreshModelRow(WhisperModelChoice choice) + { + if (!modelRows.TryGetValue(choice, out ModelRow row)) + return; + + long? downloadedBytes = AudioTranscriptionUtilities.DownloadedModelSizeBytes(choice); + + row.StatusText.Text = downloadedBytes is long bytes + ? $"{bytes / (1024.0 * 1024.0):0.#} MB downloaded" + : $"Not downloaded ({WhisperModelInfo.ApproxDownloadSize(choice)})"; + + row.DownloadButton.Visibility = downloadedBytes is null ? Visibility.Visible : Visibility.Collapsed; + row.DeleteButton.Visibility = downloadedBytes is null ? Visibility.Collapsed : Visibility.Visible; + } + + private void DefaultModelComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!_loaded) + return; + + if (DefaultModelComboBox.SelectedItem is not ComboBoxItem item || item.Tag is not string tag) + return; + + AppUtilities.TextGrabSettings.AudioTranscriptionModel = tag; + AppUtilities.TextGrabSettings.Save(); + + foreach (WhisperModelChoice choice in modelRows.Keys) + RefreshModelRow(choice); + } + + private void LiveDefaultModelComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!_loaded) + return; + + if (LiveDefaultModelComboBox.SelectedItem is not ComboBoxItem item || item.Tag is not string tag) + return; + + AppUtilities.TextGrabSettings.LiveTranscriptionModel = tag; + AppUtilities.TextGrabSettings.Save(); + + foreach (WhisperModelChoice choice in modelRows.Keys) + RefreshModelRow(choice); + } + + private async void DownloadButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element || element.Tag is not string tag) + return; + + WhisperModelChoice choice = WhisperModelInfo.Parse(tag); + if (!modelRows.TryGetValue(choice, out ModelRow row)) + return; + + row.DownloadButton.IsEnabled = false; + Progress progress = new(message => row.StatusText.Text = message); + + try + { + await AudioTranscriptionUtilities.DownloadModelAsync(choice, progress); + } + catch (Exception ex) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Download failed", + Content = $"Couldn't download this model:\n{ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); + } + finally + { + row.DownloadButton.IsEnabled = true; + RefreshModelRow(choice); + } + } + + private async void DeleteButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element || element.Tag is not string tag) + return; + + WhisperModelChoice choice = WhisperModelInfo.Parse(tag); + + bool isFileDefault = AudioTranscriptionUtilities.CurrentModelChoice == choice; + bool isLiveDefault = AudioTranscriptionUtilities.CurrentLiveModelChoice == choice; + string content = (isFileDefault, isLiveDefault) switch + { + (true, true) => $"Delete the downloaded \"{WhisperModelInfo.DisplayName(choice)}\" model? It's your default for both file and live transcription, so Text Grab will automatically download it again the next time you transcribe.", + (true, false) => $"Delete the downloaded \"{WhisperModelInfo.DisplayName(choice)}\" model? It's your file transcription default, so Text Grab will automatically download it again the next time you transcribe.", + (false, true) => $"Delete the downloaded \"{WhisperModelInfo.DisplayName(choice)}\" model? It's your live transcription default, so Text Grab will automatically download it again the next time you transcribe.", + _ => $"Delete the downloaded \"{WhisperModelInfo.DisplayName(choice)}\" model? You can download it again later.", + }; + + Wpf.Ui.Controls.MessageBoxResult result = await new Wpf.Ui.Controls.MessageBox + { + Title = "Delete model", + Content = content, + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel" + }.ShowDialogAsync(); + + if (result != Wpf.Ui.Controls.MessageBoxResult.Primary) + return; + + AudioTranscriptionUtilities.DeleteModel(choice); + RefreshModelRow(choice); + } + + private void OpenModelsFolderButton_Click(object sender, RoutedEventArgs e) + { + Directory.CreateDirectory(AudioTranscriptionUtilities.ModelDirectory); + Process.Start(new ProcessStartInfo(AudioTranscriptionUtilities.ModelDirectory) { UseShellExecute = true }); + } + + private void GoToLanguagesButton_Click(object sender, RoutedEventArgs e) + { + if (Window.GetWindow(this) is SettingsWindow settingsWindow) + settingsWindow.SettingsNavView.Navigate(typeof(LanguageSettings)); + } +} diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 0376e9ea..53d496e6 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -12,7 +12,7 @@ namespace Text_Grab.Properties { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.9.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.11.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); @@ -479,6 +479,18 @@ public string AppTheme { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("Color")] + public string TrayIconStyle { + get { + return ((string)(this["TrayIconStyle"])); + } + set { + this["TrayIconStyle"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] @@ -767,6 +779,30 @@ public bool EtwShowSimilarMatches { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool EtwShowTranscribe { + get { + return ((bool)(this["EtwShowTranscribe"])); + } + set { + this["EtwShowTranscribe"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool TranscribeButtonJustIcon { + get { + return ((bool)(this["TranscribeButtonJustIcon"])); + } + set { + this["TranscribeButtonJustIcon"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("True")] @@ -779,6 +815,30 @@ public bool EtwNormalizeLineEndingsOnPaste { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("BaseMultilingual")] + public string AudioTranscriptionModel { + get { + return ((string)(this["AudioTranscriptionModel"])); + } + set { + this["AudioTranscriptionModel"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("BaseMultilingual")] + public string LiveTranscriptionModel { + get { + return ((string)(this["LiveTranscriptionModel"])); + } + set { + this["LiveTranscriptionModel"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("Auto")] @@ -1162,5 +1222,65 @@ public bool HdrBorderlessGranted { this["HdrBorderlessGranted"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool NotifyOnTranscriptionComplete { + get { + return ((bool)(this["NotifyOnTranscriptionComplete"])); + } + set { + this["NotifyOnTranscriptionComplete"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool IncludeTimecodesInTranscription { + get { + return ((bool)(this["IncludeTimecodesInTranscription"])); + } + set { + this["IncludeTimecodesInTranscription"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool NotifyOnLocalAiComplete { + get { + return ((bool)(this["NotifyOnLocalAiComplete"])); + } + set { + this["NotifyOnLocalAiComplete"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool SendLocalAiResultToNewWindow { + get { + return ((bool)(this["SendLocalAiResultToNewWindow"])); + } + set { + this["SendLocalAiResultToNewWindow"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool GrabFrameIgnoreRepeatedHeadersFooters { + get { + return ((bool)(this["GrabFrameIgnoreRepeatedHeadersFooters"])); + } + set { + this["GrabFrameIgnoreRepeatedHeadersFooters"] = value; + } + } } } 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/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index d6b32b78..979f3fc6 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -116,6 +116,9 @@ System + + Color + False @@ -188,9 +191,21 @@ False + + False + + + False + True + + BaseMultilingual + + + BaseMultilingual + Auto @@ -287,5 +302,20 @@ False + + True + + + False + + + True + + + True + + + True + \ No newline at end of file diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index a3e08be0..6d33cc69 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(); @@ -212,7 +205,7 @@ public async Task PopulateMenuItemWithRecentPdfs(MenuItem recentPdfsMenuItem) private async Task PopulateMenuItemWithImageHistory(MenuItem historyMenuItem, List historyItems) { - historyItems = [.. historyItems.OrderByDescending(x => x.CaptureDateTime)]; + historyItems = [.. historyItems.OrderByDescending(x => x.CaptureDateTime).Take(10)]; ClearRecentGrabsMenuItems(historyMenuItem); @@ -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/Styles/ButtonStyles.xaml b/Text-Grab/Styles/ButtonStyles.xaml index a61bc9b0..eef7507a 100644 --- a/Text-Grab/Styles/ButtonStyles.xaml +++ b/Text-Grab/Styles/ButtonStyles.xaml @@ -317,8 +317,7 @@ x:Name="PART_Popup" AllowsTransparency="true" Focusable="false" - IsOpen="{Binding IsSubmenuOpen, - RelativeSource={RelativeSource TemplatedParent}}" + IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}" Placement="Bottom" PlacementTarget="{Binding ElementName=templateRoot}" PopupAnimation="{DynamicResource {x:Static SystemParameters.MenuPopupAnimationKey}}"> @@ -337,12 +336,9 @@ VerticalAlignment="Top"> + Width="{Binding ActualWidth, ElementName=SubMenuBorder}" + Height="{Binding ActualHeight, ElementName=SubMenuBorder}" + Fill="{Binding Background, ElementName=SubMenuBorder}" /> @@ -573,12 +568,9 @@ VerticalAlignment="Top"> + Width="{Binding ActualWidth, ElementName=SubMenuBorder}" + Height="{Binding ActualHeight, ElementName=SubMenuBorder}" + Fill="{Binding Background, ElementName=SubMenuBorder}" /> + Visibility="{Binding HeadersVisibility, ConverterParameter={x:Static DataGridHeadersVisibility.Row}, Converter={x:Static DataGrid.HeadersVisibilityConverter}, RelativeSource={RelativeSource AncestorType={x:Type DataGrid}}}" /> diff --git a/Text-Grab/Styles/ListViewScrollFix.xaml b/Text-Grab/Styles/ListViewScrollFix.xaml index aa605b6f..3dad6541 100644 --- a/Text-Grab/Styles/ListViewScrollFix.xaml +++ b/Text-Grab/Styles/ListViewScrollFix.xaml @@ -26,18 +26,12 @@ VerticalScrollBarVisibility="Hidden"> + Value="{Binding Path=HorizontalOffset, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" /> + Value="{Binding Path=VerticalOffset, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" /> + Data="{Binding Content, RelativeSource={RelativeSource TemplatedParent}}"> diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 02af482a..1070dafb 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -23,17 +23,33 @@ true win-x86;win-x64;win-arm64 false - 4.15.0 + 4.16.0 $(NoWarn);WFO0003 + + + + @@ -65,35 +81,37 @@ - + + + + + + - - - + + + - - - - - - - - - - + + + + + + - - - + + none + + - + @@ -112,6 +130,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/Text-Grab/TextGrabNotificationActivator.cs b/Text-Grab/TextGrabNotificationActivator.cs index 2ab367cb..b89f7fd9 100644 --- a/Text-Grab/TextGrabNotificationActivator.cs +++ b/Text-Grab/TextGrabNotificationActivator.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using Text_Grab.Utilities; namespace Text_Grab; @@ -16,6 +17,9 @@ public override void OnActivated(string invokedArgs, NotificationUserInput userI // Tapping on the top-level header launches with empty args if (invokedArgs.Length != 0) { + if (NotificationUtilities.TryActivateTranscriptionWindow(invokedArgs)) + return; + // Perform a normal launch EditTextWindow mtw = new(invokedArgs); mtw.Show(); diff --git a/Text-Grab/Utilities/AppUtilities.cs b/Text-Grab/Utilities/AppUtilities.cs index 73a49926..80d7bcf1 100644 --- a/Text-Grab/Utilities/AppUtilities.cs +++ b/Text-Grab/Utilities/AppUtilities.cs @@ -1,37 +1,24 @@ 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; internal static Settings TextGrabSettings => TextGrabSettingsService.ClassicSettings; - internal static string GetAppVersion() - { - if (IsPackaged()) - { - PackageVersion version = Package.Current.Id.Version; - return $"{version.Major}.{version.Minor}.{version.Build}" ?? "unknown error reading package version"; - } + /// + /// Whether look-alike Greek and Cyrillic characters should be mapped to Latin. + /// Honors the CorrectToLatin setting and only applies when the current input language is + /// Latin-based, so the mapping never mangles text the user actually types in that script. + /// + internal static bool ShouldCorrectToLatin() + => TextGrabSettings is Settings settings + && settings.CorrectToLatin + && LanguageUtilities.IsCurrentLanguageLatinBased(); - - 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/CameraCaptureUtilities.cs b/Text-Grab/Utilities/CameraCaptureUtilities.cs new file mode 100644 index 00000000..78a4555b --- /dev/null +++ b/Text-Grab/Utilities/CameraCaptureUtilities.cs @@ -0,0 +1,53 @@ +using Microsoft.UI; +using Microsoft.Windows.Media.Capture; +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Interop; +using Windows.Storage; + +namespace Text_Grab.Utilities; + +public static class CameraCaptureUtilities +{ + // The Microsoft.Windows.Media.Capture.CameraCaptureUI contract is only reliably brokered for + // packaged (MSIX) apps, mirroring the same gate Text-Grab already applies to Windows AI features. + public static bool IsCameraCaptureSupported() => AppUtilities.IsPackaged(); + + public static async Task CaptureImageFromCameraAsync(Window ownerWindow) + { + if (!IsCameraCaptureSupported()) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = "Camera capture is only available in the Microsoft Store version of Text Grab.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return null; + } + + try + { + nint hwnd = new WindowInteropHelper(ownerWindow).Handle; + WindowId windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + + CameraCaptureUI cameraCaptureUI = new(windowId); + cameraCaptureUI.PhotoSettings.Format = CameraCaptureUIPhotoFormat.Png; + + StorageFile? file = await cameraCaptureUI.CaptureFileAsync(CameraCaptureUIMode.Photo); + + return file?.Path; + } + catch (Exception ex) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = $"Error capturing image from camera.{Environment.NewLine}{ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return null; + } + } +} diff --git a/Text-Grab/Utilities/ClipboardUtilities.cs b/Text-Grab/Utilities/ClipboardUtilities.cs index de941a93..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(); @@ -145,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; @@ -158,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/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs index 500e9f3b..7d927b1f 100644 --- a/Text-Grab/Utilities/GrabTemplateManager.cs +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -17,9 +17,9 @@ namespace Text_Grab.Utilities; /// the transition release. Pattern follows . /// /// -/// TODO: This class has no thread-safety guards. All current callers are UI-thread -/// methods so this is safe today, but if templates are ever read/written from -/// background threads a lock (like SettingsService._managedJsonLock) should be added. +/// Only the in-memory cache is guarded (see _cacheLock). The underlying storage — the +/// settings string and the JSON file — is not, so concurrent writes from background threads would +/// still race; all current callers are UI-thread methods. /// public static class GrabTemplateManager { @@ -33,12 +33,41 @@ public static class GrabTemplateManager private const string TemplatesFileName = "GrabTemplates.json"; + // In-memory cache of the resolved template list. GetAllTemplates() used to hit disk + // (and potentially Settings.Save(), which is slow) on every call — including every time + // a menu that lists templates (e.g. the EditTextWindow "Capture" menu) was opened. Cached + // here instead and only refreshed by writes that go through this class. + private static List? _cachedTemplates; + + // Guards _cachedTemplates: it is process-wide static state and `??=` is not atomic, so two + // threads could each load and publish a different list. + private static readonly object _cacheLock = new(); + // Allow tests to override the file path. // TODO: If more test seams are needed, consider consolidating these into a small // options/config object instead of individual static properties. - internal static string? TestFilePath { get; set; } + private static string? _testFilePath; + internal static string? TestFilePath + { + get => _testFilePath; + set { _testFilePath = value; InvalidateCache(); } + } + internal static string? TestImagesFolderPath { get; set; } - internal static bool? TestPreferFileBackedMode { get; set; } + + private static bool? _testPreferFileBackedMode; + internal static bool? TestPreferFileBackedMode + { + get => _testPreferFileBackedMode; + set { _testPreferFileBackedMode = value; InvalidateCache(); } + } + + /// Drops the in-memory template cache so the next read re-resolves from disk/settings. + internal static void InvalidateCache() + { + lock (_cacheLock) + _cachedTemplates = null; + } private static bool PreferFileBackedTemplates => TestPreferFileBackedMode ?? AppUtilities.TextGrabSettingsService.IsFileBackedManagedSettingsEnabled; @@ -131,8 +160,27 @@ public static string GetTemplateImagesFolder() // ── Read ────────────────────────────────────────────────────────────────── - /// Returns all saved templates, or an empty list if none exist. + /// + /// Returns all saved templates, or an empty list if none exist. Resolved once per process + /// (or since the last write/) and cached; callers get a fresh list + /// of fresh copies each time, so neither a structural edit (add/remove) nor an edit to a template + /// itself can leak into another caller's list — or into the cache — before being persisted. + /// public static List GetAllTemplates() + { + lock (_cacheLock) + { + _cachedTemplates ??= LoadTemplatesFromStorage(); + return [.. _cachedTemplates.Select(CloneTemplate)]; + } + } + + /// Deep copy via the same JSON shape these are persisted in. + private static GrabTemplate CloneTemplate(GrabTemplate template) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(template, JsonOptions), JsonOptions) + ?? template; + + private static List LoadTemplatesFromStorage() { try { @@ -173,6 +221,10 @@ public static void SaveTemplates(List templates) { string json = JsonSerializer.Serialize(templates, JsonOptions); SaveTemplatesJson(json); + + // Copies here too: the caller keeps its list and may go on editing it. + lock (_cacheLock) + _cachedTemplates = [.. templates.Select(CloneTemplate)]; } internal static string GetTemplatesJsonForExport() @@ -219,10 +271,7 @@ public static void DeleteTemplate(string id) if (original is null) return null; - string json = JsonSerializer.Serialize(original, JsonOptions); - GrabTemplate? copy = JsonSerializer.Deserialize(json, JsonOptions); - if (copy is null) - return null; + GrabTemplate copy = CloneTemplate(original); copy.Id = Guid.NewGuid().ToString(); copy.Name = $"{original.Name} (copy)"; 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 56% rename from Text-Grab/Utilities/MarkdownDocumentUtilities.cs rename to Text-Grab/Utilities/MarkdownFlowDocumentUtilities.cs index 1d35805b..994ca491 100644 --- a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs +++ b/Text-Grab/Utilities/MarkdownFlowDocumentUtilities.cs @@ -1,4 +1,3 @@ -using Markdig; using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -6,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; @@ -24,16 +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() - .UseAdvancedExtensions() - .Build(); - private enum MarkdownBlockRole { None, @@ -70,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); @@ -101,31 +100,230 @@ 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); + } + + /// + /// A source-markdown range and the range in 's own local rendered + /// text it corresponds to (e.g. the same span, minus stripped syntax like ** or a + /// task-list checkbox glyph standing in for [x]). / + /// are measured from 's own + /// ContentStart — never from the document's — because does + /// not reliably count characters when a range crosses into a from outside + /// it (verified empirically: every position inside a table row measured that way collapses to + /// the same offset, the row's end). Keeping every measurement local to its own paragraph sidesteps + /// that entirely, table cell or not. + /// + public readonly record struct MarkdownOffsetMapping(int RawStart, int RawEnd, Paragraph AnchorParagraph, int RenderedStart, int RenderedEnd); + + /// + /// Raw-to-rendered offset mapping for a document built by , built + /// by at two granularities, mirroring how tools like Markdown Editor's + /// preview sync (which anchors by source *line*, not by character) stay reliable: + /// is one entry per top-level paragraph/heading/code block/table cell, taken straight from Markdig's + /// own block boundaries — never guessed — so "which block is this raw offset in" can't land in the + /// wrong paragraph. is the finer per-Run breakdown used only to refine a position + /// once the containing block is already known, so a bad interpolation there is bounded to that one + /// block instead of drifting across the whole document. + /// + public sealed record MarkdownOffsetMap(IReadOnlyList Blocks, IReadOnlyList Runs); + + /// + /// Walks a document built by and collects the raw-to-rendered + /// offset mapping recorded on each tagged Paragraph and Run during construction, in document order. + /// + public static MarkdownOffsetMap BuildOffsetMap(FlowDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + List blocks = []; + List runs = []; + foreach (WpfBlock block in document.Blocks) + CollectOffsetMappings(block, blocks, runs); + + return new MarkdownOffsetMap(blocks, runs); } - public static bool ShouldPromoteLiveBlock(string? lineTextBeforeSpace) + /// + /// Translates an offset in the raw markdown source (as produced by + /// and consumed by Find & Replace) into a selectable position in the rendered document. First + /// finds the enclosing (or nearest) top-level block via — + /// using only Markdig's own raw block boundaries, never a cross-block guess — then refines within + /// that one block/paragraph using and resolves the final + /// with a walk scoped to that same paragraph (never + /// as a whole), so it stays correct even inside a table cell. + /// + public static TextPointer MapRawOffsetToPosition(FlowDocument document, MarkdownOffsetMap map, int rawOffset) { - if (string.IsNullOrWhiteSpace(lineTextBeforeSpace)) - return false; + ArgumentNullException.ThrowIfNull(document); + + IReadOnlyList blocks = map.Blocks; + if (blocks.Count == 0) + return document.ContentStart; + + if (rawOffset <= blocks[0].RawStart) + return GetLocalTextPointer(blocks[0].AnchorParagraph, blocks[0].RenderedStart); + + MarkdownOffsetMapping lastBlock = blocks[^1]; + if (rawOffset >= lastBlock.RawEnd) + return GetLocalTextPointer(lastBlock.AnchorParagraph, lastBlock.RenderedEnd); + + for (int i = 0; i < blocks.Count; i++) + { + MarkdownOffsetMapping block = blocks[i]; + + // Inclusive on RawEnd: a match's end offset (index + length) very commonly lands + // exactly on a block's exclusive end (e.g. a match that runs to the last character of + // a paragraph) — treat that as still belonging to this block rather than falling + // through to gap-snapping against the next, unrelated paragraph. + if (rawOffset >= block.RawStart && rawOffset <= block.RawEnd) + return MapWithinBlock(map.Runs, block, rawOffset); + + if (i + 1 < blocks.Count) + { + MarkdownOffsetMapping next = blocks[i + 1]; + if (rawOffset >= block.RawEnd && rawOffset < next.RawStart) + { + // A gap between two blocks (a blank line, a list/quote marker outside any + // tagged run) spans two different paragraphs, so there's no shared coordinate + // space to interpolate across — snap to whichever side is raw-closer instead. + int distanceToBlockEnd = rawOffset - block.RawEnd; + int distanceToNextStart = next.RawStart - rawOffset; + return distanceToBlockEnd <= distanceToNextStart + ? GetLocalTextPointer(block.AnchorParagraph, block.RenderedEnd) + : GetLocalTextPointer(next.AnchorParagraph, next.RenderedStart); + } + } + } + + return GetLocalTextPointer(lastBlock.AnchorParagraph, lastBlock.RenderedEnd); + } + + /// + /// Refines a raw offset already known to fall inside using whichever of + /// belong to that same paragraph. Falls back to placing it proportionally + /// across the whole block (still correct at the paragraph level) when no run in this block covers + /// the offset — e.g. a fenced code block or thematic break, tagged as a single run spanning the block. + /// + private static TextPointer MapWithinBlock(IReadOnlyList runs, MarkdownOffsetMapping block, int rawOffset) + { + foreach (MarkdownOffsetMapping run in runs) + { + if (!ReferenceEquals(run.AnchorParagraph, block.AnchorParagraph)) + continue; + if (run.RawStart < block.RawStart || run.RawEnd > block.RawEnd) + continue; + + // Inclusive on RawEnd for the same reason as the block-level lookup above: a match's + // end offset routinely lands exactly on a run's exclusive end (matching that whole run, + // e.g. a bolded word matched in full), and should still resolve within this run rather + // than falling through to the coarser whole-block proportional placement. + if (rawOffset >= run.RawStart && rawOffset <= run.RawEnd) + { + double t = (rawOffset - run.RawStart) / (double)Math.Max(1, run.RawEnd - run.RawStart); + int local = run.RenderedStart + (int)Math.Round(t * (run.RenderedEnd - run.RenderedStart)); + return GetLocalTextPointer(block.AnchorParagraph, local); + } + } + + double blockT = (rawOffset - block.RawStart) / (double)Math.Max(1, block.RawEnd - block.RawStart); + int blockLocal = block.RenderedStart + (int)Math.Round(blockT * (block.RenderedEnd - block.RenderedStart)); + return GetLocalTextPointer(block.AnchorParagraph, blockLocal); + } + + /// + /// Resolves a plain-text offset local to (i.e. measured from its own + /// ContentStart, never the document's) to an actual , by walking + /// 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. + /// + /// + /// 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 + /// paragraph's own tagged range accounts for it), but no reachable insertion position exists + /// that is "just past the marker" without also having consumed the first real character — so a + /// match that starts at the very first character of a list item's content may end up selecting + /// the marker glyph too. Every other position (mid-item, table cells, headings, code, etc.) is + /// unaffected and resolves exactly. + /// + private static TextPointer GetLocalTextPointer(Paragraph paragraph, int localOffset) + { + TextPointer navigator = paragraph.ContentStart; + TextPointer lastInsertionPosition = navigator; + + while (navigator is not null) + { + int currentOffset = new TextRange(paragraph.ContentStart, navigator).Text.Length; + if (currentOffset >= localOffset) + return navigator; + + lastInsertionPosition = navigator; + TextPointer? next = navigator.GetNextInsertionPosition(LogicalDirection.Forward); + if (next is null || next.CompareTo(paragraph.ContentEnd) > 0) + break; + + navigator = next; + } - return LiveBlockTriggerRegex.IsMatch(lineTextBeforeSpace); + return lastInsertionPosition; } - public static bool LooksLikeMarkdown(string? text) + private static void CollectOffsetMappings(WpfBlock block, List blocks, List runs) { - if (string.IsNullOrWhiteSpace(text)) - return false; + switch (block) + { + case Paragraph paragraph: + int blockRawStart = GetRawSpanStart(paragraph); + if (blockRawStart >= 0) + { + int blockRawEnd = GetRawSpanEnd(paragraph); + int paragraphRenderedLength = new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text.Length; + if (paragraphRenderedLength > 0) + blocks.Add(new MarkdownOffsetMapping(blockRawStart, blockRawEnd, paragraph, 0, paragraphRenderedLength)); + } - return MarkdownPatternRegex.IsMatch(text); + foreach (WpfInline inline in paragraph.Inlines) + CollectOffsetMappings(inline, paragraph, runs); + break; + + case WpfList list: + foreach (ListItem item in list.ListItems) + foreach (WpfBlock child in item.Blocks) + CollectOffsetMappings(child, blocks, runs); + break; + + case WpfTable table: + foreach (TableRowGroup rowGroup in table.RowGroups) + foreach (WpfTableRow row in rowGroup.Rows.Cast()) + foreach (WpfTableCell cell in row.Cells.Cast()) + foreach (WpfBlock child in cell.Blocks) + CollectOffsetMappings(child, blocks, runs); + break; + } } - public static bool ShouldPromoteLiveMarkdown(string? paragraphText) + private static void CollectOffsetMappings(WpfInline inline, Paragraph owningParagraph, List runs) { - if (string.IsNullOrWhiteSpace(paragraphText)) - return false; + switch (inline) + { + case Run run: + int rawStart = GetRawSpanStart(run); + if (rawStart < 0) + break; - return LiveInlinePromotionRegex.IsMatch(NormalizeDocumentText(paragraphText)); + int rawEnd = GetRawSpanEnd(run); + int renderedStart = new TextRange(owningParagraph.ContentStart, run.ContentStart).Text.Length; + int renderedEnd = new TextRange(owningParagraph.ContentStart, run.ContentEnd).Text.Length; + if (renderedEnd > renderedStart) + runs.Add(new MarkdownOffsetMapping(rawStart, rawEnd, owningParagraph, renderedStart, renderedEnd)); + break; + + // Bold, Italic and Hyperlink all derive from Span, so this also covers them. + case Span span: + foreach (WpfInline child in span.Inlines) + CollectOffsetMappings(child, owningParagraph, runs); + break; + } } public static void ApplyTheme(FlowDocument document, FrameworkElement resourceHost, bool isLightTheme) @@ -155,6 +353,7 @@ private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, stri }; SetHeadingLevel(headingParagraph, Math.Clamp(headingBlock.Level, 1, 6)); SetQuoteDepth(headingParagraph, quoteDepth); + SetRawSpan(headingParagraph, headingBlock.Span.Start, headingBlock.Span.End + 1); AppendInlineContainer(headingParagraph.Inlines, headingBlock.Inline, source); blocks.Add(headingParagraph); break; @@ -165,6 +364,7 @@ private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, stri Margin = new Thickness(0, 4, 0, 4) }; SetQuoteDepth(paragraph, quoteDepth); + SetRawSpan(paragraph, paragraphBlock.Span.Start, paragraphBlock.Span.End + 1); AppendInlineContainer(paragraph.Inlines, paragraphBlock.Inline, source); blocks.Add(paragraph); break; @@ -179,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); @@ -199,21 +399,24 @@ private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, stri break; case FencedCodeBlock fencedCodeBlock: - blocks.Add(CreateCodeParagraph(GetCodeBlockText(fencedCodeBlock), fencedCodeBlock.Info, quoteDepth)); + 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)); + blocks.Add(CreateCodeParagraph(MarkdownDocumentUtilities.GetCodeBlockText(codeBlock), info: null, quoteDepth, codeBlock.Span.Start, codeBlock.Span.End + 1)); break; - case ThematicBreakBlock: + case ThematicBreakBlock thematicBreakBlock: Paragraph breakParagraph = new() { Margin = new Thickness(0, 8, 0, 8) }; SetBlockRole(breakParagraph, MarkdownBlockRole.ThematicBreak); SetQuoteDepth(breakParagraph, quoteDepth); - breakParagraph.Inlines.Add(new Run("----------")); + SetRawSpan(breakParagraph, thematicBreakBlock.Span.Start, thematicBreakBlock.Span.End + 1); + Run breakRun = new("----------"); + SetRawSpan(breakRun, thematicBreakBlock.Span.Start, thematicBreakBlock.Span.End + 1); + breakParagraph.Inlines.Add(breakRun); blocks.Add(breakParagraph); break; @@ -222,12 +425,12 @@ private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, stri break; default: - blocks.Add(CreateLiteralParagraph(GetSourceSlice(source, block), quoteDepth)); + blocks.Add(CreateLiteralParagraph(MarkdownDocumentUtilities.GetSourceSlice(source, block), quoteDepth, block.Span.Start, block.Span.End + 1)); break; } } - private static Paragraph CreateCodeParagraph(string codeText, string? info, int quoteDepth) + private static Paragraph CreateCodeParagraph(string codeText, string? info, int quoteDepth, int rawStart, int rawEnd) { Paragraph paragraph = new() { @@ -237,11 +440,14 @@ private static Paragraph CreateCodeParagraph(string codeText, string? info, int SetBlockRole(paragraph, MarkdownBlockRole.CodeBlock); SetQuoteDepth(paragraph, quoteDepth); SetCodeFenceInfo(paragraph, info?.ToString() ?? string.Empty); - paragraph.Inlines.Add(new Run(codeText)); + SetRawSpan(paragraph, rawStart, rawEnd); + Run codeTextRun = new(codeText); + SetRawSpan(codeTextRun, rawStart, rawEnd); + paragraph.Inlines.Add(codeTextRun); return paragraph; } - private static Paragraph CreateLiteralParagraph(string literalMarkdown, int quoteDepth) + private static Paragraph CreateLiteralParagraph(string literalMarkdown, int quoteDepth, int rawStart, int rawEnd) { Paragraph paragraph = new() { @@ -249,8 +455,10 @@ private static Paragraph CreateLiteralParagraph(string literalMarkdown, int quot }; SetQuoteDepth(paragraph, quoteDepth); + SetRawSpan(paragraph, rawStart, rawEnd); Run literalRun = new(literalMarkdown); SetInlineRole(literalRun, MarkdownInlineRole.LiteralMarkdown); + SetRawSpan(literalRun, rawStart, rawEnd); paragraph.Inlines.Add(literalRun); return paragraph; } @@ -312,7 +520,12 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, switch (inline) { case LiteralInline literalInline: - inlines.Add(new Run(literalInline.Content.ToString())); + string literalContent = literalInline.Content.ToString(); + Run contentRun = new(literalContent); + (int literalRawStart, int literalRawEnd) = MarkdownDocumentUtilities.ResolveContentSpan( + source, literalContent, literalInline.Span.Start, literalInline.Span.End + 1); + SetRawSpan(contentRun, literalRawStart, literalRawEnd); + inlines.Add(contentRun); break; case LineBreakInline: @@ -325,6 +538,11 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, FontFamily = new FontFamily("Consolas") }; SetInlineRole(codeRun, MarkdownInlineRole.CodeSpan); + // 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 = MarkdownDocumentUtilities.GetCodeSpanContentRawStart(codeInline); + SetRawSpan(codeRun, codeContentRawStart, codeContentRawStart + codeInline.Content.Length); inlines.Add(codeRun); break; @@ -332,6 +550,7 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, Run taskListRun = new(taskList.Checked ? "\u2611" : "\u2610"); SetInlineRole(taskListRun, MarkdownInlineRole.TaskListMarker); SetTaskListMarkerChecked(taskListRun, taskList.Checked); + SetRawSpan(taskListRun, taskList.Span.Start, taskList.Span.End + 1); inlines.Add(taskListRun); break; @@ -358,20 +577,26 @@ private static void AppendInline(InlineCollection inlines, MarkdigInline inline, AppendInlineContainer(hyperlink.Inlines, linkInline, source); if (hyperlink.Inlines.FirstInline is null) - hyperlink.Inlines.Add(new Run(linkInline.Url ?? string.Empty)); + { + Run hyperlinkFallbackRun = new(linkInline.Url ?? string.Empty); + SetRawSpan(hyperlinkFallbackRun, linkInline.Span.Start, linkInline.Span.End + 1); + hyperlink.Inlines.Add(hyperlinkFallbackRun); + } inlines.Add(hyperlink); 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); break; case HtmlInline htmlInline: Run htmlRun = new(htmlInline.Tag); SetInlineRole(htmlRun, MarkdownInlineRole.LiteralMarkdown); + SetRawSpan(htmlRun, htmlInline.Span.Start, htmlInline.Span.End + 1); inlines.Add(htmlRun); break; @@ -382,8 +607,9 @@ 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); break; } @@ -413,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; } @@ -437,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; @@ -464,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; @@ -499,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) @@ -511,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); @@ -544,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: @@ -571,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; @@ -722,107 +939,55 @@ 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'); - - 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(MarkdownFlowDocumentUtilities), new PropertyMetadata(-1)); + + private static readonly DependencyProperty RawSpanEndProperty = + 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 + /// produced from, so can later pair it with the Run's rendered + /// position. No-ops for an invalid/empty span (e.g. a synthesized fallback with no source range). + /// + private static void SetRawSpan(DependencyObject element, int start, int endExclusive) + { + if (start < 0 || endExclusive <= start) + return; + + element.SetValue(RawSpanStartProperty, start); + element.SetValue(RawSpanEndProperty, endExclusive); + } + + private static int GetRawSpanStart(DependencyObject element) => (int)element.GetValue(RawSpanStartProperty); + private static int GetRawSpanEnd(DependencyObject element) => (int)element.GetValue(RawSpanEndProperty); private static void SetQuoteDepth(DependencyObject element, int value) => element.SetValue(QuoteDepthProperty, value); private static int GetQuoteDepth(DependencyObject element) => (int)element.GetValue(QuoteDepthProperty); @@ -838,14 +1003,4 @@ private static string GetSourceSlice(string source, MarkdownObject markdownObjec 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/NotificationUtilities.cs b/Text-Grab/Utilities/NotificationUtilities.cs index cab4437b..c8d695f3 100644 --- a/Text-Grab/Utilities/NotificationUtilities.cs +++ b/Text-Grab/Utilities/NotificationUtilities.cs @@ -1,6 +1,7 @@ using Microsoft.Toolkit.Uwp.Notifications; using System; using System.Text; +using System.Windows; namespace Text_Grab.Utilities; @@ -61,4 +62,83 @@ internal static void ShowToast(string copiedText) toast.Show(); } + + /// + /// Shows a toast for a finished audio transcription. Tapping it re-activates 's + /// — the one that actually received the transcript — via + /// , rather than opening a new window with the text + /// (which would also have to be truncated to fit the ~5000-byte toast payload limit). + /// + internal static void ShowTranscriptionCompleteToast(string fileDescription, Guid windowId) + { + new ToastContentBuilder() + .AddArgument("windowId", windowId.ToString()) + .AddText("Text Grab") + .AddText($"Transcription complete: {fileDescription}") + .Show(); + } + + /// + /// Shows a toast for a finished Local AI task (summarize, rewrite, translate, etc.). These run + /// with the owning disabled for the duration, so a user who has + /// switched away benefits from the same "tap to come back" behavior as + /// . + /// + internal static void ShowLocalAiCompleteToast(string taskDescription, Guid windowId) + { + new ToastContentBuilder() + .AddArgument("windowId", windowId.ToString()) + .AddText("Text Grab") + .AddText($"{taskDescription} complete") + .Show(); + } + + /// + /// Shows a toast for a Whisper model finishing a background "quick download" kicked off from a + /// transcription model flyout (see ), + /// so the user isn't left guessing whether a multi-hundred-MB download actually completed. + /// + internal static void ShowModelDownloadCompleteToast(string modelName, Guid windowId) + { + new ToastContentBuilder() + .AddArgument("windowId", windowId.ToString()) + .AddText("Text Grab") + .AddText($"\"{modelName}\" model ready") + .Show(); + } + + private const string WindowIdArgumentPrefix = "windowId="; + + /// + /// Handles a toast's windowId= activation argument (see ) + /// by re-activating the matching — the one that actually received the + /// transcript — instead of opening a new window. There are two toast-click entry points that both + /// need this: (COM activation, used when the app isn't + /// already running) and App.LaunchFromToast (fires in the already-running process). Returns + /// true if the argument was a windowId (handled either by activating the window or, if it was + /// already closed, by doing nothing) — callers should only fall back to their own "open a new + /// window" behavior when this returns false. + /// + internal static bool TryActivateTranscriptionWindow(string argsInvoked) + { + if (!argsInvoked.StartsWith(WindowIdArgumentPrefix, StringComparison.Ordinal) + || !Guid.TryParse(argsInvoked[WindowIdArgumentPrefix.Length..], out Guid windowId)) + { + return false; + } + + foreach (Window window in Application.Current.Windows) + { + if (window is EditTextWindow etw && etw.WindowId == windowId) + { + if (etw.WindowState == WindowState.Minimized) + etw.WindowState = WindowState.Normal; + etw.Activate(); + break; + } + } + + // Handled either way: if the window was already closed there's nothing to re-activate. + return true; + } } diff --git a/Text-Grab/Utilities/NotifyIconUtilities.cs b/Text-Grab/Utilities/NotifyIconUtilities.cs index 9bc10ba8..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: @@ -232,6 +232,20 @@ private static NotifyIconWindow CreateNotifyIconWindow() return notifyIconWindow; } + public static void RefreshTrayIconStyle() + { + // Windows theme changes are observed via a registry watcher that raises its event on a + // background thread, but NotifyIcon.Icon is a DependencyProperty owned by the UI thread. + System.Windows.Threading.Dispatcher dispatcher = Application.Current.Dispatcher; + if (!dispatcher.CheckAccess()) + { + dispatcher.BeginInvoke(RefreshTrayIconStyle); + return; + } + + GetExistingNotifyIconWindow()?.ApplyTrayIconStyle(); + } + private static NotifyIconWindow? GetExistingNotifyIconWindow() { return Application.Current.Windows.OfType().FirstOrDefault(); 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/OutputUtilities.cs b/Text-Grab/Utilities/OutputUtilities.cs index b2b518c9..75a5a852 100644 --- a/Text-Grab/Utilities/OutputUtilities.cs +++ b/Text-Grab/Utilities/OutputUtilities.cs @@ -1,6 +1,7 @@ using System.Windows; using System.Windows.Controls; using Text_Grab.Services; +using Text_Grab.Views; namespace Text_Grab.Utilities; @@ -13,6 +14,18 @@ public static void HandleTextFromOcr(string grabbedText, bool isSingleLine, bool if (destinationTextBox is not null) { + // A Spreadsheet-mode window's text box is hidden and its selection/cursor doesn't + // track the DataGrid's current cell, so route through the structured table model + // instead of splicing raw text at a stale cursor position (which corrupts whatever + // row happens to sit there). Applies regardless of whether table mode produced + // real column structure or a single plain-text value. + if (Window.GetWindow(destinationTextBox) is EditTextWindow { IsSpreadsheetMode: true } destinationEtw + && destinationEtw.TryInsertGrabbedTextIntoSpreadsheet(grabbedText)) + { + destinationTextBox.Focus(); + return; + } + // Do it this way instead of append text because it inserts the text at the cursor // Then puts the cursor at the end of the newly added text // AppendText() just adds the text to the end no matter what. diff --git a/Text-Grab/Utilities/PdfDocumentRenderer.cs b/Text-Grab/Utilities/PdfDocumentRenderer.cs index 3a60d4fa..6084260a 100644 --- a/Text-Grab/Utilities/PdfDocumentRenderer.cs +++ b/Text-Grab/Utilities/PdfDocumentRenderer.cs @@ -10,7 +10,7 @@ using Text_Grab.Models; using UglyToad.PdfPig.Content; using UglyToad.PdfPig.Core; -using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; +using UglyToad.PdfPig.Util; using Windows.Graphics.Imaging; using Windows.Storage; using Windows.Storage.Streams; @@ -28,11 +28,13 @@ public PdfPageContent( int pageIndex, BitmapSource renderedPage, IReadOnlyList nativeLines, + IReadOnlyList nativeWords, IReadOnlyList imageRegions) { PageIndex = pageIndex; RenderedPage = renderedPage; NativeLines = nativeLines; + NativeWords = nativeWords; ImageRegions = imageRegions; } @@ -42,6 +44,12 @@ public PdfPageContent( public IReadOnlyList NativeLines { get; } + /// + /// Individual native words (not grouped into lines), positioned in rendered-page + /// pixel space. Used for table detection, which needs per-word gaps to find columns. + /// + public IReadOnlyList NativeWords { get; } + public int PageIndex { get; } public BitmapSource RenderedPage { get; } @@ -113,7 +121,9 @@ public async Task ExtractTextAsync(ILanguage? language = null, GrabTempl else { IReadOnlyList lines = await GetSelectableLinesAsync(pageIndex, resolvedLanguage); - pageText = string.Join(Environment.NewLine, lines.Select(line => line.Text)); + pageText = BuildTextFromLines( + lines, + OcrUtilities.ShouldUseParagraphDetection(resolvedLanguage.IsSpaceJoining())); } if (string.IsNullOrWhiteSpace(pageText)) @@ -145,10 +155,12 @@ public async Task GetPageContentAsync(int pageIndex) BitmapImage renderedPage = await RenderPageBitmapAsync(renderPage); Page textPage = textDocument.GetPage(pageIndex + 1); - List nativeLines = ExtractNativeLines(textPage, renderedPage.PixelWidth, renderedPage.PixelHeight); + List<(Windows.Foundation.Rect SourceRect, string Text)> rawWords = ExtractRawWords(textPage, renderedPage.PixelWidth, renderedPage.PixelHeight); + List nativeLines = [.. GroupWordsIntoLines(rawWords)]; + List nativeWords = [.. rawWords.Select(word => new PdfPageTextLine(word.SourceRect, word.Text, isNativeText: true))]; List imageRegions = ExtractImageRegions(textPage, renderedPage.PixelWidth, renderedPage.PixelHeight); - PdfPageContent pageContent = new(pageIndex, renderedPage, nativeLines, imageRegions); + PdfPageContent pageContent = new(pageIndex, renderedPage, nativeLines, nativeWords, imageRegions); long pageSizeBytes = EstimateBitmapBytes(renderedPage); while (cacheOrder.First is not null @@ -190,6 +202,26 @@ public async Task> GetSelectableLinesAsync(int pa return SortLines(combinedLines); } + /// + /// Returns individual native words and OCR words from embedded images for table detection. + /// Bounds are in rendered-page pixel coordinates, independent of the OCR scale. + /// Empty when the page has no native text (e.g. a scanned page), since callers on + /// that path already fall back to word-level OCR of the rendered page. + /// + public async Task> GetSelectableWordsAsync(int pageIndex, ILanguage? language = null) + { + PdfPageContent pageContent = await GetPageContentAsync(pageIndex); + if (!pageContent.HasNativeText || pageContent.ImageRegions.Count == 0) + return pageContent.NativeWords; + + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetCurrentInputLanguage(); + using Bitmap bitmap = ImageMethods.BitmapSourceToBitmap(pageContent.RenderedPage); + // Recognize the page once so overlapping image regions do not duplicate words + // and all OCR bounds share the rendered page's origin. + (IOcrLinesWords? ocrResult, double scale) = await OcrSourceUtilities.GetOcrResultFromBitmapAsync(bitmap, resolvedLanguage); + return CombineNativeAndOcrWords(pageContent.NativeWords, pageContent.ImageRegions, ocrResult, scale); + } + public async Task RenderPageAsync(int pageIndex) { PdfPageContent pageContent = await GetPageContentAsync(pageIndex); @@ -246,6 +278,71 @@ internal static Windows.Foundation.Rect ConvertPdfRectToImageRect( return new Windows.Foundation.Rect(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top)); } + internal static string BuildTextFromLines( + IEnumerable lines, + bool useParagraphDetection) + { + List orderedLines = SortLines(lines) + .Where(line => !string.IsNullOrWhiteSpace(line.Text)) + .ToList(); + + if (orderedLines.Count == 0) + return string.Empty; + + StringBuilder text = new(orderedLines[0].Text); + + for (int i = 1; i < orderedLines.Count; i++) + { + PdfPageTextLine previousLine = orderedLines[i - 1]; + PdfPageTextLine currentLine = orderedLines[i]; + + if (useParagraphDetection && IsWrappedPdfLine(previousLine, currentLine)) + { + text.Append(' '); + } + else + { + text.AppendLine(); + } + + text.Append(currentLine.Text); + } + + return text.ToString(); + } + + private static bool IsWrappedPdfLine(PdfPageTextLine previousLine, PdfPageTextLine currentLine) + { + return IsWrappedPdfLine( + previousLine.SourceRect.Top, + previousLine.SourceRect.Height, + currentLine.SourceRect.Top, + currentLine.SourceRect.Height); + } + + internal static bool IsWrappedPdfLine( + double previousTop, + double previousHeight, + double currentTop, + double currentHeight) + { + if (previousHeight <= 0 || currentHeight <= 0) + return false; + + double minHeight = Math.Min(previousHeight, currentHeight); + double maxHeight = Math.Max(previousHeight, currentHeight); + if (maxHeight / minHeight > 1.5) + return false; + + double verticalAdvance = currentTop - previousTop; + if (verticalAdvance < minHeight * 0.5) + return false; + + double gap = verticalAdvance - previousHeight; + double averageLineHeight = (previousHeight + currentHeight) / 2; + return gap < averageLineHeight * 1.2; + } + internal static IReadOnlyList GroupWordsIntoLines(IEnumerable<(Windows.Foundation.Rect SourceRect, string Text)> words) { List<(Windows.Foundation.Rect SourceRect, string Text)> orderedWords = [.. words @@ -256,37 +353,33 @@ internal static IReadOnlyList GroupWordsIntoLines(IEnumerable<( if (orderedWords.Count == 0) return []; - List> groups = []; + List> rows = []; foreach ((Windows.Foundation.Rect SourceRect, string Text) word in orderedWords) { - if (groups.Count == 0) + List<(Windows.Foundation.Rect SourceRect, string Text)>? row = rows.FirstOrDefault(candidate => + { + Windows.Foundation.Rect rowBounds = GetBounds(candidate.Select(item => item.SourceRect)); + double overlap = Math.Min(rowBounds.Bottom, word.SourceRect.Bottom) - Math.Max(rowBounds.Top, word.SourceRect.Top); + double minHeight = Math.Min(rowBounds.Height, word.SourceRect.Height); + return minHeight > 0 && overlap >= minHeight * 0.5; + }); + + if (row is null) { - groups.Add([word]); + rows.Add([word]); continue; } - List<(Windows.Foundation.Rect SourceRect, string Text)> currentGroup = groups[^1]; - Windows.Foundation.Rect currentBounds = GetBounds(currentGroup.Select(item => item.SourceRect)); - double currentCenterY = currentBounds.Y + (currentBounds.Height / 2); - double wordCenterY = word.SourceRect.Y + (word.SourceRect.Height / 2); - double lineHeight = Math.Max(currentBounds.Height, word.SourceRect.Height); - double maxGap = lineHeight * 6; - double horizontalGap = Math.Max(0, word.SourceRect.X - currentBounds.Right); - bool sameBaseline = Math.Abs(wordCenterY - currentCenterY) <= lineHeight * 0.6; - - if (sameBaseline && horizontalGap <= maxGap) - currentGroup.Add(word); - else - groups.Add([word]); + row.Add(word); } List lines = []; - foreach (List<(Windows.Foundation.Rect SourceRect, string Text)> group in groups) + foreach (List<(Windows.Foundation.Rect SourceRect, string Text)> row in rows) { - List<(Windows.Foundation.Rect SourceRect, string Text)> orderedGroup = [.. group.OrderBy(item => item.SourceRect.X)]; - Windows.Foundation.Rect lineBounds = GetBounds(orderedGroup.Select(item => item.SourceRect)); - string text = string.Join(" ", orderedGroup.Select(item => item.Text.Trim())); + List<(Windows.Foundation.Rect SourceRect, string Text)> orderedRow = [.. row.OrderBy(item => item.SourceRect.X)]; + Windows.Foundation.Rect lineBounds = GetBounds(orderedRow.Select(item => item.SourceRect)); + string text = string.Join(" ", orderedRow.Select(item => item.Text.Trim())); lines.Add(new PdfPageTextLine(lineBounds, text, isNativeText: true)); } @@ -357,6 +450,41 @@ internal static bool ShouldIncludeOcrLine(Windows.Foundation.Rect sourceRect, IR return false; } + internal static IReadOnlyList CombineNativeAndOcrWords( + IReadOnlyList nativeWords, + IReadOnlyList imageRegions, + IOcrLinesWords? ocrResult, + double scale) + { + if (ocrResult is null || ocrResult.Lines.Length == 0 || imageRegions.Count == 0) + return nativeWords; + + List combinedWords = [.. nativeWords]; + // Native line bounds span column gaps that can contain image-only table cells. + IReadOnlyList nativeRects = [.. nativeWords.Select(word => word.SourceRect)]; + + foreach (IOcrWord ocrWord in ocrResult.Lines.SelectMany(line => line.Words)) + { + if (string.IsNullOrWhiteSpace(ocrWord.Text)) + continue; + + Windows.Foundation.Rect scaledRect = ocrWord.BoundingBox; + Windows.Foundation.Rect sourceRect = new( + scaledRect.X / scale, + scaledRect.Y / scale, + scaledRect.Width / scale, + scaledRect.Height / scale); + + if (!ShouldIncludeOcrLine(sourceRect, imageRegions) + || ShouldIncludeOcrLine(sourceRect, nativeRects)) + continue; + + combinedWords.Add(new PdfPageTextLine(sourceRect, ocrWord.Text.Trim(), isNativeText: false)); + } + + return SortLines(combinedWords); + } + private static PdfPageRenderOptions CreateRenderOptions(WinPdfPage page) { (uint width, uint height) = GetRenderDimensions(page.Size.Width, page.Size.Height); @@ -378,17 +506,15 @@ private static PdfPageRenderOptions CreateRenderOptions(WinPdfPage page) .Where(rect => rect.Width > 0 && rect.Height > 0)]; } - private static List ExtractNativeLines(Page textPage, int renderedWidth, int renderedHeight) + private static List<(Windows.Foundation.Rect SourceRect, string Text)> ExtractRawWords(Page textPage, int renderedWidth, int renderedHeight) { - List<(Windows.Foundation.Rect SourceRect, string Text)> words = [.. textPage - .GetWords(NearestNeighbourWordExtractor.Instance) + return [.. textPage + .GetWords(DefaultWordExtractor.Instance) .Where(word => !string.IsNullOrWhiteSpace(word.Text)) .Select(word => ( SourceRect: ConvertPdfRectToImageRect(word.BoundingBox, (double)textPage.Width, (double)textPage.Height, renderedWidth, renderedHeight), Text: word.Text.Trim())) .Where(word => word.SourceRect.Width > 0 && word.SourceRect.Height > 0)]; - - return [.. GroupWordsIntoLines(words)]; } private static Windows.Foundation.Rect GetBounds(IEnumerable rects) @@ -417,7 +543,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 +598,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 723dc416..45cf09a9 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -65,6 +65,15 @@ public static List GetDefaultPostGrabActions() { OrderNumber = 6.2 }, + new ButtonInfo( + buttonText: "Clean up text", + clickEvent: "CleanUpText_Click", + symbolIcon: SymbolRegular.TextClearFormatting24, + defaultCheckState: DefaultCheckState.Off + ) + { + OrderNumber = 6.25 + }, new ButtonInfo( buttonText: "Remove duplicate lines", clickEvent: "RemoveDuplicateLines_Click", @@ -187,15 +196,11 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, PostGr break; case "TrimEachLine_Click": - string[] stringSplit = text.Split(Environment.NewLine); - string[] trimmedLines = stringSplit - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Select(line => line.Trim()) - .ToArray(); - - result = trimmedLines.Length == 0 - ? string.Empty - : string.Join(Environment.NewLine, trimmedLines) + Environment.NewLine; + result = text.TrimEachLine(); + break; + + case "CleanUpText_Click": + result = text.CleanUpText(AppUtilities.ShouldCorrectToLatin()); break; case "RemoveDuplicateLines_Click": @@ -204,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 @@ -220,11 +225,23 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, PostGr break; case "Translate_Click": - if (WindowsAiUtilities.CanDeviceUseWinAI()) - { - string systemLanguage = LanguageUtilities.GetSystemLanguageForTranslation(); - result = await WindowsAiUtilities.TranslateText(text, systemLanguage); - } + string systemLanguage = LanguageUtilities.GetSystemLanguageForTranslation(); + TranslationResult translation = await WinAiTranslator.TranslateAsync(text, systemLanguage); + result = translation.Text; + + // The grab already happened, so a real failure must be reported or the user just + // sees untranslated text with no explanation. NotNeeded (the text is already in the + // target language) and Unavailable (no Windows AI on this device) are the normal + // case for most users on every single grab, and the text is unchanged either way — + // a modal there would fire after every grab and tell the user nothing. + if (!translation.Succeeded + && translation.Failure is not TranslationFailure.NotNeeded and not TranslationFailure.Unavailable) + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Failed", + Content = translation.Message ?? "The text could not be translated.", + CloseButtonText = "OK" + }.ShowDialogAsync(); break; case "ApplyTemplate_Click": 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..986c5952 --- /dev/null +++ b/Text-Grab/Utilities/ResultTableRenderer.cs @@ -0,0 +1,125 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using Text_Grab.Extensions; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +/// +/// Which corner of the table's bounding rectangle a drag handle controls. +/// +public enum TableBoundsCorner +{ + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +/// +/// 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 +{ + private const double HandleSize = 10; + + public static Canvas BuildTableLines(ResultTable table, bool includeBoundsHandles = false) + { + 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); + } + + if (includeBoundsHandles) + { + tableLines.Children.Add(BuildBoundsHandle(boundingRect.Left, boundingRect.Top, TableBoundsCorner.TopLeft, tableColor)); + tableLines.Children.Add(BuildBoundsHandle(boundingRect.Right, boundingRect.Top, TableBoundsCorner.TopRight, tableColor)); + tableLines.Children.Add(BuildBoundsHandle(boundingRect.Left, boundingRect.Bottom, TableBoundsCorner.BottomLeft, tableColor)); + tableLines.Children.Add(BuildBoundsHandle(boundingRect.Right, boundingRect.Bottom, TableBoundsCorner.BottomRight, tableColor)); + } + + return tableLines; + } + + private static Thumb BuildBoundsHandle(double centerX, double centerY, TableBoundsCorner corner, Brush fillBrush) + { + Thumb handle = new() + { + Width = HandleSize, + Height = HandleSize, + Tag = corner, + Cursor = corner is TableBoundsCorner.TopLeft or TableBoundsCorner.BottomRight + ? Cursors.SizeNWSE + : Cursors.SizeNESW, + Template = BuildHandleTemplate(fillBrush) + }; + + Canvas.SetLeft(handle, centerX - (HandleSize / 2)); + Canvas.SetTop(handle, centerY - (HandleSize / 2)); + + return handle; + } + + private static ControlTemplate BuildHandleTemplate(Brush fillBrush) + { + FrameworkElementFactory borderFactory = new(typeof(Border)); + borderFactory.SetValue(Border.BackgroundProperty, fillBrush); + borderFactory.SetValue(Border.BorderBrushProperty, Brushes.White); + borderFactory.SetValue(Border.BorderThicknessProperty, new Thickness(1.5)); + borderFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(2)); + + return new ControlTemplate(typeof(Thumb)) + { + VisualTree = borderFactory + }; + } +} 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/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs index ca8d2c3e..45e72caa 100644 --- a/Text-Grab/Utilities/WindowUtilities.cs +++ b/Text-Grab/Utilities/WindowUtilities.cs @@ -301,6 +301,19 @@ internal static bool ShouldOpenNewEtwInSpreadsheetMode(bool isTableModeSelected, return isTableModeSelected && !hasExistingEditTextWindow; } + /// + /// When a new grab (Grab Frame or Full Screen Grab) is launched from an Edit Text Window + /// that is already in Spreadsheet mode, Table mode should be preselected so the OCR + /// result comes back column-aware instead of being stuffed into a single cell. + /// + internal static bool ShouldForceTableModeForNewGrab( + bool hasDestinationTextBox, + bool isDestinationSpreadsheetMode, + bool isTableModeAvailable) + { + return hasDestinationTextBox && isDestinationSpreadsheetMode && isTableModeAvailable; + } + internal static EditTextWindow OpenOrActivateEditTextWindow(bool isTableModeSelected = false) { WindowCollection allWindows = Application.Current.Windows; @@ -335,6 +348,36 @@ internal static EditTextWindow OpenOrActivateEditTextWindow(bool isTableModeSele return newWindow; } + /// + /// Always opens a fresh Edit Text Window holding , optionally already + /// in spreadsheet mode, regardless of whether other Edit Text Windows are open. + /// + internal static EditTextWindow? OpenTextInNewEditTextWindow(string text, bool enterSpreadsheetMode = false) + { + EditTextWindow newWindow = new(text, isEncoded: false); + + try + { + // Switch modes before Show(): the mode switch only touches XAML elements that + // InitializeComponent() already wired up, and the text is parsed into cells on entry. + if (enterSpreadsheetMode) + newWindow.EnterSpreadsheetMode(); + + newWindow.Show(); + return newWindow; + } + catch (Exception ex) + { + _ = new Wpf.Ui.Controls.MessageBox + { + Title = ex.Message, + Content = "An error occurred while trying to open a new window. Please try again.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return null; + } + } + internal static T OpenOrActivateWindow() where T : Window, new() { WindowCollection allWindows = Application.Current.Windows; diff --git a/Text-Grab/Utilities/WindowsAiUtilities.cs b/Text-Grab/Utilities/WindowsAiUtilities.cs deleted file mode 100644 index 5c737127..00000000 --- a/Text-Grab/Utilities/WindowsAiUtilities.cs +++ /dev/null @@ -1,710 +0,0 @@ -using Microsoft.Graphics.Imaging; -using Microsoft.Windows.AI; -using Microsoft.Windows.AI.ContentSafety; -using Microsoft.Windows.AI.Imaging; -using Microsoft.Windows.AI.Text; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Drawing; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Text_Grab.Extensions; -using Text_Grab.Models; -using Text_Grab.Properties; -using Windows.Graphics.Imaging; - -namespace Text_Grab.Utilities; - -public static class WindowsAiUtilities -{ - private const string TranslationPromptTemplate = "Translate to {0} using local alphabet and characters of that langauage:\n\n{1}"; - private static LanguageModel? _translationLanguageModel; - private static readonly SemaphoreSlim _modelInitializationLock = new(1, 1); - private static bool _disposed; - - // Language code mapping for quick lookup - private static readonly Dictionary LanguageCodeMap = new(StringComparer.OrdinalIgnoreCase) - { - { "English", "en" }, - { "Spanish", "es" }, - { "French", "fr" }, - { "German", "de" }, - { "Italian", "it" }, - { "Portuguese", "pt" }, - { "Russian", "ru" }, - { "Japanese", "ja" }, - { "Chinese (Simplified)", "zh-Hans" }, - { "Chinese", "zh-Hans" }, - { "Korean", "ko" }, - { "Arabic", "ar" }, - { "Hindi", "hi" }, - }; - - /// - /// Quickly detects if text is likely in the target language using simple heuristics. - /// This is a fast check to avoid expensive translation calls. - /// - /// Text to analyze - /// Target language name (e.g., "English", "Spanish") - /// True if text appears to already be in target language - private static bool IsLikelyInTargetLanguage(string text, string targetLanguage) - { - if (string.IsNullOrWhiteSpace(text) || text.Length < 3) - return false; - - // Get language code for target - if (!LanguageCodeMap.TryGetValue(targetLanguage, out string? targetCode)) - return false; // Unknown language, proceed with translation - - // Character range detection - bool hasCJK = text.Any(c => c is >= (char)0x4E00 and <= (char)0x9FFF or // CJK Unified Ideographs - >= (char)0x3040 and <= (char)0x309F or // Hiragana - >= (char)0x30A0 and <= (char)0x30FF or // Katakana - >= (char)0xAC00 and <= (char)0xD7AF); // Hangul - - bool hasArabic = text.Any(c => c is >= (char)0x0600 and <= (char)0x06FF); - bool hasCyrillic = text.Any(c => c is >= (char)0x0400 and <= (char)0x04FF); - bool hasDevanagari = text.Any(c => c is >= (char)0x0900 and <= (char)0x097F); - bool hasLatin = text.Any(c => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z'); - - // Quick script-based checks - switch (targetCode) - { - case "en": - case "es": - case "fr": - case "de": - case "it": - case "pt": - // Latin script languages - if mostly CJK/Arabic/Cyrillic, definitely not in target - if (hasCJK || hasArabic || hasCyrillic || hasDevanagari) - return false; - // If has Latin characters, might be in target language - if (hasLatin && text.Length > 10 && targetCode == "en") - { - // Check for common English words as additional heuristic - string lowerText = text.ToLowerInvariant(); - string[] commonEnglishWords = [" the ", " and ", " or ", " is ", " are ", " was ", " were ", " in ", " on ", " at ", " to ", " of ", " for ", " with "]; - int englishWordCount = commonEnglishWords.Count(w => lowerText.Contains(w)); - // If text contains multiple common English words, likely already English - if (englishWordCount >= 2) - return true; - } - break; - - case "ru": - // Russian - should have Cyrillic - return hasCyrillic && !hasCJK && !hasArabic; - - case "ja": - // Japanese - should have Hiragana/Katakana/Kanji - return hasCJK && !hasArabic && !hasCyrillic; - - case "zh-Hans": - // Chinese - should have CJK - return hasCJK && !hasArabic && !hasCyrillic; - - case "ko": - // Korean - should have Hangul - return text.Any(c => c is >= (char)0xAC00 and <= (char)0xD7AF) && !hasArabic && !hasCyrillic; - - case "ar": - // Arabic - should have Arabic script - return hasArabic && !hasCJK && !hasCyrillic; - - case "hi": - // Hindi - should have Devanagari - return hasDevanagari && !hasCJK && !hasArabic; - } - - return false; - } - - public static bool CanDeviceUseWinAI() - { - return CanDeviceUseWinAiFeature(TextRecognizer.GetReadyState); - } - - public static bool CanDeviceDescribeImagesWithWinAI() - { - return CanDeviceUseWinAiFeature(ImageDescriptionGenerator.GetReadyState); - } - - private static bool CanDeviceUseWinAiFeature(Func getReadyState) - { - if (!MeetsWindowsAiPrerequisites()) - return false; - - try - { - return getReadyState() != AIFeatureReadyState.NotSupportedOnCurrentSystem; - } - catch (Exception) - { -#if DEBUG - throw; -#else - return false; -#endif - } - } - - private static bool MeetsWindowsAiPrerequisites() - { - // Check if the app is packaged and if the AI feature is supported - if (!AppUtilities.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) - return false; - - return true; - } - - public static async Task GetTextWithWinAI(string imagePath) - { - if (!CanDeviceUseWinAI()) - return "ERROR: Cannot use Windows AI on this device."; - - AIFeatureReadyState readyState = TextRecognizer.GetReadyState(); - if (readyState == AIFeatureReadyState.NotReady) - { - AIFeatureReadyResult op = await TextRecognizer.EnsureReadyAsync(); - } - - using TextRecognizer textRecognizer = await TextRecognizer.CreateAsync(); - - SoftwareBitmap bitmap = await imagePath.FilePathToSoftwareBitmapAsync(); - using ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(bitmap); - - RecognizedText? result = textRecognizer? - .RecognizeTextFromImage(imageBuffer); - - if (result is null || result.Lines is null) - return string.Empty; - - StringBuilder stringBuilder = new(); - - foreach (RecognizedLine? line in result.Lines) - stringBuilder.AppendLine(line.Text); - - return stringBuilder.ToString(); - } - - public static async Task GetTextDescriptionWithWinAI(string imagePath) - { - using SoftwareBitmap bitmap = await imagePath.FilePathToSoftwareBitmapAsync(); - return await GetTextDescriptionWithWinAI(bitmap); - } - - /// - /// Describes a with Windows AI. The - /// aborts the on-device inference; a cancelled call throws . - /// - public static async Task GetTextDescriptionWithWinAI(Bitmap bmp, CancellationToken cancellationToken) - { - string tempFilePath = AutomationProfile.GetTemporaryFilePath(".png"); - bmp.Save(tempFilePath, System.Drawing.Imaging.ImageFormat.Png); - try - { - using SoftwareBitmap softwareBitmap = await tempFilePath.FilePathToSoftwareBitmapAsync(); - return await GetTextDescriptionWithWinAI(softwareBitmap, cancellationToken); - } - finally - { - if (System.IO.File.Exists(tempFilePath)) - System.IO.File.Delete(tempFilePath); - } - } - - public static async Task GetTextDescriptionWithWinAI(SoftwareBitmap bitmap, CancellationToken cancellationToken = default) - { - // Return empty rather than an error message so callers treat this as a - // failed grab instead of committing the message as recognized text. - if (!CanDeviceDescribeImagesWithWinAI()) - return string.Empty; - - AIFeatureReadyState readyState = ImageDescriptionGenerator.GetReadyState(); - if (readyState == AIFeatureReadyState.NotReady) - { - // EnsureReadyAsync may download the model; thread the token so Cancel - // aborts the wait, and bail out if the feature still failed to get ready. - AIFeatureReadyResult readyResult = await ImageDescriptionGenerator.EnsureReadyAsync().AsTask(cancellationToken); - if (readyResult.Status != AIFeatureReadyResultState.Success) - { - Debug.WriteLine($"Image description model not ready: {readyResult.Status}"); - return string.Empty; - } - } - - cancellationToken.ThrowIfCancellationRequested(); - - using ImageDescriptionGenerator imageDescriptionGenerator = await ImageDescriptionGenerator.CreateAsync(); - using ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(bitmap); - return await GetTextDescriptionWithWinAI(imageDescriptionGenerator, imageBuffer, cancellationToken); - } - - private static async Task GetTextDescriptionWithWinAI(ImageDescriptionGenerator imageDescriptionGenerator, ImageBuffer imageBuffer, CancellationToken cancellationToken = default) - { - // Create content moderation thresholds object. - ContentFilterOptions filterOptions = new(); - filterOptions.ResponseMaxAllowedSeverityLevel.SelfHarm = SeverityLevel.Medium; - filterOptions.ResponseMaxAllowedSeverityLevel.Violent = SeverityLevel.Medium; - - try - { - // Get text description. Awaiting DescribeAsync already waits for the on-device - // inference to finish; AsTask threads the cancellation token so the model call - // itself is aborted when the user cancels. - ImageDescriptionResult languageModelResponse = await imageDescriptionGenerator.DescribeAsync( - imageBuffer, - ImageDescriptionKind.AccessibleDescription, - filterOptions).AsTask(cancellationToken); - - if (languageModelResponse.Status != ImageDescriptionResultStatus.Complete) - { - Debug.WriteLine($"Image description did not complete. Status: {languageModelResponse.Status}"); - return string.Empty; - } - - return languageModelResponse.Description?.Trim() ?? string.Empty; - } - catch (OperationCanceledException) - { - // Let cancellation propagate so callers can distinguish it from an empty result. - throw; - } - catch (Exception ex) - { - Debug.WriteLine($"Image description failed: {ex.Message}"); - return string.Empty; - } - } - - public static async Task GetOcrResultAsync(Bitmap bmp) - { - string tempFilePath = AutomationProfile.GetTemporaryFilePath(".png"); - bmp.Save(tempFilePath, System.Drawing.Imaging.ImageFormat.Png); - SoftwareBitmap softwareBitmap = await tempFilePath.FilePathToSoftwareBitmapAsync(); - - // for some reason "await bmp.CreateSoftwareBitmap()" does not work, so we use the file path method instead - RecognizedText? recognizedText = await GetOcrResultAsync(softwareBitmap); - - if (recognizedText is null) - return null; - - return new WinAiOcrLinesWords(recognizedText); - } - - public static async Task GetOcrResultAsync(SoftwareBitmap softwareBitmap) - { - if (!CanDeviceUseWinAI()) - return null; - - AIFeatureReadyState readyState = TextRecognizer.GetReadyState(); - if (readyState == AIFeatureReadyState.NotReady) - { - AIFeatureReadyResult op = await TextRecognizer.EnsureReadyAsync(); - } - - using TextRecognizer textRecognizer = await TextRecognizer.CreateAsync(); - ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(softwareBitmap); - - RecognizedText? result = textRecognizer? - .RecognizeTextFromImage(imageBuffer); - - return result; - } - - internal static async Task SummarizeParagraph(string textToSummarize) - { - using LanguageModel languageModel = await LanguageModel.CreateAsync(); - - TextSummarizer textSummarizer = new(languageModel); - - bool wasTruncated = false; - - // TODO: in WinAppSDK 1.8+ we can use this API when the GitHub Actions runner passes - // if (textSummarizer.IsPromptLargerThanContext(textToSummarize, out ulong cutOff)) - // { - // textToSummarize = textToSummarize[..(int)cutOff]; - // wasTruncated = true; - // } - - try - { - LanguageModelResponseResult result = await textSummarizer.SummarizeParagraphAsync(textToSummarize); - - if (result.Status == LanguageModelResponseStatus.Complete) - { - if (wasTruncated) - return $"NOTE: The input text was too long and had to be truncated.\n\nSummary:\n{result.Text}"; - else - return result.Text; - } - else - return $"ERROR: Unable to summarize text. {result.ExtendedError.Message}"; - } - catch (Exception ex) - { - return $"ERROR: Unable to summarize text. {ex.Message}"; - } - } - - internal static async Task Rewrite(string textToRewrite) - { - using LanguageModel languageModel = await LanguageModel.CreateAsync(); - - TextRewriter textRewriter = new(languageModel); - try - { - // TODO: in WinAppSDK 1.8+ we can use this API when the GitHub Actions runner passes - //LanguageModelResponseResult result = await textRewriter.RewriteAsync(textToRewrite, TextRewriteTone.Concise); - LanguageModelResponseResult result = await textRewriter.RewriteAsync(textToRewrite); - if (result.Status == LanguageModelResponseStatus.Complete) - { - return result.Text; - } - else - return $"ERROR: Unable to rewrite text. {result.ExtendedError.Message}"; - } - catch (Exception ex) - { - return $"ERROR: Failed to Rewrite: {ex.Message}"; - } - } - - internal static async Task TextToTable(string textToTable) - { - using LanguageModel languageModel = await LanguageModel.CreateAsync(); - - TextToTableConverter toTableConverter = new(languageModel); - try - { - TextToTableResponseResult result = await toTableConverter.ConvertAsync(textToTable); - if (result.Status == LanguageModelResponseStatus.Complete) - { - TextToTableRow[] rows = result.GetRows(); - StringBuilder sb = new(); - foreach (TextToTableRow row in rows) - { - string[] columns = row.GetColumns(); - sb.AppendLine(string.Join("\t", columns)); - } - return sb.ToString(); - } - else - return $"ERROR: Unable to rewrite text. {result.ExtendedError.Message}"; - } - catch (Exception ex) - { - return $"ERROR: Failed to Rewrite: {ex.Message}"; - } - } - - /// - /// Cleans up translation result by removing instruction echoes and unwanted prefixes. - /// - private static string CleanTranslationResult(string translatedText, string originalText) - { - if (string.IsNullOrWhiteSpace(translatedText)) - return originalText; - - string cleaned = translatedText.Trim(); - - // Remove common instruction echoes (case-insensitive) - string[] instructionPhrases = - [ - "translate", - "translation", - "translated", - "do not reply", - "do not respond", - "extraneous content", - "besides the translated text", - "other than the translated text", - "here is the translation", - "here's the translation", - "the translation is", - ]; - - string lowerCleaned = cleaned.ToLowerInvariant(); - - // If the result contains instruction-like phrases, try to extract just the translation - if (instructionPhrases.Any(phrase => lowerCleaned.Contains(phrase))) - { - // Split by common delimiters and take the longest non-instruction part - string[] parts = cleaned.Split(['\n', '.', ':', '"'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - string? bestPart = null; - int maxLength = 0; - - foreach (string part in parts) - { - string lowerPart = part.ToLowerInvariant(); - bool hasInstructions = instructionPhrases.Any(phrase => lowerPart.Contains(phrase)); - - if (!hasInstructions && part.Length > maxLength && part.Length >= 3) - { - bestPart = part; - maxLength = part.Length; - } - } - - if (bestPart != null && bestPart.Length > originalText.Length / 3) - { - cleaned = bestPart.Trim(); - } - else - { - // Couldn't extract clean translation, return original - Debug.WriteLine($"Translation contained instructions, returning original text"); - return originalText; - } - } - - // Remove common prefixes that might leak through - string[] commonPrefixes = - [ - "translation: ", - "translated: ", - "result: ", - "output: ", - ]; - - foreach (string prefix in commonPrefixes.Where(prefix => cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) - { - cleaned = cleaned[prefix.Length..].Trim(); - } - - // If cleaned result is suspiciously short or empty, return original - if (string.IsNullOrWhiteSpace(cleaned) || cleaned.Length < 2) - { - Debug.WriteLine($"Translation result too short, returning original text"); - return originalText; - } - - return cleaned; - } - - /// - /// Initializes the shared LanguageModel for translation if not already created. - /// Thread-safe initialization using SemaphoreSlim. - /// - private static async Task EnsureTranslationModelInitializedAsync() - { - if (_translationLanguageModel is not null) - return; - - await _modelInitializationLock.WaitAsync(); - try - { - _translationLanguageModel ??= await LanguageModel.CreateAsync(); - } - finally - { - _modelInitializationLock.Release(); - } - } - - /// - /// Disposes the shared LanguageModel to free resources. - /// Should be called when translation is no longer needed. - /// - public static void DisposeTranslationModel() - { - _translationLanguageModel?.Dispose(); - _translationLanguageModel = null; - } - - /// - /// Releases resources held by static members of . - /// Should be called once during application shutdown. - /// - public static void Cleanup() - { - if (_disposed) - return; - - DisposeTranslationModel(); - _modelInitializationLock.Dispose(); - _disposed = true; - } - - - /// - /// Translates text to a target language using Windows AI LanguageModel. - /// Reuses a shared LanguageModel instance for improved performance. - /// Includes fast language detection to skip translation if text is already in target language. - /// Filters out instruction echoes from AI responses. - /// - /// The text to translate - /// The target language (e.g., "English", "Spanish") - /// The translated text, or the original text if translation fails or is unnecessary - /// - /// This implementation uses TextRewriter with a custom prompt as a workaround - /// since Microsoft.Windows.AI.Text doesn't include a dedicated translation API. - /// Translation quality may vary compared to dedicated translation services. - /// The LanguageModel is reused across calls for better performance. - /// Fast language detection is performed first to avoid unnecessary API calls. - /// Result is cleaned to remove any instruction echoes from the AI response. - /// - internal static async Task TranslateText(string textToTranslate, string targetLanguage) - { - if (!CanDeviceUseWinAI()) - return textToTranslate; // Return original text if Windows AI is not available - - // Quick check: if text appears to already be in target language, skip translation - if (IsLikelyInTargetLanguage(textToTranslate, targetLanguage)) - { - Debug.WriteLine($"Skipping translation - text appears to already be in {targetLanguage}"); - return textToTranslate; - } - - try - { - await EnsureTranslationModelInitializedAsync(); - - if (_translationLanguageModel is null) - return textToTranslate; - - // Note: This uses TextRewriter with a simple prompt - // We use a minimal prompt to reduce the chance of instruction echoes - TextRewriter textRewriter = new(_translationLanguageModel); - string translationPrompt = string.Format(TranslationPromptTemplate, targetLanguage, textToTranslate); - - LanguageModelResponseResult result = await textRewriter.RewriteAsync(translationPrompt); - - if (result.Status == LanguageModelResponseStatus.Complete) - { - // Clean the result to remove any instruction echoes - string cleanedResult = CleanTranslationResult(result.Text, textToTranslate); - return cleanedResult; - } - else - { - // Log the error if debugging is enabled - Debug.WriteLine($"Translation failed with status: {result.Status}"); - if (result.ExtendedError != null) - Debug.WriteLine($"Translation error: {result.ExtendedError.Message}"); - return textToTranslate; // Return original text on error - } - } - catch (Exception ex) - { - // Log the exception for debugging - Debug.WriteLine($"Translation exception: {ex.Message}"); - return textToTranslate; // Return original text on error - } - } - - /// - /// Extracts a regular expression pattern from text using Windows AI LanguageModel. - /// - /// The text describing what to match or containing example text to match - /// A regular expression pattern string, or empty string if extraction fails - /// - /// This method uses the LanguageModel to generate a regex pattern based on the input text. - /// The result is cleaned to contain only the regex pattern without explanations or formatting. - /// - internal static async Task ExtractRegex(string textDescription) - { - if (!CanDeviceUseWinAI()) - return string.Empty; - - if (string.IsNullOrWhiteSpace(textDescription)) - return string.Empty; - - try - { - using LanguageModel languageModel = await LanguageModel.CreateAsync(); - TextRewriter textRewriter = new(languageModel); - - string regexPrompt = $"Generate a general regular expression pattern (regex) for: {textDescription}\n\nDo not make it overly constrained on the exact text.\n\nReturn ONLY the regex pattern, nothing else."; - - LanguageModelResponseResult result = await textRewriter.RewriteAsync(regexPrompt); - - if (result.Status == LanguageModelResponseStatus.Complete) - { - return CleanRegexResult(result.Text); - } - else - { - Debug.WriteLine($"Regex extraction failed with status: {result.Status}"); - if (result.ExtendedError != null) - Debug.WriteLine($"Regex extraction error: {result.ExtendedError.Message}"); - return string.Empty; - } - } - catch (Exception ex) - { - Debug.WriteLine($"Regex extraction exception: {ex.Message}"); - return string.Empty; - } - } - - /// - /// Cleans the AI-generated regex result by removing markdown formatting, code blocks, and explanations. - /// - /// The raw AI response containing the regex pattern - /// The cleaned regex pattern string - public static string CleanRegexResult(string regexText) - { - if (string.IsNullOrWhiteSpace(regexText)) - return string.Empty; - - string cleaned = regexText.Trim(); - - // Remove markdown code blocks - if (cleaned.StartsWith("```")) - { - // Remove opening code fence - int firstNewline = cleaned.IndexOf('\n'); - if (firstNewline > 0) - cleaned = cleaned[(firstNewline + 1)..]; - - // Remove closing code fence - if (cleaned.EndsWith("```")) - cleaned = cleaned[..^3]; - - cleaned = cleaned.Trim(); - } - - // Remove backticks - cleaned = cleaned.Trim('`'); - - // Split by newlines and process lines - string[] lines = cleaned.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - // Find the first line that looks like a regex pattern - string? regexPattern = lines - .Select(line => line.Trim()) - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Where(line => !line.StartsWith("//", StringComparison.Ordinal) && - !line.StartsWith('#') && - !line.StartsWith("Expression:", StringComparison.OrdinalIgnoreCase)) - .Select(line => - { - // Remove common prefixes - if (line.StartsWith("regex:", StringComparison.OrdinalIgnoreCase)) - return line[6..].Trim(); - else if (line.StartsWith("pattern:", StringComparison.OrdinalIgnoreCase)) - return line[8..].Trim(); - return line; - }) - .FirstOrDefault(line => line.Length > 0 && - (line.Contains('[') || line.Contains('(') || - line.Contains('\\') || line.Contains('^') || line.Contains('$') || - line.Contains('+') || line.Contains('*') || line.Contains('?') || - line.Contains('|') || line.Contains('.'))); - - // If a regex pattern was found, return it; otherwise return the cleaned text as-is - return regexPattern ?? cleaned; - } -} diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 156b3435..2ab3d018 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -8,12 +8,12 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Edit Text" - AutomationProperties.AutomationId="EditTextWindow" Width="800" Height="500" MinWidth="200" MinHeight="200" Activated="Window_Activated" + AutomationProperties.AutomationId="EditTextWindow" Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" Closed="Window_Closed" Closing="Window_Closing" @@ -175,11 +175,11 @@ Command="{x:Static local:EditTextWindow.TransposeTableCmd}" Executed="TransposeTableExecuted" /> @@ -192,11 +192,11 @@ @@ -288,6 +288,11 @@ x:Name="_TrimEachLineMenuItem" Click="TrimEachLineMenuItem_Click" Header="_Trim Each Line" /> + + + + + + + + + + + + + + + + + + + + - + + Header="New Window with Selected _Text" + InputGestureText="Ctrl + N" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Views/OpenMediaWindow.xaml.cs b/Text-Grab/Views/OpenMediaWindow.xaml.cs new file mode 100644 index 00000000..27e4f0c3 --- /dev/null +++ b/Text-Grab/Views/OpenMediaWindow.xaml.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using Text_Grab.Utilities; +using Wpf.Ui.Controls; + +namespace Text_Grab.Views; + +public partial class OpenMediaWindow : FluentWindow +{ + private string? selectedFilePath; + private EditTextWindow? transcribingOwner; + + public OpenMediaWindow() + { + InitializeComponent(); + App.SetTheme(); + + NotifyOnCompleteToggle.IsChecked = AppUtilities.TextGrabSettings.NotifyOnTranscriptionComplete; + IncludeTimecodesToggle.IsChecked = AppUtilities.TextGrabSettings.IncludeTimecodesInTranscription; + + WhisperModelChoice currentChoice = AudioTranscriptionUtilities.CurrentModelChoice; + foreach (ComboBoxItem item in ModelComboBox.Items) + { + if (item.Tag is not string tag) + continue; + + item.Content = WhisperModelInfo.DisplayNameWithSize(WhisperModelInfo.Parse(tag)); + if (tag == currentChoice.ToString()) + ModelComboBox.SelectedItem = item; + } + + UpdateModelDetails(currentChoice); + } + + private void BrowseButton_Click(object sender, RoutedEventArgs e) + { + Microsoft.Win32.OpenFileDialog dlg = new() + { + Filter = AudioTranscriptionUtilities.GetAudioFileFilter(), + DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + }; + + bool? result = dlg.ShowDialog(); + if (result is true) + UpdateFileInfo(dlg.FileName); + } + + private void UpdateFileInfo(string path) + { + FilePathTextBox.Text = path; + FileErrorText.Visibility = Visibility.Collapsed; + FileInfoPanel.Visibility = Visibility.Collapsed; + selectedFilePath = null; + StartTranscriptionButton.IsEnabled = false; + + try + { + AudioTranscriptionUtilities.AudioFileInfo info = AudioTranscriptionUtilities.GetAudioFileInfo(path); + + FileNameText.Text = info.FileName; + FileSizeText.Text = $"Size: {info.FileSizeBytes / (1024.0 * 1024.0):0.#} MB"; + FileDurationText.Text = $"Duration: {AudioTranscriptionUtilities.FormatTimecode(info.Duration)}"; + FileInfoPanel.Visibility = Visibility.Visible; + + selectedFilePath = path; + StartTranscriptionButton.IsEnabled = true; + } + catch (Exception ex) + { + FileErrorText.Text = $"⚠ Couldn't read this file: {ex.Message}"; + FileErrorText.Visibility = Visibility.Visible; + } + } + + private void HotWordsLookupButton_Click(object sender, RoutedEventArgs e) + { + QuickSimpleLookup qsl = new() + { + DestinationTextBox = HotWordsTextBox, + IsPickerMode = true, + }; + qsl.Owner = this; + qsl.Show(); + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + if (transcribingOwner is not null) + { + // A transcription is running: stop it instead of just closing over it. The window + // closes itself once StartTranscriptionButton_Click's await returns. + transcribingOwner.CancelAudioTranscription(); + CancelButton.IsEnabled = false; + TranscribingStatusText.Text = "Cancelling…"; + return; + } + + Close(); + } + + private void NotifyOnCompleteToggle_Checked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.NotifyOnTranscriptionComplete = true; + AppUtilities.TextGrabSettings.Save(); + } + + private void NotifyOnCompleteToggle_Unchecked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.NotifyOnTranscriptionComplete = false; + AppUtilities.TextGrabSettings.Save(); + } + + private void IncludeTimecodesToggle_Checked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.IncludeTimecodesInTranscription = true; + AppUtilities.TextGrabSettings.Save(); + } + + private void IncludeTimecodesToggle_Unchecked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.IncludeTimecodesInTranscription = false; + AppUtilities.TextGrabSettings.Save(); + } + + private void ModelComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ModelComboBox.SelectedItem is not ComboBoxItem item || item.Tag is not string tag) + return; + + WhisperModelChoice choice = WhisperModelInfo.Parse(tag); + + AppUtilities.TextGrabSettings.AudioTranscriptionModel = tag; + AppUtilities.TextGrabSettings.Save(); + UpdateModelDetails(choice); + } + + /// Fills in the language/accuracy/download-size details panel for the given model. + private void UpdateModelDetails(WhisperModelChoice choice) + { + ModelLanguageText.Text = WhisperModelInfo.LanguageSummary(choice); + ModelDescriptionText.Text = WhisperModelInfo.Description(choice); + + long? downloadedBytes = AudioTranscriptionUtilities.DownloadedModelSizeBytes(choice); + ModelDownloadStatusText.Text = downloadedBytes is long bytes + ? $"Already downloaded — {bytes / (1024.0 * 1024.0):0.#} MB on disk." + : $"Not downloaded yet ({WhisperModelInfo.ApproxDownloadSize(choice)}) — it will download automatically the first time you use it."; + } + + private async void StartTranscriptionButton_Click(object sender, RoutedEventArgs e) + { + if (Owner is EditTextWindow etw && selectedFilePath is not null) + { + transcribingOwner = etw; + etw.Activate(); + SetTranscribingState(true); + + Progress progress = new(fraction => + { + TranscribingProgressBar.Value = fraction * 100; + TranscribingStatusText.Text = $"Transcribing… {fraction:P0}"; + }); + + await etw.TranscribeAudioFilesAsync([selectedFilePath], HotWordsTextBox.Text.Trim(), progress); + } + + Close(); + } + + /// + /// Toggles this window between "pick a file" and "transcription in progress": inputs and the + /// Start button are disabled/hidden, and the Cancel button switches to cancelling the running + /// transcription (owned by the main editor window) rather than just closing over it. + /// + private void SetTranscribingState(bool transcribing) + { + BrowseButton.IsEnabled = !transcribing; + ModelComboBox.IsEnabled = !transcribing; + HotWordsTextBox.IsEnabled = !transcribing; + HotWordsLookupButton.IsEnabled = !transcribing; + NotifyOnCompleteToggle.IsEnabled = !transcribing; + IncludeTimecodesToggle.IsEnabled = !transcribing; + + StartTranscriptionButton.Visibility = transcribing ? Visibility.Collapsed : Visibility.Visible; + TranscribingPanel.Visibility = transcribing ? Visibility.Visible : Visibility.Collapsed; + TranscribingProgressBar.Value = 0; + TranscribingStatusText.Text = "Transcribing…"; + CancelButton.IsEnabled = true; + } +} diff --git a/Text-Grab/Views/QuickSimpleLookup.xaml.cs b/Text-Grab/Views/QuickSimpleLookup.xaml.cs index c28adf32..63b903f3 100644 --- a/Text-Grab/Views/QuickSimpleLookup.xaml.cs +++ b/Text-Grab/Views/QuickSimpleLookup.xaml.cs @@ -59,6 +59,14 @@ private async void SearchBar_SearchChanged(object? sender, EventArgs e) public bool IsEditingDataGrid { get; set; } = false; public bool IsFromETW { get; set; } = false; + + /// + /// When set, a pick always writes straight into and closes the + /// window, regardless of EditWindowToggleButton — for callers (like the audio hot-words + /// picker) that want QSL purely as a value picker, not the ETW insert/clipboard flow. + /// + public bool IsPickerMode { get; set; } = false; + public List ItemsDictionary { get; set; } = []; #endregion Properties @@ -88,9 +96,15 @@ private static LookupItem ParseStringToLookupItem(char splitChar, string row) if (cells.FirstOrDefault() is string firstCell) newRow.ShortValue = firstCell; + // CSV rows are written as "ShortValue,LongValue" with no quoting/escaping (see + // LookupItem.ToCSVString), so a LongValue that itself contains commas splits into more than + // two cells here. Rejoin with the same delimiter to reconstitute the original value instead of + // losing the commas (space-joining is still correct for tab-split rows: typed/pasted multi-cell + // entries are meant to read as one space-separated phrase, not regain literal tab characters). + string joinSeparator = splitChar == ',' ? "," : " "; newRow.LongValue = ""; if (cells.Count > 1 && cells[1] is not null) - newRow.LongValue = string.Join(" ", cells.Skip(1).ToArray()); + newRow.LongValue = string.Join(joinSeparator, cells.Skip(1).ToArray()); newRow.Kind = kind; return newRow; @@ -569,7 +583,7 @@ private async void PutValueIntoClipboard(KeyboardModifiersDown? keysDown = null) if (stringBuilder.Length > 3 && stringBuilder.ToString().EndsWith("\r\n")) stringBuilder.Remove(stringBuilder.Length - 2, 2); - if (DestinationTextBox is not null && EditWindowToggleButton.IsChecked is true) + if (DestinationTextBox is not null && (IsPickerMode || EditWindowToggleButton.IsChecked is true)) { // Do it this way instead of append text because it inserts the text at the cursor // Then puts the cursor at the end of the newly added text diff --git a/Text-Grab/Views/SettingsWindow.xaml b/Text-Grab/Views/SettingsWindow.xaml index f4c5da9f..988983ca 100644 --- a/Text-Grab/Views/SettingsWindow.xaml +++ b/Text-Grab/Views/SettingsWindow.xaml @@ -9,13 +9,13 @@ xmlns:pages="clr-namespace:Text_Grab.Pages" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Text Grab Settings" - AutomationProperties.AutomationId="SettingsWindow" - Width="660" + Width="800" Height="700" MinWidth="200" MinHeight="300" d:Background="Black" d:Height="600" + AutomationProperties.AutomationId="SettingsWindow" Closed="Window_Closed" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Loaded="Window_Loaded" @@ -53,12 +53,15 @@ Icon="{StaticResource TextGrabIcon}" /> - + - + Fullscreen @@ -83,7 +89,10 @@ - + Grab Frame @@ -94,7 +103,10 @@ - + Quick Simple @@ -107,7 +119,10 @@ - + Edit Text @@ -120,7 +135,10 @@ - + - + Keyboard @@ -145,7 +166,10 @@ - + - + Voice @@ -170,10 +197,28 @@ + + + + + + + + + - + . The publisher ID is +the hash half of the package family name (`40087JoeFinApps.TextGrab_`). + +**The token is a secret. It must never be committed to this repository.** + +## The two values + +| Name | Meaning | +| --- | --- | +| `LAF_TOKEN` | The unlock token Microsoft issued for `com.microsoft.windows.ai.languagemodel`. | +| `LAF_PUBLISHER_ID` | The publisher ID the token was issued against, used to build the usage string. | + +Set `LAF_PUBLISHER_ID` explicitly rather than relying on the fallback. `LimitedAccessFeatureUtilities` +derives it from `Package.Current.Id.FamilyName` when it is unset, and a locally sideloaded MSIX +signed with a development certificate has a *different* publisher hash than the Store package — so +the fallback would build a usage string the token was not issued for. + +## Local development + +Persist both values for your user account once, then restart Visual Studio or your shell so it picks +them up: + +```powershell +setx LAF_TOKEN "" +setx LAF_PUBLISHER_ID "" +``` + +`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 + +The properties can also be passed directly, which is what CI does: + +```powershell +dotnet build Text-Grab/Text-Grab.csproj -p:LafToken="" -p:LafPublisherId="" +``` + +The project maps them into assembly metadata: + +| MSBuild property | `AssemblyMetadata` key | +| --- | --- | +| `LafToken` | `LAF_TOKEN` | +| `LafPublisherId` | `LAF_PUBLISHER_ID` | + +At runtime `LimitedAccessFeatureUtilities.GetSetting` reads the assembly metadata first and falls +back to the environment variables, so a build with neither still runs — it just reports the feature +as unavailable instead of crashing. + +## CI + +`Release.yml` and `buildDev.yml` read the repository secrets `LAF_TOKEN` and `LAF_PUBLISHER_ID` and +forward them to every `dotnet publish` step. Add them under **Settings → Secrets and variables → +Actions**. If they are missing the build still succeeds; the published binaries simply ship without +working on-device text AI. 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 diff --git a/global.json b/global.json index a5bf76b6..7a33100e 100644 --- a/global.json +++ b/global.json @@ -3,5 +3,8 @@ "version": "10.0.100", "allowPrerelease": false, "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file