diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 210267d5..e7e464a3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,11 @@ "Bash(curl -o bin/pdm https://app.produckmap.com/cli/pdm)", "Bash(chmod +x bin/pdm)", "Bash(bin/pdm ui-element:*)", - "Bash(bin/pdm *)" + "Bash(pdm api *)", + "Bash(bin/pdm *)", + "Bash(Get-ChildItem -Path \"D:/source/TheJoeFin/Text-Grab/Text-Grab/\" -Directory)", + "Bash(Select-Object Name)", + "PowerShell(dotnet build *)" ], "deny": [] } diff --git a/.codetesting/AnalysisReport_20260125_220624_678.md b/.codetesting/AnalysisReport_20260125_220624_678.md deleted file mode 100644 index bef7b7d5..00000000 --- a/.codetesting/AnalysisReport_20260125_220624_678.md +++ /dev/null @@ -1,20 +0,0 @@ -# Test Failures due possible code bugs - -## Tests.csproj - Text_Grab.Utilities.UnitTests.WindowsAiUtilitiesTests.CleanRegexResult_OnlyOpeningFence_ReturnsPattern -- **Confidence**: High -- **Test File**: Tests\Utilities\WindowsAiUtilitiesTests.cs -- **Bug Location**: Text-Grab\Utilities\WindowsAiUtilities.cs@588-592 - -### Analysis -The production code has a logical error in the CleanRegexResult method. The Where clause at lines 588-592 filters out lines starting with 'Pattern:' (case-insensitive) BEFORE the Select clause at lines 593-601 can remove the 'pattern:' prefix. When the input is '```\npattern: [a-z]+', after removing the opening fence, we have 'pattern: [a-z]+'. This line gets filtered out by the Where clause because it starts with 'Pattern:' (case-insensitive), so the Select clause never gets a chance to remove the prefix. The method then returns the cleaned text as-is ('pattern: [a-z]+') instead of the extracted pattern ('[a-z]+'). The fix is to remove the filtering of 'Pattern:', 'Regex:', and 'Expression:' from the Where clause (lines 590-592), allowing the Select clause to handle prefix removal. - -### Suggested Fix -In the CleanRegexResult method at D:\source\TheJoeFin\Text-Grab\Text-Grab\Utilities\WindowsAiUtilities.cs, remove lines 590-592 from the Where clause. The Where clause should only filter out comment lines (lines starting with '//' or '#'), not descriptor lines like 'Regex:', 'Pattern:', or 'Expression:', since the subsequent Select clause is designed to handle removing these prefixes. The corrected Where clause should be: - -```csharp -.Where(line => !line.StartsWith("//", StringComparison.Ordinal) && - !line.StartsWith('#')) -``` - -This allows lines with 'pattern:', 'regex:', or 'expression:' prefixes to reach the Select clause where the prefixes are properly removed. - diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ac1c778f..19ea836c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # Text Grab - GitHub Copilot Instructions -Text Grab is a Windows-specific .NET 9.0 WPF OCR (Optical Character Recognition) application that extracts text from images using Windows APIs. It provides multiple modes for text capture including full-screen grab, grab frame, edit text window, and quick lookup. +Text Grab is a Windows-specific .NET 10.0 WPF OCR (Optical Character Recognition) application that extracts text from images using Windows APIs. It provides multiple modes for text capture including full-screen grab, grab frame, edit text window, and quick lookup. **ALWAYS reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.** @@ -17,15 +17,15 @@ Text Grab is a Windows-specific .NET 9.0 WPF OCR (Optical Character Recognition) ### Prerequisites (Windows Only) For full development on Windows: - Windows 10/11 with Windows 10 SDK 22621.0 -- Visual Studio 2019/2022 with workloads: +- Visual Studio 2022 with workloads: - "Universal Windows Platform Development" - ".NET desktop development" - ".NET cross-platform development" -- **OR** .NET 9.0 SDK: https://dotnet.microsoft.com/download/dotnet/9.0 +- **OR** .NET 10.0 SDK: https://dotnet.microsoft.com/download/dotnet/10.0 ### Cross-Platform Dependency Validation For non-Windows environments (validation only): -- Install .NET 9.0: `curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 9.0.101` +- Install .NET 10.0: `curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 10.0.100` - Add to PATH: `export PATH="$HOME/.dotnet:$PATH"` ### Build Commands @@ -49,7 +49,7 @@ For non-Windows environments (validation only): ### Running the Application (Windows Only) - Debug in Visual Studio: Set Text-Grab-Package as startup project, press F5 - Command line debug: `dotnet run --project Text-Grab/Text-Grab.csproj` -- Production executable: `Text-Grab/bin/Release/net9.0-windows10.0.22621.0/Text-Grab.exe` +- Production executable: `Text-Grab/bin/Release/net10.0-windows10.0.22621.0/Text-Grab.exe` ### CLI Usage (Windows Only) The application supports command-line arguments: @@ -83,7 +83,7 @@ The application supports command-line arguments: ## Key Project Structure ### Primary Components -- **Text-Grab/**: Main WPF application (.NET 9.0) +- **Text-Grab/**: Main WPF application (.NET 10.0) - **Text-Grab-Package/**: Windows application packaging project (.wapproj) - **Tests/**: XUnit test suite with WPF support - **.github/workflows/buildDev.yml**: CI/CD pipeline (Windows-only) @@ -158,7 +158,7 @@ dotnet test Tests/Tests.csproj .\build-unpackaged.ps1 # Non-Windows Validation Only (ALWAYS include -p:EnableWindowsTargeting=true) -curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 9.0.101 +curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 10.0.100 export PATH="$HOME/.dotnet:$PATH" dotnet restore Text-Grab.sln -p:EnableWindowsTargeting=true # Note: Full build will fail - only restore and dependency validation possible @@ -173,4 +173,4 @@ dotnet restore Text-Grab.sln -p:EnableWindowsTargeting=true - **Performance** matters for OCR operations - profile changes that affect image processing - **Package references** - only add new package references when absolutely needed or explicitly asked -Remember: This is a Windows-native application leveraging platform-specific APIs. Development and testing should primarily occur on Windows systems. \ No newline at end of file +Remember: This is a Windows-native application leveraging platform-specific APIs. Development and testing should primarily occur on Windows systems. diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index db3fba66..b51c1e06 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -36,12 +36,12 @@ jobs: build: runs-on: windows-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + global-json-file: global.json - name: Install dependencies run: dotnet restore ${{ env.PROJECT_PATH }} @@ -231,25 +231,25 @@ jobs: } - name: Upload build artifact (x64 framework-dependent) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Text-Grab-win-x64-framework-dependent path: ${{ env.BUILD_X64 }} - name: Upload build artifact (x64 self-contained) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Text-Grab-win-x64-self-contained path: ${{ steps.compute.outputs.archive_x64_sc }} - name: Upload build artifact (ARM64 framework-dependent) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Text-Grab-win-arm64-framework-dependent path: ${{ env.BUILD_ARM64 }} - name: Upload build artifact (ARM64 self-contained) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Text-Grab-win-arm64-self-contained path: ${{ steps.compute.outputs.archive_arm64_sc }} diff --git a/.github/workflows/buildDev.yml b/.github/workflows/buildDev.yml index 71e04cf9..a72cfec5 100644 --- a/.github/workflows/buildDev.yml +++ b/.github/workflows/buildDev.yml @@ -17,11 +17,11 @@ jobs: build: runs-on: windows-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: "9.0.x" + global-json-file: global.json - name: Install dependencies run: dotnet restore ${{ env.PROJECT_PATH }} - name: Build @@ -33,7 +33,7 @@ jobs: run: dotnet publish ${{ env.PROJECT_PATH }} -c Release --self-contained -r win-x64 -p:PublishSingleFile=true -p:EnableMsixTooling=true -o publish - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Text-Grab path: .\publish diff --git a/.vscode/launch.json b/.vscode/launch.json index 6eb83965..039c01fd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Text-Grab/bin/Debug/net6.0-windows10.0.20348.0/Text-Grab.exe", + "program": "${workspaceFolder}/Text-Grab/bin/Debug/net10.0-windows10.0.22621.0/Text-Grab.exe", "args": [], "cwd": "${workspaceFolder}/Text-Grab", "console": "internalConsole", @@ -21,4 +21,4 @@ "request": "attach" } ] -} \ No newline at end of file +} diff --git a/BUILT-WITH.md b/BUILT-WITH.md new file mode 100644 index 00000000..9ef27731 --- /dev/null +++ b/BUILT-WITH.md @@ -0,0 +1,39 @@ +# Built With + +Text Grab depends on the direct NuGet packages listed below. + +- **Scope** identifies whether a package is used by the app, the tests, or both. +- **Notice** links to a bundled local notice file when one ships with Text Grab, or to the upstream project license file otherwise. +- **Project** links to the upstream project home or repository. +- Test-only packages are documented for completeness, but they are not part of normal end-user app builds. + +The same package inventory is also available from the app's **About → Licenses** flow. + +| Package | Version | Scope | License | Notice | Project | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| CliWrap | 3.10.1 | App | MIT | [Open](https://github.com/Tyrrrz/CliWrap/blob/master/License.txt) | [Project](https://github.com/Tyrrrz/CliWrap) | — | +| Dapplo.Windows.User32 | 2.0.89 | App | MIT | [Open](https://github.com/dapplo/Dapplo.Windows/blob/master/LICENSE) | [Project](https://github.com/dapplo/Dapplo.Windows) | — | +| Humanizer.Core | 3.0.10 | App | MIT | [Open](https://github.com/Humanizr/Humanizer/blob/main/license.txt) | [Project](https://github.com/Humanizr/Humanizer) | — | +| Magick.NET-Q16-AnyCPU | 14.12.0 | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | +| Magick.NET.SystemDrawing | 8.0.20 | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | +| Magick.NET.SystemWindowsMedia | 8.0.20 | App | Apache-2.0 | [Open](https://github.com/dlemstra/Magick.NET/blob/main/License.txt) | [Project](https://github.com/dlemstra/Magick.NET) | — | +| Markdig | 1.1.3 | App | BSD-2-Clause | [Open](ThirdPartyNotices/licenses/Markdig-license.txt) | [Project](https://github.com/xoofx/markdig) | Bundled to satisfy BSD-2-Clause binary redistribution notice requirements. | +| Microsoft.Toolkit.Uwp.Notifications | 7.1.3 | App | MIT | [Open](https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/License.md) | [Project](https://github.com/CommunityToolkit/WindowsCommunityToolkit) | — | +| Microsoft.WindowsAppSDK.AI | 1.8.70 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| Microsoft.WindowsAppSDK.Foundation | 1.8.260415000 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| Microsoft.WindowsAppSDK.Runtime | 1.8.260416003 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| Microsoft.WindowsAppSDK.WinUI | 1.8.260415005 | App | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt) | [Project](https://github.com/microsoft/windowsappsdk) | Package ships Microsoft Windows App SDK license terms. | +| NCalcAsync | 5.12.0 | App, Tests | MIT | [Open](https://github.com/ncalc/ncalc/blob/master/LICENSE) | [Project](https://github.com/ncalc/ncalc) | Shared by the application and the test project. | +| PdfPig | 0.1.14 | App | Apache-2.0 | [Open](https://github.com/UglyToad/PdfPig/blob/master/LICENSE) | [Project](https://github.com/UglyToad/PdfPig) | — | +| UnitsNet | 5.75.0 | App | MIT-0 | [Open](https://github.com/angularsen/UnitsNet/blob/master/LICENSE) | [Project](https://github.com/angularsen/UnitsNet) | — | +| WPF-UI | 4.2.1 | App | MIT | [Open](https://github.com/lepoco/wpfui/blob/main/LICENSE) | [Project](https://github.com/lepoco/wpfui) | — | +| WPF-UI.Tray | 4.2.1 | App | MIT | [Open](https://github.com/lepoco/wpfui/blob/main/LICENSE) | [Project](https://github.com/lepoco/wpfui) | — | +| ZXing.Net | 0.16.11 | App | Apache-2.0 | [Open](https://github.com/micjahn/ZXing.Net/blob/master/COPYING) | [Project](https://github.com/micjahn/ZXing.Net) | — | +| ZXing.Net.Bindings.Windows.Compatibility | 0.16.14 | App | Apache-2.0 | [Open](https://github.com/micjahn/ZXing.Net/blob/master/COPYING) | [Project](https://github.com/micjahn/ZXing.Net) | — | +| BenchmarkDotNet | 0.15.8 | Tests | MIT | [Open](https://github.com/dotnet/BenchmarkDotNet/blob/master/LICENSE.md) | [Project](https://github.com/dotnet/BenchmarkDotNet) | Test-only dependency. | +| coverlet.collector | 10.0.0 | Tests | MIT | [Open](https://github.com/coverlet-coverage/coverlet/blob/master/LICENSE) | [Project](https://github.com/coverlet-coverage/coverlet) | Test-only dependency. | +| Microsoft.NET.Test.Sdk | 18.4.0 | Tests | MIT | [Open](https://github.com/microsoft/vstest/blob/main/LICENSE) | [Project](https://github.com/microsoft/vstest) | Test-only dependency. | +| Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers | 18.7.37220.1 | Tests | Microsoft license terms | [Open](ThirdPartyNotices/licenses/Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md) | [Project](https://learn.microsoft.com/visualstudio/profiling/) | Visual Studio benchmarking tooling; test-only dependency. | +| xunit.runner.visualstudio | 3.1.5 | Tests | Apache-2.0 | [Open](https://github.com/xunit/visualstudio.xunit/blob/main/License.txt) | [Project](https://github.com/xunit/visualstudio.xunit) | Test-only dependency. | +| Xunit.StaFact | 3.0.13 | Tests | MS-PL | [Open](https://github.com/AArnott/Xunit.StaFact/blob/main/LICENSE) | [Project](https://github.com/AArnott/Xunit.StaFact) | Test-only dependency. | +| xunit.v3 | 3.2.2 | Tests | Apache-2.0 | [Open](https://github.com/xunit/xunit/blob/main/LICENSE) | [Project](https://github.com/xunit/xunit) | Test-only dependency. | diff --git a/README.md b/README.md index d04fa79f..71b91fc0 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ This is a minimal optical character recognition (OCR) utility for Windows 10/11 which makes all visible text available to be copied. -Too often text is trapped within images, videos, or within parts of applications and cannot be selected. Text Grab takes a screenshot, passes that image to the OCR engine, then puts the text into the clipboard for use anywhere. The OCR is done locally by [Windows API](https://docs.microsoft.com/en-us/uwp/api/Windows.Media.Ocr). This enables Text Grab to have essentially no UI and not require a constantly running background process. Working with text can be much more than just copying text from images, so Text Grab has a range of different modes to make working with text fast and easy. +Too often text is trapped within images, videos, or parts of applications and cannot be selected. Text Grab takes a screenshot, passes that image to the OCR engine, then puts the text into the clipboard for use anywhere. The OCR is done locally by the [Windows OCR API](https://learn.microsoft.com/uwp/api/windows.media.ocr). This enables Text Grab to have essentially no UI and not require a constantly running background process. Working with text can be much more than just copying text from images, so Text Grab has a range of different modes to make working with text fast and easy. -I am the author of the [PowerToy Text Extractor](https://learn.microsoft.com/en-us/windows/powertoys/text-extractor). The Full-Screen Grab mode of this app was the basis of that PowerToy +I am the author of the [PowerToys Text Extractor](https://learn.microsoft.com/en-us/windows/powertoys/text-extractor). The Full-Screen Grab mode of this app was the basis for that PowerToy. ## How to Install @@ -40,26 +40,34 @@ I am the author of the [PowerToy Text Extractor](https://learn.microsoft.com/en- - [choco](https://community.chocolatey.org) - `choco install text-grab` ## How to Build + +Text Grab is a Windows application, so build and test it on Windows. + Get the code: -- Install git: https://git-scm.com/download/win -- git clone https://github.com/TheJoeFin/Text-Grab.git +- Install Git: https://git-scm.com/download/win + - `winget install git.git` +- `git clone https://github.com/TheJoeFin/Text-Grab.git` -### With Visual Studio 2019 or 2022 -- Install the Visual Studio (the free community edition is sufficient). - - Install the "Universal Windows Platform Development" workload. +### With Visual Studio 2022 +- Install Visual Studio 2022 (the free Community edition is sufficient). + - Install the "Universal Windows Platform development" workload. - Install the ".NET desktop development" workload. - - Install ".NET cross-platform development" toolset - - Install Windows 10 SDK (10.0.19041.0) -- Open `\Text-Grab\Text-Grab.sln` in Visual Studio. -- Set Text-Grab-Package as Startup Project -- Set CPU Target to x86 or x64 -- Key F5 or Press "▶ Local Machine" - -### With Visual Studio Code (VS Code) -- Install Visual Studio Code https://code.visualstudio.com/ -- Install .NET 6.0 SDK https://dotnet.microsoft.com/download/dotnet/6.0 -- Open `\Text-Grab\` Folder in VS Code (Same folder as .sln file) -- Key F5 to launch with debugger + - Install the ".NET cross-platform development" workload. + - Install Windows 10 SDK `10.0.22621.0` +- Open `Text-Grab.sln` in Visual Studio. +- Set `Text-Grab-Package` as the startup project. +- Set the CPU target to `x64` or `ARM64`. +- Press `F5` or choose **Local Machine**. + +### With the .NET SDK or Visual Studio Code +- Install the .NET 10 SDK: https://dotnet.microsoft.com/download/dotnet/10.0 +- This repository pins SDK `10.0.100` in `global.json`. +- Optional for debugging: install Visual Studio Code https://code.visualstudio.com/ and the C# extension / C# Dev Kit. +- Open the `Text-Grab` folder in VS Code. +- Restore dependencies with `dotnet restore Text-Grab.sln` +- Build with `dotnet build Text-Grab\Text-Grab.csproj` +- Run tests with `dotnet test Tests\Tests.csproj` +- In VS Code, press `F5` to launch with the included debug configuration. ## Text Grab has Four Modes @@ -82,9 +90,9 @@ The underlying OCR technology is the same as the full screen mode and has all of ### 3. Edit Text Window -Similar to Notepad, the Edit Text Window is a "Pure Text" editing experience, with no formatting. This means copying text into or out of the Window will remove all formatting, but linebreaks and tabs will remain. Gather text using Full Screen Grabs or Grab Frames. +Similar to Notepad, the Edit Text Window is a pure-text editing experience with no formatting. This means copying text into or out of the window removes formatting, but line breaks and tabs remain. Gather text using Full-Screen Grabs or Grab Frames. -There are several tools with in the Edit Text Window which make it quick and easy to fix or change text. +There are several tools within the Edit Text Window which make it quick and easy to fix or change text. - List files and folders in chosen directory - Watch clipboard for changes - Make text into a single line @@ -103,7 +111,7 @@ There are several tools with in the Edit Text Window which make it quick and eas ### 4. Quick Simple Lookup ![Quick Simple Lookup](images/Quick-Simple-Lookup.gif) -This mode of Text Grab is not about OCR, but instead it is about retreiving frequently used text. Think of Quick Simple Lookup as your long term memory. Use it to store frequently used URLs, emails, part numbers, etc. Basically a custom dictionary you can edit and recall instantly at any time. The workflow for Quick Simple Lookup is designed to be fast and functional, here is how it works. +This mode of Text Grab is not about OCR, but instead it is about retrieving frequently used text. Think of Quick Simple Lookup as your long-term memory. Use it to store frequently used URLs, emails, part numbers, and more. It is basically a custom dictionary you can edit and recall instantly at any time. The workflow for Quick Simple Lookup is designed to be fast and functional: 1. Press the hotkey (Default is Win + Shift + Q) 2. Begin typing to filter the lookup to the item you want @@ -111,15 +119,18 @@ This mode of Text Grab is not about OCR, but instead it is about retreiving freq 4. Then paste the value you just copied into the application you are using -### Bonus. Command Line Interface +## Command Line Interface Arguments - `Fullscreen` launches into Fullscreen Grab mode - `GrabFrame` launches a new Grab Frame - `EditText` launches a new Edit Text Window -- "Settings` opens Text Grab settings -- `"file path"` Text Grab will open the file if it is a Text file, but if it is an image file it will OCR the file and place the results into a new Edit Text Window. -- `"folder path"` e.g. `.\Text-Grab.exe "C:\Users\myPC\Downloads"` Text Grab will launch a new Edit Text Window and scan all images in that directory. +- `QuickLookup` launches Quick Simple Lookup +- `Settings` opens Text Grab settings +- `--grabframe "file path"` opens a supported image or PDF directly in Grab Frame +- `--windowless "file path"` reads or OCRs a file and copies the resulting text without opening a window +- `"file path"` opens text files in Edit Text and opens supported image or PDF files in Grab Frame +- `"folder path"` e.g. `.\Text-Grab.exe "C:\Users\myPC\Downloads"` launches a new Edit Text Window and scans the images in that directory ## Principles Text Grab is designed to have multiple modes, from minimal to fully featured; all focused on productivity. By using Windows 10’s OCR capabilities Text Grab can launch quickly without needing to run in the background. Pinning Text Grab to the Taskbar enables launching via keyboard shortcut. Now with version 2.4 when the background process is enabled Text Grab can be activated from anywhere using global hotkeys. The full-screen mode is designed to be used hundreds of times a day. Reducing clicks and menus means saving time, which is the primary focus of Text Grab. The Grab Frame tool can be positioned on top of any text content for quick OCR any time. When it comes to manipulating the text you've copied the Edit Text Window offers a wide range of tools to speed up common tasks and take the raw text into clean usable content. @@ -130,6 +141,8 @@ Text Grab is designed to have multiple modes, from minimal to fully featured; al - CliWrap: https://github.com/Tyrrrz/CliWrap - Microsoft Community Toolkit: https://github.com/CommunityToolkit +For the current direct NuGet dependency list and local third-party license notices, see [BUILT-WITH.md](BUILT-WITH.md). + ### Thanks for using Text Grab Hopefully this simple app makes you more productive and saves you time from transcribing text. If you have any questions or feedback reach out on Twitter [@TheJoeFin](http://www.twitter.com/thejoefin) or by email joe@textgrab.net diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index 2490cff2..77357fec 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -13,7 +13,7 @@ public async Task NCalc_HasBuiltInPi_ReturnsFalse() // Test if NCalc has built-in Pi constant AsyncExpression expression = new("Pi"); - NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync(async () => await expression.EvaluateAsync()); + NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync(async () => await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Contains("Pi", exception.Message); } @@ -23,7 +23,7 @@ public async Task NCalc_HasBuiltInE_ReturnsFalse() // Test if NCalc has built-in E constant AsyncExpression expression = new("E"); - NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync(async () => await expression.EvaluateAsync()); + NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync(async () => await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Contains("E", exception.Message); } @@ -57,7 +57,7 @@ public async Task NCalc_SupportsBasicMathFunctions() }; } - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Equal(expected, result, 10); // 10 decimal places precision } @@ -75,7 +75,7 @@ public async Task NCalc_WithCustomPiParameter_Works() return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Equal(1.0, result, 10); } @@ -92,7 +92,7 @@ public async Task NCalc_WithCustomEParameter_Works() return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Equal(1.0, result, 10); } @@ -113,7 +113,7 @@ public async Task NCalc_WithMultipleMathConstants_Works() return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); double expected = Math.PI * Math.E; Assert.Equal(expected, result, 10); @@ -149,7 +149,7 @@ public async Task NCalc_WithTauConstant_Works() return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Equal(Math.PI, result, 10); } @@ -185,7 +185,7 @@ public async Task NCalc_CaseInsensitive_MathConstants() return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Equal(expectedValue, result, 10); } @@ -207,7 +207,7 @@ public async Task NCalc_ComplexMathExpression_WithConstants() return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); // Sin(π/6) + Cos(π/3) + Log_e(e) = 0.5 + 0.5 + 1 = 2.0 Assert.Equal(2.0, result, 10); @@ -230,7 +230,7 @@ public async Task AsyncNCalc_WithMathConstants_Works() return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); double expected = Math.Sqrt(Math.PI * Math.E); Assert.Equal(expected, result, 10); @@ -274,7 +274,7 @@ public async Task MathConstants_Integration_Test(string constantName, double exp return ValueTask.CompletedTask; }; - double result = Convert.ToDouble(await expression.EvaluateAsync()); + double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken)); Assert.Equal(expectedValue, result, 5); // 5 decimal places precision for constants } @@ -1523,7 +1523,7 @@ public async Task QuantityParser_Octillion_ParsesCorrectly(string input) // For very large numbers like octillion (10^27), expect some precision loss // Just verify we get a number in the right ballpark (starts with 1 or 2) string cleanResult = result.Output.Replace(",", "").Replace(".0", ""); - Assert.True(cleanResult.StartsWith("1") || cleanResult.StartsWith("2"), + Assert.True(cleanResult.StartsWith('1') || cleanResult.StartsWith('2'), $"Expected result to start with 1 or 2, got: {cleanResult}"); Assert.True(cleanResult.Length >= 27, $"Expected at least 27 digits for octillion, got: {cleanResult.Length}"); @@ -3151,6 +3151,32 @@ public async Task DateTimeMath_DateSubtraction_SingularUnits() Assert.Equal(0, result.ErrorCount); } + [Fact] + public async Task DateTimeMath_DateSubtraction_TargetUnitWeeks() + { + CalculationService service = new(); + string input = "5-14-26 - 1-12-25 in weeks"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + Assert.Contains("weeks", result.Output); + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], 69.57, 69.58); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_TargetUnitDays() + { + CalculationService service = new(); + string input = "March 10, 2026 - January 1, 2026 to days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + Assert.Equal("68 days", result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(68, result.OutputNumbers[0], 3); + Assert.Equal(0, result.ErrorCount); + } + #endregion Date Subtraction (Date - Date = Timespan) Tests #region Date Operator Continuation Tests @@ -3232,6 +3258,15 @@ public async Task CalculationService_DateWithTime_OperatorContinuation() Assert.Equal(0, result.ErrorCount); } + [Fact] + public void TryEvaluateDateTimeMath_DurationConversion_ReturnsTrue() + { + bool matched = CalculationService.TryEvaluateDateTimeMath("3.6 years to days", out string result); + + Assert.True(matched); + Assert.Contains("days", result); + } + [Fact] public async Task CalculationService_DateContinuation_CommentDoesNotResetDate() { diff --git a/Tests/CaptureLanguageUtilitiesTests.cs b/Tests/CaptureLanguageUtilitiesTests.cs index b992513d..290b777b 100644 --- a/Tests/CaptureLanguageUtilitiesTests.cs +++ b/Tests/CaptureLanguageUtilitiesTests.cs @@ -9,15 +9,18 @@ namespace Tests; public class CaptureLanguageUtilitiesTests : IDisposable { private readonly bool _originalUiAutomationEnabled; + private readonly bool _originalWindowsAiDescriptionEnabled; public CaptureLanguageUtilitiesTests() { _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + _originalWindowsAiDescriptionEnabled = Settings.Default.WindowsAiDescriptionEnabled; } public void Dispose() { Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.WindowsAiDescriptionEnabled = _originalWindowsAiDescriptionEnabled; Settings.Default.Save(); LanguageUtilities.InvalidateAllCaches(); } @@ -42,6 +45,16 @@ public void MatchesPersistedLanguage_MatchesLegacyTesseractDisplayName() Assert.True(matches); } + [Fact] + public void MatchesPersistedLanguage_MatchesWindowsAiDescriptionTag() + { + WindowsAiDescriptionLang language = new(); + + bool matches = CaptureLanguageUtilities.MatchesPersistedLanguage(language, WindowsAiDescriptionLang.Tag); + + Assert.True(matches); + } + [Fact] public void FindPreferredLanguageIndex_PrefersPersistedMatchBeforeFallbackLanguage() { @@ -84,12 +97,45 @@ public async Task GetCaptureLanguagesAsync_IncludesUiAutomationWhenEnabled() Assert.Contains(languages, language => language is UiAutomationLang); } + [WpfFact] + public async Task GetCaptureLanguagesAsync_ExcludesWindowsAiDescriptionByDefault() + { + Settings.Default.WindowsAiDescriptionEnabled = false; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + Assert.DoesNotContain(languages, language => language is WindowsAiDescriptionLang); + } + + [WpfFact] + public async Task GetCaptureLanguagesAsync_IncludesWindowsAiDescriptionOnlyWhenSupported() + { + Settings.Default.WindowsAiDescriptionEnabled = true; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + List languages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(includeTesseract: false); + + if (WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) + Assert.Contains(languages, language => language is WindowsAiDescriptionLang); + else + Assert.DoesNotContain(languages, language => language is WindowsAiDescriptionLang); + } + [Fact] public void SupportsTableOutput_ReturnsFalseForUiAutomation() { Assert.False(CaptureLanguageUtilities.SupportsTableOutput(new UiAutomationLang())); } + [Fact] + public void SupportsTableOutput_ReturnsFalseForWindowsAiDescription() + { + Assert.False(CaptureLanguageUtilities.SupportsTableOutput(new WindowsAiDescriptionLang())); + } + [Fact] public void RequiresLiveUiAutomationSource_ReturnsTrueForStaticUiAutomationWithoutSnapshot() { diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs new file mode 100644 index 00000000..f88de72c --- /dev/null +++ b/Tests/ClipboardUtilitiesTests.cs @@ -0,0 +1,137 @@ +using Text_Grab.Utilities; + +namespace Tests; + +public class ClipboardUtilitiesTests +{ + private const string SampleCfHtml = """ + Version:1.0 + StartHTML:00000097 + EndHTML:00002353 + StartFragment:00000153 + EndFragment:00002320 + + + + + + + + + + + + + + + + + + +
MonthIntSeason
January1Winter
February2Winter
+ + + """; + + [Fact] + public void ConvertHtmlToTabSeparated_ParsesBasicTable() + { + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(SampleCfHtml); + + string[] lines = result.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("Month\tInt\tSeason", lines[0]); + Assert.Equal("January\t1\tWinter", lines[1]); + Assert.Equal("February\t2\tWinter", lines[2]); + } + + [Fact] + public void ConvertHtmlToTabSeparated_HandlesBrTag() + { + string html = """ + + +
4
A
Spring
+ """; + + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + + Assert.Equal("4 A\tSpring", result); + } + + [Fact] + public void ConvertHtmlToTabSeparated_ReturnsEmptyWhenNoTable() + { + string html = "

No table here

"; + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + Assert.Empty(result); + } + + [Fact] + public void ConvertHtmlToTabSeparated_DecodesHtmlEntities() + { + string html = """ + + +
A & B<tag>
+ """; + + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + + Assert.Equal("A & B\t", result); + } + + [Fact] + public void ConvertHtmlToTabSeparated_HandlesThElements() + { + string html = """ + + + +
NameValue
Foo42
+ """; + + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + + string[] lines = result.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Name\tValue", lines[0]); + Assert.Equal("Foo\t42", lines[1]); + } + + [Fact] + public void ConvertHtmlToTabSeparated_HandlesColspan() + { + string html = """ + + + +
MergedRight
ABC
+ """; + + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + + string[] lines = result.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Merged\tMerged\tRight", lines[0]); + Assert.Equal("A\tB\tC", lines[1]); + } + + [Fact] + public void ConvertHtmlToTabSeparated_HandlesRowspan() + { + string html = """ + + + +
TallTop
Bottom
+ """; + + string result = ClipboardUtilities.ConvertHtmlToTabSeparated(html); + + string[] lines = result.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Tall\tTop", lines[0]); + Assert.Equal("Tall\tBottom", lines[1]); + } +} diff --git a/Tests/DiagnosticsTests.cs b/Tests/DiagnosticsTests.cs index cafd2b8b..6af12e93 100644 --- a/Tests/DiagnosticsTests.cs +++ b/Tests/DiagnosticsTests.cs @@ -103,6 +103,7 @@ public async Task BugReport_SettingsInfo_ContainsAllKeySettings() // OCR Assert.Contains("\"correctErrors\"", bugReport); Assert.Contains("\"correctToLatin\"", bugReport); + Assert.Contains("\"paragraphDetection\"", bugReport); Assert.Contains("\"useTesseract\"", bugReport); Assert.Contains("\"tesseractPathConfigured\"", bugReport); // bool only — no path exposed Assert.Contains("\"uiAutomationEnabled\"", bugReport); diff --git a/Tests/EditTextTableDocumentTests.cs b/Tests/EditTextTableDocumentTests.cs new file mode 100644 index 00000000..d81f228a --- /dev/null +++ b/Tests/EditTextTableDocumentTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextTableDocumentTests +{ + [Fact] + public void Tsv_RoundTrips_WithoutMinimumGridPadding() + { + const string input = "Name\tValue\r\nAlpha\t42"; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.Tsv, document.Format); + Assert.Equal("\r\n", document.NewLineSequence); + Assert.Equal(input, document.SerializeToText()); + } + + [Fact] + public void Csv_QuotedFields_RoundTrip() + { + const string input = "Name,Notes\r\nJoe,\"Hello, \"\"world\"\"\""; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.Csv, document.Format); + Assert.Equal(input, document.SerializeToText()); + } + + [Fact] + public void Xml_FlattensRows_AndSerializesAttributesAndChildren() + { + const string input = "Alpha42Beta99"; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.Xml, document.Format); + Assert.Equal(["@id", "name", "value"], document.ColumnNames.Take(3).ToList()); + Assert.Equal("1", document.Rows[0][0]); + Assert.Equal("Alpha", document.Rows[0][1]); + Assert.Contains("id=\"1\"", document.SerializeToText()); + Assert.Contains("Alpha", document.SerializeToText()); + } + + [Fact] + public void PlainText_PreservesNewLineStyle() + { + const string input = "first\nsecond\nthird"; + + EditTextTableDocument document = EditTextTableDocument.CreateFromText(input); + + Assert.Equal(EtwStructuredTextFormat.PlainText, document.Format); + Assert.Equal("\n", document.NewLineSequence); + Assert.Equal(input, document.SerializeToText()); + } + + [Fact] + public void AddedRowsAndColumns_ExpandSerializedDocument_NotMinimumCapacity() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB"); + + document.InsertColumn(2); + document.InsertRow(1); + document.Rows[0][2] = "C"; + document.Rows[1][0] = "D"; + document.Rows[1][1] = "E"; + document.Rows[1][2] = "F"; + + Assert.Equal("A\tB\tC\r\nD\tE\tF", document.SerializeToText()); + } + + [Fact] + public void SerializedJson_RestoresLogicalDimensions() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("left\tright"); + document.InsertColumn(2); + document.Rows[0][2] = "extra"; + document.SetColumnWidth(0, 180); + document.SetRowHeight(0, 36); + document.SetCellWrap(0, 1, true); + + string json = document.SerializeToJson(); + EditTextTableDocument? restored = EditTextTableDocument.TryDeserialize(json); + + Assert.NotNull(restored); + Assert.Equal(document.RowCount, restored!.RowCount); + Assert.Equal(document.ColumnCount, restored.ColumnCount); + Assert.Equal(document.SerializeToText(), restored.SerializeToText()); + Assert.Equal(180, restored.ColumnWidths[0]); + Assert.Equal(36, restored.RowHeights[0]); + Assert.True(restored.IsCellWrapped(0, 1)); + Assert.True(JsonDocument.Parse(json).RootElement.TryGetProperty("ColumnCount", out _)); + } + + [Fact] + public void MoveAndDeleteRow_UpdateLogicalOrdering() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\t1\r\nB\t2\r\nC\t3"); + + document.MoveRow(2, 0); + document.DeleteRow(1); + + Assert.Equal("C\t3\r\nB\t2", document.SerializeToText()); + } + + [Fact] + public void MoveAndDeleteColumn_UpdateLogicalOrdering() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB\tC"); + + document.MoveColumn(2, 0); + document.DeleteColumn(1); + + Assert.Equal("C\tB", document.SerializeToText()); + } + + [Fact] + public void ViewMetrics_MoveWithRowsAndColumns() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB\r\nC\tD"); + document.SetColumnWidth(0, 140); + document.SetColumnWidth(1, 220); + document.SetRowHeight(0, 30); + document.SetRowHeight(1, 44); + + document.MoveColumn(1, 0); + document.MoveRow(1, 0); + + Assert.Equal(220, document.ColumnWidths[0]); + Assert.Equal(140, document.ColumnWidths[1]); + Assert.Equal(44, document.RowHeights[0]); + Assert.Equal(30, document.RowHeights[1]); + } + + [Fact] + public void ApplyViewMetricsFrom_PreservesExistingSizing() + { + EditTextTableDocument source = EditTextTableDocument.CreateFromText("A\tB\r\nC\tD"); + source.SetColumnWidth(0, 160); + source.SetColumnWidth(1, 240); + source.SetRowHeight(0, 28); + source.SetRowHeight(1, 40); + source.SetCellWrap(1, 1, true); + + EditTextTableDocument target = EditTextTableDocument.CreateFromText("1\t2\r\n3\t4\r\n5\t6"); + target.ApplyViewMetricsFrom(source); + + Assert.Equal(160, target.ColumnWidths[0]); + Assert.Equal(240, target.ColumnWidths[1]); + Assert.Equal(28, target.RowHeights[0]); + Assert.Equal(40, target.RowHeights[1]); + Assert.Null(target.RowHeights[2]); + Assert.True(target.IsCellWrapped(1, 1)); + } + + [Fact] + public void Transpose_SwapsRowsAndColumns_AndResetsViewMetrics() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText( + "A\tB\tC\r\n1\t2\t3", + minimumRowCount: 2, + minimumColumnCount: 3); + document.SetColumnWidth(0, 180); + document.SetRowHeight(0, 36); + document.SetCellWrap(0, 2, true); + + document.Transpose(); + + Assert.Equal("A\t1\r\nB\t2\r\nC\t3", document.SerializeToText()); + Assert.Equal(3, document.RowCount); + Assert.Equal(2, document.ColumnCount); + Assert.Equal(3, document.MinimumRowCount); + Assert.Equal(2, document.MinimumColumnCount); + Assert.All(document.ColumnWidths.Take(document.ColumnCount), width => Assert.Null(width)); + Assert.All(document.RowHeights.Take(document.RowCount), height => Assert.Null(height)); + Assert.True(document.IsCellWrapped(2, 0)); + } + + [Fact] + public void WrappedCells_MoveWithInsertedMovedAndDeletedRowsAndColumns() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("A\tB\tC\r\n1\t2\t3\r\nx\ty\tz"); + document.SetCellWrap(1, 1, true); + + document.InsertRow(1); + document.InsertColumn(1); + Assert.True(document.IsCellWrapped(2, 2)); + + document.MoveRow(2, 0); + document.MoveColumn(2, 0); + Assert.True(document.IsCellWrapped(0, 0)); + + document.DeleteRow(0); + document.DeleteColumn(0); + Assert.False(document.WrappedCells.Any()); + } +} diff --git a/Tests/EditTextWindowActionCatalogTests.cs b/Tests/EditTextWindowActionCatalogTests.cs new file mode 100644 index 00000000..b94fb194 --- /dev/null +++ b/Tests/EditTextWindowActionCatalogTests.cs @@ -0,0 +1,28 @@ +using System.Reflection; +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextWindowActionCatalogTests +{ + private readonly record struct ExpectedButtonAction(string ButtonText, string? Command = null, string? ClickEvent = null); + + [Fact] + public void AllButtons_UsesResolvableEditTextCommandsAndClickEvents() + { + HashSet commandNames = [.. EditTextWindow.GetRoutedCommands().Keys]; + HashSet methodNames = [.. typeof(EditTextWindow) + .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) + .Select(method => method.Name)]; + + foreach (ButtonInfo button in ButtonInfo.AllButtons) + { + if (!string.IsNullOrWhiteSpace(button.Command)) + Assert.Contains(button.Command, commandNames); + + if (!string.IsNullOrWhiteSpace(button.ClickEvent)) + Assert.Contains(button.ClickEvent, methodNames); + } + } +} diff --git a/Tests/EditTextWindowFileStateTests.cs b/Tests/EditTextWindowFileStateTests.cs new file mode 100644 index 00000000..8a3ace11 --- /dev/null +++ b/Tests/EditTextWindowFileStateTests.cs @@ -0,0 +1,68 @@ +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextWindowFileStateTests +{ + [Theory] + [InlineData(null, false, "Edit Text")] + [InlineData("", true, "Edit Text")] + [InlineData(@"C:\Temp\notes.md", false, "Edit Text | notes.md")] + [InlineData(@"C:\Temp\notes.md", true, "Edit Text | *notes.md")] + public void GetWindowTitle_ReflectsTrackedFileAndPendingEdits(string? path, bool hasPendingEdits, string expectedTitle) + { + Assert.Equal(expectedTitle, EditTextWindow.GetWindowTitle(path, hasPendingEdits)); + } + + [Theory] + [InlineData(null, "saved", "changed", false)] + [InlineData("", "saved", "changed", false)] + [InlineData(@"C:\Temp\notes.md", "same", "same", false)] + [InlineData(@"C:\Temp\notes.md", "same", "changed", true)] + public void ShouldShowPendingFileEdits_RequiresTrackedFileAndChangedText(string? path, string savedText, string currentText, bool expected) + { + Assert.Equal(expected, EditTextWindow.ShouldShowPendingFileEdits(path, savedText, currentText)); + } + + [Theory] + [InlineData(null, EtwEditorMode.Text, null, null, ".txt")] + [InlineData(null, EtwEditorMode.Markdown, null, null, ".md")] + [InlineData(null, EtwEditorMode.Spreadsheet, null, null, ".tsv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.Csv, ",", ".csv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.Tsv, "\t", ".tsv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.DelimitedText, ",", ".csv")] + [InlineData(null, EtwEditorMode.Spreadsheet, EtwStructuredTextFormat.DelimitedText, "|", ".tsv")] + [InlineData(@"C:\Temp\notes.markdown", EtwEditorMode.Text, null, null, ".markdown")] + [InlineData(@"C:\Temp\data.json", EtwEditorMode.Markdown, null, null, ".json")] + public void GetDefaultSaveExtension_MatchesEditorMode( + string? openedFilePath, + EtwEditorMode editorMode, + EtwStructuredTextFormat? format, + string? delimiter, + string expectedExtension) + { + EditTextTableDocument? tableDocument = format.HasValue + ? new EditTextTableDocument + { + Format = format.Value, + Delimiter = delimiter ?? "\t" + } + : null; + + Assert.Equal(expectedExtension, EditTextWindow.GetDefaultSaveExtension(openedFilePath, editorMode, tableDocument)); + } + + [Theory] + [InlineData(null, EtwEditorMode.Spreadsheet, 1)] + [InlineData(null, EtwEditorMode.Markdown, 2)] + [InlineData(null, EtwEditorMode.Text, 3)] + [InlineData(@"C:\Temp\sheet.csv", EtwEditorMode.Markdown, 1)] + [InlineData(@"C:\Temp\notes.md", EtwEditorMode.Text, 2)] + [InlineData(@"C:\Temp\notes.txt", EtwEditorMode.Markdown, 3)] + [InlineData(@"C:\Temp\data.json", EtwEditorMode.Text, 4)] + public void GetSaveDocumentFilterIndex_MatchesEditorMode(string? openedFilePath, EtwEditorMode editorMode, int expectedFilterIndex) + { + Assert.Equal(expectedFilterIndex, EditTextWindow.GetSaveDocumentFilterIndex(openedFilePath, editorMode)); + } +} diff --git a/Tests/EditTextWindowSpreadsheetTests.cs b/Tests/EditTextWindowSpreadsheetTests.cs new file mode 100644 index 00000000..fc5ea3ad --- /dev/null +++ b/Tests/EditTextWindowSpreadsheetTests.cs @@ -0,0 +1,272 @@ +using System.Data; +using Text_Grab; +using Text_Grab.Models; + +namespace Tests; + +public class EditTextWindowSpreadsheetTests +{ + [Fact] + public void ClearSpreadsheetCellValues_ClearsOnlyRequestedCells() + { + 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"); + + EditTextWindow.ClearSpreadsheetCellValues( + dataTable, + [ + (0, 0), + (1, 2), + (1, 2), + (-1, 1), + (5, 0), + (0, 5) + ]); + + Assert.Equal(string.Empty, dataTable.Rows[0][0]); + Assert.Equal("b1", dataTable.Rows[0][1]); + Assert.Equal("c1", dataTable.Rows[0][2]); + Assert.Equal("a2", dataTable.Rows[1][0]); + Assert.Equal("b2", dataTable.Rows[1][1]); + Assert.Equal(string.Empty, dataTable.Rows[1][2]); + } + + [Fact] + public void TryCutSpreadsheetCellValues_CopiesThenClearsRequestedCells() + { + 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 clipboardText = string.Empty; + + bool didCut = EditTextWindow.TryCutSpreadsheetCellValues( + dataTable, + [ + (1, 2), + (0, 1), + (1, 0), + (0, 1), + (-1, 0), + (5, 5) + ], + text => + { + clipboardText = text; + return true; + }); + + Assert.True(didCut); + Assert.Equal("b1" + Environment.NewLine + "a2\tc2", clipboardText); + Assert.Equal("a1", dataTable.Rows[0][0]); + Assert.Equal(string.Empty, dataTable.Rows[0][1]); + Assert.Equal("c1", dataTable.Rows[0][2]); + Assert.Equal(string.Empty, dataTable.Rows[1][0]); + Assert.Equal("b2", dataTable.Rows[1][1]); + Assert.Equal(string.Empty, dataTable.Rows[1][2]); + } + + [Fact] + public void TryCutSpreadsheetCellValues_DoesNotClearWhenClipboardCopyFails() + { + DataTable dataTable = new(); + dataTable.Columns.Add("A", typeof(string)); + dataTable.Columns.Add("B", typeof(string)); + dataTable.Rows.Add("a1", "b1"); + + bool didCut = EditTextWindow.TryCutSpreadsheetCellValues( + dataTable, + [ + (0, 0), + (0, 1) + ], + _ => false); + + Assert.False(didCut); + Assert.Equal("a1", dataTable.Rows[0][0]); + Assert.Equal("b1", dataTable.Rows[0][1]); + } + + [Fact] + public void BuildSpreadsheetSelectionText_IncludesOnlySelectedCells() + { + 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 selectionText = EditTextWindow.BuildSpreadsheetSelectionText( + dataTable, + [ + (1, 2), + (0, 1), + (1, 0), + (0, 1), + (-1, 0), + (5, 5) + ]); + + Assert.Equal("b1" + Environment.NewLine + "a2\tc2", selectionText); + } + + [Fact] + public void GetSelectedOrPopulatedSpreadsheetCellCoordinates_PrefersValidSelection() + { + 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", string.Empty, "c1"); + dataTable.Rows.Add("a2", "b2", string.Empty); + + List<(int RowIndex, int ColumnIndex)> coordinates = EditTextWindow.GetSelectedOrPopulatedSpreadsheetCellCoordinates( + dataTable, + [ + (0, 1), + (1, 2), + (1, 2), + (-1, 0), + (5, 5) + ]); + + Assert.Equal([(0, 1), (1, 2)], coordinates); + } + + [Fact] + public void GetSelectedOrPopulatedSpreadsheetCellCoordinates_FallsBackToPopulatedCells() + { + 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", " ", string.Empty); + dataTable.Rows.Add(string.Empty, "b2", "c2"); + + List<(int RowIndex, int ColumnIndex)> coordinates = EditTextWindow.GetSelectedOrPopulatedSpreadsheetCellCoordinates( + dataTable, + [ + (-1, 0), + (10, 10) + ]); + + Assert.Equal([(0, 0), (1, 1), (1, 2)], coordinates); + } + + [Fact] + public void TransformSpreadsheetDocumentCellValues_TransformsOnlyRequestedCells() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\tc1\r\na2\tb2\tc2"); + + EditTextWindow.TransformSpreadsheetDocumentCellValues( + document, + [ + (0, 0), + (1, 2), + (1, 2), + (-1, 0), + (5, 5) + ], + value => $"[{value}]"); + + Assert.Equal("[a1]\tb1\tc1\r\na2\tb2\t[c2]", document.SerializeToText()); + } + + [Fact] + public void SetSpreadsheetDocumentCellValues_SetsOnlyRequestedCells() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\tc1\r\na2\tb2\tc2"); + + EditTextWindow.SetSpreadsheetDocumentCellValues( + document, + [ + (0, 1, "B!"), + (1, 0, "A!"), + (1, 0, "A!"), + (8, 1, "ignored") + ]); + + Assert.Equal("a1\tB!\tc1\r\nA!\tb2\tc2", document.SerializeToText()); + } + + [Fact] + public void SetSpreadsheetDocumentCellWrapState_UpdatesOnlyRequestedCells() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\tc1\r\na2\tb2\tc2"); + + EditTextWindow.SetSpreadsheetDocumentCellWrapState( + document, + [ + (0, 1), + (1, 2), + (1, 2), + (-1, 0), + (9, 9) + ], + shouldWrap: true); + + Assert.False(document.IsCellWrapped(0, 0)); + Assert.True(document.IsCellWrapped(0, 1)); + Assert.False(document.IsCellWrapped(0, 2)); + Assert.False(document.IsCellWrapped(1, 0)); + Assert.False(document.IsCellWrapped(1, 1)); + Assert.True(document.IsCellWrapped(1, 2)); + } + + [Fact] + public void AreSpreadsheetDocumentCellsWrapped_ReturnsTrueOnlyWhenAllValidTargetsAreWrapped() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\tc1\r\na2\tb2\tc2"); + document.SetCellWrap(0, 1, true); + document.SetCellWrap(1, 2, true); + + Assert.True(EditTextWindow.AreSpreadsheetDocumentCellsWrapped( + document, + [ + (0, 1), + (1, 2), + (1, 2), + (-1, 0) + ])); + + Assert.False(EditTextWindow.AreSpreadsheetDocumentCellsWrapped( + document, + [ + (0, 1), + (1, 1) + ])); + } + + [Fact] + public void ClearSpreadsheetDocumentRowHeights_ClearsOnlyRequestedRows() + { + EditTextTableDocument document = EditTextTableDocument.CreateFromText("a1\tb1\r\na2\tb2"); + document.SetRowHeight(0, 32); + document.SetRowHeight(1, 48); + + EditTextWindow.ClearSpreadsheetDocumentRowHeights(document, [1, 1, -1, 8]); + + Assert.Equal(32, document.RowHeights[0]); + Assert.Null(document.RowHeights[1]); + } + + [Theory] + [InlineData(24d, 24d)] + [InlineData(36.5, 36.5)] + [InlineData(double.NaN, null)] + [InlineData(double.PositiveInfinity, null)] + [InlineData(0d, null)] + [InlineData(-10d, null)] + public void GetSpreadsheetPersistedRowHeight_PersistsOnlyExplicitPositiveHeights(double rowHeight, double? expectedHeight) + { + Assert.Equal(expectedHeight, EditTextWindow.GetSpreadsheetPersistedRowHeight(rowHeight)); + } +} diff --git a/Tests/ExtractedPatternTests.cs b/Tests/ExtractedPatternTests.cs index f4dbe522..b0e97d48 100644 --- a/Tests/ExtractedPatternTests.cs +++ b/Tests/ExtractedPatternTests.cs @@ -190,7 +190,7 @@ public void AllPatterns_IsReadOnly() IReadOnlyDictionary allPatterns = extractedPattern.AllPatterns; // Then - Assert.IsAssignableFrom>(allPatterns); + Assert.IsType>(allPatterns, exactMatch: false); } [Fact] @@ -503,7 +503,7 @@ public void DetermineStartingLevel_SpecialCharsShort_ReturnsSeparatorAgnostic(st [InlineData("", 3)] // Empty string [InlineData(" ", 3)] // Whitespace only [InlineData(null, 3)] // Null - public void DetermineStartingLevel_EmptyOrWhitespace_ReturnsDefault(string input, int expectedLevel) + public void DetermineStartingLevel_EmptyOrWhitespace_ReturnsDefault(string? input, int expectedLevel) { // When int actualLevel = ExtractedPattern.DetermineStartingLevel(input); @@ -630,7 +630,7 @@ public void ExtractSimplePattern_CaseSensitive_MatchesExactCase() MatchCollection matches = Regex.Matches(text, pattern); // Then - Should match only exact case - Assert.Equal(1, matches.Count); // Only "Test" + Assert.Single(matches.Cast()); // Only "Test" Assert.DoesNotContain("(?i)", pattern); // Verify inline flag is NOT present } diff --git a/Tests/FilesIoTests.cs b/Tests/FilesIoTests.cs index 18808438..6560b7ca 100644 --- a/Tests/FilesIoTests.cs +++ b/Tests/FilesIoTests.cs @@ -1,4 +1,6 @@ -using System.Drawing; +using System.Drawing; +using System.IO; +using System.Windows; using Text_Grab; using Text_Grab.Models; using Text_Grab.Utilities; @@ -94,4 +96,104 @@ public async Task ReadNotExistingImageFileEmpty(FileStorageKind storageKind) Bitmap? emptyReturn = await FileUtilities.GetImageFileAsync(fileName, 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() + { + string firstPath = Path.GetTempFileName(); + string secondPath = Path.GetTempFileName(); + string missingPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt"); + DataObject dataObject = new(DataFormats.FileDrop, new[] { firstPath, missingPath, secondPath }); + + try + { + IReadOnlyList paths = App.GetDroppedFilePaths(dataObject); + + Assert.Equal([firstPath, secondPath], paths); + } + finally + { + File.Delete(firstPath); + File.Delete(secondPath); + } + } + + [WpfFact] + public void GetDroppedFileEffect_ReturnsCopyWhenExistingFilesAreDropped() + { + string path = Path.GetTempFileName(); + DataObject dataObject = new(DataFormats.FileDrop, new[] { path }); + + try + { + Assert.Equal(DragDropEffects.Copy, App.GetDroppedFileEffect(dataObject)); + } + finally + { + File.Delete(path); + } + } + + [WpfFact] + public void GetDroppedFileEffect_ReturnsNoneWhenNoFilesCanBeOpened() + { + DataObject dataObject = new(DataFormats.Text, "hello"); + + Assert.Equal(DragDropEffects.None, App.GetDroppedFileEffect(dataObject)); + } } diff --git a/Tests/GrabFrameEtwTests.cs b/Tests/GrabFrameEtwTests.cs new file mode 100644 index 00000000..60dcd5df --- /dev/null +++ b/Tests/GrabFrameEtwTests.cs @@ -0,0 +1,23 @@ +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabFrameEtwTests +{ + [Theory] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + public void ShouldOpenNewEtwInSpreadsheetMode_OnlyReturnsTrueForNewTableEtw( + bool isTableModeSelected, + bool hasExistingEditTextWindow, + bool expected) + { + bool shouldUseSpreadsheetMode = WindowUtilities.ShouldOpenNewEtwInSpreadsheetMode( + isTableModeSelected, + hasExistingEditTextWindow); + + Assert.Equal(expected, shouldUseSpreadsheetMode); + } +} diff --git a/Tests/GrabFrameTableEditStateTests.cs b/Tests/GrabFrameTableEditStateTests.cs new file mode 100644 index 00000000..cd0ffe2e --- /dev/null +++ b/Tests/GrabFrameTableEditStateTests.cs @@ -0,0 +1,31 @@ +using Text_Grab.Models; + +namespace Tests; + +public class GrabFrameTableEditStateTests +{ + [Fact] + public void TryCommitPreview_AddsAndSortsManualSeparators() + { + GrabFrameTableEditState state = new(); + state.SetManualSeparators([40], [70]); + + state.BeginPlacement(GrabFrameTablePlacementMode.AddRow); + + Assert.True(state.TryUpdatePreview(20, 0, 100, state.ManualRowSeparators)); + Assert.True(state.TryCommitPreview()); + Assert.Equal([20d, 40d], state.ManualRowSeparators); + } + + [Fact] + public void TryUpdatePreview_RejectsSeparatorTooCloseToExistingDivider() + { + GrabFrameTableEditState state = new(); + state.BeginPlacement(GrabFrameTablePlacementMode.AddColumn); + + Assert.False(state.TryUpdatePreview(22, 0, 100, [20d])); + Assert.Equal(22d, state.PreviewPosition); + Assert.False(state.IsPreviewValid); + Assert.False(state.TryCommitPreview()); + } +} diff --git a/Tests/GrabFrameViewScaleUtilitiesTests.cs b/Tests/GrabFrameViewScaleUtilitiesTests.cs new file mode 100644 index 00000000..7d14d9ce --- /dev/null +++ b/Tests/GrabFrameViewScaleUtilitiesTests.cs @@ -0,0 +1,61 @@ +using System.Windows; +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabFrameViewScaleUtilitiesTests +{ + [Theory] + [InlineData(1.0, 1, 1.25)] + [InlineData(1.0, -1, 0.75)] + [InlineData(0.5, -1, 0.5)] + [InlineData(5.0, 1, 5.0)] + public void StepScale_AdjustsAndClampsAsExpected(double currentScale, int direction, double expected) + { + double actual = GrabFrameViewScaleUtilities.StepScale(currentScale, direction); + + Assert.Equal(expected, actual, 3); + } + + [Fact] + public void GetMinimumWindowRect_LeavesLargeWindowUnchanged() + { + Rect currentWindowRect = new(300, 200, 900, 700); + Size minimumWindowSize = new( + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowWidth, + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowHeight); + Rect workArea = new(0, 0, 1920, 1080); + + Rect actual = GrabFrameViewScaleUtilities.GetMinimumWindowRect(currentWindowRect, minimumWindowSize, workArea); + + Assert.Equal(currentWindowRect, actual); + } + + [Fact] + public void GetMinimumWindowRect_ExpandsAndCentersWithinWorkArea() + { + Rect currentWindowRect = new(500, 250, 400, 300); + Size minimumWindowSize = new( + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowWidth, + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowHeight); + Rect workArea = new(0, 0, 1920, 1080); + + Rect actual = GrabFrameViewScaleUtilities.GetMinimumWindowRect(currentWindowRect, minimumWindowSize, workArea); + + Assert.Equal(new Rect(300, 175, 800, 450), actual); + } + + [Fact] + public void GetMinimumWindowRect_ClampsExpandedWindowInsideWorkArea() + { + Rect currentWindowRect = new(1500, 700, 400, 300); + Size minimumWindowSize = new( + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowWidth, + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowHeight); + Rect workArea = new(0, 0, 1920, 1080); + + Rect actual = GrabFrameViewScaleUtilities.GetMinimumWindowRect(currentWindowRect, minimumWindowSize, workArea); + + Assert.Equal(new Rect(1120, 625, 800, 450), actual); + } +} diff --git a/Tests/HistoryServiceTests.cs b/Tests/HistoryServiceTests.cs index c2965c80..b32d1d26 100644 --- a/Tests/HistoryServiceTests.cs +++ b/Tests/HistoryServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Windows; +using System.Reflection; using Text_Grab; using Text_Grab.Models; using Text_Grab.Services; @@ -102,7 +103,10 @@ public async Task ImageHistory_KeepsInlineWordBorderJsonWhileMirroringSidecarSto new() { Word = "hello", + DisplayText = $"hello{Environment.NewLine}world", BorderRect = new Rect(1, 2, 30, 40), + DisplayLineHeight = 18, + KeepSingleLineOutput = true, LineNumber = 1, ResultColumnID = 2, ResultRowID = 3 @@ -120,7 +124,9 @@ await SaveHistoryFileAsync( TextContent = "history with borders", ImagePath = "borders.bmp", SourceMode = TextGrabMode.GrabFrame, - WordBorderInfoJson = inlineWordBorderJson + WordBorderInfoJson = inlineWordBorderJson, + ManualTableColumnSeparators = [44], + ManualTableRowSeparators = [18] } ]); @@ -129,18 +135,25 @@ await SaveHistoryFileAsync( Assert.Equal(inlineWordBorderJson, historyItem.WordBorderInfoJson); Assert.Equal("image-with-borders.wordborders.json", historyItem.WordBorderInfoFileName); + Assert.Equal([44d], historyItem.ManualTableColumnSeparators); + Assert.Equal([18d], historyItem.ManualTableRowSeparators); List wordBorderInfos = await historyService.GetWordBorderInfosAsync(historyItem); WordBorderInfo wordBorderInfo = Assert.Single(wordBorderInfos); Assert.Equal("hello", wordBorderInfo.Word); + Assert.Equal($"hello{Environment.NewLine}world", wordBorderInfo.DisplayText); Assert.Equal(30d, wordBorderInfo.BorderRect.Width); Assert.Equal(40d, wordBorderInfo.BorderRect.Height); + Assert.Equal(18d, wordBorderInfo.DisplayLineHeight); + Assert.True(wordBorderInfo.KeepSingleLineOutput); historyService.ReleaseLoadedHistories(); string savedHistoryJson = await FileUtilities.GetTextFileAsync("HistoryWithImage.json", FileStorageKind.WithHistory); Assert.Contains("\"WordBorderInfoJson\"", savedHistoryJson); Assert.Contains("\"WordBorderInfoFileName\"", savedHistoryJson); + Assert.Contains("\"ManualTableColumnSeparators\"", savedHistoryJson); + Assert.Contains("\"ManualTableRowSeparators\"", savedHistoryJson); string savedWordBorderJson = await FileUtilities.GetTextFileAsync(historyItem.WordBorderInfoFileName!, FileStorageKind.WithHistory); Assert.Contains("hello", savedWordBorderJson); @@ -181,6 +194,72 @@ await SaveHistoryFileAsync( Assert.Contains("\"UsedUiAutomation\": true", savedHistoryJson); } + [WpfFact] + public async Task TextHistory_PreservesMarkdownEditorModeAndSource() + { + await SaveHistoryFileAsync( + "HistoryTextOnly.json", + [ + new HistoryInfo + { + ID = "markdown-history", + CaptureDateTime = new DateTimeOffset(2024, 1, 5, 12, 0, 0, TimeSpan.Zero), + TextContent = "# Heading\r\n\r\n**bold**", + SourceMode = TextGrabMode.EditText, + EditorMode = EtwEditorMode.Markdown + } + ]); + + HistoryService historyService = new(); + HistoryInfo historyItem = Assert.Single(historyService.GetEditWindows()); + + Assert.Equal(EtwEditorMode.Markdown, historyItem.EditorMode); + Assert.Equal("# Heading\r\n\r\n**bold**", historyItem.TextContent); + } + + [WpfFact] + public void TextHistory_WriteHistory_PersistsSavedEditWindowText() + { + bool originalUseHistory = AppUtilities.TextGrabSettings.UseHistory; + AppUtilities.TextGrabSettings.UseHistory = true; + + try + { + HistoryService historyService = new(); + historyService.DeleteHistory(); + SetPrivateField(historyService, "HistoryTextOnly", new List + { + new() + { + ID = "saved-edit-window", + CaptureDateTime = new DateTimeOffset(2024, 1, 6, 12, 0, 0, TimeSpan.Zero), + TextContent = "history text from close action", + SourceMode = TextGrabMode.EditText + } + }); + SetPrivateField(historyService, "_textHistoryLoaded", true); + SetPrivateField(historyService, "_hasPendingWrite", true); + + historyService.WriteHistory(); + historyService.ReleaseLoadedHistories(); + + HistoryInfo historyItem = Assert.Single(historyService.GetEditWindows()); + Assert.Equal("history text from close action", historyItem.TextContent); + } + finally + { + AppUtilities.TextGrabSettings.UseHistory = originalUseHistory; + } + } + + private static void SetPrivateField(object target, string fieldName, T value) + { + FieldInfo fieldInfo = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Field '{fieldName}' was not found."); + + fieldInfo.SetValue(target, value); + } + private static Task SaveHistoryFileAsync(string fileName, List historyItems) { string historyJson = JsonSerializer.Serialize(historyItems, HistoryJsonOptions); diff --git a/Tests/Images/paragraph-test-image.png b/Tests/Images/paragraph-test-image.png new file mode 100644 index 00000000..d8e151af Binary files /dev/null and b/Tests/Images/paragraph-test-image.png differ diff --git a/Tests/LanguageServiceTests.cs b/Tests/LanguageServiceTests.cs index 17109145..cf8b015d 100644 --- a/Tests/LanguageServiceTests.cs +++ b/Tests/LanguageServiceTests.cs @@ -13,17 +13,20 @@ public class LanguageServiceTests : IDisposable { private readonly string _originalLastUsedLang; private readonly bool _originalUiAutomationEnabled; + private readonly bool _originalWindowsAiDescriptionEnabled; public LanguageServiceTests() { _originalLastUsedLang = Settings.Default.LastUsedLang; _originalUiAutomationEnabled = Settings.Default.UiAutomationEnabled; + _originalWindowsAiDescriptionEnabled = Settings.Default.WindowsAiDescriptionEnabled; } public void Dispose() { Settings.Default.LastUsedLang = _originalLastUsedLang; Settings.Default.UiAutomationEnabled = _originalUiAutomationEnabled; + Settings.Default.WindowsAiDescriptionEnabled = _originalWindowsAiDescriptionEnabled; Settings.Default.Save(); LanguageUtilities.InvalidateAllCaches(); } @@ -48,6 +51,16 @@ public void GetLanguageTag_WithWindowsAiLang_ReturnsWinAI() Assert.Equal("WinAI", tag); } + [Fact] + public void GetLanguageTag_WithWindowsAiDescriptionLang_ReturnsDescriptionTag() + { + WindowsAiDescriptionLang windowsAiDescriptionLang = new(); + + string tag = LanguageService.GetLanguageTag(windowsAiDescriptionLang); + + Assert.Equal(WindowsAiDescriptionLang.Tag, tag); + } + [Fact] public void GetLanguageTag_WithUiAutomationLang_ReturnsUiAutomationTag() { @@ -98,6 +111,16 @@ public void GetLanguageKind_WithWindowsAiLang_ReturnsWindowsAi() Assert.Equal(LanguageKind.WindowsAi, kind); } + [Fact] + public void GetLanguageKind_WithWindowsAiDescriptionLang_ReturnsWindowsAiDescription() + { + WindowsAiDescriptionLang windowsAiDescriptionLang = new(); + + LanguageKind kind = LanguageService.GetLanguageKind(windowsAiDescriptionLang); + + Assert.Equal(LanguageKind.WindowsAiDescription, kind); + } + [Fact] public void GetLanguageKind_WithUiAutomationLang_ReturnsUiAutomation() { @@ -162,6 +185,19 @@ public void GetOCRLanguage_WhenUiAutomationWasLastUsedButFeatureIsDisabled_Falls Assert.IsNotType(language); } + [Fact] + public void GetOCRLanguage_WhenWindowsAiDescriptionWasLastUsedButFeatureIsDisabled_FallsBack() + { + Settings.Default.WindowsAiDescriptionEnabled = false; + Settings.Default.LastUsedLang = WindowsAiDescriptionLang.Tag; + Settings.Default.Save(); + LanguageUtilities.InvalidateAllCaches(); + + ILanguage language = Singleton.Instance.GetOCRLanguage(); + + Assert.IsNotType(language); + } + [Fact] public void LanguageService_IsSingleton() { @@ -193,4 +229,16 @@ public void HistoryInfo_OcrLanguage_FallsBackForUiAutomationPersistence() Assert.IsNotType(historyInfo.OcrLanguage); } + + [Fact] + public void HistoryInfo_OcrLanguage_ReturnsWindowsAiDescriptionLanguage() + { + HistoryInfo historyInfo = new() + { + LanguageTag = WindowsAiDescriptionLang.Tag, + LanguageKind = LanguageKind.WindowsAiDescription, + }; + + Assert.IsType(historyInfo.OcrLanguage); + } } diff --git a/Tests/MarkdownDocumentUtilitiesTests.cs b/Tests/MarkdownDocumentUtilitiesTests.cs new file mode 100644 index 00000000..d403729b --- /dev/null +++ b/Tests/MarkdownDocumentUtilitiesTests.cs @@ -0,0 +1,159 @@ +using System.Windows.Documents; +using System.Windows.Media; +using Text_Grab.Utilities; + +namespace Tests; + +public class MarkdownDocumentUtilitiesTests +{ + [WpfFact] + public void Markdown_RoundTrips_CommonFormatting() + { + const string markdown = """ +# Heading + +Plain **bold** text with a [link](https://example.com). + +- one +- two + +> quoted + +```csharp +Console.WriteLine("hi"); +``` +"""; + + FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Contains("# Heading", serialized); + Assert.Contains("**bold**", serialized); + Assert.Contains("[link](https://example.com)", serialized); + Assert.Contains("- one", serialized); + Assert.Contains("> quoted", serialized); + Assert.Contains("```csharp", serialized); + Assert.Contains("Console.WriteLine(\"hi\");", serialized); + } + + [WpfFact] + public void Markdown_Tables_RoundTrip_ToPipeTable() + { + const string markdown = """ +| Name | Value | +| --- | --- | +| Alpha | 42 | +| Beta | 99 | +"""; + + FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Contains("| Name | Value |", serialized); + Assert.Contains("| Alpha | 42 |", serialized); + Assert.Contains("| Beta | 99 |", serialized); + } + + [WpfFact] + public void Markdown_TaskLists_RoundTrip_ToCheckboxMarkers() + { + const string markdown = """ + - [ ] open item + - [x] done item + """; + + FlowDocument document = MarkdownDocumentUtilities.CreateFlowDocument(markdown, new FontFamily("Segoe UI"), 16); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Contains("- [ ] open item", serialized); + Assert.Contains("- [x] done item", serialized); + } + + [WpfFact] + public void PlainText_WithMarkdownCharacters_IsEscapedDuringSerialization() + { + FlowDocument document = new(); + document.Blocks.Add(new Paragraph(new Run("*literal* [value]"))); + + string serialized = MarkdownDocumentUtilities.SerializeToMarkdown(document); + + Assert.Equal(@"\*literal\* \[value\]", serialized); + } + + [WpfFact] + 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); + + Assert.Equal("**bold** [link](https://example.com)", serialized); + } + + [Theory] + [InlineData("#")] + [InlineData("##")] + [InlineData(">")] + [InlineData(" >")] + [InlineData("-")] + [InlineData("1.")] + public void LiveBlockTriggerMarkers_AreRecognized(string marker) + { + Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(marker)); + } + + [Theory] + [InlineData("text")] + [InlineData("hello # world")] + [InlineData("1.2")] + public void NonTriggerText_DoesNotPromoteLiveBlock(string text) + { + Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveBlock(text)); + } + + [Theory] + [InlineData("**bold**")] + [InlineData("`code`")] + [InlineData("[link](https://example.com)")] + [InlineData("[ ] task")] + [InlineData("[x] done")] + public void CompletedMarkdownSyntax_PromotesLiveParsing(string text) + { + Assert.True(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + } + + [Theory] + [InlineData("*")] + [InlineData("[link]")] + [InlineData("plain text")] + [InlineData("2026.04 release notes")] + public void IncompleteMarkdownSyntax_DoesNotPromoteLiveParsing(string text) + { + Assert.False(MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(text)); + } + + [Theory] + [InlineData("# Heading")] + [InlineData("> quote")] + [InlineData("- item")] + [InlineData("1. item")] + [InlineData("[link](https://example.com)")] + [InlineData("```csharp\nConsole.WriteLine(\"hi\");\n```")] + public void MarkdownLikeText_IsDetectedForPasteParsing(string text) + { + Assert.True(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + } + + [Theory] + [InlineData("Just a normal sentence.")] + [InlineData("2026.04 release notes")] + [InlineData("email me at joe@example.com")] + public void PlainText_IsNotDetectedAsMarkdown(string text) + { + Assert.False(MarkdownDocumentUtilities.LooksLikeMarkdown(text)); + } +} diff --git a/Tests/OcrTests.cs b/Tests/OcrTests.cs index d0f94949..996488be 100644 --- a/Tests/OcrTests.cs +++ b/Tests/OcrTests.cs @@ -155,6 +155,119 @@ public async Task AnalyzeTable() } + [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, true)] // overlapping bounding boxes + [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); + } + [WpfFact] public async Task ReadQrCode() { @@ -362,4 +475,28 @@ public async Task GetTesseractGitHubLanguage() File.Delete(tempFilePath); } + + 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; } + } } diff --git a/Tests/PdfDocumentRendererTests.cs b/Tests/PdfDocumentRendererTests.cs new file mode 100644 index 00000000..8d00801c --- /dev/null +++ b/Tests/PdfDocumentRendererTests.cs @@ -0,0 +1,89 @@ +using Text_Grab.Utilities; +using UglyToad.PdfPig.Core; +using Windows.Media.Ocr; + +namespace Tests; + +public class PdfDocumentRendererTests +{ + [Fact] + public void GetRenderDimensions_DoublesTypicalPdfPageSize() + { + (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(612, 792); + + Assert.Equal(1224u, width); + Assert.Equal(1584u, height); + } + + [Fact] + public void GetRenderDimensions_ClampsToOcrEngineLimit() + { + (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(5000, 2500); + + Assert.True(Math.Max(width, height) <= OcrEngine.MaxImageDimension); + Assert.True(width > height); + } + + [Fact] + public void GetRenderDimensions_InvalidSize_ReturnsSinglePixel() + { + (uint width, uint height) = PdfDocumentRenderer.GetRenderDimensions(0, -1); + + Assert.Equal(1u, width); + Assert.Equal(1u, height); + } + + [Fact] + public void ConvertPdfRectToImageRect_MapsPdfCoordinatesToRenderedBitmapSpace() + { + PdfRectangle pdfRect = new(10, 20, 60, 80); + + Windows.Foundation.Rect imageRect = PdfDocumentRenderer.ConvertPdfRectToImageRect(pdfRect, 100, 100, 200, 200); + + Assert.Equal(20, imageRect.X); + Assert.Equal(40, imageRect.Y); + Assert.Equal(100, imageRect.Width); + Assert.Equal(120, imageRect.Height); + } + + [Fact] + public void GroupWordsIntoLines_GroupsNearbyWordsIntoSingleLine() + { + IReadOnlyList lines = PdfDocumentRenderer.GroupWordsIntoLines( + [ + (new Windows.Foundation.Rect(10, 10, 20, 12), "Hello"), + (new Windows.Foundation.Rect(35, 11, 25, 12), "world"), + (new Windows.Foundation.Rect(12, 40, 30, 12), "Again") + ]); + + Assert.Collection( + lines, + firstLine => + { + Assert.Equal("Hello world", firstLine.Text); + Assert.True(firstLine.IsNativeText); + Assert.Equal(10, firstLine.SourceRect.X); + Assert.Equal(10, firstLine.SourceRect.Y); + Assert.Equal(50, firstLine.SourceRect.Width); + Assert.Equal(13, firstLine.SourceRect.Height); + }, + secondLine => Assert.Equal("Again", secondLine.Text)); + } + + [Fact] + public void ShouldIncludeOcrLine_OnlyReturnsTrueWhenImageOverlapIsMeaningful() + { + Windows.Foundation.Rect sourceRect = new(0, 0, 10, 10); + + bool shouldIncludeFromLargeOverlap = PdfDocumentRenderer.ShouldIncludeOcrLine( + sourceRect, + [new Windows.Foundation.Rect(5, 5, 10, 10)]); + + bool shouldIgnoreFromSmallOverlap = PdfDocumentRenderer.ShouldIncludeOcrLine( + sourceRect, + [new Windows.Foundation.Rect(8, 8, 10, 10)]); + + Assert.True(shouldIncludeFromLargeOverlap); + Assert.False(shouldIgnoreFromSmallOverlap); + } +} diff --git a/Tests/ResultTableManualSeparatorTests.cs b/Tests/ResultTableManualSeparatorTests.cs new file mode 100644 index 00000000..de777577 --- /dev/null +++ b/Tests/ResultTableManualSeparatorTests.cs @@ -0,0 +1,96 @@ +using System.Drawing; +using System.Text; +using System.Windows; +using Text_Grab.Models; + +namespace Tests; + +public class ResultTableManualSeparatorTests +{ + [WpfFact] + public void AnalyzeAsTable_ManualRowSeparatorSplitsMergedRowOutput() + { + List automaticInfos = + [ + CreateWord("Top", left: 20, top: 10, width: 30, height: 10), + CreateWord("Bottom", left: 20, top: 17, width: 45, height: 10) + ]; + + ResultTable automaticTable = new(); + automaticTable.AnalyzeAsTable(automaticInfos, new Rectangle(0, 0, 200, 200), drawTable: false); + + StringBuilder automaticText = new(); + ResultTable.GetTextFromTabledWordBorders(automaticText, automaticInfos, true); + Assert.Equal("Top Bottom", automaticText.ToString()); + + List manualInfos = + [ + CreateWord("Top", left: 20, top: 10, width: 30, height: 10), + CreateWord("Bottom", left: 20, top: 17, width: 45, height: 10) + ]; + + ResultTable manualTable = new(); + manualTable.AnalyzeAsTable( + manualInfos, + new Rectangle(0, 0, 200, 200), + manualRowSeparators: [18d], + manualColumnSeparators: null, + drawTable: false); + + StringBuilder manualText = new(); + ResultTable.GetTextFromTabledWordBorders(manualText, manualInfos, true); + + Assert.Equal($"Top{Environment.NewLine}Bottom", manualText.ToString()); + Assert.Equal([18d], manualTable.ManualRowSeparators); + } + + [WpfFact] + public void AnalyzeAsTable_ManualColumnSeparatorSplitsMergedColumnOutput() + { + List automaticInfos = + [ + CreateWord("LeftTop", left: 10, top: 10, width: 12, height: 10), + CreateWord("RightTop", left: 30, top: 10, width: 18, height: 10), + CreateWord("LeftBottom", left: 10, top: 32, width: 20, height: 10), + CreateWord("RightBottom", left: 30, top: 32, width: 28, height: 10) + ]; + + ResultTable automaticTable = new(); + automaticTable.AnalyzeAsTable(automaticInfos, new Rectangle(0, 0, 200, 200), drawTable: false); + + StringBuilder automaticText = new(); + ResultTable.GetTextFromTabledWordBorders(automaticText, automaticInfos, true); + Assert.Equal($"LeftTop RightTop{Environment.NewLine}LeftBottom RightBottom", automaticText.ToString()); + + List manualInfos = + [ + CreateWord("LeftTop", left: 10, top: 10, width: 12, height: 10), + CreateWord("RightTop", left: 30, top: 10, width: 18, height: 10), + CreateWord("LeftBottom", left: 10, top: 32, width: 20, height: 10), + CreateWord("RightBottom", left: 30, top: 32, width: 28, height: 10) + ]; + + ResultTable manualTable = new(); + manualTable.AnalyzeAsTable( + manualInfos, + new Rectangle(0, 0, 200, 200), + manualRowSeparators: null, + manualColumnSeparators: [25d], + drawTable: false); + + StringBuilder manualText = new(); + ResultTable.GetTextFromTabledWordBorders(manualText, manualInfos, true); + + Assert.Equal($"LeftTop\tRightTop{Environment.NewLine}LeftBottom\tRightBottom", manualText.ToString()); + Assert.Equal([25d], manualTable.ManualColumnSeparators); + } + + 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) + }; + } +} diff --git a/Tests/SettingsServiceTests.cs b/Tests/SettingsServiceTests.cs index 5ef92f34..abf66e14 100644 --- a/Tests/SettingsServiceTests.cs +++ b/Tests/SettingsServiceTests.cs @@ -9,11 +9,13 @@ namespace Tests; public class SettingsServiceTests : IDisposable { private readonly string _tempFolder; + private readonly string _regularSettingsFilePath; public SettingsServiceTests() { _tempFolder = Path.Combine(Path.GetTempPath(), $"TextGrab_SettingsService_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempFolder); + _regularSettingsFilePath = Path.Combine(_tempFolder, "Settings.json"); } public void Dispose() @@ -181,6 +183,108 @@ public void Constructor_UnpackagedUpgradePathDoesNotThrowWhenNoPreviousVersion() Assert.False(service.IsFileBackedManagedSettingsEnabled); } + [Fact] + public void Constructor_RegularSettingsSidecarWithFileBackedFlagImportsPortableSettings() + { + Settings settings = new() + { + FirstRun = false, + EnableFileBackedManagedSettings = false, + ShowToast = true, + DefaultLaunch = "Fullscreen" + }; + + File.WriteAllText( + _regularSettingsFilePath, + """ + { + "EnableFileBackedManagedSettings": true, + "ShowToast": false, + "DefaultLaunch": "EditText" + } + """); + + SettingsService service = CreateService(settings); + + Assert.True(service.IsFileBackedManagedSettingsEnabled); + Assert.True(settings.EnableFileBackedManagedSettings); + Assert.False(settings.ShowToast); + Assert.Equal("EditText", settings.DefaultLaunch); + } + + [Fact] + public void Constructor_FileBackedModeWithoutRegularSettingsSidecarCreatesOneFromClassicSettings() + { + Settings settings = new() + { + FirstRun = false, + EnableFileBackedManagedSettings = true, + ShowToast = false, + DefaultLaunch = "QuickLookup", + GrabTemplatesJSON = """[{ "id": "template-1" }]""" + }; + + SettingsService service = CreateService(settings); + + Assert.True(service.IsFileBackedManagedSettingsEnabled); + Assert.True(File.Exists(_regularSettingsFilePath)); + + string persistedJson = File.ReadAllText(_regularSettingsFilePath); + Assert.Contains(@"""EnableFileBackedManagedSettings"": true", persistedJson); + Assert.Contains(@"""ShowToast"": false", persistedJson); + Assert.Contains(@"""DefaultLaunch"": ""QuickLookup""", persistedJson); + Assert.DoesNotContain("GrabTemplatesJSON", persistedJson); + } + + [Fact] + public void Constructor_RegularSettingsSidecarOnlyOverridesKnownValuesAndBackfillsMissingOnes() + { + Settings settings = new() + { + FirstRun = false, + EnableFileBackedManagedSettings = true, + ShowToast = true, + DefaultLaunch = "QuickLookup" + }; + + File.WriteAllText( + _regularSettingsFilePath, + """ + { + "EnableFileBackedManagedSettings": true, + "ShowToast": false + } + """); + + SettingsService service = CreateService(settings); + + Assert.True(service.IsFileBackedManagedSettingsEnabled); + Assert.False(settings.ShowToast); + Assert.Equal("QuickLookup", settings.DefaultLaunch); + + string persistedJson = File.ReadAllText(_regularSettingsFilePath); + Assert.Contains(@"""ShowToast"": false", persistedJson); + Assert.Contains(@"""DefaultLaunch"": ""QuickLookup""", persistedJson); + } + + [Fact] + public void RegularSettingChange_PersistsToRegularSettingsSidecarWhenFileBackedModeEnabled() + { + Settings settings = new() + { + FirstRun = false, + EnableFileBackedManagedSettings = true, + ShowToast = true + }; + + SettingsService service = CreateService(settings); + + settings.ShowToast = false; + + string persistedJson = File.ReadAllText(_regularSettingsFilePath); + Assert.Contains(@"""ShowToast"": false", persistedJson); + } + [Fact] public void LoadStoredRegexes_SidecarSurvivesSimulatedPackageUpgrade() { @@ -209,6 +313,7 @@ private SettingsService CreateService(Settings settings) => settings, localSettings: null, managedJsonSettingsFolderPath: _tempFolder, + regularSettingsSidecarFilePath: _regularSettingsFilePath, saveClassicSettingsChanges: false); private static string SerializeRegexes(string id) => diff --git a/Tests/SpreadsheetUndoHistoryTests.cs b/Tests/SpreadsheetUndoHistoryTests.cs new file mode 100644 index 00000000..4dd9b6a9 --- /dev/null +++ b/Tests/SpreadsheetUndoHistoryTests.cs @@ -0,0 +1,69 @@ +using Text_Grab.Models; + +namespace Tests; + +public class SpreadsheetUndoHistoryTests +{ + [Fact] + public void RecordChange_UndoAndRedo_RestoreExpectedStates() + { + SpreadsheetUndoHistory history = new(); + SpreadsheetUndoState originalState = new("{\"Rows\":[[\"one\"]]}", 1, 2); + SpreadsheetUndoState editedState = new("{\"Rows\":[[\"two\"]]}", 3, 4); + + history.RecordChange(originalState, editedState); + + Assert.True(history.CanUndo); + Assert.False(history.CanRedo); + + SpreadsheetUndoState? undoneState = history.Undo(editedState); + + Assert.NotNull(undoneState); + Assert.Equal(originalState.DocumentJson, undoneState.DocumentJson); + Assert.Equal(originalState.FocusRow, undoneState.FocusRow); + Assert.Equal(originalState.FocusColumn, undoneState.FocusColumn); + Assert.False(history.CanUndo); + Assert.True(history.CanRedo); + + SpreadsheetUndoState? redoneState = history.Redo(undoneState); + + Assert.NotNull(redoneState); + Assert.Equal(editedState.DocumentJson, redoneState.DocumentJson); + Assert.Equal(editedState.FocusRow, redoneState.FocusRow); + Assert.Equal(editedState.FocusColumn, redoneState.FocusColumn); + Assert.True(history.CanUndo); + Assert.False(history.CanRedo); + } + + [Fact] + public void RecordChange_NoOpChange_DoesNotCreateUndoEntry() + { + SpreadsheetUndoHistory history = new(); + SpreadsheetUndoState state = new("{\"Rows\":[[\"same\"]]}", 0, 0); + + history.RecordChange(state, new SpreadsheetUndoState(state.DocumentJson, 5, 6)); + + Assert.False(history.CanUndo); + Assert.False(history.CanRedo); + } + + [Fact] + public void RecordChange_NewEditClearsRedoHistory() + { + SpreadsheetUndoHistory history = new(); + SpreadsheetUndoState stateA = new("{\"Rows\":[[\"A\"]]}", 0, 0); + SpreadsheetUndoState stateB = new("{\"Rows\":[[\"B\"]]}", 0, 1); + SpreadsheetUndoState stateC = new("{\"Rows\":[[\"C\"]]}", 1, 0); + + history.RecordChange(stateA, stateB); + SpreadsheetUndoState? undoneState = history.Undo(stateB); + + Assert.NotNull(undoneState); + Assert.True(history.CanRedo); + + history.RecordChange(undoneState, stateC); + + Assert.True(history.CanUndo); + Assert.False(history.CanRedo); + } +} diff --git a/Tests/StartupTests.cs b/Tests/StartupTests.cs index 84308747..8a4c1370 100644 --- a/Tests/StartupTests.cs +++ b/Tests/StartupTests.cs @@ -1,4 +1,5 @@ using System.IO; +using Text_Grab; namespace Tests; @@ -117,4 +118,32 @@ public void FileUtilitiesLocalFilePathCalculation_OldVsNewLogic() // Assert - New logic should point to base directory (correct) Assert.Equal(@"C:\Program Files\Text-Grab\images\logo.png", newLogicPath); } -} \ No newline at end of file + + [Fact] + public void ParseStartupArguments_IgnoresFlagsWhenSelectingPrimaryArgument() + { + App.StartupArguments startupArguments = App.ParseStartupArguments(["--windowless", "Settings"]); + + Assert.True(startupArguments.IsQuiet); + Assert.Equal("Settings", startupArguments.PrimaryArgument); + } + + [Fact] + public void ParseStartupArguments_FindsGrabFramePathCaseInsensitive() + { + string tempFilePath = Path.GetTempFileName(); + + try + { + App.StartupArguments startupArguments = App.ParseStartupArguments(["--GRABFRAME", tempFilePath]); + + Assert.True(startupArguments.OpenInGrabFrame); + Assert.Equal(tempFilePath, startupArguments.PrimaryArgument); + Assert.Equal(Path.GetFullPath(tempFilePath), startupArguments.GrabFramePath); + } + finally + { + File.Delete(tempFilePath); + } + } +} diff --git a/Tests/StringMethodTests.cs b/Tests/StringMethodTests.cs index a0412b95..41f6b535 100644 --- a/Tests/StringMethodTests.cs +++ b/Tests/StringMethodTests.cs @@ -1,4 +1,6 @@ -using System.Text; +using System; +using System.Collections.Generic; +using System.Text; using Text_Grab; using Text_Grab.Utilities; @@ -6,6 +8,20 @@ namespace Tests; public class StringMethodTests { + private sealed class PredictableRandom(params int[] values) : Random + { + private readonly Queue values = new(values); + + public override int Next(int maxValue) + { + Assert.NotEmpty(values); + + int nextValue = values.Dequeue(); + Assert.InRange(nextValue, 0, maxValue - 1); + return nextValue; + } + } + [Fact] public void MakeMultiLineStringSingleLine() { @@ -22,6 +38,42 @@ This has Assert.Equal(lineOfText, bodyOfText.MakeStringSingleLine()); } + [Fact] + public void MakeStringSingleLine_NewlineOnly_ReturnsEmptyString() + { + Assert.Equal(string.Empty, Environment.NewLine.MakeStringSingleLine()); + } + + [Fact] + public void JoinLines_WithJoiningTextAndAffixes_AsExpected() + { + string input = $"alpha{Environment.NewLine}beta{Environment.NewLine}gamma"; + + string actual = input.JoinLines(", ", trimLineBeforeJoining: false, "[", "]"); + + Assert.Equal("[alpha, beta, gamma]", actual); + } + + [Fact] + public void JoinLines_TrimEachLineBeforeJoining_AsExpected() + { + string input = " alpha \r\n\tbeta\t\r\ngamma "; + + string actual = input.JoinLines(" | ", trimLineBeforeJoining: true); + + Assert.Equal("alpha | beta | gamma", actual); + } + + [Fact] + public void JoinLines_TrailingLineBreak_DoesNotAddExtraJoiningText() + { + const string input = "alpha\nbeta\n"; + + string actual = input.JoinLines(", ", trimLineBeforeJoining: false); + + Assert.Equal("alpha, beta", actual); + } + [Theory] [InlineData("", "")] [InlineData("is", "This is test string data")] @@ -35,6 +87,28 @@ public void ReturnWordAtCursorPositionSix(string expectedWord, string fullLine) Assert.Equal(expectedWord, singleWordAtSix); } + [Theory] + [InlineData("there", "hello there", 11)] + [InlineData("world", "hello world", 10)] + [InlineData("Alpha", "Alpha", 5)] + [InlineData("hello", " hello", 0)] + public void CursorWordBoundaries_ClampsEndOfTextToNearestWord(string expectedWord, string input, int cursorPosition) + { + (int start, int length) = input.CursorWordBoundaries(cursorPosition); + + Assert.Equal(expectedWord, input.Substring(start, length)); + } + + [Fact] + public void CursorWordBoundaries_AllWhitespace_ReturnsEmptyRange() + { + const string input = " "; + + (int start, int length) = input.CursorWordBoundaries(1); + + Assert.Equal(string.Empty, input.Substring(start, length)); + } + private static string multiLineInput = @"Hello this is lots of text which has several lines and some spaces at the ends of line @@ -140,6 +214,34 @@ Another Line Assert.Equal(expectedString, actualString); } + [Fact] + public void ShuffleLines_UsesProvidedRandom() + { + string inputString = @"one +two +three +four"; + + string actualString = inputString.ShuffleLines(new PredictableRandom(1, 1, 0)); + + Assert.Equal( + @"three +one +four +two", + actualString); + } + + [Fact] + public void ShuffleLines_PreservesTrailingNewline() + { + string inputString = $"alpha{Environment.NewLine}beta{Environment.NewLine}"; + + string actualString = inputString.ShuffleLines(new PredictableRandom(0)); + + Assert.Equal($"beta{Environment.NewLine}alpha{Environment.NewLine}", actualString); + } + // { ' ', '"', '*', '/', ':', '<', '>', '?', '\\', '|', '+', ',', '.', ';', '=', '[', ']', '!', '@' }; [Theory] [InlineData("", "")] @@ -429,30 +531,6 @@ public void TestLimitEachLine(string inputString, string expected, int charLimit Assert.Equal(expected, inputString.LimitCharactersPerLine(charLimit, spotInLine)); } - private string actualGuids = """ - 97a56312-d8e8-4ca5-87fa-18e35266d31e - bdc5a5f2-d6ff-403d-a632-f9006387e149 - aeef14aa-9aff-4f0d-8ca5-e5df1b399c20 - c702f24c-e51b-4ebd-bb45-56df08266e80 - a87f9201-a046-425d-b92b-b488667a5d92 - bc656414-4f2a-4219-b763-810632a535e2 - 11c5ecc4-0c0a-4606-a54f-16df976637d1 - 5cec5cc9-782d-47aa-bff3-c84e13a81604 - 8501db7b-ee04-4fb2-8516-f5e2f0bc71bf - 8da03c16-6d3f-4750-831b-c3866af85551 - 03d82c33-489c-41b2-8222-cc489d00b1bf - edf3b5ee-658e-41ea-8f7a-494a07beb322 - 4418874a-30c7-4a16-aba5-0f2a0c49b4f9 - d4144186-4fad-40a0-bda9-7e3a2ea58a48 - 486d81d0-0d56-466b-856c-0bc37e897b7b - 935155d5-1a96-4901-8b7d-23854fceb32d - ff826fac-d166-441e-8040-05218989e805 - 0a4ed755-f236-4e10-8b0b-592a527bb560 - 9be83ad8-5e2d-4e37-a9f5-9b728cd9b934 - 926ef504-264d-4762-b781-8813156eaa86 - """; - - [Theory] [InlineData("g7a56312-d8e8-4ca5-87fa-18e3S266d3le", "97a56312-d8e8-4ca5-87fa-18e35266d31e")] [InlineData("g7a56312-d8e 8-4ca5-87fa-18e3S2 66d3le", "97a56312-d8e8-4ca5-87fa-18e35266d31e")] diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 3a93deb7..e7955cb9 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,12 +13,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +26,7 @@ - + @@ -47,6 +46,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest @@ -56,9 +58,6 @@ PreserveNewest - - PreserveNewest - diff --git a/Tests/TextSearchUtilitiesTests.cs b/Tests/TextSearchUtilitiesTests.cs new file mode 100644 index 00000000..70abc954 --- /dev/null +++ b/Tests/TextSearchUtilitiesTests.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; +using Text_Grab.Utilities; + +namespace Tests; + +public class TextSearchUtilitiesTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", true)] + [InlineData(" ", true)] + [InlineData("text", true)] + [InlineData("\t", true)] + [InlineData("\n", true)] + public void HasSearchText_TreatsWhitespaceAsSearchableInput(string? searchText, bool expected) + { + Assert.Equal(expected, TextSearchUtilities.HasSearchText(searchText)); + } + + [Fact] + public void CreateFindAndReplaceSearchRegex_MatchesLiteralDoubleSpaces() + { + Regex regex = TextSearchUtilities.CreateFindAndReplaceSearchRegex( + " ".EscapeSpecialRegexChars(matchExactly: false), + usePatternMode: false, + exactMatch: false); + + Match match = regex.Match("alpha beta"); + + Assert.True(match.Success); + Assert.Equal(" ", match.Value); + } + + [Fact] + public void CreateReplacementRegex_CollapsesDoubleSpaces() + { + Regex regex = TextSearchUtilities.CreateReplacementRegex( + " ".EscapeSpecialRegexChars(matchExactly: false), + exactMatch: false); + + string replaced = regex.Replace("alpha beta gamma", " "); + + Assert.Equal("alpha beta gamma", replaced); + } + + [Fact] + public void CreateGrabFrameSearchRegex_TreatsSpacesLiterally() + { + Regex regex = TextSearchUtilities.CreateGrabFrameSearchRegex("a b", exactMatch: true); + + Assert.Matches(regex, "a b"); + Assert.DoesNotMatch(regex, "ab"); + } + + [Theory] + [InlineData(" ", "·")] + [InlineData(" ", "··")] + [InlineData("\t", "⇥")] + [InlineData("\n", "⏎")] + [InlineData("\r", "␍")] + [InlineData("\r\n", "⏎")] + public void FormatMatchTextForDisplay_MakesWhitespaceMatchesVisible(string input, string expected) + { + Assert.Equal(expected, TextSearchUtilities.FormatMatchTextForDisplay(input)); + } +} diff --git a/Tests/ThirdPartyNoticeUtilitiesTests.cs b/Tests/ThirdPartyNoticeUtilitiesTests.cs new file mode 100644 index 00000000..84b896f3 --- /dev/null +++ b/Tests/ThirdPartyNoticeUtilitiesTests.cs @@ -0,0 +1,71 @@ +using System.Linq; +using Text_Grab.Utilities; + +namespace Tests; + +public class ThirdPartyNoticeUtilitiesTests +{ + [Fact] + public void PackageCatalog_CoversAllDirectReferences() + { + string[] expectedPackageIds = + [ + "BenchmarkDotNet", + "CliWrap", + "coverlet.collector", + "Dapplo.Windows.User32", + "Humanizer.Core", + "Magick.NET-Q16-AnyCPU", + "Magick.NET.SystemDrawing", + "Magick.NET.SystemWindowsMedia", + "Markdig", + "Microsoft.NET.Test.Sdk", + "Microsoft.Toolkit.Uwp.Notifications", + "Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers", + "Microsoft.WindowsAppSDK.AI", + "Microsoft.WindowsAppSDK.Foundation", + "Microsoft.WindowsAppSDK.Runtime", + "Microsoft.WindowsAppSDK.WinUI", + "NCalcAsync", + "PdfPig", + "UnitsNet", + "WPF-UI", + "WPF-UI.Tray", + "xunit.runner.visualstudio", + "Xunit.StaFact", + "xunit.v3", + "ZXing.Net", + "ZXing.Net.Bindings.Windows.Compatibility" + ]; + + string[] actualPackageIds = ThirdPartyNoticeUtilities.Packages + .Select(package => package.PackageId) + .OrderBy(packageId => packageId) + .ToArray(); + + Assert.Equal(expectedPackageIds.OrderBy(packageId => packageId), actualPackageIds); + } + + [Fact] + public void PackageCatalog_ProvidesProjectAndNoticeLinksForEveryEntry() + { + Assert.All( + ThirdPartyNoticeUtilities.Packages, + package => + { + Assert.True(Uri.IsWellFormedUriString(package.ProjectUrl, UriKind.Absolute), package.PackageId); + Assert.False(string.IsNullOrWhiteSpace(package.NoticeTarget), package.PackageId); + }); + } + + [Fact] + public void PackageCatalog_UsesLocalNoticeForMarkdig() + { + var package = ThirdPartyNoticeUtilities.Packages + .SingleOrDefault(package => package.PackageId == "Markdig"); + + Assert.NotNull(package); + Assert.True(package.NoticeIsLocal); + Assert.Equal(@"ThirdPartyNotices\licenses\Markdig-license.txt", package.NoticeTarget); + } +} diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs index e8d116a0..950728a8 100644 --- a/Tests/UnitConversionTests.cs +++ b/Tests/UnitConversionTests.cs @@ -15,6 +15,10 @@ public class UnitConversionTests [InlineData("1 kg to pounds", "lb")] [InlineData("3.5 gallons to liters", "L")] [InlineData("60 mph to km/h", "km/h")] + [InlineData("8 min/mi to mph", "mph")] + [InlineData("9:30 min/mi to mph", "mph")] + [InlineData("8 min/mi to min/km", "min/km")] + [InlineData("12 km/hr to min/km", "min/km")] [InlineData("1 acre to sq m", "m²")] [InlineData("12 inches to feet", "ft")] [InlineData("1000 grams to kg", "kg")] @@ -41,6 +45,13 @@ public async Task ExplicitConversion_ContainsTargetUnit(string input, string exp [InlineData("1 kg to grams", 1000, 0.01)] [InlineData("100 cm to meters", 1, 0.01)] [InlineData("1 tonne to kg", 1000, 0.01)] + [InlineData("8 min/mi to mph", 7.5, 0.01)] + [InlineData("9:30 min/mi to mph", 6.316, 0.01)] + [InlineData("8 min/mi to km/h", 12.070, 0.01)] + [InlineData("8 min/mi to min/km", 4.971, 0.01)] + [InlineData("5 min/km to km/h", 12, 0.01)] + [InlineData("12 km/hr to min/km", 5, 0.01)] + [InlineData("6 mph to min/mi", 10, 0.01)] public async Task ExplicitConversion_CorrectNumericValue(string input, double expectedValue, double tolerance) { CalculationResult result = await _service.EvaluateExpressionsAsync(input); @@ -72,6 +83,31 @@ public async Task ExplicitConversion_InKeyword_Works() Assert.InRange(result.OutputNumbers[0], 18.9, 18.95); } + [Theory] + [InlineData("3.6 years to days", "days", 1314.9, 0.01)] + [InlineData("48 hours in days", "days", 2, 0.001)] + [InlineData("90 minutes to hours", "hours", 1.5, 0.001)] + public async Task DurationConversion_ExplicitSyntax_Works(string input, string expectedUnit, double expectedValue, double tolerance) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Contains(expectedUnit, result.Output); + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], expectedValue - tolerance, expectedValue + tolerance); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DurationConversion_UsesFixedAverageMonthLength() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("1 month to days"); + + Assert.Equal("30.44 days", result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(30.44, result.OutputNumbers[0], 2); + Assert.Equal(0, result.ErrorCount); + } + [Fact] public async Task ExplicitConversion_ZeroValue_Works() { @@ -140,6 +176,35 @@ public async Task ContinuationConversion_ChainedConversions() Assert.InRange(result.OutputNumbers[2], 1609, 1610); } + [Fact] + public async Task ContinuationConversion_PaceToSpeed() + { + string input = "8 min/mi\nto km/h"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("min/mi", lines[0]); + Assert.Contains("km/h", lines[1]); + Assert.Equal(2, result.OutputNumbers.Count); + Assert.InRange(result.OutputNumbers[1], 12.06, 12.08); + } + + [Fact] + public async Task ContinuationConversion_PaceTimeToSpeed() + { + string input = "9:30 min/mi\nto mph"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("min/mi", lines[0]); + Assert.Contains("mph", lines[1]); + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(9.5, result.OutputNumbers[0], 3); + Assert.InRange(result.OutputNumbers[1], 6.30, 6.32); + } + #endregion Continuation Conversion Tests #region Operator Continuation Tests @@ -221,6 +286,9 @@ public async Task OperatorContinuation_ThenConvert() [InlineData("3.5 gallons", "gal")] [InlineData("10 miles", "mi")] [InlineData("25 mph", "mph")] + [InlineData("8 min/mi", "min/mi")] + [InlineData("9:30 min/mi", "min/mi")] + [InlineData("5 min/km", "min/km")] public async Task StandaloneUnit_DetectedAndDisplayed(string input, string expectedAbbrev) { CalculationResult result = await _service.EvaluateExpressionsAsync(input); @@ -234,6 +302,8 @@ public async Task StandaloneUnit_DetectedAndDisplayed(string input, string expec [InlineData("5 meters", 5)] [InlineData("100 kg", 100)] [InlineData("3.5 gallons", 3.5)] + [InlineData("8 min/mi", 8)] + [InlineData("9:30 min/mi", 9.5)] public async Task StandaloneUnit_CorrectNumericValue(string input, double expected) { CalculationResult result = await _service.EvaluateExpressionsAsync(input); @@ -277,6 +347,10 @@ public async Task StandaloneUnit_CorrectNumericValue(string input, double expect [InlineData("100 km/h to mph", "mph")] [InlineData("1 m/s to km/h", "km/h")] [InlineData("1 knot to mph", "mph")] + [InlineData("8 min/mi to mph", "mph")] + [InlineData("5 min/km to km/hr", "km/h")] + [InlineData("10 km/h to min/km", "min/km")] + [InlineData("6 mph to min/mi", "min/mi")] // Area [InlineData("1 acre to sq m", "m²")] [InlineData("1 hectare to acres", "ac")] @@ -374,6 +448,7 @@ public async Task DominantUnit_NullForPlainMath() [InlineData("100 fahrenheit to celsius", true)] [InlineData("100 F to C", true)] [InlineData("32 C to F", true)] + [InlineData("8 min/mi to mph", true)] [InlineData("2 + 3", false)] [InlineData("hello world", false)] [InlineData("x = 10", false)] diff --git a/Tests/WordBorderTests.cs b/Tests/WordBorderTests.cs new file mode 100644 index 00000000..92625b19 --- /dev/null +++ b/Tests/WordBorderTests.cs @@ -0,0 +1,21 @@ +using Text_Grab.Controls; + +namespace Tests; + +public class WordBorderTests +{ + [WpfFact] + public void ParagraphDisplayText_KeepsLogicalWordSingleLine() + { + WordBorder wordBorder = new() + { + KeepSingleLineOutput = true, + DisplayLineHeight = 18, + DisplayText = $"Static cling{Environment.NewLine}is useful" + }; + + Assert.Equal("Static cling is useful", wordBorder.Word); + Assert.Equal($"Static cling{Environment.NewLine}is useful", wordBorder.DisplayText); + Assert.True(wordBorder.KeepSingleLineOutput); + } +} diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index bccb2398..ff9f0dcb 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -14,7 +14,7 @@ + Version="4.14.0.0" /> Text Grab @@ -80,6 +80,22 @@ + + + + .csv + .tsv + .tab + .md + .markdown + .txt + + + Open with Text Grab + + + + diff --git a/Text-Grab/App.config b/Text-Grab/App.config index a1c719d6..497325ee 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -193,12 +193,18 @@ False + + True + False + + False + False @@ -241,6 +247,9 @@ False + + True + diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 85738b95..f84baa00 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -2,14 +2,12 @@ using Microsoft.Win32; using RegistryUtils; using System; -using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading.Tasks; using System.Windows; -using System.Windows.Markup; -using System.Windows.Media; using System.Windows.Threading; using Text_Grab.Controls; using Text_Grab.Models; @@ -19,7 +17,6 @@ using Text_Grab.Views; using Wpf.Ui; using Wpf.Ui.Appearance; -using Wpf.Ui.Extensions; namespace Text_Grab; @@ -28,9 +25,17 @@ namespace Text_Grab; /// public partial class App : System.Windows.Application { + internal readonly record struct StartupArguments( + bool IsQuiet, + bool OpenInGrabFrame, + string? PrimaryArgument, + string? GrabFramePath); + #region Fields private static readonly Settings _defaultSettings = AppUtilities.TextGrabSettings; + private static RegistryMonitor? _themeRegistryMonitor; + private static RegistryKey? _themeRegistryKey; #endregion Fields @@ -74,6 +79,49 @@ public static void DefaultLaunch() SetTheme(); } + public static async Task OpenFileWithPickerAsync(bool isQuiet = false) + { + OpenFileDialog openFileDialog = new() + { + Filter = FileUtilities.GetOpenDocumentFilter(), + Title = "Open File", + CheckFileExists = true, + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + }; + + if (openFileDialog.ShowDialog() == true) + await TryToOpenFilePathAsync(openFileDialog.FileName, isQuiet); + } + + public static DragDropEffects GetDroppedFileEffect(IDataObject? dataObject) + { + return GetDroppedFilePaths(dataObject).Any() + ? DragDropEffects.Copy + : DragDropEffects.None; + } + + public static IReadOnlyList GetDroppedFilePaths(IDataObject? dataObject) + { + if (dataObject is null || !dataObject.GetDataPresent(DataFormats.FileDrop, true)) + return []; + + + if (dataObject.GetData(DataFormats.FileDrop, true) is not string[] paths || paths.Length == 0) + return []; + + return [.. paths.Where(File.Exists)]; + } + + public static async Task TryToOpenDroppedFilesAsync(IDataObject? dataObject, bool isQuiet = false) + { + bool openedAny = false; + + foreach (string path in GetDroppedFilePaths(dataObject)) + openedAny = await TryToOpenFilePathAsync(path, isQuiet) || openedAny; + + return openedAny; + } + public static void SetTheme(object? sender = null, EventArgs? e = null) { bool gotTheme = Enum.TryParse(_defaultSettings.AppTheme.ToString(), true, out AppTheme currentAppTheme); @@ -162,15 +210,35 @@ public static void SetTheme(object? sender = null, EventArgs? e = null) public static void WatchTheme() { + StopWatchingTheme(); + if (Registry.CurrentUser.OpenSubKey(SystemThemeUtility.themeKeyPath) is not RegistryKey key) + { + SetTheme(); return; + } - RegistryMonitor monitor = new(key); - monitor.RegChanged += new EventHandler(SetTheme); - monitor.Start(); + _themeRegistryKey = key; + _themeRegistryMonitor = new RegistryMonitor(key); + _themeRegistryMonitor.RegChanged += new EventHandler(SetTheme); + _themeRegistryMonitor.Start(); SetTheme(); } + private static void StopWatchingTheme() + { + if (_themeRegistryMonitor is not null) + { + _themeRegistryMonitor.RegChanged -= new EventHandler(SetTheme); + try { _themeRegistryMonitor.Dispose(); } + catch (ObjectDisposedException) { } + _themeRegistryMonitor = null; + } + + _themeRegistryKey?.Dispose(); + _themeRegistryKey = null; + } + private static async Task CheckForOcringFolder(string currentArgument) { if (!Directory.Exists(currentArgument)) @@ -183,74 +251,84 @@ private static async Task CheckForOcringFolder(string currentArgument) return true; } - private static readonly HashSet KnownFlags = ["--windowless", "--grabframe"]; - - private static async Task HandleStartupArgs(string[] args) + internal static StartupArguments ParseStartupArguments(IEnumerable args) { - string currentArgument = args[0]; - bool isQuiet = false; bool openInGrabFrame = false; + string? primaryArgument = null; + string? grabFramePath = null; foreach (string arg in args) { - if (arg == "--windowless") + if (string.Equals(arg, "--windowless", StringComparison.OrdinalIgnoreCase)) { isQuiet = true; - _defaultSettings.FirstRun = false; - _defaultSettings.Save(); + continue; } - else if (arg == "--grabframe") + + if (string.Equals(arg, "--grabframe", StringComparison.OrdinalIgnoreCase)) { openInGrabFrame = true; + continue; } - } - // Handle --grabframe flag: open the next argument (file path) in GrabFrame - if (openInGrabFrame) - { - // Find the file path argument (skip known flags) - string? filePath = null; - foreach (string arg in args) + primaryArgument ??= arg; + + if (grabFramePath is not null) + continue; + + try + { + string absolutePath = Path.GetFullPath(arg); + if (File.Exists(absolutePath)) + grabFramePath = absolutePath; + } + catch (Exception ex) { - if (!KnownFlags.Contains(arg)) - { - // Convert to absolute path to handle relative paths correctly - try - { - string absolutePath = Path.GetFullPath(arg); - if (File.Exists(absolutePath)) - { - filePath = absolutePath; - break; - } - } - catch (Exception ex) - { - Debug.WriteLine($"Invalid path argument: {arg}, error: {ex.Message}"); - } - } + Debug.WriteLine($"Invalid path argument: {arg}, error: {ex.Message}"); } + } + + return new StartupArguments(isQuiet, openInGrabFrame, primaryArgument, grabFramePath); + } + + private static async Task HandleStartupArgs(string[] args) + { + StartupArguments startupArguments = ParseStartupArguments(args); + + if (startupArguments.IsQuiet) + { + _defaultSettings.FirstRun = false; + _defaultSettings.Save(); + } - if (!string.IsNullOrEmpty(filePath)) + // Handle --grabframe flag: open the next argument (file path) in GrabFrame + if (startupArguments.OpenInGrabFrame) + { + if (!string.IsNullOrEmpty(startupArguments.GrabFramePath)) { - GrabFrame gf = new(filePath); + GrabFrame gf = new(startupArguments.GrabFramePath); gf.Show(); return true; } else { - Debug.WriteLine("--grabframe flag specified but no valid image file path provided"); + Debug.WriteLine("--grabframe flag specified but no valid image or PDF file path provided"); // Fall through to default launch behavior } } - if (currentArgument.Contains("ToastActivated")) + if (string.IsNullOrWhiteSpace(startupArguments.PrimaryArgument)) + return false; + + string currentArgument = startupArguments.PrimaryArgument; + + if (currentArgument.Contains("ToastActivated", StringComparison.Ordinal)) { Debug.WriteLine("Launched from toast"); return true; } - else if (currentArgument == "Settings") + else if (string.Equals(currentArgument, "Settings", StringComparison.OrdinalIgnoreCase)) { SettingsWindow sw = new(); sw.Show(); @@ -265,7 +343,7 @@ private static async Task HandleStartupArgs(string[] args) return true; } - bool openedFile = await TryToOpenFile(currentArgument, isQuiet); + bool openedFile = await TryToOpenFilePathAsync(currentArgument, startupArguments.IsQuiet); if (openedFile) return true; @@ -305,7 +383,7 @@ private static void ShowAndSetFirstRun() _defaultSettings.Save(); } - private static async Task TryToOpenFile(string possiblePath, bool isQuiet) + public static async Task TryToOpenFilePathAsync(string possiblePath, bool isQuiet = false) { if (!File.Exists(possiblePath)) return false; @@ -318,7 +396,7 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) false, false); } - else if (IoUtilities.IsImageFile(possiblePath)) + else if (IoUtilities.IsVisualDocumentFile(possiblePath)) { GrabFrame gf = new(possiblePath); gf.Show(); @@ -329,6 +407,7 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) EditTextWindow manipulateTextWindow = new(); manipulateTextWindow.OpenPath(possiblePath); manipulateTextWindow.Show(); + manipulateTextWindow.Activate(); } return true; } @@ -336,7 +415,15 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) private void appExit(object sender, ExitEventArgs e) { TextGrabIcon?.Close(); - Singleton.Instance.WriteHistory(); + + NotifyIconUtilities.UnregisterHotkeys(this); + HotKeyIds.Clear(); + + StopWatchingTheme(); + + HistoryService historyService = Singleton.Instance; + historyService.WriteHistory(); + historyService.Dispose(); } private async void appStartup(object sender, StartupEventArgs e) diff --git a/Text-Grab/Controls/BottomBarSettings.xaml b/Text-Grab/Controls/BottomBarSettings.xaml index 1a540dfe..6a041e8c 100644 --- a/Text-Grab/Controls/BottomBarSettings.xaml +++ b/Text-Grab/Controls/BottomBarSettings.xaml @@ -41,71 +41,79 @@ Margin="2,2,2,0" Padding="8,2" Icon="{StaticResource TextGrabIcon}" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + Symbol="{Binding Path=SymbolIcon, + Mode=TwoWay}" /> diff --git a/Text-Grab/Controls/BottomBarSettings.xaml.cs b/Text-Grab/Controls/BottomBarSettings.xaml.cs index faadc7ea..6b090ebe 100644 --- a/Text-Grab/Controls/BottomBarSettings.xaml.cs +++ b/Text-Grab/Controls/BottomBarSettings.xaml.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; using Text_Grab.Models; using Text_Grab.Properties; using Text_Grab.Utilities; using Wpf.Ui.Controls; +using TextBox = Wpf.Ui.Controls.TextBox; namespace Text_Grab.Controls; @@ -20,9 +24,12 @@ public BottomBarSettings() { InitializeComponent(); - List allBtns = [.. ButtonInfo.AllButtons]; + bool canUseCopilotPlus = WindowsAiUtilities.CanDeviceUseWinAI(); + List allBtns = [.. ButtonInfo.AllButtons + .Where(b => !b.RequiresCopilotPlus || canUseCopilotPlus)]; - ButtonsInRightList = [.. CustomBottomBarUtilities.GetCustomBottomBarItemsSetting()]; + ButtonsInRightList = [.. CustomBottomBarUtilities.GetCustomBottomBarItemsSetting() + .Where(b => !b.RequiresCopilotPlus || canUseCopilotPlus)]; RightListBox.ItemsSource = ButtonsInRightList; foreach (ButtonInfo cbutton in ButtonsInRightList) { @@ -31,6 +38,7 @@ public BottomBarSettings() ButtonsInLeftList = [.. allBtns]; LeftListBox.ItemsSource = ButtonsInLeftList; + _leftListView = CollectionViewSource.GetDefaultView(ButtonsInLeftList); ShowCursorTextCheckBox.IsChecked = DefaultSettings.ShowCursorText; ShowScrollbarCheckBox.IsChecked = DefaultSettings.ScrollBottomBar; @@ -48,6 +56,7 @@ public BottomBarSettings() private ObservableCollection ButtonsInLeftList { get; set; } private ObservableCollection ButtonsInRightList { get; set; } + private ICollectionView _leftListView = null!; #endregion Properties @@ -92,6 +101,14 @@ private void CloseBTN_Click(object sender, RoutedEventArgs e) this.Close(); } + private void FilterSearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + string filter = (sender as TextBox)?.Text.Trim() ?? string.Empty; + _leftListView.Filter = string.IsNullOrEmpty(filter) + ? null + : obj => obj is ButtonInfo btn && btn.ButtonText.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + private void MoveDownButton_Click(object sender, RoutedEventArgs e) { int newIndex = MoveDown(ButtonsInRightList, RightListBox.SelectedIndex); diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml b/Text-Grab/Controls/FindAndReplaceWindow.xaml index a6677039..8935d419 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml @@ -289,8 +289,7 @@ - - + @@ -302,23 +301,18 @@ + Text="{Binding LocationDisplay}" /> - diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs index 851735d3..3ad2ba26 100644 --- a/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs +++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; @@ -12,7 +11,6 @@ using System.Windows.Input; using System.Windows.Threading; using Text_Grab.Models; -using Text_Grab.Properties; using Text_Grab.Utilities; using Wpf.Ui.Controls; @@ -59,6 +57,8 @@ public FindAndReplaceWindow() #region Properties + private bool IsSpreadsheetSearch => textEditWindow?.IsSpreadsheetMode is true; + public List FindResults { get; set; } = []; public string StringFromWindow @@ -72,6 +72,8 @@ public EditTextWindow? TextEditWindow get => textEditWindow; set { + textEditWindow?.PassedTextControl.TextChanged -= EditTextBoxChanged; + textEditWindow = value; textEditWindow?.PassedTextControl.TextChanged += EditTextBoxChanged; @@ -85,9 +87,18 @@ public EditTextWindow? TextEditWindow public void SearchForText() { + if (IsSpreadsheetSearch) { SearchSpreadsheetCells(); return; } + FindResults.Clear(); ResultsListView.ItemsSource = null; + if (!TextSearchUtilities.HasSearchText(FindTextBox.Text)) + { + Matches = null; + MatchesText.Text = "0 Matches"; + return; + } + Pattern = FindTextBox.Text; // Auto-detect regex pattern: if starts with ^ and ends with $, enable regex mode and strip anchors @@ -109,16 +120,8 @@ public void SearchForText() // Otherwise, use RegexOptions for backward compatibility bool usingPatternMode = UsePatternCheckBox.IsChecked is true; bool exactMatch = ExactMatchCheckBox.IsChecked is true; - TimeSpan timeout = TimeSpan.FromSeconds(5); - - if (exactMatch) - Matches = Regex.Matches(StringFromWindow, Pattern, RegexOptions.Multiline, timeout); - else if (usingPatternMode) - // Pattern mode with inline (?i) flags - don't add redundant RegexOptions - Matches = Regex.Matches(StringFromWindow, Pattern, RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace, timeout); - else - // Non-pattern mode - use RegexOptions for case insensitivity - Matches = Regex.Matches(StringFromWindow, Pattern, RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace, timeout); + Regex regex = TextSearchUtilities.CreateFindAndReplaceSearchRegex(Pattern, usingPatternMode, exactMatch); + Matches = regex.Matches(StringFromWindow); } catch (RegexMatchTimeoutException) { @@ -138,7 +141,7 @@ public void SearchForText() return; } - if (Matches.Count < 1 || string.IsNullOrWhiteSpace(FindTextBox.Text)) + if (Matches.Count < 1) { MatchesText.Text = "0 Matches"; return; @@ -156,9 +159,10 @@ public void SearchForText() FindResult fr = new() { Index = m.Index, - Text = m.Value.MakeStringSingleLine(), + Text = TextSearchUtilities.FormatMatchTextForDisplay(m.Value), PreviewLeft = StringMethods.GetCharactersToLeftOfNewLine(ref stringFromWindow, m.Index, 12).MakeStringSingleLine(), PreviewRight = StringMethods.GetCharactersToRightOfNewLine(ref stringFromWindow, m.Index + m.Length, 12).MakeStringSingleLine(), + Length = m.Length, Count = count }; FindResults.Add(fr); @@ -180,6 +184,57 @@ public void SearchForText() } } + private Regex? BuildCurrentRegex() + { + string rawPattern = FindTextBox.Text; + if (!TextSearchUtilities.HasSearchText(rawPattern)) return null; + + if (rawPattern.StartsWith('^') && rawPattern.EndsWith('$') && rawPattern.Length > 2) + rawPattern = rawPattern[1..^1]; + + if (UsePatternCheckBox.IsChecked is false && ExactMatchCheckBox.IsChecked is bool matchExactly) + rawPattern = rawPattern.EscapeSpecialRegexChars(matchExactly); + + try { return TextSearchUtilities.CreateReplacementRegex(rawPattern, ExactMatchCheckBox.IsChecked is true); } + catch { return null; } + } + + private void SearchSpreadsheetCells() + { + FindResults.Clear(); + ResultsListView.ItemsSource = null; + Matches = null; + + if (textEditWindow is null || !TextSearchUtilities.HasSearchText(FindTextBox.Text)) + { + MatchesText.Text = "0 Matches"; + return; + } + + Regex? regex = BuildCurrentRegex(); + if (regex is null) { MatchesText.Text = "0 Matches"; return; } + + textEditWindow.CommitSpreadsheetAndSync(); + + List results; + try { results = textEditWindow.SearchSpreadsheetCells(regex); } + catch (RegexMatchTimeoutException) { MatchesText.Text = "Regex timeout"; return; } + + FindResults.AddRange(results); + if (FindResults.Count == 0) { MatchesText.Text = "0 Matches"; return; } + + MatchesText.Text = FindResults.Count == 1 ? "1 Match" : $"{FindResults.Count} Matches"; + ResultsListView.IsEnabled = true; + ResultsListView.ItemsSource = FindResults; + + FindResult first = FindResults[0]; + if (this.IsFocused && first.RowIndex.HasValue && first.ColumnIndex.HasValue) + { + textEditWindow.NavigateToSpreadsheetCell(first.RowIndex.Value, first.ColumnIndex.Value); + this.Focus(); + } + } + public void ShouldCloseWithThisETW(EditTextWindow etw) { if (textEditWindow is not null && etw == textEditWindow) @@ -200,6 +255,12 @@ private void PrecisionSlider_Tick(object? sender, EventArgs? e) private void CopyMatchesCmd_CanExecute(object sender, CanExecuteRoutedEventArgs e) { + if (IsSpreadsheetSearch) + { + e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text); + return; + } + if (Matches is null || Matches.Count < 1 || string.IsNullOrEmpty(FindTextBox.Text)) e.CanExecute = false; else @@ -208,9 +269,9 @@ private void CopyMatchesCmd_CanExecute(object sender, CanExecuteRoutedEventArgs private void CopyMatchesCmd_Executed(object sender, ExecutedRoutedEventArgs e) { - if (Matches is null - || textEditWindow is null - || Matches.Count < 1) + if (textEditWindow is null) return; + + if (!IsSpreadsheetSearch && (Matches is null || Matches.Count < 1)) return; StringBuilder stringBuilder = new(); @@ -230,6 +291,12 @@ private void CopyMatchesCmd_Executed(object sender, ExecutedRoutedEventArgs e) private void DeleteAll_CanExecute(object sender, CanExecuteRoutedEventArgs e) { + if (IsSpreadsheetSearch) + { + e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(FindTextBox.Text); + return; + } + if (Matches is not null && Matches.Count > 1 && !string.IsNullOrEmpty(FindTextBox.Text)) e.CanExecute = true; else @@ -238,24 +305,41 @@ private void DeleteAll_CanExecute(object sender, CanExecuteRoutedEventArgs e) private async void DeleteAll_Executed(object sender, ExecutedRoutedEventArgs e) { - if (Matches is null - || Matches.Count < 1 - || textEditWindow is null) + if (textEditWindow is null) return; + + if (IsSpreadsheetSearch) + { + if (FindResults.Count == 0) return; + SetWindowToLoading(); + Regex? regex = BuildCurrentRegex(); + if (regex is null) { ResetWindowLoading(); return; } + IList selection = ResultsListView.SelectedItems; + List targets = selection.Count >= 2 + ? [.. selection.Cast()] + : [.. ResultsListView.Items.Cast()]; + await Task.Run(() => Dispatcher.Invoke(() => + textEditWindow.ReplaceInSpreadsheetCells(targets, string.Empty, regex))); + SearchForText(); + ResetWindowLoading(); + return; + } + + if (Matches is null || Matches.Count < 1) return; SetWindowToLoading(); - IList selection = ResultsListView.SelectedItems; + IList selection2 = ResultsListView.SelectedItems; StringBuilder stringBuilderOfText = new(textEditWindow.PassedTextControl.Text); await Task.Run(() => { - if (selection.Count < 2) - selection = ResultsListView.Items; + if (selection2.Count < 2) + selection2 = ResultsListView.Items; - for (int j = selection.Count - 1; j >= 0; j--) + for (int j = selection2.Count - 1; j >= 0; j--) { - if (selection[j] is not FindResult selectedResult) + if (selection2[j] is not FindResult selectedResult) continue; stringBuilderOfText.Remove(selectedResult.Index, selectedResult.Length); @@ -270,6 +354,8 @@ await Task.Run(() => private void EditTextBoxChanged(object sender, TextChangedEventArgs e) { + if (IsSpreadsheetSearch) return; + ChangeFindTextTimer.Stop(); if (textEditWindow is not null) StringFromWindow = textEditWindow.PassedTextControl.Text; @@ -279,6 +365,8 @@ private void EditTextBoxChanged(object sender, TextChangedEventArgs e) private void ExtractPattern_CanExecute(object sender, CanExecuteRoutedEventArgs e) { + if (IsSpreadsheetSearch) { e.CanExecute = false; return; } + if (textEditWindow is not null && textEditWindow.PassedTextControl.SelectedText.Length > 0) e.CanExecute = true; @@ -312,7 +400,7 @@ private void ExtractPattern_Executed(object sender, ExecutedRoutedEventArgs e) private void FindAndReplacedLoaded(object sender, RoutedEventArgs e) { - if (!string.IsNullOrWhiteSpace(FindTextBox.Text)) + if (TextSearchUtilities.HasSearchText(FindTextBox.Text)) SearchForText(); // Update save button visibility on load @@ -374,7 +462,7 @@ private void OptionsChangedRefresh(object sender, RoutedEventArgs e) FindTextBox.Text = extractedPattern.GetPattern(precisionLevel); } } - else if (UsePatternCheckBox.IsChecked is true && !string.IsNullOrWhiteSpace(FindTextBox.Text)) + else if (UsePatternCheckBox.IsChecked is true && TextSearchUtilities.HasSearchText(FindTextBox.Text)) { // No extracted pattern, but we're in pattern mode - manually toggle (?i) flag string currentPattern = FindTextBox.Text; @@ -410,6 +498,12 @@ private void OptionsChangedRefresh(object sender, RoutedEventArgs e) private void Replace_CanExecute(object sender, CanExecuteRoutedEventArgs e) { + if (IsSpreadsheetSearch) + { + e.CanExecute = FindResults.Count > 0 && !string.IsNullOrEmpty(ReplaceTextBox.Text); + return; + } + if (string.IsNullOrEmpty(ReplaceTextBox.Text) || Matches is null || Matches.Count < 1) @@ -420,10 +514,21 @@ private void Replace_CanExecute(object sender, CanExecuteRoutedEventArgs e) private void Replace_Executed(object sender, ExecutedRoutedEventArgs e) { - if (Matches is null - || textEditWindow is null - || ResultsListView.Items.Count is 0) + if (textEditWindow is null || ResultsListView.Items.Count is 0) + return; + + if (IsSpreadsheetSearch) + { + if (ResultsListView.SelectedIndex == -1) ResultsListView.SelectedIndex = 0; + if (ResultsListView.SelectedItem is not FindResult fr) return; + Regex? regex = BuildCurrentRegex(); + if (regex is null) return; + textEditWindow.ReplaceInSpreadsheetCells([fr], ReplaceTextBox.Text, regex); + SearchForText(); return; + } + + if (Matches is null) return; if (ResultsListView.SelectedIndex == -1) ResultsListView.SelectedIndex = 0; @@ -439,26 +544,44 @@ private void Replace_Executed(object sender, ExecutedRoutedEventArgs e) private async void ReplaceAll_Executed(object sender, ExecutedRoutedEventArgs e) { - if (Matches is null - || Matches.Count < 1 - || textEditWindow is null) + if (textEditWindow is null) return; + + if (IsSpreadsheetSearch) + { + if (FindResults.Count == 0) return; + SetWindowToLoading(); + Regex? regex = BuildCurrentRegex(); + if (regex is null) { ResetWindowLoading(); return; } + IList selection = ResultsListView.SelectedItems; + List targets = selection.Count >= 2 + ? [.. selection.Cast()] + : [.. ResultsListView.Items.Cast()]; + string replaceWith = ReplaceTextBox.Text; + await Task.Run(() => Dispatcher.Invoke(() => + textEditWindow.ReplaceInSpreadsheetCells(targets, replaceWith, regex))); + SearchForText(); + ResetWindowLoading(); + return; + } + + if (Matches is null || Matches.Count < 1) return; SetWindowToLoading(); StringBuilder stringBuilder = new(textEditWindow.PassedTextControl.Text); - IList selection = ResultsListView.SelectedItems; + IList selection2 = ResultsListView.SelectedItems; string newText = ReplaceTextBox.Text; await Task.Run(() => { - if (selection.Count < 2) - selection = ResultsListView.Items; + if (selection2.Count < 2) + selection2 = ResultsListView.Items; - for (int j = selection.Count - 1; j >= 0; j--) + for (int j = selection2.Count - 1; j >= 0; j--) { - if (selection[j] is not FindResult selectedResult) + if (selection2[j] is not FindResult selectedResult) continue; stringBuilder.Remove(selectedResult.Index, selectedResult.Length); @@ -486,15 +609,21 @@ private void SetWindowToLoading() private void ResultsListView_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (ResultsListView.SelectedItem is not FindResult selectedResult) + if (ResultsListView.SelectedItem is not FindResult selectedResult || textEditWindow is null) return; - if (textEditWindow is not null) + if (IsSpreadsheetSearch) { - textEditWindow.PassedTextControl.Focus(); - textEditWindow.PassedTextControl.Select(selectedResult.Index, selectedResult.Length); + if (selectedResult.RowIndex.HasValue && selectedResult.ColumnIndex.HasValue) + textEditWindow.NavigateToSpreadsheetCell( + selectedResult.RowIndex.Value, selectedResult.ColumnIndex.Value); this.Focus(); + return; } + + textEditWindow.PassedTextControl.Focus(); + textEditWindow.PassedTextControl.Select(selectedResult.Index, selectedResult.Length); + this.Focus(); } private void SetExtraOptionsVisibility(Visibility optionsVisibility) @@ -509,7 +638,7 @@ private void SetExtraOptionsVisibility(Visibility optionsVisibility) private void TextSearch_CanExecute(object sender, CanExecuteRoutedEventArgs e) { - if (string.IsNullOrWhiteSpace(FindTextBox.Text)) + if (!TextSearchUtilities.HasSearchText(FindTextBox.Text)) e.CanExecute = false; else e.CanExecute = true; @@ -530,7 +659,7 @@ private void Window_KeyUp(object sender, KeyEventArgs e) { if (e.Key == Key.Escape) { - if (!string.IsNullOrWhiteSpace(FindTextBox.Text)) + if (TextSearchUtilities.HasSearchText(FindTextBox.Text)) FindTextBox.Clear(); else this.Close(); diff --git a/Text-Grab/Controls/JoinLinesWindow.xaml b/Text-Grab/Controls/JoinLinesWindow.xaml new file mode 100644 index 00000000..c06c3bb1 --- /dev/null +++ b/Text-Grab/Controls/JoinLinesWindow.xaml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Trim line before joining + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/JoinLinesWindow.xaml.cs b/Text-Grab/Controls/JoinLinesWindow.xaml.cs new file mode 100644 index 00000000..af2c5298 --- /dev/null +++ b/Text-Grab/Controls/JoinLinesWindow.xaml.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; +using Text_Grab.Utilities; + +namespace Text_Grab.Controls; + +/// +/// Interaction logic for JoinLinesWindow.xaml +/// +public partial class JoinLinesWindow : Wpf.Ui.Controls.FluentWindow +{ + private const int PreviewDebounceDelayMs = 250; + private const int PreviewLeadingSegmentCount = 3; + private const int PreviewTrailingSegmentCount = 2; + private const int PreviewLeadingLineCount = 8; + private const int PreviewTrailingLineCount = 4; + private const int PreviewMaxCharsPerSegment = 180; + private const int PreviewMaxCharsOverall = 420; + private const int PreviewMaxSourceCharsSingleLine = 240; + private const string PreviewOmittedText = "[...]"; + + private readonly DispatcherTimer previewDebounceTimer = new(); + private PreviewSegment[] previewSourceSegments = []; + private bool previewUsesSampling; + + public static RoutedCommand JoinLinesCmd = new(); + public static RoutedCommand ApplyCmd = new(); + + public JoinLinesWindow() + { + InitializeComponent(); + + previewDebounceTimer.Interval = TimeSpan.FromMilliseconds(PreviewDebounceDelayMs); + previewDebounceTimer.Tick += PreviewDebounceTimer_Tick; + } + + private void JoinLines_CanExecute(object sender, CanExecuteRoutedEventArgs e) + { + e.CanExecute = Owner is EditTextWindow; + } + + private void JoinLines_Executed(object sender, ExecutedRoutedEventArgs e) + { + ApplyJoinLines(); + Close(); + } + + private void Apply_Executed(object sender, ExecutedRoutedEventArgs e) + { + ApplyJoinLines(); + } + + private void ApplyJoinLines() + { + if (Owner is not EditTextWindow etwOwner) + return; + + etwOwner.JoinLinesInEditTextWindow( + JoiningTextTextBox.Text, + TrimLineBeforeJoiningToggle.IsChecked is true, + TextAtBeginningTextBox.Text, + TextAtEndTextBox.Text); + } + + private void PreviewDebounceTimer_Tick(object? sender, EventArgs e) + { + previewDebounceTimer.Stop(); + UpdatePreview(); + } + + private void PreviewInputChanged(object sender, RoutedEventArgs e) + { + if (!IsLoaded) + return; + + previewDebounceTimer.Stop(); + previewDebounceTimer.Start(); + } + + private void Window_KeyUp(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Close(); + } + + private void Window_Loaded(object sender, RoutedEventArgs e) + { + if (Owner is EditTextWindow etwOwner) + { + (previewSourceSegments, previewUsesSampling) = BuildPreviewSegments(etwOwner.GetSelectedOrAllTextSegmentsForPreview()); + UpdatePreview(); + } + + JoiningTextTextBox.Focus(); + JoiningTextTextBox.SelectAll(); + } + + private void Window_Closed(object? sender, EventArgs e) + { + previewDebounceTimer.Stop(); + previewSourceSegments = []; + PreviewTextBox.Clear(); + } + + private void UpdatePreview() + { + bool previewWasTruncated = false; + string previewText = BuildPreviewText(ref previewWasTruncated); + + if (!string.Equals(PreviewTextBox.Text, previewText, StringComparison.Ordinal)) + PreviewTextBox.Text = previewText; + + PreviewHeaderTextBlock.Text = previewUsesSampling || previewWasTruncated ? "Preview (sampled)" : "Preview"; + } + + private string BuildPreviewText(ref bool previewWasTruncated) + { + if (previewSourceSegments.Length == 0) + return string.Empty; + + StringBuilder previewBuilder = new(PreviewMaxCharsOverall + 64); + + for (int i = 0; i < previewSourceSegments.Length; i++) + { + if (i > 0) + previewBuilder.Append(Environment.NewLine); + + PreviewSegment previewSegment = previewSourceSegments[i]; + if (previewSegment.IsPlaceholder) + { + previewBuilder.Append(previewSegment.Text); + continue; + } + + string transformedSegment = previewSegment.Text.JoinLines( + JoiningTextTextBox.Text, + TrimLineBeforeJoiningToggle.IsChecked is true, + TextAtBeginningTextBox.Text, + TextAtEndTextBox.Text); + + previewBuilder.Append(TruncateMiddle(transformedSegment, PreviewMaxCharsPerSegment, ref previewWasTruncated)); + } + + return TruncateMiddle(previewBuilder.ToString(), PreviewMaxCharsOverall, ref previewWasTruncated); + } + + private static (PreviewSegment[] Segments, bool UsesSampling) BuildPreviewSegments(IEnumerable sourceSegments) + { + List leadingSegments = []; + Queue trailingSegments = new(); + int totalSegmentCount = 0; + bool usesSampling = false; + + foreach (string sourceSegment in sourceSegments) + { + string previewSegmentText = SampleSegmentText(sourceSegment, out bool segmentWasSampled); + PreviewSegment previewSegment = new(previewSegmentText); + + if (totalSegmentCount < PreviewLeadingSegmentCount) + leadingSegments.Add(previewSegment); + + if (PreviewTrailingSegmentCount > 0) + { + if (trailingSegments.Count == PreviewTrailingSegmentCount) + trailingSegments.Dequeue(); + + trailingSegments.Enqueue(previewSegment); + } + + usesSampling |= segmentWasSampled; + totalSegmentCount++; + } + + if (totalSegmentCount <= PreviewLeadingSegmentCount + PreviewTrailingSegmentCount) + { + PreviewSegment[] trailingArray = [.. trailingSegments]; + int overlapCount = Math.Max(0, leadingSegments.Count + trailingArray.Length - totalSegmentCount); + + PreviewSegment[] segmentsWithoutOverlap = + overlapCount == 0 ? trailingArray : trailingArray[overlapCount..]; + + return ([.. leadingSegments, .. segmentsWithoutOverlap], usesSampling); + } + + usesSampling = true; + return ([.. leadingSegments, new PreviewSegment(PreviewOmittedText, true), .. trailingSegments], usesSampling); + } + + private static string SampleSegmentText(string sourceText, out bool segmentWasSampled) + { + if (string.IsNullOrEmpty(sourceText)) + { + segmentWasSampled = false; + return sourceText; + } + + List<(int Start, int Length)> leadingLineRanges = []; + Queue<(int Start, int Length)> trailingLineRanges = new(); + int totalLineCount = 0; + int index = 0; + + while (index < sourceText.Length) + { + int lineStart = index; + + while (index < sourceText.Length + && sourceText[index] != '\r' + && sourceText[index] != '\n') + { + index++; + } + + int lineLength = index - lineStart; + + if (totalLineCount < PreviewLeadingLineCount) + leadingLineRanges.Add((lineStart, lineLength)); + + if (PreviewTrailingLineCount > 0) + { + if (trailingLineRanges.Count == PreviewTrailingLineCount) + trailingLineRanges.Dequeue(); + + trailingLineRanges.Enqueue((lineStart, lineLength)); + } + + totalLineCount++; + + if (index >= sourceText.Length) + break; + + if (sourceText[index] == '\r' + && index + 1 < sourceText.Length + && sourceText[index + 1] == '\n') + { + index += 2; + } + else + { + index++; + } + } + + if (totalLineCount <= 1) + { + segmentWasSampled = false; + string truncatedSingleLine = TruncateMiddle(sourceText, PreviewMaxSourceCharsSingleLine, ref segmentWasSampled); + return truncatedSingleLine; + } + + if (totalLineCount <= PreviewLeadingLineCount + PreviewTrailingLineCount) + { + segmentWasSampled = false; + return sourceText; + } + + segmentWasSampled = true; + StringBuilder sampledTextBuilder = new(); + AppendLineRanges(sampledTextBuilder, sourceText, leadingLineRanges); + sampledTextBuilder.Append(Environment.NewLine); + sampledTextBuilder.Append(PreviewOmittedText); + + foreach ((int start, int length) in trailingLineRanges) + { + sampledTextBuilder.Append(Environment.NewLine); + sampledTextBuilder.Append(sourceText, start, length); + } + + return sampledTextBuilder.ToString(); + } + + private static void AppendLineRanges(StringBuilder builder, string sourceText, IEnumerable<(int Start, int Length)> lineRanges) + { + bool isFirstLine = true; + + foreach ((int start, int length) in lineRanges) + { + if (!isFirstLine) + builder.Append(Environment.NewLine); + + builder.Append(sourceText, start, length); + isFirstLine = false; + } + } + + private static string TruncateMiddle(string text, int maxLength, ref bool wasTruncated) + { + if (text.Length <= maxLength) + return text; + + wasTruncated = true; + + int remainingLength = maxLength - PreviewOmittedText.Length; + int prefixLength = remainingLength / 2; + int suffixLength = remainingLength - prefixLength; + + StringBuilder truncatedBuilder = new(maxLength); + truncatedBuilder.Append(text, 0, prefixLength); + truncatedBuilder.Append(PreviewOmittedText); + truncatedBuilder.Append(text, text.Length - suffixLength, suffixLength); + return truncatedBuilder.ToString(); + } + + private readonly record struct PreviewSegment(string Text, bool IsPlaceholder = false); +} diff --git a/Text-Grab/Controls/LanguagePicker.xaml.cs b/Text-Grab/Controls/LanguagePicker.xaml.cs index 1edc9075..2f9b1da2 100644 --- a/Text-Grab/Controls/LanguagePicker.xaml.cs +++ b/Text-Grab/Controls/LanguagePicker.xaml.cs @@ -44,14 +44,14 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) // it needs to represent real languages and not just OCR engine target languages // As new models are supported they will need to be caught and filtered here too - if (currentSelectedLanguage is UiAutomationLang or WindowsAiLang) + if (currentSelectedLanguage is UiAutomationLang or WindowsAiLang or WindowsAiDescriptionLang) currentSelectedLanguage = new GlobalLang(keyboardLanguage.Name); int selectedIndex = 0; int i = 0; foreach (ILanguage langFromUtil in LanguageUtilities.GetAllLanguages()) { - if (langFromUtil is UiAutomationLang or WindowsAiLang) + if (langFromUtil is UiAutomationLang or WindowsAiLang or WindowsAiDescriptionLang) continue; Languages.Add(langFromUtil); diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index bd6e13f7..10ee70fe 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -10,14 +10,16 @@ Title="NotifyIconWindow" Width="0" Height="0" - Activated="Window_Activated" Background="Transparent" IsHitTestVisible="False" Left="-50" + Loaded="Window_Loaded" Opacity="0" - ShowInTaskbar="True" + ShowActivated="False" + ShowInTaskbar="False" Top="-50" - WindowStyle="ToolWindow" + Visibility="Hidden" + WindowStyle="None" mc:Ignorable="d"> + + + + + .Instance.GetEditWindows().LastOrDefault(); @@ -151,7 +200,7 @@ private void OpenClipboardImageGrabFrame_Click(object sender, RoutedEventArgs e) BitmapSource? bitmapSource = null; - if (clipboardImage is System.Windows.Interop.InteropBitmap interopBitmap) + if (clipboardImage is InteropBitmap interopBitmap) { System.Drawing.Bitmap bmp = ImageMethods.InteropBitmapToBitmap(interopBitmap); bitmapSource = ImageMethods.BitmapToImageSource(bmp); diff --git a/Text-Grab/Controls/PdfTextLineOverlay.cs b/Text-Grab/Controls/PdfTextLineOverlay.cs new file mode 100644 index 00000000..a08ecaf3 --- /dev/null +++ b/Text-Grab/Controls/PdfTextLineOverlay.cs @@ -0,0 +1,90 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Text_Grab.Utilities; + +namespace Text_Grab.Controls; + +internal sealed class PdfTextLineOverlay : Border +{ + private static readonly Brush DefaultBorderBrush = new SolidColorBrush(Color.FromArgb(0x90, 0x00, 0x78, 0xD7)); + private static readonly Brush DefaultHighlightBrush = new SolidColorBrush(Color.FromArgb(0x50, 0x00, 0x78, 0xD7)); + private static readonly Brush TransparentTextBrush = new SolidColorBrush(Colors.Transparent); + + public PdfTextLineOverlay(string text) + { + Text = text; + Child = new TextBlock + { + Text = text, + Foreground = TransparentTextBrush, + TextWrapping = TextWrapping.NoWrap, + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(1, 0, 1, 0), + IsHitTestVisible = false + }; + + Background = Brushes.Transparent; + BorderBrush = Brushes.Transparent; + BorderThickness = new Thickness(0); + ClipToBounds = true; + IsHitTestVisible = true; + SnapsToDevicePixels = true; + } + + public bool IsSelected { get; private set; } + + public double Left + { + get => Canvas.GetLeft(this); + private set => Canvas.SetLeft(this, value); + } + + public double Top + { + get => Canvas.GetTop(this); + private set => Canvas.SetTop(this, value); + } + + public string Text { get; } + + public bool WasRegionSelected { get; set; } + + public void ApplyLayout(Rect bounds) + { + Width = Math.Max(1, bounds.Width + 2); + Height = Math.Max(1, bounds.Height + 2); + Left = Math.Max(0, bounds.X - 1); + Top = Math.Max(0, bounds.Y - 1); + + if (Child is TextBlock textBlock) + { + textBlock.FontSize = Math.Max(1, bounds.Height * 0.75); + textBlock.LineHeight = Math.Max(1, bounds.Height); + } + } + + public void Deselect() + { + IsSelected = false; + Background = Brushes.Transparent; + BorderBrush = Brushes.Transparent; + BorderThickness = new Thickness(0); + } + + public bool IntersectsWith(Rect rectToCheck) + { + Rect overlayRect = new(Left, Top, Width, Height); + return rectToCheck.IntersectsWith(overlayRect); + } + + public void Select() + { + IsSelected = true; + Background = DefaultHighlightBrush; + BorderBrush = DefaultBorderBrush; + BorderThickness = new Thickness(1); + } +} diff --git a/Text-Grab/Controls/QrCodeWindow.xaml.cs b/Text-Grab/Controls/QrCodeWindow.xaml.cs index 59d96342..085df232 100644 --- a/Text-Grab/Controls/QrCodeWindow.xaml.cs +++ b/Text-Grab/Controls/QrCodeWindow.xaml.cs @@ -80,7 +80,10 @@ private void CodeImage_MouseLeftButtonDown(object sender, System.Windows.Input.M private void CopyButton_Click(object sender, RoutedEventArgs e) { - Clipboard.SetData(DataFormats.Bitmap, QrBitmap); + if (QrBitmap is not Bitmap qrBitmap) + return; + + Clipboard.SetData(DataFormats.Bitmap, qrBitmap); } private void ErrorCorrectionComboBox_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) diff --git a/Text-Grab/Controls/WordBorder.xaml b/Text-Grab/Controls/WordBorder.xaml index 833e9588..9461a2d7 100644 --- a/Text-Grab/Controls/WordBorder.xaml +++ b/Text-Grab/Controls/WordBorder.xaml @@ -154,7 +154,7 @@ GotFocus="EditWordTextBox_GotFocus" MouseDown="EditWordTextBox_MouseDown" Style="{StaticResource TransparentTextBox}" - Text="{Binding ElementName=WordBorderControl, Path=Word, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + Text="{Binding ElementName=WordBorderControl, Path=DisplayText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="EditWordTextBox_TextChanged" Visibility="Visible" /> diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index 44a80425..ef7c9394 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -22,7 +22,16 @@ public partial class WordBorder : UserControl, INotifyPropertyChanged // Using a DependencyProperty as the backing store for Word. This enables animation, styling, binding, etc... public static readonly DependencyProperty WordProperty = - DependencyProperty.Register("Word", typeof(string), typeof(WordBorder), new PropertyMetadata("")); + DependencyProperty.Register(nameof(Word), typeof(string), typeof(WordBorder), new PropertyMetadata(string.Empty, OnWordChanged)); + + public static readonly DependencyProperty DisplayTextProperty = + DependencyProperty.Register(nameof(DisplayText), typeof(string), typeof(WordBorder), new PropertyMetadata(string.Empty, OnDisplayTextChanged)); + + public static readonly DependencyProperty KeepSingleLineOutputProperty = + DependencyProperty.Register(nameof(KeepSingleLineOutput), typeof(bool), typeof(WordBorder), new PropertyMetadata(false, OnLayoutPropertyChanged)); + + public static readonly DependencyProperty DisplayLineHeightProperty = + DependencyProperty.Register(nameof(DisplayLineHeight), typeof(double), typeof(WordBorder), new PropertyMetadata(0d, OnLayoutPropertyChanged)); public static readonly DependencyProperty TemplateIndexProperty = DependencyProperty.Register(nameof(TemplateIndex), typeof(int), typeof(WordBorder), @@ -40,7 +49,8 @@ private static void OnTemplateIndexChanged(DependencyObject d, DependencyPropert public static RoutedCommand MergeWordsCommand = new(); private int contextMenuBaseSize; private SolidColorBrush contrastingForeground = new(Colors.White); - private DispatcherTimer debounceTimer = new(); + private readonly DispatcherTimer debounceTimer = new(); + private bool isSyncingTextProperties; private double left = 0; private SolidColorBrush matchingBackground = new(Colors.Black); private double top = 0; @@ -58,7 +68,10 @@ public WordBorder(WordBorderInfo info) { StandardInitialization(); + KeepSingleLineOutput = info.KeepSingleLineOutput; + DisplayLineHeight = info.DisplayLineHeight; Word = info.Word; + DisplayText = string.IsNullOrWhiteSpace(info.DisplayText) ? info.Word : info.DisplayText; Left = info.BorderRect.Left; Top = info.BorderRect.Top; Width = info.BorderRect.Width; @@ -80,10 +93,47 @@ private void StandardInitialization() InitializeComponent(); DataContext = this; contextMenuBaseSize = WordBorderBorder.ContextMenu.Items.Count; + Loaded += WordBorder_Loaded; + SizeChanged += WordBorder_SizeChanged; debounceTimer.Interval = new(0, 0, 0, 0, 300); debounceTimer.Tick += DebounceTimer_Tick; } + + private static void OnDisplayTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not WordBorder wb || wb.isSyncingTextProperties) + return; + + wb.isSyncingTextProperties = true; + wb.Word = wb.KeepSingleLineOutput + ? (e.NewValue as string ?? string.Empty).MakeStringSingleLine() + : e.NewValue as string ?? string.Empty; + wb.isSyncingTextProperties = false; + wb.PropertyChanged?.Invoke(wb, new PropertyChangedEventArgs(nameof(DisplayText))); + wb.ApplyTextLayout(); + } + + private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is WordBorder wb) + wb.ApplyTextLayout(); + } + + private static void OnWordChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not WordBorder wb) + return; + + if (!wb.isSyncingTextProperties) + { + wb.isSyncingTextProperties = true; + wb.DisplayText = e.NewValue as string ?? string.Empty; + wb.isSyncingTextProperties = false; + } + + wb.PropertyChanged?.Invoke(wb, new PropertyChangedEventArgs(nameof(Word))); + } #endregion Constructors #region Events @@ -99,6 +149,24 @@ private void StandardInitialization() public bool IsEditing => EditWordTextBox.IsFocused; public bool IsFromEditWindow { get; set; } = false; public bool IsSelected { get; set; } = false; + public string DisplayText + { + get { return (string)GetValue(DisplayTextProperty); } + set { SetValue(DisplayTextProperty, value); } + } + + public double DisplayLineHeight + { + get { return (double)GetValue(DisplayLineHeightProperty); } + set { SetValue(DisplayLineHeightProperty, value); } + } + + public bool KeepSingleLineOutput + { + get { return (bool)GetValue(KeepSingleLineOutputProperty); } + set { SetValue(KeepSingleLineOutputProperty, value); } + } + public double Left { get { return left; } @@ -248,6 +316,30 @@ public void SetAsBarcode() EditWordTextBox.Background = new SolidColorBrush(Colors.Blue); } + private void ApplyTextLayout() + { + if (IsBarcode) + return; + + if (KeepSingleLineOutput && DisplayLineHeight > 0) + { + EditWordTextBox.TextWrapping = TextWrapping.Wrap; + EditWordTextBox.Width = Math.Max(Width - 2, 10); + EditWordTextBox.Height = Math.Max(Height - 2, 14); + EditWordTextBox.FontSize = Math.Max(1, DisplayLineHeight * 0.75); + EditWordTextBox.SetValue(TextBlock.LineHeightProperty, Math.Max(1, DisplayLineHeight)); + EditWordTextBox.SetValue(TextBlock.LineStackingStrategyProperty, LineStackingStrategy.BlockLineHeight); + return; + } + + EditWordTextBox.TextWrapping = TextWrapping.NoWrap; + EditWordTextBox.ClearValue(FrameworkElement.WidthProperty); + EditWordTextBox.ClearValue(FrameworkElement.HeightProperty); + EditWordTextBox.ClearValue(Control.FontSizeProperty); + EditWordTextBox.ClearValue(TextBlock.LineHeightProperty); + EditWordTextBox.ClearValue(TextBlock.LineStackingStrategyProperty); + } + private void BreakIntoWordsMenuItem_Click(object sender, RoutedEventArgs e) { if (OwnerGrabFrame is null) @@ -315,16 +407,12 @@ private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventA translateMenuItem.Header = $"Translate to {systemLanguage}"; } - if (translateSeparator != null) - translateSeparator.Visibility = Visibility.Visible; + translateSeparator?.Visibility = Visibility.Visible; } else { - if (translateMenuItem != null) - translateMenuItem.Visibility = Visibility.Collapsed; - - if (translateSeparator != null) - translateSeparator.Visibility = Visibility.Collapsed; + translateMenuItem?.Visibility = Visibility.Collapsed; + translateSeparator?.Visibility = Visibility.Collapsed; } if (Uri.TryCreate(Word, UriKind.Absolute, out Uri? uri)) @@ -334,8 +422,10 @@ private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventA if (headerText.Length > maxLength) headerText = string.Concat(headerText.AsSpan(0, maxLength), "..."); - MenuItem urlMi = new(); - urlMi.Header = headerText; + MenuItem urlMi = new() + { + Header = headerText + }; urlMi.Click += (sender, e) => { Process.Start(new ProcessStartInfo(Word) { UseShellExecute = true }); @@ -468,73 +558,81 @@ private void WordBorderControl_MouseDown(object sender, MouseButtonEventArgs e) else Select(); } - private void WordBorderControl_Unloaded(object sender, RoutedEventArgs e) + private void WordBorderControl_Unloaded(object sender, RoutedEventArgs e) + { + this.MouseDoubleClick -= WordBorderControl_MouseDoubleClick; + this.MouseDown -= WordBorderControl_MouseDown; + this.Unloaded -= WordBorderControl_Unloaded; + Loaded -= WordBorder_Loaded; + SizeChanged -= WordBorder_SizeChanged; + + debounceTimer.Stop(); + debounceTimer.Tick -= DebounceTimer_Tick; + + OwnerGrabFrame = null; + } + + private void WordBorder_Loaded(object sender, RoutedEventArgs e) => ApplyTextLayout(); + + private void WordBorder_SizeChanged(object sender, SizeChangedEventArgs e) => ApplyTextLayout(); + + private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(Word)) + return; + + if (!WindowsAiUtilities.CanDeviceUseWinAI()) { - this.MouseDoubleClick -= WordBorderControl_MouseDoubleClick; - this.MouseDown -= WordBorderControl_MouseDown; - this.Unloaded -= WordBorderControl_Unloaded; + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Not Available", + Content = "Windows AI is not available on this device.", + CloseButtonText = "OK" + }.ShowDialogAsync(); + return; } - private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) - { - if (string.IsNullOrWhiteSpace(Word)) - return; + // Store original text + string originalWord = Word; - if (!WindowsAiUtilities.CanDeviceUseWinAI()) - { - await new Wpf.Ui.Controls.MessageBox - { - Title = "Translation Not Available", - Content = "Windows AI is not available on this device.", - CloseButtonText = "OK" - }.ShowDialogAsync(); - return; - } + try + { + // Get system language + string targetLanguage = GetSystemLanguageName(); - // Store original text - string originalWord = Word; + // Translate the word + string translatedText = await WindowsAiUtilities.TranslateText(originalWord, targetLanguage); - try - { - // Get system language - string targetLanguage = GetSystemLanguageName(); - - // Translate the word - string translatedText = await WindowsAiUtilities.TranslateText(originalWord, targetLanguage); - - // Update the word with translation - if (!string.IsNullOrWhiteSpace(translatedText) && translatedText != originalWord) - { - // Notify the owner GrabFrame of the change for undo support - if (OwnerGrabFrame != null) - { - OwnerGrabFrame.UndoableWordChange(this, originalWord, true); - } - - Word = translatedText; - } - } - catch (Exception ex) + // Update the word with translation + if (!string.IsNullOrWhiteSpace(translatedText) && translatedText != originalWord) { - Debug.WriteLine($"Translation failed: {ex.Message}"); - await new Wpf.Ui.Controls.MessageBox - { - Title = "Translation Error", - Content = $"Translation failed: {ex.Message}", - CloseButtonText = "OK" - }.ShowDialogAsync(); + // Notify the owner GrabFrame of the change for undo support + OwnerGrabFrame?.UndoableWordChange(this, originalWord, true); + + Word = translatedText; } } - - /// - /// Gets the system's display language name (e.g., "English", "Spanish", "French") - /// Falls back to "English" if the system language is not recognized. - /// - private static string GetSystemLanguageName() + catch (Exception ex) { - // Use the shared utility method from LanguageUtilities - return LanguageUtilities.GetSystemLanguageForTranslation(); + Debug.WriteLine($"Translation failed: {ex.Message}"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Error", + Content = $"Translation failed: {ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); } + } - #endregion Methods + /// + /// Gets the system's display language name (e.g., "English", "Spanish", "French") + /// Falls back to "English" if the system language is not recognized. + /// + private static string GetSystemLanguageName() + { + // Use the shared utility method from LanguageUtilities + return LanguageUtilities.GetSystemLanguageForTranslation(); } + + #endregion Methods +} diff --git a/Text-Grab/Controls/ZoomBorder.cs b/Text-Grab/Controls/ZoomBorder.cs index dc29ee82..c4229e97 100644 --- a/Text-Grab/Controls/ZoomBorder.cs +++ b/Text-Grab/Controls/ZoomBorder.cs @@ -1,10 +1,12 @@ -using System.Linq; +using System; +using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; +using System.Windows.Media.Media3D; -// From StackOverFlow: +// From StackOverFlow: // https://stackoverflow.com/questions/741956/pan-zoom-image // Answered by https://stackoverflow.com/users/282801/wies%c5%82aw-%c5%a0olt%c3%a9s // Read on 2024-05-02 @@ -15,9 +17,17 @@ namespace Text_Grab.Controls; public class ZoomBorder : Border { private UIElement? child = null; + private bool isPanning = false; private Point origin; private Point start; + public event EventHandler? ResetRequested; + + public ZoomBorder() + { + Background = Brushes.Transparent; + } + private TranslateTransform GetTranslateTransform(UIElement element) => (TranslateTransform)((TransformGroup)element.RenderTransform) .Children.First(tr => tr is TranslateTransform); @@ -41,6 +51,18 @@ public override UIElement Child public bool CanZoom { get; set; } = true; + public bool IsSpacePanModifierPressed { get; set; } = false; + + public bool RequireSpaceToPan { get; set; } = false; + + public double GetScale() + { + if (child is null) + return 1.0; + + return GetScaleTransform(child).ScaleX; + } + public void Initialize(UIElement element) { child = element; @@ -55,18 +77,9 @@ public void Initialize(UIElement element) child.RenderTransform = group; child.RenderTransformOrigin = new Point(0.0, 0.0); MouseWheel += Child_MouseWheel; - MouseLeftButtonDown += Child_MouseLeftButtonDown; - MouseLeftButtonUp += Child_MouseLeftButtonUp; - PreviewMouseDown += ZoomBorder_PreviewMouseDown; - MouseMove += Child_MouseMove; - PreviewMouseRightButtonDown += new MouseButtonEventHandler( - Child_PreviewMouseRightButtonDown); - } - - private void ZoomBorder_PreviewMouseDown(object sender, MouseButtonEventArgs e) - { - if (e.MiddleButton == MouseButtonState.Pressed) - Reset(); + AddHandler(Mouse.PreviewMouseDownEvent, new MouseButtonEventHandler(Child_PreviewMouseDown), true); + AddHandler(Mouse.PreviewMouseUpEvent, new MouseButtonEventHandler(Child_PreviewMouseUp), true); + AddHandler(Mouse.PreviewMouseMoveEvent, new MouseEventHandler(Child_MouseMove), true); } public void Reset() @@ -84,9 +97,76 @@ public void Reset() tt.X = 0.0; tt.Y = 0.0; + isPanning = false; + ReleaseMouseCapture(); + Cursor = Cursors.Arrow; CanPan = false; } + public void SetScale(double scale) + { + if (child is null) + return; + + if (!double.IsFinite(scale) || scale <= 0) + scale = 1.0; + + ScaleTransform st = GetScaleTransform(child); + TranslateTransform tt = GetTranslateTransform(child); + + st.ScaleX = scale; + st.ScaleY = scale; + + double childWidth = child.RenderSize.Width > 0 ? child.RenderSize.Width : ActualWidth; + double childHeight = child.RenderSize.Height > 0 ? child.RenderSize.Height : ActualHeight; + + if (double.IsFinite(childWidth) && childWidth > 0) + tt.X = ((childWidth * scale) - childWidth) * -0.5; + else + tt.X = 0; + + if (double.IsFinite(childHeight) && childHeight > 0) + tt.Y = ((childHeight * scale) - childHeight) * -0.5; + else + tt.Y = 0; + + isPanning = false; + ReleaseMouseCapture(); + Cursor = Cursors.Arrow; + CanPan = scale > 1.0; + } + + private bool IsPanGestureActive() => + !RequireSpaceToPan || IsSpacePanModifierPressed || Keyboard.IsKeyDown(Key.Space); + + private bool BlocksPanFromSource(object? originalSource) + { + DependencyObject? current = originalSource switch + { + DependencyObject dependencyObject => dependencyObject, + null => null, + _ => null + }; + + while (current is not null) + { + if (current is TextBox) + return true; + + if (current is PdfTextLineOverlay) + return !IsPanGestureActive(); + + current = current switch + { + Visual visual => VisualTreeHelper.GetParent(visual), + Visual3D visual3D => VisualTreeHelper.GetParent(visual3D), + _ => null + }; + } + + return false; + } + private void Child_MouseWheel(object sender, MouseWheelEventArgs e) { if (child is null || !CanZoom) @@ -115,45 +195,69 @@ private void Child_MouseWheel(object sender, MouseWheelEventArgs e) CanPan = true; } - private void Child_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + private void Child_PreviewMouseDown(object sender, MouseButtonEventArgs e) { - if (child is null) + if (e.ChangedButton == MouseButton.Middle) + { + ResetRequested?.Invoke(this, EventArgs.Empty); + Reset(); + e.Handled = true; + return; + } + + if (e.ChangedButton != MouseButton.Left) return; + if (child is null + || GetScaleTransform(child) is not ScaleTransform st + || st.ScaleX == 1.0 + || !CanPan + || !IsPanGestureActive() + || BlocksPanFromSource(e.OriginalSource)) + { + return; + } + TranslateTransform tt = GetTranslateTransform(child); start = e.GetPosition(this); origin = new Point(tt.X, tt.Y); + + bool captured = CaptureMouse(); + if (!captured) + return; + + isPanning = true; Cursor = Cursors.Hand; - // child.CaptureMouse(); + e.Handled = true; } - private void Child_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + private void Child_PreviewMouseUp(object sender, MouseButtonEventArgs e) { - if (child is null) + if (e.ChangedButton != MouseButton.Left || child is null || !isPanning) return; - child.ReleaseMouseCapture(); + isPanning = false; + ReleaseMouseCapture(); Cursor = Cursors.Arrow; - } - - private void Child_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) - { + e.Handled = true; } private void Child_MouseMove(object sender, MouseEventArgs e) { - if (e.OriginalSource is TextBox) + if (!isPanning && BlocksPanFromSource(e.OriginalSource)) return; if (child is null || GetScaleTransform(child) is not ScaleTransform st || st.ScaleX == 1.0 - || Mouse.LeftButton == MouseButtonState.Released + || !isPanning || !CanPan || KeyboardExtensions.IsShiftDown() || KeyboardExtensions.IsCtrlDown()) { - child?.ReleaseMouseCapture(); + isPanning = false; + ReleaseMouseCapture(); + Cursor = Cursors.Arrow; return; } @@ -161,5 +265,6 @@ private void Child_MouseMove(object sender, MouseEventArgs e) Vector v = start - e.GetPosition(this); tt.X = origin.X - v.X; tt.Y = origin.Y - v.Y; + e.Handled = true; } } diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs index 52824fa6..50135df7 100644 --- a/Text-Grab/Enums.cs +++ b/Text-Grab/Enums.cs @@ -33,6 +33,7 @@ public enum OpenContentKind Image = 0, TextFile = 1, Directory = 2, + PdfDocument = 3, } public enum OcrEngineKind @@ -92,6 +93,7 @@ public enum LanguageKind Tesseract = 1, WindowsAi = 2, UiAutomation = 3, + WindowsAiDescription = 4, } public enum UiAutomationTraversalMode diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index 442af591..b2ce5dbf 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -36,6 +36,11 @@ public class ButtonInfo /// public string TemplateId { get; set; } = string.Empty; + /// + /// When true, this button requires a Copilot+ PC (Windows AI capable device) to function. + /// + public bool RequiresCopilotPlus { get; set; } = false; + public ButtonInfo() { @@ -224,6 +229,14 @@ public static List AllButtons SymbolIcon = SymbolRegular.SubtractSquare24 }, new() + { + OrderNumber = 1.31, + ButtonText = "Join Lines...", + SymbolText = "", + ClickEvent = "JoinLinesMenuItem_Click", + SymbolIcon = SymbolRegular.Merge24 + }, + new() { OrderNumber = 1.4, ButtonText = "New Fullscreen Grab", @@ -300,7 +313,7 @@ public static List AllButtons OrderNumber = 2.3, ButtonText = "OCR Paste", SymbolText = "", - Command = "PasteCommand", + Command = "OcrPasteCommand", SymbolIcon = SymbolRegular.ClipboardImage24 }, new() @@ -352,6 +365,14 @@ public static List AllButtons SymbolIcon = SymbolRegular.MultiselectLtr24 }, new() + { + OrderNumber = 3.51, + ButtonText = "Shuffle Lines", + SymbolText = "", + ClickEvent = "ShuffleLinesMenuItem_Click", + SymbolIcon = SymbolRegular.ArrowShuffle24 + }, + new() { OrderNumber = 3.6, ButtonText = "Replace Reserved Characters", @@ -490,9 +511,9 @@ public static List AllButtons new() { OrderNumber = 5.4, - ButtonText = "Extract Text from Images to txt Files...", + ButtonText = "Write .txt File For Each Image", SymbolText = "", - ClickEvent = "ReadFolderOfImagesWriteTxtFiles_Click", + ClickEvent = "ToggleWriteTxtFileForEachImage_Click", SymbolIcon = SymbolRegular.TabDesktopImage24 }, new() @@ -520,18 +541,399 @@ public static List AllButtons SymbolIcon = SymbolRegular.QrCode24 }, new() + { + OrderNumber = 6.1, + ButtonText = "Close", + ClickEvent = "CloseMenuItem_Click", + SymbolIcon = SymbolRegular.WindowAdOff20 + }, + new() + { + OrderNumber = 6.2, + ButtonText = "Correct Common GUID/UUID Errors", + ClickEvent = "CorrectGuid_Click", + SymbolIcon = SymbolRegular.TextWholeWord20 + }, + new() + { + OrderNumber = 6.3, + ButtonText = "Transpose Table", + Command = "TransposeTableCmd", + SymbolIcon = SymbolRegular.TableSwitch24 + }, + new() + { + OrderNumber = 6.4, + ButtonText = "Add Spreadsheet Row", + ClickEvent = "AddSpreadsheetRowMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertRow24 + }, + new() + { + OrderNumber = 6.5, + ButtonText = "Add Spreadsheet Column", + ClickEvent = "AddSpreadsheetColumnMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertColumn24 + }, + new() + { + OrderNumber = 6.6, + ButtonText = "Copy Selected Spreadsheet Cells", + ClickEvent = "CopySpreadsheetSelectionMenuItem_Click", + SymbolIcon = SymbolRegular.CopySelect20 + }, + new() + { + OrderNumber = 6.7, + ButtonText = "Copy Selected Spreadsheet Rows", + ClickEvent = "CopySpreadsheetRowsMenuItem_Click", + SymbolIcon = SymbolRegular.TableCopy20 + }, + new() + { + OrderNumber = 6.8, + ButtonText = "Copy Current Spreadsheet Column", + ClickEvent = "CopySpreadsheetColumnMenuItem_Click", + SymbolIcon = SymbolRegular.Column20 + }, + new() + { + OrderNumber = 6.9, + ButtonText = "Move Spreadsheet Row Up", + ClickEvent = "MoveSpreadsheetRowUpMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertRow24 + }, + new() + { + OrderNumber = 6.91, + ButtonText = "Move Spreadsheet Row Down", + ClickEvent = "MoveSpreadsheetRowDownMenuItem_Click", + SymbolIcon = SymbolRegular.TableInsertRow24 + }, + new() + { + OrderNumber = 6.92, + ButtonText = "Delete Spreadsheet Row", + ClickEvent = "DeleteSpreadsheetRowMenuItem_Click", + SymbolIcon = SymbolRegular.TableDeleteRow24 + }, + new() + { + OrderNumber = 6.93, + ButtonText = "Move Spreadsheet Column Left", + ClickEvent = "MoveSpreadsheetColumnLeftMenuItem_Click", + SymbolIcon = SymbolRegular.TableMoveLeft24 + }, + new() + { + OrderNumber = 6.94, + ButtonText = "Move Spreadsheet Column Right", + ClickEvent = "MoveSpreadsheetColumnRightMenuItem_Click", + SymbolIcon = SymbolRegular.TableMoveRight24 + }, + new() + { + OrderNumber = 6.95, + ButtonText = "Delete Spreadsheet Column", + ClickEvent = "DeleteSpreadsheetColumnMenuItem_Click", + SymbolIcon = SymbolRegular.TableDeleteColumn24 + }, + new() + { + OrderNumber = 6.96, + ButtonText = "Enter Raw Text Mode", + ClickEvent = "EnterRawTextMode_Click", + SymbolIcon = SymbolRegular.TextT24 + }, + new() + { + OrderNumber = 6.97, + ButtonText = "Enter Spreadsheet Mode", + ClickEvent = "EnterSpreadsheetMode_Click", + SymbolIcon = SymbolRegular.Table24 + }, + new() + { + OrderNumber = 6.98, + ButtonText = "Enter Markdown Mode", + ClickEvent = "EnterMarkdownMode_Click", + SymbolIcon = SymbolRegular.Markdown20 + }, + new() + { + OrderNumber = 7.1, + ButtonText = "Toggle Show Calc Errors", + ClickEvent = "ToggleShowMathErrors_Click", + SymbolIcon = SymbolRegular.MathSymbols24 + }, + new() + { + OrderNumber = 7.11, + ButtonText = "Toggle Calculation Pane", + ClickEvent = "CalcToggleButton_Click", + SymbolIcon = SymbolRegular.Calculator24 + }, + new() + { + OrderNumber = 7.12, + ButtonText = "Copy All Calculation Results", + ClickEvent = "CalcCopyAllButton_Click", + SymbolIcon = SymbolRegular.CopyAdd24 + }, + new() + { + OrderNumber = 7.2, + ButtonText = "Toggle Always On Top", + ClickEvent = "ToggleAlwaysOnTop_Click", + SymbolIcon = SymbolRegular.WindowLocationTarget20 + }, + new() + { + OrderNumber = 7.21, + ButtonText = "Toggle Hide Bottom Bar", + ClickEvent = "ToggleHideBottomBar_Click", + SymbolIcon = SymbolRegular.PanelBottomContract20 + }, + new() + { + OrderNumber = 7.24, + ButtonText = "Restore This Window Position", + ClickEvent = "RestoreThisPosition_Click", + SymbolIcon = SymbolRegular.WindowWrench24 + }, + new() + { + OrderNumber = 7.25, + ButtonText = "Toggle Margins", + ClickEvent = "ToggleMargins_Click", + SymbolIcon = SymbolRegular.DocumentMargins24 + }, + new() + { + OrderNumber = 7.26, + ButtonText = "Toggle Wrap Text", + ClickEvent = "ToggleWrapText_Click", + SymbolIcon = SymbolRegular.TextWrap24 + }, + new() + { + OrderNumber = 7.27, + ButtonText = "Font...", + ClickEvent = "FontMenuItem_Click", + SymbolIcon = SymbolRegular.TextFont24 + }, + new() + { + OrderNumber = 7.3, + ButtonText = "Grab Previous Region", + ClickEvent = "PreviousRegion_Click", + SymbolIcon = SymbolRegular.WindowArrowUp24 + }, + new() + { + OrderNumber = 7.31, + ButtonText = "Edit Last Grab", + ClickEvent = "OpenLastAsGrabFrameMenuItem_Click", + SymbolIcon = SymbolRegular.ImageEdit24 + }, + new() + { + OrderNumber = 7.4, + ButtonText = "Select All", + ClickEvent = "SelectAllMenuItem_Click", + SymbolIcon = SymbolRegular.SelectAllOn24 + }, + new() + { + OrderNumber = 7.41, + ButtonText = "Select None", + ClickEvent = "SelectNoneMenuItem_Click", + SymbolIcon = SymbolRegular.TextClearFormatting24 + }, + new() + { + OrderNumber = 7.42, + ButtonText = "Delete Selected Text", + ClickEvent = "DeleteSelectedTextMenuItem_Click", + SymbolIcon = SymbolRegular.Delete24 + }, + new() + { + OrderNumber = 7.43, + ButtonText = "Show Character Details", + ClickEvent = "CharDetailsButton_Click", + SymbolIcon = SymbolRegular.TextFontInfo24 + }, + new() + { + OrderNumber = 7.44, + ButtonText = "Find Similar Matches", + ClickEvent = "SimilarMatchesButton_Click", + SymbolIcon = SymbolRegular.DocumentSearch24 + }, + new() + { + OrderNumber = 7.45, + ButtonText = "Open Regex Pattern Search", + ClickEvent = "RegexPatternButton_Click", + SymbolIcon = SymbolRegular.TextEffects24 + }, + new() + { + OrderNumber = 7.46, + ButtonText = "Save Regex Pattern", + ClickEvent = "SavePatternMenuItem_Click", + SymbolIcon = SymbolRegular.SaveCopy24 + }, + new() + { + OrderNumber = 8.1, + ButtonText = "Summarize Paragraph", + ClickEvent = "SummarizeMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.2, + ButtonText = "Rewrite with Local AI", + ClickEvent = "RewriteMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.3, + ButtonText = "Convert to Table", + ClickEvent = "ConvertTableMenuItem_Click", + SymbolIcon = SymbolRegular.BotSparkle24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.4, + ButtonText = "Translate to System Language", + ClickEvent = "TranslateToSystemLanguageMenuItem_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.41, + ButtonText = "Translate to English", + ClickEvent = "TranslateToEnglish_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.42, + ButtonText = "Translate to Spanish", + ClickEvent = "TranslateToSpanish_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.43, + ButtonText = "Translate to French", + ClickEvent = "TranslateToFrench_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.44, + ButtonText = "Translate to German", + ClickEvent = "TranslateToGerman_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.45, + ButtonText = "Translate to Italian", + ClickEvent = "TranslateToItalian_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.46, + ButtonText = "Translate to Portuguese", + ClickEvent = "TranslateToPortuguese_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.47, + ButtonText = "Translate to Russian", + ClickEvent = "TranslateToRussian_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.48, + ButtonText = "Translate to Japanese", + ClickEvent = "TranslateToJapanese_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.49, + ButtonText = "Translate to Chinese (Simplified)", + ClickEvent = "TranslateToChineseSimplified_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.5, + ButtonText = "Translate to Korean", + ClickEvent = "TranslateToKorean_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.51, + ButtonText = "Translate to Arabic", + ClickEvent = "TranslateToArabic_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.52, + ButtonText = "Translate to Hindi", + ClickEvent = "TranslateToHindi_Click", + SymbolIcon = SymbolRegular.Translate24, + RequiresCopilotPlus = true + }, + new() + { + OrderNumber = 8.6, + ButtonText = "Extract RegEx", + ClickEvent = "ExtractRegexMenuItem_Click", + SymbolIcon = SymbolRegular.TextWholeWord20, + RequiresCopilotPlus = true + }, + new() { ButtonText = "Edit Bottom Bar", ClickEvent = "EditBottomBarMenuItem_Click", - SymbolIcon = SymbolRegular.CalendarEdit24 + SymbolIcon = SymbolRegular.PanelBottom20 }, new() { - ButtonText = "Settings", - ClickEvent = "SettingsMenuItem_Click", - SymbolIcon = SymbolRegular.Settings24 - }, - ]; + ButtonText = "Settings", + ClickEvent = "SettingsMenuItem_Click", + SymbolIcon = SymbolRegular.Settings24 + }, + ]; return _allButtons; } diff --git a/Text-Grab/Models/EditTextTableDocument.cs b/Text-Grab/Models/EditTextTableDocument.cs new file mode 100644 index 00000000..32d46b33 --- /dev/null +++ b/Text-Grab/Models/EditTextTableDocument.cs @@ -0,0 +1,921 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; + +namespace Text_Grab.Models; + +public enum EtwEditorMode +{ + Text, + Markdown, + Spreadsheet +} + +public enum EtwStructuredTextFormat +{ + PlainText, + DelimitedText, + Csv, + Tsv, + Xml +} + +public sealed record EditTextTableWrappedCell(int RowIndex, int ColumnIndex); + +public sealed class EditTextTableDocument +{ + public const int DefaultMinimumRowCount = 25; + public const int DefaultMinimumColumnCount = 8; + + public EtwStructuredTextFormat Format { get; set; } = EtwStructuredTextFormat.PlainText; + + public string NewLineSequence { get; set; } = Environment.NewLine; + + public string Delimiter { get; set; } = "\t"; + + public string XmlRootElementName { get; set; } = "rows"; + + public string? XmlContainerElementName { get; set; } + + public string XmlRowElementName { get; set; } = "row"; + + public List ColumnNames { get; set; } = []; + + public List> Rows { get; set; } = []; + + public int RowCount { get; set; } + + public int ColumnCount { get; set; } + + public int MinimumRowCount { get; set; } = DefaultMinimumRowCount; + + public int MinimumColumnCount { get; set; } = DefaultMinimumColumnCount; + + public List ColumnWidths { get; set; } = []; + + public List RowHeights { get; set; } = []; + + public List WrappedCells { get; set; } = []; + + public static EditTextTableDocument CreateFromText( + string? text, + int minimumRowCount = DefaultMinimumRowCount, + int minimumColumnCount = DefaultMinimumColumnCount) + { + string safeText = text ?? string.Empty; + string newlineSequence = DetectNewLineSequence(safeText); + + EditTextTableDocument document = + TryCreateDelimitedDocument(safeText, '\t', EtwStructuredTextFormat.Tsv, newlineSequence, minimumRowCount, minimumColumnCount) + ?? TryCreateDelimitedDocument(safeText, ',', EtwStructuredTextFormat.Csv, newlineSequence, minimumRowCount, minimumColumnCount) + ?? TryCreateXmlDocument(safeText, newlineSequence, minimumRowCount, minimumColumnCount) + ?? TryCreateHeuristicDelimitedDocument(safeText, newlineSequence, minimumRowCount, minimumColumnCount) + ?? CreatePlainTextDocument(safeText, newlineSequence, minimumRowCount, minimumColumnCount); + + document.EnsureMinimumSize(); + return document; + } + + public static EditTextTableDocument? TryDeserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + EditTextTableDocument? document = JsonSerializer.Deserialize(json); + if (document is null) + return null; + + document.EnsureMinimumSize(); + return document; + } + catch (JsonException) + { + return null; + } + } + + public string SerializeToJson() + { + return JsonSerializer.Serialize(this); + } + + public string SerializeToText() + { + EnsureMinimumSize(); + + return Format switch + { + EtwStructuredTextFormat.Xml => SerializeToXml(), + EtwStructuredTextFormat.Csv => SerializeDelimitedText(','), + EtwStructuredTextFormat.Tsv => SerializeDelimitedText('\t'), + EtwStructuredTextFormat.DelimitedText => SerializeDelimitedText(GetDelimiterCharacter()), + _ => SerializePlainText(), + }; + } + + public void InsertRow(int rowIndex) + { + EnsureMinimumSize(); + + int insertIndex = Math.Clamp(rowIndex, 0, RowCount); + WrappedCells = [.. WrappedCells + .Select(cell => cell.RowIndex >= insertIndex + ? cell with { RowIndex = cell.RowIndex + 1 } + : cell)]; + Rows.Insert(insertIndex, Enumerable.Repeat(string.Empty, ColumnNames.Count).ToList()); + RowHeights.Insert(insertIndex, null); + RowCount++; + MinimumRowCount = Math.Max(MinimumRowCount, RowCount); + } + + public void InsertColumn(int columnIndex, string? columnName = null) + { + EnsureMinimumSize(); + + int insertIndex = Math.Clamp(columnIndex, 0, ColumnCount); + string nameToInsert = EnsureUniqueColumnName(columnName ?? GetDefaultColumnName(insertIndex), ColumnNames); + + WrappedCells = [.. WrappedCells + .Select(cell => cell.ColumnIndex >= insertIndex + ? cell with { ColumnIndex = cell.ColumnIndex + 1 } + : cell)]; + ColumnNames.Insert(insertIndex, nameToInsert); + ColumnWidths.Insert(insertIndex, null); + foreach (List row in Rows) + row.Insert(insertIndex, string.Empty); + + ColumnCount++; + MinimumColumnCount = Math.Max(MinimumColumnCount, ColumnCount); + } + + public void DeleteRow(int rowIndex) + { + EnsureMinimumSize(); + + if (rowIndex < 0 || rowIndex >= RowCount) + return; + + WrappedCells = [.. WrappedCells + .Where(cell => cell.RowIndex != rowIndex) + .Select(cell => cell.RowIndex > rowIndex + ? cell with { RowIndex = cell.RowIndex - 1 } + : cell)]; + Rows.RemoveAt(rowIndex); + if (rowIndex < RowHeights.Count) + RowHeights.RemoveAt(rowIndex); + RowCount = Math.Max(1, RowCount - 1); + } + + public void DeleteColumn(int columnIndex) + { + EnsureMinimumSize(); + + if (columnIndex < 0 || columnIndex >= ColumnCount) + return; + + WrappedCells = [.. WrappedCells + .Where(cell => cell.ColumnIndex != columnIndex) + .Select(cell => cell.ColumnIndex > columnIndex + ? cell with { ColumnIndex = cell.ColumnIndex - 1 } + : cell)]; + ColumnNames.RemoveAt(columnIndex); + if (columnIndex < ColumnWidths.Count) + ColumnWidths.RemoveAt(columnIndex); + foreach (List row in Rows) + { + if (columnIndex < row.Count) + row.RemoveAt(columnIndex); + } + + ColumnCount = Math.Max(1, ColumnCount - 1); + } + + public void MoveRow(int fromIndex, int toIndex) + { + EnsureMinimumSize(); + + if (fromIndex < 0 || fromIndex >= RowCount || toIndex < 0 || toIndex >= RowCount || fromIndex == toIndex) + return; + + WrappedCells = [.. WrappedCells + .Select(cell => cell with { RowIndex = TranslateMovedIndex(cell.RowIndex, fromIndex, toIndex) })]; + List row = Rows[fromIndex]; + Rows.RemoveAt(fromIndex); + Rows.Insert(toIndex, row); + MoveListItem(RowHeights, fromIndex, toIndex); + } + + public void MoveColumn(int fromIndex, int toIndex) + { + EnsureMinimumSize(); + + if (fromIndex < 0 || fromIndex >= ColumnCount || toIndex < 0 || toIndex >= ColumnCount || fromIndex == toIndex) + return; + + WrappedCells = [.. WrappedCells + .Select(cell => cell with { ColumnIndex = TranslateMovedIndex(cell.ColumnIndex, fromIndex, toIndex) })]; + string columnName = ColumnNames[fromIndex]; + ColumnNames.RemoveAt(fromIndex); + ColumnNames.Insert(toIndex, columnName); + MoveListItem(ColumnWidths, fromIndex, toIndex); + + foreach (List row in Rows) + { + string value = row[fromIndex]; + row.RemoveAt(fromIndex); + row.Insert(toIndex, value); + } + } + + public void Transpose() + { + EnsureMinimumSize(); + + int sourceRowCount = Math.Max(1, RowCount); + int sourceColumnCount = Math.Max(1, ColumnCount); + int originalMinimumRowCount = MinimumRowCount; + int originalMinimumColumnCount = MinimumColumnCount; + + List> transposedRows = []; + for (int columnIndex = 0; columnIndex < sourceColumnCount; columnIndex++) + { + List transposedRow = []; + for (int rowIndex = 0; rowIndex < sourceRowCount; rowIndex++) + { + string value = rowIndex < Rows.Count && columnIndex < Rows[rowIndex].Count + ? Rows[rowIndex][columnIndex] ?? string.Empty + : string.Empty; + transposedRow.Add(value); + } + + transposedRows.Add(transposedRow); + } + + Rows = transposedRows; + RowCount = sourceColumnCount; + ColumnCount = sourceRowCount; + MinimumRowCount = Math.Max(1, originalMinimumColumnCount); + MinimumColumnCount = Math.Max(1, originalMinimumRowCount); + ColumnNames = BuildGenericColumnNames(Math.Max(1, ColumnCount)); + ColumnWidths = []; + RowHeights = []; + WrappedCells = [.. WrappedCells + .Select(cell => new EditTextTableWrappedCell(cell.ColumnIndex, cell.RowIndex))]; + EnsureMinimumSize(); + } + + public void EnsureMinimumSize() + { + if (MinimumRowCount < 1) + MinimumRowCount = DefaultMinimumRowCount; + + if (MinimumColumnCount < 1) + MinimumColumnCount = DefaultMinimumColumnCount; + + int maxRowWidth = Rows.Count == 0 ? 0 : Rows.Max(row => row.Count); + + if (ColumnCount < 0) + ColumnCount = 0; + + if (RowCount < 0) + RowCount = 0; + + if (ColumnCount == 0) + ColumnCount = InferLogicalColumnCount(); + + if (RowCount == 0 && Rows.Any(row => row.Any(value => !string.IsNullOrEmpty(value)))) + RowCount = Rows.Count; + + int requiredColumns = Math.Max(Math.Max(ColumnCount, maxRowWidth), MinimumColumnCount); + + while (ColumnNames.Count < requiredColumns) + ColumnNames.Add(EnsureUniqueColumnName(GetDefaultColumnName(ColumnNames.Count), ColumnNames)); + + while (ColumnWidths.Count < requiredColumns) + ColumnWidths.Add(null); + + while (ColumnWidths.Count > requiredColumns) + ColumnWidths.RemoveAt(ColumnWidths.Count - 1); + + foreach (List row in Rows) + while (row.Count < requiredColumns) + row.Add(string.Empty); + + int requiredRows = Math.Max(RowCount, MinimumRowCount); + while (Rows.Count < requiredRows) + Rows.Add(Enumerable.Repeat(string.Empty, requiredColumns).ToList()); + + while (RowHeights.Count < requiredRows) + RowHeights.Add(null); + + while (RowHeights.Count > requiredRows) + RowHeights.RemoveAt(RowHeights.Count - 1); + + NormalizeWrappedCells(); + } + + public void ApplyViewMetricsFrom(EditTextTableDocument source) + { + ArgumentNullException.ThrowIfNull(source); + + EnsureMinimumSize(); + source.EnsureMinimumSize(); + + for (int columnIndex = 0; columnIndex < Math.Min(ColumnWidths.Count, source.ColumnWidths.Count); columnIndex++) + ColumnWidths[columnIndex] = source.ColumnWidths[columnIndex]; + + for (int rowIndex = 0; rowIndex < Math.Min(RowHeights.Count, source.RowHeights.Count); rowIndex++) + RowHeights[rowIndex] = source.RowHeights[rowIndex]; + + WrappedCells = [.. source.WrappedCells]; + NormalizeWrappedCells(); + } + + public void SetColumnWidth(int columnIndex, double? width) + { + EnsureMinimumSize(); + if (columnIndex < 0 || columnIndex >= ColumnWidths.Count) + return; + + ColumnWidths[columnIndex] = NormalizeViewMetric(width); + } + + public void SetRowHeight(int rowIndex, double? height) + { + EnsureMinimumSize(); + if (rowIndex < 0 || rowIndex >= RowHeights.Count) + return; + + RowHeights[rowIndex] = NormalizeViewMetric(height); + } + + public bool IsCellWrapped(int rowIndex, int columnIndex) + { + EnsureMinimumSize(); + return WrappedCells.Contains(new EditTextTableWrappedCell(rowIndex, columnIndex)); + } + + public void SetCellWrap(int rowIndex, int columnIndex, bool shouldWrap) + { + EnsureMinimumSize(); + + if (rowIndex < 0 + || rowIndex >= Rows.Count + || columnIndex < 0 + || columnIndex >= ColumnNames.Count) + { + return; + } + + EditTextTableWrappedCell wrappedCell = new(rowIndex, columnIndex); + if (shouldWrap) + { + if (!WrappedCells.Contains(wrappedCell)) + WrappedCells.Add(wrappedCell); + } + else + { + WrappedCells.RemoveAll(cell => cell == wrappedCell); + } + + NormalizeWrappedCells(); + } + + private string SerializePlainText() + { + if (ColumnCount <= 1) + return string.Join(NewLineSequence, Rows.Take(RowCount).Select(row => row.FirstOrDefault() ?? string.Empty)); + + return SerializeDelimitedText(GetDelimiterCharacter()); + } + + private static void MoveListItem(List items, int fromIndex, int toIndex) + { + if (fromIndex < 0 || fromIndex >= items.Count || toIndex < 0 || toIndex >= items.Count || fromIndex == toIndex) + return; + + T item = items[fromIndex]; + items.RemoveAt(fromIndex); + items.Insert(toIndex, item); + } + + private static int TranslateMovedIndex(int currentIndex, int fromIndex, int toIndex) + { + if (currentIndex == fromIndex) + return toIndex; + + if (fromIndex < toIndex && currentIndex > fromIndex && currentIndex <= toIndex) + return currentIndex - 1; + + if (toIndex < fromIndex && currentIndex >= toIndex && currentIndex < fromIndex) + return currentIndex + 1; + + return currentIndex; + } + + private static double? NormalizeViewMetric(double? value) + { + if (!value.HasValue || double.IsNaN(value.Value) || double.IsInfinity(value.Value) || value.Value <= 0) + return null; + + return value.Value; + } + + private void NormalizeWrappedCells() + { + int maxRowCount = Rows.Count; + int maxColumnCount = ColumnNames.Count; + + WrappedCells = [.. WrappedCells + .Where(cell => cell.RowIndex >= 0 + && cell.RowIndex < maxRowCount + && cell.ColumnIndex >= 0 + && cell.ColumnIndex < maxColumnCount) + .Distinct() + .OrderBy(cell => cell.RowIndex) + .ThenBy(cell => cell.ColumnIndex)]; + } + + private string SerializeDelimitedText(char delimiter) + { + StringBuilder builder = new(); + + for (int rowIndex = 0; rowIndex < RowCount; rowIndex++) + { + if (rowIndex > 0) + builder.Append(NewLineSequence); + + List row = Rows[rowIndex]; + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + if (columnIndex > 0) + builder.Append(delimiter); + + string cellValue = columnIndex < row.Count ? row[columnIndex] ?? string.Empty : string.Empty; + builder.Append(EscapeDelimitedValue(cellValue, delimiter)); + } + } + + return builder.ToString(); + } + + private string SerializeToXml() + { + XElement root = new(CreateXmlName(XmlRootElementName, "rows", 0)); + XContainer rowContainer = root; + + if (!string.IsNullOrWhiteSpace(XmlContainerElementName)) + { + XElement container = new(CreateXmlName(XmlContainerElementName, "items", 0)); + root.Add(container); + rowContainer = container; + } + + for (int rowIndex = 0; rowIndex < RowCount; rowIndex++) + { + XElement rowElement = new(CreateXmlName(XmlRowElementName, "row", rowIndex)); + List row = Rows[rowIndex]; + + for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++) + { + string columnName = ColumnNames[columnIndex]; + string value = columnIndex < row.Count ? row[columnIndex] ?? string.Empty : string.Empty; + + if (columnName.StartsWith('@')) + { + rowElement.SetAttributeValue(CreateXmlName(columnName[1..], "attribute", columnIndex), value); + continue; + } + + rowElement.Add(new XElement(CreateXmlName(columnName, "column", columnIndex), value)); + } + + rowContainer.Add(rowElement); + } + + XDocument document = new(root); + return NormalizeLineEndings(document.ToString(), NewLineSequence); + } + + private char GetDelimiterCharacter() + { + return string.IsNullOrEmpty(Delimiter) ? '\t' : Delimiter[0]; + } + + private static EditTextTableDocument? TryCreateDelimitedDocument( + string text, + char delimiter, + EtwStructuredTextFormat format, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + List> rows = ParseDelimitedText(text, delimiter); + if (!LooksStructured(rows)) + return null; + + TrimParserAddedTerminalRow(text, rows); + + return new EditTextTableDocument + { + Format = format, + Delimiter = delimiter.ToString(), + NewLineSequence = newlineSequence, + ColumnNames = BuildGenericColumnNames(rows.Max(row => row.Count)), + Rows = rows, + RowCount = rows.Count, + ColumnCount = rows.Max(row => row.Count), + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + + private static EditTextTableDocument? TryCreateHeuristicDelimitedDocument( + string text, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + char[] heuristicDelimiters = ['|', ';', ':']; + + foreach (char delimiter in heuristicDelimiters) + { + List> rows = ParseDelimitedText(text, delimiter); + if (!LooksStructured(rows)) + continue; + + TrimParserAddedTerminalRow(text, rows); + + return new EditTextTableDocument + { + Format = EtwStructuredTextFormat.DelimitedText, + Delimiter = delimiter.ToString(), + NewLineSequence = newlineSequence, + ColumnNames = BuildGenericColumnNames(rows.Max(row => row.Count)), + Rows = rows, + RowCount = rows.Count, + ColumnCount = rows.Max(row => row.Count), + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + + return null; + } + + private static EditTextTableDocument? TryCreateXmlDocument( + string text, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + if (string.IsNullOrWhiteSpace(text) || !text.TrimStart().StartsWith('<')) + return null; + + try + { + XDocument xDocument = XDocument.Parse(text, LoadOptions.None); + XElement? root = xDocument.Root; + if (root is null) + return null; + + XElement? rowParent = null; + List? rowElements = null; + + foreach (XElement candidateParent in root.DescendantsAndSelf()) + { + IGrouping? repeatedGroup = candidateParent.Elements() + .GroupBy(element => element.Name.LocalName) + .OrderByDescending(group => group.Count()) + .FirstOrDefault(group => group.Count() > 1); + + if (repeatedGroup is null) + continue; + + if (rowElements is null || repeatedGroup.Count() > rowElements.Count) + { + rowParent = candidateParent; + rowElements = repeatedGroup.ToList(); + } + } + + if (rowParent is null || rowElements is null || rowElements.Count == 0) + return null; + + List columnNames = []; + foreach (XElement rowElement in rowElements) + { + foreach (XAttribute attribute in rowElement.Attributes()) + AddUnique(columnNames, $"@{attribute.Name.LocalName}"); + + foreach (XElement child in rowElement.Elements()) + AddUnique(columnNames, child.Name.LocalName); + } + + if (columnNames.Count == 0) + columnNames.Add("Value"); + + List> rows = []; + foreach (XElement rowElement in rowElements) + { + List row = []; + foreach (string columnName in columnNames) + { + if (columnName.StartsWith('@')) + { + row.Add(rowElement.Attribute(columnName[1..])?.Value ?? string.Empty); + continue; + } + + row.Add(rowElement.Element(columnName)?.Value ?? string.Empty); + } + + rows.Add(row); + } + + string? containerElementName = rowParent == root ? null : rowParent.Name.LocalName; + + return new EditTextTableDocument + { + Format = EtwStructuredTextFormat.Xml, + NewLineSequence = newlineSequence, + XmlRootElementName = root.Name.LocalName, + XmlContainerElementName = containerElementName, + XmlRowElementName = rowElements[0].Name.LocalName, + ColumnNames = columnNames, + Rows = rows, + RowCount = rows.Count, + ColumnCount = columnNames.Count, + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + catch (XmlException) + { + return null; + } + } + + private static EditTextTableDocument CreatePlainTextDocument( + string text, + string newlineSequence, + int minimumRowCount, + int minimumColumnCount) + { + List> rows = SplitPlainTextRows(text); + + return new EditTextTableDocument + { + Format = EtwStructuredTextFormat.PlainText, + NewLineSequence = newlineSequence, + Delimiter = "\t", + ColumnNames = ["Column A"], + Rows = rows, + RowCount = rows.Count, + ColumnCount = 1, + MinimumRowCount = minimumRowCount, + MinimumColumnCount = minimumColumnCount, + }; + } + + private static List> SplitPlainTextRows(string text) + { + if (string.IsNullOrEmpty(text)) + return []; + + string normalized = NormalizeLineEndings(text, "\n"); + string[] lines = normalized.Split('\n', StringSplitOptions.None); + return lines.Select(line => new List { line }).ToList(); + } + + private static List> ParseDelimitedText(string text, char delimiter) + { + List> rows = []; + List currentRow = []; + StringBuilder currentField = new(); + bool insideQuotes = false; + + for (int index = 0; index < text.Length; index++) + { + char character = text[index]; + + if (insideQuotes) + { + if (character == '"') + { + if (index + 1 < text.Length && text[index + 1] == '"') + { + currentField.Append('"'); + index++; + } + else + { + insideQuotes = false; + } + } + else + { + currentField.Append(character); + } + + continue; + } + + if (character == '"') + { + insideQuotes = true; + continue; + } + + if (character == delimiter) + { + currentRow.Add(currentField.ToString()); + currentField.Clear(); + continue; + } + + if (character is '\r' or '\n') + { + currentRow.Add(currentField.ToString()); + currentField.Clear(); + rows.Add(currentRow); + currentRow = []; + + if (character == '\r' && index + 1 < text.Length && text[index + 1] == '\n') + index++; + + continue; + } + + currentField.Append(character); + } + + currentRow.Add(currentField.ToString()); + rows.Add(currentRow); + + return rows; + } + + private static bool LooksStructured(List> rows) + { + if (rows.Count == 0) + return false; + + List> nonEmptyRows = rows.Where(row => row.Any(value => !string.IsNullOrWhiteSpace(value))).ToList(); + if (nonEmptyRows.Count == 0) + return false; + + int maxColumns = nonEmptyRows.Max(row => row.Count); + if (maxColumns < 2) + return false; + + int matchingStructuredRows = nonEmptyRows.Count(row => row.Count == maxColumns && maxColumns > 1); + if (matchingStructuredRows >= 2) + return true; + + return nonEmptyRows.Count == 1 && maxColumns >= 2; + } + + private int InferLogicalColumnCount() + { + for (int columnIndex = ColumnNames.Count - 1; columnIndex >= 0; columnIndex--) + { + foreach (List row in Rows) + { + if (columnIndex < row.Count && !string.IsNullOrEmpty(row[columnIndex])) + return columnIndex + 1; + } + } + + return ColumnNames.Count > 0 ? 1 : 0; + } + + private static void TrimParserAddedTerminalRow(string originalText, List> rows) + { + if (rows.Count < 2 || string.IsNullOrEmpty(originalText)) + return; + + bool endsWithNewLine = originalText.EndsWith("\r", StringComparison.Ordinal) + || originalText.EndsWith("\n", StringComparison.Ordinal); + + if (!endsWithNewLine) + return; + + List lastRow = rows[^1]; + if (lastRow.All(string.IsNullOrEmpty)) + rows.RemoveAt(rows.Count - 1); + } + + private static string EscapeDelimitedValue(string value, char delimiter) + { + bool needsQuotes = + value.Contains(delimiter) + || value.Contains('"') + || value.Contains('\r') + || value.Contains('\n'); + + if (!needsQuotes) + return value; + + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + private static string DetectNewLineSequence(string text) + { + int carriageReturnLineFeedIndex = text.IndexOf("\r\n", StringComparison.Ordinal); + if (carriageReturnLineFeedIndex >= 0) + return "\r\n"; + + if (text.Contains('\n')) + return "\n"; + + if (text.Contains('\r')) + return "\r"; + + return Environment.NewLine; + } + + private static string NormalizeLineEndings(string text, string newLineSequence) + { + return text + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal) + .Replace("\n", newLineSequence, StringComparison.Ordinal); + } + + private static List BuildGenericColumnNames(int count) + { + List columnNames = []; + for (int index = 0; index < count; index++) + columnNames.Add(GetDefaultColumnName(index)); + + return columnNames; + } + + public static string GetSpreadsheetColumnLabel(int index) + { + return ToSpreadsheetColumnName(index); + } + + private static string GetDefaultColumnName(int index) + { + return $"Column {GetSpreadsheetColumnLabel(index)}"; + } + + private static string ToSpreadsheetColumnName(int index) + { + int workingIndex = index + 1; + StringBuilder builder = new(); + + while (workingIndex > 0) + { + workingIndex--; + builder.Insert(0, (char)('A' + (workingIndex % 26))); + workingIndex /= 26; + } + + return builder.ToString(); + } + + private static string EnsureUniqueColumnName(string desiredName, IEnumerable existingNames) + { + string baseName = string.IsNullOrWhiteSpace(desiredName) ? "Column" : desiredName.Trim(); + HashSet existingNameSet = new(existingNames, StringComparer.OrdinalIgnoreCase); + + if (!existingNameSet.Contains(baseName)) + return baseName; + + int suffix = 2; + while (existingNameSet.Contains($"{baseName} {suffix}")) + suffix++; + + return $"{baseName} {suffix}"; + } + + private static void AddUnique(ICollection names, string name) + { + if (!names.Contains(name, StringComparer.OrdinalIgnoreCase)) + names.Add(name); + } + + private static XName CreateXmlName(string? rawName, string fallbackPrefix, int index) + { + string safeName = string.IsNullOrWhiteSpace(rawName) + ? $"{fallbackPrefix}{index + 1}" + : rawName.Trim(); + + safeName = safeName.Replace(' ', '_'); + safeName = XmlConvert.EncodeLocalName(safeName); + + if (char.IsDigit(safeName[0])) + safeName = $"_{safeName}"; + + return XName.Get(safeName); + } +} diff --git a/Text-Grab/Models/ExtractedPattern.cs b/Text-Grab/Models/ExtractedPattern.cs index b3f6829b..8000e03c 100644 --- a/Text-Grab/Models/ExtractedPattern.cs +++ b/Text-Grab/Models/ExtractedPattern.cs @@ -142,9 +142,9 @@ public static string GetLevelLabel(int level) /// Determines the optimal starting precision level based on text characteristics. /// Analyzes length, content type, and structure to suggest the most useful level. /// - /// The text to analyze + /// The text to analyze, or null /// Recommended precision level (0-5) - public static int DetermineStartingLevel(string selection) + public static int DetermineStartingLevel(string? selection) { if (string.IsNullOrWhiteSpace(selection)) return DefaultPrecisionLevel; @@ -161,7 +161,7 @@ public static int DetermineStartingLevel(string selection) return 2; // Length-based pattern for long strings // Content-based analysis (check in priority order) - + // Pure numbers (123, 4567) - likely want similar number sequences if (IsAllDigits(trimmed)) return 2; // Length-flexible for number sequences @@ -239,8 +239,8 @@ private static bool IsAlphanumericMixed(string text) private static bool IsSimpleWord(string text) { string trimmed = text.Trim(); - return trimmed.Length > 0 - && trimmed.All(char.IsLetter) + return trimmed.Length > 0 + && trimmed.All(char.IsLetter) && !trimmed.Any(char.IsWhiteSpace); } diff --git a/Text-Grab/Models/FindResult.cs b/Text-Grab/Models/FindResult.cs index 7a083c6e..69089d07 100644 --- a/Text-Grab/Models/FindResult.cs +++ b/Text-Grab/Models/FindResult.cs @@ -13,11 +13,22 @@ public class FindResult public string PreviewRight { get; set; } = ""; - public int Length + public int Length { get; set; } + + public int? RowIndex { get; set; } + + public int? ColumnIndex { get; set; } + + public string CellAddress { get { - return Text.Length; + if (RowIndex is null || ColumnIndex is null) return string.Empty; + string colLabel = EditTextTableDocument.GetSpreadsheetColumnLabel(ColumnIndex.Value); + return $"Cell: {colLabel}{RowIndex.Value + 1}"; } } + + public string LocationDisplay => + CellAddress.Length > 0 ? CellAddress : $"At index: {Index}"; } diff --git a/Text-Grab/Models/GeneratedOcrLinesWords.cs b/Text-Grab/Models/GeneratedOcrLinesWords.cs new file mode 100644 index 00000000..2f4565b6 --- /dev/null +++ b/Text-Grab/Models/GeneratedOcrLinesWords.cs @@ -0,0 +1,52 @@ +using Windows.Foundation; + +namespace Text_Grab.Models; + +public class GeneratedOcrLinesWords : IOcrLinesWords +{ + public string Text { get; set; } = string.Empty; + + public IOcrLine[] Lines { get; set; } = []; + + public float Angle { get; set; } + + public static GeneratedOcrLinesWords FromParagraph(string text, Rect boundingBox) + { + string normalizedText = text?.Trim() ?? string.Empty; + + return new GeneratedOcrLinesWords + { + Text = normalizedText, + Angle = 0, + Lines = string.IsNullOrWhiteSpace(normalizedText) + ? [] + : [GeneratedOcrLine.FromText(normalizedText, boundingBox)] + }; + } +} + +public class GeneratedOcrLine : IOcrLine +{ + public string Text { get; set; } = string.Empty; + + public IOcrWord[] Words { get; set; } = []; + + public Rect BoundingBox { get; set; } + + public static GeneratedOcrLine FromText(string text, Rect boundingBox) + { + return new GeneratedOcrLine + { + Text = text, + BoundingBox = boundingBox, + Words = [new GeneratedOcrWord { Text = text, BoundingBox = boundingBox }] + }; + } +} + +public class GeneratedOcrWord : IOcrWord +{ + public string Text { get; set; } = string.Empty; + + public Rect BoundingBox { get; set; } +} diff --git a/Text-Grab/Models/GrabFrameTableEditState.cs b/Text-Grab/Models/GrabFrameTableEditState.cs new file mode 100644 index 00000000..1e656176 --- /dev/null +++ b/Text-Grab/Models/GrabFrameTableEditState.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Text_Grab.Models; + +public enum GrabFrameTablePlacementMode +{ + None, + AddRow, + AddColumn, +} + +public sealed class GrabFrameTableEditState +{ + public const double MinimumSeparatorGap = 6; + + public List ManualColumnSeparators { get; private set; } = []; + + public List ManualRowSeparators { get; private set; } = []; + + public GrabFrameTablePlacementMode PlacementMode { get; private set; } + + public double? PreviewPosition { get; private set; } + + public bool IsPreviewValid { get; private set; } + + public bool IsPlacementActive => PlacementMode != GrabFrameTablePlacementMode.None; + + public void BeginPlacement(GrabFrameTablePlacementMode placementMode) + { + PlacementMode = placementMode; + PreviewPosition = null; + IsPreviewValid = false; + } + + public void CancelPlacement() + { + PlacementMode = GrabFrameTablePlacementMode.None; + PreviewPosition = null; + IsPreviewValid = false; + } + + public void ClearAll() + { + CancelPlacement(); + ManualRowSeparators = []; + ManualColumnSeparators = []; + } + + public IReadOnlyList GetExistingSeparatorsForPlacement() + { + return PlacementMode switch + { + GrabFrameTablePlacementMode.AddRow => ManualRowSeparators, + GrabFrameTablePlacementMode.AddColumn => ManualColumnSeparators, + _ => [] + }; + } + + public void SetManualSeparators(IEnumerable? manualRowSeparators, IEnumerable? manualColumnSeparators) + { + ManualRowSeparators = NormalizeSeparators(manualRowSeparators); + ManualColumnSeparators = NormalizeSeparators(manualColumnSeparators); + } + + public void ScaleSeparators(double rowScale, double columnScale) + { + if (double.IsFinite(rowScale) && rowScale > 0) + ManualRowSeparators = NormalizeSeparators(ManualRowSeparators.Select(position => position * rowScale)); + + if (double.IsFinite(columnScale) && columnScale > 0) + ManualColumnSeparators = NormalizeSeparators(ManualColumnSeparators.Select(position => position * columnScale)); + + if (PreviewPosition is not double previewPosition) + return; + + if (PlacementMode == GrabFrameTablePlacementMode.AddRow && double.IsFinite(rowScale) && rowScale > 0) + PreviewPosition = Math.Round(previewPosition * rowScale); + else if (PlacementMode == GrabFrameTablePlacementMode.AddColumn && double.IsFinite(columnScale) && columnScale > 0) + PreviewPosition = Math.Round(previewPosition * columnScale); + } + + public bool TryCommitPreview() + { + if (!IsPlacementActive || !IsPreviewValid || PreviewPosition is not double previewPosition) + return false; + + List separatorList = PlacementMode == GrabFrameTablePlacementMode.AddRow + ? ManualRowSeparators + : ManualColumnSeparators; + + separatorList.Add(previewPosition); + separatorList.Sort(); + separatorList = NormalizeSeparators(separatorList); + + if (PlacementMode == GrabFrameTablePlacementMode.AddRow) + ManualRowSeparators = separatorList; + else + ManualColumnSeparators = separatorList; + + return true; + } + + public bool TryUpdatePreview( + double requestedPosition, + double minimumPosition, + double maximumPosition, + IEnumerable existingSeparators, + double minimumGap = MinimumSeparatorGap) + { + if (!IsPlacementActive) + { + PreviewPosition = null; + IsPreviewValid = false; + return false; + } + + IsPreviewValid = TryNormalizeSeparatorPosition( + requestedPosition, + minimumPosition, + maximumPosition, + existingSeparators, + minimumGap, + out double normalizedPosition); + + PreviewPosition = normalizedPosition; + return IsPreviewValid; + } + + public static List NormalizeSeparators(IEnumerable? separators) + { + if (separators is null) + return []; + + return [.. separators + .Where(double.IsFinite) + .Select(position => Math.Round(position)) + .Distinct() + .OrderBy(position => position)]; + } + + public static bool TryNormalizeSeparatorPosition( + double requestedPosition, + double minimumPosition, + double maximumPosition, + IEnumerable? existingSeparators, + double minimumGap, + out double normalizedPosition) + { + normalizedPosition = 0; + + if (!double.IsFinite(requestedPosition) + || !double.IsFinite(minimumPosition) + || !double.IsFinite(maximumPosition) + || !double.IsFinite(minimumGap) + || maximumPosition <= minimumPosition) + { + return false; + } + + double clampedPosition = Math.Round(Math.Clamp(requestedPosition, minimumPosition, maximumPosition)); + normalizedPosition = clampedPosition; + + if (clampedPosition <= minimumPosition || clampedPosition >= maximumPosition) + return false; + + foreach (double existingPosition in NormalizeSeparators(existingSeparators)) + { + if (Math.Abs(existingPosition - clampedPosition) < minimumGap) + return false; + } + + return true; + } +} diff --git a/Text-Grab/Models/HistoryInfo.cs b/Text-Grab/Models/HistoryInfo.cs index 69f3e52c..1910b35f 100644 --- a/Text-Grab/Models/HistoryInfo.cs +++ b/Text-Grab/Models/HistoryInfo.cs @@ -48,6 +48,17 @@ public HistoryInfo() public int CalcPaneWidth { get; set; } = 0; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ManualTableColumnSeparators { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ManualTableRowSeparators { get; set; } + + public EtwEditorMode EditorMode { get; set; } = EtwEditorMode.Text; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EditTextTableDocumentJson { get; set; } + [JsonIgnore] public ILanguage OcrLanguage { @@ -64,6 +75,7 @@ public ILanguage OcrLanguage LanguageKind.Global => new GlobalLang(new Language(normalizedLanguageTag)), LanguageKind.Tesseract => new TessLang(normalizedLanguageTag), LanguageKind.WindowsAi => new WindowsAiLang(), + LanguageKind.WindowsAiDescription => new WindowsAiDescriptionLang(), LanguageKind.UiAutomation => CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(), _ => new GlobalLang(LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US")), }; diff --git a/Text-Grab/Models/ResultTable.cs b/Text-Grab/Models/ResultTable.cs index 512d7731..39a9b301 100644 --- a/Text-Grab/Models/ResultTable.cs +++ b/Text-Grab/Models/ResultTable.cs @@ -22,9 +22,13 @@ public class ResultTable public Rect BoundingRect { get; set; } = new(); - public List ColumnLines { get; set; } = []; + public List ColumnLines { get; set; } = []; - public List RowLines { get; set; } = []; + public List ManualColumnSeparators { get; private set; } = []; + + public List ManualRowSeparators { get; private set; } = []; + + public List RowLines { get; set; } = []; public Canvas? TableLines { get; set; } = null; @@ -94,7 +98,7 @@ private void ParseRowAndColumnLines() for (int i = 0; i < Columns.Count - 1; i++) { - int columnMid = (int)(Columns[i].Right + Columns[i + 1].Left) / 2; + double columnMid = (Columns[i].Right + Columns[i + 1].Left) / 2; ColumnLines.Add(columnMid); } @@ -104,7 +108,7 @@ private void ParseRowAndColumnLines() for (int i = 0; i < Rows.Count - 1; i++) { - int rowMid = (int)(Rows[i].Bottom + Rows[i + 1].Top) / 2; + double rowMid = (Rows[i].Bottom + Rows[i + 1].Top) / 2; RowLines.Add(rowMid); } } @@ -171,11 +175,25 @@ public static List ParseOcrResultIntoWordBorderInfos(IOcrLinesWo // 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); + } + + public void AnalyzeAsTable( + ICollection wordBorders, + Rectangle rectCanvasSize, + IReadOnlyCollection? manualRowSeparators, + IReadOnlyCollection? manualColumnSeparators, + bool drawTable = true) { if (wordBorders == null || wordBorders.Count == 0) { Rows.Clear(); Columns.Clear(); + RowLines.Clear(); + ColumnLines.Clear(); + ManualRowSeparators = []; + ManualColumnSeparators = []; return; } @@ -200,10 +218,9 @@ public void AnalyzeAsTable(ICollection wordBorders, Rectangle re Rows.AddRange(resultRows); Columns.Clear(); Columns.AddRange(resultColumns); - - AssignWordBordersToFinalGrid(wordBorders); - ParseRowAndColumnLines(); + ApplyManualSeparators(manualRowSeparators, manualColumnSeparators); + AssignWordBordersToFinalGrid(wordBorders); if (drawTable) DrawTable(); } @@ -445,7 +462,7 @@ private void DrawTable() Canvas.SetTop(tableOutline, this.BoundingRect.Y); Canvas.SetLeft(tableOutline, this.BoundingRect.X); - foreach (int columnLine in this.ColumnLines) + foreach (double columnLine in this.ColumnLines) { Border vertLine = new() { @@ -458,7 +475,7 @@ private void DrawTable() Canvas.SetLeft(vertLine, columnLine); } - foreach (int rowLine in this.RowLines) + foreach (double rowLine in this.RowLines) { Border horzLine = new() { @@ -809,96 +826,92 @@ private static void MergeTheseRowIDs(List resultRows, List outli // Overload for WordBorderInfo private void AssignWordBordersToFinalGrid(ICollection wordBorders) { - if (Rows.Count == 0 || Columns.Count == 0) + if (Rows.Count == 0 && Columns.Count == 0) return; - // Precompute row and column edge arrays (sorted by ID/index) - int rowCount = Rows.Count; - int colCount = Columns.Count; - double[] rowTops = new double[rowCount]; - double[] rowBottoms = new double[rowCount]; - for (int i = 0; i < rowCount; i++) - { - rowTops[i] = Rows[i].Top; - rowBottoms[i] = Rows[i].Bottom; - } - double[] colLefts = new double[colCount]; - double[] colRights = new double[colCount]; - for (int j = 0; j < colCount; j++) + foreach (WordBorderInfo wb in wordBorders) { - colLefts[j] = Columns[j].Left; - colRights[j] = Columns[j].Right; + double centerX = wb.BorderRect.Left + (wb.BorderRect.Width / 2.0); + double centerY = wb.BorderRect.Top + (wb.BorderRect.Height / 2.0); + wb.ResultRowID = CountSeparatorsBefore(RowLines, centerY); + wb.ResultColumnID = CountSeparatorsBefore(ColumnLines, centerX); } + } + + private void ApplyManualSeparators( + IReadOnlyCollection? manualRowSeparators, + IReadOnlyCollection? manualColumnSeparators) + { + ManualRowSeparators = SanitizeManualSeparators( + manualRowSeparators, + RowLines, + BoundingRect.Top, + BoundingRect.Bottom); + ManualColumnSeparators = SanitizeManualSeparators( + manualColumnSeparators, + ColumnLines, + BoundingRect.Left, + BoundingRect.Right); + + RowLines = MergeSeparatorLines(RowLines, ManualRowSeparators); + ColumnLines = MergeSeparatorLines(ColumnLines, ManualColumnSeparators); + } - static int LowerBound(double[] arr, double value) + private static int CountSeparatorsBefore(IEnumerable separators, double coordinate) + { + int count = 0; + + foreach (double separator in separators) { - int lo = 0, hi = arr.Length; // [lo, hi) - while (lo < hi) - { - int mid = (lo + hi) >> 1; - if (arr[mid] <= value) lo = mid + 1; else hi = mid; - } - return lo - 1; // last index with arr[i] <= value, or -1 if none + if (coordinate > separator) + count++; } - foreach (WordBorderInfo wb in wordBorders) - { - double centerX = wb.BorderRect.Left + (wb.BorderRect.Width / 2.0); - double centerY = wb.BorderRect.Top + (wb.BorderRect.Height / 2.0); + return count; + } - // Find row by binary search on Tops then validate with Bottoms - int rowIndex = LowerBound(rowTops, centerY); - if (rowIndex < 0) rowIndex = 0; - if (rowIndex >= rowCount) rowIndex = rowCount - 1; - if (!(centerY >= rowTops[rowIndex] && centerY <= rowBottoms[rowIndex])) - { - // choose nearest neighbor row by distance to boundaries - double bestDist = double.MaxValue; - int bestIdx = rowIndex; - // candidate current - double dCur = centerY < rowTops[rowIndex] ? (rowTops[rowIndex] - centerY) : (centerY - rowBottoms[rowIndex]); - if (dCur < bestDist) { bestDist = dCur; bestIdx = rowIndex; } - // candidate previous - if (rowIndex - 1 >= 0) - { - double dPrev = centerY < rowTops[rowIndex - 1] ? (rowTops[rowIndex - 1] - centerY) : (centerY - rowBottoms[rowIndex - 1]); - if (dPrev < bestDist) { bestDist = dPrev; bestIdx = rowIndex - 1; } - } - // candidate next - if (rowIndex + 1 < rowCount) - { - double dNext = centerY < rowTops[rowIndex + 1] ? (rowTops[rowIndex + 1] - centerY) : (centerY - rowBottoms[rowIndex + 1]); - if (dNext < bestDist) { bestDist = dNext; bestIdx = rowIndex + 1; } - } - rowIndex = bestIdx; - } + private static List MergeSeparatorLines(IEnumerable automaticSeparators, IEnumerable manualSeparators) + { + return [.. automaticSeparators + .Concat(manualSeparators) + .Where(double.IsFinite) + .Distinct() + .OrderBy(position => position)]; + } - // Find column by binary search on Lefts then validate with Rights - int colIndex = LowerBound(colLefts, centerX); - if (colIndex < 0) colIndex = 0; - if (colIndex >= colCount) colIndex = colCount - 1; - if (!(centerX >= colLefts[colIndex] && centerX <= colRights[colIndex])) - { - double bestDist = double.MaxValue; - int bestIdx = colIndex; - double dCur = centerX < colLefts[colIndex] ? (colLefts[colIndex] - centerX) : (centerX - colRights[colIndex]); - if (dCur < bestDist) { bestDist = dCur; bestIdx = colIndex; } - if (colIndex - 1 >= 0) - { - double dPrev = centerX < colLefts[colIndex - 1] ? (colLefts[colIndex - 1] - centerX) : (centerX - colRights[colIndex - 1]); - if (dPrev < bestDist) { bestDist = dPrev; bestIdx = colIndex - 1; } - } - if (colIndex + 1 < colCount) - { - double dNext = centerX < colLefts[colIndex + 1] ? (colLefts[colIndex + 1] - centerX) : (centerX - colRights[colIndex + 1]); - if (dNext < bestDist) { bestDist = dNext; bestIdx = colIndex + 1; } - } - colIndex = bestIdx; + private static List SanitizeManualSeparators( + IEnumerable? manualSeparators, + IEnumerable automaticSeparators, + double minimumPosition, + double maximumPosition) + { + if (manualSeparators is null) + return []; + + List appliedSeparators = []; + List existingSeparators = MergeSeparatorLines(automaticSeparators, []); + + foreach (double manualSeparator in manualSeparators + .Where(double.IsFinite) + .OrderBy(position => position)) + { + if (!GrabFrameTableEditState.TryNormalizeSeparatorPosition( + manualSeparator, + minimumPosition + GrabFrameTableEditState.MinimumSeparatorGap, + maximumPosition - GrabFrameTableEditState.MinimumSeparatorGap, + existingSeparators, + GrabFrameTableEditState.MinimumSeparatorGap, + out double normalizedSeparator)) + { + continue; } - wb.ResultRowID = rowIndex; - wb.ResultColumnID = colIndex; + appliedSeparators.Add(normalizedSeparator); + existingSeparators.Add(normalizedSeparator); + existingSeparators.Sort(); } + + return appliedSeparators; } } diff --git a/Text-Grab/Models/SpreadsheetUndoHistory.cs b/Text-Grab/Models/SpreadsheetUndoHistory.cs new file mode 100644 index 00000000..7dc51512 --- /dev/null +++ b/Text-Grab/Models/SpreadsheetUndoHistory.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace Text_Grab.Models; + +internal sealed class SpreadsheetUndoState +{ + public SpreadsheetUndoState(string documentJson, int? focusRow, int? focusColumn) + { + DocumentJson = documentJson ?? string.Empty; + FocusRow = focusRow; + FocusColumn = focusColumn; + } + + public string DocumentJson { get; } + + public int? FocusRow { get; } + + public int? FocusColumn { get; } +} + +internal sealed class SpreadsheetUndoHistory +{ + private readonly Stack undoStack = []; + private readonly Stack redoStack = []; + + public bool CanUndo => undoStack.Count > 0; + + public bool CanRedo => redoStack.Count > 0; + + public void Clear() + { + undoStack.Clear(); + redoStack.Clear(); + } + + public void RecordChange(SpreadsheetUndoState? beforeChange, SpreadsheetUndoState? afterChange) + { + if (beforeChange is null + || afterChange is null + || string.Equals(beforeChange.DocumentJson, afterChange.DocumentJson, StringComparison.Ordinal)) + { + return; + } + + undoStack.Push(beforeChange); + redoStack.Clear(); + } + + public SpreadsheetUndoState? Undo(SpreadsheetUndoState? currentState) + { + if (currentState is null || undoStack.Count == 0) + return null; + + SpreadsheetUndoState previousState = undoStack.Pop(); + redoStack.Push(currentState); + return previousState; + } + + public SpreadsheetUndoState? Redo(SpreadsheetUndoState? currentState) + { + if (currentState is null || redoStack.Count == 0) + return null; + + SpreadsheetUndoState nextState = redoStack.Pop(); + undoStack.Push(currentState); + return nextState; + } +} diff --git a/Text-Grab/Models/ThirdPartyPackageInfo.cs b/Text-Grab/Models/ThirdPartyPackageInfo.cs new file mode 100644 index 00000000..4586a194 --- /dev/null +++ b/Text-Grab/Models/ThirdPartyPackageInfo.cs @@ -0,0 +1,14 @@ +namespace Text_Grab.Models; + +public sealed record ThirdPartyPackageInfo( + string PackageId, + string Version, + string Scope, + string License, + string ProjectUrl, + string NoticeTarget, + bool NoticeIsLocal = false, + string Notes = "") +{ + public string DisplayNotes => string.IsNullOrWhiteSpace(Notes) ? "\u2014" : Notes; +} diff --git a/Text-Grab/Models/WindowsAiDescriptionLang.cs b/Text-Grab/Models/WindowsAiDescriptionLang.cs new file mode 100644 index 00000000..a6029d10 --- /dev/null +++ b/Text-Grab/Models/WindowsAiDescriptionLang.cs @@ -0,0 +1,26 @@ +using Text_Grab.Interfaces; +using Windows.Globalization; + +namespace Text_Grab.Models; + +public class WindowsAiDescriptionLang : ILanguage +{ + public const string Tag = "WinAI-Desc"; + public const string DisplayLabel = "Windows AI Description"; + + public string AbbreviatedName => "WinAI Desc"; + + public string DisplayName => DisplayLabel; + + public string CurrentInputMethodLanguageTag => string.Empty; + + public string CultureDisplayName => DisplayLabel; + + public string LanguageTag => Tag; + + public LanguageLayoutDirection LayoutDirection => LanguageLayoutDirection.Ltr; + + public string NativeName => DisplayLabel; + + public string Script => string.Empty; +} diff --git a/Text-Grab/Models/WordBorderInfo.cs b/Text-Grab/Models/WordBorderInfo.cs index c8bc8f33..25e6934f 100644 --- a/Text-Grab/Models/WordBorderInfo.cs +++ b/Text-Grab/Models/WordBorderInfo.cs @@ -1,4 +1,5 @@ -using System.Windows; +using System; +using System.Windows; using Text_Grab.Controls; namespace Text_Grab.Models; @@ -6,7 +7,10 @@ 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; @@ -21,6 +25,11 @@ 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; diff --git a/Text-Grab/NativeMethods.cs b/Text-Grab/NativeMethods.cs index ed8beccb..7ac8315d 100644 --- a/Text-Grab/NativeMethods.cs +++ b/Text-Grab/NativeMethods.cs @@ -5,6 +5,7 @@ internal static partial class NativeMethods { // See http://msdn.microsoft.com/en-us/library/ms649021%28v=vs.85%29.aspx public const int WM_CLIPBOARDUPDATE = 0x031D; + public static readonly uint WM_TASKBARCREATED = RegisterWindowMessage("TaskbarCreated"); public static IntPtr HWND_MESSAGE = new(-3); // See http://msdn.microsoft.com/en-us/library/ms632599%28VS.85%29.aspx#message_only @@ -12,6 +13,9 @@ internal static partial class NativeMethods [return: MarshalAs(UnmanagedType.Bool)] public static partial bool AddClipboardFormatListener(IntPtr hwnd); + [LibraryImport("user32.dll", EntryPoint = "RegisterWindowMessageW", StringMarshalling = StringMarshalling.Utf16)] + public static partial uint RegisterWindowMessage(string lpString); + [LibraryImport("gdi32.dll")] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool DeleteObject(IntPtr hObject); @@ -25,4 +29,14 @@ internal static partial class NativeMethods [LibraryImport("shell32.dll")] public static partial void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + + public const int GWL_EX_STYLE = -20; + public const int WS_EX_APPWINDOW = 0x00040000; + public const int WS_EX_TOOLWINDOW = 0x00000080; + + [DllImport("user32.dll")] + public static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll")] + public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); } diff --git a/Text-Grab/Pages/EditTextWindowSettings.xaml b/Text-Grab/Pages/EditTextWindowSettings.xaml index 4f3917de..6b03ff73 100644 --- a/Text-Grab/Pages/EditTextWindowSettings.xaml +++ b/Text-Grab/Pages/EditTextWindowSettings.xaml @@ -64,6 +64,17 @@ Style="{StaticResource TextBodyNormal}" Text="Remember and restore the Edit Text Window's size and position between sessions." /> + + + - Add "Grab text with Text Grab" to right-click menu for image files + Add "Grab text with Text Grab" to right-click menu for image and PDF files - Right-click on PNG, JPG, BMP, GIF, or TIFF files to quickly grab text. + Right-click on supported image files or PDFs to quickly grab text. @@ -254,11 +254,11 @@ Checked="RegisterOpenWithCheckBox_Checked" Unchecked="RegisterOpenWithCheckBox_Unchecked"> - Register Text Grab as an "Open with" app for image files + Register Text Grab as an "Open with" app for image and PDF files - Opens images directly in Grab Frame when using "Open with" from File Explorer. + Opens supported images and PDFs directly in Grab Frame when using "Open with" from File Explorer. + + + Detect paragraph wrapping — join wrapped lines into one paragraph + + + diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 987e789f..69406c5d 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -135,6 +135,7 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) HistorySwitch.IsChecked = DefaultSettings.UseHistory; ErrorCorrectBox.IsChecked = DefaultSettings.CorrectErrors; CorrectToLatin.IsChecked = DefaultSettings.CorrectToLatin; + ParagraphDetectionToggle.IsChecked = DefaultSettings.ParagraphDetection; NeverUseClipboardChkBx.IsChecked = DefaultSettings.NeverAutoUseClipboard; TryInsertCheckbox.IsChecked = DefaultSettings.TryInsert; InsertDelaySeconds = DefaultSettings.InsertDelay; @@ -301,6 +302,22 @@ private void ErrorCorrectBox_Unchecked(object sender, RoutedEventArgs e) DefaultSettings.CorrectErrors = false; } + private void ParagraphDetectionToggle_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.ParagraphDetection = true; + } + + private void ParagraphDetectionToggle_Unchecked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.ParagraphDetection = false; + } + private void CorrectToLatin_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) diff --git a/Text-Grab/Pages/LanguageSettings.xaml b/Text-Grab/Pages/LanguageSettings.xaml index cc473f19..96412571 100644 --- a/Text-Grab/Pages/LanguageSettings.xaml +++ b/Text-Grab/Pages/LanguageSettings.xaml @@ -52,6 +52,21 @@ TextWrapping="Wrap"> Windows AI OCR is a new state-of-the-art OCR engine first introduced on Copilot+ PCs. It supports a wide range of styles and languages, as well as being more accurate. + + + Show Windows AI Description as a language option + + + + When enabled, Text Grab can use Windows AI to generate a description of an image instead of extracted OCR text. + False + + True + False + + False + False @@ -236,5 +242,8 @@ False + + True + - + \ No newline at end of file diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs index 04a16177..c1d277fe 100644 --- a/Text-Grab/Services/CalculationService.DateTimeMath.cs +++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs @@ -7,6 +7,14 @@ namespace Text_Grab.Services; public partial class CalculationService { + private const double AverageDaysPerMonth = 30.44; + private const double AverageDaysPerYear = 365.25; + private const double HoursPerDay = 24d; + private const double MinutesPerDay = HoursPerDay * 60d; + private const double SecondsPerDay = MinutesPerDay * 60d; + + private readonly record struct DurationUnitInfo(string SingularName, string PluralName, double DaysPerUnit); + /// /// Attempts to evaluate a line as a date/time math expression. /// Supports expressions like "March 10th + 10 days", "2/25/26 11:02pm + 800 mins", etc. @@ -18,7 +26,7 @@ public partial class CalculationService /// True if the line was successfully evaluated as a date/time math expression public static bool TryEvaluateDateTimeMath(string line, out string result) { - return TryEvaluateDateTimeMath(line, out result, out _, null); + return TryEvaluateDateTimeMath(line, out result, out _, null, out _); } /// @@ -31,15 +39,35 @@ public static bool TryEvaluateDateTimeMath(string line, out string result) /// An optional base DateTime from a previous line's result, used when the date part is empty /// True if the line was successfully evaluated as a date/time math expression public static bool TryEvaluateDateTimeMath(string line, out string result, out DateTime? parsedDateTime, DateTime? baseDateTime) + { + return TryEvaluateDateTimeMath(line, out result, out parsedDateTime, baseDateTime, out _); + } + + /// + /// Attempts to evaluate a line as a date/time math expression, optionally returning a numeric + /// result for duration conversions like "3.6 years to days" or date differences with a target unit. + /// + private static bool TryEvaluateDateTimeMath(string line, out string result, out DateTime? parsedDateTime, DateTime? baseDateTime, out double? numericResult) { result = string.Empty; parsedDateTime = null; + numericResult = null; if (string.IsNullOrWhiteSpace(line)) return false; // Try date subtraction first (date - date = timespan) - if (TryEvaluateDateSubtraction(line, out result)) + if (TryEvaluateDateSubtraction(line, out result, out double? dateSubtractionNumericResult)) + { + numericResult = dateSubtractionNumericResult; + return true; + } + + // Then try standalone duration conversions like "3.6 years to days" + if (TryEvaluateDurationConversion(line, out result, out double durationConversionValue)) + { + numericResult = durationConversionValue; return true; + } // Find the first explicit arithmetic operation (requires +/-) to anchor where arithmetic starts Match anchorMatch = DateTimeArithmeticPattern().Match(line); @@ -152,7 +180,7 @@ private static DateTime AddFractionalYears(DateTime dateTime, double years) dateTime = dateTime.AddYears(wholeYears); if (Math.Abs(fraction) > double.Epsilon) - dateTime = dateTime.AddDays(fraction * 365.25); + dateTime = dateTime.AddDays(fraction * AverageDaysPerYear); return dateTime; } @@ -164,7 +192,7 @@ private static DateTime AddFractionalMonths(DateTime dateTime, double months) dateTime = dateTime.AddMonths(wholeMonths); if (Math.Abs(fraction) > double.Epsilon) - dateTime = dateTime.AddDays(fraction * 30.44); + dateTime = dateTime.AddDays(fraction * AverageDaysPerMonth); return dateTime; } @@ -252,20 +280,30 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime) /// /// Attempts to evaluate a line as a date subtraction expression (date - date = timespan). /// Supports expressions like "March 10th - January 1st", "today - yesterday", etc. - /// Returns the duration between the two dates in whole units down to seconds. + /// Returns the duration between the two dates in whole units down to seconds, or as a + /// single requested unit for expressions like "... in weeks". /// - private static bool TryEvaluateDateSubtraction(string line, out string result) + private static bool TryEvaluateDateSubtraction(string line, out string result, out double? numericResult) { result = string.Empty; + numericResult = null; + + string subtractionExpression = line.Trim(); + DurationUnitInfo? targetUnit = null; + if (TryExtractRequestedDurationUnit(subtractionExpression, out string expressionWithoutTargetUnit, out DurationUnitInfo requestedTargetUnit)) + { + subtractionExpression = expressionWithoutTargetUnit; + targetUnit = requestedTargetUnit; + } - MatchCollection matches = DateSubtractionSplitPattern().Matches(line); + MatchCollection matches = DateSubtractionSplitPattern().Matches(subtractionExpression); if (matches.Count == 0) return false; foreach (Match splitMatch in matches) { - string leftPart = line[..splitMatch.Index].Trim(); - string rightPart = line[(splitMatch.Index + splitMatch.Length)..].Trim(); + string leftPart = subtractionExpression[..splitMatch.Index].Trim(); + string rightPart = subtractionExpression[(splitMatch.Index + splitMatch.Length)..].Trim(); if (string.IsNullOrEmpty(leftPart) || string.IsNullOrEmpty(rightPart)) continue; @@ -287,13 +325,52 @@ private static bool TryEvaluateDateSubtraction(string line, out string result) earlier = date1; } - result = FormatTimeSpanHumanReadable(earlier, later); + if (targetUnit.HasValue) + { + double convertedValue = ConvertDurationValue((later - earlier).TotalDays, new DurationUnitInfo("day", "days", 1d), targetUnit.Value); + result = FormatDurationValue(convertedValue, targetUnit.Value); + numericResult = convertedValue; + } + else + { + result = FormatTimeSpanHumanReadable(earlier, later); + } return true; } return false; } + /// + /// Attempts to evaluate a standalone duration conversion like "3.6 years to days". + /// Uses fixed-average durations for months, years, and decades. + /// + private static bool TryEvaluateDurationConversion(string line, out string result, out double numericResult) + { + result = string.Empty; + numericResult = 0; + + Match match = ToConversionPattern().Match(line); + if (!match.Success) + match = InConversionPattern().Match(line); + + if (!match.Success) + return false; + + string sourcePart = match.Groups[1].Value.Trim(); + string targetPart = match.Groups[2].Value.Trim(); + + if (!TryParseDurationValueAndUnit(sourcePart, out double sourceValue, out DurationUnitInfo sourceUnit)) + return false; + + if (!TryResolveDurationUnit(targetPart, out DurationUnitInfo targetUnit)) + return false; + + numericResult = ConvertDurationValue(sourceValue, sourceUnit, targetUnit); + result = FormatDurationValue(numericResult, targetUnit); + return true; + } + /// /// Formats the difference between two dates as a human-readable string /// with whole units from years down to seconds (e.g., "2 weeks 3 days 2 hours"). @@ -333,6 +410,110 @@ private static string FormatTimeSpanHumanReadable(DateTime earlier, DateTime lat return parts.Count == 0 ? "0 seconds" : string.Join(" ", parts); } + private static bool TryExtractRequestedDurationUnit(string input, out string expressionWithoutTargetUnit, out DurationUnitInfo targetUnit) + { + expressionWithoutTargetUnit = input.Trim(); + targetUnit = default; + + Match match = DateSubtractionTargetUnitPattern().Match(expressionWithoutTargetUnit); + if (!match.Success) + return false; + + string body = match.Groups["body"].Value.Trim(); + string unitText = match.Groups["unit"].Value.Trim(); + if (string.IsNullOrEmpty(body) || !TryResolveDurationUnit(unitText, out targetUnit)) + return false; + + expressionWithoutTargetUnit = body; + return true; + } + + private static bool TryParseDurationValueAndUnit(string input, out double value, out DurationUnitInfo unit) + { + value = 0; + unit = default; + + Match match = DurationValuePattern().Match(input.Trim()); + if (!match.Success) + return false; + + if (!TryParseFlexibleDouble(match.Groups["number"].Value, out value)) + return false; + + return TryResolveDurationUnit(match.Groups["unit"].Value, out unit); + } + + private static bool TryResolveDurationUnit(string unitText, out DurationUnitInfo unit) + { + switch (unitText.Trim().ToLowerInvariant()) + { + case "decade": + case "decades": + unit = new DurationUnitInfo("decade", "decades", AverageDaysPerYear * 10d); + return true; + case "year": + case "years": + unit = new DurationUnitInfo("year", "years", AverageDaysPerYear); + return true; + case "month": + case "months": + unit = new DurationUnitInfo("month", "months", AverageDaysPerMonth); + return true; + case "week": + case "weeks": + unit = new DurationUnitInfo("week", "weeks", 7d); + return true; + case "day": + case "days": + unit = new DurationUnitInfo("day", "days", 1d); + return true; + case "hour": + case "hours": + case "hr": + case "hrs": + unit = new DurationUnitInfo("hour", "hours", 1d / HoursPerDay); + return true; + case "minute": + case "minutes": + case "min": + case "mins": + unit = new DurationUnitInfo("minute", "minutes", 1d / MinutesPerDay); + return true; + case "second": + case "seconds": + case "sec": + case "secs": + unit = new DurationUnitInfo("second", "seconds", 1d / SecondsPerDay); + return true; + default: + unit = default; + return false; + } + } + + private static double ConvertDurationValue(double value, DurationUnitInfo sourceUnit, DurationUnitInfo targetUnit) + { + double totalDays = value * sourceUnit.DaysPerUnit; + return totalDays / targetUnit.DaysPerUnit; + } + + private static string FormatDurationValue(double value, DurationUnitInfo unit) + { + string unitName = Math.Abs(Math.Abs(value) - 1d) < 1e-9 + ? unit.SingularName + : unit.PluralName; + + return $"{FormatDurationNumber(value)} {unitName}"; + } + + private static string FormatDurationNumber(double value) + { + double rounded = Math.Round(value); + return Math.Abs(value - rounded) < 1e-9 + ? rounded.ToString("N0") + : value.ToString("#,##0.###"); + } + [System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] private static partial System.Text.RegularExpressions.Regex DateTimeArithmeticPattern(); @@ -350,4 +531,10 @@ private static string FormatTimeSpanHumanReadable(DateTime earlier, DateTime lat [System.Text.RegularExpressions.GeneratedRegex(@"\s+-\s+")] private static partial System.Text.RegularExpressions.Regex DateSubtractionSplitPattern(); + + [System.Text.RegularExpressions.GeneratedRegex(@"^(?.+?)\s+(?:to|in)\s+(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min|seconds?|secs?|sec)\s*$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex DateSubtractionTargetUnitPattern(); + + [System.Text.RegularExpressions.GeneratedRegex(@"^(?[-+]?(?:\d[\d,._ ]*\d|\d))\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min|seconds?|secs?|sec)\s*$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex DurationValuePattern(); } diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs index 48173c2a..add120ee 100644 --- a/Text-Grab/Services/CalculationService.UnitMath.cs +++ b/Text-Grab/Services/CalculationService.UnitMath.cs @@ -14,6 +14,14 @@ public partial class CalculationService /// private readonly record struct UnitInfo(Enum Unit, string QuantityName, string Abbreviation); + private enum PaceUnit + { + MinutePerMile, + MinutePerKilometer + } + + private const double KilometersPerMile = 1.609344; + /// /// Represents the result of a unit-bearing evaluation for tracking across lines. /// Used for operator continuation (e.g., "5 km" then "+ 3 km" or "to miles"). @@ -156,6 +164,7 @@ public class UnitResult { "mph", new(SpeedUnit.MilePerHour, "Speed", "mph") }, { "miles per hour", new(SpeedUnit.MilePerHour, "Speed", "mph") }, { "km/h", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") }, + { "km/hr", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") }, { "kph", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") }, { "kilometers per hour", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") }, { "m/s", new(SpeedUnit.MeterPerSecond, "Speed", "m/s") }, @@ -163,6 +172,15 @@ public class UnitResult { "knot", new(SpeedUnit.Knot, "Speed", "kn") }, { "knots", new(SpeedUnit.Knot, "Speed", "kn") }, { "kn", new(SpeedUnit.Knot, "Speed", "kn") }, + { "min/mi", new(PaceUnit.MinutePerMile, "Speed", "min/mi") }, + { "min/mile", new(PaceUnit.MinutePerMile, "Speed", "min/mi") }, + { "minute per mile", new(PaceUnit.MinutePerMile, "Speed", "min/mi") }, + { "minutes per mile", new(PaceUnit.MinutePerMile, "Speed", "min/mi") }, + { "min/km", new(PaceUnit.MinutePerKilometer, "Speed", "min/km") }, + { "minute per kilometer", new(PaceUnit.MinutePerKilometer, "Speed", "min/km") }, + { "minutes per kilometer", new(PaceUnit.MinutePerKilometer, "Speed", "min/km") }, + { "minute per kilometre", new(PaceUnit.MinutePerKilometer, "Speed", "min/km") }, + { "minutes per kilometre", new(PaceUnit.MinutePerKilometer, "Speed", "min/km") }, // ══════════════════════════════════════════════════════════════ // AREA @@ -279,30 +297,18 @@ private bool TryContinuationConversion( if (!TryResolveUnit(targetStr, out UnitInfo target)) return false; - // Ensure compatible quantity types (e.g., both are Length) - if (previous.Unit.GetType() != target.Unit.GetType()) + if (!TryConvertUnitValue(previous.Value, previous.Unit, target.Unit, out double convertedValue)) return false; - try - { - IQuantity source = Quantity.From(previous.Value, previous.Unit); - IQuantity converted = source.ToUnit(target.Unit); - double convertedValue = (double)converted.Value; - - unitResult = new UnitResult - { - Value = convertedValue, - Unit = target.Unit, - QuantityName = target.QuantityName, - Abbreviation = target.Abbreviation - }; - result = FormatUnitValue(convertedValue, target.Abbreviation); - return true; - } - catch + unitResult = new UnitResult { - return false; - } + Value = convertedValue, + Unit = target.Unit, + QuantityName = target.QuantityName, + Abbreviation = target.Abbreviation + }; + result = FormatUnitValue(convertedValue, target.Abbreviation); + return true; } /// @@ -332,43 +338,33 @@ private bool TryOperatorWithUnit( if (!TryResolveUnit(unitStr, out UnitInfo operandUnit)) return false; - // Must be same quantity type + // Arithmetic stays limited to the same unit family. if (previous.Unit.GetType() != operandUnit.Unit.GetType()) return false; - try + double operandInPreviousUnit; + if (operandUnit.Unit.Equals(previous.Unit)) { - // Convert operand to the previous result's unit - double operandInPreviousUnit; - if (operandUnit.Unit.Equals(previous.Unit)) - { - operandInPreviousUnit = number; - } - else - { - IQuantity operandQuantity = Quantity.From(number, operandUnit.Unit); - IQuantity converted = operandQuantity.ToUnit(previous.Unit); - operandInPreviousUnit = (double)converted.Value; - } - - double newValue = op == "+" - ? previous.Value + operandInPreviousUnit - : previous.Value - operandInPreviousUnit; - - unitResult = new UnitResult - { - Value = newValue, - Unit = previous.Unit, - QuantityName = previous.QuantityName, - Abbreviation = previous.Abbreviation - }; - result = FormatUnitValue(newValue, previous.Abbreviation); - return true; + operandInPreviousUnit = number; } - catch + else if (!TryConvertUnitValue(number, operandUnit.Unit, previous.Unit, out operandInPreviousUnit)) { return false; } + + double newValue = op == "+" + ? previous.Value + operandInPreviousUnit + : previous.Value - operandInPreviousUnit; + + unitResult = new UnitResult + { + Value = newValue, + Unit = previous.Unit, + QuantityName = previous.QuantityName, + Abbreviation = previous.Abbreviation + }; + result = FormatUnitValue(newValue, previous.Abbreviation); + return true; } /// @@ -443,30 +439,18 @@ private bool TryExplicitConversion( if (!TryResolveUnit(targetStr, out UnitInfo targetUnit)) return false; - // Ensure compatible quantity types - if (sourceUnit.Unit.GetType() != targetUnit.Unit.GetType()) + if (!TryConvertUnitValue(value, sourceUnit.Unit, targetUnit.Unit, out double convertedValue)) return false; - try - { - IQuantity source = Quantity.From(value, sourceUnit.Unit); - IQuantity converted = source.ToUnit(targetUnit.Unit); - double convertedValue = (double)converted.Value; - - unitResult = new UnitResult - { - Value = convertedValue, - Unit = targetUnit.Unit, - QuantityName = targetUnit.QuantityName, - Abbreviation = targetUnit.Abbreviation - }; - result = FormatUnitValue(convertedValue, targetUnit.Abbreviation); - return true; - } - catch + unitResult = new UnitResult { - return false; - } + Value = convertedValue, + Unit = targetUnit.Unit, + QuantityName = targetUnit.QuantityName, + Abbreviation = targetUnit.Abbreviation + }; + result = FormatUnitValue(convertedValue, targetUnit.Abbreviation); + return true; } /// @@ -515,7 +499,8 @@ private bool TryStandaloneUnit( } /// - /// Extracts a numeric value and unit from a string like "5 miles", "100°F", "3.5 gallons". + /// Extracts a numeric value and unit from a string like "5 miles", "100°F", "3.5 gallons", + /// or runner pace values like "9:30 min/mi". /// The number must appear at the beginning and the unit at the end. /// private static bool TryExtractValueAndUnit(string input, out double value, out UnitInfo unitInfo) @@ -530,13 +515,160 @@ private static bool TryExtractValueAndUnit(string input, out double value, out U if (!match.Success) return false; - string numberStr = match.Groups["number"].Value; string unitStr = match.Groups["unit"].Value.Trim(); + if (!TryResolveUnit(unitStr, out unitInfo)) + return false; + + string numberStr = match.Groups["number"].Value; + return TryParseUnitValue(numberStr, unitInfo, out value); + } + + private static bool TryParseUnitValue(string input, UnitInfo unitInfo, out double value) + { + if (unitInfo.Unit is PaceUnit) + { + return input.Contains(':', StringComparison.Ordinal) + ? TryParsePaceTimeValue(input, out value) + : TryParseFlexibleDouble(input, out value); + } + + value = 0; + return !input.Contains(':', StringComparison.Ordinal) + && TryParseFlexibleDouble(input, out value); + } + + private static bool TryParsePaceTimeValue(string input, out double value) + { + value = 0; + + if (string.IsNullOrWhiteSpace(input)) + return false; - if (!TryParseFlexibleDouble(numberStr, out value)) + string[] parts = input.Trim().Split(':'); + if (parts.Length is < 2 or > 3) return false; - return TryResolveUnit(unitStr, out unitInfo); + if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out int leading) + || leading < 0) + { + return false; + } + + if (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out int seconds) + || seconds is < 0 or >= 60) + { + return false; + } + + if (parts.Length == 2) + { + value = leading + (seconds / 60d); + return true; + } + + if (!int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out int thirdPart) + || thirdPart is < 0 or >= 60) + { + return false; + } + + value = (leading * 60) + seconds + (thirdPart / 60d); + return true; + } + + private static bool TryConvertUnitValue(double value, Enum sourceUnit, Enum targetUnit, out double convertedValue) + { + convertedValue = 0; + + if (sourceUnit.Equals(targetUnit)) + { + convertedValue = value; + return true; + } + + if (sourceUnit is PaceUnit || targetUnit is PaceUnit) + return TryConvertSpeedLikeUnitValue(value, sourceUnit, targetUnit, out convertedValue); + + try + { + IQuantity source = Quantity.From(value, sourceUnit); + IQuantity converted = source.ToUnit(targetUnit); + convertedValue = (double)converted.Value; + return true; + } + catch + { + return false; + } + } + + private static bool TryConvertSpeedLikeUnitValue(double value, Enum sourceUnit, Enum targetUnit, out double convertedValue) + { + convertedValue = 0; + + if (!TryConvertToKilometersPerHour(value, sourceUnit, out double kilometersPerHour)) + return false; + + return TryConvertFromKilometersPerHour(kilometersPerHour, targetUnit, out convertedValue); + } + + private static bool TryConvertToKilometersPerHour(double value, Enum unit, out double kilometersPerHour) + { + kilometersPerHour = 0; + + if (unit is PaceUnit paceUnit) + { + if (value <= 0) + return false; + + kilometersPerHour = paceUnit switch + { + PaceUnit.MinutePerMile => (60 * KilometersPerMile) / value, + PaceUnit.MinutePerKilometer => 60 / value, + _ => 0 + }; + + return kilometersPerHour > 0; + } + + if (unit is SpeedUnit speedUnit) + { + if (value <= 0) + return false; + + kilometersPerHour = Speed.From(value, speedUnit).KilometersPerHour; + return true; + } + + return false; + } + + private static bool TryConvertFromKilometersPerHour(double kilometersPerHour, Enum unit, out double convertedValue) + { + convertedValue = 0; + + if (kilometersPerHour <= 0) + return false; + + if (unit is PaceUnit paceUnit) + { + convertedValue = paceUnit switch + { + PaceUnit.MinutePerMile => (60 * KilometersPerMile) / kilometersPerHour, + PaceUnit.MinutePerKilometer => 60 / kilometersPerHour, + _ => 0 + }; + + return convertedValue > 0; + } + + if (unit is SpeedUnit speedUnit) + { + convertedValue = (double)Speed.FromKilometersPerHour(kilometersPerHour).ToUnit(speedUnit).Value; + return true; + } + + return false; } /// @@ -648,7 +780,7 @@ private string FormatUnitValue(double value, string abbreviation) /// /// Matches a number followed by a unit: "5 miles", "100°F", "3.5 gallons". /// - [System.Text.RegularExpressions.GeneratedRegex(@"^(?-?\d+\.?\d*)\s*(?.+)$")] + [System.Text.RegularExpressions.GeneratedRegex(@"^(?-?(?:\d+(?::\d{1,2}){1,2}|\d+\.?\d*))\s*(?.+)$")] private static partial System.Text.RegularExpressions.Regex NumberWithUnitPattern(); /// diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs index 26f10512..25ffa930 100644 --- a/Text-Grab/Services/CalculationService.cs +++ b/Text-Grab/Services/CalculationService.cs @@ -74,12 +74,21 @@ public async Task EvaluateExpressionsAsync(string input) try { - if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult, out DateTime? parsedDateTime, previousDateTimeResult)) + if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult, out DateTime? parsedDateTime, previousDateTimeResult, out double? numericDateTimeResult)) { results.Add(dateTimeResult); previousDateTimeResult = parsedDateTime; - previousLineResult = null; previousUnitResult = null; + + if (numericDateTimeResult.HasValue) + { + outputNumbers.Add(numericDateTimeResult.Value); + previousLineResult = numericDateTimeResult.Value; + } + else + { + previousLineResult = null; + } } else if (TryEvaluateUnitConversion(trimmedLine, out string unitResultStr, out UnitResult? newUnitResult, previousUnitResult)) { diff --git a/Text-Grab/Services/HistoryService.cs b/Text-Grab/Services/HistoryService.cs index 20e81efb..6b882cfe 100644 --- a/Text-Grab/Services/HistoryService.cs +++ b/Text-Grab/Services/HistoryService.cs @@ -15,10 +15,12 @@ using Text_Grab.Properties; using Text_Grab.Utilities; using Text_Grab.Views; +using SymbolIcon = Wpf.Ui.Controls.SymbolIcon; +using SymbolRegular = Wpf.Ui.Controls.SymbolRegular; namespace Text_Grab.Services; -public class HistoryService +public partial class HistoryService : IDisposable { #region Fields @@ -41,6 +43,7 @@ public class HistoryService private bool _textHistoryLoaded; private bool _imageHistoryLoaded; private bool _hasPendingWrite; + private bool _disposed; private DateTimeOffset _lastHistoryAccessUtc = DateTimeOffset.MinValue; #endregion Fields @@ -68,9 +71,13 @@ public HistoryService() public void CacheLastBitmap(Bitmap bmp) { + // Acquire the HBITMAP first so a failure here doesn't leave CachedBitmap + // pointing at a bitmap whose handle we never recorded. + nint newHandle = bmp.GetHbitmap(); + DisposeCachedBitmap(); CachedBitmap = bmp; - _cachedBitmapHandle = bmp.GetHbitmap(); + _cachedBitmapHandle = newHandle; } public void DeleteHistory() @@ -177,7 +184,7 @@ public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) List grabsHistory = GetRecentGrabs(); grabsHistory = [.. grabsHistory.OrderByDescending(x => x.CaptureDateTime)]; - recentGrabsMenuItem.Items.Clear(); + ClearRecentGrabsMenuItems(recentGrabsMenuItem); if (grabsHistory.Count < 1) { @@ -185,6 +192,8 @@ public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) return; } + recentGrabsMenuItem.IsEnabled = true; + string historyBasePath = await FileUtilities.GetPathToHistory(); foreach (HistoryInfo history in grabsHistory) @@ -193,28 +202,51 @@ public async Task PopulateMenuItemWithRecentGrabs(MenuItem recentGrabsMenuItem) if (string.IsNullOrWhiteSpace(history.ImagePath) || !File.Exists(imageFullPath)) continue; - MenuItem menuItem = new(); - string historyId = history.ID; - menuItem.Click += (object sender, RoutedEventArgs args) => - { - HistoryInfo? selectedHistory = GetImageHistoryById(historyId); + MenuItem menuItem = new() { Tag = history.ID }; + menuItem.Click += RecentGrabMenuItem_Click; - if (selectedHistory is null) + string snippet = history.TextContent.Trim().Replace("\t", " ").MakeStringSingleLine().Truncate(40); + menuItem.Header = $"{history.CaptureDateTime.Humanize().Trim()} | {snippet}"; + menuItem.Icon = new SymbolIcon + { + Symbol = history.EditorMode switch { - menuItem.IsEnabled = false; - return; + EtwEditorMode.Spreadsheet => SymbolRegular.Table24, + EtwEditorMode.Markdown => SymbolRegular.Markdown20, + _ => SymbolRegular.TextT24, } - - GrabFrame grabFrame = new(selectedHistory); - try { grabFrame.Show(); } - catch { menuItem.IsEnabled = false; } }; - - menuItem.Header = $"{history.CaptureDateTime.Humanize()} | {history.TextContent.MakeStringSingleLine().Truncate(20)}"; recentGrabsMenuItem.Items.Add(menuItem); } } + public void ClearRecentGrabsMenuItems(MenuItem recentGrabsMenuItem) + { + foreach (object item in recentGrabsMenuItem.Items) + { + if (item is MenuItem oldItem) + oldItem.Click -= RecentGrabMenuItem_Click; + } + recentGrabsMenuItem.Items.Clear(); + } + + private void RecentGrabMenuItem_Click(object sender, RoutedEventArgs args) + { + if (sender is not MenuItem menuItem || menuItem.Tag is not string historyId) + return; + + HistoryInfo? selectedHistory = GetImageHistoryById(historyId); + if (selectedHistory is null) + { + menuItem.IsEnabled = false; + return; + } + + GrabFrame grabFrame = new(selectedHistory); + try { grabFrame.Show(); } + catch { menuItem.IsEnabled = false; } + } + public void SaveToHistory(GrabFrame grabFrameToSave) { if (!DefaultSettings.UseHistory) @@ -442,6 +474,28 @@ public void ReleaseLoadedHistories() ReleaseLoadedHistoriesCore(); } + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + saveTimer.Stop(); + saveTimer.Tick -= SaveTimer_Tick; + + historyCacheReleaseTimer.Stop(); + historyCacheReleaseTimer.Tick -= HistoryCacheReleaseTimer_Tick; + + if (_hasPendingWrite) + WriteHistory(); + + DisposeCachedBitmap(); + ReleaseLoadedHistoriesCore(); + + GC.SuppressFinalize(this); + } + #endregion Public Methods #region Private Methods @@ -470,7 +524,7 @@ private static void WriteHistoryFiles(List history, string fileName try { - FileUtilities.SaveTextFile(historyAsJson, $"{fileName}.json", FileStorageKind.WithHistory); + SaveHistoryTextFileBlocking(historyAsJson, $"{fileName}.json"); } catch (Exception ex) { diff --git a/Text-Grab/Services/LanguageService.cs b/Text-Grab/Services/LanguageService.cs index c4232dd1..ae0a40e0 100644 --- a/Text-Grab/Services/LanguageService.cs +++ b/Text-Grab/Services/LanguageService.cs @@ -28,9 +28,11 @@ public class LanguageService private ILanguage? _cachedOcrLanguage; private readonly object _cacheLock = new(); - // Static instance of WindowsAiLang to avoid allocations + // Static instances of pseudo-languages to avoid allocations private static readonly WindowsAiLang _windowsAiLangInstance = new(); private static readonly string _windowsAiLangTag = _windowsAiLangInstance.LanguageTag; + private static readonly WindowsAiDescriptionLang _windowsAiDescriptionLangInstance = new(); + private static readonly string _windowsAiDescriptionLangTag = _windowsAiDescriptionLangInstance.LanguageTag; private static readonly UiAutomationLang _uiAutomationLangInstance = new(); private static readonly string _uiAutomationLangTag = _uiAutomationLangInstance.LanguageTag; @@ -83,6 +85,12 @@ public IList GetAllLanguages() languages.Add(_windowsAiLangInstance); } + if (AppUtilities.TextGrabSettings.WindowsAiDescriptionEnabled + && WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) + { + languages.Add(_windowsAiDescriptionLangInstance); + } + foreach (Language lang in OcrEngine.AvailableRecognizerLanguages) { // Wrap Windows.Globalization.Language in a compatible ILanguage implementation @@ -103,6 +111,7 @@ public static string GetLanguageTag(object language) { Language lang => lang.LanguageTag, WindowsAiLang => _windowsAiLangTag, + WindowsAiDescriptionLang => _windowsAiDescriptionLangTag, UiAutomationLang => _uiAutomationLangTag, TessLang tessLang => tessLang.RawTag, GlobalLang gLang => gLang.LanguageTag, @@ -119,6 +128,7 @@ public static LanguageKind GetLanguageKind(object language) { Language => LanguageKind.Global, WindowsAiLang => LanguageKind.WindowsAi, + WindowsAiDescriptionLang => LanguageKind.WindowsAiDescription, UiAutomationLang => LanguageKind.UiAutomation, TessLang => LanguageKind.Tesseract, _ => LanguageKind.Global, // Default fallback @@ -174,18 +184,31 @@ public ILanguage GetOCRLanguage() { if (lastUsedLang == _windowsAiLangTag) { - // If the last used language is Windows AI, return static instance - _cachedOcrLanguage = _windowsAiLangInstance; - return _cachedOcrLanguage; + if (WindowsAiUtilities.CanDeviceUseWinAI()) + { + _cachedOcrLanguage = _windowsAiLangInstance; + return _cachedOcrLanguage; + } + + selectedLanguage = GetCurrentInputLanguage(); } + else if (lastUsedLang == _windowsAiDescriptionLangTag) + { + if (AppUtilities.TextGrabSettings.WindowsAiDescriptionEnabled + && WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) + { + _cachedOcrLanguage = _windowsAiDescriptionLangInstance; + return _cachedOcrLanguage; + } - if (lastUsedLang == _uiAutomationLangTag && AppUtilities.TextGrabSettings.UiAutomationEnabled) + selectedLanguage = GetCurrentInputLanguage(); + } + else if (lastUsedLang == _uiAutomationLangTag && AppUtilities.TextGrabSettings.UiAutomationEnabled) { _cachedOcrLanguage = _uiAutomationLangInstance; return _cachedOcrLanguage; } - - if (lastUsedLang == _uiAutomationLangTag) + else if (lastUsedLang == _uiAutomationLangTag) { selectedLanguage = CaptureLanguageUtilities.GetUiAutomationFallbackLanguage(); } diff --git a/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index fc28f999..3f7d0a0b 100644 --- a/Text-Grab/Services/SettingsService.cs +++ b/Text-Grab/Services/SettingsService.cs @@ -16,6 +16,7 @@ namespace Text_Grab.Services; internal class SettingsService : IDisposable { private const string ManagedJsonSettingsFolderName = "settings-data"; + private const string RegularSettingsSidecarFileName = "Settings.json"; private static readonly Dictionary ManagedJsonSettingFiles = new(StringComparer.Ordinal) { @@ -29,6 +30,7 @@ internal class SettingsService : IDisposable private readonly ApplicationDataContainer? _localSettings; private readonly string _managedJsonSettingsFolderPath; + private readonly string _regularSettingsSidecarFilePath; private readonly bool _saveClassicSettingsChanges; private readonly bool _preferFileBackedManagedSettings; private readonly Lock _managedJsonLock = new(); @@ -58,12 +60,15 @@ internal SettingsService( Properties.Settings classicSettings, ApplicationDataContainer? localSettings, string? managedJsonSettingsFolderPath = null, + string? regularSettingsSidecarFilePath = null, bool saveClassicSettingsChanges = true) { ClassicSettings = classicSettings; _localSettings = localSettings; _managedJsonSettingsFolderPath = managedJsonSettingsFolderPath ?? GetManagedJsonSettingsFolderPath(); + _regularSettingsSidecarFilePath = regularSettingsSidecarFilePath ?? GetRegularSettingsSidecarFilePath(); _saveClassicSettingsChanges = saveClassicSettingsChanges; + Dictionary regularSettingsSidecarSnapshot = ReadRegularSettingsSidecarSnapshot(); if (ClassicSettings.FirstRun) { @@ -83,6 +88,12 @@ internal SettingsService( } } + bool shouldUseRegularSettingsSidecar = _localSettings is null + && (ClassicSettings.EnableFileBackedManagedSettings || SidecarEnablesFileBackedManagedSettings(regularSettingsSidecarSnapshot)); + + if (shouldUseRegularSettingsSidecar) + SyncRegularSettingsSidecarWithClassic(regularSettingsSidecarSnapshot); + // Must be read after any migration so the user's saved preference is respected. _preferFileBackedManagedSettings = ClassicSettings.EnableFileBackedManagedSettings; @@ -127,6 +138,9 @@ private void ClassicSettings_PropertyChanged(object? sender, PropertyChangedEven } SaveSettingInContainer(propertyName, ClassicSettings[propertyName]); + + if (ShouldPersistRegularSettingsSidecar(propertyName)) + PersistRegularSettingsSidecar(); } public void Dispose() @@ -297,6 +311,15 @@ public void SavePostGrabCheckStates(IReadOnlyDictionary checkState ref _cachedPostGrabCheckStates); } + internal void ReconcileManagedJsonSettings() + { + foreach (string propertyName in ManagedJsonSettingFiles.Keys) + { + InvalidateManagedJsonCache(propertyName); + _ = ReadManagedJsonSettingText(propertyName); + } + } + private void HandleManagedJsonSettingChanged(string propertyName) { InvalidateManagedJsonCache(propertyName); @@ -505,6 +528,197 @@ private static string GetManagedJsonSettingsFolderPath() return Path.Combine(exeDir ?? "c:\\Text-Grab", ManagedJsonSettingsFolderName); } + private void SyncRegularSettingsSidecarWithClassic(IReadOnlyDictionary sidecarSnapshot) + { + Dictionary mergedValues = CaptureRegularSettingsSnapshot(); + + foreach ((string propertyName, JsonElement jsonValue) in sidecarSnapshot) + { + if (!TryGetRegularSettingsProperty(propertyName, out SettingsProperty? property)) + continue; + + if (!TryConvertJsonElementToSettingValue(jsonValue, property.PropertyType, out object? value)) + continue; + + mergedValues[propertyName] = value; + } + + mergedValues[nameof(Properties.Settings.EnableFileBackedManagedSettings)] = true; + + ApplyRegularSettingsSnapshot(mergedValues); + PersistRegularSettingsSidecar(mergedValues); + } + + private Dictionary CaptureRegularSettingsSnapshot() + { + Dictionary settingsSnapshot = new(StringComparer.Ordinal); + + foreach (SettingsProperty property in ClassicSettings.Properties) + { + if (!IsRegularSettingsSidecarProperty(property)) + continue; + + settingsSnapshot[property.Name] = ClassicSettings[property.Name]; + } + + return settingsSnapshot; + } + + private void ApplyRegularSettingsSnapshot(IReadOnlyDictionary snapshot) + { + bool changedAny = false; + + foreach ((string propertyName, object? value) in snapshot) + { + object? currentValue = ClassicSettings[propertyName]; + if (Equals(currentValue, value)) + continue; + + ClassicSettings[propertyName] = value; + changedAny = true; + } + + if (changedAny && _saveClassicSettingsChanges) + ClassicSettings.Save(); + } + + private void PersistRegularSettingsSidecar() + { + PersistRegularSettingsSidecar(CaptureRegularSettingsSnapshot()); + } + + private void PersistRegularSettingsSidecar(IReadOnlyDictionary snapshot) + { + try + { + string? directory = Path.GetDirectoryName(_regularSettingsSidecarFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + JsonSerializerOptions options = new() + { + WriteIndented = true, + }; + + string json = JsonSerializer.Serialize(snapshot, options); + File.WriteAllText(_regularSettingsSidecarFilePath, json); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to persist regular settings sidecar: {ex.Message}"); + } + } + + private Dictionary ReadRegularSettingsSidecarSnapshot() + { + if (_localSettings is not null || !File.Exists(_regularSettingsSidecarFilePath)) + return []; + + try + { + string json = File.ReadAllText(_regularSettingsSidecarFilePath); + return JsonSerializer.Deserialize>(json) ?? []; + } + catch (Exception ex) when (ex is IOException or JsonException) + { + Debug.WriteLine($"Failed to read regular settings sidecar: {ex.Message}"); + return []; + } + } + + private static bool SidecarEnablesFileBackedManagedSettings(IReadOnlyDictionary sidecarSnapshot) + { + if (!sidecarSnapshot.TryGetValue(nameof(Properties.Settings.EnableFileBackedManagedSettings), out JsonElement settingValue)) + return false; + + return TryConvertJsonElementToSettingValue( + settingValue, + typeof(bool), + out object? convertedValue) + && convertedValue is true; + } + + private bool TryGetRegularSettingsProperty(string propertyName, out SettingsProperty property) + { + SettingsProperty? candidate = ClassicSettings.Properties[propertyName]; + if (candidate is null || !IsRegularSettingsSidecarProperty(candidate)) + { + property = null!; + return false; + } + + property = candidate; + return true; + } + + private bool ShouldPersistRegularSettingsSidecar(string propertyName) + { + if (_localSettings is not null) + return false; + + return ClassicSettings.EnableFileBackedManagedSettings + || string.Equals(propertyName, nameof(Properties.Settings.EnableFileBackedManagedSettings), StringComparison.Ordinal); + } + + private static bool IsRegularSettingsSidecarProperty(SettingsProperty property) + { + return property.Attributes[typeof(UserScopedSettingAttribute)] is not null + && !IsManagedJsonSetting(property.Name) + && !string.Equals(property.Name, nameof(Properties.Settings.GrabTemplatesJSON), StringComparison.Ordinal); + } + + private static bool TryConvertJsonElementToSettingValue(JsonElement jsonElement, Type propertyType, out object? value) + { + try + { + if (propertyType == typeof(string)) + { + value = jsonElement.ValueKind == JsonValueKind.Null ? null : jsonElement.GetString(); + return true; + } + + if (propertyType == typeof(bool)) + { + value = jsonElement.GetBoolean(); + return true; + } + + if (propertyType == typeof(int)) + { + value = jsonElement.GetInt32(); + return true; + } + + if (propertyType == typeof(double)) + { + value = jsonElement.GetDouble(); + return true; + } + + if (propertyType == typeof(long)) + { + value = jsonElement.GetInt64(); + return true; + } + } + catch (Exception ex) when (ex is InvalidOperationException or FormatException or OverflowException) + { + Debug.WriteLine($"Failed to convert sidecar setting value: {ex.Message}"); + } + + value = null; + return false; + } + + private static string GetRegularSettingsSidecarFilePath() + { + if (AppUtilities.IsPackaged()) + return Path.Combine(ApplicationData.Current.LocalFolder.Path, RegularSettingsSidecarFileName); + + string? exeDir = Path.GetDirectoryName(FileUtilities.GetExePath()); + return Path.Combine(exeDir ?? "c:\\Text-Grab", RegularSettingsSidecarFileName); + } + private void InvalidateManagedJsonCache(string propertyName) { lock (_managedJsonLock) diff --git a/Text-Grab/Styles/ButtonStyles.xaml b/Text-Grab/Styles/ButtonStyles.xaml index 8e4a300b..10b109ce 100644 --- a/Text-Grab/Styles/ButtonStyles.xaml +++ b/Text-Grab/Styles/ButtonStyles.xaml @@ -218,7 +218,8 @@ 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}}"> @@ -237,9 +238,12 @@ VerticalAlignment="Top"> + Width="{Binding ActualWidth, + ElementName=SubMenuBorder}" + Height="{Binding ActualHeight, + ElementName=SubMenuBorder}" + Fill="{Binding Background, + ElementName=SubMenuBorder}" /> @@ -469,9 +474,12 @@ 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}}}" /> @@ -75,7 +81,7 @@ + + diff --git a/Text-Grab/Styles/ListViewScrollFix.xaml b/Text-Grab/Styles/ListViewScrollFix.xaml index 3dad6541..aa605b6f 100644 --- a/Text-Grab/Styles/ListViewScrollFix.xaml +++ b/Text-Grab/Styles/ListViewScrollFix.xaml @@ -26,12 +26,18 @@ VerticalScrollBarVisibility="Hidden"> + Value="{Binding Path=HorizontalOffset, + RelativeSource={RelativeSource TemplatedParent}, + Mode=OneWay}" /> + Value="{Binding Path=VerticalOffset, + RelativeSource={RelativeSource TemplatedParent}, + Mode=OneWay}" /> + HorizontalScrollBarVisibility="{TemplateBinding HorizontalScrollBarVisibility}" + VerticalScrollBarVisibility="{TemplateBinding VerticalScrollBarVisibility}" /> @@ -126,8 +126,8 @@ x:Name="PART_ContentHost" Padding="0" Focusable="false" - HorizontalScrollBarVisibility="Hidden" - VerticalScrollBarVisibility="Hidden" /> + HorizontalScrollBarVisibility="{TemplateBinding HorizontalScrollBarVisibility}" + VerticalScrollBarVisibility="{TemplateBinding VerticalScrollBarVisibility}" /> @@ -169,7 +169,8 @@ x:Name="Arrow" HorizontalAlignment="Center" VerticalAlignment="Center" - Data="{Binding Content, RelativeSource={RelativeSource TemplatedParent}}"> + Data="{Binding Content, + RelativeSource={RelativeSource TemplatedParent}}"> diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 8ebd0208..f039a5ef 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -12,8 +12,8 @@ Text_Grab.App app.manifest x64;x86;ARM64 - Joseph Finney 2025 - TextGrab.net + Joseph Finney 2026 + https://textgrab.net git https://github.com/TheJoeFin/Text-Grab README.md @@ -23,7 +23,9 @@ true win-x86;win-x64;win-arm64 false - 4.13.2 + 4.14.0 + + $(NoWarn);WFO0003 @@ -44,6 +46,18 @@ True \ + + BUILT-WITH.md + True + \ + PreserveNewest + + + ThirdPartyNotices\licenses\%(RecursiveDir)%(Filename)%(Extension) + True + ThirdPartyNotices\licenses\%(RecursiveDir) + PreserveNewest + True \ @@ -54,18 +68,20 @@ - - - + + + + - - - - + + + + + - - + + @@ -120,4 +136,4 @@ Settings.Designer.cs - \ No newline at end of file + diff --git a/Text-Grab/Utilities/CaptureLanguageUtilities.cs b/Text-Grab/Utilities/CaptureLanguageUtilities.cs index 564177c3..c5e96756 100644 --- a/Text-Grab/Utilities/CaptureLanguageUtilities.cs +++ b/Text-Grab/Utilities/CaptureLanguageUtilities.cs @@ -20,6 +20,12 @@ public static async Task> GetCaptureLanguagesAsync(bool includeT if (WindowsAiUtilities.CanDeviceUseWinAI()) languages.Add(new WindowsAiLang()); + if (AppUtilities.TextGrabSettings.WindowsAiDescriptionEnabled + && WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) + { + languages.Add(new WindowsAiDescriptionLang()); + } + if (includeTesseract && AppUtilities.TextGrabSettings.UseTesseract && TesseractHelper.CanLocateTesseractExe()) @@ -75,7 +81,7 @@ public static ILanguage GetUiAutomationFallbackLanguage() } public static bool SupportsTableOutput(ILanguage language) - => language is not TessLang && language is not UiAutomationLang; + => language is not TessLang && language is not UiAutomationLang && language is not WindowsAiDescriptionLang; public static bool IsStaticImageCompatible(ILanguage language) => language is not UiAutomationLang; diff --git a/Text-Grab/Utilities/ClipboardUtilities.cs b/Text-Grab/Utilities/ClipboardUtilities.cs index 833e09ba..bea9d2a9 100644 --- a/Text-Grab/Utilities/ClipboardUtilities.cs +++ b/Text-Grab/Utilities/ClipboardUtilities.cs @@ -1,6 +1,10 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Net; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; using System.Windows.Media; @@ -53,7 +57,7 @@ public static (bool, ImageSource?) TryGetImageFromClipboard() if (!ClipboardContainsBase64Image()) { - IDataObject clipboardData = System.Windows.Clipboard.GetDataObject(); + IDataObject? clipboardData = System.Windows.Clipboard.GetDataObject(); if (clipboardData is null || !clipboardData.GetDataPresent(System.Windows.Forms.DataFormats.Bitmap)) return (false, null); @@ -127,6 +131,240 @@ private static string CleanTeamsBase64Image(string dirtyTeamsString) return sb.ToString(); } + public static bool TryGetHtmlTableAsTabSeparated(out string tabSeparated) + { + tabSeparated = string.Empty; + try + { + if (!System.Windows.Clipboard.ContainsData(System.Windows.DataFormats.Html)) + return false; + + string htmlData = System.Windows.Clipboard.GetData(System.Windows.DataFormats.Html) as string ?? string.Empty; + if (string.IsNullOrEmpty(htmlData)) + return false; + + string result = ConvertHtmlToTabSeparated(htmlData); + if (string.IsNullOrEmpty(result)) + return false; + + tabSeparated = result; + return true; + } + catch + { + return false; + } + } + + 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) + { + // Advance past columns already occupied by rowspan carry-overs + while (rowData.ContainsKey(nextFreeCol)) + nextFreeCol++; + + 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 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 ? span : 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 @@ -147,4 +385,4 @@ private static string base64ImageExtension(ref string base64String) else return string.Empty; } -} \ No newline at end of file +} diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs index 4088aab2..7ef899fb 100644 --- a/Text-Grab/Utilities/ContextMenuUtilities.cs +++ b/Text-Grab/Utilities/ContextMenuUtilities.cs @@ -6,7 +6,7 @@ namespace Text_Grab.Utilities; /// /// Utility class for managing Windows context menu integration. -/// Adds "Grab text with Text Grab" and "Open in Grab Frame" options to the right-click context menu for image files. +/// Adds "Grab text with Text Grab" and "Open in Grab Frame" options to the right-click context menu for supported visual documents. /// internal static class ContextMenuUtilities { @@ -16,22 +16,17 @@ internal static class ContextMenuUtilities private const string GrabFrameDisplayText = "Open in Grab Frame"; /// - /// Supported image file extensions for context menu integration. + /// Supported image and PDF file extensions for context menu integration. /// - private static readonly string[] ImageExtensions = + private static readonly string[] VisualDocumentExtensions = [ - ".png", - ".jpg", - ".jpeg", - ".bmp", - ".gif", - ".tiff", - ".tif" + .. IoUtilities.ImageExtensions, + .. IoUtilities.PdfExtensions ]; /// - /// Adds Text Grab to the Windows context menu for image files. - /// This allows users to right-click on an image and select "Grab text with Text Grab" or "Open in Grab Frame". + /// Adds Text Grab to the Windows context menu for supported visual documents. + /// This allows users to right-click a file and select "Grab text with Text Grab" or "Open in Grab Frame". /// /// When the method returns false, contains an error message describing the failure. /// True if registration was successful, false otherwise. @@ -48,7 +43,7 @@ public static bool AddToContextMenu(out string? errorMessage) try { - foreach (string extension in ImageExtensions) + foreach (string extension in VisualDocumentExtensions) { RegisterGrabTextContextMenu(extension, executablePath); RegisterGrabFrameContextMenu(extension, executablePath); @@ -70,7 +65,7 @@ public static bool AddToContextMenu(out string? errorMessage) } /// - /// Removes Text Grab from the Windows context menu for image files. + /// Removes Text Grab from the Windows context menu for supported visual documents. /// /// When the method returns false, contains an error message describing the failure. /// True if removal was successful, false otherwise. @@ -79,7 +74,7 @@ public static bool RemoveFromContextMenu(out string? errorMessage) errorMessage = null; try { - foreach (string extension in ImageExtensions) + foreach (string extension in VisualDocumentExtensions) { UnregisterContextMenuForExtension(extension, GrabTextRegistryKeyName); UnregisterContextMenuForExtension(extension, GrabFrameRegistryKeyName); @@ -109,7 +104,7 @@ public static bool IsRegisteredInContextMenu() try { // Check if at least one extension has the context menu registered - foreach (string extension in ImageExtensions) + foreach (string extension in VisualDocumentExtensions) { string keyPath = GetShellKeyPath(extension, GrabTextRegistryKeyName); using RegistryKey? key = Registry.CurrentUser.OpenSubKey(keyPath); @@ -186,7 +181,7 @@ private static void RegisterGrabFrameContextMenu(string extension, string execut throw new InvalidOperationException($"Could not create command registry key for {extension}"); } - // --grabframe flag opens the image in GrabFrame instead of EditTextWindow + // --grabframe flag opens the visual document in GrabFrame instead of EditTextWindow commandKey.SetValue(string.Empty, $"\"{executablePath}\" --grabframe \"%1\""); } } diff --git a/Text-Grab/Utilities/DiagnosticsUtilities.cs b/Text-Grab/Utilities/DiagnosticsUtilities.cs index f552ac3e..9b9eb206 100644 --- a/Text-Grab/Utilities/DiagnosticsUtilities.cs +++ b/Text-Grab/Utilities/DiagnosticsUtilities.cs @@ -161,6 +161,7 @@ private static SettingsInfoModel GetSettingsInfo() // OCR / error correction CorrectErrors = s.CorrectErrors, CorrectToLatin = s.CorrectToLatin, + ParagraphDetection = s.ParagraphDetection, TryToReadBarcodes = s.TryToReadBarcodes, UseTesseract = s.UseTesseract, TesseractPathConfigured = !string.IsNullOrWhiteSpace(s.TesseractPath), @@ -508,6 +509,7 @@ public class SettingsInfoModel // OCR / error correction public bool CorrectErrors { get; set; } public bool CorrectToLatin { get; set; } + public bool ParagraphDetection { get; set; } public bool TryToReadBarcodes { get; set; } public bool UseTesseract { get; set; } public bool TesseractPathConfigured { get; set; } // true/false only — full path is PII diff --git a/Text-Grab/Utilities/FileUtilities.cs b/Text-Grab/Utilities/FileUtilities.cs index 97b84f15..c84033a9 100644 --- a/Text-Grab/Utilities/FileUtilities.cs +++ b/Text-Grab/Utilities/FileUtilities.cs @@ -3,6 +3,7 @@ using System.Drawing; using System.Drawing.Imaging; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using Windows.Storage; @@ -31,27 +32,45 @@ public class FileUtilities /// Modified by Joseph Finney public static string GetImageFilter() { - string imageExtensions = string.Empty; - string separator = ""; - ImageCodecInfo[] codecs = ImageCodecInfo.GetImageEncoders(); - Dictionary imageFilters = []; - foreach (ImageCodecInfo codec in codecs) + string imageExtensions = GetImageExtensionsFilterPattern(); + return string.IsNullOrEmpty(imageExtensions) ? string.Empty : $"Image files|{imageExtensions}"; + } + + public static string GetVisualDocumentFilter() + { + string pdfExtensions = GetExtensionsFilterPattern(IoUtilities.PdfExtensions); + string combinedExtensions = GetVisualDocumentFilterPattern(); + string imageFilter = GetImageFilter(); + + return string.Join("|", new[] { - if (codec.FilenameExtension is not string extension) - continue; + $"Image and PDF files|{combinedExtensions}", + $"PDF files|{pdfExtensions}", + imageFilter + }); + } - imageExtensions = $"{imageExtensions}{separator}{extension.ToLower()}"; - separator = ";"; - imageFilters.Add($"{codec.FormatDescription} files ({extension.ToLower()})", extension.ToLower()); - } - string result = string.Empty; - separator = ""; + public static string GetOpenDocumentFilter() + { + string spreadsheetExtensions = GetExtensionsFilterPattern(IoUtilities.SpreadsheetExtensions); + string markdownExtensions = GetExtensionsFilterPattern(IoUtilities.MarkdownExtensions); + string supportedExtensions = string.Join(";", new[] + { + GetVisualDocumentFilterPattern(), + spreadsheetExtensions, + markdownExtensions, + "*.txt" + }.Where(pattern => !string.IsNullOrWhiteSpace(pattern))); - if (!string.IsNullOrEmpty(imageExtensions)) + return string.Join("|", new[] { - result += $"{separator}Image files|{imageExtensions}"; - } - return result; + $"Supported documents|{supportedExtensions}", + GetVisualDocumentFilter(), + $"Spreadsheet documents|{spreadsheetExtensions}", + $"Markdown documents|{markdownExtensions}", + "Text documents (*.txt)|*.txt", + "All files (*.*)|*.*" + }); } public static string GetPathToLocalFile(string imageRelativePath) @@ -99,6 +118,40 @@ public static Task SaveTextFile(string textContent, string filename, FileS return SaveTextFileUnpackaged(textContent, filename, storageKind); } + private static string GetImageExtensionsFilterPattern() + { + string imageExtensions = string.Empty; + string separator = string.Empty; + ImageCodecInfo[] codecs = ImageCodecInfo.GetImageEncoders(); + Dictionary imageFilters = []; + + foreach (ImageCodecInfo codec in codecs) + { + if (codec.FilenameExtension is not string extension) + continue; + + imageExtensions = $"{imageExtensions}{separator}{extension.ToLower()}"; + separator = ";"; + imageFilters.Add($"{codec.FormatDescription} files ({extension.ToLower()})", extension.ToLower()); + } + + return imageExtensions; + } + + private static string GetExtensionsFilterPattern(IEnumerable extensions) + { + return string.Join(";", extensions.Select(extension => $"*{extension}")); + } + + private static string GetVisualDocumentFilterPattern() + { + return string.Join(";", new[] + { + GetImageExtensionsFilterPattern(), + GetExtensionsFilterPattern(IoUtilities.PdfExtensions) + }.Where(pattern => !string.IsNullOrWhiteSpace(pattern))); + } + private static async Task GetImageFilePackaged(string fileName, FileStorageKind storageKind) { StorageFolder folder = await GetStorageFolderPackaged(fileName, storageKind); @@ -137,7 +190,7 @@ private static async Task GetTextFilePackaged(string fileName, FileStora StorageFile file = await folder.GetFileAsync(fileName); using Stream stream = await file.OpenStreamForReadAsync(); StreamReader streamReader = new(stream); - return streamReader.ReadToEnd(); + return await streamReader.ReadToEndAsync(); } catch { diff --git a/Text-Grab/Utilities/GrabFrameViewScaleUtilities.cs b/Text-Grab/Utilities/GrabFrameViewScaleUtilities.cs new file mode 100644 index 00000000..67b7f48c --- /dev/null +++ b/Text-Grab/Utilities/GrabFrameViewScaleUtilities.cs @@ -0,0 +1,66 @@ +using System; +using System.Windows; +using Text_Grab; + +namespace Text_Grab.Utilities; + +public static class GrabFrameViewScaleUtilities +{ + public const double MaximumLoadedDocumentScale = 5.0; + public const double MinimumLoadedDocumentScale = 0.5; + public const double MinimumLoadedDocumentWindowHeight = 450; + public const double MinimumLoadedDocumentWindowWidth = 800; + public const double ScaleStep = 0.25; + + public static double CoerceScale(double scale) + { + if (!double.IsFinite(scale)) + return 1.0; + + return Math.Clamp(scale, MinimumLoadedDocumentScale, MaximumLoadedDocumentScale); + } + + public static Rect GetMinimumWindowRect(Rect currentWindowRect, Size minimumWindowSize, Rect workArea) + { + if (!currentWindowRect.IsGood()) + return currentWindowRect; + + double targetWidth = Math.Max(currentWindowRect.Width, minimumWindowSize.Width); + double targetHeight = Math.Max(currentWindowRect.Height, minimumWindowSize.Height); + + double centerX = currentWindowRect.Left + (currentWindowRect.Width / 2.0); + double centerY = currentWindowRect.Top + (currentWindowRect.Height / 2.0); + + Rect desiredRect = new( + centerX - (targetWidth / 2.0), + centerY - (targetHeight / 2.0), + targetWidth, + targetHeight); + + if (!workArea.IsGood()) + return desiredRect; + + double width = Math.Min(desiredRect.Width, workArea.Width); + double height = Math.Min(desiredRect.Height, workArea.Height); + double left = Math.Clamp(desiredRect.Left, workArea.Left, workArea.Right - width); + double top = Math.Clamp(desiredRect.Top, workArea.Top, workArea.Bottom - height); + + return new Rect(left, top, width, height); + } + + public static double StepScale(double currentScale, int direction) + { + double coercedScale = CoerceScale(currentScale); + int normalizedDirection = direction switch + { + < 0 => -1, + > 0 => 1, + _ => 0 + }; + + if (normalizedDirection == 0) + return coercedScale; + + return CoerceScale(coercedScale + (normalizedDirection * ScaleStep)); + } +} diff --git a/Text-Grab/Utilities/ImplementAppOptions.cs b/Text-Grab/Utilities/ImplementAppOptions.cs index 50ec062e..125255b7 100644 --- a/Text-Grab/Utilities/ImplementAppOptions.cs +++ b/Text-Grab/Utilities/ImplementAppOptions.cs @@ -8,7 +8,11 @@ namespace Text_Grab.Utilities; internal class ImplementAppOptions { - private static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif", ".webp", ".ico"]; + private static readonly string[] SupportedOpenWithExtensions = + [ + .. IoUtilities.ImageExtensions, + .. IoUtilities.PdfExtensions + ]; public static async Task ImplementStartupOption(bool startupOnLogin) { @@ -60,8 +64,8 @@ public static void RegisterAsImageOpenWithApp() iconKey?.SetValue("", $"\"{executablePath}\",0"); } - // Register Text Grab in OpenWithProgids for each image extension - foreach (string ext in ImageExtensions) + // Register Text Grab in OpenWithProgids for each supported visual document extension + foreach (string ext in SupportedOpenWithExtensions) { string extKey = $@"SOFTWARE\Classes\{ext}\OpenWithProgids"; using RegistryKey? key = Registry.CurrentUser.CreateSubKey(extKey); @@ -80,7 +84,7 @@ public static void RegisterAsImageOpenWithApp() using RegistryKey? supportedTypes = key.CreateSubKey("SupportedTypes"); if (supportedTypes is not null) { - foreach (string ext in ImageExtensions) + foreach (string ext in SupportedOpenWithExtensions) supportedTypes.SetValue(ext, ""); } @@ -108,7 +112,7 @@ public static void UnregisterAsImageOpenWithApp() Registry.CurrentUser.DeleteSubKeyTree(@"SOFTWARE\Classes\Text-Grab.Image", false); // Remove OpenWithProgids entries for each extension - foreach (string ext in ImageExtensions) + foreach (string ext in SupportedOpenWithExtensions) { string extKey = $@"SOFTWARE\Classes\{ext}\OpenWithProgids"; using RegistryKey? key = Registry.CurrentUser.OpenSubKey(extKey, true); diff --git a/Text-Grab/Utilities/IoUtilities.cs b/Text-Grab/Utilities/IoUtilities.cs index 748eb472..698bf16b 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab/Utilities/IoUtilities.cs @@ -4,12 +4,16 @@ using System.Text; using System.Threading.Tasks; using Text_Grab.Interfaces; +using Text_Grab.Models; namespace Text_Grab.Utilities; public class IoUtilities { public static readonly List ImageExtensions = [".png", ".bmp", ".jpg", ".jpeg", ".tiff", ".gif", ".tif", ".webp", ".ico"]; + public static readonly List PdfExtensions = [".pdf"]; + public static readonly List MarkdownExtensions = [".md", ".markdown"]; + public static readonly List SpreadsheetExtensions = [".csv", ".tsv", ".tab"]; public static bool IsImageFile(string path) { @@ -27,15 +31,86 @@ public static bool IsImageFileExtension(string extension) return ImageExtensions.Contains(extension.ToLowerInvariant()); } + public static bool IsPdfFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return false; + + return IsPdfFileExtension(Path.GetExtension(path)); + } + + public static bool IsPdfFileExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return false; + + return PdfExtensions.Contains(extension.ToLowerInvariant()); + } + + public static bool IsVisualDocumentFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return false; + + return IsVisualDocumentFileExtension(Path.GetExtension(path)); + } + + public static bool IsVisualDocumentFileExtension(string extension) + { + return IsImageFileExtension(extension) || IsPdfFileExtension(extension); + } + + public static bool IsMarkdownFileExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return false; + + return MarkdownExtensions.Contains(extension.ToLowerInvariant()); + } + + public static bool IsSpreadsheetFileExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return false; + + return SpreadsheetExtensions.Contains(extension.ToLowerInvariant()); + } + + public static EtwEditorMode GetEditorModeForPath(string? path) + { + string extension = Path.GetExtension(path ?? string.Empty); + + if (IsSpreadsheetFileExtension(extension)) + return EtwEditorMode.Spreadsheet; + + if (IsMarkdownFileExtension(extension)) + return EtwEditorMode.Markdown; + + return EtwEditorMode.Text; + } + + public static OpenContentKind GetOpenContentKindForPath(string? path) + { + string extension = Path.GetExtension(path ?? string.Empty); + + if (IsPdfFileExtension(extension)) + return OpenContentKind.PdfDocument; + + if (IsImageFileExtension(extension)) + return OpenContentKind.Image; + + 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 = OpenContentKind.Image; + OpenContentKind openContentKind = GetOpenContentKindForPath(pathOfFileToOpen); if (isMultipleFiles) stringBuilder.AppendLine(pathOfFileToOpen); - if (ImageExtensions.Contains(Path.GetExtension(pathOfFileToOpen).ToLower())) + if (openContentKind is OpenContentKind.Image or OpenContentKind.PdfDocument) { try { diff --git a/Text-Grab/Utilities/MarkdownDocumentUtilities.cs b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs new file mode 100644 index 00000000..0097bc59 --- /dev/null +++ b/Text-Grab/Utilities/MarkdownDocumentUtilities.cs @@ -0,0 +1,839 @@ +using Markdig; +using Markdig.Extensions.TaskLists; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using System; +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; +using MarkdigBlock = Markdig.Syntax.Block; +using MarkdigInline = Markdig.Syntax.Inlines.Inline; +using MarkdigTable = Markdig.Extensions.Tables.Table; +using MarkdigTableCell = Markdig.Extensions.Tables.TableCell; +using MarkdigTableRow = Markdig.Extensions.Tables.TableRow; +using WpfBlock = System.Windows.Documents.Block; +using WpfInline = System.Windows.Documents.Inline; +using WpfList = System.Windows.Documents.List; +using WpfTable = System.Windows.Documents.Table; +using WpfTableCell = System.Windows.Documents.TableCell; +using WpfTableRow = System.Windows.Documents.TableRow; + +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(); + + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + private enum MarkdownBlockRole + { + None, + CodeBlock, + ThematicBreak + } + + private enum MarkdownInlineRole + { + None, + CodeSpan, + LiteralMarkdown, + TaskListMarker + } + + private sealed record MarkdownTheme( + Brush ForegroundBrush, + Brush BorderBrush, + Brush AccentBrush, + Brush QuoteBrush, + Brush TableHeaderBrush, + Brush CodeBackgroundBrush, + FontFamily BaseFontFamily, + FontFamily CodeFontFamily, + double BaseFontSize); + + public static FlowDocument CreateFlowDocument(string? markdownText, FontFamily fontFamily, double fontSize) + { + string safeMarkdown = markdownText ?? string.Empty; + FlowDocument document = new() + { + FontFamily = fontFamily, + FontSize = fontSize, + PagePadding = new Thickness(0) + }; + + MarkdownDocument markdownDocument = Markdown.Parse(safeMarkdown, MarkdownPipeline); + foreach (MarkdigBlock block in markdownDocument) + AppendBlock(document.Blocks, block, safeMarkdown, quoteDepth: 0); + + if (document.Blocks.Count == 0) + document.Blocks.Add(new Paragraph()); + + return document; + } + + public static string SerializeToMarkdown(FlowDocument document, bool preserveLiteralMarkdown = false) + { + ArgumentNullException.ThrowIfNull(document); + + StringBuilder builder = new(); + bool wroteBlock = false; + foreach (WpfBlock block in document.Blocks) + { + if (wroteBlock) + builder.Append($"{Environment.NewLine}{Environment.NewLine}"); + + WriteBlock(builder, block, listDepth: 0, preserveLiteralMarkdown); + wroteBlock = true; + } + + return builder.ToString().TrimEnd('\r', '\n'); + } + + public static string GetDocumentPlainText(FlowDocument document) + { + ArgumentNullException.ThrowIfNull(document); + return NormalizeDocumentText(new TextRange(document.ContentStart, document.ContentEnd).Text); + } + + public static bool ShouldPromoteLiveBlock(string? lineTextBeforeSpace) + { + if (string.IsNullOrWhiteSpace(lineTextBeforeSpace)) + return false; + + return LiveBlockTriggerRegex.IsMatch(lineTextBeforeSpace); + } + + public static bool LooksLikeMarkdown(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + return MarkdownPatternRegex.IsMatch(text); + } + + public static bool ShouldPromoteLiveMarkdown(string? paragraphText) + { + if (string.IsNullOrWhiteSpace(paragraphText)) + return false; + + return LiveInlinePromotionRegex.IsMatch(NormalizeDocumentText(paragraphText)); + } + + public static void ApplyTheme(FlowDocument document, FrameworkElement resourceHost, bool isLightTheme) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(resourceHost); + + MarkdownTheme theme = CreateTheme(resourceHost, isLightTheme, document.FontFamily, document.FontSize); + document.Foreground = theme.ForegroundBrush; + document.FontFamily = theme.BaseFontFamily; + document.FontSize = theme.BaseFontSize; + document.PagePadding = new Thickness(0); + + foreach (WpfBlock block in document.Blocks) + ApplyBlockTheme(block, theme); + } + + private static void AppendBlock(BlockCollection blocks, MarkdigBlock block, string source, int quoteDepth) + { + switch (block) + { + case HeadingBlock headingBlock: + Paragraph headingParagraph = new() + { + Margin = new Thickness(0, 10, 0, 4), + FontWeight = FontWeights.Bold + }; + SetHeadingLevel(headingParagraph, Math.Clamp(headingBlock.Level, 1, 6)); + SetQuoteDepth(headingParagraph, quoteDepth); + AppendInlineContainer(headingParagraph.Inlines, headingBlock.Inline, source); + blocks.Add(headingParagraph); + break; + + case ParagraphBlock paragraphBlock: + Paragraph paragraph = new() + { + Margin = new Thickness(0, 4, 0, 4) + }; + SetQuoteDepth(paragraph, quoteDepth); + AppendInlineContainer(paragraph.Inlines, paragraphBlock.Inline, source); + blocks.Add(paragraph); + break; + + case QuoteBlock quoteBlock: + foreach (MarkdigBlock child in quoteBlock) + AppendBlock(blocks, child, source, quoteDepth + 1); + break; + + case ListBlock listBlock: + WpfList list = new() + { + MarkerStyle = listBlock.IsOrdered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc, + Margin = new Thickness(0, 4, 0, 4) + }; + SetQuoteDepth(list, quoteDepth); + + foreach (ListItemBlock itemBlock in listBlock.OfType()) + { + ListItem listItem = new(); + foreach (MarkdigBlock child in itemBlock) + AppendBlock(listItem.Blocks, child, source, quoteDepth: 0); + + if (listItem.Blocks.Count == 0) + listItem.Blocks.Add(new Paragraph()); + + list.ListItems.Add(listItem); + } + + blocks.Add(list); + break; + + case FencedCodeBlock fencedCodeBlock: + blocks.Add(CreateCodeParagraph(GetCodeBlockText(fencedCodeBlock), fencedCodeBlock.Info, quoteDepth)); + break; + + case CodeBlock codeBlock: + blocks.Add(CreateCodeParagraph(GetCodeBlockText(codeBlock), info: null, quoteDepth)); + break; + + case ThematicBreakBlock: + Paragraph breakParagraph = new() + { + Margin = new Thickness(0, 8, 0, 8) + }; + SetBlockRole(breakParagraph, MarkdownBlockRole.ThematicBreak); + SetQuoteDepth(breakParagraph, quoteDepth); + breakParagraph.Inlines.Add(new Run("----------")); + blocks.Add(breakParagraph); + break; + + case MarkdigTable table: + blocks.Add(CreateTable(table, source, quoteDepth)); + break; + + default: + blocks.Add(CreateLiteralParagraph(GetSourceSlice(source, block), quoteDepth)); + break; + } + } + + private static Paragraph CreateCodeParagraph(string codeText, string? info, int quoteDepth) + { + Paragraph paragraph = new() + { + Margin = new Thickness(0, 6, 0, 6) + }; + + SetBlockRole(paragraph, MarkdownBlockRole.CodeBlock); + SetQuoteDepth(paragraph, quoteDepth); + SetCodeFenceInfo(paragraph, info?.ToString() ?? string.Empty); + paragraph.Inlines.Add(new Run(codeText)); + return paragraph; + } + + private static Paragraph CreateLiteralParagraph(string literalMarkdown, int quoteDepth) + { + Paragraph paragraph = new() + { + Margin = new Thickness(0, 4, 0, 4) + }; + + SetQuoteDepth(paragraph, quoteDepth); + Run literalRun = new(literalMarkdown); + SetInlineRole(literalRun, MarkdownInlineRole.LiteralMarkdown); + paragraph.Inlines.Add(literalRun); + return paragraph; + } + + private static WpfTable CreateTable(MarkdigTable table, string source, int quoteDepth) + { + WpfTable flowTable = new() + { + CellSpacing = 0, + Margin = new Thickness(0, 6, 0, 6) + }; + + SetQuoteDepth(flowTable, quoteDepth); + + int maxColumnCount = table.OfType().Select(row => row.Count).DefaultIfEmpty(0).Max(); + for (int columnIndex = 0; columnIndex < maxColumnCount; columnIndex++) + flowTable.Columns.Add(new TableColumn()); + + TableRowGroup rowGroup = new(); + flowTable.RowGroups.Add(rowGroup); + + foreach (MarkdigTableRow row in table.OfType()) + { + WpfTableRow flowRow = new(); + rowGroup.Rows.Add(flowRow); + + foreach (MarkdigTableCell cell in row.OfType()) + { + WpfTableCell flowCell = new() + { + Padding = new Thickness(6, 4, 6, 4) + }; + SetIsTableHeader(flowCell, row.IsHeader); + + foreach (MarkdigBlock child in cell) + AppendBlock(flowCell.Blocks, child, source, quoteDepth: 0); + + if (flowCell.Blocks.Count == 0) + flowCell.Blocks.Add(new Paragraph()); + + flowRow.Cells.Add(flowCell); + } + } + + return flowTable; + } + + private static void AppendInlineContainer(InlineCollection inlines, ContainerInline? container, string source) + { + if (container is null) + return; + + for (MarkdigInline? inline = container.FirstChild; inline is not null; inline = inline.NextSibling) + AppendInline(inlines, inline, source); + } + + private static void AppendInline(InlineCollection inlines, MarkdigInline inline, string source) + { + switch (inline) + { + case LiteralInline literalInline: + inlines.Add(new Run(literalInline.Content.ToString())); + break; + + case LineBreakInline: + inlines.Add(new LineBreak()); + break; + + case CodeInline codeInline: + Run codeRun = new(codeInline.Content) + { + FontFamily = new FontFamily("Consolas") + }; + SetInlineRole(codeRun, MarkdownInlineRole.CodeSpan); + inlines.Add(codeRun); + break; + + case TaskList taskList: + Run taskListRun = new(taskList.Checked ? "\u2611" : "\u2610"); + SetInlineRole(taskListRun, MarkdownInlineRole.TaskListMarker); + SetTaskListMarkerChecked(taskListRun, taskList.Checked); + inlines.Add(taskListRun); + break; + + case EmphasisInline emphasisInline: + Span emphasisSpan = emphasisInline.DelimiterCount >= 2 + ? new Bold() + : new Italic(); + + AppendInlineContainer(emphasisSpan.Inlines, emphasisInline, source); + if (emphasisInline.DelimiterCount >= 3) + inlines.Add(new Italic(emphasisSpan)); + else + inlines.Add(emphasisSpan); + break; + + case LinkInline linkInline when !linkInline.IsImage: + Hyperlink hyperlink = new(); + string? linkUrl = linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() : linkInline.Url; + if (!string.IsNullOrWhiteSpace(linkUrl) && + Uri.TryCreate(linkUrl, UriKind.RelativeOrAbsolute, out Uri? navigateUri)) + { + hyperlink.NavigateUri = navigateUri; + } + + AppendInlineContainer(hyperlink.Inlines, linkInline, source); + if (hyperlink.Inlines.FirstInline is null) + hyperlink.Inlines.Add(new Run(linkInline.Url ?? string.Empty)); + + inlines.Add(hyperlink); + break; + + case LinkInline linkInline: + Run literalImageRun = new(GetSourceSlice(source, linkInline)); + SetInlineRole(literalImageRun, MarkdownInlineRole.LiteralMarkdown); + inlines.Add(literalImageRun); + break; + + case HtmlInline htmlInline: + Run htmlRun = new(htmlInline.Tag); + SetInlineRole(htmlRun, MarkdownInlineRole.LiteralMarkdown); + inlines.Add(htmlRun); + break; + + case ContainerInline containerInline: + Span containerSpan = new(); + AppendInlineContainer(containerSpan.Inlines, containerInline, source); + inlines.Add(containerSpan); + break; + + default: + Run literalRun = new(GetSourceSlice(source, inline)); + SetInlineRole(literalRun, MarkdownInlineRole.LiteralMarkdown); + inlines.Add(literalRun); + break; + } + } + + private static void WriteBlock(StringBuilder builder, WpfBlock block, int listDepth, bool preserveLiteralMarkdown) + { + switch (block) + { + case Paragraph paragraph: + WriteParagraph(builder, paragraph, preserveLiteralMarkdown); + break; + + case WpfList list: + WriteList(builder, list, listDepth, preserveLiteralMarkdown); + break; + + case WpfTable table: + WriteTable(builder, table); + break; + + default: + builder.Append(SerializeLiteralText(block, preserveLiteralMarkdown)); + break; + } + } + + private static void WriteParagraph(StringBuilder builder, Paragraph paragraph, bool preserveLiteralMarkdown) + { + string quotePrefix = GetQuotePrefix(GetQuoteDepth(paragraph)); + + if (GetBlockRole(paragraph) == MarkdownBlockRole.ThematicBreak) + { + builder.Append(ApplyQuotePrefix("---", quotePrefix)); + return; + } + + if (GetBlockRole(paragraph) == MarkdownBlockRole.CodeBlock) + { + string codeInfo = GetCodeFenceInfo(paragraph); + string codeText = 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)); + return; + } + + string content = SerializeInlines(paragraph.Inlines, preserveLiteralMarkdown); + int headingLevel = GetHeadingLevel(paragraph); + if (headingLevel > 0) + content = $"{new string('#', headingLevel)} {content}"; + + builder.Append(ApplyQuotePrefix(content, quotePrefix)); + } + + private static void WriteList(StringBuilder builder, WpfList list, int listDepth, bool preserveLiteralMarkdown) + { + string quotePrefix = GetQuotePrefix(GetQuoteDepth(list)); + bool isOrdered = list.MarkerStyle == TextMarkerStyle.Decimal; + int itemIndex = 1; + + foreach (ListItem item in list.ListItems) + { + if (itemIndex > 1) + builder.AppendLine(); + + StringBuilder itemBuilder = new(); + bool wroteItemBlock = false; + foreach (WpfBlock block in item.Blocks) + { + if (wroteItemBlock) + itemBuilder.Append($"{Environment.NewLine}{Environment.NewLine}"); + + WriteBlock(itemBuilder, block, listDepth + 1, preserveLiteralMarkdown); + wroteItemBlock = true; + } + + string[] itemLines = NormalizeNewlines(itemBuilder.ToString()).Split('\n'); + string indent = new(' ', listDepth * 2); + string marker = isOrdered ? $"{itemIndex}. " : "- "; + + builder.Append(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)); + } + + itemIndex++; + } + } + + private static void WriteTable(StringBuilder builder, WpfTable table) + { + string quotePrefix = GetQuotePrefix(GetQuoteDepth(table)); + TableRowGroup? firstGroup = table.RowGroups.FirstOrDefault(); + if (firstGroup is null || firstGroup.Rows.Count == 0) + return; + + List rows = [.. firstGroup.Rows.Cast()]; + List headerCells = [.. rows[0].Cells.Cast().Select(SerializeTableCell)]; + + builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", headerCells)} |", quotePrefix)); + builder.AppendLine(); + builder.Append(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) + : rows; + + foreach (WpfTableRow row in dataRows) + { + builder.AppendLine(); + List rowCells = [.. row.Cells.Cast().Select(SerializeTableCell)]; + builder.Append(ApplyQuotePrefix($"| {string.Join(" | ", rowCells)} |", quotePrefix)); + } + } + + private static string SerializeTableCell(WpfTableCell cell) + { + string rawText = NormalizeDocumentText(new TextRange(cell.ContentStart, cell.ContentEnd).Text); + return rawText + .Replace("|", "\\|", StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal); + } + + private static string SerializeInlines(InlineCollection inlines, bool preserveLiteralMarkdown) + { + StringBuilder builder = new(); + foreach (WpfInline inline in inlines) + WriteInline(builder, inline, preserveLiteralMarkdown); + + return builder.ToString(); + } + + private static void WriteInline(StringBuilder builder, WpfInline inline, bool preserveLiteralMarkdown) + { + switch (inline) + { + case LineBreak: + builder.Append($" {Environment.NewLine}"); + break; + + case Run run: + builder.Append(GetInlineRole(run) switch + { + MarkdownInlineRole.TaskListMarker => GetTaskListMarkerChecked(run) ? "[x]" : "[ ]", + MarkdownInlineRole.CodeSpan => $"`{NormalizeDocumentText(run.Text)}`", + MarkdownInlineRole.LiteralMarkdown => run.Text, + _ when preserveLiteralMarkdown => run.Text, + _ => EscapeMarkdownText(run.Text) + }); + break; + + case Hyperlink hyperlink: + string linkText = SerializeInlines(hyperlink.Inlines, preserveLiteralMarkdown); + string linkTarget = hyperlink.NavigateUri?.OriginalString ?? linkText; + builder.Append($"[{linkText}]({EscapeLinkDestination(linkTarget)})"); + break; + + case Bold bold: + builder.Append("**"); + builder.Append(SerializeInlines(bold.Inlines, preserveLiteralMarkdown)); + builder.Append("**"); + break; + + case Italic italic: + builder.Append('*'); + builder.Append(SerializeInlines(italic.Inlines, preserveLiteralMarkdown)); + builder.Append('*'); + break; + + case Span span when GetInlineRole(span) == MarkdownInlineRole.CodeSpan: + builder.Append('`'); + builder.Append(NormalizeDocumentText(new TextRange(span.ContentStart, span.ContentEnd).Text)); + builder.Append('`'); + break; + + case Span span: + builder.Append(SerializeInlines(span.Inlines, preserveLiteralMarkdown)); + break; + + default: + builder.Append(SerializeLiteralText(inline, preserveLiteralMarkdown)); + break; + } + } + + private static void ApplyBlockTheme(WpfBlock block, MarkdownTheme theme) + { + switch (block) + { + case Paragraph paragraph: + paragraph.Foreground = theme.ForegroundBrush; + paragraph.BorderThickness = new Thickness(0); + paragraph.Padding = new Thickness(0); + + int headingLevel = GetHeadingLevel(paragraph); + if (headingLevel > 0) + { + paragraph.FontWeight = FontWeights.SemiBold; + paragraph.FontSize = theme.BaseFontSize + Math.Max(2, 14 - (headingLevel * 2)); + } + else if (GetBlockRole(paragraph) == MarkdownBlockRole.CodeBlock) + { + paragraph.FontFamily = theme.CodeFontFamily; + paragraph.Background = theme.CodeBackgroundBrush; + paragraph.Padding = new Thickness(8, 6, 8, 6); + paragraph.BorderBrush = theme.BorderBrush; + paragraph.BorderThickness = new Thickness(1); + } + else + { + paragraph.FontFamily = theme.BaseFontFamily; + paragraph.FontSize = theme.BaseFontSize; + paragraph.Background = Brushes.Transparent; + } + + int quoteDepth = GetQuoteDepth(paragraph); + paragraph.Margin = quoteDepth > 0 + ? new Thickness(18 * quoteDepth, 4, 0, 4) + : paragraph.Margin; + + if (quoteDepth > 0 && GetBlockRole(paragraph) != MarkdownBlockRole.CodeBlock) + paragraph.Foreground = theme.QuoteBrush; + + foreach (WpfInline inline in paragraph.Inlines) + ApplyInlineTheme(inline, theme); + + break; + + case WpfList list: + list.Foreground = theme.ForegroundBrush; + list.Margin = GetQuoteDepth(list) > 0 + ? new Thickness(18 * GetQuoteDepth(list), 4, 0, 4) + : list.Margin; + + foreach (ListItem item in list.ListItems) + { + foreach (WpfBlock child in item.Blocks) + ApplyBlockTheme(child, theme); + } + + break; + + case WpfTable table: + table.Foreground = theme.ForegroundBrush; + table.Margin = GetQuoteDepth(table) > 0 + ? new Thickness(18 * GetQuoteDepth(table), 6, 0, 6) + : table.Margin; + + foreach (TableRowGroup rowGroup in table.RowGroups) + { + foreach (WpfTableRow row in rowGroup.Rows.Cast()) + { + foreach (WpfTableCell cell in row.Cells.Cast()) + { + cell.BorderBrush = theme.BorderBrush; + cell.BorderThickness = new Thickness(0.5); + cell.Background = GetIsTableHeader(cell) ? theme.TableHeaderBrush : Brushes.Transparent; + + foreach (WpfBlock child in cell.Blocks) + ApplyBlockTheme(child, theme); + } + } + } + + break; + } + } + + private static void ApplyInlineTheme(WpfInline inline, MarkdownTheme theme) + { + switch (inline) + { + case Hyperlink hyperlink: + hyperlink.Foreground = theme.AccentBrush; + hyperlink.TextDecorations = TextDecorations.Underline; + foreach (WpfInline child in hyperlink.Inlines) + ApplyInlineTheme(child, theme); + break; + + case Run run when GetInlineRole(run) == MarkdownInlineRole.CodeSpan: + run.FontFamily = theme.CodeFontFamily; + run.Background = theme.CodeBackgroundBrush; + break; + + case Span span: + foreach (WpfInline child in span.Inlines) + ApplyInlineTheme(child, theme); + break; + } + } + + private static MarkdownTheme CreateTheme(FrameworkElement resourceHost, bool isLightTheme, FontFamily baseFontFamily, double baseFontSize) + { + Brush foreground = FindBrush(resourceHost, "TextFillColorPrimaryBrush", Colors.Black); + Brush border = FindBrush(resourceHost, "ControlStrokeColorDefaultBrush", Color.FromRgb(120, 120, 120)); + Brush accent = FindBrush(resourceHost, "Teal", Color.FromRgb(48, 142, 152)); + Brush quote = FindBrush(resourceHost, "TextFillColorSecondaryBrush", isLightTheme ? Color.FromRgb(70, 70, 70) : Color.FromRgb(190, 190, 190)); + Brush tableHeader = new SolidColorBrush(isLightTheme ? Color.FromRgb(244, 246, 248) : Color.FromRgb(43, 43, 46)); + Brush codeBackground = new SolidColorBrush(isLightTheme ? Color.FromRgb(245, 245, 245) : Color.FromRgb(32, 32, 36)); + + return new MarkdownTheme( + foreground, + border, + accent, + quote, + tableHeader, + codeBackground, + baseFontFamily, + new FontFamily("Consolas"), + baseFontSize); + } + + private static Brush FindBrush(FrameworkElement resourceHost, string resourceKey, Color fallback) + { + return resourceHost.TryFindResource(resourceKey) switch + { + Brush brush => brush, + Color color => new SolidColorBrush(color), + _ => new SolidColorBrush(fallback) + }; + } + + 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); + } + + private static readonly DependencyProperty QuoteDepthProperty = + DependencyProperty.RegisterAttached("QuoteDepth", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(0)); + + private static readonly DependencyProperty HeadingLevelProperty = + DependencyProperty.RegisterAttached("HeadingLevel", typeof(int), typeof(MarkdownDocumentUtilities), new PropertyMetadata(0)); + + private static readonly DependencyProperty BlockRoleProperty = + DependencyProperty.RegisterAttached("BlockRole", typeof(MarkdownBlockRole), typeof(MarkdownDocumentUtilities), new PropertyMetadata(MarkdownBlockRole.None)); + + private static readonly DependencyProperty InlineRoleProperty = + DependencyProperty.RegisterAttached("InlineRole", typeof(MarkdownInlineRole), typeof(MarkdownDocumentUtilities), new PropertyMetadata(MarkdownInlineRole.None)); + + private static readonly DependencyProperty TaskListMarkerCheckedProperty = + DependencyProperty.RegisterAttached("TaskListMarkerChecked", typeof(bool), typeof(MarkdownDocumentUtilities), new PropertyMetadata(false)); + + private static readonly DependencyProperty CodeFenceInfoProperty = + DependencyProperty.RegisterAttached("CodeFenceInfo", typeof(string), typeof(MarkdownDocumentUtilities), new PropertyMetadata(string.Empty)); + + private static readonly DependencyProperty IsTableHeaderProperty = + DependencyProperty.RegisterAttached("IsTableHeader", typeof(bool), typeof(MarkdownDocumentUtilities), new PropertyMetadata(false)); + + private static void SetQuoteDepth(DependencyObject element, int value) => element.SetValue(QuoteDepthProperty, value); + private static int GetQuoteDepth(DependencyObject element) => (int)element.GetValue(QuoteDepthProperty); + private static void SetHeadingLevel(DependencyObject element, int value) => element.SetValue(HeadingLevelProperty, value); + private static int GetHeadingLevel(DependencyObject element) => (int)element.GetValue(HeadingLevelProperty); + private static void SetBlockRole(DependencyObject element, MarkdownBlockRole value) => element.SetValue(BlockRoleProperty, value); + private static MarkdownBlockRole GetBlockRole(DependencyObject element) => (MarkdownBlockRole)element.GetValue(BlockRoleProperty); + private static void SetInlineRole(DependencyObject element, MarkdownInlineRole value) => element.SetValue(InlineRoleProperty, value); + private static MarkdownInlineRole GetInlineRole(DependencyObject element) => (MarkdownInlineRole)element.GetValue(InlineRoleProperty); + private static void SetTaskListMarkerChecked(DependencyObject element, bool value) => element.SetValue(TaskListMarkerCheckedProperty, value); + private static bool GetTaskListMarkerChecked(DependencyObject element) => (bool)element.GetValue(TaskListMarkerCheckedProperty); + private static void SetCodeFenceInfo(DependencyObject element, string value) => element.SetValue(CodeFenceInfoProperty, value); + private static string GetCodeFenceInfo(DependencyObject element) => (string)element.GetValue(CodeFenceInfoProperty); + private static void SetIsTableHeader(DependencyObject element, bool value) => element.SetValue(IsTableHeaderProperty, value); + private static bool GetIsTableHeader(DependencyObject element) => (bool)element.GetValue(IsTableHeaderProperty); + + + [GeneratedRegex(@"^\s{0,3}(#{1,6}|>+|[-+*]|\d+[.)])$", RegexOptions.Compiled)] + private static partial Regex LiveBlockTrigger(); + + [GeneratedRegex(@"(^|\s)\[( |x|X)\](\s|$)|(\*\*|__)(?=\S).+?\4|(?+\s|[-+*]\s|\d+[.)]\s|```|~~~|---\s*$|___\s*$|\*\*\*\s*$)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|(^|\n)\|.+\|\s*$", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex MarkdownPattern(); +} diff --git a/Text-Grab/Utilities/NotifyIconUtilities.cs b/Text-Grab/Utilities/NotifyIconUtilities.cs index 889841ca..de687874 100644 --- a/Text-Grab/Utilities/NotifyIconUtilities.cs +++ b/Text-Grab/Utilities/NotifyIconUtilities.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Windows; using System.Windows.Media.Imaging; using Text_Grab.Controls; using Text_Grab.Models; @@ -24,7 +25,7 @@ public static void SetupNotifyIcon() } RegisterHotKeys(app); - app.TextGrabIcon = WindowUtilities.OpenOrActivateWindow(); + app.TextGrabIcon = CreateNotifyIconWindow(); } public static async Task ResetNotifyIcon() @@ -33,12 +34,13 @@ public static async Task ResetNotifyIcon() app.TextGrabIcon = null; UnregisterHotkeys(app); - NotifyIconWindow existingIcon = WindowUtilities.OpenOrActivateWindow(); - existingIcon.Close(); + + NotifyIconWindow? existingIcon = GetExistingNotifyIconWindow(); + existingIcon?.Close(); RegisterHotKeys(app); - app.TextGrabIcon = WindowUtilities.OpenOrActivateWindow(); + app.TextGrabIcon = CreateNotifyIconWindow(); } public static void RegisterHotKeys(App app) @@ -203,4 +205,22 @@ private static void HotKeyManager_HotKeyPressed(object? sender, HotKeyEventArgs break; } } + + private static NotifyIconWindow CreateNotifyIconWindow() + { + NotifyIconWindow? existingIcon = GetExistingNotifyIconWindow(); + + if (existingIcon is not null) + return existingIcon; + + NotifyIconWindow notifyIconWindow = new(); + notifyIconWindow.Show(); + + return notifyIconWindow; + } + + private static NotifyIconWindow? GetExistingNotifyIconWindow() + { + return Application.Current.Windows.OfType().FirstOrDefault(); + } } diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index 55864e52..7602d4a6 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -1,4 +1,5 @@ -using System; +using Microsoft.Windows.AI.Imaging; +using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Imaging; @@ -33,6 +34,7 @@ public static partial class OcrUtilities private static readonly Regex _cachedSpaceJoiningWordRegex = SpaceJoiningWordRegex(); private static bool IsUiAutomationLanguage(ILanguage language) => language is UiAutomationLang; + private static bool IsWindowsAiDescriptionLanguage(ILanguage language) => language is WindowsAiDescriptionLang; private static ILanguage GetCompatibleOcrLanguage(ILanguage language) { @@ -214,6 +216,9 @@ public static async Task GetTextFromBitmapSourceAsTableAsync(BitmapSourc language = GetCompatibleOcrLanguage(language); using Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(region); + if (IsWindowsAiDescriptionLanguage(language)) + return (await GetOcrResultFromImageAsync(bmp, language), 1.0); + if (language is WindowsAiLang) { return (await WindowsAiUtilities.GetOcrResultAsync(bmp), 1.0); @@ -235,6 +240,9 @@ public static async Task GetTextFromBitmapSourceAsTableAsync(BitmapSourc { language = GetCompatibleOcrLanguage(language); + if (IsWindowsAiDescriptionLanguage(language)) + return (await GetOcrResultFromImageAsync(bmp, language), 1.0); + if (language is WindowsAiLang) return (await WindowsAiUtilities.GetOcrResultAsync(bmp), 1.0); @@ -251,9 +259,18 @@ public static async Task GetOcrResultFromImageAsync(SoftwareBitm { language = GetCompatibleOcrLanguage(language); + if (IsWindowsAiDescriptionLanguage(language)) + return await GetWindowsAiDescriptionOcrResultAsync(scaledBitmap); + if (language is WindowsAiLang winAiLang) { - return new WinAiOcrLinesWords(await WindowsAiUtilities.GetOcrResultAsync(scaledBitmap)); + RecognizedText? recognizedText = await WindowsAiUtilities.GetOcrResultAsync(scaledBitmap); + if (recognizedText is not null) + return new WinAiOcrLinesWords(recognizedText); + + language = LanguageUtilities.GetCurrentInputLanguage().AsLanguage() is Language fallbackLanguage + ? new GlobalLang(fallbackLanguage) + : new GlobalLang("en-US"); } if (language is not GlobalLang globalLang) @@ -369,24 +386,45 @@ public static async Task> GetTextFromRandomAccessStream(IRandomA public static async Task> GetTextFromWinAiAsync(Bitmap bitmap, WindowsAiLang language) { + if (ShouldUseParagraphDetection(language.IsSpaceJoining())) + { + WinAiOcrLinesWords? ocrResult = await WindowsAiUtilities.GetOcrResultAsync(bitmap); + if (ocrResult is not null) + return [GetTextFromOcrResult(language, bitmap, ocrResult)]; + } + // get temp path string tempPath = Path.GetTempPath(); string tempFileName = Path.GetRandomFileName() + ".bmp"; string tempFilePath = Path.Combine(tempPath, tempFileName); - bitmap.Save(tempFilePath, ImageFormat.Bmp); + try + { + bitmap.Save(tempFilePath, ImageFormat.Bmp); - string result = await WindowsAiUtilities.GetTextWithWinAI(tempFilePath); + string result = await WindowsAiUtilities.GetTextWithWinAI(tempFilePath); - OcrOutput paragraphsOutput = new() + OcrOutput paragraphsOutput = new() + { + Kind = OcrOutputKind.Paragraph, + RawOutput = result, + Language = language, + SourceBitmap = bitmap, + }; + + List outputs = [paragraphsOutput]; + return outputs; + } + finally { - Kind = OcrOutputKind.Paragraph, - RawOutput = result, - Language = language, - SourceBitmap = bitmap, - }; + if (File.Exists(tempFilePath)) + File.Delete(tempFilePath); + } + } - List outputs = [paragraphsOutput]; - return outputs; + public static async Task> GetTextFromWinAiDescriptionAsync(Bitmap bitmap, WindowsAiDescriptionLang language) + { + IOcrLinesWords descriptionResult = await GetOcrResultFromImageAsync(bitmap, language); + return [GetTextFromOcrResult(language, bitmap, descriptionResult)]; } public static async Task> GetTextFromImageAsync(Bitmap bitmap, ILanguage language) @@ -410,6 +448,10 @@ public static async Task> GetTextFromImageAsync(Bitmap bitmap, I { outputs.AddRange(await GetTextFromWinAiAsync(bitmap, winAiLang)); } + else if (language is WindowsAiDescriptionLang windowsAiDescriptionLang) + { + outputs.AddRange(await GetTextFromWinAiDescriptionAsync(bitmap, windowsAiDescriptionLang)); + } else { GlobalLang ocrLanguageFromILang = language as GlobalLang ?? new GlobalLang("en-US"); @@ -427,25 +469,165 @@ public static async Task> GetTextFromImageAsync(Bitmap bitmap, I } private static OcrOutput GetTextFromOcrResult(ILanguage language, Bitmap? scaledBitmap, IOcrLinesWords ocrResult) + { + OcrOutput paragraphsOutput = new() + { + Kind = OcrOutputKind.Paragraph, + RawOutput = 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)); + + public string SingleLineText => string.Join(" ", Lines.Select(static line => line.Text).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; - foreach (IOcrLine ocrLine in ocrResult.Lines) - ocrLine.GetTextFromOcrLine(isSpaceJoiningOCRLang, text); + 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 + { + foreach (IOcrLine ocrLine in lines) + ocrLine.GetTextFromOcrLine(isSpaceJoiningOCRLang, text); + } if (language.IsRightToLeft()) text.ReverseWordsForRightToLeft(); - OcrOutput paragraphsOutput = new() + return text.ToString(); + } + + 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++) { - Kind = OcrOutputKind.Paragraph, - RawOutput = text.ToString(), - Language = language, - SourceBitmap = scaledBitmap, - }; - return paragraphsOutput; + 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; + + // 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) @@ -467,8 +649,15 @@ public static string GetStringFromOcrOutputs(List outputs) public static async Task OcrAbsoluteFilePathAsync(string absolutePath, ILanguage? language = null) { - Bitmap bmp = LoadBitmapFromFile(absolutePath); language ??= LanguageUtilities.GetCurrentInputLanguage(); + + if (IoUtilities.IsPdfFileExtension(Path.GetExtension(absolutePath))) + { + using PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(absolutePath); + return await pdfDocument.ExtractTextAsync(language); + } + + using Bitmap bmp = LoadBitmapFromFile(absolutePath); return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); } @@ -523,11 +712,21 @@ private static string GetTextFromClickedWord(Point singlePoint, IOcrLinesWords o public static async Task GetIdealScaleFactorForOcrAsync(Bitmap bitmap, ILanguage selectedLanguage) { + if (IsWindowsAiDescriptionLanguage(selectedLanguage)) + return 1.0; + selectedLanguage = GetCompatibleOcrLanguage(selectedLanguage); IOcrLinesWords ocrResult = await OcrUtilities.GetOcrResultFromImageAsync(bitmap, selectedLanguage); return GetIdealScaleFactorForOcrResult(ocrResult, bitmap.Height, bitmap.Width); } + private static async Task GetWindowsAiDescriptionOcrResultAsync(SoftwareBitmap softwareBitmap) + { + string description = await WindowsAiUtilities.GetTextDescriptionWithWinAI(softwareBitmap); + Windows.Foundation.Rect fullBounds = new(0, 0, softwareBitmap.PixelWidth, softwareBitmap.PixelHeight); + return GeneratedOcrLinesWords.FromParagraph(description, fullBounds); + } + private static double GetIdealScaleFactorForOcrResult(IOcrLinesWords ocrResult, int height, int width) { List heightsList = []; @@ -584,8 +783,16 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag string ocrText; if (options.GrabTemplate is GrabTemplate grabTemplate) { - Bitmap bmp = LoadBitmapFromFile(path); - ocrText = await GrabTemplateExecutor.ExecuteTemplateOnBitmapAsync(grabTemplate, bmp, selectedLanguage); + if (IoUtilities.IsPdfFileExtension(Path.GetExtension(path))) + { + using PdfDocumentRenderer pdfDocument = await PdfDocumentRenderer.LoadAsync(path); + ocrText = await pdfDocument.ExtractTextAsync(selectedLanguage, grabTemplate); + } + else + { + using Bitmap bmp = LoadBitmapFromFile(path); + ocrText = await GrabTemplateExecutor.ExecuteTemplateOnBitmapAsync(grabTemplate, bmp, selectedLanguage); + } } else ocrText = await OcrAbsoluteFilePathAsync(path, selectedLanguage); diff --git a/Text-Grab/Utilities/PdfDocumentRenderer.cs b/Text-Grab/Utilities/PdfDocumentRenderer.cs new file mode 100644 index 00000000..6e27cd11 --- /dev/null +++ b/Text-Grab/Utilities/PdfDocumentRenderer.cs @@ -0,0 +1,451 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Text_Grab.Interfaces; +using Text_Grab.Models; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.Core; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; +using Windows.Graphics.Imaging; +using Windows.Storage; +using Windows.Storage.Streams; +using OcrEngine = Windows.Media.Ocr.OcrEngine; +using PigPdfDocument = UglyToad.PdfPig.PdfDocument; +using PdfPageRenderOptions = Windows.Data.Pdf.PdfPageRenderOptions; +using WinPdfDocument = Windows.Data.Pdf.PdfDocument; +using WinPdfPage = Windows.Data.Pdf.PdfPage; + +namespace Text_Grab.Utilities; + +internal sealed class PdfPageContent +{ + public PdfPageContent( + int pageIndex, + BitmapSource renderedPage, + IReadOnlyList nativeLines, + IReadOnlyList imageRegions) + { + PageIndex = pageIndex; + RenderedPage = renderedPage; + NativeLines = nativeLines; + ImageRegions = imageRegions; + } + + public bool HasNativeText => NativeLines.Count > 0; + + public IReadOnlyList ImageRegions { get; } + + public IReadOnlyList NativeLines { get; } + + public int PageIndex { get; } + + public BitmapSource RenderedPage { get; } +} + +internal sealed class PdfPageTextLine +{ + public PdfPageTextLine(Windows.Foundation.Rect sourceRect, string text, bool isNativeText) + { + SourceRect = sourceRect; + Text = text; + IsNativeText = isNativeText; + } + + public bool IsNativeText { get; } + + public Windows.Foundation.Rect SourceRect { get; } + + public string Text { get; } +} + +internal sealed class PdfDocumentRenderer : IDisposable +{ + private const double DefaultRenderScale = 2.0; + private const int MaxCachedPages = 10; + private readonly WinPdfDocument renderDocument; + private readonly PigPdfDocument textDocument; + private readonly Dictionary pageCache = []; + private readonly LinkedList cacheOrder = new(); + + private PdfDocumentRenderer(string filePath, WinPdfDocument renderDocument, PigPdfDocument textDocument) + { + FilePath = filePath; + this.renderDocument = renderDocument; + this.textDocument = textDocument; + } + + public string FilePath { get; } + + public int PageCount => (int)renderDocument.PageCount; + + public void Dispose() + { + textDocument.Dispose(); + } + + public async Task ExtractTextAsync(ILanguage? language = null, GrabTemplate? grabTemplate = null) + { + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetCurrentInputLanguage(); + StringBuilder extractedText = new(); + + for (int pageIndex = 0; pageIndex < PageCount; pageIndex++) + { + string pageText; + if (grabTemplate is not null) + { + BitmapSource pageImage = await RenderPageAsync(pageIndex); + using Bitmap pageBitmap = ImageMethods.BitmapSourceToBitmap(pageImage); + pageText = await GrabTemplateExecutor.ExecuteTemplateOnBitmapAsync(grabTemplate, pageBitmap, resolvedLanguage); + } + else + { + IReadOnlyList lines = await GetSelectableLinesAsync(pageIndex, resolvedLanguage); + pageText = string.Join(Environment.NewLine, lines.Select(line => line.Text)); + } + + if (string.IsNullOrWhiteSpace(pageText)) + continue; + + if (extractedText.Length > 0) + extractedText.AppendLine().AppendLine(); + + extractedText.Append(pageText.Trim()); + } + + return extractedText.ToString(); + } + + public async Task GetPageContentAsync(int pageIndex) + { + ValidatePageIndex(pageIndex); + + if (pageCache.TryGetValue(pageIndex, out PdfPageContent? cachedPage)) + { + cacheOrder.Remove(pageIndex); + cacheOrder.AddLast(pageIndex); + return cachedPage; + } + + WinPdfPage renderPage = renderDocument.GetPage((uint)pageIndex); + try + { + BitmapImage renderedPage = await RenderPageBitmapAsync(renderPage); + Page textPage = textDocument.GetPage(pageIndex + 1); + + List nativeLines = ExtractNativeLines(textPage, renderedPage.PixelWidth, renderedPage.PixelHeight); + List imageRegions = ExtractImageRegions(textPage, renderedPage.PixelWidth, renderedPage.PixelHeight); + + PdfPageContent pageContent = new(pageIndex, renderedPage, nativeLines, imageRegions); + + if (pageCache.Count >= MaxCachedPages && cacheOrder.First is LinkedListNode oldest) + { + pageCache.Remove(oldest.Value); + cacheOrder.RemoveFirst(); + } + + pageCache[pageIndex] = pageContent; + cacheOrder.AddLast(pageIndex); + return pageContent; + } + finally + { + (renderPage as IDisposable)?.Dispose(); + } + } + + public async Task> GetSelectableLinesAsync(int pageIndex, ILanguage? language = null) + { + PdfPageContent pageContent = await GetPageContentAsync(pageIndex); + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetCurrentInputLanguage(); + + if (!pageContent.HasNativeText) + return await GetOcrLinesAsync(pageContent.RenderedPage, resolvedLanguage); + + if (pageContent.ImageRegions.Count == 0) + return pageContent.NativeLines; + + List combinedLines = [.. pageContent.NativeLines]; + IReadOnlyList nativeRects = [.. pageContent.NativeLines.Select(l => l.SourceRect)]; + IReadOnlyList imageOcrLines = await GetOcrLinesAsync( + pageContent.RenderedPage, + resolvedLanguage, + sourceRect => ShouldIncludeOcrLine(sourceRect, pageContent.ImageRegions) + && !ShouldIncludeOcrLine(sourceRect, nativeRects)); + + combinedLines.AddRange(imageOcrLines); + return SortLines(combinedLines); + } + + public async Task RenderPageAsync(int pageIndex) + { + PdfPageContent pageContent = await GetPageContentAsync(pageIndex); + return pageContent.RenderedPage; + } + + public static async Task LoadAsync(string filePath) + { + if (!IoUtilities.IsPdfFileExtension(Path.GetExtension(filePath))) + throw new InvalidOperationException("The provided path is not a PDF document."); + + string absolutePath = Path.GetFullPath(filePath); + StorageFile storageFile = await StorageFile.GetFileFromPathAsync(absolutePath); + WinPdfDocument renderDocument = await WinPdfDocument.LoadFromFileAsync(storageFile); + PigPdfDocument textDocument = PigPdfDocument.Open(absolutePath); + + return new PdfDocumentRenderer(absolutePath, renderDocument, textDocument); + } + + internal static Windows.Foundation.Rect ConvertPdfRectToImageRect( + PdfRectangle pdfRect, + double pageWidthPoints, + double pageHeightPoints, + double renderedWidth, + double renderedHeight) + { + if (pageWidthPoints <= 0 || pageHeightPoints <= 0 || renderedWidth <= 0 || renderedHeight <= 0) + return new Windows.Foundation.Rect(0, 0, 0, 0); + + PdfPoint[] points = + [ + pdfRect.TopLeft, + pdfRect.TopRight, + pdfRect.BottomLeft, + pdfRect.BottomRight + ]; + + List xs = []; + List ys = []; + + foreach (PdfPoint point in points) + { + double x = (double)point.X / pageWidthPoints * renderedWidth; + double y = (1 - ((double)point.Y / pageHeightPoints)) * renderedHeight; + xs.Add(x); + ys.Add(y); + } + + double left = xs.Min(); + double top = ys.Min(); + double right = xs.Max(); + double bottom = ys.Max(); + + return new Windows.Foundation.Rect(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top)); + } + + internal static IReadOnlyList GroupWordsIntoLines(IEnumerable<(Windows.Foundation.Rect SourceRect, string Text)> words) + { + List<(Windows.Foundation.Rect SourceRect, string Text)> orderedWords = [.. words + .Where(word => !string.IsNullOrWhiteSpace(word.Text) && word.SourceRect.Width > 0 && word.SourceRect.Height > 0) + .OrderBy(word => word.SourceRect.Y) + .ThenBy(word => word.SourceRect.X)]; + + if (orderedWords.Count == 0) + return []; + + List> groups = []; + + foreach ((Windows.Foundation.Rect SourceRect, string Text) word in orderedWords) + { + if (groups.Count == 0) + { + groups.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]); + } + + List lines = []; + foreach (List<(Windows.Foundation.Rect SourceRect, string Text)> group in groups) + { + 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())); + lines.Add(new PdfPageTextLine(lineBounds, text, isNativeText: true)); + } + + return SortLines(lines); + } + + internal static (uint Width, uint Height) GetRenderDimensions(double pageWidth, double pageHeight, double scaleFactor = DefaultRenderScale) + { + if (!double.IsFinite(pageWidth) || pageWidth <= 0 || !double.IsFinite(pageHeight) || pageHeight <= 0) + return (1, 1); + + double scaledWidth = Math.Max(1, pageWidth * scaleFactor); + double scaledHeight = Math.Max(1, pageHeight * scaleFactor); + double maxDimension = Math.Max(scaledWidth, scaledHeight); + + if (maxDimension > OcrEngine.MaxImageDimension) + { + double scaleDownRatio = OcrEngine.MaxImageDimension / maxDimension; + scaledWidth *= scaleDownRatio; + scaledHeight *= scaleDownRatio; + } + + return ((uint)Math.Max(1, Math.Round(scaledWidth)), (uint)Math.Max(1, Math.Round(scaledHeight))); + } + + internal static bool ShouldIncludeOcrLine(Windows.Foundation.Rect sourceRect, IReadOnlyList imageRegions) + { + if (sourceRect.Width <= 0 || sourceRect.Height <= 0) + return false; + + double sourceArea = sourceRect.Width * sourceRect.Height; + if (sourceArea <= 0) + return false; + + foreach (Windows.Foundation.Rect imageRegion in imageRegions) + { + double intersectionLeft = Math.Max(sourceRect.Left, imageRegion.Left); + double intersectionTop = Math.Max(sourceRect.Top, imageRegion.Top); + double intersectionRight = Math.Min(sourceRect.Right, imageRegion.Right); + double intersectionBottom = Math.Min(sourceRect.Bottom, imageRegion.Bottom); + + double intersectionWidth = Math.Max(0, intersectionRight - intersectionLeft); + double intersectionHeight = Math.Max(0, intersectionBottom - intersectionTop); + double intersectionArea = intersectionWidth * intersectionHeight; + + if (intersectionArea / sourceArea >= 0.25) + return true; + } + + return false; + } + + private static PdfPageRenderOptions CreateRenderOptions(WinPdfPage page) + { + (uint width, uint height) = GetRenderDimensions(page.Size.Width, page.Size.Height); + + return new PdfPageRenderOptions + { + BackgroundColor = new Windows.UI.Color { A = byte.MaxValue, R = byte.MaxValue, G = byte.MaxValue, B = byte.MaxValue }, + BitmapEncoderId = Windows.Graphics.Imaging.BitmapEncoder.PngEncoderId, + DestinationWidth = width, + DestinationHeight = height, + IsIgnoringHighContrast = true + }; + } + + private static List ExtractImageRegions(Page textPage, int renderedWidth, int renderedHeight) + { + return [.. textPage.GetImages() + .Select(image => ConvertPdfRectToImageRect(image.BoundingBox, (double)textPage.Width, (double)textPage.Height, renderedWidth, renderedHeight)) + .Where(rect => rect.Width > 0 && rect.Height > 0)]; + } + + private static List ExtractNativeLines(Page textPage, int renderedWidth, int renderedHeight) + { + List<(Windows.Foundation.Rect SourceRect, string Text)> words = [.. textPage + .GetWords(NearestNeighbourWordExtractor.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) + { + List rectList = [.. rects.Where(rect => rect.Width > 0 && rect.Height > 0)]; + if (rectList.Count == 0) + return new Windows.Foundation.Rect(0, 0, 0, 0); + + double left = rectList.Min(rect => rect.Left); + double top = rectList.Min(rect => rect.Top); + double right = rectList.Max(rect => rect.Right); + double bottom = rectList.Max(rect => rect.Bottom); + + return new Windows.Foundation.Rect(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top)); + } + + private async Task> GetOcrLinesAsync( + BitmapSource renderedPage, + ILanguage language, + Func? sourceRectPredicate = null) + { + using Bitmap bitmap = ImageMethods.BitmapSourceToBitmap(renderedPage); + (IOcrLinesWords? ocrResult, double scale) = await OcrUtilities.GetOcrResultFromBitmapAsync(bitmap, language); + if (ocrResult is null || ocrResult.Lines.Length == 0) + return []; + + return ConvertOcrLines(ocrResult, scale, language, sourceRectPredicate); + } + + private static IReadOnlyList ConvertOcrLines( + IOcrLinesWords ocrResult, + double scale, + ILanguage language, + Func? sourceRectPredicate) + { + List lines = []; + bool isSpaceJoiningLanguage = language.IsSpaceJoining(); + + foreach (IOcrLine ocrLine in ocrResult.Lines) + { + StringBuilder textBuilder = new(); + ocrLine.GetTextFromOcrLine(isSpaceJoiningLanguage, textBuilder); + textBuilder.RemoveTrailingNewlines(); + + string lineText = textBuilder.ToString(); + if (string.IsNullOrWhiteSpace(lineText)) + continue; + + Windows.Foundation.Rect scaledRect = ocrLine.BoundingBox; + Windows.Foundation.Rect sourceRect = new( + scaledRect.X / scale, + scaledRect.Y / scale, + scaledRect.Width / scale, + scaledRect.Height / scale); + + if (sourceRectPredicate is not null && !sourceRectPredicate(sourceRect)) + continue; + + lines.Add(new PdfPageTextLine(sourceRect, lineText.Trim(), isNativeText: false)); + } + + return SortLines(lines); + } + + private static List SortLines(IEnumerable lines) + { + return [.. lines.OrderBy(line => line.SourceRect.Y).ThenBy(line => line.SourceRect.X)]; + } + + private static async Task RenderPageBitmapAsync(WinPdfPage page) + { + using InMemoryRandomAccessStream renderedStream = new(); + PdfPageRenderOptions renderOptions = CreateRenderOptions(page); + + await page.RenderToStreamAsync(renderedStream, renderOptions); + renderedStream.Seek(0); + + using Bitmap renderedBitmap = ImageMethods.GetBitmapFromIRandomAccessStream(renderedStream); + return ImageMethods.BitmapToImageSource(renderedBitmap); + } + + private void ValidatePageIndex(int pageIndex) + { + if (pageIndex < 0 || pageIndex >= PageCount) + throw new ArgumentOutOfRangeException(nameof(pageIndex), pageIndex, "Page index is outside the document bounds."); + } +} diff --git a/Text-Grab/Utilities/SettingsImportExportUtilities.cs b/Text-Grab/Utilities/SettingsImportExportUtilities.cs index b35ceb8b..14990dec 100644 --- a/Text-Grab/Utilities/SettingsImportExportUtilities.cs +++ b/Text-Grab/Utilities/SettingsImportExportUtilities.cs @@ -84,6 +84,7 @@ public static async Task ImportSettingsFromZipAsync(string zipFilePath) { string tempDir = Path.Combine(Path.GetTempPath(), $"TextGrab_Import_{Guid.NewGuid()}"); Directory.CreateDirectory(tempDir); + SettingsService settingsService = AppUtilities.TextGrabSettingsService; try { @@ -98,6 +99,7 @@ public static async Task ImportSettingsFromZipAsync(string zipFilePath) } ImportManagedJsonSettingsFolder(tempDir); + settingsService.ReconcileManagedJsonSettings(); await ImportGrabTemplatesAsync(tempDir); // Import history if present diff --git a/Text-Grab/Utilities/StringMethods.cs b/Text-Grab/Utilities/StringMethods.cs index a026c8eb..c82ba643 100644 --- a/Text-Grab/Utilities/StringMethods.cs +++ b/Text-Grab/Utilities/StringMethods.cs @@ -119,17 +119,7 @@ public static (int, int) CursorWordBoundaries(this string input, int cursorPosit if (string.IsNullOrEmpty(input)) return (0, 0); - if (cursorPosition < 0) - cursorPosition = 0; - - try - { - char check = input[cursorPosition]; - } - catch (IndexOutOfRangeException) - { - return (cursorPosition, 0); - } + cursorPosition = Math.Clamp(cursorPosition, 0, input.Length - 1); // Check if the cursor is at a space if (char.IsWhiteSpace(input[cursorPosition])) @@ -165,7 +155,7 @@ public static string GetWordAtCursorPosition(this string input, int cursorPositi private static int FindNearestLetterIndex(string input, int cursorPosition) { - Math.Clamp(cursorPosition, 0, input.Length - 1); + cursorPosition = Math.Clamp(cursorPosition, 0, input.Length - 1); int lastCharIndex = input.Length - 1; @@ -183,6 +173,12 @@ private static int FindNearestLetterIndex(string input, int cursorPosition) && nearestToTheRight > lastCharIndex) return cursorPosition; + if (nearestToTheLeft < 0) + return nearestToTheRight; + + if (nearestToTheRight > lastCharIndex) + return nearestToTheLeft; + int leftDistance = cursorPosition - nearestToTheLeft; int rightDistance = nearestToTheRight - cursorPosition; @@ -303,15 +299,41 @@ public static string MakeStringSingleLine(this string textToEdit) string temp = MultiSpaces().Replace(workingString.ToString(), " "); workingString.Clear(); workingString.Append(temp); + if (workingString.Length == 0) + return string.Empty; + if (workingString[0] == ' ') workingString.Remove(0, 1); + if (workingString.Length == 0) + return string.Empty; + if (workingString[^1] == ' ') workingString.Remove(workingString.Length - 1, 1); return workingString.ToString(); } + public static string JoinLines(this string textToJoin, string joiningText, bool trimLineBeforeJoining, string textAtBeginning = "", string textAtEnd = "") + { + ArgumentNullException.ThrowIfNull(textToJoin); + ArgumentNullException.ThrowIfNull(joiningText); + ArgumentNullException.ThrowIfNull(textAtBeginning); + ArgumentNullException.ThrowIfNull(textAtEnd); + + string normalizedText = NewlineRegex().Replace(textToJoin, Environment.NewLine); + string[] lines = normalizedText.Split([Environment.NewLine], StringSplitOptions.None); + + if (normalizedText.EndsWith(Environment.NewLine, StringComparison.Ordinal) && lines.Length > 0) + lines = [.. lines[..^1]]; + + if (trimLineBeforeJoining) + lines = [.. lines.Select(line => line.Trim())]; + + string joinedText = string.Join(joiningText, lines); + return $"{textAtBeginning}{joinedText}{textAtEnd}"; + } + public static string ToCamel(this string stringToCamel) { string toReturn = string.Empty; @@ -734,6 +756,31 @@ public static string RemoveDuplicateLines(this string stringToDeduplicate) return string.Join(Environment.NewLine, [.. uniqueLines]); } + public static string ShuffleLines(this string textToShuffle, Random? random = null) + { + ArgumentNullException.ThrowIfNull(textToShuffle); + + string[] lines = textToShuffle.Split([Environment.NewLine], StringSplitOptions.None); + bool endsWithNewline = textToShuffle.EndsWith(Environment.NewLine, StringComparison.Ordinal); + + if (endsWithNewline) + lines = [.. lines[..^1]]; + + if (lines.Length <= 1) + return textToShuffle; + + random ??= Random.Shared; + + for (int i = lines.Length - 1; i > 0; i--) + { + int swapIndex = random.Next(i + 1); + (lines[i], lines[swapIndex]) = (lines[swapIndex], lines[i]); + } + + string shuffledText = string.Join(Environment.NewLine, lines); + return endsWithNewline ? $"{shuffledText}{Environment.NewLine}" : shuffledText; + } + public static string RemoveAllInstancesOf(this string stringToBeEdited, string stringToRemove) { Regex regex = new(stringToRemove.EscapeSpecialRegexChars(false)); diff --git a/Text-Grab/Utilities/TextSearchUtilities.cs b/Text-Grab/Utilities/TextSearchUtilities.cs new file mode 100644 index 00000000..6cb6e2d7 --- /dev/null +++ b/Text-Grab/Utilities/TextSearchUtilities.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Text_Grab.Utilities; + +internal static class TextSearchUtilities +{ + private static readonly TimeSpan DefaultRegexTimeout = TimeSpan.FromSeconds(5); + + internal static bool HasSearchText(string? searchText) => !string.IsNullOrEmpty(searchText); + + internal static string FormatMatchTextForDisplay(string matchText) + { + if (!matchText.All(char.IsWhiteSpace)) + return matchText.MakeStringSingleLine(); + + StringBuilder displayText = new(); + for (int i = 0; i < matchText.Length; i++) + { + if (matchText[i] == '\r' && i + 1 < matchText.Length && matchText[i + 1] == '\n') + { + displayText.Append('⏎'); + i++; + continue; + } + + char character = matchText[i]; + displayText.Append(character switch + { + ' ' => '·', + '\t' => '⇥', + '\r' => '␍', + '\n' => '⏎', + _ => '␣' + }); + } + + return displayText.ToString(); + } + + internal static Regex CreateFindAndReplaceSearchRegex(string pattern, bool usePatternMode, bool exactMatch) + { + RegexOptions options = RegexOptions.Multiline; + + if (!exactMatch && !usePatternMode) + options |= RegexOptions.IgnoreCase; + + return new Regex(pattern, options, DefaultRegexTimeout); + } + + internal 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) + { + 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/Utilities/ThirdPartyNoticeUtilities.cs new file mode 100644 index 00000000..800fd217 --- /dev/null +++ b/Text-Grab/Utilities/ThirdPartyNoticeUtilities.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +public static class ThirdPartyNoticeUtilities +{ + public const string BuiltWithFileName = "BUILT-WITH.md"; + public const string NoticesDirectoryName = "ThirdPartyNotices"; + + private const string MarkdigNoticePath = @"ThirdPartyNotices\licenses\Markdig-license.txt"; + private const string WindowsAppSdkNoticePath = @"ThirdPartyNotices\licenses\Microsoft.WindowsAppSDK-license.txt"; + private const string DiagnosticsHubNoticePath = @"ThirdPartyNotices\licenses\Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md"; + + public static IReadOnlyList Packages { get; } = + [ + new("CliWrap", "3.10.1", "App", "MIT", "https://github.com/Tyrrrz/CliWrap", "https://github.com/Tyrrrz/CliWrap/blob/master/License.txt"), + new("Dapplo.Windows.User32", "2.0.89", "App", "MIT", "https://github.com/dapplo/Dapplo.Windows", "https://github.com/dapplo/Dapplo.Windows/blob/master/LICENSE"), + new("Humanizer.Core", "3.0.10", "App", "MIT", "https://github.com/Humanizr/Humanizer", "https://github.com/Humanizr/Humanizer/blob/main/license.txt"), + new("Magick.NET-Q16-AnyCPU", "14.12.0", "App", "Apache-2.0", "https://github.com/dlemstra/Magick.NET", "https://github.com/dlemstra/Magick.NET/blob/main/License.txt"), + new("Magick.NET.SystemDrawing", "8.0.20", "App", "Apache-2.0", "https://github.com/dlemstra/Magick.NET", "https://github.com/dlemstra/Magick.NET/blob/main/License.txt"), + new("Magick.NET.SystemWindowsMedia", "8.0.20", "App", "Apache-2.0", "https://github.com/dlemstra/Magick.NET", "https://github.com/dlemstra/Magick.NET/blob/main/License.txt"), + new("Markdig", "1.1.3", "App", "BSD-2-Clause", "https://github.com/xoofx/markdig", MarkdigNoticePath, true, "Bundled to satisfy BSD-2-Clause binary redistribution notice requirements."), + new("Microsoft.Toolkit.Uwp.Notifications", "7.1.3", "App", "MIT", "https://github.com/CommunityToolkit/WindowsCommunityToolkit", "https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/License.md"), + new("Microsoft.WindowsAppSDK.AI", "1.8.70", "App", "Microsoft license terms", "https://github.com/microsoft/windowsappsdk", WindowsAppSdkNoticePath, true, "Package ships Microsoft Windows App SDK license terms."), + new("Microsoft.WindowsAppSDK.Foundation", "1.8.260415000", "App", "Microsoft license terms", "https://github.com/microsoft/windowsappsdk", WindowsAppSdkNoticePath, true, "Package ships Microsoft Windows App SDK license terms."), + new("Microsoft.WindowsAppSDK.Runtime", "1.8.260416003", "App", "Microsoft license terms", "https://github.com/microsoft/windowsappsdk", WindowsAppSdkNoticePath, true, "Package ships Microsoft Windows App SDK license terms."), + new("Microsoft.WindowsAppSDK.WinUI", "1.8.260415005", "App", "Microsoft license terms", "https://github.com/microsoft/windowsappsdk", WindowsAppSdkNoticePath, true, "Package ships Microsoft Windows App SDK license terms."), + new("NCalcAsync", "5.12.0", "App, Tests", "MIT", "https://github.com/ncalc/ncalc", "https://github.com/ncalc/ncalc/blob/master/LICENSE", false, "Shared by the application and the test project."), + new("PdfPig", "0.1.14", "App", "Apache-2.0", "https://github.com/UglyToad/PdfPig", "https://github.com/UglyToad/PdfPig/blob/master/LICENSE"), + new("UnitsNet", "5.75.0", "App", "MIT-0", "https://github.com/angularsen/UnitsNet", "https://github.com/angularsen/UnitsNet/blob/master/LICENSE"), + new("WPF-UI", "4.2.1", "App", "MIT", "https://github.com/lepoco/wpfui", "https://github.com/lepoco/wpfui/blob/main/LICENSE"), + new("WPF-UI.Tray", "4.2.1", "App", "MIT", "https://github.com/lepoco/wpfui", "https://github.com/lepoco/wpfui/blob/main/LICENSE"), + new("ZXing.Net", "0.16.11", "App", "Apache-2.0", "https://github.com/micjahn/ZXing.Net", "https://github.com/micjahn/ZXing.Net/blob/master/COPYING"), + new("ZXing.Net.Bindings.Windows.Compatibility", "0.16.14", "App", "Apache-2.0", "https://github.com/micjahn/ZXing.Net", "https://github.com/micjahn/ZXing.Net/blob/master/COPYING"), + new("BenchmarkDotNet", "0.15.8", "Tests", "MIT", "https://github.com/dotnet/BenchmarkDotNet", "https://github.com/dotnet/BenchmarkDotNet/blob/master/LICENSE.md", false, "Test-only dependency."), + new("coverlet.collector", "10.0.0", "Tests", "MIT", "https://github.com/coverlet-coverage/coverlet", "https://github.com/coverlet-coverage/coverlet/blob/master/LICENSE", false, "Test-only dependency."), + new("Microsoft.NET.Test.Sdk", "18.4.0", "Tests", "MIT", "https://github.com/microsoft/vstest", "https://github.com/microsoft/vstest/blob/main/LICENSE", false, "Test-only dependency."), + new("Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers", "18.7.37220.1", "Tests", "Microsoft license terms", "https://learn.microsoft.com/visualstudio/profiling/", DiagnosticsHubNoticePath, true, "Visual Studio benchmarking tooling; test-only dependency."), + new("xunit.runner.visualstudio", "3.1.5", "Tests", "Apache-2.0", "https://github.com/xunit/visualstudio.xunit", "https://github.com/xunit/visualstudio.xunit/blob/main/License.txt", false, "Test-only dependency."), + 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/Utilities/WindowResizer.cs b/Text-Grab/Utilities/WindowResizer.cs index d2c793fd..16a1fbfb 100644 --- a/Text-Grab/Utilities/WindowResizer.cs +++ b/Text-Grab/Utilities/WindowResizer.cs @@ -28,7 +28,7 @@ public enum WindowDockPosition /// /// Fixes the issue with Windows of Style covering the taskbar /// -public class WindowResizer +public partial class WindowResizer : IDisposable { #region Private Members @@ -37,6 +37,13 @@ public class WindowResizer ///
private Window? mWindow; + /// + /// The HwndSource we hooked WindowProc into. Tracked so we can remove the hook on dispose. + /// + private HwndSource? mHookedSource; + + private bool mDisposed; + /// /// The last calculated available screen size /// @@ -66,15 +73,15 @@ public class WindowResizer #region Dll Imports - [DllImport("user32.dll")] + [LibraryImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetCursorPos(out POINT lpPoint); + private static partial bool GetCursorPos(out POINT lpPoint); [DllImport("user32.dll")] private static extern bool GetMonitorInfo(IntPtr hMonitor, MONITORINFO lpmi); - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr MonitorFromPoint(POINT pt, MonitorOptions dwFlags); + [LibraryImport("user32.dll", SetLastError = true)] + private static partial IntPtr MonitorFromPoint(POINT pt, MonitorOptions dwFlags); #endregion @@ -124,7 +131,7 @@ private void GetTransform() PresentationSource source = PresentationSource.FromVisual(mWindow); // Reset the transform to default - mTransformToDevice = default(Matrix); + mTransformToDevice = default; // If we cannot get the source, ignore if (source?.CompositionTarget == null) @@ -151,6 +158,29 @@ private void Window_SourceInitialized(object? sender, System.EventArgs e) // Hook into it's Windows messages handleSource.AddHook(WindowProc); + mHookedSource = handleSource; + } + + public void Dispose() + { + if (mDisposed) + return; + + mDisposed = true; + + if (mWindow is not null) + { + mWindow.SourceInitialized -= Window_SourceInitialized; + mWindow.SizeChanged -= Window_SizeChanged; + mWindow = null; + } + + mHookedSource?.RemoveHook(WindowProc); + mHookedSource = null; + + WindowDockChanged = static (_) => { }; + + GC.SuppressFinalize(this); } #endregion @@ -165,7 +195,7 @@ private void Window_SourceInitialized(object? sender, System.EventArgs e) private void Window_SizeChanged(object sender, SizeChangedEventArgs e) { // We cannot find positioning until the window transform has been established - if (mTransformToDevice == default(Matrix) + if (mTransformToDevice == default || mWindow is null) return; @@ -250,8 +280,7 @@ private void WmGetMinMaxInfo(System.IntPtr hwnd, System.IntPtr lParam) return; // Get the point position to determine what screen we are on - POINT lMousePosition; - GetCursorPos(out lMousePosition); + GetCursorPos(out POINT lMousePosition); // Get the primary monitor at cursor position 0,0 nint lPrimaryScreen = MonitorFromPoint(new POINT(0, 0), MonitorOptions.MONITOR_DEFAULTTOPRIMARY); @@ -265,7 +294,7 @@ private void WmGetMinMaxInfo(System.IntPtr hwnd, System.IntPtr lParam) nint lCurrentScreen = MonitorFromPoint(lMousePosition, MonitorOptions.MONITOR_DEFAULTTONEAREST); // If this has changed from the last one, update the transform - if (lCurrentScreen != mLastScreen || mTransformToDevice == default(Matrix)) + if (lCurrentScreen != mLastScreen || mTransformToDevice == default) GetTransform(); // Store last know screen diff --git a/Text-Grab/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs index bcf95aed..1301af52 100644 --- a/Text-Grab/Utilities/WindowUtilities.cs +++ b/Text-Grab/Utilities/WindowUtilities.cs @@ -295,6 +295,45 @@ private static void TryInjectModifierKeyUp(ref List inputs, VirtualKeySho } } + internal static bool ShouldOpenNewEtwInSpreadsheetMode(bool isTableModeSelected, bool hasExistingEditTextWindow) + { + return isTableModeSelected && !hasExistingEditTextWindow; + } + + internal static EditTextWindow OpenOrActivateEditTextWindow(bool isTableModeSelected = false) + { + WindowCollection allWindows = Application.Current.Windows; + + foreach (Window window in allWindows) + { + if (window is EditTextWindow matchWindow) + { + matchWindow.Activate(); + return matchWindow; + } + } + + EditTextWindow newWindow = new(); + if (ShouldOpenNewEtwInSpreadsheetMode(isTableModeSelected, hasExistingEditTextWindow: false)) + newWindow.EnterSpreadsheetMode(); + + try + { + newWindow.Show(); + } + 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 newWindow; + } + 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 index 4f386490..05a21105 100644 --- a/Text-Grab/Utilities/WindowsAiUtilities.cs +++ b/Text-Grab/Utilities/WindowsAiUtilities.cs @@ -1,5 +1,6 @@ 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; @@ -20,7 +21,7 @@ namespace Text_Grab.Utilities; public static class WindowsAiUtilities { - private const string TranslationPromptTemplate = "Translate to {0}:\n\n{1}"; + 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; @@ -125,35 +126,45 @@ private static bool IsLikelyInTargetLanguage(string text, string targetLanguage) public static bool CanDeviceUseWinAI() { - // Check if the app is packaged and if the AI feature is supported - if (!AppUtilities.IsPackaged() || OSInterop.IsWindows10()) - return false; + return CanDeviceUseWinAiFeature(TextRecognizer.GetReadyState); + } - // Today, Windows AI Text Recognition is only supported on ARM64 - Architecture arch = RuntimeInformation.ProcessArchitecture; - if (arch != Architecture.Arm64 && !Settings.Default.OverrideAiArchCheck) - return false; + public static bool CanDeviceDescribeImagesWithWinAI() + { + return CanDeviceUseWinAiFeature(ImageDescriptionGenerator.GetReadyState); + } - // After checking for Arm64 the remainder checks should be good to catch supporting devices + private static bool CanDeviceUseWinAiFeature(Func getReadyState) + { + if (!MeetsWindowsAiPrerequisites()) + return false; try { - AIFeatureReadyState readyState = TextRecognizer.GetReadyState(); - if (readyState == AIFeatureReadyState.NotSupportedOnCurrentSystem) - return false; - else - return true; + return getReadyState() != AIFeatureReadyState.NotSupportedOnCurrentSystem; } catch (Exception) { #if DEBUG throw; +#else + return false; #endif + } + } -#pragma warning disable CS0162 // Unreachable code detected + private static bool MeetsWindowsAiPrerequisites() + { + // Check if the app is packaged and if the AI feature is supported + if (!AppUtilities.IsPackaged() || OSInterop.IsWindows10()) return false; -#pragma warning restore CS0162 // Unreachable code detected - } + + // 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) @@ -170,7 +181,7 @@ public static async Task GetTextWithWinAI(string imagePath) using TextRecognizer textRecognizer = await TextRecognizer.CreateAsync(); SoftwareBitmap bitmap = await imagePath.FilePathToSoftwareBitmapAsync(); - ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(bitmap); + using ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(bitmap); RecognizedText? result = textRecognizer? .RecognizeTextFromImage(imageBuffer); @@ -186,6 +197,55 @@ public static async Task GetTextWithWinAI(string imagePath) return stringBuilder.ToString(); } + public static async Task GetTextDescriptionWithWinAI(string imagePath) + { + using SoftwareBitmap bitmap = await imagePath.FilePathToSoftwareBitmapAsync(); + return await GetTextDescriptionWithWinAI(bitmap); + } + + public static async Task GetTextDescriptionWithWinAI(SoftwareBitmap bitmap) + { + if (!CanDeviceDescribeImagesWithWinAI()) + return "ERROR: Cannot use Windows AI on this device."; + + AIFeatureReadyState readyState = ImageDescriptionGenerator.GetReadyState(); + if (readyState == AIFeatureReadyState.NotReady) + { + AIFeatureReadyResult op = await ImageDescriptionGenerator.EnsureReadyAsync(); + } + + using ImageDescriptionGenerator imageDescriptionGenerator = await ImageDescriptionGenerator.CreateAsync(); + using ImageBuffer imageBuffer = ImageBuffer.CreateForSoftwareBitmap(bitmap); + return await GetTextDescriptionWithWinAI(imageDescriptionGenerator, imageBuffer); + } + + private static async Task GetTextDescriptionWithWinAI(ImageDescriptionGenerator imageDescriptionGenerator, ImageBuffer imageBuffer) + { + // Create content moderation thresholds object. + ContentFilterOptions filterOptions = new(); + filterOptions.ResponseMaxAllowedSeverityLevel.SelfHarm = SeverityLevel.Medium; + filterOptions.ResponseMaxAllowedSeverityLevel.Violent = SeverityLevel.Medium; + + // Get text description. + ImageDescriptionResult languageModelResponse = await imageDescriptionGenerator.DescribeAsync( + imageBuffer, + ImageDescriptionKind.AccessibleDescription, + filterOptions); + + int maxWait = 50; + int wait = 0; + while (languageModelResponse.Status != ImageDescriptionResultStatus.Complete && wait < maxWait) + { + wait++; + await Task.Delay(100); + } + + if (languageModelResponse.Status != ImageDescriptionResultStatus.Complete) + return string.Empty; + + return languageModelResponse.Description?.Trim() ?? string.Empty; + } + public static async Task GetOcrResultAsync(Bitmap bmp) { string tempFilePath = System.IO.Path.GetTempFileName(); @@ -597,7 +657,7 @@ public static string CleanRegexResult(string regexText) return line[8..].Trim(); return line; }) - .FirstOrDefault(line => line.Length > 0 && + .FirstOrDefault(line => line.Length > 0 && (line.Contains('[') || line.Contains('(') || line.Contains('\\') || line.Contains('^') || line.Contains('$') || line.Contains('+') || line.Contains('*') || line.Contains('?') || diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 335f9357..51341fe4 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -68,6 +68,46 @@ Click="MoveLineDownMenuItem_Click" Header="Move Selection Down" InputGestureText="Alt + Down" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -127,6 +167,10 @@ CanExecute="MakeQrCodeCanExecute" Command="{x:Static local:EditTextWindow.MakeQrCodeCmd}" Executed="MakeQrCodeExecuted" /> + + + + + + + + + + + - + - + Orientation="Horizontal"> + + + + - + + + + + - + + + + - + + + + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 55afd1a0..4eb53d1f 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -1,23 +1,29 @@ -using Humanizer; +using Humanizer; using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Data; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Text; -using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; using System.Windows.Forms; using System.Windows.Input; +using System.Windows.Interop; using System.Windows.Markup; using System.Windows.Media; using System.Windows.Media.Animation; +using System.Windows.Navigation; using System.Windows.Threading; using Text_Grab.Controls; using Text_Grab.Interfaces; @@ -27,12 +33,12 @@ using Text_Grab.Utilities; using Text_Grab.Views; using Windows.ApplicationModel.DataTransfer; -using Windows.Globalization; -using Windows.Media.Ocr; using Windows.Storage; using Windows.Storage.Streams; using ContextMenu = System.Windows.Controls.ContextMenu; using MenuItem = System.Windows.Controls.MenuItem; +using SymbolIcon = Wpf.Ui.Controls.SymbolIcon; +using SymbolRegular = Wpf.Ui.Controls.SymbolRegular; namespace Text_Grab; @@ -42,6 +48,11 @@ namespace Text_Grab; public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow { + private const string EditTextWindowTitle = "Edit Text"; + private const double SpreadsheetDefaultColumnWidth = 120; + private const double HorizontalWheelScrollStep = 48; + private const int WmMouseHWheel = 0x020E; + private const string SaveDocumentFilter = "Spreadsheet documents (*.csv;*.tsv;*.tab)|*.csv;*.tsv;*.tab|Markdown documents (*.md;*.markdown)|*.md;*.markdown|Text documents (*.txt)|*.txt|All files (*.*)|*.*"; #region Fields public static RoutedCommand DeleteAllSelectionCmd = new(); @@ -56,6 +67,7 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow public static RoutedCommand SplitOnSelectionCmd = new(); public static RoutedCommand SplitAfterSelectionCmd = new(); public static RoutedCommand ToggleCaseCmd = new(); + public static RoutedCommand TransposeTableCmd = new(); public static RoutedCommand UnstackCmd = new(); public static RoutedCommand UnstackGroupCmd = new(); public static RoutedCommand WebSearchCmd = new(); @@ -84,6 +96,47 @@ public partial class EditTextWindow : Wpf.Ui.Controls.FluentWindow private ExtractedPattern? currentExtractedPattern = null; private int currentPrecisionLevel = ExtractedPattern.DefaultPrecisionLevel; private CalculationResult? calculationResult; + private EditTextTableDocument? tableDocument; + private readonly SpreadsheetUndoHistory spreadsheetUndoHistory = new(); + private readonly DataTable spreadsheetTable = new(); + private readonly List trackedSpreadsheetColumns = []; + private List<(int RowIndex, int ColumnIndex)> selectedSpreadsheetCellCoordinates = []; + private EtwEditorMode editorMode = EtwEditorMode.Text; + private bool isSyncingTextFromSpreadsheet = false; + private bool isSyncingTextFromMarkdown = false; + private bool isApplyingSpreadsheetLayout = false; + private bool isApplyingMarkdownDocument = false; + private bool isLoadingOpenedFile = false; + private bool hasPendingFileEdits = false; + private bool isShowingPendingFileClosePrompt = false; + private bool allowCloseAfterPendingFilePrompt = false; + private bool isRestoringSpreadsheetUndoState = false; + private int? spreadsheetContextRowIndex; + private int? spreadsheetContextColumnIndex; + private SpreadsheetUndoState? pendingSpreadsheetUndoState; + private string savedFileText = string.Empty; + private HwndSource? windowSource; + + private enum PendingFileCloseAction + { + Cancel, + Save, + DontSave, + SaveToHistory, + } + + private sealed class SpreadsheetCellTextWrappingConverter(EditTextWindow owner, int columnIndex) : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return owner.GetSpreadsheetCellTextWrapping(value, columnIndex); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return System.Windows.Data.Binding.DoNothing; + } + } #endregion Fields @@ -114,6 +167,8 @@ public EditTextWindow(HistoryInfo historyInfo) App.SetTheme(); PassedTextControl.Text = historyInfo.TextContent; + editorMode = historyInfo.EditorMode; + tableDocument = EditTextTableDocument.TryDeserialize(historyInfo.EditTextTableDocumentJson); historyId = historyInfo.ID; @@ -172,6 +227,7 @@ public static Dictionary GetRoutedCommands() {nameof(SplitAfterSelectionCmd), SplitAfterSelectionCmd}, {nameof(OcrPasteCommand), OcrPasteCommand}, {nameof(MakeQrCodeCmd), MakeQrCodeCmd}, + {nameof(TransposeTableCmd), TransposeTableCmd}, {nameof(WebSearchCmd), WebSearchCmd}, {nameof(DefaultWebSearchCmd), DefaultWebSearchCmd}, }; @@ -182,6 +238,11 @@ public void AddCharsToEditTextWindow(string stringToAdd, SpotInLine spotInLine) PassedTextControl.Text = PassedTextControl.Text.AddCharsToEachLine(stringToAdd, spotInLine); } + public void JoinLinesInEditTextWindow(string joiningText, bool trimLineBeforeJoining, string textAtBeginning = "", string textAtEnd = "") + { + ApplySelectedTextOrAllTextTransform(text => text.JoinLines(joiningText, trimLineBeforeJoining, textAtBeginning, textAtEnd)); + } + public void AddThisText(string textToAdd) { PassedTextControl.AppendText(textToAdd); @@ -192,6 +253,8 @@ public System.Windows.Controls.TextBox GetMainTextBox() return PassedTextControl; } + internal void EnterSpreadsheetMode() => SetEditorMode(EtwEditorMode.Spreadsheet); + public async Task OcrAllImagesInFolder(string folderPath, OcrDirectoryOptions options) { IEnumerable? files = null; @@ -218,11 +281,11 @@ public async Task OcrAllImagesInFolder(string folderPath, OcrDirectoryOptions op if (files is null) return; - List imageFiles = [.. files.Where(x => IoUtilities.ImageExtensions.Contains(Path.GetExtension(x).ToLower()))]; + List imageFiles = [.. files.Where(x => IoUtilities.IsVisualDocumentFileExtension(Path.GetExtension(x).ToLower()))]; if (imageFiles.Count == 0) { - PassedTextControl.AppendText($"{folderPath} contains no images"); + PassedTextControl.AppendText($"{folderPath} contains no images or PDFs"); return; } @@ -252,7 +315,7 @@ public async Task OcrAllImagesInFolder(string folderPath, OcrDirectoryOptions op { PassedTextControl.AppendText(folderPath); PassedTextControl.AppendText(Environment.NewLine); - PassedTextControl.AppendText($"{imageFiles.Count} images found"); + PassedTextControl.AppendText($"{imageFiles.Count} files found"); if (!string.IsNullOrEmpty(tesseractLanguageTag)) { @@ -301,14 +364,14 @@ public async Task OcrAllImagesInFolder(string folderPath, OcrDirectoryOptions op if (options.OutputFooter) { PassedTextControl.AppendText(Environment.NewLine); - PassedTextControl.AppendText($"----- COMPLETED OCR OF {imageFiles.Count} images"); + PassedTextControl.AppendText($"----- COMPLETED OCR OF {imageFiles.Count} files"); } } catch (OperationCanceledException) { PassedTextControl.AppendText(Environment.NewLine); int countCompleted = ocrFileResults.Where(r => r.OcrResult is not null).Count(); - PassedTextControl.AppendText($"----- CANCELLED OCR OF {ocrFileResults.Count - countCompleted}, Completed {countCompleted} images"); + PassedTextControl.AppendText($"----- CANCELLED OCR OF {ocrFileResults.Count - countCompleted}, Completed {countCompleted} files"); } finally { @@ -401,8 +464,1613 @@ private void SetCultureAndLanguageToDefault() Language = xmlDefaultLang; } + private void ApplySpreadsheetDocumentChange( + Action changeAction, + int? focusRow = null, + int? focusColumn = null, + bool beginEdit = true) + { + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? beforeChange = CreateCurrentSpreadsheetUndoState(syncFromTable: true); + + if (tableDocument is null) + return; + + changeAction(tableDocument); + tableDocument.EnsureMinimumSize(); + RecordSpreadsheetUndoChange(beforeChange, CreateCurrentSpreadsheetUndoState(syncFromTable: false)); + RebuildSpreadsheetTable(); + UpdateTextFromSpreadsheetDocument(); + + if (focusRow.HasValue && focusColumn.HasValue) + { + Dispatcher.BeginInvoke( + () => FocusSpreadsheetCell(focusRow.Value, focusColumn.Value, beginEdit), + DispatcherPriority.Background); + } + } + + private SpreadsheetUndoState? CreateCurrentSpreadsheetUndoState(bool syncFromTable = false) + { + if (syncFromTable && editorMode == EtwEditorMode.Spreadsheet) + SyncSpreadsheetDocumentFromTable(writeText: false); + + if (tableDocument is null) + return null; + + tableDocument.EnsureMinimumSize(); + return new SpreadsheetUndoState( + tableDocument.SerializeToJson(), + GetSpreadsheetCurrentRowIndex(), + GetSpreadsheetCurrentColumnIndex()); + } + + private int? GetSpreadsheetCurrentRowIndex() + { + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + return rowIndex >= 0 ? rowIndex : null; + } + + private int? GetSpreadsheetCurrentColumnIndex() + { + return SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex; + } + + private void CommitSpreadsheetEditsAndCapturePendingHistory() + { + if (editorMode != EtwEditorMode.Spreadsheet) + return; + + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Cell, true); + _ = SpreadsheetDataGrid.CommitEdit(DataGridEditingUnit.Row, true); + CaptureCommittedSpreadsheetEditIfPending(); + } + + private void CaptureCommittedSpreadsheetEditIfPending() + { + if (pendingSpreadsheetUndoState is null || isRestoringSpreadsheetUndoState) + return; + + SpreadsheetUndoState beforeChange = pendingSpreadsheetUndoState; + pendingSpreadsheetUndoState = null; + RecordSpreadsheetUndoChange(beforeChange, CreateCurrentSpreadsheetUndoState(syncFromTable: true)); + } + + private void RecordSpreadsheetUndoChange(SpreadsheetUndoState? beforeChange, SpreadsheetUndoState? afterChange) + { + spreadsheetUndoHistory.RecordChange(beforeChange, afterChange); + CommandManager.InvalidateRequerySuggested(); + } + + private void ResetSpreadsheetUndoHistory() + { + spreadsheetUndoHistory.Clear(); + pendingSpreadsheetUndoState = null; + CommandManager.InvalidateRequerySuggested(); + } + + private void RestoreSpreadsheetUndoState(SpreadsheetUndoState stateToRestore) + { + EditTextTableDocument? restoredDocument = EditTextTableDocument.TryDeserialize(stateToRestore.DocumentJson); + if (restoredDocument is null) + return; + + isRestoringSpreadsheetUndoState = true; + try + { + pendingSpreadsheetUndoState = null; + tableDocument = restoredDocument; + RebuildSpreadsheetTable(); + UpdateTextFromSpreadsheetDocument(); + } + finally + { + isRestoringSpreadsheetUndoState = false; + } + + if (SpreadsheetDataGrid.Items.Count == 0 || SpreadsheetDataGrid.Columns.Count == 0) + { + UpdateLineAndColumnText(); + return; + } + + int focusRow = Math.Clamp(stateToRestore.FocusRow ?? 0, 0, SpreadsheetDataGrid.Items.Count - 1); + int focusColumn = Math.Clamp(stateToRestore.FocusColumn ?? 0, 0, SpreadsheetDataGrid.Columns.Count - 1); + + Dispatcher.BeginInvoke( + () => FocusSpreadsheetCell(focusRow, focusColumn, beginEdit: false), + DispatcherPriority.Background); + UpdateLineAndColumnText(); + } + + private void CopySpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs e) + { + int columnIndex = + spreadsheetContextColumnIndex + ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex + ?? -1; + + if (columnIndex < 0) + return; + List values = []; + + foreach (DataRow row in spreadsheetTable.Rows) + { + if (columnIndex >= spreadsheetTable.Columns.Count) + break; + + values.Add(row[columnIndex]?.ToString() ?? string.Empty); + } + + TrySetClipboardText(string.Join(Environment.NewLine, values)); + } + + private void CopySpreadsheetRowsMenuItem_Click(object sender, RoutedEventArgs e) + { + List selectedRows = [.. SpreadsheetDataGrid.SelectedItems.OfType()]; + + if (selectedRows.Count == 0 && SpreadsheetDataGrid.CurrentItem is DataRowView currentRow) + selectedRows.Add(currentRow); + + if (selectedRows.Count == 0) + return; + + string rowText = string.Join( + Environment.NewLine, + selectedRows.Select(row => string.Join("\t", row.Row.ItemArray.Select(value => value?.ToString() ?? string.Empty)))); + + TrySetClipboardText(rowText); + } + + private void CopySpreadsheetSelectionMenuItem_Click(object sender, RoutedEventArgs e) + { + _ = TryCopySpreadsheetSelectionToClipboard(GetSelectedSpreadsheetCellCoordinates()); + } + + private void AddSpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs e) + { + int currentColumnIndex = + spreadsheetContextColumnIndex + ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex + ?? ((tableDocument?.ColumnCount ?? 1) - 1); + int insertIndex = Math.Clamp(currentColumnIndex + 1, 0, Math.Max(tableDocument?.ColumnCount ?? 0, 0)); + + ApplySpreadsheetDocumentChange( + document => document.InsertColumn(insertIndex), + SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem), + insertIndex); + } + + private void AddSpreadsheetRowMenuItem_Click(object sender, RoutedEventArgs e) + { + int currentRowIndex = spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + if (currentRowIndex < 0) + currentRowIndex = (tableDocument?.RowCount ?? 1) - 1; + + int insertIndex = Math.Clamp(currentRowIndex + 1, 0, Math.Max(tableDocument?.RowCount ?? 0, 0)); + int focusColumn = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + + ApplySpreadsheetDocumentChange( + document => document.InsertRow(insertIndex), + insertIndex, + focusColumn); + } + + private void TransposeTableCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + e.CanExecute = editorMode == EtwEditorMode.Spreadsheet; + } + + private void TransposeTableExecuted(object sender, ExecutedRoutedEventArgs e) + { + int currentRowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int currentColumnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + + ApplySpreadsheetDocumentChange(document => document.Transpose()); + + if (SpreadsheetDataGrid.Items.Count == 0 || SpreadsheetDataGrid.Columns.Count == 0) + return; + + int focusRow = Math.Clamp(currentColumnIndex, 0, SpreadsheetDataGrid.Items.Count - 1); + int focusColumn = Math.Clamp(Math.Max(0, currentRowIndex), 0, SpreadsheetDataGrid.Columns.Count - 1); + + Dispatcher.BeginInvoke( + () => FocusSpreadsheetCell(focusRow, focusColumn), + DispatcherPriority.Background); + } + + private void DeleteSpreadsheetColumnMenuItem_Click(object sender, RoutedEventArgs e) + { + int columnIndex = spreadsheetContextColumnIndex ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? -1; + if (columnIndex < 0) + return; + + int nextColumnIndex = Math.Max(0, Math.Min(columnIndex, (tableDocument?.ColumnCount ?? 1) - 2)); + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + + ApplySpreadsheetDocumentChange( + document => document.DeleteColumn(columnIndex), + Math.Max(0, rowIndex), + nextColumnIndex); + } + + private void DeleteSpreadsheetRowMenuItem_Click(object sender, RoutedEventArgs e) + { + int rowIndex = spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + if (rowIndex < 0) + return; + + int nextRowIndex = Math.Max(0, Math.Min(rowIndex, (tableDocument?.RowCount ?? 1) - 2)); + int columnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + + ApplySpreadsheetDocumentChange( + document => document.DeleteRow(rowIndex), + nextRowIndex, + columnIndex); + } + + private void EnsureSpreadsheetDocumentFromText() + { + if (tableDocument is not null) + { + tableDocument.EnsureMinimumSize(); + return; + } + + tableDocument = EditTextTableDocument.CreateFromText(PassedTextControl.Text); + } + + private void FocusSpreadsheetCell(int rowIndex, int columnIndex, bool beginEdit = true) + { + if (rowIndex < 0 + || columnIndex < 0 + || rowIndex >= SpreadsheetDataGrid.Items.Count + || columnIndex >= SpreadsheetDataGrid.Columns.Count) + { + return; + } + + object rowItem = SpreadsheetDataGrid.Items[rowIndex]; + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + + SelectSpreadsheetCell(rowItem, column, clearExistingSelection: true); + + if (beginEdit) + SpreadsheetDataGrid.BeginEdit(); + } + + private void MoveSpreadsheetColumnLeftMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetColumn(-1); + } + + private void MoveSpreadsheetColumnRightMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetColumn(1); + } + + private void MoveSpreadsheetColumn(int direction) + { + int fromIndex = spreadsheetContextColumnIndex ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? -1; + if (fromIndex < 0) + return; + + int toIndex = fromIndex + direction; + if (toIndex < 0 || toIndex >= (tableDocument?.ColumnCount ?? 0)) + return; + + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + ApplySpreadsheetDocumentChange( + document => document.MoveColumn(fromIndex, toIndex), + Math.Max(0, rowIndex), + toIndex); + } + + private void MoveSpreadsheetRowDownMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetRow(1); + } + + private void MoveSpreadsheetRowUpMenuItem_Click(object sender, RoutedEventArgs e) + { + MoveSpreadsheetRow(-1); + } + + private void MoveSpreadsheetRow(int direction) + { + int fromIndex = spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + if (fromIndex < 0) + return; + + int toIndex = fromIndex + direction; + if (toIndex < 0 || toIndex >= (tableDocument?.RowCount ?? 0)) + return; + + int columnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0; + ApplySpreadsheetDocumentChange( + document => document.MoveRow(fromIndex, toIndex), + toIndex, + columnIndex); + } + + private void HideSelectionSpecificUi() + { + MatchCountButton.Visibility = Visibility.Collapsed; + RegexPatternButton.Visibility = Visibility.Collapsed; + SimilarMatchesButton.Visibility = Visibility.Collapsed; + CharDetailsButton.Visibility = Visibility.Collapsed; + } + + private void RebuildSpreadsheetTable() + { + if (tableDocument is null) + return; + + DetachSpreadsheetColumnWidthTracking(); + isApplyingSpreadsheetLayout = true; + spreadsheetTable.BeginInit(); + spreadsheetTable.Clear(); + spreadsheetTable.Columns.Clear(); + + foreach (string columnName in tableDocument.ColumnNames) + spreadsheetTable.Columns.Add(columnName, typeof(string)); + + foreach (List row in tableDocument.Rows) + { + DataRow dataRow = spreadsheetTable.NewRow(); + for (int columnIndex = 0; columnIndex < tableDocument.ColumnNames.Count; columnIndex++) + dataRow[columnIndex] = columnIndex < row.Count ? row[columnIndex] ?? string.Empty : string.Empty; + + spreadsheetTable.Rows.Add(dataRow); + } + + spreadsheetTable.EndInit(); + + SpreadsheetDataGrid.ItemsSource = spreadsheetTable.DefaultView; + selectedSpreadsheetCellCoordinates = []; + SpreadsheetDataGrid.Columns.Clear(); + + for (int columnIndex = 0; columnIndex < spreadsheetTable.Columns.Count; columnIndex++) + { + DataColumn column = spreadsheetTable.Columns[columnIndex]; + double width = tableDocument.ColumnWidths.ElementAtOrDefault(columnIndex) ?? SpreadsheetDefaultColumnWidth; + DataGridTextColumn gridColumn = new() + { + Header = EditTextTableDocument.GetSpreadsheetColumnLabel(columnIndex), + Binding = new System.Windows.Data.Binding($"[{column.ColumnName}]"), + ElementStyle = CreateSpreadsheetDisplayTextStyle(columnIndex), + EditingElementStyle = CreateSpreadsheetEditingTextStyle(columnIndex), + MinWidth = SpreadsheetDefaultColumnWidth, + Width = new DataGridLength(Math.Max(SpreadsheetDefaultColumnWidth, width)), + }; + + SpreadsheetDataGrid.Columns.Add(gridColumn); + TrackSpreadsheetColumnWidth(gridColumn); + } + + SpreadsheetDataGrid.Items.Refresh(); + isApplyingSpreadsheetLayout = false; + } + + private void RefreshSpreadsheetFromText(bool rebuildTable = true) + { + if (isSyncingTextFromSpreadsheet) + return; + + EditTextTableDocument? existingDocument = tableDocument; + tableDocument = EditTextTableDocument.CreateFromText( + PassedTextControl.Text, + existingDocument?.MinimumRowCount ?? EditTextTableDocument.DefaultMinimumRowCount, + existingDocument?.MinimumColumnCount ?? EditTextTableDocument.DefaultMinimumColumnCount); + + if (existingDocument is not null) + tableDocument.ApplyViewMetricsFrom(existingDocument); + + if (rebuildTable) + RebuildSpreadsheetTable(); + UpdateLineAndColumnText(); + } + + private void RefreshMarkdownFromText() + { + if (isSyncingTextFromMarkdown) + return; + + LoadMarkdownDocumentFromText(PassedTextControl.Text); + UpdateLineAndColumnText(); + } + + private void LoadMarkdownDocumentFromText(string? markdownText) + { + isApplyingMarkdownDocument = true; + MarkdownEditorControl.Document = MarkdownDocumentUtilities.CreateFlowDocument( + markdownText, + MarkdownEditorControl.FontFamily, + MarkdownEditorControl.FontSize); + ApplyMarkdownTheme(); + ApplyMarkdownWrapSetting(); + SetMargins(MarginsMenuItem.IsChecked is true); + isApplyingMarkdownDocument = false; + } + + private void SyncMarkdownTextFromDocument() + { + if (MarkdownEditorControl.Document is null) + return; + + isSyncingTextFromMarkdown = true; + PassedTextControl.Text = MarkdownDocumentUtilities.SerializeToMarkdown( + MarkdownEditorControl.Document, + preserveLiteralMarkdown: true); + isSyncingTextFromMarkdown = false; + } + + private void ApplyMarkdownTheme() + { + if (MarkdownEditorControl.Document is null) + return; + + MarkdownDocumentUtilities.ApplyTheme( + MarkdownEditorControl.Document, + this, + SystemThemeUtility.IsLightTheme()); + } + + private void ApplyMarkdownWrapSetting() + { + if (MarkdownEditorControl.Document is null) + return; + + if (WrapTextMenuItem.IsChecked) + { + MarkdownEditorControl.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + MarkdownEditorControl.Document.PageWidth = double.NaN; + } + else + { + MarkdownEditorControl.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + MarkdownEditorControl.Document.PageWidth = 4000; + } + } + + private void ReloadMarkdownDocumentAndRestoreCaret(int targetPlainTextOffset) + { + SyncMarkdownTextFromDocument(); + LoadMarkdownDocumentFromText(PassedTextControl.Text); + + if (MarkdownEditorControl.Document is null) + return; + + TextPointer caretPosition = GetMarkdownTextPointerAtPlainTextOffset(targetPlainTextOffset); + MarkdownEditorControl.Selection.Select(caretPosition, caretPosition); + } + + private int GetMarkdownPlainTextOffset(TextPointer position) + { + if (MarkdownEditorControl.Document is null) + return 0; + + return new TextRange(MarkdownEditorControl.Document.ContentStart, position).Text.Length; + } + + private TextPointer GetMarkdownTextPointerAtPlainTextOffset(int targetPlainTextOffset) + { + if (MarkdownEditorControl.Document is null) + return MarkdownEditorControl.CaretPosition; + + TextPointer navigator = MarkdownEditorControl.Document.ContentStart; + TextPointer lastInsertionPosition = navigator; + + while (navigator is not null) + { + int currentOffset = new TextRange(MarkdownEditorControl.Document.ContentStart, navigator).Text.Length; + if (currentOffset >= targetPlainTextOffset) + return navigator; + + lastInsertionPosition = navigator; + TextPointer? next = navigator.GetNextInsertionPosition(LogicalDirection.Forward); + if (next is null) + break; + + navigator = next; + } + + return lastInsertionPosition; + } + + private static T? FindParent(DependencyObject? current) where T : DependencyObject + { + while (current is not null) + { + if (current is T typedParent) + return typedParent; + + current = current switch + { + TextElement textElement => textElement.Parent, + _ => VisualTreeHelper.GetParent(current) + }; + } + + return null; + } + + private void SetEditorMode(EtwEditorMode mode) + { + bool isModeAlreadyApplied = mode switch + { + EtwEditorMode.Spreadsheet => SpreadsheetDataGrid.Visibility == Visibility.Visible + && PassedTextControl.Visibility != Visibility.Visible + && MarkdownEditorControl.Visibility != Visibility.Visible, + EtwEditorMode.Markdown => MarkdownEditorControl.Visibility == Visibility.Visible + && PassedTextControl.Visibility != Visibility.Visible + && SpreadsheetDataGrid.Visibility != Visibility.Visible, + _ => PassedTextControl.Visibility == Visibility.Visible + && SpreadsheetDataGrid.Visibility != Visibility.Visible + && MarkdownEditorControl.Visibility != Visibility.Visible + }; + + if (editorMode == mode && isModeAlreadyApplied) + { + if (mode == EtwEditorMode.Markdown) + ApplyMarkdownTheme(); + + UpdateSpreadsheetModeUi(); + UpdateLineAndColumnText(); + return; + } + + if (mode == EtwEditorMode.Spreadsheet) + { + if (editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.Visibility == Visibility.Visible) + SyncMarkdownTextFromDocument(); + + EnsureSpreadsheetDocumentFromText(); + RebuildSpreadsheetTable(); + PassedTextControl.Visibility = Visibility.Collapsed; + MarkdownEditorControl.Visibility = Visibility.Collapsed; + SpreadsheetDataGrid.Visibility = Visibility.Visible; + editorMode = EtwEditorMode.Spreadsheet; + SpreadsheetDataGrid.Focus(); + } + else if (mode == EtwEditorMode.Markdown) + { + if (editorMode == EtwEditorMode.Spreadsheet && SpreadsheetDataGrid.Visibility == Visibility.Visible) + SyncSpreadsheetDocumentFromTable(); + + LoadMarkdownDocumentFromText(PassedTextControl.Text); + SpreadsheetDataGrid.Visibility = Visibility.Collapsed; + PassedTextControl.Visibility = Visibility.Collapsed; + MarkdownEditorControl.Visibility = Visibility.Visible; + editorMode = EtwEditorMode.Markdown; + MarkdownEditorControl.Focus(); + } + else + { + if (editorMode == EtwEditorMode.Spreadsheet && SpreadsheetDataGrid.Visibility == Visibility.Visible) + SyncSpreadsheetDocumentFromTable(); + else if (editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.Visibility == Visibility.Visible) + SyncMarkdownTextFromDocument(); + + SpreadsheetDataGrid.Visibility = Visibility.Collapsed; + MarkdownEditorControl.Visibility = Visibility.Collapsed; + PassedTextControl.Visibility = Visibility.Visible; + editorMode = EtwEditorMode.Text; + PassedTextControl.Focus(); + } + + UpdateSpreadsheetModeUi(); + UpdateLineAndColumnText(); + } + + private void SpreadsheetDataGrid_BeginningEdit(object sender, DataGridBeginningEditEventArgs e) + { + if (isRestoringSpreadsheetUndoState) + return; + + pendingSpreadsheetUndoState = CreateCurrentSpreadsheetUndoState(syncFromTable: true); + } + + private void SpreadsheetDataGrid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e) + { + if (e.EditAction == DataGridEditAction.Cancel) + { + pendingSpreadsheetUndoState = null; + return; + } + + Dispatcher.BeginInvoke( + () => + { + CaptureCommittedSpreadsheetEditIfPending(); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + + private void SpreadsheetDataGrid_CurrentCellChanged(object sender, EventArgs e) + { + if (editorMode == EtwEditorMode.Spreadsheet) + UpdateLineAndColumnText(); + } + + private void SpreadsheetDataGrid_LoadingRow(object sender, DataGridRowEventArgs e) + { + int rowIndex = e.Row.GetIndex(); + e.Row.Header = (rowIndex + 1).ToString(CultureInfo.InvariantCulture); + e.Row.SizeChanged -= SpreadsheetRow_SizeChanged; + e.Row.SizeChanged += SpreadsheetRow_SizeChanged; + + double? rowHeight = tableDocument?.RowHeights.ElementAtOrDefault(rowIndex); + if (rowHeight.HasValue) + e.Row.Height = rowHeight.Value; + else + e.Row.ClearValue(HeightProperty); + } + + private void SpreadsheetDataGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) + { + if (e.Key == Key.Delete) + { + bool hasMultipleSelectedCells = SpreadsheetDataGrid.SelectedCells.Count > 1; + if (!hasMultipleSelectedCells) + return; + + e.Handled = true; + ClearSelectedSpreadsheetCellValues(); + return; + } + + if (e.Key == Key.C + && (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) + && !IsSpreadsheetCellEditorFocused()) + { + e.Handled = true; + _ = TryCopySpreadsheetSelectionToClipboard(GetSelectedSpreadsheetCellCoordinates()); + return; + } + + if (e.Key == Key.X + && (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) + && !IsSpreadsheetCellEditorFocused()) + { + e.Handled = true; + _ = TryCutSelectedSpreadsheetCellValues(); + return; + } + + if (e.Key == Key.V + && (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) + && !IsSpreadsheetCellEditorFocused()) + { + e.Handled = true; + PasteIntoSpreadsheet(); + return; + } + + } + + private void SpreadsheetDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + spreadsheetContextRowIndex = null; + spreadsheetContextColumnIndex = null; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is not null) + return; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridColumnHeader columnHeader + && columnHeader.Column is DataGridColumn dataGridColumn) + { + SelectSpreadsheetColumn(dataGridColumn.DisplayIndex); + e.Handled = true; + return; + } + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridRowHeader rowHeader + && rowHeader.DataContext is not null) + { + SelectSpreadsheetRow(rowHeader.DataContext); + e.Handled = true; + } + } + + private void SpreadsheetDataGrid_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + spreadsheetContextRowIndex = null; + spreadsheetContextColumnIndex = null; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is not null) + return; + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridColumnHeader columnHeader + && columnHeader.Column is DataGridColumn dataGridColumn) + { + spreadsheetContextColumnIndex = dataGridColumn.DisplayIndex; + SelectSpreadsheetColumn(dataGridColumn.DisplayIndex); + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetColumnHeaderContextMenu") as ContextMenu; + return; + } + + if (FindVisualParent(e.OriginalSource as DependencyObject) is DataGridRowHeader rowHeader + && rowHeader.DataContext is not null) + { + spreadsheetContextRowIndex = SpreadsheetDataGrid.Items.IndexOf(rowHeader.DataContext); + SelectSpreadsheetRow(rowHeader.DataContext); + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetRowHeaderContextMenu") as ContextMenu; + return; + } + + if (FindVisualParent(e.OriginalSource as DependencyObject) is System.Windows.Controls.DataGridCell dataGridCell + && dataGridCell.DataContext is not null + && dataGridCell.Column is DataGridColumn clickedCellColumn) + { + spreadsheetContextRowIndex = SpreadsheetDataGrid.Items.IndexOf(dataGridCell.DataContext); + spreadsheetContextColumnIndex = clickedCellColumn.DisplayIndex; + + bool isCellAlreadySelected = GetSelectedSpreadsheetCellCoordinates().Contains((spreadsheetContextRowIndex.Value, spreadsheetContextColumnIndex.Value)); + SelectSpreadsheetCell(dataGridCell.DataContext, clickedCellColumn, clearExistingSelection: !isCellAlreadySelected); + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetContextMenu") as ContextMenu; + return; + } + + SpreadsheetDataGrid.ContextMenu = FindResource("SpreadsheetContextMenu") as ContextMenu; + } + + private void SpreadsheetDataGrid_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e) + { + UpdateSelectedSpreadsheetCellCoordinates(); + + if (editorMode == EtwEditorMode.Spreadsheet) + UpdateLineAndColumnText(); + } + + private void ClearSelectedSpreadsheetCellValues() + { + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); + + if (selectedCellCoordinates.Count == 0) + return; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? beforeChange = CreateCurrentSpreadsheetUndoState(syncFromTable: true); + + ClearSpreadsheetCellValues(spreadsheetTable, selectedCellCoordinates); + SyncSpreadsheetDocumentFromTable(); + RecordSpreadsheetUndoChange(beforeChange, CreateCurrentSpreadsheetUndoState(syncFromTable: false)); + UpdateLineAndColumnText(); + } + + internal static void ClearSpreadsheetCellValues(DataTable dataTable, IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates) + { + ArgumentNullException.ThrowIfNull(dataTable); + ArgumentNullException.ThrowIfNull(cellCoordinates); + + foreach ((int rowIndex, int columnIndex) in cellCoordinates.Distinct()) + { + if (rowIndex < 0 + || rowIndex >= dataTable.Rows.Count + || columnIndex < 0 + || columnIndex >= dataTable.Columns.Count) + { + continue; + } + + dataTable.Rows[rowIndex][columnIndex] = string.Empty; + } + } + + internal static bool TryCutSpreadsheetCellValues( + DataTable dataTable, + IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates, + Func trySetClipboardText) + { + ArgumentNullException.ThrowIfNull(dataTable); + ArgumentNullException.ThrowIfNull(cellCoordinates); + ArgumentNullException.ThrowIfNull(trySetClipboardText); + + string selectionText = BuildSpreadsheetSelectionText(dataTable, cellCoordinates); + if (string.IsNullOrEmpty(selectionText) || !trySetClipboardText(selectionText)) + return false; + + ClearSpreadsheetCellValues(dataTable, cellCoordinates); + return true; + } + + private void PasteIntoSpreadsheet() + { + string clipboardText; + try + { + if (!ClipboardUtilities.TryGetHtmlTableAsTabSeparated(out clipboardText)) + clipboardText = System.Windows.Clipboard.GetText(); + } + catch (Exception ex) + { + Debug.WriteLine($"PasteIntoSpreadsheet: clipboard read failed. {ex.Message}"); + return; + } + + if (string.IsNullOrEmpty(clipboardText)) + return; + + if (AppUtilities.TextGrabSettings.EtwNormalizeLineEndingsOnPaste) + clipboardText = NormalizeLineEndings(clipboardText); + + int startRow = Math.Max(0, SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem)); + int startCol = Math.Max(0, SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0); + + // Parse clipboard text into a 2D array of cell values + string[] lines = clipboardText.Split('\n'); + List pastedRows = []; + foreach (string line in lines) + pastedRows.Add(line.TrimEnd('\r').Split('\t')); + + // Remove trailing empty row artifact produced by a final newline in copied table text + while (pastedRows.Count > 1 && pastedRows[^1].Length == 1 && pastedRows[^1][0].Length == 0) + pastedRows.RemoveAt(pastedRows.Count - 1); + + if (pastedRows.Count == 0) + return; + + int maxPastedCols = pastedRows.Max(row => row.Length); + + ApplySpreadsheetDocumentChange(document => + { + // Expand the document to fit the pasted data if necessary + int requiredRows = startRow + pastedRows.Count; + int requiredCols = startCol + maxPastedCols; + document.RowCount = Math.Max(document.RowCount, requiredRows); + document.ColumnCount = Math.Max(document.ColumnCount, requiredCols); + document.MinimumRowCount = Math.Max(document.MinimumRowCount, requiredRows); + document.MinimumColumnCount = Math.Max(document.MinimumColumnCount, requiredCols); + document.EnsureMinimumSize(); + + // Write values into the target cells + for (int r = 0; r < pastedRows.Count; r++) + { + int targetRow = startRow + r; + for (int c = 0; c < pastedRows[r].Length; c++) + { + int targetCol = startCol + c; + if (targetRow < document.Rows.Count && targetCol < document.Rows[targetRow].Count) + document.Rows[targetRow][targetCol] = pastedRows[r][c]; + } + } + }, startRow, startCol); + } + + internal static string BuildSpreadsheetSelectionText( + DataTable dataTable, + IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates) + { + ArgumentNullException.ThrowIfNull(dataTable); + ArgumentNullException.ThrowIfNull(cellCoordinates); + + List<(int RowIndex, int ColumnIndex)> validCoordinates = [.. cellCoordinates + .Distinct() + .Where(cell => cell.RowIndex >= 0 + && cell.RowIndex < dataTable.Rows.Count + && cell.ColumnIndex >= 0 + && cell.ColumnIndex < dataTable.Columns.Count)]; + + if (validCoordinates.Count == 0) + return string.Empty; + + return string.Join( + Environment.NewLine, + validCoordinates + .GroupBy(cell => cell.RowIndex) + .OrderBy(group => group.Key) + .Select(group => string.Join( + "\t", + group.OrderBy(cell => cell.ColumnIndex) + .Select(cell => dataTable.Rows[cell.RowIndex][cell.ColumnIndex]?.ToString() ?? string.Empty)))); + } + + internal static List<(int RowIndex, int ColumnIndex)> GetSelectedOrPopulatedSpreadsheetCellCoordinates( + DataTable dataTable, + IEnumerable<(int RowIndex, int ColumnIndex)> selectedCellCoordinates) + { + ArgumentNullException.ThrowIfNull(dataTable); + ArgumentNullException.ThrowIfNull(selectedCellCoordinates); + + List<(int RowIndex, int ColumnIndex)> validSelectedCoordinates = [.. selectedCellCoordinates + .Distinct() + .Where(cell => cell.RowIndex >= 0 + && cell.RowIndex < dataTable.Rows.Count + && cell.ColumnIndex >= 0 + && cell.ColumnIndex < dataTable.Columns.Count)]; + + if (validSelectedCoordinates.Count > 0) + return validSelectedCoordinates; + + List<(int RowIndex, int ColumnIndex)> populatedCoordinates = []; + + for (int rowIndex = 0; rowIndex < dataTable.Rows.Count; rowIndex++) + { + for (int columnIndex = 0; columnIndex < dataTable.Columns.Count; columnIndex++) + { + string cellValue = dataTable.Rows[rowIndex][columnIndex]?.ToString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(cellValue)) + populatedCoordinates.Add((rowIndex, columnIndex)); + } + } + + return populatedCoordinates; + } + + internal static void TransformSpreadsheetDocumentCellValues( + EditTextTableDocument document, + IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates, + Func transform) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(cellCoordinates); + ArgumentNullException.ThrowIfNull(transform); + + document.EnsureMinimumSize(); + + foreach ((int rowIndex, int columnIndex) in cellCoordinates.Distinct()) + { + if (rowIndex < 0 + || rowIndex >= document.Rows.Count + || columnIndex < 0 + || document.Rows[rowIndex] is null + || columnIndex >= document.Rows[rowIndex].Count) + { + continue; + } + + string updatedValue = transform(document.Rows[rowIndex][columnIndex] ?? string.Empty); + ArgumentNullException.ThrowIfNull(updatedValue); + document.Rows[rowIndex][columnIndex] = updatedValue; + } + } + + internal static void SetSpreadsheetDocumentCellValues( + EditTextTableDocument document, + IEnumerable<(int RowIndex, int ColumnIndex, string Value)> cellValues) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(cellValues); + + document.EnsureMinimumSize(); + + foreach ((int rowIndex, int columnIndex, string value) in cellValues.Distinct()) + { + if (rowIndex < 0 + || rowIndex >= document.Rows.Count + || columnIndex < 0 + || document.Rows[rowIndex] is null + || columnIndex >= document.Rows[rowIndex].Count) + { + continue; + } + + document.Rows[rowIndex][columnIndex] = value ?? string.Empty; + } + } + + internal static bool AreSpreadsheetDocumentCellsWrapped( + EditTextTableDocument document, + IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(cellCoordinates); + + document.EnsureMinimumSize(); + + List<(int RowIndex, int ColumnIndex)> validCoordinates = [.. cellCoordinates + .Distinct() + .Where(cell => cell.RowIndex >= 0 + && cell.RowIndex < document.Rows.Count + && cell.ColumnIndex >= 0 + && cell.ColumnIndex < document.ColumnNames.Count)]; + + return validCoordinates.Count > 0 + && validCoordinates.All(cell => document.IsCellWrapped(cell.RowIndex, cell.ColumnIndex)); + } + + internal static void SetSpreadsheetDocumentCellWrapState( + EditTextTableDocument document, + IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates, + bool shouldWrap) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(cellCoordinates); + + document.EnsureMinimumSize(); + + foreach ((int rowIndex, int columnIndex) in cellCoordinates.Distinct()) + document.SetCellWrap(rowIndex, columnIndex, shouldWrap); + } + + internal static void ClearSpreadsheetDocumentRowHeights(EditTextTableDocument document, IEnumerable rowIndices) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(rowIndices); + + document.EnsureMinimumSize(); + + foreach (int rowIndex in rowIndices.Distinct()) + document.SetRowHeight(rowIndex, null); + } + + internal static double? GetSpreadsheetPersistedRowHeight(double rowHeight) + { + if (double.IsNaN(rowHeight) || double.IsInfinity(rowHeight) || rowHeight <= 0) + return null; + + return rowHeight; + } + + private void UpdateSelectedSpreadsheetCellCoordinates() + { + selectedSpreadsheetCellCoordinates = [.. SpreadsheetDataGrid.SelectedCells + .Select(cell => ( + RowIndex: SpreadsheetDataGrid.Items.IndexOf(cell.Item), + ColumnIndex: cell.Column?.DisplayIndex ?? -1)) + .Where(cell => cell.RowIndex >= 0 && cell.ColumnIndex >= 0) + .Distinct()]; + } + + private List<(int RowIndex, int ColumnIndex)> GetSelectedSpreadsheetCellCoordinates() + { + return [.. selectedSpreadsheetCellCoordinates]; + } + + private List<(int RowIndex, int ColumnIndex)> GetSelectedOrCurrentSpreadsheetCellCoordinates() + { + List<(int RowIndex, int ColumnIndex)> selectedCells = GetSelectedSpreadsheetCellCoordinates(); + if (selectedCells.Count > 0) + return selectedCells; + + int rowIndex = spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int columnIndex = spreadsheetContextColumnIndex ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? -1; + + if (rowIndex < 0 || columnIndex < 0) + return []; + + return [(rowIndex, columnIndex)]; + } + + private List<(int RowIndex, int ColumnIndex)> GetSelectedOrPopulatedSpreadsheetCellCoordinates() + { + return GetSelectedOrPopulatedSpreadsheetCellCoordinates(spreadsheetTable, GetSelectedSpreadsheetCellCoordinates()); + } + + private IEnumerable GetSelectedOrPopulatedSpreadsheetCellTexts() + { + foreach ((int rowIndex, int columnIndex) in GetSelectedOrPopulatedSpreadsheetCellCoordinates()) + yield return spreadsheetTable.Rows[rowIndex][columnIndex]?.ToString() ?? string.Empty; + } + + private bool TryApplySpreadsheetTextTransform(Func transform) + { + ArgumentNullException.ThrowIfNull(transform); + + if (editorMode != EtwEditorMode.Spreadsheet) + return false; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + EnsureSpreadsheetDocumentFromText(); + + if (tableDocument is null) + return true; + + List<(int RowIndex, int ColumnIndex)> targetCells = GetSelectedOrPopulatedSpreadsheetCellCoordinates(); + if (targetCells.Count == 0) + return true; + + int focusRow = Math.Max(0, SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem)); + int focusColumn = Math.Max(0, SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0); + + ApplySpreadsheetDocumentChange( + document => TransformSpreadsheetDocumentCellValues(document, targetCells, transform), + focusRow, + focusColumn); + UpdateLineAndColumnText(); + return true; + } + + private async Task TryApplySpreadsheetTextTransformAsync(Func> transformAsync) + { + ArgumentNullException.ThrowIfNull(transformAsync); + + if (editorMode != EtwEditorMode.Spreadsheet) + return false; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + EnsureSpreadsheetDocumentFromText(); + + if (tableDocument is null) + return true; + + List<(int RowIndex, int ColumnIndex)> targetCells = GetSelectedOrPopulatedSpreadsheetCellCoordinates(); + if (targetCells.Count == 0) + return true; + + int focusRow = Math.Max(0, SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem)); + int focusColumn = Math.Max(0, SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0); + List<(int RowIndex, int ColumnIndex, string Value)> transformedCells = []; + + foreach ((int rowIndex, int columnIndex) in targetCells) + { + if (rowIndex < 0 + || rowIndex >= tableDocument.Rows.Count + || columnIndex < 0 + || columnIndex >= tableDocument.Rows[rowIndex].Count) + { + continue; + } + + string updatedValue = await transformAsync(tableDocument.Rows[rowIndex][columnIndex] ?? string.Empty); + ArgumentNullException.ThrowIfNull(updatedValue); + transformedCells.Add((rowIndex, columnIndex, updatedValue)); + } + + ApplySpreadsheetDocumentChange( + document => SetSpreadsheetDocumentCellValues(document, transformedCells), + focusRow, + focusColumn); + UpdateLineAndColumnText(); + return true; + } + + private void SpreadsheetUndoCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + e.CanExecute = spreadsheetUndoHistory.CanUndo; + e.Handled = true; + } + + private void SpreadsheetCopyCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + e.CanExecute = GetSelectedSpreadsheetCellCoordinates().Count > 0; + e.Handled = true; + } + + private void SpreadsheetPasteCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + e.CanExecute = true; + e.Handled = true; + } + + private void SpreadsheetRedoCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + e.CanExecute = spreadsheetUndoHistory.CanRedo; + e.Handled = true; + } + + private void SpreadsheetUndoExecuted(object sender, ExecutedRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? previousState = spreadsheetUndoHistory.Undo(CreateCurrentSpreadsheetUndoState(syncFromTable: true)); + if (previousState is null) + return; + + RestoreSpreadsheetUndoState(previousState); + CommandManager.InvalidateRequerySuggested(); + e.Handled = true; + } + + private void SpreadsheetRedoExecuted(object sender, ExecutedRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? nextState = spreadsheetUndoHistory.Redo(CreateCurrentSpreadsheetUndoState(syncFromTable: true)); + if (nextState is null) + return; + + RestoreSpreadsheetUndoState(nextState); + CommandManager.InvalidateRequerySuggested(); + e.Handled = true; + } + + private void SpreadsheetCopyExecuted(object sender, ExecutedRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + _ = TryCopySpreadsheetSelectionToClipboard(GetSelectedSpreadsheetCellCoordinates()); + e.Handled = true; + } + + private void SpreadsheetCutExecuted(object sender, ExecutedRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + _ = TryCutSelectedSpreadsheetCellValues(); + e.Handled = true; + } + + private void SpreadsheetPasteExecuted(object sender, ExecutedRoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Spreadsheet || IsSpreadsheetCellEditorFocused()) + return; + + PasteIntoSpreadsheet(); + e.Handled = true; + } + + private bool IsSpreadsheetCellEditorFocused() + { + if (Keyboard.FocusedElement is not DependencyObject focusedElement) + return false; + + return FindVisualParent(focusedElement) is not null + && FindVisualParent(focusedElement) is not null; + } + + private void SpreadsheetColumnWidthChanged(object? sender, EventArgs e) + { + if (isApplyingSpreadsheetLayout || tableDocument is null || sender is not DataGridColumn column) + return; + + int columnIndex = SpreadsheetDataGrid.Columns.IndexOf(column); + if (columnIndex < 0) + return; + + double width = column.ActualWidth > 0 ? column.ActualWidth : column.Width.DisplayValue; + tableDocument.SetColumnWidth(columnIndex, width); + } + + private void SpreadsheetRow_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (isApplyingSpreadsheetLayout || tableDocument is null || sender is not DataGridRow row) + return; + + int rowIndex = row.GetIndex(); + if (rowIndex < 0) + return; + + double? height = GetSpreadsheetPersistedRowHeight(row.Height); + if (!height.HasValue) + return; + + tableDocument.SetRowHeight(rowIndex, height.Value); + } + + private void EditorModeMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender == RawTextModeMenuItem) + SetEditorMode(EtwEditorMode.Text); + else if (sender == SpreadsheetModeMenuItem) + SetEditorMode(EtwEditorMode.Spreadsheet); + else if (sender == MarkdownModeMenuItem) + SetEditorMode(EtwEditorMode.Markdown); + } + + private void ToggleMenuItem(MenuItem menuItem, RoutedEventHandler handler) + { + menuItem.IsChecked = !menuItem.IsChecked; + handler(menuItem, new RoutedEventArgs()); + } + + private void EnterRawTextMode_Click(object sender, RoutedEventArgs e) => SetEditorMode(EtwEditorMode.Text); + + private void EnterSpreadsheetMode_Click(object sender, RoutedEventArgs e) => SetEditorMode(EtwEditorMode.Spreadsheet); + + private void EnterMarkdownMode_Click(object sender, RoutedEventArgs e) => SetEditorMode(EtwEditorMode.Markdown); + + private void ToggleAlwaysOnTop_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(AlwaysOnTop, AlwaysOnTop_Checked); + + private void ToggleHideBottomBar_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(HideBottomBarMenuItem, HideBottomBarMenuItem_Click); + + private void ToggleLaunchFullscreenOnLoad_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(LaunchFullscreenOnLoad, LaunchFullscreenOnLoad_Click); + + private void ToggleRestorePosition_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(RestorePositionMenuItem, RestorePositionMenuItem_Checked); + + private void ToggleMargins_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(MarginsMenuItem, MarginsMenuItem_Checked); + + private void ToggleWrapText_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(WrapTextMenuItem, WrapTextCHBX_Checked); + + private void ToggleShowMathErrors_Click(object sender, RoutedEventArgs e) => ToggleMenuItem(ShowErrorsMenuItem, ShowErrorsMenuItem_Click); + + private void ToggleWriteTxtFileForEachImage_Click(object sender, RoutedEventArgs e) + { + ReadFolderOfImagesWriteTxtFiles.IsChecked = !ReadFolderOfImagesWriteTxtFiles.IsChecked; + } + + private void SyncSpreadsheetDocumentFromTable(bool writeText = true) + { + tableDocument ??= EditTextTableDocument.CreateFromText(PassedTextControl.Text); + + tableDocument.ColumnNames = [.. spreadsheetTable.Columns + .Cast() + .Select(column => column.ColumnName)]; + + tableDocument.Rows = [.. spreadsheetTable.Rows + .Cast() + .Select(row => spreadsheetTable.Columns + .Cast() + .Select(column => row[column]?.ToString() ?? string.Empty) + .ToList())]; + + int furthestNonEmptyRowIndex = -1; + int furthestNonEmptyColumnIndex = -1; + + for (int rowIndex = 0; rowIndex < tableDocument.Rows.Count; rowIndex++) + { + for (int columnIndex = 0; columnIndex < tableDocument.Rows[rowIndex].Count; columnIndex++) + { + if (string.IsNullOrWhiteSpace(tableDocument.Rows[rowIndex][columnIndex])) + continue; + + furthestNonEmptyRowIndex = Math.Max(furthestNonEmptyRowIndex, rowIndex); + furthestNonEmptyColumnIndex = Math.Max(furthestNonEmptyColumnIndex, columnIndex); + } + } + + tableDocument.RowCount = Math.Max(tableDocument.RowCount, furthestNonEmptyRowIndex + 1); + tableDocument.ColumnCount = Math.Max(tableDocument.ColumnCount, furthestNonEmptyColumnIndex + 1); + tableDocument.MinimumColumnCount = Math.Max(tableDocument.MinimumColumnCount, spreadsheetTable.Columns.Count); + tableDocument.MinimumRowCount = Math.Max(tableDocument.MinimumRowCount, spreadsheetTable.Rows.Count); + CaptureSpreadsheetLayoutMetrics(); + tableDocument.EnsureMinimumSize(); + + if (writeText) + UpdateTextFromSpreadsheetDocument(); + } + + private void CaptureSpreadsheetLayoutMetrics() + { + if (tableDocument is null) + return; + + for (int columnIndex = 0; columnIndex < SpreadsheetDataGrid.Columns.Count; columnIndex++) + { + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + double width = column.ActualWidth > 0 ? column.ActualWidth : column.Width.DisplayValue; + tableDocument.SetColumnWidth(columnIndex, width); + } + + foreach (object item in SpreadsheetDataGrid.Items) + { + if (item == CollectionView.NewItemPlaceholder) + continue; + + if (SpreadsheetDataGrid.ItemContainerGenerator.ContainerFromItem(item) is not DataGridRow row) + continue; + + int rowIndex = row.GetIndex(); + if (rowIndex < 0) + continue; + + double? height = GetSpreadsheetPersistedRowHeight(row.Height); + tableDocument.SetRowHeight(rowIndex, height); + } + } + + private void DetachSpreadsheetColumnWidthTracking() + { + foreach (DataGridColumn column in trackedSpreadsheetColumns) + DependencyPropertyDescriptor.FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn))?.RemoveValueChanged(column, SpreadsheetColumnWidthChanged); + + trackedSpreadsheetColumns.Clear(); + } + + private void TrackSpreadsheetColumnWidth(DataGridColumn column) + { + trackedSpreadsheetColumns.Add(column); + DependencyPropertyDescriptor.FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn))?.AddValueChanged(column, SpreadsheetColumnWidthChanged); + } + + private bool TrySetClipboardText(string text) + { + try + { + System.Windows.Clipboard.SetDataObject(text, true); + return true; + } + catch + { + return false; + } + } + + private bool TryCopySpreadsheetSelectionToClipboard(IEnumerable<(int RowIndex, int ColumnIndex)> cellCoordinates) + { + string selectionText = BuildSpreadsheetSelectionText(spreadsheetTable, cellCoordinates); + return !string.IsNullOrEmpty(selectionText) && TrySetClipboardText(selectionText); + } + + private bool TryCutSelectedSpreadsheetCellValues() + { + List<(int RowIndex, int ColumnIndex)> selectedCellCoordinates = GetSelectedSpreadsheetCellCoordinates(); + if (selectedCellCoordinates.Count == 0) + return false; + + CommitSpreadsheetEditsAndCapturePendingHistory(); + SpreadsheetUndoState? beforeChange = CreateCurrentSpreadsheetUndoState(syncFromTable: true); + + if (!TryCutSpreadsheetCellValues(spreadsheetTable, selectedCellCoordinates, TrySetClipboardText)) + return false; + + SyncSpreadsheetDocumentFromTable(); + RecordSpreadsheetUndoChange(beforeChange, CreateCurrentSpreadsheetUndoState(syncFromTable: false)); + UpdateLineAndColumnText(); + return true; + } + + private void UpdateSpreadsheetModeUi() + { + bool isSpreadsheetMode = editorMode == EtwEditorMode.Spreadsheet; + bool isMarkdownMode = editorMode == EtwEditorMode.Markdown; + + AddSpreadsheetRowButton.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + AddSpreadsheetColumnButton.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + AddSpreadsheetRowMenuItem.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + AddSpreadsheetColumnMenuItem.Visibility = isSpreadsheetMode ? Visibility.Visible : Visibility.Collapsed; + RawTextModeMenuItem.IsChecked = editorMode == EtwEditorMode.Text; + SpreadsheetModeMenuItem.IsChecked = isSpreadsheetMode; + MarkdownModeMenuItem.IsChecked = isMarkdownMode; + CommandManager.InvalidateRequerySuggested(); + } + + private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject + { + while (child is not null) + { + if (child is T matchingParent) + return matchingParent; + + child = VisualTreeHelper.GetParent(child); + } + + return null; + } + + private System.Windows.Data.Binding CreateSpreadsheetCellTextWrappingBinding(int columnIndex) + { + return new System.Windows.Data.Binding + { + Converter = new SpreadsheetCellTextWrappingConverter(this, columnIndex), + Mode = BindingMode.OneWay + }; + } + + private Style CreateSpreadsheetDisplayTextStyle(int columnIndex) + { + Style style = new(typeof(TextBlock)); + style.Setters.Add(new Setter(TextBlock.TextWrappingProperty, CreateSpreadsheetCellTextWrappingBinding(columnIndex))); + style.Setters.Add(new Setter(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Top)); + return style; + } + + private Style CreateSpreadsheetEditingTextStyle(int columnIndex) + { + Style style = new(typeof(System.Windows.Controls.TextBox)); + style.Setters.Add(new Setter(System.Windows.Controls.TextBox.TextWrappingProperty, CreateSpreadsheetCellTextWrappingBinding(columnIndex))); + style.Setters.Add(new Setter(System.Windows.Controls.TextBox.AcceptsReturnProperty, false)); + style.Setters.Add(new Setter(System.Windows.Controls.TextBox.VerticalContentAlignmentProperty, VerticalAlignment.Top)); + return style; + } + + private TextWrapping GetSpreadsheetCellTextWrapping(object? rowItem, int columnIndex) + { + if (tableDocument is null || rowItem is not DataRowView dataRowView) + return TextWrapping.NoWrap; + + int rowIndex = dataRowView.Row.Table.Rows.IndexOf(dataRowView.Row); + if (rowIndex < 0) + return TextWrapping.NoWrap; + + return tableDocument.IsCellWrapped(rowIndex, columnIndex) + ? TextWrapping.Wrap + : TextWrapping.NoWrap; + } + + private static MenuItem? GetContextMenuItem(ContextMenu contextMenu, string itemTag) + { + return contextMenu.Items + .OfType() + .FirstOrDefault(item => string.Equals(item.Tag as string, itemTag, StringComparison.Ordinal)); + } + + private void SelectSpreadsheetColumn(int columnIndex) + { + if (columnIndex < 0 || columnIndex >= SpreadsheetDataGrid.Columns.Count) + return; + + SpreadsheetDataGrid.SelectedItems.Clear(); + SpreadsheetDataGrid.SelectedCells.Clear(); + + DataGridColumn column = SpreadsheetDataGrid.Columns[columnIndex]; + object? firstRowItem = null; + + foreach (object item in SpreadsheetDataGrid.Items) + { + if (ReferenceEquals(item, CollectionView.NewItemPlaceholder)) + continue; + + firstRowItem ??= item; + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(item, column)); + } + + if (firstRowItem is not null) + { + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(firstRowItem, column); + SpreadsheetDataGrid.ScrollIntoView(firstRowItem, column); + } + + UpdateSelectedSpreadsheetCellCoordinates(); + SpreadsheetDataGrid.Focus(); + UpdateLineAndColumnText(); + } + + private void SelectSpreadsheetCell(object rowItem, DataGridColumn column, bool clearExistingSelection) + { + if (clearExistingSelection) + { + SpreadsheetDataGrid.SelectedItems.Clear(); + SpreadsheetDataGrid.SelectedCells.Clear(); + } + + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, column); + + if (!SpreadsheetDataGrid.SelectedCells.Any(cell => ReferenceEquals(cell.Item, rowItem) && cell.Column == column)) + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); + + SpreadsheetDataGrid.ScrollIntoView(rowItem, column); + UpdateSelectedSpreadsheetCellCoordinates(); + SpreadsheetDataGrid.Focus(); + UpdateLineAndColumnText(); + } + + private void SelectSpreadsheetRow(object rowItem) + { + SpreadsheetDataGrid.SelectedItems.Clear(); + SpreadsheetDataGrid.SelectedCells.Clear(); + + if (SpreadsheetDataGrid.Columns.Count == 0) + return; + + DataGridColumn firstColumn = SpreadsheetDataGrid.Columns[0]; + foreach (DataGridColumn column in SpreadsheetDataGrid.Columns) + SpreadsheetDataGrid.SelectedCells.Add(new DataGridCellInfo(rowItem, column)); + + SpreadsheetDataGrid.CurrentCell = new DataGridCellInfo(rowItem, firstColumn); + SpreadsheetDataGrid.ScrollIntoView(rowItem, firstColumn); + UpdateSelectedSpreadsheetCellCoordinates(); + SpreadsheetDataGrid.Focus(); + UpdateLineAndColumnText(); + } + + private void UpdateTextFromSpreadsheetDocument() + { + if (tableDocument is null) + return; + + isSyncingTextFromSpreadsheet = true; + PassedTextControl.Text = tableDocument.SerializeToText(); + isSyncingTextFromSpreadsheet = false; + } + internal HistoryInfo AsHistoryItem() { + if (editorMode == EtwEditorMode.Spreadsheet) + SyncSpreadsheetDocumentFromTable(); + else if (editorMode == EtwEditorMode.Markdown) + SyncMarkdownTextFromDocument(); + int calcPaneWidth = 0; if (ShowCalcPaneMenuItem.IsChecked is true && CalcColumn.Width.Value > 0) { @@ -421,7 +2089,9 @@ internal HistoryInfo AsHistoryItem() TextContent = PassedTextControl.Text, SourceMode = TextGrabMode.EditText, CalcPaneWidth = calcPaneWidth, - HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true + HasCalcPaneOpen = ShowCalcPaneMenuItem.IsChecked is true, + EditorMode = editorMode, + EditTextTableDocumentJson = tableDocument?.SerializeToJson() }; if (string.IsNullOrWhiteSpace(historyInfo.ID)) @@ -430,6 +2100,75 @@ internal HistoryInfo AsHistoryItem() return historyInfo; } + internal static string GetWindowTitle(string? openedFilePath, bool hasPendingEdits) + { + if (string.IsNullOrWhiteSpace(openedFilePath)) + return EditTextWindowTitle; + + string fileName = Path.GetFileName(openedFilePath); + if (hasPendingEdits) + fileName = $"*{fileName}"; + + return $"{EditTextWindowTitle} | {fileName}"; + } + + internal static bool ShouldShowPendingFileEdits(string? openedFilePath, string savedText, string currentText) + { + return !string.IsNullOrWhiteSpace(openedFilePath) + && !string.Equals(savedText, currentText, StringComparison.Ordinal); + } + + internal static string GetDefaultSaveExtension(string? openedFilePath, EtwEditorMode editorMode, EditTextTableDocument? tableDocument) + { + string existingExtension = Path.GetExtension(openedFilePath ?? string.Empty); + if (!string.IsNullOrWhiteSpace(existingExtension)) + return existingExtension; + + return editorMode switch + { + EtwEditorMode.Spreadsheet => GetSpreadsheetSaveExtension(tableDocument), + EtwEditorMode.Markdown => ".md", + _ => ".txt" + }; + } + + internal static int GetSaveDocumentFilterIndex(string? openedFilePath, EtwEditorMode editorMode) + { + string existingExtension = Path.GetExtension(openedFilePath ?? string.Empty); + if (IoUtilities.IsSpreadsheetFileExtension(existingExtension)) + return 1; + + if (IoUtilities.IsMarkdownFileExtension(existingExtension)) + return 2; + + if (string.Equals(existingExtension, ".txt", StringComparison.OrdinalIgnoreCase)) + return 3; + + if (!string.IsNullOrWhiteSpace(existingExtension)) + return 4; + + return editorMode switch + { + EtwEditorMode.Spreadsheet => 1, + EtwEditorMode.Markdown => 2, + _ => 3 + }; + } + + private static string GetSpreadsheetSaveExtension(EditTextTableDocument? tableDocument) + { + if (tableDocument is null) + return ".tsv"; + + return tableDocument.Format switch + { + EtwStructuredTextFormat.Csv => ".csv", + EtwStructuredTextFormat.Tsv => ".tsv", + EtwStructuredTextFormat.DelimitedText when string.Equals(tableDocument.Delimiter, ",", StringComparison.Ordinal) => ".csv", + _ => ".tsv" + }; + } + internal void LimitNumberOfCharsPerLine(int numberOfChars, SpotInLine spotInLine) { PassedTextControl.Text = PassedTextControl.Text.LimitCharactersPerLine(numberOfChars, spotInLine); @@ -437,16 +2176,41 @@ internal void LimitNumberOfCharsPerLine(int numberOfChars, SpotInLine spotInLine internal async void OpenPath(string pathOfFileToOpen, bool isMultipleFiles = false) { - OpenedFilePath = pathOfFileToOpen; - + ResetSpreadsheetUndoHistory(); (string TextContent, OpenContentKind KindOpened) = await IoUtilities.GetContentFromPath(pathOfFileToOpen, isMultipleFiles, selectedILanguage); + bool shouldTrackOpenedFile = KindOpened == OpenContentKind.TextFile && !isMultipleFiles; - if (KindOpened == OpenContentKind.TextFile - && !isMultipleFiles - && !string.IsNullOrWhiteSpace(TextContent)) - UiTitleBar.Title = $"Edit Text | {Path.GetFileName(OpenedFilePath)}"; + if (KindOpened == OpenContentKind.TextFile) + { + EtwEditorMode targetMode = isMultipleFiles + ? EtwEditorMode.Text + : IoUtilities.GetEditorModeForPath(pathOfFileToOpen); + + if (IsLoaded) + SetEditorMode(targetMode); + else + editorMode = targetMode; + } + + isLoadingOpenedFile = true; + try + { + PassedTextControl.Text = TextContent; + + if (!IsLoaded) + return; - PassedTextControl.AppendText(TextContent); + if (editorMode == EtwEditorMode.Spreadsheet) + RefreshSpreadsheetFromText(); + else if (editorMode == EtwEditorMode.Markdown) + RefreshMarkdownFromText(); + } + finally + { + isLoadingOpenedFile = false; + SyncTextFromActiveEditor(); + SetOpenedFileState(shouldTrackOpenedFile ? pathOfFileToOpen : null); + } } private void AboutMenuItem_Click(object sender, RoutedEventArgs e) @@ -454,8 +2218,14 @@ private void AboutMenuItem_Click(object sender, RoutedEventArgs e) WindowUtilities.OpenOrActivateWindow(); } + private static string NormalizeLineEndings(string text) => + text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n"); + private void AddCopiedTextToTextBox(string textToAdd) { + if (AppUtilities.TextGrabSettings.EtwNormalizeLineEndingsOnPaste) + textToAdd = NormalizeLineEndings(textToAdd); + PassedTextControl.SelectedText = textToAdd; int currentSelectionIndex = PassedTextControl.SelectionStart; int currentSelectionLength = PassedTextControl.SelectionLength; @@ -571,6 +2341,15 @@ private void AddRemoveAtMenuItem_Click(object sender, RoutedEventArgs e) addRemoveWindow.ShowDialog(); } + private void JoinLinesMenuItem_Click(object sender, RoutedEventArgs e) + { + JoinLinesWindow joinLinesWindow = new() + { + Owner = this + }; + joinLinesWindow.ShowDialog(); + } + private void AlwaysOnTop_Checked(object sender, RoutedEventArgs e) { if (!IsLoaded) @@ -919,6 +2698,137 @@ public string GetSelectedTextOrAllText() return textToModify; } + internal IEnumerable GetSelectedOrAllTextSegmentsForPreview() + { + if (editorMode == EtwEditorMode.Spreadsheet) + return GetSelectedOrPopulatedSpreadsheetCellTexts(); + + return [GetSelectedTextOrAllText()]; + } + + public bool IsSpreadsheetMode => editorMode == EtwEditorMode.Spreadsheet; + + public void CommitSpreadsheetAndSync() + { + CommitSpreadsheetEditsAndCapturePendingHistory(); + SyncSpreadsheetDocumentFromTable(writeText: false); + } + + public void NavigateToSpreadsheetCell(int rowIndex, int columnIndex) + { + Dispatcher.BeginInvoke( + () => FocusSpreadsheetCell(rowIndex, columnIndex, beginEdit: false), + DispatcherPriority.Background); + } + + public List SearchSpreadsheetCells(Regex pattern) + { + if (tableDocument is null) return []; + tableDocument.EnsureMinimumSize(); + List results = []; + int count = 1; + + for (int row = 0; row < tableDocument.RowCount; row++) + { + List rowData = tableDocument.Rows[row]; + for (int col = 0; col < tableDocument.ColumnCount; col++) + { + string cellValue = col < rowData.Count ? rowData[col] ?? string.Empty : string.Empty; + foreach (Match m in pattern.Matches(cellValue)) + { + int previewStart = Math.Max(0, m.Index - 12); + int previewEnd = Math.Min(cellValue.Length, m.Index + m.Length + 12); + results.Add(new FindResult + { + RowIndex = row, + ColumnIndex = col, + Index = m.Index, + Text = TextSearchUtilities.FormatMatchTextForDisplay(m.Value), + PreviewLeft = cellValue[previewStart..m.Index], + PreviewRight = cellValue[(m.Index + m.Length)..previewEnd], + Length = m.Length, + Count = count++ + }); + } + } + } + return results; + } + + public void ReplaceInSpreadsheetCells( + IEnumerable targets, + string replaceWith, + Regex pattern) + { + CommitSpreadsheetEditsAndCapturePendingHistory(); + SyncSpreadsheetDocumentFromTable(writeText: false); + + if (tableDocument is null) return; + + SpreadsheetUndoState? beforeState = CreateCurrentSpreadsheetUndoState(syncFromTable: false); + + IEnumerable<(int RowIndex, int ColumnIndex, string Value)> updates = targets + .Where(r => r.RowIndex.HasValue && r.ColumnIndex.HasValue) + .GroupBy(r => (r.RowIndex!.Value, r.ColumnIndex!.Value)) + .Select(g => + { + int row = g.Key.Item1, col = g.Key.Item2; + string oldValue = row < tableDocument.Rows.Count && col < tableDocument.Rows[row].Count + ? tableDocument.Rows[row][col] ?? string.Empty : string.Empty; + + HashSet indicesToReplace = [.. g.Select(r => r.Index)]; + string newValue = pattern.Replace(oldValue, m => + indicesToReplace.Contains(m.Index) ? m.Result(replaceWith) : m.Value); + + return (RowIndex: row, ColumnIndex: col, Value: newValue); + }); + + SetSpreadsheetDocumentCellValues(tableDocument, updates); + RebuildSpreadsheetTable(); + UpdateTextFromSpreadsheetDocument(); + RecordSpreadsheetUndoChange(beforeState, CreateCurrentSpreadsheetUndoState(syncFromTable: false)); + } + + private IEnumerable GetSelectedOrAllTextSegmentsForEdit() + { + if (editorMode == EtwEditorMode.Spreadsheet) + return GetSelectedOrPopulatedSpreadsheetCellTexts(); + + return [GetSelectedTextOrAllText()]; + } + + private void ReplaceSelectedTextOrAllText(string updatedText) + { + ArgumentNullException.ThrowIfNull(updatedText); + + if (PassedTextControl.SelectionLength == 0) + PassedTextControl.Text = updatedText; + else + PassedTextControl.SelectedText = updatedText; + } + + private void ApplySelectedTextOrAllTextTransform(Func transform) + { + ArgumentNullException.ThrowIfNull(transform); + + if (TryApplySpreadsheetTextTransform(transform)) + return; + + string updatedText = transform(GetSelectedTextOrAllText()); + ReplaceSelectedTextOrAllText(updatedText); + } + + private async Task ApplySelectedTextOrAllTextTransformAsync(Func> transformAsync) + { + ArgumentNullException.ThrowIfNull(transformAsync); + + if (await TryApplySpreadsheetTextTransformAsync(transformAsync)) + return; + + string updatedText = await transformAsync(GetSelectedTextOrAllText()); + ReplaceSelectedTextOrAllText(updatedText); + } + private void GrabFrameMenuItem_Click(object sender, RoutedEventArgs e) { CheckForGrabFrameOrLaunch(); @@ -949,6 +2859,40 @@ private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e) } } + private IntPtr EditTextWindowMessageHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg != WmMouseHWheel || Keyboard.Modifiers == ModifierKeys.Control) + return IntPtr.Zero; + + ScrollViewer? scrollViewer = GetHorizontalPanTargetScrollViewer(); + if (scrollViewer is null || scrollViewer.ScrollableWidth <= 0) + return IntPtr.Zero; + + short delta = unchecked((short)((wParam.ToInt64() >> 16) & 0xFFFF)); + double deltaSteps = delta / 120.0; + if (NumericUtilities.AreClose(deltaSteps, 0)) + return IntPtr.Zero; + + double targetOffset = scrollViewer.HorizontalOffset + (deltaSteps * HorizontalWheelScrollStep); + scrollViewer.ScrollToHorizontalOffset(Math.Clamp(targetOffset, 0, scrollViewer.ScrollableWidth)); + handled = true; + return IntPtr.Zero; + } + + private ScrollViewer? GetHorizontalPanTargetScrollViewer() + { + if (editorMode == EtwEditorMode.Spreadsheet && SpreadsheetDataGrid.Visibility == Visibility.Visible) + return WindowUtilities.GetScrollViewer(SpreadsheetDataGrid); + + if (editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.Visibility == Visibility.Visible) + return WindowUtilities.GetScrollViewer(MarkdownEditorControl); + + if (CalcResultsTextControl.Visibility == Visibility.Visible && CalcResultsTextControl.IsMouseOver) + return WindowUtilities.GetScrollViewer(CalcResultsTextControl); + + return WindowUtilities.GetScrollViewer(PassedTextControl); + } + // Keep calc pane scroll in sync with main text box private void PassedTextControl_ScrollChanged(object sender, ScrollChangedEventArgs e) { @@ -1217,6 +3161,7 @@ private void LoadGrabTemplateMenuItems(MenuItem grabTemplateMenuItem) Header = "(None)", IsCheckable = true, IsChecked = previouslySelected is null, + StaysOpenOnClick = true, }; noneItem.Click += GrabTemplateMenuItem_Click; grabTemplateMenuItem.Items.Add(noneItem); @@ -1229,6 +3174,7 @@ private void LoadGrabTemplateMenuItems(MenuItem grabTemplateMenuItem) IsCheckable = true, IsChecked = previouslySelected?.Id == template.Id, Tag = template, + StaysOpenOnClick = true, }; templateMenuItem.Click += GrabTemplateMenuItem_Click; grabTemplateMenuItem.Items.Add(templateMenuItem); @@ -1314,7 +3260,7 @@ private async void LoadLanguageMenuItems(MenuItem captureMenuItem) bool usingTesseract = DefaultSettings.UseTesseract && TesseractHelper.CanLocateTesseractExe(); List availableLanguages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(usingTesseract); - availableLanguages = availableLanguages.Where(CaptureLanguageUtilities.IsStaticImageCompatible).ToList(); + availableLanguages = [.. availableLanguages.Where(CaptureLanguageUtilities.IsStaticImageCompatible)]; int selectedIndex = CaptureLanguageUtilities.FindPreferredLanguageIndex( availableLanguages, DefaultSettings.LastUsedLang, @@ -1329,6 +3275,7 @@ private async void LoadLanguageMenuItems(MenuItem captureMenuItem) Tag = language, IsCheckable = true, IsChecked = i == selectedIndex, + StaysOpenOnClick = true, }; languageMenuItem.Click += LanguageMenuItem_Click; captureMenuItem.Items.Add(languageMenuItem); @@ -1340,7 +3287,7 @@ private void LoadRecentTextHistory() List grabsHistories = Singleton.Instance.GetEditWindows(); grabsHistories = [.. grabsHistories.OrderByDescending(x => x.CaptureDateTime)]; - OpenRecentMenuItem.Items.Clear(); + ClearRecentTextMenuItems(); if (grabsHistories.Count < 1) { @@ -1348,36 +3295,94 @@ private void LoadRecentTextHistory() return; } + OpenRecentMenuItem.IsEnabled = true; + foreach (HistoryInfo history in grabsHistories) { - MenuItem menuItem = new(); - string historyId = history.ID; - menuItem.Click += (sender, args) => - { - HistoryInfo? selectedHistory = Singleton.Instance.GetTextHistoryById(historyId); + MenuItem menuItem = new() { Tag = history.ID }; + menuItem.Click += RecentTextHistoryMenuItem_Click; - if (selectedHistory is null) - { - menuItem.IsEnabled = false; - return; - } + if (PassedTextControl.Text == history.TextContent) + menuItem.IsEnabled = false; - if (string.IsNullOrWhiteSpace(PassedTextControl.Text)) + string snippet = history.TextContent.Trim().Replace("\t", " ").MakeStringSingleLine().Truncate(40); + menuItem.Header = $"{history.CaptureDateTime.Humanize().Trim()} | {snippet}"; + menuItem.Icon = new SymbolIcon + { + Symbol = history.EditorMode switch { - PassedTextControl.Text = selectedHistory.TextContent; - return; + EtwEditorMode.Spreadsheet => SymbolRegular.Table24, + EtwEditorMode.Markdown => SymbolRegular.Markdown20, + _ => SymbolRegular.TextT24, } - - EditTextWindow etw = new(selectedHistory); - etw.Show(); }; + OpenRecentMenuItem.Items.Add(menuItem); + } + } - if (PassedTextControl.Text == history.TextContent) - menuItem.IsEnabled = false; + private void RecentTextHistoryMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem || menuItem.Tag is not string historyId) + return; - menuItem.Header = $"{history.CaptureDateTime.Humanize()} | {history.TextContent.MakeStringSingleLine().Truncate(20)}"; - OpenRecentMenuItem.Items.Add(menuItem); + HistoryInfo? selectedHistory = Singleton.Instance.GetTextHistoryById(historyId); + + if (selectedHistory is null) + { + menuItem.IsEnabled = false; + return; + } + + if (string.IsNullOrWhiteSpace(PassedTextControl.Text)) + { + ResetSpreadsheetUndoHistory(); + PassedTextControl.Text = selectedHistory.TextContent; + tableDocument = EditTextTableDocument.TryDeserialize(selectedHistory.EditTextTableDocumentJson); + editorMode = selectedHistory.EditorMode; + SetEditorMode(editorMode); + return; + } + + EditTextWindow etw = new(selectedHistory); + etw.Show(); + } + + private void ClearRecentTextMenuItems() + { + foreach (object item in OpenRecentMenuItem.Items) + { + if (item is MenuItem oldItem) + oldItem.Click -= RecentTextHistoryMenuItem_Click; + } + OpenRecentMenuItem.Items.Clear(); + } + + private void ClearLanguageMenuItems() + { + foreach (object item in LanguageMenuItem.Items) + { + if (item is MenuItem oldItem) + oldItem.Click -= LanguageMenuItem_Click; + } + LanguageMenuItem.Items.Clear(); + } + + private void ClearGrabTemplateMenuItems() + { + foreach (object item in GrabTemplateMenuItem.Items) + { + if (item is MenuItem oldItem) + oldItem.Click -= GrabTemplateMenuItem_Click; } + GrabTemplateMenuItem.Items.Clear(); + } + + private void ClearDynamicMenuItems() + { + ClearRecentTextMenuItems(); + ClearLanguageMenuItems(); + ClearGrabTemplateMenuItems(); + Singleton.Instance.ClearRecentGrabsMenuItems(OpenRecentGrabsMenuItem); } private void MakeQrCodeCanExecute(object sender, CanExecuteRoutedEventArgs e) @@ -1565,7 +3570,7 @@ private void OpenFileMenuItem_Click(object sender, RoutedEventArgs e) { // Set filter for file extension and default file extension DefaultExt = ".txt", - Filter = "Text documents (.txt)|*.txt|All files (*.*)|*.*", + Filter = FileUtilities.GetOpenDocumentFilter(), DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; @@ -1609,13 +3614,17 @@ private void PassedTextControl_ContextMenuOpening(object sender, ContextMenuEven private void PassedTextControl_SelectionChanged(object sender, RoutedEventArgs e) { + if (editorMode is EtwEditorMode.Spreadsheet or EtwEditorMode.Markdown) + return; + UpdateLineAndColumnText(); } private void PassedTextControl_SizeChanged(object sender, SizeChangedEventArgs e) { UpdateLineAndColumnText(); - SetMargins(MarginsMenuItem.IsChecked is true); + if (editorMode != EtwEditorMode.Markdown) + SetMargins(MarginsMenuItem.IsChecked is true); } private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e) @@ -1634,6 +3643,150 @@ private void PassedTextControl_TextChanged(object sender, TextChangedEventArgs e // If a newline append auto-scrolls the main box, ensure calc scroll follows too // Schedule after layout so offsets are accurate Dispatcher.BeginInvoke(SyncCalcScrollToMain, DispatcherPriority.Background); + + if (isSyncingTextFromSpreadsheet || isSyncingTextFromMarkdown) + { + if (isSyncingTextFromMarkdown) + ResetSpreadsheetUndoHistory(); + + UpdatePendingFileEditState(); + return; + } + + if (editorMode == EtwEditorMode.Spreadsheet) + { + RefreshSpreadsheetFromText(); + UpdatePendingFileEditState(); + return; + } + + if (editorMode == EtwEditorMode.Markdown) + { + RefreshMarkdownFromText(); + UpdatePendingFileEditState(); + return; + } + + ResetSpreadsheetUndoHistory(); + RefreshSpreadsheetFromText(rebuildTable: false); + UpdatePendingFileEditState(); + } + + private void MarkdownEditorControl_SelectionChanged(object sender, RoutedEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown) + return; + + UpdateLineAndColumnText(); + } + + private void MarkdownEditorControl_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown) + return; + + UpdateLineAndColumnText(); + SetMargins(MarginsMenuItem.IsChecked is true); + } + + private void MarkdownEditorControl_TextChanged(object sender, TextChangedEventArgs e) + { + if (isApplyingMarkdownDocument) + return; + + int caretOffset = GetMarkdownPlainTextOffset(MarkdownEditorControl.CaretPosition); + string currentParagraphText = FindParent(MarkdownEditorControl.CaretPosition.Parent) is Paragraph currentParagraph + ? new TextRange(currentParagraph.ContentStart, currentParagraph.ContentEnd).Text + : string.Empty; + bool shouldPromoteMarkdown = MarkdownDocumentUtilities.ShouldPromoteLiveMarkdown(currentParagraphText); + + SyncMarkdownTextFromDocument(); + UpdateLineAndColumnText(); + + if (!shouldPromoteMarkdown) + return; + + Dispatcher.BeginInvoke( + () => + { + if (editorMode != EtwEditorMode.Markdown || isApplyingMarkdownDocument) + return; + + ReloadMarkdownDocumentAndRestoreCaret(caretOffset); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + + private void MarkdownEditorControl_PreviewTextInput(object sender, TextCompositionEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown || e.Text != " ") + return; + + Paragraph? paragraph = FindParent(MarkdownEditorControl.CaretPosition.Parent); + if (paragraph is null) + return; + + string lineTextBeforeSpace = new TextRange(paragraph.ContentStart, MarkdownEditorControl.CaretPosition).Text; + if (!MarkdownDocumentUtilities.ShouldPromoteLiveBlock(lineTextBeforeSpace)) + return; + + int paragraphStartOffset = GetMarkdownPlainTextOffset(paragraph.ContentStart); + Dispatcher.BeginInvoke( + () => + { + if (editorMode != EtwEditorMode.Markdown || isApplyingMarkdownDocument) + return; + + ReloadMarkdownDocumentAndRestoreCaret(paragraphStartOffset); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + + private void MarkdownEditorControl_Pasting(object sender, DataObjectPastingEventArgs e) + { + if (editorMode != EtwEditorMode.Markdown) + return; + + string? pastedText = e.DataObject.GetData(System.Windows.DataFormats.UnicodeText) as string + ?? e.DataObject.GetData(System.Windows.DataFormats.Text) as string; + if (string.IsNullOrEmpty(pastedText)) + return; + + e.CancelCommand(); + + bool shouldParseAsMarkdown = MarkdownDocumentUtilities.LooksLikeMarkdown(pastedText); + int selectionStartOffset = GetMarkdownPlainTextOffset(MarkdownEditorControl.Selection.Start); + int renderedPasteLength = shouldParseAsMarkdown + ? MarkdownDocumentUtilities.GetDocumentPlainText( + MarkdownDocumentUtilities.CreateFlowDocument( + pastedText, + MarkdownEditorControl.FontFamily, + MarkdownEditorControl.FontSize)).Length + : pastedText.Length; + + MarkdownEditorControl.Selection.Text = pastedText; + + if (shouldParseAsMarkdown) + { + Dispatcher.BeginInvoke( + () => + { + if (editorMode != EtwEditorMode.Markdown || isApplyingMarkdownDocument) + return; + + ReloadMarkdownDocumentAndRestoreCaret(selectionStartOffset + renderedPasteLength); + UpdateLineAndColumnText(); + }, + DispatcherPriority.Background); + } + } + + private void MarkdownEditorControl_RequestNavigate(object sender, RequestNavigateEventArgs e) + { + Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); + e.Handled = true; } private DispatcherTimer? _debounceTimer = null; @@ -1677,7 +3830,7 @@ private async Task EvaluateExpressions() _calculationService.ClearParameters(); UpdateAggregateStatusDisplay(); // Keep scrolls aligned even when clearing - Dispatcher.BeginInvoke(SyncCalcScrollToMain, DispatcherPriority.Render); + await Dispatcher.InvokeAsync(SyncCalcScrollToMain, DispatcherPriority.Render); return; } @@ -1695,7 +3848,7 @@ private async Task EvaluateExpressions() UpdateAggregateStatusDisplay(); // After updating calc text, its ScrollViewer resets; resync to main scroll - Dispatcher.BeginInvoke(SyncCalcScrollToMain, DispatcherPriority.Render); + await Dispatcher.InvokeAsync(SyncCalcScrollToMain, DispatcherPriority.Render); // Optional status (kept commented) // if (result.ErrorCount == 0) { } else { } @@ -1737,7 +3890,13 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul { try { - string textFromClipboard = await dataPackageView.GetTextAsync(); + string textFromClipboard; + if (editorMode == EtwEditorMode.Text + && ClipboardUtilities.TryGetHtmlTableAsTabSeparated(out string htmlTableText)) + textFromClipboard = htmlTableText; + else + textFromClipboard = await dataPackageView.GetTextAsync(); + System.Windows.Application.Current.Dispatcher.Invoke(new Action(() => { AddCopiedTextToTextBox(textFromClipboard); })); } catch (Exception ex) @@ -1865,41 +4024,22 @@ private async void ReadFolderOfImages_Click(object sender, RoutedEventArgs e) private void RemoveDuplicateLines_Click(object sender, RoutedEventArgs e) { PassedTextControl.Text = PassedTextControl.Text.RemoveDuplicateLines(); - } - - private void ReplaceReservedCharsCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) - { - bool containsAnyReservedChars = false; - - if (PassedTextControl.SelectionLength > 0) - { - foreach (char reservedChar in StringMethods.ReservedChars) - { - if (PassedTextControl.SelectedText.Contains(reservedChar)) - containsAnyReservedChars = true; - } - } - else - { - foreach (char reservedChar in StringMethods.ReservedChars) - { - if (PassedTextControl.Text.Contains(reservedChar)) - containsAnyReservedChars = true; - } - } + } - if (containsAnyReservedChars) - e.CanExecute = true; - else - e.CanExecute = false; + private void ShuffleLinesMenuItem_Click(object sender, RoutedEventArgs e) + { + ApplySelectedTextOrAllTextTransform(text => text.ShuffleLines()); + } + + private void ReplaceReservedCharsCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) + { + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() + .Any(text => StringMethods.ReservedChars.Any(text.Contains)); } private void ReplaceReservedCharsCmdExecuted(object sender, ExecutedRoutedEventArgs e) { - if (PassedTextControl.SelectionLength > 0) - PassedTextControl.SelectedText = PassedTextControl.SelectedText.ReplaceReservedCharacters(); - else - PassedTextControl.Text = PassedTextControl.Text.ReplaceReservedCharacters(); + ApplySelectedTextOrAllTextTransform(text => text.ReplaceReservedCharacters()); } private void RestorePositionMenuItem_Checked(object sender, RoutedEventArgs e) @@ -1961,46 +4101,161 @@ private void RestoreWindowSettings() private void SaveAsBTN_Click(object sender, RoutedEventArgs e) { - string fileText = PassedTextControl.Text; + _ = SaveCurrentDocument(saveAs: true); + } - Microsoft.Win32.SaveFileDialog dialog = new() - { - Filter = "Text Files(*.txt)|*.txt|All(*.*)|*", - InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), - RestoreDirectory = true, - }; + private void SaveBTN_Click(object sender, RoutedEventArgs e) + { + _ = SaveCurrentDocument(); + } - if (dialog.ShowDialog() is true) - { - File.WriteAllText(dialog.FileName, fileText); - OpenedFilePath = dialog.FileName; - UiTitleBar.Title = $"Edit Text | {OpenedFilePath.Split('\\').LastOrDefault()}"; - } + private string GetDefaultSaveExtension() + { + return GetDefaultSaveExtension(OpenedFilePath, editorMode, tableDocument); } - private void SaveBTN_Click(object sender, RoutedEventArgs e) + private int GetSaveDocumentFilterIndex() + { + return GetSaveDocumentFilterIndex(OpenedFilePath, editorMode); + } + + private void SyncTextFromActiveEditor() { + if (editorMode == EtwEditorMode.Spreadsheet) + SyncSpreadsheetDocumentFromTable(); + else if (editorMode == EtwEditorMode.Markdown) + SyncMarkdownTextFromDocument(); + } + + private bool SaveCurrentDocument(bool saveAs = false) + { + SyncTextFromActiveEditor(); + string fileText = PassedTextControl.Text; + string? targetFilePath = saveAs ? null : OpenedFilePath; - if (string.IsNullOrEmpty(OpenedFilePath)) + if (string.IsNullOrEmpty(targetFilePath)) { Microsoft.Win32.SaveFileDialog dialog = new() { - Filter = "Text Files(*.txt)|*.txt|All(*.*)|*", + DefaultExt = GetDefaultSaveExtension(), + Filter = SaveDocumentFilter, + FilterIndex = GetSaveDocumentFilterIndex(), InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), RestoreDirectory = true, }; - if (dialog.ShowDialog() is true) + if (dialog.ShowDialog() is not true) + return false; + + targetFilePath = dialog.FileName; + } + + File.WriteAllText(targetFilePath, fileText); + SetOpenedFileState(targetFilePath); + return true; + } + + private void SetOpenedFileState(string? openedFilePath) + { + OpenedFilePath = openedFilePath; + savedFileText = string.IsNullOrWhiteSpace(openedFilePath) ? string.Empty : PassedTextControl.Text; + hasPendingFileEdits = false; + UpdateWindowTitle(); + } + + private void UpdateWindowTitle() + { + string windowTitle = GetWindowTitle(OpenedFilePath, hasPendingFileEdits); + Title = windowTitle; + UiTitleBar.Title = windowTitle; + } + + private void UpdatePendingFileEditState() + { + if (isLoadingOpenedFile) + return; + + hasPendingFileEdits = ShouldShowPendingFileEdits(OpenedFilePath, savedFileText, PassedTextControl.Text); + UpdateWindowTitle(); + } + + private async Task PromptForPendingFileEditsAsync() + { + if (string.IsNullOrWhiteSpace(OpenedFilePath)) + return PendingFileCloseAction.Cancel; + + string fileName = Path.GetFileName(OpenedFilePath); + PendingFileCloseAction closeButtonAction = PendingFileCloseAction.Cancel; + Wpf.Ui.Controls.ContentDialog promptDialog = new(PendingFileCloseDialogHost) + { + Title = $"Save changes to {fileName}?", + Content = "You have pending edits. Save the file, discard the changes, or keep the current version in Text Grab history.", + PrimaryButtonText = "Save", + SecondaryButtonText = "Don't Save", + CloseButtonText = "Save to History", + DefaultButton = Wpf.Ui.Controls.ContentDialogButton.Primary, + }; + + promptDialog.ButtonClicked += (_, e) => + { + if (e.Button == Wpf.Ui.Controls.ContentDialogButton.Close) + closeButtonAction = PendingFileCloseAction.SaveToHistory; + }; + + Wpf.Ui.Controls.ContentDialogResult result = await promptDialog.ShowAsync(); + + if (result == Wpf.Ui.Controls.ContentDialogResult.Primary) + return PendingFileCloseAction.Save; + + if (result == Wpf.Ui.Controls.ContentDialogResult.Secondary) + return PendingFileCloseAction.DontSave; + + if (closeButtonAction == PendingFileCloseAction.SaveToHistory) + return closeButtonAction; + + return PendingFileCloseAction.Cancel; + } + + private void SaveWindowTextToHistoryIfNeeded() + { + if (string.IsNullOrEmpty(OpenedFilePath) + && !string.IsNullOrWhiteSpace(PassedTextControl.Text)) + Singleton.Instance.SaveToHistory(this); + } + + private void SaveWindowTextToHistoryNow() + { + Singleton.Instance.SaveToHistory(this); + Singleton.Instance.WriteHistory(); + } + + private async Task HandlePendingFileClosePromptAsync() + { + try + { + switch (await PromptForPendingFileEditsAsync()) { - File.WriteAllText(dialog.FileName, fileText); - OpenedFilePath = dialog.FileName; - UiTitleBar.Title = $"Edit Text | {OpenedFilePath.Split('\\').LastOrDefault()}"; + case PendingFileCloseAction.Save: + if (!SaveCurrentDocument()) + return; + break; + case PendingFileCloseAction.DontSave: + break; + case PendingFileCloseAction.SaveToHistory: + SaveWindowTextToHistoryNow(); + break; + case PendingFileCloseAction.Cancel: + default: + return; } + + allowCloseAfterPendingFilePrompt = true; + Close(); } - else + finally { - File.WriteAllText(OpenedFilePath, fileText); + isShowingPendingFileClosePrompt = false; } } @@ -2014,6 +4269,13 @@ private void SelectAllMenuItem_Click(Object? sender = null, RoutedEventArgs? e = if (!IsLoaded) return; + if (editorMode == EtwEditorMode.Spreadsheet) + { + SpreadsheetDataGrid.SelectAllCells(); + SpreadsheetDataGrid.Focus(); + return; + } + PassedTextControl.SelectAll(); } @@ -2049,11 +4311,69 @@ private void SelectNoneMenuItem_Click(Object? sender = null, RoutedEventArgs? e private void SelectWord(object? sender = null, ExecutedRoutedEventArgs? e = null) { + if (TrySelectSpreadsheetWord()) + return; + (int wordStart, int wordLength) = PassedTextControl.Text.CursorWordBoundaries(PassedTextControl.CaretIndex); PassedTextControl.Select(wordStart, wordLength); } + private bool TrySelectSpreadsheetWord() + { + if (editorMode != EtwEditorMode.Spreadsheet) + return false; + + if (TryGetFocusedSpreadsheetCellEditor(out System.Windows.Controls.TextBox? focusedEditor)) + { + (int editorWordStart, int editorWordLength) = focusedEditor.Text.CursorWordBoundaries(focusedEditor.CaretIndex); + focusedEditor.Select(editorWordStart, editorWordLength); + return true; + } + + int rowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int? columnIndex = SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex; + if (rowIndex < 0 + || columnIndex is null + || rowIndex >= spreadsheetTable.Rows.Count + || columnIndex.Value < 0 + || columnIndex.Value >= spreadsheetTable.Columns.Count) + { + return true; + } + + string cellText = spreadsheetTable.Rows[rowIndex][columnIndex.Value]?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(cellText)) + return true; + + (int wordStart, int wordLength) = cellText.CursorWordBoundaries(0); + FocusSpreadsheetCell(rowIndex, columnIndex.Value); + + Dispatcher.BeginInvoke( + () => + { + if (TryGetFocusedSpreadsheetCellEditor(out System.Windows.Controls.TextBox? editor)) + editor.Select(wordStart, wordLength); + }, + DispatcherPriority.Background); + + return true; + } + + private bool TryGetFocusedSpreadsheetCellEditor([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out System.Windows.Controls.TextBox? editor) + { + editor = null; + + if (Keyboard.FocusedElement is not DependencyObject focusedElement) + return false; + + if (FindVisualParent(focusedElement) is null) + return false; + + editor = FindVisualParent(focusedElement); + return editor is not null; + } + private void SelectWordMenuItem_Click(object sender, RoutedEventArgs e) { SelectWord(); @@ -2063,6 +4383,10 @@ private void SetFontFromSettings() { PassedTextControl.FontFamily = new FontFamily(DefaultSettings.FontFamilySetting); PassedTextControl.FontSize = DefaultSettings.FontSizeSetting; + MarkdownEditorControl.FontFamily = PassedTextControl.FontFamily; + MarkdownEditorControl.FontSize = PassedTextControl.FontSize; + SpreadsheetDataGrid.FontFamily = PassedTextControl.FontFamily; + SpreadsheetDataGrid.FontSize = PassedTextControl.FontSize; if (DefaultSettings.IsFontBold) PassedTextControl.FontWeight = FontWeights.Bold; if (DefaultSettings.IsFontItalic) @@ -2072,24 +4396,36 @@ private void SetFontFromSettings() if (DefaultSettings.IsFontUnderline) tdc.Add(TextDecorations.Underline); if (DefaultSettings.IsFontStrikeout) tdc.Add(TextDecorations.Strikethrough); PassedTextControl.TextDecorations = tdc; + + if (MarkdownEditorControl.Document is not null) + { + MarkdownEditorControl.Document.FontFamily = MarkdownEditorControl.FontFamily; + MarkdownEditorControl.Document.FontSize = MarkdownEditorControl.FontSize; + ApplyMarkdownTheme(); + } } private void SetMargins(bool AreThereMargins) { + Thickness padding = new(0); + double editorWidth = editorMode == EtwEditorMode.Markdown && MarkdownEditorControl.ActualWidth > 0 + ? MarkdownEditorControl.ActualWidth + : PassedTextControl.ActualWidth; if (AreThereMargins) { - if (PassedTextControl.ActualWidth < 400) - PassedTextControl.Padding = new Thickness(10, 0, 10, 0); - else if (PassedTextControl.ActualWidth < 1000) - PassedTextControl.Padding = new Thickness(50, 0, 50, 0); - else if (PassedTextControl.ActualWidth < 1400) - PassedTextControl.Padding = new Thickness(100, 0, 100, 0); + if (editorWidth < 400) + padding = new Thickness(10, 0, 10, 0); + else if (editorWidth < 1000) + padding = new Thickness(50, 0, 50, 0); + else if (editorWidth < 1400) + padding = new Thickness(100, 0, 100, 0); else - PassedTextControl.Padding = new Thickness(160, 0, 160, 0); + padding = new Thickness(160, 0, 160, 0); } - else - PassedTextControl.Padding = new Thickness(0); + + PassedTextControl.Padding = padding; + MarkdownEditorControl.Padding = padding; } private void SettingsMenuItem_Click(object sender, RoutedEventArgs e) @@ -2099,6 +4435,12 @@ private void SettingsMenuItem_Click(object sender, RoutedEventArgs e) private void SetupRoutedCommands() { + _ = CommandBindings.Add(new CommandBinding(ApplicationCommands.Undo, SpreadsheetUndoExecuted, SpreadsheetUndoCanExecute)); + _ = CommandBindings.Add(new CommandBinding(ApplicationCommands.Redo, SpreadsheetRedoExecuted, SpreadsheetRedoCanExecute)); + _ = CommandBindings.Add(new CommandBinding(ApplicationCommands.Cut, SpreadsheetCutExecuted, SpreadsheetCopyCanExecute)); + _ = CommandBindings.Add(new CommandBinding(ApplicationCommands.Copy, SpreadsheetCopyExecuted, SpreadsheetCopyCanExecute)); + _ = CommandBindings.Add(new CommandBinding(ApplicationCommands.Paste, SpreadsheetPasteExecuted, SpreadsheetPasteCanExecute)); + RoutedCommand newFullscreenGrab = new(); _ = newFullscreenGrab.InputGestures.Add(new KeyGesture(Key.F, ModifierKeys.Control)); _ = CommandBindings.Add(new CommandBinding(newFullscreenGrab, KeyedCtrlF)); @@ -2200,25 +4542,15 @@ private void SetupRoutedCommands() private void SingleLineCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { - string textToOperateOn = GetSelectedTextOrAllText(); - - if (textToOperateOn.Contains(Environment.NewLine) - || textToOperateOn.Contains('\r') - || textToOperateOn.Contains('\n')) - { - e.CanExecute = true; - return; - } - - e.CanExecute = false; + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() + .Any(text => text.Contains(Environment.NewLine) + || text.Contains('\r') + || text.Contains('\n')); } private void SingleLineCmdExecuted(object sender, ExecutedRoutedEventArgs? e = null) { - if (PassedTextControl.SelectedText.Length > 0) - PassedTextControl.SelectedText = PassedTextControl.SelectedText.MakeStringSingleLine(); - else - PassedTextControl.Text = PassedTextControl.Text.MakeStringSingleLine(); + ApplySelectedTextOrAllTextTransform(text => text.MakeStringSingleLine()); } private void SplitOnSelectionCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) @@ -2275,6 +4607,23 @@ private async void SplitAfterSelectionCmdExecuted(object sender, ExecutedRoutedE private void ToggleCase(object? sender = null, ExecutedRoutedEventArgs? e = null) { + if (editorMode == EtwEditorMode.Spreadsheet) + { + CaseStatusOfToggle = CurrentCase.Unknown; + ApplySelectedTextOrAllTextTransform(text => + { + CurrentCase caseStatus = StringMethods.DetermineToggleCase(text); + return caseStatus switch + { + CurrentCase.Lower => selectedCultureInfo.TextInfo.ToLower(text), + CurrentCase.Camel => selectedCultureInfo.TextInfo.ToTitleCase(text), + CurrentCase.Upper => selectedCultureInfo.TextInfo.ToUpper(text), + _ => text, + }; + }); + return; + } + string textToModify = GetSelectedTextOrAllText(); if (CaseStatusOfToggle == CurrentCase.Unknown) @@ -2308,54 +4657,43 @@ private void ToggleCase(object? sender = null, ExecutedRoutedEventArgs? e = null private void ToggleCaseCmdCanExecute(object sender, CanExecuteRoutedEventArgs e) { - bool containsLetters = false; - string text = GetSelectedTextOrAllText(); - - foreach (char letter in text) - if (char.IsLetter(letter)) - containsLetters = true; - - if (containsLetters) - e.CanExecute = true; - else - e.CanExecute = false; + e.CanExecute = GetSelectedOrAllTextSegmentsForEdit() + .Any(text => text.Any(char.IsLetter)); } private void TrimEachLineMenuItem_Click(object sender, RoutedEventArgs e) { - string workingString = PassedTextControl.Text; - string[] stringSplit = workingString.Split(Environment.NewLine); + static string TrimEachLine(string workingString) + { + string[] stringSplit = workingString.Split(Environment.NewLine); + string finalString = ""; + + foreach (string line in stringSplit) + { + if (!string.IsNullOrWhiteSpace(line)) + finalString += line.Trim() + Environment.NewLine; + } + + return finalString; + } - string finalString = ""; - foreach (string line in stringSplit) - if (!string.IsNullOrWhiteSpace(line)) - finalString += line.Trim() + Environment.NewLine; + if (editorMode == EtwEditorMode.Spreadsheet) + { + TryApplySpreadsheetTextTransform(TrimEachLine); + return; + } - PassedTextControl.Text = finalString; + PassedTextControl.Text = TrimEachLine(PassedTextControl.Text); } private void TryToAlphaMenuItem_Click(object sender, RoutedEventArgs e) { - string workingString = GetSelectedTextOrAllText(); - - workingString = workingString.TryFixToLetters(); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = workingString; - else - PassedTextControl.SelectedText = workingString; + ApplySelectedTextOrAllTextTransform(text => text.TryFixToLetters()); } private void TryToNumberMenuItem_Click(object sender, RoutedEventArgs e) { - string workingString = GetSelectedTextOrAllText(); - - workingString = workingString.TryFixToNumbers(); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = workingString; - else - PassedTextControl.SelectedText = workingString; + ApplySelectedTextOrAllTextTransform(text => text.TryFixToNumbers()); } private void UnstackExecuted(object? sender = null, ExecutedRoutedEventArgs? e = null) @@ -2378,6 +4716,62 @@ private void UnstackGroupExecuted(object? sender = null, ExecutedRoutedEventArgs private void UpdateLineAndColumnText() { + if (editorMode == EtwEditorMode.Spreadsheet) + { + HideSelectionSpecificUi(); + + int rowCount = spreadsheetTable.Rows.Count; + int columnCount = spreadsheetTable.Columns.Count; + + if (SpreadsheetDataGrid.SelectedCells.Count == 0) + { + if (SpreadsheetDataGrid.CurrentCell.Column is not null) + { + int currentRowIndex = SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem); + int currentColumnIndex = SpreadsheetDataGrid.CurrentCell.Column.DisplayIndex; + + BottomBarText.Text = currentRowIndex >= 0 + ? $"Rows {rowCount}, Cols {columnCount}, Row {currentRowIndex + 1}, Col {currentColumnIndex + 1}" + : $"Rows {rowCount}, Cols {columnCount}"; + } + else + { + BottomBarText.Text = $"Rows {rowCount}, Cols {columnCount}"; + } + + return; + } + + int selectedRowCount = SpreadsheetDataGrid.SelectedCells + .Select(cell => SpreadsheetDataGrid.Items.IndexOf(cell.Item)) + .Where(index => index >= 0) + .Distinct() + .Count(); + int selectedColumnCount = SpreadsheetDataGrid.SelectedCells + .Select(cell => cell.Column.DisplayIndex) + .Distinct() + .Count(); + + BottomBarText.Text = + $"Rows {rowCount}, Cols {columnCount}, Selected {SpreadsheetDataGrid.SelectedCells.Count} cells ({selectedRowCount} rows x {selectedColumnCount} cols)"; + return; + } + + if (editorMode == EtwEditorMode.Markdown) + { + HideSelectionSpecificUi(); + + string plainText = MarkdownEditorControl.Document is null + ? string.Empty + : MarkdownDocumentUtilities.GetDocumentPlainText(MarkdownEditorControl.Document); + string selectedText = MarkdownEditorControl.Selection.Text.TrimEnd('\r', '\n'); + + BottomBarText.Text = string.IsNullOrEmpty(selectedText) + ? $"Markdown, Chars {plainText.Length}" + : $"Markdown, Selected {selectedText.Length} chars"; + return; + } + char[] delimiters = [' ', '\r', '\n']; if (PassedTextControl.SelectionLength < 1) @@ -2430,6 +4824,12 @@ private void UpdateLineAndColumnText() private void UpdateSelectionSpecificUI() { + if (editorMode == EtwEditorMode.Spreadsheet) + { + HideSelectionSpecificUi(); + return; + } + string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) @@ -2733,11 +5133,37 @@ private void CharDetailsButton_Click(object sender, RoutedEventArgs e) private void Window_Activated(object sender, EventArgs e) { - PassedTextControl.Focus(); + if (editorMode == EtwEditorMode.Spreadsheet) + SpreadsheetDataGrid.Focus(); + else if (editorMode == EtwEditorMode.Markdown) + { + ApplyMarkdownTheme(); + MarkdownEditorControl.Focus(); + } + else + PassedTextControl.Focus(); } private void Window_Closed(object sender, EventArgs e) { + DetachSpreadsheetColumnWidthTracking(); + System.Windows.DataObject.RemovePastingHandler(MarkdownEditorControl, MarkdownEditorControl_Pasting); + + windowSource?.RemoveHook(EditTextWindowMessageHook); + + EscapeKeyTimer.Stop(); + EscapeKeyTimer.Tick -= EscapeKeyTimer_Tick; + + MarkdownEditorControl.RemoveHandler(Hyperlink.RequestNavigateEvent, new RequestNavigateEventHandler(MarkdownEditorControl_RequestNavigate)); + PassedTextControl.RemoveHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(PassedTextControl_ScrollChanged)); + CalcResultsTextControl.PreviewMouseWheel -= CalcResultsTextControl_PreviewMouseWheel; + + HideCalcPaneContextItem.Click -= HideCalcPaneContextItem_Click; + ShowCalcErrorsContextItem.Click -= ShowCalcErrorsContextItem_Click; + CopyAllContextItem.Click -= CopyAllContextItem_Click; + + ClearDynamicMenuItems(); + string windowSizeAndPosition = $"{this.Left},{this.Top},{this.Width},{this.Height}"; DefaultSettings.EditTextWindowSizeAndPosition = windowSizeAndPosition; @@ -2774,23 +5200,58 @@ private void Window_Closed(object sender, EventArgs e) private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) { - if (string.IsNullOrEmpty(OpenedFilePath) - && !string.IsNullOrWhiteSpace(PassedTextControl.Text)) - Singleton.Instance.SaveToHistory(this); + SyncTextFromActiveEditor(); + UpdatePendingFileEditState(); + + if (allowCloseAfterPendingFilePrompt) + { + allowCloseAfterPendingFilePrompt = false; + SaveWindowTextToHistoryIfNeeded(); + return; + } + + if (isShowingPendingFileClosePrompt) + { + e.Cancel = true; + return; + } + + if (!hasPendingFileEdits) + { + SaveWindowTextToHistoryIfNeeded(); + return; + } + + e.Cancel = true; + isShowingPendingFileClosePrompt = true; + _ = HandlePendingFileClosePromptAsync(); } private void Window_Initialized(object sender, EventArgs e) { PassedTextControl.PreviewMouseWheel += HandlePreviewMouseWheel; + MarkdownEditorControl.PreviewMouseWheel += HandlePreviewMouseWheel; + MarkdownEditorControl.PreviewTextInput += MarkdownEditorControl_PreviewTextInput; + System.Windows.DataObject.AddPastingHandler(MarkdownEditorControl, MarkdownEditorControl_Pasting); SetFontFromSettings(); + UpdateSpreadsheetModeUi(); + UpdateWindowTitle(); } private void Window_Loaded(object sender, RoutedEventArgs e) { SetupRoutedCommands(); + if (windowSource is null) + { + nint windowHandle = new WindowInteropHelper(this).Handle; + windowSource = HwndSource.FromHwnd(windowHandle); + windowSource?.AddHook(EditTextWindowMessageHook); + } + PassedTextControl.ContextMenu = this.FindResource("ContextMenuResource") as ContextMenu; if (PassedTextControl.ContextMenu != null) numberOfContextMenuItems = PassedTextControl.ContextMenu.Items.Count; + MarkdownEditorControl.AddHandler(Hyperlink.RequestNavigateEvent, new RequestNavigateEventHandler(MarkdownEditorControl_RequestNavigate)); CheckRightToLeftLanguage(); @@ -2842,6 +5303,13 @@ private void Window_Loaded(object sender, RoutedEventArgs e) // Initialize selectedILanguage with the last used OCR language from settings // This ensures that when images are dropped or pasted, the correct language is used selectedILanguage = LanguageUtilities.GetOCRLanguage(); + + if (editorMode == EtwEditorMode.Spreadsheet) + SetEditorMode(EtwEditorMode.Spreadsheet); + else if (editorMode == EtwEditorMode.Markdown) + SetEditorMode(EtwEditorMode.Markdown); + else + UpdateSpreadsheetModeUi(); } private void HideCalcPaneContextItem_Click(object sender, RoutedEventArgs e) @@ -3291,6 +5759,47 @@ private void WindowMenuItem_SubmenuOpened(object sender, RoutedEventArgs e) OpenLastAsGrabFrameMenuItem.IsEnabled = Singleton.Instance.HasAnyHistoryWithImages(); } + private void SpreadsheetContextMenu_Opened(object sender, RoutedEventArgs e) + { + if (sender is not ContextMenu contextMenu) + return; + + MenuItem? wrapTextMenuItem = GetContextMenuItem(contextMenu, "SpreadsheetWrapTextToggle"); + if (wrapTextMenuItem is null) + return; + + EnsureSpreadsheetDocumentFromText(); + + List<(int RowIndex, int ColumnIndex)> targetCells = GetSelectedOrCurrentSpreadsheetCellCoordinates(); + bool hasTargetCells = tableDocument is not null && targetCells.Count > 0; + + wrapTextMenuItem.IsEnabled = hasTargetCells; + wrapTextMenuItem.IsChecked = hasTargetCells && AreSpreadsheetDocumentCellsWrapped(tableDocument!, targetCells); + } + + private void ToggleSpreadsheetWrapTextMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) + return; + + List<(int RowIndex, int ColumnIndex)> targetCells = GetSelectedOrCurrentSpreadsheetCellCoordinates(); + if (targetCells.Count == 0) + return; + + int focusRow = Math.Max(0, spreadsheetContextRowIndex ?? SpreadsheetDataGrid.Items.IndexOf(SpreadsheetDataGrid.CurrentItem)); + int focusColumn = Math.Max(0, spreadsheetContextColumnIndex ?? SpreadsheetDataGrid.CurrentCell.Column?.DisplayIndex ?? 0); + + ApplySpreadsheetDocumentChange( + document => + { + SetSpreadsheetDocumentCellWrapState(document, targetCells, menuItem.IsChecked); + ClearSpreadsheetDocumentRowHeights(document, targetCells.Select(cell => cell.RowIndex)); + }, + focusRow, + focusColumn, + beginEdit: false); + } + private void WrapTextCHBX_Checked(object sender, RoutedEventArgs e) { if (!IsLoaded) @@ -3301,35 +5810,23 @@ private void WrapTextCHBX_Checked(object sender, RoutedEventArgs e) else PassedTextControl.TextWrapping = TextWrapping.NoWrap; + ApplyMarkdownWrapSetting(); + DefaultSettings.EditWindowIsWordWrapOn = WrapTextMenuItem.IsChecked; } private void CorrectGuid_Click(object sender, RoutedEventArgs e) { - string workingString = GetSelectedTextOrAllText(); - - workingString = workingString.CorrectCommonGuidErrors(); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = workingString; - else - PassedTextControl.SelectedText = workingString; + ApplySelectedTextOrAllTextTransform(text => text.CorrectCommonGuidErrors()); } private async void SummarizeMenuItem_Click(object sender, RoutedEventArgs e) { - string textToSummarize = GetSelectedTextOrAllText(); - SetToLoading("Summarizing..."); try { - string summarizedText = await WindowsAiUtilities.SummarizeParagraph(textToSummarize); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = summarizedText; - else - PassedTextControl.SelectedText = summarizedText; + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.SummarizeParagraph(text)); } finally { @@ -3348,17 +5845,10 @@ private void LearnAiMenuItem_Click(object sender, RoutedEventArgs e) private async void RewriteMenuItem_Click(object sender, RoutedEventArgs e) { - string textToRewrite = GetSelectedTextOrAllText(); - SetToLoading("Rewriting..."); try { - string summarizedText = await WindowsAiUtilities.Rewrite(textToRewrite); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = summarizedText; - else - PassedTextControl.SelectedText = summarizedText; + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.Rewrite(text)); } finally { @@ -3368,18 +5858,11 @@ private async void RewriteMenuItem_Click(object sender, RoutedEventArgs e) private async void ConvertTableMenuItem_Click(object sender, RoutedEventArgs e) { - string textToTable = GetSelectedTextOrAllText(); - SetToLoading("Converting..."); try { - string summarizedText = await WindowsAiUtilities.TextToTable(textToTable); - - if (PassedTextControl.SelectionLength == 0) - PassedTextControl.Text = summarizedText; - else - PassedTextControl.SelectedText = summarizedText; + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.TextToTable(text)); } finally { @@ -3402,24 +5885,37 @@ private async void TranslateToSystemLanguageMenuItem_Click(object sender, Routed await PerformTranslationAsync(systemLanguage); } + private async void TranslateToEnglish_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("English"); + + private async void TranslateToSpanish_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Spanish"); + + private async void TranslateToFrench_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("French"); + + private async void TranslateToGerman_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("German"); + + private async void TranslateToItalian_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Italian"); + + private async void TranslateToPortuguese_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Portuguese"); + + private async void TranslateToRussian_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Russian"); + + private async void TranslateToJapanese_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Japanese"); + + private async void TranslateToChineseSimplified_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Chinese (Simplified)"); + + private async void TranslateToKorean_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Korean"); + + private async void TranslateToArabic_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Arabic"); + + private async void TranslateToHindi_Click(object sender, RoutedEventArgs e) => await PerformTranslationAsync("Hindi"); + private async Task PerformTranslationAsync(string targetLanguage) { - string textToTranslate = GetSelectedTextOrAllText(); - SetToLoading($"Translating to {targetLanguage}..."); try { - string translatedText = await WindowsAiUtilities.TranslateText(textToTranslate, targetLanguage); - - if (PassedTextControl.SelectionLength == 0) - { - PassedTextControl.Text = translatedText; - } - else - { - PassedTextControl.SelectedText = translatedText; - } + await ApplySelectedTextOrAllTextTransformAsync(text => WindowsAiUtilities.TranslateText(text, targetLanguage)); } catch (Exception ex) { diff --git a/Text-Grab/Views/FirstRunWindow.xaml b/Text-Grab/Views/FirstRunWindow.xaml index 145532bd..1735ccc1 100644 --- a/Text-Grab/Views/FirstRunWindow.xaml +++ b/Text-Grab/Views/FirstRunWindow.xaml @@ -2,19 +2,15 @@ x:Class="Text_Grab.FirstRunWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="clr-namespace:Text_Grab.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:local="clr-namespace:Text_Grab" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" - x:Name="FirstRun" - Title="About Text Grab" - Width="800" - Height="600" - MinWidth="200" - MinHeight="200" - Padding="50" - d:Height="5000" + Title="Welcome to Text Grab" + Width="1120" + Height="820" + MinWidth="760" + MinHeight="620" + Padding="0" Background="{DynamicResource ApplicationBackgroundBrush}" Closed="Window_Closed" Foreground="{DynamicResource TextFillColorPrimaryBrush}" @@ -23,433 +19,902 @@ mc:Ignorable="d"> - + + + + + + + + + + + + + + + + + + - + + - - - - - - Text Grab has four different modes for working with text. - 1. Full-Screen - Similar to taking a screenshot, but for copying text - 2. Grab Frame - An overlay for picking and finding text on screen - 3. Edit Text - Like Notepad, but with tools for fixing and changing text - 4. Quick Simple Lookup - An editable list of text items to quickly search and copy. - - Right click on Text Grab in the Start Menu, or on the Taskbar to launch the different modes. The mode can be changed in the settings at any time. - - - - - - - - - - - - - - - - - - - - - - - - Full Screen - - - - - - - - - - Try a Full-Screen Grab - - - - - - - - Grab Frame - - - - - - - - - - Show a Grab Frame - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - Edit Text Window - - - - - - - - - - Open an Edit Window - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Need help, want to file feedback, or just want to see what's new? + + + Visit the GitHub project + + + + Email support@TextGrab.net + + + + + + + + + + + + + Draw around the text you want or click a single word. Text Grab reads the selection with Windows OCR, copies the result to the clipboard, and lets you paste it anywhere. If nothing is recognized, you can immediately try again. + + + + + + Grab Frame stays on top, can be moved or resized, and reads text inside its border. You can select word borders, read everything in-frame, drop in images or PDFs, zoom in, and switch to table-oriented workflows. + + + + + + Edit Text is a plain-text workspace for fixing OCR output. It includes tools for trimming spaces, changing case, isolating selections, replacing reserved characters, running find and replace, and extracting regex matches. + + + + + + Quick Simple Lookup is the non-OCR companion mode. Store reusable text items, search them instantly, hit Enter to copy, and paste the result wherever you need it. + + + + + + You can always reopen this screen from Settings if you want the guided overview again later. + + + + + + + + + + + + + + + + + + + - - Quick Lookup - - - - - - - - - - Launch Quick Simple Lookup - - - - - - - How Full-Screen works - - - - - - Like a screenshot tool but instead of a photo output you get the text within the selection. This works two different ways: - 1. Draw a rectangle around the text you wish to copy - 2. Click on a single word you wish to copy - • Both methods use the built-in Windows 10 OCR engine. - • If there is no result Text Grab returns to Selection Mode to try again. - • When text is recognized from the screen it gets put in your Windows clipboard. - • Then paste using Ctrl + V into any program. - • To view a history of your clipboard use the Windows 10 clipboard manager by pressing Win+V. - - - How Grab Frame works - - - - • The Grab Frame is a window which can be moved or resized. It stays on top of other windows and will read all of the text within the border. - • Click or drag to select Word Borders then add them to the clipboard by clicking "Grab". - • Drop an image onto the Grab Frame to view the image and copy text. - • Pause the Grab Frame and scroll to zoom in on a piece of text. - • Edit each line to correct any errors and fix up the results to be perfect. - • Table mode will draw a grid around the lines to be pasted into a table easily. - • If there is no search string or clicked word borders then the Grab Frame reads all text within the window and copies it to the clipboard. - - - What the Edit Text Window can do - - - - • Similar to Notepad, the Edit Text Window is a "Pure Text" editing experience, with no formatting. - • This means copying text into or out of the Window will remove all formatting, but linebreaks and tabs will remain. - • Gather text using Fullscreen Grabs or Grab Frames. - • There are several tools with in the Edit Text Window which make it quick and easy to fix or change text. - - - Make text into a single line - - Toggle between UPPERCASE, lowercase, and Titlecase - - Trim spaces and empty lines - - Isolate selected text - - Replace reserved characters - - Find and replace - - Extract regular expressions - - And more! - - Where does Quick Simple Lookup fit in? - - - - • Unlike Full-Screen or Grab Frame modes Quick Simple Lookup does not use OCR. - • Fast to launch and load the lookup table. (default hotkey Win+Shift+Q) - • Search for any item in any way you like to filter results quickly. - • Press Enter to copy the looup value and close the window in a snap! - • Now paste the text into the app you need it. - - - - Application Options - - - - Clicking on notifications puts the copied text into an Edit Text Window to be copied, corrected, changed, amended, or more! - - - - - - - Enable Notifications - - - - - Running in the background enables shortcut keys and faster launch times. - - - - Run Text Grab in the background - - - - - Launching Text Grab on startup keeps the app ready for quick access. - - - - Start Text Grab on startup - - - - - How Text Grab is Different - - - Text Grab was designed with speed, efficiency, and privacy in mind. - - With no cumbersome UI Text Grab can be used like a basic part of the operating system. - - Paired with the Windows 10 Clipboard manager, Text Grab fulfills its goal without duplicating tools found elsewhere in Windows. - - By using the built-in OCR engine Text Grab does not have to constantly run in the background. - - The OCR engine built into Windows 10 enables Text Grab to respect users' privacy and not transmit data regarding the copied text. - - This does mean I will not be able to directly improve the OCR accuracy since the code is owned and maintained by Microsoft. - - I hope you find Text Grab as useful as I do. If you have any questions or comments please visit the GitHub page for Text Grab at: - GitHub.com/TheJoeFin/Text-Grab/ - - or email: - support@TextGrab.net - - - - - Joe - - - - - + Orientation="Horizontal"> + + + + + + + + - + diff --git a/Text-Grab/Views/FirstRunWindow.xaml.cs b/Text-Grab/Views/FirstRunWindow.xaml.cs index acfcc705..434a4423 100644 --- a/Text-Grab/Views/FirstRunWindow.xaml.cs +++ b/Text-Grab/Views/FirstRunWindow.xaml.cs @@ -13,6 +13,7 @@ namespace Text_Grab; public partial class FirstRunWindow : FluentWindow { private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + private bool settingsInitialized; public FirstRunWindow() { @@ -22,7 +23,9 @@ public FirstRunWindow() private async void FirstRun_Loaded(object sender, RoutedEventArgs e) { - TextGrabMode defaultLaunchSetting = Enum.Parse(DefaultSettings.DefaultLaunch, true); + settingsInitialized = false; + + TextGrabMode defaultLaunchSetting = GetDefaultLaunchSetting(); switch (defaultLaunchSetting) { case TextGrabMode.Fullscreen: @@ -73,6 +76,7 @@ private async void FirstRun_Loaded(object sender, RoutedEventArgs e) BackgroundCheckBox.IsChecked = DefaultSettings.RunInTheBackground; NotificationsCheckBox.IsChecked = DefaultSettings.ShowToast; + settingsInitialized = true; } private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) @@ -83,6 +87,9 @@ private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation. private void NotificationsCheckBox_Checked(object sender, RoutedEventArgs e) { + if (!settingsInitialized) + return; + if (sender is ToggleSwitch toggleSwitch && toggleSwitch.IsChecked is not null) { DefaultSettings.ShowToast = (bool)toggleSwitch.IsChecked; @@ -96,7 +103,7 @@ private void OkayButton_Click(object sender, RoutedEventArgs e) if (windowsCount is 2 or 1) { - TextGrabMode defaultLaunchSetting = Enum.Parse(DefaultSettings.DefaultLaunch, true); + TextGrabMode defaultLaunchSetting = GetDefaultLaunchSetting(); switch (defaultLaunchSetting) { case TextGrabMode.Fullscreen: @@ -120,17 +127,17 @@ private void OkayButton_Click(object sender, RoutedEventArgs e) } private void RadioButton_Checked(object sender, RoutedEventArgs e) { - if (this.IsLoaded != true) + if (!settingsInitialized) return; if (GrabFrameRDBTN.IsChecked is bool gfOn && gfOn) - DefaultSettings.DefaultLaunch = "GrabFrame"; + DefaultSettings.DefaultLaunch = TextGrabMode.GrabFrame.ToString(); else if (FullScreenRDBTN.IsChecked is bool fsgOn && fsgOn) - DefaultSettings.DefaultLaunch = "Fullscreen"; + DefaultSettings.DefaultLaunch = TextGrabMode.Fullscreen.ToString(); else if (QuickLookupRDBTN.IsChecked is bool qslOn && qslOn) - DefaultSettings.DefaultLaunch = "QuickLookup"; + DefaultSettings.DefaultLaunch = TextGrabMode.QuickLookup.ToString(); else - DefaultSettings.DefaultLaunch = "EditText"; + DefaultSettings.DefaultLaunch = TextGrabMode.EditText.ToString(); DefaultSettings.Save(); } @@ -141,8 +148,16 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) this.Close(); } + private void LicensesButton_Click(object sender, RoutedEventArgs e) + { + WindowUtilities.OpenOrActivateWindow(); + } + private async void StartupCheckbox_Checked(object sender, RoutedEventArgs e) { + if (!settingsInitialized) + return; + if (sender is ToggleSwitch toggleSwitch && toggleSwitch.IsChecked is not null) { DefaultSettings.StartupOnLogin = (bool)toggleSwitch.IsChecked; @@ -172,6 +187,12 @@ private void TryQuickLookup_Click(object sender, RoutedEventArgs e) private void Window_Closed(object? sender, EventArgs e) { + if (!settingsInitialized) + { + WindowUtilities.ShouldShutDown(); + return; + } + if (BackgroundCheckBox is ToggleSwitch toggleSwitch && toggleSwitch.IsChecked is not null) { @@ -182,4 +203,12 @@ private void Window_Closed(object? sender, EventArgs e) WindowUtilities.ShouldShutDown(); } + + private TextGrabMode GetDefaultLaunchSetting() + { + if (Enum.TryParse(DefaultSettings.DefaultLaunch, true, out TextGrabMode defaultLaunchSetting)) + return defaultLaunchSetting; + + return TextGrabMode.Fullscreen; + } } diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index 38c3f0c6..ce283f92 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -1190,7 +1190,7 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool if (!isWebSearch) { - EditTextWindow etw = WindowUtilities.OpenOrActivateWindow(); + EditTextWindow etw = WindowUtilities.OpenOrActivateEditTextWindow(isTable); destinationTextBox = etw.PassedTextControl; } } diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs index 5f808b48..c1324ab3 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml.cs +++ b/Text-Grab/Views/FullscreenGrab.xaml.cs @@ -16,8 +16,6 @@ using Text_Grab.Properties; using Text_Grab.Services; using Text_Grab.Utilities; -using Windows.Globalization; -using Windows.Media.Ocr; namespace Text_Grab.Views; @@ -51,6 +49,7 @@ public partial class FullscreenGrab : Window private const string EditPostGrabActionsTag = "EditPostGrabActions"; private const string ClosePostGrabMenuTag = "ClosePostGrabMenu"; private readonly DispatcherTimer edgePanTimer; + private bool _isCleanedUp; #endregion Fields @@ -68,6 +67,14 @@ public FullscreenGrab() Interval = TimeSpan.FromMilliseconds(16) }; edgePanTimer.Tick += EdgePanTimer_Tick; + + Closed += FullscreenGrab_Closed; + } + + private void FullscreenGrab_Closed(object? sender, EventArgs e) + { + Closed -= FullscreenGrab_Closed; + CleanupFullscreenGrab(); } #endregion Constructors @@ -1057,6 +1064,15 @@ private void DisposeBitmapSource(System.Windows.Controls.Image image) private void Window_Unloaded(object sender, RoutedEventArgs e) { + CleanupFullscreenGrab(); + } + + private void CleanupFullscreenGrab() + { + if (_isCleanedUp) + return; + _isCleanedUp = true; + edgePanTimer.Stop(); edgePanTimer.Tick -= EdgePanTimer_Tick; windowSelectionTimer.Stop(); diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index e6275d84..2e01e043 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -129,7 +129,9 @@ x:Name="IsTopmostMenuItem" Header="Keep Grab Frame On Top" IsCheckable="True" - IsChecked="{Binding Topmost, ElementName=GrabFrameWindow, Mode=TwoWay}" /> + IsChecked="{Binding Topmost, + ElementName=GrabFrameWindow, + Mode=TwoWay}" /> @@ -289,31 +291,56 @@ Checked="AspectRationMI_Checked" Header="Maintain Aspect Ratio" IsCheckable="True" - IsChecked="{Binding IsChecked, ElementName=AspectRationMI, Mode=TwoWay}" + IsChecked="{Binding IsChecked, + ElementName=AspectRationMI, + Mode=TwoWay}" Unchecked="AspectRationMI_Checked" /> + IsChecked="{Binding IsChecked, + ElementName=FreezeToggleButton, + Mode=TwoWay}" /> + IsChecked="{Binding IsChecked, + ElementName=TableToggleButton, + Mode=TwoWay}" /> + + + + + + + IsChecked="{Binding IsChecked, + ElementName=EditToggleButton, + Mode=TwoWay}" /> + IsChecked="{Binding IsChecked, + ElementName=EditTextToggleButton, + Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index a19092cc..a608d213 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1,4 +1,5 @@ -using Fasetto.Word; +using Dapplo.Windows.User32; +using Fasetto.Word; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -20,6 +21,7 @@ using System.Windows.Navigation; using System.Windows.Threading; using Text_Grab.Controls; +using Text_Grab.Extensions; using Text_Grab.Interfaces; using Text_Grab.Models; using Text_Grab.Properties; @@ -47,6 +49,7 @@ public partial class GrabFrame : Window public static RoutedCommand UndoCommand = new(); public static RoutedCommand GrabCommand = new(); public static RoutedCommand GrabTrimCommand = new(); + private readonly GrabFrameTableEditState tableEditState = new(); private ResultTable? AnalyzedResultTable; private Point clickedPoint; private ILanguage? currentLanguage; @@ -56,6 +59,9 @@ public partial class GrabFrame : Window private readonly GrabTemplate? _editingTemplate; private GrabTemplate? _activeGrabTemplate = null; private string? _currentImagePath; + private PdfDocumentRenderer? _loadedPdfDocument; + private PdfPageContent? _currentPdfPageContent; + private int _currentPdfPageIndex = -1; private bool hasLoadedImageSource = false; private bool IsDragOver = false; private bool isDrawing = false; @@ -65,6 +71,8 @@ public partial class GrabFrame : Window private bool isSearchSelectionOverridden = false; private bool isSelecting; private bool isSpaceJoining = true; + private bool isSpacePanModifierDown = false; + private DispatcherTimer? _spacePanGraceTimer; private bool isStaticImageSource = false; private readonly Dictionary movingWordBordersDictionary = []; private IOcrLinesWords? ocrResultOfWindow; @@ -92,7 +100,13 @@ public partial class GrabFrame : Window private int totalWordsToTranslate = 0; private int translatedWordsCount = 0; private CancellationTokenSource? translationCancellationTokenSource; + private readonly List pdfTextLineOverlays = []; + private CancellationTokenSource? _pdfPageNavCts; + private bool isLoadedVisualDocument = false; + private double frozenFrameContentScale = 1; private const string TargetLanguageMenuHeader = "Target Language"; + private WindowResizer? windowResizer; + private bool _isCleanedUp; #endregion Fields @@ -114,9 +128,9 @@ public GrabFrame(HistoryInfo historyInfo) } /// - /// Creates a GrabFrame and loads the specified image file. + /// Creates a GrabFrame and loads the specified image or PDF file. /// - /// The path to the image file to load. + /// The path to the file to load. public GrabFrame(string imagePath) { StandardInitialize(); @@ -126,11 +140,11 @@ public GrabFrame(string imagePath) // Validate the path before loading if (string.IsNullOrEmpty(imagePath)) { - Debug.WriteLine("GrabFrame: Empty image path provided"); + Debug.WriteLine("GrabFrame: Empty file path provided"); Loaded += async (s, e) => await new Wpf.Ui.Controls.MessageBox { Title = "Text Grab", - Content = "No image file path was provided.", + Content = "No file path was provided.", CloseButtonText = "OK" }.ShowDialogAsync(); return; @@ -141,17 +155,17 @@ public GrabFrame(string imagePath) if (!File.Exists(absolutePath)) { - Debug.WriteLine($"GrabFrame: Image file not found: {absolutePath}"); + Debug.WriteLine($"GrabFrame: File not found: {absolutePath}"); Loaded += async (s, e) => await new Wpf.Ui.Controls.MessageBox { Title = "Text Grab", - Content = $"Image file not found:\n{absolutePath}", + Content = $"File not found:\n{absolutePath}", CloseButtonText = "OK" }.ShowDialogAsync(); return; } - Loaded += async (s, e) => await TryLoadImageFromPath(absolutePath); + Loaded += async (s, e) => await TryLoadDocumentFromPath(absolutePath); } /// @@ -202,7 +216,7 @@ private async Task LoadTemplateForEditing(GrabTemplate template) if (!string.IsNullOrEmpty(template.SourceImagePath) && File.Exists(template.SourceImagePath)) { isStaticImageSource = true; - await TryLoadImageFromPath(template.SourceImagePath); + await TryLoadDocumentFromPath(template.SourceImagePath); reDrawTimer.Stop(); } else @@ -283,6 +297,7 @@ private static string BuildPatternPlaceholderValue(TemplatePatternMatch config) private async Task LoadContentFromHistory(HistoryInfo history) { + CancelTablePlacement(clearManualSeparators: true); FrameText = history.TextContent; currentLanguage = history.OcrLanguage; SyncLanguageComboBoxSelection(currentLanguage); @@ -335,6 +350,7 @@ private async Task LoadContentFromHistory(HistoryInfo history) if (wbInfoList.Count > 0) { ScaleHistoryWordBordersToCanvas(history, wbInfoList); + tableEditState.SetManualSeparators(history.ManualTableRowSeparators, history.ManualTableColumnSeparators); foreach (WordBorderInfo info in wbInfoList) { @@ -352,6 +368,7 @@ private async Task LoadContentFromHistory(HistoryInfo history) } else { + tableEditState.SetManualSeparators(history.ManualTableRowSeparators, history.ManualTableColumnSeparators); reDrawTimer.Start(); ShouldSaveOnClose = true; } @@ -389,8 +406,14 @@ private Size GetGrabFrameNonContentSize() private void ScaleHistoryWordBordersToCanvas(HistoryInfo history, List wbInfoList) { - if (wbInfoList.Count == 0 || RectanglesCanvas.Width <= 0 || RectanglesCanvas.Height <= 0) + if ((wbInfoList.Count == 0 + && (history.ManualTableRowSeparators?.Count ?? 0) == 0 + && (history.ManualTableColumnSeparators?.Count ?? 0) == 0) + || RectanglesCanvas.Width <= 0 + || RectanglesCanvas.Height <= 0) + { return; + } Size savedContentSize = GetSavedHistoryContentSize(history); if (savedContentSize.Width <= 0 || savedContentSize.Height <= 0) @@ -401,13 +424,16 @@ private void ScaleHistoryWordBordersToCanvas(HistoryInfo history, List info.BorderRect.Right); - double maxBottom = wbInfoList.Max(info => info.BorderRect.Bottom); + double maxRight = wbInfoList.Count > 0 ? wbInfoList.Max(info => info.BorderRect.Right) : 0; + double maxBottom = wbInfoList.Count > 0 ? wbInfoList.Max(info => info.BorderRect.Bottom) : 0; // Scale only when saved word borders look like they were captured in // the old window-content coordinate space rather than image-space. - if (maxRight > savedContentSize.Width * 1.1 || maxBottom > savedContentSize.Height * 1.1) + if (wbInfoList.Count > 0 + && (maxRight > savedContentSize.Width * 1.1 || maxBottom > savedContentSize.Height * 1.1)) + { return; + } foreach (WordBorderInfo info in wbInfoList) { @@ -417,7 +443,16 @@ private void ScaleHistoryWordBordersToCanvas(HistoryInfo history, List 0) + info.DisplayLineHeight *= scaleY; } + + if (history.ManualTableRowSeparators is not null) + history.ManualTableRowSeparators = [.. history.ManualTableRowSeparators.Select(position => position * scaleY)]; + + if (history.ManualTableColumnSeparators is not null) + history.ManualTableColumnSeparators = [.. history.ManualTableColumnSeparators.Select(position => position * scaleX)]; } private Size GetSavedHistoryContentSize(HistoryInfo history) @@ -494,12 +529,13 @@ private void StandardInitialize() { InitializeComponent(); App.SetTheme(); + MainZoomBorder.ResetRequested += MainZoomBorder_ResetRequested; _ = LoadOcrLanguagesAsync(); SetRestoreState(); - WindowResizer resizer = new(this); + windowResizer = new WindowResizer(this); reDrawTimer.Interval = new(0, 0, 0, 0, 500); reDrawTimer.Tick += ReDrawTimer_Tick; @@ -519,6 +555,7 @@ private void StandardInitialize() SetRefreshOrOcrFrameBtnVis(); DataContext = this; + UpdateTableEditingUiState(); } private void FrameMessageTimer_Tick(object? sender, EventArgs e) @@ -544,6 +581,277 @@ private void ShowFrameMessage(string message) frameMessageTimer.Start(); } + private void AddTableColumnMenuItem_Click(object sender, RoutedEventArgs e) + { + BeginTablePlacement(GrabFrameTablePlacementMode.AddColumn); + } + + private void AddTableRowMenuItem_Click(object sender, RoutedEventArgs e) + { + BeginTablePlacement(GrabFrameTablePlacementMode.AddRow); + } + + private void BeginTablePlacement(GrabFrameTablePlacementMode placementMode) + { + if (TableToggleButton.IsChecked is not true || wordBorders.Count == 0) + { + ShowFrameMessage("Turn on table analysis after OCR to place table dividers."); + UpdateTableEditingUiState(); + return; + } + + _ = TryToPlaceTable(); + tableEditState.BeginPlacement(placementMode); + ClearTablePlacementPreview(); + UpdateTableEditingUiState(); + } + + private void CancelTablePlacement(bool clearManualSeparators = false) + { + if (clearManualSeparators) + tableEditState.ClearAll(); + else + tableEditState.CancelPlacement(); + + ClearTablePlacementPreview(); + UpdateTableEditingUiState(); + } + + private void CancelTablePlacement_Click(object sender, RoutedEventArgs e) + { + CancelTablePlacement(); + } + + private void ClearTablePlacementPreview() + { + TablePlacementOverlayCanvas.Children.Clear(); + } + + private void DrawTablePlacementPreview(Rect tableBounds) + { + ClearTablePlacementPreview(); + + if (!tableEditState.IsPlacementActive || tableEditState.PreviewPosition is not double previewPosition) + return; + + SolidColorBrush previewBrush = tableEditState.IsPreviewValid + ? new SolidColorBrush(Color.FromArgb(255, 40, 118, 126)) + : new SolidColorBrush(Color.FromArgb(255, 196, 43, 28)); + + Border previewLine = new() + { + Background = previewBrush, + IsHitTestVisible = false + }; + + if (tableEditState.PlacementMode == GrabFrameTablePlacementMode.AddRow) + { + previewLine.Height = 2; + previewLine.Width = tableBounds.Width; + Canvas.SetLeft(previewLine, tableBounds.Left); + Canvas.SetTop(previewLine, previewPosition - 1); + } + else + { + previewLine.Width = 2; + previewLine.Height = tableBounds.Height; + Canvas.SetLeft(previewLine, previewPosition - 1); + Canvas.SetTop(previewLine, tableBounds.Top); + } + + TablePlacementOverlayCanvas.Children.Add(previewLine); + } + + private bool TryCommitTablePlacement(Point pointerPosition) + { + UpdateTablePlacementPreview(pointerPosition); + + if (!tableEditState.TryCommitPreview()) + { + ShowFrameMessage("Move farther from the table edge or another divider."); + return true; + } + + string placementLabel = tableEditState.PlacementMode == GrabFrameTablePlacementMode.AddRow + ? "row" + : "column"; + + UpdateFrameText(); + UpdateTablePlacementPreview(pointerPosition); + ShowFrameMessage($"Added {placementLabel} divider."); + return true; + } + + private bool TryGetTablePlacementBounds(out Rect tableBounds) + { + tableBounds = Rect.Empty; + + if (TableToggleButton.IsChecked is not true || wordBorders.Count == 0) + return false; + + if (AnalyzedResultTable is null) + _ = TryToPlaceTable(); + + tableBounds = AnalyzedResultTable?.BoundingRect ?? Rect.Empty; + return tableBounds != Rect.Empty + && tableBounds.Width > 0 + && tableBounds.Height > 0; + } + + private void UpdateTableEditingUiState() + { + bool canEditTable = TableToggleButton.IsChecked is true && wordBorders.Count > 0; + + EditTableMenuItem.IsEnabled = canEditTable; + AddTableRowMenuItem.IsEnabled = canEditTable; + AddTableColumnMenuItem.IsEnabled = canEditTable; + CancelTablePlacementMenuItem.IsEnabled = tableEditState.IsPlacementActive; + TableToggleAddRowMenuItem.IsEnabled = canEditTable; + TableToggleAddColumnMenuItem.IsEnabled = canEditTable; + TableToggleCancelPlacementMenuItem.IsEnabled = tableEditState.IsPlacementActive; + + TablePlacementBanner.Visibility = tableEditState.IsPlacementActive ? Visibility.Visible : Visibility.Collapsed; + + if (!tableEditState.IsPlacementActive) + return; + + string placementTarget = tableEditState.PlacementMode == GrabFrameTablePlacementMode.AddRow + ? "row" + : "column"; + TablePlacementInstructionsTextBlock.Text = $"Click inside the table to place a {placementTarget} divider. Press Esc to cancel."; + } + + private void UpdateTablePlacementPreview(Point pointerPosition) + { + if (!tableEditState.IsPlacementActive || !TryGetTablePlacementBounds(out Rect tableBounds)) + { + ClearTablePlacementPreview(); + return; + } + + double minimumPosition; + double maximumPosition; + double requestedPosition; + IEnumerable existingSeparators; + + if (tableEditState.PlacementMode == GrabFrameTablePlacementMode.AddRow) + { + minimumPosition = tableBounds.Top + GrabFrameTableEditState.MinimumSeparatorGap; + maximumPosition = tableBounds.Bottom - GrabFrameTableEditState.MinimumSeparatorGap; + requestedPosition = pointerPosition.Y; + existingSeparators = AnalyzedResultTable?.RowLines ?? []; + } + else + { + minimumPosition = tableBounds.Left + GrabFrameTableEditState.MinimumSeparatorGap; + maximumPosition = tableBounds.Right - GrabFrameTableEditState.MinimumSeparatorGap; + requestedPosition = pointerPosition.X; + existingSeparators = AnalyzedResultTable?.ColumnLines ?? []; + } + + if (!tableEditState.TryUpdatePreview( + requestedPosition, + minimumPosition, + maximumPosition, + existingSeparators)) + { + DrawTablePlacementPreview(tableBounds); + return; + } + + DrawTablePlacementPreview(tableBounds); + } + + private void ClearLoadedPdfDocument() + { + _pdfPageNavCts?.Cancel(); + _pdfPageNavCts?.Dispose(); + _pdfPageNavCts = null; + _loadedPdfDocument?.Dispose(); + _loadedPdfDocument = null; + _currentPdfPageContent = null; + _currentPdfPageIndex = -1; + SetSpacePanModifierState(false); + UpdateZoomPanMode(); + SetScrollBehaviorMenuItems(); + UpdatePdfPageNavigation(); + } + + private async Task ChangePdfPageAsync(int delta) + { + if (_loadedPdfDocument is null) + return; + + int targetPageIndex = _currentPdfPageIndex + delta; + if (targetPageIndex < 0 || targetPageIndex >= _loadedPdfDocument.PageCount) + return; + + await ShowPdfPageAsync(targetPageIndex); + } + + private async Task ShowPdfPageAsync(int pageIndex) + { + if (_loadedPdfDocument is null) + return; + + CancellationTokenSource? previousCts = _pdfPageNavCts; + _pdfPageNavCts = new CancellationTokenSource(); + CancellationToken ct = _pdfPageNavCts.Token; + previousCts?.Cancel(); + previousCts?.Dispose(); + + try + { + reDrawTimer.Stop(); + CancelTablePlacement(clearManualSeparators: true); + ResetGrabFrame(); + await Task.Delay(300, ct); + + if (_loadedPdfDocument is null || ct.IsCancellationRequested) + return; + + _currentPdfPageContent = await _loadedPdfDocument.GetPageContentAsync(pageIndex); + frameContentImageSource = _currentPdfPageContent.RenderedPage; + hasLoadedImageSource = true; + isStaticImageSource = true; + frozenUiAutomationSnapshot = null; + liveUiAutomationSnapshot = null; + _currentImagePath = _loadedPdfDocument.FilePath; + _currentPdfPageIndex = pageIndex; + FreezeToggleButton.IsChecked = true; + FreezeGrabFrame(); + EnsureMinimumLoadedDocumentWindowSize(); + MainZoomBorder.CanZoom = true; + FreezeToggleButton.Visibility = Visibility.Collapsed; + UpdatePdfPageNavigation(); + SwitchToOcrFallbackIfUiAutomation(); + + reDrawTimer.Start(); + } + catch (OperationCanceledException) + { + // Navigation superseded by a newer request — no-op + } + } + + private void UpdatePdfPageNavigation() + { + bool isPdfLoaded = _loadedPdfDocument is not null; + PdfPagePanel.Visibility = isPdfLoaded ? Visibility.Visible : Visibility.Collapsed; + + if (!isPdfLoaded || _currentPdfPageIndex < 0) + { + PdfPageTextBlock.Text = string.Empty; + PreviousPdfPageButton.IsEnabled = false; + NextPdfPageButton.IsEnabled = false; + return; + } + + PdfPageTextBlock.Text = $"Page {_currentPdfPageIndex + 1} / {_loadedPdfDocument!.PageCount}"; + PreviousPdfPageButton.IsEnabled = _currentPdfPageIndex > 0; + NextPdfPageButton.IsEnabled = _currentPdfPageIndex < _loadedPdfDocument.PageCount - 1; + } + /// /// When a static image is loaded and the active language is UI Automation (Direct Text), /// silently switch to the OCR fallback language so no warning is shown. @@ -624,6 +932,7 @@ public TextBox? DestinationTextBox public bool IsEditingAnyWordBorders => wordBorders.Any(x => x.IsEditing); public bool IsFreezeMode { get; set; } = false; public bool IsFromEditWindow => destinationTextBox is not null; + private bool IsPdfDocumentLoaded => _loadedPdfDocument is not null; public bool IsWordEditMode { get; set; } = true; public bool ShouldSaveOnClose { get; set; } = true; @@ -637,6 +946,222 @@ public static bool CheckKey(VirtualKeyCodes code) return (GetKeyState(code) & 0xFF00) == 0xFF00; } + private static FrameworkElement? GetInteractionSurface(object? sender) => sender as FrameworkElement; + + private bool IsPdfTextInteraction(object? sender) => ReferenceEquals(sender, PdfTextCanvas); + + private bool IsZoomPanGestureActive => + MainZoomBorder.CanPan + && !KeyboardExtensions.IsShiftDown() + && !KeyboardExtensions.IsCtrlDown() + && (!MainZoomBorder.RequireSpaceToPan || isSpacePanModifierDown || Keyboard.IsKeyDown(Key.Space)); + + private bool CanUseSpacePanModifier => + MainZoomBorder.RequireSpaceToPan + && MainZoomBorder.CanPan + && !IsEditingAnyWordBorders + && Keyboard.FocusedElement is not TextBox and not RichTextBox; + + private void SetSpacePanModifierState(bool isDown) + { + isSpacePanModifierDown = isDown; + MainZoomBorder.IsSpacePanModifierPressed = isDown; + } + + private void MoveKeyboardFocusFromButtonBase() + { + if (MainZoomBorder.CanPan && Keyboard.FocusedElement is ButtonBase) + RectanglesCanvas.Focus(); + } + + private void UpdateZoomPanMode() + { + MainZoomBorder.RequireSpaceToPan = true; + } + + private void MainZoomBorder_ResetRequested(object? sender, EventArgs e) + { + frozenFrameContentScale = 1; + } + + private void ScaleFrozenOverlayElements(double widthScale, double heightScale) + { + if ((!double.IsFinite(widthScale) || widthScale <= 0) + || (!double.IsFinite(heightScale) || heightScale <= 0)) + { + return; + } + + if (Math.Abs(widthScale - 1) < 0.001 && Math.Abs(heightScale - 1) < 0.001) + { + return; + } + + foreach (WordBorder wordBorder in wordBorders) + { + wordBorder.Left *= widthScale; + wordBorder.Top *= heightScale; + wordBorder.Width *= widthScale; + wordBorder.Height *= heightScale; + + if (wordBorder.DisplayLineHeight > 0) + wordBorder.DisplayLineHeight *= heightScale; + } + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + { + pdfTextLine.Width *= widthScale; + pdfTextLine.Height *= heightScale; + Canvas.SetLeft(pdfTextLine, Canvas.GetLeft(pdfTextLine) * widthScale); + Canvas.SetTop(pdfTextLine, Canvas.GetTop(pdfTextLine) * heightScale); + } + + if (RectanglesCanvas.Children.Contains(selectBorder)) + { + selectBorder.Width *= widthScale; + selectBorder.Height *= heightScale; + Canvas.SetLeft(selectBorder, Canvas.GetLeft(selectBorder) * widthScale); + Canvas.SetTop(selectBorder, Canvas.GetTop(selectBorder) * heightScale); + } + + tableEditState.ScaleSeparators(heightScale, widthScale); + ClearTablePlacementPreview(); + } + + private void ApplyFrozenFrameContentScale() + { + MainZoomBorder.Reset(); + + if (!IsFreezeMode || frameContentImageSource is null) + return; + + double currentCanvasWidth = RectanglesCanvas.Width > 0 ? RectanglesCanvas.Width : RectanglesCanvas.ActualWidth; + double currentCanvasHeight = RectanglesCanvas.Height > 0 ? RectanglesCanvas.Height : RectanglesCanvas.ActualHeight; + + SyncRectanglesCanvasSizeToImage(); + + double newCanvasWidth = RectanglesCanvas.Width > 0 ? RectanglesCanvas.Width : RectanglesCanvas.ActualWidth; + double newCanvasHeight = RectanglesCanvas.Height > 0 ? RectanglesCanvas.Height : RectanglesCanvas.ActualHeight; + + if (currentCanvasWidth > 0 && currentCanvasHeight > 0 && newCanvasWidth > 0 && newCanvasHeight > 0) + ScaleFrozenOverlayElements(newCanvasWidth / currentCanvasWidth, newCanvasHeight / currentCanvasHeight); + + if (TableToggleButton.IsChecked is true && wordBorders.Count > 0) + UpdateFrameText(); + else + UpdateTemplateRegionOverlay(); + } + + private Rect GetCurrentWorkAreaBounds() + { + Rect currentWindowRect = new( + Left, + Top, + ActualWidth > 1 ? ActualWidth : Width, + ActualHeight > 1 ? ActualHeight : Height); + + Point windowCenter = new( + currentWindowRect.Left + (currentWindowRect.Width / 2.0), + currentWindowRect.Top + (currentWindowRect.Height / 2.0)); + + Rect? fallbackBounds = null; + double bestIntersectionArea = -1; + + foreach (DisplayInfo display in DisplayInfo.AllDisplayInfos) + { + Rect scaledBounds = display.ScaledBounds(); + + fallbackBounds ??= scaledBounds; + + if (scaledBounds.Contains(windowCenter)) + return scaledBounds; + + Rect intersection = Rect.Intersect(scaledBounds, currentWindowRect); + double intersectionArea = intersection.IsEmpty ? -1 : intersection.Width * intersection.Height; + + if (intersectionArea > bestIntersectionArea) + { + bestIntersectionArea = intersectionArea; + fallbackBounds = scaledBounds; + } + } + + return fallbackBounds ?? SystemParameters.WorkArea; + } + + private void EnsureMinimumLoadedDocumentWindowSize() + { + if (!isLoadedVisualDocument) + return; + + Rect currentWindowRect = new( + Left, + Top, + ActualWidth > 1 ? ActualWidth : Width, + ActualHeight > 1 ? ActualHeight : Height); + + Rect targetWindowRect = GrabFrameViewScaleUtilities.GetMinimumWindowRect( + currentWindowRect, + new Size( + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowWidth, + GrabFrameViewScaleUtilities.MinimumLoadedDocumentWindowHeight), + GetCurrentWorkAreaBounds()); + + if (Math.Abs(targetWindowRect.Width - currentWindowRect.Width) < 0.1 + && Math.Abs(targetWindowRect.Height - currentWindowRect.Height) < 0.1 + && Math.Abs(targetWindowRect.Left - currentWindowRect.Left) < 0.1 + && Math.Abs(targetWindowRect.Top - currentWindowRect.Top) < 0.1) + { + return; + } + + Left = targetWindowRect.Left; + Top = targetWindowRect.Top; + Width = targetWindowRect.Width; + Height = targetWindowRect.Height; + } + + private void ClearLoadedVisualDocumentState() + { + isLoadedVisualDocument = false; + frozenFrameContentScale = 1; + } + + private void MarkLoadedVisualDocumentOpened() + { + isLoadedVisualDocument = true; + frozenFrameContentScale = 1; + } + + private void ResetView() + { + frozenFrameContentScale = 1; + ApplyFrozenFrameContentScale(); + } + + private bool HasRenderedOcrOverlay() => wordBorders.Count > 0 || pdfTextLineOverlays.Count > 0; + + private void ChangeFrozenFrameScale(int direction) + { + bool preserveExistingOcr = HasRenderedOcrOverlay(); + + if (preserveExistingOcr) + reDrawTimer.Stop(); + + if (!IsFreezeMode) + FreezeGrabFrame(); + + if (frameContentImageSource is null) + return; + + frozenFrameContentScale = GrabFrameViewScaleUtilities.StepScale( + frozenFrameContentScale, + direction); + + ApplyFrozenFrameContentScale(); + ShowFrameMessage($"Image scale {frozenFrameContentScale:P0}"); + } + public HistoryInfo AsHistoryItem() { System.Drawing.Bitmap? bitmap = ImageMethods.ImageSourceToBitmap(frameContentImageSource); @@ -690,6 +1215,8 @@ public HistoryInfo AsHistoryItem() ImageContent = bitmap, PositionRect = sizePosRect, IsTable = TableToggleButton.IsChecked!.Value, + ManualTableColumnSeparators = tableEditState.ManualColumnSeparators.Count > 0 ? [.. tableEditState.ManualColumnSeparators] : null, + ManualTableRowSeparators = tableEditState.ManualRowSeparators.Count > 0 ? [.. tableEditState.ManualRowSeparators] : null, SourceMode = TextGrabMode.GrabFrame, }; @@ -698,7 +1225,10 @@ public HistoryInfo AsHistoryItem() public void BreakWordBorderIntoWords(WordBorder wordBorder) { - ICollection wordLines = wordBorder.Word.Split(Environment.NewLine); + ICollection wordLines = + (string.IsNullOrWhiteSpace(wordBorder.DisplayText) ? wordBorder.Word : wordBorder.DisplayText) + .Replace("\r\n", "\n") + .Split('\n'); const double widthScaleAdjustFactor = 1.5; ShouldSaveOnClose = true; @@ -804,6 +1334,16 @@ public async void GrabFrame_Loaded(object sender, RoutedEventArgs e) public void GrabFrame_Unloaded(object sender, RoutedEventArgs e) { + CleanupGrabFrame(); + } + + private void CleanupGrabFrame() + { + if (_isCleanedUp) + return; + _isCleanedUp = true; + + MainZoomBorder.ResetRequested -= MainZoomBorder_ResetRequested; Activated -= GrabFrameWindow_Activated; Closed -= Window_Closed; Deactivated -= GrabFrameWindow_Deactivated; @@ -820,6 +1360,9 @@ public void GrabFrame_Unloaded(object sender, RoutedEventArgs e) reDrawTimer.Stop(); reDrawTimer.Tick -= ReDrawTimer_Tick; + reSearchTimer.Stop(); + reSearchTimer.Tick -= ReSearchTimer_Tick; + frameMessageTimer.Stop(); frameMessageTimer.Tick -= FrameMessageTimer_Tick; @@ -855,6 +1398,30 @@ public void GrabFrame_Unloaded(object sender, RoutedEventArgs e) EditToggleButton.Click -= EditToggleButton_Click; SettingsBTN.Click -= SettingsBTN_Click; EditTextToggleButton.Click -= EditTextBTN_Click; + + windowResizer?.Dispose(); + windowResizer = null; + + foreach (WordBorder wb in wordBorders) + wb.OwnerGrabFrame = null; + wordBorders.Clear(); + + _loadedPdfDocument?.Dispose(); + _loadedPdfDocument = null; + _currentPdfPageContent = null; + + frameContentImageSource = null; + GrabFrameImage.Source = null; + ocrResultOfWindow = null; + frozenUiAutomationSnapshot = null; + liveUiAutomationSnapshot = null; + AnalyzedResultTable = null; + destinationTextBox = null; + historyItem = null; + movingWordBordersDictionary.Clear(); + originalTexts.Clear(); + pdfTextLineOverlays.Clear(); + RectanglesCanvas.Children.Clear(); } public void MergeSelectedWordBorders() @@ -1249,12 +1816,44 @@ private void CheckSelectBorderIntersections(bool finalCheck = false) wordBorder.WasRegionSelected = false; } + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + { + if (rectSelect.IntersectsWith(new Rect(pdfTextLine.Left, pdfTextLine.Top, pdfTextLine.Width, pdfTextLine.Height))) + { + clickedEmptySpace = false; + + if (!smallSelection) + { + pdfTextLine.Select(); + pdfTextLine.WasRegionSelected = true; + } + else if (!finalCheck) + { + if (pdfTextLine.IsSelected) + pdfTextLine.Deselect(); + else + pdfTextLine.Select(); + pdfTextLine.WasRegionSelected = false; + } + } + else if (pdfTextLine.WasRegionSelected && !smallSelection) + { + pdfTextLine.Deselect(); + } + + if (finalCheck) + pdfTextLine.WasRegionSelected = false; + } + if (clickedEmptySpace && smallSelection && finalCheck) { foreach (WordBorder wb in wordBorders) wb.Deselect(); + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + pdfTextLine.Deselect(); } if (finalCheck) @@ -1324,6 +1923,13 @@ private void ClearRenderedWordBorders() { RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); + } + + private void ClearRenderedPdfTextLines() + { + PdfTextCanvas.Children.Clear(); + pdfTextLineOverlays.Clear(); } private IReadOnlyCollection? GetUiAutomationExcludedHandles() @@ -1338,8 +1944,97 @@ private void ClearRenderedWordBorders() if (!double.IsFinite(viewBoxZoomFactor) || viewBoxZoomFactor <= 0 || viewBoxZoomFactor > 4) viewBoxZoomFactor = 1; - Point canvasOriginInBorder = RectanglesCanvas.TranslatePoint(new Point(0, 0), RectanglesBorder); - return (viewBoxZoomFactor, -canvasOriginInBorder.X, -canvasOriginInBorder.Y); + Point canvasOriginInBorder = RectanglesCanvas.TranslatePoint(new Point(0, 0), RectanglesBorder); + return (viewBoxZoomFactor, -canvasOriginInBorder.X, -canvasOriginInBorder.Y); + } + + private sealed class OcrBorderRenderInfo + { + public double DisplayLineHeight { get; init; } + + public string DisplayText { get; init; } = string.Empty; + + public bool KeepSingleLineOutput { get; init; } + + public int LineNumber { get; init; } + + public Windows.Foundation.Rect SourceRect { get; init; } + + public string Text { get; init; } = string.Empty; + } + + private IReadOnlyList CreateOcrBorderRenderInfos(DpiScale dpi, double viewBoxZoomFactor) + { + if (ocrResultOfWindow is null) + return []; + + List positionedLines = []; + + for (int i = 0; i < ocrResultOfWindow.Lines.Length; i++) + { + IOcrLine ocrLine = ocrResultOfWindow.Lines[i]; + positionedLines.Add(new OcrUtilities.PositionedOcrLine(i, GetNormalizedOcrLineText(ocrLine), ocrLine.BoundingBox)); + } + + if (!IsParagraphDetectionActive()) + { + return + [ + .. positionedLines.Select(line => new OcrBorderRenderInfo + { + DisplayText = line.Text, + LineNumber = line.LineNumber, + SourceRect = line.BoundingBox, + Text = line.Text, + }) + ]; + } + + return + [ + .. OcrUtilities.GroupWrappedParagraphLines(positionedLines) + .Select(group => new OcrBorderRenderInfo + { + DisplayLineHeight = group.Lines.Count > 1 + ? group.Lines.Average(line => GetDisplayHeightFromSourceHeight(line.BoundingBox.Height, dpi, windowFrameImageScale, viewBoxZoomFactor)) + : 0, + DisplayText = group.DisplayText, + KeepSingleLineOutput = group.Lines.Count > 1, + LineNumber = group.StartingLineNumber, + SourceRect = group.BoundingBox, + Text = group.SingleLineText, + }) + ]; + } + + private double GetDisplayHeightFromSourceHeight(double sourceHeight, DpiScale dpi, double sourceScale, double viewBoxZoomFactor) + { + return ((sourceHeight / (dpi.DpiScaleY * sourceScale)) + 2) / viewBoxZoomFactor; + } + + private string GetNormalizedOcrLineText(IOcrLine ocrLine) + { + StringBuilder lineText = new(); + ocrLine.GetTextFromOcrLine(isSpaceJoining, lineText); + lineText.RemoveTrailingNewlines(); + + string ocrText = lineText.ToString(); + + if (DefaultSettings.CorrectErrors) + ocrText = ocrText.TryFixEveryWordLetterNumberErrors(); + + if (DefaultSettings.CorrectToLatin) + ocrText = ocrText.ReplaceGreekOrCyrillicWithLatin(); + + if (CurrentLanguage!.IsRightToLeft()) + { + StringBuilder rtlText = new(ocrText); + rtlText.ReverseWordsForRightToLeft(); + rtlText.RemoveTrailingNewlines(); + return rtlText.ToString(); + } + + return ocrText; } private WordBorder CreateWordBorderFromSourceRect( @@ -1351,20 +2046,33 @@ private WordBorder CreateWordBorderFromSourceRect( DpiScale dpi, double viewBoxZoomFactor, double borderToCanvasX, - double borderToCanvasY) + double borderToCanvasY, + string? displayText = null, + bool keepSingleLineOutput = false, + double displayLineHeight = 0) { - return new() + double contentScale = IsFreezeMode ? frozenFrameContentScale : 1; + + WordBorder wordBorder = new() { - Width = ((sourceRect.Width / (dpi.DpiScaleX * sourceScale)) + 2) / viewBoxZoomFactor, - Height = ((sourceRect.Height / (dpi.DpiScaleY * sourceScale)) + 2) / viewBoxZoomFactor, - Top = ((sourceRect.Y / (dpi.DpiScaleY * sourceScale) - 1) + borderToCanvasY) / viewBoxZoomFactor, - Left = ((sourceRect.X / (dpi.DpiScaleX * sourceScale) - 1) + borderToCanvasX) / viewBoxZoomFactor, - Word = text, + DisplayLineHeight = displayLineHeight * contentScale, + Width = (((sourceRect.Width / (dpi.DpiScaleX * sourceScale)) + 2) / viewBoxZoomFactor) * contentScale, + Height = (((sourceRect.Height / (dpi.DpiScaleY * sourceScale)) + 2) / viewBoxZoomFactor) * contentScale, + KeepSingleLineOutput = keepSingleLineOutput, + Top = (((sourceRect.Y / (dpi.DpiScaleY * sourceScale) - 1) + borderToCanvasY) / viewBoxZoomFactor) * contentScale, + Left = (((sourceRect.X / (dpi.DpiScaleX * sourceScale) - 1) + borderToCanvasX) / viewBoxZoomFactor) * contentScale, OwnerGrabFrame = this, LineNumber = lineNumber, IsFromEditWindow = IsFromEditWindow, MatchingBackground = backgroundBrush, }; + + if (keepSingleLineOutput && !string.IsNullOrWhiteSpace(displayText)) + wordBorder.DisplayText = displayText; + else + wordBorder.Word = text; + + return wordBorder; } private void AddRenderedWordBorder(WordBorder wordBorderBox) @@ -1384,6 +2092,29 @@ private void AddRenderedWordBorder(WordBorder wordBorderBox) }); } + private PdfTextLineOverlay CreatePdfTextLineOverlay(Windows.Foundation.Rect sourceRect, double sourceScale, string text, DpiScale dpi) + { + double contentScale = IsFreezeMode ? frozenFrameContentScale : 1; + Rect displayRect = new( + (sourceRect.X / (dpi.DpiScaleX * sourceScale)) * contentScale, + (sourceRect.Y / (dpi.DpiScaleY * sourceScale)) * contentScale, + (sourceRect.Width / (dpi.DpiScaleX * sourceScale)) * contentScale, + (sourceRect.Height / (dpi.DpiScaleY * sourceScale)) * contentScale); + + PdfTextLineOverlay overlay = new(text); + overlay.ApplyLayout(displayRect); + return overlay; + } + + private void AddRenderedPdfTextLine(PdfTextLineOverlay overlay) + { + if (!IsOcrValid) + return; + + pdfTextLineOverlays.Add(overlay); + _ = PdfTextCanvas.Children.Add(overlay); + } + private Task DrawRectanglesAroundWords(string searchWord = "") { return CurrentLanguage is UiAutomationLang @@ -1396,6 +2127,12 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") if (isDrawing || IsDragOver) return; + if (_currentPdfPageContent?.HasNativeText is true) + { + await DrawPdfRectanglesAsync(searchWord); + return; + } + isDrawing = true; IsOcrValid = true; @@ -1449,7 +2186,6 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") shouldDisposeBmp = true; } - int lineNumber = 0; bool useImageCoords = frameContentImageSource is not null; (double viewBoxZoomFactor, double borderToCanvasX, double borderToCanvasY) = useImageCoords ? (1.0, 0.0, 0.0) : GetOverlayRenderMetrics(); @@ -1457,49 +2193,32 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") if (useImageCoords) SyncRectanglesCanvasSizeToImage(); - foreach (IOcrLine ocrLine in ocrResultOfWindow.Lines) - { - StringBuilder lineText = new(); - ocrLine.GetTextFromOcrLine(isSpaceJoining, lineText); - lineText.RemoveTrailingNewlines(); + IReadOnlyList renderInfos = CreateOcrBorderRenderInfos(dpi, viewBoxZoomFactor); - Windows.Foundation.Rect lineRect = ocrLine.BoundingBox; + foreach (OcrBorderRenderInfo renderInfo in renderInfos) + { + Windows.Foundation.Rect lineRect = renderInfo.SourceRect; SolidColorBrush backgroundBrush = new(Colors.Black); if (bmp is not null) backgroundBrush = GetBackgroundBrushFromOcrBitmap(windowFrameImageScale, bmp, ref lineRect); - string ocrText = lineText.ToString(); - - if (DefaultSettings.CorrectErrors) - ocrText = ocrText.TryFixEveryWordLetterNumberErrors(); - - if (DefaultSettings.CorrectToLatin) - ocrText = ocrText.ReplaceGreekOrCyrillicWithLatin(); - WordBorder wordBorderBox = CreateWordBorderFromSourceRect( lineRect, windowFrameImageScale, - ocrText, - lineNumber, + renderInfo.Text, + renderInfo.LineNumber, backgroundBrush, dpi, viewBoxZoomFactor, borderToCanvasX, - borderToCanvasY); - - if (CurrentLanguage!.IsRightToLeft()) - { - StringBuilder sb = new(ocrText); - sb.ReverseWordsForRightToLeft(); - sb.RemoveTrailingNewlines(); - wordBorderBox.Word = sb.ToString(); - } + borderToCanvasY, + renderInfo.DisplayText, + renderInfo.KeepSingleLineOutput, + renderInfo.DisplayLineHeight); AddRenderedWordBorder(wordBorderBox); - - lineNumber++; } SetRotationBasedOnOcrResult(); @@ -1524,6 +2243,71 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") } } + private async Task DrawPdfRectanglesAsync(string searchWord = "") + { + if (isDrawing || IsDragOver || _loadedPdfDocument is null || _currentPdfPageContent is null || _currentPdfPageIndex < 0) + return; + + isDrawing = true; + IsOcrValid = true; + windowFrameImageScale = 1; + ocrResultOfWindow = null; + + if (string.IsNullOrWhiteSpace(searchWord)) + searchWord = SearchBox.Text; + + ClearRenderedWordBorders(); + + if (frameContentImageSource is not BitmapSource) + { + isDrawing = false; + reDrawTimer.Start(); + return; + } + + DpiScale dpi = VisualTreeHelper.GetDpi(this); + SyncRectanglesCanvasSizeToImage(); + isSpaceJoining = CurrentLanguage!.IsSpaceJoining(); + + IReadOnlyList pageLines = await _loadedPdfDocument.GetSelectableLinesAsync(_currentPdfPageIndex, CurrentLanguage); + + foreach (PdfPageTextLine pageLine in pageLines) + { + string lineText = pageLine.Text; + if (!pageLine.IsNativeText) + { + if (DefaultSettings.CorrectErrors) + lineText = lineText.TryFixEveryWordLetterNumberErrors(); + + if (DefaultSettings.CorrectToLatin) + lineText = lineText.ReplaceGreekOrCyrillicWithLatin(); + } + + if (CurrentLanguage!.IsRightToLeft() && !pageLine.IsNativeText) + { + StringBuilder sb = new(lineText); + sb.ReverseWordsForRightToLeft(); + sb.RemoveTrailingNewlines(); + lineText = sb.ToString(); + } + + PdfTextLineOverlay overlay = CreatePdfTextLineOverlay(pageLine.SourceRect, 1, lineText, dpi); + AddRenderedPdfTextLine(overlay); + } + + if (DefaultSettings.TryToReadBarcodes) + TryToReadBarcodes(dpi); + + isDrawing = false; + reSearchTimer.Start(); + + if (isTranslationEnabled && WindowsAiUtilities.CanDeviceUseWinAI()) + { + translationTimer.Stop(); + translationTimer.Start(); + } + } + private async Task DrawUiAutomationRectanglesAsync(string searchWord = "") { if (isDrawing || IsDragOver) @@ -1673,7 +2457,7 @@ private void EditTextBTN_Click(object? sender = null, RoutedEventArgs? e = null) if (destinationTextBox is null) { - EditTextWindow etw = WindowUtilities.OpenOrActivateWindow(); + EditTextWindow etw = WindowUtilities.OpenOrActivateEditTextWindow(TableToggleButton.IsChecked is true); destinationTextBox = etw.GetMainTextBox(); } @@ -1709,16 +2493,30 @@ private void EnterEditMode() private void Escape_Keyed(object sender, ExecutedRoutedEventArgs e) { + if (tableEditState.IsPlacementActive) + { + CancelTablePlacement(); + return; + } + if (wordBorders.Any(x => x.IsEditing)) { GrabBTN.Focus(); return; } - if (!string.IsNullOrWhiteSpace(SearchBox.Text) && SearchBox.Text != "Search For Text...") + if (TextSearchUtilities.HasSearchText(SearchBox.Text) && SearchBox.Text != "Search For Text...") SearchBox.Text = ""; else if (RectanglesCanvas.Children.Count > 0) + { + CancelTablePlacement(clearManualSeparators: true); ResetGrabFrame(); + } + else if (PdfTextCanvas.Children.Count > 0) + { + CancelTablePlacement(clearManualSeparators: true); + ResetGrabFrame(); + } else Close(); } @@ -1768,9 +2566,12 @@ private void FreezeGrabFrame() Background = new SolidColorBrush(Colors.DimGray); RectanglesBorder.Background.Opacity = 0; IsFreezeMode = true; + UpdateZoomPanMode(); if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen) MainZoomBorder.CanZoom = true; + + ApplyFrozenFrameContentScale(); } private void SyncRectanglesCanvasSizeToImage() @@ -1784,20 +2585,25 @@ private void SyncRectanglesCanvasSizeToImage() // Using raw PixelWidth would cause the Viewbox to scale down at DPI > 100%, // shifting viewBoxZoomFactor and borderToCanvasX/Y, and misplacing word borders. DpiScale dpi = VisualTreeHelper.GetDpi(this); - double sourceWidth = source.PixelWidth > 0 ? source.PixelWidth / dpi.DpiScaleX : source.Width; - double sourceHeight = source.PixelHeight > 0 ? source.PixelHeight / dpi.DpiScaleY : source.Height; + double contentScale = IsFreezeMode ? frozenFrameContentScale : 1; + double sourceWidth = (source.PixelWidth > 0 ? source.PixelWidth / dpi.DpiScaleX : source.Width) * contentScale; + double sourceHeight = (source.PixelHeight > 0 ? source.PixelHeight / dpi.DpiScaleY : source.Height) * contentScale; if (double.IsFinite(sourceWidth) && sourceWidth > 0) { GrabFrameImage.Width = sourceWidth; + PdfTextCanvas.Width = sourceWidth; RectanglesCanvas.Width = sourceWidth; + TablePlacementOverlayCanvas.Width = sourceWidth; TemplateRegionOverlayCanvas.Width = sourceWidth; } if (double.IsFinite(sourceHeight) && sourceHeight > 0) { GrabFrameImage.Height = sourceHeight; + PdfTextCanvas.Height = sourceHeight; RectanglesCanvas.Height = sourceHeight; + TablePlacementOverlayCanvas.Height = sourceHeight; TemplateRegionOverlayCanvas.Height = sourceHeight; } } @@ -1806,6 +2612,12 @@ private async void FreezeMI_Click(object sender, RoutedEventArgs e) { if (IsFreezeMode) { + if (IsPdfDocumentLoaded) + { + FreezeToggleButton.IsChecked = true; + return; + } + FreezeToggleButton.IsChecked = false; UnfreezeGrabFrame(); ResetGrabFrame(); @@ -1827,6 +2639,8 @@ private void FreezeToggleButton_Click(object? sender = null, RoutedEventArgs? e { if (FreezeToggleButton.IsChecked is bool freezeMode && freezeMode) FreezeGrabFrame(); + else if (IsPdfDocumentLoaded) + FreezeToggleButton.IsChecked = true; else UnfreezeGrabFrame(); } @@ -1964,14 +2778,20 @@ private void GrabFrameWindow_Closing(object sender, System.ComponentModel.Cancel Singleton.Instance.SaveToHistory(this); historyItem?.ClearTransientImage(); + ClearLoadedPdfDocument(); FrameText = ""; wordBorders.Clear(); + pdfTextLineOverlays.Clear(); UpdateFrameText(); } private void GrabFrameWindow_Deactivated(object? sender, EventArgs e) { + _spacePanGraceTimer?.Stop(); + _spacePanGraceTimer = null; + SetSpacePanModifierState(false); + if (!IsWordEditMode && !IsFreezeMode) { ResetGrabFrame(); @@ -2011,7 +2831,7 @@ private async void GrabFrameWindow_Drop(object sender, DragEventArgs e) frameContentImageSource = null; isStaticImageSource = true; - await TryLoadImageFromPath(fileName); + await TryLoadDocumentFromPath(fileName); IsDragOver = false; @@ -2118,6 +2938,14 @@ private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e) if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen && IsFreezeMode) return; // ZoomBorder handles scroll when frozen + if (IsPdfDocumentLoaded) + { + // ZoomBorder handles the scroll and sets CanPan=true synchronously after we return. + // Defer a focus check so ButtonBase never holds focus while panning is possible. + Dispatcher.InvokeAsync(MoveKeyboardFocusFromButtonBase, DispatcherPriority.Input); + return; + } + e.Handled = true; double aspectRatio = (Height - 66) / (Width - 4); @@ -2161,6 +2989,16 @@ private void InvertSelection(object? sender = null, RoutedEventArgs? e = null) else wordBorder.Select(); } + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + { + if (pdfTextLine.IsSelected) + pdfTextLine.Deselect(); + else + pdfTextLine.Select(); + } + + UpdateFrameText(); } private void LanguagesComboBox_MouseDown(object sender, MouseButtonEventArgs e) @@ -2351,7 +3189,7 @@ private async void OpenImageMenuItem_Click(object? sender = null, RoutedEventArg Microsoft.Win32.OpenFileDialog dlg = new() { // Set filter for file extension and default file extension - Filter = FileUtilities.GetImageFilter() + Filter = FileUtilities.GetVisualDocumentFilter() }; bool? result = dlg.ShowDialog(); @@ -2359,7 +3197,7 @@ private async void OpenImageMenuItem_Click(object? sender = null, RoutedEventArg if (result is false || !File.Exists(dlg.FileName)) return; - await TryLoadImageFromPath(dlg.FileName); + await TryLoadDocumentFromPath(dlg.FileName); reDrawTimer.Start(); } @@ -2373,6 +3211,8 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul reDrawTimer.Stop(); + ClearLoadedVisualDocumentState(); + CancelTablePlacement(clearManualSeparators: true); ResetGrabFrame(); await Task.Delay(300); @@ -2386,12 +3226,15 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul frameContentImageSource = clipboardImage; } + ClearLoadedPdfDocument(); hasLoadedImageSource = true; isStaticImageSource = true; + MarkLoadedVisualDocumentOpened(); frozenUiAutomationSnapshot = null; liveUiAutomationSnapshot = null; FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); + EnsureMinimumLoadedDocumentWindowSize(); FreezeToggleButton.Visibility = Visibility.Collapsed; SwitchToOcrFallbackIfUiAutomation(); @@ -2405,8 +3248,36 @@ private async void RateAndReview_Click(object sender, RoutedEventArgs e) private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) { + bool isPdfTextInteraction = IsPdfTextInteraction(sender); + FrameworkElement interactionSurface = isPdfTextInteraction + ? (e.OriginalSource as FrameworkElement ?? PdfTextCanvas) + : (GetInteractionSurface(sender) ?? RectanglesCanvas); + bool shouldPanInsteadOfSelect = MainZoomBorder.CanPan + && (IsPdfDocumentLoaded + ? IsZoomPanGestureActive + : (IsZoomPanGestureActive && !isPdfTextInteraction)); + reDrawTimer.Stop(); - GrabBTN.Focus(); + if (!MainZoomBorder.CanPan) + GrabBTN.Focus(); + + if (tableEditState.IsPlacementActive) + { + if (e.RightButton == MouseButtonState.Pressed + || e.MiddleButton == MouseButtonState.Pressed + || IsCtrlDown + || shouldPanInsteadOfSelect) + { + e.Handled = true; + return; + } + + if (e.ChangedButton == MouseButton.Left) + { + e.Handled = TryCommitTablePlacement(e.GetPosition(RectanglesCanvas)); + return; + } + } if (e.RightButton == MouseButtonState.Pressed) { @@ -2418,17 +3289,17 @@ private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) { if (e.MiddleButton == MouseButtonState.Pressed) { - MainZoomBorder.Reset(); + ResetView(); return; } - if (!KeyboardExtensions.IsShiftDown() && !KeyboardExtensions.IsCtrlDown()) + if (shouldPanInsteadOfSelect) return; } isSelecting = true; clickedPoint = e.GetPosition(RectanglesCanvas); - RectanglesCanvas.CaptureMouse(); + interactionSurface.CaptureMouse(); selectBorder.Height = 1; selectBorder.Width = 1; @@ -2439,8 +3310,11 @@ private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) e.Handled = true; isMiddleDown = true; - ResetGrabFrame(); - UnfreezeGrabFrame(); + if (!IsPdfDocumentLoaded) + { + ResetGrabFrame(); + UnfreezeGrabFrame(); + } return; } @@ -2460,21 +3334,42 @@ private void RectanglesCanvas_MouseDown(object sender, MouseButtonEventArgs e) private void RectanglesCanvas_MouseMove(object sender, MouseEventArgs e) { + FrameworkElement interactionSurface = GetInteractionSurface(sender) ?? RectanglesCanvas; + bool isPdfTextInteraction = IsPdfTextInteraction(sender); + bool shouldPanInsteadOfSelect = MainZoomBorder.CanPan + && ((IsPdfDocumentLoaded || !isPdfTextInteraction) && IsZoomPanGestureActive); + + if (tableEditState.IsPlacementActive) + { + interactionSurface.Cursor = (!IsCtrlDown && !shouldPanInsteadOfSelect) + ? Cursors.Cross + : Cursors.Arrow; + + if (IsCtrlDown || shouldPanInsteadOfSelect) + { + ClearTablePlacementPreview(); + return; + } + + UpdateTablePlacementPreview(e.GetPosition(RectanglesCanvas)); + return; + } + if (IsCtrlDown) - RectanglesCanvas.Cursor = Cursors.Cross; + interactionSurface.Cursor = Cursors.Cross; else if (MainZoomBorder.CanPan) - RectanglesCanvas.Cursor = Cursors.SizeAll; + interactionSurface.Cursor = shouldPanInsteadOfSelect + ? Cursors.SizeAll + : Cursors.Arrow; else - RectanglesCanvas.Cursor = null; + interactionSurface.Cursor = null; if (!isSelecting && !isMiddleDown && movingWordBordersDictionary.Count == 0) return; isMiddleDown = e.MiddleButton == MouseButtonState.Pressed; - if (MainZoomBorder.CanPan - && !KeyboardExtensions.IsShiftDown() - && !KeyboardExtensions.IsCtrlDown()) + if (shouldPanInsteadOfSelect) { isSelecting = false; return; @@ -2522,12 +3417,16 @@ private void RectanglesCanvas_MouseUp(object sender, MouseButtonEventArgs e) { isSelecting = false; CursorClipper.UnClipCursor(); - RectanglesCanvas.ReleaseMouseCapture(); + Mouse.Captured?.ReleaseMouseCapture(); + + if (tableEditState.IsPlacementActive) + return; if (e.ChangedButton == MouseButton.Middle && scrollBehavior != ScrollBehavior.Zoom) { isMiddleDown = false; - FreezeGrabFrame(); + if (!IsPdfDocumentLoaded) + FreezeGrabFrame(); reDrawTimer.Start(); return; } @@ -2657,13 +3556,13 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = private void RemoveTableLines() { - Canvas? tableLines = null; - - foreach (object? child in RectanglesCanvas.Children) - if (child is Canvas element && element.Tag is "TableLines") - tableLines = element; + List tableLines = + [.. RectanglesCanvas.Children + .OfType() + .Where(element => element.Tag is "TableLines")]; - RectanglesCanvas.Children.Remove(tableLines); + foreach (Canvas tableLineCanvas in tableLines) + RectanglesCanvas.Children.Remove(tableLineCanvas); } private void ReSearchTimer_Tick(object? sender, EventArgs e) @@ -2672,10 +3571,13 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e) if (SearchBox.Text is not string searchText) return; - if (string.IsNullOrWhiteSpace(searchText) && !isSearchSelectionOverridden) + if (!TextSearchUtilities.HasSearchText(searchText) && !isSearchSelectionOverridden) { foreach (WordBorder wb in wordBorders) wb.Deselect(); + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + pdfTextLine.Deselect(); MatchesTXTBLK.Text = $"0 Matches"; UpdateFrameText(); return; @@ -2688,15 +3590,17 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e) try { - regex = new(searchText, RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); - - if (ExactMatchChkBx.IsChecked is true) - regex = new(searchText, RegexOptions.Multiline); + regex = TextSearchUtilities.CreateGrabFrameSearchRegex( + searchText, + ExactMatchChkBx.IsChecked is true); } catch (Exception) { foreach (WordBorder wb in wordBorders) wb.Deselect(); + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + pdfTextLine.Deselect(); UpdateFrameText(); MatchesTXTBLK.Text = $"Search Error"; return; @@ -2716,6 +3620,17 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e) else wb.Deselect(); } + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + { + int numberOfMatchesInLine = regex.Count(pdfTextLine.Text); + numberOfMatches += numberOfMatchesInLine; + + if (numberOfMatchesInLine > 0) + pdfTextLine.Select(); + else + pdfTextLine.Deselect(); + } } UpdateFrameText(); @@ -2739,12 +3654,17 @@ private void ReSearchTimer_Tick(object? sender, EventArgs e) private void ResetGrabFrame() { + CancelTablePlacement(); + RemoveTableLines(); + AnalyzedResultTable = null; SetRefreshOrOcrFrameBtnVis(); MainZoomBorder.Reset(); RectanglesCanvas.RenderTransform = Transform.Identity; RectanglesCanvas.ClearValue(WidthProperty); RectanglesCanvas.ClearValue(HeightProperty); + TablePlacementOverlayCanvas.ClearValue(WidthProperty); + TablePlacementOverlayCanvas.ClearValue(HeightProperty); TemplateRegionOverlayCanvas.ClearValue(WidthProperty); TemplateRegionOverlayCanvas.ClearValue(HeightProperty); GrabFrameImage.ClearValue(WidthProperty); @@ -2796,6 +3716,11 @@ private void SelectAllWordBorders(object? sender = null, RoutedEventArgs? e = nu { foreach (WordBorder wordBorder in wordBorders) wordBorder.Select(); + + foreach (PdfTextLineOverlay pdfTextLine in pdfTextLineOverlays) + pdfTextLine.Select(); + + UpdateFrameText(); } private void SetGrabFrameUserSettings() @@ -3203,17 +4128,40 @@ private static StoredRegex[] LoadSavedPatterns() return patterns.Length == 0 ? StoredRegex.GetDefaultPatterns() : patterns; } - private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null) + private async void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null) { + CancelTablePlacement(); RemoveTableLines(); + + if (ShouldRefreshOcrBordersForTableModeActivation()) + { + await DrawRectanglesAroundWords(SearchBox.Text); + UpdateFrameText(); + return; + } + UpdateFrameText(); } + private async Task TryLoadDocumentFromPath(string path) + { + if (IoUtilities.IsPdfFileExtension(Path.GetExtension(path))) + { + await TryLoadPdfFromPath(path); + return; + } + + await TryLoadImageFromPath(path); + } + private async Task TryLoadImageFromPath(string path) { Uri fileURI = new(path); try { + ClearLoadedPdfDocument(); + ClearLoadedVisualDocumentState(); + CancelTablePlacement(clearManualSeparators: true); ResetGrabFrame(); await Task.Delay(300); BitmapImage droppedImage = new(); @@ -3226,11 +4174,13 @@ private async Task TryLoadImageFromPath(string path) frameContentImageSource = droppedImage; hasLoadedImageSource = true; isStaticImageSource = true; + MarkLoadedVisualDocumentOpened(); frozenUiAutomationSnapshot = null; liveUiAutomationSnapshot = null; _currentImagePath = path; FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); + EnsureMinimumLoadedDocumentWindowSize(); FreezeToggleButton.Visibility = Visibility.Collapsed; SwitchToOcrFallbackIfUiAutomation(); @@ -3238,6 +4188,7 @@ private async Task TryLoadImageFromPath(string path) } catch (Exception) { + ClearLoadedVisualDocumentState(); hasLoadedImageSource = false; UnfreezeGrabFrame(); await new Wpf.Ui.Controls.MessageBox @@ -3249,6 +4200,32 @@ private async Task TryLoadImageFromPath(string path) } } + private async Task TryLoadPdfFromPath(string path) + { + try + { + ClearLoadedPdfDocument(); + ClearLoadedVisualDocumentState(); + _loadedPdfDocument = await PdfDocumentRenderer.LoadAsync(path); + MarkLoadedVisualDocumentOpened(); + _currentImagePath = Path.GetFullPath(path); + await ShowPdfPageAsync(0); + } + catch (Exception ex) + { + ClearLoadedPdfDocument(); + ClearLoadedVisualDocumentState(); + hasLoadedImageSource = false; + UnfreezeGrabFrame(); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = $"Failed to open PDF.{Environment.NewLine}{ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); + } + } + private void TryToAlphaMenuItem_Click(object sender, RoutedEventArgs e) { List wbToEdit = SelectedWordBorders(); @@ -3283,10 +4260,18 @@ private void TryToNumberMenuItem_Click(object sender, RoutedEventArgs e) UndoRedo.EndTransaction(); } - private void TryToPlaceTable() + private List TryToPlaceTable() { RemoveTableLines(); + List wbInfos = [.. wordBorders.Select(wb => new WordBorderInfo(wb))]; + if (wbInfos.Count == 0) + { + AnalyzedResultTable = null; + tableEditState.SetManualSeparators(tableEditState.ManualRowSeparators, tableEditState.ManualColumnSeparators); + return wbInfos; + } + Point windowPosition = this.GetAbsolutePosition(); DpiScale dpi = VisualTreeHelper.GetDpi(this); System.Drawing.Rectangle rectCanvasSize = new() @@ -3300,9 +4285,14 @@ private void TryToPlaceTable() try { AnalyzedResultTable = new(); - // Convert UI controls to model-only infos - List wbInfos = [.. wordBorders.Select(wb => new WordBorderInfo(wb))]; - AnalyzedResultTable.AnalyzeAsTable(wbInfos, rectCanvasSize); + AnalyzedResultTable.AnalyzeAsTable( + wbInfos, + rectCanvasSize, + tableEditState.ManualRowSeparators, + tableEditState.ManualColumnSeparators); + tableEditState.SetManualSeparators( + AnalyzedResultTable.ManualRowSeparators, + AnalyzedResultTable.ManualColumnSeparators); if (AnalyzedResultTable.TableLines is not null) RectanglesCanvas.Children.Add(AnalyzedResultTable.TableLines); } @@ -3310,6 +4300,8 @@ private void TryToPlaceTable() { Debug.WriteLine(ex.Message); } + + return wbInfos; } private void TryToReadBarcodes(DpiScale dpi) @@ -3426,7 +4418,12 @@ private void UndoExecuted(object sender, ExecutedRoutedEventArgs e) private void UnfreezeGrabFrame() { + if (IsPdfDocumentLoaded) + return; + reDrawTimer.Stop(); + ClearLoadedPdfDocument(); + ClearLoadedVisualDocumentState(); hasLoadedImageSource = false; isStaticImageSource = false; frozenUiAutomationSnapshot = null; @@ -3441,6 +4438,7 @@ private void UnfreezeGrabFrame() FreezeToggleButton.Visibility = Visibility.Visible; Background = new SolidColorBrush(Colors.Transparent); IsFreezeMode = false; + UpdateZoomPanMode(); if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen) MainZoomBorder.CanZoom = false; @@ -3448,28 +4446,78 @@ private void UnfreezeGrabFrame() reDrawTimer.Start(); } - private void UpdateFrameText() + private async void PreviousPdfPageButton_Click(object sender, RoutedEventArgs e) + { + await ChangePdfPageAsync(-1); + } + + private async void NextPdfPageButton_Click(object sender, RoutedEventArgs e) + { + await ChangePdfPageAsync(1); + } + + private void AppendPositionedTextLines( + StringBuilder stringBuilder, + IEnumerable<(double Top, double Left, double Height, string Text, bool AllowParagraphJoin)> lines) { - string[] selectedWbs = [.. wordBorders - .OrderBy(b => b.Top) - .Where(w => w.IsSelected) - .Select(t => t.Word)]; + List<(double Top, double Left, double Height, string Text, bool AllowParagraphJoin)> orderedLines = + [.. lines + .Where(line => !string.IsNullOrWhiteSpace(line.Text)) + .OrderBy(line => line.Top) + .ThenBy(line => line.Left)]; + + if (orderedLines.Count == 0) + return; + + stringBuilder.Append(orderedLines[0].Text); + for (int i = 1; i < orderedLines.Count; i++) + { + (double Top, double Left, double Height, string Text, bool AllowParagraphJoin) previousLine = orderedLines[i - 1]; + (double Top, double Left, double Height, string Text, bool AllowParagraphJoin) currentLine = orderedLines[i]; + + bool shouldJoinParagraph = + IsParagraphDetectionActive() + && previousLine.AllowParagraphJoin + && currentLine.AllowParagraphJoin + && OcrUtilities.IsWrappedParagraph(previousLine.Top, previousLine.Height, currentLine.Top, currentLine.Height); + + if (shouldJoinParagraph) + stringBuilder.Append(' '); + else + stringBuilder.AppendLine(); + + stringBuilder.Append(currentLine.Text); + } + } + private void UpdateFrameText() + { StringBuilder stringBuilder = new(); + List<(double Top, double Left, double Height, string Text, bool AllowParagraphJoin)> selectedLines = + [.. wordBorders + .Where(w => w.IsSelected) + .Select(w => (w.Top, w.Left, w.Height, w.Word, AllowParagraphJoin: false)) + .Concat(pdfTextLineOverlays + .Where(line => line.IsSelected) + .Select(line => (line.Top, line.Left, line.Height, line.Text, AllowParagraphJoin: true)))]; - if (TableToggleButton.IsChecked is true) + if (TableToggleButton.IsChecked is true && wordBorders.Count > 0) { - TryToPlaceTable(); - // Build table text via model-only API - List infos = [.. wordBorders.Select(wb => new WordBorderInfo(wb))]; + List infos = TryToPlaceTable(); ResultTable.GetTextFromTabledWordBorders(stringBuilder, infos, isSpaceJoining); } else { - if (selectedWbs.Length > 0) - stringBuilder.AppendJoin(Environment.NewLine, selectedWbs); + if (selectedLines.Count > 0) + AppendPositionedTextLines(stringBuilder, selectedLines); + else if (pdfTextLineOverlays.Count > 0) + AppendPositionedTextLines( + stringBuilder, + wordBorders + .Select(w => (w.Top, w.Left, w.Height, w.Word, AllowParagraphJoin: false)) + .Concat(pdfTextLineOverlays.Select(line => (line.Top, line.Left, line.Height, line.Text, AllowParagraphJoin: true)))); else - stringBuilder.AppendJoin(Environment.NewLine, [.. wordBorders.Select(w => w.Word)]); + AppendWordBordersWithParagraphDetection(stringBuilder); } FrameText = stringBuilder.ToString(); @@ -3483,12 +4531,50 @@ private void UpdateFrameText() destinationTextBox.SelectedText = FrameText; } + UpdateTableEditingUiState(); UpdateTemplateRegionOverlay(); } + private void AppendWordBordersWithParagraphDetection(StringBuilder sb) + { + List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)]; + if (sorted.Count == 0) + return; + + sb.Append(sorted[0].Word); + for (int i = 1; i < sorted.Count; i++) + { + WordBorder prev = sorted[i - 1]; + WordBorder curr = sorted[i]; + if (IsParagraphDetectionActive() + && OcrUtilities.IsWrappedParagraph(prev.Top, prev.Height, curr.Top, curr.Height)) + sb.Append(' '); + else + sb.AppendLine(); + sb.Append(curr.Word); + } + } + + private bool IsParagraphDetectionActive() + { + return OcrUtilities.ShouldUseParagraphDetection(isSpaceJoining, TableToggleButton.IsChecked is true); + } + + private bool ShouldRefreshOcrBordersForTableModeActivation() + { + return TableToggleButton.IsChecked is true + && CurrentLanguage is not null + && CurrentLanguage is not UiAutomationLang + && CurrentLanguage.IsSpaceJoining() + && DefaultSettings.ParagraphDetection + && _currentPdfPageContent?.HasNativeText is not true + && wordBorders.Any(wb => wb.KeepSingleLineOutput); + } + private void Window_Closed(object? sender, EventArgs e) { SetGrabFrameUserSettings(); + CleanupGrabFrame(); WindowUtilities.ShouldShutDown(); } @@ -3504,6 +4590,19 @@ private void Window_LocationChanged(object? sender, EventArgs e) private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { + if (e.Key == Key.Space) + { + // Cancel any pending grace-period clear when Space is pressed + _spacePanGraceTimer?.Stop(); + _spacePanGraceTimer = null; + if (CanUseSpacePanModifier) + { + SetSpacePanModifierState(true); + e.Handled = true; + return; + } + } + if (!wasAltHeld && (e.SystemKey == Key.LeftAlt || e.SystemKey == Key.RightAlt)) { RectanglesCanvas.Opacity = 0.1; @@ -3529,6 +4628,29 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e) private void Window_PreviewKeyUp(object sender, KeyEventArgs e) { + if (e.Key == Key.Space) + { + // Keep the pan modifier active for a short grace period after Space is released. + // Users commonly release Space a split-second before clicking to start a pan, + // so clearing immediately makes the gesture feel broken. + _spacePanGraceTimer?.Stop(); + _spacePanGraceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _spacePanGraceTimer.Tick += (_, _) => + { + _spacePanGraceTimer?.Stop(); + _spacePanGraceTimer = null; + if (!Keyboard.IsKeyDown(Key.Space)) + SetSpacePanModifierState(false); + }; + _spacePanGraceTimer.Start(); + + if (CanUseSpacePanModifier) + { + e.Handled = true; + return; + } + } + if (wasAltHeld && (e.SystemKey == Key.LeftAlt || e.SystemKey == Key.RightAlt)) { RectanglesCanvas.Opacity = 1; @@ -3565,14 +4687,27 @@ private void CloseOnGrabMenuItem_Click(object sender, RoutedEventArgs e) private void ResetViewMenuItem_Click(object sender, RoutedEventArgs e) { - MainZoomBorder.Reset(); + ResetView(); + } + + private void ScaleUpMenuItem_Click(object sender, RoutedEventArgs e) + { + ChangeFrozenFrameScale(1); + } + + private void ScaleDownMenuItem_Click(object sender, RoutedEventArgs e) + { + ChangeFrozenFrameScale(-1); } private void ShowWordBordersMenuItem_Click(object sender, RoutedEventArgs e) { - RectanglesCanvas.Visibility = ShowWordBordersMenuItem.IsChecked is true + Visibility overlayVisibility = ShowWordBordersMenuItem.IsChecked is true ? Visibility.Visible : Visibility.Hidden; + + RectanglesCanvas.Visibility = overlayVisibility; + PdfTextCanvas.Visibility = overlayVisibility; } private void OverlayOpacityMenuItem_Click(object sender, RoutedEventArgs e) @@ -3728,6 +4863,9 @@ private void SetScrollBehaviorMenuItems() default: break; } + + if (IsPdfDocumentLoaded) + MainZoomBorder.CanZoom = true; } private void InvertColorsMI_Click(object sender, RoutedEventArgs e) @@ -3802,6 +4940,7 @@ private void AutoContrastMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); @@ -3849,6 +4988,7 @@ private void BrightenMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); @@ -3896,6 +5036,7 @@ private void DarkenMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); @@ -3943,6 +5084,7 @@ private void GrayscaleMI_Click(object sender, RoutedEventArgs e) reDrawTimer.Stop(); RectanglesCanvas.Children.Clear(); wordBorders.Clear(); + ClearRenderedPdfTextLines(); if (!IsFreezeMode) FreezeGrabFrame(); diff --git a/Text-Grab/Views/LicensesWindow.xaml b/Text-Grab/Views/LicensesWindow.xaml new file mode 100644 index 00000000..0d05c658 --- /dev/null +++ b/Text-Grab/Views/LicensesWindow.xaml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + This page lists the direct NuGet package references used by the Text Grab application and test projects. Common permissive licenses open the upstream repository license file, and package-specific Microsoft terms are included locally when the NuGet package ships its own license text. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Views/LicensesWindow.xaml.cs b/Text-Grab/Views/LicensesWindow.xaml.cs new file mode 100644 index 00000000..deb099d8 --- /dev/null +++ b/Text-Grab/Views/LicensesWindow.xaml.cs @@ -0,0 +1,41 @@ +using System.Collections.ObjectModel; +using System.Windows; +using Text_Grab.Models; +using Text_Grab.Utilities; +using Wpf.Ui.Controls; + +namespace Text_Grab.Views; + +public partial class LicensesWindow : FluentWindow +{ + public ObservableCollection Packages { get; } = [.. ThirdPartyNoticeUtilities.Packages]; + + public LicensesWindow() + { + InitializeComponent(); + App.SetTheme(); + DataContext = this; + } + + private void BuiltWithButton_Click(object sender, RoutedEventArgs e) + { + ThirdPartyNoticeUtilities.OpenBuiltWithFile(); + } + + private void NoticesFolderButton_Click(object sender, RoutedEventArgs e) + { + ThirdPartyNoticeUtilities.OpenNoticesDirectory(); + } + + private void NoticeButton_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { DataContext: ThirdPartyPackageInfo package }) + ThirdPartyNoticeUtilities.OpenNoticeFile(package); + } + + private void ProjectButton_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { DataContext: ThirdPartyPackageInfo package }) + ThirdPartyNoticeUtilities.OpenProjectUrl(package); + } +} diff --git a/ThirdPartyNotices/licenses/Markdig-license.txt b/ThirdPartyNotices/licenses/Markdig-license.txt new file mode 100644 index 00000000..31051235 --- /dev/null +++ b/ThirdPartyNotices/licenses/Markdig-license.txt @@ -0,0 +1,23 @@ +Copyright (c) 2018-2019, Alexandre Mutel +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification +, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/ThirdPartyNotices/licenses/Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md b/ThirdPartyNotices/licenses/Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md new file mode 100644 index 00000000..16414343 --- /dev/null +++ b/ThirdPartyNotices/licenses/Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers-LICENSE.md @@ -0,0 +1,94 @@ +# **MICROSOFT SOFTWARE LICENSE TERMS** + +## MICROSOFT VISUAL STUDIO 2022 REMOTE DEBUGGER, INTELLITRACE COLLECTOR, other DEBUGGERS, AGENTS and BUILD TOOLS + +--- + +These license terms are an agreement between you and Microsoft Corporation (or based on where you live, one of its affiliates). They apply to the software named above. The terms also apply to any Microsoft services or updates for the software, except to the extent those have different terms. + +**IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW.** + +### 1.INSTALLATION AND USE RIGHTS. + +A. You may install and use any number of copies of the software to use solely with Visual Studio Community, Visual Studio Professional, and Visual Studio Enterprise (collectively, "Visual Studio Products"), to develop and test your applications; and + +B. **Build Tools additional use right.** Regardless of whether you have a Visual Studio license as described above, you may install and use copies of the software to compile and build C++ components that both have been released by their owner under an open-source software license approved by the Open Source Initiative and are reasonably required to build your applications ("Open Source Dependencies"). + +i. You may not use the software pursuant to subsection (B) to develop and test the Open Source Dependencies, except, and only to the extent, minor modifications are necessary so that the Open Source Dependencies can be compiled and built with the software. + +### 2.TERMS FOR SPECIFIC COMPONENTS. + +A. **Utilities.** The software contains items on the Utilities List at [https://aka.ms/vs/17/utilities](https://aka.ms/vs/17/utilities). You may copy and install those items onto your devices to debug and deploy your applications and databases you developed with Visual Studio Products.The Utilities are designed for temporary use. Microsoft may not be able to patch or update Utilities separately from the rest of the software. Some Utilities by their nature may make it possible for others to access the devices on which the Utilities are installed. You should delete all Utilities you have installed after you finish debugging or deploying your applications and databases. Microsoft is not responsible for any third party use or access of devices, or of the applications or databases on devices, on which Utilities have been installed. + +B. **Build Devices and Visual Studio Build Tools.** You may copy and install files from the software onto your build devices, including physical devices and virtual machines or containers on those machines, whether on-premises or remote machines that are owned by you, hosted on Microsoft Azure for you, or dedicated solely to your use (collectively, "Build Devices"). You and others in your organization may use these files on your Build Devices solely to (a) compile, build, and verify (i) applications developed by using Visual Studio Products and (ii) Open Source Dependencies, and (b) run quality or performance tests of those applications and Open Source Dependencies as part of the build process. + +C. **Microsoft Platforms.** The software may include components from Microsoft Windows; Microsoft Windows Server; Microsoft SQL Server; Microsoft Exchange; Microsoft Office; and Microsoft SharePoint, or other Microsoft software. These components are governed by separate agreements and their own product support policies, as described in the Microsoft "Licenses" folder accompanying the software, except that, if license terms for those components are also included in the associated installation directory, those license terms control. + +D. **Third Party Components.** The software may include third party components with separate legal notices or governed by other agreements, as may be described in the notices file(s) accompanying the software. + +### 3.DATA. + +A. **Data Collection.** The software may collect information about you and your use of the software, and send that to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may opt-out of many of these scenarios, but not all, as described in the software documentation. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft's privacy statement. Our privacy statement is located at [https://aka.ms/privacy](https://aka.ms/privacy). You can learn more about data collection and its use from the software documentation and our privacy statement. Your use of the software operates as your consent to these practices. + +B. **Processing of Personal Data.** To the extent Microsoft is a processor or subprocessor of personal data in connection with the software, Microsoft makes the commitments in the European Union General Data Protection Regulation Terms of the Microsoft Products and Services Data Protection Addendum to all customers effective May 25, 2018, at [https://docs.microsoft.com/legal/gdpr](https://docs.microsoft.com/legal/gdpr). + +### 4. SCOPE OF LICENSE. + +The software is licensed, not sold. These license terms only give you some rights to use the software. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you may use the software only as expressly permitted in these license terms. In doing so, you must comply with any technical limitations in the software that only allow you to use it in certain ways. In addition, you may not: + +- work around any technical limitations in the software; +- reverse engineer, decompile or disassemble the software, or otherwise attempt to derive the source code for the software, except and only to the extent required by third party licensing terms governing use of certain open source components that may be included with the software; +- remove, minimize, block or modify any notices of Microsoft or its suppliers in the software; +- use the software in any way that is against the law; +- share, publish, rent, or lease the software; or +- provide the software as a stand-alone offering or combine it with any of your applications for others to use, or transfer the software or this agreement to any third party. + +### 5. FEEDBACK. + +If you give feedback about the software to Microsoft, you give to Microsoft, without charge, the right to use, share, and commercialize your feedback in any way and for any purpose. You will not give feedback that is subject to a license that requires Microsoft to license its software or documentation to third parties because we include your feedback in them. These rights survive this agreement. + +### 6. SUPPORT SERVICES. + +Because this software is "as is", we may not provide support services for it. + +### 7. ENTIRE AGREEMENT. + +This agreement, and the terms for supplements, updates, Internet-based services and support services, are the entire agreement for the software and support services. + +### 8.EXPORT RESTRICTIONS. + +You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users, and end use. For further information on export restrictions, visit [www.microsoft.com/exporting](http://www.microsoft.com/exporting). + +### 9.APPLICABLE LAW. + +If you acquired the software in the United States, Washington state law applies to interpretation of, and claims for breach of this agreement, and the laws of the state where you live apply to all other claims. If you acquired the software in any other country, its laws apply. + +### 10.CONSUMER RIGHTS; REGIONAL VARIATIONS. + +These license terms describe certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. You may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you: + +1.) **Australia.** You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights. + +2.) **Canada.** You may stop receiving updates on your device by turning off Internet access. If and when you re-connect to the Internet, the software will resume checking for and installing updates. + +3.) **Germany and Austria.** + +i. **Warranty**. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software. + +ii. **Limitation of Liability**. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in the case of death or personal or physical injury, Microsoft is liable according to the statutory law. + +Subject to the preceding sentence (ii), Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence. + +### 11.DISCLAIMER OF WARRANTY. + +THE SOFTWARE IS LICENSED "AS-IS". YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES OR CONDITIONS. TO THE EXTENT PERMITTED UNDER YOUR LOCAL LAWS, MICROSOFT EXCLUDES THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + +### 12.LIMITATION ON AND EXCLUSION OF DAMAGES. + +YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT OR INCIDENTAL DAMAGES. + +This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party applications; and (b) claims for breach of contract, breach of warranty, guarantee or condition, strict liability, negligence, or other tort to the extent permitted by applicable law. + +It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your country may not allow the exclusion or limitation of incidental, consequential or other damages. + +EULAID: VS_2022_Tools_2022July_md_ENU.1033 diff --git a/ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt b/ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt new file mode 100644 index 00000000..832ce752 --- /dev/null +++ b/ThirdPartyNotices/licenses/Microsoft.WindowsAppSDK-license.txt @@ -0,0 +1,91 @@ +MICROSOFT SOFTWARE LICENSE TERMS +MICROSOFT WINDOWS APP SDK +________________________________________ +IF YOU LIVE IN (OR ARE A BUSINESS WITH A PRINCIPAL PLACE OF BUSINESS IN) THE UNITED STATES, PLEASE READ THE “BINDING ARBITRATION AND CLASS ACTION WAIVER” SECTION BELOW. IT AFFECTS HOW DISPUTES ARE RESOLVED. +________________________________________ + +These license terms are an agreement between you and Microsoft Corporation (or one of its affiliates). They apply to the software named above and any Microsoft services or software updates (except to the extent such services or updates are accompanied by new or additional terms, in which case those different terms apply prospectively and do not alter your or Microsoft’s rights relating to pre-updated software or services). IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW. BY USING THE SOFTWARE, YOU ACCEPT THESE TERMS. + +1. INSTALLATION AND USE RIGHTS. + + a) General. Subject to the terms of this agreement, you may install and use any number of copies of the software to develop and test your applications, solely for use on Windows. When building Generative AI applications follow the guidelines in https://learn.microsoft.com/windows/ai/rai. + + b) Included Microsoft Applications. The software may include other Microsoft applications. These license terms apply to those included applications, if any, unless other license terms are provided with the other Microsoft applications. + + c) Microsoft Platforms. The software may include components from Microsoft Windows. These components are governed by separate agreements and their own product support policies, as described in the license terms found in the installation directory for that component or in the “Licenses” folder accompanying the software. + +2. DATA. + + a) Data Collection. The software may collect information about you and your use of the software, and send that to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may opt-out of many of these scenarios, but not all, as described in the product documentation. There are also some features in the software that may enable you to collect data from users of your applications. If you use these features to enable data collection in your applications, you must comply with applicable law, including providing appropriate notices to users of your applications. You can learn more about data collection and use in the help documentation and the privacy statement at https://aka.ms/privacy. Your use of the software operates as your consent to these practices. + + b) Processing of Personal Data. To the extent Microsoft is a processor or subprocessor of personal data in connection with the software, Microsoft makes the commitments in the European Union General Data Protection Regulation Terms of the Online Services Terms to all customers effective May 25, 2018, at https://docs.microsoft.com/en-us/legal/gdpr. + +3. DISTRIBUTABLE CODE. The software may contain code you are permitted to distribute (i.e. make available for third parties) in applications you develop, as described in this Section. + + a) Distribution Rights. The code and test files described below are distributable if included with the software. + + i. Any files that are binplaced with your application by the WindowsAppSDK NuGet package are, by definition, permitted to be redistributed. This applies to both framework package dependent and self-contained deployments. + + ii. Image Library. You may copy and distribute images, graphics, and animations in the Image Library as described in the software documentation; and + + iii. Third Party Distribution. You may permit distributors of your applications to copy and distribute any of this distributable code you elect to distribute with your applications. + + b) Distribution Requirements. For any code you distribute, you must: + + i. add significant primary functionality to it in your applications; + + ii. require distributors and external end users to agree to terms that protect it and Microsoft at least as much as this agreement; and + + iii. indemnify, defend, and hold harmless Microsoft from any claims, including attorneys’ fees, related to the distribution or use of your applications, except to the extent that any claim is based solely on the unmodified distributable code. + + c) Distribution Restrictions. You may not: + + i. use Microsoft’s trademarks or trade dress in your application in any way that suggests your application comes from or is endorsed by Microsoft; or + + ii. modify or distribute the source code of any distributable code so that any part of it becomes subject to any license that requires that the distributable code, any other part of the software, or any of Microsoft’s other intellectual property be disclosed or distributed in source code form, or that others have the right to modify it. + +4. SCOPE OF LICENSE. The software is licensed, not sold. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you will not (and have no right to): + + a) work around any technical limitations in the software that only allow you to use it in certain ways; + + b) reverse engineer, decompile or disassemble the software, or otherwise attempt to derive the source code for the software, except and to the extent required by third party licensing terms governing use of certain open source components that may be included in the software; + + c) remove, minimize, block, or modify any notices of Microsoft or its suppliers in the software; + + d) use the software in any way that is against the law or to create or propagate malware; or + + e) share, publish, distribute, or lease the software (except for any distributable code, subject to the terms above), provide the software as a stand-alone offering for others to use, or transfer the software or this agreement to any third party. + +5. EXPORT RESTRICTIONS. You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users, and end use. For further information on export restrictions, visit https://aka.ms/exporting. + +6. SUPPORT SERVICES. Microsoft is not obligated under this agreement to provide any support services for the software. Any support provided is “as is”, “with all faults”, and without warranty of any kind. + +7. UPDATES. The software may periodically check for updates, and download and install them for you. You may obtain updates only from Microsoft or authorized sources. Microsoft may need to update your system to provide you with updates. You agree to receive these automatic updates without any additional notice. Updates may not include or support all existing software features, services, or peripheral devices. + +8. BINDING ARBITRATION AND CLASS ACTION WAIVER. This Section applies if you live in (or, if a business, your principal place of business is in) the United States. If you and Microsoft have a dispute, you and Microsoft agree to try for 60 days to resolve it informally. If you and Microsoft can’t, you and Microsoft agree to binding individual arbitration before the American Arbitration Association under the Federal Arbitration Act (“FAA”), and not to sue in court in front of a judge or jury. Instead, a neutral arbitrator will decide. Class action lawsuits, class-wide arbitrations, private attorney-general actions, and any other proceeding where someone acts in a representative capacity are not allowed; nor is combining individual proceedings without the consent of all parties. The complete Arbitration Agreement contains more terms and is at https://aka.ms/arb-agreement-4. You and Microsoft agree to these terms. + +9. ENTIRE AGREEMENT. This agreement, and any other terms Microsoft may provide for supplements, updates, or third-party applications, is the entire agreement for the software. + +10. APPLICABLE LAW AND PLACE TO RESOLVE DISPUTES. If you acquired the software in the United States or Canada, the laws of the state or province where you live (or, if a business, where your principal place of business is located) govern the interpretation of this agreement, claims for its breach, and all other claims (including consumer protection, unfair competition, and tort claims), regardless of conflict of laws principles, except that the FAA governs everything related to arbitration. If you acquired the software in any other country, its laws apply, except that the FAA governs everything related to arbitration. If U.S. federal jurisdiction exists, you and Microsoft consent to exclusive jurisdiction and venue in the federal court in King County, Washington for all disputes heard in court (excluding arbitration). If not, you and Microsoft consent to exclusive jurisdiction and venue in the Superior Court of King County, Washington for all disputes heard in court (excluding arbitration). + +11. CONSUMER RIGHTS; REGIONAL VARIATIONS. This agreement describes certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. Separate and apart from your relationship with Microsoft, you may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you: + + a) Australia. You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights. + + b) Canada. If you acquired this software in Canada, you may stop receiving updates by turning off the automatic update feature, disconnecting your device from the Internet (if and when you re-connect to the Internet, however, the software will resume checking for and installing updates), or uninstalling the software. The product documentation, if any, may also specify how to turn off updates for your specific device or software. + + c) Germany and Austria. + + i. Warranty. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software. + + ii. Limitation of Liability. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in case of death or personal or physical injury, Microsoft is liable according to the statutory law. + + Subject to the foregoing clause ii., Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence. + +12. DISCLAIMER OF WARRANTY. THE SOFTWARE IS LICENSED “AS IS.” YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES, OR CONDITIONS. TO THE EXTENT PERMITTED UNDER APPLICABLE LAWS, MICROSOFT EXCLUDES ALL IMPLIED WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. + +13. LIMITATION ON AND EXCLUSION OF DAMAGES. IF YOU HAVE ANY BASIS FOR RECOVERING DAMAGES DESPITE THE PRECEDING DISCLAIMER OF WARRANTY, YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT, OR INCIDENTAL DAMAGES. + +This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party applications; and (b) claims for breach of contract, warranty, guarantee, or condition; strict liability, negligence, or other tort; or any other claim; in each case to the extent permitted by applicable law. + +It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your state, province, or country may not allow the exclusion or limitation of incidental, consequential, or other damages.