diff --git a/Tests/ClipboardUtilitiesTests.cs b/Tests/ClipboardUtilitiesTests.cs
index f88de72c..9efef250 100644
--- a/Tests/ClipboardUtilitiesTests.cs
+++ b/Tests/ClipboardUtilitiesTests.cs
@@ -134,4 +134,34 @@ public void ConvertHtmlToTabSeparated_HandlesRowspan()
Assert.Equal("Tall\tTop", lines[0]);
Assert.Equal("Tall\tBottom", lines[1]);
}
+
+ // The Text Grab browser extension's Table mode (including its layout
+ // reconstruction fallback for non-
grids) writes a clean
+ // to the clipboard with
for
+ // newlines and &-style entity escaping, then hands off via
+ // text-grab://paste-spreadsheet. This pins compatibility with that exact
+ // output (see Text-Grab-Extension/lib/formats.js -> toCleanHtmlTable).
+ private const string ExtensionRegionTableCfHtml = """
+ Version:0.9
+ StartHTML:00000097
+ EndHTML:00000260
+ StartFragment:00000131
+ EndFragment:00000224
+
+ | Product | Qty | Unit price |
| USB-C hub | 12 | $24.50 |
Monitor arm | 5 | $130 & up |
+
+ """;
+
+ [Fact]
+ public void ConvertHtmlToTabSeparated_ParsesBrowserExtensionRegionTable()
+ {
+ string result = ClipboardUtilities.ConvertHtmlToTabSeparated(ExtensionRegionTableCfHtml);
+
+ string[] lines = result.Split('\n');
+ Assert.Equal(3, lines.Length);
+ Assert.Equal("Product\tQty\tUnit price", lines[0]);
+ Assert.Equal("USB-C hub\t12\t$24.50", lines[1]);
+ //
collapses to a space; & decodes to &.
+ Assert.Equal("Monitor arm\t5\t$130 & up", lines[2]);
+ }
}
diff --git a/Tests/EditTextWindowSpellCheckTests.cs b/Tests/EditTextWindowSpellCheckTests.cs
new file mode 100644
index 00000000..878f16d6
--- /dev/null
+++ b/Tests/EditTextWindowSpellCheckTests.cs
@@ -0,0 +1,108 @@
+using Text_Grab;
+
+namespace Tests;
+
+public class EditTextWindowSpellCheckTests
+{
+ [Fact]
+ public void NormalSentence_SpellCheckEnabled()
+ {
+ string text = "The quick brown fox jumps over the lazy dog.";
+ Assert.True(EditTextWindow.ShouldEnableSpellCheck(text));
+ }
+
+ [Fact]
+ public void EmptyString_SpellCheckEnabled()
+ {
+ Assert.True(EditTextWindow.ShouldEnableSpellCheck(string.Empty));
+ }
+
+ [Fact]
+ public void TextExceedsLengthThreshold_SpellCheckDisabled()
+ {
+ string longText = new string('a', 10_001);
+ Assert.False(EditTextWindow.ShouldEnableSpellCheck(longText));
+ }
+
+ [Fact]
+ public void TwoLongWords_SpellCheckEnabled()
+ {
+ // Only 2 long words — below the threshold of 3
+ string text = "normal words then SomeVeryLongManifestTokenThatIsOver25Chars and AnotherReallyLongTokenHere123 end";
+ Assert.True(EditTextWindow.ShouldEnableSpellCheck(text));
+ }
+
+ [Fact]
+ public void ThreeLongWords_SpellCheckDisabled()
+ {
+ // 3 words each >= 25 chars → should disable spell check
+ string text = "Microsoft.Windows.AppManifest.Version1234 " +
+ "com.example.application.package.name.v2 " +
+ "SomeGuidLike_1234567890abcdef1234 " +
+ "normal short words";
+ Assert.False(EditTextWindow.ShouldEnableSpellCheck(text));
+ }
+
+ [Fact]
+ public void AppManifestLikeContent_SpellCheckDisabled()
+ {
+ string manifest = """
+
+
+
+
+ """;
+ Assert.False(EditTextWindow.ShouldEnableSpellCheck(manifest));
+ }
+
+ [Fact]
+ public void WordExactlyAtLongWordLength_NotCountedAsLong()
+ {
+ // Word of exactly 24 chars should NOT count as "very long"
+ string word24 = new string('x', 24);
+ string word25 = new string('y', 25);
+ // Two words of 24 + one of 25 = only one long word → still enabled
+ string text = $"{word24} {word24} {word25} normal text";
+ Assert.True(EditTextWindow.ShouldEnableSpellCheck(text));
+ }
+
+ [Fact]
+ public void GuidTokens_SpellCheckDisabled()
+ {
+ // GUIDs are 32+ chars without hyphens when copy-pasted from some apps
+ string text = "id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 " +
+ "token=f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2 " +
+ "hash=1234567890abcdef1234567890abcdef12";
+ Assert.False(EditTextWindow.ShouldEnableSpellCheck(text));
+ }
+
+ [Fact]
+ public void AlwaysOnMode_EnabledEvenForContentAutoWouldReject()
+ {
+ // Content Auto mode would disable (3+ long tokens), but Always On forces it on
+ string text = "Microsoft.Windows.AppManifest.Version1234 " +
+ "com.example.application.package.name.v2 " +
+ "SomeGuidLike_1234567890abcdef1234";
+ Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, text));
+ Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.AlwaysOn, text));
+ }
+
+ [Fact]
+ public void OffMode_DisabledEvenForNormalText()
+ {
+ string text = "The quick brown fox jumps over the lazy dog.";
+ Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, text));
+ Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Off, text));
+ }
+
+ [Fact]
+ public void AutoMode_MatchesContentHeuristic()
+ {
+ string normal = "The quick brown fox.";
+ string longTokens = new string('a', 10_001);
+ Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, normal));
+ Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, longTokens));
+ }
+}
diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs
index 18f17917..0ee8f830 100644
--- a/Tests/GrabTemplateExecutorTests.cs
+++ b/Tests/GrabTemplateExecutorTests.cs
@@ -436,6 +436,79 @@ public void GrabTemplate_GetReferencedPatternNames_ParsesNames()
Assert.Contains("Phone Number", names);
}
+ [Fact]
+ public void GrabTemplate_IsTextOnly_TrueWhenNoRegions()
+ {
+ GrabTemplate template = new("Test")
+ {
+ OutputTemplate = "{p:Email:first}"
+ };
+
+ Assert.True(template.IsTextOnly);
+ }
+
+ [Fact]
+ public void GrabTemplate_IsTextOnly_FalseWhenRegionsPresent()
+ {
+ GrabTemplate template = new("Test")
+ {
+ OutputTemplate = "{1}",
+ Regions = [new TemplateRegion { RegionNumber = 1 }]
+ };
+
+ Assert.False(template.IsTextOnly);
+ }
+
+ // ── ApplyTextOnlyTemplate ─────────────────────────────────────────────────
+
+ [Fact]
+ public void ApplyTextOnlyTemplate_LiteralOutput_IgnoresInputText()
+ {
+ GrabTemplate template = new("Header")
+ {
+ OutputTemplate = "Static header line"
+ };
+
+ string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "some selected text");
+ Assert.Equal("Static header line", result);
+ }
+
+ [Fact]
+ public void ApplyTextOnlyTemplate_RegionPlaceholders_ResolveToEmpty()
+ {
+ GrabTemplate template = new("Test")
+ {
+ OutputTemplate = "Region: {1}!"
+ };
+
+ string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "input");
+ Assert.Equal("Region: !", result);
+ }
+
+ [Fact]
+ public void ApplyTextOnlyTemplate_EscapeSequences_AreProcessed()
+ {
+ GrabTemplate template = new("Test")
+ {
+ OutputTemplate = @"line1\nline2"
+ };
+
+ string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "input");
+ Assert.Equal("line1\nline2", result);
+ }
+
+ [Fact]
+ public void ApplyTextOnlyTemplate_InvalidTemplate_ReturnsInputUnchanged()
+ {
+ GrabTemplate template = new("Test")
+ {
+ OutputTemplate = string.Empty
+ };
+
+ string result = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, "keep me");
+ Assert.Equal("keep me", result);
+ }
+
// ── ValidateOutputTemplate with patterns ──────────────────────────────────
[Fact]
diff --git a/Tests/ImageChangeDetectorTests.cs b/Tests/ImageChangeDetectorTests.cs
new file mode 100644
index 00000000..2b61a58d
--- /dev/null
+++ b/Tests/ImageChangeDetectorTests.cs
@@ -0,0 +1,73 @@
+using System.Drawing;
+using Text_Grab.Utilities;
+
+namespace Tests;
+
+public class ImageChangeDetectorTests
+{
+ private const string fontTestPath = @".\Images\FontTest.png";
+ private const string fontSamplePath = @".\Images\font_sample.png";
+
+ [Fact]
+ public void FirstCapture_EstablishesBaseline_ReportsNoChange()
+ {
+ using ImageChangeDetector detector = new();
+ using Bitmap image = new(FileUtilities.GetPathToLocalFile(fontTestPath));
+
+ Assert.False(detector.CheckForChangeAndUpdate(image));
+ }
+
+ [Fact]
+ public void SameCapture_ReportsNoChange()
+ {
+ using ImageChangeDetector detector = new();
+ using Bitmap image = new(FileUtilities.GetPathToLocalFile(fontTestPath));
+
+ _ = detector.CheckForChangeAndUpdate(image);
+
+ Assert.False(detector.CheckForChangeAndUpdate(image));
+ }
+
+ [Fact]
+ public void DifferentCapture_ReportsChange_OnceItHoldsForTwoChecks()
+ {
+ using ImageChangeDetector detector = new();
+ using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath));
+ using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontSamplePath));
+
+ _ = detector.CheckForChangeAndUpdate(image1);
+
+ // First differing capture is not yet stable, so no change is reported.
+ Assert.False(detector.CheckForChangeAndUpdate(image2));
+ Assert.True(detector.CheckForChangeAndUpdate(image2));
+ }
+
+ [Fact]
+ public void TransientCapture_DoesNotReportChange()
+ {
+ using ImageChangeDetector detector = new();
+ using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath));
+ using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontSamplePath));
+
+ _ = detector.CheckForChangeAndUpdate(image1);
+
+ // A one-check blip (flash indicator, half-rendered frame) that
+ // reverts to the baseline never reports a change.
+ Assert.False(detector.CheckForChangeAndUpdate(image2));
+ Assert.False(detector.CheckForChangeAndUpdate(image1));
+ Assert.False(detector.CheckForChangeAndUpdate(image1));
+ }
+
+ [Fact]
+ public void Reset_NextCaptureBecomesBaseline_ReportsNoChange()
+ {
+ using ImageChangeDetector detector = new();
+ using Bitmap image1 = new(FileUtilities.GetPathToLocalFile(fontTestPath));
+ using Bitmap image2 = new(FileUtilities.GetPathToLocalFile(fontSamplePath));
+
+ _ = detector.CheckForChangeAndUpdate(image1);
+ detector.Reset();
+
+ Assert.False(detector.CheckForChangeAndUpdate(image2));
+ }
+}
diff --git a/Tests/ProtocolUtilitiesTests.cs b/Tests/ProtocolUtilitiesTests.cs
new file mode 100644
index 00000000..9bd0ce64
--- /dev/null
+++ b/Tests/ProtocolUtilitiesTests.cs
@@ -0,0 +1,196 @@
+using System;
+using System.IO;
+using Text_Grab.Utilities;
+
+namespace Tests;
+
+public class ProtocolUtilitiesTests
+{
+ [Theory]
+ [InlineData("text-grab://paste-spreadsheet")]
+ [InlineData("TEXT-GRAB://EDIT-TEXT")]
+ [InlineData("text-grab:grab-frame")]
+ public void IsProtocolUri_RecognizesProtocolArguments(string argument)
+ {
+ Assert.True(ProtocolUtilities.IsProtocolUri(argument));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("Settings")]
+ [InlineData(@"C:\images\screenshot.png")]
+ [InlineData("https://example.com")]
+ [InlineData("--windowless")]
+ public void IsProtocolUri_RejectsOtherArguments(string? argument)
+ {
+ Assert.False(ProtocolUtilities.IsProtocolUri(argument));
+ }
+
+ [Theory]
+ [InlineData("text-grab://paste-spreadsheet", "paste-spreadsheet")]
+ [InlineData("text-grab://edit-text", "edit-text")]
+ [InlineData("text-grab://grab-frame", "grab-frame")]
+ [InlineData("text-grab://grab-text", "grab-text")]
+ [InlineData("text-grab://fullscreen", "fullscreen")]
+ [InlineData("text-grab://quick-lookup", "quick-lookup")]
+ [InlineData("text-grab://settings", "settings")]
+ public void TryParseProtocolUri_ParsesCommands(string uri, string expectedCommand)
+ {
+ bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out _);
+
+ Assert.True(parsed);
+ Assert.Equal(expectedCommand, command);
+ }
+
+ [Theory]
+ [InlineData("TEXT-GRAB://Paste-Spreadsheet", "paste-spreadsheet")]
+ [InlineData("text-grab://paste-spreadsheet/", "paste-spreadsheet")]
+ [InlineData("text-grab:paste-spreadsheet", "paste-spreadsheet")]
+ public void TryParseProtocolUri_NormalizesCommandForms(string uri, string expectedCommand)
+ {
+ bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out _);
+
+ Assert.True(parsed);
+ Assert.Equal(expectedCommand, command);
+ }
+
+ [Fact]
+ public void TryParseProtocolUri_ExtractsUrlEncodedPathParameter()
+ {
+ string localPath = @"C:\Users\joe\Downloads\TextGrab\capture 2026-06-12.png";
+ string uri = $"text-grab://grab-frame?path={Uri.EscapeDataString(localPath)}";
+
+ bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out Dictionary parameters);
+
+ Assert.True(parsed);
+ Assert.Equal("grab-frame", command);
+ Assert.Equal(localPath, parameters["path"]);
+ }
+
+ [Fact]
+ public void TryParseProtocolUri_ParsesGrabTextWithPath()
+ {
+ string localPath = @"C:\Users\joe\Downloads\TextGrab\image-2026-06-12.png";
+ string uri = $"text-grab://grab-text?path={Uri.EscapeDataString(localPath)}";
+
+ bool parsed = ProtocolUtilities.TryParseProtocolUri(uri, out string command, out Dictionary parameters);
+
+ Assert.True(parsed);
+ Assert.Equal("grab-text", command);
+ Assert.Equal(localPath, parameters["path"]);
+ }
+
+ [Fact]
+ public void TryParseProtocolUri_ParameterKeysAreCaseInsensitive()
+ {
+ bool parsed = ProtocolUtilities.TryParseProtocolUri(
+ "text-grab://grab-frame?PATH=C%3A%5Cimage.png",
+ out _,
+ out Dictionary parameters);
+
+ Assert.True(parsed);
+ Assert.Equal(@"C:\image.png", parameters["path"]);
+ }
+
+ [Theory]
+ [InlineData("https://example.com")]
+ [InlineData("not a uri")]
+ [InlineData("text-grab://")]
+ [InlineData("")]
+ public void TryParseProtocolUri_RejectsInvalidUris(string uri)
+ {
+ Assert.False(ProtocolUtilities.TryParseProtocolUri(uri, out _, out _));
+ }
+
+ [Fact]
+ public void TryParseProtocolUri_IgnoresMalformedQueryPairs()
+ {
+ bool parsed = ProtocolUtilities.TryParseProtocolUri(
+ "text-grab://grab-frame?=novalue&path=C%3A%5Ca.png&flag",
+ out string command,
+ out Dictionary parameters);
+
+ Assert.True(parsed);
+ Assert.Equal("grab-frame", command);
+ Assert.Single(parameters);
+ Assert.Equal(@"C:\a.png", parameters["path"]);
+ }
+
+ // ── TryGetSafeProtocolFilePath ────────────────────────────────────────────
+
+ [Fact]
+ public void TryGetSafeProtocolFilePath_AcceptsImageInTempFolder()
+ {
+ string tempImage = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.png");
+ File.WriteAllBytes(tempImage, [0]);
+ try
+ {
+ bool safe = ProtocolUtilities.TryGetSafeProtocolFilePath(tempImage, out string fullPath);
+
+ Assert.True(safe);
+ Assert.Equal(Path.GetFullPath(tempImage), fullPath);
+ }
+ finally
+ {
+ File.Delete(tempImage);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(@"\\server\share\image.png")] // UNC: would trigger an SMB credential leak
+ [InlineData("//server/share/image.png")] // forward-slash UNC
+ [InlineData(@"\\?\C:\Windows\image.png")] // extended-length device path
+ [InlineData(@"\\.\PhysicalDrive0")] // device namespace
+ public void TryGetSafeProtocolFilePath_RejectsUncDeviceAndEmptyPaths(string? path)
+ {
+ Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(path, out _));
+ }
+
+ [Fact]
+ public void TryGetSafeProtocolFilePath_RejectsPathOutsideAllowedRoots()
+ {
+ // The Windows folder is never an allowed root; rejection happens before any
+ // existence check, so the file need not exist.
+ string outside = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.Windows),
+ $"text-grab-{Guid.NewGuid():N}.png");
+
+ Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(outside, out _));
+ }
+
+ [Fact]
+ public void TryGetSafeProtocolFilePath_RejectsTraversalEscapingAllowedRoot()
+ {
+ // Starts inside Temp but climbs out to the Windows folder.
+ string traversal = Path.Combine(Path.GetTempPath(), "..", "..", "..", "Windows", "image.png");
+
+ Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(traversal, out _));
+ }
+
+ [Fact]
+ public void TryGetSafeProtocolFilePath_RejectsNonImageExtensionInAllowedRoot()
+ {
+ string tempText = Path.Combine(Path.GetTempPath(), $"text-grab-test-{Guid.NewGuid():N}.txt");
+ File.WriteAllText(tempText, "hello");
+ try
+ {
+ Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(tempText, out _));
+ }
+ finally
+ {
+ File.Delete(tempText);
+ }
+ }
+
+ [Fact]
+ public void TryGetSafeProtocolFilePath_RejectsNonexistentImageInAllowedRoot()
+ {
+ string missing = Path.Combine(Path.GetTempPath(), $"text-grab-missing-{Guid.NewGuid():N}.png");
+
+ Assert.False(ProtocolUtilities.TryGetSafeProtocolFilePath(missing, out _));
+ }
+}
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index 664d059a..19ae399f 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -18,7 +18,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/Tests/UndoRedoTests.cs b/Tests/UndoRedoTests.cs
new file mode 100644
index 00000000..4a37d95c
--- /dev/null
+++ b/Tests/UndoRedoTests.cs
@@ -0,0 +1,81 @@
+using Text_Grab.UndoRedoOperations;
+
+namespace Tests;
+
+public class UndoRedoTests
+{
+ private sealed class FakeOperation(uint transactionId) : IUndoRedoOperation
+ {
+ public uint TransactionId { get; } = transactionId;
+
+ public int UndoCount { get; private set; }
+
+ public int RedoCount { get; private set; }
+
+ public UndoRedoOperation GetUndoRedoOperation() => UndoRedoOperation.None;
+
+ public void Undo() => UndoCount++;
+
+ public void Redo() => RedoCount++;
+ }
+
+ [Fact]
+ public void UndoStack_TrimsOldestTransactions_WhenOverCapacity()
+ {
+ UndoRedo undoRedo = new();
+ int transactionCount = UndoRedo.UndoRedoTransactionCapacity + 50;
+
+ for (uint transactionId = 0; transactionId < transactionCount; transactionId++)
+ {
+ undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId));
+ undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId));
+ }
+
+ Assert.Equal(UndoRedo.UndoRedoTransactionCapacity * 2, undoRedo.UndoOperationCount);
+ }
+
+ [Fact]
+ public void UndoStack_KeepsAllOperations_WhenUnderCapacity()
+ {
+ UndoRedo undoRedo = new();
+
+ for (uint transactionId = 0; transactionId < 10; transactionId++)
+ undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId));
+
+ Assert.Equal(10, undoRedo.UndoOperationCount);
+ }
+
+ [Fact]
+ public void Undo_RunsAllOperationsOfNewestTransaction()
+ {
+ UndoRedo undoRedo = new();
+ FakeOperation olderOperation = new(transactionId: 1);
+ FakeOperation newerOperation1 = new(transactionId: 2);
+ FakeOperation newerOperation2 = new(transactionId: 2);
+ undoRedo.AddOperationToUndoStack(olderOperation);
+ undoRedo.AddOperationToUndoStack(newerOperation1);
+ undoRedo.AddOperationToUndoStack(newerOperation2);
+
+ undoRedo.Undo();
+
+ Assert.Equal(0, olderOperation.UndoCount);
+ Assert.Equal(1, newerOperation1.UndoCount);
+ Assert.Equal(1, newerOperation2.UndoCount);
+ Assert.Equal(1, undoRedo.UndoOperationCount);
+ Assert.True(undoRedo.HasRedoOperations());
+ }
+
+ [Fact]
+ public void Reset_ClearsAllOperations()
+ {
+ UndoRedo undoRedo = new();
+ undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId: 1));
+ undoRedo.Undo();
+ undoRedo.AddOperationToUndoStack(new FakeOperation(transactionId: 2));
+
+ undoRedo.Reset();
+
+ Assert.False(undoRedo.HasUndoOperations());
+ Assert.False(undoRedo.HasRedoOperations());
+ }
+}
diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs
index 950728a8..1170dd15 100644
--- a/Tests/UnitConversionTests.cs
+++ b/Tests/UnitConversionTests.cs
@@ -1,4 +1,3 @@
-using System.Globalization;
using Text_Grab.Services;
namespace Tests;
@@ -40,6 +39,7 @@ public async Task ExplicitConversion_ContainsTargetUnit(string input, string exp
[InlineData("100 F to C", 37.778, 0.01)]
[InlineData("0 C to F", 32, 0.01)]
[InlineData("1 foot to inches", 12, 0.01)]
+ [InlineData("0.876 ft to in", 10.512, 0.1)]
[InlineData("1 mile to feet", 5280, 1)]
[InlineData("1 gallon to liters", 3.785, 0.01)]
[InlineData("1 kg to grams", 1000, 0.01)]
@@ -140,6 +140,82 @@ public async Task ExplicitConversion_IncompatibleTypes_FallsThrough()
#endregion Explicit Conversion Tests
+ #region Feet and Inches Tests
+
+ [Theory]
+ [InlineData("1.9 meters to feet", "6 ft 3 in")]
+ [InlineData("1 meter to feet", "3 ft 3 in")]
+ [InlineData("6 feet to feet", "6 ft")]
+ [InlineData("12 inches to feet", "1 ft")]
+ [InlineData("1 mile to feet", "5280 ft")]
+ public async Task ConversionToFeet_FormatsAsFeetAndInches(string input, string expectedOutput)
+ {
+ CalculationResult result = await _service.EvaluateExpressionsAsync(input);
+
+ Assert.Equal(0, result.ErrorCount);
+ Assert.Equal(expectedOutput, result.Output);
+ }
+
+ [Fact]
+ public async Task ConversionToFeet_StillTracksNumericValue()
+ {
+ // OutputNumbers should still contain the fractional feet value
+ CalculationResult result = await _service.EvaluateExpressionsAsync("1.9 meters to feet");
+
+ Assert.Single(result.OutputNumbers);
+ Assert.InRange(result.OutputNumbers[0], 6.23, 6.24);
+ }
+
+ [Fact]
+ public async Task ContinuationConversionToFeet_FormatsAsFeetAndInches()
+ {
+ string input = "1.9 meters\nto feet";
+ CalculationResult result = await _service.EvaluateExpressionsAsync(input);
+
+ Assert.Equal(0, result.ErrorCount);
+ string[] lines = result.Output.Split('\n');
+ Assert.Equal(2, lines.Length);
+ Assert.Contains("6 ft", lines[1]);
+ Assert.Contains("in", lines[1]);
+ }
+
+ #endregion Feet and Inches Tests
+
+ #region Decimal Parsing Tests
+
+ ///
+ /// Regression: a 3-digit decimal like 0.345 was incorrectly stripped of its dot
+ /// (treated as a European thousands separator like "1.000") and parsed as 345.
+ /// The fix: only strip the dot when the integer part doesn't start with 0.
+ ///
+ [Theory]
+ [InlineData("0.345 meters to cm", 34.5, 0.01)]
+ [InlineData("0.100 km to meters", 100, 0.1)]
+ [InlineData("0.500 kg to grams", 500, 0.1)]
+ [InlineData("0.125 miles to km", 0.2012, 0.01)]
+ public async Task DecimalWithThreeDigits_ParsedCorrectly(string input, double expectedValue, double tolerance)
+ {
+ CalculationResult result = await _service.EvaluateExpressionsAsync(input);
+
+ Assert.Equal(0, result.ErrorCount);
+ Assert.Single(result.OutputNumbers);
+ Assert.InRange(result.OutputNumbers[0], expectedValue - tolerance, expectedValue + tolerance);
+ }
+
+ [Theory]
+ [InlineData("1.000 km to meters", 1000000, 1)] // "1.000" → thousands sep → 1000 km → 1,000,000 m
+ [InlineData("2.000 meters to cm", 200000, 1)] // "2.000" → thousands sep → 2000 m → 200,000 cm
+ public async Task DecimalVsThousandsSeparator_CorrectBehavior(string input, double expectedValue, double tolerance)
+ {
+ CalculationResult result = await _service.EvaluateExpressionsAsync(input);
+
+ Assert.Equal(0, result.ErrorCount);
+ Assert.Single(result.OutputNumbers);
+ Assert.InRange(result.OutputNumbers[0], expectedValue - tolerance, expectedValue + tolerance);
+ }
+
+ #endregion Decimal Parsing Tests
+
#region Continuation Conversion Tests
[Fact]
@@ -474,7 +550,7 @@ public void TryEvaluateUnitConversion_ContinuationWithoutPrevious_ReturnsFalse()
[Fact]
public void TryEvaluateUnitConversion_ContinuationWithPrevious_ReturnsTrue()
{
- var previous = new CalculationService.UnitResult
+ CalculationService.UnitResult previous = new()
{
Value = 5,
Unit = UnitsNet.Units.LengthUnit.Mile,
diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest
index 22057898..97abd3f7 100644
--- a/Text-Grab-Package/Package.appxmanifest
+++ b/Text-Grab-Package/Package.appxmanifest
@@ -14,7 +14,7 @@
+ Version="4.14.2.0" />
Text Grab
@@ -96,6 +96,13 @@
+
+
+
+ Text Grab
+
+
+
diff --git a/Text-Grab/App.config b/Text-Grab/App.config
index f9b12a29..30822db8 100644
--- a/Text-Grab/App.config
+++ b/Text-Grab/App.config
@@ -196,6 +196,9 @@
True
+
+ Auto
+
diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs
index f84baa00..40b16da1 100644
--- a/Text-Grab/App.xaml.cs
+++ b/Text-Grab/App.xaml.cs
@@ -294,6 +294,12 @@ internal static StartupArguments ParseStartupArguments(IEnumerable args)
private static async Task HandleStartupArgs(string[] args)
{
+ // text-grab:// protocol activation (e.g. from the Text Grab browser
+ // extension); short-circuit before any file-path probing of arguments.
+ foreach (string arg in args)
+ if (ProtocolUtilities.IsProtocolUri(arg))
+ return HandleProtocolUri(arg);
+
StartupArguments startupArguments = ParseStartupArguments(args);
if (startupArguments.IsQuiet)
@@ -350,6 +356,116 @@ private static async Task HandleStartupArgs(string[] args)
return await CheckForOcringFolder(currentArgument);
}
+ internal static bool HandleProtocolUri(string uriString)
+ {
+ if (!ProtocolUtilities.TryParseProtocolUri(uriString, out string command, out Dictionary parameters))
+ return false;
+
+ switch (command)
+ {
+ case "paste-spreadsheet":
+ {
+ EditTextWindow etw = new();
+ etw.Show();
+ etw.EnterSpreadsheetMode();
+ // Defer the paste until the window has loaded so the
+ // spreadsheet grid is ready to receive the clipboard table.
+ etw.Dispatcher.InvokeAsync(
+ etw.PasteClipboardIntoSpreadsheet,
+ DispatcherPriority.Loaded);
+ etw.Activate();
+ return true;
+ }
+ case "edit-text":
+ {
+ EditTextWindow etw = new();
+ try
+ {
+ string clipboardText = Clipboard.GetText();
+ if (!string.IsNullOrEmpty(clipboardText))
+ etw.AddThisText(clipboardText);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"edit-text protocol: clipboard read failed. {ex.Message}");
+ }
+ etw.Show();
+ etw.Activate();
+ return true;
+ }
+ case "grab-frame":
+ {
+ // A path is optional. When present it is untrusted (the protocol can be
+ // launched by any web page), so it must pass the safe-path gate before we
+ // open the file; an unsafe path falls back to an empty Grab Frame.
+ if (parameters.TryGetValue("path", out string? path))
+ {
+ if (ProtocolUtilities.TryGetSafeProtocolFilePath(path, out string safePath))
+ {
+ GrabFrame gfWithFile = new(safePath);
+ gfWithFile.Show();
+ gfWithFile.Activate();
+ return true;
+ }
+
+ Debug.WriteLine("grab-frame protocol: rejected unsafe path; opening empty frame.");
+ }
+
+ GrabFrame gf = new();
+ gf.Show();
+ gf.Activate();
+ return true;
+ }
+ case "grab-text":
+ {
+ // OCR a local image/PDF straight to the clipboard, no window. The path is
+ // untrusted; only proceed for a validated, allowed local file.
+ if (parameters.TryGetValue("path", out string? path)
+ && ProtocolUtilities.TryGetSafeProtocolFilePath(path, out string safePath))
+ {
+ _ = GrabTextFromFileAsync(safePath);
+ return true;
+ }
+
+ Debug.WriteLine("grab-text protocol: missing or unsafe path; ignoring.");
+ return false;
+ }
+ case "fullscreen":
+ LaunchStandardMode(TextGrabMode.Fullscreen);
+ return true;
+ case "quick-lookup":
+ LaunchStandardMode(TextGrabMode.QuickLookup);
+ return true;
+ case "settings":
+ {
+ SettingsWindow sw = new();
+ sw.Show();
+ return true;
+ }
+ default:
+ Debug.WriteLine($"Unknown text-grab:// command: {command}");
+ return false;
+ }
+ }
+
+ ///
+ /// OCRs a local image/PDF file and routes the result to the clipboard (and
+ /// a toast), the same handling as a normal grab. Used by text-grab://grab-text.
+ ///
+ private static async Task GrabTextFromFileAsync(string path)
+ {
+ try
+ {
+ string ocrText = await OcrUtilities.OcrAbsoluteFilePathAsync(
+ path, LanguageUtilities.GetOCRLanguage());
+ OutputUtilities.HandleTextFromOcr(ocrText, isSingleLine: false, isTable: false);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"grab-text protocol: OCR failed. {ex.Message}");
+ }
+ }
+
private static void LaunchStandardMode(TextGrabMode launchMode)
{
switch (launchMode)
@@ -431,6 +547,10 @@ private async void appStartup(object sender, StartupEventArgs e)
NumberOfRunningInstances = Process.GetProcessesByName("Text-Grab").Length;
Current.DispatcherUnhandledException += CurrentDispatcherUnhandledException;
+ // Per-user text-grab:// registration for unpackaged installs
+ // (packaged installs register the protocol via the MSIX manifest).
+ ProtocolUtilities.EnsureProtocolRegistration();
+
// Register COM server and activator type
bool handledArgument = false;
diff --git a/Text-Grab/Controls/FindAndReplaceWindow.xaml b/Text-Grab/Controls/FindAndReplaceWindow.xaml
index 8935d419..b0a6bcbb 100644
--- a/Text-Grab/Controls/FindAndReplaceWindow.xaml
+++ b/Text-Grab/Controls/FindAndReplaceWindow.xaml
@@ -212,6 +212,16 @@
ButtonSymbol="WindowEdit20"
ButtonText="Edit Matches"
Command="{x:Static local:FindAndReplaceWindow.CopyMatchesCmd}" />
+
+
ResetWindowLoading();
}
+ private void ApplyTemplateButton_Click(object sender, RoutedEventArgs e)
+ {
+ System.Windows.Controls.ContextMenu menu = new()
+ {
+ PlacementTarget = ApplyTemplateButton,
+ Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
+ };
+
+ if (IsSpreadsheetSearch)
+ {
+ menu.Items.Add(DisabledMenuItem("Not available in spreadsheet mode"));
+ menu.IsOpen = true;
+ return;
+ }
+
+ // Load text-only templates fresh each time, mirroring the Edit Text Window menu.
+ List textOnlyTemplates = GrabTemplateManager.GetAllTemplates()
+ .Where(template => template.IsTextOnly && template.IsValid)
+ .ToList();
+
+ if (textOnlyTemplates.Count == 0)
+ {
+ menu.Items.Add(DisabledMenuItem("No text-only templates found"));
+ menu.IsOpen = true;
+ return;
+ }
+
+ bool hasMatches = Matches is not null && Matches.Count > 0;
+
+ foreach (GrabTemplate template in textOnlyTemplates)
+ {
+ System.Windows.Controls.MenuItem item = new()
+ {
+ Header = template.Name,
+ ToolTip = string.IsNullOrWhiteSpace(template.Description) ? null : template.Description,
+ Tag = template,
+ IsEnabled = hasMatches,
+ };
+ item.Click += TemplateMenuItem_Click;
+ menu.Items.Add(item);
+ }
+
+ if (!hasMatches)
+ menu.Items.Add(DisabledMenuItem("Run a search to find matches first"));
+
+ menu.IsOpen = true;
+ }
+
+ private static System.Windows.Controls.MenuItem DisabledMenuItem(string text) =>
+ new() { Header = text, IsEnabled = false };
+
+ private void TemplateMenuItem_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is System.Windows.Controls.MenuItem { Tag: GrabTemplate template })
+ _ = ApplyTemplateToMatchesAsync(template);
+ }
+
+ ///
+ /// Applies a text-only Grab Template to every matched result, replacing each match
+ /// with the template output evaluated against that match's own text. When two or
+ /// more results are selected, only those are affected; otherwise all matches are.
+ ///
+ private async Task ApplyTemplateToMatchesAsync(GrabTemplate template)
+ {
+ if (textEditWindow is null || IsSpreadsheetSearch)
+ return;
+
+ if (Matches is null || Matches.Count < 1)
+ return;
+
+ SetWindowToLoading();
+
+ string originalText = textEditWindow.PassedTextControl.Text;
+ StringBuilder stringBuilder = new(originalText);
+
+ IList selection = ResultsListView.SelectedItems;
+ List targets = selection.Count >= 2
+ ? [.. selection.Cast()]
+ : [.. ResultsListView.Items.Cast()];
+
+ await Task.Run(() =>
+ {
+ // Apply from the end backwards so earlier indices stay valid as we edit.
+ foreach (FindResult result in targets.OrderByDescending(r => r.Index))
+ {
+ if (result.Index < 0 || result.Index + result.Length > originalText.Length)
+ continue;
+
+ string matchText = originalText.Substring(result.Index, result.Length);
+ string replacement = GrabTemplateExecutor.ApplyTextOnlyTemplate(template, matchText);
+
+ stringBuilder.Remove(result.Index, result.Length);
+ stringBuilder.Insert(result.Index, replacement);
+ }
+ });
+
+ textEditWindow.PassedTextControl.Text = stringBuilder.ToString();
+ GrabTemplateManager.RecordUsage(template.Id);
+
+ SearchForText();
+ ResetWindowLoading();
+ }
+
private void ResetWindowLoading()
{
MainContentGrid.IsEnabled = true;
diff --git a/Text-Grab/Controls/RegexEditorDialog.xaml b/Text-Grab/Controls/RegexEditorDialog.xaml
index 06d76453..a9889dd8 100644
--- a/Text-Grab/Controls/RegexEditorDialog.xaml
+++ b/Text-Grab/Controls/RegexEditorDialog.xaml
@@ -30,39 +30,86 @@
Padding="8,2"
Icon="{StaticResource TextGrabIcon}" />
-
-
-
+
+
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /// Inserts the clicked reference token into the pattern box at the current caret
+ /// position (replacing any selection), then returns focus to the pattern box.
+ ///
+ private void InsertToken_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Wpf.Ui.Controls.Button { Tag: string token } || string.IsNullOrEmpty(token))
+ return;
+
+ int caret = PatternTextBox.CaretIndex;
+ if (PatternTextBox.SelectionLength > 0)
+ {
+ caret = PatternTextBox.SelectionStart;
+ PatternTextBox.SelectedText = token;
+ }
+ else
+ {
+ PatternTextBox.Text = PatternTextBox.Text.Insert(caret, token);
+ }
+
+ PatternTextBox.CaretIndex = caret + token.Length;
+ PatternTextBox.Focus();
+ }
+
+ private static List BuildRegexReference() =>
+ [
+ new("Character classes",
+ [
+ new(@"\d", "Any digit (0–9)"),
+ new(@"\D", "Any non-digit"),
+ new(@"\w", "Word character: letter, digit, or underscore"),
+ new(@"\W", "Any non-word character"),
+ new(@"\s", "Any whitespace (space, tab, newline)"),
+ new(@"\S", "Any non-whitespace character"),
+ new(".", "Any single character (except newline)"),
+ new("[abc]", "Any one of the listed characters"),
+ new("[^abc]", "Any character NOT listed"),
+ new("[a-z]", "Any character in the range a to z"),
+ ]),
+ new("Anchors & boundaries",
+ [
+ new("^", "Start of the line/string"),
+ new("$", "End of the line/string"),
+ new(@"\b", "Word boundary (edge of a word)"),
+ new(@"\B", "Not a word boundary"),
+ ]),
+ new("Quantifiers",
+ [
+ new("*", "Zero or more of the preceding item"),
+ new("+", "One or more of the preceding item"),
+ new("?", "Zero or one (makes it optional)"),
+ new("{3}", "Exactly 3 of the preceding item"),
+ new("{2,}", "2 or more of the preceding item"),
+ new("{2,5}", "Between 2 and 5 of the preceding item"),
+ new("*?", "Lazy: as few as possible (also +? and ??)"),
+ ]),
+ new("Groups & alternation",
+ [
+ new("(...)", "Capture group — remembers the match"),
+ new("(?:...)", "Group without capturing"),
+ new("(?...)", "Named capture group"),
+ new("a|b", "Match either a or b"),
+ ]),
+ new("Lookaround (match context without consuming it)",
+ [
+ new("(?=...)", "Lookahead: followed by ... (text after a match)"),
+ new("(?!...)", "Negative lookahead: NOT followed by ..."),
+ new("(?<=...)", "Lookbehind: preceded by ... (text before a match)"),
+ new("(?A named group of regex reference rows shown in the quick-reference expander.
+ public sealed record RegexReferenceCategory(string CategoryName, IReadOnlyList Items);
+
+ /// A single regex token and a plain-language description of what it does.
+ public sealed record RegexReferenceItem(string Token, string Description);
}
diff --git a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml
index 7f633da0..1c71e64c 100644
--- a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml
+++ b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml
@@ -50,11 +50,21 @@
ToolTip="Type { to insert a pattern placeholder. Plain text is also supported."
VerticalScrollBarVisibility="Auto" />
-
+
+
+
+
();
+ regexManager.Show();
+ regexManager.Activate();
+ }
+
private void ValidateInput(object sender, TextChangedEventArgs e) => UpdateSaveButton();
private void OutputTemplateBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSaveButton();
diff --git a/Text-Grab/Controls/WordBorder.xaml b/Text-Grab/Controls/WordBorder.xaml
index 9461a2d7..deb63d15 100644
--- a/Text-Grab/Controls/WordBorder.xaml
+++ b/Text-Grab/Controls/WordBorder.xaml
@@ -12,7 +12,9 @@
MouseEnter="WordBorder_MouseEnter"
MouseLeave="WordBorder_MouseLeave"
MouseMove="WordBorder_MouseEnter"
- ToolTip="{Binding Path=Word, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
+ ToolTip="{Binding Path=Word,
+ Mode=OneWay,
+ UpdateSourceTrigger=PropertyChanged}"
Unloaded="WordBorderControl_Unloaded"
mc:Ignorable="d">
@@ -67,58 +69,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -133,7 +83,6 @@
Margin="0,0,0,0"
BorderBrush="#308E98"
BorderThickness="0,0,0,2"
- ContextMenu="{StaticResource ContextOptions}"
ContextMenuOpening="EditWordTextBox_ContextMenuOpening"
CornerRadius="0">
@@ -145,7 +94,6 @@
Margin="-1,-3,-1,-1"
d:Text="Test g"
AcceptsReturn="True"
- ContextMenu="{StaticResource ContextOptions}"
ContextMenuOpening="EditWordTextBox_ContextMenuOpening"
FontFamily="Segoe UI"
FontSize="12"
@@ -154,26 +102,31 @@
GotFocus="EditWordTextBox_GotFocus"
MouseDown="EditWordTextBox_MouseDown"
Style="{StaticResource TransparentTextBox}"
- Text="{Binding ElementName=WordBorderControl, Path=DisplayText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
+ Text="{Binding ElementName=WordBorderControl,
+ Path=DisplayText,
+ Mode=TwoWay,
+ UpdateSourceTrigger=PropertyChanged}"
TextChanged="EditWordTextBox_TextChanged"
Visibility="Visible" />
+ Visibility="{Binding Path=TemplateBadgeVisibility,
+ ElementName=WordBorderControl}">
+ Text="{Binding Path=TemplateBadgeText,
+ ElementName=WordBorderControl}" />
0)
+ return;
+
+ contextMenu.Items.Add(NewContextMenuItem("Copy Text", CopyWordMenuItem_Click));
+ contextMenu.Items.Add(NewContextMenuItem("Try To Make _Numbers", TryToNumberMenuItem_Click));
+ contextMenu.Items.Add(NewContextMenuItem("Try To Make _Letters", TryToAlphaMenuItem_Click));
+ contextMenu.Items.Add(NewContextMenuItem("Make Text _Single Line", MakeSingleLineMenuItem_Click));
+ contextMenu.Items.Add(new Separator());
+
+ MenuItem translateMenuItem = NewContextMenuItem("Translate to System Language", TranslateWordMenuItem_Click);
+ translateMenuItem.Name = "TranslateWordMenuItem";
+ translateMenuItem.Visibility = Visibility.Collapsed;
+ contextMenu.Items.Add(translateMenuItem);
+ contextMenu.Items.Add(new Separator()
+ {
+ Name = "TranslateSeparator",
+ Visibility = Visibility.Collapsed
+ });
+
+ contextMenu.Items.Add(new MenuItem()
+ {
+ Header = "_Merge Selected Word Borders",
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Command = MergeWordsCommand,
+ InputGestureText = "Ctrl + M"
+ });
+ contextMenu.Items.Add(NewContextMenuItem("_Break into words", BreakIntoWordsMenuItem_Click));
+ contextMenu.Items.Add(NewContextMenuItem("_Search for similar text", SearchForSimilarMenuItem_Click));
+ contextMenu.Items.Add(new Separator());
+ contextMenu.Items.Add(NewContextMenuItem("_Delete", DeleteWordMenuItem_Click));
+
+ contextMenuBaseSize = contextMenu.Items.Count;
+ }
+
private void EditWordTextBox_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
- if (sender is not FrameworkElement senderElement)
+ if (sender is not FrameworkElement senderElement
+ || senderElement.ContextMenu is not ContextMenu textBoxContextMenu)
+ {
return;
+ }
- ContextMenu textBoxContextMenu = senderElement.ContextMenu;
+ EnsureContextMenuItems(textBoxContextMenu);
while (textBoxContextMenu.Items.Count > contextMenuBaseSize)
{
diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs
index 4ed5a1f5..39399410 100644
--- a/Text-Grab/Enums.cs
+++ b/Text-Grab/Enums.cs
@@ -87,6 +87,17 @@ public enum ScrollBehavior
ZoomWhenFrozen = 3,
}
+public enum SpellCheckMode
+{
+ // Enable spell check unless the text looks like it would choke the checker
+ // (very long documents or several long unspaced tokens).
+ Auto = 0,
+ // Always show spell check, regardless of content.
+ AlwaysOn = 1,
+ // Never show spell check.
+ Off = 2,
+}
+
public enum LanguageKind
{
Global = 0,
diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs
index ea063af1..92fc6e34 100644
--- a/Text-Grab/Models/GrabTemplate.cs
+++ b/Text-Grab/Models/GrabTemplate.cs
@@ -93,6 +93,13 @@ public GrabTemplate(string name)
!string.IsNullOrWhiteSpace(Name)
&& !string.IsNullOrWhiteSpace(OutputTemplate);
+ ///
+ /// True when this template has no capture regions and therefore operates purely
+ /// on text (its output template uses only literal text and {p:Name:mode} pattern
+ /// placeholders). These can be applied to existing text in the Edit Text Window.
+ ///
+ public bool IsTextOnly => Regions.Count == 0;
+
///
/// Returns all region numbers referenced in the output template.
///
diff --git a/Text-Grab/Pages/EditTextWindowSettings.xaml b/Text-Grab/Pages/EditTextWindowSettings.xaml
index 6b03ff73..844ea71b 100644
--- a/Text-Grab/Pages/EditTextWindowSettings.xaml
+++ b/Text-Grab/Pages/EditTextWindowSettings.xaml
@@ -75,6 +75,57 @@
Style="{StaticResource TextBodyNormal}"
Text="Convert Unix (LF) and classic Mac (CR) line endings to Windows (CRLF) when pasting text." />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
True
+
+ Auto
+
diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs
index 30809fb6..2dddddca 100644
--- a/Text-Grab/Services/CalculationService.UnitMath.cs
+++ b/Text-Grab/Services/CalculationService.UnitMath.cs
@@ -308,7 +308,9 @@ private bool TryContinuationConversion(
QuantityName = target.QuantityName,
Abbreviation = target.Abbreviation
};
- result = FormatUnitValue(convertedValue, target.Abbreviation);
+ result = target.Unit is LengthUnit.Foot
+ ? FormatFeetAndInches(convertedValue)
+ : FormatUnitValue(convertedValue, target.Abbreviation);
return true;
}
@@ -450,7 +452,9 @@ private bool TryExplicitConversion(
QuantityName = targetUnit.QuantityName,
Abbreviation = targetUnit.Abbreviation
};
- result = FormatUnitValue(convertedValue, targetUnit.Abbreviation);
+ result = targetUnit.Unit is LengthUnit.Foot
+ ? FormatFeetAndInches(convertedValue)
+ : FormatUnitValue(convertedValue, targetUnit.Abbreviation);
return true;
}
@@ -699,6 +703,30 @@ private string FormatUnitValue(double value, string abbreviation)
return $"{formatted} {abbreviation}";
}
+ ///
+ /// Formats a fractional feet value as a human-readable "X ft Y in" string.
+ /// For example, 6.2336 feet → "6 ft 3 in", 6.0 feet → "6 ft".
+ ///
+ private static string FormatFeetAndInches(double totalFeet)
+ {
+ bool negative = totalFeet < 0;
+ double absoluteFeet = Math.Abs(totalFeet);
+ int wholeFeet = (int)Math.Floor(absoluteFeet);
+ double remainingInches = (absoluteFeet - wholeFeet) * 12;
+ int wholeInches = (int)Math.Round(remainingInches);
+
+ if (wholeInches == 12)
+ {
+ wholeFeet++;
+ wholeInches = 0;
+ }
+
+ string sign = negative ? "-" : "";
+ return wholeInches == 0
+ ? $"{sign}{wholeFeet} ft"
+ : $"{sign}{wholeFeet} ft {wholeInches} in";
+ }
+
#endregion Unit Conversion Helpers
#region Unit Math Regex Patterns
diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj
index fabf80af..9af1b174 100644
--- a/Text-Grab/Text-Grab.csproj
+++ b/Text-Grab/Text-Grab.csproj
@@ -23,7 +23,7 @@
true
win-x86;win-x64;win-arm64
false
- 4.14.1
+ 4.14.2
$(NoWarn);WFO0003
@@ -71,13 +71,13 @@
-
+
-
+
diff --git a/Text-Grab/UndoRedoOperations/UndoRedo.cs b/Text-Grab/UndoRedoOperations/UndoRedo.cs
index 0f4df573..97624186 100644
--- a/Text-Grab/UndoRedoOperations/UndoRedo.cs
+++ b/Text-Grab/UndoRedoOperations/UndoRedo.cs
@@ -16,6 +16,9 @@ internal class UndoRedo
private LinkedList UndoStack { get; } = new();
+ // Exposed for tests so capacity trimming can be verified.
+ internal int UndoOperationCount => UndoStack.Count;
+
// used for readability.
public void StartTransaction()
{
@@ -24,10 +27,7 @@ public void StartTransaction()
public void EndTransaction()
{
if (TransactionId <= HighestUsedTransactionId)
- {
TransactionId++;
- ActiveTransactionIdCount++;
- }
}
public void Reset()
@@ -39,21 +39,27 @@ public void Reset()
ActiveTransactionIdCount = 0;
}
- private void AddOperationToUndoStack(IUndoRedoOperation operation)
+ internal void AddOperationToUndoStack(IUndoRedoOperation operation)
{
- if (ActiveTransactionIdCount >= UndoRedoTransactionCapacity)
+ // A transaction is a run of operations sharing a TransactionId, so a
+ // new transaction starts whenever the incoming id differs from the
+ // last stacked operation. Counting here (instead of in EndTransaction)
+ // also covers operations inserted without transaction bracketing.
+ if (UndoStack.Last is null || UndoStack.Last.Value.TransactionId != operation.TransactionId)
+ ++ActiveTransactionIdCount;
+
+ UndoStack.AddLast(operation);
+
+ // Trim whole transactions from the oldest end so the stack cannot pin
+ // an unbounded number of WordBorder controls and their visual trees.
+ while (ActiveTransactionIdCount > UndoRedoTransactionCapacity && UndoStack.First is not null)
{
- uint? transactionIdToRemove = UndoStack.First?.Value.TransactionId;
- while (UndoStack.First?.Value.TransactionId == transactionIdToRemove && transactionIdToRemove is not null)
- {
- if (UndoStack.Count != 0)
- UndoStack.RemoveFirst();
- }
+ uint transactionIdToRemove = UndoStack.First.Value.TransactionId;
+ while (UndoStack.First is not null && UndoStack.First.Value.TransactionId == transactionIdToRemove)
+ UndoStack.RemoveFirst();
--ActiveTransactionIdCount;
}
-
- UndoStack.AddLast(operation);
}
private void ClearRedoStack()
@@ -134,7 +140,8 @@ public void Undo()
operationNode = prev;
}
- --ActiveTransactionIdCount;
+ if (ActiveTransactionIdCount > 0)
+ --ActiveTransactionIdCount;
}
public void Redo()
diff --git a/Text-Grab/Utilities/DiagnosticsUtilities.cs b/Text-Grab/Utilities/DiagnosticsUtilities.cs
index ec443e64..3413a1c5 100644
--- a/Text-Grab/Utilities/DiagnosticsUtilities.cs
+++ b/Text-Grab/Utilities/DiagnosticsUtilities.cs
@@ -211,6 +211,7 @@ private static SettingsInfoModel GetSettingsInfo()
EditWindowStartFullscreen = s.EditWindowStartFullscreen,
RestoreEtwPositions = s.RestoreEtwPositions,
EtwUseMargins = s.EtwUseMargins,
+ EtwSpellCheckMode = s.EtwSpellCheckMode ?? string.Empty,
ShowCursorText = s.ShowCursorText,
ScrollBottomBar = s.ScrollBottomBar,
EtwShowLangPicker = s.EtwShowLangPicker,
@@ -560,6 +561,7 @@ public class SettingsInfoModel
public bool EditWindowStartFullscreen { get; set; }
public bool RestoreEtwPositions { get; set; }
public bool EtwUseMargins { get; set; }
+ public string EtwSpellCheckMode { get; set; } = string.Empty;
public bool ShowCursorText { get; set; }
public bool ScrollBottomBar { get; set; }
public bool EtwShowLangPicker { get; set; }
diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs
index cea400d4..ead23425 100644
--- a/Text-Grab/Utilities/GrabTemplateExecutor.cs
+++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs
@@ -203,6 +203,37 @@ public static async Task ExecuteTemplateOnBitmapAsync(
return output;
}
+ ///
+ /// Applies a text-only template (one with no capture regions) to existing
+ /// . The text itself is used as the source for any
+ /// {p:Name:mode} pattern placeholders; region placeholders resolve to empty.
+ /// No OCR is performed, so this runs synchronously. Used when applying a template
+ /// to text already present in the Edit Text Window.
+ ///
+ public static string ApplyTextOnlyTemplate(GrabTemplate template, string text)
+ {
+ if (!template.IsValid)
+ return text;
+
+ // No regions to OCR — region placeholders simply resolve to empty.
+ string output = ApplyOutputTemplate(template.OutputTemplate, new Dictionary());
+
+ bool hasPatternRefs = template.PatternMatches.Count > 0
+ || PatternPlaceholderRegex.IsMatch(template.OutputTemplate);
+
+ if (hasPatternRefs)
+ {
+ List effectivePatternMatches = template.PatternMatches.Count > 0
+ ? template.PatternMatches
+ : ParsePatternMatchesFromOutputTemplate(template.OutputTemplate);
+
+ Dictionary patternRegexes = ResolvePatternRegexes(effectivePatternMatches);
+ output = ApplyPatternPlaceholders(output, text, effectivePatternMatches, patternRegexes);
+ }
+
+ return output;
+ }
+
///
/// Applies the output template string with the provided region text values.
/// Useful for unit testing the string processing independently of OCR.
diff --git a/Text-Grab/Utilities/ImageChangeDetector.cs b/Text-Grab/Utilities/ImageChangeDetector.cs
new file mode 100644
index 00000000..cbdf34a7
--- /dev/null
+++ b/Text-Grab/Utilities/ImageChangeDetector.cs
@@ -0,0 +1,86 @@
+using ImageMagick;
+using ImageMagick.Factories;
+using System;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+
+namespace Text_Grab.Utilities;
+
+///
+/// Detects whether successive captures of the same screen region differ by
+/// running a Magick.NET Compare on small downscaled copies, so each check is
+/// fast and allocates very little. The first capture after construction or
+/// Reset() becomes a fixed baseline; later captures are judged against it.
+/// Holds two small images between checks; dispose to release them.
+///
+public sealed partial class ImageChangeDetector : IDisposable
+{
+ // Comparing fixed-size thumbnails keeps Compare cheap regardless of how
+ // large the captured region is, while word-sized changes still register.
+ private const int ComparisonSize = 96;
+
+ // NormalizedMeanError at or below this is treated as noise, such as a
+ // blinking caret or antialiasing differences between captures.
+ private const double ChangeThreshold = 0.001;
+
+ private readonly MagickImageFactory imageFactory = new();
+ private MagickImage? baselineImage;
+ private MagickImage? previousImage;
+
+ ///
+ /// Compares the capture against the fixed baseline. Returns true only
+ /// when the capture differs from the baseline AND matches the previous
+ /// capture, so a transient state (an indicator flash, a half-rendered
+ /// frame) never triggers; the content must hold for two checks. The
+ /// first capture after construction or Reset() establishes the baseline
+ /// and returns false.
+ ///
+ public bool CheckForChangeAndUpdate(Bitmap capture)
+ {
+ using Bitmap thumbnail = CreateThumbnail(capture);
+
+ if (imageFactory.Create(thumbnail) is not MagickImage currentImage)
+ return false;
+
+ if (baselineImage is null)
+ {
+ baselineImage = currentImage;
+ previousImage?.Dispose();
+ previousImage = null;
+ return false;
+ }
+
+ bool differsFromBaseline = baselineImage.Compare(currentImage).NormalizedMeanError > ChangeThreshold;
+ bool isStable = previousImage is not null
+ && previousImage.Compare(currentImage).NormalizedMeanError <= ChangeThreshold;
+
+ previousImage?.Dispose();
+ previousImage = currentImage;
+
+ return differsFromBaseline && isStable;
+ }
+
+ ///
+ /// Drops the baseline so the next capture starts a fresh comparison.
+ ///
+ public void Reset()
+ {
+ baselineImage?.Dispose();
+ baselineImage = null;
+ previousImage?.Dispose();
+ previousImage = null;
+ }
+
+ public void Dispose() => Reset();
+
+ private static Bitmap CreateThumbnail(Bitmap source)
+ {
+ Bitmap thumbnail = new(ComparisonSize, ComparisonSize, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
+ using Graphics graphics = Graphics.FromImage(thumbnail);
+ // HighQualityBilinear prefilters when shrinking, so small on-screen
+ // changes still influence the thumbnail instead of being skipped over.
+ graphics.InterpolationMode = InterpolationMode.HighQualityBilinear;
+ graphics.DrawImage(source, 0, 0, ComparisonSize, ComparisonSize);
+ return thumbnail;
+ }
+}
diff --git a/Text-Grab/Utilities/NumericUtilities.cs b/Text-Grab/Utilities/NumericUtilities.cs
index 93943f97..51aea17a 100644
--- a/Text-Grab/Utilities/NumericUtilities.cs
+++ b/Text-Grab/Utilities/NumericUtilities.cs
@@ -7,11 +7,9 @@
namespace Text_Grab.Utilities;
-public static class NumericUtilities
+public static partial class NumericUtilities
{
- private static readonly Regex FirstNumericTokenRegex = new(
- @"[-+]?(?:(?:\d[\d\s_,.]*)?\d)(?:[eE][-+]?\d+)?",
- RegexOptions.Compiled);
+ private static readonly Regex FirstNumericTokenRegex = FirstNumericToken();
public static double CalculateMedian(List numbers)
{
@@ -38,26 +36,26 @@ public static string FormatNumber(double value)
// Handle special floating-point values first
if (double.IsNaN(value))
return "NaN";
-
+
if (double.IsPositiveInfinity(value))
return "∞";
-
+
if (double.IsNegativeInfinity(value))
return "-∞";
-
+
double absValue = Math.Abs(value);
-
+
// Use scientific notation for very large or very small numbers
- if (absValue >= 1e15 || (absValue < 1e-4 && absValue > 0))
+ if (absValue is >= 1e15 or < 1e-4 and > 0)
{
return value.ToString("E6", CultureInfo.CurrentCulture);
}
-
+
// Check if value is "close enough" to an integer using epsilon comparison
// Use a small tolerance to account for floating-point precision
double fractionalPart = Math.Abs(value - Math.Round(value));
bool isEffectivelyInteger = fractionalPart < 1e-10 && absValue < 1e10;
-
+
if (isEffectivelyInteger)
{
return Math.Round(value).ToString("N0", CultureInfo.CurrentCulture);
@@ -114,7 +112,7 @@ private static string NormalizeNumberString(string input)
StringBuilder sb = new();
foreach (char c in input.Trim())
{
- if (c != ' ' && c != '_')
+ if (c is not ' ' and not '_')
sb.Append(c);
}
@@ -150,11 +148,17 @@ private static string NormalizeNumberString(string input)
int lastDotIndex = compact.LastIndexOf('.');
int digitsAfterDot = compact.Length - lastDotIndex - 1;
bool hasMultipleDots = compact.Count(c => c == '.') > 1;
+ // A leading 0 before the dot (e.g. "0.345") means it can't be a thousands separator.
+ string beforeDot = compact[..lastDotIndex].TrimStart('-');
+ bool couldBeThousandsSep = beforeDot.Length > 0 && !beforeDot.StartsWith('0');
- if (hasMultipleDots || digitsAfterDot == 3)
+ if (hasMultipleDots || (digitsAfterDot == 3 && couldBeThousandsSep))
compact = compact.Replace(".", string.Empty);
}
return compact;
}
+
+ [GeneratedRegex(@"[-+]?(?:(?:\d[\d\s_,.]*)?\d)(?:[eE][-+]?\d+)?", RegexOptions.Compiled)]
+ private static partial Regex FirstNumericToken();
}
diff --git a/Text-Grab/Utilities/ProtocolUtilities.cs b/Text-Grab/Utilities/ProtocolUtilities.cs
new file mode 100644
index 00000000..cb5b250f
--- /dev/null
+++ b/Text-Grab/Utilities/ProtocolUtilities.cs
@@ -0,0 +1,228 @@
+using Microsoft.Win32;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+
+namespace Text_Grab.Utilities;
+
+///
+/// Utility class for the text-grab:// protocol used by companion apps such as
+/// the Text Grab browser extension. The URI is only a command channel; any
+/// data payload (like a copied table) travels via the clipboard.
+/// Supported URIs:
+/// text-grab://paste-spreadsheet Edit Text window in spreadsheet mode, paste clipboard
+/// text-grab://edit-text Edit Text window with clipboard text
+/// text-grab://grab-frame[?path=...] Grab Frame, optionally opening a local image/PDF
+/// text-grab://grab-text?path=... OCR a local image/PDF straight to the clipboard (no window)
+/// text-grab://fullscreen Fullscreen grab
+/// text-grab://quick-lookup Quick Simple Lookup
+/// text-grab://settings Settings window
+///
+internal static class ProtocolUtilities
+{
+ internal const string Scheme = "text-grab";
+
+ private const string ProtocolKeyPath = @"Software\Classes\" + Scheme;
+
+ ///
+ /// Returns true when a startup argument looks like a text-grab:// URI.
+ ///
+ internal static bool IsProtocolUri(string? argument)
+ {
+ return argument is not null
+ && argument.StartsWith($"{Scheme}:", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Parses a text-grab:// URI into a lowercase command and its query parameters.
+ /// Accepts both text-grab://command?key=value and text-grab:command forms.
+ ///
+ internal static bool TryParseProtocolUri(
+ string uriString,
+ out string command,
+ out Dictionary parameters)
+ {
+ command = string.Empty;
+ parameters = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (!Uri.TryCreate(uriString, UriKind.Absolute, out Uri? uri)
+ || !string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ // text-grab://paste-spreadsheet puts the command in Host;
+ // text-grab:paste-spreadsheet puts it in AbsolutePath.
+ string rawCommand = !string.IsNullOrEmpty(uri.Host) ? uri.Host : uri.AbsolutePath;
+ command = rawCommand.Trim('/').ToLowerInvariant();
+ if (string.IsNullOrEmpty(command))
+ return false;
+
+ string query = uri.Query.TrimStart('?');
+ foreach (string pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ int separatorIndex = pair.IndexOf('=');
+ if (separatorIndex <= 0)
+ continue;
+ string key = Uri.UnescapeDataString(pair[..separatorIndex]);
+ string value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
+ parameters[key] = value;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Validates a path= parameter supplied via the text-grab:// protocol and,
+ /// when safe, returns its canonical full path. Because any web page can launch the
+ /// protocol, the path is treated as untrusted and must clear several gates:
+ ///
+ /// - No UNC or device paths (\\server\share, \\?\, \\.\) — probing
+ /// one would trigger an outbound SMB authentication and leak the user's NTLM credentials.
+ /// - Canonicalized with so traversal
+ /// (..\..\) and relative paths cannot escape the allowed locations.
+ /// - Rooted on a local, non-network drive.
+ /// - Located under a folder the companion extension legitimately writes to
+ /// (Downloads, Temp, Pictures), so a page cannot point us at arbitrary files.
+ /// - An existing image/PDF file.
+ ///
+ /// All checks that could touch the path (existence, drive type) run only after the
+ /// UNC/device gate, so the dangerous network probe is never performed.
+ ///
+ internal static bool TryGetSafeProtocolFilePath(string? rawPath, out string fullPath)
+ {
+ fullPath = string.Empty;
+
+ if (string.IsNullOrWhiteSpace(rawPath))
+ return false;
+
+ // Reject UNC and device/extended-length paths before any filesystem call.
+ if (rawPath.StartsWith(@"\\", StringComparison.Ordinal)
+ || rawPath.StartsWith("//", StringComparison.Ordinal))
+ return false;
+
+ string candidate;
+ try
+ {
+ candidate = Path.GetFullPath(rawPath);
+ }
+ catch
+ {
+ return false;
+ }
+
+ // GetFullPath can still surface a UNC root for some inputs; re-check.
+ if (candidate.StartsWith(@"\\", StringComparison.Ordinal))
+ return false;
+
+ // Require a drive-letter root (e.g. "C:\"). This also rejects rooted-but-
+ // drive-less paths like "\Windows\..." which resolve against the current drive.
+ string? root = Path.GetPathRoot(candidate);
+ if (root is null || root.Length < 2 || root[1] != ':')
+ return false;
+
+ try
+ {
+ DriveInfo drive = new(root);
+ if (drive.DriveType is DriveType.Network or DriveType.NoRootDirectory or DriveType.Unknown)
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+
+ if (!IsUnderAllowedRoot(candidate))
+ return false;
+
+ if (!IoUtilities.IsVisualDocumentFile(candidate))
+ return false;
+
+ fullPath = candidate;
+ return true;
+ }
+
+ ///
+ /// Folders the text-grab:// protocol is allowed to open files from — the locations a
+ /// companion app such as the browser extension realistically deposits a captured image.
+ ///
+ private static IEnumerable AllowedFileRoots()
+ {
+ yield return Path.GetTempPath();
+
+ string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (!string.IsNullOrEmpty(userProfile))
+ yield return Path.Combine(userProfile, "Downloads");
+
+ yield return Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
+ }
+
+ private static bool IsUnderAllowedRoot(string fullPath)
+ {
+ foreach (string root in AllowedFileRoots())
+ {
+ if (string.IsNullOrEmpty(root))
+ continue;
+
+ string normalizedRoot;
+ try
+ {
+ normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
+ }
+ catch
+ {
+ continue;
+ }
+
+ if (fullPath.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ // Require a separator after the root so "C:\Downloads" does not also
+ // match a sibling like "C:\DownloadsEvil".
+ if (fullPath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Registers the text-grab:// protocol for the current user when running
+ /// unpackaged. Packaged installs register it through the MSIX manifest.
+ /// Safe to call on every startup; only writes when missing or stale.
+ ///
+ internal static void EnsureProtocolRegistration()
+ {
+ if (AppUtilities.IsPackaged())
+ return;
+
+ string executablePath = FileUtilities.GetExePath();
+ if (string.IsNullOrEmpty(executablePath))
+ return;
+
+ string expectedCommand = $"\"{executablePath}\" \"%1\"";
+
+ try
+ {
+ using (RegistryKey? existingCommandKey =
+ Registry.CurrentUser.OpenSubKey($@"{ProtocolKeyPath}\shell\open\command"))
+ {
+ if (existingCommandKey?.GetValue(string.Empty) as string == expectedCommand)
+ return;
+ }
+
+ using RegistryKey protocolKey = Registry.CurrentUser.CreateSubKey(ProtocolKeyPath);
+ protocolKey.SetValue(string.Empty, "URL:Text Grab Protocol");
+ protocolKey.SetValue("URL Protocol", string.Empty);
+
+ using RegistryKey iconKey = protocolKey.CreateSubKey("DefaultIcon");
+ iconKey.SetValue(string.Empty, $"\"{executablePath}\",0");
+
+ using RegistryKey commandKey = protocolKey.CreateSubKey(@"shell\open\command");
+ commandKey.SetValue(string.Empty, expectedCommand);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"text-grab:// protocol registration failed: {ex.Message}");
+ }
+ }
+}
diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml
index 51341fe4..07514bf7 100644
--- a/Text-Grab/Views/EditTextWindow.xaml
+++ b/Text-Grab/Views/EditTextWindow.xaml
@@ -240,7 +240,7 @@
Header="Close"
InputGestureText="Alt + F4" />
-